Commit graph

6,187 commits

Author SHA1 Message Date
danielhanchen
8384cea660 Harden sandbox: fail closed on unvetted FunctionType code objects, subscript-stored exec aliases, and workdir deserializers; only treat shell keywords as separators at command position
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).
2026-07-10 17:17:32 +00:00
danielhanchen
997c7247f2 Harden sandbox: builtins-qualified exec sinks and dotted network imports in workdir modules; keyword-only compile payload; sponge child writer
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).
2026-07-10 16:41:10 +00:00
danielhanchen
f11fbdcb7f Harden sandbox: compile(source=) payload; workdir import vetter -- PEP263 decode, ignore bytecode cache, refuse symlinks, network sinks, os import aliases
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).
2026-07-10 16:05:17 +00:00
danielhanchen
6f54040042 Harden sandbox: env -C command-subs / nested-shell / argv cwd, env --unset git hooks, workdir meta_path, git apply --unsafe-paths, patch; scope literal-path reads to readers
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.
2026-07-10 15:28:30 +00:00
pre-commit-ci[bot]
3570bf8687 [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2026-07-10 14:51:22 +00:00
danielhanchen
b4d6b5325d Harden sandbox: global/nonlocal chained aliases, wrapper-hidden env -C / readers, meta_path.__class__, getattr re-exports, git -o/x, find/ls expanded reads
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).
2026-07-10 14:50:00 +00:00
pre-commit-ci[bot]
7ee6993c7d [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2026-07-10 13:46:33 +00:00
danielhanchen
a965f01a6e Harden sandbox: sys.meta_path mutation, env -i/-u git hook strip, git include.path, pyc/from-os workdir imports, single-quote command-sub FP
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).
2026-07-10 13:45:50 +00:00
danielhanchen
6cdac72b6b Harden sandbox: git config-env / GIT_DIR overrides, env argv assignments, interactive shells, sed -e writes, PATH+=, git --exec-path/--config-env/--file
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).
2026-07-10 13:09:32 +00:00
danielhanchen
d8f8566a3b Harden sandbox: git operand expansion, single-assign env PATH, env -C argv, git exec configs, workdir import vetter, env -S reads, make
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.
2026-07-10 12:31:23 +00:00
danielhanchen
c3be77614b Harden sandbox: $VAR PATH, hash -p, find -fls, git output/env-C/hooks, xargs --arg-file, watch, numpy allow_pickle, yaml positional loader
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.
2026-07-10 11:52:31 +00:00
danielhanchen
3bad13b58d Harden sandbox: home-rooted PATH, git write targets, args= shell child, posix_spawn, python launchers, command globs, pickle loaders
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.
2026-07-10 11:13:59 +00:00
danielhanchen
c2e10298b0 Harden sandbox: PATH-relative exec, git alias dispatch, executable=, alias body, trap terminator, interactive/BASH_ENV shells
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.
2026-07-10 10:39:56 +00:00
danielhanchen
8be5765b04 Harden sandbox: quoted command subs, path-normalize system-bin, flock/coproc/trap, low-level posix dir readers + fresh-module fd deniers
- 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.
2026-07-10 10:10:01 +00:00
danielhanchen
b725253e61 Harden sandbox: shell condition-body command position, chrt/mktemp, alias-aware module/builtins recovery
- 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.
2026-07-10 09:30:53 +00:00
danielhanchen
cc12c8d10a Harden sandbox: block frame introspection, opaque compile, and shell pipeline negation
- 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.
2026-07-10 08:37:51 +00:00
danielhanchen
bcc023b456 Harden sandbox: env -C in argv reads, yaml.load from-imports, shell startup env, exact sensitive dirs, Unpickler.load, find -exec reads
- 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.
2026-07-10 07:57:36 +00:00
danielhanchen
e80c4b4b1f Harden sandbox: wrapper-prefixed shell argv, wrapper durations, args= cwd, relative env -C, diff readers, literal kwargs
- 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.
2026-07-10 06:39:37 +00:00
danielhanchen
475c06c968 Harden sandbox: aliased open modules, os shell from-imports, subprocess shell cwd, device sink writes
- 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.
2026-07-10 06:09:54 +00:00
danielhanchen
e6d24369a9 Harden sandbox: local exec scripts, dynamic/env cwd reads, BASH_ENV, exact /root, pathlib glob, quoted newlines
- 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.
2026-07-10 05:45:53 +00:00
danielhanchen
fbc67fd490 Harden sandbox: history writes, cwd-relative reads, unpacking aliases, pin guard builtins/stat, root-home reads
- 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.
2026-07-10 05:12:39 +00:00
danielhanchen
7f8c4d7a8f Harden sandbox classifier: brace expansion, prefixed/nested shell reads, unbound MRO gadget
- 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.
2026-07-10 04:41:56 +00:00
danielhanchen
5eef982723 Harden sandbox classifier: sed write/exec, rmdir, glued redirect, PyYAML, methodcaller, chained aliases, bash reads
- 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.
2026-07-10 04:08:13 +00:00
danielhanchen
6f5ab8a65d Harden sandbox: versioned interpreters and per-wrapper option arity in argv, low-level os alias and shell-string reads, exec-family varargs and traversal reads, inherited class sinks, newline separators, dir-reader path materialization 2026-07-10 03:16:24 +00:00
danielhanchen
c3c2106ffb Harden sandbox: env option arity and hidden shells in argv, find/sed actions in argv vectors, split child-writer, dot source builtin, class sinks through instances, user-site disable 2026-07-10 02:35:01 +00:00
danielhanchen
65781ccf14 Harden sandbox: wrapper option operands and hidden shells in argv, shell=True sequence payloads, pty/posix/runpy import and alias sinks, dunder/vars/unbound-dict namespace access, expansions behind wrappers, fresh built-in module creation 2026-07-10 01:57:32 +00:00
danielhanchen
daea6c84d0 Harden sandbox: keyword subprocess args, shell-separator reads, sed write commands, non-shell argv scoping, getattr(sys, 'modules'), FileIO MRO iteration, dir-reader sensitive reads 2026-07-10 01:22:07 +00:00
danielhanchen
52e68507ff Harden sandbox: global/nonlocal aliases, wrapper-hidden commands, sys.modules aliases, descriptor gadgets, higher-order containers, keyword path guard
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.
2026-07-10 00:42:34 +00:00
danielhanchen
94cb233099 Harden sandbox: command-sub, posix/pty imports, unbound sys.modules, mutating read utils, os re-exports, instance-attr aliases, network aliases/keywords
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.
2026-07-10 00:11:00 +00:00
danielhanchen
cf7503da21 Harden sandbox: compile/FunctionType gadgets, lambda/comprehension aliases, IFS + symlink redirects, workdir import shadowing
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.
2026-07-09 23:35:46 +00:00
danielhanchen
93a1786898 Harden sandbox: Path.open reads, shell -c argv reads, ANSI-C quoting, from-import subprocess sinks, escaping globs
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.
2026-07-09 23:01:47 +00:00
danielhanchen
59fff4b86b Harden sandbox: alias read sinks, gc graph walk, list-concat fold cap, runtime sensitive-read backstop, multi-component redirects
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.
2026-07-09 22:34:19 +00:00
danielhanchen
dc8dde653f Harden sandbox classifier against round-14 shell-argv, path-alias, and class-alias bypasses
Shell command scanner:
- Analyze subprocess shell argv vectors as a whole (['sh', 's.sh'] / ['bash', '-s'] / bare ['bash'] blocked; ['bash', '-c', 'literal'] scans the payload; dynamic -c blocked).
- Add archive / compression writers (tar, zip, gzip, xz, zstd, 7z, rar, cpio, rsync, ...) to the child write blocklist.
- Deny any command-position shell without an inline -c payload, covering piped bare shells (printf ... | bash).
- Fail closed on shell-expanded read paths: an input redirect (< $VAR) or a $ / backtick expansion passed to a file-reading command (cat $P).

Static read scanner:
- Fold os-aliased / from-imported path builders (import os as o -> o.path.join(...); from os.path import join -> join(...)).

Dynamic-exec / obfuscation:
- Normalize operator.methodcaller('__getattribute__', 'name')(obj) as an attribute fetch like attrgetter.
- Resolve class-body sink aliases reached as ClassName.attr (class C: f = os.system; C.f(...)) for shell / exec / deserializer sinks.

Adds TestRound14Bypasses covering each vector plus benign controls.
2026-07-09 21:59:23 +00:00
danielhanchen
e92119eaac Harden sandbox classifier against round-13 shell, read-callee, and dir_fd bypasses
Shell command scanner:
- Recognize >& as a redirection operator (echo hi >& /tmp/x), keeping fd redirects (>&2) allowed.
- Block pushd cwd escapes alongside cd, including behind command / builtin wrappers.
- Add awk / gawk / mawk / nawk to the interpreter child blocklist.
- Block shell script-file execution (bash s.sh, sh script.sh, bash -s) since only inline -c is analyzable.

Static read scanner:
- Resolve non-bare open callees for traversal reads (builtins.open, __builtins__.open, open.__call__).
- Fold function-local single-assignment constants inside path-builder calls (p = '/etc'; os.path.join(p, 'passwd')).
- Resolve single-assignment Path constructor aliases (P = pathlib.Path; P('/etc', 'passwd').read_text()).
- Treat subprocess argv path traversals as host reads (subprocess.run(['cat', '../../root/.ssh/id_rsa'])).
- Block getattr(<sensitive module>, '__dict__') namespace obfuscation.

Runtime backstop:
- Deny read-only os.open with dir_fd (an fd-relative read under an outside directory fd escapes the workdir).

Adds TestRound13Bypasses and a read-only os.open dir_fd runtime test.
2026-07-09 21:24:24 +00:00
danielhanchen
389303d3de Harden sandbox classifier against round-12 mro/attrgetter/loader bypasses
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.
2026-07-09 20:55:05 +00:00
danielhanchen
6baedd40d5 Harden sandbox classifier against round-11 obfuscation and higher-order sink bypasses
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.
2026-07-09 20:30:42 +00:00
danielhanchen
e895535b96 Harden sandbox classifier against round-10 introspection and indirection bypasses
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.
2026-07-09 19:51:54 +00:00
danielhanchen
ccac1f0293 Studio sandbox: close ninth-round review bypasses (module table, exec sinks, aliases, budget)
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
2026-07-09 19:17:07 +00:00
danielhanchen
0c17f074ae Studio sandbox: close eighth-round review bypasses (closures, class scopes, redirects, aliases)
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
2026-07-09 18:45:38 +00:00
danielhanchen
bf4bc7e449 Studio sandbox: close seventh-round review bypasses (scope counts, obfuscation, child writers, reads)
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
2026-07-09 18:14:22 +00:00
danielhanchen
0f4b4b3d36 Studio sandbox: close sixth-round review bypasses (obfuscation, reads, child procs, guard pinning)
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
2026-07-09 17:32:27 +00:00
danielhanchen
5dcb93f57d Studio sandbox: close fifth-round review bypasses (aliases, reads, path guards)
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.
2026-07-09 16:50:43 +00:00
danielhanchen
0441be5e11 Studio sandbox: close fourth-round review bypasses (scope-aware aliases + guards)
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.
2026-07-09 16:22:40 +00:00
danielhanchen
daaaee5bcc Studio sandbox: close third-round review bypasses (backstop + folder + classifier)
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.
2026-07-09 15:50:14 +00:00
pre-commit-ci[bot]
89650ddc95 [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2026-07-09 15:18:32 +00:00
danielhanchen
fbb030b019 Studio sandbox: close second-round review bypasses (classifier + backstop)
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.
2026-07-09 15:17:37 +00:00
pre-commit-ci[bot]
47dd8fd277 [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2026-07-09 14:39:53 +00:00
danielhanchen
8c317ddb44 Studio sandbox: close static-classifier and runtime-guard review gaps
Harden the code-exec sandbox against bypasses raised in review, keeping the
static gate a pure classifier (it never executes the tool call):

- Shell scan: `timeout` duration/float args (5m, 0.5, 2h) no longer drop the
  following command out of command position, and `find -exec CMD ... ;` rescans
  the whole slice so a wrapped `env`/`timeout`/`sh -c` target is still caught.
- exec/eval/compile of a bytes payload that is not valid UTF-8 Python now blocks:
  those sinks honor PEP 263 coding cookies (e.g. utf-7) that the static UTF-8 view
  cannot see; plain ASCII bytes payloads stay allowed.
- Resolve aliased/indirect reaches to exec, dynamic import, sensitive modules and
  deserialization sinks: builtins.eval / __builtins__.exec, from builtins import
  exec as e, importlib aliases, sys.modules[...] (and the getattr form), os.__dict__,
  posix/nt, and pickle/marshal module-and-symbol aliases plus the *.load variants.
- Shell-sink aliasing walks the whole tree, so a function-local `s = os.system`
  alias is resolved (the stored-once guard keeps it low false-positive).
- Drop __mro__ / __code__ from the introspection-gadget dunders: on their own they
  do not reach an execution primitive and are read by ordinary ML/debug code.
- Refuse folding oversized `bytes(n)` / `bytearray(n)` so static analysis cannot OOM.

Runtime realpath backstop:
- Fail closed on a mutating dir_fd / src_dir_fd / dst_dir_fd (an fd-relative path
  cannot be confined by a string realpath) for os and shutil mutators.
- Confine Path.rename/replace/symlink_to/hardlink_to when the destination is passed
  as the `target=` keyword, not only positionally.
- Splice the guard after a leading docstring and `from __future__` imports instead
  of prepending it, so future-import programs no longer raise SyntaxError while the
  sandbox is still established before the first real statement.

Adds regression tests for each gap across the shell, const-fold, exec-recursion,
aliasing and runtime-backstop suites.
2026-07-09 14:39:12 +00:00
danielhanchen
98cd44861e Studio sandbox: fix Path.open/write_text on Python <= 3.11 under the runtime guard
On Python <= 3.11, pathlib._NormalAccessor captures io.open (and os.* mutators)
into class attributes at pathlib import time. A C builtin captured there does not
bind on instance access, but a Python wrapper does: self shifts into the next
positional, so Path.open / Path.write_text raised 'open() argument mode must be
str, not PosixPath' once the guard had replaced io.open with a Python wrapper
before pathlib was imported. (3.12+ dropped the accessor, which is why it only
failed on the 3.10 CI leg.)

Import io and pathlib at the top of the guard, before any patching, so the
accessor captures the original builtins, and confine Path.open by wrapping the
public method directly (mode-aware) rather than relying on the io.open patch to
reach it. Direct io.open() writers are still guarded for the zipfile-based cases.
2026-07-09 12:53:36 +00:00
pre-commit-ci[bot]
f6a813f161 [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2026-07-09 12:36:20 +00:00