diff --git a/README.md b/README.md index 91ef74f..93923a3 100644 --- a/README.md +++ b/README.md @@ -77,6 +77,23 @@ npm run render:example # -> out/example.mp4 ffmpeg -y -i out/example.mp4 -vf "fps=15,scale=720:-1:flags=lanczos,split[s0][s1];[s0]palettegen=max_colors=128:stats_mode=diff[p];[s1][p]paletteuse=dither=bayer:bayer_scale=3:diff_mode=rectangle" -loop 0 example.gif ``` +## NodeKit Present evidence + +FeatureClipStudio can project selected, content-addressed clips and screenshots into a +`nodekit.evidence-index/v1` Change Story input without pretending that a checked-in +media file is fresh production-browser proof. + +```bash +npm run evidence:check +npm run proof +``` + +The verifier checks repository containment, Git tracking, media signatures, +dimensions, byte sizes, digests, duplicate IDs/paths, and explicit captured-versus- +generated provenance. The committed P1 assets project as `observed`, not `verified`, +because this repository does not currently commit per-clip judge or browser receipts. +See [`docs/NODEKIT_PRESENT_EVIDENCE.md`](docs/NODEKIT_PRESENT_EVIDENCE.md). + The bundled example is a [solo-founder 3D proof-run app](https://github.com/HomenShum/solo-founder-agent-builder) (builder console → generate a scroll-driven 3D product story → customer-facing landing page → internal proof report with gates) — the captured frames + diff --git a/docs/NODEKIT_PRESENT_EVIDENCE.md b/docs/NODEKIT_PRESENT_EVIDENCE.md new file mode 100644 index 0000000..795bf5f --- /dev/null +++ b/docs/NODEKIT_PRESENT_EVIDENCE.md @@ -0,0 +1,67 @@ +# NodeKit Present evidence bridge + +FeatureClipStudio is a presentation-evidence tool, not a product-agent runtime. Its +NodeKit integration exposes checked-in clips and screenshots to a Change Story or +NodeSlide deck without upgrading a media file into a stronger proof claim than the +repository can support. + +## Contract + +`evidence/nodekit-present.manifest.json` is the authored manifest. Every entry must +declare: + +- an existing Git-tracked GIF or PNG; +- its SHA-256 digest, byte size, MIME signature, and dimensions; +- whether it is captured UI, rendered capture, or generated illustration; +- whether the capture came from a deployed application, local fixture, or is unknown; +- whether a separate judge/browser receipt is committed; and +- content-addressed source files and limitations that explain the provenance boundary. + +The verifier fails closed on missing or untracked files, absolute/traversing paths, +symlink escapes, duplicate IDs or paths, media-signature mismatch, digest/size/dimension +drift, provenance-basis drift, stale receipt references, and +generated-versus-captured ambiguity. + +## Commands + +```bash +npm run doctor +npm run evidence:check +npm run evidence:project +npm run proof +npm run check +``` + +`evidence:project` writes two deterministic artifacts: + +- `proof/nodekit-present.evidence-index.json`, consumable by the + `nodekit.evidence-index/v1` Change Story lane; and +- `proof/featureclip-evidence.receipt.json`, which records the exact content and the + claim boundary used for projection. Text-basis digests normalize line endings and + JSON contract digests use canonical key ordering, so the same committed source + verifies on Windows, macOS, and Linux. + +`proof` does not write. It recomputes both outputs and fails if the committed files are +missing or stale. + +## Status semantics + +Repository byte verification is not workflow verification. + +- A checked-in file with matching digest, size, signature, dimensions, and provenance + projects as `observed` when no independent workflow receipt is committed. +- It can project as `verified` only when the manifest references a matching committed + judge or browser receipt with an explicit schema version. +- Generated illustrations can never certify a product workflow. + +The initial P1 manifest deliberately contains zero independent workflow receipts. +FeatureClipStudio documents a runnable Gemini judge, but its existing per-clip judge +JSON is written to ignored `out/` paths and is not committed. The receipt therefore +sets `releaseReady: false` and `productWorkflowProof: not-certified`. + +## Safe consumption + +NodeKit Present or NodeSlide may use the projected assets as supplemental presentation +material. Consumers must retain `status`, the content-addressed location, and the +summary boundary. They must not relabel an `observed` asset as fresh production-browser +proof or claim that its depicted workflow passed the judge. diff --git a/evidence/nodekit-present.manifest.json b/evidence/nodekit-present.manifest.json new file mode 100644 index 0000000..f97d51e --- /dev/null +++ b/evidence/nodekit-present.manifest.json @@ -0,0 +1,129 @@ +{ + "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": "README.md", + "digestMode": "normalized-text-sha256", + "sha256": "b2b9f0f4b1389ea9578c53040bec8941cad574aa2042510d58011ec6ea25f423" + }, + { + "path": "NODE-LOOPS.md", + "digestMode": "normalized-text-sha256", + "sha256": "e7b926d35dea7be0cdeb1592dd2f56b920b2d30ae8a170eda1e81c30375f03e9" + }, + { + "path": "walkthrough.noderoom.specs.mjs", + "digestMode": "normalized-text-sha256", + "sha256": "23a6068b0f83dcd090ad08a4779fcf6278aa78a5182d863b05a4e7b5a56d9426" + } + ], + "limitations": [ + "The repository does not commit a per-clip judge or browser receipt, so this record verifies the media bytes and declared provenance but not the depicted workflow independently." + ] + }, + { + "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": "README.md", + "digestMode": "normalized-text-sha256", + "sha256": "b2b9f0f4b1389ea9578c53040bec8941cad574aa2042510d58011ec6ea25f423" + }, + { + "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." + ] + }, + { + "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": "README.md", + "digestMode": "normalized-text-sha256", + "sha256": "b2b9f0f4b1389ea9578c53040bec8941cad574aa2042510d58011ec6ea25f423" + }, + { + "path": "NODE-LOOPS.md", + "digestMode": "normalized-text-sha256", + "sha256": "e7b926d35dea7be0cdeb1592dd2f56b920b2d30ae8a170eda1e81c30375f03e9" + }, + { + "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." + ] + } + ] +} diff --git a/nodekit.yaml b/nodekit.yaml new file mode 100644 index 0000000..2f6b493 --- /dev/null +++ b/nodekit.yaml @@ -0,0 +1,36 @@ +schemaVersion: nodekit.repo/v1 +repository: HomenShum/FeatureClipStudio +lifecycle: preview +support: active +role: presentation-evidence-tool +commandProfile: protocol + +canonicalFor: + - featureclip.evidence-manifest + - featureclip.evidence-receipt + +consumes: + - nodeplatform.repo-contract + - nodeplatform.change-story + +commands: + doctor: { script: doctor, mode: finite } + check: { script: check, mode: finite } + proof: { script: proof, mode: finite } + +noKey: + status: certified + command: npm run proof + externalAccountsRequired: 0 + disclosure: Evidence verification and NodeKit projection are deterministic local-file operations. Capture, rendering, judging, and deployment are separate opt-in workflows. + +environment: + contractVersion: nodeplatform.env/v1 + status: not-applicable + +proof: + command: npm run proof + receiptSchema: featureclip.evidence-receipt/v1 + +contractDeclarations: [] +architectureExceptions: [] diff --git a/package.json b/package.json index b0252f3..bfa0b17 100644 --- a/package.json +++ b/package.json @@ -26,7 +26,13 @@ "render": "remotion render src/index.js", "render:example": "remotion render src/index.js WT-SoloFounder out/example.mp4 --concurrency=2", "render:roomos": "remotion render src/roomos-index.js WTG-RoomOSV0123 out/room-os-v0-v1-v2-v3.mp4 --concurrency=2", - "judge": "node judge-video.mjs" + "judge": "node judge-video.mjs", + "doctor": "node scripts/featureclip-evidence.mjs doctor", + "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", + "check": "npm run doctor && npm run test:evidence && npm run proof" }, "dependencies": { "@remotion/cli": "^4.0.474", diff --git a/proof/featureclip-evidence.receipt.json b/proof/featureclip-evidence.receipt.json new file mode 100644 index 0000000..13b1aeb --- /dev/null +++ b/proof/featureclip-evidence.receipt.json @@ -0,0 +1,135 @@ +{ + "schemaVersion": "featureclip.evidence-receipt/v1", + "collectionId": "featureclip-nodekit-present-p1", + "manifest": { + "path": "evidence/nodekit-present.manifest.json", + "digestMode": "canonical-json-sha256", + "sha256": "eb5ecc6c35e7c516358db272dddc26131e502c696d9edfa1e8a9287577c4e7f3", + "schemaVersion": "featureclip.evidence-manifest/v1" + }, + "projection": { + "path": "proof/nodekit-present.evidence-index.json", + "digestMode": "canonical-json-sha256", + "sha256": "a0f56026987e1e3802a39f4736afa5aac7560b497dcee67416c0ed5cb492ab0b", + "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, + "independentWorkflowReceipts": 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": "README.md", + "digestMode": "normalized-text-sha256", + "sha256": "b2b9f0f4b1389ea9578c53040bec8941cad574aa2042510d58011ec6ea25f423" + }, + { + "path": "NODE-LOOPS.md", + "digestMode": "normalized-text-sha256", + "sha256": "e7b926d35dea7be0cdeb1592dd2f56b920b2d30ae8a170eda1e81c30375f03e9" + }, + { + "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": "README.md", + "digestMode": "normalized-text-sha256", + "sha256": "b2b9f0f4b1389ea9578c53040bec8941cad574aa2042510d58011ec6ea25f423" + }, + { + "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": "README.md", + "digestMode": "normalized-text-sha256", + "sha256": "b2b9f0f4b1389ea9578c53040bec8941cad574aa2042510d58011ec6ea25f423" + }, + { + "path": "NODE-LOOPS.md", + "digestMode": "normalized-text-sha256", + "sha256": "e7b926d35dea7be0cdeb1592dd2f56b920b2d30ae8a170eda1e81c30375f03e9" + }, + { + "path": "walkthrough.solo-founder.specs.mjs", + "digestMode": "normalized-text-sha256", + "sha256": "3725d99e55838e04803af7674e3365f1bd99f62ab168a55e95362f06ea1d8a85" + } + ], + "metadataMatched": true + } + ], + "claimBoundary": { + "releaseReady": false, + "productWorkflowProof": "not-certified", + "statement": "This receipt certifies repository containment, Git tracking, media signatures, dimensions, byte sizes, digests, and explicit source-truth labels. It does not certify production freshness, depicted workflow correctness, judge quality, or release readiness unless an artifact separately references an independent receipt." + } +} diff --git a/proof/nodekit-present.evidence-index.json b/proof/nodekit-present.evidence-index.json new file mode 100644 index 0000000..75a407d --- /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. Source truth is rendered-from-captured-product-ui in deployed-application; no committed independent workflow receipt." + }, + { + "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. Source truth is rendered-from-captured-product-ui in deployed-application; no committed independent workflow receipt." + }, + { + "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. Source truth is captured-product-ui in local-fixture; no committed independent workflow receipt." + } + ] +} diff --git a/schemas/featureclip.evidence-manifest.v1.schema.json b/schemas/featureclip.evidence-manifest.v1.schema.json new file mode 100644 index 0000000..e7501f1 --- /dev/null +++ b/schemas/featureclip.evidence-manifest.v1.schema.json @@ -0,0 +1,122 @@ +{ + "$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": { + "enum": ["unverified", "judge-receipt", "browser-receipt"] + }, + "verificationReceipt": { + "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": { + "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..ce4e50f --- /dev/null +++ b/schemas/featureclip.evidence-receipt.v1.schema.json @@ -0,0 +1,148 @@ +{ + "$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", + "independentWorkflowReceipts", + "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 }, + "independentWorkflowReceipts": { "type": "integer", "minimum": 0 }, + "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": { + "enum": ["unverified", "judge-receipt", "browser-receipt"] + }, + "basis": { + "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": { "enum": ["not-certified", "receipt-referenced"] }, + "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..dfa9bc5 --- /dev/null +++ b/scripts/featureclip-evidence.mjs @@ -0,0 +1,531 @@ +#!/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`); + const bytes = readFileSync(file.real); + const digest = sha256(bytes); + if (digest !== artifact.sha256) fail(`${label} digest drift: expected ${artifact.sha256}, received ${digest}`); + if (file.stat.size !== artifact.bytes) fail(`${label} byte-size drift: expected ${artifact.bytes}, received ${file.stat.size}`); + 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 independentlyVerified = artifact.sourceTruth.workflowVerification !== "unverified"; + const boundary = independentlyVerified + ? `${artifact.sourceTruth.workflowVerification} ${artifact.verificationReceipt.schemaVersion}` + : "no committed independent workflow receipt"; + return { + id: `featureclip-${artifact.id}`, + kind: artifact.artifactKind === "clip" ? "presentation-clip" : "presentation-screenshot", + status: independentlyVerified ? "verified" : "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. Source truth is ${artifact.sourceTruth.representation} in ${artifact.sourceTruth.captureEnvironment}; ${boundary}.`, + }; + }), + }; +} + +export function buildEvidenceReceipt(verification, projection, projectionPath = DEFAULT_PROJECTION) { + const independentWorkflowReceipts = verification.artifacts.filter( + (artifact) => artifact.sourceTruth.workflowVerification !== "unverified", + ).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, + independentWorkflowReceipts, + 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: independentWorkflowReceipts === verification.artifacts.length ? "receipt-referenced" : "not-certified", + statement: "This receipt certifies repository containment, Git tracking, media signatures, dimensions, byte sizes, digests, and explicit source-truth labels. It does not certify production freshness, depicted workflow correctness, judge quality, or release readiness unless an artifact separately references an independent receipt.", + }, + }; +} + +function writeAtomic(path, contents) { + mkdirSync(dirname(path), { recursive: true }); + const temporary = resolve(dirname(path), `.${basename(path)}.${process.pid}.tmp`); + try { + writeFileSync(temporary, contents, { flag: "wx" }); + 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"); + writeAtomic(resolve(root, DEFAULT_PROJECTION), prettyJson(projection)); + writeAtomic(resolve(root, DEFAULT_RECEIPT), 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") { + compareCommitted(resolve(root, DEFAULT_PROJECTION), projection, "NodeKit Evidence Index projection"); + compareCommitted(resolve(root, DEFAULT_RECEIPT), receipt, "FeatureClip evidence receipt"); + process.stdout.write(prettyJson({ + schemaVersion: "featureclip.evidence-proof/v1", + passed: true, + releaseReady: false, + artifacts: verification.artifacts.length, + independentWorkflowReceipts: receipt.checks.independentWorkflowReceipts, + 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..ff46dd7 --- /dev/null +++ b/test/featureclip-evidence.test.mjs @@ -0,0 +1,229 @@ +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { + mkdirSync, + mkdtempSync, + rmSync, + symlinkSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { spawnSync } from "node:child_process"; +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(() => rmSync(root, { recursive: true, force: true })); + 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 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 committed independent workflow receipt/); + assert.equal(receipt.checks.passed, true); + assert.equal(receipt.checks.independentWorkflowReceipts, 0); + 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("fails closed on case-insensitive duplicate ids", (t) => { + const { root, manifest } = makeRepo(t); + manifest.artifacts.push({ ...structuredClone(manifest.artifacts[0]), 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("projects verified only when a matching independent receipt is tracked", (t) => { + const { root, manifest } = makeRepo(t); + const receipt = Buffer.from('{"schemaVersion":"browser.receipt/v1"}\n'); + mkdirSync(join(root, "proof"), { recursive: true }); + 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); + assert.equal(projection.evidence[0].status, "verified"); + assert.match(projection.evidence[0].summary, /browser-receipt browser\.receipt\/v1/); +}); + +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(() => rmSync(outside, { recursive: true, force: true })); + 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/); +});