diff --git a/docs/cli/plugins.md b/docs/cli/plugins.md
index c05a23758e2b..e5645ff817a7 100644
--- a/docs/cli/plugins.md
+++ b/docs/cli/plugins.md
@@ -159,7 +159,7 @@ is available, then fall back to `latest`.
`--dangerously-force-unsafe-install` is deprecated and is now a no-op. OpenClaw no longer runs built-in install-time dangerous-code blocking for plugin installs.
- Use the shared operator-owned `security.installPolicy` surface when host-specific install policy is required. Plugin `before_install` hooks and `security.installPolicy` can still block installs.
+ Use the shared operator-owned `security.installPolicy` surface when host-specific install policy is required. Plugin `before_install` hooks are plugin-runtime lifecycle hooks and are not the primary policy boundary for CLI installs.
If a plugin you published on ClawHub is hidden or blocked by a registry scan, use the publisher steps in [ClawHub publishing](/clawhub/publishing). `--dangerously-force-unsafe-install` does not ask ClawHub to rescan the plugin or make a blocked release public.
@@ -405,7 +405,7 @@ Updates apply to tracked plugin installs in the managed plugin index and tracked
- `--dangerously-force-unsafe-install` is also accepted on `plugins update` for compatibility, but it is deprecated and no longer changes plugin update behavior. Operator `security.installPolicy` and plugin `before_install` hooks can still block updates.
+ `--dangerously-force-unsafe-install` is also accepted on `plugins update` for compatibility, but it is deprecated and no longer changes plugin update behavior. Operator `security.installPolicy` can still block updates; plugin `before_install` hooks only apply in processes where plugin hooks are loaded.
diff --git a/docs/concepts/agent-loop.md b/docs/concepts/agent-loop.md
index f4235ae6bedc..7adcb4403a98 100644
--- a/docs/concepts/agent-loop.md
+++ b/docs/concepts/agent-loop.md
@@ -97,7 +97,7 @@ These run inside the agent loop or gateway pipeline:
- **`agent_end`**: inspect the final message list and run metadata after completion.
- **`before_compaction` / `after_compaction`**: observe or annotate compaction cycles.
- **`before_tool_call` / `after_tool_call`**: intercept tool params/results.
-- **`before_install`**: inspect install context and optionally block skill or plugin installs after operator install policy runs.
+- **`before_install`**: inspect staged skill or plugin install material after operator install policy runs, when plugin hooks are loaded in the current OpenClaw process.
- **`tool_result_persist`**: synchronously transform tool results before they are written to an OpenClaw-owned session transcript.
- **`message_received` / `message_sending` / `message_sent`**: inbound + outbound message hooks.
- **`session_start` / `session_end`**: session lifecycle boundaries.
@@ -109,6 +109,7 @@ Hook decision rules for outbound/tool guards:
- `before_tool_call`: `{ block: false }` is a no-op and does not clear a prior block.
- `before_install`: `{ block: true }` is terminal and stops lower-priority handlers.
- `before_install`: `{ block: false }` is a no-op and does not clear a prior block.
+- Use `security.installPolicy`, not `before_install`, for operator-owned install allow/block decisions that must cover CLI install and update paths.
- `message_sending`: `{ cancel: true }` is terminal and stops lower-priority handlers.
- `message_sending`: `{ cancel: false }` is a no-op and does not clear a prior cancel.
diff --git a/docs/plugins/hooks.md b/docs/plugins/hooks.md
index d18d9dc68a83..374b6f9e19a8 100644
--- a/docs/plugins/hooks.md
+++ b/docs/plugins/hooks.md
@@ -152,7 +152,8 @@ observation-only.
- `gateway_start` / `gateway_stop` - start or stop plugin-owned services with the Gateway
- `deactivate` - deprecated compatibility alias for `gateway_stop`; use `gateway_stop` in new plugins
- `cron_changed` - observe gateway-owned cron lifecycle changes (added, updated, removed, started, finished, scheduled)
-- **`before_install`** - inspect skill or plugin install context and optionally block
+- **`before_install`** - inspect staged skill or plugin install material from a loaded
+ plugin runtime
## Debug runtime hooks
@@ -462,11 +463,19 @@ Decision rules:
## Install hooks
-`before_install` runs after the operator-owned `security.installPolicy` check
-when one is configured. The `builtinScan` field remains in the event payload for
-compatibility, but OpenClaw no longer runs built-in install-time dangerous-code
-blocking, so it is an empty `ok` result. Return additional findings or
-`{ block: true, blockReason }` to stop the install.
+Use `security.installPolicy` for operator-owned allow/block decisions. That
+policy runs from OpenClaw config, covers CLI install and update paths, and fails
+closed when enabled but unavailable.
+
+`before_install` is a plugin-runtime lifecycle hook. It runs after
+`security.installPolicy` only in the OpenClaw process where plugin hooks have
+already been loaded, such as Gateway-backed install flows. It is useful for
+plugin-owned observations, warnings, and compatibility checks, but it is not the
+primary enterprise or host security boundary for installs. The `builtinScan`
+field remains in the event payload for compatibility, but OpenClaw no longer
+runs built-in install-time dangerous-code blocking, so it is an empty `ok`
+result. Return additional findings or `{ block: true, blockReason }` to stop the
+install in that process.
`block: true` is terminal. `block: false` is treated as no decision.
Handler failures block the install fail-closed.
diff --git a/docs/plugins/sdk-overview.md b/docs/plugins/sdk-overview.md
index 512209b1930f..601de43a3767 100644
--- a/docs/plugins/sdk-overview.md
+++ b/docs/plugins/sdk-overview.md
@@ -431,6 +431,10 @@ semantics.
### Hook decision semantics
+`before_install` is a plugin-runtime lifecycle hook, not the operator install
+policy surface. Use `security.installPolicy` when an allow/block decision must
+cover CLI and Gateway-backed install or update paths.
+
- `before_tool_call`: returning `{ block: true }` is terminal. Once any handler sets it, lower-priority handlers are skipped.
- `before_tool_call`: returning `{ block: false }` is treated as no decision (same as omitting `block`), not as an override.
- `before_install`: returning `{ block: true }` is terminal. Once any handler sets it, lower-priority handlers are skipped.
diff --git a/docs/tools/plugin.md b/docs/tools/plugin.md
index 9ce999105d61..a38234d6957d 100644
--- a/docs/tools/plugin.md
+++ b/docs/tools/plugin.md
@@ -147,10 +147,12 @@ such as `@beta` stay pinned to the selected package and fail when incompatible.
Configure `security.installPolicy` to run a trusted local policy command before
plugin install or update proceeds. The policy receives metadata plus the staged
-source path and can allow or block the install. It runs before plugin
-`before_install` hooks. The deprecated `--dangerously-force-unsafe-install`
-flag is accepted for compatibility but does not bypass install policy, hooks, or
-OpenClaw's built-in plugin dependency denylist.
+source path and can allow or block the install. It covers CLI and Gateway-backed
+plugin install/update paths. Plugin `before_install` hooks run later only in
+OpenClaw processes where plugin hooks are loaded, so use `security.installPolicy`
+for operator-owned install decisions. The deprecated
+`--dangerously-force-unsafe-install` flag is accepted for compatibility but does
+not bypass install policy or OpenClaw's built-in plugin dependency denylist.
See [Skills config](/tools/skills-config#operator-install-policy-securityinstallpolicy)
for the shared `security.installPolicy` exec schema used by both skills and
diff --git a/src/cli/plugins-cli.ts b/src/cli/plugins-cli.ts
index f09cb0cd332e..cc0457d37488 100644
--- a/src/cli/plugins-cli.ts
+++ b/src/cli/plugins-cli.ts
@@ -153,7 +153,7 @@ export function registerPluginsCli(program: Command) {
.option("--pin", "Record npm installs as exact resolved @", false)
.option(
"--dangerously-force-unsafe-install",
- "Deprecated no-op; install policy and plugin hooks may still block",
+ "Deprecated no-op; security.installPolicy may still block",
false,
)
.option(
@@ -184,7 +184,7 @@ export function registerPluginsCli(program: Command) {
.option("--dry-run", "Show what would change without writing", false)
.option(
"--dangerously-force-unsafe-install",
- "Deprecated no-op; install policy and plugin hooks may still block",
+ "Deprecated no-op; security.installPolicy may still block",
false,
)
.action(async (id: string | undefined, opts: PluginUpdateOptions) => {
diff --git a/src/cli/plugins-cli.update.test.ts b/src/cli/plugins-cli.update.test.ts
index c9a23a8c6db5..0870b2fc9412 100644
--- a/src/cli/plugins-cli.update.test.ts
+++ b/src/cli/plugins-cli.update.test.ts
@@ -78,8 +78,8 @@ describe("plugins cli update", () => {
expect(helpText).toContain("--dangerously-force-unsafe-install");
expect(helpText).toContain("Deprecated no-op");
- expect(helpText).toContain("install policy and");
- expect(helpText).toContain("plugin hooks may still block");
+ expect(helpText).toContain("security.installPolicy");
+ expect(helpText).toContain("may still block");
});
it("refuses plugin updates in Nix mode before package-manager work", async () => {
diff --git a/src/plugins/install-policy-context.ts b/src/plugins/install-policy-context.ts
index 3426dffd701f..976c9fb07c9e 100644
--- a/src/plugins/install-policy-context.ts
+++ b/src/plugins/install-policy-context.ts
@@ -1,4 +1,4 @@
-// Builds install policy context for plugin install checks.
+// Builds payloads for the plugin-runtime before_install lifecycle hook.
import type {
PluginHookBeforeInstallBuiltinScan,
PluginHookBeforeInstallContext,
diff --git a/src/plugins/install.test.ts b/src/plugins/install.test.ts
index d8b19c62b0c1..3ee417d2bc16 100644
--- a/src/plugins/install.test.ts
+++ b/src/plugins/install.test.ts
@@ -1970,7 +1970,7 @@ describe("installPluginFromArchive", () => {
);
});
- it("surfaces plugin scanner findings from before_install", async () => {
+ it("surfaces plugin lifecycle findings from before_install", async () => {
const handler = vi.fn().mockReturnValue({
findings: [
{
@@ -2052,10 +2052,10 @@ describe("installPluginFromArchive", () => {
expect(requests[1]?.request.requestedSpecifier).toBe(pluginDir);
});
- it("blocks plugin install when before_install rejects dangerous-looking source", async () => {
+ it("blocks plugin install when before_install rejects the staged source", async () => {
const handler = vi.fn().mockReturnValue({
block: true,
- blockReason: "Blocked by enterprise policy",
+ blockReason: "Blocked by plugin lifecycle hook",
});
initializeGlobalHookRunner(createMockPluginRegistry([{ hookName: "before_install", handler }]));
@@ -2078,7 +2078,7 @@ describe("installPluginFromArchive", () => {
expect(result.ok).toBe(false);
if (!result.ok) {
- expect(result.error).toBe("Blocked by enterprise policy");
+ expect(result.error).toBe("Blocked by plugin lifecycle hook");
expect(result.code).toBe(PLUGIN_INSTALL_ERROR_CODE.SECURITY_SCAN_BLOCKED);
}
expect(handler).toHaveBeenCalledTimes(1);
@@ -2098,14 +2098,14 @@ describe("installPluginFromArchive", () => {
extensions: ["index.js"],
});
expect(
- warnings.some((w) => w.includes("blocked by plugin hook: Blocked by enterprise policy")),
+ warnings.some((w) => w.includes("blocked by plugin hook: Blocked by plugin lifecycle hook")),
).toBe(true);
});
it("keeps before_install hook blocks even when dangerous force unsafe install is set", async () => {
const handler = vi.fn().mockReturnValue({
block: true,
- blockReason: "Blocked by enterprise policy",
+ blockReason: "Blocked by plugin lifecycle hook",
});
initializeGlobalHookRunner(createMockPluginRegistry([{ hookName: "before_install", handler }]));
@@ -2132,7 +2132,7 @@ describe("installPluginFromArchive", () => {
expect(result.ok).toBe(false);
if (!result.ok) {
- expect(result.error).toBe("Blocked by enterprise policy");
+ expect(result.error).toBe("Blocked by plugin lifecycle hook");
expect(result.code).toBe(PLUGIN_INSTALL_ERROR_CODE.SECURITY_SCAN_BLOCKED);
}
expect(
@@ -2144,7 +2144,7 @@ describe("installPluginFromArchive", () => {
).toBe(false);
expect(
warnings.some((warning) =>
- warning.includes("blocked by plugin hook: Blocked by enterprise policy"),
+ warning.includes("blocked by plugin hook: Blocked by plugin lifecycle hook"),
),
).toBe(true);
});
diff --git a/src/skills/lifecycle/install.test.ts b/src/skills/lifecycle/install.test.ts
index 457feadd7755..b12f3a140a49 100644
--- a/src/skills/lifecycle/install.test.ts
+++ b/src/skills/lifecycle/install.test.ts
@@ -109,7 +109,7 @@ async function withWorkspaceCase(
}
}
-describe("installSkill install policy hooks", () => {
+describe("installSkill before_install hooks", () => {
beforeEach(() => {
resetGlobalHookRunner();
runCommandWithTimeoutMock.mockClear();
@@ -273,7 +273,7 @@ describe("installSkill install policy hooks", () => {
it("blocks install when before_install rejects the skill", async () => {
const handler = vi.fn().mockReturnValue({
block: true,
- blockReason: "Blocked by enterprise policy",
+ blockReason: "Blocked by plugin lifecycle hook",
});
initializeGlobalHookRunner(createMockPluginRegistry([{ hookName: "before_install", handler }]));
@@ -287,7 +287,7 @@ describe("installSkill install policy hooks", () => {
});
expect(result.ok).toBe(false);
- expect(result.message).toBe("Blocked by enterprise policy");
+ expect(result.message).toBe("Blocked by plugin lifecycle hook");
expect(runCommandWithTimeoutMock).not.toHaveBeenCalled();
});
});