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¶
- phase-arch-design.md §"Component design" #5 (Layer G scanners —
SbomProbeandCveProbereuse this shape). - phase-arch-design.md §"Edge cases" rows 1, 2, 3, 13 — tool-missing, non-zero exit, invalid JSON, oversized payload.
- final-design.md §"Components" #5 — uniform Layer G scanner pattern; no shared
ScannerRunner. - final-design.md §"Design patterns applied" row 7 — "One file per Layer G scanner; no shared
ScannerRunnerabstraction" — SRP + Rule of Three. - localv2.md §5.3 C2 (SBOMProbe), §5.3 C3 (CVEProbe) — output slices.
- 02-ADR-0001 —
syft,grypeinALLOWED_BINARIES. - 02-ADR-0004 — image-digest token shared with
RuntimeTraceProbe. - High-level-impl.md §"Step 5" —
requires=["runtime_trace"]enforces dispatch order.
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.pyexists;class SbomProbe(Probe)decorated with@register_probe(heaviness="medium", runs_last=False)(decorator carries onlyheaviness+runs_lastper 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 onlyheaviness+runs_last(norequireskey) — a mutation that passesrequires=to the decorator must fail at import time. - [ ]
SbomProbe.declared_inputsliteral:["Dockerfile", "image-digest:<resolved>"](shared withRuntimeTraceProbeper 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 siblingruntime_traceslice viaread_raw_slices(raw_dir(snapshot.root))(the S4-01 kernel — the ONLY sanctioned sibling-slice reader; noctx.sibling_slices, no per-probe disk-IO duplication). The key isIndexName("runtime_trace"); the value is theruntime_traceslice's parsed JSON. If absent / unparseable /outcome.kind != "ran"/built_image_digest is None→ emitScannerSkipped(reason="upstream_unavailable")+confidence="unavailable"AND DO NOT INVOKEsyft. The defensive read is the correctness mechanism, NOT therequires=["runtime_trace"]class attribute (which is metadata-only per 02-ADR-0003). - [ ]
SbomProbe.run()invokesrun_external_cli("syft", [<image-ref>, "-o", "json", "--quiet"])(syft uses--quietto 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 fromruntime_trace.built_image_digestvia the same_image_ref_for_digest(digest)helper S5-02 uses (or its analogue — surface in Notes if the helper is not exported). - [ ]
SbomProbeoutcome variants (classified by a pure helper_classify_syft_outcome(attempt: ScannerAttempt) -> ScannerOutcomeover a tagged-union input — see Notes for the implementer): - Upstream unavailable (per the read_raw_slices AC above) →
ScannerSkipped(reason="upstream_unavailable")+confidence="unavailable". syftnot on$PATH(Phase 0tool_cachemiss) →ScannerSkipped(reason="tool_missing")+confidence="low".syftexits non-zero →ScannerFailed(exit_code, stderr_tail)+confidence="low".syftstdout is not valid JSON →ScannerFailed(reason="invalid_json", stderr_tail)+confidence="low". (TheScannerFailedvariant'skinddiscriminator distinguishes the cases —reasonis a Pydantic-validated subfield. If S5-01'sScannerFailedshape doesn't yet support areasonfield alongsideexit_code/stderr_tail, surface in "Notes for the implementer" — S5-01's "Acceptance criteria" already lists both.)syftexits 0 with valid JSON →ScannerRan(findings=[...])+ the slice'spackage_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. - [ ]
SbomProbeemits the slice schema subset fromlocalv2.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 undersrc/codegenie/schema/probes/layer_c/sbom.schema.jsonwithadditionalProperties: false. - [ ] Two raw files, one each for the slice and the tool output.
SbomProbewrites (a)<raw_dir>/sbom.json— the typed slice (theProbeOutput.schema_sliceserialized, includingoutcome,artifact_uri,built_image_digest,package_count, etc.) — this is the fileread_raw_slices(raw_dir(repo.root))reads keyed byIndexName("sbom"), the contract surfaceCveProbereads from and any futureIndexHealthProbesbomfreshness check (registered in S5-05 per S5-02'sruntime_traceprecedent) will read; AND (b)<raw_dir>/syft-sbom.json— the raw syft JSON bytes (the slice'sartifact_uripoints here;grypeconsumes this file via thesbom: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 isScannerFailed(reason="invalid_json", ...), onlysbom.json(the typed-outcome slice) is written; the malformedsyft-sbom.jsonis NOT written (artifact_uri isNone). - [ ]
src/codegenie/probes/layer_c/cve.pyexists;class CveProbe(Probe)decorated with@register_probe(heaviness="medium", runs_last=False)(decorator carries onlyheaviness+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 onlyheaviness+runs_last(norequireskey). - [ ]
CveProbe.declared_inputsliteral:["image-digest:<resolved>"]. A unit test asserts the literal token at exact string-equal level. - [ ]
CveProbe.run()reads the siblingsbomslice viaread_raw_slices(raw_dir(snapshot.root))keyed byIndexName("sbom"). If absent / unparseable /outcome.kind != "ran"→ emitScannerSkipped(reason="upstream_unavailable")+confidence="unavailable"AND DO NOT INVOKEgrype. ADDITIONALLY: if the slice saysoutcome.kind == "ran"but the rawsyft-sbom.jsonfile pointed to byartifact_uriis missing or empty on disk (e.g., concurrent gather, user deletion), emitScannerFailed(reason="sbom_artifact_missing", stderr_tail="<expected path>")+confidence="low"— never silently invokegrypeagainst a non-existent file path. - [ ]
CveProbe.run()invokesrun_external_cli("grype", ["sbom:<resolved-syft-sbom-path>", "-o", "json"])— thesbom:prefix is grype's documented way to consume a syft SBOM. The path is derived from thesbomslice'sartifact_urifield (NOT a hardcoded literal; the slice may live under a non-default<raw_dir>in tests). 30 s timeout. - [ ]
CveProbeoutcome variants (classified by a pure helper_classify_grype_outcome(attempt: ScannerAttempt) -> ScannerOutcome— same total-function discipline asSbomProbe): - 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). grypenot on$PATH→ScannerSkipped(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. - [ ]
CveProbeemits the slice schema subset fromlocalv2.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 undersrc/codegenie/schema/probes/layer_c/cve.schema.jsonwithadditionalProperties: false. - [ ] Two raw files (same shape as
SbomProbe):CveProbewrites (a)<raw_dir>/cve.json— the typed slice keyed byIndexName("cve")for sibling-slice consumers; (b)<raw_dir>/grype-cves.json— the raw grype JSON bytes (the slice'sartifact_uri). Failed/invalid-JSON path → onlycve.json(the slice with the failure outcome) is written; the malformedgrype-cves.jsonis NOT written;artifact_uriisNone. - [ ]
_TOP_FINDINGS_N: Final[int] = 20module constant incve.py.top_findingsis 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) declaremodel_config = ConfigDict(extra="allow")— the tool's output evolution is outside our control; we tolerate unknown fields. The emitted slice (the JSON schemas undersrc/codegenie/schema/probes/layer_c/) hasadditionalProperties: false; the slice's Pydantic model (if any) declaresextra="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_slicesfrom its sanctioned module (S4-01) andraw_dirfromcodegenie.output.paths; an AST walk asserts no other disk-IO path (noPath(...).glob(), noopen(...)on raw-dir files, nojson.loads(Path(...).read_text())) appears in either module. Mutation test: replacing the kernel call with an open-codedPath(...).read_text()flips this red. - [ ] AST audit —
run_external_clidiscipline. Both probe modules importrun_external_clifromcodegenie.exec; an AST walk asserts neither module importsrun_allowlisteddirectly (only Layer Cdocker/stracecallers do). Replacetest_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'strivycross-validation is explicitly OUT of scope for Phase 2 — notrivyinALLOWED_BINARIES, nocross_validated_withfield emitted; the slice'sscannerfield 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). Thefindings[].metadatamay carry package names that match secret patterns by coincidence (e.g., a package namedaws-sdkis not a secret; the BLAKE3 entropy check + named-pattern check onSecretRedactor(S3-01) handles false-positive resistance). A test asserts a clean SBOM hassecrets_redacted_count == 0. - [ ] Tool-missing path is graceful —
syftorgrypemissing causes the probe to emitScannerSkipped(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; thecliexit 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.jsonis not written (or is overwritten with an empty file flagged asinvalid_json— pick one and document; recommendation: do not write the malformed file, the artifact_uri isNonein this case). - [ ] Image-digest invalidation: running
SbomProbeagainst two differentimage-digest:values (same Dockerfile, different built image) produces two distinct cache entries (verified by integration test). - [ ] Both probes' modules ≤ 200 LOC excluding docstrings.
- [ ]
mypy --strictclean. - [ ]
forbidden-patternsstays green — nomodel_construct; no directsubprocess; norun_allowlisted(Layer C scanners userun_external_cli— onlydocker/stracein S5-02 userun_allowlisteddirectly). - [ ]
fencejob stays green.
Implementation outline¶
SbomProbe:- Class attributes:
name = "sbom",layer = "C",tier = "base",applies_to_tasks = ["*"],applies_to_languages = ["*"],requires: list[str] = ["runtime_trace"](metadata-only — see notes). declared_inputs(repo_root)→["Dockerfile", "image-digest:<resolved>"](noscenarios.yaml— SBOM doesn't depend on it).run(snapshot, ctx):slices = read_raw_slices(raw_dir(snapshot.root))(kernel reuse — S4-01); look upslices.get(IndexName("runtime_trace")).- If absent / unparseable /
outcome.kind != "ran"/built_image_digest is None→ returnProbeOutput(schema_slice={..., "outcome": ScannerSkipped(reason="upstream_unavailable").model_dump()}, confidence="unavailable"). DO NOT invokesyft. - Else: build
attempt: ScannerAttemptby checkingctx.tool_cacheforsyft; if missing →attempt = ToolMissing("syft"). Otherwise callrun_external_cli("syft", argv)with 30 s timeout and wrap asProcessExited(exit_code, stdout, stderr_tail). outcome = _classify_syft_outcome(attempt)(pure helper; smallmatchstatement on the tagged-union; exhaustiveness viaassert_never).- On
ScannerRan: parse stdout viaSyftJsonSchema.model_validate_json(stdout)(Pydantic smart constructor,extra="allow"; pin tosyftv1.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 withmetadata.type == "native"or matching.nodeartifact path). - Write
<raw_dir>/sbom.json(typed slice, via writer chokepoint) AND<raw_dir>/syft-sbom.json(raw syft bytes, via writer chokepoint asRedactedSlice). On non-ScannerRanoutcomes, onlysbom.jsonis written. - Return
ProbeOutput(schema_slice={...}, confidence=<derived>).
CveProbe:- Class attributes:
name = "cve",layer = "C",tier = "base",applies_to_tasks = ["*"],applies_to_languages = ["*"],requires: list[str] = ["sbom"](metadata-only). declared_inputs(repo_root)→["image-digest:<resolved>"].run(snapshot, ctx):slices = read_raw_slices(raw_dir(snapshot.root)); look upslices.get(IndexName("sbom")).- If absent / unparseable /
outcome.kind != "ran"→ returnScannerSkipped(reason="upstream_unavailable")+confidence="unavailable". DO NOT invokegrype. - 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
attemptand call_classify_grype_outcome(attempt)— same shape as SbomProbe. - On
ScannerRan: parse stdout viaGrypeJsonSchema.model_validate_json(stdout); deriveby_severity,by_source,top_findings(deterministic truncation per the_TOP_FINDINGS_NAC — 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(...).
- Sub-schemas — two JSON schemas,
additionalProperties: false, referenced fromrepo-context.schema.json. - Pydantic smart-constructor pattern — define
SyftJsonSchema(BaseModel)andGrypeJsonSchema(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 hasextra="forbid". Document the asymmetry in module docstring. - Tests —
pytest-subprocessmocksrun_external_cli; fixture stdout JSONs undertests/fixtures/syft/andtests/fixtures/grype/.
TDD plan — red / green / refactor¶
Red:
test_sbom_probe_class_attributes_pinned— assertsSbomProbe.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.test_sbom_registry_entry_carries_heaviness_only— registry introspection assertsheaviness == "medium",runs_last is False, and NOrequireskey on the registry entry (mutation: passingrequires=["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 astest_cve_registry_entry_carries_heaviness_only(Test 12).test_sbom_declared_inputs_literal— assertsSbomProbe().declared_inputs == ["Dockerfile", "image-digest:<resolved>"](order, count, exact-string-equal). Mutation: replacing the token with"image-digest:resolved"flips red.test_sbom_reads_runtime_trace_via_read_raw_slices— patchread_raw_slicesto return{IndexName("runtime_trace"): {"outcome": {"kind": "ran"}, "built_image_digest": "sha256:cafe…"}}; assert SbomProbe.run() callsread_raw_slices(raw_dir(snapshot.root))exactly once (no other disk-IO observed via filesystem-spy). Mutation: open-codedPath(...).read_text()flips red.test_sbom_upstream_unavailable_table— parametrized over four absent-upstream sub-cases: (a)read_raw_slicesreturns{}(slice missing); (b) returns{IndexName("runtime_trace"): {"malformed": "yes"}}(unparseable); (c) returns slice withoutcome.kind == "skipped"; (d) returns slice withoutcome.kind == "ran"butbuilt_image_digest is None. Each case →outcome.kind == "skipped",reason == "upstream_unavailable",confidence == "unavailable", ANDrun_external_cliwas NOT called (mock-spy verifies zero invocations).test_sbom_tool_missing— Phase 0tool_cachereturns False forsyft; assertoutcome.kind == "skipped",reason == "tool_missing",confidence == "low".test_sbom_non_zero_exit—run_external_clireturnsProcessResult(exit_code=2, stdout="", stderr="syft: image not found"); assertoutcome.kind == "failed",exit_code == 2,stderr_tailnon-empty.test_sbom_invalid_json—run_external_clireturns truncated JSON; assertoutcome.kind == "failed",reason == "invalid_json".test_sbom_happy_path— fixture syft JSON undertests/fixtures/syft/hello-world.json; assertoutcome.kind == "ran",package_count == <expected>,packages_by_source["apk"] == <expected>, etc.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. Assertsbom.jsonvalidates against the slice sub-schema;syft-sbom.jsonis byte-identical to the fixture.test_sbom_no_raw_artifact_on_invalid_json— invalid-JSON path; assertsbom.jsonIS written (carries the failure outcome) butsyft-sbom.jsonis NOT written; assert sliceartifact_uri is None.test_cve_probe_class_attributes_pinned— assertsCveProbe.requires == ["sbom"]ANDCveProbe.name == "cve"etc. (literal class-attribute introspection).test_cve_registry_entry_carries_heaviness_only— same shape as Test 2 for the CveProbe registry entry.test_cve_declared_inputs_literal— assertsCveProbe().declared_inputs == ["image-digest:<resolved>"].test_cve_reads_sbom_via_read_raw_slices— same shape as Test 4; mutation-resistant.test_cve_upstream_unavailable_table— same four-case parametrization as Test 5, against the sbom upstream.test_cve_sbom_artifact_missing— fixture:sbom.jsonslice withoutcome.kind == "ran"andartifact_uri = "<path-that-does-not-exist>"; assertoutcome.kind == "failed",reason == "sbom_artifact_missing",confidence == "low"; assertgrypewas NOT invoked.test_cve_tool_missing—grypenot intool_cache;outcome.kind == "skipped",reason == "tool_missing".test_cve_non_zero_exit— same shape as Test 7.test_cve_invalid_json— truncated JSON.test_cve_happy_path— fixture grype JSON undertests/fixtures/grype/hello-world.json; asserttotal,by_severity,by_sourcepopulated;scanner == "grype".test_cve_no_trivy_field— assert the slice does NOT havecross_validated_with(Phase 2 scope guard).test_cve_top_findings_constant_and_deterministic_truncation—_TOP_FINDINGS_NisFinal[int] == 20; given a fixture with 50 findings in randomized order, the truncatedtop_findingsis 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.test_cve_two_files_written_on_happy_path— happy path; assert both<raw_dir>/cve.jsonAND<raw_dir>/grype-cves.jsonexist;cve.jsonvalidates against the slice sub-schema.test_ast_audit_sibling_slice_access_uses_read_raw_slices— AST-walk bothsbom.pyandcve.py; assert each module importsread_raw_slicesfrom its sanctioned module; assert noPath(...).glob,open(...)onraw_dir, orjson.loads(Path(...).read_text())appears in either module (catches an open-coded re-implementation that string-equal source-grep would miss).test_ast_audit_run_external_cli_not_run_allowlisted— AST-walk both modules; assert each importsrun_external_clifromcodegenie.exec; assert neither importsrun_allowlistedfrom any path (mutation: re-importingrun_allowlistedfor "just one" call site flips red).test_pydantic_extra_asymmetry— assertSyftJsonSchema.model_config["extra"] == "allow"ANDGrypeJsonSchema.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.test_image_digest_invalidation_two_distinct_cache_entries— integration test: two probe runs with two distinctimage-digest:resolver returns; assert Phase 0Cachestores two distinct cache entries.test_classify_syft_outcome_property_total_function— Hypothesis property: for anyScannerAttempt(drawn fromToolMissing | ProcessExited(exit_code ∈ ints, stdout ∈ texts, stderr_tail ∈ texts)),_classify_syft_outcome(attempt)returns one of the threeScannerOutcomevariants and NEVER raises. Same shape for_classify_grype_outcome.test_classify_outcomes_are_pure— Hypothesis property: same input → same output across 200 draws; the helpers have no side effects (asserted by spying onos,Path,subprocessmodules — zero calls during classification).test_mutation_resistance_table— parametrized over 6+ intentionally-wrong implementation stubs (e.g., "always returnScannerRanregardless of exit code", "skip the tool-cache check", "swapScannerFailedforScannerSkipped", "drop theimage-digest:<resolved>token from declared_inputs", "truncatetop_findingsbypop(0)instead of sort-then-slice", "open-codedPath(...).read_text()instead ofread_raw_slices"); assert each stub fails at least one named test from Tests 1–30. Mirrors S5-03 hardening Test 16.- Sub-schema rejection tests per probe (assert
additionalProperties: falserejects an unknown top-level field).
Green:
- Implement both probes per the implementation outline.
- Mock
run_external_cliviapytest-subprocessor a simple sniffer; never invoke real syft/grype in unit tests.
Refactor:
- Extract the "outcome decision tree" into a pure helper per probe —
_classify_syft_outcome(attempt: ScannerAttempt) -> ScannerOutcome;_classify_grype_outcome(attempt: ScannerAttempt) -> ScannerOutcome. TheScannerAttemptis 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 smallmatchwithassert_neveron the unreachable branch — exhaustiveness enforced bymypy --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. - Resist the temptation to extract a shared
_run_scanner_and_classifyhelper 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 landsSemgrepProbe+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. - Confirm both modules ≤ 200 LOC excluding docstrings.
- 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. Thehello_worldfixtures double as the Phase-2 syft/grype JSON-format pins (the in-tree pin discipline; see Notes for the implementer).
Out of scope¶
trivycross-validation (localv2.md§5.3 C3 mentions it as optional secondary) — not inALLOWED_BINARIES, no ADR; Phase-3+ follow-up.- The full
Findingshape's evolution — S5-01's placeholderFindingPydantic model is sufficient; this story emits CVE-finding-shaped metadata asfindings[].metadata: dict[str, JSONValue]. image_digest_driftadversarial test — S5-05.runtime_tracefreshness check — S5-05.adversarial_dockerfilecontainer-hardening test — S5-06.SemgrepProbe,GitleaksProbe,AstGrepProbe,RipgrepCuratedProbe,TestCoverageMapping— Step 6 (S6-06 / S6-07 / S6-08).
Notes for the implementer¶
requiresis a class attribute, NOT a decorator kwarg, and is metadata-only. Per 02-ADR-0003 Option D, the@register_probedecorator accepts onlyheaviness+runs_last.requires: list[str]lives on theProbeABC as a class attribute (S5-02's precedent:RuntimeTraceProbe.requires: list[str] = []). Phase 2's coordinator does NOT topologically sort byrequires— Option C was explicitly rejected in 02-ADR-0003. Correctness for the SbomProbe → CveProbe ordering comes from the graceful absent-upstream path (read_raw_slicesreturns absent slice →ScannerSkipped(reason="upstream_unavailable")), NOT from dispatch ordering. If you find yourself wantingrequires=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>.jsonis the contract surface (e.g.,<raw_dir>/runtime_trace.jsonis whatRuntimeTraceProbewrites per S5-02 — confirm the exact filename matches theIndexNamenewtype stem when implementing; if S5-02 wroteruntime-trace.json(hyphenated), surface that drift in the PR and align with whichever filenameIndexHealthProbeactually reads). The raw tool output (e.g.,syft-sbom.json,grype-cves.json) is a SEPARATE file, pointed to by the slice'sartifact_uri— it is consumed by downstream tools (grypereadssyft-sbom.json), not by sibling probes. - Why
run_external_cli(not directrun_allowlisted) forsyft/grypebut directrun_allowlistedfordocker/strace(S5-02)?syft/grypeare Go binaries that benefit frombubblewrap --unshare-net --ro-bind <repo> /work --bind <tmpdir> /tmp/probeon Linux (their stdout-JSON is the only thing we trust; their network access is paranoia-grade unneeded — they should not phone home).docker/stracecannot bebubblewrap-wrapped because Docker needs the daemon socket and strace needsPTRACE_ATTACHcapability. This is final-design.md §"Tradeoffs accepted" — two subprocess pathways by design, named by the call site. syft --quietsuppresses syft's TTY-progress noise on stderr.--metrics=offis asemgrepflag, not asyftflag — document the difference.grypehas--quietsimilarly. The 02-ADR-0001 prose says "--metrics=offonsemgrepto refuse phone-home" — extend the spirit (refuse-phone-home) tosyft/grypeby 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-auditdoes not coversyft/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.grypeconsumes the syft SBOM file via thesbom:prefix. Document in module docstring:grype sbom:./<path>.json -o json— thesbom: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 withRuntimeTraceProbeis the single most important cache-correctness story for Layer C. If apackage.json-only change cache-HITs (image rebuilds with same digest) but the SBOM cache stays warm — that's the correct behavior. If aFROM-line bump cache-MISSES (new digest) and SBOM rebuilds — also correct. Mutating onlyDockerfile(without rebuilding) cache-HITssyft/grypebecause 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/GrypeJsonSchemaPydantic models are thin andextra="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_findingstruncation.grypecan 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 viaartifact_uriraw JSON).- No
Findingsplaintext leak.grypefinding 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 anAKIA…and asserts it is redacted in the persisted output. additionalProperties: falseon emitted sub-schemas is non-negotiable; rejection test per probe is the structural enforcement.- The two-probe shape (no shared base class, no
ScannerRunnertemplate-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) andGitleaksProbe(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_classifyor aJsonScannermixin/protocol) becomes mandatory per S5-03's D2 precedent (read_raw_sliceswas extracted at the 4th consumer). The extraction is a NEW MODULE undercodegenie/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 amatchon the sum type withassert_nevergives compile-time exhaustiveness (mypy--warn-unreachablewill catch a future variant added but un-handled). The downside is a small (5–10 LOC)ScannerAttemptPydantic 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'sFindingplaceholder is a thin Pydantic model withmetadata: dict[str, JSONValue]. CveProbe carries CVE descriptions, CVSS scores, and fix-availability flags inside that metadata bag. A typedCveMetadatasum 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/GrypeJsonSchemaare 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: falseat 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. Ifsyftever ships a v2 with a different JSON shape, theSyftJsonSchemasmart 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.