diff --git a/agents/anyplot/code/validate.py b/agents/anyplot/code/validate.py index 04254ca5620..886eb0635fb 100644 --- a/agents/anyplot/code/validate.py +++ b/agents/anyplot/code/validate.py @@ -330,6 +330,30 @@ # (`dataclasses._FuncBuilder`), and numpy's file opener (`numpy.lib._datasource.open`). ESCAPE_ATTRIBUTES: frozenset[str] = frozenset( { + # Frame, generator, coroutine, traceback and code-object introspection (security review of + # 2026-10-14): a generator's frame reaches the interpreter's builtins without a dunder name. + "gi_frame", + "gi_code", + "gi_yieldfrom", + "cr_frame", + "cr_code", + "cr_await", + "cr_origin", + "ag_frame", + "ag_code", + "ag_await", + "f_back", + "f_builtins", + "f_globals", + "f_locals", + "f_code", + "f_trace", + "f_generator", # Python 3.14: the generator or coroutine that owns a frame + "tb_frame", + "tb_next", + "co_code", + "co_consts", + "co_names", # Native code and raw memory through a value, not an import (second opinion of 2026-10-14): # `np.zeros(1).ctypes._ctypes.CDLL(None)` reaches libc, `from_address` reads and writes any # address, `as_strided` views memory outside an array, `.dump` pickles an array to a file; diff --git a/changelog.d/validator-frame-introspection.md b/changelog.d/validator-frame-introspection.md new file mode 100644 index 00000000000..33095dec737 --- /dev/null +++ b/changelog.d/validator-frame-introspection.md @@ -0,0 +1,10 @@ +### Fixed + +- **The plot-code validator refuses frame and generator introspection.** A security review + of 2026-10-14 found that a generator's frame (`gi_frame`, then `f_builtins`) reached the + interpreter's builtins without a dunder name, which the SECURITY profile allowed. Frame, + generator, coroutine, traceback and code-object attributes (`gi_frame`, `gi_code`, + `cr_frame`, `f_back`, `f_builtins`, `f_globals`, `f_locals`, `tb_frame`, `co_consts` and + their siblings) are now refused on any object. The renderer's gVisor sandbox (read-only, + no egress, no secrets in its environment) bounded the exposure; catalogue eligibility is + unchanged (285 matplotlib and 287 seaborn files). (#12169) diff --git a/tests/unit/agents/code/test_validate.py b/tests/unit/agents/code/test_validate.py index b86092b39c4..73da90ac7fb 100644 --- a/tests/unit/agents/code/test_validate.py +++ b/tests/unit/agents/code/test_validate.py @@ -407,6 +407,7 @@ def test_a_reader_call_says_where_the_data_comes_from(self) -> None: "import cmath\nz = cmath.exp(1j)\n", "v = df._values\n", # a private attribute of a value, not of an imported module "top = df.main.max()\n", # a column called main: calendar.main is banned on the calendar module only + "up = df['close'] >= df['open']\n", # OHLC columns: a key named like a builtin is data, not a reach for it ] ESCAPES = [ ("import warnings\nline = warnings.formatwarning('', UserWarning, 'f', 1)\n", "banned-call"), @@ -464,6 +465,16 @@ def test_a_reader_call_says_where_the_data_comes_from(self) -> None: ("import matplotlib\nrun = matplotlib.subprocess\n", "banned-attribute"), ("import matplotlib\nm = matplotlib.os\n", "banned-attribute"), ("import warnings\nw = warnings.sys\n", "banned-attribute"), + # Frame and generator introspection (security review of 2026-10-14): each name alone is refused. + ("g = (i for i in range(1))\nframe = g.gi_frame\n", "banned-attribute"), + ("g = (i for i in range(1))\ncode = g.gi_code\n", "banned-attribute"), + ("names = frame.f_builtins\n", "banned-attribute"), + ("names = frame.f_globals\n", "banned-attribute"), + ("caller = frame.f_back\n", "banned-attribute"), + ("names = frame.f_locals\n", "banned-attribute"), + ("frame = error.tb_frame\n", "banned-attribute"), + ("frame = coroutine.cr_frame\n", "banned-attribute"), + ("values = code.co_consts\n", "banned-attribute"), # Native code and raw memory through a value (second opinion of 2026-10-14): these passed before. ('lib = np.zeros(1).ctypes._ctypes.CDLL(None)\nlib.system(b"id")\n', "banned-attribute"), ( @@ -501,6 +512,18 @@ def test_allowed(self, body: str) -> None: def test_escape_is_caught(self, body: str, rule: str) -> None: assert rule in security_rules(body) + @pytest.mark.parametrize( + "name", + [ + *("gi_frame", "gi_code", "gi_yieldfrom", "cr_frame", "cr_code", "cr_await", "cr_origin"), + *("ag_frame", "ag_code", "ag_await", "f_back", "f_builtins", "f_globals", "f_locals"), + *("f_code", "f_trace", "f_generator", "tb_frame", "tb_next", "co_code", "co_consts", "co_names"), + ], + ) + def test_every_introspection_attribute_is_refused(self, name: str) -> None: + """Each spelling of the 2026-10-14 frame and generator denylist, so a typo cannot reopen one.""" + assert "banned-attribute" in security_rules(f"value = thing.{name}\n") + def test_a_private_module_in_an_import_path_is_named_and_logged_by_its_known_prefix(self) -> None: findings = validate_security(plot("import scipy.fft._backend as backend\n"), library="matplotlib")