diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1cc4323..72d0638 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,6 +36,14 @@ jobs: - name: Verify generated schemas are up to date run: python scripts/generate_schemas.py --check + - name: Trust-model invariant lint (static, no user code execution) + run: python -m pytest tests/test_adapter_static_only.py -q + # Fails fast and visibly before the full suite when an adapter + # under src/agents_shipgate/inputs/ introduces exec/eval/__import__/ + # compile or a dynamic-import surface (importlib/runpy/subprocess). + # The companion live-load tests in tests/test_fixture_no_import.py + # run as part of the main Test step below. + - name: Test run: python -m pytest --cov=agents_shipgate --cov-report=term-missing --cov-fail-under=75 diff --git a/STABILITY.md b/STABILITY.md index 6b84e86..133bacb 100644 --- a/STABILITY.md +++ b/STABILITY.md @@ -186,6 +186,49 @@ The scanner does not, under any circumstances: - Invoke LLMs - Send telemetry +The no-execute / no-import property is enforced by two complementary +tests on every CI run, not by convention: + +- **[`tests/test_adapter_static_only.py`](tests/test_adapter_static_only.py)** — + AST scan of every source under `src/agents_shipgate/inputs/`. The scan + rejects: + - Bare-name calls to `exec` / `eval` / `__import__` / `compile`. + - Attribute calls to `importlib.import_module`, + `importlib.util.spec_from_file_location`, + `importlib.util.module_from_spec`, + `importlib.machinery.SourceFileLoader`, + `runpy.run_path`, `runpy.run_module`, + `subprocess.{run, call, Popen, check_call, check_output}`, + `os.system`, `os.popen`, and every variant under the + `os.exec*` / `os.spawn*` / `os.posix_spawn*` prefixes. + - Module imports of `runpy`, `subprocess`, `importlib`, + `importlib.util`, `importlib.machinery`, and `builtins` — in any + `import X`, `import X as Y`, `import X.child`, or + `from X.child import …` form. + - Wildcard `from os import *`. + + `importlib.metadata` is intentionally allowed: the plugin registry + under `checks/` (not `inputs/`) uses it for entry-point discovery, + and discovery happens against the *installed* environment, not user + workspace files. Aliased re-exports (`import os as oo`, + `from os import system as sh`, `import os; import pathlib as os`) are + resolved through union-of-bindings alias maps so a later import + cannot erase an earlier forbidden binding. The lint runs as a + dedicated CI step labeled *Trust-model invariant lint* before the + main test suite so a regression is visible at the top of CI logs. +- **[`tests/test_fixture_no_import.py`](tests/test_fixture_no_import.py)** — + per-adapter live-load tests. Each adapter (LangChain, CrewAI, OpenAI Agents + SDK, Google ADK, MCP, OpenAPI, Anthropic, OpenAI API, n8n, Codex plugin) is + driven against a fixture whose Python content (or a sibling `trap.py`, for + declarative adapters) raises `RuntimeError` at module load. Each test + additionally snapshots `sys.modules` and asserts no module whose `__file__` + resolves under the fixture root ends up cached after the scan — a stronger + property than relying on the runtime raise alone. + +If a contributor introduces a real need for one of the forbidden surfaces, +update this section in the same PR. The intent is not "we tried to forbid X" +— it is that X is *structurally absent* from the scanner's parsing path. + Plugins are off by default. `AGENTS_SHIPGATE_ENABLE_PLUGINS=1` enables loading; `--no-plugins` overrides at the CLI level. When loaded, every plugin is enumerated in `report.loaded_plugins`. ### Manifest Schema diff --git a/tests/test_adapter_static_only.py b/tests/test_adapter_static_only.py new file mode 100644 index 0000000..8ee565f --- /dev/null +++ b/tests/test_adapter_static_only.py @@ -0,0 +1,613 @@ +"""Trust-model structural invariant: scanner does not execute or import user code. + +This test enforces the core trust property of agents-shipgate at the source +level: every adapter under ``src/agents_shipgate/inputs/`` must parse user +files with ``ast.parse``, ``yaml.safe_load``, or ``json.loads`` only — never +through ``exec`` / ``eval`` / ``__import__`` / ``compile`` builtins, and +never via dynamic-import or process-execution surfaces. + +What the scanner catches: + +- Bare-name calls to ``exec`` / ``eval`` / ``__import__`` / ``compile``. +- Attribute calls to a fixed forbidden set + (``importlib.import_module``, ``importlib.util.spec_from_file_location``, + ``importlib.util.module_from_spec``, ``importlib.machinery.SourceFileLoader``, + ``runpy.run_path``, ``runpy.run_module``, ``subprocess.{run,call,Popen, + check_call,check_output}``, ``os.system``, ``os.popen``). +- The full ``os.exec*`` / ``os.spawn*`` / ``os.posix_spawn*`` families + via prefix matching, so a future Python addition like ``os.execlpe`` is + caught without an enumeration update. +- Module imports from a forbidden set (``runpy``, ``subprocess``, + ``importlib``, ``importlib.util``, ``importlib.machinery``, ``builtins``) + — including ``import X as Y``, ``import X.child``, and + ``from X.child import ...`` forms. ``importlib.metadata`` is the one + explicit exception because it reads installed-package metadata, not user + workspace code. +- **Aliased re-exports.** A two-pass walk first builds alias maps from + ``import`` and ``from-import`` statements, then resolves attribute chains + and bare-name calls back to their canonical dotted path before checking + the forbidden sets. ``import os as oo; oo.system(...)``, + ``from os import system as sh; sh(...)``, and + ``from os import execve as runner; runner(...)`` all resolve to + ``os.system`` / ``os.execve`` and are flagged. +- **Wildcard from-imports** from any tracked module + (``from os import *``) — they would alias forbidden names into local + scope and defeat the call-site checks. + +What the scanner does NOT catch (acknowledged limitations): + +- Flow-sensitive aliasing: ``ev = eval; ev("1+1")`` — no AST signal at the + call site once the name is reassigned. Requires dataflow. +- Reflective access: ``getattr(os, "system")("ls")``, + ``globals()["eval"]("...")``, ``__builtins__["eval"](...)``. +- ``import os.path`` followed by ``os.system(...)`` — caught (the top-level + ``os`` binding is tracked) — but ``import os.path as p; p.path.system(...)`` + is not, because we don't follow attribute paths through aliased submodules. + +These bypasses are flow-sensitive and require code review to catch. The lint +is the structural floor; code review is the dataflow ceiling. + +Tests live in two layers: + +1. **This file** (``test_adapter_static_only.py``) — AST scan of every + ``inputs/*.py`` source. Catches a regression *before* it ships and runs + in CI in well under a second. +2. **``test_fixture_no_import.py``** — companion live-load tests that drive + each adapter end-to-end against a fixture with a module-level + ``raise RuntimeError(...)`` trap, then assert ``sys.modules`` is unchanged + for the fixture directory. + +The two layers together back the public claim in +`STABILITY.md` § *Trust-model invariants*. +""" + +from __future__ import annotations + +import ast +from pathlib import Path + +import pytest + +REPO_ROOT = Path(__file__).resolve().parent.parent +INPUTS_DIR = REPO_ROOT / "src" / "agents_shipgate" / "inputs" + +# Bare-name forbidden builtins. Catches direct calls like ``exec("...")``. +FORBIDDEN_NAME_CALLS: frozenset[str] = frozenset( + { + "exec", + "eval", + "__import__", + "compile", + } +) + +# Forbidden attribute-chain calls (exact match). Each entry is the +# canonical dotted path AFTER alias resolution. ``os.exec*`` and +# ``os.spawn*`` are intentionally NOT enumerated here — they are caught +# by ``FORBIDDEN_ATTR_CALL_PREFIXES`` below. +FORBIDDEN_ATTR_CALLS_EXACT: frozenset[str] = frozenset( + { + "importlib.import_module", + "importlib.util.spec_from_file_location", + "importlib.util.module_from_spec", + "importlib.machinery.SourceFileLoader", + "runpy.run_path", + "runpy.run_module", + "subprocess.run", + "subprocess.call", + "subprocess.Popen", + "subprocess.check_call", + "subprocess.check_output", + "os.system", + "os.popen", + } +) + +# Prefix-matched forbidden families. Covers every ``os.exec*`` / +# ``os.spawn*`` / ``os.posix_spawn*`` variant the os module +# documents — including current names (``execv``, ``execve``, +# ``execvp``, ``execvpe``, ``execl``, ``execle``, ``execlp``, +# ``execlpe``, ``spawnv``, ``spawnve``, ``spawnvp``, ``spawnvpe``, +# ``spawnl``, ``spawnle``, ``spawnlp``, ``spawnlpe``, +# ``posix_spawn``, ``posix_spawnp``) and any future addition under +# these prefixes. +FORBIDDEN_ATTR_CALL_PREFIXES: tuple[str, ...] = ( + "os.exec", + "os.spawn", + "os.posix_spawn", +) + +# Module imports we forbid outright. ``import X`` / ``import X as Y`` and +# child imports like ``import X.child`` all bind code into the process namespace +# and are rejected. +# ``builtins`` is included because ``builtins.exec`` / ``builtins.eval`` +# would otherwise bypass the bare-name checks. +FORBIDDEN_MODULES: frozenset[str] = frozenset( + { + "runpy", + "subprocess", + "importlib", + "importlib.util", + "importlib.machinery", + "builtins", + } +) + +# Exact module import exceptions that would otherwise be caught by a +# forbidden parent module. Keep this small: each exception needs a trust-model +# reason in STABILITY.md. +ALLOWED_FORBIDDEN_MODULE_IMPORTS: frozenset[str] = frozenset({"importlib.metadata"}) + +# Modules we allow at the import line (legitimate adapter use) but whose +# specific attributes are still subject to the forbidden-chain checks. +# ``os`` is the canonical example: ``os.path`` / ``os.environ`` / +# ``os.getcwd`` are fine, but ``os.system`` / ``os.exec*`` / ``os.spawn*`` +# / ``os.popen`` are out of bounds. We track aliases on these modules so +# ``import os as oo; oo.system(...)`` and +# ``from os import system as sh; sh(...)`` are resolved before checking. +TRACKED_NON_FORBIDDEN_MODULES: frozenset[str] = frozenset({"os"}) + + +def _is_forbidden_chain(chain: str) -> bool: + """True if a canonical dotted call chain hits the forbidden surface.""" + if chain in FORBIDDEN_ATTR_CALLS_EXACT: + return True + return chain.startswith(FORBIDDEN_ATTR_CALL_PREFIXES) + + +def _is_forbidden_module_import(module: str) -> bool: + """True if ``module`` imports a forbidden module or one of its children.""" + if module in ALLOWED_FORBIDDEN_MODULE_IMPORTS: + return False + return any( + module == forbidden or module.startswith(f"{forbidden}.") + for forbidden in FORBIDDEN_MODULES + ) + + +def _resolve_attribute_chain_all( + node: ast.Attribute, module_aliases: dict[str, set[str]] +) -> list[str]: + """Return every canonical dotted chain a ``a.b.c`` Attribute could resolve to. + + Substitutes the root Name through ``module_aliases`` for **all** modules the + local name was ever bound to in the file. If the root was bound to multiple + modules (``import os as p; import pathlib as p``), each binding produces one + candidate chain. The caller flags the call if *any* candidate is forbidden. + + Returns an empty list for chains rooted in something other than a Name + (e.g. ``func().attr``) — those are out of scope for static lint. + + Conservative union-of-bindings: an order-aware single-pass walk would be + more precise, but for trust-model lint we want to flag a chain as soon as + *any* possible binding leads to a forbidden surface. False positives here + force a code review of suspicious aliasing patterns, which is the right + failure mode. + """ + parts: list[str] = [] + current: ast.AST = node + while isinstance(current, ast.Attribute): + parts.append(current.attr) + current = current.value + if not isinstance(current, ast.Name): + return [] + parts.append(current.id) + parts.reverse() + root = parts[0] + if root not in module_aliases: + # Root was never bound by an import in this file. Treat the + # textual root as canonical — catches ``os.system(...)`` written + # without a prior ``import os`` (broken code that the lint should + # still call out structurally). + return [".".join(parts)] + return [ + ".".join(canonical_root.split(".") + parts[1:]) + for canonical_root in module_aliases[root] + ] + + +def _scan_source(source: str, path: Path) -> list[str]: + """Return a list of human-readable violation strings. + + Two passes: + + 1. Walk every ``Import`` / ``ImportFrom`` and accumulate alias maps as + **unions of bindings**. ``module_aliases[local]`` is the set of every + canonical module that local name was ever bound to in the file; + ``name_aliases[local]`` is the set of every ``(module, attr)`` pair + a from-import alias could resolve to. This deliberately ignores + statement order — a later ``import pathlib as os`` does NOT erase + an earlier ``import os`` binding, because the earlier ``os.system(...)`` + call at lines between still resolves through the original ``os``. + 2. Walk every ``Call`` and resolve names through the alias unions. A + call is flagged if *any* possible resolution hits the forbidden + surface. False positives are acceptable for trust-model lint — + suspicious aliasing should be a code-review trigger. + """ + try: + tree = ast.parse(source, filename=str(path)) + except SyntaxError as exc: # pragma: no cover - adapter files compile in CI step + return [f"{path}:{exc.lineno}: failed to parse: {exc.msg}"] + rel = path.relative_to(REPO_ROOT) + violations: list[str] = [] + + # --- Pass 1: imports --------------------------------------------------- + # module_aliases: locally-bound name -> {every canonical module path it + # was ever bound to in this file}. + # ``import os`` -> {"os": {"os"}} + # ``import os as op`` -> {"op": {"os"}} + # ``import os; import pathlib as os`` -> {"os": {"os", "pathlib"}} + # ``import os.path`` -> {"os": {"os"}} + # ``import os.path as p`` -> {"p": {"os.path"}} + module_aliases: dict[str, set[str]] = {} + # name_aliases: locally-bound name -> {every (canonical_module, attr) it + # was ever bound to in this file}. + # ``from os import system`` -> {"system": {("os", "system")}} + # ``from os import system as sh`` -> {"sh": {("os", "system")}} + name_aliases: dict[str, set[tuple[str, str]]] = {} + + for node in ast.walk(tree): + if isinstance(node, ast.Import): + for alias in node.names: + if _is_forbidden_module_import(alias.name): + violations.append( + f"{rel}:{node.lineno}: forbidden import " + f"{alias.name!r} (dynamic Python loading surface)" + ) + if alias.asname: + module_aliases.setdefault(alias.asname, set()).add(alias.name) + else: + # ``import os.path`` binds the top-level ``os`` locally. + top = alias.name.split(".")[0] + module_aliases.setdefault(top, set()).add(top) + elif isinstance(node, ast.ImportFrom): + mod = node.module or "" + if _is_forbidden_module_import(mod): + violations.append( + f"{rel}:{node.lineno}: forbidden from-import " + f"{mod!r} (dynamic Python loading surface)" + ) + # Skip the per-name pass below — the whole module is forbidden. + continue + for alias in node.names: + if alias.name == "*": + if mod in TRACKED_NON_FORBIDDEN_MODULES: + violations.append( + f"{rel}:{node.lineno}: forbidden wildcard " + f"from-import from {mod!r} " + f"(would alias forbidden names into local scope)" + ) + continue + # Flag the from-import line itself when the imported + # attribute resolves to a forbidden surface — gives a + # clearer error than waiting for the call site. + if mod in TRACKED_NON_FORBIDDEN_MODULES: + canonical = f"{mod}.{alias.name}" + if _is_forbidden_chain(canonical): + violations.append( + f"{rel}:{node.lineno}: forbidden from-import " + f"of {canonical!r}" + ) + local = alias.asname or alias.name + name_aliases.setdefault(local, set()).add((mod, alias.name)) + + # --- Pass 2: call sites ------------------------------------------------ + for node in ast.walk(tree): + if not isinstance(node, ast.Call): + continue + func = node.func + if isinstance(func, ast.Name): + # Direct bare-name builtin: e.g. ``exec("...")``. + if func.id in FORBIDDEN_NAME_CALLS: + violations.append( + f"{rel}:{node.lineno}: forbidden builtin call {func.id!r}" + ) + continue + # Aliased ``from X import Y[ as Z]; Z(...)``. Iterate every + # possible (module, attr) binding for this local name. Flag + # the call once if any resolution is forbidden. + if func.id in name_aliases: + for mod, attr in sorted(name_aliases[func.id]): + canonical = f"{mod}.{attr}" + if _is_forbidden_chain(canonical): + via = ( + f" (via from-import alias {func.id!r})" + if func.id != attr + else f" (via from-import of {attr!r})" + ) + violations.append( + f"{rel}:{node.lineno}: forbidden call " + f"{canonical!r}{via}" + ) + break + elif isinstance(func, ast.Attribute): + # Iterate every possible resolution of the attribute chain + # (the root may have been bound to multiple modules in the + # file). Flag the call once if any resolution is forbidden. + for chain in _resolve_attribute_chain_all(func, module_aliases): + if _is_forbidden_chain(chain): + violations.append( + f"{rel}:{node.lineno}: forbidden call {chain!r}" + ) + break + return violations + + +def _adapter_sources() -> list[Path]: + """Every .py file under inputs/ (including __init__.py and helpers).""" + return sorted(INPUTS_DIR.rglob("*.py")) + + +@pytest.mark.parametrize( + "adapter_source", + _adapter_sources(), + ids=lambda p: str(p.relative_to(INPUTS_DIR)), +) +def test_adapter_source_contains_no_forbidden_calls_or_imports( + adapter_source: Path, +) -> None: + """Each adapter source under inputs/ is statically free of code-execution + surfaces. + + A regression here means a contributor added a way for the scanner to + execute or import user code. That breaks the public trust claim in + README and STABILITY.md and must be rejected. If a legitimate need + arises (it should not), update STABILITY.md first and consider whether + the addition belongs in ``inputs/`` at all. + """ + source = adapter_source.read_text(encoding="utf-8") + violations = _scan_source(source, adapter_source) + assert not violations, ( + "Trust-model invariant violation under src/agents_shipgate/inputs/:\n " + + "\n ".join(violations) + + "\n\n" + + "Adapters MUST NOT execute or import user code. They parse user " + + "files with ast.parse / yaml.safe_load / json.loads ONLY. See " + + "STABILITY.md § 'Trust-model invariants' and the companion " + + "tests/test_fixture_no_import.py live-load tests." + ) + + +@pytest.mark.parametrize( + "snippet,expected_substring", + [ + # --- Bare-name forbidden builtins --- + ("exec('print(1)')", "forbidden builtin call 'exec'"), + ("eval('1+1')", "forbidden builtin call 'eval'"), + ("__import__('os')", "forbidden builtin call '__import__'"), + ("compile('x', '', 'exec')", "forbidden builtin call 'compile'"), + # --- Attribute-chain forbidden calls (exact set) --- + ( + "import importlib\nimportlib.import_module('os')", + "forbidden call 'importlib.import_module'", + ), + ( + "import runpy\nrunpy.run_path('/etc/passwd')", + "forbidden call 'runpy.run_path'", + ), + ( + "import subprocess\nsubprocess.run(['ls'])", + "forbidden call 'subprocess.run'", + ), + ( + "import os\nos.system('ls')", + "forbidden call 'os.system'", + ), + ( + "import os\nos.popen('ls')", + "forbidden call 'os.popen'", + ), + # --- os.exec* / os.spawn* / os.posix_spawn* prefix families --- + ( + "import os\nos.execv('/bin/sh', ['sh'])", + "forbidden call 'os.execv'", + ), + ( + "import os\nos.execve('/bin/sh', ['sh'], {})", + "forbidden call 'os.execve'", + ), + ( + "import os\nos.execvpe('sh', ['sh'], {})", + "forbidden call 'os.execvpe'", + ), + ( + "import os\nos.execlpe('sh', 'sh', {})", + "forbidden call 'os.execlpe'", + ), + ( + "import os\nos.spawnv(0, '/bin/sh', ['sh'])", + "forbidden call 'os.spawnv'", + ), + ( + "import os\nos.spawnvp(0, 'sh', ['sh'])", + "forbidden call 'os.spawnvp'", + ), + ( + "import os\nos.spawnvpe(0, 'sh', ['sh'], {})", + "forbidden call 'os.spawnvpe'", + ), + ( + "import os\nos.posix_spawn('/bin/sh', ['sh'], {})", + "forbidden call 'os.posix_spawn'", + ), + ( + "import os\nos.posix_spawnp('sh', ['sh'], {})", + "forbidden call 'os.posix_spawnp'", + ), + # --- Module-alias bypass: ``import X as Y; Y.forbidden(...)`` --- + ( + "import os as operating_system\noperating_system.system('ls')", + "forbidden call 'os.system'", + ), + ( + "import os as o\no.execv('/bin/sh', ['sh'])", + "forbidden call 'os.execv'", + ), + ( + "import os as o\no.posix_spawn('sh', ['sh'], {})", + "forbidden call 'os.posix_spawn'", + ), + # --- From-import alias bypass --- + ( + "from os import system\nsystem('ls')", + "forbidden from-import of 'os.system'", + ), + ( + "from os import system as sh\nsh('ls')", + "forbidden from-import of 'os.system'", + ), + ( + "from os import execv as run_binary\nrun_binary('/bin/sh', ['sh'])", + "forbidden from-import of 'os.execv'", + ), + ( + "from os import execve\nexecve('/bin/sh', ['sh'], {})", + "forbidden from-import of 'os.execve'", + ), + # --- Wildcard from-import from tracked module --- + ( + "from os import *", + "forbidden wildcard from-import from 'os'", + ), + # --- Order-of-import rebind bypass --- + # The reviewer's case: a later ``import pathlib as os`` must not + # erase the earlier ``import os`` binding for purposes of the + # call-site check at the lines between. Union-of-bindings means + # ``os.system(...)`` resolves through *both* ``os`` and ``pathlib`` + # and ``os.system`` is forbidden regardless of statement order. + ( + "import os\nos.system('echo hi')\nimport pathlib as os\n", + "forbidden call 'os.system'", + ), + ( + "import os as runner\nrunner.execve('/bin/sh', ['sh'])\n" + "import pathlib as runner\n", + "forbidden call 'os.execve'", + ), + ( + "from os import system\nsystem('ls')\n" + "from pathlib import system\n", + "forbidden from-import of 'os.system'", + ), + # Even when the FORBIDDEN binding comes *after* the safe one, + # the union catches it. + ( + "import pathlib as os\nos.system('echo hi')\nimport os\n", + "forbidden call 'os.system'", + ), + # --- ``builtins`` module surfaces --- + ("import builtins", "forbidden import 'builtins'"), + ("import builtins as b", "forbidden import 'builtins'"), + ("from builtins import eval", "forbidden from-import 'builtins'"), + ( + "from builtins import eval as e", + "forbidden from-import 'builtins'", + ), + # --- Existing imports-alone checks for forbidden modules --- + ("import runpy", "forbidden import 'runpy'"), + ("from runpy import run_path", "forbidden from-import 'runpy'"), + ("import subprocess", "forbidden import 'subprocess'"), + ("import importlib.util", "forbidden import 'importlib.util'"), + ("import subprocess.run", "forbidden import 'subprocess.run'"), + ("import runpy.extra", "forbidden import 'runpy.extra'"), + ( + "from subprocess.foo import bar", + "forbidden from-import 'subprocess.foo'", + ), + ( + "from importlib.util.extra import loader", + "forbidden from-import 'importlib.util.extra'", + ), + ], + ids=lambda x: x if isinstance(x, str) and len(x) < 40 else "case", +) +def test_lint_scanner_catches_known_violation_shapes( + snippet: str, expected_substring: str +) -> None: + """The scanner itself has fingers: each forbidden shape must be detected. + + This is the negative-control test for the lint. Without it, a refactor + that broke the scanner's NodeVisitor logic could silently make the + invariant tests pass vacuously. Cases below cover every bypass pattern + documented in the module docstring (bare names, attribute chains, prefix + families, module aliases, from-import aliases, wildcard imports, and + the ``builtins`` surface). + """ + fake_path = INPUTS_DIR / "__synthetic__.py" + violations = _scan_source(snippet, fake_path) + assert violations, ( + f"Scanner failed to flag a forbidden shape:\n snippet: {snippet!r}\n" + f" expected substring: {expected_substring!r}" + ) + assert any(expected_substring in v for v in violations), ( + f"Scanner caught a violation but not the expected one:\n" + f" snippet: {snippet!r}\n violations: {violations!r}\n" + f" expected substring: {expected_substring!r}" + ) + + +def test_lint_scanner_does_not_false_positive_on_safe_shapes() -> None: + """Common safe patterns must not trip the scanner. + + Documents the boundary so a future "be stricter" patch knows what to + avoid breaking. Anything an adapter under inputs/ legitimately does + with ``re``, ``ast``, ``os.path`` / ``os.environ`` / ``os.getcwd``, + ``yaml``, or ``json`` must remain green. + """ + safe = ( + # re.compile is a method call, not the builtin. + "import re\nPATTERN = re.compile(r'foo')\n" + # ast.parse is the canonical safe parsing path. + "import ast\ntree = ast.parse('1+1')\n" + # Legitimate os surface used by real adapters. + "import os\nROOT = os.environ.get('FOO', '')\n" + "import os\nABS = os.path.join('a', 'b')\n" + "import os.path\nABS = os.path.abspath('x')\n" + "from os import getcwd\ncwd = getcwd()\n" + "from os import environ\nval = environ.get('FOO')\n" + # yaml.safe_load + json.loads are the declared declarative paths. + "import yaml\nimport json\nx = yaml.safe_load('a: 1')\ny = json.loads('{}')\n" + # importlib.metadata is explicitly allowed for installed-package metadata. + "import importlib.metadata\n" + "from importlib.metadata import version\npkg_version = version('agents-shipgate')\n" + ) + fake_path = INPUTS_DIR / "__synthetic_safe__.py" + violations = _scan_source(safe, fake_path) + assert not violations, ( + "Safe parsing patterns must not be flagged. Unexpected violations:\n " + + "\n ".join(sorted(violations)) + ) + + +def test_invariant_lint_covers_every_adapter_module() -> None: + """Sanity check: the parametrized scan reaches every known adapter file. + + Catches the case where someone reorganizes inputs/ into a subpackage + and the rglob silently stops finding the new home. + """ + scanned_names = {p.name for p in _adapter_sources()} + expected_adapter_files = { + "anthropic_api.py", + "codex_plugin.py", + "common.py", + "crewai.py", + "google_adk.py", + "langchain.py", + "mcp.py", + "n8n.py", + "openai_api.py", + "openai_sdk_static.py", + "openapi.py", + "policy_packs.py", + "protocol.py", + "python_static.py", + "traces.py", + "validation.py", + "_python_framework.py", + "__init__.py", + } + missing = expected_adapter_files - scanned_names + assert not missing, ( + f"Expected adapter files not found under {INPUTS_DIR}: {sorted(missing)}. " + f"If inputs/ was reorganized, update the expected set above." + ) diff --git a/tests/test_fixture_no_import.py b/tests/test_fixture_no_import.py index dbe2a07..fa93b82 100644 --- a/tests/test_fixture_no_import.py +++ b/tests/test_fixture_no_import.py @@ -1,62 +1,791 @@ -import runpy +"""Per-adapter trust-model live-load tests + sample-protection assertion. + +Companion to ``tests/test_adapter_static_only.py``: + +- That file enforces the no-import invariant *statically* by scanning + every ``src/agents_shipgate/inputs/*.py`` source for forbidden builtins + (``exec``/``eval``/``__import__``/``compile``) and dynamic-import + surfaces (``importlib``/``runpy``/``subprocess``). +- This file enforces the same invariant *at runtime* by driving each + adapter end-to-end against a fixture that raises ``RuntimeError`` at + module load and watching for ``sys.modules`` pollution from the + fixture directory. + +Two fixture shapes: + +1. **Python-parsing adapters** (LangChain, CrewAI, OpenAI Agents SDK, + Google ADK) take a Python source as their primary input. The fixture + *is* a Python file with a module-level ``raise RuntimeError(...)``; + if the adapter ever ``import``s it, the test fails. + +2. **Declarative adapters** (MCP, OpenAPI, Anthropic, OpenAI API, n8n, + Codex plugin) ingest JSON/YAML/Markdown/plugin manifests. The fixture + places a sibling ``trap.py`` with the same ``raise`` at module top. + The adapter must never touch that file even though it sits in the + workspace. + +The strongest cross-cutting assertion is the ``sys.modules`` snapshot: +no module whose ``__file__`` resolves under the workspace root may +appear in ``sys.modules`` after the scan. ``ast.parse`` / +``yaml.safe_load`` / ``json.loads`` must be the only ingestion paths. + +If you add a new adapter, add a test here. +""" + +from __future__ import annotations + import sys -import types from pathlib import Path +from textwrap import dedent import pytest +from agents_shipgate.cli.scan import run_scan + +REPO_ROOT = Path(__file__).resolve().parent.parent +TRAP = ( + 'raise RuntimeError("agents-shipgate must parse this file without importing")' +) + + +# --- Helpers --------------------------------------------------------------- + + +def _user_modules_under(root: Path) -> set[str]: + """Set of ``sys.modules`` keys whose ``__file__`` resolves under ``root``. + + Filters by resolved path so a fixture inside ``tmp_path`` is not + confused with the agents-shipgate source tree or stdlib. + """ + resolved_root = root.resolve() + out: set[str] = set() + for name, mod in list(sys.modules.items()): + file_attr = getattr(mod, "__file__", None) + if not file_attr: + continue + try: + mod_path = Path(file_attr).resolve() + except (ValueError, OSError): + continue + try: + if mod_path.is_relative_to(resolved_root): + out.add(name) + except ValueError: + # Different drive on Windows; cannot be under the same root. + continue + return out + + +def _run_and_assert_no_import(workspace: Path) -> object: + """Run a scan rooted at ``workspace`` and enforce the no-import invariant. + + Returns the parsed report. The universal invariant is no module + under the workspace ending up in ``sys.modules``. Callers add + adapter-specific "adapter actually did work" assertions on the + returned report (most adapters populate ``tool_inventory``; + framework adapters like n8n populate ``frameworks[]``). + """ + before = _user_modules_under(workspace) + report, _exit_code = run_scan( + config_path=workspace / "shipgate.yaml", + output_dir=workspace / "_reports", + formats=["json"], + ci_mode="advisory", + ) + after = _user_modules_under(workspace) + leaked = sorted(after - before) + assert not leaked, ( + f"Adapter imported user modules from {workspace}:\n " + + "\n ".join(leaked) + + "\n\nTrust-model invariant violation. Adapters must parse user " + + "files with ast.parse / yaml.safe_load / json.loads only. See " + + "STABILITY.md § 'Trust-model invariants' and " + + "tests/test_adapter_static_only.py for the structural check." + ) + return report + + +def _assert_did_work_tool_inventory(report: object, *, minimum: int = 1) -> None: + """For adapters that surface tools into ``report.tool_inventory``.""" + count = len(report.tool_inventory) # type: ignore[attr-defined] + assert count >= minimum, ( + f"Adapter did not extract enough tools into tool_inventory " + f"(got {count}, expected ≥ {minimum}). If the adapter changed its " + "scope, update the fixture or the assertion." + ) + + +def _assert_did_work_framework( + report: object, framework: str, *, key: str, minimum: int = 1 +) -> None: + """For framework adapters that surface their work into ``report.frameworks``.""" + summary = report.frameworks.get(framework) # type: ignore[attr-defined] + assert summary is not None, ( + f"Adapter did not populate report.frameworks[{framework!r}]. " + f"frameworks keys: {sorted(report.frameworks)}" # type: ignore[attr-defined] + ) + actual = summary.get(key, 0) + assert actual >= minimum, ( + f"report.frameworks[{framework!r}][{key!r}] = {actual}, " + f"expected ≥ {minimum}. If the adapter changed its scope, update " + "the fixture or the assertion." + ) + + +def _write(path: Path, content: str) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(dedent(content).lstrip("\n"), encoding="utf-8") + + +# --- Python-parsing adapters ----------------------------------------------- + + +def test_langchain_adapter_does_not_import_user_module(tmp_path: Path) -> None: + """LangChain adapter must parse ``agent.py`` via ast.parse only. + + The fixture's module-level ``raise`` would fire on any real import; + we additionally watch ``sys.modules`` for any pollution. + """ + workspace = tmp_path / "lc" + _write( + workspace / "agent.py", + f""" + from langchain.agents import create_agent + from langchain.tools import tool + from langchain_core.tools import StructuredTool + from pydantic import BaseModel, Field + + {TRAP} + + + class LookupInput(BaseModel): + case_id: str = Field(..., description="Support case identifier.") + + + @tool(args_schema=LookupInput) + def lookup_case(case_id: str) -> dict: + \"\"\"Look up read-only case metadata.\"\"\" + return {{"case_id": case_id}} + + + def summarize_case(case_id: str) -> dict: + \"\"\"Summarize read-only case metadata.\"\"\" + return {{"case_id": case_id, "summary": ""}} + + + summary_tool = StructuredTool.from_function( + func=summarize_case, + name="summarize_case", + description="Summarize read-only case metadata.", + ) + + agent = create_agent(model=None, tools=[lookup_case, summary_tool]) + """, + ) + _write( + workspace / "shipgate.yaml", + """ + version: "0.1" + project: + name: lc-no-import + agent: + name: lc-agent + declared_purpose: + - read case metadata + environment: + target: local + tool_sources: + - id: lc + type: langchain + path: agent.py + """, + ) + report = _run_and_assert_no_import(workspace) + _assert_did_work_tool_inventory(report, minimum=2) + + +def test_crewai_adapter_does_not_import_user_module(tmp_path: Path) -> None: + """CrewAI adapter must parse ``crew.py`` via ast.parse only.""" + workspace = tmp_path / "crew" + _write( + workspace / "crew.py", + f""" + from crewai import Agent, Crew + from crewai.tools import BaseTool, tool + from crewai_tools import FileReadTool + from pydantic import BaseModel, Field + + {TRAP} + + + class LookupInput(BaseModel): + case_id: str = Field(..., description="Support case identifier.") + + + @tool("summarize_case") + def summarize_case(case_id: str) -> dict: + \"\"\"Summarize read-only case metadata.\"\"\" + return {{"case_id": case_id}} + + + class LookupTool(BaseTool): + name: str = "lookup_case" + description: str = "Look up read-only case metadata." + args_schema = LookupInput + + def _run(self, case_id: str) -> dict: + return {{"case_id": case_id}} + + + file_tool = FileReadTool() + lookup_tool = LookupTool() + + researcher = Agent( + role="reader", + goal="read case metadata", + backstory="reads cases", + tools=[summarize_case, lookup_tool, file_tool], + ) + crew = Crew(agents=[researcher]) + """, + ) + _write( + workspace / "shipgate.yaml", + """ + version: "0.1" + project: + name: crewai-no-import + agent: + name: crew-agent + declared_purpose: + - read case metadata + environment: + target: local + tool_sources: + - id: crewai + type: crewai + path: crew.py + """, + ) + report = _run_and_assert_no_import(workspace) + _assert_did_work_tool_inventory(report, minimum=2) + + +def test_openai_agents_sdk_adapter_does_not_import_user_module( + tmp_path: Path, +) -> None: + """OpenAI Agents SDK static loader must parse via ast.parse only.""" + workspace = tmp_path / "openai_sdk" + _write( + workspace / "agent.py", + f""" + from agents import Agent, function_tool + + {TRAP} + + + @function_tool + def send_email_preview(recipient: str, subject: str, body: str) -> str: + \"\"\"Render a customer email preview without sending it.\"\"\" + return f"To: {{recipient}}" + + + refund_agent = Agent( + name="refund-assistant", + instructions="Answer refund policy questions.", + tools=[send_email_preview], + ) + """, + ) + _write( + workspace / "shipgate.yaml", + """ + version: "0.1" + project: + name: openai-sdk-no-import + agent: + name: refund-assistant + sdk: + type: openai-agents + language: python + entrypoint: agent.py + object: refund_agent + static_extract: true + deep_import: false + declared_purpose: + - prepare refund requests for human review + environment: + target: local + tool_sources: + - id: sdk + type: openai_agents_sdk + path: agent.py + mode: static + """, + ) + report = _run_and_assert_no_import(workspace) + _assert_did_work_tool_inventory(report, minimum=1) + + +def test_google_adk_adapter_does_not_import_user_module(tmp_path: Path) -> None: + """Google ADK static extractor must parse via ast.parse only. + + A separate end-to-end ADK test lives in tests/test_google_adk.py; + this one adds the ``sys.modules`` snapshot assertion uniformly with + the other adapters so a future regression in either dimension is + caught here. + """ + workspace = tmp_path / "adk" + _write( + workspace / "agent.py", + f""" + from google.adk.agents import LlmAgent + from google.adk.tools import FunctionTool + + {TRAP} + + + def lookup(case_id: str) -> dict: + \"\"\"Look up read-only case metadata.\"\"\" + return {{"case_id": case_id}} + + + lookup_tool = FunctionTool(func=lookup) + + root_agent = LlmAgent( + name="root_agent", + instruction="Read case metadata.", + tools=[lookup_tool], + ) + """, + ) + _write( + workspace / "shipgate.yaml", + """ + version: "0.1" + project: + name: adk-no-import + agent: + name: root-agent + declared_purpose: + - read case metadata + environment: + target: local + tool_sources: + - id: adk + type: google_adk + path: agent.py + """, + ) + report = _run_and_assert_no_import(workspace) + _assert_did_work_framework( + report, "google_adk", key="function_tool_count", minimum=1 + ) + + +# --- Declarative adapters: sibling-trap pattern ---------------------------- + + +def _write_sibling_trap(workspace: Path) -> None: + """Plant a load-trap Python file in the workspace next to the manifest. + + A declarative adapter must never touch ``trap.py`` — it has no + business reading Python sources. We assert that via the + ``sys.modules`` snapshot in the helper. + """ + _write(workspace / "trap.py", TRAP + "\n") + + +def _write_codex_plugin_package(root: Path) -> None: + """Write a minimal Codex plugin package with executable-looking stubs. + + The scanner may parse these JSON/Markdown files, but it must never execute + the hook command or launch the MCP server command. + """ + _write( + root / ".codex-plugin/plugin.json", + """ + { + "name": "browserish", + "version": "1.0.0", + "description": "Review browser automation from static Codex plugin files.", + "skills": "./skills/", + "mcpServers": "./.mcp.json", + "hooks": "./hooks.json" + } + """, + ) + _write( + root / "skills/browser/SKILL.md", + """ + --- + name: browser + description: Use browser automation for local UI inspection. + --- + + # Browser + """, + ) + _write( + root / ".mcp.json", + """ + { + "mcpServers": { + "browser": { + "command": "python", + "args": ["-c", "raise SystemExit('must not execute')"] + } + } + } + """, + ) + _write( + root / "hooks.json", + """ + { + "preRun": { + "command": "touch should-never-run" + } + } + """, + ) + + +def test_mcp_adapter_does_not_import_sibling_python(tmp_path: Path) -> None: + """MCP adapter loads JSON only — sibling trap.py must remain untouched.""" + workspace = tmp_path / "mcp" + _write( + workspace / "mcp-tools.json", + """ + { + "tools": [ + { + "name": "support.search_kb", + "description": "Search support knowledge base.", + "annotations": {"readOnlyHint": true} + } + ] + } + """, + ) + _write_sibling_trap(workspace) + _write( + workspace / "shipgate.yaml", + """ + version: "0.1" + project: + name: mcp-no-import + agent: + name: mcp-agent + declared_purpose: + - search support knowledge base + environment: + target: local + tool_sources: + - id: mcp + type: mcp + path: mcp-tools.json + """, + ) + report = _run_and_assert_no_import(workspace) + _assert_did_work_tool_inventory(report, minimum=1) + + +def test_codex_plugin_adapter_does_not_import_sibling_python( + tmp_path: Path, +) -> None: + """Codex plugin adapter loads package files only. + + The sibling trap.py stays untouched. + """ + workspace = tmp_path / "codex_plugin" + _write_codex_plugin_package(workspace / "plugins/browserish") + _write_sibling_trap(workspace) + marker = workspace / "should-never-run" + _write( + workspace / "shipgate.yaml", + """ + version: "0.1" + project: + name: codex-plugin-no-import + agent: + name: codex-plugin-agent + declared_purpose: + - review a plugin package + environment: + target: local + tool_sources: + - id: browserish + type: codex_plugin + mode: package + path: plugins/browserish + """, + ) + report = _run_and_assert_no_import(workspace) + assert marker.exists() is False, "Codex plugin hook command must not run." + assert report.codex_plugin_surface is not None + assert report.codex_plugin_surface.plugin_count == 1 + assert report.codex_plugin_surface.skill_count == 1 + assert report.codex_plugin_surface.mcp_server_stub_count == 1 + assert report.codex_plugin_surface.hook_stub_count == 1 + + +def test_openapi_adapter_does_not_import_sibling_python(tmp_path: Path) -> None: + """OpenAPI adapter loads YAML only — sibling trap.py must remain untouched.""" + workspace = tmp_path / "openapi" + _write( + workspace / "support.openapi.yaml", + """ + openapi: 3.1.0 + info: + title: Support + version: "1.0" + paths: + /records: + get: + operationId: support.lookup_record + responses: + "200": + description: ok + """, + ) + _write_sibling_trap(workspace) + _write( + workspace / "shipgate.yaml", + """ + version: "0.1" + project: + name: openapi-no-import + agent: + name: openapi-agent + declared_purpose: + - look up support records + environment: + target: local + tool_sources: + - id: openapi + type: openapi + path: support.openapi.yaml + """, + ) + report = _run_and_assert_no_import(workspace) + _assert_did_work_tool_inventory(report, minimum=1) + + +def test_anthropic_adapter_does_not_import_sibling_python( + tmp_path: Path, +) -> None: + """Anthropic adapter loads JSON tools + Markdown prompts only.""" + workspace = tmp_path / "anthropic" + _write( + workspace / "tools/anthropic-tools.json", + """ + { + "tools": [ + { + "name": "get_help_article", + "description": "Look up a help center article.", + "input_schema": { + "type": "object", + "properties": { + "article_id": {"type": "string"} + }, + "required": ["article_id"] + } + } + ] + } + """, + ) + _write( + workspace / "prompts/support.md", + """ + # Support assistant -def test_simple_langchain_fixture_fails_if_imported(monkeypatch): - _install_langchain_stubs(monkeypatch) + Look up help center articles only. Do not change customer records. + """, + ) + _write_sibling_trap(workspace) + _write( + workspace / "shipgate.yaml", + """ + version: "0.1" + project: + name: anthropic-no-import + agent: + name: anthropic-agent + prohibited_actions: + - change customer records + environment: + target: local + anthropic: + prompt_files: + - prompts/support.md + tools: + - path: tools/anthropic-tools.json + """, + ) + report = _run_and_assert_no_import(workspace) + _assert_did_work_tool_inventory(report, minimum=1) - with pytest.raises(RuntimeError, match="must parse this file without importing"): - runpy.run_path(str(Path("samples/simple_langchain_agent/agent.py"))) +def test_n8n_adapter_does_not_import_sibling_python(tmp_path: Path) -> None: + """n8n adapter loads workflow JSON only — sibling trap.py must remain untouched.""" + workspace = tmp_path / "n8n" + _write( + workspace / "workflow.json", + """ + { + "id": "workflow-1", + "name": "Support assistant", + "nodes": [ + { + "id": "webhook", + "name": "Webhook", + "type": "n8n-nodes-base.webhook", + "parameters": {"path": "support", "httpMethod": "POST"} + }, + { + "id": "http-tool", + "name": "Lookup Article", + "type": "n8n-nodes-langchain.toolHttpRequest", + "parameters": { + "url": "https://help.example.com/api/articles/{{ $json.article_id }}", + "method": "GET", + "toolDescription": "Look up a help center article." + } + } + ], + "connections": { + "Webhook": { + "main": [[{"node": "Lookup Article", "type": "main", "index": 0}]] + } + } + } + """, + ) + _write_sibling_trap(workspace) + _write( + workspace / "shipgate.yaml", + """ + version: "0.1" + project: + name: n8n-no-import + agent: + name: n8n-agent + declared_purpose: + - look up help center articles + environment: + target: local + n8n: + workflows: + - path: workflow.json + """, + ) + report = _run_and_assert_no_import(workspace) + _assert_did_work_framework(report, "n8n", key="workflow_count", minimum=1) -def test_simple_crewai_fixture_fails_if_imported(monkeypatch): - _install_crewai_stubs(monkeypatch) - with pytest.raises(RuntimeError, match="must parse this file without importing"): - runpy.run_path(str(Path("samples/simple_crewai_agent/crew.py"))) +def test_openai_api_adapter_does_not_import_sibling_python( + tmp_path: Path, +) -> None: + """OpenAI Chat Completions API adapter loads JSON + Markdown only.""" + workspace = tmp_path / "openai_api" + _write( + workspace / "tools/openai-tools.json", + """ + { + "tools": [ + { + "type": "function", + "function": { + "name": "get_help_article", + "description": "Look up a help center article.", + "parameters": { + "type": "object", + "properties": { + "article_id": {"type": "string"} + }, + "required": ["article_id"] + } + } + } + ] + } + """, + ) + _write( + workspace / "prompts/support.md", + """ + # Support assistant + Look up help center articles only. + """, + ) + _write_sibling_trap(workspace) + _write( + workspace / "shipgate.yaml", + """ + version: "0.1" + project: + name: openai-api-no-import + agent: + name: openai-api-agent + prohibited_actions: + - change customer records + environment: + target: local + openai_api: + prompt_files: + - prompts/support.md + tools: + - path: tools/openai-tools.json + """, + ) + report = _run_and_assert_no_import(workspace) + _assert_did_work_tool_inventory(report, minimum=1) -def _install_langchain_stubs(monkeypatch: pytest.MonkeyPatch) -> None: - langchain = types.ModuleType("langchain") - langchain.__path__ = [] - langchain_agents = types.ModuleType("langchain.agents") - langchain_agents.create_agent = lambda *args, **kwargs: None - langchain_tools = types.ModuleType("langchain.tools") - langchain_tools.tool = lambda *args, **kwargs: (lambda fn: fn) - langchain_core = types.ModuleType("langchain_core") - langchain_core.__path__ = [] - langchain_core_tools = types.ModuleType("langchain_core.tools") - class StructuredTool: - @classmethod - def from_function(cls, *args, **kwargs): - return cls() +# --- Sample-protection assertion ------------------------------------------- - langchain_core_tools.StructuredTool = StructuredTool - monkeypatch.setitem(sys.modules, "langchain", langchain) - monkeypatch.setitem(sys.modules, "langchain.agents", langchain_agents) - monkeypatch.setitem(sys.modules, "langchain.tools", langchain_tools) - monkeypatch.setitem(sys.modules, "langchain_core", langchain_core) - monkeypatch.setitem(sys.modules, "langchain_core.tools", langchain_core_tools) +# Each sample under samples/ that is a Python load-trap demonstrator must +# keep its module-level ``raise`` intact. Without it, the sample stops +# being a faithful no-import demonstrator for adopters reading the repo. +PROTECTED_SAMPLES = ( + REPO_ROOT / "samples/simple_langchain_agent/agent.py", + REPO_ROOT / "samples/simple_crewai_agent/crew.py", +) -def _install_crewai_stubs(monkeypatch: pytest.MonkeyPatch) -> None: - crewai = types.ModuleType("crewai") - crewai.Agent = lambda *args, **kwargs: None - crewai.Crew = lambda *args, **kwargs: None - crewai_tools_module = types.ModuleType("crewai.tools") - crewai_tools_module.tool = lambda *args, **kwargs: (lambda fn: fn) - class BaseTool: - pass +@pytest.mark.parametrize( + "sample_path", + PROTECTED_SAMPLES, + ids=lambda p: str(p.relative_to(REPO_ROOT)), +) +def test_published_sample_keeps_module_level_raise(sample_path: Path) -> None: + """Published samples must keep their module-level RuntimeError trap. - crewai_tools_module.BaseTool = BaseTool - prebuilt_tools = types.ModuleType("crewai_tools") - prebuilt_tools.FileReadTool = type("FileReadTool", (), {}) - monkeypatch.setitem(sys.modules, "crewai", crewai) - monkeypatch.setitem(sys.modules, "crewai.tools", crewai_tools_module) - monkeypatch.setitem(sys.modules, "crewai_tools", prebuilt_tools) + These files are referenced by quickstart docs and ``fixture run`` as + proof points that the scanner does not import user code. Stripping + the ``raise`` line silently turns them into innocuous examples; + block that at the contract level. + """ + text = sample_path.read_text(encoding="utf-8") + assert "raise RuntimeError(" in text, ( + f"{sample_path.relative_to(REPO_ROOT)} no longer raises at module load. " + "Restore the load-trap so this sample remains a faithful " + "no-import demonstrator (see STABILITY.md § 'Trust-model invariants')." + ) + # And the trap must be at module level — not inside an `if False:` + # block or a function body. Quick check: line must not be indented. + raise_lines = [ + ln for ln in text.splitlines() if "raise RuntimeError(" in ln + ] + assert any( + ln.startswith("raise RuntimeError(") for ln in raise_lines + ), ( + f"{sample_path.relative_to(REPO_ROOT)} has a RuntimeError but not " + "at module level (every match is indented). The trap must fire on " + "any unguarded import." + )