mirror of
https://bitbucket.org/siakitem/my-pi.git
synced 2026-08-28 08:35:57 +00:00
1131 lines
38 KiB
TypeScript
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'.",
|
|
);
|
|
});
|
|
});
|