From 56a3b7896bd7143f70e76b4ababc897255cbc54b Mon Sep 17 00:00:00 2001 From: iperev Date: Fri, 4 Sep 2026 07:13:08 +0200 Subject: [PATCH 1/4] feat: add explicit adoption planning front door --- ADOPTION.md | 18 +- README.md | 27 +- docs/proofkit-contract-map.md | 10 +- .../proofkit-package-boundary/overview.md | 9 +- .../requirements.v1.json | 2 +- .../proofkit-spec-proof-core/overview.md | 10 +- .../requirements.v1.json | 13 + .../proofkit-supply-chain-quality/overview.md | 17 +- .../requirements.v1.json | 2 +- internal/app/adoption_front_door_command.go | 167 ++++++ .../app/adoption_front_door_command_test.go | 155 ++++++ .../adoption_front_door_version_edge_test.go | 242 ++++++++ internal/app/agent_route_version_edge_test.go | 12 +- internal/app/app.go | 24 +- internal/app/app_test.go | 3 +- internal/app/cli_abi_test.go | 20 - internal/app/cli_contract_test.go | 40 +- internal/app/command_contract_generated.go | 164 +++--- internal/app/command_coverage_routes.go | 3 +- internal/app/command_coverage_test.go | 15 +- internal/app/command_descriptors.go | 14 +- internal/app/command_family_catalog.go | 9 +- .../app/command_family_catalog_generated.go | 6 +- internal/app/command_family_catalog_test.go | 17 +- internal/app/command_flag_constraints.go | 17 +- internal/app/command_help.go | 26 +- internal/app/command_registry.go | 5 - internal/app/command_routes.go | 99 ++++ internal/app/invocation_profile_test.go | 12 +- .../v0.6-release-change-record.v2.json | 61 ++ .../app/testdata/v0.6-wire-observations.json | 2 +- .../app/testdata/v0.7-wire-observations.json | 49 ++ internal/command/adoptionplan/admission.go | 58 ++ .../command/adoptionplan/adoptionplan_test.go | 216 ++++++++ internal/command/adoptionplan/build.go | 102 ++++ internal/command/adoptionplan/model.go | 159 ++++++ .../adoptionplan/repository_classes_test.go | 147 +++++ internal/command/adoptionplan/text.go | 58 ++ .../capability_map_admission.go | 23 +- internal/command/initplan/initplan.go | 121 ---- internal/command/initplan/initplan_test.go | 39 -- .../json_report_cli_adapter_source.go | 32 +- .../json_report_cli_adapter_source_test.go | 53 +- .../nativeevidenceguidance/guidance.go | 71 +++ .../nativeevidenceguidance/guidance_test.go | 55 ++ .../command/repositoryinventory/admission.go | 179 ++++++ .../repositoryinventory/fifo_unix_test.go | 62 +++ internal/command/repositoryinventory/model.go | 194 +++++++ .../open_candidate_other.go | 16 + .../open_candidate_unix.go | 16 + .../repositoryinventory_test.go | 521 ++++++++++++++++++ internal/command/repositoryinventory/scan.go | 223 ++++++++ .../stackpreset/preset_ids_generated.go | 2 +- internal/command/stackpreset/stackpreset.go | 65 +++ .../command/stackpreset/stackpreset_test.go | 35 ++ internal/tools/commandcontractgen/main.go | 56 +- .../tools/commandcontractgen/main_test.go | 35 ++ internal/tools/coveragemetrics/main.go | 6 +- .../tools/installedclicontract/contract.go | 160 ++++++ .../installedclicontract/contract_test.go | 78 +++ internal/tools/packageverify/main.go | 284 ++++++---- internal/tools/packageverify/main_test.go | 97 +++- internal/tools/pythonpackage/build.go | 21 +- .../tools/pythonpackage/continuation_test.go | 42 ++ internal/tools/pythonpackage/metadata_test.go | 55 +- internal/tools/pythonpackage/verify.go | 107 +++- internal/tools/releasechange/record_test.go | 31 +- .../snapshot_process_group_unix_test.go | 52 +- package-lock.json | 4 +- package.json | 2 +- proofkit/cli-contract.v2.json | 392 +++++++++---- proofkit/command-families.v1.json | 3 +- proofkit/requirement-bindings.json | 157 ++++++ release/change-record.v2.json | 50 +- 74 files changed, 4623 insertions(+), 726 deletions(-) create mode 100644 internal/app/adoption_front_door_command.go create mode 100644 internal/app/adoption_front_door_command_test.go create mode 100644 internal/app/adoption_front_door_version_edge_test.go create mode 100644 internal/app/command_routes.go create mode 100644 internal/app/testdata/v0.6-release-change-record.v2.json create mode 100644 internal/app/testdata/v0.7-wire-observations.json create mode 100644 internal/command/adoptionplan/admission.go create mode 100644 internal/command/adoptionplan/adoptionplan_test.go create mode 100644 internal/command/adoptionplan/build.go create mode 100644 internal/command/adoptionplan/model.go create mode 100644 internal/command/adoptionplan/repository_classes_test.go create mode 100644 internal/command/adoptionplan/text.go delete mode 100644 internal/command/initplan/initplan.go delete mode 100644 internal/command/initplan/initplan_test.go create mode 100644 internal/command/repositoryinventory/admission.go create mode 100644 internal/command/repositoryinventory/fifo_unix_test.go create mode 100644 internal/command/repositoryinventory/model.go create mode 100644 internal/command/repositoryinventory/open_candidate_other.go create mode 100644 internal/command/repositoryinventory/open_candidate_unix.go create mode 100644 internal/command/repositoryinventory/repositoryinventory_test.go create mode 100644 internal/command/repositoryinventory/scan.go create mode 100644 internal/tools/installedclicontract/contract.go create mode 100644 internal/tools/installedclicontract/contract_test.go diff --git a/ADOPTION.md b/ADOPTION.md index b15f146..40bce60 100644 --- a/ADOPTION.md +++ b/ADOPTION.md @@ -151,7 +151,23 @@ check. Durable truth still starts only after the consumer commits and admits ## First Adoption Loop Proofkit can reduce initial adoption glue, but it must not turn observation into -truth. The first loop is: +truth. Start with one explicit trust intent: + +```bash +npm exec --offline -- agentic-proofkit adopt plan --mode fresh --repo-root . +npm exec --offline -- agentic-proofkit adopt plan --mode code-baseline --repo-root . +npm exec --offline -- agentic-proofkit adopt plan --mode audit-from-code --repo-root . +``` + +The command validates its arguments before filesystem access, scans only a +fixed catalog of recognized files at the selected root, and emits a +candidate-only task sequence plus a compact reference to the native-evidence +guidance owner. It does not parse those files, infer a stack, inspect arbitrary +source code, generate product requirements, write files, or run witnesses. +An optional `--stack ` records a caller-selected suggestion and +cannot alter source trust or task semantics. + +Continue the first loop as follows: ```text caller-owned capability or test observations diff --git a/README.md b/README.md index e3940d6..046c015 100644 --- a/README.md +++ b/README.md @@ -144,19 +144,22 @@ truth unless the consumer explicitly admits them. ### Invariant Authoring Loop -For a repository with no specification, Proofkit can guide an agent through two -different starting modes: +For a repository with no specification, Proofkit can guide an agent through +three explicit starting modes: ```mermaid flowchart TB Start["Code, docs, tests, issues, and maintainer intent"] --> Mode["Choose trust mode"] + Mode --> Fresh["Fresh authoring mode"] Mode --> Baseline["Code baseline mode"] Mode --> Audit["Code audit mode"] + Fresh --> Contract["Owner-authored product contract"] Baseline --> Observations["Caller-owned capability observations"] Audit --> Observations Observations --> Seeds["Candidate invariants and requirement seeds"] Seeds --> Review["Owner review and promotion"] Review --> Specs["Repo-owned requirements.v1.json"] + Contract --> Specs Specs --> Obligations["Proof obligations"] Obligations --> Evidence["Proof bindings and test inventory"] Evidence --> Admission["Proofkit admission and coverage"] @@ -164,10 +167,11 @@ flowchart TB | Mode | Use when | Result | |---|---|---| +| Fresh authoring | No existing code or specification is accepted as product truth | Owner-authored behavior statements that remain candidates until admitted as repository requirements | | Code baseline | Current behavior is accepted as the starting contract | Candidate requirements and bindings that preserve current behavior until owners review them | | Code audit | Current behavior may be wrong or incomplete | Untrusted observations and questions that must be promoted by a repository owner before becoming requirements | -In both modes, generated records remain candidates until the consuming +In all three modes, generated records remain candidates until the consuming repository admits them as repo-owned requirements, proof bindings, and witness plans. Proofkit can structure and validate candidate packets, but it does not extract complete behavior from arbitrary source code, invent product policy, or @@ -179,7 +183,8 @@ Use the CLI help route before reading source: ```bash npm exec --offline -- agentic-proofkit help -npm exec --offline -- agentic-proofkit init +npm exec --offline -- agentic-proofkit adopt plan --mode fresh --repo-root . +npm exec --offline -- agentic-proofkit help adopt plan npm exec --offline -- agentic-proofkit help repo-profile-admission npm exec --offline -- agentic-proofkit repo-profile-admission --help ``` @@ -189,12 +194,18 @@ does not read stdin. The full machine-readable command inventory remains `proofkit/cli-contract.v2.json`; the human route map is `docs/proofkit-contract-map.md`. +`adopt plan` is the read-only front door. It inventories only a fixed catalog +of recognized files at the explicit repository root, validates all arguments +before filesystem access, and returns candidate-authoring tasks. It does not +infer a stack, parse arbitrary source semantics, generate requirements, write +files, or execute evidence. `--stack` is an optional caller-selected hint and +cannot change the selected trust mode. + | Repository state | Minimal first route | Stop condition | |---|---|---| -| Unknown starting point | `init` | Stop before reading repository files, writing files, or treating route guidance as proof | -| Fresh repository with no specs and no extracted observations | `init --preset fresh`, then `scaffold-project-structure` or `gradual-adoption-bootstrap` | Stop before writing files or inventing requirement meaning | -| Current code is trusted as the initial contract | `capability-map-admission` with `trustMode: "code_baseline"` | Stop before treating generated seeds as admitted requirements | -| Current code must be audited before it becomes a contract | `capability-map-admission` with `trustMode: "audit_from_code"` | Stop at owner questions and candidate-only records | +| Fresh repository with no specification | `adopt plan --mode fresh --repo-root .` | Stop before writing files or inventing requirement meaning | +| Current code is intentionally accepted as the initial baseline | `adopt plan --mode code-baseline --repo-root .` | The flag is a caller declaration, not evidence that the code is correct; stop before promoting candidate observations | +| Current code must be audited before it becomes a contract | `adopt plan --mode audit-from-code --repo-root .` | Stop at explicit observations, owner questions, and candidate-only records | | Legacy repository has local proof infrastructure | `migration-parity-admission`, then `migration-plan` | Stop before deleting local proof owners without parity evidence | | A change set needs bounded checks | `changed-path-set`, optional `impact`, then `selective-gate-plan` and `selective-gate-evidence` | Stop on unknown scope, missing routes, or stale receipts | | An agent needs only one specification subtree | `requirement-context-compose --repo-root . --input context-catalog.json`, then `requirement-context-slice` | Stop before treating a bounded slice as complete repository truth | diff --git a/docs/proofkit-contract-map.md b/docs/proofkit-contract-map.md index d912ac6..bde46c9 100644 --- a/docs/proofkit-contract-map.md +++ b/docs/proofkit-contract-map.md @@ -40,7 +40,7 @@ owner boundaries. It is not a second command-family inventory. | Family | Main commands | Caller provides | Proofkit owns | Consumer owns | Output authority | |---|---|---|---|---|---| | Agent workflow planning | `change-workflow-plan`, `native-evidence-guidance` | explicit checkpoint, completed stage ids, bounded context refs, governing authority ref, and required context ref ids | optional built-in `proofkit.reviewed-change.v1` checkpoint relation, reference-closed next-stage context, deterministic agent prompts, bounded text/JSON/envelope projections, and repository-neutral native-evidence guidance with closed applicability classes | custom workflow topology, repository state discovery, stage execution, native witness semantics, evidence collection, review conclusions, merge, release, deployment, and rollout authority | next-action plan, terminal workflow report, bounded agent envelope, or guidance catalog | -| Adoption and scaffolding | `init`, `adoption-contract-envelope`, `adoption-workflow-plan`, `adoption-checklist`, `adoption-doctor`, `gradual-adoption`, `gradual-adoption-bootstrap`, `gradual-adoption-guidance`, `capability-map-admission`, `pilot-admission`, `scaffold-profile-plan`, `scaffold-project-structure`, `stack-preset` | adoption intent, aggregate adoption contract envelope, checklist facts, target paths, owner routes, caller-extracted stale authority vocabulary facts, explicit pre-spec capability observations, pilot records, stack preset id, optional init preset id | dry-run route selection, aggregate contract-envelope admission, deterministic starter plans, checklist/report admission, bounded guidance envelopes, dry-run manifests, pre-spec trust-mode admission, adoption gap and stale-authority classification, pilot shape admission | final files, final requirements, rollout policy, text extraction from files, code observation extraction, pilot truth | selected child output, plan, report, seed packet, or agent envelope | +| Adoption and scaffolding | `adopt plan`, `repository-inventory`, `adoption-contract-envelope`, `adoption-workflow-plan`, `adoption-checklist`, `adoption-doctor`, `gradual-adoption`, `gradual-adoption-bootstrap`, `gradual-adoption-guidance`, `capability-map-admission`, `pilot-admission`, `scaffold-profile-plan`, `scaffold-project-structure`, `stack-preset` | explicit repository root, explicit fresh/code-baseline/audit-from-code intent, optional stack hint, aggregate adoption contract envelope, checklist facts, target paths, owner routes, caller-extracted stale authority vocabulary facts, explicit pre-spec capability observations, and pilot records | bounded fixed-catalog root inventory, candidate-only front-door tasks, aggregate contract-envelope admission, deterministic starter plans, checklist/report admission, bounded guidance envelopes, dry-run manifests, pre-spec trust-mode admission, adoption gap and stale-authority classification, and pilot shape admission | stack selection, arbitrary source inspection, final files, final requirements, rollout policy, text extraction, code observation extraction, and pilot truth | inventory, candidate-only plan, selected child output, report, seed packet, or agent envelope | | Requirement source | `capability-map-admission`, `requirement-authoring-plan`, `requirement-source-admission`, `requirement-source-transition`, `spec-overview-claims`, `requirement-spec-tree`, `requirement-spec-tree-view`, `requirement-source-view`, `requirement-browser-server` | `requirements.v1.json`, caller-owned capability maps, caller-owned authoring facts, overview claim extraction, explicit spec hierarchy, view options | candidate seed admission, candidate-only authoring packets, source-shape admission, lifecycle checks, explicit tree topology/source-ref admission, shared safe renderer fragments, presentation-only views | requirement meaning, extraction completeness, Markdown extraction completeness, hierarchy ownership, proof adequacy, file materialization | capability map report, authoring packet, source report, spec-tree report, rendered view, or browser presentation | | Requirement proof binding | `requirement-bindings`, `binding-partition`, `proof-slice`, `evidence-graph`, `requirement-proof-resolver`, `requirement-proof-source-set`, `requirement-proof-view`, `spec-proof-bundle-admission` | requirement records, bindings, witness commands, source-set facts, receipt reports, partition policy | graph validation, binding partition projection, compact slices, declaration-only compact route projection with full binding identity and role-qualified witness routes, resolver projection, bundle linkage checks | selector resolution, oracle quality, witness execution, mutation adequacy, finding completeness, proof freshness, trust, assurance, merge policy | proof report, partition report, slice, declaration lookup graph, or view | | Test inventory and coverage | `test-evidence-inventory`, `test-evidence-inventory --projection discovery-draft`, `test-evidence-inventory --normalized-inventory`, `requirement-coverage-input-compose`, `requirement-coverage-view`, `requirement-browser-server --view coverage` | caller-owned direct or source-set test inventory, caller-owned explicit test discovery facts, declared quality findings, requirement source, proof binding or compact proof contract, coverage universe, optional owner-invariant registry, aggregate coverage compose input | strict inventory/source-set admission, candidate-only discovery draft projection, fail-closed normalized inventory projection, deterministic coverage-view input composition from explicit facts, missing declared assertion-signal and declared-quality classification, bounded agent action guidance, requirement/test/command/owner-invariant joins, nonsemantic command-evidence classification, stable coverage failure/warning classifications, presentation-only coverage view | inventory completeness, oracle quality, test quality, test discovery extraction, native test execution, receipt freshness, producer trust, merge policy | candidate inventory guidance, inventory report, normalized inventory data product, coverage-view input, coverage view, or browser presentation | @@ -57,7 +57,7 @@ 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 | +| CLI metadata | `help` | optional full public command route or help flag | built-in command catalog and help text routing | command selection, semantic proof, freshness, merge policy | text help only | ## Migrating Repository Route @@ -80,7 +80,7 @@ Route ambiguous modernization work through the smallest matching family: |---|---|---| | No specs exist and current code should be frozen as the first baseline. | `capability-map-admission` with `trustMode: "code_baseline"` | It admits caller-owned capability observations and emits bounded candidate requirement/proof-binding seeds only when scenarios have candidate ids and executable anchors. | | No specs exist and current code is not trusted. | `capability-map-admission` with `trustMode: "audit_from_code"` | It treats code observations as hypotheses, keeps missing anchors as owner actions, and prevents code from becoming stable requirement truth without owner review. | -| Where should adoption start? | `adoption-workflow-plan` or `scaffold-project-structure`; use `adoption-contract-envelope` when one caller-owned aggregate adoption file already exists. | They route scenario steps and first-module starter records without scanning the repository; the aggregate route removes consumer-local root-key projection scripts without owning rollout policy. | +| Where should adoption start? | `adopt plan --mode --repo-root `, where mode is `fresh`, `code-baseline`, or `audit-from-code`; add `--stack ` only when the caller already selected that hint. | It validates intent before a bounded fixed-catalog root scan and emits candidate-authoring tasks. It does not infer stack identity, inspect arbitrary source semantics, write files, or promote candidates. | | Is a candidate module ready for gradual enforcement? | `gradual-adoption-guidance` | It reports missing source, binding, witness, blocked-precondition, and advisory candidate-boundary facts by adoption mode. | | What still blocks an imperfect repository from enforcement? | `adoption-doctor` | It classifies caller-provided owner routes, candidate boundaries, child reports, blocked preconditions, and stale current authority vocabulary facts without scanning repository state or owning semantic boundary decisions. | | Does current documentation still name a retired proof package or proof owner? | `adoption-doctor --agent-envelope` with caller-extracted `staleAuthority` facts | It fails current authority surfaces, admits only explicitly scoped historical vocabulary, and emits bounded repair actions without substring-scanning files itself. | @@ -128,10 +128,10 @@ Semantic context routes are `requirement-context-compose`, | State or goal | Next Proofkit route | Stop or escalation condition | |---|---|---| -| The agent does not know where to start. | `init`, or `init --preset` with `fresh`, `code-baseline`, `code-audit`, `legacy`, or `change-set` | Treat output as dry-run route guidance only. Stop before scanning, writing files, or making requirements authoritative. | +| The agent does not know where to start. | `adopt plan --mode --repo-root `, where mode is `fresh`, `code-baseline`, or `audit-from-code` | Choose the trust intent explicitly. Treat the fixed-catalog inventory and tasks as a read-only candidate plan; stop before arbitrary source inspection, writing files, or making requirements authoritative. | | An agent needs a bounded, deterministic stage transition for an engineering change. | `change-workflow-plan` selects the optional built-in `proofkit.reviewed-change.v1` profile; use `--agent-envelope` for the compact work packet and `native-evidence-guidance` when the consuming repository has not yet materialized repository-specific evidence instructions. | Supply only explicit current checkpoint, completed stages, and admitted context references. Apply conditional guidance slots only when their applicability class matches a declared consumer mechanism. Stop before treating the profile, plan, or guidance as repository policy or as proof that a stage ran, evidence exists, review passed, or merge/release is authorized. | | No admitted spec/profile exists and the caller has explicit capability observations. | `capability-map-admission`; use `trustMode: "code_baseline"` only when maintainers intentionally freeze current code, otherwise use `trustMode: "audit_from_code"`. | Stop before treating seeds as stable requirements. The consumer owns observation extraction, materialization, requirement meaning, and proof adequacy. | -| No admitted spec/profile exists and no capability observations exist. | `scaffold-project-structure`, `adoption-workflow-plan`, or `stack-preset` | Stop before writing files; the consumer owns materialization, overwrite policy, and final requirement text. | +| No admitted spec/profile exists and no capability observations exist. | Start with `adopt plan --mode fresh --repo-root `; use `scaffold-project-structure`, `adoption-workflow-plan`, or `stack-preset` only as later specialist routes when an owner has selected them. | Treat front-door tasks as candidate-only. Stop before writing files; the consumer owns materialization, overwrite policy, and final requirement text. | | Candidate boundary is uncertain. | `adoption-doctor` or `gradual-adoption-guidance --agent-envelope` | Escalate to owner review when the boundary is advisory, ambiguous, or missing native witnesses. | | Temporary external design, implementation-plan, PR, code, or test observations may contain durable requirements. | `requirement-authoring-plan` | Treat output as candidate-only; stop before writing `requirements.v1.json`, retaining temporary documents, or claiming requirement meaning. | | Requirement records exist. | `requirement-source-admission`; use `requirement-source-transition` for lifecycle changes. | Escalate when blocking requirements lack proof routes or lifecycle replacement ids are incomplete. | diff --git a/docs/specs/proofkit-package-boundary/overview.md b/docs/specs/proofkit-package-boundary/overview.md index cf1efbf..fc54e7b 100644 --- a/docs/specs/proofkit-package-boundary/overview.md +++ b/docs/specs/proofkit-package-boundary/overview.md @@ -39,10 +39,11 @@ denial, and package artifact behavior only. strict-character-reference-encoded broad caller-supplied phrase suppressors that can hide readiness overclaims. - `REQ-PROOFKIT-PACKAGE-003`: the root package remains installable and - executable by an outside consumer through the exact offline onboarding trace - on the current native platform; every generated preset command retains the - offline npm resolver and one exact emitted continuation is re-executed - without claiming registry publication. + executable by an outside consumer; packed launcher and platform-binary bytes + match source-built artifacts, npm offline transport proves package resolution + plus one multi-token route, and direct execution under the exact admitted npm + launcher profile proves exhaustive family, leaf, preset, continuation, and README + onboarding closure without claiming registry publication. - `REQ-PROOFKIT-PACKAGE-004`: CI package-gate receipts used as merge evidence are admitted through a declared producer policy and proof-receipt shape validator instead of current-build output alone, and each fixed subprocess diff --git a/docs/specs/proofkit-package-boundary/requirements.v1.json b/docs/specs/proofkit-package-boundary/requirements.v1.json index 5acbb25..5858dee 100644 --- a/docs/specs/proofkit-package-boundary/requirements.v1.json +++ b/docs/specs/proofkit-package-boundary/requirements.v1.json @@ -62,7 +62,7 @@ { "requirementId": "REQ-PROOFKIT-PACKAGE-003", "ownerId": "proofkit.package-boundary", - "invariant": "The exact local root-package tarball remains installable and executable by an outside consumer on the current native platform through npm exec --offline, with a continuous root-help to family to stack-preset to every exact generated preset continuation and first-valid-input onboarding trace, including successful re-execution of one emitted self-continuation without network fallback, without claiming registry publication.", + "invariant": "The exact local root-package tarball remains installable and executable by an outside consumer on the current native platform. The proof byte-binds the packed package launcher and platform binary to their source-built bytes, verifies npm exec --offline resolution through root help and one contract-owned multi-token help route, then uses the exact admitted npm launcher profile to prove exhaustive root-help to family to leaf-help closure, stack-preset vocabulary, every generated preset continuation, one re-executed self-continuation, and the first valid README input without network fallback or a registry-publication claim.", "claimLevel": "blocking", "riskClass": "medium", "proofBindingRefs": [ diff --git a/docs/specs/proofkit-spec-proof-core/overview.md b/docs/specs/proofkit-spec-proof-core/overview.md index d6a9f3d..894bbad 100644 --- a/docs/specs/proofkit-spec-proof-core/overview.md +++ b/docs/specs/proofkit-spec-proof-core/overview.md @@ -116,7 +116,7 @@ execution receipts, and merge policy. caller-provided previous and next source snapshots so lifecycle changes are monotonic, evidence-backed, package-boundary-stable, and repository-neutral. - `REQ-PROOFKIT-SPEC-017`: capability map admission validates caller-owned - pre-spec observations under explicit code-baseline or code-audit trust modes + pre-spec observations under explicit code-baseline or audit-from-code trust modes and emits only bounded candidate requirements, bindings, or owner guidance. - `REQ-PROOFKIT-SPEC-018`: an authored command-family catalog covers every public CLI command exactly once, deterministically generates the private @@ -173,6 +173,14 @@ execution receipts, and merge policy. omission accounting, stable source-report identity, one top-level detail access record, and explicit full-envelope retrieval without repeating policy prose or promoting route guidance to authority. +- `REQ-PROOFKIT-SPEC-027`: the read-only `adopt plan` front door keeps explicit + source-trust intent separate from optional stack hints, composes a + candidate-only plan over a confined bounded root-catalog inventory and + content-bound child-owned evidence-guidance reference, validates arguments and + platform support before repository access, discards unknown names immediately + after batched catalog classification, and exposes owner-closed JSON plus a + bounded text projection with every authority non-claim without inferring + requirement meaning, writing files, or retaining the superseded `init` route. ## Non-Claims diff --git a/docs/specs/proofkit-spec-proof-core/requirements.v1.json b/docs/specs/proofkit-spec-proof-core/requirements.v1.json index 7c9ed18..a50d3a8 100644 --- a/docs/specs/proofkit-spec-proof-core/requirements.v1.json +++ b/docs/specs/proofkit-spec-proof-core/requirements.v1.json @@ -594,6 +594,19 @@ "lifecycle": {"state": "active", "replacementRequirementIds": [], "evidenceRefs": []}, "deferral": null, "updatePolicy": {"reviewOwnerId": "proofkit.spec-proof-core", "requiresImpactDeclaration": true, "requiresProofBindingReview": true} + }, + { + "requirementId": "REQ-PROOFKIT-SPEC-027", + "ownerId": "proofkit.spec-proof-core", + "invariant": "The public read-only adoption front door is routed only as adopt plan, requires one explicit repository root and one explicit fresh, code-baseline, or audit-from-code intent, keeps an optional stack preset orthogonal to source trust, and validates every argument before filesystem access. Its repository inventory owner scans only a fixed root-file catalog through a confined root handle, fails before filesystem access on platforms without its nonblocking no-follow open boundary, rejects recognized symlink or non-regular substitutions, preflights root cardinality and observed file sizes before content reads, enforces per-file, actual aggregate-read, and output byte bounds, hashes only recognized regular UTF-8 file bytes, and uses unrecognized root entry names only for immediate catalog membership and counting without sorting, retaining beyond the current read batch, or disclosing them. Inventory and adoption-plan outputs bind deterministic identities and re-admit unchanged through their child owners; the plan emits only candidate-authoring tasks and a child-owned native-evidence guidance reference whose digest binds the complete current guidance, never synthesizes requirement meaning or proof bindings, records code-baseline solely as an explicit caller declaration, and cannot change trust or task semantics through a stack hint. JSON and bounded text are projections of the same typed plan, every authority non-claim remains visible in text, terminal styling is capability-gated, and the superseded init route has no active descriptor, dispatcher, contract, family, or package owner.", + "claimLevel": "blocking", + "riskClass": "high", + "proofBindingRefs": ["proofkit/requirement-bindings.json"], + "nonClaimRefs": ["NC-PROOFKIT-SPEC-027"], + "nonClaims": ["The adoption front door does not infer repository stack identity, inspect arbitrary source semantics, decide product meaning, establish code correctness, write files, execute commands or native witnesses, promote candidates, authenticate freshness, approve merge or release, or establish rollout or production readiness."], + "lifecycle": {"state": "active", "replacementRequirementIds": [], "evidenceRefs": []}, + "deferral": null, + "updatePolicy": {"reviewOwnerId": "proofkit.spec-proof-core", "requiresImpactDeclaration": true, "requiresProofBindingReview": true} } ], "nonClaims": [ diff --git a/docs/specs/proofkit-supply-chain-quality/overview.md b/docs/specs/proofkit-supply-chain-quality/overview.md index 218cae7..2413dfc 100644 --- a/docs/specs/proofkit-supply-chain-quality/overview.md +++ b/docs/specs/proofkit-supply-chain-quality/overview.md @@ -124,14 +124,15 @@ vulnerability absence, or consumer rollout safety by itself. identity tuple with the exact version-tag workflow ref for workflow-published npm and PyPI channels and release closeout rejects publication claims without that tuple. -- `REQ-PROOFKIT-QUALITY-019`: installed package smoke proof verifies one - continuous offline route through every displayed family and leaf-help - transition, binds each installed invocation to its ordered exact bare Usage - command token, requires every exact generated preset command to retain the - offline npm prefix, re-executes one emitted continuation, reaches the first - valid README input, executes one successful JSON report command and one - failed-report command, applies bounded expansion-free literal parsing to - emitted and README argv, and verifies the current +- `REQ-PROOFKIT-QUALITY-019`: installed package smoke proof byte-binds the + launcher and platform binary, proves npm offline package resolution plus one + contract-owned multi-token argv route, then uses the exact admitted npm + launcher profile to exhaustively traverse displayed family and leaf-help routes, + bind installed invocations to ordered bare Usage tokens, require every exact + generated preset command to retain the offline npm prefix, re-execute one + emitted continuation, reach the first valid README input, execute one + successful JSON report command and one failed-report command, apply bounded + expansion-free literal parsing to emitted and README argv, and verify the current `json-report-cli-adapter-source` generated source artifact from the package-managed binary, including report identity, state, exit code, stdout, stderr discipline, generated-source hash, owner-source parity, and exact diff --git a/docs/specs/proofkit-supply-chain-quality/requirements.v1.json b/docs/specs/proofkit-supply-chain-quality/requirements.v1.json index 24fc3fd..05b633d 100644 --- a/docs/specs/proofkit-supply-chain-quality/requirements.v1.json +++ b/docs/specs/proofkit-supply-chain-quality/requirements.v1.json @@ -246,7 +246,7 @@ { "requirementId": "REQ-PROOFKIT-QUALITY-019", "ownerId": "proofkit.supply-chain-quality", - "invariant": "Installed package smoke proof builds the current product, installs the exact local tarball, and verifies a continuous npm exec --offline route from root help through every displayed family and leaf-help transition, with each installed invocation exactly bound to its ordered bare Usage command token; it then verifies exact stack-preset vocabulary, requires every exact generated preset command string to retain the offline npm prefix, re-executes one emitted self-continuation, and continues to exactly one marker-bounded README command and JSON first input, with every emitted or README argv admitted through bounded expansion-free literal shell-word semantics before direct execution. It also verifies that the package-managed binary can process explicit JSON input for one successful report command and one failed-report command and emit the current json-report-cli-adapter-source artifact with source bytes equal to the generator owner source, preserving report kind, report id, state, explicit input counts, exit code, stdout JSON, generated-source hash, and stderr channel discipline without admitting unlisted consumer working-directory files.", + "invariant": "Installed package smoke proof builds the current product, installs the exact local tarball, byte-compares the packed launcher and platform binary with their source-built artifacts, proves npm exec --offline package resolution at root help, and proves ordered multi-token argv forwarding through one contract-owned multi-token help route. It then executes the installed platform binary under the exact admitted npm launcher profile to verify every displayed family and leaf-help transition, with each installed invocation bound to its ordered bare Usage command token; verifies exact stack-preset vocabulary; requires every exact generated preset command string to retain the offline npm prefix; re-executes one emitted self-continuation; and continues to exactly one marker-bounded README command and JSON first input, with every emitted or README argv admitted through bounded expansion-free literal shell-word semantics before execution. It also verifies that the package-managed binary can process explicit JSON input for one successful report command and one failed-report command and emit the current json-report-cli-adapter-source artifact with source bytes equal to the generator owner source, preserving report kind, report id, state, explicit input counts, exit code, stdout JSON, generated-source hash, and stderr channel discipline without admitting unlisted consumer working-directory files.", "claimLevel": "blocking", "riskClass": "high", "proofBindingRefs": ["proofkit/requirement-bindings.json"], diff --git a/internal/app/adoption_front_door_command.go b/internal/app/adoption_front_door_command.go new file mode 100644 index 0000000..596491e --- /dev/null +++ b/internal/app/adoption_front_door_command.go @@ -0,0 +1,167 @@ +package app + +import ( + "context" + "fmt" + "io" + "strings" + + "github.com/research-engineering/agentic-proofkit/internal/command/adoptionplan" + "github.com/research-engineering/agentic-proofkit/internal/command/repositoryinventory" + "github.com/research-engineering/agentic-proofkit/internal/command/stackpreset" +) + +type adoptionFrontDoorArgs struct { + color string + colorExplicit bool + format string + mode string + repositoryRoot string + stack string +} + +func runAdoptionFrontDoor(ctx context.Context, command string, args []string, stdout io.Writer, stderr io.Writer, capabilities PresentationCapabilities) int { + options, err := parseAdoptionFrontDoorArgs(command, args) + if err != nil { + writeDiagnostic(stderr, err) + return 1 + } + inventory, err := repositoryinventory.Scan(ctx, options.repositoryRoot) + if err != nil { + writeDiagnostic(stderr, err) + return 1 + } + if command == "repository-inventory" { + return writeJSON(inventory.JSONValue(), 0, nil, stdout, stderr) + } + plan, err := adoptionplan.Build(options.mode, inventory, options.stack) + if err != nil { + writeDiagnostic(stderr, err) + return 1 + } + if options.format == "json" { + return writeJSON(plan.JSONValue(), 0, nil, stdout, stderr) + } + lines, err := adoptionplan.TextProjection(plan) + if err != nil { + return writeText("", 1, err, stdout, stderr) + } + plain, err := adoptionplan.RenderText(lines) + if err != nil { + return writeText("", 1, err, stdout, stderr) + } + view := adoptionPlanTerminalText(lines) + output, err := renderTerminalText(view, options.color, capabilities) + if err == nil && options.color == "never" && output != plain { + err = fmt.Errorf("adoption plan text projection drifted") + } + return writeText(output, 0, err, stdout, stderr) +} + +func parseAdoptionFrontDoorArgs(command string, args []string) (adoptionFrontDoorArgs, error) { + options := adoptionFrontDoorArgs{color: "never", format: "json"} + seen := map[string]bool{} + for index := 0; index < len(args); index++ { + flag := args[index] + if !adoptionFrontDoorFlagAllowed(command, flag) { + return adoptionFrontDoorArgs{}, fmt.Errorf("unsupported argument for %s: %s", commandRouteForDiagnostic(command), flag) + } + if seen[flag] { + return adoptionFrontDoorArgs{}, fmt.Errorf("%s may be specified only once", flag) + } + seen[flag] = true + if index+1 >= len(args) || args[index+1] == "" { + return adoptionFrontDoorArgs{}, missingAdoptionFrontDoorValue(flag) + } + value := args[index+1] + index++ + switch flag { + case "--repo-root": + options.repositoryRoot = value + case "--color": + if value != "auto" && value != "never" { + return adoptionFrontDoorArgs{}, fmt.Errorf("--color requires one of: auto, never") + } + options.color = value + options.colorExplicit = true + case "--format": + if value != "json" && value != "text" { + return adoptionFrontDoorArgs{}, fmt.Errorf("--format requires one of: json, text") + } + options.format = value + case "--mode": + if value != adoptionplan.IntentFresh && value != adoptionplan.IntentCodeBaseline && value != adoptionplan.IntentAuditFromCode { + return adoptionFrontDoorArgs{}, fmt.Errorf("--mode requires one of: audit-from-code, code-baseline, fresh") + } + options.mode = value + case "--stack": + if !stackpreset.IsPresetID(value) { + return adoptionFrontDoorArgs{}, fmt.Errorf("--stack requires one of: %s", strings.Join(stackpreset.IDs(), ", ")) + } + options.stack = value + } + } + if options.repositoryRoot == "" { + return adoptionFrontDoorArgs{}, fmt.Errorf("%s requires --repo-root ", commandRouteForDiagnostic(command)) + } + if command == "adopt-plan" && options.mode == "" { + return adoptionFrontDoorArgs{}, fmt.Errorf("adopt plan requires --mode ") + } + if options.colorExplicit && options.format != "text" { + return adoptionFrontDoorArgs{}, fmt.Errorf("--color is valid only with --format text") + } + return options, nil +} + +func adoptionFrontDoorFlagAllowed(command, flag string) bool { + if flag == "--repo-root" { + return true + } + if command != "adopt-plan" { + return false + } + switch flag { + case "--color", "--format", "--mode", "--stack": + return true + default: + return false + } +} + +func missingAdoptionFrontDoorValue(flag string) error { + switch flag { + case "--color": + return fmt.Errorf("--color requires one of: auto, never") + case "--format": + return fmt.Errorf("--format requires one of: json, text") + case "--mode": + return fmt.Errorf("--mode requires one of: audit-from-code, code-baseline, fresh") + case "--repo-root": + return fmt.Errorf("--repo-root requires a path") + case "--stack": + return fmt.Errorf("--stack requires a preset id") + default: + return fmt.Errorf("unsupported adoption front-door argument") + } +} + +func commandRouteForDiagnostic(command string) string { + descriptor, ok := commandDescriptorFor(command) + if !ok { + return command + } + return commandRouteText(descriptor.routeTokens) +} + +func adoptionPlanTerminalText(lines []adoptionplan.TextLine) terminalText { + tokens := make([]terminalTextToken, 0, len(lines)*2) + for _, line := range lines { + tokens = append(tokens, terminalTextToken{kind: terminalTokenLabel, text: line.Label}) + if line.Value == "" { + tokens = append(tokens, terminalTextToken{kind: terminalTokenPlain, text: "\n"}) + continue + } + tokens = append(tokens, terminalTextToken{kind: terminalTokenPlain, text: ": " + line.Value + "\n"}) + } + return newTerminalText(tokens...) +} diff --git a/internal/app/adoption_front_door_command_test.go b/internal/app/adoption_front_door_command_test.go new file mode 100644 index 0000000..2d6e213 --- /dev/null +++ b/internal/app/adoption_front_door_command_test.go @@ -0,0 +1,155 @@ +package app + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/research-engineering/agentic-proofkit/internal/command/adoptionplan" + "github.com/research-engineering/agentic-proofkit/internal/command/repositoryinventory" + "github.com/research-engineering/agentic-proofkit/internal/testsupport/commandcoverage" +) + +func TestAdoptionFrontDoorCLI(t *testing.T) { + commandcoverage.SemanticRoute(t, "proofkit.command_coverage.source_oracle.v1.090463125918686417415789408747385969243450426928488527569779502859159168077392") + commandcoverage.SemanticRoute(t, "proofkit.command_coverage.source_oracle.v1.018641750690260403629185124984627586551707121364144838147335227432629735564274") + + repositoryRoot := t.TempDir() + writeAdoptionFixture(t, repositoryRoot, "README.md", "# Pilot\n") + writeAdoptionFixture(t, repositoryRoot, "pyproject.toml", "[project]\nname = \"pilot\"\n") + writeAdoptionFixture(t, repositoryRoot, "private-notes.txt", "opaque\n") + + t.Run("public route and owner-closed JSON", func(t *testing.T) { + for _, item := range []struct { + mode string + stack string + wantTrust string + wantDeclared bool + wantCapabilityMap any + }{ + {mode: adoptionplan.IntentFresh, wantTrust: "owner_intent_required"}, + {mode: adoptionplan.IntentCodeBaseline, stack: "python_service", wantTrust: "caller_declared_code_baseline", wantDeclared: true, wantCapabilityMap: "code_baseline"}, + {mode: adoptionplan.IntentAuditFromCode, wantTrust: "untrusted_code_observation", wantCapabilityMap: "audit_from_code"}, + } { + t.Run(item.mode, func(t *testing.T) { + args := []string{"adopt", "plan", "--mode", item.mode, "--repo-root", repositoryRoot} + if item.stack != "" { + args = append(args, "--stack", item.stack) + } + status, stdout, stderr := executeAgentWorkflowCLI(t, args, panicReader{}, PresentationCapabilities{}) + if status != 0 || stderr != "" || strings.Contains(stdout, "\x1b[") { + t.Fatalf("status=%d stderr=%q stdout=%q", status, stderr, stdout) + } + raw := decodeCLIJSON(t, stdout) + plan, err := adoptionplan.AdmitOutput(raw) + if err != nil { + t.Fatalf("adoptionplan.AdmitOutput() error = %v", err) + } + if plan.Intent != item.mode || plan.TrustDeclaration.Class != item.wantTrust { + t.Fatalf("intent/trust = %s/%s", plan.Intent, plan.TrustDeclaration.Class) + } + record := raw.(map[string]any) + summary := record["summary"].(map[string]any) + if summary["codeBaselineDeclared"] != item.wantDeclared { + t.Fatalf("code baseline declaration=%v want %v", summary["codeBaselineDeclared"], item.wantDeclared) + } + trust := record["sourceTrust"].(map[string]any) + if trust["capabilityMapTrustMode"] != item.wantCapabilityMap { + t.Fatalf("capabilityMapTrustMode=%v want %v", trust["capabilityMapTrustMode"], item.wantCapabilityMap) + } + if strings.Contains(stdout, repositoryRoot) || strings.Contains(stdout, "private-notes.txt") { + t.Fatal("adoption plan disclosed repository root or an unknown entry name") + } + }) + } + }) + + t.Run("inventory route", func(t *testing.T) { + status, stdout, stderr := executeAgentWorkflowCLI(t, []string{"repository-inventory", "--repo-root", repositoryRoot}, panicReader{}, PresentationCapabilities{}) + if status != 0 || stderr != "" { + t.Fatalf("status=%d stderr=%q stdout=%q", status, stderr, stdout) + } + inventory, err := repositoryinventory.AdmitOutput(decodeCLIJSON(t, stdout)) + if err != nil { + t.Fatalf("repositoryinventory.AdmitOutput() error = %v", err) + } + if len(inventory.Entries) != 2 || inventory.Omissions.UnrecognizedCount != 1 { + t.Fatalf("unexpected inventory: %#v", inventory) + } + if strings.Contains(stdout, repositoryRoot) || strings.Contains(stdout, "private-notes.txt") { + t.Fatal("repository inventory disclosed repository root or an unknown entry name") + } + }) + + t.Run("JSON layouts preserve value", func(t *testing.T) { + baseArgs := []string{"adopt", "plan", "--mode", "fresh", "--repo-root", repositoryRoot} + prettyStatus, pretty, prettyErr := executeAgentWorkflowCLI(t, baseArgs, panicReader{}, PresentationCapabilities{}) + compactStatus, compact, compactErr := executeAgentWorkflowCLI(t, append([]string{"--json-layout", "compact"}, baseArgs...), panicReader{}, PresentationCapabilities{}) + if prettyStatus != 0 || compactStatus != 0 || prettyErr != "" || compactErr != "" { + t.Fatalf("pretty=%d/%q compact=%d/%q", prettyStatus, prettyErr, compactStatus, compactErr) + } + if !equalCLIJSON(t, decodeCLIJSON(t, pretty), decodeCLIJSON(t, compact)) || strings.Contains(compact, "\n ") { + t.Fatal("JSON layout changed adoption-plan value or retained indentation") + } + }) + + t.Run("text and color are derived", func(t *testing.T) { + args := []string{"adopt", "plan", "--mode", "fresh", "--repo-root", repositoryRoot, "--format", "text"} + status, plain, stderr := executeAgentWorkflowCLI(t, args, panicReader{}, PresentationCapabilities{StdoutIsTTY: true}) + if status != 0 || stderr != "" || strings.Contains(plain, "\x1b[") { + t.Fatalf("plain status=%d stderr=%q stdout=%q", status, stderr, plain) + } + colorArgs := append(cloneStrings(args), "--color", "auto") + status, colored, stderr := executeAgentWorkflowCLI(t, colorArgs, panicReader{}, PresentationCapabilities{StdoutIsTTY: true}) + if status != 0 || stderr != "" || !strings.Contains(colored, "\x1b[") { + t.Fatalf("colored status=%d stderr=%q stdout=%q", status, stderr, colored) + } + status, disabled, stderr := executeAgentWorkflowCLI(t, colorArgs, panicReader{}, PresentationCapabilities{StdoutIsTTY: true, NoColorPresent: true}) + if status != 0 || stderr != "" || disabled != plain { + t.Fatalf("NO_COLOR status=%d stderr=%q stdout=%q want=%q", status, stderr, disabled, plain) + } + status, nonTTY, stderr := executeAgentWorkflowCLI(t, colorArgs, panicReader{}, PresentationCapabilities{}) + if status != 0 || stderr != "" || nonTTY != plain { + t.Fatalf("non-TTY status=%d stderr=%q stdout=%q want=%q", status, stderr, nonTTY, plain) + } + }) + + t.Run("argument admission precedes scanning", func(t *testing.T) { + missingRoot := filepath.Join(t.TempDir(), "unavailable") + for _, item := range []struct { + args []string + want string + }{ + {args: []string{"adopt", "plan", "--mode", "unknown", "--repo-root", missingRoot}, want: "--mode requires one of"}, + {args: []string{"adopt", "plan", "--mode", "fresh", "--repo-root", missingRoot, "--stack", "unknown"}, want: "--stack requires one of"}, + {args: []string{"adopt", "plan", "--mode", "fresh", "--repo-root", missingRoot, "--color", "never"}, want: "adopt plan --color requires --format text"}, + {args: []string{"adopt-plan", "--mode", "fresh", "--repo-root", repositoryRoot}, want: "unsupported command: adopt-plan"}, + } { + status, stdout, stderr := executeAgentWorkflowCLI(t, item.args, panicReader{}, PresentationCapabilities{}) + if status != 1 || stdout != "" || !strings.Contains(stderr, item.want) || strings.Contains(stderr, "repository root") { + t.Fatalf("args=%v status=%d stdout=%q stderr=%q want=%q", item.args, status, stdout, stderr, item.want) + } + } + }) + + t.Run("route-aware help", func(t *testing.T) { + for _, args := range [][]string{{"adopt", "plan", "--help"}, {"adopt", "plan", "-h"}, {"help", "adopt", "plan"}} { + status, stdout, stderr := executeAgentWorkflowCLI(t, args, panicReader{}, PresentationCapabilities{}) + if status != 0 || stderr != "" || !strings.Contains(stdout, "agentic-proofkit adopt plan") || !strings.Contains(stdout, "Command ID:\n adopt-plan") { + t.Fatalf("args=%v status=%d stderr=%q stdout=%q", args, status, stderr, stdout) + } + } + status, stdout, stderr := executeAgentWorkflowCLI(t, []string{"help", "adopt"}, panicReader{}, PresentationCapabilities{}) + if status != 1 || stdout != "" || stderr != "unsupported help target: adopt\n" { + t.Fatalf("abbreviated help status=%d stderr=%q stdout=%q", status, stderr, stdout) + } + }) +} + +func writeAdoptionFixture(t *testing.T, root, name, content string) { + t.Helper() + if err := os.WriteFile(filepath.Join(root, name), []byte(content), 0o600); err != nil { + t.Fatalf("write %s: %v", name, err) + } +} diff --git a/internal/app/adoption_front_door_version_edge_test.go b/internal/app/adoption_front_door_version_edge_test.go new file mode 100644 index 0000000..8f16233 --- /dev/null +++ b/internal/app/adoption_front_door_version_edge_test.go @@ -0,0 +1,242 @@ +package app + +import ( + "bytes" + "crypto/sha256" + "encoding/json" + "fmt" + "os" + "path/filepath" + "slices" + "strings" + "testing" + + "github.com/research-engineering/agentic-proofkit/internal/command/jsonreportcliadaptersource" + "github.com/research-engineering/agentic-proofkit/internal/kernel/admission" + "github.com/research-engineering/agentic-proofkit/internal/tools/releasechange" +) + +const adoptionFrontDoorVersionEdgePath = "internal/app/testdata/v0.7-wire-observations.json" + +type adoptionFrontDoorVersionEdge struct { + AddedCommandContracts []adoptionFrontDoorCommandContract `json:"addedCommandContracts"` + AdditionChangeIDs []string `json:"additionChangeIds"` + BreakingChangeIDs []string `json:"breakingChangeIds"` + ChangeRecordRef string `json:"changeRecordRef"` + ChangeRecordSHA256 string `json:"changeRecordSha256"` + ChangedGeneratedArtifacts []adoptionChangedGeneratedArtifact `json:"changedGeneratedArtifacts"` + CurrentPublicABISHA256 string `json:"currentPublicAbiSha256"` + EdgeID string `json:"edgeId"` + EvidenceClass string `json:"evidenceClass"` + NonClaims []string `json:"nonClaims"` + PreviousPublicABISHA256 string `json:"previousPublicAbiSha256"` + PreviousVersion string `json:"previousVersion"` + RemovedCommandContract adoptionRemovedCommandContract `json:"removedCommandContract"` + SchemaVersion int `json:"schemaVersion"` + Version string `json:"version"` +} + +type adoptionFrontDoorCommandContract struct { + Command string `json:"command"` + OutputContractSHA256 string `json:"outputContractSha256"` + Route []string `json:"route"` +} + +type adoptionRemovedCommandContract struct { + Command string `json:"command"` + DefaultInvocationPreset string `json:"defaultInvocationPreset"` + OutputContractSHA256 string `json:"outputContractSha256"` +} + +type adoptionChangedGeneratedArtifact struct { + ArtifactKind string `json:"artifactKind"` + CurrentSourceSHA256 string `json:"currentSourceSha256"` + GeneratorID string `json:"generatorId"` + PreviousSourceSHA256 string `json:"previousSourceSha256"` +} + +func TestAdoptionFrontDoorVersionEdgeClosesInitRetirement(t *testing.T) { + record := readAdoptionFrontDoorVersionEdge(t) + if err := validateAdoptionFrontDoorVersionEdge(record, repoRoot(t)); err != nil { + t.Fatal(err) + } + + mutants := []func(*adoptionFrontDoorVersionEdge){ + func(value *adoptionFrontDoorVersionEdge) { value.CurrentPublicABISHA256 += "0" }, + func(value *adoptionFrontDoorVersionEdge) { + value.PreviousPublicABISHA256 = value.CurrentPublicABISHA256 + }, + func(value *adoptionFrontDoorVersionEdge) { value.RemovedCommandContract.Command = "help" }, + func(value *adoptionFrontDoorVersionEdge) { + value.AddedCommandContracts[0].Route = []string{"adopt-plan"} + }, + func(value *adoptionFrontDoorVersionEdge) { + value.AddedCommandContracts = value.AddedCommandContracts[1:] + }, + func(value *adoptionFrontDoorVersionEdge) { value.BreakingChangeIDs[0] += ".drift" }, + func(value *adoptionFrontDoorVersionEdge) { value.AdditionChangeIDs = value.AdditionChangeIDs[1:] }, + func(value *adoptionFrontDoorVersionEdge) { value.ChangeRecordSHA256 += "0" }, + func(value *adoptionFrontDoorVersionEdge) { + value.ChangedGeneratedArtifacts[0].PreviousSourceSHA256 = value.ChangedGeneratedArtifacts[0].CurrentSourceSHA256 + }, + func(value *adoptionFrontDoorVersionEdge) { + value.RemovedCommandContract.DefaultInvocationPreset = "fresh" + }, + } + for index, mutate := range mutants { + t.Run(fmt.Sprintf("mutant-%d", index), func(t *testing.T) { + value := cloneAdoptionFrontDoorVersionEdge(record) + mutate(&value) + if err := validateAdoptionFrontDoorVersionEdge(value, repoRoot(t)); err == nil { + t.Fatal("version-edge mutant was admitted") + } + }) + } +} + +func TestRetiredInitRouteHasNoPublicDispatcher(t *testing.T) { + status, stdout, stderr := executeAgentWorkflowCLI(t, []string{"init"}, panicReader{}, PresentationCapabilities{}) + if status != 1 || stdout != "" || !strings.Contains(stderr, "unsupported command: init") { + t.Fatalf("init status/stdout/stderr = %d/%q/%q", status, stdout, stderr) + } + if _, ok := commandDescriptorFor("init"); ok { + t.Fatal("init remains in the command descriptor registry") + } +} + +func readAdoptionFrontDoorVersionEdge(t *testing.T) adoptionFrontDoorVersionEdge { + t.Helper() + content, err := os.ReadFile(filepath.Join(repoRoot(t), adoptionFrontDoorVersionEdgePath)) + if err != nil { + t.Fatal(err) + } + value, err := admission.DecodeJSON(bytes.NewReader(content), int64(len(content))) + if err != nil { + t.Fatal(err) + } + root, ok := value.(map[string]any) + if !ok { + t.Fatal("adoption front-door version edge must be an object") + } + assertExactObjectKeys(t, root, []string{"addedCommandContracts", "additionChangeIds", "breakingChangeIds", "changeRecordRef", "changeRecordSha256", "changedGeneratedArtifacts", "currentPublicAbiSha256", "edgeId", "evidenceClass", "nonClaims", "previousPublicAbiSha256", "previousVersion", "removedCommandContract", "schemaVersion", "version"}, "adoption front-door version edge") + removed, ok := root["removedCommandContract"].(map[string]any) + if !ok { + t.Fatal("removed command contract must be an object") + } + assertExactObjectKeys(t, removed, []string{"command", "defaultInvocationPreset", "outputContractSha256"}, "removed command contract") + added, ok := root["addedCommandContracts"].([]any) + if !ok { + t.Fatal("added command contracts must be an array") + } + for index, raw := range added { + item, ok := raw.(map[string]any) + if !ok { + t.Fatalf("added command contract %d must be an object", index) + } + assertExactObjectKeys(t, item, []string{"command", "outputContractSha256", "route"}, fmt.Sprintf("added command contract %d", index)) + } + changedArtifacts, ok := root["changedGeneratedArtifacts"].([]any) + if !ok { + t.Fatal("changed generated artifacts must be an array") + } + for index, raw := range changedArtifacts { + item, ok := raw.(map[string]any) + if !ok { + t.Fatalf("changed generated artifact %d must be an object", index) + } + assertExactObjectKeys(t, item, []string{"artifactKind", "currentSourceSha256", "generatorId", "previousSourceSha256"}, fmt.Sprintf("changed generated artifact %d", index)) + } + var record adoptionFrontDoorVersionEdge + if err := json.Unmarshal(content, &record); err != nil { + t.Fatal(err) + } + return record +} + +func validateAdoptionFrontDoorVersionEdge(record adoptionFrontDoorVersionEdge, root string) error { + if record.SchemaVersion != 1 || record.EdgeID != "proofkit.public-wire.0.6.0-to-0.7.0" || record.EvidenceClass != "owner_authored_frozen_version_edge_observation" { + return fmt.Errorf("adoption front-door version-edge identity is invalid") + } + if record.PreviousVersion != "0.6.0" || record.Version != "0.7.0" { + return fmt.Errorf("adoption front-door version-edge release identity is stale") + } + if record.PreviousPublicABISHA256 != "sha256:163f06bf6fc94f15040fecf3e352d4600a8611a227e26f35369b7fe97e90bde5" || record.CurrentPublicABISHA256 != "sha256:7b36077db9c82ca005606f571e1f6c6e208ab5b35b95b4d9b885e6047f091b91" || record.PreviousPublicABISHA256 == record.CurrentPublicABISHA256 { + return fmt.Errorf("adoption front-door version-edge ABI identity is invalid") + } + wantRemoved := adoptionRemovedCommandContract{Command: "init", DefaultInvocationPreset: "all", OutputContractSHA256: "sha256:3e59a3002327c759e5e747f8baacaa63a4d6784e1a1c520f0a54e01af3f2faa0"} + if record.RemovedCommandContract != wantRemoved { + return fmt.Errorf("adoption front-door removed command contract is not exact") + } + wantAdded := []adoptionFrontDoorCommandContract{ + {Command: "adopt-plan", Route: []string{"adopt", "plan"}, OutputContractSHA256: "sha256:55f0852120ca978b9557553b1af8923a4d0b08c4dc794ba7751cb28d2414b7ec"}, + {Command: "repository-inventory", Route: []string{"repository-inventory"}, OutputContractSHA256: "sha256:5b0967fefb4abbfbc91027ff30ed6579c12e1f27d8310466cd4fd4d1f865552e"}, + } + if !slices.EqualFunc(record.AddedCommandContracts, wantAdded, equalAdoptionCommandContract) { + return fmt.Errorf("adoption front-door added command contracts are not exact") + } + wantGeneratedArtifacts := []adoptionChangedGeneratedArtifact{{ + ArtifactKind: "proofkit.json-report-cli-adapter-source", + CurrentSourceSHA256: "sha256:329b88b6b134dc30fb3704d32ac9708fc01608b9df68815bc9585108971be37d", + GeneratorID: jsonreportcliadaptersource.TypeScriptGeneratorID, + PreviousSourceSHA256: "sha256:a171cc1b95c6078b7190ac50fc9fd298db8f42bfc9b65bbb67fa77d63dc04a93", + }} + if !slices.Equal(record.ChangedGeneratedArtifacts, wantGeneratedArtifacts) { + return fmt.Errorf("adoption front-door changed generated artifacts are not exact") + } + currentSourceDigest := sha256.Sum256([]byte(jsonreportcliadaptersource.TypeScriptSource())) + if record.ChangedGeneratedArtifacts[0].CurrentSourceSHA256 != fmt.Sprintf("sha256:%x", currentSourceDigest) { + return fmt.Errorf("adoption front-door generated adapter source identity is stale") + } + if !slices.Equal(record.BreakingChangeIDs, []string{"proofkit.adoption.init-retired"}) || !slices.Equal(record.AdditionChangeIDs, []string{"proofkit.adoption.front-door", "proofkit.adoption.repository-inventory", "proofkit.cli.generated-adapter-command-routes", "proofkit.cli.hierarchical-command-routes", "proofkit.python-wheel.embedded-cli-contract"}) { + return fmt.Errorf("adoption front-door change inventory is not exact") + } + if record.ChangeRecordRef != "release/change-record.v2.json" { + return fmt.Errorf("adoption front-door change record reference is not exact") + } + changeRecordContent, err := os.ReadFile(filepath.Join(root, filepath.FromSlash(record.ChangeRecordRef))) + if err != nil { + return fmt.Errorf("read adoption front-door change record: %w", err) + } + digest := sha256.Sum256(changeRecordContent) + if record.ChangeRecordSHA256 != fmt.Sprintf("sha256:%x", digest) { + return fmt.Errorf("adoption front-door change record digest is not exact") + } + changeRecord, err := releasechange.Read(filepath.Join(root, filepath.FromSlash(record.ChangeRecordRef))) + if err != nil { + return fmt.Errorf("admit adoption front-door change record: %w", err) + } + if changeRecord.PreviousVersion != record.PreviousVersion || changeRecord.Version != record.Version || !changeRecord.Migration.Required { + return fmt.Errorf("adoption front-door change record identity is inconsistent") + } + wantMigrationSteps := []string{ + "Replace explicit init --preset fresh with adopt plan --mode fresh --repo-root .", + "Replace init --preset code-baseline with adopt plan --mode code-baseline --repo-root , and replace init --preset code-audit with adopt plan --mode audit-from-code --repo-root .", + "Replace init --preset legacy with migration-parity-admission followed by migration-plan over explicit caller-owned records; run requirement-source-transition when the migration changes requirement lifecycle state.", + "Replace init --preset change-set with changed-path-set followed by the explicit impact and selective-gate composition routes required by the consuming repository.", + "Replace bare init or init --preset all with help families, then select the smallest applicable bounded route rather than materializing every route family.", + "Regenerate any materialized TypeScript CLI adapter source before invoking a multi-token route such as adopt plan; one-token adapter calls remain compatible.", + } + if !slices.Equal(changeRecord.Migration.Steps, wantMigrationSteps) { + return fmt.Errorf("adoption front-door migration semantics are not exact") + } + if !slices.Equal(record.NonClaims, []string{"This owner-authored version-edge observation binds reviewed public contract identities; it does not authenticate Git history, registry publication, provider ingestion, native witness truth, rollout, or production readiness."}) { + return fmt.Errorf("adoption front-door version-edge non-claims are not exact") + } + return nil +} + +func equalAdoptionCommandContract(left, right adoptionFrontDoorCommandContract) bool { + return left.Command == right.Command && left.OutputContractSHA256 == right.OutputContractSHA256 && slices.Equal(left.Route, right.Route) +} + +func cloneAdoptionFrontDoorVersionEdge(record adoptionFrontDoorVersionEdge) adoptionFrontDoorVersionEdge { + record.AddedCommandContracts = append([]adoptionFrontDoorCommandContract(nil), record.AddedCommandContracts...) + for index := range record.AddedCommandContracts { + record.AddedCommandContracts[index].Route = append([]string(nil), record.AddedCommandContracts[index].Route...) + } + record.AdditionChangeIDs = append([]string(nil), record.AdditionChangeIDs...) + record.BreakingChangeIDs = append([]string(nil), record.BreakingChangeIDs...) + record.ChangedGeneratedArtifacts = append([]adoptionChangedGeneratedArtifact(nil), record.ChangedGeneratedArtifacts...) + record.NonClaims = append([]string(nil), record.NonClaims...) + return record +} diff --git a/internal/app/agent_route_version_edge_test.go b/internal/app/agent_route_version_edge_test.go index 837c95e..4000b08 100644 --- a/internal/app/agent_route_version_edge_test.go +++ b/internal/app/agent_route_version_edge_test.go @@ -107,24 +107,20 @@ func validateAgentRouteVersionEdge(record agentRouteVersionEdge, root string) er if record.PreviousVersion != "0.5.1" || record.Version != "0.6.0" { return fmt.Errorf("agent-route version-edge release identity is stale") } - if record.PreviousPublicABISHA256 != "sha256:9ecd2c3d2f3f360088409f7e91cce406fc1d1d6edda1b404fce119985c4fb623" || record.CurrentPublicABISHA256 != "sha256:"+cliContractPublicABISHA256 || record.PreviousPublicABISHA256 == record.CurrentPublicABISHA256 { + if record.PreviousPublicABISHA256 != "sha256:9ecd2c3d2f3f360088409f7e91cce406fc1d1d6edda1b404fce119985c4fb623" || record.CurrentPublicABISHA256 != "sha256:163f06bf6fc94f15040fecf3e352d4600a8611a227e26f35369b7fe97e90bde5" || record.PreviousPublicABISHA256 == record.CurrentPublicABISHA256 { return fmt.Errorf("agent-route version-edge ABI identity is invalid") } - currentMetadata, ok := generatedCommandContractMetadataByName["agent-route"] - if !ok { - return fmt.Errorf("agent-route version-edge current command metadata is missing") - } wantContract := agentRouteChangedCommandContract{ Command: "agent-route", PreviousInputContractSHA256: "sha256:6b5af8287f2972bbef4c68c247f43fb16d0f0d8739e5e6d3a66543af20d2644d", - CurrentInputContractSHA256: currentMetadata.InputContractSHA256, + CurrentInputContractSHA256: "sha256:4fc7b2e5ffe3ed632e5e84d20e5ae26f9ace11df614bc9aec680853e60809ebd", PreviousOutputContractSHA256: "sha256:44ec313a43360b6138ad6c3ae5de4abd51bbf312060880c108a6351606695915", - CurrentOutputContractSHA256: currentMetadata.OutputContractSHA256, + CurrentOutputContractSHA256: "sha256:485d62afc2e5ed07c28f557b0d1069f167b3838abe0aed248e9ff94f3e25c0ad", } if record.ChangedCommandContract != wantContract { return fmt.Errorf("agent-route version-edge changed command contract is not exact") } - if record.ChangeRecordRef != "release/change-record.v2.json" { + if record.ChangeRecordRef != "internal/app/testdata/v0.6-release-change-record.v2.json" { return fmt.Errorf("agent-route version-edge change record reference is not exact") } changeRecordPath := filepath.Join(root, filepath.FromSlash(record.ChangeRecordRef)) diff --git a/internal/app/app.go b/internal/app/app.go index 0bb14e1..5e24d89 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "io" + "strings" "github.com/research-engineering/agentic-proofkit/internal/command/adoptiondoctor" "github.com/research-engineering/agentic-proofkit/internal/command/adoptionworkflow" @@ -40,11 +41,12 @@ func RunWithRendererAndCapabilities(ctx context.Context, args []string, stdin io } return writeText(usageWithRenderer(renderer), 0, nil, stdout, stderr) } - descriptor, ok := commandDescriptorFor(args[0]) + descriptor, consumed, ok := commandDescriptorForRoute(args) if !ok { writeDiagnosticf(stderr, "unsupported command: %s", args[0]) return 1 } + args = append([]string{descriptor.name}, args[consumed:]...) if isCommandHelpRequest(args) { if layoutExplicit { writeDiagnosticf(stderr, "--json-layout is valid only for JSON command output") @@ -91,10 +93,14 @@ func RunWithRendererAndCapabilities(ctx context.Context, args []string, stdin io } return writeText(output, 0, nil, stdout, stderr) } - if len(args) == 2 && args[1] != "--help" && args[1] != "-h" { - target, targetOK := commandDescriptorFor(args[1]) + if len(args) >= 2 && strings.HasPrefix(args[1], "-") && args[1] != "--help" && args[1] != "-h" { + writeDiagnosticf(stderr, "help supports only --help or -h") + return 1 + } + if len(args) >= 2 && args[1] != "--help" && args[1] != "-h" { + target, targetOK := commandDescriptorForHelpTarget(args[1:]) if !targetOK { - writeDiagnosticf(stderr, "unsupported help target: %s", args[1]) + writeDiagnosticf(stderr, "unsupported help target: %s", commandRouteText(args[1:])) return 1 } return writeText(commandUsageWithRenderer(target, renderer), 0, nil, stdout, stderr) @@ -104,14 +110,8 @@ func RunWithRendererAndCapabilities(ctx context.Context, args []string, stdin io return 1 } return writeText(usageWithRenderer(renderer), 0, nil, stdout, stderr) - case commandRunnerInit: - preset, err := parseInitArgs(args[1:]) - if err != nil { - writeDiagnostic(stderr, err) - return 1 - } - record, err := buildInitReport(preset) - return writeJSON(record.JSONValue(), 0, err, stdout, stderr) + case commandRunnerAdoptionFrontDoor: + return runAdoptionFrontDoor(ctx, descriptor.name, args[1:], stdout, stderr, capabilities) case commandRunnerAdoptionDoctor: return runAgentEnvelopeCommand(args[0], args[1:], stdin, stdout, stderr, agentEnvelopeBuilders{ build: adoptiondoctor.Build, diff --git a/internal/app/app_test.go b/internal/app/app_test.go index 4636169..6704205 100644 --- a/internal/app/app_test.go +++ b/internal/app/app_test.go @@ -136,7 +136,8 @@ func TestJSONLayoutRejectsNonJSONAndNonLeadingUseBeforeInput(t *testing.T) { func TestJSONLayoutRejectsEveryTextCommandHelpForm(t *testing.T) { for _, descriptor := range commandDescriptors { for _, helpFlag := range []string{"--help", "-h"} { - args := []string{"--json-layout", "compact", descriptor.name, helpFlag} + args := append([]string{"--json-layout", "compact"}, descriptor.routeTokens...) + args = append(args, helpFlag) t.Run(descriptor.name+"/"+helpFlag, func(t *testing.T) { var stdout bytes.Buffer var stderr bytes.Buffer diff --git a/internal/app/cli_abi_test.go b/internal/app/cli_abi_test.go index c7f9397..1322dbf 100644 --- a/internal/app/cli_abi_test.go +++ b/internal/app/cli_abi_test.go @@ -101,26 +101,6 @@ func TestCLIABIGoldenCorpus(t *testing.T) { " }\n" + "}\n", }, - { - name: "init emits dry run route guidance without reading stdin", - args: []string{"init", "--preset", "fresh"}, - stdin: `{"bad":`, - wantStatus: 0, - wantStdoutJSON: true, - wantStdoutHas: []string{ - `"reportKind": "proofkit.init"`, - `"selectedPreset": "fresh"`, - `"dryRunOnly": true`, - }, - wantStdoutNotHas: []string{"bad"}, - }, - { - name: "init rejects unsupported input flags", - args: []string{"init", "--input", "-"}, - stdin: `{}`, - wantStatus: 1, - wantStderrHas: []string{"unsupported argument for init: --input"}, - }, { name: "secret scan emits failed report without leaking secret", args: []string{"secret-scan", "--input", "-"}, diff --git a/internal/app/cli_contract_test.go b/internal/app/cli_contract_test.go index 26d1d71..99a5369 100644 --- a/internal/app/cli_contract_test.go +++ b/internal/app/cli_contract_test.go @@ -23,7 +23,7 @@ import ( ) const ( - cliContractPublicABISHA256 = "163f06bf6fc94f15040fecf3e352d4600a8611a227e26f35369b7fe97e90bde5" + cliContractPublicABISHA256 = "7b36077db9c82ca005606f571e1f6c6e208ab5b35b95b4d9b885e6047f091b91" maxAggregateFileReadBytesForContractTest = 64 << 20 maxPackageManifestBytesForContractTest = 256 << 10 maxSourceFileBytesForContractTest = 8 << 20 @@ -58,6 +58,9 @@ func TestCLIContractMatchesDispatcherAndHelp(t *testing.T) { if string(descriptor.scopeClass) != command.ScopeClass { t.Fatalf("%s descriptor scopeClass=%s contract=%s", command.Command, descriptor.scopeClass, command.ScopeClass) } + if !slices.Equal(descriptor.routeTokens, effectiveContractRoute(command)) { + t.Fatalf("%s descriptor route=%v contract route=%v", command.Command, descriptor.routeTokens, effectiveContractRoute(command)) + } assertStringSet(t, descriptor.allowedFlags, command.AllowedFlags, command.Command+" descriptor flags") assertStringSet(t, descriptor.outputModes, command.OutputModes, command.Command+" descriptor output modes") if descriptor.agentEnvelope != (command.AgentEnvelope != nil && *command.AgentEnvelope) { @@ -106,7 +109,8 @@ func TestCLIContractMatchesDispatcherAndHelp(t *testing.T) { if command.Command == "help" { continue } - line := helpLineForCommand(help, command.Command) + route := commandRouteText(effectiveContractRoute(command)) + line := helpLineForCommand(help, route) if line == "" { t.Fatalf("help output does not route command %s", command.Command) } @@ -124,8 +128,10 @@ func TestCLIContractMatchesDispatcherAndHelp(t *testing.T) { } } for command := range contractCommands { - if !strings.Contains(help, "agentic-proofkit "+command) && command != "help" { - t.Fatalf("help output does not route command %s", command) + contractCommand := commandByContractID(contract.Commands, command) + route := commandRouteText(effectiveContractRoute(contractCommand)) + if !strings.Contains(help, "agentic-proofkit "+route) && command != "help" { + t.Fatalf("help output does not route command %s through %s", command, route) } } } @@ -807,6 +813,7 @@ func TestCLIContractPublicABIGoldenStable(t *testing.T) { "input": command.Input, "inputPointer": command.InputPointer, "outputModes": stringsAsAny(command.OutputModes), + "route": stringsAsAny(effectiveContractRoute(command)), "requiredFlags": stringsAsAny(command.RequiredFlags), "scopeClass": command.ScopeClass, "singleOccurrenceFlags": stringsAsAny(command.SingleOccurrenceFlags), @@ -1135,6 +1142,9 @@ func commandDescriptorContractParityProblems(descriptors []commandDescriptor, co if string(descriptor.input) != command.Input { problems = append(problems, "input drift "+name) } + if !slices.Equal(descriptor.routeTokens, effectiveContractRoute(command)) { + problems = append(problems, "route drift "+name) + } if !equalStringSets(descriptor.allowedFlags, command.AllowedFlags) { problems = append(problems, "flag drift "+name) } @@ -1475,12 +1485,14 @@ func TestDescriptorFlagConstraintsMatchCommandParsers(t *testing.T) { func TestDescriptorFlagConstraintsAreRenderedTruthfully(t *testing.T) { expectedConstrainedUsage := map[string]string{ + "adopt-plan": "agentic-proofkit adopt plan [--color ] [--format ] --mode --repo-root [--stack ]", "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 ] [--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 )", + "repository-inventory": "agentic-proofkit repository-inventory --repo-root ", "stack-preset": "agentic-proofkit stack-preset --preset ", "typescript-public-api-surfaces": "agentic-proofkit typescript-public-api-surfaces --input [--input-pointer ] --repo-root ", } @@ -1639,6 +1651,7 @@ type cliContractCommand struct { OutputContract any `json:"outputContract,omitempty"` OutputModes []string `json:"outputModes"` RequiredFlags []string `json:"requiredFlags,omitempty"` + Route []string `json:"route,omitempty"` ScopeClass string `json:"scopeClass"` SingleOccurrenceFlags []string `json:"singleOccurrenceFlags,omitempty"` Stdin bool `json:"stdin"` @@ -1697,7 +1710,7 @@ func assertCLIContractSchema(t *testing.T) { assertStringSet(t, stringsFromAny(helpGrammar["rootHelpFlags"].([]any)), []string{"--help", "-h"}, "root help flags") assertStringSet(t, stringsFromAny(helpGrammar["commandHelpFlags"].([]any)), []string{"--help", "-h"}, "command help flags") if helpGrammar["commandHelpExclusive"] != true || - helpGrammar["helpCommandPositionalTarget"] != "optional_supported_command" || + helpGrammar["helpCommandPositionalTarget"] != "optional_supported_command_route" || helpGrammar["helpCatalogFormsSource"] != "proofkit/command-families.v1.json" || helpGrammar["helpReadsCommandInput"] != false { t.Fatalf("CLI help grammar does not describe runtime help routing: %#v", helpGrammar) @@ -1730,6 +1743,7 @@ func assertCLIContractSchema(t *testing.T) { "outputContract": {}, "outputModes": {}, "requiredFlags": {}, + "route": {}, "scopeClass": {}, "singleOccurrenceFlags": {}, "stdin": {}, @@ -1833,6 +1847,22 @@ func assertCLIContractSchema(t *testing.T) { } } +func effectiveContractRoute(command cliContractCommand) []string { + if len(command.Route) == 0 { + return []string{command.Command} + } + return append([]string(nil), command.Route...) +} + +func commandByContractID(commands []cliContractCommand, commandID string) cliContractCommand { + for _, command := range commands { + if command.Command == commandID { + return command + } + } + panic("unknown CLI contract command: " + commandID) +} + func stringMatrixAsAny(values [][]string) []any { out := make([]any, 0, len(values)) for _, value := range values { diff --git a/internal/app/command_contract_generated.go b/internal/app/command_contract_generated.go index d4f23e0..5fc3d87 100644 --- a/internal/app/command_contract_generated.go +++ b/internal/app/command_contract_generated.go @@ -1,94 +1,96 @@ // Code generated by internal/tools/commandcontractgen; DO NOT EDIT. package app -const commandContractSourceSHA256 = "ca3151470f502bf1c00d33c92b9d4bbce29805612342787aa89de10d36c7ad89" +const commandContractSourceSHA256 = "859296d6fcce46b0d91d56631b49a1978191479e49fafd8d2ccde8f62fda4e76" type generatedCommandContractMetadata struct { InputContractSHA256 string InputSchemaSummary []string OutputContractSHA256 string FlagChoices map[string][]string + RouteTokens []string } 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:c310214676ff4b6f536a5bc9d687f681a7e71f73d7a03ac932707d8cd3905cdf", InputSchemaSummary: []string{"schemaVersion=2", "root-shape-only definition proofkit.adoption-contract-envelope.input.v2.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:3efb2c5161fee16fd8ac6a40dcb6d9c41fbc23e468f60621436ae9e8076e0950", FlagChoices: map[string][]string{}}, - "adoption-doctor": {InputContractSHA256: "sha256:efa9acfe32bff07f56d9dc9902530df2979794289bc2f7f547f7a108a7dd0f35", 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:8fdfc6608f197e633f042f20031ae1014872a90aa3daa66885ffcaddca994766", 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:4fc7b2e5ffe3ed632e5e84d20e5ae26f9ace11df614bc9aec680853e60809ebd", 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:485d62afc2e5ed07c28f557b0d1069f167b3838abe0aed248e9ff94f3e25c0ad", 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{}}, - "branch-authority": {InputContractSHA256: "sha256:8a3ed74978898593fbdbf1f7fa684dae450fbd9019edcd60d07f818d63363ed4", InputSchemaSummary: []string{"schemaVersion=1", "root-shape-only definition proofkit.branch-authority.input.v1.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:3c7dc74842299b92cd5baf57cc8666e9415963091359e5faf654e28da89561f1", FlagChoices: map[string][]string{}}, - "capability-map-admission": {InputContractSHA256: "sha256:efe87497cf6a32cfd1f9c9c191393cac8781d1e9211914a8a7b6aaa44e7f371d", InputSchemaSummary: []string{"schemaVersion=1", "root-shape-only definition proofkit.capability-map-admission.input.v1.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:bfb32535fd51bed168111912acb1651527897fe59a592119dc0032ce9286c9db", FlagChoices: map[string][]string{}}, - "change-workflow-plan": {InputContractSHA256: "sha256:e3124fc636b7f66b24daf8e1435cea11da15a741abeabe0cc3d3890b13c71625", InputSchemaSummary: []string{"schemaVersion=1", "root-shape-only definition proofkit.change-workflow-plan.input.v1.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:cd035e9b71d83c341b1a937a18699fd727cb4b0d694983d715b064292ae4d8bd", FlagChoices: map[string][]string{"--color": []string{"auto", "never"}, "--format": []string{"json", "text"}}}, - "changed-path-set": {InputContractSHA256: "sha256:8fe97426a58969e3e8dcbd52ed44540666b4de6be0487e8a3bc5088ae9c0f933", InputSchemaSummary: []string{"schemaVersion=1", "root-shape-only definition proofkit.changed-path-set.input.v1.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:abccdbf78e67f633ce49c34e8849c03f08ce42fa934a4c68969720c5045bf593", FlagChoices: map[string][]string{}}, - "completion-criteria": {InputContractSHA256: "sha256:99c49c44b001e40383787e4c55f66621b8a8315f09635f1baf2326dc09bec4e6", InputSchemaSummary: []string{"schemaVersion=1", "root-shape-only definition proofkit.completion-criteria.input.v1.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:c90bb9605c7a22914104701a068dded510534bdeb60f4d601555d46c2d3d8a6d", FlagChoices: map[string][]string{}}, - "conformance-profile": {InputContractSHA256: "sha256:10857de4cea06702bb4d35580046275d4b1f88821d287a4c57dabc187bda954e", InputSchemaSummary: []string{"schemaVersion=2", "root-shape-only definition proofkit.conformance-profile.input.v2.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:4654015b8b9055080c1d5528773462fab3fe81d40c7f3b9870e5dbec4dc98caf", FlagChoices: map[string][]string{}}, - "custom-rule-boundary": {InputContractSHA256: "sha256:18119bc01230b9cb01cbb56bab531c1d2153c689aea3863b24de4f345eec5ede", InputSchemaSummary: []string{"schemaVersion=1", "root-shape-only definition proofkit.custom-rule-boundary.input.v1.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:8a80f33974c60f960daa4f9803f2b5dfb34ff323418f5f0ab75d7b348653a34a", FlagChoices: map[string][]string{}}, - "deployment-evidence-admission": {InputContractSHA256: "sha256:c3472b87497be14c107dcf95f2c98594d21ec88e514919fe8fb7da858206421d", InputSchemaSummary: []string{"schemaVersion=1", "root-shape-only definition proofkit.deployment-evidence-admission.input.v1.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:818fb2b7dbf67be1da27cc62e78c7bd7fdaddaa38fa5ec80f0cad64fbcb60d90", FlagChoices: map[string][]string{}}, - "document-lifecycle-boundary": {InputContractSHA256: "sha256:393836f0ce8e23df351e01d7dec221908ae76fa9b1a045b96fce229eecc844ed", InputSchemaSummary: []string{"schemaVersion=1", "root-shape-only definition proofkit.document-lifecycle-boundary.input.v1.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:65c7c0338df60d83d71df9fce3a7e60ce0b0d86819a38e2a6ffeac58b4abc934", FlagChoices: map[string][]string{}}, - "evidence-graph": {InputContractSHA256: "sha256:280eb98872d7bd3ab4a76f1aa035a4c152ec6a6d8c0ec473bdd70e466d5cfda1", InputSchemaSummary: []string{"schemaVersion=1", "root-shape-only definition proofkit.evidence-graph.input.v1.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:0900a6b72f5dfae60bfde19fc589f23edf3c82f38ff67f66ebf696daa1528031", FlagChoices: map[string][]string{}}, - "external-consumer": {InputContractSHA256: "sha256:195d4f09c6fb03004f5ba3d6bad9aea7728ecd9aa876e7e30d521ff961a3e931", InputSchemaSummary: []string{"schemaVersion=1", "root-shape-only definition proofkit.external-consumer.input.v1.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:2615a60f4cd21a865ed604aca94bf5066adb4bc8f7a3a2345bd53d181429822f", FlagChoices: map[string][]string{}}, - "gradual-adoption": {InputContractSHA256: "sha256:b82598422d2e18c7160d827836e964db6e9b0c2b14a119c1d4f5c4d18779c17f", InputSchemaSummary: []string{"schemaVersion=1", "root-shape-only definition proofkit.gradual-adoption.input.v1.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:75310c4f53b1fd3838d03e8988e7eb5faef61e757149a6f556033e23ddc488ff", FlagChoices: map[string][]string{}}, - "gradual-adoption-bootstrap": {InputContractSHA256: "sha256:453bab6dc3f3c84ab9cd7d375966b1cd881b53f8e8414da4aa837f8215db6f19", InputSchemaSummary: []string{"schemaVersion=1", "root-shape-only definition proofkit.gradual-adoption-bootstrap.input.v1.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:c00c79676575f2c5903321e87b4b494a3b6d77219b19552701cecde469db5427", FlagChoices: map[string][]string{}}, - "gradual-adoption-guidance": {InputContractSHA256: "sha256:4752cbac81c864cb3e18a39facfd666a9707314233d54798c7f71e67d7f2800c", InputSchemaSummary: []string{"schemaVersion=1", "root-shape-only definition proofkit.gradual-adoption-guidance.input.v1.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:171fed4bb8d32a47fc5ec49796f5b0b55ed666feaccc2fbbfeb12da31d80ecc9", FlagChoices: map[string][]string{}}, - "help": {InputContractSHA256: "", InputSchemaSummary: []string(nil), OutputContractSHA256: "", FlagChoices: map[string][]string{}}, - "impact": {InputContractSHA256: "sha256:41d3107414837955ee408d5ce94949a4c1a6b76f6949e6c1dc224bd06f6b09bc", InputSchemaSummary: []string{"schemaVersion=2", "root-shape-only definition proofkit.impact.input.v2.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:73066e9a5ca48f21936111ffb7223900fb629875997f4e7b16d7fef9c4177972", 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:123ba4fdaa25c23cb3a8225252de29ce136c32a1a05563fb411999bddc2c7e4b", FlagChoices: map[string][]string{}}, - "migration-parity-admission": {InputContractSHA256: "sha256:0b36c0e68da3b857dac4b13e7b3bd523052459106133aa8c908a4352682e6c05", 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:8e0f8af2b205817f018b0fe133fe789661caa29007695e036bfcab63c1830f47", 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{}}, - "native-evidence-guidance": {InputContractSHA256: "", InputSchemaSummary: []string(nil), OutputContractSHA256: "sha256:c1d23df574e948ea7160931f53790a6d133ae12eeceefe9e5fa15430d653ff7e", FlagChoices: map[string][]string{"--color": []string{"auto", "never"}, "--format": []string{"json", "text"}}}, - "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:a1d9116ce619f7d705349ff4ae44c0f4399a281ebaa9e7d62ea304ac57af59ba", InputSchemaSummary: []string{"schemaVersion=2", "root-shape-only definition proofkit.pilot-admission.input.v2.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:8f51b18472c74894155816877ccb9f55d8ef4ce34fbe2856effc8de67685ea88", 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: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:eb057ad276f0dd19929a1d425cd3d7f3d6d36e71888041bc7da0652c5efcac2a", 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:6750b4d516fff1c37e542b68884ad8ac3da65dd3d56d10596e801bc0965021c7", 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: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: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: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:208d7d47109dee1ec355ae3970937690ae528a9cc0cb0eb885d7cc72d843f1e8", 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:e995d289c4a44310add784bbafa5a3a50ec305c3809a89506dd3b49914fbe28f", FlagChoices: map[string][]string{}}, - "requirement-bindings": {InputContractSHA256: "sha256:4771b7ed1e23b20c983060deb8f8e65391052f0e5a61cf0f5c67c0e73b8fc5dd", 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:7821c7b23ff2c0ca83c64039c22400d90660cad73a60b9afb46829c539c61168", FlagChoices: map[string][]string{}}, - "requirement-browser-server": {InputContractSHA256: "sha256:acb36856dc08ae8efa68986bb4c419f480951b1ad9ee9a43fc1d6c53d30ee71b", 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.v3.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:16e741d88e5ede4271c5e769c724e72164fcba461f0c6c17d285f318f8e03005", FlagChoices: map[string][]string{}}, - "requirement-context-compose": {InputContractSHA256: "sha256:3b06ebca2a07d01b34005d915918b8a7743ef8175901ee8dd0a37ecfb6ee80fe", 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:a2b8bf00d2308628e1835fe890dc6e9c4f402c531726f0bdaffad1d7100a7466", FlagChoices: map[string][]string{}}, - "requirement-context-slice": {InputContractSHA256: "sha256:883e864e44944270f7b85c013635835e5e1f1a49a7fd7ca3ebe860c7b56da601", 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:971f78ec94bb29a24057343ccfb3d1192134d2ac88968aab5193e3d64ac87506", FlagChoices: map[string][]string{}}, - "requirement-coverage-input-compose": {InputContractSHA256: "sha256:c18294cc0dc76949ea7df200d45d3eca7a1d0e48ff086778502f6b88eaeda614", InputSchemaSummary: []string{"schemaVersion=2", "root-shape-only definition proofkit.requirement-coverage-input-compose.input.v2.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:fd38161adcb4f676d58135cdfd2b01d99806fcdce85df97a993258650118bde4", FlagChoices: map[string][]string{}}, - "requirement-coverage-view": {InputContractSHA256: "sha256:40b06176f12dc9ec92226d7c01be53df5aa2b0e949a7791a894c8cf148e44b21", InputSchemaSummary: []string{"schemaVersion=2", "root-shape-only definition proofkit.requirement-coverage-view.input.v2.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:4ec6c0bb6d616d23b777e59721a3ff045e6b447401333300031d641218c926bf", FlagChoices: map[string][]string{}}, - "requirement-impact-input-compose": {InputContractSHA256: "sha256:c80c57489205004f92603fec541ce3d36dd0d9b65109dcfefb97bcfb07b90679", InputSchemaSummary: []string{"schemaVersion=2", "root-shape-only definition proofkit.requirement-impact-input-compose.input.v2.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:b0b689f4d0b5bafc52c6041a2aa3583c9c8628aa0f13a0f0d7c42a610ed1a0d6", FlagChoices: map[string][]string{}}, - "requirement-proof-resolver": {InputContractSHA256: "sha256:7ffedf651fbeda57f11f780373ae8f2b15dd587ce3aaddf739a092bc4835f2c5", InputSchemaSummary: []string{"schemaVersion=2", "root-shape-only definition proofkit.requirement-proof-resolver.input.v2.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:d6b1fbbf4a7fe3624c64f88c8e316fd927df8f35355198962110dab41113bc94", FlagChoices: map[string][]string{}}, - "requirement-proof-source-set": {InputContractSHA256: "sha256:e00367ea2c9b700b46756d112868fd14d20ca337d71c2032e7fb8ea0b11cf0ce", InputSchemaSummary: []string{"canonicalEnvelope", "schemaVersion", "sourceSet", "sources", "root-shape-only definition proofkit.requirement-proof-source-set.input.v2.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:9889e93cf457ecacfd673bd1aa148740b549ae614bb8c1e56ce5bb9fb387bdea", FlagChoices: map[string][]string{}}, - "requirement-proof-view": {InputContractSHA256: "sha256:d3dd0d897244c827435d0f84af2d8e42eec7ca5fa82a045042e9bb156c19a413", InputSchemaSummary: []string{"schemaVersion=2", "root-shape-only definition proofkit.requirement-proof-view.input.v2.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:00e5b1c8292832822f2d93eb10462fa72ab19a9ce8bb842e76a599889a80861c", 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:f9afe7bb6ef0b2a1f32209f02805f91ee17bd7db9f072aa54c3975afb9a37180", 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:aed5c873f09004435a68490ad33b0d289c286a3e67d3d150389ce638fad2b727", 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: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: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:e47635cbb0b058d386fc55e7e58ef2f340bfd02bb1ec0bc44fa0e78977b41d5a", 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:62cfd4f4663c4bb0535ad3699e3b166bb2f206651b3d733c027b71e9eb8ec374", 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:8d07bc37143833d4332106ca332bbc06abf24adddccf6d7448e5faf42771c19e", InputSchemaSummary: []string{"schemaVersion=2", "root-shape-only definition proofkit.test-evidence-inventory.input.v2.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:4e91925c8c135fa8f95121ebb2e7824f5a68ba41fa9b0171c07ac0501b14ce59", FlagChoices: map[string][]string{}}, - "text-policy": {InputContractSHA256: "sha256:afc366b0bcdcb33d7b85d3347f832cb647de68cb737cfa81a52e55f5b4901038", 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:94031bd5ee75115e80ebe10cb8c3c7b77806dc349721ba550cacaa99ff8184e4", FlagChoices: map[string][]string{}}, - "typescript-public-api-surfaces": {InputContractSHA256: "sha256:11ddef5ae86c8f5ff9fcb8cfac540c8ea680cd61233e54e9ba74563b28020e2a", 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:f26372435db16de436cd5483089d06d3723496574b28d80de97ee36ccddf1587", 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{}}, - "workspace-changed-package-plan": {InputContractSHA256: "sha256:77528c486d3b95c85be7c653d544ae9190662e5a3293cc70475efb28e8fe7485", InputSchemaSummary: []string{"schemaVersion=1", "root-shape-only definition proofkit.workspace-changed-package-plan.input.v1.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:02db1649b4d5aaa4b5abff0579b6edbbd89af43a58fcafe0b1258fa396a78570", FlagChoices: map[string][]string{}}, - "workspace-manifest-facts": {InputContractSHA256: "sha256:f47345a49a8efe208fec8ccf336fcf18d433601b583eb9147596e6c2e4d4faa0", InputSchemaSummary: []string{"schemaVersion=1", "root-shape-only definition proofkit.workspace-manifest-facts.input.v1.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:d04416ec9e2cfded2be80ce8558da228cdb23f2c4c3bf6794309b7c74b7cfe1d", FlagChoices: map[string][]string{}}, - "workspace-registry": {InputContractSHA256: "sha256:7da33057c0ebc034aa256ff86809bb9a84fad5ad26a412d5982682719560058b", InputSchemaSummary: []string{"schemaVersion=1", "root-shape-only definition proofkit.workspace-registry.input.v1.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:80ef9a2a27256527211a00dca3bf08936121173044b95d95f26e55e7f63f1f90", FlagChoices: map[string][]string{}}, - "workspace-shard-partition": {InputContractSHA256: "sha256:caf4c37f9f264a1886b251eaa3695972fa6ef547e0968397f4e9cfa18b7e5f6f", InputSchemaSummary: []string{"schemaVersion=1", "root-shape-only definition proofkit.workspace-shard-partition.input.v1.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:7fedc9ebd169865719ff8ab079a3da67b832144776e2497694a2674d46c6e2f5", FlagChoices: map[string][]string{}}, + "adopt-plan": {InputContractSHA256: "", InputSchemaSummary: []string(nil), OutputContractSHA256: "sha256:55f0852120ca978b9557553b1af8923a4d0b08c4dc794ba7751cb28d2414b7ec", FlagChoices: map[string][]string{"--color": []string{"auto", "never"}, "--format": []string{"json", "text"}, "--mode": []string{"audit-from-code", "code-baseline", "fresh"}, "--stack": []string{"agentic_runtime_repo", "generated_docs_contract_repo", "python_service", "python_typescript_service", "typescript_monorepo", "typescript_workspace"}}, RouteTokens: []string{"adopt", "plan"}}, + "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{}, RouteTokens: []string{"adoption-checklist"}}, + "adoption-contract-envelope": {InputContractSHA256: "sha256:c310214676ff4b6f536a5bc9d687f681a7e71f73d7a03ac932707d8cd3905cdf", InputSchemaSummary: []string{"schemaVersion=2", "root-shape-only definition proofkit.adoption-contract-envelope.input.v2.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:3efb2c5161fee16fd8ac6a40dcb6d9c41fbc23e468f60621436ae9e8076e0950", FlagChoices: map[string][]string{}, RouteTokens: []string{"adoption-contract-envelope"}}, + "adoption-doctor": {InputContractSHA256: "sha256:efa9acfe32bff07f56d9dc9902530df2979794289bc2f7f547f7a108a7dd0f35", 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:8fdfc6608f197e633f042f20031ae1014872a90aa3daa66885ffcaddca994766", FlagChoices: map[string][]string{}, RouteTokens: []string{"adoption-doctor"}}, + "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{}, RouteTokens: []string{"adoption-workflow-plan"}}, + "agent-route": {InputContractSHA256: "sha256:4fc7b2e5ffe3ed632e5e84d20e5ae26f9ace11df614bc9aec680853e60809ebd", 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:9e9492ff60a5d870fca9b76a7bff98c4ad8767928fd86e31d8d2db006f511352", FlagChoices: map[string][]string{}, RouteTokens: []string{"agent-route"}}, + "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{}, RouteTokens: []string{"binding-partition"}}, + "branch-authority": {InputContractSHA256: "sha256:8a3ed74978898593fbdbf1f7fa684dae450fbd9019edcd60d07f818d63363ed4", InputSchemaSummary: []string{"schemaVersion=1", "root-shape-only definition proofkit.branch-authority.input.v1.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:3c7dc74842299b92cd5baf57cc8666e9415963091359e5faf654e28da89561f1", FlagChoices: map[string][]string{}, RouteTokens: []string{"branch-authority"}}, + "capability-map-admission": {InputContractSHA256: "sha256:e49433f295c43c34d5d660ac9d656b117ed87208406b57723d25165ffec5d486", InputSchemaSummary: []string{"schemaVersion=1", "root-shape-only definition proofkit.capability-map-admission.input.v1.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:bfa35fe1be210ab98f3620694ab63b52a9724f7b92cd9dcbfd7b01b2c6a3555e", FlagChoices: map[string][]string{}, RouteTokens: []string{"capability-map-admission"}}, + "change-workflow-plan": {InputContractSHA256: "sha256:e3124fc636b7f66b24daf8e1435cea11da15a741abeabe0cc3d3890b13c71625", InputSchemaSummary: []string{"schemaVersion=1", "root-shape-only definition proofkit.change-workflow-plan.input.v1.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:cd035e9b71d83c341b1a937a18699fd727cb4b0d694983d715b064292ae4d8bd", FlagChoices: map[string][]string{"--color": []string{"auto", "never"}, "--format": []string{"json", "text"}}, RouteTokens: []string{"change-workflow-plan"}}, + "changed-path-set": {InputContractSHA256: "sha256:8fe97426a58969e3e8dcbd52ed44540666b4de6be0487e8a3bc5088ae9c0f933", InputSchemaSummary: []string{"schemaVersion=1", "root-shape-only definition proofkit.changed-path-set.input.v1.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:abccdbf78e67f633ce49c34e8849c03f08ce42fa934a4c68969720c5045bf593", FlagChoices: map[string][]string{}, RouteTokens: []string{"changed-path-set"}}, + "completion-criteria": {InputContractSHA256: "sha256:99c49c44b001e40383787e4c55f66621b8a8315f09635f1baf2326dc09bec4e6", InputSchemaSummary: []string{"schemaVersion=1", "root-shape-only definition proofkit.completion-criteria.input.v1.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:c90bb9605c7a22914104701a068dded510534bdeb60f4d601555d46c2d3d8a6d", FlagChoices: map[string][]string{}, RouteTokens: []string{"completion-criteria"}}, + "conformance-profile": {InputContractSHA256: "sha256:10857de4cea06702bb4d35580046275d4b1f88821d287a4c57dabc187bda954e", InputSchemaSummary: []string{"schemaVersion=2", "root-shape-only definition proofkit.conformance-profile.input.v2.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:4654015b8b9055080c1d5528773462fab3fe81d40c7f3b9870e5dbec4dc98caf", FlagChoices: map[string][]string{}, RouteTokens: []string{"conformance-profile"}}, + "custom-rule-boundary": {InputContractSHA256: "sha256:18119bc01230b9cb01cbb56bab531c1d2153c689aea3863b24de4f345eec5ede", InputSchemaSummary: []string{"schemaVersion=1", "root-shape-only definition proofkit.custom-rule-boundary.input.v1.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:8a80f33974c60f960daa4f9803f2b5dfb34ff323418f5f0ab75d7b348653a34a", FlagChoices: map[string][]string{}, RouteTokens: []string{"custom-rule-boundary"}}, + "deployment-evidence-admission": {InputContractSHA256: "sha256:c3472b87497be14c107dcf95f2c98594d21ec88e514919fe8fb7da858206421d", InputSchemaSummary: []string{"schemaVersion=1", "root-shape-only definition proofkit.deployment-evidence-admission.input.v1.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:818fb2b7dbf67be1da27cc62e78c7bd7fdaddaa38fa5ec80f0cad64fbcb60d90", FlagChoices: map[string][]string{}, RouteTokens: []string{"deployment-evidence-admission"}}, + "document-lifecycle-boundary": {InputContractSHA256: "sha256:393836f0ce8e23df351e01d7dec221908ae76fa9b1a045b96fce229eecc844ed", InputSchemaSummary: []string{"schemaVersion=1", "root-shape-only definition proofkit.document-lifecycle-boundary.input.v1.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:65c7c0338df60d83d71df9fce3a7e60ce0b0d86819a38e2a6ffeac58b4abc934", FlagChoices: map[string][]string{}, RouteTokens: []string{"document-lifecycle-boundary"}}, + "evidence-graph": {InputContractSHA256: "sha256:280eb98872d7bd3ab4a76f1aa035a4c152ec6a6d8c0ec473bdd70e466d5cfda1", InputSchemaSummary: []string{"schemaVersion=1", "root-shape-only definition proofkit.evidence-graph.input.v1.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:0900a6b72f5dfae60bfde19fc589f23edf3c82f38ff67f66ebf696daa1528031", FlagChoices: map[string][]string{}, RouteTokens: []string{"evidence-graph"}}, + "external-consumer": {InputContractSHA256: "sha256:195d4f09c6fb03004f5ba3d6bad9aea7728ecd9aa876e7e30d521ff961a3e931", InputSchemaSummary: []string{"schemaVersion=1", "root-shape-only definition proofkit.external-consumer.input.v1.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:2615a60f4cd21a865ed604aca94bf5066adb4bc8f7a3a2345bd53d181429822f", FlagChoices: map[string][]string{}, RouteTokens: []string{"external-consumer"}}, + "gradual-adoption": {InputContractSHA256: "sha256:b82598422d2e18c7160d827836e964db6e9b0c2b14a119c1d4f5c4d18779c17f", InputSchemaSummary: []string{"schemaVersion=1", "root-shape-only definition proofkit.gradual-adoption.input.v1.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:75310c4f53b1fd3838d03e8988e7eb5faef61e757149a6f556033e23ddc488ff", FlagChoices: map[string][]string{}, RouteTokens: []string{"gradual-adoption"}}, + "gradual-adoption-bootstrap": {InputContractSHA256: "sha256:453bab6dc3f3c84ab9cd7d375966b1cd881b53f8e8414da4aa837f8215db6f19", InputSchemaSummary: []string{"schemaVersion=1", "root-shape-only definition proofkit.gradual-adoption-bootstrap.input.v1.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:c00c79676575f2c5903321e87b4b494a3b6d77219b19552701cecde469db5427", FlagChoices: map[string][]string{}, RouteTokens: []string{"gradual-adoption-bootstrap"}}, + "gradual-adoption-guidance": {InputContractSHA256: "sha256:4752cbac81c864cb3e18a39facfd666a9707314233d54798c7f71e67d7f2800c", InputSchemaSummary: []string{"schemaVersion=1", "root-shape-only definition proofkit.gradual-adoption-guidance.input.v1.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:171fed4bb8d32a47fc5ec49796f5b0b55ed666feaccc2fbbfeb12da31d80ecc9", FlagChoices: map[string][]string{}, RouteTokens: []string{"gradual-adoption-guidance"}}, + "help": {InputContractSHA256: "", InputSchemaSummary: []string(nil), OutputContractSHA256: "", FlagChoices: map[string][]string{}, RouteTokens: []string{"help"}}, + "impact": {InputContractSHA256: "sha256:41d3107414837955ee408d5ce94949a4c1a6b76f6949e6c1dc224bd06f6b09bc", InputSchemaSummary: []string{"schemaVersion=2", "root-shape-only definition proofkit.impact.input.v2.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:73066e9a5ca48f21936111ffb7223900fb629875997f4e7b16d7fef9c4177972", FlagChoices: map[string][]string{}, RouteTokens: []string{"impact"}}, + "json-report-cli-adapter-source": {InputContractSHA256: "", InputSchemaSummary: []string(nil), OutputContractSHA256: "sha256:0ae40ace957b24bc1b48a3a6791690076de18e8513e2eceec449b595cefafe00", FlagChoices: map[string][]string{}, RouteTokens: []string{"json-report-cli-adapter-source"}}, + "migration-parity-admission": {InputContractSHA256: "sha256:0b36c0e68da3b857dac4b13e7b3bd523052459106133aa8c908a4352682e6c05", 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:8e0f8af2b205817f018b0fe133fe789661caa29007695e036bfcab63c1830f47", FlagChoices: map[string][]string{}, RouteTokens: []string{"migration-parity-admission"}}, + "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{}, RouteTokens: []string{"migration-plan"}}, + "native-evidence-guidance": {InputContractSHA256: "", InputSchemaSummary: []string(nil), OutputContractSHA256: "sha256:c9306d800668ecea18aaced6a21334036a935570f267baed356e6a4888025c8d", FlagChoices: map[string][]string{"--color": []string{"auto", "never"}, "--format": []string{"json", "text"}}, RouteTokens: []string{"native-evidence-guidance"}}, + "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{}, RouteTokens: []string{"obligation-decision"}}, + "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{}, RouteTokens: []string{"package-runtime-dependency-admission"}}, + "pilot-admission": {InputContractSHA256: "sha256:a1d9116ce619f7d705349ff4ae44c0f4399a281ebaa9e7d62ea304ac57af59ba", InputSchemaSummary: []string{"schemaVersion=2", "root-shape-only definition proofkit.pilot-admission.input.v2.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:34acb96baa4411d2d1de3a44938a2ca06b4d8b272d508ae181799e6cddec0633", FlagChoices: map[string][]string{}, RouteTokens: []string{"pilot-admission"}}, + "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{}, RouteTokens: []string{"producer-policy-self-proof"}}, + "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{}, RouteTokens: []string{"proof-obligation-algebra"}}, + "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{}, RouteTokens: []string{"proof-receipt-admission"}}, + "proof-slice": {InputContractSHA256: "sha256:eb057ad276f0dd19929a1d425cd3d7f3d6d36e71888041bc7da0652c5efcac2a", 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:6750b4d516fff1c37e542b68884ad8ac3da65dd3d56d10596e801bc0965021c7", FlagChoices: map[string][]string{}, RouteTokens: []string{"proof-slice"}}, + "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{}, RouteTokens: []string{"readiness-closeout"}}, + "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{}, RouteTokens: []string{"receipt-currentness-scope"}}, + "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{}, RouteTokens: []string{"receipt-producer-admission"}}, + "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{}, RouteTokens: []string{"receipt-trust-class"}}, + "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{}, RouteTokens: []string{"registry-consumer"}}, + "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{}, RouteTokens: []string{"registry-consumer-proof-input-compose"}}, + "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{}, RouteTokens: []string{"release-authority"}}, + "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{}, RouteTokens: []string{"rendered-artifact-freshness"}}, + "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{}, RouteTokens: []string{"repo-profile-admission"}}, + "repository-inventory": {InputContractSHA256: "", InputSchemaSummary: []string(nil), OutputContractSHA256: "sha256:5b0967fefb4abbfbc91027ff30ed6579c12e1f27d8310466cd4fd4d1f865552e", FlagChoices: map[string][]string{}, RouteTokens: []string{"repository-inventory"}}, + "requirement-authoring-plan": {InputContractSHA256: "sha256:208d7d47109dee1ec355ae3970937690ae528a9cc0cb0eb885d7cc72d843f1e8", 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:e995d289c4a44310add784bbafa5a3a50ec305c3809a89506dd3b49914fbe28f", FlagChoices: map[string][]string{}, RouteTokens: []string{"requirement-authoring-plan"}}, + "requirement-bindings": {InputContractSHA256: "sha256:4771b7ed1e23b20c983060deb8f8e65391052f0e5a61cf0f5c67c0e73b8fc5dd", 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:7821c7b23ff2c0ca83c64039c22400d90660cad73a60b9afb46829c539c61168", FlagChoices: map[string][]string{}, RouteTokens: []string{"requirement-bindings"}}, + "requirement-browser-server": {InputContractSHA256: "sha256:acb36856dc08ae8efa68986bb4c419f480951b1ad9ee9a43fc1d6c53d30ee71b", 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.v3.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:16e741d88e5ede4271c5e769c724e72164fcba461f0c6c17d285f318f8e03005", FlagChoices: map[string][]string{}, RouteTokens: []string{"requirement-browser-server"}}, + "requirement-context-compose": {InputContractSHA256: "sha256:3b06ebca2a07d01b34005d915918b8a7743ef8175901ee8dd0a37ecfb6ee80fe", 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:a2b8bf00d2308628e1835fe890dc6e9c4f402c531726f0bdaffad1d7100a7466", FlagChoices: map[string][]string{}, RouteTokens: []string{"requirement-context-compose"}}, + "requirement-context-slice": {InputContractSHA256: "sha256:883e864e44944270f7b85c013635835e5e1f1a49a7fd7ca3ebe860c7b56da601", 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:971f78ec94bb29a24057343ccfb3d1192134d2ac88968aab5193e3d64ac87506", FlagChoices: map[string][]string{}, RouteTokens: []string{"requirement-context-slice"}}, + "requirement-coverage-input-compose": {InputContractSHA256: "sha256:c18294cc0dc76949ea7df200d45d3eca7a1d0e48ff086778502f6b88eaeda614", InputSchemaSummary: []string{"schemaVersion=2", "root-shape-only definition proofkit.requirement-coverage-input-compose.input.v2.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:fd38161adcb4f676d58135cdfd2b01d99806fcdce85df97a993258650118bde4", FlagChoices: map[string][]string{}, RouteTokens: []string{"requirement-coverage-input-compose"}}, + "requirement-coverage-view": {InputContractSHA256: "sha256:40b06176f12dc9ec92226d7c01be53df5aa2b0e949a7791a894c8cf148e44b21", InputSchemaSummary: []string{"schemaVersion=2", "root-shape-only definition proofkit.requirement-coverage-view.input.v2.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:4ec6c0bb6d616d23b777e59721a3ff045e6b447401333300031d641218c926bf", FlagChoices: map[string][]string{}, RouteTokens: []string{"requirement-coverage-view"}}, + "requirement-impact-input-compose": {InputContractSHA256: "sha256:c80c57489205004f92603fec541ce3d36dd0d9b65109dcfefb97bcfb07b90679", InputSchemaSummary: []string{"schemaVersion=2", "root-shape-only definition proofkit.requirement-impact-input-compose.input.v2.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:b0b689f4d0b5bafc52c6041a2aa3583c9c8628aa0f13a0f0d7c42a610ed1a0d6", FlagChoices: map[string][]string{}, RouteTokens: []string{"requirement-impact-input-compose"}}, + "requirement-proof-resolver": {InputContractSHA256: "sha256:7ffedf651fbeda57f11f780373ae8f2b15dd587ce3aaddf739a092bc4835f2c5", InputSchemaSummary: []string{"schemaVersion=2", "root-shape-only definition proofkit.requirement-proof-resolver.input.v2.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:d6b1fbbf4a7fe3624c64f88c8e316fd927df8f35355198962110dab41113bc94", FlagChoices: map[string][]string{}, RouteTokens: []string{"requirement-proof-resolver"}}, + "requirement-proof-source-set": {InputContractSHA256: "sha256:e00367ea2c9b700b46756d112868fd14d20ca337d71c2032e7fb8ea0b11cf0ce", InputSchemaSummary: []string{"canonicalEnvelope", "schemaVersion", "sourceSet", "sources", "root-shape-only definition proofkit.requirement-proof-source-set.input.v2.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:9889e93cf457ecacfd673bd1aa148740b549ae614bb8c1e56ce5bb9fb387bdea", FlagChoices: map[string][]string{}, RouteTokens: []string{"requirement-proof-source-set"}}, + "requirement-proof-view": {InputContractSHA256: "sha256:d3dd0d897244c827435d0f84af2d8e42eec7ca5fa82a045042e9bb156c19a413", InputSchemaSummary: []string{"schemaVersion=2", "root-shape-only definition proofkit.requirement-proof-view.input.v2.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:00e5b1c8292832822f2d93eb10462fa72ab19a9ce8bb842e76a599889a80861c", FlagChoices: map[string][]string{}, RouteTokens: []string{"requirement-proof-view"}}, + "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{}, RouteTokens: []string{"requirement-semantic-diff"}}, + "requirement-source-admission": {InputContractSHA256: "sha256:f9afe7bb6ef0b2a1f32209f02805f91ee17bd7db9f072aa54c3975afb9a37180", 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:aed5c873f09004435a68490ad33b0d289c286a3e67d3d150389ce638fad2b727", FlagChoices: map[string][]string{}, RouteTokens: []string{"requirement-source-admission"}}, + "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{}, RouteTokens: []string{"requirement-source-transition"}}, + "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{}, RouteTokens: []string{"requirement-source-view"}}, + "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{}, RouteTokens: []string{"requirement-spec-tree"}}, + "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{}, RouteTokens: []string{"requirement-spec-tree-view"}}, + "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{}, RouteTokens: []string{"requirement-traceability-graph"}}, + "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{}, RouteTokens: []string{"scaffold-profile-plan"}}, + "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{}, RouteTokens: []string{"scaffold-project-structure"}}, + "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{}, RouteTokens: []string{"secret-scan"}}, + "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{}, RouteTokens: []string{"selective-gate-evidence"}}, + "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{}, RouteTokens: []string{"selective-gate-obligation-decision-input"}}, + "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{}, RouteTokens: []string{"selective-gate-plan"}}, + "self-check": {InputContractSHA256: "sha256:e44ed7f6b80d57e5ae1456a8eb7bd1ca5c4f7a03e761fd7190328a7518367aff", 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:34418e2007b0d08011155e3b1d6ec27fbfc255f206a8767cc6ae52c6e83ba0ae", FlagChoices: map[string][]string{}, RouteTokens: []string{"self-check"}}, + "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{}, RouteTokens: []string{"spec-overview-claims"}}, + "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{}, RouteTokens: []string{"spec-proof-bundle-admission"}}, + "stack-preset": {InputContractSHA256: "", InputSchemaSummary: []string(nil), OutputContractSHA256: "sha256:ef5920f363a4a96dcac308ea8412260a06e64ba4876460a369aefb8983130a9d", FlagChoices: map[string][]string{"--preset": []string{"agentic_runtime_repo", "generated_docs_contract_repo", "python_service", "python_typescript_service", "typescript_monorepo", "typescript_workspace"}}, RouteTokens: []string{"stack-preset"}}, + "test-evidence-inventory": {InputContractSHA256: "sha256:8d07bc37143833d4332106ca332bbc06abf24adddccf6d7448e5faf42771c19e", InputSchemaSummary: []string{"schemaVersion=2", "root-shape-only definition proofkit.test-evidence-inventory.input.v2.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:4e91925c8c135fa8f95121ebb2e7824f5a68ba41fa9b0171c07ac0501b14ce59", FlagChoices: map[string][]string{}, RouteTokens: []string{"test-evidence-inventory"}}, + "text-policy": {InputContractSHA256: "sha256:afc366b0bcdcb33d7b85d3347f832cb647de68cb737cfa81a52e55f5b4901038", 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:94031bd5ee75115e80ebe10cb8c3c7b77806dc349721ba550cacaa99ff8184e4", FlagChoices: map[string][]string{}, RouteTokens: []string{"text-policy"}}, + "typescript-public-api-surfaces": {InputContractSHA256: "sha256:11ddef5ae86c8f5ff9fcb8cfac540c8ea680cd61233e54e9ba74563b28020e2a", 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:f26372435db16de436cd5483089d06d3723496574b28d80de97ee36ccddf1587", FlagChoices: map[string][]string{}, RouteTokens: []string{"typescript-public-api-surfaces"}}, + "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{}, RouteTokens: []string{"witness-plan"}}, + "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{}, RouteTokens: []string{"witness-scheduler-plan"}}, + "workspace-changed-package-plan": {InputContractSHA256: "sha256:77528c486d3b95c85be7c653d544ae9190662e5a3293cc70475efb28e8fe7485", InputSchemaSummary: []string{"schemaVersion=1", "root-shape-only definition proofkit.workspace-changed-package-plan.input.v1.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:02db1649b4d5aaa4b5abff0579b6edbbd89af43a58fcafe0b1258fa396a78570", FlagChoices: map[string][]string{}, RouteTokens: []string{"workspace-changed-package-plan"}}, + "workspace-manifest-facts": {InputContractSHA256: "sha256:f47345a49a8efe208fec8ccf336fcf18d433601b583eb9147596e6c2e4d4faa0", InputSchemaSummary: []string{"schemaVersion=1", "root-shape-only definition proofkit.workspace-manifest-facts.input.v1.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:d04416ec9e2cfded2be80ce8558da228cdb23f2c4c3bf6794309b7c74b7cfe1d", FlagChoices: map[string][]string{}, RouteTokens: []string{"workspace-manifest-facts"}}, + "workspace-registry": {InputContractSHA256: "sha256:7da33057c0ebc034aa256ff86809bb9a84fad5ad26a412d5982682719560058b", InputSchemaSummary: []string{"schemaVersion=1", "root-shape-only definition proofkit.workspace-registry.input.v1.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:80ef9a2a27256527211a00dca3bf08936121173044b95d95f26e55e7f63f1f90", FlagChoices: map[string][]string{}, RouteTokens: []string{"workspace-registry"}}, + "workspace-shard-partition": {InputContractSHA256: "sha256:caf4c37f9f264a1886b251eaa3695972fa6ef547e0968397f4e9cfa18b7e5f6f", InputSchemaSummary: []string{"schemaVersion=1", "root-shape-only definition proofkit.workspace-shard-partition.input.v1.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:7fedc9ebd169865719ff8ab079a3da67b832144776e2497694a2674d46c6e2f5", FlagChoices: map[string][]string{}, RouteTokens: []string{"workspace-shard-partition"}}, } diff --git a/internal/app/command_coverage_routes.go b/internal/app/command_coverage_routes.go index 0fc3577..90601d0 100644 --- a/internal/app/command_coverage_routes.go +++ b/internal/app/command_coverage_routes.go @@ -88,7 +88,7 @@ var commandCoverageRoutes = map[string][]commandCoverageRoute{ "gradual-adoption-guidance": {requiredInputAdmissionRoute, packageFalsifierRoute("internal/command/gradualadoption/guidance_test.go", "TestGuidanceEnforcementFailsClosedForCandidateBoundaries", semanticRouteProof("guidance.guidance_enforcement_fails_closed_for_candidate_boundaries"), "Gradual adoption guidance must fail closed for candidate boundaries in enforcement modes.")}, "help": {directCLIRoute("internal/app/cli_contract_test.go", "TestHelpCommandContractForms", semanticRouteProof("cli_contract.help_command_contract_forms"), "Help command forms must emit the documented usage contract.")}, "impact": {requiredInputAdmissionRoute, packageFalsifierRoute("internal/command/impact/impact_test.go", "TestBuildRoutesChangedRecordToObligationAndRejectsUnboundProofChange", semanticRouteProof("impact.build_routes_changed_record_to_obligation_and_rejects_unbound_proof_change"), "Impact analysis must route changed requirement records to obligations and reject unbound proof-like changes.")}, - "init": {directCLIRoute("internal/app/cli_abi_test.go", "TestCLIABIGoldenCorpus", semanticRouteProof("cli_abi.init_golden_corpus"), "Init CLI ABI must emit dry-run route guidance without reading stdin, scanning, writing, or promoting repository facts.")}, + "adopt-plan": {directCLIRoute("internal/app/adoption_front_door_command_test.go", "TestAdoptionFrontDoorCLI", semanticRouteProof("adoption_front_door.adopt_plan_cli"), "Adopt-plan CLI must preserve explicit intent, bounded repository inventory, candidate-only authority, route grammar, text parity, and pre-I/O argument admission."), packageFalsifierRoute("internal/command/adoptionplan/adoptionplan_test.go", "TestPlanWireAdmissionIsDeterministicAndOwnerClosed", semanticRouteProof("adoptionplan.plan_wire_admission_is_deterministic_and_owner_closed"), "Adoption plan output must be deterministic and re-admitted by its child owners without semantic payload promotion.")}, "json-report-cli-adapter-source": {packageFalsifierRoute("internal/command/jsonreportcliadaptersource/json_report_cli_adapter_source_test.go", "TestGeneratedTypeScriptAdapterExecutesCoreSemantics", semanticRouteProof("json_report_cli_adapter_source.generated_type_script_adapter_executes_core_semantics"), "JSON report CLI adapter source generation must emit executable TypeScript that preserves parser, stable JSON, subprocess exit-code, stdout, stderr, and redacted direct-main semantics.")}, "migration-parity-admission": {requiredInputAdmissionRoute, packageFalsifierRoute("internal/command/migrationparityadmission/migrationparityadmission_test.go", "TestBuildAdmitsCallerDeclaredMatchAndRejectsDigestDrift", semanticRouteProof("migrationparityadmission.build_admits_caller_declared_match_and_rejects_digest_drift"), "Migration parity admission must reject caller-declared matches whose supplied digests differ without claiming native digest verification.")}, "migration-plan": {requiredInputAdmissionRoute, packageFalsifierRoute("internal/command/migrationplan/migrationplan_test.go", "TestSortedFollowUpCommandsRejectsShellControlTokens", semanticRouteProof("migrationplan.sorted_follow_up_commands_rejects_shell_control_tokens"), "Migration plans must reject shell-control follow-up commands.")}, @@ -113,6 +113,7 @@ var commandCoverageRoutes = map[string][]commandCoverageRoute{ "release-authority": {requiredInputAdmissionRoute, packageFalsifierRoute("internal/command/releaseauthority/releaseauthority_test.go", "TestBuildRejectsPrivateSourceNPMProvenanceClaim", semanticRouteProof("releaseauthority.build_rejects_private_source_npmprovenance_claim"), "Release authority must reject npm provenance claims without public source repository proof.")}, "rendered-artifact-freshness": {requiredInputAdmissionRoute, packageFalsifierRoute("internal/command/renderedartifactfreshness/rendered_artifact_freshness_test.go", "TestBuildAdmitsFreshRenderedArtifactAndRejectsDigestDrift", semanticRouteProof("rendered_artifact_freshness.build_admits_fresh_rendered_artifact_and_rejects_digest_drift"), "Rendered artifact freshness must reject recorded/current digest drift.")}, "repo-profile-admission": {requiredInputAdmissionRoute, packageFalsifierRoute("internal/command/repoprofileadmission/repo_profile_admission_test.go", "TestBuildAdmitsValidRepoProfileAndRejectsRootPackageMismatch", semanticRouteProof("repo_profile_admission.build_admits_valid_repo_profile_and_rejects_root_package_mismatch"), "Repo profile admission must reject mismatch between profile root package and observed package facts.")}, + "repository-inventory": {directCLIRoute("internal/app/adoption_front_door_command_test.go", "TestAdoptionFrontDoorCLI", semanticRouteProof("adoption_front_door.repository_inventory_cli"), "Repository-inventory CLI must expose only bounded catalog observations from an explicit repository root."), packageFalsifierRoute("internal/command/repositoryinventory/repositoryinventory_test.go", "TestScanProducesBoundedClosedInventory", semanticRouteProof("repositoryinventory.scan_produces_bounded_closed_inventory"), "Repository inventory scanning must remain root-bounded, catalog-limited, opaque for unknown entries, and closed under output admission.")}, "requirement-bindings": {requiredInputAdmissionRoute, packageFalsifierRoute("internal/command/requirementbinding/projections_test.go", "TestBuildReportFailsUnknownRequirementBinding", semanticRouteProof("projections.build_report_fails_unknown_requirement_binding"), "Requirement binding reports must fail closed when bindings reference unknown requirements.")}, "requirement-browser-server": {requiredInputAdmissionRoute, packageFalsifierRoute("internal/command/requirementbrowser/server_test.go", "TestStartServerFailsClosedForNonLoopbackHosts", semanticRouteProof("server.start_server_fails_closed_for_non_loopback_hosts"), "Requirement browser server must reject non-loopback host binding."), directCLIRoute("internal/app/cli_abi_test.go", "TestRequirementBrowserServerSpecTreeCLIABI", semanticRouteProof("cli_abi.requirement_browser_server_spec_tree_cliabi"), "Requirement browser server CLI ABI must admit explicit spec-tree view routing and emit a presentation-only browser plan.")}, "requirement-context-compose": {requiredInputAdmissionRoute, directCLIRoute("internal/app/requirement_context_cli_test.go", "TestRequirementContextCommandsComposeThroughWholeCLI", semanticRouteProof("requirement_context_cli.compose_through_whole_cli"), "Requirement context composition must honor its explicit repository root and emit an owner-admitted snapshot through the public CLI."), packageFalsifierRoute("internal/command/requirementcontext/requirementcontext_test.go", "TestComposeAndSliceRoundTrip", semanticRouteProof("requirementcontext.compose_and_slice_round_trip"), "Requirement context composition must read only an explicit catalog and produce a content-bound snapshot accepted unchanged by the slice owner.")}, diff --git a/internal/app/command_coverage_test.go b/internal/app/command_coverage_test.go index 93c2c2d..3fe0caa 100644 --- a/internal/app/command_coverage_test.go +++ b/internal/app/command_coverage_test.go @@ -497,7 +497,7 @@ func TestNoInputCommandDescriptorsHaveRuntimeSmoke(t *testing.T) { continue } t.Run(descriptor.name, func(t *testing.T) { - args, wantJSON := noInputRuntimeSmokeArgs(descriptor.name) + args, wantJSON := noInputRuntimeSmokeArgs(t, descriptor) var stdout bytes.Buffer var stderr bytes.Buffer status := Run(t.Context(), args, strings.NewReader(""), &stdout, &stderr) @@ -518,20 +518,23 @@ func TestNoInputCommandDescriptorsHaveRuntimeSmoke(t *testing.T) { } } -func noInputRuntimeSmokeArgs(command string) ([]string, bool) { - switch command { +func noInputRuntimeSmokeArgs(t *testing.T, descriptor commandDescriptor) ([]string, bool) { + t.Helper() + switch descriptor.name { + case "adopt-plan": + return append(cloneStrings(descriptor.routeTokens), "--mode", "fresh", "--repo-root", t.TempDir()), true case "help": return []string{"help"}, false - case "init": - return []string{"init", "--preset", "fresh"}, true case "json-report-cli-adapter-source": return []string{"json-report-cli-adapter-source", "--language", "typescript"}, true case "native-evidence-guidance": return []string{"native-evidence-guidance"}, true + case "repository-inventory": + return append(cloneStrings(descriptor.routeTokens), "--repo-root", t.TempDir()), true case "stack-preset": return []string{"stack-preset", "--preset", "typescript_workspace"}, true default: - panic("missing no-input command smoke args for " + command) + panic("missing no-input command smoke args for " + descriptor.name) } } diff --git a/internal/app/command_descriptors.go b/internal/app/command_descriptors.go index e673145..f20fc13 100644 --- a/internal/app/command_descriptors.go +++ b/internal/app/command_descriptors.go @@ -19,6 +19,7 @@ type commandRunner string const ( commandRunnerGenericInput commandRunner = "generic_input" + commandRunnerAdoptionFrontDoor commandRunner = "adoption_front_door" commandRunnerAdoptionContractEnvelope commandRunner = "adoption_contract_envelope" commandRunnerAdoptionDoctor commandRunner = "adoption_doctor" commandRunnerAdoptionWorkflow commandRunner = "adoption_workflow" @@ -29,7 +30,6 @@ const ( commandRunnerGradualAdoptionBootstrap commandRunner = "gradual_adoption_bootstrap" commandRunnerGradualAdoptionGuidance commandRunner = "gradual_adoption_guidance" commandRunnerHelp commandRunner = "help" - commandRunnerInit commandRunner = "init" commandRunnerJSONReportCLIAdapterSource commandRunner = "json_report_cli_adapter_source" commandRunnerPilotAdmission commandRunner = "pilot_admission" commandRunnerPlanning commandRunner = "planning" @@ -53,6 +53,7 @@ const ( type commandDescriptor struct { name string + routeTokens []string input commandInputMode runner commandRunner scopeClass commandScopeClass @@ -91,6 +92,7 @@ type requiredFlagValue struct { } var commandDescriptors = []commandDescriptor{ + command("adopt-plan", commandInputNone, flags("--color", "--format", "--mode", "--repo-root", "--stack"), modes("json", "text"), ownerDirs("adoptionplan", "repositoryinventory"), withRunner(commandRunnerAdoptionFrontDoor), withSemanticAppTests("TestAdoptionFrontDoorCLI"), withScopeClass(commandScopeExplicitFileSystemScan), withRequiredFlags("--mode", "--repo-root"), withFlagPresenceAndRequiredValue("--color", "--format", "text"), withSingleOccurrenceFlags("--color", "--mode", "--repo-root", "--stack")), command("adoption-checklist", commandInputRequired, flags("--input", "--input-pointer"), modes("json"), ownerDirs("adoptionchecklist")), command("adoption-contract-envelope", commandInputRequired, flags("--agent-envelope", "--checked-scope", "--guidance-mode", "--input", "--materialization-manifest", "--mode", "--pilot", "--touched-rule-id"), modes("json"), ownerDirs("adoptioncontract"), withRunner(commandRunnerAdoptionContractEnvelope), withAgentEnvelope(), withRequiredFlags("--mode")), command("adoption-doctor", commandInputRequired, flags("--agent-envelope", "--input", "--input-pointer"), modes("json"), ownerDirs("adoptiondoctor"), withRunner(commandRunnerAdoptionDoctor), withSemanticAppTests("TestAdoptionDoctorCLIABI"), withAgentEnvelope()), @@ -113,7 +115,6 @@ var commandDescriptors = []commandDescriptor{ command("gradual-adoption-guidance", commandInputRequired, flags("--agent-envelope", "--checked-scope", "--contract-envelope", "--guidance-mode", "--input", "--input-pointer", "--touched-rule-id"), modes("json"), ownerDirs("gradualadoption"), withRunner(commandRunnerGradualAdoptionGuidance), withAgentEnvelope(), withContractEnvelope()), command("help", commandInputNone, flags("--help", "-h"), modes("text"), ownerDirs("help"), withRunner(commandRunnerHelp), withSemanticAppTests("TestHelpCommandContractForms")), command("impact", commandInputRequired, flags("--input", "--input-pointer"), modes("json"), ownerDirs("impact")), - command("init", commandInputNone, flags("--preset"), modes("json"), ownerDirs("initplan"), withRunner(commandRunnerInit), withSemanticAppTests("TestCLIABIGoldenCorpus")), command("json-report-cli-adapter-source", commandInputNone, flags("--format", "--language"), modes("json"), ownerDirs("jsonreportcliadaptersource"), withRunner(commandRunnerJSONReportCLIAdapterSource), withRequiredFlags("--language")), command("migration-parity-admission", commandInputRequired, flags("--input", "--input-pointer"), modes("json"), ownerDirs("migrationparityadmission")), command("migration-plan", commandInputRequired, flags("--input", "--input-pointer"), modes("json"), ownerDirs("migrationplan")), @@ -134,6 +135,7 @@ var commandDescriptors = []commandDescriptor{ command("release-authority", commandInputRequired, flags("--input", "--input-pointer"), modes("json"), ownerDirs("releaseauthority")), command("rendered-artifact-freshness", commandInputRequired, flags("--input", "--input-pointer"), modes("json"), ownerDirs("renderedartifactfreshness")), command("repo-profile-admission", commandInputRequired, flags("--input", "--input-pointer"), modes("json"), ownerDirs("repoprofileadmission")), + command("repository-inventory", commandInputNone, flags("--repo-root"), modes("json"), ownerDirs("repositoryinventory"), withRunner(commandRunnerAdoptionFrontDoor), withSemanticAppTests("TestAdoptionFrontDoorCLI"), withScopeClass(commandScopeExplicitFileSystemScan), withRequiredFlags("--repo-root"), withSingleOccurrenceFlags("--repo-root")), 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"), 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...)), @@ -175,6 +177,7 @@ var commandDescriptors = []commandDescriptor{ var knownCommandRunners = map[commandRunner]struct{}{ commandRunnerGenericInput: {}, + commandRunnerAdoptionFrontDoor: {}, commandRunnerAdoptionContractEnvelope: {}, commandRunnerAdoptionDoctor: {}, commandRunnerAdoptionWorkflow: {}, @@ -185,7 +188,6 @@ var knownCommandRunners = map[commandRunner]struct{}{ commandRunnerGradualAdoptionBootstrap: {}, commandRunnerGradualAdoptionGuidance: {}, commandRunnerHelp: {}, - commandRunnerInit: {}, commandRunnerJSONReportCLIAdapterSource: {}, commandRunnerPilotAdmission: {}, commandRunnerPlanning: {}, @@ -213,6 +215,7 @@ type commandDescriptorOption func(*commandDescriptor) func command(name string, input commandInputMode, allowedFlags []string, outputModes []string, semanticOwnerDirs []string, options ...commandDescriptorOption) commandDescriptor { descriptor := commandDescriptor{ name: name, + routeTokens: []string{name}, input: input, runner: commandRunnerGenericInput, scopeClass: defaultCommandScopeClass(input), @@ -228,6 +231,7 @@ func command(name string, input commandInputMode, allowedFlags []string, outputM } explicitFlagChoices := cloneStringMap(descriptor.flagValueChoices) if metadata, ok := generatedCommandContractMetadataByName[name]; ok { + descriptor.routeTokens = cloneStrings(metadata.RouteTokens) descriptor.inputSchemaSummary = cloneStrings(metadata.InputSchemaSummary) descriptor.flagValueChoices = cloneStringMap(metadata.FlagChoices) for flag, choices := range explicitFlagChoices { @@ -377,6 +381,9 @@ func buildCommandDescriptorIndex(descriptors []commandDescriptor) map[string]com if descriptor.name == "" { panic("command descriptor name is empty") } + if !validCommandRoute(descriptor.routeTokens) { + panic("invalid command descriptor route: " + descriptor.name) + } if _, exists := index[descriptor.name]; exists { panic("duplicate command descriptor: " + descriptor.name) } @@ -565,6 +572,7 @@ func isSortedUniqueFlagValueRequirements(values []flagValueRequirement) bool { } func (descriptor commandDescriptor) clone() commandDescriptor { + descriptor.routeTokens = cloneStrings(descriptor.routeTokens) descriptor.allowedFlags = cloneStrings(descriptor.allowedFlags) descriptor.requiredFlags = cloneStrings(descriptor.requiredFlags) descriptor.exactlyOneOfFlagGroups = cloneStringMatrix(descriptor.exactlyOneOfFlagGroups) diff --git a/internal/app/command_family_catalog.go b/internal/app/command_family_catalog.go index 73031a6..7905262 100644 --- a/internal/app/command_family_catalog.go +++ b/internal/app/command_family_catalog.go @@ -47,8 +47,13 @@ func commandFamilyUsageWithRenderer(familyID string, renderer cliexec.Renderer) "Commands:", } for _, command := range family.Commands { - lines = append(lines, " "+command) - lines = append(lines, " "+renderer.DisplayCommand("help", command)) + descriptor, ok := commandDescriptorFor(command) + if !ok { + return "", fmt.Errorf("command family contains an unsupported command") + } + route := commandRouteText(descriptor.routeTokens) + lines = append(lines, " "+route) + lines = append(lines, " "+renderer.DisplayCommand(append([]string{"help"}, descriptor.routeTokens...)...)) } return strings.Join(lines, "\n") + "\n", nil } diff --git a/internal/app/command_family_catalog_generated.go b/internal/app/command_family_catalog_generated.go index 45d301f..c6b0253 100644 --- a/internal/app/command_family_catalog_generated.go +++ b/internal/app/command_family_catalog_generated.go @@ -1,13 +1,13 @@ // Code generated by internal/tools/commandfamilygen; DO NOT EDIT. package app -const commandFamilyCatalogSourceSHA256 = "53119e4f643386395a29dc61e09ca1f3cc3bdf61075d1d27ade6eb46ee5fe4dd" +const commandFamilyCatalogSourceSHA256 = "4372ad45898c54cf4389819dea2c024462044f1a2c8df2a4d81e28b0423f6489" func generatedCommandFamilyCatalog() commandFamilyCatalog { return commandFamilyCatalog{ CatalogID: "proofkit.command-families.v1", Families: []commandFamily{ - {ID: "adoption-lifecycle", Label: "Adoption lifecycle", Purpose: "Select and assess repository adoption stages.", Commands: []string{"adoption-checklist", "adoption-doctor", "adoption-workflow-plan", "gradual-adoption", "gradual-adoption-bootstrap", "gradual-adoption-guidance", "init", "pilot-admission"}}, + {ID: "adoption-lifecycle", Label: "Adoption lifecycle", Purpose: "Select and assess repository adoption stages.", Commands: []string{"adopt-plan", "adoption-checklist", "adoption-doctor", "adoption-workflow-plan", "gradual-adoption", "gradual-adoption-bootstrap", "gradual-adoption-guidance", "pilot-admission"}}, {ID: "agent-workflow-planning", Label: "Agent workflow planning", Purpose: "Plan bounded engineering-change stages and expose repository-neutral native-evidence guidance.", Commands: []string{"change-workflow-plan", "native-evidence-guidance"}}, {ID: "cli-metadata-and-conformance", Label: "CLI metadata and conformance", Purpose: "Expose, route, generate, and self-check CLI contract surfaces.", Commands: []string{"agent-route", "conformance-profile", "help", "json-report-cli-adapter-source", "self-check"}}, {ID: "deployment-and-readiness", Label: "Deployment and readiness", Purpose: "Admit deployment evidence and bounded closeout decisions.", Commands: []string{"branch-authority", "completion-criteria", "deployment-evidence-admission", "readiness-closeout"}}, @@ -16,7 +16,7 @@ func generatedCommandFamilyCatalog() commandFamilyCatalog { {ID: "proof-binding-topology", Label: "Proof binding topology", Purpose: "Admit, resolve, partition, and inspect requirement proof topology.", Commands: []string{"binding-partition", "evidence-graph", "proof-slice", "requirement-bindings", "requirement-proof-resolver", "requirement-proof-source-set", "spec-proof-bundle-admission"}}, {ID: "receipt-authority", Label: "Receipt authority", Purpose: "Admit receipts, producer compatibility, currentness, and trust classes.", Commands: []string{"producer-policy-self-proof", "proof-receipt-admission", "receipt-currentness-scope", "receipt-producer-admission", "receipt-trust-class"}}, {ID: "release-artifact-consumption", Label: "Release artifact consumption", Purpose: "Check package release facts and external or registry consumption.", Commands: []string{"external-consumer", "registry-consumer", "registry-consumer-proof-input-compose", "release-authority"}}, - {ID: "repository-facts-and-scanning", Label: "Repository facts and scanning", Purpose: "Admit explicit repository facts and bounded policy scans.", Commands: []string{"repo-profile-admission", "secret-scan", "text-policy", "typescript-public-api-surfaces", "workspace-manifest-facts"}}, + {ID: "repository-facts-and-scanning", Label: "Repository facts and scanning", Purpose: "Admit explicit repository facts and bounded policy scans.", Commands: []string{"repo-profile-admission", "repository-inventory", "secret-scan", "text-policy", "typescript-public-api-surfaces", "workspace-manifest-facts"}}, {ID: "requirement-context-and-traceability", Label: "Requirement context and traceability", Purpose: "Compose bounded semantic context and derive diff and traceability projections.", Commands: []string{"requirement-context-compose", "requirement-context-slice", "requirement-semantic-diff", "requirement-traceability-graph"}}, {ID: "requirement-navigation-and-rendering", Label: "Requirement navigation and rendering", Purpose: "Build and present requirement, proof, and coverage views.", Commands: []string{"requirement-browser-server", "requirement-coverage-view", "requirement-proof-view", "requirement-source-view", "requirement-spec-tree", "requirement-spec-tree-view"}}, {ID: "requirement-source-lifecycle", Label: "Requirement source lifecycle", Purpose: "Author, admit, transition, and check requirement source claims.", Commands: []string{"requirement-authoring-plan", "requirement-source-admission", "requirement-source-transition", "spec-overview-claims"}}, diff --git a/internal/app/command_family_catalog_test.go b/internal/app/command_family_catalog_test.go index 5596146..d6174f8 100644 --- a/internal/app/command_family_catalog_test.go +++ b/internal/app/command_family_catalog_test.go @@ -57,16 +57,17 @@ func TestCommandFamilyHelpFormsAreOptInAndLeafDispatchIsUnchanged(t *testing.T) } familyHelp := runTextCommand(t, []string{"help", "family", family.ID}) for _, command := range family.Commands { - if !strings.Contains(familyHelp, " "+command+"\n") { - t.Fatalf("family %s help does not route command %s", family.ID, command) + descriptor, _ := commandDescriptorFor(command) + route := commandRouteText(descriptor.routeTokens) + if !strings.Contains(familyHelp, " "+route+"\n") { + t.Fatalf("family %s help does not route command %s through %s", family.ID, command, route) } - copyableRoute := "agentic-proofkit help " + command + copyableRoute := "agentic-proofkit help " + route copyableLine := " " + copyableRoute + "\n" if strings.Count(familyHelp, copyableLine) != 1 { t.Fatalf("family %s copyable route %q count=%d, want 1", family.ID, copyableRoute, strings.Count(familyHelp, copyableLine)) } - direct := runTextCommand(t, []string{"help", command}) - descriptor, _ := commandDescriptorFor(command) + direct := runTextCommand(t, append([]string{"help"}, descriptor.routeTokens...)) if direct != commandUsage(descriptor) { t.Fatalf("direct help for %s changed through family navigation", command) } @@ -138,7 +139,7 @@ func TestStackPresetVocabularyProjectsFromOneOwner(t *testing.T) { func TestExistingHelpEntrypointsRemainCompatible(t *testing.T) { rootHelp := usage() - if !strings.Contains(rootHelp, "agentic-proofkit help [|-h|--help]") { + if !strings.Contains(rootHelp, "agentic-proofkit help [|-h|--help]") { t.Fatal("root help omits the admitted positional command target") } rootForms := [][]string{nil, {"help"}, {"--help"}, {"-h"}, {"help", "--help"}, {"help", "-h"}} @@ -153,9 +154,9 @@ func TestExistingHelpEntrypointsRemainCompatible(t *testing.T) { if strings.Count(expected, installedLine) != 1 { t.Fatalf("command %s installed invocation %q count=%d, want 1", descriptor.name, installedCommandUsageLine(descriptor), strings.Count(expected, installedLine)) } - aliases := [][]string{{"help", descriptor.name}} + aliases := [][]string{append([]string{"help"}, descriptor.routeTokens...)} if descriptor.name != "help" { - aliases = append(aliases, []string{descriptor.name, "--help"}, []string{descriptor.name, "-h"}) + aliases = append(aliases, append(cloneStrings(descriptor.routeTokens), "--help"), append(cloneStrings(descriptor.routeTokens), "-h")) } for _, args := range aliases { if got := runTextCommand(t, args); got != expected { diff --git a/internal/app/command_flag_constraints.go b/internal/app/command_flag_constraints.go index ed0a117..e916889 100644 --- a/internal/app/command_flag_constraints.go +++ b/internal/app/command_flag_constraints.go @@ -37,6 +37,7 @@ func classifyDescriptorArguments(descriptor commandDescriptor, args []string) de } func validateFlagConstraints(descriptor commandDescriptor, parsed descriptorArguments) error { + commandRoute := commandRouteText(descriptor.routeTokens) for _, flag := range descriptor.singleOccurrenceFlags { if parsed.counts[flag] > 1 { return fmt.Errorf("%s may be specified only once", flag) @@ -50,11 +51,11 @@ func validateFlagConstraints(descriptor commandDescriptor, parsed descriptorArgu } } if descriptor.input == commandInputRequired && !parsed.present["--input"] { - return fmt.Errorf("%s requires --input ", descriptor.name) + return fmt.Errorf("%s requires --input ", commandRoute) } for _, flag := range descriptor.requiredFlags { if !parsed.present[flag] { - return fmt.Errorf("%s requires %s", descriptor.name, flag) + return fmt.Errorf("%s requires %s", commandRoute, flag) } } for _, group := range descriptor.exactlyOneOfFlagGroups { @@ -65,7 +66,7 @@ func validateFlagConstraints(descriptor commandDescriptor, parsed descriptorArgu } } if count != 1 { - return fmt.Errorf("%s requires exactly one of %v", descriptor.name, group) + return fmt.Errorf("%s requires exactly one of %v", commandRoute, group) } } for _, group := range descriptor.atMostOneOfFlagGroups { @@ -76,7 +77,7 @@ func validateFlagConstraints(descriptor commandDescriptor, parsed descriptorArgu } } if count > 1 { - return fmt.Errorf("%s permits at most one of %v", descriptor.name, group) + return fmt.Errorf("%s permits at most one of %v", commandRoute, group) } } for _, requirement := range descriptor.flagPresenceRequirements { @@ -85,12 +86,12 @@ func validateFlagConstraints(descriptor commandDescriptor, parsed descriptorArgu } for _, flag := range requirement.RequiredFlags { if !parsed.present[flag] { - return fmt.Errorf("%s %s requires %s", descriptor.name, requirement.Flag, flag) + return fmt.Errorf("%s %s requires %s", commandRoute, 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) + return fmt.Errorf("%s %s requires %s %s", commandRoute, requirement.Flag, required.Flag, required.Value) } } } @@ -100,12 +101,12 @@ func validateFlagConstraints(descriptor commandDescriptor, parsed descriptorArgu } for _, flag := range requirement.RequiredFlags { if !parsed.present[flag] { - return fmt.Errorf("%s %s %s requires %s", descriptor.name, requirement.Flag, requirement.Value, flag) + return fmt.Errorf("%s %s %s requires %s", commandRoute, 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 fmt.Errorf("%s %s %s requires %s %s", commandRoute, requirement.Flag, requirement.Value, required.Flag, required.Value) } } } diff --git a/internal/app/command_help.go b/internal/app/command_help.go index cd641b9..0a15930 100644 --- a/internal/app/command_help.go +++ b/internal/app/command_help.go @@ -32,9 +32,12 @@ func commandUsageWithRenderer(descriptor commandDescriptor, renderer cliexec.Ren } lines = append(lines, "", - "Command:", + "Command ID:", " "+descriptor.name, "", + "Route:", + " "+commandRouteText(descriptor.routeTokens), + "", "Input:", " "+commandInputHelp(descriptor), "", @@ -119,9 +122,9 @@ func installedCommandUsageLineWithRenderer(descriptor commandDescriptor, rendere func commandUsageLine(descriptor commandDescriptor) string { if descriptor.name == "help" { - return "agentic-proofkit help [|-h|--help]" + return "agentic-proofkit help [|-h|--help]" } - segments := []string{"agentic-proofkit", descriptor.name} + segments := append([]string{"agentic-proofkit"}, descriptor.routeTokens...) if descriptor.input == commandInputRequired { segments = append(segments, "--input ") } @@ -214,20 +217,3 @@ func commandInputHelp(descriptor commandDescriptor) string { } return "Does not accept caller JSON input and never reads stdin." } - -func parseInitArgs(args []string) (string, error) { - preset := "" - for index := 0; index < len(args); index++ { - switch args[index] { - case "--preset": - if index+1 >= len(args) { - return "", fmt.Errorf("init --preset requires all, fresh, code-baseline, code-audit, legacy, or change-set") - } - preset = args[index+1] - index++ - default: - return "", fmt.Errorf("unsupported argument for init: %s", args[index]) - } - } - return preset, nil -} diff --git a/internal/app/command_registry.go b/internal/app/command_registry.go index 5c1c681..f6ca4c9 100644 --- a/internal/app/command_registry.go +++ b/internal/app/command_registry.go @@ -13,7 +13,6 @@ import ( "github.com/research-engineering/agentic-proofkit/internal/command/documentlifecycle" "github.com/research-engineering/agentic-proofkit/internal/command/externalconsumer" "github.com/research-engineering/agentic-proofkit/internal/command/impact" - "github.com/research-engineering/agentic-proofkit/internal/command/initplan" "github.com/research-engineering/agentic-proofkit/internal/command/migrationparityadmission" "github.com/research-engineering/agentic-proofkit/internal/command/migrationplan" "github.com/research-engineering/agentic-proofkit/internal/command/packageruntimedependency" @@ -163,7 +162,3 @@ func mustGenericCommandBuilders(builders map[string]genericCommandBuilder) map[s } return builders } - -func buildInitReport(preset string) (report.Record, error) { - return initplan.Build(preset) -} diff --git a/internal/app/command_routes.go b/internal/app/command_routes.go new file mode 100644 index 0000000..ebda6fe --- /dev/null +++ b/internal/app/command_routes.go @@ -0,0 +1,99 @@ +package app + +import ( + "slices" + "strings" +) + +const maximumCommandRouteTokens = 4 + +var commandDescriptorByRoute = buildCommandDescriptorRouteIndex(commandDescriptors) + +func buildCommandDescriptorRouteIndex(descriptors []commandDescriptor) map[string]commandDescriptor { + index := make(map[string]commandDescriptor, len(descriptors)) + for descriptorIndex, descriptor := range descriptors { + if !validCommandRoute(descriptor.routeTokens) { + panic("invalid command route: " + descriptor.name) + } + for existingIndex := 0; existingIndex < descriptorIndex; existingIndex++ { + existingDescriptor := descriptors[existingIndex] + existing := existingDescriptor.routeTokens + if slices.Equal(existing, descriptor.routeTokens) { + panic("duplicate command route: " + commandRouteText(descriptor.routeTokens)) + } + if commandRoutePrefix(existing, descriptor.routeTokens) || commandRoutePrefix(descriptor.routeTokens, existing) { + panic("ambiguous command route prefix: " + existingDescriptor.name + " and " + descriptor.name) + } + } + index[commandRouteKey(descriptor.routeTokens)] = descriptor.clone() + } + return index +} + +func commandDescriptorForRoute(args []string) (commandDescriptor, int, bool) { + maximum := len(args) + if maximum > maximumCommandRouteTokens { + maximum = maximumCommandRouteTokens + } + for consumed := maximum; consumed >= 1; consumed-- { + descriptor, ok := commandDescriptorByRoute[commandRouteKey(args[:consumed])] + if ok { + return descriptor.clone(), consumed, true + } + } + return commandDescriptor{}, 0, false +} + +func commandDescriptorForHelpTarget(tokens []string) (commandDescriptor, bool) { + if descriptor, consumed, ok := commandDescriptorForRoute(tokens); ok && consumed == len(tokens) { + return descriptor, true + } + return commandDescriptor{}, false +} + +func validCommandRoute(route []string) bool { + if len(route) == 0 || len(route) > maximumCommandRouteTokens { + return false + } + for _, token := range route { + if !validCommandRouteToken(token) { + return false + } + } + return true +} + +func validCommandRouteToken(token string) bool { + if token == "" || token[0] == '-' || token[len(token)-1] == '-' { + return false + } + previousHyphen := false + for _, value := range token { + if value == '-' { + if previousHyphen { + return false + } + previousHyphen = true + continue + } + previousHyphen = false + if value < 'a' || value > 'z' { + if value < '0' || value > '9' { + return false + } + } + } + return true +} + +func commandRoutePrefix(prefix, value []string) bool { + return len(prefix) < len(value) && slices.Equal(prefix, value[:len(prefix)]) +} + +func commandRouteKey(route []string) string { + return strings.Join(route, "\x00") +} + +func commandRouteText(route []string) string { + return strings.Join(route, " ") +} diff --git a/internal/app/invocation_profile_test.go b/internal/app/invocation_profile_test.go index 7ec0705..aa1fb38 100644 --- a/internal/app/invocation_profile_test.go +++ b/internal/app/invocation_profile_test.go @@ -236,16 +236,16 @@ func invocationProfileAssertHelpRouteClosure(t *testing.T, renderer cliexec.Rend t.Fatalf("family %s leaf route count=%d, want %d", family.ID, strings.Count(familyHelp, " help "), len(family.Commands)) } for _, command := range family.Commands { - leafRoute := renderer.DisplayCommand("help", command) - leafRouteLine := " " + leafRoute + "\n" - if strings.Count(familyHelp, leafRouteLine) != 1 { - t.Fatalf("family %s leaf route %q count=%d, want 1", family.ID, leafRoute, strings.Count(familyHelp, leafRouteLine)) - } descriptor, ok := commandDescriptorFor(command) if !ok { t.Fatalf("family %s command %s has no descriptor", family.ID, command) } - leafHelp := invocationProfileRunText(t, renderer, []string{"help", command}) + leafRoute := renderer.DisplayCommand(append([]string{"help"}, descriptor.routeTokens...)...) + leafRouteLine := " " + leafRoute + "\n" + if strings.Count(familyHelp, leafRouteLine) != 1 { + t.Fatalf("family %s leaf route %q count=%d, want 1", family.ID, leafRoute, strings.Count(familyHelp, leafRouteLine)) + } + leafHelp := invocationProfileRunText(t, renderer, append([]string{"help"}, descriptor.routeTokens...)) installedUsage := "Installed invocation:\n " + installedCommandUsageLineWithRenderer(descriptor, renderer) + "\n" if strings.Count(leafHelp, installedUsage) != 1 { t.Fatalf("command %s installed usage %q count=%d, want 1", command, installedUsage, strings.Count(leafHelp, installedUsage)) diff --git a/internal/app/testdata/v0.6-release-change-record.v2.json b/internal/app/testdata/v0.6-release-change-record.v2.json new file mode 100644 index 0000000..82094fc --- /dev/null +++ b/internal/app/testdata/v0.6-release-change-record.v2.json @@ -0,0 +1,61 @@ +{ + "schemaVersion": 2, + "previousVersion": "0.5.1", + "version": "0.6.0", + "changeClass": "breaking", + "breakingChanges": [ + { + "changeId": "proofkit.agent-route.brief-default", + "summary": "Change bare agent-route --agent-envelope output from the generic full envelope to the bounded proofkit.agent-route.brief packet." + }, + { + "changeId": "proofkit.agent-route.materialized-artifact-refs", + "summary": "Reject the stdin transport sentinel as an available-input or observed-report artifact identity; route references must name materialized caller-owned artifacts." + }, + { + "changeId": "proofkit.agent-route.report-schema-v3", + "summary": "Advance the bare agent-route report to schemaVersion 3 while preserving route-family semantics and adding exact launcher-profile and pre-suppression available-command context." + }, + { + "changeId": "proofkit.platform.macos-13", + "summary": "Raise published Darwin package compatibility to macOS 13.0 so wheel tags remain truthful for binaries built with Go 1.27.1." + }, + { + "changeId": "proofkit.toolchain.npm-12", + "summary": "Require npm 12.0.2 for repository-owned contributor and release scripts and consume npm view and pack evidence using npm 12 wire forms." + } + ], + "additions": [ + { + "changeId": "proofkit.agent-route.envelope-detail-mode", + "summary": "Add --agent-envelope-mode brief|full so agents can request bounded default guidance or the complete generic envelope explicitly." + }, + { + "changeId": "proofkit.toolchain.currentness", + "summary": "Update the verified build and CI baseline to Go 1.27.1 with Unicode 17.0.0, Node 26.8.1, npm 12.0.2, Python 3.14.7, and the current stable repository-owned dependency and action pins." + } + ], + "migration": { + "required": true, + "steps": [ + "Consumers that require the former generic agent-route envelope must add --agent-envelope-mode full after --agent-envelope; consumers that accept bounded route guidance may keep bare --agent-envelope.", + "Consumers of the bare agent-route report must admit schemaVersion 3; the prior route-family fields remain, and summary now binds launcherProfile and availableCommandCount.", + "Consumers that used the stdin transport sentinel as an agent-route availableInputs or observedReports ref must materialize that artifact and pass its safe repo-relative path instead.", + "Darwin consumers must use macOS 13.0 or later.", + "Contributors must use Go 1.27.1, Node 26.8.1, npm 12.0.2, and Python 3.14.7 for repository-owned build and release workflows." + ] + }, + "platformRequirements": [ + "Published Darwin package binaries require macOS 13.0 or later on arm64 and x86_64." + ], + "knownLimitations": [ + "Agent workflow plans, prompts, text, and envelopes are derived guidance and do not execute agents, repository mutations, native witnesses, CI, release, rollout, or production operations.", + "Brief agent-route packets cap pretty JSON at 3072 bytes and may defer oversized argv to explicit full detail; the bound does not claim tokenizer-specific token counts.", + "Complete nested public structural contracts remain blocked under SCHEMA-01; current CLI contracts own exact root variants only.", + "The selected requirement-source v2 codec remains internal; current requirement sources are not migrated and no source cutover is claimed.", + "TSX source parsing remains unsupported." + ], + "rollback": { + "strategy": "previous_admitted_version" + } +} diff --git a/internal/app/testdata/v0.6-wire-observations.json b/internal/app/testdata/v0.6-wire-observations.json index 41da08f..438fbee 100644 --- a/internal/app/testdata/v0.6-wire-observations.json +++ b/internal/app/testdata/v0.6-wire-observations.json @@ -4,7 +4,7 @@ "previousVersion": "0.5.1", "version": "0.6.0", "evidenceClass": "owner_authored_frozen_version_edge_observation", - "changeRecordRef": "release/change-record.v2.json", + "changeRecordRef": "internal/app/testdata/v0.6-release-change-record.v2.json", "changeRecordSha256": "sha256:053f459a5d9d8d1568da531d566adb185541bb85167a5867c7c4074f99f8f5ec", "previousPublicAbiSha256": "sha256:9ecd2c3d2f3f360088409f7e91cce406fc1d1d6edda1b404fce119985c4fb623", "currentPublicAbiSha256": "sha256:163f06bf6fc94f15040fecf3e352d4600a8611a227e26f35369b7fe97e90bde5", diff --git a/internal/app/testdata/v0.7-wire-observations.json b/internal/app/testdata/v0.7-wire-observations.json new file mode 100644 index 0000000..3ed2bc4 --- /dev/null +++ b/internal/app/testdata/v0.7-wire-observations.json @@ -0,0 +1,49 @@ +{ + "schemaVersion": 1, + "edgeId": "proofkit.public-wire.0.6.0-to-0.7.0", + "previousVersion": "0.6.0", + "version": "0.7.0", + "evidenceClass": "owner_authored_frozen_version_edge_observation", + "changeRecordRef": "release/change-record.v2.json", + "changeRecordSha256": "sha256:acff7afef6ac42624f4e0ce185ec3816ea155e5fc2608de003376b3c635bacf3", + "changedGeneratedArtifacts": [ + { + "artifactKind": "proofkit.json-report-cli-adapter-source", + "currentSourceSha256": "sha256:329b88b6b134dc30fb3704d32ac9708fc01608b9df68815bc9585108971be37d", + "generatorId": "proofkit.json-report-cli-adapter-source.typescript.v2", + "previousSourceSha256": "sha256:a171cc1b95c6078b7190ac50fc9fd298db8f42bfc9b65bbb67fa77d63dc04a93" + } + ], + "previousPublicAbiSha256": "sha256:163f06bf6fc94f15040fecf3e352d4600a8611a227e26f35369b7fe97e90bde5", + "currentPublicAbiSha256": "sha256:7b36077db9c82ca005606f571e1f6c6e208ab5b35b95b4d9b885e6047f091b91", + "removedCommandContract": { + "command": "init", + "defaultInvocationPreset": "all", + "outputContractSha256": "sha256:3e59a3002327c759e5e747f8baacaa63a4d6784e1a1c520f0a54e01af3f2faa0" + }, + "addedCommandContracts": [ + { + "command": "adopt-plan", + "route": ["adopt", "plan"], + "outputContractSha256": "sha256:55f0852120ca978b9557553b1af8923a4d0b08c4dc794ba7751cb28d2414b7ec" + }, + { + "command": "repository-inventory", + "route": ["repository-inventory"], + "outputContractSha256": "sha256:5b0967fefb4abbfbc91027ff30ed6579c12e1f27d8310466cd4fd4d1f865552e" + } + ], + "breakingChangeIds": [ + "proofkit.adoption.init-retired" + ], + "additionChangeIds": [ + "proofkit.adoption.front-door", + "proofkit.adoption.repository-inventory", + "proofkit.cli.generated-adapter-command-routes", + "proofkit.cli.hierarchical-command-routes", + "proofkit.python-wheel.embedded-cli-contract" + ], + "nonClaims": [ + "This owner-authored version-edge observation binds reviewed public contract identities; it does not authenticate Git history, registry publication, provider ingestion, native witness truth, rollout, or production readiness." + ] +} diff --git a/internal/command/adoptionplan/admission.go b/internal/command/adoptionplan/admission.go new file mode 100644 index 0000000..2eb620e --- /dev/null +++ b/internal/command/adoptionplan/admission.go @@ -0,0 +1,58 @@ +package adoptionplan + +import ( + "bytes" + "fmt" + + "github.com/research-engineering/agentic-proofkit/internal/command/repositoryinventory" + "github.com/research-engineering/agentic-proofkit/internal/command/stackpreset" + "github.com/research-engineering/agentic-proofkit/internal/kernel/admit" + "github.com/research-engineering/agentic-proofkit/internal/kernel/stablejson" +) + +var intentValues = map[string]struct{}{ + IntentAuditFromCode: {}, + IntentCodeBaseline: {}, + IntentFresh: {}, +} + +// AdmitOutput replays the plan's semantic owners and requires byte-canonical +// equality with their deterministic projection. +func AdmitOutput(raw any) (Plan, error) { + record, ok := raw.(map[string]any) + if !ok { + return Plan{}, fmt.Errorf("adoption plan must be an object") + } + intent, err := admit.Enum(record["intent"], intentValues, "adoption plan intent") + if err != nil { + return Plan{}, err + } + inventory, err := repositoryinventory.AdmitOutput(record["repositoryInventory"]) + if err != nil { + return Plan{}, err + } + stackPresetID := "" + if rawHint := record["stackHint"]; rawHint != nil { + hint, err := stackpreset.AdmitPlanningHint(rawHint) + if err != nil { + return Plan{}, err + } + stackPresetID = hint.PresetID + } + expected, err := Build(intent, inventory, stackPresetID) + if err != nil { + return Plan{}, err + } + rawBytes, err := stablejson.Marshal(record) + if err != nil { + return Plan{}, fmt.Errorf("encode adoption plan") + } + expectedBytes, err := stablejson.Marshal(expected.JSONValue()) + if err != nil { + return Plan{}, fmt.Errorf("encode expected adoption plan") + } + if !bytes.Equal(rawBytes, expectedBytes) { + return Plan{}, fmt.Errorf("adoption plan does not match its semantic owners") + } + return expected, nil +} diff --git a/internal/command/adoptionplan/adoptionplan_test.go b/internal/command/adoptionplan/adoptionplan_test.go new file mode 100644 index 0000000..a869556 --- /dev/null +++ b/internal/command/adoptionplan/adoptionplan_test.go @@ -0,0 +1,216 @@ +package adoptionplan + +import ( + "bytes" + "context" + "os" + "path/filepath" + "reflect" + "strings" + "testing" + + "github.com/research-engineering/agentic-proofkit/internal/command/capabilitymapadmission" + "github.com/research-engineering/agentic-proofkit/internal/command/repositoryinventory" + "github.com/research-engineering/agentic-proofkit/internal/kernel/admission" + "github.com/research-engineering/agentic-proofkit/internal/kernel/stablejson" + "github.com/research-engineering/agentic-proofkit/internal/testsupport/commandcoverage" +) + +func TestBuildSeparatesAdoptionIntentFromCandidateAuthority(t *testing.T) { + inventory := adoptionInventory(t) + tests := []struct { + intent string + declarationClass string + trustMode string + wantTaskCount int + baselineDeclared bool + }{ + {intent: IntentFresh, declarationClass: "owner_intent_required", wantTaskCount: 3}, + {intent: IntentCodeBaseline, declarationClass: "caller_declared_code_baseline", trustMode: capabilitymapadmission.TrustModeCodeBaseline, wantTaskCount: 4, baselineDeclared: true}, + {intent: IntentAuditFromCode, declarationClass: "untrusted_code_observation", trustMode: capabilitymapadmission.TrustModeAuditFromCode, wantTaskCount: 4}, + } + for _, test := range tests { + t.Run(test.intent, func(t *testing.T) { + plan, err := Build(test.intent, inventory, "") + if err != nil { + t.Fatalf("Build() error = %v", err) + } + if plan.Intent != test.intent || plan.TrustDeclaration.Class != test.declarationClass || len(plan.Packet.Tasks) != test.wantTaskCount { + t.Fatalf("Build() = %#v, want intent=%s declaration=%s tasks=%d", plan, test.intent, test.declarationClass, test.wantTaskCount) + } + gotTrustMode := "" + if plan.TrustDeclaration.CapabilityMapTrustMode != nil { + gotTrustMode = *plan.TrustDeclaration.CapabilityMapTrustMode + } + if gotTrustMode != test.trustMode { + t.Fatalf("capability-map trust mode = %q, want %q", gotTrustMode, test.trustMode) + } + value := plan.JSONValue() + summary := value["summary"].(map[string]any) + if summary["codeBaselineDeclared"] != test.baselineDeclared { + t.Fatalf("codeBaselineDeclared = %#v, want %t", summary["codeBaselineDeclared"], test.baselineDeclared) + } + packet := value["authoringPacket"].(map[string]any) + if packet["authority"] != "candidate_only" || !zeroJSONNumber(packet["proposedRequirementCount"]) || !zeroJSONNumber(packet["proposedBindingCount"]) { + t.Fatalf("authoring packet escalated authority: %#v", packet) + } + assertNoSemanticRequirementPayload(t, value) + }) + } +} + +func TestBuildStackHintCannotChangeIntentTrustOrTasks(t *testing.T) { + inventory := adoptionInventory(t) + withoutStack, err := Build(IntentAuditFromCode, inventory, "") + if err != nil { + t.Fatalf("Build(without stack) error = %v", err) + } + withStack, err := Build(IntentAuditFromCode, inventory, "python_typescript_service") + if err != nil { + t.Fatalf("Build(with stack) error = %v", err) + } + if withStack.StackHint == nil || withStack.StackHint.PresetID != "python_typescript_service" { + t.Fatalf("stack hint = %#v, want selected preset", withStack.StackHint) + } + if withoutStack.Intent != withStack.Intent || !reflect.DeepEqual(withoutStack.TrustDeclaration, withStack.TrustDeclaration) || !reflect.DeepEqual(withoutStack.Packet.Tasks, withStack.Packet.Tasks) { + t.Fatalf("stack selection changed authority semantics:\nwithout=%#v\nwith=%#v", withoutStack, withStack) + } + if withoutStack.PlanID == withStack.PlanID { + t.Fatal("plan identity did not bind stack selection") + } + if withoutStack.Inventory.InventoryID != withStack.Inventory.InventoryID { + t.Fatal("stack selection changed repository inventory") + } +} + +func TestBuildRejectsUnknownIntentPresetAndForgedInventory(t *testing.T) { + inventory := adoptionInventory(t) + if _, err := Build("trust-code", inventory, ""); err == nil || !strings.Contains(err.Error(), "--mode requires") { + t.Fatalf("Build(unknown intent) error = %v, want mode rejection", err) + } + if _, err := Build(IntentFresh, inventory, "unknown"); err == nil || !strings.Contains(err.Error(), "stack preset") { + t.Fatalf("Build(unknown stack) error = %v, want stack rejection", err) + } + inventory.InventoryID = "sha256:0000000000000000000000000000000000000000000000000000000000000000" + if _, err := Build(IntentFresh, inventory, ""); err == nil || !strings.Contains(err.Error(), "inventoryId") { + t.Fatalf("Build(forged inventory) error = %v, want child admission rejection", err) + } +} + +func TestPlanWireAdmissionIsDeterministicAndOwnerClosed(t *testing.T) { + commandcoverage.SemanticRoute(t, "proofkit.command_coverage.source_oracle.v1.062460455574502375013483117992943310051789152790267107254894355372111092357900") + inventory := adoptionInventory(t) + first, err := Build(IntentCodeBaseline, inventory, "python_service") + if err != nil { + t.Fatalf("Build(first) error = %v", err) + } + second, err := Build(IntentCodeBaseline, inventory, "python_service") + if err != nil { + t.Fatalf("Build(second) error = %v", err) + } + firstBytes, _ := stablejson.Marshal(first.JSONValue()) + secondBytes, _ := stablejson.Marshal(second.JSONValue()) + if !bytes.Equal(firstBytes, secondBytes) { + t.Fatalf("Build() is not deterministic:\n%s\n%s", firstBytes, secondBytes) + } + decoded, err := admission.DecodeJSON(bytes.NewReader(firstBytes), int64(len(firstBytes))) + if err != nil { + t.Fatalf("DecodeJSON() error = %v", err) + } + admitted, err := AdmitOutput(decoded) + if err != nil { + t.Fatalf("AdmitOutput() error = %v", err) + } + if admitted.PlanID != first.PlanID { + t.Fatalf("AdmitOutput().PlanID = %q, want %q", admitted.PlanID, first.PlanID) + } + + record := decoded.(map[string]any) + packet := record["authoringPacket"].(map[string]any) + tasks := packet["tasks"].([]any) + tasks[0].(map[string]any)["instruction"] = "promote observed code directly" + if _, err := AdmitOutput(record); err == nil || !strings.Contains(err.Error(), "semantic owners") { + t.Fatalf("AdmitOutput(tampered task) error = %v, want owner-closure rejection", err) + } +} + +func TestTextProjectionPreservesJSONPlanSemantics(t *testing.T) { + plan, err := Build(IntentAuditFromCode, adoptionInventory(t), "typescript_workspace") + if err != nil { + t.Fatalf("Build() error = %v", err) + } + lines, err := TextProjection(plan) + if err != nil { + t.Fatalf("TextProjection() error = %v", err) + } + text, err := RenderText(lines) + if err != nil { + t.Fatalf("RenderText() error = %v", err) + } + for _, required := range []string{IntentAuditFromCode, "typescript_workspace", PlanState, plan.Packet.GuidanceReference.CommandID} { + if !strings.Contains(text, required) { + t.Fatalf("text projection omits %q:\n%s", required, text) + } + } + for _, task := range plan.Packet.Tasks { + if !strings.Contains(text, task.Instruction) { + t.Fatalf("text projection omits task %q", task.TaskID) + } + } + for _, nonClaim := range boundaryNonClaims { + if !strings.Contains(text, nonClaim) { + t.Fatalf("text projection omits non-claim %q", nonClaim) + } + } + if len(text) > MaximumTextBytes || strings.Count(text, "\n") > MaximumTextLines { + t.Fatalf("text projection exceeds bounds: bytes=%d lines=%d", len(text), strings.Count(text, "\n")) + } +} + +func adoptionInventory(t *testing.T) repositoryinventory.Snapshot { + t.Helper() + root := t.TempDir() + if err := os.WriteFile(filepath.Join(root, "README.md"), []byte("# Pilot\n"), 0o600); err != nil { + t.Fatalf("WriteFile() error = %v", err) + } + if err := os.WriteFile(filepath.Join(root, "pyproject.toml"), []byte("[project]\nname = \"pilot\"\n"), 0o600); err != nil { + t.Fatalf("WriteFile() error = %v", err) + } + inventory, err := repositoryinventory.Scan(context.Background(), root) + if err != nil { + t.Fatalf("repositoryinventory.Scan() error = %v", err) + } + return inventory +} + +func zeroJSONNumber(raw any) bool { + return raw != nil && raw.(interface{ String() string }).String() == "0" +} + +func assertNoSemanticRequirementPayload(t *testing.T, raw any) { + t.Helper() + forbidden := map[string]struct{}{ + "candidateBindings": {}, + "candidateRequirements": {}, + "invariant": {}, + "requirementId": {}, + "requirements": {}, + } + var visit func(any) + visit = func(value any) { + switch typed := value.(type) { + case map[string]any: + for key, child := range typed { + if _, found := forbidden[key]; found { + t.Fatalf("adoption plan emitted semantic requirement field %q", key) + } + visit(child) + } + case []any: + for _, child := range typed { + visit(child) + } + } + } + visit(raw) +} diff --git a/internal/command/adoptionplan/build.go b/internal/command/adoptionplan/build.go new file mode 100644 index 0000000..1d66664 --- /dev/null +++ b/internal/command/adoptionplan/build.go @@ -0,0 +1,102 @@ +package adoptionplan + +import ( + "fmt" + "strings" + + "github.com/research-engineering/agentic-proofkit/internal/command/capabilitymapadmission" + "github.com/research-engineering/agentic-proofkit/internal/command/nativeevidenceguidance" + "github.com/research-engineering/agentic-proofkit/internal/command/repositoryinventory" + "github.com/research-engineering/agentic-proofkit/internal/command/stackpreset" +) + +// Build composes a read-only adoption plan from explicit caller intent and an +// already observed bounded repository inventory. +func Build(intent string, inventory repositoryinventory.Snapshot, stackPresetID string) (Plan, error) { + admittedInventory, err := repositoryinventory.AdmitOutput(inventory.JSONValue()) + if err != nil { + return Plan{}, fmt.Errorf("admit repository inventory: %w", err) + } + trust, tasks, err := intentPlan(intent) + if err != nil { + return Plan{}, err + } + guidance, err := nativeevidenceguidance.GuidanceReference() + if err != nil { + return Plan{}, err + } + var stackHint *stackpreset.PlanningHint + if stackPresetID != "" { + hint, ok := stackpreset.PlanningHintFor(stackPresetID) + if !ok { + return Plan{}, fmt.Errorf("stack preset must be one of: %s", joinStackIDs()) + } + stackHint = &hint + } + return finalize(Plan{ + Intent: intent, + Inventory: admittedInventory, + Packet: AuthoringPacket{ + GuidanceReference: guidance, + InventoryRef: admittedInventory.InventoryID, + Tasks: tasks, + }, + StackHint: stackHint, + TrustDeclaration: trust, + }) +} + +func intentPlan(intent string) (TrustDeclaration, []Task, error) { + switch intent { + case IntentFresh: + return TrustDeclaration{Class: "owner_intent_required"}, freshTasks(), nil + case IntentCodeBaseline: + mode := capabilitymapadmission.TrustModeCodeBaseline + return TrustDeclaration{CapabilityMapTrustMode: &mode, Class: "caller_declared_code_baseline"}, codeTasks(intent), nil + case IntentAuditFromCode: + mode := capabilitymapadmission.TrustModeAuditFromCode + return TrustDeclaration{CapabilityMapTrustMode: &mode, Class: "untrusted_code_observation"}, codeTasks(intent), nil + default: + return TrustDeclaration{}, nil, fmt.Errorf("--mode requires fresh, code-baseline, or audit-from-code") + } +} + +func freshTasks() []Task { + return []Task{ + newTask(1, "author-product-contract", nil, "candidate_behavior_statements", "Ask the consuming repository owner for observable outcomes, supported scenarios, guarantees, limits, edge cases, stable identifiers, owners, risk classes, and explicit non-claims. Do not infer product meaning from inventory filenames."), + newTask(2, "materialize-requirement-source", commandRef("requirement-source-admission"), "candidate_requirement_source", "Materialize only owner-approved statements as a candidate requirement source, then admit that source before creating bindings or claiming coverage."), + newTask(3, "design-native-evidence", commandRef("native-evidence-guidance"), "repository_specific_evidence_design", "For every admitted invariant, design a falsifier and native witness by resolving the referenced evidence-guidance slots under consuming-repository authority."), + } +} + +func codeTasks(intent string) []Task { + observationInstruction := "Use the inventory only as non-semantic routing context. Ask the repository owner to select an explicit bounded code, test, and documentation scope, including a module root when root entries are opaque; inspect only that scope and materialize caller-owned capability observations without treating observed behavior as product truth." + if intent == IntentCodeBaseline { + observationInstruction = "Use the inventory only as non-semantic routing context. Ask the repository owner to select an explicit bounded code, test, and documentation scope, including a module root when root entries are opaque; materialize current behavior only from that scope as caller-declared baseline candidates, and keep every statement candidate-only until owner review and source admission." + } + return []Task{ + newTask(1, "materialize-capability-observations", nil, "caller_owned_capability_map", observationInstruction), + newTask(2, "admit-capability-observations", commandRef("capability-map-admission"), "candidate_requirement_and_binding_seeds", "Run capability-map-admission with the plan's exact capabilityMapTrustMode; preserve unresolved owner questions and do not promote candidate seeds."), + newTask(3, "review-and-author-requirements", commandRef("requirement-authoring-plan"), "owner_reviewed_requirement_candidates", "Require the consuming repository owner to accept, reject, or rewrite each candidate meaning before materializing stable requirement-source changes."), + newTask(4, "design-native-evidence", commandRef("native-evidence-guidance"), "repository_specific_evidence_design", "For every owner-approved invariant, design a falsifier and native witness by resolving the referenced evidence-guidance slots under consuming-repository authority."), + } +} + +func newTask(order int, id string, commandID *string, outputKind string, instruction string) Task { + return Task{ + CommandID: commandID, + Instruction: instruction, + Order: order, + OutputKind: outputKind, + Owner: "consuming_repository_owner", + TaskID: "proofkit.adoption-plan." + id, + } +} + +func commandRef(value string) *string { + return &value +} + +func joinStackIDs() string { + return strings.Join(stackpreset.IDs(), ", ") +} diff --git a/internal/command/adoptionplan/model.go b/internal/command/adoptionplan/model.go new file mode 100644 index 0000000..9420611 --- /dev/null +++ b/internal/command/adoptionplan/model.go @@ -0,0 +1,159 @@ +// Package adoptionplan owns the non-authoritative adoption front-door plan. +package adoptionplan + +import ( + "encoding/json" + "fmt" + + "github.com/research-engineering/agentic-proofkit/internal/command/nativeevidenceguidance" + "github.com/research-engineering/agentic-proofkit/internal/command/repositoryinventory" + "github.com/research-engineering/agentic-proofkit/internal/command/stackpreset" + "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/stablejson" +) + +const ( + SchemaVersion = 1 + PlanKind = "proofkit.adoption-plan" + PacketKind = "proofkit.candidate-authoring-packet-template" + PlanState = "authoring_required" + + IntentAuditFromCode = "audit-from-code" + IntentCodeBaseline = "code-baseline" + IntentFresh = "fresh" + + MaximumOutputBytes = 192 << 10 + MaximumTextBytes = 8 << 10 + MaximumTextLines = 24 +) + +var boundaryNonClaims = []string{ + "Adoption plans do not infer or generate requirement meaning, invariant text, product promises, owner decisions, or proof adequacy from repository inventory or stack hints.", + "Adoption plans do not read source trees, write files, execute commands or witnesses, promote candidate data, approve merge, release, rollout, or production readiness.", + "Selecting code-baseline records an explicit caller declaration; it does not independently prove that current code is correct or suitable as product truth.", + "Stack hints are optional non-authoritative suggestions and cannot change the selected source-trust intent.", +} + +type TrustDeclaration struct { + CapabilityMapTrustMode *string + Class string +} + +type Task struct { + CommandID *string + Instruction string + Order int + OutputKind string + Owner string + TaskID string +} + +type AuthoringPacket struct { + GuidanceReference nativeevidenceguidance.Reference + InventoryRef string + Tasks []Task +} + +type Plan struct { + Intent string + Inventory repositoryinventory.Snapshot + Packet AuthoringPacket + PlanID string + StackHint *stackpreset.PlanningHint + TrustDeclaration TrustDeclaration +} + +type TextLine struct { + Label string + Value string +} + +func (plan Plan) JSONValue() map[string]any { + var stackHint any + if plan.StackHint != nil { + stackHint = plan.StackHint.JSONValue() + } + var capabilityMapTrustMode any + if plan.TrustDeclaration.CapabilityMapTrustMode != nil { + capabilityMapTrustMode = *plan.TrustDeclaration.CapabilityMapTrustMode + } + return map[string]any{ + "authority": "derived_non_authoritative_plan", + "authoringPacket": packetValue(plan.Packet), + "intent": plan.Intent, + "nonClaims": admit.StringSliceToAny(boundaryNonClaims), + "planId": plan.PlanID, + "planKind": PlanKind, + "repositoryInventory": plan.Inventory.JSONValue(), + "schemaVersion": json.Number("1"), + "sourceTrust": map[string]any{ + "capabilityMapTrustMode": capabilityMapTrustMode, + "declarationClass": plan.TrustDeclaration.Class, + }, + "stackHint": stackHint, + "state": PlanState, + "summary": map[string]any{ + "codeBaselineDeclared": plan.Intent == IntentCodeBaseline, + "generatedBindingCount": json.Number("0"), + "generatedRequirementCount": json.Number("0"), + "observedCatalogFileCount": json.Number(fmt.Sprintf("%d", len(plan.Inventory.Entries))), + "omittedRecognizedCount": json.Number(fmt.Sprintf("%d", len(plan.Inventory.Omissions.OmittedRecognized))), + "selectedStackPreset": selectedStackPreset(plan.StackHint), + "taskCount": json.Number(fmt.Sprintf("%d", len(plan.Packet.Tasks))), + "unrecognizedRootEntryCount": json.Number(fmt.Sprintf("%d", plan.Inventory.Omissions.UnrecognizedCount)), + }, + } +} + +func packetValue(packet AuthoringPacket) map[string]any { + tasks := make([]any, 0, len(packet.Tasks)) + for _, task := range packet.Tasks { + var commandID any + if task.CommandID != nil { + commandID = *task.CommandID + } + tasks = append(tasks, map[string]any{ + "commandId": commandID, + "instruction": task.Instruction, + "order": json.Number(fmt.Sprintf("%d", task.Order)), + "outputKind": task.OutputKind, + "owner": task.Owner, + "taskId": task.TaskID, + }) + } + return map[string]any{ + "authority": "candidate_only", + "inventoryRef": packet.InventoryRef, + "nativeEvidenceGuidance": packet.GuidanceReference.JSONValue(), + "packetKind": PacketKind, + "proposedBindingCount": json.Number("0"), + "proposedRequirementCount": json.Number("0"), + "tasks": tasks, + } +} + +func selectedStackPreset(hint *stackpreset.PlanningHint) any { + if hint == nil { + return nil + } + return hint.PresetID +} + +func finalize(plan Plan) (Plan, error) { + identity := plan.JSONValue() + delete(identity, "planId") + planID, err := digest.StableJSONSHA256Ref(identity) + if err != nil { + return Plan{}, err + } + plan.PlanID = planID + encoded, err := stablejson.Marshal(plan.JSONValue()) + if err != nil { + return Plan{}, err + } + if len(encoded) > MaximumOutputBytes { + return Plan{}, fmt.Errorf("adoption plan exceeds output byte limit") + } + return plan, nil +} diff --git a/internal/command/adoptionplan/repository_classes_test.go b/internal/command/adoptionplan/repository_classes_test.go new file mode 100644 index 0000000..04a6ac3 --- /dev/null +++ b/internal/command/adoptionplan/repository_classes_test.go @@ -0,0 +1,147 @@ +package adoptionplan + +import ( + "context" + "os" + "path/filepath" + "reflect" + "slices" + "testing" + + "github.com/research-engineering/agentic-proofkit/internal/command/capabilitymapadmission" + "github.com/research-engineering/agentic-proofkit/internal/command/repositoryinventory" +) + +type expectedTask struct { + commandID string + instruction string + order int + outputKind string + owner string + taskID string +} + +var exactTasksByIntent = map[string][]expectedTask{ + IntentFresh: { + {taskID: "proofkit.adoption-plan.author-product-contract", order: 1, owner: "consuming_repository_owner", outputKind: "candidate_behavior_statements", instruction: "Ask the consuming repository owner for observable outcomes, supported scenarios, guarantees, limits, edge cases, stable identifiers, owners, risk classes, and explicit non-claims. Do not infer product meaning from inventory filenames."}, + {taskID: "proofkit.adoption-plan.materialize-requirement-source", order: 2, owner: "consuming_repository_owner", commandID: "requirement-source-admission", outputKind: "candidate_requirement_source", instruction: "Materialize only owner-approved statements as a candidate requirement source, then admit that source before creating bindings or claiming coverage."}, + {taskID: "proofkit.adoption-plan.design-native-evidence", order: 3, owner: "consuming_repository_owner", commandID: "native-evidence-guidance", outputKind: "repository_specific_evidence_design", instruction: "For every admitted invariant, design a falsifier and native witness by resolving the referenced evidence-guidance slots under consuming-repository authority."}, + }, + IntentCodeBaseline: { + {taskID: "proofkit.adoption-plan.materialize-capability-observations", order: 1, owner: "consuming_repository_owner", outputKind: "caller_owned_capability_map", instruction: "Use the inventory only as non-semantic routing context. Ask the repository owner to select an explicit bounded code, test, and documentation scope, including a module root when root entries are opaque; materialize current behavior only from that scope as caller-declared baseline candidates, and keep every statement candidate-only until owner review and source admission."}, + {taskID: "proofkit.adoption-plan.admit-capability-observations", order: 2, owner: "consuming_repository_owner", commandID: "capability-map-admission", outputKind: "candidate_requirement_and_binding_seeds", instruction: "Run capability-map-admission with the plan's exact capabilityMapTrustMode; preserve unresolved owner questions and do not promote candidate seeds."}, + {taskID: "proofkit.adoption-plan.review-and-author-requirements", order: 3, owner: "consuming_repository_owner", commandID: "requirement-authoring-plan", outputKind: "owner_reviewed_requirement_candidates", instruction: "Require the consuming repository owner to accept, reject, or rewrite each candidate meaning before materializing stable requirement-source changes."}, + {taskID: "proofkit.adoption-plan.design-native-evidence", order: 4, owner: "consuming_repository_owner", commandID: "native-evidence-guidance", outputKind: "repository_specific_evidence_design", instruction: "For every owner-approved invariant, design a falsifier and native witness by resolving the referenced evidence-guidance slots under consuming-repository authority."}, + }, + IntentAuditFromCode: { + {taskID: "proofkit.adoption-plan.materialize-capability-observations", order: 1, owner: "consuming_repository_owner", outputKind: "caller_owned_capability_map", instruction: "Use the inventory only as non-semantic routing context. Ask the repository owner to select an explicit bounded code, test, and documentation scope, including a module root when root entries are opaque; inspect only that scope and materialize caller-owned capability observations without treating observed behavior as product truth."}, + {taskID: "proofkit.adoption-plan.admit-capability-observations", order: 2, owner: "consuming_repository_owner", commandID: "capability-map-admission", outputKind: "candidate_requirement_and_binding_seeds", instruction: "Run capability-map-admission with the plan's exact capabilityMapTrustMode; preserve unresolved owner questions and do not promote candidate seeds."}, + {taskID: "proofkit.adoption-plan.review-and-author-requirements", order: 3, owner: "consuming_repository_owner", commandID: "requirement-authoring-plan", outputKind: "owner_reviewed_requirement_candidates", instruction: "Require the consuming repository owner to accept, reject, or rewrite each candidate meaning before materializing stable requirement-source changes."}, + {taskID: "proofkit.adoption-plan.design-native-evidence", order: 4, owner: "consuming_repository_owner", commandID: "native-evidence-guidance", outputKind: "repository_specific_evidence_design", instruction: "For every owner-approved invariant, design a falsifier and native witness by resolving the referenced evidence-guidance slots under consuming-repository authority."}, + }, +} + +func TestPlanKeepsRepositoryClassesObservationalAndStackNeutral(t *testing.T) { + repositoryClasses := []struct { + name string + files map[string]string + wantPaths []string + wantRoles []string + }{ + {name: "python", files: map[string]string{"pyproject.toml": "[project]\nname = \"pilot\"\n", "requirements.txt": "example==1.0.0\n", "uv.lock": "version = 1\n"}, wantPaths: []string{"pyproject.toml", "requirements.txt", "uv.lock"}, wantRoles: []string{"ecosystem_manifest", "dependency_declaration", "dependency_lock"}}, + {name: "typescript", files: map[string]string{"package.json": "{\"name\":\"pilot\"}\n", "tsconfig.json": "{}\n"}, wantPaths: []string{"package.json", "tsconfig.json"}, wantRoles: []string{"ecosystem_manifest", "build_configuration"}}, + {name: "go", files: map[string]string{"go.mod": "module example.test/pilot\n", "go.sum": "example.test/mod v1.0.0 h1:value\n"}, wantPaths: []string{"go.mod", "go.sum"}, wantRoles: []string{"ecosystem_manifest", "dependency_lock"}}, + {name: "mixed", files: map[string]string{"package.json": "{\"name\":\"pilot\"}\n", "pyproject.toml": "[project]\nname = \"pilot\"\n"}, wantPaths: []string{"package.json", "pyproject.toml"}, wantRoles: []string{"ecosystem_manifest", "ecosystem_manifest"}}, + {name: "documentation only", files: map[string]string{"AGENTS.md": "# Instructions\n", "README.md": "# Pilot\n"}, wantPaths: []string{"AGENTS.md", "README.md"}, wantRoles: []string{"agent_instructions", "human_overview"}}, + } + stacks := []string{"", "python_service"} + intents := []string{IntentAuditFromCode, IntentCodeBaseline, IntentFresh} + + for _, repositoryClass := range repositoryClasses { + t.Run(repositoryClass.name, func(t *testing.T) { + root := t.TempDir() + for path, content := range repositoryClass.files { + if err := os.WriteFile(filepath.Join(root, path), []byte(content), 0o600); err != nil { + t.Fatalf("write %s: %v", path, err) + } + } + if err := os.WriteFile(filepath.Join(root, "consumer-private-file"), []byte("opaque\n"), 0o600); err != nil { + t.Fatalf("write unknown file: %v", err) + } + + inventory, err := repositoryinventory.Scan(context.Background(), root) + if err != nil { + t.Fatalf("repositoryinventory.Scan() error = %v", err) + } + gotPaths := make([]string, 0, len(inventory.Entries)) + gotRoles := make([]string, 0, len(inventory.Entries)) + for _, entry := range inventory.Entries { + gotPaths = append(gotPaths, entry.Path) + gotRoles = append(gotRoles, entry.Role) + } + if !slices.Equal(gotPaths, repositoryClass.wantPaths) || !slices.Equal(gotRoles, repositoryClass.wantRoles) || inventory.Omissions.UnrecognizedCount != 1 { + t.Fatalf("inventory paths/roles/count = %v/%v/%d, want %v/%v/1", gotPaths, gotRoles, inventory.Omissions.UnrecognizedCount, repositoryClass.wantPaths, repositoryClass.wantRoles) + } + + for _, intent := range intents { + for _, stack := range stacks { + plan, err := Build(intent, inventory, stack) + if err != nil { + t.Fatalf("Build(%s, %q) error = %v", intent, stack, err) + } + if !reflect.DeepEqual(projectTasks(plan.Packet.Tasks), exactTasksByIntent[intent]) { + t.Fatalf("Build(%s, %q) tasks = %#v, want %#v", intent, stack, projectTasks(plan.Packet.Tasks), exactTasksByIntent[intent]) + } + assertExactTrustDeclaration(t, plan, intent) + if stack == "" && plan.StackHint != nil { + t.Fatalf("Build(%s) inferred stack hint %#v", intent, plan.StackHint) + } + if stack != "" && (plan.StackHint == nil || plan.StackHint.PresetID != stack) { + t.Fatalf("Build(%s, %q) stack hint = %#v", intent, stack, plan.StackHint) + } + assertNoSemanticRequirementPayload(t, plan.JSONValue()) + } + } + }) + } +} + +func projectTasks(tasks []Task) []expectedTask { + result := make([]expectedTask, 0, len(tasks)) + for _, task := range tasks { + commandID := "" + if task.CommandID != nil { + commandID = *task.CommandID + } + result = append(result, expectedTask{ + commandID: commandID, instruction: task.Instruction, order: task.Order, + outputKind: task.OutputKind, owner: task.Owner, taskID: task.TaskID, + }) + } + return result +} + +func assertExactTrustDeclaration(t *testing.T, plan Plan, intent string) { + t.Helper() + wantClass := map[string]string{ + IntentFresh: "owner_intent_required", + IntentCodeBaseline: "caller_declared_code_baseline", + IntentAuditFromCode: "untrusted_code_observation", + }[intent] + if plan.TrustDeclaration.Class != wantClass { + t.Fatalf("Build(%s) trust class = %q, want %q", intent, plan.TrustDeclaration.Class, wantClass) + } + if intent == IntentFresh { + if plan.TrustDeclaration.CapabilityMapTrustMode != nil { + t.Fatalf("Build(%s) capability trust mode = %#v, want nil", intent, plan.TrustDeclaration.CapabilityMapTrustMode) + } + return + } + wantMode := map[string]string{ + IntentCodeBaseline: capabilitymapadmission.TrustModeCodeBaseline, + IntentAuditFromCode: capabilitymapadmission.TrustModeAuditFromCode, + }[intent] + if plan.TrustDeclaration.CapabilityMapTrustMode == nil || *plan.TrustDeclaration.CapabilityMapTrustMode != wantMode { + t.Fatalf("Build(%s) capability trust mode = %#v, want %q", intent, plan.TrustDeclaration.CapabilityMapTrustMode, wantMode) + } +} diff --git a/internal/command/adoptionplan/text.go b/internal/command/adoptionplan/text.go new file mode 100644 index 0000000..f0b5b6a --- /dev/null +++ b/internal/command/adoptionplan/text.go @@ -0,0 +1,58 @@ +package adoptionplan + +import ( + "fmt" + "strings" +) + +// TextProjection derives bounded human coordinates from the same typed plan as +// JSON output. Presentation adapters may style labels but not semantic values. +func TextProjection(plan Plan) ([]TextLine, error) { + if _, err := AdmitOutput(plan.JSONValue()); err != nil { + return nil, err + } + stack := "none" + if plan.StackHint != nil { + stack = plan.StackHint.PresetID + } + lines := []TextLine{ + {Label: "Adoption plan"}, + {Label: "Mode", Value: plan.Intent}, + {Label: "State", Value: PlanState}, + {Label: "Inventory", Value: fmt.Sprintf("%d recognized, %d omitted, %d opaque", len(plan.Inventory.Entries), len(plan.Inventory.Omissions.OmittedRecognized), plan.Inventory.Omissions.UnrecognizedCount)}, + {Label: "Stack hint", Value: stack}, + {Label: "Authority", Value: "candidate-only; consuming repository owner"}, + } + for _, task := range plan.Packet.Tasks { + lines = append(lines, TextLine{Label: fmt.Sprintf("Next %d", task.Order), Value: task.Instruction}) + } + lines = append(lines, TextLine{Label: "Evidence template", Value: plan.Packet.GuidanceReference.CommandID}) + for _, nonClaim := range boundaryNonClaims { + lines = append(lines, TextLine{Label: "Non-claim", Value: nonClaim}) + } + if len(lines) > MaximumTextLines { + return nil, fmt.Errorf("adoption plan text exceeds line limit") + } + return append([]TextLine{}, lines...), nil +} + +func RenderText(lines []TextLine) (string, error) { + if len(lines) > MaximumTextLines { + return "", fmt.Errorf("adoption plan text exceeds line limit") + } + plain := make([]string, len(lines)) + for index, line := range lines { + if line.Label == "" || strings.ContainsAny(line.Label, "\r\n") || strings.ContainsAny(line.Value, "\r\n") { + return "", fmt.Errorf("adoption plan text coordinate is invalid") + } + plain[index] = line.Label + if line.Value != "" { + plain[index] += ": " + line.Value + } + } + text := strings.Join(plain, "\n") + "\n" + if len(text) > MaximumTextBytes { + return "", fmt.Errorf("adoption plan text exceeds byte limit") + } + return text, nil +} diff --git a/internal/command/capabilitymapadmission/capability_map_admission.go b/internal/command/capabilitymapadmission/capability_map_admission.go index 77c2e12..7783f33 100644 --- a/internal/command/capabilitymapadmission/capability_map_admission.go +++ b/internal/command/capabilitymapadmission/capability_map_admission.go @@ -11,13 +11,18 @@ import ( const reportKind = "proofkit.capability-map-admission" +const ( + TrustModeAuditFromCode = "audit_from_code" + TrustModeCodeBaseline = "code_baseline" +) + var ( authorityValues = map[string]struct{}{ "caller_owned_observation": {}, } modeValues = map[string]struct{}{ - "audit_from_code": {}, - "code_baseline": {}, + TrustModeAuditFromCode: {}, + TrustModeCodeBaseline: {}, } requiredEvidenceWitnessKind = map[string]string{ "negative_test": "falsification", @@ -172,7 +177,7 @@ func buildReport(input input) (report.Record, int) { } } - if input.TrustMode == "code_baseline" { + if input.TrustMode == TrustModeCodeBaseline { if missingCandidateRequirement { failures = append(failures, fmt.Sprintf("scenario %s must declare candidateRequirementId in code_baseline mode", shape.ScenarioID)) } @@ -799,28 +804,28 @@ func candidateBinding(input input, requirementID string, requiredEvidence []stri } func evidenceAuthority(mode string) string { - if mode == "code_baseline" { + if mode == TrustModeCodeBaseline { return "caller_owned_executable_anchor" } return "untrusted_code_observation" } func executableEvidenceState(mode string) string { - if mode == "code_baseline" { + if mode == TrustModeCodeBaseline { return "candidate_executable_anchor" } return "not_executable_until_owner_materialized" } func promotionState(mode string) string { - if mode == "code_baseline" { + if mode == TrustModeCodeBaseline { return "candidate_requires_admission" } return "owner_review_required" } func instructions(mode string) []any { - if mode == "code_baseline" { + if mode == TrustModeCodeBaseline { return []any{ "Review candidateRequirementSeeds and materialize accepted records into requirements.v1.json.", "Run requirement-source-admission after materialization.", @@ -900,7 +905,7 @@ func modeRuleMessage(mode string, failures []string) string { if len(failures) > 0 { return "Capability map mode preconditions are not satisfied." } - if mode == "code_baseline" { + if mode == TrustModeCodeBaseline { return "Code baseline mode has candidate requirement ids and executable anchors for admitted scenarios." } return "Audit-from-code mode emitted candidate-only guidance without trusting code as stable requirement truth." @@ -914,7 +919,7 @@ func statusForFailures(failures []string) string { } func severityForMode(mode string, condition string) string { - if mode == "code_baseline" && condition == "missing_anchor" { + if mode == TrustModeCodeBaseline && condition == "missing_anchor" { return "blocking" } return "review" diff --git a/internal/command/initplan/initplan.go b/internal/command/initplan/initplan.go deleted file mode 100644 index f6d6b98..0000000 --- a/internal/command/initplan/initplan.go +++ /dev/null @@ -1,121 +0,0 @@ -package initplan - -import ( - "fmt" - "sort" - - "github.com/research-engineering/agentic-proofkit/internal/kernel/report" -) - -const reportKind = "proofkit.init" - -var presets = map[string][]route{ - "change-set": { - {"changed-path-set", "admit caller-owned changed paths before impact planning"}, - {"requirement-impact-input-compose", "compose admitted impact input from changed paths and proof records"}, - {"selective-gate-plan", "plan the smallest caller-owned gate set with explicit scan obligations"}, - }, - "code-audit": { - {"capability-map-admission", "capture untrusted code observations as owner questions"}, - {"test-evidence-inventory --projection discovery-draft", "turn explicit discovered tests into candidate-only inventory guidance"}, - {"requirement-authoring-plan", "draft owner-review work without promoting observations to requirements"}, - }, - "code-baseline": { - {"capability-map-admission", "freeze trusted current behavior as candidate requirement and binding seeds"}, - {"test-evidence-inventory --projection discovery-draft", "capture explicit discovered tests as candidate inventory only"}, - {"requirement-source-admission", "admit owner-reviewed requirements after materialization"}, - }, - "fresh": { - {"scaffold-project-structure", "draft caller-owned starter file topology without writing files"}, - {"gradual-adoption-bootstrap", "draft bootstrap payloads from explicit caller input"}, - {"adoption-workflow-plan", "route the first bounded adoption workflow"}, - }, - "legacy": { - {"migration-parity-admission", "admit explicit old/new parity rows"}, - {"migration-plan", "plan migration work from admitted parity records"}, - {"requirement-source-transition", "admit owner-reviewed requirement lifecycle transitions"}, - }, -} - -type route struct { - Command string - Reason string -} - -func Build(preset string) (report.Record, error) { - if preset == "" { - preset = "all" - } - if preset != "all" { - if _, ok := presets[preset]; !ok { - return report.Record{}, fmt.Errorf("init --preset must be all, fresh, code-baseline, code-audit, legacy, or change-set") - } - } - routeRecords := selectedRouteRecords(preset) - return report.Record{ - SchemaVersion: 1, - ReportKind: reportKind, - ReportID: "proofkit.init", - State: "passed", - Summary: map[string]any{ - "dryRunOnly": true, - "selectedPreset": preset, - "routeCount": len(routeRecords), - "routePresets": stringsToAny(sortedPresetIDs()), - }, - Diagnostics: []report.Diagnostic{ - {Key: "routes", Value: routeRecords}, - }, - RuleResults: []report.RuleResult{ - { - RuleID: "proofkit.init.dry-run-routes", - Status: "passed", - Message: "Init emits dry-run route guidance only and does not scan, write, or promote repository facts.", - Diagnostics: []report.Diagnostic{ - {Key: "selectedPreset", Value: preset}, - {Key: "routeCount", Value: len(routeRecords)}, - }, - }, - }, - NonClaims: []any{ - "Init does not read repository files, discover tests, execute commands, write files, create requirements, approve merge, release, rollout, or production readiness.", - "Init route guidance is a decision aid; caller-owned repository facts must still be materialized and admitted by the target command owners.", - }, - }, nil -} - -func stringsToAny(values []string) []any { - out := make([]any, 0, len(values)) - for _, value := range values { - out = append(out, value) - } - return out -} - -func selectedRouteRecords(preset string) []any { - presetIDs := sortedPresetIDs() - if preset != "all" { - presetIDs = []string{preset} - } - records := []any{} - for _, presetID := range presetIDs { - for index, item := range presets[presetID] { - records = append(records, map[string]any{ - "command": item.Command, - "order": index + 1, - "preset": presetID, - "reason": item.Reason, - }) - } - } - return records -} - -func sortedPresetIDs() []string { - ids := make([]string, 0, len(presets)) - for id := range presets { - ids = append(ids, id) - } - sort.Strings(ids) - return ids -} diff --git a/internal/command/initplan/initplan_test.go b/internal/command/initplan/initplan_test.go deleted file mode 100644 index d5e1dee..0000000 --- a/internal/command/initplan/initplan_test.go +++ /dev/null @@ -1,39 +0,0 @@ -package initplan - -import ( - "encoding/json" - "strings" - "testing" - - "github.com/research-engineering/agentic-proofkit/internal/testsupport/commandcoverage" -) - -func TestBuildEmitsDryRunDecisionRoutes(t *testing.T) { - commandcoverage.SemanticRoute(t, "proofkit.command_coverage.source_oracle.v1.029604058402699797876482176530432798172890455890181537273269246789159546473190") - record, err := Build("fresh") - if err != nil { - t.Fatalf("Build() error = %v", err) - } - if record.ReportKind != reportKind || record.State != "passed" { - t.Fatalf("unexpected record: %#v", record) - } - if record.Summary["selectedPreset"] != "fresh" || record.Summary["dryRunOnly"] != true { - t.Fatalf("summary does not describe dry-run fresh route: %#v", record.Summary) - } - encoded, err := json.Marshal(record.JSONValue()) - if err != nil { - t.Fatalf("marshal report: %v", err) - } - for _, unexpected := range []string{"repoRoot", "merge_ready"} { - if strings.Contains(string(encoded), unexpected) { - t.Fatalf("init report overclaims or implies repository access: %s", encoded) - } - } -} - -func TestBuildRejectsUnknownPreset(t *testing.T) { - _, err := Build("unknown") - if err == nil || !strings.Contains(err.Error(), "init --preset must be") { - t.Fatalf("Build() error=%v, want preset rejection", err) - } -} diff --git a/internal/command/jsonreportcliadaptersource/json_report_cli_adapter_source.go b/internal/command/jsonreportcliadaptersource/json_report_cli_adapter_source.go index f9c9375..47775a3 100644 --- a/internal/command/jsonreportcliadaptersource/json_report_cli_adapter_source.go +++ b/internal/command/jsonreportcliadaptersource/json_report_cli_adapter_source.go @@ -619,14 +619,14 @@ export function runProofkitJsonReportCliMain(input: { } export function runProofkitJsonCommand( - command: string, + commandRoute: string, input: unknown, args: readonly string[], options: ProofkitCommandRunOptions, ): ProofkitJsonCommandResult { - const {child, outputFile} = runProofkitCommand(command, input, args, options); + const {child, outputFile} = runProofkitCommand(commandRoute, input, args, options); if (child.status !== 0 && child.stdout.trim().length === 0) { - throw new Error(formatProofkitCliError(child.stderr.trim() || command + " failed with exit code " + String(child.status))); + throw new Error(formatProofkitCliError(child.stderr.trim() || commandRoute + " failed with exit code " + String(child.status))); } const jsonText = child.stdout.trim().length === 0 && outputFile !== null ? readProofkitTextReportInput(requireRegularOutputTarget(outputFile)) : @@ -639,20 +639,20 @@ export function runProofkitJsonCommand( value: parseProofkitJsonStrict(jsonText) as T, }; } catch (error) { - throw new Error(formatProofkitCliError(command + " produced invalid JSON: " + formatProofkitCliError(error))); + throw new Error(formatProofkitCliError(commandRoute + " produced invalid JSON: " + formatProofkitCliError(error))); } } export function runProofkitNoInputJsonCommand( - command: string, + commandRoute: string, args: readonly string[], options: ProofkitCommandRunOptions, ): ProofkitJsonCommandResult { - return runProofkitJsonCommand(command, null, args, {...options, inputMode: "none"}); + return runProofkitJsonCommand(commandRoute, null, args, {...options, inputMode: "none"}); } export function runProofkitTextCommand( - command: string, + commandRoute: string, input: unknown, args: readonly string[], options: ProofkitCommandRunOptions, @@ -660,9 +660,9 @@ export function runProofkitTextCommand( if (options.jsonLayout !== undefined) { throw new Error("Proofkit jsonLayout is valid only for JSON command output"); } - const {child, outputFile} = runProofkitCommand(command, input, args, options); + const {child, outputFile} = runProofkitCommand(commandRoute, input, args, options); if (child.status !== 0 && child.stdout.length === 0) { - throw new Error(formatProofkitCliError(child.stderr.trim() || command + " failed with exit code " + String(child.status))); + throw new Error(formatProofkitCliError(child.stderr.trim() || commandRoute + " failed with exit code " + String(child.status))); } const text = child.status === 0 && child.stdout.length === 0 && outputFile !== null ? readProofkitTextReportInput(requireRegularOutputTarget(outputFile)) : @@ -675,8 +675,8 @@ export function runProofkitTextCommand( }; } -function runProofkitCommand(command: string, input: unknown, args: readonly string[], options: ProofkitCommandRunOptions) { - admitCommand(command); +function runProofkitCommand(commandRoute: string, input: unknown, args: readonly string[], options: ProofkitCommandRunOptions) { + const routeTokens = admitCommandRoute(commandRoute); admitRunOptions(options); let prepared: {readonly args: readonly string[]; readonly outputFile: string | null}; try { @@ -685,7 +685,7 @@ function runProofkitCommand(command: string, input: unknown, args: readonly stri throw new Error(formatProofkitCliError(error)); } const processArgs = options.jsonLayout === undefined ? [] : ["--json-layout", options.jsonLayout]; - const childArgs = options.inputMode === "none" ? [...processArgs, command, ...prepared.args] : [...processArgs, command, "--input", "-", ...prepared.args]; + const childArgs = options.inputMode === "none" ? [...processArgs, ...routeTokens, ...prepared.args] : [...processArgs, ...routeTokens, "--input", "-", ...prepared.args]; const childResult = spawnSync(options.binaryPath, childArgs, { cwd: options.cwd, env: options.env, @@ -1109,10 +1109,12 @@ function nextCliValue( return value; } -function admitCommand(command: string): void { - if (command.length === 0 || command.startsWith("-") || command.includes("\0")) { - throw new Error("agentic-proofkit command must be a non-empty command id"); +function admitCommandRoute(commandRoute: string): readonly string[] { + const tokens = commandRoute.split(" "); + if (tokens.length === 0 || tokens.length > 4 || tokens.some((token) => !/^[a-z0-9]+(?:-[a-z0-9]+)*$/u.test(token))) { + throw new Error("agentic-proofkit command route must contain one to four canonical tokens"); } + return tokens; } function admitRunOptions(options: ProofkitCommandRunOptions): void { diff --git a/internal/command/jsonreportcliadaptersource/json_report_cli_adapter_source_test.go b/internal/command/jsonreportcliadaptersource/json_report_cli_adapter_source_test.go index 80206a6..baba5c7 100644 --- a/internal/command/jsonreportcliadaptersource/json_report_cli_adapter_source_test.go +++ b/internal/command/jsonreportcliadaptersource/json_report_cli_adapter_source_test.go @@ -15,7 +15,7 @@ import ( "github.com/research-engineering/agentic-proofkit/internal/testsupport/commandcoverage" ) -const expectedTypeScriptSourceSha256 = "sha256:a171cc1b95c6078b7190ac50fc9fd298db8f42bfc9b65bbb67fa77d63dc04a93" +const expectedTypeScriptSourceSha256 = "sha256:329b88b6b134dc30fb3704d32ac9708fc01608b9df68815bc9585108971be37d" func TestBuildEmitsDeterministicTypeScriptSourceBundle(t *testing.T) { if !slices.IsSorted(exportedSymbols) { @@ -130,7 +130,7 @@ func TestGeneratedSourcePreservesCLIExitCodeAsPublicContract(t *testing.T) { "stdout: child.stdout", "stderr: child.stderr", "value: parseProofkitJsonStrict(jsonText)", - "const {child, outputFile} = runProofkitCommand(command, input, args, options)", + "const {child, outputFile} = runProofkitCommand(commandRoute, input, args, options)", "function outputPathFromArgs(args: readonly string[])", "function admitWritableOutputTarget", "prepared = prepareOutputArgs(options.cwd, args)", @@ -142,6 +142,19 @@ func TestGeneratedSourcePreservesCLIExitCodeAsPublicContract(t *testing.T) { } } +func TestGeneratedSourceAdmitsBoundedCanonicalCommandRoutes(t *testing.T) { + source := TypeScriptSource() + for _, required := range []string{ + "function admitCommandRoute(commandRoute: string): readonly string[]", + "tokens.length > 4", + "...routeTokens, \"--input\", \"-\"", + } { + if !strings.Contains(source, required) { + t.Fatalf("generated source missing command-route guard %q", required) + } + } +} + func TestGeneratedSourceAvoidsGenericIndexedAssignmentDrift(t *testing.T) { source := TypeScriptSource() for _, forbidden := range []string{ @@ -208,12 +221,32 @@ import { writeFileSync } from "node:fs"; const commandIndex = process.argv[2] === "--json-layout" ? 4 : 2; const command = process.argv[commandIndex]; +let commandRoute = command; +let commandRouteLength = 1; +if (command === "adopt" && process.argv[commandIndex + 1] === "plan") { + commandRoute = "adopt plan"; + commandRouteLength = 2; +} else if (command === "three" && process.argv.slice(commandIndex, commandIndex + 3).join(" ") === "three route tokens") { + commandRoute = "three route tokens"; + commandRouteLength = 3; +} else if (command === "four" && process.argv.slice(commandIndex, commandIndex + 4).join(" ") === "four route tokens exactly") { + commandRoute = "four route tokens exactly"; + commandRouteLength = 4; +} let input = ""; process.stdin.on("data", (chunk) => { input += chunk; }); process.stdin.on("end", () => { - if (command === "json-pass") { + if (["adopt plan", "three route tokens", "four route tokens exactly"].includes(commandRoute)) { + if (process.argv.includes("--input")) { + process.stderr.write("unexpected input flag"); + process.exit(2); + } + process.stdout.write(JSON.stringify({schemaVersion: 1, state: "passed", route: process.argv.slice(commandIndex, commandIndex + commandRouteLength), args: process.argv.slice(commandIndex + commandRouteLength)}) + "\n"); + process.exit(0); + } + if (commandRoute === "json-pass") { const parsed = JSON.parse(input); process.stdout.write(JSON.stringify({schemaVersion: 1, state: "passed", received: parsed}) + "\n"); process.exit(0); @@ -506,6 +539,20 @@ const pass = runProofkitJsonCommand("json-pass", {z: 1, a: true}, [], {binaryPat assert.equal(pass.status, 0); assert.equal(pass.value.state, "passed"); assert.deepEqual(pass.value.received, {a: true, z: 1}); +const routedPass = runProofkitNoInputJsonCommand("adopt plan", ["--mode", "fresh", "--repo-root", "."], {binaryPath: fakeProofkitPath, cwd: repositoryRoot}); +assert.equal(routedPass.status, 0); +assert.deepEqual(routedPass.value.route, ["adopt", "plan"]); +assert.deepEqual(routedPass.value.args, ["--mode", "fresh", "--repo-root", "."]); +const maxMinusOneRoute = runProofkitNoInputJsonCommand("three route tokens", [], {binaryPath: fakeProofkitPath, cwd: repositoryRoot}); +assert.deepEqual(maxMinusOneRoute.value.route, ["three", "route", "tokens"]); +const maximumRoute = runProofkitNoInputJsonCommand("four route tokens exactly", [], {binaryPath: fakeProofkitPath, cwd: repositoryRoot}); +assert.deepEqual(maximumRoute.value.route, ["four", "route", "tokens", "exactly"]); +for (const invalidRoute of ["", "adopt plan", "adopt plan now extra later", "Adopt plan", "--help"]) { + assert.throws( + () => runProofkitJsonCommand(invalidRoute, {}, [], {binaryPath: fakeProofkitPath, cwd: repositoryRoot}), + /one to four canonical tokens/, + ); +} const compactPass = runProofkitJsonCommand("json-pass", {z: 1, a: true}, [], {binaryPath: fakeProofkitPath, cwd: repositoryRoot, jsonLayout: "compact"}); assert.equal(compactPass.status, 0); assert.deepEqual(compactPass.value.received, {a: true, z: 1}); diff --git a/internal/command/nativeevidenceguidance/guidance.go b/internal/command/nativeevidenceguidance/guidance.go index aa923c0..12d2929 100644 --- a/internal/command/nativeevidenceguidance/guidance.go +++ b/internal/command/nativeevidenceguidance/guidance.go @@ -2,8 +2,14 @@ package nativeevidenceguidance import ( + "bytes" "errors" + "fmt" "strings" + + "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/stablejson" ) const ( @@ -41,6 +47,24 @@ type Guidance struct { NonClaims []string `json:"nonClaims"` } +// Reference is the bounded progressive-disclosure link used by other command +// owners. The full guidance remains available from one canonical command. +type Reference struct { + CommandID string + ContentSHA256 string + GuidanceID string + SlotCount int +} + +func (reference Reference) JSONValue() map[string]any { + return map[string]any{ + "commandId": reference.CommandID, + "contentSha256": reference.ContentSHA256, + "guidanceId": reference.GuidanceID, + "slotCount": reference.SlotCount, + } +} + // JSONValue returns a fresh stablejson-compatible projection. func (guidance Guidance) JSONValue() map[string]any { slots := make([]any, 0, len(guidance.Slots)) @@ -116,6 +140,53 @@ func Build() (Guidance, error) { }, nil } +// GuidanceReference validates the owner table before returning its compact +// progressive-disclosure reference. +func GuidanceReference() (Reference, error) { + guidance, err := Build() + if err != nil { + return Reference{}, err + } + contentSHA256, err := digest.StableJSONSHA256Ref(guidance.JSONValue()) + if err != nil { + return Reference{}, fmt.Errorf("digest native evidence guidance") + } + return Reference{ + CommandID: "native-evidence-guidance", + ContentSHA256: contentSHA256, + GuidanceID: GuidanceID, + SlotCount: SlotCount, + }, nil +} + +// AdmitReference validates a serialized compact reference against the sole +// guidance owner. +func AdmitReference(raw any) (Reference, error) { + record, ok := raw.(map[string]any) + if !ok { + return Reference{}, fmt.Errorf("native evidence guidance reference must be an object") + } + if _, err := admit.NonEmptyText(record["guidanceId"], "native evidence guidance reference guidanceId"); err != nil { + return Reference{}, err + } + expected, err := GuidanceReference() + if err != nil { + return Reference{}, err + } + rawBytes, err := stablejson.Marshal(record) + if err != nil { + return Reference{}, fmt.Errorf("encode native evidence guidance reference") + } + expectedBytes, err := stablejson.Marshal(expected.JSONValue()) + if err != nil { + return Reference{}, fmt.Errorf("encode expected native evidence guidance reference") + } + if !bytes.Equal(rawBytes, expectedBytes) { + return Reference{}, fmt.Errorf("native evidence guidance reference does not match its owner") + } + return expected, nil +} + // RenderPlainText renders the fixed guidance in at most two lines per slot. func RenderPlainText() (string, error) { lines, err := TextProjection() diff --git a/internal/command/nativeevidenceguidance/guidance_test.go b/internal/command/nativeevidenceguidance/guidance_test.go index 61deaef..ab8b792 100644 --- a/internal/command/nativeevidenceguidance/guidance_test.go +++ b/internal/command/nativeevidenceguidance/guidance_test.go @@ -1,13 +1,17 @@ package nativeevidenceguidance import ( + "bytes" "crypto/sha256" + "encoding/json" "fmt" "maps" "slices" "strings" "testing" + "github.com/research-engineering/agentic-proofkit/internal/kernel/admission" + "github.com/research-engineering/agentic-proofkit/internal/kernel/stablejson" "github.com/research-engineering/agentic-proofkit/internal/testsupport/commandcoverage" ) @@ -166,6 +170,57 @@ func TestGuidanceJSONProjectionIsFreshAndComplete(t *testing.T) { } } +func TestGuidanceReferenceIsCompactAndOwnerBound(t *testing.T) { + reference, err := GuidanceReference() + if err != nil { + t.Fatalf("GuidanceReference() error = %v", err) + } + want := map[string]any{ + "commandId": "native-evidence-guidance", + "contentSha256": reference.ContentSHA256, + "guidanceId": GuidanceID, + "slotCount": SlotCount, + } + if !maps.Equal(reference.JSONValue(), want) { + t.Fatalf("GuidanceReference().JSONValue() = %#v, want %#v", reference.JSONValue(), want) + } + guidance, err := Build() + if err != nil { + t.Fatalf("Build() error = %v", err) + } + guidanceBytes, err := stablejson.Marshal(guidance.JSONValue()) + if err != nil { + t.Fatalf("stablejson.Marshal(guidance) error = %v", err) + } + wantContentSHA256 := fmt.Sprintf("sha256:%x", sha256.Sum256(guidanceBytes)) + if reference.ContentSHA256 != wantContentSHA256 { + t.Fatalf("GuidanceReference().ContentSHA256 = %q, want %q", reference.ContentSHA256, wantContentSHA256) + } + encoded, err := stablejson.Marshal(reference.JSONValue()) + if err != nil { + t.Fatalf("stablejson.Marshal() error = %v", err) + } + decoded, err := admission.DecodeJSON(bytes.NewReader(encoded), int64(len(encoded))) + if err != nil { + t.Fatalf("DecodeJSON() error = %v", err) + } + if _, err := AdmitReference(decoded); err != nil { + t.Fatalf("AdmitReference() error = %v", err) + } + decoded.(map[string]any)["slotCount"] = json.Number("21") + if _, err := AdmitReference(decoded); err == nil { + t.Fatal("AdmitReference accepted owner drift") + } + decoded, err = admission.DecodeJSON(bytes.NewReader(encoded), int64(len(encoded))) + if err != nil { + t.Fatalf("DecodeJSON() error = %v", err) + } + decoded.(map[string]any)["contentSha256"] = "sha256:0000000000000000000000000000000000000000000000000000000000000000" + if _, err := AdmitReference(decoded); err == nil { + t.Fatal("AdmitReference accepted content identity drift") + } +} + func slotFingerprint(slot Slot) string { value := fmt.Sprintf("%d\x1f%s\x1f%s\x1f%s\x1f%s\x1f%s", slot.Order, slot.SlotID, slot.ApplicabilityClass, slot.Question, slot.RequiredConsumerDecision, slot.CompletionCriterion) return fmt.Sprintf("%x", sha256.Sum256([]byte(value))) diff --git a/internal/command/repositoryinventory/admission.go b/internal/command/repositoryinventory/admission.go new file mode 100644 index 0000000..966d8ba --- /dev/null +++ b/internal/command/repositoryinventory/admission.go @@ -0,0 +1,179 @@ +package repositoryinventory + +import ( + "fmt" + "slices" + + "github.com/research-engineering/agentic-proofkit/internal/kernel/admit" +) + +func AdmitOutput(raw any) (Snapshot, error) { + record, ok := raw.(map[string]any) + if !ok { + return Snapshot{}, fmt.Errorf("repository inventory must be an object") + } + if err := admit.KnownKeys(record, []string{"entries", "inventoryId", "inventoryKind", "nonClaims", "omissions", "policyId", "schemaVersion", "scope"}, "repository inventory"); err != nil { + return Snapshot{}, err + } + if !admit.JSONNumberEquals(record["schemaVersion"], SchemaVersion) || record["inventoryKind"] != InventoryKind || record["policyId"] != PolicyID { + return Snapshot{}, fmt.Errorf("repository inventory identity is invalid") + } + if err := admitExactScope(record["scope"]); err != nil { + return Snapshot{}, err + } + entries, err := admitEntries(record["entries"]) + if err != nil { + return Snapshot{}, err + } + omissions, err := admitOmissions(record["omissions"]) + if err != nil { + return Snapshot{}, err + } + seenPaths := make(map[string]struct{}, len(entries)+len(omissions.OmittedRecognized)) + for _, entry := range entries { + seenPaths[entry.Path] = struct{}{} + } + for _, entry := range omissions.OmittedRecognized { + if _, duplicate := seenPaths[entry.Path]; duplicate { + return Snapshot{}, fmt.Errorf("repository inventory observed and omitted paths must be disjoint") + } + seenPaths[entry.Path] = struct{}{} + } + if omissions.RootEntryCount != len(entries)+len(omissions.OmittedRecognized)+omissions.UnrecognizedCount { + return Snapshot{}, fmt.Errorf("repository inventory omissions do not partition root entries") + } + nonClaims, err := admit.TextArray(record["nonClaims"], "repository inventory nonClaims", false) + if err != nil || !equalStrings(nonClaims, boundaryNonClaims) { + return Snapshot{}, fmt.Errorf("repository inventory nonClaims are not exact") + } + inventoryID, err := admit.SHA256Ref(record["inventoryId"], "repository inventory inventoryId") + if err != nil { + return Snapshot{}, err + } + snapshot, err := finalize(Snapshot{Entries: entries, Omissions: omissions}) + if err != nil { + return Snapshot{}, err + } + if snapshot.InventoryID != inventoryID { + return Snapshot{}, fmt.Errorf("repository inventory inventoryId does not match admitted content") + } + return snapshot, nil +} + +func admitExactScope(raw any) error { + record, ok := raw.(map[string]any) + if !ok { + return fmt.Errorf("repository inventory scope must be an object") + } + if err := admit.KnownKeys(record, []string{"class", "repositoryRootState", "versionControlState"}, "repository inventory scope"); err != nil { + return err + } + if record["class"] != "root_catalog" || record["repositoryRootState"] != "caller_selected_not_disclosed" || record["versionControlState"] != "not_evaluated" { + return fmt.Errorf("repository inventory scope is invalid") + } + return nil +} + +func admitEntries(raw any) ([]Entry, error) { + values, ok := raw.([]any) + if !ok || len(values) > len(rootCatalog) { + return nil, fmt.Errorf("repository inventory entries must be a bounded array") + } + entries := make([]Entry, 0, len(values)) + previousPath := "" + aggregateBytes := 0 + for index, rawEntry := range values { + record, ok := rawEntry.(map[string]any) + if !ok { + return nil, fmt.Errorf("repository inventory entry %d must be an object", index) + } + if err := admit.KnownKeys(record, []string{"byteLength", "contentSha256", "path", "role", "syntaxState"}, "repository inventory entry"); err != nil { + return nil, err + } + path, err := admit.NonEmptyText(record["path"], "repository inventory entry path") + if err != nil { + return nil, err + } + role, recognized := CatalogRole(path) + if !recognized || record["role"] != role || record["syntaxState"] != "not_evaluated" { + return nil, fmt.Errorf("repository inventory entry does not match the catalog") + } + if previousPath != "" && previousPath >= path { + return nil, fmt.Errorf("repository inventory entries must be sorted and unique") + } + previousPath = path + length, err := boundedCount(record["byteLength"], MaximumFileBytes, "repository inventory entry byteLength") + if err != nil { + return nil, err + } + if aggregateBytes > MaximumAggregateBytes-length { + return nil, fmt.Errorf("repository inventory entries exceed aggregate byte limit") + } + aggregateBytes += length + sha, err := admit.SHA256Ref(record["contentSha256"], "repository inventory entry contentSha256") + if err != nil { + return nil, err + } + entries = append(entries, Entry{ByteLength: length, ContentSHA256: sha, Path: path, Role: role, SyntaxState: "not_evaluated"}) + } + return entries, nil +} + +func admitOmissions(raw any) (Omissions, error) { + record, ok := raw.(map[string]any) + if !ok { + return Omissions{}, fmt.Errorf("repository inventory omissions must be an object") + } + if err := admit.KnownKeys(record, []string{"omittedRecognized", "rootEntryCount", "unrecognizedCount"}, "repository inventory omissions"); err != nil { + return Omissions{}, err + } + rootCount, err := boundedCount(record["rootEntryCount"], MaximumRootEntries, "repository inventory rootEntryCount") + if err != nil { + return Omissions{}, err + } + unknownCount, err := boundedCount(record["unrecognizedCount"], MaximumRootEntries, "repository inventory unrecognizedCount") + if err != nil { + return Omissions{}, err + } + rawOmitted, ok := record["omittedRecognized"].([]any) + if !ok || len(rawOmitted) > len(rootCatalog) { + return Omissions{}, fmt.Errorf("repository inventory omittedRecognized must be a bounded array") + } + omitted := make([]OmittedRecognizedEntry, 0, len(rawOmitted)) + previousPath := "" + for _, rawEntry := range rawOmitted { + entry, ok := rawEntry.(map[string]any) + if !ok { + return Omissions{}, fmt.Errorf("repository inventory omittedRecognized entry must be an object") + } + if err := admit.KnownKeys(entry, []string{"path", "reason"}, "repository inventory omittedRecognized entry"); err != nil { + return Omissions{}, err + } + path, err := admit.NonEmptyText(entry["path"], "repository inventory omittedRecognized path") + if err != nil { + return Omissions{}, err + } + if _, recognized := CatalogRole(path); !recognized || previousPath != "" && previousPath >= path { + return Omissions{}, fmt.Errorf("repository inventory omittedRecognized entries must be sorted unique catalog paths") + } + previousPath = path + reason, err := admit.Enum(entry["reason"], map[string]struct{}{OmissionNonText: {}, OmissionOversize: {}}, "repository inventory omittedRecognized reason") + if err != nil { + return Omissions{}, err + } + omitted = append(omitted, OmittedRecognizedEntry{Path: path, Reason: reason}) + } + return Omissions{OmittedRecognized: omitted, RootEntryCount: rootCount, UnrecognizedCount: unknownCount}, nil +} + +func boundedCount(raw any, maximum int, context string) (int, error) { + value, err := admit.CanonicalInteger(raw, context) + if err != nil || value < 0 || value > int64(maximum) { + return 0, fmt.Errorf("%s must be an integer between 0 and %d", context, maximum) + } + return int(value), nil +} + +func equalStrings(left, right []string) bool { + return slices.Equal(left, right) +} diff --git a/internal/command/repositoryinventory/fifo_unix_test.go b/internal/command/repositoryinventory/fifo_unix_test.go new file mode 100644 index 0000000..3b4f914 --- /dev/null +++ b/internal/command/repositoryinventory/fifo_unix_test.go @@ -0,0 +1,62 @@ +//go:build darwin || linux + +package repositoryinventory + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "strings" + "syscall" + "testing" + "time" +) + +const fifoHelperEnvironment = "PROOFKIT_REPOSITORY_INVENTORY_FIFO_HELPER" + +func TestScanRejectsFIFOReplacementWithoutBlocking(t *testing.T) { + if os.Getenv(fifoHelperEnvironment) == "1" { + runFIFOReplacementHelper(t) + return + } + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + command := exec.CommandContext(ctx, os.Args[0], "-test.run=^TestScanRejectsFIFOReplacementWithoutBlocking$") + command.Env = append(os.Environ(), fifoHelperEnvironment+"=1") + output, err := command.CombinedOutput() + if ctx.Err() != nil { + t.Fatalf("FIFO replacement was not rejected within the bounded subprocess lifetime: %v", ctx.Err()) + } + if err != nil { + t.Fatalf("FIFO replacement helper failed: %v\n%s", err, output) + } +} + +func runFIFOReplacementHelper(t *testing.T) { + t.Helper() + rootPath := t.TempDir() + path := filepath.Join(rootPath, "README.md") + if err := os.WriteFile(path, []byte("regular\n"), 0o600); err != nil { + t.Fatal(err) + } + root, err := os.OpenRoot(rootPath) + if err != nil { + t.Fatal(err) + } + defer root.Close() + info, err := root.Lstat("README.md") + if err != nil { + t.Fatal(err) + } + if err := os.Remove(path); err != nil { + t.Fatal(err) + } + if err := syscall.Mkfifo(path, 0o600); err != nil { + t.Fatal(err) + } + value := candidate{info: info, item: catalogItem{Path: "README.md", Role: "human_overview"}} + if _, err := readCandidate(root, value, MaximumFileBytes, MaximumAggregateBytes); err == nil || !strings.Contains(err.Error(), "recognized repository entry") { + t.Fatalf("readCandidate() error = %v, want non-regular replacement rejection", err) + } +} diff --git a/internal/command/repositoryinventory/model.go b/internal/command/repositoryinventory/model.go new file mode 100644 index 0000000..27634f9 --- /dev/null +++ b/internal/command/repositoryinventory/model.go @@ -0,0 +1,194 @@ +// Package repositoryinventory owns the bounded, read-only repository root +// inventory used by adoption planning. +package repositoryinventory + +import ( + "encoding/json" + "fmt" + "slices" + + "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/stablejson" +) + +const ( + SchemaVersion = 1 + InventoryKind = "proofkit.repository-inventory" + PolicyID = "proofkit.repository-root-catalog.v1" + + MaximumRootEntries = 4096 + MaximumFileBytes = 1 << 20 + MaximumAggregateBytes = 8 << 20 + MaximumOutputBytes = 128 << 10 +) + +const ( + OmissionNonText = "non_text" + OmissionOversize = "over_file_limit" +) + +var boundaryNonClaims = []string{ + "Repository inventory records a bounded read observation and does not prove an atomic repository snapshot or continuing freshness.", + "Repository inventory re-admission proves canonical structure and content identity; it does not authenticate scanner execution or filesystem origin.", + "Repository inventory does not evaluate version-control trackedness, ignored state, manifest syntax, dependencies, source semantics, stack identity, requirement meaning, proof adequacy, merge readiness, release readiness, rollout, or production readiness.", + "Unrecognized root entry names are used only for fixed-catalog membership and aggregate counting; their names and metadata are not retained or disclosed, and their file contents are not opened.", +} + +type catalogItem struct { + Path string + Role string +} + +var rootCatalog = [...]catalogItem{ + {Path: "AGENTS.md", Role: "agent_instructions"}, + {Path: "Cargo.lock", Role: "dependency_lock"}, + {Path: "Cargo.toml", Role: "ecosystem_manifest"}, + {Path: "Gemfile", Role: "ecosystem_manifest"}, + {Path: "Gemfile.lock", Role: "dependency_lock"}, + {Path: "README.md", Role: "human_overview"}, + {Path: "build.gradle", Role: "build_configuration"}, + {Path: "build.gradle.kts", Role: "build_configuration"}, + {Path: "bun.lock", Role: "dependency_lock"}, + {Path: "composer.json", Role: "ecosystem_manifest"}, + {Path: "composer.lock", Role: "dependency_lock"}, + {Path: "deno.json", Role: "ecosystem_manifest"}, + {Path: "deno.jsonc", Role: "ecosystem_manifest"}, + {Path: "go.mod", Role: "ecosystem_manifest"}, + {Path: "go.sum", Role: "dependency_lock"}, + {Path: "package-lock.json", Role: "dependency_lock"}, + {Path: "package.json", Role: "ecosystem_manifest"}, + {Path: "pnpm-lock.yaml", Role: "dependency_lock"}, + {Path: "poetry.lock", Role: "dependency_lock"}, + {Path: "pom.xml", Role: "ecosystem_manifest"}, + {Path: "pyproject.toml", Role: "ecosystem_manifest"}, + {Path: "requirements.txt", Role: "dependency_declaration"}, + {Path: "tsconfig.json", Role: "build_configuration"}, + {Path: "uv.lock", Role: "dependency_lock"}, + {Path: "yarn.lock", Role: "dependency_lock"}, +} + +type Entry struct { + ByteLength int + ContentSHA256 string + Path string + Role string + SyntaxState string +} + +type OmittedRecognizedEntry struct { + Path string + Reason string +} + +type Omissions struct { + OmittedRecognized []OmittedRecognizedEntry + RootEntryCount int + UnrecognizedCount int +} + +type Snapshot struct { + Entries []Entry + InventoryID string + Omissions Omissions +} + +func CatalogPaths() []string { + paths := make([]string, 0, len(rootCatalog)) + for _, item := range rootCatalog { + paths = append(paths, item.Path) + } + return paths +} + +func CatalogRole(path string) (string, bool) { + index, found := slices.BinarySearchFunc(rootCatalog[:], path, func(item catalogItem, target string) int { + switch { + case item.Path < target: + return -1 + case item.Path > target: + return 1 + default: + return 0 + } + }) + if !found { + return "", false + } + return rootCatalog[index].Role, true +} + +func (snapshot Snapshot) JSONValue() map[string]any { + entries := make([]any, 0, len(snapshot.Entries)) + for _, entry := range snapshot.Entries { + entries = append(entries, entryValue(entry)) + } + omitted := make([]any, 0, len(snapshot.Omissions.OmittedRecognized)) + for _, entry := range snapshot.Omissions.OmittedRecognized { + omitted = append(omitted, map[string]any{"path": entry.Path, "reason": entry.Reason}) + } + return map[string]any{ + "entries": entries, + "inventoryId": snapshot.InventoryID, + "inventoryKind": InventoryKind, + "nonClaims": admit.StringSliceToAny(boundaryNonClaims), + "omissions": map[string]any{ + "omittedRecognized": omitted, + "rootEntryCount": json.Number(fmt.Sprintf("%d", snapshot.Omissions.RootEntryCount)), + "unrecognizedCount": json.Number(fmt.Sprintf("%d", snapshot.Omissions.UnrecognizedCount)), + }, + "policyId": PolicyID, + "schemaVersion": json.Number("1"), + "scope": map[string]any{ + "class": "root_catalog", + "repositoryRootState": "caller_selected_not_disclosed", + "versionControlState": "not_evaluated", + }, + } +} + +func entryValue(entry Entry) map[string]any { + return map[string]any{ + "byteLength": json.Number(fmt.Sprintf("%d", entry.ByteLength)), + "contentSha256": entry.ContentSHA256, + "path": entry.Path, + "role": entry.Role, + "syntaxState": entry.SyntaxState, + } +} + +func identityValue(snapshot Snapshot) map[string]any { + entries := make([]any, 0, len(snapshot.Entries)) + for _, entry := range snapshot.Entries { + entries = append(entries, entryValue(entry)) + } + omitted := make([]any, 0, len(snapshot.Omissions.OmittedRecognized)) + for _, entry := range snapshot.Omissions.OmittedRecognized { + omitted = append(omitted, map[string]any{"path": entry.Path, "reason": entry.Reason}) + } + return map[string]any{ + "entries": entries, + "omissions": map[string]any{ + "omittedRecognized": omitted, + "rootEntryCount": json.Number(fmt.Sprintf("%d", snapshot.Omissions.RootEntryCount)), + "unrecognizedCount": json.Number(fmt.Sprintf("%d", snapshot.Omissions.UnrecognizedCount)), + }, + "policyId": PolicyID, + } +} + +func finalize(snapshot Snapshot) (Snapshot, error) { + id, err := digest.StableJSONSHA256Ref(identityValue(snapshot)) + if err != nil { + return Snapshot{}, err + } + snapshot.InventoryID = id + encoded, err := stablejson.Marshal(snapshot.JSONValue()) + if err != nil { + return Snapshot{}, err + } + if len(encoded) > MaximumOutputBytes { + return Snapshot{}, fmt.Errorf("repository inventory exceeds output byte limit") + } + return snapshot, nil +} diff --git a/internal/command/repositoryinventory/open_candidate_other.go b/internal/command/repositoryinventory/open_candidate_other.go new file mode 100644 index 0000000..3926eb4 --- /dev/null +++ b/internal/command/repositoryinventory/open_candidate_other.go @@ -0,0 +1,16 @@ +//go:build !(darwin || linux) + +package repositoryinventory + +import ( + "fmt" + "os" +) + +func requireScannerPlatform() error { + return fmt.Errorf("repository inventory scanning is unsupported on this platform") +} + +func openCandidateFile(root *os.Root, path string) (*os.File, error) { + return nil, fmt.Errorf("repository inventory scanning is unsupported on this platform") +} diff --git a/internal/command/repositoryinventory/open_candidate_unix.go b/internal/command/repositoryinventory/open_candidate_unix.go new file mode 100644 index 0000000..01d9160 --- /dev/null +++ b/internal/command/repositoryinventory/open_candidate_unix.go @@ -0,0 +1,16 @@ +//go:build darwin || linux + +package repositoryinventory + +import ( + "os" + "syscall" +) + +func requireScannerPlatform() error { + return nil +} + +func openCandidateFile(root *os.Root, path string) (*os.File, error) { + return root.OpenFile(path, os.O_RDONLY|syscall.O_NONBLOCK|syscall.O_NOFOLLOW, 0) +} diff --git a/internal/command/repositoryinventory/repositoryinventory_test.go b/internal/command/repositoryinventory/repositoryinventory_test.go new file mode 100644 index 0000000..6aac48b --- /dev/null +++ b/internal/command/repositoryinventory/repositoryinventory_test.go @@ -0,0 +1,521 @@ +package repositoryinventory + +import ( + "bytes" + "context" + "io" + "os" + "path/filepath" + "slices" + "strconv" + "strings" + "testing" + + "github.com/research-engineering/agentic-proofkit/internal/kernel/admission" + "github.com/research-engineering/agentic-proofkit/internal/kernel/digest" + "github.com/research-engineering/agentic-proofkit/internal/kernel/stablejson" + "github.com/research-engineering/agentic-proofkit/internal/testsupport/commandcoverage" +) + +func TestCatalogPathsAreSortedAndUnique(t *testing.T) { + paths := CatalogPaths() + if !slices.IsSorted(paths) { + t.Fatalf("CatalogPaths() = %v, want sorted paths", paths) + } + for index := 1; index < len(paths); index++ { + if paths[index-1] == paths[index] { + t.Fatalf("CatalogPaths() contains duplicate %q", paths[index]) + } + } +} + +func TestCatalogRolePolicyIsExact(t *testing.T) { + want := map[string]string{ + "AGENTS.md": "agent_instructions", + "Cargo.lock": "dependency_lock", + "Cargo.toml": "ecosystem_manifest", + "Gemfile": "ecosystem_manifest", + "Gemfile.lock": "dependency_lock", + "README.md": "human_overview", + "build.gradle": "build_configuration", + "build.gradle.kts": "build_configuration", + "bun.lock": "dependency_lock", + "composer.json": "ecosystem_manifest", + "composer.lock": "dependency_lock", + "deno.json": "ecosystem_manifest", + "deno.jsonc": "ecosystem_manifest", + "go.mod": "ecosystem_manifest", + "go.sum": "dependency_lock", + "package-lock.json": "dependency_lock", + "package.json": "ecosystem_manifest", + "pnpm-lock.yaml": "dependency_lock", + "poetry.lock": "dependency_lock", + "pom.xml": "ecosystem_manifest", + "pyproject.toml": "ecosystem_manifest", + "requirements.txt": "dependency_declaration", + "tsconfig.json": "build_configuration", + "uv.lock": "dependency_lock", + "yarn.lock": "dependency_lock", + } + if got := CatalogPaths(); len(got) != len(want) { + t.Fatalf("catalog path count = %d, want %d", len(got), len(want)) + } + for path, wantRole := range want { + gotRole, ok := CatalogRole(path) + if !ok || gotRole != wantRole { + t.Fatalf("CatalogRole(%q) = %q/%t, want %q/true", path, gotRole, ok, wantRole) + } + } + if role, ok := CatalogRole("unknown"); ok || role != "" { + t.Fatalf("CatalogRole(unknown) = %q/%t, want empty/false", role, ok) + } +} + +func TestReadRootInventoryClassifiesPartialBatchesWithoutRetainingUnknownNames(t *testing.T) { + root := t.TempDir() + writeInventoryFixture(t, root, "README.md", nil) + writeInventoryFixture(t, root, "api_key=unknown-name-sentinel", nil) + entries, err := os.ReadDir(root) + if err != nil { + t.Fatalf("ReadDir() error = %v", err) + } + reader := &partialDirectoryReader{batches: [][]os.DirEntry{{entries[0]}, {entries[1]}}} + inventory, err := readRootInventory(context.Background(), reader, 2) + if err != nil { + t.Fatalf("readRootInventory() error = %v", err) + } + if inventory.rootEntryCount != 2 || inventory.unrecognizedCount != 1 || + !slices.Equal(inventory.catalogItems, []catalogItem{{Path: "README.md", Role: "human_overview"}}) { + t.Fatalf("readRootInventory() = %#v, want one fixed-catalog item and one opaque count", inventory) + } + for _, item := range inventory.catalogItems { + if strings.Contains(item.Path, "sentinel") { + t.Fatalf("readRootInventory retained unknown name in %#v", inventory) + } + } + + reader = &partialDirectoryReader{batches: [][]os.DirEntry{{entries[0]}, {entries[1]}}} + if _, err := readRootInventory(context.Background(), reader, 1); err == nil || !strings.Contains(err.Error(), "entry limit") { + t.Fatalf("readRootInventory() error = %v, want entry-limit rejection", err) + } +} + +type partialDirectoryReader struct { + batches [][]os.DirEntry + index int +} + +func (reader *partialDirectoryReader) ReadDir(_ int) ([]os.DirEntry, error) { + if reader.index >= len(reader.batches) { + return nil, io.EOF + } + batch := reader.batches[reader.index] + reader.index++ + return batch, nil +} + +func TestScanProducesBoundedClosedInventory(t *testing.T) { + commandcoverage.SemanticRoute(t, "proofkit.command_coverage.source_oracle.v1.110211556856580299924889797330865109231063384949664159872540683038190051047716") + root := t.TempDir() + writeInventoryFixture(t, root, "README.md", []byte("# Example\n")) + writeInventoryFixture(t, root, "package.json", []byte("{\"name\":\"example\"}\n")) + unknownName := "api_key=sentinel-should-not-be-disclosed" + writeInventoryFixture(t, root, unknownName, []byte("sentinel-unknown-content")) + + snapshot, err := Scan(context.Background(), root) + if err != nil { + t.Fatalf("Scan() error = %v", err) + } + if got, want := len(snapshot.Entries), 2; got != want { + t.Fatalf("len(Entries) = %d, want %d", got, want) + } + if snapshot.Entries[0].Path != "README.md" || snapshot.Entries[1].Path != "package.json" { + t.Fatalf("Entries = %#v, want canonical catalog order", snapshot.Entries) + } + if snapshot.Entries[0].ContentSHA256 != digest.SHA256BytesRef([]byte("# Example\n")) { + t.Fatalf("README digest = %q, want content digest", snapshot.Entries[0].ContentSHA256) + } + if snapshot.Omissions.RootEntryCount != 3 || snapshot.Omissions.UnrecognizedCount != 1 { + t.Fatalf("Omissions = %#v, want closed root partition", snapshot.Omissions) + } + + encoded, err := stablejson.Marshal(snapshot.JSONValue()) + if err != nil { + t.Fatalf("stablejson.Marshal() error = %v", err) + } + for _, forbidden := range []string{root, unknownName, "sentinel-unknown-content"} { + if strings.Contains(string(encoded), forbidden) { + t.Fatalf("inventory disclosed %q: %s", forbidden, encoded) + } + } + decoded, err := admission.DecodeJSON(bytes.NewReader(encoded), int64(len(encoded))) + if err != nil { + t.Fatalf("DecodeJSON() error = %v", err) + } + admitted, err := AdmitOutput(decoded) + if err != nil { + t.Fatalf("AdmitOutput() error = %v", err) + } + if admitted.InventoryID != snapshot.InventoryID { + t.Fatalf("AdmitOutput().InventoryID = %q, want %q", admitted.InventoryID, snapshot.InventoryID) + } +} + +func TestScanRejectsRecognizedSymlinkWithoutReadingTarget(t *testing.T) { + root := t.TempDir() + target := filepath.Join(t.TempDir(), "secret.txt") + writeInventoryFixture(t, filepath.Dir(target), filepath.Base(target), []byte("sentinel-target-content")) + if err := os.Symlink(target, filepath.Join(root, "README.md")); err != nil { + t.Fatalf("Symlink() error = %v", err) + } + + _, err := Scan(context.Background(), root) + if err == nil || !strings.Contains(err.Error(), "must not be a symlink") { + t.Fatalf("Scan() error = %v, want recognized symlink rejection", err) + } + if strings.Contains(err.Error(), target) || strings.Contains(err.Error(), "sentinel") { + t.Fatalf("Scan() disclosed symlink target: %v", err) + } +} + +func TestScanDoesNotFollowUnknownSymlink(t *testing.T) { + root := t.TempDir() + target := filepath.Join(t.TempDir(), "secret.txt") + writeInventoryFixture(t, filepath.Dir(target), filepath.Base(target), []byte("sentinel-target-content")) + if err := os.Symlink(target, filepath.Join(root, "unknown-link")); err != nil { + t.Fatalf("Symlink() error = %v", err) + } + + snapshot, err := Scan(context.Background(), root) + if err != nil { + t.Fatalf("Scan() error = %v", err) + } + if len(snapshot.Entries) != 0 || snapshot.Omissions.RootEntryCount != 1 || snapshot.Omissions.UnrecognizedCount != 1 { + t.Fatalf("Scan() = %#v, want one opaque unrecognized entry", snapshot) + } + encoded, _ := stablejson.Marshal(snapshot.JSONValue()) + if strings.Contains(string(encoded), "unknown-link") || strings.Contains(string(encoded), "sentinel") { + t.Fatalf("inventory disclosed unknown symlink details: %s", encoded) + } +} + +func TestScanEnforcesPreflightBoundsAndExplicitOmissions(t *testing.T) { + t.Run("root entry limit", func(t *testing.T) { + rootPath := t.TempDir() + writeInventoryFixture(t, rootPath, "README.md", []byte("a")) + writeInventoryFixture(t, rootPath, "unknown", []byte("b")) + root, err := os.OpenRoot(rootPath) + if err != nil { + t.Fatalf("OpenRoot() error = %v", err) + } + defer root.Close() + policy := defaultScanPolicy + policy.maximumRootEntries = 1 + if _, err := scanRoot(context.Background(), root, policy); err == nil || !strings.Contains(err.Error(), "entry limit") { + t.Fatalf("scanRoot() error = %v, want entry limit rejection", err) + } + }) + + t.Run("aggregate byte limit precedes reads", func(t *testing.T) { + rootPath := t.TempDir() + writeInventoryFixture(t, rootPath, "README.md", []byte{0, 1}) + writeInventoryFixture(t, rootPath, "package.json", []byte("{}")) + root, err := os.OpenRoot(rootPath) + if err != nil { + t.Fatalf("OpenRoot() error = %v", err) + } + defer root.Close() + policy := defaultScanPolicy + policy.maximumAggregateBytes = 3 + if _, err := scanRoot(context.Background(), root, policy); err == nil || !strings.Contains(err.Error(), "aggregate byte limit") { + t.Fatalf("scanRoot() error = %v, want aggregate preflight rejection", err) + } + }) + + t.Run("growth after preflight remains aggregate bounded", func(t *testing.T) { + rootPath := t.TempDir() + writeInventoryFixture(t, rootPath, "README.md", []byte("a")) + root, err := os.OpenRoot(rootPath) + if err != nil { + t.Fatalf("OpenRoot() error = %v", err) + } + defer root.Close() + info, err := root.Lstat("README.md") + if err != nil { + t.Fatalf("Lstat() error = %v", err) + } + writeInventoryFixture(t, rootPath, "README.md", []byte("four")) + value := candidate{info: info, item: catalogItem{Path: "README.md", Role: "human_overview"}} + if _, err := readCandidate(root, value, 8, 3); err == nil || !strings.Contains(err.Error(), "aggregate byte limit") { + t.Fatalf("readCandidate() error = %v, want aggregate limit rejection", err) + } + }) + + t.Run("oversize and non-text files are explicit", func(t *testing.T) { + rootPath := t.TempDir() + writeInventoryFixture(t, rootPath, "README.md", []byte("four")) + writeInventoryFixture(t, rootPath, "package.json", []byte{0, 1}) + root, err := os.OpenRoot(rootPath) + if err != nil { + t.Fatalf("OpenRoot() error = %v", err) + } + defer root.Close() + policy := defaultScanPolicy + policy.maximumFileBytes = 3 + snapshot, err := scanRoot(context.Background(), root, policy) + if err != nil { + t.Fatalf("scanRoot() error = %v", err) + } + want := []OmittedRecognizedEntry{ + {Path: "README.md", Reason: OmissionOversize}, + {Path: "package.json", Reason: OmissionNonText}, + } + if !slices.Equal(snapshot.Omissions.OmittedRecognized, want) { + t.Fatalf("OmittedRecognized = %#v, want %#v", snapshot.Omissions.OmittedRecognized, want) + } + }) +} + +func TestScanPolicyBoundariesAreExact(t *testing.T) { + t.Run("root entries", func(t *testing.T) { + for _, test := range []struct { + count int + wantErr bool + }{{count: 1}, {count: 2}, {count: 3, wantErr: true}} { + t.Run(strconv.Itoa(test.count), func(t *testing.T) { + rootPath := t.TempDir() + for index := 0; index < test.count; index++ { + writeInventoryFixture(t, rootPath, string(rune('a'+index)), []byte("x")) + } + root, err := os.OpenRoot(rootPath) + if err != nil { + t.Fatal(err) + } + defer root.Close() + policy := defaultScanPolicy + policy.maximumRootEntries = 2 + snapshot, err := scanRoot(context.Background(), root, policy) + if test.wantErr { + if err == nil || !strings.Contains(err.Error(), "entry limit") { + t.Fatalf("scanRoot() error = %v, want entry-limit rejection", err) + } + return + } + if err != nil { + t.Fatalf("scanRoot() error = %v", err) + } + assertInventoryRoundTrip(t, snapshot) + }) + } + }) + + t.Run("file bytes", func(t *testing.T) { + for _, size := range []int{2, 3, 4} { + t.Run(strconv.Itoa(size), func(t *testing.T) { + rootPath := t.TempDir() + writeInventoryFixture(t, rootPath, "README.md", bytes.Repeat([]byte("x"), size)) + root, err := os.OpenRoot(rootPath) + if err != nil { + t.Fatal(err) + } + defer root.Close() + policy := defaultScanPolicy + policy.maximumFileBytes = 3 + snapshot, err := scanRoot(context.Background(), root, policy) + if err != nil { + t.Fatalf("scanRoot() error = %v", err) + } + if size <= 3 { + if len(snapshot.Entries) != 1 || len(snapshot.Omissions.OmittedRecognized) != 0 { + t.Fatalf("size %d snapshot = %#v, want observed entry", size, snapshot) + } + } else if len(snapshot.Entries) != 0 || !slices.Equal(snapshot.Omissions.OmittedRecognized, []OmittedRecognizedEntry{{Path: "README.md", Reason: OmissionOversize}}) { + t.Fatalf("size %d snapshot = %#v, want explicit oversize omission", size, snapshot) + } + assertInventoryRoundTrip(t, snapshot) + }) + } + }) + + t.Run("aggregate bytes", func(t *testing.T) { + for _, total := range []int{3, 4, 5} { + t.Run(strconv.Itoa(total), func(t *testing.T) { + rootPath := t.TempDir() + writeInventoryFixture(t, rootPath, "README.md", []byte("xx")) + writeInventoryFixture(t, rootPath, "package.json", bytes.Repeat([]byte("y"), total-2)) + root, err := os.OpenRoot(rootPath) + if err != nil { + t.Fatal(err) + } + defer root.Close() + policy := defaultScanPolicy + policy.maximumAggregateBytes = 4 + policy.maximumFileBytes = 8 + snapshot, err := scanRoot(context.Background(), root, policy) + if total == 5 { + if err == nil || !strings.Contains(err.Error(), "aggregate byte limit") { + t.Fatalf("scanRoot() error = %v, want aggregate-limit rejection", err) + } + return + } + if err != nil { + t.Fatalf("scanRoot() error = %v", err) + } + assertInventoryRoundTrip(t, snapshot) + }) + } + }) +} + +func assertInventoryRoundTrip(t *testing.T, snapshot Snapshot) { + t.Helper() + content, err := stablejson.Marshal(snapshot.JSONValue()) + if err != nil { + t.Fatalf("stablejson.Marshal() error = %v", err) + } + value, err := admission.DecodeJSON(bytes.NewReader(content), int64(len(content))) + if err != nil { + t.Fatalf("DecodeJSON() error = %v", err) + } + admitted, err := AdmitOutput(value) + if err != nil { + t.Fatalf("AdmitOutput() error = %v", err) + } + if admitted.InventoryID != snapshot.InventoryID { + t.Fatalf("round-trip inventory id = %q, want %q", admitted.InventoryID, snapshot.InventoryID) + } +} + +func TestInventoryIdentityBindsEverySemanticOperand(t *testing.T) { + base := Snapshot{ + Entries: []Entry{{ + ByteLength: 7, + ContentSHA256: digest.SHA256BytesRef([]byte("content")), + Path: "README.md", + Role: "human_overview", + SyntaxState: "not_evaluated", + }}, + Omissions: Omissions{ + OmittedRecognized: []OmittedRecognizedEntry{{Path: "package.json", Reason: OmissionNonText}}, + RootEntryCount: 3, + UnrecognizedCount: 1, + }, + } + base, err := finalize(base) + if err != nil { + t.Fatalf("finalize(base) error = %v", err) + } + mutations := map[string]func(*Snapshot){ + "entry byte length": func(value *Snapshot) { value.Entries[0].ByteLength++ }, + "entry content digest": func(value *Snapshot) { value.Entries[0].ContentSHA256 = digest.SHA256BytesRef([]byte("changed")) }, + "entry path": func(value *Snapshot) { value.Entries[0].Path = "AGENTS.md" }, + "entry role": func(value *Snapshot) { value.Entries[0].Role = "agent_instructions" }, + "entry syntax state": func(value *Snapshot) { value.Entries[0].SyntaxState = "evaluated" }, + "omitted path": func(value *Snapshot) { value.Omissions.OmittedRecognized[0].Path = "pyproject.toml" }, + "omitted reason": func(value *Snapshot) { value.Omissions.OmittedRecognized[0].Reason = OmissionOversize }, + "root entry count": func(value *Snapshot) { value.Omissions.RootEntryCount++ }, + "unrecognized entry count": func(value *Snapshot) { value.Omissions.UnrecognizedCount++ }, + } + for name, mutate := range mutations { + t.Run(name, func(t *testing.T) { + candidate := Snapshot{ + Entries: append([]Entry(nil), base.Entries...), + Omissions: Omissions{ + OmittedRecognized: append([]OmittedRecognizedEntry(nil), base.Omissions.OmittedRecognized...), + RootEntryCount: base.Omissions.RootEntryCount, + UnrecognizedCount: base.Omissions.UnrecognizedCount, + }, + } + mutate(&candidate) + candidate, err = finalize(candidate) + if err != nil { + t.Fatalf("finalize(mutant) error = %v", err) + } + if candidate.InventoryID == base.InventoryID { + t.Fatalf("%s did not change inventory identity", name) + } + }) + } +} + +func TestScanHonorsCancellationBeforeFilesystemAccess(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + _, err := Scan(ctx, filepath.Join(t.TempDir(), "missing")) + if err != context.Canceled { + t.Fatalf("Scan() error = %v, want context.Canceled", err) + } +} + +func TestAdmitOutputRejectsIdentityAndPartitionDrift(t *testing.T) { + root := t.TempDir() + writeInventoryFixture(t, root, "README.md", []byte("example")) + snapshot, err := Scan(context.Background(), root) + if err != nil { + t.Fatalf("Scan() error = %v", err) + } + + decode := func(t *testing.T) map[string]any { + t.Helper() + encoded, err := stablejson.Marshal(snapshot.JSONValue()) + if err != nil { + t.Fatalf("stablejson.Marshal() error = %v", err) + } + decoded, err := admission.DecodeJSON(bytes.NewReader(encoded), int64(len(encoded))) + if err != nil { + t.Fatalf("DecodeJSON() error = %v", err) + } + return decoded.(map[string]any) + } + + t.Run("identity", func(t *testing.T) { + record := decode(t) + record["inventoryId"] = digest.SHA256BytesRef([]byte("different")) + if _, err := AdmitOutput(record); err == nil || !strings.Contains(err.Error(), "does not match") { + t.Fatalf("AdmitOutput() error = %v, want identity rejection", err) + } + }) + + t.Run("partition", func(t *testing.T) { + record := decode(t) + omissions := record["omissions"].(map[string]any) + omissions["unrecognizedCount"] = omissions["rootEntryCount"] + if _, err := AdmitOutput(record); err == nil || !strings.Contains(err.Error(), "partition") { + t.Fatalf("AdmitOutput() error = %v, want partition rejection", err) + } + }) + + t.Run("catalog role", func(t *testing.T) { + record := decode(t) + entries := record["entries"].([]any) + entries[0].(map[string]any)["role"] = "ecosystem_manifest" + if _, err := AdmitOutput(record); err == nil || !strings.Contains(err.Error(), "catalog") { + t.Fatalf("AdmitOutput() error = %v, want catalog-role rejection", err) + } + }) + + t.Run("observed and omitted overlap", func(t *testing.T) { + record := decode(t) + omissions := record["omissions"].(map[string]any) + omissions["omittedRecognized"] = []any{map[string]any{"path": "README.md", "reason": OmissionNonText}} + omissions["rootEntryCount"] = jsonNumber("2") + if _, err := AdmitOutput(record); err == nil || !strings.Contains(err.Error(), "disjoint") { + t.Fatalf("AdmitOutput() error = %v, want overlap rejection", err) + } + }) +} + +func writeInventoryFixture(t *testing.T, root, name string, content []byte) { + t.Helper() + if err := os.WriteFile(filepath.Join(root, name), content, 0o600); err != nil { + t.Fatalf("WriteFile(%q) error = %v", name, err) + } +} + +func jsonNumber(value string) any { + decoded, err := admission.DecodeJSON(strings.NewReader(value), int64(len(value))) + if err != nil { + panic(err) + } + return decoded +} diff --git a/internal/command/repositoryinventory/scan.go b/internal/command/repositoryinventory/scan.go new file mode 100644 index 0000000..64df6dd --- /dev/null +++ b/internal/command/repositoryinventory/scan.go @@ -0,0 +1,223 @@ +package repositoryinventory + +import ( + "bytes" + "context" + "fmt" + "io" + "os" + "sort" + "unicode/utf8" + + "github.com/research-engineering/agentic-proofkit/internal/kernel/digest" +) + +type candidate struct { + info os.FileInfo + item catalogItem +} + +type scanPolicy struct { + maximumAggregateBytes int64 + maximumFileBytes int64 + maximumRootEntries int +} + +type directoryEntryReader interface { + ReadDir(int) ([]os.DirEntry, error) +} + +type rootInventory struct { + catalogItems []catalogItem + rootEntryCount int + unrecognizedCount int +} + +var defaultScanPolicy = scanPolicy{ + maximumAggregateBytes: MaximumAggregateBytes, + maximumFileBytes: MaximumFileBytes, + maximumRootEntries: MaximumRootEntries, +} + +func Scan(ctx context.Context, repositoryRoot string) (Snapshot, error) { + if err := ctx.Err(); err != nil { + return Snapshot{}, err + } + if err := requireScannerPlatform(); err != nil { + return Snapshot{}, err + } + root, err := os.OpenRoot(repositoryRoot) + if err != nil { + return Snapshot{}, fmt.Errorf("open repository root") + } + snapshot, scanErr := scanRoot(ctx, root, defaultScanPolicy) + if closeErr := root.Close(); scanErr == nil && closeErr != nil { + return Snapshot{}, fmt.Errorf("close repository root") + } + return snapshot, scanErr +} + +func scanRoot(ctx context.Context, root *os.Root, policy scanPolicy) (Snapshot, error) { + directory, err := root.Open(".") + if err != nil { + return Snapshot{}, fmt.Errorf("open repository root directory") + } + inventory, readErr := readRootInventory(ctx, directory, policy.maximumRootEntries) + closeErr := directory.Close() + if readErr != nil { + return Snapshot{}, readErr + } + if closeErr != nil { + return Snapshot{}, fmt.Errorf("close repository root directory") + } + if err := ctx.Err(); err != nil { + return Snapshot{}, err + } + candidates := make([]candidate, 0, len(inventory.catalogItems)) + omissions := Omissions{ + RootEntryCount: inventory.rootEntryCount, + UnrecognizedCount: inventory.unrecognizedCount, + } + preflightAggregateBytes := int64(0) + for _, item := range inventory.catalogItems { + if err := ctx.Err(); err != nil { + return Snapshot{}, err + } + info, err := root.Lstat(item.Path) + if err != nil { + return Snapshot{}, fmt.Errorf("inspect recognized repository entry") + } + if info.Mode()&os.ModeSymlink != 0 { + return Snapshot{}, fmt.Errorf("recognized repository entry must not be a symlink") + } + if !info.Mode().IsRegular() { + return Snapshot{}, fmt.Errorf("recognized repository entry must be a regular file") + } + if info.Size() > policy.maximumFileBytes { + omissions.OmittedRecognized = append(omissions.OmittedRecognized, OmittedRecognizedEntry{Path: item.Path, Reason: OmissionOversize}) + continue + } + if info.Size() < 0 || preflightAggregateBytes > policy.maximumAggregateBytes-info.Size() { + return Snapshot{}, fmt.Errorf("recognized repository entries exceed aggregate byte limit") + } + preflightAggregateBytes += info.Size() + candidates = append(candidates, candidate{info: info, item: item}) + } + sort.Slice(candidates, func(left, right int) bool { return candidates[left].item.Path < candidates[right].item.Path }) + + observed := make([]Entry, 0, len(candidates)) + actualAggregateBytes := int64(0) + for _, item := range candidates { + if err := ctx.Err(); err != nil { + return Snapshot{}, err + } + content, err := readCandidate(root, item, policy.maximumFileBytes, policy.maximumAggregateBytes-actualAggregateBytes) + if err != nil { + return Snapshot{}, err + } + actualAggregateBytes += int64(len(content)) + if !utf8.Valid(content) || bytes.IndexByte(content, 0) >= 0 { + omissions.OmittedRecognized = append(omissions.OmittedRecognized, OmittedRecognizedEntry{Path: item.item.Path, Reason: OmissionNonText}) + continue + } + observed = append(observed, Entry{ + ByteLength: len(content), + ContentSHA256: digest.SHA256BytesRef(content), + Path: item.item.Path, + Role: item.item.Role, + SyntaxState: "not_evaluated", + }) + } + sort.Slice(omissions.OmittedRecognized, func(left, right int) bool { + return omissions.OmittedRecognized[left].Path < omissions.OmittedRecognized[right].Path + }) + return finalize(Snapshot{Entries: observed, Omissions: omissions}) +} + +func readRootInventory(ctx context.Context, directory directoryEntryReader, maximum int) (rootInventory, error) { + const batchLimit = 256 + result := rootInventory{catalogItems: make([]catalogItem, 0, len(rootCatalog))} + seenCatalogPaths := make(map[string]struct{}, len(rootCatalog)) + for result.rootEntryCount <= maximum { + if err := ctx.Err(); err != nil { + return rootInventory{}, err + } + remaining := maximum + 1 - result.rootEntryCount + if remaining > batchLimit { + remaining = batchLimit + } + batch, err := directory.ReadDir(remaining) + for _, entry := range batch { + result.rootEntryCount++ + if result.rootEntryCount > maximum { + return rootInventory{}, fmt.Errorf("repository root exceeds entry limit") + } + role, recognized := CatalogRole(entry.Name()) + if !recognized { + result.unrecognizedCount++ + continue + } + if _, duplicate := seenCatalogPaths[entry.Name()]; duplicate { + return rootInventory{}, fmt.Errorf("repository root contains a duplicate catalog entry") + } + seenCatalogPaths[entry.Name()] = struct{}{} + result.catalogItems = append(result.catalogItems, catalogItem{Path: entry.Name(), Role: role}) + } + if err == io.EOF { + return result, nil + } + if err != nil { + return rootInventory{}, fmt.Errorf("read repository root directory") + } + if len(batch) == 0 { + return rootInventory{}, fmt.Errorf("read repository root directory made no progress") + } + } + return rootInventory{}, fmt.Errorf("repository root exceeds entry limit") +} + +func readCandidate(root *os.Root, value candidate, maximumFileBytes int64, maximumAggregateRemaining int64) (content []byte, returnErr error) { + file, err := openCandidateFile(root, value.item.Path) + if err != nil { + return nil, fmt.Errorf("open recognized repository entry") + } + defer func() { + if closeErr := file.Close(); returnErr == nil && closeErr != nil { + content = nil + returnErr = fmt.Errorf("close recognized repository entry") + } + }() + opened, err := file.Stat() + if err != nil || !opened.Mode().IsRegular() || !os.SameFile(value.info, opened) { + return nil, fmt.Errorf("recognized repository entry changed before reading") + } + if opened.Size() < 0 || opened.Size() > maximumFileBytes { + return nil, fmt.Errorf("recognized repository entry exceeds file byte limit") + } + if opened.Size() > maximumAggregateRemaining { + return nil, fmt.Errorf("recognized repository entries exceed aggregate byte limit") + } + readLimit := maximumFileBytes + if maximumAggregateRemaining < readLimit { + readLimit = maximumAggregateRemaining + } + content, err = io.ReadAll(io.LimitReader(file, readLimit+1)) + if err != nil { + return nil, fmt.Errorf("read recognized repository entry within byte limit") + } + if int64(len(content)) > maximumFileBytes { + return nil, fmt.Errorf("recognized repository entry exceeds file byte limit") + } + if int64(len(content)) > maximumAggregateRemaining { + return nil, fmt.Errorf("recognized repository entries exceed aggregate byte limit") + } + afterHandle, err := file.Stat() + if err != nil || !os.SameFile(opened, afterHandle) || afterHandle.Size() != int64(len(content)) { + return nil, fmt.Errorf("recognized repository entry changed while reading") + } + afterPath, err := root.Lstat(value.item.Path) + if err != nil || afterPath.Mode()&os.ModeSymlink != 0 || !os.SameFile(opened, afterPath) || afterPath.Size() != int64(len(content)) { + return nil, fmt.Errorf("recognized repository entry changed after reading") + } + return content, nil +} diff --git a/internal/command/stackpreset/preset_ids_generated.go b/internal/command/stackpreset/preset_ids_generated.go index a869167..219a9b0 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 = "ca3151470f502bf1c00d33c92b9d4bbce29805612342787aa89de10d36c7ad89" +const presetContractSourceSHA256 = "859296d6fcce46b0d91d56631b49a1978191479e49fafd8d2ccde8f62fda4e76" var presetIDs = []string{"agentic_runtime_repo", "generated_docs_contract_repo", "python_service", "python_typescript_service", "typescript_monorepo", "typescript_workspace"} diff --git a/internal/command/stackpreset/stackpreset.go b/internal/command/stackpreset/stackpreset.go index a972a75..95586a3 100644 --- a/internal/command/stackpreset/stackpreset.go +++ b/internal/command/stackpreset/stackpreset.go @@ -1,12 +1,14 @@ package stackpreset import ( + "bytes" "fmt" "strings" "github.com/research-engineering/agentic-proofkit/internal/kernel/admit" "github.com/research-engineering/agentic-proofkit/internal/kernel/cliexec" "github.com/research-engineering/agentic-proofkit/internal/kernel/report" + "github.com/research-engineering/agentic-proofkit/internal/kernel/stablejson" ) var presetNonClaims = []string{ @@ -36,6 +38,25 @@ type Profile struct { SuggestedCommands []string } +// PlanningHint is the compact, child-owned stack projection used by +// repository-neutral planners. It intentionally omits path suggestions and +// rendered commands; callers can request the complete preset report when needed. +type PlanningHint struct { + PresetID string + PrimaryLanguages []string + StarterEnvironmentClasses []string + StarterWitnessKinds []string +} + +func (hint PlanningHint) JSONValue() map[string]any { + return map[string]any{ + "presetId": hint.PresetID, + "primaryLanguages": stringsToAny(hint.PrimaryLanguages), + "starterEnvironmentClasses": stringsToAny(hint.StarterEnvironmentClasses), + "starterWitnessKinds": stringsToAny(hint.StarterWitnessKinds), + } +} + var presets = map[string]preset{ "agentic_runtime_repo": { Purpose: "Starter profile for repositories that run agent harnesses, queues, or credentialed adapters.", @@ -166,6 +187,50 @@ func ProfileFor(presetID string) (Profile, bool) { return ProfileForWithRenderer(presetID, cliexec.PathRenderer()) } +// PlanningHintFor returns the bounded stack facts that are useful during +// adoption planning without duplicating the complete preset contract. +func PlanningHintFor(presetID string) (PlanningHint, bool) { + profile, ok := ProfileFor(presetID) + if !ok { + return PlanningHint{}, false + } + return PlanningHint{ + PresetID: presetID, + PrimaryLanguages: append([]string{}, profile.PrimaryLanguages...), + StarterEnvironmentClasses: append([]string{}, profile.StarterEnvironmentClasses...), + StarterWitnessKinds: append([]string{}, profile.StarterWitnessKinds...), + }, true +} + +// AdmitPlanningHint validates a serialized planning hint against the preset +// owner instead of asking a parent command to duplicate its fields. +func AdmitPlanningHint(raw any) (PlanningHint, error) { + record, ok := raw.(map[string]any) + if !ok { + return PlanningHint{}, fmt.Errorf("stack planning hint must be an object") + } + presetID, err := admit.NonEmptyText(record["presetId"], "stack planning hint presetId") + if err != nil { + return PlanningHint{}, err + } + expected, ok := PlanningHintFor(presetID) + if !ok { + return PlanningHint{}, fmt.Errorf("stack planning hint presetId is unknown") + } + rawBytes, err := stablejson.Marshal(record) + if err != nil { + return PlanningHint{}, fmt.Errorf("encode stack planning hint") + } + expectedBytes, err := stablejson.Marshal(expected.JSONValue()) + if err != nil { + return PlanningHint{}, fmt.Errorf("encode expected stack planning hint") + } + if !bytes.Equal(rawBytes, expectedBytes) { + return PlanningHint{}, fmt.Errorf("stack planning hint does not match its preset owner") + } + return expected, nil +} + func ProfileForWithRenderer(presetID string, renderer cliexec.Renderer) (Profile, bool) { preset, ok := presets[presetID] if !ok { diff --git a/internal/command/stackpreset/stackpreset_test.go b/internal/command/stackpreset/stackpreset_test.go index a75ea28..869b205 100644 --- a/internal/command/stackpreset/stackpreset_test.go +++ b/internal/command/stackpreset/stackpreset_test.go @@ -1,10 +1,13 @@ package stackpreset import ( + "bytes" "strings" "testing" + "github.com/research-engineering/agentic-proofkit/internal/kernel/admission" "github.com/research-engineering/agentic-proofkit/internal/kernel/cliexec" + "github.com/research-engineering/agentic-proofkit/internal/kernel/stablejson" "github.com/research-engineering/agentic-proofkit/internal/testsupport/commandcoverage" ) @@ -81,6 +84,35 @@ func TestPresetInventoryIsCompleteDeterministicAndDefensivelyCopied(t *testing.T fresh.SuggestedCommands[0] == "mutated" { t.Fatalf("ProfileFor leaked mutable preset slices: %#v", fresh) } + + hint, ok := PlanningHintFor("typescript_workspace") + if !ok { + t.Fatal("PlanningHintFor(typescript_workspace) missing") + } + if hint.PresetID != "typescript_workspace" || len(hint.PrimaryLanguages) == 0 || len(hint.StarterEnvironmentClasses) == 0 || len(hint.StarterWitnessKinds) == 0 { + t.Fatalf("PlanningHintFor(typescript_workspace) = %#v, want bounded complete hint", hint) + } + firstHint := hint.JSONValue() + firstHint["primaryLanguages"].([]any)[0] = "mutated" + secondHint, _ := PlanningHintFor("typescript_workspace") + if secondHint.JSONValue()["primaryLanguages"].([]any)[0] == "mutated" { + t.Fatal("PlanningHint JSON projection leaked mutable state") + } + encoded, err := stablejson.Marshal(secondHint.JSONValue()) + if err != nil { + t.Fatalf("stablejson.Marshal() error = %v", err) + } + decoded, err := admission.DecodeJSON(bytes.NewReader(encoded), int64(len(encoded))) + if err != nil { + t.Fatalf("DecodeJSON() error = %v", err) + } + if _, err := AdmitPlanningHint(decoded); err != nil { + t.Fatalf("AdmitPlanningHint() error = %v", err) + } + decoded.(map[string]any)["starterWitnessKinds"] = []any{"mutated"} + if _, err := AdmitPlanningHint(decoded); err == nil { + t.Fatal("AdmitPlanningHint accepted owner drift") + } } func TestUnknownPresetIsRejected(t *testing.T) { @@ -91,6 +123,9 @@ func TestUnknownPresetIsRejected(t *testing.T) { if _, ok := ProfileFor("unknown"); ok { t.Fatal("ProfileFor accepted unknown preset") } + if _, ok := PlanningHintFor("unknown"); ok { + t.Fatal("PlanningHintFor accepted unknown preset") + } if _, err := Build("unknown"); err == nil { t.Fatal("Build accepted unknown preset") } diff --git a/internal/tools/commandcontractgen/main.go b/internal/tools/commandcontractgen/main.go index 085acad..a0b1339 100644 --- a/internal/tools/commandcontractgen/main.go +++ b/internal/tools/commandcontractgen/main.go @@ -35,8 +35,9 @@ const ( ) var ( - digestPattern = regexp.MustCompile(`^sha256:[0-9a-f]{64}$`) - testNamePattern = regexp.MustCompile(`^Test[A-Z0-9_][A-Za-z0-9_]*$`) + commandTokenPattern = regexp.MustCompile(`^[a-z0-9]+(?:-[a-z0-9]+)*$`) + digestPattern = regexp.MustCompile(`^sha256:[0-9a-f]{64}$`) + testNamePattern = regexp.MustCompile(`^Test[A-Z0-9_][A-Za-z0-9_]*$`) ) type definitionRecord struct { @@ -51,6 +52,7 @@ type generatedMetadata struct { InputSummary []string OutputContractDigest string FlagChoices map[string][]string + RouteTokens []string } func main() { @@ -415,6 +417,7 @@ func admitCommands(root string, contract map[string]any, definitions map[string] metadata := make(map[string]generatedMetadata, len(rawCommands)) contractIDs := map[string]string{} activeTestFiles := map[string]map[string]struct{}{} + routes := map[string]string{} var presets []string previous := "" for index, raw := range rawCommands { @@ -430,7 +433,18 @@ func admitCommands(root string, contract map[string]any, definitions map[string] return nil, nil, errors.New("CLI commands must be sorted and unique") } previous = name - item := generatedMetadata{FlagChoices: map[string][]string{}} + route := []string{name} + if rawRoute, present := command["route"]; present { + var err error + route, err = stringList(rawRoute, "command "+name+" route") + if err != nil { + return nil, nil, err + } + } + if err := admitCommandRoute(name, route, routes); err != nil { + return nil, nil, err + } + item := generatedMetadata{FlagChoices: map[string][]string{}, RouteTokens: route} allowedFlags, err := stringList(command["allowedFlags"], "command "+name+" allowedFlags") if err != nil || !sort.StringsAreSorted(allowedFlags) || hasDuplicate(allowedFlags) { return nil, nil, fmt.Errorf("command %s allowedFlags must be sorted and unique", name) @@ -494,9 +508,39 @@ func admitCommands(root string, contract map[string]any, definitions map[string] } metadata[name] = item } + if adoption, ok := metadata["adopt-plan"]; ok && !slices.Equal(adoption.FlagChoices["--stack"], presets) { + return nil, nil, errors.New("adopt-plan --stack choices must equal stack-preset --preset choices") + } return metadata, presets, nil } +func admitCommandRoute(name string, route []string, existing map[string]string) error { + if len(route) == 0 || len(route) > 4 { + return fmt.Errorf("command %s route must contain between one and four tokens", name) + } + for _, token := range route { + if !commandTokenPattern.MatchString(token) { + return fmt.Errorf("command %s route contains invalid token", name) + } + } + for _, encoded := range sortedKeys(existing) { + owner := existing[encoded] + other := strings.Split(encoded, "\x00") + if slices.Equal(route, other) { + return fmt.Errorf("commands %s and %s have the same route", owner, name) + } + if routePrefix(route, other) || routePrefix(other, route) { + return fmt.Errorf("command routes for %s and %s have an ambiguous prefix", owner, name) + } + } + existing[strings.Join(route, "\x00")] = name + return nil +} + +func routePrefix(prefix, value []string) bool { + return len(prefix) < len(value) && slices.Equal(prefix, value[:len(prefix)]) +} + func admitUniqueContractID(command string, direction string, contract map[string]any, seen map[string]string) error { id, ok := contract["contractId"].(string) if !ok || id == "" { @@ -850,12 +894,12 @@ func renderApp(sourceDigest string, metadata map[string]generatedMetadata) ([]by output.WriteString("package app\n\n") fmt.Fprintf(&output, "const commandContractSourceSHA256 = %q\n\n", sourceDigest) output.WriteString("type generatedCommandContractMetadata struct {\n") - output.WriteString("\tInputContractSHA256 string\n\tInputSchemaSummary []string\n\tOutputContractSHA256 string\n\tFlagChoices map[string][]string\n}\n\n") + output.WriteString("\tInputContractSHA256 string\n\tInputSchemaSummary []string\n\tOutputContractSHA256 string\n\tFlagChoices map[string][]string\n\tRouteTokens []string\n}\n\n") output.WriteString("var generatedCommandContractMetadataByName = map[string]generatedCommandContractMetadata{\n") for _, name := range sortedKeys(metadata) { item := metadata[name] - fmt.Fprintf(&output, "\t%q: {InputContractSHA256: %q, InputSchemaSummary: %#v, OutputContractSHA256: %q, FlagChoices: %#v},\n", - name, item.InputContractDigest, item.InputSummary, item.OutputContractDigest, item.FlagChoices) + fmt.Fprintf(&output, "\t%q: {InputContractSHA256: %q, InputSchemaSummary: %#v, OutputContractSHA256: %q, FlagChoices: %#v, RouteTokens: %#v},\n", + name, item.InputContractDigest, item.InputSummary, item.OutputContractDigest, item.FlagChoices, item.RouteTokens) } output.WriteString("}\n") formatted, err := format.Source([]byte(output.String())) diff --git a/internal/tools/commandcontractgen/main_test.go b/internal/tools/commandcontractgen/main_test.go index a7c81de..b462396 100644 --- a/internal/tools/commandcontractgen/main_test.go +++ b/internal/tools/commandcontractgen/main_test.go @@ -138,6 +138,41 @@ func TestCLIFlagConditionModelRejectsAmbiguity(t *testing.T) { }) } +func TestCommandRoutesAreBoundedSafeAndUnambiguous(t *testing.T) { + routes := map[string]string{} + if err := admitCommandRoute("adopt-plan", []string{"adopt", "plan"}, routes); err != nil { + t.Fatalf("admitCommandRoute(valid) error = %v", err) + } + if err := admitCommandRoute("change-plan", []string{"change", "plan"}, routes); err != nil { + t.Fatalf("admitCommandRoute(disjoint) error = %v", err) + } + if err := admitCommandRoute("three-token", []string{"three", "route", "tokens"}, routes); err != nil { + t.Fatalf("admitCommandRoute(max-1) error = %v", err) + } + if err := admitCommandRoute("four-token", []string{"four", "route", "tokens", "exactly"}, routes); err != nil { + t.Fatalf("admitCommandRoute(max) error = %v", err) + } + for _, test := range []struct { + name string + route []string + want string + }{ + {name: "empty", route: nil, want: "between one and four"}, + {name: "flag token", route: []string{"--adopt"}, want: "invalid token"}, + {name: "duplicate", route: []string{"adopt", "plan"}, want: "same route"}, + {name: "prefix", route: []string{"adopt"}, want: "ambiguous prefix"}, + {name: "extended prefix", route: []string{"change", "plan", "now"}, want: "ambiguous prefix"}, + {name: "five tokens", route: []string{"five", "route", "tokens", "are", "invalid"}, want: "between one and four"}, + } { + t.Run(test.name, func(t *testing.T) { + copyOfRoutes := maps.Clone(routes) + if err := admitCommandRoute("mutant", test.route, copyOfRoutes); err == nil || !strings.Contains(err.Error(), test.want) { + t.Fatalf("admitCommandRoute(%v) error = %v, want %q", test.route, err, test.want) + } + }) + } +} + func mustParseCLIFlagCondition(t *testing.T, condition string) map[string]string { t.Helper() dimensions, err := parseCLIFlagCondition(condition) diff --git a/internal/tools/coveragemetrics/main.go b/internal/tools/coveragemetrics/main.go index 2da9a87..33bf95c 100644 --- a/internal/tools/coveragemetrics/main.go +++ b/internal/tools/coveragemetrics/main.go @@ -289,7 +289,10 @@ func validateRequiredBindingWitnessSelectors(bindings bindingFile) error { "TestSelfCheckOutputUsesExactRootShape", "TestStandaloneMultiVariantCommandsUseExactRootShapes", }, - {"REQ-PROOFKIT-PACKAGE-003", "proofkit.package-boundary.outside-consumer-artifact"}: {"TestExactTarballOnboardingTrace"}, + {"REQ-PROOFKIT-PACKAGE-003", "proofkit.package-boundary.outside-consumer-artifact"}: { + "TestExactTarballOnboardingTrace", + "TestVerifyPackedOwnerRecordsRejectsSourceArtifactContentDrift", + }, {"REQ-PROOFKIT-PACKAGE-004", "proofkit.package-boundary.ci-receipt-anchor"}: { "TestReceiptIDKeepsLocalAndCIIdentitiesDistinct", "TestRunInvokesEveryRequiredSelfHostingAdmissionBoundary", @@ -420,6 +423,7 @@ func validateRequiredBindingWitnessSelectors(bindings bindingFile) error { "TestInstalledREADMEFirstInputUsesBoundedLiteralShellWords", "TestLiteralShellWordsConsumesLongBackslashRun", "TestOnboardingTraceCoversEveryDiscoveredPresetAndREADMEInput", + "TestVerifyPackedOwnerRecordsRejectsSourceArtifactContentDrift", }, {"REQ-PROOFKIT-QUALITY-023", "proofkit.supply-chain-quality.release-closeout-npm-byte-admission"}: { "TestBuildInputFailsClosedForEachBlockingEvidenceClass", diff --git a/internal/tools/installedclicontract/contract.go b/internal/tools/installedclicontract/contract.go new file mode 100644 index 0000000..c5ff151 --- /dev/null +++ b/internal/tools/installedclicontract/contract.go @@ -0,0 +1,160 @@ +package installedclicontract + +import ( + "bytes" + "fmt" + "sort" + "strings" + + "github.com/research-engineering/agentic-proofkit/internal/kernel/admission" +) + +const maximumCommandRouteTokens = 4 + +// Contract is the admitted package-artifact projection needed by installed +// consumer witnesses. Its accessors return copies so callers cannot mutate it. +type Contract struct { + commandIDsByRoute map[string]string + presetIDs []string +} + +func Admit(content []byte) (Contract, error) { + value, err := admission.DecodeJSON(bytes.NewReader(content), int64(len(content))) + if err != nil { + return Contract{}, fmt.Errorf("decode installed CLI contract: %w", err) + } + record, ok := value.(map[string]any) + if !ok { + return Contract{}, fmt.Errorf("installed CLI contract must be an object") + } + commands, ok := record["commands"].([]any) + if !ok || len(commands) == 0 { + return Contract{}, fmt.Errorf("installed CLI contract commands must be a non-empty array") + } + + commandIDsByRoute := make(map[string]string, len(commands)) + seenCommandIDs := make(map[string]struct{}, len(commands)) + admittedRoutes := make([]string, 0, len(commands)) + var presetIDs []string + for index, raw := range commands { + command, ok := raw.(map[string]any) + if !ok { + return Contract{}, fmt.Errorf("installed CLI contract command %d must be an object", index) + } + commandID, ok := command["command"].(string) + if !ok || !ValidRouteToken(commandID) { + return Contract{}, fmt.Errorf("installed CLI contract command %d has an invalid command id", index) + } + if _, exists := seenCommandIDs[commandID]; exists { + return Contract{}, fmt.Errorf("installed CLI contract duplicates a command id") + } + seenCommandIDs[commandID] = struct{}{} + + routeTokens := []string{commandID} + if rawRoute, exists := command["route"]; exists { + route, ok := rawRoute.([]any) + if !ok || len(route) == 0 || len(route) > maximumCommandRouteTokens { + return Contract{}, fmt.Errorf("installed CLI contract command %d has an invalid route", index) + } + routeTokens = make([]string, 0, len(route)) + for _, rawToken := range route { + token, ok := rawToken.(string) + if !ok || !ValidRouteToken(token) { + return Contract{}, fmt.Errorf("installed CLI contract command %d has an invalid route token", index) + } + routeTokens = append(routeTokens, token) + } + } + routeText := strings.Join(routeTokens, " ") + if _, exists := commandIDsByRoute[routeText]; exists { + return Contract{}, fmt.Errorf("installed CLI contract duplicates a command route") + } + for _, priorRoute := range admittedRoutes { + if strings.HasPrefix(routeText, priorRoute+" ") || strings.HasPrefix(priorRoute, routeText+" ") { + return Contract{}, fmt.Errorf("installed CLI contract has ambiguous command route prefixes") + } + } + commandIDsByRoute[routeText] = commandID + admittedRoutes = append(admittedRoutes, routeText) + + if commandID == "stack-preset" { + if presetIDs != nil { + return Contract{}, fmt.Errorf("installed CLI contract duplicates the stack-preset command") + } + var err error + presetIDs, err = admitPresetIDs(command) + if err != nil { + return Contract{}, err + } + } + } + return Contract{commandIDsByRoute: commandIDsByRoute, presetIDs: presetIDs}, nil +} + +func (contract Contract) CommandIDsByRoute() map[string]string { + result := make(map[string]string, len(contract.commandIDsByRoute)) + for route, commandID := range contract.commandIDsByRoute { + result[route] = commandID + } + return result +} + +func (contract Contract) PresetIDs() ([]string, error) { + if len(contract.presetIDs) == 0 { + return nil, fmt.Errorf("installed CLI contract omitted stack-preset") + } + return append([]string(nil), contract.presetIDs...), nil +} + +func ValidRouteToken(token string) bool { + if token == "" || token[0] == '-' || token[len(token)-1] == '-' { + return false + } + previousHyphen := false + for _, value := range token { + if value == '-' { + if previousHyphen { + return false + } + previousHyphen = true + continue + } + if (value < 'a' || value > 'z') && (value < '0' || value > '9') { + return false + } + previousHyphen = false + } + return true +} + +func admitPresetIDs(command map[string]any) ([]string, error) { + output, ok := command["outputContract"].(map[string]any) + if !ok { + return nil, fmt.Errorf("installed CLI contract stack-preset outputContract must be an object") + } + choices, ok := output["flagChoices"].(map[string]any) + if !ok { + return nil, fmt.Errorf("installed CLI contract stack-preset flagChoices must be an object") + } + rawIDs, ok := choices["--preset"].([]any) + if !ok || len(rawIDs) == 0 { + return nil, fmt.Errorf("installed CLI contract stack-preset choices must be a non-empty array") + } + ids := make([]string, 0, len(rawIDs)) + seen := make(map[string]struct{}, len(rawIDs)) + for _, rawID := range rawIDs { + id, ok := rawID.(string) + if !ok || id == "" { + return nil, fmt.Errorf("installed CLI contract stack-preset choices must be non-empty strings") + } + if _, exists := seen[id]; exists { + return nil, fmt.Errorf("installed CLI contract stack-preset choices must be unique") + } + seen[id] = struct{}{} + ids = append(ids, id) + } + if !sort.StringsAreSorted(ids) { + return nil, fmt.Errorf("installed CLI contract stack-preset choices must be sorted") + } + return ids, nil +} diff --git a/internal/tools/installedclicontract/contract_test.go b/internal/tools/installedclicontract/contract_test.go new file mode 100644 index 0000000..34eb4f9 --- /dev/null +++ b/internal/tools/installedclicontract/contract_test.go @@ -0,0 +1,78 @@ +package installedclicontract + +import ( + "slices" + "testing" +) + +func TestAdmitPreservesExactRoutesAndPresetChoices(t *testing.T) { + content := []byte(`{"commands":[{"command":"adopt-plan","route":["adopt","plan"]},{"command":"stack-preset","outputContract":{"flagChoices":{"--preset":["go_cli_repo","python_service"]}}}]}`) + contract, err := Admit(content) + if err != nil { + t.Fatalf("Admit() error = %v", err) + } + routes := contract.CommandIDsByRoute() + if len(routes) != 2 || routes["adopt plan"] != "adopt-plan" || routes["stack-preset"] != "stack-preset" { + t.Fatalf("routes = %v", routes) + } + presetIDs, err := contract.PresetIDs() + if err != nil || !slices.Equal(presetIDs, []string{"go_cli_repo", "python_service"}) { + t.Fatalf("preset IDs = %v, error = %v", presetIDs, err) + } + + routes["mutated"] = "mutated" + presets, _ := contract.PresetIDs() + presets[0] = "mutated" + ownerPresets, _ := contract.PresetIDs() + if len(contract.CommandIDsByRoute()) != 2 || ownerPresets[0] != "go_cli_repo" { + t.Fatal("contract accessors exposed mutable owner state") + } +} + +func TestAdmitRejectsAmbiguousOrIncompleteContracts(t *testing.T) { + mutants := map[string]string{ + "duplicate key": `{"commands":[{"command":"one","command":"two"},{"command":"stack-preset","outputContract":{"flagChoices":{"--preset":["go_cli_repo"]}}}]}`, + "duplicate id": `{"commands":[{"command":"same"},{"command":"same","route":["other"]},{"command":"stack-preset","outputContract":{"flagChoices":{"--preset":["go_cli_repo"]}}}]}`, + "duplicate route": `{"commands":[{"command":"one","route":["same"]},{"command":"two","route":["same"]},{"command":"stack-preset","outputContract":{"flagChoices":{"--preset":["go_cli_repo"]}}}]}`, + "route prefix": `{"commands":[{"command":"one","route":["adopt"]},{"command":"two","route":["adopt","plan"]},{"command":"stack-preset","outputContract":{"flagChoices":{"--preset":["go_cli_repo"]}}}]}`, + "duplicate preset": `{"commands":[{"command":"stack-preset","outputContract":{"flagChoices":{"--preset":["same","same"]}}}]}`, + "unsorted presets": `{"commands":[{"command":"stack-preset","outputContract":{"flagChoices":{"--preset":["z","a"]}}}]}`, + } + for name, content := range mutants { + t.Run(name, func(t *testing.T) { + if _, err := Admit([]byte(content)); err == nil { + t.Fatalf("Admit() accepted mutant: %s", content) + } + }) + } + contract, err := Admit([]byte(`{"commands":[{"command":"one"}]}`)) + if err != nil { + t.Fatalf("route-only contract rejected: %v", err) + } + if _, err := contract.PresetIDs(); err == nil { + t.Fatal("PresetIDs() accepted a contract without stack-preset") + } +} + +func TestAdmitCommandRouteTokenBoundariesAreExact(t *testing.T) { + for _, test := range []struct { + name string + route string + wantErr bool + }{ + {name: "max-1", route: `["three","route","tokens"]`}, + {name: "max", route: `["four","route","tokens","exactly"]`}, + {name: "max+1", route: `["five","route","tokens","are","invalid"]`, wantErr: true}, + } { + t.Run(test.name, func(t *testing.T) { + content := []byte(`{"commands":[{"command":"sample","route":` + test.route + `}]}`) + _, err := Admit(content) + if test.wantErr && err == nil { + t.Fatal("Admit() accepted route above token limit") + } + if !test.wantErr && err != nil { + t.Fatalf("Admit() error = %v", err) + } + }) + } +} diff --git a/internal/tools/packageverify/main.go b/internal/tools/packageverify/main.go index 5a1e52a..62e61ce 100644 --- a/internal/tools/packageverify/main.go +++ b/internal/tools/packageverify/main.go @@ -25,10 +25,12 @@ import ( "github.com/research-engineering/agentic-proofkit/internal/command/jsonreportcliadaptersource" "github.com/research-engineering/agentic-proofkit/internal/kernel/admission" + "github.com/research-engineering/agentic-proofkit/internal/kernel/cliexec" "github.com/research-engineering/agentic-proofkit/internal/kernel/diagnostic" "github.com/research-engineering/agentic-proofkit/internal/kernel/digest" "github.com/research-engineering/agentic-proofkit/internal/kernel/releaseplatform" "github.com/research-engineering/agentic-proofkit/internal/kernel/unicodepolicy" + "github.com/research-engineering/agentic-proofkit/internal/tools/installedclicontract" "github.com/research-engineering/agentic-proofkit/internal/tools/workflowsmoke" ) @@ -280,7 +282,7 @@ func verifyPackedPlatformBinariesMatchSource(artifact rootPackageArtifact) error func sourceOwnedPackageEntry(entry string) bool { switch entry { - case "package/LICENSE", "package/package.json": + case "package/LICENSE", "package/dist/agentic-proofkit", "package/package.json": return true default: return packageTextEntry(entry) @@ -1677,7 +1679,7 @@ func verifyOutsideConsumer(artifact rootPackageArtifact) error { func verifyExactTarballConsumer(artifact rootPackageArtifact) error { return withExactTarballConsumer(artifact, func(consumer string) error { - if err := verifyInstalledOnboardingTrace(consumer, runInstalledWithInput); err != nil { + if err := verifyInstalledOnboardingTraceWithExecutors(consumer, runInstalledWithInput, runInstalledBinaryWithInput); err != nil { return err } if err := verifyInstalledJSONABI(consumer); err != nil { @@ -1736,12 +1738,17 @@ func run(dir string, name string, args ...string) ([]byte, error) { type installedCommandOperation func(string, []byte, ...string) (installedCommandResult, error) type installedHelpRoute struct { - ID string - Argv []string + ID string + Route string + Argv []string } func verifyInstalledOnboardingTrace(consumer string, execute installedCommandOperation) error { - rootHelp, err := execute(consumer, nil, "help") + return verifyInstalledOnboardingTraceWithExecutors(consumer, execute, execute) +} + +func verifyInstalledOnboardingTraceWithExecutors(consumer string, transportExecute installedCommandOperation, binaryExecute installedCommandOperation) error { + rootHelp, err := transportExecute(consumer, nil, "help") if err != nil { return fmt.Errorf("outside consumer root help failed to run: %w", err) } @@ -1755,7 +1762,7 @@ func verifyInstalledOnboardingTrace(consumer string, execute installedCommandOpe if !bytes.Contains(rootHelp.Stdout, []byte("CLI/JSON is the public cross-language contract")) { return fmt.Errorf("outside consumer root help did not expose the family discovery and CLI contract routes") } - families, err := execute(consumer, nil, familyArgv...) + families, err := binaryExecute(consumer, nil, familyArgv...) if err != nil { return fmt.Errorf("outside consumer family help failed to run: %w", err) } @@ -1766,47 +1773,82 @@ func verifyInstalledOnboardingTrace(consumer string, execute installedCommandOpe if err != nil { return err } + contractPath := filepath.Join(consumer, "node_modules", "@research-engineering", "agentic-proofkit", "proofkit", "cli-contract.v2.json") + contractContent, err := os.ReadFile(contractPath) + if err != nil { + return fmt.Errorf("read installed CLI contract: %w", err) + } + contractCommandIDsByRoute, err := installedContractCommandIDsByRoute(contractContent) + if err != nil { + return err + } + multiTokenRoute, err := representativeMultiTokenHelpRoute(contractCommandIDsByRoute) + if err != nil { + return err + } + transportMultiTokenHelp, err := transportExecute(consumer, nil, multiTokenRoute.Argv...) + if err != nil { + return fmt.Errorf("outside consumer multi-token route transport failed to run: %w", err) + } + if err := requireInstalledTextSuccess(transportMultiTokenHelp, "multi-token route transport"); err != nil { + return err + } var stackHelp installedCommandResult var requirementSourceHelp installedCommandResult stackHelpFound := false requirementSourceHelpFound := false + allLeafRoutes := make([]installedHelpRoute, 0, len(contractCommandIDsByRoute)) + familyByLeafRoute := make(map[string]string, len(contractCommandIDsByRoute)) for _, familyRoute := range familyRoutes { - family, err := execute(consumer, nil, familyRoute.Argv...) + family, err := binaryExecute(consumer, nil, familyRoute.Argv...) if err != nil { return fmt.Errorf("outside consumer family %s failed to run: %w", familyRoute.ID, err) } if err := requireInstalledTextSuccess(family, "family "+familyRoute.ID); err != nil { return err } - leafRoutes, err := parseInstalledLeafHelpRoutes(string(family.Stdout)) + leafRoutes, err := parseInstalledLeafHelpRoutes(string(family.Stdout), contractCommandIDsByRoute) if err != nil { return fmt.Errorf("outside consumer family %s: %w", familyRoute.ID, err) } for _, leafRoute := range leafRoutes { - leafHelp, err := execute(consumer, nil, leafRoute.Argv...) - if err != nil { - return fmt.Errorf("outside consumer %s help failed to run: %w", leafRoute.ID, err) + if priorFamily, exists := familyByLeafRoute[leafRoute.Route]; exists { + return fmt.Errorf("outside consumer command route %q is exposed by both %s and %s", leafRoute.Route, priorFamily, familyRoute.ID) } - if err := requireInstalledTextSuccess(leafHelp, leafRoute.ID+" help"); err != nil { - return err - } - if err := requireInstalledInvocationSyntax(leafHelp.Stdout, leafRoute.ID); err != nil { - return err + familyByLeafRoute[leafRoute.Route] = familyRoute.ID + allLeafRoutes = append(allLeafRoutes, leafRoute) + } + } + if err := requireInstalledCommandRouteBijection(familyByLeafRoute, contractCommandIDsByRoute); err != nil { + return err + } + for _, leafRoute := range allLeafRoutes { + leafHelp, err := binaryExecute(consumer, nil, leafRoute.Argv...) + if err != nil { + return fmt.Errorf("outside consumer %s help failed to run: %w", leafRoute.ID, err) + } + if err := requireInstalledTextSuccess(leafHelp, leafRoute.ID+" help"); err != nil { + return err + } + if err := requireInstalledInvocationSyntax(leafHelp.Stdout, leafRoute.Route); err != nil { + return err + } + if leafRoute.Route == multiTokenRoute.Route && !bytes.Equal(leafHelp.Stdout, transportMultiTokenHelp.Stdout) { + return fmt.Errorf("outside consumer multi-token route differs between npm transport and direct installed binary") + } + switch leafRoute.ID { + case "stack-preset": + if stackHelpFound { + return fmt.Errorf("outside consumer family navigation exposed stack-preset more than once") } - switch leafRoute.ID { - case "stack-preset": - if stackHelpFound { - return fmt.Errorf("outside consumer family navigation exposed stack-preset more than once") - } - stackHelp = leafHelp - stackHelpFound = true - case "requirement-source-admission": - if requirementSourceHelpFound { - return fmt.Errorf("outside consumer family navigation exposed requirement-source-admission more than once") - } - requirementSourceHelp = leafHelp - requirementSourceHelpFound = true + stackHelp = leafHelp + stackHelpFound = true + case "requirement-source-admission": + if requirementSourceHelpFound { + return fmt.Errorf("outside consumer family navigation exposed requirement-source-admission more than once") } + requirementSourceHelp = leafHelp + requirementSourceHelpFound = true } } if !stackHelpFound { @@ -1819,11 +1861,6 @@ func verifyInstalledOnboardingTrace(consumer string, execute installedCommandOpe if err != nil { return err } - contractPath := filepath.Join(consumer, "node_modules", "@research-engineering", "agentic-proofkit", "proofkit", "cli-contract.v2.json") - contractContent, err := os.ReadFile(contractPath) - if err != nil { - return fmt.Errorf("read installed CLI contract: %w", err) - } contractPresetIDs, err := installedContractPresetIDs(contractContent) if err != nil { return err @@ -1837,7 +1874,7 @@ func verifyInstalledOnboardingTrace(consumer string, execute installedCommandOpe } var firstSelfContinuation []string for _, presetRoute := range presetRoutes { - result, err := execute(consumer, nil, presetRoute.Argv...) + result, err := binaryExecute(consumer, nil, presetRoute.Argv...) if err != nil { return fmt.Errorf("outside consumer stack preset %s failed to run: %w", presetRoute.ID, err) } @@ -1855,7 +1892,7 @@ func verifyInstalledOnboardingTrace(consumer string, execute installedCommandOpe if len(firstSelfContinuation) == 0 { return fmt.Errorf("outside consumer stack presets exposed no executable self-continuation") } - continuation, err := execute(consumer, nil, firstSelfContinuation...) + continuation, err := binaryExecute(consumer, nil, firstSelfContinuation...) if err != nil { return fmt.Errorf("outside consumer stack preset self-continuation failed to run: %w", err) } @@ -1875,13 +1912,44 @@ func verifyInstalledOnboardingTrace(consumer string, execute installedCommandOpe if err != nil { return err } - result, err := execute(consumer, input, argv...) + result, err := binaryExecute(consumer, input, argv...) if err != nil { return fmt.Errorf("outside consumer README first-input command failed to run: %w", err) } return requirePassedJSON(result, "README first-input command") } +func representativeMultiTokenHelpRoute(commandIDsByRoute map[string]string) (installedHelpRoute, error) { + routes := make([]string, 0, len(commandIDsByRoute)) + for route := range commandIDsByRoute { + if strings.Contains(route, " ") { + routes = append(routes, route) + } + } + if len(routes) == 0 { + return installedHelpRoute{}, fmt.Errorf("installed CLI contract exposes no multi-token route for transport proof") + } + sort.Strings(routes) + route := routes[0] + return installedHelpRoute{ + ID: commandIDsByRoute[route], + Route: route, + Argv: append([]string{"help"}, strings.Split(route, " ")...), + }, nil +} + +func requireInstalledCommandRouteBijection(observed map[string]string, expected map[string]string) error { + if len(observed) != len(expected) { + return fmt.Errorf("outside consumer family command routes=%d installed contract routes=%d", len(observed), len(expected)) + } + for route := range expected { + if _, exists := observed[route]; !exists { + return fmt.Errorf("outside consumer family navigation omitted installed contract route %q", route) + } + } + return nil +} + func installedRootHelpFamilyArgv(content []byte) ([]string, error) { const routeFragment = "agentic-proofkit help families" decoded, err := unicodepolicy.DecodeUTF8(content) @@ -1949,9 +2017,9 @@ func parseInstalledFamilyRoutes(help string) ([]installedHelpRoute, error) { return routes, nil } -func parseInstalledLeafHelpRoutes(help string) ([]installedHelpRoute, error) { - commandIDs := []string{} - seenCommandIDs := map[string]struct{}{} +func parseInstalledLeafHelpRoutes(help string, contractCommandIDsByRoute map[string]string) ([]installedHelpRoute, error) { + routeTexts := []string{} + seenRouteTexts := map[string]struct{}{} inCommands := false for _, line := range strings.Split(help, "\n") { if line == "Commands:" { @@ -1961,21 +2029,30 @@ func parseInstalledLeafHelpRoutes(help string) ([]installedHelpRoute, error) { if !inCommands || !strings.HasPrefix(line, " ") || strings.HasPrefix(line, " ") { continue } - commandID := strings.TrimSpace(line) - if commandID == "" || strings.ContainsAny(commandID, " \t") { - return nil, fmt.Errorf("command family has an invalid command id %q", commandID) + routeText := strings.TrimSpace(line) + routeTokens, err := parseLiteralShellWords(routeText) + if err != nil || len(routeTokens) == 0 || len(routeTokens) > 4 || strings.Join(routeTokens, " ") != routeText { + return nil, fmt.Errorf("command family has an invalid command route %q", routeText) } - if _, exists := seenCommandIDs[commandID]; exists { - return nil, fmt.Errorf("command family has duplicate command id %q", commandID) + for _, token := range routeTokens { + if token == "" || strings.HasPrefix(token, "-") { + return nil, fmt.Errorf("command family has an invalid command route %q", routeText) + } } - seenCommandIDs[commandID] = struct{}{} - commandIDs = append(commandIDs, commandID) + if _, exists := seenRouteTexts[routeText]; exists { + return nil, fmt.Errorf("command family has duplicate command route %q", routeText) + } + if _, exists := contractCommandIDsByRoute[routeText]; !exists { + return nil, fmt.Errorf("command family route %q is absent from the installed CLI contract", routeText) + } + seenRouteTexts[routeText] = struct{}{} + routeTexts = append(routeTexts, routeText) } - if len(commandIDs) == 0 { - return nil, fmt.Errorf("command family exposed no command ids") + if len(routeTexts) == 0 { + return nil, fmt.Errorf("command family exposed no command routes") } - routeByID := map[string][]string{} + argvByRoute := map[string][]string{} for _, line := range strings.Split(help, "\n") { if !strings.Contains(line, "agentic-proofkit help ") { continue @@ -1984,24 +2061,32 @@ func parseInstalledLeafHelpRoutes(help string) ([]installedHelpRoute, error) { if err != nil { return nil, err } - if len(argv) != 2 || argv[0] != "help" || argv[1] == "" { - return nil, fmt.Errorf("outside consumer leaf help route must resolve to help ") + if len(argv) < 2 || len(argv) > 5 || argv[0] != "help" { + return nil, fmt.Errorf("outside consumer leaf help route must resolve to help ") } - if _, exists := routeByID[argv[1]]; exists { - return nil, fmt.Errorf("outside consumer command %q has duplicate copyable help routes", argv[1]) + routeText := strings.Join(argv[1:], " ") + if _, exists := seenRouteTexts[routeText]; !exists { + return nil, fmt.Errorf("outside consumer copyable help route %q is not a displayed command route", routeText) } - routeByID[argv[1]] = argv + if _, exists := argvByRoute[routeText]; exists { + return nil, fmt.Errorf("outside consumer command route %q has duplicate copyable help routes", routeText) + } + argvByRoute[routeText] = argv } - if len(routeByID) != len(commandIDs) { - return nil, fmt.Errorf("outside consumer leaf help routes=%d command ids=%d", len(routeByID), len(commandIDs)) + if len(argvByRoute) != len(routeTexts) { + return nil, fmt.Errorf("outside consumer leaf help routes=%d command routes=%d", len(argvByRoute), len(routeTexts)) } - routes := make([]installedHelpRoute, 0, len(commandIDs)) - for _, commandID := range commandIDs { - argv, ok := routeByID[commandID] + routes := make([]installedHelpRoute, 0, len(routeTexts)) + for _, routeText := range routeTexts { + argv, ok := argvByRoute[routeText] if !ok { - return nil, fmt.Errorf("outside consumer command %q has no copyable help route", commandID) + return nil, fmt.Errorf("outside consumer command route %q has no copyable help route", routeText) } - routes = append(routes, installedHelpRoute{ID: commandID, Argv: argv}) + routes = append(routes, installedHelpRoute{ + ID: contractCommandIDsByRoute[routeText], + Route: routeText, + Argv: argv, + }) } return routes, nil } @@ -2250,40 +2335,19 @@ func parseInstalledPresetIDs(help string) ([]string, error) { } func installedContractPresetIDs(content []byte) ([]string, error) { - value, err := admission.DecodeJSON(bytes.NewReader(content), int64(len(content))) + contract, err := installedclicontract.Admit(content) if err != nil { - return nil, fmt.Errorf("decode installed CLI contract: %w", err) - } - record, ok := value.(map[string]any) - if !ok { - return nil, fmt.Errorf("installed CLI contract must be an object") - } - commands, ok := record["commands"].([]any) - if !ok { - return nil, fmt.Errorf("installed CLI contract commands must be an array") + return nil, err } - for _, raw := range commands { - command, _ := raw.(map[string]any) - if command["command"] != "stack-preset" { - continue - } - output, _ := command["outputContract"].(map[string]any) - choices, _ := output["flagChoices"].(map[string]any) - rawIDs, _ := choices["--preset"].([]any) - ids := make([]string, 0, len(rawIDs)) - for _, rawID := range rawIDs { - id, ok := rawID.(string) - if !ok || id == "" { - return nil, fmt.Errorf("installed CLI contract stack-preset choices must be non-empty strings") - } - ids = append(ids, id) - } - if len(ids) == 0 || !sort.StringsAreSorted(ids) { - return nil, fmt.Errorf("installed CLI contract stack-preset choices must be non-empty and sorted") - } - return ids, nil + return contract.PresetIDs() +} + +func installedContractCommandIDsByRoute(content []byte) (map[string]string, error) { + contract, err := installedclicontract.Admit(content) + if err != nil { + return nil, err } - return nil, fmt.Errorf("installed CLI contract omitted stack-preset") + return contract.CommandIDsByRoute(), nil } func installedREADMEFirstInput(content []byte) ([]string, []byte, error) { @@ -2669,11 +2733,16 @@ func verifyJSONAdapterSourceSmokeReport(result installedCommandResult, expectedS } func runWithInput(dir string, name string, input []byte, args ...string) (installedCommandResult, error) { + return runWithInputEnvironment(dir, name, nil, input, args...) +} + +func runWithInputEnvironment(dir string, name string, environment []string, input []byte, args ...string) (installedCommandResult, error) { ctx, cancel := context.WithTimeout(context.Background(), packageVerifyProcessTimeout) defer cancel() result, err := workflowsmoke.RunProcess(ctx, workflowsmoke.ProcessCarrier{ - Directory: dir, - Executable: name, + Directory: dir, + Executable: name, + Environment: environment, }, workflowsmoke.Invocation{Args: args, Input: input, StdinClass: workflowsmoke.StdinBytes}) if err != nil { return installedCommandResult{}, err @@ -2686,6 +2755,31 @@ func runInstalledWithInput(dir string, input []byte, args ...string) (installedC return runWithInput(dir, "npm", input, npmArgs...) } +func runInstalledBinaryWithInput(dir string, input []byte, args ...string) (installedCommandResult, error) { + target, err := releaseplatform.CurrentTarget() + if err != nil { + return installedCommandResult{}, err + } + binaryPath := filepath.Join(dir, "node_modules", "@research-engineering", "agentic-proofkit", filepath.FromSlash(target.BinaryPath)) + environment := installedNPMBinaryEnvironment(os.Environ()) + return runWithInputEnvironment(dir, binaryPath, environment, input, args...) +} + +func installedNPMBinaryEnvironment(environment []string) []string { + result := make([]string, 0, len(environment)+2) + for _, entry := range environment { + name, _, ok := strings.Cut(entry, "=") + if ok && (name == cliexec.LauncherProfileEnvironment || name == cliexec.PythonExecutableEnvironment) { + continue + } + result = append(result, entry) + } + return append(result, + cliexec.LauncherProfileEnvironment+"="+cliexec.ProfileNPMOffline, + cliexec.PythonExecutableEnvironment+"=", + ) +} + func verifyTextPolicySmokeReport(result installedCommandResult, reportID string, state string, exitCode int, summary textPolicySmokeSummary) error { if result.ExitCode != exitCode { return fmt.Errorf("exit code %d, want %d; stdout=%s stderr=%s", result.ExitCode, exitCode, result.Stdout, result.Stderr) diff --git a/internal/tools/packageverify/main_test.go b/internal/tools/packageverify/main_test.go index 516f922..1e91c01 100644 --- a/internal/tools/packageverify/main_test.go +++ b/internal/tools/packageverify/main_test.go @@ -13,6 +13,7 @@ import ( "os" "os/exec" "path/filepath" + "reflect" "slices" "strings" "testing" @@ -84,6 +85,7 @@ func TestVerifyPackedOwnerRecordsRejectsSourceArtifactContentDrift(t *testing.T) withWorkingDirectory(t, root) entries := []string{ "package/LICENSE", + "package/dist/agentic-proofkit", "package/package.json", "package/docs/specs/example/requirements.v1.json", "package/proofkit/cli-contract.v2.json", @@ -930,17 +932,31 @@ func TestOnboardingTraceCoversEveryDiscoveredPresetAndREADMEInput(t *testing.T) presetRoutePrefix := "npm exec --offline -- agentic-proofkit stack-preset --preset " readmeContinuation := "Path: node_modules/@research-engineering/agentic-proofkit/README.md" installedRoot := filepath.Join(consumer, "node_modules", "@research-engineering", "agentic-proofkit") - for _, source := range []string{"README.md", "proofkit/cli-contract.v2.json"} { - content, err := os.ReadFile(filepath.Join("..", "..", "..", filepath.FromSlash(source))) - if err != nil { - t.Fatal(err) - } - writeFileBytes(t, filepath.Join(installedRoot, filepath.FromSlash(source)), content) + readme, err := os.ReadFile(filepath.Join("..", "..", "..", "README.md")) + if err != nil { + t.Fatal(err) } - choices, err := installedContractPresetIDs(mustReadBytes(t, filepath.Join(installedRoot, "proofkit", "cli-contract.v2.json"))) + writeFileBytes(t, filepath.Join(installedRoot, "README.md"), readme) + ownerContract := mustReadBytes(t, filepath.Join("..", "..", "..", "proofkit", "cli-contract.v2.json")) + choices, err := installedContractPresetIDs(ownerContract) if err != nil { t.Fatal(err) } + fixtureContract, err := json.Marshal(map[string]any{"commands": []any{ + map[string]any{"command": "adopt-plan", "route": []string{"adopt", "plan"}}, + map[string]any{"command": "requirement-source-admission"}, + map[string]any{"command": "self-check"}, + map[string]any{ + "command": "stack-preset", + "outputContract": map[string]any{ + "flagChoices": map[string]any{"--preset": choices}, + }, + }, + }}) + if err != nil { + t.Fatal(err) + } + writeFileBytes(t, filepath.Join(installedRoot, "proofkit", "cli-contract.v2.json"), fixtureContract) stackInstalledInvocation = "npm exec --offline -- agentic-proofkit stack-preset --preset <" + strings.Join(choices, "|") + ">" seenPresets := map[string]struct{}{} presetExecutionCounts := map[string]int{} @@ -957,6 +973,9 @@ func TestOnboardingTraceCoversEveryDiscoveredPresetAndREADMEInput(t *testing.T) stdout = rootHelpRoute + "\nCLI/JSON is the public cross-language contract.\n" case slices.Equal(args, []string{"help", "families"}): stdout = "Command families:\n" + + " adoption\tAdoption\n" + + " Plan adoption.\n" + + " " + familyRoutePrefix + "adoption\n" + " scaffolding\tScaffolding\n" + " Scaffold projects.\n" + " " + familyRoutePrefix + "scaffolding\n" + @@ -966,6 +985,8 @@ func TestOnboardingTraceCoversEveryDiscoveredPresetAndREADMEInput(t *testing.T) " requirement-source-lifecycle\tRequirement source lifecycle\n" + " Admit requirement sources.\n" + " " + familyRoutePrefix + "requirement-source-lifecycle\n" + case slices.Equal(args, []string{"help", "family", "adoption"}): + stdout = "Commands:\n adopt plan\n " + installedNPMExecCommandPrefix + "help adopt plan\n" case slices.Equal(args, []string{"help", "family", "scaffolding"}): stdout = "Commands:\n stack-preset\n " + stackHelpRoute + "\n" case slices.Equal(args, []string{"help", "family", "quality"}): @@ -986,6 +1007,9 @@ func TestOnboardingTraceCoversEveryDiscoveredPresetAndREADMEInput(t *testing.T) case slices.Equal(args, []string{"help", "self-check"}): stdout = "Usage:\n agentic-proofkit self-check --input \n" + "\nInstalled invocation:\n " + selfCheckInstalledInvocation + "\n" + case slices.Equal(args, []string{"help", "adopt", "plan"}): + stdout = "Usage:\n agentic-proofkit adopt plan --mode --repo-root \n" + + "\nInstalled invocation:\n " + installedNPMExecCommandPrefix + "adopt plan --mode --repo-root \n" case len(args) == 3 && args[0] == "stack-preset" && args[1] == "--preset": seenPresets[args[2]] = struct{}{} presetExecutionCounts[args[2]]++ @@ -1012,9 +1036,18 @@ func TestOnboardingTraceCoversEveryDiscoveredPresetAndREADMEInput(t *testing.T) } return installedCommandResult{Stdout: []byte(stdout)}, nil } - if err := verifyInstalledOnboardingTrace(consumer, execute); err != nil { + var transportCalls [][]string + transportExecute := func(consumer string, input []byte, args ...string) (installedCommandResult, error) { + transportCalls = append(transportCalls, append([]string(nil), args...)) + return execute(consumer, input, args...) + } + if err := verifyInstalledOnboardingTraceWithExecutors(consumer, transportExecute, execute); err != nil { t.Fatalf("verifyInstalledOnboardingTrace() error=%v", err) } + wantTransportCalls := [][]string{{"help"}, {"help", "adopt", "plan"}} + if !reflect.DeepEqual(transportCalls, wantTransportCalls) { + t.Fatalf("transport calls = %v, want %v", transportCalls, wantTransportCalls) + } if len(seenPresets) != len(choices) { t.Fatalf("executed preset count=%d, want %d", len(seenPresets), len(choices)) } @@ -1175,9 +1208,51 @@ func TestInstalledHelpRouteParsersRejectDuplicateOwnerIDs(t *testing.T) { " npm exec --offline -- agentic-proofkit help self-check\n" + " self-check\n" + " npm exec --offline -- agentic-proofkit help other\n" - if _, err := parseInstalledLeafHelpRoutes(leafHelp); err == nil || - !strings.Contains(err.Error(), "duplicate command id") { - t.Fatalf("duplicate command id error=%v", err) + contractRoutes := map[string]string{"self-check": "self-check", "other": "other"} + if _, err := parseInstalledLeafHelpRoutes(leafHelp, contractRoutes); err == nil || + !strings.Contains(err.Error(), "duplicate command route") { + t.Fatalf("duplicate command route error=%v", err) + } +} + +func TestInstalledLeafHelpRoutesResolveMultiTokenContractRoute(t *testing.T) { + help := "Commands:\n" + + " adopt plan\n" + + " npm exec --offline -- agentic-proofkit help adopt plan\n" + routes, err := parseInstalledLeafHelpRoutes(help, map[string]string{"adopt plan": "adopt-plan"}) + if err != nil { + t.Fatalf("parse multi-token command route: %v", err) + } + if len(routes) != 1 || routes[0].ID != "adopt-plan" || routes[0].Route != "adopt plan" || + !slices.Equal(routes[0].Argv, []string{"help", "adopt", "plan"}) { + t.Fatalf("routes=%v", routes) + } +} + +func TestInstalledContractCommandRoutesRejectAmbiguousIdentity(t *testing.T) { + valid := []byte(`{"commands":[{"command":"self-check"},{"command":"adopt-plan","route":["adopt","plan"]}]}`) + routes, err := installedContractCommandIDsByRoute(valid) + if err != nil { + t.Fatalf("installedContractCommandIDsByRoute() error=%v", err) + } + if len(routes) != 2 || routes["self-check"] != "self-check" || routes["adopt plan"] != "adopt-plan" { + t.Fatalf("installed contract routes=%v", routes) + } + mutants := map[string]string{ + "empty commands": `{"commands":[]}`, + "duplicate id": `{"commands":[{"command":"same"},{"command":"same","route":["other"]}]}`, + "duplicate route": `{"commands":[{"command":"one","route":["same"]},{"command":"two","route":["same"]}]}`, + "route prefix": `{"commands":[{"command":"one","route":["adopt"]},{"command":"two","route":["adopt","plan"]}]}`, + "empty route": `{"commands":[{"command":"one","route":[]}]}`, + "invalid route": `{"commands":[{"command":"one","route":["bad token"]}]}`, + "duplicate JSON key": `{"commands":[{"command":"one","command":"two"}]}`, + } + for name, content := range mutants { + t.Run(name, func(t *testing.T) { + if _, err := installedContractCommandIDsByRoute([]byte(content)); err == nil { + t.Fatalf("ambiguous installed contract route was admitted: %s", content) + } + }) } } diff --git a/internal/tools/pythonpackage/build.go b/internal/tools/pythonpackage/build.go index b1d5044..f121d40 100644 --- a/internal/tools/pythonpackage/build.go +++ b/internal/tools/pythonpackage/build.go @@ -28,6 +28,11 @@ type wheelEntry struct { const maxZip32Size = uint64(^uint32(0)) +const ( + sourceCLIContractPath = "proofkit/cli-contract.v2.json" + embeddedCLIContractPath = "agentic_proofkit/proofkit/cli-contract.v2.json" +) + func buildPythonPackages() error { return buildPythonPackagesForTargets(releaseTargets()) } @@ -117,21 +122,25 @@ func buildWheel(outputDir string, manifest packageJSON, target target) (wheelRec func wheelEntries(manifest packageJSON, target target, binary []byte) ([]wheelEntry, error) { distInfo := distInfoDir(manifest.Version) - sourceFiles := []string{ - "python/agentic_proofkit/__init__.py", - "python/agentic_proofkit/__main__.py", - "python/agentic_proofkit/cli.py", + sourceFiles := []struct { + path string + wheelPath string + }{ + {path: "python/agentic_proofkit/__init__.py", wheelPath: "agentic_proofkit/__init__.py"}, + {path: "python/agentic_proofkit/__main__.py", wheelPath: "agentic_proofkit/__main__.py"}, + {path: "python/agentic_proofkit/cli.py", wheelPath: "agentic_proofkit/cli.py"}, + {path: sourceCLIContractPath, wheelPath: embeddedCLIContractPath}, } entries := make([]wheelEntry, 0, len(sourceFiles)+6) for _, source := range sourceFiles { - content, err := os.ReadFile(source) + content, err := os.ReadFile(source.path) if err != nil { return nil, err } entries = append(entries, wheelEntry{ Content: content, Mode: 0o644, - Path: strings.TrimPrefix(source, "python/"), + Path: source.wheelPath, }) } license, err := readLicenseFile() diff --git a/internal/tools/pythonpackage/continuation_test.go b/internal/tools/pythonpackage/continuation_test.go index e7a5867..affacf1 100644 --- a/internal/tools/pythonpackage/continuation_test.go +++ b/internal/tools/pythonpackage/continuation_test.go @@ -33,6 +33,48 @@ func TestExactDisplayedRouteOperandsRejectsWhitespaceAndExpansionMutants(t *test } } +func TestExactDisplayedCommandRoutesAdmitBoundedMultiTokenRoutes(t *testing.T) { + const prefix = "/venv/bin/python -m agentic_proofkit help " + routes, err := exactDisplayedCommandRoutes([]byte("Commands:\n "+prefix+"adopt plan\n"), prefix, "test command routes") + if err != nil || len(routes) != 1 || routes[0] != "adopt plan" { + t.Fatalf("exact command routes=%v error=%v, want [adopt plan]", routes, err) + } + mutants := map[string]string{ + "empty token": "adopt plan", + "too many tokens": "one two three four five", + "shell punctuation": "adopt plan;touch-pwned", + "quoted token": "adopt 'plan'", + } + for name, route := range mutants { + t.Run(name, func(t *testing.T) { + output := []byte("Commands:\n " + prefix + route + "\n") + if _, err := exactDisplayedCommandRoutes(output, prefix, "test command routes"); err == nil { + t.Fatalf("mutant survived exact command-route admission: %q", route) + } + }) + } +} + +func TestInstalledPythonCommandRoutesRequireExactContractBijection(t *testing.T) { + expected := map[string]string{"adopt plan": "adopt-plan", "self-check": "self-check"} + if err := requireInstalledPythonCommandRouteBijection( + map[string]string{"adopt plan": "adoption", "self-check": "quality"}, + expected, + ); err != nil { + t.Fatalf("exact route bijection rejected: %v", err) + } + mutants := []map[string]string{ + {"adopt plan": "adoption"}, + {"adopt plan": "adoption", "other": "quality"}, + {"adopt plan": "adoption", "self-check": "quality", "other": "quality"}, + } + for _, mutant := range mutants { + if err := requireInstalledPythonCommandRouteBijection(mutant, expected); err == nil { + t.Fatalf("route mutant survived exact bijection: %v", mutant) + } + } +} + func TestInstalledWheelContinuationUsesExactPythonModuleProfileWithoutNPM(t *testing.T) { if runtime.GOOS == "windows" { t.Fatal("Windows wheels are not supported") diff --git a/internal/tools/pythonpackage/metadata_test.go b/internal/tools/pythonpackage/metadata_test.go index b19dc5d..0e342a2 100644 --- a/internal/tools/pythonpackage/metadata_test.go +++ b/internal/tools/pythonpackage/metadata_test.go @@ -15,7 +15,10 @@ import ( "github.com/research-engineering/agentic-proofkit/internal/kernel/releaseplatform" ) -const testLicenseContent = "MIT License\n" +const ( + testLicenseContent = "MIT License\n" + testCLIContractContent = `{"commands":[{"command":"stack-preset","outputContract":{"flagChoices":{"--preset":["go_cli_repo"]}}}]}` +) func TestPythonPackageReadersRejectAmbiguousJSON(t *testing.T) { cases := []struct { @@ -231,12 +234,17 @@ func TestWheelEntriesIncludeCanonicalLicense(t *testing.T) { "python/agentic_proofkit/__init__.py", "python/agentic_proofkit/__main__.py", "python/agentic_proofkit/cli.py", + sourceCLIContractPath, } { path := filepath.Join(root, source) if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { t.Fatal(err) } - if err := os.WriteFile(path, []byte(source), 0o600); err != nil { + content := []byte(source) + if source == sourceCLIContractPath { + content = []byte(testCLIContractContent) + } + if err := os.WriteFile(path, content, 0o600); err != nil { t.Fatal(err) } } @@ -253,15 +261,25 @@ func TestWheelEntriesIncludeCanonicalLicense(t *testing.T) { } }) wantPath := distInfoDir("1.2.3") + "/licenses/" + licenseFilename + foundLicense := false + foundContract := false for _, entry := range entries { if entry.Path == wantPath { if string(entry.Content) != testLicenseContent { t.Fatalf("wheel license = %q, want canonical source license", entry.Content) } - return + foundLicense = true + } + if entry.Path == embeddedCLIContractPath { + if string(entry.Content) != testCLIContractContent { + t.Fatalf("wheel CLI contract = %q, want canonical source contract", entry.Content) + } + foundContract = true } } - t.Fatalf("wheelEntries() missing %s", wantPath) + if !foundLicense || !foundContract { + t.Fatalf("wheelEntries() found license=%t contract=%t", foundLicense, foundContract) + } } func TestVerifyWheelContentsRequiresExactWheelMetadata(t *testing.T) { @@ -270,7 +288,7 @@ func TestVerifyWheelContentsRequiresExactWheelMetadata(t *testing.T) { path := filepath.Join(t.TempDir(), "wheel.whl") writeMinimalWheel(t, path, version, wheelMetadata(target)+"Tag: py3-none-conflicting\n") - err := verifyWheelContents(path, testPackageManifest(version), target, binarySHA256([]byte("binary")), []byte(testLicenseContent)) + err := verifyWheelContents(path, testPackageManifest(version), target, binarySHA256([]byte("binary")), []byte(testLicenseContent), []byte(testCLIContractContent)) if err == nil || !strings.Contains(err.Error(), "WHEEL metadata must match release platform target") { t.Fatalf("verifyWheelContents() error=%v, want exact WHEEL metadata rejection", err) } @@ -300,7 +318,7 @@ func TestVerifyWheelContentsRejectsDataDescriptors(t *testing.T) { path := filepath.Join(t.TempDir(), "wheel.whl") writeDataDescriptorWheel(t, path, version, wheelMetadata(target)) - err := verifyWheelContents(path, testPackageManifest(version), target, binarySHA256([]byte("binary")), []byte(testLicenseContent)) + err := verifyWheelContents(path, testPackageManifest(version), target, binarySHA256([]byte("binary")), []byte(testLicenseContent), []byte(testCLIContractContent)) if err == nil || !strings.Contains(err.Error(), "ZIP data descriptor") { t.Fatalf("verifyWheelContents() error=%v, want data descriptor rejection", err) } @@ -312,7 +330,7 @@ func TestVerifyWheelContentsRejectsEmbeddedBinaryDifferentFromSourceRecord(t *te path := filepath.Join(t.TempDir(), "wheel.whl") writeMinimalWheelWithBinary(t, path, version, wheelMetadata(target), []byte("corrupted-binary")) - err := verifyWheelContents(path, testPackageManifest(version), target, binarySHA256([]byte("source-binary")), []byte(testLicenseContent)) + err := verifyWheelContents(path, testPackageManifest(version), target, binarySHA256([]byte("source-binary")), []byte(testLicenseContent), []byte(testCLIContractContent)) if err == nil || !strings.Contains(err.Error(), "embedded binary sha256 mismatch") { t.Fatalf("verifyWheelContents() error=%v, want embedded/source binary identity rejection", err) } @@ -327,7 +345,7 @@ func TestVerifyWheelContentsRejectsDarwinTagBelowMachOMinimum(t *testing.T) { path := filepath.Join(t.TempDir(), "wheel.whl") writeMinimalWheelWithBinary(t, path, version, wheelMetadata(target), binaryContent) - err := verifyWheelContents(path, testPackageManifest(version), target, binarySHA256(binaryContent), []byte(testLicenseContent)) + err := verifyWheelContents(path, testPackageManifest(version), target, binarySHA256(binaryContent), []byte(testLicenseContent), []byte(testCLIContractContent)) if err == nil || !strings.Contains(err.Error(), "embedded Mach-O requires macOS 12.0") { t.Fatalf("verifyWheelContents() error=%v, want stale Darwin tag rejection", err) } @@ -344,7 +362,7 @@ func TestVerifyWheelContentsAcceptsDarwinTagAtOrAboveMachOMinimum(t *testing.T) path := filepath.Join(t.TempDir(), "wheel.whl") writeMinimalWheelWithBinary(t, path, version, wheelMetadata(target), binaryContent) - if err := verifyWheelContents(path, testPackageManifest(version), target, binarySHA256(binaryContent), []byte(testLicenseContent)); err != nil { + if err := verifyWheelContents(path, testPackageManifest(version), target, binarySHA256(binaryContent), []byte(testLicenseContent), []byte(testCLIContractContent)); err != nil { t.Fatalf("verifyWheelContents() error=%v, want truthful Darwin tag accepted", err) } }) @@ -396,7 +414,7 @@ func TestVerifyWheelContentsRejectsLegacyLicenseMetadata(t *testing.T) { path := filepath.Join(t.TempDir(), "wheel.whl") writeMinimalWheelFixture(t, path, version, wheelMetadata(target), []byte("binary"), "Metadata-Version: 2.1\nLicense: MIT\n", true, []byte(testLicenseContent)) - err := verifyWheelContents(path, testPackageManifest(version), target, binarySHA256([]byte("binary")), []byte(testLicenseContent)) + err := verifyWheelContents(path, testPackageManifest(version), target, binarySHA256([]byte("binary")), []byte(testLicenseContent), []byte(testCLIContractContent)) if err == nil || !strings.Contains(err.Error(), "METADATA mismatch") { t.Fatalf("verifyWheelContents() error=%v, want legacy METADATA rejection", err) } @@ -408,7 +426,7 @@ func TestVerifyWheelContentsRequiresLicenseFile(t *testing.T) { path := filepath.Join(t.TempDir(), "wheel.whl") writeMinimalWheelFixture(t, path, version, wheelMetadata(target), []byte("binary"), metadata(testPackageManifest(version)), false, nil) - err := verifyWheelContents(path, testPackageManifest(version), target, binarySHA256([]byte("binary")), []byte(testLicenseContent)) + err := verifyWheelContents(path, testPackageManifest(version), target, binarySHA256([]byte("binary")), []byte(testLicenseContent), []byte(testCLIContractContent)) if err == nil || !strings.Contains(err.Error(), "licenses/LICENSE") { t.Fatalf("verifyWheelContents() error=%v, want missing license rejection", err) } @@ -420,12 +438,24 @@ func TestVerifyWheelContentsRejectsDifferentLicenseFile(t *testing.T) { path := filepath.Join(t.TempDir(), "wheel.whl") writeMinimalWheelFixture(t, path, version, wheelMetadata(target), []byte("binary"), metadata(testPackageManifest(version)), true, []byte("different license\n")) - err := verifyWheelContents(path, testPackageManifest(version), target, binarySHA256([]byte("binary")), []byte(testLicenseContent)) + err := verifyWheelContents(path, testPackageManifest(version), target, binarySHA256([]byte("binary")), []byte(testLicenseContent), []byte(testCLIContractContent)) if err == nil || !strings.Contains(err.Error(), "embedded LICENSE mismatch") { t.Fatalf("verifyWheelContents() error=%v, want source/artifact license mismatch rejection", err) } } +func TestVerifyWheelContentsRejectsDifferentCLIContract(t *testing.T) { + target := releaseTargets()[2] + version := "1.2.3" + path := filepath.Join(t.TempDir(), "wheel.whl") + writeMinimalWheel(t, path, version, wheelMetadata(target)) + + err := verifyWheelContents(path, testPackageManifest(version), target, binarySHA256([]byte("binary")), []byte(testLicenseContent), []byte(`{"commands":[]}`)) + if err == nil || !strings.Contains(err.Error(), "embedded CLI contract mismatch") { + t.Fatalf("verifyWheelContents() error=%v, want source/artifact contract mismatch rejection", err) + } +} + func writeMinimalWheel(t *testing.T, path string, version string, wheel string) { writeMinimalWheelWithBinary(t, path, version, wheel, []byte("binary")) } @@ -442,6 +472,7 @@ func writeMinimalWheelFixture(t *testing.T, path string, version string, wheel s {Path: "agentic_proofkit/__main__.py", Mode: 0o644}, {Path: "agentic_proofkit/cli.py", Mode: 0o644}, {Path: "agentic_proofkit/bin/agentic-proofkit", Content: binary, Mode: 0o755}, + {Path: embeddedCLIContractPath, Content: []byte(testCLIContractContent), Mode: 0o644}, {Path: distInfo + "/METADATA", Content: []byte(metadataContent), Mode: 0o644}, {Path: distInfo + "/WHEEL", Content: []byte(wheel), Mode: 0o644}, {Path: distInfo + "/entry_points.txt", Content: []byte(entryPoints()), Mode: 0o644}, diff --git a/internal/tools/pythonpackage/verify.go b/internal/tools/pythonpackage/verify.go index dda2576..99aa676 100644 --- a/internal/tools/pythonpackage/verify.go +++ b/internal/tools/pythonpackage/verify.go @@ -23,6 +23,7 @@ import ( "github.com/research-engineering/agentic-proofkit/internal/kernel/admission" "github.com/research-engineering/agentic-proofkit/internal/kernel/cliexec" "github.com/research-engineering/agentic-proofkit/internal/kernel/unicodepolicy" + "github.com/research-engineering/agentic-proofkit/internal/tools/installedclicontract" "github.com/research-engineering/agentic-proofkit/internal/tools/workflowsmoke" ) @@ -118,10 +119,14 @@ func verifyWheelRecord(manifest packageJSON, target target, record wheelRecord) if err != nil { return err } - return verifyWheelContents(wheelPath, manifest, target, record.BinarySha256, license) + contract, err := os.ReadFile(sourceCLIContractPath) + if err != nil { + return fmt.Errorf("read source CLI contract: %w", err) + } + return verifyWheelContents(wheelPath, manifest, target, record.BinarySha256, license, contract) } -func verifyWheelContents(path string, manifest packageJSON, target target, expectedBinarySHA256 string, expectedLicense []byte) error { +func verifyWheelContents(path string, manifest packageJSON, target target, expectedBinarySHA256 string, expectedLicense []byte, expectedCLIContract []byte) error { reader, err := zip.OpenReader(path) if err != nil { return err @@ -144,6 +149,7 @@ func verifyWheelContents(path string, manifest packageJSON, target target, expec "agentic_proofkit/__main__.py", "agentic_proofkit/cli.py", "agentic_proofkit/bin/agentic-proofkit", + embeddedCLIContractPath, distInfo + "/METADATA", distInfo + "/WHEEL", distInfo + "/entry_points.txt", @@ -174,6 +180,16 @@ func verifyWheelContents(path string, manifest packageJSON, target target, expec if !bytes.Equal(licenseContent, expectedLicense) { return fmt.Errorf("%s embedded %s mismatch", path, licenseFilename) } + contractContent, err := readZipFile(entries[embeddedCLIContractPath]) + if err != nil { + return err + } + if !bytes.Equal(contractContent, expectedCLIContract) { + return fmt.Errorf("%s embedded CLI contract mismatch", path) + } + if _, err := installedclicontract.Admit(contractContent); err != nil { + return fmt.Errorf("%s embedded CLI contract is invalid: %w", path, err) + } wheel, err := readZipFile(entries[distInfo+"/WHEEL"]) if err != nil { return err @@ -517,6 +533,16 @@ func verifyInstalledPythonPresetContinuation(consumer string, venvPython string) } func verifyInstalledPythonHelpAndAgentRouteContinuity(consumer string, environment []string, renderer cliexec.Renderer) error { + contractContent, err := readInstalledPythonCLIContract(consumer, environment, renderer) + if err != nil { + return err + } + contract, err := installedclicontract.Admit(contractContent) + if err != nil { + return fmt.Errorf("admit installed Python wheel CLI contract: %w", err) + } + contractCommandIDsByRoute := contract.CommandIDsByRoute() + rootHelp, err := runArgvWithEnvironment(consumer, environment, renderer.Argv("help")) if err != nil { return fmt.Errorf("installed Python wheel root help route failed: %w\n%s", err, rootHelp) @@ -535,27 +561,35 @@ func verifyInstalledPythonHelpAndAgentRouteContinuity(consumer string, environme return err } leafPrefix := renderer.DisplayCommand() + " help " - leafRouteCount := 0 + observedRoutes := make(map[string]string, len(contractCommandIDsByRoute)) for _, familyID := range familyIDs { familyRoute := renderer.DisplayCommand("help", "family", familyID) familyHelp, err := runArgvWithEnvironment(consumer, environment, renderer.Argv("help", "family", familyID)) if err != nil { return fmt.Errorf("installed Python wheel family route %q failed: %w\n%s", familyRoute, err, familyHelp) } - leafCommands, err := exactDisplayedRouteOperands(familyHelp, leafPrefix, "installed Python wheel leaf routes") + leafRoutes, err := exactDisplayedCommandRoutes(familyHelp, leafPrefix, "installed Python wheel leaf routes") if err != nil { return err } - leafRouteCount += len(leafCommands) - for _, leafCommand := range leafCommands { - leafRoute := renderer.DisplayCommand("help", leafCommand) - if _, err := runArgvWithEnvironment(consumer, environment, renderer.Argv("help", leafCommand)); err != nil { - return fmt.Errorf("installed Python wheel leaf help route %q failed: %w", leafRoute, err) + for _, leafRouteText := range leafRoutes { + commandID, exists := contractCommandIDsByRoute[leafRouteText] + if !exists { + return fmt.Errorf("installed Python wheel family %s exposes route %q absent from its embedded CLI contract", familyID, leafRouteText) + } + if priorFamily, exists := observedRoutes[leafRouteText]; exists { + return fmt.Errorf("installed Python wheel route %q is exposed by both %s and %s", leafRouteText, priorFamily, familyID) + } + observedRoutes[leafRouteText] = familyID + helpArgs := append([]string{"help"}, strings.Split(leafRouteText, " ")...) + leafRoute := renderer.DisplayCommand(helpArgs...) + if _, err := runArgvWithEnvironment(consumer, environment, renderer.Argv(helpArgs...)); err != nil { + return fmt.Errorf("installed Python wheel leaf help route %q for command %s failed: %w", leafRoute, commandID, err) } } } - if leafRouteCount == 0 { - return fmt.Errorf("installed Python wheel help chain exposed no leaf routes") + if err := requireInstalledPythonCommandRouteBijection(observedRoutes, contractCommandIDsByRoute); err != nil { + return err } requirementSourceRef := "requirements.v1.json" @@ -639,6 +673,34 @@ func verifyInstalledPythonHelpAndAgentRouteContinuity(consumer string, environme return requirePythonPassedJSON(commandOutput, "installed Python wheel emitted agent-route argv") } +func readInstalledPythonCLIContract(consumer string, environment []string, renderer cliexec.Renderer) ([]byte, error) { + launcher := renderer.Argv() + if len(launcher) != 3 || launcher[1] != "-m" || launcher[2] != "agentic_proofkit" { + return nil, fmt.Errorf("installed Python wheel contract reader requires the Python module launcher") + } + const script = `from importlib.resources import files; print(files("agentic_proofkit").joinpath("proofkit", "cli-contract.v2.json").read_text(encoding="utf-8"), end="")` + content, err := runCommandWithEnvironment(consumer, environment, launcher[0], "-c", script) + if err != nil { + return nil, fmt.Errorf("read installed Python wheel CLI contract: %w\n%s", err, content) + } + if len(content) == 0 || len(content) > 1<<20 { + return nil, fmt.Errorf("installed Python wheel CLI contract size must be between 1 and %d bytes", 1<<20) + } + return content, nil +} + +func requireInstalledPythonCommandRouteBijection(observed map[string]string, expected map[string]string) error { + if len(observed) != len(expected) { + return fmt.Errorf("installed Python wheel family routes=%d embedded contract routes=%d", len(observed), len(expected)) + } + for route := range expected { + if _, exists := observed[route]; !exists { + return fmt.Errorf("installed Python wheel family navigation omitted embedded contract route %q", route) + } + } + return nil +} + func verifyInstalledPythonAgentRouteEnvelopeModes(consumer string, environment []string, renderer cliexec.Renderer, inputPath string) error { bareBrief, err := runArgvWithEnvironment(consumer, environment, renderer.Argv("agent-route", "--input", inputPath, "--agent-envelope")) if err != nil { @@ -703,6 +765,14 @@ func verifyInstalledPythonAgentRouteBrief(output []byte) error { } func exactDisplayedRouteOperands(output []byte, prefix string, context string) ([]string, error) { + return exactDisplayedRouteSuffixes(output, prefix, context, 1) +} + +func exactDisplayedCommandRoutes(output []byte, prefix string, context string) ([]string, error) { + return exactDisplayedRouteSuffixes(output, prefix, context, 4) +} + +func exactDisplayedRouteSuffixes(output []byte, prefix string, context string, maximumTokens int) ([]string, error) { decoded, err := unicodepolicy.DecodeUTF8(output) if err != nil { return nil, fmt.Errorf("installed Python wheel route output is not valid UTF-8") @@ -718,7 +788,7 @@ func exactDisplayedRouteOperands(output []byte, prefix string, context string) ( continue } operand := strings.TrimPrefix(route, prefix) - if !isCommandRouteOperand(operand) || route != prefix+operand { + if !isCommandRoute(operand, maximumTokens) || route != prefix+operand { return nil, fmt.Errorf("%s contain non-canonical route %q", context, route) } if _, duplicate := seen[operand]; duplicate { @@ -734,6 +804,19 @@ func exactDisplayedRouteOperands(output []byte, prefix string, context string) ( return operands, nil } +func isCommandRoute(value string, maximumTokens int) bool { + tokens := strings.Split(value, " ") + if len(tokens) == 0 || len(tokens) > maximumTokens { + return false + } + for _, token := range tokens { + if !isCommandRouteOperand(token) { + return false + } + } + return true +} + func isCommandRouteOperand(value string) bool { if value == "" { return false diff --git a/internal/tools/releasechange/record_test.go b/internal/tools/releasechange/record_test.go index 35c0932..a83ca13 100644 --- a/internal/tools/releasechange/record_test.go +++ b/internal/tools/releasechange/record_test.go @@ -195,24 +195,24 @@ func TestCurrentChangeRecordNamesReviewedSemanticChanges(t *testing.T) { } var currentBreakingChanges = []Change{ - {ChangeID: "proofkit.agent-route.brief-default", Summary: "Change bare agent-route --agent-envelope output from the generic full envelope to the bounded proofkit.agent-route.brief packet."}, - {ChangeID: "proofkit.agent-route.materialized-artifact-refs", Summary: "Reject the stdin transport sentinel as an available-input or observed-report artifact identity; route references must name materialized caller-owned artifacts."}, - {ChangeID: "proofkit.agent-route.report-schema-v3", Summary: "Advance the bare agent-route report to schemaVersion 3 while preserving route-family semantics and adding exact launcher-profile and pre-suppression available-command context."}, - {ChangeID: "proofkit.platform.macos-13", Summary: "Raise published Darwin package compatibility to macOS 13.0 so wheel tags remain truthful for binaries built with Go 1.27.1."}, - {ChangeID: "proofkit.toolchain.npm-12", Summary: "Require npm 12.0.2 for repository-owned contributor and release scripts and consume npm view and pack evidence using npm 12 wire forms."}, + {ChangeID: "proofkit.adoption.init-retired", Summary: "Remove the overloaded init command and its route presets in favor of the explicit read-only adopt plan trust-mode route and the existing bounded specialist commands."}, } var currentAdditions = []Change{ - {ChangeID: "proofkit.agent-route.envelope-detail-mode", Summary: "Add --agent-envelope-mode brief|full so agents can request bounded default guidance or the complete generic envelope explicitly."}, - {ChangeID: "proofkit.toolchain.currentness", Summary: "Update the verified build and CI baseline to Go 1.27.1 with Unicode 17.0.0, Node 26.8.1, npm 12.0.2, Python 3.14.7, and the current stable repository-owned dependency and action pins."}, + {ChangeID: "proofkit.adoption.front-door", Summary: "Add adopt plan as a read-only candidate-authoring front door with explicit fresh, code-baseline, and audit-from-code intent plus an optional orthogonal stack hint."}, + {ChangeID: "proofkit.adoption.repository-inventory", Summary: "Add a bounded explicit repository-inventory command that observes only a fixed root-file catalog without stack or source-semantic inference."}, + {ChangeID: "proofkit.cli.generated-adapter-command-routes", Summary: "Extend the generated TypeScript CLI adapter to admit one-to-four-token public command routes and pass each route token as a separate process argument while preserving one-token calls."}, + {ChangeID: "proofkit.cli.hierarchical-command-routes", Summary: "Add owner-generated multi-token command routes while retaining stable internal command IDs for contract and implementation ownership."}, + {ChangeID: "proofkit.python-wheel.embedded-cli-contract", Summary: "Embed the exact public CLI contract in every Python wheel and use the installed record to prove command-family route closure."}, } var currentMigrationSteps = []string{ - "Consumers that require the former generic agent-route envelope must add --agent-envelope-mode full after --agent-envelope; consumers that accept bounded route guidance may keep bare --agent-envelope.", - "Consumers of the bare agent-route report must admit schemaVersion 3; the prior route-family fields remain, and summary now binds launcherProfile and availableCommandCount.", - "Consumers that used the stdin transport sentinel as an agent-route availableInputs or observedReports ref must materialize that artifact and pass its safe repo-relative path instead.", - "Darwin consumers must use macOS 13.0 or later.", - "Contributors must use Go 1.27.1, Node 26.8.1, npm 12.0.2, and Python 3.14.7 for repository-owned build and release workflows.", + "Replace explicit init --preset fresh with adopt plan --mode fresh --repo-root .", + "Replace init --preset code-baseline with adopt plan --mode code-baseline --repo-root , and replace init --preset code-audit with adopt plan --mode audit-from-code --repo-root .", + "Replace init --preset legacy with migration-parity-admission followed by migration-plan over explicit caller-owned records; run requirement-source-transition when the migration changes requirement lifecycle state.", + "Replace init --preset change-set with changed-path-set followed by the explicit impact and selective-gate composition routes required by the consuming repository.", + "Replace bare init or init --preset all with help families, then select the smallest applicable bounded route rather than materializing every route family.", + "Regenerate any materialized TypeScript CLI adapter source before invoking a multi-token route such as adopt plan; one-token adapter calls remain compatible.", } func validateCurrentChangeRecord(record Record, notes string) error { @@ -233,7 +233,7 @@ func validateCurrentChangeRecord(record Record, notes string) error { func currentExpectedReleaseNotes() string { lines := []string{ - "# @research-engineering/agentic-proofkit 0.6.0", + "# @research-engineering/agentic-proofkit 0.7.0", "", "## Breaking Contract Changes", "", @@ -266,6 +266,7 @@ func currentExpectedReleaseNotes() string { "", "## Known Limitations", "", + "- Adopt plan inventories only a fixed root-file catalog; it does not infer stack identity, inspect arbitrary source semantics, generate requirements, write files, or execute native evidence.", "- Agent workflow plans, prompts, text, and envelopes are derived guidance and do not execute agents, repository mutations, native witnesses, CI, release, rollout, or production operations.", "- Brief agent-route packets cap pretty JSON at 3072 bytes and may defer oversized argv to explicit full detail; the bound does not claim tokenizer-specific token counts.", "- Complete nested public structural contracts remain blocked under SCHEMA-01; current CLI contracts own exact root variants only.", @@ -277,7 +278,7 @@ func currentExpectedReleaseNotes() string { "Primary npm channel:", "", "```bash", - "npm install --save-dev --save-exact @research-engineering/agentic-proofkit@0.6.0", + "npm install --save-dev --save-exact @research-engineering/agentic-proofkit@0.7.0", "```", "", "Pre-1.0 npm consumers must keep this dependency exact-pinned.", @@ -288,7 +289,7 @@ func currentExpectedReleaseNotes() string { "", "## Rollback", "", - "- Pin npm consumers to the previous admitted version 0.5.1 with `npm install --save-dev --save-exact @research-engineering/agentic-proofkit@0.5.1`.", + "- Pin npm consumers to the previous admitted version 0.6.0 with `npm install --save-dev --save-exact @research-engineering/agentic-proofkit@0.6.0`.", "- Treat local package artifacts as candidates until registry identity is proven.", ) return strings.Join(lines, "\n") + "\n" diff --git a/internal/tools/repositorysnapshot/snapshot_process_group_unix_test.go b/internal/tools/repositorysnapshot/snapshot_process_group_unix_test.go index 20c482c..18d769a 100644 --- a/internal/tools/repositorysnapshot/snapshot_process_group_unix_test.go +++ b/internal/tools/repositorysnapshot/snapshot_process_group_unix_test.go @@ -4,10 +4,8 @@ package repositorysnapshot import ( "context" - "errors" "os" "path/filepath" - "strconv" "strings" "syscall" "testing" @@ -15,30 +13,58 @@ import ( ) func testCaptureContextTerminatesGitProcessGroupOnOutputOverflow(t *testing.T) { + if os.Getenv("PROOFKIT_TEST_PROCESS_GROUP_MODE") == "lock-holder" { + holdProcessGroupOracleLock(t) + return + } + bin := t.TempDir() gitPath := filepath.Join(bin, "git") - processGroupPath := filepath.Join(t.TempDir(), "process-group") - script := "#!/bin/sh\n/bin/sleep 30 &\nprintf '%s\\n' \"$$\" > \"$PROOFKIT_TEST_PROCESS_GROUP\"\n/bin/dd if=/dev/zero bs=17825792 count=1 1>&2\nwait\n" + oracleDir := t.TempDir() + lockPath := filepath.Join(oracleDir, "descendant.lock") + readyPath := filepath.Join(oracleDir, "descendant.ready") + testBinary, err := os.Executable() + if err != nil { + t.Fatal(err) + } + script := "#!/bin/sh\n\"$PROOFKIT_TEST_BINARY\" -test.run '^TestCaptureContextTerminatesGitProcessGroupOnOutputOverflow$' &\nwhile [ ! -f \"$PROOFKIT_TEST_PROCESS_GROUP_READY\" ]; do /bin/sleep 0.01; done\n/bin/dd if=/dev/zero bs=17825792 count=1 1>&2\nwait\n" if err := os.WriteFile(gitPath, []byte(script), 0o755); err != nil { t.Fatal(err) } t.Setenv("PATH", bin) - t.Setenv("PROOFKIT_TEST_PROCESS_GROUP", processGroupPath) + t.Setenv("PROOFKIT_TEST_BINARY", testBinary) + t.Setenv("PROOFKIT_TEST_PROCESS_GROUP_MODE", "lock-holder") + t.Setenv("PROOFKIT_TEST_PROCESS_GROUP_LOCK", lockPath) + t.Setenv("PROOFKIT_TEST_PROCESS_GROUP_READY", readyPath) ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second) defer cancel() if _, err := CaptureContext(ctx, t.TempDir()); err == nil || !strings.Contains(err.Error(), "output exceeds resource limit") { t.Fatalf("CaptureContext() error = %v, want output-limit rejection", err) } - processGroupBytes, err := os.ReadFile(processGroupPath) + lockFile, err := os.OpenFile(lockPath, os.O_RDWR, 0) + if err != nil { + t.Fatalf("open process-group oracle lock: %v", err) + } + defer lockFile.Close() + if err := syscall.Flock(int(lockFile.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); err != nil { + t.Fatalf("descendant retained process-group oracle lock after output-limit termination: %v", err) + } + if err := syscall.Flock(int(lockFile.Fd()), syscall.LOCK_UN); err != nil { + t.Fatalf("release process-group oracle lock: %v", err) + } +} + +func holdProcessGroupOracleLock(t *testing.T) { + lockFile, err := os.OpenFile(os.Getenv("PROOFKIT_TEST_PROCESS_GROUP_LOCK"), os.O_CREATE|os.O_RDWR, 0o600) if err != nil { - t.Fatalf("read process-group oracle: %v", err) + t.Fatal(err) } - processGroupID, err := strconv.Atoi(strings.TrimSpace(string(processGroupBytes))) - if err != nil || processGroupID <= 0 { - t.Fatalf("invalid process-group oracle %q", processGroupBytes) + defer lockFile.Close() + if err := syscall.Flock(int(lockFile.Fd()), syscall.LOCK_EX); err != nil { + t.Fatal(err) } - if err := syscall.Kill(-processGroupID, syscall.Signal(0)); !errors.Is(err, syscall.ESRCH) { - _ = syscall.Kill(-processGroupID, syscall.SIGKILL) - t.Fatalf("git process group %d survived output-limit termination: %v", processGroupID, err) + if err := os.WriteFile(os.Getenv("PROOFKIT_TEST_PROCESS_GROUP_READY"), []byte("ready\n"), 0o600); err != nil { + t.Fatal(err) } + select {} } diff --git a/package-lock.json b/package-lock.json index c98c25d..0984c11 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@research-engineering/agentic-proofkit", - "version": "0.6.0", + "version": "0.7.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@research-engineering/agentic-proofkit", - "version": "0.6.0", + "version": "0.7.0", "cpu": [ "arm64", "x64" diff --git a/package.json b/package.json index 0d8791c..6a57a4b 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.6.0", + "version": "0.7.0", "type": "module", "license": "MIT", "sideEffects": false, diff --git a/proofkit/cli-contract.v2.json b/proofkit/cli-contract.v2.json index 52c7fe6..cfefe96 100644 --- a/proofkit/cli-contract.v2.json +++ b/proofkit/cli-contract.v2.json @@ -29,12 +29,132 @@ "-h" ], "commandHelpExclusive": true, - "helpCommandPositionalTarget": "optional_supported_command", + "helpCommandPositionalTarget": "optional_supported_command_route", "helpCatalogFormsSource": "proofkit/command-families.v1.json", "helpReadsCommandInput": false } }, "commands": [ + { + "command": "adopt-plan", + "route": [ + "adopt", + "plan" + ], + "input": "none", + "stdin": false, + "inputPointer": false, + "scopeClass": "explicit_filesystem_scan", + "outputModes": [ + "json", + "text" + ], + "allowedFlags": [ + "--color", + "--format", + "--mode", + "--repo-root", + "--stack" + ], + "requiredFlags": [ + "--mode", + "--repo-root" + ], + "singleOccurrenceFlags": [ + "--color", + "--format", + "--mode", + "--repo-root", + "--stack" + ], + "flagChoices": { + "--color": [ + "auto", + "never" + ], + "--format": [ + "json", + "text" + ], + "--mode": [ + "audit-from-code", + "code-baseline", + "fresh" + ], + "--stack": [ + "agentic_runtime_repo", + "generated_docs_contract_repo", + "python_service", + "python_typescript_service", + "typescript_monorepo", + "typescript_workspace" + ] + }, + "flagPresenceRequirements": [ + { + "flag": "--color", + "requiredFlagValues": [ + { + "flag": "--format", + "value": "text" + } + ], + "requiredFlags": [] + } + ], + "outputContract": { + "contractId": "proofkit.adopt-plan.output.v1", + "schemaVersion": 1, + "rootType": "object", + "closed": true, + "rootDefinitionRef": "proofkit.adopt-plan.output.v1.root-shape", + "rootDefinitionDigest": "sha256:c7bc9d7b70231d57a3066cad755d30aacfbc92fe57ac54d2df5d2ec7ed175a32", + "nativeSource": { + "path": "internal/command/adoptionplan", + "canonicalDigest": "sha256:dc9f0423cf3c7819daa7c4b4da2831bad1dc21d3c91ca93862451b9c10e90642", + "evidenceClass": "source_checkout" + }, + "nativeOutputWitnessSelector": { + "path": "internal/command/adoptionplan/adoptionplan_test.go", + "test": "TestPlanWireAdmissionIsDeterministicAndOwnerClosed", + "command": "go test ./internal/command/adoptionplan -run '^TestPlanWireAdmissionIsDeterministicAndOwnerClosed$'", + "evidenceClass": "source_checkout" + }, + "compatibilitySummary": [ + "schemaVersion=1", + "JSON adoption plan; text is a bounded derived presentation", + "root-shape-only definition proofkit.adopt-plan.output.v1.root-shape; nested fields, types, and cardinalities are non-claims" + ], + "ownerRequirementRefs": [ + "REQ-PROOFKIT-PACKAGE-002", + "REQ-PROOFKIT-QUALITY-004", + "REQ-PROOFKIT-SPEC-027" + ], + "flagChoices": { + "--color": [ + "auto", + "never" + ], + "--format": [ + "json", + "text" + ], + "--mode": [ + "audit-from-code", + "code-baseline", + "fresh" + ], + "--stack": [ + "agentic_runtime_repo", + "generated_docs_contract_repo", + "python_service", + "python_typescript_service", + "typescript_monorepo", + "typescript_workspace" + ] + } + } + }, { "command": "adoption-checklist", "input": "required", @@ -657,7 +777,7 @@ "nativeSources": [ { "path": "internal/app", - "canonicalDigest": "sha256:3fb162add7aad5988f19c88016571f113af948190c0042f74016b7a688625386", + "canonicalDigest": "sha256:22bd7683f1e429c98ea32753c01d93d75a845e77a00f5e1e16df5b8454158feb", "evidenceClass": "source_checkout" }, { @@ -845,7 +965,7 @@ "rootDefinitionDigest": "sha256:9b1b0b9b884f84f856db7373844a1988bb7ef0198a9f125219025530ed6054b2", "nativeSource": { "path": "internal/command/capabilitymapadmission", - "canonicalDigest": "sha256:ec4f642c3c56483f098ce7eb6f3448ce114f5c87cb2e4d5d23a259e40c94a8f1", + "canonicalDigest": "sha256:000dc65922353143b40c0a5e7be633ac79c898bb7260cc8a3801db48153b7d43", "evidenceClass": "source_checkout" }, "nativeAdmissionWitnessSelector": { @@ -873,7 +993,7 @@ "rootDefinitionDigest": "sha256:d20d4d55690f7b98768d8bb1c2c313f78bdf9ac8163fa6d1ff694664b2d7d999", "nativeSource": { "path": "internal/command/capabilitymapadmission", - "canonicalDigest": "sha256:ec4f642c3c56483f098ce7eb6f3448ce114f5c87cb2e4d5d23a259e40c94a8f1", + "canonicalDigest": "sha256:000dc65922353143b40c0a5e7be633ac79c898bb7260cc8a3801db48153b7d43", "evidenceClass": "source_checkout" }, "nativeOutputWitnessSelector": { @@ -1892,70 +2012,6 @@ ] } }, - { - "command": "init", - "input": "none", - "stdin": false, - "inputPointer": false, - "scopeClass": "built_in_package_catalog", - "outputModes": [ - "json" - ], - "allowedFlags": [ - "--preset" - ], - "outputContract": { - "contractId": "proofkit.init.output.v1", - "schemaVersion": 1, - "authority": "dry-run adoption route guidance", - "presets": [ - "all", - "change-set", - "code-audit", - "code-baseline", - "fresh", - "legacy" - ], - "requiredReportFields": [ - "diagnostics", - "nonClaims", - "reportId", - "reportKind", - "ruleResults", - "schemaVersion", - "state", - "summary" - ], - "nonClaim": "Init does not read repository files, discover tests, write files, create requirements, or approve merge/release/rollout.", - "rootType": "object", - "closed": true, - "rootDefinitionRef": "proofkit.init.output.v1.root-shape", - "rootDefinitionDigest": "sha256:9f57ccd7905a2d8cbffeb8cf3495d338ea67c0ebfe66889b4d5ee16434be5f88", - "nativeSource": { - "path": "internal/command/initplan", - "canonicalDigest": "sha256:b7cf7f6d0cf534c062ac6b9aa20129308aae0b1a1716e2be74569a5f2bd7dfcf", - "evidenceClass": "source_checkout" - }, - "nativeOutputWitnessSelector": { - "path": "internal/app/cli_abi_test.go", - "test": "TestCLIABIGoldenCorpus", - "command": "go test ./internal/app -run '^TestCLIABIGoldenCorpus$'", - "evidenceClass": "source_checkout" - }, - "compatibilitySummary": [ - "schemaVersion=1", - "root-shape-only definition proofkit.init.output.v1.root-shape; nested fields, types, and cardinalities are non-claims" - ], - "ownerRequirementRefs": [ - "REQ-PROOFKIT-PACKAGE-002", - "REQ-PROOFKIT-QUALITY-004", - "REQ-PROOFKIT-QUALITY-021", - "REQ-PROOFKIT-SPEC-009", - "REQ-PROOFKIT-SPEC-011", - "REQ-PROOFKIT-SPEC-012" - ] - } - }, { "command": "json-report-cli-adapter-source", "input": "none", @@ -2001,7 +2057,7 @@ "rootDefinitionDigest": "sha256:9378c7663485f39089cf6c39a3d2f778e28b5801637229fe9524a9652a7b0737", "nativeSource": { "path": "internal/command/jsonreportcliadaptersource", - "canonicalDigest": "sha256:4844b3312ab79264da9a70cc722df8b02fcda77182f5d1c717c39abee0143a83", + "canonicalDigest": "sha256:2ecc34739665b8b268c361c62001f05d22e5264c8ff6950c65024b01a39922d0", "evidenceClass": "source_checkout" }, "nativeOutputWitnessSelector": { @@ -2229,7 +2285,7 @@ "rootDefinitionDigest": "sha256:218011a133540f57ef74f8748747e00d33b6757c9f77725ecef39f45fb19423f", "nativeSource": { "path": "internal/command/nativeevidenceguidance", - "canonicalDigest": "sha256:14c609853ffb5a0decd40676f6b9621d983cd58ea4e7946e6c32ff31667221e1", + "canonicalDigest": "sha256:dc0f538649d2f097f6cd39dad1db4b11da249206cac1b741a528beab48969974", "evidenceClass": "source_checkout" }, "nativeOutputWitnessSelector": { @@ -2499,7 +2555,7 @@ "nativeSources": [ { "path": "internal/app", - "canonicalDigest": "sha256:3fb162add7aad5988f19c88016571f113af948190c0042f74016b7a688625386", + "canonicalDigest": "sha256:22bd7683f1e429c98ea32753c01d93d75a845e77a00f5e1e16df5b8454158feb", "evidenceClass": "source_checkout" }, { @@ -3442,6 +3498,54 @@ ] } }, + { + "command": "repository-inventory", + "input": "none", + "stdin": false, + "inputPointer": false, + "scopeClass": "explicit_filesystem_scan", + "outputModes": [ + "json" + ], + "allowedFlags": [ + "--repo-root" + ], + "requiredFlags": [ + "--repo-root" + ], + "singleOccurrenceFlags": [ + "--repo-root" + ], + "outputContract": { + "contractId": "proofkit.repository-inventory.output.v1", + "schemaVersion": 1, + "rootType": "object", + "closed": true, + "rootDefinitionRef": "proofkit.repository-inventory.output.v1.root-shape", + "rootDefinitionDigest": "sha256:cc91b8e1a3fb25180165204979ccbc07c378679e2ffe7f85348b3fd38ecbdc8e", + "nativeSource": { + "path": "internal/command/repositoryinventory", + "canonicalDigest": "sha256:cbf99b000fa1ddcef8b468e1aba9b515b7aeab9be254a84a6fc654100464383c", + "evidenceClass": "source_checkout" + }, + "nativeOutputWitnessSelector": { + "path": "internal/command/repositoryinventory/repositoryinventory_test.go", + "test": "TestScanProducesBoundedClosedInventory", + "command": "go test ./internal/command/repositoryinventory -run '^TestScanProducesBoundedClosedInventory$'", + "evidenceClass": "source_checkout" + }, + "compatibilitySummary": [ + "schemaVersion=1", + "bounded root-catalog observation without manifest or source semantics", + "root-shape-only definition proofkit.repository-inventory.output.v1.root-shape; nested fields, types, and cardinalities are non-claims" + ], + "ownerRequirementRefs": [ + "REQ-PROOFKIT-PACKAGE-002", + "REQ-PROOFKIT-QUALITY-004", + "REQ-PROOFKIT-SPEC-027" + ] + } + }, { "command": "requirement-authoring-plan", "input": "required", @@ -5741,7 +5845,7 @@ "rootDefinitionDigest": "sha256:3c842174dff5361e7f83166469b832805e05aa314b073c16234b5b64e346281e", "nativeSource": { "path": "internal/app", - "canonicalDigest": "sha256:3fb162add7aad5988f19c88016571f113af948190c0042f74016b7a688625386", + "canonicalDigest": "sha256:22bd7683f1e429c98ea32753c01d93d75a845e77a00f5e1e16df5b8454158feb", "evidenceClass": "source_checkout" }, "nativeAdmissionWitnessSelector": { @@ -5770,7 +5874,7 @@ "rootDefinitionDigest": "sha256:0ea95e277ebe44cd2de42c29b47c38686ac0b6b390d8965367437b3fe138e209", "nativeSource": { "path": "internal/app", - "canonicalDigest": "sha256:3fb162add7aad5988f19c88016571f113af948190c0042f74016b7a688625386", + "canonicalDigest": "sha256:22bd7683f1e429c98ea32753c01d93d75a845e77a00f5e1e16df5b8454158feb", "evidenceClass": "source_checkout" }, "nativeOutputWitnessSelector": { @@ -5963,7 +6067,7 @@ "rootDefinitionDigest": "sha256:338f720a24bd36f4ab34cad9b14d3bd26389119e5c713146f1c4eb019f4a0637", "nativeSource": { "path": "internal/command/stackpreset", - "canonicalDigest": "sha256:21977828a569c4fca863ca3f4ea143650f5545088568fae90a7ca936d855fe87", + "canonicalDigest": "sha256:15217c3923804206b0a05069566cb7b62787829785d314adcbfaf6637c87f4db", "evidenceClass": "source_checkout" }, "nativeOutputWitnessSelector": { @@ -7007,6 +7111,58 @@ } ], "contractDefinitions": [ + { + "definitionId": "proofkit.adopt-plan.output.v1.root-shape", + "schemaVersion": 1, + "rootType": "object", + "closed": true, + "definitionRefs": [], + "fieldTree": { + "kind": "root_shape_only", + "nonClaims": [ + "Root-shape definitions do not claim nested field shapes, leaf types, cardinalities, or semantic validity.", + "Root-shape definitions do not replace direct public-CLI runtime witnesses for variant selection." + ], + "variants": [ + { + "allowedFields": [ + "authoringPacket", + "authority", + "intent", + "nonClaims", + "planId", + "planKind", + "repositoryInventory", + "schemaVersion", + "sourceTrust", + "stackHint", + "state", + "summary" + ], + "requiredFields": [ + "authoringPacket", + "authority", + "intent", + "nonClaims", + "planId", + "planKind", + "repositoryInventory", + "schemaVersion", + "sourceTrust", + "stackHint", + "state", + "summary" + ], + "rootKind": "object", + "variantId": "01-root", + "when": [ + "default JSON mode" + ] + } + ] + }, + "canonicalDigest": "sha256:c7bc9d7b70231d57a3066cad755d30aacfbc92fe57ac54d2df5d2ec7ed175a32" + }, { "definitionId": "proofkit.adoption-checklist.input.v1.root-shape", "schemaVersion": 1, @@ -9806,50 +9962,6 @@ }, "canonicalDigest": "sha256:e6dacd55cace039ca8e24bdec628b1498d0c12bbfcdb60c46854ab2047c3f2d7" }, - { - "definitionId": "proofkit.init.output.v1.root-shape", - "schemaVersion": 1, - "rootType": "object", - "closed": true, - "definitionRefs": [], - "fieldTree": { - "kind": "root_shape_only", - "nonClaims": [ - "Root-shape definitions do not claim nested field shapes, leaf types, cardinalities, or semantic validity.", - "Root-shape definitions do not replace direct public-CLI runtime witnesses for variant selection." - ], - "variants": [ - { - "allowedFields": [ - "diagnostics", - "nonClaims", - "reportId", - "reportKind", - "ruleResults", - "schemaVersion", - "state", - "summary" - ], - "requiredFields": [ - "diagnostics", - "nonClaims", - "reportId", - "reportKind", - "ruleResults", - "schemaVersion", - "state", - "summary" - ], - "rootKind": "object", - "variantId": "01-root", - "when": [ - "default JSON mode" - ] - } - ] - }, - "canonicalDigest": "sha256:9f57ccd7905a2d8cbffeb8cf3495d338ea67c0ebfe66889b4d5ee16434be5f88" - }, { "definitionId": "proofkit.json-report-cli-adapter-source.output.v1.root-shape", "schemaVersion": 1, @@ -11675,6 +11787,50 @@ }, "canonicalDigest": "sha256:acf8e7db39bfb1325d83c2cdfe68a7c243847d207e56691a3b2937b28f95d3e7" }, + { + "definitionId": "proofkit.repository-inventory.output.v1.root-shape", + "schemaVersion": 1, + "rootType": "object", + "closed": true, + "definitionRefs": [], + "fieldTree": { + "kind": "root_shape_only", + "nonClaims": [ + "Root-shape definitions do not claim nested field shapes, leaf types, cardinalities, or semantic validity.", + "Root-shape definitions do not replace direct public-CLI runtime witnesses for variant selection." + ], + "variants": [ + { + "allowedFields": [ + "entries", + "inventoryId", + "inventoryKind", + "nonClaims", + "omissions", + "policyId", + "schemaVersion", + "scope" + ], + "requiredFields": [ + "entries", + "inventoryId", + "inventoryKind", + "nonClaims", + "omissions", + "policyId", + "schemaVersion", + "scope" + ], + "rootKind": "object", + "variantId": "01-root", + "when": [ + "default JSON mode" + ] + } + ] + }, + "canonicalDigest": "sha256:cc91b8e1a3fb25180165204979ccbc07c378679e2ffe7f85348b3fd38ecbdc8e" + }, { "definitionId": "proofkit.requirement-authoring-plan.input.v1.root-shape", "schemaVersion": 1, diff --git a/proofkit/command-families.v1.json b/proofkit/command-families.v1.json index 0b79830..ea62f8e 100644 --- a/proofkit/command-families.v1.json +++ b/proofkit/command-families.v1.json @@ -7,13 +7,13 @@ "label": "Adoption lifecycle", "purpose": "Select and assess repository adoption stages.", "commands": [ + "adopt-plan", "adoption-checklist", "adoption-doctor", "adoption-workflow-plan", "gradual-adoption", "gradual-adoption-bootstrap", "gradual-adoption-guidance", - "init", "pilot-admission" ] }, @@ -113,6 +113,7 @@ "purpose": "Admit explicit repository facts and bounded policy scans.", "commands": [ "repo-profile-admission", + "repository-inventory", "secret-scan", "text-policy", "typescript-public-api-surfaces", diff --git a/proofkit/requirement-bindings.json b/proofkit/requirement-bindings.json index 8e2f86e..ff2e28e 100644 --- a/proofkit/requirement-bindings.json +++ b/proofkit/requirement-bindings.json @@ -712,6 +712,14 @@ "proofState": "witness_backed", "nonClaims": ["The 3072-byte packet bound and stable source-report digest do not prove provider token consumption, semantic sufficiency, source freshness, command execution, native witness truth, or policy approval; detail retrieval requires the caller to retain the original admitted input and launcher context."] }, + { + "requirementId": "REQ-PROOFKIT-SPEC-027", + "ownerId": "proofkit.spec-proof-core", + "specPath": "docs/specs/proofkit-spec-proof-core/requirements.v1.json", + "claimLevel": "blocking", + "proofState": "witness_backed", + "nonClaims": ["The adoption front door does not infer repository stack identity, inspect arbitrary source semantics, decide product meaning, establish code correctness, write files, execute commands or native witnesses, promote candidates, authenticate freshness, approve merge or release, or establish rollout or production readiness."] + }, { "requirementId": "REQ-PROOFKIT-WORKFLOW-001", "ownerId": "proofkit.agent-workflow", @@ -1150,6 +1158,10 @@ { "selector": "TestExactTarballOnboardingTrace", "command": "go test ./internal/tools/packageverify -run '^TestExactTarballOnboardingTrace$'" + }, + { + "selector": "TestVerifyPackedOwnerRecordsRejectsSourceArtifactContentDrift", + "command": "go test ./internal/tools/packageverify -run '^TestVerifyPackedOwnerRecordsRejectsSourceArtifactContentDrift$'" } ], "commandIds": [ @@ -1161,6 +1173,21 @@ "local-go-python" ] }, + { + "requirementId": "REQ-PROOFKIT-PACKAGE-003", + "scenarioId": "proofkit.package-boundary.package-launcher-argv-forwarding", + "witnessId": "proofkit.packagebuild.launcher-argv-forwarding", + "witnessKind": "contract", + "witnessPath": "internal/tools/packagebuild/main_test.go", + "witnessSelectors": [ + { + "selector": "TestWrapperScriptRoutesEveryReleasePlatformTarget", + "command": "go test ./internal/tools/packagebuild -run '^TestWrapperScriptRoutesEveryReleasePlatformTarget$'" + } + ], + "commandIds": ["proofkit.go-test", "proofkit.package-artifact"], + "environmentClasses": ["local-go", "local-go-python"] + }, { "requirementId": "REQ-PROOFKIT-PACKAGE-004", "scenarioId": "proofkit.package-boundary.ci-receipt-anchor", @@ -3240,6 +3267,10 @@ "selector": "TestOnboardingTraceCoversEveryDiscoveredPresetAndREADMEInput", "command": "go test ./internal/tools/packageverify -run '^TestOnboardingTraceCoversEveryDiscoveredPresetAndREADMEInput$'" }, + { + "selector": "TestVerifyPackedOwnerRecordsRejectsSourceArtifactContentDrift", + "command": "go test ./internal/tools/packageverify -run '^TestVerifyPackedOwnerRecordsRejectsSourceArtifactContentDrift$'" + }, { "selector": "TestInstalledREADMEFirstInputUsesBoundedLiteralShellWords", "command": "go test ./internal/tools/packageverify -run '^TestInstalledREADMEFirstInputUsesBoundedLiteralShellWords$'" @@ -3262,6 +3293,21 @@ "local-go-python" ] }, + { + "requirementId": "REQ-PROOFKIT-QUALITY-019", + "scenarioId": "proofkit.supply-chain-quality.package-launcher-argv-forwarding", + "witnessId": "proofkit.packagebuild.quality-launcher-argv-forwarding", + "witnessKind": "contract", + "witnessPath": "internal/tools/packagebuild/main_test.go", + "witnessSelectors": [ + { + "selector": "TestWrapperScriptRoutesEveryReleasePlatformTarget", + "command": "go test ./internal/tools/packagebuild -run '^TestWrapperScriptRoutesEveryReleasePlatformTarget$'" + } + ], + "commandIds": ["proofkit.go-test", "proofkit.package-artifact"], + "environmentClasses": ["local-go", "local-go-python"] + }, { "requirementId": "REQ-PROOFKIT-QUALITY-019", "scenarioId": "proofkit.supply-chain-quality.registry-install-json-abi-evidence", @@ -5386,6 +5432,117 @@ ], "commandIds": ["proofkit.go-test"], "environmentClasses": ["local-go"] + }, + { + "requirementId": "REQ-PROOFKIT-SPEC-027", + "scenarioId": "proofkit.spec-proof-core.adoption-inventory-boundary", + "witnessId": "proofkit.repository-inventory.boundary-falsifier", + "witnessKind": "contract", + "witnessPath": "internal/command/repositoryinventory/repositoryinventory_test.go", + "witnessSelectors": [ + { + "selector": "TestScanProducesBoundedClosedInventory", + "command": "go test ./internal/command/repositoryinventory -run '^TestScanProducesBoundedClosedInventory$'" + }, + { + "selector": "TestScanRejectsRecognizedSymlinkWithoutReadingTarget", + "command": "go test ./internal/command/repositoryinventory -run '^TestScanRejectsRecognizedSymlinkWithoutReadingTarget$'" + }, + { + "selector": "TestScanEnforcesPreflightBoundsAndExplicitOmissions", + "command": "go test ./internal/command/repositoryinventory -run '^TestScanEnforcesPreflightBoundsAndExplicitOmissions$'" + }, + { + "selector": "TestScanPolicyBoundariesAreExact", + "command": "go test ./internal/command/repositoryinventory -run '^TestScanPolicyBoundariesAreExact$'" + }, + { + "selector": "TestInventoryIdentityBindsEverySemanticOperand", + "command": "go test ./internal/command/repositoryinventory -run '^TestInventoryIdentityBindsEverySemanticOperand$'" + }, + { + "selector": "TestCatalogRolePolicyIsExact", + "command": "go test ./internal/command/repositoryinventory -run '^TestCatalogRolePolicyIsExact$'" + }, + { + "selector": "TestReadRootInventoryClassifiesPartialBatchesWithoutRetainingUnknownNames", + "command": "go test ./internal/command/repositoryinventory -run '^TestReadRootInventoryClassifiesPartialBatchesWithoutRetainingUnknownNames$'" + } + ], + "commandIds": ["proofkit.go-test"], + "environmentClasses": ["local-go"] + }, + { + "requirementId": "REQ-PROOFKIT-SPEC-027", + "scenarioId": "proofkit.spec-proof-core.adoption-plan-authority-closure", + "witnessId": "proofkit.adoption-plan.authority-closure-falsifier", + "witnessKind": "contract", + "witnessPath": "internal/command/adoptionplan/adoptionplan_test.go", + "witnessSelectors": [ + { + "selector": "TestBuildSeparatesAdoptionIntentFromCandidateAuthority", + "command": "go test ./internal/command/adoptionplan -run '^TestBuildSeparatesAdoptionIntentFromCandidateAuthority$'" + }, + { + "selector": "TestBuildStackHintCannotChangeIntentTrustOrTasks", + "command": "go test ./internal/command/adoptionplan -run '^TestBuildStackHintCannotChangeIntentTrustOrTasks$'" + }, + { + "selector": "TestPlanWireAdmissionIsDeterministicAndOwnerClosed", + "command": "go test ./internal/command/adoptionplan -run '^TestPlanWireAdmissionIsDeterministicAndOwnerClosed$'" + } + ], + "commandIds": ["proofkit.go-test"], + "environmentClasses": ["local-go"] + }, + { + "requirementId": "REQ-PROOFKIT-SPEC-027", + "scenarioId": "proofkit.spec-proof-core.adoption-front-door-whole-cli", + "witnessId": "proofkit.adoption-front-door.whole-cli-falsifier", + "witnessKind": "contract", + "witnessPath": "internal/app/adoption_front_door_command_test.go", + "witnessSelectors": [ + { + "selector": "TestAdoptionFrontDoorCLI", + "command": "go test ./internal/app -run '^TestAdoptionFrontDoorCLI$'" + } + ], + "commandIds": ["proofkit.command-contract-check", "proofkit.go-test"], + "environmentClasses": ["local-go"] + }, + { + "requirementId": "REQ-PROOFKIT-SPEC-027", + "scenarioId": "proofkit.spec-proof-core.adoption-route-contract-closure", + "witnessId": "proofkit.adoption-front-door.route-contract-falsifier", + "witnessKind": "contract", + "witnessPath": "internal/tools/commandcontractgen/main_test.go", + "witnessSelectors": [ + { + "selector": "TestCommandRoutesAreBoundedSafeAndUnambiguous", + "command": "go test ./internal/tools/commandcontractgen -run '^TestCommandRoutesAreBoundedSafeAndUnambiguous$'" + } + ], + "commandIds": ["proofkit.command-contract-check", "proofkit.go-test"], + "environmentClasses": ["local-go"] + }, + { + "requirementId": "REQ-PROOFKIT-SPEC-027", + "scenarioId": "proofkit.spec-proof-core.adoption-version-edge-closure", + "witnessId": "proofkit.adoption-front-door.version-edge-falsifier", + "witnessKind": "contract", + "witnessPath": "internal/app/adoption_front_door_version_edge_test.go", + "witnessSelectors": [ + { + "selector": "TestAdoptionFrontDoorVersionEdgeClosesInitRetirement", + "command": "go test ./internal/app -run '^TestAdoptionFrontDoorVersionEdgeClosesInitRetirement$'" + }, + { + "selector": "TestRetiredInitRouteHasNoPublicDispatcher", + "command": "go test ./internal/app -run '^TestRetiredInitRouteHasNoPublicDispatcher$'" + } + ], + "commandIds": ["proofkit.go-test"], + "environmentClasses": ["local-go"] } ], "witnessCommands": [ diff --git a/release/change-record.v2.json b/release/change-record.v2.json index 82094fc..09b2e66 100644 --- a/release/change-record.v2.json +++ b/release/change-record.v2.json @@ -1,54 +1,52 @@ { "schemaVersion": 2, - "previousVersion": "0.5.1", - "version": "0.6.0", + "previousVersion": "0.6.0", + "version": "0.7.0", "changeClass": "breaking", "breakingChanges": [ { - "changeId": "proofkit.agent-route.brief-default", - "summary": "Change bare agent-route --agent-envelope output from the generic full envelope to the bounded proofkit.agent-route.brief packet." - }, + "changeId": "proofkit.adoption.init-retired", + "summary": "Remove the overloaded init command and its route presets in favor of the explicit read-only adopt plan trust-mode route and the existing bounded specialist commands." + } + ], + "additions": [ { - "changeId": "proofkit.agent-route.materialized-artifact-refs", - "summary": "Reject the stdin transport sentinel as an available-input or observed-report artifact identity; route references must name materialized caller-owned artifacts." + "changeId": "proofkit.adoption.front-door", + "summary": "Add adopt plan as a read-only candidate-authoring front door with explicit fresh, code-baseline, and audit-from-code intent plus an optional orthogonal stack hint." }, { - "changeId": "proofkit.agent-route.report-schema-v3", - "summary": "Advance the bare agent-route report to schemaVersion 3 while preserving route-family semantics and adding exact launcher-profile and pre-suppression available-command context." + "changeId": "proofkit.adoption.repository-inventory", + "summary": "Add a bounded explicit repository-inventory command that observes only a fixed root-file catalog without stack or source-semantic inference." }, { - "changeId": "proofkit.platform.macos-13", - "summary": "Raise published Darwin package compatibility to macOS 13.0 so wheel tags remain truthful for binaries built with Go 1.27.1." + "changeId": "proofkit.cli.generated-adapter-command-routes", + "summary": "Extend the generated TypeScript CLI adapter to admit one-to-four-token public command routes and pass each route token as a separate process argument while preserving one-token calls." }, { - "changeId": "proofkit.toolchain.npm-12", - "summary": "Require npm 12.0.2 for repository-owned contributor and release scripts and consume npm view and pack evidence using npm 12 wire forms." - } - ], - "additions": [ - { - "changeId": "proofkit.agent-route.envelope-detail-mode", - "summary": "Add --agent-envelope-mode brief|full so agents can request bounded default guidance or the complete generic envelope explicitly." + "changeId": "proofkit.cli.hierarchical-command-routes", + "summary": "Add owner-generated multi-token command routes while retaining stable internal command IDs for contract and implementation ownership." }, { - "changeId": "proofkit.toolchain.currentness", - "summary": "Update the verified build and CI baseline to Go 1.27.1 with Unicode 17.0.0, Node 26.8.1, npm 12.0.2, Python 3.14.7, and the current stable repository-owned dependency and action pins." + "changeId": "proofkit.python-wheel.embedded-cli-contract", + "summary": "Embed the exact public CLI contract in every Python wheel and use the installed record to prove command-family route closure." } ], "migration": { "required": true, "steps": [ - "Consumers that require the former generic agent-route envelope must add --agent-envelope-mode full after --agent-envelope; consumers that accept bounded route guidance may keep bare --agent-envelope.", - "Consumers of the bare agent-route report must admit schemaVersion 3; the prior route-family fields remain, and summary now binds launcherProfile and availableCommandCount.", - "Consumers that used the stdin transport sentinel as an agent-route availableInputs or observedReports ref must materialize that artifact and pass its safe repo-relative path instead.", - "Darwin consumers must use macOS 13.0 or later.", - "Contributors must use Go 1.27.1, Node 26.8.1, npm 12.0.2, and Python 3.14.7 for repository-owned build and release workflows." + "Replace explicit init --preset fresh with adopt plan --mode fresh --repo-root .", + "Replace init --preset code-baseline with adopt plan --mode code-baseline --repo-root , and replace init --preset code-audit with adopt plan --mode audit-from-code --repo-root .", + "Replace init --preset legacy with migration-parity-admission followed by migration-plan over explicit caller-owned records; run requirement-source-transition when the migration changes requirement lifecycle state.", + "Replace init --preset change-set with changed-path-set followed by the explicit impact and selective-gate composition routes required by the consuming repository.", + "Replace bare init or init --preset all with help families, then select the smallest applicable bounded route rather than materializing every route family.", + "Regenerate any materialized TypeScript CLI adapter source before invoking a multi-token route such as adopt plan; one-token adapter calls remain compatible." ] }, "platformRequirements": [ "Published Darwin package binaries require macOS 13.0 or later on arm64 and x86_64." ], "knownLimitations": [ + "Adopt plan inventories only a fixed root-file catalog; it does not infer stack identity, inspect arbitrary source semantics, generate requirements, write files, or execute native evidence.", "Agent workflow plans, prompts, text, and envelopes are derived guidance and do not execute agents, repository mutations, native witnesses, CI, release, rollout, or production operations.", "Brief agent-route packets cap pretty JSON at 3072 bytes and may defer oversized argv to explicit full detail; the bound does not claim tokenizer-specific token counts.", "Complete nested public structural contracts remain blocked under SCHEMA-01; current CLI contracts own exact root variants only.", From 196e498434459aa1099a6c6fa231eddfebe555e0 Mon Sep 17 00:00:00 2001 From: iperev Date: Fri, 4 Sep 2026 08:17:35 +0200 Subject: [PATCH 2/4] fix: close adoption proof boundaries --- internal/app/adoption_front_door_command.go | 6 +- .../app/adoption_front_door_command_test.go | 18 +++ .../adoption_front_door_version_edge_test.go | 13 +- internal/app/cli_contract_test.go | 15 ++- internal/app/command_contract_generated.go | 12 +- .../app/testdata/v0.7-wire-observations.json | 6 +- internal/command/adoptionplan/admission.go | 14 +- .../command/adoptionplan/adoptionplan_test.go | 125 ++++++++++++++++-- internal/command/adoptionplan/model.go | 32 ++++- internal/command/adoptionplan/text.go | 13 +- internal/command/repositoryinventory/model.go | 42 +++--- .../repositoryinventory_test.go | 39 ++++++ internal/command/repositoryinventory/scan.go | 6 + .../stackpreset/preset_ids_generated.go | 2 +- internal/tools/coveragemetrics/main.go | 66 ++++++++- .../tools/installedclicontract/contract.go | 76 +++++++++-- .../installedclicontract/contract_test.go | 64 +++++++++ internal/tools/packageverify/main.go | 23 +++- internal/tools/packageverify/main_test.go | 25 +++- .../tools/pythonpackage/continuation_test.go | 9 +- internal/tools/pythonpackage/verify.go | 75 +++++++++-- internal/tools/workflowsmoke/process.go | 44 +++++- .../tools/workflowsmoke/workflow_smoke.go | 2 +- .../workflowsmoke/workflow_smoke_test.go | 32 +++++ proofkit/cli-contract.v2.json | 12 +- proofkit/requirement-bindings.json | 89 +++++++++++++ 26 files changed, 738 insertions(+), 122 deletions(-) diff --git a/internal/app/adoption_front_door_command.go b/internal/app/adoption_front_door_command.go index 596491e..19a5288 100644 --- a/internal/app/adoption_front_door_command.go +++ b/internal/app/adoption_front_door_command.go @@ -90,8 +90,8 @@ func parseAdoptionFrontDoorArgs(command string, args []string) (adoptionFrontDoo } options.format = value case "--mode": - if value != adoptionplan.IntentFresh && value != adoptionplan.IntentCodeBaseline && value != adoptionplan.IntentAuditFromCode { - return adoptionFrontDoorArgs{}, fmt.Errorf("--mode requires one of: audit-from-code, code-baseline, fresh") + if !adoptionplan.IsIntent(value) { + return adoptionFrontDoorArgs{}, fmt.Errorf("--mode requires one of: %s", strings.Join(adoptionplan.IntentValues(), ", ")) } options.mode = value case "--stack": @@ -135,7 +135,7 @@ func missingAdoptionFrontDoorValue(flag string) error { case "--format": return fmt.Errorf("--format requires one of: json, text") case "--mode": - return fmt.Errorf("--mode requires one of: audit-from-code, code-baseline, fresh") + return fmt.Errorf("--mode requires one of: %s", strings.Join(adoptionplan.IntentValues(), ", ")) case "--repo-root": return fmt.Errorf("--repo-root requires a path") case "--stack": diff --git a/internal/app/adoption_front_door_command_test.go b/internal/app/adoption_front_door_command_test.go index 2d6e213..7b126c1 100644 --- a/internal/app/adoption_front_door_command_test.go +++ b/internal/app/adoption_front_door_command_test.go @@ -3,6 +3,7 @@ package app import ( "os" "path/filepath" + "slices" "strings" "testing" @@ -19,6 +20,13 @@ func TestAdoptionFrontDoorCLI(t *testing.T) { writeAdoptionFixture(t, repositoryRoot, "README.md", "# Pilot\n") writeAdoptionFixture(t, repositoryRoot, "pyproject.toml", "[project]\nname = \"pilot\"\n") writeAdoptionFixture(t, repositoryRoot, "private-notes.txt", "opaque\n") + descriptor, ok := commandDescriptorFor("adopt-plan") + if !ok { + t.Fatal("adopt-plan descriptor missing") + } + if !slices.Equal(descriptor.flagValueChoices["--mode"], adoptionplan.IntentValues()) { + t.Fatalf("adopt-plan mode choices = %v, want owner values %v", descriptor.flagValueChoices["--mode"], adoptionplan.IntentValues()) + } t.Run("public route and owner-closed JSON", func(t *testing.T) { for _, item := range []struct { @@ -121,8 +129,18 @@ func TestAdoptionFrontDoorCLI(t *testing.T) { args []string want string }{ + {args: []string{"adopt", "plan"}, want: "requires --mode"}, + {args: []string{"adopt", "plan", "--mode", "fresh"}, want: "requires --repo-root"}, + {args: []string{"adopt", "plan", "--repo-root", missingRoot}, want: "requires --mode"}, + {args: []string{"adopt", "plan", "--repo-root", missingRoot, "--mode"}, want: "--mode requires one of"}, + {args: []string{"adopt", "plan", "--mode", "fresh", "--repo-root"}, want: "--repo-root requires a path"}, {args: []string{"adopt", "plan", "--mode", "unknown", "--repo-root", missingRoot}, want: "--mode requires one of"}, {args: []string{"adopt", "plan", "--mode", "fresh", "--repo-root", missingRoot, "--stack", "unknown"}, want: "--stack requires one of"}, + {args: []string{"adopt", "plan", "--mode", "fresh", "--mode", "fresh", "--repo-root", missingRoot}, want: "--mode may be specified only once"}, + {args: []string{"adopt", "plan", "--mode", "fresh", "--repo-root", missingRoot, "--format", "yaml"}, want: "--format requires one of"}, + {args: []string{"adopt", "plan", "--mode", "fresh", "--repo-root", missingRoot, "--format", "text", "--color", "always"}, want: "--color requires one of"}, + {args: []string{"adopt", "plan", "--mode", "fresh", "--repo-root", missingRoot, "--unknown", "value"}, want: "unsupported argument"}, + {args: []string{"repository-inventory", "--repo-root", missingRoot, "--mode", "fresh"}, want: "unsupported argument"}, {args: []string{"adopt", "plan", "--mode", "fresh", "--repo-root", missingRoot, "--color", "never"}, want: "adopt plan --color requires --format text"}, {args: []string{"adopt-plan", "--mode", "fresh", "--repo-root", repositoryRoot}, want: "unsupported command: adopt-plan"}, } { diff --git a/internal/app/adoption_front_door_version_edge_test.go b/internal/app/adoption_front_door_version_edge_test.go index 8f16233..828712c 100644 --- a/internal/app/adoption_front_door_version_edge_test.go +++ b/internal/app/adoption_front_door_version_edge_test.go @@ -57,7 +57,8 @@ type adoptionChangedGeneratedArtifact struct { func TestAdoptionFrontDoorVersionEdgeClosesInitRetirement(t *testing.T) { record := readAdoptionFrontDoorVersionEdge(t) - if err := validateAdoptionFrontDoorVersionEdge(record, repoRoot(t)); err != nil { + currentPublicABI := "sha256:" + currentCLIContractPublicABISHA256(t) + if err := validateAdoptionFrontDoorVersionEdge(record, repoRoot(t), currentPublicABI); err != nil { t.Fatal(err) } @@ -87,7 +88,7 @@ func TestAdoptionFrontDoorVersionEdgeClosesInitRetirement(t *testing.T) { t.Run(fmt.Sprintf("mutant-%d", index), func(t *testing.T) { value := cloneAdoptionFrontDoorVersionEdge(record) mutate(&value) - if err := validateAdoptionFrontDoorVersionEdge(value, repoRoot(t)); err == nil { + if err := validateAdoptionFrontDoorVersionEdge(value, repoRoot(t), currentPublicABI); err == nil { t.Fatal("version-edge mutant was admitted") } }) @@ -153,14 +154,14 @@ func readAdoptionFrontDoorVersionEdge(t *testing.T) adoptionFrontDoorVersionEdge return record } -func validateAdoptionFrontDoorVersionEdge(record adoptionFrontDoorVersionEdge, root string) error { +func validateAdoptionFrontDoorVersionEdge(record adoptionFrontDoorVersionEdge, root string, currentPublicABI string) error { if record.SchemaVersion != 1 || record.EdgeID != "proofkit.public-wire.0.6.0-to-0.7.0" || record.EvidenceClass != "owner_authored_frozen_version_edge_observation" { return fmt.Errorf("adoption front-door version-edge identity is invalid") } if record.PreviousVersion != "0.6.0" || record.Version != "0.7.0" { return fmt.Errorf("adoption front-door version-edge release identity is stale") } - if record.PreviousPublicABISHA256 != "sha256:163f06bf6fc94f15040fecf3e352d4600a8611a227e26f35369b7fe97e90bde5" || record.CurrentPublicABISHA256 != "sha256:7b36077db9c82ca005606f571e1f6c6e208ab5b35b95b4d9b885e6047f091b91" || record.PreviousPublicABISHA256 == record.CurrentPublicABISHA256 { + if record.PreviousPublicABISHA256 != "sha256:163f06bf6fc94f15040fecf3e352d4600a8611a227e26f35369b7fe97e90bde5" || record.CurrentPublicABISHA256 != currentPublicABI || record.PreviousPublicABISHA256 == record.CurrentPublicABISHA256 { return fmt.Errorf("adoption front-door version-edge ABI identity is invalid") } wantRemoved := adoptionRemovedCommandContract{Command: "init", DefaultInvocationPreset: "all", OutputContractSHA256: "sha256:3e59a3002327c759e5e747f8baacaa63a4d6784e1a1c520f0a54e01af3f2faa0"} @@ -168,8 +169,8 @@ func validateAdoptionFrontDoorVersionEdge(record adoptionFrontDoorVersionEdge, r return fmt.Errorf("adoption front-door removed command contract is not exact") } wantAdded := []adoptionFrontDoorCommandContract{ - {Command: "adopt-plan", Route: []string{"adopt", "plan"}, OutputContractSHA256: "sha256:55f0852120ca978b9557553b1af8923a4d0b08c4dc794ba7751cb28d2414b7ec"}, - {Command: "repository-inventory", Route: []string{"repository-inventory"}, OutputContractSHA256: "sha256:5b0967fefb4abbfbc91027ff30ed6579c12e1f27d8310466cd4fd4d1f865552e"}, + {Command: "adopt-plan", Route: []string{"adopt", "plan"}, OutputContractSHA256: generatedCommandContractMetadataByName["adopt-plan"].OutputContractSHA256}, + {Command: "repository-inventory", Route: []string{"repository-inventory"}, OutputContractSHA256: generatedCommandContractMetadataByName["repository-inventory"].OutputContractSHA256}, } if !slices.EqualFunc(record.AddedCommandContracts, wantAdded, equalAdoptionCommandContract) { return fmt.Errorf("adoption front-door added command contracts are not exact") diff --git a/internal/app/cli_contract_test.go b/internal/app/cli_contract_test.go index 99a5369..afdb5a6 100644 --- a/internal/app/cli_contract_test.go +++ b/internal/app/cli_contract_test.go @@ -23,7 +23,7 @@ import ( ) const ( - cliContractPublicABISHA256 = "7b36077db9c82ca005606f571e1f6c6e208ab5b35b95b4d9b885e6047f091b91" + cliContractPublicABISHA256 = "5a238b80bfdbe22f0e1b76850dab9568c89faf303e4653bf017cdf9535f3d7ae" maxAggregateFileReadBytesForContractTest = 64 << 20 maxPackageManifestBytesForContractTest = 256 << 10 maxSourceFileBytesForContractTest = 8 << 20 @@ -798,6 +798,14 @@ func TestProofkitContractMapRoutesRequiredInputCommands(t *testing.T) { } func TestCLIContractPublicABIGoldenStable(t *testing.T) { + got := currentCLIContractPublicABISHA256(t) + if got != cliContractPublicABISHA256 { + t.Fatalf("public CLI ABI hash drifted: got %s want %s", got, cliContractPublicABISHA256) + } +} + +func currentCLIContractPublicABISHA256(t *testing.T) string { + t.Helper() contract := readCLIContract(t) definitions := cliContractDefinitionMap(t, contract.ContractDefinitions) commands := []any{} @@ -849,10 +857,7 @@ func TestCLIContractPublicABIGoldenStable(t *testing.T) { t.Fatalf("marshal CLI ABI projection: %v", err) } sum := sha256.Sum256(encoded) - got := fmt.Sprintf("%x", sum[:]) - if got != cliContractPublicABISHA256 { - t.Fatalf("public CLI ABI hash drifted: got %s want %s", got, cliContractPublicABISHA256) - } + return fmt.Sprintf("%x", sum[:]) } func cliContractDefinitionMap(t *testing.T, raw []any) map[string]map[string]any { diff --git a/internal/app/command_contract_generated.go b/internal/app/command_contract_generated.go index 5fc3d87..dd9d898 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 = "859296d6fcce46b0d91d56631b49a1978191479e49fafd8d2ccde8f62fda4e76" +const commandContractSourceSHA256 = "c7de8b5f737b7616095b83cd19204761b2e188f17e5f461ede10c3f1d6095447" type generatedCommandContractMetadata struct { InputContractSHA256 string @@ -12,12 +12,12 @@ type generatedCommandContractMetadata struct { } var generatedCommandContractMetadataByName = map[string]generatedCommandContractMetadata{ - "adopt-plan": {InputContractSHA256: "", InputSchemaSummary: []string(nil), OutputContractSHA256: "sha256:55f0852120ca978b9557553b1af8923a4d0b08c4dc794ba7751cb28d2414b7ec", FlagChoices: map[string][]string{"--color": []string{"auto", "never"}, "--format": []string{"json", "text"}, "--mode": []string{"audit-from-code", "code-baseline", "fresh"}, "--stack": []string{"agentic_runtime_repo", "generated_docs_contract_repo", "python_service", "python_typescript_service", "typescript_monorepo", "typescript_workspace"}}, RouteTokens: []string{"adopt", "plan"}}, + "adopt-plan": {InputContractSHA256: "", InputSchemaSummary: []string(nil), OutputContractSHA256: "sha256:16583d956455cc360a13382e0d783e5590a2a19adedbb4bf9fc7b6dd10ee35b8", FlagChoices: map[string][]string{"--color": []string{"auto", "never"}, "--format": []string{"json", "text"}, "--mode": []string{"audit-from-code", "code-baseline", "fresh"}, "--stack": []string{"agentic_runtime_repo", "generated_docs_contract_repo", "python_service", "python_typescript_service", "typescript_monorepo", "typescript_workspace"}}, RouteTokens: []string{"adopt", "plan"}}, "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{}, RouteTokens: []string{"adoption-checklist"}}, "adoption-contract-envelope": {InputContractSHA256: "sha256:c310214676ff4b6f536a5bc9d687f681a7e71f73d7a03ac932707d8cd3905cdf", InputSchemaSummary: []string{"schemaVersion=2", "root-shape-only definition proofkit.adoption-contract-envelope.input.v2.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:3efb2c5161fee16fd8ac6a40dcb6d9c41fbc23e468f60621436ae9e8076e0950", FlagChoices: map[string][]string{}, RouteTokens: []string{"adoption-contract-envelope"}}, "adoption-doctor": {InputContractSHA256: "sha256:efa9acfe32bff07f56d9dc9902530df2979794289bc2f7f547f7a108a7dd0f35", 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:8fdfc6608f197e633f042f20031ae1014872a90aa3daa66885ffcaddca994766", FlagChoices: map[string][]string{}, RouteTokens: []string{"adoption-doctor"}}, "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{}, RouteTokens: []string{"adoption-workflow-plan"}}, - "agent-route": {InputContractSHA256: "sha256:4fc7b2e5ffe3ed632e5e84d20e5ae26f9ace11df614bc9aec680853e60809ebd", 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:9e9492ff60a5d870fca9b76a7bff98c4ad8767928fd86e31d8d2db006f511352", FlagChoices: map[string][]string{}, RouteTokens: []string{"agent-route"}}, + "agent-route": {InputContractSHA256: "sha256:4fc7b2e5ffe3ed632e5e84d20e5ae26f9ace11df614bc9aec680853e60809ebd", 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:1ba71e0f314c7e592d8a57e23dbb12b5d95c17a8fa9caa20d05a56b87b814963", FlagChoices: map[string][]string{}, RouteTokens: []string{"agent-route"}}, "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{}, RouteTokens: []string{"binding-partition"}}, "branch-authority": {InputContractSHA256: "sha256:8a3ed74978898593fbdbf1f7fa684dae450fbd9019edcd60d07f818d63363ed4", InputSchemaSummary: []string{"schemaVersion=1", "root-shape-only definition proofkit.branch-authority.input.v1.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:3c7dc74842299b92cd5baf57cc8666e9415963091359e5faf654e28da89561f1", FlagChoices: map[string][]string{}, RouteTokens: []string{"branch-authority"}}, "capability-map-admission": {InputContractSHA256: "sha256:e49433f295c43c34d5d660ac9d656b117ed87208406b57723d25165ffec5d486", InputSchemaSummary: []string{"schemaVersion=1", "root-shape-only definition proofkit.capability-map-admission.input.v1.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:bfa35fe1be210ab98f3620694ab63b52a9724f7b92cd9dcbfd7b01b2c6a3555e", FlagChoices: map[string][]string{}, RouteTokens: []string{"capability-map-admission"}}, @@ -41,7 +41,7 @@ var generatedCommandContractMetadataByName = map[string]generatedCommandContract "native-evidence-guidance": {InputContractSHA256: "", InputSchemaSummary: []string(nil), OutputContractSHA256: "sha256:c9306d800668ecea18aaced6a21334036a935570f267baed356e6a4888025c8d", FlagChoices: map[string][]string{"--color": []string{"auto", "never"}, "--format": []string{"json", "text"}}, RouteTokens: []string{"native-evidence-guidance"}}, "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{}, RouteTokens: []string{"obligation-decision"}}, "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{}, RouteTokens: []string{"package-runtime-dependency-admission"}}, - "pilot-admission": {InputContractSHA256: "sha256:a1d9116ce619f7d705349ff4ae44c0f4399a281ebaa9e7d62ea304ac57af59ba", InputSchemaSummary: []string{"schemaVersion=2", "root-shape-only definition proofkit.pilot-admission.input.v2.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:34acb96baa4411d2d1de3a44938a2ca06b4d8b272d508ae181799e6cddec0633", FlagChoices: map[string][]string{}, RouteTokens: []string{"pilot-admission"}}, + "pilot-admission": {InputContractSHA256: "sha256:a1d9116ce619f7d705349ff4ae44c0f4399a281ebaa9e7d62ea304ac57af59ba", InputSchemaSummary: []string{"schemaVersion=2", "root-shape-only definition proofkit.pilot-admission.input.v2.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:4342a1deb3aa49cf000658c2bc3d172cf610209673b1dac4d1f05685b0a18865", FlagChoices: map[string][]string{}, RouteTokens: []string{"pilot-admission"}}, "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{}, RouteTokens: []string{"producer-policy-self-proof"}}, "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{}, RouteTokens: []string{"proof-obligation-algebra"}}, "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{}, RouteTokens: []string{"proof-receipt-admission"}}, @@ -55,7 +55,7 @@ var generatedCommandContractMetadataByName = map[string]generatedCommandContract "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{}, RouteTokens: []string{"release-authority"}}, "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{}, RouteTokens: []string{"rendered-artifact-freshness"}}, "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{}, RouteTokens: []string{"repo-profile-admission"}}, - "repository-inventory": {InputContractSHA256: "", InputSchemaSummary: []string(nil), OutputContractSHA256: "sha256:5b0967fefb4abbfbc91027ff30ed6579c12e1f27d8310466cd4fd4d1f865552e", FlagChoices: map[string][]string{}, RouteTokens: []string{"repository-inventory"}}, + "repository-inventory": {InputContractSHA256: "", InputSchemaSummary: []string(nil), OutputContractSHA256: "sha256:474ac75c2ccaf66d248f4e96df51404d9a0e6c2341aa8278e91207f76b4c12d6", FlagChoices: map[string][]string{}, RouteTokens: []string{"repository-inventory"}}, "requirement-authoring-plan": {InputContractSHA256: "sha256:208d7d47109dee1ec355ae3970937690ae528a9cc0cb0eb885d7cc72d843f1e8", 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:e995d289c4a44310add784bbafa5a3a50ec305c3809a89506dd3b49914fbe28f", FlagChoices: map[string][]string{}, RouteTokens: []string{"requirement-authoring-plan"}}, "requirement-bindings": {InputContractSHA256: "sha256:4771b7ed1e23b20c983060deb8f8e65391052f0e5a61cf0f5c67c0e73b8fc5dd", 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:7821c7b23ff2c0ca83c64039c22400d90660cad73a60b9afb46829c539c61168", FlagChoices: map[string][]string{}, RouteTokens: []string{"requirement-bindings"}}, "requirement-browser-server": {InputContractSHA256: "sha256:acb36856dc08ae8efa68986bb4c419f480951b1ad9ee9a43fc1d6c53d30ee71b", 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.v3.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:16e741d88e5ede4271c5e769c724e72164fcba461f0c6c17d285f318f8e03005", FlagChoices: map[string][]string{}, RouteTokens: []string{"requirement-browser-server"}}, @@ -80,7 +80,7 @@ var generatedCommandContractMetadataByName = map[string]generatedCommandContract "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{}, RouteTokens: []string{"selective-gate-evidence"}}, "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{}, RouteTokens: []string{"selective-gate-obligation-decision-input"}}, "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{}, RouteTokens: []string{"selective-gate-plan"}}, - "self-check": {InputContractSHA256: "sha256:e44ed7f6b80d57e5ae1456a8eb7bd1ca5c4f7a03e761fd7190328a7518367aff", 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:34418e2007b0d08011155e3b1d6ec27fbfc255f206a8767cc6ae52c6e83ba0ae", FlagChoices: map[string][]string{}, RouteTokens: []string{"self-check"}}, + "self-check": {InputContractSHA256: "sha256:ae0ea8d425320045513c4b7ba17ad4faad5834574624927b59e4a737a7def5ab", 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:34a8a735482395789490659c81d95d74cfe6cabf426e2a9d6248896f2f76d82e", FlagChoices: map[string][]string{}, RouteTokens: []string{"self-check"}}, "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{}, RouteTokens: []string{"spec-overview-claims"}}, "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{}, RouteTokens: []string{"spec-proof-bundle-admission"}}, "stack-preset": {InputContractSHA256: "", InputSchemaSummary: []string(nil), OutputContractSHA256: "sha256:ef5920f363a4a96dcac308ea8412260a06e64ba4876460a369aefb8983130a9d", FlagChoices: map[string][]string{"--preset": []string{"agentic_runtime_repo", "generated_docs_contract_repo", "python_service", "python_typescript_service", "typescript_monorepo", "typescript_workspace"}}, RouteTokens: []string{"stack-preset"}}, diff --git a/internal/app/testdata/v0.7-wire-observations.json b/internal/app/testdata/v0.7-wire-observations.json index 3ed2bc4..2cd9691 100644 --- a/internal/app/testdata/v0.7-wire-observations.json +++ b/internal/app/testdata/v0.7-wire-observations.json @@ -15,7 +15,7 @@ } ], "previousPublicAbiSha256": "sha256:163f06bf6fc94f15040fecf3e352d4600a8611a227e26f35369b7fe97e90bde5", - "currentPublicAbiSha256": "sha256:7b36077db9c82ca005606f571e1f6c6e208ab5b35b95b4d9b885e6047f091b91", + "currentPublicAbiSha256": "sha256:5a238b80bfdbe22f0e1b76850dab9568c89faf303e4653bf017cdf9535f3d7ae", "removedCommandContract": { "command": "init", "defaultInvocationPreset": "all", @@ -25,12 +25,12 @@ { "command": "adopt-plan", "route": ["adopt", "plan"], - "outputContractSha256": "sha256:55f0852120ca978b9557553b1af8923a4d0b08c4dc794ba7751cb28d2414b7ec" + "outputContractSha256": "sha256:16583d956455cc360a13382e0d783e5590a2a19adedbb4bf9fc7b6dd10ee35b8" }, { "command": "repository-inventory", "route": ["repository-inventory"], - "outputContractSha256": "sha256:5b0967fefb4abbfbc91027ff30ed6579c12e1f27d8310466cd4fd4d1f865552e" + "outputContractSha256": "sha256:474ac75c2ccaf66d248f4e96df51404d9a0e6c2341aa8278e91207f76b4c12d6" } ], "breakingChangeIds": [ diff --git a/internal/command/adoptionplan/admission.go b/internal/command/adoptionplan/admission.go index 2eb620e..e351b92 100644 --- a/internal/command/adoptionplan/admission.go +++ b/internal/command/adoptionplan/admission.go @@ -10,11 +10,13 @@ import ( "github.com/research-engineering/agentic-proofkit/internal/kernel/stablejson" ) -var intentValues = map[string]struct{}{ - IntentAuditFromCode: {}, - IntentCodeBaseline: {}, - IntentFresh: {}, -} +var intentValueSet = func() map[string]struct{} { + values := make(map[string]struct{}, len(intentValues)) + for _, value := range intentValues { + values[value] = struct{}{} + } + return values +}() // AdmitOutput replays the plan's semantic owners and requires byte-canonical // equality with their deterministic projection. @@ -23,7 +25,7 @@ func AdmitOutput(raw any) (Plan, error) { if !ok { return Plan{}, fmt.Errorf("adoption plan must be an object") } - intent, err := admit.Enum(record["intent"], intentValues, "adoption plan intent") + intent, err := admit.Enum(record["intent"], intentValueSet, "adoption plan intent") if err != nil { return Plan{}, err } diff --git a/internal/command/adoptionplan/adoptionplan_test.go b/internal/command/adoptionplan/adoptionplan_test.go index a869556..7a8d2a7 100644 --- a/internal/command/adoptionplan/adoptionplan_test.go +++ b/internal/command/adoptionplan/adoptionplan_test.go @@ -6,11 +6,13 @@ import ( "os" "path/filepath" "reflect" + "slices" "strings" "testing" "github.com/research-engineering/agentic-proofkit/internal/command/capabilitymapadmission" "github.com/research-engineering/agentic-proofkit/internal/command/repositoryinventory" + "github.com/research-engineering/agentic-proofkit/internal/command/stackpreset" "github.com/research-engineering/agentic-proofkit/internal/kernel/admission" "github.com/research-engineering/agentic-proofkit/internal/kernel/stablejson" "github.com/research-engineering/agentic-proofkit/internal/testsupport/commandcoverage" @@ -65,21 +67,56 @@ func TestBuildStackHintCannotChangeIntentTrustOrTasks(t *testing.T) { if err != nil { t.Fatalf("Build(without stack) error = %v", err) } - withStack, err := Build(IntentAuditFromCode, inventory, "python_typescript_service") + seenPlanIDs := map[string]string{withoutStack.PlanID: "none"} + for _, presetID := range stackpreset.IDs() { + withStack, err := Build(IntentAuditFromCode, inventory, presetID) + if err != nil { + t.Fatalf("Build(%s) error = %v", presetID, err) + } + if withStack.StackHint == nil || withStack.StackHint.PresetID != presetID { + t.Fatalf("stack hint = %#v, want %s", withStack.StackHint, presetID) + } + if withoutStack.Intent != withStack.Intent || !reflect.DeepEqual(withoutStack.TrustDeclaration, withStack.TrustDeclaration) || !reflect.DeepEqual(withoutStack.Packet.Tasks, withStack.Packet.Tasks) { + t.Fatalf("stack %s changed authority semantics:\nwithout=%#v\nwith=%#v", presetID, withoutStack, withStack) + } + if prior, duplicate := seenPlanIDs[withStack.PlanID]; duplicate { + t.Fatalf("stack %s shares plan identity with %s", presetID, prior) + } + seenPlanIDs[withStack.PlanID] = presetID + if withoutStack.Inventory.InventoryID != withStack.Inventory.InventoryID { + t.Fatalf("stack %s changed repository inventory", presetID) + } + } +} + +func TestPlanIdentityBindsIntentAndInventory(t *testing.T) { + inventory := adoptionInventory(t) + fresh, err := Build(IntentFresh, inventory, "") if err != nil { - t.Fatalf("Build(with stack) error = %v", err) + t.Fatalf("Build(fresh) error = %v", err) } - if withStack.StackHint == nil || withStack.StackHint.PresetID != "python_typescript_service" { - t.Fatalf("stack hint = %#v, want selected preset", withStack.StackHint) + baseline, err := Build(IntentCodeBaseline, inventory, "") + if err != nil { + t.Fatalf("Build(baseline) error = %v", err) } - if withoutStack.Intent != withStack.Intent || !reflect.DeepEqual(withoutStack.TrustDeclaration, withStack.TrustDeclaration) || !reflect.DeepEqual(withoutStack.Packet.Tasks, withStack.Packet.Tasks) { - t.Fatalf("stack selection changed authority semantics:\nwithout=%#v\nwith=%#v", withoutStack, withStack) + if fresh.PlanID == baseline.PlanID { + t.Fatal("plan identity did not bind source-trust intent") } - if withoutStack.PlanID == withStack.PlanID { - t.Fatal("plan identity did not bind stack selection") + + changedRoot := t.TempDir() + if err := os.WriteFile(filepath.Join(changedRoot, "README.md"), []byte("# Changed\n"), 0o600); err != nil { + t.Fatalf("WriteFile() error = %v", err) } - if withoutStack.Inventory.InventoryID != withStack.Inventory.InventoryID { - t.Fatal("stack selection changed repository inventory") + changedInventory, err := repositoryinventory.Scan(context.Background(), changedRoot) + if err != nil { + t.Fatalf("repositoryinventory.Scan() error = %v", err) + } + changed, err := Build(IntentFresh, changedInventory, "") + if err != nil { + t.Fatalf("Build(changed inventory) error = %v", err) + } + if fresh.PlanID == changed.PlanID { + t.Fatal("plan identity did not bind repository inventory") } } @@ -157,9 +194,9 @@ func TestTextProjectionPreservesJSONPlanSemantics(t *testing.T) { t.Fatalf("text projection omits task %q", task.TaskID) } } - for _, nonClaim := range boundaryNonClaims { - if !strings.Contains(text, nonClaim) { - t.Fatalf("text projection omits non-claim %q", nonClaim) + for _, nonClaim := range collectAuthorityNonClaims(plan.JSONValue()) { + if strings.Count(text, nonClaim) != 1 { + t.Fatalf("text projection must contain non-claim exactly once %q", nonClaim) } } if len(text) > MaximumTextBytes || strings.Count(text, "\n") > MaximumTextLines { @@ -167,6 +204,68 @@ func TestTextProjectionPreservesJSONPlanSemantics(t *testing.T) { } } +func TestAdoptionPlanOutputAndTextBoundsAreExact(t *testing.T) { + plan, err := Build(IntentFresh, adoptionInventory(t), "") + if err != nil { + t.Fatalf("Build() error = %v", err) + } + encoded, err := stablejson.Marshal(plan.JSONValue()) + if err != nil { + t.Fatalf("stablejson.Marshal() error = %v", err) + } + if err := validateOutputByteLimit(plan, len(encoded)); err != nil { + t.Fatalf("exact plan output byte limit rejected: %v", err) + } + if err := validateOutputByteLimit(plan, len(encoded)-1); err == nil { + t.Fatal("one-over plan output survived the byte limit") + } + + exactByteLines := []TextLine{{Label: "L", Value: strings.Repeat("x", MaximumTextBytes-4)}} + if text, err := renderTextWithinLimits(exactByteLines, MaximumTextBytes, MaximumTextLines); err != nil || len(text) != MaximumTextBytes { + t.Fatalf("exact text byte limit: bytes=%d error=%v", len(text), err) + } + overByteLines := []TextLine{{Label: "L", Value: strings.Repeat("x", MaximumTextBytes-3)}} + if _, err := renderTextWithinLimits(overByteLines, MaximumTextBytes, MaximumTextLines); err == nil { + t.Fatal("one-over text survived the byte limit") + } + exactLineCount := make([]TextLine, MaximumTextLines) + for index := range exactLineCount { + exactLineCount[index] = TextLine{Label: "L"} + } + if _, err := renderTextWithinLimits(exactLineCount, MaximumTextBytes, MaximumTextLines); err != nil { + t.Fatalf("exact text line limit rejected: %v", err) + } + if _, err := renderTextWithinLimits(append(exactLineCount, TextLine{Label: "L"}), MaximumTextBytes, MaximumTextLines); err == nil { + t.Fatal("one-over text survived the line limit") + } +} + +func collectAuthorityNonClaims(value any) []string { + result := []string{} + var visit func(any) + visit = func(current any) { + switch typed := current.(type) { + case map[string]any: + for key, child := range typed { + if key == "nonClaims" { + for _, raw := range child.([]any) { + result = append(result, raw.(string)) + } + continue + } + visit(child) + } + case []any: + for _, child := range typed { + visit(child) + } + } + } + visit(value) + slices.Sort(result) + return result +} + func adoptionInventory(t *testing.T) repositoryinventory.Snapshot { t.Helper() root := t.TempDir() diff --git a/internal/command/adoptionplan/model.go b/internal/command/adoptionplan/model.go index 9420611..24de492 100644 --- a/internal/command/adoptionplan/model.go +++ b/internal/command/adoptionplan/model.go @@ -35,6 +35,23 @@ var boundaryNonClaims = []string{ "Stack hints are optional non-authoritative suggestions and cannot change the selected source-trust intent.", } +var intentValues = []string{IntentAuditFromCode, IntentCodeBaseline, IntentFresh} + +// IntentValues returns the closed public source-trust vocabulary in canonical +// order. CLI adapters consume this owner rather than repeating mode literals. +func IntentValues() []string { + return append([]string(nil), intentValues...) +} + +func IsIntent(value string) bool { + for _, intent := range intentValues { + if value == intent { + return true + } + } + return false +} + type TrustDeclaration struct { CapabilityMapTrustMode *string Class string @@ -148,12 +165,19 @@ func finalize(plan Plan) (Plan, error) { return Plan{}, err } plan.PlanID = planID + if err := validateOutputByteLimit(plan, MaximumOutputBytes); err != nil { + return Plan{}, err + } + return plan, nil +} + +func validateOutputByteLimit(plan Plan, maximum int) error { encoded, err := stablejson.Marshal(plan.JSONValue()) if err != nil { - return Plan{}, err + return err } - if len(encoded) > MaximumOutputBytes { - return Plan{}, fmt.Errorf("adoption plan exceeds output byte limit") + if len(encoded) > maximum { + return fmt.Errorf("adoption plan exceeds output byte limit") } - return plan, nil + return nil } diff --git a/internal/command/adoptionplan/text.go b/internal/command/adoptionplan/text.go index f0b5b6a..3e25057 100644 --- a/internal/command/adoptionplan/text.go +++ b/internal/command/adoptionplan/text.go @@ -3,6 +3,8 @@ package adoptionplan import ( "fmt" "strings" + + "github.com/research-engineering/agentic-proofkit/internal/command/repositoryinventory" ) // TextProjection derives bounded human coordinates from the same typed plan as @@ -30,6 +32,9 @@ func TextProjection(plan Plan) ([]TextLine, error) { for _, nonClaim := range boundaryNonClaims { lines = append(lines, TextLine{Label: "Non-claim", Value: nonClaim}) } + for _, nonClaim := range repositoryinventory.NonClaims() { + lines = append(lines, TextLine{Label: "Inventory non-claim", Value: nonClaim}) + } if len(lines) > MaximumTextLines { return nil, fmt.Errorf("adoption plan text exceeds line limit") } @@ -37,7 +42,11 @@ func TextProjection(plan Plan) ([]TextLine, error) { } func RenderText(lines []TextLine) (string, error) { - if len(lines) > MaximumTextLines { + return renderTextWithinLimits(lines, MaximumTextBytes, MaximumTextLines) +} + +func renderTextWithinLimits(lines []TextLine, maximumBytes, maximumLines int) (string, error) { + if len(lines) > maximumLines { return "", fmt.Errorf("adoption plan text exceeds line limit") } plain := make([]string, len(lines)) @@ -51,7 +60,7 @@ func RenderText(lines []TextLine) (string, error) { } } text := strings.Join(plain, "\n") + "\n" - if len(text) > MaximumTextBytes { + if len(text) > maximumBytes { return "", fmt.Errorf("adoption plan text exceeds byte limit") } return text, nil diff --git a/internal/command/repositoryinventory/model.go b/internal/command/repositoryinventory/model.go index 27634f9..81c88af 100644 --- a/internal/command/repositoryinventory/model.go +++ b/internal/command/repositoryinventory/model.go @@ -101,6 +101,13 @@ func CatalogPaths() []string { return paths } +// NonClaims returns a copy of the inventory authority boundary for parent +// presentation projections. The repository inventory remains the sole owner +// of these statements. +func NonClaims() []string { + return append([]string(nil), boundaryNonClaims...) +} + func CatalogRole(path string) (string, bool) { index, found := slices.BinarySearchFunc(rootCatalog[:], path, func(item catalogItem, target string) int { switch { @@ -158,23 +165,9 @@ func entryValue(entry Entry) map[string]any { } func identityValue(snapshot Snapshot) map[string]any { - entries := make([]any, 0, len(snapshot.Entries)) - for _, entry := range snapshot.Entries { - entries = append(entries, entryValue(entry)) - } - omitted := make([]any, 0, len(snapshot.Omissions.OmittedRecognized)) - for _, entry := range snapshot.Omissions.OmittedRecognized { - omitted = append(omitted, map[string]any{"path": entry.Path, "reason": entry.Reason}) - } - return map[string]any{ - "entries": entries, - "omissions": map[string]any{ - "omittedRecognized": omitted, - "rootEntryCount": json.Number(fmt.Sprintf("%d", snapshot.Omissions.RootEntryCount)), - "unrecognizedCount": json.Number(fmt.Sprintf("%d", snapshot.Omissions.UnrecognizedCount)), - }, - "policyId": PolicyID, - } + identity := snapshot.JSONValue() + delete(identity, "inventoryId") + return identity } func finalize(snapshot Snapshot) (Snapshot, error) { @@ -183,12 +176,19 @@ func finalize(snapshot Snapshot) (Snapshot, error) { return Snapshot{}, err } snapshot.InventoryID = id + if err := validateOutputByteLimit(snapshot, MaximumOutputBytes); err != nil { + return Snapshot{}, err + } + return snapshot, nil +} + +func validateOutputByteLimit(snapshot Snapshot, maximum int) error { encoded, err := stablejson.Marshal(snapshot.JSONValue()) if err != nil { - return Snapshot{}, err + return err } - if len(encoded) > MaximumOutputBytes { - return Snapshot{}, fmt.Errorf("repository inventory exceeds output byte limit") + if len(encoded) > maximum { + return fmt.Errorf("repository inventory exceeds output byte limit") } - return snapshot, nil + return nil } diff --git a/internal/command/repositoryinventory/repositoryinventory_test.go b/internal/command/repositoryinventory/repositoryinventory_test.go index 6aac48b..923b31f 100644 --- a/internal/command/repositoryinventory/repositoryinventory_test.go +++ b/internal/command/repositoryinventory/repositoryinventory_test.go @@ -436,6 +436,33 @@ func TestInventoryIdentityBindsEverySemanticOperand(t *testing.T) { } }) } + identity := identityValue(base) + assertExactKeys(t, identity, []string{"entries", "inventoryKind", "nonClaims", "omissions", "policyId", "schemaVersion", "scope"}) + identityBytes, err := stablejson.Marshal(identity) + if err != nil { + t.Fatalf("marshal identity value: %v", err) + } + wantID := digest.SHA256BytesRef(identityBytes) + if base.InventoryID != wantID { + t.Fatalf("inventory identity = %q, want full-record identity %q", base.InventoryID, wantID) + } +} + +func TestInventoryOutputByteLimitIsExact(t *testing.T) { + snapshot, err := finalize(Snapshot{}) + if err != nil { + t.Fatalf("finalize() error = %v", err) + } + encoded, err := stablejson.Marshal(snapshot.JSONValue()) + if err != nil { + t.Fatalf("stablejson.Marshal() error = %v", err) + } + if err := validateOutputByteLimit(snapshot, len(encoded)); err != nil { + t.Fatalf("exact output byte limit rejected: %v", err) + } + if err := validateOutputByteLimit(snapshot, len(encoded)-1); err == nil { + t.Fatal("one-over output survived the byte limit") + } } func TestScanHonorsCancellationBeforeFilesystemAccess(t *testing.T) { @@ -505,6 +532,18 @@ func TestAdmitOutputRejectsIdentityAndPartitionDrift(t *testing.T) { }) } +func assertExactKeys(t *testing.T, record map[string]any, want []string) { + t.Helper() + got := make([]string, 0, len(record)) + for key := range record { + got = append(got, key) + } + slices.Sort(got) + if !slices.Equal(got, want) { + t.Fatalf("record keys = %v, want %v", got, want) + } +} + func writeInventoryFixture(t *testing.T, root, name string, content []byte) { t.Helper() if err := os.WriteFile(filepath.Join(root, name), content, 0o600); err != nil { diff --git a/internal/command/repositoryinventory/scan.go b/internal/command/repositoryinventory/scan.go index 64df6dd..3719ec0 100644 --- a/internal/command/repositoryinventory/scan.go +++ b/internal/command/repositoryinventory/scan.go @@ -115,6 +115,9 @@ func scanRoot(ctx context.Context, root *os.Root, policy scanPolicy) (Snapshot, if err != nil { return Snapshot{}, err } + if err := ctx.Err(); err != nil { + return Snapshot{}, err + } actualAggregateBytes += int64(len(content)) if !utf8.Valid(content) || bytes.IndexByte(content, 0) >= 0 { omissions.OmittedRecognized = append(omissions.OmittedRecognized, OmittedRecognizedEntry{Path: item.item.Path, Reason: OmissionNonText}) @@ -131,6 +134,9 @@ func scanRoot(ctx context.Context, root *os.Root, policy scanPolicy) (Snapshot, sort.Slice(omissions.OmittedRecognized, func(left, right int) bool { return omissions.OmittedRecognized[left].Path < omissions.OmittedRecognized[right].Path }) + if err := ctx.Err(); err != nil { + return Snapshot{}, err + } return finalize(Snapshot{Entries: observed, Omissions: omissions}) } diff --git a/internal/command/stackpreset/preset_ids_generated.go b/internal/command/stackpreset/preset_ids_generated.go index 219a9b0..cf1e8a3 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 = "859296d6fcce46b0d91d56631b49a1978191479e49fafd8d2ccde8f62fda4e76" +const presetContractSourceSHA256 = "c7de8b5f737b7616095b83cd19204761b2e188f17e5f461ede10c3f1d6095447" var presetIDs = []string{"agentic_runtime_repo", "generated_docs_contract_repo", "python_service", "python_typescript_service", "typescript_monorepo", "typescript_workspace"} diff --git a/internal/tools/coveragemetrics/main.go b/internal/tools/coveragemetrics/main.go index 33bf95c..345db78 100644 --- a/internal/tools/coveragemetrics/main.go +++ b/internal/tools/coveragemetrics/main.go @@ -264,7 +264,7 @@ func validateRequiredBindingWitnessSelectors(bindings bindingFile) error { {"REQ-PROOFKIT-WORKFLOW-005", "proofkit.agent-workflow.reference-closed-bounded-context"}: {"TestWorkflowClosurePredicates"}, {"REQ-PROOFKIT-WORKFLOW-006", "proofkit.agent-workflow.no-ambient-authority"}: {"TestWorkflowAmbientAuthorityPredicates"}, {"REQ-PROOFKIT-WORKFLOW-007", "proofkit.agent-workflow.native-evidence-guidance-purity"}: {"TestGuidanceNoAmbientDependencyPredicates"}, - {"REQ-PROOFKIT-WORKFLOW-007", "proofkit.agent-workflow.native-evidence-guidance-slot-closure"}: {"TestGuidanceSlotPredicates"}, + {"REQ-PROOFKIT-WORKFLOW-007", "proofkit.agent-workflow.native-evidence-guidance-slot-closure"}: {"TestGuidanceReferenceIsCompactAndOwnerBound", "TestGuidanceSlotPredicates"}, {"REQ-PROOFKIT-WORKFLOW-008", "proofkit.agent-workflow.bounded-safe-text"}: {"TestWorkflowTerminalTextIsOperationallyComplete", "TestWorkflowTextPredicates"}, {"REQ-PROOFKIT-WORKFLOW-008", "proofkit.agent-workflow.prompt-coordinate-and-escalation-closure"}: {"TestWorkflowPromptPredicates"}, {"REQ-PROOFKIT-WORKFLOW-009", "proofkit.agent-workflow.cli-presentation-capability-product"}: {"TestAgentWorkflowCLITruthTable"}, @@ -272,11 +272,16 @@ func validateRequiredBindingWitnessSelectors(bindings bindingFile) error { {"REQ-PROOFKIT-WORKFLOW-010", "proofkit.agent-workflow.catalog-prerequisite-causality"}: {"TestWorkflowStatePredicates"}, {"REQ-PROOFKIT-WORKFLOW-010", "proofkit.agent-workflow.semantic-owner-minimality"}: {"TestGuidancePurityPredicates"}, {"REQ-PROOFKIT-WORKFLOW-010", "proofkit.agent-workflow.semantic-owner-topology"}: {"TestAgentWorkflowSemanticOwnerTopology"}, - {"REQ-PROOFKIT-WORKFLOW-011", "proofkit.agent-workflow.installed-carrier-smoke-closure"}: {"TestVerifyAcceptsApplicationCLI", "TestVerifyRejectsCarrierContractMutations"}, - {"REQ-PROOFKIT-WORKFLOW-011", "proofkit.agent-workflow.public-cli-relation-closure"}: {"TestAgentWorkflowCLITruthTable"}, - {"REQ-PROOFKIT-WORKFLOW-011", "proofkit.agent-workflow.version-edge-wire-observation"}: {"TestAgentWorkflowVersionEdgeClosesPublicWireAdditions"}, - {"REQ-PROOFKIT-PACKAGE-001", "proofkit.package-boundary.root-export-and-deep-import-denial"}: {"TestVerifyRootPackageRejectsEachForbiddenRootEntry"}, - {"REQ-PROOFKIT-PACKAGE-002", "proofkit.package-boundary.launcher-profile-admission"}: {"TestLauncherProfileAdmissionMatrix"}, + {"REQ-PROOFKIT-WORKFLOW-011", "proofkit.agent-workflow.installed-carrier-smoke-closure"}: { + "TestRunProcessCustomOutputLimitsAreExact", + "TestRunProcessRejectsInvalidCustomOutputLimitsBeforeStart", + "TestVerifyAcceptsApplicationCLI", + "TestVerifyRejectsCarrierContractMutations", + }, + {"REQ-PROOFKIT-WORKFLOW-011", "proofkit.agent-workflow.public-cli-relation-closure"}: {"TestAgentWorkflowCLITruthTable"}, + {"REQ-PROOFKIT-WORKFLOW-011", "proofkit.agent-workflow.version-edge-wire-observation"}: {"TestAgentWorkflowVersionEdgeClosesPublicWireAdditions"}, + {"REQ-PROOFKIT-PACKAGE-001", "proofkit.package-boundary.root-export-and-deep-import-denial"}: {"TestVerifyRootPackageRejectsEachForbiddenRootEntry"}, + {"REQ-PROOFKIT-PACKAGE-002", "proofkit.package-boundary.launcher-profile-admission"}: {"TestLauncherProfileAdmissionMatrix"}, {"REQ-PROOFKIT-PACKAGE-002", "proofkit.package-boundary.generated-command-field-inventory"}: { "TestGeneratedCommandInvocationProfileFieldInventory", "TestGeneratedCommandInvocationProfileRouteClosure", @@ -291,6 +296,7 @@ func validateRequiredBindingWitnessSelectors(bindings bindingFile) error { }, {"REQ-PROOFKIT-PACKAGE-003", "proofkit.package-boundary.outside-consumer-artifact"}: { "TestExactTarballOnboardingTrace", + "TestInstalledCommandRouteBijectionBindsCommandIdentity", "TestVerifyPackedOwnerRecordsRejectsSourceArtifactContentDrift", }, {"REQ-PROOFKIT-PACKAGE-004", "proofkit.package-boundary.ci-receipt-anchor"}: { @@ -302,6 +308,7 @@ func validateRequiredBindingWitnessSelectors(bindings bindingFile) error { {"REQ-PROOFKIT-PACKAGE-006", "proofkit.package-boundary.python-wheel-candidate"}: {"TestPythonArtifactRefsRejectEachWheelIdentityDefect"}, {"REQ-PROOFKIT-PACKAGE-006", "proofkit.package-boundary.python-wheel-generated-continuation"}: { "TestExactDisplayedRouteOperandsRejectsWhitespaceAndExpansionMutants", + "TestInstalledPythonCommandRoutesRequireExactContractBijection", "TestInstalledWheelContinuationUsesExactPythonModuleProfileWithoutNPM", }, {"REQ-PROOFKIT-PACKAGE-007", "proofkit.package-boundary.package-public-docs-no-mutable-release-facts"}: { @@ -539,6 +546,45 @@ func validateRequiredBindingWitnessSelectors(bindings bindingFile) error { {"REQ-PROOFKIT-SPEC-026", "proofkit.spec-proof-core.agent-route-materialized-ref-admission"}: { "TestBuildRejectsStdinTransportSentinelAsArtifactReference", }, + {"REQ-PROOFKIT-SPEC-027", "proofkit.spec-proof-core.adoption-inventory-boundary"}: { + "TestCatalogRolePolicyIsExact", + "TestInventoryIdentityBindsEverySemanticOperand", + "TestInventoryOutputByteLimitIsExact", + "TestReadRootInventoryClassifiesPartialBatchesWithoutRetainingUnknownNames", + "TestScanDoesNotFollowUnknownSymlink", + "TestScanEnforcesPreflightBoundsAndExplicitOmissions", + "TestScanPolicyBoundariesAreExact", + "TestScanProducesBoundedClosedInventory", + "TestScanRejectsRecognizedSymlinkWithoutReadingTarget", + }, + {"REQ-PROOFKIT-SPEC-027", "proofkit.spec-proof-core.adoption-inventory-nonblocking-open"}: { + "TestScanRejectsFIFOReplacementWithoutBlocking", + }, + {"REQ-PROOFKIT-SPEC-027", "proofkit.spec-proof-core.adoption-plan-authority-closure"}: { + "TestAdoptionPlanOutputAndTextBoundsAreExact", + "TestBuildRejectsUnknownIntentPresetAndForgedInventory", + "TestBuildSeparatesAdoptionIntentFromCandidateAuthority", + "TestBuildStackHintCannotChangeIntentTrustOrTasks", + "TestPlanIdentityBindsIntentAndInventory", + "TestPlanWireAdmissionIsDeterministicAndOwnerClosed", + "TestTextProjectionPreservesJSONPlanSemantics", + }, + {"REQ-PROOFKIT-SPEC-027", "proofkit.spec-proof-core.adoption-plan-observational-stack"}: { + "TestPlanKeepsRepositoryClassesObservationalAndStackNeutral", + }, + {"REQ-PROOFKIT-SPEC-027", "proofkit.spec-proof-core.adoption-guidance-reference-closure"}: { + "TestGuidanceReferenceIsCompactAndOwnerBound", + }, + {"REQ-PROOFKIT-SPEC-027", "proofkit.spec-proof-core.adoption-front-door-whole-cli"}: { + "TestAdoptionFrontDoorCLI", + }, + {"REQ-PROOFKIT-SPEC-027", "proofkit.spec-proof-core.adoption-route-contract-closure"}: { + "TestCommandRoutesAreBoundedSafeAndUnambiguous", + }, + {"REQ-PROOFKIT-SPEC-027", "proofkit.spec-proof-core.adoption-version-edge-closure"}: { + "TestAdoptionFrontDoorVersionEdgeClosesInitRetirement", + "TestRetiredInitRouteHasNoPublicDispatcher", + }, {"REQ-PROOFKIT-RETIRE-006", "proofkit.consumer-infra-retirement.migration-parity-admission"}: { "TestBuildProjectsEveryCallerDeclaredStatusAndSummaryField", }, @@ -621,6 +667,14 @@ func validateRequiredBindingWitnessSelectors(bindings bindingFile) error { {"REQ-PROOFKIT-SPEC-026", "proofkit.spec-proof-core.agent-route-flag-pre-read-admission"}: "internal/app/app_test.go", {"REQ-PROOFKIT-SPEC-026", "proofkit.spec-proof-core.agent-route-materialized-ref-admission"}: "internal/command/agentroute/agentroute_test.go", {"REQ-PROOFKIT-SPEC-026", "proofkit.spec-proof-core.agent-route-report-contract-closure"}: "internal/app/cli_contract_test.go", + {"REQ-PROOFKIT-SPEC-027", "proofkit.spec-proof-core.adoption-inventory-boundary"}: "internal/command/repositoryinventory/repositoryinventory_test.go", + {"REQ-PROOFKIT-SPEC-027", "proofkit.spec-proof-core.adoption-inventory-nonblocking-open"}: "internal/command/repositoryinventory/fifo_unix_test.go", + {"REQ-PROOFKIT-SPEC-027", "proofkit.spec-proof-core.adoption-plan-authority-closure"}: "internal/command/adoptionplan/adoptionplan_test.go", + {"REQ-PROOFKIT-SPEC-027", "proofkit.spec-proof-core.adoption-plan-observational-stack"}: "internal/command/adoptionplan/repository_classes_test.go", + {"REQ-PROOFKIT-SPEC-027", "proofkit.spec-proof-core.adoption-guidance-reference-closure"}: "internal/command/nativeevidenceguidance/guidance_test.go", + {"REQ-PROOFKIT-SPEC-027", "proofkit.spec-proof-core.adoption-front-door-whole-cli"}: "internal/app/adoption_front_door_command_test.go", + {"REQ-PROOFKIT-SPEC-027", "proofkit.spec-proof-core.adoption-route-contract-closure"}: "internal/tools/commandcontractgen/main_test.go", + {"REQ-PROOFKIT-SPEC-027", "proofkit.spec-proof-core.adoption-version-edge-closure"}: "internal/app/adoption_front_door_version_edge_test.go", } if len(requiredPaths) != len(required) { return fmt.Errorf("required selector path inventory=%d, selector inventory=%d", len(requiredPaths), len(required)) diff --git a/internal/tools/installedclicontract/contract.go b/internal/tools/installedclicontract/contract.go index c5ff151..ce3a68f 100644 --- a/internal/tools/installedclicontract/contract.go +++ b/internal/tools/installedclicontract/contract.go @@ -5,11 +5,18 @@ import ( "fmt" "sort" "strings" + "unicode/utf8" "github.com/research-engineering/agentic-proofkit/internal/kernel/admission" ) -const maximumCommandRouteTokens = 4 +const ( + MaximumContractBytes = 1 << 20 + MaximumCommands = 512 + MaximumPresetIDs = 128 + maximumCommandRouteTokens = 4 + maximumHelpBytes = 256 << 10 +) // Contract is the admitted package-artifact projection needed by installed // consumer witnesses. Its accessors return copies so callers cannot mutate it. @@ -18,7 +25,15 @@ type Contract struct { presetIDs []string } +type HelpIdentity struct { + CommandID string + Route string +} + func Admit(content []byte) (Contract, error) { + if len(content) == 0 || len(content) > MaximumContractBytes { + return Contract{}, fmt.Errorf("installed CLI contract size must be between 1 and %d bytes", MaximumContractBytes) + } value, err := admission.DecodeJSON(bytes.NewReader(content), int64(len(content))) if err != nil { return Contract{}, fmt.Errorf("decode installed CLI contract: %w", err) @@ -28,8 +43,8 @@ func Admit(content []byte) (Contract, error) { return Contract{}, fmt.Errorf("installed CLI contract must be an object") } commands, ok := record["commands"].([]any) - if !ok || len(commands) == 0 { - return Contract{}, fmt.Errorf("installed CLI contract commands must be a non-empty array") + if !ok || len(commands) == 0 || len(commands) > MaximumCommands { + return Contract{}, fmt.Errorf("installed CLI contract commands must contain between 1 and %d entries", MaximumCommands) } commandIDsByRoute := make(map[string]string, len(commands)) @@ -69,11 +84,6 @@ func Admit(content []byte) (Contract, error) { if _, exists := commandIDsByRoute[routeText]; exists { return Contract{}, fmt.Errorf("installed CLI contract duplicates a command route") } - for _, priorRoute := range admittedRoutes { - if strings.HasPrefix(routeText, priorRoute+" ") || strings.HasPrefix(priorRoute, routeText+" ") { - return Contract{}, fmt.Errorf("installed CLI contract has ambiguous command route prefixes") - } - } commandIDsByRoute[routeText] = commandID admittedRoutes = append(admittedRoutes, routeText) @@ -88,9 +98,55 @@ func Admit(content []byte) (Contract, error) { } } } + sort.Strings(admittedRoutes) + for index := 1; index < len(admittedRoutes); index++ { + if strings.HasPrefix(admittedRoutes[index], admittedRoutes[index-1]+" ") { + return Contract{}, fmt.Errorf("installed CLI contract has ambiguous command route prefixes") + } + } return Contract{commandIDsByRoute: commandIDsByRoute, presetIDs: presetIDs}, nil } +// AdmitHelpIdentity extracts the exact public command identity from one leaf +// help response. Package verifiers use it to prove route-to-command ownership, +// not merely route-set equality. +func AdmitHelpIdentity(content []byte) (HelpIdentity, error) { + if len(content) == 0 || len(content) > maximumHelpBytes || !utf8.Valid(content) || bytes.IndexByte(content, 0) >= 0 { + return HelpIdentity{}, fmt.Errorf("installed CLI leaf help is not bounded UTF-8 text") + } + lines := strings.Split(string(content), "\n") + commandID, commandCount := helpField(lines, "Command ID:") + route, routeCount := helpField(lines, "Route:") + if commandCount != 1 || routeCount != 1 || !ValidRouteToken(commandID) { + return HelpIdentity{}, fmt.Errorf("installed CLI leaf help identity is invalid") + } + routeTokens := strings.Split(route, " ") + if len(routeTokens) == 0 || len(routeTokens) > maximumCommandRouteTokens { + return HelpIdentity{}, fmt.Errorf("installed CLI leaf help route is invalid") + } + for _, token := range routeTokens { + if !ValidRouteToken(token) { + return HelpIdentity{}, fmt.Errorf("installed CLI leaf help route is invalid") + } + } + return HelpIdentity{CommandID: commandID, Route: route}, nil +} + +func helpField(lines []string, label string) (string, int) { + value := "" + count := 0 + for index, line := range lines { + if line != label { + continue + } + count++ + if index+1 < len(lines) && strings.HasPrefix(lines[index+1], " ") && !strings.HasPrefix(lines[index+1], " ") { + value = strings.TrimPrefix(lines[index+1], " ") + } + } + return value, count +} + func (contract Contract) CommandIDsByRoute() map[string]string { result := make(map[string]string, len(contract.commandIDsByRoute)) for route, commandID := range contract.commandIDsByRoute { @@ -137,8 +193,8 @@ func admitPresetIDs(command map[string]any) ([]string, error) { return nil, fmt.Errorf("installed CLI contract stack-preset flagChoices must be an object") } rawIDs, ok := choices["--preset"].([]any) - if !ok || len(rawIDs) == 0 { - return nil, fmt.Errorf("installed CLI contract stack-preset choices must be a non-empty array") + if !ok || len(rawIDs) == 0 || len(rawIDs) > MaximumPresetIDs { + return nil, fmt.Errorf("installed CLI contract stack-preset choices must contain between 1 and %d entries", MaximumPresetIDs) } ids := make([]string, 0, len(rawIDs)) seen := make(map[string]struct{}, len(rawIDs)) diff --git a/internal/tools/installedclicontract/contract_test.go b/internal/tools/installedclicontract/contract_test.go index 34eb4f9..622ecfe 100644 --- a/internal/tools/installedclicontract/contract_test.go +++ b/internal/tools/installedclicontract/contract_test.go @@ -1,7 +1,10 @@ package installedclicontract import ( + "bytes" + "fmt" "slices" + "strings" "testing" ) @@ -76,3 +79,64 @@ func TestAdmitCommandRouteTokenBoundariesAreExact(t *testing.T) { }) } } + +func TestAdmitContractResourceBoundsAreExact(t *testing.T) { + base := []byte(`{"commands":[{"command":"one"}]}`) + exactBytes := append(append([]byte(nil), base...), bytes.Repeat([]byte(" "), MaximumContractBytes-len(base))...) + if _, err := Admit(exactBytes); err != nil { + t.Fatalf("exact byte limit rejected: %v", err) + } + if _, err := Admit(append(exactBytes, ' ')); err == nil { + t.Fatal("one-over contract byte limit was accepted") + } + + commands := make([]string, MaximumCommands+1) + for index := range commands { + commands[index] = fmt.Sprintf(`{"command":"command-%d"}`, index) + } + contract := func(count int) []byte { + return []byte(`{"commands":[` + strings.Join(commands[:count], ",") + `]}`) + } + if _, err := Admit(contract(MaximumCommands)); err != nil { + t.Fatalf("exact command cardinality rejected: %v", err) + } + if _, err := Admit(contract(MaximumCommands + 1)); err == nil { + t.Fatal("one-over command cardinality was accepted") + } + + presets := make([]string, MaximumPresetIDs+1) + for index := range presets { + presets[index] = fmt.Sprintf(`"preset-%03d"`, index) + } + presetContract := func(count int) []byte { + return []byte(`{"commands":[{"command":"stack-preset","outputContract":{"flagChoices":{"--preset":[` + strings.Join(presets[:count], ",") + `]}}}]}`) + } + if _, err := Admit(presetContract(MaximumPresetIDs)); err != nil { + t.Fatalf("exact preset cardinality rejected: %v", err) + } + if _, err := Admit(presetContract(MaximumPresetIDs + 1)); err == nil { + t.Fatal("one-over preset cardinality was accepted") + } +} + +func TestAdmitHelpIdentityRequiresExactCommandAndRoute(t *testing.T) { + help := []byte("Usage:\n agentic-proofkit adopt plan\n\nCommand ID:\n adopt-plan\n\nRoute:\n adopt plan\n") + identity, err := AdmitHelpIdentity(help) + if err != nil { + t.Fatalf("AdmitHelpIdentity() error = %v", err) + } + if identity.CommandID != "adopt-plan" || identity.Route != "adopt plan" { + t.Fatalf("help identity = %#v", identity) + } + mutants := [][]byte{ + bytes.Replace(help, []byte("adopt-plan"), []byte("Wrong_ID"), 1), + append(append([]byte(nil), help...), []byte("Command ID:\n adopt-plan\n")...), + bytes.Replace(help, []byte(" adopt plan\n"), []byte(" adopt plan\n"), 1), + append([]byte(nil), bytes.Repeat([]byte("x"), maximumHelpBytes+1)...), + } + for index, mutant := range mutants { + if _, err := AdmitHelpIdentity(mutant); err == nil { + t.Fatalf("help identity mutant %d was accepted", index) + } + } +} diff --git a/internal/tools/packageverify/main.go b/internal/tools/packageverify/main.go index 62e61ce..64bb5ce 100644 --- a/internal/tools/packageverify/main.go +++ b/internal/tools/packageverify/main.go @@ -1799,6 +1799,7 @@ func verifyInstalledOnboardingTraceWithExecutors(consumer string, transportExecu requirementSourceHelpFound := false allLeafRoutes := make([]installedHelpRoute, 0, len(contractCommandIDsByRoute)) familyByLeafRoute := make(map[string]string, len(contractCommandIDsByRoute)) + observedCommandIDsByRoute := make(map[string]string, len(contractCommandIDsByRoute)) for _, familyRoute := range familyRoutes { family, err := binaryExecute(consumer, nil, familyRoute.Argv...) if err != nil { @@ -1819,9 +1820,6 @@ func verifyInstalledOnboardingTraceWithExecutors(consumer string, transportExecu allLeafRoutes = append(allLeafRoutes, leafRoute) } } - if err := requireInstalledCommandRouteBijection(familyByLeafRoute, contractCommandIDsByRoute); err != nil { - return err - } for _, leafRoute := range allLeafRoutes { leafHelp, err := binaryExecute(consumer, nil, leafRoute.Argv...) if err != nil { @@ -1833,6 +1831,14 @@ func verifyInstalledOnboardingTraceWithExecutors(consumer string, transportExecu if err := requireInstalledInvocationSyntax(leafHelp.Stdout, leafRoute.Route); err != nil { return err } + helpIdentity, err := installedclicontract.AdmitHelpIdentity(leafHelp.Stdout) + if err != nil { + return fmt.Errorf("outside consumer %s help identity is invalid: %w", leafRoute.ID, err) + } + if helpIdentity.Route != leafRoute.Route { + return fmt.Errorf("outside consumer help route=%q, want %q", helpIdentity.Route, leafRoute.Route) + } + observedCommandIDsByRoute[helpIdentity.Route] = helpIdentity.CommandID if leafRoute.Route == multiTokenRoute.Route && !bytes.Equal(leafHelp.Stdout, transportMultiTokenHelp.Stdout) { return fmt.Errorf("outside consumer multi-token route differs between npm transport and direct installed binary") } @@ -1851,6 +1857,9 @@ func verifyInstalledOnboardingTraceWithExecutors(consumer string, transportExecu requirementSourceHelpFound = true } } + if err := requireInstalledCommandRouteBijection(observedCommandIDsByRoute, contractCommandIDsByRoute); err != nil { + return err + } if !stackHelpFound { return fmt.Errorf("outside consumer family navigation did not expose stack-preset") } @@ -1942,10 +1951,14 @@ func requireInstalledCommandRouteBijection(observed map[string]string, expected if len(observed) != len(expected) { return fmt.Errorf("outside consumer family command routes=%d installed contract routes=%d", len(observed), len(expected)) } - for route := range expected { - if _, exists := observed[route]; !exists { + for route, commandID := range expected { + observedCommandID, exists := observed[route] + if !exists { return fmt.Errorf("outside consumer family navigation omitted installed contract route %q", route) } + if observedCommandID != commandID { + return fmt.Errorf("outside consumer route %q command id=%q, want %q", route, observedCommandID, commandID) + } } return nil } diff --git a/internal/tools/packageverify/main_test.go b/internal/tools/packageverify/main_test.go index 1e91c01..54e04e1 100644 --- a/internal/tools/packageverify/main_test.go +++ b/internal/tools/packageverify/main_test.go @@ -996,6 +996,7 @@ func TestOnboardingTraceCoversEveryDiscoveredPresetAndREADMEInput(t *testing.T) case slices.Equal(args, []string{"help", "stack-preset"}): stdout = "Usage:\n agentic-proofkit stack-preset --preset <" + strings.Join(choices, "|") + ">\n" + "\nInstalled invocation:\n " + stackInstalledInvocation + "\n" + + "\nCommand ID:\n stack-preset\n\nRoute:\n stack-preset\n" + "Copyable preset commands:\n" for _, choice := range choices { stdout += " " + presetRoutePrefix + choice + "\n" @@ -1003,13 +1004,16 @@ func TestOnboardingTraceCoversEveryDiscoveredPresetAndREADMEInput(t *testing.T) case slices.Equal(args, []string{"help", "requirement-source-admission"}): stdout = "Usage:\n agentic-proofkit requirement-source-admission --input [--input-pointer ]\n" + "\nInstalled invocation:\n " + requirementSourceInstalledInvocation + "\n" + + "\nCommand ID:\n requirement-source-admission\n\nRoute:\n requirement-source-admission\n" + "Continue with the installed README first-valid-input example:\n " + readmeContinuation + "\n" case slices.Equal(args, []string{"help", "self-check"}): stdout = "Usage:\n agentic-proofkit self-check --input \n" + - "\nInstalled invocation:\n " + selfCheckInstalledInvocation + "\n" + "\nInstalled invocation:\n " + selfCheckInstalledInvocation + "\n" + + "\nCommand ID:\n self-check\n\nRoute:\n self-check\n" case slices.Equal(args, []string{"help", "adopt", "plan"}): stdout = "Usage:\n agentic-proofkit adopt plan --mode --repo-root \n" + - "\nInstalled invocation:\n " + installedNPMExecCommandPrefix + "adopt plan --mode --repo-root \n" + "\nInstalled invocation:\n " + installedNPMExecCommandPrefix + "adopt plan --mode --repo-root \n" + + "\nCommand ID:\n adopt-plan\n\nRoute:\n adopt plan\n" case len(args) == 3 && args[0] == "stack-preset" && args[1] == "--preset": seenPresets[args[2]] = struct{}{} presetExecutionCounts[args[2]]++ @@ -1256,6 +1260,23 @@ func TestInstalledContractCommandRoutesRejectAmbiguousIdentity(t *testing.T) { } } +func TestInstalledCommandRouteBijectionBindsCommandIdentity(t *testing.T) { + expected := map[string]string{"adopt plan": "adopt-plan", "self-check": "self-check"} + if err := requireInstalledCommandRouteBijection(map[string]string{"adopt plan": "adopt-plan", "self-check": "self-check"}, expected); err != nil { + t.Fatalf("exact route-to-command bijection rejected: %v", err) + } + mutants := []map[string]string{ + {"adopt plan": "wrong-command", "self-check": "self-check"}, + {"adopt plan": "adopt-plan"}, + {"adopt plan": "adopt-plan", "self-check": "self-check", "extra": "extra"}, + } + for index, mutant := range mutants { + if err := requireInstalledCommandRouteBijection(mutant, expected); err == nil { + t.Fatalf("route-to-command mutant %d was accepted", index) + } + } +} + func TestInstalledInvocationRequiresAuthoredOrderAndExactCommandToken(t *testing.T) { valid := "Usage:\n" + " agentic-proofkit self-check --input \n\n" + diff --git a/internal/tools/pythonpackage/continuation_test.go b/internal/tools/pythonpackage/continuation_test.go index affacf1..1d91248 100644 --- a/internal/tools/pythonpackage/continuation_test.go +++ b/internal/tools/pythonpackage/continuation_test.go @@ -58,15 +58,16 @@ func TestExactDisplayedCommandRoutesAdmitBoundedMultiTokenRoutes(t *testing.T) { func TestInstalledPythonCommandRoutesRequireExactContractBijection(t *testing.T) { expected := map[string]string{"adopt plan": "adopt-plan", "self-check": "self-check"} if err := requireInstalledPythonCommandRouteBijection( - map[string]string{"adopt plan": "adoption", "self-check": "quality"}, + map[string]string{"adopt plan": "adopt-plan", "self-check": "self-check"}, expected, ); err != nil { t.Fatalf("exact route bijection rejected: %v", err) } mutants := []map[string]string{ - {"adopt plan": "adoption"}, - {"adopt plan": "adoption", "other": "quality"}, - {"adopt plan": "adoption", "self-check": "quality", "other": "quality"}, + {"adopt plan": "adopt-plan"}, + {"adopt plan": "wrong-command", "self-check": "self-check"}, + {"adopt plan": "adopt-plan", "other": "self-check"}, + {"adopt plan": "adopt-plan", "self-check": "self-check", "other": "other"}, } for _, mutant := range mutants { if err := requireInstalledPythonCommandRouteBijection(mutant, expected); err == nil { diff --git a/internal/tools/pythonpackage/verify.go b/internal/tools/pythonpackage/verify.go index 99aa676..8db0759 100644 --- a/internal/tools/pythonpackage/verify.go +++ b/internal/tools/pythonpackage/verify.go @@ -19,6 +19,7 @@ import ( "sort" "strconv" "strings" + "time" "github.com/research-engineering/agentic-proofkit/internal/kernel/admission" "github.com/research-engineering/agentic-proofkit/internal/kernel/cliexec" @@ -34,6 +35,7 @@ const ( machoMinimumVersionCommand = 0x24 machoMinimumVersionCommandSize = 16 machoPlatformMacOS = 1 + installedContractReadTimeout = 10 * time.Second ) func verifyPythonPackages() error { @@ -562,6 +564,7 @@ func verifyInstalledPythonHelpAndAgentRouteContinuity(consumer string, environme } leafPrefix := renderer.DisplayCommand() + " help " observedRoutes := make(map[string]string, len(contractCommandIDsByRoute)) + familyByRoute := make(map[string]string, len(contractCommandIDsByRoute)) for _, familyID := range familyIDs { familyRoute := renderer.DisplayCommand("help", "family", familyID) familyHelp, err := runArgvWithEnvironment(consumer, environment, renderer.Argv("help", "family", familyID)) @@ -577,15 +580,24 @@ func verifyInstalledPythonHelpAndAgentRouteContinuity(consumer string, environme if !exists { return fmt.Errorf("installed Python wheel family %s exposes route %q absent from its embedded CLI contract", familyID, leafRouteText) } - if priorFamily, exists := observedRoutes[leafRouteText]; exists { + if priorFamily, exists := familyByRoute[leafRouteText]; exists { return fmt.Errorf("installed Python wheel route %q is exposed by both %s and %s", leafRouteText, priorFamily, familyID) } - observedRoutes[leafRouteText] = familyID + familyByRoute[leafRouteText] = familyID helpArgs := append([]string{"help"}, strings.Split(leafRouteText, " ")...) leafRoute := renderer.DisplayCommand(helpArgs...) - if _, err := runArgvWithEnvironment(consumer, environment, renderer.Argv(helpArgs...)); err != nil { + leafHelp, err := runArgvWithEnvironment(consumer, environment, renderer.Argv(helpArgs...)) + if err != nil { return fmt.Errorf("installed Python wheel leaf help route %q for command %s failed: %w", leafRoute, commandID, err) } + helpIdentity, err := installedclicontract.AdmitHelpIdentity(leafHelp) + if err != nil { + return fmt.Errorf("installed Python wheel leaf help route %q has invalid identity: %w", leafRoute, err) + } + if helpIdentity.Route != leafRouteText { + return fmt.Errorf("installed Python wheel leaf help route=%q, want %q", helpIdentity.Route, leafRouteText) + } + observedRoutes[helpIdentity.Route] = helpIdentity.CommandID } } if err := requireInstalledPythonCommandRouteBijection(observedRoutes, contractCommandIDsByRoute); err != nil { @@ -678,25 +690,66 @@ func readInstalledPythonCLIContract(consumer string, environment []string, rende if len(launcher) != 3 || launcher[1] != "-m" || launcher[2] != "agentic_proofkit" { return nil, fmt.Errorf("installed Python wheel contract reader requires the Python module launcher") } - const script = `from importlib.resources import files; print(files("agentic_proofkit").joinpath("proofkit", "cli-contract.v2.json").read_text(encoding="utf-8"), end="")` - content, err := runCommandWithEnvironment(consumer, environment, launcher[0], "-c", script) + script := fmt.Sprintf(` +import os +import stat +import sys +from importlib.resources import files + +limit = %d +path = files("agentic_proofkit").joinpath("proofkit", "cli-contract.v2.json") +with path.open("rb") as stream: + before = os.fstat(stream.fileno()) + if not stat.S_ISREG(before.st_mode) or before.st_size < 1 or before.st_size > limit: + raise SystemExit("installed CLI contract resource is not a bounded regular file") + total = 0 + while True: + chunk = stream.read(min(65536, limit + 1 - total)) + if not chunk: + break + total += len(chunk) + if total > limit: + raise SystemExit("installed CLI contract resource exceeds its byte limit") + sys.stdout.buffer.write(chunk) + after = os.fstat(stream.fileno()) + if total != before.st_size or (before.st_dev, before.st_ino, before.st_size, before.st_mtime_ns) != (after.st_dev, after.st_ino, after.st_size, after.st_mtime_ns): + raise SystemExit("installed CLI contract resource changed during the read") +`, installedclicontract.MaximumContractBytes) + ctx, cancel := context.WithTimeout(context.Background(), installedContractReadTimeout) + defer cancel() + result, err := workflowsmoke.RunProcessWithOutputLimits(ctx, workflowsmoke.ProcessCarrier{ + Directory: consumer, + Executable: launcher[0], + Prefix: []string{"-c", script}, + Environment: environment, + }, workflowsmoke.Invocation{StdinClass: workflowsmoke.StdinMustRemainUnread}, workflowsmoke.ProcessOutputLimits{ + MaximumStdoutBytes: installedclicontract.MaximumContractBytes, + MaximumStderrBytes: 64 << 10, + }) if err != nil { - return nil, fmt.Errorf("read installed Python wheel CLI contract: %w\n%s", err, content) + return nil, fmt.Errorf("read installed Python wheel CLI contract: %w", err) + } + if result.ExitCode != 0 { + return nil, fmt.Errorf("read installed Python wheel CLI contract exited with code %d", result.ExitCode) } - if len(content) == 0 || len(content) > 1<<20 { - return nil, fmt.Errorf("installed Python wheel CLI contract size must be between 1 and %d bytes", 1<<20) + if _, err := installedclicontract.Admit(result.Stdout); err != nil { + return nil, fmt.Errorf("admit installed Python wheel CLI contract resource: %w", err) } - return content, nil + return result.Stdout, nil } func requireInstalledPythonCommandRouteBijection(observed map[string]string, expected map[string]string) error { if len(observed) != len(expected) { return fmt.Errorf("installed Python wheel family routes=%d embedded contract routes=%d", len(observed), len(expected)) } - for route := range expected { - if _, exists := observed[route]; !exists { + for route, commandID := range expected { + observedCommandID, exists := observed[route] + if !exists { return fmt.Errorf("installed Python wheel family navigation omitted embedded contract route %q", route) } + if observedCommandID != commandID { + return fmt.Errorf("installed Python wheel route %q command id=%q, want %q", route, observedCommandID, commandID) + } } return nil } diff --git a/internal/tools/workflowsmoke/process.go b/internal/tools/workflowsmoke/process.go index c9136af..89fd73d 100644 --- a/internal/tools/workflowsmoke/process.go +++ b/internal/tools/workflowsmoke/process.go @@ -14,11 +14,19 @@ import ( ) const ( - processWaitDelay = 2 * time.Second - maximumStdoutBytes = 256 * 1024 - maximumStderrBytes = 64 * 1024 + processWaitDelay = 2 * time.Second + maximumProcessOutputBytes = 8 << 20 + defaultMaximumStdoutBytes = 256 << 10 + defaultMaximumStderrBytes = 64 << 10 ) +// ProcessOutputLimits bounds the memory retained from one process invocation. +// Both values must be positive and no greater than the package hard limit. +type ProcessOutputLimits struct { + MaximumStdoutBytes int + MaximumStderrBytes int +} + // ProcessCarrier identifies one installed executable and any fixed argv prefix. type ProcessCarrier struct { Directory string @@ -37,9 +45,21 @@ func VerifyProcess(ctx context.Context, carrier ProcessCarrier) error { // RunProcess executes one bounded carrier invocation and terminates its whole // process group before returning on every post-start path. func RunProcess(ctx context.Context, carrier ProcessCarrier, invocation Invocation) (Result, error) { + return RunProcessWithOutputLimits(ctx, carrier, invocation, ProcessOutputLimits{ + MaximumStdoutBytes: defaultMaximumStdoutBytes, + MaximumStderrBytes: defaultMaximumStderrBytes, + }) +} + +// RunProcessWithOutputLimits executes one invocation with caller-selected, +// package-bounded stdout and stderr limits. +func RunProcessWithOutputLimits(ctx context.Context, carrier ProcessCarrier, invocation Invocation, limits ProcessOutputLimits) (Result, error) { if ctx == nil || carrier.Executable == "" { return Result{}, fmt.Errorf("process carrier requires a context and executable") } + if err := admitProcessOutputLimits(limits); err != nil { + return Result{}, err + } if invocation.StdinClass != StdinBytes && invocation.StdinClass != StdinMustRemainUnread { return Result{}, fmt.Errorf("process carrier received an unsupported stdin class") } @@ -53,8 +73,8 @@ func RunProcess(ctx context.Context, carrier ProcessCarrier, invocation Invocati command.Env = append([]string(nil), carrier.Environment...) } processgroup.Configure(command) - stdout := newBoundedBuffer(maximumStdoutBytes, cancel) - stderr := newBoundedBuffer(maximumStderrBytes, cancel) + stdout := newBoundedBuffer(limits.MaximumStdoutBytes, cancel) + stderr := newBoundedBuffer(limits.MaximumStderrBytes, cancel) command.Stdout = stdout command.Stderr = stderr var stdinRead *os.File @@ -78,10 +98,10 @@ func RunProcess(ctx context.Context, carrier ProcessCarrier, invocation Invocati return Result{}, fmt.Errorf("terminate process carrier group: %w", cleanupErr) } if stdout.Overflowed() { - return Result{}, fmt.Errorf("process carrier stdout exceeds %d bytes", maximumStdoutBytes) + return Result{}, fmt.Errorf("process carrier stdout exceeds %d bytes", limits.MaximumStdoutBytes) } if stderr.Overflowed() { - return Result{}, fmt.Errorf("process carrier stderr exceeds %d bytes", maximumStderrBytes) + return Result{}, fmt.Errorf("process carrier stderr exceeds %d bytes", limits.MaximumStderrBytes) } if ctx.Err() != nil { return Result{}, fmt.Errorf("process carrier invocation canceled: %w", ctx.Err()) @@ -97,6 +117,16 @@ func RunProcess(ctx context.Context, carrier ProcessCarrier, invocation Invocati return Result{}, fmt.Errorf("process carrier invocation failed: %w", runErr) } +func admitProcessOutputLimits(limits ProcessOutputLimits) error { + if limits.MaximumStdoutBytes <= 0 || limits.MaximumStdoutBytes > maximumProcessOutputBytes { + return fmt.Errorf("process carrier stdout limit must be between 1 and %d bytes", maximumProcessOutputBytes) + } + if limits.MaximumStderrBytes <= 0 || limits.MaximumStderrBytes > maximumProcessOutputBytes { + return fmt.Errorf("process carrier stderr limit must be between 1 and %d bytes", maximumProcessOutputBytes) + } + return nil +} + type boundedBuffer struct { mu sync.Mutex content []byte diff --git a/internal/tools/workflowsmoke/workflow_smoke.go b/internal/tools/workflowsmoke/workflow_smoke.go index b9283cb..a3c9033 100644 --- a/internal/tools/workflowsmoke/workflow_smoke.go +++ b/internal/tools/workflowsmoke/workflow_smoke.go @@ -197,7 +197,7 @@ func verifyFailure(ctx context.Context, run Runner, label string, invocation Inv } func verifyExactJSONObject(result Result, expected map[string]any, label string) error { - actual, err := admission.DecodeJSON(bytes.NewReader(result.Stdout), maximumStdoutBytes) + actual, err := admission.DecodeJSON(bytes.NewReader(result.Stdout), defaultMaximumStdoutBytes) if err != nil { return fmt.Errorf("%s stdout must contain exactly one strict JSON value: %w", label, err) } diff --git a/internal/tools/workflowsmoke/workflow_smoke_test.go b/internal/tools/workflowsmoke/workflow_smoke_test.go index 99cb284..9d9b2ff 100644 --- a/internal/tools/workflowsmoke/workflow_smoke_test.go +++ b/internal/tools/workflowsmoke/workflow_smoke_test.go @@ -106,6 +106,35 @@ func TestRunProcessAcceptsExactOutputBounds(t *testing.T) { } } +func TestRunProcessCustomOutputLimitsAreExact(t *testing.T) { + limits := workflowsmoke.ProcessOutputLimits{MaximumStdoutBytes: 1024, MaximumStderrBytes: 128} + ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) + defer cancel() + result, err := workflowsmoke.RunProcessWithOutputLimits(ctx, helperCarrier("custom-output-boundary"), workflowsmoke.Invocation{StdinClass: workflowsmoke.StdinBytes}, limits) + if err != nil { + t.Fatal(err) + } + if len(result.Stdout) != limits.MaximumStdoutBytes || len(result.Stderr) != limits.MaximumStderrBytes { + t.Fatalf("output lengths=(%d,%d), want (%d,%d)", len(result.Stdout), len(result.Stderr), limits.MaximumStdoutBytes, limits.MaximumStderrBytes) + } + + limits.MaximumStdoutBytes-- + if _, err := workflowsmoke.RunProcessWithOutputLimits(ctx, helperCarrier("custom-output-boundary"), workflowsmoke.Invocation{StdinClass: workflowsmoke.StdinBytes}, limits); err == nil || !strings.Contains(err.Error(), "stdout exceeds") { + t.Fatalf("one-over custom stdout error=%v, want overflow", err) + } +} + +func TestRunProcessRejectsInvalidCustomOutputLimitsBeforeStart(t *testing.T) { + for _, limits := range []workflowsmoke.ProcessOutputLimits{ + {}, + {MaximumStdoutBytes: 1, MaximumStderrBytes: 9 << 20}, + } { + if _, err := workflowsmoke.RunProcessWithOutputLimits(t.Context(), helperCarrier("hang"), workflowsmoke.Invocation{StdinClass: workflowsmoke.StdinBytes}, limits); err == nil { + t.Fatalf("invalid limits %#v were accepted", limits) + } + } +} + func TestRunProcessProvesUnreadStdin(t *testing.T) { t.Run("non-reader exits", func(t *testing.T) { ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) @@ -143,6 +172,9 @@ func TestWorkflowSmokeProcessHelper(t *testing.T) { case "output-boundary": _, _ = os.Stdout.Write(bytes.Repeat([]byte{'x'}, 256*1024)) _, _ = os.Stderr.Write(bytes.Repeat([]byte{'x'}, 64*1024)) + case "custom-output-boundary": + _, _ = os.Stdout.Write(bytes.Repeat([]byte{'x'}, 1024)) + _, _ = os.Stderr.Write(bytes.Repeat([]byte{'x'}, 128)) case "read-stdin": _, _ = io.Copy(io.Discard, os.Stdin) case "no-read": diff --git a/proofkit/cli-contract.v2.json b/proofkit/cli-contract.v2.json index cfefe96..f80d488 100644 --- a/proofkit/cli-contract.v2.json +++ b/proofkit/cli-contract.v2.json @@ -111,7 +111,7 @@ "rootDefinitionDigest": "sha256:c7bc9d7b70231d57a3066cad755d30aacfbc92fe57ac54d2df5d2ec7ed175a32", "nativeSource": { "path": "internal/command/adoptionplan", - "canonicalDigest": "sha256:dc9f0423cf3c7819daa7c4b4da2831bad1dc21d3c91ca93862451b9c10e90642", + "canonicalDigest": "sha256:f181c815ee62728a7bc6e476c05ec1a29fb2832299e300e91fbcc1794994256c", "evidenceClass": "source_checkout" }, "nativeOutputWitnessSelector": { @@ -777,7 +777,7 @@ "nativeSources": [ { "path": "internal/app", - "canonicalDigest": "sha256:22bd7683f1e429c98ea32753c01d93d75a845e77a00f5e1e16df5b8454158feb", + "canonicalDigest": "sha256:573c70cd1937ab3284daf86b5fd1b67ef72baca38c206194faa8e7b9140f030c", "evidenceClass": "source_checkout" }, { @@ -2555,7 +2555,7 @@ "nativeSources": [ { "path": "internal/app", - "canonicalDigest": "sha256:22bd7683f1e429c98ea32753c01d93d75a845e77a00f5e1e16df5b8454158feb", + "canonicalDigest": "sha256:573c70cd1937ab3284daf86b5fd1b67ef72baca38c206194faa8e7b9140f030c", "evidenceClass": "source_checkout" }, { @@ -3525,7 +3525,7 @@ "rootDefinitionDigest": "sha256:cc91b8e1a3fb25180165204979ccbc07c378679e2ffe7f85348b3fd38ecbdc8e", "nativeSource": { "path": "internal/command/repositoryinventory", - "canonicalDigest": "sha256:cbf99b000fa1ddcef8b468e1aba9b515b7aeab9be254a84a6fc654100464383c", + "canonicalDigest": "sha256:16e1e37a0face206a6a8cfd47cd867811f7f8e7ac85777b400d355d0a2752d68", "evidenceClass": "source_checkout" }, "nativeOutputWitnessSelector": { @@ -5845,7 +5845,7 @@ "rootDefinitionDigest": "sha256:3c842174dff5361e7f83166469b832805e05aa314b073c16234b5b64e346281e", "nativeSource": { "path": "internal/app", - "canonicalDigest": "sha256:22bd7683f1e429c98ea32753c01d93d75a845e77a00f5e1e16df5b8454158feb", + "canonicalDigest": "sha256:573c70cd1937ab3284daf86b5fd1b67ef72baca38c206194faa8e7b9140f030c", "evidenceClass": "source_checkout" }, "nativeAdmissionWitnessSelector": { @@ -5874,7 +5874,7 @@ "rootDefinitionDigest": "sha256:0ea95e277ebe44cd2de42c29b47c38686ac0b6b390d8965367437b3fe138e209", "nativeSource": { "path": "internal/app", - "canonicalDigest": "sha256:22bd7683f1e429c98ea32753c01d93d75a845e77a00f5e1e16df5b8454158feb", + "canonicalDigest": "sha256:573c70cd1937ab3284daf86b5fd1b67ef72baca38c206194faa8e7b9140f030c", "evidenceClass": "source_checkout" }, "nativeOutputWitnessSelector": { diff --git a/proofkit/requirement-bindings.json b/proofkit/requirement-bindings.json index ff2e28e..8fa1978 100644 --- a/proofkit/requirement-bindings.json +++ b/proofkit/requirement-bindings.json @@ -1162,6 +1162,10 @@ { "selector": "TestVerifyPackedOwnerRecordsRejectsSourceArtifactContentDrift", "command": "go test ./internal/tools/packageverify -run '^TestVerifyPackedOwnerRecordsRejectsSourceArtifactContentDrift$'" + }, + { + "selector": "TestInstalledCommandRouteBijectionBindsCommandIdentity", + "command": "go test ./internal/tools/packageverify -run '^TestInstalledCommandRouteBijectionBindsCommandIdentity$'" } ], "commandIds": [ @@ -1366,6 +1370,10 @@ { "selector": "TestInstalledWheelContinuationUsesExactPythonModuleProfileWithoutNPM", "command": "go test ./internal/tools/pythonpackage -run '^TestInstalledWheelContinuationUsesExactPythonModuleProfileWithoutNPM$'" + }, + { + "selector": "TestInstalledPythonCommandRoutesRequireExactContractBijection", + "command": "go test ./internal/tools/pythonpackage -run '^TestInstalledPythonCommandRoutesRequireExactContractBijection$'" } ], "commandIds": [ @@ -5251,6 +5259,10 @@ { "selector": "TestGuidanceSlotPredicates", "command": "go test ./internal/command/nativeevidenceguidance -run '^TestGuidanceSlotPredicates$'" + }, + { + "selector": "TestGuidanceReferenceIsCompactAndOwnerBound", + "command": "go test ./internal/command/nativeevidenceguidance -run '^TestGuidanceReferenceIsCompactAndOwnerBound$'" } ], "commandIds": ["proofkit.go-test"], @@ -5413,6 +5425,14 @@ { "selector": "TestVerifyRejectsCarrierContractMutations", "command": "go test ./internal/tools/workflowsmoke -run '^TestVerifyRejectsCarrierContractMutations$'" + }, + { + "selector": "TestRunProcessCustomOutputLimitsAreExact", + "command": "go test ./internal/tools/workflowsmoke -run '^TestRunProcessCustomOutputLimitsAreExact$'" + }, + { + "selector": "TestRunProcessRejectsInvalidCustomOutputLimitsBeforeStart", + "command": "go test ./internal/tools/workflowsmoke -run '^TestRunProcessRejectsInvalidCustomOutputLimitsBeforeStart$'" } ], "commandIds": ["proofkit.go-test", "proofkit.package-artifact"], @@ -5448,6 +5468,10 @@ "selector": "TestScanRejectsRecognizedSymlinkWithoutReadingTarget", "command": "go test ./internal/command/repositoryinventory -run '^TestScanRejectsRecognizedSymlinkWithoutReadingTarget$'" }, + { + "selector": "TestScanDoesNotFollowUnknownSymlink", + "command": "go test ./internal/command/repositoryinventory -run '^TestScanDoesNotFollowUnknownSymlink$'" + }, { "selector": "TestScanEnforcesPreflightBoundsAndExplicitOmissions", "command": "go test ./internal/command/repositoryinventory -run '^TestScanEnforcesPreflightBoundsAndExplicitOmissions$'" @@ -5460,6 +5484,10 @@ "selector": "TestInventoryIdentityBindsEverySemanticOperand", "command": "go test ./internal/command/repositoryinventory -run '^TestInventoryIdentityBindsEverySemanticOperand$'" }, + { + "selector": "TestInventoryOutputByteLimitIsExact", + "command": "go test ./internal/command/repositoryinventory -run '^TestInventoryOutputByteLimitIsExact$'" + }, { "selector": "TestCatalogRolePolicyIsExact", "command": "go test ./internal/command/repositoryinventory -run '^TestCatalogRolePolicyIsExact$'" @@ -5472,6 +5500,21 @@ "commandIds": ["proofkit.go-test"], "environmentClasses": ["local-go"] }, + { + "requirementId": "REQ-PROOFKIT-SPEC-027", + "scenarioId": "proofkit.spec-proof-core.adoption-inventory-nonblocking-open", + "witnessId": "proofkit.repository-inventory.nonblocking-open-falsifier", + "witnessKind": "technical", + "witnessPath": "internal/command/repositoryinventory/fifo_unix_test.go", + "witnessSelectors": [ + { + "selector": "TestScanRejectsFIFOReplacementWithoutBlocking", + "command": "go test ./internal/command/repositoryinventory -run '^TestScanRejectsFIFOReplacementWithoutBlocking$'" + } + ], + "commandIds": ["proofkit.go-test"], + "environmentClasses": ["local-go"] + }, { "requirementId": "REQ-PROOFKIT-SPEC-027", "scenarioId": "proofkit.spec-proof-core.adoption-plan-authority-closure", @@ -5487,9 +5530,55 @@ "selector": "TestBuildStackHintCannotChangeIntentTrustOrTasks", "command": "go test ./internal/command/adoptionplan -run '^TestBuildStackHintCannotChangeIntentTrustOrTasks$'" }, + { + "selector": "TestPlanIdentityBindsIntentAndInventory", + "command": "go test ./internal/command/adoptionplan -run '^TestPlanIdentityBindsIntentAndInventory$'" + }, + { + "selector": "TestBuildRejectsUnknownIntentPresetAndForgedInventory", + "command": "go test ./internal/command/adoptionplan -run '^TestBuildRejectsUnknownIntentPresetAndForgedInventory$'" + }, { "selector": "TestPlanWireAdmissionIsDeterministicAndOwnerClosed", "command": "go test ./internal/command/adoptionplan -run '^TestPlanWireAdmissionIsDeterministicAndOwnerClosed$'" + }, + { + "selector": "TestTextProjectionPreservesJSONPlanSemantics", + "command": "go test ./internal/command/adoptionplan -run '^TestTextProjectionPreservesJSONPlanSemantics$'" + }, + { + "selector": "TestAdoptionPlanOutputAndTextBoundsAreExact", + "command": "go test ./internal/command/adoptionplan -run '^TestAdoptionPlanOutputAndTextBoundsAreExact$'" + } + ], + "commandIds": ["proofkit.go-test"], + "environmentClasses": ["local-go"] + }, + { + "requirementId": "REQ-PROOFKIT-SPEC-027", + "scenarioId": "proofkit.spec-proof-core.adoption-plan-observational-stack", + "witnessId": "proofkit.adoption-plan.observational-stack-falsifier", + "witnessKind": "technical", + "witnessPath": "internal/command/adoptionplan/repository_classes_test.go", + "witnessSelectors": [ + { + "selector": "TestPlanKeepsRepositoryClassesObservationalAndStackNeutral", + "command": "go test ./internal/command/adoptionplan -run '^TestPlanKeepsRepositoryClassesObservationalAndStackNeutral$'" + } + ], + "commandIds": ["proofkit.go-test"], + "environmentClasses": ["local-go"] + }, + { + "requirementId": "REQ-PROOFKIT-SPEC-027", + "scenarioId": "proofkit.spec-proof-core.adoption-guidance-reference-closure", + "witnessId": "proofkit.adoption-plan.guidance-reference-falsifier", + "witnessKind": "contract", + "witnessPath": "internal/command/nativeevidenceguidance/guidance_test.go", + "witnessSelectors": [ + { + "selector": "TestGuidanceReferenceIsCompactAndOwnerBound", + "command": "go test ./internal/command/nativeevidenceguidance -run '^TestGuidanceReferenceIsCompactAndOwnerBound$'" } ], "commandIds": ["proofkit.go-test"], From 8814e36598fce8d8a52df370b8c0da8393dba433 Mon Sep 17 00:00:00 2001 From: iperev Date: Fri, 4 Sep 2026 12:02:51 +0200 Subject: [PATCH 3/4] fix: close adoption contract and carrier proof --- BACKLOG.md | 2 + .../requirements.v1.json | 4 +- .../proofkit-spec-proof-core/overview.md | 31 +- .../requirements.v1.json | 58 ++- .../requirements.v1.json | 4 +- .../app/adoption_front_door_command_test.go | 4 + .../adoption_front_door_version_edge_test.go | 103 +++++- internal/app/app.go | 2 +- internal/app/cli_contract_test.go | 52 ++- internal/app/command_contract_generated.go | 14 +- internal/app/command_descriptors.go | 20 +- internal/app/command_routes.go | 62 +--- .../app/testdata/v0.7-wire-observations.json | 26 +- .../command/adoptionplan/adoptionplan_test.go | 52 +-- internal/command/adoptionplan/text.go | 7 + internal/command/agentroute/agentroute.go | 2 +- .../command/agentroute/agentroute_test.go | 16 + .../json_report_cli_adapter_source.go | 19 +- .../json_report_cli_adapter_source_test.go | 12 +- .../command/repositoryinventory/admission.go | 4 +- internal/command/repositoryinventory/model.go | 4 +- .../open_candidate_other.go | 4 - .../open_candidate_unix.go | 4 - .../repositoryinventory_test.go | 40 +- internal/command/repositoryinventory/scan.go | 27 +- .../stackpreset/preset_ids_generated.go | 2 +- internal/kernel/commandroute/route.go | 53 +++ internal/kernel/commandroute/route_test.go | 48 +++ internal/tools/artifactfile/file.go | 28 +- internal/tools/artifactfile/file_unix_test.go | 35 ++ .../tools/artifactfile/open_source_other.go | 12 + .../tools/artifactfile/open_source_unix.go | 12 + internal/tools/commandcontractgen/main.go | 44 ++- .../tools/commandcontractgen/main_test.go | 37 +- internal/tools/coveragemetrics/main.go | 70 +++- .../tools/installedclicontract/contract.go | 97 +++-- .../installedclicontract/contract_test.go | 60 ++- internal/tools/packagebuild/main_test.go | 31 +- internal/tools/packageverify/main.go | 135 ++++--- internal/tools/packageverify/main_test.go | 201 +++++++--- .../tools/pythonpackage/continuation_test.go | 188 +++++++++- internal/tools/pythonpackage/metadata_test.go | 48 ++- internal/tools/pythonpackage/verify.go | 342 +++++++++++++----- internal/tools/releasechange/record_test.go | 6 +- .../workflowsmoke/workflow_smoke_test.go | 51 ++- proofkit/cli-contract.v2.json | 42 ++- proofkit/requirement-bindings.json | 213 ++++++++++- release/change-record.v2.json | 11 +- 48 files changed, 1844 insertions(+), 495 deletions(-) create mode 100644 internal/kernel/commandroute/route.go create mode 100644 internal/kernel/commandroute/route_test.go create mode 100644 internal/tools/artifactfile/file_unix_test.go create mode 100644 internal/tools/artifactfile/open_source_other.go create mode 100644 internal/tools/artifactfile/open_source_unix.go diff --git a/BACKLOG.md b/BACKLOG.md index b87c339..a6dacec 100644 --- a/BACKLOG.md +++ b/BACKLOG.md @@ -56,3 +56,5 @@ records, generated release manifests, or the owning docs named above. | BLOCKED | RELOCATION-01 | Add provenance-bounded witness relocation candidates without introducing a second binding path or trusting a caller-authored prior digest; detailed candidate contract is retained in [issue #66](https://github.com/research-engineering/agentic-proofkit/issues/66). | An owner-admitted content-addressed baseline binds witness id, prior path and digest, source revision, evidence class, authentication non-claims, and freshness non-claims; the scanner then proves the zero/one/many match partition while remaining non-current until fresh execution evidence exists. | | BLOCKED | RELEASE-01 | Prove signed protected-tag release policy as provider-side release governance, not source-only intent. | Repository tag protection/ruleset and release workflow variables require signed annotated release tags; the next public release records provider-side evidence or the row is explicitly retired as an accepted non-claim. | | BLOCKED | RELEASE-02 | Retire the inaccurate PyPI `0.1.159` wheel compatibility and license projection without mutating immutable release history. | After a public replacement release proves that each advertised macOS wheel minimum is no lower than its embedded Mach-O minimum, embedded MIT license identity, npm/PyPI/GitHub byte closure, and installed-package smoke, yank PyPI `0.1.159` with an exact compatibility-and-license reason and retain provider evidence of the yank. | +| DEFERRED | INSTALLED-CONSUMER-01 | Evaluate one carrier-neutral installed-contract and route-verification protocol without merging npm process transport with Python module transport. | Exact npm and wheel decision tables plus a shared mutant corpus first prove behavioral equivalence for contract admission, route/help identity, and byte-bound carrier checks; extract only the proven common protocol while retaining carrier-specific installation and execution owners, or retire the row if the common layer does not reduce semantic duplication. | +| DEFERRED | CLI-ARGS-01 | Evaluate one immutable typed parse result between descriptor admission and command execution instead of independently interpreting already-admitted command operands. | A reproducible descriptor-versus-handler drift falsifier establishes the defect class; a bounded prototype proves exact flag, multiplicity, value, help, input, and presentation parity across every affected command with no new ambient authority or generic option bag; otherwise retain the current bounded parsers and retire the row. | diff --git a/docs/specs/proofkit-package-boundary/requirements.v1.json b/docs/specs/proofkit-package-boundary/requirements.v1.json index 5858dee..aa0a1d7 100644 --- a/docs/specs/proofkit-package-boundary/requirements.v1.json +++ b/docs/specs/proofkit-package-boundary/requirements.v1.json @@ -62,7 +62,7 @@ { "requirementId": "REQ-PROOFKIT-PACKAGE-003", "ownerId": "proofkit.package-boundary", - "invariant": "The exact local root-package tarball remains installable and executable by an outside consumer on the current native platform. The proof byte-binds the packed package launcher and platform binary to their source-built bytes, verifies npm exec --offline resolution through root help and one contract-owned multi-token help route, then uses the exact admitted npm launcher profile to prove exhaustive root-help to family to leaf-help closure, stack-preset vocabulary, every generated preset continuation, one re-executed self-continuation, and the first valid README input without network fallback or a registry-publication claim.", + "invariant": "The exact local root-package tarball remains installable and executable by an outside consumer on the current native platform. The proof opens installed package resources through one bounded package-root-confined non-symlink boundary, byte-binds the installed launcher, selected platform binary, README, and complete CLI contract to their exact packed owner bytes, admits that contract before interpreting routes or presets, verifies npm exec --offline resolution through root help and one contract-owned multi-token help route, then uses the exact admitted npm launcher profile to prove exhaustive root-help to family to leaf-help closure, stack-preset vocabulary, every generated preset continuation, one re-executed self-continuation, and the first valid README input without network fallback or a registry-publication claim.", "claimLevel": "blocking", "riskClass": "medium", "proofBindingRefs": [ @@ -143,7 +143,7 @@ { "requirementId": "REQ-PROOFKIT-PACKAGE-006", "ownerId": "proofkit.package-boundary", - "invariant": "Python/uv distribution is a platform wheel wrapper over the same Go CLI, with wheel-safe package metadata, wheel tags, embedded binary identity, exact wheel-set version, uniqueness, presence, and SHA closure, local install smoke proof, POSIX exec process-identity and signal preservation, generated display and argv routes bound to the absolute active interpreter plus -m agentic_proofkit, successful direct-argv re-execution of an exact emitted self-continuation, the complete root-help to family-help to leaf-help chain, and an exact emitted agent-route argv with npm absent from PATH, and a marker-bounded README projection of the owned Python and platform matrix with explicit non-claims until PyPI publication.", + "invariant": "Python/uv distribution is a platform wheel wrapper over the same Go CLI, with wheel-safe package metadata, wheel tags, embedded binary identity, exact wheel-set version, uniqueness, presence, and SHA closure; the local consumer proof installs through an isolated offline no-dependency pip invocation, opens installed resources through one bounded package-root-confined non-symlink boundary, byte-binds and admits the exact embedded CLI contract plus executable before and after route execution, preserves POSIX exec process identity and signals, binds generated display and argv routes to the absolute active interpreter plus -m agentic_proofkit, re-executes one exact emitted self-continuation, proves the complete root-help to family-help to leaf-help chain and one exact emitted agent-route argv with npm absent from PATH, and verifies a marker-bounded README projection of the owned Python and platform matrix with explicit non-claims until PyPI publication.", "claimLevel": "blocking", "riskClass": "medium", "proofBindingRefs": [ diff --git a/docs/specs/proofkit-spec-proof-core/overview.md b/docs/specs/proofkit-spec-proof-core/overview.md index 894bbad..fd5ea2f 100644 --- a/docs/specs/proofkit-spec-proof-core/overview.md +++ b/docs/specs/proofkit-spec-proof-core/overview.md @@ -120,9 +120,11 @@ execution receipts, and merge policy. and emits only bounded candidate requirements, bindings, or owner guidance. - `REQ-PROOFKIT-SPEC-018`: an authored command-family catalog covers every public CLI command exactly once, deterministically generates the private - runtime navigation projection, adds token-bounded root discovery, and projects - exact stack-preset IDs into help and diagnostics while preserving existing - help invocation forms, process channels, no-input behavior, and leaf dispatch; + runtime navigation projection, while one reusable bounded token grammar owns + native routing, the public machine contract, generated adapters, and installed + npm and wheel route admission. It adds root discovery and projects exact + stack-preset IDs into help and diagnostics while preserving existing help + invocation forms, process channels, no-input behavior, and leaf dispatch; descriptor and help truth remains owned by `REQ-PROOFKIT-QUALITY-004`. - `REQ-PROOFKIT-SPEC-019`: explicit catalogs compose content-bound semantic schema-v2 context snapshots with exact expected-digest coverage through @@ -173,14 +175,21 @@ execution receipts, and merge policy. omission accounting, stable source-report identity, one top-level detail access record, and explicit full-envelope retrieval without repeating policy prose or promoting route guidance to authority. -- `REQ-PROOFKIT-SPEC-027`: the read-only `adopt plan` front door keeps explicit - source-trust intent separate from optional stack hints, composes a - candidate-only plan over a confined bounded root-catalog inventory and - content-bound child-owned evidence-guidance reference, validates arguments and - platform support before repository access, discards unknown names immediately - after batched catalog classification, and exposes owner-closed JSON plus a - bounded text projection with every authority non-claim without inferring - requirement meaning, writing files, or retaining the superseded `init` route. +- `REQ-PROOFKIT-SPEC-027`: the read-only `adopt plan` front door validates its + explicit trust intent, optional stack hint, repository root, and platform + before access, then composes only owner-admitted inventory and plan records. +- `REQ-PROOFKIT-SPEC-028`: repository inventory confines a fixed root catalog, + rejects unsafe file identities, enforces exact resource bounds, discards + unknown names, and emits one deterministic owner-re-admissible record. +- `REQ-PROOFKIT-SPEC-029`: adoption planning keeps stack observations separate + from trust, emits candidate-authoring tasks plus a digest-bound child guidance + reference, and never promotes inferred requirements or proof bindings. +- `REQ-PROOFKIT-SPEC-030`: adoption JSON and bounded text are deterministic + projections of one admitted plan, preserve authority non-claims, and apply + terminal styling only through an explicit presentation capability. +- `REQ-PROOFKIT-SPEC-031`: the adoption version edge binds exact ABI, command, + contract, generated-artifact, and release-change inventories while proving + that the retired `init` route has no remaining public owner. ## Non-Claims diff --git a/docs/specs/proofkit-spec-proof-core/requirements.v1.json b/docs/specs/proofkit-spec-proof-core/requirements.v1.json index a50d3a8..f1422ae 100644 --- a/docs/specs/proofkit-spec-proof-core/requirements.v1.json +++ b/docs/specs/proofkit-spec-proof-core/requirements.v1.json @@ -467,7 +467,7 @@ { "requirementId": "REQ-PROOFKIT-SPEC-018", "ownerId": "proofkit.spec-proof-core", - "invariant": "Command family navigation projects the complete public CLI command inventory from one authored family catalog into a deterministic private runtime projection, preserves root and per-command help invocation forms, stdout and stderr channel discipline, no-input behavior, and leaf dispatch, exposes one token-bounded root discovery route, and projects the exact stack-preset vocabulary from the authored CLI contract into generated runtime IDs, direct help, and invalid-ID diagnostics; it admits only opt-in family help forms, rejects missing, duplicated, oversized, unsorted, or reserved-operand-colliding families, and does not infer command selection, execute commands through family membership, or create a second command inventory owner.", + "invariant": "Command family navigation projects the complete public CLI command inventory from one authored family catalog into a deterministic private runtime projection, preserves root and per-command help invocation forms, stdout and stderr channel discipline, no-input behavior, and leaf dispatch, exposes one root discovery route, and projects the exact stack-preset vocabulary from the authored CLI contract into generated runtime IDs, direct help, and invalid-ID diagnostics. One reusable command-route grammar owner defines canonical token syntax, separator, cardinality, and strict no-prefix ambiguity; the public CLI contract carries its exact machine projection, contract generation rejects drift from the native owner, generated adapters consume that owner, and installed npm and wheel verifiers independently admit the shipped projection before interpreting route-bearing help. Family navigation admits only opt-in help forms, rejects missing, duplicated, oversized, unsorted, or reserved-operand-colliding families, and does not infer command selection, execute commands through family membership, or create a second command inventory owner.", "claimLevel": "blocking", "riskClass": "medium", "proofBindingRefs": [ @@ -598,12 +598,64 @@ { "requirementId": "REQ-PROOFKIT-SPEC-027", "ownerId": "proofkit.spec-proof-core", - "invariant": "The public read-only adoption front door is routed only as adopt plan, requires one explicit repository root and one explicit fresh, code-baseline, or audit-from-code intent, keeps an optional stack preset orthogonal to source trust, and validates every argument before filesystem access. Its repository inventory owner scans only a fixed root-file catalog through a confined root handle, fails before filesystem access on platforms without its nonblocking no-follow open boundary, rejects recognized symlink or non-regular substitutions, preflights root cardinality and observed file sizes before content reads, enforces per-file, actual aggregate-read, and output byte bounds, hashes only recognized regular UTF-8 file bytes, and uses unrecognized root entry names only for immediate catalog membership and counting without sorting, retaining beyond the current read batch, or disclosing them. Inventory and adoption-plan outputs bind deterministic identities and re-admit unchanged through their child owners; the plan emits only candidate-authoring tasks and a child-owned native-evidence guidance reference whose digest binds the complete current guidance, never synthesizes requirement meaning or proof bindings, records code-baseline solely as an explicit caller declaration, and cannot change trust or task semantics through a stack hint. JSON and bounded text are projections of the same typed plan, every authority non-claim remains visible in text, terminal styling is capability-gated, and the superseded init route has no active descriptor, dispatcher, contract, family, or package owner.", + "invariant": "The public read-only adoption front door is routed only as adopt plan, requires one explicit repository root and one explicit fresh, code-baseline, or audit-from-code intent, keeps an optional stack preset orthogonal to source trust, validates every argument and platform support before filesystem access, and composes its result only from an owner-admitted repository inventory and adoption plan without writing files, executing commands, or promoting candidate artifacts.", "claimLevel": "blocking", "riskClass": "high", "proofBindingRefs": ["proofkit/requirement-bindings.json"], "nonClaimRefs": ["NC-PROOFKIT-SPEC-027"], - "nonClaims": ["The adoption front door does not infer repository stack identity, inspect arbitrary source semantics, decide product meaning, establish code correctness, write files, execute commands or native witnesses, promote candidates, authenticate freshness, approve merge or release, or establish rollout or production readiness."], + "nonClaims": ["The adoption front door does not write files, execute commands or native witnesses, promote candidate artifacts, authenticate freshness, approve merge or release, or establish rollout or production readiness."], + "lifecycle": {"state": "active", "replacementRequirementIds": [], "evidenceRefs": []}, + "deferral": null, + "updatePolicy": {"reviewOwnerId": "proofkit.spec-proof-core", "requiresImpactDeclaration": true, "requiresProofBindingReview": true} + }, + { + "requirementId": "REQ-PROOFKIT-SPEC-028", + "ownerId": "proofkit.spec-proof-core", + "invariant": "The adoption repository-inventory owner scans only a fixed root-file catalog through a confined root handle, fails before filesystem access on platforms without its nonblocking no-follow open boundary, rejects recognized symlink or non-regular substitutions, preflights root cardinality and observed file sizes before content reads, enforces per-file, actual aggregate-read, and output byte bounds, hashes only recognized regular UTF-8 file bytes, discards unrecognized root entry names immediately after bounded classification, and emits one deterministic identity-bound output that re-admits unchanged through the same owner.", + "claimLevel": "blocking", + "riskClass": "high", + "proofBindingRefs": ["proofkit/requirement-bindings.json"], + "nonClaimRefs": ["NC-PROOFKIT-SPEC-028"], + "nonClaims": ["Repository inventory does not inspect arbitrary source semantics, infer repository intent or stack identity, authenticate checkout freshness, or establish code correctness."], + "lifecycle": {"state": "active", "replacementRequirementIds": [], "evidenceRefs": []}, + "deferral": null, + "updatePolicy": {"reviewOwnerId": "proofkit.spec-proof-core", "requiresImpactDeclaration": true, "requiresProofBindingReview": true} + }, + { + "requirementId": "REQ-PROOFKIT-SPEC-029", + "ownerId": "proofkit.spec-proof-core", + "invariant": "The adoption-plan owner consumes one fully admitted repository inventory, binds deterministic plan and inventory identities, emits only candidate-authoring tasks and one child-owned native-evidence guidance reference whose digest binds the complete current guidance, records code-baseline solely as an explicit caller declaration, keeps every stack hint observational so it cannot change trust or task semantics, and re-admits its output unchanged without synthesizing requirement meaning or proof bindings.", + "claimLevel": "blocking", + "riskClass": "high", + "proofBindingRefs": ["proofkit/requirement-bindings.json"], + "nonClaimRefs": ["NC-PROOFKIT-SPEC-029"], + "nonClaims": ["The adoption plan does not make candidate requirements, contracts, bindings, or tests authoritative and does not authenticate evidence, execute witnesses, or approve a change."], + "lifecycle": {"state": "active", "replacementRequirementIds": [], "evidenceRefs": []}, + "deferral": null, + "updatePolicy": {"reviewOwnerId": "proofkit.spec-proof-core", "requiresImpactDeclaration": true, "requiresProofBindingReview": true} + }, + { + "requirementId": "REQ-PROOFKIT-SPEC-030", + "ownerId": "proofkit.spec-proof-core", + "invariant": "Adoption-plan JSON and bounded text are deterministic projections of the same fully admitted typed plan, text preserves every authority non-claim and identity needed to recover the machine record, output and text byte bounds are exact, and terminal styling occurs only when explicitly requested or supported by the caller-provided presentation capability.", + "claimLevel": "blocking", + "riskClass": "medium", + "proofBindingRefs": ["proofkit/requirement-bindings.json"], + "nonClaimRefs": ["NC-PROOFKIT-SPEC-030"], + "nonClaims": ["The text projection is a human view, not requirement, proof, execution, merge, release, rollout, deployment, or readiness authority."], + "lifecycle": {"state": "active", "replacementRequirementIds": [], "evidenceRefs": []}, + "deferral": null, + "updatePolicy": {"reviewOwnerId": "proofkit.spec-proof-core", "requiresImpactDeclaration": true, "requiresProofBindingReview": true} + }, + { + "requirementId": "REQ-PROOFKIT-SPEC-031", + "ownerId": "proofkit.spec-proof-core", + "invariant": "The adoption-front-door public version edge binds the exact previous and current public ABI digests, the exact removal of init, the exact addition of adopt plan and repository-inventory, one explicit selection policy covering every command whose declared semantic contract identity or wire schema identity changed while leaving source-bound digest churn to the enclosing ABI digests, every changed generated artifact identity, and the ordered breaking and additive change inventories to one digest-bound release change record; the superseded init route has no active descriptor, dispatcher, CLI contract, command family, or package owner.", + "claimLevel": "blocking", + "riskClass": "high", + "proofBindingRefs": ["proofkit/requirement-bindings.json"], + "nonClaimRefs": ["NC-PROOFKIT-SPEC-031"], + "nonClaims": ["A source-bound version edge does not authenticate Git history, registry publication, provider ingestion, consumer migration, rollout, or production readiness."], "lifecycle": {"state": "active", "replacementRequirementIds": [], "evidenceRefs": []}, "deferral": null, "updatePolicy": {"reviewOwnerId": "proofkit.spec-proof-core", "requiresImpactDeclaration": true, "requiresProofBindingReview": true} diff --git a/docs/specs/proofkit-supply-chain-quality/requirements.v1.json b/docs/specs/proofkit-supply-chain-quality/requirements.v1.json index 05b633d..144c1cd 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, descriptor-owned command proof-route candidates, and a separate execution-backed command-oracle ledger. The ledger must bind every candidate commandRef, selector, concrete negative case, falsification event, assertion oracle, expected public outcome, and owner invariant to one immutable exact-file materialized source snapshot; execute exactly the selected active Go tests through canonically ordered package-scoped argv vectors that cannot select same-named non-candidate tests in another package; require unique package start and pass events, unique test run and pass events, owner-reserved cooperative attributes for every candidate, bounded cancellable subprocesses, and immediate process-group termination on output-limit violation; reject missing, unknown, duplicate, skipped, failed, reordered, context-invalid, spoofed-output, cross-test, source-drift, producer-unreachable candidate projections, and correlated identity substitutions; publish and invalidate local diagnostic and metrics artifacts atomically through repository-confined non-symlink destinations; revalidate current candidate, corpus, runtime, and source identities before and after artifact publication; and fail closed when any public command lacks execution-backed evidence. An independently authored versioned counterfeit corpus must bijectively cover every shipped evidence class, required policy axis, record coordinate, and declared substitution axis with positive controls and checked-in exact expected decisions evaluated through the production admission and lifecycle owners rather than a generated expectation registry. Static route metadata, prose, legacy source markers, test existence, and failure-capable syntax remain proof-route candidates and cannot satisfy the execution-backed closure.", + "invariant": "Coverage metrics report requirement, binding, witness, CLI inventory linkage, descriptor-owned command proof-route candidates, and a separate execution-backed command-oracle ledger. The ledger must bind every candidate commandRef, selector, concrete negative case, falsification event, assertion oracle, expected public outcome, and owner invariant to one immutable exact-file materialized source snapshot; execute exactly the selected active Go tests through canonically ordered package-scoped argv vectors that cannot select same-named non-candidate tests in another package; require unique package start and pass events, unique test run and pass events, owner-reserved cooperative attributes for every candidate, bounded cancellable subprocesses, and immediate process-group termination on output-limit violation; reject missing, unknown, duplicate, skipped, failed, reordered, context-invalid, spoofed-output, cross-test, source-drift, producer-unreachable candidate projections, and correlated identity substitutions; publish and invalidate local diagnostic and metrics artifacts atomically through repository-confined non-symlink destinations; on supported release platforms, open retained input artifacts descriptor-first through a nonblocking no-follow boundary before type, identity, and exact byte-limit checks so named pipes, symlinks, path substitution, and post-read mutation cannot block or escape, and fail closed instead of using a weaker file-open path elsewhere; revalidate current candidate, corpus, runtime, and source identities before and after artifact publication; and fail closed when any public command lacks execution-backed evidence. An independently authored versioned counterfeit corpus must bijectively cover every shipped evidence class, required policy axis, record coordinate, and declared substitution axis with positive controls and checked-in exact expected decisions evaluated through the production admission and lifecycle owners rather than a generated expectation registry. Static route metadata, prose, legacy source markers, test existence, and failure-capable syntax remain proof-route candidates and cannot satisfy the execution-backed closure.", "claimLevel": "blocking", "riskClass": "medium", "proofBindingRefs": ["proofkit/requirement-bindings.json"], @@ -298,7 +298,7 @@ { "requirementId": "REQ-PROOFKIT-QUALITY-023", "ownerId": "proofkit.supply-chain-quality", - "invariant": "Every npm platform-binary tar entry and every Python wheel independently binds its embedded executable bytes to the same release-platform binary, and release metadata generation admits every final npm tarball, wheel, and SBOM exactly once into one bounded regular non-symlink immutable snapshot, derives npm pack SHA-1 and SRI claims, final carrier decoding, direct chunked binary equality, wheel and embedded-binary digests, release checksums, and SBOM-subject checksums only from that epoch, requires every npm pack and Python package-set digest claim to equal those decoded bytes, and revalidates every source identity before metadata persistence without relying on a mutable intermediate dist directory or claimed digests; release closeout independently reopens every declared repository-confined npm tarball and wheel through bounded identity-checked non-symlink handles, recomputes npm SHA-1 and SRI plus both wheel and embedded-binary digests, and rejects unsafe or duplicate archive topology before accepting package evidence; both tar and wheel decoders reject duplicate entry names and require exact release-platform closure; the wheel-set verifier independently rejects version drift, duplicate identities, missing files, and SHA drift; each wheel independently decodes Darwin Mach-O bytes so an advertised macOS platform minimum is never lower than the executable minimum, uses Core Metadata 2.4 License-Expression and License-File fields, carries an exact repository LICENSE copy under the wheel licenses directory, and closes every payload entry through RECORD.", + "invariant": "Every npm platform-binary tar entry and every Python wheel independently binds its embedded executable bytes to the same release-platform binary, while an independently asserted complete OS/architecture-to-suffix bijection proves that every launcher target selects exactly one owned platform payload. Release metadata generation admits every final npm tarball, wheel, and SBOM exactly once into one bounded regular non-symlink immutable snapshot, derives npm pack SHA-1 and SRI claims, final carrier decoding, direct chunked binary equality, wheel and embedded-binary digests, release checksums, and SBOM-subject checksums only from that epoch, requires every npm pack and Python package-set digest claim to equal those decoded bytes, and revalidates every source identity before metadata persistence without relying on a mutable intermediate dist directory or claimed digests; release closeout independently reopens every declared repository-confined npm tarball and wheel through bounded identity-checked non-symlink handles, recomputes npm SHA-1 and SRI plus both wheel and embedded-binary digests, and rejects unsafe or duplicate archive topology before accepting package evidence; both tar and wheel decoders reject duplicate entry names, preflight bounded archive and entry cardinality plus compressed and uncompressed sizes before decompression, cap every decoded payload, and require exact release-platform closure; the wheel-set verifier independently rejects version drift, duplicate identities, missing files, and SHA drift; each wheel independently decodes Darwin Mach-O bytes so an advertised macOS platform minimum is never lower than the executable minimum, uses Core Metadata 2.4 License-Expression and License-File fields, carries an exact repository LICENSE copy under the wheel licenses directory, and closes every payload entry through RECORD.", "claimLevel": "blocking", "riskClass": "critical", "proofBindingRefs": ["proofkit/requirement-bindings.json"], diff --git a/internal/app/adoption_front_door_command_test.go b/internal/app/adoption_front_door_command_test.go index 7b126c1..a882bd0 100644 --- a/internal/app/adoption_front_door_command_test.go +++ b/internal/app/adoption_front_door_command_test.go @@ -162,6 +162,10 @@ func TestAdoptionFrontDoorCLI(t *testing.T) { if status != 1 || stdout != "" || stderr != "unsupported help target: adopt\n" { t.Fatalf("abbreviated help status=%d stderr=%q stdout=%q", status, stderr, stdout) } + status, stdout, stderr = executeAgentWorkflowCLI(t, []string{"adopt", "plan", "--mode", "fresh", "--help"}, panicReader{}, PresentationCapabilities{}) + if status != 1 || stdout != "" || stderr != "adopt plan help accepts no additional arguments\n" { + t.Fatalf("exclusive help status=%d stderr=%q stdout=%q", status, stderr, stdout) + } }) } diff --git a/internal/app/adoption_front_door_version_edge_test.go b/internal/app/adoption_front_door_version_edge_test.go index 828712c..083c308 100644 --- a/internal/app/adoption_front_door_version_edge_test.go +++ b/internal/app/adoption_front_door_version_edge_test.go @@ -24,6 +24,8 @@ type adoptionFrontDoorVersionEdge struct { BreakingChangeIDs []string `json:"breakingChangeIds"` ChangeRecordRef string `json:"changeRecordRef"` ChangeRecordSHA256 string `json:"changeRecordSha256"` + CommandContractSelection string `json:"commandContractSelection"` + ChangedCommandContracts []adoptionChangedCommandContract `json:"changedCommandContracts"` ChangedGeneratedArtifacts []adoptionChangedGeneratedArtifact `json:"changedGeneratedArtifacts"` CurrentPublicABISHA256 string `json:"currentPublicAbiSha256"` EdgeID string `json:"edgeId"` @@ -55,6 +57,17 @@ type adoptionChangedGeneratedArtifact struct { PreviousSourceSHA256 string `json:"previousSourceSha256"` } +type adoptionChangedCommandContract struct { + Command string `json:"command"` + CurrentInputContractID string `json:"currentInputContractId"` + CurrentInputContractSHA256 string `json:"currentInputContractSha256"` + CurrentOutputContractSHA256 string `json:"currentOutputContractSha256"` + PreviousInputContractID string `json:"previousInputContractId"` + PreviousInputContractSHA256 string `json:"previousInputContractSha256"` + PreviousOutputContractSHA256 string `json:"previousOutputContractSha256"` + WireSchemaVersion int `json:"wireSchemaVersion"` +} + func TestAdoptionFrontDoorVersionEdgeClosesInitRetirement(t *testing.T) { record := readAdoptionFrontDoorVersionEdge(t) currentPublicABI := "sha256:" + currentCLIContractPublicABISHA256(t) @@ -77,6 +90,13 @@ func TestAdoptionFrontDoorVersionEdgeClosesInitRetirement(t *testing.T) { func(value *adoptionFrontDoorVersionEdge) { value.BreakingChangeIDs[0] += ".drift" }, func(value *adoptionFrontDoorVersionEdge) { value.AdditionChangeIDs = value.AdditionChangeIDs[1:] }, func(value *adoptionFrontDoorVersionEdge) { value.ChangeRecordSHA256 += "0" }, + func(value *adoptionFrontDoorVersionEdge) { value.CommandContractSelection = "all_digest_changes" }, + func(value *adoptionFrontDoorVersionEdge) { + value.ChangedCommandContracts[0].CurrentInputContractID = value.ChangedCommandContracts[0].PreviousInputContractID + }, + func(value *adoptionFrontDoorVersionEdge) { + value.ChangedCommandContracts[0].CurrentOutputContractSHA256 = value.ChangedCommandContracts[0].PreviousOutputContractSHA256 + }, func(value *adoptionFrontDoorVersionEdge) { value.ChangedGeneratedArtifacts[0].PreviousSourceSHA256 = value.ChangedGeneratedArtifacts[0].CurrentSourceSHA256 }, @@ -95,6 +115,41 @@ func TestAdoptionFrontDoorVersionEdgeClosesInitRetirement(t *testing.T) { } } +func TestAdoptionFrontDoorVersionEdgeRejectsDigestBoundInventoryContradiction(t *testing.T) { + record := readAdoptionFrontDoorVersionEdge(t) + content, err := os.ReadFile(filepath.Join(repoRoot(t), record.ChangeRecordRef)) + if err != nil { + t.Fatal(err) + } + value, err := admission.DecodeJSON(bytes.NewReader(content), int64(len(content))) + if err != nil { + t.Fatal(err) + } + root := value.(map[string]any) + breaking := root["breakingChanges"].([]any) + breaking[0].(map[string]any)["changeId"] = "proofkit.adoption.init-retired.drift" + mutantContent, err := json.MarshalIndent(root, "", " ") + if err != nil { + t.Fatal(err) + } + mutantContent = append(mutantContent, '\n') + mutantRoot := t.TempDir() + path := filepath.Join(mutantRoot, filepath.FromSlash(record.ChangeRecordRef)) + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, mutantContent, 0o600); err != nil { + t.Fatal(err) + } + mutant := cloneAdoptionFrontDoorVersionEdge(record) + digest := sha256.Sum256(mutantContent) + mutant.ChangeRecordSHA256 = fmt.Sprintf("sha256:%x", digest) + currentPublicABI := "sha256:" + currentCLIContractPublicABISHA256(t) + if err := validateAdoptionFrontDoorVersionEdge(mutant, mutantRoot, currentPublicABI); err == nil || !strings.Contains(err.Error(), "contradicts") { + t.Fatalf("coordinated change-record mutant error=%v, want inventory contradiction", err) + } +} + func TestRetiredInitRouteHasNoPublicDispatcher(t *testing.T) { status, stdout, stderr := executeAgentWorkflowCLI(t, []string{"init"}, panicReader{}, PresentationCapabilities{}) if status != 1 || stdout != "" || !strings.Contains(stderr, "unsupported command: init") { @@ -119,7 +174,7 @@ func readAdoptionFrontDoorVersionEdge(t *testing.T) adoptionFrontDoorVersionEdge if !ok { t.Fatal("adoption front-door version edge must be an object") } - assertExactObjectKeys(t, root, []string{"addedCommandContracts", "additionChangeIds", "breakingChangeIds", "changeRecordRef", "changeRecordSha256", "changedGeneratedArtifacts", "currentPublicAbiSha256", "edgeId", "evidenceClass", "nonClaims", "previousPublicAbiSha256", "previousVersion", "removedCommandContract", "schemaVersion", "version"}, "adoption front-door version edge") + assertExactObjectKeys(t, root, []string{"addedCommandContracts", "additionChangeIds", "breakingChangeIds", "changeRecordRef", "changeRecordSha256", "changedCommandContracts", "changedGeneratedArtifacts", "commandContractSelection", "currentPublicAbiSha256", "edgeId", "evidenceClass", "nonClaims", "previousPublicAbiSha256", "previousVersion", "removedCommandContract", "schemaVersion", "version"}, "adoption front-door version edge") removed, ok := root["removedCommandContract"].(map[string]any) if !ok { t.Fatal("removed command contract must be an object") @@ -147,6 +202,17 @@ func readAdoptionFrontDoorVersionEdge(t *testing.T) adoptionFrontDoorVersionEdge } assertExactObjectKeys(t, item, []string{"artifactKind", "currentSourceSha256", "generatorId", "previousSourceSha256"}, fmt.Sprintf("changed generated artifact %d", index)) } + changedContracts, ok := root["changedCommandContracts"].([]any) + if !ok { + t.Fatal("changed command contracts must be an array") + } + for index, raw := range changedContracts { + item, ok := raw.(map[string]any) + if !ok { + t.Fatalf("changed command contract %d must be an object", index) + } + assertExactObjectKeys(t, item, []string{"command", "currentInputContractId", "currentInputContractSha256", "currentOutputContractSha256", "previousInputContractId", "previousInputContractSha256", "previousOutputContractSha256", "wireSchemaVersion"}, fmt.Sprintf("changed command contract %d", index)) + } var record adoptionFrontDoorVersionEdge if err := json.Unmarshal(content, &record); err != nil { t.Fatal(err) @@ -161,6 +227,9 @@ func validateAdoptionFrontDoorVersionEdge(record adoptionFrontDoorVersionEdge, r if record.PreviousVersion != "0.6.0" || record.Version != "0.7.0" { return fmt.Errorf("adoption front-door version-edge release identity is stale") } + if record.CommandContractSelection != "declared_semantic_identity_or_wire_schema_change" { + return fmt.Errorf("adoption front-door command-contract selection policy is invalid") + } if record.PreviousPublicABISHA256 != "sha256:163f06bf6fc94f15040fecf3e352d4600a8611a227e26f35369b7fe97e90bde5" || record.CurrentPublicABISHA256 != currentPublicABI || record.PreviousPublicABISHA256 == record.CurrentPublicABISHA256 { return fmt.Errorf("adoption front-door version-edge ABI identity is invalid") } @@ -177,18 +246,33 @@ func validateAdoptionFrontDoorVersionEdge(record adoptionFrontDoorVersionEdge, r } wantGeneratedArtifacts := []adoptionChangedGeneratedArtifact{{ ArtifactKind: "proofkit.json-report-cli-adapter-source", - CurrentSourceSHA256: "sha256:329b88b6b134dc30fb3704d32ac9708fc01608b9df68815bc9585108971be37d", + CurrentSourceSHA256: "sha256:62c34f1b920466f157d32d982fd5dd8355cbfb023eeda342e8cdbe5c15d731a0", GeneratorID: jsonreportcliadaptersource.TypeScriptGeneratorID, PreviousSourceSHA256: "sha256:a171cc1b95c6078b7190ac50fc9fd298db8f42bfc9b65bbb67fa77d63dc04a93", }} if !slices.Equal(record.ChangedGeneratedArtifacts, wantGeneratedArtifacts) { return fmt.Errorf("adoption front-door changed generated artifacts are not exact") } + wantChangedContracts := []adoptionChangedCommandContract{{ + Command: "agent-route", + CurrentInputContractID: "proofkit.agent-route.input.v2", + CurrentInputContractSHA256: generatedCommandContractMetadataByName["agent-route"].InputContractSHA256, + CurrentOutputContractSHA256: generatedCommandContractMetadataByName["agent-route"].OutputContractSHA256, + PreviousInputContractID: "proofkit.agent-route.input.v1", + PreviousInputContractSHA256: "sha256:4fc7b2e5ffe3ed632e5e84d20e5ae26f9ace11df614bc9aec680853e60809ebd", + PreviousOutputContractSHA256: "sha256:485d62afc2e5ed07c28f557b0d1069f167b3838abe0aed248e9ff94f3e25c0ad", + WireSchemaVersion: 1, + }} + if !slices.Equal(record.ChangedCommandContracts, wantChangedContracts) || + record.ChangedCommandContracts[0].CurrentInputContractID == record.ChangedCommandContracts[0].PreviousInputContractID || + record.ChangedCommandContracts[0].CurrentOutputContractSHA256 == record.ChangedCommandContracts[0].PreviousOutputContractSHA256 { + return fmt.Errorf("adoption front-door changed command contracts are not exact") + } currentSourceDigest := sha256.Sum256([]byte(jsonreportcliadaptersource.TypeScriptSource())) if record.ChangedGeneratedArtifacts[0].CurrentSourceSHA256 != fmt.Sprintf("sha256:%x", currentSourceDigest) { return fmt.Errorf("adoption front-door generated adapter source identity is stale") } - if !slices.Equal(record.BreakingChangeIDs, []string{"proofkit.adoption.init-retired"}) || !slices.Equal(record.AdditionChangeIDs, []string{"proofkit.adoption.front-door", "proofkit.adoption.repository-inventory", "proofkit.cli.generated-adapter-command-routes", "proofkit.cli.hierarchical-command-routes", "proofkit.python-wheel.embedded-cli-contract"}) { + if !slices.Equal(record.BreakingChangeIDs, []string{"proofkit.adoption.init-retired", "proofkit.agent-route.input-contract-v2"}) || !slices.Equal(record.AdditionChangeIDs, []string{"proofkit.adoption.front-door", "proofkit.adoption.repository-inventory", "proofkit.cli.generated-adapter-command-routes", "proofkit.cli.hierarchical-command-routes", "proofkit.python-wheel.embedded-cli-contract"}) { return fmt.Errorf("adoption front-door change inventory is not exact") } if record.ChangeRecordRef != "release/change-record.v2.json" { @@ -209,6 +293,9 @@ func validateAdoptionFrontDoorVersionEdge(record adoptionFrontDoorVersionEdge, r if changeRecord.PreviousVersion != record.PreviousVersion || changeRecord.Version != record.Version || !changeRecord.Migration.Required { return fmt.Errorf("adoption front-door change record identity is inconsistent") } + if !slices.Equal(record.BreakingChangeIDs, releaseChangeIDs(changeRecord.BreakingChanges)) || !slices.Equal(record.AdditionChangeIDs, releaseChangeIDs(changeRecord.Additions)) { + return fmt.Errorf("adoption front-door change inventory contradicts the bound change record") + } wantMigrationSteps := []string{ "Replace explicit init --preset fresh with adopt plan --mode fresh --repo-root .", "Replace init --preset code-baseline with adopt plan --mode code-baseline --repo-root , and replace init --preset code-audit with adopt plan --mode audit-from-code --repo-root .", @@ -216,6 +303,7 @@ func validateAdoptionFrontDoorVersionEdge(record adoptionFrontDoorVersionEdge, r "Replace init --preset change-set with changed-path-set followed by the explicit impact and selective-gate composition routes required by the consuming repository.", "Replace bare init or init --preset all with help families, then select the smallest applicable bounded route rather than materializing every route family.", "Regenerate any materialized TypeScript CLI adapter source before invoking a multi-token route such as adopt plan; one-token adapter calls remain compatible.", + "Replace persisted proofkit.agent-route.input.v1 contract identity with proofkit.agent-route.input.v2; the admitted wire schemaVersion remains 1.", } if !slices.Equal(changeRecord.Migration.Steps, wantMigrationSteps) { return fmt.Errorf("adoption front-door migration semantics are not exact") @@ -226,6 +314,14 @@ func validateAdoptionFrontDoorVersionEdge(record adoptionFrontDoorVersionEdge, r return nil } +func releaseChangeIDs(changes []releasechange.Change) []string { + ids := make([]string, len(changes)) + for index, change := range changes { + ids[index] = change.ChangeID + } + return ids +} + func equalAdoptionCommandContract(left, right adoptionFrontDoorCommandContract) bool { return left.Command == right.Command && left.OutputContractSHA256 == right.OutputContractSHA256 && slices.Equal(left.Route, right.Route) } @@ -237,6 +333,7 @@ func cloneAdoptionFrontDoorVersionEdge(record adoptionFrontDoorVersionEdge) adop } record.AdditionChangeIDs = append([]string(nil), record.AdditionChangeIDs...) record.BreakingChangeIDs = append([]string(nil), record.BreakingChangeIDs...) + record.ChangedCommandContracts = append([]adoptionChangedCommandContract(nil), record.ChangedCommandContracts...) record.ChangedGeneratedArtifacts = append([]adoptionChangedGeneratedArtifact(nil), record.ChangedGeneratedArtifacts...) record.NonClaims = append([]string(nil), record.NonClaims...) return record diff --git a/internal/app/app.go b/internal/app/app.go index 5e24d89..18afc7d 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -56,7 +56,7 @@ func RunWithRendererAndCapabilities(ctx context.Context, args []string, stdin io } parsedArguments := classifyDescriptorArguments(descriptor, args[1:]) if descriptor.runner != commandRunnerHelp && (parsedArguments.present["--help"] || parsedArguments.present["-h"]) { - writeDiagnosticf(stderr, "%s help accepts no additional arguments", descriptor.name) + writeDiagnosticf(stderr, "%s help accepts no additional arguments", commandRouteText(descriptor.routeTokens)) return 1 } if err := validateFlagConstraints(descriptor, parsedArguments); err != nil { diff --git a/internal/app/cli_contract_test.go b/internal/app/cli_contract_test.go index afdb5a6..b646412 100644 --- a/internal/app/cli_contract_test.go +++ b/internal/app/cli_contract_test.go @@ -18,12 +18,13 @@ import ( "github.com/research-engineering/agentic-proofkit/internal/command/agentroute" "github.com/research-engineering/agentic-proofkit/internal/command/stackpreset" "github.com/research-engineering/agentic-proofkit/internal/kernel/admission" + "github.com/research-engineering/agentic-proofkit/internal/kernel/commandroute" "github.com/research-engineering/agentic-proofkit/internal/kernel/stablejson" "github.com/research-engineering/agentic-proofkit/internal/testsupport/commandcoverage" ) const ( - cliContractPublicABISHA256 = "5a238b80bfdbe22f0e1b76850dab9568c89faf303e4653bf017cdf9535f3d7ae" + cliContractPublicABISHA256 = "c3b7219fccd7d400b182beb53715f69758e02a4fef6f9465ba0c80a866abd1c7" maxAggregateFileReadBytesForContractTest = 64 << 20 maxPackageManifestBytesForContractTest = 256 << 10 maxSourceFileBytesForContractTest = 8 << 20 @@ -905,6 +906,15 @@ func resolveCLIContractDefinition(t *testing.T, id string, definitions map[strin } func TestCommandDescriptorContractParityRejectsMutations(t *testing.T) { + t.Run("missing generated metadata", func(t *testing.T) { + defer func() { + if recovered := recover(); recovered == nil || !strings.Contains(fmt.Sprint(recovered), "missing generated contract metadata") { + t.Fatalf("descriptor panic = %v, want generated metadata failure", recovered) + } + }() + _ = command("missing-generated-command", commandInputNone, nil, modes("text"), nil) + }) + contract := readCLIContract(t) cases := []struct { name string @@ -912,9 +922,18 @@ func TestCommandDescriptorContractParityRejectsMutations(t *testing.T) { commands []cliContractCommand }{ { - name: "descriptor only command", - descriptors: append(cloneCommandDescriptors(commandDescriptors), command("descriptor-only", commandInputNone, flags("--help"), modes("text"), ownerDirs("descriptoronly"), withRunner(commandRunnerHelp))), - commands: contract.Commands, + name: "descriptor only command", + descriptors: append(cloneCommandDescriptors(commandDescriptors), commandDescriptor{ + name: "descriptor-only", + routeTokens: []string{"descriptor-only"}, + input: commandInputNone, + runner: commandRunnerHelp, + scopeClass: commandScopeBuiltInPackageCatalog, + allowedFlags: flags("--help"), + outputModes: modes("text"), + semanticOwnerDirs: ownerDirs("descriptoronly"), + }), + commands: contract.Commands, }, { name: "contract only command", @@ -1032,7 +1051,16 @@ func TestCommandDescriptorTopologyRejectsInvalidScopeClass(t *testing.T) { } func TestCommandDescriptorTopologyRejectsImplicitPlanningRoute(t *testing.T) { - descriptors := append(cloneCommandDescriptors(commandDescriptors), command("new-planning-command", commandInputRequired, flags("--input"), modes("json"), ownerDirs("newplanning"), withRunner(commandRunnerPlanning))) + descriptors := append(cloneCommandDescriptors(commandDescriptors), commandDescriptor{ + name: "new-planning-command", + routeTokens: []string{"new-planning-command"}, + input: commandInputRequired, + runner: commandRunnerPlanning, + scopeClass: commandScopeExplicitCallerInput, + allowedFlags: flags("--input"), + outputModes: modes("json"), + semanticOwnerDirs: ownerDirs("newplanning"), + }) if problems := commandDescriptorTopologyProblems(descriptors); len(problems) == 0 { t.Fatal("planning runner descriptor without explicit route was admitted") } @@ -1692,7 +1720,19 @@ func assertCLIContractSchema(t *testing.T) { if err := json.Unmarshal(record["processContract"], &processContract); err != nil { t.Fatalf("decode process contract: %v", err) } - assertKeys(t, "CLI process contract", keys(processContract), []string{"failureExitCode", "globalOptions", "helpGrammar", "stderr", "stdout", "successExitCode"}) + assertKeys(t, "CLI process contract", keys(processContract), []string{"commandRouteGrammar", "failureExitCode", "globalOptions", "helpGrammar", "stderr", "stdout", "successExitCode"}) + var routeGrammar map[string]any + if err := json.Unmarshal(processContract["commandRouteGrammar"], &routeGrammar); err != nil { + t.Fatalf("decode command route grammar: %v", err) + } + assertKeys(t, "CLI command route grammar", keysAny(routeGrammar), []string{"ambiguityPolicy", "maximumTokens", "minimumTokens", "separator", "tokenPattern"}) + if routeGrammar["minimumTokens"] != float64(commandroute.MinimumTokens) || + routeGrammar["maximumTokens"] != float64(commandroute.MaximumTokens) || + routeGrammar["separator"] != commandroute.Separator || + routeGrammar["tokenPattern"] != commandroute.TokenPattern || + routeGrammar["ambiguityPolicy"] != commandroute.AmbiguityPolicy { + t.Fatalf("CLI command route grammar does not match runtime owner: %#v", routeGrammar) + } var globalOptions map[string]any if err := json.Unmarshal(processContract["globalOptions"], &globalOptions); err != nil { t.Fatalf("decode global options: %v", err) diff --git a/internal/app/command_contract_generated.go b/internal/app/command_contract_generated.go index dd9d898..ea8abbe 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 = "c7de8b5f737b7616095b83cd19204761b2e188f17e5f461ede10c3f1d6095447" +const commandContractSourceSHA256 = "e0c7484b588a119947ee6f2568ecc44fdb5e93c3a6fa1d61e677bde45df682fe" type generatedCommandContractMetadata struct { InputContractSHA256 string @@ -12,12 +12,12 @@ type generatedCommandContractMetadata struct { } var generatedCommandContractMetadataByName = map[string]generatedCommandContractMetadata{ - "adopt-plan": {InputContractSHA256: "", InputSchemaSummary: []string(nil), OutputContractSHA256: "sha256:16583d956455cc360a13382e0d783e5590a2a19adedbb4bf9fc7b6dd10ee35b8", FlagChoices: map[string][]string{"--color": []string{"auto", "never"}, "--format": []string{"json", "text"}, "--mode": []string{"audit-from-code", "code-baseline", "fresh"}, "--stack": []string{"agentic_runtime_repo", "generated_docs_contract_repo", "python_service", "python_typescript_service", "typescript_monorepo", "typescript_workspace"}}, RouteTokens: []string{"adopt", "plan"}}, + "adopt-plan": {InputContractSHA256: "", InputSchemaSummary: []string(nil), OutputContractSHA256: "sha256:13e3392d9005c27fed3003a1d036123fe12a341dbe16a97c0cd9f8272fb83320", FlagChoices: map[string][]string{"--color": []string{"auto", "never"}, "--format": []string{"json", "text"}, "--mode": []string{"audit-from-code", "code-baseline", "fresh"}, "--stack": []string{"agentic_runtime_repo", "generated_docs_contract_repo", "python_service", "python_typescript_service", "typescript_monorepo", "typescript_workspace"}}, RouteTokens: []string{"adopt", "plan"}}, "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{}, RouteTokens: []string{"adoption-checklist"}}, "adoption-contract-envelope": {InputContractSHA256: "sha256:c310214676ff4b6f536a5bc9d687f681a7e71f73d7a03ac932707d8cd3905cdf", InputSchemaSummary: []string{"schemaVersion=2", "root-shape-only definition proofkit.adoption-contract-envelope.input.v2.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:3efb2c5161fee16fd8ac6a40dcb6d9c41fbc23e468f60621436ae9e8076e0950", FlagChoices: map[string][]string{}, RouteTokens: []string{"adoption-contract-envelope"}}, "adoption-doctor": {InputContractSHA256: "sha256:efa9acfe32bff07f56d9dc9902530df2979794289bc2f7f547f7a108a7dd0f35", 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:8fdfc6608f197e633f042f20031ae1014872a90aa3daa66885ffcaddca994766", FlagChoices: map[string][]string{}, RouteTokens: []string{"adoption-doctor"}}, "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{}, RouteTokens: []string{"adoption-workflow-plan"}}, - "agent-route": {InputContractSHA256: "sha256:4fc7b2e5ffe3ed632e5e84d20e5ae26f9ace11df614bc9aec680853e60809ebd", 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:1ba71e0f314c7e592d8a57e23dbb12b5d95c17a8fa9caa20d05a56b87b814963", FlagChoices: map[string][]string{}, RouteTokens: []string{"agent-route"}}, + "agent-route": {InputContractSHA256: "sha256:c00e832b4e9eac6b858eec46e810431c0a5c9f56c5c50f055f39ee024f50014c", InputSchemaSummary: []string{"availableInputs", "browserMode", "goal", "knownChangedPaths", "mode", "nonClaims", "observedReports", "openBrowser", "routeId", "schemaVersion", "root-shape-only definition proofkit.agent-route.input.v2.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:a972f7178986b89334262db8047aabbb5a7b67025b30a009a96af47722b707c6", FlagChoices: map[string][]string{}, RouteTokens: []string{"agent-route"}}, "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{}, RouteTokens: []string{"binding-partition"}}, "branch-authority": {InputContractSHA256: "sha256:8a3ed74978898593fbdbf1f7fa684dae450fbd9019edcd60d07f818d63363ed4", InputSchemaSummary: []string{"schemaVersion=1", "root-shape-only definition proofkit.branch-authority.input.v1.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:3c7dc74842299b92cd5baf57cc8666e9415963091359e5faf654e28da89561f1", FlagChoices: map[string][]string{}, RouteTokens: []string{"branch-authority"}}, "capability-map-admission": {InputContractSHA256: "sha256:e49433f295c43c34d5d660ac9d656b117ed87208406b57723d25165ffec5d486", InputSchemaSummary: []string{"schemaVersion=1", "root-shape-only definition proofkit.capability-map-admission.input.v1.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:bfa35fe1be210ab98f3620694ab63b52a9724f7b92cd9dcbfd7b01b2c6a3555e", FlagChoices: map[string][]string{}, RouteTokens: []string{"capability-map-admission"}}, @@ -35,13 +35,13 @@ var generatedCommandContractMetadataByName = map[string]generatedCommandContract "gradual-adoption-guidance": {InputContractSHA256: "sha256:4752cbac81c864cb3e18a39facfd666a9707314233d54798c7f71e67d7f2800c", InputSchemaSummary: []string{"schemaVersion=1", "root-shape-only definition proofkit.gradual-adoption-guidance.input.v1.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:171fed4bb8d32a47fc5ec49796f5b0b55ed666feaccc2fbbfeb12da31d80ecc9", FlagChoices: map[string][]string{}, RouteTokens: []string{"gradual-adoption-guidance"}}, "help": {InputContractSHA256: "", InputSchemaSummary: []string(nil), OutputContractSHA256: "", FlagChoices: map[string][]string{}, RouteTokens: []string{"help"}}, "impact": {InputContractSHA256: "sha256:41d3107414837955ee408d5ce94949a4c1a6b76f6949e6c1dc224bd06f6b09bc", InputSchemaSummary: []string{"schemaVersion=2", "root-shape-only definition proofkit.impact.input.v2.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:73066e9a5ca48f21936111ffb7223900fb629875997f4e7b16d7fef9c4177972", FlagChoices: map[string][]string{}, RouteTokens: []string{"impact"}}, - "json-report-cli-adapter-source": {InputContractSHA256: "", InputSchemaSummary: []string(nil), OutputContractSHA256: "sha256:0ae40ace957b24bc1b48a3a6791690076de18e8513e2eceec449b595cefafe00", FlagChoices: map[string][]string{}, RouteTokens: []string{"json-report-cli-adapter-source"}}, + "json-report-cli-adapter-source": {InputContractSHA256: "", InputSchemaSummary: []string(nil), OutputContractSHA256: "sha256:6c3dd1c8507a90e055cf2c886089446d8560ff3e0d3ca9cc6360a3377d2d85da", FlagChoices: map[string][]string{}, RouteTokens: []string{"json-report-cli-adapter-source"}}, "migration-parity-admission": {InputContractSHA256: "sha256:0b36c0e68da3b857dac4b13e7b3bd523052459106133aa8c908a4352682e6c05", 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:8e0f8af2b205817f018b0fe133fe789661caa29007695e036bfcab63c1830f47", FlagChoices: map[string][]string{}, RouteTokens: []string{"migration-parity-admission"}}, "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{}, RouteTokens: []string{"migration-plan"}}, "native-evidence-guidance": {InputContractSHA256: "", InputSchemaSummary: []string(nil), OutputContractSHA256: "sha256:c9306d800668ecea18aaced6a21334036a935570f267baed356e6a4888025c8d", FlagChoices: map[string][]string{"--color": []string{"auto", "never"}, "--format": []string{"json", "text"}}, RouteTokens: []string{"native-evidence-guidance"}}, "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{}, RouteTokens: []string{"obligation-decision"}}, "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{}, RouteTokens: []string{"package-runtime-dependency-admission"}}, - "pilot-admission": {InputContractSHA256: "sha256:a1d9116ce619f7d705349ff4ae44c0f4399a281ebaa9e7d62ea304ac57af59ba", InputSchemaSummary: []string{"schemaVersion=2", "root-shape-only definition proofkit.pilot-admission.input.v2.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:4342a1deb3aa49cf000658c2bc3d172cf610209673b1dac4d1f05685b0a18865", FlagChoices: map[string][]string{}, RouteTokens: []string{"pilot-admission"}}, + "pilot-admission": {InputContractSHA256: "sha256:a1d9116ce619f7d705349ff4ae44c0f4399a281ebaa9e7d62ea304ac57af59ba", InputSchemaSummary: []string{"schemaVersion=2", "root-shape-only definition proofkit.pilot-admission.input.v2.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:f684b389f8da6a2f8f4a6a7dc9748dccf28dd5e30ae037850fa2487d21428df6", FlagChoices: map[string][]string{}, RouteTokens: []string{"pilot-admission"}}, "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{}, RouteTokens: []string{"producer-policy-self-proof"}}, "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{}, RouteTokens: []string{"proof-obligation-algebra"}}, "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{}, RouteTokens: []string{"proof-receipt-admission"}}, @@ -55,7 +55,7 @@ var generatedCommandContractMetadataByName = map[string]generatedCommandContract "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{}, RouteTokens: []string{"release-authority"}}, "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{}, RouteTokens: []string{"rendered-artifact-freshness"}}, "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{}, RouteTokens: []string{"repo-profile-admission"}}, - "repository-inventory": {InputContractSHA256: "", InputSchemaSummary: []string(nil), OutputContractSHA256: "sha256:474ac75c2ccaf66d248f4e96df51404d9a0e6c2341aa8278e91207f76b4c12d6", FlagChoices: map[string][]string{}, RouteTokens: []string{"repository-inventory"}}, + "repository-inventory": {InputContractSHA256: "", InputSchemaSummary: []string(nil), OutputContractSHA256: "sha256:4a6fc5b5ef55090854e70927494d220afdee0ae234de4f61a720a6018865f02f", FlagChoices: map[string][]string{}, RouteTokens: []string{"repository-inventory"}}, "requirement-authoring-plan": {InputContractSHA256: "sha256:208d7d47109dee1ec355ae3970937690ae528a9cc0cb0eb885d7cc72d843f1e8", 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:e995d289c4a44310add784bbafa5a3a50ec305c3809a89506dd3b49914fbe28f", FlagChoices: map[string][]string{}, RouteTokens: []string{"requirement-authoring-plan"}}, "requirement-bindings": {InputContractSHA256: "sha256:4771b7ed1e23b20c983060deb8f8e65391052f0e5a61cf0f5c67c0e73b8fc5dd", 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:7821c7b23ff2c0ca83c64039c22400d90660cad73a60b9afb46829c539c61168", FlagChoices: map[string][]string{}, RouteTokens: []string{"requirement-bindings"}}, "requirement-browser-server": {InputContractSHA256: "sha256:acb36856dc08ae8efa68986bb4c419f480951b1ad9ee9a43fc1d6c53d30ee71b", 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.v3.root-shape; nested fields, types, and cardinalities are non-claims"}, OutputContractSHA256: "sha256:16e741d88e5ede4271c5e769c724e72164fcba461f0c6c17d285f318f8e03005", FlagChoices: map[string][]string{}, RouteTokens: []string{"requirement-browser-server"}}, @@ -80,7 +80,7 @@ var generatedCommandContractMetadataByName = map[string]generatedCommandContract "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{}, RouteTokens: []string{"selective-gate-evidence"}}, "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{}, RouteTokens: []string{"selective-gate-obligation-decision-input"}}, "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{}, RouteTokens: []string{"selective-gate-plan"}}, - "self-check": {InputContractSHA256: "sha256:ae0ea8d425320045513c4b7ba17ad4faad5834574624927b59e4a737a7def5ab", 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:34a8a735482395789490659c81d95d74cfe6cabf426e2a9d6248896f2f76d82e", FlagChoices: map[string][]string{}, RouteTokens: []string{"self-check"}}, + "self-check": {InputContractSHA256: "sha256:ec13b324d3276ba82a58b683ba053db12e9a29b4747eb1f498972882ce84722b", 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:a40ca0b4ad6b0f58ba927679a7380556e81c8420a4cba78fffa1a05b92d4e418", FlagChoices: map[string][]string{}, RouteTokens: []string{"self-check"}}, "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{}, RouteTokens: []string{"spec-overview-claims"}}, "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{}, RouteTokens: []string{"spec-proof-bundle-admission"}}, "stack-preset": {InputContractSHA256: "", InputSchemaSummary: []string(nil), OutputContractSHA256: "sha256:ef5920f363a4a96dcac308ea8412260a06e64ba4876460a369aefb8983130a9d", FlagChoices: map[string][]string{"--preset": []string{"agentic_runtime_repo", "generated_docs_contract_repo", "python_service", "python_typescript_service", "typescript_monorepo", "typescript_workspace"}}, RouteTokens: []string{"stack-preset"}}, diff --git a/internal/app/command_descriptors.go b/internal/app/command_descriptors.go index f20fc13..42804d4 100644 --- a/internal/app/command_descriptors.go +++ b/internal/app/command_descriptors.go @@ -230,16 +230,18 @@ func command(name string, input commandInputMode, allowedFlags []string, outputM descriptor.singleOccurrenceFlags = sortedUniqueStrings(append(descriptor.singleOccurrenceFlags, "--format")) } explicitFlagChoices := cloneStringMap(descriptor.flagValueChoices) - if metadata, ok := generatedCommandContractMetadataByName[name]; ok { - descriptor.routeTokens = cloneStrings(metadata.RouteTokens) - 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) + metadata, ok := generatedCommandContractMetadataByName[name] + if !ok { + panic("command descriptor is missing generated contract metadata: " + name) + } + descriptor.routeTokens = cloneStrings(metadata.RouteTokens) + 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 } diff --git a/internal/app/command_routes.go b/internal/app/command_routes.go index ebda6fe..ba1b757 100644 --- a/internal/app/command_routes.go +++ b/internal/app/command_routes.go @@ -2,41 +2,40 @@ package app import ( "slices" - "strings" -) -const maximumCommandRouteTokens = 4 + "github.com/research-engineering/agentic-proofkit/internal/kernel/commandroute" +) var commandDescriptorByRoute = buildCommandDescriptorRouteIndex(commandDescriptors) func buildCommandDescriptorRouteIndex(descriptors []commandDescriptor) map[string]commandDescriptor { index := make(map[string]commandDescriptor, len(descriptors)) for descriptorIndex, descriptor := range descriptors { - if !validCommandRoute(descriptor.routeTokens) { + if !commandroute.Valid(descriptor.routeTokens) { panic("invalid command route: " + descriptor.name) } for existingIndex := 0; existingIndex < descriptorIndex; existingIndex++ { existingDescriptor := descriptors[existingIndex] existing := existingDescriptor.routeTokens if slices.Equal(existing, descriptor.routeTokens) { - panic("duplicate command route: " + commandRouteText(descriptor.routeTokens)) + panic("duplicate command route: " + commandroute.Text(descriptor.routeTokens)) } - if commandRoutePrefix(existing, descriptor.routeTokens) || commandRoutePrefix(descriptor.routeTokens, existing) { + if commandroute.Prefix(existing, descriptor.routeTokens) || commandroute.Prefix(descriptor.routeTokens, existing) { panic("ambiguous command route prefix: " + existingDescriptor.name + " and " + descriptor.name) } } - index[commandRouteKey(descriptor.routeTokens)] = descriptor.clone() + index[commandroute.Key(descriptor.routeTokens)] = descriptor.clone() } return index } func commandDescriptorForRoute(args []string) (commandDescriptor, int, bool) { maximum := len(args) - if maximum > maximumCommandRouteTokens { - maximum = maximumCommandRouteTokens + if maximum > commandroute.MaximumTokens { + maximum = commandroute.MaximumTokens } for consumed := maximum; consumed >= 1; consumed-- { - descriptor, ok := commandDescriptorByRoute[commandRouteKey(args[:consumed])] + descriptor, ok := commandDescriptorByRoute[commandroute.Key(args[:consumed])] if ok { return descriptor.clone(), consumed, true } @@ -52,48 +51,9 @@ func commandDescriptorForHelpTarget(tokens []string) (commandDescriptor, bool) { } func validCommandRoute(route []string) bool { - if len(route) == 0 || len(route) > maximumCommandRouteTokens { - return false - } - for _, token := range route { - if !validCommandRouteToken(token) { - return false - } - } - return true -} - -func validCommandRouteToken(token string) bool { - if token == "" || token[0] == '-' || token[len(token)-1] == '-' { - return false - } - previousHyphen := false - for _, value := range token { - if value == '-' { - if previousHyphen { - return false - } - previousHyphen = true - continue - } - previousHyphen = false - if value < 'a' || value > 'z' { - if value < '0' || value > '9' { - return false - } - } - } - return true -} - -func commandRoutePrefix(prefix, value []string) bool { - return len(prefix) < len(value) && slices.Equal(prefix, value[:len(prefix)]) -} - -func commandRouteKey(route []string) string { - return strings.Join(route, "\x00") + return commandroute.Valid(route) } func commandRouteText(route []string) string { - return strings.Join(route, " ") + return commandroute.Text(route) } diff --git a/internal/app/testdata/v0.7-wire-observations.json b/internal/app/testdata/v0.7-wire-observations.json index 2cd9691..966a889 100644 --- a/internal/app/testdata/v0.7-wire-observations.json +++ b/internal/app/testdata/v0.7-wire-observations.json @@ -4,18 +4,31 @@ "previousVersion": "0.6.0", "version": "0.7.0", "evidenceClass": "owner_authored_frozen_version_edge_observation", + "commandContractSelection": "declared_semantic_identity_or_wire_schema_change", "changeRecordRef": "release/change-record.v2.json", - "changeRecordSha256": "sha256:acff7afef6ac42624f4e0ce185ec3816ea155e5fc2608de003376b3c635bacf3", + "changeRecordSha256": "sha256:25dfeccb631449f0c1eb0d1bc6d42d483c3100b34d7ede7a0d16390f3bef3d49", "changedGeneratedArtifacts": [ { "artifactKind": "proofkit.json-report-cli-adapter-source", - "currentSourceSha256": "sha256:329b88b6b134dc30fb3704d32ac9708fc01608b9df68815bc9585108971be37d", + "currentSourceSha256": "sha256:62c34f1b920466f157d32d982fd5dd8355cbfb023eeda342e8cdbe5c15d731a0", "generatorId": "proofkit.json-report-cli-adapter-source.typescript.v2", "previousSourceSha256": "sha256:a171cc1b95c6078b7190ac50fc9fd298db8f42bfc9b65bbb67fa77d63dc04a93" } ], + "changedCommandContracts": [ + { + "command": "agent-route", + "currentInputContractId": "proofkit.agent-route.input.v2", + "currentInputContractSha256": "sha256:c00e832b4e9eac6b858eec46e810431c0a5c9f56c5c50f055f39ee024f50014c", + "currentOutputContractSha256": "sha256:a972f7178986b89334262db8047aabbb5a7b67025b30a009a96af47722b707c6", + "previousInputContractId": "proofkit.agent-route.input.v1", + "previousInputContractSha256": "sha256:4fc7b2e5ffe3ed632e5e84d20e5ae26f9ace11df614bc9aec680853e60809ebd", + "previousOutputContractSha256": "sha256:485d62afc2e5ed07c28f557b0d1069f167b3838abe0aed248e9ff94f3e25c0ad", + "wireSchemaVersion": 1 + } + ], "previousPublicAbiSha256": "sha256:163f06bf6fc94f15040fecf3e352d4600a8611a227e26f35369b7fe97e90bde5", - "currentPublicAbiSha256": "sha256:5a238b80bfdbe22f0e1b76850dab9568c89faf303e4653bf017cdf9535f3d7ae", + "currentPublicAbiSha256": "sha256:c3b7219fccd7d400b182beb53715f69758e02a4fef6f9465ba0c80a866abd1c7", "removedCommandContract": { "command": "init", "defaultInvocationPreset": "all", @@ -25,16 +38,17 @@ { "command": "adopt-plan", "route": ["adopt", "plan"], - "outputContractSha256": "sha256:16583d956455cc360a13382e0d783e5590a2a19adedbb4bf9fc7b6dd10ee35b8" + "outputContractSha256": "sha256:13e3392d9005c27fed3003a1d036123fe12a341dbe16a97c0cd9f8272fb83320" }, { "command": "repository-inventory", "route": ["repository-inventory"], - "outputContractSha256": "sha256:474ac75c2ccaf66d248f4e96df51404d9a0e6c2341aa8278e91207f76b4c12d6" + "outputContractSha256": "sha256:4a6fc5b5ef55090854e70927494d220afdee0ae234de4f61a720a6018865f02f" } ], "breakingChangeIds": [ - "proofkit.adoption.init-retired" + "proofkit.adoption.init-retired", + "proofkit.agent-route.input-contract-v2" ], "additionChangeIds": [ "proofkit.adoption.front-door", diff --git a/internal/command/adoptionplan/adoptionplan_test.go b/internal/command/adoptionplan/adoptionplan_test.go index 7a8d2a7..878af16 100644 --- a/internal/command/adoptionplan/adoptionplan_test.go +++ b/internal/command/adoptionplan/adoptionplan_test.go @@ -63,28 +63,30 @@ func TestBuildSeparatesAdoptionIntentFromCandidateAuthority(t *testing.T) { func TestBuildStackHintCannotChangeIntentTrustOrTasks(t *testing.T) { inventory := adoptionInventory(t) - withoutStack, err := Build(IntentAuditFromCode, inventory, "") - if err != nil { - t.Fatalf("Build(without stack) error = %v", err) - } - seenPlanIDs := map[string]string{withoutStack.PlanID: "none"} - for _, presetID := range stackpreset.IDs() { - withStack, err := Build(IntentAuditFromCode, inventory, presetID) + for _, intent := range IntentValues() { + withoutStack, err := Build(intent, inventory, "") if err != nil { - t.Fatalf("Build(%s) error = %v", presetID, err) - } - if withStack.StackHint == nil || withStack.StackHint.PresetID != presetID { - t.Fatalf("stack hint = %#v, want %s", withStack.StackHint, presetID) + t.Fatalf("Build(%s, without stack) error = %v", intent, err) } - if withoutStack.Intent != withStack.Intent || !reflect.DeepEqual(withoutStack.TrustDeclaration, withStack.TrustDeclaration) || !reflect.DeepEqual(withoutStack.Packet.Tasks, withStack.Packet.Tasks) { - t.Fatalf("stack %s changed authority semantics:\nwithout=%#v\nwith=%#v", presetID, withoutStack, withStack) - } - if prior, duplicate := seenPlanIDs[withStack.PlanID]; duplicate { - t.Fatalf("stack %s shares plan identity with %s", presetID, prior) - } - seenPlanIDs[withStack.PlanID] = presetID - if withoutStack.Inventory.InventoryID != withStack.Inventory.InventoryID { - t.Fatalf("stack %s changed repository inventory", presetID) + seenPlanIDs := map[string]string{withoutStack.PlanID: "none"} + for _, presetID := range stackpreset.IDs() { + withStack, err := Build(intent, inventory, presetID) + if err != nil { + t.Fatalf("Build(%s, %s) error = %v", intent, presetID, err) + } + if withStack.StackHint == nil || withStack.StackHint.PresetID != presetID { + t.Fatalf("stack hint = %#v, want %s", withStack.StackHint, presetID) + } + if withoutStack.Intent != withStack.Intent || !reflect.DeepEqual(withoutStack.TrustDeclaration, withStack.TrustDeclaration) || !reflect.DeepEqual(withoutStack.Packet.Tasks, withStack.Packet.Tasks) { + t.Fatalf("intent %s stack %s changed authority semantics:\nwithout=%#v\nwith=%#v", intent, presetID, withoutStack, withStack) + } + if prior, duplicate := seenPlanIDs[withStack.PlanID]; duplicate { + t.Fatalf("intent %s stack %s shares plan identity with %s", intent, presetID, prior) + } + seenPlanIDs[withStack.PlanID] = presetID + if withoutStack.Inventory.InventoryID != withStack.Inventory.InventoryID { + t.Fatalf("intent %s stack %s changed repository inventory", intent, presetID) + } } } } @@ -184,7 +186,15 @@ func TestTextProjectionPreservesJSONPlanSemantics(t *testing.T) { if err != nil { t.Fatalf("RenderText() error = %v", err) } - for _, required := range []string{IntentAuditFromCode, "typescript_workspace", PlanState, plan.Packet.GuidanceReference.CommandID} { + for _, required := range []string{ + IntentAuditFromCode, + capabilitymapadmission.TrustModeAuditFromCode, + "typescript_workspace", + PlanState, + plan.PlanID, + plan.Inventory.InventoryID, + plan.Packet.GuidanceReference.CommandID, + } { if !strings.Contains(text, required) { t.Fatalf("text projection omits %q:\n%s", required, text) } diff --git a/internal/command/adoptionplan/text.go b/internal/command/adoptionplan/text.go index 3e25057..3bfee98 100644 --- a/internal/command/adoptionplan/text.go +++ b/internal/command/adoptionplan/text.go @@ -17,9 +17,16 @@ func TextProjection(plan Plan) ([]TextLine, error) { if plan.StackHint != nil { stack = plan.StackHint.PresetID } + capabilityMapTrustMode := "not-applicable" + if plan.TrustDeclaration.CapabilityMapTrustMode != nil { + capabilityMapTrustMode = *plan.TrustDeclaration.CapabilityMapTrustMode + } lines := []TextLine{ {Label: "Adoption plan"}, + {Label: "Plan ID", Value: plan.PlanID}, + {Label: "Inventory ID", Value: plan.Inventory.InventoryID}, {Label: "Mode", Value: plan.Intent}, + {Label: "Capability map trust mode", Value: capabilityMapTrustMode}, {Label: "State", Value: PlanState}, {Label: "Inventory", Value: fmt.Sprintf("%d recognized, %d omitted, %d opaque", len(plan.Inventory.Entries), len(plan.Inventory.Omissions.OmittedRecognized), plan.Inventory.Omissions.UnrecognizedCount)}, {Label: "Stack hint", Value: stack}, diff --git a/internal/command/agentroute/agentroute.go b/internal/command/agentroute/agentroute.go index 3ae2479..1aadfa6 100644 --- a/internal/command/agentroute/agentroute.go +++ b/internal/command/agentroute/agentroute.go @@ -432,7 +432,7 @@ func BuildWithRenderer(raw any, renderer cliexec.Renderer) (map[string]any, int, func InputContract() map[string]any { return map[string]any{ - "contractId": "proofkit.agent-route.input.v1", + "contractId": "proofkit.agent-route.input.v2", "schemaVersion": 1, "authority": "agent-route input admission", "fields": map[string]any{ diff --git a/internal/command/agentroute/agentroute_test.go b/internal/command/agentroute/agentroute_test.go index 343aaa3..e65e5f2 100644 --- a/internal/command/agentroute/agentroute_test.go +++ b/internal/command/agentroute/agentroute_test.go @@ -110,6 +110,19 @@ func TestBuildRejectsStdinTransportSentinelAsArtifactReference(t *testing.T) { } }) } + + _, exitCode, err := Build(map[string]any{ + "schemaVersion": jsonNumber("1"), + "routeId": "consumer.route.materialized-reference", + "goal": "validate_requirement_source", + "mode": "observe", + "availableInputs": []any{ + map[string]any{"kind": "requirement_source", "ref": "proofkit/requirements.v1.json"}, + }, + }) + if err != nil || exitCode != 0 { + t.Fatalf("materialized repository-relative reference exit=%d error=%v, want success", exitCode, err) + } } func TestBuildRejectsUnknownAdoptionMode(t *testing.T) { @@ -884,6 +897,9 @@ func TestInputContractMatchesAdmissionVocabulary(t *testing.T) { t.Parallel() contract := InputContract() + if contract["contractId"] != "proofkit.agent-route.input.v2" || contract["schemaVersion"] != 1 { + t.Fatalf("input contract identity = %v schemaVersion=%v, want v2 identity over wire schema 1", contract["contractId"], contract["schemaVersion"]) + } fields := contract["fields"].(map[string]any) assertContractEnum(t, fields, "goal", goalValues) assertContractEnum(t, fields, "mode", modeValues) diff --git a/internal/command/jsonreportcliadaptersource/json_report_cli_adapter_source.go b/internal/command/jsonreportcliadaptersource/json_report_cli_adapter_source.go index 47775a3..15882eb 100644 --- a/internal/command/jsonreportcliadaptersource/json_report_cli_adapter_source.go +++ b/internal/command/jsonreportcliadaptersource/json_report_cli_adapter_source.go @@ -2,7 +2,10 @@ package jsonreportcliadaptersource import ( "fmt" + "strconv" + "strings" + "github.com/research-engineering/agentic-proofkit/internal/kernel/commandroute" "github.com/research-engineering/agentic-proofkit/internal/kernel/digest" ) @@ -83,7 +86,13 @@ func (bundle Bundle) JSONValue() map[string]any { } func TypeScriptSource() string { - return typeScriptSource + return strings.NewReplacer( + "__PROOFKIT_COMMAND_ROUTE_MINIMUM__", strconv.Itoa(commandroute.MinimumTokens), + "__PROOFKIT_COMMAND_ROUTE_MAXIMUM__", strconv.Itoa(commandroute.MaximumTokens), + "__PROOFKIT_COMMAND_ROUTE_SEPARATOR__", strconv.Quote(commandroute.Separator), + "__PROOFKIT_COMMAND_ROUTE_TOKEN_PATTERN__", strings.ReplaceAll(commandroute.TokenPattern, "/", `\/`), + "__PROOFKIT_COMMAND_ROUTE_ERROR__", strconv.Quote(fmt.Sprintf("agentic-proofkit command route must contain %d to %d canonical tokens", commandroute.MinimumTokens, commandroute.MaximumTokens)), + ).Replace(typeScriptSourceTemplate) } func stringsToAny(values []string) []any { @@ -110,7 +119,7 @@ func sourceLineCount(source string) int { return count } -const typeScriptSource = `/* Generated by agentic-proofkit json-report-cli-adapter-source. +const typeScriptSourceTemplate = `/* Generated by agentic-proofkit json-report-cli-adapter-source. * Public contract: local generated source that drives the Proofkit CLI/JSON ABI. * Non-claim: this file does not publish an agentic-proofkit package SDK. */ @@ -1110,9 +1119,9 @@ function nextCliValue( } function admitCommandRoute(commandRoute: string): readonly string[] { - const tokens = commandRoute.split(" "); - if (tokens.length === 0 || tokens.length > 4 || tokens.some((token) => !/^[a-z0-9]+(?:-[a-z0-9]+)*$/u.test(token))) { - throw new Error("agentic-proofkit command route must contain one to four canonical tokens"); + const tokens = commandRoute.split(__PROOFKIT_COMMAND_ROUTE_SEPARATOR__); + if (tokens.length < __PROOFKIT_COMMAND_ROUTE_MINIMUM__ || tokens.length > __PROOFKIT_COMMAND_ROUTE_MAXIMUM__ || tokens.some((token) => !/__PROOFKIT_COMMAND_ROUTE_TOKEN_PATTERN__/u.test(token))) { + throw new Error(__PROOFKIT_COMMAND_ROUTE_ERROR__); } return tokens; } diff --git a/internal/command/jsonreportcliadaptersource/json_report_cli_adapter_source_test.go b/internal/command/jsonreportcliadaptersource/json_report_cli_adapter_source_test.go index baba5c7..d8656cf 100644 --- a/internal/command/jsonreportcliadaptersource/json_report_cli_adapter_source_test.go +++ b/internal/command/jsonreportcliadaptersource/json_report_cli_adapter_source_test.go @@ -2,20 +2,23 @@ package jsonreportcliadaptersource import ( "encoding/json" + "fmt" "os" "os/exec" "path/filepath" "regexp" "slices" + "strconv" "strings" "testing" "github.com/research-engineering/agentic-proofkit/internal/kernel/admit" + "github.com/research-engineering/agentic-proofkit/internal/kernel/commandroute" "github.com/research-engineering/agentic-proofkit/internal/kernel/digest" "github.com/research-engineering/agentic-proofkit/internal/testsupport/commandcoverage" ) -const expectedTypeScriptSourceSha256 = "sha256:329b88b6b134dc30fb3704d32ac9708fc01608b9df68815bc9585108971be37d" +const expectedTypeScriptSourceSha256 = "sha256:62c34f1b920466f157d32d982fd5dd8355cbfb023eeda342e8cdbe5c15d731a0" func TestBuildEmitsDeterministicTypeScriptSourceBundle(t *testing.T) { if !slices.IsSorted(exportedSymbols) { @@ -146,7 +149,10 @@ func TestGeneratedSourceAdmitsBoundedCanonicalCommandRoutes(t *testing.T) { source := TypeScriptSource() for _, required := range []string{ "function admitCommandRoute(commandRoute: string): readonly string[]", - "tokens.length > 4", + "commandRoute.split(" + strconv.Quote(commandroute.Separator) + ")", + fmt.Sprintf("tokens.length < %d", commandroute.MinimumTokens), + fmt.Sprintf("tokens.length > %d", commandroute.MaximumTokens), + strconv.Quote(fmt.Sprintf("agentic-proofkit command route must contain %d to %d canonical tokens", commandroute.MinimumTokens, commandroute.MaximumTokens)), "...routeTokens, \"--input\", \"-\"", } { if !strings.Contains(source, required) { @@ -550,7 +556,7 @@ assert.deepEqual(maximumRoute.value.route, ["four", "route", "tokens", "exactly" for (const invalidRoute of ["", "adopt plan", "adopt plan now extra later", "Adopt plan", "--help"]) { assert.throws( () => runProofkitJsonCommand(invalidRoute, {}, [], {binaryPath: fakeProofkitPath, cwd: repositoryRoot}), - /one to four canonical tokens/, + /1 to 4 canonical tokens/, ); } const compactPass = runProofkitJsonCommand("json-pass", {z: 1, a: true}, [], {binaryPath: fakeProofkitPath, cwd: repositoryRoot, jsonLayout: "compact"}); diff --git a/internal/command/repositoryinventory/admission.go b/internal/command/repositoryinventory/admission.go index 966d8ba..a916e53 100644 --- a/internal/command/repositoryinventory/admission.go +++ b/internal/command/repositoryinventory/admission.go @@ -94,7 +94,7 @@ func admitEntries(raw any) ([]Entry, error) { if err != nil { return nil, err } - role, recognized := CatalogRole(path) + role, recognized := catalogRole(path) if !recognized || record["role"] != role || record["syntaxState"] != "not_evaluated" { return nil, fmt.Errorf("repository inventory entry does not match the catalog") } @@ -153,7 +153,7 @@ func admitOmissions(raw any) (Omissions, error) { if err != nil { return Omissions{}, err } - if _, recognized := CatalogRole(path); !recognized || previousPath != "" && previousPath >= path { + if _, recognized := catalogRole(path); !recognized || previousPath != "" && previousPath >= path { return Omissions{}, fmt.Errorf("repository inventory omittedRecognized entries must be sorted unique catalog paths") } previousPath = path diff --git a/internal/command/repositoryinventory/model.go b/internal/command/repositoryinventory/model.go index 81c88af..fad22af 100644 --- a/internal/command/repositoryinventory/model.go +++ b/internal/command/repositoryinventory/model.go @@ -93,7 +93,7 @@ type Snapshot struct { Omissions Omissions } -func CatalogPaths() []string { +func catalogPaths() []string { paths := make([]string, 0, len(rootCatalog)) for _, item := range rootCatalog { paths = append(paths, item.Path) @@ -108,7 +108,7 @@ func NonClaims() []string { return append([]string(nil), boundaryNonClaims...) } -func CatalogRole(path string) (string, bool) { +func catalogRole(path string) (string, bool) { index, found := slices.BinarySearchFunc(rootCatalog[:], path, func(item catalogItem, target string) int { switch { case item.Path < target: diff --git a/internal/command/repositoryinventory/open_candidate_other.go b/internal/command/repositoryinventory/open_candidate_other.go index 3926eb4..da76f10 100644 --- a/internal/command/repositoryinventory/open_candidate_other.go +++ b/internal/command/repositoryinventory/open_candidate_other.go @@ -7,10 +7,6 @@ import ( "os" ) -func requireScannerPlatform() error { - return fmt.Errorf("repository inventory scanning is unsupported on this platform") -} - func openCandidateFile(root *os.Root, path string) (*os.File, error) { return nil, fmt.Errorf("repository inventory scanning is unsupported on this platform") } diff --git a/internal/command/repositoryinventory/open_candidate_unix.go b/internal/command/repositoryinventory/open_candidate_unix.go index 01d9160..9a13a91 100644 --- a/internal/command/repositoryinventory/open_candidate_unix.go +++ b/internal/command/repositoryinventory/open_candidate_unix.go @@ -7,10 +7,6 @@ import ( "syscall" ) -func requireScannerPlatform() error { - return nil -} - func openCandidateFile(root *os.Root, path string) (*os.File, error) { return root.OpenFile(path, os.O_RDONLY|syscall.O_NONBLOCK|syscall.O_NOFOLLOW, 0) } diff --git a/internal/command/repositoryinventory/repositoryinventory_test.go b/internal/command/repositoryinventory/repositoryinventory_test.go index 923b31f..abedafc 100644 --- a/internal/command/repositoryinventory/repositoryinventory_test.go +++ b/internal/command/repositoryinventory/repositoryinventory_test.go @@ -18,13 +18,13 @@ import ( ) func TestCatalogPathsAreSortedAndUnique(t *testing.T) { - paths := CatalogPaths() + paths := catalogPaths() if !slices.IsSorted(paths) { - t.Fatalf("CatalogPaths() = %v, want sorted paths", paths) + t.Fatalf("catalogPaths() = %v, want sorted paths", paths) } for index := 1; index < len(paths); index++ { if paths[index-1] == paths[index] { - t.Fatalf("CatalogPaths() contains duplicate %q", paths[index]) + t.Fatalf("catalogPaths() contains duplicate %q", paths[index]) } } } @@ -57,17 +57,17 @@ func TestCatalogRolePolicyIsExact(t *testing.T) { "uv.lock": "dependency_lock", "yarn.lock": "dependency_lock", } - if got := CatalogPaths(); len(got) != len(want) { + if got := catalogPaths(); len(got) != len(want) { t.Fatalf("catalog path count = %d, want %d", len(got), len(want)) } for path, wantRole := range want { - gotRole, ok := CatalogRole(path) + gotRole, ok := catalogRole(path) if !ok || gotRole != wantRole { - t.Fatalf("CatalogRole(%q) = %q/%t, want %q/true", path, gotRole, ok, wantRole) + t.Fatalf("catalogRole(%q) = %q/%t, want %q/true", path, gotRole, ok, wantRole) } } - if role, ok := CatalogRole("unknown"); ok || role != "" { - t.Fatalf("CatalogRole(unknown) = %q/%t, want empty/false", role, ok) + if role, ok := catalogRole("unknown"); ok || role != "" { + t.Fatalf("catalogRole(unknown) = %q/%t, want empty/false", role, ok) } } @@ -227,9 +227,17 @@ func TestScanEnforcesPreflightBoundsAndExplicitOmissions(t *testing.T) { defer root.Close() policy := defaultScanPolicy policy.maximumAggregateBytes = 3 - if _, err := scanRoot(context.Background(), root, policy); err == nil || !strings.Contains(err.Error(), "aggregate byte limit") { + readCount := 0 + reader := func(*os.Root, candidate, int64, int64) ([]byte, error) { + readCount++ + return nil, nil + } + if _, err := scanRootWithCandidateReader(context.Background(), root, policy, reader); err == nil || !strings.Contains(err.Error(), "aggregate byte limit") { t.Fatalf("scanRoot() error = %v, want aggregate preflight rejection", err) } + if readCount != 0 { + t.Fatalf("aggregate preflight performed %d content reads, want zero", readCount) + } }) t.Run("growth after preflight remains aggregate bounded", func(t *testing.T) { @@ -276,6 +284,20 @@ func TestScanEnforcesPreflightBoundsAndExplicitOmissions(t *testing.T) { }) } +func TestUnsupportedPlatformFailsBeforeOpeningRepositoryRoot(t *testing.T) { + openCount := 0 + opener := func(string) (*os.Root, error) { + openCount++ + return nil, nil + } + if _, err := scanForPlatform(context.Background(), "caller-root", "windows", opener); err == nil || !strings.Contains(err.Error(), "unsupported") { + t.Fatalf("scanForPlatform() error = %v, want unsupported-platform rejection", err) + } + if openCount != 0 { + t.Fatalf("unsupported platform opened repository root %d time(s)", openCount) + } +} + func TestScanPolicyBoundariesAreExact(t *testing.T) { t.Run("root entries", func(t *testing.T) { for _, test := range []struct { diff --git a/internal/command/repositoryinventory/scan.go b/internal/command/repositoryinventory/scan.go index 3719ec0..37eb526 100644 --- a/internal/command/repositoryinventory/scan.go +++ b/internal/command/repositoryinventory/scan.go @@ -6,6 +6,7 @@ import ( "fmt" "io" "os" + "runtime" "sort" "unicode/utf8" @@ -27,6 +28,9 @@ type directoryEntryReader interface { ReadDir(int) ([]os.DirEntry, error) } +type repositoryRootOpener func(string) (*os.Root, error) +type candidateContentReader func(*os.Root, candidate, int64, int64) ([]byte, error) + type rootInventory struct { catalogItems []catalogItem rootEntryCount int @@ -40,13 +44,17 @@ var defaultScanPolicy = scanPolicy{ } func Scan(ctx context.Context, repositoryRoot string) (Snapshot, error) { + return scanForPlatform(ctx, repositoryRoot, runtime.GOOS, os.OpenRoot) +} + +func scanForPlatform(ctx context.Context, repositoryRoot, goos string, openRoot repositoryRootOpener) (Snapshot, error) { if err := ctx.Err(); err != nil { return Snapshot{}, err } - if err := requireScannerPlatform(); err != nil { + if err := requireScannerPlatform(goos); err != nil { return Snapshot{}, err } - root, err := os.OpenRoot(repositoryRoot) + root, err := openRoot(repositoryRoot) if err != nil { return Snapshot{}, fmt.Errorf("open repository root") } @@ -57,7 +65,18 @@ func Scan(ctx context.Context, repositoryRoot string) (Snapshot, error) { return snapshot, scanErr } +func requireScannerPlatform(goos string) error { + if goos != "darwin" && goos != "linux" { + return fmt.Errorf("repository inventory scanning is unsupported on this platform") + } + return nil +} + func scanRoot(ctx context.Context, root *os.Root, policy scanPolicy) (Snapshot, error) { + return scanRootWithCandidateReader(ctx, root, policy, readCandidate) +} + +func scanRootWithCandidateReader(ctx context.Context, root *os.Root, policy scanPolicy, readContent candidateContentReader) (Snapshot, error) { directory, err := root.Open(".") if err != nil { return Snapshot{}, fmt.Errorf("open repository root directory") @@ -111,7 +130,7 @@ func scanRoot(ctx context.Context, root *os.Root, policy scanPolicy) (Snapshot, if err := ctx.Err(); err != nil { return Snapshot{}, err } - content, err := readCandidate(root, item, policy.maximumFileBytes, policy.maximumAggregateBytes-actualAggregateBytes) + content, err := readContent(root, item, policy.maximumFileBytes, policy.maximumAggregateBytes-actualAggregateBytes) if err != nil { return Snapshot{}, err } @@ -158,7 +177,7 @@ func readRootInventory(ctx context.Context, directory directoryEntryReader, maxi if result.rootEntryCount > maximum { return rootInventory{}, fmt.Errorf("repository root exceeds entry limit") } - role, recognized := CatalogRole(entry.Name()) + role, recognized := catalogRole(entry.Name()) if !recognized { result.unrecognizedCount++ continue diff --git a/internal/command/stackpreset/preset_ids_generated.go b/internal/command/stackpreset/preset_ids_generated.go index cf1e8a3..c3aa457 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 = "c7de8b5f737b7616095b83cd19204761b2e188f17e5f461ede10c3f1d6095447" +const presetContractSourceSHA256 = "e0c7484b588a119947ee6f2568ecc44fdb5e93c3a6fa1d61e677bde45df682fe" var presetIDs = []string{"agentic_runtime_repo", "generated_docs_contract_repo", "python_service", "python_typescript_service", "typescript_monorepo", "typescript_workspace"} diff --git a/internal/kernel/commandroute/route.go b/internal/kernel/commandroute/route.go new file mode 100644 index 0000000..f30ada0 --- /dev/null +++ b/internal/kernel/commandroute/route.go @@ -0,0 +1,53 @@ +package commandroute + +import ( + "regexp" + "slices" + "strings" +) + +var tokenPattern = regexp.MustCompile(TokenPattern) + +const ( + MinimumTokens = 1 + MaximumTokens = 4 + Separator = " " + TokenPattern = `^[a-z0-9]+(?:-[a-z0-9]+)*$` + AmbiguityPolicy = "no_route_is_prefix_of_another" +) + +func Valid(tokens []string) bool { + if len(tokens) < MinimumTokens || len(tokens) > MaximumTokens { + return false + } + for _, token := range tokens { + if !ValidToken(token) { + return false + } + } + return true +} + +func ValidToken(token string) bool { + return tokenPattern.MatchString(token) +} + +func Parse(text string) ([]string, bool) { + tokens := strings.Split(text, Separator) + if !Valid(tokens) || strings.Join(tokens, Separator) != text { + return nil, false + } + return tokens, true +} + +func Prefix(prefix, value []string) bool { + return len(prefix) < len(value) && slices.Equal(prefix, value[:len(prefix)]) +} + +func Key(tokens []string) string { + return strings.Join(tokens, "\x00") +} + +func Text(tokens []string) string { + return strings.Join(tokens, Separator) +} diff --git a/internal/kernel/commandroute/route_test.go b/internal/kernel/commandroute/route_test.go new file mode 100644 index 0000000..42f16b7 --- /dev/null +++ b/internal/kernel/commandroute/route_test.go @@ -0,0 +1,48 @@ +package commandroute + +import ( + "slices" + "testing" +) + +func TestGrammarBoundariesAreExact(t *testing.T) { + valid := []string{"one", "two", "three", "four"} + if !Valid(valid[:MinimumTokens]) || !Valid(valid[:MaximumTokens]) { + t.Fatal("exact command-route token bounds were rejected") + } + if Valid(nil) || Valid(append(valid, "five")) { + t.Fatal("command-route values outside exact token bounds were accepted") + } + for _, token := range []string{"a", "0", "a0", "a-b", "0-9"} { + if !ValidToken(token) { + t.Fatalf("valid command-route token %q was rejected", token) + } + } + for _, token := range []string{"", "-leading", "trailing-", "double--hyphen", "Upper", "under_score"} { + if ValidToken(token) { + t.Fatalf("invalid command-route token %q was accepted", token) + } + } +} + +func TestParseRequiresCanonicalSeparatorAndRoundTrip(t *testing.T) { + want := []string{"adopt", "plan"} + got, ok := Parse("adopt plan") + if !ok || !slices.Equal(got, want) || Text(got) != "adopt plan" { + t.Fatalf("Parse()=%v,%v, want exact round trip %v", got, ok, want) + } + for _, mutant := range []string{"", "adopt plan", " adopt plan", "adopt plan ", "adopt\tplan"} { + if _, ok := Parse(mutant); ok { + t.Fatalf("non-canonical command route %q was accepted", mutant) + } + } +} + +func TestPrefixIsStrict(t *testing.T) { + if !Prefix([]string{"adopt"}, []string{"adopt", "plan"}) { + t.Fatal("strict route prefix was not recognized") + } + if Prefix([]string{"adopt"}, []string{"adopt"}) || Prefix([]string{"plan"}, []string{"adopt", "plan"}) { + t.Fatal("non-prefix route was recognized") + } +} diff --git a/internal/tools/artifactfile/file.go b/internal/tools/artifactfile/file.go index a85887c..cb8dd95 100644 --- a/internal/tools/artifactfile/file.go +++ b/internal/tools/artifactfile/file.go @@ -99,23 +99,23 @@ func ReadBounded(rootPath, relativePath string, maxBytes int64) ([]byte, error) if _, err := admitDirectories(root, filepath.Dir(path), false); err != nil { return nil, err } - before, err := root.Lstat(path) + file, err := openSource(root, path) if err != nil { - return nil, fmt.Errorf("inspect artifact file failed") + if info, inspectErr := root.Lstat(path); inspectErr == nil && (info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular()) { + return nil, fmt.Errorf("artifact source must be a regular non-symlink file") + } + return nil, fmt.Errorf("open artifact file failed") } - if before.Mode()&os.ModeSymlink != 0 || !before.Mode().IsRegular() { + defer file.Close() + opened, err := file.Stat() + if err != nil || !opened.Mode().IsRegular() { return nil, fmt.Errorf("artifact source must be a regular non-symlink file") } - if before.Size() > maxBytes { + if opened.Size() > maxBytes { return nil, fmt.Errorf("artifact file exceeds resource limit") } - file, err := root.Open(path) - if err != nil { - return nil, fmt.Errorf("open artifact file failed") - } - defer file.Close() - opened, err := file.Stat() - if err != nil || !os.SameFile(before, opened) || !opened.Mode().IsRegular() { + current, err := root.Lstat(path) + if err != nil || current.Mode()&os.ModeSymlink != 0 || !os.SameFile(opened, current) { return nil, fmt.Errorf("artifact source changed during admission") } content, err := io.ReadAll(io.LimitReader(file, maxBytes+1)) @@ -126,9 +126,13 @@ func ReadBounded(rootPath, relativePath string, maxBytes int64) ([]byte, error) return nil, fmt.Errorf("artifact file exceeds resource limit") } after, err := file.Stat() - if err != nil || !os.SameFile(opened, after) || opened.Size() != after.Size() || after.Size() != int64(len(content)) { + if err != nil || !os.SameFile(opened, after) || opened.Size() != after.Size() || !opened.ModTime().Equal(after.ModTime()) || after.Size() != int64(len(content)) { return nil, fmt.Errorf("artifact source changed during read") } + current, err = root.Lstat(path) + if err != nil || current.Mode()&os.ModeSymlink != 0 || !os.SameFile(opened, current) { + return nil, fmt.Errorf("artifact source path changed during read") + } return content, nil } diff --git a/internal/tools/artifactfile/file_unix_test.go b/internal/tools/artifactfile/file_unix_test.go new file mode 100644 index 0000000..41dfead --- /dev/null +++ b/internal/tools/artifactfile/file_unix_test.go @@ -0,0 +1,35 @@ +//go:build darwin || linux + +package artifactfile + +import ( + "os" + "path/filepath" + "syscall" + "testing" + "time" +) + +func TestReadBoundedRejectsFIFOWithoutBlocking(t *testing.T) { + root := t.TempDir() + path := filepath.Join(root, "artifact") + if err := syscall.Mkfifo(path, 0o600); err != nil { + t.Fatal(err) + } + done := make(chan error, 1) + go func() { + _, err := ReadBounded(root, "artifact", 64) + done <- err + }() + select { + case err := <-done: + if err == nil { + t.Fatal("ReadBounded() admitted a FIFO") + } + case <-time.After(time.Second): + t.Fatal("ReadBounded() blocked while opening a FIFO") + } + if info, err := os.Lstat(path); err != nil || info.Mode()&os.ModeNamedPipe == 0 { + t.Fatalf("FIFO fixture changed: info=%v error=%v", info, err) + } +} diff --git a/internal/tools/artifactfile/open_source_other.go b/internal/tools/artifactfile/open_source_other.go new file mode 100644 index 0000000..b24f7d6 --- /dev/null +++ b/internal/tools/artifactfile/open_source_other.go @@ -0,0 +1,12 @@ +//go:build !darwin && !linux + +package artifactfile + +import ( + "fmt" + "os" +) + +func openSource(_ *os.Root, _ string) (*os.File, error) { + return nil, fmt.Errorf("nonblocking no-follow artifact reads are unsupported on this platform") +} diff --git a/internal/tools/artifactfile/open_source_unix.go b/internal/tools/artifactfile/open_source_unix.go new file mode 100644 index 0000000..fe3f772 --- /dev/null +++ b/internal/tools/artifactfile/open_source_unix.go @@ -0,0 +1,12 @@ +//go:build darwin || linux + +package artifactfile + +import ( + "os" + "syscall" +) + +func openSource(root *os.Root, path string) (*os.File, error) { + return root.OpenFile(path, os.O_RDONLY|syscall.O_NONBLOCK|syscall.O_NOFOLLOW, 0) +} diff --git a/internal/tools/commandcontractgen/main.go b/internal/tools/commandcontractgen/main.go index a0b1339..e7150fd 100644 --- a/internal/tools/commandcontractgen/main.go +++ b/internal/tools/commandcontractgen/main.go @@ -23,6 +23,7 @@ import ( "strings" "github.com/research-engineering/agentic-proofkit/internal/kernel/admission" + "github.com/research-engineering/agentic-proofkit/internal/kernel/commandroute" "github.com/research-engineering/agentic-proofkit/internal/kernel/diagnostic" "github.com/research-engineering/agentic-proofkit/internal/kernel/stablejson" ) @@ -35,9 +36,8 @@ const ( ) var ( - commandTokenPattern = regexp.MustCompile(`^[a-z0-9]+(?:-[a-z0-9]+)*$`) - digestPattern = regexp.MustCompile(`^sha256:[0-9a-f]{64}$`) - testNamePattern = regexp.MustCompile(`^Test[A-Z0-9_][A-Za-z0-9_]*$`) + digestPattern = regexp.MustCompile(`^sha256:[0-9a-f]{64}$`) + testNamePattern = regexp.MustCompile(`^Test[A-Z0-9_][A-Za-z0-9_]*$`) ) type definitionRecord struct { @@ -154,9 +154,35 @@ func readContract(path string) ([]byte, map[string]any, error) { if err := rejectUnknownKeys(record, []string{"commands", "contractDefinitions", "contractId", "packageName", "processContract", "schemaVersion"}, "CLI contract"); err != nil { return nil, nil, err } + if err := admitCommandRouteGrammar(record["processContract"]); err != nil { + return nil, nil, err + } return content, record, nil } +func admitCommandRouteGrammar(raw any) error { + processContract, ok := raw.(map[string]any) + if !ok { + return errors.New("CLI processContract must be an object") + } + grammar, ok := processContract["commandRouteGrammar"].(map[string]any) + if !ok { + return errors.New("CLI processContract commandRouteGrammar must be an object") + } + if err := rejectUnknownKeys(grammar, []string{"ambiguityPolicy", "maximumTokens", "minimumTokens", "separator", "tokenPattern"}, "CLI command route grammar"); err != nil { + return err + } + minimum, minimumOK := positiveJSONInteger(grammar["minimumTokens"]) + maximum, maximumOK := positiveJSONInteger(grammar["maximumTokens"]) + if !minimumOK || !maximumOK || minimum != commandroute.MinimumTokens || maximum != commandroute.MaximumTokens { + return fmt.Errorf("CLI command route grammar token bounds must be %d through %d", commandroute.MinimumTokens, commandroute.MaximumTokens) + } + if grammar["separator"] != commandroute.Separator || grammar["tokenPattern"] != commandroute.TokenPattern || grammar["ambiguityPolicy"] != commandroute.AmbiguityPolicy { + return errors.New("CLI command route grammar does not match the native route owner") + } + return nil +} + func admitDefinitions(contract map[string]any) (map[string]definitionRecord, error) { rawDefinitions, ok := contract["contractDefinitions"].([]any) if !ok || len(rawDefinitions) == 0 { @@ -515,11 +541,11 @@ func admitCommands(root string, contract map[string]any, definitions map[string] } func admitCommandRoute(name string, route []string, existing map[string]string) error { - if len(route) == 0 || len(route) > 4 { + if len(route) < commandroute.MinimumTokens || len(route) > commandroute.MaximumTokens { return fmt.Errorf("command %s route must contain between one and four tokens", name) } for _, token := range route { - if !commandTokenPattern.MatchString(token) { + if !commandroute.ValidToken(token) { return fmt.Errorf("command %s route contains invalid token", name) } } @@ -529,18 +555,14 @@ func admitCommandRoute(name string, route []string, existing map[string]string) if slices.Equal(route, other) { return fmt.Errorf("commands %s and %s have the same route", owner, name) } - if routePrefix(route, other) || routePrefix(other, route) { + if commandroute.Prefix(route, other) || commandroute.Prefix(other, route) { return fmt.Errorf("command routes for %s and %s have an ambiguous prefix", owner, name) } } - existing[strings.Join(route, "\x00")] = name + existing[commandroute.Key(route)] = name return nil } -func routePrefix(prefix, value []string) bool { - return len(prefix) < len(value) && slices.Equal(prefix, value[:len(prefix)]) -} - func admitUniqueContractID(command string, direction string, contract map[string]any, seen map[string]string) error { id, ok := contract["contractId"].(string) if !ok || id == "" { diff --git a/internal/tools/commandcontractgen/main_test.go b/internal/tools/commandcontractgen/main_test.go index b462396..5c8872c 100644 --- a/internal/tools/commandcontractgen/main_test.go +++ b/internal/tools/commandcontractgen/main_test.go @@ -188,6 +188,27 @@ func TestRenderRejectsIncompleteAndStaleCommandContracts(t *testing.T) { mutate func(map[string]any) want string }{ + { + name: "command route grammar missing", + mutate: func(contract map[string]any) { + delete(contract["processContract"].(map[string]any), "commandRouteGrammar") + }, + want: "commandRouteGrammar must be an object", + }, + { + name: "command route maximum drift", + mutate: func(contract map[string]any) { + contract["processContract"].(map[string]any)["commandRouteGrammar"].(map[string]any)["maximumTokens"] = 5 + }, + want: "token bounds must be 1 through 4", + }, + { + name: "command route token grammar drift", + mutate: func(contract map[string]any) { + contract["processContract"].(map[string]any)["commandRouteGrammar"].(map[string]any)["tokenPattern"] = `^[a-z]+$` + }, + want: "does not match the native route owner", + }, { name: "required input contract missing", mutate: func(contract map[string]any) { @@ -619,10 +640,18 @@ func writeFixture(t *testing.T) string { structuralFixtureDefinition("proofkit.output.v1"), } contract := map[string]any{ - "schemaVersion": 2, - "contractId": "proofkit.cli-contract.v2", - "packageName": "@research-engineering/agentic-proofkit", - "processContract": map[string]any{}, + "schemaVersion": 2, + "contractId": "proofkit.cli-contract.v2", + "packageName": "@research-engineering/agentic-proofkit", + "processContract": map[string]any{ + "commandRouteGrammar": map[string]any{ + "minimumTokens": 1, + "maximumTokens": 4, + "separator": " ", + "tokenPattern": `^[a-z0-9]+(?:-[a-z0-9]+)*$`, + "ambiguityPolicy": "no_route_is_prefix_of_another", + }, + }, "contractDefinitions": definitions, "commands": []any{ map[string]any{ diff --git a/internal/tools/coveragemetrics/main.go b/internal/tools/coveragemetrics/main.go index 345db78..046565f 100644 --- a/internal/tools/coveragemetrics/main.go +++ b/internal/tools/coveragemetrics/main.go @@ -297,6 +297,7 @@ func validateRequiredBindingWitnessSelectors(bindings bindingFile) error { {"REQ-PROOFKIT-PACKAGE-003", "proofkit.package-boundary.outside-consumer-artifact"}: { "TestExactTarballOnboardingTrace", "TestInstalledCommandRouteBijectionBindsCommandIdentity", + "TestInstalledNPMCarrierIsExactRegularTarballProjection", "TestVerifyPackedOwnerRecordsRejectsSourceArtifactContentDrift", }, {"REQ-PROOFKIT-PACKAGE-004", "proofkit.package-boundary.ci-receipt-anchor"}: { @@ -308,8 +309,11 @@ func validateRequiredBindingWitnessSelectors(bindings bindingFile) error { {"REQ-PROOFKIT-PACKAGE-006", "proofkit.package-boundary.python-wheel-candidate"}: {"TestPythonArtifactRefsRejectEachWheelIdentityDefect"}, {"REQ-PROOFKIT-PACKAGE-006", "proofkit.package-boundary.python-wheel-generated-continuation"}: { "TestExactDisplayedRouteOperandsRejectsWhitespaceAndExpansionMutants", + "TestInstalledPythonCarrierRejectsContractReplacementRemovalAndSymlink", "TestInstalledPythonCommandRoutesRequireExactContractBijection", "TestInstalledWheelContinuationUsesExactPythonModuleProfileWithoutNPM", + "TestPipInstallArgumentsAreIsolatedAndOffline", + "TestPythonVerificationEnvironmentRemovesAmbientImportControls", }, {"REQ-PROOFKIT-PACKAGE-007", "proofkit.package-boundary.package-public-docs-no-mutable-release-facts"}: { "TestVerifyNoStalePackageDocsRejectsMutableReleaseFactsInMarkdown", @@ -357,6 +361,9 @@ func validateRequiredBindingWitnessSelectors(bindings bindingFile) error { "TestReadBoundedRejectsUnrepresentableLimit", "TestWriteReadAndRemoveRoundTrip", }, + {"REQ-PROOFKIT-QUALITY-010", "proofkit.supply-chain-quality.artifact-file-nonblocking-open"}: { + "TestReadBoundedRejectsFIFOWithoutBlocking", + }, {"REQ-PROOFKIT-QUALITY-010", "proofkit.supply-chain-quality.coverage-metrics"}: { "TestEachCommandRouteClosureConjunctHasIndependentFalsifier", "TestEachLinkageDeadZoneConjunctHasIndependentFalsifier", @@ -426,6 +433,7 @@ func validateRequiredBindingWitnessSelectors(bindings bindingFile) error { {"REQ-PROOFKIT-QUALITY-019", "proofkit.supply-chain-quality.installed-package-json-abi-smoke"}: { "TestExactTarballOnboardingTrace", "TestInstalledInvocationRequiresAuthoredOrderAndExactCommandToken", + "TestInstalledNPMCarrierIsExactRegularTarballProjection", "TestInstalledREADMEFirstInputPreservesJSONExampleBytes", "TestInstalledREADMEFirstInputUsesBoundedLiteralShellWords", "TestLiteralShellWordsConsumesLongBackslashRun", @@ -456,6 +464,13 @@ func validateRequiredBindingWitnessSelectors(bindings bindingFile) error { "TestVerifyWheelContentsAcceptsDarwinTagAtOrAboveMachOMinimum", "TestVerifyWheelContentsRejectsDarwinTagBelowMachOMinimum", }, + {"REQ-PROOFKIT-QUALITY-023", "proofkit.supply-chain-quality.python-wheel-resource-bounds"}: { + "TestVerifyWheelContentsRejectsOversizedCompressedEntryBeforeDecompression", + "TestVerifyWheelContentsRejectsOversizedEntryBeforeDecompression", + }, + {"REQ-PROOFKIT-QUALITY-023", "proofkit.supply-chain-quality.wrapper-platform-bijection"}: { + "TestWrapperScriptRoutesEveryReleasePlatformTarget", + }, {"REQ-PROOFKIT-QUALITY-015", "proofkit.supply-chain-quality.release-closeout-completion-criteria"}: { "TestBuildInputFailsClosedForEachBlockingEvidenceClass", "TestSelfEvidenceInvokesCurrentCommandOracleOwner", @@ -546,7 +561,7 @@ func validateRequiredBindingWitnessSelectors(bindings bindingFile) error { {"REQ-PROOFKIT-SPEC-026", "proofkit.spec-proof-core.agent-route-materialized-ref-admission"}: { "TestBuildRejectsStdinTransportSentinelAsArtifactReference", }, - {"REQ-PROOFKIT-SPEC-027", "proofkit.spec-proof-core.adoption-inventory-boundary"}: { + {"REQ-PROOFKIT-SPEC-028", "proofkit.spec-proof-core.adoption-inventory-boundary"}: { "TestCatalogRolePolicyIsExact", "TestInventoryIdentityBindsEverySemanticOperand", "TestInventoryOutputByteLimitIsExact", @@ -556,33 +571,49 @@ func validateRequiredBindingWitnessSelectors(bindings bindingFile) error { "TestScanPolicyBoundariesAreExact", "TestScanProducesBoundedClosedInventory", "TestScanRejectsRecognizedSymlinkWithoutReadingTarget", + "TestUnsupportedPlatformFailsBeforeOpeningRepositoryRoot", }, - {"REQ-PROOFKIT-SPEC-027", "proofkit.spec-proof-core.adoption-inventory-nonblocking-open"}: { + {"REQ-PROOFKIT-SPEC-028", "proofkit.spec-proof-core.adoption-inventory-nonblocking-open"}: { "TestScanRejectsFIFOReplacementWithoutBlocking", }, - {"REQ-PROOFKIT-SPEC-027", "proofkit.spec-proof-core.adoption-plan-authority-closure"}: { - "TestAdoptionPlanOutputAndTextBoundsAreExact", + {"REQ-PROOFKIT-SPEC-029", "proofkit.spec-proof-core.adoption-plan-authority-closure"}: { "TestBuildRejectsUnknownIntentPresetAndForgedInventory", "TestBuildSeparatesAdoptionIntentFromCandidateAuthority", "TestBuildStackHintCannotChangeIntentTrustOrTasks", "TestPlanIdentityBindsIntentAndInventory", "TestPlanWireAdmissionIsDeterministicAndOwnerClosed", - "TestTextProjectionPreservesJSONPlanSemantics", }, - {"REQ-PROOFKIT-SPEC-027", "proofkit.spec-proof-core.adoption-plan-observational-stack"}: { + {"REQ-PROOFKIT-SPEC-029", "proofkit.spec-proof-core.adoption-plan-observational-stack"}: { "TestPlanKeepsRepositoryClassesObservationalAndStackNeutral", }, - {"REQ-PROOFKIT-SPEC-027", "proofkit.spec-proof-core.adoption-guidance-reference-closure"}: { + {"REQ-PROOFKIT-SPEC-029", "proofkit.spec-proof-core.adoption-guidance-reference-closure"}: { "TestGuidanceReferenceIsCompactAndOwnerBound", }, + {"REQ-PROOFKIT-SPEC-030", "proofkit.spec-proof-core.adoption-plan-presentation-closure"}: { + "TestAdoptionPlanOutputAndTextBoundsAreExact", + "TestTextProjectionPreservesJSONPlanSemantics", + }, {"REQ-PROOFKIT-SPEC-027", "proofkit.spec-proof-core.adoption-front-door-whole-cli"}: { "TestAdoptionFrontDoorCLI", }, - {"REQ-PROOFKIT-SPEC-027", "proofkit.spec-proof-core.adoption-route-contract-closure"}: { + {"REQ-PROOFKIT-SPEC-018", "proofkit.spec-proof-core.command-route-contract-closure"}: { "TestCommandRoutesAreBoundedSafeAndUnambiguous", + "TestRenderRejectsIncompleteAndStaleCommandContracts", + }, + {"REQ-PROOFKIT-SPEC-018", "proofkit.spec-proof-core.command-route-generated-adapter"}: { + "TestGeneratedSourceAdmitsBoundedCanonicalCommandRoutes", + }, + {"REQ-PROOFKIT-SPEC-018", "proofkit.spec-proof-core.command-route-installed-contract"}: { + "TestAdmitCommandRouteTokenBoundariesAreExact", + "TestAdmitRequiresExactCommandRouteGrammarProjection", + }, + {"REQ-PROOFKIT-SPEC-018", "proofkit.spec-proof-core.command-route-kernel-owner"}: { + "TestGrammarBoundariesAreExact", + "TestParseRequiresCanonicalSeparatorAndRoundTrip", }, - {"REQ-PROOFKIT-SPEC-027", "proofkit.spec-proof-core.adoption-version-edge-closure"}: { + {"REQ-PROOFKIT-SPEC-031", "proofkit.spec-proof-core.adoption-version-edge-closure"}: { "TestAdoptionFrontDoorVersionEdgeClosesInitRetirement", + "TestAdoptionFrontDoorVersionEdgeRejectsDigestBoundInventoryContradiction", "TestRetiredInitRouteHasNoPublicDispatcher", }, {"REQ-PROOFKIT-RETIRE-006", "proofkit.consumer-infra-retirement.migration-parity-admission"}: { @@ -630,6 +661,7 @@ func validateRequiredBindingWitnessSelectors(bindings bindingFile) error { {"REQ-PROOFKIT-QUALITY-006", "proofkit.supply-chain-quality.osv-permission-separation"}: "scripts/workflow_security_scanner_oracles_test.go", {"REQ-PROOFKIT-QUALITY-007", "proofkit.supply-chain-quality.scorecard-permission-and-publication-inputs"}: "scripts/workflow_security_scanner_oracles_test.go", {"REQ-PROOFKIT-QUALITY-010", "proofkit.supply-chain-quality.artifact-file-boundary"}: "internal/tools/artifactfile/file_test.go", + {"REQ-PROOFKIT-QUALITY-010", "proofkit.supply-chain-quality.artifact-file-nonblocking-open"}: "internal/tools/artifactfile/file_unix_test.go", {"REQ-PROOFKIT-QUALITY-010", "proofkit.supply-chain-quality.binding-selector-executability"}: "internal/tools/coveragemetrics/main_test.go", {"REQ-PROOFKIT-QUALITY-010", "proofkit.supply-chain-quality.coverage-metrics"}: "internal/tools/coveragemetrics/main_test.go", {"REQ-PROOFKIT-QUALITY-010", "proofkit.supply-chain-quality.command-oracle-execution-ledger"}: "internal/tools/commandoracle/execute_test.go", @@ -645,6 +677,8 @@ func validateRequiredBindingWitnessSelectors(bindings bindingFile) error { {"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-023", "proofkit.supply-chain-quality.python-wheel-resource-bounds"}: "internal/tools/pythonpackage/metadata_test.go", + {"REQ-PROOFKIT-QUALITY-023", "proofkit.supply-chain-quality.wrapper-platform-bijection"}: "internal/tools/packagebuild/main_test.go", {"REQ-PROOFKIT-QUALITY-015", "proofkit.supply-chain-quality.release-closeout-completion-criteria"}: "internal/tools/releasecloseoutinput/main_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", @@ -667,14 +701,18 @@ func validateRequiredBindingWitnessSelectors(bindings bindingFile) error { {"REQ-PROOFKIT-SPEC-026", "proofkit.spec-proof-core.agent-route-flag-pre-read-admission"}: "internal/app/app_test.go", {"REQ-PROOFKIT-SPEC-026", "proofkit.spec-proof-core.agent-route-materialized-ref-admission"}: "internal/command/agentroute/agentroute_test.go", {"REQ-PROOFKIT-SPEC-026", "proofkit.spec-proof-core.agent-route-report-contract-closure"}: "internal/app/cli_contract_test.go", - {"REQ-PROOFKIT-SPEC-027", "proofkit.spec-proof-core.adoption-inventory-boundary"}: "internal/command/repositoryinventory/repositoryinventory_test.go", - {"REQ-PROOFKIT-SPEC-027", "proofkit.spec-proof-core.adoption-inventory-nonblocking-open"}: "internal/command/repositoryinventory/fifo_unix_test.go", - {"REQ-PROOFKIT-SPEC-027", "proofkit.spec-proof-core.adoption-plan-authority-closure"}: "internal/command/adoptionplan/adoptionplan_test.go", - {"REQ-PROOFKIT-SPEC-027", "proofkit.spec-proof-core.adoption-plan-observational-stack"}: "internal/command/adoptionplan/repository_classes_test.go", - {"REQ-PROOFKIT-SPEC-027", "proofkit.spec-proof-core.adoption-guidance-reference-closure"}: "internal/command/nativeevidenceguidance/guidance_test.go", + {"REQ-PROOFKIT-SPEC-028", "proofkit.spec-proof-core.adoption-inventory-boundary"}: "internal/command/repositoryinventory/repositoryinventory_test.go", + {"REQ-PROOFKIT-SPEC-028", "proofkit.spec-proof-core.adoption-inventory-nonblocking-open"}: "internal/command/repositoryinventory/fifo_unix_test.go", + {"REQ-PROOFKIT-SPEC-029", "proofkit.spec-proof-core.adoption-plan-authority-closure"}: "internal/command/adoptionplan/adoptionplan_test.go", + {"REQ-PROOFKIT-SPEC-029", "proofkit.spec-proof-core.adoption-plan-observational-stack"}: "internal/command/adoptionplan/repository_classes_test.go", + {"REQ-PROOFKIT-SPEC-029", "proofkit.spec-proof-core.adoption-guidance-reference-closure"}: "internal/command/nativeevidenceguidance/guidance_test.go", + {"REQ-PROOFKIT-SPEC-030", "proofkit.spec-proof-core.adoption-plan-presentation-closure"}: "internal/command/adoptionplan/adoptionplan_test.go", {"REQ-PROOFKIT-SPEC-027", "proofkit.spec-proof-core.adoption-front-door-whole-cli"}: "internal/app/adoption_front_door_command_test.go", - {"REQ-PROOFKIT-SPEC-027", "proofkit.spec-proof-core.adoption-route-contract-closure"}: "internal/tools/commandcontractgen/main_test.go", - {"REQ-PROOFKIT-SPEC-027", "proofkit.spec-proof-core.adoption-version-edge-closure"}: "internal/app/adoption_front_door_version_edge_test.go", + {"REQ-PROOFKIT-SPEC-018", "proofkit.spec-proof-core.command-route-contract-closure"}: "internal/tools/commandcontractgen/main_test.go", + {"REQ-PROOFKIT-SPEC-018", "proofkit.spec-proof-core.command-route-generated-adapter"}: "internal/command/jsonreportcliadaptersource/json_report_cli_adapter_source_test.go", + {"REQ-PROOFKIT-SPEC-018", "proofkit.spec-proof-core.command-route-installed-contract"}: "internal/tools/installedclicontract/contract_test.go", + {"REQ-PROOFKIT-SPEC-018", "proofkit.spec-proof-core.command-route-kernel-owner"}: "internal/kernel/commandroute/route_test.go", + {"REQ-PROOFKIT-SPEC-031", "proofkit.spec-proof-core.adoption-version-edge-closure"}: "internal/app/adoption_front_door_version_edge_test.go", } if len(requiredPaths) != len(required) { return fmt.Errorf("required selector path inventory=%d, selector inventory=%d", len(requiredPaths), len(required)) diff --git a/internal/tools/installedclicontract/contract.go b/internal/tools/installedclicontract/contract.go index ce3a68f..e79564c 100644 --- a/internal/tools/installedclicontract/contract.go +++ b/internal/tools/installedclicontract/contract.go @@ -2,20 +2,22 @@ package installedclicontract import ( "bytes" + "encoding/json" "fmt" "sort" + "strconv" "strings" "unicode/utf8" "github.com/research-engineering/agentic-proofkit/internal/kernel/admission" + "github.com/research-engineering/agentic-proofkit/internal/kernel/commandroute" ) const ( - MaximumContractBytes = 1 << 20 - MaximumCommands = 512 - MaximumPresetIDs = 128 - maximumCommandRouteTokens = 4 - maximumHelpBytes = 256 << 10 + MaximumContractBytes = 1 << 20 + MaximumCommands = 512 + MaximumPresetIDs = 128 + maximumHelpBytes = 256 << 10 ) // Contract is the admitted package-artifact projection needed by installed @@ -42,6 +44,9 @@ func Admit(content []byte) (Contract, error) { if !ok { return Contract{}, fmt.Errorf("installed CLI contract must be an object") } + if err := admitCommandRouteGrammar(record); err != nil { + return Contract{}, err + } commands, ok := record["commands"].([]any) if !ok || len(commands) == 0 || len(commands) > MaximumCommands { return Contract{}, fmt.Errorf("installed CLI contract commands must contain between 1 and %d entries", MaximumCommands) @@ -57,7 +62,7 @@ func Admit(content []byte) (Contract, error) { return Contract{}, fmt.Errorf("installed CLI contract command %d must be an object", index) } commandID, ok := command["command"].(string) - if !ok || !ValidRouteToken(commandID) { + if !ok || !commandroute.ValidToken(commandID) { return Contract{}, fmt.Errorf("installed CLI contract command %d has an invalid command id", index) } if _, exists := seenCommandIDs[commandID]; exists { @@ -68,19 +73,19 @@ func Admit(content []byte) (Contract, error) { routeTokens := []string{commandID} if rawRoute, exists := command["route"]; exists { route, ok := rawRoute.([]any) - if !ok || len(route) == 0 || len(route) > maximumCommandRouteTokens { + if !ok || len(route) < commandroute.MinimumTokens || len(route) > commandroute.MaximumTokens { return Contract{}, fmt.Errorf("installed CLI contract command %d has an invalid route", index) } routeTokens = make([]string, 0, len(route)) for _, rawToken := range route { token, ok := rawToken.(string) - if !ok || !ValidRouteToken(token) { + if !ok || !commandroute.ValidToken(token) { return Contract{}, fmt.Errorf("installed CLI contract command %d has an invalid route token", index) } routeTokens = append(routeTokens, token) } } - routeText := strings.Join(routeTokens, " ") + routeText := commandroute.Text(routeTokens) if _, exists := commandIDsByRoute[routeText]; exists { return Contract{}, fmt.Errorf("installed CLI contract duplicates a command route") } @@ -107,28 +112,63 @@ func Admit(content []byte) (Contract, error) { return Contract{commandIDsByRoute: commandIDsByRoute, presetIDs: presetIDs}, nil } +func admitCommandRouteGrammar(contract map[string]any) error { + processContract, ok := contract["processContract"].(map[string]any) + if !ok { + return fmt.Errorf("installed CLI contract processContract must be an object") + } + grammar, ok := processContract["commandRouteGrammar"].(map[string]any) + if !ok || len(grammar) != 5 { + return fmt.Errorf("installed CLI contract commandRouteGrammar must contain exactly five fields") + } + for _, key := range []string{"ambiguityPolicy", "maximumTokens", "minimumTokens", "separator", "tokenPattern"} { + if _, exists := grammar[key]; !exists { + return fmt.Errorf("installed CLI contract commandRouteGrammar is missing %s", key) + } + } + minimum, minimumOK := exactPositiveInteger(grammar["minimumTokens"]) + maximum, maximumOK := exactPositiveInteger(grammar["maximumTokens"]) + if !minimumOK || !maximumOK || minimum != commandroute.MinimumTokens || maximum != commandroute.MaximumTokens || + grammar["separator"] != commandroute.Separator || grammar["tokenPattern"] != commandroute.TokenPattern || + grammar["ambiguityPolicy"] != commandroute.AmbiguityPolicy { + return fmt.Errorf("installed CLI contract commandRouteGrammar differs from the supported process grammar") + } + return nil +} + +func exactPositiveInteger(value any) (int, bool) { + number, ok := value.(json.Number) + if !ok { + return 0, false + } + result, err := strconv.Atoi(number.String()) + return result, err == nil && result > 0 +} + +func (contract Contract) AdmitRouteText(text string) ([]string, error) { + tokens, ok := commandroute.Parse(text) + if !ok { + return nil, fmt.Errorf("installed CLI command route does not match commandRouteGrammar") + } + return tokens, nil +} + // AdmitHelpIdentity extracts the exact public command identity from one leaf // help response. Package verifiers use it to prove route-to-command ownership, // not merely route-set equality. -func AdmitHelpIdentity(content []byte) (HelpIdentity, error) { +func (contract Contract) AdmitHelpIdentity(content []byte) (HelpIdentity, error) { if len(content) == 0 || len(content) > maximumHelpBytes || !utf8.Valid(content) || bytes.IndexByte(content, 0) >= 0 { return HelpIdentity{}, fmt.Errorf("installed CLI leaf help is not bounded UTF-8 text") } lines := strings.Split(string(content), "\n") commandID, commandCount := helpField(lines, "Command ID:") route, routeCount := helpField(lines, "Route:") - if commandCount != 1 || routeCount != 1 || !ValidRouteToken(commandID) { + if commandCount != 1 || routeCount != 1 || !commandroute.ValidToken(commandID) { return HelpIdentity{}, fmt.Errorf("installed CLI leaf help identity is invalid") } - routeTokens := strings.Split(route, " ") - if len(routeTokens) == 0 || len(routeTokens) > maximumCommandRouteTokens { + if _, err := contract.AdmitRouteText(route); err != nil { return HelpIdentity{}, fmt.Errorf("installed CLI leaf help route is invalid") } - for _, token := range routeTokens { - if !ValidRouteToken(token) { - return HelpIdentity{}, fmt.Errorf("installed CLI leaf help route is invalid") - } - } return HelpIdentity{CommandID: commandID, Route: route}, nil } @@ -162,27 +202,6 @@ func (contract Contract) PresetIDs() ([]string, error) { return append([]string(nil), contract.presetIDs...), nil } -func ValidRouteToken(token string) bool { - if token == "" || token[0] == '-' || token[len(token)-1] == '-' { - return false - } - previousHyphen := false - for _, value := range token { - if value == '-' { - if previousHyphen { - return false - } - previousHyphen = true - continue - } - if (value < 'a' || value > 'z') && (value < '0' || value > '9') { - return false - } - previousHyphen = false - } - return true -} - func admitPresetIDs(command map[string]any) ([]string, error) { output, ok := command["outputContract"].(map[string]any) if !ok { diff --git a/internal/tools/installedclicontract/contract_test.go b/internal/tools/installedclicontract/contract_test.go index 622ecfe..6530d3c 100644 --- a/internal/tools/installedclicontract/contract_test.go +++ b/internal/tools/installedclicontract/contract_test.go @@ -9,7 +9,7 @@ import ( ) func TestAdmitPreservesExactRoutesAndPresetChoices(t *testing.T) { - content := []byte(`{"commands":[{"command":"adopt-plan","route":["adopt","plan"]},{"command":"stack-preset","outputContract":{"flagChoices":{"--preset":["go_cli_repo","python_service"]}}}]}`) + content := contractFixture(`{"command":"adopt-plan","route":["adopt","plan"]},{"command":"stack-preset","outputContract":{"flagChoices":{"--preset":["go_cli_repo","python_service"]}}}`) contract, err := Admit(content) if err != nil { t.Fatalf("Admit() error = %v", err) @@ -34,21 +34,21 @@ func TestAdmitPreservesExactRoutesAndPresetChoices(t *testing.T) { func TestAdmitRejectsAmbiguousOrIncompleteContracts(t *testing.T) { mutants := map[string]string{ - "duplicate key": `{"commands":[{"command":"one","command":"two"},{"command":"stack-preset","outputContract":{"flagChoices":{"--preset":["go_cli_repo"]}}}]}`, - "duplicate id": `{"commands":[{"command":"same"},{"command":"same","route":["other"]},{"command":"stack-preset","outputContract":{"flagChoices":{"--preset":["go_cli_repo"]}}}]}`, - "duplicate route": `{"commands":[{"command":"one","route":["same"]},{"command":"two","route":["same"]},{"command":"stack-preset","outputContract":{"flagChoices":{"--preset":["go_cli_repo"]}}}]}`, - "route prefix": `{"commands":[{"command":"one","route":["adopt"]},{"command":"two","route":["adopt","plan"]},{"command":"stack-preset","outputContract":{"flagChoices":{"--preset":["go_cli_repo"]}}}]}`, - "duplicate preset": `{"commands":[{"command":"stack-preset","outputContract":{"flagChoices":{"--preset":["same","same"]}}}]}`, - "unsorted presets": `{"commands":[{"command":"stack-preset","outputContract":{"flagChoices":{"--preset":["z","a"]}}}]}`, - } - for name, content := range mutants { + "duplicate key": `{"command":"one","command":"two"},{"command":"stack-preset","outputContract":{"flagChoices":{"--preset":["go_cli_repo"]}}}`, + "duplicate id": `{"command":"same"},{"command":"same","route":["other"]},{"command":"stack-preset","outputContract":{"flagChoices":{"--preset":["go_cli_repo"]}}}`, + "duplicate route": `{"command":"one","route":["same"]},{"command":"two","route":["same"]},{"command":"stack-preset","outputContract":{"flagChoices":{"--preset":["go_cli_repo"]}}}`, + "route prefix": `{"command":"one","route":["adopt"]},{"command":"two","route":["adopt","plan"]},{"command":"stack-preset","outputContract":{"flagChoices":{"--preset":["go_cli_repo"]}}}`, + "duplicate preset": `{"command":"stack-preset","outputContract":{"flagChoices":{"--preset":["same","same"]}}}`, + "unsorted presets": `{"command":"stack-preset","outputContract":{"flagChoices":{"--preset":["z","a"]}}}`, + } + for name, commands := range mutants { t.Run(name, func(t *testing.T) { - if _, err := Admit([]byte(content)); err == nil { - t.Fatalf("Admit() accepted mutant: %s", content) + if _, err := Admit(contractFixture(commands)); err == nil { + t.Fatalf("Admit() accepted mutant: %s", commands) } }) } - contract, err := Admit([]byte(`{"commands":[{"command":"one"}]}`)) + contract, err := Admit(contractFixture(`{"command":"one"}`)) if err != nil { t.Fatalf("route-only contract rejected: %v", err) } @@ -68,7 +68,7 @@ func TestAdmitCommandRouteTokenBoundariesAreExact(t *testing.T) { {name: "max+1", route: `["five","route","tokens","are","invalid"]`, wantErr: true}, } { t.Run(test.name, func(t *testing.T) { - content := []byte(`{"commands":[{"command":"sample","route":` + test.route + `}]}`) + content := contractFixture(`{"command":"sample","route":` + test.route + `}`) _, err := Admit(content) if test.wantErr && err == nil { t.Fatal("Admit() accepted route above token limit") @@ -81,7 +81,7 @@ func TestAdmitCommandRouteTokenBoundariesAreExact(t *testing.T) { } func TestAdmitContractResourceBoundsAreExact(t *testing.T) { - base := []byte(`{"commands":[{"command":"one"}]}`) + base := contractFixture(`{"command":"one"}`) exactBytes := append(append([]byte(nil), base...), bytes.Repeat([]byte(" "), MaximumContractBytes-len(base))...) if _, err := Admit(exactBytes); err != nil { t.Fatalf("exact byte limit rejected: %v", err) @@ -95,7 +95,7 @@ func TestAdmitContractResourceBoundsAreExact(t *testing.T) { commands[index] = fmt.Sprintf(`{"command":"command-%d"}`, index) } contract := func(count int) []byte { - return []byte(`{"commands":[` + strings.Join(commands[:count], ",") + `]}`) + return contractFixture(strings.Join(commands[:count], ",")) } if _, err := Admit(contract(MaximumCommands)); err != nil { t.Fatalf("exact command cardinality rejected: %v", err) @@ -109,7 +109,7 @@ func TestAdmitContractResourceBoundsAreExact(t *testing.T) { presets[index] = fmt.Sprintf(`"preset-%03d"`, index) } presetContract := func(count int) []byte { - return []byte(`{"commands":[{"command":"stack-preset","outputContract":{"flagChoices":{"--preset":[` + strings.Join(presets[:count], ",") + `]}}}]}`) + return contractFixture(`{"command":"stack-preset","outputContract":{"flagChoices":{"--preset":[` + strings.Join(presets[:count], ",") + `]}}}`) } if _, err := Admit(presetContract(MaximumPresetIDs)); err != nil { t.Fatalf("exact preset cardinality rejected: %v", err) @@ -120,8 +120,12 @@ func TestAdmitContractResourceBoundsAreExact(t *testing.T) { } func TestAdmitHelpIdentityRequiresExactCommandAndRoute(t *testing.T) { + contract, err := Admit(contractFixture(`{"command":"adopt-plan","route":["adopt","plan"]}`)) + if err != nil { + t.Fatal(err) + } help := []byte("Usage:\n agentic-proofkit adopt plan\n\nCommand ID:\n adopt-plan\n\nRoute:\n adopt plan\n") - identity, err := AdmitHelpIdentity(help) + identity, err := contract.AdmitHelpIdentity(help) if err != nil { t.Fatalf("AdmitHelpIdentity() error = %v", err) } @@ -135,8 +139,28 @@ func TestAdmitHelpIdentityRequiresExactCommandAndRoute(t *testing.T) { append([]byte(nil), bytes.Repeat([]byte("x"), maximumHelpBytes+1)...), } for index, mutant := range mutants { - if _, err := AdmitHelpIdentity(mutant); err == nil { + if _, err := contract.AdmitHelpIdentity(mutant); err == nil { t.Fatalf("help identity mutant %d was accepted", index) } } } + +func TestAdmitRequiresExactCommandRouteGrammarProjection(t *testing.T) { + base := string(contractFixture(`{"command":"one"}`)) + mutants := []string{ + strings.Replace(base, `"maximumTokens":4`, `"maximumTokens":5`, 1), + strings.Replace(base, `"maximumTokens":4`, `"maximumTokens":18446744073709551620`, 1), + strings.Replace(base, `"separator":" "`, `"separator":"/"`, 1), + strings.Replace(base, `"tokenPattern":"^[a-z0-9]+(?:-[a-z0-9]+)*$"`, `"tokenPattern":"^[a-z]+$"`, 1), + strings.Replace(base, `"ambiguityPolicy":"no_route_is_prefix_of_another"`, `"ambiguityPolicy":"allow_prefixes"`, 1), + } + for index, mutant := range mutants { + if _, err := Admit([]byte(mutant)); err == nil { + t.Fatalf("command route grammar mutant %d was accepted", index) + } + } +} + +func contractFixture(commands string) []byte { + return []byte(`{"processContract":{"commandRouteGrammar":{"minimumTokens":1,"maximumTokens":4,"separator":" ","tokenPattern":"^[a-z0-9]+(?:-[a-z0-9]+)*$","ambiguityPolicy":"no_route_is_prefix_of_another"}},"commands":[` + commands + `]}`) +} diff --git a/internal/tools/packagebuild/main_test.go b/internal/tools/packagebuild/main_test.go index 7e6a6d3..f13afb0 100644 --- a/internal/tools/packagebuild/main_test.go +++ b/internal/tools/packagebuild/main_test.go @@ -38,18 +38,25 @@ func TestWrapperScriptRoutesEveryReleasePlatformTarget(t *testing.T) { if err != nil { t.Fatalf("wrapperScript() error = %v", err) } - for _, target := range releaseplatform.Targets() { - if !strings.Contains(wrapper, `platform="`+target.PlatformSuffix+`"`) { - t.Fatalf("wrapperScript() missing platform route for %s:\n%s", target.PlatformSuffix, wrapper) - } - for _, osName := range wrapperOSAliases(target.GOOS) { - for _, arch := range wrapperArchAliases(target.GOARCH) { - pattern := osName + "/" + arch - if !strings.Contains(wrapper, pattern+")") { - t.Fatalf("wrapperScript() missing uname pattern %s for %s:\n%s", pattern, target.PlatformSuffix, wrapper) - } - } - } + wantCases := strings.Join([]string{ + ` Darwin/aarch64) platform="darwin-arm64" ;;`, + ` Darwin/amd64) platform="darwin-x64" ;;`, + ` Darwin/arm64) platform="darwin-arm64" ;;`, + ` Darwin/x86_64) platform="darwin-x64" ;;`, + ` Linux/aarch64) platform="linux-arm64" ;;`, + ` Linux/amd64) platform="linux-x64" ;;`, + ` Linux/arm64) platform="linux-arm64" ;;`, + ` Linux/x86_64) platform="linux-x64" ;;`, + }, "\n") + gotCases, err := wrapperPlatformCases(releaseplatform.Targets()) + if err != nil { + t.Fatalf("wrapperPlatformCases() error = %v", err) + } + if gotCases != wantCases { + t.Fatalf("wrapperPlatformCases() =\n%s\nwant exact OS/architecture mapping\n%s", gotCases, wantCases) + } + if strings.Count(wrapper, wantCases) != 1 { + t.Fatalf("wrapperScript() must embed the exact platform mapping once:\n%s", wrapper) } for _, required := range []string{ "AGENTIC_PROOFKIT_LAUNCHER_PROFILE=npm_offline\n", diff --git a/internal/tools/packageverify/main.go b/internal/tools/packageverify/main.go index 64bb5ce..90f0ef1 100644 --- a/internal/tools/packageverify/main.go +++ b/internal/tools/packageverify/main.go @@ -26,16 +26,19 @@ import ( "github.com/research-engineering/agentic-proofkit/internal/command/jsonreportcliadaptersource" "github.com/research-engineering/agentic-proofkit/internal/kernel/admission" "github.com/research-engineering/agentic-proofkit/internal/kernel/cliexec" + "github.com/research-engineering/agentic-proofkit/internal/kernel/commandroute" "github.com/research-engineering/agentic-proofkit/internal/kernel/diagnostic" "github.com/research-engineering/agentic-proofkit/internal/kernel/digest" "github.com/research-engineering/agentic-proofkit/internal/kernel/releaseplatform" "github.com/research-engineering/agentic-proofkit/internal/kernel/unicodepolicy" + "github.com/research-engineering/agentic-proofkit/internal/tools/artifactfile" "github.com/research-engineering/agentic-proofkit/internal/tools/installedclicontract" "github.com/research-engineering/agentic-proofkit/internal/tools/workflowsmoke" ) const rootPackageName = "@research-engineering/agentic-proofkit" const rootBinaryName = "agentic-proofkit" +const installedNPMPackageRelativeRoot = "node_modules/@research-engineering/agentic-proofkit" const installedNPMExecCommandPrefix = "npm exec --offline -- agentic-proofkit " const maxTarEntryBytes = 128 << 20 const maxEmbeddedBinaryBytes = 64 << 20 @@ -1678,17 +1681,84 @@ func verifyOutsideConsumer(artifact rootPackageArtifact) error { } func verifyExactTarballConsumer(artifact rootPackageArtifact) error { + snapshot, err := installedNPMCarrierSnapshotFromTarball(artifact) + if err != nil { + return err + } return withExactTarballConsumer(artifact, func(consumer string) error { - if err := verifyInstalledOnboardingTraceWithExecutors(consumer, runInstalledWithInput, runInstalledBinaryWithInput); err != nil { + if err := snapshot.Verify(consumer); err != nil { + return err + } + if err := verifyInstalledOnboardingTraceWithCarrier(consumer, snapshot.Contract, snapshot.Readme, runInstalledWithInput, runInstalledBinaryWithInput); err != nil { return err } if err := verifyInstalledJSONABI(consumer); err != nil { return err } - return verifyOutsideConsumerImports(consumer) + if err := verifyOutsideConsumerImports(consumer); err != nil { + return err + } + return snapshot.Verify(consumer) }) } +func installedNPMCarrierSnapshotFromTarball(artifact rootPackageArtifact) (installedNPMCarrierSnapshot, error) { + target, err := releaseplatform.CurrentTarget() + if err != nil { + return installedNPMCarrierSnapshot{}, err + } + entries := []struct { + maximumBytes int64 + relativePath string + tarEntry string + }{ + {maximumBytes: 1 << 20, relativePath: "dist/agentic-proofkit", tarEntry: "package/dist/agentic-proofkit"}, + {maximumBytes: maxEmbeddedBinaryBytes, relativePath: strings.TrimPrefix(target.PackageTarEntry, "package/"), tarEntry: target.PackageTarEntry}, + {maximumBytes: 4 << 20, relativePath: "README.md", tarEntry: "package/README.md"}, + {maximumBytes: installedclicontract.MaximumContractBytes, relativePath: "proofkit/cli-contract.v2.json", tarEntry: "package/proofkit/cli-contract.v2.json"}, + } + snapshot := installedNPMCarrierSnapshot{Files: make([]installedNPMCarrierFile, 0, len(entries))} + for _, entry := range entries { + content, err := readTarFileFromBytes(artifact.Content, entry.tarEntry) + if err != nil { + return installedNPMCarrierSnapshot{}, fmt.Errorf("read package carrier entry %s: %w", entry.tarEntry, err) + } + if len(content) == 0 || int64(len(content)) > entry.maximumBytes { + return installedNPMCarrierSnapshot{}, fmt.Errorf("package carrier entry %s is outside its byte bounds", entry.tarEntry) + } + ownedContent := append([]byte(nil), content...) + snapshot.Files = append(snapshot.Files, installedNPMCarrierFile{ + Content: ownedContent, + MaximumBytes: entry.maximumBytes, + RelativePath: entry.relativePath, + }) + if entry.relativePath == "proofkit/cli-contract.v2.json" { + snapshot.Contract = append([]byte(nil), ownedContent...) + } + if entry.relativePath == "README.md" { + snapshot.Readme = append([]byte(nil), ownedContent...) + } + } + if _, err := installedclicontract.Admit(snapshot.Contract); err != nil { + return installedNPMCarrierSnapshot{}, fmt.Errorf("admit package carrier CLI contract: %w", err) + } + return snapshot, nil +} + +func (snapshot installedNPMCarrierSnapshot) Verify(consumer string) error { + for _, file := range snapshot.Files { + installedPath := pathpkg.Join(installedNPMPackageRelativeRoot, file.RelativePath) + content, err := artifactfile.ReadBounded(consumer, installedPath, file.MaximumBytes) + if err != nil { + return fmt.Errorf("read installed npm carrier %s: %w", file.RelativePath, err) + } + if !bytes.Equal(content, file.Content) { + return fmt.Errorf("installed npm carrier %s differs from the exact package tarball", file.RelativePath) + } + } + return nil +} + func withExactTarballConsumer(artifact rootPackageArtifact, verify func(string) error) error { consumer, err := os.MkdirTemp("", "proofkit-consumer-*") if err != nil { @@ -1743,11 +1813,23 @@ type installedHelpRoute struct { Argv []string } -func verifyInstalledOnboardingTrace(consumer string, execute installedCommandOperation) error { - return verifyInstalledOnboardingTraceWithExecutors(consumer, execute, execute) +type installedNPMCarrierFile struct { + Content []byte + MaximumBytes int64 + RelativePath string } -func verifyInstalledOnboardingTraceWithExecutors(consumer string, transportExecute installedCommandOperation, binaryExecute installedCommandOperation) error { +type installedNPMCarrierSnapshot struct { + Contract []byte + Files []installedNPMCarrierFile + Readme []byte +} + +func verifyInstalledOnboardingTraceWithCarrier(consumer string, contractContent []byte, readme []byte, transportExecute installedCommandOperation, binaryExecute installedCommandOperation) error { + installedContract, err := installedclicontract.Admit(contractContent) + if err != nil { + return fmt.Errorf("admit outside consumer CLI contract: %w", err) + } rootHelp, err := transportExecute(consumer, nil, "help") if err != nil { return fmt.Errorf("outside consumer root help failed to run: %w", err) @@ -1773,15 +1855,7 @@ func verifyInstalledOnboardingTraceWithExecutors(consumer string, transportExecu if err != nil { return err } - contractPath := filepath.Join(consumer, "node_modules", "@research-engineering", "agentic-proofkit", "proofkit", "cli-contract.v2.json") - contractContent, err := os.ReadFile(contractPath) - if err != nil { - return fmt.Errorf("read installed CLI contract: %w", err) - } - contractCommandIDsByRoute, err := installedContractCommandIDsByRoute(contractContent) - if err != nil { - return err - } + contractCommandIDsByRoute := installedContract.CommandIDsByRoute() multiTokenRoute, err := representativeMultiTokenHelpRoute(contractCommandIDsByRoute) if err != nil { return err @@ -1831,7 +1905,7 @@ func verifyInstalledOnboardingTraceWithExecutors(consumer string, transportExecu if err := requireInstalledInvocationSyntax(leafHelp.Stdout, leafRoute.Route); err != nil { return err } - helpIdentity, err := installedclicontract.AdmitHelpIdentity(leafHelp.Stdout) + helpIdentity, err := installedContract.AdmitHelpIdentity(leafHelp.Stdout) if err != nil { return fmt.Errorf("outside consumer %s help identity is invalid: %w", leafRoute.ID, err) } @@ -1870,7 +1944,7 @@ func verifyInstalledOnboardingTraceWithExecutors(consumer string, transportExecu if err != nil { return err } - contractPresetIDs, err := installedContractPresetIDs(contractContent) + contractPresetIDs, err := installedContract.PresetIDs() if err != nil { return err } @@ -1908,15 +1982,9 @@ func verifyInstalledOnboardingTraceWithExecutors(consumer string, transportExecu if err := requirePassedJSON(continuation, "stack preset self-continuation"); err != nil { return err } - readmeRelativePath, err := installedREADMEPath(requirementSourceHelp.Stdout) - if err != nil { + if _, err := installedREADMEPath(requirementSourceHelp.Stdout); err != nil { return err } - readmePath := filepath.Join(consumer, filepath.FromSlash(readmeRelativePath)) - readme, err := os.ReadFile(readmePath) - if err != nil { - return fmt.Errorf("read installed README: %w", err) - } argv, input, err := installedREADMEFirstInput(readme) if err != nil { return err @@ -2044,7 +2112,8 @@ func parseInstalledLeafHelpRoutes(help string, contractCommandIDsByRoute map[str } routeText := strings.TrimSpace(line) routeTokens, err := parseLiteralShellWords(routeText) - if err != nil || len(routeTokens) == 0 || len(routeTokens) > 4 || strings.Join(routeTokens, " ") != routeText { + admittedTokens, admitted := commandroute.Parse(routeText) + if err != nil || !admitted || !slices.Equal(routeTokens, admittedTokens) { return nil, fmt.Errorf("command family has an invalid command route %q", routeText) } for _, token := range routeTokens { @@ -2074,7 +2143,7 @@ func parseInstalledLeafHelpRoutes(help string, contractCommandIDsByRoute map[str if err != nil { return nil, err } - if len(argv) < 2 || len(argv) > 5 || argv[0] != "help" { + if len(argv) < 1+commandroute.MinimumTokens || len(argv) > 1+commandroute.MaximumTokens || argv[0] != "help" { return nil, fmt.Errorf("outside consumer leaf help route must resolve to help ") } routeText := strings.Join(argv[1:], " ") @@ -2347,22 +2416,6 @@ func parseInstalledPresetIDs(help string) ([]string, error) { return ids, nil } -func installedContractPresetIDs(content []byte) ([]string, error) { - contract, err := installedclicontract.Admit(content) - if err != nil { - return nil, err - } - return contract.PresetIDs() -} - -func installedContractCommandIDsByRoute(content []byte) (map[string]string, error) { - contract, err := installedclicontract.Admit(content) - if err != nil { - return nil, err - } - return contract.CommandIDsByRoute(), nil -} - func installedREADMEFirstInput(content []byte) ([]string, []byte, error) { text, err := unicodepolicy.DecodeUTF8(content) if err != nil { diff --git a/internal/tools/packageverify/main_test.go b/internal/tools/packageverify/main_test.go index 54e04e1..c6e62d2 100644 --- a/internal/tools/packageverify/main_test.go +++ b/internal/tools/packageverify/main_test.go @@ -18,8 +18,10 @@ import ( "strings" "testing" + "github.com/research-engineering/agentic-proofkit/internal/kernel/commandroute" "github.com/research-engineering/agentic-proofkit/internal/kernel/digest" "github.com/research-engineering/agentic-proofkit/internal/kernel/releaseplatform" + "github.com/research-engineering/agentic-proofkit/internal/tools/installedclicontract" ) func TestPackageVerifyReadersRejectAmbiguousJSON(t *testing.T) { @@ -872,12 +874,93 @@ func TestExactTarballOnboardingTrace(t *testing.T) { } t.Setenv("TMPDIR", quotedTempRoot) if err := withExactTarballConsumer(artifact, func(consumer string) error { - return verifyInstalledOnboardingTrace(consumer, runInstalledWithInput) + snapshot, err := installedNPMCarrierSnapshotFromTarball(artifact) + if err != nil { + return err + } + if err := snapshot.Verify(consumer); err != nil { + return err + } + return verifyInstalledOnboardingTraceWithCarrier(consumer, snapshot.Contract, snapshot.Readme, runInstalledWithInput, runInstalledBinaryWithInput) }); err != nil { t.Fatalf("exact tarball onboarding trace failed: %v", err) } } +func TestInstalledNPMCarrierIsExactRegularTarballProjection(t *testing.T) { + target, err := releaseplatform.CurrentTarget() + if err != nil { + t.Fatalf("current platform has no admitted npm package target: %v", err) + } + contract := mustReadBytes(t, filepath.Join("..", "..", "..", "proofkit", "cli-contract.v2.json")) + readme := mustReadBytes(t, filepath.Join("..", "..", "..", "README.md")) + tarball := writePackageTarball(t, map[string]string{ + "package/dist/agentic-proofkit": "wrapper\n", + target.PackageTarEntry: "binary\n", + "package/README.md": string(readme), + "package/proofkit/cli-contract.v2.json": string(contract), + }) + snapshot, err := installedNPMCarrierSnapshotFromTarball(rootPackageArtifact{Content: mustReadBytes(t, tarball)}) + if err != nil { + t.Fatal(err) + } + + materialize := func(t *testing.T) string { + t.Helper() + consumer := t.TempDir() + packageRoot := installedNPMPackageRoot(consumer) + for _, file := range snapshot.Files { + writeFileBytes(t, filepath.Join(packageRoot, filepath.FromSlash(file.RelativePath)), file.Content) + } + return consumer + } + + t.Run("exact bytes", func(t *testing.T) { + if err := snapshot.Verify(materialize(t)); err != nil { + t.Fatalf("exact carrier rejected: %v", err) + } + }) + t.Run("changed bytes", func(t *testing.T) { + consumer := materialize(t) + writeFileBytes(t, filepath.Join(installedNPMPackageRoot(consumer), "proofkit", "cli-contract.v2.json"), append(contract, ' ')) + if err := snapshot.Verify(consumer); err == nil || !strings.Contains(err.Error(), "differs from the exact package tarball") { + t.Fatalf("changed carrier error=%v, want exact-byte rejection", err) + } + }) + t.Run("symlink", func(t *testing.T) { + consumer := materialize(t) + contractPath := filepath.Join(installedNPMPackageRoot(consumer), "proofkit", "cli-contract.v2.json") + if err := os.Remove(contractPath); err != nil { + t.Fatal(err) + } + targetPath := filepath.Join(t.TempDir(), "contract.json") + writeFileBytes(t, targetPath, contract) + if err := os.Symlink(targetPath, contractPath); err != nil { + t.Fatalf("create carrier symlink falsifier: %v", err) + } + if err := snapshot.Verify(consumer); err == nil || !strings.Contains(err.Error(), "regular non-symlink") { + t.Fatalf("symlink carrier error=%v, want non-symlink rejection", err) + } + }) + t.Run("package root symlink", func(t *testing.T) { + consumer := materialize(t) + packageRoot := installedNPMPackageRoot(consumer) + external := t.TempDir() + for _, file := range snapshot.Files { + writeFileBytes(t, filepath.Join(external, filepath.FromSlash(file.RelativePath)), file.Content) + } + if err := os.RemoveAll(packageRoot); err != nil { + t.Fatal(err) + } + if err := os.Symlink(external, packageRoot); err != nil { + t.Fatal(err) + } + if err := snapshot.Verify(consumer); err == nil || !strings.Contains(err.Error(), "symlink") { + t.Fatalf("package-root symlink error=%v, want confinement rejection", err) + } + }) +} + func installedNPMWrapperFixture(platformSuffix string) string { return `#!/usr/bin/env sh set -eu @@ -904,6 +987,10 @@ exec "$binary" "$@" ` } +func installedNPMPackageRoot(consumer string) string { + return filepath.Join(consumer, "node_modules", "@research-engineering", "agentic-proofkit") +} + func TestREADMEInstallPolicyRequiresPreOneExactPinExplanation(t *testing.T) { if err := verifyREADMEInstallPolicy(readmePreOneExactPinPolicy); err != nil { t.Fatalf("owner policy rejected: %v", err) @@ -931,32 +1018,36 @@ func TestOnboardingTraceCoversEveryDiscoveredPresetAndREADMEInput(t *testing.T) selfCheckInstalledInvocation := "npm exec --offline -- agentic-proofkit self-check --input " presetRoutePrefix := "npm exec --offline -- agentic-proofkit stack-preset --preset " readmeContinuation := "Path: node_modules/@research-engineering/agentic-proofkit/README.md" - installedRoot := filepath.Join(consumer, "node_modules", "@research-engineering", "agentic-proofkit") readme, err := os.ReadFile(filepath.Join("..", "..", "..", "README.md")) if err != nil { t.Fatal(err) } - writeFileBytes(t, filepath.Join(installedRoot, "README.md"), readme) ownerContract := mustReadBytes(t, filepath.Join("..", "..", "..", "proofkit", "cli-contract.v2.json")) - choices, err := installedContractPresetIDs(ownerContract) + contract, err := installedclicontract.Admit(ownerContract) + if err != nil { + t.Fatal(err) + } + choices, err := contract.PresetIDs() if err != nil { t.Fatal(err) } - fixtureContract, err := json.Marshal(map[string]any{"commands": []any{ - map[string]any{"command": "adopt-plan", "route": []string{"adopt", "plan"}}, - map[string]any{"command": "requirement-source-admission"}, - map[string]any{"command": "self-check"}, - map[string]any{ - "command": "stack-preset", - "outputContract": map[string]any{ - "flagChoices": map[string]any{"--preset": choices}, + fixtureContract, err := json.Marshal(map[string]any{ + "processContract": map[string]any{"commandRouteGrammar": testCommandRouteGrammar()}, + "commands": []any{ + map[string]any{"command": "adopt-plan", "route": []string{"adopt", "plan"}}, + map[string]any{"command": "requirement-source-admission"}, + map[string]any{"command": "self-check"}, + map[string]any{ + "command": "stack-preset", + "outputContract": map[string]any{ + "flagChoices": map[string]any{"--preset": choices}, + }, }, }, - }}) + }) if err != nil { t.Fatal(err) } - writeFileBytes(t, filepath.Join(installedRoot, "proofkit", "cli-contract.v2.json"), fixtureContract) stackInstalledInvocation = "npm exec --offline -- agentic-proofkit stack-preset --preset <" + strings.Join(choices, "|") + ">" seenPresets := map[string]struct{}{} presetExecutionCounts := map[string]int{} @@ -1045,8 +1136,11 @@ func TestOnboardingTraceCoversEveryDiscoveredPresetAndREADMEInput(t *testing.T) transportCalls = append(transportCalls, append([]string(nil), args...)) return execute(consumer, input, args...) } - if err := verifyInstalledOnboardingTraceWithExecutors(consumer, transportExecute, execute); err != nil { - t.Fatalf("verifyInstalledOnboardingTrace() error=%v", err) + verifyTrace := func(transport, binary installedCommandOperation) error { + return verifyInstalledOnboardingTraceWithCarrier(consumer, fixtureContract, readme, transport, binary) + } + if err := verifyTrace(transportExecute, execute); err != nil { + t.Fatalf("verifyInstalledOnboardingTraceWithCarrier() error=%v", err) } wantTransportCalls := [][]string{{"help"}, {"help", "adopt", "plan"}} if !reflect.DeepEqual(transportCalls, wantTransportCalls) { @@ -1109,7 +1203,7 @@ func TestOnboardingTraceCoversEveryDiscoveredPresetAndREADMEInput(t *testing.T) }, } { presetSuggestedCommands = mutant.commands - if err := verifyInstalledOnboardingTrace(consumer, execute); err == nil || + if err := verifyTrace(execute, execute); err == nil || !strings.Contains(err.Error(), mutant.want) { t.Fatalf("%s error=%v, want %q", mutant.name, err, mutant.want) } @@ -1126,7 +1220,7 @@ func TestOnboardingTraceCoversEveryDiscoveredPresetAndREADMEInput(t *testing.T) {name: "trailing NBSP", route: "npm exec --offline -- agentic-proofkit help families\u00a0", want: "must resolve to help families"}, } { rootHelpRoute = mutant.route - if err := verifyInstalledOnboardingTrace(consumer, execute); err == nil || + if err := verifyTrace(execute, execute); err == nil || !strings.Contains(err.Error(), mutant.want) { t.Fatalf("%s root-help route error=%v, want %q", mutant.name, err, mutant.want) } @@ -1134,63 +1228,63 @@ func TestOnboardingTraceCoversEveryDiscoveredPresetAndREADMEInput(t *testing.T) rootHelpRoute = "npm exec --offline -- agentic-proofkit help families" familyRoutePrefix = "agentic-proofkit help family " - if err := verifyInstalledOnboardingTrace(consumer, execute); err == nil || + if err := verifyTrace(execute, execute); err == nil || !strings.Contains(err.Error(), "family discovery route must use npm exec --offline") { t.Fatalf("bare family route error=%v, want npm exec --offline rejection", err) } familyRoutePrefix = "npm exec --offline -- agentic-proofkit help family " stackHelpRoute = "agentic-proofkit help stack-preset" - if err := verifyInstalledOnboardingTrace(consumer, execute); err == nil || + if err := verifyTrace(execute, execute); err == nil || !strings.Contains(err.Error(), "leaf help route must use npm exec --offline") { t.Fatalf("bare leaf route error=%v, want npm exec --offline rejection", err) } stackHelpRoute = "npm exec --offline -- agentic-proofkit help stack-preset" requirementSourceHelpRoute = "agentic-proofkit help requirement-source-admission" - if err := verifyInstalledOnboardingTrace(consumer, execute); err == nil || + if err := verifyTrace(execute, execute); err == nil || !strings.Contains(err.Error(), "leaf help route must use npm exec --offline") { t.Fatalf("bare requirement-source leaf route error=%v, want npm exec --offline rejection", err) } requirementSourceHelpRoute = "npm exec --offline -- agentic-proofkit help requirement-source-admission" selfCheckHelpRoute = "agentic-proofkit help self-check" - if err := verifyInstalledOnboardingTrace(consumer, execute); err == nil || + if err := verifyTrace(execute, execute); err == nil || !strings.Contains(err.Error(), "leaf help route must use npm exec --offline") { t.Fatalf("bare ordinary leaf route error=%v, want npm exec --offline rejection", err) } selfCheckHelpRoute = "npm exec --offline -- agentic-proofkit help self-check" stackInstalledInvocation = "agentic-proofkit stack-preset --preset <" + strings.Join(choices, "|") + ">" - if err := verifyInstalledOnboardingTrace(consumer, execute); err == nil || + if err := verifyTrace(execute, execute); err == nil || !strings.Contains(err.Error(), "installed invocation must prefix its exact usage with npm exec --offline") { t.Fatalf("bare stack invocation error=%v, want installed invocation rejection", err) } stackInstalledInvocation = "npm exec --offline -- agentic-proofkit stack-preset --preset <" + strings.Join(choices, "|") + ">" requirementSourceInstalledInvocation = "agentic-proofkit requirement-source-admission --input [--input-pointer ]" - if err := verifyInstalledOnboardingTrace(consumer, execute); err == nil || + if err := verifyTrace(execute, execute); err == nil || !strings.Contains(err.Error(), "installed invocation must prefix its exact usage with npm exec --offline") { t.Fatalf("bare requirement-source invocation error=%v, want installed invocation rejection", err) } requirementSourceInstalledInvocation = "npm exec --offline -- agentic-proofkit requirement-source-admission --input [--input-pointer ]" selfCheckInstalledInvocation = "agentic-proofkit self-check --input " - if err := verifyInstalledOnboardingTrace(consumer, execute); err == nil || + if err := verifyTrace(execute, execute); err == nil || !strings.Contains(err.Error(), "installed invocation must prefix its exact usage with npm exec --offline") { t.Fatalf("bare ordinary invocation error=%v, want installed invocation rejection", err) } selfCheckInstalledInvocation = "npm exec --offline -- agentic-proofkit self-check --input " presetRoutePrefix = "agentic-proofkit stack-preset --preset " - if err := verifyInstalledOnboardingTrace(consumer, execute); err == nil || + if err := verifyTrace(execute, execute); err == nil || !strings.Contains(err.Error(), "stack preset route must use npm exec --offline") { t.Fatalf("bare preset route error=%v, want npm exec --offline rejection", err) } presetRoutePrefix = "npm exec --offline -- agentic-proofkit stack-preset --preset " readmeContinuation = "" - if err := verifyInstalledOnboardingTrace(consumer, execute); err == nil || + if err := verifyTrace(execute, execute); err == nil || !strings.Contains(err.Error(), "must expose the exact installed README path") { t.Fatalf("missing README continuation error=%v, want exact path rejection", err) } @@ -1234,32 +1328,57 @@ func TestInstalledLeafHelpRoutesResolveMultiTokenContractRoute(t *testing.T) { } func TestInstalledContractCommandRoutesRejectAmbiguousIdentity(t *testing.T) { - valid := []byte(`{"commands":[{"command":"self-check"},{"command":"adopt-plan","route":["adopt","plan"]}]}`) - routes, err := installedContractCommandIDsByRoute(valid) + valid := installedContractFixture(`{"command":"self-check"},{"command":"adopt-plan","route":["adopt","plan"]}`) + contract, err := installedclicontract.Admit(valid) if err != nil { - t.Fatalf("installedContractCommandIDsByRoute() error=%v", err) + t.Fatalf("installedclicontract.Admit() error=%v", err) } + routes := contract.CommandIDsByRoute() if len(routes) != 2 || routes["self-check"] != "self-check" || routes["adopt plan"] != "adopt-plan" { t.Fatalf("installed contract routes=%v", routes) } mutants := map[string]string{ - "empty commands": `{"commands":[]}`, - "duplicate id": `{"commands":[{"command":"same"},{"command":"same","route":["other"]}]}`, - "duplicate route": `{"commands":[{"command":"one","route":["same"]},{"command":"two","route":["same"]}]}`, - "route prefix": `{"commands":[{"command":"one","route":["adopt"]},{"command":"two","route":["adopt","plan"]}]}`, - "empty route": `{"commands":[{"command":"one","route":[]}]}`, - "invalid route": `{"commands":[{"command":"one","route":["bad token"]}]}`, - "duplicate JSON key": `{"commands":[{"command":"one","command":"two"}]}`, - } - for name, content := range mutants { + "empty commands": "", + "duplicate id": `{"command":"same"},{"command":"same","route":["other"]}`, + "duplicate route": `{"command":"one","route":["same"]},{"command":"two","route":["same"]}`, + "route prefix": `{"command":"one","route":["adopt"]},{"command":"two","route":["adopt","plan"]}`, + "empty route": `{"command":"one","route":[]}`, + "invalid route": `{"command":"one","route":["bad token"]}`, + "duplicate JSON key": `{"command":"one","command":"two"}`, + } + for name, commands := range mutants { t.Run(name, func(t *testing.T) { - if _, err := installedContractCommandIDsByRoute([]byte(content)); err == nil { - t.Fatalf("ambiguous installed contract route was admitted: %s", content) + content := installedContractFixture(commands) + if _, err := installedclicontract.Admit([]byte(content)); err == nil { + t.Fatalf("ambiguous installed contract route was admitted: %s", commands) } }) } } +func installedContractFixture(commands string) []byte { + content, err := json.Marshal(map[string]any{ + "commands": []json.RawMessage{}, + "processContract": map[string]any{ + "commandRouteGrammar": testCommandRouteGrammar(), + }, + }) + if err != nil { + panic(err) + } + return bytes.Replace(content, []byte(`[]`), []byte(`[`+commands+`]`), 1) +} + +func testCommandRouteGrammar() map[string]any { + return map[string]any{ + "ambiguityPolicy": commandroute.AmbiguityPolicy, + "maximumTokens": commandroute.MaximumTokens, + "minimumTokens": commandroute.MinimumTokens, + "separator": commandroute.Separator, + "tokenPattern": commandroute.TokenPattern, + } +} + func TestInstalledCommandRouteBijectionBindsCommandIdentity(t *testing.T) { expected := map[string]string{"adopt plan": "adopt-plan", "self-check": "self-check"} if err := requireInstalledCommandRouteBijection(map[string]string{"adopt plan": "adopt-plan", "self-check": "self-check"}, expected); err != nil { diff --git a/internal/tools/pythonpackage/continuation_test.go b/internal/tools/pythonpackage/continuation_test.go index 1d91248..1dcfa0c 100644 --- a/internal/tools/pythonpackage/continuation_test.go +++ b/internal/tools/pythonpackage/continuation_test.go @@ -4,11 +4,22 @@ import ( "os" "os/exec" "path/filepath" + "reflect" "runtime" "strings" "testing" + + "github.com/research-engineering/agentic-proofkit/internal/kernel/cliexec" + "github.com/research-engineering/agentic-proofkit/internal/tools/installedclicontract" ) +func TestPipInstallArgumentsAreIsolatedAndOffline(t *testing.T) { + want := []string{"-m", "pip", "--isolated", "install", "--no-index", "--no-deps", "--no-input", "/tmp/package.whl"} + if got := pipInstallArguments("/tmp/package.whl"); !reflect.DeepEqual(got, want) { + t.Fatalf("pipInstallArguments()=%v, want %v", got, want) + } +} + func TestExactDisplayedRouteOperandsRejectsWhitespaceAndExpansionMutants(t *testing.T) { const prefix = "/venv/bin/python -m agentic_proofkit help " operands, err := exactDisplayedRouteOperands([]byte("Commands:\n "+prefix+"self-check\n"), prefix, "test routes") @@ -35,7 +46,8 @@ func TestExactDisplayedRouteOperandsRejectsWhitespaceAndExpansionMutants(t *test func TestExactDisplayedCommandRoutesAdmitBoundedMultiTokenRoutes(t *testing.T) { const prefix = "/venv/bin/python -m agentic_proofkit help " - routes, err := exactDisplayedCommandRoutes([]byte("Commands:\n "+prefix+"adopt plan\n"), prefix, "test command routes") + contract := testInstalledCLIContract(t) + routes, err := exactDisplayedCommandRoutes([]byte("Commands:\n "+prefix+"adopt plan\n"), prefix, "test command routes", contract) if err != nil || len(routes) != 1 || routes[0] != "adopt plan" { t.Fatalf("exact command routes=%v error=%v, want [adopt plan]", routes, err) } @@ -48,13 +60,22 @@ func TestExactDisplayedCommandRoutesAdmitBoundedMultiTokenRoutes(t *testing.T) { for name, route := range mutants { t.Run(name, func(t *testing.T) { output := []byte("Commands:\n " + prefix + route + "\n") - if _, err := exactDisplayedCommandRoutes(output, prefix, "test command routes"); err == nil { + if _, err := exactDisplayedCommandRoutes(output, prefix, "test command routes", contract); err == nil { t.Fatalf("mutant survived exact command-route admission: %q", route) } }) } } +func testInstalledCLIContract(t *testing.T) installedclicontract.Contract { + t.Helper() + contract, err := installedclicontract.Admit([]byte(`{"processContract":{"commandRouteGrammar":{"minimumTokens":1,"maximumTokens":4,"separator":" ","tokenPattern":"^[a-z0-9]+(?:-[a-z0-9]+)*$","ambiguityPolicy":"no_route_is_prefix_of_another"}},"commands":[{"command":"sample","route":["adopt","plan"]}]}`)) + if err != nil { + t.Fatal(err) + } + return contract +} + func TestInstalledPythonCommandRoutesRequireExactContractBijection(t *testing.T) { expected := map[string]string{"adopt plan": "adopt-plan", "self-check": "self-check"} if err := requireInstalledPythonCommandRouteBijection( @@ -89,7 +110,128 @@ func TestInstalledWheelContinuationUsesExactPythonModuleProfileWithoutNPM(t *tes }) } +func TestInstalledPythonCarrierRejectsContractReplacementRemovalAndSymlink(t *testing.T) { + if runtime.GOOS == "windows" { + t.Fatal("Windows wheels are not supported") + } + repositoryRoot, err := filepath.Abs(filepath.Join("..", "..", "..")) + if err != nil { + t.Fatal(err) + } + withWorkingDirectory(t, repositoryRoot, func() { + fixture := prepareInstalledWheelFixture(t, repositoryRoot) + if err := installPythonWheel(fixture.venvPython, fixture.wheelPath, fixture.environment); err != nil { + t.Fatal(err) + } + renderer, err := cliexec.AdmitLauncherProfile(cliexec.ProfilePythonModule, fixture.venvPython) + if err != nil { + t.Fatal(err) + } + if _, err := verifyInstalledPythonCarrier(fixture.consumer, fixture.environment, renderer, fixture.contract, fixture.binary); err != nil { + t.Fatalf("exact installed carrier was rejected: %v", err) + } + contractPath := installedPythonContractPath(t, fixture) + externalContractPath := filepath.Join(t.TempDir(), "cli-contract.v2.json") + if err := os.WriteFile(externalContractPath, fixture.contract, 0o600); err != nil { + t.Fatal(err) + } + restore := func(t *testing.T) { + t.Helper() + if err := os.Remove(contractPath); err != nil && !os.IsNotExist(err) { + t.Fatal(err) + } + if err := os.WriteFile(contractPath, fixture.contract, 0o600); err != nil { + t.Fatal(err) + } + } + mutations := []struct { + name string + apply func(*testing.T) + }{ + {name: "replacement", apply: func(t *testing.T) { + if err := os.WriteFile(contractPath, append(append([]byte(nil), fixture.contract...), '\n'), 0o600); err != nil { + t.Fatal(err) + } + }}, + {name: "removal", apply: func(t *testing.T) { + if err := os.Remove(contractPath); err != nil { + t.Fatal(err) + } + }}, + {name: "symlink", apply: func(t *testing.T) { + if err := os.Remove(contractPath); err != nil { + t.Fatal(err) + } + if err := os.Symlink(externalContractPath, contractPath); err != nil { + t.Fatal(err) + } + }}, + } + for _, mutation := range mutations { + t.Run(mutation.name, func(t *testing.T) { + restore(t) + mutation.apply(t) + if _, err := verifyInstalledPythonCarrier(fixture.consumer, fixture.environment, renderer, fixture.contract, fixture.binary); err == nil { + t.Fatal("mutated installed contract was accepted") + } + }) + } + }) +} + +func TestPythonVerificationEnvironmentRemovesAmbientImportControls(t *testing.T) { + environment := pythonVerificationEnvironment([]string{ + "PATH=/usr/bin", + "PYTHONHOME=/tmp/home", + "PYTHONPATH=/tmp/path", + "PYTHONNOUSERSITE=0", + "proofkit_fixture=retained", + }, map[string]string{ + "PATH": "/empty", + "PYTHONPATH": "/attacker", + }) + values := map[string]string{} + for _, entry := range environment { + name, value, ok := strings.Cut(entry, "=") + if !ok { + t.Fatalf("environment entry is malformed: %q", entry) + } + if _, duplicate := values[name]; duplicate { + t.Fatalf("environment contains duplicate key %q", name) + } + values[name] = value + } + if values["PATH"] != "/empty" || values["proofkit_fixture"] != "retained" { + t.Fatalf("non-Python environment was not preserved exactly: %v", values) + } + if values["PYTHONNOUSERSITE"] != "1" || values["PYTHONSAFEPATH"] != "1" { + t.Fatalf("Python isolation controls = %v, want enabled", values) + } + for name := range values { + if strings.HasPrefix(strings.ToUpper(name), "PYTHON") && name != "PYTHONNOUSERSITE" && name != "PYTHONSAFEPATH" { + t.Fatalf("ambient Python import control survived: %s", name) + } + } +} + func runInstalledWheelContinuationWitness(t *testing.T, repositoryRoot string) { + t.Helper() + fixture := prepareInstalledWheelFixture(t, repositoryRoot) + if err := verifyInstalledPythonWheel(fixture.consumer, fixture.venvPython, fixture.wheelPath, fixture.contract, fixture.binary, fixture.environment); err != nil { + t.Fatal(err) + } +} + +type installedWheelFixture struct { + binary []byte + consumer string + contract []byte + environment []string + venvPython string + wheelPath string +} + +func prepareInstalledWheelFixture(t *testing.T, repositoryRoot string) installedWheelFixture { t.Helper() target, err := currentTarget() if err != nil { @@ -122,11 +264,49 @@ func runInstalledWheelContinuationWitness(t *testing.T, repositoryRoot string) { t.Fatalf("python3 is required for the installed wheel continuation witness: %v", err) } consumer := t.TempDir() - if output, err := runCommand("", python, "-m", "venv", consumer); err != nil { + environment := pythonVerificationEnvironment(os.Environ(), nil) + if output, err := runCommandWithEnvironment("", environment, python, "-m", "venv", consumer); err != nil { t.Fatalf("create Python consumer venv: %v\n%s", err, output) } venvPython := filepath.Join(consumer, "bin", "python") - if err := verifyInstalledPythonWheel(consumer, venvPython, wheelPath); err != nil { + expectedContract, err := os.ReadFile(filepath.Join(repositoryRoot, sourceCLIContractPath)) + if err != nil { t.Fatal(err) } + return installedWheelFixture{ + binary: binaryContent, + consumer: consumer, + contract: expectedContract, + environment: environment, + venvPython: venvPython, + wheelPath: wheelPath, + } +} + +func installedPythonContractPath(t *testing.T, fixture installedWheelFixture) string { + t.Helper() + const script = ` +import os +from importlib.resources import files + +print(os.fspath(files("agentic_proofkit").joinpath("proofkit", "cli-contract.v2.json"))) +` + output, err := runCommandWithEnvironment("", fixture.environment, fixture.venvPython, "-I", "-c", script) + if err != nil { + t.Fatalf("resolve installed contract path: %v\n%s", err, output) + } + path := strings.TrimSuffix(string(output), "\n") + resolvedConsumer, err := filepath.EvalSymlinks(fixture.consumer) + if err != nil { + t.Fatal(err) + } + resolvedPath, err := filepath.EvalSymlinks(path) + if err != nil { + t.Fatal(err) + } + relative, err := filepath.Rel(resolvedConsumer, resolvedPath) + if err != nil || !filepath.IsLocal(relative) || filepath.IsAbs(relative) { + t.Fatalf("installed contract path %q is outside consumer root %q", path, fixture.consumer) + } + return path } diff --git a/internal/tools/pythonpackage/metadata_test.go b/internal/tools/pythonpackage/metadata_test.go index 0e342a2..63656ce 100644 --- a/internal/tools/pythonpackage/metadata_test.go +++ b/internal/tools/pythonpackage/metadata_test.go @@ -2,6 +2,7 @@ package main import ( "archive/zip" + "bytes" "crypto/sha256" "encoding/binary" "fmt" @@ -17,7 +18,7 @@ import ( const ( testLicenseContent = "MIT License\n" - testCLIContractContent = `{"commands":[{"command":"stack-preset","outputContract":{"flagChoices":{"--preset":["go_cli_repo"]}}}]}` + testCLIContractContent = `{"processContract":{"commandRouteGrammar":{"minimumTokens":1,"maximumTokens":4,"separator":" ","tokenPattern":"^[a-z0-9]+(?:-[a-z0-9]+)*$","ambiguityPolicy":"no_route_is_prefix_of_another"}},"commands":[{"command":"stack-preset","outputContract":{"flagChoices":{"--preset":["go_cli_repo"]}}}]}` ) func TestPythonPackageReadersRejectAmbiguousJSON(t *testing.T) { @@ -336,6 +337,51 @@ func TestVerifyWheelContentsRejectsEmbeddedBinaryDifferentFromSourceRecord(t *te } } +func TestVerifyWheelContentsRejectsOversizedEntryBeforeDecompression(t *testing.T) { + target := releaseTargets()[2] + version := "1.2.3" + path := filepath.Join(t.TempDir(), "wheel.whl") + writeMinimalWheelFixture( + t, + path, + version, + wheelMetadata(target), + []byte("binary"), + strings.Repeat("x", maximumWheelTextEntryBytes+1), + true, + []byte(testLicenseContent), + ) + + err := verifyWheelContents(path, testPackageManifest(version), target, binarySHA256([]byte("binary")), []byte(testLicenseContent), []byte(testCLIContractContent)) + if err == nil || !strings.Contains(err.Error(), "exceeds resource limit") { + t.Fatalf("verifyWheelContents() error=%v, want pre-decompression resource rejection", err) + } +} + +func TestVerifyWheelContentsRejectsOversizedCompressedEntryBeforeDecompression(t *testing.T) { + target := releaseTargets()[2] + version := "1.2.3" + path := filepath.Join(t.TempDir(), "wheel.whl") + writeMinimalWheel(t, path, version, wheelMetadata(target)) + content, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + centralHeader := bytes.Index(content, []byte{0x50, 0x4b, 0x01, 0x02}) + if centralHeader < 0 { + t.Fatal("wheel fixture has no central directory entry") + } + binary.LittleEndian.PutUint32(content[centralHeader+20:centralHeader+24], uint32(maximumWheelArchiveBytes+1)) + if err := os.WriteFile(path, content, 0o600); err != nil { + t.Fatal(err) + } + + err = verifyWheelContents(path, testPackageManifest(version), target, binarySHA256([]byte("binary")), []byte(testLicenseContent), []byte(testCLIContractContent)) + if err == nil || !strings.Contains(err.Error(), "compressed resource limit") { + t.Fatalf("verifyWheelContents() error=%v, want compressed pre-decompression resource rejection", err) + } +} + func TestVerifyWheelContentsRejectsDarwinTagBelowMachOMinimum(t *testing.T) { target := releaseTargets()[0] target.PlatformTag = "macosx_11_0_arm64" diff --git a/internal/tools/pythonpackage/verify.go b/internal/tools/pythonpackage/verify.go index 8db0759..c14c65a 100644 --- a/internal/tools/pythonpackage/verify.go +++ b/internal/tools/pythonpackage/verify.go @@ -23,19 +23,26 @@ import ( "github.com/research-engineering/agentic-proofkit/internal/kernel/admission" "github.com/research-engineering/agentic-proofkit/internal/kernel/cliexec" + "github.com/research-engineering/agentic-proofkit/internal/kernel/commandroute" "github.com/research-engineering/agentic-proofkit/internal/kernel/unicodepolicy" + "github.com/research-engineering/agentic-proofkit/internal/tools/artifactfile" "github.com/research-engineering/agentic-proofkit/internal/tools/installedclicontract" "github.com/research-engineering/agentic-proofkit/internal/tools/workflowsmoke" ) const ( zipDataDescriptorFlag = 0x8 + maximumWheelArchiveBytes = 96 << 20 + maximumWheelBinaryBytes = 64 << 20 + maximumWheelEntryCount = 16 + maximumWheelTextEntryBytes = 1 << 20 machoBuildVersionCommand = 0x32 machoBuildVersionCommandSize = 24 machoMinimumVersionCommand = 0x24 machoMinimumVersionCommandSize = 16 machoPlatformMacOS = 1 - installedContractReadTimeout = 10 * time.Second + installedResourceReadTimeout = 10 * time.Second + pythonPackageProcessTimeout = 2 * time.Minute ) func verifyPythonPackages() error { @@ -129,19 +136,34 @@ func verifyWheelRecord(manifest packageJSON, target target, record wheelRecord) } func verifyWheelContents(path string, manifest packageJSON, target target, expectedBinarySHA256 string, expectedLicense []byte, expectedCLIContract []byte) error { - reader, err := zip.OpenReader(path) + content, err := artifactfile.ReadBounded(filepath.Dir(path), filepath.Base(path), maximumWheelArchiveBytes) if err != nil { - return err + return fmt.Errorf("read bounded wheel %s: %w", path, err) + } + reader, err := zip.NewReader(bytes.NewReader(content), int64(len(content))) + if err != nil { + return fmt.Errorf("decode wheel %s: %w", path, err) + } + if len(reader.File) > maximumWheelEntryCount { + return fmt.Errorf("%s exceeds wheel entry count limit", path) } - defer reader.Close() entries := map[string]*zip.File{} for _, file := range reader.File { if file.Flags&zipDataDescriptorFlag != 0 { return fmt.Errorf("%s entry %s uses a ZIP data descriptor", path, file.Name) } + if file.CompressedSize64 > uint64(maximumWheelArchiveBytes) { + return fmt.Errorf("%s entry %s exceeds compressed resource limit", path, file.Name) + } if _, exists := entries[file.Name]; exists { return fmt.Errorf("%s contains duplicate entry %s", path, file.Name) } + if !file.Mode().IsRegular() { + return fmt.Errorf("%s entry %s must be a regular file", path, file.Name) + } + if file.UncompressedSize64 > uint64(maximumWheelEntryBytes(file.Name)) { + return fmt.Errorf("%s entry %s exceeds resource limit", path, file.Name) + } entries[file.Name] = file } distInfo := distInfoDir(manifest.Version) @@ -430,7 +452,8 @@ func verifyLocalPythonConsumer(recordsBySuffix map[string]wheelRecord) error { return err } defer os.RemoveAll(consumer) - if output, err := runCommand("", python, "-m", "venv", consumer); err != nil { + environment := pythonVerificationEnvironment(os.Environ(), nil) + if output, err := runCommandWithEnvironment("", environment, python, "-m", "venv", consumer); err != nil { return fmt.Errorf("create Python consumer venv: %w\n%s", err, output) } venvPython := filepath.Join(consumer, "bin", "python") @@ -441,14 +464,30 @@ func verifyLocalPythonConsumer(recordsBySuffix map[string]wheelRecord) error { if err != nil { return err } - return verifyInstalledPythonWheel(consumer, venvPython, wheelPath) + expectedContract, err := artifactfile.ReadBounded(".", sourceCLIContractPath, installedclicontract.MaximumContractBytes) + if err != nil { + return fmt.Errorf("read source CLI contract for installed wheel proof: %w", err) + } + expectedBinary, err := artifactfile.ReadBounded(".", filepath.ToSlash(target.BinaryPath), maximumWheelBinaryBytes) + if err != nil { + return fmt.Errorf("read source binary for installed wheel proof: %w", err) + } + return verifyInstalledPythonWheel(consumer, venvPython, wheelPath, expectedContract, expectedBinary, environment) } -func verifyInstalledPythonWheel(consumer string, venvPython string, wheelPath string) error { - if output, err := runCommand("", venvPython, "-m", "pip", "install", "--no-index", wheelPath); err != nil { - return fmt.Errorf("install local Python wheel: %w\n%s", err, output) +func verifyInstalledPythonWheel(consumer string, venvPython string, wheelPath string, expectedContract []byte, expectedBinary []byte, environment []string) error { + environment = pythonVerificationEnvironment(environment, nil) + if err := installPythonWheel(venvPython, wheelPath, environment); err != nil { + return err + } + renderer, err := cliexec.AdmitLauncherProfile(cliexec.ProfilePythonModule, venvPython) + if err != nil { + return fmt.Errorf("construct installed Python module renderer: %w", err) } - output, err := runCommand("", venvPython, "-m", "agentic_proofkit", "--help") + if _, err := verifyInstalledPythonCarrier(consumer, environment, renderer, expectedContract, expectedBinary); err != nil { + return err + } + output, err := runCommandWithEnvironment("", environment, venvPython, "-m", "agentic_proofkit", "--help") if err != nil { return fmt.Errorf("python module CLI smoke failed: %w\n%s", err, output) } @@ -459,36 +498,53 @@ func verifyInstalledPythonWheel(consumer string, venvPython string, wheelPath st if runtime.GOOS == "windows" { binPath = filepath.Join(consumer, "Scripts", "agentic-proofkit.exe") } - output, err = runCommand("", binPath, "--help") + output, err = runCommandWithEnvironment("", environment, binPath, "--help") if err != nil { return fmt.Errorf("python console script smoke failed: %w\n%s", err, output) } if !bytes.Contains(output, []byte("CLI/JSON is the public cross-language contract")) { return fmt.Errorf("python console script smoke did not expose CLI contract") } - if err := verifyInstalledWorkflowSmoke(consumer, venvPython, "-m", "agentic_proofkit"); err != nil { + if err := verifyInstalledWorkflowSmoke(consumer, environment, venvPython, "-m", "agentic_proofkit"); err != nil { return fmt.Errorf("python module agent-workflow smoke failed: %w", err) } - if err := verifyInstalledWorkflowSmoke(consumer, binPath); err != nil { + if err := verifyInstalledWorkflowSmoke(consumer, environment, binPath); err != nil { return fmt.Errorf("python console script agent-workflow smoke failed: %w", err) } - return verifyInstalledPythonPresetContinuation(consumer, venvPython) + if err := verifyInstalledPythonPresetContinuation(consumer, venvPython, expectedContract, environment); err != nil { + return err + } + _, err = verifyInstalledPythonCarrier(consumer, environment, renderer, expectedContract, expectedBinary) + return err +} + +func pipInstallArguments(wheelPath string) []string { + return []string{"-m", "pip", "--isolated", "install", "--no-index", "--no-deps", "--no-input", wheelPath} +} + +func installPythonWheel(venvPython string, wheelPath string, environment []string) error { + output, err := runCommandWithEnvironment("", pythonVerificationEnvironment(environment, nil), venvPython, pipInstallArguments(wheelPath)...) + if err != nil { + return fmt.Errorf("install local Python wheel: %w\n%s", err, output) + } + return nil } -func verifyInstalledWorkflowSmoke(dir string, executable string, prefix ...string) error { +func verifyInstalledWorkflowSmoke(dir string, environment []string, executable string, prefix ...string) error { return workflowsmoke.VerifyProcess(context.Background(), workflowsmoke.ProcessCarrier{ - Directory: dir, - Executable: executable, - Prefix: append([]string(nil), prefix...), + Directory: dir, + Executable: executable, + Environment: environment, + Prefix: append([]string(nil), prefix...), }) } -func verifyInstalledPythonPresetContinuation(consumer string, venvPython string) error { +func verifyInstalledPythonPresetContinuation(consumer string, venvPython string, expectedContract []byte, baseEnvironment []string) error { emptyPath := filepath.Join(consumer, "empty-path") if err := os.Mkdir(emptyPath, 0o700); err != nil && !os.IsExist(err) { return fmt.Errorf("create npm-free PATH: %w", err) } - environment := environmentWithOverrides(os.Environ(), map[string]string{ + environment := pythonVerificationEnvironment(baseEnvironment, map[string]string{ "PATH": emptyPath, cliexec.LauncherProfileEnvironment: cliexec.ProfilePath, cliexec.PythonExecutableEnvironment: filepath.Join(consumer, "wrong-python"), @@ -531,11 +587,11 @@ func verifyInstalledPythonPresetContinuation(consumer string, venvPython string) if err := requirePythonPassedJSON(output, "installed Python wheel self-continuation"); err != nil { return err } - return verifyInstalledPythonHelpAndAgentRouteContinuity(consumer, environment, renderer) + return verifyInstalledPythonHelpAndAgentRouteContinuity(consumer, environment, renderer, expectedContract) } -func verifyInstalledPythonHelpAndAgentRouteContinuity(consumer string, environment []string, renderer cliexec.Renderer) error { - contractContent, err := readInstalledPythonCLIContract(consumer, environment, renderer) +func verifyInstalledPythonHelpAndAgentRouteContinuity(consumer string, environment []string, renderer cliexec.Renderer, expectedContract []byte) error { + contractContent, err := readInstalledPythonCLIContract(consumer, environment, renderer, expectedContract) if err != nil { return err } @@ -571,7 +627,7 @@ func verifyInstalledPythonHelpAndAgentRouteContinuity(consumer string, environme if err != nil { return fmt.Errorf("installed Python wheel family route %q failed: %w\n%s", familyRoute, err, familyHelp) } - leafRoutes, err := exactDisplayedCommandRoutes(familyHelp, leafPrefix, "installed Python wheel leaf routes") + leafRoutes, err := exactDisplayedCommandRoutes(familyHelp, leafPrefix, "installed Python wheel leaf routes", contract) if err != nil { return err } @@ -590,7 +646,7 @@ func verifyInstalledPythonHelpAndAgentRouteContinuity(consumer string, environme if err != nil { return fmt.Errorf("installed Python wheel leaf help route %q for command %s failed: %w", leafRoute, commandID, err) } - helpIdentity, err := installedclicontract.AdmitHelpIdentity(leafHelp) + helpIdentity, err := contract.AdmitHelpIdentity(leafHelp) if err != nil { return fmt.Errorf("installed Python wheel leaf help route %q has invalid identity: %w", leafRoute, err) } @@ -685,55 +741,149 @@ func verifyInstalledPythonHelpAndAgentRouteContinuity(consumer string, environme return requirePythonPassedJSON(commandOutput, "installed Python wheel emitted agent-route argv") } -func readInstalledPythonCLIContract(consumer string, environment []string, renderer cliexec.Renderer) ([]byte, error) { +func readInstalledPythonCLIContract(consumer string, environment []string, renderer cliexec.Renderer, expectedContract []byte) ([]byte, error) { launcher := renderer.Argv() if len(launcher) != 3 || launcher[1] != "-m" || launcher[2] != "agentic_proofkit" { return nil, fmt.Errorf("installed Python wheel contract reader requires the Python module launcher") } - script := fmt.Sprintf(` + content, err := readInstalledPythonPackageResource( + consumer, + environment, + launcher[0], + "proofkit/cli-contract.v2.json", + installedclicontract.MaximumContractBytes, + "bytes", + ) + if err != nil { + return nil, fmt.Errorf("read installed Python wheel CLI contract: %w", err) + } + if !bytes.Equal(content, expectedContract) { + return nil, fmt.Errorf("installed Python wheel CLI contract differs from the exact source contract") + } + if _, err := installedclicontract.Admit(content); err != nil { + return nil, fmt.Errorf("admit installed Python wheel CLI contract resource: %w", err) + } + return content, nil +} + +func verifyInstalledPythonCarrier(consumer string, environment []string, renderer cliexec.Renderer, expectedContract []byte, expectedBinary []byte) ([]byte, error) { + contract, err := readInstalledPythonCLIContract(consumer, environment, renderer, expectedContract) + if err != nil { + return nil, err + } + launcher := renderer.Argv() + if len(launcher) != 3 { + return nil, fmt.Errorf("installed Python carrier requires the Python module launcher") + } + if err := verifyInstalledPythonBinary(consumer, environment, launcher[0], expectedBinary); err != nil { + return nil, err + } + return contract, nil +} + +func verifyInstalledPythonBinary(consumer string, environment []string, pythonExecutable string, expectedBinary []byte) error { + digestText, err := readInstalledPythonPackageResource( + consumer, + environment, + pythonExecutable, + "bin/agentic-proofkit", + maximumWheelBinaryBytes, + "sha256", + ) + if err != nil { + return fmt.Errorf("read installed Python wheel binary: %w", err) + } + expectedDigest := sha256.Sum256(expectedBinary) + if string(digestText) != fmt.Sprintf("%x\n", expectedDigest) { + return fmt.Errorf("installed Python wheel binary differs from the exact wheel source binary") + } + return nil +} + +func readInstalledPythonPackageResource(consumer string, environment []string, pythonExecutable string, relativePath string, maximumBytes int64, outputMode string) ([]byte, error) { + if relativePath == "" || strings.HasPrefix(relativePath, "/") || strings.Contains(relativePath, "\\") { + return nil, fmt.Errorf("installed Python package resource path is invalid") + } + for _, component := range strings.Split(relativePath, "/") { + if component == "" || component == "." || component == ".." { + return nil, fmt.Errorf("installed Python package resource path is invalid") + } + } + if maximumBytes <= 0 { + return nil, fmt.Errorf("installed Python package resource limit is invalid") + } + if outputMode != "bytes" && outputMode != "sha256" { + return nil, fmt.Errorf("installed Python package resource output mode is invalid") + } + script := ` +import hashlib import os import stat import sys from importlib.resources import files -limit = %d -path = files("agentic_proofkit").joinpath("proofkit", "cli-contract.v2.json") -with path.open("rb") as stream: +relative_path = sys.argv[1] +limit = int(sys.argv[2]) +output_mode = sys.argv[3] +resource = files("agentic_proofkit") +for component in relative_path.split("/"): + resource = resource.joinpath(component) +path = os.path.abspath(os.fspath(resource)) +prefix = os.path.realpath(sys.prefix) +if os.path.commonpath((prefix, path)) != prefix: + raise SystemExit("installed package resource is outside the active environment") +cursor = os.path.sep if os.path.isabs(path) else "" +for component in path.split(os.path.sep): + if not component: + continue + cursor = os.path.join(cursor, component) + if stat.S_ISLNK(os.lstat(cursor).st_mode): + raise SystemExit("installed package resource contains a symlink") +flags = os.O_RDONLY | getattr(os, "O_CLOEXEC", 0) | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_NONBLOCK", 0) +fd = os.open(path, flags) +with os.fdopen(fd, "rb", closefd=True) as stream: before = os.fstat(stream.fileno()) if not stat.S_ISREG(before.st_mode) or before.st_size < 1 or before.st_size > limit: - raise SystemExit("installed CLI contract resource is not a bounded regular file") + raise SystemExit("installed package resource is not a bounded regular file") total = 0 + digest = hashlib.sha256() while True: chunk = stream.read(min(65536, limit + 1 - total)) if not chunk: break total += len(chunk) if total > limit: - raise SystemExit("installed CLI contract resource exceeds its byte limit") - sys.stdout.buffer.write(chunk) + raise SystemExit("installed package resource exceeds its byte limit") + if output_mode == "bytes": + sys.stdout.buffer.write(chunk) + else: + digest.update(chunk) after = os.fstat(stream.fileno()) if total != before.st_size or (before.st_dev, before.st_ino, before.st_size, before.st_mtime_ns) != (after.st_dev, after.st_ino, after.st_size, after.st_mtime_ns): - raise SystemExit("installed CLI contract resource changed during the read") -`, installedclicontract.MaximumContractBytes) - ctx, cancel := context.WithTimeout(context.Background(), installedContractReadTimeout) + raise SystemExit("installed package resource changed during the read") + if output_mode == "sha256": + sys.stdout.write(digest.hexdigest() + "\n") +` + maximumOutputBytes := int(maximumBytes) + if outputMode == "sha256" { + maximumOutputBytes = sha256.Size*2 + 1 + } + ctx, cancel := context.WithTimeout(context.Background(), installedResourceReadTimeout) defer cancel() result, err := workflowsmoke.RunProcessWithOutputLimits(ctx, workflowsmoke.ProcessCarrier{ Directory: consumer, - Executable: launcher[0], - Prefix: []string{"-c", script}, - Environment: environment, + Executable: pythonExecutable, + Prefix: []string{"-I", "-c", script, relativePath, strconv.FormatInt(maximumBytes, 10), outputMode}, + Environment: pythonVerificationEnvironment(environment, nil), }, workflowsmoke.Invocation{StdinClass: workflowsmoke.StdinMustRemainUnread}, workflowsmoke.ProcessOutputLimits{ - MaximumStdoutBytes: installedclicontract.MaximumContractBytes, + MaximumStdoutBytes: maximumOutputBytes, MaximumStderrBytes: 64 << 10, }) if err != nil { - return nil, fmt.Errorf("read installed Python wheel CLI contract: %w", err) + return nil, err } if result.ExitCode != 0 { - return nil, fmt.Errorf("read installed Python wheel CLI contract exited with code %d", result.ExitCode) - } - if _, err := installedclicontract.Admit(result.Stdout); err != nil { - return nil, fmt.Errorf("admit installed Python wheel CLI contract resource: %w", err) + return nil, fmt.Errorf("installed Python package resource reader exited with code %d: %s", result.ExitCode, result.Stderr) } return result.Stdout, nil } @@ -818,14 +968,20 @@ func verifyInstalledPythonAgentRouteBrief(output []byte) error { } func exactDisplayedRouteOperands(output []byte, prefix string, context string) ([]string, error) { - return exactDisplayedRouteSuffixes(output, prefix, context, 1) + return exactDisplayedRouteSuffixes(output, prefix, context, func(value string) bool { + tokens, ok := commandroute.Parse(value) + return ok && len(tokens) == 1 + }) } -func exactDisplayedCommandRoutes(output []byte, prefix string, context string) ([]string, error) { - return exactDisplayedRouteSuffixes(output, prefix, context, 4) +func exactDisplayedCommandRoutes(output []byte, prefix string, context string, contract installedclicontract.Contract) ([]string, error) { + return exactDisplayedRouteSuffixes(output, prefix, context, func(value string) bool { + _, err := contract.AdmitRouteText(value) + return err == nil + }) } -func exactDisplayedRouteSuffixes(output []byte, prefix string, context string, maximumTokens int) ([]string, error) { +func exactDisplayedRouteSuffixes(output []byte, prefix string, context string, admit func(string) bool) ([]string, error) { decoded, err := unicodepolicy.DecodeUTF8(output) if err != nil { return nil, fmt.Errorf("installed Python wheel route output is not valid UTF-8") @@ -841,7 +997,7 @@ func exactDisplayedRouteSuffixes(output []byte, prefix string, context string, m continue } operand := strings.TrimPrefix(route, prefix) - if !isCommandRoute(operand, maximumTokens) || route != prefix+operand { + if !admit(operand) || route != prefix+operand { return nil, fmt.Errorf("%s contain non-canonical route %q", context, route) } if _, duplicate := seen[operand]; duplicate { @@ -857,31 +1013,6 @@ func exactDisplayedRouteSuffixes(output []byte, prefix string, context string, m return operands, nil } -func isCommandRoute(value string, maximumTokens int) bool { - tokens := strings.Split(value, " ") - if len(tokens) == 0 || len(tokens) > maximumTokens { - return false - } - for _, token := range tokens { - if !isCommandRouteOperand(token) { - return false - } - } - return true -} - -func isCommandRouteOperand(value string) bool { - if value == "" { - return false - } - for _, character := range value { - if (character < 'a' || character > 'z') && (character < '0' || character > '9') && character != '-' { - return false - } - } - return true -} - func writeJSONFixture(path string, value any) error { content, err := json.Marshal(value) if err != nil { @@ -1000,30 +1131,69 @@ func environmentWithOverrides(environment []string, overrides map[string]string) return result } +func pythonVerificationEnvironment(environment []string, overrides map[string]string) []string { + filtered := make([]string, 0, len(environment)+len(overrides)+2) + for _, item := range environment { + name, _, ok := strings.Cut(item, "=") + if ok && strings.HasPrefix(strings.ToUpper(name), "PYTHON") { + continue + } + filtered = append(filtered, item) + } + safeOverrides := make(map[string]string, len(overrides)+2) + for name, value := range overrides { + if !strings.HasPrefix(strings.ToUpper(name), "PYTHON") { + safeOverrides[name] = value + } + } + safeOverrides["PYTHONNOUSERSITE"] = "1" + safeOverrides["PYTHONSAFEPATH"] = "1" + return environmentWithOverrides(filtered, safeOverrides) +} + func readZipFile(file *zip.File) ([]byte, error) { + maximumBytes := maximumWheelEntryBytes(file.Name) + if file.UncompressedSize64 > uint64(maximumBytes) { + return nil, fmt.Errorf("wheel entry %s exceeds resource limit", file.Name) + } reader, err := file.Open() if err != nil { return nil, err } defer reader.Close() - return io.ReadAll(reader) + content, err := io.ReadAll(io.LimitReader(reader, maximumBytes+1)) + if err != nil { + return nil, err + } + if int64(len(content)) > maximumBytes || uint64(len(content)) != file.UncompressedSize64 { + return nil, fmt.Errorf("wheel entry %s exceeds or contradicts its admitted size", file.Name) + } + return content, nil } -func runCommand(dir string, name string, args ...string) ([]byte, error) { - command := exec.Command(name, args...) - if dir != "" { - command.Dir = dir +func maximumWheelEntryBytes(name string) int64 { + if name == "agentic_proofkit/bin/agentic-proofkit" { + return maximumWheelBinaryBytes } - return command.CombinedOutput() + return maximumWheelTextEntryBytes } func runCommandWithEnvironment(dir string, environment []string, name string, args ...string) ([]byte, error) { - command := exec.Command(name, args...) - command.Env = environment - if dir != "" { - command.Dir = dir + ctx, cancel := context.WithTimeout(context.Background(), pythonPackageProcessTimeout) + defer cancel() + result, err := workflowsmoke.RunProcess(ctx, workflowsmoke.ProcessCarrier{ + Directory: dir, + Environment: environment, + Executable: name, + }, workflowsmoke.Invocation{Args: args, StdinClass: workflowsmoke.StdinBytes}) + if err != nil { + return nil, err + } + output := append(append([]byte(nil), result.Stdout...), result.Stderr...) + if result.ExitCode != 0 { + return output, fmt.Errorf("process exited with code %d", result.ExitCode) } - return command.CombinedOutput() + return output, nil } func runArgvWithEnvironment(dir string, environment []string, argv []string) ([]byte, error) { diff --git a/internal/tools/releasechange/record_test.go b/internal/tools/releasechange/record_test.go index a83ca13..9b149b4 100644 --- a/internal/tools/releasechange/record_test.go +++ b/internal/tools/releasechange/record_test.go @@ -196,13 +196,14 @@ func TestCurrentChangeRecordNamesReviewedSemanticChanges(t *testing.T) { var currentBreakingChanges = []Change{ {ChangeID: "proofkit.adoption.init-retired", Summary: "Remove the overloaded init command and its route presets in favor of the explicit read-only adopt plan trust-mode route and the existing bounded specialist commands."}, + {ChangeID: "proofkit.agent-route.input-contract-v2", Summary: "Replace the agent-route input contract identity with proofkit.agent-route.input.v2 so the materialized-reference rule that rejects the stdin sentinel is machine-distinguishable from earlier v1 semantics; the wire schema remains version 1."}, } var currentAdditions = []Change{ {ChangeID: "proofkit.adoption.front-door", Summary: "Add adopt plan as a read-only candidate-authoring front door with explicit fresh, code-baseline, and audit-from-code intent plus an optional orthogonal stack hint."}, {ChangeID: "proofkit.adoption.repository-inventory", Summary: "Add a bounded explicit repository-inventory command that observes only a fixed root-file catalog without stack or source-semantic inference."}, - {ChangeID: "proofkit.cli.generated-adapter-command-routes", Summary: "Extend the generated TypeScript CLI adapter to admit one-to-four-token public command routes and pass each route token as a separate process argument while preserving one-token calls."}, - {ChangeID: "proofkit.cli.hierarchical-command-routes", Summary: "Add owner-generated multi-token command routes while retaining stable internal command IDs for contract and implementation ownership."}, + {ChangeID: "proofkit.cli.generated-adapter-command-routes", Summary: "Extend the generated TypeScript CLI adapter to consume the exact public contract-projected one-to-four-token command-route grammar and pass each admitted route token as a separate process argument while preserving one-token calls."}, + {ChangeID: "proofkit.cli.hierarchical-command-routes", Summary: "Publish one exact bounded command-route grammar in the CLI process contract and add owner-generated multi-token routes while retaining stable internal command IDs for contract and implementation ownership."}, {ChangeID: "proofkit.python-wheel.embedded-cli-contract", Summary: "Embed the exact public CLI contract in every Python wheel and use the installed record to prove command-family route closure."}, } @@ -213,6 +214,7 @@ var currentMigrationSteps = []string{ "Replace init --preset change-set with changed-path-set followed by the explicit impact and selective-gate composition routes required by the consuming repository.", "Replace bare init or init --preset all with help families, then select the smallest applicable bounded route rather than materializing every route family.", "Regenerate any materialized TypeScript CLI adapter source before invoking a multi-token route such as adopt plan; one-token adapter calls remain compatible.", + "Replace persisted proofkit.agent-route.input.v1 contract identity with proofkit.agent-route.input.v2; the admitted wire schemaVersion remains 1.", } func validateCurrentChangeRecord(record Record, notes string) error { diff --git a/internal/tools/workflowsmoke/workflow_smoke_test.go b/internal/tools/workflowsmoke/workflow_smoke_test.go index 9d9b2ff..2fff4ae 100644 --- a/internal/tools/workflowsmoke/workflow_smoke_test.go +++ b/internal/tools/workflowsmoke/workflow_smoke_test.go @@ -107,32 +107,57 @@ func TestRunProcessAcceptsExactOutputBounds(t *testing.T) { } func TestRunProcessCustomOutputLimitsAreExact(t *testing.T) { - limits := workflowsmoke.ProcessOutputLimits{MaximumStdoutBytes: 1024, MaximumStderrBytes: 128} - ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) - defer cancel() - result, err := workflowsmoke.RunProcessWithOutputLimits(ctx, helperCarrier("custom-output-boundary"), workflowsmoke.Invocation{StdinClass: workflowsmoke.StdinBytes}, limits) - if err != nil { - t.Fatal(err) - } - if len(result.Stdout) != limits.MaximumStdoutBytes || len(result.Stderr) != limits.MaximumStderrBytes { - t.Fatalf("output lengths=(%d,%d), want (%d,%d)", len(result.Stdout), len(result.Stderr), limits.MaximumStdoutBytes, limits.MaximumStderrBytes) + const stdoutBytes = 1024 + const stderrBytes = 128 + invocation := workflowsmoke.Invocation{StdinClass: workflowsmoke.StdinBytes} + tests := []struct { + name string + limits workflowsmoke.ProcessOutputLimits + want string + }{ + {name: "exact", limits: workflowsmoke.ProcessOutputLimits{MaximumStdoutBytes: stdoutBytes, MaximumStderrBytes: stderrBytes}}, + {name: "stdout one over", limits: workflowsmoke.ProcessOutputLimits{MaximumStdoutBytes: stdoutBytes - 1, MaximumStderrBytes: stderrBytes}, want: "stdout exceeds"}, + {name: "stderr one over", limits: workflowsmoke.ProcessOutputLimits{MaximumStdoutBytes: stdoutBytes, MaximumStderrBytes: stderrBytes - 1}, want: "stderr exceeds"}, } - - limits.MaximumStdoutBytes-- - if _, err := workflowsmoke.RunProcessWithOutputLimits(ctx, helperCarrier("custom-output-boundary"), workflowsmoke.Invocation{StdinClass: workflowsmoke.StdinBytes}, limits); err == nil || !strings.Contains(err.Error(), "stdout exceeds") { - t.Fatalf("one-over custom stdout error=%v, want overflow", err) + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) + defer cancel() + result, err := workflowsmoke.RunProcessWithOutputLimits(ctx, helperCarrier("custom-output-boundary"), invocation, test.limits) + if test.want != "" { + if err == nil || !strings.Contains(err.Error(), test.want) { + t.Fatalf("one-over custom output error=%v, want %s", err, test.want) + } + return + } + if err != nil { + t.Fatal(err) + } + if len(result.Stdout) != stdoutBytes || len(result.Stderr) != stderrBytes { + t.Fatalf("output lengths=(%d,%d), want (%d,%d)", len(result.Stdout), len(result.Stderr), stdoutBytes, stderrBytes) + } + }) } } func TestRunProcessRejectsInvalidCustomOutputLimitsBeforeStart(t *testing.T) { for _, limits := range []workflowsmoke.ProcessOutputLimits{ {}, + {MaximumStdoutBytes: 0, MaximumStderrBytes: 1}, + {MaximumStdoutBytes: 1, MaximumStderrBytes: 0}, + {MaximumStdoutBytes: (8 << 20) + 1, MaximumStderrBytes: 1}, {MaximumStdoutBytes: 1, MaximumStderrBytes: 9 << 20}, } { if _, err := workflowsmoke.RunProcessWithOutputLimits(t.Context(), helperCarrier("hang"), workflowsmoke.Invocation{StdinClass: workflowsmoke.StdinBytes}, limits); err == nil { t.Fatalf("invalid limits %#v were accepted", limits) } } + if _, err := workflowsmoke.RunProcessWithOutputLimits(t.Context(), helperCarrier("no-read"), workflowsmoke.Invocation{StdinClass: workflowsmoke.StdinBytes}, workflowsmoke.ProcessOutputLimits{ + MaximumStdoutBytes: 8 << 20, + MaximumStderrBytes: 8 << 20, + }); err != nil { + t.Fatalf("exact package hard limits were rejected: %v", err) + } } func TestRunProcessProvesUnreadStdin(t *testing.T) { diff --git a/proofkit/cli-contract.v2.json b/proofkit/cli-contract.v2.json index f80d488..65638e7 100644 --- a/proofkit/cli-contract.v2.json +++ b/proofkit/cli-contract.v2.json @@ -7,6 +7,13 @@ "failureExitCode": 1, "stdout": "Successful JSON commands write exactly one JSON value to stdout unless an admitted explicit output-file flag writes that JSON value to the selected path and leaves stdout empty. Successful text or help commands write text to stdout unless an admitted explicit output-file flag writes the selected representation to the selected path and leaves stdout empty. Commands do not write stderr on success.", "stderr": "Argument parsing, unsupported command, input admission, and output serialization failures write human diagnostics to stderr. Failed report commands may still write a machine-readable failed report to stdout when admission succeeds. Opt-in agent-envelope repair packets may write deterministic invalid-input JSON to stdout with exit code 1 and empty stderr.", + "commandRouteGrammar": { + "minimumTokens": 1, + "maximumTokens": 4, + "separator": " ", + "tokenPattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$", + "ambiguityPolicy": "no_route_is_prefix_of_another" + }, "globalOptions": { "jsonLayout": { "flag": "--json-layout", @@ -111,7 +118,7 @@ "rootDefinitionDigest": "sha256:c7bc9d7b70231d57a3066cad755d30aacfbc92fe57ac54d2df5d2ec7ed175a32", "nativeSource": { "path": "internal/command/adoptionplan", - "canonicalDigest": "sha256:f181c815ee62728a7bc6e476c05ec1a29fb2832299e300e91fbcc1794994256c", + "canonicalDigest": "sha256:c34b3405ed178abf711ab3f43562dccb03c1c8d16dcdc00a0462c5197b65dc67", "evidenceClass": "source_checkout" }, "nativeOutputWitnessSelector": { @@ -128,7 +135,8 @@ "ownerRequirementRefs": [ "REQ-PROOFKIT-PACKAGE-002", "REQ-PROOFKIT-QUALITY-004", - "REQ-PROOFKIT-SPEC-027" + "REQ-PROOFKIT-SPEC-029", + "REQ-PROOFKIT-SPEC-030" ], "flagChoices": { "--color": [ @@ -492,7 +500,7 @@ } ], "inputContract": { - "contractId": "proofkit.agent-route.input.v1", + "contractId": "proofkit.agent-route.input.v2", "schemaVersion": 1, "authority": "agent-route input admission", "fields": { @@ -648,11 +656,11 @@ ], "rootType": "object", "closed": true, - "rootDefinitionRef": "proofkit.agent-route.input.v1.root-shape", - "rootDefinitionDigest": "sha256:fd1121285d1b357ed848febc248700a75417d06619e22ed36991631d7526375b", + "rootDefinitionRef": "proofkit.agent-route.input.v2.root-shape", + "rootDefinitionDigest": "sha256:7151fa0ac2e070079c0a584b8a04757639d0b6955314a100044a8b471a7602d6", "nativeSource": { "path": "internal/command/agentroute", - "canonicalDigest": "sha256:3256eac5777824ad0b7b9bce6537efdb42b75430579a1a3f160577cf8cdce379", + "canonicalDigest": "sha256:9fdb6a0fcb98b32fc58f6a1abb95946f1b433cc3022d6f731ae6701462b0d6f2", "evidenceClass": "source_checkout" }, "nativeAdmissionWitnessSelector": { @@ -672,7 +680,7 @@ "openBrowser", "routeId", "schemaVersion", - "root-shape-only definition proofkit.agent-route.input.v1.root-shape; nested fields, types, and cardinalities are non-claims" + "root-shape-only definition proofkit.agent-route.input.v2.root-shape; nested fields, types, and cardinalities are non-claims" ], "ownerRequirementRefs": [ "REQ-PROOFKIT-PACKAGE-002", @@ -777,12 +785,12 @@ "nativeSources": [ { "path": "internal/app", - "canonicalDigest": "sha256:573c70cd1937ab3284daf86b5fd1b67ef72baca38c206194faa8e7b9140f030c", + "canonicalDigest": "sha256:7f8837c629226c740c41b14ea0c1fc9c03337faeeec5453bd4d2899a8f7b0bf8", "evidenceClass": "source_checkout" }, { "path": "internal/command/agentroute", - "canonicalDigest": "sha256:3256eac5777824ad0b7b9bce6537efdb42b75430579a1a3f160577cf8cdce379", + "canonicalDigest": "sha256:9fdb6a0fcb98b32fc58f6a1abb95946f1b433cc3022d6f731ae6701462b0d6f2", "evidenceClass": "source_checkout" } ], @@ -2057,7 +2065,7 @@ "rootDefinitionDigest": "sha256:9378c7663485f39089cf6c39a3d2f778e28b5801637229fe9524a9652a7b0737", "nativeSource": { "path": "internal/command/jsonreportcliadaptersource", - "canonicalDigest": "sha256:2ecc34739665b8b268c361c62001f05d22e5264c8ff6950c65024b01a39922d0", + "canonicalDigest": "sha256:e2d464fd22ab51809883622acfe23b35ffe7615701bc8f8672a097fc3b202462", "evidenceClass": "source_checkout" }, "nativeOutputWitnessSelector": { @@ -2555,7 +2563,7 @@ "nativeSources": [ { "path": "internal/app", - "canonicalDigest": "sha256:573c70cd1937ab3284daf86b5fd1b67ef72baca38c206194faa8e7b9140f030c", + "canonicalDigest": "sha256:7f8837c629226c740c41b14ea0c1fc9c03337faeeec5453bd4d2899a8f7b0bf8", "evidenceClass": "source_checkout" }, { @@ -3525,7 +3533,7 @@ "rootDefinitionDigest": "sha256:cc91b8e1a3fb25180165204979ccbc07c378679e2ffe7f85348b3fd38ecbdc8e", "nativeSource": { "path": "internal/command/repositoryinventory", - "canonicalDigest": "sha256:16e1e37a0face206a6a8cfd47cd867811f7f8e7ac85777b400d355d0a2752d68", + "canonicalDigest": "sha256:4fb3ddaf49f9db46192a5ee2c88552cb60f2eca94197c4046045f48728a99d29", "evidenceClass": "source_checkout" }, "nativeOutputWitnessSelector": { @@ -3542,7 +3550,7 @@ "ownerRequirementRefs": [ "REQ-PROOFKIT-PACKAGE-002", "REQ-PROOFKIT-QUALITY-004", - "REQ-PROOFKIT-SPEC-027" + "REQ-PROOFKIT-SPEC-028" ] } }, @@ -5845,7 +5853,7 @@ "rootDefinitionDigest": "sha256:3c842174dff5361e7f83166469b832805e05aa314b073c16234b5b64e346281e", "nativeSource": { "path": "internal/app", - "canonicalDigest": "sha256:573c70cd1937ab3284daf86b5fd1b67ef72baca38c206194faa8e7b9140f030c", + "canonicalDigest": "sha256:7f8837c629226c740c41b14ea0c1fc9c03337faeeec5453bd4d2899a8f7b0bf8", "evidenceClass": "source_checkout" }, "nativeAdmissionWitnessSelector": { @@ -5874,7 +5882,7 @@ "rootDefinitionDigest": "sha256:0ea95e277ebe44cd2de42c29b47c38686ac0b6b390d8965367437b3fe138e209", "nativeSource": { "path": "internal/app", - "canonicalDigest": "sha256:573c70cd1937ab3284daf86b5fd1b67ef72baca38c206194faa8e7b9140f030c", + "canonicalDigest": "sha256:7f8837c629226c740c41b14ea0c1fc9c03337faeeec5453bd4d2899a8f7b0bf8", "evidenceClass": "source_checkout" }, "nativeOutputWitnessSelector": { @@ -7898,7 +7906,7 @@ "canonicalDigest": "sha256:c06f7bfa9c10f73575aa5f927696d41db818697c9a3d320074b7c254011830d5" }, { - "definitionId": "proofkit.agent-route.input.v1.root-shape", + "definitionId": "proofkit.agent-route.input.v2.root-shape", "schemaVersion": 1, "rootType": "object", "closed": true, @@ -7937,7 +7945,7 @@ } ] }, - "canonicalDigest": "sha256:fd1121285d1b357ed848febc248700a75417d06619e22ed36991631d7526375b" + "canonicalDigest": "sha256:7151fa0ac2e070079c0a584b8a04757639d0b6955314a100044a8b471a7602d6" }, { "definitionId": "proofkit.agent-route.output.v3.root-shape", diff --git a/proofkit/requirement-bindings.json b/proofkit/requirement-bindings.json index 8fa1978..ac744bc 100644 --- a/proofkit/requirement-bindings.json +++ b/proofkit/requirement-bindings.json @@ -718,7 +718,39 @@ "specPath": "docs/specs/proofkit-spec-proof-core/requirements.v1.json", "claimLevel": "blocking", "proofState": "witness_backed", - "nonClaims": ["The adoption front door does not infer repository stack identity, inspect arbitrary source semantics, decide product meaning, establish code correctness, write files, execute commands or native witnesses, promote candidates, authenticate freshness, approve merge or release, or establish rollout or production readiness."] + "nonClaims": ["The adoption front door does not write files, execute commands or native witnesses, promote candidate artifacts, authenticate freshness, approve merge or release, or establish rollout or production readiness."] + }, + { + "requirementId": "REQ-PROOFKIT-SPEC-028", + "ownerId": "proofkit.spec-proof-core", + "specPath": "docs/specs/proofkit-spec-proof-core/requirements.v1.json", + "claimLevel": "blocking", + "proofState": "witness_backed", + "nonClaims": ["Repository inventory does not inspect arbitrary source semantics, infer repository intent or stack identity, authenticate checkout freshness, or establish code correctness."] + }, + { + "requirementId": "REQ-PROOFKIT-SPEC-029", + "ownerId": "proofkit.spec-proof-core", + "specPath": "docs/specs/proofkit-spec-proof-core/requirements.v1.json", + "claimLevel": "blocking", + "proofState": "witness_backed", + "nonClaims": ["The adoption plan does not make candidate requirements, contracts, bindings, or tests authoritative and does not authenticate evidence, execute witnesses, or approve a change."] + }, + { + "requirementId": "REQ-PROOFKIT-SPEC-030", + "ownerId": "proofkit.spec-proof-core", + "specPath": "docs/specs/proofkit-spec-proof-core/requirements.v1.json", + "claimLevel": "blocking", + "proofState": "witness_backed", + "nonClaims": ["The text projection is a human view, not requirement, proof, execution, merge, release, rollout, deployment, or readiness authority."] + }, + { + "requirementId": "REQ-PROOFKIT-SPEC-031", + "ownerId": "proofkit.spec-proof-core", + "specPath": "docs/specs/proofkit-spec-proof-core/requirements.v1.json", + "claimLevel": "blocking", + "proofState": "witness_backed", + "nonClaims": ["A source-bound version edge does not authenticate Git history, registry publication, provider ingestion, consumer migration, rollout, or production readiness."] }, { "requirementId": "REQ-PROOFKIT-WORKFLOW-001", @@ -1159,6 +1191,10 @@ "selector": "TestExactTarballOnboardingTrace", "command": "go test ./internal/tools/packageverify -run '^TestExactTarballOnboardingTrace$'" }, + { + "selector": "TestInstalledNPMCarrierIsExactRegularTarballProjection", + "command": "go test ./internal/tools/packageverify -run '^TestInstalledNPMCarrierIsExactRegularTarballProjection$'" + }, { "selector": "TestVerifyPackedOwnerRecordsRejectsSourceArtifactContentDrift", "command": "go test ./internal/tools/packageverify -run '^TestVerifyPackedOwnerRecordsRejectsSourceArtifactContentDrift$'" @@ -1374,6 +1410,18 @@ { "selector": "TestInstalledPythonCommandRoutesRequireExactContractBijection", "command": "go test ./internal/tools/pythonpackage -run '^TestInstalledPythonCommandRoutesRequireExactContractBijection$'" + }, + { + "selector": "TestInstalledPythonCarrierRejectsContractReplacementRemovalAndSymlink", + "command": "go test ./internal/tools/pythonpackage -run '^TestInstalledPythonCarrierRejectsContractReplacementRemovalAndSymlink$'" + }, + { + "selector": "TestPipInstallArgumentsAreIsolatedAndOffline", + "command": "go test ./internal/tools/pythonpackage -run '^TestPipInstallArgumentsAreIsolatedAndOffline$'" + }, + { + "selector": "TestPythonVerificationEnvironmentRemovesAmbientImportControls", + "command": "go test ./internal/tools/pythonpackage -run '^TestPythonVerificationEnvironmentRemovesAmbientImportControls$'" } ], "commandIds": [ @@ -3267,6 +3315,10 @@ "selector": "TestExactTarballOnboardingTrace", "command": "go test ./internal/tools/packageverify -run '^TestExactTarballOnboardingTrace$'" }, + { + "selector": "TestInstalledNPMCarrierIsExactRegularTarballProjection", + "command": "go test ./internal/tools/packageverify -run '^TestInstalledNPMCarrierIsExactRegularTarballProjection$'" + }, { "selector": "TestInstalledInvocationRequiresAuthoredOrderAndExactCommandToken", "command": "go test ./internal/tools/packageverify -run '^TestInstalledInvocationRequiresAuthoredOrderAndExactCommandToken$'" @@ -3617,6 +3669,21 @@ "commandIds": ["proofkit.go-test"], "environmentClasses": ["local-go"] }, + { + "requirementId": "REQ-PROOFKIT-QUALITY-010", + "scenarioId": "proofkit.supply-chain-quality.artifact-file-nonblocking-open", + "witnessId": "proofkit.artifact-file.nonblocking-open-falsifier", + "witnessKind": "technical", + "witnessPath": "internal/tools/artifactfile/file_unix_test.go", + "witnessSelectors": [ + { + "selector": "TestReadBoundedRejectsFIFOWithoutBlocking", + "command": "go test ./internal/tools/artifactfile -run '^TestReadBoundedRejectsFIFOWithoutBlocking$'" + } + ], + "commandIds": ["proofkit.go-test"], + "environmentClasses": ["local-go"] + }, { "requirementId": "REQ-PROOFKIT-QUALITY-021", "scenarioId": "proofkit.supply-chain-quality.cli-json-layout-process-boundary", @@ -3805,6 +3872,40 @@ "commandIds": ["proofkit.go-test"], "environmentClasses": ["local-go"] }, + { + "requirementId": "REQ-PROOFKIT-QUALITY-023", + "scenarioId": "proofkit.supply-chain-quality.python-wheel-resource-bounds", + "witnessId": "proofkit.python-package.wheel-resource-bound-falsifier", + "witnessKind": "contract", + "witnessPath": "internal/tools/pythonpackage/metadata_test.go", + "witnessSelectors": [ + { + "selector": "TestVerifyWheelContentsRejectsOversizedCompressedEntryBeforeDecompression", + "command": "go test ./internal/tools/pythonpackage -run '^TestVerifyWheelContentsRejectsOversizedCompressedEntryBeforeDecompression$'" + }, + { + "selector": "TestVerifyWheelContentsRejectsOversizedEntryBeforeDecompression", + "command": "go test ./internal/tools/pythonpackage -run '^TestVerifyWheelContentsRejectsOversizedEntryBeforeDecompression$'" + } + ], + "commandIds": ["proofkit.go-test"], + "environmentClasses": ["local-go"] + }, + { + "requirementId": "REQ-PROOFKIT-QUALITY-023", + "scenarioId": "proofkit.supply-chain-quality.wrapper-platform-bijection", + "witnessId": "proofkit.package-build.wrapper-platform-bijection-falsifier", + "witnessKind": "contract", + "witnessPath": "internal/tools/packagebuild/main_test.go", + "witnessSelectors": [ + { + "selector": "TestWrapperScriptRoutesEveryReleasePlatformTarget", + "command": "go test ./internal/tools/packagebuild -run '^TestWrapperScriptRoutesEveryReleasePlatformTarget$'" + } + ], + "commandIds": ["proofkit.go-test"], + "environmentClasses": ["local-go"] + }, { "requirementId": "REQ-PROOFKIT-QUALITY-023", "scenarioId": "proofkit.supply-chain-quality.python-wheel-license-closure", @@ -5454,7 +5555,7 @@ "environmentClasses": ["local-go"] }, { - "requirementId": "REQ-PROOFKIT-SPEC-027", + "requirementId": "REQ-PROOFKIT-SPEC-028", "scenarioId": "proofkit.spec-proof-core.adoption-inventory-boundary", "witnessId": "proofkit.repository-inventory.boundary-falsifier", "witnessKind": "contract", @@ -5476,6 +5577,10 @@ "selector": "TestScanEnforcesPreflightBoundsAndExplicitOmissions", "command": "go test ./internal/command/repositoryinventory -run '^TestScanEnforcesPreflightBoundsAndExplicitOmissions$'" }, + { + "selector": "TestUnsupportedPlatformFailsBeforeOpeningRepositoryRoot", + "command": "go test ./internal/command/repositoryinventory -run '^TestUnsupportedPlatformFailsBeforeOpeningRepositoryRoot$'" + }, { "selector": "TestScanPolicyBoundariesAreExact", "command": "go test ./internal/command/repositoryinventory -run '^TestScanPolicyBoundariesAreExact$'" @@ -5501,7 +5606,7 @@ "environmentClasses": ["local-go"] }, { - "requirementId": "REQ-PROOFKIT-SPEC-027", + "requirementId": "REQ-PROOFKIT-SPEC-028", "scenarioId": "proofkit.spec-proof-core.adoption-inventory-nonblocking-open", "witnessId": "proofkit.repository-inventory.nonblocking-open-falsifier", "witnessKind": "technical", @@ -5516,7 +5621,7 @@ "environmentClasses": ["local-go"] }, { - "requirementId": "REQ-PROOFKIT-SPEC-027", + "requirementId": "REQ-PROOFKIT-SPEC-029", "scenarioId": "proofkit.spec-proof-core.adoption-plan-authority-closure", "witnessId": "proofkit.adoption-plan.authority-closure-falsifier", "witnessKind": "contract", @@ -5541,21 +5646,13 @@ { "selector": "TestPlanWireAdmissionIsDeterministicAndOwnerClosed", "command": "go test ./internal/command/adoptionplan -run '^TestPlanWireAdmissionIsDeterministicAndOwnerClosed$'" - }, - { - "selector": "TestTextProjectionPreservesJSONPlanSemantics", - "command": "go test ./internal/command/adoptionplan -run '^TestTextProjectionPreservesJSONPlanSemantics$'" - }, - { - "selector": "TestAdoptionPlanOutputAndTextBoundsAreExact", - "command": "go test ./internal/command/adoptionplan -run '^TestAdoptionPlanOutputAndTextBoundsAreExact$'" } ], "commandIds": ["proofkit.go-test"], "environmentClasses": ["local-go"] }, { - "requirementId": "REQ-PROOFKIT-SPEC-027", + "requirementId": "REQ-PROOFKIT-SPEC-029", "scenarioId": "proofkit.spec-proof-core.adoption-plan-observational-stack", "witnessId": "proofkit.adoption-plan.observational-stack-falsifier", "witnessKind": "technical", @@ -5570,7 +5667,7 @@ "environmentClasses": ["local-go"] }, { - "requirementId": "REQ-PROOFKIT-SPEC-027", + "requirementId": "REQ-PROOFKIT-SPEC-029", "scenarioId": "proofkit.spec-proof-core.adoption-guidance-reference-closure", "witnessId": "proofkit.adoption-plan.guidance-reference-falsifier", "witnessKind": "contract", @@ -5584,6 +5681,25 @@ "commandIds": ["proofkit.go-test"], "environmentClasses": ["local-go"] }, + { + "requirementId": "REQ-PROOFKIT-SPEC-030", + "scenarioId": "proofkit.spec-proof-core.adoption-plan-presentation-closure", + "witnessId": "proofkit.adoption-plan.presentation-closure-falsifier", + "witnessKind": "contract", + "witnessPath": "internal/command/adoptionplan/adoptionplan_test.go", + "witnessSelectors": [ + { + "selector": "TestTextProjectionPreservesJSONPlanSemantics", + "command": "go test ./internal/command/adoptionplan -run '^TestTextProjectionPreservesJSONPlanSemantics$'" + }, + { + "selector": "TestAdoptionPlanOutputAndTextBoundsAreExact", + "command": "go test ./internal/command/adoptionplan -run '^TestAdoptionPlanOutputAndTextBoundsAreExact$'" + } + ], + "commandIds": ["proofkit.go-test"], + "environmentClasses": ["local-go"] + }, { "requirementId": "REQ-PROOFKIT-SPEC-027", "scenarioId": "proofkit.spec-proof-core.adoption-front-door-whole-cli", @@ -5600,22 +5716,79 @@ "environmentClasses": ["local-go"] }, { - "requirementId": "REQ-PROOFKIT-SPEC-027", - "scenarioId": "proofkit.spec-proof-core.adoption-route-contract-closure", - "witnessId": "proofkit.adoption-front-door.route-contract-falsifier", + "requirementId": "REQ-PROOFKIT-SPEC-018", + "scenarioId": "proofkit.spec-proof-core.command-route-contract-closure", + "witnessId": "proofkit.command-route.contract-closure-falsifier", "witnessKind": "contract", "witnessPath": "internal/tools/commandcontractgen/main_test.go", "witnessSelectors": [ { "selector": "TestCommandRoutesAreBoundedSafeAndUnambiguous", "command": "go test ./internal/tools/commandcontractgen -run '^TestCommandRoutesAreBoundedSafeAndUnambiguous$'" + }, + { + "selector": "TestRenderRejectsIncompleteAndStaleCommandContracts", + "command": "go test ./internal/tools/commandcontractgen -run '^TestRenderRejectsIncompleteAndStaleCommandContracts$'" } ], "commandIds": ["proofkit.command-contract-check", "proofkit.go-test"], "environmentClasses": ["local-go"] }, { - "requirementId": "REQ-PROOFKIT-SPEC-027", + "requirementId": "REQ-PROOFKIT-SPEC-018", + "scenarioId": "proofkit.spec-proof-core.command-route-generated-adapter", + "witnessId": "proofkit.command-route.generated-adapter-falsifier", + "witnessKind": "contract", + "witnessPath": "internal/command/jsonreportcliadaptersource/json_report_cli_adapter_source_test.go", + "witnessSelectors": [ + { + "selector": "TestGeneratedSourceAdmitsBoundedCanonicalCommandRoutes", + "command": "go test ./internal/command/jsonreportcliadaptersource -run '^TestGeneratedSourceAdmitsBoundedCanonicalCommandRoutes$'" + } + ], + "commandIds": ["proofkit.go-test"], + "environmentClasses": ["local-go"] + }, + { + "requirementId": "REQ-PROOFKIT-SPEC-018", + "scenarioId": "proofkit.spec-proof-core.command-route-installed-contract", + "witnessId": "proofkit.command-route.installed-contract-falsifier", + "witnessKind": "contract", + "witnessPath": "internal/tools/installedclicontract/contract_test.go", + "witnessSelectors": [ + { + "selector": "TestAdmitCommandRouteTokenBoundariesAreExact", + "command": "go test ./internal/tools/installedclicontract -run '^TestAdmitCommandRouteTokenBoundariesAreExact$'" + }, + { + "selector": "TestAdmitRequiresExactCommandRouteGrammarProjection", + "command": "go test ./internal/tools/installedclicontract -run '^TestAdmitRequiresExactCommandRouteGrammarProjection$'" + } + ], + "commandIds": ["proofkit.go-test"], + "environmentClasses": ["local-go"] + }, + { + "requirementId": "REQ-PROOFKIT-SPEC-018", + "scenarioId": "proofkit.spec-proof-core.command-route-kernel-owner", + "witnessId": "proofkit.command-route.kernel-owner-falsifier", + "witnessKind": "contract", + "witnessPath": "internal/kernel/commandroute/route_test.go", + "witnessSelectors": [ + { + "selector": "TestGrammarBoundariesAreExact", + "command": "go test ./internal/kernel/commandroute -run '^TestGrammarBoundariesAreExact$'" + }, + { + "selector": "TestParseRequiresCanonicalSeparatorAndRoundTrip", + "command": "go test ./internal/kernel/commandroute -run '^TestParseRequiresCanonicalSeparatorAndRoundTrip$'" + } + ], + "commandIds": ["proofkit.go-test"], + "environmentClasses": ["local-go"] + }, + { + "requirementId": "REQ-PROOFKIT-SPEC-031", "scenarioId": "proofkit.spec-proof-core.adoption-version-edge-closure", "witnessId": "proofkit.adoption-front-door.version-edge-falsifier", "witnessKind": "contract", @@ -5625,6 +5798,10 @@ "selector": "TestAdoptionFrontDoorVersionEdgeClosesInitRetirement", "command": "go test ./internal/app -run '^TestAdoptionFrontDoorVersionEdgeClosesInitRetirement$'" }, + { + "selector": "TestAdoptionFrontDoorVersionEdgeRejectsDigestBoundInventoryContradiction", + "command": "go test ./internal/app -run '^TestAdoptionFrontDoorVersionEdgeRejectsDigestBoundInventoryContradiction$'" + }, { "selector": "TestRetiredInitRouteHasNoPublicDispatcher", "command": "go test ./internal/app -run '^TestRetiredInitRouteHasNoPublicDispatcher$'" diff --git a/release/change-record.v2.json b/release/change-record.v2.json index 09b2e66..cb498ec 100644 --- a/release/change-record.v2.json +++ b/release/change-record.v2.json @@ -7,6 +7,10 @@ { "changeId": "proofkit.adoption.init-retired", "summary": "Remove the overloaded init command and its route presets in favor of the explicit read-only adopt plan trust-mode route and the existing bounded specialist commands." + }, + { + "changeId": "proofkit.agent-route.input-contract-v2", + "summary": "Replace the agent-route input contract identity with proofkit.agent-route.input.v2 so the materialized-reference rule that rejects the stdin sentinel is machine-distinguishable from earlier v1 semantics; the wire schema remains version 1." } ], "additions": [ @@ -20,11 +24,11 @@ }, { "changeId": "proofkit.cli.generated-adapter-command-routes", - "summary": "Extend the generated TypeScript CLI adapter to admit one-to-four-token public command routes and pass each route token as a separate process argument while preserving one-token calls." + "summary": "Extend the generated TypeScript CLI adapter to consume the exact public contract-projected one-to-four-token command-route grammar and pass each admitted route token as a separate process argument while preserving one-token calls." }, { "changeId": "proofkit.cli.hierarchical-command-routes", - "summary": "Add owner-generated multi-token command routes while retaining stable internal command IDs for contract and implementation ownership." + "summary": "Publish one exact bounded command-route grammar in the CLI process contract and add owner-generated multi-token routes while retaining stable internal command IDs for contract and implementation ownership." }, { "changeId": "proofkit.python-wheel.embedded-cli-contract", @@ -39,7 +43,8 @@ "Replace init --preset legacy with migration-parity-admission followed by migration-plan over explicit caller-owned records; run requirement-source-transition when the migration changes requirement lifecycle state.", "Replace init --preset change-set with changed-path-set followed by the explicit impact and selective-gate composition routes required by the consuming repository.", "Replace bare init or init --preset all with help families, then select the smallest applicable bounded route rather than materializing every route family.", - "Regenerate any materialized TypeScript CLI adapter source before invoking a multi-token route such as adopt plan; one-token adapter calls remain compatible." + "Regenerate any materialized TypeScript CLI adapter source before invoking a multi-token route such as adopt plan; one-token adapter calls remain compatible.", + "Replace persisted proofkit.agent-route.input.v1 contract identity with proofkit.agent-route.input.v2; the admitted wire schemaVersion remains 1." ] }, "platformRequirements": [ From 84a7f9782c87899d2005bd2b76b4b9f425731de4 Mon Sep 17 00:00:00 2001 From: iperev Date: Fri, 4 Sep 2026 12:58:33 +0200 Subject: [PATCH 4/4] fix: close adoption review findings --- .../requirements.v1.json | 2 +- .../adoption_front_door_version_edge_test.go | 22 ++++++++++---- .../app/testdata/v0.7-wire-observations.json | 2 +- internal/kernel/commandroute/route.go | 2 +- internal/tools/packageverify/main.go | 7 +++-- internal/tools/pythonpackage/verify.go | 30 ++++++++++++------- 6 files changed, 43 insertions(+), 22 deletions(-) diff --git a/docs/specs/proofkit-spec-proof-core/requirements.v1.json b/docs/specs/proofkit-spec-proof-core/requirements.v1.json index f1422ae..beb1ae7 100644 --- a/docs/specs/proofkit-spec-proof-core/requirements.v1.json +++ b/docs/specs/proofkit-spec-proof-core/requirements.v1.json @@ -650,7 +650,7 @@ { "requirementId": "REQ-PROOFKIT-SPEC-031", "ownerId": "proofkit.spec-proof-core", - "invariant": "The adoption-front-door public version edge binds the exact previous and current public ABI digests, the exact removal of init, the exact addition of adopt plan and repository-inventory, one explicit selection policy covering every command whose declared semantic contract identity or wire schema identity changed while leaving source-bound digest churn to the enclosing ABI digests, every changed generated artifact identity, and the ordered breaking and additive change inventories to one digest-bound release change record; the superseded init route has no active descriptor, dispatcher, CLI contract, command family, or package owner.", + "invariant": "The adoption-front-door public version edge binds the exact previous and current public ABI digests, the exact removal of init, the exact addition of adopt plan and repository-inventory, one explicit selection policy covering every command whose declared input-contract identifier changed while leaving source-bound digest churn to the enclosing ABI digests, the live current input-contract identifier and wire schema of every selected command, every changed generated artifact identity, and the ordered breaking and additive change inventories to one digest-bound release change record; the superseded init route has no active descriptor, dispatcher, CLI contract, command family, or package owner.", "claimLevel": "blocking", "riskClass": "high", "proofBindingRefs": ["proofkit/requirement-bindings.json"], diff --git a/internal/app/adoption_front_door_version_edge_test.go b/internal/app/adoption_front_door_version_edge_test.go index 083c308..89232b0 100644 --- a/internal/app/adoption_front_door_version_edge_test.go +++ b/internal/app/adoption_front_door_version_edge_test.go @@ -11,6 +11,7 @@ import ( "strings" "testing" + "github.com/research-engineering/agentic-proofkit/internal/command/agentroute" "github.com/research-engineering/agentic-proofkit/internal/command/jsonreportcliadaptersource" "github.com/research-engineering/agentic-proofkit/internal/kernel/admission" "github.com/research-engineering/agentic-proofkit/internal/tools/releasechange" @@ -227,7 +228,7 @@ func validateAdoptionFrontDoorVersionEdge(record adoptionFrontDoorVersionEdge, r if record.PreviousVersion != "0.6.0" || record.Version != "0.7.0" { return fmt.Errorf("adoption front-door version-edge release identity is stale") } - if record.CommandContractSelection != "declared_semantic_identity_or_wire_schema_change" { + if record.CommandContractSelection != "declared_input_contract_id_change" { return fmt.Errorf("adoption front-door command-contract selection policy is invalid") } if record.PreviousPublicABISHA256 != "sha256:163f06bf6fc94f15040fecf3e352d4600a8611a227e26f35369b7fe97e90bde5" || record.CurrentPublicABISHA256 != currentPublicABI || record.PreviousPublicABISHA256 == record.CurrentPublicABISHA256 { @@ -253,21 +254,30 @@ func validateAdoptionFrontDoorVersionEdge(record adoptionFrontDoorVersionEdge, r if !slices.Equal(record.ChangedGeneratedArtifacts, wantGeneratedArtifacts) { return fmt.Errorf("adoption front-door changed generated artifacts are not exact") } + currentAgentRouteInputContract := agentroute.InputContract() + currentAgentRouteInputContractID, contractIDOK := currentAgentRouteInputContract["contractId"].(string) + currentAgentRouteWireSchemaVersion, schemaVersionOK := currentAgentRouteInputContract["schemaVersion"].(int) + if !contractIDOK || currentAgentRouteInputContractID == "" || !schemaVersionOK || currentAgentRouteWireSchemaVersion < 1 { + return fmt.Errorf("current agent-route input contract identity is invalid") + } wantChangedContracts := []adoptionChangedCommandContract{{ Command: "agent-route", - CurrentInputContractID: "proofkit.agent-route.input.v2", + CurrentInputContractID: currentAgentRouteInputContractID, CurrentInputContractSHA256: generatedCommandContractMetadataByName["agent-route"].InputContractSHA256, CurrentOutputContractSHA256: generatedCommandContractMetadataByName["agent-route"].OutputContractSHA256, PreviousInputContractID: "proofkit.agent-route.input.v1", PreviousInputContractSHA256: "sha256:4fc7b2e5ffe3ed632e5e84d20e5ae26f9ace11df614bc9aec680853e60809ebd", PreviousOutputContractSHA256: "sha256:485d62afc2e5ed07c28f557b0d1069f167b3838abe0aed248e9ff94f3e25c0ad", - WireSchemaVersion: 1, + WireSchemaVersion: currentAgentRouteWireSchemaVersion, }} - if !slices.Equal(record.ChangedCommandContracts, wantChangedContracts) || - record.ChangedCommandContracts[0].CurrentInputContractID == record.ChangedCommandContracts[0].PreviousInputContractID || - record.ChangedCommandContracts[0].CurrentOutputContractSHA256 == record.ChangedCommandContracts[0].PreviousOutputContractSHA256 { + if !slices.Equal(record.ChangedCommandContracts, wantChangedContracts) { return fmt.Errorf("adoption front-door changed command contracts are not exact") } + for _, contract := range record.ChangedCommandContracts { + if contract.CurrentInputContractID == contract.PreviousInputContractID { + return fmt.Errorf("adoption front-door changed command contract %s did not change input identity", contract.Command) + } + } currentSourceDigest := sha256.Sum256([]byte(jsonreportcliadaptersource.TypeScriptSource())) if record.ChangedGeneratedArtifacts[0].CurrentSourceSHA256 != fmt.Sprintf("sha256:%x", currentSourceDigest) { return fmt.Errorf("adoption front-door generated adapter source identity is stale") diff --git a/internal/app/testdata/v0.7-wire-observations.json b/internal/app/testdata/v0.7-wire-observations.json index 966a889..f5166af 100644 --- a/internal/app/testdata/v0.7-wire-observations.json +++ b/internal/app/testdata/v0.7-wire-observations.json @@ -4,7 +4,7 @@ "previousVersion": "0.6.0", "version": "0.7.0", "evidenceClass": "owner_authored_frozen_version_edge_observation", - "commandContractSelection": "declared_semantic_identity_or_wire_schema_change", + "commandContractSelection": "declared_input_contract_id_change", "changeRecordRef": "release/change-record.v2.json", "changeRecordSha256": "sha256:25dfeccb631449f0c1eb0d1bc6d42d483c3100b34d7ede7a0d16390f3bef3d49", "changedGeneratedArtifacts": [ diff --git a/internal/kernel/commandroute/route.go b/internal/kernel/commandroute/route.go index f30ada0..18e5f1d 100644 --- a/internal/kernel/commandroute/route.go +++ b/internal/kernel/commandroute/route.go @@ -34,7 +34,7 @@ func ValidToken(token string) bool { func Parse(text string) ([]string, bool) { tokens := strings.Split(text, Separator) - if !Valid(tokens) || strings.Join(tokens, Separator) != text { + if !Valid(tokens) { return nil, false } return tokens, true diff --git a/internal/tools/packageverify/main.go b/internal/tools/packageverify/main.go index 90f0ef1..a2fd282 100644 --- a/internal/tools/packageverify/main.go +++ b/internal/tools/packageverify/main.go @@ -39,6 +39,7 @@ import ( const rootPackageName = "@research-engineering/agentic-proofkit" const rootBinaryName = "agentic-proofkit" const installedNPMPackageRelativeRoot = "node_modules/@research-engineering/agentic-proofkit" +const installedNPMReadmeRelativePath = "README.md" const installedNPMExecCommandPrefix = "npm exec --offline -- agentic-proofkit " const maxTarEntryBytes = 128 << 20 const maxEmbeddedBinaryBytes = 64 << 20 @@ -1714,7 +1715,7 @@ func installedNPMCarrierSnapshotFromTarball(artifact rootPackageArtifact) (insta }{ {maximumBytes: 1 << 20, relativePath: "dist/agentic-proofkit", tarEntry: "package/dist/agentic-proofkit"}, {maximumBytes: maxEmbeddedBinaryBytes, relativePath: strings.TrimPrefix(target.PackageTarEntry, "package/"), tarEntry: target.PackageTarEntry}, - {maximumBytes: 4 << 20, relativePath: "README.md", tarEntry: "package/README.md"}, + {maximumBytes: 4 << 20, relativePath: installedNPMReadmeRelativePath, tarEntry: pathpkg.Join("package", installedNPMReadmeRelativePath)}, {maximumBytes: installedclicontract.MaximumContractBytes, relativePath: "proofkit/cli-contract.v2.json", tarEntry: "package/proofkit/cli-contract.v2.json"}, } snapshot := installedNPMCarrierSnapshot{Files: make([]installedNPMCarrierFile, 0, len(entries))} @@ -1735,7 +1736,7 @@ func installedNPMCarrierSnapshotFromTarball(artifact rootPackageArtifact) (insta if entry.relativePath == "proofkit/cli-contract.v2.json" { snapshot.Contract = append([]byte(nil), ownedContent...) } - if entry.relativePath == "README.md" { + if entry.relativePath == installedNPMReadmeRelativePath { snapshot.Readme = append([]byte(nil), ownedContent...) } } @@ -2256,7 +2257,7 @@ func parseInstalledPresetRoutes(help string) ([]installedHelpRoute, error) { func installedREADMEPath(content []byte) (string, error) { const prefix = "Path: " - const expected = "node_modules/@research-engineering/agentic-proofkit/README.md" + expected := pathpkg.Join(installedNPMPackageRelativeRoot, installedNPMReadmeRelativePath) var discovered string matchCount := 0 decoded, err := unicodepolicy.DecodeUTF8(content) diff --git a/internal/tools/pythonpackage/verify.go b/internal/tools/pythonpackage/verify.go index c14c65a..55e37df 100644 --- a/internal/tools/pythonpackage/verify.go +++ b/internal/tools/pythonpackage/verify.go @@ -829,18 +829,28 @@ resource = files("agentic_proofkit") for component in relative_path.split("/"): resource = resource.joinpath(component) path = os.path.abspath(os.fspath(resource)) -prefix = os.path.realpath(sys.prefix) -if os.path.commonpath((prefix, path)) != prefix: +prefix_path = os.path.abspath(sys.prefix) +prefix = os.path.realpath(prefix_path) +if os.path.commonpath((prefix_path, path)) == prefix_path: + relative = os.path.relpath(path, prefix_path) +elif os.path.commonpath((prefix, path)) == prefix: + relative = os.path.relpath(path, prefix) +else: raise SystemExit("installed package resource is outside the active environment") -cursor = os.path.sep if os.path.isabs(path) else "" -for component in path.split(os.path.sep): - if not component: - continue - cursor = os.path.join(cursor, component) - if stat.S_ISLNK(os.lstat(cursor).st_mode): - raise SystemExit("installed package resource contains a symlink") +components = relative.split(os.path.sep) +if not components or any(component in ("", ".", "..") for component in components): + raise SystemExit("installed package resource path is invalid") +directory_flags = os.O_RDONLY | os.O_DIRECTORY | getattr(os, "O_CLOEXEC", 0) | getattr(os, "O_NOFOLLOW", 0) flags = os.O_RDONLY | getattr(os, "O_CLOEXEC", 0) | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_NONBLOCK", 0) -fd = os.open(path, flags) +directory_fd = os.open(prefix, directory_flags) +try: + for component in components[:-1]: + next_fd = os.open(component, directory_flags, dir_fd=directory_fd) + os.close(directory_fd) + directory_fd = next_fd + fd = os.open(components[-1], flags, dir_fd=directory_fd) +finally: + os.close(directory_fd) with os.fdopen(fd, "rb", closefd=True) as stream: before = os.fstat(stream.fileno()) if not stat.S_ISREG(before.st_mode) or before.st_size < 1 or before.st_size > limit: