diff --git a/src/agents/skills/workspace.ts b/src/agents/skills/workspace.ts index 80624a30139..0ff326efd87 100644 --- a/src/agents/skills/workspace.ts +++ b/src/agents/skills/workspace.ts @@ -21,6 +21,7 @@ import { } from "./frontmatter.js"; import { resolvePluginSkillDirs } from "./plugin-skills.js"; import { serializeByKey } from "./serialize.js"; +import { scanSource } from "../../security/skill-scanner.js"; import type { ParsedSkillFrontmatter, SkillEligibilityContext, @@ -512,6 +513,12 @@ function loadSkillEntries( let frontmatter: ParsedSkillFrontmatter = {}; try { const raw = fs.readFileSync(skill.filePath, "utf-8"); + // SECURITY: scan skill source for malicious patterns before loading + const findings = scanSource(raw, skill.filePath); + const critical = findings.filter((f) => f.severity === "critical"); + if (critical.length > 0) { + skillsLogger.warn(`[security] Skill "${skill.name}" has ${critical.length} critical finding(s): ${critical.map((f) => f.ruleId).join(", ")}`); + } frontmatter = parseFrontmatter(raw); } catch { // ignore malformed skills diff --git a/src/commands/doctor-verification-debt.ts b/src/commands/doctor-verification-debt.ts new file mode 100644 index 00000000000..028be8c4b0f --- /dev/null +++ b/src/commands/doctor-verification-debt.ts @@ -0,0 +1,61 @@ +import type { OpenClawConfig } from "../config/config.js"; +import type { DoctorCommandOptions } from "./doctor.js"; +import { note } from "../terminal/note.js"; +import { resolveDefaultAgentId } from "../config/agent-id.js"; +import { resolveAgentWorkspaceDir } from "../config/paths.js"; +import { + loadVerificationDebt, + calculateDebtScore, + getDebtSummary, +} from "../security/verification-debt.js"; + +export async function noteVerificationDebt( + cfg: OpenClawConfig, + options: DoctorCommandOptions, +): Promise { + const workspaceDir = resolveAgentWorkspaceDir(cfg, resolveDefaultAgentId(cfg)); + + try { + const state = await loadVerificationDebt({ workspaceDir }); + const score = calculateDebtScore(state); + const summary = getDebtSummary(state); + + if (summary.unresolved === 0) { + note("✓ No verification debt — all security checks up to date.", "Security"); + return; + } + + const lines: string[] = []; + lines.push(`Verification Debt Score: ${score}`); + lines.push(""); + lines.push(`Unresolved: ${summary.unresolved} / ${summary.total}`); + lines.push(""); + + if (summary.highRisk.length > 0) { + lines.push("⚠️ High-risk items (score ≥7):"); + for (const item of summary.highRisk.slice(0, 5)) { + const ageDays = Math.floor((Date.now() - item.skippedAt) / (24 * 60 * 60 * 1000)); + lines.push(` - [${item.category}] ${item.description} (risk: ${item.riskScore}, ${ageDays}d)`); + } + if (summary.highRisk.length > 5) { + lines.push(` ... and ${summary.highRisk.length - 5} more`); + } + lines.push(""); + } + + lines.push("By category:"); + for (const [cat, count] of Object.entries(summary.byCategory)) { + if (count > 0) { + lines.push(` - ${cat}: ${count}`); + } + } + + lines.push(""); + lines.push("Run 'openclaw security audit --deep' to address security debt."); + + const severity = score >= 20 ? "error" : score >= 10 ? "warn" : "info"; + note(lines.join("\n"), "Security", severity); + } catch { + // Debt file doesn't exist yet — that's fine, first run will create it + } +} diff --git a/src/commands/doctor.ts b/src/commands/doctor.ts index 252b44efaca..34b0888abb8 100644 --- a/src/commands/doctor.ts +++ b/src/commands/doctor.ts @@ -60,6 +60,7 @@ import { import { maybeRepairUiProtocolFreshness } from "./doctor-ui.js"; import { maybeOfferUpdateBeforeDoctor } from "./doctor-update.js"; import { noteWorkspaceStatus } from "./doctor-workspace-status.js"; +import { noteVerificationDebt } from "./doctor-verification-debt.js"; import { MEMORY_SYSTEM_PROMPT, shouldSuggestMemorySystem } from "./doctor-workspace.js"; import { noteOpenAIOAuthTlsPrerequisites } from "./oauth-tls-preflight.js"; import { applyWizardMetadata, printWizardHeader, randomToken } from "./onboard-helpers.js"; @@ -381,5 +382,7 @@ export async function doctorCommand( } } + await noteVerificationDebt(cfg, options); + outro("Doctor complete."); } diff --git a/src/infra/heartbeat-runner.ts b/src/infra/heartbeat-runner.ts index 5e6ddcf07cf..c8f46925c08 100644 --- a/src/infra/heartbeat-runner.ts +++ b/src/infra/heartbeat-runner.ts @@ -21,6 +21,7 @@ import { } from "../auto-reply/heartbeat.js"; import { getReplyFromConfig } from "../auto-reply/reply.js"; import { HEARTBEAT_TOKEN } from "../auto-reply/tokens.js"; +import { detectSuspiciousPatterns } from "../security/external-content.js"; import type { ReplyPayload } from "../auto-reply/types.js"; import { getChannelPlugin } from "../channels/plugins/index.js"; import type { ChannelHeartbeatDeps } from "../channels/plugins/types.js"; @@ -453,6 +454,13 @@ async function resolveHeartbeatPreflight(params: { const heartbeatFilePath = path.join(workspaceDir, DEFAULT_HEARTBEAT_FILENAME); try { const heartbeatFileContent = await fs.readFile(heartbeatFilePath, "utf-8"); + // SECURITY: scan HEARTBEAT.md for injection patterns before injecting into agent prompt + const suspiciousPatterns = detectSuspiciousPatterns(heartbeatFileContent); + if (suspiciousPatterns.length > 0) { + log.warn( + `[security] Suspicious patterns detected in HEARTBEAT.md (patterns=${suspiciousPatterns.length}): ${suspiciousPatterns.slice(0, 3).join(", ")}`, + ); + } if (isHeartbeatContentEffectivelyEmpty(heartbeatFileContent)) { return { ...basePreflight, diff --git a/src/security/verification-debt.ts b/src/security/verification-debt.ts new file mode 100644 index 00000000000..bc665231dc7 --- /dev/null +++ b/src/security/verification-debt.ts @@ -0,0 +1,165 @@ +/** + * Verification Debt Tracker + * + * "Every skipped verification is a loan against future trust." + * + * This module tracks verification events that were deferred or skipped, + * creating an auditable ledger of technical debt in the security posture. + */ + +import fs from "node:fs/promises"; +import path from "node:path"; + +export type VerificationDebtCategory = + | "security_audit" + | "skill_scan" + | "api_health" + | "memory_injection" + | "cron_rejection" + | "external_content"; + +export type VerificationDebtEntry = { + id: string; + category: VerificationDebtCategory; + description: string; + skippedAt: number; + reason: string; + riskScore: number; + resolved?: boolean; + resolvedAt?: number; + resolvedBy?: string; +}; + +export type VerificationDebtState = { + version: 1; + entries: VerificationDebtEntry[]; + lastPruned: number; +}; + +const DEBT_FILE = "verification-debt.json"; + +export async function loadVerificationDebt(params: { workspaceDir: string }): Promise { + const debtPath = path.join(params.workspaceDir, "state", DEBT_FILE); + try { + const raw = await fs.readFile(debtPath, "utf-8"); + return JSON.parse(raw) as VerificationDebtState; + } catch { + return { version: 1, entries: [], lastPruned: Date.now() }; + } +} + +export async function saveVerificationDebt(params: { + workspaceDir: string; + state: VerificationDebtState; +}): Promise { + const debtPath = path.join(params.workspaceDir, "state", DEBT_FILE); + const stateDir = path.dirname(debtPath); + await fs.mkdir(stateDir, { recursive: true }); + await fs.writeFile(debtPath, JSON.stringify(params.state, null, 2), "utf-8"); +} + +export async function addVerificationDebt(params: { + workspaceDir: string; + category: VerificationDebtCategory; + description: string; + reason: string; + riskScore: number; +}): Promise { + const state = await loadVerificationDebt({ workspaceDir: params.workspaceDir }); + + const entry: VerificationDebtEntry = { + id: crypto.randomUUID(), + category: params.category, + description: params.description, + skippedAt: Date.now(), + reason: params.reason, + riskScore: Math.max(1, Math.min(10, params.riskScore)), + }; + + state.entries.push(entry); + await saveVerificationDebt({ workspaceDir: params.workspaceDir, state }); + + return entry; +} + +export async function resolveVerificationDebt(params: { + workspaceDir: string; + entryId: string; + resolvedBy: string; +}): Promise { + const state = await loadVerificationDebt({ workspaceDir: params.workspaceDir }); + + const entry = state.entries.find((e) => e.id === params.entryId); + if (!entry || entry.resolved) { + return false; + } + + entry.resolved = true; + entry.resolvedAt = Date.now(); + entry.resolvedBy = params.resolvedBy; + + await saveVerificationDebt({ workspaceDir: params.workspaceDir, state }); + return true; +} + +export function calculateDebtScore(state: VerificationDebtState): number { + const now = Date.now(); + const oneWeekMs = 7 * 24 * 60 * 60 * 1000; + + return state.entries.reduce((score, entry) => { + if (entry.resolved) return score; + let entryScore = entry.riskScore; + if (now - entry.skippedAt > oneWeekMs) { + entryScore *= 2; + } + return score + entryScore; + }, 0); +} + +export function getDebtSummary(state: VerificationDebtState): { + total: number; + unresolved: number; + byCategory: Record; + highRisk: VerificationDebtEntry[]; +} { + const unresolved = state.entries.filter((e) => !e.resolved); + const byCategory: Record = { + security_audit: 0, + skill_scan: 0, + api_health: 0, + memory_injection: 0, + cron_rejection: 0, + external_content: 0, + }; + + for (const entry of unresolved) { + byCategory[entry.category]++; + } + + const highRisk = unresolved.filter((e) => e.riskScore >= 7).sort((a, b) => b.riskScore - a.riskScore); + + return { + total: state.entries.length, + unresolved: unresolved.length, + byCategory, + highRisk, + }; +} + +export async function pruneResolvedDebts(params: { workspaceDir: string; maxAgeDays: number }): Promise { + const state = await loadVerificationDebt({ workspaceDir: params.workspaceDir }); + const now = Date.now(); + const maxAgeMs = params.maxAgeDays * 24 * 60 * 60 * 1000; + + const initialCount = state.entries.length; + state.entries = state.entries.filter((entry) => { + if (!entry.resolved) return true; + if (now - (entry.resolvedAt ?? 0) < maxAgeMs) return true; + return false; + }); + + state.lastPruned = now; + await saveVerificationDebt({ workspaceDir: params.workspaceDir, state }); + + return initialCount - state.entries.length; +}