cmdpolicy

package
v1.0.33 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: May 18, 2026 License: MIT Imports: 13 Imported by: 0

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

View Source
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.

View Source
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

View Source
var ErrMultipleRestricts = errors.New("multiple plugins called Restrict; only one is permitted")

Functions

func Apply

func Apply(root *cobra.Command, deniedByPath map[string]Denial) int

Apply walks the command tree and installs denyStubs for every path in deniedByPath whose Denial.Layer == "policy". It is the user-layer counterpart to applyStrictModeDenials in cmd/prune.go; both consume the same deniedByPath map produced by the bootstrap pipeline, neither re-evaluates rules.

Three things must happen for every denied command (hard-constraints 1-4 in the tech doc):

  1. cmd.Hidden = true -- removes from help / completion
  2. cmd.DisableFlagParsing = true -- denial-wins invariant; otherwise cobra would intercept the call with "missing required flag" before we can return our error
  3. cmd.RunE = denyStub(denial) -- returns *output.ExitError so cmd/root.go's envelope writer emits structured JSON (with error.type = denial.Layer and detail.reason_code = ReasonCode); the wrapped error chain still exposes *platform.CommandDeniedError via errors.As for in-process consumers

Apply must be called once during the Bootstrap pipeline BEFORE cobra.Execute. It mutates the command tree in place and is not safe to call concurrently with command dispatch. Returns the number of commands modified.

func BuildDenialError

func BuildDenialError(path string, d Denial) *output.ExitError

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

func CanonicalPath(cmd *cobra.Command) string

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

func IsDiagnosticPath(path string) bool

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

func IsPureGroup(cmd *cobra.Command) bool

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

func LoadYAMLPolicy(path string) (*platform.Rule, error)

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

func ValidateRule(r *platform.Rule) error

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

type ChildDenial struct {
	Path   string
	Denial Denial
}

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

func New(rule *platform.Rule) *Engine

New returns an Engine bound to a Rule. A nil Rule means "no user-layer restriction" -- EvaluateOne always returns Allowed=true.

func (*Engine) EvaluateAll

func (e *Engine) EvaluateAll(root *cobra.Command) map[string]Decision

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 (*Engine) EvaluateOne

func (e *Engine) EvaluateOne(cmd *cobra.Command) Decision

EvaluateOne returns the user-layer decision for a single command. Always Allowed=true when the engine has no Rule.

type PluginRule

type PluginRule struct {
	PluginName string
	Rule       *platform.Rule
}

type ResolveSource

type ResolveSource struct {
	Kind SourceKind
	Name string
}

func Resolve

func Resolve(s Sources) (*platform.Rule, ResolveSource, error)

Resolve picks by precedence: plugin > yaml > none. Pure function; load yaml via LoadYAMLPolicy first. Winner is validated.

type SourceKind

type SourceKind string
const (
	SourcePlugin SourceKind = "plugin"
	SourceYAML   SourceKind = "yaml"
	SourceNone   SourceKind = "none"
)

type Sources

type Sources struct {
	PluginRules []PluginRule
	YAMLRule    *platform.Rule
	YAMLPath    string
}

Directories

Path Synopsis
Package yaml parses a Rule from yaml bytes.
Package yaml parses a Rule from yaml bytes.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL