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
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:
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
@@ -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])
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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]
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user