diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2444529a..01e77d2c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -44,6 +44,33 @@ jobs: - name: Build demo run: pnpm build:demo + browser-release-evidence: + name: Cross-engine Clipboard / 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@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: 22 + cache: pnpm + - name: Install root dependencies from the immutable lock + run: pnpm install --frozen-lockfile + - name: Install browser-test dependencies from the isolated immutable lock + run: pnpm --dir tests/browser install --frozen-lockfile + - name: Install Playwright browser revisions pinned by 1.62.0 + run: pnpm --dir tests/browser exec playwright install --with-deps chromium firefox webkit + - name: Verify real-engine rich clipboard release evidence + env: + INKSPAN_EXPECTED_HEAD_SHA: ${{ github.event.pull_request.head.sha || github.sha }} + run: pnpm --dir tests/browser exec playwright test --config playwright.config.ts + office: name: Office / Python ${{ matrix.python-version }} runs-on: ubuntu-24.04 diff --git a/.gitignore b/.gitignore index 9296a379..2f74949f 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,10 @@ coverage/ .eslintcache *.tsbuildinfo +# Browser release evidence is ephemeral exact-head CI output. +tests/browser/.browser-evidence/ +tests/browser/test-results/ + # Python build, test, and environment artifacts __pycache__/ *.py[cod] diff --git a/CHANGELOG.md b/CHANGELOG.md index 0c1c20d0..7a9ad7e0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ Historical release entries from **0.1.0 through 0.5.27** are preserved verbatim - Kept collaborative Yjs document content out of server markup until the host-owned client collaboration lifecycle is bound ### Added +- Added bounded `inspectDocumentEnvelopeIdentity()` and `inspectDocumentEnvelopeIdentityBytes()` routing metadata plus the framework-independent `envelope-identity` package subpath so hosts can select explicit schema migrations without exposing document bodies, weakening the strict current-schema parser, or moving migration/persistence authority into Inkspan - Added one optional construction-time `onSnapshotChange` callback to the framework-free autosave queue and durable autosave session so hosts can observe saving, pending, blocked, recovery, idle, and shutdown state without polling or introducing a subscriber collection - Added privacy-minimized revision-scoped selection evidence through `getSelectionRevisionEvidence()`, binding frozen ProseMirror coordinates to the SHA-256 strong revision of the exact same immutable editor state before asynchronous hashing begins - Added privacy-minimized document transition evidence for validated previous and resulting canonical revisions through the framework-independent `revision-evidence` subpath, with object/JSON and strict UTF-8 entry points, deterministic previous-then-resulting SHA-256 derivation, frozen revision-only results, and no document body, actor, tenant, time, authorization, signature, transport, model, or durable-write claim @@ -31,10 +32,12 @@ Historical release entries from **0.1.0 through 0.5.27** are preserved verbatim - Added deterministic regression and documentation contracts plus APA 7th doctoring for exact `Control`/`Meta` shortcut alternatives, the descriptive-only accessibility boundary, repository-level shortcut verification, and omission of unsupported shortcut claims ### Tests +- Added a dependency-locked Chromium/Firefox/WebKit **cross-engine rich-clipboard release gate** using Playwright 1.62.0, one versioned synthetic adversarial corpus, the actual TipTap/ProseMirror paste path, exact-source-head/lock/browser evidence, hostile-DOM and resource-ceiling cases, bounded performance alarm evidence, and fail-closed three-engine consensus without generic normalization - Added test-first Node `renderToString` evidence for the missing SSR native value, controlled-over-default selection, escaping, external form ownership, no ProseMirror server construction, and opt-out non-disclosure - Added browser-DOM handoff tests proving the field retains and updates the selected value before TipTap exists while reset-only unnamed fields remain empty ### Documentation +- Added browser-assurance doctoring, operability, test-strategy, clipboard-security, and documentation-fitness coverage for the **dependency-locked Chromium/Firefox/WebKit** release boundary, including Playwright 1.62.0 provenance, exact-head/corpus identity, standards-backed difference policy, evidence minimization, fail-closed behavior, and rollback - Added a canonical acquisition documentation spine covering product requirements, technical requirements, public interface/integration contracts, Mermaid UML, a conceptual data/evidence model, a threat model, test strategy, operability/recovery, standards/evidence traceability, and seventeen linked architecture decision records without inventing Inkspan-owned persistence or host authority; the newest decisions make envelope schema identity/host-owned migration routing, cross-engine browser-semantic release assurance, and the protected security-disclosure lifecycle first-class while keeping unimplemented capabilities explicitly planned - Added machine-checkable canonical-documentation decision coverage that keeps required files, ADR index links and completeness, migration-routing and browser-assurance UML/data-model/traceability evidence, physical-ERD non-applicability, browser-security evidence, offline font provenance/no-runtime-font-egress, standards references, rollback sections, host-vs-Inkspan authority boundaries, implemented-vs-active-PR status, and work-conserving autonomous-maintenance guidance synchronized - Documented work-conserving autonomous-maintenance governance in `AGENTS.md` and `CLAUDE.md`: a blocked PR blocks only its lane, status/report/prompt/documentation milestones are intermediate while safe work remains, and the external scheduler owns cadence rather than becoming an Inkspan runtime capability diff --git a/docs/DOCUMENTATION_FITNESS.md b/docs/DOCUMENTATION_FITNESS.md index ef5f49f5..fd86715b 100644 --- a/docs/DOCUMENTATION_FITNESS.md +++ b/docs/DOCUMENTATION_FITNESS.md @@ -38,22 +38,23 @@ Document fitness and implementation maturity are independent. A `present_current | TRD | `docs/TRD.md` | `present_current` | Same mixed maturity discipline; protected `main` remains runtime authority | Technical invariants, failure semantics, package boundaries and release evidence are reconstructable. | | ARCHITECTURE | `ARCHITECTURE.md` | `present_current` | `implemented_on_protected_main` for the bounded standalone/modular architecture | Host-vs-Inkspan authority and modular CWL composition are explicit. | | Public/API/schema/plugin contracts | `docs/CONTRACTS.md` | `present_current` | Protected-main contracts plus explicitly proposed extensions | Integration authority and degraded behavior are not hidden in implementation details. | -| ADR | `docs/adr/README.md` and detailed ADRs | `present_current` | Decisions distinguish current, proposed and future work; ADR 0017 records the protected security-disclosure lifecycle without duplicating its root policy | Alternatives, consequences, recovery, migration, verification and supersession are reviewable. | +| ADR | `docs/adr/README.md` and detailed ADRs | `present_current` | Decisions distinguish current, proposed and future work; ADR 0015 governs protected identity routing, ADR 0016 governs active browser-semantic assurance, and ADR 0017 records the protected security-disclosure lifecycle | Alternatives, consequences, recovery, migration, verification and supersession are reviewable. | | UML | `docs/UML.md` | `present_current` | Diagrams include protected-main and clearly proposed flows | Component, sequence, state, deployment, degraded-mode and authority flows are visible as diagram-as-code. | | DATA_MODEL / ERD | `docs/DATA_MODEL.md` | `present_current` | Current logical evidence/domain model; host persistence remains outside Inkspan | The model distinguishes document/evidence/conversion/release values from host-owned entities. | | physical relational ERD | none by design | `not_applicable` | `out_of_scope` while Inkspan owns no application database | No fake database is invented merely to satisfy an ERD checklist; a physical ERD becomes mandatory if persistence authority moves into Inkspan. | | SECURITY disclosure policy | root `SECURITY.md` plus ADR 0017 | `present_current` | `implemented_on_protected_main`; the root policy is protected authority and ADR 0017 records its durable architecture/process decision | Private reporting, evidence minimization, supported release-line binding, ownership limits, coordinated disclosure, and explicit no-SLA/no-certification claim boundaries are reconstructable. | +| Safe rich clipboard | PRD, TRD, `docs/clipboard-security.md`, ADR 0003/0016 and protected SafeClipboard source | `present_current` | `implemented_on_protected_main`; protected main contains the real TipTap/ProseMirror SafeClipboard paste boundary | Buyers can reconstruct bounded active/hidden/resource rejection, transform ordering, error redaction and host ownership without treating the sanitizer as active-only work. | | Autosave lifecycle observation | PRD, TRD, `docs/document-autosave.md`, lifecycle doctoring and protected autosave package/session source | `present_current` | `implemented_on_protected_main`; protected main exposes the bounded construction-time observer contract | Buyers can reconstruct saving/blocked/recovery/idle/shutdown observation, document-free snapshots, observer-failure isolation, and durable-validator coherence without treating it as an active-PR promise. | | SSR/native-form serialization | PRD, TRD, `docs/server-rendering.md`, SSR doctoring and protected editor/form source | `present_current` | `implemented_on_protected_main`; protected main includes the explicit server-value handoff and synchronous hydrated mirror | Buyers can reconstruct opt-in server serialization, hydration continuity, client-controlled submission semantics, reset behavior and host-owned auth/CSRF/persistence boundaries. | | Toolbar shortcut accessibility metadata | PRD/TRD accessibility requirements, accessibility guide/doctoring and protected toolbar source | `present_current` | `implemented_on_protected_main`; shipped bold/italic/link/undo/redo shortcuts expose truthful `aria-keyshortcuts` metadata | Accessibility metadata is tied to actual repository-level keyboard behavior rather than extension-local assumptions. | | Revision-scoped selection evidence | selection lifecycle guide, doctoring and protected public handle/type contract | `present_current` | `implemented_on_protected_main`; protected main atomically binds structural selection coordinates to the exact revision | Atomic selection+revision evidence, privacy minimization and host-owned re-anchoring are reconstructable as shipped behavior without overstating cross-revision authority. | | Document-transition evidence | transition doctoring, public framework-independent contract and protected revision-evidence package | `present_current` | `implemented_on_protected_main`; protected main exposes object/JSON and strict UTF-8 transition evidence | Previous/resulting revision lineage and privacy/provenance boundaries are reconstructable as shipped local evidence without implying actor/time/durable-write provenance. | | THREAT_MODEL | `docs/THREAT_MODEL.md` | `present_current` | Covers current and explicitly proposed trust boundaries | Clipboard, Office, SSR/form, Yjs, model, host-authority and supply-chain threats are reconstructable. | -| TEST_STRATEGY | `docs/TEST_STRATEGY.md` | `present_current` | Current deterministic evidence plus `planned` cross-engine acceptance where dependency order requires it | Test authority and claim limits are explicit rather than inferred from CI badges. | -| OPERABILITY | `docs/OPERABILITY.md` | `present_current` | Current local/product responsibilities plus host-owned recovery boundaries | Conflict, collaboration, conversion and release recovery/rollback ownership are explicit. | +| TEST_STRATEGY | `docs/TEST_STRATEGY.md` | `present_current` | Protected deterministic evidence plus `implemented_on_active_pr` Playwright 1.62.0 cross-engine assurance | Test authority, exact source-head browser evidence and claim limits are explicit rather than inferred from CI badges. | +| OPERABILITY | `docs/OPERABILITY.md` | `present_current` | Current product responsibilities plus active browser-assurance recovery boundaries and host-owned recovery boundaries | Conflict, collaboration, conversion, browser divergence and release recovery/rollback ownership are explicit. | | Release / rollback / provenance | TRD, OPERABILITY and release ADRs | `present_current` | Mix of `implemented_on_protected_main` and active hardening | Exact-source release authority, stale-evidence rejection and rollback are reconstructable. | -| Envelope schema identity / migration routing | ADR 0015, PRD, TRD, DATA_MODEL and Issue #74 | `present_current` | Identity-only routing capability is `planned`; strict current-schema parsing and host migration ownership remain authoritative | The architecture distinguishes bounded schema identification from host-owned migration execution without calling the planned API shipped. | -| Cross-engine browser-semantic release assurance | ADR 0016, UML, TEST_STRATEGY, TRACEABILITY and Issue #66 | `present_current` | Differential Chromium/Firefox/WebKit release gate is `planned` behind PR #65 | Browser-realistic security assurance is a durable release decision even though its implementation remains dependency-ordered future work. | +| Envelope schema identity / migration routing | ADR 0015, PRD, TRD, DATA_MODEL, envelope guide/doctoring and protected identity-routing source | `present_current` | `implemented_on_protected_main`; strict current-schema parsing and host migration ownership remain authoritative | The architecture distinguishes bounded schema identification from host-owned migration execution without expanding Inkspan persistence authority. | +| Cross-engine browser-semantic release assurance | ADR 0016, `docs/doctoring/cross-engine-rich-clipboard-assurance.md`, TEST_STRATEGY, OPERABILITY, TRACEABILITY and PR #85 | `present_current` | `implemented_on_active_pr`; SafeClipboard itself is `implemented_on_protected_main` | Browser-realistic Chromium/Firefox/WebKit release assurance is implemented and reviewable without promoting active-PR evidence to protected release authority. | | TRACEABILITY | `docs/TRACEABILITY.md` | `present_current` | Links standards/research/requirements to decisions and evidence with scoped claims | Acquisition reviewers can distinguish evidence from aspiration. | | Contributor/agent authority | `AGENTS.md`, `CLAUDE.md`, `docs/README.md` | `present_current` | Protected-main-first decision discipline | Agents are directed back to the same canonical graph rather than parallel private memory. | | Autonomous maintenance governance | `AGENTS.md`, `CLAUDE.md` plus the external scheduler | `present_current` | `out_of_scope` as Inkspan runtime behavior; the external scheduler owns cadence/continuation | Work-conserving execution, lane-local waiting, no-report-as-completion, and the scheduler-vs-product authority boundary are reconstructable without pretending automation is an Inkspan API. | @@ -63,7 +64,7 @@ Document fitness and implementation maturity are independent. A `present_current The canonical graph must retain durable product decisions from the project conversation only when they agree with live implementation or are explicitly labeled as target architecture. The reviewed baseline currently covers: - Markdown/HTML WYSIWYG authoring and deterministic source/document authority; -- strict link, image, clipboard, envelope and revision/evidence boundaries; +- strict link, image, protected-main SafeClipboard, envelope and revision/evidence boundaries; - bundled local/offline font licensing and air-gapped asset behavior; - deterministic email/document conversion boundaries and independently reusable Office rendering; - provider-neutral collaboration with host-owned Yjs provider, room, persistence and awareness authority; @@ -76,8 +77,8 @@ The canonical graph must retain durable product decisions from the project conve - accessibility, keyboard, print/export and document-fidelity evidence boundaries; - host ownership of transport, authentication, authorization, tenant isolation, persistence, credentials, migration, retention, deployment, durable audit and model policy; - protected-main private vulnerability reporting and coordinated disclosure with explicit evidence-minimization and no-SLA/no-certification boundaries; -- strict current-schema parsing plus planned identity-only envelope routing, while migration execution remains host-owned; -- real Chromium/Firefox/WebKit differential evidence as a release gate for browser-semantic clipboard security rather than a jsdom conformance claim; and +- protected-main identity-only envelope routing with strict current-schema parsing and host-owned migration execution; +- active dependency-locked Playwright 1.62.0 Chromium/Firefox/WebKit differential evidence as a release gate for browser-semantic clipboard security rather than a jsdom conformance claim; and - exact-head/package/security/provenance/release evidence as separate authorities from comments, model verdicts and historical checks. Autonomous commercial-maintenance scheduling and the no-early-stop execution discipline are **control-plane governance, not a shipped Inkspan product capability**. The external scheduler is the execution authority for cadence and continuation; repository guidance records writer leases, work-conserving queue behavior, lane-local waiting, evidence hierarchy and protected-main authority without pretending the automation prompt is a runtime API or architectural feature. @@ -88,14 +89,13 @@ Where an older conversation, PR body, or plan conflicts with protected `main`, i The documentation pack itself is substantially complete for acquisition review, but **repository closure is not documentation closure**. The remaining gaps are intentionally represented rather than hidden: -1. Issue #74 remains `planned`: the identity-only migration-routing API must still be implemented test-first while the current parser remains strict. Its architectural decision is now present rather than hidden in issue prose. -2. Issue #66 remains `planned` behind PR #65: the dependency-locked Chromium/Firefox/WebKit differential suite must still be implemented before the rich-clipboard release line. Its release-assurance decision is now present rather than hidden in issue prose. -3. SafeClipboard (PR #65) remains `implemented_on_active_pr` until protected integration. Autosave lifecycle observation, security disclosure, toolbar shortcut accessibility metadata, SSR/native-form serialization, revision-scoped selection evidence, and document-transition evidence are now `implemented_on_protected_main` and must not be described as active-only work. -4. The canonical documentation graph is already integrated on protected `main`; future reconciliation is required only when protected source, accepted decisions, or implementation maturity materially changes. -5. Documentation becoming mergeable or protected-merged is not a reason for the commercial loop to stop; the next safe product, release, security, accessibility or interoperability lane must continue. +1. PR #85 implements Issue #66 on an active branch: the dependency-locked Playwright 1.62.0 Chromium/Firefox/WebKit differential suite must prove exact-head browser evidence, complete its reviews/checks, and reach protected integration before becoming release authority. +2. Envelope identity routing, SafeClipboard, autosave lifecycle observation, security disclosure, toolbar shortcut accessibility metadata, SSR/native-form serialization, revision-scoped selection evidence, and document-transition evidence are `implemented_on_protected_main` and must not be described as active-only work. +3. The canonical documentation graph is integrated on protected `main`; future reconciliation is required when protected source, accepted decisions, active implementation maturity, or release evidence materially changes. +4. Documentation becoming mergeable or protected-merged is not a reason for the commercial loop to stop; the next safe product, release, security, accessibility or interoperability lane must continue. ## Sufficiency decision -PRD, TRD, Architecture, ADR, UML, conceptual ERD/data model, contracts, threat model, test strategy, operability, security disclosure, and traceability are `present_current` for the durable product and accepted/planned architecture decisions reconstructed from the conversation and live repository. Autosave lifecycle observation, the security disclosure lifecycle, toolbar shortcut accessibility metadata, SSR/native-form serialization, revision-scoped selection evidence, and document-transition evidence are `implemented_on_protected_main`. SafeClipboard remains active-PR work. Envelope migration routing and cross-engine browser assurance are explicit Proposed ADR decisions while their implementations remain `planned`. A physical relational ERD is `not_applicable` because Inkspan deliberately owns no application persistence. +PRD, TRD, Architecture, ADR, UML, conceptual ERD/data model, contracts, threat model, test strategy, operability, security disclosure, and traceability are `present_current` for the durable product and accepted/planned architecture decisions reconstructed from the conversation and live repository. Envelope identity routing, SafeClipboard, autosave lifecycle observation, the security disclosure lifecycle, toolbar shortcut accessibility metadata, SSR/native-form serialization, revision-scoped selection evidence, and document-transition evidence are `implemented_on_protected_main`. Cross-engine browser assurance is `implemented_on_active_pr` and remains non-authoritative until protected integration. A physical relational ERD is `not_applicable` because Inkspan deliberately owns no application persistence. No material product architecture decision identified by this review remains only in chat or issue prose. Accordingly, the **documentation graph is a protected-main canonical baseline** and is sufficient for acquisition reconstruction under the current product boundary. Product/release readiness must continue to be evaluated independently of documentation completeness. diff --git a/docs/OPERABILITY.md b/docs/OPERABILITY.md index b5699a91..61e01b9f 100644 --- a/docs/OPERABILITY.md +++ b/docs/OPERABILITY.md @@ -39,6 +39,16 @@ Markdown/HTML/editor conversion and Office rendering are deterministic local ope File publication must follow the documented atomic/non-overwrite behavior. A caller-requested overwrite remains explicit. A failed write or validation does not authorize cleanup of unrelated host files. +## Cross-engine clipboard assurance operations + +SafeClipboard is shipped on protected `main`; the browser-realistic release assurance is implemented on the active browser-assurance PR and remains non-authoritative until protected integration. That active gate uses dependency-locked **Playwright 1.62.0** Chromium, Firefox, and WebKit projects and binds every result to one **exact source head**, one browser-test lock digest, and one corpus version. + +Treat missing, skipped, cancelled, provisioning-failed, incomplete, or semantically divergent browser evidence as a **fail closed** release condition. Do not silently drop one engine or substitute predecessor-head results. The first response to divergence is to determine whether the difference is a sanitizer/integration defect, a standards-permitted serialization difference, or a test/environment defect. Unsafe behavior is repaired at the runtime boundary test-first. A safe difference is admitted only with focused regression evidence, current authoritative **standards** basis, threat analysis, exact affected engine/version evidence, canonical interpretation, compatibility impact, and explicit **rollback**. + +Browser evidence contains only committed synthetic fixtures and bounded version/hash/timing metadata; no tenant document, credential, model data, authorization context, or production clipboard payload belongs in the evidence bundle. The test scenario permits only loopback harness requests; browser installation happens before the scenario as a pinned build prerequisite. + +A Playwright/browser revision upgrade is an operational compatibility event. Rebuild the browser evidence from the new immutable lock on one exact source head and rerun the complete corpus. If browser provisioning is unavailable, only the rich-clipboard release lane is blocked; unrelated Inkspan work continues. Rolling back the browser gate leaves the 0.6.0 rich-clipboard publication claim unaccepted unless equivalent or stronger real-engine assurance replaces it. + ## Release operations Release publication occurs only from an exact integrated protected head. Release evidence includes package artifacts, deterministic checksums, CI/security/package/provenance results, required review, zero valid unresolved findings, and repository-policy acceptance. The normative inventory and digest rules are defined by the `docs/CONTRACTS.md` Release and rollback contract. @@ -72,7 +82,7 @@ Do not publish or reuse the artifact. Rebuild from exact source with determinist ### Browser/parser divergence -When a browser-specific clipboard/security difference is found, add it to the cross-engine corpus. Accept a difference only with explicit standards basis and threat analysis. Do not normalize a security-relevant difference away solely to regain parity. +When a browser-specific clipboard/security difference is found, reproduce it in the dependency-locked cross-engine corpus on the exact affected source head and browser versions. Classify the semantic/security result before changing expectations. Accept a difference only with explicit standards basis, threat analysis, compatibility consequence, and rollback. Do not normalize a security-relevant difference away solely to regain parity, and never convert an unavailable required browser into a successful result. ### Dependency or workflow incident @@ -90,7 +100,8 @@ Rollback is boundary-specific: - autosave/observer feature: fall back to explicit `getSnapshot()`/host coordination without rewriting durable state; - collaboration adapter: detach the adapter without destroying the host provider or Yjs document; - Office renderer change: revert the deterministic renderer behavior and rebuild artifacts; do not modify host files outside the explicit output target; +- browser assurance: revert the faulty gate only while keeping the affected rich-clipboard release claim unaccepted; never retain a release claim after removing its required engine evidence; - documentation: supersede inaccurate decisions with an ADR and synchronized canonical docs rather than deleting history; - release: issue a verified corrective release or supported withdrawal action; preserve provenance and incident evidence. -Every rollback requires fresh exact-head tests and must not weaken authorization, tenant isolation, release provenance, or host ownership boundaries. +Every rollback requires fresh exact-head tests and must not weaken authorization, tenant isolation, release provenance, browser-security evidence, or host ownership boundaries. diff --git a/docs/TEST_STRATEGY.md b/docs/TEST_STRATEGY.md index 1b036ab0..cf1e59ea 100644 --- a/docs/TEST_STRATEGY.md +++ b/docs/TEST_STRATEGY.md @@ -22,7 +22,11 @@ Install or consume the packed artifacts rather than source-tree aliases. Verify ### Browser differential tests -Where browser fragment parsing or serialization participates in a security boundary, use dependency-locked Playwright coverage across Chromium, Firefox, and WebKit. The same adversarial corpus must run in all required engines. Differences are not normalized away merely to produce parity; every reviewed allowlisted difference requires a standards basis and threat analysis. Missing/skipped browsers are not successful release evidence. +Where browser fragment parsing or serialization participates in a security boundary, use dependency-locked Playwright coverage across Chromium, Firefox, and WebKit. SafeClipboard is already implemented on protected `main`; the cross-engine publication gate is implemented on the active browser-assurance PR and is not shipped until protected integration. + +The active gate pins **Playwright 1.62.0** in an isolated immutable browser-test lock and runs the same versioned synthetic corpus through the supported TipTap/ProseMirror `transformPastedHTML` path in named Chromium, Firefox, and WebKit projects on one **exact source head**. Evidence binds the corpus version, browser-test lock digest, Playwright version, actual browser versions, operating-system identity, and exact source head. The corpus covers active/resource/form content, hidden/Office/popover semantics, safe and unsafe links, malformed fragments, tables/lists, SVG/MathML, interactive/native fallback, byte/node/depth ceilings, hostile DOM capability failures, and a representative Word-like performance alarm. + +Differences are not normalized away merely to produce parity. The default gate has no generic normalization or broad engine allowlist; a permitted difference requires a focused regression fixture, authoritative standards basis, threat analysis, exact affected engine/version evidence, canonical interpretation, compatibility impact, and rollback. Missing, skipped, cancelled, incomplete, or divergent required browser evidence must fail closed rather than becoming successful release evidence. Passing the active PR does not become protected-main release authority until that exact implementation is reviewed and integrated. ### Office artifact tests @@ -47,7 +51,7 @@ Office Python uses a distinct language/tool contract. Across every advertised su At minimum, maintain regressions for: - duplicate JSON object names, negative zero, malformed JSON, malformed UTF-8, BOM, depth/value/string/byte limits, sparse/decorated/non-plain objects, symbols, accessors, proxies, reflection failures, and detached/cross-realm byte views; -- rich clipboard scripts, embeds, resources, forms, metadata, SVG/MathML, images, hidden subtrees, `dialog`, `details`, `popover`, Office `mso-hide`, CSS comments/escapes/case/whitespace, malformed fragments, tables/lists/formatting elements, unsafe links, and resource ceilings; +- rich clipboard scripts, embeds, resources, forms, metadata, SVG/MathML, images, hidden subtrees, `dialog`, `details`, `popover`, Office `mso-hide`, CSS comments/escapes/case/whitespace, malformed fragments, tables/lists/formatting elements, unsafe links, resource ceilings, real-engine parser/serializer differences, and hostile DOM capabilities; - SSR client-controlled form values, escaping, hydration continuity, reset behavior, and absence of server editor construction; - 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; @@ -66,7 +70,7 @@ A release candidate requires the exact integrated protected head to satisfy appl The release workflow must also satisfy the normative `docs/CONTRACTS.md` draft inventory contract: exactly one npm tarball, exactly one Office wheel, 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 0.6.0 rich-clipboard release line specifically requires the Chromium, Firefox, and WebKit differential gate before publication. Deterministic jsdom coverage remains useful but is not a substitute for browser-engine acceptance. +The 0.6.0 rich-clipboard release line specifically requires the dependency-locked **Playwright 1.62.0** Chromium, Firefox, and WebKit differential gate on the exact integrated protected source head before publication. Deterministic jsdom coverage remains useful but is not a substitute for browser-engine acceptance; active-PR browser evidence remains proposed evidence until protected integration. ## Documentation verification @@ -74,4 +78,4 @@ Documentation tests must compare canonical PRD/TRD/Architecture/ADR/UML/data-mod ## Rollback of a test gate -A gate may be changed only because its product contract changed or the gate itself is technically invalid. The replacement begins with a regression that demonstrates the mismatch. Do not disable, skip, broaden allowlists, or lower coverage/security thresholds merely to make a branch green. +A gate may be changed only because its product contract changed or the gate itself is technically invalid. The replacement begins with a regression that demonstrates the mismatch. Do not disable, skip, broaden allowlists, or lower coverage/security thresholds merely to make a branch green. Browser-gate rollback must leave the rich-clipboard publication claim unaccepted unless equivalent or stronger real-engine evidence replaces it. diff --git a/docs/clipboard-security.md b/docs/clipboard-security.md index a7c841bb..7e72f6d9 100644 --- a/docs/clipboard-security.md +++ b/docs/clipboard-security.md @@ -261,18 +261,35 @@ test-first evidence, standards interpretation, residual risk, and rollback. ## Browser evidence boundary -The current deterministic corpus runs in jsdom and proves the repository's -allowlist, bounds, error redaction, integration wiring, transform ordering, and -known Office/Google fixtures. It does not by itself prove parser, CSS, or -serialization parity across Chromium, Firefox, and WebKit. The doctoring record -therefore treats cross-engine differential execution as a release-acceptance -gate for the future 0.6.0 publication rather than claiming browser conformance -from jsdom evidence. +SafeClipboard itself is implemented on protected `main`. Deterministic jsdom +coverage remains the fast structural/security regression layer, but real browser +fragment parsing and serialization are a separate release-assurance authority. +The active cross-engine assurance PR pins **Playwright 1.62.0** and executes one +versioned synthetic **corpus version** through the supported TipTap/ProseMirror +paste pipeline in Chromium, Firefox, and WebKit on one **exact source head**. + +The browser gate records the exact source head, corpus version, browser-test lock +digest, Playwright version, actual engine versions, runner identity, and bounded +synthetic observations. It compares sanitized HTML, resulting ProseMirror JSON, +and rejection behavior and must fail closed when a required engine is missing, +skipped, cancelled, incomplete, or divergent. It uses no generic normalization; +a permitted difference requires a focused regression plus standards basis, +threat analysis, compatibility consequence, and rollback. The committed browser +fixtures are synthetic and the evidence contains no tenant document or +production clipboard payload. The detailed implementation and claim limits are +recorded in `docs/doctoring/cross-engine-rich-clipboard-assurance.md` and +`docs/TEST_STRATEGY.md`. + +Until the active browser-assurance PR reaches protected integration, its results +are `implemented_on_active_pr` evidence rather than shipped release authority. +The 0.6.0 rich-clipboard line must not be published without equivalent or stronger +exact-protected-head Chromium, Firefox, and WebKit acceptance. ## Ownership boundary Inkspan owns clipboard HTML validation, semantic reconstruction, shared editor -integration, bounded errors, and deterministic tests. The host still owns: +integration, bounded errors, deterministic tests, and its protected release +assurance contract. The host still owns: - clipboard permissions or custom clipboard APIs; - user notification and recovery UX; @@ -283,5 +300,7 @@ integration, bounded errors, and deterministic tests. The host still owns: - model or AI use of pasted content; and - legal, privacy, and information-governance policy. -The feature introduces no network request, storage adapter, credential, -database object, model call, or provider dependency. +The feature introduces no application network request, storage adapter, +credential, database object, model call, or provider dependency. Browser binary +provisioning belongs to CI/build evidence and does not grant runtime egress to the +sanitizer. diff --git a/docs/doctoring/cross-engine-rich-clipboard-assurance.md b/docs/doctoring/cross-engine-rich-clipboard-assurance.md new file mode 100644 index 00000000..91a22b64 --- /dev/null +++ b/docs/doctoring/cross-engine-rich-clipboard-assurance.md @@ -0,0 +1,49 @@ +# Cross-engine rich-clipboard release assurance + +Status: Implemented on active PR + +## Decision boundary + +Inkspan's SafeClipboard runtime is already integrated on protected `main`, but HTML fragment parsing, DOM reconstruction, CSS interpretation, serialization, and ProseMirror parsing are browser semantics. jsdom remains useful deterministic unit evidence; it is not real-engine conformance. The 0.6.0 rich-clipboard publication boundary therefore requires the same committed synthetic fixtures to execute through the supported TipTap/ProseMirror paste path in real Chromium, Firefox, and WebKit on one exact source head. + +This active implementation uses **Playwright 1.62.0** from the isolated `tests/browser/pnpm-lock.yaml`. That pinned Playwright release identifies Chromium 151.0.7922.34, Firefox 153, and WebKit 26.5 as its bundled browser versions. The CI evidence records the actual `browser.version()` value for each engine, the Playwright version, operating-system identity, corpus version, SHA-256 of the browser-test `pnpm-lock.yaml`, and the exact source head. Those observed values, rather than this prose, are the release evidence when a browser revision changes. + +## Test-first evidence + +RED commit `cd6945b642a5d48449e1a59581e728ce6b440ff6` added the permanent release-oracle contract before its implementation existed. Hosted CI failed at TypeScript resolution because `crossEngineClipboardEvidence` was deliberately absent. The contract also injects deliberate sanitized-HTML, ProseMirror-structure, rejection-behavior, missing-engine, duplicate-engine, and mixed-case divergences. The production oracle then implemented fail-closed three-engine consensus, and a later exact coverage run exposed and removed one unreachable fallback instead of excluding it from coverage. + +The browser corpus covers active content, external-resource and form subtrees, hidden CSS/ARIA/Office/popover content, safe and unsafe links, malformed formatting and paragraph reconstruction, table/list parser repair, SVG/MathML, interactive and native-widget fallback, semantic inline-style reconstruction, byte limits, node limits, and depth limits. A separate real-browser probe uses a revoked `Document` proxy to require stable redacted DOM-capability failure without reflecting the private source string. A representative Word-like fixture provides a generous release alarm rather than a universal performance benchmark. + +## Hermeticity and evidence minimization + +The browser scenario permits requests only to the loopback Vite harness and aborts any external request. SafeClipboard itself performs no network fetch. The workflow installs the exact browser revisions selected by pinned Playwright before the test scenario begins; browser provisioning is a build prerequisite, not application egress. + +The evidence files contain only public synthetic fixture identifiers, sanitized output, ProseMirror JSON produced from those synthetic fixtures, stable rejection codes, engine/runtime versions, lock digest, corpus version, source SHA, runner identity, and representative timing. They contain **no tenant document**, production clipboard payload, credential, model prompt/output, authorization context, user identity, or private local path. The committed corpus uses synthetic fixtures only. + +## Difference policy + +Security-relevant results must agree across Chromium, Firefox, and WebKit. The default comparator uses **no generic normalization** and no broad engine allowlist. A difference may be admitted only through a focused regression fixture and a reviewed rule that records the authoritative standards basis, exact affected engine/version, threat analysis, canonical interpretation, compatibility consequence, and rollback. An unexplained parser, sanitizer, error, or ProseMirror-structure difference must **fail closed**. + +The same rule applies when one project is missing, skipped, cancelled, unable to provision, or unable to emit exact-head evidence: the rich-clipboard release lane remains blocked. Other Inkspan work may continue; the browser gate itself does not become optional. + +## Compatibility and rollback + +Playwright/browser upgrades are compatibility events. Update the immutable browser-test lock, rerun every engine and the complete corpus, review any difference against current standards, and accept the new evidence only on the unchanged exact head. Do not transfer browser evidence from a predecessor commit. + +If the browser gate itself is faulty, rollback may revert the gate change while explicitly leaving the 0.6.0 rich-clipboard publication claim unaccepted. After protected integration, removing a required engine, weakening the corpus, broadening normalization, or replacing the exact-head evidence contract requires a superseding ADR and new threat analysis. A sanitizer defect discovered by the gate is fixed at the runtime boundary test-first rather than hidden in an engine-specific expectation. + +## Claim limits + +Passing these projects proves the committed SafeClipboard corpus and supported paste integration under the pinned Playwright engine builds on the recorded runner. It does not claim byte-identical behavior for every browser build, enterprise browser policy, extension environment, branded channel, downstream renderer, or arbitrary HTML. Hosts continue to own authorization, tenancy, persistence, CSP, application egress, deployment, model-use policy, and legal/privacy policy. + +## References + +Microsoft. (2026). *Playwright Test 1.62.0*. npm. https://www.npmjs.com/package/@playwright/test/v/1.62.0 + +Microsoft. (n.d.-a). *Browsers*. Playwright documentation. Retrieved August 10, 2026, from https://playwright.dev/docs/browsers + +Microsoft. (n.d.-b). *Projects*. Playwright documentation. Retrieved August 10, 2026, from https://playwright.dev/docs/test-projects + +Web Hypertext Application Technology Working Group. (2026). *HTML Standard: Parsing HTML documents* (Living Standard). Retrieved August 10, 2026, from https://html.spec.whatwg.org/multipage/parsing.html + +World Wide Web Consortium. (2026, June 24). *Clipboard API and events* (W3C Working Draft). https://www.w3.org/TR/2026/WD-clipboard-apis-20260624/ diff --git a/src/crossEngineClipboardDocumentation.test.ts b/src/crossEngineClipboardDocumentation.test.ts new file mode 100644 index 00000000..3b1c8622 --- /dev/null +++ b/src/crossEngineClipboardDocumentation.test.ts @@ -0,0 +1,59 @@ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { describe, expect, it } from 'vitest'; + +const documentText = (path: string): string => + readFileSync(resolve(process.cwd(), path), 'utf8').replace(/\s+/gu, ' ').trim(); + +describe('cross-engine clipboard documentation contract', () => { + it('documents the exact browser release gate and evidence identity', () => { + const guide = documentText('docs/clipboard-security.md'); + const doctoring = documentText( + 'docs/doctoring/cross-engine-rich-clipboard-assurance.md', + ); + const strategy = documentText('docs/TEST_STRATEGY.md'); + + for (const text of [guide, doctoring, strategy]) { + expect(text).toContain('Chromium'); + expect(text).toContain('Firefox'); + expect(text).toContain('WebKit'); + expect(text).toContain('Playwright 1.62.0'); + expect(text).toContain('exact source head'); + expect(text).toContain('corpus version'); + } + expect(doctoring).toContain('Chromium 151.0.7922.34'); + expect(doctoring).toContain('Firefox 153'); + expect(doctoring).toContain('WebKit 26.5'); + expect(doctoring).toContain('pnpm-lock.yaml'); + }); + + it('keeps browser differences fail-closed and narrowly reviewable', () => { + const doctoring = documentText( + 'docs/doctoring/cross-engine-rich-clipboard-assurance.md', + ); + const operability = documentText('docs/OPERABILITY.md'); + + for (const text of [doctoring, operability]) { + expect(text).toContain('fail closed'); + expect(text).toContain('standards'); + expect(text).toContain('rollback'); + } + expect(doctoring).toContain('no generic normalization'); + expect(doctoring).toContain('synthetic fixtures'); + expect(doctoring).toContain('no tenant document'); + }); + + it('records the current implementation maturity without calling the active PR shipped', () => { + const fitness = documentText('docs/DOCUMENTATION_FITNESS.md'); + const changelog = documentText('CHANGELOG.md'); + + expect(fitness).toContain( + 'Cross-engine browser-semantic release assurance', + ); + expect(fitness).toContain('`implemented_on_active_pr`'); + expect(fitness).toContain('SafeClipboard'); + expect(fitness).toContain('`implemented_on_protected_main`'); + expect(changelog).toContain('dependency-locked Chromium/Firefox/WebKit'); + expect(changelog).toContain('cross-engine rich-clipboard release gate'); + }); +}); diff --git a/src/crossEngineClipboardEvidence.test.ts b/src/crossEngineClipboardEvidence.test.ts new file mode 100644 index 00000000..a8430cf8 --- /dev/null +++ b/src/crossEngineClipboardEvidence.test.ts @@ -0,0 +1,118 @@ +import { describe, expect, it } from 'vitest'; +import { + SAFE_CLIPBOARD_CROSS_ENGINE_CORPUS, + assertCrossEngineClipboardConsensus, + type CrossEngineClipboardObservation, +} from './crossEngineClipboardEvidence.js'; + +const observation = ( + engine: 'chromium' | 'firefox' | 'webkit', + overrides: Partial = {}, +): CrossEngineClipboardObservation => ({ + caseId: 'active-script', + engine, + sanitizedHtml: '

safe

', + documentJson: { type: 'doc', content: [{ type: 'paragraph' }] }, + errorCode: null, + ...overrides, +}); + +describe('cross-engine rich clipboard release oracle', () => { + it('keeps one bounded adversarial corpus spanning the required semantic risk families', () => { + const families = new Set( + SAFE_CLIPBOARD_CROSS_ENGINE_CORPUS.map((testCase) => testCase.riskFamily), + ); + + expect(families).toEqual( + new Set([ + 'active-content', + 'hidden-content', + 'unsafe-link', + 'malformed-markup', + 'table-list', + 'svg-mathml', + 'parser-edge', + 'resource-limit', + ]), + ); + expect(SAFE_CLIPBOARD_CROSS_ENGINE_CORPUS.length).toBeGreaterThanOrEqual(18); + expect( + SAFE_CLIPBOARD_CROSS_ENGINE_CORPUS.every( + (testCase) => + testCase.id.length > 0 && + testCase.sourceHtml.length > 0 && + testCase.expectedSanitizedHtml !== undefined, + ), + ).toBe(true); + }); + + it('accepts identical sanitized HTML, ProseMirror structure, and rejection behavior across all engines', () => { + expect(() => + assertCrossEngineClipboardConsensus([ + observation('chromium'), + observation('firefox'), + observation('webkit'), + ]), + ).not.toThrow(); + }); + + it('fails closed when one engine reconstructs unsafe or divergent HTML', () => { + expect(() => + assertCrossEngineClipboardConsensus([ + observation('chromium'), + observation('firefox'), + observation('webkit', { + sanitizedHtml: '

safe

', + }), + ]), + ).toThrow(/sanitized HTML differs across browser engines/u); + }); + + it('fails closed when ProseMirror structure or rejection behavior diverges', () => { + expect(() => + assertCrossEngineClipboardConsensus([ + observation('chromium'), + observation('firefox'), + observation('webkit', { + documentJson: { + type: 'doc', + content: [{ type: 'heading', attrs: { level: 1 } }], + }, + }), + ]), + ).toThrow(/document structure differs across browser engines/u); + + expect(() => + assertCrossEngineClipboardConsensus([ + observation('chromium'), + observation('firefox'), + observation('webkit', { errorCode: 'invalid_html' }), + ]), + ).toThrow(/rejection behavior differs across browser engines/u); + }); + + it('rejects incomplete, duplicate, or mixed-case observations instead of silently weakening the gate', () => { + expect(() => + assertCrossEngineClipboardConsensus([ + observation('chromium'), + observation('firefox'), + ]), + ).toThrow(/exactly one observation from chromium, firefox, and webkit/u); + + expect(() => + assertCrossEngineClipboardConsensus([ + observation('chromium'), + observation('chromium'), + observation('webkit'), + ]), + ).toThrow(/exactly one observation from chromium, firefox, and webkit/u); + + expect(() => + assertCrossEngineClipboardConsensus([ + observation('chromium'), + observation('firefox', { caseId: 'different-case' }), + observation('webkit'), + ]), + ).toThrow(/same corpus case/u); + }); +}); diff --git a/src/crossEngineClipboardEvidence.ts b/src/crossEngineClipboardEvidence.ts new file mode 100644 index 00000000..2767e666 --- /dev/null +++ b/src/crossEngineClipboardEvidence.ts @@ -0,0 +1,247 @@ +import type { + ClipboardConfig, + ClipboardSanitizationErrorCode, +} from './extensions/SafeClipboard.js'; + +/** Version of the release corpus and its interpretation contract. */ +export const SAFE_CLIPBOARD_CROSS_ENGINE_CORPUS_VERSION = 1; + +/** Browser engines that must independently pass the rich-clipboard release gate. */ +export type CrossEngineClipboardEngine = 'chromium' | 'firefox' | 'webkit'; + +/** Security/semantic risk families exercised by the shared browser corpus. */ +export type CrossEngineClipboardRiskFamily = + | 'active-content' + | 'hidden-content' + | 'unsafe-link' + | 'malformed-markup' + | 'table-list' + | 'svg-mathml' + | 'parser-edge' + | 'resource-limit'; + +/** One immutable public fixture in the cross-engine rich-clipboard corpus. */ +export interface CrossEngineClipboardCase { + readonly id: string; + readonly riskFamily: CrossEngineClipboardRiskFamily; + readonly sourceHtml: string; + readonly expectedSanitizedHtml: string; + readonly expectedErrorCode: ClipboardSanitizationErrorCode | null; + readonly clipboardConfig?: ClipboardConfig; +} + +/** One browser observation used by the fail-closed release consensus oracle. */ +export interface CrossEngineClipboardObservation { + readonly caseId: string; + readonly engine: CrossEngineClipboardEngine; + readonly sanitizedHtml: string | null; + readonly documentJson: unknown | null; + readonly errorCode: ClipboardSanitizationErrorCode | null; +} + +const CORPUS: readonly CrossEngineClipboardCase[] = [ + { + id: 'active-script', + riskFamily: 'active-content', + sourceHtml: '

safe

', + expectedSanitizedHtml: '

safe

', + expectedErrorCode: null, + }, + { + id: 'active-resource-and-form', + riskFamily: 'active-content', + sourceHtml: + '
before
after
', + expectedSanitizedHtml: '
beforeafter
', + expectedErrorCode: null, + }, + { + id: 'hidden-display-and-aria', + riskFamily: 'hidden-content', + sourceHtml: + '

visibledisplayend

', + expectedSanitizedHtml: '

visibleend

', + expectedErrorCode: null, + }, + { + id: 'hidden-office-eof-comment', + riskFamily: 'hidden-content', + sourceHtml: + '

beforesecretafter

', + expectedSanitizedHtml: '

beforeafter

', + expectedErrorCode: null, + }, + { + id: 'hidden-content-visibility-popover', + riskFamily: 'hidden-content', + sourceHtml: + '
onetwothreefour
', + expectedSanitizedHtml: '
onefour
', + expectedErrorCode: null, + }, + { + id: 'unsafe-javascript-link', + riskFamily: 'unsafe-link', + sourceHtml: 'click', + expectedSanitizedHtml: 'click', + expectedErrorCode: null, + }, + { + id: 'safe-https-link', + riskFamily: 'unsafe-link', + sourceHtml: 'safe', + expectedSanitizedHtml: + 'safe', + expectedErrorCode: null, + }, + { + id: 'malformed-formatting', + riskFamily: 'malformed-markup', + sourceHtml: 'text', + expectedSanitizedHtml: 'text', + expectedErrorCode: null, + }, + { + id: 'malformed-paragraph', + riskFamily: 'malformed-markup', + sourceHtml: '

one

two', + expectedSanitizedHtml: '

one

two

', + expectedErrorCode: null, + }, + { + id: 'table-parser-repair', + riskFamily: 'table-list', + sourceHtml: '
cell
', + expectedSanitizedHtml: + '
cell
', + expectedErrorCode: null, + }, + { + id: 'ordered-list-repair', + riskFamily: 'table-list', + sourceHtml: '
  1. one
  2. two
', + expectedSanitizedHtml: '
  1. one
  2. two
', + expectedErrorCode: null, + }, + { + id: 'svg-and-mathml-subtrees', + riskFamily: 'svg-mathml', + sourceHtml: + '

axbyc

', + expectedSanitizedHtml: '

abc

', + expectedErrorCode: null, + }, + { + id: 'closed-details-summary', + riskFamily: 'parser-edge', + sourceHtml: '
label

secret

', + expectedSanitizedHtml: 'label', + expectedErrorCode: null, + }, + { + id: 'dialog-and-native-widget-fallback', + riskFamily: 'parser-edge', + sourceHtml: + 'closed

open

end

', + expectedSanitizedHtml: '

open

end

', + expectedErrorCode: null, + }, + { + id: 'semantic-style-reconstruction', + riskFamily: 'parser-edge', + sourceHtml: + 'styled', + expectedSanitizedHtml: 'styled', + expectedErrorCode: null, + }, + { + id: 'utf8-byte-ceiling', + riskFamily: 'resource-limit', + sourceHtml: '

private source

', + expectedSanitizedHtml: '', + expectedErrorCode: 'input_too_large', + clipboardConfig: { maxHtmlBytes: 1 }, + }, + { + id: 'node-ceiling', + riskFamily: 'resource-limit', + sourceHtml: '

onetwo

', + expectedSanitizedHtml: '', + expectedErrorCode: 'node_limit_exceeded', + clipboardConfig: { maxNodes: 2 }, + }, + { + id: 'depth-ceiling', + riskFamily: 'resource-limit', + sourceHtml: '

deep

', + expectedSanitizedHtml: '', + expectedErrorCode: 'depth_limit_exceeded', + clipboardConfig: { maxDepth: 1 }, + }, +] as const; + +/** Immutable adversarial corpus shared by every required browser project. */ +export const SAFE_CLIPBOARD_CROSS_ENGINE_CORPUS: readonly CrossEngineClipboardCase[] = + Object.freeze(CORPUS.map((testCase) => Object.freeze({ ...testCase }))); + +const REQUIRED_ENGINES: readonly CrossEngineClipboardEngine[] = Object.freeze([ + 'chromium', + 'firefox', + 'webkit', +]); + +/** + * Require exact rich-clipboard parity across one observation from every engine. + * + * The default gate intentionally contains no broad normalization or difference + * allowlist. A future standards-permitted engine exception must first add a + * focused corpus case, threat rationale, explicit comparison rule, and rollback + * note rather than being silently normalized here. + */ +export function assertCrossEngineClipboardConsensus( + observations: readonly CrossEngineClipboardObservation[], +): void { + const engines = observations.map((item) => item.engine); + if ( + observations.length !== REQUIRED_ENGINES.length || + REQUIRED_ENGINES.some( + (engine) => engines.filter((candidate) => candidate === engine).length !== 1, + ) + ) { + throw new Error( + 'Cross-engine clipboard evidence requires exactly one observation from chromium, firefox, and webkit.', + ); + } + + const [reference, ...others] = observations as readonly [ + CrossEngineClipboardObservation, + CrossEngineClipboardObservation, + CrossEngineClipboardObservation, + ]; + if (others.some((item) => item.caseId !== reference.caseId)) { + throw new Error( + 'Cross-engine clipboard evidence must describe the same corpus case.', + ); + } + if (others.some((item) => item.errorCode !== reference.errorCode)) { + throw new Error( + 'Cross-engine clipboard rejection behavior differs across browser engines.', + ); + } + if (others.some((item) => item.sanitizedHtml !== reference.sanitizedHtml)) { + throw new Error( + 'Cross-engine clipboard sanitized HTML differs across browser engines.', + ); + } + + const referenceDocument = canonicalJson(reference.documentJson); + if (others.some((item) => canonicalJson(item.documentJson) !== referenceDocument)) { + throw new Error( + 'Cross-engine clipboard document structure differs across browser engines.', + ); + } +} + +function canonicalJson(value: unknown): string { + return JSON.stringify(value); +} diff --git a/src/workflowExactHead.test.ts b/src/workflowExactHead.test.ts index 8a44b57a..ccda889a 100644 --- a/src/workflowExactHead.test.ts +++ b/src/workflowExactHead.test.ts @@ -16,14 +16,14 @@ const CHECKOUT_PIN = describe('exact-head CI workflow contract', () => { it('uses a fixed runner and checks out the immutable current PR head', () => { expect(workflow).not.toContain('ubuntu-latest'); - expect(workflow.match(/runs-on: ubuntu-24\.04/g)).toHaveLength(2); - expect(workflow.match(new RegExp(CHECKOUT_PIN, 'g'))).toHaveLength(2); + expect(workflow.match(/runs-on: ubuntu-24\.04/g)).toHaveLength(3); + expect(workflow.match(new RegExp(CHECKOUT_PIN, 'g'))).toHaveLength(3); expect( workflow.match( /ref: \$\{\{ github\.event\.pull_request\.head\.sha \|\| github\.sha \}\}/g, ), - ).toHaveLength(2); - expect(workflow.match(/persist-credentials: false/g)).toHaveLength(2); + ).toHaveLength(3); + expect(workflow.match(/persist-credentials: false/g)).toHaveLength(3); }); it('keeps the workflow read-only and hash-pins every third-party action', () => { diff --git a/tests/browser/harness.html b/tests/browser/harness.html new file mode 100644 index 00000000..1cc57ee4 --- /dev/null +++ b/tests/browser/harness.html @@ -0,0 +1,12 @@ + + + + + + Inkspan cross-engine clipboard harness + + +
+ + + diff --git a/tests/browser/harness.ts b/tests/browser/harness.ts new file mode 100644 index 00000000..459917e6 --- /dev/null +++ b/tests/browser/harness.ts @@ -0,0 +1,89 @@ +import { Editor } from '@tiptap/core'; +import { + ClipboardSanitizationError, + sanitizeRichClipboardHtml, + type ClipboardConfig, + type ClipboardSanitizationErrorCode, +} from '../../src/extensions/SafeClipboard.js'; +import { buildExtensions } from '../../src/extensions/kit.js'; + +interface BrowserClipboardProbeRequest { + readonly sourceHtml: string; + readonly clipboardConfig?: ClipboardConfig; +} + +interface BrowserClipboardProbeResult { + readonly sanitizedHtml: string; + readonly documentJson: unknown | null; + readonly errorCode: ClipboardSanitizationErrorCode | null; +} + +interface BrowserHostileDocumentProbeResult { + readonly errorCode: ClipboardSanitizationErrorCode | null; + readonly message: string; +} + +declare global { + interface Window { + runInkspanClipboardProbe( + request: BrowserClipboardProbeRequest, + ): BrowserClipboardProbeResult; + runInkspanHostileDocumentProbe( + sourceHtml: string, + ): BrowserHostileDocumentProbeResult; + } +} + +window.runInkspanClipboardProbe = ( + request: BrowserClipboardProbeRequest, +): BrowserClipboardProbeResult => { + let errorCode: ClipboardSanitizationErrorCode | null = null; + const editor = new Editor({ + element: document.createElement('div'), + extensions: buildExtensions({ + clipboard: request.clipboardConfig, + onClipboardError: (error) => { + errorCode = error.code; + }, + }), + content: '', + }); + + try { + let sanitizedHtml = request.sourceHtml; + editor.view.someProp('transformPastedHTML', (transform) => { + sanitizedHtml = transform(sanitizedHtml, editor.view); + }); + + if (errorCode !== null) { + return Object.freeze({ sanitizedHtml, documentJson: null, errorCode }); + } + + editor.commands.setContent(sanitizedHtml, false); + return Object.freeze({ + sanitizedHtml, + documentJson: editor.getJSON(), + errorCode: null, + }); + } finally { + editor.destroy(); + } +}; + +window.runInkspanHostileDocumentProbe = ( + sourceHtml: string, +): BrowserHostileDocumentProbeResult => { + const revoked = Proxy.revocable(document, {}); + revoked.revoke(); + try { + sanitizeRichClipboardHtml(sourceHtml, undefined, revoked.proxy as Document); + return Object.freeze({ errorCode: null, message: '' }); + } catch (error) { + if (error instanceof ClipboardSanitizationError) { + return Object.freeze({ errorCode: error.code, message: error.message }); + } + return Object.freeze({ errorCode: 'invalid_html', message: 'unclassified' }); + } +}; + +export {}; diff --git a/tests/browser/package.json b/tests/browser/package.json new file mode 100644 index 00000000..fc99e7c1 --- /dev/null +++ b/tests/browser/package.json @@ -0,0 +1,9 @@ +{ + "name": "inkspan-cross-engine-browser-tests", + "private": true, + "type": "module", + "packageManager": "pnpm@11.5.3", + "devDependencies": { + "@playwright/test": "1.62.0" + } +} diff --git a/tests/browser/playwright.config.ts b/tests/browser/playwright.config.ts new file mode 100644 index 00000000..8b49b262 --- /dev/null +++ b/tests/browser/playwright.config.ts @@ -0,0 +1,43 @@ +import { defineConfig, devices } from '@playwright/test'; + +const HARNESS_URL = 'http://127.0.0.1:4173/tests/browser/harness.html'; + +export default defineConfig({ + testDir: './specs', + outputDir: './test-results', + fullyParallel: false, + workers: 3, + retries: 0, + timeout: 20_000, + expect: { timeout: 5_000 }, + reporter: [['line']], + webServer: { + command: + 'pnpm --dir ../.. exec vite --host 127.0.0.1 --port 4173 --strictPort', + url: HARNESS_URL, + reuseExistingServer: false, + timeout: 120_000, + }, + projects: [ + { + name: 'chromium', + testMatch: /clipboard\.browser\.spec\.ts/u, + use: { ...devices['Desktop Chrome'], browserName: 'chromium' }, + }, + { + name: 'firefox', + testMatch: /clipboard\.browser\.spec\.ts/u, + use: { ...devices['Desktop Firefox'], browserName: 'firefox' }, + }, + { + name: 'webkit', + testMatch: /clipboard\.browser\.spec\.ts/u, + use: { ...devices['Desktop Safari'], browserName: 'webkit' }, + }, + { + name: 'consensus', + testMatch: /clipboard\.consensus\.spec\.ts/u, + dependencies: ['chromium', 'firefox', 'webkit'], + }, + ], +}); diff --git a/tests/browser/pnpm-lock.yaml b/tests/browser/pnpm-lock.yaml new file mode 100644 index 00000000..67f19004 --- /dev/null +++ b/tests/browser/pnpm-lock.yaml @@ -0,0 +1,49 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + .: + devDependencies: + '@playwright/test': + specifier: 1.62.0 + version: 1.62.0 + +packages: + '@playwright/test@1.62.0': + resolution: {integrity: sha512-9zOJ6ZQRAena31MpOH9VSzIz8Ou3YJ/wtY/eQm5T2uhfhG7/U3COrMS8xOtUrZrp9OgdmzEnIYODye3nY1VqzA==} + engines: {node: '>=20'} + hasBin: true + + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + playwright-core@1.62.0: + resolution: {integrity: sha512-nsNRyq0r2zsG8AcRHWknc9QRA5XCueC7gWMrs+Gx2tlZn9hcl8zudfh00lhJPY1DE7NmZ6bDsT9g2yey8mXljA==} + engines: {node: '>=20'} + hasBin: true + + playwright@1.62.0: + resolution: {integrity: sha512-Z14dG305dgaLu6foB1TXQagFiW8JfSUIUaUuPaKQ6NtBPKF1P/qXcqfh6c6K/icPqdy37JmjbiBXf6JNg6Sylw==} + engines: {node: '>=20'} + hasBin: true + +snapshots: + '@playwright/test@1.62.0': + dependencies: + playwright: 1.62.0 + + fsevents@2.3.2: + optional: true + + playwright-core@1.62.0: {} + + playwright@1.62.0: + dependencies: + playwright-core: 1.62.0 + optionalDependencies: + fsevents: 2.3.2 diff --git a/tests/browser/pnpm-workspace.yaml b/tests/browser/pnpm-workspace.yaml new file mode 100644 index 00000000..fcbac674 --- /dev/null +++ b/tests/browser/pnpm-workspace.yaml @@ -0,0 +1,4 @@ +packages: + - . + +onlyBuiltDependencies: [] diff --git a/tests/browser/specs/clipboard.browser.spec.ts b/tests/browser/specs/clipboard.browser.spec.ts new file mode 100644 index 00000000..47fb2d6b --- /dev/null +++ b/tests/browser/specs/clipboard.browser.spec.ts @@ -0,0 +1,172 @@ +import { createHash } from 'node:crypto'; +import { mkdir, readFile, writeFile } from 'node:fs/promises'; +import { fileURLToPath } from 'node:url'; +import { dirname, resolve } from 'node:path'; +import { expect, test } from '@playwright/test'; +import { + SAFE_CLIPBOARD_CROSS_ENGINE_CORPUS, + SAFE_CLIPBOARD_CROSS_ENGINE_CORPUS_VERSION, + type CrossEngineClipboardEngine, + type CrossEngineClipboardObservation, +} from '../../../src/crossEngineClipboardEvidence.js'; + +type BrowserProbe = (request: { + sourceHtml: string; + clipboardConfig?: unknown; +}) => { + sanitizedHtml: string; + documentJson: unknown | null; + errorCode: string | null; +}; + +type HostileDocumentProbe = (sourceHtml: string) => { + errorCode: string | null; + message: string; +}; + +const evidenceDirectory = resolve(dirname(fileURLToPath(import.meta.url)), '../.browser-evidence'); +const lockfilePath = resolve(dirname(fileURLToPath(import.meta.url)), '../pnpm-lock.yaml'); +const packagePath = resolve(dirname(fileURLToPath(import.meta.url)), '../package.json'); +const observations: CrossEngineClipboardObservation[] = []; +let representativeWordMillis: number | null = null; + +const allowHarnessRequest = (requestUrl: string): boolean => { + const url = new URL(requestUrl); + return url.hostname === '127.0.0.1' && url.port === '4173'; +}; + +test.describe.configure({ mode: 'serial' }); + +test.beforeEach(async ({ page }) => { + const rejectedExternalRequests: string[] = []; + await page.route('**/*', async (route) => { + if (allowHarnessRequest(route.request().url())) { + await route.continue(); + return; + } + rejectedExternalRequests.push(new URL(route.request().url()).origin); + await route.abort('blockedbyclient'); + }); + await page.goto('http://127.0.0.1:4173/tests/browser/harness.html'); + expect(rejectedExternalRequests).toEqual([]); +}); + +for (const testCase of SAFE_CLIPBOARD_CROSS_ENGINE_CORPUS) { + test(`sanitizes corpus case ${testCase.id}`, async ({ page, browserName }) => { + const rejectedExternalRequests: string[] = []; + page.on('request', (request) => { + if (!allowHarnessRequest(request.url())) { + rejectedExternalRequests.push(new URL(request.url()).origin); + } + }); + + const result = await page.evaluate( + ({ sourceHtml, clipboardConfig }) => + ( + window as unknown as { + runInkspanClipboardProbe: BrowserProbe; + } + ).runInkspanClipboardProbe({ sourceHtml, clipboardConfig }), + { + sourceHtml: testCase.sourceHtml, + clipboardConfig: testCase.clipboardConfig, + }, + ); + + expect(result.sanitizedHtml).toBe(testCase.expectedSanitizedHtml); + expect(result.errorCode).toBe(testCase.expectedErrorCode); + if (testCase.expectedErrorCode === null) { + expect(result.documentJson).not.toBeNull(); + } else { + expect(result.documentJson).toBeNull(); + } + expect(rejectedExternalRequests).toEqual([]); + + observations.push({ + caseId: testCase.id, + engine: browserName as CrossEngineClipboardEngine, + sanitizedHtml: result.sanitizedHtml, + documentJson: result.documentJson, + errorCode: result.errorCode as CrossEngineClipboardObservation['errorCode'], + }); + }); +} + +test('redacts hostile document capability failures without source disclosure', async ({ + page, +}) => { + const privateSource = '

private source must not escape

'; + const result = await page.evaluate( + (sourceHtml) => + ( + window as unknown as { + runInkspanHostileDocumentProbe: HostileDocumentProbe; + } + ).runInkspanHostileDocumentProbe(sourceHtml), + privateSource, + ); + + expect(result.errorCode).toBe('invalid_html'); + expect(result.message).toBe('Rich clipboard HTML could not be sanitized.'); + expect(result.message).not.toContain('private source'); +}); + +test('keeps representative Word-like sanitization within the release alarm budget', async ({ + page, +}) => { + const sourceHtml = `
${Array.from( + { length: 800 }, + (_, index) => + `

paragraph-${index}

`, + ).join('')}
`; + + const measurement = await page.evaluate((html) => { + const started = performance.now(); + const result = ( + window as unknown as { runInkspanClipboardProbe: BrowserProbe } + ).runInkspanClipboardProbe({ sourceHtml: html }); + return { elapsedMillis: performance.now() - started, result }; + }, sourceHtml); + + expect(measurement.result.errorCode).toBeNull(); + expect(measurement.elapsedMillis).toBeLessThan(8_000); + representativeWordMillis = Math.round(measurement.elapsedMillis * 100) / 100; +}); + +test.afterAll(async ({ browser, browserName }) => { + const lockfile = await readFile(lockfilePath); + const browserPackage = JSON.parse(await readFile(packagePath, 'utf8')) as { + devDependencies?: Record; + }; + const playwrightVersion = browserPackage.devDependencies?.['@playwright/test']; + if (playwrightVersion !== '1.62.0') { + throw new Error('Cross-engine browser evidence requires pinned @playwright/test 1.62.0.'); + } + if (observations.length !== SAFE_CLIPBOARD_CROSS_ENGINE_CORPUS.length) { + throw new Error('Cross-engine browser evidence is incomplete for the shared corpus.'); + } + if (representativeWordMillis === null) { + throw new Error('Cross-engine browser performance evidence is missing.'); + } + + await mkdir(evidenceDirectory, { recursive: true }); + const evidence = Object.freeze({ + schemaVersion: 1, + corpusVersion: SAFE_CLIPBOARD_CROSS_ENGINE_CORPUS_VERSION, + engine: browserName, + playwrightVersion, + browserVersion: browser.version(), + osPlatform: process.platform, + runnerImage: process.env.ImageOS ?? null, + headSha: + process.env.INKSPAN_EXPECTED_HEAD_SHA ?? process.env.GITHUB_SHA ?? null, + lockSha256: createHash('sha256').update(lockfile).digest('hex'), + representativeWordMillis, + observations, + }); + await writeFile( + resolve(evidenceDirectory, `${browserName}.json`), + `${JSON.stringify(evidence)}\n`, + 'utf8', + ); +}); diff --git a/tests/browser/specs/clipboard.consensus.spec.ts b/tests/browser/specs/clipboard.consensus.spec.ts new file mode 100644 index 00000000..6b4a73ef --- /dev/null +++ b/tests/browser/specs/clipboard.consensus.spec.ts @@ -0,0 +1,94 @@ +import { readFile } from 'node:fs/promises'; +import { fileURLToPath } from 'node:url'; +import { dirname, resolve } from 'node:path'; +import { expect, test } from '@playwright/test'; +import { + SAFE_CLIPBOARD_CROSS_ENGINE_CORPUS, + SAFE_CLIPBOARD_CROSS_ENGINE_CORPUS_VERSION, + assertCrossEngineClipboardConsensus, + type CrossEngineClipboardEngine, + type CrossEngineClipboardObservation, +} from '../../../src/crossEngineClipboardEvidence.js'; + +interface BrowserEvidence { + readonly schemaVersion: number; + readonly corpusVersion: number; + readonly engine: CrossEngineClipboardEngine; + readonly playwrightVersion: string; + readonly browserVersion: string; + readonly osPlatform: string; + readonly runnerImage: string | null; + readonly headSha: string | null; + readonly lockSha256: string; + readonly representativeWordMillis: number; + readonly observations: readonly CrossEngineClipboardObservation[]; +} + +const evidenceDirectory = resolve(dirname(fileURLToPath(import.meta.url)), '../.browser-evidence'); +const engines: readonly CrossEngineClipboardEngine[] = [ + 'chromium', + 'firefox', + 'webkit', +]; + +const readEvidence = async ( + engine: CrossEngineClipboardEngine, +): Promise => + JSON.parse( + await readFile(resolve(evidenceDirectory, `${engine}.json`), 'utf8'), + ) as BrowserEvidence; + +test('requires complete exact-head browser evidence and exact corpus consensus', async () => { + const evidence = await Promise.all(engines.map(readEvidence)); + const [reference] = evidence; + if (!reference) throw new Error('Cross-engine browser evidence is missing.'); + + for (const [index, item] of evidence.entries()) { + expect(item.schemaVersion).toBe(1); + expect(item.corpusVersion).toBe(SAFE_CLIPBOARD_CROSS_ENGINE_CORPUS_VERSION); + expect(item.engine).toBe(engines[index]); + expect(item.playwrightVersion).toBe('1.62.0'); + expect(item.browserVersion.length).toBeGreaterThan(0); + expect(item.lockSha256).toBe(reference.lockSha256); + expect(item.headSha).toBe(reference.headSha); + expect(item.observations).toHaveLength(SAFE_CLIPBOARD_CROSS_ENGINE_CORPUS.length); + expect(item.representativeWordMillis).toBeGreaterThanOrEqual(0); + expect(item.representativeWordMillis).toBeLessThan(8_000); + } + + if (process.env.GITHUB_ACTIONS === 'true') { + expect(reference.headSha).toBe(process.env.INKSPAN_EXPECTED_HEAD_SHA); + expect(reference.runnerImage).not.toBeNull(); + } + + for (const testCase of SAFE_CLIPBOARD_CROSS_ENGINE_CORPUS) { + const observations = evidence.map((item) => { + const observation = item.observations.find( + (candidate) => candidate.caseId === testCase.id, + ); + if (!observation) { + throw new Error( + `Cross-engine browser evidence is missing corpus case ${testCase.id}.`, + ); + } + return observation; + }); + assertCrossEngineClipboardConsensus(observations); + } + + const summary = { + schemaVersion: 1, + corpusVersion: SAFE_CLIPBOARD_CROSS_ENGINE_CORPUS_VERSION, + headSha: reference.headSha, + lockSha256: reference.lockSha256, + playwrightVersion: reference.playwrightVersion, + engines: evidence.map((item) => ({ + engine: item.engine, + browserVersion: item.browserVersion, + osPlatform: item.osPlatform, + runnerImage: item.runnerImage, + representativeWordMillis: item.representativeWordMillis, + })), + }; + console.log(`[inkspan-cross-engine-evidence] ${JSON.stringify(summary)}`); +});