diff --git a/src/commands/status.command.ts b/src/commands/status.command.ts index 8a8bcf50e..c17a9e4e8 100644 --- a/src/commands/status.command.ts +++ b/src/commands/status.command.ts @@ -4,6 +4,7 @@ import { buildGatewayConnectionDetails, callGateway } from "../gateway/call.js"; import { info } from "../globals.js"; import { formatUsageReportLines, loadProviderUsageSummary } from "../infra/provider-usage.js"; import type { RuntimeEnv } from "../runtime.js"; +import { runSecurityAudit } from "../security/audit.js"; import { renderTable } from "../terminal/table.js"; import { theme } from "../terminal/theme.js"; import { formatHealthChannelLines, type HealthSummary } from "./health.js"; @@ -61,6 +62,21 @@ export async function statusCommand( summary, } = scan; + const securityAudit = await withProgress( + { + label: "Running security audit…", + indeterminate: true, + enabled: opts.json !== true, + }, + async () => + await runSecurityAudit({ + config: cfg, + deep: false, + includeFilesystem: true, + includeChannelSecurity: true, + }), + ); + const usage = opts.usage ? await withProgress( { @@ -104,6 +120,7 @@ export async function statusCommand( error: gatewayProbe?.error ?? null, }, agents: agentStatus, + securityAudit, ...(health || usage ? { health, usage } : {}), }, null, @@ -236,6 +253,44 @@ export async function statusCommand( }).trimEnd(), ); + runtime.log(""); + runtime.log(theme.heading("Security audit")); + const fmtSummary = (value: { critical: number; warn: number; info: number }) => { + const parts = [ + theme.error(`${value.critical} critical`), + theme.warn(`${value.warn} warn`), + theme.muted(`${value.info} info`), + ]; + return parts.join(" · "); + }; + runtime.log(theme.muted(`Summary: ${fmtSummary(securityAudit.summary)}`)); + const importantFindings = securityAudit.findings.filter( + (f) => f.severity === "critical" || f.severity === "warn", + ); + if (importantFindings.length === 0) { + runtime.log(theme.muted("No critical or warn findings detected.")); + } else { + const severityLabel = (sev: "critical" | "warn" | "info") => { + if (sev === "critical") return theme.error("CRITICAL"); + if (sev === "warn") return theme.warn("WARN"); + return theme.muted("INFO"); + }; + const sevRank = (sev: "critical" | "warn" | "info") => + sev === "critical" ? 0 : sev === "warn" ? 1 : 2; + const sorted = [...importantFindings].sort((a, b) => sevRank(a.severity) - sevRank(b.severity)); + const shown = sorted.slice(0, 6); + for (const f of shown) { + runtime.log(` ${severityLabel(f.severity)} ${f.title}`); + runtime.log(` ${shortenText(f.detail.replaceAll("\n", " "), 160)}`); + if (f.remediation?.trim()) runtime.log(` ${theme.muted(`Fix: ${f.remediation.trim()}`)}`); + } + if (sorted.length > shown.length) { + runtime.log(theme.muted(`… +${sorted.length - shown.length} more`)); + } + } + runtime.log(theme.muted("Full report: clawdbot security audit")); + runtime.log(theme.muted("Deep probe: clawdbot security audit --deep")); + runtime.log(""); runtime.log(theme.heading("Channels")); const channelIssuesByChannel = (() => { diff --git a/src/commands/status.test.ts b/src/commands/status.test.ts index 563ae2e6d..bddd74ec8 100644 --- a/src/commands/status.test.ts +++ b/src/commands/status.test.ts @@ -32,6 +32,37 @@ const mocks = vi.hoisted(() => ({ configSnapshot: null, }), callGateway: vi.fn().mockResolvedValue({}), + runSecurityAudit: vi.fn().mockResolvedValue({ + ts: 0, + summary: { critical: 1, warn: 1, info: 2 }, + findings: [ + { + checkId: "test.critical", + severity: "critical", + title: "Test critical finding", + detail: "Something is very wrong\nbut on two lines", + remediation: "Do the thing", + }, + { + checkId: "test.warn", + severity: "warn", + title: "Test warning finding", + detail: "Something is maybe wrong", + }, + { + checkId: "test.info", + severity: "info", + title: "Test info finding", + detail: "FYI only", + }, + { + checkId: "test.info2", + severity: "info", + title: "Another info finding", + detail: "More FYI", + }, + ], + }), })); vi.mock("../config/sessions.js", () => ({ @@ -185,6 +216,9 @@ vi.mock("../daemon/service.js", () => ({ }), }), })); +vi.mock("../security/audit.js", () => ({ + runSecurityAudit: mocks.runSecurityAudit, +})); import { statusCommand } from "./status.js"; @@ -206,6 +240,8 @@ describe("statusCommand", () => { expect(payload.sessions.recent[0].percentUsed).toBe(50); expect(payload.sessions.recent[0].remainingTokens).toBe(5000); expect(payload.sessions.recent[0].flags).toContain("verbose:on"); + expect(payload.securityAudit.summary.critical).toBe(1); + expect(payload.securityAudit.summary.warn).toBe(1); }); it("prints formatted lines otherwise", async () => { @@ -214,6 +250,9 @@ describe("statusCommand", () => { const logs = (runtime.log as vi.Mock).mock.calls.map((c) => String(c[0])); expect(logs.some((l) => l.includes("Clawdbot status"))).toBe(true); expect(logs.some((l) => l.includes("Overview"))).toBe(true); + expect(logs.some((l) => l.includes("Security audit"))).toBe(true); + expect(logs.some((l) => l.includes("Summary:"))).toBe(true); + expect(logs.some((l) => l.includes("CRITICAL"))).toBe(true); expect(logs.some((l) => l.includes("Dashboard"))).toBe(true); expect(logs.some((l) => l.includes("macos 14.0 (arm64)"))).toBe(true); expect(logs.some((l) => l.includes("Channels"))).toBe(true);