@theaux/clawdbot 2026.1.14 → 2026.1.15

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.
@@ -0,0 +1,525 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ import JSON5 from "json5";
4
+ import { createConfigIO } from "../config/config.js";
5
+ import { resolveOAuthDir } from "../config/paths.js";
6
+ import { resolveDefaultAgentId } from "../agents/agent-scope.js";
7
+ import { INCLUDE_KEY, MAX_INCLUDE_DEPTH } from "../config/includes.js";
8
+ import { normalizeAgentId } from "../routing/session-key.js";
9
+ import { formatOctal, isGroupReadable, isGroupWritable, isWorldReadable, isWorldWritable, modeBits, safeStat, } from "./audit-fs.js";
10
+ function expandTilde(p, env) {
11
+ if (!p.startsWith("~"))
12
+ return p;
13
+ const home = typeof env.HOME === "string" && env.HOME.trim() ? env.HOME.trim() : null;
14
+ if (!home)
15
+ return null;
16
+ if (p === "~")
17
+ return home;
18
+ if (p.startsWith("~/") || p.startsWith("~\\"))
19
+ return path.join(home, p.slice(2));
20
+ return null;
21
+ }
22
+ function summarizeGroupPolicy(cfg) {
23
+ const channels = cfg.channels;
24
+ if (!channels || typeof channels !== "object")
25
+ return { open: 0, allowlist: 0, other: 0 };
26
+ let open = 0;
27
+ let allowlist = 0;
28
+ let other = 0;
29
+ for (const value of Object.values(channels)) {
30
+ if (!value || typeof value !== "object")
31
+ continue;
32
+ const section = value;
33
+ const policy = section.groupPolicy;
34
+ if (policy === "open")
35
+ open += 1;
36
+ else if (policy === "allowlist")
37
+ allowlist += 1;
38
+ else
39
+ other += 1;
40
+ }
41
+ return { open, allowlist, other };
42
+ }
43
+ export function collectAttackSurfaceSummaryFindings(cfg) {
44
+ const group = summarizeGroupPolicy(cfg);
45
+ const elevated = cfg.tools?.elevated?.enabled !== false;
46
+ const hooksEnabled = cfg.hooks?.enabled === true;
47
+ const browserEnabled = Boolean(cfg.browser?.enabled ?? cfg.browser?.controlUrl);
48
+ const detail = `groups: open=${group.open}, allowlist=${group.allowlist}` +
49
+ `\n` +
50
+ `tools.elevated: ${elevated ? "enabled" : "disabled"}` +
51
+ `\n` +
52
+ `hooks: ${hooksEnabled ? "enabled" : "disabled"}` +
53
+ `\n` +
54
+ `browser control: ${browserEnabled ? "enabled" : "disabled"}`;
55
+ return [
56
+ {
57
+ checkId: "summary.attack_surface",
58
+ severity: "info",
59
+ title: "Attack surface summary",
60
+ detail,
61
+ },
62
+ ];
63
+ }
64
+ function isProbablySyncedPath(p) {
65
+ const s = p.toLowerCase();
66
+ return (s.includes("icloud") ||
67
+ s.includes("dropbox") ||
68
+ s.includes("google drive") ||
69
+ s.includes("googledrive") ||
70
+ s.includes("onedrive"));
71
+ }
72
+ export function collectSyncedFolderFindings(params) {
73
+ const findings = [];
74
+ if (isProbablySyncedPath(params.stateDir) || isProbablySyncedPath(params.configPath)) {
75
+ findings.push({
76
+ checkId: "fs.synced_dir",
77
+ severity: "warn",
78
+ title: "State/config path looks like a synced folder",
79
+ detail: `stateDir=${params.stateDir}, configPath=${params.configPath}. Synced folders (iCloud/Dropbox/OneDrive/Google Drive) can leak tokens and transcripts onto other devices.`,
80
+ remediation: `Keep CLAWDBOT_STATE_DIR on a local-only volume and re-run "clawdbot security audit --fix".`,
81
+ });
82
+ }
83
+ return findings;
84
+ }
85
+ function looksLikeEnvRef(value) {
86
+ const v = value.trim();
87
+ return v.startsWith("${") && v.endsWith("}");
88
+ }
89
+ export function collectSecretsInConfigFindings(cfg) {
90
+ const findings = [];
91
+ const password = typeof cfg.gateway?.auth?.password === "string" ? cfg.gateway.auth.password.trim() : "";
92
+ if (password && !looksLikeEnvRef(password)) {
93
+ findings.push({
94
+ checkId: "config.secrets.gateway_password_in_config",
95
+ severity: "warn",
96
+ title: "Gateway password is stored in config",
97
+ detail: "gateway.auth.password is set in the config file; prefer environment variables for secrets when possible.",
98
+ remediation: "Prefer CLAWDBOT_GATEWAY_PASSWORD (env) and remove gateway.auth.password from disk.",
99
+ });
100
+ }
101
+ const browserToken = typeof cfg.browser?.controlToken === "string" ? cfg.browser.controlToken.trim() : "";
102
+ if (browserToken && !looksLikeEnvRef(browserToken)) {
103
+ findings.push({
104
+ checkId: "config.secrets.browser_control_token_in_config",
105
+ severity: "warn",
106
+ title: "Browser control token is stored in config",
107
+ detail: "browser.controlToken is set in the config file; prefer environment variables for secrets when possible.",
108
+ remediation: "Prefer CLAWDBOT_BROWSER_CONTROL_TOKEN (env) and remove browser.controlToken from disk.",
109
+ });
110
+ }
111
+ const hooksToken = typeof cfg.hooks?.token === "string" ? cfg.hooks.token.trim() : "";
112
+ if (cfg.hooks?.enabled === true && hooksToken && !looksLikeEnvRef(hooksToken)) {
113
+ findings.push({
114
+ checkId: "config.secrets.hooks_token_in_config",
115
+ severity: "info",
116
+ title: "Hooks token is stored in config",
117
+ detail: "hooks.token is set in the config file; keep config perms tight and treat it like an API secret.",
118
+ });
119
+ }
120
+ return findings;
121
+ }
122
+ export function collectHooksHardeningFindings(cfg) {
123
+ const findings = [];
124
+ if (cfg.hooks?.enabled !== true)
125
+ return findings;
126
+ const token = typeof cfg.hooks?.token === "string" ? cfg.hooks.token.trim() : "";
127
+ if (token && token.length < 24) {
128
+ findings.push({
129
+ checkId: "hooks.token_too_short",
130
+ severity: "warn",
131
+ title: "Hooks token looks short",
132
+ detail: `hooks.token is ${token.length} chars; prefer a long random token.`,
133
+ });
134
+ }
135
+ const gatewayToken = typeof cfg.gateway?.auth?.token === "string" && cfg.gateway.auth.token.trim()
136
+ ? cfg.gateway.auth.token.trim()
137
+ : null;
138
+ if (token && gatewayToken && token === gatewayToken) {
139
+ findings.push({
140
+ checkId: "hooks.token_reuse_gateway_token",
141
+ severity: "warn",
142
+ title: "Hooks token reuses the Gateway token",
143
+ detail: "hooks.token matches gateway.auth token; compromise of hooks expands blast radius to the Gateway API.",
144
+ remediation: "Use a separate hooks.token dedicated to hook ingress.",
145
+ });
146
+ }
147
+ const browserToken = typeof cfg.browser?.controlToken === "string" && cfg.browser.controlToken.trim()
148
+ ? cfg.browser.controlToken.trim()
149
+ : process.env.CLAWDBOT_BROWSER_CONTROL_TOKEN?.trim() || null;
150
+ if (token && browserToken && token === browserToken) {
151
+ findings.push({
152
+ checkId: "hooks.token_reuse_browser_token",
153
+ severity: "warn",
154
+ title: "Hooks token reuses the browser control token",
155
+ detail: "hooks.token matches browser control token; compromise of hooks may enable browser control endpoints.",
156
+ remediation: "Use a separate hooks.token dedicated to hook ingress.",
157
+ });
158
+ }
159
+ const rawPath = typeof cfg.hooks?.path === "string" ? cfg.hooks.path.trim() : "";
160
+ if (rawPath === "/") {
161
+ findings.push({
162
+ checkId: "hooks.path_root",
163
+ severity: "critical",
164
+ title: "Hooks base path is '/'",
165
+ detail: "hooks.path='/' would shadow other HTTP endpoints and is unsafe.",
166
+ remediation: "Use a dedicated path like '/hooks'.",
167
+ });
168
+ }
169
+ return findings;
170
+ }
171
+ function addModel(models, raw, source) {
172
+ if (typeof raw !== "string")
173
+ return;
174
+ const id = raw.trim();
175
+ if (!id)
176
+ return;
177
+ models.push({ id, source });
178
+ }
179
+ function collectModels(cfg) {
180
+ const out = [];
181
+ addModel(out, cfg.agents?.defaults?.model?.primary, "agents.defaults.model.primary");
182
+ for (const f of cfg.agents?.defaults?.model?.fallbacks ?? [])
183
+ addModel(out, f, "agents.defaults.model.fallbacks");
184
+ addModel(out, cfg.agents?.defaults?.imageModel?.primary, "agents.defaults.imageModel.primary");
185
+ for (const f of cfg.agents?.defaults?.imageModel?.fallbacks ?? [])
186
+ addModel(out, f, "agents.defaults.imageModel.fallbacks");
187
+ const list = Array.isArray(cfg.agents?.list) ? cfg.agents?.list : [];
188
+ for (const agent of list ?? []) {
189
+ if (!agent || typeof agent !== "object")
190
+ continue;
191
+ const id = typeof agent.id === "string" ? agent.id : "";
192
+ const model = agent.model;
193
+ if (typeof model === "string") {
194
+ addModel(out, model, `agents.list.${id}.model`);
195
+ }
196
+ else if (model && typeof model === "object") {
197
+ addModel(out, model.primary, `agents.list.${id}.model.primary`);
198
+ const fallbacks = model.fallbacks;
199
+ if (Array.isArray(fallbacks)) {
200
+ for (const f of fallbacks)
201
+ addModel(out, f, `agents.list.${id}.model.fallbacks`);
202
+ }
203
+ }
204
+ }
205
+ return out;
206
+ }
207
+ const LEGACY_MODEL_PATTERNS = [
208
+ { id: "openai.gpt35", re: /\bgpt-3\.5\b/i, label: "GPT-3.5 family" },
209
+ { id: "anthropic.claude2", re: /\bclaude-(instant|2)\b/i, label: "Claude 2/Instant family" },
210
+ { id: "openai.gpt4_legacy", re: /\bgpt-4-(0314|0613)\b/i, label: "Legacy GPT-4 snapshots" },
211
+ ];
212
+ export function collectModelHygieneFindings(cfg) {
213
+ const findings = [];
214
+ const models = collectModels(cfg);
215
+ if (models.length === 0)
216
+ return findings;
217
+ const matches = [];
218
+ for (const entry of models) {
219
+ for (const pat of LEGACY_MODEL_PATTERNS) {
220
+ if (pat.re.test(entry.id)) {
221
+ matches.push({ model: entry.id, source: entry.source, reason: pat.label });
222
+ break;
223
+ }
224
+ }
225
+ }
226
+ if (matches.length > 0) {
227
+ const lines = matches
228
+ .slice(0, 12)
229
+ .map((m) => `- ${m.model} (${m.reason}) @ ${m.source}`)
230
+ .join("\n");
231
+ const more = matches.length > 12 ? `\n…${matches.length - 12} more` : "";
232
+ findings.push({
233
+ checkId: "models.legacy",
234
+ severity: "warn",
235
+ title: "Some configured models look legacy",
236
+ detail: "Older/legacy models can be less robust against prompt injection and tool misuse.\n" +
237
+ lines +
238
+ more,
239
+ remediation: "Prefer modern, instruction-hardened models for any bot that can run tools.",
240
+ });
241
+ }
242
+ return findings;
243
+ }
244
+ export async function collectPluginsTrustFindings(params) {
245
+ const findings = [];
246
+ const extensionsDir = path.join(params.stateDir, "extensions");
247
+ const st = await safeStat(extensionsDir);
248
+ if (!st.ok || !st.isDir)
249
+ return findings;
250
+ const entries = await fs.readdir(extensionsDir, { withFileTypes: true }).catch(() => []);
251
+ const pluginDirs = entries
252
+ .filter((e) => e.isDirectory())
253
+ .map((e) => e.name)
254
+ .filter(Boolean);
255
+ if (pluginDirs.length === 0)
256
+ return findings;
257
+ const allow = params.cfg.plugins?.allow;
258
+ const allowConfigured = Array.isArray(allow) && allow.length > 0;
259
+ if (!allowConfigured) {
260
+ findings.push({
261
+ checkId: "plugins.extensions_no_allowlist",
262
+ severity: "warn",
263
+ title: "Extensions exist but plugins.allow is not set",
264
+ detail: `Found ${pluginDirs.length} extension(s) under ${extensionsDir}. Without plugins.allow, any discovered plugin id may load (depending on config and plugin behavior).`,
265
+ remediation: "Set plugins.allow to an explicit list of plugin ids you trust.",
266
+ });
267
+ }
268
+ return findings;
269
+ }
270
+ function resolveIncludePath(baseConfigPath, includePath) {
271
+ return path.normalize(path.isAbsolute(includePath)
272
+ ? includePath
273
+ : path.resolve(path.dirname(baseConfigPath), includePath));
274
+ }
275
+ function listDirectIncludes(parsed) {
276
+ const out = [];
277
+ const visit = (value) => {
278
+ if (!value)
279
+ return;
280
+ if (Array.isArray(value)) {
281
+ for (const item of value)
282
+ visit(item);
283
+ return;
284
+ }
285
+ if (typeof value !== "object")
286
+ return;
287
+ const rec = value;
288
+ const includeVal = rec[INCLUDE_KEY];
289
+ if (typeof includeVal === "string")
290
+ out.push(includeVal);
291
+ else if (Array.isArray(includeVal)) {
292
+ for (const item of includeVal) {
293
+ if (typeof item === "string")
294
+ out.push(item);
295
+ }
296
+ }
297
+ for (const v of Object.values(rec))
298
+ visit(v);
299
+ };
300
+ visit(parsed);
301
+ return out;
302
+ }
303
+ async function collectIncludePathsRecursive(params) {
304
+ const visited = new Set();
305
+ const result = [];
306
+ const walk = async (basePath, parsed, depth) => {
307
+ if (depth > MAX_INCLUDE_DEPTH)
308
+ return;
309
+ for (const raw of listDirectIncludes(parsed)) {
310
+ const resolved = resolveIncludePath(basePath, raw);
311
+ if (visited.has(resolved))
312
+ continue;
313
+ visited.add(resolved);
314
+ result.push(resolved);
315
+ const rawText = await fs.readFile(resolved, "utf-8").catch(() => null);
316
+ if (!rawText)
317
+ continue;
318
+ const nestedParsed = (() => {
319
+ try {
320
+ return JSON5.parse(rawText);
321
+ }
322
+ catch {
323
+ return null;
324
+ }
325
+ })();
326
+ if (nestedParsed) {
327
+ // eslint-disable-next-line no-await-in-loop
328
+ await walk(resolved, nestedParsed, depth + 1);
329
+ }
330
+ }
331
+ };
332
+ await walk(params.configPath, params.parsed, 0);
333
+ return result;
334
+ }
335
+ export async function collectIncludeFilePermFindings(params) {
336
+ const findings = [];
337
+ if (!params.configSnapshot.exists)
338
+ return findings;
339
+ const configPath = params.configSnapshot.path;
340
+ const includePaths = await collectIncludePathsRecursive({
341
+ configPath,
342
+ parsed: params.configSnapshot.parsed,
343
+ });
344
+ if (includePaths.length === 0)
345
+ return findings;
346
+ for (const p of includePaths) {
347
+ // eslint-disable-next-line no-await-in-loop
348
+ const st = await safeStat(p);
349
+ if (!st.ok)
350
+ continue;
351
+ const bits = modeBits(st.mode);
352
+ if (isWorldWritable(bits) || isGroupWritable(bits)) {
353
+ findings.push({
354
+ checkId: "fs.config_include.perms_writable",
355
+ severity: "critical",
356
+ title: "Config include file is writable by others",
357
+ detail: `${p} mode=${formatOctal(bits)}; another user could influence your effective config.`,
358
+ remediation: `chmod 600 ${p}`,
359
+ });
360
+ }
361
+ else if (isWorldReadable(bits)) {
362
+ findings.push({
363
+ checkId: "fs.config_include.perms_world_readable",
364
+ severity: "critical",
365
+ title: "Config include file is world-readable",
366
+ detail: `${p} mode=${formatOctal(bits)}; include files can contain tokens and private settings.`,
367
+ remediation: `chmod 600 ${p}`,
368
+ });
369
+ }
370
+ else if (isGroupReadable(bits)) {
371
+ findings.push({
372
+ checkId: "fs.config_include.perms_group_readable",
373
+ severity: "warn",
374
+ title: "Config include file is group-readable",
375
+ detail: `${p} mode=${formatOctal(bits)}; include files can contain tokens and private settings.`,
376
+ remediation: `chmod 600 ${p}`,
377
+ });
378
+ }
379
+ }
380
+ return findings;
381
+ }
382
+ export async function collectStateDeepFilesystemFindings(params) {
383
+ const findings = [];
384
+ const oauthDir = resolveOAuthDir(params.env, params.stateDir);
385
+ const oauthStat = await safeStat(oauthDir);
386
+ if (oauthStat.ok && oauthStat.isDir) {
387
+ const bits = modeBits(oauthStat.mode);
388
+ if (isWorldWritable(bits) || isGroupWritable(bits)) {
389
+ findings.push({
390
+ checkId: "fs.credentials_dir.perms_writable",
391
+ severity: "critical",
392
+ title: "Credentials dir is writable by others",
393
+ detail: `${oauthDir} mode=${formatOctal(bits)}; another user could drop/modify credential files.`,
394
+ remediation: `chmod 700 ${oauthDir}`,
395
+ });
396
+ }
397
+ else if (isGroupReadable(bits) || isWorldReadable(bits)) {
398
+ findings.push({
399
+ checkId: "fs.credentials_dir.perms_readable",
400
+ severity: "warn",
401
+ title: "Credentials dir is readable by others",
402
+ detail: `${oauthDir} mode=${formatOctal(bits)}; credentials and allowlists can be sensitive.`,
403
+ remediation: `chmod 700 ${oauthDir}`,
404
+ });
405
+ }
406
+ }
407
+ const agentIds = Array.isArray(params.cfg.agents?.list)
408
+ ? params.cfg.agents?.list
409
+ .map((a) => (a && typeof a === "object" && typeof a.id === "string" ? a.id.trim() : ""))
410
+ .filter(Boolean)
411
+ : [];
412
+ const defaultAgentId = resolveDefaultAgentId(params.cfg);
413
+ const ids = Array.from(new Set([defaultAgentId, ...agentIds])).map((id) => normalizeAgentId(id));
414
+ for (const agentId of ids) {
415
+ const agentDir = path.join(params.stateDir, "agents", agentId, "agent");
416
+ const authPath = path.join(agentDir, "auth-profiles.json");
417
+ // eslint-disable-next-line no-await-in-loop
418
+ const authStat = await safeStat(authPath);
419
+ if (authStat.ok) {
420
+ const bits = modeBits(authStat.mode);
421
+ if (isWorldWritable(bits) || isGroupWritable(bits)) {
422
+ findings.push({
423
+ checkId: "fs.auth_profiles.perms_writable",
424
+ severity: "critical",
425
+ title: "auth-profiles.json is writable by others",
426
+ detail: `${authPath} mode=${formatOctal(bits)}; another user could inject credentials.`,
427
+ remediation: `chmod 600 ${authPath}`,
428
+ });
429
+ }
430
+ else if (isWorldReadable(bits) || isGroupReadable(bits)) {
431
+ findings.push({
432
+ checkId: "fs.auth_profiles.perms_readable",
433
+ severity: "warn",
434
+ title: "auth-profiles.json is readable by others",
435
+ detail: `${authPath} mode=${formatOctal(bits)}; auth-profiles.json contains API keys and OAuth tokens.`,
436
+ remediation: `chmod 600 ${authPath}`,
437
+ });
438
+ }
439
+ }
440
+ const storePath = path.join(params.stateDir, "agents", agentId, "sessions", "sessions.json");
441
+ // eslint-disable-next-line no-await-in-loop
442
+ const storeStat = await safeStat(storePath);
443
+ if (storeStat.ok) {
444
+ const bits = modeBits(storeStat.mode);
445
+ if (isWorldReadable(bits) || isGroupReadable(bits)) {
446
+ findings.push({
447
+ checkId: "fs.sessions_store.perms_readable",
448
+ severity: "warn",
449
+ title: "sessions.json is readable by others",
450
+ detail: `${storePath} mode=${formatOctal(bits)}; routing and transcript metadata can be sensitive.`,
451
+ remediation: `chmod 600 ${storePath}`,
452
+ });
453
+ }
454
+ }
455
+ }
456
+ const logFile = typeof params.cfg.logging?.file === "string" ? params.cfg.logging.file.trim() : "";
457
+ if (logFile) {
458
+ const expanded = logFile.startsWith("~") ? expandTilde(logFile, params.env) : logFile;
459
+ if (expanded) {
460
+ const logPath = path.resolve(expanded);
461
+ const st = await safeStat(logPath);
462
+ if (st.ok) {
463
+ const bits = modeBits(st.mode);
464
+ if (isWorldReadable(bits) || isGroupReadable(bits)) {
465
+ findings.push({
466
+ checkId: "fs.log_file.perms_readable",
467
+ severity: "warn",
468
+ title: "Log file is readable by others",
469
+ detail: `${logPath} mode=${formatOctal(bits)}; logs can contain private messages and tool output.`,
470
+ remediation: `chmod 600 ${logPath}`,
471
+ });
472
+ }
473
+ }
474
+ }
475
+ }
476
+ return findings;
477
+ }
478
+ function listGroupPolicyOpen(cfg) {
479
+ const out = [];
480
+ const channels = cfg.channels;
481
+ if (!channels || typeof channels !== "object")
482
+ return out;
483
+ for (const [channelId, value] of Object.entries(channels)) {
484
+ if (!value || typeof value !== "object")
485
+ continue;
486
+ const section = value;
487
+ if (section.groupPolicy === "open")
488
+ out.push(`channels.${channelId}.groupPolicy`);
489
+ const accounts = section.accounts;
490
+ if (accounts && typeof accounts === "object") {
491
+ for (const [accountId, accountVal] of Object.entries(accounts)) {
492
+ if (!accountVal || typeof accountVal !== "object")
493
+ continue;
494
+ const acc = accountVal;
495
+ if (acc.groupPolicy === "open")
496
+ out.push(`channels.${channelId}.accounts.${accountId}.groupPolicy`);
497
+ }
498
+ }
499
+ }
500
+ return out;
501
+ }
502
+ export function collectExposureMatrixFindings(cfg) {
503
+ const findings = [];
504
+ const openGroups = listGroupPolicyOpen(cfg);
505
+ if (openGroups.length === 0)
506
+ return findings;
507
+ const elevatedEnabled = cfg.tools?.elevated?.enabled !== false;
508
+ if (elevatedEnabled) {
509
+ findings.push({
510
+ checkId: "security.exposure.open_groups_with_elevated",
511
+ severity: "critical",
512
+ title: "Open groupPolicy with elevated tools enabled",
513
+ detail: `Found groupPolicy="open" at:\n${openGroups.map((p) => `- ${p}`).join("\n")}\n` +
514
+ "With tools.elevated enabled, a prompt injection in those rooms can become a high-impact incident.",
515
+ remediation: `Set groupPolicy="allowlist" and keep elevated allowlists extremely tight.`,
516
+ });
517
+ }
518
+ return findings;
519
+ }
520
+ export async function readConfigSnapshotForAudit(params) {
521
+ return await createConfigIO({
522
+ env: params.env,
523
+ configPath: params.configPath,
524
+ }).readConfigFileSnapshot();
525
+ }
@@ -0,0 +1,55 @@
1
+ import fs from "node:fs/promises";
2
+ export async function safeStat(targetPath) {
3
+ try {
4
+ const lst = await fs.lstat(targetPath);
5
+ return {
6
+ ok: true,
7
+ isSymlink: lst.isSymbolicLink(),
8
+ isDir: lst.isDirectory(),
9
+ mode: typeof lst.mode === "number" ? lst.mode : null,
10
+ uid: typeof lst.uid === "number" ? lst.uid : null,
11
+ gid: typeof lst.gid === "number" ? lst.gid : null,
12
+ };
13
+ }
14
+ catch (err) {
15
+ return {
16
+ ok: false,
17
+ isSymlink: false,
18
+ isDir: false,
19
+ mode: null,
20
+ uid: null,
21
+ gid: null,
22
+ error: String(err),
23
+ };
24
+ }
25
+ }
26
+ export function modeBits(mode) {
27
+ if (mode == null)
28
+ return null;
29
+ return mode & 0o777;
30
+ }
31
+ export function formatOctal(bits) {
32
+ if (bits == null)
33
+ return "unknown";
34
+ return bits.toString(8).padStart(3, "0");
35
+ }
36
+ export function isWorldWritable(bits) {
37
+ if (bits == null)
38
+ return false;
39
+ return (bits & 0o002) !== 0;
40
+ }
41
+ export function isGroupWritable(bits) {
42
+ if (bits == null)
43
+ return false;
44
+ return (bits & 0o020) !== 0;
45
+ }
46
+ export function isWorldReadable(bits) {
47
+ if (bits == null)
48
+ return false;
49
+ return (bits & 0o004) !== 0;
50
+ }
51
+ export function isGroupReadable(bits) {
52
+ if (bits == null)
53
+ return false;
54
+ return (bits & 0o040) !== 0;
55
+ }
@@ -0,0 +1,469 @@
1
+ import { listChannelPlugins } from "../channels/plugins/index.js";
2
+ import { resolveChannelDefaultAccountId } from "../channels/plugins/helpers.js";
3
+ import { resolveBrowserConfig } from "../browser/config.js";
4
+ import { resolveConfigPath, resolveStateDir } from "../config/paths.js";
5
+ import { resolveGatewayAuth } from "../gateway/auth.js";
6
+ import { buildGatewayConnectionDetails } from "../gateway/call.js";
7
+ import { probeGateway } from "../gateway/probe.js";
8
+ import { collectAttackSurfaceSummaryFindings, collectExposureMatrixFindings, collectHooksHardeningFindings, collectIncludeFilePermFindings, collectModelHygieneFindings, collectPluginsTrustFindings, collectSecretsInConfigFindings, collectStateDeepFilesystemFindings, collectSyncedFolderFindings, readConfigSnapshotForAudit, } from "./audit-extra.js";
9
+ import { formatOctal, isGroupReadable, isGroupWritable, isWorldReadable, isWorldWritable, modeBits, safeStat, } from "./audit-fs.js";
10
+ function countBySeverity(findings) {
11
+ let critical = 0;
12
+ let warn = 0;
13
+ let info = 0;
14
+ for (const f of findings) {
15
+ if (f.severity === "critical")
16
+ critical += 1;
17
+ else if (f.severity === "warn")
18
+ warn += 1;
19
+ else
20
+ info += 1;
21
+ }
22
+ return { critical, warn, info };
23
+ }
24
+ function normalizeAllowFromList(list) {
25
+ if (!Array.isArray(list))
26
+ return [];
27
+ return list.map((v) => String(v).trim()).filter(Boolean);
28
+ }
29
+ function classifyChannelWarningSeverity(message) {
30
+ const s = message.toLowerCase();
31
+ if (s.includes("dms: open") ||
32
+ s.includes('grouppolicy="open"') ||
33
+ s.includes('dmpolicy="open"')) {
34
+ return "critical";
35
+ }
36
+ if (s.includes("allows any") || s.includes("anyone can dm") || s.includes("public")) {
37
+ return "critical";
38
+ }
39
+ if (s.includes("locked") || s.includes("disabled")) {
40
+ return "info";
41
+ }
42
+ return "warn";
43
+ }
44
+ async function collectFilesystemFindings(params) {
45
+ const findings = [];
46
+ const stateDirStat = await safeStat(params.stateDir);
47
+ if (stateDirStat.ok) {
48
+ const bits = modeBits(stateDirStat.mode);
49
+ if (stateDirStat.isSymlink) {
50
+ findings.push({
51
+ checkId: "fs.state_dir.symlink",
52
+ severity: "warn",
53
+ title: "State dir is a symlink",
54
+ detail: `${params.stateDir} is a symlink; treat this as an extra trust boundary.`,
55
+ });
56
+ }
57
+ if (isWorldWritable(bits)) {
58
+ findings.push({
59
+ checkId: "fs.state_dir.perms_world_writable",
60
+ severity: "critical",
61
+ title: "State dir is world-writable",
62
+ detail: `${params.stateDir} mode=${formatOctal(bits)}; other users can write into your Clawdbot state.`,
63
+ remediation: `chmod 700 ${params.stateDir}`,
64
+ });
65
+ }
66
+ else if (isGroupWritable(bits)) {
67
+ findings.push({
68
+ checkId: "fs.state_dir.perms_group_writable",
69
+ severity: "warn",
70
+ title: "State dir is group-writable",
71
+ detail: `${params.stateDir} mode=${formatOctal(bits)}; group users can write into your Clawdbot state.`,
72
+ remediation: `chmod 700 ${params.stateDir}`,
73
+ });
74
+ }
75
+ else if (isGroupReadable(bits) || isWorldReadable(bits)) {
76
+ findings.push({
77
+ checkId: "fs.state_dir.perms_readable",
78
+ severity: "warn",
79
+ title: "State dir is readable by others",
80
+ detail: `${params.stateDir} mode=${formatOctal(bits)}; consider restricting to 700.`,
81
+ remediation: `chmod 700 ${params.stateDir}`,
82
+ });
83
+ }
84
+ }
85
+ const configStat = await safeStat(params.configPath);
86
+ if (configStat.ok) {
87
+ const bits = modeBits(configStat.mode);
88
+ if (configStat.isSymlink) {
89
+ findings.push({
90
+ checkId: "fs.config.symlink",
91
+ severity: "warn",
92
+ title: "Config file is a symlink",
93
+ detail: `${params.configPath} is a symlink; make sure you trust its target.`,
94
+ });
95
+ }
96
+ if (isWorldWritable(bits) || isGroupWritable(bits)) {
97
+ findings.push({
98
+ checkId: "fs.config.perms_writable",
99
+ severity: "critical",
100
+ title: "Config file is writable by others",
101
+ detail: `${params.configPath} mode=${formatOctal(bits)}; another user could change gateway/auth/tool policies.`,
102
+ remediation: `chmod 600 ${params.configPath}`,
103
+ });
104
+ }
105
+ else if (isWorldReadable(bits)) {
106
+ findings.push({
107
+ checkId: "fs.config.perms_world_readable",
108
+ severity: "critical",
109
+ title: "Config file is world-readable",
110
+ detail: `${params.configPath} mode=${formatOctal(bits)}; config can contain tokens and private settings.`,
111
+ remediation: `chmod 600 ${params.configPath}`,
112
+ });
113
+ }
114
+ else if (isGroupReadable(bits)) {
115
+ findings.push({
116
+ checkId: "fs.config.perms_group_readable",
117
+ severity: "warn",
118
+ title: "Config file is group-readable",
119
+ detail: `${params.configPath} mode=${formatOctal(bits)}; config can contain tokens and private settings.`,
120
+ remediation: `chmod 600 ${params.configPath}`,
121
+ });
122
+ }
123
+ }
124
+ return findings;
125
+ }
126
+ function collectGatewayConfigFindings(cfg) {
127
+ const findings = [];
128
+ const bind = typeof cfg.gateway?.bind === "string" ? cfg.gateway.bind : "loopback";
129
+ const tailscaleMode = cfg.gateway?.tailscale?.mode ?? "off";
130
+ const auth = resolveGatewayAuth({ authConfig: cfg.gateway?.auth, tailscaleMode });
131
+ if (bind !== "loopback" && auth.mode === "none") {
132
+ findings.push({
133
+ checkId: "gateway.bind_no_auth",
134
+ severity: "critical",
135
+ title: "Gateway binds beyond loopback without auth",
136
+ detail: `gateway.bind="${bind}" but no gateway.auth token/password is configured.`,
137
+ remediation: `Set gateway.auth (token recommended) or bind to loopback.`,
138
+ });
139
+ }
140
+ if (tailscaleMode === "funnel") {
141
+ findings.push({
142
+ checkId: "gateway.tailscale_funnel",
143
+ severity: "critical",
144
+ title: "Tailscale Funnel exposure enabled",
145
+ detail: `gateway.tailscale.mode="funnel" exposes the Gateway publicly; keep auth strict and treat it as internet-facing.`,
146
+ remediation: `Prefer tailscale.mode="serve" (tailnet-only) or set tailscale.mode="off".`,
147
+ });
148
+ }
149
+ else if (tailscaleMode === "serve") {
150
+ findings.push({
151
+ checkId: "gateway.tailscale_serve",
152
+ severity: "info",
153
+ title: "Tailscale Serve exposure enabled",
154
+ detail: `gateway.tailscale.mode="serve" exposes the Gateway to your tailnet (loopback behind Tailscale).`,
155
+ });
156
+ }
157
+ const token = typeof auth.token === "string" && auth.token.trim().length > 0 ? auth.token.trim() : null;
158
+ if (auth.mode === "token" && token && token.length < 24) {
159
+ findings.push({
160
+ checkId: "gateway.token_too_short",
161
+ severity: "warn",
162
+ title: "Gateway token looks short",
163
+ detail: `gateway auth token is ${token.length} chars; prefer a long random token.`,
164
+ });
165
+ }
166
+ return findings;
167
+ }
168
+ function isLoopbackClientHost(hostname) {
169
+ const h = hostname.trim().toLowerCase();
170
+ return h === "localhost" || h === "127.0.0.1" || h === "::1";
171
+ }
172
+ function collectBrowserControlFindings(cfg) {
173
+ const findings = [];
174
+ let resolved;
175
+ try {
176
+ resolved = resolveBrowserConfig(cfg.browser);
177
+ }
178
+ catch (err) {
179
+ findings.push({
180
+ checkId: "browser.control_invalid_config",
181
+ severity: "warn",
182
+ title: "Browser control config looks invalid",
183
+ detail: String(err),
184
+ remediation: `Fix browser.controlUrl/browser.cdpUrl in ${resolveConfigPath()} and re-run "clawdbot security audit --deep".`,
185
+ });
186
+ return findings;
187
+ }
188
+ if (!resolved.enabled)
189
+ return findings;
190
+ const url = new URL(resolved.controlUrl);
191
+ const isLoopback = isLoopbackClientHost(url.hostname);
192
+ const envToken = process.env.CLAWDBOT_BROWSER_CONTROL_TOKEN?.trim();
193
+ const controlToken = (envToken || resolved.controlToken)?.trim() || null;
194
+ if (!isLoopback) {
195
+ if (!controlToken) {
196
+ findings.push({
197
+ checkId: "browser.control_remote_no_token",
198
+ severity: "critical",
199
+ title: "Remote browser control is missing an auth token",
200
+ detail: `browser.controlUrl is non-loopback (${resolved.controlUrl}) but no browser.controlToken (or CLAWDBOT_BROWSER_CONTROL_TOKEN) is configured.`,
201
+ remediation: "Set browser.controlToken (or export CLAWDBOT_BROWSER_CONTROL_TOKEN) and prefer serving over Tailscale Serve or HTTPS reverse proxy.",
202
+ });
203
+ }
204
+ if (url.protocol === "http:") {
205
+ findings.push({
206
+ checkId: "browser.control_remote_http",
207
+ severity: "warn",
208
+ title: "Remote browser control uses HTTP",
209
+ detail: `browser.controlUrl=${resolved.controlUrl} is http; this is OK only if it's tailnet-only (Tailscale) or behind another encrypted tunnel.`,
210
+ remediation: `Prefer HTTPS termination (Tailscale Serve) and keep the endpoint tailnet-only.`,
211
+ });
212
+ }
213
+ if (controlToken && controlToken.length < 24) {
214
+ findings.push({
215
+ checkId: "browser.control_token_too_short",
216
+ severity: "warn",
217
+ title: "Browser control token looks short",
218
+ detail: `browser control token is ${controlToken.length} chars; prefer a long random token.`,
219
+ });
220
+ }
221
+ const tailscaleMode = cfg.gateway?.tailscale?.mode ?? "off";
222
+ const gatewayAuth = resolveGatewayAuth({ authConfig: cfg.gateway?.auth, tailscaleMode });
223
+ const gatewayToken = gatewayAuth.mode === "token" &&
224
+ typeof gatewayAuth.token === "string" &&
225
+ gatewayAuth.token.trim()
226
+ ? gatewayAuth.token.trim()
227
+ : null;
228
+ if (controlToken && gatewayToken && controlToken === gatewayToken) {
229
+ findings.push({
230
+ checkId: "browser.control_token_reuse_gateway_token",
231
+ severity: "warn",
232
+ title: "Browser control token reuses the Gateway token",
233
+ detail: `browser.controlToken matches gateway.auth token; compromise of browser control expands blast radius to the Gateway API.`,
234
+ remediation: `Use a separate browser.controlToken dedicated to browser control.`,
235
+ });
236
+ }
237
+ }
238
+ return findings;
239
+ }
240
+ function collectLoggingFindings(cfg) {
241
+ const redact = cfg.logging?.redactSensitive;
242
+ if (redact !== "off")
243
+ return [];
244
+ return [
245
+ {
246
+ checkId: "logging.redact_off",
247
+ severity: "warn",
248
+ title: "Tool summary redaction is disabled",
249
+ detail: `logging.redactSensitive="off" can leak secrets into logs and status output.`,
250
+ remediation: `Set logging.redactSensitive="tools".`,
251
+ },
252
+ ];
253
+ }
254
+ function collectElevatedFindings(cfg) {
255
+ const findings = [];
256
+ const enabled = cfg.tools?.elevated?.enabled;
257
+ const allowFrom = cfg.tools?.elevated?.allowFrom ?? {};
258
+ const anyAllowFromKeys = Object.keys(allowFrom).length > 0;
259
+ if (enabled === false)
260
+ return findings;
261
+ if (!anyAllowFromKeys)
262
+ return findings;
263
+ for (const [provider, list] of Object.entries(allowFrom)) {
264
+ const normalized = normalizeAllowFromList(list);
265
+ if (normalized.includes("*")) {
266
+ findings.push({
267
+ checkId: `tools.elevated.allowFrom.${provider}.wildcard`,
268
+ severity: "critical",
269
+ title: "Elevated exec allowlist contains wildcard",
270
+ detail: `tools.elevated.allowFrom.${provider} includes "*" which effectively approves everyone on that channel for elevated mode.`,
271
+ });
272
+ }
273
+ else if (normalized.length > 25) {
274
+ findings.push({
275
+ checkId: `tools.elevated.allowFrom.${provider}.large`,
276
+ severity: "warn",
277
+ title: "Elevated exec allowlist is large",
278
+ detail: `tools.elevated.allowFrom.${provider} has ${normalized.length} entries; consider tightening elevated access.`,
279
+ });
280
+ }
281
+ }
282
+ return findings;
283
+ }
284
+ async function collectChannelSecurityFindings(params) {
285
+ const findings = [];
286
+ const warnDmPolicy = async (input) => {
287
+ const policyPath = input.policyPath ?? `${input.allowFromPath}policy`;
288
+ const configAllowFrom = normalizeAllowFromList(input.allowFrom);
289
+ const hasWildcard = configAllowFrom.includes("*");
290
+ if (input.dmPolicy === "open") {
291
+ const allowFromKey = `${input.allowFromPath}allowFrom`;
292
+ findings.push({
293
+ checkId: `channels.${input.provider}.dm.open`,
294
+ severity: "critical",
295
+ title: `${input.label} DMs are open`,
296
+ detail: `${policyPath}="open" allows anyone to DM the bot.`,
297
+ remediation: `Use pairing/allowlist; if you really need open DMs, ensure ${allowFromKey} includes "*".`,
298
+ });
299
+ if (!hasWildcard) {
300
+ findings.push({
301
+ checkId: `channels.${input.provider}.dm.open_invalid`,
302
+ severity: "warn",
303
+ title: `${input.label} DM config looks inconsistent`,
304
+ detail: `"open" requires ${allowFromKey} to include "*".`,
305
+ });
306
+ }
307
+ return;
308
+ }
309
+ if (input.dmPolicy === "disabled") {
310
+ findings.push({
311
+ checkId: `channels.${input.provider}.dm.disabled`,
312
+ severity: "info",
313
+ title: `${input.label} DMs are disabled`,
314
+ detail: `${policyPath}="disabled" ignores inbound DMs.`,
315
+ });
316
+ }
317
+ };
318
+ for (const plugin of params.plugins) {
319
+ if (!plugin.security)
320
+ continue;
321
+ const accountIds = plugin.config.listAccountIds(params.cfg);
322
+ const defaultAccountId = resolveChannelDefaultAccountId({
323
+ plugin,
324
+ cfg: params.cfg,
325
+ accountIds,
326
+ });
327
+ const account = plugin.config.resolveAccount(params.cfg, defaultAccountId);
328
+ const enabled = plugin.config.isEnabled ? plugin.config.isEnabled(account, params.cfg) : true;
329
+ if (!enabled)
330
+ continue;
331
+ const configured = plugin.config.isConfigured
332
+ ? await plugin.config.isConfigured(account, params.cfg)
333
+ : true;
334
+ if (!configured)
335
+ continue;
336
+ const dmPolicy = plugin.security.resolveDmPolicy?.({
337
+ cfg: params.cfg,
338
+ accountId: defaultAccountId,
339
+ account,
340
+ });
341
+ if (dmPolicy) {
342
+ await warnDmPolicy({
343
+ label: plugin.meta.label ?? plugin.id,
344
+ provider: plugin.id,
345
+ dmPolicy: dmPolicy.policy,
346
+ allowFrom: dmPolicy.allowFrom,
347
+ policyPath: dmPolicy.policyPath,
348
+ allowFromPath: dmPolicy.allowFromPath,
349
+ });
350
+ }
351
+ if (plugin.security.collectWarnings) {
352
+ const warnings = await plugin.security.collectWarnings({
353
+ cfg: params.cfg,
354
+ accountId: defaultAccountId,
355
+ account,
356
+ });
357
+ for (const message of warnings ?? []) {
358
+ const trimmed = String(message).trim();
359
+ if (!trimmed)
360
+ continue;
361
+ findings.push({
362
+ checkId: `channels.${plugin.id}.warning.${findings.length + 1}`,
363
+ severity: classifyChannelWarningSeverity(trimmed),
364
+ title: `${plugin.meta.label ?? plugin.id} security warning`,
365
+ detail: trimmed.replace(/^-\s*/, ""),
366
+ });
367
+ }
368
+ }
369
+ }
370
+ return findings;
371
+ }
372
+ async function maybeProbeGateway(params) {
373
+ const connection = buildGatewayConnectionDetails({ config: params.cfg });
374
+ const url = connection.url;
375
+ const isRemoteMode = params.cfg.gateway?.mode === "remote";
376
+ const remoteUrlRaw = typeof params.cfg.gateway?.remote?.url === "string" ? params.cfg.gateway.remote.url.trim() : "";
377
+ const remoteUrlMissing = isRemoteMode && !remoteUrlRaw;
378
+ const resolveAuth = (mode) => {
379
+ const authToken = params.cfg.gateway?.auth?.token;
380
+ const authPassword = params.cfg.gateway?.auth?.password;
381
+ const remote = params.cfg.gateway?.remote;
382
+ const token = mode === "remote"
383
+ ? typeof remote?.token === "string" && remote.token.trim()
384
+ ? remote.token.trim()
385
+ : undefined
386
+ : process.env.CLAWDBOT_GATEWAY_TOKEN?.trim() ||
387
+ (typeof authToken === "string" && authToken.trim() ? authToken.trim() : undefined);
388
+ const password = process.env.CLAWDBOT_GATEWAY_PASSWORD?.trim() ||
389
+ (mode === "remote"
390
+ ? typeof remote?.password === "string" && remote.password.trim()
391
+ ? remote.password.trim()
392
+ : undefined
393
+ : typeof authPassword === "string" && authPassword.trim()
394
+ ? authPassword.trim()
395
+ : undefined);
396
+ return { token, password };
397
+ };
398
+ const auth = remoteUrlMissing ? resolveAuth("local") : resolveAuth("remote");
399
+ const res = await params.probe({ url, auth, timeoutMs: params.timeoutMs }).catch((err) => ({
400
+ ok: false,
401
+ url,
402
+ connectLatencyMs: null,
403
+ error: String(err),
404
+ close: null,
405
+ health: null,
406
+ status: null,
407
+ presence: null,
408
+ configSnapshot: null,
409
+ }));
410
+ return {
411
+ gateway: {
412
+ attempted: true,
413
+ url,
414
+ ok: res.ok,
415
+ error: res.ok ? null : res.error,
416
+ close: res.close ? { code: res.close.code, reason: res.close.reason } : null,
417
+ },
418
+ };
419
+ }
420
+ export async function runSecurityAudit(opts) {
421
+ const findings = [];
422
+ const cfg = opts.config;
423
+ const env = process.env;
424
+ const stateDir = opts.stateDir ?? resolveStateDir(env);
425
+ const configPath = opts.configPath ?? resolveConfigPath(env, stateDir);
426
+ findings.push(...collectAttackSurfaceSummaryFindings(cfg));
427
+ findings.push(...collectSyncedFolderFindings({ stateDir, configPath }));
428
+ findings.push(...collectGatewayConfigFindings(cfg));
429
+ findings.push(...collectBrowserControlFindings(cfg));
430
+ findings.push(...collectLoggingFindings(cfg));
431
+ findings.push(...collectElevatedFindings(cfg));
432
+ findings.push(...collectHooksHardeningFindings(cfg));
433
+ findings.push(...collectSecretsInConfigFindings(cfg));
434
+ findings.push(...collectModelHygieneFindings(cfg));
435
+ findings.push(...collectExposureMatrixFindings(cfg));
436
+ const configSnapshot = opts.includeFilesystem !== false
437
+ ? await readConfigSnapshotForAudit({ env, configPath }).catch(() => null)
438
+ : null;
439
+ if (opts.includeFilesystem !== false) {
440
+ findings.push(...(await collectFilesystemFindings({ stateDir, configPath })));
441
+ if (configSnapshot) {
442
+ findings.push(...(await collectIncludeFilePermFindings({ configSnapshot })));
443
+ }
444
+ findings.push(...(await collectStateDeepFilesystemFindings({ cfg, env, stateDir })));
445
+ findings.push(...(await collectPluginsTrustFindings({ cfg, stateDir })));
446
+ }
447
+ if (opts.includeChannelSecurity !== false) {
448
+ const plugins = opts.plugins ?? listChannelPlugins();
449
+ findings.push(...(await collectChannelSecurityFindings({ cfg, plugins })));
450
+ }
451
+ const deep = opts.deep === true
452
+ ? await maybeProbeGateway({
453
+ cfg,
454
+ timeoutMs: Math.max(250, opts.deepTimeoutMs ?? 5000),
455
+ probe: opts.probeGatewayFn ?? probeGateway,
456
+ })
457
+ : undefined;
458
+ if (deep?.gateway?.attempted && deep.gateway.ok === false) {
459
+ findings.push({
460
+ checkId: "gateway.probe_failed",
461
+ severity: "warn",
462
+ title: "Gateway probe failed (deep)",
463
+ detail: deep.gateway.error ?? "gateway unreachable",
464
+ remediation: `Run "clawdbot status --all" to debug connectivity/auth, then re-run "clawdbot security audit --deep".`,
465
+ });
466
+ }
467
+ const summary = countBySeverity(findings);
468
+ return { ts: Date.now(), summary, findings, deep };
469
+ }
@@ -0,0 +1,317 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ import JSON5 from "json5";
4
+ import { createConfigIO } from "../config/config.js";
5
+ import { resolveConfigPath, resolveOAuthDir, resolveStateDir } from "../config/paths.js";
6
+ import { resolveDefaultAgentId } from "../agents/agent-scope.js";
7
+ import { INCLUDE_KEY, MAX_INCLUDE_DEPTH } from "../config/includes.js";
8
+ import { normalizeAgentId } from "../routing/session-key.js";
9
+ import { readChannelAllowFromStore } from "../pairing/pairing-store.js";
10
+ async function safeChmod(params) {
11
+ try {
12
+ const st = await fs.lstat(params.path);
13
+ if (st.isSymbolicLink()) {
14
+ return {
15
+ kind: "chmod",
16
+ path: params.path,
17
+ mode: params.mode,
18
+ ok: false,
19
+ skipped: "symlink",
20
+ };
21
+ }
22
+ if (params.require === "dir" && !st.isDirectory()) {
23
+ return {
24
+ kind: "chmod",
25
+ path: params.path,
26
+ mode: params.mode,
27
+ ok: false,
28
+ skipped: "not-a-directory",
29
+ };
30
+ }
31
+ if (params.require === "file" && !st.isFile()) {
32
+ return {
33
+ kind: "chmod",
34
+ path: params.path,
35
+ mode: params.mode,
36
+ ok: false,
37
+ skipped: "not-a-file",
38
+ };
39
+ }
40
+ const current = st.mode & 0o777;
41
+ if (current === params.mode) {
42
+ return {
43
+ kind: "chmod",
44
+ path: params.path,
45
+ mode: params.mode,
46
+ ok: false,
47
+ skipped: "already",
48
+ };
49
+ }
50
+ await fs.chmod(params.path, params.mode);
51
+ return { kind: "chmod", path: params.path, mode: params.mode, ok: true };
52
+ }
53
+ catch (err) {
54
+ const code = err.code;
55
+ if (code === "ENOENT") {
56
+ return {
57
+ kind: "chmod",
58
+ path: params.path,
59
+ mode: params.mode,
60
+ ok: false,
61
+ skipped: "missing",
62
+ };
63
+ }
64
+ return {
65
+ kind: "chmod",
66
+ path: params.path,
67
+ mode: params.mode,
68
+ ok: false,
69
+ error: String(err),
70
+ };
71
+ }
72
+ }
73
+ function setGroupPolicyAllowlist(params) {
74
+ if (!params.cfg.channels)
75
+ return;
76
+ const section = params.cfg.channels[params.channel];
77
+ if (!section || typeof section !== "object")
78
+ return;
79
+ const topPolicy = section.groupPolicy;
80
+ if (topPolicy === "open") {
81
+ section.groupPolicy = "allowlist";
82
+ params.changes.push(`channels.${params.channel}.groupPolicy=open -> allowlist`);
83
+ params.policyFlips.add(`channels.${params.channel}.`);
84
+ }
85
+ const accounts = section.accounts;
86
+ if (!accounts || typeof accounts !== "object")
87
+ return;
88
+ for (const [accountId, accountValue] of Object.entries(accounts)) {
89
+ if (!accountId)
90
+ continue;
91
+ if (!accountValue || typeof accountValue !== "object")
92
+ continue;
93
+ const account = accountValue;
94
+ if (account.groupPolicy === "open") {
95
+ account.groupPolicy = "allowlist";
96
+ params.changes.push(`channels.${params.channel}.accounts.${accountId}.groupPolicy=open -> allowlist`);
97
+ params.policyFlips.add(`channels.${params.channel}.accounts.${accountId}.`);
98
+ }
99
+ }
100
+ }
101
+ function setWhatsAppGroupAllowFromFromStore(params) {
102
+ const section = params.cfg.channels?.whatsapp;
103
+ if (!section || typeof section !== "object")
104
+ return;
105
+ if (params.storeAllowFrom.length === 0)
106
+ return;
107
+ const maybeApply = (prefix, obj) => {
108
+ if (!params.policyFlips.has(prefix))
109
+ return;
110
+ const allowFrom = Array.isArray(obj.allowFrom) ? obj.allowFrom : [];
111
+ const groupAllowFrom = Array.isArray(obj.groupAllowFrom) ? obj.groupAllowFrom : [];
112
+ if (allowFrom.length > 0)
113
+ return;
114
+ if (groupAllowFrom.length > 0)
115
+ return;
116
+ obj.groupAllowFrom = params.storeAllowFrom;
117
+ params.changes.push(`${prefix}groupAllowFrom=pairing-store`);
118
+ };
119
+ maybeApply("channels.whatsapp.", section);
120
+ const accounts = section.accounts;
121
+ if (!accounts || typeof accounts !== "object")
122
+ return;
123
+ for (const [accountId, accountValue] of Object.entries(accounts)) {
124
+ if (!accountValue || typeof accountValue !== "object")
125
+ continue;
126
+ const account = accountValue;
127
+ maybeApply(`channels.whatsapp.accounts.${accountId}.`, account);
128
+ }
129
+ }
130
+ function applyConfigFixes(params) {
131
+ const next = structuredClone(params.cfg ?? {});
132
+ const changes = [];
133
+ const policyFlips = new Set();
134
+ if (next.logging?.redactSensitive === "off") {
135
+ next.logging = { ...next.logging, redactSensitive: "tools" };
136
+ changes.push('logging.redactSensitive=off -> "tools"');
137
+ }
138
+ for (const channel of [
139
+ "telegram",
140
+ "whatsapp",
141
+ "discord",
142
+ "signal",
143
+ "imessage",
144
+ "slack",
145
+ "msteams",
146
+ ]) {
147
+ setGroupPolicyAllowlist({ cfg: next, channel, changes, policyFlips });
148
+ }
149
+ return { cfg: next, changes, policyFlips };
150
+ }
151
+ function listDirectIncludes(parsed) {
152
+ const out = [];
153
+ const visit = (value) => {
154
+ if (!value)
155
+ return;
156
+ if (Array.isArray(value)) {
157
+ for (const item of value)
158
+ visit(item);
159
+ return;
160
+ }
161
+ if (typeof value !== "object")
162
+ return;
163
+ const rec = value;
164
+ const includeVal = rec[INCLUDE_KEY];
165
+ if (typeof includeVal === "string")
166
+ out.push(includeVal);
167
+ else if (Array.isArray(includeVal)) {
168
+ for (const item of includeVal) {
169
+ if (typeof item === "string")
170
+ out.push(item);
171
+ }
172
+ }
173
+ for (const v of Object.values(rec))
174
+ visit(v);
175
+ };
176
+ visit(parsed);
177
+ return out;
178
+ }
179
+ function resolveIncludePath(baseConfigPath, includePath) {
180
+ return path.normalize(path.isAbsolute(includePath)
181
+ ? includePath
182
+ : path.resolve(path.dirname(baseConfigPath), includePath));
183
+ }
184
+ async function collectIncludePathsRecursive(params) {
185
+ const visited = new Set();
186
+ const result = [];
187
+ const walk = async (basePath, parsed, depth) => {
188
+ if (depth > MAX_INCLUDE_DEPTH)
189
+ return;
190
+ for (const raw of listDirectIncludes(parsed)) {
191
+ const resolved = resolveIncludePath(basePath, raw);
192
+ if (visited.has(resolved))
193
+ continue;
194
+ visited.add(resolved);
195
+ result.push(resolved);
196
+ const rawText = await fs.readFile(resolved, "utf-8").catch(() => null);
197
+ if (!rawText)
198
+ continue;
199
+ const nestedParsed = (() => {
200
+ try {
201
+ return JSON5.parse(rawText);
202
+ }
203
+ catch {
204
+ return null;
205
+ }
206
+ })();
207
+ if (nestedParsed) {
208
+ // eslint-disable-next-line no-await-in-loop
209
+ await walk(resolved, nestedParsed, depth + 1);
210
+ }
211
+ }
212
+ };
213
+ await walk(params.configPath, params.parsed, 0);
214
+ return result;
215
+ }
216
+ async function chmodCredentialsAndAgentState(params) {
217
+ const credsDir = resolveOAuthDir(params.env, params.stateDir);
218
+ params.actions.push(await safeChmod({ path: credsDir, mode: 0o700, require: "dir" }));
219
+ const credsEntries = await fs.readdir(credsDir, { withFileTypes: true }).catch(() => []);
220
+ for (const entry of credsEntries) {
221
+ if (!entry.isFile())
222
+ continue;
223
+ if (!entry.name.endsWith(".json"))
224
+ continue;
225
+ const p = path.join(credsDir, entry.name);
226
+ // eslint-disable-next-line no-await-in-loop
227
+ params.actions.push(await safeChmod({ path: p, mode: 0o600, require: "file" }));
228
+ }
229
+ const ids = new Set();
230
+ ids.add(resolveDefaultAgentId(params.cfg));
231
+ const list = Array.isArray(params.cfg.agents?.list) ? params.cfg.agents?.list : [];
232
+ for (const agent of list ?? []) {
233
+ if (!agent || typeof agent !== "object")
234
+ continue;
235
+ const id = typeof agent.id === "string" ? agent.id.trim() : "";
236
+ if (id)
237
+ ids.add(id);
238
+ }
239
+ for (const agentId of ids) {
240
+ const normalizedAgentId = normalizeAgentId(agentId);
241
+ const agentRoot = path.join(params.stateDir, "agents", normalizedAgentId);
242
+ const agentDir = path.join(agentRoot, "agent");
243
+ const sessionsDir = path.join(agentRoot, "sessions");
244
+ // eslint-disable-next-line no-await-in-loop
245
+ params.actions.push(await safeChmod({ path: agentRoot, mode: 0o700, require: "dir" }));
246
+ // eslint-disable-next-line no-await-in-loop
247
+ params.actions.push(await safeChmod({ path: agentDir, mode: 0o700, require: "dir" }));
248
+ const authPath = path.join(agentDir, "auth-profiles.json");
249
+ // eslint-disable-next-line no-await-in-loop
250
+ params.actions.push(await safeChmod({ path: authPath, mode: 0o600, require: "file" }));
251
+ // eslint-disable-next-line no-await-in-loop
252
+ params.actions.push(await safeChmod({ path: sessionsDir, mode: 0o700, require: "dir" }));
253
+ const storePath = path.join(sessionsDir, "sessions.json");
254
+ // eslint-disable-next-line no-await-in-loop
255
+ params.actions.push(await safeChmod({ path: storePath, mode: 0o600, require: "file" }));
256
+ }
257
+ }
258
+ export async function fixSecurityFootguns(opts) {
259
+ const env = opts?.env ?? process.env;
260
+ const stateDir = opts?.stateDir ?? resolveStateDir(env);
261
+ const configPath = opts?.configPath ?? resolveConfigPath(env, stateDir);
262
+ const actions = [];
263
+ const errors = [];
264
+ const io = createConfigIO({ env, configPath });
265
+ const snap = await io.readConfigFileSnapshot();
266
+ if (!snap.valid) {
267
+ errors.push(...snap.issues.map((i) => `${i.path}: ${i.message}`));
268
+ }
269
+ let configWritten = false;
270
+ let changes = [];
271
+ if (snap.valid) {
272
+ const fixed = applyConfigFixes({ cfg: snap.config, env });
273
+ changes = fixed.changes;
274
+ const whatsappStoreAllowFrom = await readChannelAllowFromStore("whatsapp", env).catch(() => []);
275
+ if (whatsappStoreAllowFrom.length > 0) {
276
+ setWhatsAppGroupAllowFromFromStore({
277
+ cfg: fixed.cfg,
278
+ storeAllowFrom: whatsappStoreAllowFrom,
279
+ changes,
280
+ policyFlips: fixed.policyFlips,
281
+ });
282
+ }
283
+ if (changes.length > 0) {
284
+ try {
285
+ await io.writeConfigFile(fixed.cfg);
286
+ configWritten = true;
287
+ }
288
+ catch (err) {
289
+ errors.push(`writeConfigFile failed: ${String(err)}`);
290
+ }
291
+ }
292
+ }
293
+ actions.push(await safeChmod({ path: stateDir, mode: 0o700, require: "dir" }));
294
+ actions.push(await safeChmod({ path: configPath, mode: 0o600, require: "file" }));
295
+ if (snap.exists) {
296
+ const includePaths = await collectIncludePathsRecursive({
297
+ configPath: snap.path,
298
+ parsed: snap.parsed,
299
+ }).catch(() => []);
300
+ for (const p of includePaths) {
301
+ // eslint-disable-next-line no-await-in-loop
302
+ actions.push(await safeChmod({ path: p, mode: 0o600, require: "file" }));
303
+ }
304
+ }
305
+ await chmodCredentialsAndAgentState({ env, stateDir, cfg: snap.config ?? {}, actions }).catch((err) => {
306
+ errors.push(`chmodCredentialsAndAgentState failed: ${String(err)}`);
307
+ });
308
+ return {
309
+ ok: errors.length === 0,
310
+ stateDir,
311
+ configPath,
312
+ configWritten,
313
+ changes,
314
+ actions,
315
+ errors,
316
+ };
317
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@theaux/clawdbot",
3
- "version": "2026.1.14",
3
+ "version": "2026.1.15",
4
4
  "description": "WhatsApp gateway CLI (Baileys web) with Pi RPC agent",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -30,6 +30,7 @@
30
30
  "dist/process/**",
31
31
  "dist/plugins/**",
32
32
  "dist/sessions/**",
33
+ "dist/security/**",
33
34
  "dist/providers/**",
34
35
  "dist/signal/**",
35
36
  "dist/slack/**",