import { afterEach, describe, expect, test, vi } from "vitest"; // Mock node:os so tilde-expansion is deterministic across platforms. vi.mock("node:os", () => { const homedir = vi.fn(() => "/mock/home"); return { homedir, default: { homedir }, }; }); // Mock node:fs with an identity realpathSync so canonicalizePath // (used by BashProgram.externalPaths) leaves test paths unchanged and // existing expected-value literals remain accurate across platforms. vi.mock("node:fs", () => ({ realpathSync: (p: string) => p, default: { realpathSync: (p: string) => p }, })); import { extractExternalPathsFromBashCommand as extractWithNormalizer } from "#src/handlers/gates/bash-path-extractor"; import { pathFlavorForPlatform, win32PathFlavor } from "#src/path/path-flavor"; import { PathNormalizer } from "#src/path-normalizer"; import { renderPolicyDenial } from "#src/presentation/agent-renderer"; import type { ExternalPathDisclosure } from "#src/presentation/path-ask-payload"; import { buildBashExternalDirectoryAskPayload } from "#src/presentation/path-ask-payload"; afterEach(() => { vi.restoreAllMocks(); }); // The production facade now takes a PathNormalizer (platform + cwd baked in); // this wrapper preserves the (command, cwd) call shape the suite uses // throughout so the projection-correctness assertions stay unchanged. function extractExternalPathsFromBashCommand( command: string, cwd: string, ): Promise { return extractWithNormalizer( command, new PathNormalizer(pathFlavorForPlatform(process.platform), cwd), ); } describe("extractExternalPathsFromBashCommand", () => { const cwd = "/projects/my-app"; describe("absolute paths", () => { test("detects absolute path outside CWD", async () => { const result = await extractExternalPathsFromBashCommand( "cat /etc/hosts", cwd, ); expect(result).toContain("/etc/hosts"); }); test("detects multiple absolute paths outside CWD", async () => { const result = await extractExternalPathsFromBashCommand( "diff /etc/hosts /var/log/syslog", cwd, ); expect(result).toContain("/etc/hosts"); expect(result).toContain("/var/log/syslog"); }); test("does not flag absolute path within CWD", async () => { const result = await extractExternalPathsFromBashCommand( "cat /projects/my-app/src/index.ts", cwd, ); expect(result).toHaveLength(0); }); }); describe("home-relative paths", () => { test("detects ~/path outside CWD", async () => { const result = await extractExternalPathsFromBashCommand( "cat ~/documents/secret.txt", cwd, ); expect(result).toContain("/mock/home/documents/secret.txt"); }); test("does not flag ~/path that resolves within CWD", async () => { // CWD is under /mock/home for this test const result = await extractExternalPathsFromBashCommand( "cat ~/myproject/file.ts", "/mock/home/myproject", ); expect(result).toHaveLength(0); }); }); describe("dot-dot relative paths", () => { test("detects ../ path that resolves outside CWD", async () => { const result = await extractExternalPathsFromBashCommand( "cat ../../other-project/secrets.env", cwd, ); expect(result).toContain("/other-project/secrets.env"); }); test("does not flag ../ path that stays within CWD", async () => { const result = await extractExternalPathsFromBashCommand( "cat src/../lib/utils.ts", cwd, ); expect(result).toHaveLength(0); }); }); describe("commands within CWD only", () => { test("returns empty for relative paths within CWD", async () => { const result = await extractExternalPathsFromBashCommand( "cat src/index.ts", cwd, ); expect(result).toHaveLength(0); }); test("returns empty for bare command with no path arguments", async () => { const result = await extractExternalPathsFromBashCommand( "git status", cwd, ); expect(result).toHaveLength(0); }); }); describe("flags are skipped", () => { test("does not treat flags as paths", async () => { const result = await extractExternalPathsFromBashCommand( "ls -la --color=auto", cwd, ); expect(result).toHaveLength(0); }); test("detects path after flags", async () => { const result = await extractExternalPathsFromBashCommand( "ls -la /etc/passwd", cwd, ); expect(result).toContain("/etc/passwd"); }); }); describe("env assignments are skipped", () => { test("does not treat FOO=/bar as a path", async () => { const result = await extractExternalPathsFromBashCommand( "FOO=/usr/local/bin command", cwd, ); expect(result).toHaveLength(0); }); }); describe("shell metacharacters split correctly", () => { test("detects path after pipe", async () => { const result = await extractExternalPathsFromBashCommand( "echo hello | tee /tmp/output.txt", cwd, ); expect(result).toContain("/tmp/output.txt"); }); test("detects path after semicolon", async () => { const result = await extractExternalPathsFromBashCommand( "echo done; cat /etc/hosts", cwd, ); expect(result).toContain("/etc/hosts"); }); test("detects path after &&", async () => { const result = await extractExternalPathsFromBashCommand( "true && cat /etc/hosts", cwd, ); expect(result).toContain("/etc/hosts"); }); test("detects path in redirect target", async () => { const result = await extractExternalPathsFromBashCommand( "echo hello > /tmp/out.txt", cwd, ); expect(result).toContain("/tmp/out.txt"); }); }); describe("URLs are skipped", () => { test("does not treat http:// URL as a path", async () => { const result = await extractExternalPathsFromBashCommand( "curl http://example.com/path", cwd, ); expect(result).toHaveLength(0); }); test("does not treat https:// URL as a path", async () => { const result = await extractExternalPathsFromBashCommand( "curl https://example.com/etc/hosts", cwd, ); expect(result).toHaveLength(0); }); }); describe("@scope/package patterns are skipped", () => { test("does not treat @scope/package as a path", async () => { const result = await extractExternalPathsFromBashCommand( "npm install @types/node", cwd, ); expect(result).toHaveLength(0); }); }); describe("quoted strings are ignored", () => { test("does not flag path inside double-quoted string", async () => { const result = await extractExternalPathsFromBashCommand( 'git commit -m "fix: update /etc/hosts handler"', cwd, ); expect(result).toHaveLength(0); }); test("does not flag path inside single-quoted string", async () => { const result = await extractExternalPathsFromBashCommand( "echo 'see /usr/local/docs for info'", cwd, ); expect(result).toHaveLength(0); }); test("still flags unquoted path alongside quoted content", async () => { const result = await extractExternalPathsFromBashCommand( 'cat /etc/hosts && echo "done"', cwd, ); expect(result).toContain("/etc/hosts"); }); test("does not flag path when adjacent quoted segments form one word", async () => { // tree-sitter parses adjacent quoted/unquoted segments as a concatenation node // whose resolved text is 'path is /etc/hosts' (one token, not a path candidate). const result = await extractExternalPathsFromBashCommand( 'echo "path is "/etc/hosts""', cwd, ); expect(result).toHaveLength(0); }); }); describe("safe system paths are filtered", () => { test("does not flag /dev/null in stderr redirect", async () => { const result = await extractExternalPathsFromBashCommand( "command 2>/dev/null", cwd, ); expect(result).toHaveLength(0); }); test("does not flag /dev/null as a redirect target", async () => { const result = await extractExternalPathsFromBashCommand( "echo hello > /dev/null", cwd, ); expect(result).toHaveLength(0); }); test("does not flag /dev/stdin", async () => { const result = await extractExternalPathsFromBashCommand( "cat /dev/stdin", cwd, ); expect(result).toHaveLength(0); }); test("does not flag /dev/stdout", async () => { const result = await extractExternalPathsFromBashCommand( "cat /dev/stdout", cwd, ); expect(result).toHaveLength(0); }); test("does not flag /dev/stderr", async () => { const result = await extractExternalPathsFromBashCommand( "cat /dev/stderr", cwd, ); expect(result).toHaveLength(0); }); test("still flags a real external path alongside /dev/null", async () => { const result = await extractExternalPathsFromBashCommand( "cat /etc/hosts 2>/dev/null", cwd, ); expect(result).toContain("/etc/hosts"); expect(result).not.toContain("/dev/null"); }); test("does not flag /dev/null/subdir (not a safe path)", async () => { const result = await extractExternalPathsFromBashCommand( "cat /dev/null/subdir", cwd, ); expect(result).toContain("/dev/null/subdir"); }); }); describe("bare-slash tokens resolve to external root", () => { test("find / scans the whole filesystem → flags root (#583)", async () => { const result = await extractExternalPathsFromBashCommand("find /", cwd); expect(result).toEqual(["/"]); }); test("find / with search predicates → flags root (#583)", async () => { const result = await extractExternalPathsFromBashCommand( 'find / -path "*/pi-coding-agent/*.d.ts"', cwd, ); expect(result).toContain("/"); }); test("bare / token → flags root", async () => { const result = await extractExternalPathsFromBashCommand("echo /", cwd); expect(result).toEqual(["/"]); }); test("bare // token normalizes to root", async () => { const result = await extractExternalPathsFromBashCommand("echo //", cwd); expect(result).toEqual(["/"]); }); test("bare /// token normalizes to root", async () => { const result = await extractExternalPathsFromBashCommand("echo ///", cwd); expect(result).toEqual(["/"]); }); test("bare / among other args → flags root once", async () => { const result = await extractExternalPathsFromBashCommand( "echo // hello", cwd, ); expect(result).toEqual(["/"]); }); test("flags root alongside another external path", async () => { const result = await extractExternalPathsFromBashCommand( "cat /etc/hosts; echo //", cwd, ); expect(result).toContain("/etc/hosts"); expect(result).toContain("/"); expect(result).toHaveLength(2); }); }); describe("node -e and multi-line commands", () => { test("does not flag path inside single-quoted string in node -e argument", async () => { const result = await extractExternalPathsFromBashCommand( "node -e \"const p = '/etc/hosts'; console.log(p);\"", cwd, ); expect(result).toHaveLength(0); }); test("does not flag path inside multi-line node -e argument", async () => { // Actual newlines inside the double-quoted -e argument. const cmd = "node -e \"\nimport('x').then(() => {\n console.log('/etc/hosts');\n});\n\""; const result = await extractExternalPathsFromBashCommand(cmd, cwd); expect(result).toHaveLength(0); }); test("does not flag path that appears after escaped quote in multi-line node -e argument", async () => { // This is the shape of the command that triggered a prompt during dog-fooding. // The outer \"...\" arg contains both actual newlines and \\" escape sequences, // with /etc/hosts appearing after a \\" boundary. const cmd = [ 'node -e "', "import('shell-quote').then(({ parse }) => {", " const cmd = \\\"cat << 'EOF'\\n/etc/hosts\\nsome content\\nEOF\\\";", " console.log(JSON.stringify(parse(cmd)));", "});", '"', ].join("\n"); const result = await extractExternalPathsFromBashCommand(cmd, cwd); expect(result).toHaveLength(0); }); }); describe("tokenizer edge cases", () => { test("does not flag path inside string when escaped quote is present", async () => { // tree-sitter correctly parses the escaped quote and keeps the path inside the string. const result = await extractExternalPathsFromBashCommand( 'git commit -m "fix: update \\"the /etc/hosts\\" handler"', cwd, ); expect(result).toHaveLength(0); }); test("does not flag path appearing only in a shell comment", async () => { const result = await extractExternalPathsFromBashCommand( "echo hello # /etc/shadow", cwd, ); expect(result).toHaveLength(0); }); test("flags real path before comment but not path inside comment", async () => { const result = await extractExternalPathsFromBashCommand( "cat /etc/hosts # see also /etc/shadow", cwd, ); expect(result).toContain("/etc/hosts"); expect(result).not.toContain("/etc/shadow"); expect(result).toHaveLength(1); }); }); describe("heredoc handling", () => { test("does not flag path inside single-quoted heredoc delimiter", async () => { const cmd = "cat << 'EOF'\n/etc/hosts\nEOF"; const result = await extractExternalPathsFromBashCommand(cmd, cwd); expect(result).toHaveLength(0); }); test("does not flag path inside double-quoted heredoc delimiter", async () => { const cmd = 'cat << "EOF"\n/etc/hosts\nEOF'; const result = await extractExternalPathsFromBashCommand(cmd, cwd); expect(result).toHaveLength(0); }); test("does not flag path inside unquoted heredoc delimiter", async () => { const cmd = "cat << EOF\n/etc/hosts\nEOF"; const result = await extractExternalPathsFromBashCommand(cmd, cwd); expect(result).toHaveLength(0); }); test("flags real path alongside heredoc but not heredoc content", async () => { const cmd = "cat /etc/hosts << 'EOF'\nsome content\nEOF"; const result = await extractExternalPathsFromBashCommand(cmd, cwd); expect(result).toContain("/etc/hosts"); expect(result).toHaveLength(1); }); test("does not flag path inside indented heredoc (<<-)", async () => { const cmd = "cat <<- 'EOF'\n\t/etc/hosts\nEOF"; const result = await extractExternalPathsFromBashCommand(cmd, cwd); expect(result).toHaveLength(0); }); }); describe("defense-in-depth guards with tree-sitter", () => { test("env assignment is a variable_assignment node, not a command argument", async () => { // tree-sitter parses FOO=/usr/local/bin as a variable_assignment node. // The walker skips variable_assignment, so the env-assignment guard in // classifyTokenAsPathCandidate is defense-in-depth. const result = await extractExternalPathsFromBashCommand( "FOO=/usr/local/bin command", cwd, ); expect(result).toHaveLength(0); }); test("URL is a word argument, classifyTokenAsPathCandidate rejects it", async () => { // tree-sitter emits the URL as a plain word argument. // classifyTokenAsPathCandidate's URL pattern must still reject it. const result = await extractExternalPathsFromBashCommand( "curl https://example.com/etc/hosts", cwd, ); expect(result).toHaveLength(0); }); test("flag arguments are word nodes, classifyTokenAsPathCandidate rejects them", async () => { // tree-sitter emits '-la' as a word argument. // classifyTokenAsPathCandidate's flag check must still reject it. const result = await extractExternalPathsFromBashCommand( "ls -la --color=auto", cwd, ); expect(result).toHaveLength(0); }); }); describe("command substitution", () => { test("detects path inside command substitution", async () => { const result = await extractExternalPathsFromBashCommand( "echo $(cat /etc/hosts)", cwd, ); expect(result).toContain("/etc/hosts"); }); test("detects path inside nested command substitution", async () => { const result = await extractExternalPathsFromBashCommand( "echo $(echo $(cat /etc/hosts))", cwd, ); expect(result).toContain("/etc/hosts"); }); test("does not flag command substitution inside single-quoted heredoc", async () => { // Single-quoted heredoc delimiter prevents expansion — content is literal. const cmd = "cat << 'EOF'\n$(cat /etc/hosts)\nEOF"; const result = await extractExternalPathsFromBashCommand(cmd, cwd); expect(result).toHaveLength(0); }); test("detects path in subshell", async () => { const result = await extractExternalPathsFromBashCommand( "(cat /etc/hosts)", cwd, ); expect(result).toContain("/etc/hosts"); }); }); describe("redirect targets", () => { test("detects path in output redirect", async () => { const result = await extractExternalPathsFromBashCommand( "echo hello > /tmp/out.txt", cwd, ); expect(result).toContain("/tmp/out.txt"); }); test("detects path in append redirect", async () => { const result = await extractExternalPathsFromBashCommand( "echo hello >> /tmp/out.txt", cwd, ); expect(result).toContain("/tmp/out.txt"); }); test("detects path in input redirect", async () => { const result = await extractExternalPathsFromBashCommand( "sort < /etc/hosts", cwd, ); expect(result).toContain("/etc/hosts"); }); test("detects path in stderr redirect", async () => { const result = await extractExternalPathsFromBashCommand( "command 2>/tmp/errors.log", cwd, ); expect(result).toContain("/tmp/errors.log"); }); }); describe("deduplication", () => { test("returns deduplicated paths", async () => { const result = await extractExternalPathsFromBashCommand( "cat /etc/hosts; grep foo /etc/hosts", cwd, ); const etcHostsCount = result.filter((p) => p === "/etc/hosts").length; expect(etcHostsCount).toBe(1); }); }); describe("command-aware extraction", () => { describe("sed", () => { test("issue #91 reproducer: sed address pattern is not flagged", async () => { const cmd = `sed -i '' '/source: "tool",/{/origin:/!s/source: "tool",/source: "tool",\n origin: "builtin",/;}' tests/tool-input-preview.test.ts`; const result = await extractExternalPathsFromBashCommand(cmd, cwd); expect(result).toHaveLength(0); }); test("sed script is skipped but file argument is extracted", async () => { const result = await extractExternalPathsFromBashCommand( "sed 's/foo/bar/g' /etc/hosts", cwd, ); expect(result).toContain("/etc/hosts"); }); test("sed address pattern starting with / is skipped", async () => { const result = await extractExternalPathsFromBashCommand( "sed '/pattern/d' /etc/hosts", cwd, ); expect(result).toContain("/etc/hosts"); expect(result).toHaveLength(1); }); test("sed with only in-CWD file returns empty", async () => { const result = await extractExternalPathsFromBashCommand( "sed 's/foo/bar/' src/index.ts", cwd, ); expect(result).toHaveLength(0); }); test("sed -e: script consumed by flag, file extracted", async () => { const result = await extractExternalPathsFromBashCommand( "sed -e 's/foo/bar/' /etc/hosts", cwd, ); expect(result).toContain("/etc/hosts"); expect(result).toHaveLength(1); }); test("sed -n: regular flag does not consume next arg", async () => { const result = await extractExternalPathsFromBashCommand( "sed -n '/pattern/p' /etc/hosts", cwd, ); expect(result).toContain("/etc/hosts"); expect(result).toHaveLength(1); }); test("sed -f: script file is extracted as path", async () => { const result = await extractExternalPathsFromBashCommand( "sed -f /etc/sed-script.sed input.txt", cwd, ); expect(result).toContain("/etc/sed-script.sed"); expect(result).toHaveLength(1); }); test("sed -i '': extension consumed, script skipped, file extracted", async () => { const result = await extractExternalPathsFromBashCommand( "sed -i '' 's/foo/bar/' /etc/hosts", cwd, ); expect(result).toContain("/etc/hosts"); expect(result).toHaveLength(1); }); }); describe("grep", () => { test("grep: pattern skipped, file extracted", async () => { const result = await extractExternalPathsFromBashCommand( "grep '/etc/' /var/log/syslog", cwd, ); expect(result).toContain("/var/log/syslog"); expect(result).toHaveLength(1); }); test("grep -e: pattern consumed by flag, file extracted", async () => { const result = await extractExternalPathsFromBashCommand( "grep -e '/etc/' /var/log/syslog", cwd, ); expect(result).toContain("/var/log/syslog"); expect(result).toHaveLength(1); }); }); describe("awk", () => { test("awk: program skipped, file extracted", async () => { const result = await extractExternalPathsFromBashCommand( "awk '{print}' /etc/hosts", cwd, ); expect(result).toContain("/etc/hosts"); expect(result).toHaveLength(1); }); test("awk -F: separator consumed, program skipped, file extracted", async () => { const result = await extractExternalPathsFromBashCommand( "awk -F: '{print $1}' /etc/passwd", cwd, ); expect(result).toContain("/etc/passwd"); expect(result).toHaveLength(1); }); }); describe("rg", () => { test("rg: pattern skipped, path extracted", async () => { const result = await extractExternalPathsFromBashCommand( "rg '/usr/local' /etc/profile.d/", cwd, ); expect(result).toContain("/etc/profile.d"); expect(result).toHaveLength(1); }); test("rg -e: pattern consumed by flag, path extracted", async () => { const result = await extractExternalPathsFromBashCommand( "rg -e '/usr/local' /etc/profile.d/", cwd, ); expect(result).toContain("/etc/profile.d"); expect(result).toHaveLength(1); }); }); describe("sd", () => { test("sd: both pattern positionals skipped, file extracted", async () => { const result = await extractExternalPathsFromBashCommand( "sd '/usr/local/bin' '/opt/bin' /etc/profile", cwd, ); expect(result).toContain("/etc/profile"); expect(result).toHaveLength(1); }); test("sd with only in-CWD file returns empty", async () => { const result = await extractExternalPathsFromBashCommand( "sd 'foo' 'bar' src/index.ts", cwd, ); expect(result).toHaveLength(0); }); }); describe("unknown commands", () => { test("unknown command: all args go through generic extraction", async () => { const result = await extractExternalPathsFromBashCommand( "some-tool /etc/hosts", cwd, ); expect(result).toContain("/etc/hosts"); }); }); describe("edge cases", () => { test("full-path command invocation: /usr/bin/sed", async () => { const result = await extractExternalPathsFromBashCommand( "/usr/bin/sed 's/foo/bar/' /etc/hosts", cwd, ); expect(result).toContain("/etc/hosts"); expect(result).toHaveLength(1); }); test("-- end-of-flags: all remaining args are positional files", async () => { const result = await extractExternalPathsFromBashCommand( "grep -- '/etc/' /var/log/syslog", cwd, ); // After --, '/etc/' is the pattern positional, /var/log/syslog is a file expect(result).toContain("/var/log/syslog"); expect(result).toHaveLength(1); }); test("redirect target still extracted for pattern-first command", async () => { const result = await extractExternalPathsFromBashCommand( "sed 's/foo/bar/' input.txt > /tmp/output.txt", cwd, ); expect(result).toContain("/tmp/output.txt"); }); test("pipeline: sed piped to cat with external path", async () => { const result = await extractExternalPathsFromBashCommand( "sed 's/foo/bar/' src/file.ts | cat /etc/hosts", cwd, ); expect(result).toContain("/etc/hosts"); expect(result).toHaveLength(1); }); test("command substitution inside pattern-first command", async () => { const result = await extractExternalPathsFromBashCommand( "grep 'pattern' $(cat /etc/file-list)", cwd, ); // /etc/file-list is an argument to cat inside command substitution expect(result).toContain("/etc/file-list"); }); }); describe("known limitations", () => { test("sed -i without extension (GNU sed): /etc/hosts is missed (false negative)", async () => { // GNU sed treats -i as a flag with no argument, so 's/foo/bar/' is // the inline script and /etc/hosts is the input file. Our logic // treats -i as arg-consuming (correct for BSD sed -i ''), so it // consumes the script as the -i extension and /etc/hosts becomes // the first positional — which is skipped as the inline script. // This is a known false negative. The bash permission gate still // applies, so external access is not silently allowed. const result = await extractExternalPathsFromBashCommand( "sed -i 's/foo/bar/' /etc/hosts", cwd, ); // Ideally this would detect /etc/hosts, but position tracking // treats it as the inline script. Assert current behavior so // a future fix can flip this expectation. expect(result).toHaveLength(0); }); }); }); describe("regex patterns are not mistaken for paths", () => { test("grep -v with //.*pattern is not flagged", async () => { const result = await extractExternalPathsFromBashCommand( 'grep -n "glob" src/foo.ts 2>/dev/null | grep -v "//.*glob\\|globalConfig" | head -30', cwd, ); expect(result).toHaveLength(0); }); test("grep -v with //.*pattern without backslash-pipe is not flagged", async () => { const result = await extractExternalPathsFromBashCommand( 'grep -v "//.*foo" file.txt', cwd, ); expect(result).toHaveLength(0); }); test("grep with backslash-pipe alternation is not flagged", async () => { const result = await extractExternalPathsFromBashCommand( 'grep "foo\\|bar\\|baz" src/file.ts', cwd, ); expect(result).toHaveLength(0); }); test("grep -E with ^/ anchored regex is not flagged", async () => { const result = await extractExternalPathsFromBashCommand( 'grep -E "^/usr/bin" file.txt', cwd, ); expect(result).toHaveLength(0); }); test("sed with regex containing slashes is not flagged", async () => { const result = await extractExternalPathsFromBashCommand( 'sed "s/foo.*/bar/g" file.txt', cwd, ); expect(result).toHaveLength(0); }); test("real external paths are still detected alongside regex args", async () => { const result = await extractExternalPathsFromBashCommand( 'grep -v "//.*pattern" /etc/hosts', cwd, ); expect(result).toContain("/etc/hosts"); }); }); describe("leading cd prefix", () => { test("regression: cd to subdir with relative path traversing back into cwd is not flagged", async () => { // Real-world command that triggered a false-positive external-directory // prompt. The relative path .pi/../../../.pi/skills/... resolves inside // cwd when resolved from the cd target, but outside cwd when resolved // from cwd itself. const result = await extractExternalPathsFromBashCommand( 'cd /projects/my-app/packages/sub && grep -n "pattern" .pi/../../../.pi/skills/pkg/SKILL.md', cwd, ); expect(result).toHaveLength(0); }); test("cd to subdir: still flags genuinely external paths after cd", async () => { const result = await extractExternalPathsFromBashCommand( "cd /projects/my-app/packages/sub && cat /etc/hosts", cwd, ); expect(result).toContain("/etc/hosts"); }); test("cd to subdir: relative path that stays inside cwd is not flagged", async () => { const result = await extractExternalPathsFromBashCommand( "cd /projects/my-app/src && cat ../README.md", cwd, ); expect(result).toHaveLength(0); }); test("cd to external dir: subsequent paths resolve against the (external) effective directory", async () => { // The effective directory is tracked faithfully: `cd /tmp` makes /tmp the // base, so the cd target itself is flagged AND ../etc/hosts resolves to // /etc/hosts (both outside cwd). const result = await extractExternalPathsFromBashCommand( "cd /tmp && cat ../etc/hosts", cwd, ); expect(result).toContain("/tmp"); expect(result).toContain("/etc/hosts"); }); test("cd with relative target: resolves inside cwd", async () => { const result = await extractExternalPathsFromBashCommand( 'cd packages/sub && grep -n "x" .pi/../../../.pi/skills/pkg/SKILL.md', cwd, ); expect(result).toHaveLength(0); }); test("no cd prefix: ../ path that escapes cwd is flagged", async () => { // Without the cd prefix, the path resolves against cwd and escapes. const result = await extractExternalPathsFromBashCommand( 'grep -n "pattern" .pi/../../../.pi/skills/pkg/SKILL.md', cwd, ); expect(result.length).toBeGreaterThan(0); }); test("sequential fold: a cd that is not the first command still updates the base", async () => { // The current-shell `cd` folds even though it is not the first command; // ../../outside.txt resolves against /projects/my-app/src → /projects/outside.txt. const result = await extractExternalPathsFromBashCommand( "echo hello && cd /projects/my-app/src && cat ../../outside.txt", cwd, ); expect(result).toContain("/projects/outside.txt"); }); test("cd with semicolon separator", async () => { const result = await extractExternalPathsFromBashCommand( "cd /projects/my-app/src ; cat ../README.md", cwd, ); expect(result).toHaveLength(0); }); }); }); describe("the bash external-directory ask payload", () => { /** The payload the gate emits for a command that reached outside the tree. */ function buildAsk(facts: { command: string; externalPaths: ExternalPathDisclosure[]; cwd: string; agentName?: string; }) { return buildBashExternalDirectoryAskPayload({ ...facts, agentName: facts.agentName ?? null, toolName: "bash", }); } test("carries the command, the boundary, and the path it reached", () => { const payload = buildAsk({ command: "cat /etc/hosts", externalPaths: [{ path: "/etc/hosts" }], cwd: "/projects/my-app", }); expect(payload.request.value).toBe("cat /etc/hosts"); expect(payload.evidence).toEqual([ { label: "working directory", text: "/projects/my-app", detail: null }, { label: "external path", text: "/etc/hosts", detail: null }, ]); }); test("names the requesting agent when one is known", () => { expect( buildAsk({ command: "cat /etc/hosts", externalPaths: [{ path: "/etc/hosts" }], cwd: "/projects/my-app", agentName: "my-agent", }).request.requester.agentName, ).toBe("my-agent"); }); test("lists every external path the command reached", () => { expect( buildAsk({ command: "diff /etc/hosts /var/log/syslog", externalPaths: [{ path: "/etc/hosts" }, { path: "/var/log/syslog" }], cwd: "/projects/my-app", }).evidence, ).toEqual([ { label: "working directory", text: "/projects/my-app", detail: null }, { label: "external path", text: "/etc/hosts", detail: null }, { label: "external path", text: "/var/log/syslog", detail: null }, ]); }); test("binds a resolved target to the path it belongs to", () => { expect( buildAsk({ command: "cat demo-symlink-passwd /etc/hosts", externalPaths: [ { path: "demo-symlink-passwd", resolvedPath: "/etc/passwd" }, { path: "/etc/hosts" }, ], cwd: "/projects/my-app", }).evidence, ).toEqual([ { label: "working directory", text: "/projects/my-app", detail: null }, { label: "external path", text: "demo-symlink-passwd", detail: "/etc/passwd", }, { label: "external path", text: "/etc/hosts", detail: null }, ]); }); }); describe("Windows drive-letter paths (win32 semantics)", () => { const windowsCwd = "C:/projects/app"; async function extractWin32(command: string): Promise { return extractWithNormalizer( command, new PathNormalizer(win32PathFlavor, windowsCwd), ); } test("forward-slash drive path outside CWD is flagged", async () => { const result = await extractWin32("cat C:/Windows/win.ini"); expect(result).not.toHaveLength(0); }); test("different drive letter outside CWD is flagged", async () => { const result = await extractWin32("cat D:/secrets/password.txt"); expect(result).not.toHaveLength(0); }); test("drive path inside CWD is not flagged (known base)", async () => { const result = await extractWin32("cat C:/projects/app/inside.txt"); expect(result).toHaveLength(0); }); test("drive path inside CWD is not flagged after non-literal cd (unknown base)", async () => { // Before the isRelativeCandidate conversion, the hand-rolled startsWith("/") // check treats C:/ as relative on win32, so the unknown-base conservative // branch fires and over-flags an inside-CWD drive path. // After the conversion (!normalizer.isAbsolute), C:/ is absolute on win32 // and routes to the resolved branch with its inside-CWD check. const result = await extractWin32( 'cd "$D" && cat C:/projects/app/inside.txt', ); expect(result).toHaveLength(0); }); }); describe("Git Bash POSIX device paths (win32 semantics)", () => { const windowsCwd = "C:/projects/app"; async function extractWin32(command: string): Promise { return extractWithNormalizer( command, new PathNormalizer(win32PathFlavor, windowsCwd), ); } test("a /dev/null redirect target is not flagged", async () => { const result = await extractWin32("echo hi > /dev/null"); expect(result).toHaveLength(0); }); test("all four safe device paths are excluded", async () => { const result = await extractWin32( "cat /dev/stdin /dev/stdout /dev/stderr /dev/null", ); expect(result).toHaveLength(0); }); }); describe("Git Bash MSYS drive mounts (win32 semantics)", () => { const windowsCwd = "C:/projects/app"; async function extractWin32(command: string): Promise { return extractWithNormalizer( command, new PathNormalizer(win32PathFlavor, windowsCwd), ); } test("an in-cwd drive mount is not flagged", async () => { const result = await extractWin32("cat /c/projects/app/inside.txt"); expect(result).toHaveLength(0); }); test("an out-of-cwd drive mount is flagged as its translated Windows path", async () => { const result = await extractWin32("cat /c/Other/secret.txt"); expect(result).toEqual(["c:\\other\\secret.txt"]); }); test("a different-drive mount is flagged as its translated Windows path", async () => { const result = await extractWin32("cat /d/secrets/pw.txt"); expect(result).toEqual(["d:\\secrets\\pw.txt"]); }); }); describe("Git Bash POSIX absolute paths (win32 semantics)", () => { const windowsCwd = "C:/projects/app"; async function extractWin32(command: string): Promise { return extractWithNormalizer( command, new PathNormalizer(win32PathFlavor, windowsCwd), ); } test("a /tmp path is flagged as typed, not fabricated into C:\\tmp", async () => { const result = await extractWin32("ls /tmp"); expect(result).toEqual(["/tmp"]); }); test("a /usr path is flagged as typed", async () => { const result = await extractWin32("cat /usr/bin/tool"); expect(result).toEqual(["/usr/bin/tool"]); }); test("distinct literal-only POSIX absolutes are not deduplicated together", async () => { const result = await extractWin32("cat /tmp/a /tmp/b"); expect(result).toEqual(["/tmp/a", "/tmp/b"]); }); }); describe("the bash external-directory denial the agent sees", () => { test("names the escaping paths and the boundary, never the command", () => { const result = renderPolicyDenial( buildBashExternalDirectoryAskPayload({ command: "cat /etc/hosts", externalPaths: [{ path: "/etc/hosts" }], cwd: "/projects/my-app", agentName: null, toolName: "bash", matchedPattern: "*", }), null, ); expect(result).toBe( "[pi-permission-system] Denied by policy: 'external_directory' for tool 'bash' for path '/etc/hosts' (rule '*'): outside working directory '/projects/my-app'.", ); }); });