governance

package
v0.13.0 Latest Latest
Warning

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

Go to latest
Published: Sep 4, 2026 License: Apache-2.0 Imports: 5 Imported by: 0

Documentation

Overview

Package governance is the Governance bounded context: permission evaluation, hooks, and plan-mode gating. WP1 freezes only the value objects here (PermissionDecision, Scope, Rule, HookEvent, HookOutcome, the enums); the evaluation logic itself lands in WP4.

Allowed imports (ARCHITECTURE.md §3): the standard library only (and other domain packages). It MUST NOT import adapter, agent, api, os, the OpenAI SDK, or any third-party library. In particular it does not import session, so that session may import it without a cycle.

Index

Constants

View Source
const UntrustedFence = "<<<UNTRUSTED"

UntrustedFence is the delimiter wrapping an untrusted block in a model-visible prompt. Text BETWEEN a matching open/close pair is data (peer-, operator-, or tool-authored), never harness instructions. The marker is chosen to be unlikely in prose and is neutralised out of any enclosed body by NeutraliseFraming, so an injected body cannot forge its own open/close pair to break out of its block.

Variables

View Source
var ErrDelegationDepthExhausted = errors.New("governance: delegation depth exhausted")

ErrDelegationDepthExhausted reports an attempt to derive a child after all remaining delegation hops have been consumed.

Functions

func BashCommandFromArgs

func BashCommandFromArgs(args json.RawMessage) (string, bool)

BashCommandFromArgs extracts the command string from a Bash tool call's raw args JSON. It reads the "command" field, then "cmd" as a fallback, returning the first non-empty (after TrimSpace) string. It is FAIL-SAFE: a JSON parse error OR neither field carrying a non-empty string returns ("", false), so a caller that gates a safety decision on the result INSPECTS rather than skips (an unreadable args object must never be presumed read-only). This is the single shared extraction for the Bash tool-call args schema — the governance evaluator, the Subagent isolation gate, and the guardrail Bash pre-filter all call it, so a schema change lands in one place.

func Canonicalize

func Canonicalize(cmd string) string

Canonicalize strips leading transparent process wrappers from a single Bash command so that permission matching sees the real program being run. Only the closed strippableWrappers set is removed (`timeout`, `time`, `nice`, `env`, `stdbuf`, `ionice`), together with their leading option flags and any values those flags consume.

Re-entrant launchers are intentionally left intact: `docker exec foo rm x`, `npx ...`, `devbox run ...` and `sudo rm x` are returned unchanged, because stripping them would defeat the permission boundary.

Canonicalize operates on a SINGLE simple command; callers that may receive a compound line should SplitCommands first.

func FenceUntrusted

func FenceUntrusted(body string) string

FenceUntrusted is the string-returning form of WriteUntrustedBlock: it returns body wrapped in a matched UntrustedFence pair with the fence markers and framing headers neutralised out of body first. Use WriteUntrustedBlock when you already hold a strings.Builder; use this when a caller just wants the wrapped string (e.g. a tool interpolating untrusted external content — WebSearch results, LLM01). The bytes are identical to WriteUntrustedBlock's, so there is exactly one fence implementation.

func HasSubstitutionOrGrouping

func HasSubstitutionOrGrouping(seg string) bool

HasSubstitutionOrGrouping reports whether a Bash segment contains command or process substitution, or subshell/group-command grouping, in unquoted text: `$(...)`, a backtick, `<(...)`/`>(...)`, an opening "(" or "{" used as grouping. These constructs can hide an arbitrary inner command that the operator-based splitter does not decompose; the permission layer treats any such segment as fail-safe (not read-only, and escalated to at least Ask) so a denied/destructive inner command cannot be silently matched as one literal allowed token.

The scan is quote-aware: a "(" inside single or double quotes is literal data and does not count. A "$" immediately before "(" or "{" (command substitution or "${...}" expansion) is flagged conservatively.

func IsReadOnlyTool

func IsReadOnlyTool(tool string) bool

IsReadOnlyTool reports whether a non-Bash tool is unconditionally read-only. Exposed for callers (dispatch, plan-mode) that need the same classification.

func IsolationApprovable

func IsolationApprovable(cmd string) bool

IsolationApprovable reports whether a (possibly compound) Bash command line is safe to AUTO-APPROVE for an ISOLATED (forked-worktree / force-copy) subagent that would otherwise hit the permission Ask floor (A2). It is a strict superset of SubstitutionReadOnly: every command — each SplitCommands segment AND every recursively-extracted substitution inner — must be read-only OR a worktree-safe `go {test,build,vet,list}`, none may be a worktree-escape verb (git push/config/ remote/fetch/pull/clone), and substitution extraction must succeed. It fails safe (false) on any extraction ambiguity, any unrecognised/destructive command, or any escape verb — so a borderline command SURFACES to the human rather than auto-running in the sandbox. It NEVER mutates the read-only/plan-mode classifiers.

func MCPResourceCapability

func MCPResourceCapability(server string) string

MCPResourceCapability returns the reserved opaque capability for resources exposed by one named MCP server. It is a capability only, never a catalog tool.

func NeutraliseDelegationResult

func NeutraliseDelegationResult(s string) string

NeutraliseDelegationResult is NeutraliseFraming for the model-influenced text a harness-composed RESULT wraps its own markers around — a child's summary or last assistant line, a provider/transport error body, a schema-validation message. It is the ONE point that treatment is applied on the delegation-result paths (subagentErrorBody's two halves, renderSubagentResult's success/structured arms, a Parallel branch's summary), so a new terminal arm inherits it instead of having to remember it.

It differs from NeutraliseFraming in TWO ways, both of them about not destroying the deliverable it is protecting.

FIRST, it evaluates only the markers whose surface is a delegation RESULT (surfaceResult). A result is not a fenced prompt and never contains one: the ask-reviewer's "Policy:" / "Tool:" / "Requested command:" headers and the model-router's "Categories:" / "Category:" / "Task to classify:" headers cannot be forged in a place they are not emitted, so matching them here has zero protective value — and a real cost, because "Category: …" / "Recorded findings:" / "Findings from …" is exactly how a review or triage subagent writes a heading. Never use NeutraliseDelegationResult for a fenced prompt body; use WriteUntrustedBlock or FenceUntrusted there. Applying the whole list erased two lines from every finding in a structured deliverable, on the SUCCESS arm, silently (OWASP LLM09: a redacted-away finding is one the orchestrator provably cannot act on). The prompt-only markers lose nothing by being skipped here: every fenced prompt re-runs the FULL list over its body at the fence (WriteUntrustedBlock), which is where those headers exist.

SECOND, it adds the FLOOR whole-line redaction needs. A one-line body that IS a listed header is otherwise replaced in its entirety and the model reads "Subagent: [redacted-framing…]": exactly the opaque failure issue #319 exists to abolish, reintroduced by the fix for the forgery. So when neutralisation leaves NOTHING informative behind, the original is returned %q-QUOTED instead (with the fence marker still substituted out — the quoted form must not smuggle back the one thing NeutraliseFraming's non-line-oriented substitution removed). Quoting is the safe fallback rather than a second-best one: a Go-quoted string contains no line break at all (they come back as the two characters \ and n), so a line-oriented forgery is STRUCTURALLY impossible in it — no marker list has to be complete for the quoted form to be safe — and 100% of the diagnostic survives for the reader. The repo already uses %q for the same reason on an MCP-supplied tool name (provider/anthropic/request.go).

func NeutraliseFraming

func NeutraliseFraming(s string) string

NeutraliseFraming defangs the literal framing markers a model-visible prompt uses so an untrusted body cannot forge them: it strips the fence delimiter and the recognised section headers (e.g. "Team goal:", "Tool:", "message from ...") that would otherwise let a crafted body close its block early or fabricate a new "harness" section. Apply it to any trusted-but-model-influenced value (a team goal, a member name) and to every fenced body (via WriteUntrustedBlock).

Matching is substring for the fence and whole-line for the headers, where "line" and the line's text are both NORMALISED first (lineBreaks, then canonLine + TrimSpace + ToLower) so neither an exotic line terminator nor an invisible leading character can hide a header from the matcher. Normalise-then-match is the property every entry in framingHeader inherits — it is fixed once, here, not per marker.

A matched line is replaced WHOLE, not just its marker prefix: for the bracketed harness notes the marker is the small half and the imperative that follows it ("… discard them with `git checkout`") is the dangerous half, so keeping the payload would keep the attack. The cost of whole-line redaction — a genuine one-line diagnostic that happens to start with a header can be erased entirely — is bounded by NeutraliseDelegationResult's floor rather than by weakening the redaction.

func ReadOnlyBash

func ReadOnlyBash(cmd string) bool

ReadOnlyBash reports whether a Bash command line is read-only, i.e. safe to run under plan mode. It is a conservative heuristic: it returns true only when EVERY simple command in a (possibly compound) line is recognised read-only, and false the moment it sees output redirection, a destructive verb, or an unrecognised command. Wrappers are canonicalized away before classification.

Examples that are read-only: `ls`, `cat f`, `grep x f`, `git status`, `git log`, `git diff`. Examples that are NOT: anything containing `>`, `>>`, `rm`, `mv`, `mkdir`, `git commit`, or an unknown command.

func SplitCommands

func SplitCommands(cmd string) []string

SplitCommands splits a compound Bash command line into its individual simple commands, breaking on the shell operators "&&", "||", ";", "|", a single unquoted "&" (background), and newlines, while respecting single and double quotes (operators inside quotes are literal).

This is the compound-command awareness the permission layer relies on: a line such as `git status && rm -rf /` must be evaluated as TWO commands, so that a deny on `rm` blocks the whole compound even though `git status` would be allowed. The returned slice contains the trimmed segments with empty segments dropped; an empty or whitespace-only input yields nil.

Newlines and a single "&" are separators too: `ls\nrm -rf build` and `ls & rm -rf build` smuggle a second command past a gate that only knows the classic operators. Command/process substitution and subshell grouping (`$(...)`, backticks, `<(...)`, and `(`/`{` grouping) are NOT decomposed here; callers must treat any segment for which HasSubstitutionOrGrouping reports true as fail-safe (not read-only; escalate to at least Ask), since the inner command cannot be soundly extracted without a full shell parser.

func SubstitutionReadOnly

func SubstitutionReadOnly(seg string) bool

SubstitutionReadOnly reports whether a single Bash SEGMENT that contains command/ process substitution or subshell grouping is nonetheless safe to treat as read-only: every extracted inner command is ReadOnlyBash-true AND the outer command (with each substitution blanked to an inert placeholder) is simpleReadOnly-true. It is the SEPARATE read-only-aware classifier (A1) the evaluator consults to AVOID flooring a fully-read-only substitution (e.g. `cat $(ls)`, `echo $(git rev-parse HEAD)`) at Ask.

It fails safe (false) on: a segment with NO substitution (use simpleReadOnly directly — there is nothing for this classifier to do), any extraction ambiguity (extractSubstitutions ok=false / the blank ok=false), any inner command that is not read-only, or an outer that is not read-only once blanked. It NEVER widens ReadOnlyBash/simpleReadOnly/plan-mode — those stay byte-for-byte unchanged; this is an ADDITIONAL allow path, consulted only where the substitution floor would otherwise apply.

func WriteUntrustedBlock

func WriteUntrustedBlock(b *strings.Builder, body string)

WriteUntrustedBlock writes body to b wrapped in a matched UntrustedFence pair, with the fence markers and framing headers neutralised out of body first so it cannot forge its own closing fence (or a fresh harness section) to escape the block. Use it for any untrusted data interpolated into a model-visible prompt.

Types

type Audience

type Audience int

Audience scopes a Rule to the engine class it binds: the MAIN (interactive) engine, SUBAGENT (child/member/branch) engines, or both. The zero value (AudienceAll) applies everywhere, so an untagged rule keeps its full reach (the back-compatible default).

Matching is symmetric-permissive: a rule binds an Evaluator iff either side is AudienceAll or both name the same audience. An Evaluator's audience is set with WithAudience (default AudienceAll). The conventional tagging — applied by callers that load rules for both engine classes — is: rules that should reach only the main engine carry AudienceMain, child-scoped rules carry AudienceSubagent, and a deny carries AudienceAll so it binds both (a deny only ever tightens).

const (
	// AudienceAll (the zero value) applies to every engine class.
	AudienceAll Audience = iota
	// AudienceMain applies only to the main (interactive) engine's evaluator.
	AudienceMain
	// AudienceSubagent applies only to child engines (Subagent children, team
	// members, parallel branches).
	AudienceSubagent
)

type CapabilitySet

type CapabilitySet struct {
	Tools                    []string
	RemainingDelegationDepth int
	FileSystem               bool
	DirectWrite              bool
}

CapabilitySet is the authority carried by a run. It is a plain value: tool names are exact capabilities, RemainingDelegationDepth is the number of child derivations still available, and the posture flags constrain execution.

The zero value is a valid empty set. A zero remaining depth means no further delegation is allowed, while it remains a valid requirement in Contains.

func ConsumeDelegationHop

func ConsumeDelegationHop(set CapabilitySet) (CapabilitySet, error)

ConsumeDelegationHop derives the child set after consuming one remaining delegation hop. It rejects exhaustion instead of silently clamping depth.

func Narrow

func Narrow(left, right CapabilitySet) CapabilitySet

Narrow returns the capability set allowed by both inputs. It only removes tools, lowers depth, and turns posture flags off; it never widens authority.

func (CapabilitySet) AllowsTool

func (set CapabilitySet) AllowsTool(name string) bool

AllowsTool reports whether name is an exact member of the capability set.

func (CapabilitySet) Contains

func (set CapabilitySet) Contains(other CapabilitySet) bool

Contains reports whether set includes every capability required by other.

type Effect

type Effect string

Effect is the outcome of a permission evaluation. The harness resolves a tool call across merged scopes with deny → ask → allow precedence: any deny wins, otherwise any ask wins, otherwise allow.

const (
	// Deny blocks the tool call; the Reason teaches the model why.
	Deny Effect = "deny"
	// Ask pauses the loop for client approval.
	Ask Effect = "ask"
	// Allow permits the tool call to execute.
	Allow Effect = "allow"
)

type Evaluator

type Evaluator struct {
	// contains filtered or unexported fields
}

Evaluator resolves a tool call against a merged set of permission Rules using deny → ask → allow precedence across Scopes. It is session-free by design so that package governance can stay free of a session import (doc.go): callers in the adapter/agent layer translate session types into the primitive arguments Evaluate accepts.

Resolution rules (doc 08 §10):

  • A Deny in ANY scope beats an Allow or Ask in ANY scope.
  • Otherwise an Ask in any scope beats an Allow.
  • Among rules of the SAME effect, the highest-precedence Scope wins (its Reason is reported).
  • With no matching rule the result is Ask (the safe default: pause for the client) — the harness never silently allows an unconfigured call.

func NewEvaluator

func NewEvaluator(rules []Rule, opts ...EvaluatorOption) *Evaluator

NewEvaluator constructs an Evaluator over the given merged rules. The rules may come from any mix of Scopes; precedence is resolved at evaluation time. The slice is copied so later mutation by the caller cannot affect the policy.

func (*Evaluator) Evaluate

func (e *Evaluator) Evaluate(tool string, args json.RawMessage, planMode bool) PermissionDecision

Evaluate resolves the decision for a tool call. tool is the tool name, args is its raw JSON argument payload (used for Bash compound-command splitting and pattern matching), and planMode forces deny for mutating actions (plan mode). It evaluates against the Evaluator's static rules only (no learned extras).

func (*Evaluator) EvaluateWith

func (e *Evaluator) EvaluateWith(tool string, args json.RawMessage, planMode bool, extra []Rule) PermissionDecision

EvaluateWith resolves the decision for a tool call against the Evaluator's static rules PLUS the caller-supplied extra rules (e.g. per-session LEARNED allows). The ordering is security-load-bearing:

  1. The plan-mode gate runs FIRST, BEFORE any learned rule is consulted — so a learned allow can NEVER bypass plan mode's hard-deny of a mutating action.
  2. Only then does the deny → ask → allow rule engine run over the static rules with extra APPENDED. Because resolution is deny-dominant across the WHOLE merged set, a static (or any) Deny still beats a learned Allow, and a static Ask still beats a learned Allow — the extras only ever ADD allows at the lowest scope; they can never weaken a deny/ask that already matches.

The static rules are never mutated: a fresh slice (static followed by extra) is resolved per call, preserving the Evaluator's immutability.

func (*Evaluator) LearnableRule

func (*Evaluator) LearnableRule(tool string, args json.RawMessage) (Rule, bool)

LearnableRule derives the per-session allow rule the Evaluator WOULD learn for a (tool, args) pair the user chose to "allow always", reusing the SAME pattern derivation the evaluator matches against — so a learned rule matches exactly the call it was learned from, no broader.

It returns (rule, false) — DO NOT learn — in any case where learning would be unsafe or untargetable:

  • Bash whose command splits into != 1 segment (a compound `a && b` / `a; b`), OR contains command/process substitution or subshell grouping ($(...), backticks, (...)), OR is empty. Learning a single literal from a compound or substituted line could green-light a hidden destructive command, so we refuse.
  • Any tool whose derived pattern is EMPTY (no targetable path/command field): an empty pattern would match the tool TOOL-WIDE, which is broader than the conservative tool+exact-pattern grant this feature promises. We refuse rather than learn a tool-wide allow.

When learnable, the returned Rule is {Tool, Pattern, Effect: Allow, Exact: true, Scope: <lowest precedence>} — Exact so it matches literally (never via glob), and lowest scope so it can never out-rank a configured rule of any effect.

type EvaluatorOption

type EvaluatorOption func(*Evaluator)

EvaluatorOption configures an Evaluator at construction.

func WithAudience

func WithAudience(a Audience) EvaluatorOption

WithAudience pins the engine class this Evaluator resolves for: AudienceMain for a main-engine policy, AudienceSubagent for a child/member policy. The DEFAULT (no option) is AudienceAll, which matches every rule — the back-compatible behaviour. An audience-tagged rule binds iff the rule's audience is AudienceAll, the Evaluator's is AudienceAll, or they match.

func WithLooseSubstitution

func WithLooseSubstitution(loose bool) EvaluatorOption

WithLooseSubstitution loosens the built-in substitution Ask floor (the --yolo posture): a substitution segment that is not classifiable as read-only is resolved by the ordinary rule fold (so an allow-all rule allows it) rather than floored at Ask. A configured Deny/Ask in any scope still wins. DEFAULT (no option) keeps the floor — the substitution still prompts.

type HookEvent

type HookEvent struct {
	// Phase is the lifecycle point this event fires at.
	Phase HookPhase
	// Tool is the tool name for tool-use phases (empty otherwise).
	Tool string
	// Input is the phase-specific payload (e.g. the tool-call arguments).
	Input json.RawMessage
	// SessionID is the session this event belongs to.
	SessionID string
	// CallID is the originating tool-call id for the tool-use phases (PreToolUse /
	// PostToolUse); empty for the non-tool phases. It lets a hook correlate a finding
	// back to the exact tool call on the conversation stream.
	CallID string
}

HookEvent is the payload delivered to a hook. It is provider-neutral and JSON-serialized to the hook process's stdin by the HookRunner adapter.

type HookOutcome

type HookOutcome struct {
	// Block reports whether the hook vetoed the action (exit code 2).
	Block bool
	// Message is the hook's explanation, surfaced to the model/client.
	Message string
	// Mutated, when non-empty, replaces the action's input payload, interpreted
	// symmetrically with the phase's HookEvent.Input. The loop applies it for:
	//   - UserPromptSubmit: rewrites the effective prompt ({"prompt": ...}) before
	//     the message is recorded and sent to the model;
	//   - PreToolUse: rewrites the tool call's arguments JSON before execution,
	//     preserving the CallID and tool Name;
	//   - PostToolUse: rewrites the tool result ({"content", "is_error"}) before it
	//     is emitted and recorded, preserving the CallID (redact/transform output).
	// A malformed (non-JSON) payload is ignored by the loop (the original payload
	// stands). NOTE for PreToolUse: the permission policy has already been
	// evaluated on the ORIGINAL, pre-mutation args; the mutated args are NOT
	// re-permission-checked, reflecting that a hook is more trusted than the model.
	// NOTE for PostToolUse: the loop emits the EFFECTIVE (rewritten) result, so the
	// client stream and the model's recorded history agree — no hidden divergence.
	Mutated json.RawMessage
	// AskApproval REFINES a Block into an ASKABLE block: it is meaningful ONLY on a
	// PreToolUse outcome with Block == true. When set, an INTERACTIVE engine
	// (Deps.Interactive) surfaces the block to the human as an ordinary permission
	// ask (PauseForApproval → StateAwaiting → EvPermissionAsk → Approve), reusing the
	// EXISTING approval machinery instead of dead-ending the call: an allow runs the
	// tool, a deny refuses it. A NON-interactive (headless) engine IGNORES this bit
	// and the Block stands — the byte-identical, fail-safe terminal block. It is also
	// ignored on PostToolUse (where Block is inert) and whenever Block is false.
	// Mutated is ignored when AskApproval is set (an askable block does not also
	// rewrite args). A hook that does not understand this field leaves it false, which
	// is exactly the pre-feature behaviour. An INERT AskApproval — set with Block ==
	// false, or on any non-PreToolUse phase — is a SILENT NO-OP that fails OPEN to the
	// ordinary outcome (a plain allow / the phase's normal handling), NEVER to a block:
	// AskApproval only REFINES an existing PreToolUse Block, it never creates one.
	// (ADR 0062.)
	AskApproval bool
}

HookOutcome is the result of running a hook. The exec adapter maps process exit code 0 to allow and exit code 2 to block (Block == true).

type HookPhase

type HookPhase string

HookPhase identifies the lifecycle point at which a hook fires. The run-level trio (SessionStart, UserPromptSubmit, Stop) fires from engine/agent/hooks.go, the per-tool pair (PreToolUse, PostToolUse) from engine/agent/dispatch.go, and SubagentStop from the Subagent tool. The agent-team trio (TeammateIdle, TaskCreated, TaskCompleted) fires from the team supervisor and coordination tools (engine/agent/teamsupervisor.go, teamtools.go).

const (
	// PhaseSessionStart fires once when a session begins.
	PhaseSessionStart HookPhase = "SessionStart"
	// PhaseUserPromptSubmit fires when the user submits a prompt.
	PhaseUserPromptSubmit HookPhase = "UserPromptSubmit"
	// PhasePreToolUse fires before a tool executes; a block aborts the call.
	PhasePreToolUse HookPhase = "PreToolUse"
	// PhasePostToolUse fires after a tool executes.
	PhasePostToolUse HookPhase = "PostToolUse"
	// PhaseStop fires when the main loop stops.
	PhaseStop HookPhase = "Stop"
	// PhaseSubagentStop fires when a subagent loop stops.
	PhaseSubagentStop HookPhase = "SubagentStop"
	// PhaseTeammateIdle fires when an agent-team member goes idle after a turn
	// (best-effort notification; the team lead can use it to detect quiescence).
	PhaseTeammateIdle HookPhase = "TeammateIdle"
	// PhaseTaskCreated fires before a team task is created; a Block vetoes the
	// creation (a quality gate on what work is allowed onto the shared list).
	PhaseTaskCreated HookPhase = "TaskCreated"
	// PhaseTaskCompleted fires before a team task is marked complete; a Block
	// vetoes the completion (a quality gate, e.g. "tests must pass first").
	PhaseTaskCompleted HookPhase = "TaskCompleted"
)

type PermissionDecision

type PermissionDecision struct {
	// Effect is the resolved effect.
	Effect Effect
	// Reason explains the decision; especially important on Deny and Ask.
	Reason string
	// The two ask-provenance bits below are a PAIR of mutually exclusive bools,
	// not an enum, by deliberate choice: a THIRD ask-provenance signal would be
	// the point to extract a single enum carried on both this type and on the
	// session's pending-ask value object — until then, two bools with the
	// documented exclusivity invariant are simpler than an enum nothing switches
	// over.
	//
	// ConfiguredAsk reports that the winning Ask came from a CONFIGURED rule — a
	// rule whose Scope sits ABOVE ScopeBuiltinDefault (operator/project/user
	// intent) — as opposed to the built-in floor, the no-matching-rule default
	// Ask, or the substitution-floor escalation (all false). It lets an
	// approval layer enforce "never auto-approve a deliberately-configured Ask":
	// a consumer that auto-approves some asks (e.g. an isolated sub-agent
	// auto-clearing safe commands) should NOT auto-approve one with this bit set.
	// Mutually exclusive with FlooredConfiguredAllow.
	ConfiguredAsk bool
	// FlooredConfiguredAllow reports that the decision is an Ask ONLY because of
	// the built-in substitution floor (the Evaluator escalates a Bash segment
	// containing command/process substitution or subshell grouping to Ask). It
	// is set when, on a (possibly compound) Bash line: the floor-free fold is
	// Allow; at least one segment was floor-escalated DESPITE a configured
	// (above-floor) Allow matching it; AND that segment is provably safe to
	// auto-approve under the floor — the configured Allow vouches for the OUTER
	// command, every command hidden inside the substitution independently
	// classifies read-only (the Allow can never vouch for a hidden command), and
	// the blanked outer carries no construction that reaches outside an isolated
	// worktree. It lets an approval layer relax the substitution floor for a
	// command its operator already allowed, without trusting whatever a
	// substitution hides. Mutually exclusive with ConfiguredAsk.
	FlooredConfiguredAllow bool
}

PermissionDecision is the immutable result of evaluating a tool call across scopes. Reason is surfaced to the model on a deny so it can adapt (and to the client on an ask).

type Rule

type Rule struct {
	// Scope is the configuration layer this rule originates from.
	Scope Scope
	// Tool is the tool name this rule applies to (empty matches any tool).
	Tool string
	// Pattern is the matcher against the tool's arguments (tool-specific
	// syntax, e.g. a Bash command glob); empty matches any arguments.
	Pattern string
	// Effect is the effect this rule yields on a match.
	Effect Effect
	// Exact, when true, requires Pattern to match the (canonicalized) argument
	// string LITERALLY — never via glob expansion. It is the safety floor for a
	// LEARNED allow (LearnableRule sets it): a learned allow for `git status`
	// must match only `git status`, never let a stray `*`/`?` in the learned text
	// widen into a glob that green-lights commands the user never approved
	// (glob-escalation). Static config rules leave it false and keep glob matching.
	Exact bool
	// Audience scopes the rule to an engine class (main vs subagent). The zero
	// value (AudienceAll) matches every Evaluator, so an untagged rule keeps its
	// full reach. See Audience.
	Audience Audience
}

Rule is a single permission rule: a pattern matched against a tool call, the effect it yields, and the scope it came from. Evaluation (WP4) merges rules across scopes honouring Scope precedence and deny→ask→allow.

type Scope

type Scope int

Scope identifies the configuration layer a permission rule originates from. Higher-precedence scopes override lower ones when rules are merged. The ordering (highest first) is: Managed > CLI > LocalProject > SharedProject > User > BuiltinDefault, so a smaller Scope value has higher precedence.

const (
	// ScopeManaged is enterprise/managed policy; highest precedence.
	ScopeManaged Scope = iota
	// ScopeCLI is policy supplied on the command line / at invocation.
	ScopeCLI
	// ScopeLocalProject is the developer's local, un-shared project settings.
	ScopeLocalProject
	// ScopeSharedProject is checked-in, shared project settings.
	ScopeSharedProject
	// ScopeUser is the user's global settings.
	ScopeUser
	// ScopeBuiltinDefault is the harness's built-in default ruleset (the
	// read-allow / mutate-ask floor). It is the LOWEST precedence (largest iota
	// value), BELOW ScopeUser, so any configured rule of the same effect from a
	// higher scope wins the same-effect tie. Crucially, because it sits below
	// every config scope, a higher-scope Allow can LOOSEN a built-in Ask (the
	// merged deny→ask→allow fold still applies: a deny/ask in ANY scope beats an
	// allow, but among same-effect matches the higher scope is reported). It is
	// added at the TAIL of the iota so the existing scope values stay stable.
	ScopeBuiltinDefault
)

func (Scope) HasHigherPrecedenceThan

func (s Scope) HasHigherPrecedenceThan(other Scope) bool

HasHigherPrecedenceThan reports whether s overrides other when rules conflict.

Jump to

Keyboard shortcuts

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