From 94935d1686b51519804c2ea1f37dee2eeb761eeb Mon Sep 17 00:00:00 2001 From: Markus Neusinger <2921697+MarkusNeusinger@users.noreply.github.com> Date: Wed, 14 Oct 2026 13:24:10 +0200 Subject: [PATCH 1/4] fix(agents): refuse frame and generator introspection in plot code Security review of 2026-10-14: a generator's frame reached the builtins without a dunder name. Ban frame, generator, coroutine, traceback and code-object attributes on any object. Catalogue eligibility unchanged. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01GdMSqLR5ww4ji74EUSmk9M --- agents/anyplot/code/validate.py | 23 ++++++++++++++++++++ changelog.d/validator-frame-introspection.md | 10 +++++++++ tests/unit/agents/code/test_validate.py | 11 ++++++++++ 3 files changed, 44 insertions(+) create mode 100644 changelog.d/validator-frame-introspection.md diff --git a/agents/anyplot/code/validate.py b/agents/anyplot/code/validate.py index 04254ca5620..c2fc5c4b828 100644 --- a/agents/anyplot/code/validate.py +++ b/agents/anyplot/code/validate.py @@ -330,6 +330,29 @@ # (`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", + "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..f716dfa412a --- /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). diff --git a/tests/unit/agents/code/test_validate.py b/tests/unit/agents/code/test_validate.py index b86092b39c4..b33d29a76d1 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"), ( From 3080e62bff3f2976d8a5ac20c433d4e4391f7724 Mon Sep 17 00:00:00 2001 From: Markus Neusinger <2921697+MarkusNeusinger@users.noreply.github.com> Date: Wed, 14 Oct 2026 13:24:20 +0200 Subject: [PATCH 2/4] chore: changelog PR reference Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01GdMSqLR5ww4ji74EUSmk9M --- changelog.d/validator-frame-introspection.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/changelog.d/validator-frame-introspection.md b/changelog.d/validator-frame-introspection.md index f716dfa412a..33095dec737 100644 --- a/changelog.d/validator-frame-introspection.md +++ b/changelog.d/validator-frame-introspection.md @@ -7,4 +7,4 @@ `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). + unchanged (285 matplotlib and 287 seaborn files). (#12169) From caf42b6235046bc46feb2a9dfb333827469c8671 Mon Sep 17 00:00:00 2001 From: Markus Neusinger <2921697+MarkusNeusinger@users.noreply.github.com> Date: Wed, 14 Oct 2026 13:27:52 +0200 Subject: [PATCH 3/4] test(agents): every frame-introspection name is refused Copilot review of #12169: cover all 21 denylist spellings. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01GdMSqLR5ww4ji74EUSmk9M --- tests/unit/agents/code/test_validate.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/tests/unit/agents/code/test_validate.py b/tests/unit/agents/code/test_validate.py index b33d29a76d1..dbc7bfa58a0 100644 --- a/tests/unit/agents/code/test_validate.py +++ b/tests/unit/agents/code/test_validate.py @@ -512,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", "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") From b54bb5b5cd9594ee6e02d702b69075f395ba38b4 Mon Sep 17 00:00:00 2001 From: Markus Neusinger <2921697+MarkusNeusinger@users.noreply.github.com> Date: Wed, 14 Oct 2026 13:38:08 +0200 Subject: [PATCH 4/4] fix(agents): refuse f_generator too Copilot review of #12169: Python 3.14 adds frame.f_generator. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01GdMSqLR5ww4ji74EUSmk9M --- agents/anyplot/code/validate.py | 1 + tests/unit/agents/code/test_validate.py | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/agents/anyplot/code/validate.py b/agents/anyplot/code/validate.py index c2fc5c4b828..886eb0635fb 100644 --- a/agents/anyplot/code/validate.py +++ b/agents/anyplot/code/validate.py @@ -348,6 +348,7 @@ "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", diff --git a/tests/unit/agents/code/test_validate.py b/tests/unit/agents/code/test_validate.py index dbc7bfa58a0..73da90ac7fb 100644 --- a/tests/unit/agents/code/test_validate.py +++ b/tests/unit/agents/code/test_validate.py @@ -517,7 +517,7 @@ def test_escape_is_caught(self, body: str, rule: str) -> None: [ *("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", "tb_frame", "tb_next", "co_code", "co_consts", "co_names"), + *("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: