Documentation
¶
Overview ¶
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.
Index ¶
- Constants
- Variables
- func Apply(root *cobra.Command, deniedByPath map[string]Denial) int
- func BuildDenialError(path string, d Denial) *output.ExitError
- func BuildDeniedByPath(root *cobra.Command, decisions map[string]Decision, source ResolveSource, ...) map[string]Denial
- func CanonicalPath(cmd *cobra.Command) string
- func CommandDeniedFromDenial(path string, d Denial) *platform.CommandDeniedError
- func DenialDetailMap(cd *platform.CommandDeniedError) map[string]any
- func IsDiagnosticPath(path string) bool
- func IsPureGroup(cmd *cobra.Command) bool
- func LoadYAMLPolicy(path string) (*platform.Rule, error)
- func ResetActiveForTesting()
- func SetActive(p *ActivePolicy)
- func SortChildren(children []ChildDenial)
- func ValidateRule(r *platform.Rule) error
- type ActivePolicy
- type ChildDenial
- type Decision
- type Denial
- type Engine
- type PluginRule
- type ResolveSource
- type SourceKind
- type Sources
Constants ¶
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" )
AnnotationDenialLayer / AnnotationDenialSource carry the denial signal to internal/hook through cobra annotations, avoiding an import cycle between hook and cmdpolicy.
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" )
Layer values match CommandDeniedError.Layer and the detail.layer field of the JSON envelope (under error.type = "command_denied").
Variables ¶
var ErrMultipleRestricts = errors.New("multiple plugins called Restrict; only one is permitted")
Functions ¶
func Apply ¶
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):
- cmd.Hidden = true -- removes from help / completion
- cmd.DisableFlagParsing = true -- denial-wins invariant; otherwise cobra would intercept the call with "missing required flag" before we can return our error
- cmd.RunE = denyStub(denial) -- returns *output.ExitError so cmd/root.go's envelope writer emits structured JSON (with error.type = denial.Layer and detail.reason_code = ReasonCode); the wrapped error chain still exposes *platform.CommandDeniedError via errors.As for in-process consumers
Apply must be called once during the Bootstrap pipeline BEFORE cobra.Execute. It mutates the command tree in place and is not safe to call concurrently with command dispatch. Returns the number of commands modified.
func BuildDenialError ¶
BuildDenialError is the default envelope for user-layer denials: Message comes from CommandDeniedError.Error(), no Hint. Callers that need a custom Message or an independent Hint (strict-mode) should compose CommandDeniedFromDenial + DenialDetailMap themselves.
func BuildDeniedByPath ¶
func BuildDeniedByPath(root *cobra.Command, decisions map[string]Decision, source ResolveSource, ruleName string) map[string]Denial
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 CanonicalPath ¶
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 CommandDeniedFromDenial ¶
func CommandDeniedFromDenial(path string, d Denial) *platform.CommandDeniedError
CommandDeniedFromDenial materialises the wrapped error type carried on ExitError.Err so errors.As works for in-process consumers.
func DenialDetailMap ¶
func DenialDetailMap(cd *platform.CommandDeniedError) map[string]any
DenialDetailMap is the canonical detail.* shape every `command_denied` envelope shares (see docs/extension/reason-codes.md). Use it as ErrDetail.Detail when constructing an envelope outside BuildDenialError.
func IsDiagnosticPath ¶
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 IsPureGroup ¶
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 LoadYAMLPolicy ¶
LoadYAMLPolicy returns (nil, nil) when path is empty or file is absent, so callers can pass the result straight into Sources.YAMLRule.
func ResetActiveForTesting ¶
func ResetActiveForTesting()
ResetActiveForTesting clears the recorded policy. Tests must call this in t.Cleanup when they exercise the bootstrap path.
func SetActive ¶
func SetActive(p *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 SortChildren ¶
func SortChildren(children []ChildDenial)
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 ValidateRule ¶
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).
Types ¶
type ActivePolicy ¶
type ActivePolicy struct {
Rule *platform.Rule
Source ResolveSource
DeniedPaths int // number of commands the engine marked as denied (post-aggregation)
}
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.
func GetActive ¶
func GetActive() *ActivePolicy
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.
type ChildDenial ¶
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 Decision ¶
type Decision struct {
Allowed bool
ReasonCode string // "" when Allowed=true
Reason string // human-readable
}
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 Denial ¶
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
}
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).
func AggregateChildren ¶
func AggregateChildren(children []ChildDenial) 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".
type Engine ¶
type Engine struct {
// contains filtered or unexported fields
}
Engine evaluates a Rule against the command tree. It is stateless except for the Rule snapshot it was constructed with.
func New ¶
New returns an Engine bound to a Rule. A nil Rule means "no user-layer restriction" -- EvaluateOne always returns Allowed=true.
func (*Engine) EvaluateAll ¶
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.
type PluginRule ¶
type ResolveSource ¶
type ResolveSource struct {
Kind SourceKind
Name string
}
type SourceKind ¶
type SourceKind string
const ( SourcePlugin SourceKind = "plugin" SourceYAML SourceKind = "yaml" SourceNone SourceKind = "none" )