Merge 21b74f7bdd2de32b92ee293f8a3ee0b9c0d7b73e into 8a05c05596ca9ba0735dafd8e359885de4c2c969

This commit is contained in:
let5sne 2026-03-20 22:55:44 -07:00 committed by GitHub
commit 2f93d069fe
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 244 additions and 0 deletions

View File

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

View File

@ -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<void> {
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
}
}

View File

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

View File

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

View File

@ -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<VerificationDebtState> {
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<void> {
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<VerificationDebtEntry> {
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<boolean> {
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<VerificationDebtCategory, number>;
highRisk: VerificationDebtEntry[];
} {
const unresolved = state.entries.filter((e) => !e.resolved);
const byCategory: Record<VerificationDebtCategory, number> = {
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<number> {
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;
}