fix(tools): close execute_code sandbox allow-list fail-open - #41297
fix(tools): close execute_code sandbox allow-list fail-open#41297petrichor-op wants to merge 1 commit into
Conversation
## What does this PR do? The execute_code sandbox computes the set of Hermes tools its child process may reach over RPC as the intersection of SANDBOX_ALLOWED_TOOLS with the session's enabled tools. Both execution paths then applied the fallback `if not sandbox_tools: sandbox_tools = SANDBOX_ALLOWED_TOOLS`, which conflated two distinct cases: the caller passing `enabled_tools=None` (the legacy "allow everything" contract) and the caller passing a non-None list whose intersection with the sandbox allow-list happens to be empty. The second case is a real, reachable configuration: a session that enables execute_code but deliberately disables terminal, file, and web tools. In that situation the intersection is empty, so the fallback re-granted the full set (terminal, write_file, patch, read_file, search_files, web_search, web_extract) to the sandbox. The schema layer in model_tools.py already reports zero sandbox tools to the model in this case, so the runtime silently disagreed with the advertised contract: a script could simply import and call terminal() despite the session forbidding it, defeating the enabled_tools guard that exists specifically so a restricted or subagent session cannot widen its own tool set. This change makes `enabled_tools is None` the only path that falls back to the full allow-list. Any explicit list, including an empty or disjoint one, is treated as authoritative and the sandbox receives only the intersection. ## Related Issue N/A ## Type of Change - [x] 🐛 Bug fix (non-breaking change that fixes an issue) - [x] 🔒 Security fix ## Changes Made - `tools/code_execution_tool.py`: replace the fail-open `if not sandbox_tools` fallback in both `_execute_remote` and the local path of `execute_code` with an explicit `enabled_tools is None` check, so a specified-but-disjoint (or empty) allow-list yields no sandbox tools instead of all of them. - `tests/tools/test_code_execution_modes.py`: add `test_disjoint_enabled_tools_grants_no_sandbox_tools` and `test_empty_enabled_tools_grants_no_sandbox_tools` to the cross-mode security-invariant suite; extend its `_run` helper to accept an explicit `enabled_tools` argument. - `tests/tools/test_code_execution.py`: update the two edge-case tests that asserted the old fail-open behavior to assert the corrected fail-closed semantics (empty and non-overlapping lists expose no sandbox tools; `enabled_tools=None` still grants all). ## How to Test 1. Reproduce the regression by reverting the `enabled_tools is None` guard back to `if not sandbox_tools: sandbox_tools = SANDBOX_ALLOWED_TOOLS`; the new tests fail with `terminal: True`, demonstrating that a restricted session still receives terminal access. 2. Restore the fix and run the targeted suites: `scripts/run_tests.sh tests/tools/test_code_execution.py tests/tools/test_code_execution_modes.py -q` 3. Confirm that `execute_code` with `enabled_tools=["execute_code", "todo"]` (or `[]`) exposes none of the sandbox tools, while `enabled_tools=None` continues to expose the full allow-list. ## Checklist ### Code - [x] I've read the Contributing Guide - [x] My commit messages follow Conventional Commits (`fix(scope):`, `feat(scope):`, etc.) - [x] I searched for existing PRs to make sure this isn't a duplicate - [x] My PR contains **only** changes related to this fix/feature (no unrelated commits) - [x] I've run `pytest tests/ -q` and all tests pass - [x] I've added tests for my changes (required for bug fixes, strongly encouraged for features) - [x] I've tested on my platform: macOS 15 (Darwin 25.5.0) ### Documentation & Housekeeping - [x] I've updated relevant documentation (README, `docs/`, docstrings) — or N/A - [x] I've updated `cli-config.yaml.example` if I added/changed config keys — or N/A - [x] I've updated `CONTRIBUTING.md` or `AGENTS.md` if I changed architecture or workflows — or N/A - [x] I've considered cross-platform impact (Windows, macOS) per the compatibility guide — or N/A - [x] I've updated tool descriptions/schemas if I changed tool behavior — or N/A ## Screenshots / Logs No UI changes. The corrected behavior is captured by the regression tests described above.
✅ Verification — clean security fix (fail-open sandbox allow-list)The fix correctly distinguishes Code change: Both Test coverage:
The schema layer already advertises zero sandbox tools to the model when the list is disjoint, so this brings the runtime enforcement in line with what the model was told it has. Clean fix — no issues found. |
|
Thanks for the focused authorization-boundary fix. Current main still restores the full sandbox allow-list after an empty intersection in both Suggested changes
Automated hermes-sweeper review. |
Interlock with #82243#82243 now centralizes local and remote sandbox selection in This PR remains the canonical fail-closed implementation, although its branch now conflicts with the newer helper. Please carry the semantic fix into #82243 rather than treating the new helper as resolving this report. The #47494/#6614 maintainer objection still needs an explicit policy decision: either |
What does this PR do?
The execute_code sandbox computes the set of Hermes tools its child
process may reach over RPC as the intersection of SANDBOX_ALLOWED_TOOLS
with the session's enabled tools. Both execution paths then applied the
fallback
if not sandbox_tools: sandbox_tools = SANDBOX_ALLOWED_TOOLS,which conflated two distinct cases: the caller passing
enabled_tools=None(the legacy "allow everything" contract) and the caller passing a non-None
list whose intersection with the sandbox allow-list happens to be empty.
The second case is a real, reachable configuration: a session that enables
execute_code but deliberately disables terminal, file, and web tools. In
that situation the intersection is empty, so the fallback re-granted the
full set (terminal, write_file, patch, read_file, search_files, web_search,
web_extract) to the sandbox. The schema layer in model_tools.py already
reports zero sandbox tools to the model in this case, so the runtime
silently disagreed with the advertised contract: a script could simply
import and call terminal() despite the session forbidding it, defeating the
enabled_tools guard that exists specifically so a restricted or subagent
session cannot widen its own tool set.
This change makes
enabled_tools is Nonethe only path that falls back tothe full allow-list. Any explicit list, including an empty or disjoint one,
is treated as authoritative and the sandbox receives only the intersection.
Related Issue
N/A
Type of Change
Changes Made
tools/code_execution_tool.py: replace the fail-openif not sandbox_toolsfallback in both_execute_remoteand the local path ofexecute_codewith an explicitenabled_tools is Nonecheck, so a specified-but-disjoint (or empty) allow-list yields no sandbox tools instead of all of them.tests/tools/test_code_execution_modes.py: addtest_disjoint_enabled_tools_grants_no_sandbox_toolsandtest_empty_enabled_tools_grants_no_sandbox_toolsto the cross-mode security-invariant suite; extend its_runhelper to accept an explicitenabled_toolsargument.tests/tools/test_code_execution.py: update the two edge-case tests that asserted the old fail-open behavior to assert the corrected fail-closed semantics (empty and non-overlapping lists expose no sandbox tools;enabled_tools=Nonestill grants all).How to Test
enabled_tools is Noneguard back toif not sandbox_tools: sandbox_tools = SANDBOX_ALLOWED_TOOLS; the new tests fail withterminal: True, demonstrating that a restricted session still receives terminal access.scripts/run_tests.sh tests/tools/test_code_execution.py tests/tools/test_code_execution_modes.py -qexecute_codewithenabled_tools=["execute_code", "todo"](or[]) exposes none of the sandbox tools, whileenabled_tools=Nonecontinues to expose the full allow-list.Checklist
Code
fix(scope):,feat(scope):, etc.)pytest tests/ -qand all tests passDocumentation & Housekeeping
docs/, docstrings) — or N/Acli-config.yaml.exampleif I added/changed config keys — or N/ACONTRIBUTING.mdorAGENTS.mdif I changed architecture or workflows — or N/AScreenshots / Logs
No UI changes. The corrected behavior is captured by the regression tests described above.