unsloth/studio
Daniel Han aab371a068
studio: tighten sandbox blocklist precision (bash, hf upload, NOFILE) (#5487)
* studio: tighten sandbox blocklist precision (bash, hf upload, NOFILE)

Three precision fixes in core/inference/tools.py. Same security
boundary; fewer false positives that broke legitimate sandbox use.

bash blocklist:
The per-token loop introduced in #5375 fired on any blocklist word in
any token position, so the entirely benign `grep -r curl .`,
`echo source the data`, and `ls /usr/bin/curl` were rejected with
"blocked command 'curl'". The position-anchored regex already covers
real command-position invocations, including `;rm`, `&&wget`, `$(rm)`,
`<(rm)`, backticked subshells, and `/usr/bin/sudo`. The token loop is
re-scoped: it only fires when the previous shlex token is a shell
separator (or at start of line), so split-quoting obfuscations like
`r''m -rf /` are still caught (shlex collapses them to a single
command-position token) while argument-position blocklist words pass
through. Trailing meta-chars glued to a shlex token (`rm;`) are
stripped before basename matching.

hf upload AST gate:
`_method_call_is_hf_upload` previously matched any method named
`upload_file` / `upload_folder` / `upload_large_folder` / `create_commit`
on any receiver, so paramiko.SFTPClient.upload_file, boto3.create_commit,
and similar non-HF SDK methods were rejected. The fallback now requires
an `import huggingface_hub` / `import hf_api` / `from huggingface_hub
import ...` somewhere in the same module. Fully-qualified
huggingface_hub.upload_file(...) calls are unchanged.

NOFILE env knob:
`RLIMIT_NOFILE = (1024, 1024)` was the only sandbox rlimit without an
env override. 1024 is below Linux's typical soft default and below
what multi-shard safetensors mmap chains need on Llama-3 70B-class
loads. Default is now 16384 with UNSLOTH_STUDIO_SANDBOX_NOFILE, parity
with the other rlimits.

15 new bash-blocklist-position tests pin both the false-positive
fixes and the still-blocked invariants (semicolon, &&, subshell,
backtick, split-quote, /usr/bin/ prefix, nested bash -c).
4 new hf-upload-import-gate tests pin both the false-positive
allowances and that HF-imported uses are still blocked.
1 new pin asserts the NOFILE env var is wired.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* studio: cover command wrappers, find -exec, dynamic HF imports, NOFILE clamp

Reviewer follow-ups to the sandbox blocklist precision change.

Command-position scanner missed Bash command-prefix wrappers and inline
shell assignments. shlex tokenised `env curl`, `time curl`, `nohup rm`,
`FOO=bar curl`, `sudo rm`, etc. with the prefix at command position and
the real command at argument position, so the position-anchored check
returned set() while pre-PR's per-token scan caught them. Likewise the
position-anchored regex requires `^` or a shell separator before the
command, so `env curl` slipped through.

Reworked the scanner to track an expect_command flag plus a
prefix_pending flag:
  - assignments (FOO=bar) keep expect_command=True for the next token,
  - flags ('-oL', '--') keep it intact while prefix_pending is set,
  - numeric duration args ('timeout 1 cmd') skip without breaking
    expect_command,
  - known wrappers (env, command, builtin, exec, time, nohup, nice,
    setsid, stdbuf, timeout, ionice, chroot, sudo, doas, su, xargs)
    set prefix_pending so the wrapper's command is still checked,
  - shell separators now include `{`, `}`, `)`, `then`, `do`,
    `else`, `elif` so brace groups and if/then/while/do bodies are
    recognised as command positions.

Also lex with `shlex.shlex(punctuation_chars=";&|()`")` so split-quote
forms like `echo done; r''m -rf /tmp/x` and `echo done;r''m` tokenise
as `[..., ';', 'rm', ...]` and the command position check fires.

Added a small `find -exec CMD ... ;` / `-execdir CMD ... ;` pass so
`find . -exec rm -f {} +` and friends are caught even though the
direct token is at argument position to `find`.

Dynamic Hugging Face imports were treated as no-HF-in-scope. The
upload-method gate now also resolves `__import__('huggingface_hub')`,
`importlib.import_module('huggingface_hub')`, and bare
`import_module('huggingface_hub')` (via `from importlib import
import_module`) as HF imports, so HfApi().upload_file via dynamic
import is still blocked.

RLIMIT_NOFILE: setrlimit(NOFILE, (16384, 16384)) silently failed if
the parent's hard cap is below the requested value; the broad
except swallowed the OSError and left the sandbox at the parent's
default. Clamp the requested value to the inherited hard limit
before calling setrlimit.

Test cleanup: the existing test_cat_with_word_source_allowed had
`assert ... or True` so it could not fail; rewrote it to assert the
actual return value plus the two membership checks. Added
parametrised coverage for shell prefix wrappers, find -exec / xargs,
brace groups, if/then, while/do, split-quote command-name forms, and
dynamic HF import upload patterns.

Test:
  - python -m pytest studio/backend/tests/test_sandbox_tools.py -q
    -> 90 passed (was 67 before this commit)
  - full studio/backend/tests/ minus llama_cpp_load_progress_live and
    GPU CUDA_VISIBLE_DEVICES tests (pre-existing isolation flake)
    -> 1063 passed

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* studio: catch bare-name HF upload calls in AST gate

`from huggingface_hub import upload_file; upload_file(...)` is a
canonical HF call shape that the previous Attribute-only check missed:
the bare-name call lands as ast.Name (not ast.Attribute), so the
fuzzy gate skipped it.

Extend _method_call_is_hf_upload to also match ast.Name when HF is in
scope. Same import-gating discipline as the Attribute branch, so
paramiko/boto3 and locally-defined `def upload_file(...)` helpers
without HF imports still pass.

Pins: 4 new TestHfUploadImportGate cases (upload_file/folder/create_commit
bare-name imports blocked; local upload_file without HF import allowed).

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* studio: scope HF uploads to sandbox-local literals; block env / token leaks

The previous gate dropped every HF upload call. Two refinements make it
precise enough to allow legitimate sandbox->HF uploads while still
catching credential / file exfil:

- path_or_fileobj / folder_path / create_commit operation paths must be
  sandbox-local relative-path literals (no '/', '~', drive letter, or
  '..' segments). Variable / dynamic paths are rejected.

- Any positional or keyword argument that statically resolves to
  os.environ / os.environ.get / os.getenv / bare getenv / subprocess
  shape readers is rejected (env-var exfil).

- token / hf_token / api_token / api_key / auth_token / access_token /
  password / secret kwargs are always rejected; sandbox env strips all
  parent credentials by construction, so any value here is hard-coded
  or lifted.

Recursive subtree walk in _reads_env_or_secret catches wrapper shapes
(str(os.environ), json.dumps(os.environ.items()), etc.).

Add TestSandboxEnvIsolation: pin that _build_safe_env builds the env
from a whitelist, not by stripping. Cover Linux/macOS/WSL/Windows
secret shapes. The whitelist is PATH / HOME / TMPDIR / LANG / TERM /
PYTHONIOENCODING (+ VIRTUAL_ENV / SystemRoot when applicable); HOME
points at the sandbox workdir, so HF / wandb / aws SDKs cannot reach
the operator's ~/.cache credentials.

Test classes added:
- TestHfUploadSandboxLocalPaths (relative literals allowed; absolute,
  drive-letter, '~', '..', mid-path traversal, dynamic vars, and
  open() of unsafe paths blocked, including create_commit recursion).
- TestHfUploadEnvAndSecretLeakBlock (os.environ subscript/get/getenv,
  bare getenv, subprocess.check_output, str(os.environ), token=,
  hf_token=, api_key=, and create_commit operations referencing env).
- TestSandboxEnvIsolation (no parent secret leaks into sandbox env).

131 tests in test_sandbox_tools.py pass.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2026-05-18 00:01:17 -07:00
..
backend studio: tighten sandbox blocklist precision (bash, hf upload, NOFILE) (#5487) 2026-05-18 00:01:17 -07:00
frontend studio: expose launcher capability bits on unauth /api/health (#5486) 2026-05-17 21:29:24 -07:00
src-tauri studio: expose launcher capability bits on unauth /api/health (#5486) 2026-05-17 21:29:24 -07:00
__init__.py Final cleanup 2026-03-12 18:28:04 +00:00
install_llama_prebuilt.py Fix Linux prebuilt installs for branch-based llama.cpp releases (#5493) 2026-05-17 07:16:55 -07:00
install_python_stack.py studio: skip flash-attn install on Blackwell GPUs (sm_100+) (#5420) 2026-05-14 18:13:50 +04:00
LICENSE.AGPL-3.0 Add AGPL-3.0 license to studio folder 2026-03-09 19:36:25 +00:00
setup.bat Final cleanup 2026-03-12 18:28:04 +00:00
setup.ps1 CI: scope GITHUB_TOKEN permissions, add MLX CI, unblock ~60 skipped tests (#5312) 2026-05-11 03:19:13 -07:00
setup.sh Route CPU-only Linux x86_64 to ggml-org/llama.cpp prebuilts (#5302) 2026-05-05 23:22:22 -07:00
Unsloth_Studio_Colab.ipynb studio: add --local to setup.sh + overlay unsloth-zoo from git main (#5252) 2026-05-02 08:51:56 +04:00