fix: preserve legacy overrides in hook policy

This commit is contained in:
Gustavo Madeira Santana 2026-03-05 14:02:15 -05:00
parent f14bff643c
commit 6aefd1c0e8
8 changed files with 85 additions and 16 deletions

View File

@ -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.<id>.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

View File

@ -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.<id>.apiKey`: plugin-level API key convenience field (when supported by the plugin).
- `plugins.entries.<id>.env`: plugin-scoped env var map.
- `plugins.entries.<id>.hooks.allowPromptInjection`: when `false`, core blocks this plugin from registering prompt-mutating typed hooks (`before_prompt_build`, `before_agent_start`).
- `plugins.entries.<id>.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.<id>.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`.

View File

@ -489,7 +489,7 @@ Important hooks for prompt construction:
Core-enforced hook policy:
- Operators can disable prompt mutation hooks per plugin via `plugins.entries.<id>.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:

View File

@ -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", () => {

View File

@ -914,7 +914,7 @@ export const FIELD_HELP: Record<string, string> = {
"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":

View File

@ -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<string, unknown>;

View File

@ -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", () => {

View File

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