mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-25 08:02:04 +00:00
plugins: add before_install hook for install scanners
This commit is contained in:
@@ -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"]);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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.`,
|
||||
|
||||
@@ -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
8
src/types/pi-coding-agent.d.ts
vendored
Normal 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user