diff --git a/CHANGELOG.md b/CHANGELOG.md index afea749285e..ff3a805bf69 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ Docs: https://docs.openclaw.ai - TTS/OpenAI-compatible endpoints: add `messages.tts.openai.baseUrl` config support with config-over-env precedence, endpoint-aware directive validation, and OpenAI TTS request routing to the resolved base URL. (#34321) thanks @RealKai42. - Plugins/before_prompt_build system-context fields: add `prependSystemContext` and `appendSystemContext` so static plugin guidance can be placed in system prompt space for provider caching and lower repeated prompt token cost. (#35177) thanks @maweibin. - Gateway: add SecretRef support for gateway.auth.token with auth-mode guardrails. (#35094) Thanks @joshavant. +- Plugins/hook policy: add `plugins.entries..hooks.allowPromptInjection`, validate unknown typed hook names at runtime, and preserve legacy `before_agent_start` model/provider overrides while stripping prompt-mutating fields when prompt injection is disabled. (#36567) thanks @gumadeiras. ### Breaking diff --git a/docs/gateway/configuration-reference.md b/docs/gateway/configuration-reference.md index 46fc36b94c0..bd4406718d9 100644 --- a/docs/gateway/configuration-reference.md +++ b/docs/gateway/configuration-reference.md @@ -2310,7 +2310,7 @@ See [Local Models](/gateway/local-models). TL;DR: run MiniMax M2.5 via LM Studio - `allow`: optional allowlist (only listed plugins load). `deny` wins. - `plugins.entries..apiKey`: plugin-level API key convenience field (when supported by the plugin). - `plugins.entries..env`: plugin-scoped env var map. -- `plugins.entries..hooks.allowPromptInjection`: when `false`, core blocks this plugin from registering prompt-mutating typed hooks (`before_prompt_build`, `before_agent_start`). +- `plugins.entries..hooks.allowPromptInjection`: when `false`, core blocks `before_prompt_build` and ignores prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride`. - `plugins.entries..config`: plugin-defined config object (validated by plugin schema). - `plugins.slots.memory`: pick the active memory plugin id, or `"none"` to disable memory plugins. - `plugins.installs`: CLI-managed install metadata used by `openclaw plugins update`. diff --git a/docs/tools/plugin.md b/docs/tools/plugin.md index 30c4a12c981..e7b84cfd815 100644 --- a/docs/tools/plugin.md +++ b/docs/tools/plugin.md @@ -489,7 +489,7 @@ Important hooks for prompt construction: Core-enforced hook policy: - Operators can disable prompt mutation hooks per plugin via `plugins.entries..hooks.allowPromptInjection: false`. -- When disabled, OpenClaw blocks registration of `before_prompt_build` and `before_agent_start` for that plugin while leaving other plugin capabilities enabled. +- When disabled, OpenClaw blocks `before_prompt_build` and ignores prompt-mutating fields returned from legacy `before_agent_start` while preserving legacy `modelOverride` and `providerOverride`. `before_prompt_build` result fields: diff --git a/src/config/schema.help.quality.test.ts b/src/config/schema.help.quality.test.ts index 66ce3618b4f..146ffc17101 100644 --- a/src/config/schema.help.quality.test.ts +++ b/src/config/schema.help.quality.test.ts @@ -767,6 +767,7 @@ describe("config help copy quality", () => { const pluginPromptPolicy = FIELD_HELP["plugins.entries.*.hooks.allowPromptInjection"]; expect(pluginPromptPolicy.includes("before_prompt_build")).toBe(true); expect(pluginPromptPolicy.includes("before_agent_start")).toBe(true); + expect(pluginPromptPolicy.includes("modelOverride")).toBe(true); }); it("documents auth/model root semantics and provider secret handling", () => { diff --git a/src/config/schema.help.ts b/src/config/schema.help.ts index 8f0338425b8..9b6bca6a05b 100644 --- a/src/config/schema.help.ts +++ b/src/config/schema.help.ts @@ -914,7 +914,7 @@ export const FIELD_HELP: Record = { "plugins.entries.*.hooks": "Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.", "plugins.entries.*.hooks.allowPromptInjection": - "Controls whether this plugin may register prompt-mutating typed hooks (`before_prompt_build`, `before_agent_start`). Set false to block prompt injection while keeping other plugin capabilities enabled.", + "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", "plugins.entries.*.apiKey": "Optional API key field consumed by plugins that accept direct key configuration in entry settings. Use secret/env substitution and avoid committing real credentials into config files.", "plugins.entries.*.env": diff --git a/src/config/types.plugins.ts b/src/config/types.plugins.ts index 6bd5280d25f..5244795d51e 100644 --- a/src/config/types.plugins.ts +++ b/src/config/types.plugins.ts @@ -1,7 +1,7 @@ export type PluginEntryConfig = { enabled?: boolean; hooks?: { - /** Controls typed prompt mutation hooks (before_prompt_build, before_agent_start). */ + /** Controls prompt mutation via before_prompt_build and prompt fields from legacy before_agent_start. */ allowPromptInjection?: boolean; }; config?: Record; diff --git a/src/plugins/loader.test.ts b/src/plugins/loader.test.ts index 91502b4e895..5bebad861bb 100644 --- a/src/plugins/loader.test.ts +++ b/src/plugins/loader.test.ts @@ -4,6 +4,7 @@ import path from "node:path"; import { afterAll, afterEach, describe, expect, it } from "vitest"; import { withEnv } from "../test-utils/env.js"; import { getGlobalHookRunner, resetGlobalHookRunner } from "./hook-runner-global.js"; +import { createHookRunner } from "./hooks.js"; import { __testing, loadOpenClawPlugins } from "./loader.js"; type TempPlugin = { dir: string; file: string; id: string }; @@ -685,14 +686,18 @@ describe("loadOpenClawPlugins", () => { expect(disabled?.status).toBe("disabled"); }); - it("blocks prompt-injection typed hooks when disabled by plugin hook policy", () => { + it("blocks before_prompt_build but preserves legacy model overrides when prompt injection is disabled", async () => { useNoBundledPlugins(); const plugin = writePlugin({ id: "hook-policy", filename: "hook-policy.cjs", body: `module.exports = { id: "hook-policy", register(api) { api.on("before_prompt_build", () => ({ prependContext: "prepend" })); - api.on("before_agent_start", () => ({ prependContext: "legacy" })); + api.on("before_agent_start", () => ({ + prependContext: "legacy", + modelOverride: "gpt-4o", + providerOverride: "anthropic", + })); api.on("before_model_resolve", () => ({ providerOverride: "openai" })); } };`, }); @@ -712,13 +717,28 @@ describe("loadOpenClawPlugins", () => { }); expect(registry.plugins.find((entry) => entry.id === "hook-policy")?.status).toBe("loaded"); - expect(registry.typedHooks.map((entry) => entry.hookName)).toEqual(["before_model_resolve"]); + expect(registry.typedHooks.map((entry) => entry.hookName)).toEqual([ + "before_agent_start", + "before_model_resolve", + ]); + const runner = createHookRunner(registry); + const legacyResult = await runner.runBeforeAgentStart({ prompt: "hello", messages: [] }, {}); + expect(legacyResult).toEqual({ + modelOverride: "gpt-4o", + providerOverride: "anthropic", + }); const blockedDiagnostics = registry.diagnostics.filter((diag) => String(diag.message).includes( "blocked by plugins.entries.hook-policy.hooks.allowPromptInjection=false", ), ); - expect(blockedDiagnostics).toHaveLength(2); + expect(blockedDiagnostics).toHaveLength(1); + const constrainedDiagnostics = registry.diagnostics.filter((diag) => + String(diag.message).includes( + "prompt fields constrained by plugins.entries.hook-policy.hooks.allowPromptInjection=false", + ), + ); + expect(constrainedDiagnostics).toHaveLength(1); }); it("keeps prompt-injection typed hooks enabled by default", () => { diff --git a/src/plugins/registry.ts b/src/plugins/registry.ts index dec624cde84..4f24714d4e7 100644 --- a/src/plugins/registry.ts +++ b/src/plugins/registry.ts @@ -32,6 +32,7 @@ import type { PluginLogger, PluginOrigin, PluginKind, + PluginHookBeforeAgentStartResult, PluginHookName, PluginHookHandlerMap, PluginHookRegistration as TypedPluginHookRegistration, @@ -145,6 +146,38 @@ type PluginTypedHookPolicy = { allowPromptInjection?: boolean; }; +const stripPromptMutationFieldsFromLegacyHookResult = ( + result: PluginHookBeforeAgentStartResult | void, +): PluginHookBeforeAgentStartResult | void => { + if (!result || typeof result !== "object") { + return result; + } + const { + systemPrompt: _systemPrompt, + prependContext: _prependContext, + prependSystemContext: _prependSystemContext, + appendSystemContext: _appendSystemContext, + ...remaining + } = result; + return Object.keys(remaining).length > 0 + ? (remaining as PluginHookBeforeAgentStartResult) + : undefined; +}; + +const constrainLegacyPromptInjectionHook = ( + handler: PluginHookHandlerMap["before_agent_start"], +): PluginHookHandlerMap["before_agent_start"] => { + return (event, ctx) => { + const result = handler(event, ctx); + if (result && typeof result === "object" && "then" in result) { + return Promise.resolve(result).then((resolved) => + stripPromptMutationFieldsFromLegacyHookResult(resolved), + ); + } + return stripPromptMutationFieldsFromLegacyHookResult(result); + }; +}; + export function createEmptyPluginRegistry(): PluginRegistry { return { plugins: [], @@ -496,20 +529,34 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) { }); return; } + let effectiveHandler = handler; if (policy?.allowPromptInjection === false && isPromptInjectionHookName(hookName)) { - pushDiagnostic({ - level: "warn", - pluginId: record.id, - source: record.source, - message: `typed hook "${hookName}" blocked by plugins.entries.${record.id}.hooks.allowPromptInjection=false`, - }); - return; + if (hookName === "before_prompt_build") { + pushDiagnostic({ + level: "warn", + pluginId: record.id, + source: record.source, + message: `typed hook "${hookName}" blocked by plugins.entries.${record.id}.hooks.allowPromptInjection=false`, + }); + return; + } + if (hookName === "before_agent_start") { + pushDiagnostic({ + level: "warn", + pluginId: record.id, + source: record.source, + message: `typed hook "${hookName}" prompt fields constrained by plugins.entries.${record.id}.hooks.allowPromptInjection=false`, + }); + effectiveHandler = constrainLegacyPromptInjectionHook( + handler as PluginHookHandlerMap["before_agent_start"], + ) as PluginHookHandlerMap[K]; + } } record.hookCount += 1; registry.typedHooks.push({ pluginId: record.id, hookName, - handler, + handler: effectiveHandler, priority: opts?.priority, source: record.source, } as TypedPluginHookRegistration);