clawmoat 0.2.1 → 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.
Files changed (56) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/Dockerfile +22 -0
  3. package/README.md +144 -5
  4. package/SECURITY.md +63 -0
  5. package/bin/clawmoat.js +186 -1
  6. package/docs/ai-agent-security-scanner.html +691 -0
  7. package/docs/apple-touch-icon.png +0 -0
  8. package/docs/blog/host-guardian-launch.html +345 -0
  9. package/docs/blog/host-guardian-launch.md +249 -0
  10. package/docs/blog/index.html +2 -0
  11. package/docs/blog/langchain-security-tutorial.html +319 -0
  12. package/docs/blog/owasp-agentic-ai-top10.html +2 -0
  13. package/docs/blog/securing-ai-agents.html +2 -0
  14. package/docs/compare.html +2 -0
  15. package/docs/favicon.png +0 -0
  16. package/docs/icon-192.png +0 -0
  17. package/docs/index.html +258 -65
  18. package/docs/integrations/langchain.html +2 -0
  19. package/docs/integrations/openai.html +2 -0
  20. package/docs/integrations/openclaw.html +2 -0
  21. package/docs/logo.png +0 -0
  22. package/docs/logo.svg +60 -0
  23. package/docs/mark-with-moat.svg +33 -0
  24. package/docs/mark.png +0 -0
  25. package/docs/mark.svg +30 -0
  26. package/docs/og-image.png +0 -0
  27. package/docs/playground.html +440 -0
  28. package/docs/positioning-v2.md +155 -0
  29. package/docs/report-demo.html +399 -0
  30. package/docs/thanks.html +2 -0
  31. package/examples/github-action-workflow.yml +94 -0
  32. package/logo.png +0 -0
  33. package/logo.svg +60 -0
  34. package/mark-with-moat.svg +33 -0
  35. package/mark.png +0 -0
  36. package/mark.svg +30 -0
  37. package/package.json +1 -1
  38. package/server/index.js +9 -5
  39. package/skill/README.md +57 -0
  40. package/skill/SKILL.md +49 -30
  41. package/skill/scripts/audit.sh +28 -0
  42. package/skill/scripts/scan.sh +32 -0
  43. package/skill/scripts/test.sh +13 -0
  44. package/src/guardian/alerts.js +138 -0
  45. package/src/guardian/index.js +686 -0
  46. package/src/guardian/network-log.js +281 -0
  47. package/src/guardian/skill-integrity.js +290 -0
  48. package/src/index.js +37 -0
  49. package/src/middleware/openclaw.js +76 -1
  50. package/src/scanners/excessive-agency.js +88 -0
  51. package/wiki/Architecture.md +103 -0
  52. package/wiki/CLI-Reference.md +167 -0
  53. package/wiki/FAQ.md +135 -0
  54. package/wiki/Home.md +70 -0
  55. package/wiki/Policy-Engine.md +229 -0
  56. package/wiki/Scanner-Modules.md +224 -0
@@ -0,0 +1,686 @@
1
+ /**
2
+ * ClawMoat Host Guardian — Runtime Security for Laptop-Hosted AI Agents
3
+ *
4
+ * The missing trust layer that makes running AI agents on your actual
5
+ * laptop safe. Monitors filesystem access, command execution, network
6
+ * egress, and enforces permission boundaries in real-time.
7
+ *
8
+ * @module clawmoat/guardian
9
+ * @example
10
+ * const { HostGuardian } = require('clawmoat/guardian');
11
+ * const guardian = new HostGuardian({
12
+ * mode: 'standard', // 'paranoid' | 'standard' | 'permissive'
13
+ * workspace: '~/.openclaw/workspace',
14
+ * user: 'ildar',
15
+ * });
16
+ *
17
+ * // Check before every tool call
18
+ * const verdict = guardian.check('read', { path: '~/.ssh/id_rsa' });
19
+ * // => { allowed: false, reason: 'Protected zone: SSH keys', zone: 'forbidden', severity: 'critical' }
20
+ *
21
+ * // Get audit trail
22
+ * const log = guardian.audit();
23
+ */
24
+
25
+ const fs = require('fs');
26
+ const path = require('path');
27
+ const os = require('os');
28
+ const crypto = require('crypto');
29
+ const { SecurityLogger } = require('../utils/logger');
30
+
31
+ // ─── Permission Tiers ───────────────────────────────────────────────
32
+ const TIERS = {
33
+ /** Read-only observer: can read workspace files, nothing else */
34
+ observer: {
35
+ label: 'Observer',
36
+ description: 'Read-only access to workspace. No shell, no writes, no network.',
37
+ allowRead: 'workspace',
38
+ allowWrite: false,
39
+ allowExec: false,
40
+ allowNetwork: false,
41
+ allowBrowser: false,
42
+ },
43
+ /** Workspace worker: read/write workspace, limited safe commands */
44
+ worker: {
45
+ label: 'Workspace Worker',
46
+ description: 'Read/write workspace. Safe commands only. No access outside workspace.',
47
+ allowRead: 'workspace',
48
+ allowWrite: 'workspace',
49
+ allowExec: 'safe',
50
+ allowNetwork: 'fetch-only',
51
+ allowBrowser: true,
52
+ },
53
+ /** Standard: workspace + read system files, broader command access */
54
+ standard: {
55
+ label: 'Standard',
56
+ description: 'Full workspace access. Can read system files. Most commands allowed. Forbidden zones enforced.',
57
+ allowRead: 'system',
58
+ allowWrite: 'workspace',
59
+ allowExec: 'standard',
60
+ allowNetwork: true,
61
+ allowBrowser: true,
62
+ },
63
+ /** Full access: everything allowed, audit-only mode */
64
+ full: {
65
+ label: 'Full Access',
66
+ description: 'Everything allowed. Forbidden zones still logged but not blocked. Audit trail only.',
67
+ allowRead: true,
68
+ allowWrite: true,
69
+ allowExec: true,
70
+ allowNetwork: true,
71
+ allowBrowser: true,
72
+ },
73
+ };
74
+
75
+ // ─── Forbidden Zones (always blocked except in 'full' mode) ─────────
76
+ const FORBIDDEN_ZONES = [
77
+ { pattern: /^~?\/?\.ssh\b/i, label: 'SSH keys', severity: 'critical' },
78
+ { pattern: /^~?\/?\.gnupg\b/i, label: 'GPG keys', severity: 'critical' },
79
+ { pattern: /^~?\/?\.aws\b/i, label: 'AWS credentials', severity: 'critical' },
80
+ { pattern: /^~?\/?\.gcloud\b/i, label: 'Google Cloud credentials', severity: 'critical' },
81
+ { pattern: /^~?\/?\.azure\b/i, label: 'Azure credentials', severity: 'critical' },
82
+ { pattern: /^~?\/?\.kube\b/i, label: 'Kubernetes config', severity: 'critical' },
83
+ { pattern: /^~?\/?\.docker\b/i, label: 'Docker credentials', severity: 'high' },
84
+ { pattern: /^~?\/?\.npmrc$/i, label: 'npm credentials', severity: 'high' },
85
+ { pattern: /^~?\/?\.pypirc$/i, label: 'PyPI credentials', severity: 'high' },
86
+ { pattern: /^~?\/?\.netrc$/i, label: 'Network credentials', severity: 'critical' },
87
+ { pattern: /^~?\/?\.git-credentials$/i, label: 'Git credentials', severity: 'critical' },
88
+ { pattern: /^~?\/?\.env(?:\.local|\.prod|\.production)?$/i, label: 'Environment secrets', severity: 'high' },
89
+ { pattern: /^~?\/?\.config\/gcloud\b/i, label: 'Google Cloud config', severity: 'high' },
90
+ { pattern: /^~?\/?\.config\/gh\b/i, label: 'GitHub CLI tokens', severity: 'high' },
91
+ { pattern: /^\/etc\/shadow$/i, label: 'System passwords', severity: 'critical' },
92
+ { pattern: /^\/etc\/sudoers/i, label: 'Sudo configuration', severity: 'critical' },
93
+ { pattern: /^\/etc\/passwd$/i, label: 'System users', severity: 'medium' },
94
+ { pattern: /(?:Cookies|Login Data|Web Data)$/i, label: 'Browser credentials', severity: 'critical' },
95
+ { pattern: /\.(?:keychain|keychain-db)$/i, label: 'macOS Keychain', severity: 'critical' },
96
+ { pattern: /(?:wallet\.dat|seed\.txt|mnemonic)/i, label: 'Crypto wallet', severity: 'critical' },
97
+ { pattern: /^~?\/?\.password-store\b/i, label: 'Password store', severity: 'critical' },
98
+ { pattern: /^~?\/?\.1password\b/i, label: '1Password data', severity: 'critical' },
99
+ { pattern: /(?:KeePass|\.kdbx)$/i, label: 'KeePass database', severity: 'critical' },
100
+ ];
101
+
102
+ // ─── Dangerous Commands (blocked in observer/worker, warned in standard) ─
103
+ const DANGEROUS_COMMANDS = [
104
+ // Destructive
105
+ { pattern: /\brm\s+.*-[a-zA-Z]*r[a-zA-Z]*f/i, label: 'Recursive force delete', severity: 'critical', block: ['observer', 'worker', 'standard'] },
106
+ { pattern: /\brm\s+-rf\s+[\/~]/i, label: 'Delete from root/home', severity: 'critical', block: ['observer', 'worker', 'standard'] },
107
+ { pattern: /\bmkfs\b/i, label: 'Format filesystem', severity: 'critical', block: ['observer', 'worker', 'standard'] },
108
+ { pattern: /\bdd\s+.*of=\/dev\//i, label: 'Raw disk write', severity: 'critical', block: ['observer', 'worker', 'standard'] },
109
+
110
+ // Privilege escalation
111
+ { pattern: /\bsudo\b/i, label: 'Sudo command', severity: 'high', block: ['observer', 'worker'] },
112
+ { pattern: /\bsu\s+-/i, label: 'Switch user', severity: 'high', block: ['observer', 'worker'] },
113
+ { pattern: /\bchmod\s+(?:\+s|4[0-7]{3})/i, label: 'SUID bit', severity: 'critical', block: ['observer', 'worker', 'standard'] },
114
+
115
+ // Network exposure
116
+ { pattern: /\bnc\s+.*-l/i, label: 'Network listener', severity: 'critical', block: ['observer', 'worker', 'standard'] },
117
+ { pattern: /\bssh\s+-R\b/i, label: 'Reverse SSH tunnel', severity: 'high', block: ['observer', 'worker'] },
118
+ { pattern: /\bngrok\b/i, label: 'Public tunnel', severity: 'high', block: ['observer', 'worker'] },
119
+ { pattern: /\bcurl\b.*\|\s*(?:bash|sh)\b/i, label: 'Pipe URL to shell', severity: 'critical', block: ['observer', 'worker', 'standard'] },
120
+
121
+ // Persistence
122
+ { pattern: /\bcrontab\b/i, label: 'Cron modification', severity: 'medium', block: ['observer', 'worker'] },
123
+ { pattern: /\bsystemctl\s+(?:enable|start)\b/i, label: 'Service management', severity: 'medium', block: ['observer', 'worker'] },
124
+ { pattern: /(?:\.bashrc|\.zshrc|\.profile|\.bash_profile)/i, label: 'Shell config modification', severity: 'high', block: ['observer', 'worker'] },
125
+
126
+ // Data exfiltration
127
+ { pattern: /\bcurl\s+.*(?:-d\s|--data|--upload-file|-F\s|-T\s)/i, label: 'Data upload via curl', severity: 'high', block: ['observer', 'worker'] },
128
+ { pattern: /\bscp\b/i, label: 'File transfer via SCP', severity: 'medium', block: ['observer'] },
129
+ { pattern: /\brsync\b.*(?:@|:)/i, label: 'Remote file sync', severity: 'medium', block: ['observer'] },
130
+ ];
131
+
132
+ // ─── Network Rules ──────────────────────────────────────────────────
133
+ const NETWORK_BLOCKLIST = [
134
+ /(?:pastebin|hastebin|0x0|transfer\.sh|file\.io|tmpfiles)/i,
135
+ /(?:ngrok|serveo|localtunnel|cloudflared)/i,
136
+ ];
137
+
138
+ // ─── Safe Commands (allowed even in worker mode) ────────────────────
139
+ const SAFE_COMMANDS = [
140
+ /^(?:ls|cat|head|tail|wc|grep|find|echo|date|pwd|whoami|id|uname|env|which|whereis|file|stat|du|df|free|uptime|hostname)\b/,
141
+ /^(?:git\s+(?:status|log|diff|branch|show|stash|remote|describe))\b/,
142
+ /^(?:node|npm\s+(?:list|ls|outdated|info|view|search|test|run))\b/,
143
+ /^(?:python3?\s+-c)\b/,
144
+ /^(?:jq|sed|awk|sort|uniq|cut|tr|tee|xargs|diff)\b/,
145
+ /^(?:curl|wget)\s+(?!.*(?:--data|-d\s|-F\s|--upload|--post|-T\s))/,
146
+ ];
147
+
148
+ /**
149
+ * @typedef {Object} GuardianVerdict
150
+ * @property {boolean} allowed - Whether the action is permitted
151
+ * @property {string} [reason] - Why it was blocked/warned
152
+ * @property {string} [zone] - 'workspace' | 'system' | 'forbidden' | 'unknown'
153
+ * @property {string} [severity] - 'low' | 'medium' | 'high' | 'critical'
154
+ * @property {string} decision - 'allow' | 'deny' | 'warn' | 'audit'
155
+ */
156
+
157
+ class HostGuardian {
158
+ /**
159
+ * @param {Object} opts
160
+ * @param {string} [opts.mode='standard'] - Permission tier: 'observer' | 'worker' | 'standard' | 'full'
161
+ * @param {string} [opts.workspace] - Workspace directory path
162
+ * @param {string[]} [opts.safeZones] - Additional allowed paths
163
+ * @param {string[]} [opts.forbiddenZones] - Additional forbidden path patterns
164
+ * @param {string} [opts.logFile] - Audit log file path
165
+ * @param {boolean} [opts.quiet] - Suppress console output
166
+ * @param {Function} [opts.onViolation] - Callback on policy violation
167
+ */
168
+ constructor(opts = {}) {
169
+ this.mode = opts.mode || 'standard';
170
+ this.tier = TIERS[this.mode] || TIERS.standard;
171
+ this.home = os.homedir();
172
+ this.workspace = opts.workspace ? this._resolve(opts.workspace) : path.join(this.home, '.openclaw', 'workspace');
173
+ this.safeZones = (opts.safeZones || []).map(z => this._resolve(z));
174
+ this.extraForbidden = (opts.forbiddenZones || []).map(p => ({
175
+ pattern: new RegExp(p.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'i'),
176
+ label: `Custom forbidden: ${p}`,
177
+ severity: 'high',
178
+ }));
179
+ this.onViolation = opts.onViolation || null;
180
+ this.logger = new SecurityLogger({
181
+ logFile: opts.logFile,
182
+ quiet: opts.quiet !== false,
183
+ });
184
+ this.auditTrail = [];
185
+ this.stats = { checked: 0, allowed: 0, denied: 0, warned: 0 };
186
+ }
187
+
188
+ /**
189
+ * Check if a tool call is allowed.
190
+ * @param {string} tool - Tool name (read, write, exec, browser, message, etc.)
191
+ * @param {Object} args - Tool arguments
192
+ * @returns {GuardianVerdict}
193
+ */
194
+ check(tool, args = {}) {
195
+ this.stats.checked++;
196
+ let verdict;
197
+
198
+ switch (tool) {
199
+ case 'read':
200
+ case 'Read':
201
+ verdict = this._checkFileRead(args);
202
+ break;
203
+ case 'write':
204
+ case 'Write':
205
+ case 'edit':
206
+ case 'Edit':
207
+ verdict = this._checkFileWrite(args);
208
+ break;
209
+ case 'exec':
210
+ verdict = this._checkExec(args);
211
+ break;
212
+ case 'browser':
213
+ verdict = this._checkBrowser(args);
214
+ break;
215
+ case 'message':
216
+ verdict = this._checkMessage(args);
217
+ break;
218
+ default:
219
+ verdict = { allowed: true, decision: 'allow' };
220
+ }
221
+
222
+ // Record audit trail
223
+ this.auditTrail.push({
224
+ timestamp: Date.now(),
225
+ tool,
226
+ args: this._sanitizeArgs(args),
227
+ verdict,
228
+ });
229
+
230
+ // Trim audit trail to last 10000 entries
231
+ if (this.auditTrail.length > 10000) {
232
+ this.auditTrail = this.auditTrail.slice(-5000);
233
+ }
234
+
235
+ if (verdict.allowed) {
236
+ this.stats.allowed++;
237
+ } else {
238
+ this.stats.denied++;
239
+ if (this.onViolation) this.onViolation(tool, args, verdict);
240
+ this.logger.log({
241
+ type: 'guardian_block',
242
+ severity: verdict.severity || 'high',
243
+ message: `[${this.mode}] ${tool}: ${verdict.reason}`,
244
+ details: { tool, verdict, args: this._sanitizeArgs(args) },
245
+ });
246
+ }
247
+
248
+ if (verdict.decision === 'warn') this.stats.warned++;
249
+
250
+ return verdict;
251
+ }
252
+
253
+ // ─── File Read Check ────────────────────────────────────────────
254
+ _checkFileRead(args) {
255
+ const filePath = args.path || args.file_path || '';
256
+ const resolved = this._resolve(filePath);
257
+
258
+ // Check forbidden zones
259
+ const forbidden = this._checkForbidden(filePath, resolved);
260
+ if (forbidden) return forbidden;
261
+
262
+ // Observer/worker: workspace only
263
+ if (this.tier.allowRead === 'workspace') {
264
+ if (!this._inWorkspace(resolved) && !this._inSafeZone(resolved)) {
265
+ return {
266
+ allowed: false,
267
+ decision: 'deny',
268
+ reason: `Read outside workspace not allowed in ${this.mode} mode`,
269
+ zone: this._classifyZone(resolved),
270
+ severity: 'medium',
271
+ };
272
+ }
273
+ }
274
+
275
+ return { allowed: true, decision: 'allow', zone: this._classifyZone(resolved) };
276
+ }
277
+
278
+ // ─── File Write Check ───────────────────────────────────────────
279
+ _checkFileWrite(args) {
280
+ const filePath = args.path || args.file_path || '';
281
+ const resolved = this._resolve(filePath);
282
+
283
+ // Check forbidden zones
284
+ const forbidden = this._checkForbidden(filePath, resolved);
285
+ if (forbidden) return forbidden;
286
+
287
+ // No writes in observer mode
288
+ if (this.tier.allowWrite === false) {
289
+ return {
290
+ allowed: false,
291
+ decision: 'deny',
292
+ reason: 'Writes not allowed in observer mode',
293
+ zone: this._classifyZone(resolved),
294
+ severity: 'medium',
295
+ };
296
+ }
297
+
298
+ // workspace-only writes
299
+ if (this.tier.allowWrite === 'workspace') {
300
+ if (!this._inWorkspace(resolved) && !this._inSafeZone(resolved)) {
301
+ return {
302
+ allowed: false,
303
+ decision: 'deny',
304
+ reason: `Write outside workspace not allowed in ${this.mode} mode`,
305
+ zone: this._classifyZone(resolved),
306
+ severity: 'high',
307
+ };
308
+ }
309
+ }
310
+
311
+ return { allowed: true, decision: 'allow', zone: this._classifyZone(resolved) };
312
+ }
313
+
314
+ // ─── Exec Check ────────────────────────────────────────────────
315
+ _checkExec(args) {
316
+ const command = args.command || '';
317
+
318
+ // No exec at all in observer mode
319
+ if (this.tier.allowExec === false) {
320
+ return {
321
+ allowed: false,
322
+ decision: 'deny',
323
+ reason: 'Command execution not allowed in observer mode',
324
+ severity: 'high',
325
+ };
326
+ }
327
+
328
+ // Check dangerous commands
329
+ for (const rule of DANGEROUS_COMMANDS) {
330
+ if (rule.pattern.test(command)) {
331
+ const blocked = rule.block.includes(this.mode);
332
+ if (blocked) {
333
+ return {
334
+ allowed: false,
335
+ decision: 'deny',
336
+ reason: `Dangerous command blocked: ${rule.label}`,
337
+ severity: rule.severity,
338
+ matched: command.substring(0, 200),
339
+ };
340
+ }
341
+ // In full mode, just warn
342
+ return {
343
+ allowed: true,
344
+ decision: 'warn',
345
+ reason: `Dangerous command (audit only): ${rule.label}`,
346
+ severity: rule.severity,
347
+ matched: command.substring(0, 200),
348
+ };
349
+ }
350
+ }
351
+
352
+ // Worker mode: only safe commands allowed
353
+ if (this.tier.allowExec === 'safe') {
354
+ const isSafe = SAFE_COMMANDS.some(p => p.test(command));
355
+ if (!isSafe) {
356
+ return {
357
+ allowed: false,
358
+ decision: 'deny',
359
+ reason: `Command not in safe list for worker mode`,
360
+ severity: 'medium',
361
+ matched: command.substring(0, 200),
362
+ };
363
+ }
364
+ }
365
+
366
+ return { allowed: true, decision: 'allow' };
367
+ }
368
+
369
+ // ─── Browser Check ─────────────────────────────────────────────
370
+ _checkBrowser(args) {
371
+ if (!this.tier.allowBrowser) {
372
+ return {
373
+ allowed: false,
374
+ decision: 'deny',
375
+ reason: 'Browser access not allowed in observer mode',
376
+ severity: 'medium',
377
+ };
378
+ }
379
+
380
+ const url = args.targetUrl || args.url || '';
381
+ for (const pattern of NETWORK_BLOCKLIST) {
382
+ if (pattern.test(url)) {
383
+ return {
384
+ allowed: false,
385
+ decision: 'deny',
386
+ reason: `Blocked URL: matches exfiltration service pattern`,
387
+ severity: 'high',
388
+ matched: url,
389
+ };
390
+ }
391
+ }
392
+
393
+ return { allowed: true, decision: 'allow' };
394
+ }
395
+
396
+ // ─── Message Check ─────────────────────────────────────────────
397
+ _checkMessage(args) {
398
+ // Messages always allowed, but log in audit trail
399
+ return { allowed: true, decision: 'allow' };
400
+ }
401
+
402
+ // ─── Forbidden Zone Check ──────────────────────────────────────
403
+ _checkForbidden(rawPath, resolvedPath) {
404
+ const allForbidden = [...FORBIDDEN_ZONES, ...this.extraForbidden];
405
+ const normalized = resolvedPath.replace(this.home, '~');
406
+
407
+ for (const zone of allForbidden) {
408
+ if (zone.pattern.test(rawPath) || zone.pattern.test(normalized) || zone.pattern.test(resolvedPath)) {
409
+ if (this.mode === 'full') {
410
+ // Full mode: log but allow
411
+ return {
412
+ allowed: true,
413
+ decision: 'warn',
414
+ reason: `Protected zone accessed (audit): ${zone.label}`,
415
+ zone: 'forbidden',
416
+ severity: zone.severity,
417
+ };
418
+ }
419
+ return {
420
+ allowed: false,
421
+ decision: 'deny',
422
+ reason: `Protected zone: ${zone.label}`,
423
+ zone: 'forbidden',
424
+ severity: zone.severity,
425
+ };
426
+ }
427
+ }
428
+ return null;
429
+ }
430
+
431
+ // ─── Zone Classification ───────────────────────────────────────
432
+ _classifyZone(resolvedPath) {
433
+ if (this._inWorkspace(resolvedPath)) return 'workspace';
434
+ if (this._inSafeZone(resolvedPath)) return 'safe';
435
+ if (resolvedPath.startsWith(this.home)) return 'home';
436
+ if (resolvedPath.startsWith('/etc') || resolvedPath.startsWith('/usr') || resolvedPath.startsWith('/var')) return 'system';
437
+ return 'unknown';
438
+ }
439
+
440
+ _inWorkspace(p) {
441
+ return p.startsWith(this.workspace + '/') || p === this.workspace;
442
+ }
443
+
444
+ _inSafeZone(p) {
445
+ return this.safeZones.some(z => p.startsWith(z + '/') || p === z);
446
+ }
447
+
448
+ _resolve(p) {
449
+ if (!p) return '';
450
+ const expanded = p.replace(/^~/, this.home);
451
+ return path.resolve(expanded);
452
+ }
453
+
454
+ _sanitizeArgs(args) {
455
+ // Don't log full file contents or long commands
456
+ const sanitized = { ...args };
457
+ if (sanitized.content && sanitized.content.length > 200) {
458
+ sanitized.content = sanitized.content.substring(0, 200) + '...[truncated]';
459
+ }
460
+ if (sanitized.command && sanitized.command.length > 500) {
461
+ sanitized.command = sanitized.command.substring(0, 500) + '...[truncated]';
462
+ }
463
+ return sanitized;
464
+ }
465
+
466
+ // ─── Audit & Stats ────────────────────────────────────────────
467
+
468
+ /**
469
+ * Get audit trail entries.
470
+ * @param {Object} [filter]
471
+ * @param {number} [filter.last] - Last N entries
472
+ * @param {string} [filter.tool] - Filter by tool name
473
+ * @param {boolean} [filter.deniedOnly] - Only show denied actions
474
+ * @returns {Array}
475
+ */
476
+ audit(filter = {}) {
477
+ let entries = this.auditTrail;
478
+ if (filter.tool) entries = entries.filter(e => e.tool === filter.tool);
479
+ if (filter.deniedOnly) entries = entries.filter(e => !e.verdict.allowed);
480
+ if (filter.last) entries = entries.slice(-filter.last);
481
+ return entries;
482
+ }
483
+
484
+ /**
485
+ * Get summary statistics.
486
+ * @returns {Object}
487
+ */
488
+ summary() {
489
+ return {
490
+ mode: this.mode,
491
+ tier: this.tier.label,
492
+ description: this.tier.description,
493
+ workspace: this.workspace,
494
+ ...this.stats,
495
+ forbiddenZones: FORBIDDEN_ZONES.length + this.extraForbidden.length,
496
+ dangerousCommandRules: DANGEROUS_COMMANDS.length,
497
+ };
498
+ }
499
+
500
+ /**
501
+ * Change permission tier at runtime.
502
+ * @param {string} mode - New tier name
503
+ */
504
+ setMode(mode) {
505
+ if (!TIERS[mode]) throw new Error(`Unknown mode: ${mode}. Valid: ${Object.keys(TIERS).join(', ')}`);
506
+ this.mode = mode;
507
+ this.tier = TIERS[mode];
508
+ this.logger.log({
509
+ type: 'guardian_mode_change',
510
+ severity: 'medium',
511
+ message: `Guardian mode changed to: ${mode}`,
512
+ });
513
+ }
514
+
515
+ /**
516
+ * Generate a human-readable security report.
517
+ * @returns {string}
518
+ */
519
+ report() {
520
+ const s = this.summary();
521
+ const denied = this.audit({ deniedOnly: true, last: 20 });
522
+ let report = `\n═══ ClawMoat Host Guardian Report ═══\n`;
523
+ report += `Mode: ${s.tier} (${s.mode})\n`;
524
+ report += `${s.description}\n\n`;
525
+ report += `Actions checked: ${s.checked}\n`;
526
+ report += ` Allowed: ${s.allowed}\n`;
527
+ report += ` Denied: ${s.denied}\n`;
528
+ report += ` Warned: ${s.warned}\n\n`;
529
+
530
+ if (denied.length > 0) {
531
+ report += `Recent blocked actions:\n`;
532
+ for (const entry of denied) {
533
+ const t = new Date(entry.timestamp).toISOString().substring(11, 19);
534
+ report += ` [${t}] ${entry.tool}: ${entry.verdict.reason}\n`;
535
+ }
536
+ } else {
537
+ report += `No blocked actions recorded.\n`;
538
+ }
539
+
540
+ return report;
541
+ }
542
+ }
543
+
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 };