plugins: add before_install hook for install scanners

This commit is contained in:
George Zhang
2026-03-29 10:17:00 -07:00
parent 77555d6c85
commit 7cd9957f62
7 changed files with 267 additions and 18 deletions

View File

@@ -2,6 +2,7 @@ import fs from "node:fs";
import path from "node:path";
import type { OpenClawConfig } from "../config/config.js";
import { resolveBrewExecutable } from "../infra/brew.js";
import { getGlobalHookRunner } from "../plugins/hook-runner-global.js";
import { runCommandWithTimeout, type CommandOptions } from "../process/exec.js";
import { scanDirectoryWithSummary } from "../security/skill-scanner.js";
import { resolveUserPath } from "../utils.js";
@@ -55,13 +56,26 @@ function formatScanFindingDetail(
return `${finding.message} (${filePath}:${finding.line})`;
}
async function collectSkillInstallScanWarnings(entry: SkillEntry): Promise<string[]> {
type SkillScanResult = {
warnings: string[];
findings: Array<{
ruleId: string;
severity: "info" | "warn" | "critical";
file: string;
line: number;
message: string;
}>;
};
async function collectSkillInstallScanWarnings(entry: SkillEntry): Promise<SkillScanResult> {
const warnings: string[] = [];
const findings: SkillScanResult["findings"] = [];
const skillName = entry.skill.name;
const skillDir = path.resolve(entry.skill.baseDir);
try {
const summary = await scanDirectoryWithSummary(skillDir);
findings.push(...summary.findings);
if (summary.critical > 0) {
const criticalDetails = summary.findings
.filter((finding) => finding.severity === "critical")
@@ -81,7 +95,7 @@ async function collectSkillInstallScanWarnings(entry: SkillEntry): Promise<strin
);
}
return warnings;
return { warnings, findings };
}
function resolveInstallId(spec: SkillInstallSpec, index: number): string {
@@ -439,9 +453,50 @@ export async function installSkill(params: SkillInstallRequest): Promise<SkillIn
}
const spec = findInstallSpec(entry, params.installId);
const warnings = await collectSkillInstallScanWarnings(entry);
const scanResult = await collectSkillInstallScanWarnings(entry);
const warnings = scanResult.warnings;
const skillSource = entry.skill.sourceInfo?.source?.trim() || "unknown";
// Run before_install so external scanners can augment findings or block installs.
const hookRunner = getGlobalHookRunner();
if (hookRunner?.hasHooks("before_install")) {
try {
const hookResult = await hookRunner.runBeforeInstall(
{
targetName: params.skillName,
targetType: "skill",
sourceDir: path.resolve(entry.skill.baseDir),
source: skillSource,
builtinFindings: scanResult.findings,
},
{ source: skillSource, targetType: "skill" },
);
if (hookResult?.block) {
return {
ok: false,
message: hookResult.blockReason || "Installation blocked by plugin hook",
stdout: "",
stderr: "",
code: null,
warnings: warnings.length > 0 ? warnings.slice() : undefined,
};
}
if (hookResult?.findings) {
for (const finding of hookResult.findings) {
if (finding.severity === "critical") {
warnings.push(
`WARNING: Plugin scanner: ${finding.message} (${finding.file}:${finding.line})`,
);
} else if (finding.severity === "warn") {
warnings.push(`Plugin scanner: ${finding.message} (${finding.file}:${finding.line})`);
}
}
}
} catch {
// Hook errors are non-fatal — built-in scanner results still apply.
}
}
// Warn when install is triggered from a non-bundled source.
// Workspace/project/personal agent skills can contain attacker-controlled metadata.
const trustedInstallSources = new Set(["openclaw-bundled", "openclaw-managed", "openclaw-extra"]);

View File

@@ -56,6 +56,9 @@ import type {
PluginHookToolResultPersistResult,
PluginHookBeforeMessageWriteEvent,
PluginHookBeforeMessageWriteResult,
PluginHookBeforeInstallContext,
PluginHookBeforeInstallEvent,
PluginHookBeforeInstallResult,
} from "./types.js";
// Re-export types for consumers
@@ -106,6 +109,9 @@ export type {
PluginHookGatewayContext,
PluginHookGatewayStartEvent,
PluginHookGatewayStopEvent,
PluginHookBeforeInstallContext,
PluginHookBeforeInstallEvent,
PluginHookBeforeInstallResult,
};
export type HookRunnerLogger = {
@@ -977,6 +983,41 @@ export function createHookRunner(registry: PluginRegistry, options: HookRunnerOp
return runVoidHook("gateway_stop", event, ctx);
}
// =========================================================================
// Skill Install Hooks
// =========================================================================
/**
* Run before_install hook.
* Allows plugins to augment scan findings or block installs.
* Runs sequentially so higher-priority hooks can block before lower ones run.
*/
async function runBeforeInstall(
event: PluginHookBeforeInstallEvent,
ctx: PluginHookBeforeInstallContext,
): Promise<PluginHookBeforeInstallResult | undefined> {
return runModifyingHook<"before_install", PluginHookBeforeInstallResult>(
"before_install",
event,
ctx,
{
mergeResults: (acc, next) => {
if (acc?.block === true) {
return acc;
}
const mergedFindings = [...(acc?.findings ?? []), ...(next.findings ?? [])];
return {
findings: mergedFindings.length > 0 ? mergedFindings : undefined,
block: stickyTrue(acc?.block, next.block),
blockReason: lastDefined(acc?.blockReason, next.blockReason),
};
},
shouldStop: (result) => result.block === true,
terminalLabel: "block=true",
},
);
}
// =========================================================================
// Utility
// =========================================================================
@@ -1030,6 +1071,8 @@ export function createHookRunner(registry: PluginRegistry, options: HookRunnerOp
// Gateway hooks
runGatewayStart,
runGatewayStop,
// Install hooks
runBeforeInstall,
// Utility
hasHooks,
getHookCount,

View File

@@ -1,11 +1,26 @@
import path from "node:path";
import { extensionUsesSkippedScannerPath, isPathInside } from "../security/scan-paths.js";
import { scanDirectoryWithSummary } from "../security/skill-scanner.js";
import { getGlobalHookRunner } from "./hook-runner-global.js";
type InstallScanLogger = {
warn?: (message: string) => void;
};
type InstallScanFinding = {
ruleId: string;
severity: "info" | "warn" | "critical";
file: string;
line: number;
message: string;
};
export type InstallSecurityScanResult = {
blocked?: {
reason: string;
};
};
function buildCriticalDetails(params: {
findings: Array<{ file: string; line: number; message: string; severity: string }>;
}) {
@@ -15,20 +30,66 @@ function buildCriticalDetails(params: {
.join("; ");
}
async function runBeforeInstallHook(params: {
logger: InstallScanLogger;
installLabel: string;
source: string;
sourceDir: string;
targetName: string;
targetType: "skill" | "plugin";
builtinFindings: InstallScanFinding[];
}): Promise<InstallSecurityScanResult | undefined> {
const hookRunner = getGlobalHookRunner();
if (!hookRunner?.hasHooks("before_install")) {
return undefined;
}
try {
const hookResult = await hookRunner.runBeforeInstall(
{
targetName: params.targetName,
targetType: params.targetType,
source: params.source,
sourceDir: params.sourceDir,
builtinFindings: params.builtinFindings,
},
{ source: params.source, targetType: params.targetType },
);
if (hookResult?.block) {
const reason = hookResult.blockReason || "Installation blocked by plugin hook";
params.logger.warn?.(`WARNING: ${params.installLabel} blocked by plugin hook: ${reason}`);
return { blocked: { reason } };
}
if (hookResult?.findings) {
for (const finding of hookResult.findings) {
if (finding.severity === "critical" || finding.severity === "warn") {
params.logger.warn?.(
`Plugin scanner: ${finding.message} (${finding.file}:${finding.line})`,
);
}
}
}
} catch {
// Hook errors are non-fatal.
}
return undefined;
}
export async function scanBundleInstallSourceRuntime(params: {
logger: InstallScanLogger;
pluginId: string;
sourceDir: string;
}) {
}): Promise<InstallSecurityScanResult | undefined> {
let builtinFindings: InstallScanFinding[] = [];
try {
const scanSummary = await scanDirectoryWithSummary(params.sourceDir);
builtinFindings = scanSummary.findings;
if (scanSummary.critical > 0) {
params.logger.warn?.(
`WARNING: Bundle "${params.pluginId}" contains dangerous code patterns: ${buildCriticalDetails({ findings: scanSummary.findings })}`,
);
return;
}
if (scanSummary.warn > 0) {
} else if (scanSummary.warn > 0) {
params.logger.warn?.(
`Bundle "${params.pluginId}" has ${scanSummary.warn} suspicious code pattern(s). Run "openclaw security audit --deep" for details.`,
);
@@ -38,6 +99,16 @@ export async function scanBundleInstallSourceRuntime(params: {
`Bundle "${params.pluginId}" code safety scan failed (${String(err)}). Installation continues; run "openclaw security audit --deep" after install.`,
);
}
return await runBeforeInstallHook({
logger: params.logger,
installLabel: `Bundle "${params.pluginId}" installation`,
source: "plugin-bundle",
sourceDir: params.sourceDir,
targetName: params.pluginId,
targetType: "plugin",
builtinFindings,
});
}
export async function scanPackageInstallSourceRuntime(params: {
@@ -45,7 +116,7 @@ export async function scanPackageInstallSourceRuntime(params: {
logger: InstallScanLogger;
packageDir: string;
pluginId: string;
}) {
}): Promise<InstallSecurityScanResult | undefined> {
const forcedScanEntries: string[] = [];
for (const entry of params.extensions) {
const resolvedEntry = path.resolve(params.packageDir, entry);
@@ -63,17 +134,17 @@ export async function scanPackageInstallSourceRuntime(params: {
forcedScanEntries.push(resolvedEntry);
}
let builtinFindings: InstallScanFinding[] = [];
try {
const scanSummary = await scanDirectoryWithSummary(params.packageDir, {
includeFiles: forcedScanEntries,
});
builtinFindings = scanSummary.findings;
if (scanSummary.critical > 0) {
params.logger.warn?.(
`WARNING: Plugin "${params.pluginId}" contains dangerous code patterns: ${buildCriticalDetails({ findings: scanSummary.findings })}`,
);
return;
}
if (scanSummary.warn > 0) {
} else if (scanSummary.warn > 0) {
params.logger.warn?.(
`Plugin "${params.pluginId}" has ${scanSummary.warn} suspicious code pattern(s). Run "openclaw security audit --deep" for details.`,
);
@@ -83,4 +154,14 @@ export async function scanPackageInstallSourceRuntime(params: {
`Plugin "${params.pluginId}" code safety scan failed (${String(err)}). Installation continues; run "openclaw security audit --deep" after install.`,
);
}
return await runBeforeInstallHook({
logger: params.logger,
installLabel: `Plugin "${params.pluginId}" installation`,
source: "plugin-package",
sourceDir: params.packageDir,
targetName: params.pluginId,
targetType: "plugin",
builtinFindings,
});
}

View File

@@ -2,6 +2,12 @@ type InstallScanLogger = {
warn?: (message: string) => void;
};
export type InstallSecurityScanResult = {
blocked?: {
reason: string;
};
};
async function loadInstallSecurityScanRuntime() {
return await import("./install-security-scan.runtime.js");
}
@@ -10,9 +16,9 @@ export async function scanBundleInstallSource(params: {
logger: InstallScanLogger;
pluginId: string;
sourceDir: string;
}) {
}): Promise<InstallSecurityScanResult | undefined> {
const { scanBundleInstallSourceRuntime } = await loadInstallSecurityScanRuntime();
await scanBundleInstallSourceRuntime(params);
return await scanBundleInstallSourceRuntime(params);
}
export async function scanPackageInstallSource(params: {
@@ -20,7 +26,7 @@ export async function scanPackageInstallSource(params: {
logger: InstallScanLogger;
packageDir: string;
pluginId: string;
}) {
}): Promise<InstallSecurityScanResult | undefined> {
const { scanPackageInstallSourceRuntime } = await loadInstallSecurityScanRuntime();
await scanPackageInstallSourceRuntime(params);
return await scanPackageInstallSourceRuntime(params);
}

View File

@@ -376,11 +376,14 @@ async function installBundleFromSourceDir(
}
try {
await runtime.scanBundleInstallSource({
const scanResult = await runtime.scanBundleInstallSource({
sourceDir: params.sourceDir,
pluginId,
logger,
});
if (scanResult?.blocked) {
return { ok: false, error: scanResult.blocked.reason };
}
} catch (err) {
logger.warn?.(
`Bundle "${pluginId}" code safety scan failed (${String(err)}). Installation continues; run "openclaw security audit --deep" after install.`,
@@ -545,12 +548,15 @@ async function installPluginFromPackageDir(
};
}
try {
await runtime.scanPackageInstallSource({
const scanResult = await runtime.scanPackageInstallSource({
packageDir: params.packageDir,
pluginId,
logger,
extensions,
});
if (scanResult?.blocked) {
return { ok: false, error: scanResult.blocked.reason };
}
} catch (err) {
logger.warn?.(
`Plugin "${pluginId}" code safety scan failed (${String(err)}). Installation continues; run "openclaw security audit --deep" after install.`,

View File

@@ -1803,7 +1803,8 @@ export type PluginHookName =
| "subagent_ended"
| "gateway_start"
| "gateway_stop"
| "before_dispatch";
| "before_dispatch"
| "before_install";
export const PLUGIN_HOOK_NAMES = [
"before_model_resolve",
@@ -1832,6 +1833,7 @@ export const PLUGIN_HOOK_NAMES = [
"gateway_start",
"gateway_stop",
"before_dispatch",
"before_install",
] as const satisfies readonly PluginHookName[];
type MissingPluginHookNames = Exclude<PluginHookName, (typeof PLUGIN_HOOK_NAMES)[number]>;
@@ -2337,6 +2339,50 @@ export type PluginHookGatewayStopEvent = {
reason?: string;
};
export type PluginInstallTargetType = "skill" | "plugin";
// before_install hook
export type PluginHookBeforeInstallContext = {
/** Category of install target being checked. */
targetType: PluginInstallTargetType;
/** Origin of the install target (e.g. "openclaw-bundled", "plugin-package"). */
source?: string;
};
export type PluginHookBeforeInstallEvent = {
/** Category of install target being checked. */
targetType: PluginInstallTargetType;
/** Human-readable skill or plugin name. */
targetName: string;
/** Absolute path to the install target source directory being scanned. */
sourceDir: string;
/** Origin of the install target (e.g. "openclaw-bundled", "plugin-package"). */
source?: string;
/** Findings from the built-in scanner, provided for augmentation. */
builtinFindings: Array<{
ruleId: string;
severity: "info" | "warn" | "critical";
file: string;
line: number;
message: string;
}>;
};
export type PluginHookBeforeInstallResult = {
/** Additional findings to merge with built-in scanner results. */
findings?: Array<{
ruleId: string;
severity: "info" | "warn" | "critical";
file: string;
line: number;
message: string;
}>;
/** If true, block the installation entirely. */
block?: boolean;
/** Human-readable reason for blocking. */
blockReason?: string;
};
// Hook handler types mapped by hook name
export type PluginHookHandlerMap = {
before_model_resolve: (
@@ -2443,6 +2489,10 @@ export type PluginHookHandlerMap = {
event: PluginHookGatewayStopEvent,
ctx: PluginHookGatewayContext,
) => Promise<void> | void;
before_install: (
event: PluginHookBeforeInstallEvent,
ctx: PluginHookBeforeInstallContext,
) => Promise<PluginHookBeforeInstallResult | void> | PluginHookBeforeInstallResult | void;
};
export type PluginHookRegistration<K extends PluginHookName = PluginHookName> = {

8
src/types/pi-coding-agent.d.ts vendored Normal file
View File

@@ -0,0 +1,8 @@
import "@mariozechner/pi-coding-agent";
declare module "@mariozechner/pi-coding-agent" {
interface Skill {
// OpenClaw relies on the source identifier returned by pi skill loaders.
source: string;
}
}