// 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 }