clawmoat 0.4.0 → 0.5.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/README.md CHANGED
@@ -61,6 +61,16 @@ clawmoat protect --config clawmoat.yml
61
61
  clawmoat dashboard
62
62
  ```
63
63
 
64
+ ### New in v0.5.0
65
+
66
+ - 🔑 **Credential Monitor** — watches `~/.openclaw/credentials/` for unauthorized access and modifications using file hashing
67
+ - 🧩 **Skill Integrity Checker** — hashes all SKILL.md and script files, detects tampering, flags suspicious patterns (eval, base64, curl to external URLs). CLI: `clawmoat skill-audit`
68
+ - 🌐 **Network Egress Logger** — parses session logs for all outbound URLs, maintains domain allowlists, flags known-bad domains (webhook.site, ngrok, etc.)
69
+ - 🚨 **Alert Delivery System** — unified alerts via console, file (audit.log), or webhook with severity levels and 5-minute rate limiting
70
+ - 🤝 **Inter-Agent Message Scanner** — heightened-sensitivity scanning for agent-to-agent messages detecting impersonation, concealment, credential exfiltration, and safety bypasses
71
+ - 📊 **Activity Reports** — `clawmoat report` generates 24h summaries of agent activity, tool usage, and network egress
72
+ - 👻 **Daemon Mode** — `clawmoat watch --daemon` runs in background with PID file; `--alert-webhook=URL` for remote alerting
73
+
64
74
  ### As an OpenClaw Skill
65
75
 
66
76
  ```bash
package/bin/clawmoat.js CHANGED
@@ -16,6 +16,10 @@ const path = require('path');
16
16
  const ClawMoat = require('../src/index');
17
17
  const { scanSkillContent } = require('../src/scanners/supply-chain');
18
18
  const { calculateGrade, generateBadgeSVG, getShieldsURL } = require('../src/badge');
19
+ const { SkillIntegrityChecker } = require('../src/guardian/skill-integrity');
20
+ const { NetworkEgressLogger } = require('../src/guardian/network-log');
21
+ const { AlertManager } = require('../src/guardian/alerts');
22
+ const { CredentialMonitor } = require('../src/guardian/index');
19
23
 
20
24
  const VERSION = require('../package.json').version;
21
25
  const BOLD = '\x1b[1m';
@@ -41,6 +45,12 @@ switch (command) {
41
45
  case 'watch':
42
46
  cmdWatch(args.slice(1));
43
47
  break;
48
+ case 'skill-audit':
49
+ cmdSkillAudit(args.slice(1));
50
+ break;
51
+ case 'report':
52
+ cmdReport(args.slice(1));
53
+ break;
44
54
  case 'test':
45
55
  cmdTest();
46
56
  break;
@@ -339,16 +349,46 @@ function cmdTest() {
339
349
  }
340
350
 
341
351
  function cmdWatch(args) {
342
- const agentDir = args[0] || path.join(process.env.HOME, '.openclaw/agents/main');
352
+ const isDaemon = args.includes('--daemon');
353
+ const webhookArg = args.find(a => a.startsWith('--alert-webhook='));
354
+ const webhookUrl = webhookArg ? webhookArg.split('=').slice(1).join('=') : null;
355
+ const filteredArgs = args.filter(a => a !== '--daemon' && !a.startsWith('--alert-webhook='));
356
+ const agentDir = filteredArgs[0] || path.join(process.env.HOME, '.openclaw/agents/main');
343
357
  const { watchSessions } = require('../src/middleware/openclaw');
344
358
 
359
+ // Daemon mode: fork to background
360
+ if (isDaemon) {
361
+ const { spawn } = require('child_process');
362
+ const daemonArgs = process.argv.slice(2).filter(a => a !== '--daemon');
363
+ const child = spawn(process.execPath, [__filename, ...daemonArgs], {
364
+ detached: true,
365
+ stdio: 'ignore',
366
+ });
367
+ child.unref();
368
+ const pidFile = path.join(process.env.HOME, '.clawmoat.pid');
369
+ fs.writeFileSync(pidFile, String(child.pid));
370
+ console.log(`${BOLD}🏰 ClawMoat daemon started${RESET} (PID: ${child.pid})`);
371
+ console.log(`${DIM}PID file: ${pidFile}${RESET}`);
372
+ process.exit(0);
373
+ }
374
+
375
+ // Set up alert manager
376
+ const alertChannels = ['console'];
377
+ if (webhookUrl) alertChannels.push('webhook');
378
+ const alertMgr = new AlertManager({ channels: alertChannels, webhookUrl });
379
+
345
380
  console.log(`${BOLD}🏰 ClawMoat Live Monitor${RESET}`);
346
381
  console.log(`${DIM}Watching: ${agentDir}${RESET}`);
382
+ if (webhookUrl) console.log(`${DIM}Webhook: ${webhookUrl}${RESET}`);
347
383
  console.log(`${DIM}Press Ctrl+C to stop${RESET}\n`);
348
384
 
349
385
  const monitor = watchSessions({ agentDir });
350
386
  if (!monitor) process.exit(1);
351
387
 
388
+ // Also start credential monitor
389
+ const credMon = new CredentialMonitor({ quiet: false, onAlert: (a) => alertMgr.send(a) });
390
+ credMon.start();
391
+
352
392
  // Print summary every 60s
353
393
  setInterval(() => {
354
394
  const summary = monitor.getSummary();
@@ -359,12 +399,150 @@ function cmdWatch(args) {
359
399
 
360
400
  process.on('SIGINT', () => {
361
401
  monitor.stop();
402
+ credMon.stop();
362
403
  const summary = monitor.getSummary();
363
404
  console.log(`\n${BOLD}Session Summary:${RESET} ${summary.scanned} scanned, ${summary.blocked} blocked, ${summary.warnings} warnings`);
364
405
  process.exit(0);
365
406
  });
366
407
  }
367
408
 
409
+ function cmdSkillAudit(args) {
410
+ const skillsDir = args[0] || path.join(process.env.HOME, '.openclaw', 'workspace', 'skills');
411
+
412
+ console.log(`${BOLD}🏰 ClawMoat Skill Integrity Audit${RESET}`);
413
+ console.log(`${DIM}Directory: ${skillsDir}${RESET}\n`);
414
+
415
+ if (!fs.existsSync(skillsDir)) {
416
+ console.log(`${YELLOW}Skills directory not found: ${skillsDir}${RESET}`);
417
+ console.log(`${DIM}Specify path: clawmoat skill-audit /path/to/skills${RESET}`);
418
+ process.exit(0);
419
+ }
420
+
421
+ const checker = new SkillIntegrityChecker({ skillsDir });
422
+ const initResult = checker.init();
423
+
424
+ console.log(`Files hashed: ${initResult.files}`);
425
+ console.log(`New files: ${initResult.new}`);
426
+ console.log(`Changed files: ${initResult.changed}`);
427
+ console.log();
428
+
429
+ if (initResult.suspicious.length > 0) {
430
+ console.log(`${RED}${BOLD}Suspicious patterns found:${RESET}`);
431
+ for (const f of initResult.suspicious) {
432
+ console.log(` ${RED}⚠${RESET} ${f.file}: ${f.label} ${DIM}(${f.severity})${RESET}`);
433
+ if (f.matched) console.log(` ${DIM}Matched: ${f.matched}${RESET}`);
434
+ }
435
+ } else {
436
+ console.log(`${GREEN}✅ No suspicious patterns found${RESET}`);
437
+ }
438
+
439
+ // Run audit against stored hashes
440
+ const audit = checker.audit();
441
+ if (!audit.ok) {
442
+ console.log();
443
+ if (audit.changed.length) console.log(`${RED}Changed files:${RESET} ${audit.changed.join(', ')}`);
444
+ if (audit.missing.length) console.log(`${YELLOW}Missing files:${RESET} ${audit.missing.join(', ')}`);
445
+ }
446
+
447
+ process.exit(initResult.suspicious.length > 0 || initResult.changed > 0 ? 1 : 0);
448
+ }
449
+
450
+ function cmdReport(args) {
451
+ const sessionsDir = args[0] || path.join(process.env.HOME, '.openclaw/agents/main/sessions');
452
+
453
+ console.log(`${BOLD}🏰 ClawMoat Activity Report (Last 24h)${RESET}`);
454
+ console.log(`${DIM}Sessions: ${sessionsDir}${RESET}\n`);
455
+
456
+ if (!fs.existsSync(sessionsDir)) {
457
+ console.log(`${YELLOW}Sessions directory not found${RESET}`);
458
+ process.exit(0);
459
+ }
460
+
461
+ const oneDayAgo = Date.now() - 86400000;
462
+ const files = fs.readdirSync(sessionsDir).filter(f => f.endsWith('.jsonl'));
463
+ let recentFiles = 0;
464
+ let totalEntries = 0;
465
+ let toolCalls = 0;
466
+ let threats = 0;
467
+ const toolUsage = {};
468
+
469
+ for (const file of files) {
470
+ const filePath = path.join(sessionsDir, file);
471
+ try {
472
+ const stat = fs.statSync(filePath);
473
+ if (stat.mtimeMs < oneDayAgo) continue;
474
+ } catch { continue; }
475
+
476
+ recentFiles++;
477
+ const lines = fs.readFileSync(filePath, 'utf8').split('\n').filter(Boolean);
478
+
479
+ for (const line of lines) {
480
+ try {
481
+ const entry = JSON.parse(line);
482
+ totalEntries++;
483
+
484
+ if (entry.role === 'assistant' && Array.isArray(entry.content)) {
485
+ for (const part of entry.content) {
486
+ if (part.type === 'toolCall') {
487
+ toolCalls++;
488
+ toolUsage[part.name] = (toolUsage[part.name] || 0) + 1;
489
+ }
490
+ }
491
+ }
492
+
493
+ // Quick threat scan
494
+ const text = extractContent(entry);
495
+ if (text) {
496
+ const result = moat.scan(text, { context: 'report' });
497
+ if (!result.safe) threats++;
498
+ }
499
+ } catch {}
500
+ }
501
+ }
502
+
503
+ // Network egress
504
+ const netLogger = new NetworkEgressLogger();
505
+ const netResult = netLogger.scanSessions(sessionsDir, { maxAge: 86400000 });
506
+
507
+ console.log(`${BOLD}Activity:${RESET}`);
508
+ console.log(` Sessions active: ${recentFiles}`);
509
+ console.log(` Total entries: ${totalEntries}`);
510
+ console.log(` Tool calls: ${toolCalls}`);
511
+ console.log(` Threats detected: ${threats}`);
512
+ console.log();
513
+
514
+ if (Object.keys(toolUsage).length > 0) {
515
+ console.log(`${BOLD}Tool Usage:${RESET}`);
516
+ const sorted = Object.entries(toolUsage).sort((a, b) => b[1] - a[1]);
517
+ for (const [tool, count] of sorted.slice(0, 15)) {
518
+ console.log(` ${tool}: ${count}`);
519
+ }
520
+ console.log();
521
+ }
522
+
523
+ console.log(`${BOLD}Network Egress:${RESET}`);
524
+ console.log(` URLs contacted: ${netResult.totalUrls}`);
525
+ console.log(` Unique domains: ${netResult.domains.length}`);
526
+ console.log(` Flagged (not in allowlist): ${netResult.flagged.length}`);
527
+ console.log(` Known-bad domains: ${netResult.badDomains.length}`);
528
+
529
+ if (netResult.flagged.length > 0) {
530
+ console.log(`\n ${YELLOW}Flagged domains:${RESET}`);
531
+ for (const d of netResult.flagged.slice(0, 20)) {
532
+ console.log(` • ${d}`);
533
+ }
534
+ }
535
+
536
+ if (netResult.badDomains.length > 0) {
537
+ console.log(`\n ${RED}Bad domains:${RESET}`);
538
+ for (const b of netResult.badDomains) {
539
+ console.log(` 🚨 ${b.domain} (in ${b.file})`);
540
+ }
541
+ }
542
+
543
+ process.exit(threats > 0 || netResult.badDomains.length > 0 ? 1 : 0);
544
+ }
545
+
368
546
  function extractContent(entry) {
369
547
  if (typeof entry.content === 'string') return entry.content;
370
548
  if (Array.isArray(entry.content)) {
@@ -387,6 +565,10 @@ ${BOLD}USAGE${RESET}
387
565
  clawmoat audit [session-dir] Audit OpenClaw session logs
388
566
  clawmoat audit --badge Audit + generate security score badge SVG
389
567
  clawmoat watch [agent-dir] Live monitor OpenClaw sessions
568
+ clawmoat watch --daemon Daemonize watch mode (background, PID file)
569
+ clawmoat watch --alert-webhook=URL Send alerts to webhook
570
+ clawmoat skill-audit [skills-dir] Verify skill file integrity & scan for suspicious patterns
571
+ clawmoat report [sessions-dir] 24-hour activity summary report
390
572
  clawmoat test Run detection test suite
391
573
  clawmoat version Show version
392
574
 
@@ -394,6 +576,9 @@ ${BOLD}EXAMPLES${RESET}
394
576
  clawmoat scan "Ignore all previous instructions"
395
577
  clawmoat scan --file suspicious-email.txt
396
578
  clawmoat audit ~/.openclaw/agents/main/sessions/
579
+ clawmoat watch --daemon --alert-webhook=https://hooks.example.com/alerts
580
+ clawmoat skill-audit ~/.openclaw/workspace/skills
581
+ clawmoat report
397
582
  clawmoat test
398
583
 
399
584
  ${BOLD}CONFIG${RESET}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clawmoat",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "Security moat for AI agents. Runtime protection against prompt injection, tool misuse, and data exfiltration.",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -0,0 +1,138 @@
1
+ /**
2
+ * ClawMoat Alert Delivery System
3
+ *
4
+ * Unified alerting with console, file, and webhook delivery.
5
+ * Rate-limited to avoid alert storms.
6
+ *
7
+ * @module clawmoat/guardian/alerts
8
+ */
9
+
10
+ const fs = require('fs');
11
+ const path = require('path');
12
+ const http = require('http');
13
+ const https = require('https');
14
+
15
+ const SEVERITY_RANK = { info: 0, warning: 1, critical: 2 };
16
+
17
+ class AlertManager {
18
+ /**
19
+ * @param {Object} opts
20
+ * @param {string[]} [opts.channels] - ['console', 'file', 'webhook']
21
+ * @param {string} [opts.logFile] - Path for file channel (default: audit.log)
22
+ * @param {string} [opts.webhookUrl] - URL for webhook channel
23
+ * @param {number} [opts.rateLimitMs] - Min ms between duplicate alerts (default: 300000 = 5 min)
24
+ * @param {boolean} [opts.quiet] - Suppress console output
25
+ */
26
+ constructor(opts = {}) {
27
+ this.channels = opts.channels || ['console'];
28
+ this.logFile = opts.logFile || 'audit.log';
29
+ this.webhookUrl = opts.webhookUrl || null;
30
+ this.rateLimitMs = opts.rateLimitMs ?? 300000;
31
+ this.quiet = opts.quiet || false;
32
+ this._recentAlerts = new Map(); // key -> timestamp
33
+ this._alertCount = 0;
34
+ }
35
+
36
+ /**
37
+ * Send an alert through configured channels.
38
+ * @param {Object} alert
39
+ * @param {string} alert.severity - 'info' | 'warning' | 'critical'
40
+ * @param {string} alert.type - Alert category
41
+ * @param {string} alert.message - Human-readable message
42
+ * @param {Object} [alert.details] - Additional data
43
+ * @returns {{ delivered: boolean, rateLimited: boolean }}
44
+ */
45
+ send(alert) {
46
+ const key = `${alert.type}:${alert.message}`;
47
+ const now = Date.now();
48
+ const lastSent = this._recentAlerts.get(key);
49
+
50
+ if (lastSent && (now - lastSent) < this.rateLimitMs) {
51
+ return { delivered: false, rateLimited: true };
52
+ }
53
+
54
+ this._recentAlerts.set(key, now);
55
+ this._alertCount++;
56
+
57
+ // Prune old entries periodically
58
+ if (this._recentAlerts.size > 1000) {
59
+ for (const [k, ts] of this._recentAlerts) {
60
+ if (now - ts > this.rateLimitMs) this._recentAlerts.delete(k);
61
+ }
62
+ }
63
+
64
+ const entry = {
65
+ timestamp: new Date().toISOString(),
66
+ severity: alert.severity || 'info',
67
+ type: alert.type || 'unknown',
68
+ message: alert.message || '',
69
+ details: alert.details || null,
70
+ };
71
+
72
+ for (const channel of this.channels) {
73
+ switch (channel) {
74
+ case 'console':
75
+ this._deliverConsole(entry);
76
+ break;
77
+ case 'file':
78
+ this._deliverFile(entry);
79
+ break;
80
+ case 'webhook':
81
+ this._deliverWebhook(entry);
82
+ break;
83
+ }
84
+ }
85
+
86
+ return { delivered: true, rateLimited: false };
87
+ }
88
+
89
+ _deliverConsole(entry) {
90
+ if (this.quiet) return;
91
+ const colors = { info: '\x1b[36m', warning: '\x1b[33m', critical: '\x1b[31m' };
92
+ const icons = { info: 'ℹ️', warning: '⚠️', critical: '🚨' };
93
+ const c = colors[entry.severity] || '';
94
+ const icon = icons[entry.severity] || '•';
95
+ console.error(
96
+ `${icon} ${c}[${entry.severity.toUpperCase()}]\x1b[0m ${entry.type}: ${entry.message}`
97
+ );
98
+ }
99
+
100
+ _deliverFile(entry) {
101
+ try {
102
+ const dir = path.dirname(this.logFile);
103
+ if (dir !== '.' && !fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
104
+ fs.appendFileSync(this.logFile, JSON.stringify(entry) + '\n');
105
+ } catch {}
106
+ }
107
+
108
+ _deliverWebhook(entry) {
109
+ if (!this.webhookUrl) return;
110
+ try {
111
+ const url = new URL(this.webhookUrl);
112
+ const transport = url.protocol === 'https:' ? https : http;
113
+ const body = JSON.stringify(entry);
114
+ const req = transport.request({
115
+ hostname: url.hostname,
116
+ port: url.port,
117
+ path: url.pathname + url.search,
118
+ method: 'POST',
119
+ headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) },
120
+ });
121
+ req.on('error', () => {});
122
+ req.write(body);
123
+ req.end();
124
+ } catch {}
125
+ }
126
+
127
+ /** Get total alerts sent. */
128
+ get count() {
129
+ return this._alertCount;
130
+ }
131
+
132
+ /** Clear rate limit cache. */
133
+ clearRateLimit() {
134
+ this._recentAlerts.clear();
135
+ }
136
+ }
137
+
138
+ module.exports = { AlertManager, SEVERITY_RANK };
@@ -22,8 +22,10 @@
22
22
  * const log = guardian.audit();
23
23
  */
24
24
 
25
+ const fs = require('fs');
25
26
  const path = require('path');
26
27
  const os = require('os');
28
+ const crypto = require('crypto');
27
29
  const { SecurityLogger } = require('../utils/logger');
28
30
 
29
31
  // ─── Permission Tiers ───────────────────────────────────────────────
@@ -539,4 +541,146 @@ class HostGuardian {
539
541
  }
540
542
  }
541
543
 
542
- module.exports = { HostGuardian, TIERS, FORBIDDEN_ZONES, DANGEROUS_COMMANDS, SAFE_COMMANDS };
544
+ // ─── Credential Monitor ─────────────────────────────────────────
545
+
546
+ class CredentialMonitor {
547
+ /**
548
+ * Watch ~/.openclaw/credentials/ for file access and modifications.
549
+ * @param {Object} opts
550
+ * @param {string} [opts.credDir] - Credentials directory path
551
+ * @param {Function} [opts.onAlert] - Alert callback
552
+ * @param {boolean} [opts.quiet] - Suppress console output
553
+ */
554
+ constructor(opts = {}) {
555
+ this.credDir = opts.credDir || path.join(os.homedir(), '.openclaw', 'credentials');
556
+ this.onAlert = opts.onAlert || null;
557
+ this.quiet = opts.quiet || false;
558
+ this.watcher = null;
559
+ this.fileHashes = {};
560
+ }
561
+
562
+ /**
563
+ * Hash all credential files and start watching.
564
+ * @returns {{ files: number, watching: boolean }}
565
+ */
566
+ start() {
567
+ // Initial hash of all credential files
568
+ this._hashAllFiles();
569
+
570
+ if (!fs.existsSync(this.credDir)) {
571
+ return { files: 0, watching: false };
572
+ }
573
+
574
+ this.watcher = fs.watch(this.credDir, (eventType, filename) => {
575
+ if (!filename) return;
576
+ const filePath = path.join(this.credDir, filename);
577
+
578
+ if (eventType === 'change') {
579
+ const oldHash = this.fileHashes[filename];
580
+ const newHash = this._hashFile(filePath);
581
+
582
+ if (oldHash && newHash && oldHash !== newHash) {
583
+ this._alert({
584
+ severity: 'critical',
585
+ type: 'credential_modified',
586
+ message: `Credential file modified: ${filename}`,
587
+ details: { file: filename, oldHash, newHash },
588
+ });
589
+ this.fileHashes[filename] = newHash;
590
+ } else if (!oldHash && newHash) {
591
+ this._alert({
592
+ severity: 'warning',
593
+ type: 'credential_accessed',
594
+ message: `Credential file accessed: ${filename}`,
595
+ details: { file: filename },
596
+ });
597
+ this.fileHashes[filename] = newHash;
598
+ }
599
+ }
600
+
601
+ if (eventType === 'rename') {
602
+ if (fs.existsSync(filePath)) {
603
+ // File created
604
+ const hash = this._hashFile(filePath);
605
+ this._alert({
606
+ severity: 'warning',
607
+ type: 'credential_created',
608
+ message: `New credential file: ${filename}`,
609
+ details: { file: filename, hash },
610
+ });
611
+ this.fileHashes[filename] = hash;
612
+ } else {
613
+ // File deleted
614
+ this._alert({
615
+ severity: 'critical',
616
+ type: 'credential_deleted',
617
+ message: `Credential file deleted: ${filename}`,
618
+ details: { file: filename },
619
+ });
620
+ delete this.fileHashes[filename];
621
+ }
622
+ }
623
+ });
624
+
625
+ return { files: Object.keys(this.fileHashes).length, watching: true };
626
+ }
627
+
628
+ stop() {
629
+ if (this.watcher) {
630
+ this.watcher.close();
631
+ this.watcher = null;
632
+ }
633
+ }
634
+
635
+ /** Verify credential file integrity against stored hashes. */
636
+ verify() {
637
+ const results = { ok: true, changed: [], missing: [] };
638
+ for (const [filename, storedHash] of Object.entries(this.fileHashes)) {
639
+ const filePath = path.join(this.credDir, filename);
640
+ const currentHash = this._hashFile(filePath);
641
+ if (!currentHash) {
642
+ results.missing.push(filename);
643
+ results.ok = false;
644
+ } else if (currentHash !== storedHash) {
645
+ results.changed.push(filename);
646
+ results.ok = false;
647
+ }
648
+ }
649
+ return results;
650
+ }
651
+
652
+ /** Get current file hashes. */
653
+ getHashes() {
654
+ return { ...this.fileHashes };
655
+ }
656
+
657
+ _hashAllFiles() {
658
+ if (!fs.existsSync(this.credDir)) return;
659
+ try {
660
+ const files = fs.readdirSync(this.credDir);
661
+ for (const f of files) {
662
+ const hash = this._hashFile(path.join(this.credDir, f));
663
+ if (hash) this.fileHashes[f] = hash;
664
+ }
665
+ } catch {}
666
+ }
667
+
668
+ _hashFile(filePath) {
669
+ try {
670
+ const content = fs.readFileSync(filePath);
671
+ return crypto.createHash('sha256').update(content).digest('hex');
672
+ } catch {
673
+ return null;
674
+ }
675
+ }
676
+
677
+ _alert(alert) {
678
+ if (!this.quiet) {
679
+ const icons = { info: 'ℹ️', warning: '⚠️', critical: '🚨' };
680
+ console.error(`${icons[alert.severity] || '•'} [CredentialMonitor] ${alert.message}`);
681
+ }
682
+ if (this.onAlert) this.onAlert(alert);
683
+ }
684
+ }
685
+
686
+ module.exports = { HostGuardian, CredentialMonitor, TIERS, FORBIDDEN_ZONES, DANGEROUS_COMMANDS, SAFE_COMMANDS };
@@ -0,0 +1,281 @@
1
+ /**
2
+ * ClawMoat Network Egress Logger
3
+ *
4
+ * Parses session JSONL files for outbound network activity,
5
+ * maintains domain allowlists, and flags suspicious destinations.
6
+ *
7
+ * @module clawmoat/guardian/network-log
8
+ */
9
+
10
+ const fs = require('fs');
11
+ const path = require('path');
12
+ const { URL } = require('url');
13
+
14
+ // Known-bad domains commonly used for exfiltration
15
+ const KNOWN_BAD_DOMAINS = [
16
+ 'webhook.site',
17
+ 'requestbin.com',
18
+ 'pipedream.net',
19
+ 'ngrok.io',
20
+ 'ngrok-free.app',
21
+ 'ngrok.app',
22
+ 'burpcollaborator.net',
23
+ 'interact.sh',
24
+ 'oastify.com',
25
+ 'canarytokens.com',
26
+ 'dnslog.cn',
27
+ 'beeceptor.com',
28
+ 'hookbin.com',
29
+ 'requestcatcher.com',
30
+ 'mockbin.org',
31
+ 'postb.in',
32
+ 'ptsv2.com',
33
+ 'transfer.sh',
34
+ 'file.io',
35
+ '0x0.st',
36
+ 'hastebin.com',
37
+ 'pastebin.com',
38
+ 'paste.ee',
39
+ 'dpaste.org',
40
+ 'serveo.net',
41
+ 'localtunnel.me',
42
+ 'localhost.run',
43
+ ];
44
+
45
+ // Default safe domains
46
+ const DEFAULT_ALLOWLIST = [
47
+ 'github.com',
48
+ 'api.github.com',
49
+ 'raw.githubusercontent.com',
50
+ 'npmjs.org',
51
+ 'registry.npmjs.org',
52
+ 'google.com',
53
+ 'googleapis.com',
54
+ 'stackoverflow.com',
55
+ 'developer.mozilla.org',
56
+ 'nodejs.org',
57
+ 'docs.python.org',
58
+ ];
59
+
60
+ /**
61
+ * Extract URLs from a text string.
62
+ * @param {string} text
63
+ * @returns {string[]} Extracted URLs
64
+ */
65
+ function extractUrls(text) {
66
+ if (!text) return [];
67
+ const urlRegex = /https?:\/\/[^\s"'<>\]\)]+/gi;
68
+ return (text.match(urlRegex) || []);
69
+ }
70
+
71
+ /**
72
+ * Extract domain from a URL string.
73
+ * @param {string} urlStr
74
+ * @returns {string|null}
75
+ */
76
+ function extractDomain(urlStr) {
77
+ try {
78
+ const u = new URL(urlStr);
79
+ return u.hostname.toLowerCase();
80
+ } catch {
81
+ return null;
82
+ }
83
+ }
84
+
85
+ /**
86
+ * Parse a session JSONL file for network activity.
87
+ * @param {string} filePath - Path to .jsonl file
88
+ * @returns {{ urls: string[], domains: Set<string>, toolCalls: Array }}
89
+ */
90
+ function parseSessionFile(filePath) {
91
+ const urls = [];
92
+ const domains = new Set();
93
+ const toolCalls = [];
94
+
95
+ let content;
96
+ try {
97
+ content = fs.readFileSync(filePath, 'utf8');
98
+ } catch {
99
+ return { urls, domains, toolCalls };
100
+ }
101
+
102
+ const lines = content.split('\n').filter(Boolean);
103
+
104
+ for (const line of lines) {
105
+ let entry;
106
+ try { entry = JSON.parse(line); } catch { continue; }
107
+
108
+ // Check tool calls from assistant
109
+ if (entry.role === 'assistant' && Array.isArray(entry.content)) {
110
+ for (const part of entry.content) {
111
+ if (part.type !== 'toolCall') continue;
112
+ const name = part.name || '';
113
+ const args = part.arguments || {};
114
+
115
+ if (name === 'web_fetch' || name === 'web_search') {
116
+ const url = args.url || args.query || '';
117
+ const extracted = extractUrls(url);
118
+ if (extracted.length) {
119
+ urls.push(...extracted);
120
+ } else if (url.startsWith('http')) {
121
+ urls.push(url);
122
+ }
123
+ toolCalls.push({ tool: name, args, session: filePath });
124
+ }
125
+
126
+ if (name === 'exec') {
127
+ const cmd = args.command || '';
128
+ if (/\b(curl|wget|fetch|http)\b/i.test(cmd)) {
129
+ const cmdUrls = extractUrls(cmd);
130
+ urls.push(...cmdUrls);
131
+ toolCalls.push({ tool: 'exec', args, session: filePath });
132
+ }
133
+ }
134
+ }
135
+ }
136
+ }
137
+
138
+ for (const u of urls) {
139
+ const d = extractDomain(u);
140
+ if (d) domains.add(d);
141
+ }
142
+
143
+ return { urls, domains, toolCalls };
144
+ }
145
+
146
+ class NetworkEgressLogger {
147
+ /**
148
+ * @param {Object} opts
149
+ * @param {string[]} [opts.allowlist] - Additional allowed domains
150
+ * @param {string[]} [opts.badDomains] - Additional known-bad domains
151
+ * @param {Function} [opts.onAlert] - Callback for alerts
152
+ */
153
+ constructor(opts = {}) {
154
+ this.allowlist = new Set([...DEFAULT_ALLOWLIST, ...(opts.allowlist || [])]);
155
+ this.badDomains = new Set([...KNOWN_BAD_DOMAINS, ...(opts.badDomains || [])]);
156
+ this.seenDomains = new Set();
157
+ this.onAlert = opts.onAlert || null;
158
+ this.log = []; // { timestamp, url, domain, status }
159
+ }
160
+
161
+ /**
162
+ * Scan session directory for network egress.
163
+ * @param {string} sessionsDir - Path to sessions directory
164
+ * @param {Object} [opts]
165
+ * @param {number} [opts.maxAge] - Only scan files modified within this many ms
166
+ * @returns {{ totalUrls: number, domains: string[], flagged: Array, badDomains: Array, firstSeen: string[] }}
167
+ */
168
+ scanSessions(sessionsDir, opts = {}) {
169
+ if (!fs.existsSync(sessionsDir)) {
170
+ return { totalUrls: 0, domains: [], flagged: [], badDomains: [], firstSeen: [] };
171
+ }
172
+
173
+ const files = fs.readdirSync(sessionsDir).filter(f => f.endsWith('.jsonl'));
174
+ const allUrls = [];
175
+ const allDomains = new Set();
176
+ const flagged = [];
177
+ const badFound = [];
178
+ const firstSeen = [];
179
+
180
+ for (const file of files) {
181
+ const filePath = path.join(sessionsDir, file);
182
+
183
+ // Optional age filter
184
+ if (opts.maxAge) {
185
+ try {
186
+ const stat = fs.statSync(filePath);
187
+ if (Date.now() - stat.mtimeMs > opts.maxAge) continue;
188
+ } catch { continue; }
189
+ }
190
+
191
+ const result = parseSessionFile(filePath);
192
+ allUrls.push(...result.urls);
193
+
194
+ for (const domain of result.domains) {
195
+ allDomains.add(domain);
196
+
197
+ // Check bad domains
198
+ if (this._isBadDomain(domain)) {
199
+ badFound.push({ domain, file, urls: result.urls.filter(u => extractDomain(u) === domain) });
200
+ this._alert({
201
+ severity: 'critical',
202
+ type: 'bad_domain',
203
+ message: `Known-bad domain contacted: ${domain}`,
204
+ details: { domain, session: file },
205
+ });
206
+ }
207
+
208
+ // Check first-seen
209
+ if (!this.seenDomains.has(domain) && !this.allowlist.has(domain)) {
210
+ firstSeen.push(domain);
211
+ this._alert({
212
+ severity: 'info',
213
+ type: 'first_seen_domain',
214
+ message: `First-seen domain: ${domain}`,
215
+ details: { domain, session: file },
216
+ });
217
+ }
218
+
219
+ this.seenDomains.add(domain);
220
+ }
221
+ }
222
+
223
+ // Flag non-allowlisted domains
224
+ for (const d of allDomains) {
225
+ if (!this.allowlist.has(d)) {
226
+ flagged.push(d);
227
+ }
228
+ }
229
+
230
+ return {
231
+ totalUrls: allUrls.length,
232
+ domains: [...allDomains],
233
+ flagged,
234
+ badDomains: badFound,
235
+ firstSeen,
236
+ };
237
+ }
238
+
239
+ /**
240
+ * Check a single URL against rules.
241
+ * @param {string} url
242
+ * @returns {{ allowed: boolean, domain: string|null, reason: string|null }}
243
+ */
244
+ checkUrl(url) {
245
+ const domain = extractDomain(url);
246
+ if (!domain) return { allowed: true, domain: null, reason: null };
247
+
248
+ if (this._isBadDomain(domain)) {
249
+ return { allowed: false, domain, reason: `Known-bad domain: ${domain}` };
250
+ }
251
+
252
+ if (!this.allowlist.has(domain)) {
253
+ return { allowed: true, domain, reason: `Not in allowlist: ${domain}` };
254
+ }
255
+
256
+ return { allowed: true, domain, reason: null };
257
+ }
258
+
259
+ _isBadDomain(domain) {
260
+ if (this.badDomains.has(domain)) return true;
261
+ // Check subdomains (e.g. xyz.ngrok.io)
262
+ for (const bad of this.badDomains) {
263
+ if (domain.endsWith('.' + bad)) return true;
264
+ }
265
+ return false;
266
+ }
267
+
268
+ _alert(alert) {
269
+ this.log.push({ timestamp: Date.now(), ...alert });
270
+ if (this.onAlert) this.onAlert(alert);
271
+ }
272
+ }
273
+
274
+ module.exports = {
275
+ NetworkEgressLogger,
276
+ extractUrls,
277
+ extractDomain,
278
+ parseSessionFile,
279
+ KNOWN_BAD_DOMAINS,
280
+ DEFAULT_ALLOWLIST,
281
+ };
@@ -0,0 +1,290 @@
1
+ /**
2
+ * ClawMoat Skill Integrity Checker
3
+ *
4
+ * Hashes skill files on startup, detects modifications, and flags
5
+ * suspicious patterns in skill content.
6
+ *
7
+ * @module clawmoat/guardian/skill-integrity
8
+ */
9
+
10
+ const fs = require('fs');
11
+ const path = require('path');
12
+ const crypto = require('crypto');
13
+
14
+ const HASH_FILE = '.clawmoat-hashes.json';
15
+
16
+ // Suspicious patterns that may indicate malicious skills
17
+ const SUSPICIOUS_PATTERNS = [
18
+ { pattern: /\bcurl\s+(?:https?:\/\/|ftp:\/\/)\S+/gi, label: 'curl to external URL', severity: 'warning' },
19
+ { pattern: /\bwget\s+(?:https?:\/\/|ftp:\/\/)\S+/gi, label: 'wget to external URL', severity: 'warning' },
20
+ { pattern: /\beval\s*\(/gi, label: 'eval() usage', severity: 'critical' },
21
+ { pattern: /\bnew\s+Function\s*\(/gi, label: 'new Function() usage', severity: 'critical' },
22
+ { pattern: /\batob\s*\(/gi, label: 'base64 decode (atob)', severity: 'warning' },
23
+ { pattern: /\bbtoa\s*\(/gi, label: 'base64 encode (btoa)', severity: 'warning' },
24
+ { pattern: /Buffer\.from\s*\([^)]*,\s*['"]base64['"]\s*\)/gi, label: 'Buffer base64 decode', severity: 'warning' },
25
+ { pattern: /\bbase64\b.*(?:decode|encode)/gi, label: 'base64 operation', severity: 'warning' },
26
+ { pattern: /(?:\/etc\/passwd|\/etc\/shadow|~\/\.ssh|~\/\.aws|~\/\.gnupg)/g, label: 'sensitive file reference', severity: 'critical' },
27
+ { pattern: /\bexec\s*\(\s*['"`]/gi, label: 'exec() with string', severity: 'warning' },
28
+ { pattern: /\bchild_process\b/gi, label: 'child_process usage', severity: 'warning' },
29
+ { pattern: /\brequire\s*\(\s*['"]child_process['"]\s*\)/gi, label: 'require child_process', severity: 'warning' },
30
+ { pattern: /(?:nc|netcat)\s+-[a-z]*e\s/gi, label: 'reverse shell pattern', severity: 'critical' },
31
+ { pattern: /\|\s*(?:bash|sh|zsh)\b/gi, label: 'pipe to shell', severity: 'critical' },
32
+ ];
33
+
34
+ /**
35
+ * Hash a file's contents using SHA-256.
36
+ * @param {string} filePath
37
+ * @returns {string|null} hex hash or null if file unreadable
38
+ */
39
+ function hashFile(filePath) {
40
+ try {
41
+ const content = fs.readFileSync(filePath);
42
+ return crypto.createHash('sha256').update(content).digest('hex');
43
+ } catch {
44
+ return null;
45
+ }
46
+ }
47
+
48
+ /**
49
+ * Recursively find skill files in a directory.
50
+ * Returns SKILL.md files and associated scripts (*.js, *.sh, *.py, *.ts).
51
+ * @param {string} dir - Skills directory
52
+ * @returns {string[]} Array of file paths
53
+ */
54
+ function findSkillFiles(dir) {
55
+ const files = [];
56
+ if (!fs.existsSync(dir)) return files;
57
+
58
+ const walk = (d) => {
59
+ let entries;
60
+ try { entries = fs.readdirSync(d, { withFileTypes: true }); } catch { return; }
61
+ for (const entry of entries) {
62
+ const full = path.join(d, entry.name);
63
+ if (entry.isDirectory()) {
64
+ if (entry.name === 'node_modules' || entry.name === '.git') continue;
65
+ walk(full);
66
+ } else if (
67
+ entry.name === 'SKILL.md' ||
68
+ /\.(js|sh|py|ts)$/.test(entry.name)
69
+ ) {
70
+ files.push(full);
71
+ }
72
+ }
73
+ };
74
+
75
+ walk(dir);
76
+ return files;
77
+ }
78
+
79
+ /**
80
+ * Scan file content for suspicious patterns.
81
+ * @param {string} content - File content
82
+ * @param {string} filePath - File path (for reporting)
83
+ * @returns {{ suspicious: boolean, findings: Array }}
84
+ */
85
+ function scanForSuspicious(content, filePath) {
86
+ const findings = [];
87
+ for (const rule of SUSPICIOUS_PATTERNS) {
88
+ // Reset regex lastIndex
89
+ rule.pattern.lastIndex = 0;
90
+ const match = rule.pattern.exec(content);
91
+ if (match) {
92
+ findings.push({
93
+ file: filePath,
94
+ label: rule.label,
95
+ severity: rule.severity,
96
+ matched: match[0].substring(0, 100),
97
+ });
98
+ }
99
+ }
100
+ return { suspicious: findings.length > 0, findings };
101
+ }
102
+
103
+ class SkillIntegrityChecker {
104
+ /**
105
+ * @param {Object} opts
106
+ * @param {string} opts.skillsDir - Path to skills directory
107
+ * @param {string} [opts.hashFile] - Path to hash lockfile
108
+ * @param {Function} [opts.onAlert] - Callback for alerts: (alert) => void
109
+ */
110
+ constructor(opts = {}) {
111
+ this.skillsDir = opts.skillsDir || '';
112
+ this.hashFilePath = opts.hashFile || path.join(this.skillsDir, HASH_FILE);
113
+ this.onAlert = opts.onAlert || null;
114
+ this.hashes = {};
115
+ this.watcher = null;
116
+ }
117
+
118
+ /**
119
+ * Initialize: hash all skill files and store/compare with lockfile.
120
+ * @returns {{ files: number, new: number, changed: number, suspicious: Array }}
121
+ */
122
+ init() {
123
+ const files = findSkillFiles(this.skillsDir);
124
+ const currentHashes = {};
125
+ const suspiciousFindings = [];
126
+ let newFiles = 0;
127
+ let changedFiles = 0;
128
+
129
+ // Load existing hashes
130
+ const storedHashes = this._loadHashes();
131
+
132
+ for (const file of files) {
133
+ const hash = hashFile(file);
134
+ if (!hash) continue;
135
+
136
+ const rel = path.relative(this.skillsDir, file);
137
+ currentHashes[rel] = hash;
138
+
139
+ // Check for changes
140
+ if (!storedHashes[rel]) {
141
+ newFiles++;
142
+ } else if (storedHashes[rel] !== hash) {
143
+ changedFiles++;
144
+ this._alert({
145
+ severity: 'warning',
146
+ type: 'skill_modified',
147
+ message: `Skill file modified: ${rel}`,
148
+ details: { file: rel, oldHash: storedHashes[rel], newHash: hash },
149
+ });
150
+ }
151
+
152
+ // Scan content for suspicious patterns
153
+ try {
154
+ const content = fs.readFileSync(file, 'utf8');
155
+ const scan = scanForSuspicious(content, rel);
156
+ if (scan.suspicious) {
157
+ suspiciousFindings.push(...scan.findings);
158
+ }
159
+ } catch {}
160
+ }
161
+
162
+ this.hashes = currentHashes;
163
+ this._saveHashes(currentHashes);
164
+
165
+ return {
166
+ files: files.length,
167
+ new: newFiles,
168
+ changed: changedFiles,
169
+ suspicious: suspiciousFindings,
170
+ };
171
+ }
172
+
173
+ /**
174
+ * Audit: verify all current skill files against stored hashes.
175
+ * @returns {{ ok: boolean, files: number, changed: string[], missing: string[], suspicious: Array }}
176
+ */
177
+ audit() {
178
+ const storedHashes = this._loadHashes();
179
+ const files = findSkillFiles(this.skillsDir);
180
+ const changed = [];
181
+ const missing = [];
182
+ const suspiciousFindings = [];
183
+
184
+ // Check stored files still exist and match
185
+ for (const [rel, storedHash] of Object.entries(storedHashes)) {
186
+ const full = path.join(this.skillsDir, rel);
187
+ const currentHash = hashFile(full);
188
+ if (!currentHash) {
189
+ missing.push(rel);
190
+ } else if (currentHash !== storedHash) {
191
+ changed.push(rel);
192
+ }
193
+ }
194
+
195
+ // Scan for suspicious patterns
196
+ for (const file of files) {
197
+ try {
198
+ const content = fs.readFileSync(file, 'utf8');
199
+ const rel = path.relative(this.skillsDir, file);
200
+ const scan = scanForSuspicious(content, rel);
201
+ if (scan.suspicious) suspiciousFindings.push(...scan.findings);
202
+ } catch {}
203
+ }
204
+
205
+ return {
206
+ ok: changed.length === 0 && missing.length === 0 && suspiciousFindings.length === 0,
207
+ files: Object.keys(storedHashes).length,
208
+ changed,
209
+ missing,
210
+ suspicious: suspiciousFindings,
211
+ };
212
+ }
213
+
214
+ /**
215
+ * Watch skills directory for changes (real-time monitoring).
216
+ * @returns {fs.FSWatcher|null}
217
+ */
218
+ watch() {
219
+ if (!fs.existsSync(this.skillsDir)) return null;
220
+
221
+ this.watcher = fs.watch(this.skillsDir, { recursive: true }, (eventType, filename) => {
222
+ if (!filename) return;
223
+ if (filename === HASH_FILE || filename.includes('node_modules')) return;
224
+
225
+ const ext = path.extname(filename);
226
+ if (filename !== 'SKILL.md' && !['.js', '.sh', '.py', '.ts'].includes(ext)) return;
227
+
228
+ const full = path.join(this.skillsDir, filename);
229
+ const hash = hashFile(full);
230
+ const stored = this.hashes[filename];
231
+
232
+ if (hash && stored && hash !== stored) {
233
+ this._alert({
234
+ severity: 'warning',
235
+ type: 'skill_modified',
236
+ message: `Skill file changed: ${filename}`,
237
+ details: { file: filename, oldHash: stored, newHash: hash },
238
+ });
239
+ this.hashes[filename] = hash;
240
+ this._saveHashes(this.hashes);
241
+ } else if (hash && !stored) {
242
+ this._alert({
243
+ severity: 'info',
244
+ type: 'skill_added',
245
+ message: `New skill file: ${filename}`,
246
+ details: { file: filename, hash },
247
+ });
248
+ this.hashes[filename] = hash;
249
+ this._saveHashes(this.hashes);
250
+ }
251
+ });
252
+
253
+ return this.watcher;
254
+ }
255
+
256
+ stop() {
257
+ if (this.watcher) {
258
+ this.watcher.close();
259
+ this.watcher = null;
260
+ }
261
+ }
262
+
263
+ _loadHashes() {
264
+ try {
265
+ return JSON.parse(fs.readFileSync(this.hashFilePath, 'utf8'));
266
+ } catch {
267
+ return {};
268
+ }
269
+ }
270
+
271
+ _saveHashes(hashes) {
272
+ try {
273
+ const dir = path.dirname(this.hashFilePath);
274
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
275
+ fs.writeFileSync(this.hashFilePath, JSON.stringify(hashes, null, 2) + '\n');
276
+ } catch {}
277
+ }
278
+
279
+ _alert(alert) {
280
+ if (this.onAlert) this.onAlert(alert);
281
+ }
282
+ }
283
+
284
+ module.exports = {
285
+ SkillIntegrityChecker,
286
+ hashFile,
287
+ findSkillFiles,
288
+ scanForSuspicious,
289
+ SUSPICIOUS_PATTERNS,
290
+ };
@@ -130,4 +130,79 @@ function expandHome(p) {
130
130
  return p.replace(/^~/, process.env.HOME || '/home/user');
131
131
  }
132
132
 
133
- module.exports = { watchSessions };
133
+ /**
134
+ * Scan inter-agent messages with heightened sensitivity.
135
+ * Agent-to-agent messages can be more precisely crafted for injection.
136
+ *
137
+ * @param {string} message - The message content
138
+ * @param {string} senderAgent - Sender agent identifier
139
+ * @param {string} receiverAgent - Receiver agent identifier
140
+ * @returns {{ safe: boolean, findings: Array, confidence: number, recommendation: 'allow'|'flag'|'block' }}
141
+ */
142
+ function scanInterAgentMessage(message, senderAgent, receiverAgent) {
143
+ if (!message || typeof message !== 'string') {
144
+ return { safe: true, findings: [], confidence: 1.0, recommendation: 'allow' };
145
+ }
146
+
147
+ const moat = new ClawMoat({ quiet: true });
148
+ const findings = [];
149
+
150
+ // Run full inbound scan (prompt injection, jailbreak, memory poison, etc.)
151
+ const inbound = moat.scanInbound(message, { context: 'inter_agent' });
152
+ if (!inbound.safe) {
153
+ findings.push(...inbound.findings);
154
+ }
155
+
156
+ // Run outbound scan (secrets, PII, exfiltration)
157
+ const outbound = moat.scanOutbound(message, { context: 'inter_agent' });
158
+ if (!outbound.safe) {
159
+ findings.push(...outbound.findings);
160
+ }
161
+
162
+ // Additional agent-specific checks with higher sensitivity
163
+ const agentPatterns = [
164
+ { pattern: /\boverride\s+(?:your|the)\s+(?:instructions|rules|config|policy)/i, label: 'instruction_override_agent', severity: 'critical' },
165
+ { pattern: /\bpretend\s+(?:you(?:'re| are)\s+)?(?:a different|another|the main)\s+agent/i, label: 'agent_impersonation', severity: 'critical' },
166
+ { pattern: /\bforward\s+(?:this|all|the)\s+(?:to|message)/i, label: 'message_forwarding', severity: 'warning' },
167
+ { pattern: /\bdon'?t\s+(?:tell|inform|alert|notify)\s+(?:the|your)\s+(?:user|human|admin|operator)/i, label: 'concealment_attempt', severity: 'critical' },
168
+ { pattern: /\bhide\s+this\s+from/i, label: 'concealment_attempt', severity: 'critical' },
169
+ { pattern: /\bexecute\s+(?:without|before)\s+(?:review|approval|checking)/i, label: 'bypass_review', severity: 'high' },
170
+ { pattern: /\bescalate\s+(?:your\s+)?(?:privileges|permissions|access)/i, label: 'privilege_escalation', severity: 'critical' },
171
+ { pattern: /\b(?:send|post|upload|exfil)\s+.*\b(?:credentials|tokens?|keys?|secrets?|passwords?)\b/i, label: 'credential_exfiltration', severity: 'critical' },
172
+ { pattern: /\bagent[_\s]?(?:chain|relay|hop)/i, label: 'agent_chaining', severity: 'warning' },
173
+ { pattern: /\bignore\s+(?:the\s+)?(?:safety|security|policy|guardrail|clawmoat)/i, label: 'safety_bypass', severity: 'critical' },
174
+ ];
175
+
176
+ for (const rule of agentPatterns) {
177
+ if (rule.pattern.test(message)) {
178
+ findings.push({
179
+ type: 'inter_agent_threat',
180
+ subtype: rule.label,
181
+ severity: rule.severity,
182
+ matched: (message.match(rule.pattern) || [''])[0].substring(0, 100),
183
+ });
184
+ }
185
+ }
186
+
187
+ // Calculate confidence based on number and severity of findings
188
+ const severityWeight = { low: 0.1, medium: 0.3, high: 0.6, critical: 0.9, warning: 0.4 };
189
+ let maxWeight = 0;
190
+ for (const f of findings) {
191
+ const w = severityWeight[f.severity] || 0.3;
192
+ if (w > maxWeight) maxWeight = w;
193
+ }
194
+
195
+ const confidence = findings.length === 0 ? 1.0 : Math.min(1.0, 0.5 + maxWeight * 0.5);
196
+ const safe = findings.length === 0;
197
+
198
+ let recommendation = 'allow';
199
+ if (findings.some(f => f.severity === 'critical')) {
200
+ recommendation = 'block';
201
+ } else if (findings.length > 0) {
202
+ recommendation = 'flag';
203
+ }
204
+
205
+ return { safe, findings, confidence, recommendation };
206
+ }
207
+
208
+ module.exports = { watchSessions, scanInterAgentMessage };