SPB Git

spb/khaelor Public

KHAELOR — a terminal-native autonomous engineering agent powered by Anthropic.

TypeScript 82.9% HTML 14.9% CSS 1.1% JavaScript 0.7%
10.6 KB · 285 lines typescript
Raw Blame History
1/**2 * KHAELOR3 * File: tests/permissions/bash-analysis.test.ts4 * Description: Shell-word analysis tests — lexer, classification golden table, arity suggestions, refusal rule, hardline floor.5 *6 * Author: Simon-Pierre Boucher7 * Contact: contact@spboucher.ai8 */910import { describe, expect, it } from "vitest";11import {12  ARITY,13  OBFUSCATION_RISK_NOTE,14  analyzeBashCommand,15  deobfuscateCommand,16  hardlineMatch,17  lexCommand,18  suggestAlwaysPatterns,19} from "../../src/permissions/bash-analysis.js";20import type { BashClassification } from "../../src/permissions/bash-analysis.js";21import type { PathContext } from "../../src/permissions/paths.js";2223const CTX: PathContext = { projectRoot: "/proj", cwd: "/proj", home: "/Users/x" };2425describe("lexCommand", () => {26  it("splits on whitespace and respects quotes", () => {27    const r = lexCommand(`git commit -m "a  b c" -n`);28    expect(r.ok).toBe(true);29    if (r.ok) {30      expect(r.parts).toEqual([["git", "commit", "-m", "a  b c", "-n"]]);31      expect(r.operators).toEqual([]);32    }33  });3435  it("handles single quotes and backslash escapes", () => {36    const r = lexCommand(String.raw`echo 'don t' a\ b`);37    expect(r.ok).toBe(true);38    if (r.ok) expect(r.parts).toEqual([["echo", "don t", "a b"]]);39  });4041  it("recognizes separators and splits parts", () => {42    const r = lexCommand("a && b || c; d | e & f");43    expect(r.ok).toBe(true);44    if (r.ok) {45      expect(r.parts).toEqual([["a"], ["b"], ["c"], ["d"], ["e"], ["f"]]);46      expect(r.operators).toEqual(["&&", "||", ";", "|", "&"]);47    }48  });4950  it("captures write-redirect targets and excludes them from words", () => {51    const r = lexCommand("cat in.txt >> /tmp/out.txt");52    expect(r.ok).toBe(true);53    if (r.ok) {54      expect(r.parts).toEqual([["cat", "in.txt"]]);55      expect(r.redirectTargets).toEqual(["/tmp/out.txt"]);56    }57  });5859  it("treats an attached fd digit as part of the redirect", () => {60    const r = lexCommand("npm test 2>&1");61    expect(r.ok).toBe(true);62    if (r.ok) {63      expect(r.parts).toEqual([["npm", "test"]]);64      expect(r.operators).toEqual([">&"]);65    }66  });6768  it("flags substitution markers anywhere in the string", () => {69    expect(lexCommand("echo $(date)").hasSubstitution).toBe(true);70    expect(lexCommand("echo `date`").hasSubstitution).toBe(true);71    expect(lexCommand("echo ${HOME}").hasSubstitution).toBe(true);72    expect(lexCommand("diff <(a) b").hasSubstitution).toBe(true);73    expect(lexCommand("echo plain").hasSubstitution).toBe(false);74  });7576  it("fails on unterminated quotes", () => {77    expect(lexCommand("echo 'oops").ok).toBe(false);78    expect(lexCommand('echo "oops').ok).toBe(false);79  });80});8182describe("classification golden table", () => {83  const table: [string, BashClassification][] = [84    ["ls -la", "simple"],85    ["git commit -m 'a && b'", "simple"], // quoted operators are data, not operators86    ["npm test", "simple"],87    ["sh -c ls", "simple"], // no operators smuggled — documented V1 limit88    ["git status && git diff", "compound"],89    ["a; b", "compound"],90    ["ls | wc -l", "compound"],91    ["npm run dev &", "compound"],92    ["echo hi > out.txt", "compound"], // redirects leave "simple" (§4.5: cat * > file asks)93    ["npm test 2>&1", "compound"],94    ["line1\nline2", "compound"],95    ["echo $(whoami)", "obfuscated"],96    ["echo `date`", "obfuscated"],97    ["echo ${SECRET}", "obfuscated"],98    ["sh -c 'ls && rm -rf x'", "obfuscated"],99    ["bash -lc \"a | b\"", "obfuscated"],100    ["eval 'a; b'", "obfuscated"],101    ["xargs -I{} sh -c 'x; y'", "obfuscated"], // arg smuggles a `;`102    ["echo 'unterminated", "obfuscated"], // lexer failure fails closed103    ["   ", "obfuscated"], // empty/unclassifiable fails closed104  ];105  for (const [command, expected] of table) {106    it(`classifies ${JSON.stringify(command)} as ${expected}`, () => {107      expect(analyzeBashCommand(command, CTX).classification).toBe(expected);108    });109  }110111  it("carries the verbatim substitution risk note", () => {112    const a = analyzeBashCommand("echo $(whoami)", CTX);113    expect(a.riskNotes).toContain(OBFUSCATION_RISK_NOTE);114  });115});116117describe("always-allow suggestions (arity dictionary)", () => {118  const suggest = (command: string): string[] => {119    const a = analyzeBashCommand(command, CTX);120    return a.alwaysPatterns;121  };122123  it("matches the spec goldens", () => {124    expect(suggest("git push origin main")).toEqual(["git push *"]);125    expect(suggest("npm run dev")).toEqual(["npm run dev", "npm run *"]);126    expect(suggest("git config user.name Bob")).toEqual(["git config user.name *", "git config *"]);127    expect(suggest("npx tsc --noEmit")).toEqual(["npx tsc *"]);128    expect(suggest("ls -la")).toEqual(["ls *"]);129    expect(suggest("git status")).toEqual(["git status"]); // shorter than arity → exact130    expect(suggest("docker compose up")).toEqual(["docker compose up", "docker compose *"]);131  });132133  it("generalizes unknown commands only when they resolve inside the project", () => {134    expect(suggest("./scripts/build.sh --prod")).toEqual(["./scripts/build.sh *"]);135    expect(suggest("/usr/local/bin/foo --x")).toEqual(["/usr/local/bin/foo --x"]); // exact only136    expect(suggest("foobar --x")).toEqual(["foobar --x"]); // bare unknown → exact only137  });138139  it("REFUSES alwaysPatterns for every compound command", () => {140    expect(suggest("git status && git diff")).toEqual([]);141    expect(suggest("ls | wc -l")).toEqual([]);142    expect(suggest("cat a > b")).toEqual([]);143  });144145  it("REFUSES alwaysPatterns for every obfuscated command", () => {146    expect(suggest("echo $(date)")).toEqual([]);147    expect(suggest("sh -c 'a && b'")).toEqual([]);148  });149150  it("exposes the arity dictionary shape from the spec", () => {151    expect(ARITY["gh"]).toBe(3);152    expect(ARITY["git"]).toMatchObject({ "*": 2, config: 3 });153  });154155  it("suggestAlwaysPatterns dedups and handles empty words", () => {156    expect(suggestAlwaysPatterns([], CTX)).toEqual([]);157    expect(suggestAlwaysPatterns(["npm", "run"], CTX)).toEqual(["npm run"]);158  });159});160161describe("network and git detection", () => {162  it("detects network tools by command word", () => {163    expect(analyzeBashCommand("curl https://x", CTX).parts[0]?.network).toBe(true);164    expect(analyzeBashCommand("ssh host uptime", CTX).parts[0]?.network).toBe(true);165    expect(analyzeBashCommand("/usr/bin/wget https://x", CTX).parts[0]?.network).toBe(true);166    expect(analyzeBashCommand("echo curl", CTX).parts[0]?.network).toBe(false);167  });168169  it("package managers stay plain process.execute (§3.4)", () => {170    expect(analyzeBashCommand("npm install", CTX).parts[0]?.network).toBe(false);171    expect(analyzeBashCommand("pip install requests", CTX).parts[0]?.network).toBe(false);172  });173174  it("rsync is network only with a remote argument", () => {175    expect(analyzeBashCommand("rsync -a src/ host:/dst", CTX).parts[0]?.network).toBe(true);176    expect(analyzeBashCommand("rsync -a src/ dst/", CTX).parts[0]?.network).toBe(false);177  });178179  it("detects mutating git subcommands", () => {180    expect(analyzeBashCommand("git push origin main", CTX).parts[0]?.gitModify).toBe(true);181    expect(analyzeBashCommand("git commit -m x", CTX).parts[0]?.gitModify).toBe(true);182    expect(analyzeBashCommand("git checkout -b f", CTX).parts[0]?.gitModify).toBe(true);183    expect(analyzeBashCommand("git status", CTX).parts[0]?.gitModify).toBe(false);184    expect(analyzeBashCommand("git log --oneline", CTX).parts[0]?.gitModify).toBe(false);185  });186187  it("git branch mutates only with delete/move flags; remote only with add/remove/set-url", () => {188    expect(analyzeBashCommand("git branch", CTX).parts[0]?.gitModify).toBe(false);189    expect(analyzeBashCommand("git branch -d old", CTX).parts[0]?.gitModify).toBe(true);190    expect(analyzeBashCommand("git remote -v", CTX).parts[0]?.gitModify).toBe(false);191    expect(analyzeBashCommand("git remote add o url", CTX).parts[0]?.gitModify).toBe(true);192  });193194  it("compound commands union derived capabilities over parts", () => {195    const a = analyzeBashCommand("git status && curl https://x", CTX);196    expect(a.parts.some((p) => p.network)).toBe(true);197    expect(a.parts.some((p) => p.gitModify)).toBe(false);198  });199});200201describe("outside-project filesystem checks (§3.5)", () => {202  it("flags filesystem verbs writing outside the project", () => {203    const a = analyzeBashCommand("rm -rf /var/log/foo", CTX);204    expect(a.parts[0]?.outsideWrites).toEqual(["/var/log/foo"]);205  });206207  it("does not flag writes inside the project", () => {208    expect(analyzeBashCommand("touch notes.txt", CTX).parts[0]?.outsideWrites).toEqual([]);209    expect(analyzeBashCommand("mkdir -p src/new", CTX).parts[0]?.outsideWrites).toEqual([]);210  });211212  it("resolves ../ traversal out of the project", () => {213    const a = analyzeBashCommand("cp a.txt ../outside.txt", CTX);214    expect(a.parts[0]?.outsideWrites).toEqual(["/outside.txt"]);215  });216217  it("flags outside redirect targets", () => {218    const a = analyzeBashCommand("cat README.md > /tmp/out", CTX);219    expect(a.outsideRedirectWrites).toEqual(["/tmp/out"]);220  });221222  it("reads dd of= targets", () => {223    const a = analyzeBashCommand("dd if=in.img of=/tmp/out.img", CTX);224    expect(a.parts[0]?.outsideWrites).toEqual(["/tmp/out.img"]);225  });226});227228describe("hardline deny floor (§4.2)", () => {229  const HOME = "/Users/x";230  const hits: string[] = [231    "rm -rf /",232    'rm -rf "/"',233    "rm -rf /*",234    "rm -fr /",235    "rm -r -f /",236    "rm -rf ~",237    "rm -rf $HOME",238    `rm -rf ${HOME}`,239    "sudo rm -rf /",240    "cd /tmp && rm -rf /",241    "mkfs.ext4 /dev/sda1",242    "dd if=/dev/zero of=/dev/sda",243    "chmod -R 777 /",244    "chown -R nobody /",245    "shutdown -h now",246    "reboot",247    "halt",248    ":(){ :|:& };:",249    "git push --force origin main",250    "git push -f origin master",251    "echo x > /dev/sda",252  ];253  for (const command of hits) {254    it(`blocks ${JSON.stringify(command)}`, () => {255      expect(hardlineMatch(command, HOME)).not.toBeNull();256    });257  }258259  const misses: string[] = [260    "rm -rf ./build",261    "rm -rf node_modules",262    "rm file.txt",263    "chmod -R 755 /",264    "chmod 777 script.sh",265    "git push origin main",266    "git push --force origin feature-branch",267    "echo shutdown",268    "cat /dev/sda1.md", // not a redirect269  ];270  for (const command of misses) {271    it(`does not block ${JSON.stringify(command)}`, () => {272      expect(hardlineMatch(command, HOME)).toBeNull();273    });274  }275276  it("checks de-obfuscated variants (quotes stripped, $HOME expanded)", () => {277    const d = deobfuscateCommand('rm -rf "$HOME"', HOME);278    expect(d.parts).toEqual([["rm", "-rf", HOME]]);279  });280281  it("falls back to naive parsing on lexer failure and still blocks", () => {282    expect(hardlineMatch("rm -rf / 'unterminated", HOME)).not.toBeNull();283  });284});285