Drop the network-restriction note in Bypass Permissions sessions
With bypass_permissions=true the tool loop passes disable_sandbox=true, so _python_exec skips _check_code_safety and _bash_exec skips the curl/wget blocklist (there is no network namespace; the allowlist is enforced only by that AST host check and the bash blocklist). The static sandbox note and code nudge then falsely tell the model curl/wget and arbitrary hosts are blocked, which can stall a full-permissions fetch of a user-supplied remote resource. Build the python/terminal descriptions and the code nudge per request: in bypass mode drop the allowlist/curl-wget sentence and the internet-limited clause, while sandboxed sessions keep the precise restriction. Gate strictly on bypass_permissions; permission_mode=full only suppresses the confirm gate and leaves the blocklist and allowlist enforced, so its wording must stay accurate. The default note stays byte-identical (concatenated from the same text), and apply_bypass_tool_notes swaps only the python/terminal descriptions without mutating the shared tool globals. Add tests for both modes.
This commit is contained in:
parent
adf2a468bd
commit
9ddd563fd2
3 changed files with 161 additions and 7 deletions
|
|
@ -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}$")
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue