Story S2-03 — Plugin loader: filesystem walk, importlib, PLUGINS.lock integrity check¶
Step: Step 2 — Plugin Registry kernel, manifest schema, loader, resolver
Status: Done — GREEN 2026-05-19 (phase-story-executor; see _attempts/S2-03.md for the per-AC evidence table + gate log)
Effort: M
Depends on: S1-01 (PluginId, BlobDigest, parse_plugin_id, parse_blob_digest), S1-03 (tagged-union outcomes), S2-01 (PluginRegistry, default_registry), S2-02 (PluginManifest.from_yaml)
ADRs honored: Phase 0 ADR-0001 (hashing chokepoint), Phase 1 ADR-0009 (safe_json.load chokepoint), ADR-0002 (registry kernel + exit 4), ADR-0010 (tagged-union sum types; newtypes), ADR-0011 (honest framing; Phase 11 substitution seam), production ADR-0031 (filesystem walk + importlib)
Validation notes (2026-05-18 — HARDENED)¶
Validated via phase-story-validator. Full audit log: _validation/S2-03-plugin-loader-integrity.md. Substantive changes vs. the Ready draft:
- Chokepoint discipline restored. Loader now routes hashing through
codegenie.hashing.tree_digest_of_files(extending the ADR-0001 chokepoint, not bypassing it) and JSON reads throughcodegenie.parsers.safe_json.load(Phase 1 ADR-0009). Directhashlib/blake3/jsonimports are AST-fence-forbidden inloader.py. PluginRejectedupgraded to tagged-union sum type per ADR-0010 / S1-03. Seven dataclass variants (IntegrityMismatch,MissingManifest,SchemaViolation,UnlockedPlugin,MissingPluginDirectory,PluginImportError,SymlinkEscape); each carries only its evidence.expected: BlobDigest/actual: BlobDigestare mandatory onIntegrityMismatch(newtype, notstr).PluginVerifierStrategy seam introduced now — ADR-0011 §Consequences line 78 explicitly pre-commits to the substitution interfaceverify_plugin(plugin_dir) -> Result[None, VerificationError]for Phase 11 Sigstore.load_plugins(*, verifier=Sha256TreeDigestVerifier())lets Phase 11 substitute via DI with zero edits toloader.py.- Verify-all-then-import-all order pinned by AC + witness test. A tampered plugin's module body never runs even if other plugins' integrity passes. Closes the silent-registration mutation class.
- Seven-variant rejection table is parametrized; one named test per
PluginRejectedvariant; one Hypothesis property test for digest walk-order invariance; one cross-platform LF/CRLF parametrize; one__pycache__-skip witness; onesys.moduleswitness for import-not-fired. CODEOWNERSlocation corrected to.github/CODEOWNERS(existing). Story previously said "repo root, create if absent" — would have created a duplicate.plugins/__init__.pyempty package marker added to Files-to-touch soimportlib.import_module("plugins.{slug}.api")resolves.- Functional-core / imperative-shell split.
_collect_plugin_files(plugin_dir) -> Result[..., SymlinkEscape](impure) is separable fromtree_digest_of_files(pairs) -> BlobDigest(pure, chokepoint-resident). - AST source-scan fence added (
tests/static/test_plugins_loader_chokepoints.py) so a future maintainer cannot accidentally re-add a directhashlib/json/blake3import.
Context¶
S2-01 lands the in-memory kernel; S2-02 lands the per-file manifest model. This story is the bridge from disk to kernel: the filesystem walk over plugins/*/plugin.yaml, the importlib.import_module invocation for each plugin's entry module (so the plugin's @register_plugin(...) call fires), and the per-plugin SHA-256 tree-digest verification against plugins/PLUGINS.lock. The integrity check is the structural enforcement of ADR-0011 — honestly framed as "catches accidental corruption and partial merges" (not "cryptographic signature"; Phase 11 ships Sigstore at this exact seam).
plugins/PLUGINS.lock ships empty in Phase 3 / Step 2 (the first concrete plugin lands in Step 7 / S7-01). The loader must therefore handle the "lock file is empty" case as "no plugins to verify, walk finds zero plugins, registry is empty after load" — not as an error. The integrity check is the contract subsequent plugins commit to once they land. CODEOWNERS gates edits to plugins/PLUGINS.lock to the platform team (PR-template call-out), making the social anchor explicit.
The architectural shape is deliberately small: one load_plugins(plugin_root, lock_path, *, registry=None, verifier=None) -> Result[LoadReport, PluginRejected] entry point; one chokepoint hashing extension (hashing.tree_digest_of_files); one LockFile typed root-model; one PluginVerifier Protocol (defaulted to Sha256TreeDigestVerifier) so Phase 11's Sigstore swap is DI-only; one CODEOWNERS line. The loader does not validate scope or walk extends — that's the resolver (S2-04). Rejection is fail-fast; exit code 4 per ADR-0002.
References — where to look¶
- Architecture:
../phase-arch-design.md §Component design C2— failure modes (PluginRejected(integrity_mismatch), exit code 4) and the "loaded by the same mechanism" promise from ADR-0031.../phase-arch-design.md §Component design C10paragraph onSandboxedPath— informs the tree-walk's path-discipline (no symlink-following during integrity hashing). Also §Edge cases E12 (TOCTOU is real).../phase-arch-design.md §Edge cases E10 + E17— concrete-pluginimport_moduleraises →PluginRejected(import_error); SHA mismatch →PluginRejected(integrity_mismatch).../phase-arch-design.md §Process view— the loader runs once at orchestrator init; not in the hot path.../phase-arch-design.md §Patterns considered and deliberately rejectedline 951 — "No DI container" rationale (record but do not adopt).- Phase 3 ADRs:
../ADRs/0002-plugin-registry-kernel-instance-with-default-singleton.md— exit code 4 on collision/cycle/lock-mismatch; loader mutates the registry instance passed in (ordefault_registry).../ADRs/0010-domain-modeling-discipline-scope-sum-type-and-newtypes.md§Decision 3 — tagged-union sum types on every state machine; the seven rejection variants ARE a state machine.../ADRs/0011-honest-framing-capability-sandboxedpath-pluginslock.md—PLUGINS.lockis integrity check, not signature; CODEOWNERS is the social anchor; §Consequences line 78 explicitly names the Phase 11 substitution interfaceverify_plugin(plugin_dir) -> Result[None, VerificationError]— this story exposes that interface now as aPluginVerifierProtocol parameter.- Phase 0 / Phase 1 ADRs:
../../00-bullet-tracer-foundations/ADRs/0001-cache-content-hash-algorithm.md—codegenie.hashingis the only module undersrc/codegenie/allowed to importhashlib.sha256orblake3. The story'stree_digest_of_filesextends this chokepoint additively.../../01-context-gather-layer-a-node/ADRs/0009-no-new-c-extension-parser-dependencies.md—codegenie.parsers.safe_json.loadis the JSON chokepoint withO_NOFOLLOW+ size cap + depth cap.PLUGINS.lockreads route through it.- Production ADRs:
../../../production/adrs/0031-plugin-architecture.md§Discovery and resolution — filesystem walk overplugins/{slug}/plugin.yaml;importlibtriggers registration. §Plugin directory layout enumeratesprobes/,adapters/,subgraph/, etc. — noapi.py. This story introduces theapi.pyentry-module convention (see Notes §7).- Existing code:
src/codegenie/probes/__init__.py— explicit-imports precedent (noimportlib.metadataentry-point scan; supply-chain hygiene). The plugin loader is the opposite shape — filesystem walk + dynamic import — but the same hygiene principle: never load from outside the repo.src/codegenie/hashing.py:1-29— chokepoint discipline + thesha256:<64-hex>/blake3:<64-hex>prefix-tagged format. The newtree_digest_of_filesreturns un-prefixed 64-hex to matchBlobDigest(^[0-9a-f]{64}$).src/codegenie/parsers/safe_json.py— JSON chokepoint pattern.src/codegenie/parsers/_io.py(open_capped) — the shared O_NOFOLLOW+size-cap primitive.src/codegenie/probes/deployment.py:183-193— in-codebasePath.resolve(strict=True).is_relative_to(root)precedent.src/codegenie/plugins/manifest.py(S2-02) —PluginManifest.from_yaml(path)returningResult.src/codegenie/plugins/registry.py(S2-01) — the registry the loader mutates;default_registry.src/codegenie/plugins/errors.py(S2-01) —PluginRejectedplaceholder lives here; this story replaces the placeholder with the tagged-union sum type..github/CODEOWNERS:25-31— existing CODEOWNERS file (this story edits it; does NOT create a duplicate at repo root).- Prior validation history (sibling-family lineage):
_validation/S1-03-tagged-union-outcomes.md— the tagged-union pattern this story applies toPluginRejected._validation/S2-01-plugin-registry-kernel.md—restore_default_registryautouse fixture, parametrized per-variant tests, typed-.nameexception payloads._validation/S2-02-plugin-manifest-pydantic.md— loader-with-tagged-union-errors family, route-through-chokepoint discipline, Hypothesis property for never-raises invariant.
Goal¶
Ship a deterministic plugin loader that walks plugins/, verifies every plugin's tree integrity against plugins/PLUGINS.lock BEFORE any plugin module is imported, imports each verified plugin's entry module (firing @register_plugin side effects), and surfaces every failure mode as a typed Result over a tagged-union sum type. Route all hashing through the codegenie.hashing chokepoint and all JSON reads through the codegenie.parsers.safe_json chokepoint. Expose the verification step as a PluginVerifier Protocol so Phase 11 substitutes Sigstore via DI with zero edits to this loader. Ship an empty PLUGINS.lock, an empty plugins/__init__.py package marker, and an additive CODEOWNERS entry in .github/CODEOWNERS.
Acceptance criteria¶
-
[ ] AC-1 — Public surface + LoadReport shape.
src/codegenie/plugins/loader.pyexportsload_plugins(plugin_root: Path, lock_path: Path, *, registry: PluginRegistry | None = None, verifier: PluginVerifier | None = None) -> Result[LoadReport, PluginRejected]andcompute_plugin_tree_digest(plugin_dir: Path) -> Result[BlobDigest, SymlinkEscape].LoadReportis aBaseModel(frozen=True, extra="forbid")withloaded: tuple[PluginId, ...]andtotal_walked: int. -
[ ] AC-2 — Tree-digest is chokepoint-routed, deterministic, and binary-mode.
compute_plugin_tree_digestcomposes_collect_plugin_files(plugin_dir) -> Result[tuple[tuple[str, bytes], ...], SymlinkEscape](impure:sorted(plugin_dir.rglob("*")), skips__pycache__/and*.pyc, reads viaPath.read_bytes(), refuses symlink-escape viaPath.resolve(strict=True).is_relative_to(plugin_dir.resolve(strict=True))) withcodegenie.hashing.tree_digest_of_files(pairs: Iterable[tuple[str, bytes]]) -> BlobDigest(pure given inputs; new public function added tosrc/codegenie/hashing.py— useshashlib.sha256inside the chokepoint; returns un-prefixed 64-hex matchingBlobDigest's^[0-9a-f]{64}$regex viaparse_blob_digest).loader.pyMUST NOT importhashliborblake3directly (enforced by AC-14 AST scan). -
[ ] AC-3 — Empty-lock happy path.
plugins/PLUGINS.lockships with content{}(empty JSON object) plus a siblingplugins/PLUGINS.lock.README.mdexplaining ADR-0011's honest framing and Step 7's first concrete entry. The loader's filesystem walk overplugins/*/plugin.yamlfinds zero plugin directories and returnsOk(LoadReport(loaded=(), total_walked=0)). Mismatch on any plugin →Err(IntegrityMismatch(plugin=name, expected=..., actual=...)). -
[ ] AC-4 —
PluginRejectedis a tagged-union sum type.src/codegenie/plugins/errors.pydefines seven frozen dataclass variants underPluginRejected: TypeAlias = IntegrityMismatch | MissingManifest | SchemaViolation | UnlockedPlugin | MissingPluginDirectory | PluginImportError | SymlinkEscape. Each variant has aplugin: PluginIdfield and akind: Literal[...]discriminator;IntegrityMismatchadditionally carriesexpected: BlobDigestandactual: BlobDigest(both mandatory — newtype, notstr | None);SchemaViolationandPluginImportErroradditionally carrydetail: str;SymlinkEscapeadditionally carriesoffending_path: Path. A free functionexit_code_for_rejection(p: PluginRejected) -> Literal[4]is exported. A consumer-sitematchblock withassert_neverexhaustiveness check is included in TDD as amypy --strictgate. -
[ ] AC-5 —
LockFileis a PydanticRootModelrouted through thesafe_jsonchokepoint.src/codegenie/plugins/lockfile.pydefinesLockFile = RootModel[dict[PluginId, BlobDigest]]withLockFile.from_path(path: Path) -> Result[LockFile, LockFileMalformed]. The implementation callscodegenie.parsers.safe_json.load(path, max_bytes=1 << 16, max_depth=2, parser_kind="plugins_lock"). Field validators liftstr→PluginIdviaparse_plugin_idandstr→BlobDigestviaparse_blob_digest; failures are surfaced asErr(LockFileMalformed(detail=...)). Top-level non-object ([],null, scalar) →Err(LockFileMalformed(detail=...)).loader.pyMUST NOT importjsondirectly (enforced by AC-14 AST scan). -
[ ] AC-6 —
PluginVerifierProtocol seam (ADR-0011 §Consequences line 78).src/codegenie/plugins/verifiers.pydeclaresclass PluginVerifier(Protocol): def verify(self, plugin_dir: Path, expected: BlobDigest) -> Result[None, VerificationError]: ...and shipsclass Sha256TreeDigestVerifierimplementing it viacompute_plugin_tree_digest.load_plugins(..., verifier=None)defaults toSha256TreeDigestVerifier(). A unit test (test_load_plugins_accepts_alternate_verifier) constructs a fakePluginVerifierwhoseverifyreturnsOk(None)regardless of expected digest, demonstrating Phase 11's substitution path with zero edits toloader.py. -
[ ] AC-7 — Per-rejection parametrized tests.
tests/unit/plugins/test_loader.pycarries a@pytest.mark.parametrizetable covering one test perPluginRejectedvariant. Required test names: test_integrity_mismatch_returns_err(the TDD red test)test_missing_manifest_returns_errtest_schema_violation_returns_errtest_unlocked_plugin_returns_errtest_missing_plugin_directory_returns_errtest_import_error_returns_err-
test_symlink_escape_returns_errEach assertsresult.is_err(), the variantisinstance(...), the.pluginfield equals the expectedPluginId, andexit_code_for_rejection(err) == 4. -
[ ] AC-8 — Verify-all-then-import-all order is load-bearing.
load_pluginsexecutes the four-gate pipeline in strict order across the entire plugin set: - Discover —
sorted(plugin_root.glob("*/plugin.yaml"))→walked: tuple[Path, ...]. - Verify — load manifest (S2-02); reconcile manifest names against
LockFilekeys; for each (manifest, lock entry) callverifier.verify(plugin_dir, expected=lock[name]). Fail-fast on the first rejection. - Import — only if every plugin passed Verify, call
importlib.import_module(f"plugins.{slug}.api")for each in walk order. Fail-fast on the firstImportError→PluginImportError. -
Register —
@register_pluginside effects from Import populate the registry. AC-12 supplies the witness test that no module is ever imported during a rejected load. -
[ ] AC-9 — Fail-fast LoadReport semantics.
LoadReport.loadedreflects only plugins that passed both Verify AND Import (which, under AC-8, means either ALL plugins or NONE).total_walkedislen(walked)regardless of outcome. On rejection, the function returnsErr(...); on success,Ok(LoadReport(loaded=tuple(plugin_ids), total_walked=len(walked))). -
[ ] AC-10 — Digest walk-order invariance (Hypothesis property).
tests/unit/plugins/test_loader_digest_property.pyuses@hypothesis.given(...)to generate synthetic plugin trees (1–5 files, arbitrary names, arbitrary bytes) and asserts:compute_plugin_tree_digest(plugin_dir) == compute_plugin_tree_digest(plugin_dir)after each permutation of underlyingos.scandirorder (simulated by re-creating the tree with files written in shuffled order or by mockingPath.rglob). 30 runs minimum. -
[ ] AC-11 — Cross-platform +
__pycache__invariance.tests/unit/plugins/test_loader_digest_cross_platform.pyincludes: test_digest_is_bytes_modeparametrized overb"line1\nline2"andb"line1\r\nline2"content: writes the same logical content with LF then CRLF endings under different filenames; the digests differ (proves binary-mode read). The opposite mutant — text-mode read normalizing CRLF to LF — would make them equal and fail this test.test_digest_skips_pycache_directory: compute digest, populateplugin_dir/__pycache__/foo.cpython-311.pycwithb"bytecode", recompute, assert equality.-
test_digest_skips_pyc_files: compute digest, populateplugin_dir/leaked.pycat top level, recompute, assert equality. -
[ ] AC-12 — Import-not-fired-on-rejection witness.
tests/unit/plugins/test_loader.py::test_tampered_plugin_module_never_imported: build a synthetic plugin whoseapi.pycontainsimport sys; sys.modules["__PROOF_OF_IMPORT__"] = object(); lock its digest; tamper a sibling file; callload_plugins. Assertions:result.is_err(),result.unwrap_err().kind == "integrity_mismatch","__PROOF_OF_IMPORT__" not in sys.modules. Companiontest_no_partial_registration_on_any_rejectionparametrized across all sevenPluginRejectedvariants assertsregistry.all() == ()after every rejection path. -
[ ] AC-13 — Default-registry path tested.
tests/unit/plugins/test_loader.py::test_load_plugins_default_singleton_path(with autouserestore_default_registryfixture from S2-01) invokesload_plugins(plugin_root, lock_path)with noregistry=kwarg and asserts the resulting plugin appears indefault_registry.all(). Teardown restores the snapshot. -
[ ] AC-14 — AST source-scan fence.
tests/static/test_plugins_loader_chokepoints.pyparsessrc/codegenie/plugins/loader.pywithast.parseand asserts noImport/ImportFromnode namesjson,hashlib, orblake3. Permitted imports includecodegenie.hashing,codegenie.parsers.safe_json,importlib,pathlib. The fence is the structural enforcement of ADR-0001 + Phase 1 ADR-0009 at the new module's boundary. -
[ ] AC-15 — CODEOWNERS + PR template.
.github/CODEOWNERS(existing file) gains a single additive line:plugins/PLUGINS.lock @platform-team(placeholder team handle — the implementer's commit message MUST carry a TODO asking the human reviewer to substitute the actual GitHub team handle)..github/pull_request_template.md(create if absent — keep additive) gets a call-out line:If you changed plugins/PLUGINS.lock, confirm: plugin tree integrity recomputed; ADR-0011 honest-framing language preserved (integrity check, not signature). -
[ ] AC-16 — CLI exit-code class-attribute promise. The orchestrator init in S6-04 maps any
PluginRejectedto exit code 4 viaexit_code_for_rejection(p). This story does not own the CLI wire-up but it does add a unit test assertingexit_code_for_rejection(IntegrityMismatch(...)) == 4(one assertion per variant, parametrized). -
[ ] AC-17 —
ruff check,ruff format --check,mypy --strictclean on every touched file, INCLUDING thematch/assert_neverconsumer-site test that gates exhaustiveness at type-check time.
Implementation outline¶
-
Extend
src/codegenie/hashing.py(additive, chokepoint-respecting):This is the only new placedef tree_digest_of_files(pairs: Iterable[tuple[str, bytes]]) -> BlobDigest: """Pure SHA-256 tree-digest over (relpath, file_bytes) pairs. Caller MUST pre-sort and pre-filter. Each pair is serialized as ``<relpath>\\x1f<size>\\x1f<bytes>`` and records are joined by ``\\x1e`` — the same separator discipline as ``content_hash_of_inputs``. Returns un-prefixed 64-hex BlobDigest (S1-01 newtype regex). """hashlib.sha256is read; the chokepoint stays one-file-thick. -
Create
src/codegenie/plugins/lockfile.py: class LockFile(RootModel[dict[PluginId, BlobDigest]])withfield_validators lifting rawdict[str, str]viaparse_plugin_id+parse_blob_digest.LockFile.from_path(path: Path) -> Result[LockFile, LockFileMalformed]— callscodegenie.parsers.safe_json.load(path, max_bytes=1<<16, max_depth=2, parser_kind="plugins_lock"); top-level non-object →Err.-
class LockFileMalformed— frozen dataclass withdetail: str. -
Create
src/codegenie/plugins/verifiers.py(the ADR-0011 §Consequences line 78 Strategy seam):Phase 11 landsclass PluginVerifier(Protocol): def verify(self, plugin_dir: Path, expected: BlobDigest) -> Result[None, VerificationError]: ... @dataclass(frozen=True) class VerificationError: plugin_dir: Path expected: BlobDigest actual: BlobDigest @dataclass(frozen=True) class Sha256TreeDigestVerifier: def verify(self, plugin_dir: Path, expected: BlobDigest) -> Result[None, VerificationError]: match compute_plugin_tree_digest(plugin_dir): case Ok(actual) if actual == expected: return Ok(None) case Ok(actual): return Err(VerificationError(plugin_dir, expected, actual)) case Err(symlink_escape): return Err(VerificationError(plugin_dir, expected, actual=BlobDigest("0"*64)))SigstoreVerifieras a new file + DI swap; zero edits toloader.py. -
Extend
src/codegenie/plugins/errors.py— replace the S2-01PluginRejectedplaceholder with the seven-variant tagged-union per AC-4. Addexit_code_for_rejection(p: PluginRejected) -> Literal[4]. -
Create
src/codegenie/plugins/loader.py: _collect_plugin_files(plugin_dir: Path) -> Result[tuple[tuple[str, bytes], ...], SymlinkEscape]: impure walk + symlink-discipline +__pycache__/*.pycskip +Path.read_bytes().compute_plugin_tree_digest(plugin_dir: Path) -> Result[BlobDigest, SymlinkEscape]: composes_collect_plugin_fileswithcodegenie.hashing.tree_digest_of_files.-
load_plugins(plugin_root, lock_path, *, registry=None, verifier=None) -> Result[LoadReport, PluginRejected]executes the four-gate pipeline (AC-8):- Discover —
walked = sorted(plugin_root.glob("*/plugin.yaml")). - Verify — load
LockFile.from_path(lock_path); for each walked manifest, load viaPluginManifest.from_yaml(routeErr→SchemaViolationorMissingManifest); verifymanifest.name in lock; callverifier.verify(plugin_dir, expected=lock[name]). Cross-check: every entry inlockwas visited; missing dir →MissingPluginDirectory. Fail-fast on first rejection. - Import — only if Verify passed for ALL plugins, call
importlib.import_module(f"plugins.{slug}.api")in walk order. Catch and reroute toPluginImportError. - Return
Ok(LoadReport(loaded=tuple(plugin_ids), total_walked=len(walked))).
- Discover —
-
Create supporting files:
plugins/__init__.py— empty package marker (required forimportlib.import_module("plugins.{slug}.api")to resolve).plugins/PLUGINS.lock— content{}.plugins/PLUGINS.lock.README.md— one-paragraph ADR-0011 honest-framing note + Step 7 forward pointer..github/CODEOWNERS— additive lineplugins/PLUGINS.lock @platform-team(TODO in commit message: confirm team handle).-
.github/pull_request_template.md— call-out per AC-15 (additive; create if absent). -
Tests:
tests/unit/plugins/test_loader.py— TDD red + parametrized per-variant rejections + import-not-fired witness + default-registry path.tests/unit/plugins/test_loader_digest_property.py— Hypothesis walk-order invariance.tests/unit/plugins/test_loader_digest_cross_platform.py— binary-mode + pycache-skip witnesses.tests/unit/plugins/test_loader_empty.py— empty-lock + zero-plugins happy path.tests/static/test_plugins_loader_chokepoints.py— AST source-scan fence (AC-14).tests/fixtures/plugins/loader_fixtures.py—make_fake_plugin_dir(name, body=...)helper.
TDD plan — red / green / refactor¶
Red — failing test first¶
Test file path: tests/unit/plugins/test_loader.py
import re
import sys
from pathlib import Path
from codegenie.plugins.errors import IntegrityMismatch, exit_code_for_rejection
from codegenie.plugins.loader import compute_plugin_tree_digest, load_plugins
from codegenie.plugins.lockfile import LockFile
from codegenie.plugins.registry import PluginRegistry
from codegenie.types.identifiers import PluginId
def test_integrity_mismatch_returns_err(tmp_path: Path):
"""ADR-0011: `PLUGINS.lock` mismatch is a typed `IntegrityMismatch` variant
with exit code 4. Mutate a plugin file after locking; loader must refuse
AND must NOT import the tampered module."""
plugin_root = tmp_path / "plugins"
slug_dir = plugin_root / "example--noop--npm"
slug_dir.mkdir(parents=True)
(slug_dir / "plugin.yaml").write_text(
"name: example--noop--npm\nversion: 0.1.0\n"
"scope:\n task_class: example\n languages: ['*']\n build_systems: ['*']\n"
"contributes:\n tccm: ./tccm.yaml\n",
encoding="utf-8",
)
(slug_dir / "api.py").write_text("# initial body\n", encoding="utf-8")
# Snapshot the digest using the loader's own helper, then mutate AFTER locking.
locked = compute_plugin_tree_digest(slug_dir).unwrap()
# Pin the BlobDigest format — un-prefixed lowercase 64-hex per S1-01.
assert re.fullmatch(r"[0-9a-f]{64}", locked)
lock_path = plugin_root / "PLUGINS.lock"
lock_path.write_text(f'{{"example--noop--npm": "{locked}"}}', encoding="utf-8")
(slug_dir / "api.py").write_text("# tampered body\n", encoding="utf-8")
sys.modules.pop("__PROOF_OF_IMPORT__", None)
registry = PluginRegistry()
result = load_plugins(plugin_root, lock_path, registry=registry)
assert result.is_err()
err = result.unwrap_err()
assert isinstance(err, IntegrityMismatch)
assert err.plugin == PluginId("example--noop--npm")
assert exit_code_for_rejection(err) == 4
assert len(registry.all()) == 0
# The tampered module must NOT have been imported — verify-then-import order.
assert "__PROOF_OF_IMPORT__" not in sys.modules
Why it fails: codegenie.plugins.loader, lockfile, verifiers do not yet exist; IntegrityMismatch is unpopulated; tree_digest_of_files is not extended onto hashing.py.
Green follow-on tests (each is a separate, named test)¶
Listed in the order the executor implements them:
test_missing_manifest_returns_err—plugins/{slug}/directory exists but noplugin.yaml. →Err(MissingManifest(plugin=PluginId(slug))).test_schema_violation_returns_err— malformedplugin.yaml(per S2-02'sfrom_yaml). →Err(SchemaViolation(plugin=PluginId(slug), detail=...)).test_unlocked_plugin_returns_err— manifest names a plugin not inPLUGINS.lock. →Err(UnlockedPlugin(plugin=...)).test_missing_plugin_directory_returns_err—PLUGINS.lockentry exists but no matching directory. →Err(MissingPluginDirectory(plugin=...)).test_import_error_returns_err—api.pyraises at import time (e.g.,raise RuntimeError("boom")). →Err(PluginImportError(plugin=..., detail=...)).test_symlink_escape_returns_err—plugins/{slug}/escape.pyis a symlink pointing OUT ofplugin_dir. →Err(SymlinkEscape(plugin=..., offending_path=...)).test_malformed_lock_returns_err—PLUGINS.lockcontent is[]/null/ scalar →Err(LockFileMalformed(...))fromLockFile.from_path(lifted into the loader'sResult).test_no_partial_registration_on_any_rejection— parametrized across all 7PluginRejectedvariants; assertsregistry.all() == ()after each rejection path.test_tampered_plugin_module_never_imported— see AC-12. Witness:sys.modules["__PROOF_OF_IMPORT__"]is never set.test_load_plugins_default_singleton_path—load_plugins(plugin_root, lock_path)with noregistry=kwarg; autouserestore_default_registryfixture from S2-01'sconftest.py.test_load_plugins_accepts_alternate_verifier— pass a fakePluginVerifierwhoseverifyreturnsOk(None)regardless; loader proceeds to Import even with a mismatched lock entry. Demonstrates Phase 11 substitutability.test_lockfile_roundtrip— write{"a--b--c": "0"*64}, load viaLockFile.from_path, assertlockfile.root[PluginId("a--b--c")] == BlobDigest("0"*64).test_empty_lock_zero_plugins(intest_loader_empty.py) — emptyPLUGINS.lock+ emptyplugins/→Ok(LoadReport(loaded=(), total_walked=0)).- Hypothesis property in
test_loader_digest_property.py— see AC-10. - Cross-platform witnesses in
test_loader_digest_cross_platform.py— see AC-11. - AST fence in
tests/static/test_plugins_loader_chokepoints.py— see AC-14. exit_code_for_rejectionparametrized smoke — see AC-16.
Refactor¶
- Pull each error branch in
load_pluginsinto a small_reject(variant)helper so the main loop reads top-down with no nested try/except. - A consumer-site
matchblock overPluginRejectedwithassert_neverin the default arm; placed in atest_pluginrejected_exhaustiveness_gatetest that exists only to makemypy --strictfail if a new variant lands without updating consumers. - Document symlink-refusal explicitly (TOCTOU-adjacent — ADR-0011 framing).
Files to touch¶
| Path | Why |
|---|---|
src/codegenie/hashing.py |
Additive tree_digest_of_files(pairs) -> BlobDigest extension of the ADR-0001 chokepoint. |
src/codegenie/plugins/loader.py |
The loader entry point + _collect_plugin_files + compute_plugin_tree_digest. |
src/codegenie/plugins/lockfile.py |
LockFile = RootModel[dict[PluginId, BlobDigest]] routed through safe_json.load. |
src/codegenie/plugins/verifiers.py |
PluginVerifier Protocol + Sha256TreeDigestVerifier (ADR-0011 §Consequences line 78 substitution seam). |
src/codegenie/plugins/errors.py |
Replace the S2-01 PluginRejected placeholder with the 7-variant tagged-union sum type + exit_code_for_rejection. |
plugins/__init__.py |
Empty package marker so importlib.import_module("plugins.{slug}.api") resolves. |
plugins/PLUGINS.lock |
Empty JSON {}; ships in this story. |
plugins/PLUGINS.lock.README.md |
One-paragraph honest-framing note (ADR-0011) + Step 7 forward pointer. |
.github/CODEOWNERS |
Additive line plugins/PLUGINS.lock @platform-team (existing file — do NOT create at repo root). |
.github/pull_request_template.md |
Call-out for PLUGINS.lock edits (additive; create if absent). |
tests/unit/plugins/test_loader.py |
TDD red + every PluginRejected variant + import-not-fired witness + default-registry path. |
tests/unit/plugins/test_loader_empty.py |
Empty-lock + zero-plugins happy path. |
tests/unit/plugins/test_loader_digest_property.py |
Hypothesis walk-order invariance (AC-10). |
tests/unit/plugins/test_loader_digest_cross_platform.py |
Binary-mode + __pycache__ / *.pyc skip witnesses (AC-11). |
tests/static/test_plugins_loader_chokepoints.py |
AST fence asserting loader.py does not import json/hashlib/blake3 (AC-14). |
tests/fixtures/plugins/loader_fixtures.py |
make_fake_plugin_dir(name, body=...) helper. |
Out of scope¶
- Resolver /
extendswalking / specificity / precedence sort — handled by S2-04. - Concrete plugin tree under
plugins/vulnerability-remediation--node--npm/— handled by S7-01. This story ships the loader against synthetic fixture trees only. - Sigstore signing (real cryptographic verification) — Phase 11 substitutes
SigstoreVerifierat this loader'sPluginVerifierinterface; no edits toloader.pyrequired at that time. ADR-0011 §Consequences line 78. - Alternate entry-module names. Phase 3 fixes the convention to
plugins/{slug}/api.py. A future manifest-schema amendment may add anentry_module: str = "api"field if a plugin needs a different name; deferred until a concrete plugin requires it. - CLI exit-code wiring — the orchestrator init in S6-04 maps
PluginRejectedto exit 4 viaexit_code_for_rejection. This story exposes the function; S6-04 consumes it. PluginExtendsCycledetection — that's a resolver concern (S2-04). The loader does no graph analysis.- Lock-file regeneration tooling —
codegenie plugins lock-updateCLI is named in phase-arch E17 recovery column; deferred to Phase 11 alongside Sigstore. - Concurrent loader invocation / module-reload semantics — single-process Phase-3 assumption; Phase 11
--reload-pluginsis the seam.
Notes for the implementer¶
-
The empty-lock invariant is load-bearing. A future maintainer might be tempted to "guard against the empty case as bad config." Don't —
{}is the intentional state from Step 2 through Step 6. The first non-empty entry lands in S7-01 withvulnerability-remediation--node--npm. -
Honest-framing language is structural, not docs. Per ADR-0011: do not name the function
verify_signatureor use the word "signature" anywhere in this module. Use "integrity check" / "tree digest" everywhere.IntegrityMismatchnotSignatureFailure. The CODEOWNERS line + PR template +PLUGINS.lock.README.mdall preserve this language. -
Loader pipeline — four gates, fail-fast at each.
- Discover →
Verify→Import→Register. - Verify ALL before importing ANY. This is the AC-8 invariant. A per-plugin "verify then import" loop is wrong: it allows a verified plugin's module-body to run before a later plugin's integrity check fails, leaving the registry partially mutated for the discarded run.
-
Fail-fast at each gate; the four-gate boundary is what the witness tests (AC-12) defend.
-
Phase 11 substitution seam (
PluginVerifier) is an explicit pre-commitment. ADR-0011 §Consequences line 78 names this interface verbatim. Do not inline the SHA-256 verification insideload_plugins; route it through the Protocol parameter. The cost is one extra file (verifiers.py) and one parameter; the benefit is that Phase 11's Sigstore work is a new file plus a DI substitution at the CLI entry point — zero edits toloader.py. This is the canonical Plugin / Strategy / Open-Closed pattern and the ADR has already paid the design cost. -
Functional-core / imperative-shell split.
_collect_plugin_filesdoes the filesystem I/O (walk + read + symlink-check).codegenie.hashing.tree_digest_of_filesis the pure hashing core.compute_plugin_tree_digestis the public composition. This makes the Hypothesis property test (AC-10) trivially expressive: generate(path, bytes)pairs, shuffle, assert digest equality — no temp-dir fixtures required at the inner layer. -
Chokepoint discipline.
codegenie.hashingis the only place undersrc/codegenie/allowed to importhashlib.sha256/blake3(Phase 0 ADR-0001).codegenie.parsers.safe_jsonis the only place allowed to calljson.loads(Phase 1 ADR-0009). Directhashlib,blake3, orjsonimports inloader.pyare AST-fence-forbidden by AC-14. If a reviewer asks "why not justhashlib.sha256()here, it's three lines" — the answer is the chokepoint kernel pattern; future migration to a different hash (Phase 11 Sigstore) flips one file, not seven. -
Entry-module convention (
api.py) +plugins/__init__.py. Production ADR-0031 §Plugin directory layout enumeratesprobes/,adapters/,subgraph/, etc. — noapi.py. Phase 3 introducesplugins/{slug}/api.pyas the registration entry-module convention. The implementer adds an emptyplugins/__init__.pysoimportlib.import_module("plugins.{slug}.api")resolves. If a later plugin needs a different entry-module name, amend the manifest schema (deferred — see Out-of-scope). -
CODEOWNERS placeholder discipline. Edit
.github/CODEOWNERS(existing); do NOT create a duplicate at repo root. Use@platform-teamif the actual GitHub team handle is unknown at story-implementation time; leave a TODO in the commit message asking the human reviewer to substitute the correct handle. Do not invent a handle. -
Deliberately not adopted.
- No DI container (matches phase-arch §"Patterns considered and deliberately rejected" line 951). The single
verifierparameter is sufficient DI. - No
entry_modulefield on the manifest in Phase 3. Rule 2 — three concrete consumers haven't materialized; conventionapi.pycarries us until S7-01. - No
SigstoreVerifierhere. The Protocol seam is enough; Phase 11 ships the concrete implementation. -
No concurrent-load support. Single-process assumption; Phase 11
--reload-pluginsis the seam. -
Symbol references. S2-01's
PluginRegistry.register(plugin) -> Plugin(per its validation report) is the source of truth — consume what S2-01 shipped, not what phase-arch C2 line 466 named.default_registryis the module-levelFinalinstance per ADR-0002. -
Symlink discipline.
_collect_plugin_filesusesPath.resolve(strict=True).is_relative_to(plugin_dir.resolve(strict=True)). A symlink that points inside the plugin dir is fine; a symlink that escapes returnsErr(SymlinkEscape(plugin=PluginId(slug), offending_path=resolved)). The check mirrorssrc/codegenie/probes/deployment.py:183-193. -
LoadReportis minimal.loaded: tuple[PluginId, ...],total_walked: int. Resist addingtimings: ...etc. — that's bench-harness territory (S9-03).