fix(codex): honor denied app-server tool policy

This commit is contained in:
VACInc
2026-05-15 20:53:30 -04:00
committed by Peter Steinberger
parent d63c581dec
commit dad3db40d3
4 changed files with 179 additions and 12 deletions

View File

@@ -953,6 +953,35 @@ describe("runCodexAppServerAttempt", () => {
).toEqual(["exec", "apply_patch", "read"]);
});
it("treats an explicit empty Codex dynamic toolsAllow as no tools", () => {
const tools = ["message", "web_search"].map((name) => ({ name }));
expect(__testing.filterCodexDynamicToolsForAllowlist(tools, [])).toEqual([]);
});
it("treats wildcard Codex dynamic toolsAllow as unrestricted", () => {
const tools = ["message", "web_search"].map((name) => ({ name }));
expect(__testing.filterCodexDynamicToolsForAllowlist(tools, [" * "])).toEqual(tools);
});
it("disables Codex native tool surfaces for restricted runtime allowlists", () => {
const workspaceDir = path.join(tempDir, "workspace");
const params = createParams(path.join(tempDir, "session.jsonl"), workspaceDir);
params.disableTools = false;
expect(__testing.shouldEnableCodexAppServerNativeToolSurface(params)).toBe(true);
params.toolsAllow = ["*"];
expect(__testing.shouldEnableCodexAppServerNativeToolSurface(params)).toBe(true);
params.toolsAllow = [];
expect(__testing.shouldEnableCodexAppServerNativeToolSurface(params)).toBe(false);
params.toolsAllow = ["message"];
expect(__testing.shouldEnableCodexAppServerNativeToolSurface(params)).toBe(false);
});
it("forces the message dynamic tool for message-tool-only source replies", () => {
const workspaceDir = path.join(tempDir, "workspace");
const params = createParams(path.join(tempDir, "session.jsonl"), workspaceDir);
@@ -1072,6 +1101,62 @@ describe("runCodexAppServerAttempt", () => {
expect(heartbeat?.deferLoading).toBe(true);
});
it("disables Codex native tool surfaces when runtime toolsAllow is empty", async () => {
__testing.setOpenClawCodingToolsFactoryForTests(() => [
createRuntimeDynamicTool("message"),
createRuntimeDynamicTool("web_search"),
]);
const harness = createStartedThreadHarness(async (method) => {
if (method === "app/list") {
throw new Error("app/list should not run when runtime toolsAllow is empty.");
}
return undefined;
});
const params = createParams(
path.join(tempDir, "session.jsonl"),
path.join(tempDir, "workspace"),
);
params.disableTools = false;
params.runtimePlan = createCodexRuntimePlanFixture();
params.toolsAllow = [];
const run = runCodexAppServerAttempt(params, {
pluginConfig: {
appServer: { mode: "yolo" },
codexPlugins: {
enabled: true,
plugins: {
"google-calendar": {
marketplaceName: "openai-curated",
pluginName: "google-calendar",
},
},
},
},
});
await harness.waitForMethod("turn/start", 120_000);
await harness.completeTurn({ threadId: "thread-1", turnId: "turn-1" });
await run;
const startRequest = harness.requests.find((entry) => entry.method === "thread/start");
const startParams = startRequest?.params as
| {
dynamicTools?: Array<{ name?: string }>;
config?: {
"features.code_mode"?: boolean;
"features.code_mode_only"?: boolean;
apps?: Record<string, { enabled?: boolean }>;
};
}
| undefined;
expect(startParams?.dynamicTools).toEqual([]);
expect(startParams?.config?.["features.code_mode"]).toBe(false);
expect(startParams?.config?.["features.code_mode_only"]).toBe(false);
expect(startParams?.config?.apps?.["google-calendar-app"]?.enabled).toBeUndefined();
expect(harness.requests.map((entry) => entry.method)).not.toContain("app/list");
});
it("returns a run context report without deferred Codex dynamic tool schemas", async () => {
__testing.setOpenClawCodingToolsFactoryForTests(() => [
createRuntimeDynamicTool("message"),

View File

@@ -859,6 +859,7 @@ export async function runCodexAppServerAttempt(
disableTools: params.disableTools,
toolsAllow: params.toolsAllow,
});
const nativeToolSurfaceEnabled = shouldEnableCodexAppServerNativeToolSurface(params);
for (const diagnostic of bundleMcpThreadConfig.diagnostics) {
embeddedAgentLog.warn(`bundle-mcp: ${diagnostic.pluginId}: ${diagnostic.message}`);
}
@@ -1136,7 +1137,8 @@ export async function runCodexAppServerAttempt(
const threadConfig = mergeCodexThreadConfigs(
bundleMcpThreadConfig?.configPatch as JsonObject | undefined,
);
const pluginThreadConfigEnabled = shouldBuildCodexPluginThreadConfig(pluginConfig);
const pluginThreadConfigEnabled =
nativeToolSurfaceEnabled && shouldBuildCodexPluginThreadConfig(pluginConfig);
const pluginAppCacheKey = buildCodexPluginAppCacheKey({
appServer,
agentDir,
@@ -1197,6 +1199,7 @@ export async function runCodexAppServerAttempt(
developerInstructions: promptBuild.developerInstructions,
config: threadConfig,
finalConfigPatch: nativeHookRelayConfig,
nativeCodeModeEnabled: nativeToolSurfaceEnabled,
mcpServersFingerprint: bundleMcpThreadConfig.fingerprint,
mcpServersFingerprintEvaluated: bundleMcpThreadConfig.evaluated,
contextEngineProjection,
@@ -3109,10 +3112,11 @@ function includeForcedMessageToolAllow(
toolsAllow: string[] | undefined,
params: EmbeddedRunAttemptParams,
): string[] | undefined {
if (!shouldForceMessageTool(params)) {
return toolsAllow;
}
if (toolsAllow === undefined) {
if (
!shouldForceMessageTool(params) ||
toolsAllow === undefined ||
hasWildcardCodexToolsAllow(toolsAllow)
) {
return toolsAllow;
}
if (toolsAllow.length === 0) {
@@ -3122,11 +3126,28 @@ function includeForcedMessageToolAllow(
return normalized.has("message") ? toolsAllow : [...toolsAllow, "message"];
}
function shouldEnableCodexAppServerNativeToolSurface(params: EmbeddedRunAttemptParams): boolean {
const toolsAllow = includeForcedMessageToolAllow(params.toolsAllow, params);
if (toolsAllow === undefined) {
return true;
}
// Codex native code mode exposes its shell/file surface as one app-server
// capability, so narrow OpenClaw allowlists must fail closed rather than
// widening `message` or `web_search` into shell access.
return hasWildcardCodexToolsAllow(toolsAllow);
}
function filterCodexDynamicToolsForAllowlist<T extends { name: string }>(
tools: T[],
toolsAllow?: string[],
): T[] {
if (!toolsAllow || toolsAllow.length === 0) {
if (!toolsAllow) {
return tools;
}
if (toolsAllow.length === 0) {
return [];
}
if (hasWildcardCodexToolsAllow(toolsAllow)) {
return tools;
}
const allowSet = new Set(
@@ -3135,6 +3156,10 @@ function filterCodexDynamicToolsForAllowlist<T extends { name: string }>(
return tools.filter((tool) => allowSet.has(normalizeCodexDynamicToolName(tool.name)));
}
function hasWildcardCodexToolsAllow(toolsAllow: string[]): boolean {
return toolsAllow.some((name) => normalizeCodexDynamicToolName(name) === "*");
}
function shouldForceMessageTool(params: EmbeddedRunAttemptParams): boolean {
return params.sourceReplyDeliveryMode === "message_tool_only";
}
@@ -4221,6 +4246,7 @@ export const __testing = {
buildDynamicTools,
filterCodexDynamicToolsForAllowlist,
filterToolsForVisionInputs,
hasWildcardCodexToolsAllow,
handleDynamicToolCallWithTimeout,
isInvalidCodexImagePayloadError,
remapCodexContextFilePath,
@@ -4230,6 +4256,7 @@ export const __testing = {
restrictCodexAppServerSandboxForOpenClawSandbox,
resolveCodexAppServerForOpenClawToolPolicy,
resolveOpenClawCodingToolsSessionKeys,
shouldEnableCodexAppServerNativeToolSurface,
shouldForceMessageTool,
setOpenClawCodingToolsFactoryForTests(factory: OpenClawCodingToolsFactory): void {
openClawCodingToolsFactoryForTests = factory;

View File

@@ -91,6 +91,39 @@ describe("Codex app-server native code mode config", () => {
});
});
it("disables Codex native code mode on thread/start when runtime policy denies it", () => {
const request = buildThreadStartParams(createAttemptParams({ provider: "openai" }), {
cwd: "/repo",
dynamicTools: [],
appServer: createAppServerOptions() as never,
developerInstructions: "test instructions",
nativeCodeModeEnabled: false,
config: {
"features.code_mode": true,
"features.code_mode_only": true,
},
});
expect(request.config).toEqual({
"features.code_mode": false,
"features.code_mode_only": false,
});
});
it("disables Codex native code mode on thread/resume when runtime policy denies it", () => {
const request = buildThreadResumeParams(createAttemptParams({ provider: "openai" }), {
threadId: "thread-1",
appServer: createAppServerOptions() as never,
developerInstructions: "test instructions",
nativeCodeModeEnabled: false,
});
expect(request.config).toEqual({
"features.code_mode": false,
"features.code_mode_only": false,
});
});
it("disables native Codex project docs for lightweight context threads", () => {
const request = buildThreadStartParams(
createAttemptParams({

View File

@@ -73,6 +73,11 @@ export const CODEX_CODE_MODE_THREAD_CONFIG: JsonObject = {
"features.code_mode_only": true,
};
export const CODEX_CODE_MODE_DISABLED_THREAD_CONFIG: JsonObject = {
"features.code_mode": false,
"features.code_mode_only": false,
};
const CODEX_LIGHTWEIGHT_CONTEXT_THREAD_CONFIG: JsonObject = {
project_doc_max_bytes: 0,
};
@@ -87,6 +92,7 @@ export async function startOrResumeThread(params: {
developerInstructions?: string;
config?: JsonObject;
finalConfigPatch?: JsonObject;
nativeCodeModeEnabled?: boolean;
mcpServersFingerprint?: string;
mcpServersFingerprintEvaluated?: boolean;
pluginThreadConfig?: CodexPluginThreadConfigProvider;
@@ -246,6 +252,7 @@ export async function startOrResumeThread(params: {
appServer: params.appServer,
developerInstructions: params.developerInstructions,
config: resumeConfig,
nativeCodeModeEnabled: params.nativeCodeModeEnabled,
}),
),
);
@@ -341,6 +348,7 @@ export async function startOrResumeThread(params: {
appServer: params.appServer,
developerInstructions: params.developerInstructions,
config,
nativeCodeModeEnabled: params.nativeCodeModeEnabled,
}),
),
);
@@ -539,6 +547,7 @@ export function buildThreadStartParams(
appServer: CodexAppServerRuntimeOptions;
developerInstructions?: string;
config?: JsonObject;
nativeCodeModeEnabled?: boolean;
},
): CodexThreadStartParams {
const modelProvider = resolveCodexAppServerModelProvider({
@@ -557,7 +566,9 @@ export function buildThreadStartParams(
sandbox: options.appServer.sandbox,
...(options.appServer.serviceTier ? { serviceTier: options.appServer.serviceTier } : {}),
serviceName: "OpenClaw",
config: buildCodexRuntimeThreadConfigForRun(params, options.config),
config: buildCodexRuntimeThreadConfigForRun(params, options.config, {
nativeCodeModeEnabled: options.nativeCodeModeEnabled,
}),
developerInstructions: options.developerInstructions ?? buildDeveloperInstructions(params),
dynamicTools: options.dynamicTools,
experimentalRawEvents: true,
@@ -573,6 +584,7 @@ export function buildThreadResumeParams(
appServer: CodexAppServerRuntimeOptions;
developerInstructions?: string;
config?: JsonObject;
nativeCodeModeEnabled?: boolean;
},
): CodexThreadResumeParams {
const modelProvider = resolveCodexAppServerModelProvider({
@@ -590,15 +602,24 @@ export function buildThreadResumeParams(
approvalsReviewer: options.appServer.approvalsReviewer,
sandbox: options.appServer.sandbox,
...(options.appServer.serviceTier ? { serviceTier: options.appServer.serviceTier } : {}),
config: buildCodexRuntimeThreadConfigForRun(params, options.config),
config: buildCodexRuntimeThreadConfigForRun(params, options.config, {
nativeCodeModeEnabled: options.nativeCodeModeEnabled,
}),
developerInstructions: options.developerInstructions ?? buildDeveloperInstructions(params),
persistExtendedHistory: true,
};
}
export function buildCodexRuntimeThreadConfig(config: JsonObject | undefined): JsonObject {
const runtimeConfig = mergeCodexThreadConfigs(config, CODEX_CODE_MODE_THREAD_CONFIG) ?? {
...CODEX_CODE_MODE_THREAD_CONFIG,
export function buildCodexRuntimeThreadConfig(
config: JsonObject | undefined,
options: { nativeCodeModeEnabled?: boolean } = {},
): JsonObject {
const codeModeConfig =
options.nativeCodeModeEnabled === false
? CODEX_CODE_MODE_DISABLED_THREAD_CONFIG
: CODEX_CODE_MODE_THREAD_CONFIG;
const runtimeConfig = mergeCodexThreadConfigs(config, codeModeConfig) ?? {
...codeModeConfig,
};
return runtimeConfig;
}
@@ -606,8 +627,9 @@ export function buildCodexRuntimeThreadConfig(config: JsonObject | undefined): J
function buildCodexRuntimeThreadConfigForRun(
params: EmbeddedRunAttemptParams,
config: JsonObject | undefined,
options: { nativeCodeModeEnabled?: boolean } = {},
): JsonObject {
const runtimeConfig = buildCodexRuntimeThreadConfig(config);
const runtimeConfig = buildCodexRuntimeThreadConfig(config, options);
if (params.bootstrapContextMode !== "lightweight") {
return runtimeConfig;
}