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});