supercode_harness/permissions/mod.rs
1//! P5-1 (COMPOSABLE-HARNESS-DESIGN.md §2 modules 10-11, §2.1 D-3, §2.2 C5,
2//! §5.3 risk 1): the permissions engine — command canonicalization + rule
3//! algebra + approval policy/cache + oc/cx import translators.
4//!
5//! **Risk 1 (§5.3): "a wrong translation is a SILENT privilege escalation."**
6//! This module is built to that standard: ONE engine, ONE evaluation order
7//! (deny→ask→allow first-match, [`rules`]); a tree-sitter-based canonicalizer
8//! that fails CLOSED on anything it can't parse cleanly ([`canon`]); an
9//! import translator that WARNS (never silently) when a translated rule's
10//! fixed point differs from its source ([`translate`]); and a golden-vector +
11//! adversarial-bypass test suite (`crates/harness/tests/permissions_engine.rs`)
12//! pinning the documented cc§4/oc§4 semantics as a regression bar.
13//!
14//! **Integration.** The engine activates via
15//! `capabilities.permissions.enabled` ([`crate::Config::permissions_enabled`],
16//! populated by `crate::configfile::materialize_config`) and is consumed at
17//! `crate::agent::Agent`'s tool-dispatch gate
18//! (`Agent::prepare_tool_call`). DEFAULT: disabled — the gate falls through
19//! to the pre-P5-1 [`crate::Config::needs_approval`] path byte-for-byte, so
20//! every existing test and today's default posture (approval=never,
21//! sandbox=none) is unchanged.
22//!
23//! **`protected_paths` coverage, honestly stated (F4, Fable-5 adversarial
24//! review; corrected by the F6/F7 delta review, the round-3 delta review,
25//! then the round-4 delta review below).**
26//! `capabilities.permissions.protected_paths` is a RULE-LAYER floor, not an
27//! OS-level one: it is enforced for file-tool calls (`read_file`/
28//! `write_file`/`edit_file`), for a bash command's direct shell redirect
29//! targets and a best-effort set of known argv-writers (`tee`, `dd of=`,
30//! `cp`/`mv`/`install`/`ln` — including their `-t DIR`/
31//! `--target-directory=DIR` form (F6) and its getopt-bundled short-flag
32//! equivalent `-ft DIR`/`-Dt DIR`/… (round-3) — `sed -i`, `truncate`,
33//! `sort -o FILE`, `split`'s PREFIX (round-3 hardening) — see
34//! `canon::known_writer_targets`'s doc comment for that heuristic's named
35//! gaps), and for `apply_patch`'s target path(s). A write this layer
36//! RECOGNIZES but can't statically resolve to a concrete destination — an
37//! opaque wrapper, a `$VAR`/`` `cmd` `` dynamic target, an unquoted glob
38//! metacharacter (`*`/`?`/`[`) that bash would pathname-expand before the
39//! write (F7), or a known argv-writer flag shape [`canon::
40//! known_writer_targets`] can't confidently resolve to a destination token
41//! (F6, including its round-3 bundled-short-flag extension, and — round-4 —
42//! the SAME bundled-short-flag extension now also covering `sed -i` and
43//! `sort -o`) — is forced to at least `Ask`, NEVER silently `Allow`. This is
44//! now true without exception for every write shape this rule layer claims
45//! to cover (the F6/F7 delta review found and closed two forms where it
46//! wasn't; the round-3 delta review found and closed a third: F6's own fix
47//! didn't yet recognize a getopt-BUNDLED short-flag `-t`, e.g.
48//! `cp -ft .git a`; the round-4 delta review found and closed a fourth:
49//! round-3's bundled-short-flag fix was only made `-t`-specific, leaving
50//! the identical blind spot open on `sed -i`/`sort -o` — `sed -ni
51//! s/../PWNED/ .env` and `sort -uo .env a` both still fell through to a
52//! silent `Allow`). Round-4 closes this as a CLASS, not a third patched
53//! instance: every flag-driven known-writer detection (`-t` for
54//! cp/mv/install/ln, `-i` for sed, `-o` for sort) now routes through one
55//! shared, generalized bundled-short-flag scan
56//! (`canon::known_writer_targets`'s doc comment names it) — a future
57//! flag-driven writer inherits the fix by construction instead of needing
58//! its own bundled-flag audit.
59//!
60//! **What "claims to cover" does NOT mean, stated plainly (round-3
61//! hardening; then STOP enumerating).** `sort -o`/`split` (round-3) are new
62//! ADDITIONS to the enumerated-writer set, not a fix to a row already
63//! claimed covered — before that change, a write via `sort -o`/`split`
64//! simply wasn't recognized AT ALL, i.e. a false-`Allow` gap of exactly the
65//! same shape every OTHER un-enumerated writer still is today: any
66//! interpreter's own file-write builtins, a compiler's `-o`, a database
67//! client's export command, or any other bash construct this rule layer
68//! doesn't specifically parse. This module does not, and does not claim to,
69//! enumerate every file-writing command that could ever appear in a bash
70//! tool call — doing so is an unbounded, always-incomplete list. What IS
71//! true, and load-bearing, is the narrower claim above: for every write
72//! SHAPE this rule layer *does* recognize (the enumerated writers, shell
73//! redirects, `apply_patch`), fail-closed holds without exception — an
74//! unresolvable target never silently resolves to `Allow`. An
75//! un-enumerated writer is a false NEGATIVE at this rule layer (nothing
76//! flagged, default policy decides), honestly named here rather than
77//! silently claimed covered — complete OS-level write confinement of
78//! arbitrary bash, which closes that gap entirely regardless of which
79//! command wrote the file, is `capabilities.permissions.sandbox`'s job (P5
80//! module 10, a later unit), not this rule-layer heuristic's. See
81//! [`crate::Config::permissions_protected_paths`]'s doc comment for the
82//! same note where the config field is defined.
83
84pub mod approval;
85pub mod canon;
86pub mod rules;
87pub mod translate;
88
89pub use approval::{
90 cache_for_config, decision_to_approved, default_approval_store, resolve_ask, ApprovalCache,
91 ApprovalOutcome, ApprovalRequest, PermissionsApprovalHandler,
92};
93pub use canon::{canonicalize, CanonResult, CanonSubcommand};
94pub use rules::{
95 domain_denied_explicitly, evaluate_command, evaluate_domain, evaluate_path, evaluate_path_safe,
96 evaluate_path_safe_roots, evaluate_path_subject_safe, evaluate_path_subject_safe_roots,
97 protected_path_deny_rules, Decision, PathKind, RuleSet,
98};
99pub use translate::{
100 opencode_default_policy, translate_last_match_to_first_match, SourceRule, Translated,
101};
102
103/// BP-5: the deny→ask→allow [`RuleSet`] a resolved [`crate::Config`] means,
104/// with the protected-path floor already folded into the deny tier.
105///
106/// ONE construction, so every surface that has to ask "is this allowed?"
107/// asks the same engine the same way: the agent's tool-dispatch gate
108/// (`Agent::permissions_gate_denial`, which additionally narrows it with
109/// plan mode's own deny rules) and the `` !`cmd` `` expansion in a
110/// skill/command body ([`crate::skills::ShellInjection`]). A second,
111/// hand-assembled rule set anywhere is a silent privilege split.
112pub fn rules_for_config(config: &crate::Config) -> RuleSet {
113 let mut deny = config.tool_deny_patterns.clone();
114 deny.extend(protected_path_deny_rules(
115 &config.permissions_protected_paths,
116 ));
117 RuleSet {
118 deny,
119 ask: config.permissions_ask_patterns.clone(),
120 allow: config.tool_allow_patterns.clone(),
121 }
122}
123
124/// BP-5: the baseline decision for `tool` when NO rule in
125/// [`rules_for_config`] matches — derived from
126/// [`crate::config::ApprovalPolicy`] exactly as the agent's gate derives it
127/// (see `Agent::permissions_gate_denial_impl`, which calls this).
128pub fn default_decision(config: &crate::Config, tool: &str) -> Decision {
129 match config.approval {
130 crate::config::ApprovalPolicy::Never => Decision::Allow,
131 // BP-8 DEFECT-FIX (carried here verbatim): `auto_approved_tools` IS
132 // this schema's spelling of Claude Code's read-only tier, and it
133 // applies under both prompting policies.
134 crate::config::ApprovalPolicy::OnRequest | crate::config::ApprovalPolicy::Untrusted => {
135 if config.auto_approved_tools.contains(tool) {
136 Decision::Allow
137 } else {
138 Decision::Ask
139 }
140 }
141 crate::config::ApprovalPolicy::ModelRequested => Decision::Allow,
142 }
143}