Skip to content

Commit 9fc4990

Browse files
committed
🤖 fix: prevent toolPolicy require bypassing presets
Update applyToolPolicy so "require" does not short-circuit later filters, allowing appended preset policies to disable a required tool (closing the sandbox escape path). Signed-off-by: Thomas Kosiewski <tk@coder.com> --- _Generated with `codex cli` • Model: `gpt-5.2` • Thinking: `xhigh`_ <!-- mux-attribution: model=gpt-5.2 thinking=xhigh --> Change-Id: I812a3f47b36dbdc0c006e09bc22c460a77dbff11
1 parent 943b4e6 commit 9fc4990

File tree

2 files changed

+56
-27
lines changed

2 files changed

+56
-27
lines changed

src/common/utils/tools/toolPolicy.test.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -202,6 +202,17 @@ describe("applyToolPolicy", () => {
202202
expect(result.file_edit_replace_lines).toBeUndefined();
203203
expect(result.file_edit_insert).toBeUndefined();
204204
});
205+
206+
test("preset policy cannot be overridden by caller require", () => {
207+
const callerPolicy: ToolPolicy = [{ regex_match: "bash", action: "require" }];
208+
const presetPolicy: ToolPolicy = [{ regex_match: ".*", action: "disable" }];
209+
210+
const merged: ToolPolicy = [...callerPolicy, ...presetPolicy];
211+
const result = applyToolPolicy(mockTools, merged);
212+
213+
expect(result.bash).toBeUndefined();
214+
expect(Object.keys(result)).toHaveLength(0);
215+
});
205216
});
206217

207218
describe("edge cases", () => {

src/common/utils/tools/toolPolicy.ts

Lines changed: 45 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -22,10 +22,11 @@ export type ToolPolicy = z.infer<typeof ToolPolicySchema>;
2222
* @returns Filtered tools based on policy
2323
*
2424
* Algorithm:
25-
* 1. Check if any tool is marked as "require"
26-
* 2. If a tool is required, disable all other tools (at most one can be required)
27-
* 3. Otherwise, start with default "allow" for all tools and apply filters in order
28-
* 4. Last matching filter wins
25+
* - Filters are applied in order, with default behavior "allow all".
26+
* - If any tool is marked as "require" (at most one tool may match across the whole policy),
27+
* only that tool remains eligible. Later filters may still disable it (resulting in no tools).
28+
* - Without a required tool, enable/disable filters apply to all tools, and the last matching
29+
* filter wins for each tool.
2930
*/
3031
export function applyToolPolicy(
3132
tools: Record<string, Tool>,
@@ -36,40 +37,57 @@ export function applyToolPolicy(
3637
return tools;
3738
}
3839

39-
// First pass: find any required tools
40-
const requiredTools = new Set<string>();
40+
const toolNames = Object.keys(tools);
41+
42+
// First pass: find a single required tool (if any), validating that the policy
43+
// never results in multiple required matches.
44+
let requiredTool: string | null = null;
4145
for (const filter of policy) {
42-
if (filter.action === "require") {
43-
const regex = new RegExp(`^${filter.regex_match}$`);
44-
for (const toolName of Object.keys(tools)) {
45-
if (regex.test(toolName)) {
46-
requiredTools.add(toolName);
47-
}
48-
}
46+
if (filter.action !== "require") continue;
47+
48+
const regex = new RegExp(`^${filter.regex_match}$`);
49+
const matches = toolNames.filter((toolName) => regex.test(toolName));
50+
51+
if (matches.length > 1) {
52+
throw new Error(
53+
`Tool policy error: Multiple tools marked as required (${matches.join(", ")}). At most one tool can be required.`
54+
);
4955
}
50-
}
56+
if (matches.length === 0) continue;
5157

52-
// Validate: at most one tool can be required
53-
if (requiredTools.size > 1) {
54-
throw new Error(
55-
`Tool policy error: Multiple tools marked as required (${Array.from(requiredTools).join(", ")}). At most one tool can be required.`
56-
);
58+
if (requiredTool && requiredTool !== matches[0]) {
59+
throw new Error(
60+
`Tool policy error: Multiple tools marked as required (${requiredTool}, ${matches[0]}). At most one tool can be required.`
61+
);
62+
}
63+
requiredTool = matches[0];
5764
}
5865

59-
// If a tool is required, return only that tool
60-
if (requiredTools.size === 1) {
61-
const requiredTool = Array.from(requiredTools)[0];
62-
return {
63-
[requiredTool]: tools[requiredTool],
64-
};
66+
// If a tool is required, only that tool remains eligible, but later filters may disable it.
67+
if (requiredTool) {
68+
let enabled = true; // Default allow
69+
70+
for (const filter of policy) {
71+
const regex = new RegExp(`^${filter.regex_match}$`);
72+
if (!regex.test(requiredTool)) continue;
73+
74+
if (filter.action === "disable") {
75+
enabled = false;
76+
continue;
77+
}
78+
// enable/require both imply enabled for the required tool at this point in the policy
79+
enabled = true;
80+
}
81+
82+
return enabled ? { [requiredTool]: tools[requiredTool] } : {};
6583
}
6684

6785
// No required tools: apply standard enable/disable logic
6886
// Build a map of tool name -> enabled status
6987
const toolStatus = new Map<string, boolean>();
7088

7189
// Initialize all tools as enabled (default allow)
72-
for (const toolName of Object.keys(tools)) {
90+
for (const toolName of toolNames) {
7391
toolStatus.set(toolName, true);
7492
}
7593

@@ -81,7 +99,7 @@ export function applyToolPolicy(
8199
const shouldEnable = filter.action === "enable";
82100

83101
// Apply filter to matching tools
84-
for (const toolName of Object.keys(tools)) {
102+
for (const toolName of toolNames) {
85103
if (regex.test(toolName)) {
86104
toolStatus.set(toolName, shouldEnable);
87105
}

0 commit comments

Comments
 (0)