From c7fe7e759f2fa93dabce6f49fa7c07cd5fe95067 Mon Sep 17 00:00:00 2001 From: Michael Heller <21163552+mdheller@users.noreply.github.com> Date: Wed, 29 Jul 2026 17:14:24 -0400 Subject: [PATCH 1/2] =?UTF-8?q?fix(pdf-viewer-demo):=20use=20assertEnabled?= =?UTF-8?q?=20at=20the=20actual=20gate=20=E2=80=94=20deny()=20now=20bites?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes a register item: capability-ledger.deny() writes a blocked_by_policy receipt into the ledger, but the sole production caller (the pdf-viewer demo) used isEnabled() at the use-gate. isEnabled reports 'blocked_by_policy' as false and drops silently — a policy refusal indistinguishable from a not-yet-declared capability, which is the entire failure mode the register item names. - Inline CapabilityLedger drifted from the packaged version: it lacked assertEnabled and let enable() take a null policyDecisionRef. Both fixed; the packaged behaviour (enable requires a non-empty pdr, assertEnabled throws on any non-enabled state naming the reason) is now mirrored. - Click handlers use assertEnabled via a gatedClick wrapper; the diagnostic surfaces in feature-output so the refusal is visible instead of dropped. - Buttons stay clickable even when not enabled: the enforcement is at the click site, and letting the click fire is what surfaces the diagnostic. - Added pdf-export capability that is denied by policy, so the deny path is now demonstrated end-to-end alongside pdf-sign (missing_plugin) and pdf-viewer (enabled). Package tests unchanged, 38/38 pass on origin/main head. This PR only touches the demo — the surface most likely to drift because it's a manual copy of the module. --- apps/pdf-viewer-demo/index.html | 91 ++++++++++++++++++++++++++++++--- 1 file changed, 83 insertions(+), 8 deletions(-) diff --git a/apps/pdf-viewer-demo/index.html b/apps/pdf-viewer-demo/index.html index f571740..a73af75 100644 --- a/apps/pdf-viewer-demo/index.html +++ b/apps/pdf-viewer-demo/index.html @@ -145,6 +145,10 @@

Feature Gate Demo

+
+ + +
@@ -187,7 +191,17 @@

Feature Gate Demo

request(id, owner, opts = {}) { return this._emit(id, 'requested', owner, opts); } negotiate(id, owner, opts = {}) { return this._emit(id, 'negotiating', owner, opts); } setAvailable(id, owner, opts = {}) { return this._emit(id, 'available', owner, opts); } - enable(id, owner, pdr = null, ev = []) { return this._emit(id, 'enabled', owner, { policyDecisionRef: pdr, evidenceRefs: ev }); } + // enable REQUIRES a policyDecisionRef — see packages/capability-ledger/src/index.js: + // 'enabled' is the only state that authorises use, so recording it without naming + // the decision that authorised it produces a grant indistinguishable from an + // adjudicated one. The inline copy previously accepted `pdr = null`, drifting from + // the packaged behaviour it exists to mirror. + enable(id, owner, pdr, ev = []) { + if (typeof pdr !== 'string' || pdr.trim() === '') { + throw new Error(`capability "${id}" cannot be enabled without a policyDecisionRef`); + } + return this._emit(id, 'enabled', owner, { policyDecisionRef: pdr, evidenceRefs: ev }); + } deny(id, owner, pdr = null, ev = []) { return this._emit(id, 'blocked_by_policy', owner, { policyDecisionRef: pdr, evidenceRefs: ev }); } degrade(id, owner, ev = []) { return this._emit(id, 'degraded', owner, { evidenceRefs: ev }); } setUnsupportedByRuntime(id, owner, ev = []) { return this._emit(id, 'unsupported_by_runtime', owner, { evidenceRefs: ev }); } @@ -214,7 +228,27 @@

Feature Gate Demo

getState(id) { return this._receipts.get(id)?.state ?? null; } getReceipt(id) { return this._receipts.get(id) ?? null; } getAll() { return Array.from(this._receipts.values()); } + + // isEnabled returns a value a caller may ignore; every caller that forgets it + // fails OPEN. Left in for UI-state readouts (button styling, gate label) where + // the boolean IS the answer being asked. For actual use-gates, prefer + // assertEnabled, which throws and cannot be silently ignored. isEnabled(id) { return this.getState(id) === 'enabled'; } + + // Fail-closed gate — parity with packages/capability-ledger/src/index.js. + // Distinguishes 'blocked_by_policy' (deny receipt exists) from 'not in ledger', + // so a refusal is diagnosable and cites its policyDecisionRef when there is one. + assertEnabled(id) { + const state = this.getState(id); + if (state === 'enabled') return this.getReceipt(id); + const receipt = this.getReceipt(id); + const because = receipt?.policyDecisionRef ? ` (policy decision: ${receipt.policyDecisionRef})` : ''; + throw new Error( + state == null + ? `capability "${id}" is not in the ledger; refusing use of an undeclared capability` + : `capability "${id}" is "${state}", not "enabled"${because}; refusing use`, + ); + } } // ── Bootstrap ledger with PDF viewer demo capabilities ──────────────── @@ -239,6 +273,16 @@

Feature Gate Demo

['plugin:ink-sign:not-installed'], ); + // pdf-export: denied by policy. Its click handler exercises the case the + // register item was open on: a deny() receipt exists, isEnabled reports false + // and drops silently, assertEnabled throws citing the policyDecisionRef. + ledger.declare('pdf-export', 'UI'); + ledger.deny( + 'pdf-export', 'policy', + 'policy:deny-pdf-export:dlp-v2', + ['audit:dlp-review:2026-07'], + ); + // live-collab: unsupported by server ledger.declare('live-collab', 'UI'); ledger.setUnsupportedByServer( @@ -305,7 +349,11 @@

Feature Gate Demo

const enabled = ledger.isEnabled(capabilityId); const state = ledger.getState(capabilityId) ?? 'unknown'; - btn.disabled = !enabled; + // Buttons stay clickable even when the capability is not enabled: the + // enforcement is at the click site (assertEnabled), and letting the click + // fire is what surfaces the diagnostic — the point of closing the + // "deny records but blocks nothing" register item. + btn.disabled = false; btn.className = enabled ? 'btn-ok' : 'btn-deny'; gate.textContent = enabled ? '✓ enabled' @@ -313,19 +361,46 @@

Feature Gate Demo

gate.style.color = enabled ? 'var(--color-enabled)' : 'var(--color-blocked)'; } - updateGate('pdf-viewer', 'btn-pdf-view', 'gate-pdf-view'); - updateGate('pdf-sign', 'btn-pdf-sign', 'gate-pdf-sign'); + updateGate('pdf-viewer', 'btn-pdf-view', 'gate-pdf-view'); + updateGate('pdf-sign', 'btn-pdf-sign', 'gate-pdf-sign'); + updateGate('pdf-export', 'btn-pdf-export', 'gate-pdf-export'); const output = document.getElementById('feature-output'); + // Use assertEnabled at the actual use-gate: an isEnabled check a caller can ignore + // is the same defect as no check. ledger.deny(...) records a 'blocked_by_policy' + // receipt, which isEnabled reports as false and drops silently — a policy refusal + // indistinguishable from a not-yet-declared capability. assertEnabled distinguishes + // them and cites the policyDecisionRef when there is one. + function gatedClick(capabilityId, onGranted) { + try { + ledger.assertEnabled(capabilityId); + onGranted(); + } catch (err) { + output.textContent = `⛔ ${err.message}`; + output.style.color = 'var(--color-blocked)'; + } + } + document.getElementById('btn-pdf-view').addEventListener('click', () => { - if (!ledger.isEnabled('pdf-viewer')) return; - output.textContent = '📄 PDF viewer opened (capability confirmed enabled by ledger).'; + gatedClick('pdf-viewer', () => { + output.textContent = '📄 PDF viewer opened (capability confirmed enabled by ledger).'; + output.style.color = ''; + }); }); document.getElementById('btn-pdf-sign').addEventListener('click', () => { - if (!ledger.isEnabled('pdf-sign')) return; - output.textContent = '✍ PDF signed.'; + gatedClick('pdf-sign', () => { + output.textContent = '✍ PDF signed.'; + output.style.color = ''; + }); + }); + + document.getElementById('btn-pdf-export').addEventListener('click', () => { + gatedClick('pdf-export', () => { + output.textContent = '📤 PDF exported.'; + output.style.color = ''; + }); }); From 78c702b914449196ce3c54824325727d9a776da3 Mon Sep 17 00:00:00 2001 From: Michael Heller Date: Mon, 3 Aug 2026 02:09:42 -0400 Subject: [PATCH 2/2] feat(mesh): MeshTransfer schema + fail-closed admit (E3 first code) Every cross-device transfer (Handoff/drop/cast/message/sync) is an egress act; admit() grants only on an explicit policy decision + hash-sealed receipt, else refuses fail-closed (data-namespace egress additionally needs a region toleration). Spec: sourceos-spec e3-mesh-transport.md. 9 tests. --- schemas/mesh-transfer.schema.json | 63 ++++++++++++++++++++++++ tests/test_mesh_transfer.py | 68 ++++++++++++++++++++++++++ tools/mesh_transfer.py | 80 +++++++++++++++++++++++++++++++ 3 files changed, 211 insertions(+) create mode 100644 schemas/mesh-transfer.schema.json create mode 100644 tests/test_mesh_transfer.py create mode 100644 tools/mesh_transfer.py diff --git a/schemas/mesh-transfer.schema.json b/schemas/mesh-transfer.schema.json new file mode 100644 index 0000000..dc24ca4 --- /dev/null +++ b/schemas/mesh-transfer.schema.json @@ -0,0 +1,63 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://schemas.sourceos.ai/sourceos-shell/mesh-transfer.schema.json", + "title": "MeshTransfer", + "description": "A single cross-device transfer over the SourceOS personal mesh (E3): Handoff, AirDrop-style drop, AirPlay-style cast, message, or a tenant sync delta. Every transfer is an egress-purpose act and MUST carry the consent decision that admitted it plus a hash-sealed receipt. See docs/contract-additions/e3-mesh-transport.md in sourceos-spec.", + "type": "object", + "additionalProperties": false, + "required": [ + "transferId", + "specVersion", + "kind", + "fromDevice", + "toDevice", + "purpose", + "payloadClass", + "consent", + "receipt" + ], + "properties": { + "transferId": { "type": "string", "pattern": "^urn:srcos:mesh-transfer:" }, + "specVersion": { "type": "string", "const": "0.1" }, + "kind": { + "type": "string", + "enum": ["handoff", "drop", "cast", "message", "sync"], + "description": "handoff=app-state continuation; drop=file/clipboard (AirDrop parity); cast=A/V stream (AirPlay parity); message=Matrix substrate; sync=memory-mesh/hellgraph-federated delta." + }, + "fromDevice": { "type": "string", "pattern": "^urn:srcos:device:" }, + "toDevice": { "type": "string", "pattern": "^urn:srcos:device:" }, + "purpose": { + "type": "string", + "const": "egress", + "description": "A cross-device transfer always crosses a device boundary; its consent purpose is always egress." + }, + "payloadClass": { + "type": "string", + "enum": ["clipboard", "file", "app-state", "av-stream", "graph-delta", "memory-delta"] + }, + "payloadRef": { "type": "string", "description": "Opaque handle to the payload; the transport moves ciphertext, this record never inlines content." }, + "consent": { + "type": "object", + "additionalProperties": false, + "required": ["policyDecisionRef", "grantedBy", "space"], + "properties": { + "policyDecisionRef": { + "type": "string", + "pattern": "^urn:srcos:policy-decision:", + "description": "The purpose_admissibility_gate decision that admitted this egress. Absent => refused fail-closed." + }, + "grantedBy": { "type": "string", "description": "Authority that consented (human:* or a delegated agent grant)." }, + "space": { + "type": "string", + "enum": ["kernel-space", "system-space", "user-space", "agent-space", "data-namespace"] + }, + "region": { "type": "string", "description": "Jurisdiction toleration presented (GDPR Ch. V). Required when space=data-namespace." } + } + }, + "receipt": { + "type": "string", + "pattern": "^sha256:[0-9a-f]{8,64}$", + "description": "AutonomyAdmissionReceipt seal over this transfer. Absent => not receipted => refused." + } + } +} diff --git a/tests/test_mesh_transfer.py b/tests/test_mesh_transfer.py new file mode 100644 index 0000000..cca9de3 --- /dev/null +++ b/tests/test_mesh_transfer.py @@ -0,0 +1,68 @@ +from tools.mesh_transfer import admit + + +def _ok(**over): + t = { + "transferId": "urn:srcos:mesh-transfer:1", + "specVersion": "0.1", + "kind": "drop", + "fromDevice": "urn:srcos:device:laptop", + "toDevice": "urn:srcos:device:phone", + "purpose": "egress", + "payloadClass": "file", + "consent": { + "policyDecisionRef": "urn:srcos:policy-decision:abc", + "grantedBy": "human:michael", + "space": "user-space", + }, + "receipt": "sha256:deadbeef", + } + t.update(over) + return t + + +def test_admits_a_fully_consented_receipted_drop(): + d = admit(_ok()) + assert d.allow is True + assert "admitted" in d.reason + + +def test_denies_missing_policy_decision(): + t = _ok() + del t["consent"]["policyDecisionRef"] + assert admit(t).allow is False + + +def test_denies_missing_receipt(): + assert admit(_ok(receipt=None)).allow is False + assert admit(_ok(receipt="notasha")).allow is False + + +def test_denies_non_egress_purpose(): + assert admit(_ok(purpose="operate")).allow is False + + +def test_denies_unknown_kind(): + assert admit(_ok(kind="teleport")).allow is False + + +def test_denies_same_device(): + assert admit(_ok(toDevice="urn:srcos:device:laptop")).allow is False + + +def test_denies_non_device_endpoint(): + assert admit(_ok(fromDevice="laptop")).allow is False + + +def test_data_namespace_requires_region(): + t = _ok() + t["consent"]["space"] = "data-namespace" + assert admit(t).allow is False # no region + t["consent"]["region"] = "EU" + assert admit(t).allow is True + + +def test_denies_empty_and_garbage(): + assert admit({}).allow is False + assert admit(None).allow is False + assert admit("nope").allow is False diff --git a/tools/mesh_transfer.py b/tools/mesh_transfer.py new file mode 100644 index 0000000..e184869 --- /dev/null +++ b/tools/mesh_transfer.py @@ -0,0 +1,80 @@ +"""Fail-closed admission for personal-mesh transfers (E3). + +A MeshTransfer (Handoff / drop / cast / message / sync) crosses a device boundary, +so it is always an ``egress``-purpose act. Following the shell's fail-closed +convention (a gate that grants only on an explicit policy decision — see +sourceos-shell#31), a transfer is admitted ONLY when it carries the consent +decision that admitted the egress AND a hash-sealed receipt. Anything missing, +malformed, or contradictory is refused; there is no "just this once" bypass. + +Pure/stdlib-only so it validates the same way in CI, on device, and in tests. +The receiver surfaces a refusal as a pending prompt in the E11 pane rather than +silently succeeding. +""" + +from __future__ import annotations + +from typing import Any, Dict, NamedTuple + +KINDS = {"handoff", "drop", "cast", "message", "sync"} +SPACES = {"kernel-space", "system-space", "user-space", "agent-space", "data-namespace"} + + +class Decision(NamedTuple): + allow: bool + reason: str + + +def _sealed(receipt: Any) -> bool: + return ( + isinstance(receipt, str) + and receipt.startswith("sha256:") + and 8 <= len(receipt) - len("sha256:") <= 64 + and all(c in "0123456789abcdef" for c in receipt[len("sha256:"):]) + ) + + +def admit(transfer: Dict[str, Any]) -> Decision: + """Return a fail-closed Decision for one MeshTransfer record. + + Deny (never raise) on any missing/contradictory field so a caller can only + proceed on an explicit ``allow``. + """ + if not isinstance(transfer, dict): + return Decision(False, "transfer is not an object") + + kind = transfer.get("kind") + if kind not in KINDS: + return Decision(False, f"unknown transfer kind: {kind!r}") + + # A cross-device transfer is egress by construction; refuse anything claiming otherwise. + if transfer.get("purpose") != "egress": + return Decision(False, "transfer purpose must be 'egress' (crosses a device boundary)") + + for endpoint in ("fromDevice", "toDevice"): + val = transfer.get(endpoint) + if not (isinstance(val, str) and val.startswith("urn:srcos:device:")): + return Decision(False, f"{endpoint} is not a device URN") + if transfer["fromDevice"] == transfer["toDevice"]: + return Decision(False, "fromDevice == toDevice (not a transfer)") + + consent = transfer.get("consent") + if not isinstance(consent, dict): + return Decision(False, "no consent block — refused fail-closed") + + ref = consent.get("policyDecisionRef") + if not (isinstance(ref, str) and ref.startswith("urn:srcos:policy-decision:")): + return Decision(False, "no admitting policy decision — refused fail-closed") + + space = consent.get("space") + if space not in SPACES: + return Decision(False, f"unknown consent space: {space!r}") + + # GDPR Ch. V: leaving the data-namespace needs an explicit region toleration. + if space == "data-namespace" and not consent.get("region"): + return Decision(False, "data-namespace egress requires a region toleration (Ch. V)") + + if not _sealed(transfer.get("receipt")): + return Decision(False, "no valid receipt seal — not receipted => refused") + + return Decision(True, f"admitted: {kind} egress from {space} (decision {ref})")