diff --git a/CHANGELOG.md b/CHANGELOG.md index 89a899a26..bcb423033 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## 2026.1.15 + +### Changes +- Security: add `clawdbot security audit` (`--deep`) and surface it in `status --all` and `doctor`. +- Onboarding: add a security checkpoint prompt (docs link + sandboxing hint); require `--accept-risk` for `--non-interactive`. +- Docs: expand gateway security hardening guidance and incident response checklist. + ## 2026.1.14 ### Changes diff --git a/docs/gateway/security.md b/docs/gateway/security.md index 153fc0c59..847590fae 100644 --- a/docs/gateway/security.md +++ b/docs/gateway/security.md @@ -42,6 +42,12 @@ Plugins run **in-process** with the Gateway. Treat them as trusted code: - Prefer explicit `plugins.allow` allowlists. - Review plugin config before enabling. - Restart the Gateway after plugin changes. +- If you install plugins from npm (`clawdbot plugins install `), treat it like running untrusted code: + - The install path is `~/.clawdbot/extensions//` (or `$CLAWDBOT_STATE_DIR/extensions//`). + - Clawdbot uses `npm pack` and then runs `npm install --omit=dev` in that directory (npm lifecycle scripts can execute code during install). + - Prefer pinned, exact versions (`@scope/pkg@1.2.3`), and inspect the unpacked code on disk before enabling. + +Details: [Plugins](/plugin) ## DM access model (pairing / allowlist / open / disabled) @@ -120,6 +126,21 @@ Keep config + state private on the gateway host: `clawdbot doctor` can warn and offer to tighten these permissions. +### 0.4) Network exposure (bind + port + firewall) + +The Gateway multiplexes **WebSocket + HTTP** on a single port: +- Default: `18789` +- Config/flags/env: `gateway.port`, `--port`, `CLAWDBOT_GATEWAY_PORT` + +Bind mode controls where the Gateway listens: +- `gateway.bind: "loopback"` (default): only local clients can connect. +- Non-loopback binds (`"lan"`, `"tailnet"`, `"auto"`) expand the attack surface. Only use them with `gateway.auth` enabled and a real firewall. + +Rules of thumb: +- Prefer Tailscale Serve over LAN binds (Serve keeps the Gateway on loopback, and Tailscale handles access). +- If you must bind to LAN, firewall the port to a tight allowlist of source IPs; do not port-forward it broadly. +- Never expose the Gateway unauthenticated on `0.0.0.0`. + ### 0.5) Lock down the Gateway WebSocket (local auth) Gateway auth is **only** enforced when you set `gateway.auth`. If it’s unset, @@ -145,6 +166,16 @@ Doctor can generate one for you: `clawdbot doctor --generate-gateway-token`. Note: `gateway.remote.token` is **only** for remote CLI calls; it does not protect local WS access. +Auth modes: +- `gateway.auth.mode: "token"`: shared bearer token (recommended for most setups). +- `gateway.auth.mode: "password"`: password auth (prefer setting via env: `CLAWDBOT_GATEWAY_PASSWORD`). + +Rotation checklist (token/password): +1. Generate/set a new secret (`gateway.auth.token` or `CLAWDBOT_GATEWAY_PASSWORD`). +2. Restart the Gateway (or restart the macOS app if it supervises the Gateway). +3. Update any remote clients (`gateway.remote.token` / `.password` on machines that call into the Gateway). +4. Verify you can no longer connect with the old credentials. + ### 0.6) Tailscale Serve identity headers When `gateway.auth.allowTailscale` is `true` (default for Serve), Clawdbot @@ -159,6 +190,36 @@ you terminate TLS or proxy in front of the gateway, disable See [Tailscale](/gateway/tailscale) and [Web overview](/web). +### 0.7) Secrets on disk (what’s sensitive) + +Assume anything under `~/.clawdbot/` (or `$CLAWDBOT_STATE_DIR/`) may contain secrets or private data: + +- `clawdbot.json`: config may include tokens (gateway, remote gateway), provider settings, and allowlists. +- `credentials/**`: channel credentials (example: WhatsApp creds), pairing allowlists, legacy OAuth imports. +- `agents//agent/auth-profiles.json`: API keys + OAuth tokens (imported from legacy `credentials/oauth.json`). +- `agents//sessions/**`: session transcripts (`*.jsonl`) + routing metadata (`sessions.json`) that can contain private messages and tool output. +- `extensions/**`: installed plugins (plus their `node_modules/`). +- `sandboxes/**`: tool sandbox workspaces; can accumulate copies of files you read/write inside the sandbox. + +Hardening tips: +- Keep permissions tight (`700` on dirs, `600` on files). +- Use full-disk encryption on the gateway host. +- Prefer a dedicated OS user account for the Gateway if the host is shared. + +### 0.8) Logs + transcripts (redaction + retention) + +Logs and transcripts can leak sensitive info even when access controls are correct: +- Gateway logs may include tool summaries, errors, and URLs. +- Session transcripts can include pasted secrets, file contents, command output, and links. + +Recommendations: +- Keep tool summary redaction on (`logging.redactSensitive: "tools"`; default). +- Add custom patterns for your environment via `logging.redactPatterns` (tokens, hostnames, internal URLs). +- When sharing diagnostics, prefer `clawdbot status --all` (pasteable, secrets redacted) over raw logs. +- Prune old session transcripts and log files if you don’t need long retention. + +Details: [Logging](/gateway/logging) + ### 1) DMs: pairing by default ```json5 @@ -205,6 +266,29 @@ You can already build a read-only profile by combining: We may add a single `readOnlyMode` flag later to simplify this configuration. +### 5) Secure baseline (copy/paste) + +One “safe default” config that keeps the Gateway private, requires DM pairing, and avoids always-on group bots: + +```json5 +{ + gateway: { + mode: "local", + bind: "loopback", + port: 18789, + auth: { mode: "token", token: "your-long-random-token" } + }, + channels: { + whatsapp: { + dmPolicy: "pairing", + groups: { "*": { requireMention: true } } + } + } +} +``` + +If you want “safer by default” tool execution too, add a sandbox + deny dangerous tools for any non-owner agent (example below under “Per-agent access profiles”). + ## Sandboxing (recommended) Dedicated doc: [Sandboxing](/gateway/sandboxing) @@ -233,6 +317,9 @@ access those accounts and data. Treat browser profiles as **sensitive state**: - Prefer a dedicated profile for the agent (the default `clawd` profile). - Avoid pointing the agent at your personal daily-driver profile. - Keep host browser control disabled for sandboxed agents unless you trust them. +- Treat browser downloads as untrusted input; prefer an isolated downloads directory. +- Disable browser sync/password managers in the agent profile if possible (reduces blast radius). +- For remote gateways, assume “browser control” is equivalent to “operator access” to whatever that profile can reach. ## Per-agent access profiles (multi-agent) @@ -301,7 +388,7 @@ Common use cases: workspaceAccess: "none" }, tools: { - allow: ["sessions_list", "sessions_history", "sessions_send", "sessions_spawn", "session_status", "whatsapp", "telegram", "slack", "discord", "gateway"], + allow: ["sessions_list", "sessions_history", "sessions_send", "sessions_spawn", "session_status", "whatsapp", "telegram", "slack", "discord"], deny: ["read", "write", "edit", "apply_patch", "exec", "process", "browser", "canvas", "nodes", "cron", "gateway", "image"] } } @@ -327,11 +414,30 @@ Include security guidelines in your agent's system prompt: If your AI does something bad: -1. **Stop it:** stop the macOS app (if it’s supervising the Gateway) or terminate your `clawdbot gateway` process -2. **Check logs:** `/tmp/clawdbot/clawdbot-YYYY-MM-DD.log` (or your configured `logging.file`) -3. **Review session:** Check `~/.clawdbot/agents//sessions/` for what happened -4. **Rotate secrets:** If credentials were exposed -5. **Update rules:** Add to your security prompt +### Contain + +1. **Stop it:** stop the macOS app (if it supervises the Gateway) or terminate your `clawdbot gateway` process. +2. **Close exposure:** set `gateway.bind: "loopback"` (or disable Tailscale Funnel/Serve) until you understand what happened. +3. **Freeze access:** switch risky DMs/groups to `dmPolicy: "disabled"` / require mentions, and remove `"*"` allow-all entries if you had them. + +### Rotate (assume compromise if secrets leaked) + +1. Rotate Gateway auth (`gateway.auth.token` / `CLAWDBOT_GATEWAY_PASSWORD`) and restart. +2. Rotate remote client secrets (`gateway.remote.token` / `.password`) on any machine that can call the Gateway. +3. Rotate provider/API credentials (WhatsApp creds, Slack/Discord tokens, model/API keys in `auth-profiles.json`). + +### Audit + +1. Check Gateway logs: `/tmp/clawdbot/clawdbot-YYYY-MM-DD.log` (or `logging.file`). +2. Review the relevant transcript(s): `~/.clawdbot/agents//sessions/*.jsonl`. +3. Review recent config changes (anything that could have widened access: `gateway.bind`, `gateway.auth`, dm/group policies, `tools.elevated`, plugin changes). + +### Collect for a report + +- Timestamp, gateway host OS + Clawdbot version +- The session transcript(s) + a short log tail (after redacting) +- What the attacker sent + what the agent did +- Whether the Gateway was exposed beyond loopback (LAN/Tailscale Funnel/Serve) ## The Trust Hierarchy diff --git a/src/cli/program/register.onboard.ts b/src/cli/program/register.onboard.ts index eaf5ff0a5..5666a9f43 100644 --- a/src/cli/program/register.onboard.ts +++ b/src/cli/program/register.onboard.ts @@ -35,6 +35,11 @@ export function registerOnboardCommand(program: Command) { .option("--workspace ", "Agent workspace directory (default: ~/clawd)") .option("--reset", "Reset config + credentials + sessions + workspace before running wizard") .option("--non-interactive", "Run without prompts", false) + .option( + "--accept-risk", + "Acknowledge that agents are powerful and full system access is risky (required for --non-interactive)", + false, + ) .option("--flow ", "Wizard flow: quickstart|advanced") .option("--mode ", "Wizard mode: local|remote") .option( @@ -90,6 +95,7 @@ export function registerOnboardCommand(program: Command) { { workspace: opts.workspace as string | undefined, nonInteractive: Boolean(opts.nonInteractive), + acceptRisk: Boolean(opts.acceptRisk), flow: opts.flow as "quickstart" | "advanced" | undefined, mode: opts.mode as "local" | "remote" | undefined, authChoice: opts.authChoice as AuthChoice | undefined, diff --git a/src/cli/program/register.subclis.ts b/src/cli/program/register.subclis.ts index a1f4c6cc2..ad9284108 100644 --- a/src/cli/program/register.subclis.ts +++ b/src/cli/program/register.subclis.ts @@ -15,6 +15,7 @@ import { registerNodesCli } from "../nodes-cli.js"; import { registerPairingCli } from "../pairing-cli.js"; import { registerPluginsCli } from "../plugins-cli.js"; import { registerSandboxCli } from "../sandbox-cli.js"; +import { registerSecurityCli } from "../security-cli.js"; import { registerSkillsCli } from "../skills-cli.js"; import { registerTuiCli } from "../tui-cli.js"; import { registerUpdateCli } from "../update-cli.js"; @@ -35,6 +36,7 @@ export function registerSubCliCommands(program: Command) { registerPairingCli(program); registerPluginsCli(program); registerChannelsCli(program); + registerSecurityCli(program); registerSkillsCli(program); registerUpdateCli(program); registerPluginCliCommands(program, loadConfig()); diff --git a/src/cli/security-cli.ts b/src/cli/security-cli.ts new file mode 100644 index 000000000..e1a167e7c --- /dev/null +++ b/src/cli/security-cli.ts @@ -0,0 +1,91 @@ +import chalk from "chalk"; +import type { Command } from "commander"; + +import { loadConfig } from "../config/config.js"; +import { defaultRuntime } from "../runtime.js"; +import { runSecurityAudit } from "../security/audit.js"; +import { isRich, theme } from "../terminal/theme.js"; + +type SecurityAuditOptions = { + json?: boolean; + deep?: boolean; +}; + +function formatSummary(summary: { critical: number; warn: number; info: number }): string { + const rich = isRich(); + const c = summary.critical; + const w = summary.warn; + const i = summary.info; + const parts: string[] = []; + parts.push(rich ? theme.error(`${c} critical`) : `${c} critical`); + parts.push(rich ? theme.warn(`${w} warn`) : `${w} warn`); + parts.push(rich ? theme.muted(`${i} info`) : `${i} info`); + return parts.join(" · "); +} + +export function registerSecurityCli(program: Command) { + const security = program.command("security").description("Security tools (audit)"); + + security + .command("audit") + .description("Audit config + local state for common security foot-guns") + .option("--deep", "Attempt live Gateway probe (best-effort)", false) + .option("--json", "Print JSON", false) + .action(async (opts: SecurityAuditOptions) => { + const cfg = loadConfig(); + const report = await runSecurityAudit({ + config: cfg, + deep: Boolean(opts.deep), + includeFilesystem: true, + includeChannelSecurity: true, + }); + + if (opts.json) { + defaultRuntime.log(JSON.stringify(report, null, 2)); + return; + } + + const rich = isRich(); + const heading = (text: string) => (rich ? theme.heading(text) : text); + const muted = (text: string) => (rich ? theme.muted(text) : text); + + const lines: string[] = []; + lines.push(heading("Clawdbot security audit")); + lines.push(muted(`Summary: ${formatSummary(report.summary)}`)); + lines.push(muted(`Run deeper: clawdbot security audit --deep`)); + + const bySeverity = (sev: "critical" | "warn" | "info") => + report.findings.filter((f) => f.severity === sev); + + const render = (sev: "critical" | "warn" | "info") => { + const list = bySeverity(sev); + if (list.length === 0) return; + const label = + sev === "critical" + ? rich + ? theme.error("CRITICAL") + : "CRITICAL" + : sev === "warn" + ? rich + ? theme.warn("WARN") + : "WARN" + : rich + ? theme.muted("INFO") + : "INFO"; + lines.push(""); + lines.push(heading(label)); + for (const f of list) { + lines.push(`${chalk.gray(f.checkId)} ${f.title}`); + lines.push(` ${f.detail}`); + if (f.remediation?.trim()) lines.push(` ${muted(`Fix: ${f.remediation.trim()}`)}`); + } + }; + + render("critical"); + render("warn"); + render("info"); + + defaultRuntime.log(lines.join("\n")); + }); +} + diff --git a/src/commands/doctor-security.ts b/src/commands/doctor-security.ts index 535067647..73890941a 100644 --- a/src/commands/doctor-security.ts +++ b/src/commands/doctor-security.ts @@ -7,6 +7,7 @@ import { note } from "../terminal/note.js"; export async function noteSecurityWarnings(cfg: ClawdbotConfig) { const warnings: string[] = []; + const auditHint = `- Run: clawdbot security audit --deep`; const warnDmPolicy = async (params: { label: string; @@ -100,7 +101,7 @@ export async function noteSecurityWarnings(cfg: ClawdbotConfig) { } } - if (warnings.length > 0) { - note(warnings.join("\n"), "Security"); - } + const lines = warnings.length > 0 ? warnings : ["- No channel security warnings detected."]; + lines.push(auditHint); + note(lines.join("\n"), "Security"); } diff --git a/src/commands/onboard-types.ts b/src/commands/onboard-types.ts index 4f1aa1957..c690cfa88 100644 --- a/src/commands/onboard-types.ts +++ b/src/commands/onboard-types.ts @@ -40,6 +40,8 @@ export type OnboardOptions = { flow?: "quickstart" | "advanced"; workspace?: string; nonInteractive?: boolean; + /** Required for non-interactive onboarding; skips the interactive risk prompt when true. */ + acceptRisk?: boolean; reset?: boolean; authChoice?: AuthChoice; /** Used when `authChoice=token` in non-interactive mode. */ diff --git a/src/commands/onboard.ts b/src/commands/onboard.ts index 4a430f2a3..2695c14ee 100644 --- a/src/commands/onboard.ts +++ b/src/commands/onboard.ts @@ -13,6 +13,18 @@ export async function onboardCommand(opts: OnboardOptions, runtime: RuntimeEnv = const authChoice = opts.authChoice === "oauth" ? ("setup-token" as const) : opts.authChoice; const normalizedOpts = authChoice === opts.authChoice ? opts : { ...opts, authChoice }; + if (normalizedOpts.nonInteractive && normalizedOpts.acceptRisk !== true) { + runtime.error( + [ + "Non-interactive onboarding requires explicit risk acknowledgement.", + "Read: https://docs.clawd.bot/security", + "Re-run with: clawdbot onboard --non-interactive --accept-risk ...", + ].join("\n"), + ); + runtime.exit(1); + return; + } + if (normalizedOpts.reset) { const snapshot = await readConfigFileSnapshot(); const baseConfig = snapshot.valid ? snapshot.config : {}; diff --git a/src/commands/status-all.ts b/src/commands/status-all.ts index d3f89fdeb..55e941059 100644 --- a/src/commands/status-all.ts +++ b/src/commands/status-all.ts @@ -338,9 +338,10 @@ export async function statusAllCommand( Item: "Gateway", Value: `${gatewayMode}${remoteUrlMissing ? " (remote.url missing)" : ""} · ${gatewayTarget} (${connection.urlSource}) · ${gatewayStatus}${gatewayAuth}`, }, + { Item: "Security", Value: "Run: clawdbot security audit --deep" }, gatewaySelfLine - ? { Item: "Gateway self", Value: gatewaySelfLine } - : { Item: "Gateway self", Value: "unknown" }, + ? { Item: "Gateway self", Value: gatewaySelfLine } + : { Item: "Gateway self", Value: "unknown" }, daemon ? { Item: "Daemon", diff --git a/src/security/audit.test.ts b/src/security/audit.test.ts new file mode 100644 index 000000000..47925baeb --- /dev/null +++ b/src/security/audit.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, it } from "vitest"; + +import type { ClawdbotConfig } from "../config/config.js"; +import { runSecurityAudit } from "./audit.js"; + +describe("security audit", () => { + it("flags non-loopback bind without auth as critical", async () => { + const cfg: ClawdbotConfig = { + gateway: { + bind: "lan", + auth: {}, + }, + }; + + const res = await runSecurityAudit({ + config: cfg, + includeFilesystem: false, + includeChannelSecurity: false, + }); + + expect( + res.findings.some((f) => f.checkId === "gateway.bind_no_auth" && f.severity === "critical"), + ).toBe(true); + }); + + it("flags logging.redactSensitive=off", async () => { + const cfg: ClawdbotConfig = { + logging: { redactSensitive: "off" }, + }; + + const res = await runSecurityAudit({ + config: cfg, + includeFilesystem: false, + includeChannelSecurity: false, + }); + + expect(res.findings).toEqual( + expect.arrayContaining([ + expect.objectContaining({ checkId: "logging.redact_off", severity: "warn" }), + ]), + ); + }); + + it("flags tools.elevated allowFrom wildcard as critical", async () => { + const cfg: ClawdbotConfig = { + tools: { + elevated: { + allowFrom: { whatsapp: ["*"] }, + }, + }, + }; + + const res = await runSecurityAudit({ + config: cfg, + includeFilesystem: false, + includeChannelSecurity: false, + }); + + expect(res.findings).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + checkId: "tools.elevated.allowFrom.whatsapp.wildcard", + severity: "critical", + }), + ]), + ); + }); + + it("adds a warning when deep probe fails", async () => { + const cfg: ClawdbotConfig = { gateway: { mode: "local" } }; + + const res = await runSecurityAudit({ + config: cfg, + deep: true, + deepTimeoutMs: 50, + includeFilesystem: false, + includeChannelSecurity: false, + probeGatewayFn: async () => ({ + ok: false, + url: "ws://127.0.0.1:18789", + connectLatencyMs: null, + error: "connect failed", + close: null, + health: null, + status: null, + presence: null, + configSnapshot: null, + }), + }); + + expect(res.findings).toEqual( + expect.arrayContaining([ + expect.objectContaining({ checkId: "gateway.probe_failed", severity: "warn" }), + ]), + ); + }); +}); diff --git a/src/security/audit.ts b/src/security/audit.ts new file mode 100644 index 000000000..f421b11a5 --- /dev/null +++ b/src/security/audit.ts @@ -0,0 +1,533 @@ +import fs from "node:fs/promises"; + +import { listChannelPlugins } from "../channels/plugins/index.js"; +import { resolveChannelDefaultAccountId } from "../channels/plugins/helpers.js"; +import type { ChannelId } from "../channels/plugins/types.js"; +import type { ClawdbotConfig } from "../config/config.js"; +import { CONFIG_PATH_CLAWDBOT } from "../config/config.js"; +import { resolveGatewayAuth } from "../gateway/auth.js"; +import { buildGatewayConnectionDetails } from "../gateway/call.js"; +import { probeGateway } from "../gateway/probe.js"; +import { CONFIG_DIR } from "../utils.js"; + +export type SecurityAuditSeverity = "info" | "warn" | "critical"; + +export type SecurityAuditFinding = { + checkId: string; + severity: SecurityAuditSeverity; + title: string; + detail: string; + remediation?: string; +}; + +export type SecurityAuditSummary = { + critical: number; + warn: number; + info: number; +}; + +export type SecurityAuditReport = { + ts: number; + summary: SecurityAuditSummary; + findings: SecurityAuditFinding[]; + deep?: { + gateway?: { + attempted: boolean; + url: string | null; + ok: boolean; + error: string | null; + close?: { code: number; reason: string } | null; + }; + }; +}; + +export type SecurityAuditOptions = { + config: ClawdbotConfig; + deep?: boolean; + includeFilesystem?: boolean; + includeChannelSecurity?: boolean; + /** Override where to check state (default: CONFIG_DIR). */ + stateDir?: string; + /** Override config path check (default: CONFIG_PATH_CLAWDBOT). */ + configPath?: string; + /** Time limit for deep gateway probe. */ + deepTimeoutMs?: number; + /** Dependency injection for tests. */ + plugins?: ReturnType; + /** Dependency injection for tests. */ + probeGatewayFn?: typeof probeGateway; +}; + +function countBySeverity(findings: SecurityAuditFinding[]): SecurityAuditSummary { + let critical = 0; + let warn = 0; + let info = 0; + for (const f of findings) { + if (f.severity === "critical") critical += 1; + else if (f.severity === "warn") warn += 1; + else info += 1; + } + return { critical, warn, info }; +} + +function normalizeAllowFromList(list: Array | undefined | null): string[] { + if (!Array.isArray(list)) return []; + return list.map((v) => String(v).trim()).filter(Boolean); +} + +function classifyChannelWarningSeverity(message: string): SecurityAuditSeverity { + const s = message.toLowerCase(); + if (s.includes('dms: open') || s.includes('grouppolicy="open"') || s.includes('dmpolicy="open"')) { + return "critical"; + } + if (s.includes("allows any") || s.includes("anyone can dm") || s.includes("public")) { + return "critical"; + } + if (s.includes("locked") || s.includes("disabled")) { + return "info"; + } + return "warn"; +} + +async function safeStat(targetPath: string): Promise<{ + ok: boolean; + isSymlink: boolean; + isDir: boolean; + mode: number | null; + uid: number | null; + gid: number | null; + error?: string; +}> { + try { + const lst = await fs.lstat(targetPath); + return { + ok: true, + isSymlink: lst.isSymbolicLink(), + isDir: lst.isDirectory(), + mode: typeof lst.mode === "number" ? lst.mode : null, + uid: typeof lst.uid === "number" ? lst.uid : null, + gid: typeof lst.gid === "number" ? lst.gid : null, + }; + } catch (err) { + return { + ok: false, + isSymlink: false, + isDir: false, + mode: null, + uid: null, + gid: null, + error: String(err), + }; + } +} + +function modeBits(mode: number | null): number | null { + if (mode == null) return null; + return mode & 0o777; +} + +function formatOctal(bits: number | null): string { + if (bits == null) return "unknown"; + return bits.toString(8).padStart(3, "0"); +} + +function isWorldWritable(bits: number | null): boolean { + if (bits == null) return false; + return (bits & 0o002) !== 0; +} + +function isGroupWritable(bits: number | null): boolean { + if (bits == null) return false; + return (bits & 0o020) !== 0; +} + +function isWorldReadable(bits: number | null): boolean { + if (bits == null) return false; + return (bits & 0o004) !== 0; +} + +function isGroupReadable(bits: number | null): boolean { + if (bits == null) return false; + return (bits & 0o040) !== 0; +} + +async function collectFilesystemFindings(params: { + stateDir: string; + configPath: string; +}): Promise { + const findings: SecurityAuditFinding[] = []; + + const stateDirStat = await safeStat(params.stateDir); + if (stateDirStat.ok) { + const bits = modeBits(stateDirStat.mode); + if (stateDirStat.isSymlink) { + findings.push({ + checkId: "fs.state_dir.symlink", + severity: "warn", + title: "State dir is a symlink", + detail: `${params.stateDir} is a symlink; treat this as an extra trust boundary.`, + }); + } + if (isWorldWritable(bits)) { + findings.push({ + checkId: "fs.state_dir.perms_world_writable", + severity: "critical", + title: "State dir is world-writable", + detail: `${params.stateDir} mode=${formatOctal(bits)}; other users can write into your Clawdbot state.`, + remediation: `chmod 700 ${params.stateDir}`, + }); + } else if (isGroupWritable(bits)) { + findings.push({ + checkId: "fs.state_dir.perms_group_writable", + severity: "warn", + title: "State dir is group-writable", + detail: `${params.stateDir} mode=${formatOctal(bits)}; group users can write into your Clawdbot state.`, + remediation: `chmod 700 ${params.stateDir}`, + }); + } else if (isGroupReadable(bits) || isWorldReadable(bits)) { + findings.push({ + checkId: "fs.state_dir.perms_readable", + severity: "warn", + title: "State dir is readable by others", + detail: `${params.stateDir} mode=${formatOctal(bits)}; consider restricting to 700.`, + remediation: `chmod 700 ${params.stateDir}`, + }); + } + } + + const configStat = await safeStat(params.configPath); + if (configStat.ok) { + const bits = modeBits(configStat.mode); + if (configStat.isSymlink) { + findings.push({ + checkId: "fs.config.symlink", + severity: "warn", + title: "Config file is a symlink", + detail: `${params.configPath} is a symlink; make sure you trust its target.`, + }); + } + if (isWorldWritable(bits) || isGroupWritable(bits)) { + findings.push({ + checkId: "fs.config.perms_writable", + severity: "critical", + title: "Config file is writable by others", + detail: `${params.configPath} mode=${formatOctal(bits)}; another user could change gateway/auth/tool policies.`, + remediation: `chmod 600 ${params.configPath}`, + }); + } else if (isWorldReadable(bits)) { + findings.push({ + checkId: "fs.config.perms_world_readable", + severity: "critical", + title: "Config file is world-readable", + detail: `${params.configPath} mode=${formatOctal(bits)}; config can contain tokens and private settings.`, + remediation: `chmod 600 ${params.configPath}`, + }); + } else if (isGroupReadable(bits)) { + findings.push({ + checkId: "fs.config.perms_group_readable", + severity: "warn", + title: "Config file is group-readable", + detail: `${params.configPath} mode=${formatOctal(bits)}; config can contain tokens and private settings.`, + remediation: `chmod 600 ${params.configPath}`, + }); + } + } + + return findings; +} + +function collectGatewayConfigFindings(cfg: ClawdbotConfig): SecurityAuditFinding[] { + const findings: SecurityAuditFinding[] = []; + + const bind = typeof cfg.gateway?.bind === "string" ? cfg.gateway.bind : "loopback"; + const tailscaleMode = cfg.gateway?.tailscale?.mode ?? "off"; + const auth = resolveGatewayAuth({ authConfig: cfg.gateway?.auth, tailscaleMode }); + + if (bind !== "loopback" && auth.mode === "none") { + findings.push({ + checkId: "gateway.bind_no_auth", + severity: "critical", + title: "Gateway binds beyond loopback without auth", + detail: `gateway.bind="${bind}" but no gateway.auth token/password is configured.`, + remediation: `Set gateway.auth (token recommended) or bind to loopback.`, + }); + } + + if (tailscaleMode === "funnel") { + findings.push({ + checkId: "gateway.tailscale_funnel", + severity: "critical", + title: "Tailscale Funnel exposure enabled", + detail: `gateway.tailscale.mode="funnel" exposes the Gateway publicly; keep auth strict and treat it as internet-facing.`, + remediation: `Prefer tailscale.mode="serve" (tailnet-only) or set tailscale.mode="off".`, + }); + } else if (tailscaleMode === "serve") { + findings.push({ + checkId: "gateway.tailscale_serve", + severity: "info", + title: "Tailscale Serve exposure enabled", + detail: `gateway.tailscale.mode="serve" exposes the Gateway to your tailnet (loopback behind Tailscale).`, + }); + } + + const token = + typeof auth.token === "string" && auth.token.trim().length > 0 ? auth.token.trim() : null; + if (auth.mode === "token" && token && token.length < 24) { + findings.push({ + checkId: "gateway.token_too_short", + severity: "warn", + title: "Gateway token looks short", + detail: `gateway auth token is ${token.length} chars; prefer a long random token.`, + }); + } + + return findings; +} + +function collectLoggingFindings(cfg: ClawdbotConfig): SecurityAuditFinding[] { + const redact = cfg.logging?.redactSensitive; + if (redact !== "off") return []; + return [ + { + checkId: "logging.redact_off", + severity: "warn", + title: "Tool summary redaction is disabled", + detail: `logging.redactSensitive="off" can leak secrets into logs and status output.`, + remediation: `Set logging.redactSensitive="tools".`, + }, + ]; +} + +function collectElevatedFindings(cfg: ClawdbotConfig): SecurityAuditFinding[] { + const findings: SecurityAuditFinding[] = []; + const enabled = cfg.tools?.elevated?.enabled; + const allowFrom = cfg.tools?.elevated?.allowFrom ?? {}; + const anyAllowFromKeys = Object.keys(allowFrom).length > 0; + + if (enabled === false) return findings; + if (!anyAllowFromKeys) return findings; + + for (const [provider, list] of Object.entries(allowFrom)) { + const normalized = normalizeAllowFromList(list); + if (normalized.includes("*")) { + findings.push({ + checkId: `tools.elevated.allowFrom.${provider}.wildcard`, + severity: "critical", + title: "Elevated exec allowlist contains wildcard", + detail: `tools.elevated.allowFrom.${provider} includes "*" which effectively approves everyone on that channel for elevated mode.`, + }); + } else if (normalized.length > 25) { + findings.push({ + checkId: `tools.elevated.allowFrom.${provider}.large`, + severity: "warn", + title: "Elevated exec allowlist is large", + detail: `tools.elevated.allowFrom.${provider} has ${normalized.length} entries; consider tightening elevated access.`, + }); + } + } + + return findings; +} + +async function collectChannelSecurityFindings(params: { + cfg: ClawdbotConfig; + plugins: ReturnType; +}): Promise { + const findings: SecurityAuditFinding[] = []; + + const warnDmPolicy = async (input: { + label: string; + provider: ChannelId; + dmPolicy: string; + allowFrom?: Array | null; + policyPath?: string; + allowFromPath: string; + }) => { + const policyPath = input.policyPath ?? `${input.allowFromPath}policy`; + const configAllowFrom = normalizeAllowFromList(input.allowFrom); + const hasWildcard = configAllowFrom.includes("*"); + + if (input.dmPolicy === "open") { + const allowFromKey = `${input.allowFromPath}allowFrom`; + findings.push({ + checkId: `channels.${input.provider}.dm.open`, + severity: "critical", + title: `${input.label} DMs are open`, + detail: `${policyPath}="open" allows anyone to DM the bot.`, + remediation: `Use pairing/allowlist; if you really need open DMs, ensure ${allowFromKey} includes "*".`, + }); + if (!hasWildcard) { + findings.push({ + checkId: `channels.${input.provider}.dm.open_invalid`, + severity: "warn", + title: `${input.label} DM config looks inconsistent`, + detail: `"open" requires ${allowFromKey} to include "*".`, + }); + } + return; + } + + if (input.dmPolicy === "disabled") { + findings.push({ + checkId: `channels.${input.provider}.dm.disabled`, + severity: "info", + title: `${input.label} DMs are disabled`, + detail: `${policyPath}="disabled" ignores inbound DMs.`, + }); + } + }; + + for (const plugin of params.plugins) { + if (!plugin.security) continue; + const accountIds = plugin.config.listAccountIds(params.cfg); + const defaultAccountId = resolveChannelDefaultAccountId({ + plugin, + cfg: params.cfg, + accountIds, + }); + const account = plugin.config.resolveAccount(params.cfg, defaultAccountId); + const enabled = plugin.config.isEnabled ? plugin.config.isEnabled(account, params.cfg) : true; + if (!enabled) continue; + const configured = plugin.config.isConfigured + ? await plugin.config.isConfigured(account, params.cfg) + : true; + if (!configured) continue; + + const dmPolicy = plugin.security.resolveDmPolicy?.({ + cfg: params.cfg, + accountId: defaultAccountId, + account, + }); + if (dmPolicy) { + await warnDmPolicy({ + label: plugin.meta.label ?? plugin.id, + provider: plugin.id, + dmPolicy: dmPolicy.policy, + allowFrom: dmPolicy.allowFrom, + policyPath: dmPolicy.policyPath, + allowFromPath: dmPolicy.allowFromPath, + }); + } + + if (plugin.security.collectWarnings) { + const warnings = await plugin.security.collectWarnings({ + cfg: params.cfg, + accountId: defaultAccountId, + account, + }); + for (const message of warnings ?? []) { + const trimmed = String(message).trim(); + if (!trimmed) continue; + findings.push({ + checkId: `channels.${plugin.id}.warning.${findings.length + 1}`, + severity: classifyChannelWarningSeverity(trimmed), + title: `${plugin.meta.label ?? plugin.id} security warning`, + detail: trimmed.replace(/^-\s*/, ""), + }); + } + } + } + + return findings; +} + +async function maybeProbeGateway(params: { + cfg: ClawdbotConfig; + timeoutMs: number; + probe: typeof probeGateway; +}): Promise { + const connection = buildGatewayConnectionDetails({ config: params.cfg }); + const url = connection.url; + const isRemoteMode = params.cfg.gateway?.mode === "remote"; + const remoteUrlRaw = + typeof params.cfg.gateway?.remote?.url === "string" ? params.cfg.gateway.remote.url.trim() : ""; + const remoteUrlMissing = isRemoteMode && !remoteUrlRaw; + + const resolveAuth = (mode: "local" | "remote") => { + const authToken = params.cfg.gateway?.auth?.token; + const authPassword = params.cfg.gateway?.auth?.password; + const remote = params.cfg.gateway?.remote; + const token = + mode === "remote" + ? typeof remote?.token === "string" && remote.token.trim() + ? remote.token.trim() + : undefined + : process.env.CLAWDBOT_GATEWAY_TOKEN?.trim() || + (typeof authToken === "string" && authToken.trim() ? authToken.trim() : undefined); + const password = + process.env.CLAWDBOT_GATEWAY_PASSWORD?.trim() || + (mode === "remote" + ? typeof remote?.password === "string" && remote.password.trim() + ? remote.password.trim() + : undefined + : typeof authPassword === "string" && authPassword.trim() + ? authPassword.trim() + : undefined); + return { token, password }; + }; + + const auth = remoteUrlMissing ? resolveAuth("local") : resolveAuth("remote"); + const res = await params.probe({ url, auth, timeoutMs: params.timeoutMs }).catch((err) => ({ + ok: false, + url, + connectLatencyMs: null, + error: String(err), + close: null, + health: null, + status: null, + presence: null, + configSnapshot: null, + })); + + return { + gateway: { + attempted: true, + url, + ok: res.ok, + error: res.ok ? null : res.error, + close: res.close ? { code: res.close.code, reason: res.close.reason } : null, + }, + }; +} + +export async function runSecurityAudit(opts: SecurityAuditOptions): Promise { + const findings: SecurityAuditFinding[] = []; + const cfg = opts.config; + const stateDir = opts.stateDir ?? CONFIG_DIR; + const configPath = opts.configPath ?? CONFIG_PATH_CLAWDBOT; + + findings.push(...collectGatewayConfigFindings(cfg)); + findings.push(...collectLoggingFindings(cfg)); + findings.push(...collectElevatedFindings(cfg)); + + if (opts.includeFilesystem !== false) { + findings.push(...(await collectFilesystemFindings({ stateDir, configPath }))); + } + + if (opts.includeChannelSecurity !== false) { + const plugins = opts.plugins ?? listChannelPlugins(); + findings.push(...(await collectChannelSecurityFindings({ cfg, plugins }))); + } + + const deep = + opts.deep === true + ? await maybeProbeGateway({ + cfg, + timeoutMs: Math.max(250, opts.deepTimeoutMs ?? 5000), + probe: opts.probeGatewayFn ?? probeGateway, + }) + : undefined; + + if (deep?.gateway?.attempted && deep.gateway.ok === false) { + findings.push({ + checkId: "gateway.probe_failed", + severity: "warn", + title: "Gateway probe failed (deep)", + detail: deep.gateway.error ?? "gateway unreachable", + remediation: `Run "clawdbot status --all" to debug connectivity/auth, then re-run "clawdbot security audit --deep".`, + }); + } + + const summary = countBySeverity(findings); + return { ts: Date.now(), summary, findings, deep }; +} diff --git a/src/wizard/onboarding.test.ts b/src/wizard/onboarding.test.ts index 7d41515a9..b806b50f3 100644 --- a/src/wizard/onboarding.test.ts +++ b/src/wizard/onboarding.test.ts @@ -110,6 +110,7 @@ describe("runOnboardingWizard", () => { await expect( runOnboardingWizard( { + acceptRisk: true, flow: "quickstart", authChoice: "skip", installDaemon: false, @@ -150,6 +151,7 @@ describe("runOnboardingWizard", () => { await runOnboardingWizard( { + acceptRisk: true, flow: "quickstart", authChoice: "skip", installDaemon: false, @@ -201,6 +203,7 @@ describe("runOnboardingWizard", () => { await runOnboardingWizard( { + acceptRisk: true, flow: "quickstart", mode: "local", workspace: workspaceDir, diff --git a/src/wizard/onboarding.ts b/src/wizard/onboarding.ts index e51182729..0a01bbc6b 100644 --- a/src/wizard/onboarding.ts +++ b/src/wizard/onboarding.ts @@ -39,7 +39,35 @@ import { resolveUserPath } from "../utils.js"; import { finalizeOnboardingWizard } from "./onboarding.finalize.js"; import { configureGatewayForOnboarding } from "./onboarding.gateway-config.js"; import type { QuickstartGatewayDefaults, WizardFlow } from "./onboarding.types.js"; -import type { WizardPrompter } from "./prompts.js"; +import { WizardCancelledError, type WizardPrompter } from "./prompts.js"; + +async function requireRiskAcknowledgement(params: { + opts: OnboardOptions; + prompter: WizardPrompter; +}) { + if (params.opts.acceptRisk === true) return; + + await params.prompter.note( + [ + "Please read: https://docs.clawd.bot/security", + "", + "Clawdbot agents can run commands, read/write files, and act through any tools you enable. They can only send messages on channels you configure (for example, an account you log in on this machine, or a bot account like Slack/Discord).", + "", + "If you’re new to this, start with the sandbox and least privilege. It helps limit what an agent can do if it’s tricked or makes a mistake.", + "Learn more: https://docs.clawd.bot/sandboxing", + ].join("\n"), + "Security", + ); + + const ok = await params.prompter.confirm({ + message: + "I understand this is powerful and inherently risky. Continue?", + initialValue: false, + }); + if (!ok) { + throw new WizardCancelledError("risk not accepted"); + } +} export async function runOnboardingWizard( opts: OnboardOptions, @@ -48,6 +76,7 @@ export async function runOnboardingWizard( ) { printWizardHeader(runtime); await prompter.intro("Clawdbot onboarding"); + await requireRiskAcknowledgement({ opts, prompter }); const snapshot = await readConfigFileSnapshot(); let baseConfig: ClawdbotConfig = snapshot.valid ? snapshot.config : {};