mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
fix(codex): preserve allowed tools under channel policy (#122282)
Preserve Codex native tools when conversation policies deny only audited OpenClaw capabilities. Fail closed for computer-control denies and retain structured plan progress. Co-authored-by: Ayaan Zaidi <hi@obviy.us> Co-authored-by: Kelaw - Keshav's Agent <keshavbotagent@gmail.com>
This commit is contained in:
@@ -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"}
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
{"contentHash":"3d1c84bcc585b57a93054889e16fb3be02ff4d599d7d2a0566df6f463f2254a8","entrypoint":"agent-harness","importSpecifier":"openclaw/plugin-sdk/agent-harness"}
|
||||
{"contentHash":"08051f15195453d4f35616cdb4f0ff47e974f6c2260f0ee1078e1754f5284899","entrypoint":"agent-harness","importSpecifier":"openclaw/plugin-sdk/agent-harness"}
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
{"contentHash":"4245891bae7517cf001f637e80d1d813c11385d695eebbd69a61258e83399c4e","entrypoint":"channel-core","importSpecifier":"openclaw/plugin-sdk/channel-core"}
|
||||
{"contentHash":"0c78a76e24e4afa2f37813ea3202262605159ddc1bba101778fd68036ee0ae70","entrypoint":"channel-core","importSpecifier":"openclaw/plugin-sdk/channel-core"}
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"contentHash":"d531b32d0544c42f375f628bd7a6b3d3582a91078db37bc048af7c3f23a9e59f","entrypoint":"channel-message","importSpecifier":"openclaw/plugin-sdk/channel-message"}
|
||||
{"contentHash":"a152541a6f351027b97e0c99a02f27038ea5475e96f4513df1b950465c390242","entrypoint":"channel-message","importSpecifier":"openclaw/plugin-sdk/channel-message"}
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"contentHash":"40b25681a1ee102cf1d2d5b23a29f96e2501ea521404deed59303e59c35089ec","entrypoint":"channel-outbound","importSpecifier":"openclaw/plugin-sdk/channel-outbound"}
|
||||
{"contentHash":"d75ce81f95cf3a2382057e90d70f5f8b925cd3d6236a5acce844e3512f5539b6","entrypoint":"channel-outbound","importSpecifier":"openclaw/plugin-sdk/channel-outbound"}
|
||||
|
||||
@@ -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"}
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
{"contentHash":"ab54be164a2b0affece4d6c196ee64ae9259cac072d4ac6c32dd27daf4941565","entrypoint":"core","importSpecifier":"openclaw/plugin-sdk/core"}
|
||||
{"contentHash":"a6436ac71e8ba6dd99d2fb9dee159bf90675a4ed8beec9805109731630a29957","entrypoint":"core","importSpecifier":"openclaw/plugin-sdk/core"}
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
{"contentHash":"21823a6741645b81797b4be6bf16a2d067e7e2a007177fbc5c7a6cab98b08027","entrypoint":"discord","importSpecifier":"openclaw/plugin-sdk/discord"}
|
||||
{"contentHash":"eb7884340d27b0e901a2431eff2a64fae5a3d018ea6e910725ac7df2d35520b8","entrypoint":"discord","importSpecifier":"openclaw/plugin-sdk/discord"}
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"contentHash":"9eb227dd27a3d08b868b1144bd64e1ff22da707af353699b9eb0f6438d4d98c1","entrypoint":"meeting-runtime","importSpecifier":"openclaw/plugin-sdk/meeting-runtime"}
|
||||
{"contentHash":"ebad10b0c7b1838e6cdf68153383fdc57096dd252ccbc24de5691c1f1821f3cd","entrypoint":"meeting-runtime","importSpecifier":"openclaw/plugin-sdk/meeting-runtime"}
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
{"contentHash":"696f33ed5d8f059b82ff4159183b80f7e4af086abb2383bae5c02d909427fe43","entrypoint":"plugin-entry","importSpecifier":"openclaw/plugin-sdk/plugin-entry"}
|
||||
{"contentHash":"2d9af7dec89f421a59872de6623d51ed84d8ee23204f147d49b5218ae64ddf2c","entrypoint":"plugin-entry","importSpecifier":"openclaw/plugin-sdk/plugin-entry"}
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
{"contentHash":"411769ac67fd1a5b0ea4ada41b45678688e86194d03adc8d8efcad65c5f0c38c","entrypoint":"plugin-runtime","importSpecifier":"openclaw/plugin-sdk/plugin-runtime"}
|
||||
{"contentHash":"793f98ff0d772f586920d8a75df073deba94321dcba74daf1caea9ba59cca05e","entrypoint":"plugin-runtime","importSpecifier":"openclaw/plugin-sdk/plugin-runtime"}
|
||||
|
||||
@@ -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"}
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
{"contentHash":"9d1c23c498e989db4592749e735d0a09fc41ffeb86ab66f70b6894a2161e1212","entrypoint":"tool-plugin","importSpecifier":"openclaw/plugin-sdk/tool-plugin"}
|
||||
{"contentHash":"77a1e0b93fa1a501677e5a571ed5ce44358812407554e5e494488d83bcad34d6","entrypoint":"tool-plugin","importSpecifier":"openclaw/plugin-sdk/tool-plugin"}
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"contentHash":"d0b42cb31d5b2e84acb3a63ca79fdd2bc988bde3505de54dc07dd85bb76fd60c","entrypoint":"webhook-ingress","importSpecifier":"openclaw/plugin-sdk/webhook-ingress"}
|
||||
{"contentHash":"03433e97b67ba2d0a95fbb7876710f2f4f4425c66d724f55bb4476797b9f2846","entrypoint":"webhook-ingress","importSpecifier":"openclaw/plugin-sdk/webhook-ingress"}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
|
||||
@@ -18,6 +18,7 @@ type ReasoningTextGroup = {
|
||||
};
|
||||
|
||||
type AgentEvent = Parameters<NonNullable<EmbeddedRunAttemptParams["onAgentEvent"]>>[0];
|
||||
type PlanUpdateSource = "codex-app-server" | "openclaw";
|
||||
|
||||
export class CodexReasoningProjection {
|
||||
private readonly reasoningTextByGroup = new Map<string, ReasoningTextGroup>();
|
||||
@@ -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 } : {}),
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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<string, Promise<CodexDynamicToolCallResponse>>();
|
||||
const executions = new Map<string, Promise<CodexDynamicToolRuntimeResponse>>();
|
||||
const keyFor = (call: CodexDynamicToolExecutionIdentity) =>
|
||||
JSON.stringify([call.threadId, call.turnId, call.callId]);
|
||||
|
||||
@@ -59,7 +60,7 @@ export function createCodexDynamicToolExecutionRegistry() {
|
||||
},
|
||||
claim(
|
||||
call: CodexDynamicToolExecutionIdentity,
|
||||
start: () => Promise<CodexDynamicToolCallResponse>,
|
||||
start: () => Promise<CodexDynamicToolRuntimeResponse>,
|
||||
) {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
@@ -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<AgentHarness["runAttempt"]>(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<AgentHarness["runAttempt"]>(async (attempt) => {
|
||||
|
||||
@@ -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<string> | 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<typeof resolveGroupToolPolicy>[0],
|
||||
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user