diff --git a/studio/backend/core/inference/tools.py b/studio/backend/core/inference/tools.py index fe6d800d44..f51b23ae08 100644 --- a/studio/backend/core/inference/tools.py +++ b/studio/backend/core/inference/tools.py @@ -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}$") diff --git a/studio/backend/routes/inference.py b/studio/backend/routes/inference.py index b386103415..952d2cfbbf 100644 --- a/studio/backend/routes/inference.py +++ b/studio/backend/routes/inference.py @@ -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: diff --git a/studio/backend/tests/test_sandbox_paths_note.py b/studio/backend/tests/test_sandbox_paths_note.py index 90345f82f3..b6d713298f 100644 --- a/studio/backend/tests/test_sandbox_paths_note.py +++ b/studio/backend/tests/test_sandbox_paths_note.py @@ -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