feat(plugins): add before_install hook for install policy checks#56050
Conversation
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: bc50add741
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| if (hookResult?.block) { | ||
| params.logger.warn?.( | ||
| `WARNING: Bundle "${params.pluginId}" installation blocked by plugin hook: ${hookResult.blockReason || "no reason given"}`, | ||
| ); |
There was a problem hiding this comment.
Enforce before_skill_install block decisions
When hookResult.block is true, this path only logs a warning and then returns normally, so installation still proceeds. In both install flows (installBundleFromSourceDir and installPluginFromPackageDir), scan*InstallSource is awaited and then installPluginDirectoryIntoExtensions is still called, so scanners cannot actually stop unsafe installs even though the log says they were blocked.
Useful? React with 👍 / 👎.
| if (scanSummary.critical > 0) { | ||
| params.logger.warn?.( | ||
| `WARNING: Bundle "${params.pluginId}" contains dangerous code patterns: ${buildCriticalDetails({ findings: scanSummary.findings })}`, |
There was a problem hiding this comment.
Invoke before_skill_install after critical scan findings
The early return on built-in critical findings exits before the new before_skill_install hook block runs, so external scanners are skipped exactly for the highest-risk bundle/package content. This prevents policy hooks from augmenting findings or blocking those installs whenever the built-in scanner has already marked content as critical.
Useful? React with 👍 / 👎.
Greptile SummaryThis PR introduces a
Confidence Score: 3/5Not safe to merge — the hook's Two P1 defects in src/plugins/install-security-scan.runtime.ts requires the most attention — both P1 issues are isolated to this file.
|
| Filename | Overview |
|---|---|
| src/plugins/install-security-scan.runtime.ts | Adds plugin hook invocation after the built-in scan for both bundle and package install paths. Two P1 defects: (1) block: true is only logged — it does not prevent installation because the functions return void; (2) the hook is never called when the built-in scanner finds critical findings due to an early return that predates the new hook block. |
| src/agents/skills-install.ts | Refactors collectSkillInstallScanWarnings to return both warnings and raw findings, then correctly invokes the plugin hook and respects block: true by returning early with ok: false. This path is correctly implemented. |
| src/plugins/hooks.ts | Adds runBeforeSkillInstall using runModifyingHook with correct stickyTrue/short-circuit semantics. Minor edge case where a non-blocking handler's blockReason can be attributed to a later blocking handler via lastDefined. |
| src/plugins/types.ts | Adds before_skill_install to the hook name union and constant array, and defines PluginHookBeforeSkillInstallContext, Event, and Result types with appropriate optional fields. Types are correct and consistent with other hook definitions. |
Comments Outside Diff (1)
-
src/plugins/install-security-scan.runtime.ts, line 34-39 (link)Hook is silently skipped when the built-in scanner finds critical findings
When
scanSummary.critical > 0, bothscanBundleInstallSourceRuntimeandscanPackageInstallSourceRuntimeexecutereturn;before reaching the plugin hook block. As a result, thebefore_skill_installhook is never invoked for the most security-sensitive case — one where the built-in scanner already identified dangerous patterns.This is inconsistent with the
skills-install.tspath wherecollectSkillInstallScanWarningsalways returns (never short-circuits), so the hook there runs regardless of how many critical findings the built-in scanner produced.Consequences:
- External scanners cannot observe or act on critical findings in the bundle/package paths (e.g. forwarding results to a SIEM).
- The
builtinFindingsis populated before the early return, so the data is ready — the hook block just needs to be moved to run even on the early-return path.
The same early-return issue exists in
scanPackageInstallSourceRuntimeat thecritical > 0check (lines 118–123).Prompt To Fix With AI
This is a comment left during a code review. Path: src/plugins/install-security-scan.runtime.ts Line: 34-39 Comment: **Hook is silently skipped when the built-in scanner finds critical findings** When `scanSummary.critical > 0`, both `scanBundleInstallSourceRuntime` and `scanPackageInstallSourceRuntime` execute `return;` before reaching the plugin hook block. As a result, the `before_skill_install` hook is never invoked for the most security-sensitive case — one where the built-in scanner already identified dangerous patterns. This is inconsistent with the `skills-install.ts` path where `collectSkillInstallScanWarnings` always returns (never short-circuits), so the hook there runs regardless of how many critical findings the built-in scanner produced. Consequences: - External scanners cannot observe or act on critical findings in the bundle/package paths (e.g. forwarding results to a SIEM). - The `builtinFindings` is populated before the early return, so the data is ready — the hook block just needs to be moved to run even on the early-return path. The same early-return issue exists in `scanPackageInstallSourceRuntime` at the `critical > 0` check (lines 118–123). How can I resolve this? If you propose a fix, please make it concise.
Prompt To Fix All With AI
This is a comment left during a code review.
Path: src/plugins/install-security-scan.runtime.ts
Line: 63-67
Comment:
**`block: true` is logged but installation is never actually prevented**
When the plugin hook returns `block: true` in both `scanBundleInstallSourceRuntime` and `scanPackageInstallSourceRuntime`, only a warning is emitted. Both functions return `void`, so callers have no way to detect that a block was requested — installation proceeds unconditionally. This directly contradicts the stated test plan ("Register a plugin that returns `block: true` — confirm installation is prevented") and the design note ("once any handler blocks…").
The same issue exists in `scanPackageInstallSourceRuntime` at the equivalent block (lines 147–151).
For the block to have any effect these functions would need to either return a result the caller can act on, or throw an error to abort the install. As written, plugin hooks can log warnings via `block: true` but cannot actually prevent installation through the bundle/package paths.
How can I resolve this? If you propose a fix, please make it concise.
---
This is a comment left during a code review.
Path: src/plugins/install-security-scan.runtime.ts
Line: 34-39
Comment:
**Hook is silently skipped when the built-in scanner finds critical findings**
When `scanSummary.critical > 0`, both `scanBundleInstallSourceRuntime` and `scanPackageInstallSourceRuntime` execute `return;` before reaching the plugin hook block. As a result, the `before_skill_install` hook is never invoked for the most security-sensitive case — one where the built-in scanner already identified dangerous patterns.
This is inconsistent with the `skills-install.ts` path where `collectSkillInstallScanWarnings` always returns (never short-circuits), so the hook there runs regardless of how many critical findings the built-in scanner produced.
Consequences:
- External scanners cannot observe or act on critical findings in the bundle/package paths (e.g. forwarding results to a SIEM).
- The `builtinFindings` is populated before the early return, so the data is ready — the hook block just needs to be moved to run even on the early-return path.
The same early-return issue exists in `scanPackageInstallSourceRuntime` at the `critical > 0` check (lines 118–123).
How can I resolve this? If you propose a fix, please make it concise.
---
This is a comment left during a code review.
Path: src/plugins/hooks.ts
Line: 1012
Comment:
**`blockReason` from a non-blocking handler can leak into a later block**
`lastDefined` returns `next ?? prev`, so if handler A returns `{ blockReason: "reason-A", block: false }` and handler B returns `{ block: true }` (no `blockReason`), the merged result is `{ block: true, blockReason: "reason-A" }`. The user sees a block attributed to a reason that came from a handler that did *not* block.
Since `shouldStop` terminates after the first `block: true`, the scenario requires handler A to fire first and set a `blockReason` without blocking, then handler B to block without a reason. This is an unlikely edge case in practice, but using `firstDefined` on `blockReason` would ensure the reason always comes from whichever handler actually triggered the block.
How can I resolve this? If you propose a fix, please make it concise.Reviews (1): Last reviewed commit: "feat(plugins): add before_skill_install ..." | Re-trigger Greptile
| if (hookResult?.block) { | ||
| params.logger.warn?.( | ||
| `WARNING: Bundle "${params.pluginId}" installation blocked by plugin hook: ${hookResult.blockReason || "no reason given"}`, | ||
| ); | ||
| } |
There was a problem hiding this comment.
block: true is logged but installation is never actually prevented
When the plugin hook returns block: true in both scanBundleInstallSourceRuntime and scanPackageInstallSourceRuntime, only a warning is emitted. Both functions return void, so callers have no way to detect that a block was requested — installation proceeds unconditionally. This directly contradicts the stated test plan ("Register a plugin that returns block: true — confirm installation is prevented") and the design note ("once any handler blocks…").
The same issue exists in scanPackageInstallSourceRuntime at the equivalent block (lines 147–151).
For the block to have any effect these functions would need to either return a result the caller can act on, or throw an error to abort the install. As written, plugin hooks can log warnings via block: true but cannot actually prevent installation through the bundle/package paths.
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/plugins/install-security-scan.runtime.ts
Line: 63-67
Comment:
**`block: true` is logged but installation is never actually prevented**
When the plugin hook returns `block: true` in both `scanBundleInstallSourceRuntime` and `scanPackageInstallSourceRuntime`, only a warning is emitted. Both functions return `void`, so callers have no way to detect that a block was requested — installation proceeds unconditionally. This directly contradicts the stated test plan ("Register a plugin that returns `block: true` — confirm installation is prevented") and the design note ("once any handler blocks…").
The same issue exists in `scanPackageInstallSourceRuntime` at the equivalent block (lines 147–151).
For the block to have any effect these functions would need to either return a result the caller can act on, or throw an error to abort the install. As written, plugin hooks can log warnings via `block: true` but cannot actually prevent installation through the bundle/package paths.
How can I resolve this? If you propose a fix, please make it concise.| return { | ||
| findings: mergedFindings.length > 0 ? mergedFindings : undefined, | ||
| block: stickyTrue(acc?.block, next.block), | ||
| blockReason: lastDefined(acc?.blockReason, next.blockReason), |
There was a problem hiding this comment.
blockReason from a non-blocking handler can leak into a later block
lastDefined returns next ?? prev, so if handler A returns { blockReason: "reason-A", block: false } and handler B returns { block: true } (no blockReason), the merged result is { block: true, blockReason: "reason-A" }. The user sees a block attributed to a reason that came from a handler that did not block.
Since shouldStop terminates after the first block: true, the scenario requires handler A to fire first and set a blockReason without blocking, then handler B to block without a reason. This is an unlikely edge case in practice, but using firstDefined on blockReason would ensure the reason always comes from whichever handler actually triggered the block.
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/plugins/hooks.ts
Line: 1012
Comment:
**`blockReason` from a non-blocking handler can leak into a later block**
`lastDefined` returns `next ?? prev`, so if handler A returns `{ blockReason: "reason-A", block: false }` and handler B returns `{ block: true }` (no `blockReason`), the merged result is `{ block: true, blockReason: "reason-A" }`. The user sees a block attributed to a reason that came from a handler that did *not* block.
Since `shouldStop` terminates after the first `block: true`, the scenario requires handler A to fire first and set a `blockReason` without blocking, then handler B to block without a reason. This is an unlikely edge case in practice, but using `firstDefined` on `blockReason` would ensure the reason always comes from whichever handler actually triggered the block.
How can I resolve this? If you propose a fix, please make it concise.There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 2a60ff3137
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| const hookRunner = getGlobalHookRunner(); | ||
| if (hookRunner?.hasHooks("before_skill_install")) { |
There was a problem hiding this comment.
Initialize hook runner for interactive skill installs
installSkill only executes before_skill_install when getGlobalHookRunner() is already populated, but in the default CLI lazy-registration path openclaw onboard reaches this code without loading plugins first, so the condition is false and external scanner hooks never run. That means enterprise policy scanners cannot block or annotate installs in the interactive onboarding flow, even though this commit documents hook coverage for interactive skill installs. Load/initialize the plugin registry (or otherwise guarantee hook runner availability) before this check so policy hooks are actually enforced.
Useful? React with 👍 / 👎.
2a60ff3 to
895bf78
Compare
🔒 Aisle Security AnalysisWe found 6 potential security issue(s) in this PR:
Vulnerabilities1. 🟠 Fail-open install policy enforcement when before_install hook fails (skill installs)
DescriptionThe
This creates a fail-open policy path: an attacker can attempt to induce failures in the policy plugin (e.g., by triggering edge cases, resource exhaustion, or crashes) to bypass blocking decisions. Vulnerable code: try {
const hookResult = await hookRunner.runBeforeInstall(event, ctx);
if (hookResult?.block) {
return { ok: false, message: hookResult.blockReason || "Installation blocked by plugin hook", ... };
}
// ... merge findings ...
} catch {
// Hook errors are non-fatal — built-in scanner results still apply.
}RecommendationMake install policy enforcement fail-closed when configured for enforcement. Options:
const enforcement = config.plugins?.installPolicy?.failClosed === true;
try {
const hookResult = await hookRunner.runBeforeInstall(event, ctx);
if (hookResult?.block) return blocked(hookResult.blockReason);
} catch (err) {
if (enforcement) {
return blocked(`before_install hook failed: ${String(err)}`);
}
logger.warn?.(`before_install hook failed (non-fatal): ${String(err)}`);
}
2. 🟠 Fail-open install policy enforcement when before_install hook fails (plugin installs)
DescriptionThe plugin install security scan invokes the
This enables a fail-open policy bypass: an attacker can attempt to trigger failures (e.g., via crafted install targets, resource exhaustion, or crashing the policy plugin) to avoid blocking. Vulnerable code: try {
const hookResult = await hookRunner.runBeforeInstall(event, ctx);
if (hookResult?.block) { ... return { blocked: { reason } }; }
// ...
} catch {
// Hook errors are non-fatal.
}
return undefined;RecommendationIntroduce an explicit enforcement mode for install policy hooks and fail closed when enabled. Example pattern: const enforcement = config.plugins?.installPolicy?.failClosed === true;
try {
const hookResult = await hookRunner.runBeforeInstall(event, ctx);
if (hookResult?.block) return { blocked: { reason: hookResult.blockReason ?? "blocked" } };
} catch (err) {
if (enforcement) {
return { blocked: { reason: `before_install hook failed: ${String(err)}` } };
}
params.logger.warn?.(`before_install hook failed (non-fatal): ${String(err)}`);
}Also consider:
3. 🟡 Information disclosure to all plugins via before_install hook payload (absolute paths and raw specifiers/URLs)
DescriptionThe new Exposed fields include:
Because hooks run in third-party plugin code, a malicious/compromised plugin can passively collect and exfiltrate this information (install locations, user home/workspace paths, internal URLs/tokens) whenever any install occurs. Vulnerable code (payload construction and dispatch): const { event, ctx } = createBeforeInstallHookPayload({
sourcePath: params.sourcePath,
request: {
kind: params.requestKind,
mode: params.requestMode,
...(params.requestedSpecifier ? { requestedSpecifier: params.requestedSpecifier } : {}),
},
builtinScan: params.builtinScan,
});
const hookResult = await hookRunner.runBeforeInstall(event, ctx);RecommendationTreat Recommended fixes (choose one or combine):
Example: sanitize function sanitizeSpecifier(spec?: string) {
if (!spec) return undefined;
try {
const u = new URL(spec);
// drop credentials/token-like userinfo
u.username = "";
u.password = "";
return u.toString();
} catch {
// for local paths, keep only basename
return path.basename(spec);
}
}
const { event, ctx } = createBeforeInstallHookPayload({
...,
sourcePath: path.basename(params.sourcePath),
request: {
kind: params.requestKind,
mode: params.requestMode,
requestedSpecifier: sanitizeSpecifier(params.requestedSpecifier),
},
builtinScan: {
...params.builtinScan,
error: undefined,
},
});4. 🟡 Terminal/log injection via unsanitized before_install hook output (blockReason/findings) in skill install flow
DescriptionUntrusted strings returned by
Vulnerable code: message: hookResult.blockReason || "Installation blocked by plugin hook",
...
warnings.push(`Plugin scanner: ${finding.message} (${finding.file}:${finding.line})`);RecommendationSanitize/escape any plugin-provided text before returning it to callers or printing it. This repo already contains Example fix: import { sanitizeTerminalText } from "../terminal/safe-text.js";
const safeReason = sanitizeTerminalText(hookResult.blockReason ?? "Installation blocked by plugin hook");
...
message: safeReason,
...
const safeMsg = sanitizeTerminalText(finding.message);
const safeFile = sanitizeTerminalText(finding.file);
warnings.push(`Plugin scanner: ${safeMsg} (${safeFile}:${finding.line})`);Also consider enforcing a single-line constraint (or JSON-encoding) for log output destined for terminals/structured logs. 5. 🟡 Plugin file install security scan can be bypassed with oversized JS/TS file (no scan performed)
Description
However, the underlying scanner silently skips files larger than Vulnerable behavior:
Vulnerable code: if (st.size > maxFileBytes) {
...
return { scanned: false, findings: [] };
}RecommendationTreat oversized files as a security-relevant condition during install scanning, not a silent skip. Options:
Example (mark as error and surface in summary): if (st.size > maxFileBytes) {
return {
scanned: true,
findings: [{
ruleId: "file_too_large_to_scan",
severity: "warn",
file: filePath,
line: 1,
message: `File exceeds max scan size (${maxFileBytes} bytes); install-time scan incomplete`,
evidence: "",
}],
};
}Additionally, in 6. 🟡 Terminal/log injection via unsanitized before_install hook output (blockReason/findings) in plugin install security scan runtime
DescriptionUntrusted strings returned by
Vulnerable code: params.logger.warn?.(`WARNING: ${params.installLabel} blocked by plugin hook: ${reason}`);
...
params.logger.warn?.(`Plugin scanner: ${finding.message} (${finding.file}:${finding.line})`);RecommendationEscape/sanitize hook-provided fields before logging. Use the existing terminal-safe sanitizer to strip ANSI escape codes and encode control characters: import { sanitizeTerminalText } from "../terminal/safe-text.js";
const safeReason = sanitizeTerminalText(reason);
params.logger.warn?.(
`WARNING: ${sanitizeTerminalText(params.installLabel)} blocked by plugin hook: ${safeReason}`,
);
const safeMsg = sanitizeTerminalText(finding.message);
const safeFile = sanitizeTerminalText(finding.file);
params.logger.warn?.(`Plugin scanner: ${safeMsg} (${safeFile}:${finding.line})`);If logs are consumed by UIs (markdown/HTML), apply the appropriate context-sensitive escaping there as well. Analyzed PR: #56050 at commit Last updated on: 2026-03-30T09:58:30Z |
895bf78 to
7fd0d37
Compare
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 7fd0d37408
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| const hookRunner = getGlobalHookRunner(); | ||
| if (!hookRunner?.hasHooks("before_install")) { | ||
| return undefined; |
There was a problem hiding this comment.
Initialize hooks before running install security scans
This new hook path silently no-ops whenever the global runner has not been initialized, and in the CLI/plugin-install flows I checked (src/cli/plugins-install-command.ts and src/commands/channel-setup/plugin-install.ts), installs call into installPluginFromPath/installPluginFromNpmSpec without loading plugins first, so before_install scanners are never invoked in those common paths. As a result, enterprise/policy hooks cannot block or annotate plugin installs despite the new contract documenting that behavior.
Useful? React with 👍 / 👎.
7fd0d37 to
e59ad0a
Compare
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: e59ad0aba5
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| return await scanDirectoryTarget({ | ||
| includeFiles: [params.path], | ||
| logger: params.logger, | ||
| path: directory, |
There was a problem hiding this comment.
Scan only the target file in file-plugin installs
scanFileTarget passes path.dirname(file) to scanDirectoryTarget, which uses scanDirectoryWithSummary; that scanner always walks the directory in addition to includeFiles, so sibling .js/.ts files are included in builtinScan. For installPluginFromFile, this means unrelated files next to the chosen plugin file can produce findings and trigger before_install policy blocks, even though those files are never installed. Restrict this path to scanning the selected file only (or an isolated temp copy) so policy decisions reflect the actual install payload.
Useful? React with 👍 / 👎.
d5f5c91 to
c5fd153
Compare
|
Landed via rebase merge after refreshing the branch onto current main.
Thanks @odysseus0! |
Summary
Adds a
before_installplugin hook as a long-term foundation for install-time security and policy plugins.This version intentionally treats install review as a normalized decision context, not as a narrow scanner callback. It now covers:
Design goals
The hook is meant to answer the core policy questions before OpenClaw trusts an install target:
Hook contract
The hook is
before_installand remains a simple modifying hook:findingsblock: trueblock: trueis terminalThe event is richer than the original proposal and now exposes:
Target identity
targetType:"skill" | "plugin"targetNameScanned content
sourcePathsourcePathKind:"file" | "directory"originRequest provenance
request.kind:skill-install | plugin-dir | plugin-archive | plugin-file | plugin-npmrequest.mode:install | updaterequest.requestedSpecifierwhen applicableBuilt-in scanner result
builtinScan.status:ok | errorbuiltinScan.scannedFilesbuiltinScan.critical / warn / infobuiltinScan.findingsbuiltinScan.errorwhen the built-in scan failsTarget-specific metadata
For skills:
skill.installIdskill.installSpecFor plugins:
plugin.pluginIdplugin.contentType:bundle | package | fileplugin.packageNameplugin.manifestIdplugin.versionplugin.extensionsArchitectural cleanup
To keep this as a stable foundation instead of letting entropy grow, the PR also introduces a single internal payload-shaping point:
src/plugins/install-policy-context.tsAll public
before_installpayload construction now flows through that builder. This gives us one place to quickly take partner feedback and evolve the contract without letting skill/plugin install paths drift apart.The contract also now uses
origininstead of overloadingsource, so provenance remains clearer as install surfaces grow.Files changed
src/plugins/types.ts— generalizedbefore_installcontract and policy metadatasrc/plugins/install-policy-context.ts— centralized builder for the public hook payloadsrc/agents/skills-install.ts— skill install policy context wiringsrc/plugins/install-security-scan.runtime.ts— bundle/package/file scan integration with the centralized buildersrc/plugins/install.ts/src/plugins/install-security-scan.ts/src/plugins/install.runtime.ts— provenance propagation across dir/archive/npm/file plugin install pathsValidation
pnpm checkpnpm buildFocused tests were updated for skills, plugin package installs, and single-file plugin installs. The repo test wrapper was unusually slow in this environment, so I did not rely on a full
pnpm testrerun for the final loop.