Parent directory

deterministic.test.ts

10630 bytes
  1import { afterAll, beforeAll, expect, test } from "bun:test";
  2import { mkdtempSync, mkdirSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
  3import { tmpdir } from "node:os";
  4import { join } from "node:path";
  5import { checkDeterministic, checkParsedBash } from "../../src/core/deterministic";
  6import { parseBash } from "../../src/core/bash";
  7
  8let cwd: string;
  9beforeAll(() => {
 10  cwd = mkdtempSync(join(tmpdir(), "policy-bash-"));
 11  for (const directory of ["safe/Sources", "mixed", "options", "credentials/nested", "dangling", "injected", "many"])
 12    mkdirSync(join(cwd, directory), { recursive: true });
 13  for (const path of [
 14    "README.md",
 15    "credentials/data.txt",
 16    "dangling/main.swift",
 17    "injected/--include=credentials.go",
 18    "safe/Sources/main.swift",
 19    "safe/Sources/view.swift",
 20    "mixed/main.swift",
 21    "mixed/credentials.swift",
 22    "options/-f.env",
 23    "options/.env",
 24  ])
 25    writeFileSync(join(cwd, path), "");
 26  for (let index = 0; index < 260; index += 1) writeFileSync(join(cwd, "many", `${index}.txt`), "");
 27  symlinkSync(join(cwd, "mixed/credentials.swift"), join(cwd, "safe/linked.swift"));
 28  symlinkSync(join(cwd, "credentials/nested"), join(cwd, "shortcut"));
 29  symlinkSync(join(cwd, "missing"), join(cwd, "dangling/credentials.swift"));
 30  symlinkSync(join(cwd, "credentials/missing"), join(cwd, "future.swift"));
 31  symlinkSync("/etc/policy-engine-missing", join(cwd, "system-output"));
 32  symlinkSync("cycle", join(cwd, "cycle"));
 33  symlinkSync("/", join(cwd, "root-link"));
 34  symlinkSync(process.env.HOME!, join(cwd, "home-link"));
 35});
 36afterAll(() => rmSync(cwd, { recursive: true, force: true }));
 37
 38const allowed = [
 39  "cd safe",
 40  "cd -- safe",
 41  "bash -n script.sh",
 42  "command -v bun node",
 43  'grep -rn "namespace\\|Namespace" README.md',
 44  "echo .env",
 45  "git commit -m 'sudo rm -rf /'",
 46  "cp README.md README.copy",
 47  "bun test",
 48  "sed -n '10,20p' src/core/deterministic.ts",
 49  "find src -type f -name '*.ts'",
 50  "find . -iname '*.md'",
 51  "find -iname '*.md'",
 52  "find ~/dev -name '*.md'",
 53  "rg --files safe",
 54  "rg needle",
 55  "grep -rn needle safe",
 56  "grep -r needle",
 57  "rg -e / safe",
 58  "rg --glob / needle safe",
 59  "rg -n --glob '!**/secrets/**' --glob '!**/.env' needle safe",
 60  "docker compose ps --all",
 61  "systemctl --user is-active pi.service",
 62  "echo value > output.txt",
 63  "cat README.md 2>/dev/null",
 64  "ls safe/Sources/*.swift",
 65  "cd safe && wc -l safe/Sources/*.swift",
 66  "cd safe; cat safe/Sources/*.swift",
 67  "cd safe || cat safe/Sources/*.swift",
 68  "touch safe/Sources/new.swift; cat safe/Sources/*.swift",
 69  "echo value > out | cat safe/Sources/*.swift",
 70  "printf -v LABEL example; ls output/",
 71  "cd /home/pavle/dev/kaiwari/pixel/comfyui && find user -type f | head -20; echo ---; ls output/",
 72  "cd /mnt/media/documents/mama_prevod && sed -n '85,100p' drugi_rad_raw.txt; echo \"=== doc3 60-130 ===\"; sed -n '60,130p' treci_rad_raw.txt | grep -n -i -E \"abstract|key word|©|Received|Accepted\"",
 73  "echo text | wc -c",
 74  "(cd safe && ls Sources/); ls output/",
 75  'rocm-smi --showmemuse 2>&1; echo ---; ps aux | grep -E "comfyui|main.py" | grep -v grep',
 76  "ls -la safe && ls safe/Sources 2>/dev/null | head -30",
 77  'ls -la /mnt/media/documents/mama_prevod/sr/ && echo "---FRONT---" && cat /mnt/media/documents/mama_prevod/sr/01_front.txt 2>/dev/null || echo "no front file"',
 78  'rg -n "arecord|WAV|wav|Rate|Bits|bit" safe/Sources/*.swift 2>/dev/null || ls safe/Sources/; rg -rn "arecord" safe --type swift',
 79  "git -C /home/pavle/dev/kaiwari/server status --short && git -C /home/pavle/dev/kaiwari/server diff && git -C /home/pavle/dev/kaiwari/server branch --show-current",
 80  'cd /home/pavle/dev/kaiwari/ios && find . -name "*.png" -not -path "./.build/*" 2>/dev/null; echo "---all pngs incl build---"; find . -name "*.png" 2>/dev/null | head; echo "---git tracked files---"; git ls-files | grep -iE "png|icon"',
 81];
 82
 83test.each(allowed)("deterministically allows %s", async (command) => {
 84  expect(await checkDeterministic("bash", { command }, cwd)).toMatchObject({ decision: "allow" });
 85});
 86
 87const asks = [
 88  "sudo id",
 89  "cd ~/.ssh",
 90  "cd ~/.ssh && cat config",
 91  "cd -- credentials",
 92  "cd shortcut",
 93  "cd cred*",
 94  "cd safe/../credentials",
 95  "cat .env",
 96  'cat .e"nv"',
 97  "cat ~/.ssh/id_ed25519",
 98  "rg -f .env README.md",
 99  "rg --glob '**/.env' needle safe",
100  "jq --rawfile data .env '.'",
101  "git commit -F.env",
102  "unknown-tool .env",
103  "git reset --hard",
104  "git -C safe reset --hard",
105  "git checkout README.md",
106  "git clean -fd",
107  "git config --system name value",
108  "true || rm -rf /",
109  "unknown-tool; touch /etc/policy-engine",
110  "touch /tmp/../etc/policy-engine",
111  "rm -rf /",
112  "touch /etc/policy-engine",
113  "echo value > .env",
114  "cat < .env",
115  "docker volume rm app-data",
116  "docker compose down -v",
117  "kubectl --namespace default apply -f deploy.yaml",
118  "kubectl rollout restart deployment/api",
119  "kubectl set image deployment/api api=example/api:next",
120  "kubectl certificate approve request",
121  "kubectl auth reconcile -f role.yaml",
122  "aws --profile production iam attach-user-policy --user-name deploy --policy-arn arn",
123  "aws s3api put-object --bucket production --key file --body file",
124  "aws configure set region us-east-1",
125  "wc -l mixed/*.swift",
126  "cat safe/*.swift",
127  "cat shortcut/../data.txt",
128  "cat dangling/*.swift",
129  "cat future.swift",
130  "touch system-output",
131  "cd safe; touch /etc/policy-engine",
132  "command -v node; rm -rf /",
133  "(echo ok; cat .env) | head -20",
134];
135
136test.each(asks)("requires approval for %s", async (command) => {
137  expect(await checkDeterministic("bash", { command }, cwd)).toMatchObject({ decision: "ask" });
138});
139
140test.each([
141  'find / -iname "*.md" 2>/dev/null',
142  "find ~ -name '*.ts'",
143  'find "$HOME" -name "*.md"',
144  "find /tmp/.. -name '*.md'",
145  "find safe / -name '*.md'",
146  "find root-link -name '*.md'",
147  "find home-link -name '*.md'",
148  "rg needle /",
149  "echo ok | rg needle /",
150  "echo ok | rg --files ~",
151  "echo ok | grep -r needle /",
152  "echo ok; rg needle /",
153  "rg --files ~",
154  "rg -e needle ~",
155  "rg needle home-link",
156  "grep -rn needle /",
157  "grep --recursive needle ~",
158  "grep -R -e needle root-link",
159  "echo ok && find / -name '*.md'",
160  "find / -name '*.md' | head -20",
161  "cat .env; find / -name '*.md'",
162])("requires approval for whole-root and whole-home searches: %s", async (command) => {
163  expect(await checkDeterministic("bash", { command }, cwd)).toMatchObject({ decision: "ask" });
164});
165
166test.each(["find . -name '*.md'", "find -name '*.md'", "rg needle", "rg --files", "grep -rn needle"])(
167  "requires approval for implicit whole-directory searches: %s",
168  async (command) => {
169    for (const directory of ["/", process.env.HOME!]) {
170      expect(await checkDeterministic("bash", { command }, directory)).toMatchObject({ decision: "ask" });
171    }
172  },
173);
174
175test.each(["grep -e -r README.md", "grep -- -r README.md", "grep needle"])(
176  "does not treat nonrecursive grep as a whole-directory search: %s",
177  async (command) => {
178    expect(await checkDeterministic("bash", { command }, "/")).toMatchObject({ decision: "allow" });
179  },
180);
181
182test.each([
183  "echo ok | rg -n -A45 -B4 '^#.*bell-features|bell-features ='",
184  "echo ok | rg needle | rg other",
185  "echo ok | (rg needle)",
186])("allows piped rg input at filesystem roots: %s", async (command) => {
187  for (const directory of ["/", process.env.HOME!]) {
188    expect(await checkDeterministic("bash", { command }, directory)).toMatchObject({ decision: "allow" });
189  }
190});
191
192test("reviews ghostty rather than flagging its output as a filesystem search", async () => {
193  expect(
194    await checkDeterministic(
195      "bash",
196      {
197        command:
198          "ghostty +show-config --default --docs 2>/dev/null | rg -n -A45 -B4 '^#.*bell-features|bell-features ='",
199      },
200      "/",
201    ),
202  ).toBeUndefined();
203});
204
205const reviewed = [
206  "rtk ls",
207  'echo "$TARGET"',
208  'echo "$(pwd)"',
209  'rm -rf "$(echo -n /)"',
210  "echo ${VAR:-default}",
211  "echo ~other",
212  "cat **/*.swift",
213  'python -c "print(1)"',
214  'node --eval "console.log(1)"',
215  'php -r "echo 1;"',
216  'deno eval "console.log(1)"',
217  'mix run -e "IO.puts(1)"',
218  "find . -delete",
219  "find . -exec cat {} \\;",
220  "kustomize build --enable-helm deploy",
221  "systemctl --user restart pi.service",
222  "sed -n '1e id' README.md",
223  "cat many/*.txt",
224  "cat cycle",
225  "shopt -s dotglob; cat *",
226  "if true; then cat README.md; fi",
227  "for f in *.swift; do cat $f; done",
228  "cat <<'EOF'\nsudo rm -rf /\nEOF",
229  "echo <(cat .env)",
230  "cat 'unterminated",
231  "cat <> /etc/policy-engine-missing",
232  "printf -v TARGET /etc; touch $TARGET/hosts",
233  "{ cd safe; ls Sources/; }; ls output/",
234  "grep --fi=.env README.md",
235  "bash -n +n script.sh",
236];
237
238test.each(reviewed)("defers unsupported Bash safely for %s", async (command) => {
239  expect((await checkDeterministic("bash", { command }, cwd))?.decision).not.toBe("allow");
240});
241
242test.each([
243  ["injected", "grep -r needle --include=*.go ."],
244  ["options", "grep pattern *"],
245])("checks glob paths in the request directory: %s", async (directory, command) => {
246  expect(await checkDeterministic("bash", { command }, join(cwd, directory))).toMatchObject({ decision: "ask" });
247});
248
249test.each([";", "&&", "||", "|", "\n"])("checks each command across %j", async (operator) => {
250  expect(await checkDeterministic("bash", { command: `cd safe ${operator} ls output/` }, cwd)).toMatchObject({
251    decision: "allow",
252  });
253  expect(await checkDeterministic("bash", { command: `echo ok ${operator} cat .env` }, cwd)).toMatchObject({
254    decision: "ask",
255  });
256  expect(await checkDeterministic("bash", { command: `cat .env ${operator} echo ok` }, cwd)).toMatchObject({
257    decision: "ask",
258  });
259  expect(await checkDeterministic("bash", { command: `echo ok ${operator} unknown-tool` }, cwd)).toBeUndefined();
260  expect(await checkDeterministic("bash", { command: `unknown-tool ${operator} echo ok` }, cwd)).toBeUndefined();
261});
262
263test("checks resolved home paths without reading them", async () => {
264  const home = process.env.HOME!;
265  for (const path of [".docker/config.json", ".netrc", ".aws/credentials"]) {
266    expect(await checkDeterministic("bash", { command: `cat /tmp/..${home}/${path}` }, cwd)).toMatchObject({
267      decision: "ask",
268    });
269  }
270});
271
272test("denies when the parser is unavailable but reviews invalid syntax", async () => {
273  const parsed = await parseBash("echo hello", async () => {
274    throw new Error("unavailable");
275  });
276  expect(checkParsedBash(parsed, cwd)).toMatchObject({ decision: "deny" });
277  expect(checkParsedBash(await parseBash("echo '"), cwd)).toBeUndefined();
278});