security: add skill/plugin code safety scanner (#9806)

* security: add skill/plugin code safety scanner module

* security: integrate skill scanner into security audit

* security: add pre-install code safety scan for plugins

* style: fix curly brace lint errors in skill-scanner.ts

* docs: add changelog entry for skill code safety scanner

* style: append ellipsis to truncated evidence strings

* fix(security): harden plugin code safety scanning

* fix: scan skills on install and report code-safety details

* fix: dedupe audit-extra import

* fix(security): make code safety scan failures observable

* fix(test): stabilize smoke + gateway timeouts (#9806) (thanks @abdelsfane)

---------

Co-authored-by: Darshil <ddhameliya@mail.sfsu.edu>
Co-authored-by: Darshil <81693876+dvrshil@users.noreply.github.com>
Co-authored-by: George Pickett <gpickett00@gmail.com>
This commit is contained in:
Abdel Sy Fane
2026-02-05 17:06:11 -07:00
committed by GitHub
parent 141f551a4c
commit bc88e58fcf
16 changed files with 1722 additions and 95 deletions

View File

@@ -7,6 +7,7 @@ import type { OpenClawConfig } from "../config/config.js";
import { resolveBrewExecutable } from "../infra/brew.js";
import { fetchWithSsrFGuard } from "../infra/net/fetch-guard.js";
import { runCommandWithTimeout } from "../process/exec.js";
import { scanDirectoryWithSummary } from "../security/skill-scanner.js";
import { CONFIG_DIR, ensureDir, resolveUserPath } from "../utils.js";
import {
hasBinary,
@@ -32,6 +33,7 @@ export type SkillInstallResult = {
stdout: string;
stderr: string;
code: number | null;
warnings?: string[];
};
function isNodeReadableStream(value: unknown): value is NodeJS.ReadableStream {
@@ -77,6 +79,57 @@ function formatInstallFailureMessage(result: {
return `Install failed (${code}): ${summary}`;
}
function withWarnings(result: SkillInstallResult, warnings: string[]): SkillInstallResult {
if (warnings.length === 0) {
return result;
}
return {
...result,
warnings: warnings.slice(),
};
}
function formatScanFindingDetail(
rootDir: string,
finding: { message: string; file: string; line: number },
): string {
const relativePath = path.relative(rootDir, finding.file);
const filePath =
relativePath && relativePath !== "." && !relativePath.startsWith("..")
? relativePath
: path.basename(finding.file);
return `${finding.message} (${filePath}:${finding.line})`;
}
async function collectSkillInstallScanWarnings(entry: SkillEntry): Promise<string[]> {
const warnings: string[] = [];
const skillName = entry.skill.name;
const skillDir = path.resolve(entry.skill.baseDir);
try {
const summary = await scanDirectoryWithSummary(skillDir);
if (summary.critical > 0) {
const criticalDetails = summary.findings
.filter((finding) => finding.severity === "critical")
.map((finding) => formatScanFindingDetail(skillDir, finding))
.join("; ");
warnings.push(
`WARNING: Skill "${skillName}" contains dangerous code patterns: ${criticalDetails}`,
);
} else if (summary.warn > 0) {
warnings.push(
`Skill "${skillName}" has ${summary.warn} suspicious code pattern(s). Run "openclaw security audit --deep" for details.`,
);
}
} catch (err) {
warnings.push(
`Skill "${skillName}" code safety scan failed (${String(err)}). Installation continues; run "openclaw security audit --deep" after install.`,
);
}
return warnings;
}
function resolveInstallId(spec: SkillInstallSpec, index: number): string {
return (spec.id ?? `${spec.kind}-${index}`).trim();
}
@@ -356,40 +409,51 @@ export async function installSkill(params: SkillInstallRequest): Promise<SkillIn
}
const spec = findInstallSpec(entry, params.installId);
const warnings = await collectSkillInstallScanWarnings(entry);
if (!spec) {
return {
ok: false,
message: `Installer not found: ${params.installId}`,
stdout: "",
stderr: "",
code: null,
};
return withWarnings(
{
ok: false,
message: `Installer not found: ${params.installId}`,
stdout: "",
stderr: "",
code: null,
},
warnings,
);
}
if (spec.kind === "download") {
return await installDownloadSpec({ entry, spec, timeoutMs });
const downloadResult = await installDownloadSpec({ entry, spec, timeoutMs });
return withWarnings(downloadResult, warnings);
}
const prefs = resolveSkillsInstallPreferences(params.config);
const command = buildInstallCommand(spec, prefs);
if (command.error) {
return {
ok: false,
message: command.error,
stdout: "",
stderr: "",
code: null,
};
return withWarnings(
{
ok: false,
message: command.error,
stdout: "",
stderr: "",
code: null,
},
warnings,
);
}
const brewExe = hasBinary("brew") ? "brew" : resolveBrewExecutable();
if (spec.kind === "brew" && !brewExe) {
return {
ok: false,
message: "brew not installed",
stdout: "",
stderr: "",
code: null,
};
return withWarnings(
{
ok: false,
message: "brew not installed",
stdout: "",
stderr: "",
code: null,
},
warnings,
);
}
if (spec.kind === "uv" && !hasBinary("uv")) {
if (brewExe) {
@@ -397,32 +461,41 @@ export async function installSkill(params: SkillInstallRequest): Promise<SkillIn
timeoutMs,
});
if (brewResult.code !== 0) {
return {
ok: false,
message: "Failed to install uv (brew)",
stdout: brewResult.stdout.trim(),
stderr: brewResult.stderr.trim(),
code: brewResult.code,
};
return withWarnings(
{
ok: false,
message: "Failed to install uv (brew)",
stdout: brewResult.stdout.trim(),
stderr: brewResult.stderr.trim(),
code: brewResult.code,
},
warnings,
);
}
} else {
return {
ok: false,
message: "uv not installed (install via brew)",
stdout: "",
stderr: "",
code: null,
};
return withWarnings(
{
ok: false,
message: "uv not installed (install via brew)",
stdout: "",
stderr: "",
code: null,
},
warnings,
);
}
}
if (!command.argv || command.argv.length === 0) {
return {
ok: false,
message: "invalid install command",
stdout: "",
stderr: "",
code: null,
};
return withWarnings(
{
ok: false,
message: "invalid install command",
stdout: "",
stderr: "",
code: null,
},
warnings,
);
}
if (spec.kind === "brew" && brewExe && command.argv[0] === "brew") {
@@ -435,22 +508,28 @@ export async function installSkill(params: SkillInstallRequest): Promise<SkillIn
timeoutMs,
});
if (brewResult.code !== 0) {
return {
ok: false,
message: "Failed to install go (brew)",
stdout: brewResult.stdout.trim(),
stderr: brewResult.stderr.trim(),
code: brewResult.code,
};
return withWarnings(
{
ok: false,
message: "Failed to install go (brew)",
stdout: brewResult.stdout.trim(),
stderr: brewResult.stderr.trim(),
code: brewResult.code,
},
warnings,
);
}
} else {
return {
ok: false,
message: "go not installed (install via brew)",
stdout: "",
stderr: "",
code: null,
};
return withWarnings(
{
ok: false,
message: "go not installed (install via brew)",
stdout: "",
stderr: "",
code: null,
},
warnings,
);
}
}
@@ -479,11 +558,14 @@ export async function installSkill(params: SkillInstallRequest): Promise<SkillIn
})();
const success = result.code === 0;
return {
ok: success,
message: success ? "Installed" : formatInstallFailureMessage(result),
stdout: result.stdout.trim(),
stderr: result.stderr.trim(),
code: result.code,
};
return withWarnings(
{
ok: success,
message: success ? "Installed" : formatInstallFailureMessage(result),
stdout: result.stdout.trim(),
stderr: result.stderr.trim(),
code: result.code,
},
warnings,
);
}