Skip to content

Story S5-04 — SbomProbe (syft) + CveProbe (grype) chained via requires

Step: Step 5 — Ship Layer C (runtime + container) probes Status: Done — GREEN 2026-05-17 (phase-story-executor; see _attempts/S5-04.md for the per-AC evidence table and the full gate log) Effort: M Depends on: S5-02 (RuntimeTraceProbe writes the runtime_trace slice via the writer chokepoint and supplies the image-digest token in declared_inputs), S5-01 (ScannerOutcome shared discriminated union), S4-01 (read_raw_slices(raw_dir(repo.root)) kernel — the only sanctioned sibling-slice reader path; this story is the 5th+ consumer, kernel reuse is mandatory), S1-07 (run_external_cli wrapper — Layer C scanners do use it; only docker/strace use run_allowlisted directly), S1-06 (syft, grype in ALLOWED_BINARIES), S3-03 (writer chokepoint), S1-05 (IndexName newtype used as read_raw_slices key), S1-08 (@register_probe decorator — accepts only heaviness + runs_last per 02-ADR-0003 Option D; requires is a class attribute on the Probe ABC). ADRs honored: 02-ADR-0001 (syft/grype allowlist additions), 02-ADR-0003 (heaviness="medium"; decorator carries only heaviness+runs_last; requires is metadata-only — coordinator does NOT topologically sort by it), 02-ADR-0004 (image-digest declared-input token shared with RuntimeTraceProbe), 02-ADR-0007 (no Plugin Loader)

Validation notes (phase-story-validator, 2026-05-17 — HARDENED)

The original draft inherited two phase-wide misconceptions already corrected for the sibling marker-probe story S5-03: (1) the @register_probe decorator does not accept requires= (02-ADR-0003 picked Option D — only heaviness + runs_last); requires is a class attribute on the Probe ABC, declared per S5-02's precedent; (2) there is no "coordinator's slice-map" for sibling-slice access — sibling slices are read from disk via the read_raw_slices(raw_dir(repo.root)) kernel S4-01 introduced (this story is the 5th–6th consumer; kernel reuse is mandatory). The draft also implied requires= would "enforce dispatch order" — explicitly rejected as Option C in 02-ADR-0003. Three Consistency blocks resolved: rewrite both probe registration ACs to use the class-attribute requires; rewrite the slice-access mechanism to disk-anchored read_raw_slices; pin requires as metadata-only with explicit cross-reference to ADR-0003. Six Coverage hardens: explicit sibling-slice file-naming contract (<raw_dir>/sbom.json slice file + syft-sbom.json raw tool output; same shape for cve); broadened absent-upstream coverage (slice file missing OR unparseable OR outcome != "ran" OR built_image_digest is None); _TOP_FINDINGS_N deterministic-truncation contract; missing-raw-artifact path (SbomProbe wrote slice but file deleted between probes); structural pinning of extra="allow" on tool-output Pydantic schemas vs extra="forbid" on emitted slice; explicit literal-token assertion for the image-digest:<resolved> declared input. Four Test-Quality hardens: AST-walk (not source-grep) for read_raw_slices reuse + run_external_cli-not-run_allowlisted discipline; mutation-resistance suite (≥ 6 intentionally-wrong implementation stubs, each must fail ≥ 1 named test); property-based test for _classify_*_outcome total-function property (every (tool_present, exit_code, stdout) triple maps to exactly one ScannerOutcome variant, never raises); pure-function property for both classifiers (same input → same output, no side effects). Three Design-Patterns hardens: _classify_*_outcome should take a tagged-union ScannerAttempt input for assert_never exhaustiveness (implementer's call within LOC budget per Rule 2 — recorded in Notes); rule-of-three extraction trigger documented (S6 SemgrepProbe + GitleaksProbe become 3rd-4th JSON-scanner consumers — kernel extraction mandatory at that point, not now); Final[int] annotation on _TOP_FINDINGS_N + every module constant for mypy strictness. Verdict: HARDENED.

Context

SbomProbe runs syft against the analyzed-repo's built image and emits a normalized SBOM slice (localv2.md §5.3 C2 — package count, by-source breakdown, OS-package classification, native-module count, image size). CveProbe runs grype against the syft-produced SBOM and emits a CVE slice (localv2.md §5.3 C3 — total, by-severity, by-source, top findings). Both depend on RuntimeTraceProbe (S5-02) — the same docker build it runs produces the image these probes scan. The architecture (phase-arch-design.md §"Component design" #5 — Layer G scanners; final-design.md §"Components" #5) prescribes a uniform shape: run_external_cli (not direct run_allowlisted — these are Layer B/G-shaped scanners that benefit from the bubblewrap --unshare-net wrap), Pydantic smart constructor on stdout JSON, ScannerOutcome discriminated union (S5-01) for the outcome.

The requires=["runtime_trace"] mechanism enforces dispatch order — SbomProbe runs after RuntimeTraceProbe; CveProbe runs after SbomProbe. Both share the image-digest:<resolved> special token in declared_inputs (02-ADR-0004 §Consequences) so they cache-invalidate atomically with RuntimeTraceProbe.

Tool-missing is a graceful path (ScannerSkipped(reason="tool_missing") + confidence="low"); bad-JSON is fail-loud-but-isolated (ScannerFailed(reason="invalid_json", stderr_tail=…)); coordinator continues either way per Phase 0 isolation.

References

Goal

Land two scanner probes — src/codegenie/probes/layer_c/sbom.py (syft) and src/codegenie/probes/layer_c/cve.py (grype) — each ≤ 200 LOC, @register_probe(heaviness="medium"), requires=["runtime_trace"] (and CveProbe also requires=["sbom"]). Each invokes its tool via run_external_cli, parses stdout JSON via a Pydantic smart constructor, emits a typed ScannerOutcome discriminated union (S5-01) as the slice's outcome field, declares the image-digest:<resolved> special token in declared_inputs, and falls through the writer chokepoint with RedactedSlice.

Acceptance criteria

  • [ ] src/codegenie/probes/layer_c/sbom.py exists; class SbomProbe(Probe) decorated with @register_probe(heaviness="medium", runs_last=False) (decorator carries only heaviness+runs_last per 02-ADR-0003 Option D). Class attributes pinned: name = "sbom", layer = "C", tier = "base", applies_to_tasks: list[str] = ["*"], applies_to_languages: list[str] = ["*"], requires: list[str] = ["runtime_trace"] (class attribute, metadata-only — coordinator does NOT topologically sort by it per 02-ADR-0003; correctness comes from the graceful absent-upstream path, not dispatch ordering). A unit test asserts the literal class-attribute shape AND that the registry entry stores only heaviness+runs_last (no requires key) — a mutation that passes requires= to the decorator must fail at import time.
  • [ ] SbomProbe.declared_inputs literal: ["Dockerfile", "image-digest:<resolved>"] (shared with RuntimeTraceProbe per 02-ADR-0004 §Consequences) so cache invalidation is atomic across the three Layer C tier-C consumers. A unit test asserts the literal token "image-digest:<resolved>" is present at the exact string-equal level (mutation: replacing with "image-digest:resolved" flips red).
  • [ ] SbomProbe.run() reads the sibling runtime_trace slice via read_raw_slices(raw_dir(snapshot.root)) (the S4-01 kernel — the ONLY sanctioned sibling-slice reader; no ctx.sibling_slices, no per-probe disk-IO duplication). The key is IndexName("runtime_trace"); the value is the runtime_trace slice's parsed JSON. If absent / unparseable / outcome.kind != "ran" / built_image_digest is None → emit ScannerSkipped(reason="upstream_unavailable") + confidence="unavailable" AND DO NOT INVOKE syft. The defensive read is the correctness mechanism, NOT the requires=["runtime_trace"] class attribute (which is metadata-only per 02-ADR-0003).
  • [ ] SbomProbe.run() invokes run_external_cli("syft", [<image-ref>, "-o", "json", "--quiet"]) (syft uses --quiet to suppress non-JSON stdout; document the flag choice in module docstring with a link to syft's CLI reference). 30 s timeout. The <image-ref> is derived deterministically from runtime_trace.built_image_digest via the same _image_ref_for_digest(digest) helper S5-02 uses (or its analogue — surface in Notes if the helper is not exported).
  • [ ] SbomProbe outcome variants (classified by a pure helper _classify_syft_outcome(attempt: ScannerAttempt) -> ScannerOutcome over a tagged-union input — see Notes for the implementer):
  • Upstream unavailable (per the read_raw_slices AC above) → ScannerSkipped(reason="upstream_unavailable") + confidence="unavailable".
  • syft not on $PATH (Phase 0 tool_cache miss) → ScannerSkipped(reason="tool_missing") + confidence="low".
  • syft exits non-zero → ScannerFailed(exit_code, stderr_tail) + confidence="low".
  • syft stdout is not valid JSON → ScannerFailed(reason="invalid_json", stderr_tail) + confidence="low". (The ScannerFailed variant's kind discriminator distinguishes the cases — reason is a Pydantic-validated subfield. If S5-01's ScannerFailed shape doesn't yet support a reason field alongside exit_code/stderr_tail, surface in "Notes for the implementer" — S5-01's "Acceptance criteria" already lists both.)
  • syft exits 0 with valid JSON → ScannerRan(findings=[...]) + the slice's package_count, packages_by_source, etc. populated.
  • The classification function is total — every (tool_present, exit_code, stdout_validity) triple maps to exactly one variant; a property-based test (Hypothesis or equivalent) verifies the totality and asserts the function never raises.
  • [ ] SbomProbe emits the slice schema subset from localv2.md §5.3 C2: artifact_uri, built_image_digest, package_count, packages_by_source: dict[str, int], os_packages_classification: {runtime_required, build_only, convenience, unknown}, npm_packages_native_module_count, total_size_bytes, outcome: ScannerOutcome. Sub-schema lands under src/codegenie/schema/probes/layer_c/sbom.schema.json with additionalProperties: false.
  • [ ] Two raw files, one each for the slice and the tool output. SbomProbe writes (a) <raw_dir>/sbom.json — the typed slice (the ProbeOutput.schema_slice serialized, including outcome, artifact_uri, built_image_digest, package_count, etc.) — this is the file read_raw_slices(raw_dir(repo.root)) reads keyed by IndexName("sbom"), the contract surface CveProbe reads from and any future IndexHealthProbe sbom freshness check (registered in S5-05 per S5-02's runtime_trace precedent) will read; AND (b) <raw_dir>/syft-sbom.json — the raw syft JSON bytes (the slice's artifact_uri points here; grype consumes this file via the sbom: URI prefix). The two-file split is deliberate per S4-01's sibling-slice contract: typed slice for sibling consumers; raw tool output for downstream (here, grype) consumers. If the syft outcome is ScannerFailed(reason="invalid_json", ...), only sbom.json (the typed-outcome slice) is written; the malformed syft-sbom.json is NOT written (artifact_uri is None).
  • [ ] src/codegenie/probes/layer_c/cve.py exists; class CveProbe(Probe) decorated with @register_probe(heaviness="medium", runs_last=False) (decorator carries only heaviness+runs_last). Class attributes pinned: name = "cve", layer = "C", tier = "base", applies_to_tasks: list[str] = ["*"], applies_to_languages: list[str] = ["*"], requires: list[str] = ["sbom"] (class attribute, metadata-only — coordinator does NOT dispatch-order by it; correctness comes from the graceful absent-upstream path). A unit test asserts the literal class-attribute shape AND that the registry entry stores only heaviness+runs_last (no requires key).
  • [ ] CveProbe.declared_inputs literal: ["image-digest:<resolved>"]. A unit test asserts the literal token at exact string-equal level.
  • [ ] CveProbe.run() reads the sibling sbom slice via read_raw_slices(raw_dir(snapshot.root)) keyed by IndexName("sbom"). If absent / unparseable / outcome.kind != "ran" → emit ScannerSkipped(reason="upstream_unavailable") + confidence="unavailable" AND DO NOT INVOKE grype. ADDITIONALLY: if the slice says outcome.kind == "ran" but the raw syft-sbom.json file pointed to by artifact_uri is missing or empty on disk (e.g., concurrent gather, user deletion), emit ScannerFailed(reason="sbom_artifact_missing", stderr_tail="<expected path>") + confidence="low" — never silently invoke grype against a non-existent file path.
  • [ ] CveProbe.run() invokes run_external_cli("grype", ["sbom:<resolved-syft-sbom-path>", "-o", "json"]) — the sbom: prefix is grype's documented way to consume a syft SBOM. The path is derived from the sbom slice's artifact_uri field (NOT a hardcoded literal; the slice may live under a non-default <raw_dir> in tests). 30 s timeout.
  • [ ] CveProbe outcome variants (classified by a pure helper _classify_grype_outcome(attempt: ScannerAttempt) -> ScannerOutcome — same total-function discipline as SbomProbe):
  • Upstream unavailable (per the read_raw_slices AC above) → ScannerSkipped(reason="upstream_unavailable").
  • SBOM artifact missing on disk (per the AC above) → ScannerFailed(reason="sbom_artifact_missing", stderr_tail).
  • grype not on $PATHScannerSkipped(reason="tool_missing").
  • Non-zero exit → ScannerFailed(exit_code, stderr_tail).
  • Invalid JSON stdout → ScannerFailed(reason="invalid_json", stderr_tail).
  • Success → ScannerRan(findings=[...]) + slice populated.
  • [ ] CveProbe emits the slice schema subset from localv2.md §5.3 C3: artifact_uri, scanner: Literal["grype"], scanned_image_digest, total, by_severity: {critical, high, medium, low, negligible}, by_source: dict[str, int], top_findings: list[Finding], outcome: ScannerOutcome. Sub-schema lands under src/codegenie/schema/probes/layer_c/cve.schema.json with additionalProperties: false.
  • [ ] Two raw files (same shape as SbomProbe): CveProbe writes (a) <raw_dir>/cve.json — the typed slice keyed by IndexName("cve") for sibling-slice consumers; (b) <raw_dir>/grype-cves.json — the raw grype JSON bytes (the slice's artifact_uri). Failed/invalid-JSON path → only cve.json (the slice with the failure outcome) is written; the malformed grype-cves.json is NOT written; artifact_uri is None.
  • [ ] _TOP_FINDINGS_N: Final[int] = 20 module constant in cve.py. top_findings is the top 20 by severity descending (critical > high > medium > low > negligible), then by package name ascending, then by CVE id ascending — fully deterministic across runs. A property test (Hypothesis) asserts: for any permutation of the input findings list, the truncated output is byte-identical. (Catches a mutation that uses a hash-set or relies on dict ordering.)
  • [ ] Pydantic discipline (both probes): SyftJsonSchema / GrypeJsonSchema (the thin Pydantic models over the tool's stdout JSON) declare model_config = ConfigDict(extra="allow") — the tool's output evolution is outside our control; we tolerate unknown fields. The emitted slice (the JSON schemas under src/codegenie/schema/probes/layer_c/) has additionalProperties: false; the slice's Pydantic model (if any) declares extra="forbid". A unit test asserts the asymmetry: SyftJsonSchema.model_config["extra"] == "allow" AND the emitted-slice schema has "additionalProperties": false. The asymmetry is documented in each module's docstring.
  • [ ] AST audit — sibling-slice access discipline. Both probe modules import read_raw_slices from its sanctioned module (S4-01) and raw_dir from codegenie.output.paths; an AST walk asserts no other disk-IO path (no Path(...).glob(), no open(...) on raw-dir files, no json.loads(Path(...).read_text())) appears in either module. Mutation test: replacing the kernel call with an open-coded Path(...).read_text() flips this red.
  • [ ] AST audit — run_external_cli discipline. Both probe modules import run_external_cli from codegenie.exec; an AST walk asserts neither module imports run_allowlisted directly (only Layer C docker/strace callers do). Replace test_both_probes_use_run_external_cli_not_run_allowlisted (source-grep, bypassable by string concatenation) with this AST-walk audit per the S5-03 hardening precedent.
  • [ ] localv2.md §5.3 C3's trivy cross-validation is explicitly OUT of scope for Phase 2 — no trivy in ALLOWED_BINARIES, no cross_validated_with field emitted; the slice's scanner field is the literal "grype". Surface as a Phase-3+ follow-up in S8-04 if needed.
  • [ ] Both probes route through the writer chokepoint as RedactedSlice (S3-03). The findings[].metadata may carry package names that match secret patterns by coincidence (e.g., a package named aws-sdk is not a secret; the BLAKE3 entropy check + named-pattern check on SecretRedactor (S3-01) handles false-positive resistance). A test asserts a clean SBOM has secrets_redacted_count == 0.
  • [ ] Tool-missing path is gracefulsyft or grype missing causes the probe to emit ScannerSkipped(reason="tool_missing"); the gather completes; the CLI startup tool-readiness check (Phase 0) has already warned the operator. Coordinator continues per Phase 0 isolation; the cli exit code is unaffected.
  • [ ] Bad-JSON path: a fixture where syft (mocked) emits truncated JSON → ScannerFailed(reason="invalid_json", stderr_tail=…); the slice carries the failure; raw .codegenie/context/raw/syft-sbom.json is not written (or is overwritten with an empty file flagged as invalid_json — pick one and document; recommendation: do not write the malformed file, the artifact_uri is None in this case).
  • [ ] Image-digest invalidation: running SbomProbe against two different image-digest: values (same Dockerfile, different built image) produces two distinct cache entries (verified by integration test).
  • [ ] Both probes' modules ≤ 200 LOC excluding docstrings.
  • [ ] mypy --strict clean.
  • [ ] forbidden-patterns stays green — no model_construct; no direct subprocess; no run_allowlisted (Layer C scanners use run_external_cli — only docker/strace in S5-02 use run_allowlisted directly).
  • [ ] fence job stays green.

Implementation outline

  1. SbomProbe:
  2. Class attributes: name = "sbom", layer = "C", tier = "base", applies_to_tasks = ["*"], applies_to_languages = ["*"], requires: list[str] = ["runtime_trace"] (metadata-only — see notes).
  3. declared_inputs(repo_root)["Dockerfile", "image-digest:<resolved>"] (no scenarios.yaml — SBOM doesn't depend on it).
  4. run(snapshot, ctx):
    • slices = read_raw_slices(raw_dir(snapshot.root)) (kernel reuse — S4-01); look up slices.get(IndexName("runtime_trace")).
    • If absent / unparseable / outcome.kind != "ran" / built_image_digest is None → return ProbeOutput(schema_slice={..., "outcome": ScannerSkipped(reason="upstream_unavailable").model_dump()}, confidence="unavailable"). DO NOT invoke syft.
    • Else: build attempt: ScannerAttempt by checking ctx.tool_cache for syft; if missing → attempt = ToolMissing("syft"). Otherwise call run_external_cli("syft", argv) with 30 s timeout and wrap as ProcessExited(exit_code, stdout, stderr_tail).
    • outcome = _classify_syft_outcome(attempt) (pure helper; small match statement on the tagged-union; exhaustiveness via assert_never).
    • On ScannerRan: parse stdout via SyftJsonSchema.model_validate_json(stdout) (Pydantic smart constructor, extra="allow"; pin to syft v1.x output format — document in module docstring; the in-tree fixture IS the format pin for Phase 2).
    • Derive package_count, packages_by_source, os_packages_classification (table-driven over the apk/deb/rpm/etc. ecosystems), npm_packages_native_module_count (filter packages with metadata.type == "native" or matching .node artifact path).
    • Write <raw_dir>/sbom.json (typed slice, via writer chokepoint) AND <raw_dir>/syft-sbom.json (raw syft bytes, via writer chokepoint as RedactedSlice). On non-ScannerRan outcomes, only sbom.json is written.
    • Return ProbeOutput(schema_slice={...}, confidence=<derived>).
  5. CveProbe:
  6. Class attributes: name = "cve", layer = "C", tier = "base", applies_to_tasks = ["*"], applies_to_languages = ["*"], requires: list[str] = ["sbom"] (metadata-only).
  7. declared_inputs(repo_root)["image-digest:<resolved>"].
  8. run(snapshot, ctx):
    • slices = read_raw_slices(raw_dir(snapshot.root)); look up slices.get(IndexName("sbom")).
    • If absent / unparseable / outcome.kind != "ran" → return ScannerSkipped(reason="upstream_unavailable") + confidence="unavailable". DO NOT invoke grype.
    • Resolve the SBOM artifact path from the slice's artifact_uri; if the file is missing or empty → ScannerFailed(reason="sbom_artifact_missing", stderr_tail=<expected path>) + confidence="low".
    • Build attempt and call _classify_grype_outcome(attempt) — same shape as SbomProbe.
    • On ScannerRan: parse stdout via GrypeJsonSchema.model_validate_json(stdout); derive by_severity, by_source, top_findings (deterministic truncation per the _TOP_FINDINGS_N AC — severity desc, package name asc, CVE id asc).
    • Write <raw_dir>/cve.json (slice) AND <raw_dir>/grype-cves.json (raw) via writer chokepoint.
    • Return ProbeOutput(...).
  9. Sub-schemas — two JSON schemas, additionalProperties: false, referenced from repo-context.schema.json.
  10. Pydantic smart-constructor pattern — define SyftJsonSchema(BaseModel) and GrypeJsonSchema(BaseModel) thin Pydantic models for the subset of the tool's output we consume. Unknown fields are tolerated at the tool-schema layer (extra="allow" on the external schema — we don't control syft's evolution); but the slice schema we emit has extra="forbid". Document the asymmetry in module docstring.
  11. Testspytest-subprocess mocks run_external_cli; fixture stdout JSONs under tests/fixtures/syft/ and tests/fixtures/grype/.

TDD plan — red / green / refactor

Red:

  1. test_sbom_probe_class_attributes_pinned — asserts SbomProbe.name == "sbom", layer == "C", tier == "base", applies_to_tasks == ["*"], applies_to_languages == ["*"], requires == ["runtime_trace"] (literal class-attribute introspection, NOT registry). Failure mode: drift in any of the six attributes flips red.
  2. test_sbom_registry_entry_carries_heaviness_only — registry introspection asserts heaviness == "medium", runs_last is False, and NO requires key on the registry entry (mutation: passing requires=["runtime_trace"] to @register_probe(...) must fail at import time per 02-ADR-0003 Option D — the decorator does not accept the kwarg). Same shape as test_cve_registry_entry_carries_heaviness_only (Test 12).
  3. test_sbom_declared_inputs_literal — asserts SbomProbe().declared_inputs == ["Dockerfile", "image-digest:<resolved>"] (order, count, exact-string-equal). Mutation: replacing the token with "image-digest:resolved" flips red.
  4. test_sbom_reads_runtime_trace_via_read_raw_slices — patch read_raw_slices to return {IndexName("runtime_trace"): {"outcome": {"kind": "ran"}, "built_image_digest": "sha256:cafe…"}}; assert SbomProbe.run() calls read_raw_slices(raw_dir(snapshot.root)) exactly once (no other disk-IO observed via filesystem-spy). Mutation: open-coded Path(...).read_text() flips red.
  5. test_sbom_upstream_unavailable_table — parametrized over four absent-upstream sub-cases: (a) read_raw_slices returns {} (slice missing); (b) returns {IndexName("runtime_trace"): {"malformed": "yes"}} (unparseable); (c) returns slice with outcome.kind == "skipped"; (d) returns slice with outcome.kind == "ran" but built_image_digest is None. Each case → outcome.kind == "skipped", reason == "upstream_unavailable", confidence == "unavailable", AND run_external_cli was NOT called (mock-spy verifies zero invocations).
  6. test_sbom_tool_missing — Phase 0 tool_cache returns False for syft; assert outcome.kind == "skipped", reason == "tool_missing", confidence == "low".
  7. test_sbom_non_zero_exitrun_external_cli returns ProcessResult(exit_code=2, stdout="", stderr="syft: image not found"); assert outcome.kind == "failed", exit_code == 2, stderr_tail non-empty.
  8. test_sbom_invalid_jsonrun_external_cli returns truncated JSON; assert outcome.kind == "failed", reason == "invalid_json".
  9. test_sbom_happy_path — fixture syft JSON under tests/fixtures/syft/hello-world.json; assert outcome.kind == "ran", package_count == <expected>, packages_by_source["apk"] == <expected>, etc.
  10. test_sbom_two_files_written_on_happy_path — happy path; assert both <raw_dir>/sbom.json (typed slice) AND <raw_dir>/syft-sbom.json (raw bytes) exist. Assert sbom.json validates against the slice sub-schema; syft-sbom.json is byte-identical to the fixture.
  11. test_sbom_no_raw_artifact_on_invalid_json — invalid-JSON path; assert sbom.json IS written (carries the failure outcome) but syft-sbom.json is NOT written; assert slice artifact_uri is None.
  12. test_cve_probe_class_attributes_pinned — asserts CveProbe.requires == ["sbom"] AND CveProbe.name == "cve" etc. (literal class-attribute introspection).
  13. test_cve_registry_entry_carries_heaviness_only — same shape as Test 2 for the CveProbe registry entry.
  14. test_cve_declared_inputs_literal — asserts CveProbe().declared_inputs == ["image-digest:<resolved>"].
  15. test_cve_reads_sbom_via_read_raw_slices — same shape as Test 4; mutation-resistant.
  16. test_cve_upstream_unavailable_table — same four-case parametrization as Test 5, against the sbom upstream.
  17. test_cve_sbom_artifact_missing — fixture: sbom.json slice with outcome.kind == "ran" and artifact_uri = "<path-that-does-not-exist>"; assert outcome.kind == "failed", reason == "sbom_artifact_missing", confidence == "low"; assert grype was NOT invoked.
  18. test_cve_tool_missinggrype not in tool_cache; outcome.kind == "skipped", reason == "tool_missing".
  19. test_cve_non_zero_exit — same shape as Test 7.
  20. test_cve_invalid_json — truncated JSON.
  21. test_cve_happy_path — fixture grype JSON under tests/fixtures/grype/hello-world.json; assert total, by_severity, by_source populated; scanner == "grype".
  22. test_cve_no_trivy_field — assert the slice does NOT have cross_validated_with (Phase 2 scope guard).
  23. test_cve_top_findings_constant_and_deterministic_truncation_TOP_FINDINGS_N is Final[int] == 20; given a fixture with 50 findings in randomized order, the truncated top_findings is byte-identical to the truncation under a permuted input order. Hypothesis property: for any permutation of the 50-finding list, the truncated 20 are exactly the same set in the same order.
  24. test_cve_two_files_written_on_happy_path — happy path; assert both <raw_dir>/cve.json AND <raw_dir>/grype-cves.json exist; cve.json validates against the slice sub-schema.
  25. test_ast_audit_sibling_slice_access_uses_read_raw_slices — AST-walk both sbom.py and cve.py; assert each module imports read_raw_slices from its sanctioned module; assert no Path(...).glob, open(...) on raw_dir, or json.loads(Path(...).read_text()) appears in either module (catches an open-coded re-implementation that string-equal source-grep would miss).
  26. test_ast_audit_run_external_cli_not_run_allowlisted — AST-walk both modules; assert each imports run_external_cli from codegenie.exec; assert neither imports run_allowlisted from any path (mutation: re-importing run_allowlisted for "just one" call site flips red).
  27. test_pydantic_extra_asymmetry — assert SyftJsonSchema.model_config["extra"] == "allow" AND GrypeJsonSchema.model_config["extra"] == "allow" AND the emitted slice JSON schemas (sbom.schema.json, cve.schema.json) have "additionalProperties": false. Documents the deliberate asymmetry per the AC.
  28. test_image_digest_invalidation_two_distinct_cache_entries — integration test: two probe runs with two distinct image-digest: resolver returns; assert Phase 0 Cache stores two distinct cache entries.
  29. test_classify_syft_outcome_property_total_function — Hypothesis property: for any ScannerAttempt (drawn from ToolMissing | ProcessExited(exit_code ∈ ints, stdout ∈ texts, stderr_tail ∈ texts)), _classify_syft_outcome(attempt) returns one of the three ScannerOutcome variants and NEVER raises. Same shape for _classify_grype_outcome.
  30. test_classify_outcomes_are_pure — Hypothesis property: same input → same output across 200 draws; the helpers have no side effects (asserted by spying on os, Path, subprocess modules — zero calls during classification).
  31. test_mutation_resistance_table — parametrized over 6+ intentionally-wrong implementation stubs (e.g., "always return ScannerRan regardless of exit code", "skip the tool-cache check", "swap ScannerFailed for ScannerSkipped", "drop the image-digest:<resolved> token from declared_inputs", "truncate top_findings by pop(0) instead of sort-then-slice", "open-coded Path(...).read_text() instead of read_raw_slices"); assert each stub fails at least one named test from Tests 1–30. Mirrors S5-03 hardening Test 16.
  32. Sub-schema rejection tests per probe (assert additionalProperties: false rejects an unknown top-level field).

Green:

  1. Implement both probes per the implementation outline.
  2. Mock run_external_cli via pytest-subprocess or a simple sniffer; never invoke real syft/grype in unit tests.

Refactor:

  1. Extract the "outcome decision tree" into a pure helper per probe — _classify_syft_outcome(attempt: ScannerAttempt) -> ScannerOutcome; _classify_grype_outcome(attempt: ScannerAttempt) -> ScannerOutcome. The ScannerAttempt is a tagged-union sum type: ScannerAttempt = ToolMissing | ProcessExited(exit_code: int, stdout: str, stderr_tail: str) | SbomArtifactMissing (the last variant is CveProbe-only; SbomProbe's classifier never sees it). The classifier is a small match with assert_never on the unreachable branch — exhaustiveness enforced by mypy --warn-unreachable. Per Rule 2: if the tagged-union shape pushes either module past 200 LOC, fall back to the (tool_present: bool, process_result: ProcessResult | None) primitive-input shape and document the trade-off in the module docstring — the AC for total-function classification (Test 29) holds regardless of input shape.
  2. Resist the temptation to extract a shared _run_scanner_and_classify helper across the two — Rule of Three (final-design.md row 7); two scanners with the same pattern is fine duplication. Rule-of-Three trigger recorded: when S6 lands SemgrepProbe + GitleaksProbe (3rd + 4th JSON-scanner consumers), the duplication crosses the threshold and the kernel extract becomes mandatory at that point — capture the extract in S6-06 / S6-07's story drafts, not in this story.
  3. Confirm both modules ≤ 200 LOC excluding docstrings.
  4. Annotate every module-level constant as Final[…] (e.g., _TOP_FINDINGS_N: Final[int] = 20, _SYFT_TIMEOUT_S: Final[int] = 30, _GRYPE_TIMEOUT_S: Final[int] = 30) — mypy strictness backstop.

Files to touch

  • New: src/codegenie/probes/layer_c/sbom.py, src/codegenie/probes/layer_c/cve.py.
  • New schemas: src/codegenie/schema/probes/layer_c/sbom.schema.json, src/codegenie/schema/probes/layer_c/cve.schema.json (both with "additionalProperties": false).
  • New tests: tests/unit/probes/layer_c/test_sbom.py, tests/unit/probes/layer_c/test_cve.py, tests/integration/probes/layer_c/test_sbom_cve_chain.py (the image-digest invalidation chain test + the AST audits + the mutation-resistance table + the Hypothesis property tests for classifier totality and purity).
  • New fixtures: tests/fixtures/syft/{hello_world,truncated,empty}.json, tests/fixtures/grype/{hello_world,truncated,no_findings}.json. The hello_world fixtures double as the Phase-2 syft/grype JSON-format pins (the in-tree pin discipline; see Notes for the implementer).

Out of scope

  • trivy cross-validation (localv2.md §5.3 C3 mentions it as optional secondary) — not in ALLOWED_BINARIES, no ADR; Phase-3+ follow-up.
  • The full Finding shape's evolution — S5-01's placeholder Finding Pydantic model is sufficient; this story emits CVE-finding-shaped metadata as findings[].metadata: dict[str, JSONValue].
  • image_digest_drift adversarial test — S5-05.
  • runtime_trace freshness check — S5-05.
  • adversarial_dockerfile container-hardening test — S5-06.
  • SemgrepProbe, GitleaksProbe, AstGrepProbe, RipgrepCuratedProbe, TestCoverageMapping — Step 6 (S6-06 / S6-07 / S6-08).

Notes for the implementer

  • requires is a class attribute, NOT a decorator kwarg, and is metadata-only. Per 02-ADR-0003 Option D, the @register_probe decorator accepts only heaviness + runs_last. requires: list[str] lives on the Probe ABC as a class attribute (S5-02's precedent: RuntimeTraceProbe.requires: list[str] = []). Phase 2's coordinator does NOT topologically sort by requires — Option C was explicitly rejected in 02-ADR-0003. Correctness for the SbomProbe → CveProbe ordering comes from the graceful absent-upstream path (read_raw_slices returns absent slice → ScannerSkipped(reason="upstream_unavailable")), NOT from dispatch ordering. If you find yourself wanting requires= to be load-bearing for dispatch, stop — that is a Phase 3+ amendment to ADR-0003, not a Phase 2 ad-hoc.
  • Sibling-slice access is disk-anchored via read_raw_slices(raw_dir(repo.root)). This is the S4-01 kernel; with the four S5-03 marker probes already consuming it, this story is the 5th–6th consumer. The kernel reuse AC + AST-walk test prevent re-implementation. The slice file under <raw_dir>/<probe_name>.json is the contract surface (e.g., <raw_dir>/runtime_trace.json is what RuntimeTraceProbe writes per S5-02 — confirm the exact filename matches the IndexName newtype stem when implementing; if S5-02 wrote runtime-trace.json (hyphenated), surface that drift in the PR and align with whichever filename IndexHealthProbe actually reads). The raw tool output (e.g., syft-sbom.json, grype-cves.json) is a SEPARATE file, pointed to by the slice's artifact_uri — it is consumed by downstream tools (grype reads syft-sbom.json), not by sibling probes.
  • Why run_external_cli (not direct run_allowlisted) for syft/grype but direct run_allowlisted for docker/strace (S5-02)? syft/grype are Go binaries that benefit from bubblewrap --unshare-net --ro-bind <repo> /work --bind <tmpdir> /tmp/probe on Linux (their stdout-JSON is the only thing we trust; their network access is paranoia-grade unneeded — they should not phone home). docker/strace cannot be bubblewrap-wrapped because Docker needs the daemon socket and strace needs PTRACE_ATTACH capability. This is final-design.md §"Tradeoffs accepted" — two subprocess pathways by design, named by the call site.
  • syft --quiet suppresses syft's TTY-progress noise on stderr. --metrics=off is a semgrep flag, not a syft flag — document the difference. grype has --quiet similarly. The 02-ADR-0001 prose says "--metrics=off on semgrep to refuse phone-home" — extend the spirit (refuse-phone-home) to syft/grype by ensuring no --telemetry-equivalent flag is enabled in the argv. (As of writing, syft/grype do not phone home by default; document the audit in module docstring.)
  • pip-audit does not cover syft/grype — they ship as system binaries (Go-built, not PyPI). The 02-ADR-0001 §Tradeoffs row "Eight new CVE feeds to follow" applies: host-hygiene concern delegated to OS package managers.
  • grype consumes the syft SBOM file via the sbom: prefix. Document in module docstring: grype sbom:./<path>.json -o json — the sbom: is a grype-specific URI scheme. Do not write the SBOM bytes to stdin (grype supports it but the file-path form is more auditable).
  • The image-digest:<resolved> token shared with RuntimeTraceProbe is the single most important cache-correctness story for Layer C. If a package.json-only change cache-HITs (image rebuilds with same digest) but the SBOM cache stays warm — that's the correct behavior. If a FROM-line bump cache-MISSES (new digest) and SBOM rebuilds — also correct. Mutating only Dockerfile (without rebuilding) cache-HITs syft/grype because the built image digest is unchanged; this is correct per 02-ADR-0004 (the signal we cache against is the resolved-built-image, not the source-Dockerfile-bytes).
  • SyftJsonSchema / GrypeJsonSchema Pydantic models are thin and extra="allow" (we don't control the tool's output evolution); the emitted slice schema is strict (additionalProperties: false, extra="forbid"). Asymmetric on purpose — document in module docstring.
  • top_findings truncation. grype can emit thousands of findings on a big image. Pick a constant _TOP_FINDINGS_N = 20 (configurable later); document the rationale (CLI summary readability; full list always available via artifact_uri raw JSON).
  • No Findings plaintext leak. grype finding metadata can include CVE descriptions that contain example secrets (rare but documented). The writer chokepoint (S3-03) + SecretRedactor (S3-01) catches these. A future maintainer must not bypass the chokepoint for "performance" — fixture test covers a fake CVE description with an AKIA… and asserts it is redacted in the persisted output.
  • additionalProperties: false on emitted sub-schemas is non-negotiable; rejection test per probe is the structural enforcement.
  • The two-probe shape (no shared base class, no ScannerRunner template-method) is final-design.md row 7's deliberate stance. Resist refactoring "obvious duplication" into a shared helper unless you can name a third use case (Rule of Three). Two scanners with similar shape is fine.
  • Rule-of-Three extraction trigger (recorded for S6 authors): when SemgrepProbe (S6-06) and GitleaksProbe (S6-07) land — both are JSON-emitting subprocess scanners with the same tool-cache → run_external_cli → Pydantic-smart-constructor → ScannerOutcome shape — the duplication count crosses to 4. At that point, kernel extraction (_run_scanner_and_classify or a JsonScanner mixin/protocol) becomes mandatory per S5-03's D2 precedent (read_raw_slices was extracted at the 4th consumer). The extraction is a NEW MODULE under codegenie/probes/_shared/ — not an edit to the existing four scanner modules' public shape. Surface in S6-06's story header as a dependency on this trigger. Phase 2 ships duplicated; Phase 3 inherits the trigger or S6 lands it.
  • Tagged-union input to the classifier (ScannerAttempt) vs primitive inputs (Rule 2 trade-off). The Refactor step prefers _classify_syft_outcome(attempt: ScannerAttempt) because a match on the sum type with assert_never gives compile-time exhaustiveness (mypy --warn-unreachable will catch a future variant added but un-handled). The downside is a small (5–10 LOC) ScannerAttempt Pydantic discriminated-union definition per probe. If the LOC budget is tight, fall back to the (tool_present: bool, process_result: ProcessResult | None) primitive pair — the totality property test (Test 29) holds either way. Document the chosen shape in the module docstring; do NOT mix shapes between the two probes.
  • Finding.metadata: dict[str, JSONValue] is primitive-obsessed by intent in Phase 2. S5-01's Finding placeholder is a thin Pydantic model with metadata: dict[str, JSONValue]. CveProbe carries CVE descriptions, CVSS scores, and fix-availability flags inside that metadata bag. A typed CveMetadata sum type (e.g., CveMetadata = HasFix(fix_version: str) | NoFix | InvestigatingFix) is a Phase 3+ concern — Phase 3's deterministic recipe path is the first real consumer that would benefit from the discrimination. Resist the temptation to introduce it here; record the opportunity in S8-04's Phase-3 handoff.
  • SyftJsonSchema / GrypeJsonSchema are DIP boundaries. They are thin Pydantic models that decouple our slice schema from the tool's stdout evolution. extra="allow" at the tool-schema layer (forward-compat); extra="forbid" / additionalProperties: false at the emitted slice (backward-compat for our consumers). The asymmetry IS the boundary. Document in module docstrings; the unit test (Test 27) is the structural backstop.
  • The "syft v1.x output format" pin. Phase 2 has no integration test against a live syft binary (the unit tests mock run_external_cli). The in-tree fixture (tests/fixtures/syft/hello-world.json) IS the format pin for Phase 2. If syft ever ships a v2 with a different JSON shape, the SyftJsonSchema smart constructor will fail at the fixture parse — that's the alert. A Phase 3+ matrix CI job (against a real syft binary in a container) can elevate this to a runtime pin; for now, the fixture is enough.