From ac6147ccc8e096136ea0a6150d0d7cec3f9995a7 Mon Sep 17 00:00:00 2001 From: Markus Neusinger <2921697+MarkusNeusinger@users.noreply.github.com> Date: Wed, 14 Oct 2026 11:05:30 +0200 Subject: [PATCH 1/4] fix(agents): refuse native code and raw memory reached through a value Second opinion of 2026-10-14: np.zeros(1).ctypes._ctypes.CDLL(None) and type(...).from_address(0) passed the SECURITY profile, since the private-name rule applies only to imported names. Ban ctypes, _ctypes, cffi, data_as, from_address, from_buffer(_copy), as_strided, dump(s), extbuild, get_rdataset and webuse on any object, and to_string()/info() with a target. Catalogue eligibility unchanged (285 / 287). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01GdMSqLR5ww4ji74EUSmk9M --- agents/anyplot/code/validate.py | 23 ++++++++++++++++++++ changelog.d/validator-native-escape-names.md | 9 ++++++++ tests/unit/agents/code/test_validate.py | 16 ++++++++++++++ 3 files changed, 48 insertions(+) create mode 100644 changelog.d/validator-native-escape-names.md diff --git a/agents/anyplot/code/validate.py b/agents/anyplot/code/validate.py index 98b1f71e946..b4b8f2bcd3b 100644 --- a/agents/anyplot/code/validate.py +++ b/agents/anyplot/code/validate.py @@ -330,6 +330,23 @@ # (`dataclasses._FuncBuilder`), and numpy's file opener (`numpy.lib._datasource.open`). ESCAPE_ATTRIBUTES: frozenset[str] = frozenset( { + # 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; + # `extbuild` compiles C, and `get_rdataset` / `webuse` fetch over the network. + "ctypes", + "_ctypes", + "cffi", + "data_as", + "from_address", + "from_buffer", + "from_buffer_copy", + "as_strided", + "dump", + "dumps", + "extbuild", + "get_rdataset", + "webuse", "sys", "os", "builtins", @@ -440,6 +457,8 @@ # `np.loadtxt`, `sns.load_dataset`, sklearn's `load_*`). _BANNED_IO_PATTERN = re.compile(r"^(?:read_\w+|fetch_\w+|load\w*)$") _STRING_DISPATCH_METHODS = frozenset({"agg", "aggregate", "apply", "transform", "applymap"}) +# Methods that return text without a target and write to one given as `buf` (positional or keyword). +_BUFFER_WRITERS = frozenset({"to_string", "info"}) _DUNDER_NAME = re.compile(r"^__\w+__$") _DUNDER_IN_TEXT = re.compile(r"__\w+__") @@ -907,6 +926,10 @@ def _check_calls(self) -> None: plain = len(call.args) == 1 and not call.keywords and not isinstance(call.args[0], ast.Starred) if not plain: self.add("banned-call", "type() is only allowed with one argument", call) + if isinstance(func, ast.Attribute) and func.attr in _BUFFER_WRITERS: + # `df.to_string("f")` and `df.info(buf=f)` write to a path; with no target they return text. + if call.args or any(keyword.arg in ("buf", None) for keyword in call.keywords): + self.add("banned-call", f"'.{func.attr}()' with a target is not allowed", call) if isinstance(func, ast.Attribute) and func.attr in _STRING_DISPATCH_METHODS: for argument in [*call.args, *[keyword.value for keyword in call.keywords]]: for constant in ast.walk(argument): diff --git a/changelog.d/validator-native-escape-names.md b/changelog.d/validator-native-escape-names.md new file mode 100644 index 00000000000..4065a0f3dbc --- /dev/null +++ b/changelog.d/validator-native-escape-names.md @@ -0,0 +1,9 @@ +### Fixed + +- **The plot-code validator refuses native code and raw memory reached through a value.** + A second opinion of 2026-10-14 found that `np.zeros(1).ctypes._ctypes.CDLL(None)` and + `type(...).from_address(0)` passed the SECURITY profile, because the private-name rule + only applies to imported names. The validator now bans `ctypes`, `_ctypes`, `cffi`, + `data_as`, `from_address`, `from_buffer`, `as_strided`, `dump`, `extbuild`, + `get_rdataset` and `webuse` on any object, and `.to_string()` / `.info()` with a target. + The catalogue's 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 9b4fefd132b..02cf5629d33 100644 --- a/tests/unit/agents/code/test_validate.py +++ b/tests/unit/agents/code/test_validate.py @@ -464,6 +464,22 @@ 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"), + # 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"), + ( + "state = np.random.default_rng(0).bit_generator.ctypes.state\nx = type(state).from_address(0)\n", + "banned-attribute", + ), + ("ptr = np.zeros(1).ctypes.data_as(None)\n", "banned-attribute"), + ("view = np.lib.stride_tricks.as_strided(np.zeros(1), shape=(10**6,))\n", "banned-attribute"), + ('np.zeros(3).dump("f")\n', "banned-attribute"), + ("compile_c = np.testing.extbuild\n", "banned-attribute"), + ('import statsmodels.api as sm\nd = sm.datasets.get_rdataset("x")\n', "banned-attribute"), + ('import statsmodels.api as sm\nd = sm.datasets.webuse("x")\n', "banned-attribute"), + ('df.to_string("f")\n', "banned-call"), + ('df.to_string(buf="f")\n', "banned-call"), + ("df.info(buf=None)\n", "banned-call"), + ("df.info(**{})\n", "banned-call"), ] From 638874db97bea3967517b7a46634109717a708de Mon Sep 17 00:00:00 2001 From: Markus Neusinger <2921697+MarkusNeusinger@users.noreply.github.com> Date: Wed, 14 Oct 2026 11:05:42 +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-native-escape-names.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/changelog.d/validator-native-escape-names.md b/changelog.d/validator-native-escape-names.md index 4065a0f3dbc..a1449ead24f 100644 --- a/changelog.d/validator-native-escape-names.md +++ b/changelog.d/validator-native-escape-names.md @@ -6,4 +6,4 @@ only applies to imported names. The validator now bans `ctypes`, `_ctypes`, `cffi`, `data_as`, `from_address`, `from_buffer`, `as_strided`, `dump`, `extbuild`, `get_rdataset` and `webuse` on any object, and `.to_string()` / `.info()` with a target. - The catalogue's eligibility is unchanged (285 matplotlib and 287 seaborn files). + The catalogue's eligibility is unchanged (285 matplotlib and 287 seaborn files). (#12167) From 62d4af800839bd8be03b651293a7a5448d368bfb Mon Sep 17 00:00:00 2001 From: Markus Neusinger <2921697+MarkusNeusinger@users.noreply.github.com> Date: Wed, 14 Oct 2026 11:11:13 +0200 Subject: [PATCH 3/4] fix(agents): to_string/info only as direct calls; info's buf is its second argument Copilot review of #12167: a method bound to a name hid its target from the call check, and info() takes verbose first. The buffer writers may now only be called where they are named, and a call is refused when its buf (position 0 for to_string, 1 for info, or the keyword) is not None, or a starred argument could carry it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01GdMSqLR5ww4ji74EUSmk9M --- agents/anyplot/code/validate.py | 38 +++++++++++++++++++++---- tests/unit/agents/code/test_validate.py | 10 ++++++- 2 files changed, 41 insertions(+), 7 deletions(-) diff --git a/agents/anyplot/code/validate.py b/agents/anyplot/code/validate.py index b4b8f2bcd3b..e64ddbc2a95 100644 --- a/agents/anyplot/code/validate.py +++ b/agents/anyplot/code/validate.py @@ -457,8 +457,9 @@ # `np.loadtxt`, `sns.load_dataset`, sklearn's `load_*`). _BANNED_IO_PATTERN = re.compile(r"^(?:read_\w+|fetch_\w+|load\w*)$") _STRING_DISPATCH_METHODS = frozenset({"agg", "aggregate", "apply", "transform", "applymap"}) -# Methods that return text without a target and write to one given as `buf` (positional or keyword). -_BUFFER_WRITERS = frozenset({"to_string", "info"}) +# Methods that return text without a target and write to one given as `buf`, with the position of +# `buf` among their positional parameters (`DataFrame.info(verbose, buf, …)`). +_BUFFER_WRITERS: dict[str, int] = {"to_string": 0, "info": 1} _DUNDER_NAME = re.compile(r"^__\w+__$") _DUNDER_IN_TEXT = re.compile(r"__\w+__") @@ -594,6 +595,10 @@ def _hex(value: str) -> str: return value.strip().upper() +def _is_none(node: ast.expr) -> bool: + return isinstance(node, ast.Constant) and node.value is None + + def _text(node: ast.Constant) -> str | None: """The textual content of a string or bytes literal, or None for other constants.""" if isinstance(node.value, str): @@ -766,6 +771,7 @@ def security(self, library: str, allowed_imports: frozenset[str]) -> None: self._check_names() self._check_attributes() self._check_calls() + self._check_buffer_writers() self._check_statements() self._check_strings() self._check_savefig() @@ -926,16 +932,36 @@ def _check_calls(self) -> None: plain = len(call.args) == 1 and not call.keywords and not isinstance(call.args[0], ast.Starred) if not plain: self.add("banned-call", "type() is only allowed with one argument", call) - if isinstance(func, ast.Attribute) and func.attr in _BUFFER_WRITERS: - # `df.to_string("f")` and `df.info(buf=f)` write to a path; with no target they return text. - if call.args or any(keyword.arg in ("buf", None) for keyword in call.keywords): - self.add("banned-call", f"'.{func.attr}()' with a target is not allowed", call) if isinstance(func, ast.Attribute) and func.attr in _STRING_DISPATCH_METHODS: for argument in [*call.args, *[keyword.value for keyword in call.keywords]]: for constant in ast.walk(argument): if isinstance(constant, ast.Constant) and isinstance(constant.value, str): self._check_dispatch_name(constant.value, func.attr, constant) + def _check_buffer_writers(self) -> None: + """`df.to_string("f")` and `df.info(buf=f)` write to a path; with no target they return text. + + Such a method may only be called where it is named (`w = df.to_string; w("f")` would hide + the target), and a call is refused when its `buf` argument, by position or by keyword, is + anything but `None`, or when a starred argument could carry it. + """ + for attribute in self.of(ast.Attribute): + position = _BUFFER_WRITERS.get(attribute.attr) + if position is None: + continue + call = self.parents.get(attribute) + if not (isinstance(call, ast.Call) and call.func is attribute): + self.add("banned-attribute", f"'.{attribute.attr}' may only be called directly", attribute) + continue + targets = [ + *(call.args[position : position + 1]), + *(keyword.value for keyword in call.keywords if keyword.arg == "buf"), + ] + starred = any(isinstance(argument, ast.Starred) for argument in call.args[: position + 1]) + unpacked = any(keyword.arg is None for keyword in call.keywords) + if starred or unpacked or any(not _is_none(target) for target in targets): + self.add("banned-call", f"'.{attribute.attr}()' with a target is not allowed", call) + def _check_dispatch_name(self, name: str, method: str, node: ast.AST) -> None: """`df.agg("to_csv")` reaches the method by `getattr`: the name is checked as if written.""" if name in BANNED_NAMES or name in BANNED_ATTRIBUTES or name in BANNED_IO or _BANNED_IO_PATTERN.match(name): diff --git a/tests/unit/agents/code/test_validate.py b/tests/unit/agents/code/test_validate.py index 02cf5629d33..d308385250c 100644 --- a/tests/unit/agents/code/test_validate.py +++ b/tests/unit/agents/code/test_validate.py @@ -478,8 +478,13 @@ def test_a_reader_call_says_where_the_data_comes_from(self) -> None: ('import statsmodels.api as sm\nd = sm.datasets.webuse("x")\n', "banned-attribute"), ('df.to_string("f")\n', "banned-call"), ('df.to_string(buf="f")\n', "banned-call"), - ("df.info(buf=None)\n", "banned-call"), + ('df.info(True, "f")\n', "banned-call"), + ('df.info(buf="f")\n', "banned-call"), ("df.info(**{})\n", "banned-call"), + ("args = []\ndf.to_string(*args)\n", "banned-call"), + # The method bound to a name would hide the target from the call check (#12167 review). + ('writer = df.to_string\nwriter("f")\n', "banned-attribute"), + ('writer = df.info\nwriter(buf="f")\n', "banned-attribute"), ] @@ -706,6 +711,9 @@ def test_single_underscores_and_half_dunders_pass(self, body: str) -> None: "v = df.to_numpy()\n", "v = df.to_dict()\n", "v = df.to_string()\n", + "v = df.to_string(buf=None, index=False)\n", + "df.info(False)\n", + "df.info(verbose=True, buf=None)\n", "r = df['read_count']\n", "r = df['load']\n", "v = df.agg('mean')\n", From 16a8bfc5cc01a43e511fab914f15add42b76ea76 Mon Sep 17 00:00:00 2001 From: Markus Neusinger <2921697+MarkusNeusinger@users.noreply.github.com> Date: Wed, 14 Oct 2026 11:17:26 +0200 Subject: [PATCH 4/4] fix(agents): buffer writers named as a string are refused too Copilot review of #12167: df.agg("to_string", buf=...) reached the writer by name; _check_dispatch_name now includes the buffer writers. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01GdMSqLR5ww4ji74EUSmk9M --- agents/anyplot/code/validate.py | 9 ++++++++- tests/unit/agents/code/test_validate.py | 2 ++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/agents/anyplot/code/validate.py b/agents/anyplot/code/validate.py index e64ddbc2a95..04254ca5620 100644 --- a/agents/anyplot/code/validate.py +++ b/agents/anyplot/code/validate.py @@ -964,7 +964,14 @@ def _check_buffer_writers(self) -> None: def _check_dispatch_name(self, name: str, method: str, node: ast.AST) -> None: """`df.agg("to_csv")` reaches the method by `getattr`: the name is checked as if written.""" - if name in BANNED_NAMES or name in BANNED_ATTRIBUTES or name in BANNED_IO or _BANNED_IO_PATTERN.match(name): + # A buffer writer counts too: `df.agg("to_string", buf="f")` forwards the target (#12167 review). + if ( + name in BANNED_NAMES + or name in BANNED_ATTRIBUTES + or name in BANNED_IO + or name in _BUFFER_WRITERS + or _BANNED_IO_PATTERN.match(name) + ): self.add("banned-call", f"'{name}' is not allowed as a method name in .{method}()", node) def _check_statements(self) -> None: diff --git a/tests/unit/agents/code/test_validate.py b/tests/unit/agents/code/test_validate.py index d308385250c..b86092b39c4 100644 --- a/tests/unit/agents/code/test_validate.py +++ b/tests/unit/agents/code/test_validate.py @@ -485,6 +485,8 @@ def test_a_reader_call_says_where_the_data_comes_from(self) -> None: # The method bound to a name would hide the target from the call check (#12167 review). ('writer = df.to_string\nwriter("f")\n', "banned-attribute"), ('writer = df.info\nwriter(buf="f")\n', "banned-attribute"), + ('df.agg("to_string", buf="f")\n', "banned-call"), + ('df.apply("info", buf="f")\n', "banned-call"), ]