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 -o — sed -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
tuimodule (P5 row 4, not this unit) —PermissionsApprovalHandleris the seam a CLI/TUI/SDK embedder implements to plug an interactive (or scripted/headless) prompt intocrate::agent::Agent’s tool-dispatch gate, mirroring the existingcrate::reduce::summarize::SpanSummarizer/crate::session_title::SessionTitler“installing one alone changes nothing, theConfiggate 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::translateis 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§
- Approval
Cache - 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 enablescapabilities.permissionssimply never populates or consults it (§1.13-style “zero cost when off”). - Approval
Request - One
Ask-tier request handed to aPermissionsApprovalHandler— enough context for an interactive prompt (or a scripted policy) to render a decision without needing back-references intoAgent’s private state. - Canon
Subcommand - 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_commandmust never let it resolve toAllowpurely 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:
- Source
Rule - 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§
- Approval
Outcome - What a
PermissionsApprovalHandlerdecides for oneAsk-tier request. - Canon
Result - 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 recursivecommand-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(Denystrictest,Allowloosest) — 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. - Path
Kind - Which access
evaluate_pathis checking — matches module 11’sread(...)/write(...)pseudo-tool rule pattern names.
Traits§
- Permissions
Approval Handler - The non-interactive decision seam a CLI/TUI/SDK embedder implements. The
engine (
crate::agent::Agent’s gate) callsSelf::askONLY when the rule engine has already resolved a call toDecision::Ask—Denyshort-circuits before ever reaching a handler (a hard floor, never consulted), andAllownever needs one. No handler installed (the default) denies everyAsk— fail-closed, the same postureConfig::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 thebash/shelltool) into its canonical sub-commands — the D-3 security core every rule-engine decision incrate::permissions::rulesis built on. See the module doc comment for the fail-closed contract. - decision_
to_ approved - Convenience: fold a
Decisioninto the boolean “may this call proceed” the tool-dispatch gate needs, given aresolve_ask-style callback for theAskcase.Denynever reachesask_fn(hard floor);Allownever 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).toolis the calling tool’s name ("bash","shell", …) — sub-command patterns match astool(cmdglob)againsttool, e.g. a rule written"bash(rm -rf*)"applies to every sub-command of abashcall, not to ashellcall. - 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 — seeRuleSet’s doc comment). Falls back todefaultwhen nothing matches. - evaluate_
path_ safe - SECURITY (CRITICAL fix, guarantor audit, traced to this file’s former
evaluate_pathdoc comment claiming “no canonicalization … is involved here … no unparseable case to fail closed on” — the flawed assumption that let a traversal payload bypassprotected_paths): the safe entry point for evaluating a RAW, model-suppliedpathtool argument (relative or absolute, exactly as it arrives inargs["path"]) as a"read"/"write"pseudo-tool subject. Resolvesraw_pathagainstrootthroughcrate::safe_path::resolve_for_matching— the SAME dual lexical+symlink-resolved checkcrate::checkpoint’s P5-9 fix uses — and foldsevaluate_pathagainst the RAW subject, the lexically-normalized project-relative form, AND the symlink-resolved project-relative form down to the single strictestDecision(ties broken toward stricter, viaDecision::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 arbitrarytoolname subject instead of theread/writepseudo-tool — e.g. a rule authored against the REAL tool name with a path subject (design §4.4’s"read_file(*.env)"syntax, or anapply_patch-targeted rule).evaluate_path_safeis a thin wrapper over this for the pseudo-tool case; callers that need BOTH (the permissions gate always does — seecrate::agent’spermissions_gate_denial_impl) call this function a second time withtoolset 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-outsdoom_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 throughtranslate_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 theread(...)+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 aRuleSet’sdenylist (seecrate::configfile::materialize_config), which — becausedenyis always checked first, unconditionally, with no override — makes a protected path exactly as hard a floor asConfig::tool_deny_patternsalready 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); handlerDeny/Allow/AllowForSession→false/true/true(recording the grant incachefor the last case). This is the single call sitecrate::agent::Agent’s gate uses, factored out so it’s unit- testable without a fullAgent. - translate_
last_ match_ to_ first_ match - Translate
source(last-match-wins order, first rule = lowest priority) into a first-match deny→ask→allowRuleSet— see the module doc for the algorithm and its safety guarantee.