Commit graph

1,938 commits

Author SHA1 Message Date
danielhanchen
236e89d0c9 Harden sandbox: workdir-vetter alias / namespace / MRO / sys.modules gaps, network client entry points, request() URL arg, sqlite URI mode parsing
Close eight gaps Codex found on the round-60 branch (seven inline P1 plus one
review-body P1). Five are in the runtime workdir-module import vetter (a planted
workdir helper is not scanned by the outer static pass), two are in the static
network scanner, and one is the runtime sqlite URI parser.

- assigned os aliases in the vetter: the pre-pass only recorded `import os as o`
  aliases, so a workdir helper doing `import os; o = os; o.system(...)` passed
  vetting and spawned an unguarded child. Follow simple whole-module assignments
  (o = os; b = builtins; s = sys) to a fixpoint before the sink checks.

- module namespace-dict subscripts in the vetter: `os.__dict__['system'](...)`
  reached the sink because __dict__ was not a gadget and the subscript branch only
  failed closed for builtins. Fail closed on a `<module>.__dict__[...]` subscript
  for os / posix / deserializer / sys / importlib, like vars(<module>).

- module __getattribute__ / __getattr__ in the vetter: `os.__getattribute__(
  'system')(...)` reached the sink because only the builtin getattr(...) form was
  recognized. Classify bound `module.__getattribute__('name')` and unbound
  `object.__getattribute__(module, 'name')` lookups the same way as getattr.

- MRO base recovery in the vetter: `io.FileIO.__mro__[1]('/tmp/x','w')` /
  `sqlite3.Connection.__mro__[1](...)` recovered an unguarded base class because the
  helper gadget set omitted __mro__ / mro. Add both to the gadget attributes.

- sys.modules in the vetter: `sys.modules['os'].system(...)` recovered the
  guard-cached os module without an import, bypassing the denied-import path. Deny
  sys.modules access (direct attribute and getattr form) in a vetted workdir module.

- public network client entry points: `requests.api.get(...)`, `ftplib.FTP(...)`,
  and `smtplib.SMTP(...)` were not in the network prefix table, so a metadata /
  untrusted host reached through them bypassed the allowlist. Add requests.api.*,
  ftplib.FTP / FTP_TLS, and smtplib.SMTP / SMTP_SSL / LMTP (the ftplib / smtplib
  clients take a bare host), and track ftplib / smtplib import aliases.

- request(method, url) URL argument: module-level requests.request / httpx.request /
  urllib3.request (and requests.api.request) carry the URL at arg1, but the code
  passed arg0 (the HTTP method) to the host check, so the URL was never inspected.
  Read the URL from arg1 for these method-first APIs, like the client-instance
  .request() branch.

- sqlite URI mode=memory parsing: a `file:/tmp/escape.db?xmode=memory` URI was
  treated as in-memory by a substring test and skipped path confinement, but SQLite
  ignores the unknown xmode key and opens the on-disk file. Parse the query exactly
  (split on &, first occurrence of a repeated key, percent-decoded) and treat only a
  genuine mode=memory parameter as in-memory, in the runtime guard and the two static
  sqlite operand checks.

Regression coverage: TestRound61Bypasses in tests/test_sandbox_tools.py (request()
URL at arg1 for requests / httpx / urllib3 / requests.api, network client entry
points against metadata and untrusted hosts, sqlite shell xmode=memory on an
escaping absolute path, plus a benign-allowed set: trusted-host requests / api /
ftplib / smtplib, a genuine in-memory URI, and a workdir-relative db) and, in
tests/test_sandbox_runtime_backstop.py, workdir-module denials for the assigned os
alias, os.__dict__ subscript, os.__getattribute__, io.FileIO.__mro__, and
sys.modules['os'] forms, a benign os-alias helper that still imports, and the sqlite
URI xmode=memory escape denial plus a genuine mode=memory allowance.
2026-07-11 05:29:35 +00:00
danielhanchen
5343e2e993 Harden sandbox: const-fold loader-table getattr names, block type(instance) / sqlite Connection MRO recovery
Close three MRO / dynamic-attribute recovery gaps Codex found on the round-59
branch (all P1).

- const-folded loader-table getattr names: the sys.meta_path / sys.modules
  recognizers accepted only a raw string literal, so getattr(sys, 'meta_' +
  'path').pop(0) (or the sys.modules equivalent) removed the sandbox
  workdir-module vetter / dropped a guarded module before importing an unguarded
  workdir child that runs os.system / subprocess outside the session workdir.
  Add _extract_folded_string and use it for the getattr / __getattribute__ /
  __getattr__ attribute-name checks, so a folded or const-var name is recognized
  exactly like the literal. A benign read of the finder chain stays allowed.

- type(<guarded instance>).mro(): the whole-MRO recovery guard only recognized
  io.FileIO / obj.__class__ receivers, so type(io.FileIO('/dev/null','r')).mro()
  (or a same-name alias of that type(...)) iterated the guarded subclass's MRO to
  recover the original unguarded _io.FileIO base and read / write outside the
  workdir. Recognize type(<x>) where <x> constructs a guarded file / sqlite
  instance as a recovery receiver, and resolve a single-assignment alias of it.

- sqlite3.Connection MRO base recovery: the exported sqlite3.Connection is the
  guarded subclass whose MRO still exposes the unguarded _sqlite3.Connection base,
  so iterating sqlite3.Connection.mro() / .__mro__ recovered it and instantiated it
  with an absolute path, bypassing connect() and the guarded __init__. Treat the
  guarded sqlite3.Connection / _sqlite3.Connection / sqlite3.dbapi2.Connection as a
  recovery receiver so a whole-MRO walk of it is blocked like io.FileIO. The
  subscripted / popped forms were already caught; this closes the iteration form.

Regression coverage: TestRound60Bypasses in tests/test_sandbox_tools.py (folded
meta_path / sys.modules pop / clear / __getattribute__ and del-subscript
mutations, type(io.FileIO(...)) / type(sqlite3.connect(...)) whole-MRO access and
its alias, sqlite3.Connection / dbapi2.Connection MRO walks, plus a round60
benign-allowed set: reading sys.meta_path, a benign sys getattr, int.mro() /
type(42).mro(), a plain class access, an in-memory connect, and a plain dict pop).
2026-07-11 04:27:30 +00:00
danielhanchen
7e15a8bca9 Harden sandbox: exec namespace-dict aliases, bare-host + client-instance network calls, shuf/uniq writers, docstring guard splice
Close five gaps Codex found on the round-58 branch (four inline P1 plus one
review-body P1).

- exec/eval namespace-dict aliases: the outward-reference model for an exec /
  eval payload only collected ast.Name loads, so a constant-key namespace lookup
  (exec("globals()['f']('...')"), and the locals() / vars() forms) referenced the
  caller's f = os.system without a Name node and slipped past the alias check.
  Treat a literal key of a bare globals() / locals() / vars() subscript as a free
  outward reference so the caller-alias resolution runs on it.

- bare-host network APIs: http.client.HTTPConnection / HTTPSConnection and the
  socket name-resolution helpers (getaddrinfo, gethostbyname, gethostbyname_ex)
  take a HOST, not a URL, so their literal first arg (or host= keyword) was parsed
  as a scheme://host URL, found no scheme, and was never checked. Check the literal
  directly against the metadata denylist / allowlist for these callees, and add the
  forward name-resolution helpers to the scanned set.

- client-instance network calls: a request chained off a client constructor
  (requests.Session().get(url), httpx.Client().get(url), build_opener().open(url),
  or s.get(url) where s was bound to such a constructor) has a fully-qualified name
  of just the method, so it never matched a module-rooted network prefix and the
  host went unchecked. Match these by the receiver being a client-ctor call or a
  same-name single-assignment alias, extract the host (arg1 for .request), and run
  the same host check. A .get / .open on a plain dict or file receiver is excluded.

- shuf / uniq output writers: shuf -o / --output and a uniq second (output) operand
  write outside the workdir in an unguarded child that the realpath backstop cannot
  see, so they are blocked like the existing sort -o full-block. uniq skip-field /
  skip-char counts are not treated as output operands.

- module docstring under the guard splice: a leading string literal is the module
  docstring only while it is the first statement, so prepending the runtime guard
  ahead of it made __doc__ None. Splice the guard after a leading docstring (as is
  already done for future imports) so the docstring stays first; a same-line
  "\"\"\"doc\"\"\"; write" tail still moves after the guard and is confined.

Regression coverage: TestRound59Bypasses in tests/test_sandbox_tools.py (exec /
eval namespace-dict aliases, bare-host metadata / allowlist checks, client-instance
and aliased-instance request calls, shuf / uniq output writers, plus a round59
benign-allowed set: trusted-host requests / sessions / HTTPConnection / getaddrinfo,
dict.get and file .open, a benign exec payload, and shuf / uniq without an output
operand) and, in tests/test_sandbox_runtime_backstop.py, a module-docstring-preserved
case and a docstring same-line write-escape denial.
2026-07-11 03:46:40 +00:00
danielhanchen
bb1d6c9c7e Harden sandbox: bash comment / newline tokenization, sqlite .backup/.open operands, iconv -o, timeit strings, PATH / git command substitutions
Close eight shell-scanner and dynamic-execution gaps Codex found on the round-57 branch (all P1).

- bash # comments and unquoted newlines: the command scanner rewrote unquoted
  newlines to ` ; ` before shlex, and shlex's own # handling fired mid-word, so
  `echo ok #\nsed -i ...` (a comment swallowing the synthesized separator) and
  `echo ok#; sed -i ...` (a mid-word # treated as a comment) hid the second
  command. Strip bash comments at their real physical-line boundaries first, then
  rewrite newlines, and clear shlex's commenters so its non-bash-accurate # parsing
  cannot re-introduce the miss.

- shell newlines in the sensitive-read scan: the read scanner never rewrote
  newlines, so `echo ok\ncat /etc/passwd` read `cat /etc/passwd` as arguments of
  the non-reader `echo`. Apply the same comment-strip + newline-rewrite so each
  physical line starts a fresh command context.

- sqlite dot-command file operands: the dot-file check only inspected the first
  operand and omitted .open, so `.backup main /tmp/x` (the file is the LAST
  operand, after an optional schema name) and `.open /tmp/x` created databases
  outside the workdir. Scan .backup / .save / .open by their last operand and add
  .open to the file-operand set.

- iconv output files: iconv writes its converted output to -o / --output in an
  unguarded child, so `printf x | iconv -o /tmp/p` escaped. Block an escaping
  -o FILE / --output FILE / --output=FILE / -oFILE operand.

- timeit string execution: timeit.timeit / .repeat / Timer(...) compile and
  execute their stmt / setup STRING arguments, so
  `timeit.timeit("import os; os.system('...')")` ran outside the eval/exec gate.
  Analyze the stmt / setup strings like exec payloads (a benign body passes, a
  callable stmt carries no source and is left alone).

- command substitutions in PATH assignments: the PATH-entry check only recognized
  $VAR expansions, so `PATH=$(pwd) evil` was treated as a trusted expansion and a
  planted workdir executable could be resolved through it. Treat a $() / backtick
  command substitution in a PATH value as a dynamic, unsafe entry.

- dynamic git path operands: path-valued git operands only resolved same-command
  $VAR assignments, so `git init $(printf /tmp/x)` and the backtick form were
  accepted and native git created the path outside the workdir. Treat a $() /
  backtick command substitution in a git path operand as escaping (tokenization
  splits the substitution into separators, so the fragments are re-detected).

Regression coverage: TestRound58Bypasses in tests/test_sandbox_tools.py (comment /
newline command positions for the write and read scans, sqlite .backup / .save /
.open operands, iconv -o forms, PATH and git command substitutions, timeit
stmt / setup string execution) plus a round58 benign-allowed set (a real trailing
comment, a benign second line, workdir-relative git, iconv with no output file,
trusted PATH expansions, workdir-local sqlite .backup / .open, a benign timeit
body, and timeit.default_timer with no code string).
2026-07-11 03:02:05 +00:00
danielhanchen
cb243a23f1 Harden sandbox: taskset wrapper, sqlite3.Connection ctor + durable authorizer, namespace-dict sink aliases, injected subprocess, shelve reads
Close six issues Codex found on the round-56 branch (all P1).

- taskset exec wrapper: taskset [options] <mask | -c cpu-list> <command> execs the
  following command, but taskset was not a command-prefix wrapper, so taskset 1
  touch /tmp/p resolved to nothing and the write slipped. Add taskset to the
  wrapper set with its -c / --cpu-list (and -p / --pid) operand flags, and extend
  the wrapper numeric-arg skip to cover a hex affinity mask (0x3) and a cpu-list
  (0,1 / 0-3), so the wrapped command is resolved and scanned.

- sqlite3.Connection constructor confinement: wrapping only connect() left the
  public constructors unconfined, so sqlite3.Connection('/tmp/escape.db') /
  _sqlite3.Connection(...) created a database outside the workdir via the native
  extension. Route construction through a guarded Connection subclass whose
  __init__ confines the database path, and replace the module Connection attribute
  with it (isinstance stays valid); connect() forces the subclass as its factory.

- durable ATTACH / VACUUM authorizer: installing the authorizer once on the
  returned connection was not durable -- sandboxed code could call
  conn.set_authorizer(None) and then ATTACH DATABASE '/tmp/escape.db' / VACUUM
  INTO an outside file. The guarded Connection overrides set_authorizer to compose
  the workdir confinement ahead of any caller callback and keep it on
  set_authorizer(None), so the confinement cannot be removed.

- namespace-dict sink aliases: globals()/locals()/vars()[key] only blocked literal
  builtins / dangerous-module keys, so import os; f = os.system;
  globals()['f']('touch /tmp/p') passed. Resolve the key through the scope alias
  index too -- a shell / exec-builtin / deserializer sink alias makes the
  namespace-dict lookup the sink itself.

- dependency-injected subprocess / pty: a workdir helper receiving the module as
  an argument (def f(subprocess): subprocess.run([...])) has no import to reject,
  and the vetter's call check only rooted os / posix. Reject a subprocess / pty
  child-spawn method rooted at a receiver named subprocess / pty in the vetter, and
  -- robust to the callee's parameter name -- flag the subprocess / pty module
  passed by reference (f(subprocess)) as a first-class dangerous value in the
  submitted code, mirroring the existing os.system-by-reference block.

- shelve reads as pickle deserialization: shelve is a dbm-backed dict that
  unpickles a value on every read (shelf[key], shelf.get(key)), so shelve.open()
  on an attacker-planted dbm runs a pickle reduce payload just like pickle.load
  (which is already blocked). Model shelve.open as a deserialization sink. The
  read can be aliased (d = shelve.open(...); d[k]), so the open() gateway call is
  flagged; a pure write-only shelf never unpickles, so blocking it is an accepted
  narrow tradeoff.

Regression coverage: TestRound57Bypasses in tests/test_sandbox_tools.py (taskset
mask / cpu-list / nested wrappers + benign taskset; namespace-dict sink aliases
via globals / locals / vars incl. folded key and a pickle alias; injected
subprocess / pty module by reference incl. an aliased import; shelve.open read /
aliased read / get / import alias / from-import; and a round57 benign-allowed set)
and test_sandbox_runtime_backstop.py (sqlite3.Connection and _sqlite3.Connection
constructor escape denied + local allowed; set_authorizer(None) ATTACH / VACUUM
escape still denied; a caller authorizer still composes).
2026-07-11 02:19:01 +00:00
danielhanchen
570c3ff2f2 Harden sandbox: indirect eval/exec callees, native FFI imports, non-literal network targets; scope workdir exec-method calls to os
Close four issues Codex found on the round-55 branch (3 P1 + 1 P2 false positive).

- indirect eval / exec callees: the eval/exec/compile callee resolver only
  matched a bare name, a builtins attribute, an inline container, or an
  aliased name, so a callee EXPRESSION that evaluates to an exec builtin ran
  its payload unanalyzed: a ternary ((eval if c else exec)('...')), a boolean
  fallback ((getattr(__builtins__, 'ev', None) or eval)('...')), and a
  __builtins__['exec'] subscript. Resolution is refactored into
  _resolve_exec_callee, which peels the ternary / and-or composites (failing
  closed when ANY branch can be an exec builtin) and resolves the builtins
  subscript, after which the recovered payload is analyzed as usual (so a
  destructive os.system('touch ...') payload behind the indirect callee is
  caught while eval('1 + 2') stays allowed).

- native FFI imports: importing ctypes / _ctypes / cffi gives the snippet
  UNGUARDED libc / syscall access (ctypes CDLL('libc.so.6').system, a raw
  write() that never routes through the patched open / os.open), bypassing the
  filesystem confinement entirely. Refuse the import (statement and from form)
  in the static analyzer, mirroring the runtime workdir-module vetter which
  already refuses these. numpy / compiled wheels are NOT included: they expose
  no raw-syscall FFI surface.

- non-literal network targets: the host allowlist only inspected a literal URL
  / (host, port) tuple, so a target bound to a variable (url =
  'http://169.254.169.254/'; requests.get(url)) or built from an f-string /
  concat slipped past the metadata / allowlist check even though the literal
  form is blocked. Fold a non-literal target to its concrete host (a
  single-assignment constant, a foldable concat) or reduce it to the leading
  literal host prefix (f'https://hf.co/{path}', 'https://hf.co/' + p) when a
  / ? # terminates the host inside the literal so a dynamic tail cannot extend
  it. A target that stays fully opaque fails closed, since there is no runtime
  network filter to catch it. A const var / literal-prefix pointing at a
  trusted host still resolves and is allowed.

- workdir-module exec-method calls scoped to os / posix (P2 false positive):
  the runtime workdir-module vetter refused ANY attribute CALL whose method
  name matched an os exec sink (system / popen / spawn*) regardless of
  receiver, so a benign helper calling platform.system() or its own
  obj.system() method could not be imported. Root the call rejection at an
  os / posix receiver, exactly like the sink-attribute REFERENCE check beside
  it; os.system(...) in a workdir helper is still refused.

Regression coverage: TestRound56Bypasses in tests/test_sandbox_tools.py
(indirect ternary / boolop / builtins-subscript exec callees; ctypes / _ctypes
/ cffi imports; const-var / f-string / fully-opaque / raw-socket / create_
connection network targets; and a round56 benign-allowed set: literal exec,
os.system('id'), os / numpy / platform imports, and trusted host via literal /
const-var / f-string-dynamic-path / concat / raw socket). TestUntrustedHostBlock
is updated for the tightened const-var folding (untrusted host blocked, trusted
host allowed) plus a fully-dynamic fail-closed case, and
test_sandbox_runtime_backstop.py adds the platform.system() / obj.system()
workdir-helper allow and the os.system workdir-helper still-denied cases.
2026-07-11 01:22:01 +00:00
danielhanchen
5172f6893d Harden sandbox: sqlite3 stdin SQL, sed -f scripts, git EDITOR/VISUAL + object-dir + exec-env command + marks-file, find {} exec, openssl -out=; scope assignment prefixes
Close nine command-scanner gaps Codex found on the round-54 branch (8 P1 + 1 P2).

- sqlite3 stdin SQL: sqlite3 [OPTIONS] [FILENAME [SQL]] reads SQL from stdin when
  no SQL argv is given, so printf '.shell touch /tmp/p' | sqlite3 :memory: ran an
  unscanned dot-command in the unguarded child. Fail closed when sqlite3 has no
  inline SQL and a stdin source (a pipe target or a < / heredoc redirect).

- sed -f script files: the sed mutating-script check only scanned -e / positional
  scripts, so sed -n -f evil.sed loaded w / e commands from an uninspectable
  workdir file. Fail closed on any -f / --file form (separated, glued, combined
  short group).

- git EDITOR / VISUAL fallbacks: the git exec-env allowlist covered GIT_EDITOR but
  not the standard EDITOR / VISUAL fallbacks git uses for commit/tag messages.
  Treat EDITOR / VISUAL like the git exec-env vars when the command is git (or the
  value is exported).

- git object-directory env vars: GIT_OBJECT_DIRECTORY / GIT_COMMON_DIR /
  GIT_ALTERNATE_OBJECT_DIRECTORIES point git's object store outside the workdir
  (GIT_OBJECT_DIRECTORY=/tmp git hash-object -w), but only GIT_DIR / GIT_WORK_TREE
  / GIT_INDEX_FILE were path-checked. Add them to the escaping-path env check.

- assignment prefixes are now command-position aware (P2 false positive): a
  NAME=value shaped token is treated as an environment assignment only in the
  command-prefix position of its segment (or as an export / declare arg), so echo
  GIT_CONFIG_COUNT=0 and printf %s PATH=.:/bin are no longer rejected while a real
  PATH=. prefix and export PATH=.:/bin still block.

- find {} exec placeholder: find substitutes {} with each matched path, so find .
  -name evil -exec {} ';' executes a planted workdir file, but the reconstructed
  exec-segment scan saw only the harmless-looking {}. Fail closed when the exec
  command word is (or starts with) {}.

- openssl glued -out=FILE: the OpenSSL write check only handled a separated -out
  FILE operand, so openssl rand -out=/tmp/p slipped. Parse the glued -out=... /
  -writerand=... forms alongside the separated form.

- commands in git exec-env vars: the exec-env check only rejected a local
  executable path, so GIT_EXTERNAL_DIFF='touch /tmp/p' git diff (a bare system
  command that writes outside) passed. Run the value through the command scanner,
  which flags the write / escaping command.

- git marks-file options: git fast-export --export-marks=/tmp/marks (and
  fast-import --import-marks) write / read an escaping path outside the small
  _GIT_PATH_VALUE_OPTIONS list. Add the marks-file options to the path check.

Regression coverage: TestRound55Bypasses in tests/test_sandbox_tools.py (sqlite3
stdin, sed -f, git EDITOR/VISUAL incl. export, GIT_OBJECT_DIRECTORY / GIT_COMMON_DIR,
find {} exec, openssl -out=, git exec-env command values, git marks-file options,
the assignment-prefix position-awareness matrix, and a round55 benign-allowed set:
inline-SQL sqlite3, sed -e / bare script, EDITOR=vim, relative GIT_OBJECT_DIRECTORY,
find -exec cat {}, openssl -out=key, GIT_PAGER=cat, relative export-marks, export
of a benign var).
2026-07-11 00:40:34 +00:00
danielhanchen
8bb6ed501a Harden sandbox: order/scope-aware exec caller aliases, explicit exec namespaces, non-literal + non-assignment env mutations
Close five bypasses Codex found on the round-53 branch (all P1).

- exec/eval caller-alias order + scope: the caller-alias check removed a payload
  name from the free set if it was stored ANYWHERE, so f('touch /tmp/pwn');
  f = None still called the caller's f = os.system before the rebind. Replace
  the flat loaded-minus-bound with an order-aware, scope-aware analysis: a
  module-top-level Load before the name's first top-level binding (source order)
  resolves outward, as does a free / global Load inside a nested function or
  class scope (it can run after a later rebind); a name bound at module top
  level is payload-local (its own binding shadows the caller, and a
  payload-local sink is caught by the inner scan). symtable computes the
  nested-scope free / global references.

- explicit exec/eval namespace: exec("f('touch /tmp/p')", {'f': os.system})
  resolves the payload's free names from the supplied namespace, not the caller
  scope, so it was treated as a safe literal. Inspect a literal-dict namespace
  precisely (a free name mapped to a shell / exec / deserialize / import sink
  blocks) and fail closed on an opaque namespace when a non-builtin free name is
  called.

- subprocess env PATH via non-literal / bytes value: the env={'PATH': ...} check
  only read an inline str constant, missing P='.:/usr/bin'; env={'PATH': P}, a
  concatenation, and a POSIX bytes value. Const-fold / decode the value (via the
  now-folding _extract_env_scalar) and fall back to the dynamic-PATH analysis for
  a non-literal value, mirroring the os.environ['PATH'] handling.

- non-assignment env mutations: only Assign targets (plus update / setdefault)
  were covered, so os.environ['PATH'] += ':.' (AugAssign), del
  os.environ['GIT_CONFIG_COUNT'] (Delete), os.environ.pop('GIT_CONFIG_COUNT') /
  .clear(), and os.unsetenv('GIT_CONFIG_COUNT') slipped through. Add
  visit_AugAssign (modeled as old-value + appended), visit_Delete, and pop /
  clear / unsetenv handling; removing a GIT_CONFIG* var (or clearing the env)
  drops the sandbox git hook suppression.

- opaque env mapping for git children: the missing-GIT_CONFIG_COUNT check only
  fired for a fully inspectable mapping, and the non-literal fallback was scoped
  to shell children, so env={**d} / env=f() for a git child (which can evaluate
  to {} and drop the injected core.hooksPath suppression) was accepted. Fail
  closed for a git child on an opaque or non-literal env mapping unless a literal
  GIT_CONFIG_COUNT is present.

Regression coverage: TestRound54Bypasses in tests/test_sandbox_tools.py
(caller-alias-before-rebind, explicit-namespace alias, non-literal / bytes env
PATH, augmented / del / pop / clear / unsetenv env mutations, opaque git env,
plus a round54 benign-allowed set: store-only payload, benign literal namespace,
absolute PATH via const var, benign augmented / pop env var, non-git opaque env,
git with no env).
2026-07-11 00:03:45 +00:00
danielhanchen
975ef64d8d Harden sandbox: sqlite ATTACH/VACUUM confinement, asyncio subprocess creators, os.putenv escapes, str-fold DoS, socket.connect_ex
Close five bypasses Codex found on the round-52 branch (4 P1 + 1 P2).

- sqlite ATTACH / VACUUM INTO: a connection to an in-workdir DB could still
  create/open a file outside the session via ATTACH DATABASE '/tmp/x' or
  VACUUM main INTO '/tmp/x' -- the native extension writes those paths without
  passing the wrapped connect / open. Install a connection authorizer in the
  runtime guard: both fire the SQLITE_ATTACH action with the target filename,
  so deny a target that escapes the workdir (URI-decoded when uri mode is on)
  while an in-workdir / :memory: / temp attach and ordinary queries stay
  allowed. Refactored the URI-to-path decode into a shared helper.

- asyncio subprocess creators: asyncio.create_subprocess_shell(cmd) /
  create_subprocess_exec(prog, *args) start the same unguarded child as
  subprocess.run/Popen but were not classified. Rewrite them to the equivalent
  subprocess.run(cmd, shell=True) / subprocess.run([prog, *args]) node (carrying
  cwd= / env=) and reuse the full child-process command analysis. Covers the
  module-attribute form, an import alias, and a from-import bare alias.

- os.putenv: os.putenv('BASH_ENV', 'evil.sh') sets an inherited env var through
  the C setter (not os.environ), so the subscript / update checks missed the
  later-child startup / PATH escape. Run the (key, value) pair through the same
  mutation policy in visit_Call; covers os.putenv and a from-import alias.

- str()-of-container fold DoS: str(['x' * 65536] * 4096) is a small aliased
  container whose repr is hundreds of MB, and _fold_cap only checks the length
  AFTER str() materializes it, OOMing the Studio parent before the child
  rlimits apply. Estimate the repr length with a cheap bounded walk and refuse
  the fold (leaving the payload opaque, which already fails closed) before
  building it.

- socket.connect_ex: connect_ex((host, port)) opens the same outbound
  connection as connect but returns an errno instead of raising, bypassing the
  metadata / untrusted-host allowlist. Classify it identically to connect.

Regression coverage: TestRound53Bypasses in tests/test_sandbox_tools.py
(asyncio subprocess shell/exec + alias / from-import / awaited forms, os.putenv
startup+PATH escapes, connect_ex metadata/untrusted host, str-container fold
DoS, plus a round53 benign-allowed set: benign asyncio echo child, benign
putenv var, small str fold, connect_ex to a trusted host) and, in
tests/test_sandbox_runtime_backstop.py, the sqlite ATTACH and VACUUM INTO
escape denials with a benign local-ATTACH allowed.
2026-07-10 23:11:09 +00:00
danielhanchen
10962c482c Harden sandbox: env=dict/**-splat child env, aliased os.environ, git helper env vars, relative native output under an escaping cwd
Close five bypasses Codex found on the round-51 branch.

- env=dict(...) / env={**mapping} child-env mappings: the subprocess env=
  analysis only walked a literal dict and a dict() call, so a git child with
  env=dict(PATH=...) still dropped the injected hook suppression and a
  env={**{'BASH_ENV': ...}} splat hid the startup var. Flatten the mapping
  (literal dict, dict() call, and nested ** splats) into (key, value) pairs
  once via _env_mapping_pairs and run the same PATH / BASH_ENV / GIT_* policy;
  an opaque computed key on a shell child fails closed.

- aliased os.environ mutations: e = os.environ (or from os import environ as e)
  binds a new name to the same inherited environment, so a later
  e['BASH_ENV'] = ... escaped the subscript check. Track the alias
  (from-import, and a single-assignment e = os.environ / os.environb) and treat
  it as the environ mapping in _is_environ_receiver.

- git helper-command env vars: GIT_EXTERNAL_DIFF / GIT_ASKPASS / GIT_SSH /
  GIT_SSH_COMMAND / GIT_PROXY_COMMAND / GIT_EDITOR / GIT_SEQUENCE_EDITOR /
  GIT_PAGER name a program git executes; a workdir-local / ~ target
  (GIT_EXTERNAL_DIFF=./evil git diff) runs unreviewed code in the unguarded git
  child. Block an assignment-prefix helper var whose command is a local
  executable path or a ~ path; an absolute system tool (GIT_SSH=/usr/bin/ssh)
  stays allowed.

- relative native output under an escaping cwd: openssl -out FILE and a
  sqlite3 DBFILE / dot-file / -init operand only checked the literal operand,
  so a RELATIVE operand under an escaping env -C DIR (or a subprocess cwd=,
  reconstructed as env -C DIR) -- env -C /tmp openssl rand -out key,
  subprocess.run(['sqlite3','db.sqlite',...], cwd='/tmp') -- wrote outside the
  session. Scan back for an escaping chdir wrapper (_cwd_wrapper_escapes) and
  combine it with a relative operand (_operand_relative_local); a workdir-subdir
  env -C and a no-chdir relative operand stay allowed.

Regression coverage: TestRound52Bypasses in tests/test_sandbox_tools.py
(env=dict / ** splat, aliased environ, git helper env vars, relative native
output under an escaping cwd, plus a round52 benign-allowed set: non-git
env=dict, benign splat / aliased-env var, GIT_PAGER=cat, no-chdir and
workdir-subdir native output).
2026-07-10 22:24:26 +00:00
danielhanchen
81bb65f4e0 Harden sandbox: dynamic/environb/update PATH mutations; sqlite URI decode + shell/pipe dot-commands; getattr gadget dunders; find -exec in argv
Close seven bypasses Codex found on the round-50 branch.

- dynamic PATH assignment: os.environ['PATH'] = '.:' + os.environ['PATH'] (or an
  f-string) was accepted because the value is non-literal. Fold what we can and fail
  closed when a COMPLETE, fully-literal PATH entry the value contributes is a
  relative / cwd / empty entry; a dynamic ABSOLUTE extension ('/usr/local/bin:' +
  $PATH, venv + ':' + $PATH) stays allowed.

- os.environb mutations: os.environb[b'PATH'] = b'.:...' updates the same inherited
  environment, but only os.environ[...] was recognized. Match environ / environb
  (attribute and bare) and decode a bytes key / value before the policy check.

- os.environ.update / setdefault: a mapping mutator
  (os.environ.update({'PATH': '.:...'}), .update(PATH=...), .setdefault('PATH', ...))
  never hit the subscript check. Run each (key, value) pair through the mutation
  policy in visit_Call.

- sqlite URI percent-decode: sqlite3.connect('file:%2Ftmp%2Fescape.db', uri=True)
  passed the runtime guard as a relative-looking string while SQLite decodes the
  filename and opens /tmp/escape.db. Percent-decode the URI path (with the guard's
  captured chr/int) before the workdir check.

- sqlite shell / pipe dot-commands: the CLI scanner only path-checked file
  dot-commands, but .shell CMD / .system CMD run a system shell and .output |CMD
  opens a pipe. Block a .shell / .system / .excel dot-command and an .output/.once
  target that begins with '|'.

- getattr gadget dunders in the workdir vetter: a helper module could call
  getattr(open, '__closure__') / getattr(cell, 'cell_contents') to recover the guard
  wrapper's original unguarded open, because the getattr branch only rejected a few
  sensitive receivers. Reject a gadget-dunder name on ANY receiver (mirrors the
  direct-attribute check).

- find -exec in subprocess argv: the read scanner flattened the argv and checked
  each element independently, missing subprocess.run(['find','/etc',...,'-exec',
  'cat','{}',';']) reading /etc/passwd (the {} placeholder loses the escaping search
  root). Reconstruct a find child-exec argv into a shell string and run it through
  the read scanner, which carries the find-root + -exec logic.

Regression coverage: TestRound51Bypasses in tests/test_sandbox_tools.py (dynamic /
environb / update PATH mutations, sqlite .shell/.system/.output-pipe, find -exec
argv, plus a round51 benign-allowed set: absolute dynamic PATH, benign env vars,
local sqlite .output/.dump, workdir find -exec) and, in
tests/test_sandbox_runtime_backstop.py, the sqlite percent-encoded URI escape (with
a benign local URI) and the getattr gadget-dunder workdir-module denial.
2026-07-10 21:49:54 +00:00
pre-commit-ci[bot]
fa15071e14 [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2026-07-10 21:10:34 +00:00
danielhanchen
01f96315ca Harden sandbox exec/eval analysis: assigned-container sinks; shadowed fold helpers; namespace-dict writes; caller-alias payloads; unknown PATH vars
Close five bypasses Codex found on the round-49 branch (all in the static
exec/eval analyzer, plus one PATH case).

- assigned-container sink: a subscript into a container bound to a single-
  assignment NAME (d = {'e': exec}; d['e'](payload), xs = [eval]; xs[0](...))
  reached exec/eval, but the container resolvers only handled INLINE literals, so
  the Name form returned None and the payload was never scanned. Resolve a Name
  container through the const-prop env in the exec / deserialize / shell-sink
  resolvers (the last also caught d = [os.system]; d[0]('rm -rf /')).

- shadowed fold helper: the constant folder called the real builtin / stdlib
  module even when the snippet rebinds the name, so
  str = lambda _: "__import__('os').system('touch /tmp/x')"; eval(str(1)) folded
  through the real str and was marked safe. Track names rebound away from their
  canonical builtin / module (assignment, def, param, from-import, aliased import)
  and refuse to fold them, leaving the payload opaque -> eval/exec fails closed. A
  plain `import base64` keeps the canonical module and still folds.

- namespace-dict write: const-prop only tracked Name stores, so
  x = '2+2'; globals()['x'] = BAD; eval(x) folded x as the safe literal. Invalidate
  a name written through globals()/vars()/locals()[key] = ... (constant key), and
  fail closed on a dynamic key or a bulk update()/setdefault()/__setitem__.

- caller-alias payload: exec()/eval() run in the CALLER namespace, but the payload
  was scanned as a fresh module, so import os; f = os.system; exec("f('rm -rf /')")
  saw f as unknown and passed. When a payload FREE name resolves, in the caller
  scope, to a shell / exec / deserialize / import alias, fail closed. A payload that
  references only builtins (exec("print(1)")) or binds its own names stays allowed.

- unknown PATH variable: in the sandbox an unset $VAR expands to EMPTY, so
  PATH=$EVIL is an empty component that makes the shell search the cwd; a snippet
  can drop a local executable and run os.system('PATH=$EVIL evil'). Model an
  unknown/unset $VAR in a PATH entry as empty and fail closed when the entry then
  collapses to an empty or relative path; $PATH and an entry that stays absolute
  ($CONDA_PREFIX/bin -> /bin) are still trusted.

Regression coverage: TestRound50Bypasses in tests/test_sandbox_tools.py (assigned
container exec/eval + shell sinks, shadowed str/chr fold, globals/vars/update
invalidation, exec/eval caller alias, PATH=$UNKNOWN) plus a round50 benign-allowed
set (safe container callees, normal builtin/module folds, a namespace read, a safe
caller alias, and PATH with trusted absolute entries).
2026-07-10 21:09:51 +00:00
pre-commit-ci[bot]
afc84773dc [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2026-07-10 20:30:37 +00:00
danielhanchen
9121b315b5 Harden sandbox: watch sh -c payload; xargs replace into exec; sqlite3 CLI writer + native _sqlite3.connect; jq file-option reads
Close five bypasses Codex found on the round-48 branch.

- watch runs its command via `sh -c '<operands>'` unless -x/--exec is given, so a
  quoted payload (watch 'python3 -c ...', watch -n 0.1 'rm -rf /') is shell CODE,
  not one inert command word. The wrapper handling only resolved the -x argv form.
  Scan the joined non-x operands recursively; a bare `watch date` re-scans `date`,
  and `echo watch rm` (watch in argument position) is left alone.

- xargs -I / -i / --replace substitutes UNSCANNED stdin into the command at
  runtime, so `printf ... | xargs -I{} sh -c '{}'` executes stdin as code while
  the scanner sees only the inert `{}` payload. Fail closed when the replacement
  token becomes the command word (xargs -I{} {}) or flows into an interpreter code
  string (sh -c '{}', python3 -c %); a replacement used only as a data ARGUMENT to
  a non-interpreter (xargs -I{} cp {} dir/) and xargs without a replace flag stay
  allowed.

- the sqlite3 CLI creates a database / redirects output in an unguarded child that
  has no realpath guard: `sqlite3 /tmp/escape.db '...'` writes outside the workdir,
  and `.output` / `.backup` / `.dump` / `.read` dot-commands read+write arbitrary
  files. Flag a DBFILE operand or a dot-command file target that escapes the
  workdir; a local DB (sqlite3 local.db ...), :memory:, and an in-memory URI stay
  allowed. sqlite3 is added to the argv-tail scan so the subprocess.run(['sqlite3',
  ...]) form is reconstructed and checked too.

- the round-48 runtime sqlite guard wrapped sqlite3.connect and
  sqlite3.dbapi2.connect, but the native _sqlite3 C extension still exposed the
  original connect and is importable directly (import _sqlite3;
  _sqlite3.connect('/tmp/escape.db')), bypassing both Python bindings. Wrap the
  low-level _sqlite3.connect entry point too.

- jq reads files through explicit options (--rawfile / --slurpfile read a file into
  a variable, -f/--from-file reads the program file), so an expanded / sensitive
  path leaks a host secret (P=$(printf /etc/passwd); jq -n --rawfile x $P '$x').
  Scan only jq's file-valued options -- jq is NOT a generic reader because its
  positional FILTER legitimately contains `$` (jq variables), which a blanket
  reader rule would misfire on. A local --rawfile (jq --rawfile x data.txt) and a
  $-bearing filter stay allowed.

Regression coverage: TestRound49Bypasses in tests/test_sandbox_tools.py (watch
sh -c payload, xargs replace into exec, sqlite3 CLI escape, jq file-option reads,
plus a round49 benign-allowed set) and a low-level _sqlite3.connect runtime case in
tests/test_sandbox_runtime_backstop.py.
2026-07-10 20:29:56 +00:00
pre-commit-ci[bot]
740e73fd99 [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2026-07-10 19:48:28 +00:00
danielhanchen
b3758eac77 Harden sandbox: process substitution reads; var-prefix path escapes; openssl -in; find -exec over an escaping root; sqlite3 runtime confinement + benign local-DB FP
Close four bypasses and one false positive Codex found on the round-47 branch.

- process substitution <(cmd) / >(cmd): bash runs cmd in a child shell, so
  `echo <(cat /etc/passwd)` reads a host secret, but the command-sub extractor
  only handled $(...) / backticks and skipped the <(...) / >(...) forms. Extract
  the process-substitution body too so its reader is scanned.

- variable-prefix path operand: a leading $VAR / ${VAR} that expands to an
  absolute prefix escapes the workdir even when the operand appends a further
  segment (P=/tmp; git init $P/repo). The write-operand check matched only a
  whole-token variable; resolve a $VAR / ${VAR} PREFIX against the assignment map
  and re-test the concatenation.

- openssl -in: openssl base64/enc -in FILE reads its input, so it can exfiltrate
  a host secret the same way cat/base64 do. Add openssl to the shell read-command
  set so an -in over a sensitive path is caught.

- find -exec reader over an escaping root: `find /etc -name passwd -exec cat {} ;`
  reads a host file, but the -exec segment scan sees only `cat {}` -- the {}
  placeholder carries no path, so the /etc search root is lost. Compute the find
  search roots and, when a reader -exec references {} over a root that escapes the
  workdir, fail closed.

- sqlite3.connect filesystem escape + benign local-DB false positive: the network
  scanner treated any `.connect('string')` as a host, which mis-flagged benign
  local database opens (sqlite3.connect('local.db'), ':memory:') as an untrusted
  host while a bare-string socket connect is really an AF_UNIX path, never an
  AF_INET host. Restrict host classification to the (host, port) TUPLE form, and
  confine the sqlite DB path in the runtime guard instead: sqlite3.connect opens
  the file via the native _sqlite3 C extension (not builtins.open), so the
  open-like backstop never saw it; the guard now denies a database path that
  resolves outside the workdir (absolute / traversal / dynamically built) while
  :memory:, an in-memory URI, and workdir-local databases stay allowed.

Regression coverage: TestRound48Bypasses in tests/test_sandbox_tools.py (process
substitution read, variable-prefix operand escape, openssl -in, find -exec over an
escaping root, plus a round48 benign-allowed set that includes the local /
in-memory sqlite opens) and four runtime cases in
tests/test_sandbox_runtime_backstop.py (sqlite3 absolute + dynamically built
escapes denied; local and :memory: databases allowed).
2026-07-10 19:47:37 +00:00
pre-commit-ci[bot]
77fb4cac93 [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2026-07-10 19:08:14 +00:00
danielhanchen
d57f25f969 Harden sandbox: guard prelude before same-line future stmt; backslash-newline; xargs --process-slot-var; addressed sed e; os.environ mutations; git config --system/--global; command-position FP
Close six bypasses and one false positive Codex found on the round-46 branch.

- guard prelude vs a same-physical-line statement: a `from __future__ import
  annotations; open('/tmp/x','w')` puts a real write on the SAME line as the future
  import, and the line-granular split copied that write into the head, before the guard
  prelude, so it ran unguarded. Split at the last head statement's exact end column and
  drop the leading `; ` so the tail moves after the prelude.

- backslash-newline line continuation: bash removes a `\<newline>` before command
  lookup, so `tou\<nl>ch` runs `touch`, but the newline rewriter preserved it as data.
  Drop the backslash + newline (outside single quotes) so the joined word is tokenized.

- xargs --process-slot-var VAR: the separated operand VAR was mistaken for the command
  word, so `xargs --process-slot-var VAR touch /tmp/p` passed. Add --process-slot-var to
  the xargs wrapper operand set.

- addressed sed e command: GNU sed runs `e COMMAND` after an address (`/x/e cmd`,
  `1,/y/e cmd`), which the standalone-e pattern missed. Add an address-anchored regex
  (boundary or range comma before the `/regex/`, `e` followed by a separator), so
  `s/a/e /` is not misread.

- os.environ mutation before a child: setting os.environ['PATH']='.' (or BASH_ENV / ENV
  / GIT_CONFIG* / GIT_DIR) mutates the inherited environment a later unguarded
  subprocess reads, the same escape as passing env={...}. Flag the dangerous mutation
  (unsafe PATH, a non-empty startup file, a GIT_CONFIG override, an escaping GIT_DIR); a
  benign env var and an absolute PATH prepend stay allowed.

- git config --system / --global writes: the config scan handled --file but not the host
  system / user config files (/etc/gitconfig, ~/.gitconfig). Block a --system / --global
  WRITE (KEY VALUE, or a write flag / --edit); a pure read (--get / --list / a bare KEY)
  and a local `git config user.name x` stay allowed.

- false positive: the sed command-word helper (_command_word_indices) reset command
  position on every shell keyword even as an argument, so `echo if sed -i s/a/b/ file`
  recorded sed and blocked it as `mutating:sed`. Only reset at command position (the
  round-44 fix, now applied to this helper too); real compound headers stay blocked.

Regression coverage: TestRound47Bypasses in tests/test_sandbox_tools.py (backslash
newline, xargs --process-slot-var, addressed sed e, os.environ PATH/BASH_ENV/GIT_CONFIG
mutations, git config --system/--global writes, and the command-position FP allowed)
plus a round47 benign-allowed set, and two guard-prelude cases in
tests/test_sandbox_runtime_backstop.py (a same-line future-import write is confined; the
own-line future import still works).
2026-07-10 19:07:34 +00:00
pre-commit-ci[bot]
d3902d24a4 [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2026-07-10 18:21:10 +00:00
danielhanchen
736e8a6477 Harden sandbox: extend workdir-module import vetter (ctypes, dynamic import, closure/frame gadgets, indirect import-machinery, subscripted builtins); block openssl file output
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).
2026-07-10 18:20:30 +00:00
pre-commit-ci[bot]
e98f775eb8 [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2026-07-10 17:51:54 +00:00
danielhanchen
8814d7de3b Harden sandbox: fail closed on unvetted __code__ stores; workdir getattr sink obfuscation; honor subprocess cwd= and GNU env glued -C/-u for git writes
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).
2026-07-10 17:51:12 +00:00
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