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 +10 -0
- package/bin/clawmoat.js +186 -1
- package/package.json +1 -1
- package/src/guardian/alerts.js +138 -0
- package/src/guardian/index.js +145 -1
- package/src/guardian/network-log.js +281 -0
- package/src/guardian/skill-integrity.js +290 -0
- package/src/middleware/openclaw.js +76 -1
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
|
|
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
|
@@ -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 };
|
package/src/guardian/index.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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 };
|