diff --git a/.claude-plugin/marketplace.base.json b/.claude-plugin/marketplace.base.json index 4172ca1..0b9421d 100644 --- a/.claude-plugin/marketplace.base.json +++ b/.claude-plugin/marketplace.base.json @@ -9,7 +9,7 @@ "plugins": [], "metadata": { "description": "Agent collaboration plugin marketplace", - "version": "3.2.0", + "version": "3.3.0", "repository": "https://github.com/sumitake/agent-collab" } } diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 277eb17..3456311 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -10,7 +10,7 @@ { "name": "agent-collab", "description": "Unified dynamic-host collaboration package. Centralized skills and async coordination work without legacy packages; every model-execution route requires the verified signed plugin artifact.", - "version": "3.2.0", + "version": "3.3.0", "author": { "name": "John Osumi" }, @@ -32,7 +32,7 @@ ], "metadata": { "description": "Agent collaboration plugin marketplace", - "version": "3.2.0", + "version": "3.3.0", "repository": "https://github.com/sumitake/agent-collab" } } diff --git a/AGENTS.md b/AGENTS.md index 6871e24..f667efb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,8 +12,8 @@ post-install hook, or provider executor source. client behavior, migration, and release-safety checks. - Native runtime implementation and build/sign credentials stay in a separate private producer that contributors do not need to access. -- This repository may receive only final signed native artifacts and their - size/hash/signature manifest metadata. +- This repository may receive only final signed native standalone bundles and + their closed per-member/whole-bundle manifest metadata. - Never place executor source, bytecode, private absolute paths, or retired package trees in the active source or release archive. @@ -49,10 +49,11 @@ requires explicit configuration and fails closed; non-governance use carries an independence warning. Policy-only safe mode preserves only validated async inbox/coordination seams and returns typed unavailable for every model route. -The native client accepts no path override, resolves only the manifest-selected -artifact beneath the plugin root, rejects symlinks and traversal, verifies -platform/architecture/size/hash and macOS signing/notarization, scrubs the -environment, and uses only the fixed protocol. +The native client accepts no path or member override, resolves only the +manifest-selected closed bundle beneath the plugin root, rejects links, +aliases, traversal, writable or unknown members, verifies per-member +architecture/type/size/hash/signing plus whole-bundle identity and macOS +notarization, scrubs the environment, and uses only the fixed protocols. ## Clean public repository invariant diff --git a/README.md b/README.md index 9913de7..336566f 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # agent-collab -This repository distributes one package: **agent-collab** (v3.2.0). It gives +This repository distributes one package: **agent-collab** (v3.3.0). It gives Claude, Codex, Antigravity, OpenCode, ZCode, and custom primary hosts the same dynamic collaboration surface without publishing provider executors or maintaining host-specific plugin copies. @@ -10,7 +10,8 @@ skills, migration tooling, fail-closed client, contribution governance, and release-safety checks. Provider runtime implementation, build credentials, and signing infrastructure remain in a separate private build/sign system. A policy-only release contains no native runtime; an activation release may import -only the final signed and notarized artifact plus verification metadata. No +only the final signed and notarized standalone bundle plus its closed manifest +and verification metadata. No downloader, post-install hook, runtime cache, raw provider recipe, or executable source copy is shipped here. @@ -23,9 +24,26 @@ Contributors need no access to the private build/sign system. See | Package | Version | Role | |---|---:|---| -| `agent-collab` | 3.2.0 | Unified skills, dynamic host policy, migration preflight, and verified native-runtime client | - -## What's new - v3.2.0 +| `agent-collab` | 3.3.0 | Unified skills, dynamic host policy, migration preflight, and verified native-runtime client | + +## What's new - v3.3.0 + +- Add a distinct broker-only `gemini/governance` contract using exact Gemini + 3.1 Pro/high selection and complete artifact-bound proof validation. Gemini + advisory and long-context remain ordinary read-only actions and cannot emit + governance evidence. +- Advance the native runtime to manifest schema 2, contract 3, and broker + transport 2. Bind the public coordinator, closed standalone-bundle identity, + lifecycle state, release gates, generated skills, and response parser to the + same route matrix while the provider protocol remains version 1. +- Adopt canonical passwd HOME as the managed-provider reliability policy while + retaining closed environments, family exclusion, route authority, provider + state serialization, bounded lifecycle, and no raw CLI fallback. +- Move Codex advisory behind the same broker, guardian, acknowledged gate, and + cancellation lifecycle as every other provider route; only local runtime + management remains a direct exact-artifact operation. + +The v3.2.0 provider-broker changes remain in force: - Add explicit zero-idle launchd socket activation for managed Gemini, OpenCode, Grok, and Composer routes, with digest-bound state and no direct @@ -112,52 +130,64 @@ flowchart LR S --> N["Observed non-model seam
host async-inbox readiness only"] S --> C["Verified plugin-relative native runtime client"] C --> B["Per-user launchd socket
zero idle process"] - B --> X1["Managed Gemini, OpenCode,
Grok 4.5, and Composer"] - C --> X2["Managed Codex and
runtime management"] - W["Private build/sign system"] -. "signed and notarized artifact" .-> C + B --> Q["Signed guardian + acknowledged gate"] + Q --> X1["Managed Codex, Gemini, OpenCode,
Grok 4.5, and Composer"] + C --> X2["Local runtime management"] + W["Private build/sign system"] -. "signed and notarized bundle" .-> C W -. "same exact digest" .-> B ``` -The plugin runtime client accepts no binary override. It selects only the -platform/architecture entry in `runtime-manifest.json`, requires the fixed -plugin-relative path, rejects symlinks and parent traversal, verifies exact -size and SHA-256, and on macOS verifies the declared signing team plus -notarization assessment. It then launches a fixed versioned JSON protocol with -a scrubbed environment. Every artifact advertises its exact route/action contracts; +The plugin runtime client accepts no binary or member override. It selects only +the Darwin-arm64 standalone-bundle entry in `runtime-manifest.json`, requires +the fixed plugin-relative bundle and entrypoint, rejects links, path aliases, +unknown members, writable modes, and parent traversal, then verifies every +member's size, SHA-256, Mach-O type, architecture, minimum macOS, signing +profile, and the domain-separated whole-bundle identity. Production also +requires the pinned Developer ID team and notarization assessment. It then uses +the fixed broker/provider protocols with a scrubbed environment. Every artifact +advertises its exact route/action contracts; the client rejects unadvertised rows, mismatched route/authority combinations, and author-family provenance drift. Missing, blocked, unsigned, mismatched, or unsupported artifacts fail closed with typed status. -Gemini, OpenCode, Grok, and Composer use a digest-bound, per-user launchd Unix socket. Launchd +Codex, Gemini, OpenCode, Grok, and Composer use a digest-bound, per-user launchd Unix socket. Launchd owns the mode-`0600` socket and starts the exact signed runtime only when a request arrives. The broker accepts one bounded request, runs it through the managed backend, returns one bounded response, and exits; there is no `KeepAlive`, `RunAtLoad`, polling loop, interval, calendar trigger, or resident -agent process. Codex and runtime-management calls retain the fixed direct -exact-artifact path. Missing, stale, or mismatched broker state is a typed +agent process. At idle, launchd retains only the job registration and one +mode-`0600` Unix listening socket; the installed immutable bundle consumes disk +but no provider process, polling CPU, provider memory, or network traffic. Only +local runtime-management calls retain the fixed direct exact-entrypoint path. +Missing, stale, or mismatched broker state is a typed failure and never falls back to direct execution for any broker-only route. The broker removes the Codex Desktop outer-Seatbelt marker before backend dispatch because socket activation does not inherit the client's Seatbelt. Every brokered Grok and Composer attempt must therefore build and validate its -own nested read-only sandbox. Provider children receive closed, -backend-specific environment allowlists rooted in fresh private call -directories rather than the broker's ambient environment. Broker frames cannot +own nested read-only sandbox. Provider children receive closed backend-specific +environment allowlists with canonical passwd HOME for reliable authenticated +state plus fresh private temporary/cwd roots rather than the broker's ambient +environment. Grok uses real serialized `~/.grok`; OpenCode keeps private XDG +roots and selected-provider auth; Codex keeps a sealed per-call `CODEX_HOME`, +SQLite, and XDG overlay. Provider-specific policy overlays may narrow +credentials or tools without replacing process HOME. Broker frames cannot invoke local runtime-management actions. -If the client disconnects, the broker propagates cancellation through managed -OpenCode, Grok, and Composer execution/readiness, reaps provider child groups, +If the client disconnects, the broker propagates cancellation through every +managed provider route, reaps provider child groups, and discards partial output. Disconnect-driven cancellation is never retried; when too little deadline remains to establish the managed boundary, the route returns typed `timeout` before provider setup. -The current signed facade still returns typed `containment_error` for Gemini -before Google provider setup because no completion-only Google transport is -proven; socket activation establishes the safe transport boundary but does not -silently activate an agentic CLI. +Gemini uses the managed agy backend with canonical passwd HOME, mandatory PTY, +serialized state, and write containment. `gemini/governance` is distinct from +advisory/long-context and emits complete artifact-bound broker proof; ordinary +Gemini output cannot be presented as governance evidence. The broker rejects cross-UID, stale/replayed, substituted-artifact, and connecting-process mismatches. It does not claim to protect provider credentials from arbitrary malicious code already running as the same operator -UID, which can already read that user's auth state. The exact immutable runtime -path and digest are revalidated for each request. +UID, which can already read that user's auth state. The exact immutable bundle, +entrypoint path, member inventory, and identity are revalidated for each +request. The expected Apple Developer ID Team ID is pinned in the public `plugins/agent-collab/signing_policy.py` policy source, independently of the @@ -173,11 +203,11 @@ client decodes and verifies the representation before launch; neither the coordinator nor the native runtime may reconstruct the artifact from a prompt copy. -The repository intentionally contains no native runtime artifact yet. Native +The repository intentionally contains no native runtime bundle yet. Native Gemini, Codex, OpenCode, Grok 4.5, and Composer routes remain unavailable until -the private build/sign integration supplies the real artifact, manifest digest, +the private build/sign integration supplies the real bundle, manifest digest, and complete contract declaration. Deterministic tests use temporary fixture -executables only. The release gate requires every platform artifact to expose +bundles only. The release gate requires every platform artifact to expose the complete required contract matrix. Retirement and a policy-only release may land before native parity, but an activation release cannot launch any provider until the signed runtime exposes the complete matrix, including Composer. @@ -188,29 +218,32 @@ until the signed runtime exposes the complete matrix, including Composer. repository; private runtime changes remain inside the build/sign system. 2. The private producer runs its containment, provenance, and authority-boundary tests without exporting implementation source. -3. The private producer builds the Darwin-arm64 binary, signs it with hardened - runtime, notarizes it, and records size/hash/team metadata. +3. The private producer builds one Darwin-arm64 standalone bundle, signs every + nested Mach-O before the entrypoint with hardened runtime, notarizes the + closed bundle, and records whole-bundle plus per-member evidence. 4. A policy-only plugin release omits the runtime and keeps every native route - typed unavailable. An activation release imports only the final binary and - manifest metadata plus the exact third-party notice/license tree; no private - source implementation crosses the boundary. + typed unavailable. An activation release imports only the final bundle, + closed manifest metadata, and exact third-party notice/license tree; no + private source implementation crosses the boundary. 5. Plugin CI validates both Claude and Codex manifests/marketplaces, schemas, skills, migration behavior, runtime fixtures, the dependency-free secret scan, CodeQL security analysis, release consistency, and public-export safety. 6. A policy-only signed-tag release proves the runtime manifest is empty and the archive contains no runtime. For activation, a macOS verification job - binds codesign, notarization, manifest, artifact digest, and commit SHA into - release evidence before the publish job may include the binary. SPDX 2.3 + binds every member's codesign/Mach-O evidence, entrypoint notarization, + manifest digest, whole-bundle identity, and commit SHA before the publish job + may include the bundle. SPDX 2.3 evidence distinguishes project-owned PolyForm material from the embedded CPython, Nuitka, and incorporated third-party components. 7. Hosts update one package, run the migration doctor, restart, and verify the resolved profile plus eligible routes. Activation hosts run the co-packaged `runtime_setup.py status` and `prepare` commands, then explicitly run - `install-broker` before enabling Gemini, OpenCode, Grok, or Composer. Managed Grok device + `install-broker` before enabling Codex, Gemini, OpenCode, Grok, or Composer. Managed Grok device login is exposed only as `runtime_setup.py login-grok`. -8. Broker updates copy the verified artifact and manifest into an immutable - artifact-plus-manifest digest directory, atomically replace the closed +8. Broker updates copy only the manifest-listed regular bundle members and + manifest into an immutable artifact-plus-manifest digest directory, + atomically replace the closed launchd plist/state, verify the exact job and socket, activate one protocol-only request, and prove the broker process exits. Failed updates restore the complete prior verified state, including its rollback target, @@ -350,8 +383,8 @@ report managed independent visual review unavailable; they never invent a path-based attachment or raw-provider fallback. Explicit `target=gemini`, `target=codex`, and `target=opencode` requests are -fail-closed and never silently substituted. Gemini is read-only -advisory/long-context; Codex advisory and OpenCode plan/workspace-write authority are +fail-closed and never silently substituted. Gemini has separate read-only +advisory, governance, and long-context actions; Codex advisory and OpenCode plan/workspace-write authority are sealed separately and never promote or demote into one another. Codex build is a resolvable mutation-capable request but returns typed unavailable until a hardened mutation backend exists; it never widens advisory. @@ -361,7 +394,7 @@ architecture, governance, and huge-context actions; `target=composer` is constrained output-only code generation. Composer has no file, shell, test, worktree, PR, or merge authority. Both remain deterministically temporarily unavailable until the signed runtime advertises their exact route/action contracts. -Grok/Composer requests are broker-only and never fall back to direct runtime +Codex, Gemini, OpenCode, Grok, and Composer requests are broker-only and never fall back to direct runtime execution. Grok prose accepts only an explicit `EndTurn` terminal; exact `Cancelled` is a named non-success with no retained assistant text and may be retried once only when more than ten seconds remain under the original diff --git a/changelog.d/2026-07-13-gemini-governance.md b/changelog.d/2026-07-13-gemini-governance.md new file mode 100644 index 0000000..45cbfaf --- /dev/null +++ b/changelog.d/2026-07-13-gemini-governance.md @@ -0,0 +1,17 @@ +### agent-collab 3.3.0 — managed Gemini governance and reliable provider state + +- Add a distinct broker-only `gemini/governance` action with exact Gemini 3.1 + Pro/high selection, immutable artifact binding, shared-HOME/PTY containment + evidence, response hashing, and public-client proof-digest validation. +- Keep Gemini advisory and long-context separate and explicitly ineligible as + governance evidence; automatic governance now maps Gemini and Grok to their + explicit governance actions while Codex remains advisory. +- Advance the signed-runtime route contract to version 2 across policy, + manifests, schemas, archive/release verification, generated skills, and + deterministic tests. +- Document canonical passwd HOME as the managed-provider reliability policy + without relaxing family exclusion, authority separation, bounded lifecycle, + or the prohibition on raw provider fallbacks. +- Make Codex, Gemini, OpenCode, Grok, and Composer uniformly broker-only and + bind provider startup to the signed guardian's acknowledged pre-exec gate; + only local runtime management retains direct exact-artifact execution. diff --git a/changelog.d/2026-07-13-provider-broker.md b/changelog.d/2026-07-13-provider-broker.md index 1801bfc..e7392d8 100644 --- a/changelog.d/2026-07-13-provider-broker.md +++ b/changelog.d/2026-07-13-provider-broker.md @@ -1,9 +1,15 @@ -### agent-collab 3.2.0 — zero-idle provider broker +### agent-collab 3.3.0 — closed standalone provider bundle - Route managed Gemini, OpenCode, Grok, and Composer requests through an explicit, digest-bound launchd socket broker that starts for one request and returns to zero idle processes. Add closed install, status, rollback, and uninstall lifecycle commands with transactional prior-version restoration and no direct fallback. +- Replace the failed onefile launch path with manifest schema 2, native contract + 3, and broker transport 2 over one closed standalone bundle. Bind its identity + to the sorted role/mode/size/digest/Mach-O facts of every member; publish only + exact immutable `0500` files; verify the kernel-reported entrypoint and the + complete lifecycle state before provider dispatch. Provider protocol 1 and + all existing route-authority boundaries remain unchanged. - Resolve the OpenCode model per request from live OpenCode/ZCode observation, explicit central configuration, or the fixed `opencode/glm-5.2` preset while ignoring ambient and row-level model fallbacks. diff --git a/docs/migration-from-legacy-packages.md b/docs/migration-from-legacy-packages.md index 90f3cf2..78fb367 100644 --- a/docs/migration-from-legacy-packages.md +++ b/docs/migration-from-legacy-packages.md @@ -80,11 +80,13 @@ eligible-route checks pass. The active package intentionally contains no unsigned runtime placeholder. Every model route therefore remains temporarily unavailable until the signed private -runtime advertises the complete Gemini, Codex, OpenCode, Grok, and Composer -route/action matrix. A policy-only release requires an empty runtime manifest -and archive; it installs the migration and policy surface while every native -route stays typed unavailable. An activation release requires the complete -contracts plus commit-bound macOS signing/notarization evidence. No raw launcher +standalone bundle advertises the complete Gemini, Codex, OpenCode, Grok, and +Composer route/action matrix. A policy-only release requires an empty runtime +manifest and archive; it installs the migration and policy surface while every +native route stays typed unavailable. An activation release requires manifest +schema 2, native contract 3, broker transport 2, provider protocol 1, the +domain-separated whole-bundle identity, exact per-member Mach-O/signing facts, +the complete contracts, and commit-bound notarization evidence. No raw launcher is a migration fallback. Activation also requires the exact digest-pinned third-party notice/license @@ -127,6 +129,16 @@ manifest. `.claude-plugin/marketplace.json` and `.agents/plugins/marketplace.json` are generated from the same package and may contain no legacy preset or provider package. +The only native distribution shape is +`runtime/darwin-arm64/agent-collab-runtime.bundle/` plus its sibling +`runtime-manifest.json`. The manifest lists every regular `0500` entrypoint or +runtime-library member in exact UTF-8 path order; unknown, linked, writable, or +unlisted members fail closed. Installed versions are keyed by the +whole-bundle identity and exact manifest digest. Lifecycle state also binds the +bundle, entrypoint, manifest, plist, socket, label, protocol versions, and at +most one complete prior record, so rollback cannot reconstruct or promote an +old single-file or provider-specific surface. + Governance plus applicable review, fallback, and worker requests bind the artifact separately from the prompt. The native protocol receives exact captured bytes as base64 with diff --git a/docs/public-governance.md b/docs/public-governance.md index a0bf1f9..189d6c5 100644 --- a/docs/public-governance.md +++ b/docs/public-governance.md @@ -71,8 +71,10 @@ credentials, retired package trees, and unreviewed native artifacts. PR CI uses GitHub-hosted runners and receives no private build/sign credentials. Policy-only releases contain an empty runtime manifest. An activation release -may import only a final signed and notarized runtime plus the public verification -metadata. Public contributors never build or inspect the private implementation. +may import only a final signed and notarized standalone bundle, its closed +schema-2/contract-3 manifest, per-member verification metadata, and required +third-party license evidence. Public contributors never build or inspect the +private implementation. Run the gates in `README.md`, including: diff --git a/plugins/agent-collab/.claude-plugin/plugin.json b/plugins/agent-collab/.claude-plugin/plugin.json index 6f5eae1..fc005c4 100644 --- a/plugins/agent-collab/.claude-plugin/plugin.json +++ b/plugins/agent-collab/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "agent-collab", - "version": "3.2.0", + "version": "3.3.0", "description": "Unified dynamic-host collaboration package with centralized skills, migration preflight, and a verified plugin-relative native runtime boundary. The signed runtime artifact is intentionally absent until the private build/sign integration completes.", "author": { "name": "John Osumi" diff --git a/plugins/agent-collab/.codex-plugin/plugin.json b/plugins/agent-collab/.codex-plugin/plugin.json index 3a50934..9334b61 100644 --- a/plugins/agent-collab/.codex-plugin/plugin.json +++ b/plugins/agent-collab/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "agent-collab", - "version": "3.2.0", + "version": "3.3.0", "description": "Unified dynamic-host collaboration package with centralized skills, migration preflight, and a verified plugin-relative native runtime boundary. The signed runtime artifact is intentionally absent until the private build/sign integration completes.", "author": { "name": "John Osumi" diff --git a/plugins/agent-collab/README.md b/plugins/agent-collab/README.md index bc01a1a..54c859b 100644 --- a/plugins/agent-collab/README.md +++ b/plugins/agent-collab/README.md @@ -2,7 +2,7 @@ `agent-collab` is the single dynamic-host collaboration package. -Current: **3.2.0** +Current: **3.3.0** It resolves `primary_id`, `primary_family`, `active_model`, `host_runtime`, and `session_identifier` from the current host or explicit configuration. ZCode @@ -38,39 +38,51 @@ archives, which contain none of the corresponding runtime components. ## Runtime and safe mode -The package may contain a privately built signed native runtime only at the -platform path declared by `runtime-manifest.json`. `runtime_client.py` rejects -overrides, symlinks, parent traversal, wrong platform/architecture, wrong -size/hash, and on macOS the wrong signing team or failed notarization. It -inspects the executable as a thin arm64 Mach-O and requires exactly one macOS -`LC_BUILD_VERSION` with minimum macOS 14.0 instead of trusting those manifest -labels. It uses a fixed JSON protocol and scrubbed environment. The package +The package may contain a privately built signed native standalone bundle only +at the platform path declared by `runtime-manifest.json`. Native manifest schema +2 and contract 3 close the bundle path, entrypoint, sorted member list, +per-member role/mode/size/hash/Mach-O facts, signing profile, and +domain-separated whole-bundle identity. `runtime_client.py` rejects overrides, +links, aliases, extra members, parent traversal, writable modes, wrong +platform/architecture, wrong size/hash, the wrong signing team, or failed +notarization. It inspects every member as thin arm64 Mach-O and requires exactly +one macOS `LC_BUILD_VERSION` with minimum macOS 14.0 instead of trusting those +manifest labels. The broker transport is version 2 while the provider protocol +remains version 1. The package carries both `.claude-plugin/plugin.json` and `.codex-plugin/plugin.json`; both -identify this same 3.2.0 package. +identify this same 3.3.0 package. -Gemini, OpenCode, Grok, and Composer are broker-only contracts. Their sealed requests cross a +Codex, Gemini, OpenCode, Grok, and Composer are broker-only contracts. Their sealed requests cross a mode-`0600`, digest-bound per-user launchd Unix socket; launchd starts the exact signed runtime for one request, and the broker exits after its single bounded response. The plist has no keepalive, run-at-load, polling, interval, calendar, -or resident-process trigger. A missing or stale broker is a typed failure—these -routes never fall back to the direct artifact path. Codex and runtime -management retain fixed direct exact-artifact execution. +or resident-process trigger. At idle, launchd retains only its job registration +and one mode-`0600` Unix listening socket; the immutable bundle consumes disk, +but there is no provider process, polling CPU, provider memory, or network +traffic. A missing or stale broker is a typed failure—these routes never fall +back to the direct entrypoint path. Only local runtime management retains fixed +direct exact-entrypoint execution. The broker strips the Codex Desktop outer-Seatbelt marker before dispatch: socket activation does not inherit that client sandbox, so every brokered Grok and Composer attempt independently validates its own nested read-only sandbox. -Provider children receive closed backend-specific environment allowlists and -fresh private call roots. Broker transport cannot invoke local `status`, +Provider children receive closed backend-specific environment allowlists, +canonical passwd HOME for reliable authenticated state, and fresh private +temporary/cwd roots. Grok uses real serialized `~/.grok`; OpenCode keeps +private XDG roots and selected-provider auth; Codex keeps a sealed per-call +`CODEX_HOME`, SQLite, and XDG overlay. A provider-specific policy overlay may +narrow credentials or tools without replacing process HOME. Broker transport cannot invoke local `status`, `prepare`, or `grok_login` management authority. -Client disconnect propagates cancellation through managed OpenCode, Grok, and -Composer execution/readiness, reaps provider child groups, and discards partial +Client disconnect propagates cancellation through every managed provider +route, reaps provider child groups, and discards partial output. Disconnect cancellation is never retried; a deadline below the managed setup reserve returns typed `timeout` before provider setup. -The current Gemini facade remains typed `containment_error` before Google -provider setup until a separately reviewed completion-only transport exists; -the broker does not treat an agentic CLI mode as a read-only guarantee. +Gemini uses the managed agy backend with canonical passwd HOME, a mandatory +controlling PTY, serialized shared state, and write containment. Its separate +`governance` action emits complete artifact-bound broker proof; advisory and +long-context results are explicitly non-governance evidence. No signed artifact is present in this source tree yet. Native **Gemini -advisory/long-context**, **Codex advisory**, **OpenCode plan/build**, **Grok 4.5 +advisory/governance/long-context**, **Codex advisory**, **OpenCode plan/build**, **Grok 4.5 read-only architecture consultation, governance review, and huge-context ingestion**, and **Composer output-only code/patch generation** roles are therefore **temporarily unavailable**. Policy-only safe mode keeps @@ -78,7 +90,7 @@ all native model routes unavailable. A host inbox is eligible only after a current availability observation, and the public coordinator exposes readiness only rather than a send primitive. This package may still be distributed as a policy-only release: its manifest -has no artifact rows, its archive has no runtime executable, and invocation +has no artifact rows, its archive has no runtime bundle, and invocation continues to return typed unavailable until an activation release is verified. An activation archive must add the complete digest-pinned third-party legal tree and component-aware SPDX evidence alongside the signed runtime. @@ -128,8 +140,9 @@ primary's family is blocked in either direction. Explicit `target=gemini`, `target=codex`, `target=opencode`, `target=grok`, and `target=composer` requests are fail-closed. A target is never silently replaced. Automatic general **advisory** routing may try only eligible -Gemini/Codex routes. Automatic architecture and governance actions may also -try the corresponding sealed Grok action, preserving read-only authority and +Gemini/Codex routes. Automatic architecture may also try the corresponding +sealed Grok action. Automatic governance maps Gemini and Grok to their explicit +`governance` actions while Codex retains its sealed advisory action, preserving read-only authority and attempting each target once. Automatic worker routing is temporarily unavailable; worker requests require an explicit managed target. Read-only, output-only, and mutation-capable authority never promote or demote into one another. @@ -139,6 +152,7 @@ mutation-capable authority never promote or demote into one another. | Request | Authority | Managed route | |---|---|---| | `target=gemini` review or advisory | Read-only | Gemini advisory | +| `target=gemini` governance review | Read-only | Gemini `governance` with artifact-bound broker proof | | `target=gemini` large-corpus extraction | Read-only | Gemini long-context | | `target=codex` second opinion, high-stakes advice, or tiebreaker | Read-only | Codex advisory | | `target=codex` bounded implementation | Mutation-capable worker | Typed unavailable pending hardened mutation backend; no advisory fallback | @@ -221,8 +235,9 @@ their exact supported external CLIs and standard authenticated host state; install and authenticate those through their vendor-supported interfaces. Policy-only releases return typed `unavailable` for runtime-dependent commands. Broker installation is explicit; import, readiness, and invocation never -install or mutate launchd state. `install-broker` publishes the verified -artifact/manifest into an immutable artifact-plus-manifest digest directory, +install or mutate launchd state. `install-broker` publishes only the verified +manifest-listed bundle members and manifest into an immutable +artifact-plus-manifest digest directory, atomically activates a closed plist, proves the job/socket and one-request process exit, and retains one verified prior record. Failed updates restore the complete prior state; same-version reactivation preserves its rollback target, @@ -267,7 +282,7 @@ Every request contains exactly `protocol_version`, `request_id`, `operation`, `timeout_ms` is an integer from 1 through 600000. An `execute` request also has `prompt`. A governance request has `prompt` plus `artifact`, exactly `{"content":"...","author_model":"..."}`; both values must be nonblank. It may -use only a read-only governance-review contract: `gemini/advisory`, +use only a read-only governance-review contract: `gemini/governance`, `codex/advisory`, or `grok/governance`. An execute request for `opencode/build`, `composer/codegen`, `codex/build`, `auto/worker`, `auto/advisory`, `auto/architecture`, `auto/governance`, or an @@ -315,6 +330,7 @@ Exact row contracts are: | Contract | Exact `row` shape | |---|---| | `gemini/advisory` | `{"model":"google/...","effort":"low|medium|high|xhigh"}` | +| `gemini/governance` | `{"model":"google/gemini-3.1-pro","effort":"high|xhigh"}`; requires `governance=true`, a non-Google artifact snapshot, and broker proof | | `gemini/long_context` | Gemini advisory fields plus `"documents":[{"label":"...","content":"..."}]` | | `codex/advisory` | `{"model":"openai/...","effort":"low|medium|high|xhigh","mode":"prompt-only"}` or `mode=repo-review` plus absolute `cwd` | | `opencode/plan` | Absolute `cwd`; optional explicitly observed `model` and `variant` | @@ -343,8 +359,9 @@ sends an inbox message and never invokes Claude headlessly. Automatic general advisory routing uses `route=auto`, `action=advisory`; its `row` has exactly `gemini` and `codex` keys. Automatic architecture uses `action=architecture` and automatic governance uses `action=governance`; those -rows have exact `gemini`, `codex`, and `grok` keys. The coordinator maps only -the Grok leg to its sealed architecture/governance action. Generic advisory, +rows have exact `gemini`, `codex`, and `grok` keys. For architecture the +coordinator maps only Grok to `architecture`; for governance it maps Gemini and +Grok to `governance` while Codex remains advisory. Generic advisory, brainstorm, debate, QA, and fallback calls cannot select Grok. Explicit targets never fall back. @@ -381,9 +398,10 @@ fall back or promote into an advisory route. Example: No private repository, downloader, provider CLI recipe, or backend source is needed on a plugin-only machine. The coordinator accepts closed policy fields; the native client accepts only its sealed envelope. The first release target is -Darwin arm64 and requires exact integer protocol versions, safe ownership/mode/ -link state, digest, Developer ID team, the actual numeric hardened-runtime -code-signing flag, and notarization. Runtime stdout and stderr are bounded +Darwin arm64 and requires exact integer protocol versions, exact bundle +membership and identity, safe ownership/mode/link state, per-member digests and +Mach-O facts, Developer ID team, the actual numeric hardened-runtime +code-signing flag, and entrypoint notarization. Runtime stdout and stderr are bounded during execution; an output-limit violation terminates and reaps the runtime process group before returning typed `output_limit`. Unexpected selector or pipe-read failures follow the same terminate-and-reap rule before returning a @@ -392,8 +410,9 @@ typed lifecycle error. This protects distribution integrity and narrows path substitution, but it does not claim isolation from a malicious process already running as the same UID. macOS has no descriptor-only Mach-O execution path here; the client rechecks -file identity immediately before its fixed-path spawn, while treating the -operator account and selected plugin cache as trusted. +the complete bundle and entrypoint identity immediately before its fixed-path +spawn, while treating the operator account and selected plugin cache as +trusted. The manifest cannot choose its own signer. `signing_policy.py` pins the operator-owned Developer ID Team ID in reviewed policy source, and runtime plus diff --git a/plugins/agent-collab/coordinator.py b/plugins/agent-collab/coordinator.py index 1557349..5f66e0f 100644 --- a/plugins/agent-collab/coordinator.py +++ b/plugins/agent-collab/coordinator.py @@ -46,6 +46,7 @@ ("auto", "architecture"), ("auto", "governance"), ("gemini", "advisory"), + ("gemini", "governance"), ("codex", "advisory"), ("grok", "architecture"), ("grok", "governance"), @@ -55,6 +56,7 @@ DIRECT_CONTRACTS = frozenset( { ("gemini", "advisory"), + ("gemini", "governance"), ("gemini", "long_context"), ("codex", "advisory"), ("codex", "build"), @@ -172,7 +174,7 @@ def _validate(document: object) -> tuple[dict[str, Any] | None, str, str]: return None, request_label, ACTION_AUTHORITY_ERROR else: return None, request_label, "automatic action is unsupported" - if document["route"] == "grok" and ( + if document["route"] in {"gemini", "grok"} and ( (document["action"] == "governance") is not governance if document["action"] in {"architecture", "governance"} else False @@ -276,8 +278,14 @@ def process(document: object) -> tuple[dict[str, Any], int]: def issue_for(route: str, row: dict[str, Any]): action = validated["action"] - if validated["route"] == "auto" and action in {"architecture", "governance"}: - action = action if route == "grok" else "advisory" + if validated["route"] == "auto" and action == "architecture": + action = "architecture" if route == "grok" else "advisory" + elif validated["route"] == "auto" and action == "governance": + action = { + "gemini": "governance", + "codex": "advisory", + "grok": "governance", + }[route] return policy.issue_policy_envelope( request_id=validated["request_id"], operation=validated["operation"], @@ -344,7 +352,7 @@ def issue_for(route: str, row: dict[str, Any]): return _response( validated["request_id"], "unavailable", - "no eligible independent advisory route", + f"no eligible independent {validated['action']} route", attempts=[], ), 0 attempts: list[dict[str, str]] = [] @@ -391,7 +399,7 @@ def issue_for(route: str, row: dict[str, Any]): return _response( validated["request_id"], "unavailable", - "all eligible advisory routes failed", + f"all eligible {validated['action']} routes failed", attempts=attempts, ), 0 diff --git a/plugins/agent-collab/host_policy.py b/plugins/agent-collab/host_policy.py index fc7b72d..57089f1 100644 --- a/plugins/agent-collab/host_policy.py +++ b/plugins/agent-collab/host_policy.py @@ -32,9 +32,12 @@ PLUGIN_ROOT = Path(__file__).resolve().parent KNOWN_FAMILIES = frozenset({"anthropic", "google", "openai", "xai", "zhipu"}) DEFAULT_OPENCODE_MODEL = "opencode/glm-5.2" +GEMINI_GOVERNANCE_MODEL = "google/gemini-3.1-pro" +GEMINI_GOVERNANCE_EFFORTS = frozenset({"high", "xhigh"}) ROUTE_ACTIONS = frozenset( { ("gemini", "advisory"), + ("gemini", "governance"), ("gemini", "long_context"), ("codex", "advisory"), ("opencode", "plan"), @@ -70,6 +73,7 @@ } AUTHORITIES = { ("gemini", "advisory"): "read_only", + ("gemini", "governance"): "read_only", ("gemini", "long_context"): "read_only", ("codex", "advisory"): "read_only", ("opencode", "plan"): "read_only", @@ -80,7 +84,7 @@ ("composer", "codegen"): "output_only", } GOVERNANCE_CONTRACTS = frozenset( - {("gemini", "advisory"), ("codex", "advisory"), ("grok", "governance")} + {("gemini", "governance"), ("codex", "advisory"), ("grok", "governance")} ) MAX_TIMEOUT_MS = 600_000 MAX_PROMPT_BYTES = 1024 * 1024 @@ -531,6 +535,16 @@ def _validate_row( or (action == "long_context" and not _validate_documents(row["documents"])) ): return None, "unknown", "Gemini row values are invalid" + elif contract == ("gemini", "governance"): + if set(row) != {"model", "effort"}: + return None, "unknown", "Gemini governance row fields are invalid" + if ( + type(row["model"]) is not str + or row["model"] != GEMINI_GOVERNANCE_MODEL + or type(row["effort"]) is not str + or row["effort"] not in GEMINI_GOVERNANCE_EFFORTS + ): + return None, "unknown", "Gemini governance row values are invalid" elif contract == ("codex", "advisory"): allowed = {"model", "effort", "mode", "cwd"} required = {"model", "effort", "mode"} @@ -848,7 +862,7 @@ def issue_policy_envelope( None, "governance authority permits advisory reviewer routes only", ) - if not governance and (route, action) == ("grok", "governance"): + if not governance and action == "governance": return PolicyIssueOutcome( PreflightStatus.CONFIG_ERROR, profile, diff --git a/plugins/agent-collab/runtime-manifest.json b/plugins/agent-collab/runtime-manifest.json index 53c1cbd..d113359 100644 --- a/plugins/agent-collab/runtime-manifest.json +++ b/plugins/agent-collab/runtime-manifest.json @@ -1,6 +1,8 @@ { - "schema_version": 1, + "schema_version": 2, "protocol_version": 1, - "contract_version": 1, + "contract_version": 3, + "broker_protocol_version": 2, + "channel": "production", "artifacts": [] } diff --git a/plugins/agent-collab/runtime-manifest.schema.json b/plugins/agent-collab/runtime-manifest.schema.json index 79452a5..86bc9c9 100644 --- a/plugins/agent-collab/runtime-manifest.schema.json +++ b/plugins/agent-collab/runtime-manifest.schema.json @@ -1,14 +1,23 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://github.com/sumitake/agent-collab/runtime-manifest.schema.json", - "title": "agent-collab signed native runtime manifest", + "title": "agent-collab signed standalone native runtime manifest", "type": "object", "additionalProperties": false, - "required": ["schema_version", "protocol_version", "contract_version", "artifacts"], + "required": [ + "schema_version", + "protocol_version", + "contract_version", + "broker_protocol_version", + "channel", + "artifacts" + ], "properties": { - "schema_version": {"const": 1}, + "schema_version": {"const": 2}, "protocol_version": {"const": 1}, - "contract_version": {"const": 1}, + "contract_version": {"const": 3}, + "broker_protocol_version": {"const": 2}, + "channel": {"const": "production"}, "artifacts": { "type": "array", "maxItems": 1, @@ -16,14 +25,84 @@ "items": { "type": "object", "additionalProperties": false, - "required": ["platform", "arch", "minimum_macos", "path", "size", "sha256", "signing", "contracts"], + "required": [ + "platform", + "arch", + "kind", + "minimum_macos", + "path", + "entrypoint", + "size", + "sha256", + "signing", + "files", + "contracts" + ], "properties": { "platform": {"const": "darwin"}, "arch": {"const": "arm64"}, + "kind": {"const": "standalone_bundle"}, "minimum_macos": {"const": "14.0"}, - "path": {"const": "runtime/darwin-arm64/agent-collab-runtime"}, + "path": {"const": "runtime/darwin-arm64/agent-collab-runtime.bundle"}, + "entrypoint": {"const": "agent-collab-runtime"}, "size": {"type": "integer", "minimum": 1, "maximum": 67108864}, "sha256": {"type": "string", "pattern": "^[0-9a-f]{64}$"}, + "files": { + "type": "array", + "minItems": 1, + "maxItems": 64, + "uniqueItems": true, + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "path", + "role", + "install_mode", + "size", + "sha256", + "macho_type", + "architecture", + "minimum_macos", + "signing_profile" + ], + "properties": { + "path": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "pattern": "^[^/\\\\]+$" + }, + "role": {"enum": ["entrypoint", "runtime_library"]}, + "install_mode": {"const": 320}, + "size": {"type": "integer", "minimum": 0, "maximum": 67108864}, + "sha256": {"type": "string", "pattern": "^[0-9a-f]{64}$"}, + "macho_type": {"enum": ["executable", "dylib", "bundle"]}, + "architecture": {"const": "arm64"}, + "minimum_macos": {"const": "14.0"}, + "signing_profile": {"const": "production_developer_id"} + }, + "allOf": [ + { + "if": {"properties": {"role": {"const": "entrypoint"}}}, + "then": { + "properties": { + "path": {"const": "agent-collab-runtime"}, + "macho_type": {"const": "executable"} + } + } + }, + { + "if": {"properties": {"role": {"const": "runtime_library"}}}, + "then": { + "properties": { + "macho_type": {"enum": ["dylib", "bundle"]} + } + } + } + ] + } + }, "contracts": { "type": "array", "minItems": 1, @@ -31,6 +110,7 @@ "items": { "oneOf": [ {"type": "object", "additionalProperties": false, "required": ["route", "action"], "properties": {"route": {"const": "gemini"}, "action": {"const": "advisory"}}}, + {"type": "object", "additionalProperties": false, "required": ["route", "action"], "properties": {"route": {"const": "gemini"}, "action": {"const": "governance"}}}, {"type": "object", "additionalProperties": false, "required": ["route", "action"], "properties": {"route": {"const": "gemini"}, "action": {"const": "long_context"}}}, {"type": "object", "additionalProperties": false, "required": ["route", "action"], "properties": {"route": {"const": "codex"}, "action": {"const": "advisory"}}}, {"type": "object", "additionalProperties": false, "required": ["route", "action"], "properties": {"route": {"const": "opencode"}, "action": {"const": "plan"}}}, @@ -45,11 +125,24 @@ "signing": { "type": "object", "additionalProperties": false, - "required": ["team_id", "require_notarization", "hardened_runtime"], + "required": [ + "mode", + "identity", + "team_id", + "require_notarization", + "hardened_runtime", + "secure_timestamp" + ], "properties": { + "mode": {"const": "developer_id"}, + "identity": { + "type": "string", + "pattern": "^Developer ID Application: [^\\r\\n]{1,160} \\([A-Z0-9]{10}\\)$" + }, "team_id": {"type": "string", "pattern": "^[A-Z0-9]{10}$"}, "require_notarization": {"const": true}, - "hardened_runtime": {"const": true} + "hardened_runtime": {"const": true}, + "secure_timestamp": {"const": true} } } } diff --git a/plugins/agent-collab/runtime_bundle.py b/plugins/agent-collab/runtime_bundle.py new file mode 100644 index 0000000..f0ae75d --- /dev/null +++ b/plugins/agent-collab/runtime_bundle.py @@ -0,0 +1,462 @@ +"""Closed identity and filesystem verification for the standalone provider bundle.""" + +from __future__ import annotations + +import hashlib +import json +import os +from pathlib import Path +import stat +import struct +from typing import Any, Callable, Dict, Mapping, Sequence, Tuple +import unicodedata + + +class BundleContractError(ValueError): + """Fixed-value sentinel for a closed runtime-bundle contract failure.""" + + +IDENTITY_DOMAIN = b"agent-collab-runtime-bundle\0v1\0" +MAX_BUNDLE_FILES = 64 +MAX_BUNDLE_BYTES = 64 * 1024 * 1024 +MAX_MANIFEST_BYTES = 1024 * 1024 +MAX_PATH_BYTES = 255 +ENTRYPOINT_NAME = "agent-collab-runtime" +INSTALL_MODE = 0o500 +MINIMUM_MACOS = "14.0" + +_FILE_KEYS = frozenset(( + "architecture", + "install_mode", + "macho_type", + "minimum_macos", + "path", + "role", + "sha256", + "signing_profile", + "size", +)) +_INSPECTION_KEYS = frozenset(( + "architecture", "macho_type", "minimum_macos", "signing_profile", +)) +_ROLE_CODES = {"entrypoint": 1, "runtime_library": 2} +_MACHO_TYPE_CODES = {"executable": 1, "dylib": 2, "bundle": 3} +_ARCHITECTURE_CODES = {"arm64": 1} +_SIGNING_PROFILES = frozenset(( + "development_adhoc", "production_developer_id", +)) +_SLASH_CONFUSABLES = frozenset(( + "\\", + "/", + "\u2044", # fraction slash + "\u2215", # division slash + "\u29f5", # reverse solidus operator + "\u29f8", # big solidus + "\ufe68", # small reverse solidus + "\uff0f", # fullwidth solidus + "\uff3c", # fullwidth reverse solidus +)) +_JSON_MAX_DEPTH = 64 +_JSON_MAX_NODES = 100_000 + + +def _raise(message: str) -> None: + raise BundleContractError(message) + + +def _reject_json_constant(_value: str) -> None: + _raise("runtime manifest contains a non-finite number") + + +def _reject_json_float(_value: str) -> None: + _raise("runtime manifest contains a floating-point number") + + +def _closed_object(pairs: Sequence[Tuple[str, Any]]) -> Dict[str, Any]: + value: Dict[str, Any] = {} + for key, child in pairs: + if type(key) is not str or key in value: + _raise("runtime manifest contains a duplicate or invalid key") + value[key] = child + return value + + +def _validate_json_tree(root: Any) -> None: + """Require an exact built-in JSON tree with bounded depth and node count.""" + + stack = [(root, 0)] + seen = 0 + while stack: + value, depth = stack.pop() + seen += 1 + if seen > _JSON_MAX_NODES or depth > _JSON_MAX_DEPTH: + _raise("runtime manifest JSON exceeds its structural bound") + if type(value) is dict: + for key, child in value.items(): + if type(key) is not str: + _raise("runtime manifest contains an invalid key") + _validate_unicode_scalar_text(key, "runtime manifest key") + stack.append((child, depth + 1)) + elif type(value) is list: + for child in value: + stack.append((child, depth + 1)) + elif type(value) is str: + _validate_unicode_scalar_text(value, "runtime manifest string") + elif type(value) in (int, bool) or value is None: + continue + else: + _raise("runtime manifest contains a non-JSON value") + + +def load_closed_json_object(raw: bytes) -> Dict[str, Any]: + """Decode one bounded JSON object with duplicate/non-finite/float rejection.""" + + if type(raw) is not bytes or not raw or len(raw) > MAX_MANIFEST_BYTES: + _raise("runtime manifest bytes are invalid") + try: + text = raw.decode("utf-8") + value = json.loads( + text, + object_pairs_hook=_closed_object, + parse_constant=_reject_json_constant, + parse_float=_reject_json_float, + ) + except BundleContractError: + raise + except (UnicodeError, json.JSONDecodeError, ValueError, TypeError): + raise BundleContractError("runtime manifest JSON is invalid") from None + if type(value) is not dict: + _raise("runtime manifest root is not an object") + _validate_json_tree(value) + return value + + +def _validate_unicode_scalar_text(value: str, label: str) -> None: + if type(value) is not str: + _raise(f"{label} is not a string") + try: + value.encode("utf-8") + except UnicodeError: + raise BundleContractError(f"{label} is not valid UTF-8") from None + if any(0xD800 <= ord(character) <= 0xDFFF for character in value): + _raise(f"{label} contains a surrogate") + + +def _exact_string(value: Any, label: str) -> str: + if type(value) is not str: + _raise(f"runtime bundle {label} is invalid") + _validate_unicode_scalar_text(value, f"runtime bundle {label}") + return value + + +def _exact_integer(value: Any, label: str) -> int: + if type(value) is not int: + _raise(f"runtime bundle {label} is invalid") + return value + + +def _validate_path(value: Any) -> Tuple[str, bytes, str]: + path = _exact_string(value, "path") + if not path or path in (".", "..") or unicodedata.normalize("NFC", path) != path: + _raise("runtime bundle path is not normalized") + if any(character in _SLASH_CONFUSABLES for character in path): + _raise("runtime bundle path contains a separator or confusable") + if any(unicodedata.category(character).startswith("C") for character in path): + _raise("runtime bundle path contains a control character") + try: + encoded = path.encode("utf-8") + except UnicodeError: + raise BundleContractError("runtime bundle path is not valid UTF-8") from None + if not encoded or len(encoded) > MAX_PATH_BYTES: + _raise("runtime bundle path length is invalid") + representation = unicodedata.normalize("NFKC", path).casefold() + return path, encoded, representation + + +def _validate_digest(value: Any) -> str: + digest = _exact_string(value, "SHA-256") + if len(digest) != 64 or any(character not in "0123456789abcdef" for character in digest): + _raise("runtime bundle SHA-256 is invalid") + return digest + + +def validate_file_records(records: Any) -> Tuple[Dict[str, Any], ...]: + """Validate the closed file-record array without accepting coercions.""" + + if type(records) not in (list, tuple): + _raise("runtime bundle files are not a closed array") + if not records or len(records) > MAX_BUNDLE_FILES: + _raise("runtime bundle file count is invalid") + + normalized = [] + expected_sort = [] + representations = set() + total_size = 0 + entrypoints = 0 + selected_profile = None + + for record in records: + if type(record) is not dict or frozenset(record) != _FILE_KEYS: + _raise("runtime bundle file record schema is invalid") + path, path_bytes, representation = _validate_path(record["path"]) + if representation in representations: + _raise("runtime bundle has a duplicate path representation") + representations.add(representation) + expected_sort.append(path_bytes) + + role = _exact_string(record["role"], "role") + if role not in _ROLE_CODES: + _raise("runtime bundle role is invalid") + mode = _exact_integer(record["install_mode"], "install mode") + if mode != INSTALL_MODE: + _raise("runtime bundle install mode is invalid") + size = _exact_integer(record["size"], "size") + if size < 0 or size > MAX_BUNDLE_BYTES: + _raise("runtime bundle file size is invalid") + total_size += size + if total_size > MAX_BUNDLE_BYTES: + _raise("runtime bundle installed size exceeds its budget") + digest = _validate_digest(record["sha256"]) + macho_type = _exact_string(record["macho_type"], "Mach-O type") + architecture = _exact_string(record["architecture"], "architecture") + minimum_macos = _exact_string(record["minimum_macos"], "minimum macOS") + profile = _exact_string(record["signing_profile"], "signing profile") + + if role == "entrypoint": + entrypoints += 1 + if path != ENTRYPOINT_NAME or macho_type != "executable": + _raise("runtime bundle entrypoint record is invalid") + elif macho_type not in ("dylib", "bundle"): + _raise("runtime bundle library record is invalid") + if architecture != "arm64": + _raise("runtime bundle architecture is invalid") + if minimum_macos != MINIMUM_MACOS: + _raise("runtime bundle minimum macOS is invalid") + if profile not in _SIGNING_PROFILES: + _raise("runtime bundle signing profile is invalid") + if selected_profile is None: + selected_profile = profile + elif profile != selected_profile: + _raise("runtime bundle signing profiles are inconsistent") + + normalized.append({ + "path": path, + "role": role, + "install_mode": mode, + "size": size, + "sha256": digest, + "macho_type": macho_type, + "architecture": architecture, + "minimum_macos": minimum_macos, + "signing_profile": profile, + }) + + if entrypoints != 1: + _raise("runtime bundle must contain exactly one entrypoint") + if expected_sort != sorted(expected_sort): + _raise("runtime bundle files are not sorted by exact UTF-8 path bytes") + return tuple(normalized) + + +def encode_bundle_identity(records: Any) -> bytes: + """Encode the validated records using the contract-v3 identity grammar.""" + + validated = validate_file_records(records) + encoded = bytearray(IDENTITY_DOMAIN) + encoded.extend(struct.pack(">I", len(validated))) + for record in validated: + path = record["path"].encode("utf-8") + encoded.extend(struct.pack(">I", len(path))) + encoded.extend(path) + encoded.extend(struct.pack(">B", _ROLE_CODES[record["role"]])) + encoded.extend(struct.pack(">H", record["install_mode"])) + encoded.extend(struct.pack(">Q", record["size"])) + encoded.extend(bytes.fromhex(record["sha256"])) + encoded.extend(struct.pack(">B", _MACHO_TYPE_CODES[record["macho_type"]])) + encoded.extend(struct.pack(">B", _ARCHITECTURE_CODES[record["architecture"]])) + return bytes(encoded) + + +def compute_bundle_identity(records: Any) -> str: + return hashlib.sha256(encode_bundle_identity(records)).hexdigest() + + +def _stat_identity(info: os.stat_result) -> Tuple[int, int, int, int, int, int, int]: + return ( + info.st_dev, + info.st_ino, + info.st_mode, + info.st_nlink, + info.st_size, + info.st_mtime_ns, + info.st_ctime_ns, + ) + + +def _hash_descriptor(descriptor: int, expected_size: int) -> str: + try: + os.lseek(descriptor, 0, os.SEEK_SET) + except OSError: + raise BundleContractError("runtime bundle member is not seekable") from None + digest = hashlib.sha256() + remaining = expected_size + while remaining: + try: + chunk = os.read(descriptor, min(remaining, 1024 * 1024)) + except OSError: + raise BundleContractError("runtime bundle member could not be read") from None + if not chunk: + _raise("runtime bundle member changed size") + digest.update(chunk) + remaining -= len(chunk) + try: + if os.read(descriptor, 1): + _raise("runtime bundle member changed size") + except OSError: + raise BundleContractError("runtime bundle member could not be read") from None + return digest.hexdigest() + + +def _validate_inspection(value: Any, record: Mapping[str, Any]) -> None: + if type(value) is not dict or frozenset(value) != _INSPECTION_KEYS: + _raise("runtime bundle member inspection schema is invalid") + for key in _INSPECTION_KEYS: + if type(value[key]) is not str or value[key] != record[key]: + _raise("runtime bundle member inspection changed") + + +def verify_bundle_tree( + root: Path, + records: Any, + *, + inspector: Callable[[Path], Dict[str, str]], +) -> str: + """Verify one closed standalone bundle without following filesystem links.""" + + if not isinstance(root, Path) or not callable(inspector): + _raise("runtime bundle verifier arguments are invalid") + validated = validate_file_records(records) + expected_names = tuple(record["path"] for record in validated) + by_name = {record["path"]: record for record in validated} + flags = os.O_RDONLY | getattr(os, "O_DIRECTORY", 0) | getattr(os, "O_NOFOLLOW", 0) + flags |= getattr(os, "O_CLOEXEC", 0) + + try: + root_descriptor = os.open(root, flags) + except OSError: + raise BundleContractError("runtime bundle root is invalid") from None + try: + try: + root_before = os.fstat(root_descriptor) + lexical_root = root.lstat() + except OSError: + raise BundleContractError("runtime bundle root is invalid") from None + if ( + not stat.S_ISDIR(root_before.st_mode) + or stat.S_ISLNK(lexical_root.st_mode) + or _stat_identity(root_before) != _stat_identity(lexical_root) + or root_before.st_uid != os.geteuid() + or stat.S_IMODE(root_before.st_mode) != INSTALL_MODE + ): + _raise("runtime bundle root is unsafe") + try: + names = os.listdir(root_descriptor) + except OSError: + raise BundleContractError("runtime bundle could not be enumerated") from None + if any(type(name) is not str for name in names): + _raise("runtime bundle member name is invalid") + if tuple(sorted(names, key=lambda name: name.encode("utf-8"))) != expected_names: + _raise("runtime bundle membership changed") + + for name in expected_names: + record = by_name[name] + member_flags = os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0) + member_flags |= getattr(os, "O_CLOEXEC", 0) + try: + descriptor = os.open(name, member_flags, dir_fd=root_descriptor) + except OSError: + raise BundleContractError("runtime bundle member is invalid") from None + try: + try: + before = os.fstat(descriptor) + lexical_before = os.stat( + name, dir_fd=root_descriptor, follow_symlinks=False, + ) + except OSError: + raise BundleContractError("runtime bundle member is invalid") from None + if ( + not stat.S_ISREG(before.st_mode) + or stat.S_ISLNK(lexical_before.st_mode) + or before.st_uid != os.geteuid() + or before.st_nlink != 1 + or stat.S_IMODE(before.st_mode) != record["install_mode"] + or before.st_size != record["size"] + or _stat_identity(before) != _stat_identity(lexical_before) + ): + _raise("runtime bundle member metadata changed") + if _hash_descriptor(descriptor, record["size"]) != record["sha256"]: + _raise("runtime bundle member digest changed") + + member_path = root / name + try: + inspection = inspector(member_path) + except BundleContractError: + raise + except Exception: + raise BundleContractError( + "runtime bundle member inspection failed" + ) from None + _validate_inspection(inspection, record) + + try: + after = os.fstat(descriptor) + lexical_after = os.stat( + name, dir_fd=root_descriptor, follow_symlinks=False, + ) + except OSError: + raise BundleContractError("runtime bundle member changed") from None + if ( + _stat_identity(after) != _stat_identity(before) + or _stat_identity(lexical_after) != _stat_identity(before) + or _hash_descriptor(descriptor, record["size"]) != record["sha256"] + ): + _raise("runtime bundle member changed during inspection") + finally: + try: + os.close(descriptor) + except OSError: + pass + + try: + root_after = os.fstat(root_descriptor) + final_names = os.listdir(root_descriptor) + except OSError: + raise BundleContractError("runtime bundle root changed") from None + if ( + _stat_identity(root_after) != _stat_identity(root_before) + or tuple(sorted(final_names, key=lambda name: name.encode("utf-8"))) + != expected_names + ): + _raise("runtime bundle root changed during verification") + return compute_bundle_identity(validated) + finally: + try: + os.close(root_descriptor) + except OSError: + pass + + +__all__ = [ + "BundleContractError", + "ENTRYPOINT_NAME", + "IDENTITY_DOMAIN", + "INSTALL_MODE", + "MAX_BUNDLE_BYTES", + "MAX_BUNDLE_FILES", + "MINIMUM_MACOS", + "compute_bundle_identity", + "encode_bundle_identity", + "load_closed_json_object", + "validate_file_records", + "verify_bundle_tree", +] diff --git a/plugins/agent-collab/runtime_client.py b/plugins/agent-collab/runtime_client.py index acefda9..6ce7cf2 100644 --- a/plugins/agent-collab/runtime_client.py +++ b/plugins/agent-collab/runtime_client.py @@ -15,6 +15,7 @@ import base64 from contextlib import contextmanager import hashlib +import hmac import importlib.util import json import math @@ -46,14 +47,15 @@ PLUGIN_ROOT = Path(__file__).resolve().parent MANIFEST_NAME = "runtime-manifest.json" PROTOCOL_VERSION = 1 -CONTRACT_VERSION = 1 +CONTRACT_VERSION = 3 MAX_REQUEST_BYTES = 48 * 1024 * 1024 MAX_RESPONSE_BYTES = 4 * 1024 * 1024 MAX_ARTIFACT_BYTES = 64 * 1024 * 1024 MAX_TIMEOUT_MS = 600_000 -BROKER_PROTOCOL_VERSION = 1 +BROKER_PROTOCOL_VERSION = 2 BROKER_LABEL = "com.agent-collab.provider-broker" BROKER_SOCKET_NAME = "ProviderBroker" +BROKER_SOCKET_FILENAME = "provider-broker.sock" BROKER_FRAME_KEYS = frozenset( { "broker_protocol_version", @@ -66,7 +68,7 @@ "request", } ) -BROKERED_ROUTES = frozenset({"opencode", "gemini", "grok", "composer"}) +BROKERED_ROUTES = frozenset({"codex", "opencode", "gemini", "grok", "composer"}) BROKER_MAX_REQUEST_BYTES = MAX_REQUEST_BYTES BROKER_MAX_RESPONSE_BYTES = MAX_RESPONSE_BYTES BROKER_STATE_MAX_BYTES = 64 * 1024 @@ -75,9 +77,13 @@ BROKER_STATE_KEYS = frozenset( { "schema_version", + "contract_version", + "broker_protocol_version", + "runtime_protocol_version", "artifact_sha256", "manifest_sha256", - "runtime_path", + "bundle_path", + "entrypoint_path", "manifest_path", "plist_sha256", "socket_path", @@ -88,6 +94,9 @@ _BROKER_HEADER = struct.Struct(">Q") _SHA256_RE = re.compile(r"^[0-9a-f]{64}$") _TEAM_ID_RE = re.compile(r"^[A-Z0-9]{10}$") +_DEVELOPER_ID_RE = re.compile( + r"^Developer ID Application: [^\r\n]{1,160} \(([A-Z0-9]{10})\)$" +) _CODESIGN_FLAGS_RE = re.compile(r"\bflags=(0x[0-9a-f]+)(?:\([^)]*\))?", re.IGNORECASE) _CODESIGN_TIMESTAMP_RE = re.compile(r"(?m)^Timestamp=(.+)$") _CODESIGN_TEAM_RE = re.compile(r"(?m)^TeamIdentifier=([A-Z0-9]{10})(?=\s|$)") @@ -104,6 +113,21 @@ MANAGEMENT_ACTIONS = frozenset({"status", "prepare", "grok_login"}) +def _load_runtime_bundle(): + name = "agent_collab_runtime_bundle" + existing = sys.modules.get(name) + if existing is not None: + return existing + path = PLUGIN_ROOT / "runtime_bundle.py" + spec = importlib.util.spec_from_file_location(name, path) + if spec is None or spec.loader is None: + raise RuntimeError("runtime bundle verifier cannot be loaded") + module = importlib.util.module_from_spec(spec) + sys.modules[name] = module + spec.loader.exec_module(module) + return module + + def _load_signing_policy(): name = "agent_collab_signing_policy" existing = sys.modules.get(name) @@ -123,9 +147,11 @@ def _load_signing_policy(): EXPECTED_DEVELOPER_ID_TEAM = _load_signing_policy().EXPECTED_DEVELOPER_ID_TEAM except (AttributeError, OSError, RuntimeError, ValueError): EXPECTED_DEVELOPER_ID_TEAM = "" +runtime_bundle = _load_runtime_bundle() SUPPORTED_CONTRACTS = frozenset( { ("gemini", "advisory"), + ("gemini", "governance"), ("gemini", "long_context"), ("codex", "advisory"), ("opencode", "plan"), @@ -140,6 +166,40 @@ def _load_signing_policy(): "grok": "xai/grok-4.5", "composer": "xai/grok-composer-2.5-fast", } +GEMINI_GOVERNANCE_MODEL = "google/gemini-3.1-pro" +GEMINI_GOVERNANCE_DISPLAY = "Gemini 3.1 Pro (High)" +GEMINI_GOVERNANCE_RUNTIME_VERSION = "1.2.0" +GEMINI_GOVERNANCE_CONTAINMENT = "write_contained_shared_home" +GEMINI_GOVERNANCE_PROOF_KEYS = frozenset( + { + "version", + "request_id", + "action", + "authority", + "transport", + "backend", + "runtime_version", + "contract_version", + "artifact_sha256", + "artifact_author_model", + "artifact_author_family", + "reviewer_model", + "reviewer_family", + "selected_display", + "effective_effort", + "containment_level", + "tools_disabled", + "pty_used", + "lock_acquired", + "cleanup_confirmed", + "provider_process_started", + "returncode", + "model_source", + "failed_over", + "response_sha256", + "proof_sha256", + } +) TEMPORARILY_UNAVAILABLE_CONTRACTS = { ("codex", "build"): "Codex build is unavailable until a hardened mutation backend exists" } @@ -197,6 +257,8 @@ class FileIdentity: class RuntimeResolution: status: RuntimeStatus path: Path | None = None + bundle_path: Path | None = None + files: tuple[Mapping[str, Any], ...] = () contracts: frozenset[tuple[str, str]] = frozenset() manifest_digest: str = "" artifact_digest: str = "" @@ -331,13 +393,17 @@ def _manifest_entry(data: object) -> tuple[dict[str, Any] | None, str]: "schema_version", "protocol_version", "contract_version", + "broker_protocol_version", + "channel", "artifacts", }: return None, "manifest root shape is invalid" if ( - not _exact_int(data["schema_version"], 1) + not _exact_int(data["schema_version"], 2) or not _exact_int(data["protocol_version"], PROTOCOL_VERSION) or not _exact_int(data["contract_version"], CONTRACT_VERSION) + or not _exact_int(data["broker_protocol_version"], BROKER_PROTOCOL_VERSION) + or data["channel"] != "production" ): return None, "manifest or protocol version is unsupported" artifacts = data["artifacts"] @@ -349,34 +415,68 @@ def _manifest_entry(data: object) -> tuple[dict[str, Any] | None, str]: if not isinstance(item, dict) or set(item) != { "platform", "arch", + "kind", "minimum_macos", "path", + "entrypoint", "size", "sha256", "signing", + "files", "contracts", }: return None, "artifact shape is invalid" signing = item.get("signing") contracts = _contracts(item.get("contracts")) - expected_path = "runtime/darwin-arm64/agent-collab-runtime" + expected_path = "runtime/darwin-arm64/agent-collab-runtime.bundle" + try: + records = runtime_bundle.validate_file_records(item.get("files")) + bundle_digest = runtime_bundle.compute_bundle_identity(records) + except runtime_bundle.BundleContractError: + return None, "artifact file records are invalid" + identity_value = signing.get("identity") if isinstance(signing, dict) else None + identity_match = ( + _DEVELOPER_ID_RE.fullmatch(identity_value) + if isinstance(identity_value, str) + else None + ) if ( item.get("platform") != "darwin" or item.get("arch") != "arm64" - or item.get("minimum_macos") != "14.0" + or item.get("kind") != "standalone_bundle" + or item.get("minimum_macos") != EXPECTED_MINIMUM_MACOS or item.get("path") != expected_path + or item.get("entrypoint") != runtime_bundle.ENTRYPOINT_NAME or type(item.get("size")) is not int or not 1 <= item["size"] <= MAX_ARTIFACT_BYTES + or item["size"] != sum(record["size"] for record in records) or not isinstance(item.get("sha256"), str) or not _SHA256_RE.fullmatch(item["sha256"]) + or bundle_digest != item["sha256"] or not isinstance(signing, dict) - or set(signing) != {"team_id", "require_notarization", "hardened_runtime"} + or set(signing) + != { + "mode", + "identity", + "team_id", + "require_notarization", + "hardened_runtime", + "secure_timestamp", + } + or signing.get("mode") != "developer_id" + or identity_match is None or not isinstance(signing.get("team_id"), str) or not _TEAM_ID_RE.fullmatch(signing["team_id"]) + or identity_match.group(1) != signing["team_id"] or not _TEAM_ID_RE.fullmatch(EXPECTED_DEVELOPER_ID_TEAM) or signing["team_id"] != EXPECTED_DEVELOPER_ID_TEAM or signing.get("require_notarization") is not True or signing.get("hardened_runtime") is not True + or signing.get("secure_timestamp") is not True + or any( + record["signing_profile"] != "production_developer_id" + for record in records + ) or contracts is None ): return None, "artifact fields are invalid" @@ -385,7 +485,9 @@ def _manifest_entry(data: object) -> tuple[dict[str, Any] | None, str]: return None, "manifest contains duplicate host artifacts" seen_hosts.add(host_key) if host_key == ("darwin", "arm64"): - selected.append({**item, "_contracts": contracts}) + selected.append( + {**item, "_contracts": contracts, "_files": records} + ) if not selected: return {}, "runtime artifact is not packaged for this host" if len(selected) != 1: @@ -395,7 +497,9 @@ def _manifest_entry(data: object) -> tuple[dict[str, Any] | None, str]: def _path_beneath_root(root: Path, rel: str) -> Path | None: pure = PurePosixPath(rel) - expected = PurePosixPath("runtime", "darwin-arm64", "agent-collab-runtime") + expected = PurePosixPath( + "runtime", "darwin-arm64", "agent-collab-runtime.bundle" + ) if pure != expected or pure.is_absolute() or ".." in pure.parts: return None try: @@ -419,7 +523,12 @@ def _path_beneath_root(root: Path, rel: str) -> Path | None: def _verify_macos_signature( - path: Path, *, team_id: str, require_notarization: bool + path: Path, + *, + team_id: str, + require_notarization: bool, + identity: str = "", + secure_timestamp: bool = True, ) -> tuple[bool, str]: if ( not _TEAM_ID_RE.fullmatch(EXPECTED_DEVELOPER_ID_TEAM) @@ -445,6 +554,9 @@ def _verify_macos_signature( team_ids = _CODESIGN_TEAM_RE.findall(detail_output) if team_ids != [team_id]: return False, "macOS signing identity does not match" + authorities = re.findall(r"(?m)^Authority=(.+)$", detail_output) + if identity and (not authorities or authorities[0] != identity): + return False, "macOS Developer ID identity does not match" hardened = any( int(match.group(1), 16) & 0x10000 for match in _CODESIGN_FLAGS_RE.finditer(detail_output) @@ -452,12 +564,11 @@ def _verify_macos_signature( if not hardened: return False, "macOS hardened runtime flag is missing" timestamp_match = _CODESIGN_TIMESTAMP_RE.search(detail_output) - if timestamp_match is None or timestamp_match.group(1).strip().casefold() in { - "", - "none", - "not set", - "unsigned", - }: + if secure_timestamp and ( + timestamp_match is None + or timestamp_match.group(1).strip().casefold() + in {"", "none", "not set", "unsigned"} + ): return False, "macOS code signature is missing a secure Timestamp" if require_notarization: result = subprocess.run( @@ -476,6 +587,103 @@ def _verify_macos_signature( return True, "" +class _RuntimeSignatureError(runtime_bundle.BundleContractError): + """Preserve signature failures through the closed bundle verifier.""" + + +def _encoded_macos_version(value: int) -> str: + if not _exact_int(value) or value < 0: + raise ValueError("runtime Mach-O minimum version is invalid") + major = (value >> 16) & 0xFFFF + minor = (value >> 8) & 0xFF + patch = value & 0xFF + return f"{major}.{minor}" if patch == 0 else f"{major}.{minor}.{patch}" + + +def _inspect_runtime_member( + path: Path, + record: Mapping[str, Any], + *, + signing: Mapping[str, Any], +) -> dict[str, str]: + """Inspect one exact thin-arm64 Mach-O member and its production signature.""" + + flags = os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_CLOEXEC", 0) + descriptor = os.open(path, flags) + try: + info = os.fstat(descriptor) + header_raw = os.read(descriptor, 32) + if len(header_raw) != 32: + raise ValueError("runtime Mach-O header is truncated") + ( + magic, + cpu_type, + _subtype, + file_type, + command_count, + command_bytes, + _flags, + _reserved, + ) = struct.unpack(" len(commands): + raise ValueError("runtime Mach-O command is truncated") + command, size = struct.unpack_from(" len(commands): + raise ValueError("runtime Mach-O command is invalid") + if command == 0x32: + if size < 24: + raise ValueError("runtime build-version command is truncated") + _cmd, _size, platform_id, minimum, _sdk, tool_count = struct.unpack_from( + " tuple[int, ...] | None: if not _VERSION_RE.fullmatch(value): return None @@ -576,8 +784,8 @@ def resolve_runtime() -> RuntimeResolution: return RuntimeResolution(RuntimeStatus.PATH_INVALID, error="runtime manifest is unsafe") manifest_digest = hashlib.sha256(raw).hexdigest() try: - data = json.loads(raw.decode("utf-8")) - except (UnicodeError, ValueError, RecursionError): + data = runtime_bundle.load_closed_json_object(raw) + except runtime_bundle.BundleContractError: return RuntimeResolution(RuntimeStatus.MANIFEST_INVALID, error="runtime manifest unreadable") entry, error = _manifest_entry(data) if entry is None: @@ -590,36 +798,41 @@ def resolve_runtime() -> RuntimeResolution: manifest_digest=manifest_digest, error="this release supports Darwin arm64 only", ) - path = _path_beneath_root(root, entry["path"]) - if path is None: + bundle_path = _path_beneath_root(root, entry["path"]) + if bundle_path is None: return RuntimeResolution(RuntimeStatus.PATH_INVALID, manifest_digest=manifest_digest, error="runtime path is unsafe") - identity = _safe_file_identity(path, executable=True) + entrypoint = bundle_path / entry["entrypoint"] + identity = _safe_file_identity(entrypoint, executable=True) if identity is None: - if not path.exists(): + if not entrypoint.exists() or not bundle_path.exists(): return RuntimeResolution(RuntimeStatus.UNAVAILABLE, manifest_digest=manifest_digest, error="runtime artifact absent") return RuntimeResolution(RuntimeStatus.PATH_INVALID, manifest_digest=manifest_digest, error="runtime ownership, mode, or link count is unsafe") - if identity.size != entry["size"]: - return RuntimeResolution(RuntimeStatus.INTEGRITY_ERROR, manifest_digest=manifest_digest, error="runtime size mismatch") - digest = _sha256_regular(path, identity) - if digest is None or digest != entry["sha256"]: - return RuntimeResolution(RuntimeStatus.INTEGRITY_ERROR, manifest_digest=manifest_digest, error="runtime digest mismatch") try: - valid, detail = _verify_macos_signature( - path, - team_id=entry["signing"]["team_id"], - require_notarization=entry["signing"]["require_notarization"], + by_name = {record["path"]: record for record in entry["_files"]} + digest = runtime_bundle.verify_bundle_tree( + bundle_path, + entry["_files"], + inspector=lambda member: _inspect_runtime_member( + member, + by_name[member.name], + signing=entry["signing"], + ), ) - except (OSError, subprocess.SubprocessError): - return RuntimeResolution(RuntimeStatus.HOST_BLOCKED, manifest_digest=manifest_digest, error="signature tools unavailable") - if not valid: - return RuntimeResolution(RuntimeStatus.SIGNATURE_ERROR, manifest_digest=manifest_digest, error=detail) + except _RuntimeSignatureError as exc: + return RuntimeResolution(RuntimeStatus.SIGNATURE_ERROR, manifest_digest=manifest_digest, error=str(exc)) + except runtime_bundle.BundleContractError as exc: + return RuntimeResolution(RuntimeStatus.INTEGRITY_ERROR, manifest_digest=manifest_digest, error=str(exc)) + if digest != entry["sha256"]: + return RuntimeResolution(RuntimeStatus.INTEGRITY_ERROR, manifest_digest=manifest_digest, error="runtime bundle identity mismatch") # Close the verification-to-exec window as far as path-based macOS exec # permits by recording the exact identity that invoke() must recheck. - if _safe_file_identity(path, executable=True) != identity: + if _safe_file_identity(entrypoint, executable=True) != identity: return RuntimeResolution(RuntimeStatus.INTEGRITY_ERROR, manifest_digest=manifest_digest, error="runtime identity changed during verification") return RuntimeResolution( RuntimeStatus.OK, - path=path, + path=entrypoint, + bundle_path=bundle_path, + files=entry["_files"], contracts=entry["_contracts"], manifest_digest=manifest_digest, artifact_digest=digest, @@ -697,7 +910,14 @@ def _broker_record_valid(document: object, root: Path, *, allow_previous: bool) if not isinstance(document, dict) or set(document) != BROKER_STATE_KEYS: return False if ( - not _exact_int(document.get("schema_version"), 1) + not _exact_int(document.get("schema_version"), 2) + or not _exact_int(document.get("contract_version"), CONTRACT_VERSION) + or not _exact_int( + document.get("broker_protocol_version"), BROKER_PROTOCOL_VERSION + ) + or not _exact_int( + document.get("runtime_protocol_version"), PROTOCOL_VERSION + ) or not isinstance(document.get("artifact_sha256"), str) or not _SHA256_RE.fullmatch(document["artifact_sha256"]) or not isinstance(document.get("manifest_sha256"), str) @@ -713,9 +933,12 @@ def _broker_record_valid(document: object, root: Path, *, allow_previous: bool) manifest_digest=document["manifest_sha256"], ) expected_paths = { - "runtime_path": expected_version / "agent-collab-runtime", + "bundle_path": expected_version / "agent-collab-runtime.bundle", + "entrypoint_path": expected_version + / "agent-collab-runtime.bundle" + / runtime_bundle.ENTRYPOINT_NAME, "manifest_path": expected_version / MANIFEST_NAME, - "socket_path": root / "provider.sock", + "socket_path": root / BROKER_SOCKET_FILENAME, } for key, expected in expected_paths.items(): if not isinstance(document.get(key), str) or document[key] != str(expected): @@ -1023,7 +1246,7 @@ def _ensure_private_directory(path: Path, *, mode: int) -> None: def _ensure_broker_layout() -> Path: root = _broker_root() - if len(os.fsencode(root / "provider.sock")) > BROKER_SUN_PATH_MAX_BYTES: + if len(os.fsencode(root / BROKER_SOCKET_FILENAME)) > BROKER_SUN_PATH_MAX_BYTES: raise ValueError("provider broker socket path is too long") parent = root.parent if not parent.exists(): @@ -1119,46 +1342,63 @@ def _broker_version_path( def _verify_published_version( root: Path, *, artifact_digest: str, manifest_digest: str -) -> tuple[Path, Path]: +) -> tuple[Path, Path, Path]: version = _broker_version_path( root, artifact_digest=artifact_digest, manifest_digest=manifest_digest, ) - runtime = version / "agent-collab-runtime" + bundle = version / "agent-collab-runtime.bundle" + entrypoint = bundle / runtime_bundle.ENTRYPOINT_NAME manifest = version / MANIFEST_NAME if _exact_mode(version, expected_type=stat.S_IFDIR, mode=0o500) is None: raise ValueError("provider broker version directory is unsafe") - runtime_identity = _exact_mode(runtime, expected_type=stat.S_IFREG, mode=0o500) manifest_identity = _exact_mode(manifest, expected_type=stat.S_IFREG, mode=0o400) - if runtime_identity is None or manifest_identity is None: + if manifest_identity is None: raise ValueError("provider broker version files are unsafe") - observed_runtime = _sha256_regular( - runtime, - FileIdentity( - device=runtime_identity.st_dev, - inode=runtime_identity.st_ino, - size=runtime_identity.st_size, - mtime_ns=runtime_identity.st_mtime_ns, - mode=runtime_identity.st_mode, - uid=runtime_identity.st_uid, - links=runtime_identity.st_nlink, - ), - ) raw_manifest, _identity = _read_regular_nofollow(manifest, limit=1024 * 1024) - if ( - observed_runtime != artifact_digest - or raw_manifest is None - or hashlib.sha256(raw_manifest).hexdigest() != manifest_digest - ): + if raw_manifest is None or hashlib.sha256(raw_manifest).hexdigest() != manifest_digest: raise ValueError("provider broker version digest mismatch") - return runtime, manifest + try: + document = runtime_bundle.load_closed_json_object(raw_manifest) + entry, error = _manifest_entry(document) + except runtime_bundle.BundleContractError as exc: + raise ValueError("provider broker manifest is invalid") from exc + if entry is None or entry == {} or entry["sha256"] != artifact_digest: + raise ValueError(error or "provider broker manifest identity mismatch") + try: + members = sorted(item.name for item in version.iterdir()) + except OSError as exc: + raise ValueError("provider broker version cannot be enumerated") from exc + if members != ["agent-collab-runtime.bundle", MANIFEST_NAME]: + raise ValueError("provider broker version membership changed") + by_name = {record["path"]: record for record in entry["_files"]} + try: + observed = runtime_bundle.verify_bundle_tree( + bundle, + entry["_files"], + inspector=lambda member: _inspect_runtime_member( + member, + by_name[member.name], + signing=entry["signing"], + ), + ) + except runtime_bundle.BundleContractError as exc: + raise ValueError("provider broker bundle identity mismatch") from exc + if observed != artifact_digest or _safe_file_identity(entrypoint, executable=True) is None: + raise ValueError("provider broker bundle identity mismatch") + return bundle, entrypoint, manifest def _publish_broker_version( root: Path, *, resolution: RuntimeResolution -) -> tuple[Path, Path]: - if resolution.path is None or resolution.identity is None: +) -> tuple[Path, Path, Path]: + if ( + resolution.path is None + or resolution.bundle_path is None + or resolution.identity is None + or not resolution.files + ): raise ValueError("verified provider runtime is unavailable") if _safe_file_identity(resolution.path, executable=True) != resolution.identity: raise ValueError("verified provider runtime identity changed") @@ -1176,12 +1416,17 @@ def _publish_broker_version( staging = root / "tmp" / f"version-{resolution.artifact_digest}-{os.urandom(8).hex()}" staging.mkdir(mode=0o700) try: - _copy_regular_nofollow( - resolution.path, - staging / "agent-collab-runtime", - limit=MAX_ARTIFACT_BYTES, - mode=0o500, - ) + staged_bundle = staging / "agent-collab-runtime.bundle" + staged_bundle.mkdir(mode=0o700) + for record in resolution.files: + _copy_regular_nofollow( + resolution.bundle_path / record["path"], + staged_bundle / record["path"], + limit=MAX_ARTIFACT_BYTES, + mode=runtime_bundle.INSTALL_MODE, + ) + _fsync_directory(staged_bundle) + os.chmod(staged_bundle, runtime_bundle.INSTALL_MODE) _copy_regular_nofollow( PLUGIN_ROOT / MANIFEST_NAME, staging / MANIFEST_NAME, @@ -1196,7 +1441,13 @@ def _publish_broker_version( if staging.exists(): os.chmod(staging, 0o700) for child in staging.iterdir(): - child.unlink() + if child.is_dir() and not child.is_symlink(): + child.chmod(0o700) + for member in child.iterdir(): + member.unlink() + child.rmdir() + else: + child.unlink() staging.rmdir() raise return _verify_published_version( @@ -1369,13 +1620,19 @@ def _record_for( manifest_digest=manifest_digest, ) return { - "schema_version": 1, + "schema_version": 2, + "contract_version": CONTRACT_VERSION, + "broker_protocol_version": BROKER_PROTOCOL_VERSION, + "runtime_protocol_version": PROTOCOL_VERSION, "artifact_sha256": artifact_digest, "manifest_sha256": manifest_digest, - "runtime_path": str(version / "agent-collab-runtime"), + "bundle_path": str(version / "agent-collab-runtime.bundle"), + "entrypoint_path": str( + version / "agent-collab-runtime.bundle" / runtime_bundle.ENTRYPOINT_NAME + ), "manifest_path": str(version / MANIFEST_NAME), "plist_sha256": plist_digest, - "socket_path": str(root / "provider.sock"), + "socket_path": str(root / BROKER_SOCKET_FILENAME), "label": BROKER_LABEL, "previous": None if previous is None else _without_previous(previous), } @@ -1392,8 +1649,8 @@ def _verify_plist_against_state(root: Path, state: Mapping[str, Any]) -> None: except (plistlib.InvalidFileException, ValueError) as exc: raise ValueError("provider broker plist is malformed") from exc expected = _broker_plist_document( - runtime_path=Path(state["runtime_path"]), - socket_path=root / "provider.sock", + runtime_path=Path(state["entrypoint_path"]), + socket_path=root / BROKER_SOCKET_FILENAME, tmpdir=root / "tmp", home=Path(_operator_home() or ""), uid=os.getuid(), @@ -1411,7 +1668,7 @@ def _activate_broker_record( next_previous: Mapping[str, Any] | None, restore_state: Mapping[str, Any] | None, ) -> RuntimeResult: - runtime, _manifest = _verify_published_version( + _bundle, runtime, _manifest = _verify_published_version( root, artifact_digest=target_artifact, manifest_digest=target_manifest, @@ -1421,7 +1678,7 @@ def _activate_broker_record( return RuntimeResult(RuntimeStatus.CONFIG_ERROR, error="operator home is unavailable") plist_document = _broker_plist_document( runtime_path=runtime, - socket_path=root / "provider.sock", + socket_path=root / BROKER_SOCKET_FILENAME, tmpdir=root / "tmp", home=Path(home), uid=os.getuid(), @@ -1448,7 +1705,7 @@ def _activate_broker_record( raise RuntimeError("provider broker could not be bootstrapped") if not _broker_job_loaded(): raise RuntimeError("provider broker launchd identity was not observed") - socket_path = root / "provider.sock" + socket_path = root / BROKER_SOCKET_FILENAME if _exact_mode(socket_path, expected_type=stat.S_IFSOCK, mode=0o600) is None: raise RuntimeError("provider broker socket identity was not observed") if not _broker_ping(socket_path): @@ -1475,14 +1732,14 @@ def _activate_broker_record( try: _bootout_broker(plist_path) if restore_record is not None: - prior_runtime, _prior_manifest = _verify_published_version( + _prior_bundle, prior_runtime, _prior_manifest = _verify_published_version( root, artifact_digest=restore_record["artifact_sha256"], manifest_digest=restore_record["manifest_sha256"], ) prior_document = _broker_plist_document( runtime_path=prior_runtime, - socket_path=root / "provider.sock", + socket_path=root / BROKER_SOCKET_FILENAME, tmpdir=root / "tmp", home=Path(home), uid=os.getuid(), @@ -1498,7 +1755,11 @@ def _activate_broker_record( ) restored = True else: - for candidate in (root / "state.json", plist_path, root / "provider.sock"): + for candidate in ( + root / "state.json", + plist_path, + root / BROKER_SOCKET_FILENAME, + ): try: candidate.unlink() except FileNotFoundError: @@ -1575,7 +1836,9 @@ def broker_status() -> RuntimeResult: ) _verify_plist_against_state(root, state) socket_valid = _exact_mode( - root / "provider.sock", expected_type=stat.S_IFSOCK, mode=0o600 + root / BROKER_SOCKET_FILENAME, + expected_type=stat.S_IFSOCK, + mode=0o600, ) is not None loaded = _broker_job_loaded() status = RuntimeStatus.OK if loaded and socket_valid else RuntimeStatus.UNAVAILABLE @@ -1637,7 +1900,7 @@ def uninstall_broker() -> RuntimeResult: plist_path = root / "broker.plist" if not _bootout_broker(plist_path): return RuntimeResult(RuntimeStatus.PROVIDER_ERROR, error="provider broker could not be stopped") - socket_path = root / "provider.sock" + socket_path = root / BROKER_SOCKET_FILENAME try: socket_path.lstat() except FileNotFoundError: @@ -1867,6 +2130,174 @@ def _parse_management_response( return RuntimeResult(RuntimeStatus.OK, result=response["result"]) +def _gemini_governance_readiness_result_valid( + result: Mapping[str, Any], envelope: object +) -> bool: + expected_keys = { + "ready", + "containment_level", + "tools_disabled", + "pty_used", + "lock_acquired", + "cleanup_confirmed", + "selected_display", + "governance_ready", + "artifact_sha256", + "artifact_author_model", + "artifact_author_family", + } + return ( + set(result) == expected_keys + and result.get("ready") is True + and result.get("containment_level") == GEMINI_GOVERNANCE_CONTAINMENT + and result.get("tools_disabled") is False + and result.get("pty_used") is True + and result.get("lock_acquired") is True + and result.get("cleanup_confirmed") is True + and result.get("selected_display") == GEMINI_GOVERNANCE_DISPLAY + and result.get("governance_ready") is True + and result.get("artifact_sha256") == envelope.artifact_sha256 + and result.get("artifact_author_model") == envelope.artifact_author_model + and result.get("artifact_author_family") == envelope.artifact_author_family + ) + + +def _gemini_governance_execute_result_valid( + result: Mapping[str, Any], envelope: object, provenance: Mapping[str, Any] +) -> bool: + expected_result_keys = { + "text", + "containment_level", + "tools_disabled", + "pty_used", + "lock_acquired", + "cleanup_confirmed", + "selected_display", + "governance_evidence", + "artifact_sha256", + "artifact_author_model", + "artifact_author_family", + "governance_proof", + } + text = result.get("text") + proof = result.get("governance_proof") + if ( + set(result) != expected_result_keys + or type(text) is not str + or len(text.encode("utf-8")) > MAX_RESPONSE_BYTES + or result.get("containment_level") != GEMINI_GOVERNANCE_CONTAINMENT + or result.get("tools_disabled") is not False + or result.get("pty_used") is not True + or result.get("lock_acquired") is not True + or result.get("cleanup_confirmed") is not True + or result.get("selected_display") != GEMINI_GOVERNANCE_DISPLAY + or result.get("governance_evidence") is not True + or result.get("artifact_sha256") != envelope.artifact_sha256 + or result.get("artifact_author_model") != envelope.artifact_author_model + or result.get("artifact_author_family") != envelope.artifact_author_family + or type(proof) is not dict + or set(proof) != GEMINI_GOVERNANCE_PROOF_KEYS + ): + return False + + expected_scalars = { + "version": 1, + "request_id": envelope.request_id, + "action": "governance", + "authority": "read_only", + "transport": "broker", + "backend": "agy", + "runtime_version": GEMINI_GOVERNANCE_RUNTIME_VERSION, + "contract_version": CONTRACT_VERSION, + "artifact_sha256": envelope.artifact_sha256, + "artifact_author_model": envelope.artifact_author_model, + "artifact_author_family": envelope.artifact_author_family, + "reviewer_model": GEMINI_GOVERNANCE_MODEL, + "reviewer_family": "google", + "selected_display": GEMINI_GOVERNANCE_DISPLAY, + "effective_effort": "high", + "containment_level": GEMINI_GOVERNANCE_CONTAINMENT, + "tools_disabled": False, + "pty_used": True, + "lock_acquired": True, + "cleanup_confirmed": True, + "provider_process_started": True, + "returncode": 0, + "model_source": "agy-selected", + "failed_over": False, + "response_sha256": hashlib.sha256(text.encode("utf-8")).hexdigest(), + } + if any( + type(proof.get(key)) is not type(expected) or proof.get(key) != expected + for key, expected in expected_scalars.items() + ): + return False + if ( + provenance.get("author_model") != proof.get("reviewer_model") + or provenance.get("author_family") != proof.get("reviewer_family") + or type(proof.get("proof_sha256")) is not str + or _SHA256_RE.fullmatch(proof["proof_sha256"]) is None + ): + return False + unsigned = dict(proof) + supplied_digest = unsigned.pop("proof_sha256") + try: + encoded = json.dumps( + unsigned, + sort_keys=True, + separators=(",", ":"), + ensure_ascii=True, + allow_nan=False, + ).encode("ascii") + except (TypeError, ValueError, UnicodeError): + return False + return hmac.compare_digest(hashlib.sha256(encoded).hexdigest(), supplied_digest) + + +def _gemini_result_valid( + result: Mapping[str, Any], envelope: object, provenance: Mapping[str, Any] +) -> bool: + if envelope.route != "gemini": + return True + if envelope.action == "governance": + if ( + envelope.governance is not True + or envelope.authority != "read_only" + or envelope.target_author_family != "google" + or envelope.primary_family == "google" + or envelope.artifact_present is not True + or envelope.artifact_author_family in {"google", "unknown"} + ): + return False + policy = _load_host_policy() + if ( + policy.resolve_model_family(envelope.artifact_author_model) + != envelope.artifact_author_family + ): + return False + if envelope.operation == "readiness": + return _gemini_governance_readiness_result_valid(result, envelope) + if envelope.operation == "execute": + return _gemini_governance_execute_result_valid( + result, envelope, provenance + ) + return False + + forbidden = { + "governance_proof", + "artifact_sha256", + "artifact_author_model", + "artifact_author_family", + } + if forbidden.intersection(result): + return False + if envelope.operation == "readiness": + return result.get("governance_ready") is False + if envelope.operation == "execute": + return result.get("governance_evidence") is False + return False + + def _parse_response(out: bytes, envelope: object, returncode: int) -> RuntimeResult: try: response = json.loads(out.decode("utf-8")) @@ -1958,6 +2389,11 @@ def _parse_response(out: bytes, envelope: object, returncode: int) -> RuntimeRes or provenance["observation_sequence"] < 0 ): return RuntimeResult(RuntimeStatus.PROTOCOL_ERROR, error="runtime provenance contract mismatch") + if not _gemini_result_valid(response["result"], envelope, provenance): + return RuntimeResult( + RuntimeStatus.PROTOCOL_ERROR, + error="runtime Gemini result contract mismatch", + ) return RuntimeResult(RuntimeStatus.OK, result=response["result"], provenance=provenance) diff --git a/plugins/agent-collab/skills/agent-readiness/SKILL.md b/plugins/agent-collab/skills/agent-readiness/SKILL.md index c132525..0e2cb93 100644 --- a/plugins/agent-collab/skills/agent-readiness/SKILL.md +++ b/plugins/agent-collab/skills/agent-readiness/SKILL.md @@ -1,6 +1,6 @@ --- name: agent-readiness -version: 3.2.0 +version: 3.3.0 description: Evaluate whether an agent, model, CLI, plugin, or role is ready for a proposed responsibility. Use when the user says "agent readiness," "is this agent ready," "can Codex be primary," "can Grok handle this role," "promote this agent," "evaluate this worker," "review model readiness," or "/agent-collab:agent-readiness." Also offer this proactively before assigning a new primary, reviewer, worker, delegate, headless, release, or merge-related role to Claude, Codex, Antigravity/Gemini, Grok, or a future agent. --- diff --git a/plugins/agent-collab/skills/agent-runtime-status/SKILL.md b/plugins/agent-collab/skills/agent-runtime-status/SKILL.md index 5eeef26..41473ea 100644 --- a/plugins/agent-collab/skills/agent-runtime-status/SKILL.md +++ b/plugins/agent-collab/skills/agent-runtime-status/SKILL.md @@ -1,6 +1,6 @@ --- name: agent-runtime-status -version: 3.2.0 +version: 3.3.0 defaults: tier: Fast effort: low @@ -27,7 +27,7 @@ skill; no external repository is required. 2. Resolve the current primary/model/session from host evidence or explicit configuration. Do not infer the primary from installed CLIs. 3. Submit a bounded `readiness` request for each exact matrix row: - Gemini `advisory|long_context`; Codex `advisory`; OpenCode `plan|build`; + Gemini `advisory|governance|long_context`; Codex `advisory`; OpenCode `plan|build`; Grok `architecture|governance|huge_context`; Composer `codegen`. 4. Preserve typed results such as `unavailable`, `auth_error`, `quota_error`, `containment_error`, `timeout`, and `output_limit`. Do not flatten them into @@ -50,6 +50,7 @@ agent-collab runtime: - async inbox: - native artifact: - gemini/advisory: +- gemini/governance: - gemini/long_context: - codex/advisory: - opencode/plan: diff --git a/plugins/agent-collab/skills/architect/SKILL.md b/plugins/agent-collab/skills/architect/SKILL.md index 0f13def..505e4ba 100644 --- a/plugins/agent-collab/skills/architect/SKILL.md +++ b/plugins/agent-collab/skills/architect/SKILL.md @@ -1,6 +1,6 @@ --- name: architect -version: 3.2.0 +version: 3.3.0 description: Request read-only architecture consultation for codebase analysis, system design, implementation planning, decomposition, or long-horizon coding strategy. Use when the user says "ask the architect," "have Grok design this," "architecture consultation," "plan this implementation," "decompose this build," "analyze the system design," or "/agent-collab:architect." Also offer this proactively before a substantial multi-system or long-horizon implementation where an independent architecture pass can reduce rework. This role never edits files, runs shell commands or tests, mutates a worktree, opens PRs, merges, or deploys. --- diff --git a/plugins/agent-collab/skills/autonomy-readiness/SKILL.md b/plugins/agent-collab/skills/autonomy-readiness/SKILL.md index d829b8b..1055a33 100644 --- a/plugins/agent-collab/skills/autonomy-readiness/SKILL.md +++ b/plugins/agent-collab/skills/autonomy-readiness/SKILL.md @@ -1,6 +1,6 @@ --- name: autonomy-readiness -version: 3.2.0 +version: 3.3.0 description: Evaluate whether an autonomous, always-on, scheduled, headless, or self-evolving workflow is ready to run safely. Use when the user says "autonomy readiness," "activation gate review," "is this workflow ready to run autonomously," "go/no-go autonomy," "always-on readiness," "headless operation review," or "/agent-collab:autonomy-readiness." Also offer this proactively before enabling background agents, recurring automations, auto-merge/self-evolution, external actions, unattended host runs, or any workflow that can continue without a human watching. --- diff --git a/plugins/agent-collab/skills/brainstorm/SKILL.md b/plugins/agent-collab/skills/brainstorm/SKILL.md index d90764e..bf17c17 100644 --- a/plugins/agent-collab/skills/brainstorm/SKILL.md +++ b/plugins/agent-collab/skills/brainstorm/SKILL.md @@ -1,6 +1,6 @@ --- name: brainstorm -version: 3.2.0 +version: 3.3.0 defaults: tier: Fast effort: low diff --git a/plugins/agent-collab/skills/chain-configurator/SKILL.md b/plugins/agent-collab/skills/chain-configurator/SKILL.md index 56b5cee..5791f9d 100644 --- a/plugins/agent-collab/skills/chain-configurator/SKILL.md +++ b/plugins/agent-collab/skills/chain-configurator/SKILL.md @@ -1,6 +1,6 @@ --- name: chain-configurator -version: 3.2.0 +version: 3.3.0 defaults: tier: Standard effort: medium diff --git a/plugins/agent-collab/skills/chain/SKILL.md b/plugins/agent-collab/skills/chain/SKILL.md index 8654f61..699c916 100644 --- a/plugins/agent-collab/skills/chain/SKILL.md +++ b/plugins/agent-collab/skills/chain/SKILL.md @@ -1,6 +1,6 @@ --- name: chain -version: 3.2.0 +version: 3.3.0 defaults: tier: Standard effort: medium diff --git a/plugins/agent-collab/skills/code-review/SKILL.md b/plugins/agent-collab/skills/code-review/SKILL.md index 1a99a4e..c1c4cbc 100644 --- a/plugins/agent-collab/skills/code-review/SKILL.md +++ b/plugins/agent-collab/skills/code-review/SKILL.md @@ -1,6 +1,6 @@ --- name: code-review -version: 3.2.0 +version: 3.3.0 defaults: tier: Advanced effort: high diff --git a/plugins/agent-collab/skills/compose-skills/SKILL.md b/plugins/agent-collab/skills/compose-skills/SKILL.md index 33ab0c4..c9fa724 100644 --- a/plugins/agent-collab/skills/compose-skills/SKILL.md +++ b/plugins/agent-collab/skills/compose-skills/SKILL.md @@ -1,6 +1,6 @@ --- name: compose-skills -version: 3.2.0 +version: 3.3.0 description: Select a bounded, token-aware combination of collaboration skills or task lenses before execution. Use when the user says "compose skills," "which skills should I use," "use skill composition," "select a recipe," "combine these skills," or "/agent-collab:compose-skills." Also offer this proactively when a task plausibly needs multiple lenses, reviewers, or agents and would benefit from progressive disclosure, explicit fan-out limits, and a smallest-useful-skill plan before routing or loading full skill bodies. --- diff --git a/plugins/agent-collab/skills/debate/SKILL.md b/plugins/agent-collab/skills/debate/SKILL.md index 43d3338..f728a10 100644 --- a/plugins/agent-collab/skills/debate/SKILL.md +++ b/plugins/agent-collab/skills/debate/SKILL.md @@ -1,6 +1,6 @@ --- name: debate -version: 3.2.0 +version: 3.3.0 defaults: tier: Advanced effort: high diff --git a/plugins/agent-collab/skills/delegate/SKILL.md b/plugins/agent-collab/skills/delegate/SKILL.md index d7fbaf2..5f58760 100644 --- a/plugins/agent-collab/skills/delegate/SKILL.md +++ b/plugins/agent-collab/skills/delegate/SKILL.md @@ -1,6 +1,6 @@ --- name: delegate -version: 3.2.0 +version: 3.3.0 defaults: tier: Fast effort: low diff --git a/plugins/agent-collab/skills/dev-delegate/SKILL.md b/plugins/agent-collab/skills/dev-delegate/SKILL.md index c4c212d..03c9f84 100644 --- a/plugins/agent-collab/skills/dev-delegate/SKILL.md +++ b/plugins/agent-collab/skills/dev-delegate/SKILL.md @@ -1,6 +1,6 @@ --- name: dev-delegate -version: 3.2.0 +version: 3.3.0 defaults: tier: Standard effort: medium diff --git a/plugins/agent-collab/skills/governance-review/SKILL.md b/plugins/agent-collab/skills/governance-review/SKILL.md index badccfc..305a779 100644 --- a/plugins/agent-collab/skills/governance-review/SKILL.md +++ b/plugins/agent-collab/skills/governance-review/SKILL.md @@ -1,6 +1,6 @@ --- name: governance-review -version: 3.2.0 +version: 3.3.0 description: Use when the operator says "governance review," "high-stakes review," "tiebreaker," or "second opinion." Also offer this proactively when reviewer-family independence must be enforced. --- @@ -19,15 +19,21 @@ family. If either is unknown, fail closed with `unknown_family`; preserve the error detail that identifies which snapshot could not be proved. Exclude both families from the reviewer panel, tiebreaker, fallback, and retry set. -Seal the request as read-only advisory authority. A governance review must +Seal the request as read-only authority. A governance review must never enter a mutation-capable worker route. If no independent managed reviewer is eligible, return `unavailable`; a specifically selected same-family route is `same_family_blocked`. Do not weaken independence or use a same-family result. -Only advertised read-only Gemini advisory, Codex advisory, and Grok -`governance` action are eligible. OpenCode plan, every build role, and +The only eligible advertised read-only actions are Gemini `governance`, Codex +advisory, and Grok `governance`. OpenCode plan, every build role, and Composer codegen are never governance-review candidates. +Gemini governance is a distinct broker-only action, never an advisory alias. +Accept its result only when the public client validates the complete artifact- +bound governance proof, including canonical real-HOME containment, controlling +PTY, state lease, cleanup, exact model/effort, response hash, and proof hash. +Gemini advisory or long-context output is not governance evidence. + Claude participation can occur only through a separately configured host-owned async transport after readiness is observed. The public coordinator neither sends nor accepts governance over `inbox/async`; never create a synchronous diff --git a/plugins/agent-collab/skills/intent-check/SKILL.md b/plugins/agent-collab/skills/intent-check/SKILL.md index 1916888..01ba7bb 100644 --- a/plugins/agent-collab/skills/intent-check/SKILL.md +++ b/plugins/agent-collab/skills/intent-check/SKILL.md @@ -1,6 +1,6 @@ --- name: intent-check -version: 3.2.0 +version: 3.3.0 defaults: tier: Advanced effort: high @@ -22,7 +22,7 @@ Resolve the **plugin root** from this loaded file and invoke only `python3 "/coordinator.py"` with a bounded governance-review request. The public policy captures the active primary/model/session and artifact-author model, excludes both families, and selects only an eligible -Gemini or Codex advisory row, or a Grok 4.5 governance-review row. No reviewer +Gemini or Grok governance row, or a Codex advisory row. No reviewer or escalation model is fixed in this skill. Claude is asynchronous inbox-only. ## Workflow diff --git a/plugins/agent-collab/skills/knowledge-compile/SKILL.md b/plugins/agent-collab/skills/knowledge-compile/SKILL.md index e4aa4e2..a8a38e2 100644 --- a/plugins/agent-collab/skills/knowledge-compile/SKILL.md +++ b/plugins/agent-collab/skills/knowledge-compile/SKILL.md @@ -1,6 +1,6 @@ --- name: knowledge-compile -version: 3.2.0 +version: 3.3.0 description: Compile multiple sources into a durable, cited knowledge dossier without mixing claims, assumptions, and decisions. Use when the user says "compile knowledge," "build a dossier," "create a knowledge base," "synthesize these sources," "preserve research context," "make this reviewable later," or "/agent-collab:knowledge-compile." Also offer this proactively when a task spans several repos, PRs, papers, articles, logs, agent messages, or drafts and future agents need source-separated context for independent review. --- diff --git a/plugins/agent-collab/skills/logic-check/SKILL.md b/plugins/agent-collab/skills/logic-check/SKILL.md index c2d8341..b13204c 100644 --- a/plugins/agent-collab/skills/logic-check/SKILL.md +++ b/plugins/agent-collab/skills/logic-check/SKILL.md @@ -1,6 +1,6 @@ --- name: logic-check -version: 3.2.0 +version: 3.3.0 defaults: tier: Advanced effort: xhigh diff --git a/plugins/agent-collab/skills/long-context/SKILL.md b/plugins/agent-collab/skills/long-context/SKILL.md index 1bfe25a..6592f4d 100644 --- a/plugins/agent-collab/skills/long-context/SKILL.md +++ b/plugins/agent-collab/skills/long-context/SKILL.md @@ -1,6 +1,6 @@ --- name: long-context -version: 3.2.0 +version: 3.3.0 defaults: tier: Advanced effort: high diff --git a/plugins/agent-collab/skills/merge-resolve/SKILL.md b/plugins/agent-collab/skills/merge-resolve/SKILL.md index fed8a70..5476614 100644 --- a/plugins/agent-collab/skills/merge-resolve/SKILL.md +++ b/plugins/agent-collab/skills/merge-resolve/SKILL.md @@ -1,6 +1,6 @@ --- name: merge-resolve -version: 3.2.0 +version: 3.3.0 defaults: tier: Advanced effort: high diff --git a/plugins/agent-collab/skills/migration-doctor/SKILL.md b/plugins/agent-collab/skills/migration-doctor/SKILL.md index 934fe85..cf90d7d 100644 --- a/plugins/agent-collab/skills/migration-doctor/SKILL.md +++ b/plugins/agent-collab/skills/migration-doctor/SKILL.md @@ -1,6 +1,6 @@ --- name: migration-doctor -version: 3.2.0 +version: 3.3.0 description: Use when the user says "migration doctor," "check old collaboration plugins," "verify agent-collab migration," or "/agent-collab:migration-doctor." Also offer this proactively after installing or updating agent-collab, when provider routing is blocked, or when a retired package may still be selected from an installed plugin or cache. --- diff --git a/plugins/agent-collab/skills/orchestrate/SKILL.md b/plugins/agent-collab/skills/orchestrate/SKILL.md index 7871ffa..251a2ec 100644 --- a/plugins/agent-collab/skills/orchestrate/SKILL.md +++ b/plugins/agent-collab/skills/orchestrate/SKILL.md @@ -1,6 +1,6 @@ --- name: orchestrate -version: 3.2.0 +version: 3.3.0 defaults: tier: Standard effort: medium diff --git a/plugins/agent-collab/skills/qa-verify/SKILL.md b/plugins/agent-collab/skills/qa-verify/SKILL.md index 513db5c..4293682 100644 --- a/plugins/agent-collab/skills/qa-verify/SKILL.md +++ b/plugins/agent-collab/skills/qa-verify/SKILL.md @@ -1,6 +1,6 @@ --- name: qa-verify -version: 3.2.0 +version: 3.3.0 defaults: tier: Fast effort: low diff --git a/plugins/agent-collab/skills/red-team/SKILL.md b/plugins/agent-collab/skills/red-team/SKILL.md index 35366cf..3845575 100644 --- a/plugins/agent-collab/skills/red-team/SKILL.md +++ b/plugins/agent-collab/skills/red-team/SKILL.md @@ -1,6 +1,6 @@ --- name: red-team -version: 3.2.0 +version: 3.3.0 defaults: tier: Advanced effort: high diff --git a/plugins/agent-collab/skills/route/SKILL.md b/plugins/agent-collab/skills/route/SKILL.md index d77bb4a..d2ddbb0 100644 --- a/plugins/agent-collab/skills/route/SKILL.md +++ b/plugins/agent-collab/skills/route/SKILL.md @@ -1,6 +1,6 @@ --- name: route -version: 3.2.0 +version: 3.3.0 description: Use when the operator says "ask Codex," "target=gemini," "target=grok," "target=composer," or explicitly names a managed backend. Also offer this proactively when routing needs dynamic primary-family exclusion. --- @@ -30,7 +30,7 @@ read-only authority across attempts and attempt each target at most once. Automatic worker routing is temporarily unavailable; require an explicit managed worker target. -Seal only supported native contracts: Gemini advisory/long-context is +Seal only supported native contracts: Gemini advisory, governance, and long-context are read-only; Codex advisory is read-only; OpenCode plan is read-only and OpenCode build is mutation-capable workspace-write; Grok architecture, governance, and huge-context are read-only; Composer codegen is output-only. Codex build @@ -40,5 +40,10 @@ execution targets unavailable. A host async inbox is eligible only after an availability observation and exposes readiness only; the public coordinator never sends. +Managed provider execution uses canonical passwd HOME for reliable interactive +authentication state. This does not relax family exclusion, route authority, +the signed broker boundary, bounded lifecycle, or the prohibition on raw CLI +fallbacks. + Execution mechanics belong to the verified co-packaged native artifact. This skill contains no provider command and authorizes no direct invocation. diff --git a/plugins/agent-collab/skills/second-opinion/SKILL.md b/plugins/agent-collab/skills/second-opinion/SKILL.md index 0e200fa..0417828 100644 --- a/plugins/agent-collab/skills/second-opinion/SKILL.md +++ b/plugins/agent-collab/skills/second-opinion/SKILL.md @@ -1,6 +1,6 @@ --- name: second-opinion -version: 3.2.0 +version: 3.3.0 defaults: tier: Advanced effort: high diff --git a/plugins/agent-collab/skills/simulate-user/SKILL.md b/plugins/agent-collab/skills/simulate-user/SKILL.md index dbaa357..619b375 100644 --- a/plugins/agent-collab/skills/simulate-user/SKILL.md +++ b/plugins/agent-collab/skills/simulate-user/SKILL.md @@ -1,6 +1,6 @@ --- name: simulate-user -version: 3.2.0 +version: 3.3.0 defaults: tier: Fast effort: low diff --git a/plugins/agent-collab/skills/teamwork/SKILL.md b/plugins/agent-collab/skills/teamwork/SKILL.md index 79af78e..adcdd5d 100644 --- a/plugins/agent-collab/skills/teamwork/SKILL.md +++ b/plugins/agent-collab/skills/teamwork/SKILL.md @@ -1,6 +1,6 @@ --- name: teamwork -version: 3.2.0 +version: 3.3.0 defaults: tier: Standard effort: medium diff --git a/plugins/agent-collab/skills/ui-to-code/SKILL.md b/plugins/agent-collab/skills/ui-to-code/SKILL.md index f71ec2c..e4bffae 100644 --- a/plugins/agent-collab/skills/ui-to-code/SKILL.md +++ b/plugins/agent-collab/skills/ui-to-code/SKILL.md @@ -1,6 +1,6 @@ --- name: ui-to-code -version: 3.2.0 +version: 3.3.0 defaults: tier: Advanced effort: high diff --git a/plugins/agent-collab/skills/untrusted-audit/SKILL.md b/plugins/agent-collab/skills/untrusted-audit/SKILL.md index 9432cfe..6b5f5ab 100644 --- a/plugins/agent-collab/skills/untrusted-audit/SKILL.md +++ b/plugins/agent-collab/skills/untrusted-audit/SKILL.md @@ -1,6 +1,6 @@ --- name: untrusted-audit -version: 3.2.0 +version: 3.3.0 description: Audit an external or untrusted source before using it in code, skills, plugins, workflows, prompts, or operations. Use when the user says "audit this untrusted source," "can we use this repo," "review this gist," "prompt injection audit," "is this plugin safe," "evaluate this methodology," or "/agent-collab:untrusted-audit." Also offer this proactively when a task would incorporate third-party instructions, code, scripts, hooks, generated skills, package manifests, install steps, or auto-updated methodology into the workspace or agent environment. --- diff --git a/plugins/agent-collab/skills/visual-review/SKILL.md b/plugins/agent-collab/skills/visual-review/SKILL.md index 7906cdc..9f20bb5 100644 --- a/plugins/agent-collab/skills/visual-review/SKILL.md +++ b/plugins/agent-collab/skills/visual-review/SKILL.md @@ -1,6 +1,6 @@ --- name: visual-review -version: 3.2.0 +version: 3.3.0 defaults: tier: Advanced effort: high diff --git a/plugins/agent-collab/skills/worker/SKILL.md b/plugins/agent-collab/skills/worker/SKILL.md index c5a34c5..99ac2a3 100644 --- a/plugins/agent-collab/skills/worker/SKILL.md +++ b/plugins/agent-collab/skills/worker/SKILL.md @@ -1,6 +1,6 @@ --- name: worker -version: 3.2.0 +version: 3.3.0 description: Use when the operator says "delegate this implementation," "use Gemini for this corpus," "ask Codex to build," or "use Composer for codegen." Also offer this proactively when a bounded non-governance task benefits from a managed worker. --- diff --git a/scripts/build_plugin_archive.py b/scripts/build_plugin_archive.py index 2ab0223..a13c0b2 100644 --- a/scripts/build_plugin_archive.py +++ b/scripts/build_plugin_archive.py @@ -6,6 +6,7 @@ import argparse import gzip import hashlib +import importlib.util import json import os import re @@ -24,8 +25,9 @@ SPECS_DIR = REPO_ROOT / "skill-specs" PLUGIN_NAME = "agent-collab" MAX_ARTIFACT_BYTES = 64 * 1024 * 1024 -RUNTIME_REL = Path("runtime/darwin-arm64/agent-collab-runtime") -RUNTIME_FILE_MODE = 0o755 +RUNTIME_BUNDLE_REL = Path("runtime/darwin-arm64/agent-collab-runtime.bundle") +RUNTIME_REL = RUNTIME_BUNDLE_REL / "agent-collab-runtime" +RUNTIME_FILE_MODE = 0o500 THIRD_PARTY_NOTICE_REL = Path("THIRD-PARTY-NOTICES.txt") THIRD_PARTY_LICENSE_ROOT_REL = Path("third-party-licenses") THIRD_PARTY_LICENSE_FILES = ( @@ -95,6 +97,7 @@ "skills", "coordinator.py", "runtime_client.py", + "runtime_bundle.py", "runtime_setup.py", "host_policy.py", "migration_doctor.py", @@ -111,6 +114,7 @@ REQUIRED_CONTRACTS = frozenset( { ("gemini", "advisory"), + ("gemini", "governance"), ("gemini", "long_context"), ("codex", "advisory"), ("opencode", "plan"), @@ -127,6 +131,21 @@ _EXCLUDED_NAMES = frozenset({".DS_Store"}) +def _load_runtime_bundle_contract(): + path = REPO_ROOT / "plugins" / PLUGIN_NAME / "runtime_bundle.py" + spec = importlib.util.spec_from_file_location( + "agent_collab_archive_runtime_bundle", path + ) + if spec is None or spec.loader is None: + raise ValueError("runtime bundle contract cannot be loaded") + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +runtime_bundle = _load_runtime_bundle_contract() + + def _sha256(path: Path) -> str: digest = hashlib.sha256() with path.open("rb") as stream: @@ -170,14 +189,19 @@ def _load_manifest(plugin_path: Path) -> dict[str, object]: "schema_version", "protocol_version", "contract_version", + "broker_protocol_version", + "channel", "artifacts", } or type(manifest.get("schema_version")) is not int - or manifest["schema_version"] != 1 + or manifest["schema_version"] != 2 or type(manifest.get("protocol_version")) is not int or manifest["protocol_version"] != 1 or type(manifest.get("contract_version")) is not int - or manifest["contract_version"] != 1 + or manifest["contract_version"] != 3 + or type(manifest.get("broker_protocol_version")) is not int + or manifest["broker_protocol_version"] != 2 + or manifest.get("channel") != "production" or not isinstance(manifest.get("artifacts"), list) ): raise ValueError("runtime manifest root or version is invalid") @@ -188,11 +212,14 @@ def _validate_activation(plugin_path: Path, item: object) -> None: fields = { "platform", "arch", + "kind", "minimum_macos", "path", + "entrypoint", "size", "sha256", "signing", + "files", "contracts", } if not isinstance(item, dict) or set(item) != fields: @@ -207,21 +234,56 @@ def _validate_activation(plugin_path: Path, item: object) -> None: ) except (KeyError, TypeError): contracts = frozenset() + try: + records = runtime_bundle.validate_file_records(item.get("files")) + bundle_digest = runtime_bundle.compute_bundle_identity(records) + except runtime_bundle.BundleContractError: + records = () + bundle_digest = "" + identity = signing.get("identity") if isinstance(signing, dict) else None + identity_match = ( + re.fullmatch( + r"Developer ID Application: [^\r\n]{1,160} \(([A-Z0-9]{10})\)", + identity, + ) + if isinstance(identity, str) + else None + ) if ( item.get("platform") != "darwin" or item.get("arch") != "arm64" + or item.get("kind") != "standalone_bundle" or item.get("minimum_macos") != "14.0" - or item.get("path") != RUNTIME_REL.as_posix() + or item.get("path") != RUNTIME_BUNDLE_REL.as_posix() + or item.get("entrypoint") != runtime_bundle.ENTRYPOINT_NAME or type(item.get("size")) is not int or not 1 <= item["size"] <= MAX_ARTIFACT_BYTES + or item["size"] != sum(record["size"] for record in records) or not isinstance(item.get("sha256"), str) or _SHA256_RE.fullmatch(item["sha256"]) is None + or item["sha256"] != bundle_digest or not isinstance(signing, dict) - or set(signing) != {"team_id", "require_notarization", "hardened_runtime"} + or set(signing) + != { + "mode", + "identity", + "team_id", + "require_notarization", + "hardened_runtime", + "secure_timestamp", + } + or signing.get("mode") != "developer_id" + or identity_match is None or not isinstance(signing.get("team_id"), str) or _TEAM_ID_RE.fullmatch(signing["team_id"]) is None + or identity_match.group(1) != signing["team_id"] or signing.get("require_notarization") is not True or signing.get("hardened_runtime") is not True + or signing.get("secure_timestamp") is not True + or any( + record["signing_profile"] != "production_developer_id" + for record in records + ) or not isinstance(contracts_value, list) or contracts != REQUIRED_CONTRACTS or len(contracts_value) != len(contracts) @@ -232,23 +294,33 @@ def _validate_activation(plugin_path: Path, item: object) -> None: allowed = { Path("runtime"), Path("runtime/darwin-arm64"), - RUNTIME_REL, + RUNTIME_BUNDLE_REL, + *(RUNTIME_BUNDLE_REL / record["path"] for record in records), } observed = {path.relative_to(plugin_path) for path in runtime_root.rglob("*")} observed.add(Path("runtime")) if observed != allowed: - raise ValueError("activation package must contain exactly one runtime executable") - binary = plugin_path / RUNTIME_REL - info = _safe_source(binary) + raise ValueError("activation package runtime bundle membership is not exact") + bundle = plugin_path / RUNTIME_BUNDLE_REL + bundle_info = _safe_source(bundle) if ( - not stat.S_ISREG(info.st_mode) - or info.st_uid != os.getuid() - or info.st_nlink != 1 - or stat.S_IMODE(info.st_mode) != RUNTIME_FILE_MODE - or info.st_size != item["size"] - or _sha256(binary) != item["sha256"] + not stat.S_ISDIR(bundle_info.st_mode) + or bundle_info.st_uid != os.getuid() + or stat.S_IMODE(bundle_info.st_mode) != runtime_bundle.INSTALL_MODE ): - raise ValueError("activation runtime byte, size, ownership, or 0755 mode is invalid") + raise ValueError("activation runtime bundle root identity is invalid") + for record in records: + member = bundle / record["path"] + info = _safe_source(member) + if ( + not stat.S_ISREG(info.st_mode) + or info.st_uid != os.getuid() + or info.st_nlink != 1 + or stat.S_IMODE(info.st_mode) != record["install_mode"] + or info.st_size != record["size"] + or _sha256(member) != record["sha256"] + ): + raise ValueError("activation runtime bundle member identity is invalid") def classify_package(plugin_path: Path) -> str: @@ -345,11 +417,16 @@ def _member_paths(plugin_path: Path, *, mode: str) -> list[Path]: ] if mode == "activation": _require_exact_third_party_notice_tree(plugin_path) + manifest = _load_manifest(plugin_path) + records = runtime_bundle.validate_file_records( + manifest["artifacts"][0]["files"] + ) relatives.extend( ( Path("runtime"), Path("runtime/darwin-arm64"), - RUNTIME_REL, + RUNTIME_BUNDLE_REL, + *(RUNTIME_BUNDLE_REL / record["path"] for record in records), *ACTIVATION_THIRD_PARTY_MEMBERS, ) ) @@ -404,7 +481,16 @@ def verify_archive(plugin_path: Path, archive_path: Path, *, mode: str) -> None: for member in members if member.isfile() and member.name.startswith("runtime/") ] - expected_runtime = [RUNTIME_REL.as_posix()] if mode == "activation" else [] + expected_runtime = ( + [ + (RUNTIME_BUNDLE_REL / record["path"]).as_posix() + for record in runtime_bundle.validate_file_records( + _load_manifest(plugin_path)["artifacts"][0]["files"] + ) + ] + if mode == "activation" + else [] + ) if runtime_files != expected_runtime: raise ValueError("archive runtime membership does not match release mode") diff --git a/scripts/build_release_evidence.py b/scripts/build_release_evidence.py index 27859e2..ab0eac2 100644 --- a/scripts/build_release_evidence.py +++ b/scripts/build_release_evidence.py @@ -198,7 +198,7 @@ def _manifest(files: dict[str, bytes], path: str) -> dict[str, object]: def _file_license(name: str, *, mode: str) -> str: if mode == "activation" and ( - name == archive_builder.RUNTIME_REL.as_posix() + name.startswith(archive_builder.RUNTIME_BUNDLE_REL.as_posix() + "/") or name == archive_builder.THIRD_PARTY_NOTICE_REL.as_posix() or name.startswith( archive_builder.THIRD_PARTY_LICENSE_ROOT_REL.as_posix() + "/" diff --git a/scripts/check-public-export-safety.py b/scripts/check-public-export-safety.py index 598c142..cdc6a05 100644 --- a/scripts/check-public-export-safety.py +++ b/scripts/check-public-export-safety.py @@ -7,6 +7,7 @@ import ast import gzip import hashlib +import importlib.util import io import json import os @@ -75,6 +76,24 @@ MAX_ARCHIVE_MEMBER_BYTES = 64 * 1024 * 1024 MAX_ARCHIVE_TOTAL_BYTES = 256 * 1024 * 1024 MAX_ARCHIVE_DEPTH = 3 +RUNTIME_BUNDLE_REL = Path( + "plugins/agent-collab/runtime/darwin-arm64/agent-collab-runtime.bundle" +) + + +def _load_runtime_bundle_contract(): + path = REPO_ROOT / "plugins" / "agent-collab" / "runtime_bundle.py" + spec = importlib.util.spec_from_file_location( + "agent_collab_export_runtime_bundle", path + ) + if spec is None or spec.loader is None: + raise RuntimeError("runtime bundle contract cannot be loaded") + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +runtime_bundle = _load_runtime_bundle_contract() HARMLESS_AUDIT_LITERALS: dict[Path, frozenset[str | bytes]] = { Path("scripts/check-public-export-safety.py"): frozenset( EXECUTOR_SOURCES @@ -128,6 +147,7 @@ REQUIRED_RUNTIME_CONTRACTS = frozenset( { ("gemini", "advisory"), + ("gemini", "governance"), ("gemini", "long_context"), ("codex", "advisory"), ("opencode", "plan"), @@ -526,9 +546,14 @@ def scan_member(name: str, body: bytes, *, mode: int = 0, link: bool = False) -> def _runtime_contract_violation(root: Path, relative: Path, data: bytes) -> Violation | None: - expected = Path("plugins/agent-collab/runtime/darwin-arm64/agent-collab-runtime") - if relative != expected: + runtime_root = Path("plugins/agent-collab/runtime") + try: + relative.relative_to(runtime_root) + except ValueError: return None + if relative.parent != RUNTIME_BUNDLE_REL: + return Violation("unmanifested_runtime", str(relative)) + manifest_path = root / "plugins" / "agent-collab" / "runtime-manifest.json" signing_policy_path = root / "plugins" / "agent-collab" / "signing_policy.py" try: @@ -550,20 +575,27 @@ def _runtime_contract_violation(root: Path, relative: Path, data: bytes) -> Viol except (OSError, SyntaxError, ValueError): pinned_values = [] pinned_team = pinned_values[0] if len(pinned_values) == 1 else "" + if ( not isinstance(manifest, dict) - or set(manifest) != { + or set(manifest) + != { "schema_version", "protocol_version", "contract_version", + "broker_protocol_version", + "channel", "artifacts", } or type(manifest.get("schema_version")) is not int - or manifest["schema_version"] != 1 + or manifest["schema_version"] != 2 or type(manifest.get("protocol_version")) is not int or manifest["protocol_version"] != 1 or type(manifest.get("contract_version")) is not int - or manifest["contract_version"] != 1 + or manifest["contract_version"] != 3 + or type(manifest.get("broker_protocol_version")) is not int + or manifest["broker_protocol_version"] != 2 + or manifest.get("channel") != "production" or not isinstance(manifest.get("artifacts"), list) or len(manifest["artifacts"]) != 1 or not isinstance(manifest["artifacts"][0], dict) @@ -583,39 +615,98 @@ def _runtime_contract_violation(root: Path, relative: Path, data: bytes) -> Viol contract_rows = [] break contract_rows.append((route, action)) + try: + records = runtime_bundle.validate_file_records(item.get("files")) + bundle_identity = runtime_bundle.compute_bundle_identity(records) + except runtime_bundle.BundleContractError: + return Violation("unmanifested_runtime", str(relative)) + identity = signing.get("identity") if isinstance(signing, dict) else None + identity_match = ( + re.fullmatch( + r"Developer ID Application: [^\r\n]{1,160} \(([A-Z0-9]{10})\)", + identity, + ) + if isinstance(identity, str) + else None + ) if ( - set(item) != { + set(item) + != { "platform", "arch", + "kind", "minimum_macos", "path", + "entrypoint", "size", "sha256", "signing", + "files", "contracts", } or item.get("platform") != "darwin" or item.get("arch") != "arm64" + or item.get("kind") != "standalone_bundle" or item.get("minimum_macos") != "14.0" - or item.get("path") != "runtime/darwin-arm64/agent-collab-runtime" + or item.get("path") + != "runtime/darwin-arm64/agent-collab-runtime.bundle" + or item.get("entrypoint") != runtime_bundle.ENTRYPOINT_NAME or type(item.get("size")) is not int - or item["size"] != len(data) - or item.get("sha256") != hashlib.sha256(data).hexdigest() + or item["size"] != sum(record["size"] for record in records) + or item.get("sha256") != bundle_identity or not isinstance(signing, dict) - or set(signing) != {"team_id", "require_notarization", "hardened_runtime"} + or set(signing) + != { + "mode", + "identity", + "team_id", + "require_notarization", + "hardened_runtime", + "secure_timestamp", + } + or signing.get("mode") != "developer_id" + or identity_match is None or not isinstance(signing.get("team_id"), str) or not _TEAM_ID_RE.fullmatch(signing["team_id"]) + or identity_match.group(1) != signing["team_id"] or not _TEAM_ID_RE.fullmatch(pinned_team) or signing["team_id"] != pinned_team or signing.get("require_notarization") is not True or signing.get("hardened_runtime") is not True + or signing.get("secure_timestamp") is not True + or any( + record["signing_profile"] != "production_developer_id" + for record in records + ) or frozenset(contract_rows) != REQUIRED_RUNTIME_CONTRACTS or len(contract_rows) != len(REQUIRED_RUNTIME_CONTRACTS) ): return Violation("unmanifested_runtime", str(relative)) - return None - + bundle = root / RUNTIME_BUNDLE_REL + by_name = {record["path"]: record for record in records} + record = by_name.get(relative.name) + try: + bundle_info = bundle.lstat() + observed_names = sorted(path.name for path in bundle.iterdir()) + member_info = (root / relative).lstat() + except OSError: + return Violation("unmanifested_runtime", str(relative)) + if ( + record is None + or observed_names != [item["path"] for item in records] + or not stat.S_ISDIR(bundle_info.st_mode) + or stat.S_ISLNK(bundle_info.st_mode) + or stat.S_IMODE(bundle_info.st_mode) != runtime_bundle.INSTALL_MODE + or not stat.S_ISREG(member_info.st_mode) + or stat.S_ISLNK(member_info.st_mode) + or member_info.st_nlink != 1 + or stat.S_IMODE(member_info.st_mode) != record["install_mode"] + or record["size"] != len(data) + or record["sha256"] != hashlib.sha256(data).hexdigest() + ): + return Violation("unmanifested_runtime", str(relative)) + return None def scan_active_tree(root: Path) -> list[Violation]: root = root.resolve() violations: list[Violation] = [] @@ -670,7 +761,7 @@ def scan_active_tree(root: Path) -> list[Violation]: data, str(relative), renamed_candidate=relative.suffix not in {".py", ".md", ".json", ".yaml", ".yml"} - and relative != Path("plugins/agent-collab/runtime/darwin-arm64/agent-collab-runtime"), + and relative.parent != RUNTIME_BUNDLE_REL, mask_path=relative, ) ) @@ -892,10 +983,7 @@ def inspect_ref_object( path for path in scan_paths if path.suffix not in {".py", ".md", ".json", ".yaml", ".yml"} - and path - != Path( - "plugins/agent-collab/runtime/darwin-arm64/agent-collab-runtime" - ) + and path.parent != RUNTIME_BUNDLE_REL ] evidence_path = renamed_paths[0] if renamed_paths else scan_paths[0] size = sizes.get(object_id) diff --git a/scripts/skill-build-config.json b/scripts/skill-build-config.json index e7d88f8..bb98408 100644 --- a/scripts/skill-build-config.json +++ b/scripts/skill-build-config.json @@ -20,7 +20,7 @@ "tier_flash_resolves_to_claude": "an asynchronous Anthropic inbox review; never a synchronous invocation", "tier_pro_resolves_to_gemini": "an eligible managed Google-family reviewer at high effort", "tier_flash_resolves_to_gemini": "an eligible managed Google-family reviewer at low effort", - "skill_version": "3.2.0", + "skill_version": "3.3.0", "agent_runtime_status_defaults_block": "defaults:\n tier: Fast\n effort: low\n", "merge_resolve_defaults_block": "defaults:\n tier: Advanced\n effort: high\n", "merge_resolve_call_params": "`effort='high'` in every eligible advisory row and no `tier` request field", diff --git a/scripts/verify_runtime_release.py b/scripts/verify_runtime_release.py index df3f2e3..2f7a88f 100644 --- a/scripts/verify_runtime_release.py +++ b/scripts/verify_runtime_release.py @@ -20,11 +20,13 @@ PLUGIN_REL = Path("plugins/agent-collab") MANIFEST_REL = PLUGIN_REL / "runtime-manifest.json" SIGNING_POLICY_REL = PLUGIN_REL / "signing_policy.py" -RUNTIME_REL = Path("runtime/darwin-arm64/agent-collab-runtime") +RUNTIME_BUNDLE_REL = Path("runtime/darwin-arm64/agent-collab-runtime.bundle") +RUNTIME_REL = RUNTIME_BUNDLE_REL / "agent-collab-runtime" MAX_ARTIFACT_BYTES = 64 * 1024 * 1024 REQUIRED_CONTRACTS = frozenset( { ("gemini", "advisory"), + ("gemini", "governance"), ("gemini", "long_context"), ("codex", "advisory"), ("opencode", "plan"), @@ -39,10 +41,26 @@ _CODESIGN_FLAGS_RE = re.compile(r"\bflags=(0x[0-9a-f]+)(?:\([^)]*\))?", re.IGNORECASE) _CODESIGN_TIMESTAMP_RE = re.compile(r"(?m)^Timestamp=(.+)$") _CODESIGN_TEAM_RE = re.compile(r"(?m)^TeamIdentifier=([A-Z0-9]{10})(?=\s|$)") +_CODESIGN_AUTHORITY_RE = re.compile(r"(?m)^Authority=(.+)$") _VERSION_RE = re.compile(r"^\d+(?:\.\d+){1,2}$") EXPECTED_MINIMUM_MACOS = "14.0" +def _load_runtime_bundle_contract(): + path = REPO_ROOT / PLUGIN_REL / "runtime_bundle.py" + spec = importlib.util.spec_from_file_location( + "agent_collab_release_runtime_bundle", path + ) + if spec is None or spec.loader is None: + raise RuntimeError("runtime bundle contract cannot be loaded") + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +runtime_bundle = _load_runtime_bundle_contract() + + def _load_expected_team_id() -> str: path = REPO_ROOT / PLUGIN_REL / "signing_policy.py" spec = importlib.util.spec_from_file_location( @@ -177,10 +195,20 @@ def _manifest(root: Path) -> tuple[dict[str, Any] | None, Path, list[str]]: return None, manifest_path, ["runtime manifest is unreadable"] if ( not isinstance(data, dict) - or set(data) != {"schema_version", "protocol_version", "contract_version", "artifacts"} - or not _exact_int(data.get("schema_version"), 1) + or set(data) + != { + "schema_version", + "protocol_version", + "contract_version", + "broker_protocol_version", + "channel", + "artifacts", + } + or not _exact_int(data.get("schema_version"), 2) or not _exact_int(data.get("protocol_version"), 1) - or not _exact_int(data.get("contract_version"), 1) + or not _exact_int(data.get("contract_version"), 3) + or not _exact_int(data.get("broker_protocol_version"), 2) + or data.get("channel") != "production" or not isinstance(data.get("artifacts"), list) ): errors.append("runtime manifest root or version is invalid") @@ -190,6 +218,109 @@ def _manifest(root: Path) -> tuple[dict[str, Any] | None, Path, list[str]]: return data, manifest_path, errors +def _verify_macho_record( + path: Path, record: dict[str, Any] +) -> tuple[bool, bool, bool, str]: + arch_ok, minimum_ok, error = _verify_macho_identity( + path, minimum_macos=record["minimum_macos"] + ) + if error: + return arch_ok, minimum_ok, False, error + try: + header = subprocess.run( + ["/usr/bin/otool", "-hv", str(path)], + capture_output=True, + text=True, + timeout=30, + ) + except (OSError, subprocess.SubprocessError): + return arch_ok, minimum_ok, False, "Mach-O type inspection tool failed" + expected = { + "executable": "EXECUTE", + "dylib": "DYLIB", + "bundle": "BUNDLE", + }[record["macho_type"]] + tokens = header.stdout.split() + type_ok = header.returncode == 0 and tokens.count(expected) == 1 + return ( + arch_ok, + minimum_ok, + type_ok, + "" if type_ok else "Mach-O file type does not match the manifest", + ) + + +def _verify_member_signature( + path: Path, + *, + signing: dict[str, Any], + assess_notarization: bool, +) -> tuple[dict[str, Any], list[str]]: + errors: list[str] = [] + try: + verify = subprocess.run( + ["/usr/bin/codesign", "--verify", "--strict", "--verbose=4", str(path)], + capture_output=True, + text=True, + timeout=30, + ) + detail = subprocess.run( + ["/usr/bin/codesign", "-dv", "--verbose=4", str(path)], + capture_output=True, + text=True, + timeout=30, + ) + except (OSError, subprocess.SubprocessError): + return {}, ["macOS code-signing verification tool failed"] + detail_output = detail.stdout + detail.stderr + if verify.returncode != 0 or detail.returncode != 0: + errors.append("macOS code-signing verification failed") + if _CODESIGN_TEAM_RE.findall(detail_output) != [signing["team_id"]]: + errors.append("runtime signing team does not match the pinned operator identity") + authorities = _CODESIGN_AUTHORITY_RE.findall(detail_output) + if not authorities or authorities[0] != signing["identity"]: + errors.append("runtime Developer ID signing identity does not match") + hardened = any( + int(match.group(1), 16) & 0x10000 + for match in _CODESIGN_FLAGS_RE.finditer(detail_output) + ) + if not hardened: + errors.append("runtime hardened-signing flags are missing") + timestamp = _secure_codesign_timestamp(detail_output) + if not timestamp: + errors.append("runtime code signature is missing a secure Timestamp") + spctl_source = "" + if assess_notarization: + try: + assessment = subprocess.run( + [ + "/usr/sbin/spctl", + "--assess", + "--type", + "execute", + "--verbose=4", + str(path), + ], + capture_output=True, + text=True, + timeout=30, + ) + except (OSError, subprocess.SubprocessError): + errors.append("macOS notarization verification tool failed") + else: + spctl_source = _spctl_source(assessment.stdout + assessment.stderr) + if assessment.returncode != 0 or not spctl_source: + errors.append( + "runtime notarization source is not exactly Notarized Developer ID" + ) + return { + "codesign_timestamp": timestamp, + "spctl_source": spctl_source, + "codesign_verified": not errors, + "hardened_runtime_verified": hardened, + }, errors + + def verify_release( root: Path, *, git_sha: str ) -> tuple[bool, dict[str, Any], list[str]]: @@ -204,15 +335,19 @@ def verify_release( expected_fields = { "platform", "arch", + "kind", "minimum_macos", "path", + "entrypoint", "size", "sha256", "signing", + "files", "contracts", } if not isinstance(item, dict) or set(item) != expected_fields: return False, {}, ["runtime artifact manifest shape is invalid"] + signing = item.get("signing") if not _TEAM_ID_RE.fullmatch(EXPECTED_DEVELOPER_ID_TEAM): errors.append("operator Developer ID Team ID is not configured") @@ -225,114 +360,172 @@ def verify_release( except (KeyError, TypeError): contracts = frozenset() if contracts != REQUIRED_CONTRACTS or len(item.get("contracts", [])) != len(contracts): - errors.append("runtime artifact does not advertise the exact required route/action contract") + errors.append( + "runtime artifact does not advertise the exact required route/action contract" + ) + try: + records = runtime_bundle.validate_file_records(item.get("files")) + artifact_digest = runtime_bundle.compute_bundle_identity(records) + except runtime_bundle.BundleContractError: + records = () + artifact_digest = "" + errors.append("runtime bundle file records are invalid") + + identity_value = signing.get("identity") if isinstance(signing, dict) else None + identity_match = ( + re.fullmatch( + r"Developer ID Application: [^\r\n]{1,160} \(([A-Z0-9]{10})\)", + identity_value, + ) + if isinstance(identity_value, str) + else None + ) if ( item.get("platform") != "darwin" or item.get("arch") != "arm64" - or item.get("minimum_macos") != "14.0" - or item.get("path") != RUNTIME_REL.as_posix() + or item.get("kind") != "standalone_bundle" + or item.get("minimum_macos") != EXPECTED_MINIMUM_MACOS + or item.get("path") != RUNTIME_BUNDLE_REL.as_posix() + or item.get("entrypoint") != runtime_bundle.ENTRYPOINT_NAME or type(item.get("size")) is not int or not 1 <= item["size"] <= MAX_ARTIFACT_BYTES + or item["size"] != sum(record["size"] for record in records) or not isinstance(item.get("sha256"), str) + or item["sha256"] != artifact_digest or not isinstance(signing, dict) - or set(signing) != {"team_id", "require_notarization", "hardened_runtime"} + or set(signing) + != { + "mode", + "identity", + "team_id", + "require_notarization", + "hardened_runtime", + "secure_timestamp", + } + or signing.get("mode") != "developer_id" + or identity_match is None or not isinstance(signing.get("team_id"), str) - or not _TEAM_ID_RE.fullmatch(signing["team_id"]) + or _TEAM_ID_RE.fullmatch(signing["team_id"]) is None + or identity_match.group(1) != signing["team_id"] or signing["team_id"] != EXPECTED_DEVELOPER_ID_TEAM or signing.get("require_notarization") is not True or signing.get("hardened_runtime") is not True + or signing.get("secure_timestamp") is not True + or any( + record["signing_profile"] != "production_developer_id" + for record in records + ) ): errors.append("runtime artifact platform/signing fields are invalid") - binary = root / PLUGIN_REL / RUNTIME_REL + + bundle = root / PLUGIN_REL / RUNTIME_BUNDLE_REL try: - info = binary.lstat() + bundle_info = bundle.lstat() + names = sorted(path.name for path in bundle.iterdir()) + expected_names = [record["path"] for record in records] if ( - not stat.S_ISREG(info.st_mode) - or stat.S_ISLNK(info.st_mode) - or info.st_uid != os.getuid() - or info.st_nlink != 1 - or info.st_mode & (stat.S_IWGRP | stat.S_IWOTH | stat.S_ISUID | stat.S_ISGID) - or not info.st_mode & stat.S_IXUSR + not stat.S_ISDIR(bundle_info.st_mode) + or stat.S_ISLNK(bundle_info.st_mode) + or bundle_info.st_uid != os.getuid() + or stat.S_IMODE(bundle_info.st_mode) != runtime_bundle.INSTALL_MODE + or names != expected_names ): - errors.append("runtime artifact ownership, mode, or link count is unsafe") - if info.st_size != item.get("size"): - errors.append("runtime artifact size does not match the manifest") - digest = _sha256(binary) - if digest != item.get("sha256"): - errors.append("runtime artifact digest does not match the manifest") + errors.append("runtime bundle root identity or membership is unsafe") except OSError: - digest = "" - errors.append("runtime artifact is missing or unreadable") - if platform.system().lower() != "darwin" or platform.machine().lower() not in {"arm64", "aarch64"}: + errors.append("runtime bundle is missing or unreadable") + + file_evidence: list[dict[str, Any]] = [] + metadata_valid = not errors + for record in records: + member = bundle / record["path"] + member_errors: list[str] = [] + try: + info = member.lstat() + if ( + not stat.S_ISREG(info.st_mode) + or stat.S_ISLNK(info.st_mode) + or info.st_uid != os.getuid() + or info.st_nlink != 1 + or stat.S_IMODE(info.st_mode) != record["install_mode"] + or info.st_size != record["size"] + or _sha256(member) != record["sha256"] + ): + member_errors.append("runtime bundle member identity is unsafe") + except OSError: + member_errors.append("runtime bundle member is missing or unreadable") + evidence_row = { + "path": record["path"], + "sha256": record["sha256"], + "codesign_timestamp": "", + "spctl_source": "", + "codesign_verified": False, + "hardened_runtime_verified": False, + "macho_arch_verified": False, + "macho_type_verified": False, + "minimum_macos_verified": False, + } + if metadata_valid and not member_errors: + arch_ok, minimum_ok, type_ok, macho_error = _verify_macho_record( + member, record + ) + evidence_row["macho_arch_verified"] = arch_ok + evidence_row["minimum_macos_verified"] = minimum_ok + evidence_row["macho_type_verified"] = type_ok + if macho_error: + member_errors.append(macho_error) + if not member_errors: + signature_evidence, signature_errors = _verify_member_signature( + member, + signing=signing, + assess_notarization=record["role"] == "entrypoint", + ) + evidence_row.update(signature_evidence) + member_errors.extend(signature_errors) + errors.extend( + f"{record['path']}: {message}" for message in member_errors + ) + file_evidence.append(evidence_row) + + if platform.system().lower() != "darwin" or platform.machine().lower() not in { + "arm64", + "aarch64", + }: errors.append("signing verification must run on Darwin arm64") - codesign_detail_output = "" - codesign_timestamp = "" - spctl_source = "" - macho_arch_verified = False - minimum_macos_verified = False - if not errors: + + entrypoint_evidence = next( ( - macho_arch_verified, - minimum_macos_verified, - macho_error, - ) = _verify_macho_identity(binary, minimum_macos=EXPECTED_MINIMUM_MACOS) - if macho_error: - errors.append(macho_error) - if not errors: - commands = ( - ("codesign-verify", ["/usr/bin/codesign", "--verify", "--strict", "--verbose=4", str(binary)]), - ("codesign-detail", ["/usr/bin/codesign", "-dv", "--verbose=4", str(binary)]), - ("spctl", ["/usr/sbin/spctl", "--assess", "--type", "execute", "--verbose=4", str(binary)]), - ) - spctl_output = "" - for tool, command in commands: - try: - result = subprocess.run(command, capture_output=True, text=True, timeout=30) - except (OSError, subprocess.SubprocessError): - errors.append("macOS signing/notarization verification tool failed") - break - output = result.stdout + result.stderr - if tool == "codesign-detail": - codesign_detail_output = output - elif tool == "spctl": - spctl_output = output - if result.returncode != 0: - errors.append("macOS signing or notarization verification failed") - break - team_ids = _CODESIGN_TEAM_RE.findall(codesign_detail_output) - if signing and team_ids != [EXPECTED_DEVELOPER_ID_TEAM]: - errors.append("runtime signing team does not match the pinned operator identity") - hardened = any( - int(match.group(1), 16) & 0x10000 - for match in _CODESIGN_FLAGS_RE.finditer(codesign_detail_output) - ) - if not hardened: - errors.append("runtime hardened-signing flags are missing") - codesign_timestamp = _secure_codesign_timestamp(codesign_detail_output) - if not codesign_timestamp: - errors.append("runtime code signature is missing a secure Timestamp") - spctl_source = _spctl_source(spctl_output) - if not spctl_source: - errors.append("runtime notarization source is not exactly Notarized Developer ID") + row + for row, record in zip(file_evidence, records, strict=True) + if record["role"] == "entrypoint" + ), + {}, + ) evidence = { - "schema_version": 1, + "schema_version": 2, "git_sha": git_sha, "manifest_sha256": _sha256(manifest_path) if manifest_path.is_file() else "", - "artifact_path": (PLUGIN_REL / RUNTIME_REL).as_posix(), - "artifact_sha256": digest, + "artifact_path": (PLUGIN_REL / RUNTIME_BUNDLE_REL).as_posix(), + "artifact_sha256": artifact_digest, + "artifact_size": item.get("size", 0), + "files": file_evidence, "signing_policy_sha256": signing_policy_sha256, "team_id": signing.get("team_id", "") if isinstance(signing, dict) else "", - "codesign_timestamp": codesign_timestamp, - "spctl_source": spctl_source, - "codesign_verified": not errors, - "notarization_verified": not errors, - "hardened_runtime_verified": not errors, - "macho_arch_verified": macho_arch_verified, - "minimum_macos_verified": minimum_macos_verified, + "codesign_timestamp": entrypoint_evidence.get("codesign_timestamp", ""), + "spctl_source": entrypoint_evidence.get("spctl_source", ""), + "codesign_verified": bool(file_evidence) + and all(row["codesign_verified"] for row in file_evidence), + "notarization_verified": entrypoint_evidence.get("spctl_source") + == "Notarized Developer ID", + "hardened_runtime_verified": bool(file_evidence) + and all(row["hardened_runtime_verified"] for row in file_evidence), + "macho_arch_verified": bool(file_evidence) + and all(row["macho_arch_verified"] for row in file_evidence), + "macho_type_verified": bool(file_evidence) + and all(row["macho_type_verified"] for row in file_evidence), + "minimum_macos_verified": bool(file_evidence) + and all(row["minimum_macos_verified"] for row in file_evidence), } return not errors, evidence, errors - - def verify_evidence(root: Path, evidence_path: Path, *, git_sha: str) -> list[str]: root = root.resolve() try: @@ -345,6 +538,8 @@ def verify_evidence(root: Path, evidence_path: Path, *, git_sha: str) -> list[st "manifest_sha256", "artifact_path", "artifact_sha256", + "artifact_size", + "files", "signing_policy_sha256", "team_id", "codesign_timestamp", @@ -353,15 +548,18 @@ def verify_evidence(root: Path, evidence_path: Path, *, git_sha: str) -> list[st "notarization_verified", "hardened_runtime_verified", "macho_arch_verified", + "macho_type_verified", "minimum_macos_verified", } errors: list[str] = [] if not isinstance(evidence, dict) or set(evidence) != expected_keys: return ["runtime verification evidence shape is invalid"] if ( - not _exact_int(evidence.get("schema_version"), 1) + not _exact_int(evidence.get("schema_version"), 2) or evidence.get("git_sha") != git_sha - or evidence.get("artifact_path") != (PLUGIN_REL / RUNTIME_REL).as_posix() + or evidence.get("artifact_path") + != (PLUGIN_REL / RUNTIME_BUNDLE_REL).as_posix() + or type(evidence.get("artifact_size")) is not int or not isinstance(evidence.get("codesign_timestamp"), str) or not _secure_codesign_timestamp( f"Timestamp={evidence.get('codesign_timestamp', '')}" @@ -371,11 +569,12 @@ def verify_evidence(root: Path, evidence_path: Path, *, git_sha: str) -> list[st or evidence.get("notarization_verified") is not True or evidence.get("hardened_runtime_verified") is not True or evidence.get("macho_arch_verified") is not True + or evidence.get("macho_type_verified") is not True or evidence.get("minimum_macos_verified") is not True ): errors.append("runtime verification evidence is not valid for this release commit") + manifest_path = root / MANIFEST_REL - binary = root / PLUGIN_REL / RUNTIME_REL signing_policy_sha256, signing_policy_error = _signing_policy_digest(root) if signing_policy_error: errors.append(signing_policy_error) @@ -384,19 +583,74 @@ def verify_evidence(root: Path, evidence_path: Path, *, git_sha: str) -> list[st try: if evidence.get("manifest_sha256") != _sha256(manifest_path): errors.append("runtime evidence manifest digest mismatch") - if evidence.get("artifact_sha256") != _sha256(binary): - errors.append("runtime evidence artifact digest mismatch") manifest = json.loads(manifest_path.read_text(encoding="utf-8")) - signing = manifest["artifacts"][0]["signing"] + item = manifest["artifacts"][0] + records = runtime_bundle.validate_file_records(item["files"]) + artifact_digest = runtime_bundle.compute_bundle_identity(records) + if evidence.get("artifact_sha256") != artifact_digest: + errors.append("runtime evidence artifact digest mismatch") + if evidence.get("artifact_size") != sum(record["size"] for record in records): + errors.append("runtime evidence artifact size mismatch") + signing = item["signing"] if evidence.get("team_id") != signing.get("team_id"): errors.append("runtime evidence signing team mismatch") if evidence.get("team_id") != EXPECTED_DEVELOPER_ID_TEAM: errors.append("runtime evidence does not match the pinned operator identity") - except (OSError, ValueError, KeyError, IndexError, TypeError): + + rows = evidence.get("files") + row_keys = { + "path", + "sha256", + "codesign_timestamp", + "spctl_source", + "codesign_verified", + "hardened_runtime_verified", + "macho_arch_verified", + "macho_type_verified", + "minimum_macos_verified", + } + if ( + type(rows) is not list + or len(rows) != len(records) + or any(type(row) is not dict or set(row) != row_keys for row in rows) + ): + errors.append("runtime evidence bundle-member shape mismatch") + else: + for row, record in zip(rows, records, strict=True): + member = root / PLUGIN_REL / RUNTIME_BUNDLE_REL / record["path"] + if ( + row["path"] != record["path"] + or row["sha256"] != record["sha256"] + or _sha256(member) != record["sha256"] + or not _secure_codesign_timestamp( + f"Timestamp={row['codesign_timestamp']}" + ) + or row["codesign_verified"] is not True + or row["hardened_runtime_verified"] is not True + or row["macho_arch_verified"] is not True + or row["macho_type_verified"] is not True + or row["minimum_macos_verified"] is not True + or ( + record["role"] == "entrypoint" + and row["spctl_source"] != "Notarized Developer ID" + ) + or ( + record["role"] != "entrypoint" + and row["spctl_source"] not in {"", "Notarized Developer ID"} + ) + ): + errors.append("runtime evidence bundle-member identity mismatch") + break + except ( + OSError, + ValueError, + KeyError, + IndexError, + TypeError, + runtime_bundle.BundleContractError, + ): errors.append("runtime evidence cannot be bound to the packaged artifact") return errors - - def main(argv: list[str] | None = None) -> int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--repo-root", type=Path, default=REPO_ROOT) diff --git a/skill-specs/agent-runtime-status.md b/skill-specs/agent-runtime-status.md index 264ea4a..a9668f8 100644 --- a/skill-specs/agent-runtime-status.md +++ b/skill-specs/agent-runtime-status.md @@ -20,7 +20,7 @@ skill; no external repository is required. 2. Resolve the current primary/model/session from host evidence or explicit configuration. Do not infer the primary from installed CLIs. 3. Submit a bounded `readiness` request for each exact matrix row: - Gemini `advisory|long_context`; Codex `advisory`; OpenCode `plan|build`; + Gemini `advisory|governance|long_context`; Codex `advisory`; OpenCode `plan|build`; Grok `architecture|governance|huge_context`; Composer `codegen`. 4. Preserve typed results such as `unavailable`, `auth_error`, `quota_error`, `containment_error`, `timeout`, and `output_limit`. Do not flatten them into @@ -43,6 +43,7 @@ agent-collab runtime: - async inbox: - native artifact: - gemini/advisory: +- gemini/governance: - gemini/long_context: - codex/advisory: - opencode/plan: diff --git a/skill-specs/governance-review.md b/skill-specs/governance-review.md index ffd6c67..c8a62b7 100644 --- a/skill-specs/governance-review.md +++ b/skill-specs/governance-review.md @@ -19,15 +19,21 @@ family. If either is unknown, fail closed with `unknown_family`; preserve the error detail that identifies which snapshot could not be proved. Exclude both families from the reviewer panel, tiebreaker, fallback, and retry set. -Seal the request as read-only advisory authority. A governance review must +Seal the request as read-only authority. A governance review must never enter a mutation-capable worker route. If no independent managed reviewer is eligible, return `unavailable`; a specifically selected same-family route is `same_family_blocked`. Do not weaken independence or use a same-family result. -Only advertised read-only Gemini advisory, Codex advisory, and Grok -`governance` action are eligible. OpenCode plan, every build role, and +The only eligible advertised read-only actions are Gemini `governance`, Codex +advisory, and Grok `governance`. OpenCode plan, every build role, and Composer codegen are never governance-review candidates. +Gemini governance is a distinct broker-only action, never an advisory alias. +Accept its result only when the public client validates the complete artifact- +bound governance proof, including canonical real-HOME containment, controlling +PTY, state lease, cleanup, exact model/effort, response hash, and proof hash. +Gemini advisory or long-context output is not governance evidence. + Claude participation can occur only through a separately configured host-owned async transport after readiness is observed. The public coordinator neither sends nor accepts governance over `inbox/async`; never create a synchronous diff --git a/skill-specs/intent-check.md b/skill-specs/intent-check.md index 44784a4..cb8f3b4 100644 --- a/skill-specs/intent-check.md +++ b/skill-specs/intent-check.md @@ -15,7 +15,7 @@ Resolve the **plugin root** from this loaded file and invoke only `python3 "/coordinator.py"` with a bounded governance-review request. The public policy captures the active primary/model/session and artifact-author model, excludes both families, and selects only an eligible -Gemini or Codex advisory row, or a Grok 4.5 governance-review row. No reviewer +Gemini or Grok governance row, or a Codex advisory row. No reviewer or escalation model is fixed in this skill. Claude is asynchronous inbox-only. ## Workflow diff --git a/skill-specs/route.md b/skill-specs/route.md index 5b7c691..63018d2 100644 --- a/skill-specs/route.md +++ b/skill-specs/route.md @@ -30,7 +30,7 @@ read-only authority across attempts and attempt each target at most once. Automatic worker routing is temporarily unavailable; require an explicit managed worker target. -Seal only supported native contracts: Gemini advisory/long-context is +Seal only supported native contracts: Gemini advisory, governance, and long-context are read-only; Codex advisory is read-only; OpenCode plan is read-only and OpenCode build is mutation-capable workspace-write; Grok architecture, governance, and huge-context are read-only; Composer codegen is output-only. Codex build @@ -40,5 +40,10 @@ execution targets unavailable. A host async inbox is eligible only after an availability observation and exposes readiness only; the public coordinator never sends. +Managed provider execution uses canonical passwd HOME for reliable interactive +authentication state. This does not relax family exclusion, route authority, +the signed broker boundary, bounded lifecycle, or the prohibition on raw CLI +fallbacks. + Execution mechanics belong to the verified co-packaged native artifact. This skill contains no provider command and authorizes no direct invocation. diff --git a/tests/_runtime_bundle_loader.py b/tests/_runtime_bundle_loader.py new file mode 100644 index 0000000..43a00a0 --- /dev/null +++ b/tests/_runtime_bundle_loader.py @@ -0,0 +1,16 @@ +"""Load the public runtime-bundle verifier without making the plugin a package.""" + +from __future__ import annotations + +import importlib.util +from pathlib import Path +import sys + + +MODULE = Path(__file__).resolve().parents[1] / "plugins" / "agent-collab" / "runtime_bundle.py" +spec = importlib.util.spec_from_file_location("agent_collab_public_runtime_bundle", MODULE) +if spec is None or spec.loader is None: + raise RuntimeError("public runtime bundle verifier cannot be loaded") +rb = importlib.util.module_from_spec(spec) +sys.modules[spec.name] = rb +spec.loader.exec_module(rb) diff --git a/tests/fixtures/runtime_bundle_contract_v3.json b/tests/fixtures/runtime_bundle_contract_v3.json new file mode 100644 index 0000000..3dc216a --- /dev/null +++ b/tests/fixtures/runtime_bundle_contract_v3.json @@ -0,0 +1,42 @@ +{ + "schema_version": 1, + "native_contract_version": 3, + "identity_domain": "agent-collab-runtime-bundle\u0000v1\u0000", + "records": [ + { + "path": "_ssl.so", + "role": "runtime_library", + "install_mode": 320, + "size": 31, + "sha256": "2222222222222222222222222222222222222222222222222222222222222222", + "macho_type": "bundle", + "architecture": "arm64", + "minimum_macos": "14.0", + "signing_profile": "development_adhoc" + }, + { + "path": "agent-collab-runtime", + "role": "entrypoint", + "install_mode": 320, + "size": 17, + "sha256": "0000000000000000000000000000000000000000000000000000000000000000", + "macho_type": "executable", + "architecture": "arm64", + "minimum_macos": "14.0", + "signing_profile": "development_adhoc" + }, + { + "path": "libpython3.13.dylib", + "role": "runtime_library", + "install_mode": 320, + "size": 23, + "sha256": "1111111111111111111111111111111111111111111111111111111111111111", + "macho_type": "dylib", + "architecture": "arm64", + "minimum_macos": "14.0", + "signing_profile": "development_adhoc" + } + ], + "encoded_hex": "6167656e742d636f6c6c61622d72756e74696d652d62756e646c650076310000000003000000075f73736c2e736f020140000000000000001f22222222222222222222222222222222222222222222222222222222222222220301000000146167656e742d636f6c6c61622d72756e74696d65010140000000000000001100000000000000000000000000000000000000000000000000000000000000000101000000136c6962707974686f6e332e31332e64796c6962020140000000000000001711111111111111111111111111111111111111111111111111111111111111110201", + "artifact_sha256": "a6858541cad502a568a1bc3e666aa560bc3953598012549e757a97db70e18a9c" +} diff --git a/tests/test_agent_collab_migration.py b/tests/test_agent_collab_migration.py index 20418b8..247acaf 100644 --- a/tests/test_agent_collab_migration.py +++ b/tests/test_agent_collab_migration.py @@ -17,6 +17,7 @@ PLUGIN = ROOT / "plugins" / "agent-collab" NATIVE_CAPABILITIES = ( "gemini_advisory", + "gemini_governance", "gemini_long_context", "codex_advisory", "opencode_plan", @@ -58,6 +59,7 @@ def _preflight(self, **kwargs): contracts = frozenset( { ("gemini", "advisory"), + ("gemini", "governance"), ("gemini", "long_context"), ("codex", "advisory"), ("opencode", "plan"), diff --git a/tests/test_agent_collab_runtime_client.py b/tests/test_agent_collab_runtime_client.py index c89e1f9..bdbceeb 100644 --- a/tests/test_agent_collab_runtime_client.py +++ b/tests/test_agent_collab_runtime_client.py @@ -81,8 +81,35 @@ def setUp(self) -> None: ) self.platform_patch.start() self.arch_patch.start() + def fixture_inspector(path, record, *, signing): + valid, error = self.client._verify_macos_signature( + path, + team_id=signing["team_id"], + identity=signing["identity"], + secure_timestamp=signing["secure_timestamp"], + require_notarization=( + record["role"] == "entrypoint" + and signing["require_notarization"] + ), + ) + if not valid: + raise self.client._RuntimeSignatureError(error) + return { + "macho_type": record["macho_type"], + "architecture": record["architecture"], + "minimum_macos": record["minimum_macos"], + "signing_profile": record["signing_profile"], + } + + self.inspector_patch = mock.patch.object( + self.client, + "_inspect_runtime_member", + side_effect=fixture_inspector, + ) + self.inspector_patch.start() def tearDown(self) -> None: + self.inspector_patch.stop() self.arch_patch.stop() self.platform_patch.stop() self.team_patch.stop() @@ -125,8 +152,17 @@ def _fixture( ) -> Path: system = "darwin" arch = "arm64" - binary = self.root / "runtime" / f"{system}-{arch}" / "agent-collab-runtime" - binary.parent.mkdir(parents=True, exist_ok=True) + bundle = ( + self.root + / "runtime" + / f"{system}-{arch}" + / "agent-collab-runtime.bundle" + ) + bundle.mkdir(parents=True, exist_ok=True) + binary = bundle / "agent-collab-runtime" + bundle.chmod(0o700) + if binary.exists(): + binary.chmod(0o700) binary.write_text( body or """#!/usr/bin/env python3 @@ -151,6 +187,7 @@ def _fixture( "tmpdir_mode": oct(os.stat(os.environ["TMPDIR"]).st_mode & 0o777), "home": os.environ.get("HOME", ""), "host_context": request.get("host_context", ""), + "governance_evidence": False, }, "provenance": { "route": request["route"], @@ -166,25 +203,47 @@ def _fixture( """, encoding="utf-8", ) - binary.chmod(binary.stat().st_mode | stat.S_IXUSR) + binary.chmod(0o500) content = binary.read_bytes() + records = [ + { + "path": "agent-collab-runtime", + "role": "entrypoint", + "install_mode": 0o500, + "size": len(content), + "sha256": hashlib.sha256(content).hexdigest(), + "macho_type": "executable", + "architecture": "arm64", + "minimum_macos": "14.0", + "signing_profile": "production_developer_id", + } + ] + bundle.chmod(0o500) manifest = { - "schema_version": 1, + "schema_version": 2, "protocol_version": 1, - "contract_version": 1, + "contract_version": 3, + "broker_protocol_version": 2, + "channel": "production", "artifacts": [ { "platform": system, "arch": arch, + "kind": "standalone_bundle", "minimum_macos": "14.0", - "path": str(binary.relative_to(self.root)), + "path": str(bundle.relative_to(self.root)), + "entrypoint": "agent-collab-runtime", "size": len(content), - "sha256": hashlib.sha256(content).hexdigest(), + "sha256": self.client.runtime_bundle.compute_bundle_identity(records), "signing": { + "mode": "developer_id", + "identity": "Developer ID Application: Test Operator (TESTTEAM01)", "team_id": "TESTTEAM01", "require_notarization": True, "hardened_runtime": True, + "secure_timestamp": True, }, + "files": records, "contracts": [ {"route": route, "action": action} for route, action in ( @@ -243,6 +302,10 @@ def _envelope( ): defaults: dict[tuple[str, str], dict[str, object]] = { ("gemini", "advisory"): {"model": "google/gemini-test", "effort": "high"}, + ("gemini", "governance"): { + "model": "google/gemini-3.1-pro", + "effort": "high", + }, ("gemini", "long_context"): { "model": "google/gemini-test", "effort": "high", @@ -315,11 +378,15 @@ def test_source_manifest_never_contains_unsigned_placeholder(self) -> None: if manifest["artifacts"]: for artifact in manifest["artifacts"]: path = plugin_root / artifact["path"] - self.assertTrue(path.is_file()) + self.assertTrue(path.is_dir()) self.assertFalse(path.is_symlink()) - self.assertEqual(path.stat().st_size, artifact["size"]) self.assertEqual( - hashlib.sha256(path.read_bytes()).hexdigest(), artifact["sha256"] + sum((path / item["path"]).stat().st_size for item in artifact["files"]), + artifact["size"], + ) + self.assertEqual( + self.client.runtime_bundle.compute_bundle_identity(artifact["files"]), + artifact["sha256"], ) else: self.assertFalse((plugin_root / "runtime").exists()) @@ -329,9 +396,11 @@ def test_source_manifest_never_contains_unsigned_placeholder(self) -> None: def test_empty_manifest_is_unavailable_before_platform_rejection(self) -> None: manifest = { - "schema_version": 1, + "schema_version": 2, "protocol_version": 1, - "contract_version": 1, + "contract_version": 3, + "broker_protocol_version": 2, + "channel": "production", "artifacts": [], } (self.root / "runtime-manifest.json").write_text( @@ -364,7 +433,9 @@ def test_valid_fixture_launches_fixed_protocol_with_scrubbed_env(self) -> None: {"LEAK_ME": "secret", "TMPDIR": str(caller_tmpdir)}, clear=False, ): - with mock.patch.object(self.client, "PLUGIN_ROOT", self.root): + with mock.patch.object(self.client, "PLUGIN_ROOT", self.root), mock.patch.object( + self.client, "_launch_broker", side_effect=self._fixture_broker + ): result = self.client.invoke(envelope=envelope) self.assertEqual(result.status, self.client.RuntimeStatus.OK) self.assertEqual(result.result["argv"], ["invoke", "--protocol", "1"]) @@ -379,8 +450,10 @@ def test_valid_fixture_launches_fixed_protocol_with_scrubbed_env(self) -> None: self.assertFalse(child_tmpdir.exists()) def test_resolution_carries_the_verified_artifact_digest(self) -> None: - binary = self._fixture() - expected = hashlib.sha256(binary.read_bytes()).hexdigest() + self._fixture() + expected = json.loads( + (self.root / "runtime-manifest.json").read_text(encoding="utf-8") + )["artifacts"][0]["sha256"] with mock.patch.object( self.client, "_verify_macos_signature", return_value=(True, "") ), mock.patch.object(self.client, "PLUGIN_ROOT", self.root): @@ -406,7 +479,7 @@ def test_broker_frame_is_exact_digest_bound_and_uses_canonical_nonce(self) -> No timeout_ms=30_000, ) self.assertEqual(set(frame), self.client.BROKER_FRAME_KEYS) - self.assertEqual(frame["broker_protocol_version"], 1) + self.assertEqual(frame["broker_protocol_version"], 2) self.assertEqual(frame["runtime_protocol_version"], 1) self.assertEqual(frame["artifact_sha256"], "a" * 64) self.assertEqual(frame["manifest_sha256"], "b" * 64) @@ -469,6 +542,7 @@ def test_broker_frame_reader_rejects_truncated_and_oversize_payloads(self) -> No def test_all_broker_only_routes_use_broker_without_direct_fallback(self) -> None: self._fixture() for route, action in ( + ("codex", "advisory"), ("opencode", "plan"), ("gemini", "advisory"), ("grok", "architecture"), @@ -490,26 +564,11 @@ def test_all_broker_only_routes_use_broker_without_direct_fallback(self) -> None broker.assert_called_once() direct.assert_not_called() - def test_other_routes_retain_direct_runtime_path(self) -> None: - self._fixture() - for route, action in ( - ("codex", "advisory"), - ): - with self.subTest(route=route): - envelope = self._envelope(route=route, action=action) - expected = self.client.RuntimeResult( - self.client.RuntimeStatus.PROVIDER_ERROR, - error="fixture direct result", - ) - with mock.patch.object( - self.client, "_verify_macos_signature", return_value=(True, "") - ), mock.patch.object(self.client, "PLUGIN_ROOT", self.root), mock.patch.object( - self.client, "_launch_runtime", return_value=expected - ) as direct, mock.patch.object(self.client, "_launch_broker") as broker: - result = self.client.invoke(envelope=envelope) - self.assertIs(result, expected) - direct.assert_called_once() - broker.assert_not_called() + def test_every_provider_route_is_broker_only(self) -> None: + self.assertEqual( + self.client.BROKERED_ROUTES, + frozenset({"codex", "opencode", "gemini", "grok", "composer"}), + ) def test_broker_exchange_roundtrips_a_sealed_gemini_response(self) -> None: self._fixture() @@ -538,7 +597,10 @@ def server() -> None: "protocol_version": 1, "request_id": envelope.request_id, "status": "ok", - "result": {"review": "ok"}, + "result": { + "review": "ok", + "governance_evidence": False, + }, "provenance": { "route": envelope.route, "action": envelope.action, @@ -610,22 +672,14 @@ def test_broker_state_and_socket_are_exact_and_fail_closed(self) -> None: root = self.root / "provider-broker" root.mkdir(mode=0o700) state = root / "state.json" - socket_path = root / "provider.sock" - document = { - "schema_version": 1, - "artifact_sha256": "a" * 64, - "manifest_sha256": "b" * 64, - "runtime_path": str( - root / "versions" / f"{'a' * 64}-{'b' * 64}" / "agent-collab-runtime" - ), - "manifest_path": str( - root / "versions" / f"{'a' * 64}-{'b' * 64}" / "runtime-manifest.json" - ), - "plist_sha256": "c" * 64, - "socket_path": str(socket_path), - "label": self.client.BROKER_LABEL, - "previous": None, - } + socket_path = root / self.client.BROKER_SOCKET_FILENAME + document = self.client._record_for( + root=root, + artifact_digest="a" * 64, + manifest_digest="b" * 64, + plist_digest="c" * 64, + previous=None, + ) state.write_text(json.dumps(document), encoding="utf-8") state.chmod(0o600) with mock.patch.object(self.client, "_broker_root", return_value=root), mock.patch.object( @@ -674,7 +728,7 @@ def test_closed_plist_has_socket_activation_and_no_persistence_keys(self) -> Non "Sockets", }, ) - self.assertEqual(document["ProgramArguments"], [str(runtime), "broker", "--protocol", "1"]) + self.assertEqual(document["ProgramArguments"], [str(runtime), "broker", "--protocol", "2"]) self.assertEqual(document["ThrottleInterval"], 0) self.assertNotIn("KeepAlive", document) self.assertNotIn("RunAtLoad", document) @@ -683,7 +737,7 @@ def test_closed_plist_has_socket_activation_and_no_persistence_keys(self) -> Non ) def _broker_lifecycle_patches(self, root: Path): - socket_path = root / "provider.sock" + socket_path = root / self.client.BROKER_SOCKET_FILENAME def bootstrap(_plist): if not socket_path.exists(): @@ -704,9 +758,11 @@ def bootstrap(_plist): ) def test_install_broker_publishes_exact_version_and_socket_activated_state(self) -> None: - binary = self._fixture() + self._fixture() root = self.root / "broker-state" - expected_artifact = hashlib.sha256(binary.read_bytes()).hexdigest() + expected_artifact = json.loads( + (self.root / "runtime-manifest.json").read_text(encoding="utf-8") + )["artifacts"][0]["sha256"] patches = self._broker_lifecycle_patches(root) with mock.patch.object( self.client, "_verify_macos_signature", return_value=(True, "") @@ -718,10 +774,16 @@ def test_install_broker_publishes_exact_version_and_socket_activated_state(self) self.assertEqual(state["artifact_sha256"], expected_artifact) self.assertIsNone(state["previous"]) self.assertEqual(stat.S_IMODE((root / "broker.plist").stat().st_mode), 0o600) - version = Path(state["runtime_path"]).parent + version = Path(state["bundle_path"]).parent + self.assertEqual( + version.name, + f"{expected_artifact}-{state['manifest_sha256']}", + ) self.assertEqual(stat.S_IMODE(version.stat().st_mode), 0o500) self.assertEqual( - hashlib.sha256((version / "agent-collab-runtime").read_bytes()).hexdigest(), + self.client.runtime_bundle.compute_bundle_identity( + json.loads((version / "runtime-manifest.json").read_text())["artifacts"][0]["files"] + ), expected_artifact, ) plist = plistlib.loads((root / "broker.plist").read_bytes()) @@ -743,16 +805,20 @@ def test_missing_broker_root_is_uninstalled_not_an_integrity_failure(self) -> No ) def test_broker_update_records_one_verified_rollback_and_rollback_switches(self) -> None: - first = self._fixture(body="#!/bin/sh\nexit 0\n") - first_digest = hashlib.sha256(first.read_bytes()).hexdigest() + self._fixture(body="#!/bin/sh\nexit 0\n") + first_digest = json.loads( + (self.root / "runtime-manifest.json").read_text(encoding="utf-8") + )["artifacts"][0]["sha256"] root = self.root / "broker-state" patches = self._broker_lifecycle_patches(root) with mock.patch.object( self.client, "_verify_macos_signature", return_value=(True, "") ), mock.patch.object(self.client, "PLUGIN_ROOT", self.root), patches[0], patches[1], patches[2], patches[3], patches[4]: self.assertEqual(self.client.install_broker().status, self.client.RuntimeStatus.OK) - second = self._fixture(body="#!/bin/sh\nexit 7\n") - second_digest = hashlib.sha256(second.read_bytes()).hexdigest() + self._fixture(body="#!/bin/sh\nexit 7\n") + second_digest = json.loads( + (self.root / "runtime-manifest.json").read_text(encoding="utf-8") + )["artifacts"][0]["sha256"] updated = self.client.install_broker() self.assertEqual(updated.status, self.client.RuntimeStatus.OK, updated.error) state = json.loads((root / "state.json").read_text(encoding="utf-8")) @@ -805,8 +871,10 @@ def test_noop_broker_install_preserves_existing_rollback_target(self) -> None: self.assertEqual(state, prior) def test_same_artifact_with_new_manifest_gets_a_distinct_immutable_version(self) -> None: - binary = self._fixture() - artifact_digest = hashlib.sha256(binary.read_bytes()).hexdigest() + self._fixture() + artifact_digest = json.loads( + (self.root / "runtime-manifest.json").read_text(encoding="utf-8") + )["artifacts"][0]["sha256"] root = self.root / "broker-state" patches = self._broker_lifecycle_patches(root) with mock.patch.object( @@ -823,7 +891,7 @@ def test_same_artifact_with_new_manifest_gets_a_distinct_immutable_version(self) second = json.loads((root / "state.json").read_text(encoding="utf-8")) self.assertEqual(second["artifact_sha256"], artifact_digest) self.assertNotEqual(second["manifest_sha256"], first["manifest_sha256"]) - self.assertNotEqual(second["runtime_path"], first["runtime_path"]) + self.assertNotEqual(second["bundle_path"], first["bundle_path"]) self.assertEqual(second["previous"]["manifest_sha256"], first["manifest_sha256"]) self.assertEqual(len(tuple((root / "versions").iterdir())), 2) @@ -858,7 +926,7 @@ def test_uninstall_removes_mutable_state_but_retains_versions(self) -> None: self.assertTrue(any((root / "versions").iterdir())) self.assertFalse((root / "state.json").exists()) self.assertFalse((root / "broker.plist").exists()) - self.assertFalse((root / "provider.sock").exists()) + self.assertFalse((root / self.client.BROKER_SOCKET_FILENAME).exists()) def test_foreign_or_malformed_broker_plist_blocks_update(self) -> None: self._fixture() @@ -967,6 +1035,8 @@ def test_hostile_environment_is_not_forwarded_to_native_runtime(self) -> None: self.client, "_verify_macos_signature", return_value=(True, "") ), mock.patch.dict(os.environ, hostile, clear=True), mock.patch.object( self.client, "PLUGIN_ROOT", self.root + ), mock.patch.object( + self.client, "_launch_broker", side_effect=self._fixture_broker ): result = self.client.invoke(envelope=envelope) self.assertEqual(result.status, self.client.RuntimeStatus.OK) @@ -1085,7 +1155,9 @@ def test_each_invocation_gets_a_fresh_isolated_tmpdir(self) -> None: self.client, "_verify_macos_signature", return_value=(True, "") ), mock.patch.dict( os.environ, {"TMPDIR": str(caller_tmpdir)}, clear=False - ), mock.patch.object(self.client, "PLUGIN_ROOT", self.root): + ), mock.patch.object(self.client, "PLUGIN_ROOT", self.root), mock.patch.object( + self.client, "_launch_broker", side_effect=self._fixture_broker + ): for request_id in ("fresh-1", "fresh-2"): result = self.client.invoke( envelope=self._envelope(request_id=request_id) @@ -1118,9 +1190,11 @@ def test_policy_safe_mode_never_launches_native_runtime(self) -> None: def test_symlink_and_parent_escape_are_rejected(self) -> None: binary = self._fixture() + binary.parent.chmod(0o700) real = binary.with_name("real-runtime") binary.rename(real) binary.symlink_to(real.name) + binary.parent.chmod(0o500) with mock.patch.object( self.client, "_verify_macos_signature", return_value=(True, "") ): @@ -1139,7 +1213,11 @@ def test_symlink_and_parent_escape_are_rejected(self) -> None: def test_size_hash_and_signature_fail_closed(self) -> None: binary = self._fixture() + binary.parent.chmod(0o700) + binary.chmod(0o700) binary.write_text(binary.read_text() + "# changed\n") + binary.chmod(0o500) + binary.parent.chmod(0o500) with mock.patch.object(self.client, "PLUGIN_ROOT", self.root): resolution = self.client.resolve_runtime() self.assertEqual(resolution.status, self.client.RuntimeStatus.INTEGRITY_ERROR) @@ -1647,7 +1725,9 @@ def test_native_typed_failures_are_preserved(self) -> None: with mock.patch.object( self.client, "_verify_macos_signature", return_value=(True, "") ): - with mock.patch.object(self.client, "PLUGIN_ROOT", self.root): + with mock.patch.object(self.client, "PLUGIN_ROOT", self.root), mock.patch.object( + self.client, "_launch_broker", side_effect=self._fixture_broker + ): result = self.client.invoke(envelope=envelope) self.assertEqual(result.status, expected) @@ -1678,6 +1758,8 @@ def test_output_limit_terminates_and_reaps_child_while_running(self) -> None: self.client, "MAX_RESPONSE_BYTES", 1024 ), mock.patch.dict( os.environ, {"TMPDIR": str(caller_tmpdir)}, clear=False + ), mock.patch.object( + self.client, "_launch_broker", side_effect=self._fixture_broker ): result = self.client.invoke(envelope=envelope) self.assertEqual(result.status, self.client.RuntimeStatus.OUTPUT_LIMIT) @@ -1704,6 +1786,8 @@ def test_timeout_reaps_child_and_removes_isolated_tmpdir(self) -> None: self.client, "PLUGIN_ROOT", self.root ), mock.patch.dict( os.environ, {"TMPDIR": str(caller_tmpdir)}, clear=False + ), mock.patch.object( + self.client, "_launch_broker", side_effect=self._fixture_broker ): result = self.client.invoke(envelope=envelope) @@ -1731,6 +1815,8 @@ def recording_mkdtemp(*args, **kwargs): self.client.tempfile, "mkdtemp", side_effect=recording_mkdtemp ), mock.patch.object( self.client.subprocess, "Popen", side_effect=OSError("spawn failed") + ), mock.patch.object( + self.client, "_launch_broker", side_effect=self._fixture_broker ): result = self.client.invoke(envelope=envelope) @@ -1802,20 +1888,18 @@ def test_unsealed_or_tampered_envelope_cannot_launch(self) -> None: def test_hardlinked_or_group_writable_runtime_is_rejected(self) -> None: binary = self._fixture() + binary.parent.chmod(0o700) hardlink = binary.with_name("hardlink") os.link(binary, hardlink) + binary.parent.chmod(0o500) with mock.patch.object(self.client, "PLUGIN_ROOT", self.root): result = self.client.resolve_runtime() self.assertEqual(result.status, self.client.RuntimeStatus.PATH_INVALID) + binary.parent.chmod(0o700) hardlink.unlink() + binary.parent.chmod(0o500) binary.chmod(0o775) - content = binary.read_bytes() - manifest_path = self.root / "runtime-manifest.json" - manifest = json.loads(manifest_path.read_text()) - manifest["artifacts"][0]["size"] = len(content) - manifest["artifacts"][0]["sha256"] = hashlib.sha256(content).hexdigest() - manifest_path.write_text(json.dumps(manifest)) with mock.patch.object(self.client, "PLUGIN_ROOT", self.root): result = self.client.resolve_runtime() self.assertEqual(result.status, self.client.RuntimeStatus.PATH_INVALID) diff --git a/tests/test_gemini_governance_contract.py b/tests/test_gemini_governance_contract.py new file mode 100644 index 0000000..0cbc521 --- /dev/null +++ b/tests/test_gemini_governance_contract.py @@ -0,0 +1,382 @@ +"""End-to-end public contract tests for managed Gemini governance.""" + +from __future__ import annotations + +import hashlib +import importlib.util +import json +import os +import sys +import types +import unittest +from pathlib import Path +from unittest import mock + + +ROOT = Path(__file__).resolve().parents[1] +PLUGIN = ROOT / "plugins" / "agent-collab" + + +def _load(name: str, filename: str): + spec = importlib.util.spec_from_file_location(name, PLUGIN / filename) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + sys.modules[name] = module + spec.loader.exec_module(module) + return module + + +class GeminiGovernancePolicyTests(unittest.TestCase): + @classmethod + def setUpClass(cls) -> None: + cls.policy = _load("gemini_governance_policy", "host_policy.py") + cls.coordinator = _load( + "gemini_governance_coordinator", "coordinator.py" + ) + cls.runtime = _load("gemini_governance_runtime", "runtime_client.py") + + def _profile(self): + return self.policy.HostProfile( + primary_id="claude", + primary_family="anthropic", + active_model="anthropic/claude-opus", + host_runtime="claude-code", + session_identifier="claude-session", + explicit=True, + governance_ready=True, + ) + + def test_route_is_a_distinct_contract_v3_governance_action(self) -> None: + self.assertEqual(self.runtime.CONTRACT_VERSION, 3) + self.assertIn(("gemini", "governance"), self.runtime.SUPPORTED_CONTRACTS) + self.assertIn(("gemini", "governance"), self.policy.ROUTE_ACTIONS) + self.assertIn(("gemini", "governance"), self.policy.GOVERNANCE_CONTRACTS) + self.assertNotIn(("gemini", "advisory"), self.policy.GOVERNANCE_CONTRACTS) + self.assertEqual( + self.policy.AUTHORITIES[("gemini", "governance")], "read_only" + ) + + def test_governance_row_is_exact_model_and_high_effort_only(self) -> None: + for effort in ("high", "xhigh"): + with self.subTest(effort=effort): + row, family, error = self.policy._validate_row( + "gemini", + "governance", + {"model": "google/gemini-3.1-pro", "effort": effort}, + self._profile(), + None, + ) + self.assertEqual( + row, + {"model": "google/gemini-3.1-pro", "effort": effort}, + error, + ) + self.assertEqual(family, "google") + + for row in ( + {"model": "google/gemini-3.1-pro", "effort": "medium"}, + {"model": "google/gemini-2.5-pro", "effort": "high"}, + { + "model": "google/gemini-3.1-pro", + "effort": "high", + "cwd": "/tmp", + }, + ): + with self.subTest(row=row): + validated, _family, error = self.policy._validate_row( + "gemini", "governance", row, self._profile(), None + ) + self.assertIsNone(validated) + self.assertIn("Gemini governance", error) + + def test_direct_schema_requires_governance_authority(self) -> None: + request = { + "protocol_version": 1, + "request_id": "gemini-governance-direct", + "operation": "execute", + "route": "gemini", + "action": "governance", + "timeout_ms": 30_000, + "governance": True, + "primary": { + "primary_id": "claude", + "active_model": "anthropic/claude-opus", + "host_runtime": "claude-code", + "session_identifier": "claude-session", + }, + "row": { + "model": "google/gemini-3.1-pro", + "effort": "high", + }, + "prompt": "Review the captured artifact.", + "artifact": { + "content": "artifact", + "author_model": "openai/gpt-5", + }, + } + validated, _request_id, error = self.coordinator._validate(request) + self.assertEqual(validated, request, error) + + ordinary = dict(request, governance=False) + ordinary.pop("artifact") + rejected, _request_id, error = self.coordinator._validate(ordinary) + self.assertIsNone(rejected) + self.assertEqual(error, self.coordinator.ACTION_AUTHORITY_ERROR) + + def test_automatic_governance_maps_gemini_to_governance_not_advisory(self) -> None: + policy = self.policy + runtime = self.runtime + coordinator = self.coordinator + observed: list[tuple[str, str]] = [] + + def invoke(*, envelope): + observed.append((envelope.route, envelope.action)) + return runtime.RuntimeResult( + runtime.RuntimeStatus.OK, + result={"text": "approved"}, + provenance={"route": envelope.route, "action": envelope.action}, + ) + + request = { + "protocol_version": 1, + "request_id": "gemini-governance-auto", + "operation": "execute", + "route": "auto", + "action": "governance", + "timeout_ms": 30_000, + "governance": True, + "primary": { + "primary_id": "claude", + "active_model": "anthropic/claude-opus", + "host_runtime": "claude-code", + "session_identifier": "claude-session", + }, + "row": { + "gemini": { + "model": "google/gemini-3.1-pro", + "effort": "high", + }, + "codex": { + "model": "openai/codex-test", + "effort": "high", + "mode": "prompt-only", + }, + "grok": {"mode": "prompt-only"}, + }, + "prompt": "Review the captured artifact.", + "artifact": { + "content": "artifact", + "author_model": "openai/gpt-5", + }, + } + contracts = frozenset( + { + ("gemini", "governance"), + ("codex", "advisory"), + ("grok", "governance"), + } + ) + with ( + mock.patch.dict(os.environ, {}, clear=True), + mock.patch.object( + policy, "_runtime_contracts", return_value=(contracts, "digest-2") + ), + mock.patch.object(runtime, "invoke", side_effect=invoke), + mock.patch.object(coordinator, "_inventory_legacy", return_value=()), + mock.patch.object( + coordinator, + "_load", + side_effect=lambda _name, filename: ( + policy if filename == "host_policy.py" else runtime + ), + ), + ): + response, code = coordinator.process(request) + self.assertEqual(code, 0) + self.assertEqual(response["status"], "ok", response) + self.assertEqual(response["selected_route"], "gemini") + self.assertEqual(observed, [("gemini", "governance")]) + + +class GeminiGovernanceResponseTests(unittest.TestCase): + @classmethod + def setUpClass(cls) -> None: + cls.client = _load("gemini_governance_response_client", "runtime_client.py") + + def _envelope(self, *, action: str = "governance", operation: str = "execute"): + model = "google/gemini-3.1-pro" + return types.SimpleNamespace( + request_id="governance-response-1", + route="gemini", + action=action, + authority="read_only", + operation=operation, + row_json=json.dumps( + {"model": model, "effort": "high"}, + sort_keys=True, + separators=(",", ":"), + ), + target_author_family="google", + governance=action == "governance", + primary_family="anthropic", + artifact_present=action == "governance", + artifact_sha256=hashlib.sha256(b"artifact").hexdigest(), + artifact_author_model="openai/gpt-5", + artifact_author_family="openai", + ) + + def _provenance(self, envelope): + return { + "route": envelope.route, + "action": envelope.action, + "authority": envelope.authority, + "author_model": "google/gemini-3.1-pro", + "author_family": "google", + "host_runtime": "agent-collab-provider-runtime/1.2.0", + "session_identifier": "gemini-session", + "observation_sequence": 1, + } + + def _proof(self, envelope, text: str) -> dict[str, object]: + proof: dict[str, object] = { + "version": 1, + "request_id": envelope.request_id, + "action": "governance", + "authority": "read_only", + "transport": "broker", + "backend": "agy", + "runtime_version": "1.2.0", + "contract_version": 3, + "artifact_sha256": envelope.artifact_sha256, + "artifact_author_model": envelope.artifact_author_model, + "artifact_author_family": envelope.artifact_author_family, + "reviewer_model": "google/gemini-3.1-pro", + "reviewer_family": "google", + "selected_display": "Gemini 3.1 Pro (High)", + "effective_effort": "high", + "containment_level": "write_contained_shared_home", + "tools_disabled": False, + "pty_used": True, + "lock_acquired": True, + "cleanup_confirmed": True, + "provider_process_started": True, + "returncode": 0, + "model_source": "agy-selected", + "failed_over": False, + "response_sha256": hashlib.sha256(text.encode("utf-8")).hexdigest(), + } + canonical = json.dumps( + proof, sort_keys=True, separators=(",", ":"), ensure_ascii=True + ).encode("ascii") + proof["proof_sha256"] = hashlib.sha256(canonical).hexdigest() + return proof + + def _response(self, envelope, *, text: str = "approved") -> dict[str, object]: + result = { + "text": text, + "containment_level": "write_contained_shared_home", + "tools_disabled": False, + "pty_used": True, + "lock_acquired": True, + "cleanup_confirmed": True, + "selected_display": "Gemini 3.1 Pro (High)", + "governance_evidence": True, + "artifact_sha256": envelope.artifact_sha256, + "artifact_author_model": envelope.artifact_author_model, + "artifact_author_family": envelope.artifact_author_family, + } + result["governance_proof"] = self._proof(envelope, text) + return { + "protocol_version": 1, + "request_id": envelope.request_id, + "status": "ok", + "result": result, + "provenance": self._provenance(envelope), + } + + def _parse(self, response: dict[str, object], envelope): + return self.client._parse_response( + json.dumps(response, separators=(",", ":")).encode("utf-8"), + envelope, + 0, + ) + + def test_execute_requires_complete_artifact_bound_broker_proof(self) -> None: + envelope = self._envelope() + result = self._parse(self._response(envelope), envelope) + self.assertEqual(result.status, self.client.RuntimeStatus.OK, result.error) + self.assertTrue(result.result["governance_evidence"]) + + mutators = ( + lambda response: response["result"].__setitem__("pty_used", False), + lambda response: response["result"]["governance_proof"].__setitem__( + "artifact_sha256", "0" * 64 + ), + lambda response: response["result"]["governance_proof"].__setitem__( + "proof_sha256", "0" * 64 + ), + lambda response: response["result"]["governance_proof"].__setitem__( + "response_sha256", "0" * 64 + ), + lambda response: response["result"]["governance_proof"].__setitem__( + "transport", "direct" + ), + ) + for mutate in mutators: + with self.subTest(mutate=mutate): + response = self._response(envelope) + mutate(response) + rejected = self._parse(response, envelope) + self.assertEqual( + rejected.status, self.client.RuntimeStatus.PROTOCOL_ERROR + ) + + def test_governance_readiness_requires_shared_home_pty_proof_tuple(self) -> None: + envelope = self._envelope(operation="readiness") + result = { + "ready": True, + "containment_level": "write_contained_shared_home", + "tools_disabled": False, + "pty_used": True, + "lock_acquired": True, + "cleanup_confirmed": True, + "selected_display": "Gemini 3.1 Pro (High)", + "governance_ready": True, + "artifact_sha256": envelope.artifact_sha256, + "artifact_author_model": envelope.artifact_author_model, + "artifact_author_family": envelope.artifact_author_family, + } + response = { + "protocol_version": 1, + "request_id": envelope.request_id, + "status": "ok", + "result": result, + "provenance": self._provenance(envelope), + } + accepted = self._parse(response, envelope) + self.assertEqual( + accepted.status, self.client.RuntimeStatus.OK, accepted.error + ) + result["pty_used"] = False + rejected = self._parse(response, envelope) + self.assertEqual(rejected.status, self.client.RuntimeStatus.PROTOCOL_ERROR) + + def test_advisory_cannot_emit_governance_evidence(self) -> None: + envelope = self._envelope(action="advisory") + response = { + "protocol_version": 1, + "request_id": envelope.request_id, + "status": "ok", + "result": {"text": "advice", "governance_evidence": True}, + "provenance": self._provenance(envelope), + } + rejected = self._parse(response, envelope) + self.assertEqual(rejected.status, self.client.RuntimeStatus.PROTOCOL_ERROR) + + response["result"]["governance_evidence"] = False + accepted = self._parse(response, envelope) + self.assertEqual(accepted.status, self.client.RuntimeStatus.OK, accepted.error) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_plugin_archive.py b/tests/test_plugin_archive.py index 748ce2e..d3674b0 100644 --- a/tests/test_plugin_archive.py +++ b/tests/test_plugin_archive.py @@ -74,13 +74,38 @@ def _activate(self) -> Path: self.plugin / "runtime" / "darwin-arm64" + / "agent-collab-runtime.bundle" / "agent-collab-runtime" ) runtime.parent.mkdir(parents=True) runtime.write_bytes(b"signed-runtime-fixture") - runtime.chmod(0o755) + library = runtime.parent / "libpython3.13.dylib" + library.write_bytes(b"signed-library-fixture") + runtime.chmod(0o500) + library.chmod(0o500) + runtime.parent.chmod(0o500) + records = [] + for member, role, macho_type in ( + (runtime, "entrypoint", "executable"), + (library, "runtime_library", "dylib"), + ): + records.append( + { + "path": member.name, + "role": role, + "install_mode": 0o500, + "size": member.stat().st_size, + "sha256": hashlib.sha256(member.read_bytes()).hexdigest(), + "macho_type": macho_type, + "architecture": "arm64", + "minimum_macos": "14.0", + "signing_profile": "production_developer_id", + } + ) + records.sort(key=lambda item: item["path"].encode("utf-8")) contracts = ( ("gemini", "advisory"), + ("gemini", "governance"), ("gemini", "long_context"), ("codex", "advisory"), ("opencode", "plan"), @@ -91,22 +116,30 @@ def _activate(self) -> Path: ("composer", "codegen"), ) manifest = { - "schema_version": 1, + "schema_version": 2, "protocol_version": 1, - "contract_version": 1, + "contract_version": 3, + "broker_protocol_version": 2, + "channel": "production", "artifacts": [ { "platform": "darwin", "arch": "arm64", + "kind": "standalone_bundle", "minimum_macos": "14.0", - "path": "runtime/darwin-arm64/agent-collab-runtime", - "size": runtime.stat().st_size, - "sha256": hashlib.sha256(runtime.read_bytes()).hexdigest(), + "path": "runtime/darwin-arm64/agent-collab-runtime.bundle", + "entrypoint": "agent-collab-runtime", + "size": sum(record["size"] for record in records), + "sha256": self.builder.runtime_bundle.compute_bundle_identity(records), "signing": { + "mode": "developer_id", + "identity": "Developer ID Application: Test Operator (TESTTEAM01)", "team_id": "TESTTEAM01", "require_notarization": True, "hardened_runtime": True, + "secure_timestamp": True, }, + "files": records, "contracts": [ {"route": route, "action": action} for route, action in contracts @@ -185,11 +218,14 @@ def test_activation_archive_has_exactly_one_runtime_with_byte_mode_parity(self) ] self.assertEqual( [member.name for member in runtime_files], - ["runtime/darwin-arm64/agent-collab-runtime"], + [ + "runtime/darwin-arm64/agent-collab-runtime.bundle/agent-collab-runtime", + "runtime/darwin-arm64/agent-collab-runtime.bundle/libpython3.13.dylib", + ], ) member = runtime_files[0] self.assertEqual(bundle.extractfile(member).read(), runtime.read_bytes()) - self.assertEqual(stat.S_IMODE(member.mode), 0o755) + self.assertEqual(stat.S_IMODE(member.mode), 0o500) for archived in bundle.getmembers(): if not archived.isfile(): @@ -318,7 +354,8 @@ def test_archive_verifier_detects_source_byte_drift(self) -> None: def test_archive_size_limit_and_required_policy_member_are_canonical(self) -> None: self.assertEqual(self.builder.MAX_ARTIFACT_BYTES, 64 * 1024 * 1024) - self.assertEqual(self.builder.RUNTIME_FILE_MODE, 0o755) + self.assertEqual(self.builder.RUNTIME_FILE_MODE, 0o500) + self.assertIn("runtime_bundle.py", self.builder.REQUIRED_ROOTS) self.assertIn("signing_policy.py", self.builder.REQUIRED_ROOTS) self.assertIn("runtime_setup.py", self.builder.REQUIRED_ROOTS) diff --git a/tests/test_provider_plugin_retirement.py b/tests/test_provider_plugin_retirement.py index 3abe1dc..5f88090 100644 --- a/tests/test_provider_plugin_retirement.py +++ b/tests/test_provider_plugin_retirement.py @@ -114,6 +114,7 @@ def test_public_package_has_no_provider_executor_or_compatibility_source(self) - python_files, { "coordinator.py", + "runtime_bundle.py", "runtime_client.py", "runtime_setup.py", "host_policy.py", diff --git a/tests/test_public_export_safety.py b/tests/test_public_export_safety.py index cd09fed..febc114 100644 --- a/tests/test_public_export_safety.py +++ b/tests/test_public_export_safety.py @@ -444,45 +444,74 @@ def test_compiled_runtime_rejects_partial_matrix_and_missing_signing_team(self) / "agent-collab" / "runtime" / "darwin-arm64" + / "agent-collab-runtime.bundle" / "agent-collab-runtime" ) binary.parent.mkdir(parents=True) binary.write_bytes(b"compiled-binary") + binary.chmod(0o500) + binary.parent.chmod(0o500) + records = [ + { + "path": "agent-collab-runtime", + "role": "entrypoint", + "install_mode": 0o500, + "size": len(b"compiled-binary"), + "sha256": hashlib.sha256(b"compiled-binary").hexdigest(), + "macho_type": "executable", + "architecture": "arm64", + "minimum_macos": "14.0", + "signing_profile": "production_developer_id", + } + ] manifest = { - "schema_version": 1, + "schema_version": 2, "protocol_version": 1, - "contract_version": 1, + "contract_version": 3, + "broker_protocol_version": 2, + "channel": "production", "artifacts": [ { "platform": "darwin", "arch": "arm64", + "kind": "standalone_bundle", "minimum_macos": "14.0", - "path": "runtime/darwin-arm64/agent-collab-runtime", + "path": "runtime/darwin-arm64/agent-collab-runtime.bundle", + "entrypoint": "agent-collab-runtime", "size": len(b"compiled-binary"), - "sha256": hashlib.sha256(b"compiled-binary").hexdigest(), + "sha256": self.audit.runtime_bundle.compute_bundle_identity(records), "signing": { "require_notarization": True, "hardened_runtime": True, }, + "files": records, "contracts": [{"route": "composer", "action": "codegen"}], } ], } - (binary.parents[2] / "runtime-manifest.json").write_text( + plugin_root = binary.parents[3] + (plugin_root / "runtime-manifest.json").write_text( json.dumps(manifest), encoding="utf-8" ) violations = self.audit.scan_active_tree(self.root) self.assertTrue(any(item.kind == "unmanifested_runtime" for item in violations)) - manifest["artifacts"][0]["signing"]["team_id"] = "ABCDEFGHIJ" + manifest["artifacts"][0]["signing"].update( + { + "mode": "developer_id", + "identity": "Developer ID Application: Test Operator (ABCDEFGHIJ)", + "team_id": "ABCDEFGHIJ", + "secure_timestamp": True, + } + ) manifest["artifacts"][0]["contracts"] = [ {"route": route, "action": action} for route, action in sorted(self.audit.REQUIRED_RUNTIME_CONTRACTS) ] - (binary.parents[2] / "signing_policy.py").write_text( + (plugin_root / "signing_policy.py").write_text( 'EXPECTED_DEVELOPER_ID_TEAM = "ABCDEFGHIJ"\n', encoding="utf-8" ) - (binary.parents[2] / "runtime-manifest.json").write_text( + (plugin_root / "runtime-manifest.json").write_text( json.dumps(manifest), encoding="utf-8" ) violations = self.audit.scan_active_tree(self.root) diff --git a/tests/test_release_evidence.py b/tests/test_release_evidence.py index 7a0bfd4..be343a1 100644 --- a/tests/test_release_evidence.py +++ b/tests/test_release_evidence.py @@ -102,12 +102,47 @@ def _build_activation(self): ) for name in LEGAL_FILES: shutil.copy2(ROOT / name, repo / name) - runtime = plugin / "runtime" / "darwin-arm64" / "agent-collab-runtime" - runtime.parent.mkdir(parents=True) - runtime.write_bytes(b"signed-runtime-fixture") - runtime.chmod(0o755) + bundle = ( + plugin + / "runtime" + / "darwin-arm64" + / "agent-collab-runtime.bundle" + ) + bundle.mkdir(parents=True) + runtime_members = { + "agent-collab-runtime": ( + b"signed-runtime-entrypoint-fixture", + "entrypoint", + "executable", + ), + "libpython3.13.dylib": ( + b"signed-runtime-library-fixture", + "runtime_library", + "dylib", + ), + } + records = [] + for name, (payload, role, macho_type) in runtime_members.items(): + member = bundle / name + member.write_bytes(payload) + member.chmod(0o500) + records.append( + { + "path": name, + "role": role, + "install_mode": 0o500, + "size": len(payload), + "sha256": hashlib.sha256(payload).hexdigest(), + "macho_type": macho_type, + "architecture": "arm64", + "minimum_macos": "14.0", + "signing_profile": "production_developer_id", + } + ) + bundle.chmod(0o500) contracts = ( ("gemini", "advisory"), + ("gemini", "governance"), ("gemini", "long_context"), ("codex", "advisory"), ("opencode", "plan"), @@ -118,22 +153,34 @@ def _build_activation(self): ("composer", "codegen"), ) manifest = { - "schema_version": 1, + "schema_version": 2, "protocol_version": 1, - "contract_version": 1, + "contract_version": 3, + "broker_protocol_version": 2, + "channel": "production", "artifacts": [ { "platform": "darwin", "arch": "arm64", + "kind": "standalone_bundle", "minimum_macos": "14.0", - "path": "runtime/darwin-arm64/agent-collab-runtime", - "size": runtime.stat().st_size, - "sha256": hashlib.sha256(runtime.read_bytes()).hexdigest(), + "path": "runtime/darwin-arm64/agent-collab-runtime.bundle", + "entrypoint": "agent-collab-runtime", + "size": sum(record["size"] for record in records), + "sha256": archive_builder.runtime_bundle.compute_bundle_identity( + records + ), "signing": { + "mode": "developer_id", + "identity": ( + "Developer ID Application: Example Corp (TESTTEAM01)" + ), "team_id": "TESTTEAM01", "require_notarization": True, "hardened_runtime": True, + "secure_timestamp": True, }, + "files": records, "contracts": [ {"route": route, "action": action} for route, action in contracts @@ -233,10 +280,16 @@ def test_activation_spdx_identifies_runtime_components_and_file_licenses(self) - files["README.md"]["licenseConcluded"], "LicenseRef-PolyForm-Strict-1.0.0", ) - self.assertEqual( - files["runtime/darwin-arm64/agent-collab-runtime"]["licenseConcluded"], - "NOASSERTION", - ) + for name in ( + "agent-collab-runtime", + "libpython3.13.dylib", + ): + self.assertEqual( + files[ + "runtime/darwin-arm64/agent-collab-runtime.bundle/" + name + ]["licenseConcluded"], + "NOASSERTION", + ) self.assertEqual(files[THIRD_PARTY_NOTICE]["licenseConcluded"], "NOASSERTION") for name in THIRD_PARTY_LICENSE_FILES: self.assertEqual( diff --git a/tests/test_release_runtime_gate.py b/tests/test_release_runtime_gate.py index da4c049..28d11bd 100644 --- a/tests/test_release_runtime_gate.py +++ b/tests/test_release_runtime_gate.py @@ -64,27 +64,58 @@ def test_manifest_team_must_equal_pinned_operator_team(self) -> None: self.assertTrue(any("platform/signing" in error for error in errors)) def _manifest(self, contracts: set[tuple[str, str]]) -> Path: - binary = self.plugin / "runtime" / "darwin-arm64" / "agent-collab-runtime" - binary.parent.mkdir(parents=True, exist_ok=True) + bundle = ( + self.plugin + / "runtime" + / "darwin-arm64" + / "agent-collab-runtime.bundle" + ) + bundle.mkdir(parents=True, exist_ok=True) + binary = bundle / "agent-collab-runtime" + bundle.chmod(0o700) + if binary.exists(): + binary.chmod(0o700) binary.write_bytes(b"signed-runtime-fixture") - binary.chmod(0o700) + binary.chmod(0o500) + bundle.chmod(0o500) + records = [ + { + "path": "agent-collab-runtime", + "role": "entrypoint", + "install_mode": 0o500, + "size": binary.stat().st_size, + "sha256": hashlib.sha256(binary.read_bytes()).hexdigest(), + "macho_type": "executable", + "architecture": "arm64", + "minimum_macos": "14.0", + "signing_profile": "production_developer_id", + } + ] manifest = { - "schema_version": 1, + "schema_version": 2, "protocol_version": 1, - "contract_version": 1, + "contract_version": 3, + "broker_protocol_version": 2, + "channel": "production", "artifacts": [ { "platform": "darwin", "arch": "arm64", + "kind": "standalone_bundle", "minimum_macos": "14.0", - "path": "runtime/darwin-arm64/agent-collab-runtime", + "path": "runtime/darwin-arm64/agent-collab-runtime.bundle", + "entrypoint": "agent-collab-runtime", "size": binary.stat().st_size, - "sha256": hashlib.sha256(binary.read_bytes()).hexdigest(), + "sha256": self.gate.runtime_bundle.compute_bundle_identity(records), "signing": { + "mode": "developer_id", + "identity": "Developer ID Application: Test Operator (TESTTEAM01)", "team_id": "TESTTEAM01", "require_notarization": True, "hardened_runtime": True, + "secure_timestamp": True, }, + "files": records, "contracts": [ {"route": route, "action": action} for route, action in sorted(contracts) @@ -98,7 +129,7 @@ def _manifest(self, contracts: set[tuple[str, str]]) -> Path: def test_empty_manifest_blocks_activation(self) -> None: (self.plugin / "runtime-manifest.json").write_text( - '{"schema_version":1,"protocol_version":1,"contract_version":1,"artifacts":[]}' + '{"schema_version":2,"protocol_version":1,"contract_version":3,"broker_protocol_version":2,"channel":"production","artifacts":[]}' ) ok, _, errors = self.gate.verify_release(self.root, git_sha="abc") self.assertFalse(ok) @@ -134,6 +165,12 @@ def run(command, **_kwargs): if command[0] == "/usr/bin/lipo": return mock.Mock(returncode=0, stdout="arm64\n", stderr="") if command[0] == "/usr/bin/otool": + if "-hv" in command: + return mock.Mock( + returncode=0, + stdout="MH_MAGIC_64 ARM64 ALL 0x00 EXECUTE 21 1688\n", + stderr="", + ) return mock.Mock( returncode=0, stdout=( @@ -149,6 +186,7 @@ def run(command, **_kwargs): returncode=0, stdout="", stderr=( + "Authority=Developer ID Application: Test Operator (TESTTEAM01)\n" "TeamIdentifier=TESTTEAM01 flags=0x10000(runtime)\n" "Timestamp=Jul 12, 2026 at 12:00:00\n" ), @@ -201,6 +239,12 @@ def run(command, **_kwargs): if command[0] == "/usr/bin/lipo": return mock.Mock(returncode=0, stdout="arm64\n", stderr="") if command[0] == "/usr/bin/otool": + if "-hv" in command: + return mock.Mock( + returncode=0, + stdout="MH_MAGIC_64 ARM64 ALL 0x00 EXECUTE 21 1688\n", + stderr="", + ) return mock.Mock( returncode=0, stdout=( @@ -217,6 +261,7 @@ def run(command, **_kwargs): stdout="", stderr=( "Executable=/tmp/agent-collab-runtime\n" + "Authority=Developer ID Application: Test Operator (TESTTEAM01)\n" "TeamIdentifier=TESTTEAM01\n" "Timestamp=Jul 12, 2026 at 12:00:00\n" "CodeDirectory v=20500 flags=0x0(none)\n" @@ -265,12 +310,19 @@ def run(command, **_kwargs): if command[0] == "/usr/bin/lipo": return mock.Mock(returncode=0, stdout=architectures + "\n", stderr="") if command[0] == "/usr/bin/otool": + if "-hv" in command: + return mock.Mock( + returncode=0, + stdout="MH_MAGIC_64 ARM64 ALL 0x00 EXECUTE 21 1688\n", + stderr="", + ) return mock.Mock(returncode=0, stdout=load_commands, stderr="") if command[0] == "/usr/bin/codesign" and "-dv" in command: return mock.Mock( returncode=0, stdout="", stderr=( + "Authority=Developer ID Application: Test Operator (TESTTEAM01)\n" "TeamIdentifier=TESTTEAM01 flags=0x10000(runtime)\n" "Timestamp=Jul 12, 2026 at 12:00:00\n" ), @@ -320,12 +372,19 @@ def run(command, **_kwargs): if command[0] == "/usr/bin/lipo": return mock.Mock(returncode=0, stdout="arm64\n", stderr="") if command[0] == "/usr/bin/otool": + if "-hv" in command: + return mock.Mock( + returncode=0, + stdout="MH_MAGIC_64 ARM64 ALL 0x00 EXECUTE 21 1688\n", + stderr="", + ) return mock.Mock(returncode=0, stdout=valid_build, stderr="") if command[0] == "/usr/bin/codesign" and "-dv" in command: return mock.Mock( returncode=0, stdout="", stderr=( + "Authority=Developer ID Application: Test Operator (TESTTEAM01)\n" "TeamIdentifier=TESTTEAM01\n" "CodeDirectory v=20500 flags=0x10000(runtime)\n" f"{timestamp}\n" diff --git a/tests/test_runtime_bundle.py b/tests/test_runtime_bundle.py new file mode 100644 index 0000000..2bcab20 --- /dev/null +++ b/tests/test_runtime_bundle.py @@ -0,0 +1,271 @@ +from __future__ import annotations + +import copy +import hashlib +import json +import os +from pathlib import Path +import stat +import tempfile +import unittest + +from tests._runtime_bundle_loader import rb + + +REPOSITORY_ROOT = Path(__file__).resolve().parents[1] +FIXTURE_PATH = REPOSITORY_ROOT / "tests/fixtures/runtime_bundle_contract_v3.json" + + +class RuntimeBundleIdentityTests(unittest.TestCase): + @classmethod + def setUpClass(cls) -> None: + cls.fixture = json.loads(FIXTURE_PATH.read_text(encoding="utf-8")) + cls.records = cls.fixture["records"] + + def test_domain_separated_encoding_and_digest_match_fixed_fixture(self): + encoded = rb.encode_bundle_identity(self.records) + + self.assertEqual(rb.IDENTITY_DOMAIN.decode("ascii"), self.fixture["identity_domain"]) + self.assertEqual(encoded.hex(), self.fixture["encoded_hex"]) + self.assertEqual( + rb.compute_bundle_identity(self.records), self.fixture["artifact_sha256"] + ) + + def test_utf8_byte_sorting_is_mandatory_not_an_identity_alias(self): + reversed_records = list(reversed(self.records)) + + with self.assertRaisesRegex(rb.BundleContractError, "sorted"): + rb.encode_bundle_identity(reversed_records) + + def test_each_encoded_field_changes_the_identity(self): + mutations = { + "path": "_ssl2.so", + "role": "entrypoint", + "install_mode": 0o400, + "size": 32, + "sha256": "33" * 32, + "macho_type": "dylib", + "architecture": "x86_64", + } + baseline = rb.compute_bundle_identity(self.records) + for field, value in mutations.items(): + changed = copy.deepcopy(self.records) + changed[0][field] = value + changed.sort(key=lambda item: item["path"].encode("utf-8")) + with self.subTest(field=field): + try: + digest = rb.compute_bundle_identity(changed) + except rb.BundleContractError: + continue + self.assertNotEqual(digest, baseline) + + def test_record_schema_and_exact_integer_types_fail_closed(self): + mutations = [] + + def add(label, callback): + value = copy.deepcopy(self.records) + callback(value) + mutations.append((label, value)) + + add("unknown", lambda value: value[0].__setitem__("extra", 0)) + add("missing", lambda value: value[0].pop("sha256")) + add("bool-mode", lambda value: value[0].__setitem__("install_mode", True)) + add("float-size", lambda value: value[0].__setitem__("size", 31.0)) + add("negative-size", lambda value: value[0].__setitem__("size", -1)) + add("digest-case", lambda value: value[0].__setitem__("sha256", "AA" * 32)) + add("unknown-role", lambda value: value[0].__setitem__("role", "helper")) + add("unknown-macho", lambda value: value[0].__setitem__("macho_type", "object")) + add("wrong-arch", lambda value: value[0].__setitem__("architecture", "x86_64")) + add("wrong-minimum", lambda value: value[0].__setitem__("minimum_macos", "13.0")) + add("wrong-profile", lambda value: value[0].__setitem__("signing_profile", "raw")) + for label, records in mutations: + with self.subTest(label=label): + with self.assertRaises(rb.BundleContractError): + rb.validate_file_records(records) + + def test_path_normalization_confusables_and_representations_fail_closed(self): + invalid_paths = ( + "", + ".", + "..", + "/absolute", + "nested/member", + "nested\\member", + "nested\u2215member", + "nested\u2044member", + "nested\uff0fmember", + "line\nbreak", + "nul\x00byte", + "e\u0301.so", + ) + for path in invalid_paths: + records = copy.deepcopy(self.records) + records[0]["path"] = path + records.sort(key=lambda item: item["path"].encode("utf-8")) + with self.subTest(path=repr(path)): + with self.assertRaises(rb.BundleContractError): + rb.validate_file_records(records) + + duplicates = copy.deepcopy(self.records) + duplicates[2]["path"] = duplicates[0]["path"].upper() + duplicates.sort(key=lambda item: item["path"].encode("utf-8")) + with self.assertRaisesRegex(rb.BundleContractError, "representation"): + rb.validate_file_records(duplicates) + + def test_entrypoint_count_file_count_and_byte_budget_are_closed(self): + no_entrypoint = copy.deepcopy(self.records) + no_entrypoint[1]["role"] = "runtime_library" + two_entrypoints = copy.deepcopy(self.records) + two_entrypoints[0]["role"] = "entrypoint" + oversized = copy.deepcopy(self.records) + oversized[0]["size"] = rb.MAX_BUNDLE_BYTES + too_many = [] + for index in range(rb.MAX_BUNDLE_FILES + 1): + item = copy.deepcopy(self.records[0]) + item["path"] = f"lib-{index:03d}.so" + too_many.append(item) + for label, records in ( + ("no-entrypoint", no_entrypoint), + ("two-entrypoints", two_entrypoints), + ("oversized", oversized), + ("too-many", too_many), + ): + records.sort(key=lambda item: item["path"].encode("utf-8")) + with self.subTest(label=label): + with self.assertRaises(rb.BundleContractError): + rb.validate_file_records(records) + + def test_role_and_macho_type_pairing_is_closed(self): + cases = ((0, "executable"), (1, "dylib")) + for index, replacement in cases: + records = copy.deepcopy(self.records) + records[index]["macho_type"] = replacement + with self.subTest(index=index): + with self.assertRaises(rb.BundleContractError): + rb.validate_file_records(records) + + +class RuntimeBundleJsonTests(unittest.TestCase): + def test_duplicate_keys_nonfinite_and_nonobject_roots_fail_closed(self): + invalid = ( + b'{"files":[],"files":[]}', + b'{"value":NaN}', + b'{"value":Infinity}', + b'{"value":-Infinity}', + b'[]', + b'', + b'\xff', + ) + for raw in invalid: + with self.subTest(raw=raw[:20]): + with self.assertRaises(rb.BundleContractError): + rb.load_closed_json_object(raw) + + def test_bounded_exact_object_is_accepted(self): + self.assertEqual(rb.load_closed_json_object(b'{"schema_version":2}'), { + "schema_version": 2, + }) + + +class RuntimeBundleTreeTests(unittest.TestCase): + def _create_bundle(self, root: Path): + contents = { + "agent-collab-runtime": b"entrypoint", + "libpython3.13.dylib": b"library", + } + records = [] + for path, data in contents.items(): + target = root / path + target.write_bytes(data) + target.chmod(0o500) + records.append({ + "path": path, + "role": "entrypoint" if path == "agent-collab-runtime" else "runtime_library", + "install_mode": 0o500, + "size": len(data), + "sha256": hashlib.sha256(data).hexdigest(), + "macho_type": "executable" if path == "agent-collab-runtime" else "dylib", + "architecture": "arm64", + "minimum_macos": "14.0", + "signing_profile": "development_adhoc", + }) + records.sort(key=lambda item: item["path"].encode("utf-8")) + root.chmod(0o500) + return records + + @staticmethod + def _inspector(path: Path): + return { + "macho_type": "executable" if path.name == "agent-collab-runtime" else "dylib", + "architecture": "arm64", + "minimum_macos": "14.0", + "signing_profile": "development_adhoc", + } + + def test_exact_regular_single_link_tree_is_verified(self): + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) / "agent-collab-runtime.bundle" + root.mkdir() + records = self._create_bundle(root) + try: + self.assertEqual( + rb.verify_bundle_tree(root, records, inspector=self._inspector), + rb.compute_bundle_identity(records), + ) + finally: + root.chmod(0o700) + + def test_symlink_hardlink_extra_missing_and_content_drift_fail_closed(self): + mutations = ("symlink", "hardlink", "extra", "missing", "content") + for mutation in mutations: + with self.subTest(mutation=mutation), tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) / "agent-collab-runtime.bundle" + root.mkdir() + records = self._create_bundle(root) + root.chmod(0o700) + target = root / "libpython3.13.dylib" + if mutation == "symlink": + target.unlink() + target.symlink_to(root / "agent-collab-runtime") + elif mutation == "hardlink": + target.unlink() + os.link(root / "agent-collab-runtime", target) + elif mutation == "extra": + (root / "extra.dylib").write_bytes(b"extra") + elif mutation == "missing": + target.unlink() + else: + target.chmod(0o700) + target.write_bytes(b"changed") + target.chmod(0o500) + root.chmod(0o500) + try: + with self.assertRaises(rb.BundleContractError): + rb.verify_bundle_tree(root, records, inspector=self._inspector) + finally: + root.chmod(0o700) + + def test_wrong_root_or_member_mode_and_inspection_drift_fail_closed(self): + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) / "agent-collab-runtime.bundle" + root.mkdir() + records = self._create_bundle(root) + root.chmod(0o700) + with self.assertRaises(rb.BundleContractError): + rb.verify_bundle_tree(root, records, inspector=self._inspector) + root.chmod(0o500) + + def wrong_inspector(path: Path): + facts = self._inspector(path) + facts["architecture"] = "x86_64" + return facts + + try: + with self.assertRaises(rb.BundleContractError): + rb.verify_bundle_tree(root, records, inspector=wrong_inspector) + finally: + root.chmod(0o700) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_unified_skill_runtime_contract.py b/tests/test_unified_skill_runtime_contract.py index 74ec470..043d415 100644 --- a/tests/test_unified_skill_runtime_contract.py +++ b/tests/test_unified_skill_runtime_contract.py @@ -63,6 +63,7 @@ def test_plugin_readme_documents_closed_coordinator_schema(self) -> None: self.assertIn(f"`{field}`", text) for contract in ( "gemini/advisory", + "gemini/governance", "gemini/long_context", "codex/advisory", "opencode/plan",