Compare commits

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

33 commits

Author SHA1 Message Date
pre-commit-ci[bot]
ea05945070 [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2026-05-24 16:01:12 +00:00
danielhanchen
7a8c07f216 studio/sandbox: close 4 bypass classes from round-7 audit
Round-7 sonnet-panel review found four more concrete bypass
classes. All four are now closed (670 tests passing, 41 new R7
regression tests):

1. Deep path traversal through home prefix. ``~/foo/../../etc/shadow``,
   ``~/a/b/c/../../../../etc/shadow``, and chains where a regular
   segment precedes the ``..`` run slipped because the previous
   escape check only fired when the tail STARTED with ``..``.
   ``_tail_escapes_home`` now walks the tail with a depth counter
   and returns true as soon as depth goes negative, matching the
   runtime resolve when HOME is a single-segment path like
   ``/root``.

2. Brace false-positive for benign user-data listings.
   ``cat ~/data/{maps,routes}`` and ``cat /home/u/{maps,docs}/file``
   were blocked because the single unscoped brace regex matched
   ``maps`` regardless of root. The defence is now split per root:
   ``_HOME_BRACE_RE`` (home credentials), ``_ETC_BRACE_RE``
   (/etc), ``_PROC_BRACE_RE`` (per-process state names like
   ``maps`` / ``mem`` / ``environ`` only fire here), and
   ``_VAR_SPOOL_BRACE_RE`` (cron). The proc / cron generic names
   no longer fire on home or local paths.

3. BinOp.Add depth cap. ``open('/' + 'e' + 't' + 'c' + ...)``
   chains over ~63 operands hit the 64-level recursion cap in
   ``_extract_string_from_node`` and resolved to ``None``, so
   the sensitive literal escaped detection. Both
   ``_extract_string_from_node`` and ``_extract_string_literal``
   now flatten left-leaning ``+`` chains iteratively in one
   pass, so arbitrarily long concatenations resolve.

4. NetworkAndIoVisitor module rebinding. ``import shutil as sh``
   was tracked, but the plain ``import shutil; sh = shutil``
   (a Name = Name assignment) was not, so
   ``sh.copytree('~/.ssh', dst)`` slipped past the
   ``NetworkAndIoVisitor`` shutil-copy gate. A new
   ``visit_Assign`` propagates pathlib, shutil, builtins, and
   ``pathlib.Path`` class aliases across rebinding, mirroring
   ``SignalEscapeVisitor.visit_Assign`` so the two visitors are
   independently correct regardless of execution order.

Cumulative bypass closures across rounds 1 through 7: 28
distinct classes, 670 regression tests, three-OS green.
2026-05-24 15:58:28 +00:00
pre-commit-ci[bot]
c7c2e70559 [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2026-05-24 15:14:50 +00:00
danielhanchen
eae716675b studio/sandbox: close 7 bypass classes from cross-reviewer round-6 audit
Round-6 sonnet-panel review surfaced seven more concrete bypass
classes. All seven are now closed (629 tests passing, 60 new R6
regression tests):

1. Path traversal under home prefix. `~/../etc/shadow`,
   `~root/../etc/shadow`, `~ubuntu/../../etc/shadow`, and
   `/home/u/../u/.aws/credentials` all slipped because
   `_normalize_path_separators` re-attached the home prefix even
   when `..` had escaped it. Now when the tail of a home-prefixed
   path begins with `..`, the projection is treated as absolute
   (mirroring the runtime resolve when HOME is a single-segment path
   like `/root`). POSIX `~<user>/` tilde-user expansion is
   handled the same way.

2. Pandas / numpy reader keyword arguments. `pd.read_csv(filepath_or_buffer='/etc/shadow')`,
   `pd.read_excel(io=...)`, `np.fromfile(fname=...)` used the actual
   API parameter names that the previous gate's `{"file", "path"}`
   kwarg list missed. The kwarg set is expanded to cover
   `filepath_or_buffer`, `path_or_buf`, `fname`, `filename`, `io`,
   `buf`, `source`, `src` and a few common variants.

3. Bash directory exfil verbs. `cp -r ~/.ssh /tmp/out`, `mv ~/.aws /tmp`,
   `tar czf out.tar.gz ~/.ssh`, `rsync -av ~/.aws/ /tmp/`,
   `zip -r out.zip ~/.ssh` previously slipped because
   `_find_sensitive_paths` only flagged named files, leaving the
   bash side asymmetric to the Python shutil dir-exfil gate. A new
   `_BASH_DIR_EXFIL_RE` matches dir-copy verbs (`cp`, `mv`, `rsync`,
   `tar`, `zip`, `7z`, `scp`, `sftp`, `xz`) followed by a sensitive
   directory. `ls ~/.ssh` and `find ~/.aws -type f` stay allowed.

4. Inner-tree alias walk for eval / exec. `exec("import shutil as sh\nsh.copytree('~/.ssh', dst)")`
   slipped because the inner AST visit did not re-run the
   alias-tracking pre-pass that built `shutil_module_aliases`.
   Extracted both pre-pass loops into helpers (`_run_alias_prepass`,
   already had `_run_string_binding_prepass`) and call both on each
   literal eval / exec payload before the visitor recurses.

5. Chained assignment. `a = b = '/etc/shadow'; open(a).read()`
   slipped because the binding pre-pass only handled
   `len(targets) == 1`. Multi-target Assign nodes now bind every
   Name target to the resolved value.

6. Annotated assignment. `path: str = '/etc/shadow'; open(path)`
   slipped because the binding pre-pass walked `ast.Assign` but
   not `ast.AnnAssign`. Same-shape handler for single Name target.

7. Brace-bomb empty-alt bypass. `cat ~/{,x0,...,x341}/{.ssh/id_rsa,other}`
   exhausts the expansion cap before the empty alt's second-brace
   projection reaches `~/.ssh/id_rsa`. A defensive
   `_SENSITIVE_IN_BRACE_RE` catches sensitive-name fragments inside
   an unexpanded brace group attached to a sensitive root,
   regardless of whether the brace expansion completed. Anchored
   with `(?<=[,{/])` lookbehind and `(?=,|\}|/)` lookahead so
   project-local lookalikes (`./workspace/home/u/{a,b}/...`) stay
   allowed via the `_PATH_TOKEN_START` boundary.

NetworkAndIoVisitor inner-tree pre-pass. The visitor eval / exec
recursion now mirrors SignalEscapeVisitor's call to both
`_run_alias_prepass` and `_run_string_binding_prepass` so it is
independently correct regardless of visitor execution order.

Cumulative bypass closures across rounds 1 through 6: 24 distinct
classes, 629 regression tests, three-OS green.
2026-05-24 15:14:33 +00:00
pre-commit-ci[bot]
178bcf70d1 [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2026-05-24 14:47:25 +00:00
danielhanchen
5eb06e4bfe studio/sandbox: close Subscript + UDP/connect_ex metadata bypasses
Two more from the follow-up list closed (569 tests passing):

1. ``ast.Subscript`` resolution. ``open(['/etc/shadow'][0])`` and
   ``open({'k': '/etc/shadow'}['k'])`` previously slipped because
   ``_extract_string_from_node`` had no Subscript handler. List /
   tuple / dict subscripts are now resolved: when the index is a
   static constant we return the indexed value; otherwise any
   sensitive entry in the container surfaces so the gate fires.
   Indexes outside the container's static range fall back to
   sensitive-scan + first-resolvable so adversarial patterns like
   ``open(['safe.txt', '/etc/shadow'][i])`` are still blocked.

2. UDP / ``connect_ex`` metadata destination. The connect-only
   ``NetworkAndIoVisitor`` gate missed ``s.sendto(data, address)`` /
   ``s.sendmsg(buffers, ancdata, flags, address)`` (the destination
   tuple is positional but not at index 0) and ``s.connect_ex(addr)``
   (non-raising connect variant). The visitor now matches the full
   ``{connect, connect_ex, sendto, sendmsg}`` set and scans every
   positional arg for a ``(host, port)`` tuple shape; the first
   resolved host wins.

17 new regression tests cover the Subscript class (8 blocked, 3
allowed) and the UDP / connect_ex class (4 blocked, 2 allowed).

After this commit, ``bypass_hunt.py`` reports zero NEW bypasses;
the only remaining ALLOWs are the documented follow-up list
(``getattr(__builtins__, ...)``, ``vars(__builtins__)[...]``,
``base64.b64decode`` of paths, ``chr()`` / ``str.join`` concat,
trusted-host upload-shape evasion).
2026-05-24 14:47:11 +00:00
pre-commit-ci[bot]
2bf12cd89b [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2026-05-24 14:43:50 +00:00
danielhanchen
e400dac77d studio/sandbox: close bash glob and ternary IfExp bypasses
Two final bypass classes from the round-5 follow-up list are now
closed (552 tests passing):

1. Bash glob under a sensitive root. ``cat /etc/sha*ow``, ``cat
   /etc/sh?dow``, ``cat /etc/*``, ``cat ~/.ssh/id_*`` -- the shell
   expands ``*`` / ``?`` against the filesystem at runtime, so the
   literal-path scan never sees ``/etc/shadow``. A new
   ``_SENSITIVE_ROOT_WITH_GLOB_RE`` mirrors the existing
   ``_SENSITIVE_ROOT_WITH_EXPANSION_RE`` (which gates ``$(...)`` /
   backtick substitutions) for the ``*`` / ``?`` family. The
   ``[^\s'\";&|`$]*`` literal-text-only constraint keeps the match
   attached to the sensitive root token, so ``find /etc/ -name
   '*.conf'`` (whitespace between root and glob) and project-local
   globs like ``./src/*.py`` stay allowed.

2. Ternary ``IfExp`` branches. ``open('/etc/shadow' if cond else
   'data.txt')`` previously slipped because
   ``_extract_string_from_node`` had no ``ast.IfExp`` handler.
   Either branch can execute at runtime; the gate now resolves both
   branches and prefers the sensitive one (via the same
   ``_looks_sensitive`` check that backs the binding-bias) so the
   downstream check fires. Falls back to whichever branch resolved
   when neither is sensitive.

24 new regression tests cover the glob class (10 blocked, 6 allowed)
and ternary (6 blocked, 2 allowed).
2026-05-24 14:43:23 +00:00
danielhanchen
115810eae3 studio/sandbox: include /etc/passwd in pre-pass binding bias
_find_sensitive_paths does not match /etc/passwd (it lives in the
open-call gate's _SENSITIVE_FILE_PREFIXES list, not in _ABSOLUTE_SENSITIVE),
so a chained reassign p='/etc/hosts'; p='/etc/passwd'; open(p) kept
/etc/hosts as the representative and slipped through. Duplicate the
open-call prefix list in the pre-pass scope so _looks_sensitive catches
/etc/passwd and the analogous /proc/<pid> reads too.
2026-05-24 14:41:33 +00:00
danielhanchen
35d7b72832 studio/sandbox: use _find_sensitive_paths for binding-bias instead of substring
Refines the round-5 ``_looks_sensitive`` heuristic that biases
``_record_string_binding`` toward the dangerous value in a chained
reassignment. The substring hint set conflated ``/etc/shadow`` with
``/etc/hosts`` -- both contain ``/etc/`` -- so a payload like
``p = '/etc/hosts'; p = '/etc/shadow'; open(p)`` had ``cur`` already
flagged sensitive, the guard refused to update, and the resolved
value stayed at ``/etc/hosts`` (allow-listed). The chained shadow
binding then slipped through.

Now ``_looks_sensitive`` delegates to ``_find_sensitive_paths``, the
authoritative bash / file gate matcher, so the distinction is exact:
``/etc/hosts`` is allow-listed and ``/etc/shadow`` is sensitive.
``_record_string_binding`` also adopts a clean three-way rule mirroring
Python's last-wins semantics for sensitive values:

  * New sensitive value: always wins (covers the chained shadow case).
  * New benign value, current sensitive: keep current (static gate
    cannot prove the new value executes; err on blocking).
  * Both benign: latest seen wins.

Test suite still 528 passing.
2026-05-24 14:39:16 +00:00
pre-commit-ci[bot]
b038848482 [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2026-05-24 14:35:56 +00:00
danielhanchen
17739721da studio/sandbox: close 7 bypass classes from cross-reviewer round-5 audit
Sonnet-panel review of round-4 surfaced seven concrete bypass classes
in the static gate. All seven are now closed (528 tests passing, 55
new R4 / R5 regression tests):

1. ``os.path.join`` alias bypasses. ``import os as o; o.path.join(...)``,
   ``from os.path import join`` (and ``as j``), ``from os import path``
   (and ``as op``), ``import posixpath as pp``, ``from posixpath import
   join`` -- previously the FQ match was literal-only (`os.path.join`,
   `posixpath.join`, `ntpath.join`). A pre-pass walk collects every
   alias of ``os`` / ``os.path`` / ``posixpath`` / ``ntpath`` and every
   from-import of ``join`` / ``expanduser``; the resolver checks
   ``<alias>.join`` and bare aliased names too.

2. ``shutil`` alias bypasses. ``import shutil as sh; sh.copy(...)``,
   ``from shutil import copyfile``, ``from shutil import move as mv``,
   etc. -- the file-copy gate matched only the literal ``shutil.X`` FQ.
   The pre-pass now tracks shutil module aliases and from-import
   aliases for ``copyfile`` / ``copy`` / ``copy2`` / ``copytree`` /
   ``move``; the gate canonicalises any matched alias to ``shutil.X``
   so the error message identifies the operation.

3. First-assignment-wins binding bypass. ``p = '/tmp/safe'; p =
   '/etc/shadow'; open(p)`` previously slipped because the pre-pass
   guard ``_target.id not in string_bindings`` ignored every
   reassignment, and the AST walk picked the safe value while Python
   uses last-wins at runtime. New ``string_bindings_all`` tracks every
   literal ever bound to a name; ``_record_string_binding`` biases the
   representative value toward sensitive-shaped paths via a substring
   hint set covering the credential / process-state root tokens. The
   reverse order (``shadow`` then ``safe``) is also caught.

4. Brace-expansion off-by-one. ``cat ~/.aws/{x0,...,x62,credentials}``
   exploited that ``_expand_brace_projections`` started with
   ``out = {original}`` (1 item) so a cap of 64 only left 63
   alternative slots. The inner loop also broke per-alternative on
   the cap, so the sensitive name at position 64+ was never reached.
   Raised the cap to 1024 and the inner loop now expands all
   alternatives of a brace in one pass before the outer cap can stop
   the queue.

5. ``thread-self`` in shell-expansion regex. ``cat /proc/thread-self/
   $(echo environ)`` was missed because ``_SENSITIVE_ROOT_WITH_EXPANSION_RE``
   only listed ``self|\d+`` in the ``/proc/...`` alternation, while
   ``_ABSOLUTE_SENSITIVE`` correctly included ``thread-self``. One
   alternation entry restores symmetry.

6. Eval / exec pre-pass not re-run. ``exec("p='/etc/shadow'\nopen(p)")``
   slipped because the inner AST visit ran without the string-binding
   pre-pass. Extracted the pre-pass into ``_run_string_binding_prepass``
   and call it on each inner literal payload before the visitor
   recurses, so payload-local variable assignments are visible.

7. Pathlib name binding pre-pass. ``p = Path('/etc/shadow');
   p.read_text()`` slipped because the pre-pass only resolved string
   literals -- pathlib constructor calls returned None and the bound
   name remained unresolved. Pre-pass now falls back to
   ``_extract_pathlib_target`` using per-tree alias sets so
   ``import pathlib as pl; p = pl.Path(...)`` and ``from pathlib import
   Path as P; p = P(...)`` both resolve. ``NamedExpr`` (walrus) is also
   surfaced by the pre-pass so walrus-inside-eval expressions are
   visible.

Pre-pass call order. The initial pre-pass invocation moves to AFTER
``_extract_pathlib_target`` is defined so the closure cell binds
correctly (Python looks up free variables in the enclosing scope at
CALL time, not at function-definition time).

Full sandbox suite: 528 passed (455 prior + 73 R4 / R5 regression tests).
2026-05-24 14:35:17 +00:00
pre-commit-ci[bot]
a6400ffc07 [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2026-05-24 14:17:45 +00:00
danielhanchen
02e9e4867d studio/sandbox: close round-4 bypass classes (aliasing, FileIO, walrus, copytree)
Closes additional bypass classes surfaced while exercising the gate:

1. Module / function aliasing (`m = os; m.system(...)`,
   `p = os.popen; p(...)`): `visit_Assign` now propagates the source
   alias when one tracked-module name is bound to another, and tracks
   bound method references into `shell_exec_aliases`. Previously only
   `m = __import__('os')` was handled.

2. Importlib from-alias (`from importlib import import_module as IM;
   IM('os').system(...)`): a new visitor-scope `import_module_aliases`
   set plus a `_resolve_dynamic_module` wrapper recognises the
   bound name in both inline-call and bound-name forms.

3. Shutil directory exfil (`shutil.copytree('~/.ssh', dst)`):
   `_matches_sensitive_dir()` adds a directory-only matcher used by
   the file-copy gate only. The boundary `(?=/?$|/?[\s'\";&|)<>])`
   matches the path AS the directory but NOT a single file inside it,
   so per-file allow-listed reads (`~/.ssh/known_hosts`, `~/.ssh/id_rsa.pub`)
   still pass. Covers `.ssh`, `.aws`, `.config/gcloud`, `.gnupg`,
   `.docker`, `.kube`, `.password-store`, plus `/etc`, `/etc/ssh`,
   `/var/spool/cron`, `/proc/<pid>`.

4. Explicit-reader / aliased file readers (`io.FileIO('/etc/shadow')`,
   `codecs.open('/etc/shadow')`, `from io import FileIO; FileIO(...)`):
   the open-call detector now recognises these qualified forms and
   the visitor tracks `from io|codecs import FileIO|open` aliases.

5. Bytes-literal paths (`open(b'/etc/shadow')`) and walrus
   expressions (`open((p := '/etc/shadow'))`): `_extract_string_literal`
   and `_extract_string_from_node` resolve `bytes` Constants via strict
   UTF-8 decode and `NamedExpr` via RHS extraction (recording the
   binding so later uses of the walrus target resolve too).

6. Tuple / list unpacking destructuring (`(a, b) = ('/etc', 'shadow');
   open(a + '/' + b)` and `p, = ['/etc/shadow']; open(p)`): the
   string-binding pre-pass now folds matched-length Tuple/List
   destructurings element-wise.

7. Pandas / numpy file readers (`pd.read_csv('/etc/shadow')`,
   `np.fromfile('/etc/shadow')`, etc.): suffix-match the common
   reader method names so any alias of the source module flows
   through the same sensitive-path gate as `open()`.

91 new regression tests cover each class, both blocked and legitimate
allow-list cases. Full sandbox suite: 473 passed.
2026-05-24 14:17:20 +00:00
pre-commit-ci[bot]
60e0f5056e [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2026-05-22 08:13:56 +00:00
Daniel Han
d64c2a10d4 studio/sandbox: close dynamic-import + /proc/self symlink bypasses
Closes two static-bypass classes flagged during round-3 review:

1. __import__('os').system(...) / importlib.import_module('os').popen(...)
   bypassed the bare os.system / subprocess.* gate because the receiver
   was an ast.Call rather than an ast.Name in os_aliases. Adds
   _resolve_dynamic_module_name() so:
     * inline __import__('os').system(...)
     * inline importlib.import_module('os').system(...)
     * import importlib; mod = importlib.import_module('os'); mod.popen(...)
     * m = __import__('subprocess'); m.run([...], shell=True)
   all flow through the same shell-escape detection as
   import os; os.system(...). Legit dynamic imports of safe modules
   (json, pathlib, ...) remain allowed.

2. /proc/<pid>/cwd and /proc/<pid>/root are symlinks to the process
   working directory and the filesystem root. The form
   open(/proc/self/cwd/../../etc/shadow) bypassed
   _normalize_path_separators because .. was collapsed against the
   literal path, not the symlinked target. The form
   open(/proc/self/root/etc/shadow) bypassed any chroot-style
   defence. Adds matching entries to _ABSOLUTE_SENSITIVE so the bash
   gate and the AST open() gate both block any access via these
   symlink prefixes. Legitimate /proc/self/status etc. introspection
   still flows.

Tests:
  TestFollowup_DynamicImportShellEscape (2 cases, 10 parametrised)
  TestFollowup_ProcSelfSymlinkTraversal (3 cases, 16 parametrised)

  pytest studio/backend/tests/test_sandbox_hardening.py -q
    -> 382 passed in 0.67s (was 356).
2026-05-22 08:13:37 +00:00
pre-commit-ci[bot]
f2bbe27cde [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2026-05-19 12:33:45 +00:00
Daniel Han
5b0735308a Merge branch 'studio-sandbox-hardening' of https://github.com/unslothai/unsloth into studio-sandbox-hardening
# Conflicts:
#	studio/backend/core/inference/tools.py
2026-05-19 12:33:29 +00:00
Daniel Han
8176694d94 studio: address round-3 sandbox review findings
Round-4 follow-up on the hardening PR after a third 20-reviewer pass.
Closes the high-impact items from that review while preserving the
"do not regress legitimate tool calling" floor; lower-vote items that
would have measurable regression on legit code paths (broad shell
glob ?/*, $VAR in dynamic paths, ANSI-C $'...') are intentionally
deferred.

Parent-directory traversal: _normalize_path_separators now follows
.. segments through posixpath.normpath and reattaches the tilde or
${HOME} prefix, so cat /etc/apt/../shadow and
Path('/proc/self/fd/../environ').read_text() both reach the
canonical regex.

Built-in open() accepts PathLike: open(Path('/etc/shadow')) and
open(file=Path('/etc/shadow')) now flow through the pathlib resolver
the same way receiver reads do.

Pathlib home and transforms: Path.home() resolves to ~ so
(Path.home() / '.aws/credentials') hits the home regex;
.expanduser() / .resolve() / .absolute() are pass-throughs.

Pathlib semantics: _join_path_parts() now matches pathlib's
absolute-segment reset so Path('/tmp') / '/etc/shadow' resolves to
/etc/shadow as it does at runtime.

from builtins import exec as e: tracked in both visitors via
eval_exec_aliases so the aliased call still routes through the
literal-payload recursion.

Process state extensions: /proc/self/cmdline,
/proc/thread-self/*, and /proc/<pid>/task/<tid>/* are added to
_ABSOLUTE_SENSITIVE.

Numeric f-strings: f'/proc/{1}/environ' folds to a literal because
numeric ast.Constant values inside ast.FormattedValue are now
stringified.

os.path.join / os.path.expanduser: resolved statically by
_extract_string_from_node so the stdlib-helper construction paths
do not hide sensitive targets.

Variable assignment tracking: a pre-pass collects ``name = literal``
and ``name = eval`` / ``name = exec`` bindings; the visitors and the
pathlib resolver consult those bindings. The trusted-host gate
intentionally uses a separate strict literal extractor so legit
patterns like ``url = some_input; requests.get(url)`` still pass.

shutil.copyfile / copy / copy2 / copytree / move: the source argument
is gated the same way open() is, blocking file-copy exfil.

Concrete pathlib classes: PosixPath / WindowsPath / PurePath / etc.
are registered in path_aliases by default.

requests.request positional+keyword: for URL-second APIs, args[0] is
the HTTP method (not the URL); when there is only one positional, the
URL extraction falls through to the url= keyword instead of grabbing
the method.

Tests grow from 281 to 357 hardening cases; combined sweep 487 / 487.
Every fix has positive and negative coverage; legit tool calls
(open(Path('data.csv')), os.path.join('logs', 'today.log'),
url = some_input; requests.get(url), shutil.copyfile('a.txt', 'b.txt'))
continue to pass.
2026-05-19 12:32:29 +00:00
pre-commit-ci[bot]
f2809221d4 [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2026-05-19 12:02:09 +00:00
Daniel Han
7a0bacdba8 studio: address round-2 sandbox review findings
Round-3 follow-up on the same hardening PR after a second 20-reviewer
pass. Every change is detection-widening; legitimate tool calls remain
allowed.

Pathlib readers (Path.open, Path.read_text, Path.read_bytes) now share
one extraction path. The new _extract_pathlib_target helper resolves
Path(a), Path(a, b, ...), Path(...).joinpath(b), and Path(...) / b
through statically-resolvable string parts. NetworkAndIoVisitor tracks
Path aliases (from pathlib import Path as P) and pathlib module aliases
(import pathlib as pl) so the aliased forms hit the same gate. For
receiver-side reads the path is taken exclusively from the receiver --
Path('/etc/shadow').open('r') no longer mis-reads the mode flag as a
path.

The credential-path regex set now matches the POSIX ~user/ expansion
(cat ~ubuntu/.aws/credentials), and the SSH private-key end anchor
includes > so that redirect-attached forms (cat ~/.ssh/id_rsa>... )
are not split-tokenised through the gate. A new
_SENSITIVE_ROOT_WITH_EXPANSION_RE detects sensitive root prefixes
followed by $(...) or backtick substitution, and _find_sensitive_paths
now enumerates bash brace expansion {a,b} plus small glob char classes,
and runs every projection through path-separator normalisation that
collapses // and /./.

Network host validation reaches keyword arguments (url=, host=,
hostname=, address=), host-first APIs whose first positional arg is the
host (socket.create_connection, socket.getaddrinfo,
http.client.HTTPConnection, http.client.HTTPSConnection), and the
url-second APIs (requests.request, httpx.request).

builtins.exec, builtins.eval, and __builtins__.eval flow through the
same literal-payload recursion as the bare forms, including aliased
import builtins as b. open(file=...) and io.open(file=...) keyword
forms are gated alongside the positional form, and the open() path
candidates run through both backslash normalisation and the
//-collapse projection so equivalent spellings (/etc//shadow,
/etc/./shadow) cannot bypass.

Tests grow from 205 to 281 hardening cases (TestR2Finding1 through
TestR2Finding16) and from 131 + 205 = 336 to 131 + 281 = 412 in the
local sweep. Negative cases for every fix continue to ensure
legitimate tool use (Path('data.csv').open(), open(file='logs/today.log'),
requests.get(url='https://wikipedia.org/'), find src/, etc.) stays
allowed.
2026-05-19 12:00:34 +00:00
Daniel Han
e598ebf1d0 Merge remote-tracking branch 'origin/main' into studio-sandbox-hardening 2026-05-19 11:51:10 +00:00
Daniel Han
eda3be4101 Merge branch 'studio-sandbox-hardening' of https://github.com/unslothai/unsloth into studio-sandbox-hardening 2026-05-19 11:11:45 +00:00
Daniel Han
8fd57530d1 studio: always use POSIX shlex for sensitive-path dequote
On Windows runners shlex.split(posix=False) leaves splice quotes in place, so cat /etc/sha''dow tokenises to ['cat', "/etc/sha''dow"] and the dequoted scan projection still misses the credential. The threat model is POSIX-shell quote splicing in either bash invoked on Windows or POSIX shells on Linux/macOS; the dequote always wants POSIX semantics. Pre-normalise backslashes so Windows drive paths survive POSIX shlex's escape handling.
2026-05-19 11:11:20 +00:00
pre-commit-ci[bot]
40d60b05c5 [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2026-05-19 11:09:53 +00:00
Daniel Han
e06126933e Merge branch 'studio-sandbox-hardening' of https://github.com/unslothai/unsloth into studio-sandbox-hardening
# Conflicts:
#	studio/backend/core/inference/tools.py
2026-05-19 11:08:34 +00:00
Daniel Han
2ae885ce72 studio: address sandbox hardening review findings
Round-2 fixes for ten issues surfaced by a 20-reviewer code review of
the initial hardening patches. Every change is detection-widening or a
false-positive narrowing; legitimate tool calls keep working.

Direct Python open now flows through _find_sensitive_paths so
open('/home/u/.aws/credentials').read() is gated the same as
os.system('cat ~/.aws/credentials'). The previous wiring covered only
the bash and shell-exec sides.

Both SignalEscapeVisitor and NetworkAndIoVisitor fail-closed once the
eval / exec literal recursion cap is reached. Wrapping a payload in
four or more nested literal exec layers no longer silently bypasses
inspection.

_find_sensitive_paths scans three projections of the command (raw,
backslash-normalised, shlex-dequoted) and recurses into nested
bash -c and cmd /c shells. Quote-spliced and Windows-backslash forms
of credential paths are all caught.

_HOME_PREFIX_RE adds Windows-style home prefixes (USERPROFILE,
HOMEDRIVE HOMEPATH, env:USERPROFILE, drive-letter Users) so cross-OS
hardening actually applies on Windows. Both sensitive-path regexes
now have a path-token start anchor so project-local lookalike paths
under workspace, fixtures, and tmp are not blocked.

Network host validation for sock.connect and the requests / urllib
FQ-prefix branch now use _extract_string_from_node instead of raw
ast.Constant checks, so concatenated and f-string literal hosts
resolve the same way the open gate already did.

pathlib.Path('/etc/shadow').open() is now inspected; the path is
extracted from the receiver constructor when node.args is empty.

The static-string resolver depth cap moves from 6 to 64, removing the
single-character literal-concat bypass while leaving the recursion
well inside CPython's default frame limit.

The SSH private-key regex gains a filename-end boundary so reading a
public ".pub" key stays allowed (legit developer action) while the
matching private key is still denied.

New regression tests cover one class per finding (TestFinding1 through
TestFinding10) plus updated nested-depth coverage; the previous
test_nested_depth_does_not_crash assertion was inverted by the
fail-closed change and has been replaced. Local sweep: 336 passed
(131 upstream + 205 hardening).
2026-05-19 11:07:16 +00:00
Daniel Han
6efb0f64ea Merge remote-tracking branch 'origin/main' into studio-sandbox-hardening 2026-05-19 10:58:55 +00:00
pre-commit-ci[bot]
829698280a [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2026-05-19 07:52:08 +00:00
Daniel Han
8a5080f26b studio: regression tests for sandbox hardening patches A / B / D
124 tests across 7 classes:

  * TestPatchA_DynamicPaths — open() with concatenated literals + f-strings.
    Pins 7 attack patterns BLOCKED, 6 legitimate dynamic paths ALLOWED,
    12-level deep concat doesn't crash.

  * TestPatchB_FindSensitivePathsHomeAnchored — ~/.ssh/id_*, ~/.aws/,
    ~/.docker/, ~/.kube/, ~/.pypirc/.npmrc, ~/.netrc, ~/.password-store,
    ~/.gnupg/private-keys-v1.d across ~, $HOME, /home/<u>, /root,
    /Users/<u>. Pins 21 attack paths BLOCKED, 15 legitimate paths
    (~/.gitconfig, ~/.bashrc, ~/.ssh/{config,known_hosts}, ~/.npm,
    project-local rc files, /tmp/.npmrc) ALLOWED.

  * TestPatchB_FindSensitivePathsAbsolute — /etc/shadow, /etc/sudoers,
    /etc/ssh/ssh_host_*, /proc/{self,<pid>}/{environ,mem,maps,auxv},
    /proc/kcore, /proc/kallsyms, /var/spool/cron/. Pins 12 attacks
    BLOCKED, 11 legit paths (/etc/hosts, /etc/resolv.conf, /proc/cpuinfo,
    /proc/meminfo, …) ALLOWED.

  * TestPatchB_PythonShellExec — same surface flows through os.system /
    subprocess.run. 6 attacks BLOCKED, 11 legitimate tool-calls ALLOWED.

  * TestPatchD_EvalExecLiteralPayload — exec/eval with a literal payload
    parsed and re-checked. 5 attack payloads BLOCKED, 6 legit
    expressions (eval('1+2'), exec('print("hi")'), nested innocuous
    exec) ALLOWED.

  * TestPatchD_EvalExecDynamicPayload — non-literal eval/exec args
    flagged as dynamic shell escape. 4 patterns BLOCKED.

  * TestPatchD_NestedDepthCap — 10-level nested exec(exec(...)) caps
    at depth 3, doesn't crash, doesn't false-positive.

  * TestCrossCuttingNoRegression — 6 pre-existing BLOCK patterns still
    fire (sudo, signal tampering, /etc/passwd literal, untrusted host,
    metadata host); 7 pre-existing ALLOW patterns still pass (print,
    json.loads, trusted host, dataclass, legitimate open()).

Result on the rebuilt scaffold:
  131/131 pre-existing tests in test_sandbox_tools.py pass
  124/124 new hardening tests pass
  255/255 combined, zero regressions

The "must remain ALLOWED" cases form the non-regression floor that
prevents the patches from making LLM tool calling dumber.
2026-05-19 07:04:49 +00:00
Daniel Han
6984fa8d7c studio: recurse into eval / exec literal payloads (Patch D)
The AST gate previously had no special handling of eval() / exec(),
so a literal payload would slip past every detector:

  exec("import os; os.system('sudo whoami')")          # ALLOWED before
  exec("open('/etc/shadow').read()")                    # ALLOWED before
  eval("__import__('blocked_mod').dangerous()")         # still allowed (chained-call gap)
  payload = '...'; exec(payload)                        # ALLOWED before

Both visitors (SignalEscapeVisitor and NetworkAndIoVisitor) now share
the same gate at the top of visit_Call: when the call is bare-name
eval / exec, try to resolve the first argument via the shared
_extract_string_from_node helper (Patch A); if it resolves, parse it
and recursively visit so every existing detector runs on the inner
code — signal tampering, shell escape, sensitive-file open, network
policy, upload denylist, etc.

When the payload is not statically resolvable, SignalEscapeVisitor
appends a `shell_escape_dynamic` finding — eval/exec of runtime data
is the textbook code-injection vector and there is no legitimate LLM
tool-call reason to dynamically eval an external string. Static
literals (eval('1 + 2'), exec('x = 1\\ny = 2')) are unchanged because
the recursive visit only flags what the rest of the AST gate would
already flag at top level.

Each visitor caps recursion at depth 3 (own counter on the instance)
so adversarial nested eval('eval(...)') cannot blow the stack.

Closes gaps #7, #8 (partial), #11 (partial) from the 13-gap audit.
Out-of-scope chained-call cases (__import__('os').system(...),
getattr(os, 'sys'+'tem')()) stay documented gaps — the OS sandbox is
the intended backstop, see PR 5468.

Regression: 131/131 studio/backend/tests/test_sandbox_tools.py pass.
Legitimate eval/exec on literal expressions (eval('1+2'),
exec('print("hi")'), exec('exec("print(1)")')) verified ALLOWED.
2026-05-19 07:02:47 +00:00
Daniel Han
2965cd8310 studio: gate credential / process-state paths in bash and Python (Patch B)
Adds _find_sensitive_paths() and wires it into _bash_exec (alongside the
existing _find_blocked_commands check) and into _check_args_for_blocked
(so the Python AST gate catches os.system('cat ~/.ssh/id_rsa') the same
way bash $ cat ~/.ssh/id_rsa is caught).

The pattern set is intentionally narrow — only clear-cut credential and
process-state targets:

  Home-anchored (must be prefixed by ~, $HOME, ${HOME}, /home/<u>,
  /root, /Users/<u>):
    .ssh/id_rsa, .ssh/id_ed25519, .ssh/id_ecdsa, .ssh/id_dsa, .ssh/identity
    .aws/credentials, .docker/config.json, .kube/config
    .config/gcloud/{application_default_credentials,access_tokens,credentials}
    .pypirc, .npmrc, .cargo/credentials
    .netrc, .password-store, .gnupg/private-keys-v1.d

  Absolute system targets (match anywhere):
    /etc/shadow, /etc/sudoers, /etc/ssh/ssh_host_*
    /proc/{self,<pid>}/{environ,mem,maps,auxv}
    /proc/kcore, /proc/kallsyms
    /var/spool/cron/

The home-anchored category uses a regex that requires a HOME-equivalent
prefix, so project-local rc files like ./project/.npmrc remain readable
while ~/.npmrc is denied. Legitimate LLM-developer-tool paths
(~/.gitconfig, ~/.bashrc, ~/.ssh/config, ~/.ssh/known_hosts, /etc/hosts,
~/.cache/, ~/.bash_history, project rc files) are intentionally NOT in
the list and still flow through unchanged.

Closes gaps #1, #2, #3, #12, #13 from the documented 13-gap audit.

Regression sweep:
  * 131/131 studio/backend/tests/test_sandbox_tools.py pass
  * 24 legitimate-use cases verified ALLOWED
  * 17 attack patterns verified BLOCKED
2026-05-19 06:57:53 +00:00
Daniel Han
3e4704a856 studio: resolve concatenated + f-string paths in sensitive-file gate (Patch A)
Static-string resolution in _extract_string_from_node was limited to bare
ast.Constant. Concatenated string literals and f-strings with constant
parts evaluated to ast.BinOp / ast.JoinedStr and slipped past the
open() sensitive-file check, so:

  open('/etc/' + 'shadow')           # ALLOWED before
  open(f'/etc/{"shadow"}')           # ALLOWED before
  open('/etc/passwd')                # BLOCKED before (literal)

The helper now resolves ast.BinOp(Add) of two resolvable strings and
ast.JoinedStr whose parts are themselves resolvable. The open()
sensitive-file check uses the helper instead of an inline ast.Constant
isinstance check, so the same widening covers concatenated/f-string
paths without changing what was already blocked.

Resolution is depth-capped at 6 to keep adversarial deep nesting from
blowing the stack. All other call sites of the helper
(_check_args_for_blocked, dynamic-arg shell-escape detection, HF upload
path-shape inspection) automatically inherit the broader resolution.

Closes open() gaps #4 and #6 from the documented 13-gap audit. Does not
attempt to model variable flow (gap #5 stays open by design — the OS
sandbox is the right layer for runtime flow).

Regression: 131/131 studio/backend/tests/test_sandbox_tools.py pass.
2026-05-19 06:54:26 +00:00
2 changed files with 4455 additions and 33 deletions

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff