Compare commits

...
Sign in to create a new pull request.

22 commits

Author SHA1 Message Date
pre-commit-ci[bot]
2883b9a970 [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2026-07-20 11:07:39 +00:00
danielhanchen
6e09a4683d 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.
2026-07-20 11:06:00 +00:00
danielhanchen
58c02e9a5d 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.
2026-07-20 09:54:33 +00:00
danielhanchen
88f39d6f01 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.
2026-07-20 08:29:30 +00:00
pre-commit-ci[bot]
4ec9db68ad [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2026-07-20 08:24:57 +00:00
danielhanchen
937cc05b0a 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.
2026-07-20 08:23:47 +00:00
danielhanchen
339dded90a 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.
2026-07-20 04:59:29 +00:00
danielhanchen
2e91a44202 Tighten comments in the sandbox isolation hint path 2026-07-20 04:42:58 +00:00
danielhanchen
f5b9e1a4ab Merge remote-tracking branch 'origin/main' into HEAD 2026-07-20 03:28:44 +00:00
danielhanchen
8d50555a2d Merge remote-tracking branch 'origin/main' into HEAD 2026-07-19 19:26:20 +00:00
danielhanchen
830af5f627 studio: tighten comments in the sandbox isolation hint 2026-07-19 17:03:06 +00:00
pre-commit-ci[bot]
8d99c172a1 [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2026-07-19 16:14:09 +00:00
danielhanchen
e7aed125d6 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.
2026-07-19 16:13:27 +00:00
danielhanchen
fda0c11682 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.
2026-07-19 15:40:02 +00:00
pre-commit-ci[bot]
10f6db4f3e [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2026-07-19 15:12:00 +00:00
danielhanchen
9ddd563fd2 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.
2026-07-19 15:11:12 +00:00
danielhanchen
adf2a468bd 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.
2026-07-19 14:43:43 +00:00
danielhanchen
9471bc12d3 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.
2026-07-19 14:15:33 +00:00
danielhanchen
04434151f9 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.
2026-07-19 12:54:44 +00:00
danielhanchen
c2903da6a5 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.
2026-07-19 11:39:02 +00:00
danielhanchen
57a73f34d5 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.
2026-07-19 11:26:20 +00:00
danielhanchen
61523b73f1 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.
2026-07-19 10:41:34 +00:00
2 changed files with 241 additions and 0 deletions

View file

@ -5542,6 +5542,120 @@ 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 _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))
_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, 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)
except ValueError:
return []
blocked: list[str] = []
seen: set[str] = set()
for token in tokens:
# 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("/"):
if tok in _ALLOWED_ABS_PATHS:
continue
candidate = tok
elif "/" in tok:
candidate = os.path.join(workdir, tok)
else:
continue
if _is_sensitive_outside_workdir(candidate, workdir):
resolved = os.path.realpath(candidate)
if resolved not in seen:
seen.add(resolved)
blocked.append(resolved)
return blocked
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).
@ -5829,6 +5943,17 @@ def _bash_exec(
blocked = _find_blocked_commands(command)
if blocked:
return f"Blocked command(s) for safety: {', '.join(sorted(blocked))}"
# 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: 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():
# Close the /proc/<parent>/environ secret-recovery path first; if it
# cannot be applied, fail closed rather than leak the parent environ.

View file

@ -0,0 +1,116 @@
# 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 sensitive-path check on the terminal tool (#7242).
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
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, _sensitive_paths
def test_sensitive_absolute_path_is_flagged(tmp_path):
wd = str(tmp_path)
# /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 _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_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)
# 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_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) == []
assert _sensitive_paths("pip install requests", wd) == []
# Redirection to /dev/null is not a filesystem escape.
assert _sensitive_paths("python train.py 2>/dev/null", wd) == []
# URLs are not local filesystem paths.
assert _sensitive_paths("git clone https://github.com/a/b", wd) == []
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
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_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