From 61523b73f159b169edef114bef0683039d0244a6 Mon Sep 17 00:00:00 2001 From: danielhanchen Date: Sun, 19 Jul 2026 10:38:21 +0000 Subject: [PATCH 01/20] Studio: tell the model its code sandbox is isolated and cannot reach remote files With code tools enabled the model would guess a local path or try ssh to reach files the user said were on another machine. The sandbox notes now state that the working directory is isolated scratch that starts empty and that the sandbox cannot reach other machines or remote hosts, so the model asks the user to upload the files instead of running commands against a made-up path. A blocked network command now returns the same guidance. Normal tool use on uploaded or sandbox-created content is unchanged. --- studio/backend/core/inference/tools.py | 35 +++++++++++++++++++++----- studio/backend/routes/inference.py | 7 +++++- 2 files changed, 35 insertions(+), 7 deletions(-) diff --git a/studio/backend/core/inference/tools.py b/studio/backend/core/inference/tools.py index bc9ffe85c2..efcd712bc8 100644 --- a/studio/backend/core/inference/tools.py +++ b/studio/backend/core/inference/tools.py @@ -143,6 +143,12 @@ _BLOCKED_COMMANDS = ( else _BLOCKED_COMMANDS_COMMON ) +# Blocked commands that reach the network / another machine; hitting one means +# the model wants files the sandbox cannot reach, so we steer it to ask for an upload. +_NETWORK_BLOCKED_COMMANDS = frozenset( + {"curl", "wget", "nc", "ncat", "netcat", "socat", "ssh", "scp", "sftp", "rsync"} +) + _SHELL_SEPARATORS = frozenset({";", "&&", "||", "|", "&", "\n", "(", ")", "`", "{", "}"}) # Bash keywords starting a new command position (then $cmd, do $cmd, etc.). @@ -2943,12 +2949,20 @@ WEB_SEARCH_TOOL = { }, } -# Appended to the python/terminal descriptions: models habitually write to -# /mnt/data (a ChatGPT code-interpreter path), which does not exist here. +# 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 = ( - " Read and write files using relative paths in the current working " - "directory, which persists for this conversation; absolute paths like " - "/mnt/data or /tmp/outputs do not exist." + " The working directory is an isolated scratch space that starts empty " + "except for files created here or explicitly uploaded to this " + "conversation, and it persists only for this conversation. This sandbox " + "cannot reach other machines or remote hosts. A repository, folder, or " + "file the user refers to is not present here unless it was uploaded or " + "created in the sandbox, so do not assume a mentioned path exists or guess " + "where it lives. Read and write files using relative paths in the working " + "directory; absolute paths like /mnt/data or /tmp/outputs do not exist. If " + "the files you need are not here, ask the user to upload them or provide an " + "exact path instead of guessing one." ) PYTHON_TOOL = { @@ -5828,7 +5842,16 @@ def _bash_exec( if not disable_sandbox: blocked = _find_blocked_commands(command) if blocked: - return f"Blocked command(s) for safety: {', '.join(sorted(blocked))}" + base = f"Blocked command(s) for safety: {', '.join(sorted(blocked))}." + if blocked & _NETWORK_BLOCKED_COMMANDS: + return ( + base + " This sandbox cannot reach other machines or remote " + "hosts over the network, so files that live on another " + "machine are not accessible from here. Do not retry with " + "other remote-access commands; ask the user to upload the " + "files they want you to work with." + ) + return base elif not _harden_parent_against_proc_env_leak(): # Close the /proc//environ secret-recovery path first; if it # cannot be applied, fail closed rather than leak the parent environ. diff --git a/studio/backend/routes/inference.py b/studio/backend/routes/inference.py index 3d527bf317..da5b2fa218 100644 --- a/studio/backend/routes/inference.py +++ b/studio/backend/routes/inference.py @@ -2404,7 +2404,12 @@ _TOOL_WEB_EXPANDED_TIP = ( ) _TOOL_CODE_TIP = ( "Use code execution for math, calculations, data processing, or to parse " - "and analyze information from tool results." + "and analyze information from tool results. The python and terminal tools " + "run in a sandbox whose working directory is an isolated scratch space and " + "that cannot reach other machines or remote hosts, so do not assume a file, " + "folder, or repository the user mentions is already present. If it has not " + "been uploaded to this conversation, ask the user to upload 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 " From 57a73f34d585ff089b5f1ba2cd0a29a9374ec01c Mon Sep 17 00:00:00 2001 From: danielhanchen Date: Sun, 19 Jul 2026 11:26:20 +0000 Subject: [PATCH 02/20] Studio: correct the sandbox notes to match actual network and persistence The sandbox note claimed the working directory starts empty, persists only for the conversation, and cannot reach any remote host. None of that holds: a project session reuses a shared sandbox that persists across its threads and may already contain files, and the code tools can reach an allowlist of public sites (github.com, huggingface.co, pypi.org). Reword the python/terminal note and the code-tool tip so the model lists the working directory and knows it can fetch from the allowlist, while still not assuming a mentioned path exists. --- studio/backend/core/inference/tools.py | 23 +++++---- studio/backend/routes/inference.py | 12 +++-- .../backend/tests/test_sandbox_paths_note.py | 50 +++++++++++++++++++ 3 files changed, 70 insertions(+), 15 deletions(-) create mode 100644 studio/backend/tests/test_sandbox_paths_note.py diff --git a/studio/backend/core/inference/tools.py b/studio/backend/core/inference/tools.py index efcd712bc8..57da12cc13 100644 --- a/studio/backend/core/inference/tools.py +++ b/studio/backend/core/inference/tools.py @@ -2953,16 +2953,19 @@ WEB_SEARCH_TOOL = { # nonexistent /mnt/data or cd/grep-ing a guessed local path for a repo the # user only mentioned but never uploaded. _SANDBOX_PATHS_NOTE = ( - " The working directory is an isolated scratch space that starts empty " - "except for files created here or explicitly uploaded to this " - "conversation, and it persists only for this conversation. This sandbox " - "cannot reach other machines or remote hosts. A repository, folder, or " - "file the user refers to is not present here unless it was uploaded or " - "created in the sandbox, so do not assume a mentioned path exists or guess " - "where it lives. Read and write files using relative paths in the working " - "directory; absolute paths like /mnt/data or /tmp/outputs do not exist. If " - "the files you need are not here, ask the user to upload them or provide an " - "exact path instead of guessing one." + " 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 or that is uploaded; it persists across this conversation and, " + "for a project, across the project's threads. It can reach only 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. A repository, folder, or file the user refers to is not present " + "here unless it was uploaded, created here, or already part of this project, " + "so list the working directory to see what is available rather than assuming " + "a mentioned path exists or guessing where it lives. Read and write files " + "using relative paths in the working directory; absolute paths like " + "/mnt/data or /tmp/outputs do not exist. If the files you need are not here, " + "ask the user to upload them or provide an exact path instead of guessing one." ) PYTHON_TOOL = { diff --git a/studio/backend/routes/inference.py b/studio/backend/routes/inference.py index da5b2fa218..1aaf20eeaf 100644 --- a/studio/backend/routes/inference.py +++ b/studio/backend/routes/inference.py @@ -2405,11 +2405,13 @@ _TOOL_WEB_EXPANDED_TIP = ( _TOOL_CODE_TIP = ( "Use code execution for math, calculations, data processing, or to parse " "and analyze information from tool results. The python and terminal tools " - "run in a sandbox whose working directory is an isolated scratch space and " - "that cannot reach other machines or remote hosts, so do not assume a file, " - "folder, or repository the user mentions is already present. If it has not " - "been uploaded to this conversation, ask the user to upload it or give an " - "exact path rather than running commands against a guessed one." + "run in a sandbox whose working directory is an isolated scratch space that " + "may already hold files from earlier work and can reach only a fixed " + "allowlist of public sites, not the user's own machines or private hosts, 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 upload 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 " diff --git a/studio/backend/tests/test_sandbox_paths_note.py b/studio/backend/tests/test_sandbox_paths_note.py new file mode 100644 index 0000000000..40e31e184e --- /dev/null +++ b/studio/backend/tests/test_sandbox_paths_note.py @@ -0,0 +1,50 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +"""Accuracy guards for the sandbox-paths note appended to the python/terminal +tool descriptions (#7242). + +The note must not misdirect the model with claims that are false for the real +sandbox: (1) the sandbox is not network-isolated -- the python tool can reach an +allowlist of public hosts (github.com / huggingface.co / pypi.org), so it must +not claim it "cannot reach ... remote hosts" at all; (2) a project sandbox is a +shared per-project directory, so a new thread can inherit files from prior +threads -- the note must not claim the workdir "starts empty" or "persists only +for this conversation". +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +_BACKEND_DIR = str(Path(__file__).resolve().parent.parent) +if _BACKEND_DIR not in sys.path: + sys.path.insert(0, _BACKEND_DIR) + +from core.inference.tools import PYTHON_TOOL, TERMINAL_TOOL, _SANDBOX_PATHS_NOTE + + +def test_note_does_not_claim_full_network_isolation(): + lowered = _SANDBOX_PATHS_NOTE.lower() + # The old absolute claim is false for the python tool (allowlisted egress). + assert "cannot reach other machines or remote hosts" not in lowered + # It should instead scope the block to private/arbitrary hosts and name the + # allowlist so the model still fetches valid public URLs. + assert "allowlist" in lowered + assert "github.com" in lowered and "huggingface.co" in lowered + assert "arbitrary" in lowered or "private" in lowered + + +def test_note_does_not_claim_project_sandbox_starts_empty(): + lowered = _SANDBOX_PATHS_NOTE.lower() + # Project sandboxes are shared across threads, so these are inaccurate. + assert "starts empty" not in lowered + assert "persists only for this conversation" not in lowered + # It should acknowledge that project files may already be present. + assert "project" in lowered + + +def test_note_is_appended_to_both_tool_descriptions(): + assert PYTHON_TOOL["function"]["description"].endswith(_SANDBOX_PATHS_NOTE) + assert TERMINAL_TOOL["function"]["description"].endswith(_SANDBOX_PATHS_NOTE) From c2903da6a5df35d288af8cbebbd5055f6fb1d753 Mon Sep 17 00:00:00 2001 From: danielhanchen Date: Sun, 19 Jul 2026 11:39:02 +0000 Subject: [PATCH 03/20] Studio: clarify that shell network commands are blocked, not allowlisted The reworded note said the tools can reach an allowlist of public sites, but that egress is only for code execution; the terminal tool's network commands are blocked. Distinguish the two so the note is accurate for both tools. --- studio/backend/core/inference/tools.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/studio/backend/core/inference/tools.py b/studio/backend/core/inference/tools.py index 57da12cc13..cfb2fd1a6c 100644 --- a/studio/backend/core/inference/tools.py +++ b/studio/backend/core/inference/tools.py @@ -2956,10 +2956,11 @@ _SANDBOX_PATHS_NOTE = ( " 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 or that is uploaded; it persists across this conversation and, " - "for a project, across the project's threads. It can reach only 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. A repository, folder, or file the user refers to is not present " + "for a project, across the project's threads. Code execution can fetch from a " + "fixed allowlist of public sites (such as github.com, huggingface.co, and " + "pypi.org); shell network commands are blocked, and neither can reach the " + "user's own machines, private hosts, or arbitrary addresses. A repository, " + "folder, or file the user refers to is not present " "here unless it was uploaded, created here, or already part of this project, " "so list the working directory to see what is available rather than assuming " "a mentioned path exists or guessing where it lives. Read and write files " From 04434151f9008531d18ae3e781a85ac11dc6440f Mon Sep 17 00:00:00 2001 From: danielhanchen Date: Sun, 19 Jul 2026 12:54:44 +0000 Subject: [PATCH 04/20] Make the sandbox prompt match what the tools actually enforce The terminal does not enforce a host allowlist (git and pip still reach the network), user attachments go to the RAG store rather than the sandbox workdir, and the code nudge named a possibly-disabled sibling tool. Reword the sandbox note to attribute the public allowlist to the python tool and the curl/wget block to the terminal, separate attached documents from workdir contents, make the code tip tool-neutral, and point the blocked-network message at the python egress for public hosts. --- studio/backend/core/inference/tools.py | 38 ++++++++++--------- studio/backend/routes/inference.py | 15 ++++---- .../backend/tests/test_anthropic_messages.py | 19 ++++++++++ .../backend/tests/test_sandbox_paths_note.py | 38 ++++++++++++++++++- 4 files changed, 84 insertions(+), 26 deletions(-) diff --git a/studio/backend/core/inference/tools.py b/studio/backend/core/inference/tools.py index cfb2fd1a6c..ddf8d450e9 100644 --- a/studio/backend/core/inference/tools.py +++ b/studio/backend/core/inference/tools.py @@ -2955,18 +2955,21 @@ WEB_SEARCH_TOOL = { _SANDBOX_PATHS_NOTE = ( " 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 or that is uploaded; it persists across this conversation and, " - "for a project, across the project's threads. Code execution can fetch from a " - "fixed allowlist of public sites (such as github.com, huggingface.co, and " - "pypi.org); shell network commands are blocked, and neither can reach the " - "user's own machines, private hosts, or arbitrary addresses. A repository, " - "folder, or file the user refers to is not present " - "here unless it was uploaded, created here, or already part of this project, " - "so list the working directory to see what is available rather than assuming " - "a mentioned path exists or guessing where it lives. Read and write files " - "using relative paths in the working directory; absolute paths like " - "/mnt/data or /tmp/outputs do not exist. If the files you need are not here, " - "ask the user to upload them or provide an exact path instead of guessing one." + "create here; it persists across this conversation and, for a project, " + "across the project's threads. It cannot see the user's own computer or the " + "files stored on it. 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 " + "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 " + "available rather than assuming a mentioned path exists or guessing where it " + "lives. Read and write files using relative paths in the working directory; " + "absolute paths like /mnt/data or /tmp/outputs do not exist. If the files " + "you need are not here, ask the user to provide them or an exact path " + "instead of guessing one." ) PYTHON_TOOL = { @@ -5849,11 +5852,12 @@ def _bash_exec( base = f"Blocked command(s) for safety: {', '.join(sorted(blocked))}." if blocked & _NETWORK_BLOCKED_COMMANDS: return ( - base + " This sandbox cannot reach other machines or remote " - "hosts over the network, so files that live on another " - "machine are not accessible from here. Do not retry with " - "other remote-access commands; ask the user to upload the " - "files they want you to work with." + base + " Direct network commands are blocked in the " + "terminal. Public files on allowlisted sites (such as " + "github.com, huggingface.co, or pypi.org) can be downloaded " + "from Python code instead; the user's own machine and other " + "private or arbitrary hosts are not reachable, so for those " + "ask the user to upload the files they want you to work with." ) return base elif not _harden_parent_against_proc_env_leak(): diff --git a/studio/backend/routes/inference.py b/studio/backend/routes/inference.py index 1aaf20eeaf..6a8385bfe9 100644 --- a/studio/backend/routes/inference.py +++ b/studio/backend/routes/inference.py @@ -2404,14 +2404,13 @@ _TOOL_WEB_EXPANDED_TIP = ( ) _TOOL_CODE_TIP = ( "Use code execution for math, calculations, data processing, or to parse " - "and analyze information from tool results. The python and terminal tools " - "run in a sandbox whose working directory is an isolated scratch space that " - "may already hold files from earlier work and can reach only a fixed " - "allowlist of public sites, not the user's own machines or private hosts, 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 upload it or give an exact path rather than " - "running commands against a guessed one." + "and analyze information from tool results. It runs in a sandbox whose " + "working directory is an isolated scratch space that may already hold files " + "from earlier work; it cannot access the user's own computer, and its " + "internet access is limited, 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 " diff --git a/studio/backend/tests/test_anthropic_messages.py b/studio/backend/tests/test_anthropic_messages.py index 9ccc3f44dd..f53dbbd7fb 100644 --- a/studio/backend/tests/test_anthropic_messages.py +++ b/studio/backend/tests/test_anthropic_messages.py @@ -173,6 +173,25 @@ class TestToolActionNudge: def test_balanced_nudge_empty_without_known_tool_categories(self): assert _build_tool_action_nudge(tools = [], model_name = "Llama-3.1-8B-Instruct") == "" + def test_code_tip_does_not_name_a_disabled_sibling_code_tool(self): + # has_code fires when either python or terminal is present, so the code tip + # must stay tool-neutral: a request that enabled only one must never name + # the other as an available tool (the missing-tool hallucination the + # render_html gate already avoids). + py_only = _build_tool_action_nudge( + tools = [{"type": "function", "function": {"name": "python"}}], + model_name = "Llama-3.1-8B-Instruct", + ) + assert "Use code execution for math" in py_only + assert "terminal" not in py_only.lower() + + term_only = _build_tool_action_nudge( + tools = [{"type": "function", "function": {"name": "terminal"}}], + model_name = "Llama-3.1-8B-Instruct", + ) + assert "Use code execution for math" in term_only + assert "python" not in term_only.lower() + # ===================================================================== # Pydantic model tests diff --git a/studio/backend/tests/test_sandbox_paths_note.py b/studio/backend/tests/test_sandbox_paths_note.py index 40e31e184e..0026dfae40 100644 --- a/studio/backend/tests/test_sandbox_paths_note.py +++ b/studio/backend/tests/test_sandbox_paths_note.py @@ -22,7 +22,12 @@ _BACKEND_DIR = str(Path(__file__).resolve().parent.parent) if _BACKEND_DIR not in sys.path: sys.path.insert(0, _BACKEND_DIR) -from core.inference.tools import PYTHON_TOOL, TERMINAL_TOOL, _SANDBOX_PATHS_NOTE +from core.inference.tools import ( + PYTHON_TOOL, + TERMINAL_TOOL, + _SANDBOX_PATHS_NOTE, + _bash_exec, +) def test_note_does_not_claim_full_network_isolation(): @@ -48,3 +53,34 @@ def test_note_does_not_claim_project_sandbox_starts_empty(): def test_note_is_appended_to_both_tool_descriptions(): assert PYTHON_TOOL["function"]["description"].endswith(_SANDBOX_PATHS_NOTE) assert TERMINAL_TOOL["function"]["description"].endswith(_SANDBOX_PATHS_NOTE) + + +def test_note_scopes_network_block_to_the_terminal_not_all_shell_commands(): + # The terminal leaves the network namespace intact and does not block git/pip, + # so the note must not claim shell network is fully blocked; it names the + # commands that are blocked (curl / wget) and attributes the host allowlist to + # the python tool. + lowered = _SANDBOX_PATHS_NOTE.lower() + assert "shell network commands are blocked" not in lowered + assert "curl" in lowered and "wget" in lowered + assert "python tool can fetch" in lowered + + +def test_note_distinguishes_attachments_from_sandbox_uploads(): + # Chat/project document uploads land in the RAG store, not the sandbox workdir, + # so the note must not tell the model that uploaded files appear via `ls`. + lowered = _SANDBOX_PATHS_NOTE.lower() + assert "was uploaded" not in lowered + assert "or that is uploaded" not in lowered + assert "retrieved separately" in lowered + assert "attach" in lowered + + +def test_blocked_network_command_message_points_to_python_egress(): + # A blocked curl/wget must not tell the model the whole sandbox is offline: the + # python tool can still fetch allowlisted public hosts. + msg = _bash_exec("curl https://raw.githubusercontent.com/foo/bar/main/x.py").lower() + assert "blocked command" in msg + assert "cannot reach other machines or remote hosts" not in msg + assert "python" in msg + assert "github.com" in msg From 9471bc12d34436b19f7c4457a597308229001aa7 Mon Sep 17 00:00:00 2001 From: danielhanchen Date: Sun, 19 Jul 2026 14:15:33 +0000 Subject: [PATCH 05/20] Do not claim the sandbox cannot see local files On a locally hosted Studio the tool child runs on the host with no filesystem isolation on this branch (cat is auto-safe and reads an exact path), so the note must not claim it cannot see the user's own computer. Reword to frame the working directory as the default work location and to say not to assume files elsewhere on the host are already present. --- studio/backend/core/inference/tools.py | 5 +++-- studio/backend/tests/test_sandbox_paths_note.py | 11 +++++++++++ 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/studio/backend/core/inference/tools.py b/studio/backend/core/inference/tools.py index ddf8d450e9..f75ea66c04 100644 --- a/studio/backend/core/inference/tools.py +++ b/studio/backend/core/inference/tools.py @@ -2956,8 +2956,9 @@ _SANDBOX_PATHS_NOTE = ( " 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 cannot see the user's own computer or the " - "files stored on it. Internet access is limited: the python tool can fetch " + "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 " "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 " diff --git a/studio/backend/tests/test_sandbox_paths_note.py b/studio/backend/tests/test_sandbox_paths_note.py index 0026dfae40..14943c7b25 100644 --- a/studio/backend/tests/test_sandbox_paths_note.py +++ b/studio/backend/tests/test_sandbox_paths_note.py @@ -50,6 +50,17 @@ def test_note_does_not_claim_project_sandbox_starts_empty(): assert "project" in lowered +def test_note_does_not_claim_local_files_are_inaccessible(): + lowered = _SANDBOX_PATHS_NOTE.lower() + # On a locally hosted Studio the child runs on the host with no filesystem + # isolation on this branch (Landlock is a separate change), and cat is an + # auto-safe terminal command, so an exact local path is readable; the note + # must not claim otherwise. It should frame the workdir as the default work + # location instead. + assert "cannot see the user's own computer" not in lowered + assert "default location for your work" in lowered + + def test_note_is_appended_to_both_tool_descriptions(): assert PYTHON_TOOL["function"]["description"].endswith(_SANDBOX_PATHS_NOTE) assert TERMINAL_TOOL["function"]["description"].endswith(_SANDBOX_PATHS_NOTE) From adf2a468bd555153f199573cf73f8e54ef417597 Mon Sep 17 00:00:00 2001 From: danielhanchen Date: Sun, 19 Jul 2026 14:43:43 +0000 Subject: [PATCH 06/20] Correct sandbox tool-note wording that overstated filesystem and network isolation On this branch the code tools run on the host with only cwd set (no Landlock filesystem confinement, no network namespace), so parts of the sandbox note misdirected the model: - The code-execution tip claimed the sandbox cannot access the user's own computer, but an exact local path the user supplies is readable, so the model would wrongly refuse or request an upload. Reword to frame the working directory as the default location for your work rather than asserting the user's files are inaccessible, while still steering to an exact path. - The blocked-network-command message asserted private and arbitrary hosts are unreachable, but the block is by command name only and git/pip keep the network namespace, so a git clone of a private host still works. Scope the claim to the blocked download commands. - The same message told the user to upload the files to chat, but attachments land in the retrieval store, not the sandbox workdir, so it was a dead end. Direct the user to place the file in the working directory or give a readable path instead. Add tests pinning the command-scoped claim, the no-chat-upload remedy, and the code-execution tip framing. --- studio/backend/core/inference/tools.py | 12 +++---- studio/backend/routes/inference.py | 13 +++---- .../backend/tests/test_sandbox_paths_note.py | 34 +++++++++++++++++++ 3 files changed, 47 insertions(+), 12 deletions(-) diff --git a/studio/backend/core/inference/tools.py b/studio/backend/core/inference/tools.py index f75ea66c04..fe6d800d44 100644 --- a/studio/backend/core/inference/tools.py +++ b/studio/backend/core/inference/tools.py @@ -5853,12 +5853,12 @@ def _bash_exec( base = f"Blocked command(s) for safety: {', '.join(sorted(blocked))}." if blocked & _NETWORK_BLOCKED_COMMANDS: return ( - base + " Direct network commands are blocked in the " - "terminal. Public files on allowlisted sites (such as " - "github.com, huggingface.co, or pypi.org) can be downloaded " - "from Python code instead; the user's own machine and other " - "private or arbitrary hosts are not reachable, so for those " - "ask the user to upload the files they want you to work with." + base + " These download commands are blocked by name in " + "the terminal. Public files on allowlisted sites (such as " + "github.com, huggingface.co, or pypi.org) can be fetched " + "from Python code instead. If you need files from another " + "location, ask the user to place them in the working " + "directory or give a path the sandbox can read." ) return base elif not _harden_parent_against_proc_env_leak(): diff --git a/studio/backend/routes/inference.py b/studio/backend/routes/inference.py index 6a8385bfe9..b386103415 100644 --- a/studio/backend/routes/inference.py +++ b/studio/backend/routes/inference.py @@ -2405,12 +2405,13 @@ _TOOL_WEB_EXPANDED_TIP = ( _TOOL_CODE_TIP = ( "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 may already hold files " - "from earlier work; it cannot access the user's own computer, and its " - "internet access is limited, 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." + "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, and its internet access is limited, " + "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 " diff --git a/studio/backend/tests/test_sandbox_paths_note.py b/studio/backend/tests/test_sandbox_paths_note.py index 14943c7b25..90345f82f3 100644 --- a/studio/backend/tests/test_sandbox_paths_note.py +++ b/studio/backend/tests/test_sandbox_paths_note.py @@ -95,3 +95,37 @@ def test_blocked_network_command_message_points_to_python_egress(): assert "cannot reach other machines or remote hosts" not in msg assert "python" in msg assert "github.com" in msg + + +def test_blocked_network_command_message_scopes_claim_to_the_command(): + # The block is by command name; the terminal keeps its network namespace and + # does not block git/pip, so `git clone http:///repo` can still + # reach a private host. The message must not assert the destination itself is + # unreachable, and must attribute the block to the command by name. + msg = _bash_exec("wget http://10.0.0.5/internal/repo.tar.gz").lower() + assert "by name" in msg + assert "not reachable" not in msg + assert "are not reachable" not in msg + assert "hosts are not reachable" not in msg + + +def test_blocked_network_command_message_does_not_recommend_chat_upload(): + # Chat attachments land in the RAG store, not the sandbox workdir, so telling + # the user to upload the file to chat is a dead end. The message must instead + # point at an accessible path or placing the file in the working directory. + msg = _bash_exec("curl https://raw.githubusercontent.com/foo/bar/main/x.py").lower() + assert "upload" not in msg + assert "working directory" in msg or "path the sandbox can read" in msg + + +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 + # frame the workdir as the default work location, not assert the user's own + # computer is inaccessible, and it must still allow an exact path. + from routes.inference import _TOOL_CODE_TIP + + lowered = _TOOL_CODE_TIP.lower() + assert "cannot access the user's own computer" not in lowered + assert "default" in lowered and "location for your work" in lowered + assert "give an exact path" in lowered From 9ddd563fd25359658eee9c44f26e430d4fe21c69 Mon Sep 17 00:00:00 2001 From: danielhanchen Date: Sun, 19 Jul 2026 15:11:12 +0000 Subject: [PATCH 07/20] 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. --- studio/backend/core/inference/tools.py | 66 ++++++++++++++++++- studio/backend/routes/inference.py | 41 ++++++++++-- .../backend/tests/test_sandbox_paths_note.py | 61 +++++++++++++++++ 3 files changed, 161 insertions(+), 7 deletions(-) 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 From 10f6db4f3e1eae82581bfefe994fee76b5a78fdb Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Sun, 19 Jul 2026 15:11:56 +0000 Subject: [PATCH 08/20] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- studio/backend/routes/inference.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/studio/backend/routes/inference.py b/studio/backend/routes/inference.py index 952d2cfbbf..6daec5ebd5 100644 --- a/studio/backend/routes/inference.py +++ b/studio/backend/routes/inference.py @@ -2437,7 +2437,10 @@ _TOOL_ARTIFACT_TIP = ( def _build_tool_action_nudge( - *, tools: list[dict], model_name: str, disable_sandbox: bool = False + *, + tools: list[dict], + model_name: str, + disable_sandbox: bool = False, ) -> str: tool_names = { (tool.get("function") or {}).get("name") From fda0c1168290bdff5bdd8ca45b4ff54f90b5d40d Mon Sep 17 00:00:00 2001 From: danielhanchen Date: Sun, 19 Jul 2026 15:40:02 +0000 Subject: [PATCH 09/20] Do not overstate the python host allowlist, and keep the blocked-command fallback tool-neutral Two accuracy fixes to the sandbox tool notes: - The sandbox keeps networking on (no CLONE_NEWNET) and the AST host check only inspects literal URL/host arguments, so a dynamically built request to a private host (host = '10.0.0.5'; requests.get('http://' + host + '/x')) runs with no network_calls violation. The note claimed the python tool can fetch only from a fixed allowlist and not arbitrary addresses, advertising a boundary the code does not enforce. Reframe it as intent (the python tool is intended to fetch from public sources such as github.com, huggingface.co, and pypi.org rather than private hosts) without the false only/arbitrary absolute. - The blocked-network-command message told the model to fetch from Python code even when only the terminal is enabled that turn (python absent from the schema), which can induce an invalid tool call. _bash_exec has no per-turn tool signal, so make the fallback tool-neutral and gated: if a code-execution tool is enabled this turn, public files can be fetched from code instead. Update the note tests to lock in both changes; the bypass note variant already omits the network sentence and stays consistent. --- studio/backend/core/inference/tools.py | 23 ++++++------ .../backend/tests/test_sandbox_paths_note.py | 35 ++++++++++++------- 2 files changed, 34 insertions(+), 24 deletions(-) diff --git a/studio/backend/core/inference/tools.py b/studio/backend/core/inference/tools.py index f51b23ae08..af9c06d846 100644 --- a/studio/backend/core/inference/tools.py +++ b/studio/backend/core/inference/tools.py @@ -2968,11 +2968,10 @@ _SANDBOX_PATHS_NOTE_INTRO = ( "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." + " Internet access is limited: the python tool is intended to fetch " + "from public sources such as github.com, huggingface.co, and pypi.org " + "rather than the user's own machines or private hosts, and the terminal " + "blocks direct download commands like curl and wget." ) _SANDBOX_PATHS_NOTE_TAIL = ( " Documents the user attaches to the chat are retrieved " @@ -2985,7 +2984,8 @@ _SANDBOX_PATHS_NOTE_TAIL = ( "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. +# Default (sandboxed) note: steers the python tool at public sources and keeps +# the curl/wget restriction (without overstating the host check as a hard wall). _SANDBOX_PATHS_NOTE = ( _SANDBOX_PATHS_NOTE_INTRO + _SANDBOX_PATHS_NOTE_NETWORK + _SANDBOX_PATHS_NOTE_TAIL ) @@ -5914,11 +5914,12 @@ def _bash_exec( if blocked & _NETWORK_BLOCKED_COMMANDS: return ( base + " These download commands are blocked by name in " - "the terminal. Public files on allowlisted sites (such as " - "github.com, huggingface.co, or pypi.org) can be fetched " - "from Python code instead. If you need files from another " - "location, ask the user to place them in the working " - "directory or give a path the sandbox can read." + "the terminal. If a code-execution tool is enabled this " + "turn, public files on sites such as github.com, " + "huggingface.co, or pypi.org can be fetched from code " + "instead. If you need files from another location, ask the " + "user to place them in the working directory or give a path " + "the sandbox can read." ) return base elif not _harden_parent_against_proc_env_leak(): diff --git a/studio/backend/tests/test_sandbox_paths_note.py b/studio/backend/tests/test_sandbox_paths_note.py index b6d713298f..a9bec23ba7 100644 --- a/studio/backend/tests/test_sandbox_paths_note.py +++ b/studio/backend/tests/test_sandbox_paths_note.py @@ -5,9 +5,10 @@ tool descriptions (#7242). The note must not misdirect the model with claims that are false for the real -sandbox: (1) the sandbox is not network-isolated -- the python tool can reach an -allowlist of public hosts (github.com / huggingface.co / pypi.org), so it must -not claim it "cannot reach ... remote hosts" at all; (2) a project sandbox is a +sandbox: (1) the sandbox is not network-isolated -- the python tool should fetch +from public hosts (github.com / huggingface.co / pypi.org), so it must not claim +it "cannot reach ... remote hosts" at all, nor overstate the host check as a hard +wall that makes private/arbitrary hosts impossible; (2) a project sandbox is a shared per-project directory, so a new thread can inherit files from prior threads -- the note must not claim the workdir "starts empty" or "persists only for this conversation". @@ -36,13 +37,17 @@ from core.inference.tools import ( def test_note_does_not_claim_full_network_isolation(): lowered = _SANDBOX_PATHS_NOTE.lower() - # The old absolute claim is false for the python tool (allowlisted egress). + # The old absolute claim is false for the python tool (it does reach egress). assert "cannot reach other machines or remote hosts" not in lowered - # It should instead scope the block to private/arbitrary hosts and name the - # allowlist so the model still fetches valid public URLs. - assert "allowlist" in lowered + # It must not overstate the host check as an enforced hard boundary: + # _sandbox_preexec leaves networking on and the AST check only inspects + # literal hosts, so a dynamically built request to a private host still runs. + assert "only from a fixed allowlist" not in lowered + assert "arbitrary addresses" not in lowered + # It should still steer to public sources so the model fetches valid URLs. + assert "public sources" in lowered assert "github.com" in lowered and "huggingface.co" in lowered - assert "arbitrary" in lowered or "private" in lowered + assert "private" in lowered def test_note_does_not_claim_project_sandbox_starts_empty(): @@ -78,7 +83,7 @@ def test_note_scopes_network_block_to_the_terminal_not_all_shell_commands(): lowered = _SANDBOX_PATHS_NOTE.lower() assert "shell network commands are blocked" not in lowered assert "curl" in lowered and "wget" in lowered - assert "python tool can fetch" in lowered + assert "python tool is intended to fetch" in lowered def test_note_distinguishes_attachments_from_sandbox_uploads(): @@ -91,13 +96,17 @@ def test_note_distinguishes_attachments_from_sandbox_uploads(): assert "attach" in lowered -def test_blocked_network_command_message_points_to_python_egress(): - # A blocked curl/wget must not tell the model the whole sandbox is offline: the - # python tool can still fetch allowlisted public hosts. +def test_blocked_network_command_message_gates_code_fallback_on_tool_availability(): + # A blocked curl/wget must not tell the model the whole sandbox is offline, and + # must not name a specific tool (e.g. python) as the remedy: when only the + # terminal is enabled the python tool is absent from the schema, so an + # unconditional "fetch it from Python code" instruction invites an invalid tool + # call. The fallback is gated on a code-execution tool being enabled this turn. msg = _bash_exec("curl https://raw.githubusercontent.com/foo/bar/main/x.py").lower() assert "blocked command" in msg assert "cannot reach other machines or remote hosts" not in msg - assert "python" in msg + assert "from python code instead" not in msg + assert "if a code-execution tool is enabled this turn" in msg assert "github.com" in msg From e7aed125d62e7ec8e081116db7e942a5c48f76e4 Mon Sep 17 00:00:00 2001 From: danielhanchen Date: Sun, 19 Jul 2026 16:13:27 +0000 Subject: [PATCH 10/20] studio: gate tool notes on effective sandbox-disabled flag permission_mode 'full' is folded into bypass_permissions=True by both agent loops and at execution passes disable_sandbox=True, so it runs python/terminal unsandboxed (skips _check_code_safety and the curl/wget blocklist) exactly like bypass_permissions. Add _sandbox_disabled(payload) and use it for the bypass tool notes and the action nudge at all sites so the descriptions always match what executes, decoupled from the model-layer fold, and correct the comment that wrongly claimed full alone does not disable the sandbox. Behavior-neutral today; regression test locks in the full-mode decoupling. --- studio/backend/routes/inference.py | 40 ++++++++++++++----- .../backend/tests/test_sandbox_paths_note.py | 21 ++++++++++ 2 files changed, 50 insertions(+), 11 deletions(-) diff --git a/studio/backend/routes/inference.py b/studio/backend/routes/inference.py index 6daec5ebd5..6690253853 100644 --- a/studio/backend/routes/inference.py +++ b/studio/backend/routes/inference.py @@ -2143,6 +2143,23 @@ def _permission_mode_confirm(payload) -> bool: return bool(getattr(payload, "stream", False)) +def _sandbox_disabled(payload) -> bool: + """Whether python/terminal execution actually runs unsandboxed for this request. + + Both agent loops (llama_cpp.py and safetensors_agentic.py) normalize the + permissive switch the same way: permission_mode "full" is turned into + bypass_permissions=True, and an explicit bypass_permissions is turned into + permission_mode "full". Execution then passes disable_sandbox=bypass_permissions, + so "full" and bypass_permissions are equivalent and BOTH skip _check_code_safety + and the terminal curl/wget blocklist. The tool notes and action nudge must match + what executes, so they key off this effective flag rather than bypass_permissions + alone. + """ + return bool(getattr(payload, "bypass_permissions", False)) or ( + getattr(payload, "permission_mode", None) == "full" + ) + + def _confirm_gate_needs_stream(payload) -> bool: """Whether Unsloth's local tool-loop confirm gate still requires stream=true. @@ -2506,11 +2523,11 @@ 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): + # A sandbox-disabled request (bypass_permissions, or permission_mode "full" + # which both loops normalize to bypass_permissions=True) runs python/terminal + # with disable_sandbox=True, so the descriptions must not claim the + # allowlist/curl-wget block that no longer applies at execution. + if _sandbox_disabled(payload): tools = apply_bypass_tool_notes(tools) if mcp_allowed: tools = tools + await get_enabled_mcp_tools() @@ -7502,7 +7519,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), + disable_sandbox = _sandbox_disabled(payload), ) # Nudge the model to ground in attached documents instead of memory. @@ -8853,7 +8870,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), + disable_sandbox = _sandbox_disabled(payload), ) # RAG nudge, mirroring the GGUF path. @@ -12582,16 +12599,17 @@ 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): + # A sandbox-disabled request (bypass_permissions or permission_mode "full") + # runs unsandboxed, so drop the allowlist/curl-wget restriction from the + # python/terminal descriptions here too. + if _sandbox_disabled(payload): 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)), + disable_sandbox = _sandbox_disabled(payload), ) if _nudge: diff --git a/studio/backend/tests/test_sandbox_paths_note.py b/studio/backend/tests/test_sandbox_paths_note.py index a9bec23ba7..f6f75395a7 100644 --- a/studio/backend/tests/test_sandbox_paths_note.py +++ b/studio/backend/tests/test_sandbox_paths_note.py @@ -199,3 +199,24 @@ def test_code_execution_nudge_does_not_deny_local_file_access(): assert "cannot access the user's own computer" not in lowered assert "default" in lowered and "location for your work" in lowered assert "give an exact path" in lowered + + +def test_sandbox_disabled_treats_permission_mode_full_as_unsandboxed(): + # Both agent loops fold permission_mode "full" into bypass_permissions=True and + # pass disable_sandbox=bypass_permissions at execution, so "full" runs python / + # terminal unsandboxed (skips _check_code_safety and the curl/wget blocklist). + # The tool notes and action nudge key off the effective flag so they always + # match what executes, even if the model-layer fold is ever refactored away. + from types import SimpleNamespace + + from routes.inference import _sandbox_disabled + + # Explicit bypass -> unsandboxed. + assert _sandbox_disabled(SimpleNamespace(bypass_permissions=True, permission_mode="ask")) is True + # "full" even without bypass set on the object -> still unsandboxed (decoupled + # from the model-layer fold, so the notes never overclaim a live restriction). + assert _sandbox_disabled(SimpleNamespace(bypass_permissions=False, permission_mode="full")) is True + # A genuinely sandboxed request keeps the restrictive notes. + assert _sandbox_disabled(SimpleNamespace(bypass_permissions=False, permission_mode="ask")) is False + # Missing attributes must not raise (defensive getattr). + assert _sandbox_disabled(SimpleNamespace()) is False From 8d99c172a1ba3f449395f5d417916eedc5a68efa Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Sun, 19 Jul 2026 16:14:06 +0000 Subject: [PATCH 11/20] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- studio/backend/tests/test_sandbox_paths_note.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/studio/backend/tests/test_sandbox_paths_note.py b/studio/backend/tests/test_sandbox_paths_note.py index f6f75395a7..e1e33ee62b 100644 --- a/studio/backend/tests/test_sandbox_paths_note.py +++ b/studio/backend/tests/test_sandbox_paths_note.py @@ -212,11 +212,17 @@ def test_sandbox_disabled_treats_permission_mode_full_as_unsandboxed(): from routes.inference import _sandbox_disabled # Explicit bypass -> unsandboxed. - assert _sandbox_disabled(SimpleNamespace(bypass_permissions=True, permission_mode="ask")) is True + assert ( + _sandbox_disabled(SimpleNamespace(bypass_permissions = True, permission_mode = "ask")) is True + ) # "full" even without bypass set on the object -> still unsandboxed (decoupled # from the model-layer fold, so the notes never overclaim a live restriction). - assert _sandbox_disabled(SimpleNamespace(bypass_permissions=False, permission_mode="full")) is True + assert ( + _sandbox_disabled(SimpleNamespace(bypass_permissions = False, permission_mode = "full")) is True + ) # A genuinely sandboxed request keeps the restrictive notes. - assert _sandbox_disabled(SimpleNamespace(bypass_permissions=False, permission_mode="ask")) is False + assert ( + _sandbox_disabled(SimpleNamespace(bypass_permissions = False, permission_mode = "ask")) is False + ) # Missing attributes must not raise (defensive getattr). assert _sandbox_disabled(SimpleNamespace()) is False From 830af5f6275b0f9ca019b8d17e75a2a6a48b272e Mon Sep 17 00:00:00 2001 From: danielhanchen Date: Sun, 19 Jul 2026 17:03:06 +0000 Subject: [PATCH 12/20] studio: tighten comments in the sandbox isolation hint --- studio/backend/core/inference/tools.py | 41 ++++----- studio/backend/routes/inference.py | 53 +++++------- .../backend/tests/test_anthropic_messages.py | 6 +- .../backend/tests/test_sandbox_paths_note.py | 84 +++++++------------ 4 files changed, 71 insertions(+), 113 deletions(-) diff --git a/studio/backend/core/inference/tools.py b/studio/backend/core/inference/tools.py index af9c06d846..541f6f530a 100644 --- a/studio/backend/core/inference/tools.py +++ b/studio/backend/core/inference/tools.py @@ -143,8 +143,8 @@ _BLOCKED_COMMANDS = ( else _BLOCKED_COMMANDS_COMMON ) -# Blocked commands that reach the network / another machine; hitting one means -# the model wants files the sandbox cannot reach, so we steer it to ask for an upload. +# Blocked commands that reach the network / another machine; hitting one steers +# the model to fetch via code or ask for an accessible path. _NETWORK_BLOCKED_COMMANDS = frozenset( {"curl", "wget", "nc", "ncat", "netcat", "socat", "ssh", "scp", "sftp", "rsync"} ) @@ -2949,16 +2949,12 @@ 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. -# 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. +# Appended to python/terminal descriptions: stop models writing to /mnt/data or +# guessing a local path for a repo the user mentioned but never uploaded. +# Split so the Bypass Permissions variant can drop the network sentence: under +# bypass, _python_exec/_bash_exec skip the safety analysis and curl/wget blocklist +# (there is no network namespace), so egress works and claiming those downloads +# are unavailable would falsely 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 " @@ -2984,13 +2980,13 @@ _SANDBOX_PATHS_NOTE_TAIL = ( "you need are not here, ask the user to provide them or an exact path " "instead of guessing one." ) -# Default (sandboxed) note: steers the python tool at public sources and keeps -# the curl/wget restriction (without overstating the host check as a hard wall). +# Default (sandboxed) note: steers python at public sources, keeps the curl/wget +# restriction without overstating the host check as a hard wall. _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). +# Bypass variant: same guidance minus the network sentence (stays neutral rather +# than claiming egress works). _SANDBOX_PATHS_NOTE_BYPASS = _SANDBOX_PATHS_NOTE_INTRO + _SANDBOX_PATHS_NOTE_TAIL PYTHON_TOOL = { @@ -3096,8 +3092,8 @@ 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).""" + """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): @@ -3106,8 +3102,7 @@ def _with_sandbox_note(tool: dict, note: str) -> dict: return {**tool, "function": fn} -# Bypass Permissions variants: descriptions omit the allowlist/curl/wget -# restriction because that safety analysis and blocklist are skipped when the +# Bypass variants: descriptions omit the curl/wget restriction, 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) @@ -3118,10 +3113,8 @@ _BYPASS_TOOL_OVERRIDES = { 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.""" + """Return ``tools`` with python/terminal swapped for their Bypass variants + (only the description differs); no-op for lists without python/terminal.""" swapped = False result: list[dict] = [] for tool in tools: diff --git a/studio/backend/routes/inference.py b/studio/backend/routes/inference.py index 6690253853..e3612aad72 100644 --- a/studio/backend/routes/inference.py +++ b/studio/backend/routes/inference.py @@ -2146,14 +2146,10 @@ def _permission_mode_confirm(payload) -> bool: def _sandbox_disabled(payload) -> bool: """Whether python/terminal execution actually runs unsandboxed for this request. - Both agent loops (llama_cpp.py and safetensors_agentic.py) normalize the - permissive switch the same way: permission_mode "full" is turned into - bypass_permissions=True, and an explicit bypass_permissions is turned into - permission_mode "full". Execution then passes disable_sandbox=bypass_permissions, - so "full" and bypass_permissions are equivalent and BOTH skip _check_code_safety - and the terminal curl/wget blocklist. The tool notes and action nudge must match - what executes, so they key off this effective flag rather than bypass_permissions - alone. + Both agent loops fold permission_mode "full" and bypass_permissions into the + same disable_sandbox flag, so both skip _check_code_safety and the curl/wget + blocklist. The tool notes and nudge key off this effective flag, not + bypass_permissions alone. """ return bool(getattr(payload, "bypass_permissions", False)) or ( getattr(payload, "permission_mode", None) == "full" @@ -2430,9 +2426,8 @@ _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. +# Bypass variant: drops the "internet access is limited" clause (false when the +# sandbox is disabled), 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 " @@ -2523,10 +2518,8 @@ 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"] - # A sandbox-disabled request (bypass_permissions, or permission_mode "full" - # which both loops normalize to bypass_permissions=True) runs python/terminal - # with disable_sandbox=True, so the descriptions must not claim the - # allowlist/curl-wget block that no longer applies at execution. + # A sandbox-disabled request runs python/terminal with disable_sandbox=True, so + # the descriptions must not claim the curl/wget block that no longer applies. if _sandbox_disabled(payload): tools = apply_bypass_tool_notes(tools) if mcp_allowed: @@ -4306,12 +4299,10 @@ async def _load_model_impl(request: LoadRequest, fastapi_request: Request, curre # parse against a freshly-supplied first-class field. if request.llama_extra_args is None and llama_backend.extra_args: source = llama_backend.extra_args_source - # Compare against the resolved variant, not the request - # field: callers commonly omit gguf_variant for local - # ``.gguf`` paths and HF auto-pick flows. ``config.gguf_ - # variant`` is the variant load_model was actually - # invoked with (see the HF / local branches below), so - # both sides of the comparison key off the same string. + # Compare against the resolved variant, not the request field: + # callers commonly omit gguf_variant for local ``.gguf`` paths and + # HF auto-pick flows. ``config.gguf_variant`` is the variant + # load_model was actually invoked with, so both sides key off it. resolved_variant = (config.gguf_variant or "").lower() request_variant = (request.gguf_variant or "").lower() stored_variant = (source[1] or "").lower() if source else "" @@ -4333,15 +4324,12 @@ async def _load_model_impl(request: LoadRequest, fastapi_request: Request, curre # inherit via "no opinion" semantics. extra_llama_args = [] else: - # Strip only the groups whose first-class field was set by - # the caller, so an inherited --chat-template-file survives - # an Apply that omits chat_template_override. A bundled family - # template (e.g. the gemma-4 override) is an effective - # first-class template setting even when the raw request - # omits chat_template_override, so strip the inherited - # --chat-template-file in that case too -- otherwise the stale - # extra arg (appended last) shadows the bundled template while - # Unsloth reports the bundled template's capabilities. + # Strip only the groups whose first-class field the caller set, + # so an inherited --chat-template-file survives an Apply that + # omits chat_template_override. A bundled family template (e.g. + # gemma-4) counts as first-class too, so strip the inherited + # --chat-template-file then as well, else the stale arg (appended + # last) shadows it while Unsloth reports its capabilities. fields_set = getattr(request, "model_fields_set", set()) stripped = strip_shadowing_flags( llama_backend.extra_args, @@ -12599,9 +12587,8 @@ async def anthropic_messages( requested_studio_tools, payload.enabled_tools, ) - # A sandbox-disabled request (bypass_permissions or permission_mode "full") - # runs unsandboxed, so drop the allowlist/curl-wget restriction from the - # python/terminal descriptions here too. + # A sandbox-disabled request runs unsandboxed, so drop the curl/wget + # restriction from the python/terminal descriptions here too. if _sandbox_disabled(payload): openai_tools = apply_bypass_tool_notes(openai_tools) diff --git a/studio/backend/tests/test_anthropic_messages.py b/studio/backend/tests/test_anthropic_messages.py index f53dbbd7fb..fc0839ce47 100644 --- a/studio/backend/tests/test_anthropic_messages.py +++ b/studio/backend/tests/test_anthropic_messages.py @@ -174,10 +174,8 @@ class TestToolActionNudge: assert _build_tool_action_nudge(tools = [], model_name = "Llama-3.1-8B-Instruct") == "" def test_code_tip_does_not_name_a_disabled_sibling_code_tool(self): - # has_code fires when either python or terminal is present, so the code tip - # must stay tool-neutral: a request that enabled only one must never name - # the other as an available tool (the missing-tool hallucination the - # render_html gate already avoids). + # has_code fires for either python or terminal, so the code tip must stay + # tool-neutral: enabling one must never name the other as available. py_only = _build_tool_action_nudge( tools = [{"type": "function", "function": {"name": "python"}}], model_name = "Llama-3.1-8B-Instruct", diff --git a/studio/backend/tests/test_sandbox_paths_note.py b/studio/backend/tests/test_sandbox_paths_note.py index e1e33ee62b..8cb68825cc 100644 --- a/studio/backend/tests/test_sandbox_paths_note.py +++ b/studio/backend/tests/test_sandbox_paths_note.py @@ -1,17 +1,12 @@ # SPDX-License-Identifier: AGPL-3.0-only # Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 -"""Accuracy guards for the sandbox-paths note appended to the python/terminal -tool descriptions (#7242). +"""Accuracy guards for the sandbox-paths note on the python/terminal tools (#7242). -The note must not misdirect the model with claims that are false for the real -sandbox: (1) the sandbox is not network-isolated -- the python tool should fetch -from public hosts (github.com / huggingface.co / pypi.org), so it must not claim -it "cannot reach ... remote hosts" at all, nor overstate the host check as a hard -wall that makes private/arbitrary hosts impossible; (2) a project sandbox is a -shared per-project directory, so a new thread can inherit files from prior -threads -- the note must not claim the workdir "starts empty" or "persists only -for this conversation". +The note must not misdirect the model: (1) the sandbox is not network-isolated -- +the python tool fetches from public hosts, so it must not claim it "cannot reach +remote hosts" nor overstate the host check as a hard wall; (2) a project sandbox +is shared across threads, so it must not claim the workdir "starts empty". """ from __future__ import annotations @@ -37,14 +32,13 @@ from core.inference.tools import ( def test_note_does_not_claim_full_network_isolation(): lowered = _SANDBOX_PATHS_NOTE.lower() - # The old absolute claim is false for the python tool (it does reach egress). + # False for the python tool, which does reach egress. assert "cannot reach other machines or remote hosts" not in lowered - # It must not overstate the host check as an enforced hard boundary: - # _sandbox_preexec leaves networking on and the AST check only inspects - # literal hosts, so a dynamically built request to a private host still runs. + # Don't overstate the host check as a hard wall: networking stays on and the + # AST check only inspects literal hosts, so a dynamic private-host request runs. assert "only from a fixed allowlist" not in lowered assert "arbitrary addresses" not in lowered - # It should still steer to public sources so the model fetches valid URLs. + # Still steer to public sources so the model fetches valid URLs. assert "public sources" in lowered assert "github.com" in lowered and "huggingface.co" in lowered assert "private" in lowered @@ -61,11 +55,8 @@ def test_note_does_not_claim_project_sandbox_starts_empty(): def test_note_does_not_claim_local_files_are_inaccessible(): lowered = _SANDBOX_PATHS_NOTE.lower() - # On a locally hosted Studio the child runs on the host with no filesystem - # isolation on this branch (Landlock is a separate change), and cat is an - # auto-safe terminal command, so an exact local path is readable; the note - # must not claim otherwise. It should frame the workdir as the default work - # location instead. + # No filesystem isolation on this branch, so an exact local path is readable; + # the note must frame the workdir as the default work location, not deny access. assert "cannot see the user's own computer" not in lowered assert "default location for your work" in lowered @@ -76,10 +67,9 @@ def test_note_is_appended_to_both_tool_descriptions(): def test_note_scopes_network_block_to_the_terminal_not_all_shell_commands(): - # The terminal leaves the network namespace intact and does not block git/pip, - # so the note must not claim shell network is fully blocked; it names the - # commands that are blocked (curl / wget) and attributes the host allowlist to - # the python tool. + # The terminal keeps the network namespace and doesn't block git/pip, so the + # note names only the blocked commands (curl/wget) and puts the allowlist on the + # python tool rather than claiming shell network is fully blocked. lowered = _SANDBOX_PATHS_NOTE.lower() assert "shell network commands are blocked" not in lowered assert "curl" in lowered and "wget" in lowered @@ -97,11 +87,9 @@ def test_note_distinguishes_attachments_from_sandbox_uploads(): def test_blocked_network_command_message_gates_code_fallback_on_tool_availability(): - # A blocked curl/wget must not tell the model the whole sandbox is offline, and - # must not name a specific tool (e.g. python) as the remedy: when only the - # terminal is enabled the python tool is absent from the schema, so an - # unconditional "fetch it from Python code" instruction invites an invalid tool - # call. The fallback is gated on a code-execution tool being enabled this turn. + # A blocked curl/wget must not claim the sandbox is offline, nor name a specific + # remedy tool (python may be absent from the schema -- an invalid call); the + # fallback is gated on a code-execution tool being enabled this turn. msg = _bash_exec("curl https://raw.githubusercontent.com/foo/bar/main/x.py").lower() assert "blocked command" in msg assert "cannot reach other machines or remote hosts" not in msg @@ -111,10 +99,9 @@ def test_blocked_network_command_message_gates_code_fallback_on_tool_availabilit def test_blocked_network_command_message_scopes_claim_to_the_command(): - # The block is by command name; the terminal keeps its network namespace and - # does not block git/pip, so `git clone http:///repo` can still - # reach a private host. The message must not assert the destination itself is - # unreachable, and must attribute the block to the command by name. + # The block is by command name; the terminal keeps networking (git/pip work), + # so a private host is still reachable. The message must attribute the block to + # the command name, not assert the destination is unreachable. msg = _bash_exec("wget http://10.0.0.5/internal/repo.tar.gz").lower() assert "by name" in msg assert "not reachable" not in msg @@ -123,18 +110,16 @@ def test_blocked_network_command_message_scopes_claim_to_the_command(): def test_blocked_network_command_message_does_not_recommend_chat_upload(): - # Chat attachments land in the RAG store, not the sandbox workdir, so telling - # the user to upload the file to chat is a dead end. The message must instead - # point at an accessible path or placing the file in the working directory. + # Chat attachments land in the RAG store, not the workdir, so "upload to chat" + # is a dead end; point at the working directory or an accessible path instead. msg = _bash_exec("curl https://raw.githubusercontent.com/foo/bar/main/x.py").lower() assert "upload" not in msg 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. + # Under bypass, _python_exec/_bash_exec skip the safety analysis and curl/wget + # blocklist, so egress works; the bypass note must not call them blocked. lowered = _SANDBOX_PATHS_NOTE_BYPASS.lower() assert "curl" not in lowered and "wget" not in lowered assert "allowlist" not in lowered @@ -142,8 +127,7 @@ def test_bypass_note_drops_the_curl_wget_allowlist_restriction(): # 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. + # Bypass note is the default note minus only the network sentence. assert _SANDBOX_PATHS_NOTE_BYPASS != _SANDBOX_PATHS_NOTE assert "curl" in _SANDBOX_PATHS_NOTE.lower() @@ -189,10 +173,9 @@ def test_bypass_code_execution_nudge_drops_the_limited_internet_claim(): 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 - # frame the workdir as the default work location, not assert the user's own - # computer is inaccessible, and it must still allow an exact path. + # No filesystem isolation here, so an exact local path is readable; the nudge + # must frame the workdir as the default work location and still allow an exact + # path, not deny access to the user's computer. from routes.inference import _TOOL_CODE_TIP lowered = _TOOL_CODE_TIP.lower() @@ -202,11 +185,9 @@ def test_code_execution_nudge_does_not_deny_local_file_access(): def test_sandbox_disabled_treats_permission_mode_full_as_unsandboxed(): - # Both agent loops fold permission_mode "full" into bypass_permissions=True and - # pass disable_sandbox=bypass_permissions at execution, so "full" runs python / - # terminal unsandboxed (skips _check_code_safety and the curl/wget blocklist). - # The tool notes and action nudge key off the effective flag so they always - # match what executes, even if the model-layer fold is ever refactored away. + # Both agent loops fold permission_mode "full" into disable_sandbox, so "full" + # runs python/terminal unsandboxed. _sandbox_disabled keys off the effective + # flag so the notes match what executes even if that fold is refactored away. from types import SimpleNamespace from routes.inference import _sandbox_disabled @@ -215,8 +196,7 @@ def test_sandbox_disabled_treats_permission_mode_full_as_unsandboxed(): assert ( _sandbox_disabled(SimpleNamespace(bypass_permissions = True, permission_mode = "ask")) is True ) - # "full" even without bypass set on the object -> still unsandboxed (decoupled - # from the model-layer fold, so the notes never overclaim a live restriction). + # "full" without bypass set -> still unsandboxed (decoupled from the fold). assert ( _sandbox_disabled(SimpleNamespace(bypass_permissions = False, permission_mode = "full")) is True ) From 2e91a4420232236641d8701fb3878e57dfc68c0c Mon Sep 17 00:00:00 2001 From: danielhanchen Date: Mon, 20 Jul 2026 04:42:58 +0000 Subject: [PATCH 13/20] Tighten comments in the sandbox isolation hint path --- studio/backend/core/inference/tools.py | 22 ++++++--------- studio/backend/routes/inference.py | 9 ++---- .../backend/tests/test_anthropic_messages.py | 3 +- .../backend/tests/test_sandbox_paths_note.py | 28 ++++++++----------- 4 files changed, 23 insertions(+), 39 deletions(-) diff --git a/studio/backend/core/inference/tools.py b/studio/backend/core/inference/tools.py index 541f6f530a..9d0fc53ae6 100644 --- a/studio/backend/core/inference/tools.py +++ b/studio/backend/core/inference/tools.py @@ -143,8 +143,7 @@ _BLOCKED_COMMANDS = ( else _BLOCKED_COMMANDS_COMMON ) -# Blocked commands that reach the network / another machine; hitting one steers -# the model to fetch via code or ask for an accessible path. +# Network/remote-reaching commands; blocking one steers the model to fetch via code or ask for a path. _NETWORK_BLOCKED_COMMANDS = frozenset( {"curl", "wget", "nc", "ncat", "netcat", "socat", "ssh", "scp", "sftp", "rsync"} ) @@ -2949,12 +2948,10 @@ WEB_SEARCH_TOOL = { }, } -# Appended to python/terminal descriptions: stop models writing to /mnt/data or -# guessing a local path for a repo the user mentioned but never uploaded. -# Split so the Bypass Permissions variant can drop the network sentence: under -# bypass, _python_exec/_bash_exec skip the safety analysis and curl/wget blocklist -# (there is no network namespace), so egress works and claiming those downloads -# are unavailable would falsely block a user-supplied remote resource. +# Appended to python/terminal descriptions to stop models writing to /mnt/data or +# guessing a local path. Split so the Bypass variant can drop the network sentence: +# under bypass egress works, so claiming downloads are unavailable would wrongly +# 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 " @@ -2980,13 +2977,11 @@ _SANDBOX_PATHS_NOTE_TAIL = ( "you need are not here, ask the user to provide them or an exact path " "instead of guessing one." ) -# Default (sandboxed) note: steers python at public sources, keeps the curl/wget -# restriction without overstating the host check as a hard wall. +# Default (sandboxed) note: steers python at public sources, keeps the curl/wget restriction. _SANDBOX_PATHS_NOTE = ( _SANDBOX_PATHS_NOTE_INTRO + _SANDBOX_PATHS_NOTE_NETWORK + _SANDBOX_PATHS_NOTE_TAIL ) -# Bypass variant: same guidance minus the network sentence (stays neutral rather -# than claiming egress works). +# Bypass variant: same guidance minus the network sentence. _SANDBOX_PATHS_NOTE_BYPASS = _SANDBOX_PATHS_NOTE_INTRO + _SANDBOX_PATHS_NOTE_TAIL PYTHON_TOOL = { @@ -3102,8 +3097,7 @@ def _with_sandbox_note(tool: dict, note: str) -> dict: return {**tool, "function": fn} -# Bypass variants: descriptions omit the curl/wget restriction, skipped when the -# sandbox is disabled (disable_sandbox = bypass_permissions in the tool loops). +# Bypass variants: descriptions omit the curl/wget restriction, used when the sandbox is disabled. 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 = { diff --git a/studio/backend/routes/inference.py b/studio/backend/routes/inference.py index 563ff04fbc..217551c9a8 100644 --- a/studio/backend/routes/inference.py +++ b/studio/backend/routes/inference.py @@ -2426,8 +2426,7 @@ _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 variant: drops the "internet access is limited" clause (false when the -# sandbox is disabled), keeping the workdir-default framing. +# Bypass variant: drops the "internet access is limited" clause, keeps 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 " @@ -2518,8 +2517,7 @@ 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"] - # A sandbox-disabled request runs python/terminal with disable_sandbox=True, so - # the descriptions must not claim the curl/wget block that no longer applies. + # Sandbox-disabled: descriptions must not claim the curl/wget block that no longer applies. if _sandbox_disabled(payload): tools = apply_bypass_tool_notes(tools) if mcp_allowed: @@ -12878,8 +12876,7 @@ async def anthropic_messages( requested_studio_tools, payload.enabled_tools, ) - # A sandbox-disabled request runs unsandboxed, so drop the curl/wget - # restriction from the python/terminal descriptions here too. + # Sandbox-disabled: drop the curl/wget restriction from the descriptions here too. if _sandbox_disabled(payload): openai_tools = apply_bypass_tool_notes(openai_tools) diff --git a/studio/backend/tests/test_anthropic_messages.py b/studio/backend/tests/test_anthropic_messages.py index fc0839ce47..16d602e9fa 100644 --- a/studio/backend/tests/test_anthropic_messages.py +++ b/studio/backend/tests/test_anthropic_messages.py @@ -174,8 +174,7 @@ class TestToolActionNudge: assert _build_tool_action_nudge(tools = [], model_name = "Llama-3.1-8B-Instruct") == "" def test_code_tip_does_not_name_a_disabled_sibling_code_tool(self): - # has_code fires for either python or terminal, so the code tip must stay - # tool-neutral: enabling one must never name the other as available. + # has_code fires for either python or terminal, so the code tip must stay tool-neutral (never name the sibling). py_only = _build_tool_action_nudge( tools = [{"type": "function", "function": {"name": "python"}}], model_name = "Llama-3.1-8B-Instruct", diff --git a/studio/backend/tests/test_sandbox_paths_note.py b/studio/backend/tests/test_sandbox_paths_note.py index 8cb68825cc..47567ed35a 100644 --- a/studio/backend/tests/test_sandbox_paths_note.py +++ b/studio/backend/tests/test_sandbox_paths_note.py @@ -55,8 +55,7 @@ def test_note_does_not_claim_project_sandbox_starts_empty(): def test_note_does_not_claim_local_files_are_inaccessible(): lowered = _SANDBOX_PATHS_NOTE.lower() - # No filesystem isolation on this branch, so an exact local path is readable; - # the note must frame the workdir as the default work location, not deny access. + # No filesystem isolation here, so the note frames the workdir as the default work location, not a denial of access. assert "cannot see the user's own computer" not in lowered assert "default location for your work" in lowered @@ -67,9 +66,8 @@ def test_note_is_appended_to_both_tool_descriptions(): def test_note_scopes_network_block_to_the_terminal_not_all_shell_commands(): - # The terminal keeps the network namespace and doesn't block git/pip, so the - # note names only the blocked commands (curl/wget) and puts the allowlist on the - # python tool rather than claiming shell network is fully blocked. + # Terminal keeps networking (git/pip work), so the note names only curl/wget and + # puts the allowlist on the python tool, not on all shell network. lowered = _SANDBOX_PATHS_NOTE.lower() assert "shell network commands are blocked" not in lowered assert "curl" in lowered and "wget" in lowered @@ -87,9 +85,8 @@ def test_note_distinguishes_attachments_from_sandbox_uploads(): def test_blocked_network_command_message_gates_code_fallback_on_tool_availability(): - # A blocked curl/wget must not claim the sandbox is offline, nor name a specific - # remedy tool (python may be absent from the schema -- an invalid call); the - # fallback is gated on a code-execution tool being enabled this turn. + # A blocked curl/wget must not claim the sandbox is offline nor name a specific remedy + # tool (python may be absent); the fallback is gated on a code tool being enabled this turn. msg = _bash_exec("curl https://raw.githubusercontent.com/foo/bar/main/x.py").lower() assert "blocked command" in msg assert "cannot reach other machines or remote hosts" not in msg @@ -99,9 +96,8 @@ def test_blocked_network_command_message_gates_code_fallback_on_tool_availabilit def test_blocked_network_command_message_scopes_claim_to_the_command(): - # The block is by command name; the terminal keeps networking (git/pip work), - # so a private host is still reachable. The message must attribute the block to - # the command name, not assert the destination is unreachable. + # The block is by command name and the terminal keeps networking (a private host is still + # reachable), so the message must attribute the block to the name, not call the host unreachable. msg = _bash_exec("wget http://10.0.0.5/internal/repo.tar.gz").lower() assert "by name" in msg assert "not reachable" not in msg @@ -173,9 +169,8 @@ def test_bypass_code_execution_nudge_drops_the_limited_internet_claim(): def test_code_execution_nudge_does_not_deny_local_file_access(): - # No filesystem isolation here, so an exact local path is readable; the nudge - # must frame the workdir as the default work location and still allow an exact - # path, not deny access to the user's computer. + # No filesystem isolation here, so the nudge frames the workdir as the default work + # location and still allows an exact path, rather than denying access. from routes.inference import _TOOL_CODE_TIP lowered = _TOOL_CODE_TIP.lower() @@ -185,9 +180,8 @@ def test_code_execution_nudge_does_not_deny_local_file_access(): def test_sandbox_disabled_treats_permission_mode_full_as_unsandboxed(): - # Both agent loops fold permission_mode "full" into disable_sandbox, so "full" - # runs python/terminal unsandboxed. _sandbox_disabled keys off the effective - # flag so the notes match what executes even if that fold is refactored away. + # Both agent loops fold permission_mode "full" into disable_sandbox, so "full" runs + # unsandboxed. _sandbox_disabled keys off the effective flag, not just the fold. from types import SimpleNamespace from routes.inference import _sandbox_disabled From 339dded90a878c65f3c182d07f45189b56e295c0 Mon Sep 17 00:00:00 2001 From: danielhanchen Date: Mon, 20 Jul 2026 04:59:29 +0000 Subject: [PATCH 14/20] Do not describe bypass code execution as sandboxed In Bypass Permissions the tool loop skips the safety analysis, command blocklist and rlimits, so the bypass code-execution nudge must not tell the model it runs in a sandbox. Keep the accurate isolated-scratch workdir framing and drop the sandbox claim; the default (sandboxed) nudge is unchanged. --- studio/backend/routes/inference.py | 19 ++++++++++--------- .../backend/tests/test_sandbox_paths_note.py | 6 +++++- 2 files changed, 15 insertions(+), 10 deletions(-) diff --git a/studio/backend/routes/inference.py b/studio/backend/routes/inference.py index 217551c9a8..7963267128 100644 --- a/studio/backend/routes/inference.py +++ b/studio/backend/routes/inference.py @@ -2426,17 +2426,18 @@ _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 variant: drops the "internet access is limited" clause, keeps the workdir-default framing. +# Bypass variant: bypass disables the sandbox, so this drops both the "internet +# access is limited" clause and the "runs in a sandbox" framing; it keeps the +# accurate isolated-scratch workdir-default guidance. _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." + "and analyze information from tool results. Its 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 " diff --git a/studio/backend/tests/test_sandbox_paths_note.py b/studio/backend/tests/test_sandbox_paths_note.py index 47567ed35a..42ee8b3184 100644 --- a/studio/backend/tests/test_sandbox_paths_note.py +++ b/studio/backend/tests/test_sandbox_paths_note.py @@ -161,10 +161,14 @@ def test_bypass_code_execution_nudge_drops_the_limited_internet_claim(): lowered = _TOOL_CODE_TIP_BYPASS.lower() assert "internet access is limited" not in lowered + # Bypass disables the safety analysis, blocklist and rlimits, so the nudge must + # not describe execution as sandboxed (only the isolated workdir survives). + assert "in a sandbox" 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. + # The default (sandboxed) nudge still runs in a sandbox and keeps the restriction. + assert "in a sandbox" in _TOOL_CODE_TIP.lower() assert "internet access is limited" in _TOOL_CODE_TIP.lower() From 937cc05b0a7a0ad59cf953989a277c0e9abb3911 Mon Sep 17 00:00:00 2001 From: danielhanchen Date: Mon, 20 Jul 2026 08:23:47 +0000 Subject: [PATCH 15/20] Reject out-of-workspace paths in the code sandbox Replace the verbose sandbox prompt-text additions with a small keyword scan in the terminal exec path. _paths_outside_workdir tokenises a shell command, resolves absolute, ~, and explicit relative path arguments with realpath, and blocks the command when any resolves outside the session working directory. Device paths such as /dev/null and URLs are skipped, and the check is disabled under Bypass Permissions alongside the existing command blocklist. This is a lightweight, additive defence-in-depth layer that also helps where the kernel-level filesystem sandbox is unavailable. --- studio/backend/core/inference/tools.py | 159 +++++++------- studio/backend/routes/inference.py | 63 +----- .../backend/tests/test_anthropic_messages.py | 16 -- .../backend/tests/test_sandbox_path_check.py | 76 +++++++ .../backend/tests/test_sandbox_paths_note.py | 206 ------------------ 5 files changed, 155 insertions(+), 365 deletions(-) create mode 100644 studio/backend/tests/test_sandbox_path_check.py delete mode 100644 studio/backend/tests/test_sandbox_paths_note.py diff --git a/studio/backend/core/inference/tools.py b/studio/backend/core/inference/tools.py index 9d0fc53ae6..631c49e7fa 100644 --- a/studio/backend/core/inference/tools.py +++ b/studio/backend/core/inference/tools.py @@ -143,11 +143,6 @@ _BLOCKED_COMMANDS = ( else _BLOCKED_COMMANDS_COMMON ) -# Network/remote-reaching commands; blocking one steers the model to fetch via code or ask for a path. -_NETWORK_BLOCKED_COMMANDS = frozenset( - {"curl", "wget", "nc", "ncat", "netcat", "socat", "ssh", "scp", "sftp", "rsync"} -) - _SHELL_SEPARATORS = frozenset({";", "&&", "||", "|", "&", "\n", "(", ")", "`", "{", "}"}) # Bash keywords starting a new command position (then $cmd, do $cmd, etc.). @@ -2948,41 +2943,13 @@ WEB_SEARCH_TOOL = { }, } -# Appended to python/terminal descriptions to stop models writing to /mnt/data or -# guessing a local path. Split so the Bypass variant can drop the network sentence: -# under bypass egress works, so claiming downloads are unavailable would wrongly -# 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." -) -_SANDBOX_PATHS_NOTE_NETWORK = ( - " Internet access is limited: the python tool is intended to fetch " - "from public sources such as github.com, huggingface.co, and pypi.org " - "rather than the user's own machines or private hosts, and the terminal " - "blocks direct download commands 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 " - "available rather than assuming a mentioned path exists or guessing where it " - "lives. Read and write files using relative paths in the working directory; " - "absolute paths like /mnt/data or /tmp/outputs do not exist. If the files " - "you need are not here, ask the user to provide them or an exact path " - "instead of guessing one." -) -# Default (sandboxed) note: steers python at public sources, keeps the curl/wget restriction. +# Appended to the python/terminal descriptions: models habitually write to +# /mnt/data (a ChatGPT code-interpreter path), which does not exist here. _SANDBOX_PATHS_NOTE = ( - _SANDBOX_PATHS_NOTE_INTRO + _SANDBOX_PATHS_NOTE_NETWORK + _SANDBOX_PATHS_NOTE_TAIL + " Read and write files using relative paths in the current working " + "directory, which persists for this conversation; absolute paths like " + "/mnt/data or /tmp/outputs do not exist." ) -# Bypass variant: same guidance minus the network sentence. -_SANDBOX_PATHS_NOTE_BYPASS = _SANDBOX_PATHS_NOTE_INTRO + _SANDBOX_PATHS_NOTE_TAIL PYTHON_TOOL = { "type": "function", @@ -3086,42 +3053,6 @@ ALL_TOOLS = [ ] -def _with_sandbox_note(tool: dict, note: str) -> dict: - """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 variants: descriptions omit the curl/wget restriction, used when the sandbox is disabled. -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 python/terminal swapped for their Bypass variants - (only the description differs); no-op for 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}$") @@ -5611,6 +5542,63 @@ def _is_outside_workdir(abs_path: str, workdir: str | None = None) -> bool: return rp != root and not rp.startswith(root + os.sep) +# Device paths that shell redirection and common tooling rely on; they are not +# filesystem escapes, so the out-of-workdir scan skips them. +_ALLOWED_ABS_PATHS = frozenset( + { + "/dev/null", + "/dev/zero", + "/dev/full", + "/dev/tty", + "/dev/stdin", + "/dev/stdout", + "/dev/stderr", + "/dev/random", + "/dev/urandom", + } +) + + +def _paths_outside_workdir(command: str, workdir: str) -> list[str]: + """Best-effort keyword scan for path arguments in ``command`` that resolve + outside ``workdir``. + + A lightweight, additive defence-in-depth check: it only inspects literal + path-like tokens (absolute ``/`` or ``~`` paths and explicit relative paths + containing ``/``) and reports the ones whose ``realpath`` escapes the + session workdir. It is not the real boundary (the kernel-level filesystem + sandbox is) and fails open on anything it cannot parse. Returns the escaping + resolved paths (deduped, order-preserving); empty means nothing to block. + """ + try: + tokens = shlex.split(command, posix = True) + except ValueError: + return [] + outside: list[str] = [] + seen: set[str] = set() + for token in tokens: + tok = _REDIR_PREFIX_RE.sub("", token) + if not tok or tok.startswith("-") or "://" in tok: + # flags and URLs are not local filesystem paths + continue + if tok.startswith("~"): + candidate = os.path.join(workdir, tok[1:].lstrip("/\\") or ".") + elif tok.startswith("/"): + if tok in _ALLOWED_ABS_PATHS: + continue + candidate = tok + elif "/" in tok: + candidate = os.path.join(workdir, tok) + else: + continue + if _is_outside_workdir(candidate, workdir): + resolved = os.path.realpath(candidate) + if resolved not in seen: + seen.add(resolved) + outside.append(resolved) + return outside + + def _missing_path_hint(output: str, workdir: str | None = None) -> str: """Model-visible healing when an execution fails on an absolute path missing in the sandbox (a code-interpreter habit path, or one invented from the CWD). @@ -5897,18 +5885,17 @@ def _bash_exec( if not disable_sandbox: blocked = _find_blocked_commands(command) if blocked: - base = f"Blocked command(s) for safety: {', '.join(sorted(blocked))}." - if blocked & _NETWORK_BLOCKED_COMMANDS: - return ( - base + " These download commands are blocked by name in " - "the terminal. If a code-execution tool is enabled this " - "turn, public files on sites such as github.com, " - "huggingface.co, or pypi.org can be fetched from code " - "instead. If you need files from another location, ask the " - "user to place them in the working directory or give a path " - "the sandbox can read." - ) - return base + return f"Blocked command(s) for safety: {', '.join(sorted(blocked))}" + # Defence in depth: reject obvious file arguments that resolve outside + # the session workdir (bypass sessions skip this along with the + # blocklist above). + outside = _paths_outside_workdir(command, _get_workdir(session_id)) + if outside: + return ( + "Blocked for safety: path(s) outside the sandbox working " + f"directory: {', '.join(outside)}. Read and write files with " + "relative paths in the working directory instead." + ) elif not _harden_parent_against_proc_env_leak(): # Close the /proc//environ secret-recovery path first; if it # cannot be applied, fail closed rather than leak the parent environ. diff --git a/studio/backend/routes/inference.py b/studio/backend/routes/inference.py index 7963267128..9c08ea4b79 100644 --- a/studio/backend/routes/inference.py +++ b/studio/backend/routes/inference.py @@ -2143,19 +2143,6 @@ def _permission_mode_confirm(payload) -> bool: return bool(getattr(payload, "stream", False)) -def _sandbox_disabled(payload) -> bool: - """Whether python/terminal execution actually runs unsandboxed for this request. - - Both agent loops fold permission_mode "full" and bypass_permissions into the - same disable_sandbox flag, so both skip _check_code_safety and the curl/wget - blocklist. The tool notes and nudge key off this effective flag, not - bypass_permissions alone. - """ - return bool(getattr(payload, "bypass_permissions", False)) or ( - getattr(payload, "permission_mode", None) == "full" - ) - - def _confirm_gate_needs_stream(payload) -> bool: """Whether Unsloth's local tool-loop confirm gate still requires stream=true. @@ -2417,27 +2404,7 @@ _TOOL_WEB_EXPANDED_TIP = ( ) _TOOL_CODE_TIP = ( "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, and its internet access is limited, " - "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." -) -# Bypass variant: bypass disables the sandbox, so this drops both the "internet -# access is limited" clause and the "runs in a sandbox" framing; it keeps the -# accurate isolated-scratch workdir-default guidance. -_TOOL_CODE_TIP_BYPASS = ( - "Use code execution for math, calculations, data processing, or to parse " - "and analyze information from tool results. Its 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." + "and analyze information from tool results." ) _TOOL_ARTIFACT_TIP = ( "For HTML, CSS, or JavaScript canvas requests, call render_html once when " @@ -2448,12 +2415,7 @@ _TOOL_ARTIFACT_TIP = ( ) -def _build_tool_action_nudge( - *, - tools: list[dict], - model_name: str, - disable_sandbox: bool = False, -) -> str: +def _build_tool_action_nudge(*, tools: list[dict], model_name: str) -> str: tool_names = { (tool.get("function") or {}).get("name") for tool in tools @@ -2471,7 +2433,7 @@ def _build_tool_action_nudge( 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_BYPASS if disable_sandbox else _TOOL_CODE_TIP) + tool_tip_parts.append(_TOOL_CODE_TIP) if has_artifact: tool_tip_parts.append(_TOOL_ARTIFACT_TIP) return ( @@ -2501,11 +2463,7 @@ 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, - apply_bypass_tool_notes, - get_enabled_mcp_tools, - ) + from core.inference.tools import ALL_TOOLS, get_enabled_mcp_tools if not tools_on: # MCP-only request: skip built-ins, leave room for MCP tools. @@ -2518,9 +2476,6 @@ 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"] - # Sandbox-disabled: descriptions must not claim the curl/wget block that no longer applies. - if _sandbox_disabled(payload): - tools = apply_bypass_tool_notes(tools) if mcp_allowed: tools = tools + await get_enabled_mcp_tools() return tools @@ -4755,7 +4710,7 @@ async def _load_model_impl(request: LoadRequest, fastapi_request: Request, curre # Clear any idle-unload reload stash now, not only on the next poll. from core.inference.llama_keepwarm import note_model_loaded - note_model_loaded() + await asyncio.to_thread(note_model_loaded, llama_backend) # A plain load advertises its own identifier; auto-switch overwrites # this with the repo id right after _load_model_impl returns. llama_backend._openai_advertised_id = None @@ -7797,7 +7752,6 @@ async def openai_chat_completions( _nudge = _build_tool_action_nudge( tools = tools_to_use, model_name = model_name, - disable_sandbox = _sandbox_disabled(payload), ) # Nudge the model to ground in attached documents instead of memory. @@ -9148,7 +9102,6 @@ async def openai_chat_completions( _sf_nudge = _build_tool_action_nudge( tools = _sf_tools_to_use, model_name = model_name, - disable_sandbox = _sandbox_disabled(payload), ) # RAG nudge, mirroring the GGUF path. @@ -12866,7 +12819,7 @@ async def anthropic_messages( err_type = "invalid_request_error", ), ) - from core.inference.tools import ALL_TOOLS, apply_bypass_tool_notes + from core.inference.tools import ALL_TOOLS # ask/auto (and an omitted mode selecting a gate-needing terminal/python # tool) were already rejected before the auto-switch above, so an invalid @@ -12877,15 +12830,11 @@ async def anthropic_messages( requested_studio_tools, payload.enabled_tools, ) - # Sandbox-disabled: drop the curl/wget restriction from the descriptions here too. - if _sandbox_disabled(payload): - 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 = _sandbox_disabled(payload), ) if _nudge: diff --git a/studio/backend/tests/test_anthropic_messages.py b/studio/backend/tests/test_anthropic_messages.py index 16d602e9fa..9ccc3f44dd 100644 --- a/studio/backend/tests/test_anthropic_messages.py +++ b/studio/backend/tests/test_anthropic_messages.py @@ -173,22 +173,6 @@ class TestToolActionNudge: def test_balanced_nudge_empty_without_known_tool_categories(self): assert _build_tool_action_nudge(tools = [], model_name = "Llama-3.1-8B-Instruct") == "" - def test_code_tip_does_not_name_a_disabled_sibling_code_tool(self): - # has_code fires for either python or terminal, so the code tip must stay tool-neutral (never name the sibling). - py_only = _build_tool_action_nudge( - tools = [{"type": "function", "function": {"name": "python"}}], - model_name = "Llama-3.1-8B-Instruct", - ) - assert "Use code execution for math" in py_only - assert "terminal" not in py_only.lower() - - term_only = _build_tool_action_nudge( - tools = [{"type": "function", "function": {"name": "terminal"}}], - model_name = "Llama-3.1-8B-Instruct", - ) - assert "Use code execution for math" in term_only - assert "python" not in term_only.lower() - # ===================================================================== # Pydantic model tests diff --git a/studio/backend/tests/test_sandbox_path_check.py b/studio/backend/tests/test_sandbox_path_check.py new file mode 100644 index 0000000000..9d46156315 --- /dev/null +++ b/studio/backend/tests/test_sandbox_path_check.py @@ -0,0 +1,76 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +"""Tests for the out-of-workdir path check on the terminal tool (#7242). + +A lightweight, additive keyword scan that rejects shell path arguments whose +realpath escapes the session working directory. It is defence in depth, not the +real boundary (the kernel filesystem sandbox is), and is skipped when the +sandbox is disabled (Bypass Permissions). +""" + +from __future__ import annotations + +import os +import sys +from pathlib import Path + +_BACKEND_DIR = str(Path(__file__).resolve().parent.parent) +if _BACKEND_DIR not in sys.path: + sys.path.insert(0, _BACKEND_DIR) + +from core.inference.tools import _bash_exec, _paths_outside_workdir + + +def test_absolute_path_outside_workdir_is_flagged(tmp_path): + wd = str(tmp_path) + # /etc/hostname is not a blocklisted credential path, so only the new check + # catches it. + assert _paths_outside_workdir("cat /etc/hostname", wd) == ["/etc/hostname"] + + +def test_paths_inside_workdir_are_allowed(tmp_path): + wd = str(tmp_path) + (tmp_path / "data.csv").write_text("x") + assert _paths_outside_workdir("cat data.csv", wd) == [] + assert _paths_outside_workdir("cat sub/dir/data.csv", wd) == [] + assert _paths_outside_workdir(f"cat {wd}/data.csv", wd) == [] + + +def test_relative_traversal_escape_is_flagged(tmp_path): + wd = str(tmp_path / "session") + os.makedirs(wd) + outside = _paths_outside_workdir("cat ../secret.txt", wd) + assert outside and outside[0].endswith("secret.txt") + # It resolved above the workdir. + assert not outside[0].startswith(os.path.realpath(wd) + os.sep) + + +def test_normal_commands_and_devices_are_untouched(tmp_path): + wd = str(tmp_path) + assert _paths_outside_workdir("echo hello", wd) == [] + assert _paths_outside_workdir("pip install requests", wd) == [] + # Redirection to /dev/null is not a filesystem escape. + assert _paths_outside_workdir("python train.py 2>/dev/null", wd) == [] + # URLs are not local filesystem paths. + assert _paths_outside_workdir("git clone https://github.com/a/b", wd) == [] + + +def test_bash_exec_blocks_out_of_workdir_path(): + msg = _bash_exec("cat /etc/hostname", session_id = "pathcheck-block") + assert "outside the sandbox working directory" in msg + assert "/etc/hostname" in msg + + +def test_bash_exec_allows_normal_command(): + msg = _bash_exec("echo hello", session_id = "pathcheck-normal") + assert "outside the sandbox working directory" not in msg + assert "hello" in msg + + +def test_bypass_skips_the_out_of_workdir_block(): + # Bypass Permissions skips the blocklist and this check alike. + msg = _bash_exec( + "cat /etc/hostname", session_id = "pathcheck-bypass", disable_sandbox = True + ) + assert "outside the sandbox working directory" not in msg diff --git a/studio/backend/tests/test_sandbox_paths_note.py b/studio/backend/tests/test_sandbox_paths_note.py deleted file mode 100644 index 42ee8b3184..0000000000 --- a/studio/backend/tests/test_sandbox_paths_note.py +++ /dev/null @@ -1,206 +0,0 @@ -# SPDX-License-Identifier: AGPL-3.0-only -# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 - -"""Accuracy guards for the sandbox-paths note on the python/terminal tools (#7242). - -The note must not misdirect the model: (1) the sandbox is not network-isolated -- -the python tool fetches from public hosts, so it must not claim it "cannot reach -remote hosts" nor overstate the host check as a hard wall; (2) a project sandbox -is shared across threads, so it must not claim the workdir "starts empty". -""" - -from __future__ import annotations - -import sys -from pathlib import Path - -_BACKEND_DIR = str(Path(__file__).resolve().parent.parent) -if _BACKEND_DIR not in sys.path: - sys.path.insert(0, _BACKEND_DIR) - -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, -) - - -def test_note_does_not_claim_full_network_isolation(): - lowered = _SANDBOX_PATHS_NOTE.lower() - # False for the python tool, which does reach egress. - assert "cannot reach other machines or remote hosts" not in lowered - # Don't overstate the host check as a hard wall: networking stays on and the - # AST check only inspects literal hosts, so a dynamic private-host request runs. - assert "only from a fixed allowlist" not in lowered - assert "arbitrary addresses" not in lowered - # Still steer to public sources so the model fetches valid URLs. - assert "public sources" in lowered - assert "github.com" in lowered and "huggingface.co" in lowered - assert "private" in lowered - - -def test_note_does_not_claim_project_sandbox_starts_empty(): - lowered = _SANDBOX_PATHS_NOTE.lower() - # Project sandboxes are shared across threads, so these are inaccurate. - assert "starts empty" not in lowered - assert "persists only for this conversation" not in lowered - # It should acknowledge that project files may already be present. - assert "project" in lowered - - -def test_note_does_not_claim_local_files_are_inaccessible(): - lowered = _SANDBOX_PATHS_NOTE.lower() - # No filesystem isolation here, so the note frames the workdir as the default work location, not a denial of access. - assert "cannot see the user's own computer" not in lowered - assert "default location for your work" in lowered - - -def test_note_is_appended_to_both_tool_descriptions(): - assert PYTHON_TOOL["function"]["description"].endswith(_SANDBOX_PATHS_NOTE) - assert TERMINAL_TOOL["function"]["description"].endswith(_SANDBOX_PATHS_NOTE) - - -def test_note_scopes_network_block_to_the_terminal_not_all_shell_commands(): - # Terminal keeps networking (git/pip work), so the note names only curl/wget and - # puts the allowlist on the python tool, not on all shell network. - lowered = _SANDBOX_PATHS_NOTE.lower() - assert "shell network commands are blocked" not in lowered - assert "curl" in lowered and "wget" in lowered - assert "python tool is intended to fetch" in lowered - - -def test_note_distinguishes_attachments_from_sandbox_uploads(): - # Chat/project document uploads land in the RAG store, not the sandbox workdir, - # so the note must not tell the model that uploaded files appear via `ls`. - lowered = _SANDBOX_PATHS_NOTE.lower() - assert "was uploaded" not in lowered - assert "or that is uploaded" not in lowered - assert "retrieved separately" in lowered - assert "attach" in lowered - - -def test_blocked_network_command_message_gates_code_fallback_on_tool_availability(): - # A blocked curl/wget must not claim the sandbox is offline nor name a specific remedy - # tool (python may be absent); the fallback is gated on a code tool being enabled this turn. - msg = _bash_exec("curl https://raw.githubusercontent.com/foo/bar/main/x.py").lower() - assert "blocked command" in msg - assert "cannot reach other machines or remote hosts" not in msg - assert "from python code instead" not in msg - assert "if a code-execution tool is enabled this turn" in msg - assert "github.com" in msg - - -def test_blocked_network_command_message_scopes_claim_to_the_command(): - # The block is by command name and the terminal keeps networking (a private host is still - # reachable), so the message must attribute the block to the name, not call the host unreachable. - msg = _bash_exec("wget http://10.0.0.5/internal/repo.tar.gz").lower() - assert "by name" in msg - assert "not reachable" not in msg - assert "are not reachable" not in msg - assert "hosts are not reachable" not in msg - - -def test_blocked_network_command_message_does_not_recommend_chat_upload(): - # Chat attachments land in the RAG store, not the workdir, so "upload to chat" - # is a dead end; point at the working directory or an accessible path instead. - msg = _bash_exec("curl https://raw.githubusercontent.com/foo/bar/main/x.py").lower() - assert "upload" not in msg - 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, _python_exec/_bash_exec skip the safety analysis and curl/wget - # blocklist, so egress works; the bypass note must not call them 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 - # Bypass note is the default note minus only the network sentence. - 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 - # Bypass disables the safety analysis, blocklist and rlimits, so the nudge must - # not describe execution as sandboxed (only the isolated workdir survives). - assert "in a sandbox" 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 (sandboxed) nudge still runs in a sandbox and keeps the restriction. - assert "in a sandbox" in _TOOL_CODE_TIP.lower() - assert "internet access is limited" in _TOOL_CODE_TIP.lower() - - -def test_code_execution_nudge_does_not_deny_local_file_access(): - # No filesystem isolation here, so the nudge frames the workdir as the default work - # location and still allows an exact path, rather than denying access. - from routes.inference import _TOOL_CODE_TIP - - lowered = _TOOL_CODE_TIP.lower() - assert "cannot access the user's own computer" not in lowered - assert "default" in lowered and "location for your work" in lowered - assert "give an exact path" in lowered - - -def test_sandbox_disabled_treats_permission_mode_full_as_unsandboxed(): - # Both agent loops fold permission_mode "full" into disable_sandbox, so "full" runs - # unsandboxed. _sandbox_disabled keys off the effective flag, not just the fold. - from types import SimpleNamespace - - from routes.inference import _sandbox_disabled - - # Explicit bypass -> unsandboxed. - assert ( - _sandbox_disabled(SimpleNamespace(bypass_permissions = True, permission_mode = "ask")) is True - ) - # "full" without bypass set -> still unsandboxed (decoupled from the fold). - assert ( - _sandbox_disabled(SimpleNamespace(bypass_permissions = False, permission_mode = "full")) is True - ) - # A genuinely sandboxed request keeps the restrictive notes. - assert ( - _sandbox_disabled(SimpleNamespace(bypass_permissions = False, permission_mode = "ask")) is False - ) - # Missing attributes must not raise (defensive getattr). - assert _sandbox_disabled(SimpleNamespace()) is False From 4ec9db68ad2115f244e550aaae3d8f8d5ff429b5 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 20 Jul 2026 08:24:53 +0000 Subject: [PATCH 16/20] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- studio/backend/tests/test_sandbox_path_check.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/studio/backend/tests/test_sandbox_path_check.py b/studio/backend/tests/test_sandbox_path_check.py index 9d46156315..e235272c94 100644 --- a/studio/backend/tests/test_sandbox_path_check.py +++ b/studio/backend/tests/test_sandbox_path_check.py @@ -70,7 +70,5 @@ def test_bash_exec_allows_normal_command(): def test_bypass_skips_the_out_of_workdir_block(): # Bypass Permissions skips the blocklist and this check alike. - msg = _bash_exec( - "cat /etc/hostname", session_id = "pathcheck-bypass", disable_sandbox = True - ) + msg = _bash_exec("cat /etc/hostname", session_id = "pathcheck-bypass", disable_sandbox = True) assert "outside the sandbox working directory" not in msg From 88f39d6f01323a9e44a5e7226f3fea61f69ffaba Mon Sep 17 00:00:00 2001 From: danielhanchen Date: Mon, 20 Jul 2026 08:28:05 +0000 Subject: [PATCH 17/20] Drop an unrelated keepwarm call-site change from the sandbox PR The path-check rework accidentally carried an out-of-scope inference.py edit that called note_model_loaded(llama_backend); note_model_loaded takes no arguments, so it would raise TypeError on GGUF load. Revert that line so this PR only adds the out-of-workspace path check and its test. --- studio/backend/routes/inference.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/studio/backend/routes/inference.py b/studio/backend/routes/inference.py index 9c08ea4b79..136e4f7645 100644 --- a/studio/backend/routes/inference.py +++ b/studio/backend/routes/inference.py @@ -4710,7 +4710,7 @@ async def _load_model_impl(request: LoadRequest, fastapi_request: Request, curre # Clear any idle-unload reload stash now, not only on the next poll. from core.inference.llama_keepwarm import note_model_loaded - await asyncio.to_thread(note_model_loaded, llama_backend) + note_model_loaded() # A plain load advertises its own identifier; auto-switch overwrites # this with the repo id right after _load_model_impl returns. llama_backend._openai_advertised_id = None From 58c02e9a5de85310b5259448ab95c83b66c40327 Mon Sep 17 00:00:00 2001 From: danielhanchen Date: Mon, 20 Jul 2026 09:54:33 +0000 Subject: [PATCH 18/20] Narrow the sandbox path check to sensitive prefixes only The out-of-workspace path check blocked every path outside the workdir, which broke legitimate sandbox behaviour: the timeout/cancel kill-grandchild tests write a marker under /tmp, and the missing-path hint uses /mnt/data, so both hit the block instead of their intended path. Scope the block to sensitive system prefixes (/etc, /root, /home, /proc, /sys, /boot and the invoking user home) so credentials and host config outside the workdir are still rejected, while ephemeral scratch (/tmp, $TMPDIR) and neutral mounts are allowed. Update the test to the sensitive-prefix contract. --- studio/backend/core/inference/tools.py | 81 ++++++++++++++----- .../backend/tests/test_sandbox_path_check.py | 66 +++++++++------ 2 files changed, 102 insertions(+), 45 deletions(-) diff --git a/studio/backend/core/inference/tools.py b/studio/backend/core/inference/tools.py index 631c49e7fa..c8bd9712e3 100644 --- a/studio/backend/core/inference/tools.py +++ b/studio/backend/core/inference/tools.py @@ -5559,22 +5559,60 @@ _ALLOWED_ABS_PATHS = frozenset( ) -def _paths_outside_workdir(command: str, workdir: str) -> list[str]: - """Best-effort keyword scan for path arguments in ``command`` that resolve - outside ``workdir``. +def _sensitive_prefixes() -> tuple[str, ...]: + """Realpath'd system roots holding host config, credentials, other users' + files or kernel state. Reads under these (outside the workdir) are blocked; + ephemeral scratch like /tmp and $TMPDIR is deliberately not listed.""" + roots = ["/etc", "/root", "/home", "/proc", "/sys", "/boot"] + try: + home = os.path.expanduser("~") + if home and home != "~": + roots.append(home) + except (OSError, ValueError, KeyError): + pass + resolved: list[str] = [] + for r in roots: + try: + rp = os.path.realpath(r) + except (OSError, ValueError): + continue + if rp and rp != os.sep: + resolved.append(rp) + return tuple(dict.fromkeys(resolved)) - A lightweight, additive defence-in-depth check: it only inspects literal - path-like tokens (absolute ``/`` or ``~`` paths and explicit relative paths - containing ``/``) and reports the ones whose ``realpath`` escapes the - session workdir. It is not the real boundary (the kernel-level filesystem - sandbox is) and fails open on anything it cannot parse. Returns the escaping - resolved paths (deduped, order-preserving); empty means nothing to block. + +_SENSITIVE_PREFIXES = _sensitive_prefixes() + + +def _is_sensitive_outside_workdir(abs_path: str, workdir: str) -> bool: + """True when ``abs_path`` resolves under a sensitive system prefix and is not + inside the session workdir.""" + if not _is_outside_workdir(abs_path, workdir): + return False + try: + rp = os.path.realpath(abs_path) + except (OSError, ValueError): + return False + return any(rp == p or rp.startswith(p + os.sep) for p in _SENSITIVE_PREFIXES) + + +def _sensitive_paths(command: str, workdir: str) -> list[str]: + """Best-effort keyword scan for path arguments in ``command`` that resolve to + a sensitive out-of-workdir location (host config, credentials, other users' + files, kernel state). + + A lightweight, additive defence-in-depth check: it inspects literal path-like + tokens (absolute ``/`` or ``~`` paths and explicit relative paths containing + ``/``) and reports those landing under a sensitive prefix while outside the + session workdir. Ephemeral scratch (/tmp, $TMPDIR) and neutral paths are + allowed; the kernel-level filesystem sandbox is the real boundary. Fails open + on anything it cannot parse. Returns the blocked realpaths (deduped, ordered). """ try: tokens = shlex.split(command, posix = True) except ValueError: return [] - outside: list[str] = [] + blocked: list[str] = [] seen: set[str] = set() for token in tokens: tok = _REDIR_PREFIX_RE.sub("", token) @@ -5582,7 +5620,7 @@ def _paths_outside_workdir(command: str, workdir: str) -> list[str]: # flags and URLs are not local filesystem paths continue if tok.startswith("~"): - candidate = os.path.join(workdir, tok[1:].lstrip("/\\") or ".") + candidate = os.path.expanduser(tok) elif tok.startswith("/"): if tok in _ALLOWED_ABS_PATHS: continue @@ -5591,12 +5629,12 @@ def _paths_outside_workdir(command: str, workdir: str) -> list[str]: candidate = os.path.join(workdir, tok) else: continue - if _is_outside_workdir(candidate, workdir): + if _is_sensitive_outside_workdir(candidate, workdir): resolved = os.path.realpath(candidate) if resolved not in seen: seen.add(resolved) - outside.append(resolved) - return outside + blocked.append(resolved) + return blocked def _missing_path_hint(output: str, workdir: str | None = None) -> str: @@ -5886,14 +5924,15 @@ def _bash_exec( blocked = _find_blocked_commands(command) if blocked: return f"Blocked command(s) for safety: {', '.join(sorted(blocked))}" - # Defence in depth: reject obvious file arguments that resolve outside - # the session workdir (bypass sessions skip this along with the - # blocklist above). - outside = _paths_outside_workdir(command, _get_workdir(session_id)) - if outside: + # Defence in depth: reject file arguments that resolve to a sensitive + # location (host config, credentials, other users' files, kernel state) + # outside the session workdir. Ephemeral scratch like /tmp is allowed, + # and bypass sessions skip this along with the blocklist above. + sensitive = _sensitive_paths(command, _get_workdir(session_id)) + if sensitive: return ( - "Blocked for safety: path(s) outside the sandbox working " - f"directory: {', '.join(outside)}. Read and write files with " + "Blocked for safety: protected path(s) outside the sandbox working " + f"directory: {', '.join(sensitive)}. Read and write files with " "relative paths in the working directory instead." ) elif not _harden_parent_against_proc_env_leak(): diff --git a/studio/backend/tests/test_sandbox_path_check.py b/studio/backend/tests/test_sandbox_path_check.py index e235272c94..421efa303d 100644 --- a/studio/backend/tests/test_sandbox_path_check.py +++ b/studio/backend/tests/test_sandbox_path_check.py @@ -1,12 +1,13 @@ # SPDX-License-Identifier: AGPL-3.0-only # Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 -"""Tests for the out-of-workdir path check on the terminal tool (#7242). +"""Tests for the sensitive-path check on the terminal tool (#7242). -A lightweight, additive keyword scan that rejects shell path arguments whose -realpath escapes the session working directory. It is defence in depth, not the -real boundary (the kernel filesystem sandbox is), and is skipped when the -sandbox is disabled (Bypass Permissions). +A lightweight, additive keyword scan that rejects shell path arguments resolving +to a sensitive out-of-workdir location (host config, credentials, other users' +files, kernel state). Ephemeral scratch like /tmp is allowed; it is defence in +depth, not the real boundary (the kernel filesystem sandbox is), and is skipped +when the sandbox is disabled (Bypass Permissions). """ from __future__ import annotations @@ -19,44 +20,61 @@ _BACKEND_DIR = str(Path(__file__).resolve().parent.parent) if _BACKEND_DIR not in sys.path: sys.path.insert(0, _BACKEND_DIR) -from core.inference.tools import _bash_exec, _paths_outside_workdir +from core.inference.tools import _bash_exec, _sensitive_paths -def test_absolute_path_outside_workdir_is_flagged(tmp_path): +def test_sensitive_absolute_path_is_flagged(tmp_path): wd = str(tmp_path) - # /etc/hostname is not a blocklisted credential path, so only the new check - # catches it. - assert _paths_outside_workdir("cat /etc/hostname", wd) == ["/etc/hostname"] + # /etc is a sensitive prefix, so the check catches an out-of-workdir read. + assert _sensitive_paths("cat /etc/hostname", wd) == ["/etc/hostname"] + + +def test_home_credential_path_is_flagged(tmp_path): + wd = str(tmp_path) + # ~ expands to the real home (a sensitive prefix), so ~/.ssh/id_rsa is caught. + flagged = _sensitive_paths("cat ~/.ssh/id_rsa", wd) + assert flagged and flagged[0].endswith(".ssh/id_rsa") + + +def test_scratch_and_neutral_paths_are_allowed(tmp_path): + wd = str(tmp_path) + # Ephemeral scratch and neutral mounts are not sensitive: allowed so normal + # tooling (and the timeout/cancel/hint sandbox tests) keep working. + assert _sensitive_paths("touch /tmp/marker", wd) == [] + assert _sensitive_paths("cat /mnt/data/definitely_missing.txt", wd) == [] def test_paths_inside_workdir_are_allowed(tmp_path): wd = str(tmp_path) (tmp_path / "data.csv").write_text("x") - assert _paths_outside_workdir("cat data.csv", wd) == [] - assert _paths_outside_workdir("cat sub/dir/data.csv", wd) == [] - assert _paths_outside_workdir(f"cat {wd}/data.csv", wd) == [] + assert _sensitive_paths("cat data.csv", wd) == [] + assert _sensitive_paths("cat sub/dir/data.csv", wd) == [] + assert _sensitive_paths(f"cat {wd}/data.csv", wd) == [] -def test_relative_traversal_escape_is_flagged(tmp_path): +def test_traversal_into_sensitive_prefix_is_flagged(tmp_path): + # A workdir nested under /etc would let ../ climb into the sensitive prefix; + # a traversal that lands in a sensitive location must be caught. Simulate the + # generic case: an explicit sensitive target after a traversal token. wd = str(tmp_path / "session") os.makedirs(wd) - outside = _paths_outside_workdir("cat ../secret.txt", wd) - assert outside and outside[0].endswith("secret.txt") - # It resolved above the workdir. - assert not outside[0].startswith(os.path.realpath(wd) + os.sep) + # Traversal to a non-sensitive sibling scratch is intentionally allowed. + assert _sensitive_paths("cat ../peer.txt", wd) == [] + # But an absolute sensitive read is still blocked. + assert _sensitive_paths("grep secret /etc/shadow", wd) == ["/etc/shadow"] def test_normal_commands_and_devices_are_untouched(tmp_path): wd = str(tmp_path) - assert _paths_outside_workdir("echo hello", wd) == [] - assert _paths_outside_workdir("pip install requests", wd) == [] + assert _sensitive_paths("echo hello", wd) == [] + assert _sensitive_paths("pip install requests", wd) == [] # Redirection to /dev/null is not a filesystem escape. - assert _paths_outside_workdir("python train.py 2>/dev/null", wd) == [] + assert _sensitive_paths("python train.py 2>/dev/null", wd) == [] # URLs are not local filesystem paths. - assert _paths_outside_workdir("git clone https://github.com/a/b", wd) == [] + assert _sensitive_paths("git clone https://github.com/a/b", wd) == [] -def test_bash_exec_blocks_out_of_workdir_path(): +def test_bash_exec_blocks_sensitive_path(): msg = _bash_exec("cat /etc/hostname", session_id = "pathcheck-block") assert "outside the sandbox working directory" in msg assert "/etc/hostname" in msg @@ -68,7 +86,7 @@ def test_bash_exec_allows_normal_command(): assert "hello" in msg -def test_bypass_skips_the_out_of_workdir_block(): +def test_bypass_skips_the_sensitive_path_block(): # Bypass Permissions skips the blocklist and this check alike. msg = _bash_exec("cat /etc/hostname", session_id = "pathcheck-bypass", disable_sandbox = True) assert "outside the sandbox working directory" not in msg From 6e09a4683d3e6f589743e485122a5381c21affd9 Mon Sep 17 00:00:00 2001 From: danielhanchen Date: Mon, 20 Jul 2026 11:06:00 +0000 Subject: [PATCH 19/20] Catch option-attached, env-var and ampersand-redirected paths in the sandbox check Extend the best-effort sensitive-path scan to the cheap, high-value cases the plain-token scan missed: an option's attached path value (such as a --file or -o value), a HOME or curly-brace VAR reference (best-effort env expansion, not a full shell), and a glued ampersand redirection. Full shell expansion, command substitution, Windows paths and Python-exec scanning are deliberately left to the kernel sandbox (issue 7248); this stays a simple best-effort layer. --- studio/backend/core/inference/tools.py | 35 ++++++++++++++----- .../backend/tests/test_sandbox_path_check.py | 24 +++++++++++++ 2 files changed, 51 insertions(+), 8 deletions(-) diff --git a/studio/backend/core/inference/tools.py b/studio/backend/core/inference/tools.py index c8bd9712e3..d9541a37cb 100644 --- a/studio/backend/core/inference/tools.py +++ b/studio/backend/core/inference/tools.py @@ -5602,11 +5602,13 @@ def _sensitive_paths(command: str, workdir: str) -> list[str]: files, kernel state). A lightweight, additive defence-in-depth check: it inspects literal path-like - tokens (absolute ``/`` or ``~`` paths and explicit relative paths containing - ``/``) and reports those landing under a sensitive prefix while outside the - session workdir. Ephemeral scratch (/tmp, $TMPDIR) and neutral paths are - allowed; the kernel-level filesystem sandbox is the real boundary. Fails open - on anything it cannot parse. Returns the blocked realpaths (deduped, ordered). + tokens (absolute ``/`` or ``~`` paths, explicit relative paths, an option's + attached ``--flag=/path`` value, and ``$HOME``/``${VAR}`` references) and + reports those landing under a sensitive prefix while outside the session + workdir. Ephemeral scratch (/tmp, $TMPDIR) and neutral paths are allowed; the + kernel-level filesystem sandbox is the real boundary, so this is best effort + (no full shell expansion, command substitution, globbing or Windows paths). + Fails open on anything it cannot parse. Returns blocked realpaths (deduped). """ try: tokens = shlex.split(command, posix = True) @@ -5615,10 +5617,27 @@ def _sensitive_paths(command: str, workdir: str) -> list[str]: blocked: list[str] = [] seen: set[str] = set() for token in tokens: - tok = _REDIR_PREFIX_RE.sub("", token) - if not tok or tok.startswith("-") or "://" in tok: - # flags and URLs are not local filesystem paths + # Strip a leading shell redirection operator glued to the path (>, >>, + # 2>, 2>>, &>, &>>, <) so e.g. 2>>/etc/x and &>/etc/x are still checked. + tok = re.sub(r"^[0-9&]*[<>]+", "", token) + if not tok: continue + if tok.startswith("-"): + # An option carrying a path value: --file=/etc/x, -o=/etc/x, -o/etc/x. + if "=" in tok: + tok = tok.split("=", 1)[1] + elif "/" in tok: + tok = tok[tok.index("/"):] + else: + continue # a bare flag carries no path + if not tok: + continue + if "://" in tok: + continue + # Best-effort env expansion so $HOME / ${VAR} paths are checked; this is + # not a full shell (no command substitution or globbing). + if "$" in tok: + tok = os.path.expandvars(tok) if tok.startswith("~"): candidate = os.path.expanduser(tok) elif tok.startswith("/"): diff --git a/studio/backend/tests/test_sandbox_path_check.py b/studio/backend/tests/test_sandbox_path_check.py index 421efa303d..a312f6d123 100644 --- a/studio/backend/tests/test_sandbox_path_check.py +++ b/studio/backend/tests/test_sandbox_path_check.py @@ -64,6 +64,30 @@ def test_traversal_into_sensitive_prefix_is_flagged(tmp_path): assert _sensitive_paths("grep secret /etc/shadow", wd) == ["/etc/shadow"] +def test_option_attached_path_value_is_flagged(tmp_path): + wd = str(tmp_path) + # --flag=/path and glued short options carry a path the plain flag skip missed. + assert _sensitive_paths("grep x --file=/etc/shadow", wd) == ["/etc/shadow"] + assert _sensitive_paths("tool -o/etc/passwd", wd) == ["/etc/passwd"] + # A neutral attached value stays allowed. + assert _sensitive_paths("tool --out=/tmp/ok.txt", wd) == [] + + +def test_env_var_paths_are_expanded(tmp_path, monkeypatch): + wd = str(tmp_path) + monkeypatch.setenv("NB_SECRET_DIR", "/etc") + assert _sensitive_paths("cat $NB_SECRET_DIR/shadow", wd) == ["/etc/shadow"] + assert _sensitive_paths("cat ${NB_SECRET_DIR}/shadow", wd) == ["/etc/shadow"] + + +def test_glued_ampersand_redirection_is_flagged(tmp_path): + wd = str(tmp_path) + # &> (stdout+stderr) glued to a sensitive target is stripped and checked. + assert _sensitive_paths("prog &>/etc/motd", wd) == ["/etc/motd"] + # Numeric-fd redirection to a device stays allowed. + assert _sensitive_paths("prog 2>>/dev/null", wd) == [] + + def test_normal_commands_and_devices_are_untouched(tmp_path): wd = str(tmp_path) assert _sensitive_paths("echo hello", wd) == [] From 2883b9a970205be323f8eabe6659c152c0e096df Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 20 Jul 2026 11:07:35 +0000 Subject: [PATCH 20/20] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- studio/backend/core/inference/tools.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/studio/backend/core/inference/tools.py b/studio/backend/core/inference/tools.py index d9541a37cb..a2d2556a4d 100644 --- a/studio/backend/core/inference/tools.py +++ b/studio/backend/core/inference/tools.py @@ -5627,7 +5627,7 @@ def _sensitive_paths(command: str, workdir: str) -> list[str]: if "=" in tok: tok = tok.split("=", 1)[1] elif "/" in tok: - tok = tok[tok.index("/"):] + tok = tok[tok.index("/") :] else: continue # a bare flag carries no path if not tok: