supercode-harness 0.4.2

The optional native Supercode agent and tool harness
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
//! 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").
//!
//! **Algorithm.** Last-match-wins over an ordered list `[r1, r2, …, rn]`
//! means: for a given probe, the LAST rule in the list whose pattern matches
//! it determines the outcome. The target engine has no notion of "position"
//! at all — it has three FIXED-PRIORITY tiers (deny, then ask, then allow).
//! There is no general position-preserving translation between the two
//! algebras (§4.4's own gap ledger: "adversarial user rule sets exploiting
//! last-match order … have no first-match equivalent"), so this module does
//! the next best thing, and does it HONESTLY:
//!
//! 1. Resolve each DISTINCT pattern to its own last-match answer (identical
//!    patterns repeated: the last occurrence wins; this is just last-match
//!    applied to the degenerate single-pattern case).
//! 2. Bucket every resolved pattern into the target's deny/ask/allow tier by
//!    that answer — a first, "naive" `RuleSet`.
//! 3. For every DISTINCT pattern, simulate what the ORIGINAL source list
//!    would decide for a probe equal to that pattern (a true last-match
//!    walk, so cross-pattern glob overlap — e.g. a `"*"` rule interacting
//!    with a more specific one — is honored, not just same-literal-pattern
//!    repeats) and what the naive target `RuleSet` from step 2 decides for
//!    the SAME probe (a true first-match deny→ask→allow evaluation).
//! 4. Where they agree: no warning, nothing to fix.
//! 5. Where they disagree: ALWAYS warn (recording the divergence — the
//!    risk-1 mitigation's actual requirement). If the target's answer is
//!    STRICTER (or equal) than the source's, the divergence is safe-direction
//!    (exactly oc-parity's named `.env.example` case, §4.4) and the target's
//!    stricter reading is KEPT. If the target's answer is MORE PERMISSIVE
//!    than the source's — an unsafe divergence — the pattern is forcibly
//!    reassigned to the source's (stricter) tier before returning, so
//!    [`translate_last_match_to_first_match`] can never silently hand back a
//!    ruleset more permissive than the one it was asked to translate.

use std::collections::HashMap;

use super::rules::{Decision, RuleSet};
use crate::config::glob_match;

/// One rule in the SOURCE (last-match-wins) ordering.
#[derive(Debug, Clone)]
pub struct SourceRule {
    /// The pattern text, in this engine's own `tool`/`tool(subject)`/`*`
    /// syntax (see `crate::permissions::rules::RuleSet`'s doc comment) —
    /// this module translates the ALGEBRA (evaluation order), not a
    /// foreign harness's pattern grammar; a caller importing opencode's own
    /// config format is responsible for first rendering its patterns into
    /// this syntax (see [`opencode_default_policy`] for the worked example
    /// this build ships).
    pub pattern: String,
    /// What this rule resolves to when it's the one that matches.
    pub decision: Decision,
}

/// The result of a translation: the target [`RuleSet`] (safe by
/// construction — see the module doc's step 5) plus one warning string per
/// pattern whose fixed point differed from the source, safe or not.
#[derive(Debug, Clone)]
pub struct Translated {
    /// The target first-match deny→ask→allow rule set.
    pub rules: RuleSet,
    /// One entry per pattern whose translated fixed point differed from the
    /// source (the risk-1 mitigation: never silent).
    pub warnings: Vec<String>,
}

/// Translate `source` (last-match-wins order, first rule = lowest priority)
/// into a first-match deny→ask→allow [`RuleSet`] — see the module doc for
/// the algorithm and its safety guarantee.
pub fn translate_last_match_to_first_match(source: &[SourceRule]) -> Translated {
    // Step 1: last-occurrence-wins per distinct pattern, order-preserving
    // for readability (not semantically load-bearing — tier bucketing
    // doesn't care about relative order within a tier, C5).
    let mut order: Vec<String> = Vec::new();
    let mut last: HashMap<String, Decision> = HashMap::new();
    for r in source {
        if !last.contains_key(&r.pattern) {
            order.push(r.pattern.clone());
        }
        last.insert(r.pattern.clone(), r.decision);
    }

    // Step 2: naive bucketing by each pattern's own resolved decision.
    let mut rules = RuleSet::default();
    for pattern in &order {
        match last[pattern] {
            Decision::Deny => rules.deny.push(pattern.clone()),
            Decision::Ask => rules.ask.push(pattern.clone()),
            Decision::Allow => rules.allow.push(pattern.clone()),
        }
    }

    // Step 3-5: fixed-point comparison per distinct pattern, probing with
    // the pattern's own text as a stand-in command/subject (the same
    // "self-probe" a golden-vector suite would use to pin one named
    // pattern's behavior) — good enough to catch genuine cross-pattern
    // overlap (e.g. an earlier `"*"` vs a later specific rule) without
    // requiring the caller to hand this generic algorithm a full sample
    // corpus of real commands.
    let mut warnings = Vec::new();
    for pattern in &order {
        let probe = pattern.as_str();
        let source_decision = simulate_last_match(source, probe);
        let target_decision = evaluate_self(&rules, pattern);
        let (Some(sd), Some(td)) = (source_decision, target_decision) else {
            continue;
        };
        if sd != td {
            let safe = sd.stricter(td);
            if safe != td {
                // Target was MORE PERMISSIVE than source — an unsafe
                // divergence. Force the pattern into the safe tier.
                reassign(&mut rules, pattern, safe);
            }
            warnings.push(format!(
                "C5 translation: pattern `{pattern}` — source (last-match) resolves to \
                 {sd:?}, first-match-translated resolves to {td:?}; kept {safe:?} \
                 ({} divergence)",
                if safe == td {
                    "safe-direction"
                } else {
                    "unsafe, corrected"
                }
            ));
        }
    }

    Translated { rules, warnings }
}

/// True last-match walk of the ORIGINAL source list for `probe` — the last
/// rule (by source position) whose pattern glob-matches `probe`'s own text
/// wins. This is the semantics [`translate_last_match_to_first_match`]
/// exists to reproduce (subject to the target engine's tier-priority
/// limitation).
fn simulate_last_match(source: &[SourceRule], probe: &str) -> Option<Decision> {
    let mut result = None;
    for r in source {
        if glob_match(&r.pattern, probe) || r.pattern == probe {
            result = Some(r.decision);
        }
    }
    result
}

/// Self-referential fixed-point probe: does `pattern`, considered as its own
/// subject, resolve under `rules`' deny→ask→allow first-match evaluation?
/// Tries the bare-glob form first (bare tool-name-style patterns like
/// `"question"`/`"plan_enter"`), then the `tool(subject)` form using
/// `pattern`'s own tool-prefix against its own command-glob, so a pattern
/// like `"read(*.env)"` can be asked "does `read(*.env)` match itself" in a
/// way that actually exercises the tool+subject matcher.
fn evaluate_self(rules: &RuleSet, pattern: &str) -> Option<Decision> {
    if let Some(open) = pattern.find('(') {
        if let Some(subject) = pattern.strip_suffix(')').and_then(|p| p.get(open + 1..)) {
            let tool = &pattern[..open];
            return rules.evaluate(tool, Some(subject));
        }
    }
    // A bare tool-name-shaped pattern (including the literal wildcard
    // `"*"`): probe with the pattern text AS the tool name, no subject —
    // this exercises bare-glob rules (`"tool"`/`"tool*"`/`"*"`) correctly.
    // `tool(cmdglob)`-shaped rules elsewhere in `rules` never match a
    // `None` subject (`rule_matches`'s contract), so they cannot spuriously
    // fire here.
    rules.evaluate(pattern, None)
}

/// Remove `pattern` from every tier of `rules` and reinsert it in
/// `target_tier` — the step-5 safety correction.
fn reassign(rules: &mut RuleSet, pattern: &str, target_tier: Decision) {
    rules.deny.retain(|p| p != pattern);
    rules.ask.retain(|p| p != pattern);
    rules.allow.retain(|p| p != pattern);
    match target_tier {
        Decision::Deny => rules.deny.push(pattern.to_string()),
        Decision::Ask => rules.ask.push(pattern.to_string()),
        Decision::Allow => rules.allow.push(pattern.to_string()),
    }
}

/// opencode's documented DEFAULT policy (oc§4 "Default policy": `{"*":
/// allow}` with carve-outs `doom_loop: ask`, `external_directory: ask`,
/// `question: deny`, `plan_enter`/`plan_exit: deny`, `read {*.env: ask,
/// *.env.*: ask, *.env.example: allow}`), rendered into this engine's
/// pattern syntax and run through [`translate_last_match_to_first_match`] —
/// the worked example design §4.4 specifies and this build reproduces.
///
/// **Two of oc's five carve-outs are deliberately EXCLUDED from the rule
/// list itself** (§4.4's own text, reproduced here as the three named
/// deviations):
///
/// 1. `doom_loop: ask` is not a rule-language pattern at all — it's a
///    repetition TRIGGER (same call repeated), not a tool/path match.
///    Routed to its real mechanism instead: `Config::doom_loop_threshold`
///    (the P4 doom-loop breaker). No rule entry; a warning names the
///    routing decision explicitly (never silently dropped).
/// 2. `external_directory: ask` is an oc PERMISSION CATEGORY (any tool
///    touching paths outside the worktree), not a tool name. Routed to its
///    real mechanism: `Config::additional_dirs` — paths outside `cwd` and
///    outside `additional_dirs` are simply unreachable, a stricter (not
///    equivalent) reading. No rule entry; a warning names the routing
///    decision.
/// 3. `.env.example` → **ASK, not ALLOW** — this one DOES fall out of the
///    generic algorithm above (a genuine, detected, safe-direction fixed-
///    point divergence): under first-match deny→ask→allow, a read of
///    `.env.example` matches the ask-rule `read(*.env.*)` BEFORE the allow
///    list (`"*"`) is ever consulted, so it asks where stock opencode
///    allows. Recorded by [`translate_last_match_to_first_match`]'s own
///    warning, not hidden.
pub fn opencode_default_policy() -> Translated {
    let source = vec![
        SourceRule {
            pattern: "*".to_string(),
            decision: Decision::Allow,
        },
        SourceRule {
            pattern: "tools_question".to_string(),
            decision: Decision::Deny,
        },
        SourceRule {
            pattern: "plan_enter".to_string(),
            decision: Decision::Deny,
        },
        SourceRule {
            pattern: "plan_exit".to_string(),
            decision: Decision::Deny,
        },
        SourceRule {
            pattern: "read(*.env)".to_string(),
            decision: Decision::Ask,
        },
        SourceRule {
            pattern: "read(*.env.*)".to_string(),
            decision: Decision::Ask,
        },
        SourceRule {
            pattern: "read(*.env.example)".to_string(),
            decision: Decision::Allow,
        },
    ];
    let mut translated = translate_last_match_to_first_match(&source);
    translated.warnings.push(
        "C5/S4 deviation 2 (doom_loop): opencode's `doom_loop: ask` carve-out is a repetition \
         TRIGGER, not a rule-language pattern — routed to `Config::doom_loop_threshold` (the P4 \
         doom-loop breaker) instead of a rule entry; not silently dropped."
            .to_string(),
    );
    translated.warnings.push(
        "C5/S4 deviation 3 (external_directory): opencode's `external_directory: ask` carve-out \
         is a PERMISSION CATEGORY (any tool touching paths outside the worktree), not a tool \
         name — routed to `Config::additional_dirs` instead of a rule entry (paths outside cwd \
         and outside additional_dirs are simply unreachable, a stricter reading); not silently \
         dropped."
            .to_string(),
    );
    translated
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::permissions::rules::{evaluate_command, evaluate_path, PathKind};

    #[test]
    fn later_allow_overriding_earlier_deny_translates_with_warning_and_safe_reading() {
        // Source (last-match): deny X first, then a later allow X — last
        // wins, so source says Allow. First-match deny->ask->allow always
        // finds the deny bucket first, so target-before-safety says Deny —
        // a divergence, but in the SAFE (stricter) direction: kept.
        let source = vec![
            SourceRule {
                pattern: "bash(rm*)".to_string(),
                decision: Decision::Deny,
            },
            SourceRule {
                pattern: "bash(rm*)".to_string(),
                decision: Decision::Allow,
            },
        ];
        let t = translate_last_match_to_first_match(&source);
        assert_eq!(t.rules.allow, vec!["bash(rm*)".to_string()]);
        assert!(t.rules.deny.is_empty());
        assert!(
            t.warnings.is_empty(),
            "identical pattern dedupes to last-wins with no divergence to report: {:?}",
            t.warnings
        );
    }

    #[test]
    fn distinct_overlapping_patterns_detect_unsafe_divergence_and_correct_it() {
        // Source (last-match): "*"=allow declared FIRST, then a more
        // specific deny declared LATER — last-match correctly picks the
        // deny for a probe of the specific pattern. Naive tier-bucketing
        // (deny always scanned first) would ALSO land on deny here — no
        // real divergence for THIS pair. To manufacture a genuine unsafe
        // divergence we need the specific rule to be ask/allow and a wider
        // deny to be positioned so last-match's more-specific-later pattern
        // wins loosely while first-match's deny-tier-always-wins would
        // still pick the (irrelevantly) matching deny. Use: a broad ask on
        // "*" declared first, a specific allow declared later (source =
        // Allow for the specific probe); target buckets: ask=["*"],
        // allow=[specific] -> first-match checks deny(none), then ask("*"
        // matches everything) BEFORE ever reaching the specific allow ->
        // target = Ask, source = Allow: target stricter -> safe, kept.
        let source = vec![
            SourceRule {
                pattern: "*".to_string(),
                decision: Decision::Ask,
            },
            SourceRule {
                pattern: "read(*.env.example)".to_string(),
                decision: Decision::Allow,
            },
        ];
        let t = translate_last_match_to_first_match(&source);
        // Safe direction: target keeps Ask (stricter than source's Allow).
        assert!(t.rules.ask.contains(&"*".to_string()));
        assert!(!t.warnings.is_empty());
    }

    #[test]
    fn opencode_default_policy_matches_the_three_named_deviations() {
        let t = opencode_default_policy();
        // Deviation 1: .env.example ends up ASK, not ALLOW.
        assert_eq!(
            evaluate_path(&t.rules, PathKind::Read, ".env.example", Decision::Allow),
            Decision::Ask,
            "expected the named safe-direction deviation (.env.example -> ask): {:?}",
            t.rules
        );
        // The generic .env / .env.* carve-outs still ask, matching oc.
        assert_eq!(
            evaluate_path(&t.rules, PathKind::Read, ".env", Decision::Allow),
            Decision::Ask
        );
        assert_eq!(
            evaluate_path(&t.rules, PathKind::Read, ".env.local", Decision::Allow),
            Decision::Ask
        );
        // question/plan_enter/plan_exit stay denied.
        assert_eq!(
            t.rules.evaluate("tools_question", None),
            Some(Decision::Deny)
        );
        assert_eq!(t.rules.evaluate("plan_enter", None), Some(Decision::Deny));
        assert_eq!(t.rules.evaluate("plan_exit", None), Some(Decision::Deny));
        // Everything else defaults to opencode's permissive "*": allow.
        assert_eq!(t.rules.evaluate("bash", Some("ls")), Some(Decision::Allow));

        // At least one warning documents the .env.example divergence, plus
        // the two routing-decision warnings (deviations 2 and 3) — three
        // named deviations total, all recorded, none silent.
        assert!(t
            .warnings
            .iter()
            .any(|w| w.contains(".env.example") || w.contains("read(*.env.example)")));
        assert!(t.warnings.iter().any(|w| w.contains("doom_loop")));
        assert!(t.warnings.iter().any(|w| w.contains("external_directory")));
        assert_eq!(
            t.warnings.len(),
            3,
            "expected exactly 3 named deviations: {:?}",
            t.warnings
        );
    }

    #[test]
    fn broader_later_allow_overlapping_an_earlier_specific_deny_warns_but_keeps_the_deny() {
        // Source (last-match): a SPECIFIC deny declared first, a BROADER
        // allow declared later that also glob-matches the specific
        // pattern's own text — last-match honestly says Allow for the
        // specific probe (the broader rule came later and covers it too).
        // First-match deny->ask->allow always checks the deny tier first,
        // so the target says Deny regardless of source position — stricter
        // than source, therefore a SAFE-direction divergence: warn, but
        // keep the (safer) Deny rather than loosen it to match source.
        let source = vec![
            SourceRule {
                pattern: "bash(rm -rf*)".to_string(),
                decision: Decision::Deny,
            },
            SourceRule {
                pattern: "bash(*)".to_string(),
                decision: Decision::Allow,
            },
        ];
        let t = translate_last_match_to_first_match(&source);
        assert_eq!(t.warnings.len(), 1, "{:?}", t.warnings);
        assert!(t.warnings[0].contains("safe-direction"), "{:?}", t.warnings);
        assert!(t.rules.deny.contains(&"bash(rm -rf*)".to_string()));
        assert_eq!(
            evaluate_command(&t.rules, "bash", "rm -rf /", Decision::Allow),
            Decision::Deny,
            "the translated ruleset must still deny the specific dangerous command"
        );
    }

    #[test]
    fn genuinely_non_overlapping_rules_translate_with_no_warnings() {
        let source = vec![
            SourceRule {
                pattern: "bash(rm -rf*)".to_string(),
                decision: Decision::Deny,
            },
            SourceRule {
                pattern: "write_file(*.lock)".to_string(),
                decision: Decision::Ask,
            },
        ];
        let t = translate_last_match_to_first_match(&source);
        assert!(
            t.warnings.is_empty(),
            "genuinely non-overlapping patterns should translate cleanly: {:?}",
            t.warnings
        );
    }
}