fix(config): allow plugin conversation access hook policy (#71221)

This commit is contained in:
BillChirico
2026-04-24 20:19:12 +01:00
committed by Peter Steinberger
parent ef9ca09b8e
commit 51dd4f288f
13 changed files with 140 additions and 3 deletions

View File

@@ -49,6 +49,7 @@ Docs: https://docs.openclaw.ai
- Plugins/Google Chat: log webhook auth rejection reasons only after all candidates fail, and warn when add-on `appPrincipal` values do not match configuration. Fixes #71078. (#71145) Thanks @luyao618.
- Models/configure: preserve the existing default model when provider auth is re-run from configure while keeping explicit default-setting commands authoritative. Fixes #70696. (#70793) Thanks @Sathvik-1007.
- Config/plugins: accept `plugins.entries.*.hooks.allowConversationAccess` in validation, generated schema metadata, and plugin policy inspection so trusted external plugins can enable conversation-access hooks such as `agent_end` without local schema patches. Fixes #71215. (#71221) Thanks @BillChirico.
- Codex harness/models: keep legacy `codex/*` harness shorthand out of model picker and `/models` choice surfaces while migrating primary legacy refs to canonical `openai/*` plus explicit Codex harness config. (#71193) Thanks @vincentkoc.
- Plugins/runtime deps: respect explicit plugin and channel disablement when repairing bundled runtime dependencies, so doctor and health checks no longer install deps for disabled configured channels.
- Diagnostics: harden tool and model diagnostic events against hostile errors, blocking listeners, and unsafe stability reason fields. Thanks @vincentkoc.

View File

@@ -1,4 +1,4 @@
2894c80bc234b33f14a0ddb09e6f42368da4ca23e0e7faceb13aba52239c1a76 config-baseline.json
bfae9b7760c3372f48d073da40059b0faa43c33f643b4aac3a942932a32df9eb config-baseline.json
c8ff25fcdd2389d5fd88f8ba188d77c21f58b56765b555eecf3b37437f743d50 config-baseline.core.json
22d7cd6d8279146b2d79c9531a55b80b52a2c99c81338c508104729154fdd02d config-baseline.channel.json
5ce9062d0ab7f9447f149fc0770571068b4c4c89e0fb80ae6ef7b3f2a146c8b3 config-baseline.plugin.json
5bace1f246d5462dcf00ec7d4f378350bc7b6d01141609d704dc8c2e03e2230a config-baseline.plugin.json

View File

@@ -118,6 +118,7 @@ provider / base-URL setup moved to a dedicated page — see
- `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`. Applies to native plugin hooks and supported bundle-provided hook directories.
- `plugins.entries.<id>.hooks.allowConversationAccess`: when `true`, trusted non-bundled plugins may read raw conversation content from typed hooks such as `llm_input`, `llm_output`, and `agent_end`.
- `plugins.entries.<id>.subagent.allowModelOverride`: explicitly trust this plugin to request per-run `provider` and `model` overrides for background subagent runs.
- `plugins.entries.<id>.subagent.allowedModels`: optional allowlist of canonical `provider/model` targets for trusted subagent overrides. Use `"*"` only when you intentionally want to allow any model.
- `plugins.entries.<id>.config`: plugin-defined config object (validated by native OpenClaw plugin schema when available).

View File

@@ -110,6 +110,49 @@ async function withExecDryRunConfigHarness(
}
describe("config cli integration", () => {
it("accepts plugin hook conversation-access policy via config set", async () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-config-cli-plugin-hooks-"));
const configPath = path.join(tempDir, "openclaw.json");
const envSnapshot = captureEnv(["OPENCLAW_CONFIG_PATH", "OPENCLAW_TEST_FAST"]);
try {
fs.writeFileSync(
configPath,
`${JSON.stringify(
{
gateway: { port: 18789 },
},
null,
2,
)}\n`,
"utf8",
);
process.env.OPENCLAW_TEST_FAST = "1";
process.env.OPENCLAW_CONFIG_PATH = configPath;
clearConfigCache();
clearRuntimeConfigSnapshot();
const runtime = createTestRuntime();
await runConfigSet({
path: "plugins.entries.openclaw-mem0.hooks.allowConversationAccess",
value: "true",
cliOptions: {},
runtime: runtime.runtime,
});
expect(runtime.errors).toEqual([]);
const afterWrite = JSON5.parse(fs.readFileSync(configPath, "utf8"));
expect(afterWrite.plugins?.entries?.["openclaw-mem0"]?.hooks).toEqual({
allowConversationAccess: true,
});
} finally {
envSnapshot.restore();
clearConfigCache();
clearRuntimeConfigSnapshot();
fs.rmSync(tempDir, { recursive: true, force: true });
}
});
it("supports batch-file dry-run and then writes real config changes", async () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-config-cli-int-"));
const configPath = path.join(tempDir, "openclaw.json");

View File

@@ -35,6 +35,7 @@ export const recordPluginInstall: UnknownMock = vi.fn();
export const clearPluginManifestRegistryCache: UnknownMock = vi.fn();
export const loadPluginManifestRegistry: UnknownMock = vi.fn();
export const buildPluginSnapshotReport: UnknownMock = vi.fn();
export const buildPluginInspectReport: UnknownMock = vi.fn();
export const buildPluginDiagnosticsReport: UnknownMock = vi.fn();
export const buildPluginCompatibilityNotices: UnknownMock = vi.fn();
export const applyExclusiveSlotSelection: UnknownMock = vi.fn();
@@ -166,6 +167,16 @@ vi.mock("../plugins/status.js", () => ({
buildPluginSnapshotReport,
...args,
)) as (typeof import("../plugins/status.js"))["buildPluginSnapshotReport"],
buildPluginInspectReport: ((
...args: Parameters<(typeof import("../plugins/status.js"))["buildPluginInspectReport"]>
) =>
invokeMock<
Parameters<(typeof import("../plugins/status.js"))["buildPluginInspectReport"]>,
ReturnType<(typeof import("../plugins/status.js"))["buildPluginInspectReport"]>
>(
buildPluginInspectReport,
...args,
)) as (typeof import("../plugins/status.js"))["buildPluginInspectReport"],
buildPluginDiagnosticsReport: ((
...args: Parameters<(typeof import("../plugins/status.js"))["buildPluginDiagnosticsReport"]>
) =>
@@ -186,6 +197,7 @@ vi.mock("../plugins/status.js", () => ({
buildPluginCompatibilityNotices,
...args,
)) as (typeof import("../plugins/status.js"))["buildPluginCompatibilityNotices"],
formatPluginCompatibilityNotice: (entry: { message: string }) => entry.message,
}));
vi.mock("../plugins/slots.js", async (importOriginal) => {
@@ -377,6 +389,7 @@ export function resetPluginsCliTestState() {
clearPluginManifestRegistryCache.mockReset();
loadPluginManifestRegistry.mockReset();
buildPluginSnapshotReport.mockReset();
buildPluginInspectReport.mockReset();
buildPluginDiagnosticsReport.mockReset();
buildPluginCompatibilityNotices.mockReset();
applyExclusiveSlotSelection.mockReset();

View File

@@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it } from "vitest";
import { createPluginRecord } from "../plugins/status.test-helpers.js";
import {
buildPluginDiagnosticsReport,
buildPluginInspectReport,
buildPluginSnapshotReport,
resetPluginsCliTestState,
runPluginsCommand,
@@ -64,4 +65,40 @@ describe("plugins cli list", () => {
expect(buildPluginDiagnosticsReport).toHaveBeenCalledWith();
expect(runtimeLogs).toContain("No plugin issues detected.");
});
it("shows conversation-access hook policy in inspect output", async () => {
buildPluginInspectReport.mockReturnValue({
workspaceDir: "/workspace",
plugin: createPluginRecord({ id: "openclaw-mem0", name: "Mem0" }),
shape: "hook-only",
capabilityMode: "plain",
capabilityCount: 1,
capabilities: [],
typedHooks: [{ name: "agent_end" }],
customHooks: [],
tools: [],
commands: [],
cliCommands: [],
services: [],
gatewayDiscoveryServices: [],
gatewayMethods: [],
mcpServers: [],
lspServers: [],
httpRouteCount: 0,
bundleCapabilities: [],
diagnostics: [],
policy: {
allowConversationAccess: true,
allowedModels: [],
hasAllowedModelsConfig: false,
},
usesLegacyBeforeAgentStart: false,
compatibility: [],
});
await runPluginsCommand(["plugins", "inspect", "openclaw-mem0"]);
expect(runtimeLogs.join("\n")).toContain("Policy");
expect(runtimeLogs.join("\n")).toContain("allowConversationAccess: true");
});
});

View File

@@ -454,6 +454,9 @@ export function registerPluginsCli(program: Command) {
if (typeof inspect.policy.allowPromptInjection === "boolean") {
policyLines.push(`allowPromptInjection: ${inspect.policy.allowPromptInjection}`);
}
if (typeof inspect.policy.allowConversationAccess === "boolean") {
policyLines.push(`allowConversationAccess: ${inspect.policy.allowConversationAccess}`);
}
if (typeof inspect.policy.allowModelOverride === "boolean") {
policyLines.push(`allowModelOverride: ${inspect.policy.allowModelOverride}`);
}

View File

@@ -148,7 +148,7 @@ describe("gateway.controlUi.allowExternalEmbedUrls", () => {
});
});
describe("plugins.entries.*.hooks.allowPromptInjection", () => {
describe("plugins.entries.*.hooks", () => {
it("accepts boolean values", () => {
const result = OpenClawSchema.safeParse({
plugins: {
@@ -156,6 +156,7 @@ describe("plugins.entries.*.hooks.allowPromptInjection", () => {
"voice-call": {
hooks: {
allowPromptInjection: false,
allowConversationAccess: true,
},
},
},
@@ -171,6 +172,23 @@ describe("plugins.entries.*.hooks.allowPromptInjection", () => {
"voice-call": {
hooks: {
allowPromptInjection: "no",
allowConversationAccess: true,
},
},
},
},
});
expect(result.success).toBe(false);
});
it("rejects non-boolean conversation access values", () => {
const result = OpenClawSchema.safeParse({
plugins: {
entries: {
"voice-call": {
hooks: {
allowPromptInjection: false,
allowConversationAccess: "yes",
},
},
},

View File

@@ -22705,6 +22705,12 @@ export const GENERATED_BASE_CONFIG_SCHEMA: BaseConfigSchemaResponse = {
description:
"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.",
},
allowConversationAccess: {
type: "boolean",
title: "Allow Conversation Access Hooks",
description:
"Controls whether this plugin may read raw conversation content from typed hooks such as `llm_input`, `llm_output`, and `agent_end`. Non-bundled plugins must opt in explicitly.",
},
},
additionalProperties: false,
title: "Plugin Hook Policy",
@@ -27246,6 +27252,11 @@ export const GENERATED_BASE_CONFIG_SCHEMA: BaseConfigSchemaResponse = {
help: "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.",
tags: ["access"],
},
"plugins.entries.*.hooks.allowConversationAccess": {
label: "Allow Conversation Access Hooks",
help: "Controls whether this plugin may read raw conversation content from typed hooks such as `llm_input`, `llm_output`, and `agent_end`. Non-bundled plugins must opt in explicitly.",
tags: ["access"],
},
"plugins.entries.*.subagent": {
label: "Plugin Subagent Policy",
help: "Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.",

View File

@@ -352,6 +352,7 @@ const TARGET_KEYS = [
"plugins.entries.*.enabled",
"plugins.entries.*.hooks",
"plugins.entries.*.hooks.allowPromptInjection",
"plugins.entries.*.hooks.allowConversationAccess",
"plugins.entries.*.subagent",
"plugins.entries.*.subagent.allowModelOverride",
"plugins.entries.*.subagent.allowedModels",
@@ -763,6 +764,11 @@ describe("config help copy quality", () => {
expect(pluginPromptPolicy.includes("before_prompt_build")).toBe(true);
expect(pluginPromptPolicy.includes("before_agent_start")).toBe(true);
expect(pluginPromptPolicy.includes("modelOverride")).toBe(true);
const pluginConversationPolicy = FIELD_HELP["plugins.entries.*.hooks.allowConversationAccess"];
expect(pluginConversationPolicy.includes("llm_input")).toBe(true);
expect(pluginConversationPolicy.includes("llm_output")).toBe(true);
expect(pluginConversationPolicy.includes("agent_end")).toBe(true);
});
it("documents auth/model root semantics and provider secret handling", () => {

View File

@@ -1106,6 +1106,8 @@ export const FIELD_HELP: Record<string, string> = {
"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.*.hooks.allowConversationAccess":
"Controls whether this plugin may read raw conversation content from typed hooks such as `llm_input`, `llm_output`, and `agent_end`. Non-bundled plugins must opt in explicitly.",
"plugins.entries.*.subagent":
"Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.",
"plugins.entries.*.subagent.allowModelOverride":

View File

@@ -837,6 +837,7 @@ export const FIELD_LABELS: Record<string, string> = {
"plugins.entries.*.enabled": "Plugin Enabled",
"plugins.entries.*.hooks": "Plugin Hook Policy",
"plugins.entries.*.hooks.allowPromptInjection": "Allow Prompt Injection Hooks",
"plugins.entries.*.hooks.allowConversationAccess": "Allow Conversation Access Hooks",
"plugins.entries.*.subagent": "Plugin Subagent Policy",
"plugins.entries.*.subagent.allowModelOverride": "Allow Plugin Subagent Model Override",
"plugins.entries.*.subagent.allowedModels": "Plugin Subagent Allowed Models",

View File

@@ -161,6 +161,7 @@ const PluginEntrySchema = z
hooks: z
.object({
allowPromptInjection: z.boolean().optional(),
allowConversationAccess: z.boolean().optional(),
})
.strict()
.optional(),