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}