/** * KHAELOR * File: tests/permissions/bash-analysis.test.ts * Description: Shell-word analysis tests — lexer, classification golden table, arity suggestions, refusal rule, hardline floor. * * Author: Simon-Pierre Boucher * Contact: contact@spboucher.ai */ import { describe, expect, it } from "vitest"; import { ARITY, OBFUSCATION_RISK_NOTE, analyzeBashCommand, deobfuscateCommand, hardlineMatch, lexCommand, suggestAlwaysPatterns, } from "../../src/permissions/bash-analysis.js"; import type { BashClassification } from "../../src/permissions/bash-analysis.js"; import type { PathContext } from "../../src/permissions/paths.js"; const CTX: PathContext = { projectRoot: "/proj", cwd: "/proj", home: "/Users/x" }; describe("lexCommand", () => { it("splits on whitespace and respects quotes", () => { const r = lexCommand(`git commit -m "a b c" -n`); expect(r.ok).toBe(true); if (r.ok) { expect(r.parts).toEqual([["git", "commit", "-m", "a b c", "-n"]]); expect(r.operators).toEqual([]); } }); it("handles single quotes and backslash escapes", () => { const r = lexCommand(String.raw`echo 'don t' a\ b`); expect(r.ok).toBe(true); if (r.ok) expect(r.parts).toEqual([["echo", "don t", "a b"]]); }); it("recognizes separators and splits parts", () => { const r = lexCommand("a && b || c; d | e & f"); expect(r.ok).toBe(true); if (r.ok) { expect(r.parts).toEqual([["a"], ["b"], ["c"], ["d"], ["e"], ["f"]]); expect(r.operators).toEqual(["&&", "||", ";", "|", "&"]); } }); it("captures write-redirect targets and excludes them from words", () => { const r = lexCommand("cat in.txt >> /tmp/out.txt"); expect(r.ok).toBe(true); if (r.ok) { expect(r.parts).toEqual([["cat", "in.txt"]]); expect(r.redirectTargets).toEqual(["/tmp/out.txt"]); } }); it("treats an attached fd digit as part of the redirect", () => { const r = lexCommand("npm test 2>&1"); expect(r.ok).toBe(true); if (r.ok) { expect(r.parts).toEqual([["npm", "test"]]); expect(r.operators).toEqual([">&"]); } }); it("flags substitution markers anywhere in the string", () => { expect(lexCommand("echo $(date)").hasSubstitution).toBe(true); expect(lexCommand("echo `date`").hasSubstitution).toBe(true); expect(lexCommand("echo ${HOME}").hasSubstitution).toBe(true); expect(lexCommand("diff <(a) b").hasSubstitution).toBe(true); expect(lexCommand("echo plain").hasSubstitution).toBe(false); }); it("fails on unterminated quotes", () => { expect(lexCommand("echo 'oops").ok).toBe(false); expect(lexCommand('echo "oops').ok).toBe(false); }); }); describe("classification golden table", () => { const table: [string, BashClassification][] = [ ["ls -la", "simple"], ["git commit -m 'a && b'", "simple"], // quoted operators are data, not operators ["npm test", "simple"], ["sh -c ls", "simple"], // no operators smuggled — documented V1 limit ["git status && git diff", "compound"], ["a; b", "compound"], ["ls | wc -l", "compound"], ["npm run dev &", "compound"], ["echo hi > out.txt", "compound"], // redirects leave "simple" (§4.5: cat * > file asks) ["npm test 2>&1", "compound"], ["line1\nline2", "compound"], ["echo $(whoami)", "obfuscated"], ["echo `date`", "obfuscated"], ["echo ${SECRET}", "obfuscated"], ["sh -c 'ls && rm -rf x'", "obfuscated"], ["bash -lc \"a | b\"", "obfuscated"], ["eval 'a; b'", "obfuscated"], ["xargs -I{} sh -c 'x; y'", "obfuscated"], // arg smuggles a `;` ["echo 'unterminated", "obfuscated"], // lexer failure fails closed [" ", "obfuscated"], // empty/unclassifiable fails closed ]; for (const [command, expected] of table) { it(`classifies ${JSON.stringify(command)} as ${expected}`, () => { expect(analyzeBashCommand(command, CTX).classification).toBe(expected); }); } it("carries the verbatim substitution risk note", () => { const a = analyzeBashCommand("echo $(whoami)", CTX); expect(a.riskNotes).toContain(OBFUSCATION_RISK_NOTE); }); }); describe("always-allow suggestions (arity dictionary)", () => { const suggest = (command: string): string[] => { const a = analyzeBashCommand(command, CTX); return a.alwaysPatterns; }; it("matches the spec goldens", () => { expect(suggest("git push origin main")).toEqual(["git push *"]); expect(suggest("npm run dev")).toEqual(["npm run dev", "npm run *"]); expect(suggest("git config user.name Bob")).toEqual(["git config user.name *", "git config *"]); expect(suggest("npx tsc --noEmit")).toEqual(["npx tsc *"]); expect(suggest("ls -la")).toEqual(["ls *"]); expect(suggest("git status")).toEqual(["git status"]); // shorter than arity → exact expect(suggest("docker compose up")).toEqual(["docker compose up", "docker compose *"]); }); it("generalizes unknown commands only when they resolve inside the project", () => { expect(suggest("./scripts/build.sh --prod")).toEqual(["./scripts/build.sh *"]); expect(suggest("/usr/local/bin/foo --x")).toEqual(["/usr/local/bin/foo --x"]); // exact only expect(suggest("foobar --x")).toEqual(["foobar --x"]); // bare unknown → exact only }); it("REFUSES alwaysPatterns for every compound command", () => { expect(suggest("git status && git diff")).toEqual([]); expect(suggest("ls | wc -l")).toEqual([]); expect(suggest("cat a > b")).toEqual([]); }); it("REFUSES alwaysPatterns for every obfuscated command", () => { expect(suggest("echo $(date)")).toEqual([]); expect(suggest("sh -c 'a && b'")).toEqual([]); }); it("exposes the arity dictionary shape from the spec", () => { expect(ARITY["gh"]).toBe(3); expect(ARITY["git"]).toMatchObject({ "*": 2, config: 3 }); }); it("suggestAlwaysPatterns dedups and handles empty words", () => { expect(suggestAlwaysPatterns([], CTX)).toEqual([]); expect(suggestAlwaysPatterns(["npm", "run"], CTX)).toEqual(["npm run"]); }); }); describe("network and git detection", () => { it("detects network tools by command word", () => { expect(analyzeBashCommand("curl https://x", CTX).parts[0]?.network).toBe(true); expect(analyzeBashCommand("ssh host uptime", CTX).parts[0]?.network).toBe(true); expect(analyzeBashCommand("/usr/bin/wget https://x", CTX).parts[0]?.network).toBe(true); expect(analyzeBashCommand("echo curl", CTX).parts[0]?.network).toBe(false); }); it("package managers stay plain process.execute (§3.4)", () => { expect(analyzeBashCommand("npm install", CTX).parts[0]?.network).toBe(false); expect(analyzeBashCommand("pip install requests", CTX).parts[0]?.network).toBe(false); }); it("rsync is network only with a remote argument", () => { expect(analyzeBashCommand("rsync -a src/ host:/dst", CTX).parts[0]?.network).toBe(true); expect(analyzeBashCommand("rsync -a src/ dst/", CTX).parts[0]?.network).toBe(false); }); it("detects mutating git subcommands", () => { expect(analyzeBashCommand("git push origin main", CTX).parts[0]?.gitModify).toBe(true); expect(analyzeBashCommand("git commit -m x", CTX).parts[0]?.gitModify).toBe(true); expect(analyzeBashCommand("git checkout -b f", CTX).parts[0]?.gitModify).toBe(true); expect(analyzeBashCommand("git status", CTX).parts[0]?.gitModify).toBe(false); expect(analyzeBashCommand("git log --oneline", CTX).parts[0]?.gitModify).toBe(false); }); it("git branch mutates only with delete/move flags; remote only with add/remove/set-url", () => { expect(analyzeBashCommand("git branch", CTX).parts[0]?.gitModify).toBe(false); expect(analyzeBashCommand("git branch -d old", CTX).parts[0]?.gitModify).toBe(true); expect(analyzeBashCommand("git remote -v", CTX).parts[0]?.gitModify).toBe(false); expect(analyzeBashCommand("git remote add o url", CTX).parts[0]?.gitModify).toBe(true); }); it("compound commands union derived capabilities over parts", () => { const a = analyzeBashCommand("git status && curl https://x", CTX); expect(a.parts.some((p) => p.network)).toBe(true); expect(a.parts.some((p) => p.gitModify)).toBe(false); }); }); describe("outside-project filesystem checks (§3.5)", () => { it("flags filesystem verbs writing outside the project", () => { const a = analyzeBashCommand("rm -rf /var/log/foo", CTX); expect(a.parts[0]?.outsideWrites).toEqual(["/var/log/foo"]); }); it("does not flag writes inside the project", () => { expect(analyzeBashCommand("touch notes.txt", CTX).parts[0]?.outsideWrites).toEqual([]); expect(analyzeBashCommand("mkdir -p src/new", CTX).parts[0]?.outsideWrites).toEqual([]); }); it("resolves ../ traversal out of the project", () => { const a = analyzeBashCommand("cp a.txt ../outside.txt", CTX); expect(a.parts[0]?.outsideWrites).toEqual(["/outside.txt"]); }); it("flags outside redirect targets", () => { const a = analyzeBashCommand("cat README.md > /tmp/out", CTX); expect(a.outsideRedirectWrites).toEqual(["/tmp/out"]); }); it("reads dd of= targets", () => { const a = analyzeBashCommand("dd if=in.img of=/tmp/out.img", CTX); expect(a.parts[0]?.outsideWrites).toEqual(["/tmp/out.img"]); }); }); describe("hardline deny floor (§4.2)", () => { const HOME = "/Users/x"; const hits: string[] = [ "rm -rf /", 'rm -rf "/"', "rm -rf /*", "rm -fr /", "rm -r -f /", "rm -rf ~", "rm -rf $HOME", `rm -rf ${HOME}`, "sudo rm -rf /", "cd /tmp && rm -rf /", "mkfs.ext4 /dev/sda1", "dd if=/dev/zero of=/dev/sda", "chmod -R 777 /", "chown -R nobody /", "shutdown -h now", "reboot", "halt", ":(){ :|:& };:", "git push --force origin main", "git push -f origin master", "echo x > /dev/sda", ]; for (const command of hits) { it(`blocks ${JSON.stringify(command)}`, () => { expect(hardlineMatch(command, HOME)).not.toBeNull(); }); } const misses: string[] = [ "rm -rf ./build", "rm -rf node_modules", "rm file.txt", "chmod -R 755 /", "chmod 777 script.sh", "git push origin main", "git push --force origin feature-branch", "echo shutdown", "cat /dev/sda1.md", // not a redirect ]; for (const command of misses) { it(`does not block ${JSON.stringify(command)}`, () => { expect(hardlineMatch(command, HOME)).toBeNull(); }); } it("checks de-obfuscated variants (quotes stripped, $HOME expanded)", () => { const d = deobfuscateCommand('rm -rf "$HOME"', HOME); expect(d.parts).toEqual([["rm", "-rf", HOME]]); }); it("falls back to naive parsing on lexer failure and still blocks", () => { expect(hardlineMatch("rm -rf / 'unterminated", HOME)).not.toBeNull(); }); });