From d817e65d91e8978b5b7d5fd906c95c6033596035 Mon Sep 17 00:00:00 2001 From: Eva Date: Wed, 15 Jul 2026 02:49:47 +0700 Subject: [PATCH 01/14] docs(mac-access): freeze standalone connector architecture --- ...1-independent-product-shared-repository.md | 128 +++ docs/evaos/mac-access/architecture.md | 338 ++++++ docs/evaos/mac-access/migration-map.md | 160 +++ docs/evaos/mac-access/threat-model.md | 153 +++ packages/mac-connector-core/README.md | 7 + .../v1/fixtures/invalid/binding.json | 117 +++ .../v1/fixtures/invalid/identity.json | 50 + .../contracts/v1/fixtures/invalid/policy.json | 303 ++++++ .../v1/fixtures/invalid/transport.json | 84 ++ .../v1/fixtures/valid/access-state.json | 31 + .../valid/access-transition-stop.json | 71 ++ .../v1/fixtures/valid/access-transition.json | 60 ++ .../v1/fixtures/valid/audit-chain-golden.json | 55 + .../v1/fixtures/valid/audit-event.json | 25 + .../v1/fixtures/valid/broker-control.json | 83 ++ .../valid/command-authority-golden.json | 34 + .../v1/fixtures/valid/full-access-state.json | 31 + .../v1/fixtures/valid/local-action.json | 19 + .../v1/fixtures/valid/local-status.json | 111 ++ .../golden/rollback-authorization-golden.json | 32 + .../mac-connector-core/contracts/v1/index.ts | 974 ++++++++++++++++++ .../tests/FixtureSmoke.swift | 27 + .../mac-connector-core/tests/fixture_smoke.py | 19 + tests/contract/macAccessContracts.test.ts | 184 ++++ tests/contract/macAccessCryptography.test.ts | 87 ++ vitest.config.ts | 1 + 26 files changed, 3184 insertions(+) create mode 100644 docs/evaos/mac-access/adr-0001-independent-product-shared-repository.md create mode 100644 docs/evaos/mac-access/architecture.md create mode 100644 docs/evaos/mac-access/migration-map.md create mode 100644 docs/evaos/mac-access/threat-model.md create mode 100644 packages/mac-connector-core/README.md create mode 100644 packages/mac-connector-core/contracts/v1/fixtures/invalid/binding.json create mode 100644 packages/mac-connector-core/contracts/v1/fixtures/invalid/identity.json create mode 100644 packages/mac-connector-core/contracts/v1/fixtures/invalid/policy.json create mode 100644 packages/mac-connector-core/contracts/v1/fixtures/invalid/transport.json create mode 100644 packages/mac-connector-core/contracts/v1/fixtures/valid/access-state.json create mode 100644 packages/mac-connector-core/contracts/v1/fixtures/valid/access-transition-stop.json create mode 100644 packages/mac-connector-core/contracts/v1/fixtures/valid/access-transition.json create mode 100644 packages/mac-connector-core/contracts/v1/fixtures/valid/audit-chain-golden.json create mode 100644 packages/mac-connector-core/contracts/v1/fixtures/valid/audit-event.json create mode 100644 packages/mac-connector-core/contracts/v1/fixtures/valid/broker-control.json create mode 100644 packages/mac-connector-core/contracts/v1/fixtures/valid/command-authority-golden.json create mode 100644 packages/mac-connector-core/contracts/v1/fixtures/valid/full-access-state.json create mode 100644 packages/mac-connector-core/contracts/v1/fixtures/valid/local-action.json create mode 100644 packages/mac-connector-core/contracts/v1/fixtures/valid/local-status.json create mode 100644 packages/mac-connector-core/contracts/v1/golden/rollback-authorization-golden.json create mode 100644 packages/mac-connector-core/contracts/v1/index.ts create mode 100644 packages/mac-connector-core/tests/FixtureSmoke.swift create mode 100644 packages/mac-connector-core/tests/fixture_smoke.py create mode 100644 tests/contract/macAccessContracts.test.ts create mode 100644 tests/contract/macAccessCryptography.test.ts diff --git a/docs/evaos/mac-access/adr-0001-independent-product-shared-repository.md b/docs/evaos/mac-access/adr-0001-independent-product-shared-repository.md new file mode 100644 index 0000000000..7a804508bd --- /dev/null +++ b/docs/evaos/mac-access/adr-0001-independent-product-shared-repository.md @@ -0,0 +1,128 @@ +# ADR-0001: independent Mac product in the evaOS-GUI repository + +- Status: proposed for #699; blocks implementation issues +- Date: 2026-07-15 +- Decision owners: evaOS Mac Access epic #698 and architecture issue #699 +- Inspected source: evaOS-GUI PR #697 head `ae9f35062f8a4b212fab4576d44602b57dc0303a` + +## Context + +The customer-facing goal is a standalone native macOS menu-bar connector that does not require evaOS Workbench. At the same time, Workbench must coexist as a client of the same connector behavior, and PR #697 establishes `100yenadmin/evaOS-GUI` as the owned connector-source repository. + +The prior `electricsheephq/evaos-desktop-bridge` repository is archived. It is useful provenance but cannot remain implementation truth or return as a private build/runtime dependency. Duplicating its source into a separate Mac Access repository would immediately create two policy, audit, transport, helper, and migration owners. Keeping Mac Access inside the Workbench application target would preserve the opposite problem: Workbench would remain a customer dependency and could continue owning TCC, connector lifecycle, and release timing. + +macOS code identity, TCC continuity, signing/notarization, updater metadata, rollback, and uninstall behavior are product/artifact concerns. Repository location alone neither couples nor isolates them; build graph, bundle identity, credentials, workflows, artifacts, appcasts, and release authorization do. + +## Decision + +Keep Mac Access and the reusable connector core in `100yenadmin/evaOS-GUI`, but ship Mac Access as an independent product. + +- `packages/mac-connector-core` is the single source for versioned contracts, embedded Python behavior, native ports, and parity tests. +- `packages/mac-access` owns the native menu app, signed helper, embedded runtime resources, tests, and product-specific build scripts. +- `packages/desktop` consumes Mac Access only through the authenticated local client protocol after #704. It does not link or copy a second connector runtime into an authoritative Workbench path. +- Mac Access uses frozen bundle/helper/service identities, its own signing/notarization job, updater/appcast namespace, artifacts, release notes, rollback metadata, and installed-app proof. +- Workbench v2.1.36 and later Workbench releases retain their own independent release truth. A Mac Access change cannot edit, delay, reuse, or publish a Workbench tag, artifact, appcast, draft, or update channel. +- The archived bridge repository is never cloned by CI, referenced as a package/source URL, or loaded at runtime. + +## Repository and build boundaries + +Sharing a Git repository permits atomic contract changes and one review graph. It does not permit implicit product coupling. + +| Surface | Shared | Isolated | +| ---------------- | ------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------- | +| Source review | Connector contracts/core, cross-client compatibility fixtures, ownership docs. | Product implementation PRs remain one child issue each. | +| Build | Reproducible source inputs and contract fixture generation. | Mac Access Xcode build, embedded runtime, entitlements, signing, notarization, packaging. | +| Identity | Contract constants and proof expectations. | Bundle IDs, helper service, Keychain access groups, login item, TCC evidence. | +| Release | Repository CI conventions and exact source SHA. | Version, tag, release draft, artifacts, appcast, promotion, rollback, publication approval. | +| Runtime | Workbench may query/request through authenticated local IPC. | Mac Access is sole connector leader, Keychain/policy/audit/TCC/CUA/transport authority. | +| Support evidence | Redaction schema and proof taxonomy. | Product-specific logs, audit IDs, installed version, artifact lineage. | + +Root workspace tooling may discover both directories, but Mac Access release workflows must name their own paths and artifacts. A root version bump or Workbench package command must not publish Mac Access. A Mac Access updater workflow must not write Workbench metadata. + +## Dependency rules + +Allowed dependencies: + +- Mac Access app/helper -> connector-core contracts, native ports, and packaged private Python core. +- Workbench main -> generated/local client contract adapter. +- Cross-language tests -> shared JSON fixtures. +- Broker relay -> independently versioned compatible wire schemas. + +Forbidden dependencies: + +- Mac Access -> Workbench renderer, Electron main, Workbench app resources, appcast, or installed app. +- Workbench renderer -> Mac Access XPC, Keychain, broker credential, raw binding, or command body. +- Connector core -> Workbench UI/lifecycle/update code. +- Any product -> archived bridge checkout, mutable private source reference, system/Homebrew Python, Tailscale, public listener, or direct connector endpoint. +- Mac Access release -> Workbench release credential namespace or publication job. + +## Why this is preferable + +1. A single repository makes the migration from PR #697 reviewable and prevents connector-source drift. +2. An independent product target removes Workbench from pristine-Mac onboarding and gives Mac Access a stable TCC/helper identity. +3. A shared contract package lets Workbench become a client without embedding a second authority. +4. Atomic source changes can update schemas, all consumers, fixtures, and migration docs in one graph while release gates stay independent. +5. The repository already contains Workbench source, packaging knowledge, and the owned Python bridge after #697; a new repository would add provenance and release complexity without removing runtime coupling by itself. + +## Consequences + +Positive: + +- One connector source and threat model. +- One place to prove Workbench compatibility. +- Independent customer installation, permissions, updater, rollback, and release cadence. +- The archived repository can remain permanently non-authoritative. + +Costs: + +- CI and release workflows must enforce path/product isolation explicitly. +- Root workspace and code owners need clear boundaries to avoid accidental Workbench coupling. +- Native Swift/Xcode and Electron/TypeScript/Python tooling coexist in one repository. +- Version compatibility must be maintained between Mac Access, Workbench client, broker relay, and VM runtimes. + +Risks and controls: + +- **Accidental joint release:** separate workflows, artifact names, version files, appcasts, environments, and approval gates. +- **Shared-core breaking change:** versioned contracts, compatibility matrix, fixtures in every consumer language, unknown-schema fail closed. +- **Two runtime copies:** Mac Access is exclusive leader; Workbench client contains no authoritative connector after #704. +- **Repository size/tooling complexity:** path-filtered focused CI and the existing ten-child/pure-logic rules. +- **Premature readiness claim:** proof taxonomy in #698/#699; source, artifact, installed-app, live-control, publication, and rollout remain separate. + +## Alternatives considered + +### Restore or reuse the archived desktop-bridge repository + +Rejected. It would reintroduce private/mutable source acquisition, split implementation truth, and contradict the explicit provenance-only constraint. + +### Create a new Mac Access repository immediately + +Rejected for v0.1. It would require cross-repository atomic changes for core, Workbench migration, and fixtures while PR #697 has already moved ownership here. A later extraction can be reconsidered after the public interfaces and independent release are stable; repository extraction must not change bundle/TCC identity. + +### Keep Mac Access inside the Workbench app + +Rejected. It fails standalone onboarding, independent release, and the single stable TCC identity goal for customers who do not need Workbench. + +### Duplicate the Python connector into both products + +Rejected. Even identical initial copies would create two policy/audit/transport owners and no safe coexistence or rollback authority. + +### Make the broker or VM the local policy authority + +Rejected. Offline local stop/revoke/kill and macOS TCC authority must remain on the Mac. Remote authority can narrow access but cannot expand local permission. + +## Verification + +This ADR is satisfied only when: + +- code and fixtures live in the declared paths with no archived-repo dependency; +- Mac Access builds without Workbench and Workbench builds without embedding an authoritative second connector; +- product IDs, signing, notarization, appcast, artifacts, and rollback are independent; +- Workbench coexistence shows one helper/connector/TCC/audit owner; +- path/product isolation tests prove neither release workflow mutates the other; +- all named proof gates retain exact source and artifact identity. + +Those are downstream acceptance gates. This ADR records the decision; it does not prove them. + +## Revisit conditions + +Revisit repository placement only after v0.1 has a stable public contract and independent release lineage, or if current-code evidence proves repository tooling makes isolation impossible. Any move must preserve the frozen app/helper/service identities, Keychain/TCC continuity, selected-binding protocol, audit state, updater trust, and Workbench client compatibility. It cannot be used to revive the archived bridge as authority. diff --git a/docs/evaos/mac-access/architecture.md b/docs/evaos/mac-access/architecture.md new file mode 100644 index 0000000000..bf6f0aaacd --- /dev/null +++ b/docs/evaos/mac-access/architecture.md @@ -0,0 +1,338 @@ +# evaOS Mac Access v0.1 architecture contract + +Issue: [#699](https://github.com/100yenadmin/evaOS-GUI/issues/699) +Parent: [#698](https://github.com/100yenadmin/evaOS-GUI/issues/698) +Contract version: `2026-07-15.v1` +Inspected Workbench source: PR [#697](https://github.com/100yenadmin/evaOS-GUI/pull/697) head `ae9f35062f8a4b212fab4576d44602b57dc0303a` + +## Decision + +evaOS Mac Access is an independently signed and updated native menu-bar app. Its embedded signed helper is the one local connector leader, policy authority, Keychain custodian, audit writer, and TCC/CUA executor. The menu app and evaOS Workbench are authenticated clients of that helper; neither renderer nor the embedded Python runtime becomes a trust authority. + +The Mac opens an outbound authenticated WebSocket to the evaOS broker relay. It accepts work only when every selected customer, VM, Mac device, grant, runtime, binding, connector-key, nonce, sequence, expiry, and request-digest field verifies. There is no direct IP, inbound public port, customer-managed Tailscale, reusable connector URL/token, terminal step, system Python, or Homebrew requirement. + +Backend support for that outbound Mac relay does not exist at the inspected ws-proxy head. It is tracked in [electricsheephq/evaos-ws-proxy#73](https://github.com/electricsheephq/evaos-ws-proxy/issues/73). Implementation must remain fail closed until that contract exists; the current browser-to-VM proxy path is not a substitute. + +This document is executable design evidence. It is not a working-app, signed-artifact, notarization, pristine-Mac, VM-to-Mac, customer-readiness, publication, or rollout claim. + +## First-principles review + +### Desired function + +A customer installs one normal macOS app, enters a public one-time code, grants macOS permissions to one stable product identity, and can always see, pause, approve, stop, revoke, or kill audited agent access. A broker-selected evaOS VM agent can then use the paired Mac without Workbench being installed or running. + +### Hard constraints + +- One stable TCC/helper/connector identity when Mac Access and Workbench coexist. +- Outbound-only authenticated broker transport with exact selected-binding verification. +- Code-only public pairing; no connector URL, token, IP, port, SSH, or tailnet instruction. +- Native Keychain custody and renderer-safe redacted views. +- `Off`, `Ask Every Time`, and `Full Access`, with local stop/revoke/kill authority. +- No system/Homebrew Python, Homebrew, Tailscale, public listener, terminal setup, or Workbench dependency. +- Independent signing, notarization, updater, rollback, and release train. +- No change to the v2.1.36 Workbench release lane. + +### Soft assumptions to remove + +- A same-UID Unix socket plus a file bearer token is not signed-client authentication. +- A local HTTP listener is not required once both clients use authenticated native IPC. +- The current Workbench-owned LaunchAgent and Tailscale host discovery are migration inputs, not target architecture. +- A signed broker execution context that omits the exact command digest is not complete command authority. +- Python can remain useful without owning TCC, credentials, lifecycle, networking, or the public product boundary. + +### Magic-wand floor + +The minimum acceptable product is a native menu item with truthful connection/TCC/access state, code pairing, three access modes, a local emergency stop, and one broker-selected CUA action with a redacted audit receipt. Anything less does not prove the core safety loop. + +### Current cost + +The inspected Workbench path combines UI status, process spawning, connector readiness, a local HTTP token, tailnet-host discovery, grant creation, enrollment, TCC attribution, and connector lifecycle in one TypeScript service. The Python bridge then adds another HTTP boundary and a same-user token/UID helper boundary. Each added owner multiplies takeover, stale-state, secret-custody, and rollback cases. + +### Software Idiot Index + +Target: one customer-facing app, one persistent connector leader, one TCC authority, one selected binding, one outbound channel, one policy epoch, and one audit sequence. Every second owner or compatibility route must be temporary, observable, and removable. + +### Delete, simplify, accelerate, automate + +1. **Delete** public/private inbound connector URLs, tailnet discovery, token export, and Workbench-owned lifecycle from the target path. +2. **Simplify** local authority to one signed helper and a small versioned IPC contract. +3. **Accelerate** implementation with shared pure contracts and the PR #697 Python behavior as an embedded implementation detail. +4. **Automate** schema fixtures, negative cases, code-sign checks, orphan cleanup, rollback checks, and exact-head release evidence. + +### Proof needed + +Source/fixture proof precedes signed-app proof. Signed-app proof precedes pristine-Mac setup. Prepared-Mac CUA proof precedes broker-selected VM-to-Mac proof. None implies customer readiness or publication. + +### Negative risk + +The most dangerous failure is not an obvious crash; it is silent authority survival: a stale grant, old process, old helper, duplicated listener, restarted Full Access session, or Workbench fallback that still accepts commands. All such uncertainty resolves to `Off` and a local recovery path. + +Confidence in this design contract: high for current repository ownership and failure boundaries; intentionally unproven for macOS code-sign/TCC behavior, relay implementation, artifact packaging, and live CUA until their named issues pass. + +## Source-of-truth package layout + +`packages/mac-access` is the independently shipped native product. `packages/mac-connector-core` is the single connector source consumed by Mac Access and, through the authenticated client protocol, Workbench. + +```text +packages/mac-access/ # at most 8 direct children +├── App/ # menu bar UI and user intent only +├── Helper/ # signed XPC/Mach service; sole authority +├── Shared/ # native DTOs and generated contract adapters +├── Resources/ # embedded private Python runtime/core payload +├── Tests/ # native unit/integration tests +├── scripts/ # deterministic build/sign/package helpers +├── MacAccess.xcodeproj/ # native app/helper targets +└── README.md + +packages/mac-connector-core/ # at most 6 direct children +├── contracts/ # versioned language-neutral contracts and fixtures +├── python/ # migrated PR #697 bridge implementation +├── swift/ # native ports: Keychain, IPC, TCC/CUA, transport +├── tests/ # cross-language behavior and negative fixtures +├── scripts/ # generation and parity checks +└── README.md +``` + +Subdirectories also obey the repository limit of ten direct children. Pure parsing, canonicalization, binding comparison, policy transitions, redaction, and receipt construction remain separate from Keychain, WebSocket, filesystem, audit, TCC, process, and CUA I/O. + +PR #699 introduces only the versioned contract source/fixtures under `packages/mac-connector-core/contracts/v1`, cross-language syntax smoke programs under `packages/mac-connector-core/tests`, and focused contract-test discovery. Creating native targets, moving Python, or changing Workbench runtime behavior is downstream work. + +## Stable identities + +| Purpose | Frozen identity | Rule | +| --------------------------- | -------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | +| Menu-bar product | `com.evaos.mac-access` | Customer-visible app and responsible TCC identity. | +| Embedded helper/XPC service | `com.evaos.mac-access.helper` | Sole native policy/TCC/CUA authority. Must share the app's signed designated requirement and release lineage. | +| Per-user connector service | `com.evaos.mac-access.connector` | Stable SMAppService/LaunchAgent identity if persistence needs a login item. It starts or brokers the same helper; it is never a second authority. | + +The certificate chain, helper embedding rule, and concrete designated requirements become signed-build inputs, not caller-supplied wire fields. Local IPC derives and verifies the caller from the connection audit token. A request cannot assert its own identity. + +Keychain custody is frozen as follows: the production access-group base is `com.evaos.mac-access.credentials`, with an effective group `com.evaos.mac-access.credentials.epoch-N` for each security-critical credential epoch; development uses the disjoint `com.evaos.mac-access.development.credentials` base; the item service is `com.evaos.mac-access.connector-credential`; items are non-synchronizing `kSecAttrAccessibleWhenUnlockedThisDeviceOnly`; and only the production helper target for the accepted epoch receives that epoch's production access-group entitlement. The menu app, Workbench, Python, debug builds, and old/replaced helpers must be denied. Connector private keys are non-exportable `SecKey` material, using hardware protection where the selected algorithm and Mac support it. #705 must record the resolved Team-ID prefix and prove the built entitlements/ACLs; it may not broaden this custody contract. + +The stable helper identity does not make an old validly signed binary safe. ws-proxy #73 registration and reconnect must reject a missing or below-floor immutable build/security identity before accepting the device credential. Raising a security-critical floor atomically issues a new credential/key into a new epoch access group, registers only the exact accepted build/source/security epoch, commits the new broker epoch, then revokes and deletes the prior credential. Old binaries lack the new group entitlement and their old broker credential is rejected. Exceptional rollback uses `evaos.mac_access.rollback_authorization_payload.v1`, signed over RFC 8785/JCS bytes, to name the authorization ID, exact source and target version/commit/lineage/security epoch, both credential epochs, resulting reader/writer floors, and issue/expiry interval. The relay and local status must equal those validated target claims. `golden/rollback-authorization-golden.json` freezes canonical bytes, digest, broker key, and Ed25519 signature. No opaque ID or local-only authorization can revive an old credential. + +Live proof must record `codesign --display --requirements`, `codesign --verify --strict --deep`, notarization/stapling, SMAppService registration, actual TCC attribution, helper replacement rejection, and upgrade continuity. If those checks expose a collision, the replacement identity must be recorded in #699 before any downstream TCC/live proof. No source document alone freezes macOS TCC behavior. + +## Process and trust topology + +```mermaid +flowchart LR + User["Local Mac user"] --> Menu["Mac Access menu app\ncom.evaos.mac-access"] + Workbench["evaOS Workbench main process"] -->|"authenticated XPC; audit-token code identity"| Helper + Menu -->|"authenticated XPC; audit-token code identity"| Helper["Mac Access helper\ncom.evaos.mac-access.helper\nsole leader and authority"] + Renderer["Workbench renderer"] -->|"redacted IPC only"| Workbench + Helper --> Keychain["Keychain\npairing and device credentials"] + Helper --> Audit["append-only local redacted audit"] + Helper --> Python["embedded private Python core\nno TCC or secret authority"] + Helper --> CUA["Accessibility and Screen Recording\nCUA execution"] + Helper -->|"outbound authenticated WSS only"| Relay["broker/ws-proxy relay\ntracked by ws-proxy #73"] + Relay --> VM["broker-selected evaOS VM runtime"] +``` + +There is one leader lease per logged-in user and connector installation. The helper creates an atomic, owner-only state record containing its runtime instance ID and policy epoch. A second process can become leader only after native process-liveness and code-identity checks show the prior leader is gone. It must never unlink another live leader's socket, take its Keychain lease, or inherit Full Access. + +## Ownership matrix + +| State or behavior | Sole owner | Other processes may | Forbidden | +| ---------------------------------- | ----------------------------------------------- | ---------------------------------------------------------- | ------------------------------------------------------------------------------ | +| Pairing credential and device key | helper Keychain adapter | Menu/Workbench request pairing and display redacted status | Renderer, Python, logs, clipboard, or issue evidence stores raw credentials. | +| Selected binding | helper policy store, populated from broker | Menu/Workbench render opaque/redacted IDs | A caller nominates customer/device/grant/runtime. | +| Access state and policy epoch | helper policy engine | Menu/Workbench submit optimistic, exact-epoch local intent | UI writes state directly or a restart restores effective Full Access. | +| TCC status and prompts | helper native adapter under Mac Access identity | Menu opens System Settings and renders returned state | Workbench/Python claims or bypasses permission. | +| CUA execution | helper native CUA port | Python supplies normalized plans after policy approval | Python, renderer, generic IPC, or remote runtime actuates directly. | +| Local audit sequence | helper append-only audit writer | Clients query redacted tail/receipts | A failed audit write permits actuation. | +| Broker WebSocket and replay window | helper transport adapter | Menu/Workbench show redacted connected/blocked state | Browser/renderer receives channel credential or endpoint. | +| Helper/login-item lifecycle | Mac Access app using native service APIs | Workbench opens Mac Access or queries it | Workbench spawns a second connector after cutover. | +| Product update/rollback | Mac Access updater | Workbench shows installed/version state | Workbench updater mutates Mac Access, or Mac Access changes Workbench appcast. | + +## Authenticated local client protocol + +Transport is an embedded XPC/Mach service associated with `com.evaos.mac-access.helper`. A Unix domain socket may remain only inside the private Python sandbox after the native helper has authenticated and scoped the call; it is not a Workbench or menu-app trust boundary. + +For every connection the helper: + +1. extracts the immutable macOS audit token from the XPC connection; +2. resolves `SecCode` for that exact process; +3. verifies Apple code validity, expected team/anchor, signing identifier, hardened runtime, and a release-bundled designated requirement; +4. maps it to `mac_access_menu` or `workbench_main` without trusting request fields; +5. hashes the audit token and designated requirement for non-secret local evidence; +6. rejects unsigned, ad-hoc, debugger-altered, wrong-team, wrong-identifier, renderer, child-shell, and stale-PID clients; +7. binds the verified peer and connection ID to every decoded request. + +The temporary legacy Workbench signing identifier `com.electricsheephq.EvaDesktop` may be accepted only with the expected team/anchor and designated requirement during #704 migration. Bundle-ID comparison alone is never sufficient. Removal of the legacy requirement is a tracked cleanup gate. + +The wire request is `evaos.mac_access.local_action.v1`; the server-derived dispatcher input is `evaos.mac_access.authenticated_local_action.v1`. Mutations carry the caller's expected policy epoch and a client nonce. Mismatched epochs, duplicate nonces, unsupported schemas, unknown fields, or stale connections fail closed. Workbench receives `evaos.mac_access.local_status.v1` and action receipts only. Renderer IPC applies a second allowlist/redaction pass and never includes peer hashes, raw binding data, channel IDs, Keychain references, paths, environment variables, or native errors containing secrets. + +Allowed Workbench actions are status, code pairing handoff, access-mode intent, pause/resume, revoke, kill switch, permission handoff, and stop. Remote CUA command bodies are not accepted over the local client protocol. + +## Outbound selected-binding transport + +The helper opens an outbound TLS WebSocket to the relay defined by ws-proxy #73. Enrollment exchanges a public short-lived code for a Keychain-custodied device credential and connector key. The user-facing code contains no endpoint, token, IP, port, customer secret, or transport instruction. + +The relay selects, and the connector verifies, this tuple: + +```text +customer_id +customer_vm_id +device_id # the paired Mac connector device +grant_id +runtime # openclaw or hermes +binding_id +binding_version +connector_installation_id +connector_key_id +binding_fingerprint_sha256 +``` + +Each command uses `evaos.mac_access.broker_control.v1` and carries: + +- the selected tuple; +- the server-owned Ed25519 `evaos.mac_control_execution_context.v1` produced by ws-proxy #69; +- a separate `evaos.mac_access.command_authority_payload.v1` signed authorization that exhaustively includes session, channel generation, the full selected tuple, execution-context digest, command ID, capability, exact request digest, random nonce, sequence, and issue/expiry times; +- a maximum 60-second command authority fully contained inside the signed #69 execution-context interval; +- no reusable connector URL/token. + +The command authorization signs the UTF-8 RFC 8785/JCS serialization of the payload object only, prefixed semantically by the fixed domain field `evaos.mac-access/command-authority/v1`. The authorization wrapper, its digest, key ID, and signature are not part of the signed payload, avoiding self-reference. Base64url is unpadded. SHA-256 covers the exact canonical bytes, and Ed25519 verifies those same bytes. `command-authority-golden.json` freezes canonical bytes, digest, public key, and a valid signature so TypeScript, Swift, Python, and ws-proxy #73 can prove byte-for-byte interoperability and one-bit mutation rejection. + +The helper validates in this order before any prompt or Python call: + +1. frame size, JSON shape, exact schema, and unknown-field rejection; +2. pinned broker key ID and both Ed25519 signatures; +3. execution-context payload digest/signature and decoded-claims equality; +4. RFC 8785 command-authority canonical bytes, digest, signature, and equality of every signed field with the delivered envelope; +5. command interval containment inside the #69 context and a monotonic receipt deadline; +6. context ID, session ID, channel generation, command ID, nonce, and sequence replay windows; +7. equality of every complete signed tuple field with the locally enrolled binding; +8. local revocation tombstone, policy epoch, pause, and kill switch; +9. access-mode decision and exact-scope local approval; +10. durable redacted decision audit write; +11. normalized CUA execution through the helper; +12. durable redacted result audit write and signed/attested result receipt. + +Any failed step prevents all later steps. An audit-write failure is a denial, not a warning. + +Reconnect uses exponential backoff with jitter and a bounded ceiling. A new connection receives a new opaque channel-generation ID. The signed payload binds that generation, while consumed context/command/nonce identities persist across reconnect and helper restart until their maximum authority expiry. Commands from an old generation or lower policy epoch are rejected. Queued commands expire normally; they are not extended across disconnect. Broker disconnect immediately blocks new remote actions; local status, stop, revoke, kill switch, and audit remain available offline. + +## Python embedding boundary + +PR #697's `evaos_desktop_bridge` source moves into `packages/mac-connector-core/python` only after #697 merges or is superseded. The Mac Access artifact includes a pinned private CPython runtime and wheels built for supported architectures. Runtime discovery never searches `/usr/bin/python3`, Homebrew, pyenv, PATH, or a customer virtual environment. + +Python may own normalized capability planning, adapters, policy-table data, redaction helpers, and behavior/canary harnesses. It may not own: + +- Keychain reads/writes; +- local client authentication; +- broker TLS credentials or WebSocket lifecycle; +- leader election or login-item lifecycle; +- TCC identity or permission claims; +- direct Accessibility, Screen Recording, Quartz, or ApplicationServices calls in the final boundary; +- access-state persistence, replay windows, revocation, kill switch, or authoritative audit writes; +- updater, rollback, signing, or notarization. + +The existing `helper_ipc.py` same-UID plus file-token check is retained only as migration provenance. Native code-identity authentication replaces it at the product boundary. The existing Python HTTP connector is not exposed in the target product. + +## Access-state machine + +Configured mode is user intent. Effective mode is the helper's current authority after binding, lifecycle, TCC, transport, audit, and safety constraints. + +| From | Event | To | Required effect | +| ----------------------- | ---------------------------------------------------------------- | ---------------------------------------------------- | ---------------------------------------------------------------------------------------------------------- | +| unpaired `Off` | one-time code claimed and user confirms selected customer/device | paired `Ask Every Time` | Rotate policy epoch; no action is pre-approved. | +| any | user selects `Off` | `Off` | Reject/clear queued commands and approvals before acknowledgment. | +| paired `Off` | user selects `Ask Every Time` | `Ask Every Time` | Each action needs a single-use approval bound to binding, capability, target, request digest, and expiry. | +| paired mode | user selects `Full Access` and confirms locally | effective `Full Access` | Bind confirmation to current helper runtime instance and policy epoch. | +| effective `Full Access` | helper/app restart or crash | configured `Full Access`, effective `Ask Every Time` | Require local reconfirmation; never silently resume Full Access. | +| paired mode | pause | effective `Off`, paused | Keep pairing but invalidate approvals and block new remote work. | +| paused | local resume | configured mode, subject to reconfirmation | Full Access still requires current-instance confirmation. | +| any | stop | effective `Off` | End current actions at the next safe cancellation boundary and clear queue. | +| any | revoke | revoked `Off` | Tombstone grant/device binding, rotate policy epoch, erase active credentials, close channel. | +| any | kill switch | `Off`, killed | Synchronously block new work before best-effort process/network cleanup. Only local recovery can clear it. | +| any | binding/schema/audit/TCC uncertainty | effective `Off` | Redacted blocker; no degraded actuation path. | + +Ask Every Time approval defaults to 60 seconds or the earlier broker authority expiry and is consumed once. A semantically changed request, target snapshot, capability, binding, sequence, or digest requires a new approval. Closing or hiding the prompt denies it. + +## Audit and redaction + +The helper writes an append-only, owner-only local journal with a monotonic sequence, previous-record digest, and record digest. `record_sha256` is SHA-256 over the exact UTF-8 RFC 8785/JCS serialization of the versioned `evaos.mac_access.audit_event.v1` payload with `record_sha256` excluded. The next payload's `previous_record_sha256` must equal that digest. `audit-chain-golden.json` freezes a two-record chain, canonical bytes, and both digests; edit/delete/reorder/previous-digest checks are required runtime tests. The payload records non-secret identity kind, binding fingerprint, command/request IDs and digests, access mode, outcome, reason code, and an explicit bounded allowlist of evidence metadata. Arbitrary evidence keys/values are not valid contract data. + +Default audit and support evidence forbids raw screenshots, Accessibility trees, typed text, clipboard content, cookies, auth headers, passwords, tokens, connector URLs, private addresses, Keychain references, environment dumps, and unredacted native exception text. Optional artifacts require a separate exact-scope user decision, encrypted storage, retention deadline, access audit, and explicit purge; that feature is outside v0.1 unless separately approved. + +If the audit cannot durably record the decision before execution, execution is denied. Result-write failure activates effective `Off` and emits only a local minimal failure marker if possible. + +## Workbench coexistence + +Workbench remains unchanged in #699. #704 will replace lifecycle ownership with the authenticated client. + +Cutover is prepare-before-atomic-commit: + +1. detect a signed, compatible Mac Access helper; +2. authenticate Workbench main over XPC and read a fresh status with matching runtime identity/policy epoch; +3. broker prepares, but does not activate, the replacement binding and exact rollback record; +4. prove local audit writable, kill switch clear, and rollback metadata present; +5. both local connectors enter deny-new-work and the broker performs one compare-and-swap that deactivates the legacy grant/lease and activates the prepared Mac Access binding/lease in the same commit; +6. Mac Access acknowledges the committed generation, then Workbench stops only the exact verified legacy connector; +7. remove Workbench access to connector tokens, URL/host discovery, grant creation, bridge process spawning, packaging/signing, and TCC execution; +8. retain a signed, time-bounded reverse-handoff authorization; after commit neither client accepts work during rollback ambiguity. + +On any ambiguous step, Workbench does not kill or replace an unknown connector. It shows a fail-closed blocker and preserves the previously verified owner. Dual listeners, duplicate grants, and simultaneous TCC prompts are test failures. + +## Update, downgrade, uninstall, and orphan cleanup + +Mac Access owns its appcast/update channel, signing/notarization, install location, login item, helper version, and rollback metadata. It never reads or writes Workbench's appcast, update cache, release draft, tag, or artifact. + +Before update, the helper moves effective access to `Off`, drains/cancels work, persists a redacted lifecycle audit, closes the channel, and releases leadership. Protected state records minimum reader/writer schema versions and a monotonic security epoch. Status/handshake records exact build version, source commit, signed lineage, reader/writer versions, credential epoch, and security epoch. The replacement must meet every floor, have a compatible schema range and matching designated requirement, and complete the broker-enforced credential-epoch migration above before it reads the new credential. Failed replacement leaves access off and restores the last signed compatible artifact only through the independent rollback path and exact broker authorization. + +Downgrade across an unsupported state/schema or below the protected security/build floor refuses to start remote transport. It does not rewrite new state with old defaults. Exceptional rollback requires an exact signed, time-bounded authorization naming source/target build and resulting security floor. Full Access is never restored across any update or rollback without current-runtime, current-policy-epoch, current-binding local confirmation. + +Uninstall/revoke order is: activate local deny barrier, invalidate approvals/queue, write revocation tombstone, notify broker best effort, close channel, delete active device credentials, unregister login item/helper, and remove executable/runtime files. A minimal non-secret tombstone may remain to prevent an old grant from being accepted after reinstall; retention and reset behavior must be explicit in #703/#705. Orphan cleanup removes only processes/files whose path and signed identity match the current or recorded prior Mac Access installation. + +## Pass/fail invariants + +| Invariant | Pass | Fail | +| -------------------- | ------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------- | +| Single owner | One verified helper runtime instance and one connector channel. | Two listeners, two leader records, two grants, or Workbench and Mac Access both accepting work. | +| Local authentication | Audit-token-derived designated requirement matches a permitted main process. | Same UID/token only, self-asserted bundle ID, renderer/child-shell access, or stale PID. | +| Selected binding | Every local, signed, and outer field matches exactly. | Any missing, stale, caller-nominated, or cross-customer field. | +| Replay/expiry | Unique context/command/nonce/sequence; deadline valid. | Duplicate, reordered, expired, future, or old-channel command. | +| Access policy | Effective authority derives from local state and current runtime confirmation. | Restarted Full Access, approval reuse, prompt dismissal treated as approval, or broker override of local stop. | +| Audit | Decision is durable before execution and evidence is redacted. | Actuation on audit failure or forbidden raw/secret field. | +| TCC | Only frozen signed Mac Access identity executes CUA. | Workbench, Python, shell, replacement helper, or a second prompt identity executes. | +| Network | Outbound broker WSS only. | Public/private inbound listener, direct IP, port forward, or customer Tailscale dependency. | +| Update/rollback | Independent signed lineage, compatible state, access off during handoff. | Workbench release mutation, unsigned replacement, incompatible downgrade, or orphan authority. | + +## Contract fixtures and downstream proof + +Versioned contracts and fixtures live at `packages/mac-connector-core/contracts/v1`. TypeScript/Zod validates structure and cross-field invariants and verifies the command-authority signature vector and audit-chain digest vector. Python and Swift smoke programs prove only that every JSON fixture is syntactically consumable without a customer-managed runtime; typed semantic parity is an explicit #700 gate. + +The negative manifests distinguish: + +- `schema`: invalid data must be rejected by every decoder; +- `runtime`: structurally valid data requires stateful cryptographic, replay, enrollment, or local-policy rejection in downstream implementation. Each appears as an explicit `todo` in #699's focused suite and may not be reported green until its named downstream handler exists. + +Downstream issues may claim completion only with the following evidence: + +| Issue lane | Minimum evidence | +| ---------------- | ----------------------------------------------------------------------------------------------------------------------------- | +| #700 core | TS/Python/Swift semantic parity; negative fixtures; no system Python; native ports own secrets/TCC/audit. | +| #701 app | native menu flow; truthful states; signed identity inspection; no Workbench dependency. | +| #702 transport | ws-proxy #73 source contract; exact selected-binding positive and all negative cases; outbound-only packet/listener evidence. | +| #703 policy | transition tests, exact-scope prompt, restart downgrade, stop/revoke/kill race tests, redacted audit chain. | +| #704 coexistence | single leader/listener/grant/TCC identity with both apps; make-before-break and rollback. | +| #705 release | signed, notarized, stapled independent artifact/appcast; upgrade/downgrade/uninstall/orphan proof. | +| #706 live proof | pristine supported Mac onboarding and broker-selected VM-to-Mac CUA with exact audit IDs. | + +Public publication, rollout, and customer readiness remain separately authorized gates after all of the above. + +## Current blockers and sequencing + +- PR #697 was still open at the inspected head. No connector implementation may land from a pre-#697 copy. +- ws-proxy #73 must supply the outbound selected-binding relay; no fallback is authorized. +- ws-proxy #69 proves an optional source-level signed execution context only. Signer deployment, downstream verification, and enforcement were unproven at inspection time. +- OpenClaw #16 and the owned Hermes/final connector verifier lanes must consume and propagate the signed context before enforcement. +- #699 blocks #700-#706. Architecture approval does not authorize merging, release work, customer mutation, or v2.1.36 changes. + +## Non-goals + +- Connector implementation, UI implementation, signed artifact, deployment, publication, or customer rollout. +- Windows or iPhone Mirroring. +- Restoring the archived `electricsheephq/evaos-desktop-bridge` repository as source, build, or runtime truth. +- Altering the Workbench v2.1.36 branch, PR, tag, release draft, artifacts, appcast, or publication state. diff --git a/docs/evaos/mac-access/migration-map.md b/docs/evaos/mac-access/migration-map.md new file mode 100644 index 0000000000..4c95d5dfc9 --- /dev/null +++ b/docs/evaos/mac-access/migration-map.md @@ -0,0 +1,160 @@ +# evaOS Mac Access source-to-target migration map + +Issue: [#699](https://github.com/100yenadmin/evaOS-GUI/issues/699) +Inspected source: PR [#697](https://github.com/100yenadmin/evaOS-GUI/pull/697) head `ae9f35062f8a4b212fab4576d44602b57dc0303a` +Contract version: `2026-07-15.v1` + +## Rule + +Every current connector module and Workbench production call site has one target owner and one migration phase. The archived `electricsheephq/evaos-desktop-bridge` repository is not a target, source, build input, or runtime dependency. + +No connector implementation may land from this inspected copy while #697 remains unmerged. After #697 merges or is superseded, downstream branches must reset their source baseline to the exact canonical merged/superseding head before edits. + +## Phases + +| Phase | GitHub owner | Purpose | Landing condition | +| ----- | ---------------------- | ---------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------- | +| A0 | #699 | Freeze identities, owners, protocols, fixtures, threat model, and migration. | Architecture review clears current #697 head. | +| A1 | #700 | Move the single Python source into connector core; add native ports and cross-language parity without product cutover. | #697 canonical head and #699 approved. | +| A2 | #701 | Build native menu app/helper with frozen identities and local-only flows. | Core contracts pass; no remote relay claim. | +| A3 | #702 plus ws-proxy #73 | Add outbound selected-binding relay and helper transport. | Backend source contract exists; negative binding/replay tests pass. | +| A4 | #703 | Make native policy/audit/revoke/kill authority complete. | Transport cannot bypass local policy. | +| A5 | #704 | Convert Workbench from owner/spawner to authenticated Mac Access client. | Mac Access exclusive-leader readiness and rollback handshake pass. | +| A6 | #705 | Independent sign/notarize/update/rollback/uninstall. | Coexistence source and local proof pass. | +| A7 | #706 | Pristine-Mac and broker-selected VM-to-Mac CUA proof. | Exact signed candidate passes prior gates. | + +## PR #697 Python module ownership + +The `Target owner` column is intentionally singular. A module assigned to the native helper is ported and then retired from Python. A module assigned to Python may call a narrow native port but does not own that port's authority. + +| Current source under `resources/evaos-beta/bridge/src/evaos_desktop_bridge/` | Current role | Target owner | Phase | Migration action and exit check | +| ---------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------ | ----- | ------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `__init__.py` | Package metadata/entry namespace. | `packages/mac-connector-core/python/evaos_desktop_bridge/__init__.py` | A1 | Move after #697 canonicalization; version must match embedded manifest. | +| `adapters/__init__.py` | Adapter namespace. | `packages/mac-connector-core/python/evaos_desktop_bridge/adapters/__init__.py` | A1 | Move; export only private runtime adapters. | +| `adapters/codex_app_server.py` | Codex app-server protocol adapter. | connector-core Python adapter | A1 | Move and keep pure protocol normalization; no credential/TCC ownership. | +| `adapters/codex_macos.py` | Direct macOS/Codex CUA adapter. | connector-core Swift `NativeCuaPort` in the signed helper | A1 | Port required behavior and negative cases; delete direct Python native execution before TCC proof. | +| `adapters/customer_mac.py` | Customer-Mac command normalization and orchestration, including legacy iPhone Mirroring branches. | connector-core Python Mac-only planner | A1 | Extract only Mac capabilities; replace native calls with a bounded helper-owned port. iPhone Mirroring branches are not packaged or exposed in v0.1. | +| `audit.py` | Local JSON audit construction/write. | connector-core Swift `AuditStore` in helper | A4 | Port authoritative write/chain/redaction; Python may return draft metadata only. Audit failure denies execution. | +| `behavior_harness.py` | Behavior parity harness. | connector-core tests | A1 | Move as parity oracle; run against embedded Python and native port fixtures. | +| `bundled_tools.py` | Tool discovery/invocation helpers. | connector-core Python private runtime | A1 | Remove PATH/Homebrew/system discovery; resolve only embedded, manifest-pinned resources. | +| `capability_manifest.py` | Capability catalog and availability. | connector-core pure contracts | A1 | Generate language adapters from one versioned manifest; availability cannot imply authority. | +| `cli.py` | Public-style bridge CLI and service entry points. | connector-core Python private process entry | A1 | Keep only private helper-launched stdin/stdout or private socket entry; remove customer/Workbench public CLI dependency. | +| `connector_server.py` | Token-gated HTTP listener, enrollment, remote command dispatch, including out-of-scope iPhone commands. | Mac Access native helper Mac-only transport/dispatcher | A3 | Port only Mac command mapping; retire HTTP listener, connector URL/token, enrollment callback, and iPhone command routes. No inbound listener remains. | +| `helper_ipc.py` | Unix socket, file token, same-UID helper and direct AX/Quartz execution. | Mac Access signed XPC helper | A1 | Replace public boundary with audit-token/SecCode authentication; port CUA; retire token/UID authority. | +| `policy.py` | Capability policy table. | connector-core Swift `AccessPolicy` in helper | A4 | Port and bind decisions to selected tuple, request digest, mode, epoch, approval, and audit. Python table becomes parity input only, then retires. | +| `pre_canary.py` | Pre-live environment/control checks. | connector-core tests | A1 | Convert to hermetic source/embedded-runtime checks; no system Python or live customer dependency. | +| `qa_canary.py` | Connector/OpenClaw/Hermes behavior suites. | connector-core proof tests | A1 | Preserve scenario semantics; replace URL/token inputs with test relay/native client fixtures before A7. | +| `queue.py` | Local queued-operation persistence. | connector-core Swift `CommandQueue` in helper | A4 | Port bounded queue, expiry, epoch, revoke/kill cancellation, and crash clearing. | +| `redaction.py` | Recursive public-output redaction. | connector-core pure Python redaction parity | A1 | Move and align with `audit_event.v1`; native helper remains final enforcement point. | +| `schema.py` | Common result/error envelope builders. | connector-core versioned contract adapters | A1 | Replace ad-hoc shapes with generated/hand-verified v1 adapters in TS, Swift, and embedded Python. | +| `state.py` | Pairing/control/audit state files. | connector-core Swift `ConnectorStateStore` in helper | A4 | Port versioned state, Keychain references, policy epoch, revocation tombstone, and atomic recovery; never copy raw tokens. | +| `types.py` | Python command/result types. | connector-core generated Python contract types | A1 | Generate or verify against v1 fixtures; unknown fields and schemas fail closed at authority boundaries. | + +## Workbench production call-site ownership + +All target owners below remain in `packages/desktop` as clients or presentation. None remains a connector authority after A5. + +| Current Workbench file | Current responsibility | Target owner | Phase | Required change and exit check | +| ----------------------------------------------------------- | -------------------------------------------------------------------------------------------------------- | -------------------------------------------- | ----- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `src/common/evaos/bridgeTypes.ts` | Native status/action DTOs, including current control-mode shapes. | Workbench generated Mac Access client DTOs | A5 | Add v1 status/actions including `off`; delete raw connector URL/token/path fields from client-visible types. | +| `src/common/evaos/nativeCompanionBoundary.ts` | Static shell/native trust matrix and canaries. | Workbench trust/presentation contract | A5 | Point native owner to Mac Access helper; canaries describe client status, not Python CLI authority. | +| `src/process/evaosNativeCompanionBoundary.ts` | Main-process local-action boundary enforcement. | Workbench fail-closed client boundary | A5 | Permit only fixed authenticated-client actions; no generic local CUA or lifecycle escape hatch. | +| `src/index.ts` | Main-process startup/teardown registration. | Workbench Mac Access client bootstrap | A5 | Register XPC client bridge; stop only Workbench client/session state, never an unknown Mac Access helper. | +| `src/process/bridge/evaosNativeCompanionBridge.ts` | Renderer-to-main status/diagnostic/action IPC. | Workbench renderer-safe Mac Access bridge | A5 | Call the authenticated client, preserve `BridgeResponse`, and return redacted status/audit IDs only. | +| `src/process/bridge/evaosRendererSecretGuard.ts` | Renderer secret-shape rejection. | Workbench renderer secret guard | A5 | Extend forbidden fields to Mac Access channel, binding, peer, Keychain, signature, and transport details. | +| `src/process/bridge/index.ts` | IPC bridge registration. | Workbench bridge registry | A5 | Register only the Mac Access client bridge once. | +| `src/process/resources/builtinMcp/evaosMacControlServer.ts` | Spawns bridge CLI and exposes Mac-control tools. | Workbench-to-Mac Access client adapter | A5 | Remove bridge binary/PATH resolution and direct process spawn; route allowed requests through helper client or selected broker path. | +| `src/process/services/evaosNativeCompanionStatus.ts` | Status, process spawn, local HTTP readiness, tailnet discovery, grants, enrollment, actions, TCC repair. | Workbench `MacAccessClientService` | A5 | Retain presentation/orchestration only; delete connector spawn/stop, URL/token, host discovery, Tailscale bootstrap, grant ownership, direct bridge CLI, and TCC authority. | +| `src/process/services/evaosBrokerSession.ts` | Completes connector enrollment and creates grants from connector URL/token/tailnet inputs. | Broker-selected Mac Access client adapter | A5 | Remove Workbench connector registration/grant authority and raw endpoint/token inputs; request only broker-side atomic prepare/commit/reverse-handoff operations. | +| `src/common/adapter/ipcBridge.ts` | Declares renderer/main native-companion IPC surface. | Workbench generated renderer-safe client IPC | A5 | Replace legacy action/status shapes with the redacted Mac Access client DTOs; no generic or secret-bearing fields. | +| `src/process/utils/runBackendMigrations.ts` | Executes backend migrations including native-companion seams. | Workbench coexistence migration coordinator | A5 | Run idempotent make-before-break client cutover marker; never migrate raw credentials in renderer/Node storage. | +| `src/renderer/evaos/useEvaosNativeCompanionStatus.ts` | Polls/requests status and actions. | Workbench renderer hook | A5 | Render redacted v1 status; no connector lifecycle assumptions. | +| `src/renderer/evaos/nativeCompanionViewModel.ts` | Converts native status into customer UI state. | Workbench presentation model | A5 | Map `Off`, `Ask Every Time`, `Full Access`, paused/revoked/blocked, and reconfirmation truthfully. | +| `src/renderer/pages/native-companion/index.tsx` | Mac & iPhone/native companion page. | Workbench Mac Access client UI | A5 | Present status/handoff controls; Mac Access app owns pairing/TCC/access confirmation. iPhone Mirroring is outside this sprint. | +| `src/renderer/pages/guid/hooks/useGuidAgentSelection.ts` | Consumes native-companion readiness during guided agent selection. | Workbench redacted readiness consumer | A5 | Consume client status only; no fallback connector start or readiness inference from legacy bridge state. | +| `src/renderer/pages/settings/AgentSettings/LocalAgents.tsx` | Consumes native-companion status in Local Agents settings. | Workbench redacted status consumer | A5 | Render Mac Access state/handoff only; no connector lifecycle, token, or TCC authority. | +| `src/process/resources/builtinMcp/constants.ts` | Names and recognizes the packaged Mac-control MCP server. | Workbench Mac Access MCP recognizer | A5 | Recognize only the authenticated Mac Access client adapter; remove legacy bridge-name and direct-process fallback. | +| `src/renderer/evaos/evaosNativeAgentAvailability.ts` | Maps native-companion state into agent/MCP availability. | Workbench redacted availability mapper | A5 | Only exact redacted Mac Access `ready` state may be usable or attach MCP; Off, repair, and reconfirmation states remain blocked. | +| `src/renderer/pages/guid/hooks/useGuidSend.ts` | Attaches native Mac-control MCP during guided sends. | Workbench guarded MCP attachment | A5 | Attach only from the exact redacted Mac Access ready state; no legacy readiness or connector-start fallback. | +| `src/renderer/pages/guid/GuidPage.tsx` | Directly presents native availability in the guided workflow. | Workbench redacted status presentation | A5 | Consume the shared redacted availability result only; never infer transport, credential, TCC, or lifecycle authority. | +| `src/renderer/pages/guid/components/AgentPillBar.tsx` | Directly presents selected-agent native availability. | Workbench redacted status presentation | A5 | Display status only; do not attach MCP or trigger connector lifecycle outside the guarded send path. | +| `src/renderer/pages/settings/AgentSettings/AgentCard.tsx` | Directly presents agent native availability. | Workbench redacted status presentation | A5 | Display status only; blocked/repair/reconfirm states cannot be promoted to usable. | +| `scripts/build-mcp-servers.js` | Packages the direct Mac-control MCP server adapter. | Workbench client-adapter packaging | A5 | Package only the Mac Access authenticated-client adapter; remove direct bridge binary/process invocation. | +| `scripts/build-with-builder.js` | Orchestrates Workbench resource preparation and product packaging. | Workbench product build only | A6 | Remove Mac Access/authoritative bridge preparation from Workbench builds and prove path/product isolation. | +| `scripts/prepareEvaosDesktopBridgeResource.js` | Prepares and manifests the Workbench-owned Python bridge/helper runtime. | connector-core migration/build tooling | A6 | Retire from Workbench packaging after verified source migration; it must not fetch, copy, or manifest Mac Access authority. | +| `packages/desktop/electron-builder.yml` | Embeds bridge resources into the Workbench artifact. | Workbench packaging manifest | A6 | Remove authoritative connector/helper/runtime resources after A5; retain no second Mac Access payload. | +| `scripts/afterPack.js` | Verifies packaged Workbench bridge provenance and resource shape. | Workbench non-embedding verifier | A6 | Invert the gate after cutover: fail if an authoritative Mac connector/helper/private runtime remains in Workbench. | +| `scripts/afterSign.js` | Signs/verifies the Workbench bridge/helper payload. | Workbench signing isolation verifier | A6 | Stop signing Mac connector authority in Workbench and fail on Mac Access identities/resources/appcast inputs. | + +Production imports, broker enrollment/grant methods, IPC declarations, MCP packaging, builder resources, after-pack/sign verification, and release-workflow inputs must be machine-inventoried again at the exact A5/A6 base. Any newly discovered owner or consumer receives a singular row before implementation; absence from this inspected head is not permission to leave an untracked owner. + +## State migration + +Migration transfers intent and pairing only through authenticated native APIs. It never copies connector tokens, raw Keychain material, bearer headers, or private URLs into Workbench storage or command lines. + +| Current state/source | Target representation | Migration rule | Failure behavior | +| --------------------------------------- | ----------------------------------------------------------------------------- | -------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------- | +| Workbench control mode `ask_permission` | `configured_mode=ask_every_time` | Import only after local user confirms Mac Access takeover. | Stay on verified prior owner; no cutover. | +| Workbench control mode `full_access` | configured `full_access`, effective `ask_every_time`, reconfirmation required | Never import effective Full Access. | Mac Access remains Ask Every Time/Off. | +| Workbench connector bearer token/file | none | Do not migrate; revoke/delete after exclusive handoff. | Token remains with prior verified owner until rollback/cutover resolved. | +| Workbench connector URL/tailnet host | none | Do not migrate; target is outbound relay channel. | A3 blocked; no direct-network fallback. | +| Broker grant/device registration | new Mac Access device credential and selected tuple | Broker performs atomic rebind/rotation with audit IDs. | Old grant remains unchanged until commit or is explicitly revoked; never duplicate silently. | +| Python state/audit files | versioned helper state and redacted chained audit | Import only allowlisted non-secret facts with source digest and migration audit. | Quarantine input; access Off. | +| Workbench TCC grants | no implicit transfer | Signed Mac Access installed artifact requests its own truthful TCC state. | Permission needed; no Workbench CUA fallback after cutover. | +| Workbench LaunchAgent/process | Mac Access login item/helper | Stop only after Mac Access exclusive-leader acknowledgment. | Leave prior owner; show blocker; do not kill unknown process. | + +## Coexistence handshake + +The A5 handoff is a two-phase commit with a rollback token that contains no credential: + +1. **Prepare:** Workbench authenticates to Mac Access, records current legacy owner evidence, and requests a compatibility/readiness snapshot. +2. **Validate:** Mac Access proves signed identity, compatible schema, audit health, policy epoch, kill state, and whether a selected binding can be migrated. +3. **Prepare:** broker creates an inactive Mac Access binding plus an exact signed reverse-handoff authorization; no second active grant/lease exists. +4. **Quiesce:** both connectors enter deny-new-work, drain/cancel queued actions, and retain rollback material without accepting commands. +5. **Atomic commit:** one broker compare-and-swap deactivates the legacy grant/lease and activates the prepared Mac Access binding/lease with a new generation. +6. **Acknowledge:** Mac Access proves the committed generation locally; Workbench then stops only the exact verified legacy process and removes lifecycle authority. +7. **Cleanup:** Workbench removes URL/token/host state; both record non-secret audit IDs. Cleanup never creates a period with two active grants. + +If any step before Atomic commit fails, the inactive prepared binding is discarded and Workbench returns to its previously verified state. If any step after commit is ambiguous, both clients remain effective Off. Reverse handoff uses the signed authorization and another atomic compare-and-swap; Workbench never restarts automatically and no rollback permits two active grants or leases. + +## Cutover kill matrix + +| Observed state | Mac Access action | Workbench action | +| ------------------------------------------- | -------------------------------------------------------------- | ------------------------------------------------------------------------------ | +| No legacy process, no Mac Access leader | May start as sole leader after local checks. | May offer install/open handoff; cannot start Mac Access internals. | +| Verified legacy owner, no Mac Access leader | Prepare handoff only. | Continues as prior owner until commit. | +| Verified Mac Access leader | Serve authenticated client. | Must not spawn/stop listener, create grant, resolve host/token, or invoke CUA. | +| Both ownership signals present | Effective Off; do not accept work. | Show split-brain blocker; do not kill by name/PID. | +| Unknown process/listener | Effective Off; preserve evidence. | Show support blocker; no takeover. | +| Mac Access crashed after commit | Relaunch with Full Access reconfirmation and duplicate checks. | Do not restart legacy fallback without explicit proven relinquish/rollback. | +| User activates kill switch | Block first, then clean queue/channel. | Reflect killed state; cannot clear remotely. | + +## Removal checklist + +A5 is incomplete while any production Workbench path can: + +- spawn `evaos_desktop_bridge` or its helper; +- bind or probe the connector HTTP port; +- read a connector token file or pass a bearer token; +- resolve a connector URL, tailnet IP, Tailscale CLI, or private interface; +- create/complete the Mac connector grant as authority; +- call Python/macOS CUA directly; +- start/stop the Mac Access login item/helper except through its authenticated public client action; +- claim TCC permission, audit authority, or update ownership; +- expose transport, Keychain, peer-code-identity, raw binding, or secret-bearing errors to renderer. + +Temporary migration code must have an owner, metric/audit signal, removal issue, and exact release cutoff. A feature flag is not a security boundary. + +## Validation map + +| Validation | Phase | Evidence | +| ------------------------------------------------------------------------------------------------ | ----------- | --------------------------------------------------------------------------------------------- | +| All v1 JSON fixtures parse in TypeScript, Swift Foundation, and embedded Python stdlib. | A0/A1 | Exact commands and source head. | +| All schema-negative fixtures fail and runtime-negative fixtures have executable rejection tests. | A0-A4 | Fixture ID to test-name ledger. | +| No pre-#697 implementation diff. | A1 | Merge-base and exact canonical source SHA. | +| No forbidden archived-repo source/build/runtime reference. | every phase | Repository and artifact string/dependency scan. | +| No public/private inbound listener, URL/token, Tailscale, or system Python. | A3/A6/A7 | Source scan, `lsof`, packet, packaged-artifact inspection, pristine-Mac proof. | +| One owner with both apps in every launch/crash/update order. | A5/A6 | Process/listener/grant/TCC/audit identity matrix. | +| Independent updater/release truth. | A6 | Appcast, bundle IDs, signatures, notarization, rollback, and Workbench non-mutation evidence. | + +This map assigns implementation ownership; it does not authorize merge, release, deployment, customer mutation, or publication. diff --git a/docs/evaos/mac-access/threat-model.md b/docs/evaos/mac-access/threat-model.md new file mode 100644 index 0000000000..34caf77188 --- /dev/null +++ b/docs/evaos/mac-access/threat-model.md @@ -0,0 +1,153 @@ +# evaOS Mac Access v0.1 threat model + +Issue: [#699](https://github.com/100yenadmin/evaOS-GUI/issues/699) +Contract version: `2026-07-15.v1` + +## Scope + +This model covers the standalone Mac Access app, its embedded helper and private runtime, authenticated Workbench client, broker-selected outbound relay, local access policy, audit, update/rollback, and coexistence migration. + +It does not claim that any mitigation is implemented or live. Source contracts, code-sign proof, signed artifacts, pristine-Mac setup, VM-to-Mac CUA, deployment, customer readiness, and rollout are separate gates. + +## Security objectives + +1. Only the locally installed, correctly signed Mac Access helper can exercise macOS Accessibility and Screen Recording authority. +2. Only a broker-selected and locally enrolled customer/VM/Mac/grant/runtime/binding can request a command. +3. The local user can always deny, pause, stop, revoke, or kill access without broker cooperation. +4. `Off` is the default and uncertainty state; Full Access never silently survives a helper runtime restart. +5. Workbench coexistence never creates a second connector, listener, grant, TCC identity, updater, or audit authority. +6. Pairing credentials, device keys, broker channel authority, and raw CUA data never cross renderer, logs, clipboard, issues, or support evidence. +7. Every allowed or denied command has a durable redacted local decision record before actuation. + +## Assets + +- Mac device credential and connector private key in Keychain. +- Selected customer, VM, Mac device, grant, runtime, and binding state. +- Local access mode, policy epoch, approval state, pause/revoke/kill state. +- macOS TCC grants and the responsible signed identity. +- Broker command authority, nonce/sequence replay window, and channel state. +- Local redacted audit journal and optional separately approved artifacts. +- Signed app/helper/update lineage and rollback metadata. +- Customer Mac pixels, Accessibility metadata, pointer/keyboard actions, and typed content while in memory. + +## Actors and capabilities + +| Actor | Assumed capability | Not automatically trusted | +| -------------------------- | ------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ | +| Local Mac user | Can install apps, approve TCC, choose access mode, pause/revoke/kill. | A click is not approval unless the native UI binds it to exact scope. | +| Mac Access menu app | Correctly signed local UI process. | Cannot execute CUA, read Keychain device secrets, or self-attest identity. | +| Mac Access helper | Correctly signed native authority process. | Remote input still requires valid binding, policy, audit, and command authority. | +| Workbench main | Correctly signed compatible client during coexistence. | Cannot own connector lifecycle, TCC, Keychain, broker channel, or CUA after cutover. | +| Workbench renderer/content | Potentially compromised web content. | No native secret, channel, raw binding, generic action, or direct XPC authority. | +| Embedded Python | Potentially exploitable parser/planner code. | No Keychain, broker credential, leader, audit, TCC, CUA, updater, or public IPC authority. | +| Broker/ws-proxy | Selects binding and signs bounded command authority. | Cannot override local Off/pause/revoke/kill or manufacture local approval. | +| Selected VM runtime | Produces tool requests within broker-selected scope. | Cannot nominate a different customer/device/grant/runtime or reach the Mac directly. | +| Other same-user process | Can inspect user files it can access and connect to discoverable sockets. | Same UID, PID, path, environment, or bearer-token knowledge is insufficient. | +| Network attacker | Can observe/drop/replay/tamper subject to TLS protections. | Cannot create a valid selected-binding or command signature. | +| Old/unsigned replacement | Can exist on disk or race launch/update. | Cannot inherit credentials, leadership, TCC, policy, or channel authority. | + +Root/admin compromise and malicious signed code with the same approved designated requirement are outside the v0.1 prevention boundary, but must remain visible as residual risk. The product still limits secret persistence and supports local revoke/uninstall. + +## Trust boundaries + +1. Menu app/Workbench to helper XPC connection. +2. Renderer to Workbench main IPC. +3. Helper to private Python subprocess/IPC. +4. Helper to Keychain and append-only audit files. +5. Helper to macOS TCC/CUA frameworks. +6. Helper outbound WebSocket to broker relay. +7. Broker relay to selected VM runtime. +8. Installed version to updater/replacement/rollback version. +9. Workbench-owned legacy connector to Mac Access-owned connector during migration. + +Data crossing each boundary is allowlisted, length-bounded, versioned, and fail closed. Unknown fields are rejected at authority boundaries rather than ignored. + +## Threat table + +| Threat | Attack or failure | Required prevention/detection | Fail-closed response | Proof gate | +| ------------------------------ | ------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- | +| Replay | Reuse context ID, session/channel generation, command ID, nonce, sequence, approval, or result receipt. | Complete RFC 8785 signed command payload; bounded replay cache persisted across reconnect/helper restart until maximum expiry; single-use approval. | Deny before prompt/audit decision; record `command_replayed`; never extend expiry. | Submit the same valid envelope twice and across reconnect/restart. Second and later attempts fail. | +| Stale binding | Use old binding ID/version after selection changes. | Compare signed context, outer tuple, Keychain enrollment, and current policy epoch exactly. | Invalidate queue/approvals; close old channel; effective `Off` until new binding is confirmed. | Old and new bindings interleaved; only current binding is accepted. | +| Wrong customer | Cross-tenant command or mismatched customer/VM. | Server-owned context plus local tuple equality before policy. | Deny and raise a high-severity redacted audit event; no prompt. | Mutate outer and signed customer/VM independently and together. All fail without matching enrollment. | +| Wrong Mac/device | Command targets another connector device or key. | Device, installation, and connector-key identity bound to enrollment and command authorization. | Deny; rotate/disable channel if broker claims disagree. | Structurally valid wrong-device fixture fails at runtime. | +| Duplicate process | Old Workbench connector and Mac Access both run, or two helpers race. | Atomic local leader lease plus one broker compare-and-swap that swaps the legacy and prepared grant/lease; signed identity/liveness checks. | Both clients deny work during ambiguity and never unlink/kill an unknown owner. | Launch/race/upgrade/reverse-handoff tests show one listener/channel/grant/audit writer. | +| Local untrusted client | Same-user malware steals token or connects to socket/XPC. | XPC audit token, `SecCode`, team/anchor, identifier, hardened runtime, designated requirement, per-connection nonce/epoch. | Reject connection before decoding mutation; no status details beyond generic denial. | Unsigned, ad-hoc, wrong-team, renderer, shell child, stale PID, and stolen-token tests. | +| Stolen pairing code | Attacker claims a copied public code. | High-entropy one-time code, short expiry, rate limit, atomic claim, installation nonce, local customer/device confirmation before state transition. | Claim expires/locks; pairing remains Off; rotate code. | Reuse, concurrent claim, expired code, wrong installation, wrong customer, and dismissed confirmation. | +| Revoked grant | Broker or local revoke occurs while commands are queued/in flight. | Local revocation tombstone and policy epoch checked before prompt, before actuation, and at safe cancellation boundaries. | Block new work synchronously; clear queue/approvals; close channel; erase active credential. | Race revoke against queued, prompted, executing, reconnecting, and offline commands. | +| Offline broker | Existing channel drops but queued authority remains. | Monotonic authority deadline; disconnect barrier; no offline extension. | New remote actuation denied. Local status, stop, revoke, kill, and audit remain available. | Disconnect before/after prompt and execution; no command starts after barrier. | +| Helper replacement | Attacker or stale updater swaps helper binary/service. | Signed embedding, designated requirement pin, code validity, expected path plus identity, versioned Keychain credential group, update handoff protocol. | Refuse leadership/Keychain/channel; effective Off; show repair path. | Replace, ad-hoc sign, wrong team, moved path, old signed version, and partial update tests. | +| Downgrade | Old but validly signed app reads new state or reacquires credentials. | Broker rejects missing/below-floor immutable build identity; critical floor rotation creates a new credential/access-group epoch and revokes the old credential; rollback names exact target. | Refuse transport/Keychain leadership and remain Off; preserve state for compatible recovery. | Install old signed builds below/above the floor and interrupt every credential rotation/update/rollback transition. | +| Crash recovery | Full Access or stale approval silently resumes. | Runtime-instance-bound Full Access confirmation; ephemeral approval store; policy epoch increment on recovery. | Effective Ask Every Time or Off; queue and approvals cleared. | Kill -9 menu/helper/Python at every transition and relaunch. | +| Request tampering | Change command body after approval/signature. | Canonical request digest in command authorization and local approval; recompute immediately before execution. | Deny without executing; invalidate approval. | Mutate every approved field, target snapshot/path, capability, and encoding. | +| Malicious/compromised renderer | Invoke generic native IPC or obtain secrets. | Main-process allowlist and redaction; authenticated helper accepts only signed main processes and fixed actions. | Deny; renderer gets generic code and audit ID only. | XSS/DevTools-like calls cannot reach raw XPC, CUA, Keychain, channel, or logs. | +| Python compromise | Planner process reads secrets or calls CUA directly. | Sandboxed/minimal environment; no secret FDs; native ports; normalized bounded messages; helper revalidates output. | Terminate Python; effective Off for command path; helper and local controls remain available. | Python emits unknown action, oversized payload, forged result, path traversal, or direct framework attempt. | +| Secret/log leakage | Token, typed text, screenshot, AX tree, path, env, or address enters audit/support logs. | Redaction schema denylist plus allowlisted evidence; bounded errors; secret scan. | Reject audit event/receipt; actuation denied if decision cannot be recorded safely. | Negative fixture and recursive scans over logs, crashes, diagnostics, issue packets. | +| Audit deletion/tamper | User malware truncates or edits local audit. | Owner-only files, append-only API, contract-required monotonic sequence/previous-record/record digests, optional broker receipt correlation. | Mark chain broken, effective Off, preserve evidence, require repair. | Edit/delete/reorder records and verify detection before next action. | +| Audit unavailable | Disk full, permissions wrong, corruption, I/O failure. | Pre-execution durable decision write and health check. | Effective Off; no actuation; minimal local blocker if possible. | Fault injection for open/write/fsync/rename/disk-full failures. | +| Kill-switch race | Command starts while local kill is activating. | In-memory atomic deny barrier first; policy epoch rotation; checks before prompt and actuation; safe cancellation. | No new action after barrier; in-flight action stops at safe boundary; channel cleanup best effort. | High-rate command stream while kill switch is toggled. | +| Approval confusion | Prompt label differs from executable target or is reused. | Approval binds selected tuple, capability, normalized target, request digest, policy epoch, and expiry. | Dismiss/change/timeout denies; changed target requires new prompt. | TOCTOU mutation between preview, prompt, and execution. | +| Broker key rotation | Connector accepts an old/unknown signing key or bricks on valid rotation. | Signed keyset with activation/retirement windows; key ID pinning; rollback plan. | Unknown/retired key denies; never fall back unsigned. | Overlap, early/late rotation, rollback, missing keyset, and clock skew. | +| Clock manipulation | Wall clock extends expiry or breaks ordering. | Signed absolute time plus monotonic elapsed deadline established on receipt; bounded clock skew. | Suspicious jump denies current channel and reconnects. | Move wall clock forward/back during queued/prompted commands. | +| Oversized/flooded input | Memory/CPU/disk exhaustion via WebSocket or XPC. | Frame limits, rate limits, bounded queue, backpressure, timeouts, bounded audit text. | Drop/reject and eventually close abusive channel; local kill remains responsive. | Oversized, fragmented, high-rate, and slow-loris scenarios. | +| Direct-network bypass | Runtime discovers a Mac URL/IP/token or public listener. | No target listener; outbound relay only; firewall/listener checks; no renderer/broker payload contains endpoint. | Pairing/transport blocked if an unauthorized listener is detected. | `lsof`/packet evidence on pristine and coexistence Macs; prompt/output secret scans. | +| Update split-brain | New menu with old helper or vice versa accepts incompatible authority. | Compatibility handshake, signed build identity, atomic handoff, access Off during update. | Refuse channel and mutations; rollback to exact signed compatible pair. | Interrupt update at each file/service transition. | +| Orphan cleanup abuse | Installer kills/deletes unrelated same-name process/file. | Match recorded path plus signed identity and installation ID; never trust name/PID alone. | Leave unknown process untouched and show support blocker. | Decoy binaries/processes and relocated prior versions. | +| Workbench fallback takeover | Workbench silently starts its legacy connector while Mac Access is leader. | Exclusive ownership status and #704 client cutover; legacy start guarded by proven absence/relinquish. | Workbench shows Mac Access status or blocker; no fallback start. | Start both apps in every order, crash either, upgrade/downgrade each. | + +## Pairing analysis + +The pairing code is public by design and therefore is not a long-term secret. It is only a short-lived rendezvous capability. Security comes from atomic one-time claim, local installation binding, broker authentication, selected customer/device confirmation in the native UI, and Keychain device-key issuance after confirmation. + +The pairing UI must display the selected customer and this Mac's human-readable device name without trusting text supplied by the remote runtime. It must not display or copy raw customer IDs by default, connector URLs, IPs, ports, tokens, SSH/VNC/CDP text, or broker JSON. A code claim never changes mode directly to Full Access; explicit consent moves unpaired Off to paired Ask Every Time. + +## Local code-identity analysis + +The current PR #697 helper IPC uses an owner-only Unix socket, file capability token, and same-UID peer check. Those controls reduce accidental access but do not distinguish Workbench main from any other process owned by the same user that can obtain the token. Environment-provided bundle/path attribution also does not prove the caller's signature. + +The target XPC boundary uses the kernel-provided audit token and Security.framework code validation for the exact connection process. PID, path, bundle ID, or environment is supplemental evidence only. Designated requirements must bind the expected release team/anchor and signing identifier. Debug/ad-hoc builds use an explicit development requirement and cannot read production Keychain items or connect to production relay authority. + +Production Keychain items use an epoch group derived from the `com.evaos.mac-access.credentials` base and the `com.evaos.mac-access.connector-credential` service, are non-synchronizing and ThisDeviceOnly/WhenUnlocked, and are granted only to the production helper target accepted for that security epoch. Development uses the disjoint `com.evaos.mac-access.development.credentials` base. Menu, Workbench, Python, debug, old, and replacement-helper denial is a required installed-artifact test. Because old signed code cannot enforce a new local rule, ws-proxy #73 must reject old build/security identities and old credentials; critical rotations re-enroll into a new access group before revoking/deleting the old credential. A valid same-team signature without the exact broker-accepted build, credential epoch, helper requirement, and protected security floor is insufficient. + +## TCC analysis + +The frozen product identity is `com.evaos.mac-access`; the embedded helper service is `com.evaos.mac-access.helper`. The native helper is the only process permitted to call CUA frameworks, but actual macOS TCC responsibility must be verified on signed installed artifacts. Until that evidence exists, the source design must not claim that prompts or grants attach to the intended identity. + +Workbench and Python must not retain direct CUA fallback after cutover. If native helper permission is missing, the only allowed response is a truthful blocked status and local System Settings handoff. + +## Remote authority analysis + +The ws-proxy #69 execution context signs runtime, customer, VM, binding ID/version, issue/expiry, key ID, and context ID. It intentionally does not sign a command. Mac Access therefore also requires the ws-proxy #73 Ed25519 authorization over the RFC 8785 canonical `command_authority_payload.v1`. That payload includes the entire selected tuple, session/channel generation, exact request/context digests, capability, nonce/sequence, and contained authority interval. The committed golden vector freezes canonical bytes, digest, public key, and signature. Verifying only #69 would permit a trusted runtime boundary to substitute a different command within the same short-lived context. + +The connector never treats TLS, channel authentication, or a valid signature alone as permission to act. Local selected-binding equality, access mode, approval, audit health, revocation, pause, and kill switch are independent mandatory checks. + +## Data minimization and retention + +Persistent default data is limited to: + +- Keychain device credential/key and opaque broker registration metadata; +- versioned selected-binding state and revocation tombstone; +- access configuration, policy epoch, and lifecycle state; +- redacted audit journal and updater/rollback metadata. + +Full screenshots, AX trees, clipboard content, typed strings, command payloads, and raw native errors remain in bounded memory only for the shortest execution window. Crash reports and analytics must exclude them. Optional evidence artifacts require a separate design and consent; they are not implied by v0.1. + +## Residual risks + +- Root/admin or malicious software signed under the same permitted requirement can subvert local controls. +- macOS TCC attribution and login-item behavior may differ across supported OS versions and require installed-artifact proof. +- Broker signing-key compromise can create otherwise valid remote authority, though local policy and kill controls still apply. +- Accessibility APIs can expose sensitive UI content in memory even when persistence is forbidden. +- A user can intentionally grant Full Access; the product must make that state continuously visible and easy to stop. + +These risks do not justify weaker local identity, direct networking, silent fallback, or broader logging. + +## Required adversarial evidence before downstream completion + +- Every negative fixture under `packages/mac-connector-core/contracts/v1/fixtures/invalid` is exercised at its declared schema or runtime gate. +- Signed-client impersonation and helper replacement matrix passes on supported macOS versions. +- Binding/signature/digest/replay/expiry/key-rotation matrix passes against source-only relay canary before deployment. +- Crash, update, downgrade, uninstall, offline, audit-failure, stop/revoke/kill races remain fail closed. +- Workbench coexistence proves one leader, channel, grant, TCC identity, audit writer, and updater. +- Redaction scan covers audit, diagnostics, crash logs, CI artifacts, PR/issue evidence, and renderer payloads. + +Passing this model is necessary but not sufficient for release or customer readiness. diff --git a/packages/mac-connector-core/README.md b/packages/mac-connector-core/README.md new file mode 100644 index 0000000000..654b75871b --- /dev/null +++ b/packages/mac-connector-core/README.md @@ -0,0 +1,7 @@ +# evaOS Mac connector core + +This is the single reusable connector source boundary defined by [evaOS-GUI#699](https://github.com/100yenadmin/evaOS-GUI/issues/699). + +PR #699 owns only the versioned contracts and cross-language JSON fixtures under `contracts/v1`. Moving the PR #697 Python source, adding native Swift ports, and integrating consumers belong to downstream child issues after their dependency gates are satisfied. + +The archived `electricsheephq/evaos-desktop-bridge` repository is provenance only and must not be restored as source, build, or runtime truth. diff --git a/packages/mac-connector-core/contracts/v1/fixtures/invalid/binding.json b/packages/mac-connector-core/contracts/v1/fixtures/invalid/binding.json new file mode 100644 index 0000000000..9b14511c86 --- /dev/null +++ b/packages/mac-connector-core/contracts/v1/fixtures/invalid/binding.json @@ -0,0 +1,117 @@ +[ + { + "id": "wrong-customer", + "threat": "wrong_customer", + "contract": "broker_control", + "base_fixture": "../valid/broker-control.json", + "mutations": [ + { + "operation": "set", + "pointer": "/binding/customer_id", + "value": "customer-02" + } + ], + "expected_stage": "schema", + "expected_error": "execution_context_binding_mismatch", + "required_runtime_rejection": null + }, + { + "id": "stale-binding-version", + "threat": "stale_binding", + "contract": "broker_control", + "base_fixture": "../valid/broker-control.json", + "mutations": [ + { + "operation": "set", + "pointer": "/binding/binding_version", + "value": "v2" + } + ], + "expected_stage": "schema", + "expected_error": "execution_context_binding_mismatch", + "required_runtime_rejection": null + }, + { + "id": "wrong-device", + "threat": "wrong_device", + "contract": "broker_control", + "base_fixture": "../valid/broker-control.json", + "mutations": [ + { + "operation": "set", + "pointer": "/binding/device_id", + "value": "mac-02" + } + ], + "expected_stage": "schema", + "expected_error": "selected_device_mismatch", + "required_runtime_rejection": null + }, + { + "id": "revoked-grant", + "threat": "revoked_grant", + "contract": "broker_control", + "base_fixture": "../valid/broker-control.json", + "mutations": [], + "expected_stage": "runtime", + "expected_error": "grant_revoked", + "required_runtime_rejection": "Reject the envelope when the local revocation tombstone or newer broker policy epoch marks grant-01 revoked." + }, + { + "id": "wrong-grant", + "threat": "wrong_grant", + "contract": "broker_control", + "base_fixture": "../valid/broker-control.json", + "mutations": [{ "operation": "set", "pointer": "/binding/grant_id", "value": "grant-02" }], + "expected_stage": "schema", + "expected_error": "selected_grant_mismatch", + "required_runtime_rejection": null + }, + { + "id": "wrong-runtime", + "threat": "wrong_runtime", + "contract": "broker_control", + "base_fixture": "../valid/broker-control.json", + "mutations": [{ "operation": "set", "pointer": "/binding/runtime", "value": "hermes" }], + "expected_stage": "schema", + "expected_error": "selected_runtime_mismatch", + "required_runtime_rejection": null + }, + { + "id": "wrong-installation", + "threat": "wrong_installation", + "contract": "broker_control", + "base_fixture": "../valid/broker-control.json", + "mutations": [{ "operation": "set", "pointer": "/binding/connector_installation_id", "value": "install-02" }], + "expected_stage": "schema", + "expected_error": "connector_installation_mismatch", + "required_runtime_rejection": null + }, + { + "id": "wrong-connector-key", + "threat": "wrong_connector_key", + "contract": "broker_control", + "base_fixture": "../valid/broker-control.json", + "mutations": [{ "operation": "set", "pointer": "/binding/connector_key_id", "value": "mac-key-02" }], + "expected_stage": "schema", + "expected_error": "connector_key_mismatch", + "required_runtime_rejection": null + }, + { + "id": "signed-payload-tampered-grant", + "threat": "command_authority_tampering", + "contract": "broker_control", + "base_fixture": "../valid/broker-control.json", + "mutations": [ + { "operation": "set", "pointer": "/binding/grant_id", "value": "grant-02" }, + { + "operation": "set", + "pointer": "/authorization/payload/binding/grant_id", + "value": "grant-02" + } + ], + "expected_stage": "runtime", + "expected_error": "command_authorization_digest_or_signature_mismatch", + "required_runtime_rejection": "RFC 8785 canonicalize the complete command-authority payload, recompute its SHA-256, verify the Ed25519 signature, and compare the selected grant with local enrollment before policy evaluation." + } +] diff --git a/packages/mac-connector-core/contracts/v1/fixtures/invalid/identity.json b/packages/mac-connector-core/contracts/v1/fixtures/invalid/identity.json new file mode 100644 index 0000000000..9a58dcae06 --- /dev/null +++ b/packages/mac-connector-core/contracts/v1/fixtures/invalid/identity.json @@ -0,0 +1,50 @@ +[ + { + "id": "untrusted-local-client", + "threat": "local_untrusted_client", + "contract": "authenticated_local_action", + "base_fixture": "../valid/local-action.json", + "mutations": [ + { + "operation": "set", + "pointer": "/peer/signing_identifier", + "value": "com.evaos.mac-access" + } + ], + "expected_stage": "schema", + "expected_error": "peer_designated_requirement_mismatch", + "required_runtime_rejection": null + }, + { + "id": "duplicate-leader", + "threat": "duplicate_process", + "contract": "local_status", + "base_fixture": "../valid/local-status.json", + "mutations": [ + { + "operation": "set", + "pointer": "/leader/runtime_instance_id", + "value": "runtime-instance-02" + } + ], + "expected_stage": "schema", + "expected_error": "leader_runtime_mismatch", + "required_runtime_rejection": null + }, + { + "id": "helper-replacement", + "threat": "helper_replacement", + "contract": "local_status", + "base_fixture": "../valid/local-status.json", + "mutations": [ + { + "operation": "set", + "pointer": "/leader/helper_service_id", + "value": "com.attacker.helper" + } + ], + "expected_stage": "schema", + "expected_error": "helper_identity_mismatch", + "required_runtime_rejection": null + } +] diff --git a/packages/mac-connector-core/contracts/v1/fixtures/invalid/policy.json b/packages/mac-connector-core/contracts/v1/fixtures/invalid/policy.json new file mode 100644 index 0000000000..8c8afebe46 --- /dev/null +++ b/packages/mac-connector-core/contracts/v1/fixtures/invalid/policy.json @@ -0,0 +1,303 @@ +[ + { + "id": "stale-rollback-authorization-id", + "threat": "signed_downgrade", + "contract": "local_status", + "base_fixture": "../valid/local-status.json", + "mutations": [ + { + "operation": "set", + "pointer": "/relay_authorization/rollback_authorization/payload/authorization_id", + "value": "rollback-stale" + } + ], + "expected_stage": "schema", + "expected_error": "rollback_authorization_id_mismatch", + "required_runtime_rejection": null + }, + { + "id": "rollback-wrong-target", + "threat": "signed_downgrade", + "contract": "local_status", + "base_fixture": "../valid/local-status.json", + "mutations": [ + { + "operation": "set", + "pointer": "/relay_authorization/rollback_authorization/payload/target/source_commit", + "value": "cccccccccccccccccccccccccccccccccccccccc" + } + ], + "expected_stage": "schema", + "expected_error": "rollback_target_mismatch", + "required_runtime_rejection": null + }, + { + "id": "expired-rollback-authorization", + "threat": "signed_downgrade", + "contract": "local_status", + "base_fixture": "../valid/local-status.json", + "mutations": [ + { + "operation": "set", + "pointer": "/relay_authorization/rollback_authorization/payload/expires_at", + "value": "2026-07-15T08:00:00Z" + } + ], + "expected_stage": "schema", + "expected_error": "rollback_authorization_expired", + "required_runtime_rejection": null + }, + { + "id": "stop-with-pending-authority", + "threat": "stop_race", + "contract": "access_transition", + "base_fixture": "../valid/access-transition-stop.json", + "mutations": [{ "operation": "set", "pointer": "/invalidated_pending_authority", "value": false }], + "expected_stage": "schema", + "expected_error": "stop_pending_authority_not_invalidated", + "required_runtime_rejection": null + }, + { + "id": "crash-restores-full-access", + "threat": "crash_recovery", + "contract": "access_state", + "base_fixture": "../valid/access-state.json", + "mutations": [ + { + "operation": "set", + "pointer": "/configured_mode", + "value": "full_access" + }, + { + "operation": "set", + "pointer": "/effective_mode", + "value": "full_access" + }, + { + "operation": "set", + "pointer": "/local_confirmation_required", + "value": true + } + ], + "expected_stage": "schema", + "expected_error": "full_access_reconfirmation_required", + "required_runtime_rejection": null + }, + { + "id": "raw-secret-audit", + "threat": "log_secret_exposure", + "contract": "audit_event", + "base_fixture": "../valid/audit-event.json", + "mutations": [ + { + "operation": "set", + "pointer": "/evidence/connector_token", + "value": "must-not-be-recorded" + } + ], + "expected_stage": "schema", + "expected_error": "audit_forbidden_field", + "required_runtime_rejection": null + }, + { + "id": "unsupported-downgrade-schema", + "threat": "downgrade", + "contract": "broker_control", + "base_fixture": "../valid/broker-control.json", + "mutations": [ + { + "operation": "set", + "pointer": "/schema_version", + "value": "evaos.mac_access.broker_control.v0" + } + ], + "expected_stage": "schema", + "expected_error": "unsupported_schema", + "required_runtime_rejection": null + }, + { + "id": "stolen-pairing-code", + "threat": "stolen_pairing_code", + "contract": "authenticated_local_action", + "base_fixture": "../valid/local-action.json", + "mutations": [ + { + "operation": "set", + "pointer": "/request/action", + "value": "begin_pairing" + }, + { + "operation": "set", + "pointer": "/request/target_mode", + "value": null + } + ], + "expected_stage": "runtime", + "expected_error": "pairing_code_reused_or_claimed", + "required_runtime_rejection": "The one-time code must be short-lived, rate-limited, atomically claimed, and bound to the locally displayed installation nonce before pairing state changes." + }, + { + "id": "audit-write-failure", + "threat": "audit_unavailable", + "contract": "local_status", + "base_fixture": "../valid/local-status.json", + "mutations": [ + { + "operation": "set", + "pointer": "/audit/writable", + "value": false + } + ], + "expected_stage": "schema", + "expected_error": "audit_failure_not_off", + "required_runtime_rejection": null + }, + { + "id": "configured-off-effective-ask", + "threat": "authority_exceeds_user_intent", + "contract": "access_state", + "base_fixture": "../valid/access-state.json", + "mutations": [{ "operation": "set", "pointer": "/configured_mode", "value": "off" }], + "expected_stage": "schema", + "expected_error": "effective_mode_exceeds_configured_mode", + "required_runtime_rejection": null + }, + { + "id": "tcc-denied-with-effective-access", + "threat": "tcc_loss", + "contract": "local_status", + "base_fixture": "../valid/local-status.json", + "mutations": [{ "operation": "set", "pointer": "/tcc/accessibility", "value": "denied" }], + "expected_stage": "schema", + "expected_error": "tcc_loss_not_off", + "required_runtime_rejection": null + }, + { + "id": "connected-without-channel", + "threat": "transport_state_confusion", + "contract": "local_status", + "base_fixture": "../valid/local-status.json", + "mutations": [{ "operation": "set", "pointer": "/transport/channel_id", "value": null }], + "expected_stage": "schema", + "expected_error": "connected_channel_missing", + "required_runtime_rejection": null + }, + { + "id": "begin-pairing-without-epoch", + "threat": "stale_local_client", + "contract": "authenticated_local_action", + "base_fixture": "../valid/local-action.json", + "mutations": [ + { "operation": "set", "pointer": "/request/action", "value": "begin_pairing" }, + { "operation": "set", "pointer": "/request/expected_policy_epoch", "value": null }, + { "operation": "set", "pointer": "/request/target_mode", "value": null } + ], + "expected_stage": "schema", + "expected_error": "pairing_epoch_required", + "required_runtime_rejection": null + }, + { + "id": "audit-screenshot-field", + "threat": "raw_screen_persistence", + "contract": "audit_event", + "base_fixture": "../valid/audit-event.json", + "mutations": [{ "operation": "set", "pointer": "/evidence/screenshot", "value": "raw-data" }], + "expected_stage": "schema", + "expected_error": "audit_evidence_not_allowlisted", + "required_runtime_rejection": null + }, + { + "id": "audit-clipboard-field", + "threat": "clipboard_persistence", + "contract": "audit_event", + "base_fixture": "../valid/audit-event.json", + "mutations": [{ "operation": "set", "pointer": "/evidence/clipboard", "value": "copied-data" }], + "expected_stage": "schema", + "expected_error": "audit_evidence_not_allowlisted", + "required_runtime_rejection": null + }, + { + "id": "audit-secret-bearing-value", + "threat": "secret_value_persistence", + "contract": "audit_event", + "base_fixture": "../valid/audit-event.json", + "mutations": [{ "operation": "set", "pointer": "/evidence/detail_code", "value": "Bearer-secret-material" }], + "expected_stage": "schema", + "expected_error": "audit_secret_bearing_value", + "required_runtime_rejection": null + }, + { + "id": "audit-chain-gap", + "threat": "audit_tampering", + "contract": "audit_event", + "base_fixture": "../valid/audit-event.json", + "mutations": [{ "operation": "set", "pointer": "/sequence", "value": 2 }], + "expected_stage": "schema", + "expected_error": "audit_previous_digest_required", + "required_runtime_rejection": null + }, + { + "id": "signed-downgrade-below-security-floor", + "threat": "signed_downgrade", + "contract": "local_status", + "base_fixture": "../valid/local-status.json", + "mutations": [ + { "operation": "set", "pointer": "/access/minimum_reader_security_epoch", "value": 2 }, + { "operation": "set", "pointer": "/access/minimum_writer_security_epoch", "value": 2 } + ], + "expected_stage": "schema", + "expected_error": "build_below_security_floor", + "required_runtime_rejection": null + }, + { + "id": "pairing-directly-enables-full-access", + "threat": "pairing_consent_escalation", + "contract": "access_transition", + "base_fixture": "../valid/access-transition.json", + "mutations": [ + { "operation": "set", "pointer": "/target_mode", "value": "full_access" }, + { "operation": "set", "pointer": "/to/configured_mode", "value": "full_access" }, + { "operation": "set", "pointer": "/to/effective_mode", "value": "full_access" }, + { + "operation": "set", + "pointer": "/to/confirmed_runtime_instance_id", + "value": "runtime-instance-01" + }, + { "operation": "set", "pointer": "/to/confirmed_policy_epoch", "value": 7 }, + { + "operation": "set", + "pointer": "/to/confirmed_binding_fingerprint_sha256", + "value": "1111111111111111111111111111111111111111111111111111111111111111" + } + ], + "expected_stage": "schema", + "expected_error": "pairing_must_default_to_ask_every_time", + "required_runtime_rejection": null + }, + { + "id": "full-access-stale-policy-confirmation", + "threat": "stale_full_access_confirmation", + "contract": "access_state", + "base_fixture": "../valid/full-access-state.json", + "mutations": [{ "operation": "set", "pointer": "/confirmed_policy_epoch", "value": 6 }], + "expected_stage": "schema", + "expected_error": "full_access_policy_epoch_mismatch", + "required_runtime_rejection": null + }, + { + "id": "full-access-stale-binding-confirmation", + "threat": "stale_full_access_binding", + "contract": "access_state", + "base_fixture": "../valid/full-access-state.json", + "mutations": [ + { + "operation": "set", + "pointer": "/confirmed_binding_fingerprint_sha256", + "value": "3333333333333333333333333333333333333333333333333333333333333333" + } + ], + "expected_stage": "schema", + "expected_error": "full_access_binding_mismatch", + "required_runtime_rejection": null + } +] diff --git a/packages/mac-connector-core/contracts/v1/fixtures/invalid/transport.json b/packages/mac-connector-core/contracts/v1/fixtures/invalid/transport.json new file mode 100644 index 0000000000..b881b47791 --- /dev/null +++ b/packages/mac-connector-core/contracts/v1/fixtures/invalid/transport.json @@ -0,0 +1,84 @@ +[ + { + "id": "expired-command", + "threat": "expired_authority", + "contract": "broker_control", + "base_fixture": "../valid/broker-control.json", + "mutations": [ + { + "operation": "set", + "pointer": "/expires_at", + "value": "2026-07-15T08:02:30Z" + } + ], + "expected_stage": "schema", + "expected_error": "command_authority_too_long", + "required_runtime_rejection": null + }, + { + "id": "replayed-command", + "threat": "replay", + "contract": "broker_control", + "base_fixture": "../valid/broker-control.json", + "mutations": [], + "expected_stage": "runtime", + "expected_error": "command_replayed", + "required_runtime_rejection": "Submit the same context ID, command ID, nonce, and sequence twice; the second attempt must be denied before local approval or actuation." + }, + { + "id": "offline-broker-actuation", + "threat": "offline_broker", + "contract": "broker_control", + "base_fixture": "../valid/broker-control.json", + "mutations": [], + "expected_stage": "runtime", + "expected_error": "broker_authority_offline", + "required_runtime_rejection": "After the authority deadline or broker disconnect barrier, queued and newly received commands must be denied even if their JSON shape remains valid." + }, + { + "id": "request-digest-mismatch", + "threat": "request_tampering", + "contract": "broker_control", + "base_fixture": "../valid/broker-control.json", + "mutations": [ + { + "operation": "set", + "pointer": "/command/request/target_snapshot_id", + "value": "snapshot-tampered" + } + ], + "expected_stage": "runtime", + "expected_error": "request_digest_mismatch", + "required_runtime_rejection": "Recompute the canonical request and envelope digests and reject before policy evaluation when either signed digest differs." + }, + { + "id": "wrong-channel-generation", + "threat": "reconnect_replay", + "contract": "broker_control", + "base_fixture": "../valid/broker-control.json", + "mutations": [{ "operation": "set", "pointer": "/channel_generation_id", "value": "channel-generation-02" }], + "expected_stage": "schema", + "expected_error": "channel_generation_mismatch", + "required_runtime_rejection": null + }, + { + "id": "authority-outlives-context", + "threat": "context_expiry_escape", + "contract": "broker_control", + "base_fixture": "../valid/broker-control.json", + "mutations": [{ "operation": "set", "pointer": "/execution_context/claims/expires_at", "value": 1784102470 }], + "expected_stage": "schema", + "expected_error": "authority_outlives_execution_context", + "required_runtime_rejection": null + }, + { + "id": "execution-context-payload-tampered", + "threat": "execution_context_tampering", + "contract": "broker_control", + "base_fixture": "../valid/broker-control.json", + "mutations": [{ "operation": "set", "pointer": "/execution_context/payload_base64url", "value": "dGFtcGVyZWQ" }], + "expected_stage": "runtime", + "expected_error": "execution_context_digest_or_signature_mismatch", + "required_runtime_rejection": "Decode the execution-context payload, recompute its SHA-256, verify its Ed25519 signature, and require the decoded claims to equal the typed claims before command authorization." + } +] diff --git a/packages/mac-connector-core/contracts/v1/fixtures/valid/access-state.json b/packages/mac-connector-core/contracts/v1/fixtures/valid/access-state.json new file mode 100644 index 0000000000..9a5093c1db --- /dev/null +++ b/packages/mac-connector-core/contracts/v1/fixtures/valid/access-state.json @@ -0,0 +1,31 @@ +{ + "schema_version": "evaos.mac_access.access_state.v1", + "runtime_instance_id": "runtime-instance-01", + "state_security_epoch": 1, + "minimum_reader_security_epoch": 1, + "minimum_writer_security_epoch": 1, + "policy_epoch": 7, + "pairing_state": "paired", + "configured_mode": "ask_every_time", + "effective_mode": "ask_every_time", + "paused": false, + "kill_switch": false, + "local_confirmation_required": false, + "confirmed_runtime_instance_id": null, + "confirmed_policy_epoch": null, + "confirmed_binding_fingerprint_sha256": null, + "binding": { + "customer_id": "customer-01", + "customer_vm_id": "vm-01", + "device_id": "mac-01", + "grant_id": "grant-01", + "runtime": "openclaw", + "binding_id": "binding-01", + "binding_version": "v3", + "connector_installation_id": "install-01", + "connector_key_id": "mac-key-01", + "binding_fingerprint_sha256": "1111111111111111111111111111111111111111111111111111111111111111" + }, + "changed_at": "2026-07-15T08:00:00Z", + "reason_code": "pairing_confirmed" +} diff --git a/packages/mac-connector-core/contracts/v1/fixtures/valid/access-transition-stop.json b/packages/mac-connector-core/contracts/v1/fixtures/valid/access-transition-stop.json new file mode 100644 index 0000000000..c26356aeb6 --- /dev/null +++ b/packages/mac-connector-core/contracts/v1/fixtures/valid/access-transition-stop.json @@ -0,0 +1,71 @@ +{ + "schema_version": "evaos.mac_access.access_transition.v1", + "transition_id": "transition-stop-01", + "event": "stop", + "explicit_user_consent": true, + "invalidated_pending_authority": true, + "safe_cancellation_requested": true, + "target_mode": null, + "from": { + "schema_version": "evaos.mac_access.access_state.v1", + "runtime_instance_id": "runtime-instance-01", + "state_security_epoch": 1, + "minimum_reader_security_epoch": 1, + "minimum_writer_security_epoch": 1, + "policy_epoch": 7, + "pairing_state": "paired", + "configured_mode": "ask_every_time", + "effective_mode": "ask_every_time", + "paused": false, + "kill_switch": false, + "local_confirmation_required": false, + "confirmed_runtime_instance_id": null, + "confirmed_policy_epoch": null, + "confirmed_binding_fingerprint_sha256": null, + "binding": { + "customer_id": "customer-01", + "customer_vm_id": "vm-01", + "device_id": "mac-01", + "grant_id": "grant-01", + "runtime": "openclaw", + "binding_id": "binding-01", + "binding_version": "v3", + "connector_installation_id": "install-01", + "connector_key_id": "mac-key-01", + "binding_fingerprint_sha256": "1111111111111111111111111111111111111111111111111111111111111111" + }, + "changed_at": "2026-07-15T08:00:00Z", + "reason_code": "pairing_confirmed" + }, + "to": { + "schema_version": "evaos.mac_access.access_state.v1", + "runtime_instance_id": "runtime-instance-01", + "state_security_epoch": 1, + "minimum_reader_security_epoch": 1, + "minimum_writer_security_epoch": 1, + "policy_epoch": 8, + "pairing_state": "paired", + "configured_mode": "ask_every_time", + "effective_mode": "off", + "paused": false, + "kill_switch": false, + "local_confirmation_required": false, + "confirmed_runtime_instance_id": null, + "confirmed_policy_epoch": null, + "confirmed_binding_fingerprint_sha256": null, + "binding": { + "customer_id": "customer-01", + "customer_vm_id": "vm-01", + "device_id": "mac-01", + "grant_id": "grant-01", + "runtime": "openclaw", + "binding_id": "binding-01", + "binding_version": "v3", + "connector_installation_id": "install-01", + "connector_key_id": "mac-key-01", + "binding_fingerprint_sha256": "1111111111111111111111111111111111111111111111111111111111111111" + }, + "changed_at": "2026-07-15T08:01:00Z", + "reason_code": "local_stop" + } +} diff --git a/packages/mac-connector-core/contracts/v1/fixtures/valid/access-transition.json b/packages/mac-connector-core/contracts/v1/fixtures/valid/access-transition.json new file mode 100644 index 0000000000..e3f914c3eb --- /dev/null +++ b/packages/mac-connector-core/contracts/v1/fixtures/valid/access-transition.json @@ -0,0 +1,60 @@ +{ + "schema_version": "evaos.mac_access.access_transition.v1", + "transition_id": "transition-01", + "event": "pair_confirmed", + "explicit_user_consent": true, + "invalidated_pending_authority": false, + "safe_cancellation_requested": false, + "target_mode": "ask_every_time", + "from": { + "schema_version": "evaos.mac_access.access_state.v1", + "runtime_instance_id": "runtime-instance-01", + "state_security_epoch": 1, + "minimum_reader_security_epoch": 1, + "minimum_writer_security_epoch": 1, + "policy_epoch": 6, + "pairing_state": "unpaired", + "configured_mode": "off", + "effective_mode": "off", + "paused": false, + "kill_switch": false, + "local_confirmation_required": false, + "confirmed_runtime_instance_id": null, + "confirmed_policy_epoch": null, + "confirmed_binding_fingerprint_sha256": null, + "binding": null, + "changed_at": "2026-07-15T07:59:00Z", + "reason_code": "not_paired" + }, + "to": { + "schema_version": "evaos.mac_access.access_state.v1", + "runtime_instance_id": "runtime-instance-01", + "state_security_epoch": 1, + "minimum_reader_security_epoch": 1, + "minimum_writer_security_epoch": 1, + "policy_epoch": 7, + "pairing_state": "paired", + "configured_mode": "ask_every_time", + "effective_mode": "ask_every_time", + "paused": false, + "kill_switch": false, + "local_confirmation_required": false, + "confirmed_runtime_instance_id": null, + "confirmed_policy_epoch": null, + "confirmed_binding_fingerprint_sha256": null, + "binding": { + "customer_id": "customer-01", + "customer_vm_id": "vm-01", + "device_id": "mac-01", + "grant_id": "grant-01", + "runtime": "openclaw", + "binding_id": "binding-01", + "binding_version": "v3", + "connector_installation_id": "install-01", + "connector_key_id": "mac-key-01", + "binding_fingerprint_sha256": "1111111111111111111111111111111111111111111111111111111111111111" + }, + "changed_at": "2026-07-15T08:00:00Z", + "reason_code": "pairing_confirmed" + } +} diff --git a/packages/mac-connector-core/contracts/v1/fixtures/valid/audit-chain-golden.json b/packages/mac-connector-core/contracts/v1/fixtures/valid/audit-chain-golden.json new file mode 100644 index 0000000000..c30a5468e9 --- /dev/null +++ b/packages/mac-connector-core/contracts/v1/fixtures/valid/audit-chain-golden.json @@ -0,0 +1,55 @@ +{ + "schema_version": "evaos.mac_access.audit_chain_golden.v1", + "canonicalization": "RFC8785-JCS", + "records": [ + { + "payload": { + "schema_version": "evaos.mac_access.audit_event.v1", + "audit_id": "audit-01", + "sequence": 1, + "previous_record_sha256": null, + "occurred_at": "2026-07-15T08:01:03Z", + "event_type": "command_result", + "actor": { "kind": "broker_runtime", "identity": "openclaw" }, + "binding_fingerprint_sha256": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", + "command_id": "command-01", + "request_digest_sha256": "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", + "access_mode": "ask_every_time", + "outcome": "executed", + "reason_code": "approved_exact_scope", + "evidence": { + "capability": "customer_mac.desktop_click", + "target_path_hash": "path-hash-01", + "artifact_count": 0, + "redaction_policy": "default_v1" + } + }, + "canonical_payload_utf8": "{\"access_mode\":\"ask_every_time\",\"actor\":{\"identity\":\"openclaw\",\"kind\":\"broker_runtime\"},\"audit_id\":\"audit-01\",\"binding_fingerprint_sha256\":\"ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff\",\"command_id\":\"command-01\",\"event_type\":\"command_result\",\"evidence\":{\"artifact_count\":0,\"capability\":\"customer_mac.desktop_click\",\"redaction_policy\":\"default_v1\",\"target_path_hash\":\"path-hash-01\"},\"occurred_at\":\"2026-07-15T08:01:03Z\",\"outcome\":\"executed\",\"previous_record_sha256\":null,\"reason_code\":\"approved_exact_scope\",\"request_digest_sha256\":\"dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd\",\"schema_version\":\"evaos.mac_access.audit_event.v1\",\"sequence\":1}", + "record_sha256": "ec3235267ea927e32c2f99ad05b7926c6ed4942ca3055263d3b000d88eb585f7" + }, + { + "payload": { + "schema_version": "evaos.mac_access.audit_event.v1", + "audit_id": "audit-02", + "sequence": 2, + "previous_record_sha256": "ec3235267ea927e32c2f99ad05b7926c6ed4942ca3055263d3b000d88eb585f7", + "occurred_at": "2026-07-15T08:01:04Z", + "event_type": "lifecycle", + "actor": { "kind": "local_user", "identity": "console-user" }, + "binding_fingerprint_sha256": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", + "command_id": null, + "request_digest_sha256": null, + "access_mode": "off", + "outcome": "stopped", + "reason_code": "local_stop", + "evidence": { + "state_from": "ask_every_time", + "state_to": "off", + "redaction_policy": "default_v1" + } + }, + "canonical_payload_utf8": "{\"access_mode\":\"off\",\"actor\":{\"identity\":\"console-user\",\"kind\":\"local_user\"},\"audit_id\":\"audit-02\",\"binding_fingerprint_sha256\":\"ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff\",\"command_id\":null,\"event_type\":\"lifecycle\",\"evidence\":{\"redaction_policy\":\"default_v1\",\"state_from\":\"ask_every_time\",\"state_to\":\"off\"},\"occurred_at\":\"2026-07-15T08:01:04Z\",\"outcome\":\"stopped\",\"previous_record_sha256\":\"ec3235267ea927e32c2f99ad05b7926c6ed4942ca3055263d3b000d88eb585f7\",\"reason_code\":\"local_stop\",\"request_digest_sha256\":null,\"schema_version\":\"evaos.mac_access.audit_event.v1\",\"sequence\":2}", + "record_sha256": "b811f2f3442a900a214ffb6d2d691cff51c32088bca50109df0b7cd044666614" + } + ] +} diff --git a/packages/mac-connector-core/contracts/v1/fixtures/valid/audit-event.json b/packages/mac-connector-core/contracts/v1/fixtures/valid/audit-event.json new file mode 100644 index 0000000000..7a9a3b59b6 --- /dev/null +++ b/packages/mac-connector-core/contracts/v1/fixtures/valid/audit-event.json @@ -0,0 +1,25 @@ +{ + "schema_version": "evaos.mac_access.audit_event.v1", + "audit_id": "audit-01", + "sequence": 1, + "previous_record_sha256": null, + "record_sha256": "ec3235267ea927e32c2f99ad05b7926c6ed4942ca3055263d3b000d88eb585f7", + "occurred_at": "2026-07-15T08:01:03Z", + "event_type": "command_result", + "actor": { + "kind": "broker_runtime", + "identity": "openclaw" + }, + "binding_fingerprint_sha256": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", + "command_id": "command-01", + "request_digest_sha256": "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", + "access_mode": "ask_every_time", + "outcome": "executed", + "reason_code": "approved_exact_scope", + "evidence": { + "capability": "customer_mac.desktop_click", + "target_path_hash": "path-hash-01", + "artifact_count": 0, + "redaction_policy": "default_v1" + } +} diff --git a/packages/mac-connector-core/contracts/v1/fixtures/valid/broker-control.json b/packages/mac-connector-core/contracts/v1/fixtures/valid/broker-control.json new file mode 100644 index 0000000000..269edf6e0b --- /dev/null +++ b/packages/mac-connector-core/contracts/v1/fixtures/valid/broker-control.json @@ -0,0 +1,83 @@ +{ + "schema_version": "evaos.mac_access.broker_control.v1", + "message_type": "command", + "session_id": "session-01", + "channel_generation_id": "channel-generation-01", + "command_id": "command-01", + "issued_at": "2026-07-15T08:01:00Z", + "expires_at": "2026-07-15T08:01:45Z", + "sequence": 42, + "nonce": "YnJva2VyLW5vbmNlLTAx", + "binding": { + "customer_id": "customer-01", + "customer_vm_id": "vm-01", + "device_id": "mac-01", + "grant_id": "grant-01", + "runtime": "openclaw", + "binding_id": "binding-01", + "binding_version": "v3", + "connector_installation_id": "install-01", + "connector_key_id": "mac-key-01", + "binding_fingerprint_sha256": "1111111111111111111111111111111111111111111111111111111111111111" + }, + "execution_context": { + "claims": { + "schema_version": "evaos.mac_control_execution_context.v1", + "key_id": "broker-signing-key-01", + "runtime": "openclaw", + "customer_id": "customer-01", + "customer_vm_id": "vm-01", + "binding_id": "binding-01", + "binding_version": "v3", + "issued_at": 1784102460, + "expires_at": 1784102505, + "context_id": "Y29udGV4dC0wMQ" + }, + "payload_base64url": "ZXhlY3V0aW9uLWNvbnRleHQ", + "payload_sha256": "9999999999999999999999999999999999999999999999999999999999999999", + "signature_base64url": "c2lnbmF0dXJlLTAx", + "key_id": "broker-signing-key-01" + }, + "command": { + "capability": "customer_mac.desktop_click", + "request": { + "target_snapshot_id": "snapshot-01", + "target_path_hash": "path-hash-01", + "dry_run": false + }, + "request_digest_sha256": "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd" + }, + "authorization": { + "schema_version": "evaos.mac_access.command_authorization.v1", + "canonicalization": "RFC8785-JCS", + "payload": { + "schema_version": "evaos.mac_access.command_authority_payload.v1", + "domain": "evaos.mac-access/command-authority/v1", + "session_id": "session-01", + "channel_generation_id": "channel-generation-01", + "command_id": "command-01", + "issued_at": "2026-07-15T08:01:00Z", + "expires_at": "2026-07-15T08:01:45Z", + "sequence": 42, + "nonce": "YnJva2VyLW5vbmNlLTAx", + "binding": { + "customer_id": "customer-01", + "customer_vm_id": "vm-01", + "device_id": "mac-01", + "grant_id": "grant-01", + "runtime": "openclaw", + "binding_id": "binding-01", + "binding_version": "v3", + "connector_installation_id": "install-01", + "connector_key_id": "mac-key-01", + "binding_fingerprint_sha256": "1111111111111111111111111111111111111111111111111111111111111111" + }, + "execution_context_sha256": "9999999999999999999999999999999999999999999999999999999999999999", + "capability": "customer_mac.desktop_click", + "request_digest_sha256": "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd" + }, + "payload_sha256": "5b53ab9ae88fd29ba82759bb7d6b0d900ce0367f9fe65314267a6667751dadf4", + "key_id": "broker-command-key-01", + "signature_base64url": "LY7Liy_zR2J4TexXTYYNe-wgoZ7EBee9aMGVk32M0Cd65LqRQ3dH4sYeE540WBTIEoZxgVhmqWhdPnn0i3DQBA" + } +} diff --git a/packages/mac-connector-core/contracts/v1/fixtures/valid/command-authority-golden.json b/packages/mac-connector-core/contracts/v1/fixtures/valid/command-authority-golden.json new file mode 100644 index 0000000000..cb55a643a6 --- /dev/null +++ b/packages/mac-connector-core/contracts/v1/fixtures/valid/command-authority-golden.json @@ -0,0 +1,34 @@ +{ + "schema_version": "evaos.mac_access.command_authority_golden.v1", + "canonicalization": "RFC8785-JCS", + "payload": { + "schema_version": "evaos.mac_access.command_authority_payload.v1", + "domain": "evaos.mac-access/command-authority/v1", + "session_id": "session-01", + "channel_generation_id": "channel-generation-01", + "command_id": "command-01", + "issued_at": "2026-07-15T08:01:00Z", + "expires_at": "2026-07-15T08:01:45Z", + "sequence": 42, + "nonce": "YnJva2VyLW5vbmNlLTAx", + "binding": { + "customer_id": "customer-01", + "customer_vm_id": "vm-01", + "device_id": "mac-01", + "grant_id": "grant-01", + "runtime": "openclaw", + "binding_id": "binding-01", + "binding_version": "v3", + "connector_installation_id": "install-01", + "connector_key_id": "mac-key-01", + "binding_fingerprint_sha256": "1111111111111111111111111111111111111111111111111111111111111111" + }, + "execution_context_sha256": "9999999999999999999999999999999999999999999999999999999999999999", + "capability": "customer_mac.desktop_click", + "request_digest_sha256": "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd" + }, + "canonical_payload_utf8": "{\"binding\":{\"binding_fingerprint_sha256\":\"1111111111111111111111111111111111111111111111111111111111111111\",\"binding_id\":\"binding-01\",\"binding_version\":\"v3\",\"connector_installation_id\":\"install-01\",\"connector_key_id\":\"mac-key-01\",\"customer_id\":\"customer-01\",\"customer_vm_id\":\"vm-01\",\"device_id\":\"mac-01\",\"grant_id\":\"grant-01\",\"runtime\":\"openclaw\"},\"capability\":\"customer_mac.desktop_click\",\"channel_generation_id\":\"channel-generation-01\",\"command_id\":\"command-01\",\"domain\":\"evaos.mac-access/command-authority/v1\",\"execution_context_sha256\":\"9999999999999999999999999999999999999999999999999999999999999999\",\"expires_at\":\"2026-07-15T08:01:45Z\",\"issued_at\":\"2026-07-15T08:01:00Z\",\"nonce\":\"YnJva2VyLW5vbmNlLTAx\",\"request_digest_sha256\":\"dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd\",\"schema_version\":\"evaos.mac_access.command_authority_payload.v1\",\"sequence\":42,\"session_id\":\"session-01\"}", + "payload_sha256": "5b53ab9ae88fd29ba82759bb7d6b0d900ce0367f9fe65314267a6667751dadf4", + "public_key_spki_base64url": "MCowBQYDK2VwAyEA7cmval-Chj6taXIbgRs1b_1mTMuGyjEnIValkKuKZfA", + "signature_base64url": "LY7Liy_zR2J4TexXTYYNe-wgoZ7EBee9aMGVk32M0Cd65LqRQ3dH4sYeE540WBTIEoZxgVhmqWhdPnn0i3DQBA" +} diff --git a/packages/mac-connector-core/contracts/v1/fixtures/valid/full-access-state.json b/packages/mac-connector-core/contracts/v1/fixtures/valid/full-access-state.json new file mode 100644 index 0000000000..0b5586c1bc --- /dev/null +++ b/packages/mac-connector-core/contracts/v1/fixtures/valid/full-access-state.json @@ -0,0 +1,31 @@ +{ + "schema_version": "evaos.mac_access.access_state.v1", + "runtime_instance_id": "runtime-instance-01", + "state_security_epoch": 1, + "minimum_reader_security_epoch": 1, + "minimum_writer_security_epoch": 1, + "policy_epoch": 7, + "pairing_state": "paired", + "configured_mode": "full_access", + "effective_mode": "full_access", + "paused": false, + "kill_switch": false, + "local_confirmation_required": false, + "confirmed_runtime_instance_id": "runtime-instance-01", + "confirmed_policy_epoch": 7, + "confirmed_binding_fingerprint_sha256": "1111111111111111111111111111111111111111111111111111111111111111", + "binding": { + "customer_id": "customer-01", + "customer_vm_id": "vm-01", + "device_id": "mac-01", + "grant_id": "grant-01", + "runtime": "openclaw", + "binding_id": "binding-01", + "binding_version": "v3", + "connector_installation_id": "install-01", + "connector_key_id": "mac-key-01", + "binding_fingerprint_sha256": "1111111111111111111111111111111111111111111111111111111111111111" + }, + "changed_at": "2026-07-15T08:00:00Z", + "reason_code": "local_full_access_confirmed" +} diff --git a/packages/mac-connector-core/contracts/v1/fixtures/valid/local-action.json b/packages/mac-connector-core/contracts/v1/fixtures/valid/local-action.json new file mode 100644 index 0000000000..c3e5af9fa7 --- /dev/null +++ b/packages/mac-connector-core/contracts/v1/fixtures/valid/local-action.json @@ -0,0 +1,19 @@ +{ + "schema_version": "evaos.mac_access.authenticated_local_action.v1", + "connection_id": "local-connection-01", + "peer": { + "verification": "verified_designated_requirement", + "role": "workbench_main", + "signing_identifier": "com.evaos.workbench", + "audit_token_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "designated_requirement_sha256": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" + }, + "request": { + "schema_version": "evaos.mac_access.local_action.v1", + "request_id": "local-request-01", + "action": "set_access_mode", + "client_nonce": "bm9uY2UtMDE", + "expected_policy_epoch": 7, + "target_mode": "ask_every_time" + } +} diff --git a/packages/mac-connector-core/contracts/v1/fixtures/valid/local-status.json b/packages/mac-connector-core/contracts/v1/fixtures/valid/local-status.json new file mode 100644 index 0000000000..6a1ed4b9e9 --- /dev/null +++ b/packages/mac-connector-core/contracts/v1/fixtures/valid/local-status.json @@ -0,0 +1,111 @@ +{ + "schema_version": "evaos.mac_access.local_status.v1", + "observed_at": "2026-07-15T08:00:10Z", + "leader": { + "runtime_instance_id": "runtime-instance-01", + "pid": 4102, + "app_bundle_id": "com.evaos.mac-access", + "helper_service_id": "com.evaos.mac-access.helper", + "connector_service_id": "com.evaos.mac-access.connector", + "helper_designated_requirement_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "build": { + "build_version": "0.1.0-architecture", + "source_commit": "ae9f35062f8a4b212fab4576d44602b57dc0303a", + "signed_lineage_id": "mac-access-production", + "security_epoch": 1, + "schema_reader_version": 1, + "schema_writer_version": 1, + "rollback_authorization_id": "rollback-01" + } + }, + "keychain": { + "custodian_signing_identifier": "com.evaos.mac-access.helper", + "access_group_suffix": "com.evaos.mac-access.credentials.epoch-1", + "credential_security_epoch": 1, + "service": "com.evaos.mac-access.connector-credential", + "accessibility": "kSecAttrAccessibleWhenUnlockedThisDeviceOnly", + "synchronizable": false, + "exportable_private_key": false + }, + "relay_authorization": { + "accepted_build_version": "0.1.0-architecture", + "accepted_source_commit": "ae9f35062f8a4b212fab4576d44602b57dc0303a", + "accepted_security_epoch": 1, + "credential_security_epoch": 1, + "rollback_authorization": { + "schema_version": "evaos.mac_access.signed_rollback_authorization.v1", + "canonicalization": "RFC8785-JCS", + "payload": { + "schema_version": "evaos.mac_access.rollback_authorization_payload.v1", + "domain": "evaos.mac-access/rollback-authorization/v1", + "authorization_id": "rollback-01", + "source": { + "build_version": "0.2.0", + "source_commit": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "signed_lineage_id": "mac-access-production", + "security_epoch": 2, + "credential_security_epoch": 2 + }, + "target": { + "build_version": "0.1.0-architecture", + "source_commit": "ae9f35062f8a4b212fab4576d44602b57dc0303a", + "signed_lineage_id": "mac-access-production", + "security_epoch": 1, + "credential_security_epoch": 1 + }, + "resulting_minimum_reader_security_epoch": 1, + "resulting_minimum_writer_security_epoch": 1, + "issued_at": "2026-07-15T07:55:00Z", + "expires_at": "2026-07-15T08:05:00Z" + }, + "payload_sha256": "af25d8ea75ce353e322e31bb855501af2c375e7388e603933b84d2d875c412a1", + "broker_key_id": "rollback-key-2026-01", + "signature_base64url": "GDifM36m6ciwLkEqKU5o4DlDFkkUwoYPsdU4-JXSIwemKrbP64MTZGVOQ-GSfbAz62SmfeLvQUo8BpJpI6coAQ" + } + }, + "access": { + "schema_version": "evaos.mac_access.access_state.v1", + "runtime_instance_id": "runtime-instance-01", + "state_security_epoch": 1, + "minimum_reader_security_epoch": 1, + "minimum_writer_security_epoch": 1, + "policy_epoch": 7, + "pairing_state": "paired", + "configured_mode": "ask_every_time", + "effective_mode": "ask_every_time", + "paused": false, + "kill_switch": false, + "local_confirmation_required": false, + "confirmed_runtime_instance_id": null, + "confirmed_policy_epoch": null, + "confirmed_binding_fingerprint_sha256": null, + "binding": { + "customer_id": "customer-01", + "customer_vm_id": "vm-01", + "device_id": "mac-01", + "grant_id": "grant-01", + "runtime": "openclaw", + "binding_id": "binding-01", + "binding_version": "v3", + "connector_installation_id": "install-01", + "connector_key_id": "mac-key-01", + "binding_fingerprint_sha256": "1111111111111111111111111111111111111111111111111111111111111111" + }, + "changed_at": "2026-07-15T08:00:00Z", + "reason_code": "pairing_confirmed" + }, + "transport": { + "state": "connected", + "channel_id": "channel-01", + "last_error_code": null + }, + "tcc": { + "responsible_identity": "com.evaos.mac-access", + "accessibility": "granted", + "screen_recording": "granted" + }, + "audit": { + "writable": true, + "last_audit_id": "audit-01" + } +} diff --git a/packages/mac-connector-core/contracts/v1/golden/rollback-authorization-golden.json b/packages/mac-connector-core/contracts/v1/golden/rollback-authorization-golden.json new file mode 100644 index 0000000000..bfcd53bc19 --- /dev/null +++ b/packages/mac-connector-core/contracts/v1/golden/rollback-authorization-golden.json @@ -0,0 +1,32 @@ +{ + "schema_version": "evaos.mac_access.signed_rollback_authorization.v1", + "canonicalization": "RFC8785-JCS", + "payload": { + "schema_version": "evaos.mac_access.rollback_authorization_payload.v1", + "domain": "evaos.mac-access/rollback-authorization/v1", + "authorization_id": "rollback-01", + "source": { + "build_version": "0.2.0", + "source_commit": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "signed_lineage_id": "mac-access-production", + "security_epoch": 2, + "credential_security_epoch": 2 + }, + "target": { + "build_version": "0.1.0-architecture", + "source_commit": "ae9f35062f8a4b212fab4576d44602b57dc0303a", + "signed_lineage_id": "mac-access-production", + "security_epoch": 1, + "credential_security_epoch": 1 + }, + "resulting_minimum_reader_security_epoch": 1, + "resulting_minimum_writer_security_epoch": 1, + "issued_at": "2026-07-15T07:55:00Z", + "expires_at": "2026-07-15T08:05:00Z" + }, + "canonical_payload_utf8": "{\"authorization_id\":\"rollback-01\",\"domain\":\"evaos.mac-access/rollback-authorization/v1\",\"expires_at\":\"2026-07-15T08:05:00Z\",\"issued_at\":\"2026-07-15T07:55:00Z\",\"resulting_minimum_reader_security_epoch\":1,\"resulting_minimum_writer_security_epoch\":1,\"schema_version\":\"evaos.mac_access.rollback_authorization_payload.v1\",\"source\":{\"build_version\":\"0.2.0\",\"credential_security_epoch\":2,\"security_epoch\":2,\"signed_lineage_id\":\"mac-access-production\",\"source_commit\":\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\"},\"target\":{\"build_version\":\"0.1.0-architecture\",\"credential_security_epoch\":1,\"security_epoch\":1,\"signed_lineage_id\":\"mac-access-production\",\"source_commit\":\"ae9f35062f8a4b212fab4576d44602b57dc0303a\"}}", + "payload_sha256": "af25d8ea75ce353e322e31bb855501af2c375e7388e603933b84d2d875c412a1", + "broker_key_id": "rollback-key-2026-01", + "signature_base64url": "GDifM36m6ciwLkEqKU5o4DlDFkkUwoYPsdU4-JXSIwemKrbP64MTZGVOQ-GSfbAz62SmfeLvQUo8BpJpI6coAQ", + "public_key_spki_base64url": "MCowBQYDK2VwAyEAHLkdfJpt68p2FYvrvSLtqZBhghT0w6gxkPpA9rYZfd0" +} diff --git a/packages/mac-connector-core/contracts/v1/index.ts b/packages/mac-connector-core/contracts/v1/index.ts new file mode 100644 index 0000000000..9d450d1eaa --- /dev/null +++ b/packages/mac-connector-core/contracts/v1/index.ts @@ -0,0 +1,974 @@ +import { z } from 'zod'; + +const identifier = z.string().regex(/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/); +const sha256 = z.string().regex(/^[a-f0-9]{64}$/); +const base64Url = z + .string() + .min(1) + .max(16_384) + .regex(/^[A-Za-z0-9_-]+$/); +const instant = z.string().datetime({ offset: true }); + +export const MAC_ACCESS_IDENTITIES = { + appBundleId: 'com.evaos.mac-access', + helperServiceId: 'com.evaos.mac-access.helper', + connectorServiceId: 'com.evaos.mac-access.connector', + productionKeychainAccessGroupSuffix: 'com.evaos.mac-access.credentials', + developmentKeychainAccessGroupSuffix: 'com.evaos.mac-access.development.credentials', + connectorCredentialService: 'com.evaos.mac-access.connector-credential', +} as const; + +export const selectedBindingSchema = z + .object({ + customer_id: identifier, + customer_vm_id: identifier, + device_id: identifier, + grant_id: identifier, + runtime: z.enum(['openclaw', 'hermes']), + binding_id: identifier, + binding_version: identifier, + connector_installation_id: identifier, + connector_key_id: identifier, + binding_fingerprint_sha256: sha256, + }) + .strict(); + +function selectedBindingsEqual( + left: z.infer, + right: z.infer +): boolean { + return ( + left.customer_id === right.customer_id && + left.customer_vm_id === right.customer_vm_id && + left.device_id === right.device_id && + left.grant_id === right.grant_id && + left.runtime === right.runtime && + left.binding_id === right.binding_id && + left.binding_version === right.binding_version && + left.connector_installation_id === right.connector_installation_id && + left.connector_key_id === right.connector_key_id && + left.binding_fingerprint_sha256 === right.binding_fingerprint_sha256 + ); +} + +export const buildIdentitySchema = z + .object({ + build_version: identifier, + source_commit: z.string().regex(/^[a-f0-9]{40}$/), + signed_lineage_id: identifier, + security_epoch: z.number().int().nonnegative(), + schema_reader_version: z.literal(1), + schema_writer_version: z.literal(1), + rollback_authorization_id: identifier.nullable(), + }) + .strict(); + +export const keychainCustodySchema = z + .object({ + custodian_signing_identifier: z.literal(MAC_ACCESS_IDENTITIES.helperServiceId), + access_group_suffix: z.string().regex(/^com\.evaos\.mac-access\.credentials\.epoch-[1-9][0-9]*$/), + credential_security_epoch: z.number().int().positive(), + service: z.literal(MAC_ACCESS_IDENTITIES.connectorCredentialService), + accessibility: z.literal('kSecAttrAccessibleWhenUnlockedThisDeviceOnly'), + synchronizable: z.literal(false), + exportable_private_key: z.literal(false), + }) + .strict() + .superRefine((custody, context) => { + if (!custody.access_group_suffix.endsWith(`.epoch-${custody.credential_security_epoch}`)) { + context.addIssue({ + code: z.ZodIssueCode.custom, + message: 'production credential access group must match its security epoch', + path: ['access_group_suffix'], + }); + } + }); + +const rollbackBuildSchema = z + .object({ + build_version: identifier, + source_commit: z.string().regex(/^[a-f0-9]{40}$/), + signed_lineage_id: identifier, + security_epoch: z.number().int().nonnegative(), + credential_security_epoch: z.number().int().positive(), + }) + .strict(); + +export const rollbackAuthorizationPayloadSchema = z + .object({ + schema_version: z.literal('evaos.mac_access.rollback_authorization_payload.v1'), + domain: z.literal('evaos.mac-access/rollback-authorization/v1'), + authorization_id: identifier, + source: rollbackBuildSchema, + target: rollbackBuildSchema, + resulting_minimum_reader_security_epoch: z.number().int().nonnegative(), + resulting_minimum_writer_security_epoch: z.number().int().nonnegative(), + issued_at: instant, + expires_at: instant, + }) + .strict() + .superRefine((authorization, context) => { + if (Date.parse(authorization.expires_at) <= Date.parse(authorization.issued_at)) { + context.addIssue({ + code: z.ZodIssueCode.custom, + message: 'rollback authorization must expire after it is issued', + path: ['expires_at'], + }); + } + if ( + authorization.source.build_version === authorization.target.build_version && + authorization.source.source_commit === authorization.target.source_commit && + authorization.source.security_epoch === authorization.target.security_epoch && + authorization.source.credential_security_epoch === authorization.target.credential_security_epoch + ) { + context.addIssue({ + code: z.ZodIssueCode.custom, + message: 'rollback authorization must name distinct source and target builds', + path: ['target'], + }); + } + }); + +export const signedRollbackAuthorizationSchema = z + .object({ + schema_version: z.literal('evaos.mac_access.signed_rollback_authorization.v1'), + canonicalization: z.literal('RFC8785-JCS'), + payload: rollbackAuthorizationPayloadSchema, + payload_sha256: sha256, + broker_key_id: identifier, + signature_base64url: base64Url, + }) + .strict(); + +export const rollbackAuthorizationGoldenSchema = signedRollbackAuthorizationSchema.extend({ + canonical_payload_utf8: z.string().min(1).max(65_536), + public_key_spki_base64url: base64Url, +}); + +export const executionContextClaimsSchema = z + .object({ + schema_version: z.literal('evaos.mac_control_execution_context.v1'), + key_id: identifier, + runtime: z.enum(['openclaw', 'hermes']), + customer_id: identifier, + customer_vm_id: identifier, + binding_id: identifier, + binding_version: identifier, + issued_at: z.number().int().nonnegative(), + expires_at: z.number().int().positive(), + context_id: base64Url, + }) + .strict() + .superRefine((claims, context) => { + if (claims.expires_at <= claims.issued_at) { + context.addIssue({ + code: z.ZodIssueCode.custom, + message: 'execution context must expire after it is issued', + path: ['expires_at'], + }); + } + }); + +export const authenticatedPeerSchema = z + .object({ + verification: z.literal('verified_designated_requirement'), + role: z.enum(['mac_access_menu', 'workbench_main']), + signing_identifier: z.enum(['com.evaos.mac-access', 'com.evaos.workbench', 'com.electricsheephq.EvaDesktop']), + audit_token_sha256: sha256, + designated_requirement_sha256: sha256, + }) + .strict() + .superRefine((peer, context) => { + const macAccessPeer = peer.role === 'mac_access_menu' && peer.signing_identifier === 'com.evaos.mac-access'; + const workbenchPeer = + peer.role === 'workbench_main' && + ['com.evaos.workbench', 'com.electricsheephq.EvaDesktop'].includes(peer.signing_identifier); + if (!macAccessPeer && !workbenchPeer) { + context.addIssue({ + code: z.ZodIssueCode.custom, + message: 'peer role and verified signing identifier do not match', + path: ['signing_identifier'], + }); + } + }); + +export const accessStateSchema = z + .object({ + schema_version: z.literal('evaos.mac_access.access_state.v1'), + runtime_instance_id: identifier, + state_security_epoch: z.number().int().nonnegative(), + minimum_reader_security_epoch: z.number().int().nonnegative(), + minimum_writer_security_epoch: z.number().int().nonnegative(), + policy_epoch: z.number().int().nonnegative(), + pairing_state: z.enum(['unpaired', 'paired', 'revoked']), + configured_mode: z.enum(['off', 'ask_every_time', 'full_access']), + effective_mode: z.enum(['off', 'ask_every_time', 'full_access']), + paused: z.boolean(), + kill_switch: z.boolean(), + local_confirmation_required: z.boolean(), + confirmed_runtime_instance_id: identifier.nullable(), + confirmed_policy_epoch: z.number().int().nonnegative().nullable(), + confirmed_binding_fingerprint_sha256: sha256.nullable(), + binding: selectedBindingSchema.nullable(), + changed_at: instant, + reason_code: identifier, + }) + .strict() + .superRefine((state, context) => { + const modeRank = { off: 0, ask_every_time: 1, full_access: 2 } as const; + const forceOff = state.pairing_state !== 'paired' || state.paused || state.kill_switch || state.binding === null; + if (forceOff && state.effective_mode !== 'off') { + context.addIssue({ + code: z.ZodIssueCode.custom, + message: 'unpaired, revoked, paused, killed, or unbound state must be effectively off', + path: ['effective_mode'], + }); + } + if (state.pairing_state === 'paired' && state.binding === null) { + context.addIssue({ + code: z.ZodIssueCode.custom, + message: 'paired state requires a selected binding', + path: ['binding'], + }); + } + if (state.pairing_state !== 'paired' && state.binding !== null) { + context.addIssue({ + code: z.ZodIssueCode.custom, + message: 'unpaired or revoked state cannot retain an active binding', + path: ['binding'], + }); + } + if (state.pairing_state !== 'paired' && state.configured_mode !== 'off') { + context.addIssue({ + code: z.ZodIssueCode.custom, + message: 'unpaired or revoked state must be configured off', + path: ['configured_mode'], + }); + } + if (modeRank[state.effective_mode] > modeRank[state.configured_mode]) { + context.addIssue({ + code: z.ZodIssueCode.custom, + message: 'effective access cannot exceed configured user intent', + path: ['effective_mode'], + }); + } + if ( + state.state_security_epoch < state.minimum_reader_security_epoch || + state.state_security_epoch < state.minimum_writer_security_epoch + ) { + context.addIssue({ + code: z.ZodIssueCode.custom, + message: 'state security epoch is below its protected compatibility floor', + path: ['state_security_epoch'], + }); + } + if (state.effective_mode === 'full_access') { + if (state.configured_mode !== 'full_access' || state.local_confirmation_required) { + context.addIssue({ + code: z.ZodIssueCode.custom, + message: 'effective full access requires configured full access and local confirmation', + path: ['effective_mode'], + }); + } + if (state.confirmed_runtime_instance_id !== state.runtime_instance_id) { + context.addIssue({ + code: z.ZodIssueCode.custom, + message: 'full access confirmation must be bound to the current runtime instance', + path: ['confirmed_runtime_instance_id'], + }); + } + if (state.confirmed_policy_epoch !== state.policy_epoch) { + context.addIssue({ + code: z.ZodIssueCode.custom, + message: 'full access confirmation must be bound to the current policy epoch', + path: ['confirmed_policy_epoch'], + }); + } + if (state.confirmed_binding_fingerprint_sha256 !== state.binding?.binding_fingerprint_sha256) { + context.addIssue({ + code: z.ZodIssueCode.custom, + message: 'full access confirmation must be bound to the current selected binding', + path: ['confirmed_binding_fingerprint_sha256'], + }); + } + } else if ( + state.confirmed_runtime_instance_id !== null || + state.confirmed_policy_epoch !== null || + state.confirmed_binding_fingerprint_sha256 !== null + ) { + context.addIssue({ + code: z.ZodIssueCode.custom, + message: 'non-full-access state must clear full access confirmation', + path: ['confirmed_runtime_instance_id'], + }); + } + if (state.local_confirmation_required && state.effective_mode === 'full_access') { + context.addIssue({ + code: z.ZodIssueCode.custom, + message: 'full access cannot remain effective while reconfirmation is required', + path: ['local_confirmation_required'], + }); + } + if (state.local_confirmation_required && state.configured_mode !== 'full_access') { + context.addIssue({ + code: z.ZodIssueCode.custom, + message: 'reconfirmation is only meaningful for configured full access', + path: ['local_confirmation_required'], + }); + } + }); + +export const localStatusSchema = z + .object({ + schema_version: z.literal('evaos.mac_access.local_status.v1'), + observed_at: instant, + leader: z + .object({ + runtime_instance_id: identifier, + pid: z.number().int().positive(), + app_bundle_id: z.literal(MAC_ACCESS_IDENTITIES.appBundleId), + helper_service_id: z.literal(MAC_ACCESS_IDENTITIES.helperServiceId), + connector_service_id: z.literal(MAC_ACCESS_IDENTITIES.connectorServiceId), + helper_designated_requirement_sha256: sha256, + build: buildIdentitySchema, + }) + .strict(), + keychain: keychainCustodySchema, + relay_authorization: z + .object({ + accepted_build_version: identifier, + accepted_source_commit: z.string().regex(/^[a-f0-9]{40}$/), + accepted_security_epoch: z.number().int().positive(), + credential_security_epoch: z.number().int().positive(), + rollback_authorization: signedRollbackAuthorizationSchema.nullable(), + }) + .strict(), + access: accessStateSchema, + transport: z + .object({ + state: z.enum(['disconnected', 'connecting', 'connected', 'revoked', 'blocked']), + channel_id: identifier.nullable(), + last_error_code: identifier.nullable(), + }) + .strict(), + tcc: z + .object({ + responsible_identity: z.literal(MAC_ACCESS_IDENTITIES.appBundleId), + accessibility: z.enum(['unknown', 'missing', 'granted', 'denied']), + screen_recording: z.enum(['unknown', 'missing', 'granted', 'denied']), + }) + .strict(), + audit: z + .object({ + writable: z.boolean(), + last_audit_id: identifier.nullable(), + }) + .strict(), + }) + .strict() + .superRefine((status, context) => { + if (status.leader.runtime_instance_id !== status.access.runtime_instance_id) { + context.addIssue({ + code: z.ZodIssueCode.custom, + message: 'leader and access state must describe the same runtime instance', + path: ['leader', 'runtime_instance_id'], + }); + } + if (!status.audit.writable && status.access.effective_mode !== 'off') { + context.addIssue({ + code: z.ZodIssueCode.custom, + message: 'audit failure must force effective access off', + path: ['audit', 'writable'], + }); + } + const tccGranted = status.tcc.accessibility === 'granted' && status.tcc.screen_recording === 'granted'; + if (!tccGranted && status.access.effective_mode !== 'off') { + context.addIssue({ + code: z.ZodIssueCode.custom, + message: 'missing, denied, or unknown TCC state must force effective access off', + path: ['tcc'], + }); + } + if (status.leader.build.security_epoch < status.access.minimum_reader_security_epoch) { + context.addIssue({ + code: z.ZodIssueCode.custom, + message: 'helper build is below the protected state reader security floor', + path: ['leader', 'build', 'security_epoch'], + }); + } + if (status.leader.build.security_epoch < status.access.minimum_writer_security_epoch) { + context.addIssue({ + code: z.ZodIssueCode.custom, + message: 'helper build is below the protected state writer security floor', + path: ['leader', 'build', 'security_epoch'], + }); + } + const relayBuild = status.relay_authorization; + const localBuild = status.leader.build; + if ( + relayBuild.accepted_build_version !== localBuild.build_version || + relayBuild.accepted_source_commit !== localBuild.source_commit || + relayBuild.accepted_security_epoch !== localBuild.security_epoch + ) { + context.addIssue({ + code: z.ZodIssueCode.custom, + message: 'relay authorization must name the exact immutable local build identity', + path: ['relay_authorization'], + }); + } + if ( + relayBuild.credential_security_epoch !== relayBuild.accepted_security_epoch || + status.keychain.credential_security_epoch !== relayBuild.credential_security_epoch + ) { + context.addIssue({ + code: z.ZodIssueCode.custom, + message: 'relay and Keychain credential epochs must match the accepted security epoch', + path: ['relay_authorization', 'credential_security_epoch'], + }); + } + const rollback = relayBuild.rollback_authorization?.payload ?? null; + if ((rollback?.authorization_id ?? null) !== localBuild.rollback_authorization_id) { + context.addIssue({ + code: z.ZodIssueCode.custom, + message: 'relay and local build must agree on exact rollback authorization', + path: ['relay_authorization', 'rollback_authorization'], + }); + } + if (rollback !== null) { + const targetMatches = + rollback.target.build_version === localBuild.build_version && + rollback.target.source_commit === localBuild.source_commit && + rollback.target.signed_lineage_id === localBuild.signed_lineage_id && + rollback.target.security_epoch === localBuild.security_epoch && + rollback.target.credential_security_epoch === status.keychain.credential_security_epoch && + rollback.resulting_minimum_reader_security_epoch === status.access.minimum_reader_security_epoch && + rollback.resulting_minimum_writer_security_epoch === status.access.minimum_writer_security_epoch; + if (!targetMatches) { + context.addIssue({ + code: z.ZodIssueCode.custom, + message: 'rollback authorization target and resulting floors must match current status exactly', + path: ['relay_authorization', 'rollback_authorization', 'payload', 'target'], + }); + } + const observedAt = Date.parse(status.observed_at); + if (observedAt < Date.parse(rollback.issued_at) || observedAt >= Date.parse(rollback.expires_at)) { + context.addIssue({ + code: z.ZodIssueCode.custom, + message: 'rollback authorization must be current at the observed status time', + path: ['relay_authorization', 'rollback_authorization', 'payload', 'expires_at'], + }); + } + } + const connected = status.transport.state === 'connected'; + if (connected !== (status.transport.channel_id !== null)) { + context.addIssue({ + code: z.ZodIssueCode.custom, + message: 'only connected transport may carry an active channel id', + path: ['transport', 'channel_id'], + }); + } + if (status.transport.state === 'revoked' && status.access.pairing_state !== 'revoked') { + context.addIssue({ + code: z.ZodIssueCode.custom, + message: 'revoked transport requires revoked access state', + path: ['transport', 'state'], + }); + } + }); + +export const accessTransitionSchema = z + .object({ + schema_version: z.literal('evaos.mac_access.access_transition.v1'), + transition_id: identifier, + event: z.enum([ + 'pair_confirmed', + 'set_mode', + 'restart', + 'pause', + 'stop', + 'resume', + 'revoke', + 'kill_switch', + 'tcc_lost', + 'audit_failed', + 'binding_changed', + ]), + explicit_user_consent: z.boolean(), + invalidated_pending_authority: z.boolean(), + safe_cancellation_requested: z.boolean(), + target_mode: z.enum(['off', 'ask_every_time', 'full_access']).nullable(), + from: accessStateSchema, + to: accessStateSchema, + }) + .strict() + .superRefine((transition, context) => { + const { from, to } = transition; + if (to.policy_epoch !== from.policy_epoch + 1) { + context.addIssue({ + code: z.ZodIssueCode.custom, + message: 'every authority transition must advance the policy epoch exactly once', + path: ['to', 'policy_epoch'], + }); + } + if (transition.event === 'pair_confirmed') { + const validPairing = + transition.explicit_user_consent && + transition.target_mode === 'ask_every_time' && + from.pairing_state === 'unpaired' && + from.configured_mode === 'off' && + from.effective_mode === 'off' && + from.binding === null && + to.pairing_state === 'paired' && + to.configured_mode === 'ask_every_time' && + to.effective_mode === 'ask_every_time' && + to.binding !== null; + if (!validPairing) { + context.addIssue({ + code: z.ZodIssueCode.custom, + message: + 'pairing requires explicit consent and may transition only from unpaired off to paired ask every time', + path: ['event'], + }); + } + } + if (transition.event === 'restart') { + const validRestart = + from.runtime_instance_id !== to.runtime_instance_id && + to.confirmed_runtime_instance_id === null && + to.confirmed_policy_epoch === null && + to.confirmed_binding_fingerprint_sha256 === null && + (to.configured_mode !== 'full_access' || + (to.effective_mode === 'ask_every_time' && to.local_confirmation_required)); + if (!validRestart) { + context.addIssue({ + code: z.ZodIssueCode.custom, + message: 'restart must clear confirmation and downgrade configured full access to ask every time', + path: ['event'], + }); + } + } + if (transition.event === 'set_mode') { + const validSetMode = + transition.target_mode !== null && + to.configured_mode === transition.target_mode && + (transition.target_mode !== 'full_access' || transition.explicit_user_consent); + if (!validSetMode) { + context.addIssue({ + code: z.ZodIssueCode.custom, + message: 'mode changes require an exact target and full access requires explicit local consent', + path: ['target_mode'], + }); + } + } + if (transition.event === 'pause' && (!to.paused || to.effective_mode !== 'off')) { + context.addIssue({ + code: z.ZodIssueCode.custom, + message: 'pause must synchronously force effective access off', + path: ['to'], + }); + } + if (transition.event === 'stop') { + const validStop = + to.effective_mode === 'off' && + transition.invalidated_pending_authority && + transition.safe_cancellation_requested && + to.confirmed_runtime_instance_id === null && + to.confirmed_policy_epoch === null && + to.confirmed_binding_fingerprint_sha256 === null; + if (!validStop) { + context.addIssue({ + code: z.ZodIssueCode.custom, + message: 'stop must rotate policy, force off, invalidate pending authority, and request safe cancellation', + path: ['event'], + }); + } + } + if (transition.event === 'resume') { + const validResume = + from.paused && + !to.paused && + (to.configured_mode !== 'full_access' || + (to.effective_mode === 'ask_every_time' && to.local_confirmation_required)); + if (!validResume) { + context.addIssue({ + code: z.ZodIssueCode.custom, + message: 'resume must clear pause without silently restoring full access', + path: ['to'], + }); + } + } + if (transition.event === 'revoke') { + const validRevoke = + to.pairing_state === 'revoked' && + to.configured_mode === 'off' && + to.effective_mode === 'off' && + to.binding === null; + if (!validRevoke) { + context.addIssue({ + code: z.ZodIssueCode.custom, + message: 'revoke must tombstone pairing and clear binding and authority', + path: ['to'], + }); + } + } + if (transition.event === 'kill_switch' && (!to.kill_switch || to.effective_mode !== 'off')) { + context.addIssue({ + code: z.ZodIssueCode.custom, + message: 'kill switch must synchronously force effective access off', + path: ['to'], + }); + } + if (['tcc_lost', 'audit_failed'].includes(transition.event) && to.effective_mode !== 'off') { + context.addIssue({ + code: z.ZodIssueCode.custom, + message: 'TCC or audit failure must force effective access off', + path: ['to', 'effective_mode'], + }); + } + if ( + transition.event === 'binding_changed' && + (from.binding?.binding_fingerprint_sha256 === to.binding?.binding_fingerprint_sha256 || + to.confirmed_binding_fingerprint_sha256 !== null) + ) { + context.addIssue({ + code: z.ZodIssueCode.custom, + message: 'binding change must rotate the binding fingerprint and clear full access confirmation', + path: ['to', 'binding'], + }); + } + }); + +const localActionName = z.enum([ + 'get_status', + 'begin_pairing', + 'set_access_mode', + 'pause', + 'resume', + 'revoke', + 'activate_kill_switch', + 'open_permissions', + 'stop', +]); + +export const localActionRequestSchema = z + .object({ + schema_version: z.literal('evaos.mac_access.local_action.v1'), + request_id: identifier, + action: localActionName, + client_nonce: base64Url, + expected_policy_epoch: z.number().int().nonnegative().nullable(), + target_mode: z.enum(['off', 'ask_every_time', 'full_access']).nullable(), + }) + .strict() + .superRefine((request, context) => { + const mutation = !['get_status', 'open_permissions'].includes(request.action); + if (mutation && request.expected_policy_epoch === null) { + context.addIssue({ + code: z.ZodIssueCode.custom, + message: 'mutating local actions require the expected policy epoch', + path: ['expected_policy_epoch'], + }); + } + if (request.action === 'set_access_mode' && request.target_mode === null) { + context.addIssue({ + code: z.ZodIssueCode.custom, + message: 'set_access_mode requires target_mode', + path: ['target_mode'], + }); + } + if (request.action !== 'set_access_mode' && request.target_mode !== null) { + context.addIssue({ + code: z.ZodIssueCode.custom, + message: 'target_mode is only valid for set_access_mode', + path: ['target_mode'], + }); + } + }); + +export const authenticatedLocalActionSchema = z + .object({ + schema_version: z.literal('evaos.mac_access.authenticated_local_action.v1'), + connection_id: identifier, + peer: authenticatedPeerSchema, + request: localActionRequestSchema, + }) + .strict(); + +export const commandAuthorityPayloadSchema = z + .object({ + schema_version: z.literal('evaos.mac_access.command_authority_payload.v1'), + domain: z.literal('evaos.mac-access/command-authority/v1'), + session_id: identifier, + channel_generation_id: identifier, + command_id: identifier, + issued_at: instant, + expires_at: instant, + sequence: z.number().int().positive(), + nonce: base64Url, + binding: selectedBindingSchema, + execution_context_sha256: sha256, + capability: identifier, + request_digest_sha256: sha256, + }) + .strict(); + +export const commandAuthorityGoldenSchema = z + .object({ + schema_version: z.literal('evaos.mac_access.command_authority_golden.v1'), + canonicalization: z.literal('RFC8785-JCS'), + payload: commandAuthorityPayloadSchema, + canonical_payload_utf8: z.string().min(1).max(65_536), + payload_sha256: sha256, + public_key_spki_base64url: base64Url, + signature_base64url: base64Url, + }) + .strict(); + +export const brokerControlEnvelopeSchema = z + .object({ + schema_version: z.literal('evaos.mac_access.broker_control.v1'), + message_type: z.literal('command'), + session_id: identifier, + channel_generation_id: identifier, + command_id: identifier, + issued_at: instant, + expires_at: instant, + sequence: z.number().int().positive(), + nonce: base64Url, + binding: selectedBindingSchema, + execution_context: z + .object({ + claims: executionContextClaimsSchema, + payload_base64url: base64Url, + payload_sha256: sha256, + signature_base64url: base64Url, + key_id: identifier, + }) + .strict(), + command: z + .object({ + capability: identifier, + request: z.record(z.string(), z.unknown()), + request_digest_sha256: sha256, + }) + .strict(), + authorization: z + .object({ + schema_version: z.literal('evaos.mac_access.command_authorization.v1'), + canonicalization: z.literal('RFC8785-JCS'), + payload: commandAuthorityPayloadSchema, + payload_sha256: sha256, + key_id: identifier, + signature_base64url: base64Url, + }) + .strict(), + }) + .strict() + .superRefine((envelope, context) => { + const issuedAt = Date.parse(envelope.issued_at); + const expiresAt = Date.parse(envelope.expires_at); + if (expiresAt <= issuedAt || expiresAt - issuedAt > 60_000) { + context.addIssue({ + code: z.ZodIssueCode.custom, + message: 'command authority must be positive and no longer than 60 seconds', + path: ['expires_at'], + }); + } + const claims = envelope.execution_context.claims; + const binding = envelope.binding; + const matches = + claims.customer_id === binding.customer_id && + claims.customer_vm_id === binding.customer_vm_id && + claims.runtime === binding.runtime && + claims.binding_id === binding.binding_id && + claims.binding_version === binding.binding_version; + if (!matches) { + context.addIssue({ + code: z.ZodIssueCode.custom, + message: 'execution context does not match the selected binding', + path: ['execution_context', 'claims'], + }); + } + if (envelope.execution_context.key_id !== claims.key_id) { + context.addIssue({ + code: z.ZodIssueCode.custom, + message: 'execution context key id must match its signed claims', + path: ['execution_context', 'key_id'], + }); + } + const contextIssuedAt = claims.issued_at * 1_000; + const contextExpiresAt = claims.expires_at * 1_000; + if (issuedAt < contextIssuedAt || expiresAt > contextExpiresAt) { + context.addIssue({ + code: z.ZodIssueCode.custom, + message: 'command authority must be contained by the signed execution context interval', + path: ['expires_at'], + }); + } + const authority = envelope.authorization.payload; + const authorityMatches = + authority.session_id === envelope.session_id && + authority.channel_generation_id === envelope.channel_generation_id && + authority.command_id === envelope.command_id && + authority.issued_at === envelope.issued_at && + authority.expires_at === envelope.expires_at && + authority.sequence === envelope.sequence && + authority.nonce === envelope.nonce && + authority.execution_context_sha256 === envelope.execution_context.payload_sha256 && + authority.capability === envelope.command.capability && + authority.request_digest_sha256 === envelope.command.request_digest_sha256 && + selectedBindingsEqual(authority.binding, envelope.binding); + if (!authorityMatches) { + context.addIssue({ + code: z.ZodIssueCode.custom, + message: 'signed command authority payload does not exactly match the delivered command envelope', + path: ['authorization', 'payload'], + }); + } + }); + +const safeEvidenceIdentifier = identifier.refine( + (value) => !/(?:authorization|bearer|cookie|password|secret|token|eyJ[A-Za-z0-9_-]{8})/i.test(value), + 'audit evidence identifier resembles secret-bearing content' +); + +export const auditEvidenceSchema = z + .object({ + capability: safeEvidenceIdentifier.optional(), + target_path_hash: safeEvidenceIdentifier.optional(), + target_fingerprint_sha256: sha256.optional(), + state_from: safeEvidenceIdentifier.optional(), + state_to: safeEvidenceIdentifier.optional(), + transport_state: safeEvidenceIdentifier.optional(), + detail_code: safeEvidenceIdentifier.optional(), + build_version: safeEvidenceIdentifier.optional(), + schema_version: safeEvidenceIdentifier.optional(), + artifact_count: z.number().int().min(0).max(16).optional(), + record_count: z.number().int().min(0).max(10_000).optional(), + redaction_policy: z.literal('default_v1'), + }) + .strict(); + +export const auditRecordPayloadSchema = z + .object({ + schema_version: z.literal('evaos.mac_access.audit_event.v1'), + audit_id: identifier, + sequence: z.number().int().positive(), + previous_record_sha256: sha256.nullable(), + occurred_at: instant, + event_type: z.enum([ + 'pairing', + 'policy_transition', + 'command_decision', + 'command_result', + 'pause', + 'revoke', + 'kill_switch', + 'lifecycle', + ]), + actor: z + .object({ + kind: z.enum(['local_user', 'workbench', 'broker_runtime', 'system']), + identity: identifier, + }) + .strict(), + binding_fingerprint_sha256: sha256.nullable(), + command_id: identifier.nullable(), + request_digest_sha256: sha256.nullable(), + access_mode: z.enum(['off', 'ask_every_time', 'full_access']), + outcome: z.enum(['allowed', 'denied', 'executed', 'failed', 'revoked', 'stopped']), + reason_code: identifier, + evidence: auditEvidenceSchema, + }) + .strict(); + +export const auditEventSchema = auditRecordPayloadSchema + .extend({ record_sha256: sha256 }) + .superRefine((event, context) => { + if (event.sequence === 1 && event.previous_record_sha256 !== null) { + context.addIssue({ + code: z.ZodIssueCode.custom, + message: 'the first audit record must not claim a previous record digest', + path: ['previous_record_sha256'], + }); + } + if (event.sequence > 1 && event.previous_record_sha256 === null) { + context.addIssue({ + code: z.ZodIssueCode.custom, + message: 'subsequent audit records must chain the previous record digest', + path: ['previous_record_sha256'], + }); + } + }); + +export const auditChainGoldenSchema = z + .object({ + schema_version: z.literal('evaos.mac_access.audit_chain_golden.v1'), + canonicalization: z.literal('RFC8785-JCS'), + records: z + .array( + z + .object({ + payload: auditRecordPayloadSchema, + canonical_payload_utf8: z.string().min(1).max(65_536), + record_sha256: sha256, + }) + .strict() + ) + .length(2), + }) + .strict(); + +export const negativeFixtureCaseSchema = z + .object({ + id: identifier, + threat: identifier, + contract: z.enum([ + 'access_state', + 'access_transition', + 'local_status', + 'authenticated_local_action', + 'broker_control', + 'audit_event', + ]), + base_fixture: z.string().min(1), + mutations: z.array( + z + .object({ + operation: z.enum(['set', 'remove']), + pointer: z.string().startsWith('/'), + value: z.unknown().optional(), + }) + .strict() + ), + expected_stage: z.enum(['schema', 'runtime']), + expected_error: identifier, + required_runtime_rejection: z.string().min(1).nullable(), + }) + .strict() + .superRefine((fixture, context) => { + if (fixture.expected_stage === 'runtime' && fixture.required_runtime_rejection === null) { + context.addIssue({ + code: z.ZodIssueCode.custom, + message: 'runtime negative fixtures must name the required rejection behavior', + path: ['required_runtime_rejection'], + }); + } + if (fixture.expected_stage === 'schema' && fixture.required_runtime_rejection !== null) { + context.addIssue({ + code: z.ZodIssueCode.custom, + message: 'schema negative fixtures must not claim runtime evidence', + path: ['required_runtime_rejection'], + }); + } + }); + +export type SelectedBinding = z.infer; +export type AccessState = z.infer; +export type AccessTransition = z.infer; +export type LocalStatus = z.infer; +export type LocalActionRequest = z.infer; +export type AuthenticatedLocalAction = z.infer; +export type BrokerControlEnvelope = z.infer; +export type AuditEvent = z.infer; +export type NegativeFixtureCase = z.infer; diff --git a/packages/mac-connector-core/tests/FixtureSmoke.swift b/packages/mac-connector-core/tests/FixtureSmoke.swift new file mode 100644 index 0000000000..8e03a5dd65 --- /dev/null +++ b/packages/mac-connector-core/tests/FixtureSmoke.swift @@ -0,0 +1,27 @@ +import Foundation + +let scriptURL = URL(fileURLWithPath: #filePath) +let fixtureRoot = scriptURL + .deletingLastPathComponent() + .deletingLastPathComponent() + .appendingPathComponent("contracts/v1/fixtures") +let fileManager = FileManager.default +guard let enumerator = fileManager.enumerator( + at: fixtureRoot, + includingPropertiesForKeys: [.isRegularFileKey], + options: [.skipsHiddenFiles] +) else { + fatalError("Mac Access contract fixture directory is unavailable") +} + +var parsed = 0 +for case let fileURL as URL in enumerator where fileURL.pathExtension == "json" { + let data = try Data(contentsOf: fileURL) + _ = try JSONSerialization.jsonObject(with: data) + parsed += 1 +} + +guard parsed > 0 else { + fatalError("no Mac Access contract fixtures found") +} +print("parsed \(parsed) Mac Access contract fixture files") diff --git a/packages/mac-connector-core/tests/fixture_smoke.py b/packages/mac-connector-core/tests/fixture_smoke.py new file mode 100644 index 0000000000..f9b91f000f --- /dev/null +++ b/packages/mac-connector-core/tests/fixture_smoke.py @@ -0,0 +1,19 @@ +from __future__ import annotations + +import json +from pathlib import Path + + +def main() -> None: + root = Path(__file__).parent.parent / "contracts" / "v1" / "fixtures" + files = sorted(root.rglob("*.json")) + if not files: + raise SystemExit("no Mac Access contract fixtures found") + for path in files: + with path.open(encoding="utf-8") as handle: + json.load(handle) + print(f"parsed {len(files)} Mac Access contract fixture files") + + +if __name__ == "__main__": + main() diff --git a/tests/contract/macAccessContracts.test.ts b/tests/contract/macAccessContracts.test.ts new file mode 100644 index 0000000000..22f1a7d9fc --- /dev/null +++ b/tests/contract/macAccessContracts.test.ts @@ -0,0 +1,184 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { describe, expect, it } from 'vitest'; + +import { + accessStateSchema, + accessTransitionSchema, + auditEventSchema, + authenticatedLocalActionSchema, + brokerControlEnvelopeSchema, + localStatusSchema, + negativeFixtureCaseSchema, +} from '../../packages/mac-connector-core/contracts/v1'; + +const contractRoot = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + '../../packages/mac-connector-core/contracts/v1' +); +const validRoot = path.join(contractRoot, 'fixtures/valid'); +const invalidRoot = path.join(contractRoot, 'fixtures/invalid'); + +const schemas = { + access_state: accessStateSchema, + access_transition: accessTransitionSchema, + local_status: localStatusSchema, + authenticated_local_action: authenticatedLocalActionSchema, + broker_control: brokerControlEnvelopeSchema, + audit_event: auditEventSchema, +} as const; + +function readJson(filePath: string): unknown { + return JSON.parse(fs.readFileSync(filePath, 'utf8')); +} + +function cloneJson(value: T): T { + return JSON.parse(JSON.stringify(value)) as T; +} + +function applyMutation( + target: unknown, + mutation: { operation?: 'set' | 'remove'; pointer?: string; value?: unknown } +): void { + if (!mutation.operation || !mutation.pointer) { + throw new Error('Fixture mutation is missing its operation or pointer'); + } + const parts = mutation.pointer + .slice(1) + .split('/') + .map((part) => part.replaceAll('~1', '/').replaceAll('~0', '~')); + if (!target || typeof target !== 'object' || parts.length === 0) { + throw new Error(`Cannot apply fixture mutation ${mutation.pointer}`); + } + let current = target as Record; + for (const part of parts.slice(0, -1)) { + const next = current[part]; + if (!next || typeof next !== 'object' || Array.isArray(next)) { + throw new Error(`Fixture mutation parent does not exist: ${mutation.pointer}`); + } + current = next as Record; + } + const key = parts.at(-1)!; + if (mutation.operation === 'remove') { + delete current[key]; + } else { + current[key] = mutation.value; + } +} + +const negativeFixtures = fs + .readdirSync(invalidRoot) + .filter((name) => name.endsWith('.json')) + .flatMap((manifestName) => { + const manifestPath = path.join(invalidRoot, manifestName); + const cases = readJson(manifestPath); + if (!Array.isArray(cases)) throw new Error(`Negative fixture manifest must be an array: ${manifestPath}`); + return cases.map((rawCase) => ({ manifestPath, fixture: negativeFixtureCaseSchema.parse(rawCase) })); + }); + +const expectedIssuePathByError: Record = { + execution_context_binding_mismatch: 'execution_context/claims', + selected_device_mismatch: 'authorization/payload', + selected_grant_mismatch: 'authorization/payload', + selected_runtime_mismatch: 'execution_context/claims', + connector_installation_mismatch: 'authorization/payload', + connector_key_mismatch: 'authorization/payload', + peer_designated_requirement_mismatch: 'peer/signing_identifier', + leader_runtime_mismatch: 'leader/runtime_instance_id', + helper_identity_mismatch: 'leader/helper_service_id', + stop_pending_authority_not_invalidated: 'event', + rollback_authorization_id_mismatch: 'relay_authorization/rollback_authorization', + rollback_target_mismatch: 'relay_authorization/rollback_authorization/payload/target', + rollback_authorization_expired: 'relay_authorization/rollback_authorization/payload/expires_at', + full_access_reconfirmation_required: 'effective_mode', + audit_forbidden_field: 'evidence', + unsupported_schema: 'schema_version', + audit_failure_not_off: 'audit/writable', + effective_mode_exceeds_configured_mode: 'effective_mode', + tcc_loss_not_off: 'tcc', + connected_channel_missing: 'transport/channel_id', + pairing_epoch_required: 'request/expected_policy_epoch', + audit_evidence_not_allowlisted: 'evidence', + audit_secret_bearing_value: 'evidence/detail_code', + audit_previous_digest_required: 'previous_record_sha256', + build_below_security_floor: 'leader/build/security_epoch', + pairing_must_default_to_ask_every_time: 'event', + full_access_policy_epoch_mismatch: 'confirmed_policy_epoch', + full_access_binding_mismatch: 'confirmed_binding_fingerprint_sha256', + command_authority_too_long: 'expires_at', + channel_generation_mismatch: 'authorization/payload', + authority_outlives_execution_context: 'expires_at', +}; + +describe('evaOS Mac Access v1 contracts', () => { + const validFixtures = [ + ['access_state', 'access-state.json'], + ['access_state', 'full-access-state.json'], + ['access_transition', 'access-transition.json'], + ['access_transition', 'access-transition-stop.json'], + ['local_status', 'local-status.json'], + ['authenticated_local_action', 'local-action.json'], + ['broker_control', 'broker-control.json'], + ['audit_event', 'audit-event.json'], + ] as const; + + it.each(validFixtures)('accepts the valid %s fixture', (contract, fileName) => { + expect(schemas[contract].safeParse(readJson(path.join(validRoot, fileName))).success).toBe(true); + }); + + it('keeps every required adversarial threat in the versioned negative fixture set', () => { + const requiredThreats = new Set([ + 'replay', + 'stale_binding', + 'wrong_customer', + 'wrong_device', + 'duplicate_process', + 'local_untrusted_client', + 'stolen_pairing_code', + 'revoked_grant', + 'offline_broker', + 'helper_replacement', + 'downgrade', + 'crash_recovery', + 'request_tampering', + 'log_secret_exposure', + 'audit_unavailable', + ]); + const seen = new Set(); + + for (const { manifestPath, fixture } of negativeFixtures) { + expect(seen.has(fixture.id)).toBe(false); + seen.add(fixture.id); + requiredThreats.delete(fixture.threat); + + const payload = cloneJson(readJson(path.resolve(path.dirname(manifestPath), fixture.base_fixture))); + for (const mutation of fixture.mutations) applyMutation(payload, mutation); + const parsed = schemas[fixture.contract].safeParse(payload); + if (fixture.expected_stage === 'schema') { + expect(parsed.success, fixture.id).toBe(false); + if (parsed.success) throw new Error(`Expected schema rejection for ${fixture.id}`); + const expectedPath = expectedIssuePathByError[fixture.expected_error]; + expect(expectedPath, `${fixture.id} must register a stable issue path`).toBeTruthy(); + expect( + parsed.error.issues.some((issue) => issue.path.join('/') === expectedPath), + `${fixture.id} must reject at ${expectedPath}; got ${parsed.error.issues + .map((issue) => issue.path.join('/')) + .join(', ')}` + ).toBe(true); + } else { + expect(parsed.success, fixture.id).toBe(true); + expect(fixture.required_runtime_rejection, fixture.id).toBeTruthy(); + } + } + + expect([...requiredThreats]).toEqual([]); + }); + + for (const { fixture: runtimeFixture } of negativeFixtures.filter( + (entry) => entry.fixture.expected_stage === 'runtime' + )) { + it.todo(`runtime rejection ${runtimeFixture.id} must return ${runtimeFixture.expected_error}`); + } +}); diff --git a/tests/contract/macAccessCryptography.test.ts b/tests/contract/macAccessCryptography.test.ts new file mode 100644 index 0000000000..af023aa3c9 --- /dev/null +++ b/tests/contract/macAccessCryptography.test.ts @@ -0,0 +1,87 @@ +import { createHash, createPublicKey, verify } from 'node:crypto'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { describe, expect, it } from 'vitest'; + +import { + auditChainGoldenSchema, + commandAuthorityGoldenSchema, + rollbackAuthorizationGoldenSchema, +} from '../../packages/mac-connector-core/contracts/v1'; + +const contractRoot = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + '../../packages/mac-connector-core/contracts/v1' +); +const validRoot = path.join(contractRoot, 'fixtures/valid'); +const goldenRoot = path.join(contractRoot, 'golden'); + +function readJson(filePath: string): unknown { + return JSON.parse(fs.readFileSync(filePath, 'utf8')); +} + +function canonicalizeJcs(value: unknown): string { + if (value === null || typeof value !== 'object') return JSON.stringify(value); + if (Array.isArray(value)) return `[${value.map(canonicalizeJcs).join(',')}]`; + return `{${Object.entries(value) + // oxlint-disable-next-line unicorn/no-array-sort -- Object.entries returns a fresh array required for JCS key order. + .sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0)) + .map(([key, item]) => `${JSON.stringify(key)}:${canonicalizeJcs(item)}`) + .join(',')}}`; +} + +describe('evaOS Mac Access canonical cryptographic contracts', () => { + it('verifies the cross-language command authorization golden vector', () => { + const vector = commandAuthorityGoldenSchema.parse(readJson(path.join(validRoot, 'command-authority-golden.json'))); + const canonical = canonicalizeJcs(vector.payload); + expect(canonical).toBe(vector.canonical_payload_utf8); + expect(createHash('sha256').update(canonical).digest('hex')).toBe(vector.payload_sha256); + const publicKey = createPublicKey({ + key: Buffer.from(vector.public_key_spki_base64url, 'base64url'), + format: 'der', + type: 'spki', + }); + expect(verify(null, Buffer.from(canonical), publicKey, Buffer.from(vector.signature_base64url, 'base64url'))).toBe( + true + ); + }); + + it('verifies the two-record audit-chain golden vector', () => { + const vector = auditChainGoldenSchema.parse(readJson(path.join(validRoot, 'audit-chain-golden.json'))); + let previousDigest: string | null = null; + + for (const [index, record] of vector.records.entries()) { + const canonical = canonicalizeJcs(record.payload); + expect(record.payload.sequence).toBe(index + 1); + expect(record.payload.previous_record_sha256).toBe(previousDigest); + expect(canonical).toBe(record.canonical_payload_utf8); + expect(createHash('sha256').update(canonical).digest('hex')).toBe(record.record_sha256); + previousDigest = record.record_sha256; + } + }); + + it('verifies the exact-target rollback authorization golden vector', () => { + const vector = rollbackAuthorizationGoldenSchema.parse( + readJson(path.join(goldenRoot, 'rollback-authorization-golden.json')) + ); + const canonical = canonicalizeJcs(vector.payload); + expect(canonical).toBe(vector.canonical_payload_utf8); + expect(createHash('sha256').update(canonical).digest('hex')).toBe(vector.payload_sha256); + const publicKey = createPublicKey({ + key: Buffer.from(vector.public_key_spki_base64url, 'base64url'), + format: 'der', + type: 'spki', + }); + expect(verify(null, Buffer.from(canonical), publicKey, Buffer.from(vector.signature_base64url, 'base64url'))).toBe( + true + ); + }); + + it.todo('rejects an edited audit payload whose record digest is unchanged'); + it.todo('rejects deletion of an audit record from a persisted chain'); + it.todo('rejects reordered audit records'); + it.todo('rejects a previous-record digest mismatch'); + it.todo('rejects a rollback payload digest or broker signature mismatch'); +}); diff --git a/vitest.config.ts b/vitest.config.ts index 4968a345e2..8bebf16084 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -29,6 +29,7 @@ export default defineConfig({ include: [ 'tests/unit/**/*.test.ts', 'tests/unit/**/test_*.ts', + 'tests/contract/**/*.test.ts', 'tests/integration/**/*.test.ts', 'tests/regression/**/*.test.ts', ], From 3c7dad3f5d825f0fafe492ba50aa822c6176c891 Mon Sep 17 00:00:00 2001 From: Eva Date: Wed, 15 Jul 2026 04:23:44 +0700 Subject: [PATCH 02/14] docs(mac-access): refresh core extraction contract --- ...1-independent-product-shared-repository.md | 2 +- docs/evaos/mac-access/architecture.md | 35 ++- docs/evaos/mac-access/migration-map.md | 162 ++++++++----- docs/evaos/mac-access/threat-model.md | 59 ++--- packages/mac-connector-core/README.md | 2 +- .../v1/fixtures/host/host-request.json | 9 + .../v1/fixtures/host/host-response.json | 16 ++ .../v1/fixtures/invalid/identity.json | 126 ++++++++++ .../v1/fixtures/valid/local-status.json | 10 +- .../golden/rollback-authorization-golden.json | 10 +- .../mac-connector-core/contracts/v1/index.ts | 226 ++++++++++++++++-- tests/contract/macAccessContracts.test.ts | 175 +++++++++++++- 12 files changed, 692 insertions(+), 140 deletions(-) create mode 100644 packages/mac-connector-core/contracts/v1/fixtures/host/host-request.json create mode 100644 packages/mac-connector-core/contracts/v1/fixtures/host/host-response.json diff --git a/docs/evaos/mac-access/adr-0001-independent-product-shared-repository.md b/docs/evaos/mac-access/adr-0001-independent-product-shared-repository.md index 7a804508bd..0421be1552 100644 --- a/docs/evaos/mac-access/adr-0001-independent-product-shared-repository.md +++ b/docs/evaos/mac-access/adr-0001-independent-product-shared-repository.md @@ -3,7 +3,7 @@ - Status: proposed for #699; blocks implementation issues - Date: 2026-07-15 - Decision owners: evaOS Mac Access epic #698 and architecture issue #699 -- Inspected source: evaOS-GUI PR #697 head `ae9f35062f8a4b212fab4576d44602b57dc0303a` +- Inspected source: evaOS-GUI PR #697 head `f197d2e51828f9f6a50e8448a09d5b064d14b498` ## Context diff --git a/docs/evaos/mac-access/architecture.md b/docs/evaos/mac-access/architecture.md index bf6f0aaacd..d23c41823e 100644 --- a/docs/evaos/mac-access/architecture.md +++ b/docs/evaos/mac-access/architecture.md @@ -3,7 +3,7 @@ Issue: [#699](https://github.com/100yenadmin/evaOS-GUI/issues/699) Parent: [#698](https://github.com/100yenadmin/evaOS-GUI/issues/698) Contract version: `2026-07-15.v1` -Inspected Workbench source: PR [#697](https://github.com/100yenadmin/evaOS-GUI/pull/697) head `ae9f35062f8a4b212fab4576d44602b57dc0303a` +Inspected Workbench source: PR [#697](https://github.com/100yenadmin/evaOS-GUI/pull/697) head `f197d2e51828f9f6a50e8448a09d5b064d14b498` ## Decision @@ -13,6 +13,8 @@ The Mac opens an outbound authenticated WebSocket to the evaOS broker relay. It Backend support for that outbound Mac relay does not exist at the inspected ws-proxy head. It is tracked in [electricsheephq/evaos-ws-proxy#73](https://github.com/electricsheephq/evaos-ws-proxy/issues/73). Implementation must remain fail closed until that contract exists; the current browser-to-VM proxy path is not a substitute. +The inspected #697 head adds Workbench candidate-source/app attestation, Tailscale enrollment diagnostics, connector URL/token readiness, generation-linearized stop/kill/start behavior, content-hashed audit evidence, packaged CLI canaries, and stricter beta/live release gates. Those are migration and parity inputs only. They reinforce the target ban on Workbench-owned networking, lifecycle, credentials, TCC/CUA, and release authority; they do not become Mac Access runtime dependencies or relax any v1 contract. + This document is executable design evidence. It is not a working-app, signed-artifact, notarization, pristine-Mac, VM-to-Mac, customer-readiness, publication, or rollout claim. ## First-principles review @@ -86,7 +88,14 @@ packages/mac-access/ # at most 8 direct children packages/mac-connector-core/ # at most 6 direct children ├── contracts/ # versioned language-neutral contracts and fixtures -├── python/ # migrated PR #697 bridge implementation +├── python/ +│ └── evaos_desktop_bridge/ # at most 6 direct children +│ ├── __init__.py +│ ├── adapters/ # protocol, planner, and temporary CUA adapters +│ ├── contracts/ # capability, schema, types, and redaction +│ ├── host/ # host API, CLI, compatibility dispatcher, tooling +│ ├── persistence/ # audit, queue, and state compatibility +│ └── policy/ # policy compatibility before native retirement ├── swift/ # native ports: Keychain, IPC, TCC/CUA, transport ├── tests/ # cross-language behavior and negative fixtures ├── scripts/ # generation and parity checks @@ -95,6 +104,8 @@ packages/mac-connector-core/ # at most 6 direct children Subdirectories also obey the repository limit of ten direct children. Pure parsing, canonicalization, binding comparison, policy transitions, redaction, and receipt construction remain separate from Keychain, WebSocket, filesystem, audit, TCC, process, and CUA I/O. +The A1 core host boundary is `evaos.mac_connector_core.host_request.v1` / `host_response.v1`, implemented by `python/evaos_desktop_bridge/host/api.py` without importing Electron or renderer code. It is a fixed-operation private interface for status, pair/unpair, connect/disconnect, access mode, action dispatch, audit summary, pause/resume, emergency stop, revoke/kill, and shutdown. Requests bind a helper-created host session, monotonic safe-integer sequence, request ID, and safe-integer expected policy epoch; unknown operations and fields fail closed. Stop is distinct from process shutdown: it must synchronously rotate policy authority, force effective Off, invalidate pending approvals/commands, and safely cancel active work before returning. The helper launches the embedded core over a private inherited channel with a one-runtime lifetime—never HTTP, a public socket, PATH discovery, or a renderer-callable endpoint. + PR #699 introduces only the versioned contract source/fixtures under `packages/mac-connector-core/contracts/v1`, cross-language syntax smoke programs under `packages/mac-connector-core/tests`, and focused contract-test discovery. Creating native targets, moving Python, or changing Workbench runtime behavior is downstream work. ## Stable identities @@ -301,7 +312,7 @@ Uninstall/revoke order is: activate local deny barrier, invalidate approvals/que ## Contract fixtures and downstream proof -Versioned contracts and fixtures live at `packages/mac-connector-core/contracts/v1`. TypeScript/Zod validates structure and cross-field invariants and verifies the command-authority signature vector and audit-chain digest vector. Python and Swift smoke programs prove only that every JSON fixture is syntactically consumable without a customer-managed runtime; typed semantic parity is an explicit #700 gate. +Versioned contracts and fixtures live at `packages/mac-connector-core/contracts/v1`. TypeScript/Zod validates structure and cross-field invariants, freezes the complete non-Electron core host operation matrix, and verifies the command-authority signature vector and audit-chain digest vector. Python and Swift smoke programs prove only that every JSON fixture is syntactically consumable without a customer-managed runtime; typed semantic parity and packaged host integration are explicit #700 gates. The negative manifests distinguish: @@ -310,15 +321,15 @@ The negative manifests distinguish: Downstream issues may claim completion only with the following evidence: -| Issue lane | Minimum evidence | -| ---------------- | ----------------------------------------------------------------------------------------------------------------------------- | -| #700 core | TS/Python/Swift semantic parity; negative fixtures; no system Python; native ports own secrets/TCC/audit. | -| #701 app | native menu flow; truthful states; signed identity inspection; no Workbench dependency. | -| #702 transport | ws-proxy #73 source contract; exact selected-binding positive and all negative cases; outbound-only packet/listener evidence. | -| #703 policy | transition tests, exact-scope prompt, restart downgrade, stop/revoke/kill race tests, redacted audit chain. | -| #704 coexistence | single leader/listener/grant/TCC identity with both apps; make-before-break and rollback. | -| #705 release | signed, notarized, stapled independent artifact/appcast; upgrade/downgrade/uninstall/orphan proof. | -| #706 live proof | pristine supported Mac onboarding and broker-selected VM-to-Mac CUA with exact audit IDs. | +| Issue lane | Minimum evidence | +| ---------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | +| #700 core | Bounded subpackages; TS/Python/Swift parity; complete host-operation integration without Electron/system Python; negative fixtures; native-port boundaries. | +| #701 app | native menu flow; truthful states; signed identity inspection; no Workbench dependency. | +| #702 transport | ws-proxy #73 source contract; exact selected-binding positive and all negative cases; outbound-only packet/listener evidence. | +| #703 policy | transition tests, exact-scope prompt, restart downgrade, stop/revoke/kill race tests, redacted audit chain. | +| #704 coexistence | single leader/listener/grant/TCC identity with both apps; make-before-break and rollback. | +| #705 release | signed, notarized, stapled independent artifact/appcast; upgrade/downgrade/uninstall/orphan proof. | +| #706 live proof | pristine supported Mac onboarding and broker-selected VM-to-Mac CUA with exact audit IDs. | Public publication, rollout, and customer readiness remain separately authorized gates after all of the above. diff --git a/docs/evaos/mac-access/migration-map.md b/docs/evaos/mac-access/migration-map.md index 4c95d5dfc9..0776af5362 100644 --- a/docs/evaos/mac-access/migration-map.md +++ b/docs/evaos/mac-access/migration-map.md @@ -1,7 +1,7 @@ # evaOS Mac Access source-to-target migration map Issue: [#699](https://github.com/100yenadmin/evaOS-GUI/issues/699) -Inspected source: PR [#697](https://github.com/100yenadmin/evaOS-GUI/pull/697) head `ae9f35062f8a4b212fab4576d44602b57dc0303a` +Inspected source: PR [#697](https://github.com/100yenadmin/evaOS-GUI/pull/697) head `f197d2e51828f9f6a50e8448a09d5b064d14b498` Contract version: `2026-07-15.v1` ## Rule @@ -19,74 +19,114 @@ No connector implementation may land from this inspected copy while #697 remains | A2 | #701 | Build native menu app/helper with frozen identities and local-only flows. | Core contracts pass; no remote relay claim. | | A3 | #702 plus ws-proxy #73 | Add outbound selected-binding relay and helper transport. | Backend source contract exists; negative binding/replay tests pass. | | A4 | #703 | Make native policy/audit/revoke/kill authority complete. | Transport cannot bypass local policy. | -| A5 | #704 | Convert Workbench from owner/spawner to authenticated Mac Access client. | Mac Access exclusive-leader readiness and rollback handshake pass. | +| A5 | #704 | Convert Workbench runtime, packaging, and release proof from connector owner to authenticated Mac Access client. | Mac Access exclusive-leader readiness and rollback handshake pass. | | A6 | #705 | Independent sign/notarize/update/rollback/uninstall. | Coexistence source and local proof pass. | | A7 | #706 | Pristine-Mac and broker-selected VM-to-Mac CUA proof. | Exact signed candidate passes prior gates. | ## PR #697 Python module ownership -The `Target owner` column is intentionally singular. A module assigned to the native helper is ported and then retired from Python. A module assigned to Python may call a narrow native port but does not own that port's authority. - -| Current source under `resources/evaos-beta/bridge/src/evaos_desktop_bridge/` | Current role | Target owner | Phase | Migration action and exit check | -| ---------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------ | ----- | ------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `__init__.py` | Package metadata/entry namespace. | `packages/mac-connector-core/python/evaos_desktop_bridge/__init__.py` | A1 | Move after #697 canonicalization; version must match embedded manifest. | -| `adapters/__init__.py` | Adapter namespace. | `packages/mac-connector-core/python/evaos_desktop_bridge/adapters/__init__.py` | A1 | Move; export only private runtime adapters. | -| `adapters/codex_app_server.py` | Codex app-server protocol adapter. | connector-core Python adapter | A1 | Move and keep pure protocol normalization; no credential/TCC ownership. | -| `adapters/codex_macos.py` | Direct macOS/Codex CUA adapter. | connector-core Swift `NativeCuaPort` in the signed helper | A1 | Port required behavior and negative cases; delete direct Python native execution before TCC proof. | -| `adapters/customer_mac.py` | Customer-Mac command normalization and orchestration, including legacy iPhone Mirroring branches. | connector-core Python Mac-only planner | A1 | Extract only Mac capabilities; replace native calls with a bounded helper-owned port. iPhone Mirroring branches are not packaged or exposed in v0.1. | -| `audit.py` | Local JSON audit construction/write. | connector-core Swift `AuditStore` in helper | A4 | Port authoritative write/chain/redaction; Python may return draft metadata only. Audit failure denies execution. | -| `behavior_harness.py` | Behavior parity harness. | connector-core tests | A1 | Move as parity oracle; run against embedded Python and native port fixtures. | -| `bundled_tools.py` | Tool discovery/invocation helpers. | connector-core Python private runtime | A1 | Remove PATH/Homebrew/system discovery; resolve only embedded, manifest-pinned resources. | -| `capability_manifest.py` | Capability catalog and availability. | connector-core pure contracts | A1 | Generate language adapters from one versioned manifest; availability cannot imply authority. | -| `cli.py` | Public-style bridge CLI and service entry points. | connector-core Python private process entry | A1 | Keep only private helper-launched stdin/stdout or private socket entry; remove customer/Workbench public CLI dependency. | -| `connector_server.py` | Token-gated HTTP listener, enrollment, remote command dispatch, including out-of-scope iPhone commands. | Mac Access native helper Mac-only transport/dispatcher | A3 | Port only Mac command mapping; retire HTTP listener, connector URL/token, enrollment callback, and iPhone command routes. No inbound listener remains. | -| `helper_ipc.py` | Unix socket, file token, same-UID helper and direct AX/Quartz execution. | Mac Access signed XPC helper | A1 | Replace public boundary with audit-token/SecCode authentication; port CUA; retire token/UID authority. | -| `policy.py` | Capability policy table. | connector-core Swift `AccessPolicy` in helper | A4 | Port and bind decisions to selected tuple, request digest, mode, epoch, approval, and audit. Python table becomes parity input only, then retires. | -| `pre_canary.py` | Pre-live environment/control checks. | connector-core tests | A1 | Convert to hermetic source/embedded-runtime checks; no system Python or live customer dependency. | -| `qa_canary.py` | Connector/OpenClaw/Hermes behavior suites. | connector-core proof tests | A1 | Preserve scenario semantics; replace URL/token inputs with test relay/native client fixtures before A7. | -| `queue.py` | Local queued-operation persistence. | connector-core Swift `CommandQueue` in helper | A4 | Port bounded queue, expiry, epoch, revoke/kill cancellation, and crash clearing. | -| `redaction.py` | Recursive public-output redaction. | connector-core pure Python redaction parity | A1 | Move and align with `audit_event.v1`; native helper remains final enforcement point. | -| `schema.py` | Common result/error envelope builders. | connector-core versioned contract adapters | A1 | Replace ad-hoc shapes with generated/hand-verified v1 adapters in TS, Swift, and embedded Python. | -| `state.py` | Pairing/control/audit state files. | connector-core Swift `ConnectorStateStore` in helper | A4 | Port versioned state, Keychain references, policy epoch, revocation tombstone, and atomic recovery; never copy raw tokens. | -| `types.py` | Python command/result types. | connector-core generated Python contract types | A1 | Generate or verify against v1 fixtures; unknown fields and schemas fail closed at authority boundaries. | - -## Workbench production call-site ownership +The `Target owner` column is intentionally singular and names the A1 canonical location. Every current runtime module first moves exactly once into an explicit subpackage under `packages/mac-connector-core/python/evaos_desktop_bridge`; harness/canary modules move into connector-core tests. The root has six direct children and every subpackage stays below ten. Later A2-A4 native ports narrow or retire Python behavior only after parity proof and never leave a second source tree. + +| Current source under `resources/evaos-beta/bridge/src/evaos_desktop_bridge/` | Current role | Target owner | Phase | Migration action and exit check | +| ---------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------ | ----- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `__init__.py` | Package metadata/entry namespace. | `packages/mac-connector-core/python/evaos_desktop_bridge/__init__.py` | A1 | Move after #697 canonicalization; version must match embedded manifest. | +| `adapters/__init__.py` | Adapter namespace. | `packages/mac-connector-core/python/evaos_desktop_bridge/adapters/__init__.py` | A1 | Move; export only private runtime adapters. | +| `adapters/codex_app_server.py` | Codex app-server protocol adapter. | `python/evaos_desktop_bridge/adapters/codex_app_server.py` | A1 | Move and keep pure protocol normalization; no credential/TCC ownership. | +| `adapters/codex_macos.py` | Direct macOS/Codex CUA adapter. | `python/evaos_desktop_bridge/adapters/codex_macos.py` | A1 | Move intact behind the core host interface; A2/A4 later port required behavior to signed native CUA and retire direct Python execution after parity proof. | +| `adapters/customer_mac.py` | Customer-Mac normalization/orchestration with generation-guarded start, stop, kill, and takeover state. | `python/evaos_desktop_bridge/adapters/customer_mac.py` | A1 | Preserve race semantics and extract only Mac capabilities; replace native calls with a bounded helper-owned port. iPhone Mirroring branches are not packaged or exposed in v0.1. | +| `audit.py` | Local append-only JSON audit writes with content hashing, no-follow open, and mode hardening. | `python/evaos_desktop_bridge/persistence/audit.py` | A1 | Move intact and preserve current behavior; A4 later ports authoritative write/chain/redaction to Swift, leaving Python draft metadata only. Audit failure always denies execution. | +| `behavior_harness.py` | Behavior parity harness. | `tests/python/behavior_harness.py` | A1 | Move as parity oracle; run against embedded Python and native port fixtures. | +| `bundled_tools.py` | Tool discovery/invocation helpers. | `python/evaos_desktop_bridge/host/bundled_tools.py` | A1 | Remove PATH/Homebrew/system discovery; resolve only embedded, manifest-pinned resources. | +| `capability_manifest.py` | Capability catalog and availability. | `python/evaos_desktop_bridge/contracts/capability_manifest.py` | A1 | Generate language adapters from one versioned manifest; availability cannot imply authority. | +| `candidate_identity.py` | Packaged Workbench source-tree and application identity attestation. | `python/evaos_desktop_bridge/host/candidate_identity.py` | A1 | Move with the runtime and preserve candidate/provenance parity; never treat Python self-attestation as runtime or broker authority. #705 adds a separate native artifact verifier. | +| `cli.py` | Public-style bridge CLI with audit hashing and control-generation gates. | `python/evaos_desktop_bridge/host/cli.py` | A1 | Preserve hashed-evidence and stale-generation negatives, then keep only the helper-launched private host entry; remove customer/Workbench public CLI dependency. | +| `connector_server.py` | Token-gated HTTP enrollment/dispatch with control-generation and kill-switch checks, plus iPhone routes. | `python/evaos_desktop_bridge/host/connector_server.py` | A1 | Move intact for Workbench parity only; A3 ports Mac dispatch to the helper and retires HTTP listener, URL/token, enrollment callback, and iPhone routes without losing race checks. | +| `helper_ipc.py` | Unix socket, file token, same-UID helper and direct AX/Quartz execution. | `python/evaos_desktop_bridge/host/helper_ipc.py` | A1 | Move intact behind the host API; A2 later replaces token/UID authority with signed XPC and ports CUA after parity proof. | +| `policy.py` | Capability policy table. | `python/evaos_desktop_bridge/policy/policy.py` | A1 | Move intact and bind contract parity tests; A4 later ports authority to Swift with selected tuple, digest, mode, epoch, approval, and audit checks. | +| `pre_canary.py` | Pre-live environment/control checks. | `tests/python/pre_canary.py` | A1 | Convert to hermetic source/embedded-runtime checks; no system Python or live customer dependency. | +| `qa_canary.py` | Connector/OpenClaw/Hermes suites plus source-bound selected-binding proof validation. | `tests/python/qa_canary.py` | A1 | Preserve scenario and proof-binding semantics; replace URL/token inputs with test relay/native client fixtures before A7. | +| `queue.py` | Local queued-operation persistence. | `python/evaos_desktop_bridge/persistence/queue.py` | A1 | Move intact and preserve crash/restart behavior; A4 later ports bounded queue, expiry, epoch, revoke/kill cancellation, and crash clearing to Swift. | +| `redaction.py` | Separate recursive public-output and audit-value redaction. | `python/evaos_desktop_bridge/contracts/redaction.py` | A1 | Move both policies, align audit output with `audit_event.v1`, and keep the native helper as final enforcement point. | +| `schema.py` | Common result/error envelope builders. | `python/evaos_desktop_bridge/contracts/schema.py` | A1 | Replace ad-hoc shapes with generated/hand-verified v1 adapters in TS, Swift, and embedded Python. | +| `state.py` | Pairing/control/audit state plus locked generation transactions and persistent kill-switch state. | `python/evaos_desktop_bridge/persistence/state.py` | A1 | Preserve linearization/race negatives without raw tokens; A4 ports versioned state, Keychain references, epochs, revocation tombstone, and atomic recovery to Swift. | +| `types.py` | Python command/result types. | `python/evaos_desktop_bridge/contracts/types.py` | A1 | Generate or verify against v1 fixtures; unknown fields and schemas fail closed at authority boundaries. | + +## Connector-core A1 host interface + +The singular runtime owner is `packages/mac-connector-core/python/evaos_desktop_bridge/host/api.py`; the language-neutral schema source is `packages/mac-connector-core/contracts/v1`. The interface is private to the helper-launched embedded core and has no Electron, renderer, HTTP, public socket, PATH, or customer-managed runtime dependency. + +| Required operation | Versioned request/response route | A1 proof | +| -------------------- | ---------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- | +| status | `status` -> status result | Start the core without Electron and compare the schema-bound status to source parity fixtures. | +| pair / unpair | `pair`, `unpair` -> pairing result | Public code plus local installation nonce; one-time claim/revoke tests; no credential in result or logs. | +| connect / disconnect | `connect`, `disconnect` -> lifecycle result | Exact selected binding, expected policy epoch, private fake transport; no direct-IP/public-listener fallback. | +| access mode | `set_access_mode` -> lifecycle result | Off/Ask/Full transition parity, confirmation rules, and stale-epoch negatives. | +| action dispatch | `dispatch_action` -> action result | Full broker envelope validation through fake native ports; no Electron import or renderer-callable command body. | +| audit summary | `audit_summary` -> bounded redacted summary | Maximum 100 allowlisted events; secret and unknown-field negatives. | +| pause / resume | fixed lifecycle operations -> lifecycle result | Pause/resume state and offline operation tests. | +| stop | `stop` -> lifecycle result | Synchronously rotate epoch, force effective Off, invalidate pending authority, and safely cancel active work. | +| revoke / kill | fixed lifecycle operations -> lifecycle result | Persistent revocation/kill state, offline operation, and fail-closed race tests. | +| shutdown | `shutdown` -> lifecycle result | Stop new work after policy-safe teardown, close the private channel, and prove no orphan process. | + +Every request binds `host_session_id`, monotonic safe-integer `sequence`, `request_id`, and the safe-integer expected policy epoch where applicable. #700 must run the complete operation matrix through the packaged embedded core using fake filesystem/native/transport ports and prove import scans contain no Electron or renderer dependency. The existing Workbench `local_action.v1` remains a separate authenticated client surface and cannot substitute for this host API. + +## Workbench production and proof-surface ownership All target owners below remain in `packages/desktop` as clients or presentation. None remains a connector authority after A5. -| Current Workbench file | Current responsibility | Target owner | Phase | Required change and exit check | -| ----------------------------------------------------------- | -------------------------------------------------------------------------------------------------------- | -------------------------------------------- | ----- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `src/common/evaos/bridgeTypes.ts` | Native status/action DTOs, including current control-mode shapes. | Workbench generated Mac Access client DTOs | A5 | Add v1 status/actions including `off`; delete raw connector URL/token/path fields from client-visible types. | -| `src/common/evaos/nativeCompanionBoundary.ts` | Static shell/native trust matrix and canaries. | Workbench trust/presentation contract | A5 | Point native owner to Mac Access helper; canaries describe client status, not Python CLI authority. | -| `src/process/evaosNativeCompanionBoundary.ts` | Main-process local-action boundary enforcement. | Workbench fail-closed client boundary | A5 | Permit only fixed authenticated-client actions; no generic local CUA or lifecycle escape hatch. | -| `src/index.ts` | Main-process startup/teardown registration. | Workbench Mac Access client bootstrap | A5 | Register XPC client bridge; stop only Workbench client/session state, never an unknown Mac Access helper. | -| `src/process/bridge/evaosNativeCompanionBridge.ts` | Renderer-to-main status/diagnostic/action IPC. | Workbench renderer-safe Mac Access bridge | A5 | Call the authenticated client, preserve `BridgeResponse`, and return redacted status/audit IDs only. | -| `src/process/bridge/evaosRendererSecretGuard.ts` | Renderer secret-shape rejection. | Workbench renderer secret guard | A5 | Extend forbidden fields to Mac Access channel, binding, peer, Keychain, signature, and transport details. | -| `src/process/bridge/index.ts` | IPC bridge registration. | Workbench bridge registry | A5 | Register only the Mac Access client bridge once. | -| `src/process/resources/builtinMcp/evaosMacControlServer.ts` | Spawns bridge CLI and exposes Mac-control tools. | Workbench-to-Mac Access client adapter | A5 | Remove bridge binary/PATH resolution and direct process spawn; route allowed requests through helper client or selected broker path. | -| `src/process/services/evaosNativeCompanionStatus.ts` | Status, process spawn, local HTTP readiness, tailnet discovery, grants, enrollment, actions, TCC repair. | Workbench `MacAccessClientService` | A5 | Retain presentation/orchestration only; delete connector spawn/stop, URL/token, host discovery, Tailscale bootstrap, grant ownership, direct bridge CLI, and TCC authority. | -| `src/process/services/evaosBrokerSession.ts` | Completes connector enrollment and creates grants from connector URL/token/tailnet inputs. | Broker-selected Mac Access client adapter | A5 | Remove Workbench connector registration/grant authority and raw endpoint/token inputs; request only broker-side atomic prepare/commit/reverse-handoff operations. | -| `src/common/adapter/ipcBridge.ts` | Declares renderer/main native-companion IPC surface. | Workbench generated renderer-safe client IPC | A5 | Replace legacy action/status shapes with the redacted Mac Access client DTOs; no generic or secret-bearing fields. | -| `src/process/utils/runBackendMigrations.ts` | Executes backend migrations including native-companion seams. | Workbench coexistence migration coordinator | A5 | Run idempotent make-before-break client cutover marker; never migrate raw credentials in renderer/Node storage. | -| `src/renderer/evaos/useEvaosNativeCompanionStatus.ts` | Polls/requests status and actions. | Workbench renderer hook | A5 | Render redacted v1 status; no connector lifecycle assumptions. | -| `src/renderer/evaos/nativeCompanionViewModel.ts` | Converts native status into customer UI state. | Workbench presentation model | A5 | Map `Off`, `Ask Every Time`, `Full Access`, paused/revoked/blocked, and reconfirmation truthfully. | -| `src/renderer/pages/native-companion/index.tsx` | Mac & iPhone/native companion page. | Workbench Mac Access client UI | A5 | Present status/handoff controls; Mac Access app owns pairing/TCC/access confirmation. iPhone Mirroring is outside this sprint. | -| `src/renderer/pages/guid/hooks/useGuidAgentSelection.ts` | Consumes native-companion readiness during guided agent selection. | Workbench redacted readiness consumer | A5 | Consume client status only; no fallback connector start or readiness inference from legacy bridge state. | -| `src/renderer/pages/settings/AgentSettings/LocalAgents.tsx` | Consumes native-companion status in Local Agents settings. | Workbench redacted status consumer | A5 | Render Mac Access state/handoff only; no connector lifecycle, token, or TCC authority. | -| `src/process/resources/builtinMcp/constants.ts` | Names and recognizes the packaged Mac-control MCP server. | Workbench Mac Access MCP recognizer | A5 | Recognize only the authenticated Mac Access client adapter; remove legacy bridge-name and direct-process fallback. | -| `src/renderer/evaos/evaosNativeAgentAvailability.ts` | Maps native-companion state into agent/MCP availability. | Workbench redacted availability mapper | A5 | Only exact redacted Mac Access `ready` state may be usable or attach MCP; Off, repair, and reconfirmation states remain blocked. | -| `src/renderer/pages/guid/hooks/useGuidSend.ts` | Attaches native Mac-control MCP during guided sends. | Workbench guarded MCP attachment | A5 | Attach only from the exact redacted Mac Access ready state; no legacy readiness or connector-start fallback. | -| `src/renderer/pages/guid/GuidPage.tsx` | Directly presents native availability in the guided workflow. | Workbench redacted status presentation | A5 | Consume the shared redacted availability result only; never infer transport, credential, TCC, or lifecycle authority. | -| `src/renderer/pages/guid/components/AgentPillBar.tsx` | Directly presents selected-agent native availability. | Workbench redacted status presentation | A5 | Display status only; do not attach MCP or trigger connector lifecycle outside the guarded send path. | -| `src/renderer/pages/settings/AgentSettings/AgentCard.tsx` | Directly presents agent native availability. | Workbench redacted status presentation | A5 | Display status only; blocked/repair/reconfirm states cannot be promoted to usable. | -| `scripts/build-mcp-servers.js` | Packages the direct Mac-control MCP server adapter. | Workbench client-adapter packaging | A5 | Package only the Mac Access authenticated-client adapter; remove direct bridge binary/process invocation. | -| `scripts/build-with-builder.js` | Orchestrates Workbench resource preparation and product packaging. | Workbench product build only | A6 | Remove Mac Access/authoritative bridge preparation from Workbench builds and prove path/product isolation. | -| `scripts/prepareEvaosDesktopBridgeResource.js` | Prepares and manifests the Workbench-owned Python bridge/helper runtime. | connector-core migration/build tooling | A6 | Retire from Workbench packaging after verified source migration; it must not fetch, copy, or manifest Mac Access authority. | -| `packages/desktop/electron-builder.yml` | Embeds bridge resources into the Workbench artifact. | Workbench packaging manifest | A6 | Remove authoritative connector/helper/runtime resources after A5; retain no second Mac Access payload. | -| `scripts/afterPack.js` | Verifies packaged Workbench bridge provenance and resource shape. | Workbench non-embedding verifier | A6 | Invert the gate after cutover: fail if an authoritative Mac connector/helper/private runtime remains in Workbench. | -| `scripts/afterSign.js` | Signs/verifies the Workbench bridge/helper payload. | Workbench signing isolation verifier | A6 | Stop signing Mac connector authority in Workbench and fail on Mac Access identities/resources/appcast inputs. | - -Production imports, broker enrollment/grant methods, IPC declarations, MCP packaging, builder resources, after-pack/sign verification, and release-workflow inputs must be machine-inventoried again at the exact A5/A6 base. Any newly discovered owner or consumer receives a singular row before implementation; absence from this inspected head is not permission to leave an untracked owner. +| Current Workbench file | Current responsibility | Target owner | Phase | Required change and exit check | +| ------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------- | ---------------------------------------------- | ----- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `src/common/evaos/bridgeTypes.ts` | Native status/action DTOs, including current control-mode shapes. | Workbench generated Mac Access client DTOs | A5 | Add v1 status/actions including `off`; delete raw connector URL/token/path fields from client-visible types. | +| `src/common/evaos/nativeCompanionBoundary.ts` | Static shell/native trust matrix and canaries. | Workbench trust/presentation contract | A5 | Point native owner to Mac Access helper; canaries describe client status, not Python CLI authority. | +| `src/process/evaosNativeCompanionBoundary.ts` | Main-process local-action boundary enforcement. | Workbench fail-closed client boundary | A5 | Permit only fixed authenticated-client actions; no generic local CUA or lifecycle escape hatch. | +| `src/index.ts` | Main-process startup/teardown registration. | Workbench Mac Access client bootstrap | A5 | Register XPC client bridge; stop only Workbench client/session state, never an unknown Mac Access helper. | +| `src/process/bridge/evaosNativeCompanionBridge.ts` | Renderer-to-main status/diagnostic/action IPC. | Workbench renderer-safe Mac Access bridge | A5 | Call the authenticated client, preserve `BridgeResponse`, and return redacted status/audit IDs only. | +| `src/process/bridge/evaosRendererSecretGuard.ts` | Renderer secret-shape rejection. | Workbench renderer secret guard | A5 | Extend forbidden fields to Mac Access channel, binding, peer, Keychain, signature, and transport details. | +| `src/process/bridge/index.ts` | IPC bridge registration. | Workbench bridge registry | A5 | Register only the Mac Access client bridge once. | +| `src/process/resources/builtinMcp/evaosMacControlServer.ts` | Spawns bridge CLI and exposes Mac-control tools. | Workbench-to-Mac Access client adapter | A5 | Remove bridge binary/PATH resolution and direct process spawn; route allowed requests through helper client or selected broker path. | +| `src/process/services/evaosNativeCompanionStatus.ts` | Status, process spawn, local HTTP readiness, tailnet discovery, grants, enrollment, actions, TCC repair. | Workbench `MacAccessClientService` | A5 | Retain presentation/orchestration only; delete connector spawn/stop, URL/token, host discovery, Tailscale bootstrap, grant ownership, direct bridge CLI, and TCC authority. | +| `src/process/services/evaosBrokerSession.ts` | Completes connector enrollment and creates grants from connector URL/token/tailnet inputs. | Broker-selected Mac Access client adapter | A5 | Remove Workbench connector registration/grant authority and raw endpoint/token inputs; request only broker-side atomic prepare/commit/reverse-handoff operations. | +| `src/common/adapter/ipcBridge.ts` | Declares renderer/main native-companion IPC surface. | Workbench generated renderer-safe client IPC | A5 | Replace legacy action/status shapes with the redacted Mac Access client DTOs; no generic or secret-bearing fields. | +| `src/process/utils/runBackendMigrations.ts` | Executes backend migrations including native-companion seams. | Workbench coexistence migration coordinator | A5 | Run idempotent make-before-break client cutover marker; never migrate raw credentials in renderer/Node storage. | +| `src/renderer/evaos/useEvaosNativeCompanionStatus.ts` | Polls/requests status and actions. | Workbench renderer hook | A5 | Render redacted v1 status; no connector lifecycle assumptions. | +| `src/renderer/evaos/nativeCompanionViewModel.ts` | Converts native status into customer UI state. | Workbench presentation model | A5 | Map `Off`, `Ask Every Time`, `Full Access`, paused/revoked/blocked, and reconfirmation truthfully. | +| `src/renderer/pages/native-companion/index.tsx` | Mac & iPhone/native companion page. | Workbench Mac Access client UI | A5 | Present status/handoff controls; Mac Access app owns pairing/TCC/access confirmation. iPhone Mirroring is outside this sprint. | +| `src/renderer/pages/guid/hooks/useGuidAgentSelection.ts` | Consumes native-companion readiness during guided agent selection. | Workbench redacted readiness consumer | A5 | Consume client status only; no fallback connector start or readiness inference from legacy bridge state. | +| `src/renderer/pages/settings/AgentSettings/LocalAgents.tsx` | Consumes native-companion status in Local Agents settings. | Workbench redacted status consumer | A5 | Render Mac Access state/handoff only; no connector lifecycle, token, or TCC authority. | +| `src/process/resources/builtinMcp/constants.ts` | Names and recognizes the packaged Mac-control MCP server. | Workbench Mac Access MCP recognizer | A5 | Recognize only the authenticated Mac Access client adapter; remove legacy bridge-name and direct-process fallback. | +| `src/renderer/evaos/evaosNativeAgentAvailability.ts` | Maps native-companion state into agent/MCP availability. | Workbench redacted availability mapper | A5 | Only exact redacted Mac Access `ready` state may be usable or attach MCP; Off, repair, and reconfirmation states remain blocked. | +| `src/renderer/pages/guid/hooks/useGuidSend.ts` | Attaches native Mac-control MCP during guided sends. | Workbench guarded MCP attachment | A5 | Attach only from the exact redacted Mac Access ready state; no legacy readiness or connector-start fallback. | +| `src/renderer/pages/guid/GuidPage.tsx` | Directly presents native availability in the guided workflow. | Workbench redacted status presentation | A5 | Consume the shared redacted availability result only; never infer transport, credential, TCC, or lifecycle authority. | +| `src/renderer/pages/guid/components/AgentPillBar.tsx` | Directly presents selected-agent native availability. | Workbench redacted status presentation | A5 | Display status only; do not attach MCP or trigger connector lifecycle outside the guarded send path. | +| `src/renderer/pages/settings/AgentSettings/AgentCard.tsx` | Directly presents agent native availability. | Workbench redacted status presentation | A5 | Display status only; blocked/repair/reconfirm states cannot be promoted to usable. | +| `scripts/build-mcp-servers.js` | Packages the direct Mac-control MCP server adapter. | Workbench client-adapter packaging | A5 | Package only the Mac Access authenticated-client adapter; remove direct bridge binary/process invocation. | +| `scripts/build-with-builder.js` | Orchestrates Workbench resource preparation and product packaging. | Workbench build consuming connector-core | A1 | Resolve the canonical package and invoke deterministic preparation; A5 later removes authoritative runtime embedding. | +| `scripts/prepareEvaosDesktopBridgeResource.js` | Prepares and manifests the Workbench-owned Python bridge/helper runtime. | connector-core deterministic packaging adapter | A1 | Consume `packages/mac-connector-core` as the only source, emit the exact core digest, and fail if the generated Workbench resource can drift. | +| `resources/evaos-beta/bridge/SOURCE.json` | Records archived import provenance for the Workbench-vendored bridge source. | connector-core versioned provenance manifest | A1 | Migrate required history into the canonical core manifest; generated Workbench resources reference that manifest and never restore the archived repo as a dependency. | +| `scripts/create-mock-release-artifacts.sh` | Builds committed-source Workbench bridge release fixtures with source/provenance manifests. | Workbench core-parity fixture builder | A1 | Consume the canonical connector-core manifest and generated resource only; never archive the legacy resource as source truth or build independent Mac Access release assets. | +| `scripts/evaosBetaReleaseGate.js` | Runs Workbench bridge source/app identity, enrollment, and packaged CLI release gates. | Workbench gate consuming connector-core proof | A1 | Verify the canonical core digest and preserve regression canaries; A5 later limits this gate to client compatibility and non-embedding. | +| `packages/desktop/electron-builder.yml` | Embeds bridge resources into the Workbench artifact. | Workbench packaging consuming connector-core | A1 | Package only generated core resources with the manifest digest; A5 removes the authoritative runtime after coexistence cutover. | +| `scripts/afterPack.js` | Verifies packaged Workbench bridge provenance and resource shape. | Workbench core-digest verifier | A1 | Prove the packaged resource equals the canonical core manifest; A5 later inverts this gate to reject an authoritative embedded connector. | +| `scripts/afterSign.js` | Signs/verifies the Workbench bridge/helper payload. | Workbench generated-core signing verifier | A1 | Preserve Workbench signing for the generated core during migration; A5 removes connector authority. It never signs or mutates independent Mac Access artifacts. | +| `.github/workflows/evaos-beta-rc-canary.yml` | Runs Workbench beta candidate bridge/enrollment proof. | Workbench core-parity canary | A1 | Prove the generated package uses the canonical core digest; A5 later converts to client/non-embedding proof. Never gate or publish Mac Access. | +| `.github/workflows/evaos-live-canary-proof.yml` | Runs Workbench live packaged bridge proof. | Workbench core-parity canary | A1 | Preserve packaged-core regressions against the canonical digest; A5 later proves client-only behavior. Never substitute for Mac Access #705/#706 proof. | +| `.github/workflows/_build-reusable.yml` | Builds/tests Workbench and provisions build-time Python on packaging runners. | Workbench generated-core build consumer | A1 | Connector proof must execute the generated packaged runtime, not runner/system Python; the setup runtime may perform build tooling only. Never build Mac Access release. | +| `.github/workflows/workbench-functional-smoke.yml` | Builds an unpacked Workbench app and exercises its pinned embedded bridge runtime. | Workbench generated-core functional smoke | A1 | Bind the generated resource to the exact canonical core digest and run bridge imports through its packaged Python; A5 converts this to authenticated-client smoke proof. | +| `.github/workflows/pr-checks.yml` | Exercises source-commit-bound Workbench release fixtures and gates. | Workbench core-parity CI | A1 | Pin fixtures to the exact candidate/core source commit; broad CI remains Workbench evidence and never proves Mac Access artifacts or live control. | +| `.github/workflows/release-distribute.yml` | Passes Workbench RC artifact proof into the Workbench distribution gate. | Workbench release proof consumer | A1 | Preserve exact artifact/provenance verification only for Workbench; do not publish, gate, or mutate independent Mac Access artifacts. | +| `tests/unit/evaos/evaosDesktopBridgeSafety.test.ts` | Proves Workbench bridge race, filesystem-mode, and audit-redaction behavior. | connector-core parity regression input | A1 | Re-run against the canonical extracted core, then A5 retain only client/non-embedding safety tests. It is not native helper or signed-artifact proof. | +| `tests/unit/evaos/evaosBrokerLiveCanary.test.ts` | Binds Workbench live-canary proof to exact source/run and selected-binding evidence. | Workbench core-parity canary test | A1 | Preserve source/binding negatives until A5 client conversion; never substitute the Workbench canary for #702 transport or #706 VM-to-Mac CUA proof. | +| `tests/unit/bootstrap/afterPackBundledResources.test.ts` | Proves packaged Workbench bridge/helper/runtime inventory and provenance checks. | Workbench generated-core package test | A1 | Build fixtures from the canonical manifest/generated resource and require the packaged embedded runtime; never accept legacy source truth or system-Python substitution. | +| `tests/unit/common-adapter/evaosIpcBridge.dom.test.ts` | Proves renderer/main action deadlines, fail-closed IPC, and URL blocking. | Workbench Mac Access client IPC test | A5 | Preserve only fixed renderer-safe client calls and fail-closed deadlines; no connector endpoint, credential, or generic host operation may cross IPC. | +| `tests/unit/evaos/NativeCompanionPage.dom.test.tsx` | Proves Workbench Mac-control UI selection, stop/kill wiring, and redacted support states. | Workbench Mac Access client UI test | A5 | Rebind to redacted Mac Access client DTOs and preserve human-selected target, visible stop/kill, and no-terminal-repair behavior; exclude iPhone Mirroring scope. | +| `tests/unit/evaos/evaosBrokerSession.test.ts` | Proves Workbench broker enrollment, grant, cancellation, and customer-binding behavior. | Workbench broker-selected Mac Access test | A5 | Replace connector URL/token/Tailscale enrollment with atomic selected-binding client flows; preserve wrong-customer, no-store, cancel, expiry, and renderer-secret negatives. | +| `tests/unit/evaos/evaosNativeCompanionStatus.test.ts` | Proves Workbench bridge discovery, status, enrollment, TCC, stop/kill, and redacted diagnostics. | Workbench Mac Access client status test | A5 | Consume redacted helper status only; remove legacy listener/PATH/Tailscale/TCC authority while preserving fail-closed readiness, generation, kill-switch, and secret checks. | +| `tests/unit/evaos/evaosNativeCompanionStatusHook.dom.test.tsx` | Proves customer-scoped renderer polling, grant handoff, and diagnostic filtering. | Workbench redacted Mac Access hook test | A5 | Preserve selected-customer isolation and allowlisted diagnostics over client DTOs; never retain grant or readiness state across customer switches. | +| `tests/unit/process/prepareEvaosDesktopBridgeResource.test.ts` | Proves current Workbench source selection, generated resource, and manifest behavior. | connector-core generation regression test | A1 | Consume canonical package source plus generated Workbench resource; fail if legacy resource source truth, system Python, or digest drift remains. | +| `tests/unit/process/evaosBetaReleaseGate.test.ts` | Proves Workbench candidate identity, packaged CLI, binding, and release-gate failures. | Workbench core-parity release test | A1 | Preserve exact candidate/race/redaction/release negatives against the generated embedded runtime; never treat them as Mac Access #705 release proof. | +| `src/renderer/evaos/__fixtures__/goldenWorkbenchParityManifest.ts` | Defines Workbench renderer parity expectations for native status. | Workbench client contract fixture | A5 | Replace legacy connector readiness/identity fields with redacted Mac Access client status; fixture truth cannot grant authority. | + +Before A1 edits, machine-inventory every production import, broker enrollment/grant method, IPC declaration, MCP/build/package consumer, test, canary, after-pack/sign check, and release-workflow input at the exact merged/superseding #697 head. Each must consume the canonical package, generated resource, or packaged embedded runtime—never system Python or `resources/evaos-beta/bridge` as source truth—and receive a singular owner row. Repeat that inventory at the exact A5/A6 base. Absence from this inspected head is not permission to leave an untracked owner. ## State migration diff --git a/docs/evaos/mac-access/threat-model.md b/docs/evaos/mac-access/threat-model.md index 34caf77188..baae797244 100644 --- a/docs/evaos/mac-access/threat-model.md +++ b/docs/evaos/mac-access/threat-model.md @@ -64,35 +64,36 @@ Data crossing each boundary is allowlisted, length-bounded, versioned, and fail ## Threat table -| Threat | Attack or failure | Required prevention/detection | Fail-closed response | Proof gate | -| ------------------------------ | ------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- | -| Replay | Reuse context ID, session/channel generation, command ID, nonce, sequence, approval, or result receipt. | Complete RFC 8785 signed command payload; bounded replay cache persisted across reconnect/helper restart until maximum expiry; single-use approval. | Deny before prompt/audit decision; record `command_replayed`; never extend expiry. | Submit the same valid envelope twice and across reconnect/restart. Second and later attempts fail. | -| Stale binding | Use old binding ID/version after selection changes. | Compare signed context, outer tuple, Keychain enrollment, and current policy epoch exactly. | Invalidate queue/approvals; close old channel; effective `Off` until new binding is confirmed. | Old and new bindings interleaved; only current binding is accepted. | -| Wrong customer | Cross-tenant command or mismatched customer/VM. | Server-owned context plus local tuple equality before policy. | Deny and raise a high-severity redacted audit event; no prompt. | Mutate outer and signed customer/VM independently and together. All fail without matching enrollment. | -| Wrong Mac/device | Command targets another connector device or key. | Device, installation, and connector-key identity bound to enrollment and command authorization. | Deny; rotate/disable channel if broker claims disagree. | Structurally valid wrong-device fixture fails at runtime. | -| Duplicate process | Old Workbench connector and Mac Access both run, or two helpers race. | Atomic local leader lease plus one broker compare-and-swap that swaps the legacy and prepared grant/lease; signed identity/liveness checks. | Both clients deny work during ambiguity and never unlink/kill an unknown owner. | Launch/race/upgrade/reverse-handoff tests show one listener/channel/grant/audit writer. | -| Local untrusted client | Same-user malware steals token or connects to socket/XPC. | XPC audit token, `SecCode`, team/anchor, identifier, hardened runtime, designated requirement, per-connection nonce/epoch. | Reject connection before decoding mutation; no status details beyond generic denial. | Unsigned, ad-hoc, wrong-team, renderer, shell child, stale PID, and stolen-token tests. | -| Stolen pairing code | Attacker claims a copied public code. | High-entropy one-time code, short expiry, rate limit, atomic claim, installation nonce, local customer/device confirmation before state transition. | Claim expires/locks; pairing remains Off; rotate code. | Reuse, concurrent claim, expired code, wrong installation, wrong customer, and dismissed confirmation. | -| Revoked grant | Broker or local revoke occurs while commands are queued/in flight. | Local revocation tombstone and policy epoch checked before prompt, before actuation, and at safe cancellation boundaries. | Block new work synchronously; clear queue/approvals; close channel; erase active credential. | Race revoke against queued, prompted, executing, reconnecting, and offline commands. | -| Offline broker | Existing channel drops but queued authority remains. | Monotonic authority deadline; disconnect barrier; no offline extension. | New remote actuation denied. Local status, stop, revoke, kill, and audit remain available. | Disconnect before/after prompt and execution; no command starts after barrier. | -| Helper replacement | Attacker or stale updater swaps helper binary/service. | Signed embedding, designated requirement pin, code validity, expected path plus identity, versioned Keychain credential group, update handoff protocol. | Refuse leadership/Keychain/channel; effective Off; show repair path. | Replace, ad-hoc sign, wrong team, moved path, old signed version, and partial update tests. | -| Downgrade | Old but validly signed app reads new state or reacquires credentials. | Broker rejects missing/below-floor immutable build identity; critical floor rotation creates a new credential/access-group epoch and revokes the old credential; rollback names exact target. | Refuse transport/Keychain leadership and remain Off; preserve state for compatible recovery. | Install old signed builds below/above the floor and interrupt every credential rotation/update/rollback transition. | -| Crash recovery | Full Access or stale approval silently resumes. | Runtime-instance-bound Full Access confirmation; ephemeral approval store; policy epoch increment on recovery. | Effective Ask Every Time or Off; queue and approvals cleared. | Kill -9 menu/helper/Python at every transition and relaunch. | -| Request tampering | Change command body after approval/signature. | Canonical request digest in command authorization and local approval; recompute immediately before execution. | Deny without executing; invalidate approval. | Mutate every approved field, target snapshot/path, capability, and encoding. | -| Malicious/compromised renderer | Invoke generic native IPC or obtain secrets. | Main-process allowlist and redaction; authenticated helper accepts only signed main processes and fixed actions. | Deny; renderer gets generic code and audit ID only. | XSS/DevTools-like calls cannot reach raw XPC, CUA, Keychain, channel, or logs. | -| Python compromise | Planner process reads secrets or calls CUA directly. | Sandboxed/minimal environment; no secret FDs; native ports; normalized bounded messages; helper revalidates output. | Terminate Python; effective Off for command path; helper and local controls remain available. | Python emits unknown action, oversized payload, forged result, path traversal, or direct framework attempt. | -| Secret/log leakage | Token, typed text, screenshot, AX tree, path, env, or address enters audit/support logs. | Redaction schema denylist plus allowlisted evidence; bounded errors; secret scan. | Reject audit event/receipt; actuation denied if decision cannot be recorded safely. | Negative fixture and recursive scans over logs, crashes, diagnostics, issue packets. | -| Audit deletion/tamper | User malware truncates or edits local audit. | Owner-only files, append-only API, contract-required monotonic sequence/previous-record/record digests, optional broker receipt correlation. | Mark chain broken, effective Off, preserve evidence, require repair. | Edit/delete/reorder records and verify detection before next action. | -| Audit unavailable | Disk full, permissions wrong, corruption, I/O failure. | Pre-execution durable decision write and health check. | Effective Off; no actuation; minimal local blocker if possible. | Fault injection for open/write/fsync/rename/disk-full failures. | -| Kill-switch race | Command starts while local kill is activating. | In-memory atomic deny barrier first; policy epoch rotation; checks before prompt and actuation; safe cancellation. | No new action after barrier; in-flight action stops at safe boundary; channel cleanup best effort. | High-rate command stream while kill switch is toggled. | -| Approval confusion | Prompt label differs from executable target or is reused. | Approval binds selected tuple, capability, normalized target, request digest, policy epoch, and expiry. | Dismiss/change/timeout denies; changed target requires new prompt. | TOCTOU mutation between preview, prompt, and execution. | -| Broker key rotation | Connector accepts an old/unknown signing key or bricks on valid rotation. | Signed keyset with activation/retirement windows; key ID pinning; rollback plan. | Unknown/retired key denies; never fall back unsigned. | Overlap, early/late rotation, rollback, missing keyset, and clock skew. | -| Clock manipulation | Wall clock extends expiry or breaks ordering. | Signed absolute time plus monotonic elapsed deadline established on receipt; bounded clock skew. | Suspicious jump denies current channel and reconnects. | Move wall clock forward/back during queued/prompted commands. | -| Oversized/flooded input | Memory/CPU/disk exhaustion via WebSocket or XPC. | Frame limits, rate limits, bounded queue, backpressure, timeouts, bounded audit text. | Drop/reject and eventually close abusive channel; local kill remains responsive. | Oversized, fragmented, high-rate, and slow-loris scenarios. | -| Direct-network bypass | Runtime discovers a Mac URL/IP/token or public listener. | No target listener; outbound relay only; firewall/listener checks; no renderer/broker payload contains endpoint. | Pairing/transport blocked if an unauthorized listener is detected. | `lsof`/packet evidence on pristine and coexistence Macs; prompt/output secret scans. | -| Update split-brain | New menu with old helper or vice versa accepts incompatible authority. | Compatibility handshake, signed build identity, atomic handoff, access Off during update. | Refuse channel and mutations; rollback to exact signed compatible pair. | Interrupt update at each file/service transition. | -| Orphan cleanup abuse | Installer kills/deletes unrelated same-name process/file. | Match recorded path plus signed identity and installation ID; never trust name/PID alone. | Leave unknown process untouched and show support blocker. | Decoy binaries/processes and relocated prior versions. | -| Workbench fallback takeover | Workbench silently starts its legacy connector while Mac Access is leader. | Exclusive ownership status and #704 client cutover; legacy start guarded by proven absence/relinquish. | Workbench shows Mac Access status or blocker; no fallback start. | Start both apps in every order, crash either, upgrade/downgrade each. | +| Threat | Attack or failure | Required prevention/detection | Fail-closed response | Proof gate | +| ------------------------------ | ------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------- | +| Replay | Reuse context ID, session/channel generation, command ID, nonce, sequence, approval, or result receipt. | Complete RFC 8785 signed command payload; bounded replay cache persisted across reconnect/helper restart until maximum expiry; single-use approval. | Deny before prompt/audit decision; record `command_replayed`; never extend expiry. | Submit the same valid envelope twice and across reconnect/restart. Second and later attempts fail. | +| Stale binding | Use old binding ID/version after selection changes. | Compare signed context, outer tuple, Keychain enrollment, and current policy epoch exactly. | Invalidate queue/approvals; close old channel; effective `Off` until new binding is confirmed. | Old and new bindings interleaved; only current binding is accepted. | +| Wrong customer | Cross-tenant command or mismatched customer/VM. | Server-owned context plus local tuple equality before policy. | Deny and raise a high-severity redacted audit event; no prompt. | Mutate outer and signed customer/VM independently and together. All fail without matching enrollment. | +| Wrong Mac/device | Command targets another connector device or key. | Device, installation, and connector-key identity bound to enrollment and command authorization. | Deny; rotate/disable channel if broker claims disagree. | Structurally valid wrong-device fixture fails at runtime. | +| Duplicate process | Old Workbench connector and Mac Access both run, or two helpers race. | Atomic local leader lease plus one broker compare-and-swap that swaps the legacy and prepared grant/lease; signed identity/liveness checks. | Both clients deny work during ambiguity and never unlink/kill an unknown owner. | Launch/race/upgrade/reverse-handoff tests show one listener/channel/grant/audit writer. | +| Local untrusted client | Same-user malware steals token or connects to socket/XPC. | XPC audit token, `SecCode`, team/anchor, identifier, hardened runtime, designated requirement, per-connection nonce/epoch. | Reject connection before decoding mutation; no status details beyond generic denial. | Unsigned, ad-hoc, wrong-team, renderer, shell child, stale PID, and stolen-token tests. | +| Host interface escape | Renderer, Electron, shell child, or stale embedded process invokes/replays a core operation. | Helper-created inherited private channel; fixed host schema; host-session ID; monotonic sequence; expected policy epoch; no HTTP/public socket/generic command. | Reject before state or native-port access; rotate host session; effective Off on channel ambiguity. | Unknown operation/field, wrong session, duplicate/reordered sequence, stale epoch, Electron import, and orphan tests. | +| Stolen pairing code | Attacker claims a copied public code. | High-entropy one-time code, short expiry, rate limit, atomic claim, installation nonce, local customer/device confirmation before state transition. | Claim expires/locks; pairing remains Off; rotate code. | Reuse, concurrent claim, expired code, wrong installation, wrong customer, and dismissed confirmation. | +| Revoked grant | Broker or local revoke occurs while commands are queued/in flight. | Local revocation tombstone and policy epoch checked before prompt, before actuation, and at safe cancellation boundaries. | Block new work synchronously; clear queue/approvals; close channel; erase active credential. | Race revoke against queued, prompted, executing, reconnecting, and offline commands. | +| Offline broker | Existing channel drops but queued authority remains. | Monotonic authority deadline; disconnect barrier; no offline extension. | New remote actuation denied. Local status, stop, revoke, kill, and audit remain available. | Disconnect before/after prompt and execution; no command starts after barrier. | +| Helper replacement | Attacker or stale updater swaps helper binary/service. | Signed embedding, designated requirement pin, code validity, expected path plus identity, versioned Keychain credential group, update handoff protocol. | Refuse leadership/Keychain/channel; effective Off; show repair path. | Replace, ad-hoc sign, wrong team, moved path, old signed version, and partial update tests. | +| Downgrade | Old but validly signed app reads new state or reacquires credentials. | Broker rejects missing/below-floor immutable build identity; critical floor rotation creates a new credential/access-group epoch and revokes the old credential; rollback names exact target. | Refuse transport/Keychain leadership and remain Off; preserve state for compatible recovery. | Install old signed builds below/above the floor and interrupt every credential rotation/update/rollback transition. | +| Crash recovery | Full Access or stale approval silently resumes. | Runtime-instance-bound Full Access confirmation; ephemeral approval store; policy epoch increment on recovery. | Effective Ask Every Time or Off; queue and approvals cleared. | Kill -9 menu/helper/Python at every transition and relaunch. | +| Request tampering | Change command body after approval/signature. | Canonical request digest in command authorization and local approval; recompute immediately before execution. | Deny without executing; invalidate approval. | Mutate every approved field, target snapshot/path, capability, and encoding. | +| Malicious/compromised renderer | Invoke generic native IPC or obtain secrets. | Main-process allowlist and redaction; authenticated helper accepts only signed main processes and fixed actions. | Deny; renderer gets generic code and audit ID only. | XSS/DevTools-like calls cannot reach raw XPC, CUA, Keychain, channel, or logs. | +| Python compromise | Planner process reads secrets or calls CUA directly. | Sandboxed/minimal environment; no secret FDs; native ports; normalized bounded messages; helper revalidates output. | Terminate Python; effective Off for command path; helper and local controls remain available. | Python emits unknown action, oversized payload, forged result, path traversal, or direct framework attempt. | +| Secret/log leakage | Token, typed text, screenshot, AX tree, path, env, or address enters audit/support logs. | Redaction schema denylist plus allowlisted evidence; bounded errors; secret scan. | Reject audit event/receipt; actuation denied if decision cannot be recorded safely. | Negative fixture and recursive scans over logs, crashes, diagnostics, issue packets. | +| Audit deletion/tamper | User malware truncates or edits local audit. | Owner-only files, append-only API, contract-required monotonic sequence/previous-record/record digests, optional broker receipt correlation. | Mark chain broken, effective Off, preserve evidence, require repair. | Edit/delete/reorder records and verify detection before next action. | +| Audit unavailable | Disk full, permissions wrong, corruption, I/O failure. | Pre-execution durable decision write and health check. | Effective Off; no actuation; minimal local blocker if possible. | Fault injection for open/write/fsync/rename/disk-full failures. | +| Kill-switch race | Command starts while local kill is activating. | In-memory atomic deny barrier first; policy epoch rotation; checks before prompt and actuation; safe cancellation. | No new action after barrier; in-flight action stops at safe boundary; channel cleanup best effort. | High-rate command stream while kill switch is toggled. | +| Approval confusion | Prompt label differs from executable target or is reused. | Approval binds selected tuple, capability, normalized target, request digest, policy epoch, and expiry. | Dismiss/change/timeout denies; changed target requires new prompt. | TOCTOU mutation between preview, prompt, and execution. | +| Broker key rotation | Connector accepts an old/unknown signing key or bricks on valid rotation. | Signed keyset with activation/retirement windows; key ID pinning; rollback plan. | Unknown/retired key denies; never fall back unsigned. | Overlap, early/late rotation, rollback, missing keyset, and clock skew. | +| Clock manipulation | Wall clock extends expiry or breaks ordering. | Signed absolute time plus monotonic elapsed deadline established on receipt; bounded clock skew. | Suspicious jump denies current channel and reconnects. | Move wall clock forward/back during queued/prompted commands. | +| Oversized/flooded input | Memory/CPU/disk exhaustion via WebSocket or XPC. | Frame limits, rate limits, bounded queue, backpressure, timeouts, bounded audit text. | Drop/reject and eventually close abusive channel; local kill remains responsive. | Oversized, fragmented, high-rate, and slow-loris scenarios. | +| Direct-network bypass | Runtime discovers a Mac URL/IP/token or public listener. | No target listener; outbound relay only; firewall/listener checks; no renderer/broker payload contains endpoint. | Pairing/transport blocked if an unauthorized listener is detected. | `lsof`/packet evidence on pristine and coexistence Macs; prompt/output secret scans. | +| Update split-brain | New menu with old helper or vice versa accepts incompatible authority. | Compatibility handshake, signed build identity, atomic handoff, access Off during update. | Refuse channel and mutations; rollback to exact signed compatible pair. | Interrupt update at each file/service transition. | +| Orphan cleanup abuse | Installer kills/deletes unrelated same-name process/file. | Match recorded path plus signed identity and installation ID; never trust name/PID alone. | Leave unknown process untouched and show support blocker. | Decoy binaries/processes and relocated prior versions. | +| Workbench fallback takeover | Workbench silently starts its legacy connector while Mac Access is leader. | Exclusive ownership status and #704 client cutover; legacy start guarded by proven absence/relinquish. | Workbench shows Mac Access status or blocker; no fallback start. | Start both apps in every order, crash either, upgrade/downgrade each. | ## Pairing analysis diff --git a/packages/mac-connector-core/README.md b/packages/mac-connector-core/README.md index 654b75871b..f72ccbda00 100644 --- a/packages/mac-connector-core/README.md +++ b/packages/mac-connector-core/README.md @@ -2,6 +2,6 @@ This is the single reusable connector source boundary defined by [evaOS-GUI#699](https://github.com/100yenadmin/evaOS-GUI/issues/699). -PR #699 owns only the versioned contracts and cross-language JSON fixtures under `contracts/v1`. Moving the PR #697 Python source, adding native Swift ports, and integrating consumers belong to downstream child issues after their dependency gates are satisfied. +PR #699 owns only the versioned contracts and cross-language JSON fixtures under `contracts/v1`, including the private non-Electron core host API. Moving the PR #697 Python source into the bounded `adapters`, `contracts`, `host`, `persistence`, and `policy` subpackages, adding native Swift ports, and integrating consumers belong to downstream child issues after their dependency gates are satisfied. The archived `electricsheephq/evaos-desktop-bridge` repository is provenance only and must not be restored as source, build, or runtime truth. diff --git a/packages/mac-connector-core/contracts/v1/fixtures/host/host-request.json b/packages/mac-connector-core/contracts/v1/fixtures/host/host-request.json new file mode 100644 index 0000000000..f6c1296e91 --- /dev/null +++ b/packages/mac-connector-core/contracts/v1/fixtures/host/host-request.json @@ -0,0 +1,9 @@ +{ + "schema_version": "evaos.mac_connector_core.host_request.v1", + "request_id": "host-request-01", + "host_session_id": "host-session-01", + "sequence": 1, + "operation": "set_access_mode", + "expected_policy_epoch": 7, + "target_mode": "ask_every_time" +} diff --git a/packages/mac-connector-core/contracts/v1/fixtures/host/host-response.json b/packages/mac-connector-core/contracts/v1/fixtures/host/host-response.json new file mode 100644 index 0000000000..98515a6776 --- /dev/null +++ b/packages/mac-connector-core/contracts/v1/fixtures/host/host-response.json @@ -0,0 +1,16 @@ +{ + "schema_version": "evaos.mac_connector_core.host_response.v1", + "request_id": "host-request-01", + "host_session_id": "host-session-01", + "sequence": 1, + "operation": "set_access_mode", + "ok": true, + "policy_epoch": 8, + "result": { + "kind": "lifecycle", + "effective_mode": "ask_every_time", + "pairing_state": "paired", + "transport_state": "connected" + }, + "error": null +} diff --git a/packages/mac-connector-core/contracts/v1/fixtures/invalid/identity.json b/packages/mac-connector-core/contracts/v1/fixtures/invalid/identity.json index 9a58dcae06..4caa012c81 100644 --- a/packages/mac-connector-core/contracts/v1/fixtures/invalid/identity.json +++ b/packages/mac-connector-core/contracts/v1/fixtures/invalid/identity.json @@ -1,4 +1,130 @@ [ + { + "id": "unknown-core-host-operation", + "threat": "host_interface_escape", + "contract": "core_host_request", + "base_fixture": "../host/host-request.json", + "mutations": [{ "operation": "set", "pointer": "/operation", "value": "run_arbitrary_command" }], + "expected_stage": "schema", + "expected_error": "host_operation_not_allowlisted", + "required_runtime_rejection": null + }, + { + "id": "unsafe-core-host-sequence", + "threat": "host_replay", + "contract": "core_host_request", + "base_fixture": "../host/host-request.json", + "mutations": [{ "operation": "set", "pointer": "/sequence", "value": 9007199254740992 }], + "expected_stage": "schema", + "expected_error": "host_counter_not_safe_integer", + "required_runtime_rejection": null + }, + { + "id": "unsafe-core-host-policy-epoch", + "threat": "host_replay", + "contract": "core_host_response", + "base_fixture": "../host/host-response.json", + "mutations": [{ "operation": "set", "pointer": "/policy_epoch", "value": 9007199254740992 }], + "expected_stage": "schema", + "expected_error": "host_policy_epoch_not_safe_integer", + "required_runtime_rejection": null + }, + { + "id": "unsafe-broker-envelope-sequence", + "threat": "host_replay", + "contract": "broker_control", + "base_fixture": "../valid/broker-control.json", + "mutations": [{ "operation": "set", "pointer": "/sequence", "value": 9007199254740992 }], + "expected_stage": "schema", + "expected_error": "broker_sequence_not_safe_integer", + "required_runtime_rejection": null + }, + { + "id": "unsafe-command-authority-sequence", + "threat": "host_replay", + "contract": "broker_control", + "base_fixture": "../valid/broker-control.json", + "mutations": [ + { + "operation": "set", + "pointer": "/authorization/payload/sequence", + "value": 9007199254740992 + } + ], + "expected_stage": "schema", + "expected_error": "authority_sequence_not_safe_integer", + "required_runtime_rejection": null + }, + { + "id": "unsafe-status-policy-epoch", + "threat": "host_replay", + "contract": "local_status", + "base_fixture": "../valid/local-status.json", + "mutations": [{ "operation": "set", "pointer": "/access/policy_epoch", "value": 9007199254740992 }], + "expected_stage": "schema", + "expected_error": "status_policy_epoch_not_safe_integer", + "required_runtime_rejection": null + }, + { + "id": "unsafe-audit-sequence", + "threat": "host_replay", + "contract": "audit_event", + "base_fixture": "../valid/audit-event.json", + "mutations": [{ "operation": "set", "pointer": "/sequence", "value": 9007199254740992 }], + "expected_stage": "schema", + "expected_error": "audit_sequence_not_safe_integer", + "required_runtime_rejection": null + }, + { + "id": "failed-core-host-response-without-error", + "threat": "host_interface_escape", + "contract": "core_host_response", + "base_fixture": "../host/host-response.json", + "mutations": [ + { "operation": "set", "pointer": "/ok", "value": false }, + { "operation": "set", "pointer": "/result", "value": null } + ], + "expected_stage": "schema", + "expected_error": "host_response_outcome_ambiguous", + "required_runtime_rejection": null + }, + { + "id": "failed-core-host-response-with-result", + "threat": "host_interface_escape", + "contract": "core_host_response", + "base_fixture": "../host/host-response.json", + "mutations": [ + { "operation": "set", "pointer": "/ok", "value": false }, + { + "operation": "set", + "pointer": "/error", + "value": { "code": "host_terminal_error", "audit_id": null } + } + ], + "expected_stage": "schema", + "expected_error": "host_response_outcome_ambiguous", + "required_runtime_rejection": null + }, + { + "id": "stale-core-host-session", + "threat": "host_session_confusion", + "contract": "core_host_request", + "base_fixture": "../host/host-request.json", + "mutations": [{ "operation": "set", "pointer": "/host_session_id", "value": "host-session-stale" }], + "expected_stage": "runtime", + "expected_error": "host_session_mismatch", + "required_runtime_rejection": "Reject before state or native-port access when the request host session does not equal the helper-created current runtime session." + }, + { + "id": "replayed-core-host-sequence", + "threat": "host_replay", + "contract": "core_host_request", + "base_fixture": "../host/host-request.json", + "mutations": [], + "expected_stage": "runtime", + "expected_error": "host_sequence_replayed", + "required_runtime_rejection": "Persist the last accepted sequence for the host-session lifetime and reject duplicate or lower sequence values before dispatch." + }, { "id": "untrusted-local-client", "threat": "local_untrusted_client", diff --git a/packages/mac-connector-core/contracts/v1/fixtures/valid/local-status.json b/packages/mac-connector-core/contracts/v1/fixtures/valid/local-status.json index 6a1ed4b9e9..b05bc278a4 100644 --- a/packages/mac-connector-core/contracts/v1/fixtures/valid/local-status.json +++ b/packages/mac-connector-core/contracts/v1/fixtures/valid/local-status.json @@ -10,7 +10,7 @@ "helper_designated_requirement_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "build": { "build_version": "0.1.0-architecture", - "source_commit": "ae9f35062f8a4b212fab4576d44602b57dc0303a", + "source_commit": "f197d2e51828f9f6a50e8448a09d5b064d14b498", "signed_lineage_id": "mac-access-production", "security_epoch": 1, "schema_reader_version": 1, @@ -29,7 +29,7 @@ }, "relay_authorization": { "accepted_build_version": "0.1.0-architecture", - "accepted_source_commit": "ae9f35062f8a4b212fab4576d44602b57dc0303a", + "accepted_source_commit": "f197d2e51828f9f6a50e8448a09d5b064d14b498", "accepted_security_epoch": 1, "credential_security_epoch": 1, "rollback_authorization": { @@ -48,7 +48,7 @@ }, "target": { "build_version": "0.1.0-architecture", - "source_commit": "ae9f35062f8a4b212fab4576d44602b57dc0303a", + "source_commit": "f197d2e51828f9f6a50e8448a09d5b064d14b498", "signed_lineage_id": "mac-access-production", "security_epoch": 1, "credential_security_epoch": 1 @@ -58,9 +58,9 @@ "issued_at": "2026-07-15T07:55:00Z", "expires_at": "2026-07-15T08:05:00Z" }, - "payload_sha256": "af25d8ea75ce353e322e31bb855501af2c375e7388e603933b84d2d875c412a1", + "payload_sha256": "41799f46249c2e7979cecb751665ac9f107886704dff8ea9da85708620ed64bc", "broker_key_id": "rollback-key-2026-01", - "signature_base64url": "GDifM36m6ciwLkEqKU5o4DlDFkkUwoYPsdU4-JXSIwemKrbP64MTZGVOQ-GSfbAz62SmfeLvQUo8BpJpI6coAQ" + "signature_base64url": "_zjknB7xwtvOhE2VtprsVZXf3ty2R0eefst8u8TFkq9vhXcIw4oCLHvMsPgD5ySuQ_VKW-odAvX8OdFCtnvSBg" } }, "access": { diff --git a/packages/mac-connector-core/contracts/v1/golden/rollback-authorization-golden.json b/packages/mac-connector-core/contracts/v1/golden/rollback-authorization-golden.json index bfcd53bc19..31b19ab02a 100644 --- a/packages/mac-connector-core/contracts/v1/golden/rollback-authorization-golden.json +++ b/packages/mac-connector-core/contracts/v1/golden/rollback-authorization-golden.json @@ -14,7 +14,7 @@ }, "target": { "build_version": "0.1.0-architecture", - "source_commit": "ae9f35062f8a4b212fab4576d44602b57dc0303a", + "source_commit": "f197d2e51828f9f6a50e8448a09d5b064d14b498", "signed_lineage_id": "mac-access-production", "security_epoch": 1, "credential_security_epoch": 1 @@ -24,9 +24,9 @@ "issued_at": "2026-07-15T07:55:00Z", "expires_at": "2026-07-15T08:05:00Z" }, - "canonical_payload_utf8": "{\"authorization_id\":\"rollback-01\",\"domain\":\"evaos.mac-access/rollback-authorization/v1\",\"expires_at\":\"2026-07-15T08:05:00Z\",\"issued_at\":\"2026-07-15T07:55:00Z\",\"resulting_minimum_reader_security_epoch\":1,\"resulting_minimum_writer_security_epoch\":1,\"schema_version\":\"evaos.mac_access.rollback_authorization_payload.v1\",\"source\":{\"build_version\":\"0.2.0\",\"credential_security_epoch\":2,\"security_epoch\":2,\"signed_lineage_id\":\"mac-access-production\",\"source_commit\":\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\"},\"target\":{\"build_version\":\"0.1.0-architecture\",\"credential_security_epoch\":1,\"security_epoch\":1,\"signed_lineage_id\":\"mac-access-production\",\"source_commit\":\"ae9f35062f8a4b212fab4576d44602b57dc0303a\"}}", - "payload_sha256": "af25d8ea75ce353e322e31bb855501af2c375e7388e603933b84d2d875c412a1", + "canonical_payload_utf8": "{\"authorization_id\":\"rollback-01\",\"domain\":\"evaos.mac-access/rollback-authorization/v1\",\"expires_at\":\"2026-07-15T08:05:00Z\",\"issued_at\":\"2026-07-15T07:55:00Z\",\"resulting_minimum_reader_security_epoch\":1,\"resulting_minimum_writer_security_epoch\":1,\"schema_version\":\"evaos.mac_access.rollback_authorization_payload.v1\",\"source\":{\"build_version\":\"0.2.0\",\"credential_security_epoch\":2,\"security_epoch\":2,\"signed_lineage_id\":\"mac-access-production\",\"source_commit\":\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\"},\"target\":{\"build_version\":\"0.1.0-architecture\",\"credential_security_epoch\":1,\"security_epoch\":1,\"signed_lineage_id\":\"mac-access-production\",\"source_commit\":\"f197d2e51828f9f6a50e8448a09d5b064d14b498\"}}", + "payload_sha256": "b38eeec01be61c5b73453bb9d2b0441225c664c672244de833b5ad9774fd47a3", "broker_key_id": "rollback-key-2026-01", - "signature_base64url": "GDifM36m6ciwLkEqKU5o4DlDFkkUwoYPsdU4-JXSIwemKrbP64MTZGVOQ-GSfbAz62SmfeLvQUo8BpJpI6coAQ", - "public_key_spki_base64url": "MCowBQYDK2VwAyEAHLkdfJpt68p2FYvrvSLtqZBhghT0w6gxkPpA9rYZfd0" + "signature_base64url": "ffwsZjguT9lqi4I6V2OILxoQXiR0mifRXiqso9j2e77tG34hSQNkjJHbfJxzMf56RVP2d2S9BwHGn1F1_SORBQ", + "public_key_spki_base64url": "MCowBQYDK2VwAyEA6XnZ8LYYflm0uBRwwhm-my0DETifwelQbjUoZHEpnDM" } diff --git a/packages/mac-connector-core/contracts/v1/index.ts b/packages/mac-connector-core/contracts/v1/index.ts index 9d450d1eaa..8fa4f2dfed 100644 --- a/packages/mac-connector-core/contracts/v1/index.ts +++ b/packages/mac-connector-core/contracts/v1/index.ts @@ -8,6 +8,8 @@ const base64Url = z .max(16_384) .regex(/^[A-Za-z0-9_-]+$/); const instant = z.string().datetime({ offset: true }); +const safePositiveCounter = z.number().int().min(1).max(Number.MAX_SAFE_INTEGER); +const safeNonnegativeCounter = z.number().int().min(0).max(Number.MAX_SAFE_INTEGER); export const MAC_ACCESS_IDENTITIES = { appBundleId: 'com.evaos.mac-access', @@ -56,7 +58,7 @@ export const buildIdentitySchema = z build_version: identifier, source_commit: z.string().regex(/^[a-f0-9]{40}$/), signed_lineage_id: identifier, - security_epoch: z.number().int().nonnegative(), + security_epoch: safeNonnegativeCounter, schema_reader_version: z.literal(1), schema_writer_version: z.literal(1), rollback_authorization_id: identifier.nullable(), @@ -67,7 +69,7 @@ export const keychainCustodySchema = z .object({ custodian_signing_identifier: z.literal(MAC_ACCESS_IDENTITIES.helperServiceId), access_group_suffix: z.string().regex(/^com\.evaos\.mac-access\.credentials\.epoch-[1-9][0-9]*$/), - credential_security_epoch: z.number().int().positive(), + credential_security_epoch: safePositiveCounter, service: z.literal(MAC_ACCESS_IDENTITIES.connectorCredentialService), accessibility: z.literal('kSecAttrAccessibleWhenUnlockedThisDeviceOnly'), synchronizable: z.literal(false), @@ -89,8 +91,8 @@ const rollbackBuildSchema = z build_version: identifier, source_commit: z.string().regex(/^[a-f0-9]{40}$/), signed_lineage_id: identifier, - security_epoch: z.number().int().nonnegative(), - credential_security_epoch: z.number().int().positive(), + security_epoch: safeNonnegativeCounter, + credential_security_epoch: safePositiveCounter, }) .strict(); @@ -101,8 +103,8 @@ export const rollbackAuthorizationPayloadSchema = z authorization_id: identifier, source: rollbackBuildSchema, target: rollbackBuildSchema, - resulting_minimum_reader_security_epoch: z.number().int().nonnegative(), - resulting_minimum_writer_security_epoch: z.number().int().nonnegative(), + resulting_minimum_reader_security_epoch: safeNonnegativeCounter, + resulting_minimum_writer_security_epoch: safeNonnegativeCounter, issued_at: instant, expires_at: instant, }) @@ -154,8 +156,8 @@ export const executionContextClaimsSchema = z customer_vm_id: identifier, binding_id: identifier, binding_version: identifier, - issued_at: z.number().int().nonnegative(), - expires_at: z.number().int().positive(), + issued_at: safeNonnegativeCounter, + expires_at: safePositiveCounter, context_id: base64Url, }) .strict() @@ -196,10 +198,10 @@ export const accessStateSchema = z .object({ schema_version: z.literal('evaos.mac_access.access_state.v1'), runtime_instance_id: identifier, - state_security_epoch: z.number().int().nonnegative(), - minimum_reader_security_epoch: z.number().int().nonnegative(), - minimum_writer_security_epoch: z.number().int().nonnegative(), - policy_epoch: z.number().int().nonnegative(), + state_security_epoch: safeNonnegativeCounter, + minimum_reader_security_epoch: safeNonnegativeCounter, + minimum_writer_security_epoch: safeNonnegativeCounter, + policy_epoch: safeNonnegativeCounter, pairing_state: z.enum(['unpaired', 'paired', 'revoked']), configured_mode: z.enum(['off', 'ask_every_time', 'full_access']), effective_mode: z.enum(['off', 'ask_every_time', 'full_access']), @@ -207,7 +209,7 @@ export const accessStateSchema = z kill_switch: z.boolean(), local_confirmation_required: z.boolean(), confirmed_runtime_instance_id: identifier.nullable(), - confirmed_policy_epoch: z.number().int().nonnegative().nullable(), + confirmed_policy_epoch: safeNonnegativeCounter.nullable(), confirmed_binding_fingerprint_sha256: sha256.nullable(), binding: selectedBindingSchema.nullable(), changed_at: instant, @@ -338,8 +340,8 @@ export const localStatusSchema = z .object({ accepted_build_version: identifier, accepted_source_commit: z.string().regex(/^[a-f0-9]{40}$/), - accepted_security_epoch: z.number().int().positive(), - credential_security_epoch: z.number().int().positive(), + accepted_security_epoch: safePositiveCounter, + credential_security_epoch: safePositiveCounter, rollback_authorization: signedRollbackAuthorizationSchema.nullable(), }) .strict(), @@ -656,7 +658,7 @@ export const localActionRequestSchema = z request_id: identifier, action: localActionName, client_nonce: base64Url, - expected_policy_epoch: z.number().int().nonnegative().nullable(), + expected_policy_epoch: safeNonnegativeCounter.nullable(), target_mode: z.enum(['off', 'ask_every_time', 'full_access']).nullable(), }) .strict() @@ -703,7 +705,7 @@ export const commandAuthorityPayloadSchema = z command_id: identifier, issued_at: instant, expires_at: instant, - sequence: z.number().int().positive(), + sequence: safePositiveCounter, nonce: base64Url, binding: selectedBindingSchema, execution_context_sha256: sha256, @@ -733,7 +735,7 @@ export const brokerControlEnvelopeSchema = z command_id: identifier, issued_at: instant, expires_at: instant, - sequence: z.number().int().positive(), + sequence: safePositiveCounter, nonce: base64Url, binding: selectedBindingSchema, execution_context: z @@ -827,6 +829,104 @@ export const brokerControlEnvelopeSchema = z } }); +export const CORE_HOST_OPERATIONS = [ + 'status', + 'pair', + 'unpair', + 'connect', + 'disconnect', + 'set_access_mode', + 'dispatch_action', + 'audit_summary', + 'pause', + 'resume', + 'stop', + 'revoke', + 'activate_kill_switch', + 'shutdown', +] as const; + +export const coreHostOperationSchema = z.enum(CORE_HOST_OPERATIONS); + +const coreHostRequestIdentity = { + schema_version: z.literal('evaos.mac_connector_core.host_request.v1'), + request_id: identifier, + host_session_id: identifier, + sequence: safePositiveCounter, +}; + +const coreHostLifecycleRequest = ( + operation: 'unpair' | 'disconnect' | 'pause' | 'resume' | 'stop' | 'revoke' | 'activate_kill_switch' | 'shutdown' +) => + z + .object({ + ...coreHostRequestIdentity, + operation: z.literal(operation), + expected_policy_epoch: safeNonnegativeCounter, + reason_code: identifier, + }) + .strict(); + +export const coreHostRequestSchema = z.discriminatedUnion('operation', [ + z + .object({ + ...coreHostRequestIdentity, + operation: z.literal('status'), + expected_policy_epoch: z.null(), + }) + .strict(), + z + .object({ + ...coreHostRequestIdentity, + operation: z.literal('pair'), + expected_policy_epoch: safeNonnegativeCounter, + pairing_code: z.string().regex(/^[A-Z0-9]{6,12}$/), + local_installation_nonce: base64Url, + }) + .strict(), + coreHostLifecycleRequest('unpair'), + z + .object({ + ...coreHostRequestIdentity, + operation: z.literal('connect'), + expected_policy_epoch: safeNonnegativeCounter, + binding: selectedBindingSchema, + }) + .strict(), + coreHostLifecycleRequest('disconnect'), + z + .object({ + ...coreHostRequestIdentity, + operation: z.literal('set_access_mode'), + expected_policy_epoch: safeNonnegativeCounter, + target_mode: z.enum(['off', 'ask_every_time', 'full_access']), + }) + .strict(), + z + .object({ + ...coreHostRequestIdentity, + operation: z.literal('dispatch_action'), + expected_policy_epoch: safeNonnegativeCounter, + envelope: brokerControlEnvelopeSchema, + }) + .strict(), + z + .object({ + ...coreHostRequestIdentity, + operation: z.literal('audit_summary'), + expected_policy_epoch: safeNonnegativeCounter, + after_sequence: safeNonnegativeCounter.nullable(), + limit: z.number().int().min(1).max(100), + }) + .strict(), + coreHostLifecycleRequest('pause'), + coreHostLifecycleRequest('resume'), + coreHostLifecycleRequest('stop'), + coreHostLifecycleRequest('revoke'), + coreHostLifecycleRequest('activate_kill_switch'), + coreHostLifecycleRequest('shutdown'), +]); + const safeEvidenceIdentifier = identifier.refine( (value) => !/(?:authorization|bearer|cookie|password|secret|token|eyJ[A-Za-z0-9_-]{8})/i.test(value), 'audit evidence identifier resembles secret-bearing content' @@ -853,7 +953,7 @@ export const auditRecordPayloadSchema = z .object({ schema_version: z.literal('evaos.mac_access.audit_event.v1'), audit_id: identifier, - sequence: z.number().int().positive(), + sequence: safePositiveCounter, previous_record_sha256: sha256.nullable(), occurred_at: instant, event_type: z.enum([ @@ -919,6 +1019,90 @@ export const auditChainGoldenSchema = z }) .strict(); +const coreHostResultSchema = z.discriminatedUnion('kind', [ + z.object({ kind: z.literal('status'), status: localStatusSchema }).strict(), + z + .object({ + kind: z.literal('pairing'), + pairing_state: z.enum(['unpaired', 'paired', 'revoked']), + device_id: identifier.nullable(), + binding_fingerprint_sha256: sha256.nullable(), + }) + .strict(), + z + .object({ + kind: z.literal('action'), + command_id: identifier, + outcome: z.enum(['denied', 'executed', 'failed', 'stopped']), + audit_id: identifier, + }) + .strict(), + z + .object({ + kind: z.literal('audit_summary'), + events: z.array(auditEventSchema).max(100), + next_sequence: safePositiveCounter.nullable(), + }) + .strict(), + z + .object({ + kind: z.literal('lifecycle'), + effective_mode: z.enum(['off', 'ask_every_time', 'full_access']), + pairing_state: z.enum(['unpaired', 'paired', 'revoked']), + transport_state: z.enum(['disconnected', 'connecting', 'connected', 'revoked', 'blocked']), + }) + .strict(), +]); + +export const coreHostResponseSchema = z + .object({ + schema_version: z.literal('evaos.mac_connector_core.host_response.v1'), + request_id: identifier, + host_session_id: identifier, + sequence: safePositiveCounter, + operation: coreHostOperationSchema, + ok: z.boolean(), + policy_epoch: safeNonnegativeCounter, + result: coreHostResultSchema.nullable(), + error: z + .object({ + code: identifier, + audit_id: identifier.nullable(), + }) + .strict() + .nullable(), + }) + .strict() + .superRefine((response, context) => { + const successful = response.ok && response.result !== null && response.error === null; + const failed = !response.ok && response.result === null && response.error !== null; + if (!successful && !failed) { + context.addIssue({ + code: z.ZodIssueCode.custom, + message: 'host response must contain exactly one successful result or terminal error', + path: ['result'], + }); + } + if (!response.ok || response.result === null) return; + const expectedKind = + response.operation === 'status' + ? 'status' + : ['pair', 'unpair'].includes(response.operation) + ? 'pairing' + : response.operation === 'dispatch_action' + ? 'action' + : response.operation === 'audit_summary' + ? 'audit_summary' + : 'lifecycle'; + if (response.result.kind !== expectedKind) { + context.addIssue({ + code: z.ZodIssueCode.custom, + message: 'host response result kind must match the requested operation', + path: ['result', 'kind'], + }); + } + }); + export const negativeFixtureCaseSchema = z .object({ id: identifier, @@ -930,6 +1114,8 @@ export const negativeFixtureCaseSchema = z 'authenticated_local_action', 'broker_control', 'audit_event', + 'core_host_request', + 'core_host_response', ]), base_fixture: z.string().min(1), mutations: z.array( @@ -971,4 +1157,6 @@ export type LocalActionRequest = z.infer; export type AuthenticatedLocalAction = z.infer; export type BrokerControlEnvelope = z.infer; export type AuditEvent = z.infer; +export type CoreHostRequest = z.infer; +export type CoreHostResponse = z.infer; export type NegativeFixtureCase = z.infer; diff --git a/tests/contract/macAccessContracts.test.ts b/tests/contract/macAccessContracts.test.ts index 22f1a7d9fc..ca45fab0c5 100644 --- a/tests/contract/macAccessContracts.test.ts +++ b/tests/contract/macAccessContracts.test.ts @@ -5,11 +5,14 @@ import { fileURLToPath } from 'node:url'; import { describe, expect, it } from 'vitest'; import { + CORE_HOST_OPERATIONS, accessStateSchema, accessTransitionSchema, auditEventSchema, authenticatedLocalActionSchema, brokerControlEnvelopeSchema, + coreHostRequestSchema, + coreHostResponseSchema, localStatusSchema, negativeFixtureCaseSchema, } from '../../packages/mac-connector-core/contracts/v1'; @@ -28,6 +31,8 @@ const schemas = { authenticated_local_action: authenticatedLocalActionSchema, broker_control: brokerControlEnvelopeSchema, audit_event: auditEventSchema, + core_host_request: coreHostRequestSchema, + core_host_response: coreHostResponseSchema, } as const; function readJson(filePath: string): unknown { @@ -52,19 +57,25 @@ function applyMutation( if (!target || typeof target !== 'object' || parts.length === 0) { throw new Error(`Cannot apply fixture mutation ${mutation.pointer}`); } - let current = target as Record; + let current: unknown = target; for (const part of parts.slice(0, -1)) { - const next = current[part]; - if (!next || typeof next !== 'object' || Array.isArray(next)) { + const next = Array.isArray(current) + ? current[Number.parseInt(part, 10)] + : (current as Record)[part]; + if (!next || typeof next !== 'object') { throw new Error(`Fixture mutation parent does not exist: ${mutation.pointer}`); } - current = next as Record; + current = next; } const key = parts.at(-1)!; - if (mutation.operation === 'remove') { - delete current[key]; + if (Array.isArray(current)) { + const index = Number.parseInt(key, 10); + if (mutation.operation === 'remove') current.splice(index, 1); + else current[index] = mutation.value; } else { - current[key] = mutation.value; + const record = current as Record; + if (mutation.operation === 'remove') delete record[key]; + else record[key] = mutation.value; } } @@ -110,6 +121,14 @@ const expectedIssuePathByError: Record = { command_authority_too_long: 'expires_at', channel_generation_mismatch: 'authorization/payload', authority_outlives_execution_context: 'expires_at', + host_operation_not_allowlisted: 'operation', + host_counter_not_safe_integer: 'sequence', + host_policy_epoch_not_safe_integer: 'policy_epoch', + broker_sequence_not_safe_integer: 'sequence', + authority_sequence_not_safe_integer: 'authorization/payload/sequence', + status_policy_epoch_not_safe_integer: 'access/policy_epoch', + audit_sequence_not_safe_integer: 'sequence', + host_response_outcome_ambiguous: 'result', }; describe('evaOS Mac Access v1 contracts', () => { @@ -122,12 +141,153 @@ describe('evaOS Mac Access v1 contracts', () => { ['authenticated_local_action', 'local-action.json'], ['broker_control', 'broker-control.json'], ['audit_event', 'audit-event.json'], + ['core_host_request', '../host/host-request.json'], + ['core_host_response', '../host/host-response.json'], ] as const; it.each(validFixtures)('accepts the valid %s fixture', (contract, fileName) => { expect(schemas[contract].safeParse(readJson(path.join(validRoot, fileName))).success).toBe(true); }); + it('freezes the complete non-Electron connector-core host operation surface', () => { + expect(CORE_HOST_OPERATIONS).toEqual([ + 'status', + 'pair', + 'unpair', + 'connect', + 'disconnect', + 'set_access_mode', + 'dispatch_action', + 'audit_summary', + 'pause', + 'resume', + 'stop', + 'revoke', + 'activate_kill_switch', + 'shutdown', + ]); + + const envelope = brokerControlEnvelopeSchema.parse(readJson(path.join(validRoot, 'broker-control.json'))); + const identity = { + schema_version: 'evaos.mac_connector_core.host_request.v1', + host_session_id: 'host-session-01', + expected_policy_epoch: 7, + }; + const reason = { reason_code: 'local_user_request' }; + const requests = [ + { ...identity, request_id: 'host-status', operation: 'status', expected_policy_epoch: null, sequence: 1 }, + { + ...identity, + request_id: 'host-pair', + operation: 'pair', + sequence: 2, + pairing_code: 'ABC123', + local_installation_nonce: 'bm9uY2U', + }, + { ...identity, ...reason, request_id: 'host-unpair', operation: 'unpair', sequence: 3 }, + { ...identity, request_id: 'host-connect', operation: 'connect', sequence: 4, binding: envelope.binding }, + { ...identity, ...reason, request_id: 'host-disconnect', operation: 'disconnect', sequence: 5 }, + { + ...identity, + request_id: 'host-mode', + operation: 'set_access_mode', + sequence: 6, + target_mode: 'ask_every_time', + }, + { ...identity, request_id: 'host-dispatch', operation: 'dispatch_action', sequence: 7, envelope }, + { + ...identity, + request_id: 'host-audit', + operation: 'audit_summary', + sequence: 8, + after_sequence: null, + limit: 25, + }, + { ...identity, ...reason, request_id: 'host-pause', operation: 'pause', sequence: 9 }, + { ...identity, ...reason, request_id: 'host-resume', operation: 'resume', sequence: 10 }, + { ...identity, ...reason, request_id: 'host-stop', operation: 'stop', sequence: 11 }, + { ...identity, ...reason, request_id: 'host-revoke', operation: 'revoke', sequence: 12 }, + { ...identity, ...reason, request_id: 'host-kill', operation: 'activate_kill_switch', sequence: 13 }, + { ...identity, ...reason, request_id: 'host-shutdown', operation: 'shutdown', sequence: 14 }, + ]; + + for (const request of requests) expect(coreHostRequestSchema.safeParse(request).success).toBe(true); + }); + + it('rejects unsafe integers in every nested security counter exposed by the host DTOs', () => { + const unsafeInteger = Number.MAX_SAFE_INTEGER + 1; + const envelope = brokerControlEnvelopeSchema.parse(readJson(path.join(validRoot, 'broker-control.json'))); + const dispatchRequest = { + schema_version: 'evaos.mac_connector_core.host_request.v1', + request_id: 'host-dispatch-unsafe', + host_session_id: 'host-session-01', + sequence: 1, + operation: 'dispatch_action', + expected_policy_epoch: 7, + envelope: cloneJson(envelope), + }; + applyMutation(dispatchRequest, { + operation: 'set', + pointer: '/envelope/sequence', + value: unsafeInteger, + }); + const dispatchResult = coreHostRequestSchema.safeParse(dispatchRequest); + expect(dispatchResult.success).toBe(false); + if (!dispatchResult.success) { + expect(dispatchResult.error.issues.some((issue) => issue.path.join('/') === 'envelope/sequence')).toBe(true); + } + + const statusResponse = { + schema_version: 'evaos.mac_connector_core.host_response.v1', + request_id: 'host-status-unsafe', + host_session_id: 'host-session-01', + sequence: 2, + operation: 'status', + ok: true, + policy_epoch: 7, + result: { kind: 'status', status: cloneJson(readJson(path.join(validRoot, 'local-status.json'))) }, + error: null, + }; + applyMutation(statusResponse, { + operation: 'set', + pointer: '/result/status/access/policy_epoch', + value: unsafeInteger, + }); + const statusResult = coreHostResponseSchema.safeParse(statusResponse); + expect(statusResult.success).toBe(false); + if (!statusResult.success) { + expect( + statusResult.error.issues.some((issue) => issue.path.join('/') === 'result/status/access/policy_epoch') + ).toBe(true); + } + + const auditResponse = { + schema_version: 'evaos.mac_connector_core.host_response.v1', + request_id: 'host-audit-unsafe', + host_session_id: 'host-session-01', + sequence: 3, + operation: 'audit_summary', + ok: true, + policy_epoch: 7, + result: { + kind: 'audit_summary', + events: [cloneJson(readJson(path.join(validRoot, 'audit-event.json')))], + next_sequence: null, + }, + error: null, + }; + applyMutation(auditResponse, { + operation: 'set', + pointer: '/result/events/0/sequence', + value: unsafeInteger, + }); + const auditResult = coreHostResponseSchema.safeParse(auditResponse); + expect(auditResult.success).toBe(false); + if (!auditResult.success) { + expect(auditResult.error.issues.some((issue) => issue.path.join('/') === 'result/events/0/sequence')).toBe(true); + } + }); + it('keeps every required adversarial threat in the versioned negative fixture set', () => { const requiredThreats = new Set([ 'replay', @@ -145,6 +305,7 @@ describe('evaOS Mac Access v1 contracts', () => { 'request_tampering', 'log_secret_exposure', 'audit_unavailable', + 'host_interface_escape', ]); const seen = new Set(); From 053f55a3a6e876f2f624cc6d8a38f2cb7d1cffec Mon Sep 17 00:00:00 2001 From: Eva Date: Wed, 15 Jul 2026 17:31:46 +0700 Subject: [PATCH 03/14] docs(mac-access): harden canonical architecture contracts --- ...1-independent-product-shared-repository.md | 2 +- docs/evaos/mac-access/architecture.md | 75 +++--- docs/evaos/mac-access/migration-map.md | 232 ++++++++++-------- docs/evaos/mac-access/threat-model.md | 73 +++--- packages/mac-connector-core/README.md | 2 +- .../v1/fixtures/invalid/identity.json | 80 ++++++ .../contracts/v1/fixtures/invalid/policy.json | 94 +++++++ .../v1/fixtures/valid/access-state.json | 3 + .../access-transition-grant-expired.json | 65 +++++ .../valid/access-transition-stop.json | 6 + .../v1/fixtures/valid/access-transition.json | 5 + .../v1/fixtures/valid/broker-control.json | 8 +- .../valid/command-authority-golden.json | 10 +- .../v1/fixtures/valid/full-access-state.json | 3 + .../v1/fixtures/valid/local-action.json | 3 +- .../v1/fixtures/valid/local-status.json | 43 +++- .../golden/rollback-authorization-golden.json | 22 +- .../mac-connector-core/contracts/v1/index.ts | 149 ++++++++++- .../tests/FixtureSmoke.swift | 33 +-- .../mac-connector-core/tests/fixture_smoke.py | 8 +- tests/contract/macAccessContracts.test.ts | 9 + tests/contract/macAccessCryptography.test.ts | 74 +++++- 22 files changed, 775 insertions(+), 224 deletions(-) create mode 100644 packages/mac-connector-core/contracts/v1/fixtures/valid/access-transition-grant-expired.json diff --git a/docs/evaos/mac-access/adr-0001-independent-product-shared-repository.md b/docs/evaos/mac-access/adr-0001-independent-product-shared-repository.md index 0421be1552..224ab2a9fa 100644 --- a/docs/evaos/mac-access/adr-0001-independent-product-shared-repository.md +++ b/docs/evaos/mac-access/adr-0001-independent-product-shared-repository.md @@ -3,7 +3,7 @@ - Status: proposed for #699; blocks implementation issues - Date: 2026-07-15 - Decision owners: evaOS Mac Access epic #698 and architecture issue #699 -- Inspected source: evaOS-GUI PR #697 head `f197d2e51828f9f6a50e8448a09d5b064d14b498` +- Inspected source: PR #697 head `fff813ef1da6b766ae09344b20021b4a4b0672c4`, merge `0ac9742cc8c42d777da627adb9cf4179567d1373`; PR #708 head `5b1308fadc481f83116c54de2b9713ab2363bed2`, merge `5c86e8e91660772da5b1b6f49b43f2de3afee737`; PR #709 head `f92d45f984db29c132e65f458df85567f04186ca`, merge `27b28cd234d537a491028e9024070cf8d33b9611` ## Context diff --git a/docs/evaos/mac-access/architecture.md b/docs/evaos/mac-access/architecture.md index d23c41823e..ccbd947b83 100644 --- a/docs/evaos/mac-access/architecture.md +++ b/docs/evaos/mac-access/architecture.md @@ -2,8 +2,8 @@ Issue: [#699](https://github.com/100yenadmin/evaOS-GUI/issues/699) Parent: [#698](https://github.com/100yenadmin/evaOS-GUI/issues/698) -Contract version: `2026-07-15.v1` -Inspected Workbench source: PR [#697](https://github.com/100yenadmin/evaOS-GUI/pull/697) head `f197d2e51828f9f6a50e8448a09d5b064d14b498` +Contract version: `2026-07-15.v2` +Inspected Workbench source: PR #697 final head `fff813ef1da6b766ae09344b20021b4a4b0672c4`, merge `0ac9742cc8c42d777da627adb9cf4179567d1373`; PR #708 final head `5b1308fadc481f83116c54de2b9713ab2363bed2`, merge `5c86e8e91660772da5b1b6f49b43f2de3afee737`; PR [#709](https://github.com/100yenadmin/evaOS-GUI/pull/709) final head `f92d45f984db29c132e65f458df85567f04186ca`, merge `27b28cd234d537a491028e9024070cf8d33b9611` ## Decision @@ -13,7 +13,9 @@ The Mac opens an outbound authenticated WebSocket to the evaOS broker relay. It Backend support for that outbound Mac relay does not exist at the inspected ws-proxy head. It is tracked in [electricsheephq/evaos-ws-proxy#73](https://github.com/electricsheephq/evaos-ws-proxy/issues/73). Implementation must remain fail closed until that contract exists; the current browser-to-VM proxy path is not a substitute. -The inspected #697 head adds Workbench candidate-source/app attestation, Tailscale enrollment diagnostics, connector URL/token readiness, generation-linearized stop/kill/start behavior, content-hashed audit evidence, packaged CLI canaries, and stricter beta/live release gates. Those are migration and parity inputs only. They reinforce the target ban on Workbench-owned networking, lifecycle, credentials, TCC/CUA, and release authority; they do not become Mac Access runtime dependencies or relax any v1 contract. +Public one-use code issue and redemption is tracked in [dashboard #669](https://github.com/electricsheephq/electric-sheep-website-dashboard-6158a244/issues/669). An authenticated broker handoff may be an optional convenience, but it cannot replace or bypass that code path. + +Merged #697 provides the owned bridge source and its generation-linearized stop/kill/start, local-consent, stale-session, pristine-runtime, audit, and release-gate behavior. Merged #708 adds the signed runtime-receipt route, `receipt_canary.py`, native Ed25519 verifier, Workbench proof consumers, and bundled OpenClaw/Hermes verifier consumers. #709 makes explicit staging signer configuration fail closed before LaunchAgent mutation. These are migration and parity inputs only. They reinforce the target ban on Workbench-owned networking, lifecycle, credentials, TCC/CUA, and release authority; they do not become Mac Access runtime dependencies or substitute for #73's outbound relay. This document is executable design evidence. It is not a working-app, signed-artifact, notarization, pristine-Mac, VM-to-Mac, customer-readiness, publication, or rollout claim. @@ -58,7 +60,7 @@ Target: one customer-facing app, one persistent connector leader, one TCC author 1. **Delete** public/private inbound connector URLs, tailnet discovery, token export, and Workbench-owned lifecycle from the target path. 2. **Simplify** local authority to one signed helper and a small versioned IPC contract. -3. **Accelerate** implementation with shared pure contracts and the PR #697 Python behavior as an embedded implementation detail. +3. **Accelerate** implementation with shared pure contracts and the canonical Workbench Python behavior through #709 as an embedded implementation detail. 4. **Automate** schema fixtures, negative cases, code-sign checks, orphan cleanup, rollback checks, and exact-head release evidence. ### Proof needed @@ -116,11 +118,23 @@ PR #699 introduces only the versioned contract source/fixtures under `packages/m | Embedded helper/XPC service | `com.evaos.mac-access.helper` | Sole native policy/TCC/CUA authority. Must share the app's signed designated requirement and release lineage. | | Per-user connector service | `com.evaos.mac-access.connector` | Stable SMAppService/LaunchAgent identity if persistence needs a login item. It starts or brokers the same helper; it is never a second authority. | -The certificate chain, helper embedding rule, and concrete designated requirements become signed-build inputs, not caller-supplied wire fields. Local IPC derives and verifies the caller from the connection audit token. A request cannot assert its own identity. +The approved Developer ID Team ID is frozen as `TC6MS3T6NN`. The expected release requirements are: + +```text +app: anchor apple generic and certificate leaf[subject.OU] = "TC6MS3T6NN" and identifier "com.evaos.mac-access" +helper: anchor apple generic and certificate leaf[subject.OU] = "TC6MS3T6NN" and identifier "com.evaos.mac-access.helper" +connector: anchor apple generic and certificate leaf[subject.OU] = "TC6MS3T6NN" and identifier "com.evaos.mac-access.connector" +``` + +The contract SHA-256 is over the exact UTF-8 requirement text above: app `da635352f249b4213aa1a96c41d7979d8b25d86b056b9f0929c1b414e35896fb`, helper `222107bb855cfc463805777c76ca8cfdac0d1145957c5f190c234e52bfd277aa`, connector `0c3de778270de5b4a1992d0e13d4f27e41929c7ace94ae143bcba92a555be422`. Authenticated Workbench client allowlists use the same anchor/Team ID with identifier `com.evaos.workbench` (digest `ff4fc126bb70bbf7fcc3cc0957377d67185124b5e31b19760357333a8a0ae329`) or the shipped legacy identifier `com.electricsheephq.EvaDesktop` (digest `c6038eaf8a20c83a1aabfd1bf8eb4053877b7af5627e570eb1de37721e76b776`). The native verifier selects the frozen requirement for the expected role, obtains the connection process from the audit token, evaluates that `SecCode` against the requirement, and records the digest of the frozen requirement used. Wire callers cannot supply a trusted role or digest. + +The artifact relationship is frozen before implementation: the app main executable is `Contents/MacOS/evaOS Mac Access`; the persistent connector is a nested signed login item at `Contents/Library/LoginItems/evaOS Mac Access Connector.app`; and the CUA helper is a nested signed XPC service at `Contents/XPCServices/evaOS Mac Access Helper.xpc`. The helper is launched only by the signed app/connector and accepts only frozen app or Workbench client requirements. The app and connector receive no production credential access group. Only the helper receives `TC6MS3T6NN.com.evaos.mac-access.credentials.epoch-N`; no target receives `get-task-allow`, JIT, unsigned-executable-memory, disable-library-validation, inbound network-server, or Apple-events automation entitlements. If App Sandbox is enabled, outbound `com.apple.security.network.client` belongs only to the connector. Development entitlements and access groups are disjoint. + +The main executable inside `/Applications/evaOS Mac Access.app` is the intended TCC responsible executable shown to the user. Only the embedded helper invokes Accessibility and Screen Recording APIs. #705 proves—not chooses—the actual `codesign -dr -` output, nested-code placement, entitlements, responsible identity, helper relationship, and TCC attribution on the installed artifact. Any mismatch stops live proof and returns to #699 before an identity or relationship changes. Local IPC derives and verifies the caller from the connection audit token. A request cannot assert its own identity. Keychain custody is frozen as follows: the production access-group base is `com.evaos.mac-access.credentials`, with an effective group `com.evaos.mac-access.credentials.epoch-N` for each security-critical credential epoch; development uses the disjoint `com.evaos.mac-access.development.credentials` base; the item service is `com.evaos.mac-access.connector-credential`; items are non-synchronizing `kSecAttrAccessibleWhenUnlockedThisDeviceOnly`; and only the production helper target for the accepted epoch receives that epoch's production access-group entitlement. The menu app, Workbench, Python, debug builds, and old/replaced helpers must be denied. Connector private keys are non-exportable `SecKey` material, using hardware protection where the selected algorithm and Mac support it. #705 must record the resolved Team-ID prefix and prove the built entitlements/ACLs; it may not broaden this custody contract. -The stable helper identity does not make an old validly signed binary safe. ws-proxy #73 registration and reconnect must reject a missing or below-floor immutable build/security identity before accepting the device credential. Raising a security-critical floor atomically issues a new credential/key into a new epoch access group, registers only the exact accepted build/source/security epoch, commits the new broker epoch, then revokes and deletes the prior credential. Old binaries lack the new group entitlement and their old broker credential is rejected. Exceptional rollback uses `evaos.mac_access.rollback_authorization_payload.v1`, signed over RFC 8785/JCS bytes, to name the authorization ID, exact source and target version/commit/lineage/security epoch, both credential epochs, resulting reader/writer floors, and issue/expiry interval. The relay and local status must equal those validated target claims. `golden/rollback-authorization-golden.json` freezes canonical bytes, digest, broker key, and Ed25519 signature. No opaque ID or local-only authorization can revive an old credential. +The stable helper identity does not make an old validly signed binary safe. ws-proxy #73 registration and reconnect must reject a missing or below-floor immutable build/security identity before accepting the device credential. Raising a security-critical floor atomically issues a new credential/key into a new epoch access group, registers only the exact accepted build/source/security epoch, commits the new broker epoch, then revokes and deletes the prior credential. Old binaries lack the new group entitlement and their old broker credential is rejected. Exceptional rollback uses `evaos.mac_access.rollback_authorization_payload.v1`, signed over RFC 8785/JCS bytes, to name the authorization ID, exact source and target version/commit/lineage/security epoch, schema reader/writer versions, both credential epochs, resulting reader/writer security and schema floors, and issue/expiry interval. The persisted verified pre-rollback build must equal the signed source, and relay/local status must equal the signed target and resulting floors. `golden/rollback-authorization-golden.json` freezes canonical bytes, digest, broker key, and Ed25519 signature. No opaque ID or local-only authorization can revive an old credential. Live proof must record `codesign --display --requirements`, `codesign --verify --strict --deep`, notarization/stapling, SMAppService registration, actual TCC attribution, helper replacement rejection, and upgrade continuity. If those checks expose a collision, the replacement identity must be recorded in #699 before any downstream TCC/live proof. No source document alone freezes macOS TCC behavior. @@ -199,8 +213,8 @@ Each command uses `evaos.mac_access.broker_control.v1` and carries: - the selected tuple; - the server-owned Ed25519 `evaos.mac_control_execution_context.v1` produced by ws-proxy #69; -- a separate `evaos.mac_access.command_authority_payload.v1` signed authorization that exhaustively includes session, channel generation, the full selected tuple, execution-context digest, command ID, capability, exact request digest, random nonce, sequence, and issue/expiry times; -- a maximum 60-second command authority fully contained inside the signed #69 execution-context interval; +- a separate `evaos.mac_access.command_authority_payload.v1` signed authorization that exhaustively includes session, channel generation, the full selected tuple and grant expiry, current policy epoch, execution-context digest, command ID, capability, exact request digest, random nonce, sequence, and issue/expiry times; +- a maximum 60-second command authority fully contained inside the signed #69 execution-context interval and ending before grant expiry; - no reusable connector URL/token. The command authorization signs the UTF-8 RFC 8785/JCS serialization of the payload object only, prefixed semantically by the fixed domain field `evaos.mac-access/command-authority/v1`. The authorization wrapper, its digest, key ID, and signature are not part of the signed payload, avoiding self-reference. Base64url is unpadded. SHA-256 covers the exact canonical bytes, and Ed25519 verifies those same bytes. `command-authority-golden.json` freezes canonical bytes, digest, public key, and a valid signature so TypeScript, Swift, Python, and ws-proxy #73 can prove byte-for-byte interoperability and one-bit mutation rejection. @@ -213,12 +227,13 @@ The helper validates in this order before any prompt or Python call: 4. RFC 8785 command-authority canonical bytes, digest, signature, and equality of every signed field with the delivered envelope; 5. command interval containment inside the #69 context and a monotonic receipt deadline; 6. context ID, session ID, channel generation, command ID, nonce, and sequence replay windows; -7. equality of every complete signed tuple field with the locally enrolled binding; -8. local revocation tombstone, policy epoch, pause, and kill switch; +7. equality of every complete signed tuple field and grant expiry with the locally enrolled binding; +8. exact current policy epoch, unexpired grant, local revocation tombstone, pause, and kill switch; 9. access-mode decision and exact-scope local approval; 10. durable redacted decision audit write; 11. normalized CUA execution through the helper; -12. durable redacted result audit write and signed/attested result receipt. + +Grant expiry is an authority transition: rotate the policy epoch, invalidate pending work, request safe cancellation, clear the binding, tombstone the grant, close transport, and force effective Off. No resume may restore an expired grant. 12. durable redacted result audit write and signed/attested result receipt. Any failed step prevents all later steps. An audit-write failure is a denial, not a warning. @@ -226,7 +241,7 @@ Reconnect uses exponential backoff with jitter and a bounded ceiling. A new conn ## Python embedding boundary -PR #697's `evaos_desktop_bridge` source moves into `packages/mac-connector-core/python` only after #697 merges or is superseded. The Mac Access artifact includes a pinned private CPython runtime and wheels built for supported architectures. Runtime discovery never searches `/usr/bin/python3`, Homebrew, pyenv, PATH, or a customer virtual environment. +The canonical `evaos_desktop_bridge` source through #709 moves into `packages/mac-connector-core/python` in #700. The Mac Access artifact includes a pinned private CPython runtime and wheels built for supported architectures. Runtime discovery never searches `/usr/bin/python3`, Homebrew, pyenv, PATH, or a customer virtual environment. Python may own normalized capability planning, adapters, policy-table data, redaction helpers, and behavior/canary harnesses. It may not own: @@ -277,14 +292,14 @@ Cutover is prepare-before-atomic-commit: 1. detect a signed, compatible Mac Access helper; 2. authenticate Workbench main over XPC and read a fresh status with matching runtime identity/policy epoch; -3. broker prepares, but does not activate, the replacement binding and exact rollback record; -4. prove local audit writable, kill switch clear, and rollback metadata present; +3. broker prepares, but does not activate, the replacement binding and exact one-use commit record; +4. prove local audit writable, kill switch clear, and commit metadata present; 5. both local connectors enter deny-new-work and the broker performs one compare-and-swap that deactivates the legacy grant/lease and activates the prepared Mac Access binding/lease in the same commit; 6. Mac Access acknowledges the committed generation, then Workbench stops only the exact verified legacy connector; 7. remove Workbench access to connector tokens, URL/host discovery, grant creation, bridge process spawning, packaging/signing, and TCC execution; -8. retain a signed, time-bounded reverse-handoff authorization; after commit neither client accepts work during rollback ambiguity. +8. after commit, permit only client-integration disablement or signed compatible repair under the same `com.evaos.mac-access*` identity; never reactivate the Workbench connector. -On any ambiguous step, Workbench does not kill or replace an unknown connector. It shows a fail-closed blocker and preserves the previously verified owner. Dual listeners, duplicate grants, and simultaneous TCC prompts are test failures. +On any ambiguous step, Workbench does not kill or replace an unknown connector. Before atomic commit it shows a blocker and preserves the previously verified owner. After commit both clients remain effective Off until same-identity repair. Dual listeners, duplicate grants, simultaneous TCC prompts, and post-cutover Workbench fallback are test failures. ## Update, downgrade, uninstall, and orphan cleanup @@ -292,7 +307,7 @@ Mac Access owns its appcast/update channel, signing/notarization, install locati Before update, the helper moves effective access to `Off`, drains/cancels work, persists a redacted lifecycle audit, closes the channel, and releases leadership. Protected state records minimum reader/writer schema versions and a monotonic security epoch. Status/handshake records exact build version, source commit, signed lineage, reader/writer versions, credential epoch, and security epoch. The replacement must meet every floor, have a compatible schema range and matching designated requirement, and complete the broker-enforced credential-epoch migration above before it reads the new credential. Failed replacement leaves access off and restores the last signed compatible artifact only through the independent rollback path and exact broker authorization. -Downgrade across an unsupported state/schema or below the protected security/build floor refuses to start remote transport. It does not rewrite new state with old defaults. Exceptional rollback requires an exact signed, time-bounded authorization naming source/target build and resulting security floor. Full Access is never restored across any update or rollback without current-runtime, current-policy-epoch, current-binding local confirmation. +Downgrade across an unsupported state/schema or below the protected security/build floor refuses to start remote transport. It does not rewrite new state with old defaults. Exceptional rollback requires an exact signed, time-bounded authorization naming the verified source, exact target build/schema identity, and resulting schema/security floors. Full Access is never restored across any update or rollback without current-runtime, current-policy-epoch, current-binding local confirmation. Uninstall/revoke order is: activate local deny barrier, invalidate approvals/queue, write revocation tombstone, notify broker best effort, close channel, delete active device credentials, unregister login item/helper, and remove executable/runtime files. A minimal non-secret tombstone may remain to prevent an old grant from being accepted after reinstall; retention and reset behavior must be explicit in #703/#705. Orphan cleanup removes only processes/files whose path and signed identity match the current or recorded prior Mac Access installation. @@ -321,24 +336,24 @@ The negative manifests distinguish: Downstream issues may claim completion only with the following evidence: -| Issue lane | Minimum evidence | -| ---------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | -| #700 core | Bounded subpackages; TS/Python/Swift parity; complete host-operation integration without Electron/system Python; negative fixtures; native-port boundaries. | -| #701 app | native menu flow; truthful states; signed identity inspection; no Workbench dependency. | -| #702 transport | ws-proxy #73 source contract; exact selected-binding positive and all negative cases; outbound-only packet/listener evidence. | -| #703 policy | transition tests, exact-scope prompt, restart downgrade, stop/revoke/kill race tests, redacted audit chain. | -| #704 coexistence | single leader/listener/grant/TCC identity with both apps; make-before-break and rollback. | -| #705 release | signed, notarized, stapled independent artifact/appcast; upgrade/downgrade/uninstall/orphan proof. | -| #706 live proof | pristine supported Mac onboarding and broker-selected VM-to-Mac CUA with exact audit IDs. | +| Issue lane | Minimum evidence | +| ---------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| #700 core | Bounded subpackages; TS/Python/Swift parity; complete host-operation integration without Electron/system Python; negative fixtures; native-port boundaries. | +| #701 app | native menu flow; truthful states; signed identity inspection; no Workbench dependency. | +| #702 transport | dashboard #669 code redemption plus ws-proxy #73 source contract; exact selected-binding positive and all negative cases; outbound-only packet/listener evidence. | +| #703 policy | transition tests, exact-scope prompt, restart downgrade, stop/revoke/kill race tests, redacted audit chain. | +| #704 coexistence | single leader/listener/grant/TCC identity with both apps; make-before-break and rollback. | +| #705 release | signed, notarized, stapled independent artifact/appcast; upgrade/downgrade/uninstall/orphan proof. | +| #706 live proof | pristine supported Mac onboarding and broker-selected VM-to-Mac CUA with exact audit IDs. | Public publication, rollout, and customer readiness remain separately authorized gates after all of the above. ## Current blockers and sequencing -- PR #697 was still open at the inspected head. No connector implementation may land from a pre-#697 copy. -- ws-proxy #73 must supply the outbound selected-binding relay; no fallback is authorized. -- ws-proxy #69 proves an optional source-level signed execution context only. Signer deployment, downstream verification, and enforcement were unproven at inspection time. -- OpenClaw #16 and the owned Hermes/final connector verifier lanes must consume and propagate the signed context before enforcement. +- #709 is merged at exact canonical commit `27b28cd234d537a491028e9024070cf8d33b9611`; #699/#700 branches must contain that merge or a later explicitly recorded canonical supersession. +- Dashboard #669 must supply one-use public code authority and ws-proxy #73 must supply the outbound selected-binding relay; no direct-network or raw-secret fallback is authorized. +- ws-proxy #69's signed execution-context source is merged; production remains signer-disabled and isolated-staging selected-binding enforcement is not yet live proof. +- #708's bundled OpenClaw/Hermes verifiers and runtime-receipt consumers are parity inputs. Their source/CI does not prove deployed relay enforcement or VM-to-Mac CUA. - #699 blocks #700-#706. Architecture approval does not authorize merging, release work, customer mutation, or v2.1.36 changes. ## Non-goals diff --git a/docs/evaos/mac-access/migration-map.md b/docs/evaos/mac-access/migration-map.md index 0776af5362..34b70f79ac 100644 --- a/docs/evaos/mac-access/migration-map.md +++ b/docs/evaos/mac-access/migration-map.md @@ -1,55 +1,57 @@ # evaOS Mac Access source-to-target migration map Issue: [#699](https://github.com/100yenadmin/evaOS-GUI/issues/699) -Inspected source: PR [#697](https://github.com/100yenadmin/evaOS-GUI/pull/697) head `f197d2e51828f9f6a50e8448a09d5b064d14b498` -Contract version: `2026-07-15.v1` +Inspected source: PR #697 final head `fff813ef1da6b766ae09344b20021b4a4b0672c4`, merge `0ac9742cc8c42d777da627adb9cf4179567d1373`; PR #708 final head `5b1308fadc481f83116c54de2b9713ab2363bed2`, merge `5c86e8e91660772da5b1b6f49b43f2de3afee737`; PR #709 final head `f92d45f984db29c132e65f458df85567f04186ca`, merge `27b28cd234d537a491028e9024070cf8d33b9611` +Contract version: `2026-07-15.v2` ## Rule Every current connector module and Workbench production call site has one target owner and one migration phase. The archived `electricsheephq/evaos-desktop-bridge` repository is not a target, source, build input, or runtime dependency. -No connector implementation may land from this inspected copy while #697 remains unmerged. After #697 merges or is superseded, downstream branches must reset their source baseline to the exact canonical merged/superseding head before edits. +PR #697, #708, and #709 are merged. `27b28cd234d537a491028e9024070cf8d33b9611` is the exact canonical source checkpoint for this architecture refresh. Downstream branches must use the exact #699-accepted head that contains that merge or a later named canonical supersession; current `main`, the old #697 branch, and pre-#709 copies are invalid bases. ## Phases -| Phase | GitHub owner | Purpose | Landing condition | -| ----- | ---------------------- | ---------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------- | -| A0 | #699 | Freeze identities, owners, protocols, fixtures, threat model, and migration. | Architecture review clears current #697 head. | -| A1 | #700 | Move the single Python source into connector core; add native ports and cross-language parity without product cutover. | #697 canonical head and #699 approved. | -| A2 | #701 | Build native menu app/helper with frozen identities and local-only flows. | Core contracts pass; no remote relay claim. | -| A3 | #702 plus ws-proxy #73 | Add outbound selected-binding relay and helper transport. | Backend source contract exists; negative binding/replay tests pass. | -| A4 | #703 | Make native policy/audit/revoke/kill authority complete. | Transport cannot bypass local policy. | -| A5 | #704 | Convert Workbench runtime, packaging, and release proof from connector owner to authenticated Mac Access client. | Mac Access exclusive-leader readiness and rollback handshake pass. | -| A6 | #705 | Independent sign/notarize/update/rollback/uninstall. | Coexistence source and local proof pass. | -| A7 | #706 | Pristine-Mac and broker-selected VM-to-Mac CUA proof. | Exact signed candidate passes prior gates. | - -## PR #697 Python module ownership - -The `Target owner` column is intentionally singular and names the A1 canonical location. Every current runtime module first moves exactly once into an explicit subpackage under `packages/mac-connector-core/python/evaos_desktop_bridge`; harness/canary modules move into connector-core tests. The root has six direct children and every subpackage stays below ten. Later A2-A4 native ports narrow or retire Python behavior only after parity proof and never leave a second source tree. - -| Current source under `resources/evaos-beta/bridge/src/evaos_desktop_bridge/` | Current role | Target owner | Phase | Migration action and exit check | -| ---------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------ | ----- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `__init__.py` | Package metadata/entry namespace. | `packages/mac-connector-core/python/evaos_desktop_bridge/__init__.py` | A1 | Move after #697 canonicalization; version must match embedded manifest. | -| `adapters/__init__.py` | Adapter namespace. | `packages/mac-connector-core/python/evaos_desktop_bridge/adapters/__init__.py` | A1 | Move; export only private runtime adapters. | -| `adapters/codex_app_server.py` | Codex app-server protocol adapter. | `python/evaos_desktop_bridge/adapters/codex_app_server.py` | A1 | Move and keep pure protocol normalization; no credential/TCC ownership. | -| `adapters/codex_macos.py` | Direct macOS/Codex CUA adapter. | `python/evaos_desktop_bridge/adapters/codex_macos.py` | A1 | Move intact behind the core host interface; A2/A4 later port required behavior to signed native CUA and retire direct Python execution after parity proof. | -| `adapters/customer_mac.py` | Customer-Mac normalization/orchestration with generation-guarded start, stop, kill, and takeover state. | `python/evaos_desktop_bridge/adapters/customer_mac.py` | A1 | Preserve race semantics and extract only Mac capabilities; replace native calls with a bounded helper-owned port. iPhone Mirroring branches are not packaged or exposed in v0.1. | -| `audit.py` | Local append-only JSON audit writes with content hashing, no-follow open, and mode hardening. | `python/evaos_desktop_bridge/persistence/audit.py` | A1 | Move intact and preserve current behavior; A4 later ports authoritative write/chain/redaction to Swift, leaving Python draft metadata only. Audit failure always denies execution. | -| `behavior_harness.py` | Behavior parity harness. | `tests/python/behavior_harness.py` | A1 | Move as parity oracle; run against embedded Python and native port fixtures. | -| `bundled_tools.py` | Tool discovery/invocation helpers. | `python/evaos_desktop_bridge/host/bundled_tools.py` | A1 | Remove PATH/Homebrew/system discovery; resolve only embedded, manifest-pinned resources. | -| `capability_manifest.py` | Capability catalog and availability. | `python/evaos_desktop_bridge/contracts/capability_manifest.py` | A1 | Generate language adapters from one versioned manifest; availability cannot imply authority. | -| `candidate_identity.py` | Packaged Workbench source-tree and application identity attestation. | `python/evaos_desktop_bridge/host/candidate_identity.py` | A1 | Move with the runtime and preserve candidate/provenance parity; never treat Python self-attestation as runtime or broker authority. #705 adds a separate native artifact verifier. | -| `cli.py` | Public-style bridge CLI with audit hashing and control-generation gates. | `python/evaos_desktop_bridge/host/cli.py` | A1 | Preserve hashed-evidence and stale-generation negatives, then keep only the helper-launched private host entry; remove customer/Workbench public CLI dependency. | -| `connector_server.py` | Token-gated HTTP enrollment/dispatch with control-generation and kill-switch checks, plus iPhone routes. | `python/evaos_desktop_bridge/host/connector_server.py` | A1 | Move intact for Workbench parity only; A3 ports Mac dispatch to the helper and retires HTTP listener, URL/token, enrollment callback, and iPhone routes without losing race checks. | -| `helper_ipc.py` | Unix socket, file token, same-UID helper and direct AX/Quartz execution. | `python/evaos_desktop_bridge/host/helper_ipc.py` | A1 | Move intact behind the host API; A2 later replaces token/UID authority with signed XPC and ports CUA after parity proof. | -| `policy.py` | Capability policy table. | `python/evaos_desktop_bridge/policy/policy.py` | A1 | Move intact and bind contract parity tests; A4 later ports authority to Swift with selected tuple, digest, mode, epoch, approval, and audit checks. | -| `pre_canary.py` | Pre-live environment/control checks. | `tests/python/pre_canary.py` | A1 | Convert to hermetic source/embedded-runtime checks; no system Python or live customer dependency. | -| `qa_canary.py` | Connector/OpenClaw/Hermes suites plus source-bound selected-binding proof validation. | `tests/python/qa_canary.py` | A1 | Preserve scenario and proof-binding semantics; replace URL/token inputs with test relay/native client fixtures before A7. | -| `queue.py` | Local queued-operation persistence. | `python/evaos_desktop_bridge/persistence/queue.py` | A1 | Move intact and preserve crash/restart behavior; A4 later ports bounded queue, expiry, epoch, revoke/kill cancellation, and crash clearing to Swift. | -| `redaction.py` | Separate recursive public-output and audit-value redaction. | `python/evaos_desktop_bridge/contracts/redaction.py` | A1 | Move both policies, align audit output with `audit_event.v1`, and keep the native helper as final enforcement point. | -| `schema.py` | Common result/error envelope builders. | `python/evaos_desktop_bridge/contracts/schema.py` | A1 | Replace ad-hoc shapes with generated/hand-verified v1 adapters in TS, Swift, and embedded Python. | -| `state.py` | Pairing/control/audit state plus locked generation transactions and persistent kill-switch state. | `python/evaos_desktop_bridge/persistence/state.py` | A1 | Preserve linearization/race negatives without raw tokens; A4 ports versioned state, Keychain references, epochs, revocation tombstone, and atomic recovery to Swift. | -| `types.py` | Python command/result types. | `python/evaos_desktop_bridge/contracts/types.py` | A1 | Generate or verify against v1 fixtures; unknown fields and schemas fail closed at authority boundaries. | +| Phase | GitHub owner | Purpose | Landing condition | +| ----- | ---------------------- | ------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------- | +| A0 | #699 | Freeze identities, owners, protocols, fixtures, threat model, and migration. | Architecture review clears exact #709 superseding head. | +| A1 | #700 | Move the single Python source into connector core; add native ports and cross-language parity without product cutover. | Exact #699-accepted head and path-gated core CI. | +| A2 | #701 | Build native menu app/helper with frozen identities and local-only flows. | Core contracts pass; no remote relay claim. | +| A3 | #702 plus ws-proxy #73 | Add outbound selected-binding relay and helper transport. | Backend source contract exists; negative binding/replay tests pass. | +| A4 | #703 | Make native policy/audit/revoke/kill authority complete. | Transport cannot bypass local policy. | +| A4.5 | #705 | Produce a private signed/notarized Mac Access RC for installed coexistence proof; no public promotion. | #700/#701/#703 pass and identity/nested code exactly match #699. | +| A5 | #704 | Convert Workbench from connector owner to authenticated client and run installed-app coexistence against the A4.5 RC. | One leader/grant/TCC identity; no second-identity rollback path. | +| A6 | #705 | Complete independent updater, rollback, uninstall, stapling, and release-candidate evidence at the same frozen identity. | A4.5 identity remains unchanged; #704 cannot mutate the Mac artifact. | +| A7 | #706 | Pristine-Mac and broker-selected VM-to-Mac CUA proof. | Exact signed candidate passes #702 plus A5/A6. | + +## Canonical Workbench Python module ownership + +The inspected source contains 22 Python modules, including #708's `receipt_canary.py`. The `Target owner` column is intentionally singular and names the A1 canonical location. Every current runtime module first moves exactly once into an explicit subpackage under `packages/mac-connector-core/python/evaos_desktop_bridge`; harness/canary modules move into connector-core tests. The root has six direct children and every subpackage stays below ten. Later A2-A4 native ports narrow or retire Python behavior only after parity proof and never leave a second source tree. + +| Current source under `resources/evaos-beta/bridge/src/evaos_desktop_bridge/` | Current role | Target owner | Phase | Migration action and exit check | +| ---------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------ | ----- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `__init__.py` | Package metadata/entry namespace. | `packages/mac-connector-core/python/evaos_desktop_bridge/__init__.py` | A1 | Move from the exact #699-accepted post-#709 baseline; version must match embedded manifest. | +| `adapters/__init__.py` | Adapter namespace. | `packages/mac-connector-core/python/evaos_desktop_bridge/adapters/__init__.py` | A1 | Move; export only private runtime adapters. | +| `adapters/codex_app_server.py` | Codex app-server protocol adapter. | `python/evaos_desktop_bridge/adapters/codex_app_server.py` | A1 | Move and keep pure protocol normalization; no credential/TCC ownership. | +| `adapters/codex_macos.py` | Direct macOS/Codex CUA adapter. | `python/evaos_desktop_bridge/adapters/codex_macos.py` | A1 | Move intact behind the core host interface; A2/A4 later port required behavior to signed native CUA and retire direct Python execution after parity proof. | +| `adapters/customer_mac.py` | Customer-Mac normalization/orchestration with generation-guarded start, stop, kill, and takeover state. | `python/evaos_desktop_bridge/adapters/customer_mac.py` | A1 | Preserve race semantics and extract only Mac capabilities; replace native calls with a bounded helper-owned port. iPhone Mirroring branches are not packaged or exposed in v0.1. | +| `audit.py` | Local append-only JSON audit writes with content hashing, no-follow open, and mode hardening. | `python/evaos_desktop_bridge/persistence/audit.py` | A1 | Move intact and preserve current behavior; A4 later ports authoritative write/chain/redaction to Swift, leaving Python draft metadata only. Audit failure always denies execution. | +| `behavior_harness.py` | Behavior parity harness. | `tests/python/behavior_harness.py` | A1 | Move as parity oracle; run against embedded Python and native port fixtures. | +| `bundled_tools.py` | Tool discovery/invocation helpers. | `python/evaos_desktop_bridge/host/bundled_tools.py` | A1 | Remove PATH/Homebrew/system discovery; resolve only embedded, manifest-pinned resources. | +| `capability_manifest.py` | Capability catalog and availability. | `python/evaos_desktop_bridge/contracts/capability_manifest.py` | A1 | Generate language adapters from one versioned manifest; availability cannot imply authority. | +| `candidate_identity.py` | Packaged Workbench source-tree and application identity attestation. | `python/evaos_desktop_bridge/host/candidate_identity.py` | A1 | Move with the runtime and preserve candidate/provenance parity; never treat Python self-attestation as runtime or broker authority. #705 adds a separate native artifact verifier. | +| `cli.py` | Public-style bridge CLI with audit hashing, control-generation gates, and #709 staging-signer validation. | `python/evaos_desktop_bridge/host/cli.py` | A1 | Preserve hashed-evidence, stale-generation negatives, and validation of all four explicit staging signer settings before any plist/LaunchAgent mutation; keep only the helper-launched private host entry. | +| `connector_server.py` | Token-gated HTTP enrollment/dispatch with control-generation and kill-switch checks, plus iPhone routes. | `python/evaos_desktop_bridge/host/connector_server.py` | A1 | Move intact for Workbench parity only; A3 ports Mac dispatch to the helper and retires HTTP listener, URL/token, enrollment callback, and iPhone routes without losing race checks. | +| `helper_ipc.py` | Unix socket, file token, same-UID helper and direct AX/Quartz execution. | `python/evaos_desktop_bridge/host/helper_ipc.py` | A1 | Move intact behind the host API; A2 later replaces token/UID authority with signed XPC and ports CUA after parity proof. | +| `policy.py` | Capability policy table. | `python/evaos_desktop_bridge/policy/policy.py` | A1 | Move intact and bind contract parity tests; A4 later ports authority to Swift with selected tuple, digest, mode, epoch, approval, and audit checks. | +| `pre_canary.py` | Pre-live environment/control checks. | `tests/python/pre_canary.py` | A1 | Convert to hermetic source/embedded-runtime checks; no system Python or live customer dependency. | +| `qa_canary.py` | Connector/OpenClaw/Hermes suites plus source-bound selected-binding proof validation. | `tests/python/qa_canary.py` | A1 | Preserve scenario and proof-binding semantics; replace URL/token inputs with test relay/native client fixtures before A7. | +| `receipt_canary.py` | Strict signed runtime-receipt, public-attestation, replay, expiry, and candidate-binding verification. | `tests/python/receipt_canary.py` | A1 | Preserve #708 receipt schemas and negative proof as Workbench parity tests; #702 adds relay command authority and #706 proves the deployed composed route. | +| `queue.py` | Local queued-operation persistence. | `python/evaos_desktop_bridge/persistence/queue.py` | A1 | Move intact and preserve crash/restart behavior; A4 later ports bounded queue, expiry, epoch, revoke/kill cancellation, and crash clearing to Swift. | +| `redaction.py` | Separate recursive public-output and audit-value redaction. | `python/evaos_desktop_bridge/contracts/redaction.py` | A1 | Move both policies, align audit output with `audit_event.v1`, and keep the native helper as final enforcement point. | +| `schema.py` | Common result/error envelope builders. | `python/evaos_desktop_bridge/contracts/schema.py` | A1 | Replace ad-hoc shapes with generated/hand-verified v1 adapters in TS, Swift, and embedded Python. | +| `state.py` | Pairing/control/audit state plus locked generation transactions and persistent kill-switch state. | `python/evaos_desktop_bridge/persistence/state.py` | A1 | Preserve linearization/race negatives without raw tokens; A4 ports versioned state, Keychain references, epochs, revocation tombstone, and atomic recovery to Swift. | +| `types.py` | Python command/result types. | `python/evaos_desktop_bridge/contracts/types.py` | A1 | Generate or verify against v1 fixtures; unknown fields and schemas fail closed at authority boundaries. | ## Connector-core A1 host interface @@ -74,59 +76,77 @@ Every request binds `host_session_id`, monotonic safe-integer `sequence`, `reque All target owners below remain in `packages/desktop` as clients or presentation. None remains a connector authority after A5. -| Current Workbench file | Current responsibility | Target owner | Phase | Required change and exit check | -| ------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------- | ---------------------------------------------- | ----- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `src/common/evaos/bridgeTypes.ts` | Native status/action DTOs, including current control-mode shapes. | Workbench generated Mac Access client DTOs | A5 | Add v1 status/actions including `off`; delete raw connector URL/token/path fields from client-visible types. | -| `src/common/evaos/nativeCompanionBoundary.ts` | Static shell/native trust matrix and canaries. | Workbench trust/presentation contract | A5 | Point native owner to Mac Access helper; canaries describe client status, not Python CLI authority. | -| `src/process/evaosNativeCompanionBoundary.ts` | Main-process local-action boundary enforcement. | Workbench fail-closed client boundary | A5 | Permit only fixed authenticated-client actions; no generic local CUA or lifecycle escape hatch. | -| `src/index.ts` | Main-process startup/teardown registration. | Workbench Mac Access client bootstrap | A5 | Register XPC client bridge; stop only Workbench client/session state, never an unknown Mac Access helper. | -| `src/process/bridge/evaosNativeCompanionBridge.ts` | Renderer-to-main status/diagnostic/action IPC. | Workbench renderer-safe Mac Access bridge | A5 | Call the authenticated client, preserve `BridgeResponse`, and return redacted status/audit IDs only. | -| `src/process/bridge/evaosRendererSecretGuard.ts` | Renderer secret-shape rejection. | Workbench renderer secret guard | A5 | Extend forbidden fields to Mac Access channel, binding, peer, Keychain, signature, and transport details. | -| `src/process/bridge/index.ts` | IPC bridge registration. | Workbench bridge registry | A5 | Register only the Mac Access client bridge once. | -| `src/process/resources/builtinMcp/evaosMacControlServer.ts` | Spawns bridge CLI and exposes Mac-control tools. | Workbench-to-Mac Access client adapter | A5 | Remove bridge binary/PATH resolution and direct process spawn; route allowed requests through helper client or selected broker path. | -| `src/process/services/evaosNativeCompanionStatus.ts` | Status, process spawn, local HTTP readiness, tailnet discovery, grants, enrollment, actions, TCC repair. | Workbench `MacAccessClientService` | A5 | Retain presentation/orchestration only; delete connector spawn/stop, URL/token, host discovery, Tailscale bootstrap, grant ownership, direct bridge CLI, and TCC authority. | -| `src/process/services/evaosBrokerSession.ts` | Completes connector enrollment and creates grants from connector URL/token/tailnet inputs. | Broker-selected Mac Access client adapter | A5 | Remove Workbench connector registration/grant authority and raw endpoint/token inputs; request only broker-side atomic prepare/commit/reverse-handoff operations. | -| `src/common/adapter/ipcBridge.ts` | Declares renderer/main native-companion IPC surface. | Workbench generated renderer-safe client IPC | A5 | Replace legacy action/status shapes with the redacted Mac Access client DTOs; no generic or secret-bearing fields. | -| `src/process/utils/runBackendMigrations.ts` | Executes backend migrations including native-companion seams. | Workbench coexistence migration coordinator | A5 | Run idempotent make-before-break client cutover marker; never migrate raw credentials in renderer/Node storage. | -| `src/renderer/evaos/useEvaosNativeCompanionStatus.ts` | Polls/requests status and actions. | Workbench renderer hook | A5 | Render redacted v1 status; no connector lifecycle assumptions. | -| `src/renderer/evaos/nativeCompanionViewModel.ts` | Converts native status into customer UI state. | Workbench presentation model | A5 | Map `Off`, `Ask Every Time`, `Full Access`, paused/revoked/blocked, and reconfirmation truthfully. | -| `src/renderer/pages/native-companion/index.tsx` | Mac & iPhone/native companion page. | Workbench Mac Access client UI | A5 | Present status/handoff controls; Mac Access app owns pairing/TCC/access confirmation. iPhone Mirroring is outside this sprint. | -| `src/renderer/pages/guid/hooks/useGuidAgentSelection.ts` | Consumes native-companion readiness during guided agent selection. | Workbench redacted readiness consumer | A5 | Consume client status only; no fallback connector start or readiness inference from legacy bridge state. | -| `src/renderer/pages/settings/AgentSettings/LocalAgents.tsx` | Consumes native-companion status in Local Agents settings. | Workbench redacted status consumer | A5 | Render Mac Access state/handoff only; no connector lifecycle, token, or TCC authority. | -| `src/process/resources/builtinMcp/constants.ts` | Names and recognizes the packaged Mac-control MCP server. | Workbench Mac Access MCP recognizer | A5 | Recognize only the authenticated Mac Access client adapter; remove legacy bridge-name and direct-process fallback. | -| `src/renderer/evaos/evaosNativeAgentAvailability.ts` | Maps native-companion state into agent/MCP availability. | Workbench redacted availability mapper | A5 | Only exact redacted Mac Access `ready` state may be usable or attach MCP; Off, repair, and reconfirmation states remain blocked. | -| `src/renderer/pages/guid/hooks/useGuidSend.ts` | Attaches native Mac-control MCP during guided sends. | Workbench guarded MCP attachment | A5 | Attach only from the exact redacted Mac Access ready state; no legacy readiness or connector-start fallback. | -| `src/renderer/pages/guid/GuidPage.tsx` | Directly presents native availability in the guided workflow. | Workbench redacted status presentation | A5 | Consume the shared redacted availability result only; never infer transport, credential, TCC, or lifecycle authority. | -| `src/renderer/pages/guid/components/AgentPillBar.tsx` | Directly presents selected-agent native availability. | Workbench redacted status presentation | A5 | Display status only; do not attach MCP or trigger connector lifecycle outside the guarded send path. | -| `src/renderer/pages/settings/AgentSettings/AgentCard.tsx` | Directly presents agent native availability. | Workbench redacted status presentation | A5 | Display status only; blocked/repair/reconfirm states cannot be promoted to usable. | -| `scripts/build-mcp-servers.js` | Packages the direct Mac-control MCP server adapter. | Workbench client-adapter packaging | A5 | Package only the Mac Access authenticated-client adapter; remove direct bridge binary/process invocation. | -| `scripts/build-with-builder.js` | Orchestrates Workbench resource preparation and product packaging. | Workbench build consuming connector-core | A1 | Resolve the canonical package and invoke deterministic preparation; A5 later removes authoritative runtime embedding. | -| `scripts/prepareEvaosDesktopBridgeResource.js` | Prepares and manifests the Workbench-owned Python bridge/helper runtime. | connector-core deterministic packaging adapter | A1 | Consume `packages/mac-connector-core` as the only source, emit the exact core digest, and fail if the generated Workbench resource can drift. | -| `resources/evaos-beta/bridge/SOURCE.json` | Records archived import provenance for the Workbench-vendored bridge source. | connector-core versioned provenance manifest | A1 | Migrate required history into the canonical core manifest; generated Workbench resources reference that manifest and never restore the archived repo as a dependency. | -| `scripts/create-mock-release-artifacts.sh` | Builds committed-source Workbench bridge release fixtures with source/provenance manifests. | Workbench core-parity fixture builder | A1 | Consume the canonical connector-core manifest and generated resource only; never archive the legacy resource as source truth or build independent Mac Access release assets. | -| `scripts/evaosBetaReleaseGate.js` | Runs Workbench bridge source/app identity, enrollment, and packaged CLI release gates. | Workbench gate consuming connector-core proof | A1 | Verify the canonical core digest and preserve regression canaries; A5 later limits this gate to client compatibility and non-embedding. | -| `packages/desktop/electron-builder.yml` | Embeds bridge resources into the Workbench artifact. | Workbench packaging consuming connector-core | A1 | Package only generated core resources with the manifest digest; A5 removes the authoritative runtime after coexistence cutover. | -| `scripts/afterPack.js` | Verifies packaged Workbench bridge provenance and resource shape. | Workbench core-digest verifier | A1 | Prove the packaged resource equals the canonical core manifest; A5 later inverts this gate to reject an authoritative embedded connector. | -| `scripts/afterSign.js` | Signs/verifies the Workbench bridge/helper payload. | Workbench generated-core signing verifier | A1 | Preserve Workbench signing for the generated core during migration; A5 removes connector authority. It never signs or mutates independent Mac Access artifacts. | -| `.github/workflows/evaos-beta-rc-canary.yml` | Runs Workbench beta candidate bridge/enrollment proof. | Workbench core-parity canary | A1 | Prove the generated package uses the canonical core digest; A5 later converts to client/non-embedding proof. Never gate or publish Mac Access. | -| `.github/workflows/evaos-live-canary-proof.yml` | Runs Workbench live packaged bridge proof. | Workbench core-parity canary | A1 | Preserve packaged-core regressions against the canonical digest; A5 later proves client-only behavior. Never substitute for Mac Access #705/#706 proof. | -| `.github/workflows/_build-reusable.yml` | Builds/tests Workbench and provisions build-time Python on packaging runners. | Workbench generated-core build consumer | A1 | Connector proof must execute the generated packaged runtime, not runner/system Python; the setup runtime may perform build tooling only. Never build Mac Access release. | -| `.github/workflows/workbench-functional-smoke.yml` | Builds an unpacked Workbench app and exercises its pinned embedded bridge runtime. | Workbench generated-core functional smoke | A1 | Bind the generated resource to the exact canonical core digest and run bridge imports through its packaged Python; A5 converts this to authenticated-client smoke proof. | -| `.github/workflows/pr-checks.yml` | Exercises source-commit-bound Workbench release fixtures and gates. | Workbench core-parity CI | A1 | Pin fixtures to the exact candidate/core source commit; broad CI remains Workbench evidence and never proves Mac Access artifacts or live control. | -| `.github/workflows/release-distribute.yml` | Passes Workbench RC artifact proof into the Workbench distribution gate. | Workbench release proof consumer | A1 | Preserve exact artifact/provenance verification only for Workbench; do not publish, gate, or mutate independent Mac Access artifacts. | -| `tests/unit/evaos/evaosDesktopBridgeSafety.test.ts` | Proves Workbench bridge race, filesystem-mode, and audit-redaction behavior. | connector-core parity regression input | A1 | Re-run against the canonical extracted core, then A5 retain only client/non-embedding safety tests. It is not native helper or signed-artifact proof. | -| `tests/unit/evaos/evaosBrokerLiveCanary.test.ts` | Binds Workbench live-canary proof to exact source/run and selected-binding evidence. | Workbench core-parity canary test | A1 | Preserve source/binding negatives until A5 client conversion; never substitute the Workbench canary for #702 transport or #706 VM-to-Mac CUA proof. | -| `tests/unit/bootstrap/afterPackBundledResources.test.ts` | Proves packaged Workbench bridge/helper/runtime inventory and provenance checks. | Workbench generated-core package test | A1 | Build fixtures from the canonical manifest/generated resource and require the packaged embedded runtime; never accept legacy source truth or system-Python substitution. | -| `tests/unit/common-adapter/evaosIpcBridge.dom.test.ts` | Proves renderer/main action deadlines, fail-closed IPC, and URL blocking. | Workbench Mac Access client IPC test | A5 | Preserve only fixed renderer-safe client calls and fail-closed deadlines; no connector endpoint, credential, or generic host operation may cross IPC. | -| `tests/unit/evaos/NativeCompanionPage.dom.test.tsx` | Proves Workbench Mac-control UI selection, stop/kill wiring, and redacted support states. | Workbench Mac Access client UI test | A5 | Rebind to redacted Mac Access client DTOs and preserve human-selected target, visible stop/kill, and no-terminal-repair behavior; exclude iPhone Mirroring scope. | -| `tests/unit/evaos/evaosBrokerSession.test.ts` | Proves Workbench broker enrollment, grant, cancellation, and customer-binding behavior. | Workbench broker-selected Mac Access test | A5 | Replace connector URL/token/Tailscale enrollment with atomic selected-binding client flows; preserve wrong-customer, no-store, cancel, expiry, and renderer-secret negatives. | -| `tests/unit/evaos/evaosNativeCompanionStatus.test.ts` | Proves Workbench bridge discovery, status, enrollment, TCC, stop/kill, and redacted diagnostics. | Workbench Mac Access client status test | A5 | Consume redacted helper status only; remove legacy listener/PATH/Tailscale/TCC authority while preserving fail-closed readiness, generation, kill-switch, and secret checks. | -| `tests/unit/evaos/evaosNativeCompanionStatusHook.dom.test.tsx` | Proves customer-scoped renderer polling, grant handoff, and diagnostic filtering. | Workbench redacted Mac Access hook test | A5 | Preserve selected-customer isolation and allowlisted diagnostics over client DTOs; never retain grant or readiness state across customer switches. | -| `tests/unit/process/prepareEvaosDesktopBridgeResource.test.ts` | Proves current Workbench source selection, generated resource, and manifest behavior. | connector-core generation regression test | A1 | Consume canonical package source plus generated Workbench resource; fail if legacy resource source truth, system Python, or digest drift remains. | -| `tests/unit/process/evaosBetaReleaseGate.test.ts` | Proves Workbench candidate identity, packaged CLI, binding, and release-gate failures. | Workbench core-parity release test | A1 | Preserve exact candidate/race/redaction/release negatives against the generated embedded runtime; never treat them as Mac Access #705 release proof. | -| `src/renderer/evaos/__fixtures__/goldenWorkbenchParityManifest.ts` | Defines Workbench renderer parity expectations for native status. | Workbench client contract fixture | A5 | Replace legacy connector readiness/identity fields with redacted Mac Access client status; fixture truth cannot grant authority. | - -Before A1 edits, machine-inventory every production import, broker enrollment/grant method, IPC declaration, MCP/build/package consumer, test, canary, after-pack/sign check, and release-workflow input at the exact merged/superseding #697 head. Each must consume the canonical package, generated resource, or packaged embedded runtime—never system Python or `resources/evaos-beta/bridge` as source truth—and receive a singular owner row. Repeat that inventory at the exact A5/A6 base. Absence from this inspected head is not permission to leave an untracked owner. +| Current Workbench file | Current responsibility | Target owner | Phase | Required change and exit check | +| ---------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------- | ----- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `src/common/evaos/bridgeTypes.ts` | Native status/action DTOs, including current control-mode shapes. | Workbench generated Mac Access client DTOs | A5 | Add v1 status/actions including `off`; delete raw connector URL/token/path fields from client-visible types. | +| `src/common/evaos/nativeCompanionBoundary.ts` | Static shell/native trust matrix and canaries. | Workbench trust/presentation contract | A5 | Point native owner to Mac Access helper; canaries describe client status, not Python CLI authority. | +| `src/process/evaosNativeCompanionBoundary.ts` | Main-process local-action boundary enforcement. | Workbench fail-closed client boundary | A5 | Permit only fixed authenticated-client actions; no generic local CUA or lifecycle escape hatch. | +| `src/index.ts` | Main-process startup/teardown registration. | Workbench Mac Access client bootstrap | A5 | Register XPC client bridge; stop only Workbench client/session state, never an unknown Mac Access helper. | +| `src/process/bridge/evaosNativeCompanionBridge.ts` | Renderer-to-main status/diagnostic/action IPC. | Workbench renderer-safe Mac Access bridge | A5 | Call the authenticated client, preserve `BridgeResponse`, and return redacted status/audit IDs only. | +| `src/process/bridge/evaosRendererSecretGuard.ts` | Renderer secret-shape rejection. | Workbench renderer secret guard | A5 | Extend forbidden fields to Mac Access channel, binding, peer, Keychain, signature, and transport details. | +| `src/process/bridge/index.ts` | IPC bridge registration. | Workbench bridge registry | A5 | Register only the Mac Access client bridge once. | +| `src/process/resources/builtinMcp/evaosMacControlServer.ts` | Spawns bridge CLI and exposes Mac-control tools. | Workbench-to-Mac Access client adapter | A5 | Remove bridge binary/PATH resolution and direct process spawn; route allowed requests through helper client or selected broker path. | +| `src/process/services/evaosNativeCompanionStatus.ts` | Status, process spawn, local HTTP readiness, tailnet discovery, grants, enrollment, actions, TCC repair. | Workbench `MacAccessClientService` | A5 | Retain presentation/orchestration only; delete connector spawn/stop, URL/token, host discovery, Tailscale bootstrap, grant ownership, direct bridge CLI, and TCC authority. | +| `src/process/services/evaosBrokerSession.ts` | Completes connector enrollment and creates grants from connector URL/token/tailnet inputs. | Broker-selected Mac Access client adapter | A5 | Remove Workbench connector registration/grant authority and raw endpoint/token inputs; request only broker-side atomic prepare/commit operations. No post-cutover reverse handoff to Workbench exists. | +| `src/common/adapter/ipcBridge.ts` | Declares renderer/main native-companion IPC surface. | Workbench generated renderer-safe client IPC | A5 | Replace legacy action/status shapes with the redacted Mac Access client DTOs; no generic or secret-bearing fields. | +| `src/process/utils/runBackendMigrations.ts` | Executes backend migrations including native-companion seams. | Workbench coexistence migration coordinator | A5 | Run idempotent make-before-break client cutover marker; never migrate raw credentials in renderer/Node storage. | +| `src/renderer/evaos/useEvaosNativeCompanionStatus.ts` | Polls/requests status and actions. | Workbench renderer hook | A5 | Render redacted v1 status; no connector lifecycle assumptions. | +| `src/renderer/evaos/nativeCompanionViewModel.ts` | Converts native status into customer UI state. | Workbench presentation model | A5 | Map `Off`, `Ask Every Time`, `Full Access`, paused/revoked/blocked, and reconfirmation truthfully. | +| `src/renderer/pages/native-companion/index.tsx` | Mac & iPhone/native companion page. | Workbench Mac Access client UI | A5 | Present status/handoff controls; Mac Access app owns pairing/TCC/access confirmation. iPhone Mirroring is outside this sprint. | +| `src/renderer/pages/guid/hooks/useGuidAgentSelection.ts` | Consumes native-companion readiness during guided agent selection. | Workbench redacted readiness consumer | A5 | Consume client status only; no fallback connector start or readiness inference from legacy bridge state. | +| `src/renderer/pages/settings/AgentSettings/LocalAgents.tsx` | Consumes native-companion status in Local Agents settings. | Workbench redacted status consumer | A5 | Render Mac Access state/handoff only; no connector lifecycle, token, or TCC authority. | +| `src/process/resources/builtinMcp/constants.ts` | Names and recognizes the packaged Mac-control MCP server. | Workbench Mac Access MCP recognizer | A5 | Recognize only the authenticated Mac Access client adapter; remove legacy bridge-name and direct-process fallback. | +| `src/renderer/evaos/evaosNativeAgentAvailability.ts` | Maps native-companion state into agent/MCP availability. | Workbench redacted availability mapper | A5 | Only exact redacted Mac Access `ready` state may be usable or attach MCP; Off, repair, and reconfirmation states remain blocked. | +| `src/renderer/pages/guid/hooks/useGuidSend.ts` | Attaches native Mac-control MCP during guided sends. | Workbench guarded MCP attachment | A5 | Attach only from the exact redacted Mac Access ready state; no legacy readiness or connector-start fallback. | +| `src/renderer/pages/guid/GuidPage.tsx` | Directly presents native availability in the guided workflow. | Workbench redacted status presentation | A5 | Consume the shared redacted availability result only; never infer transport, credential, TCC, or lifecycle authority. | +| `src/renderer/pages/guid/components/AgentPillBar.tsx` | Directly presents selected-agent native availability. | Workbench redacted status presentation | A5 | Display status only; do not attach MCP or trigger connector lifecycle outside the guarded send path. | +| `src/renderer/pages/settings/AgentSettings/AgentCard.tsx` | Directly presents agent native availability. | Workbench redacted status presentation | A5 | Display status only; blocked/repair/reconfirm states cannot be promoted to usable. | +| `scripts/build-mcp-servers.js` | Packages the direct Mac-control MCP server adapter. | Workbench client-adapter packaging | A5 | Package only the Mac Access authenticated-client adapter; remove direct bridge binary/process invocation. | +| `scripts/build-with-builder.js` | Orchestrates Workbench resource preparation and product packaging. | Workbench build consuming connector-core | A1 | Resolve the canonical package and invoke deterministic preparation; A5 later removes authoritative runtime embedding. | +| `scripts/prepareEvaosDesktopBridgeResource.js` | Prepares and manifests the Workbench-owned Python bridge/helper runtime. | connector-core deterministic packaging adapter | A1 | Consume `packages/mac-connector-core` as the only source, emit the exact core digest, and fail if the generated Workbench resource can drift. | +| `resources/evaos-beta/bridge/SOURCE.json` | Records archived import provenance for the Workbench-vendored bridge source. | connector-core versioned provenance manifest | A1 | Migrate required history into the canonical core manifest; generated Workbench resources reference that manifest and never restore the archived repo as a dependency. | +| `resources/evaos-beta/bridge/agent-tools/SOURCE.json` | Records exact bundled OpenClaw/Hermes agent-tool source and immutable file digests. | VM/runtime consumer provenance manifest | A1 | Keep separate from connector-core source ownership; verify compatible schemas/digests and never copy agent tools into the Mac connector authority. | +| `resources/evaos-beta/bridge/agent-tools/openclaw-plugin/src/runtimeReceipt.ts` plus `bridge.ts`, `firewall.ts`, and `toolParameters.ts` | Verifies selected binding, signed context, receipt/public attestation, replay, expiry, and tool boundaries in the VM runtime. | VM/runtime Mac Access relay consumer | A3 | Preserve #708 parity and negative fixtures; consume #73 relay results without becoming local policy, TCC, audit, or connector authority. | +| `resources/evaos-beta/bridge/agent-tools/openclaw-plugin/src/types.d.ts` | Declares the OpenClaw consumer runtime/type boundary. | VM/runtime consumer type contract | A3 | Generate or verify against the owned plugin contract; declarations may describe relay receipts but cannot define connector-core/helper authority. | +| `resources/evaos-beta/bridge/agent-tools/openclaw-plugin/index.ts`, `openclaw.plugin.json`, `package*.json`, and `tsconfig.json` | Registers/packages/builds the bundled VM-side plugin consumer. | VM/runtime consumer build metadata | A3 | Keep a separately versioned consumer artifact tied to `agent-tools/SOURCE.json`; never become connector-core source or inherit local helper authority. | +| `resources/evaos-beta/bridge/agent-tools/openclaw-plugin/dist/**` | Generated VM-side plugin output. | generated VM/runtime consumer artifact | A3 | Rebuild only from the owned plugin source, verify SOURCE digests, and reject committed/generated drift; never import generated output into connector core. | +| `resources/evaos-beta/bridge/agent-tools/openclaw-plugin/scripts/qa-run-bridge.mjs` and `runtime-receipt-negative-proof.mjs` | Runs built-plugin bridge QA and deployed negative receipt classification. | VM/runtime consumer proof harness | A3/A7 | Preserve exact selected-binding, replay, expiry, signer, and no-connector-access negatives against #73; results are not local Mac artifact proof. | +| `resources/evaos-beta/bridge/agent-tools/openclaw-plugin/tests/pluginContract.test.mjs` and `runtimeReceipt.test.mjs` | Proves plugin registry, verifier, firewall, stream, replay, and receipt contracts. | VM/runtime consumer contract tests | A1/A3 | Run as parity at A1 and against #73 at A3; keep generated/source/provenance equality and all negative receipt cases. | +| `resources/evaos-beta/bridge/agent-tools/hermes-adapter/bin/evaos-desktop-bridge-command` | Hermes-side selected-binding and receipt verifier/adapter. | VM/runtime Hermes Mac Access relay consumer | A3 | Preserve exact signed-context and receipt behavior; no direct connector URL/token or private-network fallback. | +| `resources/evaos-beta/bridge/native/EvaOSEd25519Verify.swift` | Native public-key verifier used by Workbench receipt proof. | `packages/mac-connector-core/native/Verification/EvaOSEd25519Verify.swift` | A1 | Move once as the canonical native verifier; preserve byte/signature parity and make Workbench/Mac Access link this package target rather than copy it. | +| `scripts/evaosMacControlSignedProof.js` and `scripts/evaosScanMacControlProofs.js` | Builds and scans Workbench signed runtime-proof packets. | Workbench proof consumer | A1 | Preserve #708 regression proof and redaction; never treat Workbench proof scripts as Mac Access #705/#706 artifact or live-CUA proof. | +| `scripts/evaosBrokerLiveCanary.js` | Calls and classifies the Workbench runtime-receipt route during canary proof. | Workbench parity canary consumer | A1 | Preserve #708 exact candidate/binding/receipt checks; A6 converts to Mac Access client receipt consumption and never substitutes for #702/#706 proof. | +| `scripts/evaosInstalledAppProductProof.js` | Validates installed Workbench product and native receipt proof inputs. | Workbench installed-product parity verifier | A1/A6 | Preserve #708 Workbench proof until cutover, then verify client-only/no-embedded-authority behavior; it never proves the independent #705 artifact. | +| `scripts/evaosLiveCanaryEnvInventory.js` and `scripts/evaosValidateLiveCanaryProofRun.sh` | Inventories and validates required live-canary receipt/signer/proof fields. | Workbench canary proof validator | A1 | Preserve exact field/redaction validation and #709 complete-signer fail-closed behavior; no environment value becomes Mac Access source or authority. | +| `scripts/create-mock-release-artifacts.sh` | Builds committed-source Workbench bridge release fixtures with source/provenance manifests. | Workbench core-parity fixture builder | A1 | Consume the canonical connector-core manifest and generated resource only; never archive the legacy resource as source truth or build independent Mac Access release assets. | +| `scripts/evaosBetaReleaseGate.js` | Runs Workbench bridge source/app identity, enrollment, and packaged CLI release gates. | Workbench gate consuming connector-core proof | A1 | Verify the canonical core digest and preserve regression canaries; A5 later limits this gate to client compatibility and non-embedding. | +| `packages/desktop/electron-builder.yml` | Embeds bridge resources into the Workbench artifact. | Workbench packaging consuming connector-core | A1 | Package only generated core resources with the manifest digest; A5 removes the authoritative runtime after coexistence cutover. | +| `scripts/afterPack.js` | Verifies packaged Workbench bridge provenance and resource shape. | Workbench core-digest verifier | A1 | Prove the packaged resource equals the canonical core manifest; A5 later inverts this gate to reject an authoritative embedded connector. | +| `scripts/afterSign.js` | Signs/verifies the Workbench bridge/helper payload. | Workbench generated-core signing verifier | A1 | Preserve Workbench signing for the generated core during migration; A5 removes connector authority. It never signs or mutates independent Mac Access artifacts. | +| `.github/workflows/evaos-beta-rc-canary.yml` | Runs Workbench beta candidate bridge/enrollment proof. | Workbench core-parity canary | A1 | Prove the generated package uses the canonical core digest; A5 later converts to client/non-embedding proof. Never gate or publish Mac Access. | +| `.github/workflows/evaos-live-canary-proof.yml` | Runs Workbench live packaged bridge proof. | Workbench core-parity canary | A1 | Preserve packaged-core regressions against the canonical digest; A5 later proves client-only behavior. Never substitute for Mac Access #705/#706 proof. | +| `.github/workflows/_build-reusable.yml` | Builds/tests Workbench and provisions build-time Python on packaging runners. | Workbench generated-core build consumer | A1 | Connector proof must execute the generated packaged runtime, not runner/system Python; the setup runtime may perform build tooling only. Never build Mac Access release. | +| `.github/workflows/workbench-functional-smoke.yml` | Builds an unpacked Workbench app and exercises its pinned embedded bridge runtime. | Workbench generated-core functional smoke | A1 | Bind the generated resource to the exact canonical core digest and run bridge imports through its packaged Python; A5 converts this to authenticated-client smoke proof. | +| `.github/workflows/pr-checks.yml` | Exercises source-commit-bound Workbench release fixtures and gates. | Workbench core-parity CI | A1 | Pin fixtures to the exact candidate/core source commit; broad CI remains Workbench evidence and never proves Mac Access artifacts or live control. | +| `.github/workflows/release-distribute.yml` | Passes Workbench RC artifact proof into the Workbench distribution gate. | Workbench release proof consumer | A1 | Preserve exact artifact/provenance verification only for Workbench; do not publish, gate, or mutate independent Mac Access artifacts. | +| `tests/unit/evaos/evaosDesktopBridgeSafety.test.ts` | Proves Workbench bridge race, filesystem-mode, and audit-redaction behavior. | connector-core parity regression input | A1 | Re-run against the canonical extracted core, then A5 retain only client/non-embedding safety tests. It is not native helper or signed-artifact proof. | +| `tests/unit/evaos/evaosDesktopBridgeReceipt.test.ts` | Proves #708 receipt schemas/routes/signers plus #709 pre-mutation staging validation. | connector-core receipt parity test | A1 | Preserve every current receipt and incomplete-signer negative against the extracted core; A3 adds #73 relay proof without weakening the Workbench regression. | +| `tests/unit/evaos/evaosDesktopBridgeControlSafetyP0.test.ts` and `evaosQaCanaryLocalControl.test.ts` | Prove generation-linearized control safety and packaged QA behavior. | connector-core safety/canary parity tests | A1 | Preserve late #697 races plus #708 receipt behavior through the embedded core before native replacement. | +| `tests/unit/evaos/evaosLiveCanaryWorkflow.test.ts` | Proves Workbench canary workflow receipt/proof composition. | Workbench canary workflow parity test | A1/A6 | Preserve #708 workflow gates, then invert to authenticated Mac Access client/no-embedded-authority proof after cutover. | +| `tests/unit/evaos/evaosInstalledAppProductProof.test.ts` and `evaosLiveCanaryEnvInventory.test.ts` | Prove installed-product receipt inputs and live-canary inventory/redaction. | Workbench installed/canary proof tests | A1/A6 | Preserve exact #708/#709 negatives, then prove client-only consumption after cutover; never become #705 artifact or #706 live-CUA evidence. | +| `tests/unit/evaos/fixtures/signedMacControlAttestation.ts` | Provides deterministic signed receipt/attestation fixtures. | connector-core verification fixture | A1 | Move or derive from the canonical native verifier contract and keep Workbench tests as consumers; test signatures are not deployed signer proof. | +| `tests/unit/evaos/evaosBrokerLiveCanary.test.ts` | Binds Workbench live-canary proof to exact source/run and selected-binding evidence. | Workbench core-parity canary test | A1 | Preserve source/binding negatives until A5 client conversion; never substitute the Workbench canary for #702 transport or #706 VM-to-Mac CUA proof. | +| `tests/unit/bootstrap/afterPackBundledResources.test.ts` | Proves packaged Workbench bridge/helper/runtime inventory and provenance checks. | Workbench generated-core package test | A1 | Build fixtures from the canonical manifest/generated resource and require the packaged embedded runtime; never accept legacy source truth or system-Python substitution. | +| `tests/unit/common-adapter/evaosIpcBridge.dom.test.ts` | Proves renderer/main action deadlines, fail-closed IPC, and URL blocking. | Workbench Mac Access client IPC test | A5 | Preserve only fixed renderer-safe client calls and fail-closed deadlines; no connector endpoint, credential, or generic host operation may cross IPC. | +| `tests/unit/evaos/NativeCompanionPage.dom.test.tsx` | Proves Workbench Mac-control UI selection, stop/kill wiring, and redacted support states. | Workbench Mac Access client UI test | A5 | Rebind to redacted Mac Access client DTOs and preserve human-selected target, visible stop/kill, and no-terminal-repair behavior; exclude iPhone Mirroring scope. | +| `tests/unit/evaos/evaosBrokerSession.test.ts` | Proves Workbench broker enrollment, grant, cancellation, and customer-binding behavior. | Workbench broker-selected Mac Access test | A5 | Replace connector URL/token/Tailscale enrollment with atomic selected-binding client flows; preserve wrong-customer, no-store, cancel, expiry, and renderer-secret negatives. | +| `tests/unit/evaos/evaosNativeCompanionStatus.test.ts` | Proves Workbench bridge discovery, status, enrollment, TCC, stop/kill, and redacted diagnostics. | Workbench Mac Access client status test | A5 | Consume redacted helper status only; remove legacy listener/PATH/Tailscale/TCC authority while preserving fail-closed readiness, generation, kill-switch, and secret checks. | +| `tests/unit/evaos/evaosNativeCompanionStatusHook.dom.test.tsx` | Proves customer-scoped renderer polling, grant handoff, and diagnostic filtering. | Workbench redacted Mac Access hook test | A5 | Preserve selected-customer isolation and allowlisted diagnostics over client DTOs; never retain grant or readiness state across customer switches. | +| `tests/unit/process/prepareEvaosDesktopBridgeResource.test.ts` | Proves current Workbench source selection, generated resource, and manifest behavior. | connector-core generation regression test | A1 | Consume canonical package source plus generated Workbench resource; fail if legacy resource source truth, system Python, or digest drift remains. | +| `tests/unit/process/evaosBetaReleaseGate.test.ts` | Proves Workbench candidate identity, packaged CLI, binding, and release-gate failures. | Workbench core-parity release test | A1 | Preserve exact candidate/race/redaction/release negatives against the generated embedded runtime; never treat them as Mac Access #705 release proof. | +| `src/renderer/evaos/__fixtures__/goldenWorkbenchParityManifest.ts` | Defines Workbench renderer parity expectations for native status. | Workbench client contract fixture | A5 | Replace legacy connector readiness/identity fields with redacted Mac Access client status; fixture truth cannot grant authority. | + +Before A1 edits, machine-inventory every production import, broker enrollment/grant method, IPC declaration, MCP/build/package consumer, VM-side agent-tool consumer, test, canary, after-pack/sign check, and release-workflow input at the exact #699-accepted head. Each must consume the canonical package, generated resource, packaged embedded runtime, or explicitly separate VM-consumer artifact—never system Python or `resources/evaos-beta/bridge` as source truth—and receive a singular owner row. Repeat that inventory at the exact A5/A6 base. Absence from this inspected head is not permission to leave an untracked owner. ## State migration @@ -149,25 +169,25 @@ The A5 handoff is a two-phase commit with a rollback token that contains no cred 1. **Prepare:** Workbench authenticates to Mac Access, records current legacy owner evidence, and requests a compatibility/readiness snapshot. 2. **Validate:** Mac Access proves signed identity, compatible schema, audit health, policy epoch, kill state, and whether a selected binding can be migrated. -3. **Prepare:** broker creates an inactive Mac Access binding plus an exact signed reverse-handoff authorization; no second active grant/lease exists. +3. **Prepare:** broker creates an inactive Mac Access binding plus an exact one-use commit authorization; no second active grant/lease exists. 4. **Quiesce:** both connectors enter deny-new-work, drain/cancel queued actions, and retain rollback material without accepting commands. 5. **Atomic commit:** one broker compare-and-swap deactivates the legacy grant/lease and activates the prepared Mac Access binding/lease with a new generation. 6. **Acknowledge:** Mac Access proves the committed generation locally; Workbench then stops only the exact verified legacy process and removes lifecycle authority. 7. **Cleanup:** Workbench removes URL/token/host state; both record non-secret audit IDs. Cleanup never creates a period with two active grants. -If any step before Atomic commit fails, the inactive prepared binding is discarded and Workbench returns to its previously verified state. If any step after commit is ambiguous, both clients remain effective Off. Reverse handoff uses the signed authorization and another atomic compare-and-swap; Workbench never restarts automatically and no rollback permits two active grants or leases. +If any step before Atomic commit fails, the inactive prepared binding is discarded and Workbench returns to its previously verified state; this is an aborted pre-#704 migration, not a post-cutover fallback. If any step after commit is ambiguous, both clients remain effective Off. After the A5/#704 commit, rollback may disable the unpublished client integration or restore a compatible artifact under the same `com.evaos.mac-access*` identity only. It never reactivates a Workbench-owned connector, and no rollback permits two active grants or leases. ## Cutover kill matrix -| Observed state | Mac Access action | Workbench action | -| ------------------------------------------- | -------------------------------------------------------------- | ------------------------------------------------------------------------------ | -| No legacy process, no Mac Access leader | May start as sole leader after local checks. | May offer install/open handoff; cannot start Mac Access internals. | -| Verified legacy owner, no Mac Access leader | Prepare handoff only. | Continues as prior owner until commit. | -| Verified Mac Access leader | Serve authenticated client. | Must not spawn/stop listener, create grant, resolve host/token, or invoke CUA. | -| Both ownership signals present | Effective Off; do not accept work. | Show split-brain blocker; do not kill by name/PID. | -| Unknown process/listener | Effective Off; preserve evidence. | Show support blocker; no takeover. | -| Mac Access crashed after commit | Relaunch with Full Access reconfirmation and duplicate checks. | Do not restart legacy fallback without explicit proven relinquish/rollback. | -| User activates kill switch | Block first, then clean queue/channel. | Reflect killed state; cannot clear remotely. | +| Observed state | Mac Access action | Workbench action | +| ------------------------------------------- | --------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------ | +| No legacy process, no Mac Access leader | May start as sole leader after local checks. | May offer install/open handoff; cannot start Mac Access internals. | +| Verified legacy owner, no Mac Access leader | Prepare handoff only. | Continues as prior owner until commit. | +| Verified Mac Access leader | Serve authenticated client. | Must not spawn/stop listener, create grant, resolve host/token, or invoke CUA. | +| Both ownership signals present | Effective Off; do not accept work. | Show split-brain blocker; do not kill by name/PID. | +| Unknown process/listener | Effective Off; preserve evidence. | Show support blocker; no takeover. | +| Mac Access crashed after commit | Relaunch/repair the same identity with Full Access reconfirmation and duplicate checks. | Show install/repair blocker; never restart a Workbench-owned connector. | +| User activates kill switch | Block first, then clean queue/channel. | Reflect killed state; cannot clear remotely. | ## Removal checklist @@ -191,7 +211,7 @@ Temporary migration code must have an owner, metric/audit signal, removal issue, | ------------------------------------------------------------------------------------------------ | ----------- | --------------------------------------------------------------------------------------------- | | All v1 JSON fixtures parse in TypeScript, Swift Foundation, and embedded Python stdlib. | A0/A1 | Exact commands and source head. | | All schema-negative fixtures fail and runtime-negative fixtures have executable rejection tests. | A0-A4 | Fixture ID to test-name ledger. | -| No pre-#697 implementation diff. | A1 | Merge-base and exact canonical source SHA. | +| No pre-#708 or non-#699-accepted implementation diff. | A1 | Merge-base and exact accepted source SHA. | | No forbidden archived-repo source/build/runtime reference. | every phase | Repository and artifact string/dependency scan. | | No public/private inbound listener, URL/token, Tailscale, or system Python. | A3/A6/A7 | Source scan, `lsof`, packet, packaged-artifact inspection, pristine-Mac proof. | | One owner with both apps in every launch/crash/update order. | A5/A6 | Process/listener/grant/TCC/audit identity matrix. | diff --git a/docs/evaos/mac-access/threat-model.md b/docs/evaos/mac-access/threat-model.md index baae797244..a51b9996d3 100644 --- a/docs/evaos/mac-access/threat-model.md +++ b/docs/evaos/mac-access/threat-model.md @@ -1,7 +1,8 @@ # evaOS Mac Access v0.1 threat model Issue: [#699](https://github.com/100yenadmin/evaOS-GUI/issues/699) -Contract version: `2026-07-15.v1` +Contract version: `2026-07-15.v2` +Inspected Workbench lineage: PR #697 head `fff813ef1da6b766ae09344b20021b4a4b0672c4`, merge `0ac9742cc8c42d777da627adb9cf4179567d1373`; PR #708 head `5b1308fadc481f83116c54de2b9713ab2363bed2`, merge `5c86e8e91660772da5b1b6f49b43f2de3afee737`; PR #709 head `f92d45f984db29c132e65f458df85567f04186ca`, merge `27b28cd234d537a491028e9024070cf8d33b9611` ## Scope @@ -64,60 +65,60 @@ Data crossing each boundary is allowlisted, length-bounded, versioned, and fail ## Threat table -| Threat | Attack or failure | Required prevention/detection | Fail-closed response | Proof gate | -| ------------------------------ | ------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------- | -| Replay | Reuse context ID, session/channel generation, command ID, nonce, sequence, approval, or result receipt. | Complete RFC 8785 signed command payload; bounded replay cache persisted across reconnect/helper restart until maximum expiry; single-use approval. | Deny before prompt/audit decision; record `command_replayed`; never extend expiry. | Submit the same valid envelope twice and across reconnect/restart. Second and later attempts fail. | -| Stale binding | Use old binding ID/version after selection changes. | Compare signed context, outer tuple, Keychain enrollment, and current policy epoch exactly. | Invalidate queue/approvals; close old channel; effective `Off` until new binding is confirmed. | Old and new bindings interleaved; only current binding is accepted. | -| Wrong customer | Cross-tenant command or mismatched customer/VM. | Server-owned context plus local tuple equality before policy. | Deny and raise a high-severity redacted audit event; no prompt. | Mutate outer and signed customer/VM independently and together. All fail without matching enrollment. | -| Wrong Mac/device | Command targets another connector device or key. | Device, installation, and connector-key identity bound to enrollment and command authorization. | Deny; rotate/disable channel if broker claims disagree. | Structurally valid wrong-device fixture fails at runtime. | -| Duplicate process | Old Workbench connector and Mac Access both run, or two helpers race. | Atomic local leader lease plus one broker compare-and-swap that swaps the legacy and prepared grant/lease; signed identity/liveness checks. | Both clients deny work during ambiguity and never unlink/kill an unknown owner. | Launch/race/upgrade/reverse-handoff tests show one listener/channel/grant/audit writer. | -| Local untrusted client | Same-user malware steals token or connects to socket/XPC. | XPC audit token, `SecCode`, team/anchor, identifier, hardened runtime, designated requirement, per-connection nonce/epoch. | Reject connection before decoding mutation; no status details beyond generic denial. | Unsigned, ad-hoc, wrong-team, renderer, shell child, stale PID, and stolen-token tests. | -| Host interface escape | Renderer, Electron, shell child, or stale embedded process invokes/replays a core operation. | Helper-created inherited private channel; fixed host schema; host-session ID; monotonic sequence; expected policy epoch; no HTTP/public socket/generic command. | Reject before state or native-port access; rotate host session; effective Off on channel ambiguity. | Unknown operation/field, wrong session, duplicate/reordered sequence, stale epoch, Electron import, and orphan tests. | -| Stolen pairing code | Attacker claims a copied public code. | High-entropy one-time code, short expiry, rate limit, atomic claim, installation nonce, local customer/device confirmation before state transition. | Claim expires/locks; pairing remains Off; rotate code. | Reuse, concurrent claim, expired code, wrong installation, wrong customer, and dismissed confirmation. | -| Revoked grant | Broker or local revoke occurs while commands are queued/in flight. | Local revocation tombstone and policy epoch checked before prompt, before actuation, and at safe cancellation boundaries. | Block new work synchronously; clear queue/approvals; close channel; erase active credential. | Race revoke against queued, prompted, executing, reconnecting, and offline commands. | -| Offline broker | Existing channel drops but queued authority remains. | Monotonic authority deadline; disconnect barrier; no offline extension. | New remote actuation denied. Local status, stop, revoke, kill, and audit remain available. | Disconnect before/after prompt and execution; no command starts after barrier. | -| Helper replacement | Attacker or stale updater swaps helper binary/service. | Signed embedding, designated requirement pin, code validity, expected path plus identity, versioned Keychain credential group, update handoff protocol. | Refuse leadership/Keychain/channel; effective Off; show repair path. | Replace, ad-hoc sign, wrong team, moved path, old signed version, and partial update tests. | -| Downgrade | Old but validly signed app reads new state or reacquires credentials. | Broker rejects missing/below-floor immutable build identity; critical floor rotation creates a new credential/access-group epoch and revokes the old credential; rollback names exact target. | Refuse transport/Keychain leadership and remain Off; preserve state for compatible recovery. | Install old signed builds below/above the floor and interrupt every credential rotation/update/rollback transition. | -| Crash recovery | Full Access or stale approval silently resumes. | Runtime-instance-bound Full Access confirmation; ephemeral approval store; policy epoch increment on recovery. | Effective Ask Every Time or Off; queue and approvals cleared. | Kill -9 menu/helper/Python at every transition and relaunch. | -| Request tampering | Change command body after approval/signature. | Canonical request digest in command authorization and local approval; recompute immediately before execution. | Deny without executing; invalidate approval. | Mutate every approved field, target snapshot/path, capability, and encoding. | -| Malicious/compromised renderer | Invoke generic native IPC or obtain secrets. | Main-process allowlist and redaction; authenticated helper accepts only signed main processes and fixed actions. | Deny; renderer gets generic code and audit ID only. | XSS/DevTools-like calls cannot reach raw XPC, CUA, Keychain, channel, or logs. | -| Python compromise | Planner process reads secrets or calls CUA directly. | Sandboxed/minimal environment; no secret FDs; native ports; normalized bounded messages; helper revalidates output. | Terminate Python; effective Off for command path; helper and local controls remain available. | Python emits unknown action, oversized payload, forged result, path traversal, or direct framework attempt. | -| Secret/log leakage | Token, typed text, screenshot, AX tree, path, env, or address enters audit/support logs. | Redaction schema denylist plus allowlisted evidence; bounded errors; secret scan. | Reject audit event/receipt; actuation denied if decision cannot be recorded safely. | Negative fixture and recursive scans over logs, crashes, diagnostics, issue packets. | -| Audit deletion/tamper | User malware truncates or edits local audit. | Owner-only files, append-only API, contract-required monotonic sequence/previous-record/record digests, optional broker receipt correlation. | Mark chain broken, effective Off, preserve evidence, require repair. | Edit/delete/reorder records and verify detection before next action. | -| Audit unavailable | Disk full, permissions wrong, corruption, I/O failure. | Pre-execution durable decision write and health check. | Effective Off; no actuation; minimal local blocker if possible. | Fault injection for open/write/fsync/rename/disk-full failures. | -| Kill-switch race | Command starts while local kill is activating. | In-memory atomic deny barrier first; policy epoch rotation; checks before prompt and actuation; safe cancellation. | No new action after barrier; in-flight action stops at safe boundary; channel cleanup best effort. | High-rate command stream while kill switch is toggled. | -| Approval confusion | Prompt label differs from executable target or is reused. | Approval binds selected tuple, capability, normalized target, request digest, policy epoch, and expiry. | Dismiss/change/timeout denies; changed target requires new prompt. | TOCTOU mutation between preview, prompt, and execution. | -| Broker key rotation | Connector accepts an old/unknown signing key or bricks on valid rotation. | Signed keyset with activation/retirement windows; key ID pinning; rollback plan. | Unknown/retired key denies; never fall back unsigned. | Overlap, early/late rotation, rollback, missing keyset, and clock skew. | -| Clock manipulation | Wall clock extends expiry or breaks ordering. | Signed absolute time plus monotonic elapsed deadline established on receipt; bounded clock skew. | Suspicious jump denies current channel and reconnects. | Move wall clock forward/back during queued/prompted commands. | -| Oversized/flooded input | Memory/CPU/disk exhaustion via WebSocket or XPC. | Frame limits, rate limits, bounded queue, backpressure, timeouts, bounded audit text. | Drop/reject and eventually close abusive channel; local kill remains responsive. | Oversized, fragmented, high-rate, and slow-loris scenarios. | -| Direct-network bypass | Runtime discovers a Mac URL/IP/token or public listener. | No target listener; outbound relay only; firewall/listener checks; no renderer/broker payload contains endpoint. | Pairing/transport blocked if an unauthorized listener is detected. | `lsof`/packet evidence on pristine and coexistence Macs; prompt/output secret scans. | -| Update split-brain | New menu with old helper or vice versa accepts incompatible authority. | Compatibility handshake, signed build identity, atomic handoff, access Off during update. | Refuse channel and mutations; rollback to exact signed compatible pair. | Interrupt update at each file/service transition. | -| Orphan cleanup abuse | Installer kills/deletes unrelated same-name process/file. | Match recorded path plus signed identity and installation ID; never trust name/PID alone. | Leave unknown process untouched and show support blocker. | Decoy binaries/processes and relocated prior versions. | -| Workbench fallback takeover | Workbench silently starts its legacy connector while Mac Access is leader. | Exclusive ownership status and #704 client cutover; legacy start guarded by proven absence/relinquish. | Workbench shows Mac Access status or blocker; no fallback start. | Start both apps in every order, crash either, upgrade/downgrade each. | +| Threat | Attack or failure | Required prevention/detection | Fail-closed response | Proof gate | +| ------------------------------ | ------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- | +| Replay | Reuse context ID, session/channel generation, command ID, nonce, sequence, approval, or result receipt. | Complete RFC 8785 signed command payload; bounded replay cache persisted across reconnect/helper restart until maximum expiry; single-use approval. | Deny before prompt/audit decision; record `command_replayed`; never extend expiry. | Submit the same valid envelope twice and across reconnect/restart. Second and later attempts fail. | +| Stale binding | Use old binding ID/version after selection changes. | Compare signed context, outer tuple, Keychain enrollment, and current policy epoch exactly. | Invalidate queue/approvals; close old channel; effective `Off` until new binding is confirmed. | Old and new bindings interleaved; only current binding is accepted. | +| Wrong customer | Cross-tenant command or mismatched customer/VM. | Server-owned context plus local tuple equality before policy. | Deny and raise a high-severity redacted audit event; no prompt. | Mutate outer and signed customer/VM independently and together. All fail without matching enrollment. | +| Wrong Mac/device | Command targets another connector device or key. | Device, installation, and connector-key identity bound to enrollment and command authorization. | Deny; rotate/disable channel if broker claims disagree. | Structurally valid wrong-device fixture fails at runtime. | +| Duplicate process | Old Workbench connector and Mac Access both run, or two helpers race. | Atomic local leader lease plus one broker compare-and-swap that swaps the legacy and prepared grant/lease; signed identity/liveness checks. | Both clients deny work during ambiguity and never unlink/kill an unknown owner. | Launch/race/upgrade/pre-commit-abort/post-commit-same-identity-repair tests show one listener/channel/grant/audit writer. | +| Local untrusted client | Same-user malware steals token or connects to socket/XPC. | XPC audit token, `SecCode`, team/anchor, identifier, hardened runtime, designated requirement, per-connection nonce/epoch. | Reject connection before decoding mutation; no status details beyond generic denial. | Unsigned, ad-hoc, wrong-team, renderer, shell child, stale PID, and stolen-token tests. | +| Host interface escape | Renderer, Electron, shell child, or stale embedded process invokes/replays a core operation. | Helper-created inherited private channel; fixed host schema; host-session ID; monotonic sequence; expected policy epoch; no HTTP/public socket/generic command. | Reject before state or native-port access; rotate host session; effective Off on channel ambiguity. | Unknown operation/field, wrong session, duplicate/reordered sequence, stale epoch, Electron import, and orphan tests. | +| Stolen pairing code | Attacker claims a copied public code. | High-entropy one-time code, short expiry, rate limit, atomic claim, installation nonce, local customer/device confirmation before state transition. | Claim expires/locks; pairing remains Off; rotate code. | Reuse, concurrent claim, expired code, wrong installation, wrong customer, and dismissed confirmation. | +| Revoked grant | Broker or local revoke occurs while commands are queued/in flight. | Local revocation tombstone and policy epoch checked before prompt, before actuation, and at safe cancellation boundaries. | Block new work synchronously; clear queue/approvals; close channel; erase active credential. | Race revoke against queued, prompted, executing, reconnecting, and offline commands. | +| Offline broker | Existing channel drops but queued authority remains. | Monotonic authority deadline; disconnect barrier; no offline extension. | New remote actuation denied. Local status, stop, revoke, kill, and audit remain available. | Disconnect before/after prompt and execution; no command starts after barrier. | +| Helper replacement | Attacker or stale updater swaps helper binary/service. | Signed embedding, designated requirement pin, code validity, expected path plus identity, versioned Keychain credential group, update handoff protocol. | Refuse leadership/Keychain/channel; effective Off; show repair path. | Replace, ad-hoc sign, wrong team, moved path, old signed version, and partial update tests. | +| Downgrade | Old but validly signed app reads new state or reacquires credentials. | Broker rejects missing/below-floor immutable build identity; critical floor rotation creates a new credential/access-group epoch and revokes the old credential; rollback names exact target. | Refuse transport/Keychain leadership and remain Off; preserve state for compatible recovery. | Install old signed builds below/above the floor and interrupt every credential rotation/update/rollback transition. | +| Crash recovery | Full Access or stale approval silently resumes. | Runtime-instance-bound Full Access confirmation; ephemeral approval store; policy epoch increment on recovery. | Effective Ask Every Time or Off; queue and approvals cleared. | Kill -9 menu/helper/Python at every transition and relaunch. | +| Request tampering | Change command body after approval/signature. | Canonical request digest in command authorization and local approval; recompute immediately before execution. | Deny without executing; invalidate approval. | Mutate every approved field, target snapshot/path, capability, and encoding. | +| Malicious/compromised renderer | Invoke generic native IPC or obtain secrets. | Main-process allowlist and redaction; authenticated helper accepts only signed main processes and fixed actions. | Deny; renderer gets generic code and audit ID only. | XSS/DevTools-like calls cannot reach raw XPC, CUA, Keychain, channel, or logs. | +| Python compromise | Planner process reads secrets or calls CUA directly. | Sandboxed/minimal environment; no secret FDs; native ports; normalized bounded messages; helper revalidates output. | Terminate Python; effective Off for command path; helper and local controls remain available. | Python emits unknown action, oversized payload, forged result, path traversal, or direct framework attempt. | +| Secret/log leakage | Token, typed text, screenshot, AX tree, path, env, or address enters audit/support logs. | Redaction schema denylist plus allowlisted evidence; bounded errors; secret scan. | Reject audit event/receipt; actuation denied if decision cannot be recorded safely. | Negative fixture and recursive scans over logs, crashes, diagnostics, issue packets. | +| Audit deletion/tamper | User malware truncates or edits local audit. | Owner-only files, append-only API, contract-required monotonic sequence/previous-record/record digests, optional broker receipt correlation. | Mark chain broken, effective Off, preserve evidence, require repair. | Edit/delete/reorder records and verify detection before next action. | +| Audit unavailable | Disk full, permissions wrong, corruption, I/O failure. | Pre-execution durable decision write and health check. | Effective Off; no actuation; minimal local blocker if possible. | Fault injection for open/write/fsync/rename/disk-full failures. | +| Kill-switch race | Command starts while local kill is activating. | In-memory atomic deny barrier first; policy epoch rotation; checks before prompt and actuation; safe cancellation. | No new action after barrier; in-flight action stops at safe boundary; channel cleanup best effort. | High-rate command stream while kill switch is toggled. | +| Approval confusion | Prompt label differs from executable target or is reused. | Approval binds selected tuple, capability, normalized target, request digest, policy epoch, and expiry. | Dismiss/change/timeout denies; changed target requires new prompt. | TOCTOU mutation between preview, prompt, and execution. | +| Broker key rotation | Connector accepts an old/unknown signing key or bricks on valid rotation. | Signed keyset with activation/retirement windows; key ID pinning; rollback plan. | Unknown/retired key denies; never fall back unsigned. | Overlap, early/late rotation, rollback, missing keyset, and clock skew. | +| Clock manipulation | Wall clock extends expiry or breaks ordering. | Signed absolute time plus monotonic elapsed deadline established on receipt; bounded clock skew. | Suspicious jump denies current channel and reconnects. | Move wall clock forward/back during queued/prompted commands. | +| Oversized/flooded input | Memory/CPU/disk exhaustion via WebSocket or XPC. | Frame limits, rate limits, bounded queue, backpressure, timeouts, bounded audit text. | Drop/reject and eventually close abusive channel; local kill remains responsive. | Oversized, fragmented, high-rate, and slow-loris scenarios. | +| Direct-network bypass | Runtime discovers a Mac URL/IP/token or public listener. | No target listener; outbound relay only; firewall/listener checks; no renderer/broker payload contains endpoint. | Pairing/transport blocked if an unauthorized listener is detected. | `lsof`/packet evidence on pristine and coexistence Macs; prompt/output secret scans. | +| Update split-brain | New menu with old helper or vice versa accepts incompatible authority. | Compatibility handshake, signed build identity, atomic handoff, access Off during update. | Refuse channel and mutations; rollback to exact signed compatible pair. | Interrupt update at each file/service transition. | +| Orphan cleanup abuse | Installer kills/deletes unrelated same-name process/file. | Match recorded path plus signed identity and installation ID; never trust name/PID alone. | Leave unknown process untouched and show support blocker. | Decoy binaries/processes and relocated prior versions. | +| Workbench fallback takeover | Workbench silently starts its legacy connector after Mac Access cutover or when Mac Access is absent/incompatible. | Before atomic cutover, the shipped legacy owner may continue only as the still-active prior owner. After A5 commit, the Workbench connector start path is removed; absence/incompatibility produces install/repair blocker or same-identity launch only. | Workbench shows Mac Access status or blocker; no second-identity fallback start. | Start both apps in every order, remove/invalidate Mac Access, crash either, upgrade/downgrade each. | ## Pairing analysis -The pairing code is public by design and therefore is not a long-term secret. It is only a short-lived rendezvous capability. Security comes from atomic one-time claim, local installation binding, broker authentication, selected customer/device confirmation in the native UI, and Keychain device-key issuance after confirmation. +The pairing code is public by design and therefore is not a long-term secret. It is only a short-lived rendezvous capability. Dashboard [#669](https://github.com/electricsheephq/electric-sheep-website-dashboard-6158a244/issues/669) owns issuance and atomic redemption. Security comes from hashed server storage, bounded attempts, short expiry, cancellation, atomic one-time claim, local installation-key possession, broker authentication, selected customer/device confirmation in the native UI, and Keychain device-key issuance after confirmation. The pairing UI must display the selected customer and this Mac's human-readable device name without trusting text supplied by the remote runtime. It must not display or copy raw customer IDs by default, connector URLs, IPs, ports, tokens, SSH/VNC/CDP text, or broker JSON. A code claim never changes mode directly to Full Access; explicit consent moves unpaired Off to paired Ask Every Time. ## Local code-identity analysis -The current PR #697 helper IPC uses an owner-only Unix socket, file capability token, and same-UID peer check. Those controls reduce accidental access but do not distinguish Workbench main from any other process owned by the same user that can obtain the token. Environment-provided bundle/path attribution also does not prove the caller's signature. +The canonical Workbench bridge through #709 uses an owner-only Unix socket, file capability token, and same-UID peer check. Those controls reduce accidental access but do not distinguish Workbench main from any other process owned by the same user that can obtain the token. Environment-provided bundle/path attribution also does not prove the caller's signature. -The target XPC boundary uses the kernel-provided audit token and Security.framework code validation for the exact connection process. PID, path, bundle ID, or environment is supplemental evidence only. Designated requirements must bind the expected release team/anchor and signing identifier. Debug/ad-hoc builds use an explicit development requirement and cannot read production Keychain items or connect to production relay authority. +The target XPC boundary uses the kernel-provided audit token and Security.framework code validation for the exact connection process. PID, path, bundle ID, or environment is supplemental evidence only. Production designated requirements bind Team ID `TC6MS3T6NN`, the Apple generic anchor, and the exact app/helper/connector signing identifier; their frozen canonical UTF-8 requirement digests are enforced by the contract allowlist, not accepted from a caller. The app/connector have no production credential group, only the nested helper has the epoch-specific group, and no production target has debug/JIT/library-validation/inbound-server authority. Debug/ad-hoc builds use explicit disjoint development requirements and cannot read production Keychain items or connect to production relay authority. Production Keychain items use an epoch group derived from the `com.evaos.mac-access.credentials` base and the `com.evaos.mac-access.connector-credential` service, are non-synchronizing and ThisDeviceOnly/WhenUnlocked, and are granted only to the production helper target accepted for that security epoch. Development uses the disjoint `com.evaos.mac-access.development.credentials` base. Menu, Workbench, Python, debug, old, and replacement-helper denial is a required installed-artifact test. Because old signed code cannot enforce a new local rule, ws-proxy #73 must reject old build/security identities and old credentials; critical rotations re-enroll into a new access group before revoking/deleting the old credential. A valid same-team signature without the exact broker-accepted build, credential epoch, helper requirement, and protected security floor is insufficient. ## TCC analysis -The frozen product identity is `com.evaos.mac-access`; the embedded helper service is `com.evaos.mac-access.helper`. The native helper is the only process permitted to call CUA frameworks, but actual macOS TCC responsibility must be verified on signed installed artifacts. Until that evidence exists, the source design must not claim that prompts or grants attach to the intended identity. +The frozen product identity is `com.evaos.mac-access`; the embedded helper service is `com.evaos.mac-access.helper`; the approved Team ID is `TC6MS3T6NN`. The installed app main executable is the expected TCC responsible executable shown to the user, while the helper is the only process permitted to call CUA frameworks. Actual macOS TCC responsibility and helper attribution must be verified on signed installed artifacts. Until that evidence exists, the source design must not claim that prompts or grants attach to the intended identity. Workbench and Python must not retain direct CUA fallback after cutover. If native helper permission is missing, the only allowed response is a truthful blocked status and local System Settings handoff. ## Remote authority analysis -The ws-proxy #69 execution context signs runtime, customer, VM, binding ID/version, issue/expiry, key ID, and context ID. It intentionally does not sign a command. Mac Access therefore also requires the ws-proxy #73 Ed25519 authorization over the RFC 8785 canonical `command_authority_payload.v1`. That payload includes the entire selected tuple, session/channel generation, exact request/context digests, capability, nonce/sequence, and contained authority interval. The committed golden vector freezes canonical bytes, digest, public key, and signature. Verifying only #69 would permit a trusted runtime boundary to substitute a different command within the same short-lived context. +The ws-proxy #69 execution context signs runtime, customer, VM, binding ID/version, issue/expiry, key ID, and context ID. It intentionally does not sign a command. Mac Access therefore also requires the ws-proxy #73 Ed25519 authorization over the RFC 8785 canonical `command_authority_payload.v1`. That payload includes the entire selected tuple and grant expiry, current policy epoch, session/channel generation, exact request/context digests, capability, nonce/sequence, and contained authority interval. The committed golden vector freezes canonical bytes, digest, public key, and signature, and negative tests reject payload, digest, signature, and key substitution. Verifying only #69 would permit a trusted runtime boundary to substitute a different command within the same short-lived context. The connector never treats TLS, channel authentication, or a valid signature alone as permission to act. Local selected-binding equality, access mode, approval, audit health, revocation, pause, and kill switch are independent mandatory checks. diff --git a/packages/mac-connector-core/README.md b/packages/mac-connector-core/README.md index f72ccbda00..6d97a48674 100644 --- a/packages/mac-connector-core/README.md +++ b/packages/mac-connector-core/README.md @@ -2,6 +2,6 @@ This is the single reusable connector source boundary defined by [evaOS-GUI#699](https://github.com/100yenadmin/evaOS-GUI/issues/699). -PR #699 owns only the versioned contracts and cross-language JSON fixtures under `contracts/v1`, including the private non-Electron core host API. Moving the PR #697 Python source into the bounded `adapters`, `contracts`, `host`, `persistence`, and `policy` subpackages, adding native Swift ports, and integrating consumers belong to downstream child issues after their dependency gates are satisfied. +PR #699 owns only the versioned contracts and cross-language JSON fixtures under `contracts/v1`, including the private non-Electron core host API. Moving the canonical Workbench Python source through PR #709 into the bounded `adapters`, `contracts`, `host`, `persistence`, and `policy` subpackages, adding native Swift ports, and integrating consumers belong to downstream child issues after their dependency gates are satisfied. The archived `electricsheephq/evaos-desktop-bridge` repository is provenance only and must not be restored as source, build, or runtime truth. diff --git a/packages/mac-connector-core/contracts/v1/fixtures/invalid/identity.json b/packages/mac-connector-core/contracts/v1/fixtures/invalid/identity.json index 4caa012c81..4e5d859bff 100644 --- a/packages/mac-connector-core/contracts/v1/fixtures/invalid/identity.json +++ b/packages/mac-connector-core/contracts/v1/fixtures/invalid/identity.json @@ -125,6 +125,38 @@ "expected_error": "host_sequence_replayed", "required_runtime_rejection": "Persist the last accepted sequence for the host-session lifetime and reject duplicate or lower sequence values before dispatch." }, + { + "id": "wrong-team-local-client", + "threat": "local_untrusted_client", + "contract": "authenticated_local_action", + "base_fixture": "../valid/local-action.json", + "mutations": [ + { + "operation": "set", + "pointer": "/peer/team_id", + "value": "ATTACKER00" + } + ], + "expected_stage": "schema", + "expected_error": "peer_team_identifier_mismatch", + "required_runtime_rejection": null + }, + { + "id": "forged-local-client-requirement", + "threat": "local_untrusted_client", + "contract": "authenticated_local_action", + "base_fixture": "../valid/local-action.json", + "mutations": [ + { + "operation": "set", + "pointer": "/peer/designated_requirement_sha256", + "value": "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd" + } + ], + "expected_stage": "schema", + "expected_error": "peer_designated_requirement_hash_mismatch", + "required_runtime_rejection": null + }, { "id": "untrusted-local-client", "threat": "local_untrusted_client", @@ -172,5 +204,53 @@ "expected_stage": "schema", "expected_error": "helper_identity_mismatch", "required_runtime_rejection": null + }, + { + "id": "forged-app-requirement", + "threat": "helper_replacement", + "contract": "local_status", + "base_fixture": "../valid/local-status.json", + "mutations": [ + { + "operation": "set", + "pointer": "/leader/app_designated_requirement_sha256", + "value": "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd" + } + ], + "expected_stage": "schema", + "expected_error": "app_requirement_mismatch", + "required_runtime_rejection": null + }, + { + "id": "forged-helper-requirement", + "threat": "helper_replacement", + "contract": "local_status", + "base_fixture": "../valid/local-status.json", + "mutations": [ + { + "operation": "set", + "pointer": "/leader/helper_designated_requirement_sha256", + "value": "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd" + } + ], + "expected_stage": "schema", + "expected_error": "helper_requirement_mismatch", + "required_runtime_rejection": null + }, + { + "id": "forged-connector-requirement", + "threat": "helper_replacement", + "contract": "local_status", + "base_fixture": "../valid/local-status.json", + "mutations": [ + { + "operation": "set", + "pointer": "/leader/connector_designated_requirement_sha256", + "value": "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd" + } + ], + "expected_stage": "schema", + "expected_error": "connector_requirement_mismatch", + "required_runtime_rejection": null } ] diff --git a/packages/mac-connector-core/contracts/v1/fixtures/invalid/policy.json b/packages/mac-connector-core/contracts/v1/fixtures/invalid/policy.json index 8c8afebe46..dffb37d261 100644 --- a/packages/mac-connector-core/contracts/v1/fixtures/invalid/policy.json +++ b/packages/mac-connector-core/contracts/v1/fixtures/invalid/policy.json @@ -31,6 +31,22 @@ "expected_error": "rollback_target_mismatch", "required_runtime_rejection": null }, + { + "id": "rollback-wrong-source", + "threat": "signed_downgrade", + "contract": "local_status", + "base_fixture": "../valid/local-status.json", + "mutations": [ + { + "operation": "set", + "pointer": "/relay_authorization/verified_pre_rollback_source/source_commit", + "value": "cccccccccccccccccccccccccccccccccccccccc" + } + ], + "expected_stage": "schema", + "expected_error": "rollback_source_mismatch", + "required_runtime_rejection": null + }, { "id": "expired-rollback-authorization", "threat": "signed_downgrade", @@ -57,6 +73,84 @@ "expected_error": "stop_pending_authority_not_invalidated", "required_runtime_rejection": null }, + { + "id": "pause-with-pending-authority", + "threat": "stop_race", + "contract": "access_transition", + "base_fixture": "../valid/access-transition-stop.json", + "mutations": [ + { "operation": "set", "pointer": "/event", "value": "pause" }, + { "operation": "set", "pointer": "/invalidated_pending_authority", "value": false } + ], + "expected_stage": "schema", + "expected_error": "destructive_transition_preserves_authority", + "required_runtime_rejection": null + }, + { + "id": "revoke-with-pending-authority", + "threat": "stop_race", + "contract": "access_transition", + "base_fixture": "../valid/access-transition-grant-expired.json", + "mutations": [ + { "operation": "set", "pointer": "/event", "value": "revoke" }, + { "operation": "set", "pointer": "/safe_cancellation_requested", "value": false } + ], + "expected_stage": "schema", + "expected_error": "destructive_transition_preserves_authority", + "required_runtime_rejection": null + }, + { + "id": "kill-switch-with-pending-authority", + "threat": "kill_switch_race", + "contract": "access_transition", + "base_fixture": "../valid/access-transition-stop.json", + "mutations": [ + { "operation": "set", "pointer": "/event", "value": "kill_switch" }, + { "operation": "set", "pointer": "/invalidated_pending_authority", "value": false } + ], + "expected_stage": "schema", + "expected_error": "destructive_transition_preserves_authority", + "required_runtime_rejection": null + }, + { + "id": "grant-expiry-with-pending-authority", + "threat": "revoked_grant", + "contract": "access_transition", + "base_fixture": "../valid/access-transition-grant-expired.json", + "mutations": [{ "operation": "set", "pointer": "/invalidated_pending_authority", "value": false }], + "expected_stage": "schema", + "expected_error": "destructive_transition_preserves_authority", + "required_runtime_rejection": null + }, + { + "id": "expired-grant-remains-active", + "threat": "revoked_grant", + "contract": "local_status", + "base_fixture": "../valid/local-status.json", + "mutations": [ + { + "operation": "set", + "pointer": "/access/binding/grant_expires_at", + "value": "2026-07-15T08:00:00Z" + } + ], + "expected_stage": "schema", + "expected_error": "grant_expiry_not_fail_closed", + "required_runtime_rejection": null + }, + { + "id": "stale-command-policy-epoch", + "threat": "replay", + "contract": "broker_control", + "base_fixture": "../valid/broker-control.json", + "mutations": [ + { "operation": "set", "pointer": "/policy_epoch", "value": 6 }, + { "operation": "set", "pointer": "/authorization/payload/policy_epoch", "value": 6 } + ], + "expected_stage": "runtime", + "expected_error": "command_policy_epoch_stale", + "required_runtime_rejection": "Reject before prompt or actuation unless the signed and delivered policy epoch equals the helper's current protected policy epoch and the host request expected epoch." + }, { "id": "crash-restores-full-access", "threat": "crash_recovery", diff --git a/packages/mac-connector-core/contracts/v1/fixtures/valid/access-state.json b/packages/mac-connector-core/contracts/v1/fixtures/valid/access-state.json index 9a5093c1db..20e319fdb5 100644 --- a/packages/mac-connector-core/contracts/v1/fixtures/valid/access-state.json +++ b/packages/mac-connector-core/contracts/v1/fixtures/valid/access-state.json @@ -4,6 +4,8 @@ "state_security_epoch": 1, "minimum_reader_security_epoch": 1, "minimum_writer_security_epoch": 1, + "minimum_reader_schema_version": 1, + "minimum_writer_schema_version": 1, "policy_epoch": 7, "pairing_state": "paired", "configured_mode": "ask_every_time", @@ -22,6 +24,7 @@ "runtime": "openclaw", "binding_id": "binding-01", "binding_version": "v3", + "grant_expires_at": "2026-07-15T09:00:00Z", "connector_installation_id": "install-01", "connector_key_id": "mac-key-01", "binding_fingerprint_sha256": "1111111111111111111111111111111111111111111111111111111111111111" diff --git a/packages/mac-connector-core/contracts/v1/fixtures/valid/access-transition-grant-expired.json b/packages/mac-connector-core/contracts/v1/fixtures/valid/access-transition-grant-expired.json new file mode 100644 index 0000000000..8e8451222a --- /dev/null +++ b/packages/mac-connector-core/contracts/v1/fixtures/valid/access-transition-grant-expired.json @@ -0,0 +1,65 @@ +{ + "schema_version": "evaos.mac_access.access_transition.v1", + "transition_id": "transition-grant-expired-01", + "event": "grant_expired", + "explicit_user_consent": false, + "invalidated_pending_authority": true, + "safe_cancellation_requested": true, + "target_mode": null, + "from": { + "schema_version": "evaos.mac_access.access_state.v1", + "runtime_instance_id": "runtime-instance-01", + "state_security_epoch": 1, + "minimum_reader_security_epoch": 1, + "minimum_writer_security_epoch": 1, + "minimum_reader_schema_version": 1, + "minimum_writer_schema_version": 1, + "policy_epoch": 7, + "pairing_state": "paired", + "configured_mode": "ask_every_time", + "effective_mode": "ask_every_time", + "paused": false, + "kill_switch": false, + "local_confirmation_required": false, + "confirmed_runtime_instance_id": null, + "confirmed_policy_epoch": null, + "confirmed_binding_fingerprint_sha256": null, + "binding": { + "customer_id": "customer-01", + "customer_vm_id": "vm-01", + "device_id": "mac-01", + "grant_id": "grant-01", + "runtime": "openclaw", + "binding_id": "binding-01", + "binding_version": "v3", + "grant_expires_at": "2026-07-15T08:01:00Z", + "connector_installation_id": "install-01", + "connector_key_id": "mac-key-01", + "binding_fingerprint_sha256": "1111111111111111111111111111111111111111111111111111111111111111" + }, + "changed_at": "2026-07-15T08:00:00Z", + "reason_code": "pairing_confirmed" + }, + "to": { + "schema_version": "evaos.mac_access.access_state.v1", + "runtime_instance_id": "runtime-instance-01", + "state_security_epoch": 1, + "minimum_reader_security_epoch": 1, + "minimum_writer_security_epoch": 1, + "minimum_reader_schema_version": 1, + "minimum_writer_schema_version": 1, + "policy_epoch": 8, + "pairing_state": "revoked", + "configured_mode": "off", + "effective_mode": "off", + "paused": false, + "kill_switch": false, + "local_confirmation_required": false, + "confirmed_runtime_instance_id": null, + "confirmed_policy_epoch": null, + "confirmed_binding_fingerprint_sha256": null, + "binding": null, + "changed_at": "2026-07-15T08:01:00Z", + "reason_code": "grant_expired" + } +} diff --git a/packages/mac-connector-core/contracts/v1/fixtures/valid/access-transition-stop.json b/packages/mac-connector-core/contracts/v1/fixtures/valid/access-transition-stop.json index c26356aeb6..eb2c5de9c6 100644 --- a/packages/mac-connector-core/contracts/v1/fixtures/valid/access-transition-stop.json +++ b/packages/mac-connector-core/contracts/v1/fixtures/valid/access-transition-stop.json @@ -12,6 +12,8 @@ "state_security_epoch": 1, "minimum_reader_security_epoch": 1, "minimum_writer_security_epoch": 1, + "minimum_reader_schema_version": 1, + "minimum_writer_schema_version": 1, "policy_epoch": 7, "pairing_state": "paired", "configured_mode": "ask_every_time", @@ -30,6 +32,7 @@ "runtime": "openclaw", "binding_id": "binding-01", "binding_version": "v3", + "grant_expires_at": "2026-07-15T09:00:00Z", "connector_installation_id": "install-01", "connector_key_id": "mac-key-01", "binding_fingerprint_sha256": "1111111111111111111111111111111111111111111111111111111111111111" @@ -43,6 +46,8 @@ "state_security_epoch": 1, "minimum_reader_security_epoch": 1, "minimum_writer_security_epoch": 1, + "minimum_reader_schema_version": 1, + "minimum_writer_schema_version": 1, "policy_epoch": 8, "pairing_state": "paired", "configured_mode": "ask_every_time", @@ -61,6 +66,7 @@ "runtime": "openclaw", "binding_id": "binding-01", "binding_version": "v3", + "grant_expires_at": "2026-07-15T09:00:00Z", "connector_installation_id": "install-01", "connector_key_id": "mac-key-01", "binding_fingerprint_sha256": "1111111111111111111111111111111111111111111111111111111111111111" diff --git a/packages/mac-connector-core/contracts/v1/fixtures/valid/access-transition.json b/packages/mac-connector-core/contracts/v1/fixtures/valid/access-transition.json index e3f914c3eb..91955fc92f 100644 --- a/packages/mac-connector-core/contracts/v1/fixtures/valid/access-transition.json +++ b/packages/mac-connector-core/contracts/v1/fixtures/valid/access-transition.json @@ -12,6 +12,8 @@ "state_security_epoch": 1, "minimum_reader_security_epoch": 1, "minimum_writer_security_epoch": 1, + "minimum_reader_schema_version": 1, + "minimum_writer_schema_version": 1, "policy_epoch": 6, "pairing_state": "unpaired", "configured_mode": "off", @@ -32,6 +34,8 @@ "state_security_epoch": 1, "minimum_reader_security_epoch": 1, "minimum_writer_security_epoch": 1, + "minimum_reader_schema_version": 1, + "minimum_writer_schema_version": 1, "policy_epoch": 7, "pairing_state": "paired", "configured_mode": "ask_every_time", @@ -50,6 +54,7 @@ "runtime": "openclaw", "binding_id": "binding-01", "binding_version": "v3", + "grant_expires_at": "2026-07-15T09:00:00Z", "connector_installation_id": "install-01", "connector_key_id": "mac-key-01", "binding_fingerprint_sha256": "1111111111111111111111111111111111111111111111111111111111111111" diff --git a/packages/mac-connector-core/contracts/v1/fixtures/valid/broker-control.json b/packages/mac-connector-core/contracts/v1/fixtures/valid/broker-control.json index 269edf6e0b..37f78bcab0 100644 --- a/packages/mac-connector-core/contracts/v1/fixtures/valid/broker-control.json +++ b/packages/mac-connector-core/contracts/v1/fixtures/valid/broker-control.json @@ -7,6 +7,7 @@ "issued_at": "2026-07-15T08:01:00Z", "expires_at": "2026-07-15T08:01:45Z", "sequence": 42, + "policy_epoch": 7, "nonce": "YnJva2VyLW5vbmNlLTAx", "binding": { "customer_id": "customer-01", @@ -16,6 +17,7 @@ "runtime": "openclaw", "binding_id": "binding-01", "binding_version": "v3", + "grant_expires_at": "2026-07-15T09:00:00Z", "connector_installation_id": "install-01", "connector_key_id": "mac-key-01", "binding_fingerprint_sha256": "1111111111111111111111111111111111111111111111111111111111111111" @@ -59,6 +61,7 @@ "issued_at": "2026-07-15T08:01:00Z", "expires_at": "2026-07-15T08:01:45Z", "sequence": 42, + "policy_epoch": 7, "nonce": "YnJva2VyLW5vbmNlLTAx", "binding": { "customer_id": "customer-01", @@ -68,6 +71,7 @@ "runtime": "openclaw", "binding_id": "binding-01", "binding_version": "v3", + "grant_expires_at": "2026-07-15T09:00:00Z", "connector_installation_id": "install-01", "connector_key_id": "mac-key-01", "binding_fingerprint_sha256": "1111111111111111111111111111111111111111111111111111111111111111" @@ -76,8 +80,8 @@ "capability": "customer_mac.desktop_click", "request_digest_sha256": "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd" }, - "payload_sha256": "5b53ab9ae88fd29ba82759bb7d6b0d900ce0367f9fe65314267a6667751dadf4", + "payload_sha256": "fb84c2148a0badf2eb45d44d5b61ef0e4b9dea1951d04d1686aaebd8cd214088", "key_id": "broker-command-key-01", - "signature_base64url": "LY7Liy_zR2J4TexXTYYNe-wgoZ7EBee9aMGVk32M0Cd65LqRQ3dH4sYeE540WBTIEoZxgVhmqWhdPnn0i3DQBA" + "signature_base64url": "oNz1ORiixjkjMJDtoidzZJh3BL1QyAIRBgEPjpcRiLMdsmlZWhoQSfQ3ZqepamtpEWOCreYcyRWqIpqwLTggCg" } } diff --git a/packages/mac-connector-core/contracts/v1/fixtures/valid/command-authority-golden.json b/packages/mac-connector-core/contracts/v1/fixtures/valid/command-authority-golden.json index cb55a643a6..fa8b0fc119 100644 --- a/packages/mac-connector-core/contracts/v1/fixtures/valid/command-authority-golden.json +++ b/packages/mac-connector-core/contracts/v1/fixtures/valid/command-authority-golden.json @@ -10,6 +10,7 @@ "issued_at": "2026-07-15T08:01:00Z", "expires_at": "2026-07-15T08:01:45Z", "sequence": 42, + "policy_epoch": 7, "nonce": "YnJva2VyLW5vbmNlLTAx", "binding": { "customer_id": "customer-01", @@ -19,6 +20,7 @@ "runtime": "openclaw", "binding_id": "binding-01", "binding_version": "v3", + "grant_expires_at": "2026-07-15T09:00:00Z", "connector_installation_id": "install-01", "connector_key_id": "mac-key-01", "binding_fingerprint_sha256": "1111111111111111111111111111111111111111111111111111111111111111" @@ -27,8 +29,8 @@ "capability": "customer_mac.desktop_click", "request_digest_sha256": "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd" }, - "canonical_payload_utf8": "{\"binding\":{\"binding_fingerprint_sha256\":\"1111111111111111111111111111111111111111111111111111111111111111\",\"binding_id\":\"binding-01\",\"binding_version\":\"v3\",\"connector_installation_id\":\"install-01\",\"connector_key_id\":\"mac-key-01\",\"customer_id\":\"customer-01\",\"customer_vm_id\":\"vm-01\",\"device_id\":\"mac-01\",\"grant_id\":\"grant-01\",\"runtime\":\"openclaw\"},\"capability\":\"customer_mac.desktop_click\",\"channel_generation_id\":\"channel-generation-01\",\"command_id\":\"command-01\",\"domain\":\"evaos.mac-access/command-authority/v1\",\"execution_context_sha256\":\"9999999999999999999999999999999999999999999999999999999999999999\",\"expires_at\":\"2026-07-15T08:01:45Z\",\"issued_at\":\"2026-07-15T08:01:00Z\",\"nonce\":\"YnJva2VyLW5vbmNlLTAx\",\"request_digest_sha256\":\"dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd\",\"schema_version\":\"evaos.mac_access.command_authority_payload.v1\",\"sequence\":42,\"session_id\":\"session-01\"}", - "payload_sha256": "5b53ab9ae88fd29ba82759bb7d6b0d900ce0367f9fe65314267a6667751dadf4", - "public_key_spki_base64url": "MCowBQYDK2VwAyEA7cmval-Chj6taXIbgRs1b_1mTMuGyjEnIValkKuKZfA", - "signature_base64url": "LY7Liy_zR2J4TexXTYYNe-wgoZ7EBee9aMGVk32M0Cd65LqRQ3dH4sYeE540WBTIEoZxgVhmqWhdPnn0i3DQBA" + "canonical_payload_utf8": "{\"binding\":{\"binding_fingerprint_sha256\":\"1111111111111111111111111111111111111111111111111111111111111111\",\"binding_id\":\"binding-01\",\"binding_version\":\"v3\",\"connector_installation_id\":\"install-01\",\"connector_key_id\":\"mac-key-01\",\"customer_id\":\"customer-01\",\"customer_vm_id\":\"vm-01\",\"device_id\":\"mac-01\",\"grant_expires_at\":\"2026-07-15T09:00:00Z\",\"grant_id\":\"grant-01\",\"runtime\":\"openclaw\"},\"capability\":\"customer_mac.desktop_click\",\"channel_generation_id\":\"channel-generation-01\",\"command_id\":\"command-01\",\"domain\":\"evaos.mac-access/command-authority/v1\",\"execution_context_sha256\":\"9999999999999999999999999999999999999999999999999999999999999999\",\"expires_at\":\"2026-07-15T08:01:45Z\",\"issued_at\":\"2026-07-15T08:01:00Z\",\"nonce\":\"YnJva2VyLW5vbmNlLTAx\",\"policy_epoch\":7,\"request_digest_sha256\":\"dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd\",\"schema_version\":\"evaos.mac_access.command_authority_payload.v1\",\"sequence\":42,\"session_id\":\"session-01\"}", + "payload_sha256": "fb84c2148a0badf2eb45d44d5b61ef0e4b9dea1951d04d1686aaebd8cd214088", + "public_key_spki_base64url": "MCowBQYDK2VwAyEAWO51w0MazRnvueU-WV5vdxiMNOdxdyy2BSqNDNgky3E", + "signature_base64url": "oNz1ORiixjkjMJDtoidzZJh3BL1QyAIRBgEPjpcRiLMdsmlZWhoQSfQ3ZqepamtpEWOCreYcyRWqIpqwLTggCg" } diff --git a/packages/mac-connector-core/contracts/v1/fixtures/valid/full-access-state.json b/packages/mac-connector-core/contracts/v1/fixtures/valid/full-access-state.json index 0b5586c1bc..874f9de7fb 100644 --- a/packages/mac-connector-core/contracts/v1/fixtures/valid/full-access-state.json +++ b/packages/mac-connector-core/contracts/v1/fixtures/valid/full-access-state.json @@ -4,6 +4,8 @@ "state_security_epoch": 1, "minimum_reader_security_epoch": 1, "minimum_writer_security_epoch": 1, + "minimum_reader_schema_version": 1, + "minimum_writer_schema_version": 1, "policy_epoch": 7, "pairing_state": "paired", "configured_mode": "full_access", @@ -22,6 +24,7 @@ "runtime": "openclaw", "binding_id": "binding-01", "binding_version": "v3", + "grant_expires_at": "2026-07-15T09:00:00Z", "connector_installation_id": "install-01", "connector_key_id": "mac-key-01", "binding_fingerprint_sha256": "1111111111111111111111111111111111111111111111111111111111111111" diff --git a/packages/mac-connector-core/contracts/v1/fixtures/valid/local-action.json b/packages/mac-connector-core/contracts/v1/fixtures/valid/local-action.json index c3e5af9fa7..f6f4f56add 100644 --- a/packages/mac-connector-core/contracts/v1/fixtures/valid/local-action.json +++ b/packages/mac-connector-core/contracts/v1/fixtures/valid/local-action.json @@ -4,9 +4,10 @@ "peer": { "verification": "verified_designated_requirement", "role": "workbench_main", + "team_id": "TC6MS3T6NN", "signing_identifier": "com.evaos.workbench", "audit_token_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", - "designated_requirement_sha256": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" + "designated_requirement_sha256": "ff4fc126bb70bbf7fcc3cc0957377d67185124b5e31b19760357333a8a0ae329" }, "request": { "schema_version": "evaos.mac_access.local_action.v1", diff --git a/packages/mac-connector-core/contracts/v1/fixtures/valid/local-status.json b/packages/mac-connector-core/contracts/v1/fixtures/valid/local-status.json index b05bc278a4..21169f6cce 100644 --- a/packages/mac-connector-core/contracts/v1/fixtures/valid/local-status.json +++ b/packages/mac-connector-core/contracts/v1/fixtures/valid/local-status.json @@ -7,10 +7,13 @@ "app_bundle_id": "com.evaos.mac-access", "helper_service_id": "com.evaos.mac-access.helper", "connector_service_id": "com.evaos.mac-access.connector", - "helper_designated_requirement_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "team_id": "TC6MS3T6NN", + "app_designated_requirement_sha256": "da635352f249b4213aa1a96c41d7979d8b25d86b056b9f0929c1b414e35896fb", + "helper_designated_requirement_sha256": "222107bb855cfc463805777c76ca8cfdac0d1145957c5f190c234e52bfd277aa", + "connector_designated_requirement_sha256": "0c3de778270de5b4a1992d0e13d4f27e41929c7ace94ae143bcba92a555be422", "build": { - "build_version": "0.1.0-architecture", - "source_commit": "f197d2e51828f9f6a50e8448a09d5b064d14b498", + "build_version": "0.1.0-contract-fixture", + "source_commit": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "signed_lineage_id": "mac-access-production", "security_epoch": 1, "schema_reader_version": 1, @@ -28,10 +31,19 @@ "exportable_private_key": false }, "relay_authorization": { - "accepted_build_version": "0.1.0-architecture", - "accepted_source_commit": "f197d2e51828f9f6a50e8448a09d5b064d14b498", + "accepted_build_version": "0.1.0-contract-fixture", + "accepted_source_commit": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "accepted_security_epoch": 1, "credential_security_epoch": 1, + "verified_pre_rollback_source": { + "build_version": "0.2.0", + "source_commit": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "signed_lineage_id": "mac-access-production", + "security_epoch": 2, + "credential_security_epoch": 2, + "schema_reader_version": 2, + "schema_writer_version": 2 + }, "rollback_authorization": { "schema_version": "evaos.mac_access.signed_rollback_authorization.v1", "canonicalization": "RFC8785-JCS", @@ -44,23 +56,29 @@ "source_commit": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", "signed_lineage_id": "mac-access-production", "security_epoch": 2, - "credential_security_epoch": 2 + "credential_security_epoch": 2, + "schema_reader_version": 2, + "schema_writer_version": 2 }, "target": { - "build_version": "0.1.0-architecture", - "source_commit": "f197d2e51828f9f6a50e8448a09d5b064d14b498", + "build_version": "0.1.0-contract-fixture", + "source_commit": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "signed_lineage_id": "mac-access-production", "security_epoch": 1, - "credential_security_epoch": 1 + "credential_security_epoch": 1, + "schema_reader_version": 1, + "schema_writer_version": 1 }, "resulting_minimum_reader_security_epoch": 1, "resulting_minimum_writer_security_epoch": 1, + "resulting_minimum_reader_schema_version": 1, + "resulting_minimum_writer_schema_version": 1, "issued_at": "2026-07-15T07:55:00Z", "expires_at": "2026-07-15T08:05:00Z" }, - "payload_sha256": "41799f46249c2e7979cecb751665ac9f107886704dff8ea9da85708620ed64bc", + "payload_sha256": "78ff364baf4916a58b30ea3fcc06a6c9cb9c39516c9ba6d55db3b9532efa8134", "broker_key_id": "rollback-key-2026-01", - "signature_base64url": "_zjknB7xwtvOhE2VtprsVZXf3ty2R0eefst8u8TFkq9vhXcIw4oCLHvMsPgD5ySuQ_VKW-odAvX8OdFCtnvSBg" + "signature_base64url": "nmLW05er6btIWJI62UT72NNTX-DgxpuxhxYvUgsZnL2Wy4SnuSjNbt5oGgwBu-HbSCdVoafhguIpJuLCYjpUBg" } }, "access": { @@ -69,6 +87,8 @@ "state_security_epoch": 1, "minimum_reader_security_epoch": 1, "minimum_writer_security_epoch": 1, + "minimum_reader_schema_version": 1, + "minimum_writer_schema_version": 1, "policy_epoch": 7, "pairing_state": "paired", "configured_mode": "ask_every_time", @@ -87,6 +107,7 @@ "runtime": "openclaw", "binding_id": "binding-01", "binding_version": "v3", + "grant_expires_at": "2026-07-15T09:00:00Z", "connector_installation_id": "install-01", "connector_key_id": "mac-key-01", "binding_fingerprint_sha256": "1111111111111111111111111111111111111111111111111111111111111111" diff --git a/packages/mac-connector-core/contracts/v1/golden/rollback-authorization-golden.json b/packages/mac-connector-core/contracts/v1/golden/rollback-authorization-golden.json index 31b19ab02a..d047a5f682 100644 --- a/packages/mac-connector-core/contracts/v1/golden/rollback-authorization-golden.json +++ b/packages/mac-connector-core/contracts/v1/golden/rollback-authorization-golden.json @@ -10,23 +10,29 @@ "source_commit": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", "signed_lineage_id": "mac-access-production", "security_epoch": 2, - "credential_security_epoch": 2 + "credential_security_epoch": 2, + "schema_reader_version": 2, + "schema_writer_version": 2 }, "target": { - "build_version": "0.1.0-architecture", - "source_commit": "f197d2e51828f9f6a50e8448a09d5b064d14b498", + "build_version": "0.1.0-contract-fixture", + "source_commit": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "signed_lineage_id": "mac-access-production", "security_epoch": 1, - "credential_security_epoch": 1 + "credential_security_epoch": 1, + "schema_reader_version": 1, + "schema_writer_version": 1 }, "resulting_minimum_reader_security_epoch": 1, "resulting_minimum_writer_security_epoch": 1, + "resulting_minimum_reader_schema_version": 1, + "resulting_minimum_writer_schema_version": 1, "issued_at": "2026-07-15T07:55:00Z", "expires_at": "2026-07-15T08:05:00Z" }, - "canonical_payload_utf8": "{\"authorization_id\":\"rollback-01\",\"domain\":\"evaos.mac-access/rollback-authorization/v1\",\"expires_at\":\"2026-07-15T08:05:00Z\",\"issued_at\":\"2026-07-15T07:55:00Z\",\"resulting_minimum_reader_security_epoch\":1,\"resulting_minimum_writer_security_epoch\":1,\"schema_version\":\"evaos.mac_access.rollback_authorization_payload.v1\",\"source\":{\"build_version\":\"0.2.0\",\"credential_security_epoch\":2,\"security_epoch\":2,\"signed_lineage_id\":\"mac-access-production\",\"source_commit\":\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\"},\"target\":{\"build_version\":\"0.1.0-architecture\",\"credential_security_epoch\":1,\"security_epoch\":1,\"signed_lineage_id\":\"mac-access-production\",\"source_commit\":\"f197d2e51828f9f6a50e8448a09d5b064d14b498\"}}", - "payload_sha256": "b38eeec01be61c5b73453bb9d2b0441225c664c672244de833b5ad9774fd47a3", + "canonical_payload_utf8": "{\"authorization_id\":\"rollback-01\",\"domain\":\"evaos.mac-access/rollback-authorization/v1\",\"expires_at\":\"2026-07-15T08:05:00Z\",\"issued_at\":\"2026-07-15T07:55:00Z\",\"resulting_minimum_reader_schema_version\":1,\"resulting_minimum_reader_security_epoch\":1,\"resulting_minimum_writer_schema_version\":1,\"resulting_minimum_writer_security_epoch\":1,\"schema_version\":\"evaos.mac_access.rollback_authorization_payload.v1\",\"source\":{\"build_version\":\"0.2.0\",\"credential_security_epoch\":2,\"schema_reader_version\":2,\"schema_writer_version\":2,\"security_epoch\":2,\"signed_lineage_id\":\"mac-access-production\",\"source_commit\":\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\"},\"target\":{\"build_version\":\"0.1.0-contract-fixture\",\"credential_security_epoch\":1,\"schema_reader_version\":1,\"schema_writer_version\":1,\"security_epoch\":1,\"signed_lineage_id\":\"mac-access-production\",\"source_commit\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"}}", + "payload_sha256": "78ff364baf4916a58b30ea3fcc06a6c9cb9c39516c9ba6d55db3b9532efa8134", "broker_key_id": "rollback-key-2026-01", - "signature_base64url": "ffwsZjguT9lqi4I6V2OILxoQXiR0mifRXiqso9j2e77tG34hSQNkjJHbfJxzMf56RVP2d2S9BwHGn1F1_SORBQ", - "public_key_spki_base64url": "MCowBQYDK2VwAyEA6XnZ8LYYflm0uBRwwhm-my0DETifwelQbjUoZHEpnDM" + "signature_base64url": "nmLW05er6btIWJI62UT72NNTX-DgxpuxhxYvUgsZnL2Wy4SnuSjNbt5oGgwBu-HbSCdVoafhguIpJuLCYjpUBg", + "public_key_spki_base64url": "MCowBQYDK2VwAyEAcIhgSfos2h9yuLvFu46o3Y8JSYSsyCnt5h3ZPuxs2uY" } diff --git a/packages/mac-connector-core/contracts/v1/index.ts b/packages/mac-connector-core/contracts/v1/index.ts index 8fa4f2dfed..f03724288a 100644 --- a/packages/mac-connector-core/contracts/v1/index.ts +++ b/packages/mac-connector-core/contracts/v1/index.ts @@ -12,9 +12,25 @@ const safePositiveCounter = z.number().int().min(1).max(Number.MAX_SAFE_INTEGER) const safeNonnegativeCounter = z.number().int().min(0).max(Number.MAX_SAFE_INTEGER); export const MAC_ACCESS_IDENTITIES = { + teamId: 'TC6MS3T6NN', appBundleId: 'com.evaos.mac-access', helperServiceId: 'com.evaos.mac-access.helper', connectorServiceId: 'com.evaos.mac-access.connector', + appDesignatedRequirement: + 'anchor apple generic and certificate leaf[subject.OU] = "TC6MS3T6NN" and identifier "com.evaos.mac-access"', + helperDesignatedRequirement: + 'anchor apple generic and certificate leaf[subject.OU] = "TC6MS3T6NN" and identifier "com.evaos.mac-access.helper"', + connectorDesignatedRequirement: + 'anchor apple generic and certificate leaf[subject.OU] = "TC6MS3T6NN" and identifier "com.evaos.mac-access.connector"', + workbenchDesignatedRequirement: + 'anchor apple generic and certificate leaf[subject.OU] = "TC6MS3T6NN" and identifier "com.evaos.workbench"', + legacyWorkbenchDesignatedRequirement: + 'anchor apple generic and certificate leaf[subject.OU] = "TC6MS3T6NN" and identifier "com.electricsheephq.EvaDesktop"', + appDesignatedRequirementSha256: 'da635352f249b4213aa1a96c41d7979d8b25d86b056b9f0929c1b414e35896fb', + helperDesignatedRequirementSha256: '222107bb855cfc463805777c76ca8cfdac0d1145957c5f190c234e52bfd277aa', + connectorDesignatedRequirementSha256: '0c3de778270de5b4a1992d0e13d4f27e41929c7ace94ae143bcba92a555be422', + workbenchDesignatedRequirementSha256: 'ff4fc126bb70bbf7fcc3cc0957377d67185124b5e31b19760357333a8a0ae329', + legacyWorkbenchDesignatedRequirementSha256: 'c6038eaf8a20c83a1aabfd1bf8eb4053877b7af5627e570eb1de37721e76b776', productionKeychainAccessGroupSuffix: 'com.evaos.mac-access.credentials', developmentKeychainAccessGroupSuffix: 'com.evaos.mac-access.development.credentials', connectorCredentialService: 'com.evaos.mac-access.connector-credential', @@ -29,6 +45,7 @@ export const selectedBindingSchema = z runtime: z.enum(['openclaw', 'hermes']), binding_id: identifier, binding_version: identifier, + grant_expires_at: instant, connector_installation_id: identifier, connector_key_id: identifier, binding_fingerprint_sha256: sha256, @@ -47,6 +64,7 @@ function selectedBindingsEqual( left.runtime === right.runtime && left.binding_id === right.binding_id && left.binding_version === right.binding_version && + left.grant_expires_at === right.grant_expires_at && left.connector_installation_id === right.connector_installation_id && left.connector_key_id === right.connector_key_id && left.binding_fingerprint_sha256 === right.binding_fingerprint_sha256 @@ -59,8 +77,8 @@ export const buildIdentitySchema = z source_commit: z.string().regex(/^[a-f0-9]{40}$/), signed_lineage_id: identifier, security_epoch: safeNonnegativeCounter, - schema_reader_version: z.literal(1), - schema_writer_version: z.literal(1), + schema_reader_version: safePositiveCounter, + schema_writer_version: safePositiveCounter, rollback_authorization_id: identifier.nullable(), }) .strict(); @@ -93,6 +111,8 @@ const rollbackBuildSchema = z signed_lineage_id: identifier, security_epoch: safeNonnegativeCounter, credential_security_epoch: safePositiveCounter, + schema_reader_version: safePositiveCounter, + schema_writer_version: safePositiveCounter, }) .strict(); @@ -105,6 +125,8 @@ export const rollbackAuthorizationPayloadSchema = z target: rollbackBuildSchema, resulting_minimum_reader_security_epoch: safeNonnegativeCounter, resulting_minimum_writer_security_epoch: safeNonnegativeCounter, + resulting_minimum_reader_schema_version: safePositiveCounter, + resulting_minimum_writer_schema_version: safePositiveCounter, issued_at: instant, expires_at: instant, }) @@ -175,6 +197,7 @@ export const authenticatedPeerSchema = z .object({ verification: z.literal('verified_designated_requirement'), role: z.enum(['mac_access_menu', 'workbench_main']), + team_id: z.literal(MAC_ACCESS_IDENTITIES.teamId), signing_identifier: z.enum(['com.evaos.mac-access', 'com.evaos.workbench', 'com.electricsheephq.EvaDesktop']), audit_token_sha256: sha256, designated_requirement_sha256: sha256, @@ -192,6 +215,18 @@ export const authenticatedPeerSchema = z path: ['signing_identifier'], }); } + const requirementDigestByIdentifier = { + 'com.evaos.mac-access': MAC_ACCESS_IDENTITIES.appDesignatedRequirementSha256, + 'com.evaos.workbench': MAC_ACCESS_IDENTITIES.workbenchDesignatedRequirementSha256, + 'com.electricsheephq.EvaDesktop': MAC_ACCESS_IDENTITIES.legacyWorkbenchDesignatedRequirementSha256, + } as const; + if (peer.designated_requirement_sha256 !== requirementDigestByIdentifier[peer.signing_identifier]) { + context.addIssue({ + code: z.ZodIssueCode.custom, + message: 'peer designated requirement digest is not in the frozen role allowlist', + path: ['designated_requirement_sha256'], + }); + } }); export const accessStateSchema = z @@ -201,6 +236,8 @@ export const accessStateSchema = z state_security_epoch: safeNonnegativeCounter, minimum_reader_security_epoch: safeNonnegativeCounter, minimum_writer_security_epoch: safeNonnegativeCounter, + minimum_reader_schema_version: safePositiveCounter, + minimum_writer_schema_version: safePositiveCounter, policy_epoch: safeNonnegativeCounter, pairing_state: z.enum(['unpaired', 'paired', 'revoked']), configured_mode: z.enum(['off', 'ask_every_time', 'full_access']), @@ -331,7 +368,10 @@ export const localStatusSchema = z app_bundle_id: z.literal(MAC_ACCESS_IDENTITIES.appBundleId), helper_service_id: z.literal(MAC_ACCESS_IDENTITIES.helperServiceId), connector_service_id: z.literal(MAC_ACCESS_IDENTITIES.connectorServiceId), - helper_designated_requirement_sha256: sha256, + team_id: z.literal(MAC_ACCESS_IDENTITIES.teamId), + app_designated_requirement_sha256: z.literal(MAC_ACCESS_IDENTITIES.appDesignatedRequirementSha256), + helper_designated_requirement_sha256: z.literal(MAC_ACCESS_IDENTITIES.helperDesignatedRequirementSha256), + connector_designated_requirement_sha256: z.literal(MAC_ACCESS_IDENTITIES.connectorDesignatedRequirementSha256), build: buildIdentitySchema, }) .strict(), @@ -342,6 +382,7 @@ export const localStatusSchema = z accepted_source_commit: z.string().regex(/^[a-f0-9]{40}$/), accepted_security_epoch: safePositiveCounter, credential_security_epoch: safePositiveCounter, + verified_pre_rollback_source: rollbackBuildSchema.nullable(), rollback_authorization: signedRollbackAuthorizationSchema.nullable(), }) .strict(), @@ -405,6 +446,20 @@ export const localStatusSchema = z path: ['leader', 'build', 'security_epoch'], }); } + if (status.leader.build.schema_reader_version < status.access.minimum_reader_schema_version) { + context.addIssue({ + code: z.ZodIssueCode.custom, + message: 'helper build is below the protected state reader schema floor', + path: ['leader', 'build', 'schema_reader_version'], + }); + } + if (status.leader.build.schema_writer_version < status.access.minimum_writer_schema_version) { + context.addIssue({ + code: z.ZodIssueCode.custom, + message: 'helper build is below the protected state writer schema floor', + path: ['leader', 'build', 'schema_writer_version'], + }); + } const relayBuild = status.relay_authorization; const localBuild = status.leader.build; if ( @@ -437,14 +492,35 @@ export const localStatusSchema = z }); } if (rollback !== null) { + const source = relayBuild.verified_pre_rollback_source; + const sourceMatches = + source !== null && + source.build_version === rollback.source.build_version && + source.source_commit === rollback.source.source_commit && + source.signed_lineage_id === rollback.source.signed_lineage_id && + source.security_epoch === rollback.source.security_epoch && + source.credential_security_epoch === rollback.source.credential_security_epoch && + source.schema_reader_version === rollback.source.schema_reader_version && + source.schema_writer_version === rollback.source.schema_writer_version; + if (!sourceMatches) { + context.addIssue({ + code: z.ZodIssueCode.custom, + message: 'rollback authorization source must match the persisted verified pre-rollback build', + path: ['relay_authorization', 'verified_pre_rollback_source'], + }); + } const targetMatches = rollback.target.build_version === localBuild.build_version && rollback.target.source_commit === localBuild.source_commit && rollback.target.signed_lineage_id === localBuild.signed_lineage_id && rollback.target.security_epoch === localBuild.security_epoch && rollback.target.credential_security_epoch === status.keychain.credential_security_epoch && + rollback.target.schema_reader_version === localBuild.schema_reader_version && + rollback.target.schema_writer_version === localBuild.schema_writer_version && rollback.resulting_minimum_reader_security_epoch === status.access.minimum_reader_security_epoch && - rollback.resulting_minimum_writer_security_epoch === status.access.minimum_writer_security_epoch; + rollback.resulting_minimum_writer_security_epoch === status.access.minimum_writer_security_epoch && + rollback.resulting_minimum_reader_schema_version === status.access.minimum_reader_schema_version && + rollback.resulting_minimum_writer_schema_version === status.access.minimum_writer_schema_version; if (!targetMatches) { context.addIssue({ code: z.ZodIssueCode.custom, @@ -460,6 +536,22 @@ export const localStatusSchema = z path: ['relay_authorization', 'rollback_authorization', 'payload', 'expires_at'], }); } + } else if (relayBuild.verified_pre_rollback_source !== null) { + context.addIssue({ + code: z.ZodIssueCode.custom, + message: 'pre-rollback source is valid only with an exact rollback authorization', + path: ['relay_authorization', 'verified_pre_rollback_source'], + }); + } + if ( + status.access.binding !== null && + Date.parse(status.observed_at) >= Date.parse(status.access.binding.grant_expires_at) + ) { + context.addIssue({ + code: z.ZodIssueCode.custom, + message: 'expired grant must clear the selected binding and force access off', + path: ['access', 'binding', 'grant_expires_at'], + }); } const connected = status.transport.state === 'connected'; if (connected !== (status.transport.channel_id !== null)) { @@ -494,6 +586,7 @@ export const accessTransitionSchema = z 'tcc_lost', 'audit_failed', 'binding_changed', + 'grant_expired', ]), explicit_user_consent: z.boolean(), invalidated_pending_authority: z.boolean(), @@ -512,6 +605,30 @@ export const accessTransitionSchema = z path: ['to', 'policy_epoch'], }); } + const authorityInvalidating = + [ + 'restart', + 'pause', + 'stop', + 'revoke', + 'kill_switch', + 'tcc_lost', + 'audit_failed', + 'binding_changed', + 'grant_expired', + ].includes(transition.event) || + (transition.event === 'set_mode' && transition.target_mode === 'off'); + if ( + authorityInvalidating && + (!transition.invalidated_pending_authority || !transition.safe_cancellation_requested) + ) { + context.addIssue({ + code: z.ZodIssueCode.custom, + message: + 'destructive or authority-changing transition must invalidate pending authority and request safe cancellation', + path: ['event'], + }); + } if (transition.event === 'pair_confirmed') { const validPairing = transition.explicit_user_consent && @@ -613,6 +730,20 @@ export const accessTransitionSchema = z }); } } + if (transition.event === 'grant_expired') { + const validExpiry = + to.pairing_state === 'revoked' && + to.configured_mode === 'off' && + to.effective_mode === 'off' && + to.binding === null; + if (!validExpiry) { + context.addIssue({ + code: z.ZodIssueCode.custom, + message: 'grant expiry must tombstone authority, clear the binding, and force access off', + path: ['to'], + }); + } + } if (transition.event === 'kill_switch' && (!to.kill_switch || to.effective_mode !== 'off')) { context.addIssue({ code: z.ZodIssueCode.custom, @@ -706,6 +837,7 @@ export const commandAuthorityPayloadSchema = z issued_at: instant, expires_at: instant, sequence: safePositiveCounter, + policy_epoch: safeNonnegativeCounter, nonce: base64Url, binding: selectedBindingSchema, execution_context_sha256: sha256, @@ -736,6 +868,7 @@ export const brokerControlEnvelopeSchema = z issued_at: instant, expires_at: instant, sequence: safePositiveCounter, + policy_epoch: safeNonnegativeCounter, nonce: base64Url, binding: selectedBindingSchema, execution_context: z @@ -776,6 +909,13 @@ export const brokerControlEnvelopeSchema = z path: ['expires_at'], }); } + if (expiresAt > Date.parse(envelope.binding.grant_expires_at)) { + context.addIssue({ + code: z.ZodIssueCode.custom, + message: 'command authority must expire before the selected grant', + path: ['binding', 'grant_expires_at'], + }); + } const claims = envelope.execution_context.claims; const binding = envelope.binding; const matches = @@ -815,6 +955,7 @@ export const brokerControlEnvelopeSchema = z authority.issued_at === envelope.issued_at && authority.expires_at === envelope.expires_at && authority.sequence === envelope.sequence && + authority.policy_epoch === envelope.policy_epoch && authority.nonce === envelope.nonce && authority.execution_context_sha256 === envelope.execution_context.payload_sha256 && authority.capability === envelope.command.capability && diff --git a/packages/mac-connector-core/tests/FixtureSmoke.swift b/packages/mac-connector-core/tests/FixtureSmoke.swift index 8e03a5dd65..802c87f881 100644 --- a/packages/mac-connector-core/tests/FixtureSmoke.swift +++ b/packages/mac-connector-core/tests/FixtureSmoke.swift @@ -1,27 +1,30 @@ import Foundation let scriptURL = URL(fileURLWithPath: #filePath) -let fixtureRoot = scriptURL +let contractRoot = scriptURL .deletingLastPathComponent() .deletingLastPathComponent() - .appendingPathComponent("contracts/v1/fixtures") + .appendingPathComponent("contracts/v1") let fileManager = FileManager.default -guard let enumerator = fileManager.enumerator( - at: fixtureRoot, - includingPropertiesForKeys: [.isRegularFileKey], - options: [.skipsHiddenFiles] -) else { - fatalError("Mac Access contract fixture directory is unavailable") -} var parsed = 0 -for case let fileURL as URL in enumerator where fileURL.pathExtension == "json" { - let data = try Data(contentsOf: fileURL) - _ = try JSONSerialization.jsonObject(with: data) - parsed += 1 +for directoryName in ["fixtures", "golden"] { + let directory = contractRoot.appendingPathComponent(directoryName) + guard let enumerator = fileManager.enumerator( + at: directory, + includingPropertiesForKeys: [.isRegularFileKey], + options: [.skipsHiddenFiles] + ) else { + fatalError("Mac Access contract \(directoryName) directory is unavailable") + } + for case let fileURL as URL in enumerator where fileURL.pathExtension == "json" { + let data = try Data(contentsOf: fileURL) + _ = try JSONSerialization.jsonObject(with: data) + parsed += 1 + } } guard parsed > 0 else { - fatalError("no Mac Access contract fixtures found") + fatalError("no Mac Access contract fixtures or golden vectors found") } -print("parsed \(parsed) Mac Access contract fixture files") +print("parsed \(parsed) Mac Access contract fixture and golden-vector files") diff --git a/packages/mac-connector-core/tests/fixture_smoke.py b/packages/mac-connector-core/tests/fixture_smoke.py index f9b91f000f..0dcc0bbab6 100644 --- a/packages/mac-connector-core/tests/fixture_smoke.py +++ b/packages/mac-connector-core/tests/fixture_smoke.py @@ -5,14 +5,14 @@ def main() -> None: - root = Path(__file__).parent.parent / "contracts" / "v1" / "fixtures" - files = sorted(root.rglob("*.json")) + root = Path(__file__).parent.parent / "contracts" / "v1" + files = sorted(path for directory in (root / "fixtures", root / "golden") for path in directory.rglob("*.json")) if not files: - raise SystemExit("no Mac Access contract fixtures found") + raise SystemExit("no Mac Access contract fixtures or golden vectors found") for path in files: with path.open(encoding="utf-8") as handle: json.load(handle) - print(f"parsed {len(files)} Mac Access contract fixture files") + print(f"parsed {len(files)} Mac Access contract fixture and golden-vector files") if __name__ == "__main__": diff --git a/tests/contract/macAccessContracts.test.ts b/tests/contract/macAccessContracts.test.ts index ca45fab0c5..142bbf2763 100644 --- a/tests/contract/macAccessContracts.test.ts +++ b/tests/contract/macAccessContracts.test.ts @@ -96,12 +96,19 @@ const expectedIssuePathByError: Record = { selected_runtime_mismatch: 'execution_context/claims', connector_installation_mismatch: 'authorization/payload', connector_key_mismatch: 'authorization/payload', + peer_team_identifier_mismatch: 'peer/team_id', + peer_designated_requirement_hash_mismatch: 'peer/designated_requirement_sha256', peer_designated_requirement_mismatch: 'peer/signing_identifier', leader_runtime_mismatch: 'leader/runtime_instance_id', helper_identity_mismatch: 'leader/helper_service_id', + app_requirement_mismatch: 'leader/app_designated_requirement_sha256', + helper_requirement_mismatch: 'leader/helper_designated_requirement_sha256', + connector_requirement_mismatch: 'leader/connector_designated_requirement_sha256', stop_pending_authority_not_invalidated: 'event', + destructive_transition_preserves_authority: 'event', rollback_authorization_id_mismatch: 'relay_authorization/rollback_authorization', rollback_target_mismatch: 'relay_authorization/rollback_authorization/payload/target', + rollback_source_mismatch: 'relay_authorization/verified_pre_rollback_source', rollback_authorization_expired: 'relay_authorization/rollback_authorization/payload/expires_at', full_access_reconfirmation_required: 'effective_mode', audit_forbidden_field: 'evidence', @@ -110,6 +117,7 @@ const expectedIssuePathByError: Record = { effective_mode_exceeds_configured_mode: 'effective_mode', tcc_loss_not_off: 'tcc', connected_channel_missing: 'transport/channel_id', + grant_expiry_not_fail_closed: 'access/binding/grant_expires_at', pairing_epoch_required: 'request/expected_policy_epoch', audit_evidence_not_allowlisted: 'evidence', audit_secret_bearing_value: 'evidence/detail_code', @@ -137,6 +145,7 @@ describe('evaOS Mac Access v1 contracts', () => { ['access_state', 'full-access-state.json'], ['access_transition', 'access-transition.json'], ['access_transition', 'access-transition-stop.json'], + ['access_transition', 'access-transition-grant-expired.json'], ['local_status', 'local-status.json'], ['authenticated_local_action', 'local-action.json'], ['broker_control', 'broker-control.json'], diff --git a/tests/contract/macAccessCryptography.test.ts b/tests/contract/macAccessCryptography.test.ts index af023aa3c9..82d8c63432 100644 --- a/tests/contract/macAccessCryptography.test.ts +++ b/tests/contract/macAccessCryptography.test.ts @@ -8,6 +8,8 @@ import { describe, expect, it } from 'vitest'; import { auditChainGoldenSchema, commandAuthorityGoldenSchema, + localStatusSchema, + MAC_ACCESS_IDENTITIES, rollbackAuthorizationGoldenSchema, } from '../../packages/mac-connector-core/contracts/v1'; @@ -33,6 +35,28 @@ function canonicalizeJcs(value: unknown): string { } describe('evaOS Mac Access canonical cryptographic contracts', () => { + it('freezes designated-requirement bytes and digests', () => { + const pairs = [ + [MAC_ACCESS_IDENTITIES.appDesignatedRequirement, MAC_ACCESS_IDENTITIES.appDesignatedRequirementSha256], + [MAC_ACCESS_IDENTITIES.helperDesignatedRequirement, MAC_ACCESS_IDENTITIES.helperDesignatedRequirementSha256], + [ + MAC_ACCESS_IDENTITIES.connectorDesignatedRequirement, + MAC_ACCESS_IDENTITIES.connectorDesignatedRequirementSha256, + ], + [ + MAC_ACCESS_IDENTITIES.workbenchDesignatedRequirement, + MAC_ACCESS_IDENTITIES.workbenchDesignatedRequirementSha256, + ], + [ + MAC_ACCESS_IDENTITIES.legacyWorkbenchDesignatedRequirement, + MAC_ACCESS_IDENTITIES.legacyWorkbenchDesignatedRequirementSha256, + ], + ] as const; + for (const [requirement, digest] of pairs) { + expect(createHash('sha256').update(requirement).digest('hex')).toBe(digest); + } + }); + it('verifies the cross-language command authorization golden vector', () => { const vector = commandAuthorityGoldenSchema.parse(readJson(path.join(validRoot, 'command-authority-golden.json'))); const canonical = canonicalizeJcs(vector.payload); @@ -77,11 +101,59 @@ describe('evaOS Mac Access canonical cryptographic contracts', () => { expect(verify(null, Buffer.from(canonical), publicKey, Buffer.from(vector.signature_base64url, 'base64url'))).toBe( true ); + + const { + canonical_payload_utf8: _canonicalPayload, + public_key_spki_base64url: _publicKey, + ...signedVector + } = vector; + const localStatus = localStatusSchema.parse(readJson(path.join(validRoot, 'local-status.json'))); + expect(localStatus.relay_authorization.rollback_authorization).toEqual(signedVector); + }); + + it('rejects rollback payload, digest, signature, and key substitution', () => { + const vector = rollbackAuthorizationGoldenSchema.parse( + readJson(path.join(goldenRoot, 'rollback-authorization-golden.json')) + ); + const publicKey = createPublicKey({ + key: Buffer.from(vector.public_key_spki_base64url, 'base64url'), + format: 'der', + type: 'spki', + }); + const tamperedPayload = { + ...vector.payload, + target: { ...vector.payload.target, source_commit: 'cccccccccccccccccccccccccccccccccccccccc' }, + }; + const tamperedCanonical = canonicalizeJcs(tamperedPayload); + expect(createHash('sha256').update(tamperedCanonical).digest('hex')).not.toBe(vector.payload_sha256); + expect( + verify(null, Buffer.from(tamperedCanonical), publicKey, Buffer.from(vector.signature_base64url, 'base64url')) + ).toBe(false); + + const tamperedSignature = Buffer.from(vector.signature_base64url, 'base64url'); + tamperedSignature[0] ^= 1; + expect(verify(null, Buffer.from(vector.canonical_payload_utf8), publicKey, tamperedSignature)).toBe(false); + + const commandVector = commandAuthorityGoldenSchema.parse( + readJson(path.join(validRoot, 'command-authority-golden.json')) + ); + const wrongKey = createPublicKey({ + key: Buffer.from(commandVector.public_key_spki_base64url, 'base64url'), + format: 'der', + type: 'spki', + }); + expect( + verify( + null, + Buffer.from(vector.canonical_payload_utf8), + wrongKey, + Buffer.from(vector.signature_base64url, 'base64url') + ) + ).toBe(false); }); it.todo('rejects an edited audit payload whose record digest is unchanged'); it.todo('rejects deletion of an audit record from a persisted chain'); it.todo('rejects reordered audit records'); it.todo('rejects a previous-record digest mismatch'); - it.todo('rejects a rollback payload digest or broker signature mismatch'); }); From 2d9a5feac5d47dc6c7d14b9e96b647d05d2b97a6 Mon Sep 17 00:00:00 2001 From: Eva Date: Wed, 15 Jul 2026 17:47:28 +0700 Subject: [PATCH 04/14] test(mac-access): execute audit tamper proofs --- packages/mac-connector-core/README.md | 2 + tests/contract/macAccessContracts.test.ts | 96 +++++++++++++++++++- tests/contract/macAccessCryptography.test.ts | 63 ++++++++++--- 3 files changed, 141 insertions(+), 20 deletions(-) diff --git a/packages/mac-connector-core/README.md b/packages/mac-connector-core/README.md index 6d97a48674..2e3951313a 100644 --- a/packages/mac-connector-core/README.md +++ b/packages/mac-connector-core/README.md @@ -4,4 +4,6 @@ This is the single reusable connector source boundary defined by [evaOS-GUI#699] PR #699 owns only the versioned contracts and cross-language JSON fixtures under `contracts/v1`, including the private non-Electron core host API. Moving the canonical Workbench Python source through PR #709 into the bounded `adapters`, `contracts`, `host`, `persistence`, and `policy` subpackages, adding native Swift ports, and integrating consumers belong to downstream child issues after their dependency gates are satisfied. +At A0, schema-stage negative fixtures and cryptographic golden-vector tamper cases execute in CI. Runtime-stage fixtures are an exact, versioned proof ledger for #700-#704; A0 does not claim those runtime rejections execute before the connector core exists. + The archived `electricsheephq/evaos-desktop-bridge` repository is provenance only and must not be restored as source, build, or runtime truth. diff --git a/tests/contract/macAccessContracts.test.ts b/tests/contract/macAccessContracts.test.ts index 142bbf2763..e55dacde13 100644 --- a/tests/contract/macAccessContracts.test.ts +++ b/tests/contract/macAccessContracts.test.ts @@ -89,6 +89,85 @@ const negativeFixtures = fs return cases.map((rawCase) => ({ manifestPath, fixture: negativeFixtureCaseSchema.parse(rawCase) })); }); +const EXPECTED_NEGATIVE_FIXTURE_IDS = [ + 'audit-chain-gap', + 'audit-clipboard-field', + 'audit-screenshot-field', + 'audit-secret-bearing-value', + 'audit-write-failure', + 'authority-outlives-context', + 'begin-pairing-without-epoch', + 'configured-off-effective-ask', + 'connected-without-channel', + 'crash-restores-full-access', + 'duplicate-leader', + 'execution-context-payload-tampered', + 'expired-command', + 'expired-grant-remains-active', + 'expired-rollback-authorization', + 'failed-core-host-response-with-result', + 'failed-core-host-response-without-error', + 'forged-app-requirement', + 'forged-connector-requirement', + 'forged-helper-requirement', + 'forged-local-client-requirement', + 'full-access-stale-binding-confirmation', + 'full-access-stale-policy-confirmation', + 'grant-expiry-with-pending-authority', + 'helper-replacement', + 'kill-switch-with-pending-authority', + 'offline-broker-actuation', + 'pairing-directly-enables-full-access', + 'pause-with-pending-authority', + 'raw-secret-audit', + 'replayed-command', + 'replayed-core-host-sequence', + 'request-digest-mismatch', + 'revoke-with-pending-authority', + 'revoked-grant', + 'rollback-wrong-source', + 'rollback-wrong-target', + 'signed-downgrade-below-security-floor', + 'signed-payload-tampered-grant', + 'stale-binding-version', + 'stale-command-policy-epoch', + 'stale-core-host-session', + 'stale-rollback-authorization-id', + 'stolen-pairing-code', + 'stop-with-pending-authority', + 'tcc-denied-with-effective-access', + 'unknown-core-host-operation', + 'unsafe-audit-sequence', + 'unsafe-broker-envelope-sequence', + 'unsafe-command-authority-sequence', + 'unsafe-core-host-policy-epoch', + 'unsafe-core-host-sequence', + 'unsafe-status-policy-epoch', + 'unsupported-downgrade-schema', + 'untrusted-local-client', + 'wrong-channel-generation', + 'wrong-connector-key', + 'wrong-customer', + 'wrong-device', + 'wrong-grant', + 'wrong-installation', + 'wrong-runtime', + 'wrong-team-local-client', +] as const; + +const EXPECTED_RUNTIME_PROOF_LEDGER = [ + ['execution-context-payload-tampered', 'execution_context_digest_or_signature_mismatch'], + ['offline-broker-actuation', 'broker_authority_offline'], + ['replayed-command', 'command_replayed'], + ['replayed-core-host-sequence', 'host_sequence_replayed'], + ['request-digest-mismatch', 'request_digest_mismatch'], + ['revoked-grant', 'grant_revoked'], + ['signed-payload-tampered-grant', 'command_authorization_digest_or_signature_mismatch'], + ['stale-command-policy-epoch', 'command_policy_epoch_stale'], + ['stale-core-host-session', 'host_session_mismatch'], + ['stolen-pairing-code', 'pairing_code_reused_or_claimed'], +] as const; + const expectedIssuePathByError: Record = { execution_context_binding_mismatch: 'execution_context/claims', selected_device_mismatch: 'authorization/payload', @@ -344,11 +423,18 @@ describe('evaOS Mac Access v1 contracts', () => { } expect([...requiredThreats]).toEqual([]); + expect([...seen].toSorted()).toEqual(EXPECTED_NEGATIVE_FIXTURE_IDS); }); - for (const { fixture: runtimeFixture } of negativeFixtures.filter( - (entry) => entry.fixture.expected_stage === 'runtime' - )) { - it.todo(`runtime rejection ${runtimeFixture.id} must return ${runtimeFixture.expected_error}`); - } + it('freezes the downstream runtime-rejection proof ledger without claiming A0 execution', () => { + const runtimeProofLedger = negativeFixtures + .filter((entry) => entry.fixture.expected_stage === 'runtime') + .map(({ fixture }) => { + expect(fixture.required_runtime_rejection, fixture.id).toBeTruthy(); + return [fixture.id, fixture.expected_error] as const; + }) + .toSorted(([left], [right]) => left.localeCompare(right)); + + expect(runtimeProofLedger).toEqual(EXPECTED_RUNTIME_PROOF_LEDGER); + }); }); diff --git a/tests/contract/macAccessCryptography.test.ts b/tests/contract/macAccessCryptography.test.ts index 82d8c63432..19ab4b0497 100644 --- a/tests/contract/macAccessCryptography.test.ts +++ b/tests/contract/macAccessCryptography.test.ts @@ -34,6 +34,22 @@ function canonicalizeJcs(value: unknown): string { .join(',')}}`; } +function verifiesAuditChainGolden(value: unknown): boolean { + const parsed = auditChainGoldenSchema.safeParse(value); + if (!parsed.success) return false; + + let previousDigest: string | null = null; + for (const [index, record] of parsed.data.records.entries()) { + const canonical = canonicalizeJcs(record.payload); + if (record.payload.sequence !== index + 1) return false; + if (record.payload.previous_record_sha256 !== previousDigest) return false; + if (canonical !== record.canonical_payload_utf8) return false; + if (createHash('sha256').update(canonical).digest('hex') !== record.record_sha256) return false; + previousDigest = record.record_sha256; + } + return true; +} + describe('evaOS Mac Access canonical cryptographic contracts', () => { it('freezes designated-requirement bytes and digests', () => { const pairs = [ @@ -73,17 +89,7 @@ describe('evaOS Mac Access canonical cryptographic contracts', () => { }); it('verifies the two-record audit-chain golden vector', () => { - const vector = auditChainGoldenSchema.parse(readJson(path.join(validRoot, 'audit-chain-golden.json'))); - let previousDigest: string | null = null; - - for (const [index, record] of vector.records.entries()) { - const canonical = canonicalizeJcs(record.payload); - expect(record.payload.sequence).toBe(index + 1); - expect(record.payload.previous_record_sha256).toBe(previousDigest); - expect(canonical).toBe(record.canonical_payload_utf8); - expect(createHash('sha256').update(canonical).digest('hex')).toBe(record.record_sha256); - previousDigest = record.record_sha256; - } + expect(verifiesAuditChainGolden(readJson(path.join(validRoot, 'audit-chain-golden.json')))).toBe(true); }); it('verifies the exact-target rollback authorization golden vector', () => { @@ -152,8 +158,35 @@ describe('evaOS Mac Access canonical cryptographic contracts', () => { ).toBe(false); }); - it.todo('rejects an edited audit payload whose record digest is unchanged'); - it.todo('rejects deletion of an audit record from a persisted chain'); - it.todo('rejects reordered audit records'); - it.todo('rejects a previous-record digest mismatch'); + it('rejects an edited audit payload whose record digest is unchanged', () => { + const vector = structuredClone(readJson(path.join(validRoot, 'audit-chain-golden.json'))) as { + records: Array<{ payload: { reason_code: string } }>; + }; + vector.records[0].payload.reason_code = 'tampered_reason'; + expect(verifiesAuditChainGolden(vector)).toBe(false); + }); + + it('rejects deletion of an audit record from a persisted chain', () => { + const vector = structuredClone(readJson(path.join(validRoot, 'audit-chain-golden.json'))) as { + records: unknown[]; + }; + vector.records.splice(0, 1); + expect(verifiesAuditChainGolden(vector)).toBe(false); + }); + + it('rejects reordered audit records', () => { + const vector = structuredClone(readJson(path.join(validRoot, 'audit-chain-golden.json'))) as { + records: unknown[]; + }; + vector.records.reverse(); + expect(verifiesAuditChainGolden(vector)).toBe(false); + }); + + it('rejects a previous-record digest mismatch', () => { + const vector = structuredClone(readJson(path.join(validRoot, 'audit-chain-golden.json'))) as { + records: Array<{ payload: { previous_record_sha256: string | null } }>; + }; + vector.records[1].payload.previous_record_sha256 = '0'.repeat(64); + expect(verifiesAuditChainGolden(vector)).toBe(false); + }); }); From d40af3c6bfa341dabac389f01a35c2fe58e380b3 Mon Sep 17 00:00:00 2001 From: Eva Date: Wed, 15 Jul 2026 17:54:12 +0700 Subject: [PATCH 05/14] fix(mac-access): close contract authority gaps --- docs/evaos/mac-access/architecture.md | 4 +- .../v1/fixtures/invalid/binding.json | 18 +- .../v1/fixtures/invalid/identity.json | 24 +-- .../contracts/v1/fixtures/invalid/policy.json | 56 +++--- .../v1/fixtures/invalid/transport.json | 14 +- .../valid/{ => audit}/audit-chain-golden.json | 0 .../valid/{ => audit}/audit-event.json | 0 .../valid/{ => authority}/broker-control.json | 0 .../command-authority-golden.json | 0 .../valid/{ => authority}/local-action.json | 0 .../valid/{ => state}/access-state.json | 0 .../access-transition-grant-expired.json | 0 .../{ => state}/access-transition-stop.json | 0 .../valid/{ => state}/access-transition.json | 0 .../valid/{ => state}/full-access-state.json | 0 .../valid/{ => state}/local-status.json | 0 .../mac-connector-core/contracts/v1/index.ts | 189 ++++++++++++------ tests/contract/macAccessContracts.test.ts | 136 +++++++++++-- tests/contract/macAccessCryptography.test.ts | 18 +- vitest.config.ts | 6 +- 20 files changed, 325 insertions(+), 140 deletions(-) rename packages/mac-connector-core/contracts/v1/fixtures/valid/{ => audit}/audit-chain-golden.json (100%) rename packages/mac-connector-core/contracts/v1/fixtures/valid/{ => audit}/audit-event.json (100%) rename packages/mac-connector-core/contracts/v1/fixtures/valid/{ => authority}/broker-control.json (100%) rename packages/mac-connector-core/contracts/v1/fixtures/valid/{ => authority}/command-authority-golden.json (100%) rename packages/mac-connector-core/contracts/v1/fixtures/valid/{ => authority}/local-action.json (100%) rename packages/mac-connector-core/contracts/v1/fixtures/valid/{ => state}/access-state.json (100%) rename packages/mac-connector-core/contracts/v1/fixtures/valid/{ => state}/access-transition-grant-expired.json (100%) rename packages/mac-connector-core/contracts/v1/fixtures/valid/{ => state}/access-transition-stop.json (100%) rename packages/mac-connector-core/contracts/v1/fixtures/valid/{ => state}/access-transition.json (100%) rename packages/mac-connector-core/contracts/v1/fixtures/valid/{ => state}/full-access-state.json (100%) rename packages/mac-connector-core/contracts/v1/fixtures/valid/{ => state}/local-status.json (100%) diff --git a/docs/evaos/mac-access/architecture.md b/docs/evaos/mac-access/architecture.md index ccbd947b83..df0137b397 100644 --- a/docs/evaos/mac-access/architecture.md +++ b/docs/evaos/mac-access/architecture.md @@ -98,7 +98,7 @@ packages/mac-connector-core/ # at most 6 direct children │ ├── host/ # host API, CLI, compatibility dispatcher, tooling │ ├── persistence/ # audit, queue, and state compatibility │ └── policy/ # policy compatibility before native retirement -├── swift/ # native ports: Keychain, IPC, TCC/CUA, transport +├── native/ # SwiftPM native ports: verification, Keychain, IPC, TCC/CUA, transport ├── tests/ # cross-language behavior and negative fixtures ├── scripts/ # generation and parity checks └── README.md @@ -332,7 +332,7 @@ Versioned contracts and fixtures live at `packages/mac-connector-core/contracts/ The negative manifests distinguish: - `schema`: invalid data must be rejected by every decoder; -- `runtime`: structurally valid data requires stateful cryptographic, replay, enrollment, or local-policy rejection in downstream implementation. Each appears as an explicit `todo` in #699's focused suite and may not be reported green until its named downstream handler exists. +- `runtime`: structurally valid data requires stateful cryptographic, replay, enrollment, or local-policy rejection in downstream implementation. #699 freezes the exact fixture/error ledger without claiming execution; each case may be reported green only after its named #700-#704 handler executes the rejection. Downstream issues may claim completion only with the following evidence: diff --git a/packages/mac-connector-core/contracts/v1/fixtures/invalid/binding.json b/packages/mac-connector-core/contracts/v1/fixtures/invalid/binding.json index 9b14511c86..5daf5cfd39 100644 --- a/packages/mac-connector-core/contracts/v1/fixtures/invalid/binding.json +++ b/packages/mac-connector-core/contracts/v1/fixtures/invalid/binding.json @@ -3,7 +3,7 @@ "id": "wrong-customer", "threat": "wrong_customer", "contract": "broker_control", - "base_fixture": "../valid/broker-control.json", + "base_fixture": "../valid/authority/broker-control.json", "mutations": [ { "operation": "set", @@ -19,7 +19,7 @@ "id": "stale-binding-version", "threat": "stale_binding", "contract": "broker_control", - "base_fixture": "../valid/broker-control.json", + "base_fixture": "../valid/authority/broker-control.json", "mutations": [ { "operation": "set", @@ -35,7 +35,7 @@ "id": "wrong-device", "threat": "wrong_device", "contract": "broker_control", - "base_fixture": "../valid/broker-control.json", + "base_fixture": "../valid/authority/broker-control.json", "mutations": [ { "operation": "set", @@ -51,7 +51,7 @@ "id": "revoked-grant", "threat": "revoked_grant", "contract": "broker_control", - "base_fixture": "../valid/broker-control.json", + "base_fixture": "../valid/authority/broker-control.json", "mutations": [], "expected_stage": "runtime", "expected_error": "grant_revoked", @@ -61,7 +61,7 @@ "id": "wrong-grant", "threat": "wrong_grant", "contract": "broker_control", - "base_fixture": "../valid/broker-control.json", + "base_fixture": "../valid/authority/broker-control.json", "mutations": [{ "operation": "set", "pointer": "/binding/grant_id", "value": "grant-02" }], "expected_stage": "schema", "expected_error": "selected_grant_mismatch", @@ -71,7 +71,7 @@ "id": "wrong-runtime", "threat": "wrong_runtime", "contract": "broker_control", - "base_fixture": "../valid/broker-control.json", + "base_fixture": "../valid/authority/broker-control.json", "mutations": [{ "operation": "set", "pointer": "/binding/runtime", "value": "hermes" }], "expected_stage": "schema", "expected_error": "selected_runtime_mismatch", @@ -81,7 +81,7 @@ "id": "wrong-installation", "threat": "wrong_installation", "contract": "broker_control", - "base_fixture": "../valid/broker-control.json", + "base_fixture": "../valid/authority/broker-control.json", "mutations": [{ "operation": "set", "pointer": "/binding/connector_installation_id", "value": "install-02" }], "expected_stage": "schema", "expected_error": "connector_installation_mismatch", @@ -91,7 +91,7 @@ "id": "wrong-connector-key", "threat": "wrong_connector_key", "contract": "broker_control", - "base_fixture": "../valid/broker-control.json", + "base_fixture": "../valid/authority/broker-control.json", "mutations": [{ "operation": "set", "pointer": "/binding/connector_key_id", "value": "mac-key-02" }], "expected_stage": "schema", "expected_error": "connector_key_mismatch", @@ -101,7 +101,7 @@ "id": "signed-payload-tampered-grant", "threat": "command_authority_tampering", "contract": "broker_control", - "base_fixture": "../valid/broker-control.json", + "base_fixture": "../valid/authority/broker-control.json", "mutations": [ { "operation": "set", "pointer": "/binding/grant_id", "value": "grant-02" }, { diff --git a/packages/mac-connector-core/contracts/v1/fixtures/invalid/identity.json b/packages/mac-connector-core/contracts/v1/fixtures/invalid/identity.json index 4e5d859bff..1f36b0ed01 100644 --- a/packages/mac-connector-core/contracts/v1/fixtures/invalid/identity.json +++ b/packages/mac-connector-core/contracts/v1/fixtures/invalid/identity.json @@ -33,7 +33,7 @@ "id": "unsafe-broker-envelope-sequence", "threat": "host_replay", "contract": "broker_control", - "base_fixture": "../valid/broker-control.json", + "base_fixture": "../valid/authority/broker-control.json", "mutations": [{ "operation": "set", "pointer": "/sequence", "value": 9007199254740992 }], "expected_stage": "schema", "expected_error": "broker_sequence_not_safe_integer", @@ -43,7 +43,7 @@ "id": "unsafe-command-authority-sequence", "threat": "host_replay", "contract": "broker_control", - "base_fixture": "../valid/broker-control.json", + "base_fixture": "../valid/authority/broker-control.json", "mutations": [ { "operation": "set", @@ -59,7 +59,7 @@ "id": "unsafe-status-policy-epoch", "threat": "host_replay", "contract": "local_status", - "base_fixture": "../valid/local-status.json", + "base_fixture": "../valid/state/local-status.json", "mutations": [{ "operation": "set", "pointer": "/access/policy_epoch", "value": 9007199254740992 }], "expected_stage": "schema", "expected_error": "status_policy_epoch_not_safe_integer", @@ -69,7 +69,7 @@ "id": "unsafe-audit-sequence", "threat": "host_replay", "contract": "audit_event", - "base_fixture": "../valid/audit-event.json", + "base_fixture": "../valid/audit/audit-event.json", "mutations": [{ "operation": "set", "pointer": "/sequence", "value": 9007199254740992 }], "expected_stage": "schema", "expected_error": "audit_sequence_not_safe_integer", @@ -129,7 +129,7 @@ "id": "wrong-team-local-client", "threat": "local_untrusted_client", "contract": "authenticated_local_action", - "base_fixture": "../valid/local-action.json", + "base_fixture": "../valid/authority/local-action.json", "mutations": [ { "operation": "set", @@ -145,7 +145,7 @@ "id": "forged-local-client-requirement", "threat": "local_untrusted_client", "contract": "authenticated_local_action", - "base_fixture": "../valid/local-action.json", + "base_fixture": "../valid/authority/local-action.json", "mutations": [ { "operation": "set", @@ -161,7 +161,7 @@ "id": "untrusted-local-client", "threat": "local_untrusted_client", "contract": "authenticated_local_action", - "base_fixture": "../valid/local-action.json", + "base_fixture": "../valid/authority/local-action.json", "mutations": [ { "operation": "set", @@ -177,7 +177,7 @@ "id": "duplicate-leader", "threat": "duplicate_process", "contract": "local_status", - "base_fixture": "../valid/local-status.json", + "base_fixture": "../valid/state/local-status.json", "mutations": [ { "operation": "set", @@ -193,7 +193,7 @@ "id": "helper-replacement", "threat": "helper_replacement", "contract": "local_status", - "base_fixture": "../valid/local-status.json", + "base_fixture": "../valid/state/local-status.json", "mutations": [ { "operation": "set", @@ -209,7 +209,7 @@ "id": "forged-app-requirement", "threat": "helper_replacement", "contract": "local_status", - "base_fixture": "../valid/local-status.json", + "base_fixture": "../valid/state/local-status.json", "mutations": [ { "operation": "set", @@ -225,7 +225,7 @@ "id": "forged-helper-requirement", "threat": "helper_replacement", "contract": "local_status", - "base_fixture": "../valid/local-status.json", + "base_fixture": "../valid/state/local-status.json", "mutations": [ { "operation": "set", @@ -241,7 +241,7 @@ "id": "forged-connector-requirement", "threat": "helper_replacement", "contract": "local_status", - "base_fixture": "../valid/local-status.json", + "base_fixture": "../valid/state/local-status.json", "mutations": [ { "operation": "set", diff --git a/packages/mac-connector-core/contracts/v1/fixtures/invalid/policy.json b/packages/mac-connector-core/contracts/v1/fixtures/invalid/policy.json index dffb37d261..2e3fe492ea 100644 --- a/packages/mac-connector-core/contracts/v1/fixtures/invalid/policy.json +++ b/packages/mac-connector-core/contracts/v1/fixtures/invalid/policy.json @@ -3,7 +3,7 @@ "id": "stale-rollback-authorization-id", "threat": "signed_downgrade", "contract": "local_status", - "base_fixture": "../valid/local-status.json", + "base_fixture": "../valid/state/local-status.json", "mutations": [ { "operation": "set", @@ -19,7 +19,7 @@ "id": "rollback-wrong-target", "threat": "signed_downgrade", "contract": "local_status", - "base_fixture": "../valid/local-status.json", + "base_fixture": "../valid/state/local-status.json", "mutations": [ { "operation": "set", @@ -35,7 +35,7 @@ "id": "rollback-wrong-source", "threat": "signed_downgrade", "contract": "local_status", - "base_fixture": "../valid/local-status.json", + "base_fixture": "../valid/state/local-status.json", "mutations": [ { "operation": "set", @@ -51,7 +51,7 @@ "id": "expired-rollback-authorization", "threat": "signed_downgrade", "contract": "local_status", - "base_fixture": "../valid/local-status.json", + "base_fixture": "../valid/state/local-status.json", "mutations": [ { "operation": "set", @@ -67,7 +67,7 @@ "id": "stop-with-pending-authority", "threat": "stop_race", "contract": "access_transition", - "base_fixture": "../valid/access-transition-stop.json", + "base_fixture": "../valid/state/access-transition-stop.json", "mutations": [{ "operation": "set", "pointer": "/invalidated_pending_authority", "value": false }], "expected_stage": "schema", "expected_error": "stop_pending_authority_not_invalidated", @@ -77,7 +77,7 @@ "id": "pause-with-pending-authority", "threat": "stop_race", "contract": "access_transition", - "base_fixture": "../valid/access-transition-stop.json", + "base_fixture": "../valid/state/access-transition-stop.json", "mutations": [ { "operation": "set", "pointer": "/event", "value": "pause" }, { "operation": "set", "pointer": "/invalidated_pending_authority", "value": false } @@ -90,7 +90,7 @@ "id": "revoke-with-pending-authority", "threat": "stop_race", "contract": "access_transition", - "base_fixture": "../valid/access-transition-grant-expired.json", + "base_fixture": "../valid/state/access-transition-grant-expired.json", "mutations": [ { "operation": "set", "pointer": "/event", "value": "revoke" }, { "operation": "set", "pointer": "/safe_cancellation_requested", "value": false } @@ -103,7 +103,7 @@ "id": "kill-switch-with-pending-authority", "threat": "kill_switch_race", "contract": "access_transition", - "base_fixture": "../valid/access-transition-stop.json", + "base_fixture": "../valid/state/access-transition-stop.json", "mutations": [ { "operation": "set", "pointer": "/event", "value": "kill_switch" }, { "operation": "set", "pointer": "/invalidated_pending_authority", "value": false } @@ -116,7 +116,7 @@ "id": "grant-expiry-with-pending-authority", "threat": "revoked_grant", "contract": "access_transition", - "base_fixture": "../valid/access-transition-grant-expired.json", + "base_fixture": "../valid/state/access-transition-grant-expired.json", "mutations": [{ "operation": "set", "pointer": "/invalidated_pending_authority", "value": false }], "expected_stage": "schema", "expected_error": "destructive_transition_preserves_authority", @@ -126,7 +126,7 @@ "id": "expired-grant-remains-active", "threat": "revoked_grant", "contract": "local_status", - "base_fixture": "../valid/local-status.json", + "base_fixture": "../valid/state/local-status.json", "mutations": [ { "operation": "set", @@ -142,7 +142,7 @@ "id": "stale-command-policy-epoch", "threat": "replay", "contract": "broker_control", - "base_fixture": "../valid/broker-control.json", + "base_fixture": "../valid/authority/broker-control.json", "mutations": [ { "operation": "set", "pointer": "/policy_epoch", "value": 6 }, { "operation": "set", "pointer": "/authorization/payload/policy_epoch", "value": 6 } @@ -155,7 +155,7 @@ "id": "crash-restores-full-access", "threat": "crash_recovery", "contract": "access_state", - "base_fixture": "../valid/access-state.json", + "base_fixture": "../valid/state/access-state.json", "mutations": [ { "operation": "set", @@ -181,7 +181,7 @@ "id": "raw-secret-audit", "threat": "log_secret_exposure", "contract": "audit_event", - "base_fixture": "../valid/audit-event.json", + "base_fixture": "../valid/audit/audit-event.json", "mutations": [ { "operation": "set", @@ -197,7 +197,7 @@ "id": "unsupported-downgrade-schema", "threat": "downgrade", "contract": "broker_control", - "base_fixture": "../valid/broker-control.json", + "base_fixture": "../valid/authority/broker-control.json", "mutations": [ { "operation": "set", @@ -213,7 +213,7 @@ "id": "stolen-pairing-code", "threat": "stolen_pairing_code", "contract": "authenticated_local_action", - "base_fixture": "../valid/local-action.json", + "base_fixture": "../valid/authority/local-action.json", "mutations": [ { "operation": "set", @@ -234,7 +234,7 @@ "id": "audit-write-failure", "threat": "audit_unavailable", "contract": "local_status", - "base_fixture": "../valid/local-status.json", + "base_fixture": "../valid/state/local-status.json", "mutations": [ { "operation": "set", @@ -250,7 +250,7 @@ "id": "configured-off-effective-ask", "threat": "authority_exceeds_user_intent", "contract": "access_state", - "base_fixture": "../valid/access-state.json", + "base_fixture": "../valid/state/access-state.json", "mutations": [{ "operation": "set", "pointer": "/configured_mode", "value": "off" }], "expected_stage": "schema", "expected_error": "effective_mode_exceeds_configured_mode", @@ -260,7 +260,7 @@ "id": "tcc-denied-with-effective-access", "threat": "tcc_loss", "contract": "local_status", - "base_fixture": "../valid/local-status.json", + "base_fixture": "../valid/state/local-status.json", "mutations": [{ "operation": "set", "pointer": "/tcc/accessibility", "value": "denied" }], "expected_stage": "schema", "expected_error": "tcc_loss_not_off", @@ -270,7 +270,7 @@ "id": "connected-without-channel", "threat": "transport_state_confusion", "contract": "local_status", - "base_fixture": "../valid/local-status.json", + "base_fixture": "../valid/state/local-status.json", "mutations": [{ "operation": "set", "pointer": "/transport/channel_id", "value": null }], "expected_stage": "schema", "expected_error": "connected_channel_missing", @@ -280,7 +280,7 @@ "id": "begin-pairing-without-epoch", "threat": "stale_local_client", "contract": "authenticated_local_action", - "base_fixture": "../valid/local-action.json", + "base_fixture": "../valid/authority/local-action.json", "mutations": [ { "operation": "set", "pointer": "/request/action", "value": "begin_pairing" }, { "operation": "set", "pointer": "/request/expected_policy_epoch", "value": null }, @@ -294,7 +294,7 @@ "id": "audit-screenshot-field", "threat": "raw_screen_persistence", "contract": "audit_event", - "base_fixture": "../valid/audit-event.json", + "base_fixture": "../valid/audit/audit-event.json", "mutations": [{ "operation": "set", "pointer": "/evidence/screenshot", "value": "raw-data" }], "expected_stage": "schema", "expected_error": "audit_evidence_not_allowlisted", @@ -304,7 +304,7 @@ "id": "audit-clipboard-field", "threat": "clipboard_persistence", "contract": "audit_event", - "base_fixture": "../valid/audit-event.json", + "base_fixture": "../valid/audit/audit-event.json", "mutations": [{ "operation": "set", "pointer": "/evidence/clipboard", "value": "copied-data" }], "expected_stage": "schema", "expected_error": "audit_evidence_not_allowlisted", @@ -314,7 +314,7 @@ "id": "audit-secret-bearing-value", "threat": "secret_value_persistence", "contract": "audit_event", - "base_fixture": "../valid/audit-event.json", + "base_fixture": "../valid/audit/audit-event.json", "mutations": [{ "operation": "set", "pointer": "/evidence/detail_code", "value": "Bearer-secret-material" }], "expected_stage": "schema", "expected_error": "audit_secret_bearing_value", @@ -324,7 +324,7 @@ "id": "audit-chain-gap", "threat": "audit_tampering", "contract": "audit_event", - "base_fixture": "../valid/audit-event.json", + "base_fixture": "../valid/audit/audit-event.json", "mutations": [{ "operation": "set", "pointer": "/sequence", "value": 2 }], "expected_stage": "schema", "expected_error": "audit_previous_digest_required", @@ -334,7 +334,7 @@ "id": "signed-downgrade-below-security-floor", "threat": "signed_downgrade", "contract": "local_status", - "base_fixture": "../valid/local-status.json", + "base_fixture": "../valid/state/local-status.json", "mutations": [ { "operation": "set", "pointer": "/access/minimum_reader_security_epoch", "value": 2 }, { "operation": "set", "pointer": "/access/minimum_writer_security_epoch", "value": 2 } @@ -347,7 +347,7 @@ "id": "pairing-directly-enables-full-access", "threat": "pairing_consent_escalation", "contract": "access_transition", - "base_fixture": "../valid/access-transition.json", + "base_fixture": "../valid/state/access-transition.json", "mutations": [ { "operation": "set", "pointer": "/target_mode", "value": "full_access" }, { "operation": "set", "pointer": "/to/configured_mode", "value": "full_access" }, @@ -372,7 +372,7 @@ "id": "full-access-stale-policy-confirmation", "threat": "stale_full_access_confirmation", "contract": "access_state", - "base_fixture": "../valid/full-access-state.json", + "base_fixture": "../valid/state/full-access-state.json", "mutations": [{ "operation": "set", "pointer": "/confirmed_policy_epoch", "value": 6 }], "expected_stage": "schema", "expected_error": "full_access_policy_epoch_mismatch", @@ -382,7 +382,7 @@ "id": "full-access-stale-binding-confirmation", "threat": "stale_full_access_binding", "contract": "access_state", - "base_fixture": "../valid/full-access-state.json", + "base_fixture": "../valid/state/full-access-state.json", "mutations": [ { "operation": "set", diff --git a/packages/mac-connector-core/contracts/v1/fixtures/invalid/transport.json b/packages/mac-connector-core/contracts/v1/fixtures/invalid/transport.json index b881b47791..b3305d2a50 100644 --- a/packages/mac-connector-core/contracts/v1/fixtures/invalid/transport.json +++ b/packages/mac-connector-core/contracts/v1/fixtures/invalid/transport.json @@ -3,7 +3,7 @@ "id": "expired-command", "threat": "expired_authority", "contract": "broker_control", - "base_fixture": "../valid/broker-control.json", + "base_fixture": "../valid/authority/broker-control.json", "mutations": [ { "operation": "set", @@ -19,7 +19,7 @@ "id": "replayed-command", "threat": "replay", "contract": "broker_control", - "base_fixture": "../valid/broker-control.json", + "base_fixture": "../valid/authority/broker-control.json", "mutations": [], "expected_stage": "runtime", "expected_error": "command_replayed", @@ -29,7 +29,7 @@ "id": "offline-broker-actuation", "threat": "offline_broker", "contract": "broker_control", - "base_fixture": "../valid/broker-control.json", + "base_fixture": "../valid/authority/broker-control.json", "mutations": [], "expected_stage": "runtime", "expected_error": "broker_authority_offline", @@ -39,7 +39,7 @@ "id": "request-digest-mismatch", "threat": "request_tampering", "contract": "broker_control", - "base_fixture": "../valid/broker-control.json", + "base_fixture": "../valid/authority/broker-control.json", "mutations": [ { "operation": "set", @@ -55,7 +55,7 @@ "id": "wrong-channel-generation", "threat": "reconnect_replay", "contract": "broker_control", - "base_fixture": "../valid/broker-control.json", + "base_fixture": "../valid/authority/broker-control.json", "mutations": [{ "operation": "set", "pointer": "/channel_generation_id", "value": "channel-generation-02" }], "expected_stage": "schema", "expected_error": "channel_generation_mismatch", @@ -65,7 +65,7 @@ "id": "authority-outlives-context", "threat": "context_expiry_escape", "contract": "broker_control", - "base_fixture": "../valid/broker-control.json", + "base_fixture": "../valid/authority/broker-control.json", "mutations": [{ "operation": "set", "pointer": "/execution_context/claims/expires_at", "value": 1784102470 }], "expected_stage": "schema", "expected_error": "authority_outlives_execution_context", @@ -75,7 +75,7 @@ "id": "execution-context-payload-tampered", "threat": "execution_context_tampering", "contract": "broker_control", - "base_fixture": "../valid/broker-control.json", + "base_fixture": "../valid/authority/broker-control.json", "mutations": [{ "operation": "set", "pointer": "/execution_context/payload_base64url", "value": "dGFtcGVyZWQ" }], "expected_stage": "runtime", "expected_error": "execution_context_digest_or_signature_mismatch", diff --git a/packages/mac-connector-core/contracts/v1/fixtures/valid/audit-chain-golden.json b/packages/mac-connector-core/contracts/v1/fixtures/valid/audit/audit-chain-golden.json similarity index 100% rename from packages/mac-connector-core/contracts/v1/fixtures/valid/audit-chain-golden.json rename to packages/mac-connector-core/contracts/v1/fixtures/valid/audit/audit-chain-golden.json diff --git a/packages/mac-connector-core/contracts/v1/fixtures/valid/audit-event.json b/packages/mac-connector-core/contracts/v1/fixtures/valid/audit/audit-event.json similarity index 100% rename from packages/mac-connector-core/contracts/v1/fixtures/valid/audit-event.json rename to packages/mac-connector-core/contracts/v1/fixtures/valid/audit/audit-event.json diff --git a/packages/mac-connector-core/contracts/v1/fixtures/valid/broker-control.json b/packages/mac-connector-core/contracts/v1/fixtures/valid/authority/broker-control.json similarity index 100% rename from packages/mac-connector-core/contracts/v1/fixtures/valid/broker-control.json rename to packages/mac-connector-core/contracts/v1/fixtures/valid/authority/broker-control.json diff --git a/packages/mac-connector-core/contracts/v1/fixtures/valid/command-authority-golden.json b/packages/mac-connector-core/contracts/v1/fixtures/valid/authority/command-authority-golden.json similarity index 100% rename from packages/mac-connector-core/contracts/v1/fixtures/valid/command-authority-golden.json rename to packages/mac-connector-core/contracts/v1/fixtures/valid/authority/command-authority-golden.json diff --git a/packages/mac-connector-core/contracts/v1/fixtures/valid/local-action.json b/packages/mac-connector-core/contracts/v1/fixtures/valid/authority/local-action.json similarity index 100% rename from packages/mac-connector-core/contracts/v1/fixtures/valid/local-action.json rename to packages/mac-connector-core/contracts/v1/fixtures/valid/authority/local-action.json diff --git a/packages/mac-connector-core/contracts/v1/fixtures/valid/access-state.json b/packages/mac-connector-core/contracts/v1/fixtures/valid/state/access-state.json similarity index 100% rename from packages/mac-connector-core/contracts/v1/fixtures/valid/access-state.json rename to packages/mac-connector-core/contracts/v1/fixtures/valid/state/access-state.json diff --git a/packages/mac-connector-core/contracts/v1/fixtures/valid/access-transition-grant-expired.json b/packages/mac-connector-core/contracts/v1/fixtures/valid/state/access-transition-grant-expired.json similarity index 100% rename from packages/mac-connector-core/contracts/v1/fixtures/valid/access-transition-grant-expired.json rename to packages/mac-connector-core/contracts/v1/fixtures/valid/state/access-transition-grant-expired.json diff --git a/packages/mac-connector-core/contracts/v1/fixtures/valid/access-transition-stop.json b/packages/mac-connector-core/contracts/v1/fixtures/valid/state/access-transition-stop.json similarity index 100% rename from packages/mac-connector-core/contracts/v1/fixtures/valid/access-transition-stop.json rename to packages/mac-connector-core/contracts/v1/fixtures/valid/state/access-transition-stop.json diff --git a/packages/mac-connector-core/contracts/v1/fixtures/valid/access-transition.json b/packages/mac-connector-core/contracts/v1/fixtures/valid/state/access-transition.json similarity index 100% rename from packages/mac-connector-core/contracts/v1/fixtures/valid/access-transition.json rename to packages/mac-connector-core/contracts/v1/fixtures/valid/state/access-transition.json diff --git a/packages/mac-connector-core/contracts/v1/fixtures/valid/full-access-state.json b/packages/mac-connector-core/contracts/v1/fixtures/valid/state/full-access-state.json similarity index 100% rename from packages/mac-connector-core/contracts/v1/fixtures/valid/full-access-state.json rename to packages/mac-connector-core/contracts/v1/fixtures/valid/state/full-access-state.json diff --git a/packages/mac-connector-core/contracts/v1/fixtures/valid/local-status.json b/packages/mac-connector-core/contracts/v1/fixtures/valid/state/local-status.json similarity index 100% rename from packages/mac-connector-core/contracts/v1/fixtures/valid/local-status.json rename to packages/mac-connector-core/contracts/v1/fixtures/valid/state/local-status.json diff --git a/packages/mac-connector-core/contracts/v1/index.ts b/packages/mac-connector-core/contracts/v1/index.ts index f03724288a..01faad6b72 100644 --- a/packages/mac-connector-core/contracts/v1/index.ts +++ b/packages/mac-connector-core/contracts/v1/index.ts @@ -568,6 +568,13 @@ export const localStatusSchema = z path: ['transport', 'state'], }); } + if (connected && (status.access.pairing_state !== 'paired' || status.access.binding === null)) { + context.addIssue({ + code: z.ZodIssueCode.custom, + message: 'connected transport requires an actively paired selected binding', + path: ['transport', 'state'], + }); + } }); export const accessTransitionSchema = z @@ -598,6 +605,17 @@ export const accessTransitionSchema = z .strict() .superRefine((transition, context) => { const { from, to } = transition; + const bindingUnchanged = + (from.binding === null && to.binding === null) || + (from.binding !== null && to.binding !== null && selectedBindingsEqual(from.binding, to.binding)); + const bindingMutationEvents = new Set(['pair_confirmed', 'binding_changed', 'revoke', 'grant_expired']); + if (!bindingUnchanged && !bindingMutationEvents.has(transition.event)) { + context.addIssue({ + code: z.ZodIssueCode.custom, + message: 'selected binding may change only through an explicit binding authority transition', + path: ['to', 'binding'], + }); + } if (to.policy_epoch !== from.policy_epoch + 1) { context.addIssue({ code: z.ZodIssueCode.custom, @@ -760,7 +778,9 @@ export const accessTransitionSchema = z } if ( transition.event === 'binding_changed' && - (from.binding?.binding_fingerprint_sha256 === to.binding?.binding_fingerprint_sha256 || + (from.binding === null || + to.binding === null || + selectedBindingsEqual(from.binding, to.binding) || to.confirmed_binding_fingerprint_sha256 !== null) ) { context.addIssue({ @@ -1008,65 +1028,75 @@ const coreHostLifecycleRequest = ( }) .strict(); -export const coreHostRequestSchema = z.discriminatedUnion('operation', [ - z - .object({ - ...coreHostRequestIdentity, - operation: z.literal('status'), - expected_policy_epoch: z.null(), - }) - .strict(), - z - .object({ - ...coreHostRequestIdentity, - operation: z.literal('pair'), - expected_policy_epoch: safeNonnegativeCounter, - pairing_code: z.string().regex(/^[A-Z0-9]{6,12}$/), - local_installation_nonce: base64Url, - }) - .strict(), - coreHostLifecycleRequest('unpair'), - z - .object({ - ...coreHostRequestIdentity, - operation: z.literal('connect'), - expected_policy_epoch: safeNonnegativeCounter, - binding: selectedBindingSchema, - }) - .strict(), - coreHostLifecycleRequest('disconnect'), - z - .object({ - ...coreHostRequestIdentity, - operation: z.literal('set_access_mode'), - expected_policy_epoch: safeNonnegativeCounter, - target_mode: z.enum(['off', 'ask_every_time', 'full_access']), - }) - .strict(), - z - .object({ - ...coreHostRequestIdentity, - operation: z.literal('dispatch_action'), - expected_policy_epoch: safeNonnegativeCounter, - envelope: brokerControlEnvelopeSchema, - }) - .strict(), - z - .object({ - ...coreHostRequestIdentity, - operation: z.literal('audit_summary'), - expected_policy_epoch: safeNonnegativeCounter, - after_sequence: safeNonnegativeCounter.nullable(), - limit: z.number().int().min(1).max(100), - }) - .strict(), - coreHostLifecycleRequest('pause'), - coreHostLifecycleRequest('resume'), - coreHostLifecycleRequest('stop'), - coreHostLifecycleRequest('revoke'), - coreHostLifecycleRequest('activate_kill_switch'), - coreHostLifecycleRequest('shutdown'), -]); +export const coreHostRequestSchema = z + .discriminatedUnion('operation', [ + z + .object({ + ...coreHostRequestIdentity, + operation: z.literal('status'), + expected_policy_epoch: z.null(), + }) + .strict(), + z + .object({ + ...coreHostRequestIdentity, + operation: z.literal('pair'), + expected_policy_epoch: safeNonnegativeCounter, + pairing_code: z.string().regex(/^[A-Z0-9]{6,12}$/), + local_installation_nonce: base64Url, + }) + .strict(), + coreHostLifecycleRequest('unpair'), + z + .object({ + ...coreHostRequestIdentity, + operation: z.literal('connect'), + expected_policy_epoch: safeNonnegativeCounter, + binding: selectedBindingSchema, + }) + .strict(), + coreHostLifecycleRequest('disconnect'), + z + .object({ + ...coreHostRequestIdentity, + operation: z.literal('set_access_mode'), + expected_policy_epoch: safeNonnegativeCounter, + target_mode: z.enum(['off', 'ask_every_time', 'full_access']), + }) + .strict(), + z + .object({ + ...coreHostRequestIdentity, + operation: z.literal('dispatch_action'), + expected_policy_epoch: safeNonnegativeCounter, + envelope: brokerControlEnvelopeSchema, + }) + .strict(), + z + .object({ + ...coreHostRequestIdentity, + operation: z.literal('audit_summary'), + expected_policy_epoch: safeNonnegativeCounter, + after_sequence: safeNonnegativeCounter.nullable(), + limit: z.number().int().min(1).max(100), + }) + .strict(), + coreHostLifecycleRequest('pause'), + coreHostLifecycleRequest('resume'), + coreHostLifecycleRequest('stop'), + coreHostLifecycleRequest('revoke'), + coreHostLifecycleRequest('activate_kill_switch'), + coreHostLifecycleRequest('shutdown'), + ]) + .superRefine((request, context) => { + if (request.operation === 'dispatch_action' && request.expected_policy_epoch !== request.envelope.policy_epoch) { + context.addIssue({ + code: z.ZodIssueCode.custom, + message: 'dispatch host epoch must exactly match the signed broker envelope epoch', + path: ['expected_policy_epoch'], + }); + } + }); const safeEvidenceIdentifier = identifier.refine( (value) => !/(?:authorization|bearer|cookie|password|secret|token|eyJ[A-Za-z0-9_-]{8})/i.test(value), @@ -1242,6 +1272,45 @@ export const coreHostResponseSchema = z path: ['result', 'kind'], }); } + if (response.result.kind !== 'lifecycle') return; + + if ( + ['pause', 'stop', 'revoke', 'activate_kill_switch', 'shutdown'].includes(response.operation) && + response.result.effective_mode !== 'off' + ) { + context.addIssue({ + code: z.ZodIssueCode.custom, + message: 'destructive lifecycle responses must report effective access off', + path: ['result', 'effective_mode'], + }); + } + if ( + response.operation === 'revoke' && + (response.result.pairing_state !== 'revoked' || response.result.transport_state !== 'revoked') + ) { + context.addIssue({ + code: z.ZodIssueCode.custom, + message: 'revoke response must report revoked pairing and transport', + path: ['result', 'pairing_state'], + }); + } + if ( + ['stop', 'shutdown'].includes(response.operation) && + ['connecting', 'connected'].includes(response.result.transport_state) + ) { + context.addIssue({ + code: z.ZodIssueCode.custom, + message: 'stop and shutdown responses cannot retain an active transport', + path: ['result', 'transport_state'], + }); + } + if (response.operation === 'activate_kill_switch' && response.result.transport_state !== 'blocked') { + context.addIssue({ + code: z.ZodIssueCode.custom, + message: 'kill-switch response must report blocked transport', + path: ['result', 'transport_state'], + }); + } }); export const negativeFixtureCaseSchema = z diff --git a/tests/contract/macAccessContracts.test.ts b/tests/contract/macAccessContracts.test.ts index e55dacde13..2bd68b07e2 100644 --- a/tests/contract/macAccessContracts.test.ts +++ b/tests/contract/macAccessContracts.test.ts @@ -220,15 +220,15 @@ const expectedIssuePathByError: Record = { describe('evaOS Mac Access v1 contracts', () => { const validFixtures = [ - ['access_state', 'access-state.json'], - ['access_state', 'full-access-state.json'], - ['access_transition', 'access-transition.json'], - ['access_transition', 'access-transition-stop.json'], - ['access_transition', 'access-transition-grant-expired.json'], - ['local_status', 'local-status.json'], - ['authenticated_local_action', 'local-action.json'], - ['broker_control', 'broker-control.json'], - ['audit_event', 'audit-event.json'], + ['access_state', 'state/access-state.json'], + ['access_state', 'state/full-access-state.json'], + ['access_transition', 'state/access-transition.json'], + ['access_transition', 'state/access-transition-stop.json'], + ['access_transition', 'state/access-transition-grant-expired.json'], + ['local_status', 'state/local-status.json'], + ['authenticated_local_action', 'authority/local-action.json'], + ['broker_control', 'authority/broker-control.json'], + ['audit_event', 'audit/audit-event.json'], ['core_host_request', '../host/host-request.json'], ['core_host_response', '../host/host-response.json'], ] as const; @@ -255,7 +255,7 @@ describe('evaOS Mac Access v1 contracts', () => { 'shutdown', ]); - const envelope = brokerControlEnvelopeSchema.parse(readJson(path.join(validRoot, 'broker-control.json'))); + const envelope = brokerControlEnvelopeSchema.parse(readJson(path.join(validRoot, 'authority/broker-control.json'))); const identity = { schema_version: 'evaos.mac_connector_core.host_request.v1', host_session_id: 'host-session-01', @@ -304,7 +304,7 @@ describe('evaOS Mac Access v1 contracts', () => { it('rejects unsafe integers in every nested security counter exposed by the host DTOs', () => { const unsafeInteger = Number.MAX_SAFE_INTEGER + 1; - const envelope = brokerControlEnvelopeSchema.parse(readJson(path.join(validRoot, 'broker-control.json'))); + const envelope = brokerControlEnvelopeSchema.parse(readJson(path.join(validRoot, 'authority/broker-control.json'))); const dispatchRequest = { schema_version: 'evaos.mac_connector_core.host_request.v1', request_id: 'host-dispatch-unsafe', @@ -333,7 +333,7 @@ describe('evaOS Mac Access v1 contracts', () => { operation: 'status', ok: true, policy_epoch: 7, - result: { kind: 'status', status: cloneJson(readJson(path.join(validRoot, 'local-status.json'))) }, + result: { kind: 'status', status: cloneJson(readJson(path.join(validRoot, 'state/local-status.json'))) }, error: null, }; applyMutation(statusResponse, { @@ -359,7 +359,7 @@ describe('evaOS Mac Access v1 contracts', () => { policy_epoch: 7, result: { kind: 'audit_summary', - events: [cloneJson(readJson(path.join(validRoot, 'audit-event.json')))], + events: [cloneJson(readJson(path.join(validRoot, 'audit/audit-event.json')))], next_sequence: null, }, error: null, @@ -376,6 +376,116 @@ describe('evaOS Mac Access v1 contracts', () => { } }); + it('rejects connected transport after access is unpaired or revoked', () => { + const status = cloneJson(readJson(path.join(validRoot, 'state/local-status.json'))) as { + access: { + pairing_state: string; + configured_mode: string; + effective_mode: string; + binding: unknown; + }; + }; + status.access.pairing_state = 'revoked'; + status.access.configured_mode = 'off'; + status.access.effective_mode = 'off'; + status.access.binding = null; + + const parsed = localStatusSchema.safeParse(status); + expect(parsed.success).toBe(false); + if (!parsed.success) { + expect(parsed.error.issues.some((issue) => issue.path.join('/') === 'transport/state')).toBe(true); + } + }); + + it('rejects dispatch when the host epoch differs from the signed broker envelope', () => { + const envelope = brokerControlEnvelopeSchema.parse(readJson(path.join(validRoot, 'authority/broker-control.json'))); + const parsed = coreHostRequestSchema.safeParse({ + schema_version: 'evaos.mac_connector_core.host_request.v1', + request_id: 'host-dispatch-stale', + host_session_id: 'host-session-01', + sequence: 1, + operation: 'dispatch_action', + expected_policy_epoch: envelope.policy_epoch - 1, + envelope, + }); + + expect(parsed.success).toBe(false); + if (!parsed.success) { + expect(parsed.error.issues.some((issue) => issue.path.join('/') === 'expected_policy_epoch')).toBe(true); + } + }); + + it('rejects silent binding replacement outside an explicit binding transition', () => { + const transition = cloneJson(readJson(path.join(validRoot, 'state/access-transition-stop.json'))) as { + to: { + binding: { + device_id: string; + binding_fingerprint_sha256: string; + }; + }; + }; + transition.to.binding.device_id = 'mac-02'; + transition.to.binding.binding_fingerprint_sha256 = '2'.repeat(64); + + const parsed = accessTransitionSchema.safeParse(transition); + expect(parsed.success).toBe(false); + if (!parsed.success) { + expect(parsed.error.issues.some((issue) => issue.path.join('/') === 'to/binding')).toBe(true); + } + }); + + it('rejects lifecycle barrier responses that report unsafe operation-specific state', () => { + const base = cloneJson(readJson(path.join(contractRoot, 'fixtures/host/host-response.json'))) as { + operation: string; + result: { + effective_mode: string; + pairing_state: string; + transport_state: string; + }; + }; + const cases = [ + { + operation: 'stop', + effective_mode: 'full_access', + pairing_state: 'paired', + transport_state: 'connected', + expectedPaths: ['result/effective_mode', 'result/transport_state'], + }, + { + operation: 'revoke', + effective_mode: 'off', + pairing_state: 'paired', + transport_state: 'connected', + expectedPaths: ['result/pairing_state'], + }, + { + operation: 'activate_kill_switch', + effective_mode: 'off', + pairing_state: 'paired', + transport_state: 'connected', + expectedPaths: ['result/transport_state'], + }, + ]; + + for (const testCase of cases) { + const response = cloneJson(base); + response.operation = testCase.operation; + response.result.effective_mode = testCase.effective_mode; + response.result.pairing_state = testCase.pairing_state; + response.result.transport_state = testCase.transport_state; + const parsed = coreHostResponseSchema.safeParse(response); + expect(parsed.success, testCase.operation).toBe(false); + if (!parsed.success) { + for (const expectedPath of testCase.expectedPaths) { + expect( + parsed.error.issues.some((issue) => issue.path.join('/') === expectedPath), + `${testCase.operation} must reject at ${expectedPath}` + ).toBe(true); + } + } + } + }); + it('keeps every required adversarial threat in the versioned negative fixture set', () => { const requiredThreats = new Set([ 'replay', diff --git a/tests/contract/macAccessCryptography.test.ts b/tests/contract/macAccessCryptography.test.ts index 19ab4b0497..e6fe5eb5e0 100644 --- a/tests/contract/macAccessCryptography.test.ts +++ b/tests/contract/macAccessCryptography.test.ts @@ -74,7 +74,9 @@ describe('evaOS Mac Access canonical cryptographic contracts', () => { }); it('verifies the cross-language command authorization golden vector', () => { - const vector = commandAuthorityGoldenSchema.parse(readJson(path.join(validRoot, 'command-authority-golden.json'))); + const vector = commandAuthorityGoldenSchema.parse( + readJson(path.join(validRoot, 'authority/command-authority-golden.json')) + ); const canonical = canonicalizeJcs(vector.payload); expect(canonical).toBe(vector.canonical_payload_utf8); expect(createHash('sha256').update(canonical).digest('hex')).toBe(vector.payload_sha256); @@ -89,7 +91,7 @@ describe('evaOS Mac Access canonical cryptographic contracts', () => { }); it('verifies the two-record audit-chain golden vector', () => { - expect(verifiesAuditChainGolden(readJson(path.join(validRoot, 'audit-chain-golden.json')))).toBe(true); + expect(verifiesAuditChainGolden(readJson(path.join(validRoot, 'audit/audit-chain-golden.json')))).toBe(true); }); it('verifies the exact-target rollback authorization golden vector', () => { @@ -113,7 +115,7 @@ describe('evaOS Mac Access canonical cryptographic contracts', () => { public_key_spki_base64url: _publicKey, ...signedVector } = vector; - const localStatus = localStatusSchema.parse(readJson(path.join(validRoot, 'local-status.json'))); + const localStatus = localStatusSchema.parse(readJson(path.join(validRoot, 'state/local-status.json'))); expect(localStatus.relay_authorization.rollback_authorization).toEqual(signedVector); }); @@ -141,7 +143,7 @@ describe('evaOS Mac Access canonical cryptographic contracts', () => { expect(verify(null, Buffer.from(vector.canonical_payload_utf8), publicKey, tamperedSignature)).toBe(false); const commandVector = commandAuthorityGoldenSchema.parse( - readJson(path.join(validRoot, 'command-authority-golden.json')) + readJson(path.join(validRoot, 'authority/command-authority-golden.json')) ); const wrongKey = createPublicKey({ key: Buffer.from(commandVector.public_key_spki_base64url, 'base64url'), @@ -159,7 +161,7 @@ describe('evaOS Mac Access canonical cryptographic contracts', () => { }); it('rejects an edited audit payload whose record digest is unchanged', () => { - const vector = structuredClone(readJson(path.join(validRoot, 'audit-chain-golden.json'))) as { + const vector = structuredClone(readJson(path.join(validRoot, 'audit/audit-chain-golden.json'))) as { records: Array<{ payload: { reason_code: string } }>; }; vector.records[0].payload.reason_code = 'tampered_reason'; @@ -167,7 +169,7 @@ describe('evaOS Mac Access canonical cryptographic contracts', () => { }); it('rejects deletion of an audit record from a persisted chain', () => { - const vector = structuredClone(readJson(path.join(validRoot, 'audit-chain-golden.json'))) as { + const vector = structuredClone(readJson(path.join(validRoot, 'audit/audit-chain-golden.json'))) as { records: unknown[]; }; vector.records.splice(0, 1); @@ -175,7 +177,7 @@ describe('evaOS Mac Access canonical cryptographic contracts', () => { }); it('rejects reordered audit records', () => { - const vector = structuredClone(readJson(path.join(validRoot, 'audit-chain-golden.json'))) as { + const vector = structuredClone(readJson(path.join(validRoot, 'audit/audit-chain-golden.json'))) as { records: unknown[]; }; vector.records.reverse(); @@ -183,7 +185,7 @@ describe('evaOS Mac Access canonical cryptographic contracts', () => { }); it('rejects a previous-record digest mismatch', () => { - const vector = structuredClone(readJson(path.join(validRoot, 'audit-chain-golden.json'))) as { + const vector = structuredClone(readJson(path.join(validRoot, 'audit/audit-chain-golden.json'))) as { records: Array<{ payload: { previous_record_sha256: string | null } }>; }; vector.records[1].payload.previous_record_sha256 = '0'.repeat(64); diff --git a/vitest.config.ts b/vitest.config.ts index 8bebf16084..47ea4bd8e8 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -59,7 +59,11 @@ export default defineConfig({ // Cover ALL source code by default — new files are automatically included. // Only exclude files that genuinely cannot be unit-tested (entry points, // type-only files, static assets, etc.). - include: ['packages/desktop/src/**/*.{ts,tsx}', 'packages/**/src/**/*.{ts,tsx}'], + include: [ + 'packages/desktop/src/**/*.{ts,tsx}', + 'packages/**/src/**/*.{ts,tsx}', + 'packages/mac-connector-core/contracts/**/*.ts', + ], exclude: [ // Type declaration files (no runtime code) 'packages/**/src/**/*.d.ts', From a2eee3aace242c97bfdbac114ea88d9176410879 Mon Sep 17 00:00:00 2001 From: Eva Date: Wed, 15 Jul 2026 18:14:24 +0700 Subject: [PATCH 06/14] fix(mac-access): tighten host outcome authority --- docs/evaos/mac-access/architecture.md | 7 +- docs/evaos/mac-access/migration-map.md | 52 ++++---- .../mac-connector-core/contracts/v1/index.ts | 63 ++++++++- tests/contract/macAccessContracts.test.ts | 124 +++++++++++++++++- tsconfig.json | 2 + 5 files changed, 213 insertions(+), 35 deletions(-) diff --git a/docs/evaos/mac-access/architecture.md b/docs/evaos/mac-access/architecture.md index df0137b397..20d462f9e8 100644 --- a/docs/evaos/mac-access/architecture.md +++ b/docs/evaos/mac-access/architecture.md @@ -91,13 +91,14 @@ packages/mac-access/ # at most 8 direct children packages/mac-connector-core/ # at most 6 direct children ├── contracts/ # versioned language-neutral contracts and fixtures ├── python/ -│ └── evaos_desktop_bridge/ # at most 6 direct children +│ └── evaos_desktop_bridge/ # at most 7 direct children │ ├── __init__.py │ ├── adapters/ # protocol, planner, and temporary CUA adapters │ ├── contracts/ # capability, schema, types, and redaction │ ├── host/ # host API, CLI, compatibility dispatcher, tooling │ ├── persistence/ # audit, queue, and state compatibility -│ └── policy/ # policy compatibility before native retirement +│ ├── policy/ # policy compatibility before native retirement +│ └── proof/ # packaged Workbench canary and receipt compatibility ├── native/ # SwiftPM native ports: verification, Keychain, IPC, TCC/CUA, transport ├── tests/ # cross-language behavior and negative fixtures ├── scripts/ # generation and parity checks @@ -106,6 +107,8 @@ packages/mac-connector-core/ # at most 6 direct children Subdirectories also obey the repository limit of ten direct children. Pure parsing, canonicalization, binding comparison, policy transitions, redaction, and receipt construction remain separate from Keychain, WebSocket, filesystem, audit, TCC, process, and CUA I/O. +The `proof/` package is not merely a test directory in A1. Workbench's generated wrapper and canary workflows still execute `pre_canary.py` and `qa_canary.py`, and `connector_server.py` imports `receipt_canary.py` for the signed runtime-receipt route. #700 moves those modules once into `proof/`, packages them from the canonical core, and keeps their installed import/entry-point compatibility explicit. A later phase may retire them only together with every production, workflow, release-gate, and VM-consumer caller; a test-only move at A1 would break the accepted #708 proof route. + The A1 core host boundary is `evaos.mac_connector_core.host_request.v1` / `host_response.v1`, implemented by `python/evaos_desktop_bridge/host/api.py` without importing Electron or renderer code. It is a fixed-operation private interface for status, pair/unpair, connect/disconnect, access mode, action dispatch, audit summary, pause/resume, emergency stop, revoke/kill, and shutdown. Requests bind a helper-created host session, monotonic safe-integer sequence, request ID, and safe-integer expected policy epoch; unknown operations and fields fail closed. Stop is distinct from process shutdown: it must synchronously rotate policy authority, force effective Off, invalidate pending approvals/commands, and safely cancel active work before returning. The helper launches the embedded core over a private inherited channel with a one-runtime lifetime—never HTTP, a public socket, PATH discovery, or a renderer-callable endpoint. PR #699 introduces only the versioned contract source/fixtures under `packages/mac-connector-core/contracts/v1`, cross-language syntax smoke programs under `packages/mac-connector-core/tests`, and focused contract-test discovery. Creating native targets, moving Python, or changing Workbench runtime behavior is downstream work. diff --git a/docs/evaos/mac-access/migration-map.md b/docs/evaos/mac-access/migration-map.md index 34b70f79ac..96358e3137 100644 --- a/docs/evaos/mac-access/migration-map.md +++ b/docs/evaos/mac-access/migration-map.md @@ -26,32 +26,32 @@ PR #697, #708, and #709 are merged. `27b28cd234d537a491028e9024070cf8d33b9611` i ## Canonical Workbench Python module ownership -The inspected source contains 22 Python modules, including #708's `receipt_canary.py`. The `Target owner` column is intentionally singular and names the A1 canonical location. Every current runtime module first moves exactly once into an explicit subpackage under `packages/mac-connector-core/python/evaos_desktop_bridge`; harness/canary modules move into connector-core tests. The root has six direct children and every subpackage stays below ten. Later A2-A4 native ports narrow or retire Python behavior only after parity proof and never leave a second source tree. - -| Current source under `resources/evaos-beta/bridge/src/evaos_desktop_bridge/` | Current role | Target owner | Phase | Migration action and exit check | -| ---------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------ | ----- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `__init__.py` | Package metadata/entry namespace. | `packages/mac-connector-core/python/evaos_desktop_bridge/__init__.py` | A1 | Move from the exact #699-accepted post-#709 baseline; version must match embedded manifest. | -| `adapters/__init__.py` | Adapter namespace. | `packages/mac-connector-core/python/evaos_desktop_bridge/adapters/__init__.py` | A1 | Move; export only private runtime adapters. | -| `adapters/codex_app_server.py` | Codex app-server protocol adapter. | `python/evaos_desktop_bridge/adapters/codex_app_server.py` | A1 | Move and keep pure protocol normalization; no credential/TCC ownership. | -| `adapters/codex_macos.py` | Direct macOS/Codex CUA adapter. | `python/evaos_desktop_bridge/adapters/codex_macos.py` | A1 | Move intact behind the core host interface; A2/A4 later port required behavior to signed native CUA and retire direct Python execution after parity proof. | -| `adapters/customer_mac.py` | Customer-Mac normalization/orchestration with generation-guarded start, stop, kill, and takeover state. | `python/evaos_desktop_bridge/adapters/customer_mac.py` | A1 | Preserve race semantics and extract only Mac capabilities; replace native calls with a bounded helper-owned port. iPhone Mirroring branches are not packaged or exposed in v0.1. | -| `audit.py` | Local append-only JSON audit writes with content hashing, no-follow open, and mode hardening. | `python/evaos_desktop_bridge/persistence/audit.py` | A1 | Move intact and preserve current behavior; A4 later ports authoritative write/chain/redaction to Swift, leaving Python draft metadata only. Audit failure always denies execution. | -| `behavior_harness.py` | Behavior parity harness. | `tests/python/behavior_harness.py` | A1 | Move as parity oracle; run against embedded Python and native port fixtures. | -| `bundled_tools.py` | Tool discovery/invocation helpers. | `python/evaos_desktop_bridge/host/bundled_tools.py` | A1 | Remove PATH/Homebrew/system discovery; resolve only embedded, manifest-pinned resources. | -| `capability_manifest.py` | Capability catalog and availability. | `python/evaos_desktop_bridge/contracts/capability_manifest.py` | A1 | Generate language adapters from one versioned manifest; availability cannot imply authority. | -| `candidate_identity.py` | Packaged Workbench source-tree and application identity attestation. | `python/evaos_desktop_bridge/host/candidate_identity.py` | A1 | Move with the runtime and preserve candidate/provenance parity; never treat Python self-attestation as runtime or broker authority. #705 adds a separate native artifact verifier. | -| `cli.py` | Public-style bridge CLI with audit hashing, control-generation gates, and #709 staging-signer validation. | `python/evaos_desktop_bridge/host/cli.py` | A1 | Preserve hashed-evidence, stale-generation negatives, and validation of all four explicit staging signer settings before any plist/LaunchAgent mutation; keep only the helper-launched private host entry. | -| `connector_server.py` | Token-gated HTTP enrollment/dispatch with control-generation and kill-switch checks, plus iPhone routes. | `python/evaos_desktop_bridge/host/connector_server.py` | A1 | Move intact for Workbench parity only; A3 ports Mac dispatch to the helper and retires HTTP listener, URL/token, enrollment callback, and iPhone routes without losing race checks. | -| `helper_ipc.py` | Unix socket, file token, same-UID helper and direct AX/Quartz execution. | `python/evaos_desktop_bridge/host/helper_ipc.py` | A1 | Move intact behind the host API; A2 later replaces token/UID authority with signed XPC and ports CUA after parity proof. | -| `policy.py` | Capability policy table. | `python/evaos_desktop_bridge/policy/policy.py` | A1 | Move intact and bind contract parity tests; A4 later ports authority to Swift with selected tuple, digest, mode, epoch, approval, and audit checks. | -| `pre_canary.py` | Pre-live environment/control checks. | `tests/python/pre_canary.py` | A1 | Convert to hermetic source/embedded-runtime checks; no system Python or live customer dependency. | -| `qa_canary.py` | Connector/OpenClaw/Hermes suites plus source-bound selected-binding proof validation. | `tests/python/qa_canary.py` | A1 | Preserve scenario and proof-binding semantics; replace URL/token inputs with test relay/native client fixtures before A7. | -| `receipt_canary.py` | Strict signed runtime-receipt, public-attestation, replay, expiry, and candidate-binding verification. | `tests/python/receipt_canary.py` | A1 | Preserve #708 receipt schemas and negative proof as Workbench parity tests; #702 adds relay command authority and #706 proves the deployed composed route. | -| `queue.py` | Local queued-operation persistence. | `python/evaos_desktop_bridge/persistence/queue.py` | A1 | Move intact and preserve crash/restart behavior; A4 later ports bounded queue, expiry, epoch, revoke/kill cancellation, and crash clearing to Swift. | -| `redaction.py` | Separate recursive public-output and audit-value redaction. | `python/evaos_desktop_bridge/contracts/redaction.py` | A1 | Move both policies, align audit output with `audit_event.v1`, and keep the native helper as final enforcement point. | -| `schema.py` | Common result/error envelope builders. | `python/evaos_desktop_bridge/contracts/schema.py` | A1 | Replace ad-hoc shapes with generated/hand-verified v1 adapters in TS, Swift, and embedded Python. | -| `state.py` | Pairing/control/audit state plus locked generation transactions and persistent kill-switch state. | `python/evaos_desktop_bridge/persistence/state.py` | A1 | Preserve linearization/race negatives without raw tokens; A4 ports versioned state, Keychain references, epochs, revocation tombstone, and atomic recovery to Swift. | -| `types.py` | Python command/result types. | `python/evaos_desktop_bridge/contracts/types.py` | A1 | Generate or verify against v1 fixtures; unknown fields and schemas fail closed at authority boundaries. | +The inspected source contains 22 Python modules, including #708's `receipt_canary.py`. The `Target owner` column is intentionally singular and names the A1 canonical location. Every current runtime module first moves exactly once into an explicit subpackage under `packages/mac-connector-core/python/evaos_desktop_bridge`. `behavior_harness.py` becomes test-only, while the installed `pre_canary.py` and `qa_canary.py` commands and the production-imported `receipt_canary.py` remain packaged compatibility/proof surfaces under `proof/` until their Workbench callers are retired in later phases. The root has six direct children, the Python package has seven, and every subpackage stays below ten. Later A2-A4 native ports narrow or retire Python behavior only after parity proof and never leave a second source tree. + +| Current source under `resources/evaos-beta/bridge/src/evaos_desktop_bridge/` | Current role | Target owner | Phase | Migration action and exit check | +| ---------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------ | ----- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `__init__.py` | Package metadata/entry namespace. | `packages/mac-connector-core/python/evaos_desktop_bridge/__init__.py` | A1 | Move from the exact #699-accepted post-#709 baseline; version must match embedded manifest. | +| `adapters/__init__.py` | Adapter namespace. | `packages/mac-connector-core/python/evaos_desktop_bridge/adapters/__init__.py` | A1 | Move; export only private runtime adapters. | +| `adapters/codex_app_server.py` | Codex app-server protocol adapter. | `python/evaos_desktop_bridge/adapters/codex_app_server.py` | A1 | Move and keep pure protocol normalization; no credential/TCC ownership. | +| `adapters/codex_macos.py` | Direct macOS/Codex CUA adapter. | `python/evaos_desktop_bridge/adapters/codex_macos.py` | A1 | Move intact behind the core host interface; A2/A4 later port required behavior to signed native CUA and retire direct Python execution after parity proof. | +| `adapters/customer_mac.py` | Customer-Mac normalization/orchestration with generation-guarded start, stop, kill, and takeover state. | `python/evaos_desktop_bridge/adapters/customer_mac.py` | A1 | Preserve race semantics and extract only Mac capabilities; replace native calls with a bounded helper-owned port. iPhone Mirroring branches are not packaged or exposed in v0.1. | +| `audit.py` | Local append-only JSON audit writes with content hashing, no-follow open, and mode hardening. | `python/evaos_desktop_bridge/persistence/audit.py` | A1 | Move intact and preserve current behavior; A4 later ports authoritative write/chain/redaction to Swift, leaving Python draft metadata only. Audit failure always denies execution. | +| `behavior_harness.py` | Behavior parity harness. | `tests/python/behavior_harness.py` | A1 | Move as parity oracle; run against embedded Python and native port fixtures. | +| `bundled_tools.py` | Tool discovery/invocation helpers. | `python/evaos_desktop_bridge/host/bundled_tools.py` | A1 | Remove PATH/Homebrew/system discovery; resolve only embedded, manifest-pinned resources. | +| `capability_manifest.py` | Capability catalog and availability. | `python/evaos_desktop_bridge/contracts/capability_manifest.py` | A1 | Generate language adapters from one versioned manifest; availability cannot imply authority. | +| `candidate_identity.py` | Packaged Workbench source-tree and application identity attestation. | `python/evaos_desktop_bridge/host/candidate_identity.py` | A1 | Move with the runtime and preserve candidate/provenance parity; never treat Python self-attestation as runtime or broker authority. #705 adds a separate native artifact verifier. | +| `cli.py` | Public-style bridge CLI with audit hashing, control-generation gates, and #709 staging-signer validation. | `python/evaos_desktop_bridge/host/cli.py` | A1 | Preserve hashed-evidence, stale-generation negatives, and validation of all four explicit staging signer settings before any plist/LaunchAgent mutation; keep only the helper-launched private host entry. | +| `connector_server.py` | Token-gated HTTP enrollment/dispatch with control-generation and kill-switch checks, plus iPhone routes. | `python/evaos_desktop_bridge/host/connector_server.py` | A1 | Move intact for Workbench parity only; A3 ports Mac dispatch to the helper and retires HTTP listener, URL/token, enrollment callback, and iPhone routes without losing race checks. | +| `helper_ipc.py` | Unix socket, file token, same-UID helper and direct AX/Quartz execution. | `python/evaos_desktop_bridge/host/helper_ipc.py` | A1 | Move intact behind the host API; A2 later replaces token/UID authority with signed XPC and ports CUA after parity proof. | +| `policy.py` | Capability policy table. | `python/evaos_desktop_bridge/policy/policy.py` | A1 | Move intact and bind contract parity tests; A4 later ports authority to Swift with selected tuple, digest, mode, epoch, approval, and audit checks. | +| `pre_canary.py` | Installed pre-live environment/control command used by Workbench proof workflows. | `python/evaos_desktop_bridge/proof/pre_canary.py` | A1 | Keep packaged for Workbench parity, convert to hermetic source/embedded-runtime checks, and retain no system-Python or live-customer dependency; retire only with its installed callers. | +| `qa_canary.py` | Installed connector/OpenClaw/Hermes command plus source-bound selected-binding proof validation. | `python/evaos_desktop_bridge/proof/qa_canary.py` | A1 | Keep packaged for Workbench parity and preserve scenario/proof-binding semantics; replace URL/token inputs with test relay/native client fixtures before A7, then retire atomically with installed callers. | +| `receipt_canary.py` | Production-imported signed runtime-receipt, public-attestation, replay, expiry, and binding verifier. | `python/evaos_desktop_bridge/proof/receipt_canary.py` | A1 | Keep packaged because `connector_server.py` serves the #708 receipt route; preserve schemas and negative proof until the route is deliberately replaced, while #702 adds relay command authority. | +| `queue.py` | Local queued-operation persistence. | `python/evaos_desktop_bridge/persistence/queue.py` | A1 | Move intact and preserve crash/restart behavior; A4 later ports bounded queue, expiry, epoch, revoke/kill cancellation, and crash clearing to Swift. | +| `redaction.py` | Separate recursive public-output and audit-value redaction. | `python/evaos_desktop_bridge/contracts/redaction.py` | A1 | Move both policies, align audit output with `audit_event.v1`, and keep the native helper as final enforcement point. | +| `schema.py` | Common result/error envelope builders. | `python/evaos_desktop_bridge/contracts/schema.py` | A1 | Replace ad-hoc shapes with generated/hand-verified v1 adapters in TS, Swift, and embedded Python. | +| `state.py` | Pairing/control/audit state plus locked generation transactions and persistent kill-switch state. | `python/evaos_desktop_bridge/persistence/state.py` | A1 | Preserve linearization/race negatives without raw tokens; A4 ports versioned state, Keychain references, epochs, revocation tombstone, and atomic recovery to Swift. | +| `types.py` | Python command/result types. | `python/evaos_desktop_bridge/contracts/types.py` | A1 | Generate or verify against v1 fixtures; unknown fields and schemas fail closed at authority boundaries. | ## Connector-core A1 host interface diff --git a/packages/mac-connector-core/contracts/v1/index.ts b/packages/mac-connector-core/contracts/v1/index.ts index 01faad6b72..77f73c74ed 100644 --- a/packages/mac-connector-core/contracts/v1/index.ts +++ b/packages/mac-connector-core/contracts/v1/index.ts @@ -623,6 +623,11 @@ export const accessTransitionSchema = z path: ['to', 'policy_epoch'], }); } + const accessModeRank = { off: 0, ask_every_time: 1, full_access: 2 } as const; + const narrowsAccess = + transition.event === 'set_mode' && + transition.target_mode !== null && + accessModeRank[transition.target_mode] < accessModeRank[from.configured_mode]; const authorityInvalidating = [ 'restart', @@ -635,7 +640,8 @@ export const accessTransitionSchema = z 'binding_changed', 'grant_expired', ].includes(transition.event) || - (transition.event === 'set_mode' && transition.target_mode === 'off'); + (transition.event === 'set_mode' && transition.target_mode === 'off') || + narrowsAccess; if ( authorityInvalidating && (!transition.invalidated_pending_authority || !transition.safe_cancellation_requested) @@ -929,7 +935,7 @@ export const brokerControlEnvelopeSchema = z path: ['expires_at'], }); } - if (expiresAt > Date.parse(envelope.binding.grant_expires_at)) { + if (expiresAt >= Date.parse(envelope.binding.grant_expires_at)) { context.addIssue({ code: z.ZodIssueCode.custom, message: 'command authority must expire before the selected grant', @@ -1272,8 +1278,57 @@ export const coreHostResponseSchema = z path: ['result', 'kind'], }); } + + if ( + response.operation === 'status' && + response.result.kind === 'status' && + response.policy_epoch !== response.result.status.access.policy_epoch + ) { + context.addIssue({ + code: z.ZodIssueCode.custom, + message: 'status response policy epoch must match the nested access state', + path: ['result', 'status', 'access', 'policy_epoch'], + }); + } + if (response.operation === 'pair' && response.result.kind === 'pairing') { + if ( + response.result.pairing_state !== 'paired' || + response.result.device_id === null || + response.result.binding_fingerprint_sha256 === null + ) { + context.addIssue({ + code: z.ZodIssueCode.custom, + message: 'pair response must establish paired device and binding identity', + path: ['result', 'pairing_state'], + }); + } + } + if (response.operation === 'unpair' && response.result.kind === 'pairing') { + if ( + response.result.pairing_state !== 'unpaired' || + response.result.device_id !== null || + response.result.binding_fingerprint_sha256 !== null + ) { + context.addIssue({ + code: z.ZodIssueCode.custom, + message: 'unpair response must clear paired device and binding identity', + path: ['result', 'pairing_state'], + }); + } + } if (response.result.kind !== 'lifecycle') return; + if ( + response.operation === 'connect' && + (response.result.pairing_state !== 'paired' || response.result.transport_state !== 'connected') + ) { + context.addIssue({ + code: z.ZodIssueCode.custom, + message: 'connect response must report paired access and connected transport', + path: ['result', 'transport_state'], + }); + } + if ( ['pause', 'stop', 'revoke', 'activate_kill_switch', 'shutdown'].includes(response.operation) && response.result.effective_mode !== 'off' @@ -1295,12 +1350,12 @@ export const coreHostResponseSchema = z }); } if ( - ['stop', 'shutdown'].includes(response.operation) && + ['disconnect', 'stop', 'shutdown'].includes(response.operation) && ['connecting', 'connected'].includes(response.result.transport_state) ) { context.addIssue({ code: z.ZodIssueCode.custom, - message: 'stop and shutdown responses cannot retain an active transport', + message: 'disconnect, stop, and shutdown responses cannot retain an active transport', path: ['result', 'transport_state'], }); } diff --git a/tests/contract/macAccessContracts.test.ts b/tests/contract/macAccessContracts.test.ts index 2bd68b07e2..fa3ce54027 100644 --- a/tests/contract/macAccessContracts.test.ts +++ b/tests/contract/macAccessContracts.test.ts @@ -262,7 +262,7 @@ describe('evaOS Mac Access v1 contracts', () => { expected_policy_epoch: 7, }; const reason = { reason_code: 'local_user_request' }; - const requests = [ + const requests: unknown[] = [ { ...identity, request_id: 'host-status', operation: 'status', expected_policy_epoch: null, sequence: 1 }, { ...identity, @@ -325,7 +325,7 @@ describe('evaOS Mac Access v1 contracts', () => { expect(dispatchResult.error.issues.some((issue) => issue.path.join('/') === 'envelope/sequence')).toBe(true); } - const statusResponse = { + const statusResponse: Record = { schema_version: 'evaos.mac_connector_core.host_response.v1', request_id: 'host-status-unsafe', host_session_id: 'host-session-01', @@ -349,7 +349,7 @@ describe('evaOS Mac Access v1 contracts', () => { ).toBe(true); } - const auditResponse = { + const auditResponse: Record = { schema_version: 'evaos.mac_connector_core.host_response.v1', request_id: 'host-audit-unsafe', host_session_id: 'host-session-01', @@ -415,6 +415,20 @@ describe('evaOS Mac Access v1 contracts', () => { } }); + it('rejects command authority that shares the selected grant expiry boundary', () => { + const envelope = cloneJson( + brokerControlEnvelopeSchema.parse(readJson(path.join(validRoot, 'authority/broker-control.json'))) + ); + envelope.expires_at = envelope.binding.grant_expires_at; + envelope.authorization.payload.expires_at = envelope.expires_at; + + const parsed = brokerControlEnvelopeSchema.safeParse(envelope); + expect(parsed.success).toBe(false); + if (!parsed.success) { + expect(parsed.error.issues.some((issue) => issue.path.join('/') === 'binding/grant_expires_at')).toBe(true); + } + }); + it('rejects silent binding replacement outside an explicit binding transition', () => { const transition = cloneJson(readJson(path.join(validRoot, 'state/access-transition-stop.json'))) as { to: { @@ -434,6 +448,110 @@ describe('evaOS Mac Access v1 contracts', () => { } }); + it('invalidates pending authority when access narrows from full access to ask every time', () => { + const transition = cloneJson(readJson(path.join(validRoot, 'state/access-transition.json'))) as { + event: string; + explicit_user_consent: boolean; + invalidated_pending_authority: boolean; + safe_cancellation_requested: boolean; + target_mode: string | null; + from: { configured_mode: string; effective_mode: string }; + to: { configured_mode: string; effective_mode: string }; + }; + transition.event = 'set_mode'; + transition.explicit_user_consent = true; + transition.invalidated_pending_authority = false; + transition.safe_cancellation_requested = false; + transition.target_mode = 'ask_every_time'; + transition.from.configured_mode = 'full_access'; + transition.from.effective_mode = 'full_access'; + transition.to.configured_mode = 'ask_every_time'; + transition.to.effective_mode = 'ask_every_time'; + + const parsed = accessTransitionSchema.safeParse(transition); + expect(parsed.success).toBe(false); + if (!parsed.success) { + expect(parsed.error.issues.some((issue) => issue.path.join('/') === 'event')).toBe(true); + } + }); + + it('rejects successful host responses whose operation outcome did not take effect', () => { + const status = cloneJson(readJson(path.join(validRoot, 'state/local-status.json'))); + const identity: Record = { + schema_version: 'evaos.mac_connector_core.host_response.v1', + request_id: 'host-outcome-check', + host_session_id: 'host-session-01', + sequence: 1, + ok: true, + policy_epoch: 7, + error: null, + }; + const responses: Array & { operation: string; expectedPath: string }> = [ + { + ...identity, + operation: 'status', + policy_epoch: 8, + result: { kind: 'status', status }, + expectedPath: 'result/status/access/policy_epoch', + }, + { + ...identity, + operation: 'pair', + result: { + kind: 'pairing', + pairing_state: 'unpaired', + device_id: null, + binding_fingerprint_sha256: null, + }, + expectedPath: 'result/pairing_state', + }, + { + ...identity, + operation: 'unpair', + result: { + kind: 'pairing', + pairing_state: 'paired', + device_id: 'mac-01', + binding_fingerprint_sha256: '1'.repeat(64), + }, + expectedPath: 'result/pairing_state', + }, + { + ...identity, + operation: 'connect', + result: { + kind: 'lifecycle', + effective_mode: 'ask_every_time', + pairing_state: 'paired', + transport_state: 'disconnected', + }, + expectedPath: 'result/transport_state', + }, + { + ...identity, + operation: 'disconnect', + result: { + kind: 'lifecycle', + effective_mode: 'ask_every_time', + pairing_state: 'paired', + transport_state: 'connected', + }, + expectedPath: 'result/transport_state', + }, + ]; + + for (const response of responses) { + const parsed = coreHostResponseSchema.safeParse(response); + expect(parsed.success, response.operation).toBe(false); + if (!parsed.success) { + expect( + parsed.error.issues.some((issue) => issue.path.join('/') === response.expectedPath), + `${response.operation} must reject at ${response.expectedPath}` + ).toBe(true); + } + } + }); + it('rejects lifecycle barrier responses that report unsafe operation-specific state', () => { const base = cloneJson(readJson(path.join(contractRoot, 'fixtures/host/host-response.json'))) as { operation: string; diff --git a/tsconfig.json b/tsconfig.json index 6344a19fba..e450410aac 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -23,6 +23,8 @@ }, "include": [ "packages/desktop/src/**/*", + "packages/mac-connector-core/**/*.ts", + "tests/contract/macAccess*.test.ts", "uno.config.ts", "packages/desktop/electron.vite.config.ts", "playwright.config.ts", From d6ce1ecd95f616c4bd86b7735bf75ce838a99ae7 Mon Sep 17 00:00:00 2001 From: Eva Date: Wed, 15 Jul 2026 19:38:26 +0700 Subject: [PATCH 07/14] fix(mac-access): close architecture review gaps --- bun.lock | 3 + docs/evaos/mac-access/architecture.md | 10 +- docs/evaos/mac-access/migration-map.md | 24 +- docs/evaos/mac-access/threat-model.md | 62 +- package.json | 1 + .../v1/fixtures/host/host-response.json | 2 + .../contracts/v1/fixtures/invalid/policy.json | 23 +- .../valid/audit/audit-chain-golden.json | 39 +- .../v1/fixtures/valid/audit/audit-event.json | 11 +- .../valid/authority/begin-pairing-action.json | 22 + .../valid/authority/local-action.json | 4 +- .../valid/state/access-transition-stop.json | 18 +- .../v1/fixtures/valid/state/local-status.json | 19 +- .../mac-connector-core/contracts/v1/index.ts | 834 ++++++++++++++++-- tests/contract/macAccessContracts.test.ts | 554 +++++++++++- tests/contract/macAccessCryptography.test.ts | 125 ++- 16 files changed, 1568 insertions(+), 183 deletions(-) create mode 100644 packages/mac-connector-core/contracts/v1/fixtures/valid/authority/begin-pairing-action.json diff --git a/bun.lock b/bun.lock index ff7f076392..36cec05a7d 100644 --- a/bun.lock +++ b/bun.lock @@ -47,6 +47,7 @@ "better-sqlite3": "^12.4.1", "buffer": "^6.0.3", "builder-util-runtime": "9.5.1", + "canonicalize": "3.0.0", "classnames": "^2.5.1", "cookie": "^1.0.2", "cookie-parser": "^1.4.7", @@ -1817,6 +1818,8 @@ "caniuse-lite": ["caniuse-lite@1.0.30001779", "", {}, "sha512-U5og2PN7V4DMgF50YPNtnZJGWVLFjjsN3zb6uMT5VGYIewieDj1upwfuVNXf4Kor+89c3iCRJnSzMD5LmTvsfA=="], + "canonicalize": ["canonicalize@3.0.0", "", { "bin": { "canonicalize": "bin/canonicalize.js" } }, "sha512-yYLfHyDMIXRyRqsKBRLX023riFLpXY2YOfdtqKXZRZy9qsfOJ9U+4F9YZL7MEzL5+ziN2x2nlBvY/Voi3EBljA=="], + "ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="], "chai": ["chai@6.2.2", "", {}, "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg=="], diff --git a/docs/evaos/mac-access/architecture.md b/docs/evaos/mac-access/architecture.md index 20d462f9e8..4216e43096 100644 --- a/docs/evaos/mac-access/architecture.md +++ b/docs/evaos/mac-access/architecture.md @@ -131,11 +131,11 @@ connector: anchor apple generic and certificate leaf[subject.OU] = "TC6MS3T6NN" The contract SHA-256 is over the exact UTF-8 requirement text above: app `da635352f249b4213aa1a96c41d7979d8b25d86b056b9f0929c1b414e35896fb`, helper `222107bb855cfc463805777c76ca8cfdac0d1145957c5f190c234e52bfd277aa`, connector `0c3de778270de5b4a1992d0e13d4f27e41929c7ace94ae143bcba92a555be422`. Authenticated Workbench client allowlists use the same anchor/Team ID with identifier `com.evaos.workbench` (digest `ff4fc126bb70bbf7fcc3cc0957377d67185124b5e31b19760357333a8a0ae329`) or the shipped legacy identifier `com.electricsheephq.EvaDesktop` (digest `c6038eaf8a20c83a1aabfd1bf8eb4053877b7af5627e570eb1de37721e76b776`). The native verifier selects the frozen requirement for the expected role, obtains the connection process from the audit token, evaluates that `SecCode` against the requirement, and records the digest of the frozen requirement used. Wire callers cannot supply a trusted role or digest. -The artifact relationship is frozen before implementation: the app main executable is `Contents/MacOS/evaOS Mac Access`; the persistent connector is a nested signed login item at `Contents/Library/LoginItems/evaOS Mac Access Connector.app`; and the CUA helper is a nested signed XPC service at `Contents/XPCServices/evaOS Mac Access Helper.xpc`. The helper is launched only by the signed app/connector and accepts only frozen app or Workbench client requirements. The app and connector receive no production credential access group. Only the helper receives `TC6MS3T6NN.com.evaos.mac-access.credentials.epoch-N`; no target receives `get-task-allow`, JIT, unsigned-executable-memory, disable-library-validation, inbound network-server, or Apple-events automation entitlements. If App Sandbox is enabled, outbound `com.apple.security.network.client` belongs only to the connector. Development entitlements and access groups are disjoint. +The artifact relationship is frozen before implementation: the app main executable is `Contents/MacOS/evaOS Mac Access`; the persistent connector is a nested signed login item at `Contents/Library/LoginItems/evaOS Mac Access Connector.app`; and the CUA helper is a nested signed XPC service at `Contents/XPCServices/evaOS Mac Access Helper.xpc`. The helper is launched only by the signed app/connector and accepts only frozen app or Workbench client requirements. The app and connector receive no production credential access group. Only the helper receives `TC6MS3T6NN.com.evaos.mac-access.credentials.epoch-N`; no target receives `get-task-allow`, JIT, unsigned-executable-memory, disable-library-validation, inbound network-server, or Apple-events automation entitlements. The helper is the authenticated relay client; if App Sandbox is enabled, outbound `com.apple.security.network.client` belongs only to the helper. The connector login item owns persistence and helper launch, not WebSocket or credential authority. Development entitlements and access groups are disjoint. The main executable inside `/Applications/evaOS Mac Access.app` is the intended TCC responsible executable shown to the user. Only the embedded helper invokes Accessibility and Screen Recording APIs. #705 proves—not chooses—the actual `codesign -dr -` output, nested-code placement, entitlements, responsible identity, helper relationship, and TCC attribution on the installed artifact. Any mismatch stops live proof and returns to #699 before an identity or relationship changes. Local IPC derives and verifies the caller from the connection audit token. A request cannot assert its own identity. -Keychain custody is frozen as follows: the production access-group base is `com.evaos.mac-access.credentials`, with an effective group `com.evaos.mac-access.credentials.epoch-N` for each security-critical credential epoch; development uses the disjoint `com.evaos.mac-access.development.credentials` base; the item service is `com.evaos.mac-access.connector-credential`; items are non-synchronizing `kSecAttrAccessibleWhenUnlockedThisDeviceOnly`; and only the production helper target for the accepted epoch receives that epoch's production access-group entitlement. The menu app, Workbench, Python, debug builds, and old/replaced helpers must be denied. Connector private keys are non-exportable `SecKey` material, using hardware protection where the selected algorithm and Mac support it. #705 must record the resolved Team-ID prefix and prove the built entitlements/ACLs; it may not broaden this custody contract. +Keychain custody is frozen as follows: the production credential access-group base is `com.evaos.mac-access.credentials`, with an effective group `com.evaos.mac-access.credentials.epoch-N` for each security-critical credential epoch; development uses the disjoint `com.evaos.mac-access.development.credentials` base; and the credential item service is `com.evaos.mac-access.connector-credential`. The independent audit-anchor item uses service `com.evaos.mac-access.audit-anchor` and effective access group `com.evaos.mac-access.audit-anchor.epoch-N`. Both item classes are helper-only, non-synchronizing `kSecAttrAccessibleWhenUnlockedThisDeviceOnly`, and their effective group epoch must equal the broker-accepted helper security epoch. The menu app, connector login item, Workbench, Python, debug builds, and old/replaced helpers must be denied. Connector private keys are non-exportable `SecKey` material, using hardware protection where the selected algorithm and Mac support it. #705 must record the resolved Team-ID prefix and prove the built entitlements/ACLs; it may not broaden this custody contract. The stable helper identity does not make an old validly signed binary safe. ws-proxy #73 registration and reconnect must reject a missing or below-floor immutable build/security identity before accepting the device credential. Raising a security-critical floor atomically issues a new credential/key into a new epoch access group, registers only the exact accepted build/source/security epoch, commits the new broker epoch, then revokes and deletes the prior credential. Old binaries lack the new group entitlement and their old broker credential is rejected. Exceptional rollback uses `evaos.mac_access.rollback_authorization_payload.v1`, signed over RFC 8785/JCS bytes, to name the authorization ID, exact source and target version/commit/lineage/security epoch, schema reader/writer versions, both credential epochs, resulting reader/writer security and schema floors, and issue/expiry interval. The persisted verified pre-rollback build must equal the signed source, and relay/local status must equal the signed target and resulting floors. `golden/rollback-authorization-golden.json` freezes canonical bytes, digest, broker key, and Ed25519 signature. No opaque ID or local-only authorization can revive an old credential. @@ -263,6 +263,8 @@ The existing `helper_ipc.py` same-UID plus file-token check is retained only as Configured mode is user intent. Effective mode is the helper's current authority after binding, lifecycle, TCC, transport, audit, and safety constraints. +Every transport state other than `connected` forces effective mode to `Off`. A helper runtime-instance change is valid if and only if it is recorded as a `restart`; restart and resume preserve configured user intent, clear prior Full Access confirmation, and require reconfirmation before Full Access can become effective again. Successful access-mode host responses echo the request-bound target and prove that the same configured mode was committed; clients validate the response together with the exact request identity and target. Host lifecycle requests carry only the fixed operation and expected policy epoch: the helper derives lifecycle reason codes from the accepted operation and current state, never from caller-supplied text. + | From | Event | To | Required effect | | ----------------------- | ---------------------------------------------------------------- | ---------------------------------------------------- | ---------------------------------------------------------------------------------------------------------- | | unpaired `Off` | one-time code claimed and user confirms selected customer/device | paired `Ask Every Time` | Rotate policy epoch; no action is pre-approved. | @@ -281,7 +283,9 @@ Ask Every Time approval defaults to 60 seconds or the earlier broker authority e ## Audit and redaction -The helper writes an append-only, owner-only local journal with a monotonic sequence, previous-record digest, and record digest. `record_sha256` is SHA-256 over the exact UTF-8 RFC 8785/JCS serialization of the versioned `evaos.mac_access.audit_event.v1` payload with `record_sha256` excluded. The next payload's `previous_record_sha256` must equal that digest. `audit-chain-golden.json` freezes a two-record chain, canonical bytes, and both digests; edit/delete/reorder/previous-digest checks are required runtime tests. The payload records non-secret identity kind, binding fingerprint, command/request IDs and digests, access mode, outcome, reason code, and an explicit bounded allowlist of evidence metadata. Arbitrary evidence keys/values are not valid contract data. +The helper writes an append-only, owner-only local journal with a monotonic sequence, previous-record digest, and record digest. `record_sha256` is SHA-256 over the exact UTF-8 RFC 8785/JCS serialization of the versioned `evaos.mac_access.audit_event.v1` payload with `record_sha256` excluded. Every returned audit record is canonicalized and rehashed before its chain, cursor, or receipt fields are trusted. The next payload's `previous_record_sha256` must equal that digest. `audit-chain-golden.json` freezes a causal command-decision/result pair, canonical bytes, and both digests; edit/delete/reorder/previous-digest and rehashed-but-uncorrelated checks are required runtime tests. Command decisions require binding, command, and request digests and use allowed/denied outcomes; command results cite the exact decision audit ID and use executed/failed/stopped outcomes. Action responses carry the complete verified decision record and, for attempted actions, its later verified causal result record; bare caller-chosen audit IDs are insufficient. A paged result may use an off-page causal decision only when that complete verified record is exactly the digest-bound page anchor; otherwise the decision must occur in the verified page or be supplied from the client's previously verified cache. Non-command events cannot carry command correlation fields. The actor is a closed non-PII pair derived by the helper: console user, authenticated Workbench main process, broker-selected OpenClaw/Hermes runtime, or one of the owned helper subsystems. The payload otherwise records access mode, outcome, a closed reason code, and field-specific allowlisted evidence metadata. Target paths are persisted only as SHA-256 digests; capabilities, access states, transport states, and detail codes use owned closed schemas. Arbitrary evidence keys/values are not valid contract data. + +The hash chain is committed outside the journal. The helper owns a separately protected Keychain anchor containing a stable journal ID plus committed sequence/audit ID/record digest and an optional complete pending next-record tuple, using a helper-only, non-synchronizing, ThisDeviceOnly item distinct from connector credentials. To append, the helper computes the next record, compare-and-swaps the anchor from committed-only to the exact pending tuple, appends and durably synchronizes that record, then compare-and-swaps pending to committed. Decision actuation is allowed only after the final commit succeeds. On startup, a pending tuple with no matching durable record is cleared only after the committed prefix verifies; a matching pending record is finalized without actuation. At startup and before every actuation, the helper verifies the stable journal ID and prefix through the exact committed sequence/digest. A shorter valid chain, a same-user whole-journal replacement with recomputed internal hashes, an anchor rollback/fork, or any unexplained suffix marks audit unhealthy and forces effective `Off`. Anchor read, compare-and-swap, or durability uncertainty denies actuation. Existing unanchored Workbench journals can migrate only while effective access is `Off`, after full validation and a redacted migration audit; otherwise they are quarantined. Default audit and support evidence forbids raw screenshots, Accessibility trees, typed text, clipboard content, cookies, auth headers, passwords, tokens, connector URLs, private addresses, Keychain references, environment dumps, and unredacted native exception text. Optional artifacts require a separate exact-scope user decision, encrypted storage, retention deadline, access audit, and explicit purge; that feature is outside v0.1 unless separately approved. diff --git a/docs/evaos/mac-access/migration-map.md b/docs/evaos/mac-access/migration-map.md index 96358e3137..1893b42302 100644 --- a/docs/evaos/mac-access/migration-map.md +++ b/docs/evaos/mac-access/migration-map.md @@ -57,18 +57,18 @@ The inspected source contains 22 Python modules, including #708's `receipt_canar The singular runtime owner is `packages/mac-connector-core/python/evaos_desktop_bridge/host/api.py`; the language-neutral schema source is `packages/mac-connector-core/contracts/v1`. The interface is private to the helper-launched embedded core and has no Electron, renderer, HTTP, public socket, PATH, or customer-managed runtime dependency. -| Required operation | Versioned request/response route | A1 proof | -| -------------------- | ---------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- | -| status | `status` -> status result | Start the core without Electron and compare the schema-bound status to source parity fixtures. | -| pair / unpair | `pair`, `unpair` -> pairing result | Public code plus local installation nonce; one-time claim/revoke tests; no credential in result or logs. | -| connect / disconnect | `connect`, `disconnect` -> lifecycle result | Exact selected binding, expected policy epoch, private fake transport; no direct-IP/public-listener fallback. | -| access mode | `set_access_mode` -> lifecycle result | Off/Ask/Full transition parity, confirmation rules, and stale-epoch negatives. | -| action dispatch | `dispatch_action` -> action result | Full broker envelope validation through fake native ports; no Electron import or renderer-callable command body. | -| audit summary | `audit_summary` -> bounded redacted summary | Maximum 100 allowlisted events; secret and unknown-field negatives. | -| pause / resume | fixed lifecycle operations -> lifecycle result | Pause/resume state and offline operation tests. | -| stop | `stop` -> lifecycle result | Synchronously rotate epoch, force effective Off, invalidate pending authority, and safely cancel active work. | -| revoke / kill | fixed lifecycle operations -> lifecycle result | Persistent revocation/kill state, offline operation, and fail-closed race tests. | -| shutdown | `shutdown` -> lifecycle result | Stop new work after policy-safe teardown, close the private channel, and prove no orphan process. | +| Required operation | Versioned request/response route | A1 proof | +| -------------------- | ---------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| status | `status` -> status result | Start the core without Electron and compare the schema-bound status to source parity fixtures. | +| pair / unpair | `pair`, `unpair` -> pairing result | Public code plus local installation nonce; one-time claim/revoke tests; no credential in result or logs. | +| connect / disconnect | `connect`, `disconnect` -> lifecycle result | Exact selected binding, expected policy epoch, private fake transport; no direct-IP/public-listener fallback. | +| access mode | `set_access_mode` -> lifecycle result | Off/Ask/Full transition parity, request/response target binding, confirmation rules, and stale-epoch negatives. | +| action dispatch | `dispatch_action` -> action result | Full broker envelope validation plus complete digest-verified decision/result audit receipts through fake native ports; no Electron import or renderer-callable command body. | +| audit summary | `audit_summary` -> bounded redacted summary | Maximum 100 rehashed allowlisted contiguous events; cursors bind sequence and digest; off-page causality is accepted only at the exact anchor; external-anchor tamper and secret negatives. | +| pause / resume | fixed lifecycle operations -> lifecycle result | Pause/resume, runtime-identity, configured-intent preservation, reconfirmation, and offline-operation tests. | +| stop | `stop` -> lifecycle result | Synchronously rotate epoch, force effective Off, invalidate pending authority, and safely cancel active work. | +| revoke / kill | fixed lifecycle operations -> lifecycle result | Persistent revocation/kill state, offline operation, and fail-closed race tests. | +| shutdown | `shutdown` -> lifecycle result | Stop new work after policy-safe teardown, close the private channel, and prove no orphan process. | Every request binds `host_session_id`, monotonic safe-integer `sequence`, `request_id`, and the safe-integer expected policy epoch where applicable. #700 must run the complete operation matrix through the packaged embedded core using fake filesystem/native/transport ports and prove import scans contain no Electron or renderer dependency. The existing Workbench `local_action.v1` remains a separate authenticated client surface and cannot substitute for this host API. diff --git a/docs/evaos/mac-access/threat-model.md b/docs/evaos/mac-access/threat-model.md index a51b9996d3..8747fbe56b 100644 --- a/docs/evaos/mac-access/threat-model.md +++ b/docs/evaos/mac-access/threat-model.md @@ -65,40 +65,40 @@ Data crossing each boundary is allowlisted, length-bounded, versioned, and fail ## Threat table -| Threat | Attack or failure | Required prevention/detection | Fail-closed response | Proof gate | -| ------------------------------ | ------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- | -| Replay | Reuse context ID, session/channel generation, command ID, nonce, sequence, approval, or result receipt. | Complete RFC 8785 signed command payload; bounded replay cache persisted across reconnect/helper restart until maximum expiry; single-use approval. | Deny before prompt/audit decision; record `command_replayed`; never extend expiry. | Submit the same valid envelope twice and across reconnect/restart. Second and later attempts fail. | -| Stale binding | Use old binding ID/version after selection changes. | Compare signed context, outer tuple, Keychain enrollment, and current policy epoch exactly. | Invalidate queue/approvals; close old channel; effective `Off` until new binding is confirmed. | Old and new bindings interleaved; only current binding is accepted. | -| Wrong customer | Cross-tenant command or mismatched customer/VM. | Server-owned context plus local tuple equality before policy. | Deny and raise a high-severity redacted audit event; no prompt. | Mutate outer and signed customer/VM independently and together. All fail without matching enrollment. | -| Wrong Mac/device | Command targets another connector device or key. | Device, installation, and connector-key identity bound to enrollment and command authorization. | Deny; rotate/disable channel if broker claims disagree. | Structurally valid wrong-device fixture fails at runtime. | -| Duplicate process | Old Workbench connector and Mac Access both run, or two helpers race. | Atomic local leader lease plus one broker compare-and-swap that swaps the legacy and prepared grant/lease; signed identity/liveness checks. | Both clients deny work during ambiguity and never unlink/kill an unknown owner. | Launch/race/upgrade/pre-commit-abort/post-commit-same-identity-repair tests show one listener/channel/grant/audit writer. | -| Local untrusted client | Same-user malware steals token or connects to socket/XPC. | XPC audit token, `SecCode`, team/anchor, identifier, hardened runtime, designated requirement, per-connection nonce/epoch. | Reject connection before decoding mutation; no status details beyond generic denial. | Unsigned, ad-hoc, wrong-team, renderer, shell child, stale PID, and stolen-token tests. | -| Host interface escape | Renderer, Electron, shell child, or stale embedded process invokes/replays a core operation. | Helper-created inherited private channel; fixed host schema; host-session ID; monotonic sequence; expected policy epoch; no HTTP/public socket/generic command. | Reject before state or native-port access; rotate host session; effective Off on channel ambiguity. | Unknown operation/field, wrong session, duplicate/reordered sequence, stale epoch, Electron import, and orphan tests. | -| Stolen pairing code | Attacker claims a copied public code. | High-entropy one-time code, short expiry, rate limit, atomic claim, installation nonce, local customer/device confirmation before state transition. | Claim expires/locks; pairing remains Off; rotate code. | Reuse, concurrent claim, expired code, wrong installation, wrong customer, and dismissed confirmation. | -| Revoked grant | Broker or local revoke occurs while commands are queued/in flight. | Local revocation tombstone and policy epoch checked before prompt, before actuation, and at safe cancellation boundaries. | Block new work synchronously; clear queue/approvals; close channel; erase active credential. | Race revoke against queued, prompted, executing, reconnecting, and offline commands. | -| Offline broker | Existing channel drops but queued authority remains. | Monotonic authority deadline; disconnect barrier; no offline extension. | New remote actuation denied. Local status, stop, revoke, kill, and audit remain available. | Disconnect before/after prompt and execution; no command starts after barrier. | -| Helper replacement | Attacker or stale updater swaps helper binary/service. | Signed embedding, designated requirement pin, code validity, expected path plus identity, versioned Keychain credential group, update handoff protocol. | Refuse leadership/Keychain/channel; effective Off; show repair path. | Replace, ad-hoc sign, wrong team, moved path, old signed version, and partial update tests. | -| Downgrade | Old but validly signed app reads new state or reacquires credentials. | Broker rejects missing/below-floor immutable build identity; critical floor rotation creates a new credential/access-group epoch and revokes the old credential; rollback names exact target. | Refuse transport/Keychain leadership and remain Off; preserve state for compatible recovery. | Install old signed builds below/above the floor and interrupt every credential rotation/update/rollback transition. | -| Crash recovery | Full Access or stale approval silently resumes. | Runtime-instance-bound Full Access confirmation; ephemeral approval store; policy epoch increment on recovery. | Effective Ask Every Time or Off; queue and approvals cleared. | Kill -9 menu/helper/Python at every transition and relaunch. | -| Request tampering | Change command body after approval/signature. | Canonical request digest in command authorization and local approval; recompute immediately before execution. | Deny without executing; invalidate approval. | Mutate every approved field, target snapshot/path, capability, and encoding. | -| Malicious/compromised renderer | Invoke generic native IPC or obtain secrets. | Main-process allowlist and redaction; authenticated helper accepts only signed main processes and fixed actions. | Deny; renderer gets generic code and audit ID only. | XSS/DevTools-like calls cannot reach raw XPC, CUA, Keychain, channel, or logs. | -| Python compromise | Planner process reads secrets or calls CUA directly. | Sandboxed/minimal environment; no secret FDs; native ports; normalized bounded messages; helper revalidates output. | Terminate Python; effective Off for command path; helper and local controls remain available. | Python emits unknown action, oversized payload, forged result, path traversal, or direct framework attempt. | -| Secret/log leakage | Token, typed text, screenshot, AX tree, path, env, or address enters audit/support logs. | Redaction schema denylist plus allowlisted evidence; bounded errors; secret scan. | Reject audit event/receipt; actuation denied if decision cannot be recorded safely. | Negative fixture and recursive scans over logs, crashes, diagnostics, issue packets. | -| Audit deletion/tamper | User malware truncates or edits local audit. | Owner-only files, append-only API, contract-required monotonic sequence/previous-record/record digests, optional broker receipt correlation. | Mark chain broken, effective Off, preserve evidence, require repair. | Edit/delete/reorder records and verify detection before next action. | -| Audit unavailable | Disk full, permissions wrong, corruption, I/O failure. | Pre-execution durable decision write and health check. | Effective Off; no actuation; minimal local blocker if possible. | Fault injection for open/write/fsync/rename/disk-full failures. | -| Kill-switch race | Command starts while local kill is activating. | In-memory atomic deny barrier first; policy epoch rotation; checks before prompt and actuation; safe cancellation. | No new action after barrier; in-flight action stops at safe boundary; channel cleanup best effort. | High-rate command stream while kill switch is toggled. | -| Approval confusion | Prompt label differs from executable target or is reused. | Approval binds selected tuple, capability, normalized target, request digest, policy epoch, and expiry. | Dismiss/change/timeout denies; changed target requires new prompt. | TOCTOU mutation between preview, prompt, and execution. | -| Broker key rotation | Connector accepts an old/unknown signing key or bricks on valid rotation. | Signed keyset with activation/retirement windows; key ID pinning; rollback plan. | Unknown/retired key denies; never fall back unsigned. | Overlap, early/late rotation, rollback, missing keyset, and clock skew. | -| Clock manipulation | Wall clock extends expiry or breaks ordering. | Signed absolute time plus monotonic elapsed deadline established on receipt; bounded clock skew. | Suspicious jump denies current channel and reconnects. | Move wall clock forward/back during queued/prompted commands. | -| Oversized/flooded input | Memory/CPU/disk exhaustion via WebSocket or XPC. | Frame limits, rate limits, bounded queue, backpressure, timeouts, bounded audit text. | Drop/reject and eventually close abusive channel; local kill remains responsive. | Oversized, fragmented, high-rate, and slow-loris scenarios. | -| Direct-network bypass | Runtime discovers a Mac URL/IP/token or public listener. | No target listener; outbound relay only; firewall/listener checks; no renderer/broker payload contains endpoint. | Pairing/transport blocked if an unauthorized listener is detected. | `lsof`/packet evidence on pristine and coexistence Macs; prompt/output secret scans. | -| Update split-brain | New menu with old helper or vice versa accepts incompatible authority. | Compatibility handshake, signed build identity, atomic handoff, access Off during update. | Refuse channel and mutations; rollback to exact signed compatible pair. | Interrupt update at each file/service transition. | -| Orphan cleanup abuse | Installer kills/deletes unrelated same-name process/file. | Match recorded path plus signed identity and installation ID; never trust name/PID alone. | Leave unknown process untouched and show support blocker. | Decoy binaries/processes and relocated prior versions. | -| Workbench fallback takeover | Workbench silently starts its legacy connector after Mac Access cutover or when Mac Access is absent/incompatible. | Before atomic cutover, the shipped legacy owner may continue only as the still-active prior owner. After A5 commit, the Workbench connector start path is removed; absence/incompatibility produces install/repair blocker or same-identity launch only. | Workbench shows Mac Access status or blocker; no second-identity fallback start. | Start both apps in every order, remove/invalidate Mac Access, crash either, upgrade/downgrade each. | +| Threat | Attack or failure | Required prevention/detection | Fail-closed response | Proof gate | +| ------------------------------ | ------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- | +| Replay | Reuse context ID, session/channel generation, command ID, nonce, sequence, approval, or result receipt. | Complete RFC 8785 signed command payload; bounded replay cache persisted across reconnect/helper restart until maximum expiry; single-use approval. | Deny before prompt/audit decision; record `command_replayed`; never extend expiry. | Submit the same valid envelope twice and across reconnect/restart. Second and later attempts fail. | +| Stale binding | Use old binding ID/version after selection changes. | Compare signed context, outer tuple, Keychain enrollment, and current policy epoch exactly. | Invalidate queue/approvals; close old channel; effective `Off` until new binding is confirmed. | Old and new bindings interleaved; only current binding is accepted. | +| Wrong customer | Cross-tenant command or mismatched customer/VM. | Server-owned context plus local tuple equality before policy. | Deny and raise a high-severity redacted audit event; no prompt. | Mutate outer and signed customer/VM independently and together. All fail without matching enrollment. | +| Wrong Mac/device | Command targets another connector device or key. | Device, installation, and connector-key identity bound to enrollment and command authorization. | Deny; rotate/disable channel if broker claims disagree. | Structurally valid wrong-device fixture fails at runtime. | +| Duplicate process | Old Workbench connector and Mac Access both run, or two helpers race. | Atomic local leader lease plus one broker compare-and-swap that swaps the legacy and prepared grant/lease; signed identity/liveness checks. | Both clients deny work during ambiguity and never unlink/kill an unknown owner. | Launch/race/upgrade/pre-commit-abort/post-commit-same-identity-repair tests show one listener/channel/grant/audit writer. | +| Local untrusted client | Same-user malware steals token or connects to socket/XPC. | XPC audit token, `SecCode`, team/anchor, identifier, hardened runtime, designated requirement, per-connection nonce/epoch. | Reject connection before decoding mutation; no status details beyond generic denial. | Unsigned, ad-hoc, wrong-team, renderer, shell child, stale PID, and stolen-token tests. | +| Host interface escape | Renderer, Electron, shell child, or stale embedded process invokes/replays a core operation. | Helper-created inherited private channel; fixed host schema; host-session ID; monotonic sequence; expected policy epoch; no HTTP/public socket/generic command. | Reject before state or native-port access; rotate host session; effective Off on channel ambiguity. | Unknown operation/field, wrong session, duplicate/reordered sequence, stale epoch, Electron import, and orphan tests. | +| Stolen pairing code | Attacker claims a copied public code. | High-entropy one-time code, short expiry, rate limit, atomic claim, installation nonce, local customer/device confirmation before state transition. | Claim expires/locks; pairing remains Off; rotate code. | Reuse, concurrent claim, expired code, wrong installation, wrong customer, and dismissed confirmation. | +| Revoked grant | Broker or local revoke occurs while commands are queued/in flight. | Local revocation tombstone and policy epoch checked before prompt, before actuation, and at safe cancellation boundaries. | Block new work synchronously; clear queue/approvals; close channel; erase active credential. | Race revoke against queued, prompted, executing, reconnecting, and offline commands. | +| Offline broker | Existing channel drops but queued authority remains. | Monotonic authority deadline; disconnect barrier; no offline extension; every non-connected transport state forces effective Off. | Effective Off; new remote actuation denied. Local status, stop, revoke, kill, and audit remain available. | Disconnect before/after prompt and execution; no command starts after barrier. | +| Helper replacement | Attacker or stale updater swaps helper binary/service. | Signed embedding, designated requirement pin, code validity, expected path plus identity, versioned Keychain credential group, update handoff protocol. | Refuse leadership/Keychain/channel; effective Off; show repair path. | Replace, ad-hoc sign, wrong team, moved path, old signed version, and partial update tests. | +| Downgrade | Old but validly signed app reads new state or reacquires credentials. | Broker rejects missing/below-floor immutable build identity; critical floor rotation creates a new credential/access-group epoch and revokes the old credential; rollback names exact target. | Refuse transport/Keychain leadership and remain Off; preserve state for compatible recovery. | Install old signed builds below/above the floor and interrupt every credential rotation/update/rollback transition. | +| Crash recovery | Full Access or stale approval silently resumes. | Runtime-instance-bound Full Access confirmation; ephemeral approval store; policy epoch increment on recovery. | Effective Ask Every Time or Off; queue and approvals cleared. | Kill -9 menu/helper/Python at every transition and relaunch. | +| Request tampering | Change command body after approval/signature. | Canonical request digest in command authorization and local approval; recompute immediately before execution. | Deny without executing; invalidate approval. | Mutate every approved field, target snapshot/path, capability, and encoding. | +| Malicious/compromised renderer | Invoke generic native IPC or obtain secrets. | Main-process allowlist and redaction; authenticated helper accepts only signed main processes and fixed actions. | Deny; renderer gets generic code and audit ID only. | XSS/DevTools-like calls cannot reach raw XPC, CUA, Keychain, channel, or logs. | +| Python compromise | Planner process reads secrets or calls CUA directly. | Sandboxed/minimal environment; no secret FDs; native ports; normalized bounded messages; helper revalidates output. | Terminate Python; effective Off for command path; helper and local controls remain available. | Python emits unknown action, oversized payload, forged result, path traversal, or direct framework attempt. | +| Secret/log leakage | Token, typed text, screenshot, AX tree, path, env, or address enters audit/support logs. | Redaction schema denylist plus allowlisted evidence; bounded errors; secret scan. | Reject audit event/receipt; actuation denied if decision cannot be recorded safely. | Negative fixture and recursive scans over logs, crashes, diagnostics, issue packets. | +| Audit deletion/tamper | User malware truncates, replaces, or edits local audit. | Owner-only files, append-only API, monotonic sequence/previous-record/record digests, plus helper-only Keychain committed sequence/digest anchor verified before actuation. | Mark chain or anchor broken, effective Off, preserve evidence, require repair. | Edit/delete/reorder records, valid-tail truncation, whole-journal replacement, anchor rollback, and crash-only suffix tests before next action. | +| Audit unavailable | Disk full, permissions wrong, corruption, I/O failure. | Pre-execution durable decision write and health check. | Effective Off; no actuation; minimal local blocker if possible. | Fault injection for open/write/fsync/rename/disk-full failures. | +| Kill-switch race | Command starts while local kill is activating. | In-memory atomic deny barrier first; policy epoch rotation; checks before prompt and actuation; safe cancellation. | No new action after barrier; in-flight action stops at safe boundary; channel cleanup best effort. | High-rate command stream while kill switch is toggled. | +| Approval confusion | Prompt label differs from executable target or is reused. | Approval binds selected tuple, capability, normalized target, request digest, policy epoch, and expiry. | Dismiss/change/timeout denies; changed target requires new prompt. | TOCTOU mutation between preview, prompt, and execution. | +| Broker key rotation | Connector accepts an old/unknown signing key or bricks on valid rotation. | Signed keyset with activation/retirement windows; key ID pinning; rollback plan. | Unknown/retired key denies; never fall back unsigned. | Overlap, early/late rotation, rollback, missing keyset, and clock skew. | +| Clock manipulation | Wall clock extends expiry or breaks ordering. | Signed absolute time plus monotonic elapsed deadline established on receipt; bounded clock skew. | Suspicious jump denies current channel and reconnects. | Move wall clock forward/back during queued/prompted commands. | +| Oversized/flooded input | Memory/CPU/disk exhaustion via WebSocket or XPC. | Frame limits, rate limits, bounded queue, backpressure, timeouts, bounded audit text. | Drop/reject and eventually close abusive channel; local kill remains responsive. | Oversized, fragmented, high-rate, and slow-loris scenarios. | +| Direct-network bypass | Runtime discovers a Mac URL/IP/token or public listener. | No target listener; outbound relay only; firewall/listener checks; no renderer/broker payload contains endpoint. | Pairing/transport blocked if an unauthorized listener is detected. | `lsof`/packet evidence on pristine and coexistence Macs; prompt/output secret scans. | +| Update split-brain | New menu with old helper or vice versa accepts incompatible authority. | Compatibility handshake, signed build identity, atomic handoff, access Off during update. | Refuse channel and mutations; rollback to exact signed compatible pair. | Interrupt update at each file/service transition. | +| Orphan cleanup abuse | Installer kills/deletes unrelated same-name process/file. | Match recorded path plus signed identity and installation ID; never trust name/PID alone. | Leave unknown process untouched and show support blocker. | Decoy binaries/processes and relocated prior versions. | +| Workbench fallback takeover | Workbench silently starts its legacy connector after Mac Access cutover or when Mac Access is absent/incompatible. | Before atomic cutover, the shipped legacy owner may continue only as the still-active prior owner. After A5 commit, the Workbench connector start path is removed; absence/incompatibility produces install/repair blocker or same-identity launch only. | Workbench shows Mac Access status or blocker; no second-identity fallback start. | Start both apps in every order, remove/invalidate Mac Access, crash either, upgrade/downgrade each. | ## Pairing analysis -The pairing code is public by design and therefore is not a long-term secret. It is only a short-lived rendezvous capability. Dashboard [#669](https://github.com/electricsheephq/electric-sheep-website-dashboard-6158a244/issues/669) owns issuance and atomic redemption. Security comes from hashed server storage, bounded attempts, short expiry, cancellation, atomic one-time claim, local installation-key possession, broker authentication, selected customer/device confirmation in the native UI, and Keychain device-key issuance after confirmation. +The pairing code is public by design and therefore is not a long-term secret. It is only a short-lived rendezvous capability. Dashboard [#669](https://github.com/electricsheephq/electric-sheep-website-dashboard-6158a244/issues/669) owns issuance and atomic redemption. Security comes from hashed server storage, bounded attempts, short expiry, cancellation, atomic one-time claim, a freshly generated 32-byte local installation nonce encoded in canonical form as exactly 43 unpadded base64url characters, local installation-key possession, broker authentication, selected customer/device confirmation in the native UI, and Keychain device-key issuance after confirmation. The nonce is generated and consumed locally; a caller cannot replace it with a shorter generic token or a non-canonical textual alias of the same bytes. The pairing UI must display the selected customer and this Mac's human-readable device name without trusting text supplied by the remote runtime. It must not display or copy raw customer IDs by default, connector URLs, IPs, ports, tokens, SSH/VNC/CDP text, or broker JSON. A code claim never changes mode directly to Full Access; explicit consent moves unpaired Off to paired Ask Every Time. diff --git a/package.json b/package.json index a68eb9c872..6f4a8553c7 100644 --- a/package.json +++ b/package.json @@ -118,6 +118,7 @@ "better-sqlite3": "^12.4.1", "buffer": "^6.0.3", "builder-util-runtime": "9.5.1", + "canonicalize": "3.0.0", "classnames": "^2.5.1", "cookie": "^1.0.2", "cookie-parser": "^1.4.7", diff --git a/packages/mac-connector-core/contracts/v1/fixtures/host/host-response.json b/packages/mac-connector-core/contracts/v1/fixtures/host/host-response.json index 98515a6776..8f7ec58685 100644 --- a/packages/mac-connector-core/contracts/v1/fixtures/host/host-response.json +++ b/packages/mac-connector-core/contracts/v1/fixtures/host/host-response.json @@ -8,7 +8,9 @@ "policy_epoch": 8, "result": { "kind": "lifecycle", + "configured_mode": "ask_every_time", "effective_mode": "ask_every_time", + "requested_target_mode": "ask_every_time", "pairing_state": "paired", "transport_state": "connected" }, diff --git a/packages/mac-connector-core/contracts/v1/fixtures/invalid/policy.json b/packages/mac-connector-core/contracts/v1/fixtures/invalid/policy.json index 2e3fe492ea..63b613910d 100644 --- a/packages/mac-connector-core/contracts/v1/fixtures/invalid/policy.json +++ b/packages/mac-connector-core/contracts/v1/fixtures/invalid/policy.json @@ -213,19 +213,8 @@ "id": "stolen-pairing-code", "threat": "stolen_pairing_code", "contract": "authenticated_local_action", - "base_fixture": "../valid/authority/local-action.json", - "mutations": [ - { - "operation": "set", - "pointer": "/request/action", - "value": "begin_pairing" - }, - { - "operation": "set", - "pointer": "/request/target_mode", - "value": null - } - ], + "base_fixture": "../valid/authority/begin-pairing-action.json", + "mutations": [], "expected_stage": "runtime", "expected_error": "pairing_code_reused_or_claimed", "required_runtime_rejection": "The one-time code must be short-lived, rate-limited, atomically claimed, and bound to the locally displayed installation nonce before pairing state changes." @@ -280,12 +269,8 @@ "id": "begin-pairing-without-epoch", "threat": "stale_local_client", "contract": "authenticated_local_action", - "base_fixture": "../valid/authority/local-action.json", - "mutations": [ - { "operation": "set", "pointer": "/request/action", "value": "begin_pairing" }, - { "operation": "set", "pointer": "/request/expected_policy_epoch", "value": null }, - { "operation": "set", "pointer": "/request/target_mode", "value": null } - ], + "base_fixture": "../valid/authority/begin-pairing-action.json", + "mutations": [{ "operation": "set", "pointer": "/request/expected_policy_epoch", "value": null }], "expected_stage": "schema", "expected_error": "pairing_epoch_required", "required_runtime_rejection": null diff --git a/packages/mac-connector-core/contracts/v1/fixtures/valid/audit/audit-chain-golden.json b/packages/mac-connector-core/contracts/v1/fixtures/valid/audit/audit-chain-golden.json index c30a5468e9..883b64c7d7 100644 --- a/packages/mac-connector-core/contracts/v1/fixtures/valid/audit/audit-chain-golden.json +++ b/packages/mac-connector-core/contracts/v1/fixtures/valid/audit/audit-chain-golden.json @@ -9,47 +9,48 @@ "sequence": 1, "previous_record_sha256": null, "occurred_at": "2026-07-15T08:01:03Z", - "event_type": "command_result", + "event_type": "command_decision", "actor": { "kind": "broker_runtime", "identity": "openclaw" }, - "binding_fingerprint_sha256": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", + "binding_fingerprint_sha256": "1111111111111111111111111111111111111111111111111111111111111111", "command_id": "command-01", "request_digest_sha256": "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", + "causation_audit_id": null, "access_mode": "ask_every_time", - "outcome": "executed", + "outcome": "allowed", "reason_code": "approved_exact_scope", "evidence": { "capability": "customer_mac.desktop_click", - "target_path_hash": "path-hash-01", + "target_path_hash": "2222222222222222222222222222222222222222222222222222222222222222", "artifact_count": 0, "redaction_policy": "default_v1" } }, - "canonical_payload_utf8": "{\"access_mode\":\"ask_every_time\",\"actor\":{\"identity\":\"openclaw\",\"kind\":\"broker_runtime\"},\"audit_id\":\"audit-01\",\"binding_fingerprint_sha256\":\"ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff\",\"command_id\":\"command-01\",\"event_type\":\"command_result\",\"evidence\":{\"artifact_count\":0,\"capability\":\"customer_mac.desktop_click\",\"redaction_policy\":\"default_v1\",\"target_path_hash\":\"path-hash-01\"},\"occurred_at\":\"2026-07-15T08:01:03Z\",\"outcome\":\"executed\",\"previous_record_sha256\":null,\"reason_code\":\"approved_exact_scope\",\"request_digest_sha256\":\"dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd\",\"schema_version\":\"evaos.mac_access.audit_event.v1\",\"sequence\":1}", - "record_sha256": "ec3235267ea927e32c2f99ad05b7926c6ed4942ca3055263d3b000d88eb585f7" + "canonical_payload_utf8": "{\"access_mode\":\"ask_every_time\",\"actor\":{\"identity\":\"openclaw\",\"kind\":\"broker_runtime\"},\"audit_id\":\"audit-01\",\"binding_fingerprint_sha256\":\"1111111111111111111111111111111111111111111111111111111111111111\",\"causation_audit_id\":null,\"command_id\":\"command-01\",\"event_type\":\"command_decision\",\"evidence\":{\"artifact_count\":0,\"capability\":\"customer_mac.desktop_click\",\"redaction_policy\":\"default_v1\",\"target_path_hash\":\"2222222222222222222222222222222222222222222222222222222222222222\"},\"occurred_at\":\"2026-07-15T08:01:03Z\",\"outcome\":\"allowed\",\"previous_record_sha256\":null,\"reason_code\":\"approved_exact_scope\",\"request_digest_sha256\":\"dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd\",\"schema_version\":\"evaos.mac_access.audit_event.v1\",\"sequence\":1}", + "record_sha256": "a0cf9968470f3ae354e1ef68e7aa80ba34c7b0d715e3db4a07fca5cbe7b6ced9" }, { "payload": { "schema_version": "evaos.mac_access.audit_event.v1", "audit_id": "audit-02", "sequence": 2, - "previous_record_sha256": "ec3235267ea927e32c2f99ad05b7926c6ed4942ca3055263d3b000d88eb585f7", + "previous_record_sha256": "a0cf9968470f3ae354e1ef68e7aa80ba34c7b0d715e3db4a07fca5cbe7b6ced9", "occurred_at": "2026-07-15T08:01:04Z", - "event_type": "lifecycle", - "actor": { "kind": "local_user", "identity": "console-user" }, - "binding_fingerprint_sha256": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", - "command_id": null, - "request_digest_sha256": null, - "access_mode": "off", - "outcome": "stopped", - "reason_code": "local_stop", + "event_type": "command_result", + "actor": { "kind": "broker_runtime", "identity": "openclaw" }, + "binding_fingerprint_sha256": "1111111111111111111111111111111111111111111111111111111111111111", + "command_id": "command-01", + "request_digest_sha256": "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", + "causation_audit_id": "audit-01", + "access_mode": "ask_every_time", + "outcome": "executed", + "reason_code": "approved_exact_scope", "evidence": { - "state_from": "ask_every_time", - "state_to": "off", + "artifact_count": 0, "redaction_policy": "default_v1" } }, - "canonical_payload_utf8": "{\"access_mode\":\"off\",\"actor\":{\"identity\":\"console-user\",\"kind\":\"local_user\"},\"audit_id\":\"audit-02\",\"binding_fingerprint_sha256\":\"ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff\",\"command_id\":null,\"event_type\":\"lifecycle\",\"evidence\":{\"redaction_policy\":\"default_v1\",\"state_from\":\"ask_every_time\",\"state_to\":\"off\"},\"occurred_at\":\"2026-07-15T08:01:04Z\",\"outcome\":\"stopped\",\"previous_record_sha256\":\"ec3235267ea927e32c2f99ad05b7926c6ed4942ca3055263d3b000d88eb585f7\",\"reason_code\":\"local_stop\",\"request_digest_sha256\":null,\"schema_version\":\"evaos.mac_access.audit_event.v1\",\"sequence\":2}", - "record_sha256": "b811f2f3442a900a214ffb6d2d691cff51c32088bca50109df0b7cd044666614" + "canonical_payload_utf8": "{\"access_mode\":\"ask_every_time\",\"actor\":{\"identity\":\"openclaw\",\"kind\":\"broker_runtime\"},\"audit_id\":\"audit-02\",\"binding_fingerprint_sha256\":\"1111111111111111111111111111111111111111111111111111111111111111\",\"causation_audit_id\":\"audit-01\",\"command_id\":\"command-01\",\"event_type\":\"command_result\",\"evidence\":{\"artifact_count\":0,\"redaction_policy\":\"default_v1\"},\"occurred_at\":\"2026-07-15T08:01:04Z\",\"outcome\":\"executed\",\"previous_record_sha256\":\"a0cf9968470f3ae354e1ef68e7aa80ba34c7b0d715e3db4a07fca5cbe7b6ced9\",\"reason_code\":\"approved_exact_scope\",\"request_digest_sha256\":\"dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd\",\"schema_version\":\"evaos.mac_access.audit_event.v1\",\"sequence\":2}", + "record_sha256": "809aecef81a5ce8d79af29ddcb0c945b74976f16b31c60d76cad4a0bebcc04bc" } ] } diff --git a/packages/mac-connector-core/contracts/v1/fixtures/valid/audit/audit-event.json b/packages/mac-connector-core/contracts/v1/fixtures/valid/audit/audit-event.json index 7a9a3b59b6..066356c334 100644 --- a/packages/mac-connector-core/contracts/v1/fixtures/valid/audit/audit-event.json +++ b/packages/mac-connector-core/contracts/v1/fixtures/valid/audit/audit-event.json @@ -3,22 +3,23 @@ "audit_id": "audit-01", "sequence": 1, "previous_record_sha256": null, - "record_sha256": "ec3235267ea927e32c2f99ad05b7926c6ed4942ca3055263d3b000d88eb585f7", + "record_sha256": "a0cf9968470f3ae354e1ef68e7aa80ba34c7b0d715e3db4a07fca5cbe7b6ced9", "occurred_at": "2026-07-15T08:01:03Z", - "event_type": "command_result", + "event_type": "command_decision", "actor": { "kind": "broker_runtime", "identity": "openclaw" }, - "binding_fingerprint_sha256": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", + "binding_fingerprint_sha256": "1111111111111111111111111111111111111111111111111111111111111111", "command_id": "command-01", "request_digest_sha256": "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", + "causation_audit_id": null, "access_mode": "ask_every_time", - "outcome": "executed", + "outcome": "allowed", "reason_code": "approved_exact_scope", "evidence": { "capability": "customer_mac.desktop_click", - "target_path_hash": "path-hash-01", + "target_path_hash": "2222222222222222222222222222222222222222222222222222222222222222", "artifact_count": 0, "redaction_policy": "default_v1" } diff --git a/packages/mac-connector-core/contracts/v1/fixtures/valid/authority/begin-pairing-action.json b/packages/mac-connector-core/contracts/v1/fixtures/valid/authority/begin-pairing-action.json new file mode 100644 index 0000000000..bbcac5ef06 --- /dev/null +++ b/packages/mac-connector-core/contracts/v1/fixtures/valid/authority/begin-pairing-action.json @@ -0,0 +1,22 @@ +{ + "schema_version": "evaos.mac_access.authenticated_local_action.v1", + "connection_id": "local-connection-01", + "peer": { + "verification": "verified_designated_requirement", + "role": "workbench_main", + "team_id": "TC6MS3T6NN", + "signing_identifier": "com.evaos.workbench", + "audit_token_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "designated_requirement_sha256": "ff4fc126bb70bbf7fcc3cc0957377d67185124b5e31b19760357333a8a0ae329" + }, + "request": { + "schema_version": "evaos.mac_access.local_action.v1", + "request_id": "local-pairing-request-01", + "action": "begin_pairing", + "client_nonce": "bm9uY2UtMDE", + "expected_policy_epoch": 7, + "target_mode": null, + "pairing_code": "ABC123", + "local_installation_nonce": "MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY" + } +} diff --git a/packages/mac-connector-core/contracts/v1/fixtures/valid/authority/local-action.json b/packages/mac-connector-core/contracts/v1/fixtures/valid/authority/local-action.json index f6f4f56add..0187366c50 100644 --- a/packages/mac-connector-core/contracts/v1/fixtures/valid/authority/local-action.json +++ b/packages/mac-connector-core/contracts/v1/fixtures/valid/authority/local-action.json @@ -15,6 +15,8 @@ "action": "set_access_mode", "client_nonce": "bm9uY2UtMDE", "expected_policy_epoch": 7, - "target_mode": "ask_every_time" + "target_mode": "ask_every_time", + "pairing_code": null, + "local_installation_nonce": null } } diff --git a/packages/mac-connector-core/contracts/v1/fixtures/valid/state/access-transition-stop.json b/packages/mac-connector-core/contracts/v1/fixtures/valid/state/access-transition-stop.json index eb2c5de9c6..c1f8a01d23 100644 --- a/packages/mac-connector-core/contracts/v1/fixtures/valid/state/access-transition-stop.json +++ b/packages/mac-connector-core/contracts/v1/fixtures/valid/state/access-transition-stop.json @@ -2,7 +2,7 @@ "schema_version": "evaos.mac_access.access_transition.v1", "transition_id": "transition-stop-01", "event": "stop", - "explicit_user_consent": true, + "explicit_user_consent": false, "invalidated_pending_authority": true, "safe_cancellation_requested": true, "target_mode": null, @@ -16,14 +16,14 @@ "minimum_writer_schema_version": 1, "policy_epoch": 7, "pairing_state": "paired", - "configured_mode": "ask_every_time", - "effective_mode": "ask_every_time", + "configured_mode": "full_access", + "effective_mode": "full_access", "paused": false, "kill_switch": false, "local_confirmation_required": false, - "confirmed_runtime_instance_id": null, - "confirmed_policy_epoch": null, - "confirmed_binding_fingerprint_sha256": null, + "confirmed_runtime_instance_id": "runtime-instance-01", + "confirmed_policy_epoch": 7, + "confirmed_binding_fingerprint_sha256": "1111111111111111111111111111111111111111111111111111111111111111", "binding": { "customer_id": "customer-01", "customer_vm_id": "vm-01", @@ -38,7 +38,7 @@ "binding_fingerprint_sha256": "1111111111111111111111111111111111111111111111111111111111111111" }, "changed_at": "2026-07-15T08:00:00Z", - "reason_code": "pairing_confirmed" + "reason_code": "local_full_access_confirmed" }, "to": { "schema_version": "evaos.mac_access.access_state.v1", @@ -50,11 +50,11 @@ "minimum_writer_schema_version": 1, "policy_epoch": 8, "pairing_state": "paired", - "configured_mode": "ask_every_time", + "configured_mode": "full_access", "effective_mode": "off", "paused": false, "kill_switch": false, - "local_confirmation_required": false, + "local_confirmation_required": true, "confirmed_runtime_instance_id": null, "confirmed_policy_epoch": null, "confirmed_binding_fingerprint_sha256": null, diff --git a/packages/mac-connector-core/contracts/v1/fixtures/valid/state/local-status.json b/packages/mac-connector-core/contracts/v1/fixtures/valid/state/local-status.json index 21169f6cce..ddb08849c6 100644 --- a/packages/mac-connector-core/contracts/v1/fixtures/valid/state/local-status.json +++ b/packages/mac-connector-core/contracts/v1/fixtures/valid/state/local-status.json @@ -116,6 +116,7 @@ "reason_code": "pairing_confirmed" }, "transport": { + "responsible_identity": "com.evaos.mac-access.helper", "state": "connected", "channel_id": "channel-01", "last_error_code": null @@ -127,6 +128,22 @@ }, "audit": { "writable": true, - "last_audit_id": "audit-01" + "anchor_healthy": true, + "anchor": { + "schema_version": "evaos.mac_access.audit_anchor.v1", + "custodian_signing_identifier": "com.evaos.mac-access.helper", + "access_group_suffix": "com.evaos.mac-access.audit-anchor.epoch-1", + "security_epoch": 1, + "service": "com.evaos.mac-access.audit-anchor", + "accessibility": "kSecAttrAccessibleWhenUnlockedThisDeviceOnly", + "synchronizable": false, + "journal_id": "audit-journal-01", + "committed_sequence": 1, + "committed_audit_id": "audit-01", + "committed_record_sha256": "a0cf9968470f3ae354e1ef68e7aa80ba34c7b0d715e3db4a07fca5cbe7b6ced9", + "pending_sequence": null, + "pending_audit_id": null, + "pending_record_sha256": null + } } } diff --git a/packages/mac-connector-core/contracts/v1/index.ts b/packages/mac-connector-core/contracts/v1/index.ts index 77f73c74ed..c91511c837 100644 --- a/packages/mac-connector-core/contracts/v1/index.ts +++ b/packages/mac-connector-core/contracts/v1/index.ts @@ -1,3 +1,6 @@ +import { createHash } from 'node:crypto'; + +import canonicalize from 'canonicalize'; import { z } from 'zod'; const identifier = z.string().regex(/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/); @@ -7,9 +10,157 @@ const base64Url = z .min(1) .max(16_384) .regex(/^[A-Za-z0-9_-]+$/); +const installationNonce = z + .string() + .length(43) + .regex(/^[A-Za-z0-9_-]{42}[AEIMQUYcgkosw048]$/); const instant = z.string().datetime({ offset: true }); const safePositiveCounter = z.number().int().min(1).max(Number.MAX_SAFE_INTEGER); const safeNonnegativeCounter = z.number().int().min(0).max(Number.MAX_SAFE_INTEGER); +type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue }; +const jsonValueSchema: z.ZodType = z.lazy(() => + z.union([ + z.string(), + z.number().finite(), + z.boolean(), + z.null(), + z.array(jsonValueSchema), + z.record(z.string(), jsonValueSchema), + ]) +); + +function hasValidUnicode(value: unknown): boolean { + if (typeof value === 'string') { + for (let index = 0; index < value.length; index += 1) { + const codeUnit = value.charCodeAt(index); + if (codeUnit >= 0xd800 && codeUnit <= 0xdbff) { + const next = value.charCodeAt(index + 1); + if (!Number.isInteger(next) || next < 0xdc00 || next > 0xdfff) return false; + index += 1; + } else if (codeUnit >= 0xdc00 && codeUnit <= 0xdfff) return false; + } + return true; + } + if (Array.isArray(value)) return value.every(hasValidUnicode); + if (value !== null && typeof value === 'object') { + return Object.entries(value).every(([key, child]) => hasValidUnicode(key) && hasValidUnicode(child)); + } + return true; +} + +function canonicalJson(value: unknown): string | null { + if (!hasValidUnicode(value)) return null; + const serialized = canonicalize(value); + return serialized ?? null; +} + +function canonicalJsonSha256(value: unknown): string | null { + const serialized = canonicalJson(value); + return serialized === null ? null : createHash('sha256').update(serialized).digest('hex'); +} + +export const auditCursorSchema = z + .object({ + sequence: safePositiveCounter, + record_sha256: sha256, + }) + .strict(); + +export const macControlCapabilitySchema = z.enum([ + 'customer_mac.desktop_see', + 'customer_mac.desktop_click', + 'customer_mac.desktop_type', + 'customer_mac.desktop_set_value', + 'customer_mac.desktop_scroll', + 'customer_mac.desktop_drag', + 'customer_mac.desktop_hotkey', + 'customer_mac.desktop_focus_app', + 'customer_mac.desktop_window', + 'customer_mac.desktop_menu', + 'customer_mac.desktop_browser_action', +]); + +const accessEvidenceStateSchema = z.enum([ + 'unpaired', + 'paired', + 'revoked', + 'off', + 'ask_every_time', + 'full_access', + 'paused', + 'active', + 'kill_switch_active', +]); +const transportStateSchema = z.enum(['disconnected', 'connecting', 'connected', 'revoked', 'blocked']); +const auditReasonCodeSchema = z.enum([ + 'approved_exact_scope', + 'denied_access_off', + 'denied_approval', + 'denied_audit_unhealthy', + 'denied_binding_mismatch', + 'denied_expired_authority', + 'denied_policy_epoch', + 'denied_replay', + 'denied_tcc', + 'denied_transport', + 'grant_expired', + 'local_full_access_confirmed', + 'local_kill_switch', + 'local_pause', + 'local_resume', + 'local_revoke', + 'local_stop', + 'pairing_confirmed', + 'pairing_failed', + 'runtime_restart', +]); +const auditDetailCodeSchema = z.enum([ + 'actuation_cancelled', + 'actuation_failed', + 'actuation_succeeded', + 'anchor_mismatch', + 'anchor_unavailable', + 'approval_expired', + 'approval_rejected', + 'approval_satisfied', + 'audit_append_failed', + 'binding_mismatch', + 'command_expired', + 'command_replayed', + 'grant_expired', + 'policy_epoch_stale', + 'request_digest_mismatch', + 'tcc_unavailable', + 'transport_unavailable', +]); +const accessStateReasonCodeSchema = z.enum([ + 'not_paired', + 'pairing_confirmed', + 'pairing_failed', + 'local_full_access_confirmed', + 'local_mode_changed', + 'local_pause', + 'local_resume', + 'local_stop', + 'local_revoke', + 'local_kill_switch', + 'runtime_restart', + 'tcc_lost', + 'audit_failed', + 'binding_changed', + 'grant_expired', +]); +const auditActorSchema = z.discriminatedUnion('kind', [ + z.object({ kind: z.literal('local_user'), identity: z.literal('console_user') }).strict(), + z.object({ kind: z.literal('workbench'), identity: z.literal('workbench_main') }).strict(), + z.object({ kind: z.literal('broker_runtime'), identity: z.enum(['openclaw', 'hermes']) }).strict(), + z + .object({ + kind: z.literal('system'), + identity: z.enum(['audit_subsystem', 'connector_core', 'policy_engine', 'updater']), + }) + .strict(), +]); export const MAC_ACCESS_IDENTITIES = { teamId: 'TC6MS3T6NN', @@ -34,6 +185,8 @@ export const MAC_ACCESS_IDENTITIES = { productionKeychainAccessGroupSuffix: 'com.evaos.mac-access.credentials', developmentKeychainAccessGroupSuffix: 'com.evaos.mac-access.development.credentials', connectorCredentialService: 'com.evaos.mac-access.connector-credential', + auditAnchorAccessGroupSuffix: 'com.evaos.mac-access.audit-anchor', + auditAnchorService: 'com.evaos.mac-access.audit-anchor', } as const; export const selectedBindingSchema = z @@ -104,6 +257,61 @@ export const keychainCustodySchema = z } }); +export const auditAnchorSchema = z + .object({ + schema_version: z.literal('evaos.mac_access.audit_anchor.v1'), + custodian_signing_identifier: z.literal(MAC_ACCESS_IDENTITIES.helperServiceId), + access_group_suffix: z.string().regex(/^com\.evaos\.mac-access\.audit-anchor\.epoch-[1-9][0-9]*$/), + security_epoch: safePositiveCounter, + service: z.literal(MAC_ACCESS_IDENTITIES.auditAnchorService), + accessibility: z.literal('kSecAttrAccessibleWhenUnlockedThisDeviceOnly'), + synchronizable: z.literal(false), + journal_id: identifier, + committed_sequence: safeNonnegativeCounter, + committed_audit_id: identifier.nullable(), + committed_record_sha256: sha256.nullable(), + pending_sequence: safePositiveCounter.nullable(), + pending_audit_id: identifier.nullable(), + pending_record_sha256: sha256.nullable(), + }) + .strict() + .superRefine((anchor, context) => { + if (!anchor.access_group_suffix.endsWith(`.epoch-${anchor.security_epoch}`)) { + context.addIssue({ + code: z.ZodIssueCode.custom, + message: 'audit anchor access group must match its security epoch', + path: ['access_group_suffix'], + }); + } + const committedEmpty = anchor.committed_sequence === 0; + if ( + committedEmpty !== (anchor.committed_audit_id === null) || + committedEmpty !== (anchor.committed_record_sha256 === null) + ) { + context.addIssue({ + code: z.ZodIssueCode.custom, + message: 'committed audit anchor fields must be empty only at sequence zero', + path: ['committed_sequence'], + }); + } + const pendingFields = [anchor.pending_sequence, anchor.pending_audit_id, anchor.pending_record_sha256]; + const hasPending = pendingFields.some((value) => value !== null); + if (hasPending && pendingFields.some((value) => value === null)) { + context.addIssue({ + code: z.ZodIssueCode.custom, + message: 'pending audit anchor fields must be committed as one complete tuple', + path: ['pending_sequence'], + }); + } + if (anchor.pending_sequence !== null && anchor.pending_sequence !== anchor.committed_sequence + 1) { + context.addIssue({ + code: z.ZodIssueCode.custom, + message: 'pending audit anchor must name exactly the next journal sequence', + path: ['pending_sequence'], + }); + } + }); + const rollbackBuildSchema = z .object({ build_version: identifier, @@ -250,7 +458,7 @@ export const accessStateSchema = z confirmed_binding_fingerprint_sha256: sha256.nullable(), binding: selectedBindingSchema.nullable(), changed_at: instant, - reason_code: identifier, + reason_code: accessStateReasonCodeSchema, }) .strict() .superRefine((state, context) => { @@ -389,9 +597,10 @@ export const localStatusSchema = z access: accessStateSchema, transport: z .object({ + responsible_identity: z.literal(MAC_ACCESS_IDENTITIES.helperServiceId), state: z.enum(['disconnected', 'connecting', 'connected', 'revoked', 'blocked']), channel_id: identifier.nullable(), - last_error_code: identifier.nullable(), + last_error_code: auditDetailCodeSchema.nullable(), }) .strict(), tcc: z @@ -404,7 +613,8 @@ export const localStatusSchema = z audit: z .object({ writable: z.boolean(), - last_audit_id: identifier.nullable(), + anchor_healthy: z.boolean(), + anchor: auditAnchorSchema, }) .strict(), }) @@ -424,6 +634,20 @@ export const localStatusSchema = z path: ['audit', 'writable'], }); } + if (!status.audit.anchor_healthy && status.access.effective_mode !== 'off') { + context.addIssue({ + code: z.ZodIssueCode.custom, + message: 'audit anchor failure must force effective access off', + path: ['audit', 'anchor_healthy'], + }); + } + if (status.audit.anchor.pending_sequence !== null && status.access.effective_mode !== 'off') { + context.addIssue({ + code: z.ZodIssueCode.custom, + message: 'pending audit anchor commit must force effective access off', + path: ['audit', 'anchor', 'pending_sequence'], + }); + } const tccGranted = status.tcc.accessibility === 'granted' && status.tcc.screen_recording === 'granted'; if (!tccGranted && status.access.effective_mode !== 'off') { context.addIssue({ @@ -462,6 +686,13 @@ export const localStatusSchema = z } const relayBuild = status.relay_authorization; const localBuild = status.leader.build; + if (status.audit.anchor.security_epoch !== localBuild.security_epoch) { + context.addIssue({ + code: z.ZodIssueCode.custom, + message: 'audit anchor security epoch must match the accepted helper build', + path: ['audit', 'anchor', 'security_epoch'], + }); + } if ( relayBuild.accepted_build_version !== localBuild.build_version || relayBuild.accepted_source_commit !== localBuild.source_commit || @@ -575,6 +806,13 @@ export const localStatusSchema = z path: ['transport', 'state'], }); } + if (!connected && status.access.effective_mode !== 'off') { + context.addIssue({ + code: z.ZodIssueCode.custom, + message: 'unavailable transport must force effective access off', + path: ['transport', 'state'], + }); + } }); export const accessTransitionSchema = z @@ -616,6 +854,27 @@ export const accessTransitionSchema = z path: ['to', 'binding'], }); } + const configuredModeMutationEvents = new Set([ + 'pair_confirmed', + 'set_mode', + 'revoke', + 'kill_switch', + 'grant_expired', + ]); + if (from.configured_mode !== to.configured_mode && !configuredModeMutationEvents.has(transition.event)) { + context.addIssue({ + code: z.ZodIssueCode.custom, + message: 'configured user intent may change only through an explicit mode-authority transition', + path: ['to', 'configured_mode'], + }); + } + if (transition.event !== 'restart' && from.runtime_instance_id !== to.runtime_instance_id) { + context.addIssue({ + code: z.ZodIssueCode.custom, + message: 'runtime instance may change only through an explicit restart transition', + path: ['to', 'runtime_instance_id'], + }); + } if (to.policy_epoch !== from.policy_epoch + 1) { context.addIssue({ code: z.ZodIssueCode.custom, @@ -677,6 +936,7 @@ export const accessTransitionSchema = z if (transition.event === 'restart') { const validRestart = from.runtime_instance_id !== to.runtime_instance_id && + to.configured_mode === from.configured_mode && to.confirmed_runtime_instance_id === null && to.confirmed_policy_epoch === null && to.confirmed_binding_fingerprint_sha256 === null && @@ -717,7 +977,8 @@ export const accessTransitionSchema = z transition.safe_cancellation_requested && to.confirmed_runtime_instance_id === null && to.confirmed_policy_epoch === null && - to.confirmed_binding_fingerprint_sha256 === null; + to.confirmed_binding_fingerprint_sha256 === null && + (to.configured_mode !== 'full_access' || to.local_confirmation_required); if (!validStop) { context.addIssue({ code: z.ZodIssueCode.custom, @@ -730,6 +991,7 @@ export const accessTransitionSchema = z const validResume = from.paused && !to.paused && + to.configured_mode === from.configured_mode && (to.configured_mode !== 'full_access' || (to.effective_mode === 'ask_every_time' && to.local_confirmation_required)); if (!validResume) { @@ -768,10 +1030,13 @@ export const accessTransitionSchema = z }); } } - if (transition.event === 'kill_switch' && (!to.kill_switch || to.effective_mode !== 'off')) { + if ( + transition.event === 'kill_switch' && + (!to.kill_switch || to.configured_mode !== 'off' || to.effective_mode !== 'off') + ) { context.addIssue({ code: z.ZodIssueCode.custom, - message: 'kill switch must synchronously force effective access off', + message: 'kill switch must synchronously clear configured and effective access', path: ['to'], }); } @@ -817,6 +1082,11 @@ export const localActionRequestSchema = z client_nonce: base64Url, expected_policy_epoch: safeNonnegativeCounter.nullable(), target_mode: z.enum(['off', 'ask_every_time', 'full_access']).nullable(), + pairing_code: z + .string() + .regex(/^[A-Z0-9]{6,12}$/) + .nullable(), + local_installation_nonce: installationNonce.nullable(), }) .strict() .superRefine((request, context) => { @@ -842,6 +1112,20 @@ export const localActionRequestSchema = z path: ['target_mode'], }); } + if ((request.action === 'begin_pairing') !== (request.pairing_code !== null)) { + context.addIssue({ + code: z.ZodIssueCode.custom, + message: 'pairing_code is required only for begin_pairing', + path: ['pairing_code'], + }); + } + if ((request.action === 'begin_pairing') !== (request.local_installation_nonce !== null)) { + context.addIssue({ + code: z.ZodIssueCode.custom, + message: 'local_installation_nonce is required only for begin_pairing', + path: ['local_installation_nonce'], + }); + } }); export const authenticatedLocalActionSchema = z @@ -867,7 +1151,7 @@ export const commandAuthorityPayloadSchema = z nonce: base64Url, binding: selectedBindingSchema, execution_context_sha256: sha256, - capability: identifier, + capability: macControlCapabilitySchema, request_digest_sha256: sha256, }) .strict(); @@ -908,7 +1192,7 @@ export const brokerControlEnvelopeSchema = z .strict(), command: z .object({ - capability: identifier, + capability: macControlCapabilitySchema, request: z.record(z.string(), z.unknown()), request_digest_sha256: sha256, }) @@ -1030,7 +1314,6 @@ const coreHostLifecycleRequest = ( ...coreHostRequestIdentity, operation: z.literal(operation), expected_policy_epoch: safeNonnegativeCounter, - reason_code: identifier, }) .strict(); @@ -1049,7 +1332,7 @@ export const coreHostRequestSchema = z operation: z.literal('pair'), expected_policy_epoch: safeNonnegativeCounter, pairing_code: z.string().regex(/^[A-Z0-9]{6,12}$/), - local_installation_nonce: base64Url, + local_installation_nonce: installationNonce, }) .strict(), coreHostLifecycleRequest('unpair'), @@ -1083,7 +1366,7 @@ export const coreHostRequestSchema = z ...coreHostRequestIdentity, operation: z.literal('audit_summary'), expected_policy_epoch: safeNonnegativeCounter, - after_sequence: safeNonnegativeCounter.nullable(), + after_cursor: auditCursorSchema.nullable(), limit: z.number().int().min(1).max(100), }) .strict(), @@ -1104,29 +1387,40 @@ export const coreHostRequestSchema = z } }); -const safeEvidenceIdentifier = identifier.refine( - (value) => !/(?:authorization|bearer|cookie|password|secret|token|eyJ[A-Za-z0-9_-]{8})/i.test(value), - 'audit evidence identifier resembles secret-bearing content' -); - export const auditEvidenceSchema = z .object({ - capability: safeEvidenceIdentifier.optional(), - target_path_hash: safeEvidenceIdentifier.optional(), + capability: macControlCapabilitySchema.optional(), + target_path_hash: sha256.optional(), target_fingerprint_sha256: sha256.optional(), - state_from: safeEvidenceIdentifier.optional(), - state_to: safeEvidenceIdentifier.optional(), - transport_state: safeEvidenceIdentifier.optional(), - detail_code: safeEvidenceIdentifier.optional(), - build_version: safeEvidenceIdentifier.optional(), - schema_version: safeEvidenceIdentifier.optional(), + state_from: accessEvidenceStateSchema.optional(), + state_to: accessEvidenceStateSchema.optional(), + transport_state: transportStateSchema.optional(), + detail_code: auditDetailCodeSchema.optional(), + build_version: z + .string() + .max(128) + .regex(/^[0-9]+\.[0-9]+\.[0-9]+(?:-[a-z0-9.-]+)?$/) + .optional(), + schema_version: z + .enum([ + 'evaos.mac_access.access_state.v1', + 'evaos.mac_access.audit_anchor.v1', + 'evaos.mac_access.audit_event.v1', + 'evaos.mac_access.broker_control.v1', + 'evaos.mac_access.command_authority_payload.v1', + 'evaos.mac_access.local_action.v1', + 'evaos.mac_access.local_status.v1', + 'evaos.mac_connector_core.host_request.v1', + 'evaos.mac_connector_core.host_response.v1', + ]) + .optional(), artifact_count: z.number().int().min(0).max(16).optional(), record_count: z.number().int().min(0).max(10_000).optional(), redaction_policy: z.literal('default_v1'), }) .strict(); -export const auditRecordPayloadSchema = z +const auditRecordPayloadBaseSchema = z .object({ schema_version: z.literal('evaos.mac_access.audit_event.v1'), audit_id: identifier, @@ -1143,25 +1437,81 @@ export const auditRecordPayloadSchema = z 'kill_switch', 'lifecycle', ]), - actor: z - .object({ - kind: z.enum(['local_user', 'workbench', 'broker_runtime', 'system']), - identity: identifier, - }) - .strict(), + actor: auditActorSchema, binding_fingerprint_sha256: sha256.nullable(), command_id: identifier.nullable(), request_digest_sha256: sha256.nullable(), + causation_audit_id: identifier.nullable(), access_mode: z.enum(['off', 'ask_every_time', 'full_access']), outcome: z.enum(['allowed', 'denied', 'executed', 'failed', 'revoked', 'stopped']), - reason_code: identifier, + reason_code: auditReasonCodeSchema, evidence: auditEvidenceSchema, }) .strict(); -export const auditEventSchema = auditRecordPayloadSchema +function requireCommandAuditCorrelation( + event: z.infer, + context: z.RefinementCtx +): void { + const commandEvent = ['command_decision', 'command_result'].includes(event.event_type); + if ( + commandEvent && + (event.binding_fingerprint_sha256 === null || event.command_id === null || event.request_digest_sha256 === null) + ) { + context.addIssue({ + code: z.ZodIssueCode.custom, + message: 'command audit events require binding, command, and request-digest correlation', + path: ['command_id'], + }); + } + if ( + !commandEvent && + (event.command_id !== null || event.request_digest_sha256 !== null || event.causation_audit_id !== null) + ) { + context.addIssue({ + code: z.ZodIssueCode.custom, + message: 'non-command audit events cannot carry command correlation fields', + path: ['command_id'], + }); + } + if ( + event.event_type === 'command_decision' && + (event.causation_audit_id !== null || !['allowed', 'denied'].includes(event.outcome)) + ) { + context.addIssue({ + code: z.ZodIssueCode.custom, + message: 'command decisions must be root records with allowed or denied outcomes', + path: ['causation_audit_id'], + }); + } + if ( + event.event_type === 'command_result' && + (event.causation_audit_id === null || + event.causation_audit_id === event.audit_id || + !['executed', 'failed', 'stopped'].includes(event.outcome)) + ) { + context.addIssue({ + code: z.ZodIssueCode.custom, + message: 'command results must cite their decision audit and use a terminal execution outcome', + path: ['causation_audit_id'], + }); + } +} + +export const auditRecordPayloadSchema = auditRecordPayloadBaseSchema.superRefine(requireCommandAuditCorrelation); + +export const auditEventSchema = auditRecordPayloadBaseSchema .extend({ record_sha256: sha256 }) .superRefine((event, context) => { + requireCommandAuditCorrelation(event, context); + const { record_sha256: expectedDigest, ...payload } = event; + if (canonicalJsonSha256(payload) !== expectedDigest) { + context.addIssue({ + code: z.ZodIssueCode.custom, + message: 'audit record digest must match the exact RFC 8785 payload', + path: ['record_sha256'], + }); + } if (event.sequence === 1 && event.previous_record_sha256 !== null) { context.addIssue({ code: z.ZodIssueCode.custom, @@ -1194,9 +1544,221 @@ export const auditChainGoldenSchema = z ) .length(2), }) - .strict(); + .strict() + .superRefine((golden, context) => { + for (const [index, record] of golden.records.entries()) { + const canonical = canonicalJson(record.payload); + if (canonical === null || canonical !== record.canonical_payload_utf8) { + context.addIssue({ + code: z.ZodIssueCode.custom, + message: 'audit golden canonical bytes must match the exact RFC 8785 payload', + path: ['records', index, 'canonical_payload_utf8'], + }); + } + if (canonicalJsonSha256(record.payload) !== record.record_sha256) { + context.addIssue({ + code: z.ZodIssueCode.custom, + message: 'audit golden record digest must match its exact payload', + path: ['records', index, 'record_sha256'], + }); + } + const previous = golden.records[index - 1]; + if ( + record.payload.sequence !== index + 1 || + record.payload.previous_record_sha256 !== (previous?.record_sha256 ?? null) + ) { + context.addIssue({ + code: z.ZodIssueCode.custom, + message: 'audit golden records must form one contiguous digest chain', + path: ['records', index, 'payload', 'previous_record_sha256'], + }); + } + } + + const [decision, result] = golden.records.map((record) => record.payload); + if ( + decision.event_type !== 'command_decision' || + result.event_type !== 'command_result' || + result.causation_audit_id !== decision.audit_id || + result.command_id !== decision.command_id || + result.request_digest_sha256 !== decision.request_digest_sha256 || + result.binding_fingerprint_sha256 !== decision.binding_fingerprint_sha256 || + result.sequence <= decision.sequence + ) { + context.addIssue({ + code: z.ZodIssueCode.custom, + message: 'audit golden records must be one causally bound command decision and result', + path: ['records', 1, 'payload', 'causation_audit_id'], + }); + } + }); + +const auditSummaryResultSchema = z + .object({ + kind: z.literal('audit_summary'), + page_anchor: auditCursorSchema.nullable(), + events: z.array(auditEventSchema).max(100), + causal_decisions: z.array(auditEventSchema).max(1), + next_cursor: auditCursorSchema.nullable(), + }) + .strict() + .superRefine((summary, context) => { + for (const [index, decision] of summary.causal_decisions.entries()) { + if ( + summary.page_anchor === null || + decision.sequence !== summary.page_anchor.sequence || + decision.record_sha256 !== summary.page_anchor.record_sha256 + ) { + context.addIssue({ + code: z.ZodIssueCode.custom, + message: 'off-page causal decision must be the exact digest-bound page anchor record', + path: ['causal_decisions', index, 'record_sha256'], + }); + } + } + if (summary.events.length === 0) { + if (summary.next_cursor !== null) { + context.addIssue({ + code: z.ZodIssueCode.custom, + message: 'empty audit summary cannot advertise a continuation cursor', + path: ['next_cursor'], + }); + } + return; + } + + const first = summary.events[0]; + const expectedFirstSequence = summary.page_anchor === null ? 1 : summary.page_anchor.sequence + 1; + if (first.sequence !== expectedFirstSequence) { + context.addIssue({ + code: z.ZodIssueCode.custom, + message: 'audit summary must begin immediately after its echoed cursor', + path: ['events', 0, 'sequence'], + }); + } + const expectedFirstPreviousDigest = summary.page_anchor?.record_sha256 ?? null; + if (first.previous_record_sha256 !== expectedFirstPreviousDigest) { + context.addIssue({ + code: z.ZodIssueCode.custom, + message: 'audit summary first record must link to the echoed page anchor', + path: ['events', 0, 'previous_record_sha256'], + }); + } + for (let index = 1; index < summary.events.length; index += 1) { + const previous = summary.events[index - 1]; + const current = summary.events[index]; + if (current.sequence !== previous.sequence + 1) { + context.addIssue({ + code: z.ZodIssueCode.custom, + message: 'audit summary events must have ascending contiguous sequences', + path: ['events', index, 'sequence'], + }); + } + if (current.previous_record_sha256 !== previous.record_sha256) { + context.addIssue({ + code: z.ZodIssueCode.custom, + message: 'audit summary events must preserve previous-record digest links', + path: ['events', index, 'previous_record_sha256'], + }); + } + } + const last = summary.events.at(-1)!; + if ( + summary.next_cursor === null || + summary.next_cursor.sequence !== last.sequence || + summary.next_cursor.record_sha256 !== last.record_sha256 + ) { + context.addIssue({ + code: z.ZodIssueCode.custom, + message: 'every nonempty audit page must return the final record as its continuation cursor', + path: ['next_cursor'], + }); + } -const coreHostResultSchema = z.discriminatedUnion('kind', [ + const decisions = new Map>(); + for (const [index, decision] of summary.causal_decisions.entries()) { + if (decision.event_type !== 'command_decision') { + context.addIssue({ + code: z.ZodIssueCode.custom, + message: 'causal decision proofs may contain only command-decision audit records', + path: ['causal_decisions', index, 'event_type'], + }); + } + } + for (const decision of [...summary.events, ...summary.causal_decisions]) { + if (decision.event_type !== 'command_decision') continue; + const existing = decisions.get(decision.audit_id); + if (existing !== undefined && existing.record_sha256 !== decision.record_sha256) { + context.addIssue({ + code: z.ZodIssueCode.custom, + message: 'causal decision audit id cannot resolve to multiple records', + path: ['causal_decisions'], + }); + } + decisions.set(decision.audit_id, decision); + } + for (const [index, event] of summary.events.entries()) { + if (event.event_type !== 'command_result') continue; + const decision = event.causation_audit_id === null ? undefined : decisions.get(event.causation_audit_id); + if ( + decision === undefined || + decision.sequence >= event.sequence || + decision.command_id !== event.command_id || + decision.request_digest_sha256 !== event.request_digest_sha256 || + decision.binding_fingerprint_sha256 !== event.binding_fingerprint_sha256 + ) { + context.addIssue({ + code: z.ZodIssueCode.custom, + message: 'command result must resolve to a matching causal decision record', + path: ['events', index, 'causation_audit_id'], + }); + } + } + }); + +const lifecycleResultSchema = z + .object({ + kind: z.literal('lifecycle'), + configured_mode: z.enum(['off', 'ask_every_time', 'full_access']), + effective_mode: z.enum(['off', 'ask_every_time', 'full_access']), + requested_target_mode: z.enum(['off', 'ask_every_time', 'full_access']).nullable(), + pairing_state: z.enum(['unpaired', 'paired', 'revoked']), + transport_state: transportStateSchema, + }) + .strict() + .superRefine((result, context) => { + const modeRank = { off: 0, ask_every_time: 1, full_access: 2 } as const; + if (modeRank[result.effective_mode] > modeRank[result.configured_mode]) { + context.addIssue({ + code: z.ZodIssueCode.custom, + message: 'lifecycle effective access cannot exceed configured user intent', + path: ['effective_mode'], + }); + } + if (result.pairing_state !== 'paired' && (result.configured_mode !== 'off' || result.effective_mode !== 'off')) { + context.addIssue({ + code: z.ZodIssueCode.custom, + message: 'unpaired or revoked lifecycle state must be configured and effective off', + path: ['pairing_state'], + }); + } + if (result.transport_state === 'connected' && result.pairing_state !== 'paired') { + context.addIssue({ + code: z.ZodIssueCode.custom, + message: 'connected lifecycle transport requires paired state', + path: ['transport_state'], + }); + } + if (result.transport_state === 'revoked' && result.pairing_state !== 'revoked') { + context.addIssue({ + code: z.ZodIssueCode.custom, + message: 'revoked lifecycle transport requires revoked pairing state', + path: ['transport_state'], + }); + } + }); + +const coreHostResultSchema = z.union([ z.object({ kind: z.literal('status'), status: localStatusSchema }).strict(), z .object({ @@ -1210,25 +1772,54 @@ const coreHostResultSchema = z.discriminatedUnion('kind', [ .object({ kind: z.literal('action'), command_id: identifier, + request_digest_sha256: sha256, outcome: z.enum(['denied', 'executed', 'failed', 'stopped']), - audit_id: identifier, - }) - .strict(), - z - .object({ - kind: z.literal('audit_summary'), - events: z.array(auditEventSchema).max(100), - next_sequence: safePositiveCounter.nullable(), + decision_audit_id: identifier, + result_audit_id: identifier.nullable(), + decision_audit: auditEventSchema, + result_audit: auditEventSchema.nullable(), }) - .strict(), - z - .object({ - kind: z.literal('lifecycle'), - effective_mode: z.enum(['off', 'ask_every_time', 'full_access']), - pairing_state: z.enum(['unpaired', 'paired', 'revoked']), - transport_state: z.enum(['disconnected', 'connecting', 'connected', 'revoked', 'blocked']), - }) - .strict(), + .strict() + .superRefine((result, context) => { + const denied = result.outcome === 'denied'; + if ( + result.decision_audit_id !== result.decision_audit.audit_id || + result.decision_audit.event_type !== 'command_decision' || + result.decision_audit.outcome !== (denied ? 'denied' : 'allowed') + ) { + context.addIssue({ + code: z.ZodIssueCode.custom, + message: 'action decision receipt must name the exact allowed or denied decision audit', + path: ['decision_audit_id'], + }); + } + if ( + (denied && (result.result_audit_id !== null || result.result_audit !== null)) || + (!denied && (result.result_audit_id === null || result.result_audit === null)) + ) { + context.addIssue({ + code: z.ZodIssueCode.custom, + message: 'denied actions have only a decision audit; attempted actions require a result audit', + path: ['result_audit_id'], + }); + } + if ( + result.result_audit !== null && + (result.result_audit_id !== result.result_audit.audit_id || + result.result_audit.event_type !== 'command_result' || + result.result_audit.outcome !== result.outcome || + result.result_audit.causation_audit_id !== result.decision_audit.audit_id || + result.result_audit.sequence <= result.decision_audit.sequence) + ) { + context.addIssue({ + code: z.ZodIssueCode.custom, + message: 'action result receipt must be the exact later causal result audit', + path: ['result_audit_id'], + }); + } + }), + auditSummaryResultSchema, + lifecycleResultSchema, ]); export const coreHostResponseSchema = z @@ -1318,6 +1909,25 @@ export const coreHostResponseSchema = z } if (response.result.kind !== 'lifecycle') return; + if (response.operation === 'set_access_mode') { + if ( + response.result.requested_target_mode === null || + response.result.configured_mode !== response.result.requested_target_mode + ) { + context.addIssue({ + code: z.ZodIssueCode.custom, + message: 'set-access-mode response must echo and establish the requested configured mode', + path: ['result', 'requested_target_mode'], + }); + } + } else if (response.result.requested_target_mode !== null) { + context.addIssue({ + code: z.ZodIssueCode.custom, + message: 'requested target mode is valid only for set-access-mode responses', + path: ['result', 'requested_target_mode'], + }); + } + if ( response.operation === 'connect' && (response.result.pairing_state !== 'paired' || response.result.transport_state !== 'connected') @@ -1339,6 +1949,20 @@ export const coreHostResponseSchema = z path: ['result', 'effective_mode'], }); } + if (response.operation === 'resume' && response.result.effective_mode === 'full_access') { + context.addIssue({ + code: z.ZodIssueCode.custom, + message: 'resume response cannot silently restore full access', + path: ['result', 'effective_mode'], + }); + } + if (response.result.transport_state !== 'connected' && response.result.effective_mode !== 'off') { + context.addIssue({ + code: z.ZodIssueCode.custom, + message: 'unavailable lifecycle transport must report effective access off', + path: ['result', 'transport_state'], + }); + } if ( response.operation === 'revoke' && (response.result.pairing_state !== 'revoked' || response.result.transport_state !== 'revoked') @@ -1368,6 +1992,89 @@ export const coreHostResponseSchema = z } }); +export const coreHostExchangeSchema = z + .object({ + request: coreHostRequestSchema, + response: coreHostResponseSchema, + }) + .strict() + .superRefine(({ request, response }, context) => { + if ( + request.request_id !== response.request_id || + request.host_session_id !== response.host_session_id || + request.sequence !== response.sequence || + request.operation !== response.operation + ) { + context.addIssue({ + code: z.ZodIssueCode.custom, + message: 'host response identity must match its exact request', + path: ['response', 'request_id'], + }); + } + if ( + request.operation === 'set_access_mode' && + response.ok && + response.result?.kind === 'lifecycle' && + response.result.requested_target_mode !== request.target_mode + ) { + context.addIssue({ + code: z.ZodIssueCode.custom, + message: 'set-access-mode response target must match the exact request target', + path: ['response', 'result', 'requested_target_mode'], + }); + } + if ( + ['set_access_mode', 'pause', 'resume', 'stop', 'revoke', 'activate_kill_switch'].includes(request.operation) && + typeof request.expected_policy_epoch === 'number' && + response.ok && + response.result?.kind === 'lifecycle' && + response.policy_epoch !== request.expected_policy_epoch + 1 + ) { + context.addIssue({ + code: z.ZodIssueCode.custom, + message: 'policy-changing lifecycle response must advance the exact expected policy epoch once', + path: ['response', 'policy_epoch'], + }); + } + if ( + request.operation === 'dispatch_action' && + response.ok && + response.result?.kind === 'action' && + (response.policy_epoch !== request.expected_policy_epoch || + response.result.command_id !== request.envelope.command_id || + response.result.request_digest_sha256 !== request.envelope.command.request_digest_sha256 || + response.result.decision_audit.command_id !== request.envelope.command_id || + response.result.decision_audit.request_digest_sha256 !== request.envelope.command.request_digest_sha256 || + response.result.decision_audit.binding_fingerprint_sha256 !== + request.envelope.binding.binding_fingerprint_sha256 || + (response.result.result_audit !== null && + (response.result.result_audit.command_id !== request.envelope.command_id || + response.result.result_audit.request_digest_sha256 !== request.envelope.command.request_digest_sha256 || + response.result.result_audit.binding_fingerprint_sha256 !== + request.envelope.binding.binding_fingerprint_sha256)) || + (response.result.result_audit_id !== null && + response.result.result_audit_id === response.result.decision_audit_id)) + ) { + context.addIssue({ + code: z.ZodIssueCode.custom, + message: 'action response must match the signed command, request digest, policy epoch, and distinct audits', + path: ['response', 'result', 'command_id'], + }); + } + if ( + request.operation === 'audit_summary' && + response.ok && + response.result?.kind === 'audit_summary' && + JSON.stringify(response.result.page_anchor) !== JSON.stringify(request.after_cursor) + ) { + context.addIssue({ + code: z.ZodIssueCode.custom, + message: 'audit summary must echo the exact requested cursor', + path: ['response', 'result', 'page_anchor'], + }); + } + }); + export const negativeFixtureCaseSchema = z .object({ id: identifier, @@ -1384,13 +2091,21 @@ export const negativeFixtureCaseSchema = z ]), base_fixture: z.string().min(1), mutations: z.array( - z - .object({ - operation: z.enum(['set', 'remove']), - pointer: z.string().startsWith('/'), - value: z.unknown().optional(), - }) - .strict() + z.discriminatedUnion('operation', [ + z + .object({ + operation: z.literal('set'), + pointer: z.string().startsWith('/'), + value: jsonValueSchema, + }) + .strict(), + z + .object({ + operation: z.literal('remove'), + pointer: z.string().startsWith('/'), + }) + .strict(), + ]) ), expected_stage: z.enum(['schema', 'runtime']), expected_error: identifier, @@ -1424,4 +2139,5 @@ export type BrokerControlEnvelope = z.infer; export type AuditEvent = z.infer; export type CoreHostRequest = z.infer; export type CoreHostResponse = z.infer; +export type CoreHostExchange = z.infer; export type NegativeFixtureCase = z.infer; diff --git a/tests/contract/macAccessContracts.test.ts b/tests/contract/macAccessContracts.test.ts index fa3ce54027..98d3060793 100644 --- a/tests/contract/macAccessContracts.test.ts +++ b/tests/contract/macAccessContracts.test.ts @@ -1,16 +1,21 @@ +import { createHash } from 'node:crypto'; import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; +import canonicalize from 'canonicalize'; import { describe, expect, it } from 'vitest'; import { CORE_HOST_OPERATIONS, + MAC_ACCESS_IDENTITIES, accessStateSchema, accessTransitionSchema, + auditChainGoldenSchema, auditEventSchema, authenticatedLocalActionSchema, brokerControlEnvelopeSchema, + coreHostExchangeSchema, coreHostRequestSchema, coreHostResponseSchema, localStatusSchema, @@ -43,6 +48,33 @@ function cloneJson(value: T): T { return JSON.parse(JSON.stringify(value)) as T; } +type LifecycleResponseFixture = { + schema_version: string; + request_id: string; + host_session_id: string; + sequence: number; + operation: string; + ok: boolean; + policy_epoch: number; + result: { + kind: 'lifecycle'; + configured_mode: 'off' | 'ask_every_time' | 'full_access'; + effective_mode: 'off' | 'ask_every_time' | 'full_access'; + requested_target_mode: 'off' | 'ask_every_time' | 'full_access' | null; + pairing_state: 'unpaired' | 'paired' | 'revoked'; + transport_state: 'disconnected' | 'connecting' | 'connected' | 'revoked' | 'blocked'; + }; + error: { code: string; audit_id: string | null } | null; +}; + +function readLifecycleResponseFixture(): LifecycleResponseFixture { + const response = cloneJson( + coreHostResponseSchema.parse(readJson(path.join(contractRoot, 'fixtures/host/host-response.json'))) + ); + if (response.result?.kind !== 'lifecycle') throw new Error('Expected lifecycle response fixture'); + return response as unknown as LifecycleResponseFixture; +} + function applyMutation( target: unknown, mutation: { operation?: 'set' | 'remove'; pointer?: string; value?: unknown } @@ -50,6 +82,9 @@ function applyMutation( if (!mutation.operation || !mutation.pointer) { throw new Error('Fixture mutation is missing its operation or pointer'); } + if (mutation.operation === 'set' && !Object.hasOwn(mutation, 'value')) { + throw new Error('Set fixture mutation is missing its value'); + } const parts = mutation.pointer .slice(1) .split('/') @@ -227,6 +262,7 @@ describe('evaOS Mac Access v1 contracts', () => { ['access_transition', 'state/access-transition-grant-expired.json'], ['local_status', 'state/local-status.json'], ['authenticated_local_action', 'authority/local-action.json'], + ['authenticated_local_action', 'authority/begin-pairing-action.json'], ['broker_control', 'authority/broker-control.json'], ['audit_event', 'audit/audit-event.json'], ['core_host_request', '../host/host-request.json'], @@ -261,7 +297,6 @@ describe('evaOS Mac Access v1 contracts', () => { host_session_id: 'host-session-01', expected_policy_epoch: 7, }; - const reason = { reason_code: 'local_user_request' }; const requests: unknown[] = [ { ...identity, request_id: 'host-status', operation: 'status', expected_policy_epoch: null, sequence: 1 }, { @@ -270,11 +305,11 @@ describe('evaOS Mac Access v1 contracts', () => { operation: 'pair', sequence: 2, pairing_code: 'ABC123', - local_installation_nonce: 'bm9uY2U', + local_installation_nonce: 'MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY', }, - { ...identity, ...reason, request_id: 'host-unpair', operation: 'unpair', sequence: 3 }, + { ...identity, request_id: 'host-unpair', operation: 'unpair', sequence: 3 }, { ...identity, request_id: 'host-connect', operation: 'connect', sequence: 4, binding: envelope.binding }, - { ...identity, ...reason, request_id: 'host-disconnect', operation: 'disconnect', sequence: 5 }, + { ...identity, request_id: 'host-disconnect', operation: 'disconnect', sequence: 5 }, { ...identity, request_id: 'host-mode', @@ -288,15 +323,15 @@ describe('evaOS Mac Access v1 contracts', () => { request_id: 'host-audit', operation: 'audit_summary', sequence: 8, - after_sequence: null, + after_cursor: null, limit: 25, }, - { ...identity, ...reason, request_id: 'host-pause', operation: 'pause', sequence: 9 }, - { ...identity, ...reason, request_id: 'host-resume', operation: 'resume', sequence: 10 }, - { ...identity, ...reason, request_id: 'host-stop', operation: 'stop', sequence: 11 }, - { ...identity, ...reason, request_id: 'host-revoke', operation: 'revoke', sequence: 12 }, - { ...identity, ...reason, request_id: 'host-kill', operation: 'activate_kill_switch', sequence: 13 }, - { ...identity, ...reason, request_id: 'host-shutdown', operation: 'shutdown', sequence: 14 }, + { ...identity, request_id: 'host-pause', operation: 'pause', sequence: 9 }, + { ...identity, request_id: 'host-resume', operation: 'resume', sequence: 10 }, + { ...identity, request_id: 'host-stop', operation: 'stop', sequence: 11 }, + { ...identity, request_id: 'host-revoke', operation: 'revoke', sequence: 12 }, + { ...identity, request_id: 'host-kill', operation: 'activate_kill_switch', sequence: 13 }, + { ...identity, request_id: 'host-shutdown', operation: 'shutdown', sequence: 14 }, ]; for (const request of requests) expect(coreHostRequestSchema.safeParse(request).success).toBe(true); @@ -359,8 +394,13 @@ describe('evaOS Mac Access v1 contracts', () => { policy_epoch: 7, result: { kind: 'audit_summary', + page_anchor: null, events: [cloneJson(readJson(path.join(validRoot, 'audit/audit-event.json')))], - next_sequence: null, + causal_decisions: [], + next_cursor: { + sequence: 1, + record_sha256: 'a0cf9968470f3ae354e1ef68e7aa80ba34c7b0d715e3db4a07fca5cbe7b6ced9', + }, }, error: null, }; @@ -521,7 +561,9 @@ describe('evaOS Mac Access v1 contracts', () => { operation: 'connect', result: { kind: 'lifecycle', + configured_mode: 'ask_every_time', effective_mode: 'ask_every_time', + requested_target_mode: null, pairing_state: 'paired', transport_state: 'disconnected', }, @@ -532,7 +574,9 @@ describe('evaOS Mac Access v1 contracts', () => { operation: 'disconnect', result: { kind: 'lifecycle', + configured_mode: 'ask_every_time', effective_mode: 'ask_every_time', + requested_target_mode: null, pairing_state: 'paired', transport_state: 'connected', }, @@ -556,6 +600,7 @@ describe('evaOS Mac Access v1 contracts', () => { const base = cloneJson(readJson(path.join(contractRoot, 'fixtures/host/host-response.json'))) as { operation: string; result: { + requested_target_mode: string | null; effective_mode: string; pairing_state: string; transport_state: string; @@ -588,6 +633,7 @@ describe('evaOS Mac Access v1 contracts', () => { for (const testCase of cases) { const response = cloneJson(base); response.operation = testCase.operation; + response.result.requested_target_mode = null; response.result.effective_mode = testCase.effective_mode; response.result.pairing_state = testCase.pairing_state; response.result.transport_state = testCase.transport_state; @@ -604,6 +650,490 @@ describe('evaOS Mac Access v1 contracts', () => { } }); + it('forces effective access off for every unavailable transport owner state', () => { + for (const transportState of ['disconnected', 'connecting', 'revoked', 'blocked'] as const) { + const status = cloneJson(localStatusSchema.parse(readJson(path.join(validRoot, 'state/local-status.json')))); + status.transport.state = transportState; + status.transport.channel_id = null; + status.access.effective_mode = 'ask_every_time'; + if (transportState === 'revoked') { + status.access.pairing_state = 'revoked'; + status.access.configured_mode = 'off'; + status.access.binding = null; + } + const parsed = localStatusSchema.safeParse(status); + expect(parsed.success, transportState).toBe(false); + if (!parsed.success) { + expect(parsed.error.issues.some((issue) => issue.path.join('/') === 'transport/state')).toBe(true); + } + } + + const status = cloneJson(localStatusSchema.parse(readJson(path.join(validRoot, 'state/local-status.json')))); + applyMutation(status, { + operation: 'set', + pointer: '/transport/responsible_identity', + value: MAC_ACCESS_IDENTITIES.connectorServiceId, + }); + const parsed = localStatusSchema.safeParse(status); + expect(parsed.success).toBe(false); + if (!parsed.success) { + expect(parsed.error.issues.some((issue) => issue.path.join('/') === 'transport/responsible_identity')).toBe(true); + } + }); + + it('forces access off while an audit anchor commit is pending', () => { + const status = cloneJson(localStatusSchema.parse(readJson(path.join(validRoot, 'state/local-status.json')))); + status.audit.anchor.pending_sequence = 2; + status.audit.anchor.pending_audit_id = 'audit-02'; + status.audit.anchor.pending_record_sha256 = '8'.repeat(64); + const parsed = localStatusSchema.safeParse(status); + expect(parsed.success).toBe(false); + if (!parsed.success) { + expect(parsed.error.issues.some((issue) => issue.path.join('/') === 'audit/anchor/pending_sequence')).toBe(true); + } + }); + + it('binds successful access-mode responses to the exact request target', () => { + const request = readJson(path.join(contractRoot, 'fixtures/host/host-request.json')); + const response = readLifecycleResponseFixture(); + expect(coreHostExchangeSchema.safeParse({ request, response }).success).toBe(true); + + response.result.configured_mode = 'off'; + response.result.effective_mode = 'off'; + response.result.requested_target_mode = 'off'; + const mismatchedExchange = coreHostExchangeSchema.safeParse({ request, response }); + expect(mismatchedExchange.success).toBe(false); + if (!mismatchedExchange.success) { + expect( + mismatchedExchange.error.issues.some( + (issue) => issue.path.join('/') === 'response/result/requested_target_mode' + ) + ).toBe(true); + } + + response.result.configured_mode = 'ask_every_time'; + const inconsistentResponse = coreHostResponseSchema.safeParse(response); + expect(inconsistentResponse.success).toBe(false); + + const staleEpochResponse = readLifecycleResponseFixture(); + staleEpochResponse.policy_epoch = 999; + const staleEpochExchange = coreHostExchangeSchema.safeParse({ request, response: staleEpochResponse }); + expect(staleEpochExchange.success).toBe(false); + if (!staleEpochExchange.success) { + expect(staleEpochExchange.error.issues.some((issue) => issue.path.join('/') === 'response/policy_epoch')).toBe( + true + ); + } + }); + + it('rejects lifecycle receipts that exceed configured or pairing authority', () => { + const exceedsConfigured = readLifecycleResponseFixture(); + exceedsConfigured.result.configured_mode = 'off'; + exceedsConfigured.result.requested_target_mode = 'off'; + exceedsConfigured.result.effective_mode = 'full_access'; + expect(coreHostResponseSchema.safeParse(exceedsConfigured).success).toBe(false); + + const unpairedConnected = readLifecycleResponseFixture(); + unpairedConnected.result.pairing_state = 'unpaired'; + expect(coreHostResponseSchema.safeParse(unpairedConnected).success).toBe(false); + + const pairedRevoked = readLifecycleResponseFixture(); + pairedRevoked.result.configured_mode = 'off'; + pairedRevoked.result.effective_mode = 'off'; + pairedRevoked.result.requested_target_mode = 'off'; + pairedRevoked.result.transport_state = 'revoked'; + expect(coreHostResponseSchema.safeParse(pairedRevoked).success).toBe(false); + }); + + it('binds policy-changing lifecycle receipts to one exact epoch advance', () => { + const cases = [ + { operation: 'pause', configured: 'ask_every_time', effective: 'off', pairing: 'paired', transport: 'connected' }, + { + operation: 'resume', + configured: 'ask_every_time', + effective: 'ask_every_time', + pairing: 'paired', + transport: 'connected', + }, + { + operation: 'stop', + configured: 'ask_every_time', + effective: 'off', + pairing: 'paired', + transport: 'disconnected', + }, + { operation: 'revoke', configured: 'off', effective: 'off', pairing: 'revoked', transport: 'revoked' }, + { + operation: 'activate_kill_switch', + configured: 'off', + effective: 'off', + pairing: 'paired', + transport: 'blocked', + }, + ] as const; + + for (const [index, testCase] of cases.entries()) { + const request = { + schema_version: 'evaos.mac_connector_core.host_request.v1', + request_id: `policy-lifecycle-${index}`, + host_session_id: 'host-session-01', + sequence: index + 1, + operation: testCase.operation, + expected_policy_epoch: 7, + }; + const response: LifecycleResponseFixture = { + schema_version: 'evaos.mac_connector_core.host_response.v1', + request_id: request.request_id, + host_session_id: request.host_session_id, + sequence: request.sequence, + operation: request.operation, + ok: true, + policy_epoch: 8, + result: { + kind: 'lifecycle', + configured_mode: testCase.configured, + effective_mode: testCase.effective, + requested_target_mode: null, + pairing_state: testCase.pairing, + transport_state: testCase.transport, + }, + error: null, + }; + expect(coreHostExchangeSchema.safeParse({ request, response }).success, testCase.operation).toBe(true); + response.policy_epoch = 999; + expect(coreHostExchangeSchema.safeParse({ request, response }).success, testCase.operation).toBe(false); + } + }); + + it('rejects runtime identity drift outside restart and preserves configured intent', () => { + const transition = cloneJson( + accessTransitionSchema.parse(readJson(path.join(validRoot, 'state/access-transition-stop.json'))) + ); + transition.to.runtime_instance_id = 'runtime-instance-02'; + const drift = accessTransitionSchema.safeParse(transition); + expect(drift.success).toBe(false); + if (!drift.success) { + expect(drift.error.issues.some((issue) => issue.path.join('/') === 'to/runtime_instance_id')).toBe(true); + } + + const unsafeStop = cloneJson( + accessTransitionSchema.parse(readJson(path.join(validRoot, 'state/access-transition-stop.json'))) + ); + unsafeStop.to.local_confirmation_required = false; + const stop = accessTransitionSchema.safeParse(unsafeStop); + expect(stop.success).toBe(false); + if (!stop.success) expect(stop.error.issues.some((issue) => issue.path.join('/') === 'event')).toBe(true); + + const intentEscalation = cloneJson( + accessTransitionSchema.parse(readJson(path.join(validRoot, 'state/access-transition-stop.json'))) + ); + intentEscalation.from.configured_mode = 'ask_every_time'; + intentEscalation.from.effective_mode = 'ask_every_time'; + intentEscalation.from.confirmed_runtime_instance_id = null; + intentEscalation.from.confirmed_policy_epoch = null; + intentEscalation.from.confirmed_binding_fingerprint_sha256 = null; + intentEscalation.from.reason_code = 'pairing_confirmed'; + const escalated = accessTransitionSchema.safeParse(intentEscalation); + expect(escalated.success).toBe(false); + if (!escalated.success) { + expect(escalated.error.issues.some((issue) => issue.path.join('/') === 'to/configured_mode')).toBe(true); + } + }); + + it('preserves configured full-access intent through restart and resume while requiring reconfirmation', () => { + const base = accessTransitionSchema.parse(readJson(path.join(validRoot, 'state/access-transition-stop.json'))); + const restart = cloneJson(base); + restart.event = 'restart'; + restart.to.runtime_instance_id = 'runtime-instance-02'; + restart.to.effective_mode = 'ask_every_time'; + restart.to.reason_code = 'runtime_restart'; + expect(accessTransitionSchema.safeParse(restart).success).toBe(true); + + const restartMutation = cloneJson(restart); + restartMutation.to.configured_mode = 'ask_every_time'; + restartMutation.to.local_confirmation_required = false; + expect(accessTransitionSchema.safeParse(restartMutation).success).toBe(false); + + const resume = cloneJson(base); + resume.event = 'resume'; + resume.invalidated_pending_authority = false; + resume.safe_cancellation_requested = false; + resume.from.effective_mode = 'off'; + resume.from.paused = true; + resume.from.local_confirmation_required = true; + resume.from.confirmed_runtime_instance_id = null; + resume.from.confirmed_policy_epoch = null; + resume.from.confirmed_binding_fingerprint_sha256 = null; + resume.to.effective_mode = 'ask_every_time'; + resume.to.reason_code = 'local_resume'; + expect(accessTransitionSchema.safeParse(resume).success).toBe(true); + + const resumeMutation = cloneJson(resume); + resumeMutation.to.configured_mode = 'ask_every_time'; + resumeMutation.to.local_confirmation_required = false; + expect(accessTransitionSchema.safeParse(resumeMutation).success).toBe(false); + }); + + it('makes the kill switch a fail-closed configured-off transition', () => { + const base = accessTransitionSchema.parse(readJson(path.join(validRoot, 'state/access-transition-stop.json'))); + const killSwitch = cloneJson(base); + killSwitch.event = 'kill_switch'; + killSwitch.to.configured_mode = 'off'; + killSwitch.to.kill_switch = true; + killSwitch.to.local_confirmation_required = false; + killSwitch.to.reason_code = 'local_kill_switch'; + expect(accessTransitionSchema.safeParse(killSwitch).success).toBe(true); + + const unsafeIntent = cloneJson(killSwitch); + unsafeIntent.to.configured_mode = 'full_access'; + unsafeIntent.to.local_confirmation_required = true; + expect(accessTransitionSchema.safeParse(unsafeIntent).success).toBe(false); + }); + + it('requires pairing code and installation nonce only for begin pairing', () => { + const pairing = cloneJson( + authenticatedLocalActionSchema.parse(readJson(path.join(validRoot, 'authority/begin-pairing-action.json'))) + ); + expect(authenticatedLocalActionSchema.safeParse(pairing).success).toBe(true); + pairing.request.pairing_code = null; + const missingCode = authenticatedLocalActionSchema.safeParse(pairing); + expect(missingCode.success).toBe(false); + if (!missingCode.success) { + expect(missingCode.error.issues.some((issue) => issue.path.join('/') === 'request/pairing_code')).toBe(true); + } + pairing.request.pairing_code = 'ABC123'; + pairing.request.local_installation_nonce = null; + const missingNonce = authenticatedLocalActionSchema.safeParse(pairing); + expect(missingNonce.success).toBe(false); + if (!missingNonce.success) { + expect( + missingNonce.error.issues.some((issue) => issue.path.join('/') === 'request/local_installation_nonce') + ).toBe(true); + } + + for (const invalidNonce of ['A', 'A'.repeat(44), `${'A'.repeat(42)}B`]) { + pairing.request.local_installation_nonce = invalidNonce; + expect(authenticatedLocalActionSchema.safeParse(pairing).success, invalidNonce.length.toString()).toBe(false); + } + + const hostPair = { + schema_version: 'evaos.mac_connector_core.host_request.v1', + request_id: 'host-pair-nonce', + host_session_id: 'host-session-01', + sequence: 1, + operation: 'pair', + expected_policy_epoch: 7, + pairing_code: 'ABC123', + local_installation_nonce: 'A', + }; + expect(coreHostRequestSchema.safeParse(hostPair).success).toBe(false); + hostPair.local_installation_nonce = `${'A'.repeat(42)}B`; + expect(coreHostRequestSchema.safeParse(hostPair).success).toBe(false); + }); + + it('uses unambiguous JSON fixture mutations', () => { + const base = { + id: 'mutation-shape', + threat: 'fixture_integrity', + contract: 'access_state', + base_fixture: '../valid/state/access-state.json', + expected_stage: 'schema', + expected_error: 'fixture_integrity', + required_runtime_rejection: null as string | null, + }; + expect( + negativeFixtureCaseSchema.safeParse({ + ...base, + mutations: [{ operation: 'set', pointer: '/reason_code' }], + }).success + ).toBe(false); + expect( + negativeFixtureCaseSchema.safeParse({ + ...base, + mutations: [{ operation: 'remove', pointer: '/reason_code', value: 'unexpected' }], + }).success + ).toBe(false); + }); + + it('requires closed redacted evidence and causal command audit records', () => { + const event = cloneJson(auditEventSchema.parse(readJson(path.join(validRoot, 'audit/audit-event.json')))); + event.command_id = null; + event.request_digest_sha256 = null; + const uncorrelated = auditEventSchema.safeParse(event); + expect(uncorrelated.success).toBe(false); + if (!uncorrelated.success) { + expect(uncorrelated.error.issues.some((issue) => issue.path.join('/') === 'command_id')).toBe(true); + } + + const unsafeEvidence = cloneJson(auditEventSchema.parse(readJson(path.join(validRoot, 'audit/audit-event.json')))); + applyMutation(unsafeEvidence, { + operation: 'set', + pointer: '/evidence/target_path_hash', + value: 'private-path', + }); + expect(auditEventSchema.safeParse(unsafeEvidence).success).toBe(false); + + const unsafeReason = cloneJson(auditEventSchema.parse(readJson(path.join(validRoot, 'audit/audit-event.json')))); + applyMutation(unsafeReason, { + operation: 'set', + pointer: '/reason_code', + value: 'Bearer-secret-material', + }); + expect(auditEventSchema.safeParse(unsafeReason).success).toBe(false); + + const invalidEvidenceCases = [ + ['/evidence/capability', 'customer_mac.desktop_unknown'], + ['/evidence/state_from', 'private_state'], + ['/evidence/transport_state', 'online'], + ['/evidence/detail_code', 'custom_detail'], + ['/evidence/schema_version', 'evaos.unknown.v99'], + ['/evidence/build_version', 'build-secret'], + ['/evidence/build_version', `1.2.3-${'a'.repeat(129)}`], + ] as const; + for (const [pointer, value] of invalidEvidenceCases) { + const candidate = cloneJson(auditEventSchema.parse(readJson(path.join(validRoot, 'audit/audit-event.json')))); + applyMutation(candidate, { operation: 'set', pointer, value }); + expect(auditEventSchema.safeParse(candidate).success, pointer).toBe(false); + } + + const selfCausal = cloneJson( + auditChainGoldenSchema.parse(readJson(path.join(validRoot, 'audit/audit-chain-golden.json'))).records[1].payload + ); + selfCausal.causation_audit_id = selfCausal.audit_id; + expect(auditEventSchema.safeParse({ ...selfCausal, record_sha256: '8'.repeat(64) }).success).toBe(false); + }); + + it('validates audit pages as cursor-bound contiguous digest chains', () => { + const golden = auditChainGoldenSchema.parse( + readJson(path.join(validRoot, 'audit/audit-chain-golden.json')) + ) as unknown as { + records: Array<{ + payload: Record & { sequence: number; previous_record_sha256: string | null }; + record_sha256: string; + }>; + }; + const events = golden.records.map((record) => + Object.assign({}, record.payload, { record_sha256: record.record_sha256 }) + ); + const response = { + schema_version: 'evaos.mac_connector_core.host_response.v1', + request_id: 'host-audit-page', + host_session_id: 'host-session-01', + sequence: 1, + operation: 'audit_summary', + ok: true, + policy_epoch: 7, + result: { + kind: 'audit_summary', + page_anchor: null as { sequence: number; record_sha256: string } | null, + events, + causal_decisions: [] as typeof events, + next_cursor: { sequence: events[1].sequence, record_sha256: events[1].record_sha256 }, + }, + error: null as { code: string; audit_id: string | null } | null, + }; + expect(coreHostResponseSchema.safeParse(response).success).toBe(true); + + const reordered = cloneJson(response); + reordered.result.events.reverse(); + expect(coreHostResponseSchema.safeParse(reordered).success).toBe(false); + + const tamperedRecord = cloneJson(response); + tamperedRecord.result.events[0].outcome = 'denied'; + expect(coreHostResponseSchema.safeParse(tamperedRecord).success).toBe(false); + + const continuation = cloneJson(response); + continuation.result.page_anchor = { sequence: events[0].sequence, record_sha256: events[0].record_sha256 }; + continuation.result.events = [events[1]]; + continuation.result.causal_decisions = [events[0]]; + expect(coreHostResponseSchema.safeParse(continuation).success).toBe(true); + + const wrongDecision = cloneJson(continuation); + wrongDecision.result.causal_decisions[0].command_id = 'unrelated-command'; + expect(coreHostResponseSchema.safeParse(wrongDecision).success).toBe(false); + + const foreignDecision = cloneJson(continuation); + const foreignDecisionRecord = cloneJson(events[0]); + foreignDecisionRecord.occurred_at = '2026-07-14T12:00:02Z'; + const { record_sha256: _foreignDigest, ...foreignDecisionPayload } = foreignDecisionRecord; + foreignDecisionRecord.record_sha256 = createHash('sha256') + .update(canonicalize(foreignDecisionPayload)!) + .digest('hex'); + foreignDecision.result.causal_decisions = [foreignDecisionRecord]; + expect(coreHostResponseSchema.safeParse(foreignDecision).success).toBe(false); + + const hiddenSuffix = cloneJson(response); + hiddenSuffix.result.next_cursor = null; + expect(coreHostResponseSchema.safeParse(hiddenSuffix).success).toBe(false); + + continuation.result.page_anchor.record_sha256 = 'f'.repeat(64); + expect(coreHostResponseSchema.safeParse(continuation).success).toBe(false); + }); + + it('binds action receipts to the exact signed dispatch envelope and distinct audits', () => { + const envelope = brokerControlEnvelopeSchema.parse(readJson(path.join(validRoot, 'authority/broker-control.json'))); + const auditGolden = auditChainGoldenSchema.parse(readJson(path.join(validRoot, 'audit/audit-chain-golden.json'))); + const [decisionRecord, resultRecord] = auditGolden.records; + const decisionAudit = { ...decisionRecord.payload, record_sha256: decisionRecord.record_sha256 }; + const resultAudit = { ...resultRecord.payload, record_sha256: resultRecord.record_sha256 }; + const request = { + schema_version: 'evaos.mac_connector_core.host_request.v1', + request_id: 'host-dispatch-receipt', + host_session_id: 'host-session-01', + sequence: 1, + operation: 'dispatch_action', + expected_policy_epoch: envelope.policy_epoch, + envelope, + }; + const response = { + schema_version: 'evaos.mac_connector_core.host_response.v1', + request_id: request.request_id, + host_session_id: request.host_session_id, + sequence: request.sequence, + operation: request.operation, + ok: true, + policy_epoch: request.expected_policy_epoch, + result: { + kind: 'action', + command_id: envelope.command_id, + request_digest_sha256: envelope.command.request_digest_sha256, + outcome: 'executed', + decision_audit_id: decisionAudit.audit_id, + result_audit_id: resultAudit.audit_id, + decision_audit: decisionAudit, + result_audit: resultAudit, + }, + error: null as { code: string; audit_id: string | null } | null, + }; + expect(coreHostExchangeSchema.safeParse({ request, response }).success).toBe(true); + + const wrongCommand = cloneJson(response); + wrongCommand.result.command_id = 'unrelated-command'; + expect(coreHostExchangeSchema.safeParse({ request, response: wrongCommand }).success).toBe(false); + + const reusedAudit = cloneJson(response); + reusedAudit.result.result_audit_id = reusedAudit.result.decision_audit_id; + expect(coreHostExchangeSchema.safeParse({ request, response: reusedAudit }).success).toBe(false); + + const arbitraryAudits = cloneJson(response); + arbitraryAudits.result.decision_audit_id = 'unrelated-decision'; + arbitraryAudits.result.result_audit_id = 'unrelated-result'; + expect(coreHostExchangeSchema.safeParse({ request, response: arbitraryAudits }).success).toBe(false); + }); + + it('rejects resume responses that silently restore full access', () => { + const response = readLifecycleResponseFixture(); + response.operation = 'resume'; + response.result.configured_mode = 'full_access'; + response.result.effective_mode = 'full_access'; + response.result.requested_target_mode = null; + const parsed = coreHostResponseSchema.safeParse(response); + expect(parsed.success).toBe(false); + if (!parsed.success) { + expect(parsed.error.issues.some((issue) => issue.path.join('/') === 'result/effective_mode')).toBe(true); + } + }); + it('keeps every required adversarial threat in the versioned negative fixture set', () => { const requiredThreats = new Set([ 'replay', diff --git a/tests/contract/macAccessCryptography.test.ts b/tests/contract/macAccessCryptography.test.ts index e6fe5eb5e0..1aeb967164 100644 --- a/tests/contract/macAccessCryptography.test.ts +++ b/tests/contract/macAccessCryptography.test.ts @@ -3,6 +3,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; +import canonicalize from 'canonicalize'; import { describe, expect, it } from 'vitest'; import { @@ -24,22 +25,51 @@ function readJson(filePath: string): unknown { return JSON.parse(fs.readFileSync(filePath, 'utf8')); } +function assertValidUnicodeString(text: string): void { + for (let index = 0; index < text.length; index += 1) { + const codeUnit = text.charCodeAt(index); + if (codeUnit >= 0xd800 && codeUnit <= 0xdbff) { + const next = text.charCodeAt(index + 1); + if (!Number.isInteger(next) || next < 0xdc00 || next > 0xdfff) { + throw new Error('RFC 8785 forbids lone surrogate code points'); + } + index += 1; + } else if (codeUnit >= 0xdc00 && codeUnit <= 0xdfff) { + throw new Error('RFC 8785 forbids lone surrogate code points'); + } + } +} + +function assertValidUnicode(value: unknown): void { + if (typeof value === 'string') assertValidUnicodeString(value); + else if (Array.isArray(value)) value.forEach(assertValidUnicode); + else if (value !== null && typeof value === 'object') { + for (const [key, child] of Object.entries(value)) { + assertValidUnicodeString(key); + assertValidUnicode(child); + } + } +} + function canonicalizeJcs(value: unknown): string { - if (value === null || typeof value !== 'object') return JSON.stringify(value); - if (Array.isArray(value)) return `[${value.map(canonicalizeJcs).join(',')}]`; - return `{${Object.entries(value) - // oxlint-disable-next-line unicorn/no-array-sort -- Object.entries returns a fresh array required for JCS key order. - .sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0)) - .map(([key, item]) => `${JSON.stringify(key)}:${canonicalizeJcs(item)}`) - .join(',')}}`; + assertValidUnicode(value); + const serialized = canonicalize(value); + if (serialized === undefined) throw new Error('RFC 8785 canonicalization rejected the contract value'); + return serialized; } -function verifiesAuditChainGolden(value: unknown): boolean { - const parsed = auditChainGoldenSchema.safeParse(value); - if (!parsed.success) return false; +type AuditGoldenRecord = { + payload: { sequence: number; previous_record_sha256: string | null } & Record; + canonical_payload_utf8: string; + record_sha256: string; +}; +function verifiesAnchoredAuditRecords( + records: AuditGoldenRecord[], + anchor: { sequence: number; recordSha256: string } | null +): boolean { let previousDigest: string | null = null; - for (const [index, record] of parsed.data.records.entries()) { + for (const [index, record] of records.entries()) { const canonical = canonicalizeJcs(record.payload); if (record.payload.sequence !== index + 1) return false; if (record.payload.previous_record_sha256 !== previousDigest) return false; @@ -47,7 +77,14 @@ function verifiesAuditChainGolden(value: unknown): boolean { if (createHash('sha256').update(canonical).digest('hex') !== record.record_sha256) return false; previousDigest = record.record_sha256; } - return true; + if (anchor === null) return true; + return records.at(-1)?.payload.sequence === anchor.sequence && previousDigest === anchor.recordSha256; +} + +function verifiesAuditChainGolden(value: unknown): boolean { + const parsed = auditChainGoldenSchema.safeParse(value); + if (!parsed.success) return false; + return verifiesAnchoredAuditRecords(parsed.data.records as unknown as AuditGoldenRecord[], null); } describe('evaOS Mac Access canonical cryptographic contracts', () => { @@ -94,6 +131,34 @@ describe('evaOS Mac Access canonical cryptographic contracts', () => { expect(verifiesAuditChainGolden(readJson(path.join(validRoot, 'audit/audit-chain-golden.json')))).toBe(true); }); + it('uses RFC 8785 number, escaping, UTF-16 ordering, and Unicode validity rules', () => { + expect(canonicalizeJcs({ numbers: [Number('333333333.33333329'), 1e30, 4.5, 0.002, 1e-27] })).toBe( + '{"numbers":[333333333.3333333,1e+30,4.5,0.002,1e-27]}' + ); + expect(canonicalizeJcs({ '€': 'Euro', '\r': 'CR', '1': 'one', '\u0080': 'control', '😀': 'emoji' })).toBe( + '{"\\r":"CR","1":"one","€":"control","€":"Euro","😀":"emoji"}' + ); + expect(() => canonicalizeJcs({ invalid: '\ud800' })).toThrow('lone surrogate'); + }); + + it('keeps authority, status, and audit fixtures on one selected binding', () => { + const broker = commandAuthorityGoldenSchema.parse( + readJson(path.join(validRoot, 'authority/command-authority-golden.json')) + ); + const status = localStatusSchema.parse(readJson(path.join(validRoot, 'state/local-status.json'))); + const audit = readJson(path.join(validRoot, 'audit/audit-event.json')) as { + binding_fingerprint_sha256: string; + }; + expect(audit.binding_fingerprint_sha256).toBe(broker.payload.binding.binding_fingerprint_sha256); + expect(audit.binding_fingerprint_sha256).toBe(status.access.binding?.binding_fingerprint_sha256); + }); + + it('verifies the standalone audit event record digest', () => { + const event = readJson(path.join(validRoot, 'audit/audit-event.json')) as Record; + const { record_sha256: recordSha256, ...payload } = event; + expect(createHash('sha256').update(canonicalizeJcs(payload)).digest('hex')).toBe(recordSha256); + }); + it('verifies the exact-target rollback authorization golden vector', () => { const vector = rollbackAuthorizationGoldenSchema.parse( readJson(path.join(goldenRoot, 'rollback-authorization-golden.json')) @@ -168,6 +233,19 @@ describe('evaOS Mac Access canonical cryptographic contracts', () => { expect(verifiesAuditChainGolden(vector)).toBe(false); }); + it('rejects a rehashed golden result that is not causally bound to its decision', () => { + const vector = structuredClone(readJson(path.join(validRoot, 'audit/audit-chain-golden.json'))) as { + records: AuditGoldenRecord[]; + }; + vector.records[1].payload.command_id = 'unrelated-command'; + vector.records[1].canonical_payload_utf8 = canonicalizeJcs(vector.records[1].payload); + vector.records[1].record_sha256 = createHash('sha256') + .update(vector.records[1].canonical_payload_utf8) + .digest('hex'); + expect(auditChainGoldenSchema.safeParse(vector).success).toBe(false); + expect(verifiesAuditChainGolden(vector)).toBe(false); + }); + it('rejects deletion of an audit record from a persisted chain', () => { const vector = structuredClone(readJson(path.join(validRoot, 'audit/audit-chain-golden.json'))) as { records: unknown[]; @@ -176,6 +254,29 @@ describe('evaOS Mac Access canonical cryptographic contracts', () => { expect(verifiesAuditChainGolden(vector)).toBe(false); }); + it('rejects valid-tail truncation and whole-journal replacement against the protected anchor', () => { + const vector = structuredClone(readJson(path.join(validRoot, 'audit/audit-chain-golden.json'))) as { + records: AuditGoldenRecord[]; + }; + const anchor = { + sequence: vector.records[1].payload.sequence, + recordSha256: vector.records[1].record_sha256, + }; + expect(verifiesAnchoredAuditRecords(vector.records, anchor)).toBe(true); + expect(verifiesAnchoredAuditRecords(vector.records.slice(0, 1), anchor)).toBe(false); + + const replacement = structuredClone(vector.records); + replacement[0].payload.reason_code = 'denied_access_off'; + replacement[0].payload.outcome = 'denied'; + replacement[0].canonical_payload_utf8 = canonicalizeJcs(replacement[0].payload); + replacement[0].record_sha256 = createHash('sha256').update(replacement[0].canonical_payload_utf8).digest('hex'); + replacement[1].payload.previous_record_sha256 = replacement[0].record_sha256; + replacement[1].canonical_payload_utf8 = canonicalizeJcs(replacement[1].payload); + replacement[1].record_sha256 = createHash('sha256').update(replacement[1].canonical_payload_utf8).digest('hex'); + expect(verifiesAnchoredAuditRecords(replacement, null)).toBe(true); + expect(verifiesAnchoredAuditRecords(replacement, anchor)).toBe(false); + }); + it('rejects reordered audit records', () => { const vector = structuredClone(readJson(path.join(validRoot, 'audit/audit-chain-golden.json'))) as { records: unknown[]; From 01b0dae2d16a95e817b827219006c3b9e614f7c8 Mon Sep 17 00:00:00 2001 From: Eva Date: Wed, 15 Jul 2026 19:44:59 +0700 Subject: [PATCH 08/14] docs(mac-access): align audit paging contract --- docs/evaos/mac-access/architecture.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/evaos/mac-access/architecture.md b/docs/evaos/mac-access/architecture.md index 4216e43096..1616ec7cee 100644 --- a/docs/evaos/mac-access/architecture.md +++ b/docs/evaos/mac-access/architecture.md @@ -283,7 +283,7 @@ Ask Every Time approval defaults to 60 seconds or the earlier broker authority e ## Audit and redaction -The helper writes an append-only, owner-only local journal with a monotonic sequence, previous-record digest, and record digest. `record_sha256` is SHA-256 over the exact UTF-8 RFC 8785/JCS serialization of the versioned `evaos.mac_access.audit_event.v1` payload with `record_sha256` excluded. Every returned audit record is canonicalized and rehashed before its chain, cursor, or receipt fields are trusted. The next payload's `previous_record_sha256` must equal that digest. `audit-chain-golden.json` freezes a causal command-decision/result pair, canonical bytes, and both digests; edit/delete/reorder/previous-digest and rehashed-but-uncorrelated checks are required runtime tests. Command decisions require binding, command, and request digests and use allowed/denied outcomes; command results cite the exact decision audit ID and use executed/failed/stopped outcomes. Action responses carry the complete verified decision record and, for attempted actions, its later verified causal result record; bare caller-chosen audit IDs are insufficient. A paged result may use an off-page causal decision only when that complete verified record is exactly the digest-bound page anchor; otherwise the decision must occur in the verified page or be supplied from the client's previously verified cache. Non-command events cannot carry command correlation fields. The actor is a closed non-PII pair derived by the helper: console user, authenticated Workbench main process, broker-selected OpenClaw/Hermes runtime, or one of the owned helper subsystems. The payload otherwise records access mode, outcome, a closed reason code, and field-specific allowlisted evidence metadata. Target paths are persisted only as SHA-256 digests; capabilities, access states, transport states, and detail codes use owned closed schemas. Arbitrary evidence keys/values are not valid contract data. +The helper writes an append-only, owner-only local journal with a monotonic sequence, previous-record digest, and record digest. `record_sha256` is SHA-256 over the exact UTF-8 RFC 8785/JCS serialization of the versioned `evaos.mac_access.audit_event.v1` payload with `record_sha256` excluded. Every returned audit record is canonicalized and rehashed before its chain, cursor, or receipt fields are trusted. The next payload's `previous_record_sha256` must equal that digest. `audit-chain-golden.json` freezes a causal command-decision/result pair, canonical bytes, and both digests; edit/delete/reorder/previous-digest and rehashed-but-uncorrelated checks are required runtime tests. Command decisions require binding, command, and request digests and use allowed/denied outcomes; command results cite the exact decision audit ID and use executed/failed/stopped outcomes. Action responses carry the complete verified decision record and, for attempted actions, its later verified causal result record; bare caller-chosen audit IDs are insufficient. A paged result may use an off-page causal decision only when that complete verified record is exactly the digest-bound page anchor; otherwise the requested page must start early enough to include the decision in its verified event chain. Non-command events cannot carry command correlation fields. The actor is a closed non-PII pair derived by the helper: console user, authenticated Workbench main process, broker-selected OpenClaw/Hermes runtime, or one of the owned helper subsystems. The payload otherwise records access mode, outcome, a closed reason code, and field-specific allowlisted evidence metadata. Target paths are persisted only as SHA-256 digests; capabilities, access states, transport states, and detail codes use owned closed schemas. Arbitrary evidence keys/values are not valid contract data. The hash chain is committed outside the journal. The helper owns a separately protected Keychain anchor containing a stable journal ID plus committed sequence/audit ID/record digest and an optional complete pending next-record tuple, using a helper-only, non-synchronizing, ThisDeviceOnly item distinct from connector credentials. To append, the helper computes the next record, compare-and-swaps the anchor from committed-only to the exact pending tuple, appends and durably synchronizes that record, then compare-and-swaps pending to committed. Decision actuation is allowed only after the final commit succeeds. On startup, a pending tuple with no matching durable record is cleared only after the committed prefix verifies; a matching pending record is finalized without actuation. At startup and before every actuation, the helper verifies the stable journal ID and prefix through the exact committed sequence/digest. A shorter valid chain, a same-user whole-journal replacement with recomputed internal hashes, an anchor rollback/fork, or any unexplained suffix marks audit unhealthy and forces effective `Off`. Anchor read, compare-and-swap, or durability uncertainty denies actuation. Existing unanchored Workbench journals can migrate only while effective access is `Off`, after full validation and a redacted migration audit; otherwise they are quarantined. From 406343af6247748746b6967c2dff08453eb32d8e Mon Sep 17 00:00:00 2001 From: Eva Date: Wed, 15 Jul 2026 20:14:42 +0700 Subject: [PATCH 09/14] fix(mac-access): bind security receipts --- docs/evaos/mac-access/architecture.md | 2 +- docs/evaos/mac-access/threat-model.md | 1 + .../v1/fixtures/host/host-response.json | 15 +- .../mac-connector-core/contracts/v1/index.ts | 94 ++++++++-- tests/contract/macAccessContracts.test.ts | 170 ++++++++++++++++++ tests/contract/macAccessCryptography.test.ts | 7 + 6 files changed, 270 insertions(+), 19 deletions(-) diff --git a/docs/evaos/mac-access/architecture.md b/docs/evaos/mac-access/architecture.md index 1616ec7cee..1302a6fad2 100644 --- a/docs/evaos/mac-access/architecture.md +++ b/docs/evaos/mac-access/architecture.md @@ -263,7 +263,7 @@ The existing `helper_ipc.py` same-UID plus file-token check is retained only as Configured mode is user intent. Effective mode is the helper's current authority after binding, lifecycle, TCC, transport, audit, and safety constraints. -Every transport state other than `connected` forces effective mode to `Off`. A helper runtime-instance change is valid if and only if it is recorded as a `restart`; restart and resume preserve configured user intent, clear prior Full Access confirmation, and require reconfirmation before Full Access can become effective again. Successful access-mode host responses echo the request-bound target and prove that the same configured mode was committed; clients validate the response together with the exact request identity and target. Host lifecycle requests carry only the fixed operation and expected policy epoch: the helper derives lifecycle reason codes from the accepted operation and current state, never from caller-supplied text. +Every transport state other than `connected` forces effective mode to `Off`. A helper runtime-instance change is valid if and only if it is recorded as a `restart`; restart and resume preserve configured user intent, clear prior Full Access confirmation, and require reconfirmation before Full Access can become effective again. Successful pair and unpair receipts advance the request-bound policy epoch exactly once. A successful connect receipt carries the complete selected binding and matches every field to the request, while access-mode responses echo the request-bound target and prove that the same configured mode was committed. Clients validate each response together with the exact request identity and authority. Standalone action receipts remain internally bound to their decision/result audit command, request digest, selected binding, causation, and ordering even before exchange-level broker-envelope validation. Host lifecycle requests carry only the fixed operation and expected policy epoch: the helper derives lifecycle reason codes from the accepted operation and current state, never from caller-supplied text. | From | Event | To | Required effect | | ----------------------- | ---------------------------------------------------------------- | ---------------------------------------------------- | ---------------------------------------------------------------------------------------------------------- | diff --git a/docs/evaos/mac-access/threat-model.md b/docs/evaos/mac-access/threat-model.md index 8747fbe56b..eef3d9aae5 100644 --- a/docs/evaos/mac-access/threat-model.md +++ b/docs/evaos/mac-access/threat-model.md @@ -146,6 +146,7 @@ These risks do not justify weaker local identity, direct networking, silent fall ## Required adversarial evidence before downstream completion - Every negative fixture under `packages/mac-connector-core/contracts/v1/fixtures/invalid` is exercised at its declared schema or runtime gate. +- Rollback status rejects any RFC 8785 payload/digest mismatch before trusting authorization fields; pairing receipts reject unchanged or skipped policy epochs; connect receipts reject a missing or field-mismatched selected binding; standalone action receipts reject audit correlation drift; and audit cursors compare semantic fields rather than serialization order. - Signed-client impersonation and helper replacement matrix passes on supported macOS versions. - Binding/signature/digest/replay/expiry/key-rotation matrix passes against source-only relay canary before deployment. - Crash, update, downgrade, uninstall, offline, audit-failure, stop/revoke/kill races remain fail closed. diff --git a/packages/mac-connector-core/contracts/v1/fixtures/host/host-response.json b/packages/mac-connector-core/contracts/v1/fixtures/host/host-response.json index 8f7ec58685..a24872dafa 100644 --- a/packages/mac-connector-core/contracts/v1/fixtures/host/host-response.json +++ b/packages/mac-connector-core/contracts/v1/fixtures/host/host-response.json @@ -12,7 +12,20 @@ "effective_mode": "ask_every_time", "requested_target_mode": "ask_every_time", "pairing_state": "paired", - "transport_state": "connected" + "transport_state": "connected", + "selected_binding": { + "customer_id": "customer-01", + "customer_vm_id": "vm-01", + "device_id": "mac-01", + "grant_id": "grant-01", + "runtime": "openclaw", + "binding_id": "binding-01", + "binding_version": "v3", + "grant_expires_at": "2026-07-15T09:00:00Z", + "connector_installation_id": "install-01", + "connector_key_id": "mac-key-01", + "binding_fingerprint_sha256": "1111111111111111111111111111111111111111111111111111111111111111" + } }, "error": null } diff --git a/packages/mac-connector-core/contracts/v1/index.ts b/packages/mac-connector-core/contracts/v1/index.ts index c91511c837..d74e3eba4b 100644 --- a/packages/mac-connector-core/contracts/v1/index.ts +++ b/packages/mac-connector-core/contracts/v1/index.ts @@ -361,21 +361,41 @@ export const rollbackAuthorizationPayloadSchema = z } }); +const signedRollbackAuthorizationShape = { + schema_version: z.literal('evaos.mac_access.signed_rollback_authorization.v1'), + canonicalization: z.literal('RFC8785-JCS'), + payload: rollbackAuthorizationPayloadSchema, + payload_sha256: sha256, + broker_key_id: identifier, + signature_base64url: base64Url, +}; + +function requireRollbackPayloadDigest( + authorization: { payload: unknown; payload_sha256: string }, + context: z.RefinementCtx +): void { + if (canonicalJsonSha256(authorization.payload) !== authorization.payload_sha256) { + context.addIssue({ + code: z.ZodIssueCode.custom, + message: 'rollback authorization payload digest must match its RFC 8785 canonical payload', + path: ['payload_sha256'], + }); + } +} + export const signedRollbackAuthorizationSchema = z + .object(signedRollbackAuthorizationShape) + .strict() + .superRefine(requireRollbackPayloadDigest); + +export const rollbackAuthorizationGoldenSchema = z .object({ - schema_version: z.literal('evaos.mac_access.signed_rollback_authorization.v1'), - canonicalization: z.literal('RFC8785-JCS'), - payload: rollbackAuthorizationPayloadSchema, - payload_sha256: sha256, - broker_key_id: identifier, - signature_base64url: base64Url, + ...signedRollbackAuthorizationShape, + canonical_payload_utf8: z.string().min(1).max(65_536), + public_key_spki_base64url: base64Url, }) - .strict(); - -export const rollbackAuthorizationGoldenSchema = signedRollbackAuthorizationSchema.extend({ - canonical_payload_utf8: z.string().min(1).max(65_536), - public_key_spki_base64url: base64Url, -}); + .strict() + .superRefine(requireRollbackPayloadDigest); export const executionContextClaimsSchema = z .object({ @@ -1724,6 +1744,7 @@ const lifecycleResultSchema = z requested_target_mode: z.enum(['off', 'ask_every_time', 'full_access']).nullable(), pairing_state: z.enum(['unpaired', 'paired', 'revoked']), transport_state: transportStateSchema, + selected_binding: selectedBindingSchema.nullable(), }) .strict() .superRefine((result, context) => { @@ -1749,6 +1770,20 @@ const lifecycleResultSchema = z path: ['transport_state'], }); } + if (result.transport_state === 'connected' && result.selected_binding === null) { + context.addIssue({ + code: z.ZodIssueCode.custom, + message: 'connected lifecycle transport requires the complete selected binding', + path: ['selected_binding'], + }); + } + if (result.pairing_state !== 'paired' && result.selected_binding !== null) { + context.addIssue({ + code: z.ZodIssueCode.custom, + message: 'unpaired or revoked lifecycle state must clear the selected binding', + path: ['selected_binding'], + }); + } if (result.transport_state === 'revoked' && result.pairing_state !== 'revoked') { context.addIssue({ code: z.ZodIssueCode.custom, @@ -1785,7 +1820,9 @@ const coreHostResultSchema = z.union([ if ( result.decision_audit_id !== result.decision_audit.audit_id || result.decision_audit.event_type !== 'command_decision' || - result.decision_audit.outcome !== (denied ? 'denied' : 'allowed') + result.decision_audit.outcome !== (denied ? 'denied' : 'allowed') || + result.decision_audit.command_id !== result.command_id || + result.decision_audit.request_digest_sha256 !== result.request_digest_sha256 ) { context.addIssue({ code: z.ZodIssueCode.custom, @@ -1809,7 +1846,10 @@ const coreHostResultSchema = z.union([ result.result_audit.event_type !== 'command_result' || result.result_audit.outcome !== result.outcome || result.result_audit.causation_audit_id !== result.decision_audit.audit_id || - result.result_audit.sequence <= result.decision_audit.sequence) + result.result_audit.sequence <= result.decision_audit.sequence || + result.result_audit.command_id !== result.command_id || + result.result_audit.request_digest_sha256 !== result.request_digest_sha256 || + result.result_audit.binding_fingerprint_sha256 !== result.decision_audit.binding_fingerprint_sha256) ) { context.addIssue({ code: z.ZodIssueCode.custom, @@ -2024,10 +2064,12 @@ export const coreHostExchangeSchema = z }); } if ( - ['set_access_mode', 'pause', 'resume', 'stop', 'revoke', 'activate_kill_switch'].includes(request.operation) && + ['pair', 'unpair', 'set_access_mode', 'pause', 'resume', 'stop', 'revoke', 'activate_kill_switch'].includes( + request.operation + ) && typeof request.expected_policy_epoch === 'number' && response.ok && - response.result?.kind === 'lifecycle' && + (response.result?.kind === 'lifecycle' || response.result?.kind === 'pairing') && response.policy_epoch !== request.expected_policy_epoch + 1 ) { context.addIssue({ @@ -2036,6 +2078,20 @@ export const coreHostExchangeSchema = z path: ['response', 'policy_epoch'], }); } + if ( + request.operation === 'connect' && + response.ok && + response.result?.kind === 'lifecycle' && + (response.policy_epoch !== request.expected_policy_epoch || + response.result.selected_binding === null || + !selectedBindingsEqual(response.result.selected_binding, request.binding)) + ) { + context.addIssue({ + code: z.ZodIssueCode.custom, + message: 'connect response must bind the exact expected policy epoch and complete selected binding', + path: ['response', 'result', 'selected_binding'], + }); + } if ( request.operation === 'dispatch_action' && response.ok && @@ -2065,7 +2121,11 @@ export const coreHostExchangeSchema = z request.operation === 'audit_summary' && response.ok && response.result?.kind === 'audit_summary' && - JSON.stringify(response.result.page_anchor) !== JSON.stringify(request.after_cursor) + (response.result.page_anchor === null + ? request.after_cursor !== null + : request.after_cursor === null || + response.result.page_anchor.sequence !== request.after_cursor.sequence || + response.result.page_anchor.record_sha256 !== request.after_cursor.record_sha256) ) { context.addIssue({ code: z.ZodIssueCode.custom, diff --git a/tests/contract/macAccessContracts.test.ts b/tests/contract/macAccessContracts.test.ts index 98d3060793..02a3c5f451 100644 --- a/tests/contract/macAccessContracts.test.ts +++ b/tests/contract/macAccessContracts.test.ts @@ -48,6 +48,14 @@ function cloneJson(value: T): T { return JSON.parse(JSON.stringify(value)) as T; } +function rehashAuditEvent & { record_sha256: string }>(event: T): T { + const { record_sha256: _recordSha256, ...payload } = event; + return { + ...event, + record_sha256: createHash('sha256').update(canonicalize(payload)!).digest('hex'), + }; +} + type LifecycleResponseFixture = { schema_version: string; request_id: string; @@ -63,6 +71,7 @@ type LifecycleResponseFixture = { requested_target_mode: 'off' | 'ask_every_time' | 'full_access' | null; pairing_state: 'unpaired' | 'paired' | 'revoked'; transport_state: 'disconnected' | 'connecting' | 'connected' | 'revoked' | 'blocked'; + selected_binding: Record | null; }; error: { code: string; audit_id: string | null } | null; }; @@ -517,6 +526,7 @@ describe('evaOS Mac Access v1 contracts', () => { it('rejects successful host responses whose operation outcome did not take effect', () => { const status = cloneJson(readJson(path.join(validRoot, 'state/local-status.json'))); + const envelope = brokerControlEnvelopeSchema.parse(readJson(path.join(validRoot, 'authority/broker-control.json'))); const identity: Record = { schema_version: 'evaos.mac_connector_core.host_response.v1', request_id: 'host-outcome-check', @@ -566,6 +576,7 @@ describe('evaOS Mac Access v1 contracts', () => { requested_target_mode: null, pairing_state: 'paired', transport_state: 'disconnected', + selected_binding: envelope.binding, }, expectedPath: 'result/transport_state', }, @@ -579,6 +590,7 @@ describe('evaOS Mac Access v1 contracts', () => { requested_target_mode: null, pairing_state: 'paired', transport_state: 'connected', + selected_binding: envelope.binding, }, expectedPath: 'result/transport_state', }, @@ -746,6 +758,7 @@ describe('evaOS Mac Access v1 contracts', () => { }); it('binds policy-changing lifecycle receipts to one exact epoch advance', () => { + const envelope = brokerControlEnvelopeSchema.parse(readJson(path.join(validRoot, 'authority/broker-control.json'))); const cases = [ { operation: 'pause', configured: 'ask_every_time', effective: 'off', pairing: 'paired', transport: 'connected' }, { @@ -796,6 +809,7 @@ describe('evaOS Mac Access v1 contracts', () => { requested_target_mode: null, pairing_state: testCase.pairing, transport_state: testCase.transport, + selected_binding: testCase.pairing === 'paired' ? envelope.binding : null, }, error: null, }; @@ -805,6 +819,117 @@ describe('evaOS Mac Access v1 contracts', () => { } }); + it('binds pairing receipts and connect receipts to the exact request authority', () => { + const identity = { + schema_version: 'evaos.mac_connector_core.host_request.v1', + host_session_id: 'host-session-01', + expected_policy_epoch: 7, + } as const; + const pairRequest = { + ...identity, + request_id: 'host-pair-authority', + sequence: 1, + operation: 'pair', + pairing_code: 'ABC123', + local_installation_nonce: 'MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY', + }; + const pairResponse = { + schema_version: 'evaos.mac_connector_core.host_response.v1', + request_id: pairRequest.request_id, + host_session_id: pairRequest.host_session_id, + sequence: pairRequest.sequence, + operation: pairRequest.operation, + ok: true, + policy_epoch: 8, + result: { + kind: 'pairing', + pairing_state: 'paired', + device_id: 'mac-01', + binding_fingerprint_sha256: '1'.repeat(64), + }, + error: null as { code: string; audit_id: string | null } | null, + }; + expect(coreHostExchangeSchema.safeParse({ request: pairRequest, response: pairResponse }).success).toBe(true); + + const stalePairing = cloneJson(pairResponse); + stalePairing.policy_epoch = 7; + expect(coreHostExchangeSchema.safeParse({ request: pairRequest, response: stalePairing }).success).toBe(false); + const skippedPairingEpoch = cloneJson(pairResponse); + skippedPairingEpoch.policy_epoch = 9; + expect(coreHostExchangeSchema.safeParse({ request: pairRequest, response: skippedPairingEpoch }).success).toBe( + false + ); + + const unpairRequest = { + schema_version: pairRequest.schema_version, + request_id: 'host-unpair-authority', + host_session_id: pairRequest.host_session_id, + sequence: 2, + operation: 'unpair', + expected_policy_epoch: 8, + }; + const unpairResponse = { + ...cloneJson(pairResponse), + request_id: unpairRequest.request_id, + sequence: unpairRequest.sequence, + operation: unpairRequest.operation, + policy_epoch: 9, + result: { + kind: 'pairing', + pairing_state: 'unpaired', + device_id: null as string | null, + binding_fingerprint_sha256: null as string | null, + }, + }; + expect(coreHostExchangeSchema.safeParse({ request: unpairRequest, response: unpairResponse }).success).toBe(true); + unpairResponse.policy_epoch = 8; + expect(coreHostExchangeSchema.safeParse({ request: unpairRequest, response: unpairResponse }).success).toBe(false); + + const envelope = brokerControlEnvelopeSchema.parse(readJson(path.join(validRoot, 'authority/broker-control.json'))); + const connectRequest = { + ...identity, + request_id: 'host-connect-authority', + sequence: 2, + operation: 'connect', + binding: envelope.binding, + }; + const connectResponse = readLifecycleResponseFixture(); + connectResponse.request_id = connectRequest.request_id; + connectResponse.sequence = connectRequest.sequence; + connectResponse.operation = connectRequest.operation; + connectResponse.policy_epoch = connectRequest.expected_policy_epoch; + connectResponse.result.requested_target_mode = null; + expect(coreHostExchangeSchema.safeParse({ request: connectRequest, response: connectResponse }).success).toBe(true); + + const bindingMismatches = [ + ['/result/selected_binding/customer_id', 'customer-02'], + ['/result/selected_binding/customer_vm_id', 'vm-02'], + ['/result/selected_binding/device_id', 'mac-02'], + ['/result/selected_binding/grant_id', 'grant-02'], + ['/result/selected_binding/runtime', 'hermes'], + ['/result/selected_binding/binding_id', 'binding-02'], + ['/result/selected_binding/binding_version', 'v4'], + ['/result/selected_binding/grant_expires_at', '2026-07-15T08:59:59Z'], + ['/result/selected_binding/connector_installation_id', 'install-02'], + ['/result/selected_binding/connector_key_id', 'mac-key-02'], + ['/result/selected_binding/binding_fingerprint_sha256', '2'.repeat(64)], + ] as const; + for (const [pointer, value] of bindingMismatches) { + const mismatchedConnect = cloneJson(connectResponse); + applyMutation(mismatchedConnect, { operation: 'set', pointer, value }); + expect( + coreHostExchangeSchema.safeParse({ request: connectRequest, response: mismatchedConnect }).success, + pointer + ).toBe(false); + } + + const missingConnectBinding = cloneJson(connectResponse); + missingConnectBinding.result.selected_binding = null; + expect(coreHostExchangeSchema.safeParse({ request: connectRequest, response: missingConnectBinding }).success).toBe( + false + ); + }); + it('rejects runtime identity drift outside restart and preserves configured intent', () => { const transition = cloneJson( accessTransitionSchema.parse(readJson(path.join(validRoot, 'state/access-transition-stop.json'))) @@ -1048,6 +1173,36 @@ describe('evaOS Mac Access v1 contracts', () => { continuation.result.causal_decisions = [events[0]]; expect(coreHostResponseSchema.safeParse(continuation).success).toBe(true); + const reorderedCursorRequest = { + schema_version: 'evaos.mac_connector_core.host_request.v1', + request_id: continuation.request_id, + host_session_id: continuation.host_session_id, + sequence: continuation.sequence, + operation: 'audit_summary', + expected_policy_epoch: continuation.policy_epoch, + after_cursor: { + record_sha256: continuation.result.page_anchor.record_sha256, + sequence: continuation.result.page_anchor.sequence, + }, + limit: 25, + }; + expect(coreHostExchangeSchema.safeParse({ request: reorderedCursorRequest, response: continuation }).success).toBe( + true + ); + for (const afterCursor of [ + null, + { + sequence: continuation.result.page_anchor.sequence + 1, + record_sha256: continuation.result.page_anchor.record_sha256, + }, + { sequence: continuation.result.page_anchor.sequence, record_sha256: 'f'.repeat(64) }, + ]) { + const mismatchedCursorRequest = { ...reorderedCursorRequest, after_cursor: afterCursor }; + expect( + coreHostExchangeSchema.safeParse({ request: mismatchedCursorRequest, response: continuation }).success + ).toBe(false); + } + const wrongDecision = cloneJson(continuation); wrongDecision.result.causal_decisions[0].command_id = 'unrelated-command'; expect(coreHostResponseSchema.safeParse(wrongDecision).success).toBe(false); @@ -1107,6 +1262,21 @@ describe('evaOS Mac Access v1 contracts', () => { }; expect(coreHostExchangeSchema.safeParse({ request, response }).success).toBe(true); + const standaloneAuditMismatch = cloneJson(response); + standaloneAuditMismatch.result.command_id = 'unrelated-command'; + expect(coreHostResponseSchema.safeParse(standaloneAuditMismatch).success).toBe(false); + + const standaloneDigestMismatch = cloneJson(response); + standaloneDigestMismatch.result.request_digest_sha256 = '2'.repeat(64); + expect(coreHostResponseSchema.safeParse(standaloneDigestMismatch).success).toBe(false); + + const standaloneResultBindingMismatch = cloneJson(response); + standaloneResultBindingMismatch.result.result_audit = rehashAuditEvent({ + ...standaloneResultBindingMismatch.result.result_audit!, + binding_fingerprint_sha256: '2'.repeat(64), + }); + expect(coreHostResponseSchema.safeParse(standaloneResultBindingMismatch).success).toBe(false); + const wrongCommand = cloneJson(response); wrongCommand.result.command_id = 'unrelated-command'; expect(coreHostExchangeSchema.safeParse({ request, response: wrongCommand }).success).toBe(false); diff --git a/tests/contract/macAccessCryptography.test.ts b/tests/contract/macAccessCryptography.test.ts index 1aeb967164..cdb146cf5b 100644 --- a/tests/contract/macAccessCryptography.test.ts +++ b/tests/contract/macAccessCryptography.test.ts @@ -182,6 +182,13 @@ describe('evaOS Mac Access canonical cryptographic contracts', () => { } = vector; const localStatus = localStatusSchema.parse(readJson(path.join(validRoot, 'state/local-status.json'))); expect(localStatus.relay_authorization.rollback_authorization).toEqual(signedVector); + + const tamperedStatus = structuredClone(localStatus); + if (tamperedStatus.relay_authorization.rollback_authorization === null) { + throw new Error('Expected rollback authorization fixture'); + } + tamperedStatus.relay_authorization.rollback_authorization.payload.expires_at = '2026-07-15T10:30:00Z'; + expect(localStatusSchema.safeParse(tamperedStatus).success).toBe(false); }); it('rejects rollback payload, digest, signature, and key substitution', () => { From 6b089a7c22f49d2ba2385467e3b83deec0c50f1b Mon Sep 17 00:00:00 2001 From: Eva Date: Wed, 15 Jul 2026 20:24:07 +0700 Subject: [PATCH 10/14] fix(mac-access): harden golden contracts --- .../mac-connector-core/contracts/v1/index.ts | 66 ++++++++++++++++--- tests/contract/macAccessCryptography.test.ts | 12 ++++ 2 files changed, 70 insertions(+), 8 deletions(-) diff --git a/packages/mac-connector-core/contracts/v1/index.ts b/packages/mac-connector-core/contracts/v1/index.ts index d74e3eba4b..e570976ff1 100644 --- a/packages/mac-connector-core/contracts/v1/index.ts +++ b/packages/mac-connector-core/contracts/v1/index.ts @@ -189,6 +189,16 @@ export const MAC_ACCESS_IDENTITIES = { auditAnchorService: 'com.evaos.mac-access.audit-anchor', } as const; +function literalPrefixPattern(prefix: string): RegExp { + const escaped = prefix.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + return new RegExp(`^${escaped}\\.epoch-[1-9][0-9]*$`); +} + +const productionKeychainAccessGroupPattern = literalPrefixPattern( + MAC_ACCESS_IDENTITIES.productionKeychainAccessGroupSuffix +); +const auditAnchorAccessGroupPattern = literalPrefixPattern(MAC_ACCESS_IDENTITIES.auditAnchorAccessGroupSuffix); + export const selectedBindingSchema = z .object({ customer_id: identifier, @@ -239,7 +249,7 @@ export const buildIdentitySchema = z export const keychainCustodySchema = z .object({ custodian_signing_identifier: z.literal(MAC_ACCESS_IDENTITIES.helperServiceId), - access_group_suffix: z.string().regex(/^com\.evaos\.mac-access\.credentials\.epoch-[1-9][0-9]*$/), + access_group_suffix: z.string().regex(productionKeychainAccessGroupPattern), credential_security_epoch: safePositiveCounter, service: z.literal(MAC_ACCESS_IDENTITIES.connectorCredentialService), accessibility: z.literal('kSecAttrAccessibleWhenUnlockedThisDeviceOnly'), @@ -261,7 +271,7 @@ export const auditAnchorSchema = z .object({ schema_version: z.literal('evaos.mac_access.audit_anchor.v1'), custodian_signing_identifier: z.literal(MAC_ACCESS_IDENTITIES.helperServiceId), - access_group_suffix: z.string().regex(/^com\.evaos\.mac-access\.audit-anchor\.epoch-[1-9][0-9]*$/), + access_group_suffix: z.string().regex(auditAnchorAccessGroupPattern), security_epoch: safePositiveCounter, service: z.literal(MAC_ACCESS_IDENTITIES.auditAnchorService), accessibility: z.literal('kSecAttrAccessibleWhenUnlockedThisDeviceOnly'), @@ -371,10 +381,14 @@ const signedRollbackAuthorizationShape = { }; function requireRollbackPayloadDigest( - authorization: { payload: unknown; payload_sha256: string }, + authorization: { payload?: unknown; payload_sha256?: string }, context: z.RefinementCtx ): void { - if (canonicalJsonSha256(authorization.payload) !== authorization.payload_sha256) { + if ( + authorization.payload === undefined || + authorization.payload_sha256 === undefined || + canonicalJsonSha256(authorization.payload) !== authorization.payload_sha256 + ) { context.addIssue({ code: z.ZodIssueCode.custom, message: 'rollback authorization payload digest must match its RFC 8785 canonical payload', @@ -395,7 +409,16 @@ export const rollbackAuthorizationGoldenSchema = z public_key_spki_base64url: base64Url, }) .strict() - .superRefine(requireRollbackPayloadDigest); + .superRefine((authorization, context) => { + requireRollbackPayloadDigest(authorization, context); + if (canonicalJson(authorization.payload) !== authorization.canonical_payload_utf8) { + context.addIssue({ + code: z.ZodIssueCode.custom, + message: 'rollback authorization canonical payload bytes must match its RFC 8785 payload', + path: ['canonical_payload_utf8'], + }); + } + }); export const executionContextClaimsSchema = z .object({ @@ -1186,7 +1209,24 @@ export const commandAuthorityGoldenSchema = z public_key_spki_base64url: base64Url, signature_base64url: base64Url, }) - .strict(); + .strict() + .superRefine((vector, context) => { + const canonicalPayload = canonicalJson(vector.payload); + if (canonicalPayload !== vector.canonical_payload_utf8) { + context.addIssue({ + code: z.ZodIssueCode.custom, + message: 'command authority canonical payload bytes must match its RFC 8785 payload', + path: ['canonical_payload_utf8'], + }); + } + if (canonicalJsonSha256(vector.payload) !== vector.payload_sha256) { + context.addIssue({ + code: z.ZodIssueCode.custom, + message: 'command authority payload digest must match its RFC 8785 canonical payload', + path: ['payload_sha256'], + }); + } + }); export const brokerControlEnvelopeSchema = z .object({ @@ -1326,8 +1366,18 @@ const coreHostRequestIdentity = { sequence: safePositiveCounter, }; -const coreHostLifecycleRequest = ( - operation: 'unpair' | 'disconnect' | 'pause' | 'resume' | 'stop' | 'revoke' | 'activate_kill_switch' | 'shutdown' +const coreHostLifecycleRequest = < + Operation extends + | 'unpair' + | 'disconnect' + | 'pause' + | 'resume' + | 'stop' + | 'revoke' + | 'activate_kill_switch' + | 'shutdown', +>( + operation: Operation ) => z .object({ diff --git a/tests/contract/macAccessCryptography.test.ts b/tests/contract/macAccessCryptography.test.ts index cdb146cf5b..ef36954480 100644 --- a/tests/contract/macAccessCryptography.test.ts +++ b/tests/contract/macAccessCryptography.test.ts @@ -125,6 +125,14 @@ describe('evaOS Mac Access canonical cryptographic contracts', () => { expect(verify(null, Buffer.from(canonical), publicKey, Buffer.from(vector.signature_base64url, 'base64url'))).toBe( true ); + + const tamperedPayload = structuredClone(vector); + tamperedPayload.payload.expires_at = '2026-07-15T08:01:44Z'; + expect(commandAuthorityGoldenSchema.safeParse(tamperedPayload).success).toBe(false); + + const tamperedCanonicalBytes = structuredClone(vector); + tamperedCanonicalBytes.canonical_payload_utf8 += ' '; + expect(commandAuthorityGoldenSchema.safeParse(tamperedCanonicalBytes).success).toBe(false); }); it('verifies the two-record audit-chain golden vector', () => { @@ -183,6 +191,10 @@ describe('evaOS Mac Access canonical cryptographic contracts', () => { const localStatus = localStatusSchema.parse(readJson(path.join(validRoot, 'state/local-status.json'))); expect(localStatus.relay_authorization.rollback_authorization).toEqual(signedVector); + const tamperedCanonicalBytes = structuredClone(vector); + tamperedCanonicalBytes.canonical_payload_utf8 += ' '; + expect(rollbackAuthorizationGoldenSchema.safeParse(tamperedCanonicalBytes).success).toBe(false); + const tamperedStatus = structuredClone(localStatus); if (tamperedStatus.relay_authorization.rollback_authorization === null) { throw new Error('Expected rollback authorization fixture'); From 493f5f0dbf62ad9eef054fc892299ea179550223 Mon Sep 17 00:00:00 2001 From: Eva Date: Wed, 15 Jul 2026 20:29:40 +0700 Subject: [PATCH 11/14] test(mac-access): clarify fixture failures --- tests/contract/macAccessContracts.test.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/tests/contract/macAccessContracts.test.ts b/tests/contract/macAccessContracts.test.ts index 02a3c5f451..143b66eb7f 100644 --- a/tests/contract/macAccessContracts.test.ts +++ b/tests/contract/macAccessContracts.test.ts @@ -29,6 +29,12 @@ const contractRoot = path.resolve( const validRoot = path.join(contractRoot, 'fixtures/valid'); const invalidRoot = path.join(contractRoot, 'fixtures/invalid'); +for (const requiredRoot of [contractRoot, validRoot, invalidRoot]) { + if (!fs.existsSync(requiredRoot)) { + throw new Error(`Mac Access contract fixture directory is missing: ${requiredRoot}`); + } +} + const schemas = { access_state: accessStateSchema, access_transition: accessTransitionSchema, @@ -41,6 +47,9 @@ const schemas = { } as const; function readJson(filePath: string): unknown { + if (!fs.existsSync(filePath)) { + throw new Error(`Mac Access contract fixture is missing: ${filePath}`); + } return JSON.parse(fs.readFileSync(filePath, 'utf8')); } @@ -263,6 +272,11 @@ const expectedIssuePathByError: Record = { }; describe('evaOS Mac Access v1 contracts', () => { + it('reports the exact missing fixture path before attempting to parse it', () => { + const missingFixture = path.join(contractRoot, 'fixtures/valid/missing-contract-fixture.json'); + expect(() => readJson(missingFixture)).toThrow(`Mac Access contract fixture is missing: ${missingFixture}`); + }); + const validFixtures = [ ['access_state', 'state/access-state.json'], ['access_state', 'state/full-access-state.json'], From 699df00eda51fae3cd2a4fe946cd79458cc8ab9e Mon Sep 17 00:00:00 2001 From: Eva Date: Wed, 15 Jul 2026 21:05:10 +0700 Subject: [PATCH 12/14] docs(mac-access): align native verifier layout --- docs/evaos/mac-access/migration-map.md | 138 ++++++++++++------------- 1 file changed, 69 insertions(+), 69 deletions(-) diff --git a/docs/evaos/mac-access/migration-map.md b/docs/evaos/mac-access/migration-map.md index 1893b42302..65c5e9fe13 100644 --- a/docs/evaos/mac-access/migration-map.md +++ b/docs/evaos/mac-access/migration-map.md @@ -76,75 +76,75 @@ Every request binds `host_session_id`, monotonic safe-integer `sequence`, `reque All target owners below remain in `packages/desktop` as clients or presentation. None remains a connector authority after A5. -| Current Workbench file | Current responsibility | Target owner | Phase | Required change and exit check | -| ---------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------- | ----- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `src/common/evaos/bridgeTypes.ts` | Native status/action DTOs, including current control-mode shapes. | Workbench generated Mac Access client DTOs | A5 | Add v1 status/actions including `off`; delete raw connector URL/token/path fields from client-visible types. | -| `src/common/evaos/nativeCompanionBoundary.ts` | Static shell/native trust matrix and canaries. | Workbench trust/presentation contract | A5 | Point native owner to Mac Access helper; canaries describe client status, not Python CLI authority. | -| `src/process/evaosNativeCompanionBoundary.ts` | Main-process local-action boundary enforcement. | Workbench fail-closed client boundary | A5 | Permit only fixed authenticated-client actions; no generic local CUA or lifecycle escape hatch. | -| `src/index.ts` | Main-process startup/teardown registration. | Workbench Mac Access client bootstrap | A5 | Register XPC client bridge; stop only Workbench client/session state, never an unknown Mac Access helper. | -| `src/process/bridge/evaosNativeCompanionBridge.ts` | Renderer-to-main status/diagnostic/action IPC. | Workbench renderer-safe Mac Access bridge | A5 | Call the authenticated client, preserve `BridgeResponse`, and return redacted status/audit IDs only. | -| `src/process/bridge/evaosRendererSecretGuard.ts` | Renderer secret-shape rejection. | Workbench renderer secret guard | A5 | Extend forbidden fields to Mac Access channel, binding, peer, Keychain, signature, and transport details. | -| `src/process/bridge/index.ts` | IPC bridge registration. | Workbench bridge registry | A5 | Register only the Mac Access client bridge once. | -| `src/process/resources/builtinMcp/evaosMacControlServer.ts` | Spawns bridge CLI and exposes Mac-control tools. | Workbench-to-Mac Access client adapter | A5 | Remove bridge binary/PATH resolution and direct process spawn; route allowed requests through helper client or selected broker path. | -| `src/process/services/evaosNativeCompanionStatus.ts` | Status, process spawn, local HTTP readiness, tailnet discovery, grants, enrollment, actions, TCC repair. | Workbench `MacAccessClientService` | A5 | Retain presentation/orchestration only; delete connector spawn/stop, URL/token, host discovery, Tailscale bootstrap, grant ownership, direct bridge CLI, and TCC authority. | -| `src/process/services/evaosBrokerSession.ts` | Completes connector enrollment and creates grants from connector URL/token/tailnet inputs. | Broker-selected Mac Access client adapter | A5 | Remove Workbench connector registration/grant authority and raw endpoint/token inputs; request only broker-side atomic prepare/commit operations. No post-cutover reverse handoff to Workbench exists. | -| `src/common/adapter/ipcBridge.ts` | Declares renderer/main native-companion IPC surface. | Workbench generated renderer-safe client IPC | A5 | Replace legacy action/status shapes with the redacted Mac Access client DTOs; no generic or secret-bearing fields. | -| `src/process/utils/runBackendMigrations.ts` | Executes backend migrations including native-companion seams. | Workbench coexistence migration coordinator | A5 | Run idempotent make-before-break client cutover marker; never migrate raw credentials in renderer/Node storage. | -| `src/renderer/evaos/useEvaosNativeCompanionStatus.ts` | Polls/requests status and actions. | Workbench renderer hook | A5 | Render redacted v1 status; no connector lifecycle assumptions. | -| `src/renderer/evaos/nativeCompanionViewModel.ts` | Converts native status into customer UI state. | Workbench presentation model | A5 | Map `Off`, `Ask Every Time`, `Full Access`, paused/revoked/blocked, and reconfirmation truthfully. | -| `src/renderer/pages/native-companion/index.tsx` | Mac & iPhone/native companion page. | Workbench Mac Access client UI | A5 | Present status/handoff controls; Mac Access app owns pairing/TCC/access confirmation. iPhone Mirroring is outside this sprint. | -| `src/renderer/pages/guid/hooks/useGuidAgentSelection.ts` | Consumes native-companion readiness during guided agent selection. | Workbench redacted readiness consumer | A5 | Consume client status only; no fallback connector start or readiness inference from legacy bridge state. | -| `src/renderer/pages/settings/AgentSettings/LocalAgents.tsx` | Consumes native-companion status in Local Agents settings. | Workbench redacted status consumer | A5 | Render Mac Access state/handoff only; no connector lifecycle, token, or TCC authority. | -| `src/process/resources/builtinMcp/constants.ts` | Names and recognizes the packaged Mac-control MCP server. | Workbench Mac Access MCP recognizer | A5 | Recognize only the authenticated Mac Access client adapter; remove legacy bridge-name and direct-process fallback. | -| `src/renderer/evaos/evaosNativeAgentAvailability.ts` | Maps native-companion state into agent/MCP availability. | Workbench redacted availability mapper | A5 | Only exact redacted Mac Access `ready` state may be usable or attach MCP; Off, repair, and reconfirmation states remain blocked. | -| `src/renderer/pages/guid/hooks/useGuidSend.ts` | Attaches native Mac-control MCP during guided sends. | Workbench guarded MCP attachment | A5 | Attach only from the exact redacted Mac Access ready state; no legacy readiness or connector-start fallback. | -| `src/renderer/pages/guid/GuidPage.tsx` | Directly presents native availability in the guided workflow. | Workbench redacted status presentation | A5 | Consume the shared redacted availability result only; never infer transport, credential, TCC, or lifecycle authority. | -| `src/renderer/pages/guid/components/AgentPillBar.tsx` | Directly presents selected-agent native availability. | Workbench redacted status presentation | A5 | Display status only; do not attach MCP or trigger connector lifecycle outside the guarded send path. | -| `src/renderer/pages/settings/AgentSettings/AgentCard.tsx` | Directly presents agent native availability. | Workbench redacted status presentation | A5 | Display status only; blocked/repair/reconfirm states cannot be promoted to usable. | -| `scripts/build-mcp-servers.js` | Packages the direct Mac-control MCP server adapter. | Workbench client-adapter packaging | A5 | Package only the Mac Access authenticated-client adapter; remove direct bridge binary/process invocation. | -| `scripts/build-with-builder.js` | Orchestrates Workbench resource preparation and product packaging. | Workbench build consuming connector-core | A1 | Resolve the canonical package and invoke deterministic preparation; A5 later removes authoritative runtime embedding. | -| `scripts/prepareEvaosDesktopBridgeResource.js` | Prepares and manifests the Workbench-owned Python bridge/helper runtime. | connector-core deterministic packaging adapter | A1 | Consume `packages/mac-connector-core` as the only source, emit the exact core digest, and fail if the generated Workbench resource can drift. | -| `resources/evaos-beta/bridge/SOURCE.json` | Records archived import provenance for the Workbench-vendored bridge source. | connector-core versioned provenance manifest | A1 | Migrate required history into the canonical core manifest; generated Workbench resources reference that manifest and never restore the archived repo as a dependency. | -| `resources/evaos-beta/bridge/agent-tools/SOURCE.json` | Records exact bundled OpenClaw/Hermes agent-tool source and immutable file digests. | VM/runtime consumer provenance manifest | A1 | Keep separate from connector-core source ownership; verify compatible schemas/digests and never copy agent tools into the Mac connector authority. | -| `resources/evaos-beta/bridge/agent-tools/openclaw-plugin/src/runtimeReceipt.ts` plus `bridge.ts`, `firewall.ts`, and `toolParameters.ts` | Verifies selected binding, signed context, receipt/public attestation, replay, expiry, and tool boundaries in the VM runtime. | VM/runtime Mac Access relay consumer | A3 | Preserve #708 parity and negative fixtures; consume #73 relay results without becoming local policy, TCC, audit, or connector authority. | -| `resources/evaos-beta/bridge/agent-tools/openclaw-plugin/src/types.d.ts` | Declares the OpenClaw consumer runtime/type boundary. | VM/runtime consumer type contract | A3 | Generate or verify against the owned plugin contract; declarations may describe relay receipts but cannot define connector-core/helper authority. | -| `resources/evaos-beta/bridge/agent-tools/openclaw-plugin/index.ts`, `openclaw.plugin.json`, `package*.json`, and `tsconfig.json` | Registers/packages/builds the bundled VM-side plugin consumer. | VM/runtime consumer build metadata | A3 | Keep a separately versioned consumer artifact tied to `agent-tools/SOURCE.json`; never become connector-core source or inherit local helper authority. | -| `resources/evaos-beta/bridge/agent-tools/openclaw-plugin/dist/**` | Generated VM-side plugin output. | generated VM/runtime consumer artifact | A3 | Rebuild only from the owned plugin source, verify SOURCE digests, and reject committed/generated drift; never import generated output into connector core. | -| `resources/evaos-beta/bridge/agent-tools/openclaw-plugin/scripts/qa-run-bridge.mjs` and `runtime-receipt-negative-proof.mjs` | Runs built-plugin bridge QA and deployed negative receipt classification. | VM/runtime consumer proof harness | A3/A7 | Preserve exact selected-binding, replay, expiry, signer, and no-connector-access negatives against #73; results are not local Mac artifact proof. | -| `resources/evaos-beta/bridge/agent-tools/openclaw-plugin/tests/pluginContract.test.mjs` and `runtimeReceipt.test.mjs` | Proves plugin registry, verifier, firewall, stream, replay, and receipt contracts. | VM/runtime consumer contract tests | A1/A3 | Run as parity at A1 and against #73 at A3; keep generated/source/provenance equality and all negative receipt cases. | -| `resources/evaos-beta/bridge/agent-tools/hermes-adapter/bin/evaos-desktop-bridge-command` | Hermes-side selected-binding and receipt verifier/adapter. | VM/runtime Hermes Mac Access relay consumer | A3 | Preserve exact signed-context and receipt behavior; no direct connector URL/token or private-network fallback. | -| `resources/evaos-beta/bridge/native/EvaOSEd25519Verify.swift` | Native public-key verifier used by Workbench receipt proof. | `packages/mac-connector-core/native/Verification/EvaOSEd25519Verify.swift` | A1 | Move once as the canonical native verifier; preserve byte/signature parity and make Workbench/Mac Access link this package target rather than copy it. | -| `scripts/evaosMacControlSignedProof.js` and `scripts/evaosScanMacControlProofs.js` | Builds and scans Workbench signed runtime-proof packets. | Workbench proof consumer | A1 | Preserve #708 regression proof and redaction; never treat Workbench proof scripts as Mac Access #705/#706 artifact or live-CUA proof. | -| `scripts/evaosBrokerLiveCanary.js` | Calls and classifies the Workbench runtime-receipt route during canary proof. | Workbench parity canary consumer | A1 | Preserve #708 exact candidate/binding/receipt checks; A6 converts to Mac Access client receipt consumption and never substitutes for #702/#706 proof. | -| `scripts/evaosInstalledAppProductProof.js` | Validates installed Workbench product and native receipt proof inputs. | Workbench installed-product parity verifier | A1/A6 | Preserve #708 Workbench proof until cutover, then verify client-only/no-embedded-authority behavior; it never proves the independent #705 artifact. | -| `scripts/evaosLiveCanaryEnvInventory.js` and `scripts/evaosValidateLiveCanaryProofRun.sh` | Inventories and validates required live-canary receipt/signer/proof fields. | Workbench canary proof validator | A1 | Preserve exact field/redaction validation and #709 complete-signer fail-closed behavior; no environment value becomes Mac Access source or authority. | -| `scripts/create-mock-release-artifacts.sh` | Builds committed-source Workbench bridge release fixtures with source/provenance manifests. | Workbench core-parity fixture builder | A1 | Consume the canonical connector-core manifest and generated resource only; never archive the legacy resource as source truth or build independent Mac Access release assets. | -| `scripts/evaosBetaReleaseGate.js` | Runs Workbench bridge source/app identity, enrollment, and packaged CLI release gates. | Workbench gate consuming connector-core proof | A1 | Verify the canonical core digest and preserve regression canaries; A5 later limits this gate to client compatibility and non-embedding. | -| `packages/desktop/electron-builder.yml` | Embeds bridge resources into the Workbench artifact. | Workbench packaging consuming connector-core | A1 | Package only generated core resources with the manifest digest; A5 removes the authoritative runtime after coexistence cutover. | -| `scripts/afterPack.js` | Verifies packaged Workbench bridge provenance and resource shape. | Workbench core-digest verifier | A1 | Prove the packaged resource equals the canonical core manifest; A5 later inverts this gate to reject an authoritative embedded connector. | -| `scripts/afterSign.js` | Signs/verifies the Workbench bridge/helper payload. | Workbench generated-core signing verifier | A1 | Preserve Workbench signing for the generated core during migration; A5 removes connector authority. It never signs or mutates independent Mac Access artifacts. | -| `.github/workflows/evaos-beta-rc-canary.yml` | Runs Workbench beta candidate bridge/enrollment proof. | Workbench core-parity canary | A1 | Prove the generated package uses the canonical core digest; A5 later converts to client/non-embedding proof. Never gate or publish Mac Access. | -| `.github/workflows/evaos-live-canary-proof.yml` | Runs Workbench live packaged bridge proof. | Workbench core-parity canary | A1 | Preserve packaged-core regressions against the canonical digest; A5 later proves client-only behavior. Never substitute for Mac Access #705/#706 proof. | -| `.github/workflows/_build-reusable.yml` | Builds/tests Workbench and provisions build-time Python on packaging runners. | Workbench generated-core build consumer | A1 | Connector proof must execute the generated packaged runtime, not runner/system Python; the setup runtime may perform build tooling only. Never build Mac Access release. | -| `.github/workflows/workbench-functional-smoke.yml` | Builds an unpacked Workbench app and exercises its pinned embedded bridge runtime. | Workbench generated-core functional smoke | A1 | Bind the generated resource to the exact canonical core digest and run bridge imports through its packaged Python; A5 converts this to authenticated-client smoke proof. | -| `.github/workflows/pr-checks.yml` | Exercises source-commit-bound Workbench release fixtures and gates. | Workbench core-parity CI | A1 | Pin fixtures to the exact candidate/core source commit; broad CI remains Workbench evidence and never proves Mac Access artifacts or live control. | -| `.github/workflows/release-distribute.yml` | Passes Workbench RC artifact proof into the Workbench distribution gate. | Workbench release proof consumer | A1 | Preserve exact artifact/provenance verification only for Workbench; do not publish, gate, or mutate independent Mac Access artifacts. | -| `tests/unit/evaos/evaosDesktopBridgeSafety.test.ts` | Proves Workbench bridge race, filesystem-mode, and audit-redaction behavior. | connector-core parity regression input | A1 | Re-run against the canonical extracted core, then A5 retain only client/non-embedding safety tests. It is not native helper or signed-artifact proof. | -| `tests/unit/evaos/evaosDesktopBridgeReceipt.test.ts` | Proves #708 receipt schemas/routes/signers plus #709 pre-mutation staging validation. | connector-core receipt parity test | A1 | Preserve every current receipt and incomplete-signer negative against the extracted core; A3 adds #73 relay proof without weakening the Workbench regression. | -| `tests/unit/evaos/evaosDesktopBridgeControlSafetyP0.test.ts` and `evaosQaCanaryLocalControl.test.ts` | Prove generation-linearized control safety and packaged QA behavior. | connector-core safety/canary parity tests | A1 | Preserve late #697 races plus #708 receipt behavior through the embedded core before native replacement. | -| `tests/unit/evaos/evaosLiveCanaryWorkflow.test.ts` | Proves Workbench canary workflow receipt/proof composition. | Workbench canary workflow parity test | A1/A6 | Preserve #708 workflow gates, then invert to authenticated Mac Access client/no-embedded-authority proof after cutover. | -| `tests/unit/evaos/evaosInstalledAppProductProof.test.ts` and `evaosLiveCanaryEnvInventory.test.ts` | Prove installed-product receipt inputs and live-canary inventory/redaction. | Workbench installed/canary proof tests | A1/A6 | Preserve exact #708/#709 negatives, then prove client-only consumption after cutover; never become #705 artifact or #706 live-CUA evidence. | -| `tests/unit/evaos/fixtures/signedMacControlAttestation.ts` | Provides deterministic signed receipt/attestation fixtures. | connector-core verification fixture | A1 | Move or derive from the canonical native verifier contract and keep Workbench tests as consumers; test signatures are not deployed signer proof. | -| `tests/unit/evaos/evaosBrokerLiveCanary.test.ts` | Binds Workbench live-canary proof to exact source/run and selected-binding evidence. | Workbench core-parity canary test | A1 | Preserve source/binding negatives until A5 client conversion; never substitute the Workbench canary for #702 transport or #706 VM-to-Mac CUA proof. | -| `tests/unit/bootstrap/afterPackBundledResources.test.ts` | Proves packaged Workbench bridge/helper/runtime inventory and provenance checks. | Workbench generated-core package test | A1 | Build fixtures from the canonical manifest/generated resource and require the packaged embedded runtime; never accept legacy source truth or system-Python substitution. | -| `tests/unit/common-adapter/evaosIpcBridge.dom.test.ts` | Proves renderer/main action deadlines, fail-closed IPC, and URL blocking. | Workbench Mac Access client IPC test | A5 | Preserve only fixed renderer-safe client calls and fail-closed deadlines; no connector endpoint, credential, or generic host operation may cross IPC. | -| `tests/unit/evaos/NativeCompanionPage.dom.test.tsx` | Proves Workbench Mac-control UI selection, stop/kill wiring, and redacted support states. | Workbench Mac Access client UI test | A5 | Rebind to redacted Mac Access client DTOs and preserve human-selected target, visible stop/kill, and no-terminal-repair behavior; exclude iPhone Mirroring scope. | -| `tests/unit/evaos/evaosBrokerSession.test.ts` | Proves Workbench broker enrollment, grant, cancellation, and customer-binding behavior. | Workbench broker-selected Mac Access test | A5 | Replace connector URL/token/Tailscale enrollment with atomic selected-binding client flows; preserve wrong-customer, no-store, cancel, expiry, and renderer-secret negatives. | -| `tests/unit/evaos/evaosNativeCompanionStatus.test.ts` | Proves Workbench bridge discovery, status, enrollment, TCC, stop/kill, and redacted diagnostics. | Workbench Mac Access client status test | A5 | Consume redacted helper status only; remove legacy listener/PATH/Tailscale/TCC authority while preserving fail-closed readiness, generation, kill-switch, and secret checks. | -| `tests/unit/evaos/evaosNativeCompanionStatusHook.dom.test.tsx` | Proves customer-scoped renderer polling, grant handoff, and diagnostic filtering. | Workbench redacted Mac Access hook test | A5 | Preserve selected-customer isolation and allowlisted diagnostics over client DTOs; never retain grant or readiness state across customer switches. | -| `tests/unit/process/prepareEvaosDesktopBridgeResource.test.ts` | Proves current Workbench source selection, generated resource, and manifest behavior. | connector-core generation regression test | A1 | Consume canonical package source plus generated Workbench resource; fail if legacy resource source truth, system Python, or digest drift remains. | -| `tests/unit/process/evaosBetaReleaseGate.test.ts` | Proves Workbench candidate identity, packaged CLI, binding, and release-gate failures. | Workbench core-parity release test | A1 | Preserve exact candidate/race/redaction/release negatives against the generated embedded runtime; never treat them as Mac Access #705 release proof. | -| `src/renderer/evaos/__fixtures__/goldenWorkbenchParityManifest.ts` | Defines Workbench renderer parity expectations for native status. | Workbench client contract fixture | A5 | Replace legacy connector readiness/identity fields with redacted Mac Access client status; fixture truth cannot grant authority. | +| Current Workbench file | Current responsibility | Target owner | Phase | Required change and exit check | +| ---------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------- | ----- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `src/common/evaos/bridgeTypes.ts` | Native status/action DTOs, including current control-mode shapes. | Workbench generated Mac Access client DTOs | A5 | Add v1 status/actions including `off`; delete raw connector URL/token/path fields from client-visible types. | +| `src/common/evaos/nativeCompanionBoundary.ts` | Static shell/native trust matrix and canaries. | Workbench trust/presentation contract | A5 | Point native owner to Mac Access helper; canaries describe client status, not Python CLI authority. | +| `src/process/evaosNativeCompanionBoundary.ts` | Main-process local-action boundary enforcement. | Workbench fail-closed client boundary | A5 | Permit only fixed authenticated-client actions; no generic local CUA or lifecycle escape hatch. | +| `src/index.ts` | Main-process startup/teardown registration. | Workbench Mac Access client bootstrap | A5 | Register XPC client bridge; stop only Workbench client/session state, never an unknown Mac Access helper. | +| `src/process/bridge/evaosNativeCompanionBridge.ts` | Renderer-to-main status/diagnostic/action IPC. | Workbench renderer-safe Mac Access bridge | A5 | Call the authenticated client, preserve `BridgeResponse`, and return redacted status/audit IDs only. | +| `src/process/bridge/evaosRendererSecretGuard.ts` | Renderer secret-shape rejection. | Workbench renderer secret guard | A5 | Extend forbidden fields to Mac Access channel, binding, peer, Keychain, signature, and transport details. | +| `src/process/bridge/index.ts` | IPC bridge registration. | Workbench bridge registry | A5 | Register only the Mac Access client bridge once. | +| `src/process/resources/builtinMcp/evaosMacControlServer.ts` | Spawns bridge CLI and exposes Mac-control tools. | Workbench-to-Mac Access client adapter | A5 | Remove bridge binary/PATH resolution and direct process spawn; route allowed requests through helper client or selected broker path. | +| `src/process/services/evaosNativeCompanionStatus.ts` | Status, process spawn, local HTTP readiness, tailnet discovery, grants, enrollment, actions, TCC repair. | Workbench `MacAccessClientService` | A5 | Retain presentation/orchestration only; delete connector spawn/stop, URL/token, host discovery, Tailscale bootstrap, grant ownership, direct bridge CLI, and TCC authority. | +| `src/process/services/evaosBrokerSession.ts` | Completes connector enrollment and creates grants from connector URL/token/tailnet inputs. | Broker-selected Mac Access client adapter | A5 | Remove Workbench connector registration/grant authority and raw endpoint/token inputs; request only broker-side atomic prepare/commit operations. No post-cutover reverse handoff to Workbench exists. | +| `src/common/adapter/ipcBridge.ts` | Declares renderer/main native-companion IPC surface. | Workbench generated renderer-safe client IPC | A5 | Replace legacy action/status shapes with the redacted Mac Access client DTOs; no generic or secret-bearing fields. | +| `src/process/utils/runBackendMigrations.ts` | Executes backend migrations including native-companion seams. | Workbench coexistence migration coordinator | A5 | Run idempotent make-before-break client cutover marker; never migrate raw credentials in renderer/Node storage. | +| `src/renderer/evaos/useEvaosNativeCompanionStatus.ts` | Polls/requests status and actions. | Workbench renderer hook | A5 | Render redacted v1 status; no connector lifecycle assumptions. | +| `src/renderer/evaos/nativeCompanionViewModel.ts` | Converts native status into customer UI state. | Workbench presentation model | A5 | Map `Off`, `Ask Every Time`, `Full Access`, paused/revoked/blocked, and reconfirmation truthfully. | +| `src/renderer/pages/native-companion/index.tsx` | Mac & iPhone/native companion page. | Workbench Mac Access client UI | A5 | Present status/handoff controls; Mac Access app owns pairing/TCC/access confirmation. iPhone Mirroring is outside this sprint. | +| `src/renderer/pages/guid/hooks/useGuidAgentSelection.ts` | Consumes native-companion readiness during guided agent selection. | Workbench redacted readiness consumer | A5 | Consume client status only; no fallback connector start or readiness inference from legacy bridge state. | +| `src/renderer/pages/settings/AgentSettings/LocalAgents.tsx` | Consumes native-companion status in Local Agents settings. | Workbench redacted status consumer | A5 | Render Mac Access state/handoff only; no connector lifecycle, token, or TCC authority. | +| `src/process/resources/builtinMcp/constants.ts` | Names and recognizes the packaged Mac-control MCP server. | Workbench Mac Access MCP recognizer | A5 | Recognize only the authenticated Mac Access client adapter; remove legacy bridge-name and direct-process fallback. | +| `src/renderer/evaos/evaosNativeAgentAvailability.ts` | Maps native-companion state into agent/MCP availability. | Workbench redacted availability mapper | A5 | Only exact redacted Mac Access `ready` state may be usable or attach MCP; Off, repair, and reconfirmation states remain blocked. | +| `src/renderer/pages/guid/hooks/useGuidSend.ts` | Attaches native Mac-control MCP during guided sends. | Workbench guarded MCP attachment | A5 | Attach only from the exact redacted Mac Access ready state; no legacy readiness or connector-start fallback. | +| `src/renderer/pages/guid/GuidPage.tsx` | Directly presents native availability in the guided workflow. | Workbench redacted status presentation | A5 | Consume the shared redacted availability result only; never infer transport, credential, TCC, or lifecycle authority. | +| `src/renderer/pages/guid/components/AgentPillBar.tsx` | Directly presents selected-agent native availability. | Workbench redacted status presentation | A5 | Display status only; do not attach MCP or trigger connector lifecycle outside the guarded send path. | +| `src/renderer/pages/settings/AgentSettings/AgentCard.tsx` | Directly presents agent native availability. | Workbench redacted status presentation | A5 | Display status only; blocked/repair/reconfirm states cannot be promoted to usable. | +| `scripts/build-mcp-servers.js` | Packages the direct Mac-control MCP server adapter. | Workbench client-adapter packaging | A5 | Package only the Mac Access authenticated-client adapter; remove direct bridge binary/process invocation. | +| `scripts/build-with-builder.js` | Orchestrates Workbench resource preparation and product packaging. | Workbench build consuming connector-core | A1 | Resolve the canonical package and invoke deterministic preparation; A5 later removes authoritative runtime embedding. | +| `scripts/prepareEvaosDesktopBridgeResource.js` | Prepares and manifests the Workbench-owned Python bridge/helper runtime. | connector-core deterministic packaging adapter | A1 | Consume `packages/mac-connector-core` as the only source, emit the exact core digest, and fail if the generated Workbench resource can drift. | +| `resources/evaos-beta/bridge/SOURCE.json` | Records archived import provenance for the Workbench-vendored bridge source. | connector-core versioned provenance manifest | A1 | Migrate required history into the canonical core manifest; generated Workbench resources reference that manifest and never restore the archived repo as a dependency. | +| `resources/evaos-beta/bridge/agent-tools/SOURCE.json` | Records exact bundled OpenClaw/Hermes agent-tool source and immutable file digests. | VM/runtime consumer provenance manifest | A1 | Keep separate from connector-core source ownership; verify compatible schemas/digests and never copy agent tools into the Mac connector authority. | +| `resources/evaos-beta/bridge/agent-tools/openclaw-plugin/src/runtimeReceipt.ts` plus `bridge.ts`, `firewall.ts`, and `toolParameters.ts` | Verifies selected binding, signed context, receipt/public attestation, replay, expiry, and tool boundaries in the VM runtime. | VM/runtime Mac Access relay consumer | A3 | Preserve #708 parity and negative fixtures; consume #73 relay results without becoming local policy, TCC, audit, or connector authority. | +| `resources/evaos-beta/bridge/agent-tools/openclaw-plugin/src/types.d.ts` | Declares the OpenClaw consumer runtime/type boundary. | VM/runtime consumer type contract | A3 | Generate or verify against the owned plugin contract; declarations may describe relay receipts but cannot define connector-core/helper authority. | +| `resources/evaos-beta/bridge/agent-tools/openclaw-plugin/index.ts`, `openclaw.plugin.json`, `package*.json`, and `tsconfig.json` | Registers/packages/builds the bundled VM-side plugin consumer. | VM/runtime consumer build metadata | A3 | Keep a separately versioned consumer artifact tied to `agent-tools/SOURCE.json`; never become connector-core source or inherit local helper authority. | +| `resources/evaos-beta/bridge/agent-tools/openclaw-plugin/dist/**` | Generated VM-side plugin output. | generated VM/runtime consumer artifact | A3 | Rebuild only from the owned plugin source, verify SOURCE digests, and reject committed/generated drift; never import generated output into connector core. | +| `resources/evaos-beta/bridge/agent-tools/openclaw-plugin/scripts/qa-run-bridge.mjs` and `runtime-receipt-negative-proof.mjs` | Runs built-plugin bridge QA and deployed negative receipt classification. | VM/runtime consumer proof harness | A3/A7 | Preserve exact selected-binding, replay, expiry, signer, and no-connector-access negatives against #73; results are not local Mac artifact proof. | +| `resources/evaos-beta/bridge/agent-tools/openclaw-plugin/tests/pluginContract.test.mjs` and `runtimeReceipt.test.mjs` | Proves plugin registry, verifier, firewall, stream, replay, and receipt contracts. | VM/runtime consumer contract tests | A1/A3 | Run as parity at A1 and against #73 at A3; keep generated/source/provenance equality and all negative receipt cases. | +| `resources/evaos-beta/bridge/agent-tools/hermes-adapter/bin/evaos-desktop-bridge-command` | Hermes-side selected-binding and receipt verifier/adapter. | VM/runtime Hermes Mac Access relay consumer | A3 | Preserve exact signed-context and receipt behavior; no direct connector URL/token or private-network fallback. | +| `resources/evaos-beta/bridge/native/EvaOSEd25519Verify.swift` | Native public-key verifier used by Workbench receipt proof. | `packages/mac-connector-core/native/EvaOSEd25519Verify.swift` plus `native/main.swift` | A1 | Split reusable verification logic from the CLI entry point so the lowercase `native` directory is not a single-file directory; preserve byte/signature parity and make Workbench/Mac Access compile this package source rather than copy it. | +| `scripts/evaosMacControlSignedProof.js` and `scripts/evaosScanMacControlProofs.js` | Builds and scans Workbench signed runtime-proof packets. | Workbench proof consumer | A1 | Preserve #708 regression proof and redaction; never treat Workbench proof scripts as Mac Access #705/#706 artifact or live-CUA proof. | +| `scripts/evaosBrokerLiveCanary.js` | Calls and classifies the Workbench runtime-receipt route during canary proof. | Workbench parity canary consumer | A1 | Preserve #708 exact candidate/binding/receipt checks; A6 converts to Mac Access client receipt consumption and never substitutes for #702/#706 proof. | +| `scripts/evaosInstalledAppProductProof.js` | Validates installed Workbench product and native receipt proof inputs. | Workbench installed-product parity verifier | A1/A6 | Preserve #708 Workbench proof until cutover, then verify client-only/no-embedded-authority behavior; it never proves the independent #705 artifact. | +| `scripts/evaosLiveCanaryEnvInventory.js` and `scripts/evaosValidateLiveCanaryProofRun.sh` | Inventories and validates required live-canary receipt/signer/proof fields. | Workbench canary proof validator | A1 | Preserve exact field/redaction validation and #709 complete-signer fail-closed behavior; no environment value becomes Mac Access source or authority. | +| `scripts/create-mock-release-artifacts.sh` | Builds committed-source Workbench bridge release fixtures with source/provenance manifests. | Workbench core-parity fixture builder | A1 | Consume the canonical connector-core manifest and generated resource only; never archive the legacy resource as source truth or build independent Mac Access release assets. | +| `scripts/evaosBetaReleaseGate.js` | Runs Workbench bridge source/app identity, enrollment, and packaged CLI release gates. | Workbench gate consuming connector-core proof | A1 | Verify the canonical core digest and preserve regression canaries; A5 later limits this gate to client compatibility and non-embedding. | +| `packages/desktop/electron-builder.yml` | Embeds bridge resources into the Workbench artifact. | Workbench packaging consuming connector-core | A1 | Package only generated core resources with the manifest digest; A5 removes the authoritative runtime after coexistence cutover. | +| `scripts/afterPack.js` | Verifies packaged Workbench bridge provenance and resource shape. | Workbench core-digest verifier | A1 | Prove the packaged resource equals the canonical core manifest; A5 later inverts this gate to reject an authoritative embedded connector. | +| `scripts/afterSign.js` | Signs/verifies the Workbench bridge/helper payload. | Workbench generated-core signing verifier | A1 | Preserve Workbench signing for the generated core during migration; A5 removes connector authority. It never signs or mutates independent Mac Access artifacts. | +| `.github/workflows/evaos-beta-rc-canary.yml` | Runs Workbench beta candidate bridge/enrollment proof. | Workbench core-parity canary | A1 | Prove the generated package uses the canonical core digest; A5 later converts to client/non-embedding proof. Never gate or publish Mac Access. | +| `.github/workflows/evaos-live-canary-proof.yml` | Runs Workbench live packaged bridge proof. | Workbench core-parity canary | A1 | Preserve packaged-core regressions against the canonical digest; A5 later proves client-only behavior. Never substitute for Mac Access #705/#706 proof. | +| `.github/workflows/_build-reusable.yml` | Builds/tests Workbench and provisions build-time Python on packaging runners. | Workbench generated-core build consumer | A1 | Connector proof must execute the generated packaged runtime, not runner/system Python; the setup runtime may perform build tooling only. Never build Mac Access release. | +| `.github/workflows/workbench-functional-smoke.yml` | Builds an unpacked Workbench app and exercises its pinned embedded bridge runtime. | Workbench generated-core functional smoke | A1 | Bind the generated resource to the exact canonical core digest and run bridge imports through its packaged Python; A5 converts this to authenticated-client smoke proof. | +| `.github/workflows/pr-checks.yml` | Exercises source-commit-bound Workbench release fixtures and gates. | Workbench core-parity CI | A1 | Pin fixtures to the exact candidate/core source commit; broad CI remains Workbench evidence and never proves Mac Access artifacts or live control. | +| `.github/workflows/release-distribute.yml` | Passes Workbench RC artifact proof into the Workbench distribution gate. | Workbench release proof consumer | A1 | Preserve exact artifact/provenance verification only for Workbench; do not publish, gate, or mutate independent Mac Access artifacts. | +| `tests/unit/evaos/evaosDesktopBridgeSafety.test.ts` | Proves Workbench bridge race, filesystem-mode, and audit-redaction behavior. | connector-core parity regression input | A1 | Re-run against the canonical extracted core, then A5 retain only client/non-embedding safety tests. It is not native helper or signed-artifact proof. | +| `tests/unit/evaos/evaosDesktopBridgeReceipt.test.ts` | Proves #708 receipt schemas/routes/signers plus #709 pre-mutation staging validation. | connector-core receipt parity test | A1 | Preserve every current receipt and incomplete-signer negative against the extracted core; A3 adds #73 relay proof without weakening the Workbench regression. | +| `tests/unit/evaos/evaosDesktopBridgeControlSafetyP0.test.ts` and `evaosQaCanaryLocalControl.test.ts` | Prove generation-linearized control safety and packaged QA behavior. | connector-core safety/canary parity tests | A1 | Preserve late #697 races plus #708 receipt behavior through the embedded core before native replacement. | +| `tests/unit/evaos/evaosLiveCanaryWorkflow.test.ts` | Proves Workbench canary workflow receipt/proof composition. | Workbench canary workflow parity test | A1/A6 | Preserve #708 workflow gates, then invert to authenticated Mac Access client/no-embedded-authority proof after cutover. | +| `tests/unit/evaos/evaosInstalledAppProductProof.test.ts` and `evaosLiveCanaryEnvInventory.test.ts` | Prove installed-product receipt inputs and live-canary inventory/redaction. | Workbench installed/canary proof tests | A1/A6 | Preserve exact #708/#709 negatives, then prove client-only consumption after cutover; never become #705 artifact or #706 live-CUA evidence. | +| `tests/unit/evaos/fixtures/signedMacControlAttestation.ts` | Provides deterministic signed receipt/attestation fixtures. | connector-core verification fixture | A1 | Move or derive from the canonical native verifier contract and keep Workbench tests as consumers; test signatures are not deployed signer proof. | +| `tests/unit/evaos/evaosBrokerLiveCanary.test.ts` | Binds Workbench live-canary proof to exact source/run and selected-binding evidence. | Workbench core-parity canary test | A1 | Preserve source/binding negatives until A5 client conversion; never substitute the Workbench canary for #702 transport or #706 VM-to-Mac CUA proof. | +| `tests/unit/bootstrap/afterPackBundledResources.test.ts` | Proves packaged Workbench bridge/helper/runtime inventory and provenance checks. | Workbench generated-core package test | A1 | Build fixtures from the canonical manifest/generated resource and require the packaged embedded runtime; never accept legacy source truth or system-Python substitution. | +| `tests/unit/common-adapter/evaosIpcBridge.dom.test.ts` | Proves renderer/main action deadlines, fail-closed IPC, and URL blocking. | Workbench Mac Access client IPC test | A5 | Preserve only fixed renderer-safe client calls and fail-closed deadlines; no connector endpoint, credential, or generic host operation may cross IPC. | +| `tests/unit/evaos/NativeCompanionPage.dom.test.tsx` | Proves Workbench Mac-control UI selection, stop/kill wiring, and redacted support states. | Workbench Mac Access client UI test | A5 | Rebind to redacted Mac Access client DTOs and preserve human-selected target, visible stop/kill, and no-terminal-repair behavior; exclude iPhone Mirroring scope. | +| `tests/unit/evaos/evaosBrokerSession.test.ts` | Proves Workbench broker enrollment, grant, cancellation, and customer-binding behavior. | Workbench broker-selected Mac Access test | A5 | Replace connector URL/token/Tailscale enrollment with atomic selected-binding client flows; preserve wrong-customer, no-store, cancel, expiry, and renderer-secret negatives. | +| `tests/unit/evaos/evaosNativeCompanionStatus.test.ts` | Proves Workbench bridge discovery, status, enrollment, TCC, stop/kill, and redacted diagnostics. | Workbench Mac Access client status test | A5 | Consume redacted helper status only; remove legacy listener/PATH/Tailscale/TCC authority while preserving fail-closed readiness, generation, kill-switch, and secret checks. | +| `tests/unit/evaos/evaosNativeCompanionStatusHook.dom.test.tsx` | Proves customer-scoped renderer polling, grant handoff, and diagnostic filtering. | Workbench redacted Mac Access hook test | A5 | Preserve selected-customer isolation and allowlisted diagnostics over client DTOs; never retain grant or readiness state across customer switches. | +| `tests/unit/process/prepareEvaosDesktopBridgeResource.test.ts` | Proves current Workbench source selection, generated resource, and manifest behavior. | connector-core generation regression test | A1 | Consume canonical package source plus generated Workbench resource; fail if legacy resource source truth, system Python, or digest drift remains. | +| `tests/unit/process/evaosBetaReleaseGate.test.ts` | Proves Workbench candidate identity, packaged CLI, binding, and release-gate failures. | Workbench core-parity release test | A1 | Preserve exact candidate/race/redaction/release negatives against the generated embedded runtime; never treat them as Mac Access #705 release proof. | +| `src/renderer/evaos/__fixtures__/goldenWorkbenchParityManifest.ts` | Defines Workbench renderer parity expectations for native status. | Workbench client contract fixture | A5 | Replace legacy connector readiness/identity fields with redacted Mac Access client status; fixture truth cannot grant authority. | Before A1 edits, machine-inventory every production import, broker enrollment/grant method, IPC declaration, MCP/build/package consumer, VM-side agent-tool consumer, test, canary, after-pack/sign check, and release-workflow input at the exact #699-accepted head. Each must consume the canonical package, generated resource, packaged embedded runtime, or explicitly separate VM-consumer artifact—never system Python or `resources/evaos-beta/bridge` as source truth—and receive a singular owner row. Repeat that inventory at the exact A5/A6 base. Absence from this inspected head is not permission to leave an untracked owner. From ba035ff7ddcbea86460dd9a934ff78b415f2fe69 Mon Sep 17 00:00:00 2001 From: Eva Date: Wed, 15 Jul 2026 21:26:31 +0700 Subject: [PATCH 13/14] fix(mac-access): close lifecycle authority gaps --- .../mac-connector-core/contracts/v1/index.ts | 57 ++++- tests/contract/macAccessContracts.test.ts | 203 ++++++++++++++++++ 2 files changed, 253 insertions(+), 7 deletions(-) diff --git a/packages/mac-connector-core/contracts/v1/index.ts b/packages/mac-connector-core/contracts/v1/index.ts index e570976ff1..3f2bfd12ff 100644 --- a/packages/mac-connector-core/contracts/v1/index.ts +++ b/packages/mac-connector-core/contracts/v1/index.ts @@ -514,6 +514,13 @@ export const accessStateSchema = z path: ['effective_mode'], }); } + if (state.kill_switch && state.configured_mode !== 'off') { + context.addIssue({ + code: z.ZodIssueCode.custom, + message: 'kill switch must clear configured user access until a future local recovery contract', + path: ['configured_mode'], + }); + } if (state.pairing_state === 'paired' && state.binding === null) { context.addIssue({ code: z.ZodIssueCode.custom, @@ -911,6 +918,21 @@ export const accessTransitionSchema = z path: ['to', 'configured_mode'], }); } + if (!['pause', 'resume'].includes(transition.event) && from.paused !== to.paused) { + context.addIssue({ + code: z.ZodIssueCode.custom, + message: 'pause barrier may change only through explicit pause or resume', + path: ['to', 'paused'], + }); + } + if (transition.event !== 'kill_switch' && from.kill_switch !== to.kill_switch) { + context.addIssue({ + code: z.ZodIssueCode.custom, + message: + 'only activation may change the v1 kill-switch barrier; clearing requires a future local recovery contract', + path: ['to', 'kill_switch'], + }); + } if (transition.event !== 'restart' && from.runtime_instance_id !== to.runtime_instance_id) { context.addIssue({ code: z.ZodIssueCode.custom, @@ -984,11 +1006,12 @@ export const accessTransitionSchema = z to.confirmed_policy_epoch === null && to.confirmed_binding_fingerprint_sha256 === null && (to.configured_mode !== 'full_access' || - (to.effective_mode === 'ask_every_time' && to.local_confirmation_required)); + (to.effective_mode === (to.paused || to.kill_switch ? 'off' : 'ask_every_time') && + to.local_confirmation_required)); if (!validRestart) { context.addIssue({ code: z.ZodIssueCode.custom, - message: 'restart must clear confirmation and downgrade configured full access to ask every time', + message: 'restart must clear confirmation and keep configured full access non-effective until reconfirmed', path: ['event'], }); } @@ -1036,7 +1059,7 @@ export const accessTransitionSchema = z !to.paused && to.configured_mode === from.configured_mode && (to.configured_mode !== 'full_access' || - (to.effective_mode === 'ask_every_time' && to.local_confirmation_required)); + (to.effective_mode === (to.kill_switch ? 'off' : 'ask_every_time') && to.local_confirmation_required)); if (!validResume) { context.addIssue({ code: z.ZodIssueCode.custom, @@ -2073,6 +2096,13 @@ export const coreHostResponseSchema = z path: ['result', 'transport_state'], }); } + if (response.operation === 'activate_kill_switch' && response.result.configured_mode !== 'off') { + context.addIssue({ + code: z.ZodIssueCode.custom, + message: 'kill-switch response must clear configured access', + path: ['result', 'configured_mode'], + }); + } if (response.operation === 'activate_kill_switch' && response.result.transport_state !== 'blocked') { context.addIssue({ code: z.ZodIssueCode.custom, @@ -2142,6 +2172,18 @@ export const coreHostExchangeSchema = z path: ['response', 'result', 'selected_binding'], }); } + if ( + ['disconnect', 'audit_summary', 'shutdown'].includes(request.operation) && + typeof request.expected_policy_epoch === 'number' && + response.ok && + response.policy_epoch !== request.expected_policy_epoch + ) { + context.addIssue({ + code: z.ZodIssueCode.custom, + message: 'non-advancing host response must match the exact expected policy epoch', + path: ['response', 'policy_epoch'], + }); + } if ( request.operation === 'dispatch_action' && response.ok && @@ -2171,16 +2213,17 @@ export const coreHostExchangeSchema = z request.operation === 'audit_summary' && response.ok && response.result?.kind === 'audit_summary' && - (response.result.page_anchor === null + ((response.result.page_anchor === null ? request.after_cursor !== null : request.after_cursor === null || response.result.page_anchor.sequence !== request.after_cursor.sequence || - response.result.page_anchor.record_sha256 !== request.after_cursor.record_sha256) + response.result.page_anchor.record_sha256 !== request.after_cursor.record_sha256) || + response.result.events.length > request.limit) ) { context.addIssue({ code: z.ZodIssueCode.custom, - message: 'audit summary must echo the exact requested cursor', - path: ['response', 'result', 'page_anchor'], + message: 'audit summary must echo the exact requested cursor and respect the requested page limit', + path: ['response', 'result', response.result.events.length > request.limit ? 'events' : 'page_anchor'], }); } }); diff --git a/tests/contract/macAccessContracts.test.ts b/tests/contract/macAccessContracts.test.ts index 143b66eb7f..303e53aa7f 100644 --- a/tests/contract/macAccessContracts.test.ts +++ b/tests/contract/macAccessContracts.test.ts @@ -460,6 +460,18 @@ describe('evaOS Mac Access v1 contracts', () => { } }); + it('keeps configured and effective access off while the kill switch is active', () => { + const state = cloneJson(accessStateSchema.parse(readJson(path.join(validRoot, 'state/access-state.json')))); + state.kill_switch = true; + state.effective_mode = 'off'; + + const parsed = accessStateSchema.safeParse(state); + expect(parsed.success).toBe(false); + if (!parsed.success) { + expect(parsed.error.issues.some((issue) => issue.path.join('/') === 'configured_mode')).toBe(true); + } + }); + it('rejects dispatch when the host epoch differs from the signed broker envelope', () => { const envelope = brokerControlEnvelopeSchema.parse(readJson(path.join(validRoot, 'authority/broker-control.json'))); const parsed = coreHostRequestSchema.safeParse({ @@ -769,6 +781,18 @@ describe('evaOS Mac Access v1 contracts', () => { pairedRevoked.result.requested_target_mode = 'off'; pairedRevoked.result.transport_state = 'revoked'; expect(coreHostResponseSchema.safeParse(pairedRevoked).success).toBe(false); + + const unsafeKillReceipt = readLifecycleResponseFixture(); + unsafeKillReceipt.operation = 'activate_kill_switch'; + unsafeKillReceipt.result.configured_mode = 'full_access'; + unsafeKillReceipt.result.effective_mode = 'off'; + unsafeKillReceipt.result.requested_target_mode = null; + unsafeKillReceipt.result.transport_state = 'blocked'; + const killReceipt = coreHostResponseSchema.safeParse(unsafeKillReceipt); + expect(killReceipt.success).toBe(false); + if (!killReceipt.success) { + expect(killReceipt.error.issues.some((issue) => issue.path.join('/') === 'result/configured_mode')).toBe(true); + } }); it('binds policy-changing lifecycle receipts to one exact epoch advance', () => { @@ -833,6 +857,38 @@ describe('evaOS Mac Access v1 contracts', () => { } }); + it('binds non-advancing lifecycle receipts to the exact expected epoch', () => { + for (const [index, operation] of ['disconnect', 'shutdown'].entries()) { + const request = { + schema_version: 'evaos.mac_connector_core.host_request.v1', + request_id: `non-advancing-lifecycle-${index}`, + host_session_id: 'host-session-01', + sequence: index + 1, + operation, + expected_policy_epoch: 7, + }; + const response = readLifecycleResponseFixture(); + response.request_id = request.request_id; + response.sequence = request.sequence; + response.operation = request.operation; + response.policy_epoch = request.expected_policy_epoch; + response.result.effective_mode = 'off'; + response.result.requested_target_mode = null; + response.result.transport_state = 'disconnected'; + expect(coreHostExchangeSchema.safeParse({ request, response }).success, operation).toBe(true); + + for (const policyEpoch of [request.expected_policy_epoch - 1, request.expected_policy_epoch + 1]) { + const mismatched = cloneJson(response); + mismatched.policy_epoch = policyEpoch; + const parsed = coreHostExchangeSchema.safeParse({ request, response: mismatched }); + expect(parsed.success, `${operation}:${policyEpoch}`).toBe(false); + if (!parsed.success) { + expect(parsed.error.issues.some((issue) => issue.path.join('/') === 'response/policy_epoch')).toBe(true); + } + } + } + }); + it('binds pairing receipts and connect receipts to the exact request authority', () => { const identity = { schema_version: 'evaos.mac_connector_core.host_request.v1', @@ -1013,6 +1069,123 @@ describe('evaOS Mac Access v1 contracts', () => { expect(accessTransitionSchema.safeParse(resumeMutation).success).toBe(false); }); + it('keeps an active kill switch effective off across restart', () => { + const restart = cloneJson( + accessTransitionSchema.parse(readJson(path.join(validRoot, 'state/access-transition-stop.json'))) + ); + restart.event = 'restart'; + restart.from.configured_mode = 'off'; + restart.from.effective_mode = 'off'; + restart.from.kill_switch = true; + restart.from.local_confirmation_required = false; + restart.from.confirmed_runtime_instance_id = null; + restart.from.confirmed_policy_epoch = null; + restart.from.confirmed_binding_fingerprint_sha256 = null; + restart.to.runtime_instance_id = 'runtime-instance-02'; + restart.to.configured_mode = 'off'; + restart.to.kill_switch = true; + restart.to.local_confirmation_required = false; + restart.to.reason_code = 'runtime_restart'; + expect(accessTransitionSchema.safeParse(restart).success).toBe(true); + }); + + it('keeps configured full access effective off when restart preserves pause', () => { + const restart = cloneJson( + accessTransitionSchema.parse(readJson(path.join(validRoot, 'state/access-transition-stop.json'))) + ); + restart.event = 'restart'; + restart.from.effective_mode = 'off'; + restart.from.paused = true; + restart.from.local_confirmation_required = true; + restart.from.confirmed_runtime_instance_id = null; + restart.from.confirmed_policy_epoch = null; + restart.from.confirmed_binding_fingerprint_sha256 = null; + restart.to.runtime_instance_id = 'runtime-instance-02'; + restart.to.paused = true; + restart.to.reason_code = 'runtime_restart'; + expect(accessTransitionSchema.safeParse(restart).success).toBe(true); + }); + + it('keeps an active kill switch effective off when clearing pause', () => { + const resume = cloneJson( + accessTransitionSchema.parse(readJson(path.join(validRoot, 'state/access-transition-stop.json'))) + ); + resume.event = 'resume'; + resume.invalidated_pending_authority = false; + resume.safe_cancellation_requested = false; + resume.from.configured_mode = 'off'; + resume.from.effective_mode = 'off'; + resume.from.paused = true; + resume.from.kill_switch = true; + resume.from.local_confirmation_required = false; + resume.from.confirmed_runtime_instance_id = null; + resume.from.confirmed_policy_epoch = null; + resume.from.confirmed_binding_fingerprint_sha256 = null; + resume.to.configured_mode = 'off'; + resume.to.paused = false; + resume.to.kill_switch = true; + resume.to.local_confirmation_required = false; + resume.to.reason_code = 'local_resume'; + expect(accessTransitionSchema.safeParse(resume).success).toBe(true); + }); + + it('preserves the pause barrier across unrelated mode transitions', () => { + const base = accessTransitionSchema.parse(readJson(path.join(validRoot, 'state/access-transition-stop.json'))); + const pausedModeChange = cloneJson(base); + pausedModeChange.event = 'set_mode'; + pausedModeChange.target_mode = 'ask_every_time'; + pausedModeChange.from.effective_mode = 'off'; + pausedModeChange.from.paused = true; + pausedModeChange.from.local_confirmation_required = true; + pausedModeChange.from.confirmed_runtime_instance_id = null; + pausedModeChange.from.confirmed_policy_epoch = null; + pausedModeChange.from.confirmed_binding_fingerprint_sha256 = null; + pausedModeChange.to.configured_mode = 'ask_every_time'; + pausedModeChange.to.paused = true; + pausedModeChange.to.local_confirmation_required = false; + pausedModeChange.to.reason_code = 'local_mode_changed'; + expect(accessTransitionSchema.safeParse(pausedModeChange).success).toBe(true); + + const clearedPause = cloneJson(pausedModeChange); + clearedPause.to.paused = false; + clearedPause.to.effective_mode = 'ask_every_time'; + const pauseResult = accessTransitionSchema.safeParse(clearedPause); + expect(pauseResult.success).toBe(false); + if (!pauseResult.success) { + expect(pauseResult.error.issues.some((issue) => issue.path.join('/') === 'to/paused')).toBe(true); + } + }); + + it('preserves the kill-switch barrier across unrelated mode transitions', () => { + const base = accessTransitionSchema.parse(readJson(path.join(validRoot, 'state/access-transition-stop.json'))); + const killedModeChange = cloneJson(base); + killedModeChange.event = 'set_mode'; + killedModeChange.target_mode = 'off'; + killedModeChange.from.configured_mode = 'off'; + killedModeChange.from.effective_mode = 'off'; + killedModeChange.from.kill_switch = true; + killedModeChange.from.local_confirmation_required = false; + killedModeChange.from.confirmed_runtime_instance_id = null; + killedModeChange.from.confirmed_policy_epoch = null; + killedModeChange.from.confirmed_binding_fingerprint_sha256 = null; + killedModeChange.to.configured_mode = 'off'; + killedModeChange.to.kill_switch = true; + killedModeChange.to.local_confirmation_required = false; + killedModeChange.to.reason_code = 'local_mode_changed'; + expect(accessTransitionSchema.safeParse(killedModeChange).success).toBe(true); + + const clearedKillSwitch = cloneJson(killedModeChange); + clearedKillSwitch.target_mode = 'ask_every_time'; + clearedKillSwitch.to.configured_mode = 'ask_every_time'; + clearedKillSwitch.to.effective_mode = 'ask_every_time'; + clearedKillSwitch.to.kill_switch = false; + const killResult = accessTransitionSchema.safeParse(clearedKillSwitch); + expect(killResult.success).toBe(false); + if (!killResult.success) { + expect(killResult.error.issues.some((issue) => issue.path.join('/') === 'to/kill_switch')).toBe(true); + } + }); + it('makes the kill switch a fail-closed configured-off transition', () => { const base = accessTransitionSchema.parse(readJson(path.join(validRoot, 'state/access-transition-stop.json'))); const killSwitch = cloneJson(base); @@ -1203,6 +1376,36 @@ describe('evaOS Mac Access v1 contracts', () => { expect(coreHostExchangeSchema.safeParse({ request: reorderedCursorRequest, response: continuation }).success).toBe( true ); + + for (const policyEpoch of [ + reorderedCursorRequest.expected_policy_epoch - 1, + reorderedCursorRequest.expected_policy_epoch + 1, + ]) { + const mismatchedAuditEpoch = cloneJson(continuation); + mismatchedAuditEpoch.policy_epoch = policyEpoch; + const epochResult = coreHostExchangeSchema.safeParse({ + request: reorderedCursorRequest, + response: mismatchedAuditEpoch, + }); + expect(epochResult.success, policyEpoch.toString()).toBe(false); + if (!epochResult.success) { + expect(epochResult.error.issues.some((issue) => issue.path.join('/') === 'response/policy_epoch')).toBe(true); + } + } + + const fullPageRequest = { + ...reorderedCursorRequest, + after_cursor: null as { record_sha256: string; sequence: number } | null, + limit: 2, + }; + expect(coreHostExchangeSchema.safeParse({ request: fullPageRequest, response }).success).toBe(true); + const undersizedPageRequest = { ...fullPageRequest, limit: 1 }; + const oversizedPage = coreHostExchangeSchema.safeParse({ request: undersizedPageRequest, response }); + expect(oversizedPage.success).toBe(false); + if (!oversizedPage.success) { + expect(oversizedPage.error.issues.some((issue) => issue.path.join('/') === 'response/result/events')).toBe(true); + } + for (const afterCursor of [ null, { From da480cbce6e4d225c0b295d67a4395b7a74c6ef5 Mon Sep 17 00:00:00 2001 From: Eva Date: Wed, 15 Jul 2026 21:36:26 +0700 Subject: [PATCH 14/14] fix(mac-access): close audit authority gaps --- .../v1/fixtures/invalid/binding.json | 4 +- .../contracts/v1/fixtures/invalid/policy.json | 5 +-- .../mac-connector-core/contracts/v1/index.ts | 19 ++++++++- tests/contract/macAccessContracts.test.ts | 40 ++++++++++++++++++- tests/contract/macAccessCryptography.test.ts | 20 ++++++++++ 5 files changed, 80 insertions(+), 8 deletions(-) diff --git a/packages/mac-connector-core/contracts/v1/fixtures/invalid/binding.json b/packages/mac-connector-core/contracts/v1/fixtures/invalid/binding.json index 5daf5cfd39..70e77e3a7c 100644 --- a/packages/mac-connector-core/contracts/v1/fixtures/invalid/binding.json +++ b/packages/mac-connector-core/contracts/v1/fixtures/invalid/binding.json @@ -110,8 +110,8 @@ "value": "grant-02" } ], - "expected_stage": "runtime", + "expected_stage": "schema", "expected_error": "command_authorization_digest_or_signature_mismatch", - "required_runtime_rejection": "RFC 8785 canonicalize the complete command-authority payload, recompute its SHA-256, verify the Ed25519 signature, and compare the selected grant with local enrollment before policy evaluation." + "required_runtime_rejection": null } ] diff --git a/packages/mac-connector-core/contracts/v1/fixtures/invalid/policy.json b/packages/mac-connector-core/contracts/v1/fixtures/invalid/policy.json index 63b613910d..02c2c2e5a1 100644 --- a/packages/mac-connector-core/contracts/v1/fixtures/invalid/policy.json +++ b/packages/mac-connector-core/contracts/v1/fixtures/invalid/policy.json @@ -143,10 +143,7 @@ "threat": "replay", "contract": "broker_control", "base_fixture": "../valid/authority/broker-control.json", - "mutations": [ - { "operation": "set", "pointer": "/policy_epoch", "value": 6 }, - { "operation": "set", "pointer": "/authorization/payload/policy_epoch", "value": 6 } - ], + "mutations": [], "expected_stage": "runtime", "expected_error": "command_policy_epoch_stale", "required_runtime_rejection": "Reject before prompt or actuation unless the signed and delivered policy epoch equals the helper's current protected policy epoch and the host request expected epoch." diff --git a/packages/mac-connector-core/contracts/v1/index.ts b/packages/mac-connector-core/contracts/v1/index.ts index 3f2bfd12ff..bcaf7a9474 100644 --- a/packages/mac-connector-core/contracts/v1/index.ts +++ b/packages/mac-connector-core/contracts/v1/index.ts @@ -9,7 +9,8 @@ const base64Url = z .string() .min(1) .max(16_384) - .regex(/^[A-Za-z0-9_-]+$/); + .regex(/^[A-Za-z0-9_-]+$/) + .refine((value) => value.length % 4 !== 1, 'unpadded base64url length cannot have remainder one'); const installationNonce = z .string() .length(43) @@ -1341,6 +1342,13 @@ export const brokerControlEnvelopeSchema = z }); } const authority = envelope.authorization.payload; + if (canonicalJsonSha256(authority) !== envelope.authorization.payload_sha256) { + context.addIssue({ + code: z.ZodIssueCode.custom, + message: 'command authority payload digest must match its RFC 8785 canonical payload', + path: ['authorization', 'payload_sha256'], + }); + } const authorityMatches = authority.session_id === envelope.session_id && authority.channel_generation_id === envelope.channel_generation_id && @@ -1671,6 +1679,7 @@ export const auditChainGoldenSchema = z const [decision, result] = golden.records.map((record) => record.payload); if ( decision.event_type !== 'command_decision' || + decision.outcome !== 'allowed' || result.event_type !== 'command_result' || result.causation_audit_id !== decision.audit_id || result.command_id !== decision.command_id || @@ -1710,6 +1719,13 @@ const auditSummaryResultSchema = z } } if (summary.events.length === 0) { + if (summary.causal_decisions.length !== 0) { + context.addIssue({ + code: z.ZodIssueCode.custom, + message: 'empty audit summary cannot carry off-page causal decision records', + path: ['causal_decisions'], + }); + } if (summary.next_cursor !== null) { context.addIssue({ code: z.ZodIssueCode.custom, @@ -1795,6 +1811,7 @@ const auditSummaryResultSchema = z const decision = event.causation_audit_id === null ? undefined : decisions.get(event.causation_audit_id); if ( decision === undefined || + decision.outcome !== 'allowed' || decision.sequence >= event.sequence || decision.command_id !== event.command_id || decision.request_digest_sha256 !== event.request_digest_sha256 || diff --git a/tests/contract/macAccessContracts.test.ts b/tests/contract/macAccessContracts.test.ts index 303e53aa7f..ef0363f377 100644 --- a/tests/contract/macAccessContracts.test.ts +++ b/tests/contract/macAccessContracts.test.ts @@ -15,6 +15,7 @@ import { auditEventSchema, authenticatedLocalActionSchema, brokerControlEnvelopeSchema, + commandAuthorityPayloadSchema, coreHostExchangeSchema, coreHostRequestSchema, coreHostResponseSchema, @@ -215,13 +216,13 @@ const EXPECTED_RUNTIME_PROOF_LEDGER = [ ['replayed-core-host-sequence', 'host_sequence_replayed'], ['request-digest-mismatch', 'request_digest_mismatch'], ['revoked-grant', 'grant_revoked'], - ['signed-payload-tampered-grant', 'command_authorization_digest_or_signature_mismatch'], ['stale-command-policy-epoch', 'command_policy_epoch_stale'], ['stale-core-host-session', 'host_session_mismatch'], ['stolen-pairing-code', 'pairing_code_reused_or_claimed'], ] as const; const expectedIssuePathByError: Record = { + command_authorization_digest_or_signature_mismatch: 'authorization/payload_sha256', execution_context_binding_mismatch: 'execution_context/claims', selected_device_mismatch: 'authorization/payload', selected_grant_mismatch: 'authorization/payload', @@ -504,6 +505,29 @@ describe('evaOS Mac Access v1 contracts', () => { } }); + it('rejects malformed base64url and stale command-authority payload digests', () => { + const envelope = cloneJson( + brokerControlEnvelopeSchema.parse(readJson(path.join(validRoot, 'authority/broker-control.json'))) + ); + + const malformedPayload = cloneJson(envelope.authorization.payload); + malformedPayload.nonce = 'A'; + const malformedNonce = commandAuthorityPayloadSchema.safeParse(malformedPayload); + expect(malformedNonce.success).toBe(false); + if (!malformedNonce.success) { + expect(malformedNonce.error.issues.some((issue) => issue.path.join('/') === 'nonce')).toBe(true); + } + + const staleDigest = cloneJson(envelope); + staleDigest.sequence += 1; + staleDigest.authorization.payload.sequence = staleDigest.sequence; + const parsed = brokerControlEnvelopeSchema.safeParse(staleDigest); + expect(parsed.success).toBe(false); + if (!parsed.success) { + expect(parsed.error.issues.some((issue) => issue.path.join('/') === 'authorization/payload_sha256')).toBe(true); + } + }); + it('rejects silent binding replacement outside an explicit binding transition', () => { const transition = cloneJson(readJson(path.join(validRoot, 'state/access-transition-stop.json'))) as { to: { @@ -1360,6 +1384,20 @@ describe('evaOS Mac Access v1 contracts', () => { continuation.result.causal_decisions = [events[0]]; expect(coreHostResponseSchema.safeParse(continuation).success).toBe(true); + const deniedDecision = cloneJson(continuation); + deniedDecision.result.causal_decisions[0].outcome = 'denied'; + deniedDecision.result.causal_decisions[0] = rehashAuditEvent(deniedDecision.result.causal_decisions[0]); + deniedDecision.result.page_anchor.record_sha256 = deniedDecision.result.causal_decisions[0].record_sha256; + deniedDecision.result.events[0].previous_record_sha256 = deniedDecision.result.causal_decisions[0].record_sha256; + deniedDecision.result.events[0] = rehashAuditEvent(deniedDecision.result.events[0]); + deniedDecision.result.next_cursor.record_sha256 = deniedDecision.result.events[0].record_sha256; + expect(coreHostResponseSchema.safeParse(deniedDecision).success).toBe(false); + + const emptyWithCausalDecision = cloneJson(continuation); + emptyWithCausalDecision.result.events = []; + emptyWithCausalDecision.result.next_cursor = null; + expect(coreHostResponseSchema.safeParse(emptyWithCausalDecision).success).toBe(false); + const reorderedCursorRequest = { schema_version: 'evaos.mac_connector_core.host_request.v1', request_id: continuation.request_id, diff --git a/tests/contract/macAccessCryptography.test.ts b/tests/contract/macAccessCryptography.test.ts index ef36954480..f7a9c27fb2 100644 --- a/tests/contract/macAccessCryptography.test.ts +++ b/tests/contract/macAccessCryptography.test.ts @@ -265,6 +265,26 @@ describe('evaOS Mac Access canonical cryptographic contracts', () => { expect(verifiesAuditChainGolden(vector)).toBe(false); }); + it('rejects a fully rehashed execution result whose causal decision was denied', () => { + const vector = structuredClone(readJson(path.join(validRoot, 'audit/audit-chain-golden.json'))) as { + records: AuditGoldenRecord[]; + }; + vector.records[0].payload.outcome = 'denied'; + vector.records[0].payload.reason_code = 'denied_access_off'; + vector.records[0].canonical_payload_utf8 = canonicalizeJcs(vector.records[0].payload); + vector.records[0].record_sha256 = createHash('sha256') + .update(vector.records[0].canonical_payload_utf8) + .digest('hex'); + vector.records[1].payload.previous_record_sha256 = vector.records[0].record_sha256; + vector.records[1].canonical_payload_utf8 = canonicalizeJcs(vector.records[1].payload); + vector.records[1].record_sha256 = createHash('sha256') + .update(vector.records[1].canonical_payload_utf8) + .digest('hex'); + + expect(verifiesAnchoredAuditRecords(vector.records, null)).toBe(true); + expect(auditChainGoldenSchema.safeParse(vector).success).toBe(false); + }); + it('rejects deletion of an audit record from a persisted chain', () => { const vector = structuredClone(readJson(path.join(validRoot, 'audit/audit-chain-golden.json'))) as { records: unknown[];