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.
- package/CHANGELOG.md +32 -0
- package/Dockerfile +22 -0
- package/README.md +144 -5
- package/SECURITY.md +63 -0
- package/bin/clawmoat.js +186 -1
- package/docs/ai-agent-security-scanner.html +691 -0
- package/docs/apple-touch-icon.png +0 -0
- package/docs/blog/host-guardian-launch.html +345 -0
- package/docs/blog/host-guardian-launch.md +249 -0
- package/docs/blog/index.html +2 -0
- package/docs/blog/langchain-security-tutorial.html +319 -0
- package/docs/blog/owasp-agentic-ai-top10.html +2 -0
- package/docs/blog/securing-ai-agents.html +2 -0
- package/docs/compare.html +2 -0
- package/docs/favicon.png +0 -0
- package/docs/icon-192.png +0 -0
- package/docs/index.html +258 -65
- package/docs/integrations/langchain.html +2 -0
- package/docs/integrations/openai.html +2 -0
- package/docs/integrations/openclaw.html +2 -0
- package/docs/logo.png +0 -0
- package/docs/logo.svg +60 -0
- package/docs/mark-with-moat.svg +33 -0
- package/docs/mark.png +0 -0
- package/docs/mark.svg +30 -0
- package/docs/og-image.png +0 -0
- package/docs/playground.html +440 -0
- package/docs/positioning-v2.md +155 -0
- package/docs/report-demo.html +399 -0
- package/docs/thanks.html +2 -0
- package/examples/github-action-workflow.yml +94 -0
- package/logo.png +0 -0
- package/logo.svg +60 -0
- package/mark-with-moat.svg +33 -0
- package/mark.png +0 -0
- package/mark.svg +30 -0
- package/package.json +1 -1
- package/server/index.js +9 -5
- package/skill/README.md +57 -0
- package/skill/SKILL.md +49 -30
- package/skill/scripts/audit.sh +28 -0
- package/skill/scripts/scan.sh +32 -0
- package/skill/scripts/test.sh +13 -0
- package/src/guardian/alerts.js +138 -0
- package/src/guardian/index.js +686 -0
- package/src/guardian/network-log.js +281 -0
- package/src/guardian/skill-integrity.js +290 -0
- package/src/index.js +37 -0
- package/src/middleware/openclaw.js +76 -1
- package/src/scanners/excessive-agency.js +88 -0
- package/wiki/Architecture.md +103 -0
- package/wiki/CLI-Reference.md +167 -0
- package/wiki/FAQ.md +135 -0
- package/wiki/Home.md +70 -0
- package/wiki/Policy-Engine.md +229 -0
- 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 };
|