diff --git a/docs/.generated/plugin-sdk-api-baseline/agent-harness-runtime.json b/docs/.generated/plugin-sdk-api-baseline/agent-harness-runtime.json index ba22e9cb9f50..b99603bd000c 100644 --- a/docs/.generated/plugin-sdk-api-baseline/agent-harness-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/agent-harness-runtime.json @@ -1 +1 @@ -{"contentHash":"48dad42d04caeb62f04f9dbe32b4562ecef695e9f0c97fb891040408400f37bd","entrypoint":"agent-harness-runtime","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime"} +{"contentHash":"3d680ab26bba9b4ccd86bd7734dbf9672920df842ae3446142319a283e711941","entrypoint":"agent-harness-runtime","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/agent-harness.json b/docs/.generated/plugin-sdk-api-baseline/agent-harness.json index 69b4ae7e72f2..d94cdcfcf9a0 100644 --- a/docs/.generated/plugin-sdk-api-baseline/agent-harness.json +++ b/docs/.generated/plugin-sdk-api-baseline/agent-harness.json @@ -1 +1 @@ -{"contentHash":"3d1c84bcc585b57a93054889e16fb3be02ff4d599d7d2a0566df6f463f2254a8","entrypoint":"agent-harness","importSpecifier":"openclaw/plugin-sdk/agent-harness"} +{"contentHash":"08051f15195453d4f35616cdb4f0ff47e974f6c2260f0ee1078e1754f5284899","entrypoint":"agent-harness","importSpecifier":"openclaw/plugin-sdk/agent-harness"} diff --git a/docs/.generated/plugin-sdk-api-baseline/channel-core.json b/docs/.generated/plugin-sdk-api-baseline/channel-core.json index fdf18fa93eb5..53f568a59f2a 100644 --- a/docs/.generated/plugin-sdk-api-baseline/channel-core.json +++ b/docs/.generated/plugin-sdk-api-baseline/channel-core.json @@ -1 +1 @@ -{"contentHash":"4245891bae7517cf001f637e80d1d813c11385d695eebbd69a61258e83399c4e","entrypoint":"channel-core","importSpecifier":"openclaw/plugin-sdk/channel-core"} +{"contentHash":"0c78a76e24e4afa2f37813ea3202262605159ddc1bba101778fd68036ee0ae70","entrypoint":"channel-core","importSpecifier":"openclaw/plugin-sdk/channel-core"} diff --git a/docs/.generated/plugin-sdk-api-baseline/channel-entry-contract.json b/docs/.generated/plugin-sdk-api-baseline/channel-entry-contract.json index 7dd5ca16f266..315c3b9a2c5f 100644 --- a/docs/.generated/plugin-sdk-api-baseline/channel-entry-contract.json +++ b/docs/.generated/plugin-sdk-api-baseline/channel-entry-contract.json @@ -1 +1 @@ -{"contentHash":"f719599ed89a658109698bc1ef1309e1709e5ae3385b7ce3f1c583343ea23b33","entrypoint":"channel-entry-contract","importSpecifier":"openclaw/plugin-sdk/channel-entry-contract"} +{"contentHash":"3b5041b5034fac21a85f1e6fbe993d79e2168b99abc98279d7e83013de000496","entrypoint":"channel-entry-contract","importSpecifier":"openclaw/plugin-sdk/channel-entry-contract"} diff --git a/docs/.generated/plugin-sdk-api-baseline/channel-message.json b/docs/.generated/plugin-sdk-api-baseline/channel-message.json index 86450cb57afe..36a3df3bd4af 100644 --- a/docs/.generated/plugin-sdk-api-baseline/channel-message.json +++ b/docs/.generated/plugin-sdk-api-baseline/channel-message.json @@ -1 +1 @@ -{"contentHash":"d531b32d0544c42f375f628bd7a6b3d3582a91078db37bc048af7c3f23a9e59f","entrypoint":"channel-message","importSpecifier":"openclaw/plugin-sdk/channel-message"} +{"contentHash":"a152541a6f351027b97e0c99a02f27038ea5475e96f4513df1b950465c390242","entrypoint":"channel-message","importSpecifier":"openclaw/plugin-sdk/channel-message"} diff --git a/docs/.generated/plugin-sdk-api-baseline/channel-outbound.json b/docs/.generated/plugin-sdk-api-baseline/channel-outbound.json index b8ae76a44c49..626938b51ccf 100644 --- a/docs/.generated/plugin-sdk-api-baseline/channel-outbound.json +++ b/docs/.generated/plugin-sdk-api-baseline/channel-outbound.json @@ -1 +1 @@ -{"contentHash":"40b25681a1ee102cf1d2d5b23a29f96e2501ea521404deed59303e59c35089ec","entrypoint":"channel-outbound","importSpecifier":"openclaw/plugin-sdk/channel-outbound"} +{"contentHash":"d75ce81f95cf3a2382057e90d70f5f8b925cd3d6236a5acce844e3512f5539b6","entrypoint":"channel-outbound","importSpecifier":"openclaw/plugin-sdk/channel-outbound"} diff --git a/docs/.generated/plugin-sdk-api-baseline/channel-plugin-common.json b/docs/.generated/plugin-sdk-api-baseline/channel-plugin-common.json index ff23b7afdb69..6482c0527282 100644 --- a/docs/.generated/plugin-sdk-api-baseline/channel-plugin-common.json +++ b/docs/.generated/plugin-sdk-api-baseline/channel-plugin-common.json @@ -1 +1 @@ -{"contentHash":"67884fcf5fc91b8b40a3c83e83080e69f54f7750cdd780368af63363f84ddc40","entrypoint":"channel-plugin-common","importSpecifier":"openclaw/plugin-sdk/channel-plugin-common"} +{"contentHash":"ac15e49c895e670c58a6574e31839a8a04a124f84801eb2e63adc15d1fb59064","entrypoint":"channel-plugin-common","importSpecifier":"openclaw/plugin-sdk/channel-plugin-common"} diff --git a/docs/.generated/plugin-sdk-api-baseline/core.json b/docs/.generated/plugin-sdk-api-baseline/core.json index 12f84c8b1167..ef56e6b14c73 100644 --- a/docs/.generated/plugin-sdk-api-baseline/core.json +++ b/docs/.generated/plugin-sdk-api-baseline/core.json @@ -1 +1 @@ -{"contentHash":"ab54be164a2b0affece4d6c196ee64ae9259cac072d4ac6c32dd27daf4941565","entrypoint":"core","importSpecifier":"openclaw/plugin-sdk/core"} +{"contentHash":"a6436ac71e8ba6dd99d2fb9dee159bf90675a4ed8beec9805109731630a29957","entrypoint":"core","importSpecifier":"openclaw/plugin-sdk/core"} diff --git a/docs/.generated/plugin-sdk-api-baseline/discord.json b/docs/.generated/plugin-sdk-api-baseline/discord.json index b8fce271f0c0..6162a03b53ba 100644 --- a/docs/.generated/plugin-sdk-api-baseline/discord.json +++ b/docs/.generated/plugin-sdk-api-baseline/discord.json @@ -1 +1 @@ -{"contentHash":"21823a6741645b81797b4be6bf16a2d067e7e2a007177fbc5c7a6cab98b08027","entrypoint":"discord","importSpecifier":"openclaw/plugin-sdk/discord"} +{"contentHash":"eb7884340d27b0e901a2431eff2a64fae5a3d018ea6e910725ac7df2d35520b8","entrypoint":"discord","importSpecifier":"openclaw/plugin-sdk/discord"} diff --git a/docs/.generated/plugin-sdk-api-baseline/inbound-reply-dispatch.json b/docs/.generated/plugin-sdk-api-baseline/inbound-reply-dispatch.json index 430076fe058e..ca7a0d56cfbb 100644 --- a/docs/.generated/plugin-sdk-api-baseline/inbound-reply-dispatch.json +++ b/docs/.generated/plugin-sdk-api-baseline/inbound-reply-dispatch.json @@ -1 +1 @@ -{"contentHash":"80686774b5023519466038e4a20604d9efce462b0cd2915ab5597fb1087ef8af","entrypoint":"inbound-reply-dispatch","importSpecifier":"openclaw/plugin-sdk/inbound-reply-dispatch"} +{"contentHash":"9d305ddc636bf8fb05767cb6be2bf3320b7801d99b0f24f9b012eae2c28959df","entrypoint":"inbound-reply-dispatch","importSpecifier":"openclaw/plugin-sdk/inbound-reply-dispatch"} diff --git a/docs/.generated/plugin-sdk-api-baseline/meeting-runtime.json b/docs/.generated/plugin-sdk-api-baseline/meeting-runtime.json index 539f07550335..485fd7d11b53 100644 --- a/docs/.generated/plugin-sdk-api-baseline/meeting-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/meeting-runtime.json @@ -1 +1 @@ -{"contentHash":"9eb227dd27a3d08b868b1144bd64e1ff22da707af353699b9eb0f6438d4d98c1","entrypoint":"meeting-runtime","importSpecifier":"openclaw/plugin-sdk/meeting-runtime"} +{"contentHash":"ebad10b0c7b1838e6cdf68153383fdc57096dd252ccbc24de5691c1f1821f3cd","entrypoint":"meeting-runtime","importSpecifier":"openclaw/plugin-sdk/meeting-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/plugin-entry.json b/docs/.generated/plugin-sdk-api-baseline/plugin-entry.json index ecfbe38df1a8..d4439981315b 100644 --- a/docs/.generated/plugin-sdk-api-baseline/plugin-entry.json +++ b/docs/.generated/plugin-sdk-api-baseline/plugin-entry.json @@ -1 +1 @@ -{"contentHash":"696f33ed5d8f059b82ff4159183b80f7e4af086abb2383bae5c02d909427fe43","entrypoint":"plugin-entry","importSpecifier":"openclaw/plugin-sdk/plugin-entry"} +{"contentHash":"2d9af7dec89f421a59872de6623d51ed84d8ee23204f147d49b5218ae64ddf2c","entrypoint":"plugin-entry","importSpecifier":"openclaw/plugin-sdk/plugin-entry"} diff --git a/docs/.generated/plugin-sdk-api-baseline/plugin-runtime.json b/docs/.generated/plugin-sdk-api-baseline/plugin-runtime.json index 3b669bec3c96..51a80932b0b8 100644 --- a/docs/.generated/plugin-sdk-api-baseline/plugin-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/plugin-runtime.json @@ -1 +1 @@ -{"contentHash":"411769ac67fd1a5b0ea4ada41b45678688e86194d03adc8d8efcad65c5f0c38c","entrypoint":"plugin-runtime","importSpecifier":"openclaw/plugin-sdk/plugin-runtime"} +{"contentHash":"793f98ff0d772f586920d8a75df073deba94321dcba74daf1caea9ba59cca05e","entrypoint":"plugin-runtime","importSpecifier":"openclaw/plugin-sdk/plugin-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/provider-catalog-runtime.json b/docs/.generated/plugin-sdk-api-baseline/provider-catalog-runtime.json index 22f4ce4ef442..0614f88a5b5a 100644 --- a/docs/.generated/plugin-sdk-api-baseline/provider-catalog-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/provider-catalog-runtime.json @@ -1 +1 @@ -{"contentHash":"6539e277a49a27344f2844bcce06f609c5e463bb5a9be9eb1532994f9fec6479","entrypoint":"provider-catalog-runtime","importSpecifier":"openclaw/plugin-sdk/provider-catalog-runtime"} +{"contentHash":"4841c241310369c97349cc63c956d466a078a1864443e24955ed79031613c806","entrypoint":"provider-catalog-runtime","importSpecifier":"openclaw/plugin-sdk/provider-catalog-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/tool-plugin.json b/docs/.generated/plugin-sdk-api-baseline/tool-plugin.json index 06bfdb667c76..cd40d8e95e15 100644 --- a/docs/.generated/plugin-sdk-api-baseline/tool-plugin.json +++ b/docs/.generated/plugin-sdk-api-baseline/tool-plugin.json @@ -1 +1 @@ -{"contentHash":"9d1c23c498e989db4592749e735d0a09fc41ffeb86ab66f70b6894a2161e1212","entrypoint":"tool-plugin","importSpecifier":"openclaw/plugin-sdk/tool-plugin"} +{"contentHash":"77a1e0b93fa1a501677e5a571ed5ce44358812407554e5e494488d83bcad34d6","entrypoint":"tool-plugin","importSpecifier":"openclaw/plugin-sdk/tool-plugin"} diff --git a/docs/.generated/plugin-sdk-api-baseline/webhook-ingress.json b/docs/.generated/plugin-sdk-api-baseline/webhook-ingress.json index e25b523b4985..772f87ea8959 100644 --- a/docs/.generated/plugin-sdk-api-baseline/webhook-ingress.json +++ b/docs/.generated/plugin-sdk-api-baseline/webhook-ingress.json @@ -1 +1 @@ -{"contentHash":"d0b42cb31d5b2e84acb3a63ca79fdd2bc988bde3505de54dc07dd85bb76fd60c","entrypoint":"webhook-ingress","importSpecifier":"openclaw/plugin-sdk/webhook-ingress"} +{"contentHash":"03433e97b67ba2d0a95fbb7876710f2f4f4425c66d724f55bb4476797b9f2846","entrypoint":"webhook-ingress","importSpecifier":"openclaw/plugin-sdk/webhook-ingress"} diff --git a/docs/plugins/sdk-agent-harness.md b/docs/plugins/sdk-agent-harness.md index 4ee0e941b4e6..1c7b3ac2427b 100644 --- a/docs/plugins/sdk-agent-harness.md +++ b/docs/plugins/sdk-agent-harness.md @@ -53,6 +53,15 @@ threads. Core passes `params.pluginHarnessToolPolicyRestricted` as the prepared decision that the native surface must be isolated. Default tool-profile narrowing does not set this flag. +Harnesses with an independently managed native surface can also declare +`conversationToolPolicySafeDenyTools` using canonical OpenClaw tool names. Core +preserves the native surface only when every expanded deny is a known core tool +in that audited safe list. Finite allowlists, undeclared or unknown tool names, +wildcards, and groups containing any undeclared name remain native-surface +restrictions. Omit the list to retain the conservative behavior where every +explicit restriction isolates the native surface. Because omissions fail +closed, new tools cannot silently relax the policy boundary. + Omit the declaration when any native capability can bypass those layers. OpenClaw then visibly rejects explicitly restricted turns before invoking the harness. The operator can switch the session to the embedded runtime or upgrade diff --git a/extensions/codex/harness.test.ts b/extensions/codex/harness.test.ts index 05036ffe2a13..14a67bf257fb 100644 --- a/extensions/codex/harness.test.ts +++ b/extensions/codex/harness.test.ts @@ -31,6 +31,12 @@ describe("Codex agent harness supports()", () => { expect(harness.autoSelection?.providerIds).toEqual(["codex", "openai"]); }); + it("keeps computer-control denies out of the native-surface exemption", () => { + expect(harness.conversationToolPolicySafeDenyTools).not.toEqual( + expect.arrayContaining(["browser", "computer", "mobile_ui", "nodes", "screen"]), + ); + }); + const harness = createCodexAppServerAgentHarness({ bindingStore: testCodexAppServerBindingStore, }); diff --git a/extensions/codex/harness.ts b/extensions/codex/harness.ts index 3d43f5ea05bf..c2aaceff2b59 100644 --- a/extensions/codex/harness.ts +++ b/extensions/codex/harness.ts @@ -17,6 +17,26 @@ import type { CodexSessionCatalogControl } from "./src/session-catalog-types.js" // New runtime identity uses the `openai` provider. const DEFAULT_CODEX_HARNESS_PROVIDER_IDS = new Set(["codex", "openai"]); const SHARED_CODEX_APP_SERVER_CLIENT_DISPOSER = Symbol.for("openclaw.codexAppServerClientDisposer"); +// Audited against @openai/codex 0.147.0 (rust-v0.147.0). These exact denies +// target OpenClaw-owned capabilities with no Codex-native equivalent. Keep the +// list positive and conservative: an omitted tool isolates the native surface. +const CODEX_TOOL_POLICY_SAFE_DENY_NAMES = [ + "web_fetch", + "x_search", + "memory_search", + "memory_get", + "dashboard", + "canvas", + "show_widget", + "message", + "heartbeat_respond", + "automations", + "gateway", + "skill_workshop", + "music_generate", + "video_generate", + "tts", +] as const; const CODEX_APP_SERVER_CONTEXT_ENGINE_HOST_CAPABILITIES = [ "bootstrap", "assemble-before-prompt", @@ -103,6 +123,7 @@ export function createCodexAppServerAgentHarness(options: { delegatedExecutionPluginIds: ["voice-call"], contextEngineHostCapabilities: CODEX_APP_SERVER_CONTEXT_ENGINE_HOST_CAPABILITIES, conversationToolPolicySupport: "exact", + conversationToolPolicySafeDenyTools: CODEX_TOOL_POLICY_SAFE_DENY_NAMES, deliveryDefaults: { visibleReplies: "message_tool", }, diff --git a/extensions/codex/src/app-server/event-projector-reasoning.ts b/extensions/codex/src/app-server/event-projector-reasoning.ts index 75cf1e0402af..a82216568d01 100644 --- a/extensions/codex/src/app-server/event-projector-reasoning.ts +++ b/extensions/codex/src/app-server/event-projector-reasoning.ts @@ -18,6 +18,7 @@ type ReasoningTextGroup = { }; type AgentEvent = Parameters>[0]; +type PlanUpdateSource = "codex-app-server" | "openclaw"; export class CodexReasoningProjection { private readonly reasoningTextByGroup = new Map(); @@ -75,7 +76,7 @@ export class CodexReasoningProjection { }); } - handleTurnPlanUpdated(params: JsonObject): void { + handleTurnPlanUpdated(params: JsonObject, source: PlanUpdateSource = "codex-app-server"): void { const explanation = readNullableString(params, "explanation"); const plan = Array.isArray(params.plan) ? params.plan.flatMap((entry) => { @@ -101,10 +102,13 @@ export class CodexReasoningProjection { // non-empty update so the terminal transcript proves planning occurred. this.turnPlanText = planText; } - this.emitPlanUpdate({ - explanation, - steps: plan, - }); + this.emitPlanUpdate( + { + explanation, + steps: plan, + }, + source, + ); } recordItem(item: CodexThreadItem | undefined): void { @@ -138,7 +142,10 @@ export class CodexReasoningProjection { ); } - private emitPlanUpdate(params: { explanation?: string | null; steps?: AgentPlanStep[] }): void { + private emitPlanUpdate( + params: { explanation?: string | null; steps?: AgentPlanStep[] }, + source: PlanUpdateSource = "codex-app-server", + ): void { if (!params.explanation && (!params.steps || params.steps.length === 0)) { return; } @@ -147,7 +154,7 @@ export class CodexReasoningProjection { data: { phase: "update", title: "Plan updated", - source: "codex-app-server", + source, ...(params.explanation ? { explanation: params.explanation } : {}), ...(params.steps && params.steps.length > 0 ? { steps: params.steps } : {}), }, diff --git a/extensions/codex/src/app-server/event-projector.ts b/extensions/codex/src/app-server/event-projector.ts index 0306f9fddb8b..1c1c94f55ee3 100644 --- a/extensions/codex/src/app-server/event-projector.ts +++ b/extensions/codex/src/app-server/event-projector.ts @@ -345,6 +345,13 @@ export class CodexAppServerEventProjector { this.toolTranscriptProjection.recordDynamicToolCall(params); } + /** Projects a successful OpenClaw update_plan call through the native plan stream. */ + recordDynamicPlanUpdate(params: unknown): void { + if (isJsonObject(params)) { + this.reasoningProjection.handleTurnPlanUpdated(params, "openclaw"); + } + } + recordDynamicToolResult(params: { callId: string; tool: string; diff --git a/extensions/codex/src/app-server/run-attempt-server-requests.ts b/extensions/codex/src/app-server/run-attempt-server-requests.ts index 2b76da70f22a..5460b3b679c7 100644 --- a/extensions/codex/src/app-server/run-attempt-server-requests.ts +++ b/extensions/codex/src/app-server/run-attempt-server-requests.ts @@ -269,6 +269,9 @@ export function createCodexAttemptServerRequestController( contentItems: protocolResponse.contentItems, }); recordCodexDynamicToolResult(projector, call, response, protocolResponse); + if (protocolResponse.success && call.tool === "update_plan") { + projector?.recordDynamicPlanUpdate(response.executedArguments ?? call.arguments); + } if (shouldEmitDynamicToolProgress) { const progressResponse = toCodexDynamicToolProgressResponse(response, protocolResponse); void emitCodexAppServerEvent(params, { diff --git a/extensions/codex/src/app-server/run-attempt-tools.ts b/extensions/codex/src/app-server/run-attempt-tools.ts index 325b4d33988a..6982f8b5abf3 100644 --- a/extensions/codex/src/app-server/run-attempt-tools.ts +++ b/extensions/codex/src/app-server/run-attempt-tools.ts @@ -1,6 +1,7 @@ import type { EmbeddedRunAttemptParamsV2 as EmbeddedRunAttemptParams } from "openclaw/plugin-sdk/agent-harness-runtime"; import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime"; import { isSystemAgentOnlyCodexDynamicToolAllowlist } from "./dynamic-tool-profile.js"; +import type { CodexDynamicToolRuntimeResponse } from "./dynamic-tool-response-state.js"; import type { CodexDynamicToolCallParams, CodexDynamicToolCallResponse } from "./protocol.js"; import { sanitizeCodexToolResponse } from "./tool-progress-normalization.js"; @@ -49,7 +50,7 @@ type CodexDynamicToolExecutionIdentity = Pick< >; export function createCodexDynamicToolExecutionRegistry() { - const executions = new Map>(); + const executions = new Map>(); const keyFor = (call: CodexDynamicToolExecutionIdentity) => JSON.stringify([call.threadId, call.turnId, call.callId]); @@ -59,7 +60,7 @@ export function createCodexDynamicToolExecutionRegistry() { }, claim( call: CodexDynamicToolExecutionIdentity, - start: () => Promise, + start: () => Promise, ) { const existing = executions.get(keyFor(call)); if (existing) { @@ -87,5 +88,11 @@ export function resolveCodexDynamicToolDirectNames( if (params.sourceReplyDeliveryMode === "message_tool_only") { names.push("message"); } + // Restricted plugin runs replace Codex's native tool surface with an exact + // OpenClaw policy-filtered catalog. Keep the replacement planner visible in + // the initial context so Codex can maintain the same user-facing plan stream. + if (params.pluginHarnessToolPolicyRestricted === true) { + names.push("update_plan"); + } return names; } diff --git a/extensions/codex/src/app-server/run-attempt.test.ts b/extensions/codex/src/app-server/run-attempt.test.ts index 262b60123962..0d7b38ef1a01 100644 --- a/extensions/codex/src/app-server/run-attempt.test.ts +++ b/extensions/codex/src/app-server/run-attempt.test.ts @@ -167,6 +167,9 @@ const testing = { if (params.sourceReplyDeliveryMode === "message_tool_only") { names.push("message"); } + if (params.pluginHarnessToolPolicyRestricted === true) { + names.push("update_plan"); + } return names; }, setOpenClawCodingToolsFactoryForTests( @@ -2314,7 +2317,9 @@ describe("runCodexAppServerAttempt", () => { it("replaces the native surface with an exact conversation-policy-filtered catalog", async () => { testing.setOpenClawCodingToolsFactoryForTests((options) => createOpenClawCodingTools(options).filter((tool) => - ["read", "write", "edit", "apply_patch", "exec", "process"].includes(tool.name), + ["read", "write", "edit", "apply_patch", "exec", "process", "update_plan"].includes( + tool.name, + ), ), ); const params = createRunParams(); @@ -2325,6 +2330,8 @@ describe("runCodexAppServerAttempt", () => { deny: ["exec", "process", "write", "edit"], }; params.pluginHarnessToolPolicyRestricted = true; + const onAgentEvent = vi.fn(); + params.onAgentEvent = onAgentEvent; const harness = createStartedThreadHarness(async (method) => { if (method === "config/read") { return { config: {}, layers: [] }; @@ -2353,7 +2360,12 @@ describe("runCodexAppServerAttempt", () => { ); expect(startParams?.environments).toEqual([]); - expect(dynamicToolNames.toSorted()).toEqual(["apply_patch", "read"]); + expect(dynamicToolNames.toSorted()).toEqual(["apply_patch", "read", "update_plan"]); + const updatePlanSpec = flattenSpecsWithNamespace(startParams?.dynamicTools ?? []).find( + (tool) => tool.name === "update_plan", + ); + expect(updatePlanSpec).not.toHaveProperty("namespace"); + expect(updatePlanSpec).not.toHaveProperty("deferLoading"); expect(startParams?.config).toMatchObject({ "features.hooks": false, "hooks.PreToolUse": [], @@ -2363,6 +2375,34 @@ describe("runCodexAppServerAttempt", () => { }); expect(harness.requests.map((request) => request.method)).toContain("mcpServerStatus/list"); + const plan = [ + { step: "Inspect regression", status: "completed" }, + { step: "Restore progress", status: "in_progress" }, + ]; + const response = await harness.handleServerRequest({ + id: "request-plan-1", + method: "item/tool/call", + params: { + threadId: "thread-1", + turnId: "turn-1", + callId: "call-plan-1", + namespace: null, + tool: "update_plan", + arguments: { explanation: "Plan restored", plan }, + }, + }); + expect(response).toMatchObject({ success: true }); + expect(onAgentEvent).toHaveBeenCalledWith({ + stream: "plan", + data: { + phase: "update", + title: "Plan updated", + source: "openclaw", + explanation: "Plan restored", + steps: plan, + }, + }); + await harness.completeTurn({ threadId: "thread-1", turnId: "turn-1" }); await run; }); diff --git a/src/agents/harness/selection.test.ts b/src/agents/harness/selection.test.ts index 3aa1825cd838..7a11be617748 100644 --- a/src/agents/harness/selection.test.ts +++ b/src/agents/harness/selection.test.ts @@ -1298,6 +1298,48 @@ describe("runAgentHarnessAttempt", () => { ]); }); + it("isolates native tools unless every exact deny is explicitly safe", async () => { + const received: boolean[] = []; + const runAttempt = vi.fn(async (attempt) => { + received.push(attempt.pluginHarnessToolPolicyRestricted === true); + return createAttemptResult("codex"); + }); + const harness: AgentHarness = { + id: "codex", + label: "Codex", + conversationToolPolicySupport: "exact", + conversationToolPolicySafeDenyTools: [ + "tts", + "music_generate", + "browser", + "unknown_native_tool", + ], + supports: (ctx) => + ctx.provider === "codex" ? { supported: true, priority: 100 } : { supported: false }, + runAttempt, + }; + registerAgentHarness(harness, { ownerPluginId: "codex" }); + + const policies = [ + { deny: ["tts", "music_generate"] }, + { deny: ["browser"] }, + { deny: ["exec"] }, + { deny: ["video_generate"] }, + { deny: ["unknown_native_tool"] }, + { deny: ["group:runtime"] }, + { deny: ["*"] }, + { allow: ["tts"] }, + ]; + for (const conversationToolPolicy of policies) { + await runAgentHarnessAttempt({ + ...createAttemptParams(), + conversationToolPolicy, + }); + } + + expect(received).toEqual([false, false, true, true, true, true, true, true]); + }); + it("marks only explicit restrictive policy layers for plugin harness isolation", async () => { const received: boolean[] = []; const runAttempt = vi.fn(async (attempt) => { diff --git a/src/agents/harness/selection.ts b/src/agents/harness/selection.ts index 2c4e0a9fa0c8..137bc77de7ff 100644 --- a/src/agents/harness/selection.ts +++ b/src/agents/harness/selection.ts @@ -30,6 +30,7 @@ import { unwrapSecretSentinelsForProviderEgress, } from "../provider-secret-egress.js"; import { resolveSandboxRuntimeStatus } from "../sandbox/runtime-status.js"; +import { isKnownCoreToolId } from "../tool-catalog.js"; import { expandToolGroups, mergeAlsoAllowPolicy, @@ -575,7 +576,7 @@ async function runSelectedAgentHarnessAttempt( isSystemAgentOnlyAllowlist(pluginAttempt.params.toolsAllow); const preparedParams = selection.builtIn ? pluginAttempt.params - : preparePluginHarnessParams(pluginAttempt.params); + : preparePluginHarnessParams(pluginAttempt.params, harness); const effectiveAttemptParams = hostOpenClawAuthority && preparedParams.pluginHarnessToolPolicyRestricted ? { ...preparedParams, pluginHarnessToolPolicyRestricted: false } @@ -773,6 +774,7 @@ function withoutPluginHarnessPrivateState( function preparePluginHarnessParams( params: import("./types.js").AgentHarnessAttemptParamsV2, + harness: AgentHarness, ): import("./types.js").AgentHarnessAttemptParamsV2 { const boundary = "plugin harness handoff"; const resolvedApiKey = params.resolvedApiKey @@ -783,7 +785,12 @@ function preparePluginHarnessParams( model === params.model && resolvedApiKey === params.resolvedApiKey ? params : { ...params, model, resolvedApiKey }; - const policies = resolvePluginHarnessToolPolicies(preparedParams); + const policies = resolvePluginHarnessToolPolicies( + preparedParams, + harness.conversationToolPolicySupport === "exact" + ? harness.conversationToolPolicySafeDenyTools + : undefined, + ); return applyPluginHarnessDenyAllToolPolicy( { ...preparedParams, @@ -861,6 +868,7 @@ function resolvePluginHarnessDenyAllToolPolicyPrompt( function resolvePluginHarnessToolPolicies( params: PluginHarnessToolPolicyContext, + safeDenyToolNames?: readonly string[], ): ResolvedPluginHarnessToolPolicies { const messageProvider = params.messageProvider ?? params.messageChannel; const sandboxSessionKey = params.sandboxSessionKey ?? params.sessionKey; @@ -924,6 +932,9 @@ function resolvePluginHarnessToolPolicies( policy.inheritedToolPolicy, policy.runtimeToolPolicyForInheritance, ]; + const safeDenyToolNameSet = safeDenyToolNames + ? new Set(safeDenyToolNames.map(normalizeToolPolicyName)) + : undefined; return { senderPolicy: policy.senderPolicy, senderScopedGroupPolicy: resolveSenderScopedGroupToolPolicy( @@ -943,10 +954,28 @@ function resolvePluginHarnessToolPolicies( policy.subagentPolicy, policy.inheritedToolPolicy, ], - toolPolicyRestricted: explicitPolicies.some(toolPolicyRestrictsTools), + toolPolicyRestricted: explicitPolicies.some((explicitPolicy) => + toolPolicyRestrictsHarnessNativeTools(explicitPolicy, safeDenyToolNameSet), + ), }; } +function toolPolicyRestrictsHarnessNativeTools( + policy: PluginHarnessToolPolicy | undefined, + safeDenyToolNames: ReadonlySet | undefined, +): boolean { + if (!safeDenyToolNames) { + return toolPolicyRestrictsTools(policy); + } + if (!policy || toolPolicyRestrictsTools({ allow: policy.allow })) { + return toolPolicyRestrictsTools(policy); + } + return expandToolGroups(policy.deny ?? []).some((deniedName) => { + const normalized = normalizeToolPolicyName(deniedName); + return !isKnownCoreToolId(normalized) || !safeDenyToolNames.has(normalized); + }); +} + function resolveSenderScopedGroupToolPolicy( params: PluginHarnessToolPolicyContext, groupPolicyParams: Parameters[0], diff --git a/src/agents/harness/types.ts b/src/agents/harness/types.ts index 4f7816796176..33bc1e06c106 100644 --- a/src/agents/harness/types.ts +++ b/src/agents/harness/types.ts @@ -347,6 +347,11 @@ type AgentHarnessRunCapability< deliveryDefaults?: AgentHarnessDeliveryDefaults; /** Certifies exact runAttempt enforcement; direct-policy-restricted channel side questions fail in core. */ conversationToolPolicySupport?: "exact"; + /** + * Canonical OpenClaw tool names whose exact denies are fully enforced outside + * this harness's native surface. Every other deny remains fail-closed. + */ + conversationToolPolicySafeDenyTools?: readonly string[]; supports(ctx: AgentHarnessSupportContext): AgentHarnessSupport; /** Lets this harness resolve forwarded profiles or its own native credentials. */ authBootstrap?: "harness";