Files
my-pi/pi-permission-system/test/bash-external-directory.test.ts

1131 lines
38 KiB
TypeScript

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<string[]> {
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<string[]> {
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<string[]> {
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<string[]> {
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<string[]> {
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'.",
);
});
});