diff --git a/docs/gateway/configuration-reference.md b/docs/gateway/configuration-reference.md index 1ba60bee31d..46fc36b94c0 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 this plugin from registering prompt-mutating typed hooks (`before_prompt_build`, `before_agent_start`). - `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..30c4a12c981 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 registration of `before_prompt_build` and `before_agent_start` for that plugin while leaving other plugin capabilities enabled. + `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..66ce3618b4f 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,10 @@ 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); }); 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..8f0338425b8 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 register prompt-mutating typed hooks (`before_prompt_build`, `before_agent_start`). Set false to block prompt injection while keeping other plugin capabilities enabled.", "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..6bd5280d25f 100644 --- a/src/config/types.plugins.ts +++ b/src/config/types.plugins.ts @@ -1,5 +1,9 @@ export type PluginEntryConfig = { enabled?: boolean; + hooks?: { + /** Controls typed prompt mutation hooks (before_prompt_build, 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..91502b4e895 100644 --- a/src/plugins/loader.test.ts +++ b/src/plugins/loader.test.ts @@ -685,6 +685,103 @@ describe("loadOpenClawPlugins", () => { expect(disabled?.status).toBe("disabled"); }); + it("blocks prompt-injection typed hooks when disabled by plugin hook policy", () => { + 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_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_model_resolve"]); + const blockedDiagnostics = registry.diagnostics.filter((diag) => + String(diag.message).includes( + "blocked by plugins.entries.hook-policy.hooks.allowPromptInjection=false", + ), + ); + expect(blockedDiagnostics).toHaveLength(2); + }); + + 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..dec624cde84 100644 --- a/src/plugins/registry.ts +++ b/src/plugins/registry.ts @@ -12,6 +12,7 @@ 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 } from "./types.js"; import type { OpenClawPluginApi, OpenClawPluginChannelRegistration, @@ -140,6 +141,10 @@ export type PluginRegistryParams = { runtime: PluginRuntime; }; +type PluginTypedHookPolicy = { + allowPromptInjection?: boolean; +}; + export function createEmptyPluginRegistry(): PluginRegistry { return { plugins: [], @@ -480,7 +485,26 @@ 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; + } + 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; + } record.hookCount += 1; registry.typedHooks.push({ pluginId: record.id, @@ -503,6 +527,7 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) { params: { config: OpenClawPluginApi["config"]; pluginConfig?: Record; + hookPolicy?: PluginTypedHookPolicy; }, ): OpenClawPluginApi => { return { @@ -526,7 +551,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..95c8935dc5b 100644 --- a/src/plugins/types.ts +++ b/src/plugins/types.ts @@ -333,6 +333,50 @@ 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[]; + +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;