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