import fs from "node:fs/promises"; import type { ClawdbotConfig } from "../config/config.js"; import { createConfigIO } from "../config/config.js"; import { resolveConfigPath, resolveStateDir } from "../config/paths.js"; import { readChannelAllowFromStore } from "../pairing/pairing-store.js"; export type SecurityFixChmodAction = { kind: "chmod"; path: string; mode: number; ok: boolean; skipped?: string; error?: string; }; export type SecurityFixResult = { ok: boolean; stateDir: string; configPath: string; configWritten: boolean; changes: string[]; actions: SecurityFixChmodAction[]; errors: string[]; }; async function safeChmod(params: { path: string; mode: number; require: "dir" | "file"; }): Promise { try { const st = await fs.lstat(params.path); if (st.isSymbolicLink()) { return { kind: "chmod", path: params.path, mode: params.mode, ok: false, skipped: "symlink", }; } if (params.require === "dir" && !st.isDirectory()) { return { kind: "chmod", path: params.path, mode: params.mode, ok: false, skipped: "not-a-directory", }; } if (params.require === "file" && !st.isFile()) { return { kind: "chmod", path: params.path, mode: params.mode, ok: false, skipped: "not-a-file", }; } const current = st.mode & 0o777; if (current === params.mode) { return { kind: "chmod", path: params.path, mode: params.mode, ok: false, skipped: "already", }; } await fs.chmod(params.path, params.mode); return { kind: "chmod", path: params.path, mode: params.mode, ok: true }; } catch (err) { const code = (err as { code?: string }).code; if (code === "ENOENT") { return { kind: "chmod", path: params.path, mode: params.mode, ok: false, skipped: "missing", }; } return { kind: "chmod", path: params.path, mode: params.mode, ok: false, error: String(err), }; } } function setGroupPolicyAllowlist(params: { cfg: ClawdbotConfig; channel: string; changes: string[]; policyFlips: Set; }): void { if (!params.cfg.channels) return; const section = params.cfg.channels[params.channel as keyof ClawdbotConfig["channels"]] as | Record | undefined; if (!section || typeof section !== "object") return; const topPolicy = section.groupPolicy; if (topPolicy === "open") { section.groupPolicy = "allowlist"; params.changes.push(`channels.${params.channel}.groupPolicy=open -> allowlist`); params.policyFlips.add(`channels.${params.channel}.`); } const accounts = section.accounts; if (!accounts || typeof accounts !== "object") return; for (const [accountId, accountValue] of Object.entries(accounts)) { if (!accountId) continue; if (!accountValue || typeof accountValue !== "object") continue; const account = accountValue as Record; if (account.groupPolicy === "open") { account.groupPolicy = "allowlist"; params.changes.push( `channels.${params.channel}.accounts.${accountId}.groupPolicy=open -> allowlist`, ); params.policyFlips.add(`channels.${params.channel}.accounts.${accountId}.`); } } } function setWhatsAppGroupAllowFromFromStore(params: { cfg: ClawdbotConfig; storeAllowFrom: string[]; changes: string[]; policyFlips: Set; }): void { const section = params.cfg.channels?.whatsapp as Record | undefined; if (!section || typeof section !== "object") return; if (params.storeAllowFrom.length === 0) return; const maybeApply = (prefix: string, obj: Record) => { if (!params.policyFlips.has(prefix)) return; const allowFrom = Array.isArray(obj.allowFrom) ? obj.allowFrom : []; const groupAllowFrom = Array.isArray(obj.groupAllowFrom) ? obj.groupAllowFrom : []; if (allowFrom.length > 0) return; if (groupAllowFrom.length > 0) return; obj.groupAllowFrom = params.storeAllowFrom; params.changes.push(`${prefix}groupAllowFrom=pairing-store`); }; maybeApply("channels.whatsapp.", section); const accounts = section.accounts; if (!accounts || typeof accounts !== "object") return; for (const [accountId, accountValue] of Object.entries(accounts)) { if (!accountValue || typeof accountValue !== "object") continue; const account = accountValue as Record; maybeApply(`channels.whatsapp.accounts.${accountId}.`, account); } } function applyConfigFixes(params: { cfg: ClawdbotConfig; env: NodeJS.ProcessEnv; }): { cfg: ClawdbotConfig; changes: string[]; policyFlips: Set } { const next = structuredClone(params.cfg ?? {}); const changes: string[] = []; const policyFlips = new Set(); if (next.logging?.redactSensitive === "off") { next.logging = { ...next.logging, redactSensitive: "tools" }; changes.push('logging.redactSensitive=off -> "tools"'); } for (const channel of ["telegram", "whatsapp", "discord", "signal", "imessage", "slack", "msteams"]) { setGroupPolicyAllowlist({ cfg: next, channel, changes, policyFlips }); } return { cfg: next, changes, policyFlips }; } export async function fixSecurityFootguns(opts?: { env?: NodeJS.ProcessEnv; stateDir?: string; configPath?: string; }): Promise { const env = opts?.env ?? process.env; const stateDir = opts?.stateDir ?? resolveStateDir(env); const configPath = opts?.configPath ?? resolveConfigPath(env, stateDir); const actions: SecurityFixChmodAction[] = []; const errors: string[] = []; const io = createConfigIO({ env, configPath }); const snap = await io.readConfigFileSnapshot(); if (!snap.valid) { errors.push(...snap.issues.map((i) => `${i.path}: ${i.message}`)); } let configWritten = false; let changes: string[] = []; if (snap.valid) { const fixed = applyConfigFixes({ cfg: snap.config, env }); changes = fixed.changes; const whatsappStoreAllowFrom = await readChannelAllowFromStore("whatsapp", env).catch(() => []); if (whatsappStoreAllowFrom.length > 0) { setWhatsAppGroupAllowFromFromStore({ cfg: fixed.cfg, storeAllowFrom: whatsappStoreAllowFrom, changes, policyFlips: fixed.policyFlips, }); } if (changes.length > 0) { try { await io.writeConfigFile(fixed.cfg); configWritten = true; } catch (err) { errors.push(`writeConfigFile failed: ${String(err)}`); } } } actions.push(await safeChmod({ path: stateDir, mode: 0o700, require: "dir" })); actions.push(await safeChmod({ path: configPath, mode: 0o600, require: "file" })); return { ok: errors.length === 0, stateDir, configPath, configWritten, changes, actions, errors, }; }