From cfc9a2195701c369b501bfa7e60b113ee314deb5 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 21:01:29 +0000 Subject: [PATCH] test: extract exec approvals shell analysis coverage --- src/infra/exec-approvals-analysis.test.ts | 330 ++++++++++++++++++++++ src/infra/exec-approvals.test.ts | 308 +------------------- 2 files changed, 333 insertions(+), 305 deletions(-) create mode 100644 src/infra/exec-approvals-analysis.test.ts diff --git a/src/infra/exec-approvals-analysis.test.ts b/src/infra/exec-approvals-analysis.test.ts new file mode 100644 index 00000000000..f1083eaa080 --- /dev/null +++ b/src/infra/exec-approvals-analysis.test.ts @@ -0,0 +1,330 @@ +import fs from "node:fs"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; +import { evaluateShellAllowlist, normalizeSafeBins } from "./exec-approvals-allowlist.js"; +import { + analyzeArgvCommand, + analyzeShellCommand, + buildEnforcedShellCommand, + buildSafeBinsShellCommand, +} from "./exec-approvals-analysis.js"; +import { makePathEnv, makeTempDir } from "./exec-approvals-test-helpers.js"; +import type { ExecAllowlistEntry } from "./exec-approvals.js"; + +describe("exec approvals shell analysis", () => { + describe("safe shell command builder", () => { + it("quotes only safeBins segments (leaves other segments untouched)", () => { + if (process.platform === "win32") { + return; + } + + const analysis = analyzeShellCommand({ + command: "rg foo src/*.ts | head -n 5 && echo ok", + cwd: "/tmp", + env: { PATH: "/usr/bin:/bin" }, + platform: process.platform, + }); + expect(analysis.ok).toBe(true); + + const res = buildSafeBinsShellCommand({ + command: "rg foo src/*.ts | head -n 5 && echo ok", + segments: analysis.segments, + segmentSatisfiedBy: [null, "safeBins", null], + platform: process.platform, + }); + expect(res.ok).toBe(true); + expect(res.command).toContain("rg foo src/*.ts"); + expect(res.command).toMatch(/'[^']*\/head' '-n' '5'/); + }); + + it("fails closed on segment metadata mismatch", () => { + const analysis = analyzeShellCommand({ command: "echo ok" }); + expect(analysis.ok).toBe(true); + + expect( + buildSafeBinsShellCommand({ + command: "echo ok", + segments: analysis.segments, + segmentSatisfiedBy: [], + }), + ).toEqual({ ok: false, reason: "segment metadata mismatch" }); + }); + + it("enforces canonical planned argv for every approved segment", () => { + if (process.platform === "win32") { + return; + } + const analysis = analyzeShellCommand({ + command: "env rg -n needle", + cwd: "/tmp", + env: { PATH: "/usr/bin:/bin" }, + platform: process.platform, + }); + expect(analysis.ok).toBe(true); + const res = buildEnforcedShellCommand({ + command: "env rg -n needle", + segments: analysis.segments, + platform: process.platform, + }); + expect(res.ok).toBe(true); + expect(res.command).toMatch(/'(?:[^']*\/)?rg' '-n' 'needle'/); + expect(res.command).not.toContain("'env'"); + }); + }); + + describe("shell parsing", () => { + it("parses pipelines and chained commands", () => { + const cases = [ + { + name: "pipeline", + command: "echo ok | jq .foo", + expectedSegments: ["echo", "jq"], + }, + { + name: "chain", + command: "ls && rm -rf /", + expectedChainHeads: ["ls", "rm"], + }, + ] as const; + for (const testCase of cases) { + const res = analyzeShellCommand({ command: testCase.command }); + expect(res.ok, testCase.name).toBe(true); + if ("expectedSegments" in testCase) { + expect( + res.segments.map((seg) => seg.argv[0]), + testCase.name, + ).toEqual(testCase.expectedSegments); + } else { + expect( + res.chains?.map((chain) => chain[0]?.argv[0]), + testCase.name, + ).toEqual(testCase.expectedChainHeads); + } + } + }); + + it("parses argv commands", () => { + const res = analyzeArgvCommand({ argv: ["/bin/echo", "ok"] }); + expect(res.ok).toBe(true); + expect(res.segments[0]?.argv).toEqual(["/bin/echo", "ok"]); + }); + + it("rejects empty argv commands", () => { + expect(analyzeArgvCommand({ argv: ["", " "] })).toEqual({ + ok: false, + reason: "empty argv", + segments: [], + }); + }); + + it("rejects unsupported shell constructs", () => { + const cases: Array<{ command: string; reason: string; platform?: NodeJS.Platform }> = [ + { command: 'echo "output: $(whoami)"', reason: "unsupported shell token: $()" }, + { command: 'echo "output: `id`"', reason: "unsupported shell token: `" }, + { command: "echo $(whoami)", reason: "unsupported shell token: $()" }, + { command: "cat < input.txt", reason: "unsupported shell token: <" }, + { command: "echo ok > output.txt", reason: "unsupported shell token: >" }, + { + command: "/usr/bin/echo first line\n/usr/bin/echo second line", + reason: "unsupported shell token: \n", + }, + { + command: 'echo "ok $\\\n(id -u)"', + reason: "unsupported shell token: newline", + }, + { + command: 'echo "ok $\\\r\n(id -u)"', + reason: "unsupported shell token: newline", + }, + { + command: "ping 127.0.0.1 -n 1 & whoami", + reason: "unsupported windows shell token: &", + platform: "win32", + }, + ]; + for (const testCase of cases) { + const res = analyzeShellCommand({ command: testCase.command, platform: testCase.platform }); + expect(res.ok).toBe(false); + expect(res.reason).toBe(testCase.reason); + } + }); + + it("accepts inert substitution-like syntax", () => { + const cases = ['echo "output: \\$(whoami)"', "echo 'output: $(whoami)'"]; + for (const command of cases) { + const res = analyzeShellCommand({ command }); + expect(res.ok).toBe(true); + expect(res.segments[0]?.argv[0]).toBe("echo"); + } + }); + + it("accepts safe heredoc forms", () => { + const cases: Array<{ command: string; expectedArgv: string[] }> = [ + { command: "/usr/bin/tee /tmp/file << 'EOF'\nEOF", expectedArgv: ["/usr/bin/tee"] }, + { command: "/usr/bin/tee /tmp/file < segment.argv[0])).toEqual(testCase.expectedArgv); + } + }); + + it("rejects unsafe or malformed heredoc forms", () => { + const cases: Array<{ command: string; reason: string }> = [ + { + command: "/usr/bin/cat < { + const res = analyzeShellCommand({ + command: '"C:\\Program Files\\Tool\\tool.exe" --version', + platform: "win32", + }); + expect(res.ok).toBe(true); + expect(res.segments[0]?.argv).toEqual(["C:\\Program Files\\Tool\\tool.exe", "--version"]); + }); + }); + + describe("shell allowlist (chained commands)", () => { + it("evaluates chained command allowlist scenarios", () => { + const cases: Array<{ + allowlist: ExecAllowlistEntry[]; + command: string; + expectedAnalysisOk: boolean; + expectedAllowlistSatisfied: boolean; + platform?: NodeJS.Platform; + }> = [ + { + allowlist: [{ pattern: "/usr/bin/obsidian-cli" }, { pattern: "/usr/bin/head" }], + command: + "/usr/bin/obsidian-cli print-default && /usr/bin/obsidian-cli search foo | /usr/bin/head", + expectedAnalysisOk: true, + expectedAllowlistSatisfied: true, + }, + { + allowlist: [{ pattern: "/usr/bin/obsidian-cli" }], + command: "/usr/bin/obsidian-cli print-default && /usr/bin/rm -rf /", + expectedAnalysisOk: true, + expectedAllowlistSatisfied: false, + }, + { + allowlist: [{ pattern: "/usr/bin/echo" }], + command: "/usr/bin/echo ok &&", + expectedAnalysisOk: false, + expectedAllowlistSatisfied: false, + }, + { + allowlist: [{ pattern: "/usr/bin/ping" }], + command: "ping 127.0.0.1 -n 1 & whoami", + expectedAnalysisOk: false, + expectedAllowlistSatisfied: false, + platform: "win32", + }, + ]; + for (const testCase of cases) { + const result = evaluateShellAllowlist({ + command: testCase.command, + allowlist: testCase.allowlist, + safeBins: new Set(), + cwd: "/tmp", + platform: testCase.platform, + }); + expect(result.analysisOk).toBe(testCase.expectedAnalysisOk); + expect(result.allowlistSatisfied).toBe(testCase.expectedAllowlistSatisfied); + } + }); + + it("respects quoted chain separators", () => { + const allowlist: ExecAllowlistEntry[] = [{ pattern: "/usr/bin/echo" }]; + const commands = ['/usr/bin/echo "foo && bar"', '/usr/bin/echo "foo\\" && bar"']; + for (const command of commands) { + const result = evaluateShellAllowlist({ + command, + allowlist, + safeBins: new Set(), + cwd: "/tmp", + }); + expect(result.analysisOk).toBe(true); + expect(result.allowlistSatisfied).toBe(true); + } + }); + + it("fails allowlist analysis for shell line continuations", () => { + const result = evaluateShellAllowlist({ + command: 'echo "ok $\\\n(id -u)"', + allowlist: [{ pattern: "/usr/bin/echo" }], + safeBins: new Set(), + cwd: "/tmp", + }); + expect(result.analysisOk).toBe(false); + expect(result.allowlistSatisfied).toBe(false); + }); + + it("satisfies allowlist when bare * wildcard is present", () => { + const dir = makeTempDir(); + const binPath = path.join(dir, "mybin"); + fs.writeFileSync(binPath, "#!/bin/sh\n", { mode: 0o755 }); + const env = makePathEnv(dir); + try { + const result = evaluateShellAllowlist({ + command: "mybin --flag", + allowlist: [{ pattern: "*" }], + safeBins: new Set(), + cwd: dir, + env, + }); + expect(result.analysisOk).toBe(true); + expect(result.allowlistSatisfied).toBe(true); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + + it("normalizes safe bin names", () => { + expect([...normalizeSafeBins([" jq ", "", "JQ", " sort "])]).toEqual(["jq", "sort"]); + }); + }); +}); diff --git a/src/infra/exec-approvals.test.ts b/src/infra/exec-approvals.test.ts index ee92d1011fc..27a1ad088b0 100644 --- a/src/infra/exec-approvals.test.ts +++ b/src/infra/exec-approvals.test.ts @@ -1,309 +1,7 @@ -import fs from "node:fs"; -import path from "node:path"; import { describe, expect, it } from "vitest"; -import { makePathEnv, makeTempDir } from "./exec-approvals-test-helpers.js"; -import { - analyzeArgvCommand, - analyzeShellCommand, - buildEnforcedShellCommand, - buildSafeBinsShellCommand, - evaluateExecAllowlist, - evaluateShellAllowlist, - normalizeSafeBins, -} from "./exec-approvals.js"; - -describe("exec approvals safe shell command builder", () => { - it("quotes only safeBins segments (leaves other segments untouched)", () => { - if (process.platform === "win32") { - return; - } - - const analysis = analyzeShellCommand({ - command: "rg foo src/*.ts | head -n 5 && echo ok", - cwd: "/tmp", - env: { PATH: "/usr/bin:/bin" }, - platform: process.platform, - }); - expect(analysis.ok).toBe(true); - - const res = buildSafeBinsShellCommand({ - command: "rg foo src/*.ts | head -n 5 && echo ok", - segments: analysis.segments, - segmentSatisfiedBy: [null, "safeBins", null], - platform: process.platform, - }); - expect(res.ok).toBe(true); - // Preserve non-safeBins segment raw (glob stays unquoted) - expect(res.command).toContain("rg foo src/*.ts"); - // SafeBins segment is fully quoted and pinned to its resolved absolute path. - expect(res.command).toMatch(/'[^']*\/head' '-n' '5'/); - }); - - it("enforces canonical planned argv for every approved segment", () => { - if (process.platform === "win32") { - return; - } - const analysis = analyzeShellCommand({ - command: "env rg -n needle", - cwd: "/tmp", - env: { PATH: "/usr/bin:/bin" }, - platform: process.platform, - }); - expect(analysis.ok).toBe(true); - const res = buildEnforcedShellCommand({ - command: "env rg -n needle", - segments: analysis.segments, - platform: process.platform, - }); - expect(res.ok).toBe(true); - expect(res.command).toMatch(/'(?:[^']*\/)?rg' '-n' 'needle'/); - expect(res.command).not.toContain("'env'"); - }); -}); - -describe("exec approvals shell parsing", () => { - it("parses pipelines and chained commands", () => { - const cases = [ - { - name: "pipeline", - command: "echo ok | jq .foo", - expectedSegments: ["echo", "jq"], - }, - { - name: "chain", - command: "ls && rm -rf /", - expectedChainHeads: ["ls", "rm"], - }, - ] as const; - for (const testCase of cases) { - const res = analyzeShellCommand({ command: testCase.command }); - expect(res.ok, testCase.name).toBe(true); - if ("expectedSegments" in testCase) { - expect( - res.segments.map((seg) => seg.argv[0]), - testCase.name, - ).toEqual(testCase.expectedSegments); - } else { - expect( - res.chains?.map((chain) => chain[0]?.argv[0]), - testCase.name, - ).toEqual(testCase.expectedChainHeads); - } - } - }); - - it("parses argv commands", () => { - const res = analyzeArgvCommand({ argv: ["/bin/echo", "ok"] }); - expect(res.ok).toBe(true); - expect(res.segments[0]?.argv).toEqual(["/bin/echo", "ok"]); - }); - - it("rejects unsupported shell constructs", () => { - const cases: Array<{ command: string; reason: string; platform?: NodeJS.Platform }> = [ - { command: 'echo "output: $(whoami)"', reason: "unsupported shell token: $()" }, - { command: 'echo "output: `id`"', reason: "unsupported shell token: `" }, - { command: "echo $(whoami)", reason: "unsupported shell token: $()" }, - { command: "cat < input.txt", reason: "unsupported shell token: <" }, - { command: "echo ok > output.txt", reason: "unsupported shell token: >" }, - { - command: "/usr/bin/echo first line\n/usr/bin/echo second line", - reason: "unsupported shell token: \n", - }, - { - command: 'echo "ok $\\\n(id -u)"', - reason: "unsupported shell token: newline", - }, - { - command: 'echo "ok $\\\r\n(id -u)"', - reason: "unsupported shell token: newline", - }, - { - command: "ping 127.0.0.1 -n 1 & whoami", - reason: "unsupported windows shell token: &", - platform: "win32", - }, - ]; - for (const testCase of cases) { - const res = analyzeShellCommand({ command: testCase.command, platform: testCase.platform }); - expect(res.ok).toBe(false); - expect(res.reason).toBe(testCase.reason); - } - }); - - it("accepts inert substitution-like syntax", () => { - const cases = ['echo "output: \\$(whoami)"', "echo 'output: $(whoami)'"]; - for (const command of cases) { - const res = analyzeShellCommand({ command }); - expect(res.ok).toBe(true); - expect(res.segments[0]?.argv[0]).toBe("echo"); - } - }); - - it("accepts safe heredoc forms", () => { - const cases: Array<{ command: string; expectedArgv: string[] }> = [ - { command: "/usr/bin/tee /tmp/file << 'EOF'\nEOF", expectedArgv: ["/usr/bin/tee"] }, - { command: "/usr/bin/tee /tmp/file < segment.argv[0])).toEqual(testCase.expectedArgv); - } - }); - - it("rejects unsafe or malformed heredoc forms", () => { - const cases: Array<{ command: string; reason: string }> = [ - { - command: "/usr/bin/cat < { - const res = analyzeShellCommand({ - command: '"C:\\Program Files\\Tool\\tool.exe" --version', - platform: "win32", - }); - expect(res.ok).toBe(true); - expect(res.segments[0]?.argv).toEqual(["C:\\Program Files\\Tool\\tool.exe", "--version"]); - }); -}); - -describe("exec approvals shell allowlist (chained commands)", () => { - it("evaluates chained command allowlist scenarios", () => { - const cases: Array<{ - allowlist: ExecAllowlistEntry[]; - command: string; - expectedAnalysisOk: boolean; - expectedAllowlistSatisfied: boolean; - platform?: NodeJS.Platform; - }> = [ - { - allowlist: [{ pattern: "/usr/bin/obsidian-cli" }, { pattern: "/usr/bin/head" }], - command: - "/usr/bin/obsidian-cli print-default && /usr/bin/obsidian-cli search foo | /usr/bin/head", - expectedAnalysisOk: true, - expectedAllowlistSatisfied: true, - }, - { - allowlist: [{ pattern: "/usr/bin/obsidian-cli" }], - command: "/usr/bin/obsidian-cli print-default && /usr/bin/rm -rf /", - expectedAnalysisOk: true, - expectedAllowlistSatisfied: false, - }, - { - allowlist: [{ pattern: "/usr/bin/echo" }], - command: "/usr/bin/echo ok &&", - expectedAnalysisOk: false, - expectedAllowlistSatisfied: false, - }, - { - allowlist: [{ pattern: "/usr/bin/ping" }], - command: "ping 127.0.0.1 -n 1 & whoami", - expectedAnalysisOk: false, - expectedAllowlistSatisfied: false, - platform: "win32", - }, - ]; - for (const testCase of cases) { - const result = evaluateShellAllowlist({ - command: testCase.command, - allowlist: testCase.allowlist, - safeBins: new Set(), - cwd: "/tmp", - platform: testCase.platform, - }); - expect(result.analysisOk).toBe(testCase.expectedAnalysisOk); - expect(result.allowlistSatisfied).toBe(testCase.expectedAllowlistSatisfied); - } - }); - - it("respects quoted chain separators", () => { - const allowlist: ExecAllowlistEntry[] = [{ pattern: "/usr/bin/echo" }]; - const commands = ['/usr/bin/echo "foo && bar"', '/usr/bin/echo "foo\\" && bar"']; - for (const command of commands) { - const result = evaluateShellAllowlist({ - command, - allowlist, - safeBins: new Set(), - cwd: "/tmp", - }); - expect(result.analysisOk).toBe(true); - expect(result.allowlistSatisfied).toBe(true); - } - }); - - it("fails allowlist analysis for shell line continuations", () => { - const result = evaluateShellAllowlist({ - command: 'echo "ok $\\\n(id -u)"', - allowlist: [{ pattern: "/usr/bin/echo" }], - safeBins: new Set(), - cwd: "/tmp", - }); - expect(result.analysisOk).toBe(false); - expect(result.allowlistSatisfied).toBe(false); - }); - - it("satisfies allowlist when bare * wildcard is present", () => { - const dir = makeTempDir(); - const binPath = path.join(dir, "mybin"); - fs.writeFileSync(binPath, "#!/bin/sh\n", { mode: 0o755 }); - const env = makePathEnv(dir); - try { - const result = evaluateShellAllowlist({ - command: "mybin --flag", - allowlist: [{ pattern: "*" }], - safeBins: new Set(), - cwd: dir, - env, - }); - expect(result.analysisOk).toBe(true); - expect(result.allowlistSatisfied).toBe(true); - } finally { - fs.rmSync(dir, { recursive: true, force: true }); - } - }); -}); +import { normalizeSafeBins } from "./exec-approvals-allowlist.js"; +import type { ExecAllowlistEntry } from "./exec-approvals.js"; +import { evaluateExecAllowlist } from "./exec-approvals.js"; describe("exec approvals allowlist evaluation", () => { function evaluateAutoAllowSkills(params: {