diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b35f7cdf0..157d42b5b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -148,48 +148,62 @@ jobs: sh "$syft_installer" -v -b "$RUNNER_TEMP/syft-bin" v1.50.0 "$RUNNER_TEMP/syft-bin/syft" version echo "$RUNNER_TEMP/syft-bin" >> "$GITHUB_PATH" - - name: Generate release SBOM + - name: Generate exact-package release SBOMs run: | set -euo pipefail - syft scan dir:. -o spdx-json > release/inkspan.spdx.json - - name: Validate release SBOM + mapfile -t npm_assets < <( + find release -maxdepth 1 -type f -name '*.tgz' -printf '%f\n' | LC_ALL=C sort + ) + mapfile -t wheel_assets < <( + find release -maxdepth 1 -type f -name '*.whl' -printf '%f\n' | LC_ALL=C sort + ) + if [[ ${#npm_assets[@]} -ne 1 || ${#wheel_assets[@]} -ne 1 ]]; then + echo "::error::SBOM generation requires exactly one npm tarball and one Office wheel." + exit 1 + fi + syft scan "release/${npm_assets[0]}" -o spdx-json > release/editor-package.spdx.json + syft scan "release/${wheel_assets[0]}" -o spdx-json > release/office-package.spdx.json + - name: Validate exact-package release SBOMs run: | set -euo pipefail node <<'NODE' const { readFileSync, statSync } = require('node:fs'); - const sbomPath = 'release/inkspan.spdx.json'; - const sbom = JSON.parse(readFileSync(sbomPath, 'utf8')); const packageMetadata = JSON.parse(readFileSync('package.json', 'utf8')); const officeMetadata = readFileSync('office/pyproject.toml', 'utf8'); - if (statSync(sbomPath).size > 16 * 1024 * 1024) { - throw new Error('Release SBOM exceeds the 16 MiB actions/attest input limit.'); - } - if (sbom.spdxVersion !== 'SPDX-2.3') { - throw new Error(`Release SBOM must be SPDX-2.3; found ${sbom.spdxVersion ?? 'missing'}.`); - } - if (!Array.isArray(sbom.packages) || sbom.packages.length === 0) { - throw new Error('Release SBOM package inventory must not be empty.'); - } - const sbomPackageNames = new Set(sbom.packages.map((pkg) => pkg.name)); if (packageMetadata.name !== '@contextualwisdomlab/cwl-editor') { throw new Error('Release source has an unexpected editor package identity.'); } if (!/^name\s*=\s*["']inkspan-office["']\s*$/m.test(officeMetadata)) { throw new Error('Release source has an unexpected Office package identity.'); } - if (!sbomPackageNames.has(packageMetadata.name)) { - throw new Error('Release SBOM inventory must include the editor package identity.'); - } - if (!sbomPackageNames.has('inkspan-office')) { - throw new Error('Release SBOM inventory must include the Office package identity.'); + + const expectedSboms = [ + ['release/editor-package.spdx.json', packageMetadata.name], + ['release/office-package.spdx.json', 'inkspan-office'], + ]; + for (const [sbomPath, expectedPackageName] of expectedSboms) { + const sbom = JSON.parse(readFileSync(sbomPath, 'utf8')); + if (statSync(sbomPath).size > 16 * 1024 * 1024) { + throw new Error(`${sbomPath} exceeds the 16 MiB actions/attest input limit.`); + } + if (sbom.spdxVersion !== 'SPDX-2.3') { + throw new Error(`${sbomPath} must be SPDX-2.3; found ${sbom.spdxVersion ?? 'missing'}.`); + } + if (!Array.isArray(sbom.packages) || sbom.packages.length === 0) { + throw new Error(`${sbomPath} package inventory must not be empty.`); + } + const packageNames = new Set(sbom.packages.map((pkg) => pkg.name)); + if (!packageNames.has(expectedPackageName)) { + throw new Error(`${sbomPath} must include exact package identity ${expectedPackageName}.`); + } } NODE - name: Generate release checksums run: | set -euo pipefail cd release - sha256sum -- *.tgz *.whl inkspan.spdx.json > SHA256SUMS + sha256sum -- *.tgz *.whl *.spdx.json > SHA256SUMS - name: Transfer exact release artifacts uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: @@ -204,7 +218,7 @@ jobs: if: github.repository == 'ContextualWisdomLab/inkspan' needs: build-release-artifacts runs-on: ubuntu-24.04 - timeout-minutes: 30 + timeout-minutes: 60 permissions: contents: read env: @@ -301,7 +315,7 @@ jobs: - name: Verify bounded local release artifact set run: | set -euo pipefail - expected_asset_count=4 + expected_asset_count=5 mapfile -t local_entries < <( find release -mindepth 1 -maxdepth 1 -printf '%f\n' | LC_ALL=C sort ) @@ -318,9 +332,10 @@ jobs: || ${#local_assets[@]} -ne $expected_asset_count \ || ${#npm_assets[@]} -ne 1 \ || ${#wheel_assets[@]} -ne 1 \ - || ! -f release/inkspan.spdx.json \ + || ! -f release/editor-package.spdx.json \ + || ! -f release/office-package.spdx.json \ || ! -f release/SHA256SUMS ]]; then - echo "::error::Unexpected local release artifact set; require exactly one *.tgz, one *.whl, inkspan.spdx.json, and SHA256SUMS." + echo "::error::Unexpected local release artifact set; require exactly one *.tgz, one *.whl, editor-package.spdx.json, office-package.spdx.json, and SHA256SUMS." exit 1 fi - name: Attest release artifacts @@ -329,21 +344,25 @@ jobs: subject-path: | release/*.tgz release/*.whl - release/inkspan.spdx.json + release/editor-package.spdx.json + release/office-package.spdx.json release/SHA256SUMS - - name: Attest release packages with SBOM + - name: Attest editor package with matching SBOM uses: actions/attest@59d89421af93a897026c735860bf21b6eb4f7b26 # v4.1.0 with: - subject-path: | - release/*.tgz - release/*.whl - sbom-path: release/inkspan.spdx.json + subject-path: release/*.tgz + sbom-path: release/editor-package.spdx.json + - name: Attest Office package with matching SBOM + uses: actions/attest@59d89421af93a897026c735860bf21b6eb4f7b26 # v4.1.0 + with: + subject-path: release/*.whl + sbom-path: release/office-package.spdx.json - name: Verify generated attestations env: GH_TOKEN: ${{ github.token }} run: | set -euo pipefail - for artifact in release/*.tgz release/*.whl release/inkspan.spdx.json release/SHA256SUMS; do + for artifact in release/*.tgz release/*.whl release/editor-package.spdx.json release/office-package.spdx.json release/SHA256SUMS; do gh attestation verify "$artifact" --repo "$GITHUB_REPOSITORY" done for artifact in release/*.tgz release/*.whl; do @@ -382,7 +401,7 @@ jobs: GH_TOKEN: ${{ github.token }} run: | set -euo pipefail - expected_asset_count=4 + expected_asset_count=5 mapfile -t local_entries < <( find release -mindepth 1 -maxdepth 1 -printf '%f\n' | LC_ALL=C sort ) @@ -399,9 +418,10 @@ jobs: || ${#local_assets[@]} -ne $expected_asset_count \ || ${#npm_assets[@]} -ne 1 \ || ${#wheel_assets[@]} -ne 1 \ - || ! -f release/inkspan.spdx.json \ + || ! -f release/editor-package.spdx.json \ + || ! -f release/office-package.spdx.json \ || ! -f release/SHA256SUMS ]]; then - echo "::error::Unexpected local release artifact set; require exactly one *.tgz, one *.whl, inkspan.spdx.json, and SHA256SUMS." + echo "::error::Unexpected local release artifact set; require exactly one *.tgz, one *.whl, editor-package.spdx.json, office-package.spdx.json, and SHA256SUMS." exit 1 fi @@ -477,6 +497,7 @@ jobs: gh release edit "$GITHUB_REF_NAME" \ --repo "$GITHUB_REPOSITORY" \ --draft=false + release_immutable="$(gh release view "$GITHUB_REF_NAME" \ --repo "$GITHUB_REPOSITORY" \ --json isImmutable \ @@ -492,7 +513,7 @@ jobs: fi gh release verify "$GITHUB_REF_NAME" --repo "$GITHUB_REPOSITORY" - for artifact in release/*.tgz release/*.whl release/inkspan.spdx.json release/SHA256SUMS; do + for artifact in release/*.tgz release/*.whl release/editor-package.spdx.json release/office-package.spdx.json release/SHA256SUMS; do gh release verify-asset "$GITHUB_REF_NAME" "$artifact" \ --repo "$GITHUB_REPOSITORY" done diff --git a/.github/workflows/writing-diagnostics-assurance-tdd.yml b/.github/workflows/writing-diagnostics-assurance-tdd.yml new file mode 100644 index 000000000..446b6c047 --- /dev/null +++ b/.github/workflows/writing-diagnostics-assurance-tdd.yml @@ -0,0 +1,90 @@ +name: Writing Diagnostics Assurance TDD + +on: + push: + branches: + - feat/writing-diagnostics-assurance + pull_request: + paths: + - 'src/components/**' + - 'src/writingDiagnosticsCanonicalDocumentation.test.ts' + - 'src/workflowExactHead.test.ts' + - 'src/releaseArtifactSbomContract.test.ts' + - 'tests/browser/**' + - 'docs/WRITING_DIAGNOSTICS.md' + - 'docs/README.md' + - '.github/workflows/release.yml' + - '.github/workflows/writing-diagnostics-assurance-tdd.yml' + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: writing-diagnostics-assurance-tdd-${{ github.ref }} + cancel-in-progress: true + +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + +jobs: + unit-assurance: + runs-on: ubuntu-24.04 + timeout-minutes: 20 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.event.pull_request.head.sha || github.sha }} + persist-credentials: false + - uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 22 + cache: pnpm + - run: pnpm install --frozen-lockfile + - name: Run hostile-input, no-fallback, focus, documentation, release-provenance, and workflow assurance + run: >- + pnpm exec vitest run + src/components/writingDiagnosticsSecurity.test.tsx + src/components/writingDiagnosticsFocus.test.tsx + src/components/WritingDiagnosticsPanel.test.tsx + src/writingDiagnosticsCanonicalDocumentation.test.ts + src/releaseArtifactSbomContract.test.ts + src/workflowExactHead.test.ts + --pool=forks + --maxWorkers=1 + - name: Typecheck assurance changes + run: pnpm typecheck + + browser-assurance: + name: Writing diagnostics / Playwright 1.62.0 + runs-on: ubuntu-24.04 + timeout-minutes: 30 + env: + PLAYWRIGHT_BROWSERS_PATH: /tmp/inkspan-playwright-browsers + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.event.pull_request.head.sha || github.sha }} + persist-credentials: false + - uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 22 + cache: pnpm + - run: pnpm install --frozen-lockfile + - run: pnpm --dir tests/browser install --frozen-lockfile + - name: Restore exact Playwright browser revisions + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: /tmp/inkspan-playwright-browsers + key: ${{ runner.os }}-playwright-${{ runner.arch }}-${{ hashFiles('tests/browser/pnpm-lock.yaml') }} + - name: Install Playwright revisions pinned by the browser-test lock + run: pnpm --dir tests/browser exec playwright install --with-deps chromium firefox webkit + - name: Run writing-diagnostic browser assurance on exact head + env: + INKSPAN_EXPECTED_HEAD_SHA: ${{ github.event.pull_request.head.sha || github.sha }} + run: >- + pnpm --dir tests/browser exec playwright test + specs/writing-diagnostics.browser.spec.ts + --config playwright.config.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index d581422dc..0758da6b3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,7 +26,7 @@ Historical release entries from **0.1.0 through 0.5.27** are preserved verbatim - Added the selected standalone Markdown or HTML value to an explicitly configured SSR native form field, preserving controlled-value precedence, external form association, React attribute escaping, and the synchronous post-hydration TipTap transaction mirror ### Security -- Added a fail-closed draft release asset inventory gate that requires exactly one npm tarball, one Office wheel, `inkspan.spdx.json`, and `SHA256SUMS`, rejects stale or unexpected draft assets before immutable publication, and verifies every GitHub-reported `sha256:` asset digest against the transferred local file +- Added a fail-closed draft release asset inventory gate that requires exactly one npm tarball, one Office wheel, matching editor and Office package SBOMs, and `SHA256SUMS`, rejects stale or unexpected draft assets before immutable publication, and verifies every GitHub-reported `sha256:` asset digest against the transferred local file - Kept SSR document disclosure opt-in through `formFieldName`; hidden-field values remain client-controlled submission data and do not replace host authentication, authorization, tenant isolation, CSRF defenses, server validation, durable concurrency, or persistence controls - Kept collaborative Yjs document content out of server markup until the host-owned client collaboration lifecycle is bound - Added packed headless Markdown authority verification that rejects external runtime imports, dynamic module loaders, ambient network/environment credential access, React/TipTap/Yjs runtime coupling, CWL host coupling, and model credential references from the dedicated conversion artifact diff --git a/docs/CONTRACTS.md b/docs/CONTRACTS.md index 5a62716e5..e072a1d12 100644 --- a/docs/CONTRACTS.md +++ b/docs/CONTRACTS.md @@ -138,7 +138,7 @@ Expected degraded states are explicit rather than mapped to false success: A public release binds one exact integrated protected source head to package/artifact identity, applicable CI/security/accessibility/document-fidelity evidence, owned production coverage, public-docstring evidence, SBOM/provenance/reproducibility where configured, formal review requirements, rollback guidance, and post-publication smoke verification. -Before immutable publication, the canonical draft inventory is **exactly four regular top-level files**: exactly one npm tarball, exactly one Inkspan Office wheel, `inkspan.spdx.json`, and `SHA256SUMS`. Missing, stale, unexpected, duplicate, non-regular, incompletely uploaded, or digest-mismatched assets fail closed. After upload and before publication, the authenticated paginated GitHub Releases API inventory must equal the local release directory by exact asset name, every remote asset must report an uploaded state, and every GitHub-reported `sha256:` digest must equal the digest of the exact transferred local file. The workflow does not silently delete an unexpected remote asset to make an ambiguous draft look clean. +Before immutable publication, the canonical draft inventory is **exactly five regular top-level files**: exactly one npm tarball, one Inkspan Office wheel, `editor-package.spdx.json`, `office-package.spdx.json`, and `SHA256SUMS`. Each package is scanned and attested only with its matching SBOM. Missing, stale, unexpected, duplicate, non-regular, incompletely uploaded, or digest-mismatched assets fail closed. After upload and before publication, the authenticated paginated GitHub Releases API inventory must equal the local release directory by exact asset name, every remote asset must report an uploaded state, and every GitHub-reported `sha256:` digest must equal the digest of the exact transferred local file. The workflow does not silently delete an unexpected remote asset to make an ambiguous draft look clean. Rollback must preserve readable canonical documents and must not require silently reinterpreting persisted schema or selector-projection semantics. Host-owned migrations, persistence rollback, annotation re-anchoring, tenant recovery, and deployment rollback remain host responsibilities unless a future versioned contract explicitly assigns them to Inkspan. @@ -155,7 +155,7 @@ Rollback must preserve readable canonical documents and must not require silentl | editor chrome theming (Active PR / Proposed) | named `--cwl-*` tokens, DTCG interchange snapshot, Storybook inventory, inventoried pair contrast including `--cwl-accent` on `--cwl-accent-soft` | host brand CSS, contrast certification, Figma Variables, design-tool sync | | naruon composition | stable local package/module boundary | authenticated compose transport, tenancy, provider/model policy | | model assistance | deterministic proposal acceptance boundary | provider, prompt/data policy, credentials, human approval | -| release evidence | exact four-file draft inventory, package/artifact/digest verification and repository evidence | downstream deployment and operational rollout | +| release evidence | exact five-file draft inventory, package/artifact/digest verification and repository evidence | downstream deployment and operational rollout | ## Related canonical documents diff --git a/docs/OPERABILITY.md b/docs/OPERABILITY.md index 23a22c6e5..3397cde87 100644 --- a/docs/OPERABILITY.md +++ b/docs/OPERABILITY.md @@ -60,7 +60,7 @@ Release publication occurs only from an exact integrated protected head. The rel Before publication: 1. fetch the current protected `main` ref and require the release tag event commit SHA to equal that exact integration tip, not merely be an ancestor of it; -2. build exactly four regular top-level release files: exactly one npm tarball, exactly one Inkspan Office wheel, `inkspan.spdx.json`, and `SHA256SUMS`; +2. build exactly five regular top-level release files: exactly one npm tarball, exactly one Inkspan Office wheel, `editor-package.spdx.json`, `office-package.spdx.json`, and `SHA256SUMS`; 3. reject missing, duplicate, non-regular, stale, or unexpected local entries and verify the local digests; 4. after upload, query the authenticated paginated GitHub Releases API and require the resumed remote draft asset-name set to equal the local release directory exactly; 5. require every remote asset state to be uploaded and every GitHub-reported `sha256:` digest to equal the exact transferred local file digest; diff --git a/docs/README.md b/docs/README.md index d4d24d8da..24c663e55 100644 --- a/docs/README.md +++ b/docs/README.md @@ -17,6 +17,7 @@ This directory is the discoverable index for Inkspan's product, technical, secur | [`TRD.md`](TRD.md) | Technical invariants, runtime boundaries, failure semantics and release evidence | | [`CONTRACTS.md`](CONTRACTS.md) | Public package/API/event/schema/plugin/collaboration and host-integration contracts | | [`package-distribution.md`](package-distribution.md) | Buyer-facing public npm package entrypoints, packaged contents, runtime dependency boundaries, and consumer verification | +| [`WRITING_DIAGNOSTICS.md`](WRITING_DIAGNOSTICS.md) | Active-PR host-owned semantic diagnostics boundary, deterministic revision integrity, privacy, accessibility and rollback contract | | [`email-output.md`](email-output.md) | Deterministic email fragment/full-document authority, language/direction metadata, accessibility and host-owned transport boundary | | [`print-output.md`](print-output.md) | Browser print/paged-media presentation, accessibility/fidelity limits, host-owned governed-export boundary, and rollback | | [`design-tokens.md`](design-tokens.md) | Host-facing editor chrome tokens, DTCG 2025.10 interchange snapshot, and Storybook inventory (Active PR / Proposed) | diff --git a/docs/TEST_STRATEGY.md b/docs/TEST_STRATEGY.md index d9cde2003..f2b2fc035 100644 --- a/docs/TEST_STRATEGY.md +++ b/docs/TEST_STRATEGY.md @@ -56,7 +56,7 @@ At minimum, maintain regressions for: - autosave stale validators, conflict/failure recovery, ambiguous transport outcomes, duplicate/no-op lifecycle transitions, callback exceptions, queue bounds, flush/close behavior, and durable-validator coherence; - selection/revision races and document movement during asynchronous hashing; - Office formula prefixes, invalid XML characters, malicious strings, path/publication races, invalid worksheet names, invalid freeze panes, cyclic input, pathological nesting, excessive container size, and partial write failure; -- package/release stale draft assets, unexpected or non-regular local entries, exact four-file inventory violations, incomplete remote uploads, GitHub-vs-local digest mismatch, stale exact-head evidence, mutable provenance inputs, and isolated packed-consumer behavior. +- package/release stale draft assets, unexpected or non-regular local entries, exact five-file inventory violations, incomplete remote uploads, GitHub-vs-local digest mismatch, stale exact-head evidence, mutable provenance inputs, and isolated packed-consumer behavior. ## Concurrency and failure testing @@ -68,7 +68,7 @@ Host persistence transactions, tenant isolation, distributed collaboration autho A release candidate requires the exact integrated protected head to satisfy applicable CI, security, JavaScript/TypeScript 100% statement/branch/function/line coverage, Office coverage.py 100% report plus public-docstring completeness, package-consumer, accessibility, browser differential, Office artifact, SBOM/provenance, reproducibility, unresolved-thread, actually required independent-review, and release-workflow gates. Queued, skipped-required, cancelled, absent, stale-head, predecessor-head, status-only, or synthetic-merge evidence is not accepted as success. -The release workflow must also satisfy the normative `docs/CONTRACTS.md` draft inventory contract: exactly one npm tarball, exactly one Office wheel, `inkspan.spdx.json`, and `SHA256SUMS`; no other top-level entry; remote uploaded asset names exactly equal local names; and every GitHub-reported `sha256:` digest equals the exact transferred local file digest. Missing, stale, unexpected, non-regular, incomplete, or digest-mismatched assets are failures, not cleanup opportunities. +The release workflow must also satisfy the normative `docs/CONTRACTS.md` draft inventory contract: exactly one npm tarball, exactly one Office wheel, `editor-package.spdx.json`, `office-package.spdx.json`, and `SHA256SUMS`; no other top-level entry; each package is attested only with its matching SBOM; remote uploaded asset names exactly equal local names; and every GitHub-reported `sha256:` digest equals the exact transferred local file digest. Missing, stale, unexpected, non-regular, incomplete, or digest-mismatched assets are failures, not cleanup opportunities. The 0.6.0 rich-clipboard release line specifically requires the protected dependency-locked **Playwright 1.62.0** Chromium, Firefox, and WebKit differential gate on the exact integrated protected release candidate before publication. Deterministic jsdom coverage remains useful but is not a substitute for browser-engine acceptance. Tagged release evidence must be generated anew from the release candidate and must verify the exact packed npm artifact, not merely reuse a previously green feature-branch run. diff --git a/docs/WRITING_DIAGNOSTICS.md b/docs/WRITING_DIAGNOSTICS.md new file mode 100644 index 000000000..edc24fdd6 --- /dev/null +++ b/docs/WRITING_DIAGNOSTICS.md @@ -0,0 +1,59 @@ +# Writing diagnostics + +Status: Active PR / Proposed + +Writing diagnostics are a host-supplied, revision-bound review surface for local Inkspan authoring. This document describes the active writing-diagnostics stack only; it is not protected-main authority until the owning stack integrates under then-live governance. The same deterministic boundary covers collaborative editing and remains safe for server rendering without acquiring host authority. + +## Authority model + +### Host semantic authority + +The host decides whether to request diagnostics, which provider or deterministic service to use, what text may leave the host boundary, and which proposed observations are admissible for presentation. The host owns model/provider selection, credentials, authorization, tenant isolation, redaction, retention, prompt and response logging, external-data-use policy, semantic policy, audit, and any human-review requirement. + +Confidence, priority, and category are host labels, not editor truth or submission policy. Model or service output is untrusted proposal data. Diagnostics never block submission, sending, persistence, export, or collaboration. + +### Inkspan deterministic integrity + +Inkspan validates the bounded diagnostic envelope, binds accepted diagnostics to the document revision supplied by the host, projects deterministic text positions, renders inert proposal data, and applies only explicitly selected deterministic editor actions after rechecking the current revision. The framework-neutral package surface is `@contextualwisdomlab/cwl-editor/writing-diagnostics`. + +Inkspan performs no grammar, tone, clarity, pragmatics, technical-quality, actionability, sender, recipient, language, or policy inference. No keyword, regex, phrase-list, language-name, sender-domain, recipient-count, nearest-text, quote-search, or word-position fallback is permitted. + +## Revision and invalidation contract + +Every local or collaborative transaction with docChanged === true invalidates the complete active diagnostic generation. This intentionally favors stale-proposal rejection over attempting semantic re-anchoring. Hosts may request a new generation against the new revision. + +Focus is allowed only after the controller verifies that the diagnostic belongs to the current revision and resolves its already-validated text-position range. If the editor is unavailable or the revision no longer matches, the action fails closed rather than guessing another range. + +Version 1 applies exactly one explicitly selected diagnostic at a time. Focus, Apply, Ignore, Dismiss, and Explain are discrete author actions; none grants semantic or authorization authority to Inkspan. Apply mutates only the selected accepted proposal through the existing editor transaction boundary. Ignore and Dismiss affect local diagnostic presentation state, not source-of-truth document semantics. Explain may expose host-supplied explanatory proposal data but does not create a model call inside Inkspan. + +## Hostile input and privacy + +Diagnostic objects, strings, HTML-like content, getters, Proxies, and callback failures are untrusted. Contract validation is bounded and fail-closed; public errors are payload-redacted. Authored text, provider messages, file names, URLs, credentials, and private thrown values are not reflected into ordinary public diagnostics errors. + +Browser presentation uses text-safe rendering rather than granting diagnostic content executable markup authority. Rejected, aborted, malformed, stale, or unavailable diagnostic responses do not trigger lexical or positional fallback behavior. + +The privacy boundary is deliberately narrow: Inkspan needs only the accepted bounded proposal fields required to render and deterministically act on a diagnostic. The host remains responsible for deciding whether text can be sent to any external service and for downstream retention, logging, tenant, and access-control policy. + +## Collaborative editing + +Collaborative editing does not change semantic ownership. Yjs/provider transport, room authorization, awareness privacy, lifecycle, durable persistence, and audit remain host-owned. A collaborative document transaction that changes the document invalidates the current generation exactly like a local document transaction. Remote presence or awareness metadata is never diagnostic authorization evidence. + +## Server rendering and packaging + +The framework-neutral validation/projection package must remain importable without React UI, browser DOM, Yjs, a model SDK, credentials, network access, persistence, naruon, or contextual-orchestrator. Server rendering must not initialize provider, collaboration, model, or browser authority merely because the package is imported. Browser-only interaction begins only in the explicit editor/UI layer. + +Packed-package verification is expected to exercise ESM, CommonJS, strict TypeScript, server-rendering-safe imports, and authority scans for the public subpath. + +## Accessibility and interaction + +The presentation layer must keep diagnostics keyboard reachable, preserve visible focus, tolerate 200% scaling and forced-colors behavior, and expose status changes without using visual styling as the only state signal. Touch interaction is detected by capability rather than viewport width. Browser assurance should cover the supported Chromium, Firefox, WebKit, and mobile interaction paths before integration claims are made. + +## Failure handling and rollback + +Malformed or hostile diagnostics fail closed without manufacturing a substitute proposal. A rejected host request, aborted route, unavailable editor, stale revision, failed deterministic action, or callback exception must not grant additional authority or silently mutate the document. + +Rollback for an applied diagnostic uses the editor's ordinary deterministic undo/revision semantics; Inkspan does not maintain a parallel semantic history. Hosts own durable recovery, persistence rollback, audit reconciliation, and regeneration policy. If package or browser assurance regresses, the safe rollback is to remove or disable the active-PR diagnostics integration while preserving standalone authoring and the framework-neutral package boundary. + +## Claim boundary + +This guide is canonical for the active diagnostics stack only when its owning branch is the current source. Protected `main` remains shipped truth. Tests, PR prose, browser runs, model verdicts, and status checks are evidence for an exact source generation; they do not by themselves promote this active-PR behavior to protected-main authority. diff --git a/docs/doctoring/release-draft-asset-inventory.md b/docs/doctoring/release-draft-asset-inventory.md index 5675b4c25..337952c35 100644 --- a/docs/doctoring/release-draft-asset-inventory.md +++ b/docs/doctoring/release-draft-asset-inventory.md @@ -8,11 +8,11 @@ Inkspan's release workflow intentionally supports retrying an unpublished GitHub Release draft. The upload command uses `--clobber`, which replaces same-name release assets. A prior failed attempt or operator action can nevertheless leave a differently named asset in the draft. Publishing that draft while immutable releases are enabled would freeze the extra asset into the official release identity. -This is a provenance-completeness problem rather than a checksum-collision problem. The current protected release workflow produces four reviewed top-level assets: one npm tarball, one Inkspan Office wheel, `inkspan.spdx.json`, and `SHA256SUMS`. All four can be correctly generated while an unrelated fifth asset remains attached to the draft. Verifying only the expected local artifacts therefore does not prove that the complete release asset set is the reviewed set. +This is a provenance-completeness problem rather than a checksum-collision problem. The current protected release workflow produces five reviewed top-level assets: one npm tarball, one Inkspan Office wheel, `editor-package.spdx.json`, `office-package.spdx.json`, and `SHA256SUMS`. All five can be correctly generated while an unrelated sixth asset remains attached to the draft. Verifying only the expected local artifacts therefore does not prove that the complete release asset set is the reviewed set. -A second local-boundary failure mode exists before upload. Counting only top-level regular files does not prove that the transfer directory has exactly four entries: an unexpected directory, symlink, socket, or other non-regular top-level entry could coexist with the four expected files. The shell upload glob can expand such an entry even though a `find ... -type f` count ignores it. The local release boundary therefore has to bind both the complete top-level entry set and the regular-file subset before attestation or upload. +A second local-boundary failure mode exists before upload. Counting only top-level regular files does not prove that the transfer directory has exactly five entries: an unexpected directory, symlink, socket, or other non-regular top-level entry could coexist with the five expected files. The shell upload glob can expand such an entry even though a `find ... -type f` count ignores it. The local release boundary therefore has to bind both the complete top-level entry set and the regular-file subset before attestation or upload. -The original 2026-08-07 record described a three-file inventory because the release workflow at that stage did not yet publish the SPDX SBOM as a top-level release asset. Protected release source and executable contract tests now require `inkspan.spdx.json` as the fourth asset. This record supersedes that stale cardinality while retaining the original threat model and provenance rationale. +The original 2026-08-07 record described a three-file inventory because the release workflow at that stage did not yet publish SPDX SBOMs as top-level release assets. The current contract publishes one SBOM per exact package, producing a five-file inventory. This record supersedes both stale cardinalities while retaining the original threat model and provenance rationale. ## Primary evidence @@ -26,7 +26,7 @@ These properties make the pre-publication draft the last safe point at which Ink After the expected files cross the workflow-artifact privilege boundary and before attestation or upload, and again after upload before `gh release edit ... --draft=false`, the release workflow must: -1. Require the local transfer directory to contain exactly four top-level entries and require all four to be regular files: one `*.tgz`, one `*.whl`, `inkspan.spdx.json`, and `SHA256SUMS`. Any extra directory, symlink, socket, device, regular file, or other top-level entry fails closed. +1. Require the local transfer directory to contain exactly five top-level entries and require all five to be regular files: one `*.tgz`, one `*.whl`, `editor-package.spdx.json`, `office-package.spdx.json`, and `SHA256SUMS`. Any extra directory, symlink, socket, device, regular file, or other top-level entry fails closed. 2. Paginate the authenticated GitHub **List releases** REST endpoint and select the exact release tag; require exactly one matching release object. 3. Require the selected remote object to remain a draft. 4. Compare the sorted local and remote asset-name sets for exact equality. @@ -43,7 +43,7 @@ The workflow does not automatically delete an unexpected stale asset. Automatic - A failed prior attempt leaves an obsolete wheel, tarball, SBOM, checksum file, or other differently named asset in the draft. - A same-name remote asset contains bytes different from the transferred local file. - An upload is incomplete or an asset does not report the `uploaded` state. -- The local artifact directory unexpectedly contains multiple npm tarballs, multiple wheels, a missing or duplicate `inkspan.spdx.json`, missing checksums, another regular file, or any additional non-regular top-level entry. +- The local artifact directory unexpectedly contains multiple npm tarballs, multiple wheels, a missing or duplicate package SBOM, missing checksums, another regular file, or any additional non-regular top-level entry. - A symlink or directory replaces one of the expected regular files. - The release stops being a draft before the inventory check. - The draft cannot be uniquely identified in the complete authenticated release listing. @@ -65,11 +65,11 @@ This change is release-contract documentation and workflow assurance; it does no ## Verification -`src/releaseDraftAssetInventory.test.ts` is the executable protected release authority for the inventory. It requires the exact-inventory check to occur after upload and before publication, requires `expected_asset_count=4`, requires one npm tarball, one Office wheel, `inkspan.spdx.json`, and `SHA256SUMS`, uses paginated draft-aware list evidence rather than a published-only by-tag route, and requires state and digest validation plus explicit fail-closed diagnostics. On Linux, which is the release-runner class, the same test extracts and executes the reviewed shell body with a local fake `gh api` response and the runner's real Bash, `jq`, `find`, `diff`, and `sha256sum`. +`src/releaseDraftAssetInventory.test.ts` is the executable protected release authority for the inventory. It requires the exact-inventory check to occur after upload and before publication, requires `expected_asset_count=5`, requires one npm tarball, one Office wheel, both matching package SBOMs, and `SHA256SUMS`, uses paginated draft-aware list evidence rather than a published-only by-tag route, and requires state and digest validation plus explicit fail-closed diagnostics. On Linux, which is the release-runner class, the same test extracts and executes the reviewed shell body with a local fake `gh api` response and the runner's real Bash, `jq`, `find`, `diff`, and `sha256sum`. -`src/releaseDraftAssetEntryType.test.ts` separately exercises the pre-attestation local entry-type boundary. Historical RED `0d905d9b244d36d55317de2237e3e3480c7ece5f` proved that a regular-file-only count could admit an unexpected top-level directory under the then-current inventory. The current contract generalizes that invariant to the exact four-file set: complete top-level cardinality and regular-file cardinality must both match before attestation or upload. +`src/releaseDraftAssetEntryType.test.ts` separately exercises the pre-attestation local entry-type boundary. Historical RED `0d905d9b244d36d55317de2237e3e3480c7ece5f` proved that a regular-file-only count could admit an unexpected top-level directory under the then-current inventory. The current contract generalizes that invariant to the exact five-file set: complete top-level cardinality and regular-file cardinality must both match before attestation or upload. -`src/releaseContractCanonicalConsistency.test.ts` rejects stale three-file wording across `docs/CONTRACTS.md`, `docs/TEST_STRATEGY.md`, `docs/OPERABILITY.md`, and this doctoring record while requiring all canonical documents to name the protected four-file SBOM-inclusive inventory. This prevents documentation from drifting behind executable release behavior again. +`src/releaseContractCanonicalConsistency.test.ts` rejects stale three- and four-file wording across `docs/CONTRACTS.md`, `docs/TEST_STRATEGY.md`, `docs/OPERABILITY.md`, and this doctoring record while requiring all canonical documents to name the protected five-file package-specific SBOM inventory. This prevents documentation from drifting behind executable release behavior again. Exact-current-head CI, security, automated review, independent review, and branch protection remain authoritative. Predecessor-head, queued, cancelled, skipped, status-only, or synthetic-merge evidence is not completion evidence. diff --git a/docs/release-security.md b/docs/release-security.md index 08e1d6ff7..d83b925ee 100644 --- a/docs/release-security.md +++ b/docs/release-security.md @@ -20,7 +20,7 @@ For stable registry releases, the root and Office package versions must both equ The GitHub Release path has a source-bearing build stage followed by a source-free publication stage, and external registry publication is downstream of that validated artifact boundary: -1. `build-release-artifacts` has read-only repository access. It checks identity, installs dependencies, runs all quality gates, builds both distributions, generates an SPDX 2.3 SBOM with signature-verified Syft, validates the SBOM, and creates checksums for the complete release set. +1. `build-release-artifacts` has read-only repository access. It checks identity, installs dependencies, runs all quality gates, builds both distributions, generates one SPDX 2.3 SBOM per exact package with signature-verified Syft, validates both SBOMs, and creates checksums for the complete release set. 2. `publish-release` receives only the validated files through GitHub's workflow artifact service. This smaller job alone receives the GitHub release, OpenID Connect, and attestation authority needed to create the immutable GitHub Release. 3. `publish-npm` and `publish-pypi` consume the same validated npm tarball and Office wheel after the GitHub Release boundary. They receive OIDC only inside their protected registry environments and do not rebuild the packages. 4. `verify-registry-publication` has no publishing credential. It performs post-publication digest verification against the public npm and PyPI registry identities and the exact validated local artifacts. @@ -45,8 +45,8 @@ The release workflow repeats merge and product gates against the tagged source r 10. Office dependency consistency, 100% shipped-symbol docstring coverage, and 100% branch coverage; 11. Office wheel construction and inspection for the bundled schema and license; 12. installation of the exact Syft v1.50.0 release through its commit-pinned installer with Cosign verification enabled, so the signed checksum material is verified before the Syft binary is accepted; -13. deterministic SPDX 2.3 SBOM generation and validation for a non-empty inventory containing both `@contextualwisdomlab/cwl-editor` and `inkspan-office`; -14. SHA-256 checksum generation for the npm tarball, Office wheel, `inkspan.spdx.json`, and checksum manifest boundary; +13. deterministic SPDX 2.3 SBOM generation and validation for each exact npm and Office package; +14. SHA-256 checksum generation for the npm tarball, Office wheel, both package SBOMs, and checksum manifest boundary; 15. checksum verification after the privilege boundary; 16. exact draft asset inventory and digest verification before GitHub publication; and 17. public npm and PyPI post-publication digest verification for stable registry releases. @@ -57,7 +57,7 @@ No release draft is created or modified unless every source-bearing build gate s The release path does not delegate Syft installation to an action that can retrieve a mutable installer from another branch. It installs Cosign from a full-commit-pinned `sigstore/cosign-installer` action, downloads Syft's installer from the exact commit behind the annotated `v1.50.0` tag, disables installer-script redirection with `DOWNLOAD_TAG_INSTALL_SCRIPT=false`, and invokes the installer with `-v`. The Syft installer therefore verifies the release checksum signature and certificate before accepting the downloaded Syft binary, then still verifies the binary checksum. -Only that signature-verified Syft executable is added to the workflow `PATH` and used to generate `release/inkspan.spdx.json`. The workflow then validates the SPDX version, package inventory, expected Inkspan package identities, and the bounded attestation-input size before the SBOM can cross the build/publication privilege boundary. +Only that signature-verified Syft executable is added to the workflow `PATH`. It scans the exact npm tarball into `release/editor-package.spdx.json` and the exact Office wheel into `release/office-package.spdx.json`. The workflow then validates each SPDX version, matching package identity, and bounded attestation-input size before either SBOM can cross the build/publication privilege boundary. This controls the generator bootstrap path; it does not assert that an SBOM is a vulnerability scan or license-policy decision. Consumers and release operators must interpret the inventory separately from provenance and security-scan results. @@ -81,12 +81,12 @@ A resumed draft is not assumed to contain only artifacts from the current workfl Immediately after upload and before the draft is published, the workflow therefore fails closed unless all of these conditions hold: -- the local release directory contains exactly one npm `*.tgz`, one Office `*.whl`, `inkspan.spdx.json`, and `SHA256SUMS`; -- `SHA256SUMS` binds the npm tarball, Office wheel, and SBOM digest to the transferred local release set; +- the local release directory contains exactly one npm `*.tgz`, one Office `*.whl`, `editor-package.spdx.json`, `office-package.spdx.json`, and `SHA256SUMS`; +- `SHA256SUMS` binds the npm tarball, Office wheel, and both SBOM digests to the transferred local release set; - the canonical GitHub Releases API still reports the release as a draft; - the sorted remote asset-name set exactly equals the sorted local artifact-name set; - every remote asset reports the `uploaded` state; and -- every GitHub release-asset `sha256:` digest, including the SBOM digest and checksum-manifest digest, exactly equals a newly computed SHA-256 digest of the corresponding transferred local file. +- every GitHub release-asset `sha256:` digest, including both SBOM digests and the checksum-manifest digest, exactly equals a newly computed SHA-256 digest of the corresponding transferred local file. The draft lookup deliberately uses the authenticated, paginated **List releases** REST endpoint and filters its complete result for the exact tag. GitHub documents that authenticated callers with repository push access receive draft releases from this endpoint. The `Get a release by tag name` endpoint is documented for a **published** release, so it is not used as evidence for this pre-publication gate. The publish job fails unless the paginated listing contains exactly one release matching the tag and that object still reports `draft: true`. @@ -102,20 +102,20 @@ Enabling immutable releases is an administrative repository control. Repository ## Published artifacts -Each successful GitHub release contains exactly four files: +Each successful GitHub release contains exactly five files: - the exact npm tarball produced by `npm pack`; - the `inkspan-office` wheel built from `office/`; -- `inkspan.spdx.json`, the validated SPDX 2.3 SBOM generated by signature-verified Syft; and -- `SHA256SUMS` covering the npm tarball, Office wheel, and SBOM. +- `editor-package.spdx.json` and `office-package.spdx.json`, the validated SPDX 2.3 SBOMs generated from their matching packages by signature-verified Syft; and +- `SHA256SUMS` covering the npm tarball, Office wheel, and both SBOMs. The workflow does not rebuild artifacts after the read-only build job. The same transferred files are checksum-verified, attested, uploaded, inventory-checked against the draft, and published to GitHub; on stable releases, the npm tarball and Office wheel are then forwarded unchanged to npm and PyPI. ## Provenance and verification -The isolated GitHub publication job requests a short-lived OpenID Connect identity and uses GitHub artifact attestations to create signed SLSA provenance for the npm tarball, Office wheel, `inkspan.spdx.json`, and checksum manifest. It also creates SPDX SBOM attestations binding the npm tarball and Office wheel to the validated `inkspan.spdx.json` predicate. The repository is public, so the attestation is backed by the public Sigstore transparency infrastructure used by GitHub. +The isolated GitHub publication job requests a short-lived OpenID Connect identity and uses GitHub artifact attestations to create signed SLSA provenance for the npm tarball, Office wheel, both package SBOMs, and checksum manifest. It creates separate SPDX attestations that bind each package only to its matching SBOM predicate. The repository is public, so the attestation is backed by the public Sigstore transparency infrastructure used by GitHub. -Consumers should verify release-level and file-level provenance, the SBOM predicate, and checksums, using the actual version and filenames from the selected release: +Consumers should verify release-level and file-level provenance, both package-specific SBOM predicates, and checksums, using the actual version and filenames from the selected release: ```bash VERSION=0.6.0 @@ -125,7 +125,9 @@ gh release verify "v${VERSION}" --repo ContextualWisdomLab/inkspan gh release verify-asset "v${VERSION}" "contextualwisdomlab-cwl-editor-${VERSION}.tgz" \ --repo ContextualWisdomLab/inkspan -gh release verify-asset "v${VERSION}" "inkspan.spdx.json" \ +gh release verify-asset "v${VERSION}" "editor-package.spdx.json" \ + --repo ContextualWisdomLab/inkspan +gh release verify-asset "v${VERSION}" "office-package.spdx.json" \ --repo ContextualWisdomLab/inkspan gh attestation verify "inkspan_office-${VERSION}-py3-none-any.whl" \ @@ -159,9 +161,9 @@ npm and PyPI are independent immutable publication domains. If one registry acce - GitHub release, OpenID Connect, and attestation permissions are scoped to the source-free jobs that actually require them. - Release tags must identify the exact current protected-main tip. - Stable root, Office, and tag versions must match before registry publication. -- The local and draft release contract is exactly one npm tarball, one Office wheel, `inkspan.spdx.json`, and `SHA256SUMS`. +- The local and draft release contract is exactly one npm tarball, one Office wheel, `editor-package.spdx.json`, `office-package.spdx.json`, and `SHA256SUMS`. - The draft asset set and every GitHub-reported SHA-256 asset digest must exactly match the transferred local release set before GitHub publication. -- The SBOM digest is covered by `SHA256SUMS`, remote release-asset digest verification, and release provenance; package attestations additionally bind the distributable packages to the SPDX predicate. +- Both SBOM digests are covered by `SHA256SUMS`, remote release-asset digest verification, and release provenance; each package attestation binds one distributable only to its matching SPDX predicate. - The published GitHub release must report an immutable state; a mutable outcome is deleted and rejected. - Existing published assets are never refreshed, replaced, or deleted by a successful workflow path. - Stable npm and PyPI publication uses protected OIDC environments rather than long-lived registry secrets. diff --git a/src/ciPnpmBootstrapContract.test.ts b/src/ciPnpmBootstrapContract.test.ts new file mode 100644 index 000000000..17c31324c --- /dev/null +++ b/src/ciPnpmBootstrapContract.test.ts @@ -0,0 +1,20 @@ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + +import { describe, expect, it } from 'vitest'; + +const workflow = readFileSync( + resolve(process.cwd(), '.github/workflows/ci.yml'), + 'utf8', +); +const SAFE_PNPM_ACTION_PIN = + 'pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10'; +const VULNERABLE_PNPM_ACTION_PIN = + 'pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8'; + +describe('CI pnpm bootstrap contract', () => { + it('uses the signed non-vulnerable action in every JavaScript job', () => { + expect(workflow.match(new RegExp(SAFE_PNPM_ACTION_PIN, 'g'))).toHaveLength(2); + expect(workflow).not.toContain(VULNERABLE_PNPM_ACTION_PIN); + }); +}); diff --git a/src/components/WritingDiagnosticsPanel.tsx b/src/components/WritingDiagnosticsPanel.tsx index 33781f828..7fdf01ccf 100644 --- a/src/components/WritingDiagnosticsPanel.tsx +++ b/src/components/WritingDiagnosticsPanel.tsx @@ -3,7 +3,10 @@ import { useState, type KeyboardEvent as ReactKeyboardEvent, } from 'react'; -import type { WritingDiagnosticsController } from './useWritingDiagnosticsController.js'; +import type { + CwlVerifiedWritingDiagnostic, + WritingDiagnosticsController, +} from './useWritingDiagnosticsController.js'; /** Props for Inkspan's provider-neutral writing-guidance presentation surface. */ export interface WritingDiagnosticsPanelProps { @@ -56,6 +59,20 @@ export function WritingDiagnosticsPanel({ itemRefs.current[targetIndex]!.focus(); }; + const focusAffectedText = ( + verified: CwlVerifiedWritingDiagnostic, + ): void => { + const diagnosticId = verified.diagnostic.diagnosticId; + setActiveDiagnosticId(diagnosticId); + const editor = controller.editor; + if (!controller.focusDiagnostic(diagnosticId) || editor === null) return; + editor + .chain() + .setTextSelection({ from: verified.from, to: verified.to }) + .focus() + .run(); + }; + const focusAfterDismissal = (dismissedIndex: number): void => { if (diagnostics.length === 1) { setActiveDiagnosticId(null); @@ -168,10 +185,7 @@ export function WritingDiagnosticsPanel({