diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e1d41ef6..743e8e62 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 @@ -724,4 +745,4 @@ jobs: done echo "::error::Registry publication verification did not converge to the exact artifact digests." - exit 1 \ No newline at end of file + exit 1 diff --git a/.github/workflows/writing-diagnostics-assurance-tdd.yml b/.github/workflows/writing-diagnostics-assurance-tdd.yml new file mode 100644 index 00000000..446b6c04 --- /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/docs/README.md b/docs/README.md index 2b617f7c..fdefd8a3 100644 --- a/docs/README.md +++ b/docs/README.md @@ -16,6 +16,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 | | [`UML.md`](UML.md) | Component, sequence, state and authority-flow diagrams | diff --git a/docs/WRITING_DIAGNOSTICS.md b/docs/WRITING_DIAGNOSTICS.md new file mode 100644 index 00000000..edc24fdd --- /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/src/ciPnpmBootstrapContract.test.ts b/src/ciPnpmBootstrapContract.test.ts new file mode 100644 index 00000000..17c31324 --- /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 33781f82..7fdf01cc 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({