mirror of
https://github.com/larksuite/cli.git
synced 2026-07-05 15:47:54 +08:00
Introduce a typed error contract framework for lark-cli so in-process
Go callers can branch via errors.As(&errs.XxxError{}) and shell scripts,
AI agents, and protocol adapters can branch on stable JSON type/subtype
fields instead of regex-parsing free-form messages.
Adds:
- Canonical taxonomy under errs/ (9 categories + typed Error structs
embedding a shared Problem, RFC 7807-aligned)
- Centralized Lark code metadata + identity-aware BuildAPIError dispatch
- Typed JSON envelope writer alongside the legacy envelope writer
- MCP / OAuth (RFC 6750 Bearer) projection adapters
- Five CI lint guards preventing ad-hoc taxonomy drift
Backward compatibility: legacy *output.ExitError producers (ErrAPI,
ErrWithHint, Errorf, ErrBare) and business shortcuts that use them
continue to render the legacy envelope unchanged. SecurityPolicyError
wire format and exit code are preserved via a carve-out; taxonomy
migration is deferred to PR 2. Domain-specific business migration is
staged across PR 3+.
Framework-direct paths now return typed *errs.*Error: ErrAuth /
ErrValidation / ErrNetwork emit category literals on the wire
(authentication / validation / network), *core.ConfigError is promoted
at the cmd/root boundary with exit code aligned from 2 to 3, and Lark
API permission denials classified by BuildAPIError exit 3.
At the SDK boundary, WrapDoAPIError preserves any already-classified
error (legacy *output.ExitError or typed *errs.*) so output.ErrAuth
from missing credentials surfaces with the auth category and exit 3
intact instead of being downgraded to a network error. Policy responses
classified by BuildAPIError (codes 21000 / 21001) extract challenge_url
and the canonical hint from the response body, matching what the
auth transport already surfaces at the HTTP layer; non-https
challenge URLs are dropped.
First PR in the feat/error-contract-* series.
235 lines
9.0 KiB
Go
235 lines
9.0 KiB
Go
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package cmdpolicy
|
|
|
|
import (
|
|
"github.com/spf13/cobra"
|
|
|
|
"github.com/larksuite/cli/extension/platform"
|
|
"github.com/larksuite/cli/internal/output"
|
|
)
|
|
|
|
// Apply walks the command tree and installs denyStubs for every path in
|
|
// deniedByPath whose Denial.Layer == "policy". It is the user-layer
|
|
// counterpart to applyStrictModeDenials in cmd/prune.go; both consume the
|
|
// same deniedByPath map produced by the bootstrap pipeline, neither
|
|
// re-evaluates rules.
|
|
//
|
|
// Three things must happen for every denied command (hard-constraints 1-4
|
|
// in the tech doc):
|
|
//
|
|
// 1. cmd.Hidden = true -- removes from help / completion
|
|
// 2. cmd.DisableFlagParsing = true -- denial-wins invariant; otherwise
|
|
// cobra would intercept the call
|
|
// with "missing required flag"
|
|
// before we can return our error
|
|
// 3. cmd.RunE = denyStub(denial) -- returns *output.ExitError so
|
|
// cmd/root.go's envelope writer
|
|
// emits structured JSON (with
|
|
// error.type = denial.Layer and
|
|
// detail.reason_code = ReasonCode);
|
|
// the wrapped error chain still
|
|
// exposes *platform.CommandDeniedError
|
|
// via errors.As for in-process
|
|
// consumers
|
|
//
|
|
// Apply must be called once during the Bootstrap pipeline BEFORE
|
|
// cobra.Execute. It mutates the command tree in place and is not safe to
|
|
// call concurrently with command dispatch. Returns the number of commands
|
|
// modified.
|
|
func Apply(root *cobra.Command, deniedByPath map[string]Denial) int {
|
|
if root == nil || len(deniedByPath) == 0 {
|
|
return 0
|
|
}
|
|
|
|
count := 0
|
|
walkTree(root, func(c *cobra.Command) {
|
|
// Never install a denyStub on the binary root itself. Even if the
|
|
// aggregation pass somehow marked it (e.g. all-children-denied at
|
|
// the top), the binary entry point must remain dispatchable so
|
|
// cobra's own help / completion paths still work.
|
|
if !c.HasParent() {
|
|
return
|
|
}
|
|
path := CanonicalPath(c)
|
|
if path == "" {
|
|
return
|
|
}
|
|
d, ok := deniedByPath[path]
|
|
if !ok || d.Layer != LayerPolicy {
|
|
return
|
|
}
|
|
if installDenyStub(c, path, d) {
|
|
count++
|
|
}
|
|
})
|
|
return count
|
|
}
|
|
|
|
// AnnotationDenialLayer / AnnotationDenialSource carry the denial
|
|
// signal to internal/hook through cobra annotations, avoiding an
|
|
// import cycle between hook and cmdpolicy.
|
|
const (
|
|
AnnotationDenialLayer = "lark:policy_denied_layer"
|
|
AnnotationDenialSource = "lark:policy_denied_source"
|
|
|
|
// AnnotationPureGroup marks a cobra.Command that is logically a
|
|
// parent-only group but had a RunE attached by the bootstrap-time
|
|
// unknown-subcommand guard. The engine treats annotated commands
|
|
// the same as un-annotated parent groups (no RunE): they are not
|
|
// evaluated against the Rule, and aggregateParents does not treat
|
|
// them as hybrids.
|
|
//
|
|
// Without this signal, a user enabling a policy.yml with
|
|
// max_risk: read would see every group (`lark-cli drive --help`,
|
|
// `lark-cli docs --help`) return exit 2 + risk_not_annotated,
|
|
// because the guard's RunE flips Runnable()=true and the engine
|
|
// then demands a risk_level annotation on the group itself.
|
|
AnnotationPureGroup = "lark:cmd_pure_group"
|
|
)
|
|
|
|
// IsPureGroup reports whether cmd carries the AnnotationPureGroup marker.
|
|
// Used by the engine to skip evaluation and by the aggregator to treat the
|
|
// command as a parent-only group regardless of cobra's Runnable() answer.
|
|
func IsPureGroup(cmd *cobra.Command) bool {
|
|
if cmd == nil || cmd.Annotations == nil {
|
|
return false
|
|
}
|
|
return cmd.Annotations[AnnotationPureGroup] == "true"
|
|
}
|
|
|
|
// CommandDeniedFromDenial materialises the wrapped error type carried
|
|
// on ExitError.Err so errors.As works for in-process consumers.
|
|
func CommandDeniedFromDenial(path string, d Denial) *platform.CommandDeniedError {
|
|
return &platform.CommandDeniedError{
|
|
Path: path,
|
|
Layer: d.Layer,
|
|
PolicySource: d.PolicySource,
|
|
RuleName: d.RuleName,
|
|
ReasonCode: d.ReasonCode,
|
|
Reason: d.Reason,
|
|
}
|
|
}
|
|
|
|
// DenialDetailMap is the canonical detail.* shape every `command_denied`
|
|
// envelope shares (see docs/extension/reason-codes.md). Use it as
|
|
// ErrDetail.Detail when constructing an envelope outside BuildDenialError.
|
|
func DenialDetailMap(cd *platform.CommandDeniedError) map[string]any {
|
|
return map[string]any{
|
|
"path": cd.Path,
|
|
"layer": cd.Layer,
|
|
"policy_source": cd.PolicySource,
|
|
"rule_name": cd.RuleName,
|
|
"reason_code": cd.ReasonCode,
|
|
"reason": cd.Reason,
|
|
}
|
|
}
|
|
|
|
// BuildDenialError is the default envelope for user-layer denials:
|
|
// Message comes from CommandDeniedError.Error(), no Hint. Callers that
|
|
// need a custom Message or an independent Hint (strict-mode) should
|
|
// compose CommandDeniedFromDenial + DenialDetailMap themselves.
|
|
//
|
|
// Deprecated: BuildDenialError produces a legacy *output.ExitError that
|
|
// predates the typed error contract introduced by errs/. New code MUST NOT
|
|
// use it — denial signals should move to a typed *errs.XxxError (a dedicated
|
|
// typed Error for policy denial is tracked for the cmdpolicy migration PR).
|
|
// This helper is retained only while existing call sites are migrated; it
|
|
// will be removed once they have moved to the typed surface.
|
|
func BuildDenialError(path string, d Denial) *output.ExitError {
|
|
cd := CommandDeniedFromDenial(path, d)
|
|
return &output.ExitError{
|
|
Code: output.ExitValidation,
|
|
Detail: &output.ErrDetail{
|
|
Type: "command_denied",
|
|
Message: cd.Error(),
|
|
Detail: DenialDetailMap(cd),
|
|
},
|
|
Err: cd,
|
|
}
|
|
}
|
|
|
|
// installDenyStub mutates a cobra.Command in place. Unlike cmd/prune.go
|
|
// which does RemoveCommand+AddCommand (changing the pointer), we modify
|
|
// the existing node so any external reference (snapshots, alias targets)
|
|
// continues to point at the same cmd.
|
|
//
|
|
// Help fields (cmd.Short / cmd.Long / cmd.Flags()) are deliberately
|
|
// preserved so `--help` on a denied command still describes what the
|
|
// command was intended to do.
|
|
//
|
|
// Two cobra Annotations are set as a denial signal that internal/hook
|
|
// reads (without taking a dependency on this package):
|
|
//
|
|
// - AnnotationDenialLayer -> "policy" or "strict_mode"
|
|
// - AnnotationDenialSource -> the PolicySource ("yaml", "plugin:foo", ...)
|
|
//
|
|
// Returns true when the stub was actually installed and false on the
|
|
// strict-mode early-return so callers can compute an accurate "commands
|
|
// modified" count.
|
|
func installDenyStub(cmd *cobra.Command, path string, d Denial) bool {
|
|
// strict-mode wins over user-layer pruning. If the command was
|
|
// already replaced by a strict-mode stub (cmd/prune.go::strictModeStubFrom
|
|
// writes layer=strict_mode), do NOT overwrite -- the user-layer
|
|
// rule cannot relax or relabel a credential-hard boundary.
|
|
//
|
|
// Behaviour without this guard (pre-fix): a user yaml rule matching
|
|
// a strict-mode stub's path would replace the RunE with the pruning
|
|
// denyStub, hiding the original strict-mode error message AND
|
|
// re-labelling detail.layer from "strict_mode" to "policy".
|
|
if cmd.Annotations != nil &&
|
|
cmd.Annotations[AnnotationDenialLayer] == LayerStrictMode {
|
|
return false
|
|
}
|
|
cmd.Hidden = true
|
|
cmd.DisableFlagParsing = true
|
|
|
|
// Bypass cobra's pre-RunE gates that would otherwise short-circuit
|
|
// before the wrapped RunE (= where observers + denial guard live):
|
|
//
|
|
// 1. Args validator: original commands often declare cobra.NoArgs
|
|
// or a custom Args function. With DisableFlagParsing=true,
|
|
// `--doc xxx` looks like positional args; cobra.ValidateArgs
|
|
// fires BEFORE PersistentPreRunE / PreRunE / RunE and would
|
|
// surface a Cobra usage error instead of our pruning envelope.
|
|
// ArbitraryArgs accepts everything.
|
|
//
|
|
// 2. Parent's PersistentPreRunE: cobra's "first PersistentPreRunE
|
|
// wins" walks UP from the leaf. cmd/auth/auth.go declares a
|
|
// PersistentPreRunE that returns external_provider when env
|
|
// credentials are set; without our leaf-level override, that
|
|
// fires before pruning's RunE and the caller sees the wrong
|
|
// envelope. We set a no-op leaf PersistentPreRunE that just
|
|
// silences usage and returns nil, so dispatch proceeds to the
|
|
// wrapped RunE (which produces the real pruning envelope and
|
|
// lets Before/After observers fire).
|
|
cmd.Args = cobra.ArbitraryArgs
|
|
cmd.PersistentPreRunE = func(c *cobra.Command, _ []string) error {
|
|
c.SilenceUsage = true
|
|
return nil
|
|
}
|
|
cmd.PersistentPreRun = nil
|
|
cmd.PreRunE = nil
|
|
cmd.PreRun = nil
|
|
|
|
if cmd.Annotations == nil {
|
|
cmd.Annotations = map[string]string{}
|
|
}
|
|
cmd.Annotations[AnnotationDenialLayer] = d.Layer
|
|
cmd.Annotations[AnnotationDenialSource] = d.PolicySource
|
|
|
|
denial := d // capture by value for the closure
|
|
cmd.RunE = func(c *cobra.Command, args []string) error {
|
|
// error.type is the user-facing semantic ("a command was denied by
|
|
// policy"). detail.layer carries the implementation distinction
|
|
// ("policy" vs "strict_mode") for debugging.
|
|
return BuildDenialError(path, denial)
|
|
}
|
|
// Clear any pre-existing Run hook: cobra prefers RunE when both are
|
|
// set, but leaving a stale Run around is a foot-gun for future
|
|
// maintainers.
|
|
cmd.Run = nil
|
|
return true
|
|
}
|