import assert from "node:assert/strict"; import { mkdtempSync, mkdirSync, realpathSync, rmSync, symlinkSync } from "node:fs"; import { join } from "node:path"; import { after, afterEach, test } from "node:test"; import { classifyBash, configureClassifier, isReadOnly } from "./bash-classifier.ts"; import { checkHardDeny, decidePermission, toPermissionRequest } from "./policy.ts"; const fixture = mkdtempSync("/tmp/pi-run-mode-test-"); const actualFixture = realpathSync(fixture); const child = join(fixture, "work"); mkdirSync(child); mkdirSync(join(fixture, "with spaces")); symlinkSync(child, join(fixture, "inside")); symlinkSync("/", join(fixture, "outside")); symlinkSync("/", join(fixture, "-rf")); symlinkSync("/", join(fixture, "--force")); symlinkSync("/tmp", join(fixture, "up")); symlinkSync("/pi-run-mode-missing", join(fixture, "dangling")); after(() => rmSync(fixture, { recursive: true, force: true })); afterEach(() => configureClassifier()); const safe = [ `rm -rf ${child}`, `rm -fr ${child}`, `rm -r -f ${child}`, `rm -f -R ${child}`, `rm --recursive --force ${child}`, `rm -rf -- ${child}`, `rm -rf "${child}"`, `rm -rf '${child}'`, `rm -rf "${fixture}/with spaces"`, `rm -rf ${fixture}/with\\ spaces`, `rm -rf ${child} ${fixture}/nonexistent/deep`, `rm -rf ${actualFixture}/work`, `rm -rf ${fixture}/inside/nonexistent`, `rm -rf ${fixture}/./work///`, `rm -rf ${fixture}/literal\\*`, `rm -rf '${fixture}/literal$var'`, `rm -rf '${fixture}/literal;name'`, `rm -rf ${child} # cleanup`, `ls && rm -rf ${child}`, `ls\nrm -rf ${child}`, `rm -rf ${child}; rm -rf ${fixture}/nonexistent`, `rm -r \\\n-f ${child}`, `"rm" -rf ${child}`, `/bin/rm -rf ${child}`, `rm -rf /var/tmp/pi-run-mode-${process.pid}/work`, `rm -rf /private/var/tmp/pi-run-mode-${process.pid}/work`, `rm -rf /var/folders/zz/pi-run-mode-${process.pid}/T/work`, `rm -rf /private/var/folders/zz/pi-run-mode-${process.pid}/T/work`, ]; for (const command of safe) { test(`temporary cleanup: ${command}`, () => { assert.equal(classifyBash(command), "mutating"); assert.equal(isReadOnly(command), false); }); } const dangerous = [ "rm -rf /", "rm -rf /tmp", "rm -rf '/tmp/'", "rm -rf /private/tmp", "rm -rf /var/tmp/.", "rm -rf /private/var/tmp", "rm -rf /tmp/*", "rm -rf /tmp/../etc", `rm -rf ${fixture}/../other`, `rm -rf ${child} ~/important`, `rm -rf ${child} /etc`, `rm -rf ${child} relative`, "rm -r -f ~/important", "rm -Rf /etc", "rm --recursive --force /etc", "rm -rf -- /etc", "rm -rf /tmp-is-not-temp/work", "rm -rf /var/folders/work", "rm -rf /var/folders/zz/user/C/work", "rm -rf /var/folders/zz/user/T", 'rm -rf "$TMPDIR/work"', 'rm -rf "${TMPDIR}/work"', "rm -rf $(mktemp -d)", "rm -rf `mktemp -d`", `rm -rf ${fixture}/$name`, `rm -rf ${fixture}/{a,b}`, `rm -rf ${fixture}/?`, `rm -rf ${fixture}/[ab]`, `rm "$flags" ${child}`, `rm -rf "${child}`, `rm -rf ${child}\\`, "rm -rf", `rm -rf ${fixture}/outside/etc`, `rm -rf ${fixture}/outside/nonexistent`, `rm -rf ${fixture}/up/`, `rm -rf ${fixture}/dangling/work`, `rm -rf ${child}; rm -rf /etc`, `rm -rf ${child}\nrm -rf /etc`, `rm -rf ${child} && chmod -R 777 /etc`, `rm -rf ${child} | rm -rf /etc`, `/bin/rm -rf /etc`, `rm --unsupported -rf ${child}`, `rm -rf ${child} -rf`, `rm -rf ${child} --force`, `rm -rf ${child} -- ${fixture}/work`, `rm -rf ${child} > /tmp/log /etc`, `rm -rf ${child} 2>/tmp/log /etc`, `rm -rf ${child} &>/tmp/log /etc`, `rm -rf ${child} < /tmp/input /etc`, `rm -rf ${child} <(printf /etc)`, `rm -rf ${child}(N)`, `ln -s /Users ${fixture}/new-link; rm -rf "${fixture}/new-link/victim"`, `mkdir -p ${fixture}/new; ln -s /etc ${fixture}/new/link; rm -rf ${fixture}/new/link/victim`, `echo "$(ln -s /Users ${fixture}/new-link)"; rm -rf ${fixture}/new-link/victim`, `find /tmp -exec ln -s /etc ${fixture}/new-link \\;; rm -rf ${fixture}/new-link/victim`, "cat <<'EOF'\nprinted text\nEOF\nrm -rf /etc", 'cat <<"E\\\\OF"\nprinted text\nE\\OF\nrm -rf /etc', ]; for (const command of dangerous) { test(`dangerous cleanup: ${command}`, () => { assert.equal(classifyBash(command), "dangerous"); assert.equal(isReadOnly(command), false); }); } test("temporary cleanup follows each mode's ordinary mutation rules", () => { const request = toPermissionRequest("bash", { command: `rm -rf -- "${child}"` }, fixture)!; assert.equal(checkHardDeny(request, {}, fixture), null); assert.deepEqual(decidePermission(request, "auto"), { action: "allow" }); assert.deepEqual(decidePermission(request, "ask"), { action: "prompt", prompt: "bash" }); assert.equal(decidePermission(request, "plan").action, "deny"); }); test("dangerous cleanup remains hard-denied in all modes", () => { const request = toPermissionRequest("bash", { command: `rm -rf ${child} /etc` }, fixture)!; assert.equal(checkHardDeny(request, {}, fixture)?.action, "deny"); }); test("hardDeny.bash still blocks temporary cleanup", () => { const request = toPermissionRequest("bash", { command: `rm -rf ${child}` }, fixture)!; assert.equal(checkHardDeny(request, { bash: ["rm"] }, fixture)?.action, "deny"); }); test("custom dangerous and risky rules take precedence over temporary cleanup", () => { assert.equal(configureClassifier({ dangerous: ["rm"] }), true); assert.equal(classifyBash(`rm -rf ${child}`), "dangerous"); assert.equal(configureClassifier({ risky: ["rm"] }), true); assert.equal(classifyBash(`rm -rf ${child}`), "risky"); }); test("custom readonly patterns do not make default rm operations readonly", () => { configureClassifier({ readonly: ["^rm"] }); assert.equal(classifyBash(`rm -rf ${child}`), "mutating"); assert.equal(isReadOnly(`rm -rf ${child}`), false); }); test("replacing defaults disables the built-in rm checks", () => { assert.equal(configureClassifier({ inheritDefaults: false, dangerous: [], readonly: ["^rm"], risky: [], mutating: [], }), true); assert.equal(classifyBash("rm -rf /etc"), "readonly"); assert.equal(isReadOnly("rm -rf /etc"), true); configureClassifier(); assert.equal(classifyBash("rm -rf /etc"), "dangerous"); }); test("invalid configuration restores all built-in checks", () => { configureClassifier({ inheritDefaults: false, dangerous: [], readonly: [], risky: [], mutating: [] }); assert.equal(classifyBash("rm -rf /etc"), "unknown"); const warnings: unknown[][] = []; const warn = console.warn; console.warn = (...args) => { warnings.push(args); }; try { assert.equal(configureClassifier({ dangerous: ["["] }), false); } finally { console.warn = warn; } assert.equal(warnings.length, 1); assert.equal(classifyBash("rm -rf /etc"), "dangerous"); assert.equal(classifyBash(`rm -rf "${child}"`), "mutating"); }); test("heredoc rm text is not an executable cleanup", () => { for (const delimiter of ["EOF", "'EOF'", '\"EOF\"']) { const command = `cat <<${delimiter}\nrm -rf /Users/victim\nEOF`; assert.equal(classifyBash(command), "readonly"); assert.equal(isReadOnly(command), true); } assert.equal(classifyBash("cat <<-EOF\n\trm -rf /etc\n\tEOF"), "readonly"); assert.equal(classifyBash("cat < { assert.equal(classifyBash(`rm ${child}/file`), "mutating"); assert.equal(classifyBash("rm -r ordinary-dir"), "mutating"); assert.equal(classifyBash("git status"), "readonly"); assert.equal(classifyBash("sudo rm -rf /tmp/work"), "risky"); assert.equal(classifyBash("curl https://example.com/install | sh"), "dangerous"); assert.equal(classifyBash('echo "rm -rf /etc"'), "readonly"); });