Skip to content

Commit f0d8048

Browse files
authored
fix(search): enforce native web search tool policy (#91750)
* fix(search): enforce native web search tool policy * fix(search): apply session policy to native web search * fix(search): gate direct OpenAI native search * fix(search): redact native web search provider context
1 parent 54415d3 commit f0d8048

16 files changed

Lines changed: 638 additions & 14 deletions
Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,2 @@
1-
ae06e87a060aaa9618e2b245553d90402c0fbbe1ebc864928dc7f771cede7c6d plugin-sdk-api-baseline.json
2-
8ae4665726d0a8e2e80587ab0b98afce6718861a996daef2fac207066c29dd4f plugin-sdk-api-baseline.jsonl
1+
424f72310250c242a3157994c45f7319370c1bf99014a13ee5f86cc811f43dd4 plugin-sdk-api-baseline.json
2+
87e786df92be36c22a7b392162c9fa39b634094d15aec4736d8b65be8d7cfd9c plugin-sdk-api-baseline.jsonl

extensions/openai/native-web-search.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,13 +87,20 @@ export function patchOpenAINativeWebSearchPayload(
8787

8888
export function createOpenAINativeWebSearchWrapper(
8989
baseStreamFn: StreamFn | undefined,
90-
params: { config?: OpenClawConfig },
90+
params: {
91+
config?: OpenClawConfig;
92+
agentId?: string;
93+
nativeWebSearchAllowedByToolPolicy?: boolean;
94+
},
9195
): StreamFn {
9296
const underlying = baseStreamFn ?? streamSimple;
9397
return (model, context, options) => {
9498
if (!shouldEnableOpenAINativeWebSearch({ config: params.config, model })) {
9599
return underlying(model, context, options);
96100
}
101+
if (params.nativeWebSearchAllowedByToolPolicy === false) {
102+
return underlying(model, context, options);
103+
}
97104
return streamWithPayloadPatch(underlying, model, context, options, (payload) => {
98105
patchOpenAINativeWebSearchPayload(payload);
99106
});

extensions/openai/openai-provider.test.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ vi.mock("openclaw/plugin-sdk/provider-stream-family", async (importOriginal) =>
5454
nextStreamFn = actual.createCodexNativeWebSearchWrapper(nextStreamFn, {
5555
config: ctx.config,
5656
agentDir: ctx.agentDir,
57+
agentId: ctx.agentId,
5758
});
5859
return actual.createOpenAIResponsesContextManagementWrapper(
5960
actual.createOpenAIReasoningCompatibilityWrapper(nextStreamFn),
@@ -80,6 +81,8 @@ function runWrappedPayloadCase(params: {
8081
| Model<"azure-openai-responses">;
8182
extraParams?: Record<string, unknown>;
8283
cfg?: Record<string, unknown>;
84+
agentId?: string;
85+
nativeWebSearchAllowedByToolPolicy?: boolean;
8386
payload?: Record<string, unknown>;
8487
}) {
8588
const payload = params.payload ?? { store: false };
@@ -96,6 +99,8 @@ function runWrappedPayloadCase(params: {
9699
extraParams: params.extraParams,
97100
config: params.cfg as never,
98101
agentDir: "/tmp/openai-provider-test",
102+
agentId: params.agentId,
103+
nativeWebSearchAllowedByToolPolicy: params.nativeWebSearchAllowedByToolPolicy,
99104
streamFn: baseStreamFn,
100105
} as never);
101106

@@ -1218,6 +1223,50 @@ describe("buildOpenAIProvider", () => {
12181223
]);
12191224
});
12201225

1226+
it("keeps managed OpenAI web_search when agent policy denies native web search", () => {
1227+
const provider = buildOpenAIProvider();
1228+
const wrap = provider.wrapStreamFn;
1229+
expect(wrap).toBeTypeOf("function");
1230+
if (!wrap) {
1231+
throw new Error("expected OpenAI wrapper");
1232+
}
1233+
1234+
const result = runWrappedPayloadCase({
1235+
wrap,
1236+
provider: "openai",
1237+
modelId: "gpt-5.4",
1238+
agentId: "main",
1239+
nativeWebSearchAllowedByToolPolicy: false,
1240+
cfg: {
1241+
agents: {
1242+
list: [
1243+
{
1244+
id: "main",
1245+
tools: { deny: ["web_search"] },
1246+
},
1247+
],
1248+
},
1249+
},
1250+
model: {
1251+
api: "openai-responses",
1252+
provider: "openai",
1253+
id: "gpt-5.4",
1254+
baseUrl: "https://api.openai.com/v1",
1255+
} as Model<"openai-responses">,
1256+
payload: {
1257+
tools: [
1258+
{ type: "function", name: "read" },
1259+
{ type: "function", name: "web_search" },
1260+
],
1261+
},
1262+
});
1263+
1264+
expect(result.payload.tools).toEqual([
1265+
{ type: "function", name: "read" },
1266+
{ type: "function", name: "web_search" },
1267+
]);
1268+
});
1269+
12211270
it("raises minimal reasoning when native OpenAI web search is injected", () => {
12221271
const provider = buildOpenAIProvider();
12231272
const wrap = provider.wrapStreamFn;

extensions/openai/shared.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,8 @@ const wrapOpenAIResponsesProviderStreamFn: NonNullable<
8888
> = (ctx) =>
8989
createOpenAINativeWebSearchWrapper(wrapOpenAIResponsesStreamFn?.(ctx) ?? ctx.streamFn, {
9090
config: ctx.config,
91+
agentId: ctx.agentId,
92+
nativeWebSearchAllowedByToolPolicy: ctx.nativeWebSearchAllowedByToolPolicy,
9193
});
9294

9395
export function buildOpenAIResponsesProviderHooks(options?: {

src/agents/agent-tools.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -285,6 +285,9 @@ function applyModelProviderToolPolicy(
285285
config: params?.config,
286286
modelProvider: params?.modelProvider,
287287
modelApi: params?.modelApi,
288+
modelId: params?.modelId,
289+
agentId: params?.agentId,
290+
sessionKey: params?.sessionKey,
288291
agentDir: params?.agentDir,
289292
})
290293
) {

src/agents/codex-native-web-search-core.ts

Lines changed: 158 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,27 @@
44
*/
55
import type { OpenClawConfig } from "../config/types.openclaw.js";
66
import { isRecord } from "../utils.js";
7+
import {
8+
isToolAllowedByPolicies,
9+
resolveEffectiveToolPolicy,
10+
resolveGroupToolPolicy,
11+
resolveInheritedToolPolicyForSession,
12+
resolveSubagentToolPolicyForSession,
13+
} from "./agent-tools.policy.js";
714
import { externalCliDiscoveryForProviderAuth } from "./auth-profiles/external-cli-discovery.js";
815
import { listProfilesForProvider } from "./auth-profiles/profile-list.js";
916
import { ensureAuthProfileStore } from "./auth-profiles/store.js";
1017
import {
1118
type CodexNativeSearchMode,
1219
resolveCodexNativeWebSearchConfig,
1320
} from "./codex-native-web-search.shared.js";
21+
import type { SandboxToolPolicy } from "./sandbox.js";
22+
import { resolveSenderToolPolicy } from "./sender-tool-policy.js";
23+
import {
24+
isSubagentEnvelopeSession,
25+
resolveSubagentCapabilityStore,
26+
} from "./subagent-capabilities.js";
27+
import { mergeAlsoAllowPolicy, resolveToolProfilePolicy } from "./tool-policy.js";
1428

1529
type CodexNativeSearchActivation = {
1630
globalWebSearchEnabled: boolean;
@@ -23,13 +37,33 @@ type CodexNativeSearchActivation = {
2337
| "globally_disabled"
2438
| "codex_not_enabled"
2539
| "model_not_eligible"
26-
| "codex_auth_missing";
40+
| "codex_auth_missing"
41+
| "tool_policy_denied";
2742
};
2843

2944
type CodexNativeSearchPayloadPatchResult = {
3045
status: "payload_not_object" | "native_tool_already_present" | "injected";
3146
};
3247

48+
export type NativeWebSearchToolPolicyParams = {
49+
config?: OpenClawConfig;
50+
modelProvider?: string;
51+
modelId?: string;
52+
agentId?: string;
53+
sessionKey?: string;
54+
sandboxToolPolicy?: SandboxToolPolicy;
55+
messageProvider?: string;
56+
agentAccountId?: string | null;
57+
groupId?: string | null;
58+
groupChannel?: string | null;
59+
groupSpace?: string | null;
60+
spawnedBy?: string | null;
61+
senderId?: string | null;
62+
senderName?: string | null;
63+
senderUsername?: string | null;
64+
senderE164?: string | null;
65+
};
66+
3367
const OPENAI_AUTH_PROVIDER_IDS = ["openai"] as const;
3468

3569
function isOpenAIAuthProviderId(provider: string | undefined): boolean {
@@ -96,6 +130,20 @@ export function resolveCodexNativeSearchActivation(params: {
96130
config?: OpenClawConfig;
97131
modelProvider?: string;
98132
modelApi?: string;
133+
modelId?: string;
134+
agentId?: string;
135+
sessionKey?: string;
136+
sandboxToolPolicy?: SandboxToolPolicy;
137+
messageProvider?: string;
138+
agentAccountId?: string | null;
139+
groupId?: string | null;
140+
groupChannel?: string | null;
141+
groupSpace?: string | null;
142+
spawnedBy?: string | null;
143+
senderId?: string | null;
144+
senderName?: string | null;
145+
senderUsername?: string | null;
146+
senderE164?: string | null;
99147
agentDir?: string;
100148
}): CodexNativeSearchActivation {
101149
const globalWebSearchEnabled = params.config?.tools?.web?.search?.enabled !== false;
@@ -105,7 +153,6 @@ export function resolveCodexNativeSearchActivation(params: {
105153
params.modelApi !== "openai-chatgpt-responses" ||
106154
!isOpenAIAuthProviderId(params.modelProvider) ||
107155
hasAvailableCodexAuth(params);
108-
109156
if (!globalWebSearchEnabled) {
110157
return {
111158
globalWebSearchEnabled,
@@ -154,6 +201,18 @@ export function resolveCodexNativeSearchActivation(params: {
154201
};
155202
}
156203

204+
if (!isNativeWebSearchAllowedByToolPolicy(params)) {
205+
return {
206+
globalWebSearchEnabled,
207+
codexNativeEnabled: true,
208+
codexMode: codexConfig.mode,
209+
nativeEligible: true,
210+
hasRequiredAuth: true,
211+
state: "managed_only",
212+
inactiveReason: "tool_policy_denied",
213+
};
214+
}
215+
157216
return {
158217
globalWebSearchEnabled,
159218
codexNativeEnabled: true,
@@ -164,6 +223,89 @@ export function resolveCodexNativeSearchActivation(params: {
164223
};
165224
}
166225

226+
export function isNativeWebSearchAllowedByToolPolicy(
227+
params: NativeWebSearchToolPolicyParams,
228+
): boolean {
229+
const {
230+
agentId,
231+
globalPolicy,
232+
globalProviderPolicy,
233+
agentPolicy,
234+
agentProviderPolicy,
235+
profile,
236+
providerProfile,
237+
profileAlsoAllow,
238+
providerProfileAlsoAllow,
239+
} = resolveEffectiveToolPolicy({
240+
config: params.config,
241+
sessionKey: params.sessionKey,
242+
agentId: params.agentId,
243+
modelProvider: params.modelProvider,
244+
modelId: params.modelId,
245+
});
246+
const profilePolicy = mergeAlsoAllowPolicy(resolveToolProfilePolicy(profile), profileAlsoAllow);
247+
const providerProfilePolicy = mergeAlsoAllowPolicy(
248+
resolveToolProfilePolicy(providerProfile),
249+
providerProfileAlsoAllow,
250+
);
251+
const groupPolicy = resolveGroupToolPolicy({
252+
config: params.config,
253+
sessionKey: params.sessionKey,
254+
spawnedBy: params.spawnedBy,
255+
messageProvider: params.messageProvider,
256+
groupId: params.groupId,
257+
groupChannel: params.groupChannel,
258+
groupSpace: params.groupSpace,
259+
accountId: params.agentAccountId,
260+
senderId: params.senderId,
261+
senderName: params.senderName,
262+
senderUsername: params.senderUsername,
263+
senderE164: params.senderE164,
264+
});
265+
const senderPolicy = resolveSenderToolPolicy({
266+
config: params.config,
267+
agentId,
268+
messageProvider: params.messageProvider,
269+
senderId: params.senderId,
270+
senderName: params.senderName,
271+
senderUsername: params.senderUsername,
272+
senderE164: params.senderE164,
273+
});
274+
const subagentStore = resolveSubagentCapabilityStore(params.sessionKey, {
275+
cfg: params.config,
276+
});
277+
const subagentPolicy =
278+
params.sessionKey &&
279+
isSubagentEnvelopeSession(params.sessionKey, {
280+
cfg: params.config,
281+
store: subagentStore,
282+
})
283+
? resolveSubagentToolPolicyForSession(params.config, params.sessionKey, {
284+
store: subagentStore,
285+
})
286+
: undefined;
287+
const inheritedToolPolicy = resolveInheritedToolPolicyForSession(
288+
params.config,
289+
params.sessionKey,
290+
{
291+
store: subagentStore,
292+
},
293+
);
294+
return isToolAllowedByPolicies("web_search", [
295+
profilePolicy,
296+
providerProfilePolicy,
297+
globalPolicy,
298+
globalProviderPolicy,
299+
agentPolicy,
300+
agentProviderPolicy,
301+
groupPolicy,
302+
senderPolicy,
303+
params.sandboxToolPolicy,
304+
subagentPolicy,
305+
inheritedToolPolicy,
306+
]);
307+
}
308+
167309
/** Builds the OpenAI Responses `web_search` tool payload from config. */
168310
export function buildCodexNativeWebSearchTool(
169311
config: OpenClawConfig | undefined,
@@ -219,6 +361,20 @@ export function shouldSuppressManagedWebSearchTool(params: {
219361
config?: OpenClawConfig;
220362
modelProvider?: string;
221363
modelApi?: string;
364+
modelId?: string;
365+
agentId?: string;
366+
sessionKey?: string;
367+
sandboxToolPolicy?: SandboxToolPolicy;
368+
messageProvider?: string;
369+
agentAccountId?: string | null;
370+
groupId?: string | null;
371+
groupChannel?: string | null;
372+
groupSpace?: string | null;
373+
spawnedBy?: string | null;
374+
senderId?: string | null;
375+
senderName?: string | null;
376+
senderUsername?: string | null;
377+
senderE164?: string | null;
222378
agentDir?: string;
223379
}): boolean {
224380
return resolveCodexNativeSearchActivation(params).state === "native_active";

0 commit comments

Comments
 (0)