From eececeaa0ec6e1f7f1ecb80e8151adfde8f999c7 Mon Sep 17 00:00:00 2001 From: homen Date: Thu, 8 Oct 2026 04:12:26 -0700 Subject: [PATCH] Keep presentation evidence observed and retain current gates --- .tours/03-debug-and-recovery.tour | 2 +- HANDOFF.md | 20 +- README.md | 36 ++ docs/NODEKIT_PRESENT_EVIDENCE.md | 114 ++++ docs/START_HERE.md | 36 +- evidence/nodekit-present.manifest.json | 112 ++++ nodekit.yaml | 6 +- package.json | 8 +- proof/featureclip-evidence.receipt.json | 110 ++++ proof/nodekit-present.evidence-index.json | 27 + ...atureclip.evidence-manifest.v1.schema.json | 125 ++++ ...eatureclip.evidence-receipt.v1.schema.json | 150 +++++ scripts/featureclip-evidence.mjs | 557 ++++++++++++++++++ test/featureclip-evidence.test.mjs | 421 +++++++++++++ 14 files changed, 1704 insertions(+), 20 deletions(-) create mode 100644 docs/NODEKIT_PRESENT_EVIDENCE.md create mode 100644 evidence/nodekit-present.manifest.json create mode 100644 proof/featureclip-evidence.receipt.json create mode 100644 proof/nodekit-present.evidence-index.json create mode 100644 schemas/featureclip.evidence-manifest.v1.schema.json create mode 100644 schemas/featureclip.evidence-receipt.v1.schema.json create mode 100644 scripts/featureclip-evidence.mjs create mode 100644 test/featureclip-evidence.test.mjs diff --git a/.tours/03-debug-and-recovery.tour b/.tours/03-debug-and-recovery.tour index caa05f2..3fc0252 100644 --- a/.tours/03-debug-and-recovery.tour +++ b/.tours/03-debug-and-recovery.tour @@ -85,7 +85,7 @@ "line": 36, "pattern": "Smoke render", "title": "12 — Gate 3: CI actually renders", - "description": "npm ci, npm run check, then a real Remotion render of frames 0-30 asserted to produce a non-empty MP4. This is the only automated check that executes application code. It deliberately excludes the Playwright capture step, which needs a live app CI does not have." + "description": "npm ci, npm run check, then a real Remotion render of frames 0-30 asserted to produce a non-empty MP4. The render step imports the composition code; the appended bridge scenarios execute the local evidence protocol and proof checks committed output without writing. Neither verifies a fresh product workflow. The Playwright capture step still needs a live app CI does not have." } ] } diff --git a/HANDOFF.md b/HANDOFF.md index bf8bf0b..cfcb961 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -70,11 +70,25 @@ The accepted framing makes the selected control/result and both client labels re The default `npm run render:example` still renders historical WT-NodeRoom inputs and retains a separate output-quality hold. LiveSync acceptance does not certify other examples, responsive browser behavior, physical devices, provider behavior or production. The earlier install reported 12 affected packages (2 low, 10 high). The normal installed-tree audit for the exact Remotion 4.0.479 lock reported zero on September 7, 2026. That is a dated dependency result, not a full security assessment. Full criterion and overall grades remain null. -Verify the packet and its selected current source bindings from the repository root: +Verify the retained packet from the repository root. Check its selected historical source bindings only against the matching retained source checkout: ```sh python promotion/evidence/local-livesync-handoff-20260907/verify.py -python promotion/evidence/local-livesync-handoff-20260907/verify.py --source-root . +python promotion/evidence/local-livesync-handoff-20260907/verify.py --source-root "" ``` -The first command checks exact packet bytes. The optional source check accepts only each row's demonstrated Git checkout newline transformation; it does not rerun capture/render or certify the whole repository. The evidence index records raw versus minimized receipts and which historical artifacts remain operator-local. +The first command checks exact packet bytes. The optional source check compares its original selected snapshot, allowing only each row's demonstrated Git checkout newline transformation. Use the checkout matching those recorded bindings; later code or documentation changes do not re-certify that snapshot. It does not rerun capture/render or certify the whole repository. The evidence index records raw versus minimized receipts and which historical artifacts remain operator-local. + +## Local evidence bridge + +For a Change Story or deck, use the [NodeKit Present evidence bridge](docs/NODEKIT_PRESENT_EVIDENCE.md) +for its commands, actual baseline/new comparison and declared limits. It projects +selected historical media as `observed`, with `productWorkflowProof: not-certified` +and `releaseReady: false`. Receipt references and retained supporting-file byte +guards do not establish passing outcomes or capture provenance. This local protocol +adds no new capture, render, provider, production or visual-quality certification. +## Local presentation-file proof update (2026-10-08) + +The no-key bridge's named proof is `CURRENT-GATES-RETAINED-EVIDENCE-OBSERVED-DRIFT-REJECTED-01`. Unchanged main passed 43 parses, 36 tour assertions, 34 prose citations and three render records. The candidate passed 45 parses (two protocol files added), the same 36/34/3 assertions and 20 local scenarios with no failures or skips. The preserved old 13-scenario suite had passed while labelling a schema-only receipt verified; the corrected scenario returns observed, including failed and unbound references. These are different suites, not a speed comparison. + +The actual projection contains three observed historical assets and zero workflow receipt references, with not-certified/releaseReady false. Stale proof did not rewrite outputs; redirected directory/file outputs were rejected with foreign files unchanged; eight repeated rounds and two simultaneous disjoint fixture jobs passed. The [bridge update log](docs/NODEKIT_PRESENT_EVIDENCE.md) records the source/claim boundaries and preserved Windows launcher failure. No capture/render, provider, production, physical-device or pixel result was added by this work. diff --git a/README.md b/README.md index 73aaa8a..aaed060 100644 --- a/README.md +++ b/README.md @@ -1468,3 +1468,39 @@ the whole pipeline: write a spec, capture, render, and embed the GIFs for you. ## License [MIT](LICENSE) © Homen Shum + +## NodeKit Present: local evidence bridge + +A developer preparing a Change Story or deck can use checked-in media as supporting +material without claiming that the pictured workflow passed. The authored +`evidence/nodekit-present.manifest.json` selects three historical assets and their +declared provenance; it does not trigger a new capture or render. + +Run from this reviewed checkout's root: + +```sh +npm run evidence:check +npm run evidence:project +npm run proof +npm run check +``` + +`evidence:project` explicitly writes `proof/nodekit-present.evidence-index.json` and +`proof/featureclip-evidence.receipt.json`. `proof` recomputes those outputs without +writing and fails if they are missing or stale. `check` retains the existing syntax, +citation and render-byte checks, then runs the local bridge scenarios and proof. + +Projected media stays `observed`; the receipt keeps +`productWorkflowProof: not-certified` and `releaseReady: false`. A tracked, matching +receipt reference is a declared link, counted as `workflowReceiptReferences`, not +a certificate of passing outcomes, target binding, freshness or judge quality. +Source-truth fields remain declared historical provenance. Active basis entries +guard retained supporting driver/state-file bytes; they do not establish capture-time +or current application behavior. Original changed README/NODE-LOOPS hashes remain +historical limitations rather than being rebound to today's documentation. + +This is a trusted local, single-writer file workflow. Capture, rendering, provider +judging and production verification remain separate. Read the +[bridge contract, actual comparison results and limits](docs/NODEKIT_PRESENT_EVIDENCE.md) +before consuming its output; these commands do not grant new visual or readiness +grades. diff --git a/docs/NODEKIT_PRESENT_EVIDENCE.md b/docs/NODEKIT_PRESENT_EVIDENCE.md new file mode 100644 index 0000000..030cb32 --- /dev/null +++ b/docs/NODEKIT_PRESENT_EVIDENCE.md @@ -0,0 +1,114 @@ +# NodeKit Present evidence bridge + +A developer preparing a deck can reuse a tracked screenshot or clip without +mistaking it for proof that the pictured workflow passed. This local bridge checks +the selected repository files and produces a content-addressed evidence index. +Matching receipt references remain references; every projected asset is observed. + +## Commands and checked inputs + +Run from the repository root with Node 20 or newer. These commands require no +dependency installation, browser, network or provider key: + +```sh +npm run evidence:check +npm run evidence:project +npm run proof +npm run check +``` + +The authored input is `evidence/nodekit-present.manifest.json`. The verifier checks +its exact field shape, Git tracking, contained regular-file paths, unique IDs and +paths, declared GIF/PNG signatures, header dimensions, byte sizes and SHA-256 +digests. It compares declared size before reading media bytes. Retained supporting +text hashes normalize newline differences. JSON contract hashes sort object keys. + +Capture environment and representation are **declared metadata**. Matching bytes +do not independently validate those declarations, decode every image, establish +visual quality or prove capture-time/current application behavior. Synthetic test +PNG headers exercise the metadata protocol, not rendered pixels. + +`evidence:project` explicitly writes two deterministic files: + +- `proof/nodekit-present.evidence-index.json`, a `nodekit.evidence-index/v1` index; +- `proof/featureclip-evidence.receipt.json`, the checked inputs and claim boundary. + +`proof` recomputes and compares both on-disk outputs without writing. Missing, +invalid or stale outputs fail with a nonzero exit. These outputs can be reviewed +before committing; their presence alone does not prove the verifier's code or CI. + +The fixed output parent and existing output files reuse containment checks and +reject directory/file redirects before output read/write. Per-file atomic writes +do not make the two-file pair transactional. This is a trusted local single-writer +tool; it is not a filesystem sandbox and does not guarantee safety against a +concurrent adversary changing paths or against writers sharing an output pair. +Input-sized collections and local synchronous reads have no global hard caps. + +## Status and receipt fields + +All matching media project as `observed`. The manifest's +`sourceTruth.workflowVerification` names a declared reference kind; +`verificationReceipt` is its optional path/digest/schema-name pointer. The bridge +checks that pointer's tracked bytes and schema name, **not** its outcome, target +binding, freshness or judge quality. Schema-only, failed and unrelated references +therefore remain observed. + +The receipt's former `checks.independentWorkflowReceipts` field is corrected to +`checks.workflowReceiptReferences`, with no alias. It counts resolved tracked +digest/schema-matched references, not passed workflows. The schema and output +always require `productWorkflowProof: "not-certified"` and `releaseReady: false`. +Generated illustrations retain their explicit unknown-capture boundary. + +The selected initial three historical assets have zero receipt references. +Other repository examples have their own historical judge files; this bridge +does not certify them or assert that the whole repository has no receipts. + +## Historical supporting files + +The three original media bytes are retained. Active basis guards use only the +unchanged walkthrough scripts and Room OS state file. Original README/NODE-LOOPS +digests from PR #4 head `36dd7741e53a6cc66e4ea0eac48d8717fa60ee69` remain in +limitations and the preserved original manifest. They are not replaced by current +document digests. Supporting-file matches do not establish that an old capture +was made from today's app. + +Consumers must retain the observed status, content-addressed location and summary +boundary. A presentation clip must not be relabelled as fresh browser proof, +passing workflow evidence or release certification. + +## Update log and before/new proof + +Named proof: `CURRENT-GATES-RETAINED-EVIDENCE-OBSERVED-DRIFT-REJECTED-01`. + +The current source/citation/render-byte gate remains `node check.mjs`. +`npm run check` appends local evidence scenarios and read-only projection proof. +The existing Remotion wrapper, lock, dependency versions, CI render command, +captured media and source anchors are retained. NodeKit's finite proof command +now uses this local bridge; its preview/partial/migration-planned disclosures stay. + +On 2026-10-08, unchanged main `b06ab1890b0a44634151e170a626d804c8098e9a` passed +43 JavaScript parses, 36 tour assertions, 34 prose citations and three render +records locally. The preserved PR module passed its 13 original scenarios while +actually reporting a schema-only browser receipt as verified. Only fixture +cleanup guards and an output diagnostic changed in that baseline harness; its +module and assertions remained unchanged. + +The candidate's explicit projection and expanded check both exited zero: + +| Evidence | Before | Candidate | +|---|---|---| +| Current source gate | 43 parses / 36 tours / 34 citations / 3 render records | 45 parses / same 36 / 34 / 3; two protocol files added | +| Local evidence scenarios | Original PR module: 13/13 passed, accepting the false promotion | 20/20 passed, no skips; 13 retained intents corrected plus seven scenarios | +| Schema-only receipt | Actual status verified | Actual status observed; failed and unbound references also observed | +| Output redirection | Source defect; before exploit not executed | Directory and file redirects rejected for project/proof; foreign files unchanged | +| Stale projection/receipt | No before CLI scenario | Exit 1 without rewriting either output | +| Repeated/disjoint handoffs | Not run before | Eight deterministic rounds; two simultaneous disjoint jobs passed | +| Selected media | Historical manifest declarations | Three actual byte/header metadata matches, three observed entries, zero receipt references | + +The counts describe different scenario suites, not a matched performance score. +The first candidate npm launch failed before adapter work because the process-only +Windows ComSpec path used forward slashes. Its failure was preserved; the same +npm launcher succeeded with the native backslash path. No product source changed +to bypass that failure. Generated receipts remain not-certified/releaseReady false. +No new render, provider, production, physical-device or visual-quality result is +claimed. The existing normal CI render baseline is a separate historical result. diff --git a/docs/START_HERE.md b/docs/START_HERE.md index d1f41db..031eef0 100644 --- a/docs/START_HERE.md +++ b/docs/START_HERE.md @@ -100,7 +100,7 @@ command. `npm run` is the discovery surface: the list below *is* the API. "studio": "node run-remotion.mjs studio src/index.js", "render:example": "node run-remotion.mjs render src/index.js WT-NodeRoom out/example.mp4 --concurrency=2", "clip": "node clip.mjs", -"check": "node check.mjs", +"check": "node check.mjs && npm run test:evidence && npm run proof", "probe:opening": "node probe-opening-frame.mjs", "probe:maxpath": "node probe-max-path.mjs" ``` @@ -166,8 +166,8 @@ logged, and the spec is retried in a brand-new page if `retries` was set. See St **Why this exists** A spec is plain data — `{ act: "click", sel: "btn:Approve" }`. Something has to turn `"btn:Approve"` into a real element and reject anything it does not understand. This is -that boundary, and it is the closest thing this repository has to schema validation. -There is no Zod, no JSON Schema, no type checker: the validation is a throw. +that boundary, and it is the validation seam on this capture path. +This capture path uses no Zod, JSON Schema or type checker: invalid ops throw. **Core code** ```js @@ -403,11 +403,11 @@ stage**: every stage's output is a file, so nothing is lost by starting over. ## Step 9 — The tests that prove the flow -There is no unit-test suite and no `npm test`. Saying otherwise would be the easiest -lie in this document. There are three real gates, in ascending order of what they -prove: +Local evidence-bridge scenarios run with `npm run test:evidence`; there is no +`npm test` alias. Separate source validation, local protocol execution and +rendered-output checks by what each one proves: -**1. `npm run check` → `check.mjs`** — two halves, and it prints how much of each it +**1. `npm run check`** starts with `node check.mjs`; this source gate prints how much it did, so it cannot silently shrink (the fifteen-file hand-kept list it replaced had drifted to covering a third of the repository). On 2026-08-13 it said: @@ -415,11 +415,11 @@ drifted to covering a third of the repository). On 2026-08-13 it said: [check] parsed 36/36 JavaScript files; 36/36 tour steps and 34/34 prose citations name a line that matches ``` -The first half runs `node --check` on every `.mjs`/`.js` file the package ships. Green +Its syntax pass runs `node --check` on every `.mjs`/`.js` file the package ships. Green means every file is syntactically valid JavaScript and **nothing more**: `--check` parses, it does not import, execute, or resolve a dependency. -The second half is the reason you can trust the numbers in this document. Every +Its citation pass checks the source references in this document. Every `file:line` written here or in a `.tours/*.tour` step has to carry the text it expects to find, and the gate opens the file and compares. Until 2026-08-13 it asserted only that the number was within the file's length, which proves an anchor is *stable* and @@ -428,6 +428,19 @@ all 36 tour steps still passed while every one of them pointed at the wrong symb A citation written in a form the gate cannot read is reported as a problem rather than skipped, because a citation that looks checked and is not is how this came back. +The same source gate also checks each committed `render.json` against its GIF, +named judge-run bytes and kept-run hash. These are byte bindings, not a new +judgment or a test that a depicted workflow passed. + +After that unchanged gate, `npm run check` runs `npm run test:evidence`, then +`npm run proof`. The scenarios execute the local evidence protocol; proof +recomputes its two committed outputs without writing and rejects missing or +stale files. The bridge checks declared bytes and references while keeping +media `observed`, `productWorkflowProof: not-certified` and `releaseReady: false`. +A matching receipt reference does not certify its outcome or target binding; +a retained supporting-file basis guard does not prove capture provenance. +See [the scoped bridge contract and comparison](NODEKIT_PRESENT_EVIDENCE.md). + **2. `npm run probe:opening` → `probe-opening-frame.mjs`** — the only committed gate that *runs the renderers and fails on a property of the rendered output*. It renders frame 0 and frame 4 of one composition per renderer, decodes both PNGs through ffmpeg, @@ -447,8 +460,9 @@ back. **3. `.github/workflows/ci.yml`** — on every push and pull request: `npm ci`, `npm run check`, then a real Remotion render of frames 0–30 of `WT-NodeRoom`, and it -asserts the MP4 exists and is non-empty. This is the only automated check that -executes application code. It deliberately excludes the Playwright capture step, which +asserts the MP4 exists and is non-empty. This step imports and renders the +composition code; the appended bridge scenarios execute the local protocol. +It deliberately excludes the Playwright capture step, which needs a live app that CI does not have. **What none of them prove:** that a capture against a real application produces correct diff --git a/evidence/nodekit-present.manifest.json b/evidence/nodekit-present.manifest.json new file mode 100644 index 0000000..d8b37b1 --- /dev/null +++ b/evidence/nodekit-present.manifest.json @@ -0,0 +1,112 @@ +{ + "schemaVersion": "featureclip.evidence-manifest/v1", + "repository": "HomenShum/FeatureClipStudio", + "collectionId": "featureclip-nodekit-present-p1", + "nodekitChangeId": "nodekit-factory-p1", + "requireGitTracked": true, + "artifacts": [ + { + "id": "noderoom-hero-walkthrough", + "title": "NodeRoom agent workflow walkthrough", + "artifactKind": "clip", + "path": "assets/feature-noderoom-hero.gif", + "mediaType": "image/gif", + "sha256": "b443e36ac89a215027104576eb29c8bc4b258660872a43775390b6ab0f6a1673", + "bytes": 2843694, + "dimensions": { + "width": 720, + "height": 405 + }, + "sourceTruth": { + "representation": "rendered-from-captured-product-ui", + "captureEnvironment": "deployed-application", + "artifactVerification": "repository-bytes", + "workflowVerification": "unverified", + "verificationReceipt": null + }, + "basis": [ + { + "path": "walkthrough.noderoom.specs.mjs", + "digestMode": "normalized-text-sha256", + "sha256": "23a6068b0f83dcd090ad08a4779fcf6278aa78a5182d863b05a4e7b5a56d9426" + } + ], + "limitations": [ + "This selected historical asset has no workflow receipt reference. Matching bytes and declared metadata do not independently verify its provenance or depicted workflow.", + "Historical supporting reference in original PR head 36dd7741e53a6cc66e4ea0eac48d8717fa60ee69: README.md, normalized-text-sha256 b2b9f0f4b1389ea9578c53040bec8941cad574aa2042510d58011ec6ea25f423. It changed on current main and is preserved as historical context, not rebound to this media.", + "Historical supporting reference in original PR head 36dd7741e53a6cc66e4ea0eac48d8717fa60ee69: NODE-LOOPS.md, normalized-text-sha256 e7b926d35dea7be0cdeb1592dd2f56b920b2d30ae8a170eda1e81c30375f03e9. It changed on current main and is preserved as historical context, not rebound to this media.", + "Active basis entries guard only retained supporting-file bytes. Representation and capture environment are historical declarations; this bridge does not recapture media or certify current application behavior, workflow outcomes, or pixels." + ] + }, + { + "id": "room-os-v1-state-screenshot", + "title": "Room OS V1 state evidence crop", + "artifactKind": "screenshot", + "path": "assets/room-os-v1-state-json.png", + "mediaType": "image/png", + "sha256": "44cef10aa164fb5491033bc596495b38d8a34ad63c2b53f466dfa18184bc48a8", + "bytes": 178021, + "dimensions": { + "width": 1280, + "height": 897 + }, + "sourceTruth": { + "representation": "rendered-from-captured-product-ui", + "captureEnvironment": "deployed-application", + "artifactVerification": "repository-bytes", + "workflowVerification": "unverified", + "verificationReceipt": null + }, + "basis": [ + { + "path": "assets/room-os-v1-state.json", + "digestMode": "normalized-text-sha256", + "sha256": "9c23f7a0bd5593fec06fa5595c567812f4929f39946eed97bd1e99223a013fdd" + }, + { + "path": "walkthrough.roomos.mjs", + "digestMode": "normalized-text-sha256", + "sha256": "3f2b0e16daa2ce309e1e11d38e0a33ac90ee7a333a0b06c1831f8eec5663cbad" + } + ], + "limitations": [ + "The crop is presentation evidence derived from captured state. It is not a fresh-browser receipt and must not be presented as one.", + "Historical supporting reference in original PR head 36dd7741e53a6cc66e4ea0eac48d8717fa60ee69: README.md, normalized-text-sha256 b2b9f0f4b1389ea9578c53040bec8941cad574aa2042510d58011ec6ea25f423. It changed on current main and is preserved as historical context, not rebound to this media.", + "Active basis entries guard only retained supporting-file bytes. Representation and capture environment are historical declarations; this bridge does not recapture media or certify current application behavior, workflow outcomes, or pixels." + ] + }, + { + "id": "solo-founder-baseline-frame", + "title": "Bundled SoloFounder captured baseline frame", + "artifactKind": "screenshot", + "path": "public/wt/SoloFounder/00.png", + "mediaType": "image/png", + "sha256": "382f000ab4fbc430138fde58b34f7905bbde99aaada1e06bfb5c7b1af2208e9f", + "bytes": 675621, + "dimensions": { + "width": 2560, + "height": 1600 + }, + "sourceTruth": { + "representation": "captured-product-ui", + "captureEnvironment": "local-fixture", + "artifactVerification": "repository-bytes", + "workflowVerification": "unverified", + "verificationReceipt": null + }, + "basis": [ + { + "path": "walkthrough.solo-founder.specs.mjs", + "digestMode": "normalized-text-sha256", + "sha256": "3725d99e55838e04803af7674e3365f1bd99f62ab168a55e95362f06ea1d8a85" + } + ], + "limitations": [ + "This is a checked-in worked-example frame from a local fixture, not evidence of a current production deployment.", + "Historical supporting reference in original PR head 36dd7741e53a6cc66e4ea0eac48d8717fa60ee69: README.md, normalized-text-sha256 b2b9f0f4b1389ea9578c53040bec8941cad574aa2042510d58011ec6ea25f423. It changed on current main and is preserved as historical context, not rebound to this media.", + "Historical supporting reference in original PR head 36dd7741e53a6cc66e4ea0eac48d8717fa60ee69: NODE-LOOPS.md, normalized-text-sha256 e7b926d35dea7be0cdeb1592dd2f56b920b2d30ae8a170eda1e81c30375f03e9. It changed on current main and is preserved as historical context, not rebound to this media.", + "Active basis entries guard only retained supporting-file bytes. Representation and capture environment are historical declarations; this bridge does not recapture media or certify current application behavior, workflow outcomes, or pixels." + ] + } + ] +} diff --git a/nodekit.yaml b/nodekit.yaml index fd78e96..f39936f 100644 --- a/nodekit.yaml +++ b/nodekit.yaml @@ -13,17 +13,17 @@ consumes: commands: doctor: { script: check, mode: finite } check: { script: check, mode: finite } - proof: { script: judge, mode: finite } + proof: { script: proof, mode: finite } noKey: status: partial command: npm run check externalAccountsRequired: 0 - disclosure: Source validation is no-key; capture and rendered proof require an available target and media inputs. + disclosure: Source checks and local media-byte/reference proof are no-key; receipt references do not certify workflow outcomes. Capture and rendered proof require an available target and media inputs. environment: contractVersion: nodeplatform.env/v1 status: migration-planned proof: - command: npm run judge + command: npm run proof receiptSchema: featureclip.evidence-receipt/v1 contractDeclarations: [] architectureExceptions: [] diff --git a/package.json b/package.json index 38a27d0..9065a49 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,7 @@ "streamlit" ], "scripts": { - "check": "node check.mjs", + "check": "node check.mjs && npm run test:evidence && npm run proof", "probe:opening": "node probe-opening-frame.mjs", "probe:maxpath": "node probe-max-path.mjs", "audit:ui": "node audit-ui.mjs", @@ -47,7 +47,11 @@ "voice": "node voice.mjs", "readability": "node readability.mjs", "retime": "node retime.mjs", - "captions": "node apply-captions.mjs" + "captions": "node apply-captions.mjs", + "test:evidence": "node --test test/featureclip-evidence.test.mjs", + "evidence:check": "node scripts/featureclip-evidence.mjs check", + "evidence:project": "node scripts/featureclip-evidence.mjs project --write", + "proof": "node scripts/featureclip-evidence.mjs proof" }, "dependencies": { "@remotion/cli": "4.0.479", diff --git a/proof/featureclip-evidence.receipt.json b/proof/featureclip-evidence.receipt.json new file mode 100644 index 0000000..56500c0 --- /dev/null +++ b/proof/featureclip-evidence.receipt.json @@ -0,0 +1,110 @@ +{ + "schemaVersion": "featureclip.evidence-receipt/v1", + "collectionId": "featureclip-nodekit-present-p1", + "manifest": { + "path": "evidence/nodekit-present.manifest.json", + "digestMode": "canonical-json-sha256", + "sha256": "fe1d37f74a8d64ad65d4f49959fa3d20a2021cd4c70b4df6790e2f80d9b0daf1", + "schemaVersion": "featureclip.evidence-manifest/v1" + }, + "projection": { + "path": "proof/nodekit-present.evidence-index.json", + "digestMode": "canonical-json-sha256", + "sha256": "aefddd8525c7a76b66b72d6a35b4cec5d7706a3b0ec03cad42b4f4d92013d1f0", + "schemaVersion": "nodekit.evidence-index/v1", + "changeId": "nodekit-factory-p1" + }, + "checks": { + "passed": true, + "pathSafety": "passed", + "duplicateIds": "none", + "duplicatePaths": "none", + "artifactsDeclared": 3, + "artifactBytesVerified": 3, + "metadataMatched": 3, + "gitTracked": 3, + "workflowReceiptReferences": 0, + "generatedIllustrations": 0 + }, + "artifacts": [ + { + "id": "noderoom-hero-walkthrough", + "path": "assets/feature-noderoom-hero.gif", + "sha256": "b443e36ac89a215027104576eb29c8bc4b258660872a43775390b6ab0f6a1673", + "bytes": 2843694, + "mediaType": "image/gif", + "dimensions": { + "width": 720, + "height": 405 + }, + "representation": "rendered-from-captured-product-ui", + "captureEnvironment": "deployed-application", + "artifactVerification": "repository-bytes", + "workflowVerification": "unverified", + "basis": [ + { + "path": "walkthrough.noderoom.specs.mjs", + "digestMode": "normalized-text-sha256", + "sha256": "23a6068b0f83dcd090ad08a4779fcf6278aa78a5182d863b05a4e7b5a56d9426" + } + ], + "metadataMatched": true + }, + { + "id": "room-os-v1-state-screenshot", + "path": "assets/room-os-v1-state-json.png", + "sha256": "44cef10aa164fb5491033bc596495b38d8a34ad63c2b53f466dfa18184bc48a8", + "bytes": 178021, + "mediaType": "image/png", + "dimensions": { + "width": 1280, + "height": 897 + }, + "representation": "rendered-from-captured-product-ui", + "captureEnvironment": "deployed-application", + "artifactVerification": "repository-bytes", + "workflowVerification": "unverified", + "basis": [ + { + "path": "assets/room-os-v1-state.json", + "digestMode": "normalized-text-sha256", + "sha256": "9c23f7a0bd5593fec06fa5595c567812f4929f39946eed97bd1e99223a013fdd" + }, + { + "path": "walkthrough.roomos.mjs", + "digestMode": "normalized-text-sha256", + "sha256": "3f2b0e16daa2ce309e1e11d38e0a33ac90ee7a333a0b06c1831f8eec5663cbad" + } + ], + "metadataMatched": true + }, + { + "id": "solo-founder-baseline-frame", + "path": "public/wt/SoloFounder/00.png", + "sha256": "382f000ab4fbc430138fde58b34f7905bbde99aaada1e06bfb5c7b1af2208e9f", + "bytes": 675621, + "mediaType": "image/png", + "dimensions": { + "width": 2560, + "height": 1600 + }, + "representation": "captured-product-ui", + "captureEnvironment": "local-fixture", + "artifactVerification": "repository-bytes", + "workflowVerification": "unverified", + "basis": [ + { + "path": "walkthrough.solo-founder.specs.mjs", + "digestMode": "normalized-text-sha256", + "sha256": "3725d99e55838e04803af7674e3365f1bd99f62ab168a55e95362f06ea1d8a85" + } + ], + "metadataMatched": true + } + ], + "claimBoundary": { + "releaseReady": false, + "productWorkflowProof": "not-certified", + "statement": "This receipt verifies checked-input containment, Git tracking, media signatures, dimensions, byte sizes, digests, and the shape of declared provenance and receipt references. Supporting-file basis hashes are byte guards, not capture-time certificates. Receipt references do not certify outcomes, target bindings, freshness, judge quality, pixels, or release readiness. This is a trusted local single-writer check, not a filesystem sandbox." + } +} diff --git a/proof/nodekit-present.evidence-index.json b/proof/nodekit-present.evidence-index.json new file mode 100644 index 0000000..5d142c5 --- /dev/null +++ b/proof/nodekit-present.evidence-index.json @@ -0,0 +1,27 @@ +{ + "schemaVersion": "nodekit.evidence-index/v1", + "changeId": "nodekit-factory-p1", + "evidence": [ + { + "id": "featureclip-noderoom-hero-walkthrough", + "kind": "presentation-clip", + "status": "observed", + "location": "repo://HomenShum/FeatureClipStudio/assets/feature-noderoom-hero.gif?sha256=b443e36ac89a215027104576eb29c8bc4b258660872a43775390b6ab0f6a1673", + "summary": "NodeRoom agent workflow walkthrough: repository bytes, media signature, dimensions, and Git tracking match the manifest. Declared provenance is rendered-from-captured-product-ui in deployed-application; supporting-file basis bytes match; no workflow receipt reference. Historical capture provenance, current behavior, pixels, and release readiness are not certified." + }, + { + "id": "featureclip-room-os-v1-state-screenshot", + "kind": "presentation-screenshot", + "status": "observed", + "location": "repo://HomenShum/FeatureClipStudio/assets/room-os-v1-state-json.png?sha256=44cef10aa164fb5491033bc596495b38d8a34ad63c2b53f466dfa18184bc48a8", + "summary": "Room OS V1 state evidence crop: repository bytes, media signature, dimensions, and Git tracking match the manifest. Declared provenance is rendered-from-captured-product-ui in deployed-application; supporting-file basis bytes match; no workflow receipt reference. Historical capture provenance, current behavior, pixels, and release readiness are not certified." + }, + { + "id": "featureclip-solo-founder-baseline-frame", + "kind": "presentation-screenshot", + "status": "observed", + "location": "repo://HomenShum/FeatureClipStudio/public/wt/SoloFounder/00.png?sha256=382f000ab4fbc430138fde58b34f7905bbde99aaada1e06bfb5c7b1af2208e9f", + "summary": "Bundled SoloFounder captured baseline frame: repository bytes, media signature, dimensions, and Git tracking match the manifest. Declared provenance is captured-product-ui in local-fixture; supporting-file basis bytes match; no workflow receipt reference. Historical capture provenance, current behavior, pixels, and release readiness are not certified." + } + ] +} diff --git a/schemas/featureclip.evidence-manifest.v1.schema.json b/schemas/featureclip.evidence-manifest.v1.schema.json new file mode 100644 index 0000000..daa37ff --- /dev/null +++ b/schemas/featureclip.evidence-manifest.v1.schema.json @@ -0,0 +1,125 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/HomenShum/FeatureClipStudio/blob/main/schemas/featureclip.evidence-manifest.v1.schema.json", + "title": "FeatureClipStudio evidence manifest", + "type": "object", + "required": [ + "schemaVersion", + "repository", + "collectionId", + "nodekitChangeId", + "requireGitTracked", + "artifacts" + ], + "properties": { + "schemaVersion": { "const": "featureclip.evidence-manifest/v1" }, + "repository": { "const": "HomenShum/FeatureClipStudio" }, + "collectionId": { "type": "string", "minLength": 1 }, + "nodekitChangeId": { "type": "string", "minLength": 1 }, + "requireGitTracked": { "const": true }, + "artifacts": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "required": [ + "id", + "title", + "artifactKind", + "path", + "mediaType", + "sha256", + "bytes", + "dimensions", + "sourceTruth", + "basis", + "limitations" + ], + "properties": { + "id": { "type": "string", "pattern": "^[a-z0-9][a-z0-9-]*$" }, + "title": { "type": "string", "minLength": 1 }, + "artifactKind": { "enum": ["clip", "screenshot"] }, + "path": { "type": "string", "minLength": 1 }, + "mediaType": { "enum": ["image/gif", "image/png"] }, + "sha256": { "type": "string", "pattern": "^[a-f0-9]{64}$" }, + "bytes": { "type": "integer", "minimum": 1 }, + "dimensions": { + "type": "object", + "required": ["width", "height"], + "properties": { + "width": { "type": "integer", "minimum": 1 }, + "height": { "type": "integer", "minimum": 1 } + }, + "additionalProperties": false + }, + "sourceTruth": { + "type": "object", + "required": [ + "representation", + "captureEnvironment", + "artifactVerification", + "workflowVerification", + "verificationReceipt" + ], + "properties": { + "representation": { + "enum": [ + "captured-product-ui", + "rendered-from-captured-product-ui", + "generated-illustration" + ] + }, + "captureEnvironment": { + "enum": ["deployed-application", "local-fixture", "unknown"] + }, + "artifactVerification": { "const": "repository-bytes" }, + "workflowVerification": { + "description": "Declared receipt-reference kind, not a certified workflow outcome.", + "enum": ["unverified", "judge-receipt", "browser-receipt"] + }, + "verificationReceipt": { + "description": "Optional tracked digest/schema-matched reference; its outcome and target binding are not validated.", + "oneOf": [ + { "type": "null" }, + { + "type": "object", + "required": ["path", "sha256", "schemaVersion"], + "properties": { + "path": { "type": "string", "minLength": 1 }, + "sha256": { "type": "string", "pattern": "^[a-f0-9]{64}$" }, + "schemaVersion": { "type": "string", "minLength": 1 } + }, + "additionalProperties": false + } + ] + } + }, + "additionalProperties": false + }, + "basis": { + "description": "Retained supporting-file byte guards, not proof of capture-time or current application behavior.", + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "required": ["path", "digestMode", "sha256"], + "properties": { + "path": { "type": "string", "minLength": 1 }, + "digestMode": { "const": "normalized-text-sha256" }, + "sha256": { "type": "string", "pattern": "^[a-f0-9]{64}$" } + }, + "additionalProperties": false + } + }, + "limitations": { + "type": "array", + "minItems": 1, + "items": { "type": "string", "minLength": 1 } + } + }, + "additionalProperties": false + } + } + }, + "additionalProperties": false +} diff --git a/schemas/featureclip.evidence-receipt.v1.schema.json b/schemas/featureclip.evidence-receipt.v1.schema.json new file mode 100644 index 0000000..5c86e65 --- /dev/null +++ b/schemas/featureclip.evidence-receipt.v1.schema.json @@ -0,0 +1,150 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/HomenShum/FeatureClipStudio/blob/main/schemas/featureclip.evidence-receipt.v1.schema.json", + "title": "FeatureClipStudio evidence verification receipt", + "type": "object", + "required": [ + "schemaVersion", + "collectionId", + "manifest", + "projection", + "checks", + "artifacts", + "claimBoundary" + ], + "properties": { + "schemaVersion": { "const": "featureclip.evidence-receipt/v1" }, + "collectionId": { "type": "string", "minLength": 1 }, + "manifest": { + "type": "object", + "required": ["path", "digestMode", "sha256", "schemaVersion"], + "properties": { + "path": { "type": "string", "minLength": 1 }, + "digestMode": { "const": "canonical-json-sha256" }, + "sha256": { "type": "string", "pattern": "^[a-f0-9]{64}$" }, + "schemaVersion": { "const": "featureclip.evidence-manifest/v1" } + }, + "additionalProperties": false + }, + "projection": { + "type": "object", + "required": ["path", "digestMode", "sha256", "schemaVersion", "changeId"], + "properties": { + "path": { "type": "string", "minLength": 1 }, + "digestMode": { "const": "canonical-json-sha256" }, + "sha256": { "type": "string", "pattern": "^[a-f0-9]{64}$" }, + "schemaVersion": { "const": "nodekit.evidence-index/v1" }, + "changeId": { "type": "string", "minLength": 1 } + }, + "additionalProperties": false + }, + "checks": { + "type": "object", + "required": [ + "passed", + "pathSafety", + "duplicateIds", + "duplicatePaths", + "artifactsDeclared", + "artifactBytesVerified", + "metadataMatched", + "gitTracked", + "workflowReceiptReferences", + "generatedIllustrations" + ], + "properties": { + "passed": { "const": true }, + "pathSafety": { "const": "passed" }, + "duplicateIds": { "const": "none" }, + "duplicatePaths": { "const": "none" }, + "artifactsDeclared": { "type": "integer", "minimum": 1 }, + "artifactBytesVerified": { "type": "integer", "minimum": 1 }, + "metadataMatched": { "type": "integer", "minimum": 1 }, + "gitTracked": { "type": "integer", "minimum": 1 }, + "workflowReceiptReferences": { "type": "integer", "minimum": 0, "description": "Resolved tracked digest/schema-matched references; outcomes are not certified." }, + "generatedIllustrations": { "type": "integer", "minimum": 0 } + }, + "additionalProperties": false + }, + "artifacts": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "required": [ + "id", + "path", + "sha256", + "bytes", + "mediaType", + "dimensions", + "representation", + "captureEnvironment", + "artifactVerification", + "workflowVerification", + "basis", + "metadataMatched" + ], + "properties": { + "id": { "type": "string", "minLength": 1 }, + "path": { "type": "string", "minLength": 1 }, + "sha256": { "type": "string", "pattern": "^[a-f0-9]{64}$" }, + "bytes": { "type": "integer", "minimum": 1 }, + "mediaType": { "enum": ["image/gif", "image/png"] }, + "dimensions": { + "type": "object", + "required": ["width", "height"], + "properties": { + "width": { "type": "integer", "minimum": 1 }, + "height": { "type": "integer", "minimum": 1 } + }, + "additionalProperties": false + }, + "representation": { + "enum": [ + "captured-product-ui", + "rendered-from-captured-product-ui", + "generated-illustration" + ] + }, + "captureEnvironment": { + "enum": ["deployed-application", "local-fixture", "unknown"] + }, + "artifactVerification": { "const": "repository-bytes" }, + "workflowVerification": { + "description": "Declared receipt-reference kind only.", + "enum": ["unverified", "judge-receipt", "browser-receipt"] + }, + "basis": { + "description": "Retained supporting-file byte guards only.", + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "required": ["path", "digestMode", "sha256"], + "properties": { + "path": { "type": "string", "minLength": 1 }, + "digestMode": { "const": "normalized-text-sha256" }, + "sha256": { "type": "string", "pattern": "^[a-f0-9]{64}$" } + }, + "additionalProperties": false + } + }, + "metadataMatched": { "const": true } + }, + "additionalProperties": false + } + }, + "claimBoundary": { + "type": "object", + "required": ["releaseReady", "productWorkflowProof", "statement"], + "properties": { + "releaseReady": { "const": false }, + "productWorkflowProof": { "const": "not-certified" }, + "statement": { "type": "string", "minLength": 1 } + }, + "additionalProperties": false + } + }, + "additionalProperties": false +} diff --git a/scripts/featureclip-evidence.mjs b/scripts/featureclip-evidence.mjs new file mode 100644 index 0000000..8d2b287 --- /dev/null +++ b/scripts/featureclip-evidence.mjs @@ -0,0 +1,557 @@ +#!/usr/bin/env node + +import { + lstatSync, + mkdirSync, + readFileSync, + realpathSync, + renameSync, + rmSync, + statSync, + writeFileSync, +} from "node:fs"; +import { createHash } from "node:crypto"; +import { basename, dirname, extname, isAbsolute, relative, resolve, sep } from "node:path"; +import { spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; + +const MANIFEST_SCHEMA = "featureclip.evidence-manifest/v1"; +const RECEIPT_SCHEMA = "featureclip.evidence-receipt/v1"; +const EVIDENCE_INDEX_SCHEMA = "nodekit.evidence-index/v1"; +const DEFAULT_MANIFEST = "evidence/nodekit-present.manifest.json"; +const DEFAULT_PROJECTION = "proof/nodekit-present.evidence-index.json"; +const DEFAULT_RECEIPT = "proof/featureclip-evidence.receipt.json"; + +const ROOT_KEYS = [ + "schemaVersion", + "repository", + "collectionId", + "nodekitChangeId", + "requireGitTracked", + "artifacts", +]; +const ARTIFACT_KEYS = [ + "id", + "title", + "artifactKind", + "path", + "mediaType", + "sha256", + "bytes", + "dimensions", + "sourceTruth", + "basis", + "limitations", +]; +const SOURCE_TRUTH_KEYS = [ + "representation", + "captureEnvironment", + "artifactVerification", + "workflowVerification", + "verificationReceipt", +]; +const RECEIPT_REF_KEYS = ["path", "sha256", "schemaVersion"]; +const BASIS_KEYS = ["path", "digestMode", "sha256"]; +const REPRESENTATIONS = new Set([ + "captured-product-ui", + "rendered-from-captured-product-ui", + "generated-illustration", +]); +const CAPTURE_ENVIRONMENTS = new Set(["deployed-application", "local-fixture", "unknown"]); +const WORKFLOW_VERIFICATIONS = new Set(["unverified", "judge-receipt", "browser-receipt"]); +const MEDIA_TYPES = new Map([ + [".gif", "image/gif"], + [".png", "image/png"], +]); + +export class EvidenceError extends Error { + constructor(message) { + super(message); + this.name = "EvidenceError"; + } +} + +function fail(message) { + throw new EvidenceError(message); +} + +function isPlainObject(value) { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +function assertExactKeys(value, expected, label) { + if (!isPlainObject(value)) fail(`${label} must be an object`); + const actual = Object.keys(value).sort(); + const wanted = [...expected].sort(); + const missing = wanted.filter((key) => !actual.includes(key)); + const unknown = actual.filter((key) => !wanted.includes(key)); + if (missing.length || unknown.length) { + fail(`${label} fields are invalid (missing: ${missing.join(", ") || "none"}; unknown: ${unknown.join(", ") || "none"})`); + } +} + +function assertNonEmptyString(value, label) { + if (typeof value !== "string" || value.trim() === "") fail(`${label} must be a non-empty string`); +} + +function assertStringArray(value, label, { min = 0 } = {}) { + if (!Array.isArray(value) || value.length < min) fail(`${label} must contain at least ${min} item(s)`); + const seen = new Set(); + value.forEach((item, index) => { + assertNonEmptyString(item, `${label}[${index}]`); + const folded = item.toLowerCase(); + if (seen.has(folded)) fail(`${label} contains duplicate value ${item}`); + seen.add(folded); + }); +} + +function sha256(buffer) { + return createHash("sha256").update(buffer).digest("hex"); +} + +function canonicalJson(value) { + if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`; + if (isPlainObject(value)) { + return `{${Object.keys(value) + .sort() + .map((key) => `${JSON.stringify(key)}:${canonicalJson(value[key])}`) + .join(",")}}`; + } + return JSON.stringify(value); +} + +function canonicalJsonSha256(value) { + return sha256(Buffer.from(canonicalJson(value), "utf8")); +} + +function prettyJson(value) { + return `${JSON.stringify(value, null, 2)}\n`; +} + +function normalizedTextSha256(buffer, label) { + let text; + try { + text = new TextDecoder("utf-8", { fatal: true }).decode(buffer); + } catch { + fail(`${label} is not valid UTF-8 text`); + } + return sha256(Buffer.from(text.replace(/\r\n/g, "\n").replace(/\r/g, "\n"), "utf8")); +} + +function safeRelativePath(input, label) { + assertNonEmptyString(input, label); + if (isAbsolute(input) || /^[a-zA-Z]:[\\/]/.test(input) || input.startsWith("\\\\")) { + fail(`${label} must be repository-relative`); + } + if (input.includes("\\")) fail(`${label} must use forward slashes`); + const parts = input.split("/"); + if (parts.some((part) => part === "" || part === "." || part === "..")) { + fail(`${label} contains an unsafe path segment`); + } + return input; +} + +function isContained(rootReal, targetReal) { + const delta = relative(rootReal, targetReal); + return delta === "" || (!delta.startsWith(`..${sep}`) && delta !== ".." && !isAbsolute(delta)); +} + +function resolveExistingContainedFile(rootReal, relativePath, label) { + const safe = safeRelativePath(relativePath, label); + const lexical = resolve(rootReal, safe); + if (!isContained(rootReal, lexical)) fail(`${label} escapes the repository root`); + let real; + try { + real = realpathSync(lexical); + } catch { + fail(`${label} does not exist: ${safe}`); + } + if (!isContained(rootReal, real)) fail(`${label} resolves outside the repository root`); + const direct = lstatSync(lexical); + if (direct.isSymbolicLink()) fail(`${label} must not be a symbolic link`); + const stat = statSync(real); + if (!stat.isFile()) fail(`${label} must resolve to a regular file`); + return { safe, real, stat }; +} + +function verifyGitTracked(rootReal, relativePath, label) { + const result = spawnSync("git", ["ls-files", "--error-unmatch", "--", relativePath], { + cwd: rootReal, + encoding: "utf8", + windowsHide: true, + }); + if (result.status !== 0) fail(`${label} is not tracked by Git: ${relativePath}`); +} + +function readImageMetadata(buffer, mediaType, label) { + if (mediaType === "image/png") { + const signature = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]); + if (buffer.length < 24 || !buffer.subarray(0, 8).equals(signature)) fail(`${label} is not a valid PNG signature`); + return { width: buffer.readUInt32BE(16), height: buffer.readUInt32BE(20) }; + } + if (mediaType === "image/gif") { + const signature = buffer.subarray(0, 6).toString("ascii"); + if (buffer.length < 10 || (signature !== "GIF87a" && signature !== "GIF89a")) { + fail(`${label} is not a valid GIF signature`); + } + return { width: buffer.readUInt16LE(6), height: buffer.readUInt16LE(8) }; + } + fail(`${label} uses unsupported media type ${mediaType}`); +} + +function validateManifestShape(manifest) { + assertExactKeys(manifest, ROOT_KEYS, "manifest"); + if (manifest.schemaVersion !== MANIFEST_SCHEMA) fail(`manifest schemaVersion must be ${MANIFEST_SCHEMA}`); + if (manifest.repository !== "HomenShum/FeatureClipStudio") fail("manifest repository must be HomenShum/FeatureClipStudio"); + assertNonEmptyString(manifest.collectionId, "manifest.collectionId"); + assertNonEmptyString(manifest.nodekitChangeId, "manifest.nodekitChangeId"); + if (manifest.requireGitTracked !== true) fail("manifest.requireGitTracked must be true"); + if (!Array.isArray(manifest.artifacts) || manifest.artifacts.length === 0) fail("manifest.artifacts must not be empty"); + + const ids = new Set(); + const paths = new Set(); + for (const [index, artifact] of manifest.artifacts.entries()) { + const label = `manifest.artifacts[${index}]`; + assertExactKeys(artifact, ARTIFACT_KEYS, label); + assertNonEmptyString(artifact.id, `${label}.id`); + if (!/^[a-z0-9][a-z0-9-]*$/.test(artifact.id)) fail(`${label}.id must be kebab-case`); + const foldedId = artifact.id.toLowerCase(); + if (ids.has(foldedId)) fail(`duplicate artifact id ${artifact.id}`); + ids.add(foldedId); + assertNonEmptyString(artifact.title, `${label}.title`); + if (!new Set(["clip", "screenshot"]).has(artifact.artifactKind)) fail(`${label}.artifactKind is unsupported`); + safeRelativePath(artifact.path, `${label}.path`); + const foldedPath = artifact.path.toLowerCase(); + if (paths.has(foldedPath)) fail(`duplicate artifact path ${artifact.path}`); + paths.add(foldedPath); + const expectedMediaType = MEDIA_TYPES.get(extname(artifact.path).toLowerCase()); + if (!expectedMediaType || artifact.mediaType !== expectedMediaType) fail(`${label}.mediaType does not match the file extension`); + if (artifact.artifactKind === "clip" && artifact.mediaType !== "image/gif") fail(`${label} clips must be GIFs in v1`); + if (artifact.artifactKind === "screenshot" && artifact.mediaType !== "image/png") fail(`${label} screenshots must be PNGs in v1`); + if (typeof artifact.sha256 !== "string" || !/^[a-f0-9]{64}$/.test(artifact.sha256)) fail(`${label}.sha256 is invalid`); + if (!Number.isSafeInteger(artifact.bytes) || artifact.bytes < 1) fail(`${label}.bytes must be a positive integer`); + assertExactKeys(artifact.dimensions, ["width", "height"], `${label}.dimensions`); + if (!Number.isSafeInteger(artifact.dimensions.width) || artifact.dimensions.width < 1) fail(`${label}.dimensions.width is invalid`); + if (!Number.isSafeInteger(artifact.dimensions.height) || artifact.dimensions.height < 1) fail(`${label}.dimensions.height is invalid`); + assertExactKeys(artifact.sourceTruth, SOURCE_TRUTH_KEYS, `${label}.sourceTruth`); + const truth = artifact.sourceTruth; + if (!REPRESENTATIONS.has(truth.representation)) fail(`${label}.sourceTruth.representation is unsupported`); + if (!CAPTURE_ENVIRONMENTS.has(truth.captureEnvironment)) fail(`${label}.sourceTruth.captureEnvironment is unsupported`); + if (truth.artifactVerification !== "repository-bytes") fail(`${label}.sourceTruth.artifactVerification must be repository-bytes`); + if (!WORKFLOW_VERIFICATIONS.has(truth.workflowVerification)) fail(`${label}.sourceTruth.workflowVerification is unsupported`); + if (truth.workflowVerification === "unverified" && truth.verificationReceipt !== null) { + fail(`${label} cannot attach a verification receipt while workflowVerification is unverified`); + } + if (truth.workflowVerification !== "unverified") { + assertExactKeys(truth.verificationReceipt, RECEIPT_REF_KEYS, `${label}.sourceTruth.verificationReceipt`); + safeRelativePath(truth.verificationReceipt.path, `${label}.sourceTruth.verificationReceipt.path`); + if (!/^[a-f0-9]{64}$/.test(truth.verificationReceipt.sha256)) fail(`${label}.sourceTruth.verificationReceipt.sha256 is invalid`); + assertNonEmptyString(truth.verificationReceipt.schemaVersion, `${label}.sourceTruth.verificationReceipt.schemaVersion`); + } + if (truth.representation === "generated-illustration") { + if (truth.captureEnvironment !== "unknown") fail(`${label} generated illustrations must use captureEnvironment=unknown`); + if (truth.workflowVerification !== "unverified") fail(`${label} generated illustrations cannot certify a product workflow`); + } else if (truth.captureEnvironment === "unknown") { + fail(`${label} captured product UI must declare its capture environment`); + } + if (!Array.isArray(artifact.basis) || artifact.basis.length === 0) fail(`${label}.basis must not be empty`); + const basisPaths = new Set(); + artifact.basis.forEach((basis, basisIndex) => { + const basisLabel = `${label}.basis[${basisIndex}]`; + assertExactKeys(basis, BASIS_KEYS, basisLabel); + safeRelativePath(basis.path, `${basisLabel}.path`); + if (basis.digestMode !== "normalized-text-sha256") fail(`${basisLabel}.digestMode is unsupported`); + if (!/^[a-f0-9]{64}$/.test(basis.sha256)) fail(`${basisLabel}.sha256 is invalid`); + const foldedBasisPath = basis.path.toLowerCase(); + if (basisPaths.has(foldedBasisPath)) fail(`${label}.basis contains duplicate path ${basis.path}`); + basisPaths.add(foldedBasisPath); + }); + assertStringArray(artifact.limitations, `${label}.limitations`, { min: 1 }); + } +} + +export function verifyEvidenceManifest(root, manifestPath = DEFAULT_MANIFEST) { + const rootReal = realpathSync(root); + const manifestFile = resolveExistingContainedFile(rootReal, manifestPath, "manifest path"); + const manifestBytes = readFileSync(manifestFile.real); + let manifest; + try { + manifest = JSON.parse(manifestBytes.toString("utf8")); + } catch (error) { + fail(`manifest is not valid JSON: ${error.message}`); + } + validateManifestShape(manifest); + if (manifest.requireGitTracked) verifyGitTracked(rootReal, manifestFile.safe, "manifest path"); + + const verifiedArtifacts = manifest.artifacts.map((artifact, index) => { + const label = `artifact ${artifact.id}`; + const file = resolveExistingContainedFile(rootReal, artifact.path, `${label} path`); + if (manifest.requireGitTracked) verifyGitTracked(rootReal, file.safe, `${label} path`); + if (file.stat.size !== artifact.bytes) fail(`${label} byte-size drift: expected ${artifact.bytes}, received ${file.stat.size}`); + const bytes = readFileSync(file.real); + const digest = sha256(bytes); + if (digest !== artifact.sha256) fail(`${label} digest drift: expected ${artifact.sha256}, received ${digest}`); + const dimensions = readImageMetadata(bytes, artifact.mediaType, label); + if (dimensions.width !== artifact.dimensions.width || dimensions.height !== artifact.dimensions.height) { + fail(`${label} dimension drift: expected ${artifact.dimensions.width}x${artifact.dimensions.height}, received ${dimensions.width}x${dimensions.height}`); + } + + const verifiedBasis = []; + for (const [basisIndex, basisRef] of artifact.basis.entries()) { + const basis = resolveExistingContainedFile(rootReal, basisRef.path, `${label} basis[${basisIndex}]`); + if (manifest.requireGitTracked) verifyGitTracked(rootReal, basis.safe, `${label} basis[${basisIndex}]`); + const basisDigest = normalizedTextSha256(readFileSync(basis.real), `${label} basis[${basisIndex}]`); + if (basisDigest !== basisRef.sha256) fail(`${label} basis[${basisIndex}] digest drift`); + verifiedBasis.push({ path: basis.safe, digestMode: basisRef.digestMode, sha256: basisDigest }); + } + + let verificationReceipt = null; + if (artifact.sourceTruth.verificationReceipt) { + const receiptRef = artifact.sourceTruth.verificationReceipt; + const receiptFile = resolveExistingContainedFile(rootReal, receiptRef.path, `${label} verification receipt`); + if (manifest.requireGitTracked) verifyGitTracked(rootReal, receiptFile.safe, `${label} verification receipt`); + const receiptBytes = readFileSync(receiptFile.real); + const receiptDigest = sha256(receiptBytes); + if (receiptDigest !== receiptRef.sha256) fail(`${label} verification receipt digest drift`); + let parsed; + try { + parsed = JSON.parse(receiptBytes.toString("utf8")); + } catch { + fail(`${label} verification receipt is not valid JSON`); + } + if (parsed.schemaVersion !== receiptRef.schemaVersion) fail(`${label} verification receipt schema drift`); + verificationReceipt = { path: receiptRef.path, sha256: receiptDigest, schemaVersion: parsed.schemaVersion }; + } + + return { + id: artifact.id, + title: artifact.title, + artifactKind: artifact.artifactKind, + path: artifact.path, + mediaType: artifact.mediaType, + sha256: digest, + bytes: file.stat.size, + dimensions, + gitTracked: manifest.requireGitTracked, + sourceTruth: artifact.sourceTruth, + basis: verifiedBasis, + limitations: artifact.limitations, + verificationReceipt, + metadataMatched: true, + }; + }); + + return { + root: rootReal, + manifestPath: manifestFile.safe, + manifestSha256: canonicalJsonSha256(manifest), + manifest, + artifacts: verifiedArtifacts, + }; +} + +export function buildNodeKitEvidenceIndex(verification) { + return { + schemaVersion: EVIDENCE_INDEX_SCHEMA, + changeId: verification.manifest.nodekitChangeId, + evidence: verification.artifacts.map((artifact) => { + const boundary = artifact.verificationReceipt + ? `matched ${artifact.sourceTruth.workflowVerification} reference ${artifact.verificationReceipt.schemaVersion}; reference contents do not certify a workflow outcome` + : "no workflow receipt reference"; + return { + id: `featureclip-${artifact.id}`, + kind: artifact.artifactKind === "clip" ? "presentation-clip" : "presentation-screenshot", + status: "observed", + location: `repo://${verification.manifest.repository}/${artifact.path}?sha256=${artifact.sha256}`, + summary: `${artifact.title}: repository bytes, media signature, dimensions, and Git tracking match the manifest. Declared provenance is ${artifact.sourceTruth.representation} in ${artifact.sourceTruth.captureEnvironment}; supporting-file basis bytes match; ${boundary}. Historical capture provenance, current behavior, pixels, and release readiness are not certified.`, + }; + }), + }; +} + +export function buildEvidenceReceipt(verification, projection, projectionPath = DEFAULT_PROJECTION) { + const workflowReceiptReferences = verification.artifacts.filter( + (artifact) => artifact.verificationReceipt !== null, + ).length; + const generatedIllustrations = verification.artifacts.filter( + (artifact) => artifact.sourceTruth.representation === "generated-illustration", + ).length; + return { + schemaVersion: RECEIPT_SCHEMA, + collectionId: verification.manifest.collectionId, + manifest: { + path: verification.manifestPath, + digestMode: "canonical-json-sha256", + sha256: verification.manifestSha256, + schemaVersion: MANIFEST_SCHEMA, + }, + projection: { + path: projectionPath, + digestMode: "canonical-json-sha256", + sha256: canonicalJsonSha256(projection), + schemaVersion: EVIDENCE_INDEX_SCHEMA, + changeId: projection.changeId, + }, + checks: { + passed: true, + pathSafety: "passed", + duplicateIds: "none", + duplicatePaths: "none", + artifactsDeclared: verification.artifacts.length, + artifactBytesVerified: verification.artifacts.length, + metadataMatched: verification.artifacts.length, + gitTracked: verification.artifacts.filter((artifact) => artifact.gitTracked).length, + workflowReceiptReferences, + generatedIllustrations, + }, + artifacts: verification.artifacts.map((artifact) => ({ + id: artifact.id, + path: artifact.path, + sha256: artifact.sha256, + bytes: artifact.bytes, + mediaType: artifact.mediaType, + dimensions: artifact.dimensions, + representation: artifact.sourceTruth.representation, + captureEnvironment: artifact.sourceTruth.captureEnvironment, + artifactVerification: artifact.sourceTruth.artifactVerification, + workflowVerification: artifact.sourceTruth.workflowVerification, + basis: artifact.basis, + metadataMatched: artifact.metadataMatched, + })), + claimBoundary: { + releaseReady: false, + productWorkflowProof: "not-certified", + statement: "This receipt verifies checked-input containment, Git tracking, media signatures, dimensions, byte sizes, digests, and the shape of declared provenance and receipt references. Supporting-file basis hashes are byte guards, not capture-time certificates. Receipt references do not certify outcomes, target bindings, freshness, judge quality, pixels, or release readiness. This is a trusted local single-writer check, not a filesystem sandbox.", + }, + }; +} + +function resolveProofFile(rootReal, relativePath, { write = false } = {}) { + const safe = safeRelativePath(relativePath, "proof output path"); + const lexical = resolve(rootReal, safe); + if (!isContained(rootReal, lexical)) fail("proof output path escapes the repository root"); + const parent = dirname(lexical); + try { + lstatSync(parent); + } catch (error) { + if (error.code !== "ENOENT" || !write || dirname(parent) !== rootReal) throw error; + mkdirSync(parent); + } + if (lstatSync(parent).isSymbolicLink() || !statSync(parent).isDirectory() + || !isContained(rootReal, realpathSync(parent))) { + fail("proof output parent must be an owned nonredirected directory"); + } + try { + lstatSync(lexical); + } catch (error) { + if (error.code === "ENOENT" && write) return lexical; + throw error; + } + return resolveExistingContainedFile(rootReal, safe, "proof output path").real; +} + +function writeAtomic(path, contents) { + const temporary = resolve(dirname(path), `.${basename(path)}.${process.pid}.tmp`); + writeFileSync(temporary, contents, { flag: "wx" }); + try { + renameSync(temporary, path); + } finally { + rmSync(temporary, { force: true }); + } +} + +function compareCommitted(path, expected, label) { + let actual; + try { + actual = JSON.parse(readFileSync(path, "utf8")); + } catch { + fail(`${label} is missing or invalid JSON; run npm run evidence:project`); + } + if (canonicalJson(actual) !== canonicalJson(expected)) fail(`${label} is stale; run npm run evidence:project`); +} + +function parseArgs(argv) { + const result = { command: argv[0] ?? "check", write: false, manifest: DEFAULT_MANIFEST }; + for (let index = 1; index < argv.length; index += 1) { + const arg = argv[index]; + if (arg === "--write") result.write = true; + else if (arg === "--manifest") result.manifest = argv[++index]; + else fail(`unknown argument ${arg}`); + } + return result; +} + +function runCli() { + const args = parseArgs(process.argv.slice(2)); + const root = process.cwd(); + if (args.command === "doctor") { + const major = Number.parseInt(process.versions.node.split(".")[0], 10); + if (major < 20) fail("Node 20 or newer is required"); + const verification = verifyEvidenceManifest(root, args.manifest); + process.stdout.write(prettyJson({ + schemaVersion: "featureclip.evidence-doctor/v1", + passed: true, + node: process.versions.node, + manifest: verification.manifestPath, + artifacts: verification.artifacts.length, + networkRequired: false, + modelKeyRequired: false, + })); + return; + } + + const verification = verifyEvidenceManifest(root, args.manifest); + const projection = buildNodeKitEvidenceIndex(verification); + const receipt = buildEvidenceReceipt(verification, projection); + if (args.command === "check") { + process.stdout.write(prettyJson({ + schemaVersion: "featureclip.evidence-check/v1", + passed: true, + collectionId: verification.manifest.collectionId, + artifacts: verification.artifacts.length, + manifestSha256: verification.manifestSha256, + })); + return; + } + if (args.command === "project") { + if (!args.write) fail("project requires --write so output changes are explicit"); + const [projectionPath, receiptPath] = [DEFAULT_PROJECTION, DEFAULT_RECEIPT] + .map((path) => resolveProofFile(verification.root, path, { write: true })); + writeAtomic(projectionPath, prettyJson(projection)); + writeAtomic(receiptPath, prettyJson(receipt)); + process.stdout.write(prettyJson({ + schemaVersion: "featureclip.evidence-project/v1", + passed: true, + projection: DEFAULT_PROJECTION, + receipt: DEFAULT_RECEIPT, + evidence: projection.evidence.length, + })); + return; + } + if (args.command === "proof") { + const [projectionPath, receiptPath] = [DEFAULT_PROJECTION, DEFAULT_RECEIPT] + .map((path) => resolveProofFile(verification.root, path)); + compareCommitted(projectionPath, projection, "NodeKit Evidence Index projection"); + compareCommitted(receiptPath, receipt, "FeatureClip evidence receipt"); + process.stdout.write(prettyJson({ + schemaVersion: "featureclip.evidence-proof/v1", + passed: true, + releaseReady: false, + artifacts: verification.artifacts.length, + workflowReceiptReferences: receipt.checks.workflowReceiptReferences, + boundary: receipt.claimBoundary.statement, + })); + return; + } + fail(`unknown command ${args.command}`); +} + +const invokedPath = process.argv[1] ? resolve(process.argv[1]) : ""; +if (invokedPath === fileURLToPath(import.meta.url)) { + try { + runCli(); + } catch (error) { + process.stderr.write(`${error.name ?? "Error"}: ${error.message}\n`); + process.exitCode = 1; + } +} diff --git a/test/featureclip-evidence.test.mjs b/test/featureclip-evidence.test.mjs new file mode 100644 index 0000000..48c846c --- /dev/null +++ b/test/featureclip-evidence.test.mjs @@ -0,0 +1,421 @@ +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { + mkdirSync, + mkdtempSync, + readFileSync, + readdirSync, + realpathSync, + rmSync, + symlinkSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { basename, dirname, isAbsolute, join, relative, sep } from "node:path"; +import { execFile, spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import test from "node:test"; + +import { + EvidenceError, + buildEvidenceReceipt, + buildNodeKitEvidenceIndex, + verifyEvidenceManifest, +} from "../scripts/featureclip-evidence.mjs"; + +function sha256(buffer) { + return createHash("sha256").update(buffer).digest("hex"); +} + +function png(width = 2, height = 3) { + const bytes = Buffer.alloc(24); + Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]).copy(bytes, 0); + bytes.writeUInt32BE(width, 16); + bytes.writeUInt32BE(height, 20); + return bytes; +} + +function runGit(root, args) { + const result = spawnSync("git", args, { cwd: root, encoding: "utf8", windowsHide: true }); + if (result.status !== 0) throw new Error(result.stderr || result.stdout || `git ${args.join(" ")} failed`); +} + +function writeJson(path, value) { + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, `${JSON.stringify(value, null, 2)}\n`); +} + +function makeRepo(t) { + const root = mkdtempSync(join(tmpdir(), "featureclip-evidence-")); + t.after(() => removeOwnedFixture(root, "featureclip-evidence-")); + const bytes = png(); + mkdirSync(join(root, "assets"), { recursive: true }); + mkdirSync(join(root, "evidence"), { recursive: true }); + writeFileSync(join(root, "assets", "frame.png"), bytes); + writeFileSync(join(root, "README.md"), "# fixture\n"); + const manifest = { + schemaVersion: "featureclip.evidence-manifest/v1", + repository: "HomenShum/FeatureClipStudio", + collectionId: "fixture", + nodekitChangeId: "change-fixture", + requireGitTracked: true, + artifacts: [ + { + id: "frame", + title: "Fixture frame", + artifactKind: "screenshot", + path: "assets/frame.png", + mediaType: "image/png", + sha256: sha256(bytes), + bytes: bytes.length, + dimensions: { width: 2, height: 3 }, + sourceTruth: { + representation: "captured-product-ui", + captureEnvironment: "local-fixture", + artifactVerification: "repository-bytes", + workflowVerification: "unverified", + verificationReceipt: null, + }, + basis: [{ + path: "README.md", + digestMode: "normalized-text-sha256", + sha256: sha256(Buffer.from("# fixture\n")), + }], + limitations: ["Fixture-only evidence."], + }, + ], + }; + writeJson(join(root, "evidence", "nodekit-present.manifest.json"), manifest); + runGit(root, ["init", "--quiet"]); + runGit(root, ["add", "README.md", "assets/frame.png", "evidence/nodekit-present.manifest.json"]); + return { root, manifest, bytes }; +} + +function removeOwnedFixture(root, prefix) { + const delta = relative(realpathSync(tmpdir()), realpathSync(root)); + assert.ok(delta && delta !== ".." && !delta.startsWith(`..${sep}`) && !isAbsolute(delta)); + assert.ok(basename(root).startsWith(prefix)); + rmSync(root, { recursive: true, force: true }); +} + +function persistManifest(root, manifest) { + writeJson(join(root, "evidence", "nodekit-present.manifest.json"), manifest); +} + +test("verifies tracked bytes and projects an honest observed evidence record", (t) => { + const { root } = makeRepo(t); + const verified = verifyEvidenceManifest(root); + const projected = buildNodeKitEvidenceIndex(verified); + const receipt = buildEvidenceReceipt(verified, projected); + + assert.equal(verified.artifacts.length, 1); + assert.equal(projected.schemaVersion, "nodekit.evidence-index/v1"); + assert.equal(projected.evidence[0].status, "observed"); + assert.match(projected.evidence[0].summary, /no workflow receipt reference/); + assert.equal(receipt.checks.passed, true); + assert.equal(receipt.checks.workflowReceiptReferences, 0); + assert.equal(Object.hasOwn(receipt.checks, "independentWorkflowReceipts"), false); + assert.equal(receipt.claimBoundary.releaseReady, false); + assert.equal(receipt.claimBoundary.productWorkflowProof, "not-certified"); +}); + +test("fails closed when a declared artifact is missing", (t) => { + const { root, manifest } = makeRepo(t); + manifest.artifacts[0].path = "assets/missing.png"; + persistManifest(root, manifest); + assert.throws(() => verifyEvidenceManifest(root), /does not exist/); +}); + +test("fails closed when an artifact exists but is not Git-tracked", (t) => { + const { root, manifest } = makeRepo(t); + const bytes = png(5, 6); + writeFileSync(join(root, "assets", "untracked.png"), bytes); + Object.assign(manifest.artifacts[0], { + path: "assets/untracked.png", + sha256: sha256(bytes), + bytes: bytes.length, + dimensions: { width: 5, height: 6 }, + }); + persistManifest(root, manifest); + assert.throws(() => verifyEvidenceManifest(root), /is not tracked by Git/); +}); + +test("fails closed on repository path traversal", (t) => { + const { root, manifest } = makeRepo(t); + manifest.artifacts[0].path = "../outside.png"; + persistManifest(root, manifest); + assert.throws(() => verifyEvidenceManifest(root), /unsafe path segment/); +}); + +test("fails closed on digest drift", (t) => { + const { root } = makeRepo(t); + writeFileSync(join(root, "assets", "frame.png"), png(4, 4)); + assert.throws(() => verifyEvidenceManifest(root), /digest drift/); +}); + +test("fails closed on declared metadata drift", (t) => { + const { root, manifest } = makeRepo(t); + manifest.artifacts[0].dimensions.width = 99; + persistManifest(root, manifest); + assert.throws(() => verifyEvidenceManifest(root), /dimension drift/); +}); + +test("fails closed on provenance-basis drift", (t) => { + const { root } = makeRepo(t); + writeFileSync(join(root, "README.md"), "# changed fixture\n"); + assert.throws(() => verifyEvidenceManifest(root), /basis\[0\] digest drift/); +}); + +test("rejects uppercase ids at the grammar boundary and valid duplicate ids", (t) => { + const { root, manifest } = makeRepo(t); + manifest.artifacts.push({ ...structuredClone(manifest.artifacts[0]), id: "FRAME" }); + persistManifest(root, manifest); + assert.throws(() => verifyEvidenceManifest(root), /must be kebab-case/); + manifest.artifacts[1].id = "frame"; + persistManifest(root, manifest); + assert.throws(() => verifyEvidenceManifest(root), /duplicate artifact id/); +}); + +test("fails closed on duplicate artifact paths", (t) => { + const { root, manifest } = makeRepo(t); + manifest.artifacts.push({ ...structuredClone(manifest.artifacts[0]), id: "other-frame" }); + persistManifest(root, manifest); + assert.throws(() => verifyEvidenceManifest(root), /duplicate artifact path/); +}); + +test("fails closed when generated media is labeled like a real capture", (t) => { + const { root, manifest } = makeRepo(t); + manifest.artifacts[0].sourceTruth.representation = "generated-illustration"; + manifest.artifacts[0].sourceTruth.captureEnvironment = "deployed-application"; + persistManifest(root, manifest); + assert.throws(() => verifyEvidenceManifest(root), /generated illustrations must use captureEnvironment=unknown/); +}); + +test("fails closed when an unverified artifact attaches a receipt", (t) => { + const { root, manifest } = makeRepo(t); + manifest.artifacts[0].sourceTruth.verificationReceipt = { + path: "proof/fake.json", + sha256: "0".repeat(64), + schemaVersion: "fake/v1", + }; + persistManifest(root, manifest); + assert.throws(() => verifyEvidenceManifest(root), /cannot attach a verification receipt/); +}); + +test("a deck author receives observed status for schema-only, failed and unbound receipt references", (t) => { + const { root, manifest } = makeRepo(t); + mkdirSync(join(root, "proof"), { recursive: true }); + for (const body of [ + { schemaVersion: "browser.receipt/v1" }, + { schemaVersion: "browser.receipt/v1", passed: false }, + { schemaVersion: "browser.receipt/v1", passed: true, artifactSha256: "f".repeat(64) }, + ]) { + const receipt = Buffer.from(`${JSON.stringify(body)}\n`); + writeFileSync(join(root, "proof", "browser.json"), receipt); + runGit(root, ["add", "proof/browser.json"]); + manifest.artifacts[0].sourceTruth.workflowVerification = "browser-receipt"; + manifest.artifacts[0].sourceTruth.verificationReceipt = { + path: "proof/browser.json", sha256: sha256(receipt), schemaVersion: "browser.receipt/v1", + }; + persistManifest(root, manifest); + const verified = verifyEvidenceManifest(root); + const projection = buildNodeKitEvidenceIndex(verified); + const result = buildEvidenceReceipt(verified, projection); + assert.equal(projection.evidence[0].status, "observed"); + assert.match(projection.evidence[0].summary, /reference contents do not certify a workflow outcome/); + assert.equal(result.checks.workflowReceiptReferences, 1); + assert.equal(result.claimBoundary.productWorkflowProof, "not-certified"); + assert.equal(result.claimBoundary.releaseReady, false); + t.diagnostic(JSON.stringify({ receiptBody: body, actualStatus: projection.evidence[0].status, references: 1, productWorkflowProof: result.claimBoundary.productWorkflowProof })); + } +}); + +test("fails closed on a symlink escape when the platform permits creating one", (t) => { + const { root, manifest } = makeRepo(t); + const outside = mkdtempSync(join(tmpdir(), "featureclip-outside-")); + t.after(() => removeOwnedFixture(outside, "featureclip-outside-")); + writeFileSync(join(outside, "frame.png"), png()); + try { + symlinkSync(join(outside, "frame.png"), join(root, "assets", "linked.png"), "file"); + } catch (error) { + if (error.code === "EPERM" || error.code === "EACCES") { + t.skip("filesystem does not permit symlink creation"); + return; + } + throw error; + } + runGit(root, ["add", "assets/linked.png"]); + manifest.artifacts[0].path = "assets/linked.png"; + persistManifest(root, manifest); + assert.throws(() => verifyEvidenceManifest(root), EvidenceError); + assert.throws(() => verifyEvidenceManifest(root), /outside the repository root|must not be a symbolic link/); +}); + +const adapterPath = fileURLToPath(new URL("../scripts/featureclip-evidence.mjs", import.meta.url)); +const proofPaths = ["proof/nodekit-present.evidence-index.json", "proof/featureclip-evidence.receipt.json"]; + +function runCli(root, args) { + return spawnSync(process.execPath, [adapterPath, ...args], { + cwd: root, encoding: "utf8", windowsHide: true, timeout: 15_000, maxBuffer: 1024 * 1024, + }); +} + +function outputBytes(root) { + return proofPaths.map((path) => readFileSync(join(root, path))); +} + +test("a publisher explicitly projects files and stale proof never rewrites them", (t) => { + const { root } = makeRepo(t); + const implicit = runCli(root, ["project"]); + assert.equal(implicit.status, 1); + assert.match(implicit.stderr, /project requires --write/); + const missing = runCli(root, ["proof"]); + assert.equal(missing.status, 1); + assert.equal(runCli(root, ["project", "--write"]).status, 0); + const original = outputBytes(root); + assert.equal(runCli(root, ["proof"]).status, 0); + assert.deepEqual(outputBytes(root), original); + + const projection = JSON.parse(original[0]); + projection.evidence[0].status = "verified"; + writeJson(join(root, proofPaths[0]), projection); + const staleProjection = outputBytes(root); + const rejectedProjection = runCli(root, ["proof"]); + assert.equal(rejectedProjection.status, 1); + assert.match(rejectedProjection.stderr, /projection is stale/); + assert.deepEqual(outputBytes(root), staleProjection); + writeFileSync(join(root, proofPaths[0]), original[0]); + + const receipt = JSON.parse(original[1]); + receipt.checks.workflowReceiptReferences = 99; + writeJson(join(root, proofPaths[1]), receipt); + const staleReceipt = outputBytes(root); + const rejectedReceipt = runCli(root, ["proof"]); + assert.equal(rejectedReceipt.status, 1); + assert.match(rejectedReceipt.stderr, /receipt is stale/); + assert.deepEqual(outputBytes(root), staleReceipt); + t.diagnostic(JSON.stringify({ implicitProject: implicit.status, missingProof: missing.status, + staleProjection: rejectedProjection.status, staleReceipt: rejectedReceipt.status, proofRewrites: false })); +}); + +test("a publisher's shape, size, extension and signature mistakes are rejected", (t) => { + for (const kind of ["shape", "size", "extension", "signature"]) { + const { root, manifest } = makeRepo(t); + if (kind === "shape") manifest.extra = true; + if (kind === "size") manifest.artifacts[0].bytes += 1; + if (kind === "extension") manifest.artifacts[0].mediaType = "image/gif"; + if (kind === "signature") { + const badBytes = Buffer.alloc(24); + writeFileSync(join(root, "assets/frame.png"), badBytes); + manifest.artifacts[0].sha256 = sha256(badBytes); + } + persistManifest(root, manifest); + const message = { shape: /fields are invalid/, size: /byte-size drift/, + extension: /mediaType does not match/, signature: /not a valid PNG signature/ }[kind]; + assert.throws(() => verifyEvidenceManifest(root), message); + const result = runCli(root, ["check"]); + assert.equal(result.status, 1); + t.diagnostic(JSON.stringify({ negative: kind, exitCode: result.status, error: result.stderr.trim() })); + } +}); + +test("a publisher cannot use missing, untracked, drifted or malformed receipt references", (t) => { + for (const kind of ["missing", "untracked", "digest", "schema", "json"]) { + const { root, manifest } = makeRepo(t); + const body = Buffer.from(kind === "json" ? "not JSON\n" : '{"schemaVersion":"browser.receipt/v1"}\n'); + mkdirSync(join(root, "proof")); + if (kind !== "missing") writeFileSync(join(root, "proof/browser.json"), body); + if (kind !== "missing" && kind !== "untracked") runGit(root, ["add", "proof/browser.json"]); + manifest.artifacts[0].sourceTruth.workflowVerification = "browser-receipt"; + manifest.artifacts[0].sourceTruth.verificationReceipt = { + path: "proof/browser.json", sha256: kind === "digest" ? "0".repeat(64) : sha256(body), + schemaVersion: kind === "schema" ? "other/v1" : "browser.receipt/v1", + }; + persistManifest(root, manifest); + const result = runCli(root, ["check"]); + assert.equal(result.status, 1); + assert.match(result.stderr, { missing: /does not exist/, untracked: /not tracked/, + digest: /receipt digest drift/, schema: /receipt schema drift/, json: /receipt is not valid JSON/ }[kind]); + t.diagnostic(JSON.stringify({ negative: `receipt-${kind}`, exitCode: result.status, error: result.stderr.trim() })); + } +}); + +test("a redirected proof directory is rejected for writing and reading with foreign files unchanged", (t) => { + const { root } = makeRepo(t); + const outside = mkdtempSync(join(tmpdir(), "featureclip-outside-")); + t.after(() => removeOwnedFixture(outside, "featureclip-outside-")); + for (const path of proofPaths) writeFileSync(join(outside, basename(path)), '{"foreign":true}\n'); + const original = proofPaths.map((path) => readFileSync(join(outside, basename(path)))); + try { + symlinkSync(outside, join(root, "proof"), process.platform === "win32" ? "junction" : "dir"); + } catch (error) { + if (error.code === "EPERM" || error.code === "EACCES") return t.skip("filesystem does not permit directory links"); + throw error; + } + for (const args of [["project", "--write"], ["proof"]]) { + const result = runCli(root, args); + assert.equal(result.status, 1); + assert.match(result.stderr, /proof output parent must be an owned nonredirected directory/); + assert.deepEqual(proofPaths.map((path) => readFileSync(join(outside, basename(path)))), original); + assert.deepEqual(readdirSync(outside).sort(), proofPaths.map((path) => basename(path)).sort()); + t.diagnostic(JSON.stringify({ redirectedCommand: args[0], exitCode: result.status, foreignFilesChanged: false })); + } +}); + +test("an existing proof-file link cannot be overwritten or read as owned output", (t) => { + const { root } = makeRepo(t); + const outside = mkdtempSync(join(tmpdir(), "featureclip-outside-")); + t.after(() => removeOwnedFixture(outside, "featureclip-outside-")); + const sentinel = join(outside, "foreign.json"); + const original = Buffer.from('{"foreign":true}\n'); + writeFileSync(sentinel, original); + mkdirSync(join(root, "proof")); + try { + symlinkSync(sentinel, join(root, proofPaths[0]), "file"); + } catch (error) { + if (error.code === "EPERM" || error.code === "EACCES") return t.skip("filesystem does not permit file links"); + throw error; + } + for (const args of [["project", "--write"], ["proof"]]) { + const result = runCli(root, args); + assert.equal(result.status, 1); + assert.match(result.stderr, /outside the repository root|must not be a symbolic link/); + assert.deepEqual(readFileSync(sentinel), original); + assert.deepEqual(readdirSync(join(root, "proof")), [basename(proofPaths[0])]); + t.diagnostic(JSON.stringify({ linkedOutputCommand: args[0], exitCode: result.status, foreignFileChanged: false })); + } +}); + +test("eight repeated handoffs remain deterministic across JSON key order and text newlines", (t) => { + const { root, manifest } = makeRepo(t); + assert.equal(runCli(root, ["project", "--write"]).status, 0); + const original = outputBytes(root); + for (let round = 0; round < 8; round += 1) { + writeFileSync(join(root, "README.md"), round % 2 ? "# fixture\r\n" : "# fixture\n"); + const ordered = Object.fromEntries(Object.entries(manifest).reverse()); + ordered.artifacts = manifest.artifacts.map((artifact) => Object.fromEntries(Object.entries(artifact).reverse())); + persistManifest(root, ordered); + assert.equal(runCli(root, ["project", "--write"]).status, 0); + assert.deepEqual(outputBytes(root), original); + assert.equal(runCli(root, ["proof"]).status, 0); + assert.deepEqual(outputBytes(root), original); + assert.deepEqual(readdirSync(join(root, "proof")).sort(), proofPaths.map((path) => basename(path)).sort()); + } + t.diagnostic(JSON.stringify({ repeatedRounds: 8, deterministicBytes: true, accumulatedTemporaryFiles: 0 })); +}); + +test("two publishers can project concurrently in disjoint owned fixture repositories", async (t) => { + const repos = [makeRepo(t), makeRepo(t)]; + const results = await Promise.all(repos.map(({ root }) => new Promise((resolve) => { + execFile(process.execPath, [adapterPath, "project", "--write"], { + cwd: root, encoding: "utf8", windowsHide: true, timeout: 15_000, maxBuffer: 1024 * 1024, + }, (error, stdout, stderr) => resolve({ error, stdout, stderr })); + }))); + for (const result of results) { + assert.equal(result.error, null, result.stderr); + assert.equal(JSON.parse(result.stdout).evidence, 1); + } + assert.deepEqual(outputBytes(repos[0].root), outputBytes(repos[1].root)); + for (const { root } of repos) assert.equal(runCli(root, ["proof"]).status, 0); + t.diagnostic(JSON.stringify({ simultaneousJobs: 2, disjointRepositories: true, sameOutputConcurrencyCertified: false })); +});