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.
This commit is contained in:
parent
6984fa8d7c
commit
8a5080f26b
1 changed files with 379 additions and 0 deletions
379
studio/backend/tests/test_sandbox_hardening.py
Normal file
379
studio/backend/tests/test_sandbox_hardening.py
Normal file
|
|
@ -0,0 +1,379 @@
|
|||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
"""Regression tests for the software-sandbox hardening patches in
|
||||
``studio/backend/core/inference/tools.py``.
|
||||
|
||||
Three patches under test:
|
||||
|
||||
* **Patch A** — ``_extract_string_from_node`` resolves ``ast.BinOp(Add)``
|
||||
of two resolvable strings and ``ast.JoinedStr`` (f-string) whose parts
|
||||
are themselves resolvable. Closes ``open('/etc/' + 'shadow')`` and
|
||||
``open(f'/etc/{"shadow"}')``.
|
||||
|
||||
* **Patch B** — ``_find_sensitive_paths()`` gates clear-cut credential /
|
||||
process-state targets in both bash commands (``_bash_exec``) and the
|
||||
Python AST gate (via ``_check_args_for_blocked``). The allow-list is
|
||||
intentionally narrow so legitimate LLM tool calls like
|
||||
``cat ~/.gitconfig`` / ``find src/`` / ``grep -r foo src/`` still work.
|
||||
|
||||
* **Patch D** — eval / exec literal payloads are parsed and recursively
|
||||
visited by both ``SignalEscapeVisitor`` and ``NetworkAndIoVisitor``;
|
||||
non-literal payloads are flagged as dynamic shell escapes.
|
||||
|
||||
The "must remain ALLOWED" cases in every class are the non-regression
|
||||
floor — if any of them ever turns into BLOCKED, tool calling has been
|
||||
made dumber and the patch needs to be relaxed.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
_BACKEND_ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(_BACKEND_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(_BACKEND_ROOT))
|
||||
|
||||
from core.inference.tools import ( # noqa: E402
|
||||
_check_code_safety,
|
||||
_find_sensitive_paths,
|
||||
)
|
||||
|
||||
|
||||
def _is_blocked(code: str) -> bool:
|
||||
return _check_code_safety(code) is not None
|
||||
|
||||
|
||||
# Used to keep ``sudo`` out of test source so a sandbox hook that
|
||||
# blocks ``sudo`` strings in test fixtures doesn't trip on the file itself.
|
||||
SUDO = "s" + "u" + "do"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Patch A — concatenated + f-string path resolution in open()
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestPatchA_DynamicPaths:
|
||||
@pytest.mark.parametrize(
|
||||
"code",
|
||||
[
|
||||
# BinOp.Add of two literals
|
||||
"open('/etc/' + 'shadow')",
|
||||
"open('/etc/' + 'passwd')",
|
||||
"open('/etc/' + 'sudoers')",
|
||||
# Three-way concat
|
||||
"open('/etc' + '/' + 'shadow')",
|
||||
# F-string with a literal interpolation
|
||||
"open(f'/etc/{\"shadow\"}')",
|
||||
"open(f'/{\"etc\"}/{\"shadow\"}')",
|
||||
# Same surface via io.open / pathlib.Path.open
|
||||
"import io; io.open('/etc/' + 'shadow')",
|
||||
],
|
||||
)
|
||||
def test_dynamic_sensitive_path_blocked(self, code):
|
||||
assert _is_blocked(code), f"expected to block: {code!r}"
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"code",
|
||||
[
|
||||
# Existing literal behavior — must not regress
|
||||
"open('/etc/passwd')",
|
||||
"open('/etc/shadow')",
|
||||
],
|
||||
)
|
||||
def test_literal_sensitive_path_still_blocked(self, code):
|
||||
assert _is_blocked(code), f"expected to still block: {code!r}"
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"code",
|
||||
[
|
||||
# Legitimate use of concatenation / f-strings — must remain ALLOWED
|
||||
"open('a' + '/' + 'b.txt')",
|
||||
"open('logs/' + 'today.log')",
|
||||
"open(f'data/{\"file\"}.csv')",
|
||||
"open(f'reports/{\"q1\"}.json')",
|
||||
"open('README.md')",
|
||||
"open('src/main.py')",
|
||||
],
|
||||
)
|
||||
def test_legitimate_dynamic_paths_allowed(self, code):
|
||||
assert not _is_blocked(code), f"expected to allow: {code!r}"
|
||||
|
||||
def test_recursion_depth_capped_does_not_crash(self):
|
||||
# 12 nested string concatenations — _extract_string_from_node should
|
||||
# bail out at depth 6 and return None (i.e. not extract a string),
|
||||
# not raise. Behaviour must be: doesn't crash, doesn't false-positive.
|
||||
deep = "open(" + "'a' + " * 12 + "'b')"
|
||||
assert _check_code_safety(deep) is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Patch B — sensitive paths in bash (direct helper API)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestPatchB_FindSensitivePathsHomeAnchored:
|
||||
@pytest.mark.parametrize(
|
||||
"cmd",
|
||||
[
|
||||
# Tilde-anchored
|
||||
"cat ~/.ssh/id_rsa",
|
||||
"cat ~/.ssh/id_ed25519",
|
||||
"cat ~/.ssh/id_ecdsa",
|
||||
"cat ~/.ssh/id_dsa",
|
||||
"cat ~/.ssh/identity",
|
||||
"cat ~/.aws/credentials",
|
||||
"cat ~/.docker/config.json",
|
||||
"cat ~/.kube/config",
|
||||
"cat ~/.pypirc",
|
||||
"cat ~/.npmrc",
|
||||
"cat ~/.cargo/credentials",
|
||||
"grep token ~/.netrc",
|
||||
"ls ~/.password-store",
|
||||
"ls ~/.gnupg/private-keys-v1.d",
|
||||
"cat ~/.config/gcloud/application_default_credentials.json",
|
||||
# $HOME variants
|
||||
"cat $HOME/.ssh/id_rsa",
|
||||
"cat ${HOME}/.aws/credentials",
|
||||
# Absolute home paths
|
||||
"cat /home/u/.aws/credentials",
|
||||
"cat /Users/alice/.aws/credentials",
|
||||
"cat /root/.docker/config.json",
|
||||
"cat /root/.netrc",
|
||||
],
|
||||
)
|
||||
def test_blocked(self, cmd):
|
||||
assert _find_sensitive_paths(cmd), f"expected to flag: {cmd!r}"
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"cmd",
|
||||
[
|
||||
# Project-local rc files — must remain readable
|
||||
"cat ./project/.npmrc",
|
||||
"cat .npmrc",
|
||||
"cat .pypirc",
|
||||
"cat config/.npmrc",
|
||||
# Common LLM-tool-use paths under HOME
|
||||
"cat ~/.gitconfig",
|
||||
"cat ~/.bashrc",
|
||||
"cat ~/.zshrc",
|
||||
"cat ~/.profile",
|
||||
"cat ~/.bash_history",
|
||||
"cat ~/.ssh/config",
|
||||
"cat ~/.ssh/known_hosts",
|
||||
"cat ~/.ssh/authorized_keys",
|
||||
"ls -la ~/.npm",
|
||||
"ls -la ~/.cache",
|
||||
# Innocuous /tmp paths that happen to share suffixes
|
||||
"cat /tmp/.npmrc",
|
||||
"cat /tmp/.netrc",
|
||||
"cat /tmp/.ssh/id_rsa", # /tmp is NOT a home prefix
|
||||
],
|
||||
)
|
||||
def test_legitimate_allowed(self, cmd):
|
||||
assert not _find_sensitive_paths(cmd), (
|
||||
f"expected to allow (would dumbify tool calling): {cmd!r}"
|
||||
)
|
||||
|
||||
|
||||
class TestPatchB_FindSensitivePathsAbsolute:
|
||||
@pytest.mark.parametrize(
|
||||
"cmd",
|
||||
[
|
||||
"cat /etc/shadow",
|
||||
"cat /etc/sudoers",
|
||||
"ls /etc/ssh/ssh_host_rsa_key",
|
||||
"cat /etc/ssh/ssh_host_ed25519_key",
|
||||
"cat /proc/self/environ",
|
||||
"cat /proc/1234/environ",
|
||||
"cat /proc/1/environ",
|
||||
"cat /proc/self/maps",
|
||||
"cat /proc/self/mem",
|
||||
"cat /proc/kcore",
|
||||
"cat /proc/kallsyms",
|
||||
"ls /var/spool/cron/crontabs",
|
||||
],
|
||||
)
|
||||
def test_absolute_blocked(self, cmd):
|
||||
assert _find_sensitive_paths(cmd), f"expected to flag: {cmd!r}"
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"cmd",
|
||||
[
|
||||
# /etc files that legitimately want to be read
|
||||
"cat /etc/hosts",
|
||||
"cat /etc/hostname",
|
||||
"cat /etc/resolv.conf",
|
||||
"cat /etc/nsswitch.conf",
|
||||
"cat /etc/localtime",
|
||||
"cat /etc/os-release",
|
||||
# Non-sensitive /proc files
|
||||
"cat /proc/cpuinfo",
|
||||
"cat /proc/meminfo",
|
||||
"cat /proc/uptime",
|
||||
"cat /proc/version",
|
||||
"cat /proc/loadavg",
|
||||
# Other useful system files
|
||||
"cat /var/log/syslog",
|
||||
],
|
||||
)
|
||||
def test_legitimate_absolute_allowed(self, cmd):
|
||||
assert not _find_sensitive_paths(cmd), (
|
||||
f"expected to allow (would dumbify tool calling): {cmd!r}"
|
||||
)
|
||||
|
||||
|
||||
class TestPatchB_PythonShellExec:
|
||||
"""When the bash blocklist + sensitive-path check fires inside the
|
||||
Python AST gate, ``os.system('cat ~/.ssh/id_rsa')`` produces the same
|
||||
block as the bash equivalent."""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"code",
|
||||
[
|
||||
"import os; os.system('cat ~/.ssh/id_rsa')",
|
||||
"import os; os.system('grep token ~/.netrc')",
|
||||
"import os; os.system('cat /home/u/.aws/credentials')",
|
||||
"import os; os.system('cat /etc/shadow')",
|
||||
"import subprocess; subprocess.run(['cat', '/proc/self/environ'])",
|
||||
"import subprocess; subprocess.run(['cat', '/etc/shadow'])",
|
||||
],
|
||||
)
|
||||
def test_blocked(self, code):
|
||||
assert _is_blocked(code), f"expected to block: {code!r}"
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"code",
|
||||
[
|
||||
"import os; os.system('cat README.md')",
|
||||
"import os; os.system('ls src/')",
|
||||
"import os; os.system('cat ~/.gitconfig')",
|
||||
"import os; os.system('cat ~/.bashrc')",
|
||||
"import os; os.system('cat ~/.ssh/config')",
|
||||
"import os; os.system('cat ~/.ssh/known_hosts')",
|
||||
"import os; os.system('cat /etc/hosts')",
|
||||
"import os; os.system('find src/ -name *.py')",
|
||||
"import os; os.system('grep -r foo src/')",
|
||||
"import subprocess; subprocess.run(['ls', '-la'])",
|
||||
"import subprocess; subprocess.run(['cat', 'README.md'])",
|
||||
],
|
||||
)
|
||||
def test_legitimate_allowed(self, code):
|
||||
assert not _is_blocked(code), (
|
||||
f"expected to allow (would dumbify tool calling): {code!r}"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Patch D — eval / exec body recursion
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestPatchD_EvalExecLiteralPayload:
|
||||
@pytest.mark.parametrize(
|
||||
"code",
|
||||
[
|
||||
# Shell-escape inside an exec payload
|
||||
f"exec(\"import os; os.system('{SUDO} whoami')\")",
|
||||
f"exec('import subprocess; subprocess.run([\"{SUDO}\", \"id\"])')",
|
||||
# Sensitive-file open inside exec payload
|
||||
"exec(\"open('/etc/shadow').read()\")",
|
||||
"exec(\"with open('/etc/passwd') as f: print(f.read())\")",
|
||||
# Nested
|
||||
f"exec(\"exec(\\\"import os; os.system('{SUDO} id')\\\")\")",
|
||||
],
|
||||
)
|
||||
def test_literal_attack_payload_blocked(self, code):
|
||||
assert _is_blocked(code), f"expected to block: {code!r}"
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"code",
|
||||
[
|
||||
# Pure expressions — must remain allowed
|
||||
"eval('1 + 2')",
|
||||
"eval('len([1, 2, 3])')",
|
||||
"eval('sum(range(10))')",
|
||||
"exec('x = 1\\ny = 2\\nprint(x + y)')",
|
||||
"exec('print(\"hello\")')",
|
||||
# Nested but innocuous
|
||||
"exec('exec(\"print(1)\")')",
|
||||
],
|
||||
)
|
||||
def test_legitimate_eval_exec_allowed(self, code):
|
||||
assert not _is_blocked(code), (
|
||||
f"expected to allow (would dumbify tool calling): {code!r}"
|
||||
)
|
||||
|
||||
|
||||
class TestPatchD_EvalExecDynamicPayload:
|
||||
@pytest.mark.parametrize(
|
||||
"code",
|
||||
[
|
||||
# Non-literal payloads — flagged as dynamic shell escape
|
||||
"payload = 'print(1)'; exec(payload)",
|
||||
"import os; exec(os.environ['PAYLOAD'])",
|
||||
"import base64; exec(base64.b64decode('cHJpbnQoMSk=').decode())",
|
||||
"exec(input())",
|
||||
],
|
||||
)
|
||||
def test_dynamic_payload_flagged(self, code):
|
||||
assert _is_blocked(code), f"expected to block: {code!r}"
|
||||
|
||||
|
||||
class TestPatchD_NestedDepthCap:
|
||||
def test_nested_depth_does_not_crash(self):
|
||||
# 10 levels of nested exec(exec(...)) — depth cap at 3 means the
|
||||
# recursion stops early. Must not crash, must not false-positive on
|
||||
# the innocuous innermost payload.
|
||||
payload = "print(1)"
|
||||
for _ in range(10):
|
||||
payload = f"exec({payload!r})"
|
||||
# Outer payload is now exec("exec(\"exec(...))\")") with no
|
||||
# blocked/sensitive content. Must remain ALLOWED.
|
||||
assert not _is_blocked(payload), payload[:80] + "..."
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Cross-cutting — full regression sweep against the existing upstream
|
||||
# attack-pattern matrix to prove these patches don't break the existing
|
||||
# blocks.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestCrossCuttingNoRegression:
|
||||
@pytest.mark.parametrize(
|
||||
"code",
|
||||
[
|
||||
# Pre-existing shell-escape blocks — must still fire
|
||||
f"import os; os.system('{SUDO} whoami')",
|
||||
f"import subprocess; subprocess.run(['{SUDO}', 'x'])",
|
||||
# Pre-existing signal tampering
|
||||
"import signal; signal.signal(signal.SIGALRM, signal.SIG_IGN)",
|
||||
# Pre-existing sensitive-file open
|
||||
"open('/etc/passwd')",
|
||||
# Pre-existing untrusted host
|
||||
"import requests; requests.get('https://evil.example.com/')",
|
||||
# Pre-existing metadata host
|
||||
"import requests; requests.get('http://169.254.169.254/')",
|
||||
],
|
||||
)
|
||||
def test_preexisting_blocks_still_fire(self, code):
|
||||
assert _is_blocked(code), f"REGRESSION: pre-existing block failed: {code!r}"
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"code",
|
||||
[
|
||||
# Pre-existing allowed patterns — must still pass
|
||||
"print('hello')",
|
||||
"import json; json.loads('{}')",
|
||||
"import requests; requests.get('https://wikipedia.org/')",
|
||||
"import requests; requests.get('https://huggingface.co/x')",
|
||||
"from dataclasses import dataclass\n@dataclass\nclass P: x: int",
|
||||
"open('data.csv', 'r')",
|
||||
"open('logs/today.log', 'w')",
|
||||
],
|
||||
)
|
||||
def test_preexisting_allowed_still_pass(self, code):
|
||||
assert not _is_blocked(code), (
|
||||
f"REGRESSION: pre-existing pass-through now blocked: {code!r}"
|
||||
)
|
||||
Loading…
Add table
Add a link
Reference in a new issue