diff --git a/agents/anyplot/code/validate.py b/agents/anyplot/code/validate.py index 98b1f71e946..04254ca5620 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,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`, 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+__") @@ -575,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): @@ -747,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() @@ -913,9 +938,40 @@ def _check_calls(self) -> None: 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): + # 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/changelog.d/validator-native-escape-names.md b/changelog.d/validator-native-escape-names.md new file mode 100644 index 00000000000..a1449ead24f --- /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). (#12167) diff --git a/tests/unit/agents/code/test_validate.py b/tests/unit/agents/code/test_validate.py index 9b4fefd132b..b86092b39c4 100644 --- a/tests/unit/agents/code/test_validate.py +++ b/tests/unit/agents/code/test_validate.py @@ -464,6 +464,29 @@ 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(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"), + ('df.agg("to_string", buf="f")\n', "banned-call"), + ('df.apply("info", buf="f")\n', "banned-call"), ] @@ -690,6 +713,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",