Tier 1 — Adapter restrictions and containment enforcement¶
Status: implementation-ready specification; implementation not authorized.
Activation gate: must exist before any tier1/adapters/ package is
created (Milestone 9 groundwork).
Related: sealed_executor.md,
capability_adapter_contract.md,
tests/tier1/test_isolation.py (existing pattern this spec extends).
Implementation note (executor build, Phase 3): I3 below originally
read "adapters have no legitimate reason to call anything shaped like a
transport verb at all, including GET (the executor performs all reads on
the adapter's behalf)." That sentence is superseded —
sealed_executor.md's Implementation note records why the executor
cannot itself dispatch a capability-specific GET without a registry this
architecture's Non-goals rule out: CapabilityAdapter gained a
read_target(read_client, natural_identity) method, which is the
adapter's read implementation, called through the executor-supplied
read_client, by design. I3's forbidden-call-name set is unaffected by
this: it still forbids a call literally named .get(...), and remains
correct as a guard forcing an adapter to call read_client's actual,
specifically-named capability method (e.g. get_firewall_aliases())
rather than something generically named — an AST exact-name check was
never going to catch every unsafe read pattern; only that one generic
escape hatch. See capability_adapter_contract.md's Implementation note
for the same correction applied to I2 there.
Purpose¶
sealed_executor.md and capability_adapter_contract.md describe what an
adapter should do. This document specifies how every restriction is
mechanically enforced — via the same AST-based static test approach
the architecture review independently verified already works for
pfsense_mcp.tier1's isolation from production — so that "the adapter
shouldn't do X" is a CI-enforced fact, not a code-review hope.
Security goals¶
- G1: Every forbidden adapter behavior listed in
sealed_executor.md's "Forbidden adapter behavior" section has a corresponding automated test that fails if violated. - G2: A new adapter cannot be merged without these tests passing, the same
way
tests/tier1/test_isolation.pyalready gates the existingtier1/production boundary inmake validate.
Invariants¶
- I1: All adapter modules live under
src/pfsense_mcp/tier1/adapters/(new package, does not exist yet) — a single, predictable location the AST scan can target exhaustively, mirroring howtest_isolation.py::test_tier1_domain_has_no_transport_or_tool_ registration_dependencyalready scans all oftier1/*.py. - I2: The forbidden-import set for
tier1/adapters/*.pyis a strict superset of the forbidden-import set already enforced fortier1/*.py— it additionally forbids importingpfsense_mcp.tier1.executor,pfsense_mcp.tier1.store, andpfsense_mcp.tier1.confirmation/reconciliation(an adapter has no legitimate reason to touch contract storage, confirmation, or reconciliation directly — only the executor does). - I3: The forbidden-call-name set for adapters is the existing
{"delete", "patch", "post", "put", "request", "tool"}plus"send"and"get"— the existing set omitsget/sendbecause production READ code legitimately calls GET. Adapters are the one place intier1/that does legitimately call a GET-shaped method (viaread_target(), see this document's Implementation note above), but never one literally named.get(...): the executor-suppliedread_clientisPfSenseClient, whose methods are named per capability (e.g.get_firewall_aliases()), so"get"stays in the forbidden set as a guard against an adapter calling something suspiciously generic instead of its own capability's named method. - I4: Every adapter's public callable surface (the
CapabilityAdapterProtocol methods) must be individually reachable and testable without constructing an executor, a store, or a transport — checked by a test-authoring convention (adapter tests import only the adapter module and plain data), not by AST, but stated here as a hard review requirement.
Trust boundaries¶
Identical to sealed_executor.md. This document is entirely about
enforcement mechanism, not new boundaries.
State ownership¶
tests/tier1/test_adapter_isolation.py (new, sibling to the existing
test_isolation.py) owns these checks. It does not replace
test_isolation.py — both run; test_isolation.py continues to guard
tier1/*.py (non-adapter modules), and the new file guards
tier1/adapters/*.py specifically, with the stricter rule set from I2/I3.
Interfaces¶
# tests/tier1/test_adapter_isolation.py (new; not created yet)
ADAPTER_FORBIDDEN_IMPORT_ROOTS = {
"pfsense_mcp.rest_api_client",
"pfsense_mcp.transport",
"pfsense_mcp.tools",
"pfsense_mcp.write_api_client",
"pfsense_mcp.tier1.executor",
"pfsense_mcp.tier1.store",
"pfsense_mcp.tier1.confirmation",
"pfsense_mcp.tier1.reconciliation",
}
ADAPTER_FORBIDDEN_CALLS = {
"delete",
"patch",
"post",
"put",
"request",
"tool",
"send",
"get",
}
def test_no_adapter_imports_forbidden_modules(): ...
def test_no_adapter_calls_forbidden_names(): ...
def test_every_adapter_model_forbids_extra_fields(): ...
def test_every_adapter_method_is_static_or_free_function(): ...
The last two are new checks beyond what test_isolation.py currently
does for non-adapter tier1 code, specific to the adapter contract's
extra guarantees (I1/I2 in capability_adapter_contract.md):
test_every_adapter_model_forbids_extra_fields: AST-walk everyclassintier1/adapters/*.pythat subclasses a Pydantic base model and assert itsmodel_configincludesextra="forbid"(string match on the AST literal, same style as the existing checks — no need to actually import/instantiate Pydantic for this check).test_every_adapter_method_is_static_or_free_function: AST-walk every method defined on a class intier1/adapters/*.pythat matches one of theCapabilityAdapterProtocol method names and assert it is decorated@staticmethod(or is a module-level function, if adapters are implemented as plain modules rather than classes).
Failure modes¶
| Failure | Detection | Resulting state | Automatic retry |
|---|---|---|---|
| Adapter imports a forbidden module | test_no_adapter_imports_forbidden_modules fails |
CI/make validate fails; adapter cannot merge |
N/A (build-time gate) |
| Adapter calls a forbidden name | test_no_adapter_calls_forbidden_names fails |
Same | N/A |
Adapter model omits extra="forbid" |
test_every_adapter_model_forbids_extra_fields fails |
Same | N/A |
Adapter method reads self state (non-static) |
test_every_adapter_method_is_static_or_free_function fails |
Same | N/A |
Recovery behavior¶
Not applicable — these are static/build-time checks, not runtime recovery paths.
Non-goals¶
- This spec does not attempt to catch every conceivable unsafe adapter
pattern via AST alone — it catches the specific, enumerated forbidden
behaviors. Human review (per
capability_adapter_contract.md's review checklist) remains required for semantic correctness (e.g., whether a fingerprint function is complete, which AST cannot verify). - This spec does not sandbox adapter code at runtime (no
exec-in-restricted-namespace, no separate process/container). The local trust model already assumes reviewed, first-party code; AST gates exist to catch mistakes and drift, not to defend against a malicious contributor with commit access — that threat is out of scope perTHREAT_MODEL.md's existing A5 boundary (supply-chain review, CI checks, pinned actions — not runtime sandboxing).
Required tests¶
Listed under Interfaces above; all four must exist before the first
adapter module is created, run against an intentionally-violating
fixture adapter first (to prove the check actually fails when it should
— the same discipline the architecture review applied when verifying
test_isolation.py was real rather than trivially passing), then against
the real adapter once written.
Activation requirements¶
- [ ]
test_adapter_isolation.pycreated and proven against both a violating fixture and a compliant fixture before any real adapter exists. - [ ] Wired into
make validatealongside the existingtests/tier1/suite (no separate opt-in — it must run every time, same astest_isolation.pytoday).
Implementation checklist¶
- [ ] Create
src/pfsense_mcp/tier1/adapters/__init__.py(empty package, created only when the first adapter is authorized — do not pre-create an empty package speculatively; an empty, unreferenced package is exactly the kind of premature scaffolding the project's "no half-finished implementations" principle warns against). - [ ] Create
tests/tier1/test_adapter_isolation.pywith all four checks. - [ ] Add a deliberately-violating fixture module (test-only, not shipped)
to prove each check fails when it should, then delete/neutralize it
once the proof is captured in the test's own assertions (a
"check the checker" test, same spirit as the fixture-safety
self-tests already in this repo, e.g.
tests/test_fixture_safety.pyconstructing deliberately-bad input to prove the checker catches it).
Review checklist¶
- [ ] Confirm the forbidden-import/call sets here are kept in sync with
test_isolation.py's existing sets — if the base sets change, review whether the adapter-specific supersets need the same change. - [ ] Confirm the "check the checker" fixture-violation tests actually ran and failed before the real checks were added (verify by temporarily reverting the check and confirming the fixture no longer fails, then restoring — a manual verification step for whoever implements this, not an automated one).
Security checklist¶
- [ ] Confirm no adapter module can bypass these checks via dynamic
imports (
importlib.import_modulewith a computed string) — AST checks only see literalimport/fromstatements; add a corresponding check that flags any use ofimportlibor__import__insidetier1/adapters/*.pyas an additional forbidden call, since it would otherwise be an unmonitored escape hatch around the static import checks.
Test checklist¶
- [ ] All four checks in Interfaces implemented and passing against a compliant fixture.
- [ ] All four checks proven to fail against a deliberately non-compliant fixture (checker self-test).
- [ ]
importlib/__import__forbidden-call addition (Security checklist) implemented and tested.