Skip to content

Commit 7f6d7df

Browse files
Fix two more lint holes flagged in PR review (M3 follow-up 2)
Two findings from the second-round review: [P2] The two-pass alias map was last-write-wins, so a later ``import pathlib as os`` erased the earlier ``import os`` binding for the call-site check. ``import os; os.system("ls"); import pathlib as os`` therefore passed the lint with zero violations. Same shape with ``import os as runner; runner.execve(...); import pathlib as runner`` also passed. [P3] STABILITY.md claimed ``import importlib*`` forms are forbidden, but ``import importlib.metadata`` and ``from importlib.metadata import version`` are intentionally allowed (the plugin registry under checks/ uses metadata for entry-point discovery). The documentation overpromised what the lint enforces. Reproduced both findings against the prior commit (rebind-os and rebind-aliased returned []; importlib.metadata returned []) before fixing. Implementation (tests/test_adapter_static_only.py): - Convert ``module_aliases: dict[str, str]`` to ``dict[str, set[str]]`` and ``name_aliases: dict[str, tuple[str, str]]`` to ``dict[str, set[tuple[str, str]]]``. A local name binds to the *union* of every canonical module/attr pair it was ever assigned in the file. ``import os; import pathlib as os`` now yields ``module_aliases["os"] = {"os", "pathlib"}``. - ``_resolve_attribute_chain`` -> ``_resolve_attribute_chain_all`` returns every canonical chain a call could resolve to. Call sites flag once if *any* resolution is forbidden. - Same conservative-union logic for from-import aliases at bare-name call sites. - Added 4 negative-control parametrize cases covering both rebind shapes (module alias + from-import alias) plus the reverse-order variant where the dangerous binding follows the safe one. Verified ``import os; import pathlib as os; os.path.join('a','b')`` does NOT false-positive — ``os.path.join`` is in neither the exact nor prefix sets under either binding. False-positive trade-off (acknowledged in the docstring): if a local name is ever bound to a dangerous module *in any branch*, every attribute chain on that name is checked against the forbidden surface. For trust-model lint this is the right failure mode — suspicious aliasing should be a code-review trigger, not a silent pass. Documentation (STABILITY.md): - Replace the vague ``import runpy / import subprocess / import importlib*`` line with an enumerated forbidden-modules list (``runpy``, ``subprocess``, ``importlib``, ``importlib.util``, ``importlib.machinery``, ``builtins``) and an enumerated forbidden-attribute list (including the ``os.exec*`` / ``os.spawn*`` / ``os.posix_spawn*`` prefix families and ``os.system`` / ``os.popen``). - Explicitly call out that ``importlib.metadata`` is allowed and why (plugin-discovery surface lives under ``checks/`` not ``inputs/``, and discovery runs against the installed environment, not user workspace files). - Document the conservative union-of-bindings alias resolution so reviewers know an aliased rebinding cannot defeat the lint. 69 tests pass (up from 65); ruff clean. Production code under src/ unchanged. Baseline scan of current inputs/ still clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 6cacfb6 commit 7f6d7df

2 files changed

Lines changed: 130 additions & 54 deletions

File tree

STABILITY.md

Lines changed: 24 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -164,13 +164,30 @@ The no-execute / no-import property is enforced by two complementary
164164
tests on every CI run, not by convention:
165165

166166
- **[`tests/test_adapter_static_only.py`](tests/test_adapter_static_only.py)**
167-
AST scan of every source under `src/agents_shipgate/inputs/`. Any call to
168-
`exec` / `eval` / `__import__` / `compile`, or any use of dynamic-import
169-
surfaces (`importlib.import_module`, `importlib.util.spec_from_file_location`,
170-
`runpy.run_path`, `subprocess.run`, `os.system`, etc.), fails the build. The
171-
matching `import runpy`/`import subprocess`/`import importlib*` forms are
172-
forbidden too so an aliased re-export cannot slip past the call-site checks.
173-
Runs as a dedicated CI step labeled *Trust-model invariant lint* before the
167+
AST scan of every source under `src/agents_shipgate/inputs/`. The scan
168+
rejects:
169+
- Bare-name calls to `exec` / `eval` / `__import__` / `compile`.
170+
- Attribute calls to `importlib.import_module`,
171+
`importlib.util.spec_from_file_location`,
172+
`importlib.util.module_from_spec`,
173+
`importlib.machinery.SourceFileLoader`,
174+
`runpy.run_path`, `runpy.run_module`,
175+
`subprocess.{run, call, Popen, check_call, check_output}`,
176+
`os.system`, `os.popen`, and every variant under the
177+
`os.exec*` / `os.spawn*` / `os.posix_spawn*` prefixes.
178+
- Module imports of `runpy`, `subprocess`, `importlib`,
179+
`importlib.util`, `importlib.machinery`, and `builtins` — in any
180+
`import X`, `import X as Y`, or `from X import …` form.
181+
- Wildcard `from os import *`.
182+
183+
`importlib.metadata` is intentionally allowed: the plugin registry
184+
under `checks/` (not `inputs/`) uses it for entry-point discovery,
185+
and discovery happens against the *installed* environment, not user
186+
workspace files. Aliased re-exports (`import os as oo`,
187+
`from os import system as sh`, `import os; import pathlib as os`) are
188+
resolved through union-of-bindings alias maps so a later import
189+
cannot erase an earlier forbidden binding. The lint runs as a
190+
dedicated CI step labeled *Trust-model invariant lint* before the
174191
main test suite so a regression is visible at the top of CI logs.
175192
- **[`tests/test_fixture_no_import.py`](tests/test_fixture_no_import.py)**
176193
per-adapter live-load tests. Each adapter (LangChain, CrewAI, OpenAI Agents

tests/test_adapter_static_only.py

Lines changed: 106 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -146,41 +146,64 @@ def _is_forbidden_chain(chain: str) -> bool:
146146
return chain.startswith(FORBIDDEN_ATTR_CALL_PREFIXES)
147147

148148

149-
def _resolve_attribute_chain(
150-
node: ast.Attribute, module_aliases: dict[str, str]
151-
) -> str | None:
152-
"""Reduce ``a.b.c`` Attribute chain to a canonical dotted string.
149+
def _resolve_attribute_chain_all(
150+
node: ast.Attribute, module_aliases: dict[str, set[str]]
151+
) -> list[str]:
152+
"""Return every canonical dotted chain a ``a.b.c`` Attribute could resolve to.
153153
154-
Substitutes the root Name through ``module_aliases`` so
155-
``import os as oo; oo.system`` resolves to ``"os.system"``.
154+
Substitutes the root Name through ``module_aliases`` for **all** modules the
155+
local name was ever bound to in the file. If the root was bound to multiple
156+
modules (``import os as p; import pathlib as p``), each binding produces one
157+
candidate chain. The caller flags the call if *any* candidate is forbidden.
156158
157-
Returns None for chains rooted in something that is not a Name
159+
Returns an empty list for chains rooted in something other than a Name
158160
(e.g. ``func().attr``) — those are out of scope for static lint.
161+
162+
Conservative union-of-bindings: an order-aware single-pass walk would be
163+
more precise, but for trust-model lint we want to flag a chain as soon as
164+
*any* possible binding leads to a forbidden surface. False positives here
165+
force a code review of suspicious aliasing patterns, which is the right
166+
failure mode.
159167
"""
160168
parts: list[str] = []
161169
current: ast.AST = node
162170
while isinstance(current, ast.Attribute):
163171
parts.append(current.attr)
164172
current = current.value
165173
if not isinstance(current, ast.Name):
166-
return None
174+
return []
167175
parts.append(current.id)
168176
parts.reverse()
169177
root = parts[0]
170-
if root in module_aliases:
171-
canonical_root = module_aliases[root]
172-
parts = canonical_root.split(".") + parts[1:]
173-
return ".".join(parts)
178+
if root not in module_aliases:
179+
# Root was never bound by an import in this file. Treat the
180+
# textual root as canonical — catches ``os.system(...)`` written
181+
# without a prior ``import os`` (broken code that the lint should
182+
# still call out structurally).
183+
return [".".join(parts)]
184+
return [
185+
".".join(canonical_root.split(".") + parts[1:])
186+
for canonical_root in module_aliases[root]
187+
]
174188

175189

176190
def _scan_source(source: str, path: Path) -> list[str]:
177191
"""Return a list of human-readable violation strings.
178192
179-
Two passes: pass 1 walks every ``Import`` / ``ImportFrom`` to build
180-
alias maps; pass 2 walks every ``Call`` and resolves names back to
181-
the canonical module-qualified path before checking the forbidden
182-
sets. This catches aliased re-exports that a single-pass call-site
183-
scan would miss.
193+
Two passes:
194+
195+
1. Walk every ``Import`` / ``ImportFrom`` and accumulate alias maps as
196+
**unions of bindings**. ``module_aliases[local]`` is the set of every
197+
canonical module that local name was ever bound to in the file;
198+
``name_aliases[local]`` is the set of every ``(module, attr)`` pair
199+
a from-import alias could resolve to. This deliberately ignores
200+
statement order — a later ``import pathlib as os`` does NOT erase
201+
an earlier ``import os`` binding, because the earlier ``os.system(...)``
202+
call at lines between still resolves through the original ``os``.
203+
2. Walk every ``Call`` and resolve names through the alias unions. A
204+
call is flagged if *any* possible resolution hits the forbidden
205+
surface. False positives are acceptable for trust-model lint —
206+
suspicious aliasing should be a code-review trigger.
184207
"""
185208
try:
186209
tree = ast.parse(source, filename=str(path))
@@ -190,16 +213,19 @@ def _scan_source(source: str, path: Path) -> list[str]:
190213
violations: list[str] = []
191214

192215
# --- Pass 1: imports ---------------------------------------------------
193-
# module_aliases: locally-bound name -> canonical dotted module path.
194-
# ``import os`` -> {"os": "os"}
195-
# ``import os as op`` -> {"op": "os"}
196-
# ``import os.path`` -> {"os": "os"} (top-level binding)
197-
# ``import os.path as p`` -> {"p": "os.path"}
198-
module_aliases: dict[str, str] = {}
199-
# name_aliases: locally-bound name -> (canonical_module, canonical_attr).
200-
# ``from os import system`` -> {"system": ("os", "system")}
201-
# ``from os import system as sh`` -> {"sh": ("os", "system")}
202-
name_aliases: dict[str, tuple[str, str]] = {}
216+
# module_aliases: locally-bound name -> {every canonical module path it
217+
# was ever bound to in this file}.
218+
# ``import os`` -> {"os": {"os"}}
219+
# ``import os as op`` -> {"op": {"os"}}
220+
# ``import os; import pathlib as os`` -> {"os": {"os", "pathlib"}}
221+
# ``import os.path`` -> {"os": {"os"}}
222+
# ``import os.path as p`` -> {"p": {"os.path"}}
223+
module_aliases: dict[str, set[str]] = {}
224+
# name_aliases: locally-bound name -> {every (canonical_module, attr) it
225+
# was ever bound to in this file}.
226+
# ``from os import system`` -> {"system": {("os", "system")}}
227+
# ``from os import system as sh`` -> {"sh": {("os", "system")}}
228+
name_aliases: dict[str, set[tuple[str, str]]] = {}
203229

204230
for node in ast.walk(tree):
205231
if isinstance(node, ast.Import):
@@ -210,11 +236,11 @@ def _scan_source(source: str, path: Path) -> list[str]:
210236
f"{alias.name!r} (dynamic Python loading surface)"
211237
)
212238
if alias.asname:
213-
module_aliases[alias.asname] = alias.name
239+
module_aliases.setdefault(alias.asname, set()).add(alias.name)
214240
else:
215241
# ``import os.path`` binds the top-level ``os`` locally.
216242
top = alias.name.split(".")[0]
217-
module_aliases[top] = top
243+
module_aliases.setdefault(top, set()).add(top)
218244
elif isinstance(node, ast.ImportFrom):
219245
mod = node.module or ""
220246
if mod in FORBIDDEN_MODULES:
@@ -244,7 +270,7 @@ def _scan_source(source: str, path: Path) -> list[str]:
244270
f"of {canonical!r}"
245271
)
246272
local = alias.asname or alias.name
247-
name_aliases[local] = (mod, alias.name)
273+
name_aliases.setdefault(local, set()).add((mod, alias.name))
248274

249275
# --- Pass 2: call sites ------------------------------------------------
250276
for node in ast.walk(tree):
@@ -258,26 +284,33 @@ def _scan_source(source: str, path: Path) -> list[str]:
258284
f"{rel}:{node.lineno}: forbidden builtin call {func.id!r}"
259285
)
260286
continue
261-
# Aliased ``from X import Y[ as Z]; Z(...)``.
287+
# Aliased ``from X import Y[ as Z]; Z(...)``. Iterate every
288+
# possible (module, attr) binding for this local name. Flag
289+
# the call once if any resolution is forbidden.
262290
if func.id in name_aliases:
263-
mod, attr = name_aliases[func.id]
264-
canonical = f"{mod}.{attr}"
265-
if _is_forbidden_chain(canonical):
266-
via = (
267-
f" (via from-import alias {func.id!r})"
268-
if func.id != attr
269-
else f" (via from-import of {attr!r})"
270-
)
291+
for mod, attr in sorted(name_aliases[func.id]):
292+
canonical = f"{mod}.{attr}"
293+
if _is_forbidden_chain(canonical):
294+
via = (
295+
f" (via from-import alias {func.id!r})"
296+
if func.id != attr
297+
else f" (via from-import of {attr!r})"
298+
)
299+
violations.append(
300+
f"{rel}:{node.lineno}: forbidden call "
301+
f"{canonical!r}{via}"
302+
)
303+
break
304+
elif isinstance(func, ast.Attribute):
305+
# Iterate every possible resolution of the attribute chain
306+
# (the root may have been bound to multiple modules in the
307+
# file). Flag the call once if any resolution is forbidden.
308+
for chain in _resolve_attribute_chain_all(func, module_aliases):
309+
if _is_forbidden_chain(chain):
271310
violations.append(
272-
f"{rel}:{node.lineno}: forbidden call "
273-
f"{canonical!r}{via}"
311+
f"{rel}:{node.lineno}: forbidden call {chain!r}"
274312
)
275-
elif isinstance(func, ast.Attribute):
276-
chain = _resolve_attribute_chain(func, module_aliases)
277-
if chain and _is_forbidden_chain(chain):
278-
violations.append(
279-
f"{rel}:{node.lineno}: forbidden call {chain!r}"
280-
)
313+
break
281314
return violations
282315

283316

@@ -417,6 +450,32 @@ def test_adapter_source_contains_no_forbidden_calls_or_imports(
417450
"from os import *",
418451
"forbidden wildcard from-import from 'os'",
419452
),
453+
# --- Order-of-import rebind bypass ---
454+
# The reviewer's case: a later ``import pathlib as os`` must not
455+
# erase the earlier ``import os`` binding for purposes of the
456+
# call-site check at the lines between. Union-of-bindings means
457+
# ``os.system(...)`` resolves through *both* ``os`` and ``pathlib``
458+
# and ``os.system`` is forbidden regardless of statement order.
459+
(
460+
"import os\nos.system('echo hi')\nimport pathlib as os\n",
461+
"forbidden call 'os.system'",
462+
),
463+
(
464+
"import os as runner\nrunner.execve('/bin/sh', ['sh'])\n"
465+
"import pathlib as runner\n",
466+
"forbidden call 'os.execve'",
467+
),
468+
(
469+
"from os import system\nsystem('ls')\n"
470+
"from pathlib import system\n",
471+
"forbidden from-import of 'os.system'",
472+
),
473+
# Even when the FORBIDDEN binding comes *after* the safe one,
474+
# the union catches it.
475+
(
476+
"import pathlib as os\nos.system('echo hi')\nimport os\n",
477+
"forbidden call 'os.system'",
478+
),
420479
# --- ``builtins`` module surfaces ---
421480
("import builtins", "forbidden import 'builtins'"),
422481
("import builtins as b", "forbidden import 'builtins'"),

0 commit comments

Comments
 (0)