Skip to content

feat(plugins): add before_install hook for install policy checks#56050

Merged
odysseus0 merged 8 commits into
openclaw:mainfrom
odysseus0:feat/before-skill-install-hook
Mar 29, 2026
Merged

feat(plugins): add before_install hook for install policy checks#56050
odysseus0 merged 8 commits into
openclaw:mainfrom
odysseus0:feat/before-skill-install-hook

Conversation

@odysseus0

@odysseus0 odysseus0 commented Mar 27, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds a before_install plugin 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:

  • interactive skill installs
  • plugin bundle installs
  • plugin package installs
  • single-file plugin installs

Design goals

The hook is meant to answer the core policy questions before OpenClaw trusts an install target:

  1. what is being installed?
  2. what exact content is being scanned?
  3. how was the install requested?
  4. what did the built-in scanner already conclude?
  5. what skill/plugin-specific metadata should policy code see?

Hook contract

The hook is before_install and remains a simple modifying hook:

  • handlers may add findings
  • handlers may return block: true
  • block: true is terminal
  • findings accumulate across handlers
  • hook errors are non-fatal

The event is richer than the original proposal and now exposes:

Target identity

  • targetType: "skill" | "plugin"
  • targetName

Scanned content

  • sourcePath
  • sourcePathKind: "file" | "directory"
  • origin

Request provenance

  • request.kind: skill-install | plugin-dir | plugin-archive | plugin-file | plugin-npm
  • request.mode: install | update
  • request.requestedSpecifier when applicable

Built-in scanner result

  • builtinScan.status: ok | error
  • builtinScan.scannedFiles
  • builtinScan.critical / warn / info
  • builtinScan.findings
  • builtinScan.error when the built-in scan fails

Target-specific metadata

For skills:

  • skill.installId
  • skill.installSpec

For plugins:

  • plugin.pluginId
  • plugin.contentType: bundle | package | file
  • optional plugin.packageName
  • optional plugin.manifestId
  • optional plugin.version
  • optional plugin.extensions

Architectural 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.ts

All public before_install payload 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 origin instead of overloading source, so provenance remains clearer as install surfaces grow.

Files changed

  • src/plugins/types.ts — generalized before_install contract and policy metadata
  • src/plugins/install-policy-context.ts — centralized builder for the public hook payload
  • src/agents/skills-install.ts — skill install policy context wiring
  • src/plugins/install-security-scan.runtime.ts — bundle/package/file scan integration with the centralized builder
  • src/plugins/install.ts / src/plugins/install-security-scan.ts / src/plugins/install.runtime.ts — provenance propagation across dir/archive/npm/file plugin install paths
  • tests + docs — updated around the stronger install-policy contract

Validation

  • pnpm check
  • pnpm build

Focused 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 test rerun for the final loop.

@openclaw-barnacle openclaw-barnacle Bot added agents Agent runtime and tooling size: M maintainer Maintainer-authored PR labels Mar 27, 2026

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment on lines +63 to +66
if (hookResult?.block) {
params.logger.warn?.(
`WARNING: Bundle "${params.pluginId}" installation blocked by plugin hook: ${hookResult.blockReason || "no reason given"}`,
);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge 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 👍 / 👎.

Comment on lines 34 to 36
if (scanSummary.critical > 0) {
params.logger.warn?.(
`WARNING: Bundle "${params.pluginId}" contains dangerous code patterns: ${buildCriticalDetails({ findings: scanSummary.findings })}`,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge 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-apps

greptile-apps Bot commented Mar 27, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR introduces a before_skill_install plugin hook that lets external security scanners augment or block skill installation. The type definitions, hook runner implementation, and the skills-install.ts invocation path are well-structured and follow existing patterns. However, the two runtime-scan helper functions (scanBundleInstallSourceRuntime / scanPackageInstallSourceRuntime) have a pair of P1 defects that undermine the security contract of the feature:

  • block: true is not enforced in the bundle/package install paths. Both functions return void, so when a plugin hook returns block: true, only a warning is logged — the caller has no signal and installation proceeds normally.
  • The hook is never invoked when the built-in scanner finds critical findings. An early return; in both helpers (on scanSummary.critical > 0) exits before the new hook block is reached, so plugins are silently skipped in exactly the highest-risk scenario.

Confidence Score: 3/5

Not safe to merge — the hook's block: true signal is silently ignored in the bundle/package install paths, so the primary security guarantee of the feature is unenforceable there.

Two P1 defects in install-security-scan.runtime.ts: block semantics are unenforced (hook result is discarded), and the hook is skipped entirely when the built-in scanner finds critical issues. These directly contradict the stated design and test plan for the feature. The skills-install.ts and hooks.ts portions are correct.

src/plugins/install-security-scan.runtime.ts requires the most attention — both P1 issues are isolated to this file.

Important Files Changed

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)

  1. src/plugins/install-security-scan.runtime.ts, line 34-39 (link)

    P1 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).

    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

Comment on lines +63 to +67
if (hookResult?.block) {
params.logger.warn?.(
`WARNING: Bundle "${params.pluginId}" installation blocked by plugin hook: ${hookResult.blockReason || "no reason given"}`,
);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 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.

Comment thread src/plugins/hooks.ts
return {
findings: mergedFindings.length > 0 ? mergedFindings : undefined,
block: stickyTrue(acc?.block, next.block),
blockReason: lastDefined(acc?.blockReason, next.blockReason),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 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.

@openclaw-barnacle openclaw-barnacle Bot added docs Improvements or additions to documentation size: L and removed size: M labels Mar 27, 2026

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment thread src/agents/skills-install.ts Outdated
Comment on lines +462 to +463
const hookRunner = getGlobalHookRunner();
if (hookRunner?.hasHooks("before_skill_install")) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge 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 👍 / 👎.

@odysseus0
odysseus0 force-pushed the feat/before-skill-install-hook branch from 2a60ff3 to 895bf78 Compare March 29, 2026 16:49
@aisle-research-bot

aisle-research-bot Bot commented Mar 29, 2026

Copy link
Copy Markdown

🔒 Aisle Security Analysis

We found 6 potential security issue(s) in this PR:

# Severity Title
1 🟠 High Fail-open install policy enforcement when before_install hook fails (skill installs)
2 🟠 High Fail-open install policy enforcement when before_install hook fails (plugin installs)
3 🟡 Medium Information disclosure to all plugins via before_install hook payload (absolute paths and raw specifiers/URLs)
4 🟡 Medium Terminal/log injection via unsanitized before_install hook output (blockReason/findings) in skill install flow
5 🟡 Medium Plugin file install security scan can be bypassed with oversized JS/TS file (no scan performed)
6 🟡 Medium Terminal/log injection via unsanitized before_install hook output (blockReason/findings) in plugin install security scan runtime
Vulnerabilities

1. 🟠 Fail-open install policy enforcement when before_install hook fails (skill installs)

Property Value
Severity High
CWE CWE-693
Location src/agents/skills-install.ts:508-552

Description

The before_install hook is intended to let enterprise policy/scanner plugins block risky installs, but errors from the hook execution are silently swallowed and treated as non-fatal.

  • before_install is executed before continuing with installation.
  • Any exception thrown during payload creation or hook execution is caught and ignored.
  • Because the global hook runner is also configured with catchErrors: true, plugin hook failures will be logged at most, and the install proceeds.

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.
}

Recommendation

Make install policy enforcement fail-closed when configured for enforcement.

Options:

  1. Add an explicit enforcement mode (e.g., config.plugins.installPolicy.failClosed = true) and block on hook errors when enabled:
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)}`);
}
  1. Consider running policy/scanner hooks out-of-process with timeouts so untrusted inputs cannot trivially crash or hang the enforcement mechanism.

  2. If before_install is used for compliance, document that hook failures are treated as deny when enforcement is enabled.


2. 🟠 Fail-open install policy enforcement when before_install hook fails (plugin installs)

Property Value
Severity High
CWE CWE-693
Location src/plugins/install-security-scan.runtime.ts:171-209

Description

The plugin install security scan invokes the before_install hook to let external policy/scanner plugins block installs, but all hook errors are swallowed and treated as non-fatal.

  • runBeforeInstallHook() wraps hook invocation in a try { ... } catch {} that discards any error.
  • The global hook runner is initialized with catchErrors: true, so handler exceptions are suppressed and converted into logs.
  • The install continues even if the policy plugin is down, throws, or cannot complete.

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;

Recommendation

Introduce 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:

  • timeouts around hook execution to prevent hangs
  • isolating policy/scanner plugins (separate process) if they must be relied upon for security/compliance

3. 🟡 Information disclosure to all plugins via before_install hook payload (absolute paths and raw specifiers/URLs)

Property Value
Severity Medium
CWE CWE-200
Location src/plugins/install-security-scan.runtime.ts:171-193

Description

The new before_install plugin hook exposes potentially sensitive local and user-provided data to any installed plugin that registers the hook, without redaction or an explicit permission/allowlist.

Exposed fields include:

  • event.sourcePath: absolute filesystem path to the scanned install source (plugin/skill)
  • event.request.requestedSpecifier: raw user-provided specifier (can be a path or URL and may include embedded credentials/tokens)
  • event.skill.installSpec.* fields such as url, archive, targetDir (may contain signed URLs, internal paths, or other sensitive values)
  • event.builtinScan.error: String(err) may include stack traces and local paths

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);

Recommendation

Treat before_install payload as sensitive and minimize by default.

Recommended fixes (choose one or combine):

  1. Redact/omit sensitive fields by default and only include them when explicitly enabled:

    • send sourcePath as a relative path or basename, or replace with a stable opaque identifier
    • drop requestedSpecifier entirely or sanitize it (remove userinfo from URLs; avoid including full local paths)
    • redact installSpec.url/archive/targetDir or only include non-sensitive derived metadata (host, scheme, etc.)
    • do not pass raw error strings; pass a stable error code/category
  2. Permission/allowlist model: only trusted/built-in plugins (or plugins granted an explicit capability such as install_policy:read_sensitive) may receive the full payload.

Example: sanitize requestedSpecifier and avoid absolute paths:

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

Property Value
Severity Medium
CWE CWE-117
Location src/agents/skills-install.ts:529-547

Description

Untrusted strings returned by before_install plugin hooks are incorporated into user-visible messages without any escaping/sanitization.

  • hookResult.blockReason is returned directly as the install failure message.
  • hookResult.findings[].message/file/line are interpolated directly into warnings strings.
  • If a hook returns strings containing newlines (\n), carriage returns (\r), tabs, or ANSI escape sequences, downstream CLI/log renderers may:
    • spoof or overwrite terminal output,
    • forge multi-line log entries,
    • hide warnings or make a blocked install look successful.

Vulnerable code:

message: hookResult.blockReason || "Installation blocked by plugin hook",
...
warnings.push(`Plugin scanner: ${finding.message} (${finding.file}:${finding.line})`);

Recommendation

Sanitize/escape any plugin-provided text before returning it to callers or printing it.

This repo already contains sanitizeTerminalText() (strips ANSI and escapes control characters). Use it for blockReason, finding.message, and finding.file (and any other untrusted fields).

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)

Property Value
Severity Medium
CWE CWE-693
Location src/security/skill-scanner.ts:489-499

Description

installPluginFromFile() now invokes scanFileInstallSourceRuntime(), which scans a single file by calling scanDirectoryWithSummary() with includeFiles=[filePath].

However, the underlying scanner silently skips files larger than maxFileBytes (default 1 MiB) and reports them as scanned: false with no findings. This results in scannedFiles remaining 0 and the install proceeding without any warning/critical findings, allowing a malicious plugin author to evade the built-in install-time scan by shipping a large .js/.ts plugin file.

Vulnerable behavior:

  • input: attacker-controlled plugin file path during installPluginFromFile()
  • scanner decision: scanFileWithCache() skips when st.size > maxFileBytes
  • effect: scanDirectoryWithSummary() returns scannedFiles: 0 and no findings; scanFileInstallSourceRuntime() returns status: ok and does not warn/block

Vulnerable code:

if (st.size > maxFileBytes) {
  ...
  return { scanned: false, findings: [] };
}

Recommendation

Treat oversized files as a security-relevant condition during install scanning, not a silent skip.

Options:

  1. Fail closed / block install for plugin installs when the target file exceeds the scan limit.
  2. Emit a prominent warning and mark scan status as error, so policy hooks can block.
  3. Scan a capped prefix (e.g., first N bytes) and still warn that the file exceeded the limit.

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 scanFileInstallSourceRuntime(), consider blocking or warning when builtinScan.scannedFiles === 0 for a supposedly scannable plugin file.


6. 🟡 Terminal/log injection via unsanitized before_install hook output (blockReason/findings) in plugin install security scan runtime

Property Value
Severity Medium
CWE CWE-117
Location src/plugins/install-security-scan.runtime.ts:193-204

Description

Untrusted strings returned by before_install plugin hooks are written directly to logs/terminal output without escaping.

  • hookResult.blockReason is interpolated into a logger.warn message.
  • hookResult.findings[].message/file/line are interpolated into logger.warn messages.
  • A malicious or compromised hook can include newlines/CRLF or ANSI escape sequences, enabling log forging and terminal UI spoofing (e.g., hiding the real warning or injecting fake status lines).

Vulnerable code:

params.logger.warn?.(`WARNING: ${params.installLabel} blocked by plugin hook: ${reason}`);
...
params.logger.warn?.(`Plugin scanner: ${finding.message} (${finding.file}:${finding.line})`);

Recommendation

Escape/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 c5fd153

Last updated on: 2026-03-30T09:58:30Z

@odysseus0
odysseus0 force-pushed the feat/before-skill-install-hook branch from 895bf78 to 7fd0d37 Compare March 29, 2026 17:19
@odysseus0 odysseus0 changed the title feat(plugins): add before_skill_install hook for external security scanners feat(plugins): add before_install hook for install scanners Mar 29, 2026

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment on lines +42 to +44
const hookRunner = getGlobalHookRunner();
if (!hookRunner?.hasHooks("before_install")) {
return undefined;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge 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 👍 / 👎.

@odysseus0 odysseus0 changed the title feat(plugins): add before_install hook for install scanners feat(plugins): add before_install hook for install policy checks Mar 29, 2026
@odysseus0
odysseus0 force-pushed the feat/before-skill-install-hook branch from 7fd0d37 to e59ad0a Compare March 29, 2026 18:02

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment on lines +121 to +124
return await scanDirectoryTarget({
includeFiles: [params.path],
logger: params.logger,
path: directory,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

@odysseus0
odysseus0 force-pushed the feat/before-skill-install-hook branch from d5f5c91 to c5fd153 Compare March 29, 2026 19:33
@odysseus0 odysseus0 self-assigned this Mar 29, 2026
@odysseus0
odysseus0 merged commit b787669 into openclaw:main Mar 29, 2026
8 checks passed
@odysseus0

Copy link
Copy Markdown
Contributor Author

Landed via rebase merge after refreshing the branch onto current main.

  • Local signals: pnpm check, pnpm build, focused before_install smoke on real plugin install entrypoints
  • Full pnpm test: not rerun in the final loop because the local wrapper was prohibitively slow in this environment
  • Prepared head: c5fd153
  • Merge commit: b787669

Thanks @odysseus0!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

agents Agent runtime and tooling docs Improvements or additions to documentation maintainer Maintainer-authored PR size: XL

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant