mirror of
https://github.com/larksuite/cli.git
synced 2026-07-08 18:13:01 +08:00
- hook/install: propagate wrapper-injected ctx to invokeOriginal so RunE/Run see context values added by upstream Wrappers - hook/testing: SetStderrForTesting returns a restore func; tests now defer it via t.Cleanup to avoid cross-test sink leakage - cmdpolicy/active: deep-copy ActivePolicy.Rule on SetActive/GetActive so callers can't mutate the stored global through shared slices - platform/inventory: deep-copy Inventory + nested Plugins / HookEntry / RuleView slices on SetActiveInventory / GetActiveInventory - platform/staging: Restrict clones the plugin-supplied Rule before retaining it so the plugin can't mutate it after Install returns - platform/version: reject RequiredCLIVersion with more than three numeric components instead of silently truncating 1.2.3.4 to 1.2.3 - cmd/platform_bootstrap: clear cmdpolicy.SetActive on yaml resolver error so config policy show doesn't surface a stale rule - cmd/platform_bootstrap_test: tmpHome pins LARKSUITE_CLI_CONFIG_DIR so host env can't bleed into the policy test fixtures - cmdpolicy/apply: installDenyStub returns bool; Apply count no longer over-reports when strict-mode short-circuits the install - cmdpolicy/engine: aggregateParents now returns the runnable hybrid's own denial status when all children are placeholder branches - cmdpolicy/resolver_test: use t.TempDir()-rooted missing path instead of hardcoded /nonexistent for hermetic missing-file assertion - cmd/config/plugins: empty-inventory branch emits total: 0 so the JSON schema stays stable across populated/empty cases - cmd/platform_guards_test: select leaf by RunE != nil (not Runnable) so the test doesn't nil-deref on Run-only commands - gofmt run on previously committed cmdpolicy/path*.go (CI fast-gate)
385 lines
12 KiB
Go
385 lines
12 KiB
Go
// 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 command_denied envelopes of the same
|
|
// shape via BuildDenialError so external agents can dispatch on
|
|
// detail.layer / reason_code uniformly regardless of which layer rejected
|
|
// the call.
|
|
package cmdpolicy
|
|
|
|
import (
|
|
"fmt"
|
|
|
|
"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 Rule against the command tree. It is stateless except
|
|
// for the Rule snapshot it was constructed with.
|
|
type Engine struct {
|
|
rule *platform.Rule
|
|
}
|
|
|
|
// New returns an Engine bound to a Rule. A nil Rule means "no user-layer
|
|
// restriction" -- EvaluateOne always returns Allowed=true.
|
|
func New(rule *platform.Rule) *Engine {
|
|
return &Engine{rule: rule}
|
|
}
|
|
|
|
// 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.
|
|
func (e *Engine) EvaluateOne(cmd *cobra.Command) Decision {
|
|
if e.rule == nil {
|
|
return Decision{Allowed: true}
|
|
}
|
|
r := e.rule
|
|
path := CanonicalPath(cmd)
|
|
|
|
if IsDiagnosticPath(path) {
|
|
return Decision{Allowed: true}
|
|
}
|
|
|
|
// A registered Rule expresses intent over the closed risk taxonomy
|
|
// (read / write / high-risk-write). Two ways a command can fall
|
|
// outside that taxonomy:
|
|
//
|
|
// - "absent" (no risk_level annotation) — fail-closed by default,
|
|
// but Rule.AllowUnannotated=true opts out for gradual adoption.
|
|
// - "invalid" (annotation exists but is a typo / not in the
|
|
// closed enum) — always fail-closed regardless of
|
|
// AllowUnannotated. Typo is a code bug, not a migration phase.
|
|
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)),
|
|
}
|
|
}
|
|
} else if !r.AllowUnannotated {
|
|
return Decision{
|
|
Allowed: false,
|
|
ReasonCode: "risk_not_annotated",
|
|
Reason: "command has no risk_level annotation; required when a Rule is active (set rule.allow_unannotated=true to opt out during gradual adoption)",
|
|
}
|
|
}
|
|
|
|
// Axis 1: Deny has priority.
|
|
if matchesAny(r.Deny, path) {
|
|
return Decision{
|
|
Allowed: false,
|
|
ReasonCode: "command_denylisted",
|
|
Reason: "command denied by rule deny list",
|
|
}
|
|
}
|
|
|
|
// 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: "command path not in rule allow list",
|
|
}
|
|
}
|
|
|
|
// 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: "command risk exceeds rule max_risk",
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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: "command identities do not intersect rule identities",
|
|
}
|
|
}
|
|
}
|
|
|
|
return Decision{Allowed: true}
|
|
}
|
|
|
|
// 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 {
|
|
for _, g := range globs {
|
|
if ok, err := doublestar.Match(g, path); err == nil && ok {
|
|
return 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)
|
|
}
|
|
}
|