@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
package/dist/index.js
ADDED
|
@@ -0,0 +1,3067 @@
|
|
|
1
|
+
// src/types.ts
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
var JsonRpcRequestSchema = z.object({
|
|
4
|
+
jsonrpc: z.literal("2.0"),
|
|
5
|
+
id: z.union([z.string(), z.number()]),
|
|
6
|
+
method: z.string(),
|
|
7
|
+
params: z.record(z.unknown()).optional()
|
|
8
|
+
});
|
|
9
|
+
var JsonRpcNotificationSchema = z.object({
|
|
10
|
+
jsonrpc: z.literal("2.0"),
|
|
11
|
+
method: z.string(),
|
|
12
|
+
params: z.record(z.unknown()).optional()
|
|
13
|
+
});
|
|
14
|
+
var JsonRpcResponseSchema = z.object({
|
|
15
|
+
jsonrpc: z.literal("2.0"),
|
|
16
|
+
id: z.union([z.string(), z.number()]),
|
|
17
|
+
result: z.unknown().optional(),
|
|
18
|
+
error: z.object({
|
|
19
|
+
code: z.number(),
|
|
20
|
+
message: z.string(),
|
|
21
|
+
data: z.unknown().optional()
|
|
22
|
+
}).optional()
|
|
23
|
+
});
|
|
24
|
+
var JsonRpcMessageSchema = z.union([
|
|
25
|
+
JsonRpcRequestSchema,
|
|
26
|
+
JsonRpcNotificationSchema,
|
|
27
|
+
JsonRpcResponseSchema
|
|
28
|
+
]);
|
|
29
|
+
function isRequest(msg) {
|
|
30
|
+
return "id" in msg && "method" in msg;
|
|
31
|
+
}
|
|
32
|
+
function isNotification(msg) {
|
|
33
|
+
return !("id" in msg) && "method" in msg;
|
|
34
|
+
}
|
|
35
|
+
function isResponse(msg) {
|
|
36
|
+
return "id" in msg && !("method" in msg);
|
|
37
|
+
}
|
|
38
|
+
function isToolCall(msg) {
|
|
39
|
+
return isRequest(msg) && msg.method === "tools/call";
|
|
40
|
+
}
|
|
41
|
+
function isToolList(msg) {
|
|
42
|
+
return isRequest(msg) && msg.method === "tools/list";
|
|
43
|
+
}
|
|
44
|
+
function getToolCallParams(msg) {
|
|
45
|
+
if (msg.method !== "tools/call" || !msg.params) return null;
|
|
46
|
+
const params = msg.params;
|
|
47
|
+
if (typeof params.name !== "string") return null;
|
|
48
|
+
return {
|
|
49
|
+
name: params.name,
|
|
50
|
+
arguments: params.arguments ?? {}
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
function createDenyResponse(id, message) {
|
|
54
|
+
return {
|
|
55
|
+
jsonrpc: "2.0",
|
|
56
|
+
id,
|
|
57
|
+
error: {
|
|
58
|
+
code: -32001,
|
|
59
|
+
// Custom: policy denied
|
|
60
|
+
message: `Agent Wall: ${message}`
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
function createPromptResponse(id, message) {
|
|
65
|
+
return {
|
|
66
|
+
jsonrpc: "2.0",
|
|
67
|
+
id,
|
|
68
|
+
error: {
|
|
69
|
+
code: -32002,
|
|
70
|
+
// Custom: awaiting approval
|
|
71
|
+
message: `Agent Wall: Awaiting approval \u2014 ${message}`
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// src/read-buffer.ts
|
|
77
|
+
var DEFAULT_MAX_BUFFER_SIZE = 10 * 1024 * 1024;
|
|
78
|
+
var ReadBuffer = class {
|
|
79
|
+
buffer = null;
|
|
80
|
+
maxBufferSize;
|
|
81
|
+
constructor(maxBufferSize = DEFAULT_MAX_BUFFER_SIZE) {
|
|
82
|
+
this.maxBufferSize = maxBufferSize;
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Append raw bytes from a stream chunk.
|
|
86
|
+
* Throws if the buffer exceeds the configured maximum size.
|
|
87
|
+
*/
|
|
88
|
+
append(chunk) {
|
|
89
|
+
this.buffer = this.buffer ? Buffer.concat([this.buffer, chunk]) : chunk;
|
|
90
|
+
if (this.buffer.length > this.maxBufferSize) {
|
|
91
|
+
const size = this.buffer.length;
|
|
92
|
+
this.buffer = null;
|
|
93
|
+
throw new BufferOverflowError(
|
|
94
|
+
`Buffer size ${size} exceeds maximum ${this.maxBufferSize} bytes \u2014 possible DOS attack`
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Try to extract the next complete JSON-RPC message.
|
|
100
|
+
* Returns null if no complete message is available yet.
|
|
101
|
+
* Automatically skips empty lines.
|
|
102
|
+
*/
|
|
103
|
+
readMessage() {
|
|
104
|
+
while (this.buffer) {
|
|
105
|
+
const index = this.buffer.indexOf("\n");
|
|
106
|
+
if (index === -1) return null;
|
|
107
|
+
const line = this.buffer.toString("utf8", 0, index).replace(/\r$/, "");
|
|
108
|
+
this.buffer = this.buffer.subarray(index + 1);
|
|
109
|
+
if (this.buffer.length === 0) this.buffer = null;
|
|
110
|
+
if (line.length === 0) continue;
|
|
111
|
+
return deserializeMessage(line);
|
|
112
|
+
}
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Extract ALL available messages from the buffer.
|
|
117
|
+
*/
|
|
118
|
+
readAllMessages() {
|
|
119
|
+
const messages = [];
|
|
120
|
+
let msg;
|
|
121
|
+
while ((msg = this.readMessage()) !== null) {
|
|
122
|
+
messages.push(msg);
|
|
123
|
+
}
|
|
124
|
+
return messages;
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Clear the buffer.
|
|
128
|
+
*/
|
|
129
|
+
clear() {
|
|
130
|
+
this.buffer = null;
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* Check if there's any pending data in the buffer.
|
|
134
|
+
*/
|
|
135
|
+
get hasPendingData() {
|
|
136
|
+
return this.buffer !== null && this.buffer.length > 0;
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* Get current buffer size in bytes.
|
|
140
|
+
*/
|
|
141
|
+
get currentSize() {
|
|
142
|
+
return this.buffer?.length ?? 0;
|
|
143
|
+
}
|
|
144
|
+
};
|
|
145
|
+
var BufferOverflowError = class extends Error {
|
|
146
|
+
constructor(message) {
|
|
147
|
+
super(message);
|
|
148
|
+
this.name = "BufferOverflowError";
|
|
149
|
+
}
|
|
150
|
+
};
|
|
151
|
+
function deserializeMessage(line) {
|
|
152
|
+
const parsed = JSON.parse(line);
|
|
153
|
+
return JsonRpcMessageSchema.parse(parsed);
|
|
154
|
+
}
|
|
155
|
+
function serializeMessage(message) {
|
|
156
|
+
return JSON.stringify(message) + "\n";
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// src/policy-engine.ts
|
|
160
|
+
import * as path from "path";
|
|
161
|
+
import { minimatch } from "minimatch";
|
|
162
|
+
var RateLimiter = class {
|
|
163
|
+
buckets = /* @__PURE__ */ new Map();
|
|
164
|
+
check(key, config) {
|
|
165
|
+
const now = Date.now();
|
|
166
|
+
const windowMs = config.windowSeconds * 1e3;
|
|
167
|
+
const bucket = this.buckets.get(key) ?? { timestamps: [] };
|
|
168
|
+
bucket.timestamps = bucket.timestamps.filter((t) => now - t < windowMs);
|
|
169
|
+
if (bucket.timestamps.length >= config.maxCalls) {
|
|
170
|
+
return false;
|
|
171
|
+
}
|
|
172
|
+
bucket.timestamps.push(now);
|
|
173
|
+
this.buckets.set(key, bucket);
|
|
174
|
+
return true;
|
|
175
|
+
}
|
|
176
|
+
reset() {
|
|
177
|
+
this.buckets.clear();
|
|
178
|
+
}
|
|
179
|
+
};
|
|
180
|
+
function normalizeValue(value) {
|
|
181
|
+
let normalized = value.normalize("NFC");
|
|
182
|
+
if (looksLikePath(normalized)) {
|
|
183
|
+
normalized = normalizePath(normalized);
|
|
184
|
+
}
|
|
185
|
+
return normalized;
|
|
186
|
+
}
|
|
187
|
+
function looksLikePath(value) {
|
|
188
|
+
return value.includes("/") || value.includes("\\") || value.startsWith(".") || value.startsWith("~");
|
|
189
|
+
}
|
|
190
|
+
function normalizePath(p) {
|
|
191
|
+
let normalized = p.replace(/\\/g, "/");
|
|
192
|
+
normalized = path.posix.normalize(normalized);
|
|
193
|
+
return normalized;
|
|
194
|
+
}
|
|
195
|
+
function safeGlobToRegex(pattern) {
|
|
196
|
+
if (pattern.length > 500) return null;
|
|
197
|
+
const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*").replace(/\?/g, ".");
|
|
198
|
+
try {
|
|
199
|
+
return new RegExp(`^${escaped}$`, "i");
|
|
200
|
+
} catch {
|
|
201
|
+
return null;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
var PATH_KEY_ALIASES = {
|
|
205
|
+
path: ["file", "filepath", "file_path", "filename", "file_name", "target", "source", "destination", "dest", "src", "uri", "url"],
|
|
206
|
+
command: ["cmd", "shell", "exec", "script", "run"],
|
|
207
|
+
content: ["text", "body", "data", "input", "message"]
|
|
208
|
+
};
|
|
209
|
+
function getKeyAliases(key) {
|
|
210
|
+
const lowerKey = key.toLowerCase();
|
|
211
|
+
const aliases = PATH_KEY_ALIASES[lowerKey];
|
|
212
|
+
if (aliases) return [lowerKey, ...aliases];
|
|
213
|
+
for (const [canonical, aliasList] of Object.entries(PATH_KEY_ALIASES)) {
|
|
214
|
+
if (aliasList.includes(lowerKey)) {
|
|
215
|
+
return [canonical, ...aliasList];
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
return [lowerKey];
|
|
219
|
+
}
|
|
220
|
+
var PolicyEngine = class {
|
|
221
|
+
config;
|
|
222
|
+
rateLimiter = new RateLimiter();
|
|
223
|
+
constructor(config) {
|
|
224
|
+
this.config = config;
|
|
225
|
+
}
|
|
226
|
+
/**
|
|
227
|
+
* Update the policy configuration (e.g., after file reload).
|
|
228
|
+
*/
|
|
229
|
+
updateConfig(config) {
|
|
230
|
+
this.config = config;
|
|
231
|
+
this.rateLimiter.reset();
|
|
232
|
+
}
|
|
233
|
+
/**
|
|
234
|
+
* Evaluate a tool call against the policy rules.
|
|
235
|
+
* Returns the verdict: allow, deny, or prompt.
|
|
236
|
+
*/
|
|
237
|
+
evaluate(toolCall) {
|
|
238
|
+
if (this.config.globalRateLimit) {
|
|
239
|
+
if (!this.rateLimiter.check("__global__", this.config.globalRateLimit)) {
|
|
240
|
+
return {
|
|
241
|
+
action: "deny",
|
|
242
|
+
rule: "__global_rate_limit__",
|
|
243
|
+
message: `Global rate limit exceeded (${this.config.globalRateLimit.maxCalls} calls per ${this.config.globalRateLimit.windowSeconds}s)`
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
for (const rule of this.config.rules) {
|
|
248
|
+
if (this.matchesRule(rule, toolCall)) {
|
|
249
|
+
if (rule.rateLimit) {
|
|
250
|
+
if (!this.rateLimiter.check(`rule:${rule.name}`, rule.rateLimit)) {
|
|
251
|
+
return {
|
|
252
|
+
action: "deny",
|
|
253
|
+
rule: rule.name,
|
|
254
|
+
message: rule.message ?? `Rate limit exceeded for rule "${rule.name}" (${rule.rateLimit.maxCalls} per ${rule.rateLimit.windowSeconds}s)`
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
return {
|
|
259
|
+
action: rule.action,
|
|
260
|
+
rule: rule.name,
|
|
261
|
+
message: rule.message ?? `${rule.action === "deny" ? "Blocked" : rule.action === "prompt" ? "Approval required" : "Allowed"} by rule "${rule.name}"`
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
const isStrict = this.config.mode === "strict";
|
|
266
|
+
const defaultAction = isStrict ? "deny" : this.config.defaultAction ?? "prompt";
|
|
267
|
+
return {
|
|
268
|
+
action: defaultAction,
|
|
269
|
+
rule: null,
|
|
270
|
+
message: isStrict ? `Zero-trust mode: no matching allow rule. Denied by default.` : `No matching rule. Default action: ${defaultAction}`
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
/**
|
|
274
|
+
* Check if a rule matches a tool call.
|
|
275
|
+
*/
|
|
276
|
+
matchesRule(rule, toolCall) {
|
|
277
|
+
const normalizedToolName = toolCall.name.normalize("NFC");
|
|
278
|
+
if (!this.matchToolName(rule.tool, normalizedToolName)) {
|
|
279
|
+
return false;
|
|
280
|
+
}
|
|
281
|
+
if (rule.match?.arguments) {
|
|
282
|
+
if (!this.matchArguments(rule.match.arguments, toolCall.arguments ?? {})) {
|
|
283
|
+
return false;
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
return true;
|
|
287
|
+
}
|
|
288
|
+
/**
|
|
289
|
+
* Match a tool name against a pattern.
|
|
290
|
+
* Pattern can contain "|" for multiple alternatives.
|
|
291
|
+
*/
|
|
292
|
+
matchToolName(pattern, toolName) {
|
|
293
|
+
const patterns = pattern.split("|").map((p) => p.trim());
|
|
294
|
+
return patterns.some((p) => minimatch(toolName, p));
|
|
295
|
+
}
|
|
296
|
+
/**
|
|
297
|
+
* Match rule argument patterns against actual tool arguments.
|
|
298
|
+
* Each key in ruleArgs is an argument name, value is a glob pattern.
|
|
299
|
+
* ALL specified argument patterns must match for the rule to match.
|
|
300
|
+
*
|
|
301
|
+
* Security: normalizes paths and unicode before matching.
|
|
302
|
+
* Security: checks key aliases (path → file, filepath, etc.)
|
|
303
|
+
*/
|
|
304
|
+
matchArguments(ruleArgs, actualArgs) {
|
|
305
|
+
for (const [key, pattern] of Object.entries(ruleArgs)) {
|
|
306
|
+
const aliases = getKeyAliases(key);
|
|
307
|
+
let rawValue;
|
|
308
|
+
for (const alias of aliases) {
|
|
309
|
+
const found = Object.entries(actualArgs).find(
|
|
310
|
+
([k]) => k.toLowerCase() === alias
|
|
311
|
+
);
|
|
312
|
+
if (found !== void 0 && found[1] !== void 0) {
|
|
313
|
+
rawValue = found[1];
|
|
314
|
+
break;
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
if (rawValue === void 0) return false;
|
|
318
|
+
const strValue = normalizeValue(String(rawValue));
|
|
319
|
+
const patterns = pattern.split("|").map((p) => p.trim());
|
|
320
|
+
const matched = patterns.some((p) => {
|
|
321
|
+
if (minimatch(strValue, p, { dot: true })) return true;
|
|
322
|
+
if (p.includes("*") || p.includes("?")) {
|
|
323
|
+
const regex = safeGlobToRegex(p);
|
|
324
|
+
if (regex && regex.test(strValue)) return true;
|
|
325
|
+
}
|
|
326
|
+
if (!p.includes("*") && !p.includes("?")) {
|
|
327
|
+
return strValue.toLowerCase().includes(p.toLowerCase());
|
|
328
|
+
}
|
|
329
|
+
return false;
|
|
330
|
+
});
|
|
331
|
+
if (!matched) return false;
|
|
332
|
+
}
|
|
333
|
+
return true;
|
|
334
|
+
}
|
|
335
|
+
/**
|
|
336
|
+
* Get the current policy config.
|
|
337
|
+
*/
|
|
338
|
+
getConfig() {
|
|
339
|
+
return this.config;
|
|
340
|
+
}
|
|
341
|
+
/**
|
|
342
|
+
* Get all rule names.
|
|
343
|
+
*/
|
|
344
|
+
getRuleNames() {
|
|
345
|
+
return this.config.rules.map((r) => r.name);
|
|
346
|
+
}
|
|
347
|
+
};
|
|
348
|
+
|
|
349
|
+
// src/policy-loader.ts
|
|
350
|
+
import * as fs from "fs";
|
|
351
|
+
import * as path2 from "path";
|
|
352
|
+
import * as yaml from "js-yaml";
|
|
353
|
+
import { z as z2 } from "zod";
|
|
354
|
+
var RateLimitSchema = z2.object({
|
|
355
|
+
maxCalls: z2.number().int().positive(),
|
|
356
|
+
windowSeconds: z2.number().positive()
|
|
357
|
+
});
|
|
358
|
+
var RuleMatchSchema = z2.object({
|
|
359
|
+
arguments: z2.record(z2.string()).optional()
|
|
360
|
+
});
|
|
361
|
+
var PolicyRuleSchema = z2.object({
|
|
362
|
+
name: z2.string().min(1),
|
|
363
|
+
tool: z2.string().min(1),
|
|
364
|
+
match: RuleMatchSchema.optional(),
|
|
365
|
+
action: z2.enum(["allow", "deny", "prompt"]),
|
|
366
|
+
message: z2.string().optional(),
|
|
367
|
+
rateLimit: RateLimitSchema.optional()
|
|
368
|
+
});
|
|
369
|
+
var ResponsePatternSchema = z2.object({
|
|
370
|
+
name: z2.string().min(1),
|
|
371
|
+
pattern: z2.string().min(1),
|
|
372
|
+
flags: z2.string().optional(),
|
|
373
|
+
action: z2.enum(["pass", "redact", "block"]),
|
|
374
|
+
message: z2.string().optional(),
|
|
375
|
+
category: z2.string().optional()
|
|
376
|
+
});
|
|
377
|
+
var ResponseScanningSchema = z2.object({
|
|
378
|
+
enabled: z2.boolean().optional(),
|
|
379
|
+
maxResponseSize: z2.number().int().nonnegative().optional(),
|
|
380
|
+
oversizeAction: z2.enum(["block", "redact"]).optional(),
|
|
381
|
+
detectSecrets: z2.boolean().optional(),
|
|
382
|
+
detectPII: z2.boolean().optional(),
|
|
383
|
+
base64Action: z2.enum(["pass", "redact", "block"]).optional(),
|
|
384
|
+
maxPatterns: z2.number().int().positive().optional(),
|
|
385
|
+
patterns: z2.array(ResponsePatternSchema).optional()
|
|
386
|
+
});
|
|
387
|
+
var InjectionDetectionSchema = z2.object({
|
|
388
|
+
enabled: z2.boolean().optional(),
|
|
389
|
+
sensitivity: z2.enum(["low", "medium", "high"]).optional(),
|
|
390
|
+
customPatterns: z2.array(z2.string()).optional(),
|
|
391
|
+
excludeTools: z2.array(z2.string()).optional()
|
|
392
|
+
});
|
|
393
|
+
var EgressControlSchema = z2.object({
|
|
394
|
+
enabled: z2.boolean().optional(),
|
|
395
|
+
allowedDomains: z2.array(z2.string()).optional(),
|
|
396
|
+
blockedDomains: z2.array(z2.string()).optional(),
|
|
397
|
+
blockPrivateIPs: z2.boolean().optional(),
|
|
398
|
+
blockMetadataEndpoints: z2.boolean().optional(),
|
|
399
|
+
excludeTools: z2.array(z2.string()).optional()
|
|
400
|
+
});
|
|
401
|
+
var KillSwitchSchema = z2.object({
|
|
402
|
+
enabled: z2.boolean().optional(),
|
|
403
|
+
checkFile: z2.boolean().optional(),
|
|
404
|
+
killFileNames: z2.array(z2.string()).optional(),
|
|
405
|
+
pollIntervalMs: z2.number().int().positive().optional()
|
|
406
|
+
});
|
|
407
|
+
var ChainDetectionSchema = z2.object({
|
|
408
|
+
enabled: z2.boolean().optional(),
|
|
409
|
+
windowSize: z2.number().int().positive().optional(),
|
|
410
|
+
windowMs: z2.number().int().positive().optional()
|
|
411
|
+
});
|
|
412
|
+
var SecuritySchema = z2.object({
|
|
413
|
+
injectionDetection: InjectionDetectionSchema.optional(),
|
|
414
|
+
egressControl: EgressControlSchema.optional(),
|
|
415
|
+
killSwitch: KillSwitchSchema.optional(),
|
|
416
|
+
chainDetection: ChainDetectionSchema.optional(),
|
|
417
|
+
signing: z2.boolean().optional(),
|
|
418
|
+
signingKey: z2.string().optional()
|
|
419
|
+
});
|
|
420
|
+
var PolicyConfigSchema = z2.object({
|
|
421
|
+
version: z2.number().int().min(1),
|
|
422
|
+
mode: z2.enum(["standard", "strict"]).optional(),
|
|
423
|
+
defaultAction: z2.enum(["allow", "deny", "prompt"]).optional(),
|
|
424
|
+
globalRateLimit: RateLimitSchema.optional(),
|
|
425
|
+
responseScanning: ResponseScanningSchema.optional(),
|
|
426
|
+
security: SecuritySchema.optional(),
|
|
427
|
+
rules: z2.array(PolicyRuleSchema)
|
|
428
|
+
});
|
|
429
|
+
var CONFIG_FILENAMES = [
|
|
430
|
+
"agent-wall.yaml",
|
|
431
|
+
"agent-wall.yml",
|
|
432
|
+
".agent-wall.yaml",
|
|
433
|
+
".agent-wall.yml"
|
|
434
|
+
// Legacy support
|
|
435
|
+
];
|
|
436
|
+
function loadPolicyFile(filePath) {
|
|
437
|
+
if (!fs.existsSync(filePath)) {
|
|
438
|
+
throw new Error(`Policy file not found: ${filePath}`);
|
|
439
|
+
}
|
|
440
|
+
const content = fs.readFileSync(filePath, "utf-8");
|
|
441
|
+
return parsePolicyYaml(content);
|
|
442
|
+
}
|
|
443
|
+
function parsePolicyYaml(yamlContent) {
|
|
444
|
+
const raw = yaml.load(yamlContent, { schema: yaml.JSON_SCHEMA });
|
|
445
|
+
const validated = PolicyConfigSchema.parse(raw);
|
|
446
|
+
return validated;
|
|
447
|
+
}
|
|
448
|
+
function discoverPolicyFile(startDir = process.cwd()) {
|
|
449
|
+
let dir = path2.resolve(startDir);
|
|
450
|
+
while (true) {
|
|
451
|
+
for (const filename of CONFIG_FILENAMES) {
|
|
452
|
+
const candidate = path2.join(dir, filename);
|
|
453
|
+
if (fs.existsSync(candidate)) {
|
|
454
|
+
return candidate;
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
const parent = path2.dirname(dir);
|
|
458
|
+
if (parent === dir) break;
|
|
459
|
+
dir = parent;
|
|
460
|
+
}
|
|
461
|
+
return null;
|
|
462
|
+
}
|
|
463
|
+
function loadPolicy(configPath) {
|
|
464
|
+
if (configPath) {
|
|
465
|
+
return {
|
|
466
|
+
config: loadPolicyFile(configPath),
|
|
467
|
+
filePath: configPath
|
|
468
|
+
};
|
|
469
|
+
}
|
|
470
|
+
const discovered = discoverPolicyFile();
|
|
471
|
+
if (discovered) {
|
|
472
|
+
return {
|
|
473
|
+
config: loadPolicyFile(discovered),
|
|
474
|
+
filePath: discovered
|
|
475
|
+
};
|
|
476
|
+
}
|
|
477
|
+
return {
|
|
478
|
+
config: getDefaultPolicy(),
|
|
479
|
+
filePath: null
|
|
480
|
+
};
|
|
481
|
+
}
|
|
482
|
+
function getDefaultPolicy() {
|
|
483
|
+
return {
|
|
484
|
+
version: 1,
|
|
485
|
+
defaultAction: "prompt",
|
|
486
|
+
globalRateLimit: {
|
|
487
|
+
maxCalls: 200,
|
|
488
|
+
windowSeconds: 60
|
|
489
|
+
},
|
|
490
|
+
responseScanning: {
|
|
491
|
+
enabled: true,
|
|
492
|
+
maxResponseSize: 5 * 1024 * 1024,
|
|
493
|
+
// 5MB
|
|
494
|
+
oversizeAction: "redact",
|
|
495
|
+
detectSecrets: true,
|
|
496
|
+
detectPII: false
|
|
497
|
+
},
|
|
498
|
+
security: {
|
|
499
|
+
injectionDetection: { enabled: true, sensitivity: "medium" },
|
|
500
|
+
egressControl: { enabled: true, blockPrivateIPs: true, blockMetadataEndpoints: true },
|
|
501
|
+
killSwitch: { enabled: true, checkFile: true },
|
|
502
|
+
chainDetection: { enabled: true },
|
|
503
|
+
signing: false
|
|
504
|
+
},
|
|
505
|
+
rules: [
|
|
506
|
+
// ── Always block: credential access ──
|
|
507
|
+
{
|
|
508
|
+
name: "block-ssh-keys",
|
|
509
|
+
tool: "*",
|
|
510
|
+
match: { arguments: { path: "**/.ssh/**|**/.ssh" } },
|
|
511
|
+
action: "deny",
|
|
512
|
+
message: "Access to SSH keys is blocked by default policy"
|
|
513
|
+
},
|
|
514
|
+
{
|
|
515
|
+
name: "block-env-files",
|
|
516
|
+
tool: "*",
|
|
517
|
+
match: { arguments: { path: "**/.env*" } },
|
|
518
|
+
action: "deny",
|
|
519
|
+
message: "Access to .env files is blocked by default policy"
|
|
520
|
+
},
|
|
521
|
+
{
|
|
522
|
+
name: "block-credential-files",
|
|
523
|
+
tool: "*",
|
|
524
|
+
match: {
|
|
525
|
+
arguments: { path: "*credentials*|**/*.pem|**/*.key|**/*.pfx|**/*.p12" }
|
|
526
|
+
},
|
|
527
|
+
action: "deny",
|
|
528
|
+
message: "Access to credential files is blocked by default policy"
|
|
529
|
+
},
|
|
530
|
+
// ── Always block: exfiltration patterns ──
|
|
531
|
+
{
|
|
532
|
+
name: "block-curl-exfil",
|
|
533
|
+
tool: "shell_exec|run_command|execute_command",
|
|
534
|
+
match: { arguments: { command: "*curl *" } },
|
|
535
|
+
action: "deny",
|
|
536
|
+
message: "Shell commands with curl are blocked \u2014 potential data exfiltration"
|
|
537
|
+
},
|
|
538
|
+
{
|
|
539
|
+
name: "block-wget-exfil",
|
|
540
|
+
tool: "shell_exec|run_command|execute_command",
|
|
541
|
+
match: { arguments: { command: "*wget *" } },
|
|
542
|
+
action: "deny",
|
|
543
|
+
message: "Shell commands with wget are blocked \u2014 potential data exfiltration"
|
|
544
|
+
},
|
|
545
|
+
{
|
|
546
|
+
name: "block-netcat-exfil",
|
|
547
|
+
tool: "shell_exec|run_command|execute_command",
|
|
548
|
+
match: { arguments: { command: "*nc *|*ncat *|*netcat *" } },
|
|
549
|
+
action: "deny",
|
|
550
|
+
message: "Shell commands with netcat are blocked \u2014 potential data exfiltration"
|
|
551
|
+
},
|
|
552
|
+
{
|
|
553
|
+
name: "block-powershell-exfil",
|
|
554
|
+
tool: "shell_exec|run_command|execute_command|bash",
|
|
555
|
+
match: { arguments: { command: "*powershell*|*pwsh*|*Invoke-WebRequest*|*Invoke-RestMethod*|*DownloadString*|*DownloadFile*|*Start-BitsTransfer*" } },
|
|
556
|
+
action: "deny",
|
|
557
|
+
message: "PowerShell command blocked \u2014 potential data exfiltration"
|
|
558
|
+
},
|
|
559
|
+
{
|
|
560
|
+
name: "block-dns-exfil",
|
|
561
|
+
tool: "shell_exec|run_command|execute_command|bash",
|
|
562
|
+
match: { arguments: { command: "*nslookup *|*dig *|*host *" } },
|
|
563
|
+
action: "deny",
|
|
564
|
+
message: "DNS lookup command blocked \u2014 potential DNS exfiltration vector"
|
|
565
|
+
},
|
|
566
|
+
// ── Require approval: scripting language one-liners ──
|
|
567
|
+
{
|
|
568
|
+
name: "approve-script-exec",
|
|
569
|
+
tool: "shell_exec|run_command|execute_command|bash",
|
|
570
|
+
match: { arguments: { command: "*python* -c *|*python3* -c *|*ruby* -e *|*perl* -e *|*node* -e *|*node* --eval*" } },
|
|
571
|
+
action: "prompt",
|
|
572
|
+
message: "Inline script execution requires approval \u2014 may be used for exfiltration"
|
|
573
|
+
},
|
|
574
|
+
// ── Require approval: destructive operations ──
|
|
575
|
+
{
|
|
576
|
+
name: "approve-file-delete",
|
|
577
|
+
tool: "*delete*|*remove*|*unlink*",
|
|
578
|
+
action: "prompt",
|
|
579
|
+
message: "File deletion requires approval"
|
|
580
|
+
},
|
|
581
|
+
{
|
|
582
|
+
name: "approve-shell-exec",
|
|
583
|
+
tool: "shell_exec|run_command|execute_command|bash",
|
|
584
|
+
action: "prompt",
|
|
585
|
+
message: "Shell command execution requires approval"
|
|
586
|
+
},
|
|
587
|
+
// ── Allow: safe read operations ──
|
|
588
|
+
{
|
|
589
|
+
name: "allow-read-file",
|
|
590
|
+
tool: "read_file|get_file_contents|view_file",
|
|
591
|
+
action: "allow"
|
|
592
|
+
},
|
|
593
|
+
{
|
|
594
|
+
name: "allow-list-dir",
|
|
595
|
+
tool: "list_directory|list_dir|ls",
|
|
596
|
+
action: "allow"
|
|
597
|
+
},
|
|
598
|
+
{
|
|
599
|
+
name: "allow-search",
|
|
600
|
+
tool: "search_files|grep|find_files|ripgrep",
|
|
601
|
+
action: "allow"
|
|
602
|
+
}
|
|
603
|
+
]
|
|
604
|
+
};
|
|
605
|
+
}
|
|
606
|
+
function generateDefaultConfigYaml() {
|
|
607
|
+
return `# Agent Wall Policy Configuration
|
|
608
|
+
# Docs: https://github.com/agent-wall/agent-wall
|
|
609
|
+
#
|
|
610
|
+
# Rules are evaluated in order \u2014 first match wins.
|
|
611
|
+
# Actions: allow, deny, prompt (ask human for approval)
|
|
612
|
+
|
|
613
|
+
version: 1
|
|
614
|
+
|
|
615
|
+
# Default action when no rule matches
|
|
616
|
+
defaultAction: prompt
|
|
617
|
+
|
|
618
|
+
# Global rate limit across all tools
|
|
619
|
+
globalRateLimit:
|
|
620
|
+
maxCalls: 200
|
|
621
|
+
windowSeconds: 60
|
|
622
|
+
|
|
623
|
+
# Response scanning \u2014 inspect what the MCP server returns
|
|
624
|
+
# before it reaches the AI agent
|
|
625
|
+
responseScanning:
|
|
626
|
+
enabled: true
|
|
627
|
+
maxResponseSize: 5242880 # 5MB
|
|
628
|
+
oversizeAction: redact # "block" or "redact" (truncate)
|
|
629
|
+
detectSecrets: true # API keys, tokens, private keys
|
|
630
|
+
detectPII: false # Email, phone, SSN, credit cards (opt-in)
|
|
631
|
+
# Custom patterns (optional):
|
|
632
|
+
# patterns:
|
|
633
|
+
# - name: internal-urls
|
|
634
|
+
# pattern: "https?://internal\\.[a-z]+\\.corp"
|
|
635
|
+
# action: redact
|
|
636
|
+
# message: "Internal URL detected"
|
|
637
|
+
# category: custom
|
|
638
|
+
|
|
639
|
+
# Security modules
|
|
640
|
+
security:
|
|
641
|
+
injectionDetection:
|
|
642
|
+
enabled: true
|
|
643
|
+
sensitivity: medium # low, medium, high
|
|
644
|
+
egressControl:
|
|
645
|
+
enabled: true
|
|
646
|
+
blockPrivateIPs: true # Block RFC1918, loopback, link-local IPs
|
|
647
|
+
blockMetadataEndpoints: true # Block cloud metadata (169.254.169.254)
|
|
648
|
+
killSwitch:
|
|
649
|
+
enabled: true
|
|
650
|
+
checkFile: true # Watch for .agent-wall-kill file
|
|
651
|
+
chainDetection:
|
|
652
|
+
enabled: true # Detect exfiltration chains (read\u2192curl, etc.)
|
|
653
|
+
signing: false # HMAC-SHA256 audit log signing
|
|
654
|
+
|
|
655
|
+
rules:
|
|
656
|
+
# \u2500\u2500 Block: Credential Access \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
657
|
+
- name: block-ssh-keys
|
|
658
|
+
tool: "*"
|
|
659
|
+
match:
|
|
660
|
+
arguments:
|
|
661
|
+
path: "**/.ssh/**|**/.ssh"
|
|
662
|
+
action: deny
|
|
663
|
+
message: "Access to SSH keys is blocked"
|
|
664
|
+
|
|
665
|
+
- name: block-env-files
|
|
666
|
+
tool: "*"
|
|
667
|
+
match:
|
|
668
|
+
arguments:
|
|
669
|
+
path: "**/.env*"
|
|
670
|
+
action: deny
|
|
671
|
+
message: "Access to .env files is blocked"
|
|
672
|
+
|
|
673
|
+
- name: block-credential-files
|
|
674
|
+
tool: "*"
|
|
675
|
+
match:
|
|
676
|
+
arguments:
|
|
677
|
+
path: "*credentials*|**/*.pem|**/*.key|**/*.pfx|**/*.p12"
|
|
678
|
+
action: deny
|
|
679
|
+
message: "Access to credential files is blocked"
|
|
680
|
+
|
|
681
|
+
# \u2500\u2500 Block: Exfiltration Patterns \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
682
|
+
- name: block-curl-exfil
|
|
683
|
+
tool: "shell_exec|run_command|execute_command"
|
|
684
|
+
match:
|
|
685
|
+
arguments:
|
|
686
|
+
command: "*curl *"
|
|
687
|
+
action: deny
|
|
688
|
+
message: "Shell commands with curl are blocked (potential exfiltration)"
|
|
689
|
+
|
|
690
|
+
- name: block-wget-exfil
|
|
691
|
+
tool: "shell_exec|run_command|execute_command"
|
|
692
|
+
match:
|
|
693
|
+
arguments:
|
|
694
|
+
command: "*wget *"
|
|
695
|
+
action: deny
|
|
696
|
+
message: "Shell commands with wget are blocked (potential exfiltration)"
|
|
697
|
+
|
|
698
|
+
- name: block-netcat-exfil
|
|
699
|
+
tool: "shell_exec|run_command|execute_command"
|
|
700
|
+
match:
|
|
701
|
+
arguments:
|
|
702
|
+
command: "*nc *|*ncat *|*netcat *"
|
|
703
|
+
action: deny
|
|
704
|
+
message: "Shell commands with netcat are blocked (potential exfiltration)"
|
|
705
|
+
|
|
706
|
+
- name: block-powershell-exfil
|
|
707
|
+
tool: "shell_exec|run_command|execute_command|bash"
|
|
708
|
+
match:
|
|
709
|
+
arguments:
|
|
710
|
+
command: "*powershell*|*pwsh*|*Invoke-WebRequest*|*Invoke-RestMethod*|*DownloadString*|*DownloadFile*|*Start-BitsTransfer*"
|
|
711
|
+
action: deny
|
|
712
|
+
message: "PowerShell command blocked (potential exfiltration)"
|
|
713
|
+
|
|
714
|
+
- name: block-dns-exfil
|
|
715
|
+
tool: "shell_exec|run_command|execute_command|bash"
|
|
716
|
+
match:
|
|
717
|
+
arguments:
|
|
718
|
+
command: "*nslookup *|*dig *|*host *"
|
|
719
|
+
action: deny
|
|
720
|
+
message: "DNS lookup blocked (potential DNS exfiltration)"
|
|
721
|
+
|
|
722
|
+
# \u2500\u2500 Prompt: Scripting One-Liners \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
723
|
+
- name: approve-script-exec
|
|
724
|
+
tool: "shell_exec|run_command|execute_command|bash"
|
|
725
|
+
match:
|
|
726
|
+
arguments:
|
|
727
|
+
command: "*python* -c *|*python3* -c *|*ruby* -e *|*perl* -e *|*node* -e *|*node* --eval*"
|
|
728
|
+
action: prompt
|
|
729
|
+
message: "Inline script execution requires approval"
|
|
730
|
+
|
|
731
|
+
# \u2500\u2500 Prompt: Destructive Operations \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
732
|
+
- name: approve-file-delete
|
|
733
|
+
tool: "*delete*|*remove*|*unlink*"
|
|
734
|
+
action: prompt
|
|
735
|
+
message: "File deletion requires approval"
|
|
736
|
+
|
|
737
|
+
- name: approve-shell-exec
|
|
738
|
+
tool: "shell_exec|run_command|execute_command|bash"
|
|
739
|
+
action: prompt
|
|
740
|
+
message: "Shell command execution requires approval"
|
|
741
|
+
|
|
742
|
+
# \u2500\u2500 Allow: Safe Read Operations \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
743
|
+
- name: allow-read-file
|
|
744
|
+
tool: "read_file|get_file_contents|view_file"
|
|
745
|
+
action: allow
|
|
746
|
+
|
|
747
|
+
- name: allow-list-dir
|
|
748
|
+
tool: "list_directory|list_dir|ls"
|
|
749
|
+
action: allow
|
|
750
|
+
|
|
751
|
+
- name: allow-search
|
|
752
|
+
tool: "search_files|grep|find_files|ripgrep"
|
|
753
|
+
action: allow
|
|
754
|
+
|
|
755
|
+
# \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
756
|
+
# Add your own rules below. Remember: first match wins!
|
|
757
|
+
# \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
758
|
+
`;
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
// src/response-scanner.ts
|
|
762
|
+
var REDOS_DANGEROUS_PATTERNS = [
|
|
763
|
+
/\([^)]*[+*][^)]*\)[+*]/,
|
|
764
|
+
// (x+)+ or (x*)* nested quantifiers
|
|
765
|
+
/\([^)]*\|[^)]*\)[+*]{/,
|
|
766
|
+
// (a|a)+ overlapping alternation with quantifier
|
|
767
|
+
/(.+)\1[+*]/
|
|
768
|
+
// backreference with quantifier
|
|
769
|
+
];
|
|
770
|
+
var MAX_PATTERN_LENGTH = 1e3;
|
|
771
|
+
var DEFAULT_MAX_PATTERNS = 100;
|
|
772
|
+
function isRegexSafe(pattern) {
|
|
773
|
+
if (pattern.length > MAX_PATTERN_LENGTH) return false;
|
|
774
|
+
for (const dangerous of REDOS_DANGEROUS_PATTERNS) {
|
|
775
|
+
if (dangerous.test(pattern)) return false;
|
|
776
|
+
}
|
|
777
|
+
try {
|
|
778
|
+
new RegExp(pattern);
|
|
779
|
+
return true;
|
|
780
|
+
} catch {
|
|
781
|
+
return false;
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
var SECRET_PATTERNS = [
|
|
785
|
+
// ── API Keys & Tokens ──
|
|
786
|
+
{
|
|
787
|
+
name: "aws-access-key",
|
|
788
|
+
pattern: "(?:^|[^A-Za-z0-9])AKIA[0-9A-Z]{16}(?:[^A-Za-z0-9]|$)",
|
|
789
|
+
action: "redact",
|
|
790
|
+
message: "AWS Access Key ID detected in response",
|
|
791
|
+
category: "secrets"
|
|
792
|
+
},
|
|
793
|
+
{
|
|
794
|
+
name: "aws-secret-key",
|
|
795
|
+
pattern: "(?:aws_secret_access_key|aws_secret_key|secret_access_key)\\s*[=:]\\s*[A-Za-z0-9/+=]{40}",
|
|
796
|
+
flags: "gi",
|
|
797
|
+
action: "redact",
|
|
798
|
+
message: "AWS Secret Access Key detected in response",
|
|
799
|
+
category: "secrets"
|
|
800
|
+
},
|
|
801
|
+
{
|
|
802
|
+
name: "github-token",
|
|
803
|
+
pattern: "(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9_]{36,255}",
|
|
804
|
+
action: "redact",
|
|
805
|
+
message: "GitHub token detected in response",
|
|
806
|
+
category: "secrets"
|
|
807
|
+
},
|
|
808
|
+
{
|
|
809
|
+
name: "openai-api-key",
|
|
810
|
+
pattern: "sk-[A-Za-z0-9]{20}T3BlbkFJ[A-Za-z0-9]{20}",
|
|
811
|
+
action: "redact",
|
|
812
|
+
message: "OpenAI API key detected in response",
|
|
813
|
+
category: "secrets"
|
|
814
|
+
},
|
|
815
|
+
{
|
|
816
|
+
name: "generic-api-key",
|
|
817
|
+
pattern: `(?:api[_-]?key|apikey|api[_-]?secret)\\s*[=:]+\\s*["']?[A-Za-z0-9_\\-]{20,}`,
|
|
818
|
+
flags: "gi",
|
|
819
|
+
action: "redact",
|
|
820
|
+
message: "Generic API key detected in response",
|
|
821
|
+
category: "secrets"
|
|
822
|
+
},
|
|
823
|
+
{
|
|
824
|
+
name: "bearer-token",
|
|
825
|
+
pattern: "Bearer\\s+[A-Za-z0-9\\-._~+/]+=*",
|
|
826
|
+
flags: "gi",
|
|
827
|
+
action: "redact",
|
|
828
|
+
message: "Bearer token detected in response",
|
|
829
|
+
category: "secrets"
|
|
830
|
+
},
|
|
831
|
+
{
|
|
832
|
+
name: "jwt-token",
|
|
833
|
+
pattern: "eyJ[A-Za-z0-9_-]{10,}\\.eyJ[A-Za-z0-9_-]{10,}\\.[A-Za-z0-9_\\-]{10,}",
|
|
834
|
+
action: "redact",
|
|
835
|
+
message: "JWT token detected in response",
|
|
836
|
+
category: "secrets"
|
|
837
|
+
},
|
|
838
|
+
// ── Private Keys & Certificates ──
|
|
839
|
+
{
|
|
840
|
+
name: "private-key",
|
|
841
|
+
pattern: "-----BEGIN (?:RSA |EC |DSA |OPENSSH )?PRIVATE KEY-----",
|
|
842
|
+
action: "block",
|
|
843
|
+
message: "Private key detected in response \u2014 blocking entirely",
|
|
844
|
+
category: "secrets"
|
|
845
|
+
},
|
|
846
|
+
{
|
|
847
|
+
name: "certificate",
|
|
848
|
+
pattern: "-----BEGIN CERTIFICATE-----",
|
|
849
|
+
action: "redact",
|
|
850
|
+
message: "Certificate detected in response",
|
|
851
|
+
category: "secrets"
|
|
852
|
+
},
|
|
853
|
+
// ── Connection Strings ──
|
|
854
|
+
{
|
|
855
|
+
name: "database-url",
|
|
856
|
+
pattern: `(?:postgres|mysql|mongodb|redis|amqp)://[^\\s"']+:[^\\s"']+@[^\\s"']+`,
|
|
857
|
+
flags: "gi",
|
|
858
|
+
action: "redact",
|
|
859
|
+
message: "Database connection string with credentials detected",
|
|
860
|
+
category: "secrets"
|
|
861
|
+
},
|
|
862
|
+
// ── Password Patterns ──
|
|
863
|
+
{
|
|
864
|
+
name: "password-assignment",
|
|
865
|
+
pattern: `(?:password|passwd|pwd)\\s*[=:]\\s*["']?[^\\s"']{8,}`,
|
|
866
|
+
flags: "gi",
|
|
867
|
+
action: "redact",
|
|
868
|
+
message: "Password assignment detected in response",
|
|
869
|
+
category: "secrets"
|
|
870
|
+
}
|
|
871
|
+
];
|
|
872
|
+
function getExfiltrationPatterns(base64Action) {
|
|
873
|
+
return [
|
|
874
|
+
{
|
|
875
|
+
name: "large-base64-blob",
|
|
876
|
+
pattern: "(?:[A-Za-z0-9+/]{100,}={0,2})",
|
|
877
|
+
action: base64Action,
|
|
878
|
+
message: "Large base64-encoded blob detected",
|
|
879
|
+
category: "exfiltration"
|
|
880
|
+
},
|
|
881
|
+
{
|
|
882
|
+
name: "hex-dump",
|
|
883
|
+
pattern: "(?:[0-9a-f]{2}[:\\s]){32,}",
|
|
884
|
+
flags: "gi",
|
|
885
|
+
action: "pass",
|
|
886
|
+
message: "Large hex dump detected (informational)",
|
|
887
|
+
category: "exfiltration"
|
|
888
|
+
}
|
|
889
|
+
];
|
|
890
|
+
}
|
|
891
|
+
var PII_PATTERNS = [
|
|
892
|
+
{
|
|
893
|
+
name: "email-address",
|
|
894
|
+
pattern: "[a-zA-Z0-9._%+\\-]+@[a-zA-Z0-9.\\-]+\\.[a-zA-Z]{2,}",
|
|
895
|
+
action: "redact",
|
|
896
|
+
message: "Email address detected in response",
|
|
897
|
+
category: "pii"
|
|
898
|
+
},
|
|
899
|
+
{
|
|
900
|
+
name: "phone-number",
|
|
901
|
+
pattern: "(?:\\+?1[\\s.-]?)?\\(?[0-9]{3}\\)?[\\s.-]?[0-9]{3}[\\s.-]?[0-9]{4}",
|
|
902
|
+
action: "redact",
|
|
903
|
+
message: "Phone number detected in response",
|
|
904
|
+
category: "pii"
|
|
905
|
+
},
|
|
906
|
+
{
|
|
907
|
+
name: "ssn",
|
|
908
|
+
pattern: "\\b\\d{3}-\\d{2}-\\d{4}\\b",
|
|
909
|
+
action: "block",
|
|
910
|
+
message: "Social Security Number detected \u2014 blocking response",
|
|
911
|
+
category: "pii"
|
|
912
|
+
},
|
|
913
|
+
{
|
|
914
|
+
name: "credit-card",
|
|
915
|
+
pattern: "\\b(?:4[0-9]{12}(?:[0-9]{3})?|5[1-5][0-9]{14}|3[47][0-9]{13}|6(?:011|5[0-9]{2})[0-9]{12})\\b",
|
|
916
|
+
action: "block",
|
|
917
|
+
message: "Credit card number detected \u2014 blocking response",
|
|
918
|
+
category: "pii"
|
|
919
|
+
},
|
|
920
|
+
{
|
|
921
|
+
name: "ip-address",
|
|
922
|
+
pattern: "\\b(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\\b",
|
|
923
|
+
action: "pass",
|
|
924
|
+
message: "IP address detected (informational)",
|
|
925
|
+
category: "pii"
|
|
926
|
+
}
|
|
927
|
+
];
|
|
928
|
+
var ResponseScanner = class {
|
|
929
|
+
config;
|
|
930
|
+
compiledPatterns = [];
|
|
931
|
+
rejectedPatterns = [];
|
|
932
|
+
constructor(config = {}) {
|
|
933
|
+
this.config = {
|
|
934
|
+
enabled: config.enabled ?? true,
|
|
935
|
+
maxResponseSize: config.maxResponseSize ?? 0,
|
|
936
|
+
oversizeAction: config.oversizeAction ?? "redact",
|
|
937
|
+
detectSecrets: config.detectSecrets ?? true,
|
|
938
|
+
detectPII: config.detectPII ?? false,
|
|
939
|
+
base64Action: config.base64Action ?? "pass",
|
|
940
|
+
maxPatterns: config.maxPatterns ?? DEFAULT_MAX_PATTERNS,
|
|
941
|
+
patterns: config.patterns ?? []
|
|
942
|
+
};
|
|
943
|
+
this.compilePatterns();
|
|
944
|
+
}
|
|
945
|
+
/**
|
|
946
|
+
* Compile all regex patterns upfront for performance.
|
|
947
|
+
* Validates each pattern for ReDoS safety before compilation.
|
|
948
|
+
*/
|
|
949
|
+
compilePatterns() {
|
|
950
|
+
this.compiledPatterns = [];
|
|
951
|
+
this.rejectedPatterns = [];
|
|
952
|
+
if (this.config.detectSecrets) {
|
|
953
|
+
for (const p of SECRET_PATTERNS) {
|
|
954
|
+
this.safeCompile(p, true);
|
|
955
|
+
}
|
|
956
|
+
}
|
|
957
|
+
if (this.config.detectSecrets) {
|
|
958
|
+
for (const p of getExfiltrationPatterns(this.config.base64Action)) {
|
|
959
|
+
this.safeCompile(p, true);
|
|
960
|
+
}
|
|
961
|
+
}
|
|
962
|
+
if (this.config.detectPII) {
|
|
963
|
+
for (const p of PII_PATTERNS) {
|
|
964
|
+
this.safeCompile(p, true);
|
|
965
|
+
}
|
|
966
|
+
}
|
|
967
|
+
const userPatterns = this.config.patterns ?? [];
|
|
968
|
+
const maxAllowed = this.config.maxPatterns;
|
|
969
|
+
const limited = userPatterns.slice(0, maxAllowed);
|
|
970
|
+
if (userPatterns.length > maxAllowed) {
|
|
971
|
+
this.rejectedPatterns.push(
|
|
972
|
+
`${userPatterns.length - maxAllowed} patterns exceeded max limit of ${maxAllowed}`
|
|
973
|
+
);
|
|
974
|
+
}
|
|
975
|
+
for (const p of limited) {
|
|
976
|
+
this.safeCompile(p, false);
|
|
977
|
+
}
|
|
978
|
+
}
|
|
979
|
+
/**
|
|
980
|
+
* Safely compile a pattern. For user patterns, validate ReDoS safety first.
|
|
981
|
+
*/
|
|
982
|
+
safeCompile(pattern, trusted) {
|
|
983
|
+
if (!trusted) {
|
|
984
|
+
if (!isRegexSafe(pattern.pattern)) {
|
|
985
|
+
this.rejectedPatterns.push(
|
|
986
|
+
`Pattern "${pattern.name}" rejected: potentially unsafe regex (ReDoS risk)`
|
|
987
|
+
);
|
|
988
|
+
return;
|
|
989
|
+
}
|
|
990
|
+
}
|
|
991
|
+
try {
|
|
992
|
+
const regex = new RegExp(pattern.pattern, pattern.flags ?? "gi");
|
|
993
|
+
this.compiledPatterns.push({ pattern, regex });
|
|
994
|
+
} catch {
|
|
995
|
+
this.rejectedPatterns.push(
|
|
996
|
+
`Pattern "${pattern.name}" rejected: invalid regex`
|
|
997
|
+
);
|
|
998
|
+
}
|
|
999
|
+
}
|
|
1000
|
+
/**
|
|
1001
|
+
* Scan a response text for sensitive content.
|
|
1002
|
+
*/
|
|
1003
|
+
scan(text) {
|
|
1004
|
+
if (!this.config.enabled) {
|
|
1005
|
+
return {
|
|
1006
|
+
clean: true,
|
|
1007
|
+
action: "pass",
|
|
1008
|
+
findings: [],
|
|
1009
|
+
originalSize: Buffer.byteLength(text, "utf-8")
|
|
1010
|
+
};
|
|
1011
|
+
}
|
|
1012
|
+
const originalSize = Buffer.byteLength(text, "utf-8");
|
|
1013
|
+
const findings = [];
|
|
1014
|
+
if (this.config.maxResponseSize && this.config.maxResponseSize > 0) {
|
|
1015
|
+
if (originalSize > this.config.maxResponseSize) {
|
|
1016
|
+
findings.push({
|
|
1017
|
+
pattern: "__oversize__",
|
|
1018
|
+
category: "size",
|
|
1019
|
+
action: this.config.oversizeAction ?? "redact",
|
|
1020
|
+
message: `Response size (${originalSize} bytes) exceeds limit (${this.config.maxResponseSize} bytes)`,
|
|
1021
|
+
matchCount: 1
|
|
1022
|
+
});
|
|
1023
|
+
}
|
|
1024
|
+
}
|
|
1025
|
+
for (const { pattern, regex } of this.compiledPatterns) {
|
|
1026
|
+
regex.lastIndex = 0;
|
|
1027
|
+
const matches = text.match(regex);
|
|
1028
|
+
if (matches && matches.length > 0) {
|
|
1029
|
+
findings.push({
|
|
1030
|
+
pattern: pattern.name,
|
|
1031
|
+
category: pattern.category ?? "custom",
|
|
1032
|
+
action: pattern.action,
|
|
1033
|
+
message: pattern.message ?? `Pattern "${pattern.name}" matched`,
|
|
1034
|
+
matchCount: matches.length,
|
|
1035
|
+
preview: this.createPreview(matches[0])
|
|
1036
|
+
});
|
|
1037
|
+
}
|
|
1038
|
+
}
|
|
1039
|
+
if (findings.length === 0) {
|
|
1040
|
+
return { clean: true, action: "pass", findings: [], originalSize };
|
|
1041
|
+
}
|
|
1042
|
+
const overallAction = this.resolveAction(findings);
|
|
1043
|
+
const result = {
|
|
1044
|
+
clean: false,
|
|
1045
|
+
action: overallAction,
|
|
1046
|
+
findings,
|
|
1047
|
+
originalSize
|
|
1048
|
+
};
|
|
1049
|
+
if (overallAction === "redact") {
|
|
1050
|
+
result.redactedText = this.redactText(text, findings);
|
|
1051
|
+
}
|
|
1052
|
+
return result;
|
|
1053
|
+
}
|
|
1054
|
+
/**
|
|
1055
|
+
* Scan the content array from an MCP tools/call response.
|
|
1056
|
+
* MCP responses have the shape: { content: [{ type: "text", text: "..." }, ...] }
|
|
1057
|
+
*/
|
|
1058
|
+
scanMcpResponse(result) {
|
|
1059
|
+
const text = this.extractText(result);
|
|
1060
|
+
return this.scan(text);
|
|
1061
|
+
}
|
|
1062
|
+
/**
|
|
1063
|
+
* Extract all text content from an MCP response result.
|
|
1064
|
+
*/
|
|
1065
|
+
extractText(result) {
|
|
1066
|
+
if (typeof result === "string") return result;
|
|
1067
|
+
if (!result || typeof result !== "object") return "";
|
|
1068
|
+
const obj = result;
|
|
1069
|
+
if (Array.isArray(obj.content)) {
|
|
1070
|
+
return obj.content.filter((c) => c?.type === "text" && typeof c?.text === "string").map((c) => c.text).join("\n");
|
|
1071
|
+
}
|
|
1072
|
+
try {
|
|
1073
|
+
return JSON.stringify(result);
|
|
1074
|
+
} catch {
|
|
1075
|
+
return "";
|
|
1076
|
+
}
|
|
1077
|
+
}
|
|
1078
|
+
/**
|
|
1079
|
+
* Resolve the highest-severity action from all findings.
|
|
1080
|
+
* Priority: block > redact > pass
|
|
1081
|
+
*/
|
|
1082
|
+
resolveAction(findings) {
|
|
1083
|
+
let highest = "pass";
|
|
1084
|
+
for (const f of findings) {
|
|
1085
|
+
if (f.action === "block") return "block";
|
|
1086
|
+
if (f.action === "redact") highest = "redact";
|
|
1087
|
+
}
|
|
1088
|
+
return highest;
|
|
1089
|
+
}
|
|
1090
|
+
/**
|
|
1091
|
+
* Redact matched patterns from the text.
|
|
1092
|
+
* Uses generic [REDACTED] marker — never leaks pattern names.
|
|
1093
|
+
*/
|
|
1094
|
+
redactText(text, findings) {
|
|
1095
|
+
let redacted = text;
|
|
1096
|
+
if (this.config.maxResponseSize && this.config.maxResponseSize > 0) {
|
|
1097
|
+
const sizeExceeded = findings.some((f) => f.pattern === "__oversize__");
|
|
1098
|
+
if (sizeExceeded) {
|
|
1099
|
+
const limit = this.config.maxResponseSize;
|
|
1100
|
+
redacted = redacted.slice(0, limit) + "\n\n[Agent Wall: Response truncated \u2014 exceeded size limit]";
|
|
1101
|
+
}
|
|
1102
|
+
}
|
|
1103
|
+
for (const { pattern, regex } of this.compiledPatterns) {
|
|
1104
|
+
const finding = findings.find((f) => f.pattern === pattern.name);
|
|
1105
|
+
if (!finding || finding.action !== "redact") continue;
|
|
1106
|
+
regex.lastIndex = 0;
|
|
1107
|
+
redacted = redacted.replace(regex, `[REDACTED]`);
|
|
1108
|
+
}
|
|
1109
|
+
return redacted;
|
|
1110
|
+
}
|
|
1111
|
+
/**
|
|
1112
|
+
* Create a safe preview of matched content (first 4 chars + last 4).
|
|
1113
|
+
*/
|
|
1114
|
+
createPreview(match) {
|
|
1115
|
+
if (match.length <= 8) return "***";
|
|
1116
|
+
const start = match.slice(0, 4);
|
|
1117
|
+
const end = match.slice(-4);
|
|
1118
|
+
return `${start}...${end}`;
|
|
1119
|
+
}
|
|
1120
|
+
/**
|
|
1121
|
+
* Get the current configuration.
|
|
1122
|
+
*/
|
|
1123
|
+
getConfig() {
|
|
1124
|
+
return { ...this.config };
|
|
1125
|
+
}
|
|
1126
|
+
/**
|
|
1127
|
+
* Get the number of compiled patterns.
|
|
1128
|
+
*/
|
|
1129
|
+
getPatternCount() {
|
|
1130
|
+
return this.compiledPatterns.length;
|
|
1131
|
+
}
|
|
1132
|
+
/**
|
|
1133
|
+
* Get list of patterns that were rejected during compilation.
|
|
1134
|
+
*/
|
|
1135
|
+
getRejectedPatterns() {
|
|
1136
|
+
return [...this.rejectedPatterns];
|
|
1137
|
+
}
|
|
1138
|
+
/**
|
|
1139
|
+
* Update configuration (e.g., after policy file reload).
|
|
1140
|
+
*/
|
|
1141
|
+
updateConfig(config) {
|
|
1142
|
+
this.config = {
|
|
1143
|
+
enabled: config.enabled ?? true,
|
|
1144
|
+
maxResponseSize: config.maxResponseSize ?? 0,
|
|
1145
|
+
oversizeAction: config.oversizeAction ?? "redact",
|
|
1146
|
+
detectSecrets: config.detectSecrets ?? true,
|
|
1147
|
+
detectPII: config.detectPII ?? false,
|
|
1148
|
+
base64Action: config.base64Action ?? "pass",
|
|
1149
|
+
maxPatterns: config.maxPatterns ?? DEFAULT_MAX_PATTERNS,
|
|
1150
|
+
patterns: config.patterns ?? []
|
|
1151
|
+
};
|
|
1152
|
+
this.compilePatterns();
|
|
1153
|
+
}
|
|
1154
|
+
};
|
|
1155
|
+
function createDefaultScanner() {
|
|
1156
|
+
return new ResponseScanner({
|
|
1157
|
+
enabled: true,
|
|
1158
|
+
maxResponseSize: 5 * 1024 * 1024,
|
|
1159
|
+
// 5MB
|
|
1160
|
+
oversizeAction: "redact",
|
|
1161
|
+
detectSecrets: true,
|
|
1162
|
+
detectPII: false
|
|
1163
|
+
});
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1166
|
+
// src/audit-logger.ts
|
|
1167
|
+
import * as crypto from "crypto";
|
|
1168
|
+
import * as fs2 from "fs";
|
|
1169
|
+
import * as path3 from "path";
|
|
1170
|
+
var SENSITIVE_PATTERNS = [
|
|
1171
|
+
/password/i,
|
|
1172
|
+
/secret/i,
|
|
1173
|
+
/token/i,
|
|
1174
|
+
/api[_-]?key/i,
|
|
1175
|
+
/auth/i,
|
|
1176
|
+
/credential/i,
|
|
1177
|
+
/private[_-]?key/i,
|
|
1178
|
+
/access[_-]?key/i
|
|
1179
|
+
];
|
|
1180
|
+
var DEFAULT_MAX_FILE_SIZE = 50 * 1024 * 1024;
|
|
1181
|
+
var DEFAULT_MAX_FILES = 5;
|
|
1182
|
+
var AuditLogger = class {
|
|
1183
|
+
options;
|
|
1184
|
+
fileFd = null;
|
|
1185
|
+
entries = [];
|
|
1186
|
+
prevHash = "genesis";
|
|
1187
|
+
seqCounter = 0;
|
|
1188
|
+
currentFileSize = 0;
|
|
1189
|
+
constructor(options = {}) {
|
|
1190
|
+
this.options = {
|
|
1191
|
+
stdout: options.stdout ?? true,
|
|
1192
|
+
filePath: options.filePath ?? "",
|
|
1193
|
+
redact: options.redact ?? true,
|
|
1194
|
+
maxArgLength: options.maxArgLength ?? 200,
|
|
1195
|
+
silent: options.silent ?? false,
|
|
1196
|
+
signing: options.signing ?? false,
|
|
1197
|
+
signingKey: options.signingKey ?? crypto.randomBytes(32).toString("hex"),
|
|
1198
|
+
maxFileSize: options.maxFileSize ?? DEFAULT_MAX_FILE_SIZE,
|
|
1199
|
+
maxFiles: options.maxFiles ?? DEFAULT_MAX_FILES,
|
|
1200
|
+
onEntry: options.onEntry
|
|
1201
|
+
};
|
|
1202
|
+
if (this.options.filePath) {
|
|
1203
|
+
this.openLogFile();
|
|
1204
|
+
}
|
|
1205
|
+
}
|
|
1206
|
+
/**
|
|
1207
|
+
* Open or reopen the log file using synchronous fd (reliable on Windows).
|
|
1208
|
+
*/
|
|
1209
|
+
openLogFile() {
|
|
1210
|
+
const dir = path3.dirname(this.options.filePath);
|
|
1211
|
+
if (!fs2.existsSync(dir)) {
|
|
1212
|
+
fs2.mkdirSync(dir, { recursive: true });
|
|
1213
|
+
}
|
|
1214
|
+
try {
|
|
1215
|
+
const stat = fs2.statSync(this.options.filePath);
|
|
1216
|
+
this.currentFileSize = stat.size;
|
|
1217
|
+
} catch {
|
|
1218
|
+
this.currentFileSize = 0;
|
|
1219
|
+
}
|
|
1220
|
+
this.fileFd = fs2.openSync(this.options.filePath, "a");
|
|
1221
|
+
}
|
|
1222
|
+
/**
|
|
1223
|
+
* Log a tool call with its policy verdict.
|
|
1224
|
+
*/
|
|
1225
|
+
log(entry) {
|
|
1226
|
+
const processed = this.options.redact ? this.redactEntry(entry) : entry;
|
|
1227
|
+
this.entries.push(processed);
|
|
1228
|
+
let outputEntry = { ...processed };
|
|
1229
|
+
if (this.options.signing) {
|
|
1230
|
+
this.seqCounter++;
|
|
1231
|
+
const sig = this.computeHmac(processed);
|
|
1232
|
+
outputEntry._seq = this.seqCounter;
|
|
1233
|
+
outputEntry._sig = sig;
|
|
1234
|
+
this.prevHash = sig;
|
|
1235
|
+
}
|
|
1236
|
+
const line = JSON.stringify(outputEntry);
|
|
1237
|
+
if (this.options.stdout && !this.options.silent) {
|
|
1238
|
+
process.stderr.write(`[agent-wall] ${line}
|
|
1239
|
+
`);
|
|
1240
|
+
}
|
|
1241
|
+
if (this.fileFd !== null) {
|
|
1242
|
+
const data = line + "\n";
|
|
1243
|
+
const bytes = Buffer.byteLength(data, "utf-8");
|
|
1244
|
+
fs2.writeSync(this.fileFd, data);
|
|
1245
|
+
this.currentFileSize += bytes;
|
|
1246
|
+
if (this.options.maxFileSize > 0 && this.currentFileSize >= this.options.maxFileSize) {
|
|
1247
|
+
this.rotateLogFile();
|
|
1248
|
+
}
|
|
1249
|
+
}
|
|
1250
|
+
this.options.onEntry?.(processed);
|
|
1251
|
+
}
|
|
1252
|
+
/**
|
|
1253
|
+
* Compute HMAC-SHA256 for a log entry in the chain.
|
|
1254
|
+
* Chain: HMAC(entry_json + prev_hash)
|
|
1255
|
+
*/
|
|
1256
|
+
computeHmac(entry) {
|
|
1257
|
+
const payload = JSON.stringify(entry) + "|" + this.prevHash;
|
|
1258
|
+
return crypto.createHmac("sha256", this.options.signingKey).update(payload).digest("hex");
|
|
1259
|
+
}
|
|
1260
|
+
/**
|
|
1261
|
+
* Rotate log files: current → .1, .1 → .2, etc.
|
|
1262
|
+
* Oldest file beyond maxFiles is deleted.
|
|
1263
|
+
*/
|
|
1264
|
+
rotateLogFile() {
|
|
1265
|
+
if (this.fileFd !== null) {
|
|
1266
|
+
fs2.closeSync(this.fileFd);
|
|
1267
|
+
this.fileFd = null;
|
|
1268
|
+
}
|
|
1269
|
+
const basePath = this.options.filePath;
|
|
1270
|
+
const oldest = `${basePath}.${this.options.maxFiles}`;
|
|
1271
|
+
try {
|
|
1272
|
+
fs2.unlinkSync(oldest);
|
|
1273
|
+
} catch {
|
|
1274
|
+
}
|
|
1275
|
+
for (let i = this.options.maxFiles - 1; i >= 1; i--) {
|
|
1276
|
+
const src = `${basePath}.${i}`;
|
|
1277
|
+
const dst = `${basePath}.${i + 1}`;
|
|
1278
|
+
try {
|
|
1279
|
+
fs2.renameSync(src, dst);
|
|
1280
|
+
} catch {
|
|
1281
|
+
}
|
|
1282
|
+
}
|
|
1283
|
+
try {
|
|
1284
|
+
fs2.renameSync(basePath, `${basePath}.1`);
|
|
1285
|
+
} catch {
|
|
1286
|
+
}
|
|
1287
|
+
this.currentFileSize = 0;
|
|
1288
|
+
this.openLogFile();
|
|
1289
|
+
}
|
|
1290
|
+
/**
|
|
1291
|
+
* Log a denied tool call (convenience method).
|
|
1292
|
+
*/
|
|
1293
|
+
logDeny(sessionId, tool, args, ruleName, message) {
|
|
1294
|
+
this.log({
|
|
1295
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1296
|
+
sessionId,
|
|
1297
|
+
direction: "request",
|
|
1298
|
+
method: "tools/call",
|
|
1299
|
+
tool,
|
|
1300
|
+
arguments: args,
|
|
1301
|
+
verdict: {
|
|
1302
|
+
action: "deny",
|
|
1303
|
+
rule: ruleName,
|
|
1304
|
+
message
|
|
1305
|
+
}
|
|
1306
|
+
});
|
|
1307
|
+
}
|
|
1308
|
+
/**
|
|
1309
|
+
* Log an allowed tool call (convenience method).
|
|
1310
|
+
*/
|
|
1311
|
+
logAllow(sessionId, tool, args, ruleName, message) {
|
|
1312
|
+
this.log({
|
|
1313
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1314
|
+
sessionId,
|
|
1315
|
+
direction: "request",
|
|
1316
|
+
method: "tools/call",
|
|
1317
|
+
tool,
|
|
1318
|
+
arguments: args,
|
|
1319
|
+
verdict: {
|
|
1320
|
+
action: "allow",
|
|
1321
|
+
rule: ruleName,
|
|
1322
|
+
message
|
|
1323
|
+
}
|
|
1324
|
+
});
|
|
1325
|
+
}
|
|
1326
|
+
/**
|
|
1327
|
+
* Get all logged entries (for the audit command).
|
|
1328
|
+
*/
|
|
1329
|
+
getEntries() {
|
|
1330
|
+
return this.entries;
|
|
1331
|
+
}
|
|
1332
|
+
/**
|
|
1333
|
+
* Get summary statistics.
|
|
1334
|
+
*/
|
|
1335
|
+
getStats() {
|
|
1336
|
+
let allowed = 0;
|
|
1337
|
+
let denied = 0;
|
|
1338
|
+
let prompted = 0;
|
|
1339
|
+
for (const entry of this.entries) {
|
|
1340
|
+
switch (entry.verdict?.action) {
|
|
1341
|
+
case "allow":
|
|
1342
|
+
allowed++;
|
|
1343
|
+
break;
|
|
1344
|
+
case "deny":
|
|
1345
|
+
denied++;
|
|
1346
|
+
break;
|
|
1347
|
+
case "prompt":
|
|
1348
|
+
prompted++;
|
|
1349
|
+
break;
|
|
1350
|
+
}
|
|
1351
|
+
}
|
|
1352
|
+
return {
|
|
1353
|
+
total: this.entries.length,
|
|
1354
|
+
allowed,
|
|
1355
|
+
denied,
|
|
1356
|
+
prompted
|
|
1357
|
+
};
|
|
1358
|
+
}
|
|
1359
|
+
/**
|
|
1360
|
+
* Redact sensitive argument values.
|
|
1361
|
+
*/
|
|
1362
|
+
redactEntry(entry) {
|
|
1363
|
+
if (!entry.arguments) return entry;
|
|
1364
|
+
const redacted = {};
|
|
1365
|
+
for (const [key, value] of Object.entries(entry.arguments)) {
|
|
1366
|
+
if (SENSITIVE_PATTERNS.some((p) => p.test(key))) {
|
|
1367
|
+
redacted[key] = "[REDACTED]";
|
|
1368
|
+
} else if (typeof value === "string" && value.length > this.options.maxArgLength) {
|
|
1369
|
+
redacted[key] = value.slice(0, this.options.maxArgLength) + "...[truncated]";
|
|
1370
|
+
} else {
|
|
1371
|
+
redacted[key] = value;
|
|
1372
|
+
}
|
|
1373
|
+
}
|
|
1374
|
+
return { ...entry, arguments: redacted };
|
|
1375
|
+
}
|
|
1376
|
+
/**
|
|
1377
|
+
* Verify the HMAC chain integrity of a log file.
|
|
1378
|
+
* Returns { valid: boolean, entries: number, firstBroken: number | null }
|
|
1379
|
+
*/
|
|
1380
|
+
static verifyChain(logFilePath, signingKey) {
|
|
1381
|
+
const content = fs2.readFileSync(logFilePath, "utf-8");
|
|
1382
|
+
const lines = content.trim().split("\n").filter(Boolean);
|
|
1383
|
+
let prevHash = "genesis";
|
|
1384
|
+
let firstBroken = null;
|
|
1385
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1386
|
+
const parsed = JSON.parse(lines[i]);
|
|
1387
|
+
const { _sig, _seq, ...entry } = parsed;
|
|
1388
|
+
if (!_sig) {
|
|
1389
|
+
continue;
|
|
1390
|
+
}
|
|
1391
|
+
const payload = JSON.stringify(entry) + "|" + prevHash;
|
|
1392
|
+
const expected = crypto.createHmac("sha256", signingKey).update(payload).digest("hex");
|
|
1393
|
+
if (_sig !== expected) {
|
|
1394
|
+
if (firstBroken === null) firstBroken = i;
|
|
1395
|
+
}
|
|
1396
|
+
prevHash = _sig;
|
|
1397
|
+
}
|
|
1398
|
+
return {
|
|
1399
|
+
valid: firstBroken === null,
|
|
1400
|
+
entries: lines.length,
|
|
1401
|
+
firstBroken
|
|
1402
|
+
};
|
|
1403
|
+
}
|
|
1404
|
+
/**
|
|
1405
|
+
* Set or replace the onEntry callback (for dashboard streaming).
|
|
1406
|
+
*/
|
|
1407
|
+
setOnEntry(callback) {
|
|
1408
|
+
this.options.onEntry = callback;
|
|
1409
|
+
}
|
|
1410
|
+
/**
|
|
1411
|
+
* Close the logger (flush file stream).
|
|
1412
|
+
*/
|
|
1413
|
+
close() {
|
|
1414
|
+
if (this.fileFd !== null) {
|
|
1415
|
+
fs2.closeSync(this.fileFd);
|
|
1416
|
+
this.fileFd = null;
|
|
1417
|
+
}
|
|
1418
|
+
}
|
|
1419
|
+
};
|
|
1420
|
+
function checkFilePermissions(filePath) {
|
|
1421
|
+
const warnings = [];
|
|
1422
|
+
try {
|
|
1423
|
+
const stat = fs2.statSync(filePath);
|
|
1424
|
+
const mode = stat.mode;
|
|
1425
|
+
if (mode !== void 0) {
|
|
1426
|
+
if (mode & 2) {
|
|
1427
|
+
warnings.push(
|
|
1428
|
+
`Policy file is world-writable (mode ${(mode & 511).toString(8)}). Run: chmod 644 ${filePath}`
|
|
1429
|
+
);
|
|
1430
|
+
}
|
|
1431
|
+
if (mode & 16) {
|
|
1432
|
+
warnings.push(
|
|
1433
|
+
`Policy file is group-writable (mode ${(mode & 511).toString(8)}). Consider: chmod 644 ${filePath}`
|
|
1434
|
+
);
|
|
1435
|
+
}
|
|
1436
|
+
}
|
|
1437
|
+
const lstat = fs2.lstatSync(filePath);
|
|
1438
|
+
if (lstat.isSymbolicLink()) {
|
|
1439
|
+
warnings.push(
|
|
1440
|
+
`Policy file is a symbolic link. Ensure it points to a trusted location.`
|
|
1441
|
+
);
|
|
1442
|
+
}
|
|
1443
|
+
} catch (err) {
|
|
1444
|
+
warnings.push(`Cannot check permissions for ${filePath}: ${err}`);
|
|
1445
|
+
}
|
|
1446
|
+
return {
|
|
1447
|
+
safe: warnings.length === 0,
|
|
1448
|
+
warnings
|
|
1449
|
+
};
|
|
1450
|
+
}
|
|
1451
|
+
|
|
1452
|
+
// src/proxy.ts
|
|
1453
|
+
import spawn from "cross-spawn";
|
|
1454
|
+
import * as crypto2 from "crypto";
|
|
1455
|
+
import * as fs3 from "fs";
|
|
1456
|
+
import * as readline from "readline";
|
|
1457
|
+
import { EventEmitter } from "events";
|
|
1458
|
+
var DEFAULT_PENDING_CALL_TTL_MS = 3e4;
|
|
1459
|
+
var PENDING_CALL_CLEANUP_INTERVAL_MS = 1e4;
|
|
1460
|
+
var StdioProxy = class extends EventEmitter {
|
|
1461
|
+
child = null;
|
|
1462
|
+
clientBuffer;
|
|
1463
|
+
serverBuffer;
|
|
1464
|
+
options;
|
|
1465
|
+
sessionId;
|
|
1466
|
+
running = false;
|
|
1467
|
+
stats = { forwarded: 0, denied: 0, prompted: 0, total: 0, scanned: 0, responseBlocked: 0, responseRedacted: 0 };
|
|
1468
|
+
/** Track pending tools/call requests by JSON-RPC id, so we can correlate responses */
|
|
1469
|
+
pendingToolCalls = /* @__PURE__ */ new Map();
|
|
1470
|
+
/** Cleanup timer for expired pending calls */
|
|
1471
|
+
pendingCleanupTimer = null;
|
|
1472
|
+
pendingCallTtlMs;
|
|
1473
|
+
constructor(options) {
|
|
1474
|
+
super();
|
|
1475
|
+
this.options = options;
|
|
1476
|
+
const maxBuf = options.maxBufferSize;
|
|
1477
|
+
this.clientBuffer = new ReadBuffer(maxBuf);
|
|
1478
|
+
this.serverBuffer = new ReadBuffer(maxBuf);
|
|
1479
|
+
this.sessionId = options.sessionId ?? `ag-${crypto2.randomUUID()}`;
|
|
1480
|
+
this.pendingCallTtlMs = options.pendingCallTtlMs ?? DEFAULT_PENDING_CALL_TTL_MS;
|
|
1481
|
+
this.on("error", (err) => {
|
|
1482
|
+
this.options.onError?.(err);
|
|
1483
|
+
});
|
|
1484
|
+
}
|
|
1485
|
+
/**
|
|
1486
|
+
* Start the proxy — spawn the child MCP server and begin intercepting.
|
|
1487
|
+
*/
|
|
1488
|
+
async start() {
|
|
1489
|
+
return new Promise((resolve3, reject) => {
|
|
1490
|
+
try {
|
|
1491
|
+
this.child = spawn(this.options.command, this.options.args ?? [], {
|
|
1492
|
+
stdio: ["pipe", "pipe", "inherit"],
|
|
1493
|
+
env: {
|
|
1494
|
+
...process.env,
|
|
1495
|
+
...this.options.env
|
|
1496
|
+
},
|
|
1497
|
+
cwd: this.options.cwd,
|
|
1498
|
+
shell: false,
|
|
1499
|
+
windowsHide: true
|
|
1500
|
+
});
|
|
1501
|
+
this.child.on("error", (err) => {
|
|
1502
|
+
this.options.onError?.(err);
|
|
1503
|
+
this.emit("error", err);
|
|
1504
|
+
reject(err);
|
|
1505
|
+
});
|
|
1506
|
+
this.child.on("spawn", () => {
|
|
1507
|
+
this.running = true;
|
|
1508
|
+
this.setupPipelines();
|
|
1509
|
+
this.startPendingCallCleanup();
|
|
1510
|
+
this.options.onReady?.();
|
|
1511
|
+
this.emit("ready");
|
|
1512
|
+
resolve3();
|
|
1513
|
+
});
|
|
1514
|
+
this.child.on("close", (code) => {
|
|
1515
|
+
this.running = false;
|
|
1516
|
+
this.options.onExit?.(code);
|
|
1517
|
+
this.emit("exit", code);
|
|
1518
|
+
});
|
|
1519
|
+
} catch (err) {
|
|
1520
|
+
reject(err);
|
|
1521
|
+
}
|
|
1522
|
+
});
|
|
1523
|
+
}
|
|
1524
|
+
/**
|
|
1525
|
+
* Stop the proxy — gracefully shut down the child process.
|
|
1526
|
+
* Follows the MCP SDK pattern: stdin.end() → SIGTERM → SIGKILL
|
|
1527
|
+
*/
|
|
1528
|
+
async stop() {
|
|
1529
|
+
if (!this.child) return;
|
|
1530
|
+
const child = this.child;
|
|
1531
|
+
this.child = null;
|
|
1532
|
+
this.running = false;
|
|
1533
|
+
const closePromise = new Promise((resolve3) => {
|
|
1534
|
+
child.once("close", () => resolve3());
|
|
1535
|
+
});
|
|
1536
|
+
try {
|
|
1537
|
+
child.stdin?.end();
|
|
1538
|
+
} catch {
|
|
1539
|
+
}
|
|
1540
|
+
const timeout = (ms) => new Promise((resolve3) => {
|
|
1541
|
+
const t = setTimeout(resolve3, ms);
|
|
1542
|
+
t.unref();
|
|
1543
|
+
});
|
|
1544
|
+
await Promise.race([closePromise, timeout(2e3)]);
|
|
1545
|
+
if (child.exitCode === null) {
|
|
1546
|
+
try {
|
|
1547
|
+
child.kill("SIGTERM");
|
|
1548
|
+
} catch {
|
|
1549
|
+
}
|
|
1550
|
+
await Promise.race([closePromise, timeout(2e3)]);
|
|
1551
|
+
}
|
|
1552
|
+
if (child.exitCode === null) {
|
|
1553
|
+
try {
|
|
1554
|
+
child.kill("SIGKILL");
|
|
1555
|
+
} catch {
|
|
1556
|
+
}
|
|
1557
|
+
}
|
|
1558
|
+
this.stopPendingCallCleanup();
|
|
1559
|
+
this.pendingToolCalls.clear();
|
|
1560
|
+
this.clientBuffer.clear();
|
|
1561
|
+
this.serverBuffer.clear();
|
|
1562
|
+
this.options.killSwitch?.dispose();
|
|
1563
|
+
this.options.chainDetector?.reset();
|
|
1564
|
+
this.options.logger.close();
|
|
1565
|
+
}
|
|
1566
|
+
/**
|
|
1567
|
+
* Get proxy statistics.
|
|
1568
|
+
*/
|
|
1569
|
+
getStats() {
|
|
1570
|
+
return { ...this.stats };
|
|
1571
|
+
}
|
|
1572
|
+
/**
|
|
1573
|
+
* Wire up the stdin/stdout pipelines with interception.
|
|
1574
|
+
*/
|
|
1575
|
+
setupPipelines() {
|
|
1576
|
+
process.stdin.on("data", (chunk) => {
|
|
1577
|
+
try {
|
|
1578
|
+
this.clientBuffer.append(chunk);
|
|
1579
|
+
this.processClientMessages();
|
|
1580
|
+
} catch (err) {
|
|
1581
|
+
if (err instanceof BufferOverflowError) {
|
|
1582
|
+
this.emit("error", err);
|
|
1583
|
+
this.clientBuffer.clear();
|
|
1584
|
+
} else {
|
|
1585
|
+
this.emit("error", new Error(`Client buffer error: ${err}`));
|
|
1586
|
+
}
|
|
1587
|
+
}
|
|
1588
|
+
});
|
|
1589
|
+
process.stdin.on("end", () => {
|
|
1590
|
+
this.stop();
|
|
1591
|
+
});
|
|
1592
|
+
this.child?.stdout?.on("data", (chunk) => {
|
|
1593
|
+
try {
|
|
1594
|
+
this.serverBuffer.append(chunk);
|
|
1595
|
+
this.processServerMessages();
|
|
1596
|
+
} catch (err) {
|
|
1597
|
+
if (err instanceof BufferOverflowError) {
|
|
1598
|
+
this.emit("error", err);
|
|
1599
|
+
this.serverBuffer.clear();
|
|
1600
|
+
} else {
|
|
1601
|
+
this.emit("error", new Error(`Server buffer error: ${err}`));
|
|
1602
|
+
}
|
|
1603
|
+
}
|
|
1604
|
+
});
|
|
1605
|
+
}
|
|
1606
|
+
/**
|
|
1607
|
+
* Start periodic cleanup of expired pending tool calls.
|
|
1608
|
+
*/
|
|
1609
|
+
startPendingCallCleanup() {
|
|
1610
|
+
this.pendingCleanupTimer = setInterval(() => {
|
|
1611
|
+
const now = Date.now();
|
|
1612
|
+
for (const [id, entry] of this.pendingToolCalls) {
|
|
1613
|
+
if (now - entry.timestamp > this.pendingCallTtlMs) {
|
|
1614
|
+
this.pendingToolCalls.delete(id);
|
|
1615
|
+
}
|
|
1616
|
+
}
|
|
1617
|
+
}, PENDING_CALL_CLEANUP_INTERVAL_MS);
|
|
1618
|
+
this.pendingCleanupTimer.unref();
|
|
1619
|
+
}
|
|
1620
|
+
/**
|
|
1621
|
+
* Stop the pending call cleanup timer.
|
|
1622
|
+
*/
|
|
1623
|
+
stopPendingCallCleanup() {
|
|
1624
|
+
if (this.pendingCleanupTimer) {
|
|
1625
|
+
clearInterval(this.pendingCleanupTimer);
|
|
1626
|
+
this.pendingCleanupTimer = null;
|
|
1627
|
+
}
|
|
1628
|
+
}
|
|
1629
|
+
/**
|
|
1630
|
+
* Process buffered messages from the MCP client.
|
|
1631
|
+
* This is where policy enforcement happens.
|
|
1632
|
+
*/
|
|
1633
|
+
processClientMessages() {
|
|
1634
|
+
try {
|
|
1635
|
+
const messages = this.clientBuffer.readAllMessages();
|
|
1636
|
+
for (const msg of messages) {
|
|
1637
|
+
this.handleClientMessage(msg);
|
|
1638
|
+
}
|
|
1639
|
+
} catch (err) {
|
|
1640
|
+
this.emit("error", new Error(`Invalid JSON from client: ${err}`));
|
|
1641
|
+
}
|
|
1642
|
+
}
|
|
1643
|
+
/**
|
|
1644
|
+
* Process buffered messages from the MCP server.
|
|
1645
|
+
* Applies response scanning before forwarding to the client.
|
|
1646
|
+
*/
|
|
1647
|
+
processServerMessages() {
|
|
1648
|
+
try {
|
|
1649
|
+
const messages = this.serverBuffer.readAllMessages();
|
|
1650
|
+
for (const msg of messages) {
|
|
1651
|
+
this.handleServerMessage(msg);
|
|
1652
|
+
}
|
|
1653
|
+
} catch (err) {
|
|
1654
|
+
this.emit("error", new Error(`Invalid JSON from server: ${err}`));
|
|
1655
|
+
}
|
|
1656
|
+
}
|
|
1657
|
+
/**
|
|
1658
|
+
* Handle a single message from the MCP server.
|
|
1659
|
+
* If it's a response to a tools/call we tracked, scan it.
|
|
1660
|
+
*/
|
|
1661
|
+
handleServerMessage(msg) {
|
|
1662
|
+
if (!isResponse(msg)) {
|
|
1663
|
+
this.writeToClient(msg);
|
|
1664
|
+
return;
|
|
1665
|
+
}
|
|
1666
|
+
const response = msg;
|
|
1667
|
+
const pending = this.pendingToolCalls.get(response.id);
|
|
1668
|
+
if (!pending || !this.options.responseScanner) {
|
|
1669
|
+
if (pending) this.pendingToolCalls.delete(response.id);
|
|
1670
|
+
this.writeToClient(msg);
|
|
1671
|
+
return;
|
|
1672
|
+
}
|
|
1673
|
+
this.pendingToolCalls.delete(response.id);
|
|
1674
|
+
this.stats.scanned++;
|
|
1675
|
+
const scanResult = response.error ? this.options.responseScanner.scan(this.extractErrorText(response)) : response.result !== void 0 ? this.options.responseScanner.scanMcpResponse(response.result) : null;
|
|
1676
|
+
if (!scanResult || scanResult.clean) {
|
|
1677
|
+
this.writeToClient(msg);
|
|
1678
|
+
return;
|
|
1679
|
+
}
|
|
1680
|
+
const findingSummary = scanResult.findings.map((f) => `${f.pattern}: ${f.message}`).join("; ");
|
|
1681
|
+
switch (scanResult.action) {
|
|
1682
|
+
case "block": {
|
|
1683
|
+
this.stats.responseBlocked++;
|
|
1684
|
+
this.options.logger.log({
|
|
1685
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1686
|
+
sessionId: this.sessionId,
|
|
1687
|
+
direction: "response",
|
|
1688
|
+
method: "tools/call",
|
|
1689
|
+
tool: pending.tool,
|
|
1690
|
+
arguments: pending.args,
|
|
1691
|
+
verdict: { action: "deny", rule: "__response_scanner__", message: `Response blocked: ${findingSummary}` }
|
|
1692
|
+
});
|
|
1693
|
+
this.emit("responseBlocked", pending.tool, findingSummary);
|
|
1694
|
+
const errorResponse = createDenyResponse(
|
|
1695
|
+
response.id,
|
|
1696
|
+
`Response blocked by Agent Wall scanner: ${findingSummary}`
|
|
1697
|
+
);
|
|
1698
|
+
this.writeToClient(errorResponse);
|
|
1699
|
+
break;
|
|
1700
|
+
}
|
|
1701
|
+
case "redact": {
|
|
1702
|
+
this.stats.responseRedacted++;
|
|
1703
|
+
this.options.logger.log({
|
|
1704
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1705
|
+
sessionId: this.sessionId,
|
|
1706
|
+
direction: "response",
|
|
1707
|
+
method: "tools/call",
|
|
1708
|
+
tool: pending.tool,
|
|
1709
|
+
arguments: pending.args,
|
|
1710
|
+
verdict: { action: "allow", rule: "__response_scanner__", message: `Response redacted: ${findingSummary}` }
|
|
1711
|
+
});
|
|
1712
|
+
this.emit("responseRedacted", pending.tool, findingSummary);
|
|
1713
|
+
const redacted = this.buildRedactedResponse(response, scanResult);
|
|
1714
|
+
this.writeToClient(redacted);
|
|
1715
|
+
break;
|
|
1716
|
+
}
|
|
1717
|
+
default:
|
|
1718
|
+
this.writeToClient(msg);
|
|
1719
|
+
break;
|
|
1720
|
+
}
|
|
1721
|
+
}
|
|
1722
|
+
/**
|
|
1723
|
+
* Build a redacted MCP response by replacing text content.
|
|
1724
|
+
*/
|
|
1725
|
+
buildRedactedResponse(original, scanResult) {
|
|
1726
|
+
if (!scanResult.redactedText || !original.result) {
|
|
1727
|
+
return original;
|
|
1728
|
+
}
|
|
1729
|
+
const result = original.result;
|
|
1730
|
+
if (Array.isArray(result.content)) {
|
|
1731
|
+
const redactedContent = result.content.map((block) => {
|
|
1732
|
+
if (block.type === "text" && typeof block.text === "string") {
|
|
1733
|
+
return { ...block, text: scanResult.redactedText };
|
|
1734
|
+
}
|
|
1735
|
+
return block;
|
|
1736
|
+
});
|
|
1737
|
+
return {
|
|
1738
|
+
...original,
|
|
1739
|
+
result: { ...result, content: redactedContent }
|
|
1740
|
+
};
|
|
1741
|
+
}
|
|
1742
|
+
return {
|
|
1743
|
+
...original,
|
|
1744
|
+
result: { content: [{ type: "text", text: scanResult.redactedText }] }
|
|
1745
|
+
};
|
|
1746
|
+
}
|
|
1747
|
+
/**
|
|
1748
|
+
* Handle a single message from the MCP client.
|
|
1749
|
+
*
|
|
1750
|
+
* Security check order (defense in depth):
|
|
1751
|
+
* 1. Kill switch — if active, deny ALL calls immediately
|
|
1752
|
+
* 2. Injection detection — scan arguments for prompt injection
|
|
1753
|
+
* 3. Egress control — check for blocked URLs/IPs
|
|
1754
|
+
* 4. Policy engine — evaluate rules (existing behavior)
|
|
1755
|
+
* 5. Chain detection — record call and check for suspicious sequences
|
|
1756
|
+
*/
|
|
1757
|
+
handleClientMessage(msg) {
|
|
1758
|
+
this.stats.total++;
|
|
1759
|
+
if (!isToolCall(msg)) {
|
|
1760
|
+
this.writeToServer(msg);
|
|
1761
|
+
return;
|
|
1762
|
+
}
|
|
1763
|
+
const request = msg;
|
|
1764
|
+
const toolCall = getToolCallParams(request);
|
|
1765
|
+
if (!toolCall) {
|
|
1766
|
+
this.writeToServer(msg);
|
|
1767
|
+
return;
|
|
1768
|
+
}
|
|
1769
|
+
const args = toolCall.arguments ?? {};
|
|
1770
|
+
if (this.options.killSwitch?.isActive()) {
|
|
1771
|
+
const reason = this.options.killSwitch.getStatus().reason;
|
|
1772
|
+
this.emit("killSwitchActive", toolCall.name);
|
|
1773
|
+
this.handleDeny(request, toolCall.name, args, {
|
|
1774
|
+
action: "deny",
|
|
1775
|
+
rule: "__kill_switch__",
|
|
1776
|
+
message: `Kill switch active: ${reason}. All tool calls denied.`
|
|
1777
|
+
});
|
|
1778
|
+
return;
|
|
1779
|
+
}
|
|
1780
|
+
if (this.options.injectionDetector) {
|
|
1781
|
+
const injection = this.options.injectionDetector.scan(toolCall);
|
|
1782
|
+
if (injection.detected && injection.confidence !== "low") {
|
|
1783
|
+
this.emit("injectionDetected", toolCall.name, injection.summary);
|
|
1784
|
+
this.handleDeny(request, toolCall.name, args, {
|
|
1785
|
+
action: "deny",
|
|
1786
|
+
rule: "__injection_detector__",
|
|
1787
|
+
message: `Prompt injection blocked: ${injection.summary}`
|
|
1788
|
+
});
|
|
1789
|
+
return;
|
|
1790
|
+
}
|
|
1791
|
+
}
|
|
1792
|
+
if (this.options.egressControl) {
|
|
1793
|
+
const egress = this.options.egressControl.check(toolCall);
|
|
1794
|
+
if (!egress.allowed) {
|
|
1795
|
+
this.emit("egressBlocked", toolCall.name, egress.summary);
|
|
1796
|
+
this.handleDeny(request, toolCall.name, args, {
|
|
1797
|
+
action: "deny",
|
|
1798
|
+
rule: "__egress_control__",
|
|
1799
|
+
message: `Egress blocked: ${egress.summary}`
|
|
1800
|
+
});
|
|
1801
|
+
return;
|
|
1802
|
+
}
|
|
1803
|
+
}
|
|
1804
|
+
const verdict = this.options.policyEngine.evaluate(toolCall);
|
|
1805
|
+
if (this.options.chainDetector && verdict.action !== "deny") {
|
|
1806
|
+
const chain = this.options.chainDetector.record(toolCall);
|
|
1807
|
+
if (chain.detected) {
|
|
1808
|
+
const critical = chain.matches.some((m) => m.severity === "critical");
|
|
1809
|
+
const summary = chain.matches.map((m) => `${m.chain}(${m.severity})`).join(", ");
|
|
1810
|
+
this.emit("chainDetected", toolCall.name, summary);
|
|
1811
|
+
if (critical) {
|
|
1812
|
+
this.handleDeny(request, toolCall.name, args, {
|
|
1813
|
+
action: "deny",
|
|
1814
|
+
rule: "__chain_detector__",
|
|
1815
|
+
message: `Critical tool chain blocked: ${summary}`
|
|
1816
|
+
});
|
|
1817
|
+
return;
|
|
1818
|
+
}
|
|
1819
|
+
this.options.logger.log({
|
|
1820
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1821
|
+
sessionId: this.sessionId,
|
|
1822
|
+
direction: "request",
|
|
1823
|
+
method: "tools/call",
|
|
1824
|
+
tool: toolCall.name,
|
|
1825
|
+
arguments: args,
|
|
1826
|
+
verdict: {
|
|
1827
|
+
action: "allow",
|
|
1828
|
+
rule: "__chain_detector__",
|
|
1829
|
+
message: `Suspicious tool chain (warning): ${summary}`
|
|
1830
|
+
}
|
|
1831
|
+
});
|
|
1832
|
+
}
|
|
1833
|
+
}
|
|
1834
|
+
switch (verdict.action) {
|
|
1835
|
+
case "allow":
|
|
1836
|
+
this.handleAllow(request, toolCall.name, args, verdict);
|
|
1837
|
+
break;
|
|
1838
|
+
case "deny":
|
|
1839
|
+
this.handleDeny(request, toolCall.name, args, verdict);
|
|
1840
|
+
break;
|
|
1841
|
+
case "prompt":
|
|
1842
|
+
this.handlePrompt(request, toolCall.name, args, verdict);
|
|
1843
|
+
break;
|
|
1844
|
+
}
|
|
1845
|
+
}
|
|
1846
|
+
/**
|
|
1847
|
+
* Handle an allowed tool call — forward to server.
|
|
1848
|
+
*/
|
|
1849
|
+
handleAllow(request, tool, args, verdict) {
|
|
1850
|
+
this.stats.forwarded++;
|
|
1851
|
+
if (this.options.responseScanner) {
|
|
1852
|
+
this.pendingToolCalls.set(request.id, { tool, args, timestamp: Date.now() });
|
|
1853
|
+
}
|
|
1854
|
+
this.options.logger.logAllow(
|
|
1855
|
+
this.sessionId,
|
|
1856
|
+
tool,
|
|
1857
|
+
args,
|
|
1858
|
+
verdict.rule,
|
|
1859
|
+
verdict.message
|
|
1860
|
+
);
|
|
1861
|
+
this.emit("allowed", tool);
|
|
1862
|
+
this.writeToServer(request);
|
|
1863
|
+
}
|
|
1864
|
+
/**
|
|
1865
|
+
* Handle a denied tool call — return error to client, never forward.
|
|
1866
|
+
*/
|
|
1867
|
+
handleDeny(request, tool, args, verdict) {
|
|
1868
|
+
this.stats.denied++;
|
|
1869
|
+
this.options.logger.logDeny(
|
|
1870
|
+
this.sessionId,
|
|
1871
|
+
tool,
|
|
1872
|
+
args,
|
|
1873
|
+
verdict.rule,
|
|
1874
|
+
verdict.message
|
|
1875
|
+
);
|
|
1876
|
+
this.emit("denied", tool, verdict.message);
|
|
1877
|
+
const errorResponse = createDenyResponse(request.id, verdict.message);
|
|
1878
|
+
this.writeToClient(errorResponse);
|
|
1879
|
+
}
|
|
1880
|
+
/**
|
|
1881
|
+
* Handle a prompt tool call — ask for human approval.
|
|
1882
|
+
*/
|
|
1883
|
+
async handlePrompt(request, tool, args, verdict) {
|
|
1884
|
+
this.emit("prompted", tool, verdict.message);
|
|
1885
|
+
if (!this.options.onPrompt) {
|
|
1886
|
+
this.handleDeny(request, tool, args, {
|
|
1887
|
+
...verdict,
|
|
1888
|
+
action: "deny",
|
|
1889
|
+
message: `${verdict.message} (auto-denied: no prompt handler)`
|
|
1890
|
+
});
|
|
1891
|
+
return;
|
|
1892
|
+
}
|
|
1893
|
+
try {
|
|
1894
|
+
const approved = await this.options.onPrompt(
|
|
1895
|
+
tool,
|
|
1896
|
+
args,
|
|
1897
|
+
verdict.message
|
|
1898
|
+
);
|
|
1899
|
+
if (approved) {
|
|
1900
|
+
this.handleAllow(request, tool, args, {
|
|
1901
|
+
...verdict,
|
|
1902
|
+
action: "allow",
|
|
1903
|
+
message: `${verdict.message} (manually approved)`
|
|
1904
|
+
});
|
|
1905
|
+
} else {
|
|
1906
|
+
this.handleDeny(request, tool, args, {
|
|
1907
|
+
...verdict,
|
|
1908
|
+
action: "deny",
|
|
1909
|
+
message: `${verdict.message} (manually denied)`
|
|
1910
|
+
});
|
|
1911
|
+
}
|
|
1912
|
+
} catch (err) {
|
|
1913
|
+
this.handleDeny(request, tool, args, {
|
|
1914
|
+
...verdict,
|
|
1915
|
+
action: "deny",
|
|
1916
|
+
message: `${verdict.message} (prompt error: ${err})`
|
|
1917
|
+
});
|
|
1918
|
+
}
|
|
1919
|
+
}
|
|
1920
|
+
/**
|
|
1921
|
+
* Extract scannable text from a JSON-RPC error response.
|
|
1922
|
+
*/
|
|
1923
|
+
extractErrorText(response) {
|
|
1924
|
+
const parts = [];
|
|
1925
|
+
if (response.error?.message) parts.push(response.error.message);
|
|
1926
|
+
if (response.error?.data !== void 0) {
|
|
1927
|
+
parts.push(typeof response.error.data === "string" ? response.error.data : JSON.stringify(response.error.data));
|
|
1928
|
+
}
|
|
1929
|
+
return parts.join("\n");
|
|
1930
|
+
}
|
|
1931
|
+
/**
|
|
1932
|
+
* Write a JSON-RPC message to the MCP server (child's stdin).
|
|
1933
|
+
*/
|
|
1934
|
+
writeToServer(msg) {
|
|
1935
|
+
if (!this.child?.stdin?.writable) return;
|
|
1936
|
+
const data = serializeMessage(msg);
|
|
1937
|
+
this.child.stdin.write(data);
|
|
1938
|
+
}
|
|
1939
|
+
/**
|
|
1940
|
+
* Write a JSON-RPC message to the MCP client (our stdout).
|
|
1941
|
+
*/
|
|
1942
|
+
writeToClient(msg) {
|
|
1943
|
+
const data = serializeMessage(msg);
|
|
1944
|
+
process.stdout.write(data);
|
|
1945
|
+
}
|
|
1946
|
+
};
|
|
1947
|
+
function createTerminalPromptHandler() {
|
|
1948
|
+
return async (tool, args, message) => {
|
|
1949
|
+
let ttyFd;
|
|
1950
|
+
try {
|
|
1951
|
+
ttyFd = fs3.openSync("/dev/tty", "r");
|
|
1952
|
+
} catch {
|
|
1953
|
+
try {
|
|
1954
|
+
ttyFd = fs3.openSync("CON", "r");
|
|
1955
|
+
} catch {
|
|
1956
|
+
process.stderr.write(
|
|
1957
|
+
"\n[agent-wall] No terminal available for prompt \u2014 auto-denying (fail-secure)\n"
|
|
1958
|
+
);
|
|
1959
|
+
return false;
|
|
1960
|
+
}
|
|
1961
|
+
}
|
|
1962
|
+
const ttyInput = fs3.createReadStream("", { fd: ttyFd, autoClose: false });
|
|
1963
|
+
const rl = readline.createInterface({
|
|
1964
|
+
input: ttyInput,
|
|
1965
|
+
output: process.stderr
|
|
1966
|
+
});
|
|
1967
|
+
process.stderr.write("\n");
|
|
1968
|
+
process.stderr.write("\u2554\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2557\n");
|
|
1969
|
+
process.stderr.write("\u2551 Agent Wall: Approval Required \u2551\n");
|
|
1970
|
+
process.stderr.write("\u2560\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2563\n");
|
|
1971
|
+
process.stderr.write(`\u2551 Tool: ${tool}
|
|
1972
|
+
`);
|
|
1973
|
+
process.stderr.write(`\u2551 Rule: ${message}
|
|
1974
|
+
`);
|
|
1975
|
+
process.stderr.write(`\u2551 Args: ${JSON.stringify(args, null, 0).slice(0, 120)}
|
|
1976
|
+
`);
|
|
1977
|
+
process.stderr.write("\u255A\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u255D\n");
|
|
1978
|
+
return new Promise((resolve3) => {
|
|
1979
|
+
rl.question(" Allow this call? [y/N]: ", (answer) => {
|
|
1980
|
+
rl.close();
|
|
1981
|
+
ttyInput.destroy();
|
|
1982
|
+
try {
|
|
1983
|
+
fs3.closeSync(ttyFd);
|
|
1984
|
+
} catch {
|
|
1985
|
+
}
|
|
1986
|
+
resolve3(answer.trim().toLowerCase() === "y");
|
|
1987
|
+
});
|
|
1988
|
+
});
|
|
1989
|
+
};
|
|
1990
|
+
}
|
|
1991
|
+
|
|
1992
|
+
// src/injection-detector.ts
|
|
1993
|
+
var INJECTION_PATTERNS = [
|
|
1994
|
+
// ── Direct instruction override (HIGH confidence) ──
|
|
1995
|
+
{
|
|
1996
|
+
category: "instruction-override",
|
|
1997
|
+
pattern: /ignore\s+(all\s+)?previous\s+(instructions?|context|rules?|prompts?)/i,
|
|
1998
|
+
confidence: "high",
|
|
1999
|
+
sensitivity: "low"
|
|
2000
|
+
},
|
|
2001
|
+
{
|
|
2002
|
+
category: "instruction-override",
|
|
2003
|
+
pattern: /disregard\s+(all\s+)?(previous|above|prior|earlier)\s+(instructions?|context|rules?)/i,
|
|
2004
|
+
confidence: "high",
|
|
2005
|
+
sensitivity: "low"
|
|
2006
|
+
},
|
|
2007
|
+
{
|
|
2008
|
+
category: "instruction-override",
|
|
2009
|
+
pattern: /forget\s+(all\s+)?(your|previous|prior)\s+(instructions?|rules?|context|training)/i,
|
|
2010
|
+
confidence: "high",
|
|
2011
|
+
sensitivity: "low"
|
|
2012
|
+
},
|
|
2013
|
+
{
|
|
2014
|
+
category: "instruction-override",
|
|
2015
|
+
pattern: /override\s+(all\s+)?(previous|system|safety)\s+(instructions?|rules?|restrictions?|filters?)/i,
|
|
2016
|
+
confidence: "high",
|
|
2017
|
+
sensitivity: "low"
|
|
2018
|
+
},
|
|
2019
|
+
{
|
|
2020
|
+
category: "instruction-override",
|
|
2021
|
+
pattern: /new\s+instructions?:\s/i,
|
|
2022
|
+
confidence: "high",
|
|
2023
|
+
sensitivity: "low"
|
|
2024
|
+
},
|
|
2025
|
+
{
|
|
2026
|
+
category: "instruction-override",
|
|
2027
|
+
pattern: /you\s+are\s+now\s+(a|an|in)\s/i,
|
|
2028
|
+
confidence: "high",
|
|
2029
|
+
sensitivity: "low"
|
|
2030
|
+
},
|
|
2031
|
+
{
|
|
2032
|
+
category: "instruction-override",
|
|
2033
|
+
pattern: /from\s+now\s+on,?\s+(you|ignore|disregard|forget)/i,
|
|
2034
|
+
confidence: "high",
|
|
2035
|
+
sensitivity: "low"
|
|
2036
|
+
},
|
|
2037
|
+
// ── System/Role prompt markers (HIGH confidence) ──
|
|
2038
|
+
{
|
|
2039
|
+
category: "prompt-marker",
|
|
2040
|
+
pattern: /<\|im_start\|>system/i,
|
|
2041
|
+
confidence: "high",
|
|
2042
|
+
sensitivity: "low"
|
|
2043
|
+
},
|
|
2044
|
+
{
|
|
2045
|
+
category: "prompt-marker",
|
|
2046
|
+
pattern: /<\|system\|>/i,
|
|
2047
|
+
confidence: "high",
|
|
2048
|
+
sensitivity: "low"
|
|
2049
|
+
},
|
|
2050
|
+
{
|
|
2051
|
+
category: "prompt-marker",
|
|
2052
|
+
pattern: /\[SYSTEM\]\s*:/i,
|
|
2053
|
+
confidence: "high",
|
|
2054
|
+
sensitivity: "low"
|
|
2055
|
+
},
|
|
2056
|
+
{
|
|
2057
|
+
category: "prompt-marker",
|
|
2058
|
+
pattern: /\[INST\]/i,
|
|
2059
|
+
confidence: "high",
|
|
2060
|
+
sensitivity: "low"
|
|
2061
|
+
},
|
|
2062
|
+
{
|
|
2063
|
+
category: "prompt-marker",
|
|
2064
|
+
pattern: /<<SYS>>/i,
|
|
2065
|
+
confidence: "high",
|
|
2066
|
+
sensitivity: "low"
|
|
2067
|
+
},
|
|
2068
|
+
{
|
|
2069
|
+
category: "prompt-marker",
|
|
2070
|
+
pattern: /###\s*(System|Instruction|Human|Assistant)\s*:/i,
|
|
2071
|
+
confidence: "medium",
|
|
2072
|
+
sensitivity: "medium"
|
|
2073
|
+
},
|
|
2074
|
+
// ── Authority claims (MEDIUM confidence) ──
|
|
2075
|
+
{
|
|
2076
|
+
category: "authority-claim",
|
|
2077
|
+
pattern: /admin(istrator)?\s+(override|mode|access|command)/i,
|
|
2078
|
+
confidence: "medium",
|
|
2079
|
+
sensitivity: "low"
|
|
2080
|
+
},
|
|
2081
|
+
{
|
|
2082
|
+
category: "authority-claim",
|
|
2083
|
+
pattern: /IMPORTANT:\s*(override|ignore|disregard|new\s+instruction)/i,
|
|
2084
|
+
confidence: "high",
|
|
2085
|
+
sensitivity: "low"
|
|
2086
|
+
},
|
|
2087
|
+
{
|
|
2088
|
+
category: "authority-claim",
|
|
2089
|
+
pattern: /URGENT:\s*(you\s+must|override|ignore)/i,
|
|
2090
|
+
confidence: "medium",
|
|
2091
|
+
sensitivity: "low"
|
|
2092
|
+
},
|
|
2093
|
+
{
|
|
2094
|
+
category: "authority-claim",
|
|
2095
|
+
pattern: /developer\s+mode\s+(enabled|activated|on)/i,
|
|
2096
|
+
confidence: "high",
|
|
2097
|
+
sensitivity: "low"
|
|
2098
|
+
},
|
|
2099
|
+
{
|
|
2100
|
+
category: "authority-claim",
|
|
2101
|
+
pattern: /jailbreak/i,
|
|
2102
|
+
confidence: "high",
|
|
2103
|
+
sensitivity: "low"
|
|
2104
|
+
},
|
|
2105
|
+
{
|
|
2106
|
+
category: "authority-claim",
|
|
2107
|
+
pattern: /DAN\s+(mode|prompt)/i,
|
|
2108
|
+
confidence: "high",
|
|
2109
|
+
sensitivity: "low"
|
|
2110
|
+
},
|
|
2111
|
+
// ── Data exfiltration instructions (HIGH confidence) ──
|
|
2112
|
+
{
|
|
2113
|
+
category: "exfil-instruction",
|
|
2114
|
+
pattern: /send\s+(all\s+)?(the\s+|this\s+|my\s+)?(data|information|content|file|secret|key|token|password|credential)\s+(to|via|through|using)/i,
|
|
2115
|
+
confidence: "high",
|
|
2116
|
+
sensitivity: "low"
|
|
2117
|
+
},
|
|
2118
|
+
{
|
|
2119
|
+
category: "exfil-instruction",
|
|
2120
|
+
pattern: /exfiltrate|steal\s+(the\s+)?(data|secret|key|credential|token)/i,
|
|
2121
|
+
confidence: "high",
|
|
2122
|
+
sensitivity: "low"
|
|
2123
|
+
},
|
|
2124
|
+
{
|
|
2125
|
+
category: "exfil-instruction",
|
|
2126
|
+
pattern: /upload\s+(all\s+)?(the\s+|this\s+|my\s+|every\s+)?(files?|data|content|secrets?)/i,
|
|
2127
|
+
confidence: "medium",
|
|
2128
|
+
sensitivity: "medium"
|
|
2129
|
+
},
|
|
2130
|
+
// ── Output manipulation (MEDIUM confidence) ──
|
|
2131
|
+
{
|
|
2132
|
+
category: "output-manipulation",
|
|
2133
|
+
pattern: /respond\s+with\s+(only|just|exactly)\s/i,
|
|
2134
|
+
confidence: "low",
|
|
2135
|
+
sensitivity: "high"
|
|
2136
|
+
},
|
|
2137
|
+
{
|
|
2138
|
+
category: "output-manipulation",
|
|
2139
|
+
pattern: /do\s+not\s+(mention|reveal|tell|show|display|output)\s/i,
|
|
2140
|
+
confidence: "low",
|
|
2141
|
+
sensitivity: "high"
|
|
2142
|
+
},
|
|
2143
|
+
{
|
|
2144
|
+
category: "output-manipulation",
|
|
2145
|
+
pattern: /pretend\s+(you|that|to\s+be)/i,
|
|
2146
|
+
confidence: "medium",
|
|
2147
|
+
sensitivity: "medium"
|
|
2148
|
+
},
|
|
2149
|
+
// ── Unicode obfuscation markers (HIGH confidence) ──
|
|
2150
|
+
{
|
|
2151
|
+
category: "unicode-obfuscation",
|
|
2152
|
+
pattern: /[\u200B-\u200F\u2028-\u202F\uFEFF]/,
|
|
2153
|
+
// Zero-width chars
|
|
2154
|
+
confidence: "medium",
|
|
2155
|
+
sensitivity: "medium"
|
|
2156
|
+
},
|
|
2157
|
+
{
|
|
2158
|
+
category: "unicode-obfuscation",
|
|
2159
|
+
pattern: /[\u2060-\u2064]/,
|
|
2160
|
+
// Invisible formatting chars
|
|
2161
|
+
confidence: "medium",
|
|
2162
|
+
sensitivity: "medium"
|
|
2163
|
+
},
|
|
2164
|
+
{
|
|
2165
|
+
category: "unicode-obfuscation",
|
|
2166
|
+
pattern: /[\uE000-\uF8FF]/,
|
|
2167
|
+
// Private Use Area (sometimes used to hide text)
|
|
2168
|
+
confidence: "low",
|
|
2169
|
+
sensitivity: "high"
|
|
2170
|
+
},
|
|
2171
|
+
// ── Encoded injection (base64 "ignore" etc.) ──
|
|
2172
|
+
{
|
|
2173
|
+
category: "encoded-injection",
|
|
2174
|
+
pattern: /aWdub3Jl/,
|
|
2175
|
+
// base64 of "ignore"
|
|
2176
|
+
confidence: "medium",
|
|
2177
|
+
sensitivity: "medium"
|
|
2178
|
+
},
|
|
2179
|
+
{
|
|
2180
|
+
category: "encoded-injection",
|
|
2181
|
+
pattern: /c3lzdGVt/,
|
|
2182
|
+
// base64 of "system"
|
|
2183
|
+
confidence: "low",
|
|
2184
|
+
sensitivity: "high"
|
|
2185
|
+
},
|
|
2186
|
+
// ── Delimiter injection (trying to break out of a tool argument) ──
|
|
2187
|
+
{
|
|
2188
|
+
category: "delimiter-injection",
|
|
2189
|
+
pattern: /\}\s*\]\s*\}\s*\{/,
|
|
2190
|
+
// Trying to close JSON and start new object
|
|
2191
|
+
confidence: "medium",
|
|
2192
|
+
sensitivity: "medium"
|
|
2193
|
+
},
|
|
2194
|
+
{
|
|
2195
|
+
category: "delimiter-injection",
|
|
2196
|
+
pattern: /```\s*(system|instruction|prompt)/i,
|
|
2197
|
+
// Code block with system marker
|
|
2198
|
+
confidence: "medium",
|
|
2199
|
+
sensitivity: "medium"
|
|
2200
|
+
}
|
|
2201
|
+
];
|
|
2202
|
+
var SENSITIVITY_LEVELS = {
|
|
2203
|
+
low: 1,
|
|
2204
|
+
medium: 2,
|
|
2205
|
+
high: 3
|
|
2206
|
+
};
|
|
2207
|
+
var InjectionDetector = class {
|
|
2208
|
+
config;
|
|
2209
|
+
customRegexes = [];
|
|
2210
|
+
constructor(config = {}) {
|
|
2211
|
+
this.config = {
|
|
2212
|
+
enabled: config.enabled ?? true,
|
|
2213
|
+
sensitivity: config.sensitivity ?? "medium",
|
|
2214
|
+
customPatterns: config.customPatterns ?? [],
|
|
2215
|
+
excludeTools: config.excludeTools ?? []
|
|
2216
|
+
};
|
|
2217
|
+
for (const p of this.config.customPatterns) {
|
|
2218
|
+
try {
|
|
2219
|
+
this.customRegexes.push(new RegExp(p, "i"));
|
|
2220
|
+
} catch {
|
|
2221
|
+
process.stderr.write(`[agent-wall] Warning: invalid custom injection pattern: "${p}"
|
|
2222
|
+
`);
|
|
2223
|
+
}
|
|
2224
|
+
}
|
|
2225
|
+
}
|
|
2226
|
+
/**
|
|
2227
|
+
* Scan a tool call's arguments for prompt injection.
|
|
2228
|
+
*/
|
|
2229
|
+
scan(toolCall) {
|
|
2230
|
+
if (!this.config.enabled) {
|
|
2231
|
+
return { detected: false, confidence: "low", matches: [], summary: "Injection detection disabled" };
|
|
2232
|
+
}
|
|
2233
|
+
if (this.config.excludeTools.includes(toolCall.name)) {
|
|
2234
|
+
return { detected: false, confidence: "low", matches: [], summary: "Tool excluded from injection scanning" };
|
|
2235
|
+
}
|
|
2236
|
+
const matches = [];
|
|
2237
|
+
const args = toolCall.arguments ?? {};
|
|
2238
|
+
const sensitivityLevel = SENSITIVITY_LEVELS[this.config.sensitivity] ?? 2;
|
|
2239
|
+
for (const [key, value] of Object.entries(args)) {
|
|
2240
|
+
const strValue = this.extractString(value);
|
|
2241
|
+
if (!strValue || strValue.length < 5) continue;
|
|
2242
|
+
for (const injPattern of INJECTION_PATTERNS) {
|
|
2243
|
+
const patternSensitivity = SENSITIVITY_LEVELS[injPattern.sensitivity] ?? 2;
|
|
2244
|
+
if (patternSensitivity > sensitivityLevel) continue;
|
|
2245
|
+
if (injPattern.pattern.test(strValue)) {
|
|
2246
|
+
const match = strValue.match(injPattern.pattern);
|
|
2247
|
+
matches.push({
|
|
2248
|
+
category: injPattern.category,
|
|
2249
|
+
matched: match ? match[0].slice(0, 80) : "***",
|
|
2250
|
+
argumentKey: key,
|
|
2251
|
+
confidence: injPattern.confidence
|
|
2252
|
+
});
|
|
2253
|
+
}
|
|
2254
|
+
}
|
|
2255
|
+
for (const regex of this.customRegexes) {
|
|
2256
|
+
regex.lastIndex = 0;
|
|
2257
|
+
if (regex.test(strValue)) {
|
|
2258
|
+
matches.push({
|
|
2259
|
+
category: "custom",
|
|
2260
|
+
matched: "custom pattern match",
|
|
2261
|
+
argumentKey: key,
|
|
2262
|
+
confidence: "medium"
|
|
2263
|
+
});
|
|
2264
|
+
}
|
|
2265
|
+
}
|
|
2266
|
+
}
|
|
2267
|
+
if (matches.length === 0) {
|
|
2268
|
+
return { detected: false, confidence: "low", matches: [], summary: "No injection detected" };
|
|
2269
|
+
}
|
|
2270
|
+
const highestConfidence = matches.reduce((best, m) => {
|
|
2271
|
+
const mLevel = SENSITIVITY_LEVELS[m.confidence] ?? 0;
|
|
2272
|
+
const bLevel = SENSITIVITY_LEVELS[best] ?? 0;
|
|
2273
|
+
return mLevel > bLevel ? m.confidence : best;
|
|
2274
|
+
}, "low");
|
|
2275
|
+
const categories = [...new Set(matches.map((m) => m.category))];
|
|
2276
|
+
const summary = `Prompt injection detected (${highestConfidence} confidence): ${categories.join(", ")}. ${matches.length} pattern(s) matched.`;
|
|
2277
|
+
return {
|
|
2278
|
+
detected: true,
|
|
2279
|
+
confidence: highestConfidence,
|
|
2280
|
+
matches,
|
|
2281
|
+
summary
|
|
2282
|
+
};
|
|
2283
|
+
}
|
|
2284
|
+
/**
|
|
2285
|
+
* Extract a string from an argument value (handles nested objects).
|
|
2286
|
+
*/
|
|
2287
|
+
extractString(value) {
|
|
2288
|
+
if (typeof value === "string") return value;
|
|
2289
|
+
if (typeof value === "number" || typeof value === "boolean") return String(value);
|
|
2290
|
+
if (value === null || value === void 0) return "";
|
|
2291
|
+
try {
|
|
2292
|
+
return JSON.stringify(value);
|
|
2293
|
+
} catch {
|
|
2294
|
+
return "";
|
|
2295
|
+
}
|
|
2296
|
+
}
|
|
2297
|
+
};
|
|
2298
|
+
|
|
2299
|
+
// src/egress-control.ts
|
|
2300
|
+
function isPrivateIP(ip) {
|
|
2301
|
+
const parts = ip.split(".").map(Number);
|
|
2302
|
+
if (parts.length === 4 && parts.every((p) => p >= 0 && p <= 255)) {
|
|
2303
|
+
if (parts[0] === 10) return true;
|
|
2304
|
+
if (parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31) return true;
|
|
2305
|
+
if (parts[0] === 192 && parts[1] === 168) return true;
|
|
2306
|
+
if (parts[0] === 127) return true;
|
|
2307
|
+
if (parts[0] === 169 && parts[1] === 254) return true;
|
|
2308
|
+
if (parts.every((p) => p === 0)) return true;
|
|
2309
|
+
}
|
|
2310
|
+
if (ip === "::1" || ip === "[::1]") return true;
|
|
2311
|
+
if (ip.toLowerCase().startsWith("fe80:")) return true;
|
|
2312
|
+
if (ip.toLowerCase().startsWith("fc") || ip.toLowerCase().startsWith("fd")) return true;
|
|
2313
|
+
return false;
|
|
2314
|
+
}
|
|
2315
|
+
var METADATA_ENDPOINTS = [
|
|
2316
|
+
"169.254.169.254",
|
|
2317
|
+
// AWS, GCP, Azure
|
|
2318
|
+
"metadata.google.internal",
|
|
2319
|
+
"metadata.goog",
|
|
2320
|
+
"100.100.100.200",
|
|
2321
|
+
// Alibaba Cloud
|
|
2322
|
+
"169.254.170.2"
|
|
2323
|
+
// AWS ECS task metadata
|
|
2324
|
+
];
|
|
2325
|
+
var URL_REGEX = /https?:\/\/[^\s"'<>\])}]+/gi;
|
|
2326
|
+
function extractUrls(value) {
|
|
2327
|
+
const matches = value.match(URL_REGEX);
|
|
2328
|
+
return matches ? [...new Set(matches)] : [];
|
|
2329
|
+
}
|
|
2330
|
+
function parseHostname(urlStr) {
|
|
2331
|
+
try {
|
|
2332
|
+
const url = new URL(urlStr);
|
|
2333
|
+
return url.hostname;
|
|
2334
|
+
} catch {
|
|
2335
|
+
const match = urlStr.match(/https?:\/\/([^/:?\s#]+)/i);
|
|
2336
|
+
return match ? match[1] : null;
|
|
2337
|
+
}
|
|
2338
|
+
}
|
|
2339
|
+
function extractRawHostname(urlStr) {
|
|
2340
|
+
const match = urlStr.match(/https?:\/\/([^/:?\s#]+)/i);
|
|
2341
|
+
return match ? match[1] : null;
|
|
2342
|
+
}
|
|
2343
|
+
var EgressControl = class {
|
|
2344
|
+
config;
|
|
2345
|
+
constructor(config = {}) {
|
|
2346
|
+
this.config = {
|
|
2347
|
+
enabled: config.enabled ?? true,
|
|
2348
|
+
allowedDomains: config.allowedDomains ?? [],
|
|
2349
|
+
blockedDomains: config.blockedDomains ?? [],
|
|
2350
|
+
blockPrivateIPs: config.blockPrivateIPs ?? true,
|
|
2351
|
+
blockMetadataEndpoints: config.blockMetadataEndpoints ?? true,
|
|
2352
|
+
excludeTools: config.excludeTools ?? []
|
|
2353
|
+
};
|
|
2354
|
+
}
|
|
2355
|
+
/**
|
|
2356
|
+
* Check a tool call's arguments for blocked URLs.
|
|
2357
|
+
*/
|
|
2358
|
+
check(toolCall) {
|
|
2359
|
+
if (!this.config.enabled) {
|
|
2360
|
+
return { allowed: true, urlsFound: [], blocked: [], summary: "Egress control disabled" };
|
|
2361
|
+
}
|
|
2362
|
+
if (this.config.excludeTools.includes(toolCall.name)) {
|
|
2363
|
+
return { allowed: true, urlsFound: [], blocked: [], summary: "Tool excluded from egress control" };
|
|
2364
|
+
}
|
|
2365
|
+
const urlsFound = [];
|
|
2366
|
+
const blocked = [];
|
|
2367
|
+
const args = toolCall.arguments ?? {};
|
|
2368
|
+
for (const [key, value] of Object.entries(args)) {
|
|
2369
|
+
const strValue = typeof value === "string" ? value : JSON.stringify(value ?? "");
|
|
2370
|
+
const urls = extractUrls(strValue);
|
|
2371
|
+
for (const url of urls) {
|
|
2372
|
+
const rawHostname = extractRawHostname(url);
|
|
2373
|
+
const hostname = parseHostname(url);
|
|
2374
|
+
if (!hostname) continue;
|
|
2375
|
+
const info = { url, hostname, argumentKey: key };
|
|
2376
|
+
urlsFound.push(info);
|
|
2377
|
+
const blockReason = this.checkUrl(hostname, url, rawHostname);
|
|
2378
|
+
if (blockReason) {
|
|
2379
|
+
blocked.push({ ...info, reason: blockReason });
|
|
2380
|
+
}
|
|
2381
|
+
}
|
|
2382
|
+
}
|
|
2383
|
+
if (blocked.length === 0) {
|
|
2384
|
+
return {
|
|
2385
|
+
allowed: true,
|
|
2386
|
+
urlsFound,
|
|
2387
|
+
blocked: [],
|
|
2388
|
+
summary: urlsFound.length > 0 ? `${urlsFound.length} URL(s) found, all allowed` : "No URLs found in arguments"
|
|
2389
|
+
};
|
|
2390
|
+
}
|
|
2391
|
+
const reasons = [...new Set(blocked.map((b) => b.reason))];
|
|
2392
|
+
return {
|
|
2393
|
+
allowed: false,
|
|
2394
|
+
urlsFound,
|
|
2395
|
+
blocked,
|
|
2396
|
+
summary: `Blocked ${blocked.length} URL(s): ${reasons.join("; ")}`
|
|
2397
|
+
};
|
|
2398
|
+
}
|
|
2399
|
+
/**
|
|
2400
|
+
* Check a single hostname/URL against all rules.
|
|
2401
|
+
* Returns the block reason, or null if allowed.
|
|
2402
|
+
*
|
|
2403
|
+
* Check order matters: more specific checks (obfuscated, metadata) run
|
|
2404
|
+
* before generic private IP, so the reason message is precise.
|
|
2405
|
+
*/
|
|
2406
|
+
checkUrl(hostname, fullUrl, rawHostname) {
|
|
2407
|
+
const lowerHost = hostname.toLowerCase();
|
|
2408
|
+
const rawHost = rawHostname ?? hostname;
|
|
2409
|
+
if (this.config.allowedDomains.length > 0) {
|
|
2410
|
+
const allowed = this.config.allowedDomains.some(
|
|
2411
|
+
(d) => lowerHost === d.toLowerCase() || lowerHost.endsWith("." + d.toLowerCase())
|
|
2412
|
+
);
|
|
2413
|
+
if (!allowed) {
|
|
2414
|
+
return `Domain "${hostname}" is not in the allowed domains list`;
|
|
2415
|
+
}
|
|
2416
|
+
}
|
|
2417
|
+
if (this.config.blockedDomains.length > 0) {
|
|
2418
|
+
const isBlocked = this.config.blockedDomains.some(
|
|
2419
|
+
(d) => lowerHost === d.toLowerCase() || lowerHost.endsWith("." + d.toLowerCase())
|
|
2420
|
+
);
|
|
2421
|
+
if (isBlocked) {
|
|
2422
|
+
return `Domain "${hostname}" is in the blocked domains list`;
|
|
2423
|
+
}
|
|
2424
|
+
}
|
|
2425
|
+
if (this.config.blockPrivateIPs) {
|
|
2426
|
+
if (/^0x[0-9a-f]+$/i.test(rawHost) || /^\d{8,}$/.test(rawHost)) {
|
|
2427
|
+
return `Obfuscated IP address "${rawHost}" is blocked (potential SSRF bypass)`;
|
|
2428
|
+
}
|
|
2429
|
+
}
|
|
2430
|
+
if (this.config.blockMetadataEndpoints) {
|
|
2431
|
+
if (METADATA_ENDPOINTS.some((ep) => lowerHost === ep.toLowerCase())) {
|
|
2432
|
+
return `Cloud metadata endpoint "${hostname}" is blocked`;
|
|
2433
|
+
}
|
|
2434
|
+
if (fullUrl.includes("/latest/meta-data") || fullUrl.includes("/metadata/instance")) {
|
|
2435
|
+
return `Cloud metadata access is blocked`;
|
|
2436
|
+
}
|
|
2437
|
+
}
|
|
2438
|
+
if (this.config.blockPrivateIPs) {
|
|
2439
|
+
if (isPrivateIP(hostname)) {
|
|
2440
|
+
return `Private/internal IP address "${hostname}" is blocked (SSRF protection)`;
|
|
2441
|
+
}
|
|
2442
|
+
if (lowerHost === "localhost" || lowerHost === "ip6-localhost") {
|
|
2443
|
+
return `Localhost address is blocked (SSRF protection)`;
|
|
2444
|
+
}
|
|
2445
|
+
}
|
|
2446
|
+
return null;
|
|
2447
|
+
}
|
|
2448
|
+
};
|
|
2449
|
+
|
|
2450
|
+
// src/kill-switch.ts
|
|
2451
|
+
import * as fs4 from "fs";
|
|
2452
|
+
import * as path4 from "path";
|
|
2453
|
+
import * as os from "os";
|
|
2454
|
+
var DEFAULT_KILL_FILES = [".agent-wall-kill"];
|
|
2455
|
+
var DEFAULT_POLL_INTERVAL = 1e3;
|
|
2456
|
+
var KillSwitch = class {
|
|
2457
|
+
config;
|
|
2458
|
+
manuallyActive = false;
|
|
2459
|
+
fileActive = false;
|
|
2460
|
+
activeReason = "";
|
|
2461
|
+
activatedAt = null;
|
|
2462
|
+
pollTimer = null;
|
|
2463
|
+
constructor(config = {}) {
|
|
2464
|
+
const isUnix = process.platform !== "win32";
|
|
2465
|
+
this.config = {
|
|
2466
|
+
enabled: config.enabled ?? true,
|
|
2467
|
+
checkFile: config.checkFile ?? true,
|
|
2468
|
+
killFileNames: config.killFileNames ?? DEFAULT_KILL_FILES,
|
|
2469
|
+
checkDirs: config.checkDirs ?? [process.cwd(), os.homedir()],
|
|
2470
|
+
pollIntervalMs: config.pollIntervalMs ?? DEFAULT_POLL_INTERVAL,
|
|
2471
|
+
registerSignal: config.registerSignal ?? isUnix
|
|
2472
|
+
};
|
|
2473
|
+
if (this.config.enabled) {
|
|
2474
|
+
this.startPolling();
|
|
2475
|
+
this.registerSignalHandler();
|
|
2476
|
+
}
|
|
2477
|
+
}
|
|
2478
|
+
/**
|
|
2479
|
+
* Check if the kill switch is currently active.
|
|
2480
|
+
* This should be called at the TOP of the proxy pipeline.
|
|
2481
|
+
*/
|
|
2482
|
+
isActive() {
|
|
2483
|
+
if (!this.config.enabled) return false;
|
|
2484
|
+
return this.manuallyActive || this.fileActive;
|
|
2485
|
+
}
|
|
2486
|
+
/**
|
|
2487
|
+
* Get the current kill switch status.
|
|
2488
|
+
*/
|
|
2489
|
+
getStatus() {
|
|
2490
|
+
return {
|
|
2491
|
+
active: this.isActive(),
|
|
2492
|
+
reason: this.isActive() ? this.activeReason : "inactive",
|
|
2493
|
+
activatedAt: this.isActive() ? this.activatedAt : null
|
|
2494
|
+
};
|
|
2495
|
+
}
|
|
2496
|
+
/**
|
|
2497
|
+
* Programmatically activate the kill switch.
|
|
2498
|
+
*/
|
|
2499
|
+
activate(reason = "Manually activated") {
|
|
2500
|
+
this.manuallyActive = true;
|
|
2501
|
+
this.activeReason = reason;
|
|
2502
|
+
this.activatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
2503
|
+
}
|
|
2504
|
+
/**
|
|
2505
|
+
* Programmatically deactivate the kill switch.
|
|
2506
|
+
* Note: file-based kill switch must be deactivated by removing the file.
|
|
2507
|
+
*/
|
|
2508
|
+
deactivate() {
|
|
2509
|
+
this.manuallyActive = false;
|
|
2510
|
+
if (!this.fileActive) {
|
|
2511
|
+
this.activeReason = "";
|
|
2512
|
+
this.activatedAt = null;
|
|
2513
|
+
}
|
|
2514
|
+
}
|
|
2515
|
+
/**
|
|
2516
|
+
* Start polling for kill files.
|
|
2517
|
+
*/
|
|
2518
|
+
startPolling() {
|
|
2519
|
+
if (!this.config.checkFile) return;
|
|
2520
|
+
this.checkKillFiles();
|
|
2521
|
+
this.pollTimer = setInterval(() => {
|
|
2522
|
+
this.checkKillFiles();
|
|
2523
|
+
}, this.config.pollIntervalMs);
|
|
2524
|
+
this.pollTimer.unref();
|
|
2525
|
+
}
|
|
2526
|
+
/**
|
|
2527
|
+
* Check if any kill file exists.
|
|
2528
|
+
*/
|
|
2529
|
+
checkKillFiles() {
|
|
2530
|
+
for (const dir of this.config.checkDirs) {
|
|
2531
|
+
for (const fileName of this.config.killFileNames) {
|
|
2532
|
+
const filePath = path4.join(dir, fileName);
|
|
2533
|
+
try {
|
|
2534
|
+
if (fs4.existsSync(filePath)) {
|
|
2535
|
+
if (!this.fileActive) {
|
|
2536
|
+
this.fileActive = true;
|
|
2537
|
+
this.activeReason = `Kill file detected: ${filePath}`;
|
|
2538
|
+
this.activatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
2539
|
+
}
|
|
2540
|
+
return;
|
|
2541
|
+
}
|
|
2542
|
+
} catch {
|
|
2543
|
+
}
|
|
2544
|
+
}
|
|
2545
|
+
}
|
|
2546
|
+
if (this.fileActive) {
|
|
2547
|
+
this.fileActive = false;
|
|
2548
|
+
if (!this.manuallyActive) {
|
|
2549
|
+
this.activeReason = "";
|
|
2550
|
+
this.activatedAt = null;
|
|
2551
|
+
}
|
|
2552
|
+
}
|
|
2553
|
+
}
|
|
2554
|
+
/**
|
|
2555
|
+
* Register SIGUSR2 signal handler to toggle kill switch.
|
|
2556
|
+
* SIGUSR2 is used (not SIGUSR1) because some tools use SIGUSR1.
|
|
2557
|
+
*/
|
|
2558
|
+
registerSignalHandler() {
|
|
2559
|
+
if (!this.config.registerSignal) return;
|
|
2560
|
+
try {
|
|
2561
|
+
process.on("SIGUSR2", () => {
|
|
2562
|
+
if (this.manuallyActive) {
|
|
2563
|
+
this.deactivate();
|
|
2564
|
+
process.stderr.write("[agent-wall] Kill switch DEACTIVATED via SIGUSR2\n");
|
|
2565
|
+
} else {
|
|
2566
|
+
this.activate("Activated via SIGUSR2 signal");
|
|
2567
|
+
process.stderr.write("[agent-wall] Kill switch ACTIVATED via SIGUSR2\n");
|
|
2568
|
+
}
|
|
2569
|
+
});
|
|
2570
|
+
} catch {
|
|
2571
|
+
}
|
|
2572
|
+
}
|
|
2573
|
+
/**
|
|
2574
|
+
* Stop the kill switch (cleanup timers and signal handlers).
|
|
2575
|
+
*/
|
|
2576
|
+
dispose() {
|
|
2577
|
+
if (this.pollTimer) {
|
|
2578
|
+
clearInterval(this.pollTimer);
|
|
2579
|
+
this.pollTimer = null;
|
|
2580
|
+
}
|
|
2581
|
+
}
|
|
2582
|
+
};
|
|
2583
|
+
|
|
2584
|
+
// src/chain-detector.ts
|
|
2585
|
+
var BUILTIN_CHAINS = [
|
|
2586
|
+
// ── Exfiltration chains ──
|
|
2587
|
+
{
|
|
2588
|
+
name: "read-then-network",
|
|
2589
|
+
sequence: ["read_*|get_*|view_*", "shell_*|run_*|execute_*|bash"],
|
|
2590
|
+
severity: "high",
|
|
2591
|
+
message: "Potential data exfiltration: file read followed by shell command"
|
|
2592
|
+
},
|
|
2593
|
+
{
|
|
2594
|
+
name: "read-write-send",
|
|
2595
|
+
sequence: ["read_*|get_*", "write_*|create_*", "shell_*|run_*|bash"],
|
|
2596
|
+
severity: "critical",
|
|
2597
|
+
message: "Exfiltration chain: read \u2192 write \u2192 shell (staged exfiltration)"
|
|
2598
|
+
},
|
|
2599
|
+
{
|
|
2600
|
+
name: "env-then-network",
|
|
2601
|
+
sequence: ["read_*|get_*", "shell_*|run_*|bash"],
|
|
2602
|
+
severity: "critical",
|
|
2603
|
+
message: "Potential secret exfiltration: file read followed by network command",
|
|
2604
|
+
trackArguments: true
|
|
2605
|
+
},
|
|
2606
|
+
// ── Reconnaissance chains ──
|
|
2607
|
+
{
|
|
2608
|
+
name: "directory-scan",
|
|
2609
|
+
sequence: ["list_*|ls", "list_*|ls", "list_*|ls", "read_*|get_*"],
|
|
2610
|
+
severity: "medium",
|
|
2611
|
+
message: "Directory scanning pattern: multiple listings followed by file read"
|
|
2612
|
+
},
|
|
2613
|
+
// ── Dropper/persistence chains ──
|
|
2614
|
+
{
|
|
2615
|
+
name: "write-execute",
|
|
2616
|
+
sequence: ["write_*|create_*", "shell_*|run_*|bash"],
|
|
2617
|
+
severity: "high",
|
|
2618
|
+
message: "Potential dropper: file write followed by shell execution"
|
|
2619
|
+
},
|
|
2620
|
+
{
|
|
2621
|
+
name: "write-chmod-execute",
|
|
2622
|
+
sequence: ["write_*|create_*", "shell_*|run_*|bash", "shell_*|run_*|bash"],
|
|
2623
|
+
severity: "critical",
|
|
2624
|
+
message: "Dropper chain: write \u2192 chmod \u2192 execute"
|
|
2625
|
+
},
|
|
2626
|
+
// ── Privilege escalation ──
|
|
2627
|
+
{
|
|
2628
|
+
name: "read-sensitive-then-write",
|
|
2629
|
+
sequence: ["read_*|get_*", "write_*|create_*|edit_*"],
|
|
2630
|
+
severity: "medium",
|
|
2631
|
+
message: "Sensitive file read followed by file modification",
|
|
2632
|
+
trackArguments: true
|
|
2633
|
+
},
|
|
2634
|
+
// ── Rapid shell commands ──
|
|
2635
|
+
{
|
|
2636
|
+
name: "shell-burst",
|
|
2637
|
+
sequence: ["shell_*|run_*|bash", "shell_*|run_*|bash", "shell_*|run_*|bash", "shell_*|run_*|bash"],
|
|
2638
|
+
severity: "high",
|
|
2639
|
+
message: "Rapid burst of shell commands \u2014 potential automated attack"
|
|
2640
|
+
}
|
|
2641
|
+
];
|
|
2642
|
+
var ChainDetector = class {
|
|
2643
|
+
config;
|
|
2644
|
+
history = [];
|
|
2645
|
+
allChains;
|
|
2646
|
+
constructor(config = {}) {
|
|
2647
|
+
this.config = {
|
|
2648
|
+
enabled: config.enabled ?? true,
|
|
2649
|
+
windowSize: config.windowSize ?? 20,
|
|
2650
|
+
windowMs: config.windowMs ?? 6e4,
|
|
2651
|
+
// 1 minute
|
|
2652
|
+
customChains: config.customChains ?? []
|
|
2653
|
+
};
|
|
2654
|
+
this.allChains = [...BUILTIN_CHAINS, ...this.config.customChains];
|
|
2655
|
+
}
|
|
2656
|
+
/**
|
|
2657
|
+
* Record a tool call and check for suspicious chains.
|
|
2658
|
+
* Call this AFTER the policy engine allows the call.
|
|
2659
|
+
*/
|
|
2660
|
+
record(toolCall) {
|
|
2661
|
+
if (!this.config.enabled) {
|
|
2662
|
+
return { detected: false, matches: [], summary: "Chain detection disabled" };
|
|
2663
|
+
}
|
|
2664
|
+
const now = Date.now();
|
|
2665
|
+
this.history.push({
|
|
2666
|
+
tool: toolCall.name,
|
|
2667
|
+
args: toolCall.arguments ?? {},
|
|
2668
|
+
timestamp: now
|
|
2669
|
+
});
|
|
2670
|
+
this.pruneHistory(now);
|
|
2671
|
+
const matches = [];
|
|
2672
|
+
for (const chain of this.allChains) {
|
|
2673
|
+
if (this.matchesChain(chain)) {
|
|
2674
|
+
matches.push({
|
|
2675
|
+
chain: chain.name,
|
|
2676
|
+
severity: chain.severity,
|
|
2677
|
+
calls: this.history.slice(-chain.sequence.length).map((c) => c.tool),
|
|
2678
|
+
message: chain.message
|
|
2679
|
+
});
|
|
2680
|
+
}
|
|
2681
|
+
}
|
|
2682
|
+
if (matches.length === 0) {
|
|
2683
|
+
return { detected: false, matches: [], summary: "No suspicious chains detected" };
|
|
2684
|
+
}
|
|
2685
|
+
const highestSeverity = matches.reduce((best, m) => {
|
|
2686
|
+
const levels = { low: 0, medium: 1, high: 2, critical: 3 };
|
|
2687
|
+
return levels[m.severity] > levels[best] ? m.severity : best;
|
|
2688
|
+
}, "low");
|
|
2689
|
+
return {
|
|
2690
|
+
detected: true,
|
|
2691
|
+
matches,
|
|
2692
|
+
summary: `Suspicious tool call chain detected (${highestSeverity}): ${matches.map((m) => m.chain).join(", ")}`
|
|
2693
|
+
};
|
|
2694
|
+
}
|
|
2695
|
+
/**
|
|
2696
|
+
* Check if the current history matches a chain pattern.
|
|
2697
|
+
* Looks for the sequence appearing in order (not necessarily consecutive).
|
|
2698
|
+
*/
|
|
2699
|
+
matchesChain(chain) {
|
|
2700
|
+
if (this.history.length < chain.sequence.length) return false;
|
|
2701
|
+
const recentCalls = this.history.slice(-chain.sequence.length);
|
|
2702
|
+
for (let i = 0; i < chain.sequence.length; i++) {
|
|
2703
|
+
const pattern = chain.sequence[i];
|
|
2704
|
+
const call = recentCalls[i];
|
|
2705
|
+
if (!this.matchesToolPattern(pattern, call.tool)) {
|
|
2706
|
+
return false;
|
|
2707
|
+
}
|
|
2708
|
+
}
|
|
2709
|
+
return true;
|
|
2710
|
+
}
|
|
2711
|
+
/**
|
|
2712
|
+
* Match a tool name against a pipe-separated glob-like pattern.
|
|
2713
|
+
*/
|
|
2714
|
+
matchesToolPattern(pattern, toolName) {
|
|
2715
|
+
const alternatives = pattern.split("|").map((p) => p.trim());
|
|
2716
|
+
return alternatives.some((p) => {
|
|
2717
|
+
if (p === "*") return true;
|
|
2718
|
+
if (p.endsWith("*")) {
|
|
2719
|
+
return toolName.startsWith(p.slice(0, -1));
|
|
2720
|
+
}
|
|
2721
|
+
if (p.startsWith("*")) {
|
|
2722
|
+
return toolName.endsWith(p.slice(1));
|
|
2723
|
+
}
|
|
2724
|
+
return toolName === p;
|
|
2725
|
+
});
|
|
2726
|
+
}
|
|
2727
|
+
/**
|
|
2728
|
+
* Remove entries outside the time window or exceeding window size.
|
|
2729
|
+
*/
|
|
2730
|
+
pruneHistory(now) {
|
|
2731
|
+
const cutoff = now - this.config.windowMs;
|
|
2732
|
+
this.history = this.history.filter((c) => c.timestamp >= cutoff);
|
|
2733
|
+
if (this.history.length > this.config.windowSize) {
|
|
2734
|
+
this.history = this.history.slice(-this.config.windowSize);
|
|
2735
|
+
}
|
|
2736
|
+
}
|
|
2737
|
+
/**
|
|
2738
|
+
* Clear the call history (e.g., on session reset).
|
|
2739
|
+
*/
|
|
2740
|
+
reset() {
|
|
2741
|
+
this.history = [];
|
|
2742
|
+
}
|
|
2743
|
+
/**
|
|
2744
|
+
* Get the current call history length.
|
|
2745
|
+
*/
|
|
2746
|
+
getHistoryLength() {
|
|
2747
|
+
return this.history.length;
|
|
2748
|
+
}
|
|
2749
|
+
};
|
|
2750
|
+
|
|
2751
|
+
// src/dashboard-server.ts
|
|
2752
|
+
import * as http from "http";
|
|
2753
|
+
import * as fs5 from "fs";
|
|
2754
|
+
import * as path5 from "path";
|
|
2755
|
+
import { WebSocketServer } from "ws";
|
|
2756
|
+
var MIME_TYPES = {
|
|
2757
|
+
".html": "text/html; charset=utf-8",
|
|
2758
|
+
".js": "application/javascript; charset=utf-8",
|
|
2759
|
+
".css": "text/css; charset=utf-8",
|
|
2760
|
+
".json": "application/json; charset=utf-8",
|
|
2761
|
+
".svg": "image/svg+xml",
|
|
2762
|
+
".png": "image/png",
|
|
2763
|
+
".ico": "image/x-icon",
|
|
2764
|
+
".woff": "font/woff",
|
|
2765
|
+
".woff2": "font/woff2"
|
|
2766
|
+
};
|
|
2767
|
+
var DashboardServer = class {
|
|
2768
|
+
httpServer = null;
|
|
2769
|
+
wss = null;
|
|
2770
|
+
statsTimer = null;
|
|
2771
|
+
ruleHitCounts = /* @__PURE__ */ new Map();
|
|
2772
|
+
startTime = Date.now();
|
|
2773
|
+
options;
|
|
2774
|
+
constructor(options) {
|
|
2775
|
+
this.options = options;
|
|
2776
|
+
}
|
|
2777
|
+
async start() {
|
|
2778
|
+
const { port, staticDir, statsIntervalMs = 2e3 } = this.options;
|
|
2779
|
+
this.httpServer = http.createServer((req, res) => {
|
|
2780
|
+
this.handleHttpRequest(req, res, staticDir);
|
|
2781
|
+
});
|
|
2782
|
+
this.wss = new WebSocketServer({ server: this.httpServer });
|
|
2783
|
+
this.wss.on("error", () => {
|
|
2784
|
+
});
|
|
2785
|
+
this.wss.on("connection", (ws) => {
|
|
2786
|
+
this.sendTo(ws, {
|
|
2787
|
+
type: "welcome",
|
|
2788
|
+
ts: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2789
|
+
payload: { message: "Agent Wall Dashboard connected" }
|
|
2790
|
+
});
|
|
2791
|
+
this.sendStats(ws);
|
|
2792
|
+
this.sendConfig(ws);
|
|
2793
|
+
this.sendRuleHits(ws);
|
|
2794
|
+
ws.on("message", (data) => {
|
|
2795
|
+
try {
|
|
2796
|
+
const msg = JSON.parse(data.toString());
|
|
2797
|
+
this.handleClientMessage(ws, msg);
|
|
2798
|
+
} catch {
|
|
2799
|
+
}
|
|
2800
|
+
});
|
|
2801
|
+
});
|
|
2802
|
+
this.wireProxyEvents();
|
|
2803
|
+
this.statsTimer = setInterval(() => {
|
|
2804
|
+
this.broadcastStats();
|
|
2805
|
+
}, statsIntervalMs);
|
|
2806
|
+
return new Promise((resolve3, reject) => {
|
|
2807
|
+
this.httpServer.on("error", reject);
|
|
2808
|
+
this.httpServer.listen(port, () => resolve3());
|
|
2809
|
+
});
|
|
2810
|
+
}
|
|
2811
|
+
async stop() {
|
|
2812
|
+
if (this.statsTimer) {
|
|
2813
|
+
clearInterval(this.statsTimer);
|
|
2814
|
+
this.statsTimer = null;
|
|
2815
|
+
}
|
|
2816
|
+
if (this.wss) {
|
|
2817
|
+
for (const ws of this.wss.clients) {
|
|
2818
|
+
ws.close();
|
|
2819
|
+
}
|
|
2820
|
+
this.wss.close();
|
|
2821
|
+
this.wss = null;
|
|
2822
|
+
}
|
|
2823
|
+
if (this.httpServer) {
|
|
2824
|
+
return new Promise((resolve3) => {
|
|
2825
|
+
this.httpServer.close(() => resolve3());
|
|
2826
|
+
this.httpServer = null;
|
|
2827
|
+
});
|
|
2828
|
+
}
|
|
2829
|
+
}
|
|
2830
|
+
/** Get the actual port (useful when port 0 is used for testing) */
|
|
2831
|
+
getPort() {
|
|
2832
|
+
const addr = this.httpServer?.address();
|
|
2833
|
+
if (addr && typeof addr === "object") return addr.port;
|
|
2834
|
+
return this.options.port;
|
|
2835
|
+
}
|
|
2836
|
+
// ── Event Wiring ──────────────────────────────────────────────────
|
|
2837
|
+
wireProxyEvents() {
|
|
2838
|
+
const { proxy } = this.options;
|
|
2839
|
+
const eventMap = [
|
|
2840
|
+
{ event: "allowed", severity: "info", getArgs: (tool) => ({ tool, detail: "" }) },
|
|
2841
|
+
{ event: "denied", severity: "warn", getArgs: (tool, msg = "") => ({ tool, detail: msg }) },
|
|
2842
|
+
{ event: "prompted", severity: "info", getArgs: (tool, msg = "") => ({ tool, detail: msg }) },
|
|
2843
|
+
{ event: "responseBlocked", severity: "warn", getArgs: (tool, findings = "") => ({ tool, detail: findings }) },
|
|
2844
|
+
{ event: "responseRedacted", severity: "info", getArgs: (tool, findings = "") => ({ tool, detail: findings }) },
|
|
2845
|
+
{ event: "injectionDetected", severity: "critical", getArgs: (tool, summary = "") => ({ tool, detail: summary }) },
|
|
2846
|
+
{ event: "egressBlocked", severity: "critical", getArgs: (tool, summary = "") => ({ tool, detail: summary }) },
|
|
2847
|
+
{ event: "killSwitchActive", severity: "critical", getArgs: (tool) => ({ tool, detail: "Kill switch is active \u2014 all calls denied" }) },
|
|
2848
|
+
{ event: "chainDetected", severity: "warn", getArgs: (tool, summary = "") => ({ tool, detail: summary }) }
|
|
2849
|
+
];
|
|
2850
|
+
for (const { event, severity, getArgs } of eventMap) {
|
|
2851
|
+
proxy.on(event, (tool, detail) => {
|
|
2852
|
+
const parsed = getArgs(tool, detail);
|
|
2853
|
+
this.broadcast({
|
|
2854
|
+
type: "event",
|
|
2855
|
+
ts: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2856
|
+
payload: { event, tool: parsed.tool, detail: parsed.detail, severity }
|
|
2857
|
+
});
|
|
2858
|
+
});
|
|
2859
|
+
}
|
|
2860
|
+
}
|
|
2861
|
+
/** Called by the audit logger's onEntry callback */
|
|
2862
|
+
handleAuditEntry(entry) {
|
|
2863
|
+
if (entry.verdict?.rule) {
|
|
2864
|
+
const key = entry.verdict.rule;
|
|
2865
|
+
const existing = this.ruleHitCounts.get(key);
|
|
2866
|
+
if (existing) {
|
|
2867
|
+
existing.hits++;
|
|
2868
|
+
} else {
|
|
2869
|
+
this.ruleHitCounts.set(key, {
|
|
2870
|
+
action: entry.verdict.action,
|
|
2871
|
+
hits: 1
|
|
2872
|
+
});
|
|
2873
|
+
}
|
|
2874
|
+
}
|
|
2875
|
+
this.broadcast({
|
|
2876
|
+
type: "audit",
|
|
2877
|
+
ts: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2878
|
+
payload: entry
|
|
2879
|
+
});
|
|
2880
|
+
if (entry.verdict?.rule) {
|
|
2881
|
+
const rules = Array.from(this.ruleHitCounts.entries()).map(
|
|
2882
|
+
([name, { action, hits }]) => ({ name, action, hits })
|
|
2883
|
+
);
|
|
2884
|
+
this.broadcast({
|
|
2885
|
+
type: "ruleHits",
|
|
2886
|
+
ts: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2887
|
+
payload: { rules }
|
|
2888
|
+
});
|
|
2889
|
+
}
|
|
2890
|
+
}
|
|
2891
|
+
// ── Client Message Handling ────────────────────────────────────────
|
|
2892
|
+
handleClientMessage(ws, msg) {
|
|
2893
|
+
switch (msg.type) {
|
|
2894
|
+
case "toggleKillSwitch":
|
|
2895
|
+
if (this.options.killSwitch) {
|
|
2896
|
+
if (this.options.killSwitch.isActive()) {
|
|
2897
|
+
this.options.killSwitch.deactivate();
|
|
2898
|
+
} else {
|
|
2899
|
+
this.options.killSwitch.activate();
|
|
2900
|
+
}
|
|
2901
|
+
this.broadcast({
|
|
2902
|
+
type: "killSwitch",
|
|
2903
|
+
ts: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2904
|
+
payload: { active: this.options.killSwitch.isActive() }
|
|
2905
|
+
});
|
|
2906
|
+
}
|
|
2907
|
+
break;
|
|
2908
|
+
case "getStats":
|
|
2909
|
+
this.sendStats(ws);
|
|
2910
|
+
break;
|
|
2911
|
+
case "getConfig":
|
|
2912
|
+
this.sendConfig(ws);
|
|
2913
|
+
break;
|
|
2914
|
+
case "getAuditLog": {
|
|
2915
|
+
const entries = this.options.logger?.getEntries() ?? [];
|
|
2916
|
+
let filtered = entries;
|
|
2917
|
+
if (msg.filter && msg.filter !== "all") {
|
|
2918
|
+
filtered = entries.filter((e) => e.verdict?.action === msg.filter);
|
|
2919
|
+
}
|
|
2920
|
+
const limited = msg.limit ? filtered.slice(-msg.limit) : filtered.slice(-100);
|
|
2921
|
+
this.sendTo(ws, {
|
|
2922
|
+
type: "audit",
|
|
2923
|
+
ts: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2924
|
+
payload: limited
|
|
2925
|
+
});
|
|
2926
|
+
break;
|
|
2927
|
+
}
|
|
2928
|
+
}
|
|
2929
|
+
}
|
|
2930
|
+
// ── Broadcasting ──────────────────────────────────────────────────
|
|
2931
|
+
broadcast(msg) {
|
|
2932
|
+
if (!this.wss) return;
|
|
2933
|
+
const data = JSON.stringify(msg);
|
|
2934
|
+
for (const ws of this.wss.clients) {
|
|
2935
|
+
if (ws.readyState === 1) {
|
|
2936
|
+
ws.send(data);
|
|
2937
|
+
}
|
|
2938
|
+
}
|
|
2939
|
+
}
|
|
2940
|
+
sendTo(ws, msg) {
|
|
2941
|
+
if (ws.readyState === 1) {
|
|
2942
|
+
ws.send(JSON.stringify(msg));
|
|
2943
|
+
}
|
|
2944
|
+
}
|
|
2945
|
+
broadcastStats() {
|
|
2946
|
+
if (!this.wss || this.wss.clients.size === 0) return;
|
|
2947
|
+
const stats = this.buildStats();
|
|
2948
|
+
this.broadcast({
|
|
2949
|
+
type: "stats",
|
|
2950
|
+
ts: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2951
|
+
payload: stats
|
|
2952
|
+
});
|
|
2953
|
+
}
|
|
2954
|
+
sendStats(ws) {
|
|
2955
|
+
this.sendTo(ws, {
|
|
2956
|
+
type: "stats",
|
|
2957
|
+
ts: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2958
|
+
payload: this.buildStats()
|
|
2959
|
+
});
|
|
2960
|
+
}
|
|
2961
|
+
buildStats() {
|
|
2962
|
+
const proxyStats = this.options.proxy.getStats();
|
|
2963
|
+
return {
|
|
2964
|
+
...proxyStats,
|
|
2965
|
+
uptime: Math.floor((Date.now() - this.startTime) / 1e3),
|
|
2966
|
+
killSwitchActive: this.options.killSwitch?.isActive() ?? false
|
|
2967
|
+
};
|
|
2968
|
+
}
|
|
2969
|
+
sendConfig(ws) {
|
|
2970
|
+
const pe = this.options.policyEngine;
|
|
2971
|
+
const policyConfig = pe?.getConfig();
|
|
2972
|
+
const config = {
|
|
2973
|
+
defaultAction: policyConfig?.defaultAction ?? "prompt",
|
|
2974
|
+
ruleCount: policyConfig?.rules?.length ?? 0,
|
|
2975
|
+
mode: policyConfig?.mode ?? "standard",
|
|
2976
|
+
security: {
|
|
2977
|
+
injection: policyConfig?.security?.injectionDetection?.enabled ?? false,
|
|
2978
|
+
egress: policyConfig?.security?.egressControl?.enabled ?? false,
|
|
2979
|
+
killSwitch: !!this.options.killSwitch,
|
|
2980
|
+
chain: policyConfig?.security?.chainDetection?.enabled ?? false,
|
|
2981
|
+
signing: policyConfig?.security?.signing ?? false
|
|
2982
|
+
}
|
|
2983
|
+
};
|
|
2984
|
+
this.sendTo(ws, {
|
|
2985
|
+
type: "config",
|
|
2986
|
+
ts: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2987
|
+
payload: config
|
|
2988
|
+
});
|
|
2989
|
+
}
|
|
2990
|
+
sendRuleHits(ws) {
|
|
2991
|
+
const rules = Array.from(this.ruleHitCounts.entries()).map(
|
|
2992
|
+
([name, { action, hits }]) => ({ name, action, hits })
|
|
2993
|
+
);
|
|
2994
|
+
this.sendTo(ws, {
|
|
2995
|
+
type: "ruleHits",
|
|
2996
|
+
ts: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2997
|
+
payload: { rules }
|
|
2998
|
+
});
|
|
2999
|
+
}
|
|
3000
|
+
// ── Static File Serving ───────────────────────────────────────────
|
|
3001
|
+
handleHttpRequest(req, res, staticDir) {
|
|
3002
|
+
if (!staticDir) {
|
|
3003
|
+
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
|
3004
|
+
res.end("<html><body><h1>Agent Wall Dashboard</h1><p>No static assets found. Build the dashboard package first.</p></body></html>");
|
|
3005
|
+
return;
|
|
3006
|
+
}
|
|
3007
|
+
const url = req.url?.split("?")[0] ?? "/";
|
|
3008
|
+
let filePath = path5.join(staticDir, url === "/" ? "index.html" : url);
|
|
3009
|
+
const resolved = path5.resolve(filePath);
|
|
3010
|
+
if (!resolved.startsWith(path5.resolve(staticDir))) {
|
|
3011
|
+
res.writeHead(403);
|
|
3012
|
+
res.end("Forbidden");
|
|
3013
|
+
return;
|
|
3014
|
+
}
|
|
3015
|
+
try {
|
|
3016
|
+
if (!fs5.existsSync(resolved) || fs5.statSync(resolved).isDirectory()) {
|
|
3017
|
+
filePath = path5.join(staticDir, "index.html");
|
|
3018
|
+
}
|
|
3019
|
+
const content = fs5.readFileSync(filePath);
|
|
3020
|
+
const ext = path5.extname(filePath).toLowerCase();
|
|
3021
|
+
const contentType = MIME_TYPES[ext] ?? "application/octet-stream";
|
|
3022
|
+
res.writeHead(200, { "Content-Type": contentType });
|
|
3023
|
+
res.end(content);
|
|
3024
|
+
} catch {
|
|
3025
|
+
res.writeHead(404);
|
|
3026
|
+
res.end("Not Found");
|
|
3027
|
+
}
|
|
3028
|
+
}
|
|
3029
|
+
};
|
|
3030
|
+
export {
|
|
3031
|
+
AuditLogger,
|
|
3032
|
+
BufferOverflowError,
|
|
3033
|
+
ChainDetector,
|
|
3034
|
+
DashboardServer,
|
|
3035
|
+
EgressControl,
|
|
3036
|
+
InjectionDetector,
|
|
3037
|
+
JsonRpcMessageSchema,
|
|
3038
|
+
JsonRpcNotificationSchema,
|
|
3039
|
+
JsonRpcRequestSchema,
|
|
3040
|
+
JsonRpcResponseSchema,
|
|
3041
|
+
KillSwitch,
|
|
3042
|
+
PolicyEngine,
|
|
3043
|
+
ReadBuffer,
|
|
3044
|
+
ResponseScanner,
|
|
3045
|
+
StdioProxy,
|
|
3046
|
+
checkFilePermissions,
|
|
3047
|
+
createDefaultScanner,
|
|
3048
|
+
createDenyResponse,
|
|
3049
|
+
createPromptResponse,
|
|
3050
|
+
createTerminalPromptHandler,
|
|
3051
|
+
deserializeMessage,
|
|
3052
|
+
discoverPolicyFile,
|
|
3053
|
+
generateDefaultConfigYaml,
|
|
3054
|
+
getDefaultPolicy,
|
|
3055
|
+
getToolCallParams,
|
|
3056
|
+
isNotification,
|
|
3057
|
+
isRegexSafe,
|
|
3058
|
+
isRequest,
|
|
3059
|
+
isResponse,
|
|
3060
|
+
isToolCall,
|
|
3061
|
+
isToolList,
|
|
3062
|
+
loadPolicy,
|
|
3063
|
+
loadPolicyFile,
|
|
3064
|
+
parsePolicyYaml,
|
|
3065
|
+
serializeMessage
|
|
3066
|
+
};
|
|
3067
|
+
//# sourceMappingURL=index.js.map
|