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}
265
266#[cfg(test)]
267mod tests {
268    use super::*;
269    use crate::permissions::rules::{evaluate_command, evaluate_path, PathKind};
270
271    #[test]
272    fn later_allow_overriding_earlier_deny_translates_with_warning_and_safe_reading() {
273        // Source (last-match): deny X first, then a later allow X — last
274        // wins, so source says Allow. First-match deny->ask->allow always
275        // finds the deny bucket first, so target-before-safety says Deny —
276        // a divergence, but in the SAFE (stricter) direction: kept.
277        let source = vec![
278            SourceRule {
279                pattern: "bash(rm*)".to_string(),
280                decision: Decision::Deny,
281            },
282            SourceRule {
283                pattern: "bash(rm*)".to_string(),
284                decision: Decision::Allow,
285            },
286        ];
287        let t = translate_last_match_to_first_match(&source);
288        assert_eq!(t.rules.allow, vec!["bash(rm*)".to_string()]);
289        assert!(t.rules.deny.is_empty());
290        assert!(
291            t.warnings.is_empty(),
292            "identical pattern dedupes to last-wins with no divergence to report: {:?}",
293            t.warnings
294        );
295    }
296
297    #[test]
298    fn distinct_overlapping_patterns_detect_unsafe_divergence_and_correct_it() {
299        // Source (last-match): "*"=allow declared FIRST, then a more
300        // specific deny declared LATER — last-match correctly picks the
301        // deny for a probe of the specific pattern. Naive tier-bucketing
302        // (deny always scanned first) would ALSO land on deny here — no
303        // real divergence for THIS pair. To manufacture a genuine unsafe
304        // divergence we need the specific rule to be ask/allow and a wider
305        // deny to be positioned so last-match's more-specific-later pattern
306        // wins loosely while first-match's deny-tier-always-wins would
307        // still pick the (irrelevantly) matching deny. Use: a broad ask on
308        // "*" declared first, a specific allow declared later (source =
309        // Allow for the specific probe); target buckets: ask=["*"],
310        // allow=[specific] -> first-match checks deny(none), then ask("*"
311        // matches everything) BEFORE ever reaching the specific allow ->
312        // target = Ask, source = Allow: target stricter -> safe, kept.
313        let source = vec![
314            SourceRule {
315                pattern: "*".to_string(),
316                decision: Decision::Ask,
317            },
318            SourceRule {
319                pattern: "read(*.env.example)".to_string(),
320                decision: Decision::Allow,
321            },
322        ];
323        let t = translate_last_match_to_first_match(&source);
324        // Safe direction: target keeps Ask (stricter than source's Allow).
325        assert!(t.rules.ask.contains(&"*".to_string()));
326        assert!(!t.warnings.is_empty());
327    }
328
329    #[test]
330    fn opencode_default_policy_matches_the_three_named_deviations() {
331        let t = opencode_default_policy();
332        // Deviation 1: .env.example ends up ASK, not ALLOW.
333        assert_eq!(
334            evaluate_path(&t.rules, PathKind::Read, ".env.example", Decision::Allow),
335            Decision::Ask,
336            "expected the named safe-direction deviation (.env.example -> ask): {:?}",
337            t.rules
338        );
339        // The generic .env / .env.* carve-outs still ask, matching oc.
340        assert_eq!(
341            evaluate_path(&t.rules, PathKind::Read, ".env", Decision::Allow),
342            Decision::Ask
343        );
344        assert_eq!(
345            evaluate_path(&t.rules, PathKind::Read, ".env.local", Decision::Allow),
346            Decision::Ask
347        );
348        // question/plan_enter/plan_exit stay denied.
349        assert_eq!(
350            t.rules.evaluate("tools_question", None),
351            Some(Decision::Deny)
352        );
353        assert_eq!(t.rules.evaluate("plan_enter", None), Some(Decision::Deny));
354        assert_eq!(t.rules.evaluate("plan_exit", None), Some(Decision::Deny));
355        // Everything else defaults to opencode's permissive "*": allow.
356        assert_eq!(t.rules.evaluate("bash", Some("ls")), Some(Decision::Allow));
357
358        // At least one warning documents the .env.example divergence, plus
359        // the two routing-decision warnings (deviations 2 and 3) — three
360        // named deviations total, all recorded, none silent.
361        assert!(t
362            .warnings
363            .iter()
364            .any(|w| w.contains(".env.example") || w.contains("read(*.env.example)")));
365        assert!(t.warnings.iter().any(|w| w.contains("doom_loop")));
366        assert!(t.warnings.iter().any(|w| w.contains("external_directory")));
367        assert_eq!(
368            t.warnings.len(),
369            3,
370            "expected exactly 3 named deviations: {:?}",
371            t.warnings
372        );
373    }
374
375    #[test]
376    fn broader_later_allow_overlapping_an_earlier_specific_deny_warns_but_keeps_the_deny() {
377        // Source (last-match): a SPECIFIC deny declared first, a BROADER
378        // allow declared later that also glob-matches the specific
379        // pattern's own text — last-match honestly says Allow for the
380        // specific probe (the broader rule came later and covers it too).
381        // First-match deny->ask->allow always checks the deny tier first,
382        // so the target says Deny regardless of source position — stricter
383        // than source, therefore a SAFE-direction divergence: warn, but
384        // keep the (safer) Deny rather than loosen it to match source.
385        let source = vec![
386            SourceRule {
387                pattern: "bash(rm -rf*)".to_string(),
388                decision: Decision::Deny,
389            },
390            SourceRule {
391                pattern: "bash(*)".to_string(),
392                decision: Decision::Allow,
393            },
394        ];
395        let t = translate_last_match_to_first_match(&source);
396        assert_eq!(t.warnings.len(), 1, "{:?}", t.warnings);
397        assert!(t.warnings[0].contains("safe-direction"), "{:?}", t.warnings);
398        assert!(t.rules.deny.contains(&"bash(rm -rf*)".to_string()));
399        assert_eq!(
400            evaluate_command(&t.rules, "bash", "rm -rf /", Decision::Allow),
401            Decision::Deny,
402            "the translated ruleset must still deny the specific dangerous command"
403        );
404    }
405
406    #[test]
407    fn genuinely_non_overlapping_rules_translate_with_no_warnings() {
408        let source = vec![
409            SourceRule {
410                pattern: "bash(rm -rf*)".to_string(),
411                decision: Decision::Deny,
412            },
413            SourceRule {
414                pattern: "write_file(*.lock)".to_string(),
415                decision: Decision::Ask,
416            },
417        ];
418        let t = translate_last_match_to_first_match(&source);
419        assert!(
420            t.warnings.is_empty(),
421            "genuinely non-overlapping patterns should translate cleanly: {:?}",
422            t.warnings
423        );
424    }
425}