Parent directory

deterministic.test.ts

9815 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  "herdr server stop",
114  "echo value > .env",
115  "cat < .env",
116  "docker volume rm app-data",
117  "docker compose down -v",
118  "kubectl --namespace default apply -f deploy.yaml",
119  "kubectl rollout restart deployment/api",
120  "kubectl set image deployment/api api=example/api:next",
121  "kubectl certificate approve request",
122  "kubectl auth reconcile -f role.yaml",
123  "aws --profile production iam attach-user-policy --user-name deploy --policy-arn arn",
124  "aws s3api put-object --bucket production --key file --body file",
125  "aws configure set region us-east-1",
126  "wc -l mixed/*.swift",
127  "cat safe/*.swift",
128  "cat shortcut/../data.txt",
129  "cat dangling/*.swift",
130  "cat future.swift",
131  "touch system-output",
132  "cd safe; touch /etc/policy-engine",
133  "command -v node; rm -rf /",
134  "(echo ok; cat .env) | head -20",
135];
136
137test.each(asks)("requires approval for %s", async (command) => {
138  expect(await checkDeterministic("bash", { command }, cwd)).toMatchObject({ decision: "ask" });
139});
140
141test.each([
142  'find / -iname "*.md" 2>/dev/null',
143  "find ~ -name '*.ts'",
144  'find "$HOME" -name "*.md"',
145  "find /tmp/.. -name '*.md'",
146  "find safe / -name '*.md'",
147  "find root-link -name '*.md'",
148  "find home-link -name '*.md'",
149  "rg needle /",
150  "rg --files ~",
151  "rg -e needle ~",
152  "rg needle home-link",
153  "grep -rn needle /",
154  "grep --recursive needle ~",
155  "grep -R -e needle root-link",
156  "echo ok && find / -name '*.md'",
157  "find / -name '*.md' | head -20",
158  "cat .env; find / -name '*.md'",
159])("requires approval for whole-root and whole-home searches: %s", async (command) => {
160  expect(await checkDeterministic("bash", { command }, cwd)).toMatchObject({ decision: "ask" });
161});
162
163test.each(["find . -name '*.md'", "find -name '*.md'", "rg needle", "rg --files", "grep -rn needle"])(
164  "requires approval for implicit whole-directory searches: %s",
165  async (command) => {
166    for (const directory of ["/", process.env.HOME!]) {
167      expect(await checkDeterministic("bash", { command }, directory)).toMatchObject({ decision: "ask" });
168    }
169  },
170);
171
172test.each(["grep -e -r README.md", "grep -- -r README.md", "grep needle"])(
173  "does not treat nonrecursive grep as a whole-directory search: %s",
174  async (command) => {
175    expect(await checkDeterministic("bash", { command }, "/")).toMatchObject({ decision: "allow" });
176  },
177);
178
179const reviewed = [
180  "rtk ls",
181  'echo "$TARGET"',
182  'echo "$(pwd)"',
183  'rm -rf "$(echo -n /)"',
184  "echo ${VAR:-default}",
185  "echo ~other",
186  "cat **/*.swift",
187  'python -c "print(1)"',
188  'node --eval "console.log(1)"',
189  'php -r "echo 1;"',
190  'deno eval "console.log(1)"',
191  'mix run -e "IO.puts(1)"',
192  "find . -delete",
193  "find . -exec cat {} \\;",
194  "kustomize build --enable-helm deploy",
195  "systemctl --user restart pi.service",
196  "sed -n '1e id' README.md",
197  "cat many/*.txt",
198  "cat cycle",
199  "shopt -s dotglob; cat *",
200  "if true; then cat README.md; fi",
201  "for f in *.swift; do cat $f; done",
202  "cat <<'EOF'\nsudo rm -rf /\nEOF",
203  "echo <(cat .env)",
204  "cat 'unterminated",
205  "cat <> /etc/policy-engine-missing",
206  "printf -v TARGET /etc; touch $TARGET/hosts",
207  "{ cd safe; ls Sources/; }; ls output/",
208  "grep --fi=.env README.md",
209  "bash -n +n script.sh",
210];
211
212test.each(reviewed)("defers unsupported Bash safely for %s", async (command) => {
213  expect((await checkDeterministic("bash", { command }, cwd))?.decision).not.toBe("allow");
214});
215
216test.each([
217  ["injected", "grep -r needle --include=*.go ."],
218  ["options", "grep pattern *"],
219])("checks glob paths in the request directory: %s", async (directory, command) => {
220  expect(await checkDeterministic("bash", { command }, join(cwd, directory))).toMatchObject({ decision: "ask" });
221});
222
223test.each([";", "&&", "||", "|", "\n"])("checks each command across %j", async (operator) => {
224  expect(await checkDeterministic("bash", { command: `cd safe ${operator} ls output/` }, cwd)).toMatchObject({
225    decision: "allow",
226  });
227  expect(await checkDeterministic("bash", { command: `echo ok ${operator} cat .env` }, cwd)).toMatchObject({
228    decision: "ask",
229  });
230  expect(await checkDeterministic("bash", { command: `cat .env ${operator} echo ok` }, cwd)).toMatchObject({
231    decision: "ask",
232  });
233  expect(await checkDeterministic("bash", { command: `echo ok ${operator} unknown-tool` }, cwd)).toBeUndefined();
234  expect(await checkDeterministic("bash", { command: `unknown-tool ${operator} echo ok` }, cwd)).toBeUndefined();
235});
236
237test("checks resolved home paths without reading them", async () => {
238  const home = process.env.HOME!;
239  for (const path of [".docker/config.json", ".netrc", ".aws/credentials"]) {
240    expect(await checkDeterministic("bash", { command: `cat /tmp/..${home}/${path}` }, cwd)).toMatchObject({
241      decision: "ask",
242    });
243  }
244});
245
246test("denies when the parser is unavailable but reviews invalid syntax", async () => {
247  const parsed = await parseBash("echo hello", async () => {
248    throw new Error("unavailable");
249  });
250  expect(checkParsedBash(parsed, cwd)).toMatchObject({ decision: "deny" });
251  expect(checkParsedBash(await parseBash("echo '"), cwd)).toBeUndefined();
252});