@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,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
+ });