feat: add security audit + onboarding checkpoint

This commit is contained in:
Peter Steinberger
2026-01-15 01:25:11 +00:00
parent c91c85532a
commit c2a4f256c8
13 changed files with 902 additions and 12 deletions

View File

@@ -35,6 +35,11 @@ export function registerOnboardCommand(program: Command) {
.option("--workspace <dir>", "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 <flow>", "Wizard flow: quickstart|advanced")
.option("--mode <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,

View File

@@ -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());

91
src/cli/security-cli.ts Normal file
View File

@@ -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"));
});
}

View File

@@ -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");
}

View File

@@ -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. */

View File

@@ -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 : {};

View File

@@ -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",

View File

@@ -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" }),
]),
);
});
});

533
src/security/audit.ts Normal file
View File

@@ -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<typeof listChannelPlugins>;
/** 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<string | number> | 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<SecurityAuditFinding[]> {
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<typeof listChannelPlugins>;
}): Promise<SecurityAuditFinding[]> {
const findings: SecurityAuditFinding[] = [];
const warnDmPolicy = async (input: {
label: string;
provider: ChannelId;
dmPolicy: string;
allowFrom?: Array<string | number> | 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<SecurityAuditReport["deep"]> {
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<SecurityAuditReport> {
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 };
}

View File

@@ -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,

View File

@@ -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 youre new to this, start with the sandbox and least privilege. It helps limit what an agent can do if its 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 : {};