mirror of https://github.com/openclaw/openclaw.git
plugins: enforce prompt hook policy with runtime validation
This commit is contained in:
parent
063e493d3d
commit
03c8acf331
|
|
@ -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`.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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":
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -796,6 +796,7 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi
|
|||
const api = createApi(record, {
|
||||
config: cfg,
|
||||
pluginConfig: validatedConfig.value,
|
||||
hookPolicy: entry?.hooks,
|
||||
});
|
||||
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
};
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in New Issue