Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 57 additions & 1 deletion agents/anyplot/code/validate.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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}
Comment thread
MarkusNeusinger marked this conversation as resolved.
Resolved

_DUNDER_NAME = re.compile(r"^__\w+__$")
_DUNDER_IN_TEXT = re.compile(r"__\w+__")
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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:
Expand Down
9 changes: 9 additions & 0 deletions changelog.d/validator-native-escape-names.md
Original file line number Diff line number Diff line change
@@ -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)
26 changes: 26 additions & 0 deletions tests/unit/agents/code/test_validate.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
]


Expand Down Expand Up @@ -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",
Expand Down
Loading