From 9fb91dc6a61ca961d4a034c94fe1e6b8febf8591 Mon Sep 17 00:00:00 2001 From: "trycua-release[bot]" Date: Fri, 24 Jul 2026 07:46:29 -0500 Subject: [PATCH] fix(cua-driver): enforce authorization in embedded SDK --- .../docs/concepts/sdk-mcp-and-hosting.mdx | 6 + .../reference/cua-driver/permission-modes.mdx | 47 +- .../cua-driver/permission-policies.mdx | 2 +- .../reference/cua-driver/sdk-reference.mdx | 12 +- ...mission-adapters-and-session-modes-plan.md | 434 ++++++++++++++++++ .../cua-driver-core/src/authorization.rs | 351 +++++++++++++- .../rust/crates/cua-driver-core/src/tool.rs | 33 ++ .../rust/crates/cua-driver-sdk/src/lib.rs | 32 ++ .../rust/crates/cua-driver/src/sdk_adapter.rs | 11 +- 9 files changed, 908 insertions(+), 20 deletions(-) create mode 100644 libs/cua-driver/docs/permission-adapters-and-session-modes-plan.md diff --git a/docs/content/docs/concepts/sdk-mcp-and-hosting.mdx b/docs/content/docs/concepts/sdk-mcp-and-hosting.mdx index 501b19b348..a2ba88ba36 100644 --- a/docs/content/docs/concepts/sdk-mcp-and-hosting.mdx +++ b/docs/content/docs/concepts/sdk-mcp-and-hosting.mdx @@ -47,6 +47,12 @@ MCP remains valuable because it standardizes discovery, calls, results, tasks, and transport for agent runtimes; the generated language SDKs are for applications embedding the native runtime. +Authorization is part of the native runtime, below this topology choice. A +same-process `CuaDriver.create()` call and a daemon-backed call both pass the +same registry authorization boundary before platform dispatch. MCP, HTTP, CLI, +and daemon adapters may reject a call earlier for defense in depth, but they do +not replace or weaken that native check. + ## Why the daemon still exists External agents and CLI calls are short-lived or run outside the desktop app diff --git a/docs/content/docs/reference/cua-driver/permission-modes.mdx b/docs/content/docs/reference/cua-driver/permission-modes.mdx index 350250ba20..e761803d95 100644 --- a/docs/content/docs/reference/cua-driver/permission-modes.mdx +++ b/docs/content/docs/reference/cua-driver/permission-modes.mdx @@ -5,9 +5,10 @@ description: Standard, bounded, and unrestricted startup modes, protected consen import { Callout } from 'fumadocs-ui/components/callout'; -Cua Driver resolves one permission mode when the daemon starts. A tool call -cannot change the mode. The mode controls when work stops for protected human -approval; permission policies still decide which calls are allowed. +Cua Driver resolves one process permission mode when a same-process runtime or +daemon starts. A tool call cannot change the mode. The mode controls when work +stops for protected human approval; permission policies still decide which +calls are allowed. ## Modes @@ -32,8 +33,8 @@ Passing `--permission-mode unrestricted` without that flag still fails closed. ## The authorization stack -Every daemon transport reaches the same authorization coordinator. A call -must pass each active layer: +Every same-process SDK call and daemon transport reaches the native registry's +authorization coordinator. A call must pass each active layer: 1. The reviewed built-in tool and risk map. 2. The administrator policy from `CUA_DRIVER_MANAGED_POLICY_FILE`, when set. @@ -45,9 +46,26 @@ Each layer can narrow access. Approval and unrestricted mode cannot widen a policy layer. An unknown tool has no reviewed risk class and is denied. Tool-list and description responses include risk metadata with `class`, -`enforcement`, `operation_sensitive`, and `version`. The first active adapter -is `browser_prepare` with `strategy.kind=existing_profile`. Other listed risks -are `metadata_only` until their resource, output, and revocation adapters ship. +`enforcement`, `operation_sensitive`, and `version`. The top-level +`enforcement_adapters` array gives the stable adapter ID, exact operation +selectors, resource scope keys, grant TTL, indicator requirement, revocation +triggers, refusal code, and provider requirement. + +The current inventory is intentionally narrow: + +| Adapter | State | Meaning | +| --- | --- | --- | +| `browser_prepare.existing_profile` | `active` | Existing-profile attachment applies the mode-specific protected-consent or unrestricted path. | +| `private_observation` | `metadata_only` | User-window/display observation is classified but not protected by a shipped grant adapter. | +| `desktop_input` | `metadata_only` | Generic desktop input is classified but not protected by a shipped grant adapter. | +| `file_transfer_and_output` | `metadata_only` | Uploads, downloads, and screenshot-to-file egress are classified but not protected by a shipped grant adapter. | +| `browser_consequential_action` | `metadata_only` | Mutating dialogs and generic page actions are classified but not protected by a shipped grant adapter. | +| `devices`, `shell_and_network` | `not_exposed` | Cua Driver does not expose these open-ended capabilities. | + +`metadata_only` is classification, not runtime consent enforcement. These +groups remain metadata-only until an exact resource adapter, certified +protected host, persistent Stop indicator, revocation path, and representative +platform tests all ship. ## Protected consent @@ -157,6 +175,12 @@ Setting only the mode or only the acknowledgement fails before the daemon binds. Treat these as trusted-launch configuration, not tool arguments an agent can choose at runtime. +One process still has one mode and, in bounded mode, one immutable manifest. +Shared-daemon per-session mode selection is not yet a supported security +boundary. Run one daemon per mode unless the host can supply an authenticated +session/action connection; caller-chosen session IDs are lifecycle labels, not +credentials. + ## Managed controls Set `CUA_DRIVER_MANAGED_POLICY_FILE` to apply an administrator-owned YAML or @@ -169,9 +193,10 @@ then rejects unrestricted startup even when the danger flag is present. ## Status and revocation -`cua-driver status` reports the effective mode, policy hashes, session-manifest -hash and expiry, active risk adapters, and protected-provider availability. It -does not print policy contents or browser data. +`cua-driver status` reports the process mode, policy hashes, session-manifest +hash and expiry, active/metadata-only/not-exposed adapter IDs, the complete +content-free adapter inventory, and protected-provider availability. It does +not print policy contents or browser data. End one session and every grant it owns: diff --git a/docs/content/docs/reference/cua-driver/permission-policies.mdx b/docs/content/docs/reference/cua-driver/permission-policies.mdx index a6d4291f26..6e2c29a407 100644 --- a/docs/content/docs/reference/cua-driver/permission-policies.mdx +++ b/docs/content/docs/reference/cua-driver/permission-policies.mdx @@ -5,7 +5,7 @@ description: YAML and Rego permission policy schema, environment variable, evalu import { Callout } from 'fumadocs-ui/components/callout'; -Cua Driver evaluates every daemon tool call against the configured policy stack. The daemon loads policies once at process startup; the MCP proxy may repeat the user-policy check as defense in depth. CLI, MCP, and raw-socket calls share the daemon enforcement point. This page describes the user and managed policy formats. [Permission modes](/reference/cua-driver/permission-modes) control when a permitted call also needs protected approval. +Cua Driver evaluates every same-process SDK and daemon tool call against the configured policy stack at the native registry boundary. The runtime loads policies once at process startup; transport adapters may repeat an early policy check as defense in depth. SDK, CLI, MCP, and raw-socket calls cannot bypass the native enforcement point. This page describes the user and managed policy formats. [Permission modes](/reference/cua-driver/permission-modes) control when a permitted call also needs protected approval. --- diff --git a/docs/content/docs/reference/cua-driver/sdk-reference.mdx b/docs/content/docs/reference/cua-driver/sdk-reference.mdx index ece24ba288..d2dc3720cf 100644 --- a/docs/content/docs/reference/cua-driver/sdk-reference.mdx +++ b/docs/content/docs/reference/cua-driver/sdk-reference.mdx @@ -24,6 +24,13 @@ asynchronous. Python uses `snake_case`; TypeScript uses `camelCase`. `claude_code_compatibility` / `claudeCodeCompatibility`, which defaults to `false`. +`create()` validates the immutable permission-mode, managed-policy, +user-policy, and bounded-manifest startup configuration before constructing +the native runtime. Every subsequent same-process call passes the same native +authorization coordinator used by daemon-backed calls. An invalid +configuration returns `DriverError.Configuration`; a denied generic call +returns a tool error with stable code `permission_denied`. + `execution_mode()` / `executionMode()` reports the generated `DriverExecutionMode` value. The current enum variants are `Embedded` for the same-process runtime and `Daemon` for a socket-backed connection. @@ -73,8 +80,9 @@ explicit escalation before desktop tools are enabled. | `hotkey` | `hotkey` | `HotkeyInput` | Use `list_tools_json()` / `listToolsJson()` and `call_tool()` / `callTool()` for -the generic, platform-extensible tool surface. Prefer typed methods when one is -available. +the generic, platform-extensible tool surface. Its top-level +`enforcement_adapters` array distinguishes active, metadata-only, and +not-exposed permission adapters. Prefer typed methods when one is available. ## `ToolResult` diff --git a/libs/cua-driver/docs/permission-adapters-and-session-modes-plan.md b/libs/cua-driver/docs/permission-adapters-and-session-modes-plan.md new file mode 100644 index 0000000000..7fa55b6793 --- /dev/null +++ b/libs/cua-driver/docs/permission-adapters-and-session-modes-plan.md @@ -0,0 +1,434 @@ +# Active Permission Adapters and Trusted Per-Session Modes + +**Status:** Reviewed; implementation sequencing accepted + +**Issues:** #2385 and #2437 + +**Base:** `origin/main` at `544bff3ac739e51185dbaf465160779817dca230` + +**Date:** 2026-07-24 + +## Goal + +Finish the permission-mode model in two dimensions: + +1. A trusted embedding host can run concurrent `standard`, `bounded`, and + `unrestricted` sessions on one daemon without allowing an agent to choose or + widen its own mode. +2. The daemon can promote reviewed capability groups from risk metadata to + real protected-consent enforcement without duplicating policy logic or + prompting for every action. + +The public `session` argument remains a user-visible lifecycle label. It is +never authority by itself. + +## Current state + +- `PermissionMode` is a process-global `OnceLock` selected at daemon startup. +- `authorize_tool_call` evaluates hard invariants, managed/user policy, risk + classification, and the process-global bounded manifest. +- Since RFC 2447, the public SDK can own a same-process native runtime and MCP, + HTTP, CLI, and daemon transports are downstream adapters. The same-process + `CuaDriver::create` path currently invokes the tool registry directly and + does not pass through `authorize_tool_call`; authorization therefore has to + move into the SDK/native-runtime dispatch boundary rather than remain a + daemon-only concern. +- `session` is caller-declared. The daemon mirrors it into reserved arguments, + while the MCP proxy supplies a separate transport session ID. +- The only active consent adapter is + `browser_prepare(strategy.kind = existing_profile)`. +- `ProtectedConsentProvider` and `ApprovalBroker` are owned by `BrowserEngine`, + so the canonical dispatch coordinator cannot use them for desktop or file + capabilities. +- Production registries currently construct `BrowserEngine` without a + protected provider. Standard/bounded existing-profile attachment therefore + correctly refuses unless a certified host adapter is installed. +- The compatibility `EmbeddedCuaDriverHost` owns daemon lifecycle and a private + parent-liveness stdin pipe, but its action transport still uses a + path-addressed socket. The preferred `CuaDriver::create` topology is + same-process and needs no shared-daemon session delegation at all. + +## Non-negotiable security decisions + +### Daemon ceiling and effective session mode are separate + +The daemon starts with an immutable `SessionModeCeiling`: + +- allowed session modes; +- whether unrestricted sessions were explicitly acknowledged; +- managed/user policy hashes; +- maximum session TTL and idle TTL; +- whether trusted session delegation is enabled. + +Every call resolves an immutable `EffectiveAuthorizationContext` containing: + +- daemon instance and generation; +- public session; +- transport session; +- effective permission mode; +- optional bounded manifest hash; +- trusted-host execution lease ID; +- policy hashes and expiry. + +No context means the call inherits the daemon's legacy process mode. This keeps +standalone CLI/MCP/raw-socket behavior compatible. + +### Session IDs are labels, not credentials + +For the certified embedded route, delegated authority is bound to an +already-connected session channel rather than represented by a serializable +bearer token. The daemon binds the channel to the full authorization context +when the trusted host creates it. Authority never enters tool arguments, +policy input, logs, telemetry, or responses. + +The following can never select a mode: + +- public tool arguments or reserved-argument lookalikes; +- MCP metadata or elicitation; +- a caller-selected public or transport session ID; +- ordinary daemon control methods; +- model-visible environment variables or files. + +### Only a trusted host can mint delegated sessions + +The first supported minting route is the Rust-owned embedded host. It uses a +dedicated host-control channel inherited at spawn and unavailable to the +model-facing MCP stream. The channel is versioned and bound to the daemon +generation. EOF revokes every delegated session and shuts the embedded daemon +down. + +The ordinary daemon listener never exposes create/upgrade mode operations. +Future native hosts may implement the same closed control protocol. Hosts that +cannot protect this channel must use one daemon per mode. + +### Bind authority to an authenticated accepted connection + +The pre-review recommendation is to avoid serializable bearer tokens entirely +for the certified embedded route. The trusted host creates an already-connected +session channel and supplies one endpoint to the daemon and the other to the +SDK or MCP proxy through an explicit inherited-handle allowlist. Every request +arriving on the channel inherits its immutable authorization context; no +request field can select it. + +The control channel can mint a delegated session, but minting is not binding. +Binding action calls without a serializable bearer value requires either +#2410's inherited connected action transport or multiplexing action calls onto +another authenticated accepted channel. #2410 is therefore one valid +implementation, not a blanket prerequisite for the context, ceiling, control, +or inventory foundations. A path-addressed same-user socket plus a +caller-declared session ID cannot carry delegated authority. + +```text +trusted host daemon model-facing proxy + | inherited control pair | | + |-------------------------->| | + | create connected pair | | + | create_session(mode, manifest, host endpoint) | + |-------------------------->| bind context to connection | + | inherit peer endpoint ---------------------------------->| MCP only + | |<==== context-bound calls =====| + | revoke / host EOF ------->| close, revoke, teardown | +``` + +On Unix the control plane can pass additional descriptors with `SCM_RIGHTS` or +create all required channels before spawn. On Windows the host uses an explicit +handle-inheritance/duplication allowlist in the interactive user session. The +implementation must never fall back to a discoverable named endpoint for a +session advertised as protected. + +### Unrestricted remains suppress-only + +An unrestricted session skips Cua runtime consent prompts, but it never widens +managed/user policy, hard invariants, identity proofs, cleanup, revocation, or +resource scoping. The daemon may host unrestricted sessions only if trusted +startup explicitly enabled and acknowledged that ceiling. + +## Architecture + +### 1. Session authorization registry + +Add a process-owned `SessionAuthorizationRegistry` in core: + +- create a delegated session from a trusted control message and bind it to the + supplied connected endpoint; +- resolve the connection plus exact public/transport session into an immutable + context; +- reject connection substitution, session mismatch, expiry, ended sessions, + daemon generation mismatch, and modes outside the daemon ceiling; +- revoke one context or every context owned by a dead host lease; +- register a session-end hook so grants, indicators, browser bindings, and + mode authority share one teardown signal. + +The first foundation slice stores the context and ceiling while calls continue +to inherit the legacy process mode. Certified per-session selection remains +disabled until action calls arrive on an authenticated connection. + +Per-session `bounded` also requires replacing the process-global +`SessionManifest` `OnceLock` with an immutable per-session manifest store. A +manifest hash in the context is not sufficient. Until that store exists, no +mixed-mode status or documentation may claim concurrent bounded manifests. + +The registry exposes no handle or resource contents through health. Status may +report counts by mode, allowed modes, provider readiness, and ceiling +provenance. + +### 2. Trusted embedded control protocol + +Replace the liveness-only stdin reader with a bounded framed control reader. +The host writes versioned messages for: + +- `hello` with daemon generation and protocol version; +- `create_session` with public session, requested mode, expiry, and optional + bounded manifest; +- `revoke_session`; +- provider decision/indicator lifecycle messages in the later provider slice; +- `shutdown` or EOF. + +The daemon sends acknowledgements and provider requests over a separate +inherited response handle. Neither handle is inherited by an MCP proxy or +returned in `EmbeddedDriverConnection`. + +The SDK returns a host-side `EmbeddedAuthorizedSession` object owning the peer +endpoint. Its MCP launch method explicitly inherits an authenticated action +endpoint into a host-owned proxy process, or multiplexes calls over the +authenticated channel; the model sees ordinary MCP schemas only. Direct SDK +calls use the same accepted connection. A plain command/args/environment +record is insufficient for the protected path because it cannot prove handle +ownership or cleanup. + +### 3. Canonical runtime authorization coordinator + +Place the coordinator at the native `ToolRegistry::invoke` chokepoint (or the +immediately enclosing `DriverRuntime::invoke`) so the typed SDK, daemon, MCP, +HTTP, CLI, and raw-socket routes cannot bypass it. This corrects an +authorization omission in the RFC 2447 implementation: the canonical +same-process SDK path currently reaches the registry without calling +`authorize_tool_call`. + +Replace the process-global lookup inside `authorize_tool_call` with: + +1. sanitize reserved arguments; +2. resolve connection-bound authority into `EffectiveAuthorizationContext`; +3. enforce hard invariants; +4. intersect managed, user, and optional session policy; +5. classify the exact operation; +6. route active classifications through the typed enforcement adapter; +7. invoke the tool only after the adapter returns an active grant or confirms + unrestricted coverage. + +The daemon transport supplies authenticated connection context to that runtime +coordinator. In-process SDK runtimes use a trusted constructor-owned context +and do not pretend to be shared daemons. Transport adapters may reject early +but can never mint or satisfy authority. + +### 4. Generic protected grant broker + +Move provider ownership out of `BrowserEngine` into a daemon-owned +`AuthorizationCoordinator`. Generalize the current broker request with a typed +`ProtectedResource` enum and a canonical resource digest. + +The current `ConsentRequest`, digest binding, `IndicatorLease`, and revocation +primitives are retained. The missing work is coordinator ownership and a +certified production host adapter, not reinvention of those primitives. + +Initial resources: + +- `ExistingBrowserProfile { pid, window_id, fingerprint, endpoint_owner }`; +- `UserWindowObservation { pid, window_id }`; +- `DesktopObservation { display_generation }`; +- `UserWindowInput { pid, window_id, delivery_ceiling }`; +- `DesktopInput { display_generation, delivery_ceiling }`; +- `BrowserFileTransfer { binding, tab, paths_or_destination_digest }`; +- `BrowserConsequentialAction { binding, tab, action_kind }`. + +Grants are bound to daemon generation, effective session, transport session, +mode, policy hashes, exact resource digest, expiry, indicator lease, and +revocation generation. They contain no raw typed text, page content, file +contents, or screenshot data. + +### 5. Stable enforcement adapter inventory + +Replace the hard-coded status string with a machine-readable inventory. Each +entry declares: + +- operation selector and risk class; +- state: `active`, `metadata_only`, or `not_exposed`; +- resource kind and scope keys; +- grant type and TTL; +- indicator requirement; +- revocation triggers; +- stable refusal code; +- supported modes and required provider capability. + +`tools/list`, authorization status, and generated docs derive from the same +inventory. Unknown tools and unrecognized operation variants remain denied. + +## Adapter rollout for the currently exposed surface + +### Group A: private observation + +Prepare scoped adapters for user-owned `get_window_state`, +`get_accessibility_tree`, and `get_desktop_state`. Keep their public state +`metadata_only` until #2411 and representative platform certification pass. + +- Driver-owned/disposable resources remain prompt-free. +- Standard obtains one window/display grant and reuses it inside scope. +- Bounded requires a manifest entry and activates the indicator without a + second prompt. +- Unrestricted skips the prompt but retains session/resource binding. +- Screenshot-to-file arguments are file egress and are covered by Group C, + never by an observation-only grant. + +### Group B: desktop input + +Prepare click, typing, key, pointer, scroll, drag, focus, and set-value routes. +Keep user-owned enforcement `metadata_only` until #2411 and platform +certification pass. + +- Window-targeted AX/PX/background/foreground variants map to one exact + window-input resource with a delivery ceiling. +- Desktop coordinate input maps to a display resource. +- Foreground delivery is a scope expansion unless already approved. +- Cua consent/indicator UI and known OS security prompts remain hard-denied. +- Coordinate input is semantically opaque; the adapter does not claim to + detect purchases, sends, deletes, or account changes. + +### Group C: browser file and consequential routes + +Prepare `browser_set_input_files`, `browser_download`, screenshot-to-file, +mutating `browser_dialog`, and mutating `page` operations. Keep them +`metadata_only` until #2411 and platform certification pass. + +- Bind file grants to canonical paths and the exact browser binding/tab. +- Prevent aliases, generic page operations, and raw routes from bypassing the + typed adapter. +- Operations whose consequence cannot be bounded are refused in standard and + bounded rather than silently treated as ordinary input. + +### Not currently exposed + +Microphone, camera, generic shell, and generic network tools are not part of +the current registry. The inventory records them as `not_exposed`; no dormant +adapter or unsupported public claim is added. + +## Intervention behavior + +- Standard: one approval per exact user-owned window/display/browser-file + scope; no prompt for repeated ordinary actions within that grant. Foreground + escalation, a new app/window/display, file destination, or consequential + action may require a new approval. +- Bounded: one trusted session creation/manifest approval; no runtime prompts + inside the manifest, but persistent indication remains mandatory. +- Unrestricted: one trusted host launch/session selection acknowledgement; no + Cua runtime prompts. + +## Delivery sequence + +1. **Canonical SDK/runtime authorization boundary:** eliminate the direct-SDK + bypass introduced by the new native-core topology while preserving typed + SDK behavior. +2. **Generic coordinator and inventory (#2385 P0):** context-aware canonical + dispatch, generic broker ownership, generated active/metadata inventory, + and stable refusal vocabulary. +3. **Provider ownership refactor:** hoist the existing `ApprovalBroker` out of + `BrowserEngine` without adding a new active group. +4. **Trusted session authority foundation (#2437):** context model, daemon + ceiling, immutable per-session manifest store, status, teardown, and + adversarial tests while mode still inherits legacy configuration. +5. **Authenticated session minting and action binding:** private embedded + control channel plus either #2410 or actions on that authenticated channel. +6. **Embedded protected provider dependency (#2411):** exact request/response, + persistent indicator leases, Stop, channel-death revocation, and packaged + host contract. +7. **Observation and input adapters (#2385 P1):** exact resource extraction, + grant reuse, foreground expansion, self-target refusal, and platform tests. +8. **File/consequential adapters (#2385 P2/P4):** browser upload/download, + dialog/page mutations, path constraints, and bypass tests. +9. **Docs and support claims:** expected intervention counts, embedding guide, + status reference, migration, and unsupported-host fallback. + +Keep the slices in dependency order and reviewable. Do not activate a group +until its provider, indicator, revocation, bypass, and platform matrix pass. + +## Verification + +### Deterministic tests + +- mode-ceiling lattice and policy intersection; +- delegated session creation only through the trusted control seam; +- tool/MCP/reserved-argument mode injection denied; +- wrong connection/session/transport/generation, replay, expiry, + reconnect, and cross-session substitution denied; +- concurrent standard/bounded/unrestricted calls preserve isolation; +- standard grant reuse stays within exact scope; +- bounded manifests cannot self-amend or auto-accept `ask`; +- unrestricted cannot widen a denial; +- active inventory matches every registered tool/alias and generated docs; +- indicator failure and host-control EOF revoke before further side effects. + +### Integration and representative environments + +- public MCP, CLI, raw socket, same-process SDK, and compatibility-daemon SDK + calls all reach the same coordinator; +- embedded host creates mixed-mode sessions while the model-facing schemas + contain no mode/token fields; +- standard without a certified provider returns a stable refusal and produces + no side effect; +- macOS logged-in VM validates TCC attribution, protected host UI, Stop, and + window/display observation/input scopes; +- Windows runs in a verified interactive RDP session, never Session 0; +- Linux validates X11 and Wayland separately and reports missing protected UI + as environment-unavailable before behavior assertions; +- before/after application state, focus, z-order, cursor, and input-isolation + oracles prove each background-input row. + +## Completion bar + +- Mixed-mode shared-daemon sessions are supported only through a certified + trusted host and cannot be selected from the model/tool channel. +- Every currently exposed sensitive capability is either actively enforced + with complete adapter evidence or explicitly listed as metadata-only with a + precise reason and no broader security claim. +- The first activated observation/input and file/consequential groups pass + exact-head unit, integration, adversarial, and representative-platform tests. +- Documentation and machine-readable status agree on the active inventory and + realistic human-intervention count. +- Delivery is split into dependency-ordered release-scoped pull requests; a + local plan or unit-only result is not completion. + +## Independent review outcome + +Claude Code Opus reviewed the plan against +`544bff3ac739e51185dbaf465160779817dca230`. The accepted must-fix findings are: + +1. close the shipping same-process SDK authorization bypass first; +2. keep user-owned capability groups `metadata_only` until #2411 and + representative platform certification exist; +3. bind delegated authority to an authenticated accepted connection, never a + public session string; +4. hoist provider ownership before generalizing adapters; +5. treat #2410 as one action-binding implementation, not a blanket dependency; +6. classify screenshot-to-file as egress; and +7. make concurrent bounded sessions depend on a real per-session manifest + store. + +The accepted delivery shape is a dependency-ordered PR series. Neither #2437 +as a whole nor any user-owned capability group's switch to `active` can be +honestly completed in one PR. + +## Prior baseline evidence + +On `849e0db2118e17f80433d7a518bae8815e5ca5f6` (Cua Driver `0.11.0`), before +implementation: + +- `cargo test -p cua-driver-core authorization::tests`: 10 passed; +- `cargo test -p cua-driver-core consent::tests`: 6 passed; +- `cargo test -p cua-driver-core session_manifest::tests`: 6 passed; +- `cargo test -p cua-driver-sdk embedded::tests::authorization_modes_require_explicit_acknowledgements`: + 1 passed. + +These are baseline contract checks, not evidence for the new behavior. + +The plan must be re-baselined on `544bff3ac739e51185dbaf465160779817dca230` +(Cua Driver `0.12.4`) after independent review because RFC 2447 changed the +canonical runtime boundary. diff --git a/libs/cua-driver/rust/crates/cua-driver-core/src/authorization.rs b/libs/cua-driver/rust/crates/cua-driver-core/src/authorization.rs index 3e9c9a5036..f15d4e10f4 100644 --- a/libs/cua-driver/rust/crates/cua-driver-core/src/authorization.rs +++ b/libs/cua-driver/rust/crates/cua-driver-core/src/authorization.rs @@ -52,6 +52,7 @@ impl RiskClass { pub enum RiskEnforcement { Active, MetadataOnly, + NotExposed, } impl RiskEnforcement { @@ -59,10 +60,244 @@ impl RiskEnforcement { match self { Self::Active => "active", Self::MetadataOnly => "metadata_only", + Self::NotExposed => "not_exposed", } } } +/// Stable, content-free description of one permission-mode enforcement +/// adapter. This inventory is the source for status and tool discovery; it +/// deliberately distinguishes shipped enforcement from reviewed metadata and +/// capabilities that are not exposed at all. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +pub struct EnforcementAdapterDescriptor { + pub id: &'static str, + pub operations: &'static [&'static str], + pub state: RiskEnforcement, + pub risk_class: RiskClass, + pub resource_kind: &'static str, + pub scope_keys: &'static [&'static str], + pub grant_type: Option<&'static str>, + pub idle_ttl_seconds: Option, + pub absolute_ttl_seconds: Option, + pub indicator_requirement: &'static str, + pub revocation_triggers: &'static [&'static str], + pub refusal_code: Option<&'static str>, + pub provider_requirement: &'static str, +} + +const EXISTING_PROFILE_OPERATIONS: &[&str] = &["browser_prepare[strategy.kind=existing_profile]"]; +const EXISTING_PROFILE_SCOPE_KEYS: &[&str] = &[ + "daemon_generation", + "public_session", + "transport_session", + "pid", + "window_id", + "process_fingerprint", + "browser_product", + "endpoint_owner", + "permission_mode", + "managed_policy_sha256", + "user_policy_sha256", +]; +const EXISTING_PROFILE_REVOCATION: &[&str] = &[ + "indicator_stop", + "session_end", + "idle_expiry", + "absolute_expiry", + "policy_change", + "process_identity_change", + "endpoint_identity_change", + "daemon_restart", + "reconnect_budget_exhaustion", +]; + +const PRIVATE_OBSERVATION_OPERATIONS: &[&str] = &[ + "get_desktop_state[without_file_output]", + "get_accessibility_tree", + "get_window_state[without_file_output]", + "page[action=get_text|query_dom]", +]; +const PRIVATE_OBSERVATION_SCOPE_KEYS: &[&str] = + &["public_session", "pid", "window_id", "display_generation"]; + +const DESKTOP_INPUT_OPERATIONS: &[&str] = &[ + "click", + "double_click", + "right_click", + "drag", + "scroll", + "move_cursor", + "mouse_button_down", + "mouse_button_up", + "mouse_drag", + "parallel_mouse_drag", + "type_text", + "type_text_chars", + "press_key", + "hotkey", + "set_value", + "bring_to_front", +]; +const DESKTOP_INPUT_SCOPE_KEYS: &[&str] = &[ + "public_session", + "pid", + "window_id", + "display_generation", + "delivery_mode_ceiling", +]; + +const FILE_TRANSFER_OPERATIONS: &[&str] = &[ + "browser_set_input_files", + "browser_download", + "get_desktop_state[with_file_output]", + "get_window_state[with_file_output]", +]; +const FILE_TRANSFER_SCOPE_KEYS: &[&str] = &[ + "public_session", + "browser_binding", + "tab", + "canonical_path", + "destination_class", +]; + +const CONSEQUENTIAL_OPERATIONS: &[&str] = &[ + "browser_dialog[action=accept|dismiss]", + "page[action!=get_text|query_dom]", +]; +const CONSEQUENTIAL_SCOPE_KEYS: &[&str] = + &["public_session", "browser_binding", "tab", "action_kind"]; + +const SESSION_REVOCATION: &[&str] = &[ + "indicator_stop", + "session_end", + "expiry", + "policy_change", + "resource_identity_change", + "daemon_restart", +]; + +pub const ENFORCEMENT_ADAPTERS: &[EnforcementAdapterDescriptor] = &[ + EnforcementAdapterDescriptor { + id: "browser_prepare.existing_profile", + operations: EXISTING_PROFILE_OPERATIONS, + state: RiskEnforcement::Active, + risk_class: RiskClass::R2, + resource_kind: "existing_browser_profile", + scope_keys: EXISTING_PROFILE_SCOPE_KEYS, + grant_type: Some("existing_profile_session_grant"), + idle_ttl_seconds: Some(30 * 60), + absolute_ttl_seconds: Some(8 * 60 * 60), + indicator_requirement: "required_in_standard_and_bounded", + revocation_triggers: EXISTING_PROFILE_REVOCATION, + refusal_code: Some("browser_consent_required"), + provider_requirement: + "protected_consent_in_standard; protected_indicator_in_bounded; none_in_unrestricted", + }, + EnforcementAdapterDescriptor { + id: "private_observation", + operations: PRIVATE_OBSERVATION_OPERATIONS, + state: RiskEnforcement::MetadataOnly, + risk_class: RiskClass::R2, + resource_kind: "user_window_or_display_observation", + scope_keys: PRIVATE_OBSERVATION_SCOPE_KEYS, + grant_type: None, + idle_ttl_seconds: None, + absolute_ttl_seconds: None, + indicator_requirement: "not_implemented", + revocation_triggers: SESSION_REVOCATION, + refusal_code: None, + provider_requirement: "certified_protected_host_not_implemented", + }, + EnforcementAdapterDescriptor { + id: "desktop_input", + operations: DESKTOP_INPUT_OPERATIONS, + state: RiskEnforcement::MetadataOnly, + risk_class: RiskClass::R1, + resource_kind: "user_window_or_display_input", + scope_keys: DESKTOP_INPUT_SCOPE_KEYS, + grant_type: None, + idle_ttl_seconds: None, + absolute_ttl_seconds: None, + indicator_requirement: "not_implemented", + revocation_triggers: SESSION_REVOCATION, + refusal_code: None, + provider_requirement: "certified_protected_host_not_implemented", + }, + EnforcementAdapterDescriptor { + id: "file_transfer_and_output", + operations: FILE_TRANSFER_OPERATIONS, + state: RiskEnforcement::MetadataOnly, + risk_class: RiskClass::R3, + resource_kind: "canonical_file_path_and_destination", + scope_keys: FILE_TRANSFER_SCOPE_KEYS, + grant_type: None, + idle_ttl_seconds: None, + absolute_ttl_seconds: None, + indicator_requirement: "not_implemented", + revocation_triggers: SESSION_REVOCATION, + refusal_code: None, + provider_requirement: "certified_protected_host_not_implemented", + }, + EnforcementAdapterDescriptor { + id: "browser_consequential_action", + operations: CONSEQUENTIAL_OPERATIONS, + state: RiskEnforcement::MetadataOnly, + risk_class: RiskClass::R3, + resource_kind: "typed_browser_consequential_action", + scope_keys: CONSEQUENTIAL_SCOPE_KEYS, + grant_type: None, + idle_ttl_seconds: None, + absolute_ttl_seconds: None, + indicator_requirement: "not_implemented", + revocation_triggers: SESSION_REVOCATION, + refusal_code: None, + provider_requirement: "certified_protected_host_not_implemented", + }, + EnforcementAdapterDescriptor { + id: "devices", + operations: &["microphone", "camera"], + state: RiskEnforcement::NotExposed, + risk_class: RiskClass::Unclassified, + resource_kind: "device_capture", + scope_keys: &[], + grant_type: None, + idle_ttl_seconds: None, + absolute_ttl_seconds: None, + indicator_requirement: "required_before_exposure", + revocation_triggers: &[], + refusal_code: None, + provider_requirement: "capability_not_exposed", + }, + EnforcementAdapterDescriptor { + id: "shell_and_network", + operations: &["generic_shell", "generic_network"], + state: RiskEnforcement::NotExposed, + risk_class: RiskClass::Unclassified, + resource_kind: "open_ended_external_capability", + scope_keys: &[], + grant_type: None, + idle_ttl_seconds: None, + absolute_ttl_seconds: None, + indicator_requirement: "required_before_exposure", + revocation_triggers: &[], + refusal_code: None, + provider_requirement: "capability_not_exposed", + }, +]; + +pub fn enforcement_adapter_inventory_json() -> Value { + serde_json::to_value(ENFORCEMENT_ADAPTERS).expect("static adapter inventory serializes") +} + +pub fn adapter_ids_with_state(state: RiskEnforcement) -> Vec<&'static str> { + ENFORCEMENT_ADAPTERS + .iter() + .filter(|adapter| adapter.state == state) + .map(|adapter| adapter.id) + .collect() +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct RiskAssessment { pub class: RiskClass, @@ -119,9 +354,7 @@ pub fn advertised_risk_for(tool: &str) -> RiskAssessment { // Surfaces that can reveal or control sensitive local/authenticated // state. Most remain metadata-only until their resource adapters ship. "zoom" - | "get_desktop_state" | "get_accessibility_tree" - | "get_window_state" | "set_config" | "escalate_session" | "start_recording" @@ -133,7 +366,9 @@ pub fn advertised_risk_for(tool: &str) -> RiskAssessment { | "browser_pointer" => RiskClass::R2, // External/file side effects or generic compound action surfaces. - "install_ffmpeg" + "get_desktop_state" + | "get_window_state" + | "install_ffmpeg" | "page" | "browser_dialog" | "browser_set_input_files" @@ -144,7 +379,14 @@ pub fn advertised_risk_for(tool: &str) -> RiskAssessment { RiskAssessment { class, enforcement: RiskEnforcement::MetadataOnly, - operation_sensitive: matches!(tool, "browser_prepare" | "browser_dialog" | "page"), + operation_sensitive: matches!( + tool, + "browser_prepare" + | "browser_dialog" + | "page" + | "get_desktop_state" + | "get_window_state" + ), } } @@ -193,6 +435,19 @@ pub fn classify_tool_call(tool: &str, args: &Value) -> RiskAssessment { operation_sensitive: true, } } + "get_desktop_state" | "get_window_state" => RiskAssessment { + class: if args + .get("screenshot_out_file") + .and_then(Value::as_str) + .is_some_and(|path| !path.is_empty()) + { + RiskClass::R3 + } else { + RiskClass::R2 + }, + enforcement: RiskEnforcement::MetadataOnly, + operation_sensitive: true, + }, _ => advertised_risk_for(tool), } } @@ -488,7 +743,10 @@ pub fn status_json() -> serde_json::Value { "built_in_ceiling": "reviewed_tool_and_risk_map_v1", "legacy_existing_profile_approval": legacy_existing_profile_approval_enabled(), "risk_metadata_version": RISK_METADATA_VERSION, - "active_risk_enforcement": ["browser_prepare.existing_profile"], + "active_risk_enforcement": adapter_ids_with_state(RiskEnforcement::Active), + "metadata_only_risk_enforcement": adapter_ids_with_state(RiskEnforcement::MetadataOnly), + "not_exposed_risk_enforcement": adapter_ids_with_state(RiskEnforcement::NotExposed), + "enforcement_adapters": enforcement_adapter_inventory_json(), "protected_consent_collector": crate::consent::configured_provider_id(), "session_policy_configured": std::env::var_os(crate::session_manifest::SESSION_POLICY_FILE_ENV).is_some(), "session_policy_approved_at_startup": env_flag(crate::session_manifest::SESSION_POLICY_APPROVED_ENV), @@ -595,6 +853,24 @@ mod tests { assert!(!risk.operation_sensitive); } + #[test] + fn screenshot_file_output_is_classified_as_egress() { + for tool in ["get_desktop_state", "get_window_state"] { + let observation = classify_tool_call(tool, &serde_json::json!({})); + assert_eq!(observation.class, RiskClass::R2); + assert_eq!(observation.enforcement, RiskEnforcement::MetadataOnly); + + let egress = classify_tool_call( + tool, + &serde_json::json!({"screenshot_out_file": "/tmp/capture.png"}), + ); + assert_eq!(egress.class, RiskClass::R3); + assert_eq!(egress.enforcement, RiskEnforcement::MetadataOnly); + assert!(egress.operation_sensitive); + assert_eq!(advertised_risk_for(tool).class, RiskClass::R3); + } + } + #[test] fn process_targeted_tools_cannot_target_the_authorization_daemon() { let error = authorize_tool_call( @@ -604,4 +880,69 @@ mod tests { .unwrap_err(); assert!(error.to_string().contains("authorization process")); } + + #[test] + fn enforcement_inventory_is_unique_and_truthful() { + let mut ids = std::collections::BTreeSet::new(); + for adapter in ENFORCEMENT_ADAPTERS { + assert!( + ids.insert(adapter.id), + "duplicate adapter id {}", + adapter.id + ); + assert!( + !adapter.operations.is_empty(), + "adapter {} needs at least one operation selector", + adapter.id + ); + } + + assert_eq!( + adapter_ids_with_state(RiskEnforcement::Active), + vec!["browser_prepare.existing_profile"] + ); + assert_eq!( + adapter_ids_with_state(RiskEnforcement::MetadataOnly), + vec![ + "private_observation", + "desktop_input", + "file_transfer_and_output", + "browser_consequential_action", + ] + ); + assert_eq!( + adapter_ids_with_state(RiskEnforcement::NotExposed), + vec!["devices", "shell_and_network"] + ); + + let existing = ENFORCEMENT_ADAPTERS + .iter() + .find(|adapter| adapter.id == "browser_prepare.existing_profile") + .unwrap(); + assert_eq!(existing.idle_ttl_seconds, Some(30 * 60)); + assert_eq!(existing.absolute_ttl_seconds, Some(8 * 60 * 60)); + assert_eq!(existing.refusal_code, Some("browser_consent_required")); + } + + #[test] + fn status_derives_adapter_summaries_from_the_inventory() { + let status = status_json(); + assert_eq!( + status["active_risk_enforcement"], + serde_json::json!(["browser_prepare.existing_profile"]) + ); + assert_eq!( + status["metadata_only_risk_enforcement"], + serde_json::json!([ + "private_observation", + "desktop_input", + "file_transfer_and_output", + "browser_consequential_action" + ]) + ); + assert_eq!( + status["enforcement_adapters"], + enforcement_adapter_inventory_json() + ); + } } diff --git a/libs/cua-driver/rust/crates/cua-driver-core/src/tool.rs b/libs/cua-driver/rust/crates/cua-driver-core/src/tool.rs index e5834b3a94..c9450fc6de 100644 --- a/libs/cua-driver/rust/crates/cua-driver-core/src/tool.rs +++ b/libs/cua-driver/rust/crates/cua-driver-core/src/tool.rs @@ -378,6 +378,7 @@ impl ToolRegistry { "tools": list, "capability_version": CAPABILITY_VERSION, "schema_version": TOOLS_LIST_SCHEMA_VERSION, + "enforcement_adapters": crate::authorization::enforcement_adapter_inventory_json(), }) } @@ -416,6 +417,27 @@ impl ToolRegistry { let Some(tool) = self.tools.get(resolved_name) else { return ToolResult::error(format!("Unknown tool: {name}")); }; + + // This registry is the canonical native dispatch boundary shared by + // the same-process SDK and every transport adapter. Authorization must + // live here: transport-only checks leave CuaDriver::create() able to + // invoke platform tools without policy, permission-mode, hard- + // invariant, or reviewed-risk enforcement. + // + // Transports may still reject earlier for defense in depth, but those + // checks must remain side-effect free. Active consent/grant adapters + // run only downstream of this boundary so a call cannot prompt twice. + if let Err(error) = crate::authorization::authorize_tool_call(resolved_name, &args) { + let message = error.to_string(); + return ToolResult::error(message.clone()).with_structured(serde_json::json!({ + "status": "refused", + "refusal": { + "code": "permission_denied", + "message": message, + } + })); + } + // Reject modality violations before reserving a recording turn. A // rejected action has no before/after evidence and must not leave a // pending recorder entry behind. @@ -1143,5 +1165,16 @@ mod capability_tests { assert_eq!(v["schema_version"], "1"); assert!(v["tools"].is_array(), "tools array must still be present"); assert_eq!(v["tools"].as_array().unwrap().len(), 0); + assert!( + v["enforcement_adapters"].is_array(), + "permission enforcement inventory must be available before tools register" + ); + assert!(v["enforcement_adapters"] + .as_array() + .unwrap() + .iter() + .any(|adapter| { + adapter["id"] == "browser_prepare.existing_profile" && adapter["state"] == "active" + })); } } diff --git a/libs/cua-driver/rust/crates/cua-driver-sdk/src/lib.rs b/libs/cua-driver/rust/crates/cua-driver-sdk/src/lib.rs index c2d2d5deb4..83961868a8 100644 --- a/libs/cua-driver/rust/crates/cua-driver-sdk/src/lib.rs +++ b/libs/cua-driver/rust/crates/cua-driver-sdk/src/lib.rs @@ -302,6 +302,11 @@ impl CuaDriver { /// `cua-driver` and never opens daemon IPC. #[uniffi::constructor] pub fn create(options: Option) -> Result, DriverError> { + cua_driver_core::authorization::validate_startup_authorization().map_err(|error| { + DriverError::Configuration { + reason: format!("authorization configuration is invalid: {error}"), + } + })?; let options = options.unwrap_or_default(); Ok(Arc::new(Self { backend: DriverBackend::Embedded(Arc::new(NativeAbiDriver::create( @@ -768,6 +773,33 @@ mod tests { )); } + #[tokio::test] + async fn embedded_runtime_enforces_authorization_before_platform_dispatch() { + let driver = CuaDriver::create(None).unwrap(); + let result = driver + .call_tool( + "click".into(), + serde_json::json!({ + "pid": std::process::id(), + "x": 1, + "y": 1 + }) + .to_string(), + ) + .await + .unwrap(); + + assert!(result.is_error); + assert_eq!(result.error_code.as_deref(), Some("permission_denied")); + assert!(result.text.contains("authorization process")); + let structured: Value = + serde_json::from_str(result.structured_json.as_deref().unwrap()).unwrap(); + assert_eq!( + structured.pointer("/refusal/code"), + Some(&Value::String("permission_denied".into())) + ); + } + #[tokio::test] async fn typed_desktop_call_serializes_contract_and_normalizes_result() { let response = serde_json::json!({ diff --git a/libs/cua-driver/rust/crates/cua-driver/src/sdk_adapter.rs b/libs/cua-driver/rust/crates/cua-driver/src/sdk_adapter.rs index 78615b8bc3..3dee6d2c67 100644 --- a/libs/cua-driver/rust/crates/cua-driver/src/sdk_adapter.rs +++ b/libs/cua-driver/rust/crates/cua-driver/src/sdk_adapter.rs @@ -123,6 +123,7 @@ fn daemon_tools_list_from(tools_list: &Value) -> Value { "tools": tools, "capability_version": tools_list.get("capability_version").cloned().unwrap_or(Value::Null), "schema_version": tools_list.get("schema_version").cloned().unwrap_or(Value::Null), + "enforcement_adapters": tools_list.get("enforcement_adapters").cloned().unwrap_or_else(|| json!([])), "tool_observation_owner": "daemon", }) } @@ -159,11 +160,19 @@ mod tests { "risk": {"level": "low"} }], "capability_version": "1", - "schema_version": "1" + "schema_version": "1", + "enforcement_adapters": [{ + "id": "browser_prepare.existing_profile", + "state": "active" + }] }); let daemon = daemon_tools_list_from(&tools_list); assert_eq!(daemon["tools"][0]["input_schema"]["type"], "object"); assert_eq!(daemon["tools"][0]["read_only"], true); + assert_eq!( + daemon["enforcement_adapters"][0]["id"], + "browser_prepare.existing_profile" + ); assert_eq!(daemon["tool_observation_owner"], "daemon"); } }