diff --git a/NON_CLAIMS.md b/NON_CLAIMS.md index 29419dc..8db9079 100644 --- a/NON_CLAIMS.md +++ b/NON_CLAIMS.md @@ -36,13 +36,15 @@ This repository state does not claim: Consuming repositories must keep their own specifications, proof bindings, native witnesses, CI gates, rollback policy, and rollout decisions. -Phase5A integration source/check implements only bounded portable generation -and read-only generated-byte freshness. It does not install, update, remove, -repair, activate host instructions, grant tool permissions, or authorize native -execution. Phase5B managed lifecycle remains open; manual shell export does not -close it. `current` neither proves semantic full proof nor guarantees stability -after return. Consumed registered-contract identity is not complete transitive -native-semantic identity, and byte budgets are not token counts. +Integration source/check provides bounded portable generation and read-only +generated-byte freshness. Managed integration plan/apply/recover provides +explicit install, update, removal and recovery through the repository +transaction owner. Neither proves host instruction discovery or activation, +grants tool permissions, or authorizes native execution. Manual shell export +does not prove native-host integration. `current` neither proves semantic full +proof nor guarantees stability after return. Consumed registered-contract +identity is not complete transitive native-semantic identity, and byte budgets +are not token counts. Declared witness routes do not prove execution. Installed npm/Python integration smokes and final frozen-tree closure require actual execution against the named diff --git a/README.md b/README.md index 046c015..0cefadd 100644 --- a/README.md +++ b/README.md @@ -1,28 +1,15 @@ # agentic-proofkit -Reusable CLI and JSON proof infrastructure for spec-to-proof workflows in -software repositories. +Machine-readable specifications, proof bindings, selective verification, +and bounded agent workflows for software repositories. -`agentic-proofkit` helps repositories validate structured requirements, bind -requirements to proof routes, plan selective checks, admit receipt-shaped -evidence, render human views, and give coding agents bounded next-action -packets without copying verifier logic between projects. - -## Current Repository State - -| Surface | State | -|---|---| -| Source repository | Declared in package metadata; provider visibility is a live GitHub fact | -| Current layer | Public-source workflow; release evidence is version-specific | -| Runtime implementation | Go CLI with npm and Python wrapper packaging | -| Package release | Scoped npm release channel configured; exact version and registry identity are owned by npm and GitHub Release artifacts | -| Public-source provenance | Claimed only for a version whose release assets, registry identity, and checksum manifests are artifact-closed | -| License | MIT | +Keep product promises in repo-owned requirements. Connect each requirement to +the checks that can falsify it. Give agents the relevant context and next action; +inspect the same records in a local browser. Your repository retains authority +over requirement meaning, native tests and approval. ## Install -The canonical registry identity is npm: - ```bash npm install --save-dev --save-exact @research-engineering/agentic-proofkit ``` @@ -31,162 +18,106 @@ Pre-1.0 releases may contain owner-declared breaking changes, so npm consumers must retain the exact saved version instead of replacing it with a version range. -The canonical local invocation resolves only the already-installed dependency: +For an existing repository without an admitted specification, start with a +read-only adoption plan: + ```bash -npm exec --offline -- agentic-proofkit help +npm exec --offline -- agentic-proofkit adopt plan --repo-root . --mode audit-from-code --format text ``` + -npm remains the release-authority toolchain because release proof records npm -registry identity, `dist.integrity`, `dist.shasum`, `npm pack`, and root-only -registry install evidence. A bare `agentic-proofkit` command is valid when the -package manager, script runner, or activated environment has already placed the -installed binary on `PATH`; it is not the canonical copy-and-paste route. -Equivalent exact-tarball Bun execution has not been admitted, so this README -does not claim a Bun execution route. - -Python consumers use the Python package as a runner wrapper over the same Go -CLI, not as a Python SDK. Python projects should still treat CLI/JSON records, -exit codes, and package metadata as the public contract. - - -Supported binary targets are macOS 13 or later on arm64 or x64. -Linux manylinux 2.17 or later is supported on arm64 or x64. Windows is unsupported. The Python -runner requires Python 3.9 or later and wraps the same Go CLI; it is not a -Python SDK. +The plan returns authoring tasks and owner questions. It reads only a fixed +root-file catalog; it does not analyze arbitrary source semantics, write a +specification or run tests. An agent must inspect the selected code and design +repo-specific checks with the owner. `--repo-root .` selects the repository; +`--offline` resolves the already-installed dependency. -After an exact Python package version is available from an admitted channel, -use one complete package-manager chain: +Before opening a project in the browser, review the candidate artifacts and +follow the separate [materialization workflow](docs/proofkit-contract-map.md#agent-decision-procedure): +`adopt materialize plan`, then explicit `adopt materialize apply` with both +reviewed transaction and desired-state identities. Planning alone does not +create that project. -```bash -python -m pip install agentic-proofkit== -python -m agentic_proofkit help -``` - -or: +## How It Works -```bash -uv add --dev agentic-proofkit== -uv run agentic-proofkit help +```mermaid +flowchart TB + Observations["Code, tests, docs and maintainer intent"] --> Candidates["Agent: candidate invariants"] + Candidates --> Review["Repository owner: review and admit"] + Review --> Specs["Repo-owned specifications and proof bindings"] + Specs --> Checks["Proofkit: select required checks"] + Checks --> Native["Repository: run native tests and CI"] + Native --> Evidence["Proofkit: admit receipt-shaped evidence"] + Specs --> Views["Proofkit: bounded agent context and browser views"] + Evidence --> Views + Views --> Decision["Repository owner: decide"] ``` -These conditional commands do not claim that any current version is available -on PyPI. - - -## Project Boundary - -`agentic-proofkit` is intended to provide reusable proof-workflow mechanics for -repositories that want explicit requirements, proof bindings, deterministic -reports, and bounded guidance for coding agents. +**Authoring is not proof.** Observed behavior and generated invariants remain +candidates until the repository owner admits them. Binding a test declares a +proof route; only its actual execution can supply execution evidence. Reports +and views do not authenticate receipts or approve a change. -Proofkit does not own a consuming repository's product requirements, native -witness execution, receipt authenticity, proof freshness, merge admission, -rollout, deployment, or production readiness. +### Choose What You Trust -## How It Works - -Proofkit has two related but separate loops: +Select the mode explicitly; Proofkit does not infer that existing code is correct. -- an **authoring loop** for turning observations into candidate invariants and - repo-owned specifications; -- a **proof loop** for admitting those specifications, binding them to evidence, - and producing derived views or bounded next actions. - -The loops are separate because generated observations are not product truth. -Only the consuming repository can promote a candidate invariant into an -admitted requirement. +| `--mode` | Starting assumption | Agent task | +|---|---|---| +| `fresh` | No existing implementation is accepted as product truth | Turn owner intent into candidate behavior statements and falsifiers. | +| `code-baseline` | The owner chooses current behavior as the initial baseline | Record observed behavior and its limits for owner review. | +| `audit-from-code` | Existing behavior may be wrong or incomplete | Separate observations, contradictions and unanswered owner questions. | -### Proof Loop +All modes need repo-specific specifications and native checks. The +[authoring and test-order guide](ADOPTION.md#requirement-contract-and-test-order) +and [agent guidance](ADOPTION.md#agent-guidance) cover candidate promotion, +policy ownership, falsifiers and evidence templates. Proofkit supplies reusable +mechanics and prompts, not product policy or a native test implementation. -```mermaid -flowchart TB - subgraph Repo["Consumer repository authority"] - Requirements["Requirements and invariants"] - Bindings["Proof bindings and witness commands"] - Execution["Native test and CI execution"] - Decision["Owner decision"] - end - - subgraph Proofkit["Proofkit reusable mechanics"] - Admission["Admit and normalize JSON"] - Graph["Build proof graph"] - Planning["Plan selected checks"] - Receipts["Admit receipt-shaped evidence"] - Views["Render derived views"] - Packets["Emit bounded agent packets"] - end - - Requirements --> Admission - Bindings --> Admission - Admission --> Graph - Graph --> Planning - Planning --> Execution - Execution --> Receipts - Receipts --> Decision - Graph --> Views - Graph --> Packets - Views --> Decision - Packets --> Decision -``` +## Inspect The Project -The core invariant is separation of authority. The consuming repository owns -what the product must do and which native checks prove it. Proofkit owns the -reusable mechanics: admitting structured inputs, preserving provenance, -checking proof-binding shape, planning bounded verification, rendering derived -views, and returning agent-readable next-action packets. +![Proofkit workspace showing four synthetic delivery requirements and a source-bound question](docs/images/workspace.png) -The diagram keeps the rendering syntax intentionally simple for GitHub README -compatibility. Requirements, bindings, witness commands, native execution, and -final decisions stay in the consumer repository. Proofkit outputs are admitted -reports, plans, views, receipts, or agent packets; they do not become product -truth unless the consumer explicitly admits them. +An actual `view` session over a materialized synthetic project. The requirements +and proof routes are declarations, not executed evidence. This project input +provides specification navigation, source-bound questions and declared +traceability; coverage and baseline diff are unavailable. The separate +[explicit-input browser](ADOPTION.md#rendering-and-browser-views) can present +admitted coverage and comparison records. -### Invariant Authoring Loop +### Daily Workflow -For a repository with no specification, Proofkit can guide an agent through -three explicit starting modes: + +For an already materialized, current project: -```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"] +```bash +npm exec --offline -- agentic-proofkit status --repo-root . --format text +npm exec --offline -- agentic-proofkit next --repo-root . --format text +npm exec --offline -- agentic-proofkit view --repo-root . --serve ``` + -| 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 | +`status` classifies project structure; `next` gives the next bounded action. +Missing, stale or interrupted state must be resolved before `view` can serve. +`verification_required` is not a passing verification result. -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 -make generated invariants authoritative by itself. +The server prints a loopback URL. Add `--open` only when you want Proofkit to +launch the browser; omit `--serve` to request a read-only browser plan instead. +Browser questions produce handoff packets, not agent execution or spec edits. -## Start Here +JSON is the default machine output. For `adopt plan`, `status` and `next`, +`--format text` selects an uncolored human view; `--color auto` opts into +terminal-aware color. To reduce JSON transport whitespace, place +`--json-layout compact` before the command. This does not change the JSON value +or the persisted specification format. -Use the CLI help route before reading source: +## Find The Next Capability ```bash npm exec --offline -- agentic-proofkit help -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 ``` Command-specific help is derived from the private command descriptor table and @@ -194,33 +125,54 @@ 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. +Use the [route map](docs/proofkit-contract-map.md#agent-decision-procedure) +for selective checks, migration parity, adoption diagnostics and bounded +specification context. Each route names its input and stopping conditions; +a plan, projection or missing receipt is not proof that a check passed. -| Repository state | Minimal first route | Stop condition | -|---|---|---| -| 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 | -| A human needs semantic navigation, comparison, or traceability | `requirement-browser-server --view workspace --serve` over an admitted workspace input | Browser output, annotations, diff, and graph remain derived and non-authoritative | - -JSON commands default to readable output. Agents can request the same JSON -value with lower transport overhead by placing the process option before the -command: +For reviewed local Claude or Codex instructions, see the +[portable bootstrap and managed file lifecycle](ADOPTION.md#portable-agent-bootstrap). +Creating an instruction file does not prove that an agent application loads it. + +## Runtimes And Installation + +The Go CLI is distributed through npm and a Python runner wrapper. Neither +package exposes the Go internals as an SDK. npm owns the canonical release +toolchain and registry proof; equivalent exact-tarball Bun execution has not +been admitted. A bare `agentic-proofkit` command is valid when an installed +environment already places it on `PATH`; npm examples use explicit offline +resolution. + + +Supported binary targets are macOS 13 or later on arm64 or x64. +Linux manylinux 2.17 or later is supported on arm64 or x64. Windows is unsupported. The Python +runner requires Python 3.9 or later and wraps the same Go CLI; it is not a +Python SDK. + +After an exact Python package version is available from an admitted channel, +use one complete package-manager chain: ```bash -npm exec --offline -- agentic-proofkit --json-layout compact requirement-context-slice --input slice-input.json +python -m pip install agentic-proofkit== +python -m agentic_proofkit help ``` +or: + +```bash +uv add --dev agentic-proofkit== +uv run agentic-proofkit help +``` + +These conditional commands do not claim that any current version is available +on PyPI. + + ### First Valid Input +
+Inspect a complete minimal JSON input + The following marker-bounded record is a complete minimal requirement-source input. Its example IDs, paths, owner, invariant, and non-claims are caller-replaceable examples, not Proofkit-owned product meaning. @@ -271,6 +223,8 @@ npm exec --offline -- agentic-proofkit requirement-source-admission --input - ``` +
+ Use `secret-scan` only when the caller provides an explicit file inventory with content. It is a dedicated secret-like text detector for admitted inventory records; it does not traverse the repository, validate credential liveness, or @@ -287,13 +241,15 @@ The generated adapter remains caller-owned after materialization. It must be reviewed, pinned to the installed package, and kept behind the same CLI/JSON contract; it does not become a separate public SDK or proof authority. +## Documentation And Boundaries + | Need | Owner | |---|---| | Human orientation | This README | -| Adoption and release-channel model | `ADOPTION.md` | -| Vulnerability reporting boundary | `SECURITY.md` | -| Explicit boundary denials | `NON_CLAIMS.md` | -| `LICENSE` | MIT license | +| Adoption and release-channel model | [ADOPTION.md](ADOPTION.md) | +| Vulnerability reporting boundary | [SECURITY.md](SECURITY.md) | +| Explicit boundary denials | [NON_CLAIMS.md](NON_CLAIMS.md) | +| License | [LICENSE](LICENSE) | ## Non-Claims diff --git a/docs/images/workspace.png b/docs/images/workspace.png new file mode 100644 index 0000000..8fdb988 Binary files /dev/null and b/docs/images/workspace.png differ diff --git a/internal/tools/packageverify/main.go b/internal/tools/packageverify/main.go index 0a31a11..84b20ad 100644 --- a/internal/tools/packageverify/main.go +++ b/internal/tools/packageverify/main.go @@ -286,7 +286,7 @@ func verifyPackedPlatformBinariesMatchSource(artifact rootPackageArtifact) error func sourceOwnedPackageEntry(entry string) bool { switch entry { - case "package/LICENSE", "package/dist/agentic-proofkit", "package/package.json": + case "package/LICENSE", "package/dist/agentic-proofkit", "package/package.json", workspaceImageEntry: return true default: return packageTextEntry(entry) @@ -324,7 +324,11 @@ func verifyRootPackage(record packRecord) (rootPackageArtifact, error) { return rootPackageArtifact{}, fmt.Errorf("root package contains unexpected entry %s", entry) } } - return rootPackageArtifact{Content: content, Entries: entries, Headers: entryHeaders, Record: record}, nil + artifact := rootPackageArtifact{Content: content, Entries: entries, Headers: entryHeaders, Record: record} + if err := verifyPackedWorkspaceImage(artifact); err != nil { + return rootPackageArtifact{}, err + } + return artifact, nil } func verifyPackRecordBytes(record packRecord) error { @@ -357,6 +361,7 @@ func requiredRootEntries() []string { "package/README.md", "package/SECURITY.md", "package/dist/agentic-proofkit", + workspaceImageEntry, "package/docs/proofkit-contract-map.md", "package/docs/release-process.md", "package/package.json", @@ -461,6 +466,9 @@ func verifyTarEntryHeader(entry tarEntry) error { if entry.Size < 0 || entry.Size > maxTarEntryBytes { return fmt.Errorf("root package tar entry %s has invalid size %d", entry.Name, entry.Size) } + if entry.Name == workspaceImageEntry && (entry.Size == 0 || entry.Size > maxWorkspaceImageBytes || entry.Mode != 0o644) { + return fmt.Errorf("root package workspace image requires bounded non-empty bytes and mode 0644") + } if rootBinaryEntry(entry.Name) { if entry.Size == 0 || entry.Size > maxEmbeddedBinaryBytes { return fmt.Errorf("root package binary entry %s has invalid size %d", entry.Name, entry.Size) @@ -570,6 +578,7 @@ func forbiddenRootEntry(path string) bool { func allowedRootEntry(path string) bool { allowedExact := map[string]struct{}{ + workspaceImageEntry: {}, "package/ADOPTION.md": {}, "package/LICENSE": {}, "package/NON_CLAIMS.md": {}, @@ -677,6 +686,7 @@ func verifyRootManifestBoundary(artifact rootPackageArtifact) error { "README.md", "SECURITY.md", "dist/**", + "docs/images/workspace.png", "docs/proofkit-contract-map.md", "docs/release-process.md", "docs/specs/**/*", @@ -2646,7 +2656,7 @@ func verifyInstalledJSONABI(consumer string) error { if err := verifyInstalledNPMWorkflowSmoke(consumer); err != nil { return fmt.Errorf("outside consumer agent-workflow smoke failed: %w", err) } - return nil + return verifyInstalledREADMEWorkflow(consumer) } func verifyInstalledAgentRouteEnvelopeModes(consumer string) error { diff --git a/internal/tools/packageverify/main_test.go b/internal/tools/packageverify/main_test.go index 37e5337..f06e24b 100644 --- a/internal/tools/packageverify/main_test.go +++ b/internal/tools/packageverify/main_test.go @@ -87,6 +87,7 @@ func TestVerifyPackedOwnerRecordsRejectsSourceArtifactContentDrift(t *testing.T) withWorkingDirectory(t, root) entries := []string{ "package/LICENSE", + "package/docs/images/workspace.png", "package/dist/agentic-proofkit", "package/package.json", "package/docs/specs/example/requirements.v1.json", @@ -881,7 +882,10 @@ func TestExactTarballOnboardingTrace(t *testing.T) { if err := snapshot.Verify(consumer); err != nil { return err } - return verifyInstalledOnboardingTraceWithCarrier(consumer, snapshot.Contract, snapshot.Readme, runInstalledWithInput, runInstalledBinaryWithInput) + if err := verifyInstalledOnboardingTraceWithCarrier(consumer, snapshot.Contract, snapshot.Readme, runInstalledWithInput, runInstalledBinaryWithInput); err != nil { + return err + } + return verifyInstalledREADMEWorkflow(consumer) }); err != nil { t.Fatalf("exact tarball onboarding trace failed: %v", err) } @@ -2057,6 +2061,7 @@ func packageManifestFixture(repositoryURL string) string { "README.md", "SECURITY.md", "dist/**", + "docs/images/workspace.png", "docs/proofkit-contract-map.md", "docs/release-process.md", "docs/specs/**/*", diff --git a/internal/tools/packageverify/readme_workflow.go b/internal/tools/packageverify/readme_workflow.go new file mode 100644 index 0000000..46f9554 --- /dev/null +++ b/internal/tools/packageverify/readme_workflow.go @@ -0,0 +1,90 @@ +package main + +import ( + "bytes" + "errors" + "fmt" + "os" + "path/filepath" + "strings" +) + +func installedREADMEWorkflowRoutes(content string) ([]string, error) { + blocks := []struct { + name string + premise string + commands []string + }{ + {"first-action", "", []string{"adopt plan --repo-root . --mode audit-from-code --format text"}}, + {"daily-workflow", "For an already materialized, current project:", []string{ + "status --repo-root . --format text", + "next --repo-root . --format text", + "view --repo-root . --serve", + }}, + } + for _, block := range blocks { + startMarker := "" + endMarker := "" + if strings.Count(content, startMarker) != 1 || strings.Count(content, endMarker) != 1 { + return nil, fmt.Errorf("installed README workflow markers must occur exactly once") + } + start := strings.Index(content, startMarker) + len(startMarker) + end := strings.Index(content, endMarker) + if end <= start { + return nil, fmt.Errorf("installed README workflow marker order is invalid") + } + lines := []string{"```bash"} + if block.premise != "" { + lines = append([]string{block.premise, ""}, lines...) + } + for _, command := range block.commands { + lines = append(lines, installedNPMExecCommandPrefix+command) + } + lines = append(lines, "```") + if strings.TrimSpace(content[start:end]) != strings.Join(lines, "\n") { + return nil, fmt.Errorf("installed README workflow must preserve its prerequisite and exact commands") + } + } + return strings.Fields(blocks[0].commands[0]), nil +} + +func verifyInstalledREADMEWorkflow(consumer string) (returnErr error) { + readme, err := os.ReadFile(filepath.Join(consumer, filepath.FromSlash(installedNPMPackageRelativeRoot), installedNPMReadmeRelativePath)) + if err != nil { + return fmt.Errorf("read installed README workflow: %w", err) + } + args, err := installedREADMEWorkflowRoutes(string(readme)) + if err != nil { + return err + } + // The empty child still resolves the installed npm dependency from its parent. + root, err := os.MkdirTemp(consumer, "readme-first-action-") + if err != nil { + return fmt.Errorf("create README first-action repository: %w", err) + } + defer func() { returnErr = errors.Join(returnErr, os.RemoveAll(root)) }() + result, err := runInstalledWithInput(root, nil, args...) + if err != nil { + return fmt.Errorf("execute installed README first action: %w", err) + } + if result.ExitCode != 0 || len(result.Stderr) != 0 || bytes.Contains(result.Stdout, []byte("\x1b")) || len(result.Stdout) > 32<<10 { + return fmt.Errorf("installed README first action must produce bounded successful uncolored text") + } + for _, line := range []string{ + "Adoption plan", "Mode: audit-from-code", "State: authoring_required", + "Inventory: 0 recognized, 0 omitted, 0 opaque", "Authority: candidate-only; consuming repository owner", + "Evidence template: native-evidence-guidance", + } { + if !strings.Contains("\n"+string(result.Stdout), "\n"+line+"\n") { + return fmt.Errorf("installed README first action lost its candidate-only empty-repository outcome") + } + } + entries, err := os.ReadDir(root) + if err != nil { + return fmt.Errorf("inspect README first-action repository: %w", err) + } + if len(entries) != 0 { + return fmt.Errorf("installed README first action must not materialize repository files") + } + return nil +} diff --git a/internal/tools/packageverify/readme_workflow_test.go b/internal/tools/packageverify/readme_workflow_test.go new file mode 100644 index 0000000..dbfd3e5 --- /dev/null +++ b/internal/tools/packageverify/readme_workflow_test.go @@ -0,0 +1,46 @@ +package main + +import ( + "os" + "path/filepath" + "slices" + "strings" + "testing" +) + +func TestREADMEWorkflowRoutes(t *testing.T) { + t.Parallel() + content, err := os.ReadFile(filepath.Join("..", "..", "..", "README.md")) + if err != nil { + t.Fatal(err) + } + readme := string(content) + args, err := installedREADMEWorkflowRoutes(readme) + if err != nil { + t.Fatal(err) + } + if !slices.Equal(args, []string{"adopt", "plan", "--repo-root", ".", "--mode", "audit-from-code", "--format", "text"}) { + t.Fatal("README first action changed its read-only root and trust mode") + } + for _, pair := range [][2]string{ + {"adopt plan --repo-root .", "adopt materialize apply --repo-root ."}, + {"--mode audit-from-code", "--mode code-baseline"}, + {"status --repo-root . --format text", "status --repo-root ."}, + {"next --repo-root . --format text", "status --repo-root . --format text"}, + {"view --repo-root . --serve", "view --repo-root ."}, + {"view --repo-root . --serve", "view --repo-root . --serve --open"}, + {"For an already materialized, current project:", ""}, + {"For an already materialized, current project:", "Immediately after the read-only plan, run:"}, + {"", ""}, + {"", ""}, + {"", "\n"}, + {"npm exec --offline -- agentic-proofkit adopt", "npx agentic-proofkit adopt"}, + } { + if !strings.Contains(readme, pair[0]) { + t.Fatal("README route mutation missed its subject") + } + if _, err := installedREADMEWorkflowRoutes(strings.ReplaceAll(readme, pair[0], pair[1])); err == nil { + t.Fatal("mutated README workflow admitted") + } + } +} diff --git a/internal/tools/packageverify/workspace_image.go b/internal/tools/packageverify/workspace_image.go new file mode 100644 index 0000000..5d90d05 --- /dev/null +++ b/internal/tools/packageverify/workspace_image.go @@ -0,0 +1,40 @@ +package main + +import ( + "bytes" + "fmt" + "image/png" +) + +const ( + workspaceImageEntry = "package/docs/images/workspace.png" + maxWorkspaceImageBytes = 2 << 20 + maxWorkspaceImageWidth = 2048 + maxWorkspaceImageHeight = 1536 +) + +func verifyPackedWorkspaceImage(artifact rootPackageArtifact) error { + content, err := readTarFileFromBytes(artifact.Content, workspaceImageEntry) + if err != nil { + return err + } + return verifyWorkspaceImage(content) +} + +func verifyWorkspaceImage(content []byte) error { + if len(content) == 0 || len(content) > maxWorkspaceImageBytes { + return fmt.Errorf("root package workspace image exceeds its byte bounds") + } + config, err := png.DecodeConfig(bytes.NewReader(content)) + if err != nil { + return fmt.Errorf("root package workspace image has invalid PNG metadata") + } + // Dimensions bound allocation independently of the compressed byte count. + if config.Width < 1 || config.Width > maxWorkspaceImageWidth || config.Height < 1 || config.Height > maxWorkspaceImageHeight { + return fmt.Errorf("root package workspace image exceeds its dimension bounds") + } + if _, err := png.Decode(bytes.NewReader(content)); err != nil { + return fmt.Errorf("root package workspace image is not a complete PNG") + } + return nil +} diff --git a/internal/tools/packageverify/workspace_image_test.go b/internal/tools/packageverify/workspace_image_test.go new file mode 100644 index 0000000..8f45027 --- /dev/null +++ b/internal/tools/packageverify/workspace_image_test.go @@ -0,0 +1,221 @@ +package main + +import ( + "archive/tar" + "bytes" + "context" + "encoding/binary" + "encoding/json" + "errors" + "hash/crc32" + "image" + "image/png" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" +) + +func TestWorkspaceImageAdmission(t *testing.T) { + t.Parallel() + valid := workspacePNG(t, 8, 5) + for _, content := range [][]byte{valid, workspacePNG(t, 2048, 1536)} { + if err := verifyWorkspaceImage(content); err != nil { + t.Fatalf("valid bounded PNG rejected: %v", err) + } + } + const sentinel = "private-workspace-image-sentinel" + cases := []struct { + name string + content []byte + want string + }{ + {"empty", nil, "byte bounds"}, + {"overflow", make([]byte, (2<<20)+1), "byte bounds"}, + {"invalid", []byte(sentinel), "PNG metadata"}, + {"truncated after metadata", valid[:33], "complete PNG"}, + {"corrupt payload", append(append([]byte{}, valid[:33]...), []byte(sentinel)...), "complete PNG"}, + {"width before decode", workspacePNGHeader(valid, 2049, 1), "dimension bounds"}, + {"height before decode", workspacePNGHeader(valid, 1, 1537), "dimension bounds"}, + } + for _, item := range cases { + t.Run(item.name, func(t *testing.T) { + err := verifyWorkspaceImage(item.content) + if err == nil || !strings.Contains(err.Error(), item.want) { + t.Fatalf("PNG admission did not reject the intended boundary: %v", err) + } + if strings.Contains(err.Error(), sentinel) { + t.Fatal("PNG diagnostic disclosed input bytes") + } + }) + } +} + +func TestWorkspaceImageHeaderAndInventory(t *testing.T) { + t.Parallel() + entry := tarEntry{Name: "package/docs/images/workspace.png", Mode: 0o644, Size: 2 << 20, Typeflag: tar.TypeReg} + if err := verifyTarEntryHeader(entry); err != nil { + t.Fatal(err) + } + for _, mutate := range []func(*tarEntry){ + func(e *tarEntry) { e.Size = 0 }, + func(e *tarEntry) { e.Size++ }, + func(e *tarEntry) { e.Mode = 0o755 }, + func(e *tarEntry) { e.Typeflag = tar.TypeSymlink }, + } { + invalid := entry + mutate(&invalid) + if err := verifyTarEntryHeader(invalid); err == nil { + t.Fatal("invalid image header admitted") + } + } + if !allowedRootEntry(entry.Name) || allowedRootEntry("package/docs/images/other.png") { + t.Fatal("image inventory is not exact") + } + entries := toSet(requiredRootEntries()) + delete(entries, entry.Name) + if err := verifyRequiredRootEntries(entries); err == nil { + t.Fatal("package omitted its required illustration") + } + if err := verifyMarkdownDestinations("package/README.md", "![Workspace](docs/images/workspace.png)", entries); err == nil { + t.Fatal("README image reference resolved without an image") + } +} + +func TestWorkspaceImageExactByteBoundary(t *testing.T) { + t.Parallel() + valid := workspacePNG(t, 1, 1) + // A valid ancillary text chunk fills the payload without changing dimensions. + payload := bytes.Repeat([]byte("x"), 2097152-len(valid)-12) + copy(payload, "fixture\x00") + chunk := binary.BigEndian.AppendUint32(nil, uint32(len(payload))) + chunk = append(chunk, "tEXt"...) + chunk = append(chunk, payload...) + chunk = binary.BigEndian.AppendUint32(chunk, crc32.ChecksumIEEE(chunk[4:])) + content := append(append(append([]byte{}, valid[:len(valid)-12]...), chunk...), valid[len(valid)-12:]...) + if len(content) != 2097152 { + t.Fatal("exact-limit fixture has the wrong byte length") + } + if _, err := png.Decode(bytes.NewReader(content)); err != nil { + t.Fatalf("exact-limit fixture is not a complete PNG: %v", err) + } + if err := verifyWorkspaceImage(content); err != nil { + t.Fatalf("inclusive PNG byte boundary rejected: %v", err) + } + if err := verifyWorkspaceImage(append(content, 0)); err == nil || !strings.Contains(err.Error(), "byte bounds") { + t.Fatal("payload above the exact byte boundary was not rejected") + } +} + +func TestPackageVerifierImageFailureProcess(t *testing.T) { + t.Parallel() + ctx, cancel := context.WithTimeout(t.Context(), 90*time.Second) + defer cancel() + binaryPath := filepath.Join(t.TempDir(), "packageverify") + build := exec.CommandContext(ctx, "go", "build", "-o", binaryPath, "./internal/tools/packageverify") + build.Dir = filepath.Join("..", "..", "..") + if output, err := build.CombinedOutput(); err != nil { + t.Fatalf("build actual package verifier: %v\n%s", err, output) + } + const sentinel = "private-image-process-sentinel" + valid := workspacePNG(t, 1, 1) + for _, item := range []struct { + name string + content []byte + want string + }{ + {"metadata", []byte(sentinel), "PNG metadata"}, + {"complete decode", append(append([]byte{}, valid[:33]...), []byte(sentinel)...), "complete PNG"}, + } { + t.Run(item.name, func(t *testing.T) { + entries := map[string]string{} + for _, name := range requiredRootEntries() { + entries[name] = "fixture" + } + entries["package/docs/images/workspace.png"] = string(item.content) + archive := mustReadBytes(t, writePackageTarball(t, entries)) + root := t.TempDir() + const filename = "image-proof.tgz" + writeFileBytes(t, filepath.Join(root, "artifacts/package", filename), archive) + records, err := json.Marshal([]packRecord{{Filename: filename, Name: rootPackageName, Version: "1.2.3", Integrity: testNPMIntegrity(archive), Shasum: testSHA1(archive)}}) + if err != nil { + t.Fatal(err) + } + writeFileBytes(t, filepath.Join(root, "artifacts/package/npm-pack.json"), records) + command := exec.CommandContext(ctx, binaryPath) + command.Dir = root + var stdout, stderr bytes.Buffer + command.Stdout, command.Stderr = &stdout, &stderr + err = command.Run() + var exit *exec.ExitError + if !errors.As(err, &exit) || exit.ExitCode() != 1 || stdout.Len() != 0 || !strings.Contains(stderr.String(), item.want) { + t.Fatal("actual verifier did not reject at the image boundary with failure exit and stderr only") + } + if bytes.Contains(stdout.Bytes(), []byte(sentinel)) || bytes.Contains(stderr.Bytes(), []byte(sentinel)) { + t.Fatal("actual verifier disclosed image input bytes") + } + }) + } +} + +func TestRootPackageDecodesImageFromArchive(t *testing.T) { + root := t.TempDir() + withWorkingDirectory(t, root) + good := workspacePNG(t, 4, 3) + // A clean source image must not hide corrupted bytes in the package. + writeFileBytes(t, filepath.Join(root, "docs/images/workspace.png"), good) + for _, content := range [][]byte{good, []byte("invalid archive image")} { + entries := map[string]string{} + for _, name := range requiredRootEntries() { + entries[name] = "fixture" + } + entries["package/docs/images/workspace.png"] = string(content) + archive := mustReadBytes(t, writePackageTarball(t, entries)) + filename := "image-proof.tgz" + writeFileBytes(t, filepath.Join(root, "artifacts/package", filename), archive) + _, err := verifyRootPackage(packRecord{Filename: filename, Name: rootPackageName, Version: "1.2.3", Integrity: testNPMIntegrity(archive), Shasum: testSHA1(archive)}) + if bytes.Equal(content, good) { + if err != nil { + t.Fatalf("valid archive image rejected: %v", err) + } + } else if err == nil || !strings.Contains(err.Error(), "PNG metadata") { + t.Fatalf("bad archive passed beside valid source: %v", err) + } + } +} + +func FuzzWorkspaceImageAdmission(f *testing.F) { + f.Add(workspacePNG(f, 1, 1)) + f.Add([]byte("not a PNG")) + f.Fuzz(func(t *testing.T, content []byte) { + if err := verifyWorkspaceImage(content); err != nil { + return + } + decoded, err := png.Decode(bytes.NewReader(content)) + if err != nil || len(content) > 2<<20 { + t.Fatal("image admitted without complete bounded PNG bytes") + } + bounds := decoded.Bounds() + if bounds.Dx() < 1 || bounds.Dx() > 2048 || bounds.Dy() < 1 || bounds.Dy() > 1536 { + t.Fatal("image admitted outside dimension bounds") + } + }) +} + +func workspacePNG(t testing.TB, width, height int) []byte { + t.Helper() + var output bytes.Buffer + if err := png.Encode(&output, image.NewNRGBA(image.Rect(0, 0, width, height))); err != nil { + t.Fatal(err) + } + return output.Bytes() +} + +func workspacePNGHeader(valid []byte, width, height uint32) []byte { + header := append([]byte{}, valid[:33]...) + binary.BigEndian.PutUint32(header[16:20], width) + binary.BigEndian.PutUint32(header[20:24], height) + binary.BigEndian.PutUint32(header[29:33], crc32.ChecksumIEEE(header[12:29])) + return header +} diff --git a/internal/tools/releasechange/record_test.go b/internal/tools/releasechange/record_test.go index 3f3f116..7848733 100644 --- a/internal/tools/releasechange/record_test.go +++ b/internal/tools/releasechange/record_test.go @@ -197,11 +197,8 @@ func TestCurrentChangeRecordNamesReviewedSemanticChanges(t *testing.T) { var currentBreakingChanges = []Change{} var currentAdditions = []Change{ - {ChangeID: "proofkit.browser.project-entry", Summary: "Use view --repo-root to prepare a read-only workspace from one complete materialized project, or add --serve to inspect it through the existing loopback browser. Explicit --open, one-shot questions, compact JSON plans and bounded diagnostics reuse the existing browser and CLI owners."}, - {ChangeID: "proofkit.browser.reference-closure", Summary: "Resolve question anchors against the exact browser-issued session inventory, preserving admitted long requirement identities without expanding base identifier limits or weakening quote, source and terminal checks."}, - {ChangeID: "proofkit.cli.deterministic-choice-diagnostics", Summary: "Validate flag choices in descriptor order so identical invalid invocations select the same diagnostic. Effect-sensitive tests cover rejection before project preparation and inspection, including errors deliberately discarded by a faulty caller."}, - {ChangeID: "proofkit.context.captured-project-origin", Summary: "Add closed project-origin context schema 3, with owner-replayed source and binding projections, role-preserving physical identities and source-level limitations retained in question handoffs. Existing context v1/v2 identities and low-level browser contracts remain unchanged."}, - {ChangeID: "proofkit.graph.derived-reference-admission", Summary: "Apply one typed identity owner to graph nodes and relation references. Transparent prefixes preserve the full admitted component length; digest-derived identities remain distinct from caller IDs. Invalid components, unresolved references and inconsistent topology remain rejected."}, + {ChangeID: "proofkit.documentation.project-workflow", Summary: "Present read-only adoption, explicit owner-reviewed materialization and daily project navigation as distinct states. One diagram connects invariant authoring to native proof and derived views; current managed integration limits replace obsolete phase status. CLI and machine contract semantics are unchanged."}, + {ChangeID: "proofkit.package.workspace-illustration", Summary: "Ship one real synthetic-project browser image with exact npm path admission, source-byte equality, independent PNG decoding and byte/dimension bounds. Installed README checks retain the read-only first action and state-qualified daily command routes. The image does not represent executed proof or certify future browser rendering."}, } var currentMigrationSteps = []string{} @@ -224,7 +221,7 @@ func validateCurrentChangeRecord(record Record, notes string) error { func currentExpectedReleaseNotes() string { lines := []string{ - "# @research-engineering/agentic-proofkit 0.14.0", + "# @research-engineering/agentic-proofkit 0.14.1", "", "## Breaking Contract Changes", "", @@ -277,7 +274,7 @@ func currentExpectedReleaseNotes() string { "Primary npm channel:", "", "```bash", - "npm install --save-dev --save-exact @research-engineering/agentic-proofkit@0.14.0", + "npm install --save-dev --save-exact @research-engineering/agentic-proofkit@0.14.1", "```", "", "Pre-1.0 npm consumers must keep this dependency exact-pinned.", @@ -289,7 +286,7 @@ func currentExpectedReleaseNotes() string { "## Rollback", "", "- First follow the migration and persistent-state compatibility restrictions above; changing a package pin does not roll back repository state.", - "- Pin npm consumers to the previous admitted version 0.13.0 with `npm install --save-dev --save-exact @research-engineering/agentic-proofkit@0.13.0`.", + "- Pin npm consumers to the previous admitted version 0.14.0 with `npm install --save-dev --save-exact @research-engineering/agentic-proofkit@0.14.0`.", "- Treat local package artifacts as candidates until registry identity is proven.", ) return strings.Join(lines, "\n") + "\n" diff --git a/package-lock.json b/package-lock.json index cdef40a..f0966b9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@research-engineering/agentic-proofkit", - "version": "0.14.0", + "version": "0.14.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@research-engineering/agentic-proofkit", - "version": "0.14.0", + "version": "0.14.1", "cpu": [ "arm64", "x64" diff --git a/package.json b/package.json index 0a27267..76b2b84 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.14.0", + "version": "0.14.1", "type": "module", "license": "MIT", "sideEffects": false, @@ -27,6 +27,7 @@ "README.md", "SECURITY.md", "dist/**", + "docs/images/workspace.png", "docs/proofkit-contract-map.md", "docs/release-process.md", "docs/specs/**/*", diff --git a/release/change-record.v2.json b/release/change-record.v2.json index 32bbbac..3de7b0f 100644 --- a/release/change-record.v2.json +++ b/release/change-record.v2.json @@ -1,29 +1,17 @@ { "schemaVersion": 2, - "previousVersion": "0.13.0", - "version": "0.14.0", + "previousVersion": "0.14.0", + "version": "0.14.1", "changeClass": "compatible", "breakingChanges": [], "additions": [ { - "changeId": "proofkit.browser.project-entry", - "summary": "Use view --repo-root to prepare a read-only workspace from one complete materialized project, or add --serve to inspect it through the existing loopback browser. Explicit --open, one-shot questions, compact JSON plans and bounded diagnostics reuse the existing browser and CLI owners." + "changeId": "proofkit.documentation.project-workflow", + "summary": "Present read-only adoption, explicit owner-reviewed materialization and daily project navigation as distinct states. One diagram connects invariant authoring to native proof and derived views; current managed integration limits replace obsolete phase status. CLI and machine contract semantics are unchanged." }, { - "changeId": "proofkit.browser.reference-closure", - "summary": "Resolve question anchors against the exact browser-issued session inventory, preserving admitted long requirement identities without expanding base identifier limits or weakening quote, source and terminal checks." - }, - { - "changeId": "proofkit.cli.deterministic-choice-diagnostics", - "summary": "Validate flag choices in descriptor order so identical invalid invocations select the same diagnostic. Effect-sensitive tests cover rejection before project preparation and inspection, including errors deliberately discarded by a faulty caller." - }, - { - "changeId": "proofkit.context.captured-project-origin", - "summary": "Add closed project-origin context schema 3, with owner-replayed source and binding projections, role-preserving physical identities and source-level limitations retained in question handoffs. Existing context v1/v2 identities and low-level browser contracts remain unchanged." - }, - { - "changeId": "proofkit.graph.derived-reference-admission", - "summary": "Apply one typed identity owner to graph nodes and relation references. Transparent prefixes preserve the full admitted component length; digest-derived identities remain distinct from caller IDs. Invalid components, unresolved references and inconsistent topology remain rejected." + "changeId": "proofkit.package.workspace-illustration", + "summary": "Ship one real synthetic-project browser image with exact npm path admission, source-byte equality, independent PNG decoding and byte/dimension bounds. Installed README checks retain the read-only first action and state-qualified daily command routes. The image does not represent executed proof or certify future browser rendering." } ], "migration": {