Skip to main content

supercode_harness/permissions/
translate.rs

1//! P5-1 (COMPOSABLE-HARNESS-DESIGN.md §2.2 conflict C5, §5.3 risk 1, §4.4
2//! oc-parity): oc/cx import translators. When importing/emulating a
3//! last-match-wins rule set (opencode's native algebra — oc§4:239-241), this
4//! module translates it into the engine's first-match deny→ask→allow form
5//! and EMITS A WARNING on every pattern whose translated fixed point differs
6//! from the source's — the risk-1 mitigation verbatim ("import-time
7//! translators emit warnings on any rule whose translated fixed point
8//! differs").
9//!
10//! **Algorithm.** Last-match-wins over an ordered list `[r1, r2, …, rn]`
11//! means: for a given probe, the LAST rule in the list whose pattern matches
12//! it determines the outcome. The target engine has no notion of "position"
13//! at all — it has three FIXED-PRIORITY tiers (deny, then ask, then allow).
14//! There is no general position-preserving translation between the two
15//! algebras (§4.4's own gap ledger: "adversarial user rule sets exploiting
16//! last-match order … have no first-match equivalent"), so this module does
17//! the next best thing, and does it HONESTLY:
18//!
19//! 1. Resolve each DISTINCT pattern to its own last-match answer (identical
20//!    patterns repeated: the last occurrence wins; this is just last-match
21//!    applied to the degenerate single-pattern case).
22//! 2. Bucket every resolved pattern into the target's deny/ask/allow tier by
23//!    that answer — a first, "naive" `RuleSet`.
24//! 3. For every DISTINCT pattern, simulate what the ORIGINAL source list
25//!    would decide for a probe equal to that pattern (a true last-match
26//!    walk, so cross-pattern glob overlap — e.g. a `"*"` rule interacting
27//!    with a more specific one — is honored, not just same-literal-pattern
28//!    repeats) and what the naive target `RuleSet` from step 2 decides for
29//!    the SAME probe (a true first-match deny→ask→allow evaluation).
30//! 4. Where they agree: no warning, nothing to fix.
31//! 5. Where they disagree: ALWAYS warn (recording the divergence — the
32//!    risk-1 mitigation's actual requirement). If the target's answer is
33//!    STRICTER (or equal) than the source's, the divergence is safe-direction
34//!    (exactly oc-parity's named `.env.example` case, §4.4) and the target's
35//!    stricter reading is KEPT. If the target's answer is MORE PERMISSIVE
36//!    than the source's — an unsafe divergence — the pattern is forcibly
37//!    reassigned to the source's (stricter) tier before returning, so
38//!    [`translate_last_match_to_first_match`] can never silently hand back a
39//!    ruleset more permissive than the one it was asked to translate.
40
41use std::collections::HashMap;
42
43use super::rules::{Decision, RuleSet};
44use crate::config::glob_match;
45
46/// One rule in the SOURCE (last-match-wins) ordering.
47#[derive(Debug, Clone)]
48pub struct SourceRule {
49    /// The pattern text, in this engine's own `tool`/`tool(subject)`/`*`
50    /// syntax (see `crate::permissions::rules::RuleSet`'s doc comment) —
51    /// this module translates the ALGEBRA (evaluation order), not a
52    /// foreign harness's pattern grammar; a caller importing opencode's own
53    /// config format is responsible for first rendering its patterns into
54    /// this syntax (see [`opencode_default_policy`] for the worked example
55    /// this build ships).
56    pub pattern: String,
57    /// What this rule resolves to when it's the one that matches.
58    pub decision: Decision,
59}
60
61/// The result of a translation: the target [`RuleSet`] (safe by
62/// construction — see the module doc's step 5) plus one warning string per
63/// pattern whose fixed point differed from the source, safe or not.
64#[derive(Debug, Clone)]
65pub struct Translated {
66    /// The target first-match deny→ask→allow rule set.
67    pub rules: RuleSet,
68    /// One entry per pattern whose translated fixed point differed from the
69    /// source (the risk-1 mitigation: never silent).
70    pub warnings: Vec<String>,
71}
72
73/// Translate `source` (last-match-wins order, first rule = lowest priority)
74/// into a first-match deny→ask→allow [`RuleSet`] — see the module doc for
75/// the algorithm and its safety guarantee.
76pub fn translate_last_match_to_first_match(source: &[SourceRule]) -> Translated {
77    // Step 1: last-occurrence-wins per distinct pattern, order-preserving
78    // for readability (not semantically load-bearing — tier bucketing
79    // doesn't care about relative order within a tier, C5).
80    let mut order: Vec<String> = Vec::new();
81    let mut last: HashMap<String, Decision> = HashMap::new();
82    for r in source {
83        if !last.contains_key(&r.pattern) {
84            order.push(r.pattern.clone());
85        }
86        last.insert(r.pattern.clone(), r.decision);
87    }
88
89    // Step 2: naive bucketing by each pattern's own resolved decision.
90    let mut rules = RuleSet::default();
91    for pattern in &order {
92        match last[pattern] {
93            Decision::Deny => rules.deny.push(pattern.clone()),
94            Decision::Ask => rules.ask.push(pattern.clone()),
95            Decision::Allow => rules.allow.push(pattern.clone()),
96        }
97    }
98
99    // Step 3-5: fixed-point comparison per distinct pattern, probing with
100    // the pattern's own text as a stand-in command/subject (the same
101    // "self-probe" a golden-vector suite would use to pin one named
102    // pattern's behavior) — good enough to catch genuine cross-pattern
103    // overlap (e.g. an earlier `"*"` vs a later specific rule) without
104    // requiring the caller to hand this generic algorithm a full sample
105    // corpus of real commands.
106    let mut warnings = Vec::new();
107    for pattern in &order {
108        let probe = pattern.as_str();
109        let source_decision = simulate_last_match(source, probe);
110        let target_decision = evaluate_self(&rules, pattern);
111        let (Some(sd), Some(td)) = (source_decision, target_decision) else {
112            continue;
113        };
114        if sd != td {
115            let safe = sd.stricter(td);
116            if safe != td {
117                // Target was MORE PERMISSIVE than source — an unsafe
118                // divergence. Force the pattern into the safe tier.
119                reassign(&mut rules, pattern, safe);
120            }
121            warnings.push(format!(
122                "C5 translation: pattern `{pattern}` — source (last-match) resolves to \
123                 {sd:?}, first-match-translated resolves to {td:?}; kept {safe:?} \
124                 ({} divergence)",
125                if safe == td {
126                    "safe-direction"
127                } else {
128                    "unsafe, corrected"
129                }
130            ));
131        }
132    }
133
134    Translated { rules, warnings }
135}
136
137/// True last-match walk of the ORIGINAL source list for `probe` — the last
138/// rule (by source position) whose pattern glob-matches `probe`'s own text
139/// wins. This is the semantics [`translate_last_match_to_first_match`]
140/// exists to reproduce (subject to the target engine's tier-priority
141/// limitation).
142fn simulate_last_match(source: &[SourceRule], probe: &str) -> Option<Decision> {
143    let mut result = None;
144    for r in source {
145        if glob_match(&r.pattern, probe) || r.pattern == probe {
146            result = Some(r.decision);
147        }
148    }
149    result
150}
151
152/// Self-referential fixed-point probe: does `pattern`, considered as its own
153/// subject, resolve under `rules`' deny→ask→allow first-match evaluation?
154/// Tries the bare-glob form first (bare tool-name-style patterns like
155/// `"question"`/`"plan_enter"`), then the `tool(subject)` form using
156/// `pattern`'s own tool-prefix against its own command-glob, so a pattern
157/// like `"read(*.env)"` can be asked "does `read(*.env)` match itself" in a
158/// way that actually exercises the tool+subject matcher.
159fn evaluate_self(rules: &RuleSet, pattern: &str) -> Option<Decision> {
160    if let Some(open) = pattern.find('(') {
161        if let Some(subject) = pattern.strip_suffix(')').and_then(|p| p.get(open + 1..)) {
162            let tool = &pattern[..open];
163            return rules.evaluate(tool, Some(subject));
164        }
165    }
166    // A bare tool-name-shaped pattern (including the literal wildcard
167    // `"*"`): probe with the pattern text AS the tool name, no subject —
168    // this exercises bare-glob rules (`"tool"`/`"tool*"`/`"*"`) correctly.
169    // `tool(cmdglob)`-shaped rules elsewhere in `rules` never match a
170    // `None` subject (`rule_matches`'s contract), so they cannot spuriously
171    // fire here.
172    rules.evaluate(pattern, None)
173}
174
175/// Remove `pattern` from every tier of `rules` and reinsert it in
176/// `target_tier` — the step-5 safety correction.
177fn reassign(rules: &mut RuleSet, pattern: &str, target_tier: Decision) {
178    rules.deny.retain(|p| p != pattern);
179    rules.ask.retain(|p| p != pattern);
180    rules.allow.retain(|p| p != pattern);
181    match target_tier {
182        Decision::Deny => rules.deny.push(pattern.to_string()),
183        Decision::Ask => rules.ask.push(pattern.to_string()),
184        Decision::Allow => rules.allow.push(pattern.to_string()),
185    }
186}
187
188/// opencode's documented DEFAULT policy (oc§4 "Default policy": `{"*":
189/// allow}` with carve-outs `doom_loop: ask`, `external_directory: ask`,
190/// `question: deny`, `plan_enter`/`plan_exit: deny`, `read {*.env: ask,
191/// *.env.*: ask, *.env.example: allow}`), rendered into this engine's
192/// pattern syntax and run through [`translate_last_match_to_first_match`] —
193/// the worked example design §4.4 specifies and this build reproduces.
194///
195/// **Two of oc's five carve-outs are deliberately EXCLUDED from the rule
196/// list itself** (§4.4's own text, reproduced here as the three named
197/// deviations):
198///
199/// 1. `doom_loop: ask` is not a rule-language pattern at all — it's a
200///    repetition TRIGGER (same call repeated), not a tool/path match.
201///    Routed to its real mechanism instead: `Config::doom_loop_threshold`
202///    (the P4 doom-loop breaker). No rule entry; a warning names the
203///    routing decision explicitly (never silently dropped).
204/// 2. `external_directory: ask` is an oc PERMISSION CATEGORY (any tool
205///    touching paths outside the worktree), not a tool name. Routed to its
206///    real mechanism: `Config::additional_dirs` — paths outside `cwd` and
207///    outside `additional_dirs` are simply unreachable, a stricter (not
208///    equivalent) reading. No rule entry; a warning names the routing
209///    decision.
210/// 3. `.env.example` → **ASK, not ALLOW** — this one DOES fall out of the
211///    generic algorithm above (a genuine, detected, safe-direction fixed-
212///    point divergence): under first-match deny→ask→allow, a read of
213///    `.env.example` matches the ask-rule `read(*.env.*)` BEFORE the allow
214///    list (`"*"`) is ever consulted, so it asks where stock opencode
215///    allows. Recorded by [`translate_last_match_to_first_match`]'s own
216///    warning, not hidden.
217pub fn opencode_default_policy() -> Translated {
218    let source = vec![
219        SourceRule {
220            pattern: "*".to_string(),
221            decision: Decision::Allow,
222        },
223        SourceRule {
224            pattern: "tools_question".to_string(),
225            decision: Decision::Deny,
226        },
227        SourceRule {
228            pattern: "plan_enter".to_string(),
229            decision: Decision::Deny,
230        },
231        SourceRule {
232            pattern: "plan_exit".to_string(),
233            decision: Decision::Deny,
234        },
235        SourceRule {
236            pattern: "read(*.env)".to_string(),
237            decision: Decision::Ask,
238        },
239        SourceRule {
240            pattern: "read(*.env.*)".to_string(),
241            decision: Decision::Ask,
242        },
243        SourceRule {
244            pattern: "read(*.env.example)".to_string(),
245            decision: Decision::Allow,
246        },
247    ];
248    let mut translated = translate_last_match_to_first_match(&source);
249    translated.warnings.push(
250        "C5/S4 deviation 2 (doom_loop): opencode's `doom_loop: ask` carve-out is a repetition \
251         TRIGGER, not a rule-language pattern — routed to `Config::doom_loop_threshold` (the P4 \
252         doom-loop breaker) instead of a rule entry; not silently dropped."
253            .to_string(),
254    );
255    translated.warnings.push(
256        "C5/S4 deviation 3 (external_directory): opencode's `external_directory: ask` carve-out \
257         is a PERMISSION CATEGORY (any tool touching paths outside the worktree), not a tool \
258         name — routed to `Config::additional_dirs` instead of a rule entry (paths outside cwd \
259         and outside additional_dirs are simply unreachable, a stricter reading); not silently \
260         dropped."
261            .to_string(),
262    );
263    translated
264}