Skip to main content

Module permissions

Module permissions 

Source
Expand description

P5-1 (COMPOSABLE-HARNESS-DESIGN.md §2 modules 10-11, §2.1 D-3, §2.2 C5, §5.3 risk 1): the permissions engine — command canonicalization + rule algebra + approval policy/cache + oc/cx import translators.

Risk 1 (§5.3): “a wrong translation is a SILENT privilege escalation.” This module is built to that standard: ONE engine, ONE evaluation order (deny→ask→allow first-match, rules); a tree-sitter-based canonicalizer that fails CLOSED on anything it can’t parse cleanly (canon); an import translator that WARNS (never silently) when a translated rule’s fixed point differs from its source (translate); and a golden-vector + adversarial-bypass test suite (crates/harness/tests/permissions_engine.rs) pinning the documented cc§4/oc§4 semantics as a regression bar.

Integration. The engine activates via capabilities.permissions.enabled (crate::Config::permissions_enabled, populated by crate::configfile::materialize_config) and is consumed at crate::agent::Agent’s tool-dispatch gate (Agent::prepare_tool_call). DEFAULT: disabled — the gate falls through to the pre-P5-1 crate::Config::needs_approval path byte-for-byte, so every existing test and today’s default posture (approval=never, sandbox=none) is unchanged.

protected_paths coverage, honestly stated (F4, Fable-5 adversarial review; corrected by the F6/F7 delta review, the round-3 delta review, then the round-4 delta review below). capabilities.permissions.protected_paths is a RULE-LAYER floor, not an OS-level one: it is enforced for file-tool calls (read_file/ write_file/edit_file), for a bash command’s direct shell redirect targets and a best-effort set of known argv-writers (tee, dd of=, cp/mv/install/ln — including their -t DIR/ --target-directory=DIR form (F6) and its getopt-bundled short-flag equivalent -ft DIR/-Dt DIR/… (round-3) — sed -i, truncate, sort -o FILE, split’s PREFIX (round-3 hardening) — see canon::known_writer_targets’s doc comment for that heuristic’s named gaps), and for apply_patch’s target path(s). A write this layer RECOGNIZES but can’t statically resolve to a concrete destination — an opaque wrapper, a $VAR/`cmd` dynamic target, an unquoted glob metacharacter (*/?/[) that bash would pathname-expand before the write (F7), or a known argv-writer flag shape [canon:: known_writer_targets] can’t confidently resolve to a destination token (F6, including its round-3 bundled-short-flag extension, and — round-4 — the SAME bundled-short-flag extension now also covering sed -i and sort -o) — is forced to at least Ask, NEVER silently Allow. This is now true without exception for every write shape this rule layer claims to cover (the F6/F7 delta review found and closed two forms where it wasn’t; the round-3 delta review found and closed a third: F6’s own fix didn’t yet recognize a getopt-BUNDLED short-flag -t, e.g. cp -ft .git a; the round-4 delta review found and closed a fourth: round-3’s bundled-short-flag fix was only made -t-specific, leaving the identical blind spot open on sed -i/sort -osed -ni s/../PWNED/ .env and sort -uo .env a both still fell through to a silent Allow). Round-4 closes this as a CLASS, not a third patched instance: every flag-driven known-writer detection (-t for cp/mv/install/ln, -i for sed, -o for sort) now routes through one shared, generalized bundled-short-flag scan (canon::known_writer_targets’s doc comment names it) — a future flag-driven writer inherits the fix by construction instead of needing its own bundled-flag audit.

What “claims to cover” does NOT mean, stated plainly (round-3 hardening; then STOP enumerating). sort -o/split (round-3) are new ADDITIONS to the enumerated-writer set, not a fix to a row already claimed covered — before that change, a write via sort -o/split simply wasn’t recognized AT ALL, i.e. a false-Allow gap of exactly the same shape every OTHER un-enumerated writer still is today: any interpreter’s own file-write builtins, a compiler’s -o, a database client’s export command, or any other bash construct this rule layer doesn’t specifically parse. This module does not, and does not claim to, enumerate every file-writing command that could ever appear in a bash tool call — doing so is an unbounded, always-incomplete list. What IS true, and load-bearing, is the narrower claim above: for every write SHAPE this rule layer does recognize (the enumerated writers, shell redirects, apply_patch), fail-closed holds without exception — an unresolvable target never silently resolves to Allow. An un-enumerated writer is a false NEGATIVE at this rule layer (nothing flagged, default policy decides), honestly named here rather than silently claimed covered — complete OS-level write confinement of arbitrary bash, which closes that gap entirely regardless of which command wrote the file, is capabilities.permissions.sandbox’s job (P5 module 10, a later unit), not this rule-layer heuristic’s. See crate::Config::permissions_protected_paths’s doc comment for the same note where the config field is defined.

Modules§

approval
P5-1 (COMPOSABLE-HARNESS-DESIGN.md §2 module 10, §2.10): approval policy plumbing — the POLICY + session-scoped CACHE + a non-interactive decision path. The INTERACTIVE ask-UI itself is the tui module (P5 row 4, not this unit) — PermissionsApprovalHandler is the seam a CLI/TUI/SDK embedder implements to plug an interactive (or scripted/headless) prompt into crate::agent::Agent’s tool-dispatch gate, mirroring the existing crate::reduce::summarize::SpanSummarizer/crate::session_title::SessionTitler “installing one alone changes nothing, the Config gate is what turns it on” pattern (Agent::set_span_summarizer/Agent::set_session_titler).
canon
P5-1 (COMPOSABLE-HARNESS-DESIGN.md §5.3 risk 1, D-3): the command canonicalizer — the security-critical parser rule-matching depends on.
rules
P5-1 (COMPOSABLE-HARNESS-DESIGN.md §2 module 11, §2.2 conflict C5, §5.3 risk 1): the rule engine. ONE engine, ONE evaluation order — deny → ask → allow, FIRST-MATCH within that fixed tier priority (the C5 decision: “the engine evaluates deny→ask→allow first-match (CC); OC-style [last-match] sets are translated at preset-import time” — crate::permissions::translate is that translator). This module never implements last-match semantics itself.
translate
P5-1 (COMPOSABLE-HARNESS-DESIGN.md §2.2 conflict C5, §5.3 risk 1, §4.4 oc-parity): oc/cx import translators. When importing/emulating a last-match-wins rule set (opencode’s native algebra — oc§4:239-241), this module translates it into the engine’s first-match deny→ask→allow form and EMITS A WARNING on every pattern whose translated fixed point differs from the source’s — the risk-1 mitigation verbatim (“import-time translators emit warnings on any rule whose translated fixed point differs”).

Structs§

ApprovalCache
Session-scoped “approve for session” decision cache (§2.10). Keyed by Self::key(tool, subject) — so a repeated identical call (the SAME canonical command, or the same path) skips re-prompting for the rest of this agent’s lifetime, exactly like CC’s “don’t ask again”/oc’s “always” (cc§4, oc§4). Cheap and unconditional to construct — an agent that never enables capabilities.permissions simply never populates or consults it (§1.13-style “zero cost when off”).
ApprovalRequest
One Ask-tier request handed to a PermissionsApprovalHandler — enough context for an interactive prompt (or a scripted policy) to render a decision without needing back-references into Agent’s private state.
CanonSubcommand
One extracted sub-command: its canonicalized argv, and whether it is “opaque” — a wrapper whose real effect can’t be statically resolved, so crate::permissions::rules::evaluate_command must never let it resolve to Allow purely by absence of a matching rule.
RuleSet
A first-match deny→ask→allow rule set (§2 module 11, C5). Each list holds pattern strings in one of two forms:
SourceRule
One rule in the SOURCE (last-match-wins) ordering.
Translated
The result of a translation: the target RuleSet (safe by construction — see the module doc’s step 5) plus one warning string per pattern whose fixed point differed from the source, safe or not.

Enums§

ApprovalOutcome
What a PermissionsApprovalHandler decides for one Ask-tier request.
CanonResult
The result of canonicalize: either a flat list of every sub-command found (top-level compounds, and every command nested inside a $(...)/backtick substitution anywhere in the tree — see the module doc for why a single recursive command-node walk is sufficient to find both), or a fail-closed reason a caller must treat as REQUIRING approval.
Decision
The three-way outcome the engine can reach for a tool call. Ordered by strictness for Decision::stricter (Deny strictest, Allow loosest) — NOT by numeric severity in the tier-priority sense (which is a fixed deny→ask→allow scan order, not a totally-ordered scale); the ordering here exists purely to fold multiple sub-command decisions down to “the single worst one wins”, the compound-safety invariant D-3/risk-1 demands.
PathKind
Which access evaluate_path is checking — matches module 11’s read(...)/write(...) pseudo-tool rule pattern names.

Traits§

PermissionsApprovalHandler
The non-interactive decision seam a CLI/TUI/SDK embedder implements. The engine (crate::agent::Agent’s gate) calls Self::ask ONLY when the rule engine has already resolved a call to Decision::AskDeny short-circuits before ever reaching a handler (a hard floor, never consulted), and Allow never needs one. No handler installed (the default) denies every Ask — fail-closed, the same posture Config::approval_handler’s doc comment already documents for the pre-P5-1 gate (“absent handler denies, so an OnRequest/Untrusted policy is fail-closed” — agent.rs).

Functions§

canonicalize
Parse command (a bash command string, as a model would pass to the bash/shell tool) into its canonical sub-commands — the D-3 security core every rule-engine decision in crate::permissions::rules is built on. See the module doc comment for the fail-closed contract.
decision_to_approved
Convenience: fold a Decision into the boolean “may this call proceed” the tool-dispatch gate needs, given a resolve_ask-style callback for the Ask case. Deny never reaches ask_fn (hard floor); Allow never needs it either.
evaluate_command
Evaluate a (possibly compound) shell command against rules, folding every extracted sub-command’s decision down to the single strictest one (§5.3 risk 1’s compound-safety invariant). tool is the calling tool’s name ("bash", "shell", …) — sub-command patterns match as tool(cmdglob) against tool, e.g. a rule written "bash(rm -rf*)" applies to every sub-command of a bash call, not to a shell call.
evaluate_path
Evaluate a single resolved path against rules, as either a "read" or "write" pseudo-tool (module 11’s “path rules”: read/write globs — see RuleSet’s doc comment). Falls back to default when nothing matches.
evaluate_path_safe
SECURITY (CRITICAL fix, guarantor audit, traced to this file’s former evaluate_path doc comment claiming “no canonicalization … is involved here … no unparseable case to fail closed on” — the flawed assumption that let a traversal payload bypass protected_paths): the safe entry point for evaluating a RAW, model-supplied path tool argument (relative or absolute, exactly as it arrives in args["path"]) as a "read"/"write" pseudo-tool subject. Resolves raw_path against root through crate::safe_path::resolve_for_matching — the SAME dual lexical+symlink-resolved check crate::checkpoint’s P5-9 fix uses — and folds evaluate_path against the RAW subject, the lexically-normalized project-relative form, AND the symlink-resolved project-relative form down to the single strictest Decision (ties broken toward stricter, via Decision::stricter), so a rule can never be satisfied by matching only one of these three views.
evaluate_path_subject_safe
Like evaluate_path_safe, but for an arbitrary tool name subject instead of the read/write pseudo-tool — e.g. a rule authored against the REAL tool name with a path subject (design §4.4’s "read_file(*.env)" syntax, or an apply_patch-targeted rule). evaluate_path_safe is a thin wrapper over this for the pseudo-tool case; callers that need BOTH (the permissions gate always does — see crate::agent’s permissions_gate_denial_impl) call this function a second time with tool set to the real tool name and fold the two results together.
opencode_default_policy
opencode’s documented DEFAULT policy (oc§4 “Default policy”: {"*": allow} with carve-outs doom_loop: ask, external_directory: ask, question: deny, plan_enter/plan_exit: deny, read {*.env: ask, *.env.*: ask, *.env.example: allow}), rendered into this engine’s pattern syntax and run through translate_last_match_to_first_match — the worked example design §4.4 specifies and this build reproduces.
protected_path_deny_rules
Module 13 (permissions.protected_paths): build the read(...) + write(...) deny rules a protected-paths glob list expands to — a protected path is unconditionally denied for BOTH read and write, unlike an ordinary rule (this is the “never auto-approved” floor cc§4 documents for .git/**/.env*/etc, not an ordinary ask/allow-able rule). Callers fold the result into a RuleSet’s deny list (see crate::configfile::materialize_config), which — because deny is always checked first, unconditionally, with no override — makes a protected path exactly as hard a floor as Config::tool_deny_patterns already is (config.rs).
resolve_ask
Resolve one Ask-tier request against the cache + an optional handler: cache hit → true (no handler call); no handler → false (fail-closed); handler Deny/Allow/AllowForSessionfalse/true/true (recording the grant in cache for the last case). This is the single call site crate::agent::Agent’s gate uses, factored out so it’s unit- testable without a full Agent.
translate_last_match_to_first_match
Translate source (last-match-wins order, first rule = lowest priority) into a first-match deny→ask→allow RuleSet — see the module doc for the algorithm and its safety guarantee.