clawarmor 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/audit.js ADDED
@@ -0,0 +1,257 @@
1
+ import { loadConfig } from './config.js';
2
+ import { paint, severityColor } from './output/colors.js';
3
+ import { progressBar, scoreColor, gradeColor, scoreToGrade } from './output/progress.js';
4
+ import { probeGatewayLive } from './probes/gateway-probe.js';
5
+ import { discoverRunningInstance } from './discovery.js';
6
+ import gatewayChecks from './checks/gateway.js';
7
+ import filesystemChecks from './checks/filesystem.js';
8
+ import channelChecks from './checks/channels.js';
9
+ import authChecks from './checks/auth.js';
10
+ import toolChecks from './checks/tools.js';
11
+ import versionChecks from './checks/version.js';
12
+ import hooksChecks from './checks/hooks.js';
13
+ import allowFromChecks from './checks/allowfrom.js';
14
+ import { writeFileSync, mkdirSync, existsSync, readFileSync, renameSync } from 'fs';
15
+ import { join } from 'path';
16
+ import { homedir } from 'os';
17
+
18
+ const W = { CRITICAL: 25, HIGH: 15, MEDIUM: 8, LOW: 3, INFO: 0 };
19
+ const SEP = paint.dim('─'.repeat(52));
20
+ const W52 = 52;
21
+ const VERSION = '1.1.0';
22
+
23
+ const HISTORY_DIR = join(homedir(), '.clawarmor');
24
+ const HISTORY_FILE = join(HISTORY_DIR, 'history.json');
25
+
26
+ function box(title) {
27
+ const pad = W52 - 2 - title.length;
28
+ const l = Math.floor(pad/2), r = pad - l;
29
+ return [
30
+ paint.dim('╔' + '═'.repeat(W52-2) + '╗'),
31
+ paint.dim('║') + ' '.repeat(l) + paint.bold(title) + ' '.repeat(r) + paint.dim('║'),
32
+ paint.dim('╚' + '═'.repeat(W52-2) + '╝'),
33
+ ].join('\n');
34
+ }
35
+
36
+ function printFinding(f) {
37
+ console.log('');
38
+ console.log(` ${paint.red('✗')} ${paint.bold(f.title)}`);
39
+ for (const line of (f.description||'').split('\n'))
40
+ console.log(` ${paint.dim(line)}`);
41
+ if (f.fix) {
42
+ console.log('');
43
+ const lines = f.fix.split('\n');
44
+ console.log(` ${paint.cyan('Fix:')} ${lines[0]}`);
45
+ for (let i=1;i<lines.length;i++) console.log(` ${lines[i]}`);
46
+ }
47
+ }
48
+
49
+ export function loadHistory() {
50
+ if (!existsSync(HISTORY_FILE)) return [];
51
+ try { return JSON.parse(readFileSync(HISTORY_FILE, 'utf8')); }
52
+ catch { return []; }
53
+ }
54
+
55
+ function appendHistory(entry) {
56
+ try {
57
+ mkdirSync(HISTORY_DIR, { recursive: true });
58
+ const existing = loadHistory();
59
+ existing.push(entry);
60
+ // Atomic write: temp file → rename
61
+ const tmp = HISTORY_FILE + '.tmp';
62
+ writeFileSync(tmp, JSON.stringify(existing, null, 2), 'utf8');
63
+ renameSync(tmp, HISTORY_FILE);
64
+ } catch { /* non-fatal */ }
65
+ }
66
+
67
+ export async function runAudit(flags = {}) {
68
+ const GATEWAY_PORT_DEFAULT = 18789;
69
+
70
+ // ── DISCOVERY: find what's actually running ──────────────────────────────
71
+ let discovery = null;
72
+ // Only auto-discover when no --url override was given
73
+ if (!flags.targetHost) {
74
+ try { discovery = await discoverRunningInstance(); }
75
+ catch { discovery = null; }
76
+ }
77
+
78
+ // Resolve target host/port
79
+ const targetHost = flags.targetHost || '127.0.0.1';
80
+ let targetPort = flags.targetPort || null;
81
+
82
+ // Load config — prefer CLI override, then discovered path, then default
83
+ const configOverridePath = flags.configPath || (discovery?.configPath !== undefined ? discovery.configPath : null);
84
+ const { config, configPath, error } = loadConfig(configOverridePath);
85
+
86
+ if (!targetPort) {
87
+ targetPort = config?.gateway?.port || GATEWAY_PORT_DEFAULT;
88
+ }
89
+
90
+ console.log(''); console.log(box('ClawArmor Audit v' + VERSION)); console.log('');
91
+
92
+ if (error) {
93
+ console.log(` ${paint.red('✗')} ${error}`); console.log(''); process.exit(2);
94
+ }
95
+
96
+ // Discovery warnings
97
+ if (discovery?.multiple) {
98
+ const chosen = targetPort;
99
+ console.log(` ${paint.yellow('!')} Found ${discovery.instances.length} running OpenClaw instances. Auditing the one on port ${chosen}. Use --url to specify a different one.`);
100
+ console.log('');
101
+ }
102
+
103
+ const isRemote = targetHost !== '127.0.0.1' && targetHost !== 'localhost' && targetHost !== '::1';
104
+ const probeTarget = `${targetHost}:${targetPort}`;
105
+
106
+ // Show config path info
107
+ if (isRemote || (discovery?.configPath && discovery.configPath !== configPath)) {
108
+ console.log(` ${paint.dim('Config:')} ${configPath} ${paint.dim('(local)')}`);
109
+ console.log(` ${paint.dim('Probes:')} ${probeTarget}`);
110
+ } else {
111
+ console.log(` ${paint.dim('Config:')} ${configPath}`);
112
+ }
113
+ console.log(` ${paint.dim('Scanned:')} ${new Date().toLocaleString('en-US',{dateStyle:'medium',timeStyle:'short'})}`);
114
+ console.log('');
115
+
116
+ // ── LIVE GATEWAY PROBES ─────────────────────────────────────────────────
117
+ console.log(SEP);
118
+ const probeLabel = isRemote
119
+ ? ` ${paint.cyan('LIVE GATEWAY PROBES')}${paint.dim(' (connecting to ' + probeTarget + ')')}`
120
+ : ` ${paint.cyan('LIVE GATEWAY PROBES')}${paint.dim(' (connecting to ' + probeTarget + ')')}`;
121
+ console.log(probeLabel);
122
+ console.log(SEP);
123
+
124
+ let liveResults = [];
125
+ try {
126
+ liveResults = await probeGatewayLive(config, { host: targetHost, port: targetPort });
127
+ } catch (e) {
128
+ liveResults = [];
129
+ }
130
+
131
+ const probeRunningResult = liveResults.find(r => r.id === 'probe.gateway_running');
132
+ const gatewayRunning = probeRunningResult?.gatewayRunning ?? false;
133
+
134
+ if (!gatewayRunning) {
135
+ if (isRemote) {
136
+ console.log(` ${paint.red('✗')} Gateway not reachable at ${probeTarget}`);
137
+ console.log(` ${paint.dim('Check that the host is reachable and the port is correct.')}`);
138
+ } else {
139
+ console.log(` ${paint.dim('ℹ')} ${paint.dim('Gateway not running — skipping live probes')}`);
140
+ }
141
+ } else {
142
+ for (const r of liveResults) {
143
+ if (r.passed) {
144
+ console.log(` ${paint.green('✓')} ${paint.dim(r.passedMsg || r.title)}`);
145
+ } else {
146
+ const sc = severityColor[r.severity] || paint.dim;
147
+ console.log(` ${paint.red('✗')} ${r.title} ${paint.dim('←')} ${sc(r.severity)}`);
148
+ }
149
+ }
150
+ }
151
+ console.log('');
152
+
153
+ // ── STATIC CONFIG CHECKS ────────────────────────────────────────────────
154
+ const allChecks = [
155
+ ...gatewayChecks, ...filesystemChecks, ...channelChecks,
156
+ ...authChecks, ...toolChecks, ...versionChecks, ...hooksChecks,
157
+ ...allowFromChecks,
158
+ ];
159
+ const staticResults = [];
160
+ for (const check of allChecks) {
161
+ try { staticResults.push(await check(config)); }
162
+ catch (e) { staticResults.push({ id:'err', severity:'LOW', passed:true, passedMsg:`Check error: ${e.message}` }); }
163
+ }
164
+
165
+ // Merge: live (non-probe.gateway_running) + static
166
+ const liveFindingResults = gatewayRunning
167
+ ? liveResults.filter(r => r.id !== 'probe.gateway_running')
168
+ : [];
169
+
170
+ const results = [...liveFindingResults, ...staticResults];
171
+ const failed = results.filter(r => !r.passed);
172
+ const passed = results.filter(r => r.passed);
173
+ const criticals = failed.filter(r => r.severity === 'CRITICAL').length;
174
+
175
+ // Score with floor rules
176
+ let score = 100;
177
+ for (const f of failed) score -= (W[f.severity] || 0);
178
+ score = Math.max(0, score);
179
+ if (criticals >= 2) score = Math.min(score, 25);
180
+ else if (criticals >= 1) score = Math.min(score, 50);
181
+
182
+ const grade = scoreToGrade(score);
183
+ const colorFn = scoreColor(score);
184
+
185
+ console.log(SEP);
186
+ console.log(` ${paint.bold('Security Score:')} ${colorFn(score+'/100')} ${paint.dim('┃')} Grade: ${gradeColor(grade)}`);
187
+ console.log(` ${colorFn(progressBar(score,20))} ${paint.dim(score+'%')}`);
188
+
189
+ // Human verdict
190
+ {
191
+ const openCriticals = failed.filter(f => f.severity === 'CRITICAL').length;
192
+ const openHighs = failed.filter(f => f.severity === 'HIGH').length;
193
+ let verdict;
194
+ if (!failed.length) {
195
+ verdict = paint.green('Your instance is secure. No issues found.');
196
+ } else if (openCriticals >= 1) {
197
+ verdict = paint.red('Your instance has CRITICAL exposure. Fix immediately before using.');
198
+ } else if (openHighs >= 1) {
199
+ verdict = paint.yellow('Your instance has HIGH-risk issues. Fix before going to production.');
200
+ } else {
201
+ verdict = paint.dim('Your instance is well-configured. Open items are low-risk hardening.');
202
+ }
203
+ console.log('');
204
+ console.log(` ${paint.dim('Verdict:')} ${verdict}`);
205
+ }
206
+
207
+ if (flags.json) {
208
+ console.log(JSON.stringify({score,grade,failed,passed},null,2));
209
+ appendHistory({ timestamp: new Date().toISOString(), score, grade,
210
+ findings: failed.length, criticals, version: VERSION,
211
+ failedIds: failed.map(f => f.id) });
212
+ return 0;
213
+ }
214
+
215
+ for (const sev of ['CRITICAL','HIGH','MEDIUM','LOW']) {
216
+ const group = failed.filter(f => f.severity === sev);
217
+ if (!group.length) continue;
218
+ console.log(''); console.log(SEP);
219
+ console.log(` ${severityColor[sev](sev)}${paint.dim(' ('+group.length+' finding'+(group.length>1?'s':'')+')')}`);
220
+ console.log(SEP);
221
+ for (const f of group) printFinding(f);
222
+ }
223
+
224
+ if (passed.length) {
225
+ console.log(''); console.log(SEP);
226
+ console.log(` ${paint.green('PASSED')}${paint.dim(' ('+passed.filter(p=>!(p.id||'').startsWith('probe.')).length+' checks)')}`);
227
+ console.log(SEP);
228
+ for (const p of passed) {
229
+ if ((p.id||'').startsWith('probe.')) continue;
230
+ console.log(` ${paint.green('✓')} ${paint.dim(p.passedMsg||p.title||p.id)}`);
231
+ }
232
+ }
233
+
234
+ console.log(''); console.log(SEP);
235
+ if (!failed.length) {
236
+ console.log(` ${paint.green('✓')} ${paint.bold('All checks passed.')}`);
237
+ } else {
238
+ console.log(` ${failed.length} issue${failed.length>1?'s':''} found. Fix above to improve score.`);
239
+ }
240
+ console.log(` ${paint.dim('Run')} ${paint.cyan('clawarmor scan')} ${paint.dim('to check installed skills.')}`);
241
+ console.log(` ${paint.dim('Run')} ${paint.cyan('clawarmor trend')} ${paint.dim('to see score history.')}`);
242
+ console.log(` ${paint.dim('Continuous monitoring:')} ${paint.cyan('github.com/pinzasai/clawarmor')}`);
243
+ console.log('');
244
+
245
+ // Persist history (atomic)
246
+ appendHistory({
247
+ timestamp: new Date().toISOString(),
248
+ score,
249
+ grade,
250
+ findings: failed.length,
251
+ criticals,
252
+ version: VERSION,
253
+ failedIds: failed.map(f => f.id),
254
+ });
255
+
256
+ return failed.length > 0 ? 1 : 0;
257
+ }
@@ -0,0 +1,104 @@
1
+ // ClawArmor v0.6 — allowFrom permissiveness check
2
+ // Scans all channel configs for dangerously open allowFrom settings.
3
+
4
+ import { get } from '../config.js';
5
+
6
+ const CHANNEL_KEYS = ['telegram', 'discord', 'whatsapp', 'signal', 'slack', 'imessage', 'matrix', 'email'];
7
+
8
+ function isWildcard(arr) {
9
+ if (!Array.isArray(arr)) return false;
10
+ return arr.some(v => v === '*' || (typeof v === 'string' && v.includes('*')));
11
+ }
12
+
13
+ function isEmptyArray(arr) {
14
+ return Array.isArray(arr) && arr.length === 0;
15
+ }
16
+
17
+ export function checkAllowFrom(config) {
18
+ const channels = get(config, 'channels', {});
19
+ const issues = [];
20
+
21
+ for (const chanKey of CHANNEL_KEYS) {
22
+ const cfg = channels[chanKey];
23
+ if (!cfg?.enabled) continue;
24
+
25
+ const allowFrom = cfg.allowFrom ?? cfg.dmAllowFrom ?? null;
26
+ const groupAllowFrom = cfg.groupAllowFrom ?? null;
27
+ const dmPolicy = cfg.dmPolicy || '';
28
+ const groupPolicy = cfg.groupPolicy || '';
29
+
30
+ // Explicit wildcard
31
+ if (isWildcard(allowFrom)) {
32
+ issues.push({
33
+ path: `channels.${chanKey}.allowFrom`,
34
+ reason: `allowFrom: ["*"] — explicitly open to anyone`,
35
+ severity: 'CRITICAL',
36
+ });
37
+ }
38
+
39
+ // Empty array with non-restrictive dmPolicy
40
+ if (isEmptyArray(allowFrom) && dmPolicy !== 'pairing' && dmPolicy !== 'disabled') {
41
+ issues.push({
42
+ path: `channels.${chanKey}.allowFrom`,
43
+ reason: `allowFrom: [] (empty) with dmPolicy="${dmPolicy||'unset'}" — may default to open`,
44
+ severity: 'HIGH',
45
+ });
46
+ }
47
+
48
+ // groupAllowFrom empty + groupPolicy not disabled
49
+ if (isEmptyArray(groupAllowFrom) && groupPolicy !== 'disabled') {
50
+ issues.push({
51
+ path: `channels.${chanKey}.groupAllowFrom`,
52
+ reason: `groupAllowFrom: [] (empty) with groupPolicy="${groupPolicy||'unset'}" — group access unscoped`,
53
+ severity: 'HIGH',
54
+ });
55
+ }
56
+
57
+ // Check nested group entries
58
+ const groups = cfg.groups || {};
59
+ for (const [gid, gcfg] of Object.entries(groups)) {
60
+ if (!gcfg || typeof gcfg !== 'object') continue;
61
+ const gAllowFrom = gcfg.allowFrom ?? null;
62
+ const gPolicy = gcfg.groupPolicy || '';
63
+
64
+ if (isWildcard(gAllowFrom)) {
65
+ issues.push({
66
+ path: `channels.${chanKey}.groups.${gid}.allowFrom`,
67
+ reason: `allowFrom: ["*"] — wildcard in group config`,
68
+ severity: 'CRITICAL',
69
+ });
70
+ }
71
+ if (isEmptyArray(gAllowFrom) && gPolicy !== 'disabled') {
72
+ issues.push({
73
+ path: `channels.${chanKey}.groups.${gid}.allowFrom`,
74
+ reason: `allowFrom: [] (empty) with groupPolicy="${gPolicy||'unset'}"`,
75
+ severity: 'MEDIUM',
76
+ });
77
+ }
78
+ }
79
+ }
80
+
81
+ if (!issues.length) {
82
+ return {
83
+ id: 'channels.allowfrom',
84
+ severity: 'HIGH',
85
+ passed: true,
86
+ passedMsg: 'All channel allowFrom settings are restricted',
87
+ };
88
+ }
89
+
90
+ const criticals = issues.filter(i => i.severity === 'CRITICAL');
91
+ const topSeverity = criticals.length ? 'CRITICAL' : 'HIGH';
92
+
93
+ return {
94
+ id: 'channels.allowfrom',
95
+ severity: topSeverity,
96
+ passed: false,
97
+ title: `Dangerously permissive allowFrom settings (${issues.length} issue${issues.length > 1 ? 's' : ''})`,
98
+ description: issues.map(i => `• ${i.path}: ${i.reason}`).join('\n') +
99
+ `\nAny user who discovers your bot/channel can send commands to your agent.`,
100
+ fix: issues.map(i => `openclaw config set ${i.path} '["your-user-id"]'`).join('\n'),
101
+ };
102
+ }
103
+
104
+ export default [checkAllowFrom];
@@ -0,0 +1,63 @@
1
+ import { get } from '../config.js';
2
+ import { execSync } from 'child_process';
3
+
4
+ function dockerAvailable() {
5
+ try { execSync('docker --version', { stdio: 'ignore', timeout: 2000 }); return true; }
6
+ catch { return false; }
7
+ }
8
+
9
+ export function checkAgentSandbox(config) {
10
+ const mode = get(config, 'agents.defaults.sandbox.mode', null);
11
+ const mainKey = get(config, 'agents.mainKey', 'main');
12
+ const hasCustomMainKey = mainKey !== 'main';
13
+ const secureModes = ['non-main', 'all', 'strict'];
14
+
15
+ if (secureModes.includes(mode)) {
16
+ const mainKeyNote = hasCustomMainKey
17
+ ? ` (mainKey="${mainKey}" — verify non-main scope is correct)`
18
+ : '';
19
+ return { id: 'agents.sandbox', severity: 'HIGH', passed: true,
20
+ passedMsg: `Agent sandbox mode: "${mode}" (sessions isolated)${mainKeyNote}` };
21
+ }
22
+ if (!dockerAvailable()) {
23
+ return { id: 'agents.sandbox', severity: 'LOW', passed: false,
24
+ title: 'Sandbox isolation not configured (Docker not installed)',
25
+ description: `Sandbox isolation is not enabled. OpenClaw sandboxes require Docker.\nDocker is not installed on this machine — sandbox cannot be enabled yet.\nInstall Docker first, then enable sandbox isolation.`,
26
+ fix: `1. Install Docker: brew install --cask docker\n2. Open Docker.app to complete setup\n3. openclaw config set agents.defaults.sandbox.mode non-main\n4. openclaw gateway restart` };
27
+ }
28
+ const mainKeyNote = hasCustomMainKey
29
+ ? `\nNote: agents.mainKey="${mainKey}" — "non-main" sandbox mode will isolate sessions that are not "${mainKey}".`
30
+ : '';
31
+ return { id: 'agents.sandbox', severity: 'HIGH', passed: false,
32
+ title: 'Agent sessions have no sandbox isolation',
33
+ description: `agents.defaults.sandbox.mode not set — tool calls from Telegram/Discord\nrun directly on your host with no container boundary.\nAttack: prompt injection via fetched webpage causes exec with full host access.${mainKeyNote}`,
34
+ fix: `openclaw config set agents.defaults.sandbox.mode non-main\nopenctl gateway restart` };
35
+ }
36
+
37
+ export function checkSandboxExecFootgun(config) {
38
+ const sandboxMode = get(config, 'agents.defaults.sandbox.mode', null);
39
+ const execHost = get(config, 'tools.exec.host', null);
40
+ if (execHost === 'sandbox' && (!sandboxMode || sandboxMode === 'off')) {
41
+ return { id: 'tools.exec.sandbox_off', severity: 'HIGH', passed: false,
42
+ title: 'exec host=sandbox but sandbox mode is off (silent footgun)',
43
+ description: `tools.exec.host="sandbox" suggests exec should run in a container.\nBut agents.defaults.sandbox.mode is "${sandboxMode||'off'}" — exec runs on your HOST.\nYou think you're sandboxed. You're not.\nAttack: any exec call bypasses the sandbox you expected.`,
44
+ fix: `openclaw config set agents.defaults.sandbox.mode non-main\nOR: openclaw config set tools.exec.host gateway` };
45
+ }
46
+ return { id: 'tools.exec.sandbox_off', severity: 'HIGH', passed: true,
47
+ passedMsg: 'exec sandbox configuration is consistent' };
48
+ }
49
+
50
+ export function checkThinkingStream(config) {
51
+ const thinking = get(config, 'agents.defaults.thinkingDefault', 'off');
52
+ const stream = get(config, 'agents.defaults.stream', null);
53
+ if (thinking === 'on' && stream === 'stream') {
54
+ return { id: 'agents.thinking.stream', severity: 'LOW', passed: false,
55
+ title: 'Thinking mode streaming leaks reasoning to channel observers',
56
+ description: `thinkingDefault="on" with stream mode — partial chain-of-thought\nvisible to channel participants before agent finishes reasoning.`,
57
+ fix: `openclaw config set agents.defaults.thinkingDefault off` };
58
+ }
59
+ return { id: 'agents.thinking.stream', severity: 'LOW', passed: true,
60
+ passedMsg: 'Thinking stream not leaking reasoning' };
61
+ }
62
+
63
+ export default [checkAgentSandbox, checkSandboxExecFootgun, checkThinkingStream];
@@ -0,0 +1,69 @@
1
+ import { get } from '../config.js';
2
+
3
+ export function checkTelegramDmPolicy(config) {
4
+ const tg = get(config, 'channels.telegram', null);
5
+ if (!tg?.enabled) return { id: 'telegram.dmPolicy', severity: 'HIGH', passed: true, passedMsg: 'Telegram not enabled' };
6
+ const dmPolicy = tg.dmPolicy || '';
7
+ const allowFrom = tg.allowFrom || tg.dmAllowFrom || null;
8
+ if (dmPolicy === 'open' && !allowFrom) {
9
+ return { id: 'telegram.dmPolicy', severity: 'HIGH', passed: false,
10
+ title: 'Telegram DMs open to anyone',
11
+ description: `dmPolicy="open" with no allowFrom — anyone who finds your bot can\nsend commands. Attack: attacker DMs bot "exec cat ~/.openclaw/openclaw.json".`,
12
+ fix: `openclaw config set channels.telegram.dmPolicy pairing` };
13
+ }
14
+ return { id: 'telegram.dmPolicy', severity: 'HIGH', passed: true,
15
+ passedMsg: `Telegram DM policy: "${dmPolicy}" (restricted)` };
16
+ }
17
+
18
+ export function checkGroupPolicies(config) {
19
+ const openGroups = [];
20
+ for (const [chan, cfg] of Object.entries(get(config, 'channels', {}))) {
21
+ if (!cfg?.enabled) continue;
22
+ if (cfg.groupPolicy === 'open') openGroups.push(`channels.${chan}.groupPolicy`);
23
+ const groups = cfg.groups || {};
24
+ for (const [gid, gcfg] of Object.entries(groups)) {
25
+ if (gcfg?.groupPolicy === 'open') openGroups.push(`channels.${chan}.groups.${gid}.groupPolicy`);
26
+ }
27
+ }
28
+ if (openGroups.length) {
29
+ return { id: 'channel.groupPolicy', severity: 'MEDIUM', passed: false,
30
+ title: 'Group policy allows anyone to trigger agent',
31
+ description: `Open group policies found:\n${openGroups.map(p=>` • ${p}`).join('\n')}\nAnyone in those groups can send commands to your agent.\nAttack: attacker joins group, sends injected content, triggers tool calls.`,
32
+ fix: `openclaw config set channels.telegram.groupPolicy allowlist` };
33
+ }
34
+ return { id: 'channel.groupPolicy', severity: 'MEDIUM', passed: true, passedMsg: 'All group policies use allowlist' };
35
+ }
36
+
37
+ export function checkOpenGroupsWithElevated(config) {
38
+ // CRITICAL combo: open groups + elevated tools = attacker can run elevated exec
39
+ const elevatedEnabled = get(config, 'tools.elevated.enabled', false) ||
40
+ get(config, 'tools.elevated', null) !== null;
41
+ const hasOpenGroup = Object.values(get(config, 'channels', {})).some(cfg =>
42
+ cfg?.enabled && cfg?.groupPolicy === 'open'
43
+ );
44
+ if (hasOpenGroup && elevatedEnabled) {
45
+ return { id: 'security.open_groups_elevated', severity: 'CRITICAL', passed: false,
46
+ title: 'CRITICAL: Open groups + elevated tools = remote code execution',
47
+ description: `You have group channels with groupPolicy="open" AND elevated tools enabled.\nAnyone in those groups can send a message that causes elevated exec on your host.\nAttack: attacker joins group, sends "run rm -rf /" — agent executes it with elevated perms.\nThis is the highest-risk configuration possible.`,
48
+ fix: `openclaw config set channels.telegram.groupPolicy allowlist\nOR: openclaw config set tools.elevated.enabled false` };
49
+ }
50
+ return { id: 'security.open_groups_elevated', severity: 'CRITICAL', passed: true,
51
+ passedMsg: 'No open groups with elevated tools (safe)' };
52
+ }
53
+
54
+ export function checkDmSessionScope(config) {
55
+ const dmScope = get(config, 'session.dmScope', 'main');
56
+ // Only flag if multiple users could DM (open DM policy or large allowFrom)
57
+ const tgAllowFrom = get(config, 'channels.telegram.allowFrom', []);
58
+ const multiUser = Array.isArray(tgAllowFrom) && tgAllowFrom.length > 1;
59
+ if (multiUser && dmScope === 'main') {
60
+ return { id: 'session.dmScope', severity: 'MEDIUM', passed: false,
61
+ title: 'Multiple DM users share the same session context',
62
+ description: `session.dmScope="main" with ${tgAllowFrom.length} allowed DM users — all share\none conversation context. User A can ask agent to recall what User B said.\nAttack: authorized user extracts another authorized user's conversation history.`,
63
+ fix: `openclaw config set session.dmScope per-channel-peer` };
64
+ }
65
+ return { id: 'session.dmScope', severity: 'MEDIUM', passed: true,
66
+ passedMsg: dmScope === 'per-channel-peer' ? 'DM sessions are isolated per user' : 'Single-user DM (session isolation not needed)' };
67
+ }
68
+
69
+ export default [checkTelegramDmPolicy, checkGroupPolicies, checkOpenGroupsWithElevated, checkDmSessionScope];
@@ -0,0 +1,87 @@
1
+ import { existsSync, statSync, readdirSync } from 'fs';
2
+ import { join } from 'path';
3
+ import { homedir } from 'os';
4
+ import { getOctalPermissions, getConfigPath, getAgentAccountsPath } from '../config.js';
5
+
6
+ const HOME = homedir();
7
+ const OC_DIR = join(HOME, '.openclaw');
8
+
9
+ function checkPerms(filePath, expected, label) {
10
+ if (!existsSync(filePath)) return null;
11
+ const p = getOctalPermissions(filePath);
12
+ if (p !== expected) return { perms: p, path: filePath, label };
13
+ return null;
14
+ }
15
+
16
+ export function checkOpenclawDirPerms() {
17
+ if (!existsSync(OC_DIR)) return { id: 'fs.dir.perms', severity: 'HIGH', passed: true, passedMsg: '~/.openclaw/ not found' };
18
+ const p = getOctalPermissions(OC_DIR);
19
+ if (p !== '700') {
20
+ return { id: 'fs.dir.perms', severity: 'HIGH', passed: false,
21
+ title: '~/.openclaw/ directory is group/world readable',
22
+ description: `~/.openclaw/ has permissions ${p}. Other users on this system can list\nyour config files, session transcripts, credentials, and installed skills.\nAttack: local user runs ls ~/.openclaw/credentials/ and reads your bot token.`,
23
+ fix: `chmod 700 ~/.openclaw` };
24
+ }
25
+ return { id: 'fs.dir.perms', severity: 'HIGH', passed: true, passedMsg: '~/.openclaw/ is owner-only (700)' };
26
+ }
27
+
28
+ export function checkConfigFilePermissions() {
29
+ const p = getOctalPermissions(getConfigPath());
30
+ if (!p) return { id: 'fs.config.perms', severity: 'HIGH', passed: true, passedMsg: 'Config file not found' };
31
+ if (p !== '600') {
32
+ return { id: 'fs.config.perms', severity: 'HIGH', passed: false,
33
+ title: 'openclaw.json is readable by other users',
34
+ description: `~/.openclaw/openclaw.json has permissions ${p}. Contains bot tokens,\nAPI keys, and channel credentials readable by any local user.\nAttack: local user cat ~/.openclaw/openclaw.json → gets your Telegram bot token.`,
35
+ fix: `chmod 600 ~/.openclaw/openclaw.json` };
36
+ }
37
+ return { id: 'fs.config.perms', severity: 'HIGH', passed: true, passedMsg: 'openclaw.json is owner-only (600)' };
38
+ }
39
+
40
+ export function checkAgentAccountsPermissions() {
41
+ const fp = getAgentAccountsPath();
42
+ if (!existsSync(fp)) return { id: 'fs.accounts.perms', severity: 'HIGH', passed: true, passedMsg: 'agent-accounts.json not found' };
43
+ const p = getOctalPermissions(fp);
44
+ if (p !== '600') {
45
+ return { id: 'fs.accounts.perms', severity: 'HIGH', passed: false,
46
+ title: 'agent-accounts.json is readable by other users',
47
+ description: `Permissions ${p}. This file contains all API keys and passwords.\nAttack: one command gets every credential your agent holds.`,
48
+ fix: `chmod 600 ~/.openclaw/agent-accounts.json` };
49
+ }
50
+ return { id: 'fs.accounts.perms', severity: 'HIGH', passed: true, passedMsg: 'agent-accounts.json is owner-only (600)' };
51
+ }
52
+
53
+ export function checkCredentialsDirPermissions() {
54
+ const credDir = join(OC_DIR, 'credentials');
55
+ if (!existsSync(credDir)) return { id: 'fs.creds.perms', severity: 'MEDIUM', passed: true, passedMsg: 'credentials/ not found' };
56
+ const p = getOctalPermissions(credDir);
57
+ if (p !== '700' && p !== '600') {
58
+ return { id: 'fs.creds.perms', severity: 'MEDIUM', passed: false,
59
+ title: 'credentials/ directory is not locked down',
60
+ description: `~/.openclaw/credentials/ has permissions ${p}.\nContains channel auth tokens and pairing allowlists.`,
61
+ fix: `chmod 700 ~/.openclaw/credentials\nchmod 600 ~/.openclaw/credentials/*.json 2>/dev/null || true` };
62
+ }
63
+ return { id: 'fs.creds.perms', severity: 'MEDIUM', passed: true, passedMsg: 'credentials/ directory is locked down' };
64
+ }
65
+
66
+ export function checkSessionTranscriptPermissions() {
67
+ const agentsDir = join(OC_DIR, 'agents');
68
+ if (!existsSync(agentsDir)) return { id: 'fs.sessions.perms', severity: 'LOW', passed: true, passedMsg: 'No agent sessions found' };
69
+ // Check first session dir found
70
+ try {
71
+ const agents = readdirSync(agentsDir);
72
+ for (const agent of agents) {
73
+ const sessDir = join(agentsDir, agent, 'sessions');
74
+ if (!existsSync(sessDir)) continue;
75
+ const p = getOctalPermissions(sessDir);
76
+ if (p && p !== '700' && p !== '600') {
77
+ return { id: 'fs.sessions.perms', severity: 'LOW', passed: false,
78
+ title: 'Session transcripts readable by other users',
79
+ description: `~/.openclaw/agents/${agent}/sessions/ has permissions ${p}.\nTranscripts contain your full conversation history, tool outputs, and potentially secrets.`,
80
+ fix: `chmod -R 700 ~/.openclaw/agents/` };
81
+ }
82
+ }
83
+ } catch { /* skip */ }
84
+ return { id: 'fs.sessions.perms', severity: 'LOW', passed: true, passedMsg: 'Session transcripts are private' };
85
+ }
86
+
87
+ export default [checkOpenclawDirPerms, checkConfigFilePermissions, checkAgentAccountsPermissions, checkCredentialsDirPermissions, checkSessionTranscriptPermissions];