@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,414 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Agent Wall Policy Engine
|
|
3
|
+
*
|
|
4
|
+
* Evaluates tool calls against YAML-defined rules.
|
|
5
|
+
* Rules are matched in order — first match wins.
|
|
6
|
+
* If no rule matches, the default action applies.
|
|
7
|
+
*
|
|
8
|
+
* Security hardening:
|
|
9
|
+
* - Path normalization (resolves ../ before matching)
|
|
10
|
+
* - Unicode NFC normalization (prevents homoglyph bypass)
|
|
11
|
+
* - Zero-trust "strict" mode (default deny, only explicit allows pass)
|
|
12
|
+
* - Safe regex construction (prevents ReDoS in argument matching)
|
|
13
|
+
* - Deep argument scanning across all string values
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import * as path from "node:path";
|
|
17
|
+
import { minimatch } from "minimatch";
|
|
18
|
+
import type { ToolCallParams } from "./types.js";
|
|
19
|
+
import type { InjectionDetectorConfig } from "./injection-detector.js";
|
|
20
|
+
import type { EgressControlConfig } from "./egress-control.js";
|
|
21
|
+
import type { KillSwitchConfig } from "./kill-switch.js";
|
|
22
|
+
import type { ChainDetectorConfig } from "./chain-detector.js";
|
|
23
|
+
|
|
24
|
+
// ── Rule Types ──────────────────────────────────────────────────────
|
|
25
|
+
|
|
26
|
+
export type RuleAction = "allow" | "deny" | "prompt";
|
|
27
|
+
|
|
28
|
+
/** Policy mode: "standard" (backward-compatible) or "strict" (zero-trust) */
|
|
29
|
+
export type PolicyMode = "standard" | "strict";
|
|
30
|
+
|
|
31
|
+
export interface RuleMatch {
|
|
32
|
+
/** Glob patterns matched against string values in arguments */
|
|
33
|
+
arguments?: Record<string, string>;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface RateLimitConfig {
|
|
37
|
+
maxCalls: number;
|
|
38
|
+
windowSeconds: number;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface PolicyRule {
|
|
42
|
+
name: string;
|
|
43
|
+
/** Glob pattern for tool name(s). Use "|" to separate multiple patterns. */
|
|
44
|
+
tool: string;
|
|
45
|
+
/** Optional argument matching */
|
|
46
|
+
match?: RuleMatch;
|
|
47
|
+
/** What to do when this rule matches */
|
|
48
|
+
action: RuleAction;
|
|
49
|
+
/** Human-readable message shown when rule triggers */
|
|
50
|
+
message?: string;
|
|
51
|
+
/** Rate limiting for this rule's scope */
|
|
52
|
+
rateLimit?: RateLimitConfig;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Security modules configuration. */
|
|
56
|
+
export interface SecurityConfig {
|
|
57
|
+
/** Prompt injection detection */
|
|
58
|
+
injectionDetection?: InjectionDetectorConfig;
|
|
59
|
+
/** URL/SSRF egress control */
|
|
60
|
+
egressControl?: EgressControlConfig;
|
|
61
|
+
/** Emergency kill switch */
|
|
62
|
+
killSwitch?: KillSwitchConfig;
|
|
63
|
+
/** Tool call chain/sequence detection */
|
|
64
|
+
chainDetection?: ChainDetectorConfig;
|
|
65
|
+
/** Enable HMAC-SHA256 audit log signing */
|
|
66
|
+
signing?: boolean;
|
|
67
|
+
/** Signing key (auto-generated per session if not provided) */
|
|
68
|
+
signingKey?: string;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export interface PolicyConfig {
|
|
72
|
+
version: number;
|
|
73
|
+
/** Policy mode: "standard" (default) or "strict" (zero-trust deny-by-default) */
|
|
74
|
+
mode?: PolicyMode;
|
|
75
|
+
/** Default action when no rule matches (overridden to "deny" in strict mode) */
|
|
76
|
+
defaultAction?: RuleAction;
|
|
77
|
+
/** Global rate limit across all tools */
|
|
78
|
+
globalRateLimit?: RateLimitConfig;
|
|
79
|
+
/** Response scanning configuration */
|
|
80
|
+
responseScanning?: ResponseScannerPolicyConfig;
|
|
81
|
+
/** Security modules (injection detection, egress control, kill switch, chain detection) */
|
|
82
|
+
security?: SecurityConfig;
|
|
83
|
+
/** Ordered list of rules — first match wins */
|
|
84
|
+
rules: PolicyRule[];
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Response scanning section in the YAML policy. */
|
|
88
|
+
export interface ResponseScannerPolicyConfig {
|
|
89
|
+
enabled?: boolean;
|
|
90
|
+
maxResponseSize?: number;
|
|
91
|
+
oversizeAction?: "block" | "redact";
|
|
92
|
+
detectSecrets?: boolean;
|
|
93
|
+
detectPII?: boolean;
|
|
94
|
+
/** Action for base64 blob detection: "pass" (default), "redact", or "block" */
|
|
95
|
+
base64Action?: "pass" | "redact" | "block";
|
|
96
|
+
/** Maximum number of custom patterns allowed (default: 100) */
|
|
97
|
+
maxPatterns?: number;
|
|
98
|
+
patterns?: Array<{
|
|
99
|
+
name: string;
|
|
100
|
+
pattern: string;
|
|
101
|
+
flags?: string;
|
|
102
|
+
action: "pass" | "redact" | "block";
|
|
103
|
+
message?: string;
|
|
104
|
+
category?: string;
|
|
105
|
+
}>;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// ── Policy Evaluation Result ────────────────────────────────────────
|
|
109
|
+
|
|
110
|
+
export interface PolicyVerdict {
|
|
111
|
+
action: RuleAction;
|
|
112
|
+
rule: string | null; // Name of the matched rule, or null for default
|
|
113
|
+
message: string;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// ── Rate Limiter ────────────────────────────────────────────────────
|
|
117
|
+
|
|
118
|
+
interface RateLimitBucket {
|
|
119
|
+
timestamps: number[];
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
class RateLimiter {
|
|
123
|
+
private buckets = new Map<string, RateLimitBucket>();
|
|
124
|
+
|
|
125
|
+
check(key: string, config: RateLimitConfig): boolean {
|
|
126
|
+
const now = Date.now();
|
|
127
|
+
const windowMs = config.windowSeconds * 1000;
|
|
128
|
+
const bucket = this.buckets.get(key) ?? { timestamps: [] };
|
|
129
|
+
|
|
130
|
+
// Prune old entries outside the window
|
|
131
|
+
bucket.timestamps = bucket.timestamps.filter((t) => now - t < windowMs);
|
|
132
|
+
|
|
133
|
+
if (bucket.timestamps.length >= config.maxCalls) {
|
|
134
|
+
return false; // Rate limit exceeded
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
bucket.timestamps.push(now);
|
|
138
|
+
this.buckets.set(key, bucket);
|
|
139
|
+
return true;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
reset(): void {
|
|
143
|
+
this.buckets.clear();
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// ── Normalization Utilities ─────────────────────────────────────────
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Normalize a string value for secure matching:
|
|
151
|
+
* 1. Unicode NFC normalization (prevents homoglyph/decomposition bypass)
|
|
152
|
+
* 2. Path normalization for path-like values (resolves ../ traversal)
|
|
153
|
+
*/
|
|
154
|
+
function normalizeValue(value: string): string {
|
|
155
|
+
// Unicode NFC normalization
|
|
156
|
+
let normalized = value.normalize("NFC");
|
|
157
|
+
|
|
158
|
+
// Path normalization: if it looks like a file path, resolve traversals
|
|
159
|
+
if (looksLikePath(normalized)) {
|
|
160
|
+
normalized = normalizePath(normalized);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return normalized;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Heuristic: does this string look like a file path?
|
|
168
|
+
*/
|
|
169
|
+
function looksLikePath(value: string): boolean {
|
|
170
|
+
return (
|
|
171
|
+
value.includes("/") ||
|
|
172
|
+
value.includes("\\") ||
|
|
173
|
+
value.startsWith(".") ||
|
|
174
|
+
value.startsWith("~")
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Normalize a file path: resolve ../ and ./ components, normalize separators.
|
|
180
|
+
* Does NOT resolve to absolute path (preserves relative paths).
|
|
181
|
+
*/
|
|
182
|
+
function normalizePath(p: string): string {
|
|
183
|
+
// Normalize separators to forward slash
|
|
184
|
+
let normalized = p.replace(/\\/g, "/");
|
|
185
|
+
|
|
186
|
+
// Use path.posix.normalize to resolve ../ and ./
|
|
187
|
+
normalized = path.posix.normalize(normalized);
|
|
188
|
+
|
|
189
|
+
return normalized;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Safe glob-to-regex conversion with ReDoS protection.
|
|
194
|
+
* Limits the resulting regex complexity.
|
|
195
|
+
*/
|
|
196
|
+
function safeGlobToRegex(pattern: string): RegExp | null {
|
|
197
|
+
// Reject patterns that are too long (ReDoS vector)
|
|
198
|
+
if (pattern.length > 500) return null;
|
|
199
|
+
|
|
200
|
+
const escaped = pattern
|
|
201
|
+
.replace(/[.+^${}()|[\]\\]/g, "\\$&")
|
|
202
|
+
.replace(/\*/g, ".*")
|
|
203
|
+
.replace(/\?/g, ".");
|
|
204
|
+
|
|
205
|
+
try {
|
|
206
|
+
return new RegExp(`^${escaped}$`, "i");
|
|
207
|
+
} catch {
|
|
208
|
+
return null;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// ── Common argument key aliases ─────────────────────────────────────
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Map of common argument key aliases for path-like values.
|
|
216
|
+
* If a rule specifies "path", it also matches "file", "filepath", etc.
|
|
217
|
+
*/
|
|
218
|
+
const PATH_KEY_ALIASES: Record<string, string[]> = {
|
|
219
|
+
path: ["file", "filepath", "file_path", "filename", "file_name", "target", "source", "destination", "dest", "src", "uri", "url"],
|
|
220
|
+
command: ["cmd", "shell", "exec", "script", "run"],
|
|
221
|
+
content: ["text", "body", "data", "input", "message"],
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Get all alias keys for a given argument key.
|
|
226
|
+
*/
|
|
227
|
+
function getKeyAliases(key: string): string[] {
|
|
228
|
+
const lowerKey = key.toLowerCase();
|
|
229
|
+
// Direct aliases
|
|
230
|
+
const aliases = PATH_KEY_ALIASES[lowerKey];
|
|
231
|
+
if (aliases) return [lowerKey, ...aliases];
|
|
232
|
+
|
|
233
|
+
// Reverse lookup: if key is an alias, find its canonical form
|
|
234
|
+
for (const [canonical, aliasList] of Object.entries(PATH_KEY_ALIASES)) {
|
|
235
|
+
if (aliasList.includes(lowerKey)) {
|
|
236
|
+
return [canonical, ...aliasList];
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
return [lowerKey];
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// ── Policy Engine ───────────────────────────────────────────────────
|
|
244
|
+
|
|
245
|
+
export class PolicyEngine {
|
|
246
|
+
private config: PolicyConfig;
|
|
247
|
+
private rateLimiter = new RateLimiter();
|
|
248
|
+
|
|
249
|
+
constructor(config: PolicyConfig) {
|
|
250
|
+
this.config = config;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Update the policy configuration (e.g., after file reload).
|
|
255
|
+
*/
|
|
256
|
+
updateConfig(config: PolicyConfig): void {
|
|
257
|
+
this.config = config;
|
|
258
|
+
this.rateLimiter.reset();
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Evaluate a tool call against the policy rules.
|
|
263
|
+
* Returns the verdict: allow, deny, or prompt.
|
|
264
|
+
*/
|
|
265
|
+
evaluate(toolCall: ToolCallParams): PolicyVerdict {
|
|
266
|
+
// 1. Check global rate limit
|
|
267
|
+
if (this.config.globalRateLimit) {
|
|
268
|
+
if (!this.rateLimiter.check("__global__", this.config.globalRateLimit)) {
|
|
269
|
+
return {
|
|
270
|
+
action: "deny",
|
|
271
|
+
rule: "__global_rate_limit__",
|
|
272
|
+
message: `Global rate limit exceeded (${this.config.globalRateLimit.maxCalls} calls per ${this.config.globalRateLimit.windowSeconds}s)`,
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// 2. Evaluate rules in order — first match wins
|
|
278
|
+
for (const rule of this.config.rules) {
|
|
279
|
+
if (this.matchesRule(rule, toolCall)) {
|
|
280
|
+
// Check per-rule rate limit
|
|
281
|
+
if (rule.rateLimit) {
|
|
282
|
+
if (!this.rateLimiter.check(`rule:${rule.name}`, rule.rateLimit)) {
|
|
283
|
+
return {
|
|
284
|
+
action: "deny",
|
|
285
|
+
rule: rule.name,
|
|
286
|
+
message:
|
|
287
|
+
rule.message ??
|
|
288
|
+
`Rate limit exceeded for rule "${rule.name}" (${rule.rateLimit.maxCalls} per ${rule.rateLimit.windowSeconds}s)`,
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
return {
|
|
294
|
+
action: rule.action,
|
|
295
|
+
rule: rule.name,
|
|
296
|
+
message:
|
|
297
|
+
rule.message ??
|
|
298
|
+
`${rule.action === "deny" ? "Blocked" : rule.action === "prompt" ? "Approval required" : "Allowed"} by rule "${rule.name}"`,
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// 3. No rule matched — use default action
|
|
304
|
+
// In strict (zero-trust) mode, default is always "deny"
|
|
305
|
+
const isStrict = this.config.mode === "strict";
|
|
306
|
+
const defaultAction = isStrict ? "deny" : (this.config.defaultAction ?? "prompt");
|
|
307
|
+
return {
|
|
308
|
+
action: defaultAction,
|
|
309
|
+
rule: null,
|
|
310
|
+
message: isStrict
|
|
311
|
+
? `Zero-trust mode: no matching allow rule. Denied by default.`
|
|
312
|
+
: `No matching rule. Default action: ${defaultAction}`,
|
|
313
|
+
};
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* Check if a rule matches a tool call.
|
|
318
|
+
*/
|
|
319
|
+
private matchesRule(rule: PolicyRule, toolCall: ToolCallParams): boolean {
|
|
320
|
+
// Match tool name — normalize with NFC
|
|
321
|
+
const normalizedToolName = toolCall.name.normalize("NFC");
|
|
322
|
+
if (!this.matchToolName(rule.tool, normalizedToolName)) {
|
|
323
|
+
return false;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// Match arguments if specified
|
|
327
|
+
if (rule.match?.arguments) {
|
|
328
|
+
if (!this.matchArguments(rule.match.arguments, toolCall.arguments ?? {})) {
|
|
329
|
+
return false;
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
return true;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* Match a tool name against a pattern.
|
|
338
|
+
* Pattern can contain "|" for multiple alternatives.
|
|
339
|
+
*/
|
|
340
|
+
private matchToolName(pattern: string, toolName: string): boolean {
|
|
341
|
+
const patterns = pattern.split("|").map((p) => p.trim());
|
|
342
|
+
return patterns.some((p) => minimatch(toolName, p));
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* Match rule argument patterns against actual tool arguments.
|
|
347
|
+
* Each key in ruleArgs is an argument name, value is a glob pattern.
|
|
348
|
+
* ALL specified argument patterns must match for the rule to match.
|
|
349
|
+
*
|
|
350
|
+
* Security: normalizes paths and unicode before matching.
|
|
351
|
+
* Security: checks key aliases (path → file, filepath, etc.)
|
|
352
|
+
*/
|
|
353
|
+
private matchArguments(
|
|
354
|
+
ruleArgs: Record<string, string>,
|
|
355
|
+
actualArgs: Record<string, unknown>
|
|
356
|
+
): boolean {
|
|
357
|
+
for (const [key, pattern] of Object.entries(ruleArgs)) {
|
|
358
|
+
// Get the actual value — try the key directly and all aliases
|
|
359
|
+
const aliases = getKeyAliases(key);
|
|
360
|
+
let rawValue: unknown;
|
|
361
|
+
for (const alias of aliases) {
|
|
362
|
+
// Case-insensitive key lookup
|
|
363
|
+
const found = Object.entries(actualArgs).find(
|
|
364
|
+
([k]) => k.toLowerCase() === alias
|
|
365
|
+
);
|
|
366
|
+
if (found !== undefined && found[1] !== undefined) {
|
|
367
|
+
rawValue = found[1];
|
|
368
|
+
break;
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
if (rawValue === undefined) return false;
|
|
373
|
+
|
|
374
|
+
// Normalize the value (Unicode NFC + path traversal resolution)
|
|
375
|
+
const strValue = normalizeValue(String(rawValue));
|
|
376
|
+
const patterns = pattern.split("|").map((p) => p.trim());
|
|
377
|
+
|
|
378
|
+
// At least one pattern must match
|
|
379
|
+
const matched = patterns.some((p) => {
|
|
380
|
+
// Try glob match with dot:true so patterns match dotfiles
|
|
381
|
+
if (minimatch(strValue, p, { dot: true })) return true;
|
|
382
|
+
|
|
383
|
+
// Fallback: safe glob-to-regex for non-path strings
|
|
384
|
+
if (p.includes("*") || p.includes("?")) {
|
|
385
|
+
const regex = safeGlobToRegex(p);
|
|
386
|
+
if (regex && regex.test(strValue)) return true;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// Also try case-insensitive substring for simple patterns
|
|
390
|
+
if (!p.includes("*") && !p.includes("?")) {
|
|
391
|
+
return strValue.toLowerCase().includes(p.toLowerCase());
|
|
392
|
+
}
|
|
393
|
+
return false;
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
if (!matched) return false;
|
|
397
|
+
}
|
|
398
|
+
return true;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/**
|
|
402
|
+
* Get the current policy config.
|
|
403
|
+
*/
|
|
404
|
+
getConfig(): PolicyConfig {
|
|
405
|
+
return this.config;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/**
|
|
409
|
+
* Get all rule names.
|
|
410
|
+
*/
|
|
411
|
+
getRuleNames(): string[] {
|
|
412
|
+
return this.config.rules.map((r) => r.name);
|
|
413
|
+
}
|
|
414
|
+
}
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for PolicyLoader — YAML config loading and validation.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { describe, it, expect } from "vitest";
|
|
6
|
+
import { parsePolicyYaml, getDefaultPolicy, generateDefaultConfigYaml } from "./policy-loader.js";
|
|
7
|
+
|
|
8
|
+
describe("parsePolicyYaml", () => {
|
|
9
|
+
it("should parse a valid policy YAML", () => {
|
|
10
|
+
const yaml = `
|
|
11
|
+
version: 1
|
|
12
|
+
defaultAction: deny
|
|
13
|
+
rules:
|
|
14
|
+
- name: allow-read
|
|
15
|
+
tool: read_file
|
|
16
|
+
action: allow
|
|
17
|
+
- name: block-shell
|
|
18
|
+
tool: shell_exec
|
|
19
|
+
action: deny
|
|
20
|
+
message: "No shell access"
|
|
21
|
+
`;
|
|
22
|
+
|
|
23
|
+
const config = parsePolicyYaml(yaml);
|
|
24
|
+
expect(config.version).toBe(1);
|
|
25
|
+
expect(config.defaultAction).toBe("deny");
|
|
26
|
+
expect(config.rules).toHaveLength(2);
|
|
27
|
+
expect(config.rules[0].name).toBe("allow-read");
|
|
28
|
+
expect(config.rules[0].action).toBe("allow");
|
|
29
|
+
expect(config.rules[1].message).toBe("No shell access");
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("should parse rules with argument matching", () => {
|
|
33
|
+
const yaml = `
|
|
34
|
+
version: 1
|
|
35
|
+
rules:
|
|
36
|
+
- name: block-ssh
|
|
37
|
+
tool: "*"
|
|
38
|
+
match:
|
|
39
|
+
arguments:
|
|
40
|
+
path: "*/.ssh/**"
|
|
41
|
+
action: deny
|
|
42
|
+
`;
|
|
43
|
+
|
|
44
|
+
const config = parsePolicyYaml(yaml);
|
|
45
|
+
expect(config.rules[0].match?.arguments?.path).toBe("*/.ssh/**");
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("should parse rules with rate limiting", () => {
|
|
49
|
+
const yaml = `
|
|
50
|
+
version: 1
|
|
51
|
+
globalRateLimit:
|
|
52
|
+
maxCalls: 100
|
|
53
|
+
windowSeconds: 60
|
|
54
|
+
rules:
|
|
55
|
+
- name: limited
|
|
56
|
+
tool: read_file
|
|
57
|
+
action: allow
|
|
58
|
+
rateLimit:
|
|
59
|
+
maxCalls: 10
|
|
60
|
+
windowSeconds: 30
|
|
61
|
+
`;
|
|
62
|
+
|
|
63
|
+
const config = parsePolicyYaml(yaml);
|
|
64
|
+
expect(config.globalRateLimit?.maxCalls).toBe(100);
|
|
65
|
+
expect(config.rules[0].rateLimit?.maxCalls).toBe(10);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("should parse responseScanning configuration", () => {
|
|
69
|
+
const yaml = `
|
|
70
|
+
version: 1
|
|
71
|
+
responseScanning:
|
|
72
|
+
enabled: true
|
|
73
|
+
maxResponseSize: 5242880
|
|
74
|
+
oversizeAction: redact
|
|
75
|
+
detectSecrets: true
|
|
76
|
+
detectPII: true
|
|
77
|
+
patterns:
|
|
78
|
+
- name: internal-url
|
|
79
|
+
pattern: "https?://internal\\\\.[a-z]+\\\\.corp"
|
|
80
|
+
action: redact
|
|
81
|
+
message: "Internal URL detected"
|
|
82
|
+
category: custom
|
|
83
|
+
rules:
|
|
84
|
+
- name: allow-all
|
|
85
|
+
tool: "*"
|
|
86
|
+
action: allow
|
|
87
|
+
`;
|
|
88
|
+
|
|
89
|
+
const config = parsePolicyYaml(yaml);
|
|
90
|
+
expect(config.responseScanning).toBeDefined();
|
|
91
|
+
expect(config.responseScanning!.enabled).toBe(true);
|
|
92
|
+
expect(config.responseScanning!.maxResponseSize).toBe(5242880);
|
|
93
|
+
expect(config.responseScanning!.oversizeAction).toBe("redact");
|
|
94
|
+
expect(config.responseScanning!.detectSecrets).toBe(true);
|
|
95
|
+
expect(config.responseScanning!.detectPII).toBe(true);
|
|
96
|
+
expect(config.responseScanning!.patterns).toHaveLength(1);
|
|
97
|
+
expect(config.responseScanning!.patterns![0].name).toBe("internal-url");
|
|
98
|
+
expect(config.responseScanning!.patterns![0].action).toBe("redact");
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("should accept config without responseScanning section", () => {
|
|
102
|
+
const yaml = `
|
|
103
|
+
version: 1
|
|
104
|
+
rules:
|
|
105
|
+
- name: test
|
|
106
|
+
tool: "*"
|
|
107
|
+
action: allow
|
|
108
|
+
`;
|
|
109
|
+
|
|
110
|
+
const config = parsePolicyYaml(yaml);
|
|
111
|
+
expect(config.responseScanning).toBeUndefined();
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("should throw on invalid YAML structure", () => {
|
|
115
|
+
expect(() => parsePolicyYaml("version: 1\nrules: not-an-array")).toThrow();
|
|
116
|
+
expect(() => parsePolicyYaml("version: 1")).toThrow();
|
|
117
|
+
expect(() => parsePolicyYaml("{}")).toThrow();
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("should throw on invalid action values", () => {
|
|
121
|
+
const yaml = `
|
|
122
|
+
version: 1
|
|
123
|
+
rules:
|
|
124
|
+
- name: bad
|
|
125
|
+
tool: "*"
|
|
126
|
+
action: invalid_action
|
|
127
|
+
`;
|
|
128
|
+
expect(() => parsePolicyYaml(yaml)).toThrow();
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it("should throw when rule is missing required fields", () => {
|
|
132
|
+
const yaml = `
|
|
133
|
+
version: 1
|
|
134
|
+
rules:
|
|
135
|
+
- tool: "*"
|
|
136
|
+
action: allow
|
|
137
|
+
`;
|
|
138
|
+
// Missing "name"
|
|
139
|
+
expect(() => parsePolicyYaml(yaml)).toThrow();
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
describe("getDefaultPolicy", () => {
|
|
144
|
+
it("should return a valid default config", () => {
|
|
145
|
+
const config = getDefaultPolicy();
|
|
146
|
+
expect(config.version).toBe(1);
|
|
147
|
+
expect(config.rules.length).toBeGreaterThan(0);
|
|
148
|
+
expect(config.defaultAction).toBe("prompt");
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it("should include response scanning defaults", () => {
|
|
152
|
+
const config = getDefaultPolicy();
|
|
153
|
+
expect(config.responseScanning).toBeDefined();
|
|
154
|
+
expect(config.responseScanning!.enabled).toBe(true);
|
|
155
|
+
expect(config.responseScanning!.detectSecrets).toBe(true);
|
|
156
|
+
expect(config.responseScanning!.detectPII).toBe(false);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it("should include critical security rules", () => {
|
|
160
|
+
const config = getDefaultPolicy();
|
|
161
|
+
const names = config.rules.map((r) => r.name);
|
|
162
|
+
|
|
163
|
+
expect(names).toContain("block-ssh-keys");
|
|
164
|
+
expect(names).toContain("block-env-files");
|
|
165
|
+
expect(names).toContain("block-credential-files");
|
|
166
|
+
expect(names).toContain("block-curl-exfil");
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it("should include bypass-resistant exfiltration rules", () => {
|
|
170
|
+
const config = getDefaultPolicy();
|
|
171
|
+
const names = config.rules.map((r) => r.name);
|
|
172
|
+
|
|
173
|
+
expect(names).toContain("block-powershell-exfil");
|
|
174
|
+
expect(names).toContain("block-dns-exfil");
|
|
175
|
+
expect(names).toContain("approve-script-exec");
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it("should have deny rules before allow rules", () => {
|
|
179
|
+
const config = getDefaultPolicy();
|
|
180
|
+
const firstDeny = config.rules.findIndex((r) => r.action === "deny");
|
|
181
|
+
const firstAllow = config.rules.findIndex((r) => r.action === "allow");
|
|
182
|
+
|
|
183
|
+
expect(firstDeny).toBeLessThan(firstAllow);
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
describe("generateDefaultConfigYaml", () => {
|
|
188
|
+
it("should generate parseable YAML", () => {
|
|
189
|
+
const yaml = generateDefaultConfigYaml();
|
|
190
|
+
const config = parsePolicyYaml(yaml);
|
|
191
|
+
expect(config.version).toBe(1);
|
|
192
|
+
expect(config.rules.length).toBeGreaterThan(0);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it("should include helpful comments", () => {
|
|
196
|
+
const yaml = generateDefaultConfigYaml();
|
|
197
|
+
expect(yaml).toContain("# Agent Wall Policy Configuration");
|
|
198
|
+
expect(yaml).toContain("# Rules are evaluated in order");
|
|
199
|
+
expect(yaml).toContain("responseScanning");
|
|
200
|
+
expect(yaml).toContain("detectSecrets");
|
|
201
|
+
});
|
|
202
|
+
});
|