From 4e05d3e089df2fffe171d5620730bef3bf9b270b Mon Sep 17 00:00:00 2001 From: iperev Date: Fri, 31 Jul 2026 11:13:12 +0200 Subject: [PATCH 1/2] fix: enforce release predecessor lineage --- .github/workflows/release.yml | 29 ++-- docs/release-process.md | 26 +++- .../proofkit-supply-chain-quality/overview.md | 5 +- .../requirements.v1.json | 4 +- internal/tools/coveragemetrics/main.go | 9 ++ internal/tools/coveragemetrics/main_test.go | 2 + internal/tools/releasechange/record_test.go | 91 ++++-------- internal/tools/releasepreflight/main.go | 70 ++++++++- internal/tools/releasepreflight/main_test.go | 138 ++++++++++++++++++ package-lock.json | 4 +- package.json | 2 +- proofkit/requirement-bindings.json | 36 ++++- release/change-record.v2.json | 120 ++------------- .../validate-self-hosting-receipts_test.go | 52 ++++++- scripts/workflow_package_gate_oracle_test.go | 2 +- 15 files changed, 396 insertions(+), 194 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index fb11ee8..1741220 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -117,6 +117,7 @@ jobs: go run ./internal/tools/releasepreflight npm-existing \ --expected-json "$metadata" \ --actual-file /tmp/proofkit-candidate-view.json + lineage_state="existing_byte_match" node - "$metadata" "$filename" "$report" <<'NODE' const { execFileSync } = require("node:child_process"); const { writeFileSync } = require("node:fs"); @@ -132,17 +133,25 @@ jobs: .sort((left, right) => left.path.localeCompare(right.path)); writeFileSync(report, `${JSON.stringify({ [metadata.name]: { ...metadata, files } }, null, 2)}\n`); NODE - continue - fi - if ! grep -Eq 'E404|404 Not Found|Not found' /tmp/proofkit-candidate-view.err; then - cat /tmp/proofkit-candidate-view.err >&2 - exit 1 + else + if ! grep -Eq 'E404|404 Not Found|Not found' /tmp/proofkit-candidate-view.err; then + cat /tmp/proofkit-candidate-view.err >&2 + exit 1 + fi + npm publish "artifacts/package/${filename}" \ + --dry-run \ + --json \ + --access public \ + --registry="${REGISTRY_URL}" > "$report" + lineage_state="unpublished" fi - npm publish "artifacts/package/${filename}" \ - --dry-run \ - --json \ - --access public \ - --registry="${REGISTRY_URL}" > "$report" + npm view "${package_name}@latest" name version --json --registry="${REGISTRY_URL}" > /tmp/proofkit-latest-view.json + go run ./internal/tools/releasepreflight npm-lineage \ + --change-record-file release/change-record.v2.json \ + --latest-file /tmp/proofkit-latest-view.json \ + --expected-name "$package_name" \ + --candidate-version "$package_version" \ + --candidate-state "$lineage_state" done < artifacts/publish/publish-order.txt node <<'NODE' const { readFileSync, readdirSync, writeFileSync } = require("node:fs"); diff --git a/docs/release-process.md b/docs/release-process.md index 0c2b663..9dd3dd0 100644 --- a/docs/release-process.md +++ b/docs/release-process.md @@ -46,7 +46,20 @@ In a source checkout, the committed `release/change-record.v2.json` owns the reviewed, version-bound declaration of the public-contract delta, migration decision, platform requirements, known limitations, and rollback strategy. It is not part of the installed npm or PyPI projection and does not infer change -completeness from the source diff. The repository-owned +completeness from the source diff. + +Release-history migration support starts at the baseline named by the first +supporting release record. The exact baseline remains in that source-only, +immutable release evidence rather than this package-public document. The +repository does not backfill a machine release-history chain for earlier +pre-baseline development releases. Each later change record in its released +source commit owns exactly one edge from `previousVersion` to `version`. A +future cumulative migration plan may be derived only from a contiguous, +owner-admitted sequence of those immutable release records; it must not become +a second authored source of truth or infer migration semantics from a source +diff. No cumulative release-history planner is currently claimed. + +The repository-owned `release:manifest` tool admits that record and creates `release-manifest.json`, `checksums.sha256`, `metadata-checksums.sha256`, `sbom-subjects.sha256`, release notes, and deterministic SBOM candidate evidence from explicit package, @@ -120,6 +133,13 @@ coverage metrics generation. The dry-run package identity proves candidate tarball shape only. It does not prove the bytes served by the registry after publish. +Candidate preflight also admits the npm registry `latest` identity. For an +unpublished candidate, `latest` must equal the change record +`previousVersion`. For an already-published candidate, exact candidate-to- +registry byte equality must be proven first and `latest` must equal the change +record `version`. This separates a new release edge from an idempotent replay +and rejects skipped or stale predecessor chains. + ## Publish Create and push an exact version tag: @@ -134,7 +154,9 @@ The `release` workflow must: 1. verify source package identity; 2. run the package gate; 3. build publish candidate evidence through either npm publish dry-run or - exact existing-byte-match validation for an already published version; + exact existing-byte-match validation for an already published version, then + bind the branch-specific candidate state and admitted change record to the + exact npm `latest` package identity; 4. build Python wheel candidates for the same embedded Go CLI; 5. prove publish readiness before any registry side effect: the tag must equal `v`, target a commit reachable from `main`, and have diff --git a/docs/specs/proofkit-supply-chain-quality/overview.md b/docs/specs/proofkit-supply-chain-quality/overview.md index ebcadaa..d75f1d0 100644 --- a/docs/specs/proofkit-supply-chain-quality/overview.md +++ b/docs/specs/proofkit-supply-chain-quality/overview.md @@ -160,8 +160,9 @@ vulnerability absence, or consumer rollout safety by itself. exact complete current breaking, addition, and migration inventories including channel-specific generated continuation bytes, rejects missing, substituted, reordered, or surplus entries, and owns one independently - authored byte-exact complete current release-note projection, while one - retained- + authored byte-exact complete current release-note projection. Candidate + preflight binds npm latest to `previousVersion` for an unpublished candidate + or to `version` only after exact existing-byte-match proof. One retained- evidence owner builds and verifies checksums against exact downloadable artifact-relative paths without inferring change completeness from the source diff. diff --git a/docs/specs/proofkit-supply-chain-quality/requirements.v1.json b/docs/specs/proofkit-supply-chain-quality/requirements.v1.json index df5c6d2..9075e8f 100644 --- a/docs/specs/proofkit-supply-chain-quality/requirements.v1.json +++ b/docs/specs/proofkit-supply-chain-quality/requirements.v1.json @@ -311,12 +311,12 @@ { "requirementId": "REQ-PROOFKIT-QUALITY-024", "ownerId": "proofkit.supply-chain-quality", - "invariant": "Release metadata generation admits one closed schema-versioned machine-readable declaration of the reviewed public-contract change set, requires exact ordered equality for the complete current breaking-change, addition, and migration inventories, including channel-specific changes to public generated continuation bytes, binds its exact previous and current canonical SemVer values to a compatible or breaking change class, rejects missing, substituted, reordered, or surplus current entries plus non-monotonic or patch-range breaking releases, and renders one byte-exact independently authored complete current release-note projection with no relocated, duplicate, appended, surplus, or second owned section, while one repository-owned retained-evidence builder and verifier checksum the exact final downloadable artifact topology with artifact-relative paths, reject unbound evidence files and symlink substitution, and fail release closeout on record, note, path, or digest drift.", + "invariant": "Release metadata generation admits one closed schema-versioned machine-readable declaration of the reviewed public-contract change set, requires exact ordered equality for the complete current breaking-change, addition, and migration inventories, including channel-specific changes to public generated continuation bytes, binds its exact previous and current canonical SemVer values to a compatible or breaking change class, rejects missing, substituted, reordered, or surplus current entries plus non-monotonic or patch-range breaking releases, and renders one byte-exact independently authored complete current release-note projection with no relocated, duplicate, appended, surplus, or second owned section; release candidate preflight binds the admitted npm package identity and candidate state to registry latest so an unpublished candidate requires latest to equal previousVersion and an exact existing-byte-match candidate requires latest to equal version; one repository-owned retained-evidence builder and verifier checksum the exact final downloadable artifact topology with artifact-relative paths, reject unbound evidence files and symlink substitution, and fail release closeout on record, note, path, or digest drift.", "claimLevel": "blocking", "riskClass": "high", "proofBindingRefs": ["proofkit/requirement-bindings.json"], "nonClaimRefs": ["NC-PROOFKIT-QUALITY-024"], - "nonClaims": ["This requirement does not infer change-record completeness from source changes, make release notes approval authority, or prove provider publication, attestation authenticity, consumer adoption, rollout approval, or production readiness."], + "nonClaims": ["This requirement does not infer change-record completeness from source changes, reconstruct release-history migration data before the declared support baseline, claim a cumulative release-history planner, make release notes approval authority, or prove provider publication, attestation authenticity, consumer adoption, rollout approval, or production readiness."], "lifecycle": {"state": "active", "replacementRequirementIds": [], "evidenceRefs": []}, "deferral": null, "updatePolicy": {"reviewOwnerId": "proofkit.supply-chain-quality", "requiresImpactDeclaration": true, "requiresProofBindingReview": true} diff --git a/internal/tools/coveragemetrics/main.go b/internal/tools/coveragemetrics/main.go index 2004b2a..4ba81a9 100644 --- a/internal/tools/coveragemetrics/main.go +++ b/internal/tools/coveragemetrics/main.go @@ -315,6 +315,13 @@ func validateRequiredBindingWitnessSelectors(bindings bindingFile) error { "TestCurrentChangeRecordNamesReviewedSemanticChanges", "TestRenderStatesPreOneExactPinPolicy", }, + {"REQ-PROOFKIT-QUALITY-024", "proofkit.supply-chain-quality.release-predecessor-lineage"}: { + "TestRunNPMLineageUsesAdmittedRecordAndProviderIdentity", + "TestValidateNPMReleaseLineage", + }, + {"REQ-PROOFKIT-QUALITY-024", "proofkit.supply-chain-quality.release-predecessor-lineage-workflow"}: { + "TestReleaseWorkflowCandidateEvidenceAllowsExistingNPMByteMatch", + }, {"REQ-PROOFKIT-QUALITY-025", "proofkit.supply-chain-quality.workflow-source-oracles"}: { "TestExistingReleasePathIsReadOnlyAndFailsOnDrift", "TestWorkflowClosedKeyAdmission", @@ -357,6 +364,8 @@ func validateRequiredBindingWitnessSelectors(bindings bindingFile) error { {"REQ-PROOFKIT-QUALITY-022", "proofkit.supply-chain-quality.browser-failure-diagnostics-retention"}: "scripts/workflow_browser_runtime_oracle_test.go", {"REQ-PROOFKIT-QUALITY-023", "proofkit.supply-chain-quality.python-wheel-platform-byte-compatibility"}: "internal/tools/pythonpackage/metadata_test.go", {"REQ-PROOFKIT-QUALITY-024", "proofkit.supply-chain-quality.release-change-record-projection"}: "internal/tools/releasechange/record_test.go", + {"REQ-PROOFKIT-QUALITY-024", "proofkit.supply-chain-quality.release-predecessor-lineage"}: "internal/tools/releasepreflight/main_test.go", + {"REQ-PROOFKIT-QUALITY-024", "proofkit.supply-chain-quality.release-predecessor-lineage-workflow"}: "scripts/validate-self-hosting-receipts_test.go", {"REQ-PROOFKIT-QUALITY-025", "proofkit.supply-chain-quality.workflow-source-oracles"}: "scripts/workflow_source_oracles_test.go", {"REQ-PROOFKIT-SPEC-011", "proofkit.spec-proof-core.adoption-contract-envelope-cli-abi"}: "internal/app/cli_abi_test.go", {"REQ-PROOFKIT-SPEC-021", "proofkit.spec-proof-core.requirement-browser-one-shot-cleanup"}: "internal/command/requirementbrowser/server_test.go", diff --git a/internal/tools/coveragemetrics/main_test.go b/internal/tools/coveragemetrics/main_test.go index 9bde679..5cb0122 100644 --- a/internal/tools/coveragemetrics/main_test.go +++ b/internal/tools/coveragemetrics/main_test.go @@ -395,6 +395,8 @@ func TestBindingWitnessSelectorsRequireExactCriticalInventories(t *testing.T) { "proofkit.supply-chain-quality.python-wheel-platform-byte-compatibility", "proofkit.supply-chain-quality.release-platform-python-wheels", "proofkit.supply-chain-quality.release-change-record-projection", + "proofkit.supply-chain-quality.release-predecessor-lineage", + "proofkit.supply-chain-quality.release-predecessor-lineage-workflow", "proofkit.supply-chain-quality.scorecard-permission-and-publication-inputs", "proofkit.supply-chain-quality.workflow-package-gate-oracle", "proofkit.supply-chain-quality.workflow-source-oracles", diff --git a/internal/tools/releasechange/record_test.go b/internal/tools/releasechange/record_test.go index d8cb4ee..cb81738 100644 --- a/internal/tools/releasechange/record_test.go +++ b/internal/tools/releasechange/record_test.go @@ -170,65 +170,38 @@ func TestCurrentChangeRecordNamesReviewedSemanticChanges(t *testing.T) { t.Fatalf("current release projection admitted missing %q", projected) } } - breakingReorderedNotes := swapAdjacentNoteItems(notes, currentChangeBullet(currentBreakingChanges[0]), currentChangeBullet(currentBreakingChanges[1])) - assertCurrentChangeRecordNotesRejected(t, "reordered breaking note", record, breakingReorderedNotes) - additionReorderedNotes := swapAdjacentNoteItems(notes, currentChangeBullet(currentAdditions[0]), currentChangeBullet(currentAdditions[1])) - assertCurrentChangeRecordNotesRejected(t, "reordered addition note", record, additionReorderedNotes) - migrationReorderedNotes := swapAdjacentNoteItems(notes, "- "+currentMigrationSteps[0], "- "+currentMigrationSteps[1]) - assertCurrentChangeRecordNotesRejected(t, "reordered migration note", record, migrationReorderedNotes) - relocatedNotes := strings.Replace(notes, currentChangeBullet(currentBreakingChanges[0])+"\n", "", 1) - relocatedNotes = strings.Replace(relocatedNotes, "## Additions\n\n", "## Additions\n\n"+currentChangeBullet(currentBreakingChanges[0])+"\n", 1) - assertCurrentChangeRecordNotesRejected(t, "breaking note relocated to additions", record, relocatedNotes) + if len(currentBreakingChanges) > 1 { + reordered := swapAdjacentNoteItems(notes, currentChangeBullet(currentBreakingChanges[0]), currentChangeBullet(currentBreakingChanges[1])) + assertCurrentChangeRecordNotesRejected(t, "reordered breaking note", record, reordered) + } + if len(currentAdditions) > 1 { + reordered := swapAdjacentNoteItems(notes, currentChangeBullet(currentAdditions[0]), currentChangeBullet(currentAdditions[1])) + assertCurrentChangeRecordNotesRejected(t, "reordered addition note", record, reordered) + } + if len(currentMigrationSteps) > 1 { + reordered := swapAdjacentNoteItems(notes, "- "+currentMigrationSteps[0], "- "+currentMigrationSteps[1]) + assertCurrentChangeRecordNotesRejected(t, "reordered migration note", record, reordered) + } + if len(currentAdditions) > 0 { + relocatedNotes := strings.Replace(notes, currentChangeBullet(currentAdditions[0])+"\n", "", 1) + relocatedNotes = strings.Replace(relocatedNotes, "## Breaking Contract Changes\n\n", "## Breaking Contract Changes\n\n"+currentChangeBullet(currentAdditions[0])+"\n", 1) + assertCurrentChangeRecordNotesRejected(t, "addition note relocated to breaking", record, relocatedNotes) + assertCurrentChangeRecordNotesRejected(t, "appended duplicate change note", record, notes+currentChangeBullet(currentAdditions[0])+"\n") + } surplusNotes := strings.Replace(notes, "\n\n## Additions", "\n- `proofkit.surplus.note`: Surplus note.\n\n## Additions", 1) assertCurrentChangeRecordNotesRejected(t, "surplus breaking note", record, surplusNotes) assertCurrentChangeRecordNotesRejected(t, "appended surplus change note", record, notes+"- `proofkit.surplus.appended`: Appended surplus note.\n") - assertCurrentChangeRecordNotesRejected(t, "appended duplicate change note", record, notes+currentChangeBullet(currentBreakingChanges[0])+"\n") assertCurrentChangeRecordNotesRejected(t, "appended duplicate change section", record, notes+"## Breaking Contract Changes\n\n- `proofkit.surplus.section`: Surplus section.\n") } -var currentBreakingChanges = []Change{ - {ChangeID: "proofkit.adoption-doctor.advisory-rule-status", Summary: "Non-enforced adoption-doctor advisory gap rules now report skipped instead of passed, including observe-mode rules and gaps outside an enforce-touched selection; these gaps do not change the top-level outcome."}, - {ChangeID: "proofkit.adoption-doctor.blocked-prerequisites", Summary: "Adoption doctor now reports unresolved external prerequisites as blocked with exit code 1 in every adoption mode; observe and warn no longer relax them."}, - {ChangeID: "proofkit.browser.native-list-keyboard-contract", Summary: "Requirement browser navigation now uses native list and button semantics; the removed synthetic tree no longer provides ArrowUp or ArrowDown roving focus."}, - {ChangeID: "proofkit.cli.adoption-contract-single-value-flags", Summary: "Adoption contract envelope now rejects repeated --mode or --pilot flags and an explicitly empty --pilot value instead of changing or misreporting the selected root-shape variant."}, - {ChangeID: "proofkit.cli.invalid-input-channels", Summary: "Malformed ordinary command input now uses stderr while explicit agent envelopes retain machine-readable invalid-input output."}, - {ChangeID: "proofkit.cli.pilot-admission-single-value-selector", Summary: "Pilot admission now rejects repeated or mixed --pilot and --stack-diverse selectors instead of applying last-write-wins routing; the single --stack-diverse alias remains supported with direct or contract-envelope input."}, - {ChangeID: "proofkit.context.digest-coverage-v2", Summary: "Requirement context, diff, graph, and browser workspace contracts advance to version 2 with expectedDigestCoverage vocabulary."}, - {ChangeID: "proofkit.launcher.python-executable-format-controls", Summary: "Python-module launcher admission now rejects Unicode format characters as well as control characters in the absolute executable path before rendering display commands."}, - {ChangeID: "proofkit.onboarding.generated-command-invocation", Summary: "Proofkit-owned generated display commands and structured argv now use one explicit installed launcher channel across help, preset, bootstrap, project, route, workflow, and coverage surfaces: offline npm exec for npm consumers and the active absolute Python interpreter module route for wheel consumers; direct binary consumers retain caller-owned PATH resolution."}, - {ChangeID: "proofkit.package.installed-governance-routes", Summary: "The npm artifact no longer ships AGENTS.md or CONTRIBUTING.md; governance and contribution routes remain source-checkout-only."}, - {ChangeID: "proofkit.readiness-closeout.character-reference-policy", Summary: "Readiness closeout now decodes one strict semicolon-terminated CommonMark or HTML character reference pass before policy phrase matching; text that previously hid a forbidden phrase through one such reference now fails closed."}, - {ChangeID: "proofkit.typescript.absolute-symlink", Summary: "The TypeScript public API scanner rejects absolute symlink targets and requires confined relative in-root links."}, -} +var currentBreakingChanges = []Change{} var currentAdditions = []Change{ - {ChangeID: "proofkit.browser.accessibility-state-matrix", Summary: "The requirement browser adds deterministic loading and failure states, native list semantics, bounded reflow, target-size, and contrast witnesses."}, - {ChangeID: "proofkit.cli.contract-closure", Summary: "The authored CLI contract now closes input and output compatibility projections, admits canonical machine-disjoint CLI variant condition models, and generates private runtime metadata."}, - {ChangeID: "proofkit.onboarding.installed-artifact-trace", Summary: "Canonical onboarding uses exact npm pins, copyable offline npm exec routes at every displayed help transition, exact preset commands, and a displayed installed-README first-input continuation."}, - {ChangeID: "proofkit.package.public-reference-closure", Summary: "The npm package excludes contributor-only governance files and closes package-public Markdown and machine references over shipped entries or explicit source-checkout evidence fields."}, - {ChangeID: "proofkit.pilot-admission.all-envelope", Summary: "Pilot admission contract envelopes now admit the existing all mode as one strict two-input envelope and return the ordered first and stack-diverse pilot reports."}, - {ChangeID: "proofkit.requirement-bindings.witness-selectors", Summary: "Requirement binding admission and output now preserve optional witnessSelectors records with exact selector and command fields."}, - {ChangeID: "proofkit.requirement-output.confined-atomic-publication", Summary: "Requirement view output now uses repository-confined same-parent atomic replacement after final destination-parent plus temporary-object identity, mode, and content admission."}, - {ChangeID: "proofkit.security-workflows.permission-separation", Summary: "Security workflow source contracts now keep advisory CodeQL, OSV, and Scorecard analysis read-only, isolate provider publication authority, and require exact Scorecard public-output inputs."}, - {ChangeID: "proofkit.release.artifact-honest-sbom", Summary: "CycloneDX runtime edges are emitted only from content-bound per-binary build information while source module inventory is excluded."}, - {ChangeID: "proofkit.release.existing-release-immutability", Summary: "Existing release verification is read-only and fails closed instead of uploading or backfilling missing assets."}, - {ChangeID: "proofkit.release.workflow-action-pins", Summary: "Every external GitHub Actions use is guarded by a full commit-SHA source oracle."}, + {ChangeID: "proofkit.release.migration-support-baseline", Summary: "Release-history migration support starts at 0.2.0, and any future cumulative plan must be derived from contiguous owner-reviewed per-release records without backfilling pre-baseline release history."}, + {ChangeID: "proofkit.release.npm-predecessor-lineage", Summary: "Release candidate preflight binds a new candidate previousVersion to npm latest, while an exact already-published idempotent candidate requires npm latest to equal the candidate version."}, } -var currentMigrationSteps = []string{ - "Update adoption-doctor consumers that inspect non-enforced advisory rule results to accept skipped instead of passed in observe mode and outside an enforce-touched selection; these gaps leave the top-level outcome unchanged.", - "Update adoption-doctor consumers to treat unresolved external prerequisites as blocked with a nonzero exit in every adoption mode; only advisory gaps remain mode-relaxable.", - "Update requirement-browser keyboard automation to use standard Tab and Shift+Tab traversal plus Enter or Space activation instead of synthetic ArrowUp or ArrowDown tree navigation.", - "Pass --mode and --pilot at most once to adoption-contract-envelope, provide a non-empty --pilot value, and split repeated-flag invocations into one unambiguous invocation.", - "Pass at most one of --pilot or --stack-diverse to pilot-admission; omit both to retain the default first pilot, and split repeated or mixed-selector invocations into one unambiguous invocation.", - "Update context, semantic-diff, graph, and workspace consumers to schemaVersion 2 and replace baselineVerification with expectedDigestCoverage.", - "Remove Unicode control or format characters from the absolute Python executable path supplied by the Python-module launcher profile.", - "Update consumers that compare, execute, or persist Proofkit-generated display commands or structured argv to accept channel-specific installed invocation prefixes across help, preset, bootstrap, project, route, workflow, and coverage surfaces; do not rewrite caller-owned bootstrap command text.", - "Update readiness-closeout consumers and fixtures to treat one strict semicolon-terminated CommonMark or HTML character reference pass as equivalent policy text before phrase matching.", - "Replace absolute symlinks traversed by the TypeScript public API scanner, including package-manifest ancestors and source paths, with relative in-root symlinks.", - "Update consumers that read AGENTS.md or CONTRIBUTING.md from the installed npm artifact to use a source checkout or their own admitted governance policy.", - "Read ordinary malformed-input diagnostics from stderr unless an explicit agent envelope was requested.", -} +var currentMigrationSteps = []string{} func validateCurrentChangeRecord(record Record, notes string) error { if !slices.Equal(record.BreakingChanges, currentBreakingChanges) { @@ -248,23 +221,21 @@ func validateCurrentChangeRecord(record Record, notes string) error { func currentExpectedReleaseNotes() string { lines := []string{ - "# @research-engineering/agentic-proofkit 0.2.0", + "# @research-engineering/agentic-proofkit 0.2.1", "", "## Breaking Contract Changes", "", - } - for _, change := range currentBreakingChanges { - lines = append(lines, currentChangeBullet(change)) + "- None.", } lines = append(lines, "", "## Additions", "") for _, change := range currentAdditions { lines = append(lines, currentChangeBullet(change)) } - lines = append(lines, "", "## Migration", "", "Migration is required:", "") - for _, step := range currentMigrationSteps { - lines = append(lines, "- "+step) - } lines = append(lines, + "", + "## Migration", + "", + "No consumer migration is required.", "", "## Platform Requirements", "", @@ -274,14 +245,14 @@ func currentExpectedReleaseNotes() string { "", "- TSX source parsing remains unsupported.", "- Static route coverage does not prove semantic execution coverage.", - "- The immutable 0.1.160 release is not modified, republished, or backfilled.", + "- Release-history migration support starts at 0.2.0; no machine release-history chain is claimed for earlier releases.", "", "## Install", "", "Primary npm channel:", "", "```bash", - "npm install --save-dev --save-exact @research-engineering/agentic-proofkit@0.2.0", + "npm install --save-dev --save-exact @research-engineering/agentic-proofkit@0.2.1", "```", "", "Pre-1.0 npm consumers must keep this dependency exact-pinned.", @@ -292,7 +263,7 @@ func currentExpectedReleaseNotes() string { "", "## Rollback", "", - "- Pin npm consumers to the previous admitted version 0.1.160 with `npm install --save-dev --save-exact @research-engineering/agentic-proofkit@0.1.160`.", + "- Pin npm consumers to the previous admitted version 0.2.0 with `npm install --save-dev --save-exact @research-engineering/agentic-proofkit@0.2.0`.", "- Treat local package artifacts as candidates until registry identity is proven.", ) return strings.Join(lines, "\n") + "\n" diff --git a/internal/tools/releasepreflight/main.go b/internal/tools/releasepreflight/main.go index 3655d2f..303db90 100644 --- a/internal/tools/releasepreflight/main.go +++ b/internal/tools/releasepreflight/main.go @@ -15,6 +15,7 @@ import ( "strings" "github.com/research-engineering/agentic-proofkit/internal/kernel/admission" + "github.com/research-engineering/agentic-proofkit/internal/tools/releasechange" "github.com/research-engineering/agentic-proofkit/internal/tools/retainedevidence" ) @@ -37,6 +38,16 @@ type npmView struct { } `json:"dist"` } +type npmReleaseIdentity struct { + Name string `json:"name"` + Version string `json:"version"` +} + +const ( + npmCandidateUnpublished = "unpublished" + npmCandidateExistingByteMatch = "existing_byte_match" +) + type pythonPackageSet struct { Packages []wheelRecord `json:"packages"` } @@ -106,7 +117,7 @@ func main() { func run(args []string) error { if len(args) == 0 { - return fmt.Errorf("usage: releasepreflight ") + return fmt.Errorf("usage: releasepreflight ") } switch args[0] { case "npm-existing": @@ -123,6 +134,26 @@ func run(args []string) error { return err } return compareNPMExisting(expected, actual) + case "npm-lineage": + options, err := parseFlags(args[1:], "change-record-file", "latest-file", "expected-name", "candidate-version", "candidate-state") + if err != nil { + return err + } + changeRecord, err := releasechange.Read(options["change-record-file"]) + if err != nil { + return err + } + var latest npmReleaseIdentity + if err := readJSON(options["latest-file"], &latest); err != nil { + return err + } + return validateNPMReleaseLineage( + changeRecord, + latest, + options["expected-name"], + options["candidate-version"], + options["candidate-state"], + ) case "npm-candidate-artifacts": options, err := parseFlags(args[1:], "metadata-file", "directory") if err != nil { @@ -233,6 +264,43 @@ func compareNPMExisting(expected npmCandidate, actual npmView) error { return nil } +func validateNPMReleaseLineage( + record releasechange.Record, + latest npmReleaseIdentity, + expectedName string, + candidateVersion string, + candidateState string, +) error { + if expectedName == "" { + return fmt.Errorf("expected npm package name must not be empty") + } + if candidateVersion == "" { + return fmt.Errorf("candidate npm package version must not be empty") + } + if candidateVersion != record.Version { + return fmt.Errorf("candidate npm package version %s !== release version %s", candidateVersion, record.Version) + } + if latest.Name == "" || latest.Version == "" { + return fmt.Errorf("npm latest identity must include name and version") + } + if latest.Name != expectedName { + return fmt.Errorf("npm latest package name %s !== %s", latest.Name, expectedName) + } + var expectedVersion string + switch candidateState { + case npmCandidateUnpublished: + expectedVersion = record.PreviousVersion + case npmCandidateExistingByteMatch: + expectedVersion = record.Version + default: + return fmt.Errorf("unsupported npm candidate state %q", candidateState) + } + if latest.Version != expectedVersion { + return fmt.Errorf("npm latest version %s !== expected %s for candidate state %s", latest.Version, expectedVersion, candidateState) + } + return nil +} + func compareNPMCandidateArtifacts(candidates []npmCandidate, directory string) error { if len(candidates) == 0 { return fmt.Errorf("candidate npm package metadata must not be empty") diff --git a/internal/tools/releasepreflight/main_test.go b/internal/tools/releasepreflight/main_test.go index 2e85c57..d2c7716 100644 --- a/internal/tools/releasepreflight/main_test.go +++ b/internal/tools/releasepreflight/main_test.go @@ -8,6 +8,8 @@ import ( "path/filepath" "strings" "testing" + + "github.com/research-engineering/agentic-proofkit/internal/tools/releasechange" ) func TestRetainedEvidenceCommandWritesArtifactRootManifest(t *testing.T) { @@ -48,6 +50,142 @@ func TestCompareNPMExisting(t *testing.T) { } } +func TestValidateNPMReleaseLineage(t *testing.T) { + record := releasechange.Record{PreviousVersion: "0.2.0", Version: "0.2.1"} + accepted := []struct { + name string + latestVersion string + candidateState string + }{ + {name: "unpublished candidate", latestVersion: "0.2.0", candidateState: npmCandidateUnpublished}, + {name: "existing byte match", latestVersion: "0.2.1", candidateState: npmCandidateExistingByteMatch}, + } + for _, item := range accepted { + t.Run(item.name, func(t *testing.T) { + latest := npmReleaseIdentity{Name: "@research-engineering/agentic-proofkit", Version: item.latestVersion} + if err := validateNPMReleaseLineage(record, latest, "@research-engineering/agentic-proofkit", "0.2.1", item.candidateState); err != nil { + t.Fatalf("validateNPMReleaseLineage() error = %v", err) + } + }) + } + + tests := []struct { + name string + latest npmReleaseIdentity + expected string + version string + state string + want string + }{ + { + name: "missing provider identity", + latest: npmReleaseIdentity{}, + expected: "@research-engineering/agentic-proofkit", + version: "0.2.1", + state: npmCandidateUnpublished, + want: "must include name and version", + }, + { + name: "package mismatch", + latest: npmReleaseIdentity{Name: "other", Version: "0.2.0"}, + expected: "@research-engineering/agentic-proofkit", + version: "0.2.1", + state: npmCandidateUnpublished, + want: "package name", + }, + { + name: "unpublished candidate skips predecessor", + latest: npmReleaseIdentity{Name: "@research-engineering/agentic-proofkit", Version: "0.2.1"}, + expected: "@research-engineering/agentic-proofkit", + version: "0.2.1", + state: npmCandidateUnpublished, + want: "expected 0.2.0", + }, + { + name: "existing candidate lacks byte-match lineage", + latest: npmReleaseIdentity{Name: "@research-engineering/agentic-proofkit", Version: "0.2.0"}, + expected: "@research-engineering/agentic-proofkit", + version: "0.2.1", + state: npmCandidateExistingByteMatch, + want: "expected 0.2.1", + }, + { + name: "empty expected package", + latest: npmReleaseIdentity{Name: "@research-engineering/agentic-proofkit", Version: "0.2.0"}, + expected: "", + version: "0.2.1", + state: npmCandidateUnpublished, + want: "must not be empty", + }, + { + name: "unsupported candidate state", + latest: npmReleaseIdentity{Name: "@research-engineering/agentic-proofkit", Version: "0.2.0"}, + expected: "@research-engineering/agentic-proofkit", + version: "0.2.1", + state: "unknown", + want: "unsupported npm candidate state", + }, + { + name: "empty candidate version", + latest: npmReleaseIdentity{Name: "@research-engineering/agentic-proofkit", Version: "0.2.0"}, + expected: "@research-engineering/agentic-proofkit", + state: npmCandidateUnpublished, + want: "version must not be empty", + }, + { + name: "candidate version differs from release record", + latest: npmReleaseIdentity{Name: "@research-engineering/agentic-proofkit", Version: "0.2.0"}, + expected: "@research-engineering/agentic-proofkit", + version: "0.2.2", + state: npmCandidateUnpublished, + want: "candidate npm package version", + }, + } + for _, item := range tests { + t.Run(item.name, func(t *testing.T) { + err := validateNPMReleaseLineage(record, item.latest, item.expected, item.version, item.state) + if err == nil || !strings.Contains(err.Error(), item.want) { + t.Fatalf("validateNPMReleaseLineage() error = %v, want %q", err, item.want) + } + }) + } +} + +func TestRunNPMLineageUsesAdmittedRecordAndProviderIdentity(t *testing.T) { + root := t.TempDir() + recordPath := filepath.Join(root, "change-record.json") + latestPath := filepath.Join(root, "latest.json") + writeFile(t, recordPath, `{ + "schemaVersion": 2, + "previousVersion": "0.2.0", + "version": "0.2.1", + "changeClass": "compatible", + "breakingChanges": [], + "additions": [], + "migration": {"required": false, "steps": []}, + "platformRequirements": [], + "knownLimitations": [], + "rollback": {"strategy": "previous_admitted_version"} + }`) + writeFile(t, latestPath, `{"name":"@research-engineering/agentic-proofkit","version":"0.2.0"}`) + args := []string{ + "npm-lineage", + "--change-record-file", recordPath, + "--latest-file", latestPath, + "--expected-name", "@research-engineering/agentic-proofkit", + "--candidate-version", "0.2.1", + "--candidate-state", npmCandidateUnpublished, + } + if err := run(args); err != nil { + t.Fatalf("run(npm-lineage) error = %v", err) + } + + writeFile(t, latestPath, `{"name":"@research-engineering/agentic-proofkit","version":"0.1.160"}`) + if err := run(args); err == nil || !strings.Contains(err.Error(), "expected 0.2.0") { + t.Fatalf("run(npm-lineage) error = %v, want lineage gap rejection", err) + } +} + func TestCompareNPMCandidateArtifactsBindsDownloadedBytes(t *testing.T) { dir := t.TempDir() content := []byte("candidate npm artifact") diff --git a/package-lock.json b/package-lock.json index b5cb120..941ab42 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@research-engineering/agentic-proofkit", - "version": "0.2.0", + "version": "0.2.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@research-engineering/agentic-proofkit", - "version": "0.2.0", + "version": "0.2.1", "cpu": [ "arm64", "x64" diff --git a/package.json b/package.json index 086012c..be34a66 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@research-engineering/agentic-proofkit", "description": "Reusable proof profile, report, graph, and witness-planning primitives.", - "version": "0.2.0", + "version": "0.2.1", "type": "module", "license": "MIT", "sideEffects": false, diff --git a/proofkit/requirement-bindings.json b/proofkit/requirement-bindings.json index 0c4e3f4..cf084fb 100644 --- a/proofkit/requirement-bindings.json +++ b/proofkit/requirement-bindings.json @@ -602,7 +602,7 @@ "specPath": "docs/specs/proofkit-supply-chain-quality/requirements.v1.json", "claimLevel": "blocking", "proofState": "witness_backed", - "nonClaims": ["This requirement does not infer change-record completeness from source changes, make release notes approval authority, or prove provider publication, attestation authenticity, consumer adoption, rollout approval, or production readiness."] + "nonClaims": ["This requirement does not infer change-record completeness from source changes, reconstruct release-history migration data before the declared support baseline, claim a cumulative release-history planner, make release notes approval authority, or prove provider publication, attestation authenticity, consumer adoption, rollout approval, or production readiness."] }, { "requirementId": "REQ-PROOFKIT-QUALITY-025", @@ -3156,6 +3156,40 @@ "commandIds": ["proofkit.go-test"], "environmentClasses": ["local-go"] }, + { + "requirementId": "REQ-PROOFKIT-QUALITY-024", + "scenarioId": "proofkit.supply-chain-quality.release-predecessor-lineage", + "witnessId": "proofkit.release-preflight.predecessor-lineage-falsifier", + "witnessKind": "contract", + "witnessPath": "internal/tools/releasepreflight/main_test.go", + "witnessSelectors": [ + { + "selector": "TestValidateNPMReleaseLineage", + "command": "go test ./internal/tools/releasepreflight -run '^TestValidateNPMReleaseLineage$'" + }, + { + "selector": "TestRunNPMLineageUsesAdmittedRecordAndProviderIdentity", + "command": "go test ./internal/tools/releasepreflight -run '^TestRunNPMLineageUsesAdmittedRecordAndProviderIdentity$'" + } + ], + "commandIds": ["proofkit.go-test"], + "environmentClasses": ["local-go"] + }, + { + "requirementId": "REQ-PROOFKIT-QUALITY-024", + "scenarioId": "proofkit.supply-chain-quality.release-predecessor-lineage-workflow", + "witnessId": "proofkit.release-workflow.predecessor-lineage-oracle", + "witnessKind": "contract", + "witnessPath": "scripts/validate-self-hosting-receipts_test.go", + "witnessSelectors": [ + { + "selector": "TestReleaseWorkflowCandidateEvidenceAllowsExistingNPMByteMatch", + "command": "go test ./scripts -run '^TestReleaseWorkflowCandidateEvidenceAllowsExistingNPMByteMatch$'" + } + ], + "commandIds": ["proofkit.go-test"], + "environmentClasses": ["local-go"] + }, { "requirementId": "REQ-PROOFKIT-QUALITY-025", "scenarioId": "proofkit.supply-chain-quality.workflow-source-oracles", diff --git a/release/change-record.v2.json b/release/change-record.v2.json index a1f335b..25d1c7d 100644 --- a/release/change-record.v2.json +++ b/release/change-record.v2.json @@ -1,120 +1,22 @@ { "schemaVersion": 2, - "previousVersion": "0.1.160", - "version": "0.2.0", - "changeClass": "breaking", - "breakingChanges": [ - { - "changeId": "proofkit.adoption-doctor.advisory-rule-status", - "summary": "Non-enforced adoption-doctor advisory gap rules now report skipped instead of passed, including observe-mode rules and gaps outside an enforce-touched selection; these gaps do not change the top-level outcome." - }, - { - "changeId": "proofkit.adoption-doctor.blocked-prerequisites", - "summary": "Adoption doctor now reports unresolved external prerequisites as blocked with exit code 1 in every adoption mode; observe and warn no longer relax them." - }, - { - "changeId": "proofkit.browser.native-list-keyboard-contract", - "summary": "Requirement browser navigation now uses native list and button semantics; the removed synthetic tree no longer provides ArrowUp or ArrowDown roving focus." - }, - { - "changeId": "proofkit.cli.adoption-contract-single-value-flags", - "summary": "Adoption contract envelope now rejects repeated --mode or --pilot flags and an explicitly empty --pilot value instead of changing or misreporting the selected root-shape variant." - }, - { - "changeId": "proofkit.cli.invalid-input-channels", - "summary": "Malformed ordinary command input now uses stderr while explicit agent envelopes retain machine-readable invalid-input output." - }, - { - "changeId": "proofkit.cli.pilot-admission-single-value-selector", - "summary": "Pilot admission now rejects repeated or mixed --pilot and --stack-diverse selectors instead of applying last-write-wins routing; the single --stack-diverse alias remains supported with direct or contract-envelope input." - }, - { - "changeId": "proofkit.context.digest-coverage-v2", - "summary": "Requirement context, diff, graph, and browser workspace contracts advance to version 2 with expectedDigestCoverage vocabulary." - }, - { - "changeId": "proofkit.launcher.python-executable-format-controls", - "summary": "Python-module launcher admission now rejects Unicode format characters as well as control characters in the absolute executable path before rendering display commands." - }, - { - "changeId": "proofkit.onboarding.generated-command-invocation", - "summary": "Proofkit-owned generated display commands and structured argv now use one explicit installed launcher channel across help, preset, bootstrap, project, route, workflow, and coverage surfaces: offline npm exec for npm consumers and the active absolute Python interpreter module route for wheel consumers; direct binary consumers retain caller-owned PATH resolution." - }, - { - "changeId": "proofkit.package.installed-governance-routes", - "summary": "The npm artifact no longer ships AGENTS.md or CONTRIBUTING.md; governance and contribution routes remain source-checkout-only." - }, - { - "changeId": "proofkit.readiness-closeout.character-reference-policy", - "summary": "Readiness closeout now decodes one strict semicolon-terminated CommonMark or HTML character reference pass before policy phrase matching; text that previously hid a forbidden phrase through one such reference now fails closed." - }, - { - "changeId": "proofkit.typescript.absolute-symlink", - "summary": "The TypeScript public API scanner rejects absolute symlink targets and requires confined relative in-root links." - } - ], + "previousVersion": "0.2.0", + "version": "0.2.1", + "changeClass": "compatible", + "breakingChanges": [], "additions": [ { - "changeId": "proofkit.browser.accessibility-state-matrix", - "summary": "The requirement browser adds deterministic loading and failure states, native list semantics, bounded reflow, target-size, and contrast witnesses." - }, - { - "changeId": "proofkit.cli.contract-closure", - "summary": "The authored CLI contract now closes input and output compatibility projections, admits canonical machine-disjoint CLI variant condition models, and generates private runtime metadata." - }, - { - "changeId": "proofkit.onboarding.installed-artifact-trace", - "summary": "Canonical onboarding uses exact npm pins, copyable offline npm exec routes at every displayed help transition, exact preset commands, and a displayed installed-README first-input continuation." - }, - { - "changeId": "proofkit.package.public-reference-closure", - "summary": "The npm package excludes contributor-only governance files and closes package-public Markdown and machine references over shipped entries or explicit source-checkout evidence fields." - }, - { - "changeId": "proofkit.pilot-admission.all-envelope", - "summary": "Pilot admission contract envelopes now admit the existing all mode as one strict two-input envelope and return the ordered first and stack-diverse pilot reports." - }, - { - "changeId": "proofkit.requirement-bindings.witness-selectors", - "summary": "Requirement binding admission and output now preserve optional witnessSelectors records with exact selector and command fields." - }, - { - "changeId": "proofkit.requirement-output.confined-atomic-publication", - "summary": "Requirement view output now uses repository-confined same-parent atomic replacement after final destination-parent plus temporary-object identity, mode, and content admission." - }, - { - "changeId": "proofkit.security-workflows.permission-separation", - "summary": "Security workflow source contracts now keep advisory CodeQL, OSV, and Scorecard analysis read-only, isolate provider publication authority, and require exact Scorecard public-output inputs." - }, - { - "changeId": "proofkit.release.artifact-honest-sbom", - "summary": "CycloneDX runtime edges are emitted only from content-bound per-binary build information while source module inventory is excluded." - }, - { - "changeId": "proofkit.release.existing-release-immutability", - "summary": "Existing release verification is read-only and fails closed instead of uploading or backfilling missing assets." + "changeId": "proofkit.release.migration-support-baseline", + "summary": "Release-history migration support starts at 0.2.0, and any future cumulative plan must be derived from contiguous owner-reviewed per-release records without backfilling pre-baseline release history." }, { - "changeId": "proofkit.release.workflow-action-pins", - "summary": "Every external GitHub Actions use is guarded by a full commit-SHA source oracle." + "changeId": "proofkit.release.npm-predecessor-lineage", + "summary": "Release candidate preflight binds a new candidate previousVersion to npm latest, while an exact already-published idempotent candidate requires npm latest to equal the candidate version." } ], "migration": { - "required": true, - "steps": [ - "Update adoption-doctor consumers that inspect non-enforced advisory rule results to accept skipped instead of passed in observe mode and outside an enforce-touched selection; these gaps leave the top-level outcome unchanged.", - "Update adoption-doctor consumers to treat unresolved external prerequisites as blocked with a nonzero exit in every adoption mode; only advisory gaps remain mode-relaxable.", - "Update requirement-browser keyboard automation to use standard Tab and Shift+Tab traversal plus Enter or Space activation instead of synthetic ArrowUp or ArrowDown tree navigation.", - "Pass --mode and --pilot at most once to adoption-contract-envelope, provide a non-empty --pilot value, and split repeated-flag invocations into one unambiguous invocation.", - "Pass at most one of --pilot or --stack-diverse to pilot-admission; omit both to retain the default first pilot, and split repeated or mixed-selector invocations into one unambiguous invocation.", - "Update context, semantic-diff, graph, and workspace consumers to schemaVersion 2 and replace baselineVerification with expectedDigestCoverage.", - "Remove Unicode control or format characters from the absolute Python executable path supplied by the Python-module launcher profile.", - "Update consumers that compare, execute, or persist Proofkit-generated display commands or structured argv to accept channel-specific installed invocation prefixes across help, preset, bootstrap, project, route, workflow, and coverage surfaces; do not rewrite caller-owned bootstrap command text.", - "Update readiness-closeout consumers and fixtures to treat one strict semicolon-terminated CommonMark or HTML character reference pass as equivalent policy text before phrase matching.", - "Replace absolute symlinks traversed by the TypeScript public API scanner, including package-manifest ancestors and source paths, with relative in-root symlinks.", - "Update consumers that read AGENTS.md or CONTRIBUTING.md from the installed npm artifact to use a source checkout or their own admitted governance policy.", - "Read ordinary malformed-input diagnostics from stderr unless an explicit agent envelope was requested." - ] + "required": false, + "steps": [] }, "platformRequirements": [ "Published Darwin package binaries require macOS 12.0 or later on arm64 and x86_64." @@ -122,7 +24,7 @@ "knownLimitations": [ "TSX source parsing remains unsupported.", "Static route coverage does not prove semantic execution coverage.", - "The immutable 0.1.160 release is not modified, republished, or backfilled." + "Release-history migration support starts at 0.2.0; no machine release-history chain is claimed for earlier releases." ], "rollback": { "strategy": "previous_admitted_version" diff --git a/scripts/validate-self-hosting-receipts_test.go b/scripts/validate-self-hosting-receipts_test.go index 2966ba6..935cda6 100644 --- a/scripts/validate-self-hosting-receipts_test.go +++ b/scripts/validate-self-hosting-receipts_test.go @@ -2,6 +2,7 @@ package main import ( "errors" + "fmt" "os" "path/filepath" "reflect" @@ -674,25 +675,70 @@ func TestReleaseWorkflowCandidateEvidenceAllowsExistingNPMByteMatch(t *testing.T t.Fatal("Build publish dry-run evidence step not found") } run := workflow.Jobs["candidate"].Steps[stepIndex].Run + if err := validateReleaseCandidateLineageRun(run); err != nil { + t.Fatal(err) + } + + lateOverride := strings.Replace( + run, + "npm view \"${package_name}@latest\" name version --json", + "lineage_state=\"unpublished\"\n npm view \"${package_name}@latest\" name version --json", + 1, + ) + if err := validateReleaseCandidateLineageRun(lateOverride); err == nil || !strings.Contains(err.Error(), "exactly two") { + t.Fatalf("late candidate-state override error=%v, want exact assignment-count rejection", err) + } + + const existingState = "lineage_state=\"existing_byte_match\"" + const unpublishedState = "lineage_state=\"unpublished\"" + swapped := strings.Replace(run, existingState, "__EXISTING_LINEAGE_STATE__", 1) + swapped = strings.Replace(swapped, unpublishedState, existingState, 1) + swapped = strings.Replace(swapped, "__EXISTING_LINEAGE_STATE__", unpublishedState, 1) + if err := validateReleaseCandidateLineageRun(swapped); err == nil || !strings.Contains(err.Error(), "only after") { + t.Fatalf("swapped candidate-state assignments error=%v, want branch-origin rejection", err) + } +} + +func validateReleaseCandidateLineageRun(run string) error { required := []string{ "npm view \"${package_name}@${package_version}\"", "go run ./internal/tools/releasepreflight npm-existing", + "lineage_state=\"existing_byte_match\"", "node - \"$metadata\" \"$filename\" \"$report\" <<'NODE'", "writeFileSync(report", - "continue", "npm publish \"artifacts/package/${filename}\"", "--dry-run", + "lineage_state=\"unpublished\"", + "npm view \"${package_name}@latest\" name version --json", + "go run ./internal/tools/releasepreflight npm-lineage", + "--change-record-file release/change-record.v2.json", + "--candidate-version \"$package_version\"", + "--candidate-state \"$lineage_state\"", } for _, item := range required { if !strings.Contains(run, item) { - t.Fatalf("candidate evidence step missing %q", item) + return fmt.Errorf("candidate evidence step missing %q", item) } } + if count := strings.Count(run, "lineage_state="); count != 2 { + return fmt.Errorf("candidate evidence must contain exactly two lineage_state assignments, got %d", count) + } existingIndex := strings.Index(run, "go run ./internal/tools/releasepreflight npm-existing") + existingStateIndex := strings.Index(run, "lineage_state=\"existing_byte_match\"") dryRunIndex := strings.Index(run, "npm publish \"artifacts/package/${filename}\"") + unpublishedStateIndex := strings.Index(run, "lineage_state=\"unpublished\"") + latestIndex := strings.Index(run, "npm view \"${package_name}@latest\" name version --json") + lineageIndex := strings.Index(run, "go run ./internal/tools/releasepreflight npm-lineage") if existingIndex < 0 || dryRunIndex < 0 || existingIndex > dryRunIndex { - t.Fatalf("candidate evidence must validate existing-byte-match before npm publish dry-run") + return fmt.Errorf("candidate evidence must validate existing-byte-match before npm publish dry-run") + } + if existingStateIndex < existingIndex || unpublishedStateIndex < dryRunIndex { + return fmt.Errorf("candidate state must be assigned only after its branch evidence succeeds") + } + if latestIndex < existingStateIndex || latestIndex < unpublishedStateIndex || lineageIndex < latestIndex { + return fmt.Errorf("candidate evidence must validate registry lineage after the exact-byte or dry-run branch") } + return nil } func TestReleaseWorkflowRetainsReleaseAssetAndPostCreateEvidenceClosure(t *testing.T) { diff --git a/scripts/workflow_package_gate_oracle_test.go b/scripts/workflow_package_gate_oracle_test.go index 65e8433..b390fbb 100644 --- a/scripts/workflow_package_gate_oracle_test.go +++ b/scripts/workflow_package_gate_oracle_test.go @@ -21,7 +21,7 @@ const requiredPlatformSmokeOwnerCommand = "go run ./internal/tools/packagebuild const setupVerifiedNPMActionSHA256 = "ead7e280f6430a9e83a544d5200217efaa36bf7aaedc879f417141fddfb20e8e" const ciSourceQualityStepInventorySHA256 = "90143666d13b499059937564e0829ecb1799946edb2975f187759cf5ef246da0" const ciBrowserRuntimeStepInventorySHA256 = "4880405e46ad4daad339117e76174a579e77cdfb70dde9c18d4afc7873f30aa4" -const releaseCandidateStepInventorySHA256 = "a7a1f3216ab9dd0700957ad23ca557df446f5ceb917525aec1e3ead293de0585" +const releaseCandidateStepInventorySHA256 = "12e2b229f711fb83e7a4230a455f764f03b5e36abf17ac0e53539037718b9e65" type packageGateWorkflowExpectation struct { label string From 7cd4176f3c2cae6865fbec459f1c6431579dc882 Mon Sep 17 00:00:00 2001 From: iperev Date: Fri, 31 Jul 2026 13:53:21 +0200 Subject: [PATCH 2/2] fix: close proof and release audit gaps --- .github/workflows/osv-scanner.yml | 4 +- .github/workflows/release.yml | 41 +- AGENTS.md | 16 +- docs/proofkit-contract-map.md | 7 + docs/release-process.md | 4 +- .../requirements.v1.json | 4 +- internal/app/app.go | 6 +- internal/app/app_test.go | 14 +- internal/app/cli_abi_test.go | 2 +- internal/app/cli_contract_test.go | 355 +++++++++++++++--- internal/app/command_contract_generated.go | 44 +-- internal/app/command_coverage_source.go | 37 +- internal/app/command_coverage_test.go | 2 +- internal/app/command_descriptors.go | 216 +++++++++-- internal/app/command_flag_constraints.go | 52 ++- internal/app/command_help.go | 21 +- internal/app/requirement_browser_command.go | 41 +- internal/app/self_hosting_semantics_test.go | 18 +- .../command/adoptiondoctor/adoptiondoctor.go | 2 +- .../changedpathset/changedpathset_test.go | 15 + .../migrationparityadmission.go | 6 +- .../obligationdecision/obligationdecision.go | 3 +- .../proof_obligation_algebra_test.go | 38 ++ .../proofobligationalgebra.go | 64 +--- .../receipt_currentness_scope_test.go | 12 + .../receiptcurrentnessscope.go | 32 +- .../receipt_trust_class_test.go | 40 ++ .../receipttrustclass/receipttrustclass.go | 105 +++--- .../renderedartifactfreshness.go | 7 +- .../requirementbrowser/http_handler.go | 18 +- .../requirementbrowser/requirementbrowser.go | 43 ++- internal/command/requirementbrowser/server.go | 3 +- .../command/requirementbrowser/server_test.go | 70 +++- internal/command/requirementcontext/model.go | 13 +- .../requirementcoverageview/admission.go | 13 +- .../requirementcoverageview/projection.go | 8 - .../requirementcoverageview_test.go | 16 + .../requirementdiff/output_admission.go | 9 +- .../requirementgraph/requirementgraph.go | 9 +- .../requirementimpactinput.go | 2 +- .../requirementproofview.go | 9 +- internal/command/secretscan/secretscan.go | 2 +- .../selectivegateevidence.go | 24 +- .../stackpreset/preset_ids_generated.go | 2 +- internal/command/textpolicy/textpolicy.go | 2 +- internal/kernel/admission/json.go | 109 +++++- internal/kernel/admission/json_test.go | 16 +- internal/kernel/admit/fields.go | 163 ++++++-- internal/kernel/admit/fields_test.go | 77 +++- internal/kernel/gotestsource/oracle.go | 302 ++++++++++++++- internal/kernel/gotestsource/oracle_test.go | 128 +++++++ .../kernel/releasechannel/releasechannel.go | 13 + .../releasechannel/releasechannel_test.go | 11 + .../kernel/witnesscommand/witnesscommand.go | 13 +- .../witnesscommand/witnesscommand_test.go | 14 + internal/tools/coveragemetrics/main.go | 105 +++++- internal/tools/coveragemetrics/main_test.go | 24 +- internal/tools/npmregistry/main.go | 187 +++++++++ internal/tools/npmregistry/main_test.go | 63 ++++ internal/tools/releasechange/record_test.go | 5 + internal/tools/releasecloseoutinput/main.go | 24 +- internal/tools/releasemanifest/main.go | 128 ++++++- internal/tools/releasemanifest/main_test.go | 80 ++++ internal/tools/releasepreflight/main.go | 8 +- internal/tools/releasepreflight/main_test.go | 7 + package.json | 1 + proofkit/cli-contract.v2.json | 220 ++++++++--- proofkit/requirement-bindings.json | 152 +++++++- proofkit/witness-plan.json | 2 +- release/change-record.v2.json | 20 + .../validate-self-hosting-receipts_test.go | 135 +++++++ .../workflow_security_scanner_oracles_test.go | 41 +- scripts/workflow_source_oracles_test.go | 2 +- 73 files changed, 2963 insertions(+), 538 deletions(-) create mode 100644 internal/tools/npmregistry/main.go create mode 100644 internal/tools/npmregistry/main_test.go diff --git a/.github/workflows/osv-scanner.yml b/.github/workflows/osv-scanner.yml index 63e31b8..bb1932c 100644 --- a/.github/workflows/osv-scanner.yml +++ b/.github/workflows/osv-scanner.yml @@ -51,7 +51,7 @@ jobs: scanner_status="$?" set -e test -s artifacts/osv/osv-results.sarif - if [ "$scanner_status" -gt 1 ]; then + if [ "$scanner_status" -ne 0 ]; then exit "$scanner_status" fi @@ -66,7 +66,7 @@ jobs: upload-sarif: name: osv / provider upload - if: ${{ github.event_name != 'pull_request' && (github.event.repository.private == false || vars.ENABLE_CODE_SCANNING_UPLOAD == 'true') }} + if: ${{ !cancelled() && needs.scan.result != 'skipped' && github.event_name != 'pull_request' && (github.event.repository.private == false || vars.ENABLE_CODE_SCANNING_UPLOAD == 'true') }} needs: scan runs-on: ubuntu-24.04 timeout-minutes: 10 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1741220..58ab25e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -391,9 +391,6 @@ jobs: node <<'NODE' const { readFileSync, readdirSync, writeFileSync } = require("node:fs"); - const expectedRecords = require("./artifacts/package/npm-pack.json"); - const publicationMode = readFileSync("artifacts/registry/npm-publication-mode.txt", "utf8").trim(); - const expectedByName = new Map(expectedRecords.map((record) => [record.name, record])); const files = readdirSync("artifacts/registry") .filter((file) => file.startsWith("pack-") && file.endsWith(".json")) .sort(); @@ -405,43 +402,9 @@ jobs: return parsed[0]; }); records.sort((left, right) => left.name.localeCompare(right.name)); - if (records.length !== expectedByName.size) { - throw new Error(`published registry package count mismatch: expected ${expectedByName.size}, got ${records.length}`); - } - const seen = new Set(); - for (const record of records) { - const expected = expectedByName.get(record.name); - if (!expected) { - throw new Error(`unexpected published registry package ${record.name}`); - } - if (seen.has(record.name)) { - throw new Error(`duplicate published registry package ${record.name}`); - } - seen.add(record.name); - for (const field of ["version", "filename", "shasum", "integrity"]) { - if (record[field] !== expected[field]) { - throw new Error(`published registry ${record.name} ${field} mismatch: expected ${expected[field]}, got ${record[field]}`); - } - } - } writeFileSync("artifacts/registry/npm-pack.json", `${JSON.stringify(records, null, 2)}\n`); - const summary = { - artifactKind: "proofkit.published-registry-artifact-set.v1", - publicationMode, - registry: "https://registry.npmjs.org", - source: publicationMode === "published_by_workflow" - ? "post-publish npm pack from registry" - : "registry npm pack byte-match for preexisting version", - packages: records.map((record) => ({ - name: record.name, - version: record.version, - filename: record.filename, - shasum: record.shasum, - integrity: record.integrity - })) - }; - writeFileSync("artifacts/registry/published-registry-artifact-set.json", `${JSON.stringify(summary, null, 2)}\n`); NODE + npm run npm:registry-evidence - name: Verify root-only registry install and signatures run: | @@ -1008,6 +971,7 @@ jobs: --asset-names-file /tmp/proofkit-expected-release-assets.txt cp /tmp/proofkit-release-view.json artifacts/release/github-release.json go run ./internal/tools/releasepreflight retained-evidence --artifact-root artifacts + go run ./internal/tools/releasepreflight retained-evidence-verify --artifact-root artifacts echo "release $GITHUB_REF_NAME already exists and matches the candidate artifacts" >&2 exit 0 fi @@ -1023,6 +987,7 @@ jobs: --notes-file artifacts/release/release-notes.md \ --asset-names-file /tmp/proofkit-expected-release-assets.txt go run ./internal/tools/releasepreflight retained-evidence --artifact-root artifacts + go run ./internal/tools/releasepreflight retained-evidence-verify --artifact-root artifacts final_dir="$(mktemp -d)" for asset in "${expected_assets[@]}"; do asset_name="$(basename "$asset")" diff --git a/AGENTS.md b/AGENTS.md index 5285e33..30c004a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -115,12 +115,26 @@ own release, provider, or deployment evidence. Use the narrowest owner-valid proof first, then the current closeout gate for the imported surface. -For public contract-only changes: +For whitespace hygiene during iteration, check both unstaged and staged +changes against `HEAD`: ```bash git diff --check +git diff --cached --check ``` +For a committed public contract-only closeout, run the narrow owner-valid +contract gate and inspect the committed range rather than an empty worktree +diff: + +```bash +git diff --check "$(git merge-base origin/main HEAD)"...HEAD +npm run command-contract:check +``` + +Neither whitespace command proves contract semantics; the owner-valid contract +or specification test remains mandatory. + For runtime, package, CLI, workflow, or specification changes: ```bash diff --git a/docs/proofkit-contract-map.md b/docs/proofkit-contract-map.md index 2200cb2..d639d1e 100644 --- a/docs/proofkit-contract-map.md +++ b/docs/proofkit-contract-map.md @@ -47,6 +47,13 @@ owner boundaries. It is not a second command-family inventory. | Receipts and producers | `proof-receipt-admission`, `receipt-producer-admission`, `receipt-currentness-scope`, `receipt-trust-class`, `producer-policy-self-proof` | receipt sets, producer policy, scope/currentness facts, trust classes | receipt shape, producer/receipt compatibility, self-proof diagnostics | producer authentication, freshness policy, CI trust roots | receipt/provenance report | | Release and deployment | `release-authority`, `external-consumer`, `registry-consumer-proof-input-compose`, `registry-consumer`, `deployment-evidence-admission`, `completion-criteria`, `branch-authority`, `readiness-closeout` | package facts, tarball/registry facts, explicit primitive registry/install/smoke facts, deployment evidence, criteria, branch facts | artifact/channel boundary checks, registry-consumer input composition, release diagnostics, falsifiable criteria shape | package publication, registry fetch, package-manager execution, deployment, rollback, approval | composed input, release/deployment/readiness report | | Supply-chain and quality | `self-check`, release workflow, `npm run release:sbom`, `npm run self:coverage`, `npm run go:actionlint`, `npm run go:bench` | release artifacts, source workflows, specs, bindings, witness plans, explicit benchmark invocation | deterministic self-check report shape, SBOM candidate evidence, coverage metrics, workflow lint routing, benchmark entrypoints | public-source provenance, vulnerability triage, license approval, CI run admission, release approval | self-check report, SBOM, metrics report, CI signal, or benchmark output | + +The `npm run release:sbom`, `npm run self:coverage`, `npm run go:actionlint`, +and `npm run go:bench` routes above are maintainer commands for a source +checkout. They are not installed-package APIs because their implementations +depend on repository-only `internal/` or workflow sources. Installed consumers +must use the public `agentic-proofkit` CLI routes declared in +`proofkit/cli-contract.v2.json`. | Repository structure | `repo-profile-admission`, `workspace-manifest-facts`, `workspace-registry`, `workspace-changed-package-plan`, `workspace-shard-partition`, `typescript-public-api-surfaces`, `text-policy`, `secret-scan`, `package-runtime-dependency-admission` | explicit repo/profile facts, caller-owned manifest records, caller-owned roots, caller-owned text file inventories, explicit TypeScript package-manifest and per-condition source paths, optional `environmentClassPolicies` tuples | structural admission, manifest-to-workspace fact projection, workspace graph projections, bounded TypeScript package public API checks over referenced files, text policy admission, explicit-inventory secret-like text detection, shard plans | repository freshness, git/file discovery, compiler output provenance, command policy, package manager truth, provider secret scanning | structural, fact, policy, or planning report | | Custom and generated artifacts | `custom-rule-boundary`, `document-lifecycle-boundary`, `rendered-artifact-freshness`, `conformance-profile`, `json-report-cli-adapter-source`, `witness-plan`, `witness-scheduler-plan` | custom rule metadata, document lifecycle records, artifact digests, profile manifests, command metadata, adapter language | boundary checks, generated-view freshness shape, deterministic adapter source generation, scheduler metadata checks | rule execution, document meaning, cache contents, CI scheduling, committed generated-source freshness | boundary report, generated source artifact, or scheduler report | | CLI metadata | `help` | optional command name or help flag | built-in command catalog and help text routing | command selection, semantic proof, freshness, merge policy | text help only | diff --git a/docs/release-process.md b/docs/release-process.md index 9dd3dd0..fcf3101 100644 --- a/docs/release-process.md +++ b/docs/release-process.md @@ -190,7 +190,9 @@ The `release` workflow must: `artifacts/retained-evidence-checksums.sha256`. The checksum manifest lives at the retained artifact root and uses exact `release/...` and `attestations/...` paths, so standard checksum verification executes against - the downloaded artifact layout without path rewriting. The release + the downloaded artifact layout without path rewriting. Each existing-release + and newly-created-release branch immediately runs the repository-owned + `retained-evidence-verify` preflight before reporting success. The release manifest records GitHub Release channel data as candidate/archive inventory; `github-release.json` owns post-create GitHub Release facts only inside retained workflow evidence, not as a public release asset. diff --git a/docs/specs/proofkit-supply-chain-quality/requirements.v1.json b/docs/specs/proofkit-supply-chain-quality/requirements.v1.json index 9075e8f..79c4048 100644 --- a/docs/specs/proofkit-supply-chain-quality/requirements.v1.json +++ b/docs/specs/proofkit-supply-chain-quality/requirements.v1.json @@ -129,7 +129,7 @@ { "requirementId": "REQ-PROOFKIT-QUALITY-010", "ownerId": "proofkit.supply-chain-quality", - "invariant": "Coverage metrics report requirement, binding, witness, CLI inventory linkage, and descriptor-owned command proof-route candidates from admitted test-evidence-inventory rows, failing closed independently for each requirement/proof linkage dead-zone class and each missing-candidate, unknown-candidate-ref, unknown-semantic-ref, contract-only, or route-only command-route class; source-checkout witness selectors must resolve to valid functions in active Go test files with exact executable commands, critical anti-vacuity scenarios must retain their exact closed selector inventories, and failed command-route inventory admission also fails closed, while static route metadata, prose, source markers, test existence, and failure-capable syntax remain candidate evidence and cannot satisfy semantic_falsifier coverage.", + "invariant": "Coverage metrics report requirement, binding, witness, CLI inventory linkage, and descriptor-owned command proof-route candidates from admitted test-evidence-inventory rows, failing closed independently for each requirement/proof linkage dead-zone class and each missing-candidate, unknown-candidate-ref, unknown-semantic-ref, contract-only, or route-only command-route class; source-checkout witness selectors must resolve to valid functions in active Go test files with exact executable commands and a failure-capable assertion candidate outside literal-dead branches, uninvoked closures, and skip-bearing helper paths, critical anti-vacuity scenarios must retain their exact closed selector inventories, and failed command-route inventory admission also fails closed, while static route metadata, prose, source markers, test existence, and failure-capable syntax remain candidate evidence and cannot satisfy semantic_falsifier coverage.", "claimLevel": "blocking", "riskClass": "medium", "proofBindingRefs": ["proofkit/requirement-bindings.json"], @@ -311,7 +311,7 @@ { "requirementId": "REQ-PROOFKIT-QUALITY-024", "ownerId": "proofkit.supply-chain-quality", - "invariant": "Release metadata generation admits one closed schema-versioned machine-readable declaration of the reviewed public-contract change set, requires exact ordered equality for the complete current breaking-change, addition, and migration inventories, including channel-specific changes to public generated continuation bytes, binds its exact previous and current canonical SemVer values to a compatible or breaking change class, rejects missing, substituted, reordered, or surplus current entries plus non-monotonic or patch-range breaking releases, and renders one byte-exact independently authored complete current release-note projection with no relocated, duplicate, appended, surplus, or second owned section; release candidate preflight binds the admitted npm package identity and candidate state to registry latest so an unpublished candidate requires latest to equal previousVersion and an exact existing-byte-match candidate requires latest to equal version; one repository-owned retained-evidence builder and verifier checksum the exact final downloadable artifact topology with artifact-relative paths, reject unbound evidence files and symlink substitution, and fail release closeout on record, note, path, or digest drift.", + "invariant": "Release metadata generation admits one closed schema-versioned machine-readable declaration of the reviewed public-contract change set, requires exact ordered equality for the complete current breaking-change, addition, and migration inventories, including channel-specific changes to public generated continuation bytes, binds its exact previous and current canonical SemVer values to a compatible or breaking change class, rejects missing, substituted, reordered, or surplus current entries plus non-monotonic or patch-range breaking releases, and renders one byte-exact independently authored complete current release-note projection with no relocated, duplicate, appended, surplus, or second owned section; release candidate preflight binds the admitted npm package identity and candidate state to registry latest so an unpublished candidate requires latest to equal previousVersion and an exact existing-byte-match candidate requires latest to equal version; release metadata promotes the npm channel to published only from a strictly admitted registry-authority record whose unique filename-keyed package set exactly matches both retained registry records and local candidate identity and bytes; one repository-owned retained-evidence builder and verifier checksum the exact final downloadable artifact topology with artifact-relative paths, reject unbound evidence files and symlink substitution, and fail release closeout on record, note, path, or digest drift.", "claimLevel": "blocking", "riskClass": "high", "proofBindingRefs": ["proofkit/requirement-bindings.json"], diff --git a/internal/app/app.go b/internal/app/app.go index 226dee4..d649758 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -49,15 +49,15 @@ func RunWithRenderer(ctx context.Context, args []string, stdin io.Reader, stdout return writeText(commandUsageWithRenderer(descriptor, renderer), 0, nil, stdout, stderr) } parsedArguments := classifyDescriptorArguments(descriptor, args[1:]) - if err := validateJSONLayoutUse(descriptor, parsedArguments, layoutExplicit); err != nil { + if err := validateFlagConstraints(descriptor, parsedArguments); err != nil { writeDiagnostic(stderr, err) return 1 } - stdout = layoutWriter{Writer: stdout, layout: layout} - if err := validateFlagConstraints(descriptor, parsedArguments); err != nil { + if err := validateJSONLayoutUse(descriptor, parsedArguments, layoutExplicit); err != nil { writeDiagnostic(stderr, err) return 1 } + stdout = layoutWriter{Writer: stdout, layout: layout} switch descriptor.runner { case commandRunnerHelp: if len(args) >= 2 && args[1] == "families" { diff --git a/internal/app/app_test.go b/internal/app/app_test.go index 1d460b8..aedd03f 100644 --- a/internal/app/app_test.go +++ b/internal/app/app_test.go @@ -40,7 +40,7 @@ func TestRequirementBrowserInvalidViewDiagnosticMatchesRuntime(t *testing.T) { &stdout, &stderr, ) - const vocabulary = "source, proof, coverage, spec-tree, or workspace" + const vocabulary = "coverage, proof, source, spec-tree, workspace" if status != 1 || stdout.Len() != 0 || strings.Count(stderr.String(), vocabulary) != 1 || @@ -236,6 +236,18 @@ func TestCompactJSONLayoutAppliesToRequirementBrowserPlan(t *testing.T) { } } +func TestDuplicateFormatIsRejectedBeforeInputRead(t *testing.T) { + var stdout bytes.Buffer + var stderr bytes.Buffer + status := Run(t.Context(), []string{ + "--json-layout", "compact", + "requirement-source-view", "--input", "-", "--format", "markdown", "--format", "json", + }, panicReader{}, &stdout, &stderr) + if status != 1 || stdout.Len() != 0 || !strings.Contains(stderr.String(), "--format may be specified only once") { + t.Fatalf("status=%d stdout=%q stderr=%q", status, stdout.String(), stderr.String()) + } +} + func TestSelfCheckRejectsDuplicateKeys(t *testing.T) { commandcoverage.SemanticRoute(t, "proofkit.command_coverage.source_oracle.v1.061049109061347524269448772857617649849822202469664158122537165529475398131547") var stdout bytes.Buffer diff --git a/internal/app/cli_abi_test.go b/internal/app/cli_abi_test.go index 4818aff..a5b630a 100644 --- a/internal/app/cli_abi_test.go +++ b/internal/app/cli_abi_test.go @@ -1662,7 +1662,7 @@ func TestStandaloneMultiVariantCommandsUseExactRootShapes(t *testing.T) { }{ { name: "repeated pilot", - args: []string{"--pilot", "all", "--pilot", "first"}, + args: []string{"--contract-envelope", "--pilot", "all", "--pilot", "first"}, }, { name: "stack alias then pilot", diff --git a/internal/app/cli_contract_test.go b/internal/app/cli_contract_test.go index 613aa3e..75894b2 100644 --- a/internal/app/cli_contract_test.go +++ b/internal/app/cli_contract_test.go @@ -22,7 +22,7 @@ import ( ) const ( - cliContractPublicABISHA256 = "8c1ed8d811ee9421a773647cc6255dcd233af6d5ab24c2c7d76bca06760da723" + cliContractPublicABISHA256 = "472517950d99dc3c6ce772ac6f63ca5be2a4ff3692aac3025dd7f4adb09289b3" maxAggregateFileReadBytesForContractTest = 64 << 20 maxPackageManifestBytesForContractTest = 256 << 10 maxSourceFileBytesForContractTest = 8 << 20 @@ -787,16 +787,20 @@ func TestCLIContractPublicABIGoldenStable(t *testing.T) { commands := []any{} for _, command := range contract.Commands { record := map[string]any{ - "allowedFlags": stringsAsAny(command.AllowedFlags), - "command": command.Command, - "exactlyOneOfFlagGroups": stringMatrixAsAny(command.ExactlyOneOfFlagGroups), - "flagValueRequirements": command.FlagValueRequirements, - "input": command.Input, - "inputPointer": command.InputPointer, - "outputModes": stringsAsAny(command.OutputModes), - "requiredFlags": stringsAsAny(command.RequiredFlags), - "scopeClass": command.ScopeClass, - "stdin": command.Stdin, + "atMostOneOfFlagGroups": stringMatrixAsAny(command.AtMostOneOfFlagGroups), + "allowedFlags": stringsAsAny(command.AllowedFlags), + "command": command.Command, + "exactlyOneOfFlagGroups": stringMatrixAsAny(command.ExactlyOneOfFlagGroups), + "flagChoices": command.FlagChoices, + "flagPresenceRequirements": command.FlagPresenceRequirements, + "flagValueRequirements": command.FlagValueRequirements, + "input": command.Input, + "inputPointer": command.InputPointer, + "outputModes": stringsAsAny(command.OutputModes), + "requiredFlags": stringsAsAny(command.RequiredFlags), + "scopeClass": command.ScopeClass, + "singleOccurrenceFlags": stringsAsAny(command.SingleOccurrenceFlags), + "stdin": command.Stdin, } if command.AgentEnvelope != nil { record["agentEnvelope"] = *command.AgentEnvelope @@ -935,6 +939,34 @@ func TestCommandDescriptorContractParityRejectsMutations(t *testing.T) { }), commands: contract.Commands, }, + { + name: "flag presence requirement drift", + descriptors: mutateDescriptor("requirement-browser-server", func(descriptor *commandDescriptor) { + descriptor.flagPresenceRequirements = nil + }), + commands: contract.Commands, + }, + { + name: "at-most-one constraint drift", + descriptors: mutateDescriptor("requirement-browser-server", func(descriptor *commandDescriptor) { + descriptor.atMostOneOfFlagGroups = nil + }), + commands: contract.Commands, + }, + { + name: "single-occurrence constraint drift", + descriptors: mutateDescriptor("requirement-source-view", func(descriptor *commandDescriptor) { + descriptor.singleOccurrenceFlags = nil + }), + commands: contract.Commands, + }, + { + name: "flag choice drift", + descriptors: mutateDescriptor("requirement-browser-server", func(descriptor *commandDescriptor) { + descriptor.flagValueChoices["--scope"] = []string{"graph"} + }), + commands: contract.Commands, + }, { name: "scope class drift", descriptors: mutateDescriptor("typescript-public-api-surfaces", func(descriptor *commandDescriptor) { @@ -1102,9 +1134,21 @@ func commandDescriptorContractParityProblems(descriptors []commandDescriptor, co if !reflect.DeepEqual(descriptor.exactlyOneOfFlagGroups, command.ExactlyOneOfFlagGroups) { problems = append(problems, "exactly-one flag group drift "+name) } + if !reflect.DeepEqual(descriptor.atMostOneOfFlagGroups, command.AtMostOneOfFlagGroups) { + problems = append(problems, "at-most-one flag group drift "+name) + } + if !reflect.DeepEqual(descriptor.flagPresenceRequirements, command.FlagPresenceRequirements) { + problems = append(problems, "flag presence requirement drift "+name) + } if !reflect.DeepEqual(descriptor.flagValueRequirements, command.FlagValueRequirements) { problems = append(problems, "flag value requirement drift "+name) } + if !equalFlagChoiceMaps(descriptor.flagValueChoices, command.FlagChoices) { + problems = append(problems, "flag choice drift "+name) + } + if !equalStringSets(descriptor.singleOccurrenceFlags, command.SingleOccurrenceFlags) { + problems = append(problems, "single-occurrence flag drift "+name) + } if !equalStringSets(descriptor.outputModes, command.OutputModes) { problems = append(problems, "output mode drift "+name) } @@ -1142,7 +1186,7 @@ func commandDescriptorTopologyProblems(descriptors []commandDescriptor) []string if len(descriptor.semanticOwnerDirs) == 0 { problems = append(problems, "missing semantic owner dirs "+descriptor.name) } - if !isSortedUnique(descriptor.allowedFlags) || !isSortedUnique(descriptor.requiredFlags) || !isSortedUnique(descriptor.outputModes) || !isSortedUnique(descriptor.semanticOwnerDirs) || !isSortedUnique(descriptor.semanticAppTests) { + if !isSortedUnique(descriptor.allowedFlags) || !isSortedUnique(descriptor.requiredFlags) || !isSortedUnique(descriptor.singleOccurrenceFlags) || !isSortedUnique(descriptor.outputModes) || !isSortedUnique(descriptor.semanticOwnerDirs) || !isSortedUnique(descriptor.semanticAppTests) || !isSortedUniqueFlagPresenceRequirements(descriptor.flagPresenceRequirements) || !isSortedUniqueFlagValueRequirements(descriptor.flagValueRequirements) { problems = append(problems, "unsorted descriptor list "+descriptor.name) } for _, requiredFlag := range descriptor.requiredFlags { @@ -1160,8 +1204,33 @@ func commandDescriptorTopologyProblems(descriptors []commandDescriptor) []string } } } + for _, group := range descriptor.atMostOneOfFlagGroups { + if len(group) < 2 || !isSortedUnique(group) { + problems = append(problems, "invalid at-most-one flag group "+descriptor.name) + } + for _, flag := range group { + if !slices.Contains(descriptor.allowedFlags, flag) { + problems = append(problems, "at-most-one flag is not allowed "+descriptor.name+" "+flag) + } + } + } + for _, requirement := range descriptor.flagPresenceRequirements { + if requirement.Flag == "" || !slices.Contains(descriptor.allowedFlags, requirement.Flag) || !isSortedUnique(requirement.RequiredFlags) || !isSortedUniqueRequiredFlagValues(requirement.RequiredFlagValues) { + problems = append(problems, "invalid flag presence requirement "+descriptor.name) + } + for _, flag := range requirement.RequiredFlags { + if !slices.Contains(descriptor.allowedFlags, flag) { + problems = append(problems, "presence-required flag is not allowed "+descriptor.name+" "+flag) + } + } + for _, required := range requirement.RequiredFlagValues { + if !slices.Contains(descriptor.allowedFlags, required.Flag) { + problems = append(problems, "presence-required flag is not allowed "+descriptor.name+" "+required.Flag) + } + } + } for _, requirement := range descriptor.flagValueRequirements { - if requirement.Flag == "" || requirement.Value == "" || !slices.Contains(descriptor.allowedFlags, requirement.Flag) || !isSortedUnique(requirement.RequiredFlags) { + if requirement.Flag == "" || requirement.Value == "" || !slices.Contains(descriptor.allowedFlags, requirement.Flag) || !isSortedUnique(requirement.RequiredFlags) || !isSortedUniqueRequiredFlagValues(requirement.RequiredFlagValues) { problems = append(problems, "invalid flag value requirement "+descriptor.name) } for _, flag := range requirement.RequiredFlags { @@ -1169,6 +1238,16 @@ func commandDescriptorTopologyProblems(descriptors []commandDescriptor) []string problems = append(problems, "value-required flag is not allowed "+descriptor.name+" "+flag) } } + for _, required := range requirement.RequiredFlagValues { + if !slices.Contains(descriptor.allowedFlags, required.Flag) { + problems = append(problems, "value-required flag is not allowed "+descriptor.name+" "+required.Flag) + } + } + } + for _, flag := range descriptor.singleOccurrenceFlags { + if !slices.Contains(descriptor.allowedFlags, flag) { + problems = append(problems, "single-occurrence flag is not allowed "+descriptor.name+" "+flag) + } } if descriptor.input == commandInputNone && descriptor.runner == commandRunnerGenericInput { problems = append(problems, "no-input command uses generic input runner "+descriptor.name) @@ -1193,7 +1272,11 @@ func cloneCLIContractCommands(commands []cliContractCommand) []cliContractComman copied.AllowedFlags = cloneStrings(command.AllowedFlags) copied.RequiredFlags = cloneStrings(command.RequiredFlags) copied.ExactlyOneOfFlagGroups = cloneStringMatrix(command.ExactlyOneOfFlagGroups) + copied.AtMostOneOfFlagGroups = cloneStringMatrix(command.AtMostOneOfFlagGroups) + copied.FlagPresenceRequirements = cloneFlagPresenceRequirements(command.FlagPresenceRequirements) copied.FlagValueRequirements = cloneFlagValueRequirements(command.FlagValueRequirements) + copied.FlagChoices = cloneStringMap(command.FlagChoices) + copied.SingleOccurrenceFlags = cloneStrings(command.SingleOccurrenceFlags) copied.OutputModes = cloneStrings(command.OutputModes) clone = append(clone, copied) } @@ -1385,7 +1468,7 @@ func TestDescriptorFlagConstraintsAreRenderedTruthfully(t *testing.T) { "adoption-contract-envelope": "agentic-proofkit adoption-contract-envelope --input [--agent-envelope] [--checked-scope ] [--guidance-mode ] [--materialization-manifest] --mode [--pilot ] [--touched-rule-id ]", "conformance-profile": "agentic-proofkit conformance-profile --input [--format ] [--input-pointer ] (--list | --profile | --verify)", "json-report-cli-adapter-source": "agentic-proofkit json-report-cli-adapter-source [--format ] --language ", - "requirement-browser-server": "agentic-proofkit requirement-browser-server --input [--empty-local-environment-policy] [--host 127.0.0.1|::1] [--input-pointer ] [--local-environment-class ] [--open] [--port ] [--scope ] [--serve] [--session-mode browse|one-shot-question] [--session-timeout-seconds <1..7200>] --view ", + "requirement-browser-server": "agentic-proofkit requirement-browser-server --input [--empty-local-environment-policy] [--host <127.0.0.1|::1>] [--input-pointer ] [--local-environment-class ] [--open] [--port ] [--scope ] [--serve] [--session-mode ] [--session-timeout-seconds <1..7200>] --view ", "requirement-context-compose": "agentic-proofkit requirement-context-compose --input [--input-pointer ] --repo-root ", "requirement-proof-resolver": "agentic-proofkit requirement-proof-resolver --input [--input-pointer ] (--empty-local-environment-policy | --local-environment-class )", "stack-preset": "agentic-proofkit stack-preset --preset ", @@ -1410,12 +1493,38 @@ func TestDescriptorFlagConstraintsAreRenderedTruthfully(t *testing.T) { } } commandHelp := commandUsage(descriptor) + for _, group := range descriptor.atMostOneOfFlagGroups { + expected := " At most one of: " + strings.Join(group, ", ") + if !strings.Contains(commandHelp, expected) { + t.Fatalf("%s at-most-one constraint %q is missing from command help", descriptor.name, expected) + } + } + for _, requirement := range descriptor.flagPresenceRequirements { + required := cloneStrings(requirement.RequiredFlags) + for _, value := range requirement.RequiredFlagValues { + required = append(required, value.Flag+" "+value.Value) + } + expected := fmt.Sprintf(" %s requires: %s", requirement.Flag, strings.Join(required, ", ")) + if !strings.Contains(commandHelp, expected) { + t.Fatalf("%s presence constraint %q is missing from command help", descriptor.name, expected) + } + } for _, requirement := range descriptor.flagValueRequirements { - expected := fmt.Sprintf(" %s %s requires: %s", requirement.Flag, requirement.Value, strings.Join(requirement.RequiredFlags, ", ")) + required := cloneStrings(requirement.RequiredFlags) + for _, value := range requirement.RequiredFlagValues { + required = append(required, value.Flag+" "+value.Value) + } + expected := fmt.Sprintf(" %s %s requires: %s", requirement.Flag, requirement.Value, strings.Join(required, ", ")) if !strings.Contains(commandHelp, expected) { t.Fatalf("%s value constraint %q is missing from command help", descriptor.name, expected) } } + for _, flag := range descriptor.singleOccurrenceFlags { + expected := " May be specified once: " + flag + if !strings.Contains(commandHelp, expected) { + t.Fatalf("%s occurrence constraint %q is missing from command help", descriptor.name, expected) + } + } } if constrainedCount != len(expectedConstrainedUsage) { t.Fatalf("constrained descriptor count = %d, independent help oracle count = %d", constrainedCount, len(expectedConstrainedUsage)) @@ -1429,6 +1538,14 @@ func TestDescriptorFlagConstraintsExecuteBeforeCommandDispatch(t *testing.T) { }{ {command: "adoption-contract-envelope", args: []string{"--input", "-"}}, {command: "conformance-profile", args: []string{"--input", "-", "--list", "--verify"}}, + {command: "pilot-admission", args: []string{"--input", "-", "--pilot", "all"}}, + {command: "requirement-browser-server", args: []string{"--input", "-", "--open", "--view", "source"}}, + {command: "requirement-browser-server", args: []string{"--input", "-", "--scope", "graph", "--view", "source"}}, + {command: "requirement-browser-server", args: []string{"--empty-local-environment-policy", "--input", "-", "--local-environment-class", "local-go", "--view", "proof"}}, + {command: "requirement-browser-server", args: []string{"--input", "-", "--open", "--serve", "--session-mode", "one-shot-question", "--view", "spec-tree"}}, + {command: "requirement-browser-server", args: []string{"--input", "-", "--serve", "--session-timeout-seconds", "30", "--view", "workspace"}}, + {command: "requirement-browser-server", args: []string{"--input", "-", "--scope", "unknown", "--view", "proof"}}, + {command: "requirement-browser-server", args: []string{"--input", "-", "--serve", "--session-mode", "browse", "--session-mode", "browse", "--view", "workspace"}}, {command: "requirement-proof-resolver", args: []string{"--input", "-"}}, {command: "stack-preset", args: nil}, } @@ -1440,6 +1557,53 @@ func TestDescriptorFlagConstraintsExecuteBeforeCommandDispatch(t *testing.T) { } } +func TestRequirementBrowserDescriptorMatchesRuntimeConditionalFlags(t *testing.T) { + descriptor := commandDescriptorByName["requirement-browser-server"] + wantChoices := map[string][]string{ + "--host": {"127.0.0.1", "::1"}, + "--scope": {"graph", "slice"}, + "--session-mode": {"browse", "one-shot-question"}, + "--view": {"coverage", "proof", "source", "spec-tree", "workspace"}, + } + if !reflect.DeepEqual(descriptor.flagValueChoices, wantChoices) { + t.Fatalf("browser flag choices=%v, want %v", descriptor.flagValueChoices, wantChoices) + } + if !slices.Equal(descriptor.singleOccurrenceFlags, requirementBrowserSingleOccurrenceFlags) { + t.Fatalf("browser singleton flags=%v, want %v", descriptor.singleOccurrenceFlags, requirementBrowserSingleOccurrenceFlags) + } + help := commandUsage(descriptor) + for _, fragment := range []string{"--host <127.0.0.1|::1>", "--scope ", "--session-mode ", "--view "} { + if !strings.Contains(help, fragment) { + t.Fatalf("browser help missing descriptor choice projection %q:\n%s", fragment, help) + } + } + for _, test := range []struct { + name string + args []string + }{ + {name: "source view", args: []string{"--input", "-", "--view", "source"}}, + {name: "browse session", args: []string{"--input", "-", "--serve", "--session-mode", "browse", "--view", "workspace"}}, + {name: "one shot session", args: []string{"--input", "-", "--open", "--serve", "--session-mode", "one-shot-question", "--session-timeout-seconds", "30", "--view", "workspace"}}, + {name: "proof scope", args: []string{"--input", "-", "--local-environment-class", "local-go", "--scope", "graph", "--view", "proof"}}, + {name: "open without serve", args: []string{"--input", "-", "--open", "--view", "source"}}, + {name: "browse wrong view", args: []string{"--input", "-", "--serve", "--session-mode", "browse", "--view", "source"}}, + {name: "timeout without one shot", args: []string{"--input", "-", "--serve", "--session-timeout-seconds", "30", "--view", "workspace"}}, + {name: "scope outside proof", args: []string{"--input", "-", "--scope", "graph", "--view", "source"}}, + {name: "conflicting environment policy", args: []string{"--empty-local-environment-policy", "--input", "-", "--local-environment-class", "local-go", "--view", "proof"}}, + {name: "invalid scope", args: []string{"--input", "-", "--scope", "unknown", "--view", "proof"}}, + {name: "repeated session mode", args: []string{"--input", "-", "--serve", "--session-mode", "browse", "--session-mode", "browse", "--view", "workspace"}}, + {name: "repeated session timeout", args: []string{"--input", "-", "--open", "--serve", "--session-mode", "one-shot-question", "--session-timeout-seconds", "30", "--session-timeout-seconds", "30", "--view", "workspace"}}, + } { + t.Run(test.name, func(t *testing.T) { + descriptorErr := validateFlagConstraints(descriptor, classifyDescriptorArguments(descriptor, test.args)) + _, runtimeErr := parseRequirementBrowserArgs(test.args) + if (descriptorErr == nil) != (runtimeErr == nil) { + t.Fatalf("descriptor error=%v runtime error=%v for argv %v", descriptorErr, runtimeErr, test.args) + } + }) + } +} + type cliContract struct { Commands []cliContractCommand `json:"commands"` ContractDefinitions []any `json:"contractDefinitions"` @@ -1450,20 +1614,24 @@ type cliContract struct { } type cliContractCommand struct { - AgentEnvelope *bool `json:"agentEnvelope,omitempty"` - AllowedFlags []string `json:"allowedFlags"` - Command string `json:"command"` - ContractEnvelope *bool `json:"contractEnvelope,omitempty"` - ExactlyOneOfFlagGroups [][]string `json:"exactlyOneOfFlagGroups,omitempty"` - FlagValueRequirements []flagValueRequirement `json:"flagValueRequirements,omitempty"` - Input string `json:"input"` - InputContract any `json:"inputContract,omitempty"` - InputPointer bool `json:"inputPointer"` - OutputContract any `json:"outputContract,omitempty"` - OutputModes []string `json:"outputModes"` - RequiredFlags []string `json:"requiredFlags,omitempty"` - ScopeClass string `json:"scopeClass"` - Stdin bool `json:"stdin"` + AgentEnvelope *bool `json:"agentEnvelope,omitempty"` + AllowedFlags []string `json:"allowedFlags"` + AtMostOneOfFlagGroups [][]string `json:"atMostOneOfFlagGroups,omitempty"` + Command string `json:"command"` + ContractEnvelope *bool `json:"contractEnvelope,omitempty"` + ExactlyOneOfFlagGroups [][]string `json:"exactlyOneOfFlagGroups,omitempty"` + FlagChoices map[string][]string `json:"flagChoices,omitempty"` + FlagPresenceRequirements []flagPresenceRequirement `json:"flagPresenceRequirements,omitempty"` + FlagValueRequirements []flagValueRequirement `json:"flagValueRequirements,omitempty"` + Input string `json:"input"` + InputContract any `json:"inputContract,omitempty"` + InputPointer bool `json:"inputPointer"` + OutputContract any `json:"outputContract,omitempty"` + OutputModes []string `json:"outputModes"` + RequiredFlags []string `json:"requiredFlags,omitempty"` + ScopeClass string `json:"scopeClass"` + SingleOccurrenceFlags []string `json:"singleOccurrenceFlags,omitempty"` + Stdin bool `json:"stdin"` } func readCLIContract(t *testing.T) cliContract { @@ -1536,20 +1704,24 @@ func assertCLIContractSchema(t *testing.T) { t.Fatalf("decode raw CLI commands: %v", err) } allowedCommandKeys := map[string]struct{}{ - "agentEnvelope": {}, - "allowedFlags": {}, - "command": {}, - "contractEnvelope": {}, - "exactlyOneOfFlagGroups": {}, - "flagValueRequirements": {}, - "input": {}, - "inputContract": {}, - "inputPointer": {}, - "outputContract": {}, - "outputModes": {}, - "requiredFlags": {}, - "scopeClass": {}, - "stdin": {}, + "agentEnvelope": {}, + "allowedFlags": {}, + "atMostOneOfFlagGroups": {}, + "command": {}, + "contractEnvelope": {}, + "exactlyOneOfFlagGroups": {}, + "flagChoices": {}, + "flagPresenceRequirements": {}, + "flagValueRequirements": {}, + "input": {}, + "inputContract": {}, + "inputPointer": {}, + "outputContract": {}, + "outputModes": {}, + "requiredFlags": {}, + "scopeClass": {}, + "singleOccurrenceFlags": {}, + "stdin": {}, } for index, command := range commands { for key := range command { @@ -1562,6 +1734,91 @@ func assertCLIContractSchema(t *testing.T) { t.Fatalf("CLI command %d missing required key %s", index, required) } } + var flagChoices map[string][]string + if raw, ok := command["flagChoices"]; ok { + if err := json.Unmarshal(raw, &flagChoices); err != nil { + t.Fatalf("decode CLI command %d flag choices: %v", index, err) + } + var allowedFlags []string + if err := json.Unmarshal(command["allowedFlags"], &allowedFlags); err != nil { + t.Fatalf("decode CLI command %d allowed flags: %v", index, err) + } + for flag, choices := range flagChoices { + if !slices.Contains(allowedFlags, flag) || !isSortedUnique(choices) { + t.Fatalf("CLI command %d has invalid choices for %s: %v", index, flag, choices) + } + } + } + var presenceRequirements []map[string]json.RawMessage + if raw, ok := command["flagPresenceRequirements"]; ok { + if err := json.Unmarshal(raw, &presenceRequirements); err != nil { + t.Fatalf("decode CLI command %d flag presence requirements: %v", index, err) + } + } + for requirementIndex, requirement := range presenceRequirements { + allowedRequirementKeys := map[string]struct{}{ + "flag": {}, + "requiredFlagValues": {}, + "requiredFlags": {}, + } + for key := range requirement { + if _, ok := allowedRequirementKeys[key]; !ok { + t.Fatalf("CLI command %d flag presence requirement %d has unsupported key %s", index, requirementIndex, key) + } + } + for _, required := range []string{"flag", "requiredFlags"} { + if _, ok := requirement[required]; !ok { + t.Fatalf("CLI command %d flag presence requirement %d missing required key %s", index, requirementIndex, required) + } + } + var requiredValues []map[string]json.RawMessage + if raw, ok := requirement["requiredFlagValues"]; ok { + if err := json.Unmarshal(raw, &requiredValues); err != nil { + t.Fatalf("decode CLI command %d flag presence requirement %d required values: %v", index, requirementIndex, err) + } + } + for valueIndex, requiredValue := range requiredValues { + assertKeys(t, fmt.Sprintf("CLI command %d flag presence requirement %d required value %d", index, requirementIndex, valueIndex), keys(requiredValue), []string{"flag", "value"}) + } + } + var requirements []map[string]json.RawMessage + if raw, ok := command["flagValueRequirements"]; ok { + if err := json.Unmarshal(raw, &requirements); err != nil { + t.Fatalf("decode CLI command %d flag value requirements: %v", index, err) + } + } + for requirementIndex, requirement := range requirements { + allowedRequirementKeys := map[string]struct{}{ + "flag": {}, + "requiredFlagValues": {}, + "requiredFlags": {}, + "value": {}, + } + for key := range requirement { + if _, ok := allowedRequirementKeys[key]; !ok { + t.Fatalf("CLI command %d flag value requirement %d has unsupported key %s", index, requirementIndex, key) + } + } + for _, required := range []string{"flag", "requiredFlags", "value"} { + if _, ok := requirement[required]; !ok { + t.Fatalf("CLI command %d flag value requirement %d missing required key %s", index, requirementIndex, required) + } + } + var requiredValues []map[string]json.RawMessage + if raw, ok := requirement["requiredFlagValues"]; ok { + if err := json.Unmarshal(raw, &requiredValues); err != nil { + t.Fatalf("decode CLI command %d flag value requirement %d required values: %v", index, requirementIndex, err) + } + } + for valueIndex, requiredValue := range requiredValues { + assertKeys(t, fmt.Sprintf("CLI command %d flag value requirement %d required value %d", index, requirementIndex, valueIndex), keys(requiredValue), []string{"flag", "value"}) + for _, required := range []string{"flag", "value"} { + if _, ok := requiredValue[required]; !ok { + t.Fatalf("CLI command %d flag value requirement %d required value %d missing required key %s", index, requirementIndex, valueIndex, required) + } + } + } + } } } @@ -2141,6 +2398,18 @@ func equalStringSets(left []string, right []string) bool { return equalStrings(left, right) } +func equalFlagChoiceMaps(left map[string][]string, right map[string][]string) bool { + if len(left) != len(right) { + return false + } + for flag, choices := range left { + if !slices.Equal(choices, right[flag]) { + return false + } + } + return true +} + func contains(values []string, needle string) bool { for _, value := range values { if value == needle { diff --git a/internal/app/command_contract_generated.go b/internal/app/command_contract_generated.go index 49410f3..cd858f9 100644 --- a/internal/app/command_contract_generated.go +++ b/internal/app/command_contract_generated.go @@ -1,7 +1,7 @@ // Code generated by internal/tools/commandcontractgen; DO NOT EDIT. package app -const commandContractSourceSHA256 = "82d4ba762c0773ab5b2ba400eb488b201ea4bbb87fae395968b0e1aba3dd022f" +const commandContractSourceSHA256 = "a6d6faaa1da035747847d7e2f9232857aa9e342413a1f7c2408ce2ca4cbe832b" type generatedCommandContractMetadata struct { InputContractSHA256 string @@ -13,7 +13,7 @@ type generatedCommandContractMetadata struct { var generatedCommandContractMetadataByName = map[string]generatedCommandContractMetadata{ "adoption-checklist": {InputContractSHA256: "sha256:4e6c4c9b369279837a5894c0b3f842a411dce529b91c91cb2d4ec63eb5ee4c2c", InputSchemaSummary: []string{"schemaVersion=1", "root-shape-only definition proofkit.adoption-checklist.input.v1.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:9d0d0e60f0935407fd31007d8502459663eb4c7228dc5e3c7727ae2c9907bdc9", FlagChoices: map[string][]string{}}, "adoption-contract-envelope": {InputContractSHA256: "sha256:33204c558b8d2d41ede42cbbebaa079ea73cdbd9568b45c0cfd5c61f7a45ccd5", InputSchemaSummary: []string{"schemaVersion=1", "root-shape-only definition proofkit.adoption-contract-envelope.input.v1.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:e66dcc00c0a1aee44c4a221f96e2f009918b67cfd9da4b352e087bcf780e4c7c", FlagChoices: map[string][]string{}}, - "adoption-doctor": {InputContractSHA256: "sha256:622f97a9bb82bb5cf772f43fe3ba79db79dc8d82f8f23f1dfb2b82e2d7c8f7ca", InputSchemaSummary: []string{"schemaVersion=1", "root-shape-only definition proofkit.adoption-doctor.input.v1.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:901ec6f06d36928738c7603d126858f9bf5f32df8bcd521b5bfa9f053c722bcd", FlagChoices: map[string][]string{}}, + "adoption-doctor": {InputContractSHA256: "sha256:43c741dab52b0e0204eace2b693c091aeb9c280b4d8c27e956dc524188bd9f42", InputSchemaSummary: []string{"schemaVersion=1", "root-shape-only definition proofkit.adoption-doctor.input.v1.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:b7d03277879eaec1d928b870a51242d399f17ee03e16dd7fc355af1109b4831f", FlagChoices: map[string][]string{}}, "adoption-workflow-plan": {InputContractSHA256: "sha256:b32ae67179d7b6dcf1ea66cb6b2b2691c8367ce2e2be367619b65973166da55c", InputSchemaSummary: []string{"schemaVersion=1", "root-shape-only definition proofkit.adoption-workflow-plan.input.v1.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:8d64cb53ebd0307e3cebc3435286a3d2a1ee8a0ad6f7514fc0fb3285db0f565b", FlagChoices: map[string][]string{}}, "agent-route": {InputContractSHA256: "sha256:ad0a30069885ee74212d7cd0bed6461ff6d835127b7a7d10f24cb9f941ad2d54", InputSchemaSummary: []string{"availableInputs", "browserMode", "goal", "knownChangedPaths", "mode", "nonClaims", "observedReports", "openBrowser", "routeId", "schemaVersion", "root-shape-only definition proofkit.agent-route.input.v1.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:805ffb86061551b826671fdab70f3c7396d6ba26d07c44eadb9853d63a35083b", FlagChoices: map[string][]string{}}, "binding-partition": {InputContractSHA256: "sha256:366ad082045af52b2ac6604f18626d0f285b2db73b45d9a82687b8d3b0d2b3fd", InputSchemaSummary: []string{"schemaVersion=1", "root-shape-only definition proofkit.binding-partition.input.v1.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:52840879e13a00ef9a4abaad6cdb33000511674d5f9003fb56f387fdf58fadc8", FlagChoices: map[string][]string{}}, @@ -34,54 +34,54 @@ var generatedCommandContractMetadataByName = map[string]generatedCommandContract "impact": {InputContractSHA256: "sha256:c67d90fde422b44e765e46712e99a2e32af64caf3eaf98abc3992ec805204153", InputSchemaSummary: []string{"schemaVersion=1", "root-shape-only definition proofkit.impact.input.v1.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:f3a528a18045c6c47b9b4eb199272c7b9e322657c8307e051eec49533140d4c6", FlagChoices: map[string][]string{}}, "init": {InputContractSHA256: "", InputSchemaSummary: []string(nil), OutputContractSHA256: "sha256:3e59a3002327c759e5e747f8baacaa63a4d6784e1a1c520f0a54e01af3f2faa0", FlagChoices: map[string][]string{}}, "json-report-cli-adapter-source": {InputContractSHA256: "", InputSchemaSummary: []string(nil), OutputContractSHA256: "sha256:5fcab8ffc599c54045ee04e5eb5abbe0175791e74d5f7edb2940e89424c5b4aa", FlagChoices: map[string][]string{}}, - "migration-parity-admission": {InputContractSHA256: "sha256:cefa31d13404e638e321c8d8d73b96082dd86fbaa1af13f0d12c16ab3e95d3e6", InputSchemaSummary: []string{"schemaVersion=1", "paritySetId", "sourceProofOwners[]", "targetProofkitRefs[]", "parityRecords[]", "nonClaims[]", "root-shape-only definition proofkit.migration-parity-admission.input.v1.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:52ecee9e28f374142c981eba96cccdb8eb017202556bcf11123ffe71160a7db4", FlagChoices: map[string][]string{}}, + "migration-parity-admission": {InputContractSHA256: "sha256:767c6e333db22837c4accb3e3d44b259fb5b2f54c783351e148ec7b0fbd86802", InputSchemaSummary: []string{"schemaVersion=1", "paritySetId", "sourceProofOwners[]", "targetProofkitRefs[]", "parityRecords[]", "nonClaims[]", "root-shape-only definition proofkit.migration-parity-admission.input.v1.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:2cba2943433dfbc2efc1350cede9340452b609158d24c5c622f0a68ef1fc43b6", FlagChoices: map[string][]string{}}, "migration-plan": {InputContractSHA256: "sha256:58a62759a634101ce2ca9218184175134bbe5633328e1b23797b94c19fc9b11a", InputSchemaSummary: []string{"schemaVersion=1", "migrationId", "sourceProofOwners[]", "targetProofkitRefs[]", "parityEvidenceRefs[]", "retainedOwners[]", "retirementCandidates[]", "followUpCommands[]", "nonClaims[]", "root-shape-only definition proofkit.migration-plan.input.v1.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:f14f0381e9dc241357c346315b95b03ef5b23f1d1bbc3b00f111fbe1515ed3ff", FlagChoices: map[string][]string{}}, - "obligation-decision": {InputContractSHA256: "sha256:48d5110ff6f50ddda69bd92fbb47014c835dba6cd36b0ea1751b178619390e18", InputSchemaSummary: []string{"schemaVersion=1", "root-shape-only definition proofkit.obligation-decision.input.v1.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:dde199c92f94ea76491a84900ed7803dd534db4df6002792a174dafbddc00889", FlagChoices: map[string][]string{}}, + "obligation-decision": {InputContractSHA256: "sha256:1dea2ed5c5066451d6d49b815cea99df2cdae2ef05d42fed16c8aeb45eb7f445", InputSchemaSummary: []string{"schemaVersion=1", "root-shape-only definition proofkit.obligation-decision.input.v1.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:96dc074f611bcc12e511bc803c548e4df623e2de869d3add29a3ea6386e04330", FlagChoices: map[string][]string{}}, "package-runtime-dependency-admission": {InputContractSHA256: "sha256:fc85887af9b8fcd899d245f0db30b2f2f68609822fc268126bf999082bb4115f", InputSchemaSummary: []string{"schemaVersion=1", "reportId", "expectedDependencySpec", "expectedLockfileIntegrity", "expectedPackageName", "expectedPackageVersion", "admissibleLocations{}", "packageResolution{}", "nonClaims[]", "root-shape-only definition proofkit.package-runtime-dependency-admission.input.v1.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:c012032e8c8212fd50bc2e85669cc610609ca2124ebc992c9e88f44a1ad2d5fc", FlagChoices: map[string][]string{}}, - "pilot-admission": {InputContractSHA256: "sha256:6c4d6fb7ba99cb8da806826584807a723891955c83c04d0054a848e303e5ca9a", InputSchemaSummary: []string{"schemaVersion=1", "root-shape-only definition proofkit.pilot-admission.input.v1.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:2c48fabddcd739ec46573345862d668fb6b48c67c9a20a14f19f72ac53ddeb1d", FlagChoices: map[string][]string{}}, + "pilot-admission": {InputContractSHA256: "sha256:6c4d6fb7ba99cb8da806826584807a723891955c83c04d0054a848e303e5ca9a", InputSchemaSummary: []string{"schemaVersion=1", "root-shape-only definition proofkit.pilot-admission.input.v1.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:d280e4144b31e45d8a47043acfc0821874fca8f518c55ab63c10041e3a15cb29", FlagChoices: map[string][]string{}}, "producer-policy-self-proof": {InputContractSHA256: "sha256:d48e18826000c8d415f3c44b6c686e1da6ed962ef7ca36c9f705de8c68d034f9", InputSchemaSummary: []string{"schemaVersion=1", "root-shape-only definition proofkit.producer-policy-self-proof.input.v1.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:e82a3989a743f8babc6069f7af82b1dd1ea62bad8dbb18d95e105b36f74e4276", FlagChoices: map[string][]string{}}, - "proof-obligation-algebra": {InputContractSHA256: "sha256:e8b03035a81579d03e1c7084999cdf2104fa4befa3ab7eecf1ae20db995f5f80", InputSchemaSummary: []string{"schemaVersion=1", "root-shape-only definition proofkit.proof-obligation-algebra.input.v1.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:2925965910426487ff135f16d20e654e2be5a880960ffeb62a83773ae0297c15", FlagChoices: map[string][]string{}}, + "proof-obligation-algebra": {InputContractSHA256: "sha256:4f176b6bc9bdbd0d96d65c071d66447d246665bda7a23269e7927f1d0b80b043", InputSchemaSummary: []string{"schemaVersion=1", "root-shape-only definition proofkit.proof-obligation-algebra.input.v1.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:f9ee9e56b349756c55856a2dab198e1ad85db70a468c38e3aeca73cfe2ed66f6", FlagChoices: map[string][]string{}}, "proof-receipt-admission": {InputContractSHA256: "sha256:7cb4c4fb60c8b5a37109bbd8c00d567749f7d181bbc905d8bc58155f139c44cb", InputSchemaSummary: []string{"schemaVersion=1", "root-shape-only definition proofkit.proof-receipt-admission.input.v1.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:3f802ac3fac6762ede51f0e0a151f16dc10b4a20344a3887b3ee8bae43ce94f2", FlagChoices: map[string][]string{}}, "proof-slice": {InputContractSHA256: "sha256:c6abf98e38371a2afdd005b7d17317aa97fbae7a5aa9672367ee8ea4a53b9258", InputSchemaSummary: []string{"schemaVersion=1", "root-shape-only definition proofkit.proof-slice.input.v1.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:73e5f9db8bf966c9818b6581b10c6b20167af4ab1c4b33fa619c040a165125c7", FlagChoices: map[string][]string{}}, "readiness-closeout": {InputContractSHA256: "sha256:4f427c1d0cefb00133d0d9fdb15f75ca9d12a26746632914e82bf72710883b9b", InputSchemaSummary: []string{"schemaVersion=1", "root-shape-only definition proofkit.readiness-closeout.input.v1.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:427a227aa59d60739bc7bdea03363ece95070520dc154fff1f063e174027cc5e", FlagChoices: map[string][]string{}}, - "receipt-currentness-scope": {InputContractSHA256: "sha256:b581dbabdb74baf30093544409a8d9b467aed4b69695bfe0ac3b9ab005913a2d", InputSchemaSummary: []string{"schemaVersion=1", "root-shape-only definition proofkit.receipt-currentness-scope.input.v1.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:3b51b4b58ca5080e2dd6e3674f24710b7d0b8e6463b592f4521b160a42709cc4", FlagChoices: map[string][]string{}}, + "receipt-currentness-scope": {InputContractSHA256: "sha256:a3787eaacabc8902e90a39fe7fa179df3464a93829991006f720d2bd30572f06", InputSchemaSummary: []string{"schemaVersion=1", "root-shape-only definition proofkit.receipt-currentness-scope.input.v1.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:007e38673d5e8bb4a5c9447a835a7692e544b0f6e1f077d6f4f109fd08e3bf2a", FlagChoices: map[string][]string{}}, "receipt-producer-admission": {InputContractSHA256: "sha256:676aa03b2331a094e287dd3f2dfad3a74403b6f8dadb17e420b446aac0b3592c", InputSchemaSummary: []string{"schemaVersion=1", "root-shape-only definition proofkit.receipt-producer-admission.input.v1.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:626b910bb8110901d6769c0ec2a14d216d9ca422aaf2f73ef5fc851c60b0847a", FlagChoices: map[string][]string{}}, - "receipt-trust-class": {InputContractSHA256: "sha256:abc502cf3ac2553d6c2f3b57011af8cfb137b4071fb46f973ab72de917aabf50", InputSchemaSummary: []string{"schemaVersion=1", "root-shape-only definition proofkit.receipt-trust-class.input.v1.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:31f36036e665b53b5bbb10eb2843e7a0937fd5c2926860d8337c2f880a5dc7a3", FlagChoices: map[string][]string{}}, + "receipt-trust-class": {InputContractSHA256: "sha256:be11e398a8e138243a0440a57fafb2b8b47faff7daefb2727da16af3e0c9d649", InputSchemaSummary: []string{"schemaVersion=1", "root-shape-only definition proofkit.receipt-trust-class.input.v1.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:7eeba48696ce232f9d7af48cf90ad57705bdcfade5a8d8837704ae2f06f5a76e", FlagChoices: map[string][]string{}}, "registry-consumer": {InputContractSHA256: "sha256:b4c71b63507b262b84d510573aa094592ea94579c4f332833f787f1719fa012b", InputSchemaSummary: []string{"schemaVersion=1", "root-shape-only definition proofkit.registry-consumer.input.v1.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:0b557f1db529d4527807513a97ebaedc4ae0b3d61b4445647ccd77667674db4b", FlagChoices: map[string][]string{}}, "registry-consumer-proof-input-compose": {InputContractSHA256: "sha256:80bcaf6de948af9087e886dbf352e071fc49b98c2d4409250bfdf51ed19e84b3", InputSchemaSummary: []string{"schemaVersion=1", "root-shape-only definition proofkit.registry-consumer-proof-input-compose.input.v1.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:7dc6c11bde951ae90d54779759b9625751983a7a3aaae02006c9e7747ff149db", FlagChoices: map[string][]string{}}, "release-authority": {InputContractSHA256: "sha256:807f53ceab20f949fda99c10448f8490e16b22ae5b8915c9f471337321f5ffef", InputSchemaSummary: []string{"schemaVersion=1", "root-shape-only definition proofkit.release-authority.input.v1.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:7b01b165b79c37bbee95f272b2ab6e7707ec9c3fd542a2a5a820b676810adb86", FlagChoices: map[string][]string{}}, - "rendered-artifact-freshness": {InputContractSHA256: "sha256:90961665c3b781202babf99b06743cb04e84a09a27bb4a967ce2cde965312f32", InputSchemaSummary: []string{"schemaVersion=1", "root-shape-only definition proofkit.rendered-artifact-freshness.input.v1.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:e5227c6c50f175c0c0b98395a8d53e8c582a4ffa51d5744e1c175e56147a15d7", FlagChoices: map[string][]string{}}, + "rendered-artifact-freshness": {InputContractSHA256: "sha256:be4f53ef1307b4c16bb15a945f8021473b5a215961f3d38f6f591a0240da91f3", InputSchemaSummary: []string{"schemaVersion=1", "root-shape-only definition proofkit.rendered-artifact-freshness.input.v1.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:c9a763142d11daca913672b0bab517767d35e275cd7c8ccb2bb360e6fc8f7425", FlagChoices: map[string][]string{}}, "repo-profile-admission": {InputContractSHA256: "sha256:3a7331d66195dbdc9f672d380efe8fdb9d1d2e36a764b8bc912dccdd81b0e965", InputSchemaSummary: []string{"schemaVersion=1", "root-shape-only definition proofkit.repo-profile-admission.input.v1.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:36d2116fa144aa86d7b9d0ac59b89ad04fb97c85a11f3f65efb7311506761fbd", FlagChoices: map[string][]string{}}, "requirement-authoring-plan": {InputContractSHA256: "sha256:9167f6ea1e888c196c14799f30a7657cf7e74a4af53f15ef61b44b9ae82cc5ba", InputSchemaSummary: []string{"schemaVersion=1", "root-shape-only definition proofkit.requirement-authoring-plan.input.v1.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:6cca126c3295537f0c7a73892e8c8778bbd10e442290e75dd45b833373312a46", FlagChoices: map[string][]string{}}, "requirement-bindings": {InputContractSHA256: "sha256:c9fc55b5b8d67849adb8a10b441b73f33a9eb1b6b6afeee7ec698874e1f9cd50", InputSchemaSummary: []string{"schemaVersion=1", "root-shape-only definition proofkit.requirement-bindings.input.v1.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:f61039fafcdb2ced7655c1be0bd9b46d689878f27aebdf12975c8e9f32cbd14e", FlagChoices: map[string][]string{}}, - "requirement-browser-server": {InputContractSHA256: "sha256:a472df133dd313c67e269699fc27e934d22689aab96aba44d399a111d60e13f3", InputSchemaSummary: []string{"workspace mode: schemaVersion=2", "workspace mode: workspaceId", "workspace mode: context=proofkit.requirement-context schemaVersion=2 with strict v1 adapter", "workspace mode: diffInput=proofkit.requirement-semantic-diff-input schemaVersion=2 (optional)", "workspace mode: graphInput=proofkit.requirement-traceability-graph-input schemaVersion=2 (optional)", "--session-mode values: browse|one-shot-question", "one-shot-question requires --view workspace --serve --open", "--session-timeout-seconds is 1..7200 and requires one-shot-question", "source|proof|coverage|spec-tree modes retain their owner input contracts", "root-shape-only definition proofkit.requirement-browser-server.input.v2.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:04400dfb27b7a8b62a66dd8db588da60dd27158b061cd863ce28bafda5e77d95", FlagChoices: map[string][]string{}}, - "requirement-context-compose": {InputContractSHA256: "sha256:847743017c9af0cf02f2403082bb1a45e6e18670d9ffecdc568e92ec62240927", InputSchemaSummary: []string{"schemaVersion=1", "catalogId", "specTree.path", "requirementSources[] (non-empty)", "requirementSources[].nodeId", "requirementSources[].path", "expectedSourceDigest (optional sha256 ref)", "proofBinding.path (optional)", "coverage.path (optional)", "exact catalog paths only; no discovery", "root-shape-only definition proofkit.requirement-context-compose.input.v1.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:9e6bb924f291b56c8da5162d6d8584c2b90313fcec62a8e3b9f2e68e9fc123a5", FlagChoices: map[string][]string{}}, - "requirement-context-slice": {InputContractSHA256: "sha256:12576719455258fdaf1e9c2c8f982758e848bce1886f43188f458cf01a3864f8", InputSchemaSummary: []string{"schemaVersion=1", "sliceId", "context=proofkit.requirement-context schemaVersion=2 with strict v1 adapter", "query.profile=routing|specification|proof|coverage|review", "query.nodeIds[]|requirementIds[]|ownerIds[]|lifecycleStates[]", "query.maxDepth=0..512", "query.maxNodes=1..4096", "query.maxRequirements=1..16384", "root-shape-only definition proofkit.requirement-context-slice.input.v1.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:bca73df6487d9253925d1ce4e209d16af75b1bbd46fc72e3f2982be3a2d9b42e", FlagChoices: map[string][]string{}}, + "requirement-browser-server": {InputContractSHA256: "sha256:29df21bfe8abc6fade2951d9080280bfc93d490ead2f432510be7bdf573567f8", InputSchemaSummary: []string{"workspace mode: schemaVersion=2", "workspace mode: workspaceId", "workspace mode: context=proofkit.requirement-context schemaVersion=2 with strict v1 adapter", "workspace mode: diffInput=proofkit.requirement-semantic-diff-input schemaVersion=2 (optional)", "workspace mode: graphInput=proofkit.requirement-traceability-graph-input schemaVersion=2 (optional)", "--session-mode values: browse|one-shot-question", "one-shot-question requires --view workspace --serve --open", "--session-timeout-seconds is 1..7200 and requires one-shot-question", "source|proof|coverage|spec-tree modes retain their owner input contracts", "root-shape-only definition proofkit.requirement-browser-server.input.v2.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:16e741d88e5ede4271c5e769c724e72164fcba461f0c6c17d285f318f8e03005", FlagChoices: map[string][]string{}}, + "requirement-context-compose": {InputContractSHA256: "sha256:04603ae61b50c44e47dde2d11f4b730bb5e91f53645bcda6dbda80b708ef2781", InputSchemaSummary: []string{"schemaVersion=1", "catalogId", "specTree.path", "requirementSources[] (non-empty)", "requirementSources[].nodeId", "requirementSources[].path", "expectedSourceDigest (optional sha256 ref)", "proofBinding.path (optional)", "coverage.path (optional)", "exact catalog paths only; no discovery", "root-shape-only definition proofkit.requirement-context-compose.input.v1.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:499413fd829de7f275f624a13f2de51afca9a6c201066eb69d95487d9abcab0b", FlagChoices: map[string][]string{}}, + "requirement-context-slice": {InputContractSHA256: "sha256:4962dbf8bdde43384ceb375620e6b00f759157a4e6ceb1614d6e58a9f33ee583", InputSchemaSummary: []string{"schemaVersion=1", "sliceId", "context=proofkit.requirement-context schemaVersion=2 with strict v1 adapter", "query.profile=routing|specification|proof|coverage|review", "query.nodeIds[]|requirementIds[]|ownerIds[]|lifecycleStates[]", "query.maxDepth=0..512", "query.maxNodes=1..4096", "query.maxRequirements=1..16384", "root-shape-only definition proofkit.requirement-context-slice.input.v1.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:93460f60611eeaf2a13d6e8779841fe78746e1fde9db7fa5e41c91c3b6b49ca4", FlagChoices: map[string][]string{}}, "requirement-coverage-input-compose": {InputContractSHA256: "sha256:26e46a1438f5633a835f4afaef293bed5417b13f4cd4c0a752d02659fcd094c6", InputSchemaSummary: []string{"schemaVersion=1", "root-shape-only definition proofkit.requirement-coverage-input-compose.input.v1.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:4e246b49fd99038d4b1df9ce484ef2214c3129769df2aaf67b398367a451dd86", FlagChoices: map[string][]string{}}, - "requirement-coverage-view": {InputContractSHA256: "sha256:afb8646a0beb81a27815c436423e0b273855217f5d78af28b334682bd8100211", InputSchemaSummary: []string{"schemaVersion=1", "root-shape-only definition proofkit.requirement-coverage-view.input.v1.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:0a353e48748887d7c2b4ab0dc68b48c0d4630ff42c954a490f94cfde05b38412", FlagChoices: map[string][]string{}}, - "requirement-impact-input-compose": {InputContractSHA256: "sha256:dd90693ee688680315d371160d5b4de936ad7a1fb9c4b2725c66cead311f983e", InputSchemaSummary: []string{"schemaVersion=1", "root-shape-only definition proofkit.requirement-impact-input-compose.input.v1.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:3a31f2500447918a4f77ec0c9c385944db603ac602bb7122957ce72cc8fe2f8b", FlagChoices: map[string][]string{}}, + "requirement-coverage-view": {InputContractSHA256: "sha256:a503fa4070bb4eae8200412a6cd6516ceefea07fb50453e17103f2d6bab5960c", InputSchemaSummary: []string{"schemaVersion=1", "root-shape-only definition proofkit.requirement-coverage-view.input.v1.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:b8b8ab6d7f0d78a33e81b3e6b37c8f7cb93f6d24ff745e6435128d38abb3c395", FlagChoices: map[string][]string{}}, + "requirement-impact-input-compose": {InputContractSHA256: "sha256:9c85648f3ec078390a807a97e5184ebab82438ec3a0915fb6049ab741f35c3bb", InputSchemaSummary: []string{"schemaVersion=1", "root-shape-only definition proofkit.requirement-impact-input-compose.input.v1.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:27efae46bedaa3f7f9132f2542344b0d16db195d5c78f09a28079d42ce4037e5", FlagChoices: map[string][]string{}}, "requirement-proof-resolver": {InputContractSHA256: "sha256:6b032c893c770f260d72976b1ead28455e2775988771eee85cb8065bcdb7a91d", InputSchemaSummary: []string{"schemaVersion=1", "root-shape-only definition proofkit.requirement-proof-resolver.input.v1.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:99cf48212bfbfc4667a4951c0b4f4480701bfe5715028b6b05db28ecc0b0ae50", FlagChoices: map[string][]string{}}, "requirement-proof-source-set": {InputContractSHA256: "sha256:3ffe27b751445e4c525513ef2953f33fda2db49b4c84f6b9e93efc67b645a814", InputSchemaSummary: []string{"canonicalEnvelope", "sourceSet", "sources", "root-shape-only definition proofkit.requirement-proof-source-set.input.v1.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:c74a57fd4ada0a06c0fafd31ea1aedb20035c67e8e7ef0fc413ae8bf086143b3", FlagChoices: map[string][]string{}}, - "requirement-proof-view": {InputContractSHA256: "sha256:236b5c87d83c64f13143cab74f2e3e50898f09ff1ec07590d6b6cce51a90ba7d", InputSchemaSummary: []string{"schemaVersion=1", "root-shape-only definition proofkit.requirement-proof-view.input.v1.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:c31930829de0cf5fec96b92bb155f21e2836ff8e5ac7b29b6c1b514c9a33390e", FlagChoices: map[string][]string{}}, - "requirement-semantic-diff": {InputContractSHA256: "sha256:c44263f7ddce2949506ccaeb73e32e78c16bad3250cc6575d355f5874d9fdaa4", InputSchemaSummary: []string{"schemaVersion=2", "diffId", "baseContext=proofkit.requirement-context schemaVersion=2", "currentContext=proofkit.requirement-context schemaVersion=2", "strict schemaVersion=1 adapter requires two v1 contexts", "query.requirementIds[] (optional)", "query.ownerIds[] (optional)", "query.maxChanges=1..8192 (optional)", "root-shape-only definition proofkit.requirement-semantic-diff.input.v2.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:f88bcb2815f7ee1d66ca5ec06d090dd7f2de4ce8430529621742474c0edf992b", FlagChoices: map[string][]string{}}, + "requirement-proof-view": {InputContractSHA256: "sha256:b0c2455d77a9b47380e0d6551f0f67bfe098b8ec22239b035164263446b29e1c", InputSchemaSummary: []string{"schemaVersion=1", "root-shape-only definition proofkit.requirement-proof-view.input.v1.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:59ab83ea52a0736f7435ceee8b2d324308e7c088c9dae56ebb289bf3750babe9", FlagChoices: map[string][]string{}}, + "requirement-semantic-diff": {InputContractSHA256: "sha256:e0829b5576210214e67d86911b6b7c1313f4cacdfbf476fa19503702e4d03031", InputSchemaSummary: []string{"schemaVersion=2", "diffId", "baseContext=proofkit.requirement-context schemaVersion=2", "currentContext=proofkit.requirement-context schemaVersion=2", "strict schemaVersion=1 adapter requires two v1 contexts", "query.requirementIds[] (optional)", "query.ownerIds[] (optional)", "query.maxChanges=1..8192 (optional)", "root-shape-only definition proofkit.requirement-semantic-diff.input.v2.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:581b7585767d7cf55f0e2f891155e04f26ec6a0871b9b97cda8aa0b9a52ea770", FlagChoices: map[string][]string{}}, "requirement-source-admission": {InputContractSHA256: "sha256:748da7b4f6bba55877cfe51efcbd85fbdab2f2329a9d9668ea2905713c80c417", InputSchemaSummary: []string{"schemaVersion=1", "root-shape-only definition proofkit.requirement-source-admission.input.v1.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:2f6a2db4163b1829bd4cdf27890477abd77697d70dfd591943d47b7be798a90e", FlagChoices: map[string][]string{}}, "requirement-source-transition": {InputContractSHA256: "sha256:fad33de2e47d31a81b2dd1a84214d061dd2b7ec88a5ba0153287aebb29b19bd8", InputSchemaSummary: []string{"schemaVersion=1", "root-shape-only definition proofkit.requirement-source-transition.input.v1.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:aedf88574794d4916fd1d8b234538dbfbf358461b4d117ea4d0380e7890182b7", FlagChoices: map[string][]string{}}, "requirement-source-view": {InputContractSHA256: "sha256:0819889f9bfaddefe0555250612ef5f4d9172899b04d427d48b0420d765c00ad", InputSchemaSummary: []string{"schemaVersion=1", "root-shape-only definition proofkit.requirement-source-view.input.v1.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:2f29bd1edea7c3e930a143c2f96c229a0a199e52c89367302c414759bf660c44", FlagChoices: map[string][]string{}}, "requirement-spec-tree": {InputContractSHA256: "sha256:96876589778a1cf1bc3f41fa33ad86de502db05886ba620ecbee59225060e315", InputSchemaSummary: []string{"schemaVersion", "treeId", "rootNodeId", "callerAnnotations", "nodes", "edges", "overlays", "root-shape-only definition proofkit.requirement-spec-tree.input.v1.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:a027461411b8614288186df2c014a9e5303f905f0fb4cfc9b3ed6439790ae547", FlagChoices: map[string][]string{}}, "requirement-spec-tree-view": {InputContractSHA256: "sha256:9e725fc145c437e0f9cdea0deed86189da855cc7d0350d33e59c80d34d91c03a", InputSchemaSummary: []string{"schemaVersion", "treeId", "rootNodeId", "callerAnnotations", "nodes", "edges", "overlays", "root-shape-only definition proofkit.requirement-spec-tree-view.input.v1.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:ba9b258e45d485936ddbfd76f7a7c43a5ae5760a4fe6da56bce4a45afd4221e6", FlagChoices: map[string][]string{}}, - "requirement-traceability-graph": {InputContractSHA256: "sha256:c3dfdd8e25325f4e1170559a4d6ab905a82cca92e5040ea22232596b93d389f1", InputSchemaSummary: []string{"schemaVersion=2", "graphId", "context=proofkit.requirement-context schemaVersion=2 with strict v1 adapter", "codeSources[].path+content (optional, bounded UTF-8)", "codeTopology.nodes[].abstractionLevel=repository|package|module|file|symbol|source_range", "codeTopology.nodes[].sourceDigest+currentnessState", "codeTopology.edges[].evidenceRefs+authorityClass+currentnessState", "codeTopology.nativeCoverage[].producerId+evidenceRef+authorityClass+currentnessState+state", "root-shape-only definition proofkit.requirement-traceability-graph.input.v2.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:5f1c027b553fb9dd558cd296b22d26371a69cbf4c0aeac38ffd51eb1b8505122", FlagChoices: map[string][]string{}}, + "requirement-traceability-graph": {InputContractSHA256: "sha256:b93bf9f9884452e4c7ae72b181efff4dd5fa4781ba62284a311d41c54bdc2ad7", InputSchemaSummary: []string{"schemaVersion=2", "graphId", "context=proofkit.requirement-context schemaVersion=2 with strict v1 adapter", "codeSources[].path+content (optional, bounded UTF-8)", "codeTopology.nodes[].abstractionLevel=repository|package|module|file|symbol|source_range", "codeTopology.nodes[].sourceDigest+currentnessState", "codeTopology.edges[].evidenceRefs+authorityClass+currentnessState", "codeTopology.nativeCoverage[].producerId+evidenceRef+authorityClass+currentnessState+state", "root-shape-only definition proofkit.requirement-traceability-graph.input.v2.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:64b0c190c64a016f3c31a60f6e2327d5fa9f6f834ff83540be690e790e9595ab", FlagChoices: map[string][]string{}}, "scaffold-profile-plan": {InputContractSHA256: "sha256:bc2a9dc33664fc0555bb5c4b67c6c2caa451995f7bcb1b8add8ea8a8aabd88a6", InputSchemaSummary: []string{"schemaVersion=1", "root-shape-only definition proofkit.scaffold-profile-plan.input.v1.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:3d5d6584ef88c14534333e62b677ecac73d5bae659edf71893faa7ab1068659c", FlagChoices: map[string][]string{}}, "scaffold-project-structure": {InputContractSHA256: "sha256:0db5eca08d353a8d314908a34d8293c947a9d208e280353d9784b489576ec55a", InputSchemaSummary: []string{"schemaVersion=1", "root-shape-only definition proofkit.scaffold-project-structure.input.v1.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:79950f8b779b00616be24b2d7e28021a83e9414881676ae86ab467940d36e6cb", FlagChoices: map[string][]string{}}, - "secret-scan": {InputContractSHA256: "sha256:6778f48f02a24472ab9e8172ba67dc1f77b0bf4a5bb58dd318aa91008315008a", InputSchemaSummary: []string{"files", "nonClaims", "reportId", "schemaVersion", "root-shape-only definition proofkit.secret-scan.input.v1.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:b26eb2f693f2bc69f30d19f8e17ed2d3f35a5c3201bbff7e261b3eb671d48118", FlagChoices: map[string][]string{}}, - "selective-gate-evidence": {InputContractSHA256: "sha256:345d1f2bf38ba2b1b18b64c2ed1298d3d25c8153a7b0504a86adfcbfd2e93077", InputSchemaSummary: []string{"schemaVersion=1", "root-shape-only definition proofkit.selective-gate-evidence.input.v1.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:2e51ee45b106004daea83fe39a2d812b72ca77cdd35a893ad81150d6d7952068", FlagChoices: map[string][]string{}}, - "selective-gate-obligation-decision-input": {InputContractSHA256: "sha256:dca418fcfbbd7c8ef67db613f0abd5ce62cfdcbdf112ab6cac2a043d18d21e31", InputSchemaSummary: []string{"schemaVersion=1", "root-shape-only definition proofkit.selective-gate-obligation-decision-input.input.v1.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:f6a493fe11ea2d2ccfa9f9bea186ab1ccee02d0f15306cb297b567a638f385d2", FlagChoices: map[string][]string{}}, + "secret-scan": {InputContractSHA256: "sha256:bf2f193e382bc1bf709031be6d9d9c913264e1c5865b926ae7d72ac14ea35324", InputSchemaSummary: []string{"files", "nonClaims", "reportId", "schemaVersion", "root-shape-only definition proofkit.secret-scan.input.v1.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:25ec4640a71e0ce30709b3215535439561d671de9b5be8228b710ab556e0fd8a", FlagChoices: map[string][]string{}}, + "selective-gate-evidence": {InputContractSHA256: "sha256:8aa178ab7ca7c475c23707bc4e15fd3f9f8d57acf6f6dcf279677e7769a45586", InputSchemaSummary: []string{"schemaVersion=1", "root-shape-only definition proofkit.selective-gate-evidence.input.v1.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:723569262bb85d9674b2a78d3bcb6e9f4cab229b71e8c784ff1b804a7fcade71", FlagChoices: map[string][]string{}}, + "selective-gate-obligation-decision-input": {InputContractSHA256: "sha256:85761fcbc0ea94239d55bf379d0592a6ca814e6612a2d609a651f6cdaf8ca10a", InputSchemaSummary: []string{"schemaVersion=1", "root-shape-only definition proofkit.selective-gate-obligation-decision-input.input.v1.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:ab9dddabe975238d7019266c43350afa2df1a61d4c2eb7bc23afd520b588a2da", FlagChoices: map[string][]string{}}, "selective-gate-plan": {InputContractSHA256: "sha256:5293a5a4c7d8426cf637e6f8d252095ca0eb1714365bb89bec83307b778c678a", InputSchemaSummary: []string{"schemaVersion=1", "root-shape-only definition proofkit.selective-gate-plan.input.v1.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:d7bffed853af5595af08b03859be01c283a3bdff1b3502d94ddc190889977647", FlagChoices: map[string][]string{}}, - "self-check": {InputContractSHA256: "sha256:422fcafeaf6aab4ffab4ee2ea71b256e42fda543ae6be1fbcdbbb6e6308ef085", InputSchemaSummary: []string{"schemaVersion=1", "root-shape-only definition proofkit.self-check.input.v1.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:99cdd9583ca3641c7e1c30e829e35c7eef1b90bd997b791323e309f4431f441c", FlagChoices: map[string][]string{}}, + "self-check": {InputContractSHA256: "sha256:f452e8e75fa5a4ce349274b295d66ebf5aa92556fb1331cda32131e94eb251a5", InputSchemaSummary: []string{"schemaVersion=1", "root-shape-only definition proofkit.self-check.input.v1.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:9b23a41a960fe3b7e717635f77f43870bdceec2921f08bc0a788eebd33a5ff86", FlagChoices: map[string][]string{}}, "spec-overview-claims": {InputContractSHA256: "sha256:2490dcd34ba7485e13f8f33e8a288a0463c4c52cc6b0d82c57777466927e49a4", InputSchemaSummary: []string{"schemaVersion=1", "root-shape-only definition proofkit.spec-overview-claims.input.v1.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:554f3a7020e9820ccb90672629fd769c52b2f298f356040aa3b0a817666cbfbf", FlagChoices: map[string][]string{}}, "spec-proof-bundle-admission": {InputContractSHA256: "sha256:6b6c2875b6476e63a1911e7d6112d9999df2babbee969f84abc4c9e4b470c933", InputSchemaSummary: []string{"schemaVersion=1", "root-shape-only definition proofkit.spec-proof-bundle-admission.input.v1.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:e9e0eb66cebca3b99fe5036fb2e7327a9284934ed76f58818d18094d0546fc52", FlagChoices: map[string][]string{}}, "stack-preset": {InputContractSHA256: "", InputSchemaSummary: []string(nil), OutputContractSHA256: "sha256:f495e9ade4e1e7af7a8f8b2059f7611cc016e6080363afccf76d8dfc2dbc6d2d", FlagChoices: map[string][]string{"--preset": []string{"agentic_runtime_repo", "generated_docs_contract_repo", "python_service", "python_typescript_service", "typescript_monorepo", "typescript_workspace"}}}, "test-evidence-inventory": {InputContractSHA256: "sha256:9f2217986ec85f017b16075749c53ae1b01b348bc9a6b66db423a6c3564e4528", InputSchemaSummary: []string{"schemaVersion=1", "root-shape-only definition proofkit.test-evidence-inventory.input.v1.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:4db9063936db590c97f33fd47b6fbafcf974ec3dab1f21ffaad37f7b9549e68c", FlagChoices: map[string][]string{}}, - "text-policy": {InputContractSHA256: "sha256:21ec8496730700d7baaac18652dee438a41011e093f7aa0d1c323b27c2de6e9c", InputSchemaSummary: []string{"schemaVersion=1", "root-shape-only definition proofkit.text-policy.input.v1.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:2b11ed3777cfbccc612a3a64c4b4dfe68f1d77143026102eead42a3331ac9489", FlagChoices: map[string][]string{}}, + "text-policy": {InputContractSHA256: "sha256:d686238fdba68d22a701030ecbfaa5d6fcb7229b3d12638b45c5473670eadd35", InputSchemaSummary: []string{"schemaVersion=1", "root-shape-only definition proofkit.text-policy.input.v1.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:ca8635ae272627e37453e03c4d535904757c73fea96d4c55defbc1d647b1f80e", FlagChoices: map[string][]string{}}, "typescript-public-api-surfaces": {InputContractSHA256: "sha256:e8e55a53390c50fe0d8e669907318474c237327f2f094b3c78908b4fd6941035", InputSchemaSummary: []string{"schemaVersion=1", "machineContract=public_api_surfaces", "entries[].packageManifestPath", "entries[].packageName", "entries[].exportKey", "entries[].exportConditions[] (non-empty, sorted unique by condition)", "entries[].exportConditions[].condition", "entries[].exportConditions[].path", "entries[].exportConditions[].sourcePath (declared and canonical target .ts/.mts/.cts)", "entries[].runtimeExports[]", "entries[].typeExports[]", "entries[].deniedExportKeys[] (optional)", "sourceGrammar=fail_closed_restricted_typescript_exports_v1", "maxSourceFileBytes=8388608", "maxPackageManifestBytes=262144", "maxAggregateFileReadBytes=67108864", "root-shape-only definition proofkit.typescript-public-api-surfaces.input.v1.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:2f05ea8073e7a2d4a08de962461f5fb31a78f14e77f9ce2406e3ce40ec7beb68", FlagChoices: map[string][]string{}}, "witness-plan": {InputContractSHA256: "sha256:7814c5d27487a361bac77045afe32c6449c24881f04d5496da7182b3f2c0c1ee", InputSchemaSummary: []string{"schemaVersion=1", "root-shape-only definition proofkit.witness-plan.input.v1.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:722c577bf5cf1dab8c9da8d18fc0634e1b9a6471aa5ca0b05f55b730cdd0d303", FlagChoices: map[string][]string{}}, "witness-scheduler-plan": {InputContractSHA256: "sha256:972c782dc8c5f012380acba2f7e80030adccef3a93c63255e60b2ac75af9cc4c", InputSchemaSummary: []string{"schemaVersion=1", "root-shape-only definition proofkit.witness-scheduler-plan.input.v1.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:1c87ee7d359b28e66e5236ff8f8e779d9d8b452f8337b589f8b7bcfd17db1dc4", FlagChoices: map[string][]string{}}, diff --git a/internal/app/command_coverage_source.go b/internal/app/command_coverage_source.go index 7150d1b..3ef9459 100644 --- a/internal/app/command_coverage_source.go +++ b/internal/app/command_coverage_source.go @@ -82,8 +82,8 @@ func goTestFunctionProblemWithMarker(filePath string, testName string, marker st if gotestsource.HasSkip(function) { return "contains t.Skip and cannot serve as an always-executable semantic oracle" } - if !hasFailureCapableAssertion(function) { - return "has no direct failure-capable assertion" + if !gotestsource.HasFailureCapableAssertionSyntax(function) { + return "has no direct failure-capable assertion candidate" } if marker != "" && !hasSemanticOracleBinding(function, marker) { return "missing source-owned semantic oracle binding " + marker @@ -122,39 +122,6 @@ func isTestingTFunction(function *ast.FuncDecl) bool { return ok && packageName.Name == "testing" } -func hasFailureCapableAssertion(function *ast.FuncDecl) bool { - paramName := testingTParamName(function) - if paramName == "" || function.Body == nil { - return false - } - found := false - ast.Inspect(function.Body, func(node ast.Node) bool { - if found { - return false - } - call, ok := node.(*ast.CallExpr) - if !ok { - return true - } - selector, ok := call.Fun.(*ast.SelectorExpr) - if !ok { - return true - } - receiver, ok := selector.X.(*ast.Ident) - if !ok || receiver.Name != paramName { - return true - } - switch selector.Sel.Name { - case "Error", "Errorf", "Fail", "FailNow", "Fatal", "Fatalf": - found = true - return false - default: - return true - } - }) - return found -} - func hasSemanticOracleBinding(function *ast.FuncDecl, marker string) bool { found := false paramName := testingTParamName(function) diff --git a/internal/app/command_coverage_test.go b/internal/app/command_coverage_test.go index 8beb3e3..2145840 100644 --- a/internal/app/command_coverage_test.go +++ b/internal/app/command_coverage_test.go @@ -227,7 +227,7 @@ func TestAssertionless(t *testing.T) { } `) problem := goTestFunctionProblem(filePath, "TestAssertionless") - if !strings.Contains(problem, "has no direct failure-capable assertion") { + if !strings.Contains(problem, "has no direct failure-capable assertion candidate") { t.Fatalf("assertionless test body was not rejected: %q", problem) } } diff --git a/internal/app/command_descriptors.go b/internal/app/command_descriptors.go index d042fde..733f77c 100644 --- a/internal/app/command_descriptors.go +++ b/internal/app/command_descriptors.go @@ -3,6 +3,9 @@ package app import ( "slices" "sort" + + "github.com/research-engineering/agentic-proofkit/internal/command/requirementbrowser" + "github.com/research-engineering/agentic-proofkit/internal/command/requirementproofview" ) type commandInputMode string @@ -48,27 +51,42 @@ const ( ) type commandDescriptor struct { - name string - input commandInputMode - runner commandRunner - scopeClass commandScopeClass - allowedFlags []string - requiredFlags []string - exactlyOneOfFlagGroups [][]string - flagValueRequirements []flagValueRequirement - flagValueChoices map[string][]string - inputSchemaSummary []string - outputModes []string - agentEnvelope bool - contractEnvelope bool - semanticAppTests []string - semanticOwnerDirs []string + name string + input commandInputMode + runner commandRunner + scopeClass commandScopeClass + allowedFlags []string + requiredFlags []string + exactlyOneOfFlagGroups [][]string + atMostOneOfFlagGroups [][]string + flagPresenceRequirements []flagPresenceRequirement + flagValueRequirements []flagValueRequirement + singleOccurrenceFlags []string + flagValueChoices map[string][]string + inputSchemaSummary []string + outputModes []string + agentEnvelope bool + contractEnvelope bool + semanticAppTests []string + semanticOwnerDirs []string } type flagValueRequirement struct { - Flag string `json:"flag"` - RequiredFlags []string `json:"requiredFlags"` - Value string `json:"value"` + Flag string `json:"flag"` + RequiredFlagValues []requiredFlagValue `json:"requiredFlagValues,omitempty"` + RequiredFlags []string `json:"requiredFlags"` + Value string `json:"value"` +} + +type flagPresenceRequirement struct { + Flag string `json:"flag"` + RequiredFlagValues []requiredFlagValue `json:"requiredFlagValues,omitempty"` + RequiredFlags []string `json:"requiredFlags"` +} + +type requiredFlagValue struct { + Flag string `json:"flag"` + Value string `json:"value"` } var commandDescriptors = []commandDescriptor{ @@ -99,7 +117,7 @@ var commandDescriptors = []commandDescriptor{ command("migration-plan", commandInputRequired, flags("--input", "--input-pointer"), modes("json"), ownerDirs("migrationplan")), command("obligation-decision", commandInputRequired, flags("--agent-envelope", "--input", "--input-pointer"), modes("json"), ownerDirs("obligationdecision"), withRunner(commandRunnerPlanning), withAgentEnvelope()), command("package-runtime-dependency-admission", commandInputRequired, flags("--input", "--input-pointer"), modes("json"), ownerDirs("packageruntimedependency")), - command("pilot-admission", commandInputRequired, flags("--contract-envelope", "--input", "--input-pointer", "--pilot", "--stack-diverse"), modes("json"), ownerDirs("pilotadmission"), withRunner(commandRunnerPilotAdmission), withContractEnvelope()), + command("pilot-admission", commandInputRequired, flags("--contract-envelope", "--input", "--input-pointer", "--pilot", "--stack-diverse"), modes("json"), ownerDirs("pilotadmission"), withRunner(commandRunnerPilotAdmission), withContractEnvelope(), withFlagValueRequirement("--pilot", "all", "--contract-envelope")), command("producer-policy-self-proof", commandInputRequired, flags("--input", "--input-pointer"), modes("json"), ownerDirs("producerpolicyselfproof")), command("proof-obligation-algebra", commandInputRequired, flags("--input", "--input-pointer"), modes("json"), ownerDirs("proofobligationalgebra")), command("proof-receipt-admission", commandInputRequired, flags("--input", "--input-pointer"), modes("json"), ownerDirs("proofreceiptadmission")), @@ -115,7 +133,7 @@ var commandDescriptors = []commandDescriptor{ command("repo-profile-admission", commandInputRequired, flags("--input", "--input-pointer"), modes("json"), ownerDirs("repoprofileadmission")), command("requirement-authoring-plan", commandInputRequired, flags("--input", "--input-pointer"), modes("json"), ownerDirs("requirementauthoringplan")), command("requirement-bindings", commandInputRequired, flags("--input", "--input-pointer"), modes("json"), ownerDirs("requirementbinding")), - command("requirement-browser-server", commandInputRequired, flags("--empty-local-environment-policy", "--host", "--input", "--input-pointer", "--local-environment-class", "--open", "--port", "--scope", "--serve", "--session-mode", "--session-timeout-seconds", "--view"), modes("json", "server"), ownerDirs("requirementbrowser"), withRunner(commandRunnerRequirementBrowserServer), withSemanticAppTests("TestRequirementBrowserServerSpecTreeCLIABI"), withRequiredFlags("--view"), withFlagValueRequirement("--session-mode", "one-shot-question", "--open", "--serve", "--view")), + command("requirement-browser-server", commandInputRequired, flags("--empty-local-environment-policy", "--host", "--input", "--input-pointer", "--local-environment-class", "--open", "--port", "--scope", "--serve", "--session-mode", "--session-timeout-seconds", "--view"), modes("json", "server"), ownerDirs("requirementbrowser"), withRunner(commandRunnerRequirementBrowserServer), withSemanticAppTests("TestRequirementBrowserServerSpecTreeCLIABI"), withRequiredFlags("--view"), withAtMostOneOfFlags("--empty-local-environment-policy", "--local-environment-class"), withFlagChoices("--host", requirementbrowser.HostChoices()...), withFlagChoices("--scope", requirementproofview.ScopeChoices()...), withFlagChoices("--session-mode", requirementbrowser.SessionModeChoices()...), withFlagChoices("--view", requirementbrowser.ViewChoices()...), withFlagPresenceAndRequiredValue("--empty-local-environment-policy", "--view", "proof"), withFlagPresenceAndRequiredValue("--local-environment-class", "--view", "proof"), withFlagPresenceRequirement("--open", "--serve"), withFlagPresenceAndRequiredValue("--scope", "--view", "proof"), withFlagPresenceAndRequiredValue("--session-timeout-seconds", "--session-mode", "one-shot-question"), withFlagValueAndRequiredValue("--session-mode", "browse", "--view", "workspace", "--serve"), withFlagValueAndRequiredValue("--session-mode", "one-shot-question", "--view", "workspace", "--open", "--serve"), withSingleOccurrenceFlags(requirementBrowserSingleOccurrenceFlags...)), command("requirement-context-compose", commandInputRequired, flags("--input", "--input-pointer", "--repo-root"), modes("json"), ownerDirs("requirementcontext"), withRunner(commandRunnerRequirementContextCompose), withSemanticAppTests("TestRequirementContextCommandsComposeThroughWholeCLI"), withScopeClass(commandScopeExplicitFileSystemScan), withRequiredFlags("--repo-root")), command("requirement-context-slice", commandInputRequired, flags("--input", "--input-pointer"), modes("json"), ownerDirs("requirementcontext"), withSemanticAppTests("TestRequirementContextCommandsComposeThroughWholeCLI")), command("requirement-coverage-input-compose", commandInputRequired, flags("--input", "--input-pointer"), modes("json"), ownerDirs("requirementcoverageinput")), @@ -201,9 +219,19 @@ func command(name string, input commandInputMode, allowedFlags []string, outputM for _, option := range options { option(&descriptor) } + if slices.Contains(descriptor.allowedFlags, "--format") { + descriptor.singleOccurrenceFlags = []string{"--format"} + } + explicitFlagChoices := cloneStringMap(descriptor.flagValueChoices) if metadata, ok := generatedCommandContractMetadataByName[name]; ok { descriptor.inputSchemaSummary = cloneStrings(metadata.InputSchemaSummary) descriptor.flagValueChoices = cloneStringMap(metadata.FlagChoices) + for flag, choices := range explicitFlagChoices { + if generated, exists := descriptor.flagValueChoices[flag]; exists && !slices.Equal(generated, choices) { + panic("explicit and generated flag choices disagree: " + name + " " + flag) + } + descriptor.flagValueChoices[flag] = cloneStrings(choices) + } } return descriptor } @@ -257,6 +285,42 @@ func withExactlyOneOfFlags(flags ...string) commandDescriptorOption { } } +func withAtMostOneOfFlags(flags ...string) commandDescriptorOption { + return func(descriptor *commandDescriptor) { + descriptor.atMostOneOfFlagGroups = append(descriptor.atMostOneOfFlagGroups, cloneStrings(flags)) + } +} + +func withFlagChoices(flag string, values ...string) commandDescriptorOption { + return func(descriptor *commandDescriptor) { + if descriptor.flagValueChoices == nil { + descriptor.flagValueChoices = map[string][]string{} + } + descriptor.flagValueChoices[flag] = cloneStrings(values) + } +} + +func withFlagPresenceRequirement(flag string, requiredFlags ...string) commandDescriptorOption { + return func(descriptor *commandDescriptor) { + descriptor.flagPresenceRequirements = append(descriptor.flagPresenceRequirements, flagPresenceRequirement{ + Flag: flag, RequiredFlags: append([]string{}, requiredFlags...), + }) + } +} + +func withFlagPresenceAndRequiredValue(flag string, requiredFlag string, requiredValue string, requiredFlags ...string) commandDescriptorOption { + return func(descriptor *commandDescriptor) { + descriptor.flagPresenceRequirements = append(descriptor.flagPresenceRequirements, flagPresenceRequirement{ + Flag: flag, + RequiredFlagValues: []requiredFlagValue{{ + Flag: requiredFlag, + Value: requiredValue, + }}, + RequiredFlags: append([]string{}, requiredFlags...), + }) + } +} + func withFlagValueRequirement(flag string, value string, requiredFlags ...string) commandDescriptorOption { return func(descriptor *commandDescriptor) { descriptor.flagValueRequirements = append(descriptor.flagValueRequirements, flagValueRequirement{ @@ -265,6 +329,26 @@ func withFlagValueRequirement(flag string, value string, requiredFlags ...string } } +func withFlagValueAndRequiredValue(flag string, value string, requiredFlag string, requiredValue string, requiredFlags ...string) commandDescriptorOption { + return func(descriptor *commandDescriptor) { + descriptor.flagValueRequirements = append(descriptor.flagValueRequirements, flagValueRequirement{ + Flag: flag, + RequiredFlagValues: []requiredFlagValue{{ + Flag: requiredFlag, + Value: requiredValue, + }}, + RequiredFlags: cloneStrings(requiredFlags), + Value: value, + }) + } +} + +func withSingleOccurrenceFlags(flags ...string) commandDescriptorOption { + return func(descriptor *commandDescriptor) { + descriptor.singleOccurrenceFlags = append(descriptor.singleOccurrenceFlags, flags...) + } +} + func flags(values ...string) []string { return cloneStrings(values) } @@ -295,7 +379,7 @@ func buildCommandDescriptorIndex(descriptors []commandDescriptor) map[string]com if len(descriptor.allowedFlags) == 0 || len(descriptor.outputModes) == 0 || len(descriptor.semanticOwnerDirs) == 0 { panic("incomplete command descriptor: " + descriptor.name) } - if !isSortedUnique(descriptor.allowedFlags) || !isSortedUnique(descriptor.requiredFlags) || !isSortedUnique(descriptor.outputModes) || !isSortedUnique(descriptor.semanticOwnerDirs) || !isSortedUnique(descriptor.semanticAppTests) { + if !isSortedUnique(descriptor.allowedFlags) || !isSortedUnique(descriptor.requiredFlags) || !isSortedUnique(descriptor.singleOccurrenceFlags) || !isSortedUnique(descriptor.outputModes) || !isSortedUnique(descriptor.semanticOwnerDirs) || !isSortedUnique(descriptor.semanticAppTests) || !isSortedUniqueFlagPresenceRequirements(descriptor.flagPresenceRequirements) || !isSortedUniqueFlagValueRequirements(descriptor.flagValueRequirements) { panic("command descriptor lists must be sorted and unique: " + descriptor.name) } for _, requiredFlag := range descriptor.requiredFlags { @@ -313,8 +397,33 @@ func buildCommandDescriptorIndex(descriptors []commandDescriptor) map[string]com } } } + for _, group := range descriptor.atMostOneOfFlagGroups { + if len(group) < 2 || !isSortedUnique(group) { + panic("at-most-one flag group must be sorted, unique, and contain at least two flags: " + descriptor.name) + } + for _, flag := range group { + if !slices.Contains(descriptor.allowedFlags, flag) { + panic("at-most-one flag is not allowed: " + descriptor.name + " " + flag) + } + } + } + for _, requirement := range descriptor.flagPresenceRequirements { + if requirement.Flag == "" || !slices.Contains(descriptor.allowedFlags, requirement.Flag) || !isSortedUnique(requirement.RequiredFlags) || !isSortedUniqueRequiredFlagValues(requirement.RequiredFlagValues) { + panic("invalid flag presence requirement: " + descriptor.name) + } + for _, flag := range requirement.RequiredFlags { + if !slices.Contains(descriptor.allowedFlags, flag) { + panic("presence-required flag is not allowed: " + descriptor.name + " " + flag) + } + } + for _, required := range requirement.RequiredFlagValues { + if !slices.Contains(descriptor.allowedFlags, required.Flag) { + panic("presence-required flag is not allowed: " + descriptor.name + " " + required.Flag) + } + } + } for _, requirement := range descriptor.flagValueRequirements { - if requirement.Flag == "" || requirement.Value == "" || !slices.Contains(descriptor.allowedFlags, requirement.Flag) || !isSortedUnique(requirement.RequiredFlags) { + if requirement.Flag == "" || requirement.Value == "" || !slices.Contains(descriptor.allowedFlags, requirement.Flag) || !isSortedUnique(requirement.RequiredFlags) || !isSortedUniqueRequiredFlagValues(requirement.RequiredFlagValues) { panic("invalid flag value requirement: " + descriptor.name) } for _, flag := range requirement.RequiredFlags { @@ -322,12 +431,22 @@ func buildCommandDescriptorIndex(descriptors []commandDescriptor) map[string]com panic("value-required flag is not allowed: " + descriptor.name + " " + flag) } } + for _, required := range requirement.RequiredFlagValues { + if !slices.Contains(descriptor.allowedFlags, required.Flag) { + panic("value-required flag is not allowed: " + descriptor.name + " " + required.Flag) + } + } } for flag, choices := range descriptor.flagValueChoices { if !slices.Contains(descriptor.allowedFlags, flag) || !isSortedUnique(choices) { panic("invalid generated flag choices: " + descriptor.name + " " + flag) } } + for _, flag := range descriptor.singleOccurrenceFlags { + if !slices.Contains(descriptor.allowedFlags, flag) { + panic("single-occurrence flag is not allowed: " + descriptor.name + " " + flag) + } + } index[descriptor.name] = descriptor.clone() } return index @@ -398,11 +517,51 @@ func isSortedUnique(values []string) bool { return true } +func isSortedUniqueRequiredFlagValues(values []requiredFlagValue) bool { + previous := requiredFlagValue{} + for index, value := range values { + if value.Flag == "" || value.Value == "" { + return false + } + if index > 0 && (previous.Flag > value.Flag || previous.Flag == value.Flag && previous.Value >= value.Value) { + return false + } + previous = value + } + return true +} + +func isSortedUniqueFlagPresenceRequirements(values []flagPresenceRequirement) bool { + previous := "" + for index, value := range values { + if value.Flag == "" || index > 0 && previous >= value.Flag { + return false + } + previous = value.Flag + } + return true +} + +func isSortedUniqueFlagValueRequirements(values []flagValueRequirement) bool { + previous := "" + for index, value := range values { + key := value.Flag + "\x00" + value.Value + if value.Flag == "" || value.Value == "" || index > 0 && previous >= key { + return false + } + previous = key + } + return true +} + func (descriptor commandDescriptor) clone() commandDescriptor { descriptor.allowedFlags = cloneStrings(descriptor.allowedFlags) descriptor.requiredFlags = cloneStrings(descriptor.requiredFlags) descriptor.exactlyOneOfFlagGroups = cloneStringMatrix(descriptor.exactlyOneOfFlagGroups) + descriptor.atMostOneOfFlagGroups = cloneStringMatrix(descriptor.atMostOneOfFlagGroups) + descriptor.flagPresenceRequirements = cloneFlagPresenceRequirements(descriptor.flagPresenceRequirements) descriptor.flagValueRequirements = cloneFlagValueRequirements(descriptor.flagValueRequirements) + descriptor.singleOccurrenceFlags = cloneStrings(descriptor.singleOccurrenceFlags) descriptor.flagValueChoices = cloneStringMap(descriptor.flagValueChoices) descriptor.inputSchemaSummary = cloneStrings(descriptor.inputSchemaSummary) descriptor.outputModes = cloneStrings(descriptor.outputModes) @@ -422,7 +581,18 @@ func cloneStringMatrix(values [][]string) [][]string { func cloneFlagValueRequirements(values []flagValueRequirement) []flagValueRequirement { out := make([]flagValueRequirement, 0, len(values)) for _, value := range values { - value.RequiredFlags = cloneStrings(value.RequiredFlags) + value.RequiredFlags = append([]string{}, value.RequiredFlags...) + value.RequiredFlagValues = append([]requiredFlagValue(nil), value.RequiredFlagValues...) + out = append(out, value) + } + return out +} + +func cloneFlagPresenceRequirements(values []flagPresenceRequirement) []flagPresenceRequirement { + out := make([]flagPresenceRequirement, 0, len(values)) + for _, value := range values { + value.RequiredFlags = append([]string{}, value.RequiredFlags...) + value.RequiredFlagValues = append([]requiredFlagValue(nil), value.RequiredFlagValues...) out = append(out, value) } return out diff --git a/internal/app/command_flag_constraints.go b/internal/app/command_flag_constraints.go index 5de00c0..c83b869 100644 --- a/internal/app/command_flag_constraints.go +++ b/internal/app/command_flag_constraints.go @@ -3,21 +3,24 @@ package app import ( "fmt" "slices" + "strings" ) type descriptorArguments struct { + counts map[string]int present map[string]bool values map[string][]string } func classifyDescriptorArguments(descriptor commandDescriptor, args []string) descriptorArguments { - parsed := descriptorArguments{present: map[string]bool{}, values: map[string][]string{}} + parsed := descriptorArguments{counts: map[string]int{}, present: map[string]bool{}, values: map[string][]string{}} for index := 0; index < len(args); index++ { argument := args[index] if !slices.Contains(descriptor.allowedFlags, argument) { continue } parsed.present[argument] = true + parsed.counts[argument]++ if flagRequiresValue(argument) { if index+1 < len(args) { parsed.values[argument] = append(parsed.values[argument], args[index+1]) @@ -29,6 +32,18 @@ func classifyDescriptorArguments(descriptor commandDescriptor, args []string) de } func validateFlagConstraints(descriptor commandDescriptor, parsed descriptorArguments) error { + for _, flag := range descriptor.singleOccurrenceFlags { + if parsed.counts[flag] > 1 { + return fmt.Errorf("%s may be specified only once", flag) + } + } + for flag, choices := range descriptor.flagValueChoices { + for _, value := range parsed.values[flag] { + if !slices.Contains(choices, value) { + return flagChoiceError(flag, choices) + } + } + } if descriptor.input == commandInputRequired && !parsed.present["--input"] { return fmt.Errorf("%s requires --input ", descriptor.name) } @@ -48,6 +63,32 @@ func validateFlagConstraints(descriptor commandDescriptor, parsed descriptorArgu return fmt.Errorf("%s requires exactly one of %v", descriptor.name, group) } } + for _, group := range descriptor.atMostOneOfFlagGroups { + count := 0 + for _, flag := range group { + if parsed.present[flag] { + count++ + } + } + if count > 1 { + return fmt.Errorf("%s permits at most one of %v", descriptor.name, group) + } + } + for _, requirement := range descriptor.flagPresenceRequirements { + if !parsed.present[requirement.Flag] { + continue + } + for _, flag := range requirement.RequiredFlags { + if !parsed.present[flag] { + return fmt.Errorf("%s %s requires %s", descriptor.name, requirement.Flag, flag) + } + } + for _, required := range requirement.RequiredFlagValues { + if !slices.Contains(parsed.values[required.Flag], required.Value) { + return fmt.Errorf("%s %s requires %s %s", descriptor.name, requirement.Flag, required.Flag, required.Value) + } + } + } for _, requirement := range descriptor.flagValueRequirements { if !slices.Contains(parsed.values[requirement.Flag], requirement.Value) { continue @@ -57,10 +98,19 @@ func validateFlagConstraints(descriptor commandDescriptor, parsed descriptorArgu return fmt.Errorf("%s %s %s requires %s", descriptor.name, requirement.Flag, requirement.Value, flag) } } + for _, required := range requirement.RequiredFlagValues { + if !slices.Contains(parsed.values[required.Flag], required.Value) { + return fmt.Errorf("%s %s %s requires %s %s", descriptor.name, requirement.Flag, requirement.Value, required.Flag, required.Value) + } + } } return nil } +func flagChoiceError(flag string, choices []string) error { + return fmt.Errorf("%s requires one of: %s", flag, strings.Join(choices, ", ")) +} + func flagRequiresValue(flag string) bool { switch flag { case "--agent-envelope", "--contract-envelope", "--empty-local-environment-policy", "--help", "-h", "--list", "--materialization-manifest", "--normalized-inventory", "--open", "--serve", "--stack-diverse", "--verify": diff --git a/internal/app/command_help.go b/internal/app/command_help.go index c885743..cd641b9 100644 --- a/internal/app/command_help.go +++ b/internal/app/command_help.go @@ -62,13 +62,30 @@ func commandUsageWithRenderer(descriptor commandDescriptor, renderer cliexec.Ren " Path: node_modules/@research-engineering/agentic-proofkit/README.md", ) } - if len(descriptor.exactlyOneOfFlagGroups) > 0 || len(descriptor.flagValueRequirements) > 0 { + if len(descriptor.exactlyOneOfFlagGroups) > 0 || len(descriptor.atMostOneOfFlagGroups) > 0 || len(descriptor.flagPresenceRequirements) > 0 || len(descriptor.flagValueRequirements) > 0 || len(descriptor.singleOccurrenceFlags) > 0 { lines = append(lines, "", "Flag constraints:") for _, group := range descriptor.exactlyOneOfFlagGroups { lines = append(lines, " Exactly one of: "+strings.Join(group, ", ")) } + for _, group := range descriptor.atMostOneOfFlagGroups { + lines = append(lines, " At most one of: "+strings.Join(group, ", ")) + } + for _, requirement := range descriptor.flagPresenceRequirements { + required := cloneStrings(requirement.RequiredFlags) + for _, value := range requirement.RequiredFlagValues { + required = append(required, value.Flag+" "+value.Value) + } + lines = append(lines, fmt.Sprintf(" %s requires: %s", requirement.Flag, strings.Join(required, ", "))) + } for _, requirement := range descriptor.flagValueRequirements { - lines = append(lines, fmt.Sprintf(" %s %s requires: %s", requirement.Flag, requirement.Value, strings.Join(requirement.RequiredFlags, ", "))) + required := cloneStrings(requirement.RequiredFlags) + for _, value := range requirement.RequiredFlagValues { + required = append(required, value.Flag+" "+value.Value) + } + lines = append(lines, fmt.Sprintf(" %s %s requires: %s", requirement.Flag, requirement.Value, strings.Join(required, ", "))) + } + for _, flag := range descriptor.singleOccurrenceFlags { + lines = append(lines, " May be specified once: "+flag) } } if len(descriptor.inputSchemaSummary) > 0 { diff --git a/internal/app/requirement_browser_command.go b/internal/app/requirement_browser_command.go index f74b9f8..f787fbf 100644 --- a/internal/app/requirement_browser_command.go +++ b/internal/app/requirement_browser_command.go @@ -7,6 +7,7 @@ import ( "io" "os" "os/signal" + "slices" "strconv" "syscall" "time" @@ -16,6 +17,11 @@ import ( "github.com/research-engineering/agentic-proofkit/internal/kernel/jsonpointer" ) +var requirementBrowserSingleOccurrenceFlags = flags( + "--session-mode", + "--session-timeout-seconds", +) + func runRequirementBrowserServer(ctx context.Context, args []string, stdin io.Reader, stdout io.Writer, stderr io.Writer) int { options, err := parseRequirementBrowserArgs(args) if err != nil { @@ -66,8 +72,14 @@ func parseRequirementBrowserArgs(args []string) (requirementBrowserArgs, error) options := requirementBrowserArgs{host: "127.0.0.1", port: 0, sessionMode: "browse"} inputPointerSeen := false sessionModeSeen := false - sessionTimeoutSeen := false + seenSingletonFlags := map[string]bool{} for index := 0; index < len(args); index++ { + if slices.Contains(requirementBrowserSingleOccurrenceFlags, args[index]) { + if seenSingletonFlags[args[index]] { + return requirementBrowserArgs{}, fmt.Errorf("%s may be specified only once", args[index]) + } + seenSingletonFlags[args[index]] = true + } switch args[index] { case "--input": if options.inputPath != "" || index+1 >= len(args) || args[index+1] == "" { @@ -83,14 +95,14 @@ func parseRequirementBrowserArgs(args []string) (requirementBrowserArgs, error) options.inputPointer = args[index+1] index++ case "--view": - if index+1 >= len(args) || (args[index+1] != "source" && args[index+1] != "proof" && args[index+1] != "coverage" && args[index+1] != "spec-tree" && args[index+1] != "workspace") { - return requirementBrowserArgs{}, fmt.Errorf("--view requires source, proof, coverage, spec-tree, or workspace") + if index+1 >= len(args) || !requirementBrowserFlagValueAllowed("--view", args[index+1]) { + return requirementBrowserArgs{}, flagChoiceError("--view", requirementBrowserFlagChoices("--view")) } options.view = args[index+1] index++ case "--host": - if index+1 >= len(args) || (args[index+1] != "127.0.0.1" && args[index+1] != "::1") { - return requirementBrowserArgs{}, fmt.Errorf("--host requires loopback literal: 127.0.0.1 or ::1") + if index+1 >= len(args) || !requirementBrowserFlagValueAllowed("--host", args[index+1]) { + return requirementBrowserArgs{}, flagChoiceError("--host", requirementBrowserFlagChoices("--host")) } options.host = args[index+1] index++ @@ -110,17 +122,16 @@ func parseRequirementBrowserArgs(args []string) (requirementBrowserArgs, error) case "--serve": options.serve = true case "--session-mode": - if sessionModeSeen || index+1 >= len(args) || (args[index+1] != "browse" && args[index+1] != "one-shot-question") { - return requirementBrowserArgs{}, fmt.Errorf("--session-mode requires browse or one-shot-question") + if index+1 >= len(args) || !requirementBrowserFlagValueAllowed("--session-mode", args[index+1]) { + return requirementBrowserArgs{}, flagChoiceError("--session-mode", requirementBrowserFlagChoices("--session-mode")) } sessionModeSeen = true options.sessionMode = args[index+1] index++ case "--session-timeout-seconds": - if sessionTimeoutSeen || index+1 >= len(args) { + if index+1 >= len(args) { return requirementBrowserArgs{}, fmt.Errorf("--session-timeout-seconds requires an integer from 1 to 7200") } - sessionTimeoutSeen = true seconds, err := strconv.Atoi(args[index+1]) if err != nil || seconds < 1 || seconds > 7200 { return requirementBrowserArgs{}, fmt.Errorf("--session-timeout-seconds requires an integer from 1 to 7200") @@ -128,8 +139,8 @@ func parseRequirementBrowserArgs(args []string) (requirementBrowserArgs, error) options.sessionTimeoutSeconds = seconds index++ case "--scope": - if index+1 >= len(args) || (args[index+1] != "graph" && args[index+1] != "slice") { - return requirementBrowserArgs{}, fmt.Errorf("--scope requires graph or slice") + if index+1 >= len(args) || !requirementBrowserFlagValueAllowed("--scope", args[index+1]) { + return requirementBrowserArgs{}, flagChoiceError("--scope", requirementBrowserFlagChoices("--scope")) } options.scope = args[index+1] index++ @@ -178,3 +189,11 @@ func parseRequirementBrowserArgs(args []string) (requirementBrowserArgs, error) } return options, nil } + +func requirementBrowserFlagChoices(flag string) []string { + return commandDescriptorByName["requirement-browser-server"].flagValueChoices[flag] +} + +func requirementBrowserFlagValueAllowed(flag string, value string) bool { + return slices.Contains(requirementBrowserFlagChoices(flag), value) +} diff --git a/internal/app/self_hosting_semantics_test.go b/internal/app/self_hosting_semantics_test.go index ece1c44..8973e4f 100644 --- a/internal/app/self_hosting_semantics_test.go +++ b/internal/app/self_hosting_semantics_test.go @@ -32,6 +32,21 @@ func TestSelfHostingProofCoreCommandsAcceptCurrentRecords(t *testing.T) { } } +func TestSelfHostingCoverageMetricsDeclaresTimestampedOutput(t *testing.T) { + plan := readJSONFile(t, "proofkit/witness-plan.json").(map[string]any) + for _, raw := range plan["policies"].([]any) { + command := raw.(map[string]any) + if command["commandId"] != "proofkit.coverage-metrics" { + continue + } + if command["deterministicOutput"] != false { + t.Fatalf("coverage metrics deterministicOutput=%v, want false for generatedAt-bearing output", command["deterministicOutput"]) + } + return + } + t.Fatal("proofkit.coverage-metrics is missing from witness plan") +} + func requirementSourcePaths(t *testing.T) []string { t.Helper() root := repoRoot(t) @@ -79,9 +94,6 @@ func TestSelfHostingWitnessBackedBindingsReferenceExistingSurfaces(t *testing.T) if !ok || witnessPath == "" { t.Fatalf("%s/%s witness_backed binding has no witnessPath", binding["requirementId"], binding["scenarioId"]) } - if strings.HasPrefix(witnessPath, "proofkit.virtual/") { - continue - } if _, err := os.Stat(filepath.Join(root, witnessPath)); err != nil { t.Fatalf("%s/%s witnessPath %q does not exist: %v", binding["requirementId"], binding["scenarioId"], witnessPath, err) } diff --git a/internal/command/adoptiondoctor/adoptiondoctor.go b/internal/command/adoptiondoctor/adoptiondoctor.go index 0c31f0e..2306bbb 100644 --- a/internal/command/adoptiondoctor/adoptiondoctor.go +++ b/internal/command/adoptiondoctor/adoptiondoctor.go @@ -738,7 +738,7 @@ func sortedPaths(raw any, context string) ([]string, error) { } paths = append(paths, path) } - return admit.PreserveSortedText(paths, context, true) + return admit.PreserveSortedPaths(paths, context, true) } func sortedRuleIDs(raw any, context string) ([]string, error) { diff --git a/internal/command/changedpathset/changedpathset_test.go b/internal/command/changedpathset/changedpathset_test.go index a6a0f85..9dcc9af 100644 --- a/internal/command/changedpathset/changedpathset_test.go +++ b/internal/command/changedpathset/changedpathset_test.go @@ -143,6 +143,21 @@ func TestBuildRejectsSecretLikeReportVisibleText(t *testing.T) { if strings.Contains(err.Error(), "password") || strings.Contains(err.Error(), "example.invalid") { t.Fatalf("Build() leaked URL credential text in error: %s", err) } + + result, err := Build(map[string]any{ + "schemaVersion": json.Number("1"), + "reportId": "proofkit.test.changed-path-set", + "preexistingFailures": []any{}, + "nonClaims": []any{"Changed-path test input does not prove git diff freshness."}, + "sources": []any{map[string]any{"sourceId": "git", "paths": []any{"artifacts/run-" + secret + ".log"}}}, + }) + if err != nil { + t.Fatalf("Build() embedded secret path error=%v", err) + } + encoded, _ := json.Marshal(result.Report) + if result.ExitCode == 0 || strings.Contains(string(encoded), secret) || strings.Contains(string(encoded), "abcdefghijklmnop") { + t.Fatalf("Build() did not fail closed without leaking embedded secret path: exit=%d report=%s", result.ExitCode, encoded) + } } func containsAnyString(values []any, want string) bool { diff --git a/internal/command/migrationparityadmission/migrationparityadmission.go b/internal/command/migrationparityadmission/migrationparityadmission.go index 39f160b..efdcebb 100644 --- a/internal/command/migrationparityadmission/migrationparityadmission.go +++ b/internal/command/migrationparityadmission/migrationparityadmission.go @@ -510,11 +510,7 @@ func sortedMapped(raw any, context string, allowEmpty bool, mapper func(any, str } result = append(result, item) } - sort.Strings(result) - if err := preserveSortedUnique(result, context, allowEmpty); err != nil { - return nil, err - } - return result, nil + return admit.NormalizeSortedText(result, context, allowEmpty) } func preserveSortedUnique(values []string, context string, allowEmpty bool) error { diff --git a/internal/command/obligationdecision/obligationdecision.go b/internal/command/obligationdecision/obligationdecision.go index 5f4fd45..a0dca81 100644 --- a/internal/command/obligationdecision/obligationdecision.go +++ b/internal/command/obligationdecision/obligationdecision.go @@ -404,8 +404,7 @@ func sortedText(values []string, context string, allowEmpty bool) ([]string, err } values[index] = trimmed } - sort.Strings(values) - return preserveSortedUnique(values, context, allowEmpty) + return admit.NormalizeSortedText(values, context, allowEmpty) } func preserveSortedUnique(values []string, context string, allowEmpty bool) ([]string, error) { diff --git a/internal/command/proofobligationalgebra/proof_obligation_algebra_test.go b/internal/command/proofobligationalgebra/proof_obligation_algebra_test.go index 24f1a0d..e47c3c7 100644 --- a/internal/command/proofobligationalgebra/proof_obligation_algebra_test.go +++ b/internal/command/proofobligationalgebra/proof_obligation_algebra_test.go @@ -204,6 +204,44 @@ func TestBuildAdmitsAtomicObligationAndRejectsMissingRoute(t *testing.T) { } } +func TestBuildRejectsShapeOnlyDelegationForCrossRequirementEdge(t *testing.T) { + input := validProofObligationAlgebraInput() + root := input["obligations"].([]any)[0].(map[string]any) + root["obligationKind"] = "all_of" + root["proofRouteRefs"] = []any{} + root["childObligationIds"] = []any{"proofkit.test.child_one", "proofkit.test.child_two"} + root["delegationRefs"] = []any{"proofkit.test.unresolved_delegation"} + for index, requirementID := range []string{"REQ-PROOFKIT-TEST-001", "REQ-PROOFKIT-TEST-002"} { + child := map[string]any{} + for key, value := range validProofObligationAlgebraInput()["obligations"].([]any)[0].(map[string]any) { + child[key] = value + } + child["obligationId"] = fmt.Sprintf("proofkit.test.child_%s", []string{"one", "two"}[index]) + child["requirementId"] = requirementID + input["obligations"] = append(input["obligations"].([]any), child) + } + + record, exitCode, err := Build(input) + if err != nil { + t.Fatalf("Build() error=%v", err) + } + if exitCode == 0 || record.State != "failed" { + t.Fatalf("Build() exit=%d state=%s, want unresolved delegation failure", exitCode, record.State) + } + encoded, _ := json.Marshal(record.JSONValue()) + if !strings.Contains(string(encoded), "without owner-admitted delegation authority") { + t.Fatalf("Build() output=%s, want unresolved delegation finding", encoded) + } +} + +func TestBuildUsesPathPolicyForEvidenceReferenceArrays(t *testing.T) { + input := validProofObligationAlgebraInput() + input["obligations"].([]any)[0].(map[string]any)["evidenceRefs"] = []any{"artifacts/run-sk-abcdefghij.log"} + if _, _, err := Build(input); err != nil { + t.Fatalf("Build() applied prose admission to canonical evidence paths: %v", err) + } +} + func validProofObligationAlgebraInput() map[string]any { return map[string]any{ "schemaVersion": json.Number("1"), diff --git a/internal/command/proofobligationalgebra/proofobligationalgebra.go b/internal/command/proofobligationalgebra/proofobligationalgebra.go index f9655bb..1e51842 100644 --- a/internal/command/proofobligationalgebra/proofobligationalgebra.go +++ b/internal/command/proofobligationalgebra/proofobligationalgebra.go @@ -45,6 +45,7 @@ var boundaryNonClaims = []string{ "Proof obligation algebra reports do not approve merge, release, rollout, or production readiness.", "Proof obligation algebra reports do not authenticate producers or receipts.", "Proof obligation algebra reports do not compute freshness or proof satisfaction.", + "Proof obligation algebra reports do not resolve delegation references or authorize cross-requirement edges.", "Proof obligation algebra reports do not execute witnesses or commands.", "Proof obligation algebra reports do not own requirement meaning, proof adequacy, or consumer policy.", } @@ -132,7 +133,7 @@ func Build(raw any) (report.Record, int, error) { ReportID: input.AlgebraID, State: state, Summary: map[string]any{ - "crossRequirementDelegationCount": countCrossRequirementDelegations(obligations, byID), + "crossRequirementDelegationCount": 0, "failedObligationCount": len(failedObligationIDs), "kindCounts": kindCounts(obligations), "nonRouteBearingObligationCount": len(nonRouteBearingObligationIDs), @@ -212,7 +213,7 @@ func obligationArray(raw any) ([]obligationInput, error) { for _, item := range obligations { ids = append(ids, item.ObligationID) } - if _, err := preserveSortedUnique(ids, "proof obligation ids", false); err != nil { + if _, err := admit.PreserveSortedText(ids, "proof obligation ids", false); err != nil { return nil, err } edgeCount := 0 @@ -343,6 +344,7 @@ func kindFindings(item obligationInput) []string { requireMinimum(item.ChildObligationIDs, 2, fmt.Sprintf("%s obligations must declare at least two childObligationIds", item.ObligationKind), &findings) requireEmpty(item.ProofRouteRefs, fmt.Sprintf("%s obligations must not declare proofRouteRefs", item.ObligationKind), &findings) requireEmpty(item.ConditionRefs, fmt.Sprintf("%s obligations must not declare conditionRefs", item.ObligationKind), &findings) + requireEmpty(item.DelegationRefs, fmt.Sprintf("%s obligations must not declare unresolved delegationRefs", item.ObligationKind), &findings) requireNull(item.ExpiryRef, fmt.Sprintf("%s obligations must not declare expiryRef", item.ObligationKind), &findings) requireNull(item.ReviewConditionRef, fmt.Sprintf("%s obligations must not declare reviewConditionRef", item.ObligationKind), &findings) return findings @@ -351,6 +353,7 @@ func kindFindings(item obligationInput) []string { requireNonEmpty(item.ChildObligationIDs, "conditional obligations must declare childObligationIds", &findings) requireNonEmpty(item.ConditionRefs, "conditional obligations must declare conditionRefs", &findings) requireEmpty(item.ProofRouteRefs, "conditional obligations must not declare proofRouteRefs", &findings) + requireEmpty(item.DelegationRefs, "conditional obligations must not declare unresolved delegationRefs", &findings) requireNull(item.ExpiryRef, "conditional obligations must not declare expiryRef", &findings) requireNull(item.ReviewConditionRef, "conditional obligations must not declare reviewConditionRef", &findings) return findings @@ -379,8 +382,8 @@ func crossRequirementFindings(item obligationInput, byID map[string]obligationIn findings := []string{} for _, childID := range item.ChildObligationIDs { child, ok := byID[childID] - if ok && child.RequirementID != item.RequirementID && len(item.DelegationRefs) == 0 { - findings = append(findings, fmt.Sprintf("child obligation %s crosses requirement scope without delegationRefs", child.ObligationID)) + if ok && child.RequirementID != item.RequirementID { + findings = append(findings, fmt.Sprintf("child obligation %s crosses requirement scope without owner-admitted delegation authority", child.ObligationID)) } } return findings @@ -495,23 +498,6 @@ func kindCounts(obligations []obligation) map[string]any { return counts } -func countCrossRequirementDelegations(obligations []obligation, byID map[string]obligationInput) int { - count := 0 - for _, item := range obligations { - hasCrossRequirementDelegation := false - for _, childID := range item.ChildObligationIDs { - child, ok := byID[childID] - if ok && child.RequirementID != item.RequirementID && len(item.DelegationRefs) > 0 { - hasCrossRequirementDelegation = true - } - } - if hasCrossRequirementDelegation { - count++ - } - } - return count -} - func requireEmpty(values []string, message string, findings *[]string) { if len(values) > 0 { *findings = append(*findings, message) @@ -555,23 +541,11 @@ func sortedRuleIDs(raw any, context string, allowEmpty bool) ([]string, error) { } ruleIDs = append(ruleIDs, ruleID) } - return preserveSortedUnique(ruleIDs, context, allowEmpty) + return admit.PreserveSortedText(ruleIDs, context, allowEmpty) } func sortedPaths(raw any, context string) ([]string, error) { - values, err := textArray(raw, context, true) - if err != nil { - return nil, err - } - paths := make([]string, 0, len(values)) - for _, value := range values { - pathValue, err := admit.SafeRepoRelativePath(value, context) - if err != nil { - return nil, err - } - paths = append(paths, pathValue) - } - return sortedText(paths, context, true) + return admit.NormalizeSortedPathArray(raw, context, true) } func textArray(raw any, context string, allowEmpty bool) ([]string, error) { @@ -616,23 +590,5 @@ func sortedText(values []string, context string, allowEmpty bool) ([]string, err } normalized = append(normalized, text) } - sort.Strings(normalized) - return preserveSortedUnique(normalized, context, allowEmpty) -} - -func preserveSortedUnique(values []string, context string, allowEmpty bool) ([]string, error) { - if len(values) == 0 && !allowEmpty { - return nil, fmt.Errorf("%s must not be empty", context) - } - sorted := append([]string{}, values...) - sort.Strings(sorted) - for index := 0; index < len(values); index++ { - if values[index] != sorted[index] { - return nil, fmt.Errorf("%s must be sorted and unique", context) - } - if index > 0 && values[index-1] == values[index] { - return nil, fmt.Errorf("%s must be sorted and unique", context) - } - } - return values, nil + return admit.NormalizeSortedText(normalized, context, allowEmpty) } diff --git a/internal/command/receiptcurrentnessscope/receipt_currentness_scope_test.go b/internal/command/receiptcurrentnessscope/receipt_currentness_scope_test.go index b65eaa8..377a97c 100644 --- a/internal/command/receiptcurrentnessscope/receipt_currentness_scope_test.go +++ b/internal/command/receiptcurrentnessscope/receipt_currentness_scope_test.go @@ -31,6 +31,18 @@ func TestBuildAdmitsCurrentScopedReceiptAndRejectsStaleDigest(t *testing.T) { } } +func TestBuildUsesPathPolicyForEvidenceReferenceArrays(t *testing.T) { + input := validReceiptCurrentnessScopeInput() + receipt := input["obligationReceipts"].([]any)[0].(map[string]any) + path := "artifacts/run-sk-abcdefghij.log" + receipt["evidenceRefs"] = []any{path} + receipt["currentnessChecks"].([]any)[0].(map[string]any)["evidenceRefs"] = []any{path} + receipt["scopeChecks"].([]any)[0].(map[string]any)["evidenceRefs"] = []any{path} + if _, _, err := Build(input); err != nil { + t.Fatalf("Build() applied prose admission to canonical evidence paths: %v", err) + } +} + func validReceiptCurrentnessScopeInput() map[string]any { return map[string]any{ "schemaVersion": json.Number("1"), diff --git a/internal/command/receiptcurrentnessscope/receiptcurrentnessscope.go b/internal/command/receiptcurrentnessscope/receiptcurrentnessscope.go index 7338bee..31bc04b 100644 --- a/internal/command/receiptcurrentnessscope/receiptcurrentnessscope.go +++ b/internal/command/receiptcurrentnessscope/receiptcurrentnessscope.go @@ -245,7 +245,7 @@ func admitObligationReceipt(record map[string]any) (obligationReceipt, error) { if err != nil { return obligationReceipt{}, err } - evidenceRefs, err := sortedPathsFromRaw(record["evidenceRefs"], "receipt currentness-scope evidenceRefs", false) + evidenceRefs, err := admit.NormalizeSortedPathArray(record["evidenceRefs"], "receipt currentness-scope evidenceRefs", false) if err != nil { return obligationReceipt{}, err } @@ -321,7 +321,7 @@ func admitCurrentnessCheck(record map[string]any) (currentnessCheck, error) { if err != nil { return currentnessCheck{}, err } - evidenceRefs, err := sortedPathsFromRaw(record["evidenceRefs"], "receipt currentness-scope currentness evidenceRefs", false) + evidenceRefs, err := admit.NormalizeSortedPathArray(record["evidenceRefs"], "receipt currentness-scope currentness evidenceRefs", false) if err != nil { return currentnessCheck{}, err } @@ -389,7 +389,7 @@ func admitScopeCheck(record map[string]any) (scopeCheck, error) { if err != nil { return scopeCheck{}, err } - evidenceRefs, err := sortedPathsFromRaw(record["evidenceRefs"], "receipt currentness-scope scope evidenceRefs", false) + evidenceRefs, err := admit.NormalizeSortedPathArray(record["evidenceRefs"], "receipt currentness-scope scope evidenceRefs", false) if err != nil { return scopeCheck{}, err } @@ -625,35 +625,11 @@ func sortedTextFromRaw(raw any, context string, allowEmpty bool) ([]string, erro return sortedText(result, context, allowEmpty) } -func sortedPathsFromRaw(raw any, context string, allowEmpty bool) ([]string, error) { - values, ok := raw.([]any) - if !ok { - return nil, fmt.Errorf("%s must be a string array", context) - } - result := make([]string, 0, len(values)) - for _, value := range values { - text, err := admit.NonEmptyText(value, context) - if err != nil { - return nil, err - } - path, err := admit.SafeRepoRelativePath(text, context) - if err != nil { - return nil, err - } - result = append(result, path) - } - return sortedText(result, context, allowEmpty) -} - func sortedText(values []string, context string, allowEmpty bool) ([]string, error) { if !allowEmpty && len(values) == 0 { return nil, fmt.Errorf("%s must not be empty", context) } - sort.Strings(values) - if err := preserveSortedUnique(values, context, allowEmpty); err != nil { - return nil, err - } - return values, nil + return admit.NormalizeSortedText(values, context, allowEmpty) } func preserveSortedUnique(values []string, context string, allowEmpty bool) error { diff --git a/internal/command/receipttrustclass/receipt_trust_class_test.go b/internal/command/receipttrustclass/receipt_trust_class_test.go index 29ec63f..86b63b5 100644 --- a/internal/command/receipttrustclass/receipt_trust_class_test.go +++ b/internal/command/receipttrustclass/receipt_trust_class_test.go @@ -62,6 +62,46 @@ func TestBuildAdmitsEveryProofVocabularyMergeSatisfactionClass(t *testing.T) { } } +func TestBuildRejectsHigherRankThatWeakensMinimumTrustSemantics(t *testing.T) { + input := validReceiptTrustClassInput() + input["trustClasses"] = append(input["trustClasses"].([]any), map[string]any{ + "trustClassId": "proofkit.test.untrusted_high_rank", + "rank": json.Number("9"), + "allowedProducerAdmissionLevels": []any{"advisory"}, + "allowedReceiptStatuses": []any{"failed", "passed"}, + "requiresArtifactRefs": false, + "requiresProvenanceRef": false, + "nonClaims": []any{"High-rank test fixture does not authenticate producers."}, + }) + receipt := input["obligationReceipts"].([]any)[0].(map[string]any) + receipt["artifactRefs"] = []any{} + receipt["producerAdmissionClass"] = "advisory" + receipt["provenanceRef"] = nil + receipt["receiptStatus"] = "failed" + receipt["trustClassId"] = "proofkit.test.untrusted_high_rank" + + if _, _, err := Build(input); err == nil { + t.Fatal("Build() accepted a higher rank that weakens the declared minimum trust semantics") + } +} + +func TestTrustClassArrayRejectsExcessiveCardinalityBeforeItemAdmission(t *testing.T) { + records := make([]any, maxTrustClasses+1) + if _, err := trustClassArray(records); err == nil { + t.Fatal("trustClassArray admitted an input above its explicit cardinality bound") + } +} + +func TestBuildUsesPathPolicyForReceiptReferenceArrays(t *testing.T) { + input := validReceiptTrustClassInput() + receipt := input["obligationReceipts"].([]any)[0].(map[string]any) + receipt["artifactRefs"] = []any{"artifacts/run-sk-abcdefghij.log"} + receipt["evidenceRefs"] = []any{"artifacts/run-sk-abcdefghij.log"} + if _, _, err := Build(input); err != nil { + t.Fatalf("Build() applied prose admission to canonical path references: %v", err) + } +} + func validReceiptTrustClassInput() map[string]any { return map[string]any{ "schemaVersion": json.Number("1"), diff --git a/internal/command/receipttrustclass/receipttrustclass.go b/internal/command/receipttrustclass/receipttrustclass.go index 23a37ab..c5eca23 100644 --- a/internal/command/receipttrustclass/receipttrustclass.go +++ b/internal/command/receipttrustclass/receipttrustclass.go @@ -9,7 +9,10 @@ import ( "github.com/research-engineering/agentic-proofkit/internal/kernel/report" ) -const reportKind = "proofkit.receipt-trust-class-admission" +const ( + reportKind = "proofkit.receipt-trust-class-admission" + maxTrustClasses = 4096 +) var producerAdmissionLevels = proofvocab.MergeSatisfactionClasses() var producerAdmissionLevelSet = proofvocab.MergeSatisfactionClassSet() @@ -210,6 +213,9 @@ func trustClassArray(raw any) ([]trustClass, error) { if err != nil { return nil, err } + if len(records) > maxTrustClasses { + return nil, fmt.Errorf("receipt trust-class trustClasses exceeds the %d-item limit", maxTrustClasses) + } result := make([]trustClass, 0, len(records)) for _, record := range records { item, err := admitTrustClass(record) @@ -227,15 +233,55 @@ func trustClassArray(raw any) ([]trustClass, error) { ids = append(ids, item.TrustClassID) ranks[item.Rank] = struct{}{} } - if err := preserveSortedUnique(ids, "receipt trust-class trustClass ids", false); err != nil { + if _, err := admit.PreserveSortedText(ids, "receipt trust-class trustClass ids", false); err != nil { return nil, err } if len(ranks) != len(result) { return nil, fmt.Errorf("receipt trust-class ranks must be unique") } + if err := requireMonotonicTrustClasses(result); err != nil { + return nil, err + } return result, nil } +func requireMonotonicTrustClasses(classes []trustClass) error { + ranked := append([]trustClass(nil), classes...) + sort.Slice(ranked, func(left int, right int) bool { + return ranked[left].Rank < ranked[right].Rank + }) + for index := 1; index < len(ranked); index++ { + lower := ranked[index-1] + higher := ranked[index] + if lower.RequiresArtifactRefs && !higher.RequiresArtifactRefs { + return fmt.Errorf("receipt trust-class higher rank must preserve lower-rank artifact requirements") + } + if lower.RequiresProvenanceRef && !higher.RequiresProvenanceRef { + return fmt.Errorf("receipt trust-class higher rank must preserve lower-rank provenance requirements") + } + if !isSubset(higher.AllowedProducerAdmissionLevels, lower.AllowedProducerAdmissionLevels) { + return fmt.Errorf("receipt trust-class higher rank producer admission levels must refine lower ranks") + } + if !isSubset(higher.AllowedReceiptStatuses, lower.AllowedReceiptStatuses) { + return fmt.Errorf("receipt trust-class higher rank receipt statuses must refine lower ranks") + } + } + return nil +} + +func isSubset(values []string, allowed []string) bool { + allowedSet := map[string]struct{}{} + for _, value := range allowed { + allowedSet[value] = struct{}{} + } + for _, value := range values { + if _, ok := allowedSet[value]; !ok { + return false + } + } + return true +} + func admitTrustClass(record map[string]any) (trustClass, error) { if err := admit.KnownKeys(record, []string{"allowedProducerAdmissionLevels", "allowedReceiptStatuses", "nonClaims", "rank", "requiresArtifactRefs", "requiresProvenanceRef", "trustClassId"}, "receipt trust-class trustClass"); err != nil { return trustClass{}, err @@ -299,7 +345,7 @@ func proofClassArray(raw any, trustClassIDs map[string]struct{}) ([]proofClass, for _, item := range result { ids = append(ids, item.ProofClassID) } - if err := preserveSortedUnique(ids, "receipt trust-class proofClass ids", false); err != nil { + if _, err := admit.PreserveSortedText(ids, "receipt trust-class proofClass ids", false); err != nil { return nil, err } return result, nil @@ -376,7 +422,7 @@ func obligationArray(raw any) ([]obligationReceipt, error) { for _, item := range result { ids = append(ids, item.ObligationID) } - if err := preserveSortedUnique(ids, "receipt trust-class obligation ids", false); err != nil { + if _, err := admit.PreserveSortedText(ids, "receipt trust-class obligation ids", false); err != nil { return nil, err } return result, nil @@ -430,11 +476,11 @@ func admitObligation(record map[string]any) (obligationReceipt, error) { if err != nil { return obligationReceipt{}, err } - artifactRefs, err := sortedPathsFromRaw(record["artifactRefs"], "receipt trust-class artifactRefs", true) + artifactRefs, err := admit.NormalizeSortedPathArray(record["artifactRefs"], "receipt trust-class artifactRefs", true) if err != nil { return obligationReceipt{}, err } - evidenceRefs, err := sortedPathsFromRaw(record["evidenceRefs"], "receipt trust-class evidenceRefs", false) + evidenceRefs, err := admit.NormalizeSortedPathArray(record["evidenceRefs"], "receipt trust-class evidenceRefs", false) if err != nil { return obligationReceipt{}, err } @@ -629,26 +675,6 @@ func sortedRuleIDs(raw any, context string) ([]string, error) { return result, nil } -func sortedPathsFromRaw(raw any, context string, allowEmpty bool) ([]string, error) { - values, ok := raw.([]any) - if !ok { - return nil, fmt.Errorf("%s must be a string array", context) - } - result := make([]string, 0, len(values)) - for _, value := range values { - text, err := admit.NonEmptyText(value, context) - if err != nil { - return nil, err - } - path, err := admit.SafeRepoRelativePath(text, context) - if err != nil { - return nil, err - } - result = append(result, path) - } - return sortedText(result, context, allowEmpty) -} - func sortedTextFromRaw(raw any, context string, allowEmpty bool) ([]string, error) { values, ok := raw.([]any) if !ok { @@ -678,34 +704,11 @@ func enumArray(raw any, allowed map[string]struct{}, ordered []string, context s } result = append(result, enumValue) } - sort.Strings(result) - if err := preserveSortedUnique(result, context, false); err != nil { - return nil, err - } - return result, nil + return admit.NormalizeSortedText(result, context, false) } func sortedText(values []string, context string, allowEmpty bool) ([]string, error) { - if !allowEmpty && len(values) == 0 { - return nil, fmt.Errorf("%s must not be empty", context) - } - sort.Strings(values) - if err := preserveSortedUnique(values, context, allowEmpty); err != nil { - return nil, err - } - return values, nil -} - -func preserveSortedUnique(values []string, context string, allowEmpty bool) error { - if !allowEmpty && len(values) == 0 { - return fmt.Errorf("%s must not be empty", context) - } - for index := range values { - if index > 0 && (values[index-1] == values[index] || values[index-1] > values[index]) { - return fmt.Errorf("%s must be sorted and unique", context) - } - } - return nil + return admit.NormalizeSortedText(values, context, allowEmpty) } func nonEmptyRecords(raw any, context string) ([]map[string]any, error) { diff --git a/internal/command/renderedartifactfreshness/renderedartifactfreshness.go b/internal/command/renderedartifactfreshness/renderedartifactfreshness.go index cb6edd4..3309151 100644 --- a/internal/command/renderedartifactfreshness/renderedartifactfreshness.go +++ b/internal/command/renderedartifactfreshness/renderedartifactfreshness.go @@ -145,8 +145,8 @@ func admitInput(raw any) (admittedInput, error) { return admittedInput{}, err } allNonClaims := append(append([]string{}, boundaryNonClaims...), nonClaims...) - sort.Strings(allNonClaims) - if _, err := preserveSortedUnique(allNonClaims, "rendered artifact freshness report nonClaims", true); err != nil { + allNonClaims, err = admit.NormalizeSortedText(allNonClaims, "rendered artifact freshness report nonClaims", true) + if err != nil { return admittedInput{}, err } return admittedInput{ @@ -423,8 +423,7 @@ func sortedText(raw any, context string, allowEmpty bool) ([]string, error) { } func sortedUnique(values []string, context string, allowEmpty bool) ([]string, error) { - sort.Strings(values) - return preserveSortedUnique(values, context, allowEmpty) + return admit.NormalizeSortedText(values, context, allowEmpty) } func preserveSortedUnique(values []string, context string, allowEmpty bool) ([]string, error) { diff --git a/internal/command/requirementbrowser/http_handler.go b/internal/command/requirementbrowser/http_handler.go index 6553470..0398709 100644 --- a/internal/command/requirementbrowser/http_handler.go +++ b/internal/command/requirementbrowser/http_handler.go @@ -22,6 +22,7 @@ import ( ) const ( + browserCapabilityBytes = 32 maxHandoffRequestBytes = 1 << 20 maxHandoffAnnotations = 64 maxHandoffQuoteBytes = 64 << 10 @@ -443,6 +444,8 @@ func serveIndex(response http.ResponseWriter, method string, rendered renderedVi response.Header().Set("content-type", "text/html; charset=utf-8") if rendered.workspace != nil { setWorkspaceSecurityHeaders(response) + } else { + setStaticDocumentSecurityHeaders(response) } response.WriteHeader(http.StatusOK) writeBody(response, method, []byte(rendered.html)) @@ -485,12 +488,13 @@ func serveHandoff(response http.ResponseWriter, request *http.Request, expectedO } func browserCapability() (string, error) { - value := make([]byte, 32) + value := make([]byte, browserCapabilityBytes) if _, err := rand.Read(value); err != nil { return "", fmt.Errorf("generate browser capability: %w", err) } return base64.RawURLEncoding.EncodeToString(value), nil } + func validCapability(request *http.Request, capability string) bool { expected, err := base64.RawURLEncoding.DecodeString(capability) if err != nil || len(expected) != 32 { @@ -502,14 +506,25 @@ func validCapability(request *http.Request, capability string) bool { } return subtle.ConstantTimeCompare(provided, expected) == 1 } + func setWorkspaceSecurityHeaders(response http.ResponseWriter) { response.Header().Set("content-security-policy", "default-src 'none'; script-src 'self'; style-src 'self'; connect-src 'self'; img-src 'self'; worker-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'") + setCommonDocumentSecurityHeaders(response) +} + +func setStaticDocumentSecurityHeaders(response http.ResponseWriter) { + response.Header().Set("content-security-policy", "default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'") + setCommonDocumentSecurityHeaders(response) +} + +func setCommonDocumentSecurityHeaders(response http.ResponseWriter) { response.Header().Set("cross-origin-opener-policy", "same-origin") response.Header().Set("cross-origin-resource-policy", "same-origin") response.Header().Set("permissions-policy", "accelerometer=(), camera=(), geolocation=(), gyroscope=(), microphone=(), payment=(), usb=()") response.Header().Set("x-content-type-options", "nosniff") response.Header().Set("referrer-policy", "no-referrer") } + func serveWorkspaceAsset(response http.ResponseWriter, method string, body []byte, contentType string) { if method != http.MethodGet && method != http.MethodHead { methodNotAllowed(response, method, "GET, HEAD") @@ -521,6 +536,7 @@ func serveWorkspaceAsset(response http.ResponseWriter, method string, body []byt response.WriteHeader(http.StatusOK) writeBody(response, method, body) } + func serveWorkspaceJSON(response http.ResponseWriter, method string, value any) { body, err := stablejson.Marshal(value) if err != nil { diff --git a/internal/command/requirementbrowser/requirementbrowser.go b/internal/command/requirementbrowser/requirementbrowser.go index b382651..deab08b 100644 --- a/internal/command/requirementbrowser/requirementbrowser.go +++ b/internal/command/requirementbrowser/requirementbrowser.go @@ -1,7 +1,9 @@ package requirementbrowser import ( + "encoding/base64" "fmt" + "slices" "strings" "time" @@ -26,6 +28,24 @@ var serverNonClaims = []string{ "Requirement browser servers do not authenticate the surrounding browser profile or operating-system user.", } +var ( + hostChoices = []string{"127.0.0.1", "::1"} + sessionModeChoices = []string{"browse", "one-shot-question"} + viewChoices = []string{"coverage", "proof", "source", "spec-tree", "workspace"} +) + +func HostChoices() []string { + return slices.Clone(hostChoices) +} + +func SessionModeChoices() []string { + return slices.Clone(sessionModeChoices) +} + +func ViewChoices() []string { + return slices.Clone(viewChoices) +} + type Options struct { EmptyLocalEnvironmentPolicy bool Host string @@ -65,7 +85,7 @@ func BuildPlan(raw any, options Options) (map[string]any, int, error) { return map[string]any{ "authority": "presentation_adapter_plan", "host": options.Host, - "htmlByteLength": len([]byte(rendered.html)), + "htmlByteLength": servedHTMLByteLength(rendered), "nonClaims": admit.StringSliceToAny(serverNonClaims), "planKind": "proofkit.requirement-browser-server-plan", "port": options.Port, @@ -127,7 +147,7 @@ func render(raw any, options Options) (renderedView, error) { }, nil } if options.View != "proof" { - return renderedView{}, fmt.Errorf("requirement-browser-server requires --view source, proof, coverage, spec-tree, or workspace") + return renderedView{}, fmt.Errorf("--view requires one of: %s", strings.Join(viewChoices, ", ")) } compact := requirementproofview.IsCompact(raw) if compact && len(options.LocalEnvironmentClasses) == 0 && !options.EmptyLocalEnvironmentPolicy { @@ -149,7 +169,7 @@ func render(raw any, options Options) (renderedView, error) { } func admitLoopbackHost(value string) error { - if value != "127.0.0.1" && value != "::1" { + if !slices.Contains(hostChoices, value) { return fmt.Errorf("requirement browser server host must be loopback literal: 127.0.0.1 or ::1") } return nil @@ -163,11 +183,26 @@ func admitPort(value int) error { } func browserURL(host string, port int) string { + return "http://" + browserAuthority(host, port) + "/" +} + +func browserAuthority(host string, port int) string { hostname := host if strings.Contains(host, ":") { hostname = "[" + host + "]" } - return fmt.Sprintf("http://%s:%d/", hostname, port) + if port == 80 { + return hostname + } + return fmt.Sprintf("%s:%d", hostname, port) +} + +func servedHTMLByteLength(rendered renderedView) int { + length := len([]byte(rendered.html)) + if rendered.workspace == nil { + return length + } + return length - len(workspaceCapabilityPlaceholder) + base64.RawURLEncoding.EncodedLen(browserCapabilityBytes) } func stringValue(raw any) string { diff --git a/internal/command/requirementbrowser/server.go b/internal/command/requirementbrowser/server.go index 57c9c18..2a13982 100644 --- a/internal/command/requirementbrowser/server.go +++ b/internal/command/requirementbrowser/server.go @@ -100,7 +100,7 @@ func StartServer(raw any, options Options) (ServerHandle, error) { _ = listener.Close() return ServerHandle{}, err } - expectedAuthority := net.JoinHostPort(options.Host, strconv.Itoa(actualPort)) + expectedAuthority := browserAuthority(options.Host, actualPort) capability, err := browserCapability() if err != nil { _ = listener.Close() @@ -305,7 +305,6 @@ func openBrowserWithLauncher(ctx context.Context, goos, rawURL string, launch br if err != nil || parsed.Scheme != "http" || (parsed.Hostname() != "127.0.0.1" && parsed.Hostname() != "::1") || - parsed.Port() == "" || parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" || diff --git a/internal/command/requirementbrowser/server_test.go b/internal/command/requirementbrowser/server_test.go index 7ac6d13..31452fb 100644 --- a/internal/command/requirementbrowser/server_test.go +++ b/internal/command/requirementbrowser/server_test.go @@ -8,6 +8,7 @@ import ( "io" "net" "net/http" + "net/http/httptest" "path/filepath" "slices" "strconv" @@ -46,6 +47,17 @@ func TestStartServerServesExplicitSourceViews(t *testing.T) { if !strings.Contains(root.Header.Get("content-type"), "text/html") { t.Fatalf("unexpected root content-type: %s", root.Header.Get("content-type")) } + for name, want := range map[string]string{ + "content-security-policy": "default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'", + "cross-origin-opener-policy": "same-origin", + "cross-origin-resource-policy": "same-origin", + "referrer-policy": "no-referrer", + "x-content-type-options": "nosniff", + } { + if got := root.Header.Get(name); got != want { + t.Fatalf("source view header %s=%q, want %q", name, got, want) + } + } rootBody, err := io.ReadAll(root.Body) if err != nil { t.Fatalf("read root body: %v", err) @@ -131,21 +143,69 @@ func TestStartServerServesExplicitSourceViews(t *testing.T) { } } +func TestBrowserHandlerCanonicalizesDefaultPortAuthority(t *testing.T) { + rendered, err := render(sourceInput(t), Options{Host: "127.0.0.1", Port: 80, PortSet: true, View: "source"}) + if err != nil { + t.Fatal(err) + } + handler := browserHandler("source", rendered, browserAuthority("127.0.0.1", 80), "", false, newTerminalArbiter()) + request := httptest.NewRequest(http.MethodGet, "http://127.0.0.1/", nil) + request.Host = "127.0.0.1" + response := httptest.NewRecorder() + handler.ServeHTTP(response, request) + if response.Code != http.StatusOK { + t.Fatalf("default-port request status=%d, want 200", response.Code) + } + if got := browserURL("127.0.0.1", 80); got != "http://127.0.0.1/" { + t.Fatalf("browserURL()=%q, want canonical default-port URL", got) + } +} + +func TestWorkspacePlanByteLengthMatchesServedCapabilityDocument(t *testing.T) { + fixture := workspaceFixture(t) + plan, exitCode, err := BuildPlan(fixture, Options{Host: "127.0.0.1", Port: 0, PortSet: true, View: "workspace"}) + if err != nil || exitCode != 0 { + t.Fatalf("BuildPlan() exit=%d error=%v", exitCode, err) + } + handle, err := StartServer(fixture, Options{Host: "127.0.0.1", Port: 0, PortSet: true, View: "workspace"}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = handle.Close(t.Context()) }) + response, err := (&http.Client{Timeout: 5 * time.Second}).Get(handle.URL) + if err != nil { + t.Fatal(err) + } + body, err := io.ReadAll(response.Body) + _ = response.Body.Close() + if err != nil { + t.Fatal(err) + } + if got, want := plan["htmlByteLength"], len(body); got != want { + t.Fatalf("plan htmlByteLength=%v, served bytes=%d", got, want) + } +} + func TestOpenBrowserUsesFixedLauncherAndLoopbackURL(t *testing.T) { - const loopbackURL = "http://127.0.0.1:43127/" cases := []struct { goos string + url string wantCommand string wantArgs []string }{ - {goos: "darwin", wantCommand: "open", wantArgs: []string{loopbackURL}}, - {goos: "linux", wantCommand: "xdg-open", wantArgs: []string{loopbackURL}}, - {goos: "windows", wantCommand: "cmd", wantArgs: []string{"/c", "start", "", loopbackURL}}, + {goos: "darwin", url: "http://127.0.0.1:43127/", wantCommand: "open", wantArgs: []string{"http://127.0.0.1:43127/"}}, + {goos: "linux", url: "http://127.0.0.1:43127/", wantCommand: "xdg-open", wantArgs: []string{"http://127.0.0.1:43127/"}}, + {goos: "linux-default-port", url: browserURL("127.0.0.1", 80), wantCommand: "xdg-open", wantArgs: []string{"http://127.0.0.1/"}}, + {goos: "windows", url: "http://127.0.0.1:43127/", wantCommand: "cmd", wantArgs: []string{"/c", "start", "", "http://127.0.0.1:43127/"}}, } for _, test := range cases { t.Run(test.goos, func(t *testing.T) { calls := 0 - err := openBrowserWithLauncher(t.Context(), test.goos, loopbackURL, func(_ context.Context, command string, args ...string) error { + goos := test.goos + if goos == "linux-default-port" { + goos = "linux" + } + err := openBrowserWithLauncher(t.Context(), goos, test.url, func(_ context.Context, command string, args ...string) error { calls++ if command != test.wantCommand || !slices.Equal(args, test.wantArgs) { t.Fatalf("launcher command=%q args=%v, want %q %v", command, args, test.wantCommand, test.wantArgs) diff --git a/internal/command/requirementcontext/model.go b/internal/command/requirementcontext/model.go index e11847c..4778710 100644 --- a/internal/command/requirementcontext/model.go +++ b/internal/command/requirementcontext/model.go @@ -4,7 +4,6 @@ import ( "encoding/json" "fmt" "sort" - "strings" "github.com/research-engineering/agentic-proofkit/internal/command/requirementbinding" "github.com/research-engineering/agentic-proofkit/internal/command/requirementcoverageview" @@ -420,15 +419,5 @@ func admitSources(raw any) ([]Source, error) { } func admitDigestRef(raw any, context string) (string, error) { - value, err := admit.NonEmptyText(raw, context) - if err != nil { - return "", err - } - if !strings.HasPrefix(value, "sha256:") { - return "", fmt.Errorf("%s must be a sha256 digest reference", context) - } - if _, err := admit.LowercaseSHA256(strings.TrimPrefix(value, "sha256:"), context); err != nil { - return "", err - } - return value, nil + return admit.SHA256Ref(raw, context) } diff --git a/internal/command/requirementcoverageview/admission.go b/internal/command/requirementcoverageview/admission.go index a854408..819a968 100644 --- a/internal/command/requirementcoverageview/admission.go +++ b/internal/command/requirementcoverageview/admission.go @@ -285,6 +285,8 @@ func admitSurfaces(raw any, context string) ([]surface, error) { return nil, fmt.Errorf("%s must be an array", context) } result := make([]surface, 0, len(values)) + ids := map[string]struct{}{} + pairs := map[string]string{} for _, value := range values { record, ok := value.(map[string]any) if !ok { @@ -309,10 +311,19 @@ func admitSurfaces(raw any, context string) ([]surface, error) { if err != nil { return nil, err } + if _, exists := ids[surfaceID]; exists { + return nil, fmt.Errorf("%s duplicate surfaceId %s", context, surfaceID) + } + pairKey := ownerID + "\x00" + pathValue + if previous, exists := pairs[pairKey]; exists { + return nil, fmt.Errorf("%s duplicate owner/path surface %s and %s", context, previous, surfaceID) + } + ids[surfaceID] = struct{}{} + pairs[pairKey] = surfaceID result = append(result, surface{OwnerID: ownerID, Path: pathValue, SurfaceID: surfaceID}) } sort.Slice(result, func(left, right int) bool { return result[left].SurfaceID < result[right].SurfaceID }) - return result, assertUnique(surfaceIDs(result), context+" surfaceIds") + return result, nil } func admitOwnerInvariantRegistry(raw any) (ownerInvariantRegistry, error) { if raw == nil { diff --git a/internal/command/requirementcoverageview/projection.go b/internal/command/requirementcoverageview/projection.go index a459228..6468596 100644 --- a/internal/command/requirementcoverageview/projection.go +++ b/internal/command/requirementcoverageview/projection.go @@ -98,14 +98,6 @@ func entryIDs(entries []testevidenceinventory.Entry) []string { } return sortedUnique(values) } -func surfaceIDs(values []surface) []string { - result := make([]string, 0, len(values)) - for _, value := range values { - result = append(result, value.SurfaceID) - } - sort.Strings(result) - return result -} func ownerInvariantIDs(values []ownerInvariant) []string { result := make([]string, 0, len(values)) for _, value := range values { diff --git a/internal/command/requirementcoverageview/requirementcoverageview_test.go b/internal/command/requirementcoverageview/requirementcoverageview_test.go index bf1b887..55e57a8 100644 --- a/internal/command/requirementcoverageview/requirementcoverageview_test.go +++ b/internal/command/requirementcoverageview/requirementcoverageview_test.go @@ -762,6 +762,22 @@ func TestBuildJSONRejectsCoverageUniverseSurfaceOutsideOwnerScope(t *testing.T) } } +func TestBuildJSONRejectsDuplicateCoverageUniverseOwnerPath(t *testing.T) { + input := validCoverageInput(t) + universe := input.(map[string]any)["coverageUniverse"].(map[string]any) + codeSurfaces := universe["codeSurfaces"].([]any) + duplicate := map[string]any{} + for key, value := range codeSurfaces[0].(map[string]any) { + duplicate[key] = value + } + duplicate["surfaceId"] = "proofkit.coverage.duplicate_code" + universe["codeSurfaces"] = append(codeSurfaces, duplicate) + + if _, _, err := BuildJSON(input, Options{}); err == nil || !strings.Contains(err.Error(), "duplicate owner/path surface") { + t.Fatalf("BuildJSON() error=%v, want duplicate owner/path rejection", err) + } +} + func TestBuildJSONRejectsInventoryEntryOutsideOwnerScope(t *testing.T) { input := validCoverageInput(t) inventoryEntry(input)["ownerId"] = "proofkit.other" diff --git a/internal/command/requirementdiff/output_admission.go b/internal/command/requirementdiff/output_admission.go index fe2fa2e..3cddd09 100644 --- a/internal/command/requirementdiff/output_admission.go +++ b/internal/command/requirementdiff/output_admission.go @@ -166,14 +166,7 @@ func admitOptionalDigest(raw any, context string) error { } func digestRef(raw any, context string) (string, error) { - value, err := admit.NonEmptyText(raw, context) - if err != nil || !strings.HasPrefix(value, "sha256:") { - return "", fmt.Errorf("%s must be a sha256 digest reference", context) - } - if _, err := admit.LowercaseSHA256(strings.TrimPrefix(value, "sha256:"), context); err != nil { - return "", err - } - return value, nil + return admit.SHA256Ref(raw, context) } func countEquals(raw any, expected int) bool { diff --git a/internal/command/requirementgraph/requirementgraph.go b/internal/command/requirementgraph/requirementgraph.go index fbbd417..94dcc66 100644 --- a/internal/command/requirementgraph/requirementgraph.go +++ b/internal/command/requirementgraph/requirementgraph.go @@ -590,14 +590,7 @@ func nonNegativeInteger(raw any, context string) (int, error) { return value, nil } func digestRef(raw any, context string) (string, error) { - value, err := admit.NonEmptyText(raw, context) - if err != nil || !strings.HasPrefix(value, "sha256:") { - return "", fmt.Errorf("%s must be a sha256 digest reference", context) - } - if _, err := admit.LowercaseSHA256(strings.TrimPrefix(value, "sha256:"), context); err != nil { - return "", err - } - return value, nil + return admit.SHA256Ref(raw, context) } func mapsToAny(values []map[string]any) []any { diff --git a/internal/command/requirementimpactinput/requirementimpactinput.go b/internal/command/requirementimpactinput/requirementimpactinput.go index 6957dd5..e4a2e21 100644 --- a/internal/command/requirementimpactinput/requirementimpactinput.go +++ b/internal/command/requirementimpactinput/requirementimpactinput.go @@ -424,7 +424,7 @@ func admitGeneratedArtifactRules(raw any) ([]generatedArtifactRule, error) { result = append(result, generatedArtifactRule{GeneratedPath: path, SourcePathPatterns: sources}) paths = append(paths, path) } - if _, err := admit.PreserveSortedText(paths, "requirement impact input compose generatedArtifactRules generated paths", true); err != nil { + if _, err := admit.PreserveSortedPaths(paths, "requirement impact input compose generatedArtifactRules generated paths", true); err != nil { return nil, err } return result, nil diff --git a/internal/command/requirementproofview/requirementproofview.go b/internal/command/requirementproofview/requirementproofview.go index e4f6568..5201b72 100644 --- a/internal/command/requirementproofview/requirementproofview.go +++ b/internal/command/requirementproofview/requirementproofview.go @@ -2,6 +2,7 @@ package requirementproofview import ( "fmt" + "slices" "sort" "strings" @@ -11,6 +12,12 @@ import ( "github.com/research-engineering/agentic-proofkit/internal/kernel/markdownfmt" ) +var scopeChoices = []string{"graph", "slice"} + +func ScopeChoices() []string { + return slices.Clone(scopeChoices) +} + type Options struct { Scope string LocalEnvironmentClasses []string @@ -84,7 +91,7 @@ func structuredView(raw any, options Options) (map[string]any, error) { if scope == "" { scope = "slice" } - if scope != "graph" && scope != "slice" { + if !slices.Contains(scopeChoices, scope) { return nil, fmt.Errorf("--scope must be graph or slice") } result, err := requirementbinding.Build(raw) diff --git a/internal/command/secretscan/secretscan.go b/internal/command/secretscan/secretscan.go index c16b1d5..8222c7b 100644 --- a/internal/command/secretscan/secretscan.go +++ b/internal/command/secretscan/secretscan.go @@ -196,7 +196,7 @@ func admitFiles(raw any) ([]fileRecord, error) { files = append(files, fileRecord{ContentBase64: contentBase64, Path: pathValue, State: state}) paths = append(paths, pathValue) } - if _, err := admit.PreserveSortedText(paths, "secret scan file paths", true); err != nil { + if _, err := admit.PreserveSortedPaths(paths, "secret scan file paths", true); err != nil { return nil, err } return files, nil diff --git a/internal/command/selectivegateevidence/selectivegateevidence.go b/internal/command/selectivegateevidence/selectivegateevidence.go index b99a3cc..7586418 100644 --- a/internal/command/selectivegateevidence/selectivegateevidence.go +++ b/internal/command/selectivegateevidence/selectivegateevidence.go @@ -552,11 +552,7 @@ func sortedTextFromAny(raw any, context string, allowEmpty bool) ([]string, erro if err != nil { return nil, err } - sort.Strings(values) - if err := preserveSortedUnique(values, context, allowEmpty); err != nil { - return nil, err - } - return values, nil + return admit.NormalizeSortedText(values, context, allowEmpty) } func sortedUniqueText(values []string) []string { @@ -583,11 +579,7 @@ func sortedPathsFromAny(raw any, context string, allowEmpty bool) ([]string, err } result = append(result, path) } - sort.Strings(result) - if err := preserveSortedUnique(result, context, allowEmpty); err != nil { - return nil, err - } - return result, nil + return admit.NormalizeSortedPaths(result, context, allowEmpty) } func uniqueSortedPaths(values []string, context string) ([]string, error) { @@ -625,18 +617,6 @@ func equalStringSlices(left []string, right []string) bool { return true } -func preserveSortedUnique(values []string, context string, allowEmpty bool) error { - if !allowEmpty && len(values) == 0 { - return fmt.Errorf("%s must be sorted and unique", context) - } - for index := range values { - if index > 0 && values[index-1] == values[index] { - return fmt.Errorf("%s must be sorted and unique", context) - } - } - return nil -} - func exitCode(raw any, status string) (any, error) { if status == "blocked" || status == "not_run" { if raw != nil { diff --git a/internal/command/stackpreset/preset_ids_generated.go b/internal/command/stackpreset/preset_ids_generated.go index 10476dd..38af489 100644 --- a/internal/command/stackpreset/preset_ids_generated.go +++ b/internal/command/stackpreset/preset_ids_generated.go @@ -1,6 +1,6 @@ // Code generated by internal/tools/commandcontractgen; DO NOT EDIT. package stackpreset -const presetContractSourceSHA256 = "82d4ba762c0773ab5b2ba400eb488b201ea4bbb87fae395968b0e1aba3dd022f" +const presetContractSourceSHA256 = "a6d6faaa1da035747847d7e2f9232857aa9e342413a1f7c2408ce2ca4cbe832b" var presetIDs = []string{"agentic_runtime_repo", "generated_docs_contract_repo", "python_service", "python_typescript_service", "typescript_monorepo", "typescript_workspace"} diff --git a/internal/command/textpolicy/textpolicy.go b/internal/command/textpolicy/textpolicy.go index 7ad590b..c52d4d0 100644 --- a/internal/command/textpolicy/textpolicy.go +++ b/internal/command/textpolicy/textpolicy.go @@ -253,7 +253,7 @@ func admitFiles(raw any) ([]FileRecord, error) { files = append(files, FileRecord{ContentBase64: contentBase64, Path: pathValue, State: state}) paths = append(paths, pathValue) } - if _, err := admit.PreserveSortedText(paths, "text policy file paths", true); err != nil { + if _, err := admit.PreserveSortedPaths(paths, "text policy file paths", true); err != nil { return nil, err } return files, nil diff --git a/internal/kernel/admission/json.go b/internal/kernel/admission/json.go index 79491f4..b6028ca 100644 --- a/internal/kernel/admission/json.go +++ b/internal/kernel/admission/json.go @@ -6,6 +6,8 @@ import ( "errors" "fmt" "io" + "reflect" + "strings" "unicode" "unicode/utf8" ) @@ -17,6 +19,10 @@ func DecodeJSON(reader io.Reader, maxBytes int64) (any, error) { if err != nil { return nil, err } + return decodeJSONSource(source) +} + +func decodeJSONSource(source []byte) (any, error) { if !utf8.Valid(source) { return nil, errors.New("invalid JSON input: source must be valid UTF-8") } @@ -37,20 +43,115 @@ func DecodeJSON(reader io.Reader, maxBytes int64) (any, error) { func DecodeTypedJSON[T any](reader io.Reader, maxBytes int64) (T, error) { var out T - value, err := DecodeJSON(reader, maxBytes) + source, err := readBounded(reader, maxBytes) if err != nil { return out, err } - normalized, err := json.Marshal(value) + value, err := decodeJSONSource(source) if err != nil { - return out, fmt.Errorf("normalize admitted JSON: %w", err) + return out, err + } + if err := rejectCaseFoldedTypedKeys(value, reflect.TypeOf((*T)(nil)).Elem()); err != nil { + return out, err } - if err := json.Unmarshal(normalized, &out); err != nil { + decoder := json.NewDecoder(bytes.NewReader(source)) + decoder.UseNumber() + if err := decoder.Decode(&out); err != nil { return out, fmt.Errorf("decode admitted JSON: %w", err) } return out, nil } +func rejectCaseFoldedTypedKeys(value any, target reflect.Type) error { + target = indirectJSONType(target) + if target == nil || target.Kind() == reflect.Interface || isOpaqueJSONType(target) { + return nil + } + switch target.Kind() { + case reflect.Struct: + record, ok := value.(map[string]any) + if !ok { + return nil + } + fields := jsonStructFields(target) + for key, child := range record { + fieldType, exact := fields[key] + if !exact { + for canonical := range fields { + if strings.EqualFold(key, canonical) { + return fmt.Errorf("invalid JSON input: object key must use exact declared field %q", canonical) + } + } + continue + } + if err := rejectCaseFoldedTypedKeys(child, fieldType); err != nil { + return err + } + } + case reflect.Slice, reflect.Array: + values, ok := value.([]any) + if !ok { + return nil + } + for _, child := range values { + if err := rejectCaseFoldedTypedKeys(child, target.Elem()); err != nil { + return err + } + } + case reflect.Map: + record, ok := value.(map[string]any) + if !ok || target.Key().Kind() != reflect.String { + return nil + } + for _, child := range record { + if err := rejectCaseFoldedTypedKeys(child, target.Elem()); err != nil { + return err + } + } + } + return nil +} + +func indirectJSONType(target reflect.Type) reflect.Type { + for target != nil && target.Kind() == reflect.Pointer { + target = target.Elem() + } + return target +} + +func isOpaqueJSONType(target reflect.Type) bool { + unmarshaler := reflect.TypeOf((*json.Unmarshaler)(nil)).Elem() + return target.Implements(unmarshaler) || (target.Kind() != reflect.Pointer && reflect.PointerTo(target).Implements(unmarshaler)) +} + +func jsonStructFields(target reflect.Type) map[string]reflect.Type { + fields := map[string]reflect.Type{} + for index := 0; index < target.NumField(); index++ { + field := target.Field(index) + if field.PkgPath != "" { + continue + } + tagName := strings.Split(field.Tag.Get("json"), ",")[0] + if tagName == "-" { + continue + } + if field.Anonymous && tagName == "" { + embedded := indirectJSONType(field.Type) + if embedded != nil && embedded.Kind() == reflect.Struct && !isOpaqueJSONType(embedded) { + for name, fieldType := range jsonStructFields(embedded) { + fields[name] = fieldType + } + continue + } + } + if tagName == "" { + tagName = field.Name + } + fields[tagName] = field.Type + } + return fields +} + func readBounded(reader io.Reader, maxBytes int64) ([]byte, error) { if maxBytes <= 0 { return nil, errors.New("maxBytes must be positive") diff --git a/internal/kernel/admission/json_test.go b/internal/kernel/admission/json_test.go index 5a865e1..8f6d70f 100644 --- a/internal/kernel/admission/json_test.go +++ b/internal/kernel/admission/json_test.go @@ -128,7 +128,9 @@ func TestDecodeJSONAcceptsNestedObjects(t *testing.T) { func TestDecodeTypedJSONUsesStrictAdmission(t *testing.T) { type record struct { - SchemaVersion int `json:"schemaVersion"` + Metadata map[string]any `json:"metadata"` + SchemaVersion int `json:"schemaVersion"` + Version string `json:"version"` } _, err := DecodeTypedJSON[record](strings.NewReader(`{"schemaVersion":1,"schemaVersion":2}`), 1024) if err == nil || !strings.Contains(err.Error(), "duplicate object key") { @@ -150,4 +152,16 @@ func TestDecodeTypedJSONUsesStrictAdmission(t *testing.T) { if raw["n"] != json.Number("123") { t.Fatalf("n=%v want 123", raw["n"]) } + + if _, err := DecodeTypedJSON[record](strings.NewReader(`{"schemaVersion":1,"VERSION":"9.9.9"}`), 1024); err == nil || !strings.Contains(err.Error(), "exact declared field") { + t.Fatalf("DecodeTypedJSON() case-folded key error = %v, want exact-key rejection", err) + } + + large, err := DecodeTypedJSON[record](strings.NewReader(`{"schemaVersion":1,"metadata":{"n":10000000000000000000}}`), 1024) + if err != nil { + t.Fatalf("DecodeTypedJSON() large number error = %v", err) + } + if number, ok := large.Metadata["n"].(json.Number); !ok || number.String() != "10000000000000000000" { + t.Fatalf("metadata.n=%#v, want exact json.Number", large.Metadata["n"]) + } } diff --git a/internal/kernel/admit/fields.go b/internal/kernel/admit/fields.go index 3dd04c4..5a58814 100644 --- a/internal/kernel/admit/fields.go +++ b/internal/kernel/admit/fields.go @@ -9,20 +9,27 @@ import ( "strings" ) +const ( + secretContextPatternSource = `authorization\s*:\s*[^\r\n]+|bearer\s+[A-Za-z0-9._~+/=-]{8,}|(?:access[-_]?token|api[-_]?key|pass(?:word|wd)|secret|token)\s*[=:]\s*\S+|-----BEGIN [A-Z ]*PRIVATE KEY-----` + secretSharedTokenPatternSource = `github_pat_[A-Za-z0-9_]+|gh[pousr]_[A-Za-z0-9_]+|xox[abprs]-[A-Za-z0-9-]+|glpat-[A-Za-z0-9_-]+|eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+` + secretScalarTokenPatternSource = secretSharedTokenPatternSource + `|sk-(?:proj-)?[A-Za-z0-9_-]{10,}` + secretPathTokenPatternSource = secretSharedTokenPatternSource + `|sk-(?:proj-[A-Za-z0-9_-]{10,}|[A-Za-z0-9_-]{16,})` +) + var ( - ruleIDPattern = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9_]*(?:[._:-][A-Za-z0-9_]+)*$`) - ruleIDSeparatorPattern = regexp.MustCompile(`[._:-]`) - timestampLikePattern = regexp.MustCompile(`\d{4}-\d{2}-\d{2}(?:T\d{2}:?\d{2}:?\d{2}(?:\.\d+)?Z?)?|\d{8}(?:T?\d{6}Z?)?`) - isoDateComponentPattern = regexp.MustCompile(`^\d{4}-\d{2}-\d{2}(?:T\d{2}:?\d{2}:?\d{2}(?:\.\d+)?Z?)?$`) - compactDateComponentRegexp = regexp.MustCompile(`^\d{8}(?:T?\d{6}Z?)?$`) - driveLikePathPattern = regexp.MustCompile(`^[A-Za-z]:(?:$|/)`) - schemeLikePathPattern = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9+.-]*:`) - secretValuePattern = regexp.MustCompile(`(?i)(authorization\s*:\s*[^\r\n]+|bearer\s+[A-Za-z0-9._~+/=-]{8,}|(?:access[-_]?token|api[-_]?key|pass(?:word|wd)|secret|token)\s*[=:]\s*\S+|github_pat_[A-Za-z0-9_]+|gh[pousr]_[A-Za-z0-9_]+|sk-(?:proj-)?[A-Za-z0-9_-]{10,}|xox[abprs]-[A-Za-z0-9-]+|glpat-[A-Za-z0-9_-]+|-----BEGIN [A-Z ]*PRIVATE KEY-----|eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+)`) - secretPathAssignmentPattern = regexp.MustCompile(`(?i)(?:access[-_]?token|api[-_]?key|pass(?:word|wd)|secret|token)\s*[=:]\s*\S+`) - secretPathTokenPattern = regexp.MustCompile(`(?i)^(?:github_pat_[A-Za-z0-9_]{10,}|gh[pousr]_[A-Za-z0-9_]{10,}|sk-(?:proj-)?[A-Za-z0-9_-]{16,}|xox[abprs]-[A-Za-z0-9-]{10,}|glpat-[A-Za-z0-9_-]{10,}|eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+)(?:\.[A-Za-z0-9_-]+)?$`) - urlUserInfoPattern = regexp.MustCompile(`[A-Za-z][A-Za-z0-9+.-]*://[^/\s:@]+:[^/\s@]+@`) - controlRunePattern = regexp.MustCompile(`[\x00-\x1f\x7f]`) - shellControlTokenPattern = regexp.MustCompile("(&&|\\|\\||[;&|<>`]|\\$\\(|\\r|\\n)") + ruleIDPattern = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9_]*(?:[._:-][A-Za-z0-9_]+)*$`) + ruleIDSeparatorPattern = regexp.MustCompile(`[._:-]`) + timestampLikePattern = regexp.MustCompile(`\d{4}-\d{2}-\d{2}(?:T\d{2}:?\d{2}:?\d{2}(?:\.\d+)?Z?)?|\d{8}(?:T?\d{6}Z?)?`) + isoDateComponentPattern = regexp.MustCompile(`^\d{4}-\d{2}-\d{2}(?:T\d{2}:?\d{2}:?\d{2}(?:\.\d+)?Z?)?$`) + compactDateComponentRegexp = regexp.MustCompile(`^\d{8}(?:T?\d{6}Z?)?$`) + driveLikePathPattern = regexp.MustCompile(`^[A-Za-z]:(?:$|/)`) + schemeLikePathPattern = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9+.-]*:`) + secretValuePattern = regexp.MustCompile(`(?i)(?:` + secretContextPatternSource + `|` + secretScalarTokenPatternSource + `)`) + secretPathContextPattern = regexp.MustCompile(`(?i)(?:` + secretContextPatternSource + `)`) + secretPathTokenPattern = regexp.MustCompile(`(?i)(?:^|[^A-Za-z0-9_])(?:` + secretPathTokenPatternSource + `)(?:$|[^A-Za-z0-9_])`) + urlUserInfoPattern = regexp.MustCompile(`[A-Za-z][A-Za-z0-9+.-]*://[^/\s:@]+:[^/\s@]+@`) + controlRunePattern = regexp.MustCompile(`[\x00-\x1f\x7f]`) + shellControlTokenPattern = regexp.MustCompile("(&&|\\|\\||[;&|<>`]|\\$\\(|\\r|\\n)") ) const ( @@ -128,20 +135,32 @@ func LowercaseSHA256(raw any, context string) (string, error) { return value, nil } +func SHA256Ref(raw any, context string) (string, error) { + value, err := NonEmptyText(raw, context) + if err != nil || !strings.HasPrefix(value, "sha256:") { + return "", fmt.Errorf("%s must be a sha256 digest reference", context) + } + digest, err := LowercaseSHA256(strings.TrimPrefix(value, "sha256:"), context) + if err != nil { + return "", err + } + return "sha256:" + digest, nil +} + +func SHA256HexRef(raw any, context string) (string, error) { + digest, err := LowercaseSHA256(raw, context) + if err != nil { + return "", err + } + return "sha256:" + digest, nil +} + func ContainsSecretLikeValue(value string) bool { return ContainsSecretTokenLikeValue(value) || ContainsURLCredentialValue(value) } func ContainsSecretLikePathValue(value string) bool { - if ContainsURLCredentialValue(value) { - return true - } - for _, component := range strings.Split(value, "/") { - if secretPathAssignmentPattern.MatchString(component) || secretPathTokenPattern.MatchString(component) { - return true - } - } - return false + return ContainsURLCredentialValue(value) || secretPathContextPattern.MatchString(value) || secretPathTokenPattern.MatchString(value) } func ContainsSecretTokenLikeValue(value string) bool { @@ -268,7 +287,14 @@ func NormalizeSortedText(values []string, context string, allowEmpty bool) ([]st if !allowEmpty && len(values) == 0 { return nil, fmt.Errorf("%s must be non-empty", context) } - normalized := append([]string{}, values...) + normalized := make([]string, 0, len(values)) + for index, value := range values { + admitted, err := NonEmptyText(value, fmt.Sprintf("%s[%d]", context, index)) + if err != nil { + return nil, err + } + normalized = append(normalized, admitted) + } sort.Strings(normalized) for index := 1; index < len(normalized); index++ { if normalized[index-1] == normalized[index] { @@ -286,6 +312,35 @@ func NormalizeSortedTextArray(raw any, context string, allowEmpty bool) ([]strin return NormalizeSortedText(values, context, allowEmpty) } +func NormalizeSortedPaths(values []string, context string, allowEmpty bool) ([]string, error) { + if !allowEmpty && len(values) == 0 { + return nil, fmt.Errorf("%s must be non-empty", context) + } + paths := make([]string, 0, len(values)) + for index, value := range values { + pathValue, err := SafeRepoRelativePath(value, fmt.Sprintf("%s[%d]", context, index)) + if err != nil { + return nil, err + } + paths = append(paths, pathValue) + } + sort.Strings(paths) + for index := 1; index < len(paths); index++ { + if paths[index-1] == paths[index] { + return nil, fmt.Errorf("%s must be unique", context) + } + } + return paths, nil +} + +func NormalizeSortedPathArray(raw any, context string, allowEmpty bool) ([]string, error) { + values, err := pathArrayValues(raw, context, allowEmpty) + if err != nil { + return nil, err + } + return NormalizeSortedPaths(values, context, allowEmpty) +} + func SortedText(values []string, context string, allowEmpty bool) ([]string, error) { return NormalizeSortedText(values, context, allowEmpty) } @@ -327,17 +382,32 @@ func PreserveSortedText(values []string, context string, allowEmpty bool) ([]str if !allowEmpty && len(values) == 0 { return nil, fmt.Errorf("%s must be non-empty", context) } - sorted := append([]string{}, values...) + canonical := make([]string, 0, len(values)) + for index, value := range values { + admitted, err := NonEmptyText(value, fmt.Sprintf("%s[%d]", context, index)) + if err != nil { + return nil, err + } + if admitted != value { + return nil, fmt.Errorf("%s must contain canonical non-empty text", context) + } + canonical = append(canonical, admitted) + } + return preserveSortedCanonical(canonical, context) +} + +func preserveSortedCanonical(canonical []string, context string) ([]string, error) { + sorted := append([]string{}, canonical...) sort.Strings(sorted) - for index := 0; index < len(values); index++ { - if values[index] != sorted[index] { + for index := 0; index < len(canonical); index++ { + if canonical[index] != sorted[index] { return nil, fmt.Errorf("%s must be sorted and unique", context) } - if index > 0 && values[index-1] == values[index] { + if index > 0 && canonical[index-1] == canonical[index] { return nil, fmt.Errorf("%s must be sorted and unique", context) } } - return values, nil + return canonical, nil } func PreserveSortedTextArray(raw any, context string, allowEmpty bool) ([]string, error) { @@ -380,19 +450,48 @@ func containsControlRune(value string) bool { } func PreserveSortedPathArray(raw any, context string, allowEmpty bool) ([]string, error) { - values, err := TextArray(raw, context, allowEmpty) + values, err := pathArrayValues(raw, context, allowEmpty) if err != nil { return nil, err } + return PreserveSortedPaths(values, context, allowEmpty) +} + +func pathArrayValues(raw any, context string, allowEmpty bool) ([]string, error) { + values, ok := raw.([]any) + if !ok { + return nil, fmt.Errorf("%s must be an array", context) + } + result := make([]string, 0, len(values)) + for index, item := range values { + value, ok := item.(string) + if !ok { + return nil, fmt.Errorf("%s[%d] must be a repository-relative POSIX path", context, index) + } + result = append(result, value) + } + if !allowEmpty && len(result) == 0 { + return nil, fmt.Errorf("%s must be non-empty", context) + } + return result, nil +} + +func PreserveSortedPaths(values []string, context string, allowEmpty bool) ([]string, error) { + if !allowEmpty && len(values) == 0 { + return nil, fmt.Errorf("%s must be non-empty", context) + } paths := make([]string, 0, len(values)) - for _, value := range values { - pathValue, err := SafeRepoRelativePath(value, context) + for index, value := range values { + pathValue, err := SafeRepoRelativePath(value, fmt.Sprintf("%s[%d]", context, index)) if err != nil { return nil, err } + if pathValue != value { + return nil, fmt.Errorf("%s must contain canonical repository-relative POSIX paths", context) + } paths = append(paths, pathValue) } - return PreserveSortedText(paths, context, allowEmpty) + return preserveSortedCanonical(paths, context) } func Enum(raw any, values map[string]struct{}, context string) (string, error) { diff --git a/internal/kernel/admit/fields_test.go b/internal/kernel/admit/fields_test.go index 2653b9a..7a52dbe 100644 --- a/internal/kernel/admit/fields_test.go +++ b/internal/kernel/admit/fields_test.go @@ -65,6 +65,65 @@ func TestContainsSecretLikeValueRecognizesHyphenatedAndPasswdLabels(t *testing.T } } +func TestSafeRepoRelativePathUsesCompleteSecretTaxonomy(t *testing.T) { + t.Parallel() + + for _, fixture := range ReportVisibleRedactionFixtures() { + path := "artifacts/run-" + fixture.Input + ".log" + if _, err := SafeRepoRelativePath(path, "report-visible path"); err == nil { + t.Fatalf("SafeRepoRelativePath admitted embedded %s token", fixture.Name) + } + } + if path, err := SafeRepoRelativePath("artifacts/run-proofkit-check.log", "report-visible path"); err != nil || path != "artifacts/run-proofkit-check.log" { + t.Fatalf("SafeRepoRelativePath rejected benign path: %q %v", path, err) + } +} + +func TestSortedTextOwnersAdmitCanonicalContentBeforeOrdering(t *testing.T) { + t.Parallel() + + if _, err := PreserveSortedText([]string{" "}, "nonClaims", false); err == nil { + t.Fatal("PreserveSortedText admitted blank typed text") + } + if _, err := PreserveSortedText([]string{"sk-proj-abcdefghijklmnop"}, "nonClaims", false); err == nil { + t.Fatal("PreserveSortedText admitted secret-shaped typed text") + } + if got, err := NormalizeSortedText([]string{" b ", "a"}, "labels", false); err != nil || strings.Join(got, ",") != "a,b" { + t.Fatalf("NormalizeSortedText()=%v error=%v, want canonical a,b", got, err) + } +} + +func TestPreserveSortedPathsUsesPathAdmissionWithoutProseFalsePositives(t *testing.T) { + t.Parallel() + + paths := []string{"docs/features/ai-risk-escalation.md", "proofkit/contracts.json"} + if got, err := PreserveSortedPaths(paths, "paths", false); err != nil || strings.Join(got, ",") != strings.Join(paths, ",") { + t.Fatalf("PreserveSortedPaths()=%v error=%v, want canonical paths", got, err) + } + if _, err := PreserveSortedPaths([]string{"artifacts/run-ghp_12345678901234567890.log"}, "paths", false); err == nil { + t.Fatal("PreserveSortedPaths admitted a secret-shaped path") + } + unsorted := []string{"proofkit/contracts.json", "docs/features/ai-risk-escalation.md"} + if got, err := NormalizeSortedPaths(unsorted, "paths", false); err != nil || strings.Join(got, ",") != strings.Join(paths, ",") { + t.Fatalf("NormalizeSortedPaths()=%v error=%v, want sorted canonical paths", got, err) + } + if _, err := NormalizeSortedPaths([]string{"docs/same.md", "docs/same.md"}, "paths", false); err == nil { + t.Fatal("NormalizeSortedPaths admitted duplicate paths") + } + pathPolicyOnly := "artifacts/run-sk-abcdefghij.log" + if _, err := NonEmptyText(pathPolicyOnly, "prose"); err == nil { + t.Fatal("test premise invalid: prose admission unexpectedly accepted the path-policy fixture") + } + for name, admitArray := range map[string]func(any, string, bool) ([]string, error){ + "normalize": NormalizeSortedPathArray, + "preserve": PreserveSortedPathArray, + } { + if got, err := admitArray([]any{pathPolicyOnly}, "paths", false); err != nil || len(got) != 1 || got[0] != pathPolicyOnly { + t.Fatalf("%s path array=%v error=%v, want the canonical path-policy fixture", name, got, err) + } + } +} + func TestMergeNonClaimsPreservesRequiredClaimsAndRejectsSecretLikeCallerText(t *testing.T) { t.Parallel() @@ -184,7 +243,7 @@ func TestPreserveSortedTextRejectsCallerOrderingDrift(t *testing.T) { func TestSafeRepoRelativePathRejectsEscapesAndNormalization(t *testing.T) { t.Parallel() - for _, value := range []string{"..", "../outside.md", "docs//INDEX.md", "/absolute.md", `docs\\INDEX.md`, ".", "C:/outside/report.json", "file:docs/report.json", "https://example.test/report.json", "packages/ghp_secretvalue/src/index.ts", "docs/api_key=abc123456789.md", "docs/sk-proj-abcdefghijklmnop.md", "docs/index\n.md", "docs/index\r.md", "docs/index\t.md", "docs/index\x7f.md"} { + for _, value := range []string{"..", "../outside.md", "docs//INDEX.md", "/absolute.md", `docs\\INDEX.md`, ".", "C:/outside/report.json", "file:docs/report.json", "https://example.test/report.json", "packages/ghp_secretvalue/src/index.ts", "artifacts/run-ghp_ABCDEFGHI.log", "docs/api_key=abc123456789.md", "docs/sk-proj-abcdefghijklmnop.md", "docs/index\n.md", "docs/index\r.md", "docs/index\t.md", "docs/index\x7f.md"} { if _, err := SafeRepoRelativePath(value, "path"); err == nil { t.Fatalf("expected unsafe path rejection for %q", value) } @@ -196,6 +255,22 @@ func TestSafeRepoRelativePathRejectsEscapesAndNormalization(t *testing.T) { } } +func TestSHA256RefAdmitsOneCanonicalRepresentation(t *testing.T) { + t.Parallel() + digest := strings.Repeat("a", 64) + if value, err := SHA256Ref("sha256:"+digest, "digest"); err != nil || value != "sha256:"+digest { + t.Fatalf("SHA256Ref() value=%q error=%v", value, err) + } + if value, err := SHA256HexRef(digest, "digest"); err != nil || value != "sha256:"+digest { + t.Fatalf("SHA256HexRef() value=%q error=%v", value, err) + } + for _, value := range []string{"sha256:" + strings.Repeat("a", 31) + " " + strings.Repeat("a", 32), "sha256:ABC", digest} { + if _, err := SHA256Ref(value, "digest"); err == nil { + t.Fatalf("SHA256Ref() accepted %q", value) + } + } +} + func TestJSONNumberEqualsRequiresDecodedJSONNumber(t *testing.T) { t.Parallel() diff --git a/internal/kernel/gotestsource/oracle.go b/internal/kernel/gotestsource/oracle.go index f35f9fb..e4cd046 100644 --- a/internal/kernel/gotestsource/oracle.go +++ b/internal/kernel/gotestsource/oracle.go @@ -3,10 +3,11 @@ package gotestsource import "go/ast" func HasSkip(function *ast.FuncDecl) bool { - paramName := testingParameterName(function) + paramName := TestingParameterName(function) if paramName == "" || function.Body == nil { return false } + tainted := testingHandleAliases(function.Body, map[string]struct{}{paramName: {}}) found := false ast.Inspect(function.Body, func(node ast.Node) bool { if found { @@ -21,7 +22,258 @@ func HasSkip(function *ast.FuncDecl) bool { return true } receiver, ok := selector.X.(*ast.Ident) - if !ok || receiver.Name != paramName { + if !ok { + return true + } + if _, trusted := tainted[receiver.Name]; !trusted { + return true + } + switch selector.Sel.Name { + case "Skip", "Skipf", "SkipNow": + found = true + return false + default: + return true + } + }) + return found +} + +func HasFailureCapableAssertionCandidate(function *ast.FuncDecl, scopes ...map[string]*ast.FuncDecl) bool { + functions := map[string]*ast.FuncDecl{} + if len(scopes) > 0 { + functions = scopes[0] + } + tainted := map[string]struct{}{} + if parameter := TestingParameterName(function); parameter != "" { + tainted[parameter] = struct{}{} + } + return hasFailureCapablePath(function, tainted, functions, map[*ast.FuncDecl]bool{}) +} + +func HasFailureCapableAssertionSyntax(function *ast.FuncDecl) bool { + if function == nil || function.Body == nil { + return false + } + parameter := TestingParameterName(function) + tainted := testingHandleAliases(function.Body, map[string]struct{}{parameter: {}}) + found := false + ast.Inspect(function.Body, func(node ast.Node) bool { + if found { + return false + } + call, ok := node.(*ast.CallExpr) + if !ok { + return true + } + if identifier, ok := call.Fun.(*ast.Ident); ok && identifier.Name == "panic" { + found = true + return false + } + selector, ok := call.Fun.(*ast.SelectorExpr) + if !ok { + return true + } + receiver, ok := selector.X.(*ast.Ident) + if !ok { + return true + } + if _, trusted := tainted[receiver.Name]; !trusted { + return true + } + switch selector.Sel.Name { + case "Error", "Errorf", "Fail", "FailNow", "Fatal", "Fatalf": + found = true + return false + default: + return true + } + }) + return found +} + +func hasFailureCapablePath(function *ast.FuncDecl, tainted map[string]struct{}, functions map[string]*ast.FuncDecl, visiting map[*ast.FuncDecl]bool) bool { + if function == nil || function.Body == nil || visiting[function] { + return false + } + tainted = testingHandleAliases(function.Body, tainted) + if hasTaintedSkip(function.Body, tainted) { + return false + } + visiting[function] = true + defer delete(visiting, function) + return hasFailureCapableNode(function.Body, tainted, functions, visiting) +} + +func hasFailureCapableNode(root ast.Node, tainted map[string]struct{}, functions map[string]*ast.FuncDecl, visiting map[*ast.FuncDecl]bool) bool { + found := false + ast.Inspect(root, func(node ast.Node) bool { + if found { + return false + } + switch typed := node.(type) { + case *ast.FuncLit: + return false + case *ast.IfStmt: + condition, constant := booleanConstant(typed.Cond) + if !constant { + return true + } + branch := ast.Node(typed.Body) + if !condition { + branch = typed.Else + } + if branch != nil && hasFailureCapableNode(branch, tainted, functions, visiting) { + found = true + } + return false + } + call, ok := node.(*ast.CallExpr) + if !ok { + return true + } + if identifier, ok := call.Fun.(*ast.Ident); ok { + if identifier.Name == "panic" { + found = true + return false + } + callee, exists := functions[identifier.Name] + if !exists { + return true + } + calleeTainted := propagatedParameters(callee, call.Args, tainted) + if hasFailureCapablePath(callee, calleeTainted, functions, visiting) { + found = true + return false + } + return true + } + selector, ok := call.Fun.(*ast.SelectorExpr) + if !ok { + return true + } + receiver, ok := selector.X.(*ast.Ident) + if !ok { + return true + } + if _, trusted := tainted[receiver.Name]; !trusted { + return true + } + if selector.Sel.Name == "Run" && len(call.Args) == 2 { + literal, ok := call.Args[1].(*ast.FuncLit) + if ok && literal.Type.Params != nil && len(literal.Type.Params.List) == 1 && len(literal.Type.Params.List[0].Names) == 1 { + innerTainted := map[string]struct{}{literal.Type.Params.List[0].Names[0].Name: {}} + innerTainted = testingHandleAliases(literal.Body, innerTainted) + if !hasTaintedSkip(literal.Body, innerTainted) && hasFailureCapableNode(literal.Body, innerTainted, functions, visiting) { + found = true + return false + } + } + return true + } + switch selector.Sel.Name { + case "Error", "Errorf", "Fail", "FailNow", "Fatal", "Fatalf": + found = true + return false + default: + return true + } + }) + return found +} + +func testingHandleAliases(root ast.Node, seed map[string]struct{}) map[string]struct{} { + aliases := make(map[string]struct{}, len(seed)) + for name := range seed { + if name != "" { + aliases[name] = struct{}{} + } + } + changed := true + for changed { + changed = false + ast.Inspect(root, func(node ast.Node) bool { + if _, nested := node.(*ast.FuncLit); nested { + return false + } + switch typed := node.(type) { + case *ast.AssignStmt: + if len(typed.Lhs) != len(typed.Rhs) { + return true + } + for index := range typed.Lhs { + if addTestingHandleAlias(aliases, typed.Lhs[index], typed.Rhs[index]) { + changed = true + } + } + case *ast.ValueSpec: + if len(typed.Names) != len(typed.Values) { + return true + } + for index := range typed.Names { + if addTestingHandleAlias(aliases, typed.Names[index], typed.Values[index]) { + changed = true + } + } + } + return true + }) + } + return aliases +} + +func addTestingHandleAlias(aliases map[string]struct{}, target, source ast.Expr) bool { + targetIdentifier, ok := target.(*ast.Ident) + if !ok || targetIdentifier.Name == "_" { + return false + } + sourceIdentifier, ok := unparenthesizedIdentifier(source) + if !ok { + return false + } + if _, trusted := aliases[sourceIdentifier.Name]; !trusted { + return false + } + if _, exists := aliases[targetIdentifier.Name]; exists { + return false + } + aliases[targetIdentifier.Name] = struct{}{} + return true +} + +func unparenthesizedIdentifier(expression ast.Expr) (*ast.Ident, bool) { + for { + parenthesized, ok := expression.(*ast.ParenExpr) + if !ok { + identifier, ok := expression.(*ast.Ident) + return identifier, ok + } + expression = parenthesized.X + } +} + +func hasTaintedSkip(root ast.Node, tainted map[string]struct{}) bool { + found := false + ast.Inspect(root, func(node ast.Node) bool { + if found { + return false + } + if _, nested := node.(*ast.FuncLit); nested { + return false + } + call, ok := node.(*ast.CallExpr) + if !ok { + return true + } + selector, ok := call.Fun.(*ast.SelectorExpr) + if !ok { + return true + } + receiver, ok := selector.X.(*ast.Ident) + if !ok { + return true + } + if _, trusted := tainted[receiver.Name]; !trusted { return true } switch selector.Sel.Name { @@ -35,7 +287,51 @@ func HasSkip(function *ast.FuncDecl) bool { return found } -func testingParameterName(function *ast.FuncDecl) string { +func booleanConstant(expression ast.Expr) (bool, bool) { + identifier, ok := expression.(*ast.Ident) + if !ok { + return false, false + } + switch identifier.Name { + case "true": + return true, true + case "false": + return false, true + default: + return false, false + } +} + +func propagatedParameters(function *ast.FuncDecl, arguments []ast.Expr, tainted map[string]struct{}) map[string]struct{} { + result := map[string]struct{}{} + if function.Type.Params == nil { + return result + } + argumentIndex := 0 + for _, field := range function.Type.Params.List { + if len(field.Names) == 0 { + if argumentIndex < len(arguments) { + argumentIndex++ + } + continue + } + for _, name := range field.Names { + if argumentIndex >= len(arguments) { + return result + } + identifier, ok := arguments[argumentIndex].(*ast.Ident) + if ok { + if _, trusted := tainted[identifier.Name]; trusted { + result[name.Name] = struct{}{} + } + } + argumentIndex++ + } + } + return result +} + +func TestingParameterName(function *ast.FuncDecl) string { if function.Type.Params == nil || len(function.Type.Params.List) != 1 { return "" } diff --git a/internal/kernel/gotestsource/oracle_test.go b/internal/kernel/gotestsource/oracle_test.go index a6f7b68..153792b 100644 --- a/internal/kernel/gotestsource/oracle_test.go +++ b/internal/kernel/gotestsource/oracle_test.go @@ -15,6 +15,9 @@ func TestHasSkipDistinguishesTestingParameterCalls(t *testing.T) { }{ {name: "skip", body: `t.Skip("blocked")`, want: true}, {name: "skipf", body: `t.Skipf("blocked: %s", "reason")`, want: true}, + {name: "direct alias", body: `u := t; u.Skip("blocked")`, want: true}, + {name: "declared alias", body: `var u = t; u.SkipNow()`, want: true}, + {name: "reassigned alias", body: `var u *testing.T; u = t; u.Skip("blocked")`, want: true}, {name: "helper skip", body: `helper.Skip()`, want: false}, {name: "ordinary assertion", body: `t.Fatal("failed")`, want: false}, } @@ -32,3 +35,128 @@ func TestHasSkipDistinguishesTestingParameterCalls(t *testing.T) { }) } } + +func TestHasFailureCapableAssertionSyntaxFollowsTestingHandleAlias(t *testing.T) { + source := `package fixture +import "testing" +func TestWitness(t *testing.T) { u := t; u.Fatal("failed") } +` + file, err := parser.ParseFile(token.NewFileSet(), "fixture_test.go", source, 0) + if err != nil { + t.Fatal(err) + } + if !HasFailureCapableAssertionSyntax(file.Decls[1].(*ast.FuncDecl)) { + t.Fatal("failure-capable assertion through a testing handle alias was not detected") + } +} + +func TestHasFailureCapableAssertionCandidateRejectsVacuousBody(t *testing.T) { + for _, test := range []struct { + body string + want bool + }{ + {body: `_ = t`, want: false}, + {body: `helper.Fatal("failed")`, want: false}, + {body: `if false { t.Fatal("unreachable") }`, want: false}, + {body: `_ = func() { t.Fatal("not invoked") }`, want: false}, + {body: `t.Fatalf("failed: %d", 1)`, want: true}, + } { + source := "package fixture\nimport \"testing\"\nfunc TestWitness(t *testing.T) { " + test.body + " }\n" + file, err := parser.ParseFile(token.NewFileSet(), "fixture_test.go", source, 0) + if err != nil { + t.Fatal(err) + } + function := file.Decls[1].(*ast.FuncDecl) + if got := HasFailureCapableAssertionCandidate(function); got != test.want { + t.Fatalf("HasFailureCapableAssertionCandidate(%q)=%v, want %v", test.body, got, test.want) + } + } +} + +func TestHasFailureCapableAssertionCandidateFollowsTestingHelperCalls(t *testing.T) { + source := `package fixture +import "testing" +func assertWitness(t *testing.T) { t.Fatal("failed") } +func TestWitness(t *testing.T) { assertWitness(t) } +` + file, err := parser.ParseFile(token.NewFileSet(), "fixture_test.go", source, 0) + if err != nil { + t.Fatal(err) + } + functions := map[string]*ast.FuncDecl{} + for _, declaration := range file.Decls { + function, ok := declaration.(*ast.FuncDecl) + if ok { + functions[function.Name.Name] = function + } + } + if !HasFailureCapableAssertionCandidate(functions["TestWitness"], functions) { + t.Fatal("reachable helper assertion was not detected") + } +} + +func TestHasFailureCapableAssertionCandidateAlignsArgumentsAfterUnnamedParameters(t *testing.T) { + source := `package fixture +import "testing" +func assertWitness(string, t *testing.T) { t.Fatal("failed") } +func TestWitness(t *testing.T) { assertWitness("context", t) } +` + file, err := parser.ParseFile(token.NewFileSet(), "fixture_test.go", source, 0) + if err != nil { + t.Fatal(err) + } + functions := map[string]*ast.FuncDecl{} + for _, declaration := range file.Decls { + function, ok := declaration.(*ast.FuncDecl) + if ok { + functions[function.Name.Name] = function + } + } + if !HasFailureCapableAssertionCandidate(functions["TestWitness"], functions) { + t.Fatal("unnamed helper parameter shifted testing.T argument propagation") + } +} + +func TestHasFailureCapableAssertionCandidateRejectsTransitiveSkip(t *testing.T) { + source := `package fixture +import "testing" +func skipWitness(t *testing.T) { t.Skip("blocked"); t.Fatal("unreachable") } +func TestWitness(t *testing.T) { skipWitness(t) } +` + file, err := parser.ParseFile(token.NewFileSet(), "fixture_test.go", source, 0) + if err != nil { + t.Fatal(err) + } + functions := map[string]*ast.FuncDecl{} + for _, declaration := range file.Decls { + function, ok := declaration.(*ast.FuncDecl) + if ok { + functions[function.Name.Name] = function + } + } + if HasFailureCapableAssertionCandidate(functions["TestWitness"], functions) { + t.Fatal("helper assertion after t.Skip was admitted as an executable candidate") + } +} + +func TestHasFailureCapableAssertionCandidateRejectsAliasedSkip(t *testing.T) { + source := `package fixture +import "testing" +func skipWitness(t *testing.T) { var u *testing.T; u = t; u.Skip("blocked"); t.Fatal("unreachable") } +func TestWitness(t *testing.T) { alias := t; skipWitness(alias) } +` + file, err := parser.ParseFile(token.NewFileSet(), "fixture_test.go", source, 0) + if err != nil { + t.Fatal(err) + } + functions := map[string]*ast.FuncDecl{} + for _, declaration := range file.Decls { + function, ok := declaration.(*ast.FuncDecl) + if ok { + functions[function.Name.Name] = function + } + } + if HasFailureCapableAssertionCandidate(functions["TestWitness"], functions) { + t.Fatal("assertion after an aliased helper skip was admitted as an executable candidate") + } +} diff --git a/internal/kernel/releasechannel/releasechannel.go b/internal/kernel/releasechannel/releasechannel.go index d83ef7d..1cc595d 100644 --- a/internal/kernel/releasechannel/releasechannel.go +++ b/internal/kernel/releasechannel/releasechannel.go @@ -15,6 +15,19 @@ const ( TarballPilot ID = "tarball_pilot" ) +func NPMRegistryEvidenceSource(publicationMode string) (string, bool) { + switch publicationMode { + case "published_by_workflow": + return "post-publish npm pack from registry", true + case "existing_byte_match": + return "registry npm pack byte-match for preexisting version", true + case "mixed": + return "post-publish and preexisting npm pack byte-match from registry", true + default: + return "", false + } +} + const ( GitHubPackagesRegistryURL = "https://npm.pkg.github.com" NPMRegistryURL = "https://registry.npmjs.org" diff --git a/internal/kernel/releasechannel/releasechannel_test.go b/internal/kernel/releasechannel/releasechannel_test.go index 99b6f8e..6bf34e1 100644 --- a/internal/kernel/releasechannel/releasechannel_test.go +++ b/internal/kernel/releasechannel/releasechannel_test.go @@ -53,3 +53,14 @@ func TestIDSetContainsOnlyCanonicalAuthorityIDs(t *testing.T) { } } } + +func TestNPMRegistryEvidenceSourceIsTotalForAdmittedPublicationModes(t *testing.T) { + for _, mode := range []string{"existing_byte_match", "mixed", "published_by_workflow"} { + if source, ok := NPMRegistryEvidenceSource(mode); !ok || source == "" { + t.Fatalf("NPMRegistryEvidenceSource(%q)=%q,%v", mode, source, ok) + } + } + if source, ok := NPMRegistryEvidenceSource("candidate"); ok || source != "" { + t.Fatalf("NPMRegistryEvidenceSource(candidate)=%q,%v, want rejection", source, ok) + } +} diff --git a/internal/kernel/witnesscommand/witnesscommand.go b/internal/kernel/witnesscommand/witnesscommand.go index 6b4a963..0973097 100644 --- a/internal/kernel/witnesscommand/witnesscommand.go +++ b/internal/kernel/witnesscommand/witnesscommand.go @@ -570,9 +570,16 @@ func witnessArgv(raw any) ([]string, error) { func normalizedExecutableName(value string) string { normalized := strings.ReplaceAll(value, `\`, "/") - executable := strings.ToLower(path.Base(normalized)) - for _, suffix := range []string{".exe", ".cmd", ".bat", ".com"} { - executable = strings.TrimSuffix(executable, suffix) + executable := strings.TrimRight(strings.ToLower(path.Base(normalized)), ". ") + for { + previous := executable + for _, suffix := range []string{".exe", ".cmd", ".bat", ".com"} { + executable = strings.TrimSuffix(executable, suffix) + } + executable = strings.TrimRight(executable, ". ") + if executable == previous { + break + } } return executable } diff --git a/internal/kernel/witnesscommand/witnesscommand_test.go b/internal/kernel/witnesscommand/witnesscommand_test.go index 8507965..0c4a7e6 100644 --- a/internal/kernel/witnesscommand/witnesscommand_test.go +++ b/internal/kernel/witnesscommand/witnesscommand_test.go @@ -36,6 +36,20 @@ func TestAdmitWithVocabularyRejectsRiskCorpus(t *testing.T) { }, want: "shell", }, + { + name: "windows trailing dot shell alias", + mutate: func(command map[string]any) { + command["argv"] = []any{"bash.", "-c", "go test ./..."} + }, + want: "shell", + }, + { + name: "windows repeated executable extension shell alias", + mutate: func(command map[string]any) { + command["argv"] = []any{"bash.exe.exe", "-c", "go test ./..."} + }, + want: "shell", + }, { name: "alternative posix shell executable", mutate: func(command map[string]any) { diff --git a/internal/tools/coveragemetrics/main.go b/internal/tools/coveragemetrics/main.go index 4ba81a9..950c0f7 100644 --- a/internal/tools/coveragemetrics/main.go +++ b/internal/tools/coveragemetrics/main.go @@ -219,8 +219,11 @@ func validateRequiredBindingWitnessSelectors(bindings bindingFile) error { "TestSelfCheckOutputUsesExactRootShape", "TestStandaloneMultiVariantCommandsUseExactRootShapes", }, - {"REQ-PROOFKIT-PACKAGE-003", "proofkit.package-boundary.outside-consumer-artifact"}: {"TestExactTarballOnboardingTrace"}, - {"REQ-PROOFKIT-PACKAGE-004", "proofkit.package-boundary.ci-receipt-anchor"}: {"TestReceiptIDKeepsLocalAndCIIdentitiesDistinct"}, + {"REQ-PROOFKIT-PACKAGE-003", "proofkit.package-boundary.outside-consumer-artifact"}: {"TestExactTarballOnboardingTrace"}, + {"REQ-PROOFKIT-PACKAGE-004", "proofkit.package-boundary.ci-receipt-anchor"}: { + "TestReceiptIDKeepsLocalAndCIIdentitiesDistinct", + "TestRunInvokesEveryRequiredSelfHostingAdmissionBoundary", + }, {"REQ-PROOFKIT-PACKAGE-004", "proofkit.package-boundary.self-hosting-report-verdict"}: {"TestRunProofkitVerdictCases"}, {"REQ-PROOFKIT-PACKAGE-005", "proofkit.package-boundary.merge-critical-runtime-preconditions"}: {"TestCISourceQualityInstallsPythonBeforeLifecycleTests"}, {"REQ-PROOFKIT-PACKAGE-006", "proofkit.package-boundary.python-wheel-candidate"}: {"TestPythonArtifactRefsRejectEachWheelIdentityDefect"}, @@ -238,6 +241,13 @@ func validateRequiredBindingWitnessSelectors(bindings bindingFile) error { "TestSelfCheckOutputUsesExactRootShape", "TestStandaloneMultiVariantCommandsUseExactRootShapes", }, + {"REQ-PROOFKIT-QUALITY-001", "proofkit.supply-chain-quality.release-attestation-wiring"}: { + "TestReleaseWorkflowRetainsReleaseAssetAndPostCreateEvidenceClosure", + }, + {"REQ-PROOFKIT-QUALITY-001", "proofkit.supply-chain-quality.retained-evidence-manifest"}: { + "TestManifestRejectsUnboundAttestationAndSymlink", + "TestManifestUsesDownloadableArtifactPaths", + }, {"REQ-PROOFKIT-QUALITY-004", "proofkit.supply-chain-quality.cli-contract-topology"}: { "TestCLIConditionModelClosesAdoptionOutputRoutes", "TestCommandDescriptorContractParityRejectsMutations", @@ -249,6 +259,7 @@ func validateRequiredBindingWitnessSelectors(bindings bindingFile) error { "TestSecurityScannerWorkflowsSeparateProviderPublicationPermissions", }, {"REQ-PROOFKIT-QUALITY-006", "proofkit.supply-chain-quality.osv-permission-separation"}: { + "TestOSVSourceScanFailsForEveryNonzeroScannerStatus", "TestSecurityScannerWorkflowsSeparateProviderPublicationPermissions", }, {"REQ-PROOFKIT-QUALITY-007", "proofkit.supply-chain-quality.scorecard-permission-and-publication-inputs"}: { @@ -261,6 +272,7 @@ func validateRequiredBindingWitnessSelectors(bindings bindingFile) error { "TestBindingWitnessSelectorsRejectInvalidGoTestSignature", "TestBindingWitnessSelectorsRejectMissingSemanticOwner", "TestBindingWitnessSelectorsRejectNonTestAndBuildExcludedFiles", + "TestBindingWitnessSelectorsRejectVacuousTestBody", "TestBindingWitnessSelectorsRequireExactCriticalInventories", }, {"REQ-PROOFKIT-QUALITY-011", "proofkit.supply-chain-quality.ci-required-aggregate-exactness"}: { @@ -301,6 +313,17 @@ func validateRequiredBindingWitnessSelectors(bindings bindingFile) error { "TestLiteralShellWordsConsumesLongBackslashRun", "TestOnboardingTraceCoversEveryDiscoveredPresetAndREADMEInput", }, + {"REQ-PROOFKIT-QUALITY-024", "proofkit.supply-chain-quality.release-manifest-json-abi-registry-evidence"}: { + "TestNPMRegistryAuthorityFlowsFromAdmittedFileToPublishedChannel", + "TestNPMRegistryPublicationRequiresTypedAuthorityEvidence", + }, + {"REQ-PROOFKIT-QUALITY-024", "proofkit.supply-chain-quality.npm-registry-authority-producer"}: { + "TestRunBuildsCanonicalTypedRegistryEvidence", + "TestRunRejectsRegistryPackageSetSubstitution", + }, + {"REQ-PROOFKIT-QUALITY-024", "proofkit.supply-chain-quality.npm-registry-workflow-delegation"}: { + "TestReleaseWorkflowDelegatesNPMRegistryEvidenceToRepositoryOwner", + }, {"REQ-PROOFKIT-QUALITY-022", "proofkit.supply-chain-quality.browser-failure-diagnostics-retention"}: { "TestCIBrowserRuntimeRetainsFailureDiagnosticsWithoutPublishingProof", }, @@ -315,6 +338,12 @@ func validateRequiredBindingWitnessSelectors(bindings bindingFile) error { "TestCurrentChangeRecordNamesReviewedSemanticChanges", "TestRenderStatesPreOneExactPinPolicy", }, + {"REQ-PROOFKIT-QUALITY-024", "proofkit.supply-chain-quality.retained-evidence-artifact-topology"}: { + "TestVerifyRejectsManifestAddressDrift", + }, + {"REQ-PROOFKIT-QUALITY-024", "proofkit.supply-chain-quality.release-closeout-change-record"}: { + "TestBuildInputFailsClosedForEachBlockingEvidenceClass", + }, {"REQ-PROOFKIT-QUALITY-024", "proofkit.supply-chain-quality.release-predecessor-lineage"}: { "TestRunNPMLineageUsesAdmittedRecordAndProviderIdentity", "TestValidateNPMReleaseLineage", @@ -330,6 +359,15 @@ func validateRequiredBindingWitnessSelectors(bindings bindingFile) error { {"REQ-PROOFKIT-SPEC-011", "proofkit.spec-proof-core.adoption-contract-envelope-cli-abi"}: { "TestAdoptionContractEnvelopeCLIABI", }, + {"REQ-PROOFKIT-SPEC-007", "proofkit.spec-proof-core.canonical-command-input-admission"}: { + "TestRequiredInputCommandsRejectMalformedCallerRecords", + }, + {"REQ-PROOFKIT-SPEC-007", "proofkit.spec-proof-core.canonical-input-admission"}: { + "TestDecodeTypedJSONUsesStrictAdmission", + }, + {"REQ-PROOFKIT-SPEC-013", "proofkit.spec-proof-core.receipt-trust-status-vocabulary-admission"}: { + "TestBuildRejectsHigherRankThatWeakensMinimumTrustSemantics", + }, {"REQ-PROOFKIT-SPEC-021", "proofkit.spec-proof-core.requirement-browser-one-shot-cleanup"}: { "TestServeOneShotDoesNotReadCompletedDoneTwice", "TestServeOneShotReturnsCleanupFailuresWithoutWritingTerminalPacket", @@ -349,6 +387,8 @@ func validateRequiredBindingWitnessSelectors(bindings bindingFile) error { {"REQ-PROOFKIT-PACKAGE-006", "proofkit.package-boundary.python-wheel-candidate"}: "scripts/validate-self-hosting-receipts_test.go", {"REQ-PROOFKIT-PACKAGE-006", "proofkit.package-boundary.python-wheel-generated-continuation"}: "internal/tools/pythonpackage/continuation_test.go", {"REQ-PROOFKIT-PACKAGE-007", "proofkit.package-boundary.package-public-docs-no-mutable-release-facts"}: "internal/tools/packageverify/main_test.go", + {"REQ-PROOFKIT-QUALITY-001", "proofkit.supply-chain-quality.release-attestation-wiring"}: "scripts/validate-self-hosting-receipts_test.go", + {"REQ-PROOFKIT-QUALITY-001", "proofkit.supply-chain-quality.retained-evidence-manifest"}: "internal/tools/retainedevidence/manifest_test.go", {"REQ-PROOFKIT-QUALITY-004", "proofkit.supply-chain-quality.cli-abi-golden"}: "internal/app/cli_abi_test.go", {"REQ-PROOFKIT-QUALITY-004", "proofkit.supply-chain-quality.cli-contract-topology"}: "internal/app/cli_contract_test.go", {"REQ-PROOFKIT-QUALITY-004", "proofkit.supply-chain-quality.cli-output-witness-contract"}: "internal/app/cli_output_witness_contract_test.go", @@ -361,13 +401,21 @@ func validateRequiredBindingWitnessSelectors(bindings bindingFile) error { {"REQ-PROOFKIT-QUALITY-013", "proofkit.supply-chain-quality.workflow-package-gate-oracle"}: "scripts/workflow_package_gate_oracle_test.go", {"REQ-PROOFKIT-QUALITY-016", "proofkit.supply-chain-quality.release-platform-python-wheels"}: "internal/tools/pythonpackage/metadata_test.go", {"REQ-PROOFKIT-QUALITY-019", "proofkit.supply-chain-quality.installed-package-json-abi-smoke"}: "internal/tools/packageverify/main_test.go", + {"REQ-PROOFKIT-QUALITY-024", "proofkit.supply-chain-quality.release-manifest-json-abi-registry-evidence"}: "internal/tools/releasemanifest/main_test.go", + {"REQ-PROOFKIT-QUALITY-024", "proofkit.supply-chain-quality.npm-registry-authority-producer"}: "internal/tools/npmregistry/main_test.go", + {"REQ-PROOFKIT-QUALITY-024", "proofkit.supply-chain-quality.npm-registry-workflow-delegation"}: "scripts/validate-self-hosting-receipts_test.go", {"REQ-PROOFKIT-QUALITY-022", "proofkit.supply-chain-quality.browser-failure-diagnostics-retention"}: "scripts/workflow_browser_runtime_oracle_test.go", {"REQ-PROOFKIT-QUALITY-023", "proofkit.supply-chain-quality.python-wheel-platform-byte-compatibility"}: "internal/tools/pythonpackage/metadata_test.go", {"REQ-PROOFKIT-QUALITY-024", "proofkit.supply-chain-quality.release-change-record-projection"}: "internal/tools/releasechange/record_test.go", + {"REQ-PROOFKIT-QUALITY-024", "proofkit.supply-chain-quality.retained-evidence-artifact-topology"}: "internal/tools/retainedevidence/manifest_test.go", + {"REQ-PROOFKIT-QUALITY-024", "proofkit.supply-chain-quality.release-closeout-change-record"}: "internal/tools/releasecloseoutinput/main_test.go", {"REQ-PROOFKIT-QUALITY-024", "proofkit.supply-chain-quality.release-predecessor-lineage"}: "internal/tools/releasepreflight/main_test.go", {"REQ-PROOFKIT-QUALITY-024", "proofkit.supply-chain-quality.release-predecessor-lineage-workflow"}: "scripts/validate-self-hosting-receipts_test.go", {"REQ-PROOFKIT-QUALITY-025", "proofkit.supply-chain-quality.workflow-source-oracles"}: "scripts/workflow_source_oracles_test.go", {"REQ-PROOFKIT-SPEC-011", "proofkit.spec-proof-core.adoption-contract-envelope-cli-abi"}: "internal/app/cli_abi_test.go", + {"REQ-PROOFKIT-SPEC-007", "proofkit.spec-proof-core.canonical-command-input-admission"}: "internal/app/command_coverage_test.go", + {"REQ-PROOFKIT-SPEC-007", "proofkit.spec-proof-core.canonical-input-admission"}: "internal/kernel/admission/json_test.go", + {"REQ-PROOFKIT-SPEC-013", "proofkit.spec-proof-core.receipt-trust-status-vocabulary-admission"}: "internal/command/receipttrustclass/receipt_trust_class_test.go", {"REQ-PROOFKIT-SPEC-021", "proofkit.spec-proof-core.requirement-browser-one-shot-cleanup"}: "internal/command/requirementbrowser/server_test.go", } if len(requiredPaths) != len(required) { @@ -410,6 +458,7 @@ func validateRequiredBindingWitnessSelectors(bindings bindingFile) error { func validateBindingWitnessSelectorExecutabilityAtRoot(root string, bindings bindingFile) error { activeWitnessPackages := map[string]map[string]struct{}{} + packageFunctionScopes := map[string]map[string]*ast.FuncDecl{} for _, binding := range bindings.Bindings { if len(binding.WitnessSelectors) == 0 { continue @@ -419,26 +468,23 @@ func validateBindingWitnessSelectorExecutabilityAtRoot(root string, bindings bin if err != nil { return fmt.Errorf("parse binding witness %s: %w", binding.WitnessPath, err) } - functions := map[string]*ast.FuncDecl{} + witnessFunctions := map[string]*ast.FuncDecl{} for _, declaration := range source.Decls { function, ok := declaration.(*ast.FuncDecl) if ok && function.Recv == nil { - functions[function.Name.Name] = function + witnessFunctions[function.Name.Name] = function } } testingAliases, dotImportedTesting := importedTestingNames(source) packagePath := "./" + filepath.ToSlash(filepath.Dir(binding.WitnessPath)) for _, selector := range binding.WitnessSelectors { - function, ok := functions[selector.Selector] + function, ok := witnessFunctions[selector.Selector] if !ok { return fmt.Errorf("binding %s selector %s is missing from %s", binding.ScenarioID, selector.Selector, binding.WitnessPath) } if !validGoTestFunction(function, testingAliases, dotImportedTesting) { return fmt.Errorf("binding %s selector %s is not a valid Go test function", binding.ScenarioID, selector.Selector) } - if gotestsource.HasSkip(function) { - return fmt.Errorf("binding %s selector %s contains t.Skip and cannot serve as an always-executable witness", binding.ScenarioID, selector.Selector) - } expectedCommand := fmt.Sprintf("go test %s -run '^%s$'", packagePath, selector.Selector) if selector.Command != expectedCommand { return fmt.Errorf("binding %s selector command=%q, want %q", binding.ScenarioID, selector.Command, expectedCommand) @@ -462,10 +508,53 @@ func validateBindingWitnessSelectorExecutabilityAtRoot(root string, bindings bin if _, active := activeFiles[filepath.Clean(witnessAbsolute)]; !active { return fmt.Errorf("binding %s witness %s is not active for the current Go build", binding.ScenarioID, binding.WitnessPath) } + scopeKey := packagePath + ":" + source.Name.Name + functionScope, scoped := packageFunctionScopes[scopeKey] + if !scoped { + functionScope, err = activePackageFunctions(activeFiles, source.Name.Name) + if err != nil { + return fmt.Errorf("parse active package functions for %s: %w", binding.WitnessPath, err) + } + packageFunctionScopes[scopeKey] = functionScope + } + for _, selector := range binding.WitnessSelectors { + function := witnessFunctions[selector.Selector] + if gotestsource.HasSkip(function) { + return fmt.Errorf("binding %s selector %s contains t.Skip and cannot serve as an always-executable witness", binding.ScenarioID, selector.Selector) + } + if !gotestsource.HasFailureCapableAssertionCandidate(function, functionScope) { + return fmt.Errorf("binding %s selector %s has no failure-capable assertion candidate", binding.ScenarioID, selector.Selector) + } + } } return nil } +func activePackageFunctions(activeFiles map[string]struct{}, packageName string) (map[string]*ast.FuncDecl, error) { + paths := make([]string, 0, len(activeFiles)) + for path := range activeFiles { + paths = append(paths, path) + } + sort.Strings(paths) + functions := map[string]*ast.FuncDecl{} + for _, path := range paths { + file, err := parser.ParseFile(token.NewFileSet(), path, nil, 0) + if err != nil { + return nil, err + } + if file.Name.Name != packageName { + continue + } + for _, declaration := range file.Decls { + function, ok := declaration.(*ast.FuncDecl) + if ok && function.Recv == nil { + functions[function.Name.Name] = function + } + } + } + return functions, nil +} + func activeGoTestFiles(root, packagePath string) (map[string]struct{}, error) { command := exec.Command("go", "list", "-json", packagePath) command.Dir = root diff --git a/internal/tools/coveragemetrics/main_test.go b/internal/tools/coveragemetrics/main_test.go index 5cb0122..ce0f5cb 100644 --- a/internal/tools/coveragemetrics/main_test.go +++ b/internal/tools/coveragemetrics/main_test.go @@ -542,7 +542,7 @@ func TestBindingWitnessSelectorsAcceptUnnamedGoTestParameter(t *testing.T) { if err := os.WriteFile(filepath.Join(root, "go.mod"), []byte("module example.com/sample\n\ngo 1.25.0\n"), 0o644); err != nil { t.Fatalf("write go.mod fixture: %v", err) } - source := "package sample\n\nimport \"testing\"\n\nfunc TestRunnable(*testing.T) {}\n" + source := "package sample\n\nimport \"testing\"\n\nfunc TestRunnable(*testing.T) { if testing.Short() { panic(\"short-mode falsifier\") } }\n" if err := os.WriteFile(filepath.Join(root, witnessPath), []byte(source), 0o644); err != nil { t.Fatalf("write witness fixture: %v", err) } @@ -612,6 +612,28 @@ func TestBindingWitnessSelectorsRejectSkippingTest(t *testing.T) { } } +func TestBindingWitnessSelectorsRejectVacuousTestBody(t *testing.T) { + root := t.TempDir() + witnessPath := filepath.Join("internal", "sample", "sample_test.go") + if err := os.MkdirAll(filepath.Join(root, filepath.Dir(witnessPath)), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, "go.mod"), []byte("module example.com/sample\n\ngo 1.25.0\n"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, witnessPath), []byte("package sample\n\nimport \"testing\"\n\nfunc TestVacuous(t *testing.T) { _ = t }\n"), 0o644); err != nil { + t.Fatal(err) + } + bindings := bindingFile{Bindings: []bindingScenario{bindingSelectorFixture( + "scenario.vacuous", witnessPath, "TestVacuous", + )}} + + err := validateBindingWitnessSelectorExecutabilityAtRoot(root, bindings) + if err == nil || !strings.Contains(err.Error(), "no failure-capable assertion candidate") { + t.Fatalf("vacuous witness error=%v", err) + } +} + func TestBindingWitnessSelectorsRejectNonTestAndBuildExcludedFiles(t *testing.T) { t.Run("non-test source", func(t *testing.T) { root := t.TempDir() diff --git a/internal/tools/npmregistry/main.go b/internal/tools/npmregistry/main.go new file mode 100644 index 0000000..56b61fe --- /dev/null +++ b/internal/tools/npmregistry/main.go @@ -0,0 +1,187 @@ +package main + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "sort" + "strings" + + "github.com/research-engineering/agentic-proofkit/internal/kernel/admission" + "github.com/research-engineering/agentic-proofkit/internal/kernel/admit" + "github.com/research-engineering/agentic-proofkit/internal/kernel/releasechannel" + "github.com/research-engineering/agentic-proofkit/internal/kernel/trustedpublisher" +) + +const ( + artifactKind = "proofkit.published-registry-artifact-set.v1" + maxPackRecordBytes = 8 << 20 + schemaVersion = 1 +) + +type packRecord struct { + Filename string `json:"filename"` + Integrity string `json:"integrity"` + Name string `json:"name"` + Shasum string `json:"shasum"` + Version string `json:"version"` +} + +type registryArtifactSet struct { + ArtifactKind string `json:"artifactKind"` + AuthorityChannel string `json:"authorityChannel"` + AuthorityValidator string `json:"authorityValidator"` + NonClaims []string `json:"nonClaims"` + Packages []packRecord `json:"packages"` + PublicationMode string `json:"publicationMode"` + Registry string `json:"registry"` + SchemaVersion int `json:"schemaVersion"` + Source string `json:"source"` +} + +func main() { + if err := run("."); err != nil { + _, _ = fmt.Fprintln(os.Stderr, err.Error()) + os.Exit(1) + } +} + +func run(root string) error { + local, err := readPackRecords(filepath.Join(root, "artifacts", "package", "npm-pack.json"), "local npm package evidence") + if err != nil { + return err + } + registry, err := readPackRecords(filepath.Join(root, "artifacts", "registry", "npm-pack.json"), "npm registry evidence") + if err != nil { + return err + } + if err := requireExactPackageSet(registry, local); err != nil { + return err + } + modeBytes, err := os.ReadFile(filepath.Join(root, "artifacts", "registry", "npm-publication-mode.txt")) + if err != nil { + return err + } + if len(modeBytes) > 64 { + return fmt.Errorf("npm publication mode exceeds byte limit") + } + mode, err := trustedpublisher.AdmitPublicationMode(strings.TrimSpace(string(modeBytes)), "npm registry evidence publicationMode") + if err != nil { + return err + } + source, ok := releasechannel.NPMRegistryEvidenceSource(mode) + if !ok { + return fmt.Errorf("npm registry evidence publication mode has no canonical source") + } + channel := releasechannel.Must(releasechannel.RegistryRelease) + record := registryArtifactSet{ + ArtifactKind: artifactKind, + AuthorityChannel: string(channel.ID), + AuthorityValidator: channel.AuthorityValidator, + NonClaims: []string{ + "npm registry identity does not prove consumer installation, consumer adoption, or rollout.", + }, + Packages: registry, + PublicationMode: mode, + Registry: channel.RegistryURL, + SchemaVersion: schemaVersion, + Source: source, + } + return writeJSON(filepath.Join(root, "artifacts", "registry", "published-registry-artifact-set.json"), record) +} + +func readPackRecords(path string, context string) ([]packRecord, error) { + file, err := os.Open(path) + if err != nil { + return nil, err + } + defer file.Close() + records, err := admission.DecodeTypedJSON[[]packRecord](file, maxPackRecordBytes) + if err != nil { + return nil, fmt.Errorf("admit %s: %w", path, err) + } + if len(records) == 0 { + return nil, fmt.Errorf("%s must be non-empty", context) + } + seenNames := map[string]struct{}{} + seenFiles := map[string]struct{}{} + for index := range records { + record := &records[index] + fields := []struct { + name string + value *string + }{ + {name: "filename", value: &record.Filename}, + {name: "integrity", value: &record.Integrity}, + {name: "name", value: &record.Name}, + {name: "shasum", value: &record.Shasum}, + {name: "version", value: &record.Version}, + } + for _, field := range fields { + value, err := admit.NonEmptyText(*field.value, fmt.Sprintf("%s[%d].%s", context, index, field.name)) + if err != nil { + return nil, err + } + *field.value = value + } + if _, duplicate := seenNames[record.Name]; duplicate { + return nil, fmt.Errorf("%s contains duplicate package name", context) + } + if _, duplicate := seenFiles[record.Filename]; duplicate { + return nil, fmt.Errorf("%s contains duplicate package filename", context) + } + seenNames[record.Name] = struct{}{} + seenFiles[record.Filename] = struct{}{} + } + sort.Slice(records, func(left int, right int) bool { + return records[left].Filename < records[right].Filename + }) + return records, nil +} + +func requireExactPackageSet(registry []packRecord, local []packRecord) error { + if len(registry) != len(local) { + return fmt.Errorf("npm registry package set must match local package evidence") + } + localByFilename := make(map[string]packRecord, len(local)) + for _, record := range local { + localByFilename[record.Filename] = record + } + for _, record := range registry { + expected, ok := localByFilename[record.Filename] + if !ok || record != expected { + return fmt.Errorf("npm registry package %s does not match local package identity and bytes", record.Filename) + } + } + return nil +} + +func writeJSON(path string, value any) error { + content, err := json.MarshalIndent(value, "", " ") + if err != nil { + return err + } + content = append(content, '\n') + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return err + } + temporary, err := os.CreateTemp(filepath.Dir(path), ".npm-registry-evidence-*") + if err != nil { + return err + } + temporaryPath := temporary.Name() + defer func() { _ = os.Remove(temporaryPath) }() + if _, err := temporary.Write(content); err != nil { + _ = temporary.Close() + return err + } + if err := temporary.Sync(); err != nil { + _ = temporary.Close() + return err + } + if err := temporary.Close(); err != nil { + return err + } + return os.Rename(temporaryPath, path) +} diff --git a/internal/tools/npmregistry/main_test.go b/internal/tools/npmregistry/main_test.go new file mode 100644 index 0000000..98f3589 --- /dev/null +++ b/internal/tools/npmregistry/main_test.go @@ -0,0 +1,63 @@ +package main + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestRunBuildsCanonicalTypedRegistryEvidence(t *testing.T) { + root := t.TempDir() + writeFixture(t, filepath.Join(root, "artifacts", "package", "npm-pack.json"), `[{ + "name":"@research-engineering/agentic-proofkit","version":"1.2.3","filename":"agentic-proofkit-1.2.3.tgz","integrity":"sha512-x","shasum":"abc" +}]`) + writeFixture(t, filepath.Join(root, "artifacts", "registry", "npm-pack.json"), `[{ + "name":"@research-engineering/agentic-proofkit","version":"1.2.3","filename":"agentic-proofkit-1.2.3.tgz","integrity":"sha512-x","shasum":"abc","providerField":"ignored" +}]`) + writeFixture(t, filepath.Join(root, "artifacts", "registry", "npm-publication-mode.txt"), "existing_byte_match\n") + if err := run(root); err != nil { + t.Fatalf("run() error=%v", err) + } + file, err := os.Open(filepath.Join(root, "artifacts", "registry", "published-registry-artifact-set.json")) + if err != nil { + t.Fatal(err) + } + defer file.Close() + var record registryArtifactSet + if err := json.NewDecoder(file).Decode(&record); err != nil { + t.Fatal(err) + } + if record.ArtifactKind != artifactKind || record.PublicationMode != "existing_byte_match" || len(record.Packages) != 1 { + t.Fatalf("registry evidence=%#v, want canonical typed record", record) + } +} + +func TestRunRejectsRegistryPackageSetSubstitution(t *testing.T) { + root := t.TempDir() + local := `[ + {"name":"a","version":"1.2.3","filename":"a.tgz","integrity":"sha512-a","shasum":"a"}, + {"name":"b","version":"1.2.3","filename":"b.tgz","integrity":"sha512-b","shasum":"b"} +]` + registry := `[ + {"name":"a","version":"1.2.3","filename":"a.tgz","integrity":"sha512-a","shasum":"a"}, + {"name":"a","version":"1.2.3","filename":"a.tgz","integrity":"sha512-a","shasum":"a"} +]` + writeFixture(t, filepath.Join(root, "artifacts", "package", "npm-pack.json"), local) + writeFixture(t, filepath.Join(root, "artifacts", "registry", "npm-pack.json"), registry) + writeFixture(t, filepath.Join(root, "artifacts", "registry", "npm-publication-mode.txt"), "existing_byte_match\n") + if err := run(root); err == nil || !strings.Contains(err.Error(), "duplicate package") { + t.Fatalf("run() error=%v, want duplicate substitution rejection", err) + } +} + +func writeFixture(t *testing.T, path string, content string) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { + t.Fatal(err) + } +} diff --git a/internal/tools/releasechange/record_test.go b/internal/tools/releasechange/record_test.go index cb81738..184d313 100644 --- a/internal/tools/releasechange/record_test.go +++ b/internal/tools/releasechange/record_test.go @@ -197,8 +197,13 @@ func TestCurrentChangeRecordNamesReviewedSemanticChanges(t *testing.T) { var currentBreakingChanges = []Change{} var currentAdditions = []Change{ + {ChangeID: "proofkit.admission.canonical-boundary-hardening", Summary: "Shared admission now preserves exact typed JSON numbers and field spelling, separates canonical path-array policy from prose-array policy, rejects complete secret-shaped path and Windows executable alias classes, and requires higher receipt-trust ranks to refine lower-rank obligations."}, + {ChangeID: "proofkit.cli.argument-constraint-closure", Summary: "The public CLI contract and generated help now expose descriptor-owned singleton, mutual-exclusion, presence, enum-domain, and value-dependent flag constraints, including requirement-browser session, scope, view, host, and cross-flag admission."}, + {ChangeID: "proofkit.coverage.witness-anti-vacuity", Summary: "Self-hosting coverage now rejects Go witness selectors that expose no statically reachable failure-capable candidate or contain statically resolved testing.T skip calls, including direct aliases and helper propagation; this remains candidate evidence rather than semantic proof."}, + {ChangeID: "proofkit.proof.delegation-authority", Summary: "Proof obligation algebra now fails closed on unresolved delegation references and cross-requirement child edges until an owner-admitted delegation authority exists."}, {ChangeID: "proofkit.release.migration-support-baseline", Summary: "Release-history migration support starts at 0.2.0, and any future cumulative plan must be derived from contiguous owner-reviewed per-release records without backfilling pre-baseline release history."}, {ChangeID: "proofkit.release.npm-predecessor-lineage", Summary: "Release candidate preflight binds a new candidate previousVersion to npm latest, while an exact already-published idempotent candidate requires npm latest to equal the candidate version."}, + {ChangeID: "proofkit.release.registry-authority-closure", Summary: "Release metadata now promotes npm publication only from repository-produced typed registry evidence whose unique package set exactly matches retained provider records and local candidate identity and bytes."}, } var currentMigrationSteps = []string{} diff --git a/internal/tools/releasecloseoutinput/main.go b/internal/tools/releasecloseoutinput/main.go index 6b6f0dd..3e37948 100644 --- a/internal/tools/releasecloseoutinput/main.go +++ b/internal/tools/releasecloseoutinput/main.go @@ -20,6 +20,7 @@ import ( "github.com/research-engineering/agentic-proofkit/internal/command/receiptproduceradmission" "github.com/research-engineering/agentic-proofkit/internal/command/specproofbundleadmission" "github.com/research-engineering/agentic-proofkit/internal/kernel/admission" + "github.com/research-engineering/agentic-proofkit/internal/kernel/admit" "github.com/research-engineering/agentic-proofkit/internal/kernel/digest" "github.com/research-engineering/agentic-proofkit/internal/kernel/releasechannel" "github.com/research-engineering/agentic-proofkit/internal/kernel/releasepublisher" @@ -733,11 +734,21 @@ func proofReceiptMatchesExecution(receipt proofReceiptEvidence, execution packag if err != nil || receipt.SourceRevision != execution.SourceRevision || receipt.StartedAt != execution.StartedAt || receipt.FinishedAt != execution.FinishedAt || - receipt.CommandDigest != commandDigest || - (execution.EnvironmentDigest != "" && receipt.EnvironmentDigest != executionDigestRef(execution.EnvironmentDigest)) || - (execution.ToolchainDigest != "" && receipt.ToolchainDigest != executionDigestRef(execution.ToolchainDigest)) { + receipt.CommandDigest != commandDigest { return false } + if execution.EnvironmentDigest != "" { + expected, err := admit.SHA256HexRef(execution.EnvironmentDigest, "package execution environmentDigest") + if err != nil || receipt.EnvironmentDigest != expected { + return false + } + } + if execution.ToolchainDigest != "" { + expected, err := admit.SHA256HexRef(execution.ToolchainDigest, "package execution toolchainDigest") + if err != nil || receipt.ToolchainDigest != expected { + return false + } + } return true } @@ -843,13 +854,6 @@ func packageArtifactCommandDigest(execution packageartifactrecord.Record) (strin }) } -func executionDigestRef(value string) string { - if strings.HasPrefix(value, "sha256:") { - return value - } - return "sha256:" + value -} - func coverageMetricsRecordMatches(record coverageMetricsEvidence) bool { return record.SchemaVersion == 1 && record.ArtifactKind == "proofkit.coverage-metrics.v1" && diff --git a/internal/tools/releasemanifest/main.go b/internal/tools/releasemanifest/main.go index 8f2203d..5a8213d 100644 --- a/internal/tools/releasemanifest/main.go +++ b/internal/tools/releasemanifest/main.go @@ -13,6 +13,7 @@ import ( "strings" "github.com/research-engineering/agentic-proofkit/internal/kernel/admission" + "github.com/research-engineering/agentic-proofkit/internal/kernel/admit" "github.com/research-engineering/agentic-proofkit/internal/kernel/releasechannel" "github.com/research-engineering/agentic-proofkit/internal/kernel/releasepublisher" "github.com/research-engineering/agentic-proofkit/internal/kernel/trustedpublisher" @@ -70,6 +71,18 @@ type pypiRegistrySet struct { Source string `json:"source"` } +type npmRegistrySet struct { + ArtifactKind string `json:"artifactKind"` + AuthorityChannel string `json:"authorityChannel"` + AuthorityValidator string `json:"authorityValidator"` + NonClaims []string `json:"nonClaims"` + Packages []packRecord `json:"packages"` + PublicationMode string `json:"publicationMode"` + Registry string `json:"registry"` + SchemaVersion int `json:"schemaVersion"` + Source string `json:"source"` +} + type pythonWheelRecord struct { AbiTag string `json:"abiTag"` BinarySha256 string `json:"binarySha256"` @@ -219,6 +232,10 @@ func run() error { if len(registryRecords) > 0 { sortPackRecords(registryRecords) } + npmRegistry, err := optionalNPMRegistrySet(filepath.Join("artifacts", "registry", "published-registry-artifact-set.json")) + if err != nil { + return err + } pypiRegistry, err := optionalPyPIRegistrySet(filepath.Join("artifacts", "pypi-registry", "pypi-release.json")) if err != nil { return err @@ -234,6 +251,9 @@ func run() error { if err := requirePublicationMode(npmPublicationMode, "npm", len(registryRecords) > 0); err != nil { return err } + if err := requireNPMRegistryMatchesLocal(npmRegistry, registryRecords, localRecords, npmPublicationMode); err != nil { + return err + } if err := requirePublicationMode(pypiPublicationMode, "pypi", pypiRegistry != nil); err != nil { return err } @@ -430,6 +450,9 @@ func publicationModeRequiresIdentity(mode string, label string) (bool, error) { } func requirePackRecordsMatchPackage(manifest packageJSON, records []packRecord, label string) error { + if _, err := packRecordsByFilename(records, label); err != nil { + return err + } for _, record := range records { if record.Name != manifest.Name || record.Version != manifest.Version { return fmt.Errorf("%s record %s must match package.json identity %s@%s", label, record.Filename, manifest.Name, manifest.Version) @@ -442,28 +465,115 @@ func requireRegistryRecordsMatchLocal(registryRecords []packRecord, localRecords if len(registryRecords) == 0 { return nil } - if len(registryRecords) != len(localRecords) { - return fmt.Errorf("npm registry evidence package count must match local package evidence") + registryByFilename, err := packRecordsByFilename(registryRecords, "npm registry evidence") + if err != nil { + return err } - localByFilename := map[string]packRecord{} - for _, record := range localRecords { - localByFilename[record.Filename] = record + localByFilename, err := packRecordsByFilename(localRecords, "local npm package evidence") + if err != nil { + return err } - for _, registryRecord := range registryRecords { - localRecord, ok := localByFilename[registryRecord.Filename] + if len(registryByFilename) != len(localByFilename) { + return fmt.Errorf("npm registry evidence package set must match local package evidence") + } + for filename, registryRecord := range registryByFilename { + localRecord, ok := localByFilename[filename] if !ok { - return fmt.Errorf("npm registry evidence contains package %s absent from local package evidence", registryRecord.Filename) + return fmt.Errorf("npm registry evidence contains package %s absent from local package evidence", filename) } if registryRecord.Name != localRecord.Name || registryRecord.Version != localRecord.Version || registryRecord.Integrity != localRecord.Integrity || registryRecord.Shasum != localRecord.Shasum { - return fmt.Errorf("npm registry evidence for %s does not match local package identity and bytes", registryRecord.Filename) + return fmt.Errorf("npm registry evidence for %s does not match local package identity and bytes", filename) + } + } + for filename := range localByFilename { + if _, ok := registryByFilename[filename]; !ok { + return fmt.Errorf("local npm package evidence contains package %s absent from registry evidence", filename) } } return nil } +func packRecordsByFilename(records []packRecord, label string) (map[string]packRecord, error) { + result := make(map[string]packRecord, len(records)) + for _, record := range records { + if record.Filename == "" { + return nil, fmt.Errorf("%s contains an empty package filename", label) + } + if _, duplicate := result[record.Filename]; duplicate { + return nil, fmt.Errorf("%s contains duplicate package filename %s", label, record.Filename) + } + result[record.Filename] = record + } + return result, nil +} + +func optionalNPMRegistrySet(path string) (*npmRegistrySet, error) { + if _, err := os.Stat(path); err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, err + } + out, err := readAdmittedJSON[npmRegistrySet](path) + if err != nil { + return nil, err + } + definition := releasechannel.Must(releasechannel.RegistryRelease) + if out.ArtifactKind != "proofkit.published-registry-artifact-set.v1" || out.SchemaVersion != 1 { + return nil, fmt.Errorf("%s has unexpected artifact kind or schema version", path) + } + if out.AuthorityChannel != string(definition.ID) || out.AuthorityValidator != definition.AuthorityValidator { + return nil, fmt.Errorf("%s must carry canonical %s authority metadata", path, definition.ID) + } + if out.Registry != definition.RegistryURL || len(out.Packages) == 0 { + return nil, fmt.Errorf("%s must carry canonical registry and package evidence", path) + } + mode, err := trustedpublisher.AdmitPublicationMode(out.PublicationMode, "npm registry evidence publicationMode") + if err != nil { + return nil, err + } + expectedSource, ok := releasechannel.NPMRegistryEvidenceSource(mode) + if !ok || out.Source != expectedSource { + return nil, fmt.Errorf("%s source does not match npm publication mode", path) + } + for _, record := range out.Packages { + if record.Name == "" || record.Version == "" || record.Filename == "" || record.Integrity == "" || record.Shasum == "" { + return nil, fmt.Errorf("%s contains incomplete npm registry package evidence", path) + } + } + if _, err := packRecordsByFilename(out.Packages, "npm registry authority evidence"); err != nil { + return nil, fmt.Errorf("%s: %w", path, err) + } + if _, err := admit.PreserveSortedText(out.NonClaims, "npm registry evidence nonClaims", false); err != nil { + return nil, err + } + out.PublicationMode = mode + sortPackRecords(out.Packages) + return &out, nil +} + +func requireNPMRegistryMatchesLocal(registry *npmRegistrySet, records []packRecord, local []packRecord, mode string) error { + if registry == nil { + if len(records) > 0 { + return fmt.Errorf("npm registry package records require typed registry authority evidence") + } + return nil + } + if len(records) == 0 { + return fmt.Errorf("typed npm registry authority evidence requires registry package records") + } + if registry.PublicationMode != mode { + return fmt.Errorf("npm registry authority evidence publication mode must match retained publication mode") + } + if err := requireRegistryRecordsMatchLocal(registry.Packages, records); err != nil { + return err + } + return requireRegistryRecordsMatchLocal(registry.Packages, local) +} + func optionalPythonPackageSet(path string) (*pythonPackageSet, error) { if _, err := os.Stat(path); err != nil { if os.IsNotExist(err) { diff --git a/internal/tools/releasemanifest/main_test.go b/internal/tools/releasemanifest/main_test.go index 89bc832..fb38c8c 100644 --- a/internal/tools/releasemanifest/main_test.go +++ b/internal/tools/releasemanifest/main_test.go @@ -61,6 +61,17 @@ func TestReleaseManifestReadersRejectAmbiguousJSON(t *testing.T) { }, want: "duplicate object key", }, + { + name: "npm registry set duplicate key", + write: func(path string) { + writeFile(t, path, `{"artifactKind":"proofkit.published-registry-artifact-set.v1","schemaVersion":1,"authorityChannel":"registry_release","authorityValidator":"releaseauthority","registry":"https://registry.npmjs.org","publicationMode":"existing_byte_match","source":"registry npm pack byte-match for preexisting version","nonClaims":["Registry evidence does not prove adoption."],"packages":[],"packages":[]}`) + }, + read: func(path string) error { + _, err := optionalNPMRegistrySet(path) + return err + }, + want: "duplicate object key", + }, } for _, item := range cases { t.Run(item.name, func(t *testing.T) { @@ -74,6 +85,66 @@ func TestReleaseManifestReadersRejectAmbiguousJSON(t *testing.T) { } } +func TestNPMRegistryPublicationRequiresTypedAuthorityEvidence(t *testing.T) { + record := packRecord{Name: "@research-engineering/agentic-proofkit", Version: "1.2.3", Filename: "research-engineering-agentic-proofkit-1.2.3.tgz", Integrity: "sha512-x", Shasum: "abc"} + if err := requireNPMRegistryMatchesLocal(nil, []packRecord{record}, []packRecord{record}, "existing_byte_match"); err == nil || !strings.Contains(err.Error(), "typed registry authority evidence") { + t.Fatalf("requireNPMRegistryMatchesLocal() error=%v, want typed authority rejection", err) + } + + registry := &npmRegistrySet{ + ArtifactKind: "proofkit.published-registry-artifact-set.v1", + AuthorityChannel: string(releasechannel.RegistryRelease), + AuthorityValidator: releasechannel.Must(releasechannel.RegistryRelease).AuthorityValidator, + NonClaims: []string{"Registry evidence does not prove adoption."}, + Packages: []packRecord{record}, + PublicationMode: "existing_byte_match", + Registry: releasechannel.NPMRegistryURL, + SchemaVersion: 1, + Source: mustNPMRegistryEvidenceSource(t, "existing_byte_match"), + } + if err := requireNPMRegistryMatchesLocal(registry, []packRecord{record}, []packRecord{record}, "existing_byte_match"); err != nil { + t.Fatalf("requireNPMRegistryMatchesLocal() error=%v", err) + } +} + +func TestNPMRegistryEvidenceRejectsDuplicateSubstitution(t *testing.T) { + recordA := packRecord{Name: "agentic-proofkit-a", Version: "1.2.3", Filename: "a.tgz", Integrity: "sha512-a", Shasum: "a"} + recordB := packRecord{Name: "agentic-proofkit-b", Version: "1.2.3", Filename: "b.tgz", Integrity: "sha512-b", Shasum: "b"} + for _, test := range []struct { + name string + registry []packRecord + local []packRecord + }{ + {name: "duplicate registry substitutes missing package", registry: []packRecord{recordA, recordA}, local: []packRecord{recordA, recordB}}, + {name: "duplicate local package", registry: []packRecord{recordA, recordB}, local: []packRecord{recordA, recordA}}, + } { + t.Run(test.name, func(t *testing.T) { + if err := requireRegistryRecordsMatchLocal(test.registry, test.local); err == nil || !strings.Contains(err.Error(), "duplicate package filename") { + t.Fatalf("requireRegistryRecordsMatchLocal() error=%v, want duplicate filename rejection", err) + } + }) + } +} + +func TestNPMRegistryAuthorityFlowsFromAdmittedFileToPublishedChannel(t *testing.T) { + record := packRecord{Name: "@research-engineering/agentic-proofkit", Version: "1.2.3", Filename: "research-engineering-agentic-proofkit-1.2.3.tgz", Integrity: "sha512-x", Shasum: "abc"} + path := filepath.Join(t.TempDir(), "published-registry-artifact-set.json") + writeFile(t, path, `{"artifactKind":"proofkit.published-registry-artifact-set.v1","schemaVersion":1,"authorityChannel":"registry_release","authorityValidator":"releaseauthority","registry":"https://registry.npmjs.org","publicationMode":"existing_byte_match","source":"registry npm pack byte-match for preexisting version","nonClaims":["Registry evidence does not prove adoption."],"packages":[{"name":"@research-engineering/agentic-proofkit","version":"1.2.3","filename":"research-engineering-agentic-proofkit-1.2.3.tgz","integrity":"sha512-x","shasum":"abc"}]}`) + registry, err := optionalNPMRegistrySet(path) + if err != nil { + t.Fatalf("optionalNPMRegistrySet() error=%v", err) + } + if err := requireNPMRegistryMatchesLocal(registry, registry.Packages, []packRecord{record}, "existing_byte_match"); err != nil { + t.Fatalf("requireNPMRegistryMatchesLocal() error=%v", err) + } + channels := releaseChannels([]packRecord{record}, registry.Packages, registry.PublicationMode, nil, nil, "", nil, trustedPublisherSet{}) + byAuthority := channelsByAuthority(t, channels) + npm := byAuthority[string(releasechannel.RegistryRelease)] + if npm.Status != "published" || npm.PublicationMode != "existing_byte_match" || len(npm.Packages) != 1 || npm.Packages[0].Filename != record.Filename { + t.Fatalf("npm channel=%#v, want admitted published package projection", npm) + } +} + func TestReleaseChannelsCarryAuthorityAndPublisherEnvironment(t *testing.T) { channels := releaseChannels( []packRecord{{Name: "agentic-proofkit", Version: "1.2.3", Filename: "agentic-proofkit-1.2.3.tgz", Integrity: "sha512-x", Shasum: "abc"}}, @@ -534,3 +605,12 @@ func writeFile(t *testing.T, path string, content string) { t.Fatalf("write %s: %v", path, err) } } + +func mustNPMRegistryEvidenceSource(t *testing.T, mode string) string { + t.Helper() + value, ok := releasechannel.NPMRegistryEvidenceSource(mode) + if !ok { + t.Fatalf("unknown npm registry publication mode %q", mode) + } + return value +} diff --git a/internal/tools/releasepreflight/main.go b/internal/tools/releasepreflight/main.go index 303db90..df071dd 100644 --- a/internal/tools/releasepreflight/main.go +++ b/internal/tools/releasepreflight/main.go @@ -117,7 +117,7 @@ func main() { func run(args []string) error { if len(args) == 0 { - return fmt.Errorf("usage: releasepreflight ") + return fmt.Errorf("usage: releasepreflight ") } switch args[0] { case "npm-existing": @@ -236,6 +236,12 @@ func run(args []string) error { return err } return retainedevidence.Write(options["artifact-root"]) + case "retained-evidence-verify": + options, err := parseFlags(args[1:], "artifact-root") + if err != nil { + return err + } + return retainedevidence.Verify(options["artifact-root"]) default: return fmt.Errorf("unknown releasepreflight command %s", args[0]) } diff --git a/internal/tools/releasepreflight/main_test.go b/internal/tools/releasepreflight/main_test.go index d2c7716..53b3bd4 100644 --- a/internal/tools/releasepreflight/main_test.go +++ b/internal/tools/releasepreflight/main_test.go @@ -28,6 +28,13 @@ func TestRetainedEvidenceCommandWritesArtifactRootManifest(t *testing.T) { t.Fatalf("retained evidence manifest missing %s:\n%s", path, content) } } + if err := run([]string{"retained-evidence-verify", "--artifact-root", root}); err != nil { + t.Fatalf("verify retained evidence: %v", err) + } + writeFile(t, filepath.Join(root, "retained-evidence-checksums.sha256"), strings.Repeat("a", 64)+" release/github-release.json\n") + if err := run([]string{"retained-evidence-verify", "--artifact-root", root}); err == nil { + t.Fatal("retained-evidence-verify accepted a stale manifest") + } } func TestCompareNPMExisting(t *testing.T) { diff --git a/package.json b/package.json index be34a66..e40b490 100644 --- a/package.json +++ b/package.json @@ -46,6 +46,7 @@ "go:test": "go test ./...", "go:vet": "go vet ./...", "npm:version": "node -e \"const {execFileSync}=require('node:child_process'); const expected=require('./package.json').packageManager.split('@').at(-1); const actual=execFileSync('npm',['--version'],{encoding:'utf8'}).trim(); if(actual!==expected){throw new Error('expected npm '+expected+', got '+actual)}\"", + "npm:registry-evidence": "go run ./internal/tools/npmregistry", "package:artifact": "go run ./internal/tools/packageartifact", "package:artifact:steps": "npm run build && go run ./internal/tools/packagepack && go run ./internal/tools/packageverify && npm run python:package && npm run python:verify && npm run release:manifest", "platform:smoke": "go run ./internal/tools/packagebuild current && ./dist/agentic-proofkit --help >/dev/null && go run ./internal/tools/pythonpackage build-current && go run ./internal/tools/pythonpackage verify-current", diff --git a/proofkit/cli-contract.v2.json b/proofkit/cli-contract.v2.json index 1255a38..1e87311 100644 --- a/proofkit/cli-contract.v2.json +++ b/proofkit/cli-contract.v2.json @@ -206,7 +206,7 @@ "rootDefinitionDigest": "sha256:104d1e8bef615582ead1c7c8c64b7e1643a76c2bc2a1f224189c372cf8cc4aa1", "nativeSource": { "path": "internal/command/adoptiondoctor", - "canonicalDigest": "sha256:95f909f36650142a58ca241c0210582c405f749187a16c03127242ba3d1b1507", + "canonicalDigest": "sha256:cec92b209d2fa36cf5da43a7049d3c24212f6762a085b065e75b46f1c5fda625", "evidenceClass": "source_checkout" }, "nativeAdmissionWitnessSelector": { @@ -237,7 +237,7 @@ "rootDefinitionDigest": "sha256:90583a47b9bdd605b2d3f24dcb1a5dbb2b4d9da20d7be097da381c9c77229e62", "nativeSource": { "path": "internal/command/adoptiondoctor", - "canonicalDigest": "sha256:95f909f36650142a58ca241c0210582c405f749187a16c03127242ba3d1b1507", + "canonicalDigest": "sha256:cec92b209d2fa36cf5da43a7049d3c24212f6762a085b065e75b46f1c5fda625", "evidenceClass": "source_checkout" }, "nativeOutputWitnessSelector": { @@ -988,6 +988,9 @@ "value": "markdown" } ], + "singleOccurrenceFlags": [ + "--format" + ], "inputContract": { "contractId": "proofkit.conformance-profile.input.v1", "schemaVersion": 1, @@ -1773,6 +1776,9 @@ "requiredFlags": [ "--language" ], + "singleOccurrenceFlags": [ + "--format" + ], "outputContract": { "contractId": "proofkit.json-report-cli-adapter-source.output.v1", "schemaVersion": 1, @@ -1850,7 +1856,7 @@ "rootDefinitionDigest": "sha256:a1f8ab777249d7ff613bfb3256cd96514235ac90d02ad39ce8720330cbd71d38", "nativeSource": { "path": "internal/command/migrationparityadmission", - "canonicalDigest": "sha256:2e19dca45e5cc77a74e65a30e0d2cbcd27279a5b17d43bd677686ade04b15585", + "canonicalDigest": "sha256:e6d7db500dea081cbf5c50a0b56051c3f5f193d5dbcc314871cd2bae0c0b3e6a", "evidenceClass": "source_checkout" }, "nativeAdmissionWitnessSelector": { @@ -1882,7 +1888,7 @@ "rootDefinitionDigest": "sha256:aaea5c7d9d12e1fbc406d080dcda5aca84351c5a2048d40535a1b3895cc37440", "nativeSource": { "path": "internal/command/migrationparityadmission", - "canonicalDigest": "sha256:2e19dca45e5cc77a74e65a30e0d2cbcd27279a5b17d43bd677686ade04b15585", + "canonicalDigest": "sha256:e6d7db500dea081cbf5c50a0b56051c3f5f193d5dbcc314871cd2bae0c0b3e6a", "evidenceClass": "source_checkout" }, "nativeOutputWitnessSelector": { @@ -2014,7 +2020,7 @@ "rootDefinitionDigest": "sha256:5c9e25b7a7e379f21cd586693f1303a823699c348129a35c20c79fe0a225597f", "nativeSource": { "path": "internal/command/obligationdecision", - "canonicalDigest": "sha256:444d161fe3c88a3106b8a7f7db0125753870f85bc2afd32f00dbb11429f71a28", + "canonicalDigest": "sha256:507d019a96d80343890f30f94cf5d908fb0140d2513370021195f28de0420573", "evidenceClass": "source_checkout" }, "nativeAdmissionWitnessSelector": { @@ -2042,7 +2048,7 @@ "rootDefinitionDigest": "sha256:0a9bf16564faaf4362e84b490ec24e722ed5b98617d8f4d7f62d8ce30e21c2e5", "nativeSource": { "path": "internal/command/obligationdecision", - "canonicalDigest": "sha256:444d161fe3c88a3106b8a7f7db0125753870f85bc2afd32f00dbb11429f71a28", + "canonicalDigest": "sha256:507d019a96d80343890f30f94cf5d908fb0140d2513370021195f28de0420573", "evidenceClass": "source_checkout" }, "nativeOutputWitnessSelector": { @@ -2179,6 +2185,15 @@ "--pilot", "--stack-diverse" ], + "flagValueRequirements": [ + { + "flag": "--pilot", + "requiredFlags": [ + "--contract-envelope" + ], + "value": "all" + } + ], "inputContract": { "contractId": "proofkit.pilot-admission.input.v1", "schemaVersion": 1, @@ -2216,7 +2231,7 @@ "nativeSources": [ { "path": "internal/app", - "canonicalDigest": "sha256:b05dbafc64cccb87ef05a31f048096629ecdfe3cb3f9ad0e8d4131a3966e68ab", + "canonicalDigest": "sha256:0bea47d33304e528ffbe087688ea2dee07cf42e55632eda3b479732e221841d9", "evidenceClass": "source_checkout" }, { @@ -2333,7 +2348,7 @@ "rootDefinitionDigest": "sha256:cf9e13e7e1186d4dcdcfcecfe33c4dc61f88791242b42a4a3a92b349c605b476", "nativeSource": { "path": "internal/command/proofobligationalgebra", - "canonicalDigest": "sha256:f8a7f7e89de6549acb0dc6853d8507916c6589617a4b2fbc22149eb3d7fce547", + "canonicalDigest": "sha256:825fe712a106ce1c6b60d9a723d06c48838684fd1cfeaa3415849e20abcd4368", "evidenceClass": "source_checkout" }, "nativeAdmissionWitnessSelector": { @@ -2360,7 +2375,7 @@ "rootDefinitionDigest": "sha256:4133b3c92c688c56a04871ededc6a595b17690d02e70ee35b87f728c21109a37", "nativeSource": { "path": "internal/command/proofobligationalgebra", - "canonicalDigest": "sha256:f8a7f7e89de6549acb0dc6853d8507916c6589617a4b2fbc22149eb3d7fce547", + "canonicalDigest": "sha256:825fe712a106ce1c6b60d9a723d06c48838684fd1cfeaa3415849e20abcd4368", "evidenceClass": "source_checkout" }, "nativeOutputWitnessSelector": { @@ -2611,7 +2626,7 @@ "rootDefinitionDigest": "sha256:912f60b637aa0ca917b1c90d4caa4e3e65ec60ad0c5529d0da9d815fc766c5f5", "nativeSource": { "path": "internal/command/receiptcurrentnessscope", - "canonicalDigest": "sha256:82b133cede2489ecf3b813b83f0453f525c623b53ac383607f3ff31e84a7c450", + "canonicalDigest": "sha256:64fe8dfe45c5af89533bcc3bd7448977cf51b446df18eff11bf4dd3a8815c1d0", "evidenceClass": "source_checkout" }, "nativeAdmissionWitnessSelector": { @@ -2638,7 +2653,7 @@ "rootDefinitionDigest": "sha256:30a1b7ef616c1e44bbdbebc6e511231166a2d1ea600d8d5954484fe8c0e7cdf4", "nativeSource": { "path": "internal/command/receiptcurrentnessscope", - "canonicalDigest": "sha256:82b133cede2489ecf3b813b83f0453f525c623b53ac383607f3ff31e84a7c450", + "canonicalDigest": "sha256:64fe8dfe45c5af89533bcc3bd7448977cf51b446df18eff11bf4dd3a8815c1d0", "evidenceClass": "source_checkout" }, "nativeOutputWitnessSelector": { @@ -2749,7 +2764,7 @@ "rootDefinitionDigest": "sha256:19b0af54f38516daf4c0fb386a16284821f07ee14028738b5287a09f8b3b2122", "nativeSource": { "path": "internal/command/receipttrustclass", - "canonicalDigest": "sha256:7bc30b98ef1e7f0fdef09f8a00d0f10158e0a8dca6a676736151eeb939a364da", + "canonicalDigest": "sha256:22a0a2e7e6f2a657a2da1a4b8ec58be090a7bf26d924b98b78ca91b45208126f", "evidenceClass": "source_checkout" }, "nativeAdmissionWitnessSelector": { @@ -2777,7 +2792,7 @@ "rootDefinitionDigest": "sha256:3914567b0719287a883352e711121f71610ab041df74a009edd2363fb6907872", "nativeSource": { "path": "internal/command/receipttrustclass", - "canonicalDigest": "sha256:7bc30b98ef1e7f0fdef09f8a00d0f10158e0a8dca6a676736151eeb939a364da", + "canonicalDigest": "sha256:22a0a2e7e6f2a657a2da1a4b8ec58be090a7bf26d924b98b78ca91b45208126f", "evidenceClass": "source_checkout" }, "nativeOutputWitnessSelector": { @@ -3027,7 +3042,7 @@ "rootDefinitionDigest": "sha256:525ed9c2b820df9f52dce95442bf48bccfba1368f82e9bf68b92c275cf79b51c", "nativeSource": { "path": "internal/command/renderedartifactfreshness", - "canonicalDigest": "sha256:56ec807644da3f730e2ca55cb7b4dcded4bfab5e707657600ecbcd211ef17a4e", + "canonicalDigest": "sha256:7782f4cecf9f35b5a5deda9b9fdf76fa485cdddd018d6f08aecd4f13a8cd4342", "evidenceClass": "source_checkout" }, "nativeAdmissionWitnessSelector": { @@ -3054,7 +3069,7 @@ "rootDefinitionDigest": "sha256:99c5af92d1f98744436493c4330c78ed409da2f72be0bb7bb82670cf7fb8fe2f", "nativeSource": { "path": "internal/command/renderedartifactfreshness", - "canonicalDigest": "sha256:56ec807644da3f730e2ca55cb7b4dcded4bfab5e707657600ecbcd211ef17a4e", + "canonicalDigest": "sha256:7782f4cecf9f35b5a5deda9b9fdf76fa485cdddd018d6f08aecd4f13a8cd4342", "evidenceClass": "source_checkout" }, "nativeOutputWitnessSelector": { @@ -3326,13 +3341,110 @@ "requiredFlags": [ "--view" ], + "singleOccurrenceFlags": [ + "--session-mode", + "--session-timeout-seconds" + ], + "flagChoices": { + "--host": [ + "127.0.0.1", + "::1" + ], + "--scope": [ + "graph", + "slice" + ], + "--session-mode": [ + "browse", + "one-shot-question" + ], + "--view": [ + "coverage", + "proof", + "source", + "spec-tree", + "workspace" + ] + }, + "atMostOneOfFlagGroups": [ + [ + "--empty-local-environment-policy", + "--local-environment-class" + ] + ], + "flagPresenceRequirements": [ + { + "flag": "--empty-local-environment-policy", + "requiredFlagValues": [ + { + "flag": "--view", + "value": "proof" + } + ], + "requiredFlags": [] + }, + { + "flag": "--local-environment-class", + "requiredFlagValues": [ + { + "flag": "--view", + "value": "proof" + } + ], + "requiredFlags": [] + }, + { + "flag": "--open", + "requiredFlags": [ + "--serve" + ] + }, + { + "flag": "--scope", + "requiredFlagValues": [ + { + "flag": "--view", + "value": "proof" + } + ], + "requiredFlags": [] + }, + { + "flag": "--session-timeout-seconds", + "requiredFlagValues": [ + { + "flag": "--session-mode", + "value": "one-shot-question" + } + ], + "requiredFlags": [] + } + ], "flagValueRequirements": [ { "flag": "--session-mode", + "requiredFlagValues": [ + { + "flag": "--view", + "value": "workspace" + } + ], + "requiredFlags": [ + "--serve" + ], + "value": "browse" + }, + { + "flag": "--session-mode", + "requiredFlagValues": [ + { + "flag": "--view", + "value": "workspace" + } + ], "requiredFlags": [ "--open", - "--serve", - "--view" + "--serve" ], "value": "one-shot-question" } @@ -3370,7 +3482,7 @@ "rootDefinitionDigest": "sha256:50a7bb1b477b6067d410cf22db7e7564a9742aa7f6a270642149d372f0156007", "nativeSource": { "path": "internal/command/requirementbrowser", - "canonicalDigest": "sha256:7d85d0f51193cd3d42fbf19eb4dad4baadf5e5a050be9ce1363bf3e92dbdfcf2", + "canonicalDigest": "sha256:df4d6ca7f6f08342e48e44dad7cfbbcb25869c039814efd875e6435b4aeefa33", "evidenceClass": "source_checkout" }, "nativeAdmissionWitnessSelector": { @@ -3407,7 +3519,7 @@ "rootDefinitionDigest": "sha256:c2e7d851c7928560d4267fe85ebc0ae61e33c4c0ef15f7f7b01deb73bb80eda7", "nativeSource": { "path": "internal/command/requirementbrowser", - "canonicalDigest": "sha256:7d85d0f51193cd3d42fbf19eb4dad4baadf5e5a050be9ce1363bf3e92dbdfcf2", + "canonicalDigest": "sha256:df4d6ca7f6f08342e48e44dad7cfbbcb25869c039814efd875e6435b4aeefa33", "evidenceClass": "source_checkout" }, "nativeOutputWitnessSelector": { @@ -3454,7 +3566,7 @@ "rootDefinitionDigest": "sha256:41bc233c96bd468bc96eeb0447e037cacdbcf92bb04161052303e8747e4282f2", "nativeSource": { "path": "internal/command/requirementcontext", - "canonicalDigest": "sha256:f81c94b2ff570bac1e3fcf1d32ee25dc36e6b3baacf2c63744f6824ee77cade2", + "canonicalDigest": "sha256:c814fcecb510be1b2fbe1d9911825d29874491e42a7e6e69b55e0f678b332492", "evidenceClass": "source_checkout" }, "nativeAdmissionWitnessSelector": { @@ -3494,7 +3606,7 @@ "rootDefinitionDigest": "sha256:cdadd7b589d682a122cfe2b801f001a3b22a2269aa4977ebe073932d11e1f816", "nativeSource": { "path": "internal/command/requirementcontext", - "canonicalDigest": "sha256:f81c94b2ff570bac1e3fcf1d32ee25dc36e6b3baacf2c63744f6824ee77cade2", + "canonicalDigest": "sha256:c814fcecb510be1b2fbe1d9911825d29874491e42a7e6e69b55e0f678b332492", "evidenceClass": "source_checkout" }, "nativeOutputWitnessSelector": { @@ -3541,7 +3653,7 @@ "rootDefinitionDigest": "sha256:43852cf1a52b3c1f000ec95e460fbad157a6b6a39958d20f81c9e1e4d31e3c6f", "nativeSource": { "path": "internal/command/requirementcontext", - "canonicalDigest": "sha256:f81c94b2ff570bac1e3fcf1d32ee25dc36e6b3baacf2c63744f6824ee77cade2", + "canonicalDigest": "sha256:c814fcecb510be1b2fbe1d9911825d29874491e42a7e6e69b55e0f678b332492", "evidenceClass": "source_checkout" }, "nativeAdmissionWitnessSelector": { @@ -3579,7 +3691,7 @@ "rootDefinitionDigest": "sha256:61f7fd43e2e9da5bde9b61bb86e02cbf337a43c6891ca888c1d42936e5b16456", "nativeSource": { "path": "internal/command/requirementcontext", - "canonicalDigest": "sha256:f81c94b2ff570bac1e3fcf1d32ee25dc36e6b3baacf2c63744f6824ee77cade2", + "canonicalDigest": "sha256:c814fcecb510be1b2fbe1d9911825d29874491e42a7e6e69b55e0f678b332492", "evidenceClass": "source_checkout" }, "nativeOutputWitnessSelector": { @@ -3762,6 +3874,9 @@ "--input", "--input-pointer" ], + "singleOccurrenceFlags": [ + "--format" + ], "outputContract": { "contractId": "proofkit.requirement-coverage-view.output.v1", "schemaVersion": 1, @@ -3805,7 +3920,7 @@ "rootDefinitionDigest": "sha256:697d24d12d4772b67283f24da2f265372de241bd4741ac23f7b984ca96023818", "nativeSource": { "path": "internal/command/requirementcoverageview", - "canonicalDigest": "sha256:446947f5ccedc573a0839c4dfbf0ea35bbf529035838035e923f71f10a1f5695", + "canonicalDigest": "sha256:c2a999a43db2e7b9ed65a599865bd291c7a0a4daf64973aefb40e684dee34c5a", "evidenceClass": "source_checkout" }, "nativeOutputWitnessSelector": { @@ -3838,7 +3953,7 @@ "rootDefinitionDigest": "sha256:b5ddab0a55a381f002b39ece0f5727171f7984856e553200bbb62a49821ff6ac", "nativeSource": { "path": "internal/command/requirementcoverageview", - "canonicalDigest": "sha256:446947f5ccedc573a0839c4dfbf0ea35bbf529035838035e923f71f10a1f5695", + "canonicalDigest": "sha256:c2a999a43db2e7b9ed65a599865bd291c7a0a4daf64973aefb40e684dee34c5a", "evidenceClass": "source_checkout" }, "nativeAdmissionWitnessSelector": { @@ -3904,7 +4019,7 @@ "rootDefinitionDigest": "sha256:9fd57d299d4e76bb3b0bf10f313a8dd9bfd593b8d15fa4844d5e342c4d929ee9", "nativeSource": { "path": "internal/command/requirementimpactinput", - "canonicalDigest": "sha256:014ecba30947ebee4f45c22ed0a06bfb2d47392e0d3420d1e4480a1c0111dd81", + "canonicalDigest": "sha256:f45499ce46858ba03325cc52875b10181d9a190244ca21471b651a122a890636", "evidenceClass": "source_checkout" }, "nativeOutputWitnessSelector": { @@ -3946,7 +4061,7 @@ "rootDefinitionDigest": "sha256:e6af8f427f781395710407a87bfd5fa91a0b3c8aaaee437c2487472a243b22a6", "nativeSource": { "path": "internal/command/requirementimpactinput", - "canonicalDigest": "sha256:014ecba30947ebee4f45c22ed0a06bfb2d47392e0d3420d1e4480a1c0111dd81", + "canonicalDigest": "sha256:f45499ce46858ba03325cc52875b10181d9a190244ca21471b651a122a890636", "evidenceClass": "source_checkout" }, "nativeAdmissionWitnessSelector": { @@ -4183,6 +4298,9 @@ "--local-environment-class", "--scope" ], + "singleOccurrenceFlags": [ + "--format" + ], "inputContract": { "contractId": "proofkit.requirement-proof-view.input.v1", "schemaVersion": 1, @@ -4192,7 +4310,7 @@ "rootDefinitionDigest": "sha256:5aed3bcb99337d4937243d61d1f8ed1b1b358108a7710152221bfd5961a3aeb7", "nativeSource": { "path": "internal/command/requirementproofview", - "canonicalDigest": "sha256:76e16289eb4e8e4d6ae3d9ac9ee52b22335338bdf0467c5968894f92bfa09895", + "canonicalDigest": "sha256:ab2902ef8c5bc19d8d084b8fa355dbab09cae61eecf6bbe6946d882b73f9b687", "evidenceClass": "source_checkout" }, "nativeAdmissionWitnessSelector": { @@ -4219,7 +4337,7 @@ "rootDefinitionDigest": "sha256:d1da2a4593aa2f4a1531e1a5be8e3bc877a5ff45f978b7f39a0bf708b01cb564", "nativeSource": { "path": "internal/command/requirementproofview", - "canonicalDigest": "sha256:76e16289eb4e8e4d6ae3d9ac9ee52b22335338bdf0467c5968894f92bfa09895", + "canonicalDigest": "sha256:ab2902ef8c5bc19d8d084b8fa355dbab09cae61eecf6bbe6946d882b73f9b687", "evidenceClass": "source_checkout" }, "nativeOutputWitnessSelector": { @@ -4260,7 +4378,7 @@ "rootDefinitionDigest": "sha256:1f30133908a40c16f8ed236725e36b1999fc125f1450efa1c28264c42e6c8a63", "nativeSource": { "path": "internal/command/requirementdiff", - "canonicalDigest": "sha256:046132ae2b29d60ca2a21b55a1d57a56930ae15db701595a1ccbc4b503aa7b94", + "canonicalDigest": "sha256:49fb852dd5b80784635165f0c02a8c8d2f275b00a261f1220c60eb3a648b03fa", "evidenceClass": "source_checkout" }, "nativeAdmissionWitnessSelector": { @@ -4298,7 +4416,7 @@ "rootDefinitionDigest": "sha256:abe2eda1ca5496bd33cd659a419a46d7b69a2b8cc50203c7ba6acd31625b760f", "nativeSource": { "path": "internal/command/requirementdiff", - "canonicalDigest": "sha256:046132ae2b29d60ca2a21b55a1d57a56930ae15db701595a1ccbc4b503aa7b94", + "canonicalDigest": "sha256:49fb852dd5b80784635165f0c02a8c8d2f275b00a261f1220c60eb3a648b03fa", "evidenceClass": "source_checkout" }, "nativeOutputWitnessSelector": { @@ -4478,6 +4596,9 @@ "--input", "--input-pointer" ], + "singleOccurrenceFlags": [ + "--format" + ], "inputContract": { "contractId": "proofkit.requirement-source-view.input.v1", "schemaVersion": 1, @@ -4683,6 +4804,9 @@ "--input-pointer", "--output" ], + "singleOccurrenceFlags": [ + "--format" + ], "outputContract": { "contractId": "proofkit.requirement-spec-tree-view.output.v2", "schemaVersion": 2, @@ -4755,7 +4879,7 @@ "rootDefinitionDigest": "sha256:6487ff537380d1cbffe5a72b9688e9ce6baffa218e76f8dbbfe8f50d14509219", "nativeSource": { "path": "internal/command/requirementgraph", - "canonicalDigest": "sha256:7aba61b6b6a66f53d9ffde32867bf4f52dd32156debc75a9d6d5da8beba39cb4", + "canonicalDigest": "sha256:330cc4ceef881c5ad8cfac76c0c9876ed65e897d5ffe631e01c8625d35b09fa3", "evidenceClass": "source_checkout" }, "nativeAdmissionWitnessSelector": { @@ -4793,7 +4917,7 @@ "rootDefinitionDigest": "sha256:37d07abc4d9122176ffab9842b5d85fbfdbe068ee9e4097da8fc21206aae1d47", "nativeSource": { "path": "internal/command/requirementgraph", - "canonicalDigest": "sha256:7aba61b6b6a66f53d9ffde32867bf4f52dd32156debc75a9d6d5da8beba39cb4", + "canonicalDigest": "sha256:330cc4ceef881c5ad8cfac76c0c9876ed65e897d5ffe631e01c8625d35b09fa3", "evidenceClass": "source_checkout" }, "nativeOutputWitnessSelector": { @@ -5017,7 +5141,7 @@ "rootDefinitionDigest": "sha256:6df7c84b104a318b2f6b04b662c3dfb54b16f4fb339cfb5f0ba6ee56485fae8f", "nativeSource": { "path": "internal/command/secretscan", - "canonicalDigest": "sha256:7fe8eb1fe43973b321fce0edce337b565d4a51e86e244bce9c3a57039209b0e3", + "canonicalDigest": "sha256:e5eb7dfbbb78c1d964cdc374e2ea43e201cd54b5cae18016ad7a00182cbb01cb", "evidenceClass": "source_checkout" }, "nativeAdmissionWitnessSelector": { @@ -5059,7 +5183,7 @@ "rootDefinitionDigest": "sha256:e4113bfd0c2affe54c8f231ca280a405ba24adfb5e219ad548bd5c175c3d5f54", "nativeSource": { "path": "internal/command/secretscan", - "canonicalDigest": "sha256:7fe8eb1fe43973b321fce0edce337b565d4a51e86e244bce9c3a57039209b0e3", + "canonicalDigest": "sha256:e5eb7dfbbb78c1d964cdc374e2ea43e201cd54b5cae18016ad7a00182cbb01cb", "evidenceClass": "source_checkout" }, "nativeOutputWitnessSelector": { @@ -5102,7 +5226,7 @@ "rootDefinitionDigest": "sha256:0615e61b546c079fa662e7a83e5472bdc137eceafd42700dcfa9e78c8c5397b0", "nativeSource": { "path": "internal/command/selectivegateevidence", - "canonicalDigest": "sha256:ddee1b6e09f88546d237fe32b4f4c98b62406efe0556273f382d1e6be577c4ef", + "canonicalDigest": "sha256:eb23cea0bb22b585f9441504278db2291ea49a12cc3118ed0a1d5f0f5eb59978", "evidenceClass": "source_checkout" }, "nativeAdmissionWitnessSelector": { @@ -5130,7 +5254,7 @@ "rootDefinitionDigest": "sha256:8b8537b08340434bcb6c91fc446d8bf7aaa782e8e88763a31aa19720bf49a113", "nativeSource": { "path": "internal/command/selectivegateevidence", - "canonicalDigest": "sha256:ddee1b6e09f88546d237fe32b4f4c98b62406efe0556273f382d1e6be577c4ef", + "canonicalDigest": "sha256:eb23cea0bb22b585f9441504278db2291ea49a12cc3118ed0a1d5f0f5eb59978", "evidenceClass": "source_checkout" }, "nativeOutputWitnessSelector": { @@ -5172,7 +5296,7 @@ "rootDefinitionDigest": "sha256:394c4523f385351794db259b7a7d365d0852ee22ec38695b1336f6559f823a15", "nativeSource": { "path": "internal/command/selectivegateevidence", - "canonicalDigest": "sha256:ddee1b6e09f88546d237fe32b4f4c98b62406efe0556273f382d1e6be577c4ef", + "canonicalDigest": "sha256:eb23cea0bb22b585f9441504278db2291ea49a12cc3118ed0a1d5f0f5eb59978", "evidenceClass": "source_checkout" }, "nativeAdmissionWitnessSelector": { @@ -5200,7 +5324,7 @@ "rootDefinitionDigest": "sha256:f4c5b8430047a0a9c19d6c180bdf8b20b1980519dbdbf18e05870ebe5ea51942", "nativeSource": { "path": "internal/command/selectivegateevidence", - "canonicalDigest": "sha256:ddee1b6e09f88546d237fe32b4f4c98b62406efe0556273f382d1e6be577c4ef", + "canonicalDigest": "sha256:eb23cea0bb22b585f9441504278db2291ea49a12cc3118ed0a1d5f0f5eb59978", "evidenceClass": "source_checkout" }, "nativeOutputWitnessSelector": { @@ -5341,7 +5465,7 @@ "rootDefinitionDigest": "sha256:3c842174dff5361e7f83166469b832805e05aa314b073c16234b5b64e346281e", "nativeSource": { "path": "internal/app", - "canonicalDigest": "sha256:b05dbafc64cccb87ef05a31f048096629ecdfe3cb3f9ad0e8d4131a3966e68ab", + "canonicalDigest": "sha256:0bea47d33304e528ffbe087688ea2dee07cf42e55632eda3b479732e221841d9", "evidenceClass": "source_checkout" }, "nativeAdmissionWitnessSelector": { @@ -5370,7 +5494,7 @@ "rootDefinitionDigest": "sha256:0ea95e277ebe44cd2de42c29b47c38686ac0b6b390d8965367437b3fe138e209", "nativeSource": { "path": "internal/app", - "canonicalDigest": "sha256:b05dbafc64cccb87ef05a31f048096629ecdfe3cb3f9ad0e8d4131a3966e68ab", + "canonicalDigest": "sha256:0bea47d33304e528ffbe087688ea2dee07cf42e55632eda3b479732e221841d9", "evidenceClass": "source_checkout" }, "nativeOutputWitnessSelector": { @@ -5544,6 +5668,16 @@ "requiredFlags": [ "--preset" ], + "flagChoices": { + "--preset": [ + "agentic_runtime_repo", + "generated_docs_contract_repo", + "python_service", + "python_typescript_service", + "typescript_monorepo", + "typescript_workspace" + ] + }, "outputContract": { "contractId": "proofkit.stack-preset.output.v1", "schemaVersion": 1, @@ -5851,7 +5985,7 @@ "rootDefinitionDigest": "sha256:af1e6196ddee9ee4262ca14c9cca1451c89241ebeef177af4a12be866b088f9a", "nativeSource": { "path": "internal/command/textpolicy", - "canonicalDigest": "sha256:d15d33f5481f994fecc70b5ab159ea16d5bed8d0ccbe3cf840a55ed18f168e79", + "canonicalDigest": "sha256:7b46a4a0372f3ecd5a9ae7c42a0ba0200d436834cfcdfa8a2d4860be0d8f78d6", "evidenceClass": "source_checkout" }, "nativeAdmissionWitnessSelector": { @@ -5878,7 +6012,7 @@ "rootDefinitionDigest": "sha256:a47795a1a72dcece9e3e513c8d33829fed187a959450d1ce007697a434b26df1", "nativeSource": { "path": "internal/command/textpolicy", - "canonicalDigest": "sha256:d15d33f5481f994fecc70b5ab159ea16d5bed8d0ccbe3cf840a55ed18f168e79", + "canonicalDigest": "sha256:7b46a4a0372f3ecd5a9ae7c42a0ba0200d436834cfcdfa8a2d4860be0d8f78d6", "evidenceClass": "source_checkout" }, "nativeOutputWitnessSelector": { diff --git a/proofkit/requirement-bindings.json b/proofkit/requirement-bindings.json index cf084fb..f42266b 100644 --- a/proofkit/requirement-bindings.json +++ b/proofkit/requirement-bindings.json @@ -1035,6 +1035,10 @@ { "selector": "TestReceiptIDKeepsLocalAndCIIdentitiesDistinct", "command": "go test ./scripts -run '^TestReceiptIDKeepsLocalAndCIIdentitiesDistinct$'" + }, + { + "selector": "TestRunInvokesEveryRequiredSelfHostingAdmissionBoundary", + "command": "go test ./scripts -run '^TestRunInvokesEveryRequiredSelfHostingAdmissionBoundary$'" } ], "commandIds": [ @@ -1469,12 +1473,37 @@ "local-go" ] }, + { + "requirementId": "REQ-PROOFKIT-SPEC-007", + "scenarioId": "proofkit.spec-proof-core.canonical-command-input-admission", + "witnessId": "proofkit.app.required-input-command-admission", + "witnessKind": "contract", + "witnessPath": "internal/app/command_coverage_test.go", + "witnessSelectors": [ + { + "selector": "TestRequiredInputCommandsRejectMalformedCallerRecords", + "command": "go test ./internal/app -run '^TestRequiredInputCommandsRejectMalformedCallerRecords$'" + } + ], + "commandIds": [ + "proofkit.go-test" + ], + "environmentClasses": [ + "local-go" + ] + }, { "requirementId": "REQ-PROOFKIT-SPEC-007", "scenarioId": "proofkit.spec-proof-core.canonical-input-admission", "witnessId": "proofkit.admission.decode-typed-json-canonical-record", "witnessKind": "contract", "witnessPath": "internal/kernel/admission/json_test.go", + "witnessSelectors": [ + { + "selector": "TestDecodeTypedJSONUsesStrictAdmission", + "command": "go test ./internal/kernel/admission -run '^TestDecodeTypedJSONUsesStrictAdmission$'" + } + ], "commandIds": [ "proofkit.go-test" ], @@ -1701,6 +1730,12 @@ "witnessId": "proofkit.receipt-trust-status-vocabulary.command-admission", "witnessKind": "contract", "witnessPath": "internal/command/receipttrustclass/receipt_trust_class_test.go", + "witnessSelectors": [ + { + "selector": "TestBuildRejectsHigherRankThatWeakensMinimumTrustSemantics", + "command": "go test ./internal/command/receipttrustclass -run '^TestBuildRejectsHigherRankThatWeakensMinimumTrustSemantics$'" + } + ], "commandIds": [ "proofkit.go-test" ], @@ -2243,6 +2278,10 @@ "selector": "TestBindingWitnessSelectorsRejectNonTestAndBuildExcludedFiles", "command": "go test ./internal/tools/coveragemetrics -run '^TestBindingWitnessSelectorsRejectNonTestAndBuildExcludedFiles$'" }, + { + "selector": "TestBindingWitnessSelectorsRejectVacuousTestBody", + "command": "go test ./internal/tools/coveragemetrics -run '^TestBindingWitnessSelectorsRejectVacuousTestBody$'" + }, { "selector": "TestBindingWitnessSelectorsRequireExactCriticalInventories", "command": "go test ./internal/tools/coveragemetrics -run '^TestBindingWitnessSelectorsRequireExactCriticalInventories$'" @@ -2790,11 +2829,21 @@ ] }, { - "requirementId": "REQ-PROOFKIT-QUALITY-019", + "requirementId": "REQ-PROOFKIT-QUALITY-024", "scenarioId": "proofkit.supply-chain-quality.release-manifest-json-abi-registry-evidence", "witnessId": "proofkit.release-manifest.json-abi-registry-evidence", "witnessKind": "contract", "witnessPath": "internal/tools/releasemanifest/main_test.go", + "witnessSelectors": [ + { + "selector": "TestNPMRegistryPublicationRequiresTypedAuthorityEvidence", + "command": "go test ./internal/tools/releasemanifest -run '^TestNPMRegistryPublicationRequiresTypedAuthorityEvidence$'" + }, + { + "selector": "TestNPMRegistryAuthorityFlowsFromAdmittedFileToPublishedChannel", + "command": "go test ./internal/tools/releasemanifest -run '^TestNPMRegistryAuthorityFlowsFromAdmittedFileToPublishedChannel$'" + } + ], "commandIds": [ "proofkit.go-test", "proofkit.package-artifact" @@ -2804,6 +2853,51 @@ "local-go-python" ] }, + { + "requirementId": "REQ-PROOFKIT-QUALITY-024", + "scenarioId": "proofkit.supply-chain-quality.npm-registry-authority-producer", + "witnessId": "proofkit.npm-registry.authority-producer", + "witnessKind": "contract", + "witnessPath": "internal/tools/npmregistry/main_test.go", + "witnessSelectors": [ + { + "selector": "TestRunBuildsCanonicalTypedRegistryEvidence", + "command": "go test ./internal/tools/npmregistry -run '^TestRunBuildsCanonicalTypedRegistryEvidence$'" + }, + { + "selector": "TestRunRejectsRegistryPackageSetSubstitution", + "command": "go test ./internal/tools/npmregistry -run '^TestRunRejectsRegistryPackageSetSubstitution$'" + } + ], + "commandIds": [ + "proofkit.go-test", + "proofkit.package-artifact" + ], + "environmentClasses": [ + "local-go", + "local-go-python" + ] + }, + { + "requirementId": "REQ-PROOFKIT-QUALITY-024", + "scenarioId": "proofkit.supply-chain-quality.npm-registry-workflow-delegation", + "witnessId": "proofkit.release-workflow.npm-registry-owner-delegation", + "witnessKind": "contract", + "witnessPath": "scripts/validate-self-hosting-receipts_test.go", + "witnessSelectors": [ + { + "selector": "TestReleaseWorkflowDelegatesNPMRegistryEvidenceToRepositoryOwner", + "command": "go test ./scripts -run '^TestReleaseWorkflowDelegatesNPMRegistryEvidenceToRepositoryOwner$'" + } + ], + "commandIds": [ + "proofkit.actionlint", + "proofkit.go-test" + ], + "environmentClasses": [ + "local-go" + ] + }, { "requirementId": "REQ-PROOFKIT-QUALITY-003", "scenarioId": "proofkit.supply-chain-quality.fuzz-property-boundaries", @@ -2837,6 +2931,10 @@ "witnessKind": "contract", "witnessPath": "scripts/workflow_security_scanner_oracles_test.go", "witnessSelectors": [ + { + "selector": "TestOSVSourceScanFailsForEveryNonzeroScannerStatus", + "command": "go test ./scripts -run '^TestOSVSourceScanFailsForEveryNonzeroScannerStatus$'" + }, { "selector": "TestSecurityScannerWorkflowsSeparateProviderPublicationPermissions", "command": "go test ./scripts -run '^TestSecurityScannerWorkflowsSeparateProviderPublicationPermissions$'" @@ -2853,16 +2951,48 @@ { "requirementId": "REQ-PROOFKIT-QUALITY-001", "scenarioId": "proofkit.supply-chain-quality.release-attestation-wiring", - "witnessId": "proofkit.release-workflow.attestation-wiring", - "witnessKind": "technical", - "witnessPath": ".github/workflows/release.yml", + "witnessId": "proofkit.release-workflow.attestation-and-retention-wiring", + "witnessKind": "contract", + "witnessPath": "scripts/validate-self-hosting-receipts_test.go", + "witnessSelectors": [ + { + "selector": "TestReleaseWorkflowRetainsReleaseAssetAndPostCreateEvidenceClosure", + "command": "go test ./scripts -run '^TestReleaseWorkflowRetainsReleaseAssetAndPostCreateEvidenceClosure$'" + } + ], "commandIds": [ - "proofkit.actionlint" + "proofkit.actionlint", + "proofkit.go-test" ], "environmentClasses": [ "local-go" ] }, + { + "requirementId": "REQ-PROOFKIT-QUALITY-001", + "scenarioId": "proofkit.supply-chain-quality.retained-evidence-manifest", + "witnessId": "proofkit.retained-evidence.downloadable-topology", + "witnessKind": "contract", + "witnessPath": "internal/tools/retainedevidence/manifest_test.go", + "witnessSelectors": [ + { + "selector": "TestManifestRejectsUnboundAttestationAndSymlink", + "command": "go test ./internal/tools/retainedevidence -run '^TestManifestRejectsUnboundAttestationAndSymlink$'" + }, + { + "selector": "TestManifestUsesDownloadableArtifactPaths", + "command": "go test ./internal/tools/retainedevidence -run '^TestManifestUsesDownloadableArtifactPaths$'" + } + ], + "commandIds": [ + "proofkit.go-test", + "proofkit.package-artifact" + ], + "environmentClasses": [ + "local-go", + "local-go-python" + ] + }, { "requirementId": "REQ-PROOFKIT-QUALITY-002", "scenarioId": "proofkit.supply-chain-quality.release-sbom", @@ -3144,6 +3274,12 @@ "witnessId": "proofkit.retained-evidence.download-layout-falsifier", "witnessKind": "contract", "witnessPath": "internal/tools/retainedevidence/manifest_test.go", + "witnessSelectors": [ + { + "selector": "TestVerifyRejectsManifestAddressDrift", + "command": "go test ./internal/tools/retainedevidence -run '^TestVerifyRejectsManifestAddressDrift$'" + } + ], "commandIds": ["proofkit.go-test"], "environmentClasses": ["local-go"] }, @@ -3153,6 +3289,12 @@ "witnessId": "proofkit.release-closeout.change-record-falsifier", "witnessKind": "contract", "witnessPath": "internal/tools/releasecloseoutinput/main_test.go", + "witnessSelectors": [ + { + "selector": "TestBuildInputFailsClosedForEachBlockingEvidenceClass", + "command": "go test ./internal/tools/releasecloseoutinput -run '^TestBuildInputFailsClosedForEachBlockingEvidenceClass$'" + } + ], "commandIds": ["proofkit.go-test"], "environmentClasses": ["local-go"] }, diff --git a/proofkit/witness-plan.json b/proofkit/witness-plan.json index 9759b20..bd70514 100644 --- a/proofkit/witness-plan.json +++ b/proofkit/witness-plan.json @@ -935,7 +935,7 @@ ], "exclusiveLocks": [], "sideEffectClass": "local_write", - "deterministicOutput": true, + "deterministicOutput": false, "cacheAdmissionRefs": [], "retryPolicy": { "kind": "none", diff --git a/release/change-record.v2.json b/release/change-record.v2.json index 25d1c7d..681f39a 100644 --- a/release/change-record.v2.json +++ b/release/change-record.v2.json @@ -5,6 +5,22 @@ "changeClass": "compatible", "breakingChanges": [], "additions": [ + { + "changeId": "proofkit.admission.canonical-boundary-hardening", + "summary": "Shared admission now preserves exact typed JSON numbers and field spelling, separates canonical path-array policy from prose-array policy, rejects complete secret-shaped path and Windows executable alias classes, and requires higher receipt-trust ranks to refine lower-rank obligations." + }, + { + "changeId": "proofkit.cli.argument-constraint-closure", + "summary": "The public CLI contract and generated help now expose descriptor-owned singleton, mutual-exclusion, presence, enum-domain, and value-dependent flag constraints, including requirement-browser session, scope, view, host, and cross-flag admission." + }, + { + "changeId": "proofkit.coverage.witness-anti-vacuity", + "summary": "Self-hosting coverage now rejects Go witness selectors that expose no statically reachable failure-capable candidate or contain statically resolved testing.T skip calls, including direct aliases and helper propagation; this remains candidate evidence rather than semantic proof." + }, + { + "changeId": "proofkit.proof.delegation-authority", + "summary": "Proof obligation algebra now fails closed on unresolved delegation references and cross-requirement child edges until an owner-admitted delegation authority exists." + }, { "changeId": "proofkit.release.migration-support-baseline", "summary": "Release-history migration support starts at 0.2.0, and any future cumulative plan must be derived from contiguous owner-reviewed per-release records without backfilling pre-baseline release history." @@ -12,6 +28,10 @@ { "changeId": "proofkit.release.npm-predecessor-lineage", "summary": "Release candidate preflight binds a new candidate previousVersion to npm latest, while an exact already-published idempotent candidate requires npm latest to equal the candidate version." + }, + { + "changeId": "proofkit.release.registry-authority-closure", + "summary": "Release metadata now promotes npm publication only from repository-produced typed registry evidence whose unique package set exactly matches retained provider records and local candidate identity and bytes." } ], "migration": { diff --git a/scripts/validate-self-hosting-receipts_test.go b/scripts/validate-self-hosting-receipts_test.go index 935cda6..f594f83 100644 --- a/scripts/validate-self-hosting-receipts_test.go +++ b/scripts/validate-self-hosting-receipts_test.go @@ -3,9 +3,13 @@ package main import ( "errors" "fmt" + "go/ast" + "go/parser" + "go/token" "os" "path/filepath" "reflect" + "strconv" "strings" "testing" @@ -46,6 +50,47 @@ func TestRunProofkitVerdictCases(t *testing.T) { } } +func TestRunInvokesEveryRequiredSelfHostingAdmissionBoundary(t *testing.T) { + parsed, err := parser.ParseFile(token.NewFileSet(), "validate-self-hosting-receipts.go", nil, 0) + if err != nil { + t.Fatal(err) + } + want := map[string]int{ + "proof-receipt-admission": 1, + "receipt-producer-admission": 1, + "spec-proof-bundle-admission": 1, + } + got := map[string]int{} + for _, declaration := range parsed.Decls { + function, ok := declaration.(*ast.FuncDecl) + if !ok || function.Name.Name != "run" { + continue + } + ast.Inspect(function.Body, func(node ast.Node) bool { + call, ok := node.(*ast.CallExpr) + if !ok || len(call.Args) != 3 { + return true + } + callee, ok := call.Fun.(*ast.Ident) + if !ok || callee.Name != "runProofkit" { + return true + } + literal, ok := call.Args[0].(*ast.BasicLit) + if !ok || literal.Kind != token.STRING { + return true + } + command, err := strconv.Unquote(literal.Value) + if err == nil { + got[command]++ + } + return true + }) + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("run() admission command inventory=%v, want exact %v", got, want) + } +} + func TestCurrentPlatformBinaryUsesReleasePlatformOwner(t *testing.T) { target, err := releaseplatform.CurrentTarget() if err != nil { @@ -763,6 +808,7 @@ func TestReleaseWorkflowRetainsReleaseAssetAndPostCreateEvidenceClosure(t *testi "artifacts/release/release-notes.md", "artifacts/release/github-release.json", "go run ./internal/tools/releasepreflight retained-evidence --artifact-root artifacts", + "go run ./internal/tools/releasepreflight retained-evidence-verify --artifact-root artifacts", } { if !strings.Contains(createRun, item) { t.Fatalf("Create GitHub Release step missing retained evidence token %q", item) @@ -771,6 +817,9 @@ func TestReleaseWorkflowRetainsReleaseAssetAndPostCreateEvidenceClosure(t *testi if strings.Contains(createRun, "$(basename \"$evidence\")") || strings.Contains(createRun, "sha256sum \"$evidence\"") { t.Fatal("Create GitHub Release step must delegate retained evidence topology to its repository owner") } + if err := validateRetainedEvidenceBranchClosure(createRun); err != nil { + t.Fatal(err) + } uploadIndex, err := uniqueStepIndex(assetJob.Steps, "Upload release evidence") if err != nil { t.Fatalf("find release evidence upload step: %v", err) @@ -791,6 +840,92 @@ func TestReleaseWorkflowRetainsReleaseAssetAndPostCreateEvidenceClosure(t *testi } } +func TestRetainedEvidenceBranchClosureRejectsUnreachableExistingReleaseVerification(t *testing.T) { + writeCommand := "go run ./internal/tools/releasepreflight retained-evidence --artifact-root artifacts" + verifyCommand := "go run ./internal/tools/releasepreflight retained-evidence-verify --artifact-root artifacts" + mutated := strings.Join([]string{ + `if gh release view "$GITHUB_REF_NAME" >/dev/null 2>&1; then`, + " exit 0", + "fi", + writeCommand, + verifyCommand, + `gh release create "$GITHUB_REF_NAME"`, + writeCommand, + verifyCommand, + }, "\n") + if err := validateRetainedEvidenceBranchClosure(mutated); err == nil { + t.Fatal("retained evidence verification after the existing-release branch was accepted") + } +} + +func validateRetainedEvidenceBranchClosure(run string) error { + writeCommand := "go run ./internal/tools/releasepreflight retained-evidence --artifact-root artifacts" + verifyCommand := "go run ./internal/tools/releasepreflight retained-evidence-verify --artifact-root artifacts" + writeOffsets := trimmedLineOffsets(run, writeCommand) + verifyOffsets := trimmedLineOffsets(run, verifyCommand) + exitOffsets := trimmedLineOffsets(run, "exit 0") + branchEndOffsets := trimmedLineOffsets(run, "fi") + existingBranchIndex := strings.Index(run, `if gh release view "$GITHUB_REF_NAME"`) + createReleaseIndex := strings.Index(run, `gh release create "$GITHUB_REF_NAME"`) + if len(writeOffsets) != 2 || len(verifyOffsets) != 2 || len(exitOffsets) != 1 || existingBranchIndex < 0 || createReleaseIndex < 0 { + return fmt.Errorf("retained evidence branch inventory write=%v verify=%v exit=%v existing=%d create=%d", writeOffsets, verifyOffsets, exitOffsets, existingBranchIndex, createReleaseIndex) + } + branchEndIndex := -1 + for _, offset := range branchEndOffsets { + if offset > exitOffsets[0] { + branchEndIndex = offset + break + } + } + if !(existingBranchIndex < writeOffsets[0] && + writeOffsets[0] < verifyOffsets[0] && + verifyOffsets[0] < exitOffsets[0] && + exitOffsets[0] < branchEndIndex && + branchEndIndex < createReleaseIndex && + createReleaseIndex < writeOffsets[1] && + writeOffsets[1] < verifyOffsets[1]) { + return fmt.Errorf("retained evidence write=%v verify=%v exit=%v branch end=%d existing=%d create=%d; want reachable write-then-verify before existing-release exit and after release creation", writeOffsets, verifyOffsets, exitOffsets, branchEndIndex, existingBranchIndex, createReleaseIndex) + } + return nil +} + +func trimmedLineOffsets(content, target string) []int { + offsets := []int{} + offset := 0 + for _, line := range strings.SplitAfter(content, "\n") { + if strings.TrimSpace(line) == target { + offsets = append(offsets, offset) + } + offset += len(line) + } + return offsets +} + +func TestReleaseWorkflowDelegatesNPMRegistryEvidenceToRepositoryOwner(t *testing.T) { + raw, err := os.ReadFile(filepath.Join("..", ".github", "workflows", "release.yml")) + if err != nil { + t.Fatalf("read release workflow: %v", err) + } + var workflow githubWorkflow + if err := yaml.Unmarshal(raw, &workflow); err != nil { + t.Fatalf("parse release workflow: %v", err) + } + publish := workflow.Jobs["publish"] + stepIndex, err := uniqueStepIndex(publish.Steps, "Capture published registry artifact identity") + if err != nil || stepIndex < 0 { + t.Fatalf("find npm registry evidence step: index=%d error=%v", stepIndex, err) + } + run := publish.Steps[stepIndex].Run + if strings.Count(run, "npm run npm:registry-evidence") != 1 { + t.Fatalf("npm registry evidence step must invoke its repository owner exactly once: %s", run) + } + for _, forbidden := range []string{"proofkit.published-registry-artifact-set.v1", "authorityValidator", "registry_release"} { + if strings.Contains(run, forbidden) { + t.Fatalf("workflow duplicates typed npm registry authority field %q", forbidden) + } + } +} + func TestReleaseWorkflowRegistryInstallUsesRootPackageName(t *testing.T) { raw, err := os.ReadFile(filepath.Join("..", ".github", "workflows", "release.yml")) if err != nil { diff --git a/scripts/workflow_security_scanner_oracles_test.go b/scripts/workflow_security_scanner_oracles_test.go index 7b8dd6d..9dde0f5 100644 --- a/scripts/workflow_security_scanner_oracles_test.go +++ b/scripts/workflow_security_scanner_oracles_test.go @@ -95,6 +95,36 @@ func TestSecurityScannerWorkflowsSeparateProviderPublicationPermissions(t *testi } } +func TestOSVSourceScanFailsForEveryNonzeroScannerStatus(t *testing.T) { + workflow := readWorkflowForTest(t, filepath.Join("..", ".github", "workflows", "osv-scanner.yml")) + job := workflow.Jobs["scan"] + run := "" + for _, step := range job.Steps { + if step.Name == "Run OSV source scan" { + run = step.Run + break + } + } + if run == "" { + t.Fatal("OSV workflow is missing the source scan step") + } + if !strings.Contains(run, `if [ "$scanner_status" -ne 0 ]`) || !strings.Contains(run, `exit "$scanner_status"`) { + t.Fatal("OSV source scan must fail for vulnerability status 1 and scanner errors") + } + for _, weak := range []string{`[ "$scanner_status" -gt 1 ]`, `[ "$scanner_status" -eq 1 ]`} { + if strings.Contains(run, weak) { + t.Fatalf("OSV source scan contains partial status gate %q", weak) + } + } + upload := workflow.Jobs["upload-sarif"] + if canonicalWorkflowExpression(upload.If) != "!cancelled()&&needs.scan.result!='skipped'&&github.event_name!='pull_request'&&(github.event.repository.private==false||vars.enable_code_scanning_upload=='true')" { + t.Fatalf("OSV provider upload must run after a finding failure but not after cancellation or a skipped scan: if=%q", upload.If) + } + if needs, ok := upload.Needs.(string); !ok || needs != "scan" { + t.Fatalf("OSV provider upload needs=%#v, want scan", upload.Needs) + } +} + func validateSecurityScannerPermissionSeparation( workflow githubWorkflow, expectation securityScannerPermissionExpectation, @@ -143,7 +173,7 @@ func validateSecurityScannerPermissionSeparation( return fmt.Errorf("%s missing provider job %q", expectation.path, jobID) } if expectation.name == "codeql" || expectation.name == "osv" { - if !providerUploadDisabledOnPullRequest(job.If) { + if !providerUploadDisabledOnPullRequest(expectation.name, job.If) { return fmt.Errorf( "%s provider job %q must not upload provider evidence on pull_request: if=%q", expectation.path, @@ -179,9 +209,12 @@ func permissionSetEquals(raw any, want map[string]string) bool { return true } -func providerUploadDisabledOnPullRequest(expression string) bool { - return canonicalWorkflowExpression(expression) == - "github.event_name!='pull_request'&&(github.event.repository.private==false||vars.enable_code_scanning_upload=='true')" +func providerUploadDisabledOnPullRequest(scanner string, expression string) bool { + expected := "github.event_name!='pull_request'&&(github.event.repository.private==false||vars.enable_code_scanning_upload=='true')" + if scanner == "osv" { + expected = "!cancelled()&&needs.scan.result!='skipped'&&" + expected + } + return canonicalWorkflowExpression(expression) == expected } func TestScorecardPublicPublishDeclaresRequiredOutputInputs(t *testing.T) { diff --git a/scripts/workflow_source_oracles_test.go b/scripts/workflow_source_oracles_test.go index 9a402df..6eebce3 100644 --- a/scripts/workflow_source_oracles_test.go +++ b/scripts/workflow_source_oracles_test.go @@ -453,7 +453,7 @@ func validateActionReference(reference string) error { return nil } -const existingReleaseReadOnlyBlockSHA256 = "8ca22124fd06580cc8d111bee06eee87cda0d4d57bdc67838e738114e3247885" +const existingReleaseReadOnlyBlockSHA256 = "b80fff2c67ae5b02d78f39e3551b18dc75b94800ebf0812063e07952342f5af5" func validateExistingReleasePath(run string) error { startMarker := `if gh release view "$GITHUB_REF_NAME" >/dev/null 2>&1; then`