Close six bypasses Codex found on the round-45 branch. Five harden the workdir-module
import vetter (the only scan of a helper .py the user wrote before import); the sixth
adds an openssl output-file scan.
- ctypes / native modules: the vetter only treated subprocess / pty as execution
modules, so a helper doing import ctypes reached UNGUARDED native libc
(ctypes.CDLL(None).open/write) bypassing the patched Python open / os.open. Refuse
ctypes / _ctypes / cffi and the source-executing runpy / code / codeop.
- dynamic import: a helper bypassed the literal import subprocess check with
importlib.import_module('subprocess'). Refuse import_module / reload whose target
is a denied module (constant or module name); a dynamic import_module target fails
closed.
- closure / frame gadgets: __closure__ / cell_contents / f_locals / __globals__ /
__subclasses__ (etc.) recover a runtime guard wrapper's original unguarded callable
or walk to os / builtins. Refuse the top-level _GADGET_DUNDERS set inside a workdir
helper too.
- indirect import-machinery access: the vetter caught only the literal
sys.meta_path attribute, so vars(sys)['meta_path'][:] = [...] (or
getattr(sys, 'meta_path')) removed the vetter and imported an unscanned sibling.
Refuse getattr / vars namespace-dict access on sys / os / builtins / importlib /
deserializer modules (constant sink name, or a non-constant name that cannot be
proven benign).
- subscripted builtins: imported helpers run with __builtins__ as a dict, so
__builtins__['ev'+'al'](...) reached eval past the attribute checks. Refuse a
subscript into __builtins__ / a builtins alias whose (statically foldable) key is
an execution builtin, and fail closed on a non-constant key.
- openssl output files: openssl rand -out /tmp/p 4 (and -writerand / -keyout /
-CAout / ...) writes a host file in an unguarded child. Block an openssl output-file
flag whose value escapes the workdir; a workdir-local -out and the no-output forms
(openssl rand -hex, openssl dgst) stay allowed. openssl joins the argv tail-scan set
so the subprocess.run(['openssl', ...]) form is covered too.
Regression coverage: TestRound46Bypasses in tests/test_sandbox_tools.py (openssl
escaping output blocked in the shell-string and argv forms; -hex / dgst / workdir-local
-out allowed) and six workdir-module vetter cases in
tests/test_sandbox_runtime_backstop.py (ctypes, dynamic import, __closure__, indirect
vars(sys) meta_path, subscripted __builtins__['eval'] denied; importlib.import_module of
json still allowed).
Close four bypasses Codex found on the round-44 branch:
- fn.__code__ = <code object>: rebinding a function's code runs it via fn()
WITHOUT eval / exec, the __code__ twin of the FunctionType gadget. The
assignment visitor only checked container-stored exec aliases, so
co = codeop.compile_command('...'); f.__code__ = co; f() ran unanalyzed
source. Flag a __code__ store whose RHS is not a vetted code object; an
in-source function's code (g.__code__) and a compile() result (analyzed at
the compile site) stay allowed.
- workdir-module getattr obfuscation: the import vetter caught direct
os.system(...) but not getattr(os, 'system')('...') in an imported helper,
so the top-level analyzer saw only the file write / import and the vetter
passed. Refuse getattr on a sink-module receiver (os / posix / builtins /
deserializers) -- a constant sink attribute name, and a non-constant name
that cannot be proven benign.
- subprocess cwd= ignored for child writes: the argv scan reconstructed the
git command but dropped cwd=, so subprocess.run(['git','init','repo'],
cwd='/tmp') created /tmp/repo outside the workdir. Model a literal escaping
cwd= as a synthetic `env -C <cwd>` wrapper on the reconstructed command so
the existing git cwd backscan resolves the escape; a workdir-relative cwd
adds no wrapper and stays allowed.
- GNU env glued -C / -u operands: env -C/tmp git init repo (and
env -uGIT_CONFIG_COUNT git ...) glue the chdir / unset operand directly onto
the short flag, which the separated and --long= scans missed, so the git cwd
and hook-suppression backscan never saw the escape. Parse the glued short
forms alongside the separated ones.
Regression coverage: TestRound45Bypasses in tests/test_sandbox_tools.py
(__code__ store of a producer / opaque code object blocked while a compile()
result and g.__code__ stay allowed; subprocess git under an escaping cwd
blocked while a workdir-relative cwd is allowed; env -C/tmp and
-uGIT_CONFIG_COUNT before git blocked while plain env git init is allowed) and
two workdir-module vetter cases in tests/test_sandbox_runtime_backstop.py
(getattr(os,'system') helper denied, benign getattr on a plain object allowed).
Close three bypasses and one false positive Codex found on the round-43 branch:
- types.FunctionType() of an unvetted code object: the gadget was only flagged
when its first arg was a compile() result, so a code object from any other
producer (codeop.compile_command(), a loader's get_code(), or an opaque
name) ran source the recursive eval/exec analysis never saw. Replace the
compile-only denylist with an allowlist: FunctionType is allowed only when
its first arg is an ordinary in-source function's code object
(fn.__code__ / meth.__func__), whose body is analyzed normally, and fails
closed for everything else. Robust against new producers instead of chasing
each one.
- subscript-stored exec alias: storing a dynamic-exec builtin into a container
element (d['e'] = exec; d['e'](payload)) hid the sink from the name /
attribute call checks -- the alias tracker only followed plain-name targets
-- so the later subscript call ran an unreviewed payload. Flag the store
itself: there is no benign reason to stash exec / eval / compile / __import__
in a container slot.
- deserializer in an imported workdir module: the module import vetter (the
only scan of a helper .py the user wrote) checked shell / eval / import /
network sinks but not deserializers, so a helper calling pickle.loads on
bytes whose reducer runs posix.system spawned an unguarded child. Refuse a
workdir module that calls a reduce-executing deserializer (pickle / marshal /
dill / cloudpickle / jsonpickle load / loads / Unpickler / decode) or binds
one via from-import. json / importing pickle for dumps stay allowed.
- false positive: shell keywords as separators regardless of position. if /
while / until (and then / do / else / elif) were treated as command
separators everywhere, so `echo if touch` was rejected as if `touch` ran even
though it is just data passed to echo. Only reset command position for these
keywords when they appear AT command position (the compound-statement
header); real separators (; | && ...) still reset everywhere, so
`if touch x; then :; fi` stays blocked.
Regression coverage: TestRound44Bypasses in tests/test_sandbox_tools.py
(FunctionType of codeop / loader / producer code objects blocked while
fn.__code__ stays allowed; subscript-stored exec / eval / compile blocked while
a benign container store is allowed; `echo if touch` allowed while the compound
headers stay blocked) and two workdir-module vetter cases in
tests/test_sandbox_runtime_backstop.py (pickle.loads reduce payload denied,
json.loads still allowed).
Close four bypasses Codex found on the round-42 branch:
- builtins-qualified exec sinks in an imported workdir module: the import
vetter only rejected the BARE eval/exec/compile/__import__ names, so a
workdir helper doing `import builtins; builtins.eval("...")` ran arbitrary
code at import unscanned. Recognize the execution builtins reached as an
attribute of the builtins module (or an alias), for both the direct call
and the assign-only reference (e = builtins.eval; e(...)). Requiring a
builtins root keeps a benign .compile()/.eval() on another object
(model.compile, df.eval) from being misread as a sink.
- keyword-only compile() payload: compile() accepts its source as the
source= keyword, and a standalone compile() with no positional arg
reached the payload-recovery early return (no node.args -> NO_PAYLOAD),
so its code object was executed via the fn.__code__ = c; fn() gadget
entirely unscanned. Recover the source= keyword before returning
NO_PAYLOAD (eval / exec take no keyword arguments in CPython, so an empty
node.args there is genuinely payload-less). A benign keyword-only compile
is analyzed, not blanket-blocked.
- dotted stdlib network imports in a workdir module: the vetter left the
urllib / http tops out so urllib.parse stays benign, but that also let a
helper `import urllib.request` (or http.client) open outbound connections
the static network policy never saw. Refuse the network submodules by
their full dotted name (urllib.request, urllib.robotparser, http.client,
xmlrpc.client), covering the import, `import ... as`, `from urllib.request
import ...`, and `from urllib import request` forms, while urllib.parse and
the bare tops remain importable.
- sponge child writer: sponge (moreutils) soaks up stdin and writes it to a
file argument (printf x | sponge /tmp/probe), an unguarded-child write
outside the workdir. Add it to the child-writer denylist next to tee /
patch / mktemp.
Regression coverage: TestRound43Bypasses in tests/test_sandbox_tools.py
(keyword-only compile via the __code__ / FunctionType / exec(compile())
gadgets, sponge child writer, plus a benign keyword-only compile that stays
allowed) and three workdir-module vetter cases in
tests/test_sandbox_runtime_backstop.py (builtins.eval sink denied,
urllib.request denied, urllib.parse still allowed).
Close six P1 bypasses Codex found on the round-41 branch:
- compile(source=...) keyword payload: compile() accepts its source as the source=
keyword, but the analyzer only recovered the 1st positional arg, so a keyword-only
compile feeding types.FunctionType(code)() (or exec(compile(source=...))) was treated
as having no payload and ran unscanned. Recover the source= keyword too (new
_compile_source_node), at both the code-object tracking and exec(compile()) sites.
The remaining five harden the workdir-module import vetter (a helper .py the user
wrote is vetted before import; each of these slipped a payload past it):
- PEP 263 source encoding: the vetter read modules as fixed UTF-8, but Python's loader
honors an encoding cookie. A `# coding: utf_7` module hides os.system in what the
UTF-8 scan sees as a comment (raw +AAo- bytes are a newline under UTF-7). Decode with
importlib.util.decode_source so the vetter sees what the loader will run.
- bytecode cache: after scanning the source, returning the original spec let
SourceFileLoader satisfy the import from a planted __pycache__ .pyc whose header
matches the harmless source. Run the EXACT vetted source via a dedicated loader
(_GuardVettedSourceLoader) so the bytecode cache is never consulted.
- symlinked module: a workdir module that is a symlink to an outside file had a realpath
outside the workdir, so it was treated as not-workdir and handed to the default loader
unvetted. Decide workdir-membership by the origin path, then fail closed when the
realpath escapes.
- network sinks: the vetter only checked command-exec/eval, so a helper doing
socket.create_connection(...) bypassed the static network policy (no runtime network
backstop). Refuse a workdir module that imports a network primitive (socket / ssl /
ftplib / smtplib / requests / httpx / aiohttp / ...).
- os import aliases: sink references were only recognized when rooted at literal os /
posix, so import os as o; s = o.system; s(...) passed (the assignment, not a direct
call). Record os / posix import aliases before checking sink references.
Regression coverage: TestRound42Bypasses in tests/test_sandbox_tools.py (compile
source= keyword, positional, and exec(compile()) forms) and five workdir-module vetter
tests in tests/test_sandbox_runtime_backstop.py (utf-7 encoding denied, forged pyc
ignored while the vetted source runs, symlinked module denied, network sink denied, os
import alias denied).
Close eight findings Codex raised on the round-40 branch (7 P1 + 1 P2 FP):
- env -C command-substitution operand: env -C $(printf /etc) cat passwd (and the
backtick form) tokenizes the operand into separator tokens, so the per-command
chdir-dynamic state was reset before the trailing reader. Keep the env -C dynamic
flag across command-substitution punctuation ( ( ) ` ), and mark it when the operand
itself starts with a substitution token.
- dynamic env -C into a nested shell: env -C ${X:-/etc} bash -c 'cat passwd' marked the
cwd dynamic but the nested-shell recursion passed only the original cwd_dynamic,
dropping it. Propagate the current env -C cwd and its dynamic flag into the payload
scan.
- argv env -C before a bash -c payload: subprocess.run(['env','-C','/etc','bash','-c',
'cat passwd']) scanned the payload before applying the argv env -C, treating passwd as
workdir-local. Fold the argv env -C (via _argv_env_chdir) into the payload's cwd, or
fail closed on a dynamic DIR.
- env --unset (separated) / bare - drop git hook suppression: the git backscan handled
-i / --ignore-environment / -u NAME / --unset=NAME but not --unset NAME (separated)
or a bare - (GNU env: implies -i). Add both so the injected GIT_CONFIG_COUNT hook
suppression cannot be stripped before a git child.
- workdir module import-vetter mutation: a workdir module of just
`import sys; sys.meta_path.pop(0)` passed the vetter (pop is not an exec attr), then a
second workdir module imported unscanned with the vetter removed. Refuse a workdir
module that touches the import machinery (sys.meta_path / path_hooks /
path_importer_cache).
- git apply --unsafe-paths: a patch applied with --unsafe-paths can write targets
outside the working tree (a +++ ../../tmp/x hunk) in the unguarded git child. Deny the
unsafe mode; a plain git apply p.patch (in-tree targets) stays allowed.
- patch child writer: patch is an unguarded native writer (patch -o /tmp/x, or a ../../
target in the diff), so add it to the child-writer denylist alongside touch / cp / tar.
- P2 FP -- literal sensitive-path scan over-blocked non-readers: the unconditional token
scan flagged any command that merely mentioned a sensitive path (echo /etc/passwd,
printf %s /etc/passwd). Make the scan command-word aware and exempt an explicit
non-reader allowlist (echo / printf / : / true / false / test / [); every other command
word -- readers AND unknown commands -- still fails closed.
Regression coverage: TestRound41Bypasses in tests/test_sandbox_tools.py (the seven
static items plus an unknown-command-still-blocks control and a benign-allowed set incl.
echo/printf/test with a sensitive path) and a workdir-module meta_path mutation test in
tests/test_sandbox_runtime_backstop.py.
Close eight P1 bypasses Codex found on the round-39 branch:
- global / nonlocal chained alias: a `global t; t = s` (with s = os.system bound in
the module / an enclosing scope) rebinds the target-scope name from an EXISTING
alias, but the global/nonlocal indexer only resolved a DIRECT sink RHS, so t stayed
unlinked and t('...') ran unguarded. Resolve a bare-name RHS through the local map
and the already-indexed enclosing / module scopes, matching the local pass.
- env -C behind a wrapper operand (argv): _argv_env_chdir stopped at a wrapper's own
operand (timeout 1 ...) before reaching env, so run(['timeout','1','env','-C','/etc',
'cat','passwd']) escaped. Skip wrapper flags / numeric operands, mirroring
_blocked_in_argv, so the trailing env -C is found.
- env -C shell operand with an expansion: `P=/etc; env -C $P cat passwd` stored $P
literally as the cwd and never combined it. Track local VAR=value bindings and
resolve a $VAR chdir operand against them; an unknown $ / backtick expansion fails
closed for the following relative reader.
- wrapper-hidden reader under a dynamic cwd: the cwd=P (non-literal) reader check only
tested argv[0], so run(['timeout','1','cat','passwd'], cwd=P) hid the reader behind
the wrapper. Resolve the executed command word past wrappers before the relative-arg
fail-closed decision.
- sys.meta_path.__class__ mutation: the unbound list-method guard recognized `list.*`
and `type(sys.meta_path).*` but not `sys.meta_path.__class__.pop(sys.meta_path, 0)`,
which removes the workdir import vetter. Add the `.__class__` receiver form (and the
same for the sys.modules loader-table guard).
- getattr / vars re-export of a call-returned module: `<mod>.os.system` was caught, but
getattr(__import__('pathlib'), 'os').system(...) / vars(...)['os'].system(...) /
<mod>.__dict__['os'].system(...) were not. Map an os / posix / subprocess fetched by
name off any expression to the sink module.
- git stuck short path option: git archive -o/tmp/x glues the escaping output path onto
the short flag with no space, which the separated / --opt=val scans missed. Handle the
-o / -O / -C stuck short form (a non-escaping value like -oout.tar stays allowed).
- find / ls on an expanded path: only _SHELL_READ_COMMANDS ran the expansion check, so
find ${P:-/root/.ssh} -exec cat {} \; and ls $SECRET enumerated an unresolved host
root. Add find / ls as enumerator readers; literal find / ls (find . -name '*.py',
ls -la) carry no expansion and stay allowed.
Regression coverage: TestRound40Bypasses in tests/test_sandbox_tools.py (per-item
blocked cases plus a benign-allowed set: benign global reassignment / alias, literal
find / ls, a relative git output path, benign git archive, a non-reader wrapped command
under a dynamic cwd, and a benign getattr on a non-module object).
Close seven follow-up findings Codex raised on the round-36..38 git / env /
import-vetter work (5 P1 bypasses + 2 P2 false positives):
- sys.meta_path mutation (P1): the workdir-module vetter is installed as the first
sys.meta_path finder, but the static analyzer never rejected mutating that list, so
sandboxed code could sys.meta_path.pop(0) (or clear / reassign / del) to drop the
vetter, then write and import a planted evil.py. Deny any Store / Del / mutating
method on sys.meta_path (bound, unbound list.*, subscript, and reassignment);
reading / iterating the list stays allowed.
- env strips git hook suppression (P1): the env-based core.hooksPath suppression only
helps if the child keeps the injected GIT_CONFIG_* vars, but env -i /
--ignore-environment starts git with an empty environment and env -u
GIT_CONFIG_COUNT / --unset=GIT_CONFIG_* removes it, re-enabling a planted
.git/hooks/*. Flag an env wrapper that drops the suppression before a git child
(git-config-env-override). env -i before a non-git command stays allowed.
- git include.path (P1): include.path / includeIf.<cond>.path pull in another config
file whose contents git honors, so an included workdir config can set core.hooksPath
even though the direct key is blocked. Treat any include*.path key as exec-capable in
_git_config_key_is_exec (covers git -c and git config forms).
- pyc-only workdir import (P1): the import vetter only inspected modules whose origin
ends in .py, so a planted sourceless evil.pyc imported via the default bytecode
loader ran unscanned. Refuse any non-source (.pyc / .so / ...) workdir module
outright; only a readable .py is source-scanned.
- from-os sink workdir import (P1): the vetter rejected import subprocess / from
subprocess but not from os import system (a bare sink name), so such a helper ran an
unguarded child at import time. Reject a from os / from posix import of a sink name
(or a star import), and flag an actual sink-named call on any receiver.
- workdir-module attribute FP (P2): the vetter refused any module containing an
attribute named system / popen / ... regardless of receiver, so a benign helper with
a data attribute (p.system = 'linux') failed to import. Scope rejection to actual
sink CALLS and to sink references rooted at os / posix; an unrelated same-named
attribute is no longer a sink.
- single-quoted command-sub FP (P2): the sensitive-read scanner extracted $() /
backtick payloads without tracking quote state, so echo '$(cat /etc/passwd)' (a
literal, since single quotes suppress substitution in POSIX) was blocked as a secret
read. Track single / double quote state in _extract_command_subs; substitutions
inside double quotes are still extracted.
Regression coverage: TestRound39Bypasses in tests/test_sandbox_tools.py (meta_path
mutation, env -i/-u git strip, include.path, double-quote-sub still blocks, plus a
benign-allowed set incl. the single-quote literal) and three workdir-module import
tests in tests/test_sandbox_runtime_backstop.py (pyc-only denied, from-os denied,
benign same-named attribute allowed).
Close seven follow-up bypasses Codex found on the round-37 git / env work:
- GIT_CONFIG_* env override: a leading GIT_CONFIG_COUNT / GIT_CONFIG_GLOBAL /
GIT_CONFIG_SYSTEM (or any GIT_CONFIG*) assignment could drop or shadow the
injected core.hooksPath suppression that _build_safe_env relies on. Treat a
GIT_CONFIG / GIT_CONFIG_* assignment in front of a git child as an unsafe
override (git-config-env-override), and in the argv env-node path require the
suppression key to be present (no override, no ** splat) for a git child.
- GIT_DIR / GIT_WORK_TREE / GIT_INDEX_FILE env vars: an assignment (shell prefix
or argv env mapping) that points git's dir / work tree / index to an escaping
path writes the repo outside the workdir. Block those when the value escapes
(git-write-outside).
- env argv assignments: env NAME=VALUE ... argv[0] carried inline assignments the
scan skipped, so run(['env','PATH=.','evil']) / BASH_ENV hid an unsafe PATH.
Reconstruct the full argv when an env wrapper carries NAME=VALUE tokens and
rerun the blocked-command / unsafe-PATH scan.
- interactive rc shells: bash -ic / sh -i -c source rc files from a user-writable
workdir before running the command. Deny an interactive (-i in a bundled short
flag) shell invocation (shell-interactive-rc:<shell>).
- sed -e / --expression writes: a write / exec command (w / W / s///w / e) can ride
in an -e SCRIPT / -e'SCRIPT' / --expression=SCRIPT operand, not just the bare
positional script. Extract and scan every script source (mutating:sed).
- PATH+= append: the assignment regex did not match NAME+=, so PATH+=:. was parsed
as a local command. Accept a += append assignment (_ASSIGNMENT_RE) and evaluate
PATH+=value as $PATH + value so an unsafe append is still caught while a benign
absolute append (PATH+=:/opt/bin) stays allowed.
- git --exec-path / --config-env / config --file: --exec-path=DIR runs git helpers
from DIR, --config-env=KEY=VAR binds an execution-capable config from an env var,
and git config --file/-f PATH writes a config outside the workdir. Block the exec
forms (git-exec-config) and the escaping --file target (git-write-outside).
Regression coverage: TestRound38Bypasses in tests/test_sandbox_tools.py (per-item
blocked cases plus a benign-allowed set: plain git commit / init, non-write sed
print and substitution, a safe env PATH prefix, a non-interactive shell, and a
benign absolute PATH+= append).
Close eight follow-up bypasses Codex found on the round-36 git / env work:
- git operand via expansion: a git path operand from a locally-assigned absolute
variable (OUT=/tmp/repo; git init $OUT) was treated as sandbox-local. The git
scan now resolves a $VAR / ${VAR} operand against the command's local bindings
(new _git_operand_escapes); an unknown external expansion is left to the literal
check so git clone $REPO_URL is not a false positive.
- single-assignment env PATH: a non-shell subprocess with an env bound by a single
assignment (e = {'PATH': '.'}; run(['evil'], env=e)) skipped the unsafe-PATH
check because env was a Name, not an inline dict. Resolve the single-assignment
env node to its literal dict before the BASH_ENV / unsafe-PATH scan.
- env -C in argv: the argv-tail git rescan sliced off a preceding env -C /tmp, so
run(['env','-C','/tmp','git','init','repo']) hid the escaping cwd. Reconstruct
from the FULL argv so the git cwd backscan sees the wrapper.
- git exec configs: git -c KEY=CMD / git config KEY CMD run their value in an
unguarded child for execution-capable keys (core.fsmonitor / sshCommand / pager /
editor / credential.helper / filter.*.clean / diff.external / ...); core.hooksPath
/ init.templateDir re-point hooks (undoing the env hook suppression). Block those
configs (alias.*=! was already handled); benign configs (user.name) stay allowed.
- workdir module import vetter: user code may import a sibling .py it wrote, but
that source was never statically analyzed, so a planted workdir/evilmod.py could
run os.system('cat /etc/passwd') at import time in the guarded interpreter. A
meta-path finder now vets a module resolved FROM the workdir and refuses it if it
reaches a command-execution sink or eval/exec/compile; library imports and benign
sibling modules still load. (Direct sinks only; deeper obfuscation is a residual.)
- env -S / --split-string reads: env -S 'cat /etc/passwd' / --split-string= run
the operand as a command, but the READ scanner treated it as inert. Recurse the
split-string payload into the sensitive-read scan (shell-string and argv forms).
- make: make runs shell recipes read from a workdir Makefile in an unguarded child,
the same escape as the pip / pytest launchers. Deny make / gmake.
Regression coverage: TestRound37Bypasses in tests/test_sandbox_tools.py and the
benign/malicious workdir-module import tests in tests/test_sandbox_runtime_backstop.py.
Close nine static-classifier bypasses and one false positive from Codex, plus
neutralize git hooks in the sandbox env:
- $VAR-expanded PATH: a PATH component from a shell variable bound to a relative
/ cwd value (P=.; PATH=$P evil) or a relative ${VAR:-.} default resolved to the
workdir but was treated as a trusted absolute path. _path_value_is_unsafe now
brace-aware splits the list and resolves local VAR=value bindings and ${VAR-def}
defaults; $PATH / an unknown external $VAR (a trusted absolute) stays allowed.
- hash -p: hash -p PATHNAME NAME binds a command name to PATHNAME, so a later bare
NAME runs a local executable unguarded (hash -p ./evil ls; ls). Block hash -p
with a local-executable pathname.
- find -fls: -fls FILE writes its listing to FILE like -fprint/-fprintf; add it to
the mutating-find actions.
- git output options: --output / -o / --output-directory (git archive / format-
patch) carry an inline path git writes to; a value escaping the workdir is now
flagged alongside -C / --git-dir / --work-tree / --separate-git-dir.
- env -C git: env -C DIR / --chdir DIR changes git's cwd, so a bare or relative
git write subcommand (env -C /tmp git init) resolves under DIR. The git scan now
looks back for an escaping env -C wrapper. A relative env -C sub, and env -C with
a non-git reader, stay allowed.
- xargs --arg-file: xargs -a FILE / --arg-file[=]FILE reads its argument list FROM
FILE, so a sensitive / expanded target is a host-file read even though xargs is a
wrapper; the read scanner now flags it.
- watch: watch [options] command runs command (via sh -c or exec -x); add watch as
a command prefix with its -n operand so the wrapped writer is resolved.
- numpy allow_pickle: numpy.load unpickles when allow_pickle is truthy; a non-
literal (flag=True) or splatted (**{'allow_pickle': True} / **kw) value is now
rejected. allow_pickle absent / a constant False stays allowed.
- yaml positional loader (FALSE POSITIVE fix): yaml.load(data, yaml.SafeLoader)
passes the loader positionally; _yaml_call_has_safe_loader now accepts args[1],
so the safe positional form is no longer wrongly blocked.
- git hooks: git runs repository hooks (.git/hooks/*) in an unguarded child; a
sandboxed snippet could plant one and trigger it via git commit / merge /
checkout. _build_safe_env points core.hooksPath at a non-directory (via git's
env-config mechanism) so no repository hook runs for any git subcommand, without
blocking git itself.
Regression coverage: TestRound36Bypasses in tests/test_sandbox_tools.py and the
sandbox-env whitelist test.
Close seven static-classifier bypasses in studio/backend/core/inference/tools.py:
- Home-rooted PATH: in the sandbox HOME and the child cwd ARE the session
workdir, so a ~ / ~user or $HOME / $PWD (${HOME} / ${PWD}) PATH entry lets a
bare command resolve to a workdir shebang. _path_value_is_unsafe now flags
those while keeping absolute, $PATH, and other $VAR (assumed absolute) entries
allowed, so PATH=~/bin evil / env={'PATH': '~/bin'} block.
- git write targets: git is a native child the runtime backstop cannot see, so
git init /tmp/x, git clone url /tmp/x, git init ../x, and git -C /outside /
--git-dir= / --work-tree= / --separate-git-dir= write outside the workdir.
Flag a git path operand or dir-option value that escapes the workdir; all
workdir-relative git usage (status, log, clone url, -C sub) stays allowed.
- args= shell child: the argv sequence can be passed through the public args=
keyword, which left _is_shell_child false and accepted a BASH_ENV / opaque env
for a bash child. Resolve the argv from node.args[0] OR the args= kwarg for
both the executable= reconstruction and the shell-child env check.
- posix_spawn: os.posix_spawn(path, argv, env) executes path while argv[0] is
cosmetic, but it never entered the exec/spawn argv reconstruction, so a
literal-env form (env=() / a byte list) ran a mutating tail (sed -i /tmp/out)
unguarded. Widen the reconstruction to os.posix_spawn / os.posix_spawnp.
- Python launcher scripts: pip / pytest / ipython console scripts start a fresh
unguarded interpreter (the same escape as the already-blocked bare python), so
subprocess.run(['pytest', 'evil.py']) / pip install <local sdist> could run
workdir code. Deny the well-known launcher entry points.
- Command-name globs: /bin/s? / touc? / /bin/[bd]ash expand to a shell / writer
before command lookup while the scanner compares the literal basename. Fail
closed on * / ? / [ ] glob metacharacters in a command word (a bare [ is the
test builtin and stays allowed).
- Pickle-backed loaders: torch.load(weights_only=False), joblib.load, and
numpy.load(allow_pickle=True) run a reduce payload. Flag the unsafe forms
while the safe defaults (torch.load(f), torch.load(f, weights_only=True),
numpy.load(f)) stay allowed.
Regression coverage: TestRound35Bypasses in tests/test_sandbox_tools.py.
Close six static-classifier bypasses in studio/backend/core/inference/tools.py:
- Unsafe PATH search list: a PATH prefix or env mapping with a relative / cwd
entry (PATH=. cmd, PATH=.:$PATH, env={'PATH': '.'}) lets a bare command word
resolve to a workdir shebang, defeating the bare-name PATH exemption. New
_path_value_is_unsafe flags such assignments in the shell-prefix, standalone,
and subprocess env= forms.
- git shell-dispatch alias: git -c alias.X=!CMD X and git config alias.X !CMD run
CMD through an unguarded shell while the scanner sees only git. Detect the !
marker on an alias config value in both the shell-string path and the argv path
(git added to the argv-tail rescan set).
- executable= override: subprocess(argv, executable=PROG) runs PROG with argv[1:]
as its flags, so scanning argv and executable separately misses
run(['x','-i','s/a/b/','/f'], executable='/usr/bin/sed'). Reconstruct PROG +
argv tail and scan the effective command line.
- alias body: alias x='touch f'; x runs the alias body at execution time under a
command word the scanner cannot resolve; scan the body of each alias definition.
- trap -- terminator: trap -- 'CMD' EXIT left the handler unscanned because the
handler operand was read as the -- token. Skip trap options / -- in both the
blocked-command and sensitive-read trap scans.
- interactive / persisted-startup shells: bash -i (and combined -ic) sources rc
files before the -c payload, and an exported BASH_ENV / ENV in a separate
command persists for later shells; flag both as unscanned startup.
Regression coverage: TestRound34Bypasses in tests/test_sandbox_tools.py.
- Scan command substitutions regardless of the outer command and quoting: bash runs $(...) /
backtick payloads even inside double quotes, so echo "$(head -1 /etc/passwd)" read the file
while the scanner only inspected reader-command arguments. Extract every substitution payload
(new _extract_command_subs) and recurse the read scanner into it.
- Normalize .. before the system-bin exemption: _is_local_executable_path trusted any /usr/bin/
prefix, so /usr/bin/../../<workdir>/evil escaped the local-exec check and ran an unguarded
shebang. normpath the command path first (the leading-slash check on the original token keeps
./evil local).
- Block flock as a command-executing wrapper: flock [opts] <file>|<fd> <command> (or flock -c
<command>) runs an arbitrary command in an unguarded child; its file/fd operand + -c forms make
the command word hard to resolve, so block the wrapper outright.
- Treat coproc as a command-position keyword: coproc [NAME] command runs COMMAND asynchronously,
so coproc touch /tmp/escape must resolve touch as the command; add coproc to the keyword set.
- Scan trap handler payloads: trap 'CMD' SIGSPEC runs CMD in the unguarded shell on EXIT / a
signal, so recurse the blocked-command and sensitive-read scanners into the handler operand; a
reset (trap - EXIT) / ignore (trap '' EXIT) has nothing to run.
- Guard the low-level posix / nt directory readers: posix.listdir / posix.scandir re-export the
ORIGINAL enumerators, so an opaque sensitive path (posix.listdir('/root')) slipped past the
os.* dir guard; apply the same sensitive-read confinement to the low-level modules (via a
module-parametrized _guard_dir_reader).
- Reapply fd deniers + dir-reader guards to a freshly created posix / nt module: _reguard_created
only rewrapped open + path mutators, so a fresh module's fchmod / fchown (host-metadata mutation
on a read-only outside fd) and listdir / scandir were unguarded; reapply them too.
Adds TestRound33Bypasses plus runtime posix dir-reader / fresh-module fd-denier tests.
- Preserve command position after shell compound-statement keywords: if / while / until (and
the existing then / do / else / elif) run their CONDITION command, so `if touch /tmp/escape;
then :; fi` executed the child writer while the scanner mistook `if` for the command and
skipped `touch`. Add if/while/until to the command-position keyword set (fixing both the
blocked-command scanner and the wrapper-aware command-word resolver) and to the sensitive-read
scanner's separators so a reader in a condition body is scanned too.
- Add chrt to the command-prefix wrappers: its arity was declared but chrt was not resolved as a
prefix, so `chrt -o 0 touch /tmp/x` treated chrt as the command and never inspected touch.
- Block mktemp as a child writer: mktemp creates a file/dir at a caller-chosen template path
(mktemp /tmp/x.XXXXXX, mktemp -d) outside the workdir in an unguarded child.
- Make the loader-table / namespace-dict / builtins recovery checks alias-aware:
- sys.modules subscript and .get() now use the alias-aware _is_sys_modules, so
`m = sys.modules; m['os'].system(...)` / `m.get('os')...` is caught like the direct form.
- namespace-dict subscript resolves a single-assignment alias
(`g = globals(); g['__builtins__'].__import__('os')`) via a new _is_namespace_dict_expr.
- the dynamic-import check resolves a builtins alias
(`b = __builtins__; b.__import__('os')`) via a new _is_builtins_ref.
Adds TestRound32Bypasses.
- Block frame / traceback introspection that recovers a runtime guard's original callable: the
open()/os.* guard wrappers hold the unguarded callable as a free variable (real), so a snippet
that triggers a denied open() could read it back via a trace hook or the caught exception's
traceback (frame.f_locals['real'], tb.tb_frame.f_locals) and call it directly. __closure__ /
cell_contents were already blocked, so the frame path was the remaining channel; add the frame
acquisition + value-read attributes (f_locals, f_globals, f_back, f_builtins, tb_frame,
tb_next, gi_frame, cr_frame, ag_frame, settrace, setprofile, _getframe, _current_frames,
currentframe) to the introspection-gadget set, flagged for any receiver in both the attribute
and getattr-string forms.
- Treat an opaque compile() source as executable: compile() does not itself run, but its code
object can be executed WITHOUT exec / eval (fn.__code__ = compile(src, '<p>', 'exec'); fn()),
so a non-literal compile source is as unverifiable as an opaque exec / eval payload and is now
blocked too. A literal compile source is still analyzed recursively and stays allowed.
- Treat a leading shell ! as command-position syntax: in bash ! negates the pipeline exit status
but the following word is still the executed command, so ! touch /tmp/escape / ! python3 -c ...
slipped past the child-writer / interpreter blocklist. Skip a command-position ! in the
command scanner and the wrapper-aware command-word resolver so the real command is scanned; a !
in argument position ([ ! -f x ], find . ! -name ...) is unaffected.
Adds TestRound31Bypasses.
- Track env -C in a subprocess argv read scan: subprocess.run(['env', '-C', '/etc', 'cat',
'passwd']) chdirs the child to /etc before the reader runs, so the relative reader arg reads
/etc/passwd. Extract an argv env -C / --chdir dir (new _argv_env_chdir) and fold it into the
cwd used to resolve relative argv reads, mirroring the shell-string env -C handling.
- Resolve from-imported yaml.load / load_all aliases: the safe-loader check only ran for the
yaml.load attribute form, so from yaml import load; load(payload) bypassed it. Track the
bare-name yaml load aliases and apply the same safe-loader check to the direct-call form.
- Fail closed on a non-literal shell startup env: the BASH_ENV / ENV check only inspected an
inline env={...} dict, so env=e, dict(BASH_ENV='env.sh'), and a computed-key dict still set a
startup script bash / sh sources before the scanned -c payload. Fold the dict(...) form and,
for a shell child (shell=True or an argv resolving to bash / sh), flag an opaque / non-literal
env mapping.
- Block a BASH_ENV / ENV assignment prefix before a shell in the shell-string scanner:
BASH_ENV=env.sh bash -c '...' (and the env BASH_ENV=env.sh bash -c form) sources the workdir
script before the -c payload; scan the command segment before each shell command word for a
non-empty startup-env assignment.
- Match a sensitive directory named without a trailing slash: the directory markers carry a
trailing slash to match descendants, so an unguarded ls /root / find /etc/ssh enumerating the
dir itself was accepted. Append a slash to the candidate before the marker check so the dir
itself matches without loosening the component boundary.
- Treat pickle.Unpickler(f).load() as a deserialization sink: the sink-name list caught
pickle.load but not the equivalent Unpickler(file).load() API (incl. dill / _pickle /
cloudpickle and a from-imported ctor). Detect the Unpickler-constructor receiver of a .load()
/ .load_all() method call.
- Recurse into find -exec nested shell reads: find . -exec sh -c 'cat /etc/passwd' ; runs the
quoted -c payload in an unguarded child; the read scanner only recursed into a shell that was
the command word. Scan each -exec segment through the read scanner (mirrors the blocked-command
find -exec handling).
Adds TestRound30Bypasses.
- Resolve a wrapper-prefixed shell argv before scanning: subprocess.run(['env', 'bash', '-c',
'cat /etc/passwd']) hid the nested shell behind argv[0]=env, so only argv[0] was checked for a
shell binary and the -c payload was never scanned. Resolve the executed command word past
wrapper prefixes (via _blocked_in_argv) so env / timeout / nice wrapped bash -c is scanned.
- Skip a wrapper's numeric duration in the shell-string read scanner: timeout 1 bash -c
'cat /etc/passwd' treated the operand 1 as the command word, so the nested bash -c was not
reached. Add the same _is_wrapper_numeric_arg skip the blocklist path already uses.
- Honor args= when failing closed on a dynamic cwd: the fail-closed only inspected positional
argv, so subprocess.run(args=['cat', 'passwd'], cwd=P) slipped. Resolve the argv from the
public args= keyword too.
- Resolve a relative env -C against the ambient subprocess cwd: env -C . cat passwd under
cwd=/etc chdirs to /etc, not the bare fragment, so the relative reader still reads /etc/passwd.
Join a relative env -C / --chdir= operand onto the current child cwd instead of replacing it.
- Treat diff-style utilities as file readers: diff / sdiff / diff3 / colordiff / cmp print file
contents, so an escaping glob (diff /etc/pass* /dev/null) exfiltrated a secret. Add them to
the shell-read command allowlist.
- Expand a literal star-star dict unpack for the shell / cwd decisions: a shell= or cwd= smuggled
through subprocess.run(cmd, **{'shell': True}) was invisible to the kwarg loop (kw.arg is None).
Iterate keywords through a helper that also expands a literal dict unpack.
- Materialize a device-sink path via the base str.replace before trusting it: a str subclass
could override replace() to return '/dev/null' while its real value escaped the workdir. Call
the genuine str.replace on the underlying buffer so the real path is checked.
Adds TestRound29Bypasses plus a runtime device-sink str-subclass escape test.
- Recognize an aliased open-module receiver (import builtins as b; b.open(...), import io as i;
i.open(...), import os as o; o.open(...)) as a read callee via a new _open_mod_aliases set, so
an aliased traversal / sensitive read is caught like the literal builtins/io/os.open forms.
- Record os shell aliases from `from os import system as s` / popen: the import-walk elif that
consumed the os module only recorded `open`, so the later shell-alias branch never saw it and
the read scan skipped s('cat /etc/passwd'). Handle os shell functions in that branch and split
the subprocess from-import handling into its own branch.
- Combine a subprocess cwd= with a shell payload's relative reads: subprocess.run('cat passwd',
shell=True, cwd='/etc') is resolved to /etc/passwd (the shared read scanner now takes a cwd
seed, overridable per-command by env -C), and a NON-literal cwd fails closed for a relative
reader.
- Allow a Python write to a standard device sink (/dev/null, /dev/stdout, ...) in the runtime
guard, checked on the requested path (not its realpath, so /dev/stdout is not followed to a
redirected outside file); benign output-suppression patterns are no longer denied.
Adds TestRound28Bypasses plus runtime device-sink write tests.
- Block running an explicit LOCAL executable path at command position (./evil, subdir/tool) in
both the argv scanner and the shell command scanner: a sandboxed snippet can create + chmod a
local script with an interpreter shebang and run it, starting an unguarded child the basename
scan never sees. Absolute system-bin paths (/bin, /usr/bin, ...) stay allowed and are still
interpreter-checked by basename.
- Fail closed on a child file-reader (cat / head / ...) with a relative argv path under a
NON-literal subprocess cwd= (cwd=P that could evaluate to /etc), which cannot be proven
sandbox-local; a literal benign cwd and a non-reader program stay allowed.
- Treat a shell startup variable (BASH_ENV / ENV) in an explicit subprocess env= dict as a shell
escape: bash / sh sources it before the -c payload runs.
- Runtime backstop: treat the exact /root path (not only /root/*) as sensitive so a directory
reader over the root home is denied, and wrap Path.glob / Path.rglob like Path.iterdir so a
dynamically built receiver pointing at a sensitive directory is screened.
- Make the newline -> ; command-separator rewrite quote-aware, and neutralize quoted separators
before the command-boundary regex, so a quoted multiline string (echo "ok\nrm") is not
mis-blocked; unquoted separators and command substitution ($(...) / backticks, including
inside double quotes) still block.
Adds TestRound27Bypasses plus runtime tests for exact /root and pathlib glob / rglob.
- Block bash's history builtin when it reads/writes a file (history -w / -a / -r / -n): it can
create or overwrite an arbitrary host path (or read a file into the buffer) in the unguarded
shell child. Bare history / -c / -d / -p / -s stay allowed.
- Combine a subprocess cwd= with relative argv paths in the sensitive-read scan, so
subprocess.run(['cat', 'passwd'], cwd='/etc') is seen as a /etc/passwd read.
- Track env -C DIR / --chdir DIR in the shell-string read scan so a later relative reader
argument (env -C /etc cat passwd) resolves against DIR.
- Record aliases created by tuple/list unpacking assignments ((s,) = (os.system,); a, b =
os.system, 1; [e] = [exec]) in the scope alias index, pairing a literal target with a literal
RHS element-wise, so the shell/exec/deserializer sink checks see them.
- Pin the builtins the runtime path guard consults (isinstance / int / bytes / str / any) into
the guard namespace, so sandboxed code cannot reassign builtins.isinstance to make
isinstance(path, int) treat an outside path as an fd and approve an absolute write.
- Re-pin os.path.stat + S_ISLNK before each realpath resolution in the guard, so a
os.path.stat.S_ISLNK = lambda mode: False (stopping realpath from following an in-workdir
symlink that escapes) cannot approve a write the real open() then routes outside.
- Restore the /root/ protection in the runtime sensitive-read backstop for an opaque path,
carving out package / library trees (site-packages, dist-packages, the stdlib) so imports
under a root home are not broken.
Adds TestRound26Bypasses plus runtime tests for the pinned builtins / stat and root-home reads.
- Model bash brace expansion (comma lists) before the block and read scans, so a payload such
as {touch,/tmp/x} or {python3,-c} '...' is seen as the writer / interpreter bash actually
runs. Only unquoted groups with a top-level comma expand; {} (find -exec), ${VAR} parameter
expansion, numeric {1..5} sequences and quoted braces are left intact, and expansion is
bounded.
- Resolve the reader / command word in the shell-string sensitive-read scan past leading
VAR=value assignments and command wrappers (env / nice / timeout / ...), flag a VAR=value
whose value is a sensitive path, and recursively scan a nested bash -c '<payload>' shell, so
a read hidden behind a normal command-prefix form is caught. The classifier and terminal
scanners now share one _scan_command_string_for_reads with a strict_traversal knob (strict
for os.system shell strings, lenient .. for benign in-tree terminal navigation).
- Treat unbound MRO / getattribute access on a guarded file class as the same recovery gadget
as io.FileIO.__mro__: type.mro(io.FileIO), type.__getattribute__(io.FileIO, '__mro__') /
object.__getattribute__(..., 'mro'), and getattr(io.FileIO, '__mro__') are blocked.
Adds TestRound25Bypasses plus terminal brace / prefixed-read regression tests.
- Block sed w-path writes without a separating space (w followed by a slash,
tilde or tab) and sed e / s///e scripts that execute a shell command (new
_SED_WRITE_RE / _SED_EXEC_RE / _SED_SFLAG_RE checks in the mutating-util scan).
- Add rmdir to the POSIX child-writer denylist.
- Split a glued input redirection (sh here-string payload) before shell
detection by adding the input-redirect operator to the shlex punctuation_chars.
- Deny PyYAML unsafe deserialization: yaml.unsafe_load / full_load(_all) are
unconditional sinks, and yaml.load / load_all are flagged unless given an
explicit safe Loader (SafeLoader / CSafeLoader / BaseLoader).
- Rewrite operator.methodcaller('system', ...)(os) to the direct os.system(...)
call in both the signal-escape visitor and the sensitive-read scanner so a
methodcaller-hidden shell / read sink is analyzed.
- Fix chained single-assignment alias resolution (s = os.system; t = s; t(...)):
the scope walk yielded assignments out of order, so process them in source
order before propagating alias identity through smap / emap / dmap.
- Apply the sensitive-read scan to direct terminal (bash) commands, which run in
an unguarded shell child that the Python-tool open() backstop does not cover;
block reads of host identity / credential files, sensitive-target directory
traversal, and escaping-glob / expansion reads while allowing benign in-tree
relative navigation.
Adds TestRound24Bypasses plus terminal sensitive-read regression tests.
Round 19 review follow-ups on the Studio code-exec sandbox classifier and runtime guard:
- Record a global/nonlocal sink alias in its TARGET scope (module for global, enclosing for
nonlocal): global s; s = os.system; s('...') now resolves s to the shell sink instead of
being skipped as a rebound name (and a global name no longer shadows an outer alias).
- Fail closed on any expansion in COMMAND POSITION, not just command substitution: a
variable-expanded command word (p=python3; $p -c ...) / ${VAR} is unprovable. Argument
position ($HOME, echo $(date)) stays allowed.
- Honor wrapper/command-position logic in the mutating-utility and shell-script scans via a
shared command-word index, so env sed -i / timeout 5 bash s.sh no longer hide behind a
wrapper prefix.
- Use the subprocess-exec callee resolver for the shell=True read scan, so
from subprocess import run as r; r('head /etc/passwd', shell=True) and r = subprocess.run
aliases are tokenized.
- Resolve sys.modules aliases (m = sys.modules; m.pop('_io')) for the loader-table mutation
checks (bound and unbound forms).
- Treat a __dict__ subscript keyed by a gadget dunder (type(open).__dict__['__closure__'].
__get__(open)) as gadget access, closing the descriptor-lookup route around the attribute
gadget scan.
- Unwrap inline literal containers in the higher-order sink check, so
list(map([eval][0], [...])) / map({'e': exec}['e'], ...) are flagged.
- Runtime: the mutator guard now accepts the path via its public keyword (os.makedirs(name=),
os.mkdir(path=)) instead of raising TypeError, while still confining the write.
Adds TestRound19Bypasses and keyword-path runtime tests; full sandbox suite green.
Round 18 review follow-ups on the Studio code-exec sandbox classifier and runtime guard:
- Fail closed on a command-position command substitution ($(printf touch) / `printf touch`
as the command word): the expansion becomes the command name and cannot be proven safe. An
argument-position substitution (echo $(date), x=$(cmd)) stays allowed.
- Model direct imports of the os C backend (posix/nt -> os aliases) and pty: posix.system(...)
resolves as an os shell sink and pty.spawn(...)/pty.fork() is flagged as an unguarded child.
- Reject unbound sys.modules mutation (dict.pop(sys.modules, '_io'),
type(sys.modules).__delitem__(sys.modules, ...)) alongside the bound sys.modules.pop form.
- Treat mutating flags of normally read-only utilities as child writers: sed -i, sort -o FILE,
find ... -delete, dd of=FILE, tee FILE, truncate. Non-mutating uses stay allowed.
- Detect os / subprocess re-exported through a stdlib module (pathlib.os.system,
tempfile.os.system, subprocess.os.system): the .os attribute IS the os module.
- Resolve instance-attribute sink aliases (c.e = exec; c.e(payload) / obj.s = os.system;
obj.s('rm -rf /')) tree-wide, alongside the existing class-attribute aliases.
- Import the guard's remaining pure-Python dep (shutil) with the workdir still stripped from
sys.path; the restore now runs at the very end of the prelude, so no workdir/shutil.py can
shadow it.
- Network policy: resolve import aliases (import requests as r; r.get(...)) and inspect the
url= / address= keyword arguments so aliased or keyword-host calls to a metadata / untrusted
host are no longer skipped.
Adds TestRound18Bypasses and extends the workdir-shadowing runtime test to shutil; full
sandbox suite green.
Round 17 review follow-ups on the Studio code-exec sandbox classifier and runtime guard:
- Track a code object built through a LOCAL compile alias (cfn = compile; co = cfn(src);
types.FunctionType(co, {})()) by resolving the callee through the scope exec-builtin map,
so the FunctionType execution gadget still gets the recursive payload analysis.
- Recognize type(lambda: None) as the function constructor: type(lambda: None) IS
types.FunctionType, so type(lambda: None)(code, {})() executed a compile() code object
without the eval/exec gate.
- Include lambda and comprehension scopes in the alias index: (lambda e=exec: e(payload))()
and [e(payload) for e in [exec]] now resolve e back to the exec sink. Lambdas/comprehensions
become their own alias scopes, and a one-element comprehension generator binds its target.
- Record annotated single-assignment aliases (e: object = exec; e(payload)) alongside plain
assignments, so the AnnAssign RHS is analyzed.
- Expand ${IFS} / $IFS to whitespace before shell command matching, so a separator-obfuscated
writer/reader (rm${IFS}-rf${IFS}/, cat${IFS}/etc/shadow) is tokenized as bash runs it.
- Import the child-guard's stdlib deps (os/io/pathlib/re) with the workdir stripped from
sys.path, then restore it, so a malicious workdir/os.py or pathlib.py cannot shadow a guard
import and run unguarded at import time.
- Fail closed on shell redirection to any real-file target (the unguarded child follows a
pre-existing symlink); only fd duplications (>&2) and the standard device sinks
(/dev/null, ...) are allowed. This also fixes a pre-existing false positive where a benign
redirect to /dev/null was blocked.
Adds TestRound17Bypasses and a workdir-shadowing runtime test; full sandbox suite green.
Round 16 review follow-ups on the Studio code-exec sandbox classifier and runtime guard:
- Apply the runtime sensitive-read backstop to Path.open reads (not just write
modes), so a dynamically assembled pathlib receiver (Path(globals()['P']).read_text())
cannot exfiltrate a host secret. On Python <= 3.11 pathlib holds the original io.open,
so confining at the public Path.open level is the version-robust fix. read_text /
read_bytes route through the same self.open() and are covered.
- Scan the -c payload of a subprocess shell argv for sensitive reads. subprocess.run(
['sh', '-c', 'head -1 /etc/passwd']) has no blocked command, but the unguarded child
prints the secret, so the payload is now tokenized and read-scanned like a string sink.
- Normalize bash ANSI-C ($'...') and locale ($"...") quoting before command matching.
shlex leaves $'touch' as the literal $touch, so a writer / interpreter hidden behind
ANSI-C quoting ($'touch' x, $'\x74ouch' x) previously evaded the command blocklist;
the escapes bash resolves (\n, \xHH, octal, \uHHHH) are decoded first.
- Recognize from-imported subprocess exec names as read sinks: from subprocess import
run as r; r(['cat', '../../etc/shadow']) now hits the traversal / sensitive-read check.
- Treat a shell glob that can expand outside the workdir (absolute / ~ rooted, e.g.
head /etc/shad*) as an escaping read expansion and fail closed, for reader arguments
and input redirects. A relative in-workdir glob (grep foo *.txt) stays allowed.
Adds TestRound16Bypasses and pathlib runtime backstop tests; full sandbox suite green.
Round 15 review follow-ups on the Studio code-exec sandbox classifier and runtime guard:
- Resolve single-assignment aliases for shutil.copy* and subprocess exec read
sinks (c = shutil.copy; c('../../etc/passwd', ...); r = subprocess.run; r([...]))
so the traversal / sensitive-path check fires on the aliased callee.
- Block gc.get_referents / get_referrers / get_objects (and from-import aliases):
they walk the object graph to a guarded wrapper's closure cell to recover the
original unguarded open / os.* callable.
- Cap list / tuple concatenation during constant folding so a doubling chain
(a + a + a + ...) cannot materialize an oversized sequence in the parent process
before the child rlimits apply.
- Add a runtime sensitive-read backstop in the child prelude: deny a read whose
realpath resolves to a known host secret (SSH / cloud / kube / netrc / HF-token /
/etc/passwd family / /proc) outside the workdir. This covers opaque read paths the
static scanner cannot fold (open(globals()['x'])) and pre-existing in-workdir
symlinks to secrets, while leaving benign outside reads and library imports intact.
- Fail closed on relative multi-component shell redirect targets (echo x > sub/out.txt)
whose subdirectory component could be a symlink traversing outside the workdir; a
bare single-component target stays allowed.
Adds TestRound15Bypasses and runtime backstop tests; full sandbox suite green.
Static classifier:
- Flag mro().pop(i) / __mro__.pop(i) base-class extraction alongside the subscript and __getitem__ forms.
- Normalize operator.attrgetter('name')(obj) as attribute access whether or not the result is immediately invoked, so attrgetter('__closure__')(open)[0] gadget recovery is caught.
- Resolve a container-hidden open alias (o = [open][0]; o('../../etc/passwd').read()) as a read callee.
- Fail closed on an opaque read path assembled from obfuscation primitives (open(''.join(map(chr, ...))).read()), matching the exec-payload obfuscation policy.
- Treat cd behind the command / builtin shell wrappers as a cwd escape before allowing a relative redirect.
- Block importlib file loaders as execution sinks (SourceFileLoader(...).load_module(), spec.loader.exec_module(...)).
- Add the in-cluster Kubernetes service-account credential path to the sensitive-read list.
Adds TestRound12Bypasses covering each vector plus benign controls.
Static classifier:
- Flag __getattribute__/__getattr__ with a non-foldable (runtime-assembled) attribute name as an obfuscated gadget access.
- Recognize POSIX noclobber redirect targets (>| path) in the shell redirect scanner.
- Detect a container-wrapped compile() code object passed to types.FunctionType.
- Flag mro().__getitem__(i) / __mro__.__getitem__(i) base-class extraction alongside the subscript form.
- Scan shell command strings (os.system / subprocess shell=True / getoutput) for embedded sensitive-file reads.
- Treat a default-parameter value that is a dangerous callable (def f(e=exec)) as a sink alias.
- Normalize a trailing .__call__ for shell/import/deserializer sinks (os.system.__call__, __import__.__call__, pickle.loads.__call__).
- Resolve a container-unwrapped sink assigned first (s = [os.system][0]; s(...)).
- Look through no-op pathlib methods (resolve/absolute/expanduser) when resolving a read receiver.
- Recognize from-imported shutil copy sinks (from shutil import copy as c).
- Extend the higher-order first-class-value check to shell and deserializer sinks (map(os.system, ...), partial(subprocess.getoutput, ...), map(pickle.loads, ...)).
- Normalize operator.attrgetter('name')(obj) as attribute-access obfuscation.
Adds TestRound11Bypasses covering each vector plus benign controls.
Static classifier:
- Flag mro()[i] base-class extraction alongside subscripted __mro__ (FileIO C base recovery).
- Fail closed on non-literal shell redirect targets and cd to an outside directory.
- Block sys.modules mutating methods (pop/popitem/clear/setdefault/update) that drop a guarded module for reimport.
- Detect indirect eval/exec: <builtin>.__call__(payload) and eval/exec/compile passed by reference to a higher-order call (map/reduce/partial), including starred literals.
- Block inspect.getclosurevars() closure recovery of a guarded wrapper.
- Track runpy run_path/run_module from-import aliases.
- Expand starred literal path arguments in the sensitive-read scanner.
- Resolve container-hidden deserializers (([pickle.loads][0])(payload)).
Runtime backstop:
- Guard the low-level posix/nt chdir (cwd escape) and fchmod/fchown fd metadata mutators, matching the os.* deniers.
Adds TestRound10Bypasses and low-level posix runtime tests.
Static classifier:
- deny sys.modules Store/Del: del sys.modules['posix']; import posix drops the guard-patched module for a fresh unwrapped C module
- flag code.InteractiveInterpreter().runcode / InteractiveConsole().runsource as code-object execution sinks (opaque compile results run un-analyzed)
- resolve indirect read-only open callees: from os/io/builtins import open as X (os.open read-only is deliberately allowed outside the workdir, so traversal must be caught statically)
- fold os.path.normpath / abspath on literals so a traversal that only emerges after normalization is scanned
- resolve inline-container-hidden exec/eval: ({'e': exec}['e'])(...) / [exec][0](...)
- normalize the bound one-arg __getattribute__ form obj.__getattribute__('name') (builtins.open.__getattribute__('__closure__'))
- descend into a literal list/tuple argv so subprocess.run(['cat', '/etc/passwd']) is caught
- enforce the analyzer node budget: charge each tree's node count against _MAX_ANALYZER_NODES and fail closed above it (parent-process DoS guard)
Runtime backstop:
- capture and re-pin os.lstat / os.readlink / os.getcwd / os.stat before realpath, since it consults the live symlink helpers -- monkeypatching os.lstat to fail could stop realpath following an in-workdir symlink that points outside
Static classifier:
- flag cell_contents (the only closure-cell reader) as a gadget so recovering a guarded wrapper's original callable via __closure__ fails closed even when the __closure__ name is built at runtime
- treat a class body as its own alias scope (class C: e = eval; e(...) now recognized) while keeping methods lexically skipping the class scope, so a same-named class attr does not shadow the module-level sink a method reaches
- block output redirection (> / >> / &> / N>) to an absolute / ~ / .. target: a child shell runs unguarded; relative in-workdir redirects stay allowed
- resolve pathlib constructor import aliases (from pathlib import Path as P) before traversal reads
- flag an integer-indexed __mro__ (io.FileIO.__mro__[1]) that extracts the unguarded FileIO C base class; plain iteration / slicing stays allowed
- resolve aliased read callees before traversal checks: o = open and import shutil as sh; sh.copy(...)
- recurse into env -S / --split-string operands so env -S 'python3 -c ...' still detects the interpreter
Static classifier:
- fix scope-local walker so a nested def/class reassigning an alias name no longer inflates the outer single-assignment count and drops a real module-level sink alias
- track non-bare compile aliases (builtins.compile, from builtins import compile as comp) for the types.FunctionType(c) code-object gadget
- block child-process file writers at shell command position (touch/tee/cp/mv/mkdir/install/truncate/mkfifo/mknod/shred/unlink): a spawned child runs without the in-process write guard
- expand a literal **{...} unpack in the read scanner so open(**{'file': '../../etc/passwd'}) is resolved
- resolve a pathlib expression bound to a single-assignment name before read methods (p = Path('..')/'etc'/'passwd'; p.read_text())
- keep a wrapper's separated option argument in command position so stdbuf -o L python -c ... still detects the interpreter (env -i rm still caught; no FP on grep patterns)
- treat object.__getattribute__ / type.__getattribute__ as attribute obfuscation, covering gadget dunders and sensitive-module attrs (also closes __closure__ recovery of a guarded wrapper's original callable)
- block runpy.run_path / runpy.run_module execution sinks
- treat shutil.copy*/move SOURCE as a read callee so a .. traversal source is caught
Runtime backstop:
- normalize a bytes realpath (fsdecode) before the workdir prefix compare so a legitimate in-workdir bytes write is not denied by a TypeError; outside bytes writes still denied
Static classifier:
- resolve pathlib expressions passed to open()/read callees so open(Path('/etc') / 'passwd') blocks like open('/etc/passwd')
- flag getattr()/setattr() of an introspection gadget dunder (__globals__, __subclasses__, ...) regardless of receiver
- add the .get() twin of the globals()/locals()/vars() namespace-dict subscript guard
- constant-fold sys.modules[...] and sys.modules.get(...) keys so a concatenated key is caught
- track 'from builtins import __import__ as imp' as a dynamic import alias
- treat deserializer module aliases (pickle, dill, ...) as sensitive targets for getattr/vars/__dict__
- flag code objects executed through types.FunctionType(compile(src, ...), ...), including the c = compile(src); FunctionType(c) two-step
- block language interpreters (python/perl/ruby/node/...) at shell command position: a spawned child runs without the in-process write guard
Runtime backstop:
- pin os.fspath/os.path.realpath to captured originals inside _within so a sandboxed reassignment of os.fspath cannot make realpath resolve an outside write target to an in-workdir path
Scope-aware assignment aliases (extends the per-scope index):
- Resolve single-assignment aliases of dangerous callables in the call's own scope:
e = builtins.eval, im = importlib.import_module (and imp = __import__), and
l = pickle.loads (incl. aliased modules). Previously only bare-name and from-import
aliases were recognized.
- Count function parameters as local bindings so a parameter lexically shadows an
outer sink alias of the same name (fixes a false positive where def f(s): s(...)
with a module-level s = os.system flagged the parameter call).
Sensitive-read scanner:
- Resolve pathlib join receivers -- (Path('/etc') / 'passwd').read_text() and
Path('/etc').joinpath('passwd') -- not just a bare Path(...) constructor.
- Normalize path spellings (collapse redundant separators / '.' and resolve '..')
before the exact / dir checks, so /etc//passwd, /etc/./passwd and
/tmp/../etc/passwd are matched.
- Fold function-local single-assignment path constants (def f(): p = '/etc/passwd';
open(p)), not only module-level constants.
- Flag sys.modules.get('os') as the method-call twin of sys.modules['os'].
Runtime realpath backstop:
- Path.open coerces a str-subclass mode through the base str (matching the other open
wrappers) so a lying __contains__ cannot skip the write check.
- Path.rename/replace/link materialize the target once so a stateful __fspath__ cannot
return an in-workdir path for the check and an outside one for the real call (the
pre-3.11 accessor path where this wrapper is the only confinement).
Adds regression tests across the classifier, aliasing and runtime-backstop suites.
Scope-aware alias resolution (replaces the flat, module-wide alias maps):
- A new per-scope index resolves shell-sink, exec-builtin and compiled-code aliases
with Python lexical scoping. This fixes two problems the flat maps had: a safe
`c = compile('1+1')` in one function no longer shadows a dynamic `exec(c)` in
another (a real bypass), and a `s = os.system` in one function no longer makes a
benign `s = print` call in another look like a shell sink (a false positive), while
still catching a genuine function-local sink and honoring local shadowing of a
module-level alias.
Runtime realpath backstop:
- io.FileIO now passes the MATERIALIZED fspath to the real constructor (a stateful
__fspath__ could otherwise return an outside path to the C constructor).
- Deny an integer fd path for the mutating single-path wrappers (os.chmod(fd) etc.):
a read-only fd opened on an outside file could otherwise mutate host metadata.
Constant-folder allocation DoS:
- Refuse dynamic printf widths/precisions ('%*s', '%.*f') that draw their size from a
runtime argument.
- Bound str.replace / str.join output before it allocates (a long replacement over
many occurrences, or joining many long parts, can build a multi-gigabyte string).
Static classifier:
- Flag builtins / a sensitive module reached through the namespace dict:
globals()['__builtins__'], locals()[...] and globals()['os'].
Adds regression tests across the aliasing, runtime-backstop, const-fold and classifier
suites for every item above.
Runtime realpath backstop:
- Guard the low-level posix / nt module mutators (os re-exports from them, so
posix.open / posix.rename / ... stayed reachable with the originals).
- Guard io.FileIO / _io.FileIO constructors for write modes (a C constructor that
opens a file without routing through open()).
- Add os.mkfifo / os.utime / os.setxattr / os.removexattr (and lchflags) to the
guarded single-path mutators.
- Materialize fspath ONCE per call so a stateful __fspath__ cannot return a workdir
path for the check and an outside path for the syscall (TOCTOU).
- Coerce open() mode through the base str and os.open flags through the base int, so
a str-subclass __contains__ or an int-subclass __and__ cannot lie to the guard.
Constant-folder allocation DoS:
- Refuse str.format templates with a nested width field ({:{}}) driven by an
oversized numeric argument before format() allocates.
Static classifier:
- Reconstruct the full pathlib receiver path (all constructor args, joined) and
accept module-qualified pathlib.Path so Path('/etc', 'passwd').read_text() and
pathlib.Path(...) reads are inspected, not just single-arg bare Path(...).
- Treat builtins.__import__ / __builtins__.__import__ as a dynamic import.
- Count alias single-assignment per function scope instead of tree-wide, so two
functions binding the same local name no longer cancel out and miss a real sink.
Adds regression tests across the runtime-backstop, const-fold, aliasing and
classifier suites for every item above.
Fixes a further batch of P1 bypasses and analysis-time DoS vectors found in review.
Static classifier:
- Decode exec/compile bytes payloads the way CPython does (PEP 263 coding cookie
via tokenize.detect_encoding), then analyze the real source. A bytes payload whose
UTF-8 view is pure comments but whose utf-7 decode runs hidden code no longer slips
through; a payload decoding to a blocked op blocks, a benign one stays allowed.
- Resolve exec-builtin aliases assigned in nested scopes (def f(): e = exec; e(...)),
matching the shell-sink aliasing (stored-once guard keeps it low false-positive).
- Treat deserializer modules (pickle/marshal/dill/...) as dangerous dynamic-import
targets so __import__('pickle').loads(blob) is caught.
- Flag vars(os) / vars(__builtins__) as a module-__dict__ obfuscation, like os.__dict__.
- Inspect the pathlib receiver path for read methods: Path('../../.ssh/id_rsa').read_text()
/ read_bytes() / open() now check the constructor path, not only call args.
- Fold literal os.path.join / posixpath.join so open(os.path.join('/etc','passwd')).read()
is seen by the sensitive-read scanner instead of treated as opaque.
Constant-folder allocation DoS (folding runs in-process, before subprocess rlimits):
- Reject oversized f-string / str.format / %-format widths and precisions before
format() allocates the padded string.
- Reject oversized str padding-method widths (ljust/rjust/center/zfill).
- Cap list/tuple repetition (seq * n) as str/bytes repetition already was.
Runtime realpath backstop:
- Do not publish __wrapped__ on the guard wrappers (functools.wraps would expose the
original unguarded callable, e.g. open.__wrapped__(outside, 'w')).
- Guard the low-level _io.open entry point (io.open / builtins.open originate there).
- Confine os.chdir to the workdir and deny os.fchdir so a cwd escape cannot turn a
later relative read/write into a host-path access.
- Deny fd-based metadata mutators (os.fchmod / os.fchown) that could reuse a read-only
descriptor opened on an outside file.
Adds regression tests across the const-fold, aliasing, exec-recursion and runtime-
backstop suites for every item above.