plugins: enforce prompt hook policy with runtime validation

This commit is contained in:
Gustavo Madeira Santana 2026-03-05 13:28:11 -05:00
parent 063e493d3d
commit 03c8acf331
14 changed files with 283 additions and 2 deletions

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 this plugin from registering prompt-mutating typed hooks (`before_prompt_build`, `before_agent_start`).
- `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 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.

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

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 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":

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 typed prompt mutation hooks (before_prompt_build, 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

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

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

View File

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