Drop the network-restriction note in Bypass Permissions sessions

With bypass_permissions=true the tool loop passes disable_sandbox=true, so
_python_exec skips _check_code_safety and _bash_exec skips the curl/wget
blocklist (there is no network namespace; the allowlist is enforced only by that
AST host check and the bash blocklist). The static sandbox note and code nudge
then falsely tell the model curl/wget and arbitrary hosts are blocked, which can
stall a full-permissions fetch of a user-supplied remote resource.

Build the python/terminal descriptions and the code nudge per request: in bypass
mode drop the allowlist/curl-wget sentence and the internet-limited clause, while
sandboxed sessions keep the precise restriction. Gate strictly on
bypass_permissions; permission_mode=full only suppresses the confirm gate and
leaves the blocklist and allowlist enforced, so its wording must stay accurate.
The default note stays byte-identical (concatenated from the same text), and
apply_bypass_tool_notes swaps only the python/terminal descriptions without
mutating the shared tool globals. Add tests for both modes.
This commit is contained in:
danielhanchen 2026-07-19 15:11:12 +00:00
commit 9ddd563fd2
3 changed files with 161 additions and 7 deletions

View file

@ -2952,17 +2952,30 @@ WEB_SEARCH_TOOL = {
# Appended to the python/terminal descriptions: stop models writing to a
# nonexistent /mnt/data or cd/grep-ing a guessed local path for a repo the
# user only mentioned but never uploaded.
_SANDBOX_PATHS_NOTE = (
# Split so the Bypass Permissions variant can drop the network-restriction
# sentence: under bypass, _python_exec/_bash_exec skip the safety analysis and
# the curl/wget blocklist (network policy is enforced only by that AST host check
# and the bash blocklist -- there is no network namespace), so egress is not
# limited to the allowlist and curl/wget work. Keeping the sentence there would
# falsely tell a full-permissions session those downloads are unavailable and can
# block a user-supplied remote resource.
_SANDBOX_PATHS_NOTE_INTRO = (
" The working directory is an isolated scratch space that may already hold "
"files from earlier work in this conversation or project, plus anything you "
"create here; it persists across this conversation and, for a project, "
"across the project's threads. It is the default location for your work, not "
"a copy of the user's own computer, so do not assume files elsewhere on the "
"host are already here. Internet access is limited: the python tool can fetch "
"host are already here."
)
_SANDBOX_PATHS_NOTE_NETWORK = (
" Internet access is limited: the python tool can fetch "
"only from a fixed allowlist of public sites (such as github.com, "
"huggingface.co, and pypi.org), not the user's own machines, private hosts, "
"or arbitrary addresses, and the terminal blocks direct download commands "
"like curl and wget. Documents the user attaches to the chat are retrieved "
"like curl and wget."
)
_SANDBOX_PATHS_NOTE_TAIL = (
" Documents the user attaches to the chat are retrieved "
"separately and are not listed here. A repository, folder, or file the user "
"refers to is not present here unless you created it here or it is already "
"part of this project, so list the working directory to see what is "
@ -2972,6 +2985,13 @@ _SANDBOX_PATHS_NOTE = (
"you need are not here, ask the user to provide them or an exact path "
"instead of guessing one."
)
# Default (sandboxed) note: keeps the precise allowlist + curl/wget restriction.
_SANDBOX_PATHS_NOTE = (
_SANDBOX_PATHS_NOTE_INTRO + _SANDBOX_PATHS_NOTE_NETWORK + _SANDBOX_PATHS_NOTE_TAIL
)
# Bypass Permissions variant: same guidance without the network-restriction
# sentence that bypass removes (stays neutral rather than claiming egress works).
_SANDBOX_PATHS_NOTE_BYPASS = _SANDBOX_PATHS_NOTE_INTRO + _SANDBOX_PATHS_NOTE_TAIL
PYTHON_TOOL = {
"type": "function",
@ -3075,6 +3095,46 @@ ALL_TOOLS = [
]
def _with_sandbox_note(tool: dict, note: str) -> dict:
"""Shallow copy of a python/terminal tool spec with its sandbox-paths note
swapped for ``note`` (the default note is stripped first)."""
fn = dict(tool["function"])
base = fn["description"]
if base.endswith(_SANDBOX_PATHS_NOTE):
base = base[: -len(_SANDBOX_PATHS_NOTE)]
fn["description"] = base + note
return {**tool, "function": fn}
# Bypass Permissions variants: descriptions omit the allowlist/curl/wget
# restriction because that safety analysis and blocklist are skipped when the
# sandbox is disabled (disable_sandbox = bypass_permissions in the tool loops).
PYTHON_TOOL_BYPASS = _with_sandbox_note(PYTHON_TOOL, _SANDBOX_PATHS_NOTE_BYPASS)
TERMINAL_TOOL_BYPASS = _with_sandbox_note(TERMINAL_TOOL, _SANDBOX_PATHS_NOTE_BYPASS)
_BYPASS_TOOL_OVERRIDES = {
"python": PYTHON_TOOL_BYPASS,
"terminal": TERMINAL_TOOL_BYPASS,
}
def apply_bypass_tool_notes(tools: list[dict]) -> list[dict]:
"""Return ``tools`` with the python/terminal specs swapped for their Bypass
Permissions variants (only their descriptions differ). Call this for a request
whose execution disables the sandbox so the note matches what the tools
actually enforce; a no-op for tool lists without python/terminal."""
swapped = False
result: list[dict] = []
for tool in tools:
name = (tool.get("function") or {}).get("name") if isinstance(tool, dict) else None
override = _BYPASS_TOOL_OVERRIDES.get(name)
if override is not None:
result.append(override)
swapped = True
else:
result.append(tool)
return result if swapped else tools
# OpenAI's function.name regex; MCP names that violate it would 400 the whole
# request, so validate up front and skip with a warning.
_OPENAI_FN_NAME_RE = re.compile(r"^[a-zA-Z0-9_-]{1,64}$")

View file

@ -2413,6 +2413,20 @@ _TOOL_CODE_TIP = (
"you need is not present, ask the user to provide it or give an exact path "
"rather than running commands against a guessed one."
)
# Bypass Permissions variant: drops the "internet access is limited" clause,
# which is false when the sandbox is disabled (curl/wget and arbitrary-host
# requests work), while keeping the workdir-default framing.
_TOOL_CODE_TIP_BYPASS = (
"Use code execution for math, calculations, data processing, or to parse "
"and analyze information from tool results. It runs in a sandbox whose "
"working directory is an isolated scratch space that is the default "
"location for your work and may already hold files from earlier work; it "
"is not a copy of the user's computer, so do not assume a file, folder, or "
"repository the user mentions is already present. List the working "
"directory to see what is there; if what you need is not present, ask the "
"user to provide it or give an exact path rather than running commands "
"against a guessed one."
)
_TOOL_ARTIFACT_TIP = (
"For HTML, CSS, or JavaScript canvas requests, call render_html once when "
"it is available with one complete self-contained HTML document in the code "
@ -2422,7 +2436,9 @@ _TOOL_ARTIFACT_TIP = (
)
def _build_tool_action_nudge(*, tools: list[dict], model_name: str) -> str:
def _build_tool_action_nudge(
*, tools: list[dict], model_name: str, disable_sandbox: bool = False
) -> str:
tool_names = {
(tool.get("function") or {}).get("name")
for tool in tools
@ -2440,7 +2456,7 @@ def _build_tool_action_nudge(*, tools: list[dict], model_name: str) -> str:
if has_web:
tool_tip_parts.append(_TOOL_WEB_COMPACT_TIP if compact_web_tip else _TOOL_WEB_EXPANDED_TIP)
if has_code:
tool_tip_parts.append(_TOOL_CODE_TIP)
tool_tip_parts.append(_TOOL_CODE_TIP_BYPASS if disable_sandbox else _TOOL_CODE_TIP)
if has_artifact:
tool_tip_parts.append(_TOOL_ARTIFACT_TIP)
return (
@ -2470,7 +2486,11 @@ async def _select_request_tools(
retrieval scope, then enabled MCP tools appended. An empty result means the
caller should skip the tool loop, so a model-emitted built-in call can't
piggy-back on the empty allow-list."""
from core.inference.tools import ALL_TOOLS, get_enabled_mcp_tools
from core.inference.tools import (
ALL_TOOLS,
apply_bypass_tool_notes,
get_enabled_mcp_tools,
)
if not tools_on:
# MCP-only request: skip built-ins, leave room for MCP tools.
@ -2483,6 +2503,12 @@ async def _select_request_tools(
# Drop the RAG tool without a scope: nothing to search over.
if not payload.rag_scope:
tools = [t for t in tools if t["function"]["name"] != "search_knowledge_base"]
# Bypass Permissions disables the sandbox (disable_sandbox = bypass_permissions
# in the tool loops), so the python/terminal descriptions must not claim the
# allowlist/curl-wget block that no longer applies. permission_mode "full"
# alone does not disable the sandbox, so gate strictly on bypass_permissions.
if getattr(payload, "bypass_permissions", False):
tools = apply_bypass_tool_notes(tools)
if mcp_allowed:
tools = tools + await get_enabled_mcp_tools()
return tools
@ -7473,6 +7499,7 @@ async def openai_chat_completions(
_nudge = _build_tool_action_nudge(
tools = tools_to_use,
model_name = model_name,
disable_sandbox = bool(payload.bypass_permissions),
)
# Nudge the model to ground in attached documents instead of memory.
@ -8823,6 +8850,7 @@ async def openai_chat_completions(
_sf_nudge = _build_tool_action_nudge(
tools = _sf_tools_to_use,
model_name = model_name,
disable_sandbox = bool(payload.bypass_permissions),
)
# RAG nudge, mirroring the GGUF path.
@ -12540,7 +12568,7 @@ async def anthropic_messages(
err_type = "invalid_request_error",
),
)
from core.inference.tools import ALL_TOOLS
from core.inference.tools import ALL_TOOLS, apply_bypass_tool_notes
# ask/auto (and an omitted mode selecting a gate-needing terminal/python
# tool) were already rejected before the auto-switch above, so an invalid
@ -12551,11 +12579,16 @@ async def anthropic_messages(
requested_studio_tools,
payload.enabled_tools,
)
# Bypass Permissions disables the sandbox, so drop the allowlist/curl-wget
# restriction from the python/terminal descriptions here too.
if getattr(payload, "bypass_permissions", False):
openai_tools = apply_bypass_tool_notes(openai_tools)
# Build tool-use system prompt nudge (same logic as /chat/completions)
_nudge = _build_tool_action_nudge(
tools = openai_tools,
model_name = model_name,
disable_sandbox = bool(getattr(payload, "bypass_permissions", False)),
)
if _nudge:

View file

@ -24,9 +24,13 @@ if _BACKEND_DIR not in sys.path:
from core.inference.tools import (
PYTHON_TOOL,
PYTHON_TOOL_BYPASS,
TERMINAL_TOOL,
TERMINAL_TOOL_BYPASS,
_SANDBOX_PATHS_NOTE,
_SANDBOX_PATHS_NOTE_BYPASS,
_bash_exec,
apply_bypass_tool_notes,
)
@ -118,6 +122,63 @@ def test_blocked_network_command_message_does_not_recommend_chat_upload():
assert "working directory" in msg or "path the sandbox can read" in msg
def test_bypass_note_drops_the_curl_wget_allowlist_restriction():
# Under Bypass Permissions _python_exec/_bash_exec skip the safety analysis and
# the curl/wget blocklist, so egress is not limited to the allowlist and those
# downloads work. The bypass note must not tell the model they are blocked.
lowered = _SANDBOX_PATHS_NOTE_BYPASS.lower()
assert "curl" not in lowered and "wget" not in lowered
assert "allowlist" not in lowered
assert "internet access is limited" not in lowered
# It keeps the workdir-default framing and the "not a copy of the host" guard.
assert "default location for your work" in lowered
assert "do not assume files elsewhere on the host are already here" in lowered
# The bypass note is a strict prefix+suffix of the default note (only the
# network sentence is removed), so the rest of the guidance is unchanged.
assert _SANDBOX_PATHS_NOTE_BYPASS != _SANDBOX_PATHS_NOTE
assert "curl" in _SANDBOX_PATHS_NOTE.lower()
def test_bypass_tool_variants_use_the_bypass_note():
assert PYTHON_TOOL_BYPASS["function"]["description"].endswith(_SANDBOX_PATHS_NOTE_BYPASS)
assert TERMINAL_TOOL_BYPASS["function"]["description"].endswith(_SANDBOX_PATHS_NOTE_BYPASS)
# Same tool names/parameters as the default variants; only the note differs.
assert PYTHON_TOOL_BYPASS["function"]["name"] == PYTHON_TOOL["function"]["name"]
assert TERMINAL_TOOL_BYPASS["function"]["name"] == TERMINAL_TOOL["function"]["name"]
assert PYTHON_TOOL_BYPASS["function"]["parameters"] == PYTHON_TOOL["function"]["parameters"]
def test_apply_bypass_tool_notes_swaps_only_python_and_terminal():
tools = [
{"function": {"name": "web_search", "description": "search"}},
PYTHON_TOOL,
TERMINAL_TOOL,
]
swapped = apply_bypass_tool_notes(tools)
by_name = {t["function"]["name"]: t for t in swapped}
assert "curl" not in by_name["python"]["function"]["description"].lower()
assert "curl" not in by_name["terminal"]["function"]["description"].lower()
# Unrelated tools pass through unchanged (same object).
assert by_name["web_search"] is tools[0]
# The shared module globals are not mutated by the swap.
assert "curl" in PYTHON_TOOL["function"]["description"].lower()
# A tool list without python/terminal is returned unchanged (same object).
plain = [{"function": {"name": "web_search", "description": "search"}}]
assert apply_bypass_tool_notes(plain) is plain
def test_bypass_code_execution_nudge_drops_the_limited_internet_claim():
from routes.inference import _TOOL_CODE_TIP, _TOOL_CODE_TIP_BYPASS
lowered = _TOOL_CODE_TIP_BYPASS.lower()
assert "internet access is limited" not in lowered
# Keeps the workdir-default framing and the exact-path guidance.
assert "default" in lowered and "location for your work" in lowered
assert "give an exact path" in lowered
# The default nudge still carries the restriction for sandboxed sessions.
assert "internet access is limited" in _TOOL_CODE_TIP.lower()
def test_code_execution_nudge_does_not_deny_local_file_access():
# On this no-Landlock branch the child runs on the host with only cwd set, so an
# exact local path the user supplies is readable. The code-execution nudge must