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 1ba60bee31d..bd4406718d9 100644 --- a/docs/gateway/configuration-reference.md +++ b/docs/gateway/configuration-reference.md @@ -2295,6 +2295,9 @@ See [Local Models](/gateway/local-models). TL;DR: run MiniMax M2.5 via LM Studio entries: { "voice-call": { enabled: true, + hooks: { + allowPromptInjection: false, + }, config: { provider: "twilio" }, }, }, @@ -2307,6 +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 `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 32c33838642..e7b84cfd815 100644 --- a/docs/tools/plugin.md +++ b/docs/tools/plugin.md @@ -486,6 +486,11 @@ Important hooks for prompt construction: - `before_prompt_build`: runs after session load (`messages` are available). Use this to shape prompt input. - `before_agent_start`: legacy compatibility hook. Prefer the two explicit hooks above. +Core-enforced hook policy: + +- Operators can disable prompt mutation hooks per plugin via `plugins.entries..hooks.allowPromptInjection: false`. +- 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: - `prependContext`: prepends text to the user prompt for this run. Best for turn-specific or dynamic content. diff --git a/src/config/config-misc.test.ts b/src/config/config-misc.test.ts index 7c2985a3071..29efaa2b136 100644 --- a/src/config/config-misc.test.ts +++ b/src/config/config-misc.test.ts @@ -48,6 +48,38 @@ describe("ui.seamColor", () => { }); }); +describe("plugins.entries.*.hooks.allowPromptInjection", () => { + it("accepts boolean values", () => { + const result = OpenClawSchema.safeParse({ + plugins: { + entries: { + "voice-call": { + hooks: { + allowPromptInjection: false, + }, + }, + }, + }, + }); + expect(result.success).toBe(true); + }); + + it("rejects non-boolean values", () => { + const result = OpenClawSchema.safeParse({ + plugins: { + entries: { + "voice-call": { + hooks: { + allowPromptInjection: "no", + }, + }, + }, + }, + }); + expect(result.success).toBe(false); + }); +}); + describe("web search provider config", () => { it("accepts kimi provider and config", () => { const res = validateConfigObject( diff --git a/src/config/schema.help.quality.test.ts b/src/config/schema.help.quality.test.ts index 9e12a0729de..146ffc17101 100644 --- a/src/config/schema.help.quality.test.ts +++ b/src/config/schema.help.quality.test.ts @@ -339,6 +339,8 @@ const TARGET_KEYS = [ "plugins.slots", "plugins.entries", "plugins.entries.*.enabled", + "plugins.entries.*.hooks", + "plugins.entries.*.hooks.allowPromptInjection", "plugins.entries.*.apiKey", "plugins.entries.*.env", "plugins.entries.*.config", @@ -761,6 +763,11 @@ describe("config help copy quality", () => { const pluginEnv = FIELD_HELP["plugins.entries.*.env"]; expect(/scope|plugin|environment/i.test(pluginEnv)).toBe(true); + + 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 b260017362a..9b6bca6a05b 100644 --- a/src/config/schema.help.ts +++ b/src/config/schema.help.ts @@ -911,6 +911,10 @@ export const FIELD_HELP: Record = { "Per-plugin settings keyed by plugin ID including enablement and plugin-specific runtime configuration payloads. Use this for scoped plugin tuning without changing global loader policy.", "plugins.entries.*.enabled": "Per-plugin enablement override for a specific entry, applied on top of global plugin policy (restart required). Use this to stage plugin rollout gradually across environments.", + "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 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/schema.labels.ts b/src/config/schema.labels.ts index 5908a370c37..4519c422b1a 100644 --- a/src/config/schema.labels.ts +++ b/src/config/schema.labels.ts @@ -797,6 +797,8 @@ export const FIELD_LABELS: Record = { "plugins.slots.memory": "Memory Plugin", "plugins.entries": "Plugin Entries", "plugins.entries.*.enabled": "Plugin Enabled", + "plugins.entries.*.hooks": "Plugin Hook Policy", + "plugins.entries.*.hooks.allowPromptInjection": "Allow Prompt Injection Hooks", "plugins.entries.*.apiKey": "Plugin API Key", "plugins.entries.*.env": "Plugin Environment Variables", "plugins.entries.*.config": "Plugin Config", diff --git a/src/config/types.plugins.ts b/src/config/types.plugins.ts index 5884bba05c4..5244795d51e 100644 --- a/src/config/types.plugins.ts +++ b/src/config/types.plugins.ts @@ -1,5 +1,9 @@ export type PluginEntryConfig = { enabled?: boolean; + hooks?: { + /** Controls prompt mutation via before_prompt_build and prompt fields from legacy before_agent_start. */ + allowPromptInjection?: boolean; + }; config?: Record; }; diff --git a/src/config/zod-schema.ts b/src/config/zod-schema.ts index 14d4163443e..fafbad0121c 100644 --- a/src/config/zod-schema.ts +++ b/src/config/zod-schema.ts @@ -149,6 +149,12 @@ const SkillEntrySchema = z const PluginEntrySchema = z .object({ enabled: z.boolean().optional(), + hooks: z + .object({ + allowPromptInjection: z.boolean().optional(), + }) + .strict() + .optional(), config: z.record(z.string(), z.unknown()).optional(), }) .strict(); diff --git a/src/plugins/config-state.test.ts b/src/plugins/config-state.test.ts index ccebd313198..47101c771cd 100644 --- a/src/plugins/config-state.test.ts +++ b/src/plugins/config-state.test.ts @@ -47,6 +47,32 @@ describe("normalizePluginsConfig", () => { }); expect(result.slots.memory).toBe("memory-core"); }); + + it("normalizes plugin hook policy flags", () => { + const result = normalizePluginsConfig({ + entries: { + "voice-call": { + hooks: { + allowPromptInjection: false, + }, + }, + }, + }); + expect(result.entries["voice-call"]?.hooks?.allowPromptInjection).toBe(false); + }); + + it("drops invalid plugin hook policy values", () => { + const result = normalizePluginsConfig({ + entries: { + "voice-call": { + hooks: { + allowPromptInjection: "nope", + } as unknown as { allowPromptInjection: boolean }, + }, + }, + }); + expect(result.entries["voice-call"]?.hooks).toBeUndefined(); + }); }); describe("resolveEffectiveEnableState", () => { diff --git a/src/plugins/config-state.ts b/src/plugins/config-state.ts index f2626e705ff..2a70033bad2 100644 --- a/src/plugins/config-state.ts +++ b/src/plugins/config-state.ts @@ -11,7 +11,16 @@ export type NormalizedPluginsConfig = { slots: { memory?: string | null; }; - entries: Record; + entries: Record< + string, + { + enabled?: boolean; + hooks?: { + allowPromptInjection?: boolean; + }; + config?: unknown; + } + >; }; export const BUNDLED_ENABLED_BY_DEFAULT = new Set([ @@ -55,8 +64,23 @@ const normalizePluginEntries = (entries: unknown): NormalizedPluginsConfig["entr continue; } const entry = value as Record; + const hooksRaw = entry.hooks; + const hooks = + hooksRaw && typeof hooksRaw === "object" && !Array.isArray(hooksRaw) + ? { + allowPromptInjection: (hooksRaw as { allowPromptInjection?: unknown }) + .allowPromptInjection, + } + : undefined; + const normalizedHooks = + hooks && typeof hooks.allowPromptInjection === "boolean" + ? { + allowPromptInjection: hooks.allowPromptInjection, + } + : undefined; normalized[key] = { enabled: typeof entry.enabled === "boolean" ? entry.enabled : undefined, + hooks: normalizedHooks, config: "config" in entry ? entry.config : undefined, }; } diff --git a/src/plugins/loader.test.ts b/src/plugins/loader.test.ts index 5e61d3e3270..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,6 +686,122 @@ describe("loadOpenClawPlugins", () => { expect(disabled?.status).toBe("disabled"); }); + 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", + modelOverride: "gpt-4o", + providerOverride: "anthropic", + })); + api.on("before_model_resolve", () => ({ providerOverride: "openai" })); +} };`, + }); + + const registry = loadRegistryFromSinglePlugin({ + plugin, + pluginConfig: { + allow: ["hook-policy"], + entries: { + "hook-policy": { + hooks: { + allowPromptInjection: false, + }, + }, + }, + }, + }); + + expect(registry.plugins.find((entry) => entry.id === "hook-policy")?.status).toBe("loaded"); + 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(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", () => { + useNoBundledPlugins(); + const plugin = writePlugin({ + id: "hook-policy-default", + filename: "hook-policy-default.cjs", + body: `module.exports = { id: "hook-policy-default", register(api) { + api.on("before_prompt_build", () => ({ prependContext: "prepend" })); + api.on("before_agent_start", () => ({ prependContext: "legacy" })); +} };`, + }); + + const registry = loadRegistryFromSinglePlugin({ + plugin, + pluginConfig: { + allow: ["hook-policy-default"], + }, + }); + + expect(registry.typedHooks.map((entry) => entry.hookName)).toEqual([ + "before_prompt_build", + "before_agent_start", + ]); + }); + + it("ignores unknown typed hooks from plugins and keeps loading", () => { + useNoBundledPlugins(); + const plugin = writePlugin({ + id: "hook-unknown", + filename: "hook-unknown.cjs", + body: `module.exports = { id: "hook-unknown", register(api) { + api.on("totally_unknown_hook_name", () => ({ foo: "bar" })); + api.on(123, () => ({ foo: "baz" })); + api.on("before_model_resolve", () => ({ providerOverride: "openai" })); +} };`, + }); + + const registry = loadRegistryFromSinglePlugin({ + plugin, + pluginConfig: { + allow: ["hook-unknown"], + }, + }); + + expect(registry.plugins.find((entry) => entry.id === "hook-unknown")?.status).toBe("loaded"); + expect(registry.typedHooks.map((entry) => entry.hookName)).toEqual(["before_model_resolve"]); + const unknownHookDiagnostics = registry.diagnostics.filter((diag) => + String(diag.message).includes('unknown typed hook "'), + ); + expect(unknownHookDiagnostics).toHaveLength(2); + expect( + unknownHookDiagnostics.some((diag) => + String(diag.message).includes('unknown typed hook "totally_unknown_hook_name" ignored'), + ), + ).toBe(true); + expect( + unknownHookDiagnostics.some((diag) => + String(diag.message).includes('unknown typed hook "123" ignored'), + ), + ).toBe(true); + }); + it("enforces memory slot selection", () => { process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = "/nonexistent/bundled/plugins"; const memoryA = writePlugin({ diff --git a/src/plugins/loader.ts b/src/plugins/loader.ts index c735249c7ad..c70bfc09251 100644 --- a/src/plugins/loader.ts +++ b/src/plugins/loader.ts @@ -796,6 +796,7 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi const api = createApi(record, { config: cfg, pluginConfig: validatedConfig.value, + hookPolicy: entry?.hooks, }); try { diff --git a/src/plugins/registry.ts b/src/plugins/registry.ts index 0b8d8144780..fde8d0e6a6d 100644 --- a/src/plugins/registry.ts +++ b/src/plugins/registry.ts @@ -12,6 +12,11 @@ import { resolveUserPath } from "../utils.js"; import { registerPluginCommand } from "./commands.js"; import { normalizePluginHttpPath } from "./http-path.js"; import type { PluginRuntime } from "./runtime/types.js"; +import { + isPluginHookName, + isPromptInjectionHookName, + stripPromptMutationFieldsFromLegacyHookResult, +} from "./types.js"; import type { OpenClawPluginApi, OpenClawPluginChannelRegistration, @@ -140,6 +145,24 @@ export type PluginRegistryParams = { runtime: PluginRuntime; }; +type PluginTypedHookPolicy = { + allowPromptInjection?: boolean; +}; + +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: [], @@ -480,12 +503,45 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) { hookName: K, handler: PluginHookHandlerMap[K], opts?: { priority?: number }, + policy?: PluginTypedHookPolicy, ) => { + if (!isPluginHookName(hookName)) { + pushDiagnostic({ + level: "warn", + pluginId: record.id, + source: record.source, + message: `unknown typed hook "${String(hookName)}" ignored`, + }); + return; + } + let effectiveHandler = handler; + if (policy?.allowPromptInjection === false && isPromptInjectionHookName(hookName)) { + 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); @@ -503,6 +559,7 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) { params: { config: OpenClawPluginApi["config"]; pluginConfig?: Record; + hookPolicy?: PluginTypedHookPolicy; }, ): OpenClawPluginApi => { return { @@ -526,7 +583,8 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) { registerService: (service) => registerService(record, service), registerCommand: (command) => registerCommand(record, command), resolvePath: (input: string) => resolveUserPath(input), - on: (hookName, handler, opts) => registerTypedHook(record, hookName, handler, opts), + on: (hookName, handler, opts) => + registerTypedHook(record, hookName, handler, opts, params.hookPolicy), }; }; diff --git a/src/plugins/types.ts b/src/plugins/types.ts index 4d79f338d84..1cb2779e8c2 100644 --- a/src/plugins/types.ts +++ b/src/plugins/types.ts @@ -333,6 +333,55 @@ export type PluginHookName = | "gateway_start" | "gateway_stop"; +export const PLUGIN_HOOK_NAMES = [ + "before_model_resolve", + "before_prompt_build", + "before_agent_start", + "llm_input", + "llm_output", + "agent_end", + "before_compaction", + "after_compaction", + "before_reset", + "message_received", + "message_sending", + "message_sent", + "before_tool_call", + "after_tool_call", + "tool_result_persist", + "before_message_write", + "session_start", + "session_end", + "subagent_spawning", + "subagent_delivery_target", + "subagent_spawned", + "subagent_ended", + "gateway_start", + "gateway_stop", +] as const satisfies readonly PluginHookName[]; + +type MissingPluginHookNames = Exclude; +type AssertAllPluginHookNamesListed = MissingPluginHookNames extends never ? true : never; +const assertAllPluginHookNamesListed: AssertAllPluginHookNamesListed = true; +void assertAllPluginHookNamesListed; + +const pluginHookNameSet = new Set(PLUGIN_HOOK_NAMES); + +export const isPluginHookName = (hookName: unknown): hookName is PluginHookName => + typeof hookName === "string" && pluginHookNameSet.has(hookName as PluginHookName); + +export const PROMPT_INJECTION_HOOK_NAMES = [ + "before_prompt_build", + "before_agent_start", +] as const satisfies readonly PluginHookName[]; + +export type PromptInjectionHookName = (typeof PROMPT_INJECTION_HOOK_NAMES)[number]; + +const promptInjectionHookNameSet = new Set(PROMPT_INJECTION_HOOK_NAMES); + +export const isPromptInjectionHookName = (hookName: PluginHookName): boolean => + promptInjectionHookNameSet.has(hookName); + // Agent context shared across agent hooks export type PluginHookAgentContext = { agentId?: string; @@ -381,6 +430,22 @@ export type PluginHookBeforePromptBuildResult = { appendSystemContext?: string; }; +export const PLUGIN_PROMPT_MUTATION_RESULT_FIELDS = [ + "systemPrompt", + "prependContext", + "prependSystemContext", + "appendSystemContext", +] as const satisfies readonly (keyof PluginHookBeforePromptBuildResult)[]; + +type MissingPluginPromptMutationResultFields = Exclude< + keyof PluginHookBeforePromptBuildResult, + (typeof PLUGIN_PROMPT_MUTATION_RESULT_FIELDS)[number] +>; +type AssertAllPluginPromptMutationResultFieldsListed = + MissingPluginPromptMutationResultFields extends never ? true : never; +const assertAllPluginPromptMutationResultFieldsListed: AssertAllPluginPromptMutationResultFieldsListed = true; +void assertAllPluginPromptMutationResultFieldsListed; + // before_agent_start hook (legacy compatibility: combines both phases) export type PluginHookBeforeAgentStartEvent = { prompt: string; @@ -391,6 +456,26 @@ export type PluginHookBeforeAgentStartEvent = { export type PluginHookBeforeAgentStartResult = PluginHookBeforePromptBuildResult & PluginHookBeforeModelResolveResult; +export type PluginHookBeforeAgentStartOverrideResult = Omit< + PluginHookBeforeAgentStartResult, + keyof PluginHookBeforePromptBuildResult +>; + +export const stripPromptMutationFieldsFromLegacyHookResult = ( + result: PluginHookBeforeAgentStartResult | void, +): PluginHookBeforeAgentStartOverrideResult | void => { + if (!result || typeof result !== "object") { + return result; + } + const remaining: Partial = { ...result }; + for (const field of PLUGIN_PROMPT_MUTATION_RESULT_FIELDS) { + delete remaining[field]; + } + return Object.keys(remaining).length > 0 + ? (remaining as PluginHookBeforeAgentStartOverrideResult) + : undefined; +}; + // llm_input hook export type PluginHookLlmInputEvent = { runId: string;