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 }, }; }); import { extractExternalPathsFromBashCommand, extractTokensForPathRules, } from "../src/handlers/gates/bash-path-extractor"; import { formatBashExternalDirectoryAskPrompt, formatBashExternalDirectoryDenyReason, } from "../src/handlers/gates/external-directory-messages"; afterEach(() => { vi.restoreAllMocks(); }); 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 are skipped", () => { test("does not flag // token", async () => { const result = await extractExternalPathsFromBashCommand("echo //", cwd); expect(result).toHaveLength(0); }); test("does not flag / token", async () => { const result = await extractExternalPathsFromBashCommand("echo /", cwd); expect(result).toHaveLength(0); }); test("does not flag /// token", async () => { const result = await extractExternalPathsFromBashCommand("echo ///", cwd); expect(result).toHaveLength(0); }); test("does not flag // in echo with other args", async () => { const result = await extractExternalPathsFromBashCommand( "echo // hello", cwd, ); expect(result).toHaveLength(0); }); test("bare-slash guard is still needed: tree-sitter emits / as a word node", async () => { // tree-sitter parses 'echo /' with '/' as a word argument node. // classifyTokenAsPathCandidate must still reject it. // This test documents that the /^\/+$/ guard remains a necessary // defense-in-depth layer even with tree-sitter as the parser. const result = await extractExternalPathsFromBashCommand("echo /", cwd); expect(result).toHaveLength(0); }); test("bare double-slash guard with tree-sitter", async () => { // tree-sitter also emits '//' as a word node — guard must reject it. const result = await extractExternalPathsFromBashCommand("echo //", cwd); expect(result).toHaveLength(0); }); test("still flags real external path alongside //", async () => { const result = await extractExternalPathsFromBashCommand( "cat /etc/hosts; echo //", cwd, ); expect(result).toContain("/etc/hosts"); expect(result).toHaveLength(1); }); }); 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("formatBashExternalDirectoryAskPrompt", () => { test("includes command, external paths, and CWD", () => { const result = formatBashExternalDirectoryAskPrompt( "cat /etc/hosts", ["/etc/hosts"], "/projects/my-app", ); expect(result).toContain("cat /etc/hosts"); expect(result).toContain("/etc/hosts"); expect(result).toContain("/projects/my-app"); }); test("includes agent name when provided", () => { const result = formatBashExternalDirectoryAskPrompt( "cat /etc/hosts", ["/etc/hosts"], "/projects/my-app", "my-agent", ); expect(result).toContain("my-agent"); }); test("shows multiple external paths", () => { const result = formatBashExternalDirectoryAskPrompt( "diff /etc/hosts /var/log/syslog", ["/etc/hosts", "/var/log/syslog"], "/projects/my-app", ); expect(result).toContain("/etc/hosts"); expect(result).toContain("/var/log/syslog"); }); }); describe("formatBashExternalDirectoryDenyReason", () => { test("includes command, external paths, and CWD", () => { const result = formatBashExternalDirectoryDenyReason( "cat /etc/hosts", ["/etc/hosts"], "/projects/my-app", ); expect(result).toContain("cat /etc/hosts"); expect(result).toContain("/etc/hosts"); expect(result).toContain("/projects/my-app"); }); test("includes hard stop hint", () => { const result = formatBashExternalDirectoryDenyReason( "cat /etc/hosts", ["/etc/hosts"], "/projects/my-app", ); expect(result).toContain("Hard stop"); }); test("includes agent name when provided", () => { const result = formatBashExternalDirectoryDenyReason( "cat /etc/hosts", ["/etc/hosts"], "/projects/my-app", "my-agent", ); expect(result).toContain("my-agent"); }); }); describe("extractTokensForPathRules", () => { test("extracts dot-files: cat .env", async () => { const tokens = await extractTokensForPathRules("cat .env"); expect(tokens).toContain(".env"); }); test("extracts relative dot-paths: git add src/.env", async () => { const tokens = await extractTokensForPathRules("git add src/.env"); expect(tokens).toContain("src/.env"); }); test("extracts nothing from plain words: echo hello", async () => { const tokens = await extractTokensForPathRules("echo hello"); expect(tokens).toHaveLength(0); }); test("extracts ./src and skips flags: rm -rf ./src", async () => { const tokens = await extractTokensForPathRules("rm -rf ./src"); expect(tokens).toContain("./src"); expect(tokens).not.toContain("-rf"); }); test("extracts absolute paths: cat /etc/hosts", async () => { const tokens = await extractTokensForPathRules("cat /etc/hosts"); expect(tokens).toContain("/etc/hosts"); }); test("skips URLs: curl https://example.com", async () => { const tokens = await extractTokensForPathRules("curl https://example.com"); expect(tokens).not.toContain("https://example.com"); }); test("extracts slash-containing tokens: cat src/foo.ts", async () => { const tokens = await extractTokensForPathRules("cat src/foo.ts"); expect(tokens).toContain("src/foo.ts"); }); test("skips heredoc content", async () => { const tokens = await extractTokensForPathRules("cat < { const tokens = await extractTokensForPathRules( "npm install @scope/package", ); expect(tokens).not.toContain("@scope/package"); }); test("skips env assignments", async () => { const tokens = await extractTokensForPathRules("FOO=/bar command"); expect(tokens).not.toContain("FOO=/bar"); }); test("skips bare-slash tokens", async () => { const tokens = await extractTokensForPathRules("ls /"); expect(tokens).not.toContain("/"); }); test("extracts redirect targets: echo test > .env", async () => { const tokens = await extractTokensForPathRules("echo test > .env"); expect(tokens).toContain(".env"); }); test("extracts multiple path tokens: cp .env .env.backup", async () => { const tokens = await extractTokensForPathRules("cp .env .env.backup"); expect(tokens).toContain(".env"); expect(tokens).toContain(".env.backup"); }); test("deduplicates repeated tokens", async () => { const tokens = await extractTokensForPathRules("cat .env && rm .env"); const envCount = tokens.filter((t) => t === ".env").length; expect(envCount).toBe(1); }); });