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.
Re-exports§
pub use approval::decision_to_approved;pub use approval::resolve_ask;pub use approval::ApprovalCache;pub use approval::ApprovalOutcome;pub use approval::ApprovalRequest;pub use approval::PermissionsApprovalHandler;pub use canon::canonicalize;pub use canon::CanonResult;pub use canon::CanonSubcommand;pub use rules::evaluate_command;pub use rules::evaluate_path;pub use rules::evaluate_path_safe;pub use rules::evaluate_path_subject_safe;pub use rules::protected_path_deny_rules;pub use rules::Decision;pub use rules::PathKind;pub use rules::RuleSet;pub use translate::opencode_default_policy;pub use translate::translate_last_match_to_first_match;pub use translate::SourceRule;pub use translate::Translated;
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”).