security: scan SKILL.md and HEARTBEAT.md for injection patterns

- workspace.ts: call scanSource() on SKILL.md content during loadSkillEntries,
  warn on critical findings (dangerous-exec, dynamic-code-execution, etc.)
- heartbeat-runner.ts: call detectSuspiciousPatterns() on HEARTBEAT.md content
  before injecting into agent prompt, warn on suspicious patterns

Both checks are warn-only (non-blocking), purely additive.

Fixes #21 (detectSkillPatterns wired into load flow)
Fixes #17 (HEARTBEAT.md injection scan)
This commit is contained in:
OpenClaw Explorer 2026-03-02 18:45:22 +08:00
parent 30718d612a
commit 21b74f7bdd
2 changed files with 15 additions and 0 deletions

View File

@ -20,6 +20,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,
@ -391,6 +392,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

@ -18,6 +18,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";
@ -535,6 +536,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,