chore: import upstream snapshot with attribution
CI / license-header (push) Has been skipped
CI / e2e-dry-run (push) Has been skipped
CI / fast-gate (push) Failing after 0s
Test PR Label Logic / test-pr-labels (push) Failing after 1s
Skill Format Check / check-format (push) Failing after 2s
CI / security (push) Failing after 5s
CI / unit-test (push) Has been skipped
CI / lint (push) Has been skipped
CI / script-test (push) Has been skipped
CI / deterministic-gate (push) Has been skipped
CI / coverage (push) Has been skipped
CI / results (push) Has been cancelled
CI / deadcode (push) Has been cancelled
CI / e2e-live (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 12:22:54 +08:00
commit bf9395e022
2349 changed files with 588574 additions and 0 deletions
+93
View File
@@ -0,0 +1,93 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmdpolicy
import (
"sync"
"github.com/larksuite/cli/extension/platform"
)
// ActivePolicy is the resolved user-layer policy after applyUserPolicyPruning
// has run during bootstrap. `lark-cli config policy show` reads this to
// answer "what rule is currently in effect, and how many commands does
// it hide?".
//
// Set once at bootstrap time; consumed read-only thereafter.
//
// Rules is the full set the winning source contributed (one rule for the
// common single-rule case, several when a plugin or yaml declares scoped
// grants). nil/empty means "no rule applied".
type ActivePolicy struct {
Rules []*platform.Rule
Source ResolveSource
DeniedPaths int // number of commands the engine marked as denied (post-aggregation)
}
var (
activeMu sync.RWMutex
activePolicy *ActivePolicy
)
// SetActive records the policy that ends up applied. Called exactly once
// per process from cmd/policy.go::applyUserPolicyPruning. The mutex is
// belt-and-braces in case future test paths interleave with bootstrap.
//
// A deep copy is taken so the snapshot is immune to later mutations of
// the input by the caller (a plugin-supplied *Rule could otherwise
// mutate the embedded Allow/Deny/Identities slices after we stored it).
func SetActive(p *ActivePolicy) {
activeMu.Lock()
defer activeMu.Unlock()
if p == nil {
activePolicy = nil
return
}
activePolicy = cloneActivePolicy(p)
}
// GetActive returns a deep copy of the recorded policy, or nil if
// bootstrap has not finished or no rule applied. Callers can freely
// mutate the result — including the embedded Rule slices — without
// affecting the stored global.
func GetActive() *ActivePolicy {
activeMu.RLock()
defer activeMu.RUnlock()
if activePolicy == nil {
return nil
}
return cloneActivePolicy(activePolicy)
}
// cloneActivePolicy deep-copies the top-level struct, the Rules slice, and
// each Rule's own slice fields. Other fields (Source, DeniedPaths) are
// value types so the struct copy already disjoints them.
func cloneActivePolicy(in *ActivePolicy) *ActivePolicy {
if in == nil {
return nil
}
cp := *in
if in.Rules != nil {
cp.Rules = make([]*platform.Rule, len(in.Rules))
for i, r := range in.Rules {
if r == nil {
continue
}
rule := *r
rule.Allow = append([]string(nil), r.Allow...)
rule.Deny = append([]string(nil), r.Deny...)
rule.Identities = append([]platform.Identity(nil), r.Identities...)
cp.Rules[i] = &rule
}
}
return &cp
}
// ResetActiveForTesting clears the recorded policy. Tests must call this
// in t.Cleanup when they exercise the bootstrap path.
func ResetActiveForTesting() {
activeMu.Lock()
defer activeMu.Unlock()
activePolicy = nil
}
+356
View File
@@ -0,0 +1,356 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmdpolicy_test
import (
"errors"
"strings"
"testing"
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/extension/platform"
"github.com/larksuite/cli/internal/cmdpolicy"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/output"
)
// EvaluateAll must skip non-runnable parent groups (their decision is
// derived in the aggregation pass). The previous regression: an
// Allow:["docs/**"] rule incorrectly denied the parent "docs" group too,
// because the parent's own path "docs" did not match "docs/**".
func TestEvaluateAll_skipsPureGroups(t *testing.T) {
root := buildTree() // docs and im are pure groups, +fetch / +update / +send are leaves
e := cmdpolicy.New(&platform.Rule{Allow: []string{"docs/**"}})
got := e.EvaluateAll(root)
if _, present := got["docs"]; present {
t.Errorf("parent group 'docs' should not appear in Decisions (Allow=docs/**)")
}
if _, present := got["im"]; present {
t.Errorf("parent group 'im' should not appear in Decisions")
}
// Children still evaluated normally.
if !got["docs/+fetch"].Allowed {
t.Errorf("docs/+fetch should still be allowed by docs/**")
}
}
// BuildDeniedByPath must aggregate: a parent group whose every runnable
// child is denied must itself get an aggregated Denial in the map.
func TestBuildDeniedByPath_parentAggregationAllChildrenDenied(t *testing.T) {
// Custom tree where ALL children of "im" will be denied.
root := &cobra.Command{Use: "lark-cli"}
im := &cobra.Command{Use: "im"}
root.AddCommand(im)
send := &cobra.Command{Use: "+send", RunE: noop}
cmdutil.SetRisk(send, "write")
im.AddCommand(send)
search := &cobra.Command{Use: "+search", RunE: noop}
cmdutil.SetRisk(search, "read")
im.AddCommand(search)
// Risk is set on both leaves so the rejection comes from the Allow
// axis (the contract this test pins), not from the risk gate.
e := cmdpolicy.New(&platform.Rule{Allow: []string{"docs/**"}}) // none of im/* matches
decisions := e.EvaluateAll(root)
// Pin the rejection axis: both leaves are rejected by Allow miss,
// NOT by the risk_not_annotated gate. If a future edit drops the
// SetRisk lines above, this assertion fails and the test stops
// silently testing the wrong axis.
if rc := decisions["im/+send"].ReasonCode; rc != "domain_not_allowed" {
t.Errorf("im/+send ReasonCode = %q, want domain_not_allowed", rc)
}
if rc := decisions["im/+search"].ReasonCode; rc != "domain_not_allowed" {
t.Errorf("im/+search ReasonCode = %q, want domain_not_allowed", rc)
}
denied := cmdpolicy.BuildDeniedByPath(root, decisions,
cmdpolicy.ResolveSource{Kind: cmdpolicy.SourceYAML, Name: "/policy.yml"}, "agent")
// Both leaves denied.
if _, ok := denied["im/+send"]; !ok {
t.Errorf("im/+send should be in denied map")
}
if _, ok := denied["im/+search"]; !ok {
t.Errorf("im/+search should be in denied map")
}
// Parent must be aggregated.
parent, ok := denied["im"]
if !ok {
t.Fatalf("parent 'im' should be aggregated into denied map")
}
if parent.Layer != "policy" {
t.Errorf("parent.Layer = %q, want pruning", parent.Layer)
}
}
// Partial children-denied means parent stays UN-denied. This is the
// counter-case to the previous regression: docs/** allowed children stays
// alive even if some siblings are denied.
func TestBuildDeniedByPath_partialDenialKeepsParent(t *testing.T) {
root := &cobra.Command{Use: "lark-cli"}
docs := &cobra.Command{Use: "docs"}
root.AddCommand(docs)
fetch := &cobra.Command{Use: "+fetch", RunE: noop}
cmdutil.SetRisk(fetch, "read")
docs.AddCommand(fetch) // allowed
delete := &cobra.Command{Use: "+delete", RunE: noop}
cmdutil.SetRisk(delete, "high-risk-write")
docs.AddCommand(delete) // denied by Deny
e := cmdpolicy.New(&platform.Rule{
Allow: []string{"docs/**"},
Deny: []string{"docs/+delete"},
})
denied := cmdpolicy.BuildDeniedByPath(root, e.EvaluateAll(root),
cmdpolicy.ResolveSource{Kind: cmdpolicy.SourcePlugin, Name: "secaudit"}, "secaudit-policy")
if _, ok := denied["docs"]; ok {
t.Errorf("parent 'docs' must NOT be denied when some children are allowed")
}
if _, ok := denied["docs/+fetch"]; ok {
t.Errorf("docs/+fetch should not be in denied map (it's allowed)")
}
if _, ok := denied["docs/+delete"]; !ok {
t.Errorf("docs/+delete should be denied (in Deny)")
}
}
// The binary root is never installed with a denyStub even when all its
// descendants are denied -- the entry point must remain dispatchable.
func TestBuildDeniedByPath_rootNeverDenied(t *testing.T) {
root := buildTree()
e := cmdpolicy.New(&platform.Rule{Allow: []string{"nonexistent/**"}})
denied := cmdpolicy.BuildDeniedByPath(root, e.EvaluateAll(root),
cmdpolicy.ResolveSource{Kind: cmdpolicy.SourceYAML, Name: "/p.yml"}, "")
// Every leaf should be denied. We do not assert on the root entry
// because Apply skips the root regardless; the contract is "root
// stays dispatchable".
if _, ok := denied["lark-cli"]; ok {
t.Errorf("root should not be in denied map")
}
}
// Hybrid command: a parent with its own RunE plus children. Aggregation
// requires both own RunE denied AND all children denied for the parent
// itself to be marked denied.
func TestBuildDeniedByPath_hybridParentOwnAllowedKeepsAlive(t *testing.T) {
root := &cobra.Command{Use: "lark-cli"}
docs := &cobra.Command{Use: "docs", RunE: noop} // hybrid: own RunE + subs
cmdutil.SetRisk(docs, "read")
root.AddCommand(docs)
delete := &cobra.Command{Use: "+delete", RunE: noop}
cmdutil.SetRisk(delete, "high-risk-write")
docs.AddCommand(delete)
// Allow "docs" (parent) but deny "+delete" child.
e := cmdpolicy.New(&platform.Rule{
Allow: []string{"docs"},
})
denied := cmdpolicy.BuildDeniedByPath(root, e.EvaluateAll(root),
cmdpolicy.ResolveSource{Kind: cmdpolicy.SourceYAML, Name: ""}, "")
// docs/+delete denied (path doesn't match Allow=["docs"]).
if _, ok := denied["docs/+delete"]; !ok {
t.Errorf("docs/+delete should be denied")
}
// docs itself allowed (path matches Allow=["docs"] exactly).
if _, ok := denied["docs"]; ok {
t.Errorf("docs (hybrid) should NOT be denied -- own RunE is allowed")
}
}
// Apply returns a typed *errs.ValidationError that exposes BOTH paths
// consumers rely on:
// 1. cmd/root.go's envelope writer (errs.ProblemOf / failed_precondition
// subtype + exit code 2)
// 2. in-process consumers extracting the platform.CommandDeniedError as
// the typed error's Cause via errors.As
//
// The policy metadata (layer / policy_source / rule_name / reason_code)
// is folded into the Hint text rather than a separate detail map.
func TestApply_runEReturnsExitErrorAndCommandDeniedError(t *testing.T) {
root := buildTree()
denied := map[string]cmdpolicy.Denial{
"docs/+update": {
Layer: "policy",
PolicySource: "plugin:secaudit",
RuleName: "secaudit-policy",
ReasonCode: "write_not_allowed",
Reason: "write disabled",
},
}
cmdpolicy.Apply(root, denied)
update := findChild(t, root, "docs", "+update")
err := update.RunE(update, []string{})
if err == nil {
t.Fatalf("denied command should return error")
}
// Path 1: typed-envelope view. The denial is a failed_precondition
// ValidationError so cmd/root.go renders the structured envelope and
// the process exits 2 (ExitValidation).
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("error chain must contain *errs.ValidationError, got %T", err)
}
if ve.Subtype != errs.SubtypeFailedPrecondition {
t.Errorf("subtype = %q, want %q", ve.Subtype, errs.SubtypeFailedPrecondition)
}
if code := output.ExitCodeOf(err); code != output.ExitValidation {
t.Errorf("exit code = %d, want ExitValidation (%d)", code, output.ExitValidation)
}
// The policy metadata is folded into the Hint text: reason_code,
// policy_source, and rule_name must all be discoverable there.
if !strings.Contains(ve.Hint, "write_not_allowed") {
t.Errorf("hint must carry reason_code write_not_allowed, got %q", ve.Hint)
}
if !strings.Contains(ve.Hint, "plugin:secaudit") {
t.Errorf("hint must carry policy_source plugin:secaudit, got %q", ve.Hint)
}
if !strings.Contains(ve.Hint, "secaudit-policy") {
t.Errorf("hint must carry rule_name secaudit-policy, got %q", ve.Hint)
}
// Path 2: in-process typed-error view -- the *platform.CommandDeniedError
// is preserved as the Cause so errors.As still reaches it.
var cd *platform.CommandDeniedError
if !errors.As(err, &cd) {
t.Fatalf("error chain must expose *platform.CommandDeniedError")
}
if cd.Path != "docs/+update" || cd.ReasonCode != "write_not_allowed" {
t.Errorf("CommandDeniedError = %+v", cd)
}
}
// Regression: a pure parent group carrying AnnotationPureGroup must be
// skipped by both EvaluateAll and aggregateParents. Without the skip,
// the cmd.installUnknownSubcommandGuard pass (which attaches a RunE to
// every group for cobra's silent-help fallback) would flip Runnable()
// to true for `docs`, `drive`, etc., and a yaml rule like
// `max_risk: read` would deny every `<group> --help` invocation with
// reason_code = risk_not_annotated.
func TestEvaluateAll_skipsAnnotatedPureGroup(t *testing.T) {
root := &cobra.Command{Use: "lark-cli"}
drive := &cobra.Command{
Use: "drive",
RunE: func(*cobra.Command, []string) error { return nil }, // emulate guard injection
Annotations: map[string]string{
cmdpolicy.AnnotationPureGroup: "true",
},
}
root.AddCommand(drive)
pull := &cobra.Command{Use: "+pull", RunE: noop}
cmdutil.SetRisk(pull, "read")
drive.AddCommand(pull)
e := cmdpolicy.New(&platform.Rule{MaxRisk: "read"})
got := e.EvaluateAll(root)
if d, present := got["drive"]; present {
t.Errorf("annotated pure group should not appear in Decisions; got %+v", d)
}
if !got["drive/+pull"].Allowed {
t.Errorf("leaf under pure group must still be evaluated; got %+v", got["drive/+pull"])
}
}
// Regression: hasRunnableDescendant must also treat
// AnnotationPureGroup-tagged commands as non-runnable. Without the
// skip, an entire branch consisting of a pure-group placeholder + a
// single pure-group leaf would advertise itself as a "live" subtree
// and the parent aggregation pass would refuse to install a deny stub
// (allLiveChildrenDenied flips to false because the pure group is
// neither runnable nor in `denied`).
func TestHasRunnableDescendant_ignoresAnnotatedPureGroup(t *testing.T) {
root := &cobra.Command{Use: "lark-cli"}
docs := &cobra.Command{Use: "docs"}
root.AddCommand(docs)
// A pure-group sibling of a real leaf. The parent must still
// aggregate based on the real leaf alone.
placeholder := &cobra.Command{
Use: "placeholder",
RunE: func(*cobra.Command, []string) error { return nil },
Annotations: map[string]string{
cmdpolicy.AnnotationPureGroup: "true",
},
}
docs.AddCommand(placeholder)
noChild := &cobra.Command{
Use: "+ghost",
RunE: func(*cobra.Command, []string) error { return nil },
Annotations: map[string]string{
cmdpolicy.AnnotationPureGroup: "true",
},
}
placeholder.AddCommand(noChild)
fetch := &cobra.Command{Use: "+fetch", RunE: noop}
cmdutil.SetRisk(fetch, "write")
docs.AddCommand(fetch)
e := cmdpolicy.New(&platform.Rule{MaxRisk: "read"})
decisions := e.EvaluateAll(root)
denied := cmdpolicy.BuildDeniedByPath(root, decisions, cmdpolicy.ResolveSource{Kind: cmdpolicy.SourceYAML}, "")
if _, ok := denied["docs"]; !ok {
t.Fatalf("docs should be aggregated as fully denied (pure-group children excluded from live count); map=%+v", denied)
}
}
// Regression: aggregateParents must treat an AnnotationPureGroup-tagged
// command exactly like a parent-only group. With cmdRunnable accidentally
// true (RunE attached by the guard), the aggregator would otherwise look
// for an own-RunE denial entry and skip aggregation, leaving `<group>
// --help` reachable even when every live child is denied.
func TestBuildDeniedByPath_aggregatesAnnotatedPureGroup(t *testing.T) {
root := &cobra.Command{Use: "lark-cli"}
drive := &cobra.Command{
Use: "drive",
RunE: func(*cobra.Command, []string) error { return nil },
Annotations: map[string]string{
cmdpolicy.AnnotationPureGroup: "true",
},
}
root.AddCommand(drive)
push := &cobra.Command{Use: "+push", RunE: noop}
cmdutil.SetRisk(push, "write")
drive.AddCommand(push)
pull := &cobra.Command{Use: "+pull", RunE: noop}
cmdutil.SetRisk(pull, "write")
drive.AddCommand(pull)
e := cmdpolicy.New(&platform.Rule{MaxRisk: "read"})
decisions := e.EvaluateAll(root)
denied := cmdpolicy.BuildDeniedByPath(root, decisions, cmdpolicy.ResolveSource{Kind: cmdpolicy.SourceYAML}, "")
if _, ok := denied["drive"]; !ok {
t.Fatalf("aggregator must install drive denial when all children denied; map=%+v", denied)
}
}
// The binary root must never receive a denyStub even if every descendant
// is denied. cobra still needs root to dispatch help / completion.
func TestApply_neverInstallsOnRoot(t *testing.T) {
root := buildTree()
denied := map[string]cmdpolicy.Denial{
"lark-cli": {Layer: "policy", ReasonCode: "all_children_denied"},
}
cmdpolicy.Apply(root, denied)
if root.RunE != nil {
t.Errorf("root.RunE should remain nil; got a denyStub installed")
}
if root.Hidden {
t.Errorf("root must stay visible")
}
}
+208
View File
@@ -0,0 +1,208 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmdpolicy
import (
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/extension/platform"
)
// 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 a typed
// *errs.ValidationError so
// cmd/root.go's envelope writer
// emits structured JSON; 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,
}
}
// BuildDenialError is the default typed error for user-layer denials:
// Message comes from CommandDeniedError.Error(); the policy layer, source,
// rule name, and reason code are folded into the Hint. The
// *platform.CommandDeniedError is preserved as the Cause so errors.As
// works for in-process consumers.
func BuildDenialError(path string, d Denial) *errs.ValidationError {
cd := CommandDeniedFromDenial(path, d)
return errs.NewValidationError(errs.SubtypeFailedPrecondition, "%s", cd.Error()).
WithHint("denied by %s policy (source %s, rule %q, reason_code %s); adjust the policy configuration to allow this command",
cd.Layer, cd.PolicySource, cd.RuleName, cd.ReasonCode).
WithCause(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 {
// The typed message carries the user-facing semantic ("a command
// was denied"); the hint carries the layer / source / rule
// 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
}
+130
View File
@@ -0,0 +1,130 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmdpolicy
import "sort"
// Layer values match CommandDeniedError.Layer and the detail.layer
// field of the JSON envelope (under error.type = "command_denied").
const (
LayerStrictMode = "strict_mode"
// LayerPolicy is the user-layer enforcement label. The string value
// is "policy" — the package name "cmdpolicy" matches it. This
// replaces the older "pruning" label.
LayerPolicy = "policy"
)
// Denial is the merged record for a single rejected command path. It
// is distinct from the user-layer-only Decision type: Denial only
// exists when the command is rejected (the Allowed bool would be
// wasted here, hence not reusing Decision).
type Denial struct {
Layer string // "strict_mode" | "policy"
PolicySource string // "plugin:secaudit" | "yaml:mywork" | "strict-mode" | ""
RuleName string // matched Rule.Name (if any)
ReasonCode string // closed enum, see docs/extension/reason-codes.md
Reason string // human-readable
}
// ChildDenial is what AggregateChildren consumes — it pairs a Denial
// with the child command's path so the aggregate can carry that
// breakdown for envelope.detail.children_denied.
type ChildDenial struct {
Path string
Denial Denial
}
// AggregateChildren produces the parent-group Denial when every child
// of a command group is itself denied. The rules:
//
// - all children share Layer "strict_mode" → parent Layer =
// strict_mode, parent ReasonCode = single child's ReasonCode (if
// consistent) or "mixed_children_strict_mode" otherwise.
// - all children share Layer "policy" → parent Layer = policy,
// ReasonCode behaves analogously.
// - mixed layers across children → parent Layer = "policy",
// ReasonCode = "all_children_denied", PolicySource = "mixed".
//
// Calling with an empty slice returns a zero Denial — callers should
// treat this as "no aggregation needed".
func AggregateChildren(children []ChildDenial) Denial {
if len(children) == 0 {
return Denial{}
}
layers := map[string]struct{}{}
reasonCodes := map[string]struct{}{}
sources := map[string]struct{}{}
ruleNames := map[string]struct{}{}
for _, c := range children {
layers[c.Denial.Layer] = struct{}{}
reasonCodes[c.Denial.ReasonCode] = struct{}{}
if c.Denial.PolicySource != "" {
sources[c.Denial.PolicySource] = struct{}{}
}
if c.Denial.RuleName != "" {
ruleNames[c.Denial.RuleName] = struct{}{}
}
}
// Mixed: layers differ across children. Parent goes to Layer=policy
// (the more "user-recoverable" of the two — swapping policy can
// flip children, swapping credential cannot).
if len(layers) > 1 {
return Denial{
Layer: LayerPolicy,
PolicySource: "mixed",
ReasonCode: "all_children_denied",
Reason: "all child commands are denied (mixed reasons)",
}
}
var layer string
for l := range layers {
layer = l
}
d := Denial{Layer: layer}
switch len(reasonCodes) {
case 1:
for rc := range reasonCodes {
d.ReasonCode = rc
}
default:
switch layer {
case LayerStrictMode:
d.ReasonCode = "mixed_children_strict_mode"
default:
d.ReasonCode = "mixed_children_policy"
}
}
if len(sources) == 1 {
for s := range sources {
d.PolicySource = s
}
}
if layer == LayerStrictMode {
d.PolicySource = "strict-mode"
}
if len(ruleNames) == 1 {
for n := range ruleNames {
d.RuleName = n
}
}
d.Reason = "all child commands are denied"
return d
}
// SortChildren orders children by Path. The aggregate output of
// AggregateChildren is deterministic regardless of slice order, but
// tests and the envelope's children_denied list want a stable order.
func SortChildren(children []ChildDenial) {
sort.Slice(children, func(i, j int) bool {
return children[i].Path < children[j].Path
})
}
+98
View File
@@ -0,0 +1,98 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmdpolicy_test
import (
"testing"
"github.com/larksuite/cli/internal/cmdpolicy"
)
func TestAggregateChildren_allSameLayerAndReason(t *testing.T) {
got := cmdpolicy.AggregateChildren([]cmdpolicy.ChildDenial{
{Path: "docs/+update", Denial: cmdpolicy.Denial{
Layer: cmdpolicy.LayerPolicy, PolicySource: "yaml:agent",
ReasonCode: "write_not_allowed", RuleName: "agent-policy",
}},
{Path: "docs/+delete", Denial: cmdpolicy.Denial{
Layer: cmdpolicy.LayerPolicy, PolicySource: "yaml:agent",
ReasonCode: "write_not_allowed", RuleName: "agent-policy",
}},
})
if got.Layer != cmdpolicy.LayerPolicy || got.ReasonCode != "write_not_allowed" {
t.Fatalf("got %+v, want layer=policy reason=write_not_allowed", got)
}
if got.PolicySource != "yaml:agent" || got.RuleName != "agent-policy" {
t.Fatalf("Source / RuleName should propagate when consistent, got %+v", got)
}
}
func TestAggregateChildren_sameLayerMixedReasons(t *testing.T) {
got := cmdpolicy.AggregateChildren([]cmdpolicy.ChildDenial{
{Denial: cmdpolicy.Denial{Layer: cmdpolicy.LayerPolicy, ReasonCode: "write_not_allowed"}},
{Denial: cmdpolicy.Denial{Layer: cmdpolicy.LayerPolicy, ReasonCode: "domain_not_allowed"}},
})
if got.Layer != cmdpolicy.LayerPolicy || got.ReasonCode != "mixed_children_policy" {
t.Fatalf("got %+v, want layer=policy reason=mixed_children_policy", got)
}
}
func TestAggregateChildren_strictModeBranch(t *testing.T) {
got := cmdpolicy.AggregateChildren([]cmdpolicy.ChildDenial{
{Denial: cmdpolicy.Denial{Layer: cmdpolicy.LayerStrictMode, ReasonCode: "identity_not_supported"}},
{Denial: cmdpolicy.Denial{Layer: cmdpolicy.LayerStrictMode, ReasonCode: "identity_not_supported"}},
})
if got.Layer != cmdpolicy.LayerStrictMode || got.ReasonCode != "identity_not_supported" {
t.Fatalf("got %+v", got)
}
if got.PolicySource != "strict-mode" {
t.Fatalf("PolicySource = %q, want strict-mode", got.PolicySource)
}
}
// Mixed layers (some strict_mode, some policy) collapse to Layer=policy
// per the design rule — a parent group failing for "both" reasons is
// most actionable framed as a user-policy issue (swappable) rather than
// a credential capability one (not swappable).
func TestAggregateChildren_mixedLayersFallsToPolicy(t *testing.T) {
got := cmdpolicy.AggregateChildren([]cmdpolicy.ChildDenial{
{Path: "docs/+update", Denial: cmdpolicy.Denial{
Layer: cmdpolicy.LayerStrictMode, ReasonCode: "identity_not_supported",
}},
{Path: "docs/+fetch", Denial: cmdpolicy.Denial{
Layer: cmdpolicy.LayerPolicy, ReasonCode: "domain_not_allowed",
}},
})
if got.Layer != cmdpolicy.LayerPolicy {
t.Fatalf("Layer = %q, want policy (mixed-children rule)", got.Layer)
}
if got.ReasonCode != "all_children_denied" {
t.Fatalf("ReasonCode = %q, want all_children_denied", got.ReasonCode)
}
if got.PolicySource != "mixed" {
t.Fatalf("PolicySource = %q, want mixed", got.PolicySource)
}
}
func TestAggregateChildren_emptySlice(t *testing.T) {
got := cmdpolicy.AggregateChildren(nil)
if (got != cmdpolicy.Denial{}) {
t.Fatalf("empty slice should produce zero Denial, got %+v", got)
}
}
func TestSortChildren_stableOrder(t *testing.T) {
children := []cmdpolicy.ChildDenial{
{Path: "docs/+update"},
{Path: "docs/+delete"},
{Path: "docs/+create"},
}
cmdpolicy.SortChildren(children)
want := []string{"docs/+create", "docs/+delete", "docs/+update"}
for i, c := range children {
if c.Path != want[i] {
t.Fatalf("children[%d].Path = %q, want %q", i, c.Path, want[i])
}
}
}
+29
View File
@@ -0,0 +1,29 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmdpolicy
// diagnosticPaths lists command paths that are unconditionally allowed,
// regardless of any user-layer Rule. Entries must satisfy two properties:
//
// 1. Read-only. The command performs no I/O outside the local process
// and never mutates remote state.
// 2. Self-reflective. Denying the command would produce a UX dead-end
// where the operator can no longer inspect / validate the policy
// that is locking them out.
//
// Today this is `config policy show` and `config plugins show` --
// both purely local introspection over the resolved policy. Keep the
// list small and audited: every entry is a permanent hole in the
// fail-closed boundary.
var diagnosticPaths = map[string]bool{
"config/policy/show": true,
"config/plugins/show": true,
}
// IsDiagnosticPath reports whether the given canonical command path is
// exempt from user-layer pruning. Exported for test packages; callers
// inside this package use the unexported helper.
func IsDiagnosticPath(path string) bool {
return diagnosticPaths[path]
}
+86
View File
@@ -0,0 +1,86 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmdpolicy_test
import (
"testing"
"github.com/spf13/cobra"
"github.com/larksuite/cli/extension/platform"
"github.com/larksuite/cli/internal/cmdpolicy"
)
// configPolicyTree builds the minimal slice of the real command tree
// where diagnostic exemption applies: root -> config -> policy -> show.
func configPolicyTree() *cobra.Command {
root := &cobra.Command{Use: "lark-cli"}
config := &cobra.Command{Use: "config"}
root.AddCommand(config)
policy := &cobra.Command{Use: "policy"}
config.AddCommand(policy)
policy.AddCommand(&cobra.Command{Use: "show", RunE: noop})
// Plus an unrelated command that the Rule will deny, to anchor the
// "everything except diagnostics" check.
im := &cobra.Command{Use: "im"}
root.AddCommand(im)
im.AddCommand(&cobra.Command{Use: "+send", RunE: noop})
return root
}
func TestEvaluate_diagnosticAllowedDespiteStrictAllow(t *testing.T) {
root := configPolicyTree()
// Rule that allows ONLY docs/** -- normally locks out everything else.
e := cmdpolicy.New(&platform.Rule{
Allow: []string{"docs/**"},
})
got := e.EvaluateAll(root)
if !got["config/policy/show"].Allowed {
t.Errorf("config/policy/show must be unconditionally allowed; got Allowed=false reason=%q",
got["config/policy/show"].ReasonCode)
}
// Sanity: a non-diagnostic command is still denied so we know the
// rule itself is active.
if got["im/+send"].Allowed {
t.Errorf("im/+send should be denied by Allow=[docs/**]; got Allowed=true")
}
}
func TestEvaluate_diagnosticAllowedDespiteExplicitDeny(t *testing.T) {
// Even a Rule that explicitly Denies the path must not lock the
// operator out -- diagnostic is a permanent hole. If a security-
// sensitive deployment needs to block introspection, they should
// strip the binary, not rely on Rule.
root := configPolicyTree()
e := cmdpolicy.New(&platform.Rule{
Allow: []string{"**"},
Deny: []string{"config/policy/**"},
})
got := e.EvaluateAll(root)
if !got["config/policy/show"].Allowed {
t.Errorf("config/policy/show must override explicit Deny; got Allowed=false reason=%q",
got["config/policy/show"].ReasonCode)
}
}
func TestIsDiagnosticPath(t *testing.T) {
cases := []struct {
path string
want bool
}{
{"config/policy/show", true},
{"config/plugins/show", true},
{"config/policy", false}, // parent group itself is not exempt
{"config/plugins", false}, // parent group itself is not exempt
{"docs/+fetch", false},
{"", false},
}
for _, tc := range cases {
if got := cmdpolicy.IsDiagnosticPath(tc.path); got != tc.want {
t.Errorf("IsDiagnosticPath(%q) = %v, want %v", tc.path, got, tc.want)
}
}
}
+478
View File
@@ -0,0 +1,478 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
// Package cmdpolicy is the user-layer command policy engine. It consumes a
// platform.Rule and the cobra command tree, evaluates each runnable command
// against the rule's four-axis filter (Allow / Deny / MaxRisk / Identities),
// and produces a path -> Decision map. A separate BuildDeniedByPath step
// converts those leaf decisions into a deniedByPath map (with parent-group
// aggregation), which the Apply step consumes to install denyStubs.
//
// This package only implements the user-layer half. Strict-mode is handled
// by cmd/prune.go, which produces typed validation errors of the same shape
// (failed_precondition, *platform.CommandDeniedError preserved as Cause) so
// external agents see a uniform envelope regardless of which layer rejected
// the call.
package cmdpolicy
import (
"fmt"
"strings"
"github.com/bmatcuk/doublestar/v4"
"github.com/spf13/cobra"
"github.com/larksuite/cli/extension/platform"
"github.com/larksuite/cli/internal/cmdmeta"
)
// Decision is the user-layer single-rule evaluation result. Distinct from
// Denial: Decision carries Allowed=true/false and the
// rejection reason when Allowed=false; Denial only ever exists when the
// command is rejected. Keeping them separate avoids a perpetually-false
// Allowed field on Denial.
type Decision struct {
Allowed bool
ReasonCode string // "" when Allowed=true
Reason string // human-readable
}
// Engine evaluates a set of Rules against the command tree with OR
// semantics: a command is allowed when it satisfies every axis of AT
// LEAST ONE rule. It is stateless except for the Rule snapshot it was
// constructed with.
type Engine struct {
rules []*platform.Rule
}
// New returns an Engine bound to a single Rule. A nil Rule means "no
// user-layer restriction" -- EvaluateOne always returns Allowed=true.
// It is the ergonomic single-rule constructor, kept so existing callers
// (and the single-rule decision path) stay byte-for-byte unchanged.
func New(rule *platform.Rule) *Engine {
if rule == nil {
return &Engine{}
}
return &Engine{rules: []*platform.Rule{rule}}
}
// NewSet returns an Engine bound to a set of Rules evaluated with OR
// semantics. An empty/nil slice means "no user-layer restriction". nil
// entries are dropped so callers may pass a slice with gaps without a
// separate filter step.
//
// With exactly one rule the behaviour is identical to New(rule): the
// rejection Decision is returned verbatim. With multiple rules a command
// rejected by all of them gets the aggregate reason_code
// "no_matching_rule" (see mergeDenials).
func NewSet(rules []*platform.Rule) *Engine {
cleaned := make([]*platform.Rule, 0, len(rules))
for _, r := range rules {
if r != nil {
cleaned = append(cleaned, r)
}
}
if len(cleaned) == 0 {
return &Engine{}
}
return &Engine{rules: cleaned}
}
// EvaluateAll walks the command tree and evaluates every **runnable**
// command against the Rule. Pure parent groups (no RunE) are deliberately
// skipped here: their decision is derived from children by
// BuildDeniedByPath. Evaluating groups directly would incorrectly deny
// "docs" under an Allow:["docs/**"] rule (the group's own path "docs"
// does not match the "**"-requiring glob).
//
// Hybrid commands (own RunE plus children) are evaluated as ordinary
// leaves here; the aggregation pass treats them specially.
func (e *Engine) EvaluateAll(root *cobra.Command) map[string]Decision {
out := map[string]Decision{}
walkTree(root, func(c *cobra.Command) {
if !c.Runnable() {
return
}
// Pure parent groups carrying the AnnotationPureGroup marker
// (installed by cmd.installUnknownSubcommandGuard) look
// Runnable to cobra but are not a real leaf: skip them just
// like cobra-native parent groups, so a user-level Rule does
// not block `<group> --help` discovery.
if IsPureGroup(c) {
return
}
path := CanonicalPath(c)
if path == "" {
return
}
out[path] = e.EvaluateOne(c)
})
return out
}
// EvaluateOne returns the user-layer decision for a single command. Always
// Allowed=true when the engine has no Rule. With multiple rules the
// decision is the OR over per-rule evaluations: the command is allowed as
// soon as one rule grants it; if every rule rejects it, the rejections are
// merged (see mergeDenials).
func (e *Engine) EvaluateOne(cmd *cobra.Command) Decision {
if len(e.rules) == 0 {
return Decision{Allowed: true}
}
path := CanonicalPath(cmd)
if IsDiagnosticPath(path) {
return Decision{Allowed: true}
}
// risk_invalid is a property of the COMMAND's own annotation (the
// annotation exists but is a typo / not in the closed taxonomy
// read / write / high-risk-write). It is independent of any Rule and
// is always fail-closed regardless of AllowUnannotated -- a typo is a
// code bug, not a migration phase. So it is checked once up front,
// before the per-rule OR loop, and short-circuits to deny.
//
// The "absent" case (no risk_level annotation at all) is per-rule:
// each rule's AllowUnannotated decides, so it lives inside evalRule.
cmdRiskStr, hasRisk := cmdmeta.Risk(cmd)
cmdRisk := platform.Risk(cmdRiskStr)
var (
cmdRank int
cmdRankOk bool
)
if hasRisk {
cmdRank, cmdRankOk = cmdRisk.Rank()
if !cmdRankOk {
return Decision{
Allowed: false,
ReasonCode: "risk_invalid",
Reason: fmt.Sprintf("invalid risk %q; did you mean %q?", cmdRiskStr, suggestRisk(cmdRiskStr)),
}
}
}
// OR across rules: the first rule that fully grants the command wins.
denials := make([]Decision, 0, len(e.rules))
for _, r := range e.rules {
d := evalRule(r, path, cmd, hasRisk, cmdRisk, cmdRank, cmdRankOk)
if d.Allowed {
return Decision{Allowed: true}
}
denials = append(denials, d)
}
return mergeDenials(e.rules, denials)
}
// evalRule applies one Rule's four-axis AND filter to a command whose
// risk annotation has already been parsed by EvaluateOne (risk_invalid is
// handled there). cmdRankOk is false only when the command is unannotated
// (hasRisk=false); a present-but-invalid risk never reaches here. Returns
// Allowed=true only when the command clears every axis of this rule.
func evalRule(r *platform.Rule, path string, cmd *cobra.Command, hasRisk bool, cmdRisk platform.Risk, cmdRank int, cmdRankOk bool) Decision {
// Unannotated gate: fail-closed unless THIS rule opts out. A command
// with no risk_level annotation can still be granted by a rule that
// sets AllowUnannotated=true (gradual-adoption opt-in); other rules in
// the set reject it here and the OR moves on.
if !hasRisk && !r.AllowUnannotated {
return Decision{
Allowed: false,
ReasonCode: "risk_not_annotated",
Reason: "command has no risk_level annotation; rule denies unannotated commands",
}
}
// Axis 1: Deny has priority. Note OR semantics scope a rule's Deny to
// that rule only -- it cannot veto another rule's Allow. A command to
// block everywhere must be denied (or simply not allowed) by every rule.
if matched, ok := firstMatch(r.Deny, path); ok {
return Decision{
Allowed: false,
ReasonCode: "command_denylisted",
Reason: fmt.Sprintf("command path %q matched deny pattern %q", path, matched),
}
}
// Axis 2: Allow gate (empty allow means "no restriction").
if len(r.Allow) > 0 && !matchesAny(r.Allow, path) {
return Decision{
Allowed: false,
ReasonCode: "domain_not_allowed",
Reason: fmt.Sprintf("command path %q not in allow list %v", path, r.Allow),
}
}
// Axis 3: MaxRisk. Skipped when cmd risk is absent + AllowUnannotated:
// the engine has no rank to compare against, and AllowUnannotated
// is the explicit "allow this through" opt-in.
if r.MaxRisk != "" && cmdRankOk {
if limit, limitOk := r.MaxRisk.Rank(); limitOk && cmdRank > limit {
return Decision{
Allowed: false,
ReasonCode: reasonCodeForRisk(cmdRisk),
Reason: fmt.Sprintf("command risk %q exceeds rule max_risk %q", cmdRisk, r.MaxRisk),
}
}
}
// Axis 4: Identities. Unknown command identities is treated as ALLOW.
if len(r.Identities) > 0 {
cmdIdents := cmdmeta.Identities(cmd)
if cmdIdents != nil && !hasIdentityIntersection(r.Identities, cmdIdents) {
return Decision{
Allowed: false,
ReasonCode: "identity_mismatch",
Reason: fmt.Sprintf("command supports identities %v; rule allows %v", cmdIdents, r.Identities),
}
}
}
return Decision{Allowed: true}
}
// mergeDenials collapses the per-rule rejections into a single Decision
// for a command that no rule granted. denials is parallel to rules (same
// order, one entry per rule, all Allowed=false).
//
// With exactly one rule the original rejection is returned verbatim, so
// single-rule envelopes are byte-for-byte identical to the pre-multi-rule
// behaviour (reason_code / reason unchanged). With multiple rules the
// rejection is the aggregate reason_code "no_matching_rule"; its Reason
// enumerates each rule's own rejection for debugging.
func mergeDenials(rules []*platform.Rule, denials []Decision) Decision {
if len(denials) == 1 {
return denials[0]
}
parts := make([]string, len(denials))
for i, d := range denials {
name := rules[i].Name
if name == "" {
name = fmt.Sprintf("#%d", i)
}
parts[i] = fmt.Sprintf("%s: %s", name, d.ReasonCode)
}
return Decision{
Allowed: false,
ReasonCode: "no_matching_rule",
Reason: fmt.Sprintf("no rule grants this command (%s)", strings.Join(parts, "; ")),
}
}
// BuildDeniedByPath converts engine Decisions to a deniedByPath map keyed
// by canonical path. It performs the parent-group aggregation defined in
// the tech doc: a non-runnable parent whose every runnable descendant is
// denied gets an aggregate denial (via AggregateChildren);
// hybrid commands (own RunE + children) get one only when both their own
// RunE and all children are denied.
//
// The root command (no parent) is never installed with a denyStub even if
// every child is denied -- the binary entry point must remain dispatchable
// so `--help` and similar remain available.
//
// source / ruleName populate PolicySource and RuleName on the produced
// Denial values, so envelope output can attribute denials.
func BuildDeniedByPath(root *cobra.Command, decisions map[string]Decision, source ResolveSource, ruleName string) map[string]Denial {
out := map[string]Denial{}
sourceLabel := policySourceLabel(source)
for path, d := range decisions {
if !d.Allowed {
out[path] = Denial{
Layer: LayerPolicy,
PolicySource: sourceLabel,
RuleName: ruleName,
ReasonCode: d.ReasonCode,
Reason: d.Reason,
}
}
}
aggregateParents(root, out)
return out
}
// aggregateParents recursively examines each parent group. Returns true
// when every runnable descendant beneath cmd (including cmd itself when
// runnable) is denied; in that case the function also inserts an aggregate
// Denial for cmd, unless cmd is the binary root or cmd is already in the
// map (own RunE denial preserved).
//
// "Live" children are those with at least one runnable descendant; pure
// non-runnable placeholders neither count toward "all denied" nor block
// the aggregation.
func aggregateParents(cmd *cobra.Command, denied map[string]Denial) bool {
if cmd == nil {
return false
}
children := cmd.Commands()
// A pure parent group decorated with the unknown-subcommand guard
// looks Runnable() to cobra but is not a true hybrid: treat it
// exactly like cobra-native parent groups so the aggregation pass
// can still install an aggregate deny stub when every live child
// is denied.
cmdRunnable := cmd.Runnable() && !IsPureGroup(cmd)
cmdPath := CanonicalPath(cmd)
// Pure leaf
if len(children) == 0 {
if !cmdRunnable {
return false // placeholder, doesn't contribute
}
_, ok := denied[cmdPath]
return ok
}
// Has children: recurse first, collect direct-child denials for the
// aggregation message.
childDenials := make([]ChildDenial, 0, len(children))
liveChildSeen := false
allLiveChildrenDenied := true
for _, child := range children {
childDenied := aggregateParents(child, denied)
if hasRunnableDescendant(child) {
liveChildSeen = true
if !childDenied {
allLiveChildrenDenied = false
}
}
if cp := CanonicalPath(child); cp != "" {
if d, ok := denied[cp]; ok {
childDenials = append(childDenials, ChildDenial{Path: cp, Denial: d})
}
}
}
if !liveChildSeen {
// No reachable runnable descendant in children, but cmd itself
// may still be a runnable hybrid (own RunE + placeholder
// children). The contract is "every runnable descendant
// beneath cmd (including cmd itself when runnable) is denied",
// so when cmd is runnable, the answer depends on whether cmd
// itself was denied. Returning false unconditionally here lost
// that signal and blocked aggregation up the chain.
if cmdRunnable {
_, ownDenied := denied[cmdPath]
return ownDenied
}
return false
}
// Hybrid: own RunE must also be denied for the group to count as denied.
if cmdRunnable {
if _, ownDenied := denied[cmdPath]; !ownDenied {
return false
}
}
if !allLiveChildrenDenied {
return false
}
// Everything reachable below this command is denied. Install the
// aggregate denyStub if there isn't already an own denial here, and
// skip the binary root.
if cmd.HasParent() && cmdPath != "" {
if _, exists := denied[cmdPath]; !exists {
SortChildren(childDenials)
denied[cmdPath] = AggregateChildren(childDenials)
}
}
return true
}
// hasRunnableDescendant reports whether cmd or any descendant has RunE.
// We use it to ignore pure placeholder branches when aggregating.
func hasRunnableDescendant(cmd *cobra.Command) bool {
if cmd == nil {
return false
}
if cmd.Runnable() && !IsPureGroup(cmd) {
return true
}
for _, c := range cmd.Commands() {
if hasRunnableDescendant(c) {
return true
}
}
return false
}
// policySourceLabel produces the "plugin:foo" / "yaml" / "" label that goes
// into CommandDeniedError.PolicySource and envelope.detail.policy_source.
//
// **Plugin name is included** because plugins live inside the binary and
// their names are part of the implementation contract; an integrator
// debugging a denial wants to know which plugin's Restrict() fired.
//
// **YAML file path is deliberately omitted** -- the envelope is observable
// by agents, CI logs, and other downstream systems, and the path leaks
// the user's home directory (e.g. /Users/alice/.lark-cli/policy.yml).
// The Denial.RuleName field already carries the human-identifier the user
// chose for their rule (yaml's "name:" field), which suffices for
// disambiguation. Use `config policy show` if the absolute path matters
// for a local debugging session.
func policySourceLabel(s ResolveSource) string {
switch s.Kind {
case SourcePlugin:
return "plugin:" + s.Name
case SourceYAML:
return "yaml"
}
return ""
}
// reasonCodeForRisk picks the canonical reason_code for an exceeds-max-risk
// rejection.
func reasonCodeForRisk(risk platform.Risk) string {
if risk == platform.RiskWrite || risk == platform.RiskHighRiskWrite {
return "write_not_allowed"
}
return "risk_too_high"
}
// matchesAny reports whether path matches any of the doublestar globs.
// Invalid globs are skipped here -- they're rejected upstream by
// ValidateRule when the rule first enters the system.
func matchesAny(globs []string, path string) bool {
_, ok := firstMatch(globs, path)
return ok
}
// firstMatch returns the first glob in globs that matches path. Used by
// command_denylisted so the envelope can name the specific deny pattern
// that fired.
func firstMatch(globs []string, path string) (string, bool) {
for _, g := range globs {
if ok, err := doublestar.Match(g, path); err == nil && ok {
return g, true
}
}
return "", false
}
// hasIdentityIntersection reports whether the rule's typed identities
// share any value with the command's raw identity strings. Both slices
// are short (usually 1-2 identities) so a nested loop beats allocating
// a set.
func hasIdentityIntersection(rule []platform.Identity, cmd []string) bool {
for _, x := range rule {
for _, y := range cmd {
if string(x) == y {
return true
}
}
}
return false
}
// walkTree applies fn to every command in the tree, depth-first. Hidden
// commands are visited too -- they can still be invoked.
func walkTree(root *cobra.Command, fn func(*cobra.Command)) {
if root == nil {
return
}
fn(root)
for _, c := range root.Commands() {
walkTree(c, fn)
}
}
+592
View File
@@ -0,0 +1,592 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmdpolicy_test
import (
"errors"
"strings"
"testing"
"github.com/spf13/cobra"
"github.com/larksuite/cli/extension/platform"
"github.com/larksuite/cli/internal/cmdmeta"
"github.com/larksuite/cli/internal/cmdpolicy"
"github.com/larksuite/cli/internal/cmdutil"
)
// buildTree assembles a tiny realistic tree for engine tests:
//
// lark-cli (root)
// ├── docs
// │ ├── +fetch risk=read identities=[user,bot]
// │ ├── +update risk=write identities=[user]
// │ └── +delete-doc risk=high-risk-write
// └── im
// └── +send risk=write identities=[bot]
func buildTree() *cobra.Command {
root := &cobra.Command{Use: "lark-cli"}
docs := &cobra.Command{Use: "docs"}
cmdmeta.SetDomain(docs, "docs")
root.AddCommand(docs)
fetch := &cobra.Command{Use: "+fetch", RunE: noop}
cmdutil.SetRisk(fetch, "read")
cmdutil.SetSupportedIdentities(fetch, []string{"user", "bot"})
docs.AddCommand(fetch)
update := &cobra.Command{Use: "+update", RunE: noop}
cmdutil.SetRisk(update, "write")
cmdutil.SetSupportedIdentities(update, []string{"user"})
docs.AddCommand(update)
deleteDoc := &cobra.Command{Use: "+delete-doc", RunE: noop}
cmdutil.SetRisk(deleteDoc, "high-risk-write")
docs.AddCommand(deleteDoc)
im := &cobra.Command{Use: "im"}
cmdmeta.SetDomain(im, "im")
root.AddCommand(im)
send := &cobra.Command{Use: "+send", RunE: noop}
cmdutil.SetRisk(send, "write")
cmdutil.SetSupportedIdentities(send, []string{"bot"})
im.AddCommand(send)
return root
}
func noop(*cobra.Command, []string) error { return nil }
func TestEvaluate_nilRuleAllowsAll(t *testing.T) {
root := buildTree()
got := cmdpolicy.New(nil).EvaluateAll(root)
for path, d := range got {
if !d.Allowed {
t.Fatalf("nil rule should allow all, got Allowed=false for %s", path)
}
}
}
func TestEvaluate_allowGlob(t *testing.T) {
root := buildTree()
e := cmdpolicy.New(&platform.Rule{
Allow: []string{"docs/**"},
})
got := e.EvaluateAll(root)
if !got["docs/+fetch"].Allowed {
t.Errorf("docs/+fetch should be allowed by docs/** glob")
}
if got["im/+send"].Allowed {
t.Errorf("im/+send should NOT be allowed when Allow=docs/**")
}
if got["im/+send"].ReasonCode != "domain_not_allowed" {
t.Errorf("im/+send ReasonCode = %q, want domain_not_allowed",
got["im/+send"].ReasonCode)
}
}
func TestEvaluate_denyTakesPriorityOverAllow(t *testing.T) {
root := buildTree()
e := cmdpolicy.New(&platform.Rule{
Allow: []string{"docs/**"},
Deny: []string{"docs/+delete-doc"},
})
got := e.EvaluateAll(root)
if got["docs/+delete-doc"].Allowed {
t.Errorf("docs/+delete-doc should be denied by Deny rule")
}
if got["docs/+delete-doc"].ReasonCode != "command_denylisted" {
t.Errorf("ReasonCode = %q, want command_denylisted",
got["docs/+delete-doc"].ReasonCode)
}
if !got["docs/+fetch"].Allowed {
t.Errorf("docs/+fetch should still be allowed (not in Deny)")
}
}
func TestEvaluate_maxRiskCutoff(t *testing.T) {
root := buildTree()
e := cmdpolicy.New(&platform.Rule{
MaxRisk: "write", // allow read+write, deny high-risk-write
})
got := e.EvaluateAll(root)
if !got["docs/+update"].Allowed {
t.Errorf("+update (risk=write) should pass MaxRisk=write")
}
if !got["docs/+fetch"].Allowed {
t.Errorf("+fetch (risk=read) should pass MaxRisk=write")
}
if got["docs/+delete-doc"].Allowed {
t.Errorf("+delete-doc (risk=high-risk-write) should fail MaxRisk=write")
}
if rc := got["docs/+delete-doc"].ReasonCode; rc != "write_not_allowed" {
t.Errorf("ReasonCode = %q, want write_not_allowed", rc)
}
}
// Unannotated commands are implicit-deny when any Rule is registered.
// The closed risk taxonomy (read / write / high-risk-write) is the only
// vocabulary a Rule can reason about; an unannotated command falls
// outside that vocabulary and is denied with reason_code
// "risk_not_annotated", regardless of whether the rule sets MaxRisk.
func TestEvaluate_unannotatedRiskIsDeny(t *testing.T) {
root := &cobra.Command{Use: "lark-cli"}
docs := &cobra.Command{Use: "docs"}
root.AddCommand(docs)
// Note: no SetRisk on this command -> unannotated
orphan := &cobra.Command{Use: "+orphan", RunE: noop}
docs.AddCommand(orphan)
// Rule without MaxRisk still triggers the implicit deny.
e := cmdpolicy.New(&platform.Rule{Allow: []string{"docs/**"}})
got := e.EvaluateAll(root)
if got["docs/+orphan"].Allowed {
t.Fatalf("unannotated risk must be denied when a Rule is registered")
}
if got["docs/+orphan"].ReasonCode != "risk_not_annotated" {
t.Errorf("ReasonCode = %q, want risk_not_annotated", got["docs/+orphan"].ReasonCode)
}
// And with MaxRisk it still uses risk_not_annotated (the missing-
// annotation gate runs before the MaxRisk axis).
e = cmdpolicy.New(&platform.Rule{MaxRisk: "read"})
got = e.EvaluateAll(root)
if got["docs/+orphan"].ReasonCode != "risk_not_annotated" {
t.Errorf("ReasonCode under MaxRisk = %q, want risk_not_annotated", got["docs/+orphan"].ReasonCode)
}
// An empty Rule{} (no Allow / Deny / MaxRisk / Identities) still
// triggers the implicit deny. "any registered Rule = enter the safety
// boundary" is the design contract; pin it so future edits cannot
// silently weaken it.
e = cmdpolicy.New(&platform.Rule{})
got = e.EvaluateAll(root)
if got["docs/+orphan"].Allowed {
t.Fatalf("empty Rule{} must still deny unannotated commands")
}
if got["docs/+orphan"].ReasonCode != "risk_not_annotated" {
t.Errorf("empty Rule{} ReasonCode = %q, want risk_not_annotated", got["docs/+orphan"].ReasonCode)
}
// Without any Rule, unannotated commands are still allowed (no
// policy engine is invoked when no plugin registers a Rule).
e = cmdpolicy.New(nil)
got = e.EvaluateAll(root)
if !got["docs/+orphan"].Allowed {
t.Fatalf("nil Rule must allow unannotated commands (no main-flow impact)")
}
}
// AllowUnannotated=true opts out of the "unannotated = deny" rule for
// gradual adoption. The flag does NOT loosen any other axis: Deny still
// rejects, MaxRisk is skipped (no rank to compare), Allow/Identities still
// apply.
func TestEvaluate_allowUnannotatedOptsOutOfDeny(t *testing.T) {
root := &cobra.Command{Use: "lark-cli"}
docs := &cobra.Command{Use: "docs"}
root.AddCommand(docs)
orphan := &cobra.Command{Use: "+orphan", RunE: noop}
docs.AddCommand(orphan)
// Without opt-in: still denied
e := cmdpolicy.New(&platform.Rule{Allow: []string{"docs/**"}})
if got := e.EvaluateAll(root); got["docs/+orphan"].Allowed {
t.Fatalf("default behaviour must deny unannotated; AllowUnannotated should be opt-in")
}
// With opt-in: allowed
e = cmdpolicy.New(&platform.Rule{
Allow: []string{"docs/**"},
AllowUnannotated: true,
})
got := e.EvaluateAll(root)
if !got["docs/+orphan"].Allowed {
t.Fatalf("AllowUnannotated=true must allow unannotated commands; got %+v", got["docs/+orphan"])
}
// AllowUnannotated does NOT bypass Deny: an unannotated command
// hitting a Deny glob is still rejected.
e = cmdpolicy.New(&platform.Rule{
Deny: []string{"docs/+orphan"},
AllowUnannotated: true,
})
got = e.EvaluateAll(root)
if got["docs/+orphan"].Allowed {
t.Fatalf("AllowUnannotated must not bypass Deny; got %+v", got["docs/+orphan"])
}
if got["docs/+orphan"].ReasonCode != "command_denylisted" {
t.Errorf("ReasonCode under Deny+AllowUnannotated = %q, want command_denylisted",
got["docs/+orphan"].ReasonCode)
}
}
// risk_invalid (typo) is unaffected by AllowUnannotated and emits a
// "did you mean" suggestion in the reason text.
func TestEvaluate_invalidRiskAlwaysDeny_andSuggests(t *testing.T) {
root := &cobra.Command{Use: "lark-cli"}
docs := &cobra.Command{Use: "docs"}
root.AddCommand(docs)
typo := &cobra.Command{Use: "+typo", RunE: noop}
cmdutil.SetRisk(typo, "wrtie")
docs.AddCommand(typo)
// AllowUnannotated=true must NOT bypass risk_invalid — typo is a
// code bug, not a missing annotation.
e := cmdpolicy.New(&platform.Rule{
MaxRisk: "read",
AllowUnannotated: true,
})
got := e.EvaluateAll(root)
if got["docs/+typo"].Allowed {
t.Fatalf("AllowUnannotated must not bypass risk_invalid; got %+v", got["docs/+typo"])
}
if got["docs/+typo"].ReasonCode != "risk_invalid" {
t.Errorf("ReasonCode = %q, want risk_invalid", got["docs/+typo"].ReasonCode)
}
if !strings.Contains(got["docs/+typo"].Reason, "write") {
t.Errorf("Reason should contain suggestion 'write', got %q", got["docs/+typo"].Reason)
}
}
// Invalid risk annotations (typos like "wrtie" or anything outside the
// read|write|high-risk-write taxonomy) are denied with reason_code
// "risk_invalid". Without this gate they used to pass the MaxRisk axis
// because RiskRank returned ok=false and the comparison was skipped --
// a typo SetRisk would silently slip past an "agent read-only" rule.
func TestEvaluate_invalidRiskIsDeny(t *testing.T) {
root := &cobra.Command{Use: "lark-cli"}
docs := &cobra.Command{Use: "docs"}
root.AddCommand(docs)
typo := &cobra.Command{Use: "+typo", RunE: noop}
cmdutil.SetRisk(typo, "wrtie") // typo for "write"
docs.AddCommand(typo)
// Even under MaxRisk=read the typo command must not slip through.
e := cmdpolicy.New(&platform.Rule{MaxRisk: "read"})
got := e.EvaluateAll(root)
if got["docs/+typo"].Allowed {
t.Fatalf("invalid risk must be denied under MaxRisk=read, got allowed")
}
if got["docs/+typo"].ReasonCode != "risk_invalid" {
t.Errorf("ReasonCode = %q, want risk_invalid", got["docs/+typo"].ReasonCode)
}
// Same when no MaxRisk is set -- the taxonomy check runs unconditionally
// once a Rule is present.
e = cmdpolicy.New(&platform.Rule{Allow: []string{"docs/**"}})
got = e.EvaluateAll(root)
if got["docs/+typo"].ReasonCode != "risk_invalid" {
t.Errorf("ReasonCode without MaxRisk = %q, want risk_invalid", got["docs/+typo"].ReasonCode)
}
// The risk_invalid gate must fire BEFORE Deny matching, otherwise a
// typo command landing in the deny list would surface as
// command_denylisted and mask the underlying taxonomy violation.
e = cmdpolicy.New(&platform.Rule{Deny: []string{"docs/+typo"}})
got = e.EvaluateAll(root)
if got["docs/+typo"].ReasonCode != "risk_invalid" {
t.Errorf("ReasonCode under Deny match = %q, want risk_invalid (taxonomy gate must precede Deny)", got["docs/+typo"].ReasonCode)
}
// Without any Rule, invalid risk is not policed (same main-flow
// no-impact rule as risk_not_annotated).
e = cmdpolicy.New(nil)
got = e.EvaluateAll(root)
if !got["docs/+typo"].Allowed {
t.Fatalf("nil Rule must allow invalid risk (no main-flow impact)")
}
}
func TestEvaluate_identitiesIntersection(t *testing.T) {
root := buildTree()
e := cmdpolicy.New(&platform.Rule{
Identities: []platform.Identity{"bot"}, // bot-only rule
})
got := e.EvaluateAll(root)
// docs/+fetch has [user, bot] -- intersection includes bot -> ALLOW
if !got["docs/+fetch"].Allowed {
t.Errorf("+fetch (identities=user,bot) should intersect bot rule")
}
// docs/+update has [user] -- no intersection with bot -> DENY
if got["docs/+update"].Allowed {
t.Errorf("+update (identities=user) should fail bot-only rule")
}
if got["docs/+update"].ReasonCode != "identity_mismatch" {
t.Errorf("ReasonCode = %q, want identity_mismatch",
got["docs/+update"].ReasonCode)
}
}
// Reason strings must carry both the attempted value and the rule's
// constraint so the envelope is self-contained for AI consumers.
// Asserting on substrings (not exact match) leaves room for minor wording
// tweaks while pinning the value-carrying behaviour.
func TestEvaluate_reasonCarriesAttemptAndConstraint(t *testing.T) {
root := buildTree()
cases := []struct {
name string
rule *platform.Rule
path string
wantInReason []string
}{
{
name: "identity_mismatch surfaces both identity sets",
rule: &platform.Rule{Identities: []platform.Identity{"bot"}},
path: "docs/+update", // identities=[user]
wantInReason: []string{"[user]", "[bot]"},
},
{
name: "domain_not_allowed surfaces path and allow list",
rule: &platform.Rule{Allow: []string{"docs/**"}},
path: "im/+send",
wantInReason: []string{`"im/+send"`, "docs/**"},
},
{
name: "command_denylisted surfaces matched deny pattern",
rule: &platform.Rule{Deny: []string{"docs/+delete-*"}},
path: "docs/+delete-doc",
wantInReason: []string{`"docs/+delete-doc"`, `"docs/+delete-*"`},
},
{
name: "risk_too_high surfaces cmd risk and max_risk",
rule: &platform.Rule{MaxRisk: "write"},
path: "docs/+delete-doc", // risk=high-risk-write
wantInReason: []string{`"high-risk-write"`, `"write"`},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := cmdpolicy.New(tc.rule).EvaluateAll(root)
d, ok := got[tc.path]
if !ok {
t.Fatalf("no decision for %q", tc.path)
}
if d.Allowed {
t.Fatalf("%q should have been denied", tc.path)
}
for _, sub := range tc.wantInReason {
if !strings.Contains(d.Reason, sub) {
t.Errorf("reason %q missing %q", d.Reason, sub)
}
}
})
}
}
// Unknown identities defaults to ALLOW. A command with risk annotated
// but without supportedIdentities passes any identity filter.
func TestEvaluate_unknownIdentitiesIsAllow(t *testing.T) {
root := &cobra.Command{Use: "lark-cli"}
cmd := &cobra.Command{Use: "+x", RunE: noop}
cmdutil.SetRisk(cmd, "read")
root.AddCommand(cmd)
// no SetSupportedIdentities
e := cmdpolicy.New(&platform.Rule{Identities: []platform.Identity{"bot"}})
got := e.EvaluateAll(root)
if !got["+x"].Allowed {
t.Fatalf("unknown identities must pass any identity rule")
}
}
// --- Multi-rule (OR) semantics ---
// Two scoped rules (docs read-only, im writable) are OR-combined: a
// command is allowed when it satisfies ANY rule. This is the headline
// multi-rule use case -- different command groups need different risk
// ceilings within one policy.
func TestEvaluate_multiRuleOR(t *testing.T) {
root := buildTree()
e := cmdpolicy.NewSet([]*platform.Rule{
{Name: "docs-ro", Allow: []string{"docs/**"}, MaxRisk: "read"},
{Name: "im-rw", Allow: []string{"im/**"}, MaxRisk: "write"},
})
got := e.EvaluateAll(root)
// docs/+fetch (read) clears docs-ro.
if !got["docs/+fetch"].Allowed {
t.Errorf("docs/+fetch should be allowed by docs-ro")
}
// im/+send (write) clears im-rw even though docs-ro rejects it.
if !got["im/+send"].Allowed {
t.Errorf("im/+send (write) should be allowed by im-rw")
}
// docs/+update (write) exceeds docs-ro's read ceiling AND is outside
// im-rw's allow list -> rejected by both -> no_matching_rule.
if got["docs/+update"].Allowed {
t.Fatalf("docs/+update should be denied: read-only in docs, not allowed in im")
}
if rc := got["docs/+update"].ReasonCode; rc != "no_matching_rule" {
t.Errorf("docs/+update ReasonCode = %q, want no_matching_rule", rc)
}
}
// Identity can differ per rule: docs limited to user, im open to bot.
// This is the second half of the requirement -- some commands restrict
// identity, others allow the bot identity.
func TestEvaluate_multiRulePerRuleIdentity(t *testing.T) {
root := buildTree()
e := cmdpolicy.NewSet([]*platform.Rule{
{Name: "docs-user", Allow: []string{"docs/**"}, MaxRisk: "write", Identities: []platform.Identity{"user"}},
{Name: "im-bot", Allow: []string{"im/**"}, MaxRisk: "write", Identities: []platform.Identity{"bot"}},
})
got := e.EvaluateAll(root)
// docs/+update identities=[user] -> docs-user grants.
if !got["docs/+update"].Allowed {
t.Errorf("docs/+update (user) should be allowed by docs-user")
}
// im/+send identities=[bot] -> im-bot grants.
if !got["im/+send"].Allowed {
t.Errorf("im/+send (bot) should be allowed by im-bot")
}
// docs/+delete-doc is high-risk-write -> exceeds both ceilings -> denied.
if got["docs/+delete-doc"].Allowed {
t.Errorf("docs/+delete-doc (high-risk-write) should be denied by both rules")
}
}
// NewSet with a single rule must behave exactly like New: the per-rule
// rejection (not the aggregate no_matching_rule) is preserved so the
// single-rule envelope is unchanged.
func TestEvaluate_newSetSingleRuleKeepsReason(t *testing.T) {
root := buildTree()
e := cmdpolicy.NewSet([]*platform.Rule{
{Allow: []string{"docs/**"}},
})
got := e.EvaluateAll(root)
if got["im/+send"].Allowed {
t.Fatalf("im/+send should be denied by docs-only rule")
}
if rc := got["im/+send"].ReasonCode; rc != "domain_not_allowed" {
t.Errorf("single-rule reason must be preserved verbatim, got %q want domain_not_allowed", rc)
}
}
// NewSet drops nil entries; an all-nil/empty set means "no restriction".
func TestNewSet_emptyAndNilMeansNoRestriction(t *testing.T) {
root := buildTree()
for _, rules := range [][]*platform.Rule{nil, {}, {nil}} {
got := cmdpolicy.NewSet(rules).EvaluateAll(root)
for path, d := range got {
if !d.Allowed {
t.Fatalf("empty/nil rule set must allow all, got deny for %s", path)
}
}
}
}
// Apply must install denyStubs only on Layer="policy" entries. A
// "strict_mode" denial in the same map must be left for
// applyStrictModeDenials in cmd/.
func TestApply_onlyTouchesPruningLayer(t *testing.T) {
root := buildTree()
denied := map[string]cmdpolicy.Denial{
"docs/+update": {Layer: "policy", ReasonCode: "write_not_allowed"},
"docs/+fetch": {Layer: "strict_mode", ReasonCode: "identity_not_supported"},
}
count := cmdpolicy.Apply(root, denied)
if count != 1 {
t.Fatalf("Apply count = %d, want 1 (only pruning-layer entries)", count)
}
update := findChild(t, root, "docs", "+update")
if !update.Hidden {
t.Errorf("+update should be Hidden after Apply")
}
if !update.DisableFlagParsing {
t.Errorf("+update should have DisableFlagParsing=true (constraint #4)")
}
// strict-mode entry must NOT have been touched here.
fetch := findChild(t, root, "docs", "+fetch")
if fetch.Hidden || fetch.DisableFlagParsing {
t.Errorf("+fetch (strict_mode layer) should NOT be touched by cmdpolicy.Apply")
}
}
// Calling the denied RunE must produce a typed CommandDeniedError with the
// right Layer/ReasonCode. This is the contract every external consumer
// (agent, integration) depends on.
func TestApply_runEReturnsTypedError(t *testing.T) {
root := buildTree()
cmdpolicy.Apply(root, map[string]cmdpolicy.Denial{
"docs/+update": {
Layer: "policy",
PolicySource: "plugin:secaudit",
RuleName: "secaudit-policy",
ReasonCode: "write_not_allowed",
Reason: "write disabled",
},
})
update := findChild(t, root, "docs", "+update")
err := update.RunE(update, []string{})
if err == nil {
t.Fatalf("denied command should return error")
}
var denied *platform.CommandDeniedError
if !errors.As(err, &denied) {
t.Fatalf("error should be *platform.CommandDeniedError, got %T", err)
}
if denied.Layer != "policy" || denied.ReasonCode != "write_not_allowed" {
t.Errorf("denial = %+v, want layer=pruning code=write_not_allowed", denied)
}
if denied.Path != "docs/+update" {
t.Errorf("Path = %q, want docs/+update", denied.Path)
}
if denied.PolicySource != "plugin:secaudit" || denied.RuleName != "secaudit-policy" {
t.Errorf("policy source / rule name lost in stub: %+v", denied)
}
}
func TestApply_emptyMapNoop(t *testing.T) {
root := buildTree()
if got := cmdpolicy.Apply(root, nil); got != 0 {
t.Fatalf("nil deniedByPath should yield count=0, got %d", got)
}
}
// CanonicalPath strips the root and joins with slashes -- the form
// doublestar globs need to work.
func TestCanonicalPath(t *testing.T) {
root := buildTree()
update := findChild(t, root, "docs", "+update")
if got := cmdpolicy.CanonicalPath(update); got != "docs/+update" {
t.Fatalf("CanonicalPath = %q, want docs/+update", got)
}
if got := cmdpolicy.CanonicalPath(root); got != "lark-cli" {
t.Fatalf("CanonicalPath(root) = %q, want lark-cli (orphan fallback)", got)
}
}
// findChild is a test helper: descend a path of cmd.Use names through the
// tree, failing the test if any step is missing.
func findChild(t *testing.T, parent *cobra.Command, names ...string) *cobra.Command {
t.Helper()
cur := parent
for _, n := range names {
var next *cobra.Command
for _, c := range cur.Commands() {
if c.Use == n {
next = c
break
}
}
if next == nil {
t.Fatalf("child %q not found under %q", n, cur.Use)
}
cur = next
}
return cur
}
+39
View File
@@ -0,0 +1,39 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmdpolicy
import (
"strings"
"github.com/spf13/cobra"
)
// CanonicalPath returns the rootless slash-separated path used everywhere in
// the pruning framework. Cobra's CommandPath() yields space-separated
// segments ("lark-cli docs +update"); doublestar globs ("docs/**") require
// slashes, so all internal lookups go through this conversion.
func CanonicalPath(cmd *cobra.Command) string {
if cmd == nil {
return ""
}
parts := make([]string, 0, 4)
for c := cmd; c != nil && c.HasParent(); c = c.Parent() {
parts = append(parts, useName(c))
}
for i, j := 0, len(parts)-1; i < j; i, j = i+1, j-1 {
parts[i], parts[j] = parts[j], parts[i]
}
if len(parts) == 0 {
return useName(cmd)
}
return strings.Join(parts, "/")
}
func useName(cmd *cobra.Command) string {
name := cmd.Use
if i := strings.IndexByte(name, ' '); i >= 0 {
name = name[:i]
}
return name
}
+117
View File
@@ -0,0 +1,117 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmdpolicy
import (
"errors"
"fmt"
"os"
"github.com/larksuite/cli/extension/platform"
pyaml "github.com/larksuite/cli/internal/cmdpolicy/yaml"
"github.com/larksuite/cli/internal/vfs"
)
type SourceKind string
const (
SourcePlugin SourceKind = "plugin"
SourceYAML SourceKind = "yaml"
SourceNone SourceKind = "none"
)
type ResolveSource struct {
Kind SourceKind
Name string
}
type PluginRule struct {
PluginName string
Rule *platform.Rule
}
type Sources struct {
PluginRules []PluginRule
YAMLRules []*platform.Rule
YAMLPath string
}
var ErrMultipleRestricts = errors.New("multiple plugins called Restrict; only one plugin may own the policy")
// Resolve picks by precedence: plugin > yaml > none, returning the full
// rule set the winning source contributes. Pure function; load yaml via
// LoadYAMLPolicy first. Every returned rule is validated.
//
// Multi-rule semantics (single owner): one plugin may contribute several
// rules (each a scoped grant, OR-combined by the engine), but two or more
// DISTINCT plugins contributing rules is still a configuration error --
// the resolver aborts so independent plugins cannot silently widen each
// other's policy. yaml may likewise carry several rules under "rules:".
func Resolve(s Sources) ([]*platform.Rule, ResolveSource, error) {
owners := distinctOwners(s.PluginRules)
if len(owners) > 1 {
return nil, ResolveSource{}, fmt.Errorf("%w: %v", ErrMultipleRestricts, owners)
}
if len(s.PluginRules) > 0 {
rules := make([]*platform.Rule, 0, len(s.PluginRules))
for _, pr := range s.PluginRules {
if err := ValidateRule(pr.Rule); err != nil {
return nil, ResolveSource{}, fmt.Errorf("plugin %q rule invalid: %w", pr.PluginName, err)
}
rules = append(rules, pr.Rule)
}
return rules, ResolveSource{Kind: SourcePlugin, Name: owners[0]}, nil
}
if len(s.YAMLRules) > 0 {
for _, r := range s.YAMLRules {
if err := ValidateRule(r); err != nil {
return nil, ResolveSource{}, fmt.Errorf("policy yaml %q: %w", s.YAMLPath, err)
}
}
return s.YAMLRules, ResolveSource{Kind: SourceYAML, Name: s.YAMLPath}, nil
}
return nil, ResolveSource{Kind: SourceNone}, nil
}
// distinctOwners returns the unique plugin names contributing a rule, in
// first-seen order. A single plugin contributing N rules collapses to one
// owner; that is the case the single-owner check below permits.
func distinctOwners(prs []PluginRule) []string {
seen := map[string]bool{}
owners := make([]string, 0, len(prs))
for _, pr := range prs {
if !seen[pr.PluginName] {
seen[pr.PluginName] = true
owners = append(owners, pr.PluginName)
}
}
return owners
}
// LoadYAMLPolicy returns (nil, nil) when path is empty or file is absent,
// so callers can pass the result straight into Sources.YAMLRules. A
// present file yields one or more rules (see yaml.Parse).
func LoadYAMLPolicy(path string) ([]*platform.Rule, error) {
if path == "" {
return nil, nil
}
if _, err := vfs.Stat(path); err != nil {
if errors.Is(err, os.ErrNotExist) {
return nil, nil
}
return nil, fmt.Errorf("stat policy yaml %q: %w", path, err)
}
data, err := vfs.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("read policy yaml %q: %w", path, err)
}
rules, err := pyaml.Parse(data)
if err != nil {
return nil, fmt.Errorf("policy yaml %q: %w", path, err)
}
return rules, nil
}
+162
View File
@@ -0,0 +1,162 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmdpolicy_test
import (
"errors"
"os"
"path/filepath"
"testing"
"github.com/larksuite/cli/extension/platform"
"github.com/larksuite/cli/internal/cmdpolicy"
)
func TestResolve_singlePluginWins(t *testing.T) {
rule := &platform.Rule{Name: "secaudit"}
got, src, err := cmdpolicy.Resolve(cmdpolicy.Sources{
PluginRules: []cmdpolicy.PluginRule{{PluginName: "secaudit", Rule: rule}},
})
if err != nil {
t.Fatalf("Resolve err: %v", err)
}
if len(got) != 1 || got[0] != rule || src.Kind != cmdpolicy.SourcePlugin || src.Name != "secaudit" {
t.Fatalf("Resolve = (%v, %+v)", got, src)
}
}
// A single plugin may contribute several rules (each a scoped grant). They
// are all returned, in registration order, under one plugin source.
func TestResolve_singlePluginMultipleRules(t *testing.T) {
r1 := &platform.Rule{Name: "docs-ro", Allow: []string{"docs/**"}, MaxRisk: "read"}
r2 := &platform.Rule{Name: "im-rw", Allow: []string{"im/**"}, MaxRisk: "write"}
got, src, err := cmdpolicy.Resolve(cmdpolicy.Sources{
PluginRules: []cmdpolicy.PluginRule{
{PluginName: "secaudit", Rule: r1},
{PluginName: "secaudit", Rule: r2},
},
})
if err != nil {
t.Fatalf("Resolve err: %v", err)
}
if len(got) != 2 || got[0] != r1 || got[1] != r2 {
t.Fatalf("expected both rules in order, got %v", got)
}
if src.Kind != cmdpolicy.SourcePlugin || src.Name != "secaudit" {
t.Fatalf("source = %+v, want plugin:secaudit", src)
}
}
func TestResolve_pluginShadowsYaml(t *testing.T) {
pluginRule := &platform.Rule{Name: "from-plugin"}
yamlRule := &platform.Rule{Name: "from-yaml"}
got, src, err := cmdpolicy.Resolve(cmdpolicy.Sources{
PluginRules: []cmdpolicy.PluginRule{{PluginName: "secaudit", Rule: pluginRule}},
YAMLRules: []*platform.Rule{yamlRule},
YAMLPath: "/some/policy.yml",
})
if err != nil {
t.Fatalf("Resolve err: %v", err)
}
if len(got) != 1 || got[0].Name != "from-plugin" || src.Kind != cmdpolicy.SourcePlugin {
t.Fatalf("plugin should shadow yaml, got %+v / %+v", got, src)
}
}
func TestResolve_yamlWhenNoPlugin(t *testing.T) {
yamlRule := &platform.Rule{Name: "from-yaml", MaxRisk: "read"}
got, src, err := cmdpolicy.Resolve(cmdpolicy.Sources{
YAMLRules: []*platform.Rule{yamlRule},
YAMLPath: "/some/policy.yml",
})
if err != nil {
t.Fatalf("Resolve err: %v", err)
}
if len(got) != 1 || got[0].Name != "from-yaml" || src.Kind != cmdpolicy.SourceYAML {
t.Fatalf("yaml should win when no plugin, got %+v / %+v", got, src)
}
if src.Name != "/some/policy.yml" {
t.Errorf("yaml source Name should carry path, got %q", src.Name)
}
}
// yaml may also carry several rules under "rules:"; all are returned.
func TestResolve_yamlMultipleRules(t *testing.T) {
r1 := &platform.Rule{Name: "a", MaxRisk: "read"}
r2 := &platform.Rule{Name: "b", MaxRisk: "write"}
got, src, err := cmdpolicy.Resolve(cmdpolicy.Sources{
YAMLRules: []*platform.Rule{r1, r2},
YAMLPath: "/some/policy.yml",
})
if err != nil {
t.Fatalf("Resolve err: %v", err)
}
if len(got) != 2 || src.Kind != cmdpolicy.SourceYAML {
t.Fatalf("expected both yaml rules, got %v / %+v", got, src)
}
}
func TestResolve_emptyEverythingIsNone(t *testing.T) {
got, src, err := cmdpolicy.Resolve(cmdpolicy.Sources{})
if err != nil {
t.Fatalf("Resolve err: %v", err)
}
if len(got) != 0 || src.Kind != cmdpolicy.SourceNone {
t.Fatalf("expected (empty, SourceNone), got (%v, %+v)", got, src)
}
}
// Two DISTINCT plugins both contributing a Rule must produce the typed
// error so the bootstrap pipeline aborts (single-owner invariant): one
// plugin cannot silently widen another plugin's policy.
func TestResolve_multipleRestrictPluginsIsError(t *testing.T) {
_, _, err := cmdpolicy.Resolve(cmdpolicy.Sources{
PluginRules: []cmdpolicy.PluginRule{
{PluginName: "a", Rule: &platform.Rule{Name: "a"}},
{PluginName: "b", Rule: &platform.Rule{Name: "b"}},
},
})
if !errors.Is(err, cmdpolicy.ErrMultipleRestricts) {
t.Fatalf("err = %v, want ErrMultipleRestricts", err)
}
}
// LoadYAMLPolicy: missing file returns (nil, nil) silently so callers
// can pass the result straight into Sources.YAMLRules without special-
// casing not-exist.
func TestLoadYAMLPolicy_missingIsSilent(t *testing.T) {
missing := filepath.Join(t.TempDir(), "absent-policy.yml")
rules, err := cmdpolicy.LoadYAMLPolicy(missing)
if err != nil {
t.Fatalf("missing yaml should not error, got %v", err)
}
if rules != nil {
t.Fatalf("missing yaml should return nil rules, got %+v", rules)
}
}
func TestLoadYAMLPolicy_emptyPathIsNoop(t *testing.T) {
rules, err := cmdpolicy.LoadYAMLPolicy("")
if err != nil {
t.Fatalf("empty path should not error, got %v", err)
}
if rules != nil {
t.Fatalf("empty path should return nil rules, got %+v", rules)
}
}
func TestLoadYAMLPolicy_parsesValid(t *testing.T) {
dir := t.TempDir()
yamlPath := filepath.Join(dir, "policy.yml")
if err := os.WriteFile(yamlPath, []byte("name: from-yaml\nmax_risk: read\n"), 0o644); err != nil {
t.Fatalf("write yaml: %v", err)
}
rules, err := cmdpolicy.LoadYAMLPolicy(yamlPath)
if err != nil {
t.Fatalf("LoadYAMLPolicy err: %v", err)
}
if len(rules) != 1 || rules[0].Name != "from-yaml" {
t.Fatalf("expected one rule with name=from-yaml, got %+v", rules)
}
}
+94
View File
@@ -0,0 +1,94 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmdpolicy_test
import (
"errors"
"strings"
"testing"
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/extension/platform"
"github.com/larksuite/cli/internal/cmdpolicy"
)
// The envelope's policy_source must never leak the absolute home path.
// "yaml:/Users/alice/.lark-cli/policy.yml" would expose Alice's username
// to any agent or log consumer; the contract is to emit just "yaml" and
// rely on rule_name (from the yaml's "name:" field) for disambiguation.
func TestEnvelope_yamlPolicySourceDoesNotLeakHomePath(t *testing.T) {
root := &cobra.Command{Use: "lark-cli"}
docs := &cobra.Command{Use: "docs"}
root.AddCommand(docs)
leaf := &cobra.Command{Use: "+write", RunE: func(*cobra.Command, []string) error { return nil }}
docs.AddCommand(leaf)
e := cmdpolicy.New(&platform.Rule{
Name: "my-readonly-rule",
Allow: []string{"contact/**"}, // docs/* falls outside, denied
})
denied := cmdpolicy.BuildDeniedByPath(root, e.EvaluateAll(root),
cmdpolicy.ResolveSource{
Kind: cmdpolicy.SourceYAML,
Name: "/Users/alice/.lark-cli/policy.yml", // simulate an absolute path
}, "my-readonly-rule")
cmdpolicy.Apply(root, denied)
err := leaf.RunE(leaf, nil)
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("expected denial *errs.ValidationError, got %T %v", err, err)
}
// The policy source is folded into the Hint as "yaml" -- the bare
// kind, never the absolute path.
if !strings.Contains(ve.Hint, "source yaml") {
t.Errorf("hint must carry policy_source %q (no path leak), got %q", "yaml", ve.Hint)
}
// rule_name carries the disambiguating identifier.
if !strings.Contains(ve.Hint, "my-readonly-rule") {
t.Errorf("hint must carry rule_name my-readonly-rule, got %q", ve.Hint)
}
// Direct privacy probe: the absolute home path must not appear
// anywhere in the user-facing message OR hint text.
if strings.Contains(ve.Message, "/Users/alice") {
t.Errorf("error message must not leak '/Users/alice', got %q", ve.Message)
}
if strings.Contains(ve.Hint, "/Users/alice") {
t.Errorf("error hint must not leak '/Users/alice', got %q", ve.Hint)
}
}
// Plugin name IS allowed in policy_source because plugins are in-binary
// and their names are part of the contract (an integrator debugging a
// denial wants to know which plugin fired). This test pins that intent
// so a future change does not silently strip the plugin name too.
func TestEnvelope_pluginPolicySourceCarriesName(t *testing.T) {
root := &cobra.Command{Use: "lark-cli"}
leaf := &cobra.Command{Use: "+block", RunE: func(*cobra.Command, []string) error { return nil }}
root.AddCommand(leaf)
e := cmdpolicy.New(&platform.Rule{
Name: "secaudit-policy",
Deny: []string{"+block"},
})
denied := cmdpolicy.BuildDeniedByPath(root, e.EvaluateAll(root),
cmdpolicy.ResolveSource{Kind: cmdpolicy.SourcePlugin, Name: "secaudit"},
"secaudit-policy")
cmdpolicy.Apply(root, denied)
err := leaf.RunE(leaf, nil)
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("expected *errs.ValidationError, got %T", err)
}
// The plugin name IS surfaced (in-binary, part of the contract): it
// must appear in the Hint so an integrator debugging a denial knows
// which plugin fired.
if !strings.Contains(ve.Hint, "plugin:secaudit") {
t.Errorf("hint must carry policy_source plugin:secaudit, got %q", ve.Hint)
}
}
+163
View File
@@ -0,0 +1,163 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmdpolicy_test
import (
"errors"
"testing"
"github.com/spf13/cobra"
"github.com/larksuite/cli/internal/cmdpolicy"
)
// cmdpolicy.Apply MUST NOT overwrite the denial annotation on a command
// already marked as strict-mode denied. strict-mode is a hard boundary
// (credential-derived); a user-layer rule cannot relabel or replace
// the error path.
//
// Without this invariant: when a user yaml rule happened to match the
// path of a strict-mode stub, Apply would change layer=strict_mode to
// layer=pruning, and the user-visible error would say "denied by yaml"
// instead of "strict mode". The hard-boundary contract demands
// strict_mode wins.
func TestApply_PreservesStrictModeAnnotation(t *testing.T) {
root := &cobra.Command{Use: "root"}
stub := &cobra.Command{
Use: "victim",
Hidden: true,
Annotations: map[string]string{
cmdpolicy.AnnotationDenialLayer: cmdpolicy.LayerStrictMode,
cmdpolicy.AnnotationDenialSource: "strict-mode",
},
RunE: func(*cobra.Command, []string) error { return nil },
}
root.AddCommand(stub)
// User-layer pruning denies the same path.
denied := map[string]cmdpolicy.Denial{
"victim": {
Layer: cmdpolicy.LayerPolicy,
PolicySource: "yaml",
Reason: "denied by user yaml",
ReasonCode: "command_denylisted",
},
}
cmdpolicy.Apply(root, denied)
if got := stub.Annotations[cmdpolicy.AnnotationDenialLayer]; got != cmdpolicy.LayerStrictMode {
t.Errorf("strict-mode layer overwritten by pruning: got %q want %q",
got, cmdpolicy.LayerStrictMode)
}
if got := stub.Annotations[cmdpolicy.AnnotationDenialSource]; got != "strict-mode" {
t.Errorf("strict-mode source overwritten: got %q", got)
}
}
// Regression for codex H13 / C6: a denied command that carries
// flag-like positional args (because DisableFlagParsing=true makes
// every `--doc xxx` look positional) MUST surface the pruning
// envelope, not a cobra usage error. Pre-fix, the original command's
// Args validator (e.g. cobra.NoArgs from shortcut registration) would
// fire BEFORE PersistentPreRunE / RunE and produce
// "Error: positional arguments are not supported".
//
// Fix: installDenyStub sets Args=ArbitraryArgs so cobra's validate
// step always passes, letting dispatch reach the wrapped RunE.
func TestApply_DenyStubBypassesArgsValidator(t *testing.T) {
root := &cobra.Command{Use: "root"}
leaf := &cobra.Command{
Use: "+update",
Args: cobra.NoArgs, // shortcut style: refuse all positional args
RunE: func(*cobra.Command, []string) error { return nil },
}
root.AddCommand(leaf)
denied := map[string]cmdpolicy.Denial{
"+update": {
Layer: cmdpolicy.LayerPolicy,
PolicySource: "yaml",
ReasonCode: "command_denylisted",
Reason: "denied by user yaml",
},
}
cmdpolicy.Apply(root, denied)
if leaf.Args == nil {
t.Fatal("denied command must have non-nil Args validator after Apply")
}
// ArbitraryArgs returns nil for every input -> Args validation no-ops.
if err := leaf.Args(leaf, []string{"--doc", "xxx", "--mode", "append"}); err != nil {
t.Errorf("denied command Args validator should accept any input, got %v", err)
}
}
// Regression for codex C11 / C13: a denied command whose PARENT
// declares a PersistentPreRunE (e.g. cmd/auth/auth.go's
// external_provider check) MUST surface the pruning envelope, not
// the parent's error. Cobra's "first PersistentPreRunE walking up
// from leaf wins" semantics will pick the parent's PersistentPreRunE
// unless the denied leaf carries its own.
//
// Fix: installDenyStub installs a no-op PersistentPreRunE on the leaf
// so cobra stops there and proceeds to the wrapped RunE (which holds
// the real pruning envelope).
func TestApply_DenyStubBypassesParentPersistentPreRunE(t *testing.T) {
root := &cobra.Command{Use: "root"}
parent := &cobra.Command{
Use: "auth",
PersistentPreRunE: func(*cobra.Command, []string) error {
return errors.New("parent PersistentPreRunE fired (would mask pruning)")
},
}
root.AddCommand(parent)
leaf := &cobra.Command{
Use: "login",
RunE: func(*cobra.Command, []string) error { return nil },
}
parent.AddCommand(leaf)
denied := map[string]cmdpolicy.Denial{
"auth/login": {
Layer: cmdpolicy.LayerPolicy,
PolicySource: "yaml",
ReasonCode: "identity_mismatch",
Reason: "denied",
},
}
cmdpolicy.Apply(root, denied)
if leaf.PersistentPreRunE == nil {
t.Fatal("denied command must have leaf-level PersistentPreRunE")
}
// Our PersistentPreRunE must NOT propagate the parent's error.
if err := leaf.PersistentPreRunE(leaf, nil); err != nil {
t.Errorf("denied command leaf PersistentPreRunE should be no-op, got %v", err)
}
}
// Sanity: a normal command (no prior annotation) still gets the
// pruning denial annotations after Apply.
func TestApply_NonStrictCommandStillGetsPruningAnnotation(t *testing.T) {
root := &cobra.Command{Use: "root"}
leaf := &cobra.Command{
Use: "normal",
RunE: func(*cobra.Command, []string) error { return nil },
}
root.AddCommand(leaf)
denied := map[string]cmdpolicy.Denial{
"normal": {
Layer: cmdpolicy.LayerPolicy,
PolicySource: "yaml",
Reason: "denied",
ReasonCode: "command_denylisted",
},
}
cmdpolicy.Apply(root, denied)
if got := leaf.Annotations[cmdpolicy.AnnotationDenialLayer]; got != cmdpolicy.LayerPolicy {
t.Errorf("expected pruning layer annotation, got %q", got)
}
}
+43
View File
@@ -0,0 +1,43 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmdpolicy
import (
"github.com/larksuite/cli/extension/platform"
"github.com/larksuite/cli/internal/suggest"
)
// suggestRisk returns the closest valid Risk literal by edit distance
// for risk_invalid diagnostics; input is never silently substituted.
// Case-insensitive ("WRITE" → "write"); empty in, empty out (the
// absent-annotation case goes to risk_not_annotated, not here).
func suggestRisk(bad string) string {
if bad == "" {
return ""
}
lowered := toLower(bad)
candidates := []platform.Risk{
platform.RiskRead, platform.RiskWrite, platform.RiskHighRiskWrite,
}
best := string(candidates[0])
bestDist := suggest.Levenshtein(lowered, best)
for _, c := range candidates[1:] {
if d := suggest.Levenshtein(lowered, string(c)); d < bestDist {
bestDist, best = d, string(c)
}
}
return best
}
// toLower is an ASCII-only lowercase. Risk taxonomy values are
// ASCII; pulling in unicode here would be overkill.
func toLower(s string) string {
b := []byte(s)
for i, c := range b {
if c >= 'A' && c <= 'Z' {
b[i] = c + ('a' - 'A')
}
}
return string(b)
}
+31
View File
@@ -0,0 +1,31 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmdpolicy
import "testing"
// suggest is unexported, so the test lives in the same package.
func TestSuggestRisk(t *testing.T) {
cases := []struct {
input string
want string
}{
{"wrtie", "write"},
{"WRITE", "write"},
{"reed", "read"},
{"rad", "read"},
{"high-rik-write", "high-risk-write"},
// "highrisk" is genuinely ambiguous between "write" and
// "high-risk-write" — not testing it.
{"", ""}, // empty input has no meaningful suggestion; the engine
// routes the absent case to risk_not_annotated, not risk_invalid.
}
for _, c := range cases {
got := suggestRisk(c.input)
if got != c.want {
t.Errorf("suggestRisk(%q) = %q, want %q", c.input, got, c.want)
}
}
}
+75
View File
@@ -0,0 +1,75 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmdpolicy
import (
"fmt"
"github.com/bmatcuk/doublestar/v4"
"github.com/larksuite/cli/extension/platform"
)
// ValidateRule is the single Rule-validation entry point. It runs from
// every source: yaml file load, Plugin.Restrict (once the Hook surface
// lands), and the policy CLI's validate subcommand. Catching invalid
// rules HERE rather than during evaluation prevents silent fail-open
// scenarios:
//
// - bad MaxRisk string ("readd") would skip the risk check entirely
// - malformed doublestar pattern ("docs/[abc") never matches, so a
// plugin that meant to allow "docs/*" silently allows nothing,
// and a deny list with the same typo silently denies nothing
//
// A typo in either field by a plugin author or admin must abort the load
// rather than continue with a degraded rule (hard-constraint #6 / #11
// safety contract).
//
// A nil rule is a no-op (treated as "no restriction" everywhere -- not an
// error).
func ValidateRule(r *platform.Rule) error {
if r == nil {
return nil
}
if r.MaxRisk != "" {
if !r.MaxRisk.IsValid() {
return fmt.Errorf("invalid max_risk %q: must be one of read|write|high-risk-write", r.MaxRisk)
}
}
for _, id := range r.Identities {
if !id.IsValid() {
return fmt.Errorf("invalid identities entry %q: must be 'user' or 'bot'", id)
}
}
for _, g := range r.Allow {
if err := validateGlob(g); err != nil {
return fmt.Errorf("invalid allow glob %q: %w", g, err)
}
}
for _, g := range r.Deny {
if err := validateGlob(g); err != nil {
return fmt.Errorf("invalid deny glob %q: %w", g, err)
}
}
return nil
}
// validateGlob rejects malformed doublestar patterns. doublestar.Match
// returns an error for unbalanced brackets / bad escape sequences; that
// error path is the canonical signal for "this pattern is not valid".
//
// We probe with an empty string -- the goal is to exercise the parser,
// not to compute a match.
func validateGlob(g string) error {
if g == "" {
return fmt.Errorf("empty pattern")
}
if _, err := doublestar.Match(g, ""); err != nil {
return err
}
return nil
}
+97
View File
@@ -0,0 +1,97 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmdpolicy_test
import (
"strings"
"testing"
"github.com/larksuite/cli/extension/platform"
"github.com/larksuite/cli/internal/cmdpolicy"
)
// nil rule is "no restriction" everywhere -- validation must agree.
func TestValidateRule_nilIsOk(t *testing.T) {
if err := cmdpolicy.ValidateRule(nil); err != nil {
t.Fatalf("nil rule should validate, got %v", err)
}
}
func TestValidateRule_validRule(t *testing.T) {
r := &platform.Rule{
Allow: []string{"docs/**", "contact/+search-*"},
Deny: []string{"docs/+delete-doc"},
MaxRisk: "write",
Identities: []platform.Identity{"user", "bot"},
}
if err := cmdpolicy.ValidateRule(r); err != nil {
t.Fatalf("valid rule rejected: %v", err)
}
}
// A typo in MaxRisk must abort the load; otherwise the engine would skip
// the risk check entirely and let high-risk-write commands pass under
// what the operator thought was a "read" cap.
func TestValidateRule_badMaxRisk(t *testing.T) {
cases := []string{"readd", "Read", "high_risk_write", "anything"}
for _, bad := range cases {
r := &platform.Rule{MaxRisk: platform.Risk(bad)}
err := cmdpolicy.ValidateRule(r)
if err == nil {
t.Errorf("ValidateRule should reject MaxRisk=%q", bad)
continue
}
if !strings.Contains(err.Error(), "max_risk") {
t.Errorf("error should mention max_risk for MaxRisk=%q, got %v", bad, err)
}
}
}
// Identities must come from the closed taxonomy {"user","bot"}. A typo
// like "users" would silently lock out everyone (no command intersects
// the typo), so it must abort.
func TestValidateRule_badIdentity(t *testing.T) {
r := &platform.Rule{Identities: []platform.Identity{"user", "admin"}}
err := cmdpolicy.ValidateRule(r)
if err == nil {
t.Fatalf("ValidateRule should reject identity 'admin'")
}
if !strings.Contains(err.Error(), "identities") {
t.Fatalf("error should mention identities, got %v", err)
}
}
// Malformed doublestar globs are silent fail-open if not caught here
// (doublestar.Match returns an error which matchesAny() ignores).
func TestValidateRule_malformedGlob(t *testing.T) {
cases := []struct {
name string
rule *platform.Rule
}{
{"bad allow", &platform.Rule{Allow: []string{"docs/[abc"}}},
{"bad deny", &platform.Rule{Deny: []string{"docs/[abc"}}},
{"empty allow entry", &platform.Rule{Allow: []string{"", "docs/**"}}},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
err := cmdpolicy.ValidateRule(c.rule)
if err == nil {
t.Fatalf("ValidateRule should reject %+v", c.rule)
}
})
}
}
// Empty MaxRisk and Empty Identities slices are both "no restriction" --
// not an error.
func TestValidateRule_emptyFieldsAreOk(t *testing.T) {
r := &platform.Rule{
Allow: []string{"docs/**"},
MaxRisk: "",
Identities: nil,
}
if err := cmdpolicy.ValidateRule(r); err != nil {
t.Fatalf("empty optional fields should validate, got %v", err)
}
}
+24
View File
@@ -0,0 +1,24 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package yaml
import "io"
// bytesReader avoids pulling in bytes.NewReader at the call site -- yaml.v3
// only needs an io.Reader. Plain wrapper, no allocation surprises.
type byteReader struct {
data []byte
pos int
}
func bytesReader(data []byte) io.Reader { return &byteReader{data: data} }
func (b *byteReader) Read(p []byte) (int, error) {
if b.pos >= len(b.data) {
return 0, io.EOF
}
n := copy(p, b.data[b.pos:])
b.pos += n
return n, nil
}
+135
View File
@@ -0,0 +1,135 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
// Package yaml parses one or more Rules from yaml bytes. It is kept
// separate from the public extension/platform package so that platform
// stays free of yaml library dependencies -- plugins constructing a Rule
// in Go code never import yaml, only the file loader does.
//
// This package does **structural** parsing only (yaml syntax + unknown-field
// rejection). Semantic validation (valid MaxRisk enum, valid identity
// values, valid doublestar glob syntax) is centralised in
// internal/cmdpolicy.ValidateRule so a single contract is enforced regardless
// of whether the Rule came from yaml or from Plugin.Restrict.
package yaml
import (
"errors"
"fmt"
"io"
gopkgyaml "gopkg.in/yaml.v3"
"github.com/larksuite/cli/extension/platform"
)
// ruleSchema is the internal yaml-tagged shape of one rule. Mirrors
// platform.Rule but lives here so the public Rule has no yaml tag baggage.
type ruleSchema struct {
Name string `yaml:"name"`
Description string `yaml:"description,omitempty"`
Allow []string `yaml:"allow,omitempty"`
Deny []string `yaml:"deny,omitempty"`
MaxRisk string `yaml:"max_risk,omitempty"`
Identities []string `yaml:"identities,omitempty"`
AllowUnannotated bool `yaml:"allow_unannotated,omitempty"`
}
// fileSchema is the top-level document shape. Two mutually-exclusive
// layouts are accepted:
//
// - a single rule written with flat top-level fields (the historical
// layout; the inlined ruleSchema), or
// - a "rules:" list of rule objects (multi-rule layout).
//
// Mixing the two (flat fields AND a rules: list in the same file) is a
// configuration error -- Parse rejects it rather than guessing intent.
//
// Rules is a pointer so Parse can tell "rules: key absent" (nil) apart
// from "rules: present but empty" (non-nil, len 0). The latter is a
// foot-gun -- a config generator that renders an empty list would
// otherwise yield a single all-zero Rule that lets every annotated
// command through -- so Parse rejects it outright.
type fileSchema struct {
ruleSchema `yaml:",inline"`
Rules *[]ruleSchema `yaml:"rules,omitempty"`
}
// isZero reports whether every field is its zero value. Used to detect
// the flat-fields-plus-rules: mixing error.
func (s ruleSchema) isZero() bool {
return s.Name == "" && s.Description == "" &&
len(s.Allow) == 0 && len(s.Deny) == 0 &&
s.MaxRisk == "" && len(s.Identities) == 0 && !s.AllowUnannotated
}
func (s ruleSchema) toRule() *platform.Rule {
// Leave Identities nil when absent (omitempty-style), matching how the
// Allow/Deny slices arrive nil from yaml. A zero-length but non-nil
// slice is behaviourally identical to the engine but trips
// reflect.DeepEqual in tests and reads as "explicitly empty".
var idents []platform.Identity
if len(s.Identities) > 0 {
idents = make([]platform.Identity, len(s.Identities))
for i, id := range s.Identities {
idents[i] = platform.Identity(id)
}
}
return &platform.Rule{
Name: s.Name,
Description: s.Description,
Allow: s.Allow,
Deny: s.Deny,
MaxRisk: platform.Risk(s.MaxRisk),
Identities: idents,
AllowUnannotated: s.AllowUnannotated,
}
}
// Parse decodes yaml bytes into one or more *platform.Rule. Unknown fields
// are rejected so an old binary cannot silently ignore new schema additions
// (forward-compat safeguard).
//
// The result always has at least one element: a flat-fields document
// yields a single rule (possibly an all-zero "no restriction" rule), and a
// "rules:" list yields one rule per entry.
//
// Semantic validation (MaxRisk taxonomy, identity values, glob syntax) is
// the caller's responsibility -- run each result through
// internal/cmdpolicy.ValidateRule before handing it to the engine.
func Parse(data []byte) ([]*platform.Rule, error) {
var s fileSchema
dec := gopkgyaml.NewDecoder(bytesReader(data))
dec.KnownFields(true)
if err := dec.Decode(&s); err != nil {
return nil, fmt.Errorf("parse policy yaml: %w", err)
}
// Reject multi-document input: yaml.v3 only decodes one document
// per call, so a stray "---" followed by another document would
// silently drop the trailing rule.
var extra fileSchema
if err := dec.Decode(&extra); !errors.Is(err, io.EOF) {
if err == nil {
return nil, fmt.Errorf("parse policy yaml: multiple YAML documents are not allowed")
}
return nil, fmt.Errorf("parse policy yaml: %w", err)
}
if s.Rules != nil {
if len(*s.Rules) == 0 {
return nil, fmt.Errorf("parse policy yaml: 'rules:' is present but empty; remove the key, or list at least one rule")
}
if !s.ruleSchema.isZero() {
return nil, fmt.Errorf("parse policy yaml: top-level rule fields cannot be combined with a 'rules:' list; move every rule under 'rules:'")
}
out := make([]*platform.Rule, 0, len(*s.Rules))
for _, rs := range *s.Rules {
out = append(out, rs.toRule())
}
return out, nil
}
// Backward-compatible single top-level rule (flat fields).
return []*platform.Rule{s.ruleSchema.toRule()}, nil
}
+182
View File
@@ -0,0 +1,182 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package yaml_test
import (
"reflect"
"testing"
"github.com/larksuite/cli/extension/platform"
pyaml "github.com/larksuite/cli/internal/cmdpolicy/yaml"
)
func TestParse_validRule(t *testing.T) {
data := []byte(`
name: agent-docs-readonly
description: only-read docs
allow:
- docs/**
- contact/**
deny:
- docs/+update
max_risk: read
identities:
- user
`)
rules, err := pyaml.Parse(data)
if err != nil {
t.Fatalf("Parse failed: %v", err)
}
want := &platform.Rule{
Name: "agent-docs-readonly",
Description: "only-read docs",
Allow: []string{"docs/**", "contact/**"},
Deny: []string{"docs/+update"},
MaxRisk: "read",
Identities: []platform.Identity{"user"},
}
// A flat top-level rule yields exactly one element (backward compat).
if !reflect.DeepEqual(rules, []*platform.Rule{want}) {
t.Fatalf("rules = %+v, want single %+v", rules, want)
}
}
// A "rules:" list yields one platform.Rule per entry, in order. This is
// the multi-rule layout: each rule is a scoped grant the engine
// OR-combines.
func TestParse_rulesList(t *testing.T) {
data := []byte(`
rules:
- name: docs-ro
allow: [docs/**]
max_risk: read
- name: im-rw
allow: [im/**]
max_risk: write
identities: [user, bot]
`)
rules, err := pyaml.Parse(data)
if err != nil {
t.Fatalf("Parse failed: %v", err)
}
want := []*platform.Rule{
{Name: "docs-ro", Allow: []string{"docs/**"}, MaxRisk: "read"},
{Name: "im-rw", Allow: []string{"im/**"}, MaxRisk: "write", Identities: []platform.Identity{"user", "bot"}},
}
if !reflect.DeepEqual(rules, want) {
t.Fatalf("rules = %+v, want %+v", rules, want)
}
}
// A "rules:" key that is present but empty is a foot-gun: an empty list
// would otherwise fall through to a single all-zero Rule that allows
// every annotated command ("looks like a policy, enforces almost
// nothing"). Parse must reject it outright instead.
func TestParse_rejectsEmptyRulesList(t *testing.T) {
if _, err := pyaml.Parse([]byte("rules: []\n")); err == nil {
t.Fatalf("Parse should reject a present-but-empty 'rules:' list")
}
}
// Mixing top-level flat rule fields with a rules: list is ambiguous and
// must be rejected rather than silently picking one.
func TestParse_rejectsFlatPlusRulesMix(t *testing.T) {
data := []byte(`
name: top-level
rules:
- name: nested
`)
if _, err := pyaml.Parse(data); err == nil {
t.Fatalf("Parse should reject mixing top-level fields with a rules: list")
}
}
// allow_unannotated is documented in the README / author guide as the
// gradual-adoption opt-in. The yaml schema must carry it through to
// platform.Rule, otherwise a user following the docs would either hit
// "unknown field" (under KnownFields strict mode) or silently lose the
// opt-in and end up with a safer-but-broken policy.
func TestParse_allowUnannotatedPassesThrough(t *testing.T) {
data := []byte(`
name: agent-readonly
max_risk: read
allow_unannotated: true
`)
rules, err := pyaml.Parse(data)
if err != nil {
t.Fatalf("Parse failed: %v", err)
}
if !rules[0].AllowUnannotated {
t.Fatalf("AllowUnannotated = false, want true (yaml field must propagate)")
}
if rules[0].MaxRisk != "read" || rules[0].Name != "agent-readonly" {
t.Errorf("other fields lost: %+v", rules[0])
}
}
// Default is false when the key is absent: pin the fail-closed default so
// future schema edits cannot accidentally flip it.
func TestParse_allowUnannotatedDefaultsFalse(t *testing.T) {
data := []byte(`
name: x
max_risk: read
`)
rules, err := pyaml.Parse(data)
if err != nil {
t.Fatalf("Parse failed: %v", err)
}
if rules[0].AllowUnannotated {
t.Fatalf("AllowUnannotated must default to false when key is absent")
}
}
// Unknown fields must be rejected so the old binary cannot silently ignore
// new schema additions (forward-compat safeguard).
func TestParse_rejectsUnknownFields(t *testing.T) {
data := []byte(`
name: x
mystery_field: oh no
`)
if _, err := pyaml.Parse(data); err == nil {
t.Fatalf("Parse should reject unknown yaml field 'mystery_field'")
}
}
// Semantic validation lives in cmdpolicy.ValidateRule. Parse only checks
// structural yaml; an invalid max_risk passes through (validation happens
// downstream).
func TestParse_doesNotValidateSemantics(t *testing.T) {
rules, err := pyaml.Parse([]byte("max_risk: nuclear\n"))
if err != nil {
t.Fatalf("structural parse should succeed, got %v", err)
}
if rules[0].MaxRisk != "nuclear" {
t.Fatalf("MaxRisk = %q, want passed through as-is", rules[0].MaxRisk)
}
}
// An entirely empty file is rejected: the resolver should fall back to
// "no rule" by skipping the file in the first place, not by feeding empty
// bytes through Parse.
func TestParse_emptyIsError(t *testing.T) {
if _, err := pyaml.Parse([]byte{}); err == nil {
t.Fatalf("Parse should reject empty input; the resolver handles 'no file' separately")
}
}
// A stray "---" separator followed by another document would silently
// drop the trailing rule if yaml.v3 stopped after the first Decode.
// Parse must reject multi-document input so the operator can't typo a
// separator and end up with an unintentionally empty policy.
func TestParse_rejectsMultipleDocuments(t *testing.T) {
data := []byte(`name: first
max_risk: read
---
name: second
max_risk: write
`)
if _, err := pyaml.Parse(data); err == nil {
t.Fatalf("Parse should reject multi-document YAML input")
}
}