plugins: enforce prompt hook policy with runtime validation (#36567)

Merged via squash.

Prepared head SHA: 6b9d883b6a
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Reviewed-by: @gumadeiras
This commit is contained in:
Gustavo Madeira Santana
2026-03-05 18:15:54 -05:00
committed by GitHub
parent 063e493d3d
commit 688b72e158
15 changed files with 379 additions and 3 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

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

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

View File

@@ -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(

View File

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

View File

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

View File

@@ -797,6 +797,8 @@ export const FIELD_LABELS: Record<string, string> = {
"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",

View File

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

View File

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

View File

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

View File

@@ -11,7 +11,16 @@ export type NormalizedPluginsConfig = {
slots: {
memory?: string | null;
};
entries: Record<string, { enabled?: boolean; config?: unknown }>;
entries: Record<
string,
{
enabled?: boolean;
hooks?: {
allowPromptInjection?: boolean;
};
config?: unknown;
}
>;
};
export const BUNDLED_ENABLED_BY_DEFAULT = new Set<string>([
@@ -55,8 +64,23 @@ const normalizePluginEntries = (entries: unknown): NormalizedPluginsConfig["entr
continue;
}
const entry = value as Record<string, unknown>;
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,
};
}

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,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({

View File

@@ -796,6 +796,7 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi
const api = createApi(record, {
config: cfg,
pluginConfig: validatedConfig.value,
hookPolicy: entry?.hooks,
});
try {

View File

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

View File

@@ -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<PluginHookName, (typeof PLUGIN_HOOK_NAMES)[number]>;
type AssertAllPluginHookNamesListed = MissingPluginHookNames extends never ? true : never;
const assertAllPluginHookNamesListed: AssertAllPluginHookNamesListed = true;
void assertAllPluginHookNamesListed;
const pluginHookNameSet = new Set<PluginHookName>(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<PluginHookName>(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<PluginHookBeforeAgentStartResult> = { ...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;