/** * Metamorphic totality property for the bash command gate (#452, A3). * * Wrapping any `ask`/`deny` command in `cd /x && ` must not weaken the * decision — the chain decomposition + most-restrictive-wins, combined with the * fail-closed empty-parse fallback, guarantees a `cd …` prefix can never let a * gated command ride a permissive top-level `*`. * * A focused parametrized table over the real tree-sitter parse + resolve, not a * full fuzzer (tree-sitter fuzzing is brittle); it pins A3 directly. */ import { describe, expect, it } from "vitest"; import { BashProgram } from "#src/access-intent/bash/program"; import { resolveBashCommandCheck } from "#src/handlers/gates/bash-command"; import { pathFlavorForPlatform } from "#src/path/path-flavor"; import { PathNormalizer } from "#src/path-normalizer"; import { PermissionResolver } from "#src/permission-resolver"; import type { ScopedPermissionResolver } from "#src/permission-resolver"; import { SessionRules } from "#src/session-rules"; import type { DenyWithReason, PermissionState } from "#src/types"; import { makeCheckResult } from "#test/helpers/handler-fixtures"; import { createInMemoryManager } from "#test/helpers/manager-harness"; /** Decision strength ordering: deny (2) > ask (1) > allow (0). */ const STRENGTH: Record = { allow: 0, ask: 1, deny: 2, }; /** * Resolver whose decision keys on a command substring → state map. A command * matching no entry resolves to allow (the permissive top-level `*`). */ function makeKeyedResolver( rules: { match: string; state: PermissionState }[], ): ScopedPermissionResolver { return { resolve: (intent) => { const command = intent.kind === "tool" ? ((intent.input as { command?: string }).command ?? "") : ""; const rule = rules.find((r) => command.includes(r.match)); const state: PermissionState = rule?.state ?? "allow"; return makeCheckResult({ state, source: "bash", command }); }, }; } async function decide( command: string, resolver: ScopedPermissionResolver, ): Promise { const program = await BashProgram.parse( command, new PathNormalizer(pathFlavorForPlatform(process.platform), "/cwd"), ); return resolveBashCommandCheck( command, program.commands(), undefined, resolver, ).state; } describe("bash command gate — metamorphic totality", () => { const cases: { bare: string; state: PermissionState }[] = [ { bare: "git push", state: "ask" }, { bare: "git commit -m wip", state: "ask" }, { bare: "rm -rf build", state: "deny" }, { bare: "npm install pkg", state: "deny" }, { bare: "gh pr create", state: "ask" }, ]; for (const { bare, state } of cases) { it(`wrapping "${bare}" in a cd prefix does not weaken its ${state} decision`, async () => { const resolver = makeKeyedResolver([ { match: bare.split(" ")[0] ?? bare, state }, ]); const bareDecision = await decide(bare, resolver); const wrappedDecision = await decide(`cd /repo && ${bare}`, resolver); expect(STRENGTH[wrappedDecision]).toBeGreaterThanOrEqual( STRENGTH[bareDecision], ); expect(wrappedDecision).toBe(state); }); } }); /** * The same totality property for nested execution hosts (#741). * * A command hosted in a redirect target or an interpolating heredoc body really * executes, so hosting a gated command there must not weaken its decision — the * enclosing `echo`/`cat` resolves to a permissive allow, and only the nested * unit carries the restriction. */ describe("bash command gate — nested execution hosts do not weaken", () => { const hosts: { label: string; wrap: (cmd: string) => string }[] = [ { label: "a stdout redirect", wrap: (c) => `echo hi > $(${c})` }, { label: "an appending redirect", wrap: (c) => `echo hi >> $(${c})` }, { label: "a stderr redirect", wrap: (c) => `echo hi 2> \`${c}\`` }, { label: "an input process substitution", wrap: (c) => `cat < <(${c})` }, { label: "an interpolating heredoc", wrap: (c) => `cat < { const resolver = makeKeyedResolver([ { match: bare.split(" ")[0] ?? bare, state }, ]); const bareDecision = await decide(bare, resolver); const hostedDecision = await decide(wrap(bare), resolver); expect(STRENGTH[hostedDecision]).toBeGreaterThanOrEqual( STRENGTH[bareDecision], ); expect(hostedDecision).toBe(state); }); } } it("denies the reported repro when the enclosing command is allowed", async () => { // #741: `echo *` allowed, `rm *` denied — the redirect-hosted `rm` decides. const resolver = makeKeyedResolver([{ match: "rm", state: "deny" }]); expect(await decide('echo "hello world" > $(rm *.txt)', resolver)).toBe( "deny", ); }); it("leaves a quoted heredoc body literal, so it does not gate", async () => { const resolver = makeKeyedResolver([{ match: "rm", state: "deny" }]); expect(await decide("cat <<'EOF'\n$(rm x)\nEOF", resolver)).toBe("allow"); }); }); /** * The same totality property for control-flow bodies. * * A `for`/`while`/`if`/`case`/function was once emitted whole without * descending, so `for d in …; do find …; done` only ever matched patterns * starting with `for` — a `find*: deny` rule was bypassed. The enumerator now * emits the whole statement AND its body commands, so hosting a gated command * inside control flow cannot weaken its decision. */ describe("bash command gate — control-flow bodies do not weaken", () => { /** Real pattern matching over a bash map: `*` allow, the rest deny-with-reason. */ function makePolicyResolver( bash: Record, ): ScopedPermissionResolver { const manager = createInMemoryManager({ global: { permission: { "*": "allow", bash } }, }); return new PermissionResolver(manager, new SessionRules()); } async function checkWith( command: string, resolver: ScopedPermissionResolver, ) { const program = await BashProgram.parse( command, new PathNormalizer(pathFlavorForPlatform(process.platform), "/cwd"), ); return resolveBashCommandCheck(command, program.commands(), undefined, resolver); } it("denies the reported repro: find inside a for-loop command substitution", async () => { const resolver = makePolicyResolver({ "*": "allow", "find*": { action: "deny", reason: "use the find tool" }, "grep*": { action: "deny", reason: "use the grep tool" }, }); const result = await checkWith( 'cd /Users/sia/code/my-pi && ls -1 extensions/ && echo "=== LOC by ext ===" && for d in pi-*/; do n=$(find "$d" -name \'*.ts\' -not -path \'*/node_modules/*\' -not -path \'*/dist/*\' -not -path \'*/test*\' | xargs wc -l 2>/dev/null | tail -1 | awk \'{print $1}\'); echo "$d $n"; done && echo "=== extensions ts ===" && wc -l extensions/*.ts | tail -3', resolver, ); expect(result.state).toBe("deny"); expect(result.command).toBe( 'find "$d" -name \'*.ts\' -not -path \'*/node_modules/*\' -not -path \'*/dist/*\' -not -path \'*/test*\'', ); expect(result.matchedPattern).toBe("find*"); expect(result.reason).toBe("use the find tool"); expect(result.commandContext).toBe("command_substitution"); }); it.each([ 'for d in a b; do find "$d" -name x; done', 'while read l; do grep x "$l"; done < list', "if grep -q x f; then echo y; else echo n; fi", "case $x in a) find . ;; esac", "{ grep x f; }", "f() { grep x f; }", "for a in 1; do for b in 2; do find .; done; done", "x=$(grep foo f)", "export X=$(find .)", "f() { local y=$(rg z); }", "! grep -q x f", "if ! grep -q x f; then :; fi", ])("denies a gated command inside `%s`", async (command) => { const resolver = makePolicyResolver({ "*": "allow", "find*": "deny", "grep*": "deny", "rg*": "deny", }); expect((await checkWith(command, resolver)).state).toBe("deny"); }); it("denies a hard-guard payload hidden in a loop body", async () => { const resolver = makePolicyResolver({ "*": "allow", "rm -rf*": "deny", }); const result = await checkWith("for f in a; do rm -rf /; done", resolver); expect(result.state).toBe("deny"); expect(result.matchedPattern).toBe("rm -rf*"); }); it("allows a control-flow statement whose body commands are allowed", async () => { const resolver = makePolicyResolver({ "*": "allow", "find*": "deny", "grep*": "deny", }); expect( (await checkWith('for d in a b; do echo "$d"; done', resolver)).state, ).toBe("allow"); }); });