diff --git a/.scafld/receipts/verify-review-coverage-parity.json b/.scafld/receipts/verify-review-coverage-parity.json new file mode 100644 index 00000000..49f41838 --- /dev/null +++ b/.scafld/receipts/verify-review-coverage-parity.json @@ -0,0 +1,107 @@ +{ + "body": { + "schema_version": 1, + "task_id": "verify-review-coverage-parity", + "session_id": "verify-review-coverage-parity", + "verdict": "pass", + "snapshot_mode": "base_delta", + "base_ref": "3be719f89bf90c50e2242d108faf0ff3efb37e7f", + "base_commit": "3be719f89bf90c50e2242d108faf0ff3efb37e7f", + "head_commit": "3be719f89bf90c50e2242d108faf0ff3efb37e7f", + "scope": [ + "internal/adapters/cli/finalize/run.go", + "internal/adapters/cli/finalize/run_test.go", + "internal/adapters/cli/verify/verify.go", + "internal/adapters/cli/verify/verify_test.go", + "internal/adapters/providers/evidence_sandbox.go", + "internal/core/reviewevidence/evidence.go" + ], + "tree_sha": "8cf3965171bafd13311d1f0dd4e725ff0df7a197", + "file_digests": { + "internal/adapters/cli/finalize/run.go": "593e4edf77f171f86ef21d008b99b53b8ad5e7afbc8d7edbe089b1fafeea3858", + "internal/adapters/cli/finalize/run_test.go": "22423347234cc7b1e0a913b70eda40470d65d99ecfe5313cdf1830eb651323af", + "internal/adapters/cli/verify/verify.go": "211cb78da6c0657aef62847deb74875cb8980b56755f4e528862a1454a8dfff5", + "internal/adapters/cli/verify/verify_test.go": "cdb272c19fe5156afd5bb44ce4be00a5b331c95fff6422f9b44cf90a017b9c15", + "internal/adapters/providers/evidence_sandbox.go": "f4bff75e90928c0832ed9827ba83e91194a4817b0fa551a7d447f41b930b19f4", + "internal/core/reviewevidence/evidence.go": "6b6e9c31b49b2560c85ef84ba47edae2143a5604ad1ff698acb20f8fb90170ba" + }, + "ignored_unreviewed": [], + "reviewed_context_provenance": [ + { + "kind": "evidence_file", + "path": "internal/adapters/cli/finalize/run.go", + "sha256": "593e4edf77f171f86ef21d008b99b53b8ad5e7afbc8d7edbe089b1fafeea3858" + }, + { + "kind": "evidence_file", + "path": "internal/adapters/cli/finalize/run_test.go", + "sha256": "22423347234cc7b1e0a913b70eda40470d65d99ecfe5313cdf1830eb651323af" + }, + { + "kind": "evidence_file", + "path": "internal/adapters/cli/verify/verify.go", + "sha256": "211cb78da6c0657aef62847deb74875cb8980b56755f4e528862a1454a8dfff5" + }, + { + "kind": "evidence_file", + "path": "internal/adapters/cli/verify/verify_test.go", + "sha256": "cdb272c19fe5156afd5bb44ce4be00a5b331c95fff6422f9b44cf90a017b9c15" + }, + { + "kind": "evidence_file", + "path": "internal/adapters/providers/evidence_sandbox.go", + "sha256": "f4bff75e90928c0832ed9827ba83e91194a4817b0fa551a7d447f41b930b19f4" + }, + { + "kind": "evidence_file", + "path": "internal/core/reviewevidence/evidence.go", + "sha256": "6b6e9c31b49b2560c85ef84ba47edae2143a5604ad1ff698acb20f8fb90170ba" + } + ], + "reviewer": { + "provider": "claude", + "model": "claude-opus-5[1m]" + }, + "host_under_review": { + "agent": "codex", + "session_id": "verify-review-coverage-parity" + }, + "independence": { + "level": "cross_vendor", + "distinct": true, + "reason": "cross_vendor: reviewer \"claude\" and host \"codex\" are different model vendors; this is multi-model review that reduces correlated blind spots but remains single-party local tooling" + }, + "spec_fingerprint": "7773fdbfc8fc7725a520d0a316fff287e9811f6699735b6c8ef9bacb534f91f9", + "acceptance_declared": true, + "acceptance": [ + { + "id": "ac1", + "command": "make check", + "expected_kind": "exit_code_zero", + "status": "pass", + "reason": "exit code was 0", + "output_sha256": "492aa450ed14c030868b30f86d014d5396f32b039d18ca204533f7817d94b050", + "diagnostic": "/Users/kam/dev/0state/scafld/.scafld/runs/verify-review-coverage-parity/artifacts/commands/ac1-1788839146855016000.txt" + } + ], + "open_blockers": [], + "mutation_guard": { + "status": "clean", + "scope": [ + "internal/adapters/cli/finalize/run.go", + "internal/adapters/cli/finalize/run_test.go", + "internal/adapters/cli/verify/verify.go", + "internal/adapters/cli/verify/verify_test.go", + "internal/adapters/providers/evidence_sandbox.go", + "internal/core/reviewevidence/evidence.go" + ] + }, + "ledger_head": "7a619f6a55637676262ecb7c34ba0698f789fed416e3a82d5f4d67c44e4d7ac2", + "minted_at": "2026-09-08T03:45:44Z" + }, + "signature": { + "alg": "ed25519", + "key_id": "ed25519:RGGe6gUQpsB6KGu0VcnMxGUL5IA1I1VDSKi3ecCzYOU", + "sig": "7eNBj6ErdMGxGy5/CyV1IEVzL0eS565zahnZ1EJBQf5GeXONgwhUdyp2f272T2qA3G8AOVU8GqNqoPsXWf+LDQ==" + } +} diff --git a/internal/adapters/cli/finalize/run.go b/internal/adapters/cli/finalize/run.go index a1d805d7..6264c44b 100644 --- a/internal/adapters/cli/finalize/run.go +++ b/internal/adapters/cli/finalize/run.go @@ -319,14 +319,14 @@ func snapshotReviewCoverage(snap appfinalize.Snapshot) ([]receipt.Provenance, [] provenance := make([]receipt.Provenance, 0, len(snap.Files)+len(snap.Deleted)) ignored := append([]string(nil), snap.IgnoredUnreviewed...) for _, file := range snap.Files { - if file.Status == "gitlink" || blocklistedEvidence(file.Path) { + if file.Status == "gitlink" || reviewevidence.PinnedEvidenceExclusionReason(file.Path) != "" { ignored = append(ignored, file.Path) continue } provenance = append(provenance, receipt.Provenance{Kind: "evidence_file", Path: file.Path, SHA256: file.SHA256}) } for _, path := range snap.Deleted { - if blocklistedEvidence(path) { + if reviewevidence.PinnedEvidenceExclusionReason(path) != "" { ignored = append(ignored, path) continue } @@ -655,14 +655,6 @@ func (a gateAcceptance) Evaluate(ctx context.Context, in appacceptance.EvaluateI return appacceptance.Evaluate(ctx, a.runner, in), nil } -func blocklistedEvidence(path string) bool { - switch filepath.Base(filepath.FromSlash(path)) { - case "CLAUDE.md", "AGENTS.md", "GEMINI.md": - return true - } - return strings.TrimSpace(path) == ".scafld/config.yaml" -} - func gateCriteria(model spec.Model, ledger session.Session) []appacceptance.Criterion { criteria := model.AllCriteria() out := make([]appacceptance.Criterion, 0, len(criteria)) diff --git a/internal/adapters/cli/finalize/run_test.go b/internal/adapters/cli/finalize/run_test.go index f6e0e9f6..b8187f33 100644 --- a/internal/adapters/cli/finalize/run_test.go +++ b/internal/adapters/cli/finalize/run_test.go @@ -16,6 +16,7 @@ import ( "testing" "time" + verifyadapter "github.com/nilstate/scafld/v2/internal/adapters/cli/verify" "github.com/nilstate/scafld/v2/internal/adapters/git" "github.com/nilstate/scafld/v2/internal/adapters/jsonstore" "github.com/nilstate/scafld/v2/internal/adapters/markdown" @@ -434,7 +435,12 @@ func TestCommittedBaseDeltaSealVerifiesAfterCommit(t *testing.T) { t.Fatalf("default base_ref = %q, want parent HEAD %q", baseRef, parent) } - out, trusted := mintTestReceipt(t, root, baseRef) + scope := []string{"file.txt", "AGENTS.md", "nested/CLAUDE.md", "GEMINI.md", ".scafld/config.yaml", "docs/AGENTS.md"} + if err := os.Remove(filepath.Join(root, "docs/AGENTS.md")); err != nil { + t.Fatal(err) + } + writeFinalizeFile(t, root, "AGENTS.md", "updated instructions\n") + out, trusted := mintTestReceipt(t, root, baseRef, scope) if out.Receipt == nil { t.Fatal("finalize did not mint a receipt") } @@ -451,13 +457,7 @@ func TestCommittedBaseDeltaSealVerifiesAfterCommit(t *testing.T) { finalizeRunGit(t, root, "add", "-A") finalizeRunGit(t, root, "commit", "-m", "seal") - ports := appverify.Ports{ - Snapshotter: finalizeVerifySnapshotter{git: git.Adapter{Root: root}}, - AcceptanceRunner: finalizeVerifyAcceptance{runner: process.Runner{}, root: root}, - AncestryChecker: git.Adapter{Root: root}, - SignatureVerifier: finalizeVerifySignature{}, - } - res, err := appverify.Run(ctx, *out.Receipt, trusted, appverify.Policy{TargetCommit: parent}, ports) + res, err := verifyMintedReceipt(t, root, *out.Receipt, trusted, parent) if err != nil { t.Fatal(err) } @@ -520,13 +520,7 @@ func TestFinalizeIgnoresOutOfScopeMutationDuringAcceptance(t *testing.T) { finalizeRunGit(t, root, "add", "-A") finalizeRunGit(t, root, "commit", "-m", "task plus ambient") head := strings.TrimSpace(finalizeGitOutput(t, root, "rev-parse", "HEAD")) - ports := appverify.Ports{ - Snapshotter: finalizeVerifySnapshotter{git: git.Adapter{Root: root}}, - AcceptanceRunner: finalizeVerifyAcceptance{runner: process.Runner{}, root: root}, - AncestryChecker: git.Adapter{Root: root}, - SignatureVerifier: finalizeVerifySignature{}, - } - res, err := appverify.Run(ctx, *out.Receipt, trusted, appverify.Policy{TargetCommit: head}, ports) + res, err := verifyMintedReceipt(t, root, *out.Receipt, trusted, head) if err != nil { t.Fatal(err) } @@ -560,7 +554,7 @@ func buildEvidence(ctx context.Context, g git.Adapter, treeSHA string, scope []s var ignored []string reviewable := make([]appfinalize.FileFact, 0, len(facts)) for _, fact := range facts { - if fact.Status == "gitlink" || blocklistedEvidence(fact.Path) { + if fact.Status == "gitlink" || reviewevidence.PinnedEvidenceExclusionReason(fact.Path) != "" { ignored = append(ignored, fact.Path) continue } @@ -585,7 +579,7 @@ func buildEvidence(ctx context.Context, g git.Adapter, treeSHA string, scope []s provenance = append(provenance, receipt.Provenance{Kind: "evidence_file", Path: fact.Path, SHA256: fact.SHA256, Bytes: len(data)}) } for _, path := range deleted { - if blocklistedEvidence(path) { + if reviewevidence.PinnedEvidenceExclusionReason(path) != "" { ignored = append(ignored, path) continue } @@ -947,77 +941,32 @@ func passingReviewEvidence(t *testing.T, root string, scope []string, baseRef st } } -type finalizeVerifySnapshotter struct{ git git.Adapter } - -func (s finalizeVerifySnapshotter) Snapshot(ctx context.Context, in appverify.SnapshotInput) (appverify.Snapshot, error) { - snap, err := s.git.Snapshot(ctx, git.SnapshotInput{Scope: in.Scope, BaseRef: in.BaseRef}) - if err != nil { - return appverify.Snapshot{}, err - } - digests := make(map[string]string, len(snap.FileDigests)) - for _, d := range snap.FileDigests { - digests[d.Path] = d.SHA256 - } - ignored := make([]string, 0, len(snap.IgnoredUnreviewed)) - for _, item := range snap.IgnoredUnreviewed { - ignored = append(ignored, item.Path) - } - return appverify.Snapshot{TreeSHA: snap.TreeSHA, BaseCommit: snap.BaseCommit, FileDigests: digests, Ignored: ignored}, nil -} - -type finalizeVerifyAcceptance struct { - runner appacceptance.Runner - root string -} - type finalizeAcceptanceFunc func(context.Context, appacceptance.EvaluateInput) (appacceptance.EvaluateOutput, error) func (f finalizeAcceptanceFunc) Evaluate(ctx context.Context, in appacceptance.EvaluateInput) (appacceptance.EvaluateOutput, error) { return f(ctx, in) } -func (a finalizeVerifyAcceptance) RunAcceptance(ctx context.Context, criteria []receipt.Acceptance) ([]appverify.AcceptanceResult, error) { - out := make([]appverify.AcceptanceResult, 0, len(criteria)) - for _, c := range criteria { - evaluated := appacceptance.Evaluate(ctx, a.runner, appacceptance.EvaluateInput{ - Criteria: []appacceptance.Criterion{{ID: c.ID, Command: c.Command, ExpectedKind: c.ExpectedKind}}, - WorkDir: a.root, - }) - if len(evaluated.Results) == 0 { - continue - } - result := evaluated.Results[0] - out = append(out, appverify.AcceptanceResult{ID: result.ID, Status: result.Status, ExitCode: result.ExitCode}) - } - return out, nil -} - -type finalizeVerifySignature struct{} - -func (finalizeVerifySignature) Verify(envelope receipt.Envelope, trusted trust.TrustedKeys) error { - key, err := trusted.ActiveKey(envelope.Signature.KeyID) - if err != nil { - return err - } - pub, err := key.PublicKeyBytes() - if err != nil { - return err - } - sig, err := base64.StdEncoding.DecodeString(envelope.Signature.Sig) +func verifyMintedReceipt(t *testing.T, root string, envelope receipt.Envelope, trusted trust.TrustedKeys, target string) (appverify.Result, error) { + t.Helper() + data, err := json.Marshal(envelope) if err != nil { - return err + t.Fatal(err) } - canonical, err := receipt.CanonicalBody(envelope.Body) + keys, err := trust.MarshalTrustedKeys(trusted) if err != nil { - return err - } - if !ed25519.Verify(ed25519.PublicKey(pub), canonical, sig) { - return errors.New("invalid signature") + t.Fatal(err) } - return nil + receiptPath := filepath.Join(root, ".scafld/receipts/verify-test.json") + keysPath := filepath.Join(root, ".scafld/trusted-keys.json") + writeFinalizeFile(t, root, ".scafld/receipts/verify-test.json", string(data)) + writeFinalizeFile(t, root, ".scafld/trusted-keys.json", string(keys)) + return verifyadapter.Run(context.Background(), verifyadapter.Options{ + Root: root, ReceiptPath: receiptPath, TrustedKeys: keysPath, Target: target, + }) } -func mintTestReceipt(t *testing.T, root string, baseRef string) (appfinalize.Output, trust.TrustedKeys) { +func mintTestReceipt(t *testing.T, root string, baseRef string, scope []string) (appfinalize.Output, trust.TrustedKeys) { t.Helper() keyPath, trusted := newFinalizeSigningKey(t) out, err := appfinalize.Run(context.Background(), @@ -1027,10 +976,10 @@ func mintTestReceipt(t *testing.T, root string, baseRef string) (appfinalize.Out appfinalize.Input{ TaskID: "base-delta-seal", SessionID: "base-delta-seal", - Scope: []string{"file.txt"}, + Scope: scope, BaseRef: baseRef, SpecFingerprint: "spec", - Review: passingReviewEvidence(t, root, []string{"file.txt"}, baseRef), + Review: passingReviewEvidence(t, root, scope, baseRef), HostUnderReview: receipt.HostUnderReview{Agent: "unknown"}, Criteria: []appacceptance.Criterion{{ID: "ac1", Command: "true", ExpectedKind: "exit_code_zero"}}, WorkDir: root, @@ -1075,6 +1024,10 @@ func initFinalizeRepo(t *testing.T) string { finalizeRunGit(t, root, "config", "user.name", "scafld") finalizeRunGit(t, root, "config", "user.email", "scafld@example.invalid") writeFinalizeFile(t, root, "file.txt", "before\n") + for _, name := range []string{"AGENTS.md", "nested/CLAUDE.md", "GEMINI.md", "docs/AGENTS.md"} { + writeFinalizeFile(t, root, name, "fixture instructions\n") + } + writeFinalizeFile(t, root, ".scafld/config.yaml", "version: \"1.0\"\n") finalizeRunGit(t, root, "add", "-A") finalizeRunGit(t, root, "commit", "-m", "base") return root diff --git a/internal/adapters/cli/verify/verify.go b/internal/adapters/cli/verify/verify.go index 2ea53367..ef237c23 100644 --- a/internal/adapters/cli/verify/verify.go +++ b/internal/adapters/cli/verify/verify.go @@ -22,6 +22,7 @@ import ( appverify "github.com/nilstate/scafld/v2/internal/app/verify" "github.com/nilstate/scafld/v2/internal/core/execution" "github.com/nilstate/scafld/v2/internal/core/receipt" + "github.com/nilstate/scafld/v2/internal/core/reviewevidence" "github.com/nilstate/scafld/v2/internal/core/runartifact" "github.com/nilstate/scafld/v2/internal/core/trust" "github.com/nilstate/scafld/v2/internal/platform/processguard" @@ -200,7 +201,12 @@ func snapshotForVerify(snapshot git.Snapshot) appverify.Snapshot { } for _, item := range snapshot.FileDigests { digests[item.Path] = item.SHA256 - if item.Status == "gitlink" { + if item.Status == "gitlink" || reviewevidence.PinnedEvidenceExclusionReason(item.Path) != "" { + ignoredSet[item.Path] = struct{}{} + } + } + for _, item := range snapshot.DeletedPaths { + if reviewevidence.PinnedEvidenceExclusionReason(item.Path) != "" { ignoredSet[item.Path] = struct{}{} } } diff --git a/internal/adapters/cli/verify/verify_test.go b/internal/adapters/cli/verify/verify_test.go index 977f6996..1c130066 100644 --- a/internal/adapters/cli/verify/verify_test.go +++ b/internal/adapters/cli/verify/verify_test.go @@ -33,7 +33,7 @@ func TestParseTarget(t *testing.T) { } } -func TestSnapshotForVerifyMarksGitlinksIgnoredForFinalizeParity(t *testing.T) { +func TestSnapshotForVerifyPreservesDigestsAndFinalizeExclusionParity(t *testing.T) { t.Parallel() snapshot := snapshotForVerify(gitadapter.Snapshot{ @@ -42,15 +42,29 @@ func TestSnapshotForVerifyMarksGitlinksIgnoredForFinalizeParity(t *testing.T) { FileDigests: []gitadapter.FileDigest{ {Path: "api", Status: "gitlink", SHA256: "api-sha"}, {Path: "README.md", Status: "modified", SHA256: "readme-sha"}, + {Path: "AGENTS.md", Status: "modified", SHA256: "agents-sha"}, + {Path: "nested/CLAUDE.md", Status: "modified", SHA256: "claude-sha"}, + {Path: "GEMINI.md", Status: "added", SHA256: "gemini-sha"}, + {Path: ".scafld/config.yaml", Status: "modified", SHA256: "config-sha"}, }, + DeletedPaths: []gitadapter.DeletedPath{{Path: "docs/AGENTS.md"}, {Path: "docs/ordinary.md"}}, IgnoredUnreviewed: []gitadapter.IgnoredPath{{Path: "secret.env", Reason: "ignored"}}, }) - if got, want := snapshot.Ignored, []string{"api", "secret.env"}; !equalStrings(got, want) { + if got, want := snapshot.Ignored, []string{".scafld/config.yaml", "AGENTS.md", "GEMINI.md", "api", "docs/AGENTS.md", "nested/CLAUDE.md", "secret.env"}; !equalStrings(got, want) { t.Fatalf("ignored = %v, want %v", got, want) } - if snapshot.FileDigests["api"] != "api-sha" || snapshot.FileDigests["README.md"] != "readme-sha" { - t.Fatalf("file digests = %v", snapshot.FileDigests) + wantDigests := map[string]string{ + "api": "api-sha", "README.md": "readme-sha", "AGENTS.md": "agents-sha", + "nested/CLAUDE.md": "claude-sha", "GEMINI.md": "gemini-sha", ".scafld/config.yaml": "config-sha", + } + if len(snapshot.FileDigests) != len(wantDigests) { + t.Fatalf("file digests = %v, want all present files retained", snapshot.FileDigests) + } + for path, want := range wantDigests { + if snapshot.FileDigests[path] != want { + t.Fatalf("file digest %s = %q, want %q", path, snapshot.FileDigests[path], want) + } } } diff --git a/internal/adapters/providers/evidence_sandbox.go b/internal/adapters/providers/evidence_sandbox.go index ffc3983f..36cdc845 100644 --- a/internal/adapters/providers/evidence_sandbox.go +++ b/internal/adapters/providers/evidence_sandbox.go @@ -129,13 +129,8 @@ func rejectBlocklistedEvidencePath(path string) error { if err != nil { return err } - base := filepath.Base(filepath.FromSlash(normalized)) - switch base { - case "CLAUDE.md", "AGENTS.md", "GEMINI.md": - return fmt.Errorf("agent instruction file cannot be pinned as evidence: %s", normalized) - } - if normalized == ".scafld/config.yaml" { - return fmt.Errorf("scafld local config cannot be pinned as evidence: %s", normalized) + if reason := reviewevidence.PinnedEvidenceExclusionReason(normalized); reason != "" { + return fmt.Errorf("%s: %s", reason, normalized) } return nil } diff --git a/internal/core/reviewevidence/evidence.go b/internal/core/reviewevidence/evidence.go index be4c6bd9..b5670662 100644 --- a/internal/core/reviewevidence/evidence.go +++ b/internal/core/reviewevidence/evidence.go @@ -51,6 +51,20 @@ type MaterialFile struct { SHA256 string } +// PinnedEvidenceExclusionReason classifies a normalized repository path that +// cannot be pinned as reviewer evidence. Present files still require signed +// content digests even when this returns an exclusion reason. +func PinnedEvidenceExclusionReason(name string) string { + switch path.Base(name) { + case "CLAUDE.md", "AGENTS.md", "GEMINI.md": + return "agent instruction file cannot be pinned as evidence" + } + if strings.TrimSpace(name) == ".scafld/config.yaml" { + return "scafld local config cannot be pinned as evidence" + } + return "" +} + // NormalizePath returns a slash-separated repository-relative path. func NormalizePath(raw string) (string, error) { value := strings.TrimSpace(strings.ReplaceAll(raw, "\\", "/"))