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%
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