You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@maka.apache.org by ya...@apache.org on 2026/09/01 05:41:15 UTC

(maka) branch main updated: feat(desktop): authorize client capabilities in managed sessions (#4143)

This is an automated email from the ASF dual-hosted git repository.

YayoiNanoka pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/maka.git


The following commit(s) were added to refs/heads/main by this push:
     new 409c71a532 feat(desktop): authorize client capabilities in managed sessions (#4143)
409c71a532 is described below

commit 409c71a53289c189b8ecc25d97dd818f3fb1f295
Author: YayoiNanoka <ya...@qq.com>
AuthorDate: Tue Sep 1 13:41:11 2026 +0800

    feat(desktop): authorize client capabilities in managed sessions (#4143)
    
    * refactor(runtime-host): separate capability acceptance from admission
    
    Generated-by: OpenAI Codex
    
    * feat(runtime-host): carry capability admission evidence
    
    Generated-by: OpenAI Codex
    
    * feat(storage): persist client capability session grants
    
    Generated-by: OpenAI Codex
    
    * refactor(core): centralize capability grant identity
    
    Generated-by: OpenAI Codex
    
    * feat(core): define client capability approval interactions
    
    Generated-by: OpenAI Codex
    
    * feat(storage): atomically commit capability approvals and grants
    
    Generated-by: OpenAI Codex
    
    * feat(runtime-host): host client capability approval interactions
    
    Generated-by: OpenAI Codex
    
    * feat(runtime): prepare client capabilities before durable dispatch
    
    Generated-by: OpenAI Codex
    
    * fix(storage): share capability grants by scope
    
    Generated-by: OpenAI Codex
    
    * feat(runtime-host): authorize managed client capabilities
    
    Generated-by: OpenAI Codex
    
    * feat(desktop): attest browser origins before admission
    
    Generated-by: OpenAI Codex
    
    * fix(desktop): limit cross-origin browser results
    
    Generated-by: OpenAI Codex
    
    * fix(runtime-host): close approvals when providers disconnect
    
    Generated-by: OpenAI Codex
    
    * feat(desktop): present client capability approvals
    
    Generated-by: OpenAI Codex
    
    * fix(runtime): classify client capability interaction events
    
    Generated-by: OpenAI Codex
    
    * fix(runtime-host): canonicalize client capability provider ids
    
    Generated-by: OpenAI Codex
    
    * fix(runtime): route client capability host admission
    
    Generated-by: OpenAI Codex
    
    * fix(desktop): enforce browser origin leases
    
    Generated-by: OpenAI Codex
    
    * chore(runtime-host): advance capability protocol epoch
    
    Generated-by: OpenAI Codex
    
    * chore(desktop): sync capability prompt inventory
    
    Generated-by: OpenAI Codex
    
    * test(desktop): complete side chat story port
    
    Generated-by: OpenAI Codex
    
    * test(runtime-host): honor capability admission handshake
    
    Generated-by: OpenAI Codex
    
    * test: trim duplicate capability coverage
    
    Generated-by: OpenAI Codex
    
    * test: simplify capability coverage
    
    Generated-by: OpenAI Codex
    
    * fix(desktop): redact unapproved browser destinations
    
    Generated-by: OpenAI Codex
    
    * fix(desktop): keep capability approval outside legacy shell
    
    Generated-by: OpenAI Codex
    
    * fix(runtime-host): preserve local capability trust
    
    Generated-by: OpenAI Codex
    
    * fix(runtime-host): close approvals with callers
    
    Generated-by: OpenAI Codex
    
    * fix(runtime-host): isolate shared approval waiters
    
    Generated-by: OpenAI Codex
    
    * docs: refresh Astryx surface inventory
    
    Generated-by: OpenAI Codex
    
    ---------
    
    Co-authored-by: YayoiNanoka <27...@users.noreply.github.com>
---
 apps/desktop/renderer-architecture.json            |   6 +-
 .../main/__tests__/browser-origin-lease.test.ts    |  41 ++
 .../src/main/__tests__/browser-session.test.ts     |   7 +
 .../src/main/__tests__/browser-tools.test.ts       | 250 ++++++++++-
 .../chat-composer-region-draft-handoff.test.ts     |   3 +-
 .../permission-response-ipc-boundary.test.ts       |  15 +
 .../main/__tests__/runtime-host-client-uds.test.ts |   1 +
 .../runtime-host-desktop-candidate.test.ts         |   7 +
 .../runtime-host-native-capabilities.test.ts       |  81 +++-
 ...runtime-host-session-execution-ipc-main.test.ts |  65 +++
 .../src/main/__tests__/workbar-controller.test.ts  |  26 ++
 .../__tests__/workbar-services-adapter.test.ts     |   2 +
 apps/desktop/src/main/browser/automation-host.ts   |   6 +
 apps/desktop/src/main/browser/browser-host.ts      |  22 +
 .../src/main/browser/browser-origin-admission.ts   |  37 +-
 .../src/main/browser/browser-origin-lease.ts       |  96 ++++
 apps/desktop/src/main/browser/browser-tools.ts     | 162 +++++--
 apps/desktop/src/main/browser/controller.ts        |  20 +-
 apps/desktop/src/main/browser/session.ts           |  28 +-
 apps/desktop/src/main/permission-response-guard.ts |  19 +
 apps/desktop/src/main/runtime-host-boot.ts         |  12 +
 .../src/main/runtime-host-native-capabilities.ts   |  79 +++-
 .../runtime-host-session-execution-ipc-main.ts     |  17 +
 .../src/main/runtime-host-session-observer.ts      |   6 +
 apps/desktop/src/preload/bridge-contract.d.ts      |   5 +
 apps/desktop/src/preload/preload.ts                |  11 +
 .../src/renderer/app-shell-session-events.ts       |  18 +-
 apps/desktop/src/renderer/app-shell.tsx            |   6 +-
 apps/desktop/src/renderer/chat-composer-region.tsx |  26 +-
 .../workbar/controller/use-workbar-controller.ts   |  28 +-
 .../desktop/src/renderer/features/workbar/ports.ts |   5 +
 .../src/renderer/features/workbar/testing.ts       |   1 +
 .../tools/side-chat/quote-companion-core.ts        |   2 +
 .../tools/side-chat/quote-companion-panel.tsx      |  16 +-
 .../workbar/tools/side-chat/use-quote-companion.ts |  21 +
 .../platform/desktop/create-workbar-services.ts    |   2 +
 apps/desktop/stories/session-workbar.stories.tsx   |   1 +
 docs/astryx-surface-file-inventory.md              |   1 +
 docs/astryx-surface-file-inventory.paths           |   1 +
 .../__tests__/tui-mcp-local-integration.test.ts    |   2 +
 packages/cli/src/mcp-capability-provider.ts        |   2 +-
 packages/core/package.json                         |   1 +
 packages/core/src/__tests__/interaction.test.ts    |  44 ++
 packages/core/src/backend-types.ts                 |   9 +-
 packages/core/src/client-capability-grant.ts       | 210 +++++++++
 packages/core/src/events.ts                        |  26 +-
 packages/core/src/interaction.ts                   |  90 +++-
 ...client-capability-admission-integration.test.ts | 385 ++++++++++++++++
 .../__tests__/client-capability-channel.test.ts    |  16 +-
 .../client-capability-coordinator.test.ts          | 488 +++++++++++++++++----
 .../client-capability-invocation-broker.test.ts    | 129 ++++++
 .../__tests__/client-capability-protocol.test.ts   |   2 +
 .../client-capability-provider-id.test.ts          |  61 +++
 .../src/__tests__/client-capability-uds.test.ts    |   6 +-
 .../__tests__/execution-host-continuation.test.ts  |   2 +-
 .../__tests__/execution-model-composition.test.ts  |  40 +-
 .../src/__tests__/fixtures/client-capability.ts    |  21 +
 .../src/__tests__/interaction-coordinator.test.ts  |  44 ++
 .../src/__tests__/oauth-coordinator.test.ts        |   7 +-
 .../src/__tests__/oauth-two-client-uds.test.ts     |   3 +
 .../src/__tests__/root-turn-coordinator.test.ts    |  15 +-
 .../src/__tests__/session-projector.test.ts        |  43 ++
 .../runtime-host/src/adapter/session-projector.ts  |  10 +
 .../src/client/client-capability-channel.ts        |   8 +-
 .../runtime-host/src/client/client-capability.ts   |   5 +-
 .../runtime-host/src/client/oauth-presentation.ts  |   2 +-
 .../runtime-host/src/protocol/client-capability.ts |  31 +-
 packages/runtime-host/src/protocol/index.ts        |   4 +-
 .../src/server/client-capability-coordinator.ts    | 274 ++++++++++--
 .../server/client-capability-invocation-broker.ts  | 296 +++++++++----
 .../client-capability-provider-id.ts}              |  28 +-
 .../src/server/execution-composition.ts            |   2 +
 .../src/server/interaction-coordinator.ts          | 271 +++++++++++-
 .../src/server/interaction-projection.ts           |  10 +
 .../src/test-only/client-capability-host.ts        |  24 +
 .../__tests__/pre-dispatch-refusal-ledger.test.ts  |  42 +-
 .../__tests__/session-event-runtime-mapper.test.ts |  31 ++
 .../src/__tests__/tool-runtime-settlement.test.ts  | 121 ++++-
 .../__tests__/tool-runtime-sqlite-boundary.test.ts |   8 +-
 packages/runtime/src/mcp-tools.ts                  |  48 +-
 .../runtime/src/session-event-runtime-mapper.ts    |  22 +-
 packages/runtime/src/tool-runtime.ts               | 217 +++++----
 .../client-capability-session-grant-store.test.ts  | 105 +++++
 .../storage/src/conversation-operational-state.ts  |   3 +
 packages/storage/src/interaction-store-public.ts   |   1 +
 packages/storage/src/interaction-store.ts          | 277 ++++++++++++
 .../storage/src/sqlite-core-execution-schema.ts    |  19 +
 .../ui/src/__tests__/interaction-queue.test.ts     |  26 ++
 packages/ui/src/client-capability-prompt.tsx       | 113 +++++
 packages/ui/src/components.tsx                     |   1 +
 packages/ui/src/conversation-copy.ts               |  27 ++
 packages/ui/src/index.ts                           |   1 +
 packages/ui/src/interaction-queue.ts               |  25 +-
 93 files changed, 4353 insertions(+), 497 deletions(-)

diff --git a/apps/desktop/renderer-architecture.json b/apps/desktop/renderer-architecture.json
index ea302284f7..b186177f2f 100644
--- a/apps/desktop/renderer-architecture.json
+++ b/apps/desktop/renderer-architecture.json
@@ -710,8 +710,8 @@
           "@maka/core/ui-locale": 1,
           "@maka/ui": 1
         },
-        "importSpecifiers": 23,
-        "nonTriviaTokens": 3041
+        "importSpecifiers": 21,
+        "nonTriviaTokens": 2974
       },
       "src/renderer/app-shell-session-settings-actions.ts": {
         "importDeclarations": 9,
@@ -1061,7 +1061,7 @@
           "react": 1
         },
         "importSpecifiers": 187,
-        "nonTriviaTokens": 15882
+        "nonTriviaTokens": 15855
       },
       "src/renderer/use-app-shell-composer-quotes.ts": {
         "importDeclarations": 3,
diff --git a/apps/desktop/src/main/__tests__/browser-origin-lease.test.ts b/apps/desktop/src/main/__tests__/browser-origin-lease.test.ts
new file mode 100644
index 0000000000..ee9bf34651
--- /dev/null
+++ b/apps/desktop/src/main/__tests__/browser-origin-lease.test.ts
@@ -0,0 +1,41 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import { BrowserOriginLeaseTracker } from '../browser/browser-origin-lease.js';
+
+test('a navigate lease allows the old page but only arms for its approved target', () => {
+  let url = 'https://old.example/';
+  const tracker = new BrowserOriginLeaseTracker(() => url);
+  const lease = tracker.open('https://approved.example/path', 'navigate');
+  assert.equal(lease.snapshot().violatedUrl, undefined);
+  assert.throws(
+    () => lease.startNavigation('https://other.example/path'),
+    /outside the approved Origin/u,
+  );
+
+  lease.startNavigation('https://approved.example/next');
+  url = 'https://approved.example/next';
+  tracker.recordNavigation(url);
+  assert.deepEqual(lease.snapshot(), {
+    epoch: 1,
+    url: 'https://approved.example/next',
+  });
+});
diff --git a/apps/desktop/src/main/__tests__/browser-session.test.ts b/apps/desktop/src/main/__tests__/browser-session.test.ts
index a81d08db44..542172700c 100644
--- a/apps/desktop/src/main/__tests__/browser-session.test.ts
+++ b/apps/desktop/src/main/__tests__/browser-session.test.ts
@@ -92,6 +92,13 @@ type HostSpy = {
 function installHost(overrides: Partial<BrowserViewHost> = {}): HostSpy {
   const spy: HostSpy = { resolved: [], released: [], disposed: [], host: null as never };
   const host: BrowserViewHost = {
+    currentUrl: () => "https://example.com/",
+    openOriginLease: () => ({
+      approvedOrigin: 'https://example.com',
+      startNavigation: () => {},
+      snapshot: () => ({ epoch: 0, url: 'https://example.com/' }),
+      release: () => {},
+    }),
     canDrive: () => true,
     resolveEndpoint: async (id) => {
       spy.resolved.push(id);
diff --git a/apps/desktop/src/main/__tests__/browser-tools.test.ts b/apps/desktop/src/main/__tests__/browser-tools.test.ts
index d121388874..98edf823c8 100644
--- a/apps/desktop/src/main/__tests__/browser-tools.test.ts
+++ b/apps/desktop/src/main/__tests__/browser-tools.test.ts
@@ -26,7 +26,9 @@
 import { strict as assert } from 'node:assert';
 import { afterEach, describe, it } from 'node:test';
 import type { IPage } from '@jackwener/opencli/types';
+import type { ComputerUseToolSet } from '@maka/runtime/computer-use-tools';
 import type { MakaTool, MakaToolContext } from '@maka/runtime/tool-runtime';
+import { withBrowserOriginAdmission } from '../browser/browser-origin-admission.js';
 import {
   buildBrowserClickTool,
   buildBrowserExtractTool,
@@ -39,50 +41,100 @@ import {
   takeoverNote,
 } from '../browser/browser-tools.js';
 import { type BridgeLike, resetBrowserSessionsForTest, setBridgeFactoryForTest } from '../browser/session.js';
-import { type BrowserViewHost, provideBrowserViewHost } from '../browser/browser-host.js';
+import { createDesktopNativeCapabilityProvider } from '../runtime-host-native-capabilities.js';
+import {
+  type BrowserViewHost,
+  provideBrowserViewHost,
+} from '../browser/browser-host.js';
+import { BrowserOriginLeaseTracker } from '../browser/browser-origin-lease.js';
 
 type FakePageConfig = {
   url?: string;
+  afterGotoUrl?: string;
+  afterClickUrl?: string;
+  afterFillUrl?: string;
   title?: string;
   click?: { matches_n: number; match_level: 'exact' | 'stable' | 'reidentified' };
   fill?: { verified: boolean; actual: string; match_level: 'exact' | 'stable' | 'reidentified' };
   snapshot?: unknown;
+  snapshotImpl?: (browser: FakeBrowser) => unknown | Promise<unknown>;
   extractHtml?: string;
-  waitImpl?: (options: unknown) => Promise<void>;
+  extractImpl?: (browser: FakeBrowser) => void;
+  waitImpl?: (options: unknown, browser: FakeBrowser) => Promise<void>;
+  onLeaseOpened?: (browser: FakeBrowser) => void;
+  takeoverReloadImpl?: (browser: FakeBrowser) => void;
+};
+
+type FakeBrowser = {
+  url: string;
+  onNavigate?: (url: string) => void;
+  navigate(url: string): void;
+  clicks: number;
+  fills: number;
+  presses: number;
 };
 
-function makeFakePage(cfg: FakePageConfig): IPage {
+function createFakeBrowser(url: string): FakeBrowser {
+  const browser: FakeBrowser = {
+    url,
+    navigate(nextUrl) {
+      browser.url = nextUrl;
+      browser.onNavigate?.(nextUrl);
+    },
+    clicks: 0,
+    fills: 0,
+    presses: 0,
+  };
+  return browser;
+}
+
+function makeFakePage(cfg: FakePageConfig, browser: FakeBrowser): IPage {
   return {
-    getCurrentUrl: async () => cfg.url ?? null,
-    goto: async () => {},
+    getCurrentUrl: async () => browser.url || null,
+    goto: async (url: string) => browser.navigate(cfg.afterGotoUrl ?? url),
     evaluate: async (js: string) => {
-      if (js.includes('location.href')) return (cfg.url ?? '') as never;
+      if (js.includes('location.href')) return browser.url as never;
       if (js.includes('document.title')) return (cfg.title ?? '') as never;
       if (js.includes('outerHTML')) {
+        cfg.extractImpl?.(browser);
         return (cfg.extractHtml === undefined ? null : { html: cfg.extractHtml, truncated: false }) as never;
       }
       return '' as never;
     },
-    snapshot: async () => cfg.snapshot ?? '[1] link "Home"',
-    click: async () => cfg.click ?? { matches_n: 1, match_level: 'exact' },
-    fillText: async () =>
-      cfg.fill
+    snapshot: async () =>
+      cfg.snapshotImpl ? cfg.snapshotImpl(browser) : (cfg.snapshot ?? '[1] link "Home"'),
+    click: async () => {
+      browser.clicks += 1;
+      if (cfg.afterClickUrl) browser.navigate(cfg.afterClickUrl);
+      return cfg.click ?? { matches_n: 1, match_level: 'exact' };
+    },
+    fillText: async () => {
+      browser.fills += 1;
+      if (cfg.afterFillUrl) browser.navigate(cfg.afterFillUrl);
+      return cfg.fill
         ? { filled: true, verified: cfg.fill.verified, expected: '', actual: cfg.fill.actual, length: 0, matches_n: 1, match_level: cfg.fill.match_level }
-        : { filled: true, verified: true, expected: '', actual: '', length: 0, matches_n: 1, match_level: 'exact' },
-    pressKey: async () => {},
+        : { filled: true, verified: true, expected: '', actual: '', length: 0, matches_n: 1, match_level: 'exact' };
+    },
+    pressKey: async () => {
+      browser.presses += 1;
+    },
     wait: async (options: unknown) => {
-      if (cfg.waitImpl) return cfg.waitImpl(options);
+      if (cfg.waitImpl) return cfg.waitImpl(options, browser);
     },
   } as unknown as IPage;
 }
 
 class FakeBridge implements BridgeLike {
-  constructor(private readonly page: IPage) {}
+  constructor(
+    private readonly page: IPage,
+    private readonly onReload?: () => void,
+  ) {}
   async connect(): Promise<IPage> {
     return this.page;
   }
   async close(): Promise<void> {}
-  async send(): Promise<unknown> {
+  async send(method: string): Promise<unknown> {
+    if (method === 'Page.reload') this.onReload?.();
     return {};
   }
   async waitForEvent(): Promise<unknown> {
@@ -90,15 +142,27 @@ class FakeBridge implements BridgeLike {
   }
 }
 
-function install(cfg: FakePageConfig): void {
+function install(cfg: FakePageConfig): FakeBrowser {
+  const browser = createFakeBrowser(cfg.url ?? 'https://example.com/');
+  const originLeases = new BrowserOriginLeaseTracker(() => browser.url);
+  browser.onNavigate = (url) => originLeases.recordNavigation(url);
   const host: BrowserViewHost = {
+    currentUrl: () => browser.url,
+    openOriginLease: (_sessionId, approvedUrl, kind) => {
+      const lease = originLeases.open(approvedUrl, kind);
+      cfg.onLeaseOpened?.(browser);
+      return lease;
+    },
     canDrive: () => true,
     resolveEndpoint: async (id) => ({ cdpEndpoint: `ws://127.0.0.1:1/${id}` }),
     releaseSession: async () => {},
     disposeSession: async () => {},
   };
   provideBrowserViewHost(host);
-  setBridgeFactoryForTest(() => new FakeBridge(makeFakePage(cfg)));
+  setBridgeFactoryForTest(
+    () => new FakeBridge(makeFakePage(cfg, browser), () => cfg.takeoverReloadImpl?.(browser)),
+  );
+  return browser;
 }
 
 function ctx(): MakaToolContext {
@@ -113,7 +177,10 @@ function ctx(): MakaToolContext {
 }
 
 function run<P>(tool: MakaTool<P, string>, args: P): Promise<string> {
-  return Promise.resolve(tool.impl(args, ctx())) as Promise<string>;
+  return withBrowserOriginAdmission(
+    { sessionId: 's1', url: 'https://example.com/approved?secret=grant' },
+    () => Promise.resolve(tool.impl(args, ctx())) as Promise<string>,
+  );
 }
 
 afterEach(() => {
@@ -135,16 +202,157 @@ describe('browser tool helpers', () => {
     assert.equal(takeoverNote({ takeoverReloaded: false }), '');
     assert.match(takeoverNote({ takeoverReloaded: true }), /reloaded once/);
   });
-
 });
 
 describe('browser tool execution', () => {
-
   it('navigate rejects a non-web URL before connecting', async () => {
     install({});
     await assert.rejects(run(buildBrowserNavigateTool(), { url: 'file:///etc/passwd' }), /Not a navigable URL/);
   });
 
+  it('navigate returns only a sanitized destination after a cross-Origin redirect', async () => {
+    install({
+      url: 'https://old.example/',
+      afterGotoUrl: 'https://other.example/reset/redirect-secret?token=private#account',
+      title: 'Private destination title',
+    });
+    const out = await run(buildBrowserNavigateTool(), { url: 'https://example.com/start' });
+    assert.equal(
+      out,
+      'Navigated to https://other.example. Access to the new site requires approval on the next Browser call.',
+    );
+    assert.doesNotMatch(out, /Private destination title|reset|redirect-secret|token|account/);
+  });
+
+  it('click returns only the new URL after cross-Origin navigation', async () => {
+    install({
+      url: 'https://example.com/start',
+      afterClickUrl: 'https://other.example/reset/click-secret?token=private#account',
+    });
+    const out = await run(buildBrowserClickTool(), { ref: '[1]' });
+    assert.equal(
+      out,
+      'Navigated to https://other.example. Access to the new site requires approval on the next Browser call.',
+    );
+    assert.doesNotMatch(out, /Clicked|matched|reset|click-secret|token|account/);
+
+    resetBrowserSessionsForTest();
+    install({
+      url: 'https://example.com/start',
+      afterClickUrl: 'file:///reset/local-secret',
+    });
+    const nonWebOut = await run(buildBrowserClickTool(), { ref: '[1]' });
+    assert.equal(
+      nonWebOut,
+      'Navigated to an unapproved page. Access to the new site requires approval on the next Browser call.',
+    );
+    assert.doesNotMatch(nonWebOut, /file|reset|local-secret/);
+  });
+
+  it('covers the Provider second-check → first page await gap end to end', async () => {
+    install({
+      snapshot: 'private snapshot',
+      onLeaseOpened: (state) => state.navigate('https://other.example/private?token=secret'),
+    });
+    const computerUseTools = [] as unknown as ComputerUseToolSet;
+    computerUseTools.clearSession = () => undefined;
+    computerUseTools.sessionEvents = {} as ComputerUseToolSet['sessionEvents'];
+    let resolved = 0;
+    const provider = createDesktopNativeCapabilityProvider({
+      browserTools: [buildBrowserSnapshotTool()],
+      resolveBrowserUrl: () => {
+        resolved += 1;
+        return 'https://example.com/approved';
+      },
+      releaseBrowserSession() {},
+      computerUseTools,
+      releaseComputerUseSession() {},
+    });
+    assert.ok(provider.call);
+    if (!provider.call) return;
+    const result = await provider.call(
+      {
+        kind: 'client.capability.call',
+        invocationId: 'invocation-1',
+        registrationId: 'registration-1',
+        offerId: 'desktop_browser',
+        serverId: 'desktop_browser',
+        toolName: 'browser_snapshot',
+        arguments: {},
+        sessionId: 's1',
+        turnId: 't1',
+        toolCallId: 'c1',
+        cwd: '/tmp',
+      },
+      {
+        signal: new AbortController().signal,
+        accept: async () => undefined,
+      },
+    );
+    assert.equal(resolved, 2);
+    assert.deepEqual(result, {
+      content: [
+        {
+          type: 'text',
+          text: 'Navigated to https://other.example. Access to the new site requires approval on the next Browser call.',
+        },
+      ],
+    });
+    assert.doesNotMatch(JSON.stringify(result), /private snapshot|token/);
+  });
+
+  it('discards a snapshot when the page crosses Origin and returns to the approved site', async () => {
+    install({
+      snapshotImpl: (browser) => {
+        browser.navigate('https://other.example/reset/first-violated-secret?token=secret');
+        browser.navigate('https://example.com/back');
+        return 'private snapshot';
+      },
+    });
+    const out = await run(buildBrowserSnapshotTool(), {});
+    assert.equal(
+      out,
+      'Navigated to https://other.example. Access to the new site requires approval on the next Browser call.',
+    );
+    assert.doesNotMatch(out, /private snapshot|reset|first-violated-secret|token/);
+  });
+
+  it('covers an A→B→A takeover reload before the first mutating page call', async () => {
+    const browser = install({
+      takeoverReloadImpl: (state) => {
+        state.navigate('https://other.example/reset/reload-secret?token=secret');
+        state.navigate('https://example.com/back');
+      },
+    });
+    const out = await run(buildBrowserClickTool(), { ref: '[1]' });
+    assert.equal(
+      out,
+      'Navigated to https://other.example. Access to the new site requires approval on the next Browser call.',
+    );
+    assert.equal(browser.clicks, 0);
+    assert.doesNotMatch(out, /reset|reload-secret|token/);
+  });
+
+  it('does not press Enter when filling navigates away from the approved Origin', async () => {
+    const browser = install({
+      afterFillUrl: 'https://other.example/login?token=private#form',
+    });
+    const out = await run(buildBrowserTypeTool(), { ref: '[2]', text: 'hello', submit: true });
+    assert.equal(
+      out,
+      'Navigated to https://other.example. Access to the new site requires approval on the next Browser call.',
+    );
+    assert.equal(browser.fills, 1);
+    assert.equal(browser.presses, 0);
+    assert.doesNotMatch(out, /hello|token|form/);
+  });
+
+  it('returns only a sanitized URL after same-Origin click navigation', async () => {
+    install({ afterClickUrl: 'https://example.com/next?token=private#section' });
+    const out = await run(buildBrowserClickTool(), { ref: '[1]' });
+    assert.equal(out, 'Navigated to https://example.com/next.');
+    assert.doesNotMatch(out, /Clicked|matched|token|section/);
+  });
 
 
   it('type reports verification failure with the actual content', async () => {
@@ -162,8 +370,6 @@ describe('browser tool execution', () => {
     await assert.rejects(run(buildBrowserWaitTool(), { text: '   ' }), /non-empty/);
   });
 
-
-
   it('extract fails clearly when a selector matches nothing', async () => {
     install({ url: 'https://example.com/' }); // extractHtml undefined => page returns null
     await assert.rejects(run(buildBrowserExtractTool(), { selector: '#missing' }), /No element matches selector/);
diff --git a/apps/desktop/src/main/__tests__/chat-composer-region-draft-handoff.test.ts b/apps/desktop/src/main/__tests__/chat-composer-region-draft-handoff.test.ts
index b2fe5b61f4..a6f0302ac7 100644
--- a/apps/desktop/src/main/__tests__/chat-composer-region-draft-handoff.test.ts
+++ b/apps/desktop/src/main/__tests__/chat-composer-region-draft-handoff.test.ts
@@ -121,8 +121,7 @@ async function mountRegion(): Promise<{
               newTaskSendPending,
               stopPendingBySession: {},
               respondToSandboxBoundary: () => {},
-              activeSandboxBoundary: undefined,
-              activeQuestion: undefined,
+              respondToClientCapability: () => {},
               respondToUserQuestion: () => {},
               stop: () => {},
               onSend: () => {},
diff --git a/apps/desktop/src/main/__tests__/permission-response-ipc-boundary.test.ts b/apps/desktop/src/main/__tests__/permission-response-ipc-boundary.test.ts
index e5c6a2585f..86b0bac861 100644
--- a/apps/desktop/src/main/__tests__/permission-response-ipc-boundary.test.ts
+++ b/apps/desktop/src/main/__tests__/permission-response-ipc-boundary.test.ts
@@ -22,6 +22,7 @@ import { describe, it } from 'node:test';
 
 import {
   normalizeBranchFromTurnInput,
+  normalizeClientCapabilityResponse,
   normalizeRegenerateTurnInput,
   normalizeReviseBeforeTurnInput,
   normalizeRuntimeHostBranchFromTurnInput,
@@ -50,6 +51,14 @@ describe('permission response IPC boundary', () => {
       }),
       { requestId: 'question-1', answers: ['Option A', null] },
     );
+    assert.deepEqual(
+      normalizeClientCapabilityResponse({
+        requestId: 'capability-1',
+        decision: 'allow',
+        ignored: true,
+      }),
+      { requestId: 'capability-1', decision: 'allow' },
+    );
 
     const invalidPermissionResponses = [
       null,
@@ -60,6 +69,12 @@ describe('permission response IPC boundary', () => {
     for (const response of invalidPermissionResponses) {
       assert.throws(() => normalizeSandboxBoundaryResponse(response), /sandbox boundary response/);
     }
+    for (const response of invalidPermissionResponses) {
+      assert.throws(
+        () => normalizeClientCapabilityResponse(response),
+        /Client Capability response/,
+      );
+    }
 
     const invalidQuestionResponses = [
       { requestId: '', answers: ['A'] },
diff --git a/apps/desktop/src/main/__tests__/runtime-host-client-uds.test.ts b/apps/desktop/src/main/__tests__/runtime-host-client-uds.test.ts
index 0f6909423e..da520348ce 100644
--- a/apps/desktop/src/main/__tests__/runtime-host-client-uds.test.ts
+++ b/apps/desktop/src/main/__tests__/runtime-host-client-uds.test.ts
@@ -260,6 +260,7 @@ test('drives the renderer Session catalog facade through real UDS framing', asyn
       resizeImage: async (bytes) => bytes,
       nativeCapabilities: {
         browserTools: [nativeTool()],
+        resolveBrowserUrl: () => 'https://example.com/',
         releaseBrowserSession() {},
         computerUseTools: Object.assign([], {
           clearSession() {},
diff --git a/apps/desktop/src/main/__tests__/runtime-host-desktop-candidate.test.ts b/apps/desktop/src/main/__tests__/runtime-host-desktop-candidate.test.ts
index 26222c86ad..da5c76d48b 100644
--- a/apps/desktop/src/main/__tests__/runtime-host-desktop-candidate.test.ts
+++ b/apps/desktop/src/main/__tests__/runtime-host-desktop-candidate.test.ts
@@ -253,6 +253,7 @@ test('rejects a stale Host identity when raw Session IDs collide', async () => {
   const computerReleased: string[] = [];
   const nativeCapabilities: DesktopRuntimeHostCandidateDeps['nativeCapabilities'] = {
     browserTools: [nativeTool()],
+    resolveBrowserUrl: () => 'https://example.com/',
     releaseBrowserSession: (sessionId) => {
       browserReleased.push(sessionId);
     },
@@ -375,6 +376,7 @@ test('starts without registering an empty native capability set', async () => {
     host.connection,
     deps(ipc, {
       browserTools: [],
+      resolveBrowserUrl: () => 'https://example.com/',
       releaseBrowserSession() {},
       computerUseTools: emptyComputerUseTools(),
       releaseComputerUseSession() {},
@@ -394,6 +396,7 @@ test('refreshes native capabilities with a new immutable provider snapshot', asy
   const candidate = await createDesktopRuntimeHostCandidate(host.connection, {
     ...deps(ipc, {
       browserTools: [],
+      resolveBrowserUrl: () => 'https://example.com/',
       releaseBrowserSession() {},
       computerUseTools: emptyComputerUseTools(),
       releaseComputerUseSession() {},
@@ -448,6 +451,7 @@ test('releases all native Session resources on retirement and generation close',
     host.connection,
     deps(ipc, {
       browserTools: [nativeTool()],
+      resolveBrowserUrl: () => 'https://example.com/',
       releaseBrowserSession: async (sessionId) => {
         browserReleased.push(sessionId);
       },
@@ -554,6 +558,7 @@ test('closes the claimed Host connection when native capability construction fai
         host.connection,
         deps(ipc, {
           browserTools: [invalidTool],
+          resolveBrowserUrl: () => 'https://example.com/',
           releaseBrowserSession() {},
           computerUseTools: emptyComputerUseTools(),
           releaseComputerUseSession() {},
@@ -575,6 +580,7 @@ test('does not release or report a Revision the Host retained during cleanup', a
   const candidate = await createDesktopRuntimeHostCandidate(host.connection, {
     ...deps(ipc, {
       browserTools: [],
+      resolveBrowserUrl: () => 'https://example.com/',
       releaseBrowserSession: (sessionId) => {
         released.push(`browser:${sessionId}`);
       },
@@ -950,6 +956,7 @@ function deps(
   ipcMain: ReturnType<typeof ipcHarness>,
   nativeCapabilities: DesktopRuntimeHostCandidateDeps['nativeCapabilities'] = {
     browserTools: [nativeTool()],
+    resolveBrowserUrl: () => 'https://example.com/',
     releaseBrowserSession() {},
     computerUseTools: emptyComputerUseTools(),
     releaseComputerUseSession() {},
diff --git a/apps/desktop/src/main/__tests__/runtime-host-native-capabilities.test.ts b/apps/desktop/src/main/__tests__/runtime-host-native-capabilities.test.ts
index 07fb18e7c1..3e2f36007d 100644
--- a/apps/desktop/src/main/__tests__/runtime-host-native-capabilities.test.ts
+++ b/apps/desktop/src/main/__tests__/runtime-host-native-capabilities.test.ts
@@ -25,17 +25,20 @@ import { type MakaTool, type MakaToolContext } from '@maka/runtime/tool-runtime'
 import type { ClientCapabilityProvider } from '@maka/runtime-host/client';
 import {
   decodeClientCapabilityReplaceInput,
+  type ClientCapabilityAdmissionEvidence,
   type ClientCapabilityCallFrame,
   type ClientCapabilityServiceCallFrame,
 } from '@maka/runtime-host/protocol';
 import { z } from 'zod';
 import { buildClientSettingsTools } from '../client-settings-tools.js';
+import { browserOriginAdmission } from '../browser/browser-origin-admission.js';
 import { buildRiveWorkflowTool } from '../rive-workflow-tool.js';
 import { createDesktopNativeCapabilityProvider } from '../runtime-host-native-capabilities.js';
 
 test('publishes self-described session-affine Browser and Computer Use offers', () => {
   const provider = createDesktopNativeCapabilityProvider({
     browserTools: [tool('browser_snapshot', z.object({ includeHidden: z.boolean().optional() }), async () => 'ok')],
+    resolveBrowserUrl: () => 'https://example.com/',
     releaseBrowserSession() {},
     computerUseTools: computerTools(async () => ({ text: 'ok' })),
     releaseComputerUseSession() {},
@@ -91,6 +94,7 @@ test('remote providers do not request Host paths and use a Client-owned cwd', as
           return 'ok';
         }),
       ],
+      resolveBrowserUrl: () => 'https://example.com/',
       releaseBrowserSession() {},
       computerUseTools: computerTools(),
       releaseComputerUseSession() {},
@@ -111,6 +115,7 @@ test('publishes the real Computer Use schema through the Client Capability proto
   const computerUseTools = buildComputerUseTools({ backend: computerBackend() });
   const provider = createDesktopNativeCapabilityProvider({
     browserTools: [],
+    resolveBrowserUrl: () => 'https://example.com/',
     releaseBrowserSession() {},
     computerUseTools,
     releaseComputerUseSession: (sessionId) => computerUseTools.clearSession(sessionId),
@@ -142,6 +147,7 @@ test('publishes every production Desktop-owned tool schema through the protocol'
   });
   const provider = createDesktopNativeCapabilityProvider({
     browserTools: [],
+    resolveBrowserUrl: () => 'https://example.com/',
     releaseBrowserSession() {},
     computerUseTools: computerTools(),
     releaseComputerUseSession() {},
@@ -174,6 +180,7 @@ test('publishes and admits additional Desktop native-effect services', async ()
   const provider = createDesktopNativeCapabilityProvider(
     {
       browserTools: [],
+      resolveBrowserUrl: () => 'https://example.com/',
       releaseBrowserSession() {},
       computerUseTools: computerTools(),
       releaseComputerUseSession() {},
@@ -206,6 +213,8 @@ test('publishes and admits additional Desktop native-effect services', async ()
 test('validates before admission and invokes the exact offered tool with Host context', async () => {
   let admitted = false;
   let invoked = false;
+  const resolvedUrls: string[] = [];
+  let acceptedEvidence: ClientCapabilityAdmissionEvidence | undefined;
   let received:
     | {
         args: unknown;
@@ -217,6 +226,10 @@ test('validates before admission and invokes the exact offered tool with Host co
       browserTools: [
         tool('browser_navigate', z.object({ url: z.string().url() }), async (args, context) => {
           assert.equal(admitted, true);
+          assert.deepEqual(browserOriginAdmission(context.sessionId), {
+            sessionId: 'host-a:session-1',
+            url: 'https://example.com/path',
+          });
           invoked = true;
           received = {
             args,
@@ -230,6 +243,13 @@ test('validates before admission and invokes the exact offered tool with Host co
           return 'Loaded';
         }),
       ],
+      resolveBrowserUrl: ({ sessionId, toolName, arguments: args }) => {
+        assert.equal(sessionId, 'host-a:session-1');
+        assert.equal(toolName, 'browser_navigate');
+        const url = String(args.url);
+        resolvedUrls.push(url);
+        return url;
+      },
       releaseBrowserSession() {},
       computerUseTools: computerTools(),
       releaseComputerUseSession() {},
@@ -243,13 +263,19 @@ test('validates before admission and invokes the exact offered tool with Host co
   );
   assert.equal(invoked, false);
   assert.equal(admitted, false);
+  assert.deepEqual(resolvedUrls, []);
 
-  const result = await call(provider, capabilityFrame({ arguments: { url: 'https://example.com' } }), () => {
-    admitted = true;
-  });
+  const result = await call(
+    provider,
+    capabilityFrame({ arguments: { url: 'https://example.com/path' } }),
+    (evidence) => {
+      acceptedEvidence = evidence;
+      admitted = true;
+    },
+  );
   assert.deepEqual(result, { content: [{ type: 'text', text: 'Loaded' }] });
   assert.deepEqual(received, {
-    args: { url: 'https://example.com' },
+    args: { url: 'https://example.com/path' },
     context: {
       sessionId: 'host-a:session-1',
       turnId: 'turn-1',
@@ -257,6 +283,42 @@ test('validates before admission and invokes the exact offered tool with Host co
       toolCallId: 'tool-call-1',
     },
   });
+  assert.deepEqual(acceptedEvidence, {
+    kind: 'browser_url',
+    url: 'https://example.com/path',
+  });
+  assert.deepEqual(resolvedUrls, [
+    'https://example.com/path',
+    'https://example.com/path',
+  ]);
+});
+
+test('does not execute Browser work when its Origin changes while admission is pending', async () => {
+  let resolveCount = 0;
+  let invoked = false;
+  const provider = createDesktopNativeCapabilityProvider({
+    browserTools: [
+      tool('browser_snapshot', z.object({}), async () => {
+        invoked = true;
+        return 'snapshot';
+      }),
+    ],
+    resolveBrowserUrl: () =>
+      resolveCount++ === 0 ? 'https://first.example/page' : 'https://second.example/page',
+    releaseBrowserSession() {},
+    computerUseTools: computerTools(),
+    releaseComputerUseSession() {},
+  });
+
+  await assert.rejects(
+    () =>
+      call(
+        provider,
+        capabilityFrame({ toolName: 'browser_snapshot', arguments: {} }),
+      ),
+    /Browser origin changed while admission was pending/u,
+  );
+  assert.equal(invoked, false);
 });
 
 test('watches Computer Use turns without widening Browser lifecycle', async () => {
@@ -266,6 +328,7 @@ test('watches Computer Use turns without widening Browser lifecycle', async () =
   const provider = createDesktopNativeCapabilityProvider(
     {
       browserTools: [tool('browser_snapshot', z.object({}), async () => 'snapshot')],
+      resolveBrowserUrl: () => 'https://example.com/',
       releaseBrowserSession() {},
       computerUseTools: computerTools(async (_args, context) => {
         computerUseSessionId = context.sessionId;
@@ -323,6 +386,7 @@ test('projects Computer Use screenshots and releases all native resources for a
   );
   const provider = createDesktopNativeCapabilityProvider({
     browserTools: [tool('browser_snapshot', z.object({}), async () => 'snapshot')],
+    resolveBrowserUrl: () => 'https://example.com/',
     releaseBrowserSession: (sessionId) => {
       browserReleased.push(sessionId);
     },
@@ -380,6 +444,7 @@ test('projects Computer Use screenshots and releases all native resources for a
 test('does not advertise unavailable capability groups or dispatch unknown identities', async () => {
   const provider = createDesktopNativeCapabilityProvider({
     browserTools: [tool('browser_snapshot', z.object({}), async () => 'ok')],
+    resolveBrowserUrl: () => 'https://example.com/',
     releaseBrowserSession() {},
     computerUseTools: computerTools(),
     releaseComputerUseSession() {},
@@ -411,6 +476,7 @@ test('dispatches through the same immutable tool snapshot it advertised', async
   ];
   const provider = createDesktopNativeCapabilityProvider({
     browserTools: [],
+    resolveBrowserUrl: () => 'https://example.com/',
     releaseBrowserSession() {},
     computerUseTools: computerTools(),
     releaseComputerUseSession() {},
@@ -458,6 +524,7 @@ test('reports provider retirement once after its registration is released', asyn
   const provider = createDesktopNativeCapabilityProvider(
     {
       browserTools: [tool('snapshot', z.object({}), async () => 'snapshot')],
+      resolveBrowserUrl: () => 'https://example.com/',
       releaseBrowserSession() {},
       computerUseTools: computerTools(),
       releaseComputerUseSession() {},
@@ -483,6 +550,7 @@ test('settles every native Session cleanup before reporting a release failure',
   let computerReleased = false;
   const provider = createDesktopNativeCapabilityProvider({
     browserTools: [],
+    resolveBrowserUrl: () => 'https://example.com/',
     releaseBrowserSession() {
       throw new Error('browser release failed');
     },
@@ -519,6 +587,7 @@ test('forwards Host cancellation to an admitted Desktop invocation', async () =>
         });
       }),
     ],
+    resolveBrowserUrl: () => 'https://example.com/',
     releaseBrowserSession() {},
     computerUseTools: computerTools(),
     releaseComputerUseSession() {},
@@ -643,11 +712,11 @@ function computerFrame(overrides: Partial<ClientCapabilityCallFrame> = {}): Clie
 async function call(
   provider: ClientCapabilityProvider,
   frame: ClientCapabilityCallFrame,
-  accept: () => void = () => undefined,
+  accept: (evidence: ClientCapabilityAdmissionEvidence) => void = () => undefined,
 ) {
   if (!provider.call) throw new Error('Expected a callable provider');
   return provider.call(frame, {
     signal: new AbortController().signal,
-    accept: async () => accept(),
+    accept: async (evidence) => accept(evidence),
   });
 }
diff --git a/apps/desktop/src/main/__tests__/runtime-host-session-execution-ipc-main.test.ts b/apps/desktop/src/main/__tests__/runtime-host-session-execution-ipc-main.test.ts
index 65c8e81cd1..c5b865ed70 100644
--- a/apps/desktop/src/main/__tests__/runtime-host-session-execution-ipc-main.test.ts
+++ b/apps/desktop/src/main/__tests__/runtime-host-session-execution-ipc-main.test.ts
@@ -123,6 +123,71 @@ test("keeps synthetic E2E interactions visible through Host hydration and retire
   await observer.close();
 });
 
+test('answers a Client Capability approval through the existing Interaction authority', async () => {
+  const pending = {
+    schemaVersion: 1 as const,
+    interactionId: 'capability-1',
+    sessionId: 'session-1',
+    turnId: 'turn-1',
+    runId: 'run-1',
+    revision: 1 as const,
+    request: {
+      kind: 'client_capability' as const,
+      toolUseId: 'tool-1',
+      target: {
+        providerId: 'provider-1',
+        contractId: 'contract-1',
+        serverId: 'desktop_browser',
+        toolName: 'browser_snapshot',
+        capability: 'browser' as const,
+        scope: { kind: 'browser_origin' as const, origin: 'https://example.com' },
+      },
+    },
+    status: 'pending' as const,
+    outcome: null,
+  };
+  const observer = observerWithSnapshot({
+    interactions: { pending: [pending] },
+  });
+  const answers: unknown[] = [];
+  const ipc = ipcHarness();
+  registerExecutionIpc(
+    {
+      client: executionClient({
+        answerInteraction: async (input) => {
+          answers.push(input);
+          return {
+            ...pending,
+            revision: 2,
+            status: 'answered' as const,
+            outcome: {
+              kind: 'client_capability_decision' as const,
+              decision: 'allow' as const,
+              committedAt: 2,
+            },
+          };
+        },
+      }),
+      observer,
+    },
+    ipc,
+  );
+
+  await ipc.invoke('sessions:listActiveInteractions', 'session-1');
+  await ipc.invoke('sessions:respondToClientCapability', 'session-1', {
+    requestId: 'capability-1',
+    decision: 'allow',
+  });
+  assert.deepEqual(answers, [
+    {
+      sessionId: 'session-1',
+      interactionId: 'capability-1',
+      answer: { kind: 'client_capability', decision: 'allow' },
+    },
+  ]);
+  await observer.close();
+});
+
 test("retries committed Branch and Revision copies with the renderer-owned identity", async () => {
   const committed = new Map<string, SessionCatalogProjection>();
   const lostResponses = new Set(["branch-copy-1", "revision-copy-1"]);
diff --git a/apps/desktop/src/main/__tests__/workbar-controller.test.ts b/apps/desktop/src/main/__tests__/workbar-controller.test.ts
index e941b30bbf..e3b51fc19d 100644
--- a/apps/desktop/src/main/__tests__/workbar-controller.test.ts
+++ b/apps/desktop/src/main/__tests__/workbar-controller.test.ts
@@ -147,6 +147,32 @@ describe('useWorkbarController', () => {
     assert.deepEqual(controller().host.projectAliases, ['project-absorbed']);
   });
 
+  it('routes Client Capability decisions to the active Session', async () => {
+    const { root } = installReactRenderer();
+    const responses: Array<{ sessionId: string; requestId: string; decision: string }> = [];
+    const defaults = createFakeWorkbarServices();
+    const services = createFakeWorkbarServices({
+      sideChat: {
+        ...defaults.sideChat,
+        respondToClientCapability: async (sessionId, response) => {
+          responses.push({ sessionId, ...response });
+        },
+      },
+    });
+
+    await act(async () => renderController(root, services, input(session('a'))));
+    await act(async () =>
+      controller().commands.respondToClientCapability({
+        requestId: 'capability-1',
+        decision: 'allow',
+      }),
+    );
+
+    assert.deepEqual(responses, [
+      { sessionId: 'a', requestId: 'capability-1', decision: 'allow' },
+    ]);
+  });
+
   it('keeps the initial Session active after StrictMode replays mount effects', async () => {
     const { root } = installReactRenderer();
     const starts: string[] = [];
diff --git a/apps/desktop/src/main/__tests__/workbar-services-adapter.test.ts b/apps/desktop/src/main/__tests__/workbar-services-adapter.test.ts
index 88a3d42449..43d806fa21 100644
--- a/apps/desktop/src/main/__tests__/workbar-services-adapter.test.ts
+++ b/apps/desktop/src/main/__tests__/workbar-services-adapter.test.ts
@@ -179,6 +179,7 @@ describe('createDesktopWorkbarServices', () => {
       turnId: 'turn-3',
     });
     await services.sideChat.respondToSandboxBoundary('fork', {} as never);
+    await services.sideChat.respondToClientCapability('fork', {} as never);
     await services.sideChat.respondToUserQuestion('fork', {} as never);
     services.sideChat.subscribeEvents('fork', eventHandler)();
 
@@ -232,6 +233,7 @@ describe('createDesktopWorkbarServices', () => {
         'sessions.setPermissionMode',
         'sessions.regenerateTurn',
         'sessions.respondToSandboxBoundary',
+        'sessions.respondToClientCapability',
         'sessions.respondToUserQuestion',
         'sessions.subscribeEvents',
       ],
diff --git a/apps/desktop/src/main/browser/automation-host.ts b/apps/desktop/src/main/browser/automation-host.ts
index ca296f1360..4561629d1d 100644
--- a/apps/desktop/src/main/browser/automation-host.ts
+++ b/apps/desktop/src/main/browser/automation-host.ts
@@ -47,6 +47,12 @@ export function createBrowserViewHost(
   shownSessionId: () => string | null,
 ): BrowserViewHost {
   return {
+    currentUrl(sessionId) {
+      return manager.get(sessionId)?.state().url ?? '';
+    },
+    openOriginLease(sessionId, approvedUrl, kind) {
+      return manager.getOrCreate(sessionId).openOriginLease(approvedUrl, kind);
+    },
     canDrive(sessionId, kind, opts) {
       const shown = sessionId === shownSessionId();
       const controller = manager.get(sessionId);
diff --git a/apps/desktop/src/main/browser/browser-host.ts b/apps/desktop/src/main/browser/browser-host.ts
index 3c48ae8a27..a5307849dc 100644
--- a/apps/desktop/src/main/browser/browser-host.ts
+++ b/apps/desktop/src/main/browser/browser-host.ts
@@ -33,7 +33,29 @@
 
 import type { BrowserActionKind } from './logic.js';
 
+export interface BrowserOriginLeaseSnapshot {
+  readonly epoch: number;
+  readonly url: string;
+  readonly violatedUrl?: string;
+}
+
+export interface BrowserOriginLease {
+  readonly approvedOrigin: string;
+  /** Arm a navigate lease immediately before its approved Page.goto call. */
+  startNavigation(targetUrl: string): void;
+  snapshot(): BrowserOriginLeaseSnapshot;
+  release(): void;
+}
+
 export interface BrowserViewHost {
+  /** Read the current real URL without creating or attaching an automation endpoint. */
+  currentUrl(sessionId: string): string;
+  /**
+   * Track every committed navigation while one admitted Browser call is live.
+   * The lease remembers the first cross-Origin URL, so A→B→A cannot regain the
+   * original approval by merely ending on A again.
+   */
+  openOriginLease(sessionId: string, approvedUrl: string, kind: BrowserActionKind): BrowserOriginLease;
   /**
    * The visible-lease gate (see browserActionAllowed): may `sessionId` run a
    * `kind` action right now? EVERY kind — read, navigate, mutate — requires the
diff --git a/packages/runtime-host/src/__tests__/fixtures/client-capability.ts b/apps/desktop/src/main/browser/browser-origin-admission.ts
similarity index 51%
copy from packages/runtime-host/src/__tests__/fixtures/client-capability.ts
copy to apps/desktop/src/main/browser/browser-origin-admission.ts
index 6bdd854295..2b505623e5 100644
--- a/packages/runtime-host/src/__tests__/fixtures/client-capability.ts
+++ b/apps/desktop/src/main/browser/browser-origin-admission.ts
@@ -17,22 +17,25 @@
  * under the License.
  */
 
-import type { ClientCapabilityConnectionIdentity } from '../../server/client-capability-service.js';
+import { AsyncLocalStorage } from 'node:async_hooks';
 
-export function clientCapabilityConnectionIdentity(
-  connectionId: string,
-  clientInstanceId = connectionId,
-  principalId = 'test-principal',
-  principalKind: ClientCapabilityConnectionIdentity['principalKind'] = 'local_owner',
-  capabilityOwner?: ClientCapabilityConnectionIdentity['capabilityOwner'],
-  credentialBound = principalKind === 'remote_owner',
-): ClientCapabilityConnectionIdentity {
-  return {
-    connectionId,
-    principalId,
-    clientInstanceId,
-    ...(credentialBound ? { credentialBoundClientInstanceId: clientInstanceId } : {}),
-    principalKind,
-    ...(capabilityOwner ? { capabilityOwner } : {}),
-  };
+export interface BrowserOriginAdmission {
+  readonly sessionId: string;
+  readonly url: string;
+}
+
+const currentAdmission = new AsyncLocalStorage<BrowserOriginAdmission>();
+
+/** Bind the Host-approved Browser scope to exactly one provider invocation. */
+export function withBrowserOriginAdmission<T>(
+  admission: BrowserOriginAdmission,
+  run: () => T,
+): T {
+  return currentAdmission.run(admission, run);
+}
+
+/** Browser tools fail closed when they are invoked outside the admitted call. */
+export function browserOriginAdmission(sessionId: string): BrowserOriginAdmission | undefined {
+  const admission = currentAdmission.getStore();
+  return admission?.sessionId === sessionId ? admission : undefined;
 }
diff --git a/apps/desktop/src/main/browser/browser-origin-lease.ts b/apps/desktop/src/main/browser/browser-origin-lease.ts
new file mode 100644
index 0000000000..866cb9658e
--- /dev/null
+++ b/apps/desktop/src/main/browser/browser-origin-lease.ts
@@ -0,0 +1,96 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import type { BrowserOriginLease, BrowserOriginLeaseSnapshot } from './browser-host.js';
+import type { BrowserActionKind } from './logic.js';
+
+type ActiveLease = {
+  readonly approvedOrigin: string;
+  monitoring: boolean;
+  violatedUrl?: string;
+};
+
+/** Monotonic, page-host-owned evidence for Browser Origin admission. */
+export class BrowserOriginLeaseTracker {
+  #epoch = 0;
+  readonly #active = new Set<ActiveLease>();
+
+  constructor(private readonly currentUrl: () => string) {}
+
+  recordNavigation(url = this.currentUrl()): void {
+    this.#epoch += 1;
+    for (const lease of this.#active) {
+      if (lease.monitoring && !lease.violatedUrl && webOrigin(url) !== lease.approvedOrigin) {
+        lease.violatedUrl = url;
+      }
+    }
+  }
+
+  open(approvedUrl: string, kind: BrowserActionKind): BrowserOriginLease {
+    const approvedOrigin = webOrigin(approvedUrl);
+    if (!approvedOrigin) throw new Error('Browser admission requires an HTTP origin');
+    const state: ActiveLease = {
+      approvedOrigin,
+      // Navigate may start on another site. It becomes monitored immediately
+      // before the approved goto; every other action must start on its grant.
+      monitoring: kind !== 'navigate',
+    };
+    this.#active.add(state);
+
+    const snapshot = (): BrowserOriginLeaseSnapshot => {
+      const url = this.currentUrl();
+      if (state.monitoring && !state.violatedUrl && webOrigin(url) !== approvedOrigin) {
+        state.violatedUrl = url;
+      }
+      return {
+        epoch: this.#epoch,
+        url,
+        ...(state.violatedUrl === undefined ? {} : { violatedUrl: state.violatedUrl }),
+      };
+    };
+
+    if (state.monitoring) snapshot();
+    let released = false;
+    return {
+      approvedOrigin,
+      startNavigation: (targetUrl) => {
+        if (released) throw new Error('Browser Origin lease is no longer active');
+        if (webOrigin(targetUrl) !== approvedOrigin) {
+          throw new Error('Browser navigation target is outside the approved Origin');
+        }
+        state.monitoring = true;
+      },
+      snapshot,
+      release: () => {
+        if (released) return;
+        released = true;
+        this.#active.delete(state);
+      },
+    };
+  }
+}
+
+function webOrigin(value: string): string | undefined {
+  try {
+    const url = new URL(value);
+    return url.protocol === 'http:' || url.protocol === 'https:' ? url.origin : undefined;
+  } catch {
+    return undefined;
+  }
+}
diff --git a/apps/desktop/src/main/browser/browser-tools.ts b/apps/desktop/src/main/browser/browser-tools.ts
index bf6947b65b..1997ba8bdc 100644
--- a/apps/desktop/src/main/browser/browser-tools.ts
+++ b/apps/desktop/src/main/browser/browser-tools.ts
@@ -19,7 +19,10 @@
 
 import { z } from 'zod';
 import { htmlToMarkdown } from '@jackwener/opencli/utils';
+import type { IPage } from '@jackwener/opencli/types';
 import type { MakaTool } from '@maka/runtime/tool-runtime';
+import { browserOriginAdmission } from './browser-origin-admission.js';
+import type { BrowserOriginLease } from './browser-host.js';
 import {
   type BrowserPageRun,
   type TakeoverMode,
@@ -34,11 +37,10 @@ import { parseNavigable } from './logic.js';
  * (click / type) take a ref and self-verify the match. All six drive the
  * conversation's OWN embedded-browser view through BrowserSession.
  *
- * Permission: every tool is the dedicated `browser` category — Maka's mode
- * gates it (block in `explore`, prompt in `ask` AND `execute`), so a browser
- * action on the user's logged-in sessions is never silently auto-allowed; the
- * visible view plus the user's "allow for this turn" is the safety net. A
- * finer origin-keyed grant (remember "allow this site") is a later refinement.
+ * Permission: the Runtime Host admits these Client Capability calls against an
+ * Origin-keyed Session Grant. The Desktop provider binds that approved Origin
+ * to this exact invocation; the page-host lease below then keeps it valid for
+ * the entire action, in addition to the visible-view safety boundary.
  */
 
 const BROWSER_TOOL_CATEGORY = 'browser' as const;
@@ -76,11 +78,20 @@ export function takeoverNote(info: { takeoverReloaded: boolean }): string {
 }
 
 /**
- * Shared run path for the browser_* tools. Permission is decided by the engine
- * BEFORE impl runs (via the tool's category), so this only guards availability
- * and runs the action through BrowserSession with its tool-level timeout and
- * the user's stop signal.
+ * Shared run path for the browser_* tools. Host admission happens before impl;
+ * this layer carries its approved Origin across BrowserSession and checks it
+ * before and after every page operation, alongside timeout and stop handling.
  */
+type BrowserActionResult<T> =
+  | { readonly kind: 'completed'; readonly value: T }
+  | { readonly kind: 'navigated'; readonly url: string; readonly requiresApproval: boolean };
+
+class BrowserOriginLeaseLostError extends Error {
+  constructor(readonly url: string) {
+    super('Browser Origin lease was lost');
+  }
+}
+
 async function runBrowserAction<T>(input: {
   sessionId: string;
   label: string;
@@ -90,24 +101,74 @@ async function runBrowserAction<T>(input: {
   // omit for the pure-observe default, which never reloads.
   takeover?: TakeoverMode;
   run: BrowserPageRun<T>;
-}): Promise<T> {
+}): Promise<BrowserActionResult<T>> {
   if (!browserAutomationAvailable()) {
     throw new Error('Browser automation is only available inside the desktop app.');
   }
-  return withBrowserPage(input.sessionId, input.label, input.run, {
+  const admission = browserOriginAdmission(input.sessionId);
+  if (!admission) {
+    throw new Error('Browser action is missing its admitted Origin scope.');
+  }
+  return withBrowserPage(input.sessionId, input.label, async (page, info) => {
+    const lease = info.originLease;
+    if (!lease) throw new Error('Browser action is missing its page-host Origin lease.');
+    const started = lease.snapshot();
+    try {
+      assertOriginLease(lease);
+      const value = await input.run(guardBrowserPage(page, lease), info);
+      const finished = assertOriginLease(lease);
+      if ((input.takeover ?? 'observe') === 'mutate' && finished.epoch !== started.epoch) {
+        return { kind: 'navigated', url: finished.url, requiresApproval: false };
+      }
+      return { kind: 'completed', value };
+    } catch (error) {
+      if (error instanceof BrowserOriginLeaseLostError) {
+        return { kind: 'navigated', url: error.url, requiresApproval: true };
+      }
+      throw error;
+    }
+  }, {
     abort: input.abortSignal,
+    originAdmission: { approvedUrl: admission.url },
     ...(input.timeoutMs !== undefined ? { timeoutMs: input.timeoutMs } : {}),
     ...(input.takeover ? { takeover: input.takeover } : {}),
   });
 }
 
+function assertOriginLease(lease: BrowserOriginLease) {
+  const snapshot = lease.snapshot();
+  if (snapshot.violatedUrl !== undefined) {
+    throw new BrowserOriginLeaseLostError(snapshot.violatedUrl);
+  }
+  return snapshot;
+}
+
+function guardBrowserPage(page: IPage, lease: BrowserOriginLease): IPage {
+  return new Proxy(page, {
+    get(target, key, receiver) {
+      const member = Reflect.get(target, key, receiver);
+      if (typeof member !== 'function') return member;
+      return async (...args: unknown[]) => {
+        if (key === 'goto') {
+          lease.startNavigation(String(args[0] ?? ''));
+        } else {
+          assertOriginLease(lease);
+        }
+        const value = await Reflect.apply(member, target, args);
+        assertOriginLease(lease);
+        return value;
+      };
+    },
+  }) as IPage;
+}
+
 export function buildBrowserNavigateTool(): MakaTool<{ url: string }, string> {
   return {
     name: 'browser_navigate',
     displayName: '浏览器导航',
     description:
       'Open a URL in the conversation\'s embedded browser. Pass a full http:// or https:// URL; other schemes are rejected. ' +
-      'Returns the URL actually landed on (after redirects) and the page title. Follow with browser_snapshot to see what is on the page.',
+      'Returns the sanitized URL actually landed on (after redirects). Follow with browser_snapshot to see what is on the page.',
     parameters: z.object({
       url: z.string().min(1).max(4000).describe('Full http:// or https:// URL to open. Other schemes are rejected.'),
     }),
@@ -133,14 +194,11 @@ export function buildBrowserNavigateTool(): MakaTool<{ url: string }, string> {
           // getCurrentUrl() would just echo it back and a redirect would never
           // be visible.
           const landed = await page.evaluate<string>('window.location.href').catch(() => url);
-          const title = await page.evaluate<string>('document.title').catch(() => '');
-          return { landed: typeof landed === 'string' && landed ? landed : url, title, info };
+          return { landed: typeof landed === 'string' && landed ? landed : url, info };
         },
       });
-      return (
-        [`Loaded ${result.landed}`, result.title ? `Title: ${result.title}` : undefined].filter(Boolean).join('\n') +
-        takeoverNote(result.info)
-      );
+      if (result.kind === 'navigated') return navigationResult(result.url, result.requiresApproval);
+      return `Loaded ${sanitizedPageUrl(result.value.landed)}` + takeoverNote(result.value.info);
     },
   };
 }
@@ -165,9 +223,12 @@ export function buildBrowserSnapshotTool(): MakaTool<Record<string, never>, stri
           return { snapshot, url, info };
         },
       });
+      if (result.kind === 'navigated') return navigationResult(result.url, result.requiresApproval);
       const text =
-        typeof result.snapshot === 'string' ? result.snapshot : JSON.stringify(result.snapshot, null, 2);
-      return (result.url ? `${result.url}\n\n${text}` : text) + takeoverNote(result.info);
+        typeof result.value.snapshot === 'string'
+          ? result.value.snapshot
+          : JSON.stringify(result.value.snapshot, null, 2);
+      return (result.value.url ? `${result.value.url}\n\n${text}` : text) + takeoverNote(result.value.info);
     },
   };
 }
@@ -190,20 +251,54 @@ export function buildBrowserClickTool(): MakaTool<{ ref: string }, string> {
         abortSignal,
         // A mutating action: harden a taken-over page (reload once) before clicking.
         takeover: 'mutate',
-        run: async (page, info) => ({ outcome: await page.click(normalizeElementRef(ref)), info }),
+        run: async (page, info) => {
+          const outcome = await page.click(normalizeElementRef(ref));
+          return { outcome, info };
+        },
       });
-      const { matches_n, match_level } = result.outcome;
+      if (result.kind === 'navigated') return navigationResult(result.url, result.requiresApproval);
+      const { matches_n, match_level } = result.value.outcome;
       return (
         `Clicked ${ref} (matched ${matches_n} element${matches_n === 1 ? '' : 's'}, ${match_level} match).` +
         (matches_n > 1
           ? ' Multiple matches — verify the right element reacted, or re-snapshot for a tighter ref.'
           : '') +
-        takeoverNote(result.info)
+        takeoverNote(result.value.info)
       );
     },
   };
 }
 
+function sanitizedPageUrl(value: string): string {
+  try {
+    const url = new URL(value);
+    if (url.protocol !== 'http:' && url.protocol !== 'https:') return 'an unapproved page';
+    return `${url.origin}${url.pathname}`;
+  } catch {
+    return 'an unapproved page';
+  }
+}
+
+function unapprovedPageOrigin(value: string): string {
+  try {
+    const url = new URL(value);
+    if (url.protocol !== 'http:' && url.protocol !== 'https:') return 'an unapproved page';
+    // The pathname may itself contain credentials or reset tokens; disclose it only after approval.
+    return url.origin;
+  } catch {
+    return 'an unapproved page';
+  }
+}
+
+function navigationResult(url: string, requiresApproval: boolean): string {
+  if (requiresApproval) {
+    return `Navigated to ${unapprovedPageOrigin(url)}. Access to the new site requires approval on the next Browser call.`;
+  }
+  return (
+    `Navigated to ${sanitizedPageUrl(url)}.`
+  );
+}
+
 export function buildBrowserTypeTool(): MakaTool<{ ref: string; text: string; submit?: boolean }, string> {
   return {
     name: 'browser_type',
@@ -233,14 +328,15 @@ export function buildBrowserTypeTool(): MakaTool<{ ref: string; text: string; su
           return { outcome, info };
         },
       });
-      const { verified, actual, match_level } = result.outcome;
+      if (result.kind === 'navigated') return navigationResult(result.url, result.requiresApproval);
+      const { verified, actual, match_level } = result.value.outcome;
       const lines = [
         `Filled ${ref} (${match_level} match)${submit ? ', then pressed Enter' : ''}.`,
         verified
           ? 'Verified: the field contains the requested text.'
           : `Not verified — the field now contains: ${JSON.stringify(actual)}`,
       ];
-      return lines.join('\n') + takeoverNote(result.info);
+      return lines.join('\n') + takeoverNote(result.value.info);
     },
   };
 }
@@ -292,7 +388,7 @@ export function buildBrowserWaitTool(): MakaTool<
         : selector
           ? `selector ${JSON.stringify(selector)}`
           : `${requested}s pause`;
-      const info = await runBrowserAction({
+      const result = await runBrowserAction({
         sessionId,
         label: 'wait',
         abortSignal,
@@ -322,7 +418,8 @@ export function buildBrowserWaitTool(): MakaTool<
           return pageInfo;
         },
       });
-      return `Done: ${condition}.` + takeoverNote(info);
+      if (result.kind === 'navigated') return navigationResult(result.url, result.requiresApproval);
+      return `Done: ${condition}.` + takeoverNote(result.value);
     },
   };
 }
@@ -358,27 +455,28 @@ export function buildBrowserExtractTool(): MakaTool<{ selector?: string; start?:
           return { read, url, info };
         },
       });
-      if (typeof result.read?.html !== 'string') {
+      if (result.kind === 'navigated') return navigationResult(result.url, result.requiresApproval);
+      if (typeof result.value.read?.html !== 'string') {
         throw new Error(
           selector
             ? `No element matches selector ${JSON.stringify(selector)}.`
             : 'The page has no readable body yet — navigate somewhere first.',
         );
       }
-      const markdown = htmlToMarkdown(result.read.html);
+      const markdown = htmlToMarkdown(result.value.read.html);
       const chunk = markdown.slice(start, start + EXTRACT_CHAR_LIMIT);
       const nextStart = start + chunk.length;
       const hasMore = nextStart < markdown.length;
       return (
-        (result.url ? `${result.url}\n\n` : '') +
+        (result.value.url ? `${result.value.url}\n\n` : '') +
         chunk +
         (hasMore
           ? `\n\n(Content continues — call browser_extract again with start=${nextStart}. next_start_char: ${nextStart})`
           : '') +
-        (result.read.truncated
+        (result.value.read.truncated
           ? "\n\n(The page's HTML was larger than the extraction ceiling; trailing content was dropped before conversion. Use `selector` to target the part you need.)"
           : '') +
-        takeoverNote(result.info)
+        takeoverNote(result.value.info)
       );
     },
   };
diff --git a/apps/desktop/src/main/browser/controller.ts b/apps/desktop/src/main/browser/controller.ts
index a214fcdd6b..1cac244301 100644
--- a/apps/desktop/src/main/browser/controller.ts
+++ b/apps/desktop/src/main/browser/controller.ts
@@ -19,8 +19,11 @@
 
 import { type BrowserWindow, type Session, shell, WebContentsView } from 'electron';
 import { CdpBridge, type AutomationEndpoint } from './cdp-bridge.js';
+import type { BrowserOriginLease } from './browser-host.js';
+import { BrowserOriginLeaseTracker } from './browser-origin-lease.js';
 import { browserViewWebPreferences } from './options.js';
 import {
+  type BrowserActionKind,
   type BrowserState,
   type BrowserViewRect,
   deriveBrowserState,
@@ -57,6 +60,9 @@ export class BrowserViewController {
   /** True while the view holds real on-screen bounds (last setViewport painted it). */
   private shownWithBounds = false;
   private automation: CdpBridge | null = null;
+  private readonly originLeases = new BrowserOriginLeaseTracker(() =>
+    this.destroyed || this.wc.isDestroyed() ? '' : this.wc.getURL(),
+  );
 
   constructor(
     private readonly window: BrowserWindow,
@@ -78,8 +84,8 @@ export class BrowserViewController {
     const wc = this.wc;
     wc.on('did-start-loading', () => this.emitState());
     wc.on('did-stop-loading', () => this.emitState());
-    wc.on('did-navigate', () => this.emitState());
-    wc.on('did-navigate-in-page', () => this.emitState());
+    wc.on('did-navigate', () => this.recordNavigation());
+    wc.on('did-navigate-in-page', () => this.recordNavigation());
     wc.on('page-title-updated', () => this.emitState());
     wc.on('did-fail-load', () => this.emitState());
 
@@ -151,6 +157,16 @@ export class BrowserViewController {
     this.onState(this.sessionId, this.state());
   }
 
+  private recordNavigation(): void {
+    if (this.destroyed) return;
+    this.originLeases.recordNavigation(this.wc.getURL());
+    this.emitState();
+  }
+
+  openOriginLease(approvedUrl: string, kind: BrowserActionKind): BrowserOriginLease {
+    return this.originLeases.open(approvedUrl, kind);
+  }
+
   async navigate(input: string): Promise<void> {
     const url = parseNavigable(input);
     if (!url) return;
diff --git a/apps/desktop/src/main/browser/session.ts b/apps/desktop/src/main/browser/session.ts
index 05e2d5532e..e0a9b5a7ad 100644
--- a/apps/desktop/src/main/browser/session.ts
+++ b/apps/desktop/src/main/browser/session.ts
@@ -19,7 +19,11 @@
 
 import { CDPBridge } from '@jackwener/opencli/browser/cdp';
 import type { IPage } from '@jackwener/opencli/types';
-import { browserAutomationAvailable, browserViewHost } from './browser-host.js';
+import {
+  type BrowserOriginLease,
+  browserAutomationAvailable,
+  browserViewHost,
+} from './browser-host.js';
 import { type BrowserActionKind, parseNavigable } from './logic.js';
 
 /**
@@ -273,7 +277,10 @@ async function acquire(sessionId: string): Promise<Connection> {
   return promise;
 }
 
-export type BrowserPageRun<T> = (page: IPage, info: { takeoverReloaded: boolean }) => Promise<T>;
+export type BrowserPageRun<T> = (
+  page: IPage,
+  info: { takeoverReloaded: boolean; originLease?: BrowserOriginLease },
+) => Promise<T>;
 
 /**
  * Run one tool action against the session's embedded-browser page: lazy connect
@@ -285,7 +292,12 @@ export async function withBrowserPage<T>(
   sessionId: string,
   label: string,
   run: BrowserPageRun<T>,
-  opts?: { timeoutMs?: number; abort?: AbortSignal; takeover?: TakeoverMode },
+  opts?: {
+    timeoutMs?: number;
+    abort?: AbortSignal;
+    takeover?: TakeoverMode;
+    originAdmission?: { approvedUrl: string };
+  },
 ): Promise<T> {
   if (opts?.abort?.aborted) throw new BrowserActionCanceledError(label);
   const kind: TakeoverMode = opts?.takeover ?? 'observe';
@@ -306,6 +318,13 @@ export async function withBrowserPage<T>(
   let timer: ReturnType<typeof setTimeout> | undefined;
   let onAbort: (() => void) | undefined;
   let onRevoke: (() => void) | undefined;
+  // Establish the page-host Origin lease before endpoint acquisition and a
+  // possible takeover reload. This closes the Provider-check → first-page-await
+  // gap, while still preserving canDrive's rule that a blocked action creates
+  // no view.
+  const originLease = opts?.originAdmission
+    ? browserViewHost().openOriginLease(sessionId, opts.originAdmission.approvedUrl, kind)
+    : undefined;
   // Track this action so a switch away from the conversation can revoke it (the
   // visible lease is continuous, not just the preflight canDrive above).
   // Registered AFTER canDrive resolved true, with no await between, so the
@@ -362,7 +381,7 @@ export async function withBrowserPage<T>(
         conn.pendingTakeover = false;
       }
     }
-    return await Promise.race([run(conn.page, { takeoverReloaded }), interrupted]);
+    return await Promise.race([run(conn.page, { takeoverReloaded, ...(originLease ? { originLease } : {}) }), interrupted]);
   } catch (err) {
     if (conn && isConnectionLoss(err)) {
       invalidate(conn);
@@ -378,6 +397,7 @@ export async function withBrowserPage<T>(
   } finally {
     clearTimeout(timer);
     if (onAbort) opts?.abort?.removeEventListener('abort', onAbort);
+    originLease?.release();
     untrackInFlight(sessionId, revoke);
   }
 }
diff --git a/apps/desktop/src/main/permission-response-guard.ts b/apps/desktop/src/main/permission-response-guard.ts
index b3f9ec0861..e29fe05aab 100644
--- a/apps/desktop/src/main/permission-response-guard.ts
+++ b/apps/desktop/src/main/permission-response-guard.ts
@@ -31,6 +31,7 @@ import {
 } from '@maka/core/events';
 import type { UserQuestionResponse } from '@maka/core/user-question';
 import type { SandboxBoundaryResponse } from '@maka/core/sandbox-boundary';
+import type { ClientCapabilityResponse } from '@maka/core/client-capability-grant';
 import { MAX_ATTACHMENT_COUNT } from '@maka/core/attachments';
 import { isAttachmentRef, isCanonicalStorageRef, type AttachmentRef } from '@maka/core/events';
 
@@ -96,6 +97,24 @@ export function normalizeSandboxBoundaryResponse(input: unknown): SandboxBoundar
   };
 }
 
+export function normalizeClientCapabilityResponse(input: unknown): ClientCapabilityResponse {
+  if (!input || typeof input !== 'object') {
+    throw new Error('Invalid Client Capability response');
+  }
+  const value = input as Record<string, unknown>;
+  if (
+    typeof value.requestId !== 'string' ||
+    value.requestId.length === 0 ||
+    value.requestId.length > MAX_PERMISSION_REQUEST_ID_LENGTH
+  ) {
+    throw new Error('Invalid Client Capability response requestId');
+  }
+  if (value.decision !== 'allow' && value.decision !== 'deny') {
+    throw new Error('Invalid Client Capability response decision');
+  }
+  return { requestId: value.requestId, decision: value.decision };
+}
+
 export function normalizeUserQuestionResponse(input: unknown): UserQuestionResponse {
   const value = requireObject(input, 'Invalid user question response');
   const requestId = normalizeRequiredString(
diff --git a/apps/desktop/src/main/runtime-host-boot.ts b/apps/desktop/src/main/runtime-host-boot.ts
index a48483b34a..aa768055e5 100644
--- a/apps/desktop/src/main/runtime-host-boot.ts
+++ b/apps/desktop/src/main/runtime-host-boot.ts
@@ -79,6 +79,7 @@ import { renderAttachmentPreview, resizeImageForAttachment } from "./attachment-
 import { registerAttachmentPreviewIpc } from "./attachment-preview.js";
 import { readFileCapped } from "./attachment-ingest.js";
 import { registerBrowserIpc } from "./browser-ipc-main.js";
+import { browserViewHost } from "./browser/browser-host.js";
 import { releaseBrowserSession } from "./browser/session.js";
 import { createE2eFixtureBotOnboardingAdapters } from "./bot-onboarding-e2e-fixture.js";
 import { resolveBuildInfo } from "./build-info.js";
@@ -875,6 +876,17 @@ runtimeHostManager = await startRuntimeHostDesktopManager(
     resizeImage: resizeImageForAttachment,
     nativeCapabilities: {
       browserTools: native.browserTools,
+      resolveBrowserUrl: ({ sessionId, toolName, arguments: args }) => {
+        if (toolName === "browser_navigate") {
+          if (typeof args.url !== "string") {
+            throw new Error("Browser navigation URL is unavailable");
+          }
+          return args.url;
+        }
+        const url = browserViewHost().currentUrl(sessionId);
+        if (!url) throw new Error("Browser session has no current URL");
+        return url;
+      },
       releaseBrowserSession,
       computerUseTools: native.computerUseTools,
       additionalGroups: () => {
diff --git a/apps/desktop/src/main/runtime-host-native-capabilities.ts b/apps/desktop/src/main/runtime-host-native-capabilities.ts
index e812492c62..bebe81db1c 100644
--- a/apps/desktop/src/main/runtime-host-native-capabilities.ts
+++ b/apps/desktop/src/main/runtime-host-native-capabilities.ts
@@ -35,6 +35,7 @@ import type {
   ClientCapabilityServiceOffer,
 } from "@maka/runtime-host/protocol";
 import { toJSONSchema, z } from "zod";
+import { withBrowserOriginAdmission } from './browser/browser-origin-admission.js';
 import type { DesktopTargetScope } from '../shared/runtime-host-identity.js';
 
 const CAPABILITY_VERSION = "0";
@@ -62,6 +63,12 @@ type DesktopToolContentPart = Extract<
 
 export interface DesktopNativeCapabilityProviderInput {
   readonly browserTools: readonly MakaTool[];
+  readonly resolveBrowserUrl: (input: {
+    readonly sessionId: string;
+    readonly toolName: string;
+    readonly arguments: Record<string, unknown>;
+    readonly signal: AbortSignal;
+  }) => string | Promise<string>;
   readonly releaseBrowserSession: (sessionId: string) => void | Promise<void>;
   readonly computerUseTools: ComputerUseToolSet;
   readonly releaseComputerUseSession: (
@@ -156,6 +163,7 @@ export function createDesktopNativeCapabilityProvider(
 
       const invocation = new AbortController();
       const task = invokeNativeTool(
+        input,
         binding,
         frame,
         options,
@@ -238,7 +246,7 @@ async function invokeAdditionalService(
   options: Parameters<NonNullable<ClientCapabilityProvider["callService"]>>[1],
 ): Promise<Record<string, unknown>> {
   options.signal.throwIfAborted();
-  await options.accept();
+  await options.accept({ kind: "none" });
   options.signal.throwIfAborted();
   return service.call(frame.method, frame.input, { signal: options.signal });
 }
@@ -309,6 +317,7 @@ function capabilityGroups(
 }
 
 async function invokeNativeTool(
+  input: DesktopNativeCapabilityProviderInput,
   binding: NativeToolBinding,
   frame: ClientCapabilityCallFrame,
   options: Parameters<NonNullable<ClientCapabilityProvider["call"]>>[1],
@@ -331,29 +340,69 @@ async function invokeNativeTool(
   const parameters = requireZodSchema(binding.tool);
   const args = await parameters.parseAsync(frame.arguments);
   signal.throwIfAborted();
-  await options.accept();
+  const sessionId =
+    frame.offerId === BROWSER_OFFER_ID || frame.offerId === COMPUTER_USE_OFFER_ID
+      ? providerOptions.nativeSessionId?.(frame.sessionId) ?? frame.sessionId
+      : frame.sessionId;
+  const admissionEvidence =
+    frame.offerId === BROWSER_OFFER_ID
+      ? {
+          kind: "browser_url" as const,
+          url: await input.resolveBrowserUrl({
+            sessionId,
+            toolName: frame.toolName,
+            arguments: frame.arguments,
+            signal,
+          }),
+        }
+      : { kind: "none" as const };
+  signal.throwIfAborted();
+  await options.accept(admissionEvidence);
+  signal.throwIfAborted();
+  if (admissionEvidence.kind === "browser_url") {
+    const currentUrl = await input.resolveBrowserUrl({
+      sessionId,
+      toolName: frame.toolName,
+      arguments: frame.arguments,
+      signal,
+    });
+    if (browserOrigin(currentUrl) !== browserOrigin(admissionEvidence.url)) {
+      throw new Error("Browser origin changed while admission was pending");
+    }
+  }
   signal.throwIfAborted();
   usedSessionIds.add(frame.sessionId);
   providerOptions.onSessionUsed?.(frame.sessionId);
   if (frame.offerId === COMPUTER_USE_OFFER_ID) {
     providerOptions.onComputerUseTurnUsed?.(frame.sessionId, frame.turnId);
   }
-  const sessionId =
-    frame.offerId === BROWSER_OFFER_ID || frame.offerId === COMPUTER_USE_OFFER_ID
-      ? providerOptions.nativeSessionId?.(frame.sessionId) ?? frame.sessionId
-      : frame.sessionId;
-  const output = await binding.tool.impl(args, {
-    sessionId,
-    turnId: frame.turnId,
-    cwd,
-    toolCallId: frame.toolCallId,
-    abortSignal: signal,
-    emitOutput() {},
-    ...(options.progress ? { emitProgress: options.progress } : {}),
-  });
+  const execute = () =>
+    binding.tool.impl(args, {
+      sessionId,
+      turnId: frame.turnId,
+      cwd,
+      toolCallId: frame.toolCallId,
+      abortSignal: signal,
+      emitOutput() {},
+      ...(options.progress ? { emitProgress: options.progress } : {}),
+    });
+  const output = await (admissionEvidence.kind === "browser_url"
+    ? withBrowserOriginAdmission(
+        { sessionId, url: admissionEvidence.url },
+        execute,
+      )
+    : execute());
   return projectToolResult(binding.tool, frame.toolCallId, args, output);
 }
 
+function browserOrigin(value: string): string {
+  const url = new URL(value);
+  if (url.protocol !== "http:" && url.protocol !== "https:") {
+    throw new Error("Browser admission requires an HTTP origin");
+  }
+  return url.origin;
+}
+
 function abortInvocations(
   activeInvocations: ReadonlyMap<
     AbortController,
diff --git a/apps/desktop/src/main/runtime-host-session-execution-ipc-main.ts b/apps/desktop/src/main/runtime-host-session-execution-ipc-main.ts
index 2d7f849e3b..5699b58242 100644
--- a/apps/desktop/src/main/runtime-host-session-execution-ipc-main.ts
+++ b/apps/desktop/src/main/runtime-host-session-execution-ipc-main.ts
@@ -42,6 +42,7 @@ import {
   normalizeRegenerateTurnInput,
   normalizeRuntimeHostReviseBeforeTurnInput,
   normalizeSandboxBoundaryResponse,
+  normalizeClientCapabilityResponse,
   normalizeSessionSendCommand,
   normalizeStopSessionInput,
   normalizeUserQuestionResponse,
@@ -639,6 +640,22 @@ export function registerRuntimeHostSessionExecutionIpc(
       deps.observer.publishInteractionAnswer(answered, pending);
     },
   );
+  ipcMain.handle(
+    "sessions:respondToClientCapability",
+    async (_event, sessionId: string, input: unknown) => {
+      const response = normalizeClientCapabilityResponse(input);
+      const pending = await requireInteraction(deps.observer, sessionId, response.requestId);
+      if (pending.request.kind !== "client_capability") {
+        throw new Error("Interaction is not a Client Capability request");
+      }
+      const answered = await deps.client.answerInteraction({
+        sessionId,
+        interactionId: response.requestId,
+        answer: { kind: "client_capability", decision: response.decision },
+      });
+      deps.observer.publishInteractionAnswer(answered, pending);
+    },
+  );
 
   ipcMain.handle("sessions:compact", async (_event, sessionId: string) => {
     const turnId = newId();
diff --git a/apps/desktop/src/main/runtime-host-session-observer.ts b/apps/desktop/src/main/runtime-host-session-observer.ts
index 5eef5d7571..acf5e12377 100644
--- a/apps/desktop/src/main/runtime-host-session-observer.ts
+++ b/apps/desktop/src/main/runtime-host-session-observer.ts
@@ -562,6 +562,12 @@ export class RuntimeHostSessionObserver {
         status: answered.outcome.status,
         revision: answered.revision,
       });
+    } else if (answered.outcome.kind === "client_capability_decision") {
+      this.#broadcast(answered.sessionId, {
+        type: "client_capability_decision_ack",
+        ...base,
+        decision: answered.outcome.decision,
+      });
     }
   }
 
diff --git a/apps/desktop/src/preload/bridge-contract.d.ts b/apps/desktop/src/preload/bridge-contract.d.ts
index 05138dcb67..998aed97b5 100644
--- a/apps/desktop/src/preload/bridge-contract.d.ts
+++ b/apps/desktop/src/preload/bridge-contract.d.ts
@@ -43,6 +43,7 @@ import type { BotProvider } from '@maka/core/bot-chat-settings';
 import type { BotOnboardingSnapshot, BotOnboardingStartInput } from '@maka/core/bot-onboarding';
 import type { HealthSnapshot } from '@maka/core/health';
 import type { ExecutionBoundaryReadModel, SandboxBoundaryResponse } from '@maka/core/sandbox-boundary';
+import type { ClientCapabilityResponse } from '@maka/core/client-capability-grant';
 import type {
   ActiveInteractionRequestEvent,
   MessageContent,
@@ -1176,6 +1177,10 @@ export interface MakaBridge {
     ): Promise<DesktopSessionSummary>;
     reviseBeforeTurn(sessionId: string, input: DesktopReviseBeforeTurnInput): Promise<DesktopSessionSummary>;
     respondToSandboxBoundary(sessionId: string, response: SandboxBoundaryResponse): Promise<void>;
+    respondToClientCapability(
+      sessionId: string,
+      response: ClientCapabilityResponse,
+    ): Promise<void>;
     respondToUserQuestion(sessionId: string, response: UserQuestionResponse): Promise<void>;
     saveConversationToFile(input: {
       markdown: string;
diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts
index c6eaf64a81..0afdf81c99 100644
--- a/apps/desktop/src/preload/preload.ts
+++ b/apps/desktop/src/preload/preload.ts
@@ -125,6 +125,7 @@ import {
   collectRuntimeHostSessionCatalogsWithCoverage,
 } from './runtime-host-session-catalog.js';
 import type { ExecutionBoundaryReadModel, SandboxBoundaryResponse } from '@maka/core/sandbox-boundary';
+import type { ClientCapabilityResponse } from '@maka/core/client-capability-grant';
 import type {
   ActiveInteractionRequestEvent,
   MessageContent,
@@ -2061,6 +2062,16 @@ const makaBridge = {
     respondToSandboxBoundary(sessionId: string, response: SandboxBoundaryResponse): Promise<void> {
       return invokeSessionRuntimeHost('sessions:respondToSandboxBoundary', sessionId, response);
     },
+    respondToClientCapability(
+      sessionId: string,
+      response: ClientCapabilityResponse,
+    ): Promise<void> {
+      return invokeSessionRuntimeHost(
+        'sessions:respondToClientCapability',
+        sessionId,
+        response,
+      );
+    },
     respondToUserQuestion(sessionId: string, response: UserQuestionResponse): Promise<void> {
       return invokeSessionRuntimeHost('sessions:respondToUserQuestion', sessionId, response);
     },
diff --git a/apps/desktop/src/renderer/app-shell-session-events.ts b/apps/desktop/src/renderer/app-shell-session-events.ts
index 09e752c618..cf3491ca7f 100644
--- a/apps/desktop/src/renderer/app-shell-session-events.ts
+++ b/apps/desktop/src/renderer/app-shell-session-events.ts
@@ -23,9 +23,7 @@ import type { UiLocale } from '@maka/core/ui-locale';
 import {
   applyLiveTurnEvent,
   clearInteractions,
-  dequeueInteractionByRequestId,
-  dequeueInteractionByToolUseId,
-  enqueueInteraction,
+  reduceInteractionQueues,
   reconcileTerminalLiveTurn,
   settleLiveTurnStep,
   TOOL_STREAM_MAX_CHUNKS,
@@ -321,6 +319,9 @@ export function createAppShellSessionEventHandlers(options: {
     const pending = takePendingDisplayEvents(sessionId);
     const before = applyProjectionEvents(liveTurnBySessionRef.current[sessionId], pending);
     updateLiveTurn(sessionId, [...pending, event]);
+    setInteractionBySession((current) =>
+      reduceInteractionQueues(current, sessionId, event),
+    );
 
     switch (event.type) {
       case 'queue_update':
@@ -390,18 +391,16 @@ export function createAppShellSessionEventHandlers(options: {
         void refreshMessages(sessionId, { requiredAssistantMessageId: event.messageId }).catch(() => false);
         break;
       case 'sandbox_boundary_request':
+      case 'client_capability_request':
       case 'user_question_request':
         onInteractionChanged?.(sessionId);
-        setInteractionBySession((current) => enqueueInteraction(current, sessionId, event));
         break;
       // The runtime drops its owner on this ack, not on the tool result that
       // follows it, so this is where the request stops being answerable — the
       // same point its boundary sibling settles on, below.
       case 'user_question_answer_ack':
+      case 'client_capability_decision_ack':
         onInteractionChanged?.(sessionId);
-        setInteractionBySession((current) =>
-          dequeueInteractionByRequestId(current, sessionId, event.requestId),
-        );
         break;
       case 'sandbox_boundary_decision_ack':
         onInteractionChanged?.(sessionId);
@@ -410,17 +409,12 @@ export function createAppShellSessionEventHandlers(options: {
         // or the permission label keeps describing the permissions the session
         // had before the user granted more.
         onExecutionBoundaryChanged?.(sessionId);
-        setInteractionBySession((current) =>
-          dequeueInteractionByRequestId(current, sessionId, event.requestId),
-        );
         break;
       case 'tool_result':
-        setInteractionBySession((current) => dequeueInteractionByToolUseId(current, sessionId, event.toolUseId));
         void refreshMessages(sessionId);
         break;
       case 'error':
         onInteractionChanged?.(sessionId);
-        setInteractionBySession((current) => clearInteractions(current, sessionId));
         if (activeIdRef.current === sessionId) {
           if (isNoRealConnectionEvent(event)) {
             const reason = noRealConnectionReasonFromEvent(event);
diff --git a/apps/desktop/src/renderer/app-shell.tsx b/apps/desktop/src/renderer/app-shell.tsx
index c96cccdc8f..fa27552207 100644
--- a/apps/desktop/src/renderer/app-shell.tsx
+++ b/apps/desktop/src/renderer/app-shell.tsx
@@ -830,9 +830,6 @@ function AppShellContent({
     [sessions, onboarding.snapshot?.sessionSendOutcomes],
   );
   const activeInteraction = activeInteractionFor(interactionBySession, ownerActiveId);
-  const activeSandboxBoundary =
-    activeInteraction?.type === 'sandbox_boundary_request' ? activeInteraction : undefined;
-  const activeQuestion = activeInteraction?.type === 'user_question_request' ? activeInteraction : undefined;
   const activeSession = activeCatalogSession;
   const activeMessageQueue = activeId ? messageQueueBySession[activeId] : undefined;
   const activeMessageSubmitting = transientMessages.length > 0;
@@ -2968,9 +2965,8 @@ function AppShellContent({
                   newTaskDraftKey={currentNewTaskDraftKey}
                   newTaskSendPending={newTaskSendPending}
                   stopPendingBySession={stopPendingBySession}
-                  activeSandboxBoundary={activeSandboxBoundary}
                   respondToSandboxBoundary={respondToSandboxBoundary}
-                  activeQuestion={activeQuestion}
+                  respondToClientCapability={workbar.commands.respondToClientCapability}
                   respondToUserQuestion={respondToUserQuestion}
                   stop={stop}
                   directoryComposerProps={directoryComposerProps}
diff --git a/apps/desktop/src/renderer/chat-composer-region.tsx b/apps/desktop/src/renderer/chat-composer-region.tsx
index 0ac9850d59..ab6a6e39c0 100644
--- a/apps/desktop/src/renderer/chat-composer-region.tsx
+++ b/apps/desktop/src/renderer/chat-composer-region.tsx
@@ -18,7 +18,14 @@
  */
 
 import { useLayoutEffect, useRef, type ComponentProps, type RefObject } from 'react';
-import { Button, Composer, SandboxBoundaryPrompt, UserQuestionPrompt, Banner } from '@maka/ui';
+import {
+  Banner,
+  Button,
+  ClientCapabilityPrompt,
+  Composer,
+  SandboxBoundaryPrompt,
+  UserQuestionPrompt,
+} from '@maka/ui';
 import type { ComposerHandle, ComposerInteraction } from '@maka/ui';
 import { useComposerMentionsContext } from './composer-mentions.js';
 import {
@@ -95,8 +102,7 @@ interface ChatComposerRegionProps
   newTaskSendPending: boolean;
   stopPendingBySession: Record<string, boolean>;
   respondToSandboxBoundary: ComponentProps<typeof SandboxBoundaryPrompt>['onRespond'];
-  activeSandboxBoundary: ComponentProps<typeof SandboxBoundaryPrompt>['request'] | undefined;
-  activeQuestion: ComponentProps<typeof UserQuestionPrompt>['request'] | undefined;
+  respondToClientCapability: ComponentProps<typeof ClientCapabilityPrompt>['onRespond'];
   respondToUserQuestion: ComponentProps<typeof UserQuestionPrompt>['onRespond'];
   stop: ComponentProps<typeof UserQuestionPrompt>['onStop'];
   boundaryUnreadableNotice?: BoundaryUnreadableNotice;
@@ -117,8 +123,7 @@ export function ChatComposerRegion({
   newTaskSendPending,
   stopPendingBySession,
   respondToSandboxBoundary,
-  activeSandboxBoundary,
-  activeQuestion,
+  respondToClientCapability,
   respondToUserQuestion,
   stop,
   boundaryUnreadableNotice,
@@ -127,6 +132,11 @@ export function ChatComposerRegion({
   ...composerRest
 }: ChatComposerRegionProps) {
   const mentions = useComposerMentionsContext();
+  const activeSandboxBoundary =
+    activeInteraction?.type === 'sandbox_boundary_request' ? activeInteraction : undefined;
+  const activeClientCapability =
+    activeInteraction?.type === 'client_capability_request' ? activeInteraction : undefined;
+  const activeQuestion = activeInteraction?.type === 'user_question_request' ? activeInteraction : undefined;
   const previousNewTaskDraftKey = useRef(newTaskDraftKey);
   useLayoutEffect(() => {
     const previous = previousNewTaskDraftKey.current;
@@ -216,6 +226,12 @@ export function ChatComposerRegion({
             onRespond={respondToSandboxBoundary}
           />
         )}
+        {activeClientCapability && (
+          <ClientCapabilityPrompt
+            request={activeClientCapability}
+            onRespond={respondToClientCapability}
+          />
+        )}
         {activeQuestion && (
           <UserQuestionPrompt
             request={activeQuestion}
diff --git a/apps/desktop/src/renderer/features/workbar/controller/use-workbar-controller.ts b/apps/desktop/src/renderer/features/workbar/controller/use-workbar-controller.ts
index 554d2a73ac..688248f0a2 100644
--- a/apps/desktop/src/renderer/features/workbar/controller/use-workbar-controller.ts
+++ b/apps/desktop/src/renderer/features/workbar/controller/use-workbar-controller.ts
@@ -26,13 +26,14 @@ import {
   useState,
   type ComponentProps,
 } from 'react';
+import type { ClientCapabilityResponse } from '@maka/core/client-capability-grant';
 import type { QuoteRef } from '@maka/core/events';
 import type { SessionSummary } from '@maka/core/session';
 import { Composer, useUiLocale } from '@maka/ui';
 import type { ChatModelChoice } from '@maka/ui';
 import { safeLocalStorageGet, safeLocalStorageSet } from '../../../browser-storage.js';
 import { getDesktopConversationCopy } from '../../../locales/conversation-copy.js';
-import { localizedShellErrorMessage } from '../../../locales/shell-copy.js';
+import { getShellCopy, localizedShellErrorMessage } from '../../../locales/shell-copy.js';
 import { sideChatTitleFromPrompt } from '../../../side-chat-command.js';
 import { useWorkbarServices } from '../services-context.js';
 import type { WorkbarHostModel } from '../ui/workbar-host.js';
@@ -74,6 +75,7 @@ export interface WorkbarControllerCommands {
     options?: OpenToolOptions,
   ): void;
   openSideChatWithQuote(quote: QuoteRef): void;
+  respondToClientCapability(response: ClientCapabilityResponse): Promise<void>;
   toggleRight(): void;
 }
 
@@ -181,6 +183,26 @@ export function useWorkbarController(
       activeSessionIdRef.current = undefined;
     };
   }, [activeSessionId]);
+  const respondToClientCapability = useCallback<
+    WorkbarControllerCommands['respondToClientCapability']
+  >(
+    async (response) => {
+      const sessionId = activeSessionIdRef.current;
+      if (!sessionId) return;
+      try {
+        await sideChat.respondToClientCapability(sessionId, response);
+      } catch (error) {
+        if (activeSessionIdRef.current !== sessionId) return;
+        const copy = getShellCopy(locale).chatActions;
+        input.reportError(
+          copy.responseFailedTitle,
+          localizedShellErrorMessage(error, copy.responseFailedFallback, locale),
+          sessionId,
+        );
+      }
+    },
+    [input.reportError, locale, sideChat],
+  );
   const panelsStateRef = useRef(layout.workbarPanelsState);
   useLayoutEffect(() => {
     panelsStateRef.current = layout.workbarPanelsState;
@@ -641,8 +663,8 @@ export function useWorkbarController(
   );
 
   const commands = useMemo<WorkbarControllerCommands>(
-    () => ({ openTool, openSideChatWithQuote, toggleRight }),
-    [openSideChatWithQuote, openTool, toggleRight],
+    () => ({ openTool, openSideChatWithQuote, respondToClientCapability, toggleRight }),
+    [openSideChatWithQuote, openTool, respondToClientCapability, toggleRight],
   );
 
   const activeSideChatTabIds = useMemo(
diff --git a/apps/desktop/src/renderer/features/workbar/ports.ts b/apps/desktop/src/renderer/features/workbar/ports.ts
index e5ec333f16..37c4c87fc7 100644
--- a/apps/desktop/src/renderer/features/workbar/ports.ts
+++ b/apps/desktop/src/renderer/features/workbar/ports.ts
@@ -34,6 +34,7 @@ import type { GitReviewReadResult, GitReviewSource } from '@maka/core/git-review
 import type { PermissionMode } from '@maka/core/permission';
 import type { RegenerateTurnInput } from '@maka/core/runtime-inputs';
 import type { SandboxBoundaryResponse } from '@maka/core/sandbox-boundary';
+import type { ClientCapabilityResponse } from '@maka/core/client-capability-grant';
 import type {
   SessionChangedEvent,
   SessionSummary,
@@ -256,6 +257,10 @@ export interface SideChatSessionPort {
     sessionId: string,
     response: SandboxBoundaryResponse,
   ): Promise<void>;
+  respondToClientCapability(
+    sessionId: string,
+    response: ClientCapabilityResponse,
+  ): Promise<void>;
   respondToUserQuestion(
     sessionId: string,
     response: UserQuestionResponse,
diff --git a/apps/desktop/src/renderer/features/workbar/testing.ts b/apps/desktop/src/renderer/features/workbar/testing.ts
index 29284f6050..8bf5c6bb0a 100644
--- a/apps/desktop/src/renderer/features/workbar/testing.ts
+++ b/apps/desktop/src/renderer/features/workbar/testing.ts
@@ -136,6 +136,7 @@ export function createFakeWorkbarServices(
       },
       regenerateTurn: async () => undefined,
       respondToSandboxBoundary: async () => undefined,
+      respondToClientCapability: async () => undefined,
       respondToUserQuestion: async () => undefined,
       subscribeEvents: (_sessionId, _handler, onSeeded) => {
         onSeeded?.();
diff --git a/apps/desktop/src/renderer/features/workbar/tools/side-chat/quote-companion-core.ts b/apps/desktop/src/renderer/features/workbar/tools/side-chat/quote-companion-core.ts
index 8616c95d58..a12512b0fc 100644
--- a/apps/desktop/src/renderer/features/workbar/tools/side-chat/quote-companion-core.ts
+++ b/apps/desktop/src/renderer/features/workbar/tools/side-chat/quote-companion-core.ts
@@ -438,9 +438,11 @@ export function applyCompanionInteractionEvent(
 ): InteractionQueues {
   switch (event.type) {
     case 'sandbox_boundary_request':
+    case 'client_capability_request':
     case 'user_question_request':
       return enqueueInteraction(queues, sessionId, event);
     case 'sandbox_boundary_decision_ack':
+    case 'client_capability_decision_ack':
       return dequeueInteractionByRequestId(queues, sessionId, event.requestId);
     case 'tool_result':
       return dequeueInteractionByToolUseId(queues, sessionId, event.toolUseId);
diff --git a/apps/desktop/src/renderer/features/workbar/tools/side-chat/quote-companion-panel.tsx b/apps/desktop/src/renderer/features/workbar/tools/side-chat/quote-companion-panel.tsx
index 93d4235c3c..8f3f0dfbb3 100644
--- a/apps/desktop/src/renderer/features/workbar/tools/side-chat/quote-companion-panel.tsx
+++ b/apps/desktop/src/renderer/features/workbar/tools/side-chat/quote-companion-panel.tsx
@@ -24,6 +24,7 @@ import {
   ChatView,
   ChatSurfaceLayout,
   Composer,
+  ClientCapabilityPrompt,
   SandboxBoundaryPrompt,
   UserQuestionPrompt,
   useToast,
@@ -176,7 +177,10 @@ export function QuoteCompanionPanel(props: {
         )?.label
       : undefined) ?? activeModel?.model;
 
-  const activeInteraction = companion.activeSandboxBoundary ?? companion.activeQuestion;
+  const activeInteraction =
+    companion.activeSandboxBoundary ??
+    companion.activeClientCapability ??
+    companion.activeQuestion;
   const deriveTurnPresentation = useCallback<
     NonNullable<ComponentProps<typeof ChatView>['deriveTurnPresentation']>
   >(
@@ -215,7 +219,9 @@ export function QuoteCompanionPanel(props: {
             {companion.error && (
               <Banner status="error" role="alert" title={companion.error} />
             )}
-            {(companion.activeSandboxBoundary || companion.activeQuestion) && (
+            {(companion.activeSandboxBoundary ||
+              companion.activeClientCapability ||
+              companion.activeQuestion) && (
               <div className="maka-composer-interaction-slot">
                 {companion.activeSandboxBoundary && (
                   <SandboxBoundaryPrompt
@@ -223,6 +229,12 @@ export function QuoteCompanionPanel(props: {
                     onRespond={companion.respondToSandboxBoundary}
                   />
                 )}
+                {companion.activeClientCapability && (
+                  <ClientCapabilityPrompt
+                    request={companion.activeClientCapability}
+                    onRespond={companion.respondToClientCapability}
+                  />
+                )}
                 {companion.activeQuestion && (
                   <UserQuestionPrompt
                     request={companion.activeQuestion}
diff --git a/apps/desktop/src/renderer/features/workbar/tools/side-chat/use-quote-companion.ts b/apps/desktop/src/renderer/features/workbar/tools/side-chat/use-quote-companion.ts
index 2c6244eb18..e65526dfb6 100644
--- a/apps/desktop/src/renderer/features/workbar/tools/side-chat/use-quote-companion.ts
+++ b/apps/desktop/src/renderer/features/workbar/tools/side-chat/use-quote-companion.ts
@@ -29,11 +29,13 @@ import {
 } from '@maka/ui';
 import type {
   SandboxBoundaryRequestEvent,
+  ClientCapabilityRequestEvent,
   QuoteRef,
   SessionEvent,
   UserQuestionRequestEvent,
 } from '@maka/core/events';
 import type { SandboxBoundaryResponse } from '@maka/core/sandbox-boundary';
+import type { ClientCapabilityResponse } from '@maka/core/client-capability-grant';
 import type { PermissionMode } from '@maka/core/permission';
 import type { SessionSummary, StoredMessage } from '@maka/core/session';
 import type { UiLocale } from '@maka/core/ui-locale';
@@ -137,6 +139,7 @@ export interface UseQuoteCompanionResult {
   activeModel: { llmConnectionSlug: string; model: string } | undefined;
   /** Pending sandbox-boundary / user-question prompt raised by the companion's run. */
   activeSandboxBoundary: SandboxBoundaryRequestEvent | undefined;
+  activeClientCapability: ClientCapabilityRequestEvent | undefined;
   activeQuestion: UserQuestionRequestEvent | undefined;
   /** Returns whether the send was accepted; false leaves the draft + staged
    *  quotes in place so the user can retry. */
@@ -147,6 +150,7 @@ export interface UseQuoteCompanionResult {
   regenerate: (turnId: string) => Promise<boolean>;
   stop: () => Promise<void>;
   respondToSandboxBoundary: (response: SandboxBoundaryResponse) => Promise<void>;
+  respondToClientCapability: (response: ClientCapabilityResponse) => Promise<void>;
   respondToUserQuestion: (response: UserQuestionResponse) => Promise<void>;
 }
 
@@ -1025,6 +1029,19 @@ export function useQuoteCompanion(input: UseQuoteCompanionInput): UseQuoteCompan
     [mountedRef, sideChat],
   );
 
+  const respondToClientCapability = useCallback(
+    async (response: ClientCapabilityResponse): Promise<void> => {
+      const id = companionIdRef.current;
+      if (!mountedRef.current || !id) return;
+      try {
+        await sideChat.respondToClientCapability(id, response);
+      } catch {
+        if (mountedRef.current) setError(copyRef.current.errors.respondFailed);
+      }
+    },
+    [mountedRef, sideChat],
+  );
+
   // Only the companion's own turns render; the forked parent history stays as
   // hidden model context.
   const messages = allMessages.filter(
@@ -1043,6 +1060,8 @@ export function useQuoteCompanion(input: UseQuoteCompanionInput): UseQuoteCompan
     : undefined;
   const activeSandboxBoundary =
     activeInteraction?.type === 'sandbox_boundary_request' ? activeInteraction : undefined;
+  const activeClientCapability =
+    activeInteraction?.type === 'client_capability_request' ? activeInteraction : undefined;
   const activeQuestion =
     activeInteraction?.type === 'user_question_request' ? activeInteraction : undefined;
 
@@ -1061,6 +1080,7 @@ export function useQuoteCompanion(input: UseQuoteCompanionInput): UseQuoteCompan
     error,
     activeModel,
     activeSandboxBoundary,
+    activeClientCapability,
     activeQuestion,
     send,
     steer,
@@ -1068,6 +1088,7 @@ export function useQuoteCompanion(input: UseQuoteCompanionInput): UseQuoteCompan
     regenerate,
     stop,
     respondToSandboxBoundary,
+    respondToClientCapability,
     respondToUserQuestion,
   };
 }
diff --git a/apps/desktop/src/renderer/platform/desktop/create-workbar-services.ts b/apps/desktop/src/renderer/platform/desktop/create-workbar-services.ts
index 78cf43f8cb..31fdc82832 100644
--- a/apps/desktop/src/renderer/platform/desktop/create-workbar-services.ts
+++ b/apps/desktop/src/renderer/platform/desktop/create-workbar-services.ts
@@ -160,6 +160,8 @@ export function createDesktopWorkbarServices(
         bridge.sessions.regenerateTurn(sessionId, input),
       respondToSandboxBoundary: (sessionId, response) =>
         bridge.sessions.respondToSandboxBoundary(sessionId, response),
+      respondToClientCapability: (sessionId, response) =>
+        bridge.sessions.respondToClientCapability(sessionId, response),
       respondToUserQuestion: (sessionId, response) =>
         bridge.sessions.respondToUserQuestion(sessionId, response),
       subscribeEvents: (sessionId, handler, onSeeded, onSeedError) =>
diff --git a/apps/desktop/stories/session-workbar.stories.tsx b/apps/desktop/stories/session-workbar.stories.tsx
index 30b6387120..9c5246717b 100644
--- a/apps/desktop/stories/session-workbar.stories.tsx
+++ b/apps/desktop/stories/session-workbar.stories.tsx
@@ -910,6 +910,7 @@ function bridge(options: {
       }),
       regenerateTurn: async () => undefined,
       respondToSandboxBoundary: async () => undefined,
+      respondToClientCapability: async () => undefined,
       respondToUserQuestion: async () => undefined,
       subscribeEvents: (_sessionId, _handler, onSeeded) => {
         onSeeded?.();
diff --git a/docs/astryx-surface-file-inventory.md b/docs/astryx-surface-file-inventory.md
index 7df6c86044..6708f4a6e3 100644
--- a/docs/astryx-surface-file-inventory.md
+++ b/docs/astryx-surface-file-inventory.md
@@ -209,6 +209,7 @@ Wiki bar: Design Conventions · API Use-the-System · Theming · Container Paddi
 | `packages/ui/src/chat-surface-layout.tsx` | shell-chrome-or-panel | ChatLayout | aligned — uses Astryx (ChatLayout) | aligned |
 | `packages/ui/src/chat-turn.tsx` | shell-chrome-or-panel | Badge, Banner, Button, ChatMessage, ChatMessageBubble, ChatMessageMetadata, ChatSystemMessage, ChatTokenizedText, HStack, IconButton, Spinner, Thumbnail, Timestamp, Token, Tooltip | aligned — uses Astryx (Badge, Banner, Button, ChatMessage, ChatMessageBubble, ChatMessageMetadata, ChatSystemMessage, ChatTokenizedText) | aligned |
 | `packages/ui/src/chat-view.tsx` | shell-chrome-or-panel | Button, ButtonGroup, ChatMessageList, EmptyState, HStack, Spinner, Text | aligned — uses Astryx (Button, ButtonGroup, ChatMessageList, EmptyState, HStack, Spinner, Text) | aligned |
+| `packages/ui/src/client-capability-prompt.tsx` | ui-composition | Button | aligned — uses Astryx (Button) | aligned |
 | `packages/ui/src/components.tsx` | ui-composition | none | aligned — no raw controls; no Astryx JSX usage | aligned |
 | `packages/ui/src/composer-message-queue.tsx` | shell-chrome-or-panel | Button, IconButton, List, ListItem | raw `<textarea` (API Use-the-System) | polish |
 | `packages/ui/src/composer.tsx` | shell-chrome-or-panel | Button, ChatComposer, ChatComposerDrawer, ChatComposerInput, DropdownMenu, DropdownMenuCheckboxItem, DropdownMenuDivider, DropdownMenuItem, DropdownMenuRadioGroup, DropdownMenuRadioItem, IconButton, Lightbox, Token, Tooltip | aligned — uses Astryx (Button, ChatComposer, ChatComposerDrawer, ChatComposerInput, DropdownMenu, DropdownMenuCheckboxItem, DropdownMenuDivider, DropdownMenuItem) | aligned |
diff --git a/docs/astryx-surface-file-inventory.paths b/docs/astryx-surface-file-inventory.paths
index 228465236a..4b39af6d02 100644
--- a/docs/astryx-surface-file-inventory.paths
+++ b/docs/astryx-surface-file-inventory.paths
@@ -180,6 +180,7 @@ packages/ui/src/chat-model-switcher.tsx
 packages/ui/src/chat-surface-layout.tsx
 packages/ui/src/chat-turn.tsx
 packages/ui/src/chat-view.tsx
+packages/ui/src/client-capability-prompt.tsx
 packages/ui/src/components.tsx
 packages/ui/src/composer-message-queue.tsx
 packages/ui/src/composer.tsx
diff --git a/packages/cli/src/__tests__/tui-mcp-local-integration.test.ts b/packages/cli/src/__tests__/tui-mcp-local-integration.test.ts
index c564df2daf..aab5ec79ff 100644
--- a/packages/cli/src/__tests__/tui-mcp-local-integration.test.ts
+++ b/packages/cli/src/__tests__/tui-mcp-local-integration.test.ts
@@ -38,6 +38,7 @@ import {
 import {
   HostClientCapabilityCoordinator,
   RuntimePolicyActivationGate,
+  clientCapabilityCoordinatorTestAdmission,
   type ClientCapabilitySnapshot,
 } from '@maka/runtime-host/test-only/client-capability-host';
 import { createMcpConfigStore } from '@maka/storage/mcp-config-store';
@@ -71,6 +72,7 @@ test('local TUI discovers, publishes, invokes, republishes, and closes one MCP c
       idleGraceMs: 60_000,
       composition: defineInteractiveRuntimeHostComposition(async () => {
         coordinator = new HostClientCapabilityCoordinator({
+          ...clientCapabilityCoordinatorTestAdmission(),
           activation: new RuntimePolicyActivationGate(),
           onModelToolsChanged: () => undefined,
         });
diff --git a/packages/cli/src/mcp-capability-provider.ts b/packages/cli/src/mcp-capability-provider.ts
index d1b8d34608..ad79fe7146 100644
--- a/packages/cli/src/mcp-capability-provider.ts
+++ b/packages/cli/src/mcp-capability-provider.ts
@@ -104,7 +104,7 @@ export function createMcpCapabilityProvider(
         capabilityBindingKey(frame.offerId, frame.serverId, frame.toolName),
       );
       if (!binding) throw new Error('MCP capability is not part of the published snapshot');
-      await options.accept();
+      await options.accept({ kind: 'none' });
       return projectMcpResult(
         await manager.callTool(binding, frame.arguments, { signal: options.signal }),
       );
diff --git a/packages/core/package.json b/packages/core/package.json
index bb0fcbd321..5ce1b558e5 100644
--- a/packages/core/package.json
+++ b/packages/core/package.json
@@ -58,6 +58,7 @@
     "./e2e-fixture": "./dist/e2e-fixture.js",
     "./capabilities": "./dist/capabilities.js",
     "./capability-audit": "./dist/capability-audit.js",
+    "./client-capability-grant": "./dist/client-capability-grant.js",
     "./health": "./dist/health.js",
     "./search": "./dist/search.js",
     "./bot-events": "./dist/bot-events.js",
diff --git a/packages/core/src/__tests__/interaction.test.ts b/packages/core/src/__tests__/interaction.test.ts
index 304a78d04c..9b820797e9 100644
--- a/packages/core/src/__tests__/interaction.test.ts
+++ b/packages/core/src/__tests__/interaction.test.ts
@@ -33,6 +33,7 @@ import {
   interactionCanonicalOutcomesEquivalent,
   isInteractionAnswerValidForRequest,
   isInteractionCanonicalOutcomeValidForRequest,
+  projectInteractionClientCapabilityRequest,
   projectInteractionPermissionRequest,
   projectInteractionQuestionRequest,
   projectInteractionSandboxBoundaryRequest,
@@ -782,6 +783,49 @@ describe('Interaction decoding and validity', () => {
     );
   });
 
+  test('keeps Client Capability approval tied to the exact Host grant target', () => {
+    const request = projectInteractionClientCapabilityRequest({
+      toolUseId: 'browser-call-1',
+      target: {
+        providerId: 'provider-1',
+        contractId: 'contract-1',
+        serverId: 'desktop_browser',
+        toolName: 'browser_snapshot',
+        capability: 'browser',
+        scope: { kind: 'browser_origin', origin: 'https://example.com' },
+      },
+    });
+    const answer = decodeInteractionAnswer({ kind: 'client_capability', decision: 'allow' });
+    const outcome = decodeInteractionCanonicalOutcome({
+      kind: 'client_capability_decision',
+      decision: 'allow',
+      committedAt: 2,
+    });
+    assert.equal(isInteractionAnswerValidForRequest(request, answer), true);
+    assert.equal(isInteractionCanonicalOutcomeValidForRequest(request, outcome), true);
+    assert.equal(
+      isInteractionCanonicalOutcomeValidForRequest(
+        request,
+        decodeInteractionCanonicalOutcome({
+          kind: 'closure',
+          reason: 'timed_out',
+          committedAt: 3,
+        }),
+      ),
+      true,
+    );
+    assert.deepEqual(decodeInteractionRequest(request), request);
+    assert.throws(() =>
+      projectInteractionClientCapabilityRequest({
+        ...request,
+        target: {
+          ...request.target,
+          scope: { kind: 'browser_origin', origin: 'https://example.com/path' },
+        },
+      }),
+    );
+  });
+
   const permission = projectInteractionPermissionRequest(toolPermission);
   const question = projectInteractionQuestionRequest({
     toolUseId: 'q1',
diff --git a/packages/core/src/backend-types.ts b/packages/core/src/backend-types.ts
index e76e17fe63..9f326ca20f 100644
--- a/packages/core/src/backend-types.ts
+++ b/packages/core/src/backend-types.ts
@@ -190,11 +190,12 @@ export type BackendStopMode = 'immediate' | 'after_step';
 
 /**
  * The live session-event vocabulary accepted from a backend. `queue_update`
- * belongs to the runtime kernel, while legacy permission requests and
+ * belongs to the runtime kernel, Client Capability approval events belong to
+ * the Host-owned Interaction projection, and legacy permission requests and
  * acknowledgements were replaced by sandbox-boundary events. `send` stays
  * typed as `SessionEvent` for implementation ergonomics; the flow drops these
- * retired variants at ingress so they are never mapped, observed, or persisted
- * by a new run.
+ * non-backend variants at ingress so they are never mapped or persisted by a
+ * new run.
  */
 export type BackendSessionEvent = Exclude<
   SessionEvent,
@@ -204,6 +205,8 @@ export type BackendSessionEvent = Exclude<
       type:
         | 'queue_update'
         | 'message_admission'
+        | 'client_capability_request'
+        | 'client_capability_decision_ack'
         | 'permission_request'
         | 'permission_answer_ack'
         | 'permission_closure_ack'
diff --git a/packages/core/src/client-capability-grant.ts b/packages/core/src/client-capability-grant.ts
new file mode 100644
index 0000000000..d2ffca1f0f
--- /dev/null
+++ b/packages/core/src/client-capability-grant.ts
@@ -0,0 +1,210 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { defineObjectShape, hasExactShape } from './record-schema.js';
+
+const SAFE_ID = /^[A-Za-z0-9_-]{1,128}$/;
+
+export type ClientCapabilityGrantCapability = 'browser' | 'computer_use' | 'desktop_mcp';
+
+export type ClientCapabilityGrantScope =
+  | { readonly kind: 'browser_origin'; readonly origin: string }
+  | { readonly kind: 'capability' }
+  | { readonly kind: 'mcp_tool'; readonly serverId: string; readonly toolName: string };
+
+export interface ClientCapabilityGrantTarget {
+  readonly providerId: string;
+  readonly contractId: string;
+  readonly serverId: string;
+  readonly toolName: string;
+  readonly capability: ClientCapabilityGrantCapability;
+  readonly scope: ClientCapabilityGrantScope;
+}
+
+export interface ClientCapabilitySessionGrantKey extends ClientCapabilityGrantTarget {
+  readonly sessionId: string;
+}
+
+export interface ClientCapabilitySessionGrant extends ClientCapabilitySessionGrantKey {
+  readonly version: 1;
+  readonly grantedAt: number;
+}
+
+export interface ClientCapabilityResponse {
+  readonly requestId: string;
+  readonly decision: 'allow' | 'deny';
+}
+
+const GRANT_SHAPE = defineObjectShape<ClientCapabilitySessionGrant>()(
+  [
+    'version',
+    'sessionId',
+    'providerId',
+    'contractId',
+    'serverId',
+    'toolName',
+    'capability',
+    'scope',
+    'grantedAt',
+  ],
+  [],
+);
+const BROWSER_ORIGIN_SCOPE_SHAPE = defineObjectShape<
+  Extract<ClientCapabilityGrantScope, { kind: 'browser_origin' }>
+>()(['kind', 'origin'], []);
+const CAPABILITY_SCOPE_SHAPE = defineObjectShape<
+  Extract<ClientCapabilityGrantScope, { kind: 'capability' }>
+>()(['kind'], []);
+const MCP_TOOL_SCOPE_SHAPE = defineObjectShape<
+  Extract<ClientCapabilityGrantScope, { kind: 'mcp_tool' }>
+>()(['kind', 'serverId', 'toolName'], []);
+
+export function decodeClientCapabilitySessionGrantKey(
+  value: unknown,
+): ClientCapabilitySessionGrantKey {
+  const record = plainRecord(value, 'Client Capability Session Grant key');
+  return deepFreeze({
+    sessionId: safeId(record.sessionId, 'sessionId'),
+    ...decodeClientCapabilityGrantTarget(record),
+  });
+}
+
+export function decodeClientCapabilityGrantTarget(value: unknown): ClientCapabilityGrantTarget {
+  const record = plainRecord(value, 'Client Capability Grant target');
+  const capability = oneOf(
+    record.capability,
+    ['browser', 'computer_use', 'desktop_mcp'] as const,
+    'capability',
+  );
+  const scope = decodeClientCapabilityGrantScope(record.scope);
+  if (
+    (capability === 'browser' && scope.kind !== 'browser_origin') ||
+    (capability === 'computer_use' && scope.kind !== 'capability') ||
+    (capability === 'desktop_mcp' && scope.kind !== 'mcp_tool')
+  ) {
+    throw new Error('Client Capability Session Grant scope does not match capability');
+  }
+  return deepFreeze({
+    providerId: safeId(record.providerId, 'providerId'),
+    contractId: safeId(record.contractId, 'contractId'),
+    serverId: safeId(record.serverId, 'serverId'),
+    toolName: safeId(record.toolName, 'toolName'),
+    capability,
+    scope,
+  });
+}
+
+export function decodeClientCapabilitySessionGrant(value: unknown): ClientCapabilitySessionGrant {
+  const record = plainRecord(value, 'Client Capability Session Grant');
+  if (!hasExactShape(record, GRANT_SHAPE)) {
+    throw new Error('Invalid Client Capability Session Grant fields');
+  }
+  if (record.version !== 1) throw new Error('Invalid Client Capability Session Grant version');
+  if (
+    typeof record.grantedAt !== 'number' ||
+    !Number.isSafeInteger(record.grantedAt) ||
+    record.grantedAt < 0
+  ) {
+    throw new Error('Invalid Client Capability Session Grant timestamp');
+  }
+  return deepFreeze({
+    version: 1,
+    ...decodeClientCapabilitySessionGrantKey(record),
+    grantedAt: record.grantedAt,
+  });
+}
+
+export function clientCapabilityScopeIdentity(scope: ClientCapabilityGrantScope): string {
+  switch (scope.kind) {
+    case 'browser_origin':
+      return scope.origin;
+    case 'capability':
+      return '*';
+    case 'mcp_tool':
+      return `${scope.serverId}\0${scope.toolName}`;
+  }
+}
+
+function decodeClientCapabilityGrantScope(value: unknown): ClientCapabilityGrantScope {
+  const record = plainRecord(value, 'Client Capability Session Grant scope');
+  switch (record.kind) {
+    case 'browser_origin': {
+      if (!hasExactShape(record, BROWSER_ORIGIN_SCOPE_SHAPE)) {
+        throw new Error('Invalid Browser origin scope fields');
+      }
+      if (typeof record.origin !== 'string' || record.origin.length > 16_384) {
+        throw new Error('Invalid Browser origin scope');
+      }
+      const url = new URL(record.origin);
+      if ((url.protocol !== 'http:' && url.protocol !== 'https:') || url.origin !== record.origin) {
+        throw new Error('Browser origin scope must be a canonical HTTP origin');
+      }
+      return deepFreeze({ kind: 'browser_origin', origin: record.origin });
+    }
+    case 'capability':
+      if (!hasExactShape(record, CAPABILITY_SCOPE_SHAPE)) {
+        throw new Error('Invalid capability scope fields');
+      }
+      return Object.freeze({ kind: 'capability' });
+    case 'mcp_tool':
+      if (!hasExactShape(record, MCP_TOOL_SCOPE_SHAPE)) {
+        throw new Error('Invalid MCP tool scope fields');
+      }
+      return Object.freeze({
+        kind: 'mcp_tool',
+        serverId: safeId(record.serverId, 'scope.serverId'),
+        toolName: safeId(record.toolName, 'scope.toolName'),
+      });
+    default:
+      throw new Error('Invalid Client Capability Session Grant scope kind');
+  }
+}
+
+function plainRecord(value: unknown, label: string): Record<string, unknown> {
+  if (value === null || typeof value !== 'object' || Array.isArray(value)) {
+    throw new Error(`${label} must be a plain record`);
+  }
+  const prototype = Object.getPrototypeOf(value);
+  if (prototype !== Object.prototype && prototype !== null) {
+    throw new Error(`${label} must be a plain record`);
+  }
+  return value as Record<string, unknown>;
+}
+
+function safeId(value: unknown, label: string): string {
+  if (typeof value !== 'string' || !SAFE_ID.test(value)) throw new Error(`Invalid ${label}`);
+  return value;
+}
+
+function oneOf<const T extends readonly unknown[]>(
+  value: unknown,
+  values: T,
+  label: string,
+): T[number] {
+  if (!values.includes(value)) throw new Error(`Invalid ${label}`);
+  return value as T[number];
+}
+
+function deepFreeze<T>(value: T): T {
+  if (value !== null && typeof value === 'object' && !Object.isFrozen(value)) {
+    for (const nested of Object.values(value as Record<string, unknown>)) deepFreeze(nested);
+    Object.freeze(value);
+  }
+  return value;
+}
diff --git a/packages/core/src/events.ts b/packages/core/src/events.ts
index e6f4534ee1..5e2c3bb740 100644
--- a/packages/core/src/events.ts
+++ b/packages/core/src/events.ts
@@ -37,6 +37,10 @@ import type {
 } from './permission.js';
 import type { SandboxBoundaryExpansion, SandboxBoundaryRequestStatus } from './sandbox-boundary.js';
 import type { UserQuestionRequest } from './user-question.js';
+import type {
+  ClientCapabilityGrantCapability,
+  ClientCapabilityGrantScope,
+} from './client-capability-grant.js';
 import type {
   PipeShellOutput,
   PtyShellOutput,
@@ -557,6 +561,8 @@ export type SessionEvent =
   | AnyPermissionRequestEvent
   | SandboxBoundaryRequestEvent
   | SandboxBoundaryDecisionAckEvent
+  | ClientCapabilityRequestEvent
+  | ClientCapabilityDecisionAckEvent
   | PermissionAnswerAckEvent
   | PermissionClosureAckEvent
   | PermissionDecisionAckEvent
@@ -1016,12 +1022,23 @@ export interface SandboxBoundaryRequestEvent extends BaseEvent {
   expansion: SandboxBoundaryExpansion;
 }
 
+export interface ClientCapabilityRequestEvent extends BaseEvent {
+  type: 'client_capability_request';
+  requestId: string;
+  toolUseId: string;
+  capability: ClientCapabilityGrantCapability;
+  scope: ClientCapabilityGrantScope;
+}
+
 /**
  * The requests a session can park on while it waits for the user. Both are
  * registered by RuntimeKernel while unanswered, so a surface that missed the
  * live event can rehydrate the prompt instead of stranding the run.
  */
-export type ActiveInteractionRequestEvent = SandboxBoundaryRequestEvent | UserQuestionRequestEvent;
+export type ActiveInteractionRequestEvent =
+  | SandboxBoundaryRequestEvent
+  | UserQuestionRequestEvent
+  | ClientCapabilityRequestEvent;
 
 export interface SandboxBoundaryDecisionAckEvent extends BaseEvent {
   type: 'sandbox_boundary_decision_ack';
@@ -1032,6 +1049,13 @@ export interface SandboxBoundaryDecisionAckEvent extends BaseEvent {
   revision: number;
 }
 
+export interface ClientCapabilityDecisionAckEvent extends BaseEvent {
+  type: 'client_capability_decision_ack';
+  requestId: string;
+  toolUseId: string;
+  decision: 'allow' | 'deny';
+}
+
 /**
  * Echo that the backend accepted a user-question answer.
  * The canonical answer remains owned by InteractionStore.
diff --git a/packages/core/src/interaction.ts b/packages/core/src/interaction.ts
index f49136af08..c064906aff 100644
--- a/packages/core/src/interaction.ts
+++ b/packages/core/src/interaction.ts
@@ -38,6 +38,10 @@ import {
   type SandboxBoundaryExpansion,
   type SandboxBoundaryRequestStatus,
 } from './sandbox-boundary.js';
+import {
+  decodeClientCapabilityGrantTarget,
+  type ClientCapabilityGrantTarget,
+} from './client-capability-grant.js';
 
 export * from './interaction-permission-review.js';
 
@@ -61,6 +65,7 @@ export const INTERACTION_CLOSURE_REASONS = [
   'turn_terminal',
   'timed_out',
   'host_restarted',
+  'provider_disconnected',
 ] as const;
 
 const UTF8 = new TextEncoder();
@@ -95,10 +100,17 @@ export interface InteractionSandboxBoundaryRequest {
   readonly justification: string;
 }
 
+export interface InteractionClientCapabilityRequest {
+  readonly kind: 'client_capability';
+  readonly toolUseId: string;
+  readonly target: ClientCapabilityGrantTarget;
+}
+
 export type InteractionRequest =
   | InteractionPermissionRequest
   | InteractionQuestionRequest
-  | InteractionSandboxBoundaryRequest;
+  | InteractionSandboxBoundaryRequest
+  | InteractionClientCapabilityRequest;
 
 export type InteractionPermissionDecisionFields =
   | { readonly decision: 'allow'; readonly rememberForTurn: boolean }
@@ -118,10 +130,16 @@ export interface InteractionSandboxBoundaryAnswer {
   readonly decision: 'allow' | 'deny';
 }
 
+export interface InteractionClientCapabilityAnswer {
+  readonly kind: 'client_capability';
+  readonly decision: 'allow' | 'deny';
+}
+
 export type InteractionAnswer =
   | InteractionPermissionAnswer
   | InteractionQuestionAnswer
-  | InteractionSandboxBoundaryAnswer;
+  | InteractionSandboxBoundaryAnswer
+  | InteractionClientCapabilityAnswer;
 
 export type InteractionCanonicalPermissionOutcome = {
   readonly kind: 'permission_answer';
@@ -144,6 +162,12 @@ export interface InteractionCanonicalSandboxBoundaryOutcome {
   readonly committedAt: number;
 }
 
+export interface InteractionCanonicalClientCapabilityOutcome {
+  readonly kind: 'client_capability_decision';
+  readonly decision: 'allow' | 'deny';
+  readonly committedAt: number;
+}
+
 export interface InteractionCanonicalClosureOutcome {
   readonly kind: 'closure';
   readonly reason: InteractionClosureReason;
@@ -154,6 +178,7 @@ export type InteractionCanonicalOutcome =
   | InteractionCanonicalPermissionOutcome
   | InteractionCanonicalQuestionOutcome
   | InteractionCanonicalSandboxBoundaryOutcome
+  | InteractionCanonicalClientCapabilityOutcome
   | InteractionCanonicalClosureOutcome;
 
 export type InteractionQuestionProjectionInput = Pick<
@@ -173,6 +198,10 @@ const SANDBOX_BOUNDARY_REQUEST_SHAPE = defineObjectShape<InteractionSandboxBound
   ['kind', 'expansion', 'justification'],
   [],
 );
+const CLIENT_CAPABILITY_REQUEST_SHAPE = defineObjectShape<InteractionClientCapabilityRequest>()(
+  ['kind', 'toolUseId', 'target'],
+  [],
+);
 const PERMISSION_ANSWER_SHAPE = defineObjectShape<InteractionPermissionAnswer>()(
   ['kind', 'decision', 'rememberForTurn'],
   [],
@@ -185,6 +214,10 @@ const SANDBOX_BOUNDARY_ANSWER_SHAPE = defineObjectShape<InteractionSandboxBounda
   ['kind', 'decision'],
   [],
 );
+const CLIENT_CAPABILITY_ANSWER_SHAPE = defineObjectShape<InteractionClientCapabilityAnswer>()(
+  ['kind', 'decision'],
+  [],
+);
 const PERMISSION_OUTCOME_SHAPE = defineObjectShape<InteractionCanonicalPermissionOutcome>()(
   ['kind', 'reviewer', 'committedAt', 'decision', 'rememberForTurn'],
   ['rationale', 'riskLevel'],
@@ -198,6 +231,11 @@ const SANDBOX_BOUNDARY_OUTCOME_SHAPE =
     ['kind', 'decision', 'status', 'committedAt'],
     [],
   );
+const CLIENT_CAPABILITY_OUTCOME_SHAPE =
+  defineObjectShape<InteractionCanonicalClientCapabilityOutcome>()(
+    ['kind', 'decision', 'committedAt'],
+    [],
+  );
 const CLOSURE_OUTCOME_SHAPE = defineObjectShape<InteractionCanonicalClosureOutcome>()(
   ['kind', 'reason', 'committedAt'],
   [],
@@ -240,6 +278,13 @@ export function decodeInteractionRequest(value: unknown): InteractionRequest {
         INTERACTION_SANDBOX_BOUNDARY_JUSTIFICATION_MAX_CHARS,
       ),
     };
+  } else if (record.kind === 'client_capability') {
+    exact(record, CLIENT_CAPABILITY_REQUEST_SHAPE, 'Client Capability request');
+    request = {
+      kind: 'client_capability',
+      toolUseId: boundedString(record.toolUseId, 'toolUseId', INTERACTION_ID_MAX_BYTES),
+      target: decodeClientCapabilityGrantTarget(record.target),
+    };
   } else {
     throw new Error('Invalid Interaction request kind');
   }
@@ -271,6 +316,12 @@ export function decodeInteractionAnswer(value: unknown): InteractionAnswer {
       kind: 'sandbox_boundary',
       decision: oneOf(record.decision, ['allow', 'deny'] as const, 'decision'),
     };
+  } else if (record.kind === 'client_capability') {
+    exact(record, CLIENT_CAPABILITY_ANSWER_SHAPE, 'Client Capability answer');
+    answer = {
+      kind: 'client_capability',
+      decision: oneOf(record.decision, ['allow', 'deny'] as const, 'decision'),
+    };
   } else {
     throw new Error('Invalid Interaction answer kind');
   }
@@ -339,6 +390,13 @@ export function decodeInteractionCanonicalOutcome(value: unknown): InteractionCa
       status,
       committedAt: safeInteger(record.committedAt, 'committedAt', false),
     };
+  } else if (record.kind === 'client_capability_decision') {
+    exact(record, CLIENT_CAPABILITY_OUTCOME_SHAPE, 'Client Capability outcome');
+    outcome = {
+      kind: 'client_capability_decision',
+      decision: oneOf(record.decision, ['allow', 'deny'] as const, 'decision'),
+      committedAt: safeInteger(record.committedAt, 'committedAt', false),
+    };
   } else if (record.kind === 'closure') {
     exact(record, CLOSURE_OUTCOME_SHAPE, 'closure outcome');
     outcome = {
@@ -423,6 +481,17 @@ export function projectInteractionSandboxBoundaryRequest(input: {
   }) as InteractionSandboxBoundaryRequest;
 }
 
+export function projectInteractionClientCapabilityRequest(input: {
+  readonly toolUseId: string;
+  readonly target: ClientCapabilityGrantTarget;
+}): InteractionClientCapabilityRequest {
+  return decodeInteractionRequest({
+    kind: 'client_capability',
+    toolUseId: input.toolUseId,
+    target: input.target,
+  }) as InteractionClientCapabilityRequest;
+}
+
 export function interactionAnswerMatchesRequestKind(
   request: InteractionRequest,
   answer: InteractionAnswer,
@@ -440,7 +509,9 @@ export function interactionOutcomeMatchesRequestKind(
       ? outcome.kind === 'permission_answer'
       : request.kind === 'question'
         ? outcome.kind === 'question_answer'
-        : outcome.kind === 'sandbox_boundary_decision')
+        : request.kind === 'sandbox_boundary'
+          ? outcome.kind === 'sandbox_boundary_decision'
+          : outcome.kind === 'client_capability_decision')
   );
 }
 
@@ -476,6 +547,7 @@ export function isInteractionAnswerValidForRequest(
     );
   }
   if (answer.kind === 'sandbox_boundary') return request.kind === 'sandbox_boundary';
+  if (answer.kind === 'client_capability') return request.kind === 'client_capability';
   return interactionRememberForTurnIsEligible(request, answer);
 }
 
@@ -485,7 +557,11 @@ export function isInteractionCanonicalOutcomeValidForRequest(
 ): boolean {
   if (!interactionOutcomeMatchesRequestKind(request, outcome)) return false;
   if (outcome.kind === 'closure')
-    return request.kind === 'permission' || outcome.reason !== 'timed_out';
+    return (
+      request.kind === 'permission' ||
+      request.kind === 'client_capability' ||
+      outcome.reason !== 'timed_out'
+    );
   if (outcome.kind === 'permission_answer') {
     return interactionRememberForTurnIsEligible(request, outcome);
   }
@@ -495,6 +571,9 @@ export function isInteractionCanonicalOutcomeValidForRequest(
       interactionQuestionAnswerCountMatchesRequest(request, outcome.answers)
     );
   }
+  if (outcome.kind === 'client_capability_decision') {
+    return request.kind === 'client_capability';
+  }
   return request.kind === 'sandbox_boundary';
 }
 
@@ -510,6 +589,9 @@ export function interactionCanonicalOutcomesEquivalent(
   if (left.kind === 'sandbox_boundary_decision' && right.kind === 'sandbox_boundary_decision') {
     return left.decision === right.decision && left.status === right.status;
   }
+  if (left.kind === 'client_capability_decision' && right.kind === 'client_capability_decision') {
+    return left.decision === right.decision;
+  }
   return left.kind === 'closure' && right.kind === 'closure' && left.reason === right.reason;
 }
 
diff --git a/packages/runtime-host/src/__tests__/client-capability-admission-integration.test.ts b/packages/runtime-host/src/__tests__/client-capability-admission-integration.test.ts
new file mode 100644
index 0000000000..a68e1d6d71
--- /dev/null
+++ b/packages/runtime-host/src/__tests__/client-capability-admission-integration.test.ts
@@ -0,0 +1,385 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import assert from 'node:assert/strict';
+import { mkdir, mkdtemp, rm } from 'node:fs/promises';
+import { tmpdir } from 'node:os';
+import { join } from 'node:path';
+import { test } from 'node:test';
+
+import { createWorkspaceWritePermissionProfile } from '@maka/core/permission-profile';
+import { createManagedExecutionBoundary } from '@maka/core/sandbox-boundary';
+import type { LlmConnection } from '@maka/core/llm-connections';
+import type { SessionHeader } from '@maka/core/session';
+import { ToolRuntime } from '@maka/runtime/tool-runtime';
+import {
+  openInteractiveExecutionStoresForWrite,
+  type ExecutionStoresWriter,
+} from '@maka/storage/execution-stores';
+import type { InteractiveInteractionStoreWriterFacade } from '@maka/storage/interaction-store';
+import {
+  resolveStorageRoot,
+  tryAcquireInteractiveRootOwner,
+  type InteractiveRootOwner,
+} from '@maka/storage/root-authority';
+import {
+  HostClientCapabilityCoordinator,
+  type ClientCapabilitySnapshot,
+} from '../server/client-capability-coordinator.js';
+import type { ClientCapabilityConnection } from '../server/client-capability-service.js';
+import { clientCapabilityProviderId } from '../server/client-capability-provider-id.js';
+import {
+  HostInteractionCoordinator,
+  type HostInteractionCoordinatorOptions,
+} from '../server/interaction-coordinator.js';
+import type { ConnectionContext } from '../server/operation-dispatcher.js';
+import { RuntimePolicyActivationGate } from '../server/runtime-policy-activation-gate.js';
+import { SessionAdmissionGate } from '../server/session-admission-gate.js';
+
+const RUN = Object.freeze({
+  sessionId: 'session_1',
+  turnId: 'turn_1',
+  runId: 'run_1',
+});
+const IDENTITY = Object.freeze({
+  connectionId: 'desktop-connection-1',
+  principalKind: 'capability_provider' as const,
+  principalId: 'desktop:installation-secret',
+  clientInstanceId: 'desktop-client-secret',
+});
+
+test('cancels managed approval owners and joiners with the canonical provider identity', async () => {
+  await withStore(async ({ store }) => {
+    const order: string[] = [];
+    const toolCallByInvocation = new Map<string, string>();
+    const cancelledToolCalls: string[] = [];
+    let acceptedCount = 0;
+    let resolveThirdAccepted!: () => void;
+    const thirdAccepted = new Promise<void>((resolve) => {
+      resolveThirdAccepted = resolve;
+    });
+    const interactions = createInteractionCoordinator(store);
+    const runOwner = interactions.bindRun(RUN);
+    const capabilities = new HostClientCapabilityCoordinator({
+      activation: new RuntimePolicyActivationGate(),
+      onModelToolsChanged: () => undefined,
+      interactions,
+      grants: store,
+    });
+    let connection!: ClientCapabilityConnection;
+    connection = capabilities.attachConnection(IDENTITY, {
+      send: async (frame) => {
+        if (frame.kind === 'client.capability.call') {
+          toolCallByInvocation.set(frame.invocationId, frame.toolCallId);
+          order.push('accepted');
+          acceptedCount += 1;
+          if (acceptedCount === 3) resolveThirdAccepted();
+          connection.accept({
+            kind: 'client.capability.accepted',
+            invocationId: frame.invocationId,
+            admissionEvidence: {
+              kind: 'browser_url',
+              url: 'https://example.com/private?token=secret',
+            },
+          });
+        } else if (frame.kind === 'client.capability.cancel') {
+          const toolCallId = toolCallByInvocation.get(frame.invocationId);
+          if (toolCallId) cancelledToolCalls.push(toolCallId);
+        } else if (frame.kind === 'client.capability.admitted') {
+          order.push('admitted');
+          connection.accept({
+            kind: 'client.capability.result',
+            invocationId: frame.invocationId,
+            result: { content: [{ type: 'text', text: 'snapshot' }] },
+          });
+        }
+      },
+    });
+    let snapshot: ClientCapabilitySnapshot | undefined;
+    try {
+      const replaced = await capabilities.handlers['client.capability.replace'](
+        {
+          registrationId: 'desktop-native-registration',
+          offers: [
+            {
+              offerId: 'desktop_browser',
+              version: '1',
+              affinity: 'session',
+              hostPathAccess: 'none',
+              label: 'Desktop Browser',
+              tools: [
+                {
+                  serverId: 'desktop_browser',
+                  name: 'browser_snapshot',
+                  inputSchema: { type: 'object', additionalProperties: false },
+                },
+              ],
+            },
+          ],
+        },
+        connectionContext(IDENTITY.connectionId),
+      );
+      assert.equal(replaced.ok, true, JSON.stringify(replaced));
+      assert.deepEqual(await capabilities.bindSession(RUN.sessionId, IDENTITY.connectionId), {
+        ok: true,
+      });
+      snapshot = capabilities.snapshotForSession(RUN.sessionId);
+      assert.ok(snapshot);
+      const tool = snapshot.tools[0];
+      assert.ok(tool);
+      assert.equal(tool.hostAdmission, 'client_capability');
+      assert.equal(tool.categoryHint, 'custom_tool');
+      const runtime = new ToolRuntime({
+        sessionId: RUN.sessionId,
+        header: sessionHeader(),
+        connection: llmConnection(),
+        modelId: 'model-1',
+        appendMessage: async () => undefined,
+        readExecutionBoundary: async () =>
+          createManagedExecutionBoundary(createWorkspaceWritePermissionProfile(), 0),
+        newId: nextId(),
+        now: nextNow(),
+        getPermissionPauseTarget: () => null,
+        turnId: RUN.turnId,
+        runId: RUN.runId,
+        invocationId: 'invocation-1',
+        runtimeCommitSink: {
+          commitToolPrepared: async () => {
+            order.push('T1');
+            return { created: true, runtimeEventSeq: 1 };
+          },
+          commitToolOutcome: async () => ({ created: true, runtimeEventSeq: 2 }),
+        },
+      });
+      const caller = new AbortController();
+      const cancelled = runtime.settleToolCall({
+        tool,
+        turnId: RUN.turnId,
+        stepId: 'step-1',
+        toolCallId: 'browser-call-1',
+        input: {},
+        abortSignal: caller.signal,
+        eventSink: {
+          push: () => undefined,
+          pushAndWaitUntilConsumed: async () => undefined,
+        },
+      });
+      const request = await waitForPending(store);
+      assert.equal(request.request.kind, 'client_capability');
+      if (request.request.kind !== 'client_capability') return;
+
+      const providerId = clientCapabilityProviderId(IDENTITY);
+      assert.equal(request.request.target.providerId, providerId);
+      assert.equal(JSON.stringify(request).includes(IDENTITY.principalId), false);
+      assert.equal(JSON.stringify(request).includes(IDENTITY.clientInstanceId), false);
+
+      caller.abort(new DOMException('Code Mode deadline reached', 'TimeoutError'));
+      await cancelled;
+      const closed = await store.readInteraction(request.requestId);
+      assert.equal(closed?.outcome?.outcome.kind, 'closure');
+      if (closed?.outcome?.outcome.kind === 'closure') {
+        assert.equal(closed.outcome.outcome.reason, 'timed_out');
+      }
+      assert.deepEqual(order, ['accepted']);
+
+      const settling = runtime.settleToolCall({
+        tool,
+        turnId: RUN.turnId,
+        stepId: 'step-2',
+        toolCallId: 'browser-call-2',
+        input: {},
+        abortSignal: new AbortController().signal,
+        eventSink: {
+          push: () => undefined,
+          pushAndWaitUntilConsumed: async () => undefined,
+        },
+      });
+      const retryRequest = await waitForPending(store);
+      assert.notEqual(retryRequest.requestId, request.requestId);
+      assert.equal(retryRequest.request.kind, 'client_capability');
+      if (retryRequest.request.kind !== 'client_capability') return;
+
+      const joinedCaller = new AbortController();
+      const joined = runtime.settleToolCall({
+        tool,
+        turnId: RUN.turnId,
+        stepId: 'step-3',
+        toolCallId: 'browser-call-3',
+        input: {},
+        abortSignal: joinedCaller.signal,
+        eventSink: {
+          push: () => undefined,
+          pushAndWaitUntilConsumed: async () => undefined,
+        },
+      });
+      await thirdAccepted;
+      assert.deepEqual(
+        (await store.listSessionPending(RUN.sessionId)).map((pending) => pending.requestId),
+        [retryRequest.requestId],
+      );
+
+      joinedCaller.abort(new DOMException('Joined caller stopped', 'AbortError'));
+      await joined;
+      assert.deepEqual(cancelledToolCalls, ['browser-call-1', 'browser-call-3']);
+      assert.deepEqual(order, ['accepted', 'accepted', 'accepted']);
+      assert.equal((await store.readInteraction(retryRequest.requestId))?.outcome, undefined);
+
+      const answered = await interactions.handlers['interaction.answer'](
+        {
+          sessionId: RUN.sessionId,
+          interactionId: retryRequest.requestId,
+          answer: { kind: 'client_capability', decision: 'allow' },
+        },
+        connectionContext('desktop-ui'),
+      );
+      assert.equal(answered.ok, true);
+      order.push('approved');
+      const settlement = await settling;
+      const grant = await store.readClientCapabilitySessionGrant({
+        sessionId: RUN.sessionId,
+        ...retryRequest.request.target,
+      });
+      assert.equal(grant?.providerId, providerId);
+      assert.deepEqual(settlement.result, { content: [{ type: 'text', text: 'snapshot' }] });
+      assert.deepEqual(order, ['accepted', 'accepted', 'accepted', 'approved', 'T1', 'admitted']);
+    } finally {
+      snapshot?.release();
+      await connection.close();
+      await capabilities.close();
+      await runOwner.close('turn_terminal');
+      runOwner.release();
+      await interactions.close();
+    }
+  });
+});
+
+function sessionHeader(): SessionHeader {
+  return {
+    id: RUN.sessionId,
+    workspaceRoot: '/tmp',
+    cwd: '/tmp',
+    createdAt: 1,
+    name: 'test',
+    titleIsManual: false,
+    isFlagged: false,
+    labels: [],
+    isArchived: false,
+    status: 'active',
+    statusUpdatedAt: 1,
+    hasUnread: false,
+    backend: 'ai-sdk',
+    llmConnectionSlug: 'connection-1',
+    connectionLocked: true,
+    model: 'model-1',
+    permissionMode: 'ask',
+    schemaVersion: 1,
+  };
+}
+
+function llmConnection(): LlmConnection {
+  return {
+    slug: 'connection-1',
+    name: 'test',
+    providerType: 'openai',
+    defaultModel: 'model-1',
+    enabled: true,
+    createdAt: 1,
+    updatedAt: 1,
+  };
+}
+
+function nextId(): () => string {
+  let value = 0;
+  return () => `event-${++value}`;
+}
+
+function nextNow(): () => number {
+  let value = 100;
+  return () => ++value;
+}
+
+function connectionContext(connectionId: string): ConnectionContext {
+  return {
+    hostEpoch: 'host_epoch_1',
+    connectionId,
+    principal: 'local_os_user',
+    acquireResidency: () => ({ release: () => undefined }),
+  };
+}
+
+async function waitForPending(store: InteractiveInteractionStoreWriterFacade) {
+  for (let attempt = 0; attempt < 100; attempt += 1) {
+    const pending = await store.listSessionPending(RUN.sessionId);
+    if (pending[0]) return pending[0];
+    await new Promise<void>((resolve) => setImmediate(resolve));
+  }
+  throw new Error('Client Capability approval was not published');
+}
+
+function createInteractionCoordinator(
+  store: InteractiveInteractionStoreWriterFacade,
+): HostInteractionCoordinator {
+  let now = 100;
+  const options: HostInteractionCoordinatorOptions = {
+    store,
+    sandboxBoundaries: {
+      createSandboxBoundaryRequest: async () => {
+        throw new Error('Unexpected sandbox boundary publication');
+      },
+      readSandboxBoundaryRequest: async () => undefined,
+      listPendingSandboxBoundaryRequests: async () => [],
+      settleSandboxBoundaryRequest: async () => {
+        throw new Error('Unexpected sandbox boundary settlement');
+      },
+      listHeaders: async () => [],
+    },
+    sessionAdmission: new SessionAdmissionGate(),
+    sessions: { probeSessionRemoval: async () => ({ kind: 'present' }) },
+    now: () => ++now,
+    preflightSessionSnapshot: () => true,
+    refreshCanonicalContinuity: async () => undefined,
+    onPoison: () => undefined,
+    onSandboxBoundarySettled: async () => undefined,
+  };
+  return new HostInteractionCoordinator(options);
+}
+
+interface StoreContext {
+  readonly owner: InteractiveRootOwner;
+  readonly store: InteractiveInteractionStoreWriterFacade;
+  readonly stores: ExecutionStoresWriter<'interactive'>;
+}
+
+async function withStore(run: (context: StoreContext) => Promise<void>): Promise<void> {
+  const base = await mkdtemp(join(tmpdir(), 'maka-client-capability-admission-'));
+  const root = join(base, 'root');
+  await mkdir(root);
+  const capability = await resolveStorageRoot({ path: root, kind: 'interactive' });
+  const owner = await tryAcquireInteractiveRootOwner(capability);
+  assert.ok(owner);
+  if (!owner) return;
+  const stores = await openInteractiveExecutionStoresForWrite(owner.lease);
+  try {
+    await run({ owner, store: stores.interactionStore, stores });
+  } finally {
+    if (!owner.closed) await owner.close();
+    await rm(owner.controlDirectory, { recursive: true, force: true });
+    await rm(base, { recursive: true, force: true });
+  }
+}
diff --git a/packages/runtime-host/src/__tests__/client-capability-channel.test.ts b/packages/runtime-host/src/__tests__/client-capability-channel.test.ts
index f1cab263cc..fd48cffabb 100644
--- a/packages/runtime-host/src/__tests__/client-capability-channel.test.ts
+++ b/packages/runtime-host/src/__tests__/client-capability-channel.test.ts
@@ -95,7 +95,7 @@ test('Client Capability channel runs a self-described Host service through admis
     callService: async (frame, options) => {
       assert.equal(frame.method, 'present');
       assert.equal(accepted, false);
-      await options.accept();
+      await options.accept({ kind: 'none' });
       accepted = true;
       return { kind: 'presented' };
     },
@@ -134,7 +134,11 @@ test('Client Capability channel runs a self-described Host service through admis
   await new Promise((resolve) => setImmediate(resolve));
   assert.equal(accepted, true);
   assert.deepEqual(written, [
-    { kind: 'client.capability.accepted', invocationId: 'service_invocation' },
+    {
+      kind: 'client.capability.accepted',
+      invocationId: 'service_invocation',
+      admissionEvidence: { kind: 'none' },
+    },
     {
       kind: 'client.capability.result',
       invocationId: 'service_invocation',
@@ -227,7 +231,7 @@ test('Client Capability channel forwards admitted tool progress before the resul
       },
     ],
     call: async (_frame, options) => {
-      await options.accept();
+      await options.accept({ kind: 'none' });
       options.progress?.(1, 3);
       options.progress?.(2, 3);
       return { content: [] };
@@ -272,7 +276,11 @@ test('Client Capability channel forwards admitted tool progress before the resul
   await new Promise((resolve) => setImmediate(resolve));
 
   assert.deepEqual(written, [
-    { kind: 'client.capability.accepted', invocationId: 'progress-invocation' },
+    {
+      kind: 'client.capability.accepted',
+      invocationId: 'progress-invocation',
+      admissionEvidence: { kind: 'none' },
+    },
     {
       kind: 'client.capability.progress',
       invocationId: 'progress-invocation',
diff --git a/packages/runtime-host/src/__tests__/client-capability-coordinator.test.ts b/packages/runtime-host/src/__tests__/client-capability-coordinator.test.ts
index ddb155ab6d..c34e4611f8 100644
--- a/packages/runtime-host/src/__tests__/client-capability-coordinator.test.ts
+++ b/packages/runtime-host/src/__tests__/client-capability-coordinator.test.ts
@@ -23,14 +23,22 @@ import { createManagedExecutionBoundary } from '@maka/core/sandbox-boundary';
 import { createWorkspaceWritePermissionProfile } from '@maka/core/permission-profile';
 import { ToolOutcomeUnknownError } from '@maka/core/events';
 import type { McpCallResult } from '@maka/core/mcp';
-import type { ClientCapabilityReplaceInput } from '../protocol/index.js';
+import type {
+  ClientCapabilityAdmissionEvidence,
+  ClientCapabilityCallFrame,
+  ClientCapabilityReplaceInput,
+} from '../protocol/index.js';
 import {
   ClientCapabilityInvocationError,
   HostClientCapabilityCoordinator,
+  type HostClientCapabilityCoordinatorOptions,
 } from '../server/client-capability-coordinator.js';
 import type { ClientCapabilityConnection } from '../server/client-capability-service.js';
 import { RuntimePolicyActivationGate } from '../server/runtime-policy-activation-gate.js';
-import { clientCapabilityConnectionIdentity } from './fixtures/client-capability.js';
+import {
+  clientCapabilityConnectionIdentity,
+  clientCapabilityCoordinatorTestAdmission,
+} from './fixtures/client-capability.js';
 
 describe('Host Client Capability coordinator', () => {
   test('freezes active snapshots across replacement and releases stale registrations', async () => {
@@ -40,18 +48,27 @@ describe('Host Client Capability coordinator', () => {
     connection = coordinator.attachConnection(clientCapabilityConnectionIdentity('connection-a'), {
       send: async (frame) => {
         sent.push(frame);
-        if (frame.kind !== 'client.capability.call') return;
-        queueMicrotask(() => {
+        if (frame.kind === 'client.capability.call') {
           connection.accept({
             kind: 'client.capability.accepted',
             invocationId: frame.invocationId,
+            admissionEvidence: { kind: 'none' },
           });
+          return;
+        }
+        if (frame.kind === 'client.capability.admitted') {
+          const call = sent.find(
+            (candidate) =>
+              isRecord(candidate) &&
+              candidate.kind === 'client.capability.call' &&
+              candidate.invocationId === frame.invocationId,
+          );
           connection.accept({
             kind: 'client.capability.result',
             invocationId: frame.invocationId,
-            result: textResult(`called:${frame.toolName}`),
+            result: textResult(`called:${isRecord(call) ? String(call.toolName) : 'unknown'}`),
           });
-        });
+        }
       },
     });
 
@@ -122,21 +139,32 @@ describe('Host Client Capability coordinator', () => {
     const coordinator = createCoordinator();
     let observedCall: unknown;
     let connection!: ClientCapabilityConnection;
-    connection = coordinator.attachConnection(clientCapabilityConnectionIdentity('connection-a'), {
-      send: async (frame) => {
-        if (frame.kind !== 'client.capability.call') return;
-        observedCall = frame;
-        connection.accept({
-          kind: 'client.capability.accepted',
-          invocationId: frame.invocationId,
-        });
-        connection.accept({
-          kind: 'client.capability.result',
-          invocationId: frame.invocationId,
-          result: textResult('path independent'),
-        });
+    connection = coordinator.attachConnection(
+      clientCapabilityConnectionIdentity(
+        'connection-a',
+        'connection-a',
+        'remote-owner',
+        'remote_owner',
+      ),
+      {
+        send: async (frame) => {
+          if (frame.kind === 'client.capability.call') {
+            observedCall = frame;
+            connection.accept({
+              kind: 'client.capability.accepted',
+              invocationId: frame.invocationId,
+              admissionEvidence: { kind: 'none' },
+            });
+          } else if (frame.kind === 'client.capability.admitted') {
+            connection.accept({
+              kind: 'client.capability.result',
+              invocationId: frame.invocationId,
+              result: textResult('path independent'),
+            });
+          }
+        },
       },
-    });
+    );
     const replaced = await coordinator.handlers['client.capability.replace'](
       {
         registrationId: 'registration-a',
@@ -187,17 +215,20 @@ describe('Host Client Capability coordinator', () => {
       ),
       {
         send: async (frame) => {
-          if (frame.kind !== 'client.capability.call') return;
-          observedCall = frame;
-          connection.accept({
-            kind: 'client.capability.accepted',
-            invocationId: frame.invocationId,
-          });
-          connection.accept({
-            kind: 'client.capability.result',
-            invocationId: frame.invocationId,
-            result: textResult('remote result'),
-          });
+          if (frame.kind === 'client.capability.call') {
+            observedCall = frame;
+            connection.accept({
+              kind: 'client.capability.accepted',
+              invocationId: frame.invocationId,
+              admissionEvidence: { kind: 'none' },
+            });
+          } else if (frame.kind === 'client.capability.admitted') {
+            connection.accept({
+              kind: 'client.capability.result',
+              invocationId: frame.invocationId,
+              result: textResult('remote result'),
+            });
+          }
         },
       },
     );
@@ -265,9 +296,184 @@ describe('Host Client Capability coordinator', () => {
     await coordinator.close();
   });
 
-  test('reports capability_lost before acceptance and outcome_unknown after acceptance', async () => {
-    await assertLossClassification(false, 'capability_lost');
-    await assertLossClassification(true, 'outcome_unknown');
+  test('trusts local-owner Desktop bindings but not a remote owner spoofing their names', async () => {
+    const local = createCoordinator();
+    const localConnection = local.attachConnection(
+      clientCapabilityConnectionIdentity(
+        'local-connection',
+        'desktop-local',
+        'local_os_user',
+        'local_owner',
+      ),
+      { send: async () => undefined },
+    );
+    await registerSessionTools(
+      local,
+      'local-connection',
+      'local-native-registration',
+      'desktop_browser',
+      ['browser_snapshot'],
+      'cwd',
+    );
+    assert.deepEqual(await local.bindSession('local-session', 'local-connection'), {
+      ok: true,
+    });
+    const localSnapshot = local.snapshotForSession('local-session');
+    assert.ok(localSnapshot);
+    assert.equal(localSnapshot.tools[0]?.categoryHint, 'custom_tool');
+    assert.equal(localSnapshot.tools[0]?.hostAdmission, 'client_capability');
+    localSnapshot.release();
+    await localConnection.close();
+    await local.close();
+
+    const remote = createCoordinator();
+    const remoteConnection = attachAutoAdmittingConnection(
+      remote,
+      'remote-connection',
+      () => ({ kind: 'browser_url', url: 'https://example.com/' }),
+      'spoofed',
+      undefined,
+      'remote_owner',
+    );
+    await registerSessionTools(
+      remote,
+      'remote-connection',
+      'spoofed-native-registration',
+      'desktop_browser',
+      ['browser_snapshot'],
+    );
+    assert.deepEqual(await remote.bindSession('remote-session', 'remote-connection'), {
+      ok: true,
+    });
+    const remoteSnapshot = remote.snapshotForSession('remote-session');
+    assert.ok(remoteSnapshot);
+    assert.equal(remoteSnapshot.tools[0]?.categoryHint, 'client_capability');
+    assert.equal(remoteSnapshot.tools[0]?.hostAdmission, 'client_capability');
+    await assert.rejects(
+      () => prepare(remoteSnapshot.tools[0], {}, 'spoofed-browser-call'),
+      /requires a trusted Desktop provider/u,
+    );
+    remoteSnapshot.release();
+    await remoteConnection.close();
+    await remote.close();
+  });
+
+  test('approves a trusted Browser origin once and reuses the Session Grant across tools', async () => {
+    let approvedTarget:
+      | Parameters<
+          HostClientCapabilityCoordinatorOptions['interactions']['requestClientCapabilityApproval']
+        >[0]['target']
+      | undefined;
+    let approvalCount = 0;
+    const coordinator = createCoordinator(() => undefined, {
+      interactions: {
+        requestClientCapabilityApproval: async ({ target }) => {
+          approvalCount += 1;
+          approvedTarget = target;
+          return 'allow';
+        },
+      },
+      grants: {
+        readClientCapabilitySessionGrant: async (key) =>
+          approvedTarget &&
+          approvedTarget.providerId === key.providerId &&
+          approvedTarget.contractId === key.contractId &&
+          approvedTarget.capability === key.capability &&
+          approvedTarget.scope.kind === 'browser_origin' &&
+          key.scope.kind === 'browser_origin' &&
+          approvedTarget.scope.origin === key.scope.origin
+            ? { version: 1, ...key, grantedAt: 1 }
+            : undefined,
+      },
+    });
+    const sent: unknown[] = [];
+    const connection = attachAutoAdmittingConnection(
+      coordinator,
+      'connection-a',
+      (frame) => ({
+        kind: 'browser_url',
+        url:
+          frame.toolName === 'browser_navigate' && typeof frame.arguments.url === 'string'
+            ? frame.arguments.url
+            : 'https://example.com/current',
+      }),
+      'done',
+      sent,
+    );
+    await registerSessionTools(
+      coordinator,
+      'connection-a',
+      'registration-browser',
+      'desktop_browser',
+      ['browser_snapshot', 'browser_click', 'browser_navigate'],
+    );
+    assert.deepEqual(await coordinator.bindSession('session-a', 'connection-a'), { ok: true });
+    const snapshot = coordinator.snapshotForSession('session-a');
+    assert.ok(snapshot);
+    const tools = new Map(snapshot.tools.map((tool) => [tool.displayName, tool]));
+
+    const preparedSnapshot = await prepare(tools.get('browser_snapshot'), {}, 'tool-snapshot');
+    assert.equal(approvalCount, 1);
+    assert.equal(
+      sent.some((frame) => isRecord(frame) && frame.kind === 'client.capability.admitted'),
+      false,
+    );
+    assert.deepEqual(
+      await preparedSnapshot.execute(managedContext('tool-snapshot')),
+      textResult('done'),
+    );
+
+    const preparedClick = await prepare(tools.get('browser_click'), {}, 'tool-click');
+    assert.equal(approvalCount, 1);
+    assert.deepEqual(await preparedClick.execute(managedContext('tool-click')), textResult('done'));
+
+    const preparedNavigate = await prepare(
+      tools.get('browser_navigate'),
+      { url: 'https://other.example/path' },
+      'tool-navigate',
+    );
+    assert.equal(approvalCount, 2);
+    assert.deepEqual(
+      await preparedNavigate.execute(managedContext('tool-navigate')),
+      textResult('done'),
+    );
+
+    snapshot.release();
+    await connection.close();
+    await coordinator.close();
+  });
+
+  test('passes trusted Desktop Settings through managed admission without a Session Grant', async () => {
+    const coordinator = createCoordinator();
+    const connection = attachAutoAdmittingConnection(
+      coordinator,
+      'connection-a',
+      () => ({ kind: 'none' }),
+      'settings',
+    );
+    await registerSessionTools(
+      coordinator,
+      'connection-a',
+      'registration-settings',
+      'desktop_settings',
+      ['MakaClientSettingsGet'],
+    );
+    assert.deepEqual(await coordinator.bindSession('session-a', 'connection-a'), { ok: true });
+    const snapshot = coordinator.snapshotForSession('session-a');
+    assert.ok(snapshot);
+    const prepared = await prepare(snapshot.tools[0], {}, 'tool-settings');
+    assert.deepEqual(
+      await prepared.execute(managedContext('tool-settings')),
+      textResult('settings'),
+    );
+    snapshot.release();
+    await connection.close();
+    await coordinator.close();
+  });
+
+  test('reports capability_lost before admission and outcome_unknown after admission', async () => {
+    await assertLossClassification('before_acceptance', 'capability_lost');
+    await assertLossClassification('after_admission', 'outcome_unknown');
   });
 
   test('prefers the initiating provider and reports otherwise ambiguous selection', async () => {
@@ -879,31 +1085,37 @@ describe('Host Client Capability coordinator', () => {
     let first!: ClientCapabilityConnection;
     first = coordinator.attachConnection(clientCapabilityConnectionIdentity('connection-a'), {
       send: async (frame) => {
-        if (frame.kind !== 'client.capability.call') return;
-        first.accept({
-          kind: 'client.capability.accepted',
-          invocationId: frame.invocationId,
-        });
-        first.accept({
-          kind: 'client.capability.result',
-          invocationId: frame.invocationId,
-          result: textResult('first'),
-        });
+        if (frame.kind === 'client.capability.call') {
+          first.accept({
+            kind: 'client.capability.accepted',
+            invocationId: frame.invocationId,
+            admissionEvidence: { kind: 'none' },
+          });
+        } else if (frame.kind === 'client.capability.admitted') {
+          first.accept({
+            kind: 'client.capability.result',
+            invocationId: frame.invocationId,
+            result: textResult('first'),
+          });
+        }
       },
     });
     let second!: ClientCapabilityConnection;
     second = coordinator.attachConnection(clientCapabilityConnectionIdentity('connection-b'), {
       send: async (frame) => {
-        if (frame.kind !== 'client.capability.call') return;
-        second.accept({
-          kind: 'client.capability.accepted',
-          invocationId: frame.invocationId,
-        });
-        second.accept({
-          kind: 'client.capability.result',
-          invocationId: frame.invocationId,
-          result: textResult('second'),
-        });
+        if (frame.kind === 'client.capability.call') {
+          second.accept({
+            kind: 'client.capability.accepted',
+            invocationId: frame.invocationId,
+            admissionEvidence: { kind: 'none' },
+          });
+        } else if (frame.kind === 'client.capability.admitted') {
+          second.accept({
+            kind: 'client.capability.result',
+            invocationId: frame.invocationId,
+            result: textResult('second'),
+          });
+        }
       },
     });
     await replace(
@@ -1090,23 +1302,26 @@ describe('Host Client Capability coordinator', () => {
     await coordinator.close();
   });
 
-  test('cancels accepted work as outcome_unknown and ignores a late provider outcome', async () => {
+  test('cancels admitted work as outcome_unknown and ignores a late provider outcome', async () => {
     const coordinator = createCoordinator();
     const sent: Array<{ kind: string; invocationId?: string }> = [];
     let connection!: ClientCapabilityConnection;
-    let callArrived!: () => void;
-    const receivedCall = new Promise<void>((resolve) => {
-      callArrived = resolve;
+    let admittedArrived!: () => void;
+    const receivedAdmission = new Promise<void>((resolve) => {
+      admittedArrived = resolve;
     });
     connection = coordinator.attachConnection(clientCapabilityConnectionIdentity('connection-a'), {
       send: async (frame) => {
         sent.push(frame);
-        if (frame.kind !== 'client.capability.call') return;
-        connection.accept({
-          kind: 'client.capability.accepted',
-          invocationId: frame.invocationId,
-        });
-        callArrived();
+        if (frame.kind === 'client.capability.call') {
+          connection.accept({
+            kind: 'client.capability.accepted',
+            invocationId: frame.invocationId,
+            admissionEvidence: { kind: 'none' },
+          });
+        } else if (frame.kind === 'client.capability.admitted') {
+          admittedArrived();
+        }
       },
     });
     await replace(coordinator, 'connection-a', 'registration-a', 'opaque');
@@ -1126,7 +1341,7 @@ describe('Host Client Capability coordinator', () => {
       },
     );
     assert.ok(pending);
-    await receivedCall;
+    await receivedAdmission;
     abort.abort(new DOMException('Cancelled by test', 'AbortError'));
     await assert.rejects(
       Promise.resolve(pending),
@@ -1161,6 +1376,7 @@ describe('Host Client Capability coordinator', () => {
     const cases = [
       {
         name: 'start before acceptance',
+        afterAdmission: false,
         transition: (connection: ClientCapabilityConnection, invocationId: string) => {
           connection.accept({
             kind: 'client.capability.result_start',
@@ -1169,12 +1385,12 @@ describe('Host Client Capability coordinator', () => {
             chunkCount: 1,
           });
         },
-        message: /chunks started outside the accepted phase/,
+        message: /chunks started outside the admitted phase/,
       },
       {
         name: 'chunk before start',
+        afterAdmission: true,
         transition: (connection: ClientCapabilityConnection, invocationId: string) => {
-          connection.accept({ kind: 'client.capability.accepted', invocationId });
           connection.accept({
             kind: 'client.capability.result_chunk',
             invocationId,
@@ -1186,8 +1402,8 @@ describe('Host Client Capability coordinator', () => {
       },
       {
         name: 'duplicate start',
+        afterAdmission: true,
         transition: (connection: ClientCapabilityConnection, invocationId: string) => {
-          connection.accept({ kind: 'client.capability.accepted', invocationId });
           connection.accept({
             kind: 'client.capability.result_start',
             invocationId,
@@ -1201,12 +1417,12 @@ describe('Host Client Capability coordinator', () => {
             chunkCount: 1,
           });
         },
-        message: /chunks started outside the accepted phase/,
+        message: /chunks started outside the admitted phase/,
       },
       {
         name: 'unchunked result after start',
+        afterAdmission: true,
         transition: (connection: ClientCapabilityConnection, invocationId: string) => {
-          connection.accept({ kind: 'client.capability.accepted', invocationId });
           connection.accept({
             kind: 'client.capability.result_start',
             invocationId,
@@ -1219,12 +1435,12 @@ describe('Host Client Capability coordinator', () => {
             result: textResult('invalid terminal form'),
           });
         },
-        message: /result arrived outside the accepted phase/,
+        message: /result arrived outside the admitted phase/,
       },
       {
         name: 'out-of-order chunk',
+        afterAdmission: true,
         transition: (connection: ClientCapabilityConnection, invocationId: string) => {
-          connection.accept({ kind: 'client.capability.accepted', invocationId });
           connection.accept({
             kind: 'client.capability.result_start',
             invocationId,
@@ -1248,12 +1464,25 @@ describe('Host Client Capability coordinator', () => {
       const invocationArrived = new Promise<string>((resolve) => {
         resolveInvocation = resolve;
       });
+      let resolveAdmission!: () => void;
+      const admissionArrived = new Promise<void>((resolve) => {
+        resolveAdmission = resolve;
+      });
       const connection = coordinator.attachConnection(
         clientCapabilityConnectionIdentity('connection-a'),
         {
           send: async (frame) => {
             if (frame.kind === 'client.capability.call') {
               resolveInvocation(frame.invocationId);
+              if (malformed.afterAdmission) {
+                connection.accept({
+                  kind: 'client.capability.accepted',
+                  invocationId: frame.invocationId,
+                  admissionEvidence: { kind: 'none' },
+                });
+              }
+            } else if (frame.kind === 'client.capability.admitted') {
+              resolveAdmission();
             }
           },
         },
@@ -1264,6 +1493,7 @@ describe('Host Client Capability coordinator', () => {
       assert.ok(snapshot);
       const pending = invoke(snapshot.tools[0]);
       const invocationId = await invocationArrived;
+      if (malformed.afterAdmission) await admissionArrived;
 
       try {
         assert.throws(
@@ -1283,21 +1513,26 @@ describe('Host Client Capability coordinator', () => {
 });
 
 async function assertLossClassification(
-  accept: boolean,
+  phase: 'before_acceptance' | 'after_admission',
   expected: ClientCapabilityInvocationError['code'] | 'outcome_unknown',
 ): Promise<void> {
   const coordinator = createCoordinator();
   let connection!: ClientCapabilityConnection;
   connection = coordinator.attachConnection(clientCapabilityConnectionIdentity('connection-a'), {
     send: async (frame) => {
-      if (frame.kind !== 'client.capability.call') return;
-      if (accept) {
+      if (frame.kind === 'client.capability.call') {
+        if (phase === 'before_acceptance') {
+          connection.close();
+          return;
+        }
         connection.accept({
           kind: 'client.capability.accepted',
           invocationId: frame.invocationId,
+          admissionEvidence: { kind: 'none' },
         });
+      } else if (frame.kind === 'client.capability.admitted') {
+        connection.close();
       }
-      connection.close();
     },
   });
   await replace(coordinator, 'connection-a', 'registration-a', 'opaque');
@@ -1317,13 +1552,113 @@ async function assertLossClassification(
 
 function createCoordinator(
   onModelToolsChanged: () => void = () => undefined,
+  admission: Pick<
+    HostClientCapabilityCoordinatorOptions,
+    'interactions' | 'grants'
+  > = clientCapabilityCoordinatorTestAdmission(),
 ): HostClientCapabilityCoordinator {
   return new HostClientCapabilityCoordinator({
+    ...admission,
     activation: new RuntimePolicyActivationGate(),
     onModelToolsChanged,
   });
 }
 
+async function prepare(
+  tool: ReturnType<typeof toolAt>,
+  args: Record<string, unknown>,
+  toolCallId: string,
+) {
+  assert.ok(tool?.prepareExecution);
+  return tool.prepareExecution(args, {
+    ...managedContext(toolCallId),
+    permissionMode: 'ask',
+  });
+}
+
+function managedContext(toolCallId: string) {
+  return {
+    sessionId: 'session-a',
+    runId: 'run-a',
+    turnId: 'turn-a',
+    cwd: '/tmp',
+    toolCallId,
+    permissionMode: 'ask' as const,
+    executionBoundary: createManagedExecutionBoundary(createWorkspaceWritePermissionProfile(), 0),
+    abortSignal: new AbortController().signal,
+    emitOutput: () => undefined,
+  };
+}
+
+function attachAutoAdmittingConnection(
+  coordinator: HostClientCapabilityCoordinator,
+  connectionId: string,
+  admissionEvidence: (frame: ClientCapabilityCallFrame) => ClientCapabilityAdmissionEvidence,
+  resultText: string,
+  sent?: unknown[],
+  principalKind: Parameters<typeof clientCapabilityConnectionIdentity>[3] = 'capability_provider',
+): ClientCapabilityConnection {
+  let connection!: ClientCapabilityConnection;
+  connection = coordinator.attachConnection(
+    clientCapabilityConnectionIdentity(
+      connectionId,
+      `${connectionId}-client`,
+      `${connectionId}-principal`,
+      principalKind,
+    ),
+    {
+      send: async (frame) => {
+        sent?.push(frame);
+        if (frame.kind === 'client.capability.call') {
+          connection.accept({
+            kind: 'client.capability.accepted',
+            invocationId: frame.invocationId,
+            admissionEvidence: admissionEvidence(frame),
+          });
+        } else if (frame.kind === 'client.capability.admitted') {
+          connection.accept({
+            kind: 'client.capability.result',
+            invocationId: frame.invocationId,
+            result: textResult(resultText),
+          });
+        }
+      },
+    },
+  );
+  return connection;
+}
+
+async function registerSessionTools(
+  coordinator: HostClientCapabilityCoordinator,
+  connectionId: string,
+  registrationId: string,
+  serverId: string,
+  toolNames: readonly string[],
+  hostPathAccess: ClientCapabilityReplaceInput['offers'][number]['hostPathAccess'] = 'none',
+): Promise<void> {
+  const result = await coordinator.handlers['client.capability.replace'](
+    {
+      registrationId,
+      offers: [
+        {
+          offerId: serverId,
+          version: '1',
+          affinity: 'session',
+          hostPathAccess,
+          label: serverId,
+          tools: toolNames.map((name) => ({
+            serverId,
+            name,
+            inputSchema: { type: 'object' },
+          })),
+        },
+      ],
+    },
+    connectionContext(connectionId),
+  );
+  assert.equal(result.ok, true, JSON.stringify(result));
+}
+
 async function replace(
   coordinator: HostClientCapabilityCoordinator,
   connectionId: string,
@@ -1410,6 +1745,7 @@ test('Host services stay bound to the explicitly initiating Client connection',
           connection.accept({
             kind: 'client.capability.accepted',
             invocationId: frame.invocationId,
+            admissionEvidence: { kind: 'none' },
           });
           return;
         }
diff --git a/packages/runtime-host/src/__tests__/client-capability-invocation-broker.test.ts b/packages/runtime-host/src/__tests__/client-capability-invocation-broker.test.ts
new file mode 100644
index 0000000000..01bab875c9
--- /dev/null
+++ b/packages/runtime-host/src/__tests__/client-capability-invocation-broker.test.ts
@@ -0,0 +1,129 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import assert from 'node:assert/strict';
+import { describe, test } from 'node:test';
+import type { ClientCapabilityHostFrame } from '../protocol/index.js';
+import { ClientCapabilityInvocationBroker } from '../server/client-capability-invocation-broker.js';
+
+interface Registration {
+  readonly connectionId: string;
+  readonly registrationId: string;
+}
+
+const registration: Registration = {
+  connectionId: 'connection-a',
+  registrationId: 'registration-a',
+};
+
+const binding = {
+  offerId: 'desktop-browser',
+  hostPathAccess: 'none' as const,
+  descriptor: {
+    serverId: 'desktop_browser',
+    name: 'browser_snapshot',
+    inputSchema: { type: 'object' },
+  },
+};
+
+const context = {
+  sessionId: 'session-a',
+  turnId: 'turn-a',
+  toolCallId: 'tool-call-a',
+  cwd: '/tmp',
+};
+
+describe('ClientCapabilityInvocationBroker', () => {
+  test('keeps provider acceptance paused until explicit admission', async () => {
+    const sent: ClientCapabilityHostFrame[] = [];
+    let broker!: ClientCapabilityInvocationBroker<Registration>;
+    broker = new ClientCapabilityInvocationBroker({
+      senderFor: () => ({
+        send: async (frame) => {
+          sent.push(frame);
+          if (frame.kind === 'client.capability.call') {
+            queueMicrotask(() =>
+              broker.accept('connection-a', {
+                kind: 'client.capability.accepted',
+                invocationId: frame.invocationId,
+                admissionEvidence: { kind: 'none' },
+              }),
+            );
+          }
+          if (frame.kind === 'client.capability.admitted') {
+            queueMicrotask(() =>
+              broker.accept('connection-a', {
+                kind: 'client.capability.result',
+                invocationId: frame.invocationId,
+                result: { content: [{ type: 'text', text: 'ok' }] },
+              }),
+            );
+          }
+        },
+      }),
+      onRegistrationIdle: () => {},
+    });
+
+    const prepared = broker.prepare(registration, binding, {}, context, undefined, 20);
+    await prepared.waitUntilAccepted();
+    await delay(40);
+    assert.equal(
+      sent.some((frame) => frame.kind === 'client.capability.admitted'),
+      false,
+    );
+
+    assert.deepEqual(await prepared.admit(), { content: [{ type: 'text', text: 'ok' }] });
+    assert.equal(sent.filter((frame) => frame.kind === 'client.capability.admitted').length, 1);
+    broker.close();
+  });
+
+  test('cancels accepted work without crossing admission', async () => {
+    const sent: ClientCapabilityHostFrame[] = [];
+    let broker!: ClientCapabilityInvocationBroker<Registration>;
+    broker = new ClientCapabilityInvocationBroker({
+      senderFor: () => ({
+        send: async (frame) => {
+          sent.push(frame);
+          if (frame.kind === 'client.capability.call') {
+            broker.accept('connection-a', {
+              kind: 'client.capability.accepted',
+              invocationId: frame.invocationId,
+              admissionEvidence: { kind: 'none' },
+            });
+          }
+        },
+      }),
+      onRegistrationIdle: () => {},
+    });
+
+    const prepared = broker.prepare(registration, binding, {}, context, undefined, 1_000);
+    await prepared.waitUntilAccepted();
+    prepared.cancel();
+    await assert.rejects(() => prepared.admit(), /cancelled before admission/u);
+    assert.deepEqual(
+      sent.map((frame) => frame.kind),
+      ['client.capability.call', 'client.capability.cancel', 'client.capability.release'],
+    );
+    broker.close();
+  });
+});
+
+function delay(ms: number): Promise<void> {
+  return new Promise((resolve) => setTimeout(resolve, ms));
+}
diff --git a/packages/runtime-host/src/__tests__/client-capability-protocol.test.ts b/packages/runtime-host/src/__tests__/client-capability-protocol.test.ts
index ca0b1a7f39..1f79a4b7a3 100644
--- a/packages/runtime-host/src/__tests__/client-capability-protocol.test.ts
+++ b/packages/runtime-host/src/__tests__/client-capability-protocol.test.ts
@@ -118,10 +118,12 @@ describe('Client Capability protocol', () => {
       decodeClientFrame({
         kind: 'client.capability.accepted',
         invocationId: 'invocation',
+        admissionEvidence: { kind: 'browser_url', url: 'https://example.com/path' },
       }),
       {
         kind: 'client.capability.accepted',
         invocationId: 'invocation',
+        admissionEvidence: { kind: 'browser_url', url: 'https://example.com/path' },
       },
     );
     assert.deepEqual(
diff --git a/packages/runtime-host/src/__tests__/client-capability-provider-id.test.ts b/packages/runtime-host/src/__tests__/client-capability-provider-id.test.ts
new file mode 100644
index 0000000000..c532bf3d32
--- /dev/null
+++ b/packages/runtime-host/src/__tests__/client-capability-provider-id.test.ts
@@ -0,0 +1,61 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import assert from 'node:assert/strict';
+import { describe, test } from 'node:test';
+
+import { decodeClientCapabilityGrantTarget } from '@maka/core/client-capability-grant';
+import { clientCapabilityProviderId } from '../server/client-capability-provider-id.js';
+
+const IDENTITY = {
+  principalKind: 'capability_provider' as const,
+  principalId: 'desktop:installation-a',
+  clientInstanceId: 'client-instance-a',
+};
+
+describe('clientCapabilityProviderId', () => {
+  test('is stable, safe, private, and sensitive to every authenticated identity field', () => {
+    const first = clientCapabilityProviderId(IDENTITY);
+    const second = clientCapabilityProviderId({ ...IDENTITY });
+
+    assert.equal(first, second);
+    assert.match(first, /^[A-Za-z0-9_-]{1,128}$/u);
+    assert.equal(first.includes(IDENTITY.principalId), false);
+    assert.equal(first.includes(IDENTITY.clientInstanceId), false);
+    assert.doesNotThrow(() =>
+      decodeClientCapabilityGrantTarget({
+        providerId: first,
+        contractId: 'contract-1',
+        serverId: 'desktop_browser',
+        toolName: 'browser_snapshot',
+        capability: 'browser',
+        scope: { kind: 'browser_origin', origin: 'https://example.com' },
+      }),
+    );
+    const variants = [
+      { ...IDENTITY, principalKind: 'local_owner' as const },
+      { ...IDENTITY, principalId: 'desktop:installation-b' },
+      { ...IDENTITY, clientInstanceId: 'client-instance-b' },
+    ];
+
+    for (const variant of variants) {
+      assert.notEqual(clientCapabilityProviderId(variant), first);
+    }
+  });
+});
diff --git a/packages/runtime-host/src/__tests__/client-capability-uds.test.ts b/packages/runtime-host/src/__tests__/client-capability-uds.test.ts
index 17502b79a1..19366443e7 100644
--- a/packages/runtime-host/src/__tests__/client-capability-uds.test.ts
+++ b/packages/runtime-host/src/__tests__/client-capability-uds.test.ts
@@ -44,6 +44,7 @@ import {
   type DomainOperationHandlerMap,
 } from '../server/operation-dispatcher.js';
 import { RuntimePolicyActivationGate } from '../server/runtime-policy-activation-gate.js';
+import { clientCapabilityCoordinatorTestAdmission } from './fixtures/client-capability.js';
 
 test('unknown Client Capability loads, invokes, and rebinds after UDS reconnect', async () => {
   const base = await mkdtemp(join(tmpdir(), 'maka-client-capability-'));
@@ -63,6 +64,7 @@ test('unknown Client Capability loads, invokes, and rebinds after UDS reconnect'
       idleGraceMs: 60_000,
       composition: defineInteractiveRuntimeHostComposition(async () => {
         coordinator = new HostClientCapabilityCoordinator({
+          ...clientCapabilityCoordinatorTestAdmission(),
           activation: new RuntimePolicyActivationGate(),
           onModelToolsChanged: () => undefined,
         });
@@ -162,7 +164,7 @@ test('unknown Client Capability loads, invokes, and rebinds after UDS reconnect'
         if (frame.toolName === 'reject_unknown') {
           throw new Error('Provider rejected before acceptance');
         }
-        await accept();
+        await accept({ kind: 'none' });
         return {
           content: [
             {
@@ -267,7 +269,7 @@ test('unknown Client Capability loads, invokes, and rebinds after UDS reconnect'
     await client.replaceClientCapabilities({
       offers: provider.offers,
       call: async (frame, { accept }) => {
-        await accept();
+        await accept({ kind: 'none' });
         return {
           content: [{ type: 'text', text: `reconnected:${String(frame.arguments.prefix)}` }],
         };
diff --git a/packages/runtime-host/src/__tests__/execution-host-continuation.test.ts b/packages/runtime-host/src/__tests__/execution-host-continuation.test.ts
index 4caff505ca..6b4a1039b9 100644
--- a/packages/runtime-host/src/__tests__/execution-host-continuation.test.ts
+++ b/packages/runtime-host/src/__tests__/execution-host-continuation.test.ts
@@ -488,7 +488,7 @@ function resumeFixtureProvider(
       },
     ],
     call: async (_frame, { accept }) => {
-      await accept();
+      await accept({ kind: 'none' });
       return { content: [{ type: 'text', text: 'ok' }] };
     },
   };
diff --git a/packages/runtime-host/src/__tests__/execution-model-composition.test.ts b/packages/runtime-host/src/__tests__/execution-model-composition.test.ts
index a687759586..9052f9cda6 100644
--- a/packages/runtime-host/src/__tests__/execution-model-composition.test.ts
+++ b/packages/runtime-host/src/__tests__/execution-model-composition.test.ts
@@ -28,7 +28,10 @@ import { join } from 'node:path';
 import { promisify } from 'node:util';
 import { test } from 'node:test';
 import { z } from 'zod';
-import { clientCapabilityConnectionIdentity } from './fixtures/client-capability.js';
+import {
+  clientCapabilityConnectionIdentity,
+  clientCapabilityCoordinatorTestAdmission,
+} from './fixtures/client-capability.js';
 import {
   createBypassExecutionBoundary,
   createManagedExecutionBoundary,
@@ -1306,6 +1309,7 @@ test('backend creation does not acquire Client Capabilities beyond a bound tool
 
 test('production backend creation continues after a Session Client Capability is lost', async () => {
   const coordinator = new HostClientCapabilityCoordinator({
+    ...clientCapabilityCoordinatorTestAdmission(),
     activation: new RuntimePolicyActivationGate(),
     onModelToolsChanged: () => undefined,
   });
@@ -1371,6 +1375,7 @@ test('production backend preserves coordinator Client Capability semantics acros
   const trace: RunTraceEvent[] = [];
   const calls: Array<Extract<ClientCapabilityHostFrame, { kind: 'client.capability.call' }>> = [];
   const coordinator = new HostClientCapabilityCoordinator({
+    ...clientCapabilityCoordinatorTestAdmission(),
     activation: new RuntimePolicyActivationGate(),
     onModelToolsChanged: () => undefined,
   });
@@ -1381,21 +1386,26 @@ test('production backend preserves coordinator Client Capability semantics acros
       clientCapabilityConnectionIdentity('client-capability-provider'),
       {
         send: async (frame) => {
-          if (frame.kind !== 'client.capability.call') return;
-          calls.push(frame);
-          queueMicrotask(() => {
-            connection?.accept({
-              kind: 'client.capability.accepted',
-              invocationId: frame.invocationId,
+          if (frame.kind === 'client.capability.call') {
+            calls.push(frame);
+            queueMicrotask(() => {
+              connection?.accept({
+                kind: 'client.capability.accepted',
+                invocationId: frame.invocationId,
+                admissionEvidence: { kind: 'none' },
+              });
             });
-            connection?.accept({
-              kind: 'client.capability.result',
-              invocationId: frame.invocationId,
-              result: {
-                content: [{ type: 'text', text: CLIENT_CAPABILITY_RESULT_TEXT }],
-              },
+          } else if (frame.kind === 'client.capability.admitted') {
+            queueMicrotask(() => {
+              connection?.accept({
+                kind: 'client.capability.result',
+                invocationId: frame.invocationId,
+                result: {
+                  content: [{ type: 'text', text: CLIENT_CAPABILITY_RESULT_TEXT }],
+                },
+              });
             });
-          });
+          }
         },
       },
     );
@@ -1509,7 +1519,7 @@ test('production backend preserves coordinator Client Capability semantics acros
         (event) =>
           event.type === 'tool_started' &&
           event.data?.toolName === tool.name &&
-          event.data?.categoryHint === 'client_capability',
+          event.data?.categoryHint === 'custom_tool',
       ),
     );
     const runtimeEvents = await store.readImmutableRuntimeEvents(sessionId, runId);
diff --git a/packages/runtime-host/src/__tests__/fixtures/client-capability.ts b/packages/runtime-host/src/__tests__/fixtures/client-capability.ts
index 6bdd854295..3ab6adb0f5 100644
--- a/packages/runtime-host/src/__tests__/fixtures/client-capability.ts
+++ b/packages/runtime-host/src/__tests__/fixtures/client-capability.ts
@@ -18,6 +18,7 @@
  */
 
 import type { ClientCapabilityConnectionIdentity } from '../../server/client-capability-service.js';
+import type { HostClientCapabilityCoordinatorOptions } from '../../server/client-capability-coordinator.js';
 
 export function clientCapabilityConnectionIdentity(
   connectionId: string,
@@ -36,3 +37,23 @@ export function clientCapabilityConnectionIdentity(
     ...(capabilityOwner ? { capabilityOwner } : {}),
   };
 }
+
+export function clientCapabilityCoordinatorTestAdmission(): Pick<
+  HostClientCapabilityCoordinatorOptions,
+  'interactions' | 'grants'
+> {
+  return {
+    interactions: {
+      requestClientCapabilityApproval: async () => {
+        throw new Error('Unexpected Client Capability approval request');
+      },
+    },
+    grants: {
+      readClientCapabilitySessionGrant: async (key) => ({
+        version: 1,
+        ...key,
+        grantedAt: 0,
+      }),
+    },
+  };
+}
diff --git a/packages/runtime-host/src/__tests__/interaction-coordinator.test.ts b/packages/runtime-host/src/__tests__/interaction-coordinator.test.ts
index 7fa6da931c..bae9e4b7fc 100644
--- a/packages/runtime-host/src/__tests__/interaction-coordinator.test.ts
+++ b/packages/runtime-host/src/__tests__/interaction-coordinator.test.ts
@@ -49,6 +49,7 @@ import {
 import type { SessionInteractionProjection } from '../protocol/index.js';
 import type { ConnectionContext } from '../server/operation-dispatcher.js';
 import {
+  ClientCapabilityApprovalClosedError,
   HostInteractionCoordinator,
   type HostInteractionCoordinatorOptions,
 } from '../server/interaction-coordinator.js';
@@ -119,6 +120,49 @@ describe('HostInteractionCoordinator', () => {
     });
   });
 
+  test('closes a pending Client Capability approval when its provider disconnects', async () => {
+    await withStore(async ({ store }) => {
+      const coordinator = createCoordinator(store);
+      const owner = coordinator.bindRun(RUN);
+      const provider = new AbortController();
+      const approval = coordinator.requestClientCapabilityApproval({
+        ...RUN,
+        toolCallId: 'browser-call-disconnected',
+        providerSignal: provider.signal,
+        target: {
+          providerId: 'provider-1',
+          contractId: 'contract-1',
+          serverId: 'desktop_browser',
+          toolName: 'browser_snapshot',
+          capability: 'browser',
+          scope: { kind: 'browser_origin', origin: 'https://example.com' },
+        },
+      });
+      let pending = await store.listSessionPending(RUN.sessionId);
+      while (pending.length === 0) {
+        await new Promise((resolve) => setImmediate(resolve));
+        pending = await store.listSessionPending(RUN.sessionId);
+      }
+
+      provider.abort();
+      await assert.rejects(
+        approval,
+        (error: unknown) =>
+          error instanceof ClientCapabilityApprovalClosedError &&
+          error.reason === 'provider_disconnected',
+      );
+      const record = await store.readInteraction(pending[0]?.requestId ?? 'missing');
+      assert.equal(record?.outcome?.outcome.kind, 'closure');
+      if (record?.outcome?.outcome.kind === 'closure') {
+        assert.equal(record.outcome.outcome.reason, 'provider_disconnected');
+      }
+
+      await owner.close('turn_terminal');
+      owner.release();
+      await coordinator.close();
+    });
+  });
+
   test('settles a sandbox boundary once through the canonical boundary Store and wakes its graph', async () => {
     await withStore(async ({ owner, store, stores }) => {
       const workspace = join(owner.capability.canonicalPath, 'workspace');
diff --git a/packages/runtime-host/src/__tests__/oauth-coordinator.test.ts b/packages/runtime-host/src/__tests__/oauth-coordinator.test.ts
index 69e222d419..8d34132ca6 100644
--- a/packages/runtime-host/src/__tests__/oauth-coordinator.test.ts
+++ b/packages/runtime-host/src/__tests__/oauth-coordinator.test.ts
@@ -43,7 +43,10 @@ import {
 import { HostClientCapabilityCoordinator } from '../server/client-capability-coordinator.js';
 import { HostOAuthCoordinator } from '../server/oauth-coordinator.js';
 import { RuntimePolicyActivationGate } from '../server/runtime-policy-activation-gate.js';
-import { clientCapabilityConnectionIdentity } from './fixtures/client-capability.js';
+import {
+  clientCapabilityConnectionIdentity,
+  clientCapabilityCoordinatorTestAdmission,
+} from './fixtures/client-capability.js';
 
 const NOW = 1_800_000_000_000;
 
@@ -890,6 +893,7 @@ async function attachPresentation(
         connection.accept({
           kind: 'client.capability.accepted',
           invocationId: frame.invocationId,
+          admissionEvidence: { kind: 'none' },
         });
         return;
       }
@@ -1014,6 +1018,7 @@ async function withFixture(
   assert.ok(connection);
   const activation = new RuntimePolicyActivationGate();
   const capabilities = new HostClientCapabilityCoordinator({
+    ...clientCapabilityCoordinatorTestAdmission(),
     activation,
     onModelToolsChanged: () => undefined,
   });
diff --git a/packages/runtime-host/src/__tests__/oauth-two-client-uds.test.ts b/packages/runtime-host/src/__tests__/oauth-two-client-uds.test.ts
index 9e0ad0a09e..4197479b8b 100644
--- a/packages/runtime-host/src/__tests__/oauth-two-client-uds.test.ts
+++ b/packages/runtime-host/src/__tests__/oauth-two-client-uds.test.ts
@@ -42,6 +42,7 @@ import {
   type DomainOperationHandlerMap,
 } from '../server/operation-dispatcher.js';
 import { RuntimePolicyActivationGate } from '../server/runtime-policy-activation-gate.js';
+import { clientCapabilityCoordinatorTestAdmission } from './fixtures/client-capability.js';
 
 test('two OAuth creates bind distinct entities and present only on their initiating Clients', {
   timeout: 30_000,
@@ -77,6 +78,7 @@ test('two OAuth creates bind distinct entities and present only on their initiat
       composition: defineInteractiveRuntimeHostComposition(async (context) => {
         const activation = new RuntimePolicyActivationGate();
         const clientCapabilities = new HostClientCapabilityCoordinator({
+          ...clientCapabilityCoordinatorTestAdmission(),
           activation,
           onModelToolsChanged: () => undefined,
         });
@@ -236,6 +238,7 @@ async function assertProviderDisabledOverUds(
       composition: defineInteractiveRuntimeHostComposition(async (context) => {
         const activation = new RuntimePolicyActivationGate();
         const clientCapabilities = new HostClientCapabilityCoordinator({
+          ...clientCapabilityCoordinatorTestAdmission(),
           activation,
           onModelToolsChanged: () => undefined,
         });
diff --git a/packages/runtime-host/src/__tests__/root-turn-coordinator.test.ts b/packages/runtime-host/src/__tests__/root-turn-coordinator.test.ts
index f74e18621d..5abef48433 100644
--- a/packages/runtime-host/src/__tests__/root-turn-coordinator.test.ts
+++ b/packages/runtime-host/src/__tests__/root-turn-coordinator.test.ts
@@ -62,7 +62,10 @@ import {
   WORKHUB_COORDINATION_SESSION_ROLE,
 } from '@maka/core/session';
 import type { MakaTool } from '@maka/runtime/tool-runtime';
-import { clientCapabilityConnectionIdentity } from './fixtures/client-capability.js';
+import {
+  clientCapabilityConnectionIdentity,
+  clientCapabilityCoordinatorTestAdmission,
+} from './fixtures/client-capability.js';
 import {
   openInteractiveExecutionStoresForWrite,
   type RootTurnAdmission,
@@ -557,6 +560,7 @@ test('startup recovery closes a ScheduledTask Run after its pending fire was set
 
 test('a failed exact Capability retry does not poison the parked continuation binding', async () => {
   const capabilities = new HostClientCapabilityCoordinator({
+    ...clientCapabilityCoordinatorTestAdmission(),
     activation: new RuntimePolicyActivationGate(),
     onModelToolsChanged: () => undefined,
   });
@@ -700,6 +704,7 @@ test('a failed exact Capability retry does not poison the parked continuation bi
 test('resume query preserves Session-before-activation lock ordering', async () => {
   const activation = new RuntimePolicyActivationGate();
   const capabilities = new HostClientCapabilityCoordinator({
+    ...clientCapabilityCoordinatorTestAdmission(),
     activation,
     onModelToolsChanged: () => undefined,
   });
@@ -875,6 +880,7 @@ test('turn.start resolves explicit Skills once before durable admission and repl
   let blocked = false;
   let observedCapabilityPreview = false;
   const capabilities = new HostClientCapabilityCoordinator({
+    ...clientCapabilityCoordinatorTestAdmission(),
     activation: new RuntimePolicyActivationGate(),
     onModelToolsChanged: () => undefined,
   });
@@ -3314,6 +3320,7 @@ test('shutdown contains a successor backend start rejected by Interaction drain'
 
 test('Client Capability ambiguity fails before durable root admission', async () => {
   const clientCapabilities = new HostClientCapabilityCoordinator({
+    ...clientCapabilityCoordinatorTestAdmission(),
     activation: new RuntimePolicyActivationGate(),
     onModelToolsChanged: () => undefined,
   });
@@ -3393,6 +3400,7 @@ test('an exact active retry preserves the Client Capability admission binding',
   timeout: 20_000,
 }, async () => {
   const clientCapabilities = new HostClientCapabilityCoordinator({
+    ...clientCapabilityCoordinatorTestAdmission(),
     activation: new RuntimePolicyActivationGate(),
     onModelToolsChanged: () => undefined,
   });
@@ -3488,6 +3496,7 @@ test('mixed-Client queued follow-ups use separate Session successors without con
   timeout: 20_000,
 }, async () => {
   const clientCapabilities = new HostClientCapabilityCoordinator({
+    ...clientCapabilityCoordinatorTestAdmission(),
     activation: new RuntimePolicyActivationGate(),
     onModelToolsChanged: () => undefined,
   });
@@ -3632,6 +3641,7 @@ async function assertSessionSuccessorCapabilityDegradation(
   affinity: 'call' | 'turn',
 ): Promise<void> {
   const clientCapabilities = new HostClientCapabilityCoordinator({
+    ...clientCapabilityCoordinatorTestAdmission(),
     activation: new RuntimePolicyActivationGate(),
     onModelToolsChanged: () => undefined,
   });
@@ -3662,6 +3672,7 @@ async function assertSessionSuccessorCapabilityDegradation(
         previousProvider.accept({
           kind: 'client.capability.accepted',
           invocationId: frame.invocationId,
+          admissionEvidence: { kind: 'none' },
         });
         previousProvider.accept({
           kind: 'client.capability.result',
@@ -3681,6 +3692,7 @@ async function assertSessionSuccessorCapabilityDegradation(
         followupProvider.accept({
           kind: 'client.capability.accepted',
           invocationId: frame.invocationId,
+          admissionEvidence: { kind: 'none' },
         });
         followupProvider.accept({
           kind: 'client.capability.result',
@@ -3831,6 +3843,7 @@ test('an exact terminal retry does not require a live Client Capability binding'
   timeout: 20_000,
 }, async () => {
   const clientCapabilities = new HostClientCapabilityCoordinator({
+    ...clientCapabilityCoordinatorTestAdmission(),
     activation: new RuntimePolicyActivationGate(),
     onModelToolsChanged: () => undefined,
   });
diff --git a/packages/runtime-host/src/__tests__/session-projector.test.ts b/packages/runtime-host/src/__tests__/session-projector.test.ts
index 5fbcce8395..e0087c4efd 100644
--- a/packages/runtime-host/src/__tests__/session-projector.test.ts
+++ b/packages/runtime-host/src/__tests__/session-projector.test.ts
@@ -24,6 +24,7 @@ import type { StoredMessage } from '@maka/core/session';
 import type { SteeringMessageSnapshot } from '../protocol/message.js';
 import {
   createRuntimeHostSessionProjectionSeed,
+  projectRuntimeHostInteractionRequest,
   RuntimeHostSessionProjector,
 } from '../adapter/session-projector.js';
 import {
@@ -32,6 +33,48 @@ import {
   type SubscriptionFrame,
 } from '../protocol/index.js';
 
+test('projects Client Capability approvals without exposing provider identities', () => {
+  assert.deepEqual(
+    projectRuntimeHostInteractionRequest(
+      {
+        schemaVersion: 1,
+        interactionId: 'approval-1',
+        sessionId: 'session-1',
+        turnId: 'turn-1',
+        runId: 'run-1',
+        revision: 1,
+        request: {
+          kind: 'client_capability',
+          toolUseId: 'tool-1',
+          target: {
+            providerId: 'provider-secret',
+            contractId: 'contract-secret',
+            serverId: 'desktop_browser',
+            toolName: 'browser_snapshot',
+            capability: 'browser',
+            scope: { kind: 'browser_origin', origin: 'https://example.com' },
+          },
+        },
+        status: 'pending',
+        outcome: null,
+      },
+      10,
+    ),
+    [
+      {
+        type: 'client_capability_request',
+        id: 'host-interaction:approval-1:1',
+        turnId: 'turn-1',
+        ts: 10,
+        requestId: 'approval-1',
+        toolUseId: 'tool-1',
+        capability: 'browser',
+        scope: { kind: 'browser_origin', origin: 'https://example.com' },
+      },
+    ],
+  );
+});
+
 test('applies authoritative replacement once and does not complete it again at Turn terminal', () => {
   const projector = new RuntimeHostSessionProjector(
     snapshot(),
diff --git a/packages/runtime-host/src/adapter/session-projector.ts b/packages/runtime-host/src/adapter/session-projector.ts
index 0288be35ef..12478ef636 100644
--- a/packages/runtime-host/src/adapter/session-projector.ts
+++ b/packages/runtime-host/src/adapter/session-projector.ts
@@ -577,6 +577,16 @@ export function projectRuntimeHostInteractionRequest(
       },
     ];
   }
+  if (interaction.request.kind === 'client_capability') {
+    return [
+      {
+        type: 'client_capability_request',
+        ...base,
+        capability: interaction.request.target.capability,
+        scope: structuredClone(interaction.request.target.scope),
+      },
+    ];
+  }
   return [];
 }
 
diff --git a/packages/runtime-host/src/client/client-capability-channel.ts b/packages/runtime-host/src/client/client-capability-channel.ts
index 5bf6d20eda..5ed354567e 100644
--- a/packages/runtime-host/src/client/client-capability-channel.ts
+++ b/packages/runtime-host/src/client/client-capability-channel.ts
@@ -23,6 +23,7 @@ import {
   CLIENT_CAPABILITY_RESULT_CHUNK_MAX_BYTES,
   decodeClientCapabilityReplaceInput,
   decodeClientCapabilityResult,
+  type ClientCapabilityAdmissionEvidence,
   type ClientCapabilityCallFrame,
   type ClientCapabilityClientFrame,
   type ClientCapabilityHostFrame,
@@ -282,13 +283,13 @@ export class ClientCapabilityChannel {
     invocation: ClientCapabilityInvocation,
     execute: (options: {
       readonly signal: AbortSignal;
-      accept(): Promise<void>;
+      accept(evidence: ClientCapabilityAdmissionEvidence): Promise<void>;
       progress(current: number, total: number): void;
     }) => Promise<ReturnType<typeof decodeClientCapabilityResult>>,
   ): Promise<void> {
     let accepted = false;
     let accepting: Promise<void> | undefined;
-    const accept = (): Promise<void> => {
+    const accept = (evidence: ClientCapabilityAdmissionEvidence): Promise<void> => {
       if (invocation.released) {
         return Promise.reject(capabilityInvocationAbortReason(invocation));
       }
@@ -297,6 +298,7 @@ export class ClientCapabilityChannel {
         this.#options.write({
           kind: 'client.capability.accepted',
           invocationId,
+          admissionEvidence: evidence,
         }),
         admission.promise,
       ]).then(() => {
@@ -335,7 +337,7 @@ export class ClientCapabilityChannel {
         }),
       );
       if (invocation.released) return;
-      await accept();
+      await accept({ kind: 'none' });
       await this.#sendResult(invocationId, result, invocation);
     } catch (error) {
       if (invocation.released) return;
diff --git a/packages/runtime-host/src/client/client-capability.ts b/packages/runtime-host/src/client/client-capability.ts
index d0499d607c..e91ff2cf58 100644
--- a/packages/runtime-host/src/client/client-capability.ts
+++ b/packages/runtime-host/src/client/client-capability.ts
@@ -20,6 +20,7 @@
 import type {
   ClientCapabilityCallFrame,
   ClientCapabilityCallResult,
+  ClientCapabilityAdmissionEvidence,
   ClientCapabilityOffer,
   ClientCapabilityServiceCallFrame,
   ClientCapabilityServiceOffer,
@@ -34,7 +35,7 @@ export interface ClientCapabilityProvider {
     options: {
       readonly signal: AbortSignal;
       /** Await immediately before crossing the provider's irreversible admission cut. */
-      accept(): Promise<void>;
+      accept(evidence: ClientCapabilityAdmissionEvidence): Promise<void>;
       /** Publish bounded live progress after admission. */
       progress?(current: number, total: number): void;
     },
@@ -44,7 +45,7 @@ export interface ClientCapabilityProvider {
     options: {
       readonly signal: AbortSignal;
       /** Await immediately before crossing the provider's irreversible admission cut. */
-      accept(): Promise<void>;
+      accept(evidence: ClientCapabilityAdmissionEvidence): Promise<void>;
     },
   ): Promise<Record<string, unknown>>;
   /** Release provider-owned resources after its final registration is retired. */
diff --git a/packages/runtime-host/src/client/oauth-presentation.ts b/packages/runtime-host/src/client/oauth-presentation.ts
index 7100f6eaa4..a24206f497 100644
--- a/packages/runtime-host/src/client/oauth-presentation.ts
+++ b/packages/runtime-host/src/client/oauth-presentation.ts
@@ -48,7 +48,7 @@ export function createOAuthPresentationClientProvider(
       assertOAuthPresentationContract(frame);
       const request = decodeOAuthPresentationRequest(frame.method, frame.input);
       const url = trustedPresentationUrl(request.url);
-      await options.accept();
+      await options.accept({ kind: 'none' });
       await backend.openExternal(url, request.stateHint, options.signal);
       return decodeOAuthPresentationResult(request.method, { kind: 'presented' });
     },
diff --git a/packages/runtime-host/src/protocol/client-capability.ts b/packages/runtime-host/src/protocol/client-capability.ts
index 962128275d..ce51734621 100644
--- a/packages/runtime-host/src/protocol/client-capability.ts
+++ b/packages/runtime-host/src/protocol/client-capability.ts
@@ -188,8 +188,13 @@ export type ClientCapabilityHostFrame =
 export interface ClientCapabilityAcceptedFrame {
   readonly kind: 'client.capability.accepted';
   readonly invocationId: string;
+  readonly admissionEvidence: ClientCapabilityAdmissionEvidence;
 }
 
+export type ClientCapabilityAdmissionEvidence =
+  | { readonly kind: 'none' }
+  | { readonly kind: 'browser_url'; readonly url: string };
+
 export interface ClientCapabilityRejectedFrame {
   readonly kind: 'client.capability.rejected';
   readonly invocationId: string;
@@ -382,10 +387,15 @@ export function decodeClientCapabilityClientFrame(value: unknown): ClientCapabil
   const frame = requireRecord(value, 'Client Capability client frame');
   switch (frame.kind) {
     case 'client.capability.accepted':
-      assertExactKeys(frame, 'Client Capability accepted frame', ['kind', 'invocationId']);
+      assertExactKeys(frame, 'Client Capability accepted frame', [
+        'kind',
+        'invocationId',
+        'admissionEvidence',
+      ]);
       return {
         kind: frame.kind,
         invocationId: requireEntityId(frame.invocationId, 'invocationId'),
+        admissionEvidence: decodeClientCapabilityAdmissionEvidence(frame.admissionEvidence),
       };
     case 'client.capability.rejected':
     case 'client.capability.failed':
@@ -480,6 +490,25 @@ export function decodeClientCapabilityClientFrame(value: unknown): ClientCapabil
   }
 }
 
+function decodeClientCapabilityAdmissionEvidence(
+  value: unknown,
+): ClientCapabilityAdmissionEvidence {
+  const evidence = requireRecord(value, 'Client Capability admission evidence');
+  switch (evidence.kind) {
+    case 'none':
+      assertExactKeys(evidence, 'Client Capability admission evidence', ['kind']);
+      return { kind: evidence.kind };
+    case 'browser_url':
+      assertExactKeys(evidence, 'Client Capability admission evidence', ['kind', 'url']);
+      return {
+        kind: evidence.kind,
+        url: requireString(evidence.url, 'url', 16_384),
+      };
+    default:
+      throw invalidProtocolFrame('Unknown Client Capability admission evidence kind');
+  }
+}
+
 export function decodeClientCapabilityHostFrame(value: unknown): ClientCapabilityHostFrame {
   const frame = requireRecord(value, 'Client Capability Host frame');
   switch (frame.kind) {
diff --git a/packages/runtime-host/src/protocol/index.ts b/packages/runtime-host/src/protocol/index.ts
index 2d3d3237b5..c85b20e2c4 100644
--- a/packages/runtime-host/src/protocol/index.ts
+++ b/packages/runtime-host/src/protocol/index.ts
@@ -95,7 +95,9 @@ export const RUNTIME_HOST_REGISTRATION_SCHEMA_VERSION = 1 as const;
 export const RUNTIME_HOST_PROTOCOL_VERSION = 0 as const;
 // Increment when the same protocol version no longer guarantees safe Client-Host
 // interoperability. Mismatches are rejected before domain commands are admitted.
-export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 85 as const;
+export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 86 as const;
+// 86: Client Capability accepted frames carry typed admission evidence used to
+// enforce Session Grant scopes. Older peers cannot preserve that boundary.
 // 85: Plugin package and Entry composition operations become Host-owned protocol
 // surfaces. Older peers cannot safely exchange these strict operation shapes.
 // 84: Message content carries Host-bound directory references. Older peers
diff --git a/packages/runtime-host/src/server/client-capability-coordinator.ts b/packages/runtime-host/src/server/client-capability-coordinator.ts
index f95b8d8c64..315c440086 100644
--- a/packages/runtime-host/src/server/client-capability-coordinator.ts
+++ b/packages/runtime-host/src/server/client-capability-coordinator.ts
@@ -19,12 +19,23 @@
 
 import { createHash } from 'node:crypto';
 import { AsyncLocalStorage } from 'node:async_hooks';
-import { buildMcpTools, mcpProxyToolName, type McpToolProvider } from '@maka/runtime/mcp-tools';
+import {
+  buildMcpTools,
+  mcpProxyToolName,
+  type McpPreparedToolCall,
+  type McpToolProvider,
+} from '@maka/runtime/mcp-tools';
 import { type MakaTool } from '@maka/runtime/tool-runtime';
 import type { RootExecutionDescriptor } from '@maka/core/agent-run';
+import {
+  clientCapabilityScopeIdentity,
+  type ClientCapabilityGrantTarget,
+} from '@maka/core/client-capability-grant';
 import { type ToolGroup } from '@maka/runtime/tool-availability';
+import type { InteractiveInteractionStoreWriterFacade } from '@maka/storage/interaction-store';
 import {
   type ClientCapabilityOffer,
+  type ClientCapabilityAdmissionEvidence,
   type ClientCapabilityOwnerIdentity,
   type ClientCapabilityReplaceInput,
   type ClientCapabilityServiceOffer,
@@ -47,10 +58,23 @@ import type {
   ClientCapabilityConnectionSender,
   ClientCapabilityService,
 } from './client-capability-service.js';
+import type { HostInteractionCoordinator } from './interaction-coordinator.js';
+import { clientCapabilityProviderId } from './client-capability-provider-id.js';
 
 // Leave the Host deadline outside the provider's bounded action deadline so an
 // accepted call can return its real terminal result instead of outcome_unknown.
 const DEFAULT_CALL_TIMEOUT_MS = 150_000;
+const DESKTOP_BROWSER_SERVER_ID = 'desktop_browser';
+const DESKTOP_SETTINGS_SERVER_ID = 'desktop_settings';
+const DESKTOP_BROWSER_TOOLS = new Set([
+  'browser_navigate',
+  'browser_snapshot',
+  'browser_click',
+  'browser_type',
+  'browser_wait',
+  'browser_extract',
+]);
+const DESKTOP_SETTINGS_TOOLS = new Set(['MakaClientSettingsGet', 'MakaClientSettingsUpdate']);
 
 export { ClientCapabilityInvocationError };
 export type { ClientCapabilityInvocationFailure };
@@ -152,6 +176,11 @@ type ClientCapabilityToolBinding = ClientCapabilityBoundTool['binding'];
 export interface HostClientCapabilityCoordinatorOptions {
   readonly activation: RuntimePolicyActivationGate;
   readonly onModelToolsChanged: () => void;
+  readonly interactions: Pick<HostInteractionCoordinator, 'requestClientCapabilityApproval'>;
+  readonly grants: Pick<
+    InteractiveInteractionStoreWriterFacade,
+    'readClientCapabilitySessionGrant'
+  >;
 }
 
 export interface ClientCapabilityServiceInvocationInput {
@@ -181,9 +210,12 @@ export class HostClientCapabilityCoordinator implements ClientCapabilityService
 
   readonly #activation: RuntimePolicyActivationGate;
   readonly #onModelToolsChanged: () => void;
+  readonly #interactions: HostClientCapabilityCoordinatorOptions['interactions'];
+  readonly #grants: HostClientCapabilityCoordinatorOptions['grants'];
   readonly #providers = new Map<string, ClientProviderState>();
   readonly #connections = new Map<string, ClientProviderConnection>();
   readonly #sessions = new Map<string, SessionCapabilityState>();
+  readonly #pendingApprovals = new Map<string, Promise<'allow' | 'deny'>>();
   readonly #previewSessions = new AsyncLocalStorage<ReadonlyMap<string, SessionCapabilityState>>();
   readonly #pendingConnectionReleases = new Set<Promise<void>>();
   readonly #invocations: ClientCapabilityInvocationBroker<CapabilityRegistration>;
@@ -193,6 +225,8 @@ export class HostClientCapabilityCoordinator implements ClientCapabilityService
   constructor(options: HostClientCapabilityCoordinatorOptions) {
     this.#activation = options.activation;
     this.#onModelToolsChanged = options.onModelToolsChanged;
+    this.#interactions = options.interactions;
+    this.#grants = options.grants;
     this.#invocations = new ClientCapabilityInvocationBroker({
       senderFor: (connectionId) => {
         const connection = this.#connections.get(connectionId);
@@ -566,11 +600,14 @@ export class HostClientCapabilityCoordinator implements ClientCapabilityService
       ...buildMcpTools(this.#snapshotProvider(state?.initiatingProviderId, interactive), {
         callTimeoutMs: DEFAULT_CALL_TIMEOUT_MS,
         categoryHint: 'client_capability',
+        hostAdmission: 'client_capability',
         recoveryMode: 'outcome_unknown',
+        executionLocation: 'remote',
       }),
       ...buildMcpTools(this.#snapshotProvider(state?.initiatingProviderId, trusted), {
         callTimeoutMs: DEFAULT_CALL_TIMEOUT_MS,
         categoryHint: 'custom_tool',
+        hostAdmission: 'client_capability',
         recoveryMode: 'outcome_unknown',
         executionLocation: 'remote',
         activityKindForDescriptor: (descriptor) => trustedClientToolActivityKind(descriptor),
@@ -844,7 +881,7 @@ export class HostClientCapabilityCoordinator implements ClientCapabilityService
       let registration: CapabilityRegistration;
       try {
         if (
-          provider.trustedProvider &&
+          provider.principalKind === 'capability_provider' &&
           (input.services?.length ||
             input.offers.some(
               (offer) => offer.affinity !== 'session' || offer.hostPathAccess !== 'none',
@@ -983,29 +1020,86 @@ export class HostClientCapabilityCoordinator implements ClientCapabilityService
       revision: this.#revision,
       tools: Object.freeze(boundTools),
     });
+    const resolveBinding = (binding: ClientCapabilityToolBinding) => {
+      const selectedBinding = bindings.get(binding);
+      if (!selectedBinding) {
+        throw new ClientCapabilityInvocationError(
+          'capability_lost',
+          'Client Capability tool is not part of the frozen offer',
+        );
+      }
+      const { serverId, name: toolName } = selectedBinding.tool.descriptor;
+      const dynamicBinding = selectedBinding.registration
+        ? {
+            registration: selectedBinding.registration,
+            tool: selectedBinding.tool,
+          }
+        : this.#selectCallBinding(
+            selectedBinding.contractId,
+            initiatingProviderId,
+            toolIdentity(serverId, toolName),
+          );
+      return { selectedBinding, dynamicBinding };
+    };
     return {
       toolSnapshot: () => snapshot,
-      callTool: (binding, args, options) => {
-        const selectedBinding = bindings.get(binding);
-        if (!selectedBinding) {
-          return Promise.reject(
-            new ClientCapabilityInvocationError(
-              'capability_lost',
-              'Client Capability tool is not part of the frozen offer',
-            ),
-          );
-        }
-        const { serverId, name: toolName } = selectedBinding.tool.descriptor;
-        const dynamicBinding = selectedBinding.registration
-          ? {
-              registration: selectedBinding.registration,
-              tool: selectedBinding.tool,
+      prepareTool: async (binding, args, options): Promise<McpPreparedToolCall> => {
+        const { selectedBinding, dynamicBinding } = resolveBinding(binding);
+        const prepared = this.#invocations.prepare(
+          dynamicBinding.registration,
+          dynamicBinding.tool,
+          args,
+          options.context,
+          options.signal,
+          options.timeoutMs ?? DEFAULT_CALL_TIMEOUT_MS,
+        );
+        try {
+          const evidence = await prepared.waitUntilAccepted();
+          const boundary = options.context.executionBoundary;
+          if (!boundary) throw new Error('Client Capability execution boundary is unavailable');
+          if (boundary.kind !== 'bypass') {
+            if (options.context.permissionMode !== 'ask' || !options.context.runId) {
+              throw new Error('Client Capability is unavailable in the current permission mode');
             }
-          : this.#selectCallBinding(
+            const target = managedClientCapabilityGrantTarget(
               selectedBinding.contractId,
-              initiatingProviderId,
-              toolIdentity(serverId, toolName),
+              dynamicBinding.registration,
+              dynamicBinding.tool,
+              evidence,
             );
+            if (!target) {
+              return {
+                execute: ({ emitProgress } = {}) => prepared.admit(emitProgress),
+                cancel: () => prepared.cancel(),
+              };
+            }
+            const key = { sessionId: options.context.sessionId, ...target };
+            if (!(await this.#grants.readClientCapabilitySessionGrant(key))) {
+              const decision = await this.#requestApprovalOnce({
+                ...key,
+                turnId: options.context.turnId,
+                runId: options.context.runId,
+                toolCallId: options.context.toolCallId,
+                providerSignal: prepared.providerSignal,
+                callerSignal: options.signal,
+              });
+              if (decision !== 'allow') throw new Error('Client Capability request was denied');
+              if (!(await this.#grants.readClientCapabilitySessionGrant(key))) {
+                throw new Error('Client Capability approval did not publish its Session Grant');
+              }
+            }
+          }
+          return {
+            execute: ({ emitProgress } = {}) => prepared.admit(emitProgress),
+            cancel: () => prepared.cancel(),
+          };
+        } catch (error) {
+          prepared.cancel();
+          throw error;
+        }
+      },
+      callTool: (binding, args, options) => {
+        const { dynamicBinding } = resolveBinding(binding);
         return this.#invocations.invoke(
           dynamicBinding.registration,
           dynamicBinding.tool,
@@ -1019,10 +1113,59 @@ export class HostClientCapabilityCoordinator implements ClientCapabilityService
     };
   }
 
+  #requestApprovalOnce(input: {
+    readonly sessionId: string;
+    readonly turnId: string;
+    readonly runId: string;
+    readonly toolCallId: string;
+    readonly providerId: string;
+    readonly contractId: string;
+    readonly serverId: string;
+    readonly toolName: string;
+    readonly capability: ClientCapabilityGrantTarget['capability'];
+    readonly scope: ClientCapabilityGrantTarget['scope'];
+    readonly providerSignal: AbortSignal;
+    readonly callerSignal?: AbortSignal;
+  }): Promise<'allow' | 'deny'> {
+    const target: ClientCapabilityGrantTarget = {
+      providerId: input.providerId,
+      contractId: input.contractId,
+      serverId: input.serverId,
+      toolName: input.toolName,
+      capability: input.capability,
+      scope: input.scope,
+    };
+    const key = [
+      input.sessionId,
+      target.providerId,
+      target.contractId,
+      target.capability,
+      clientCapabilityScopeIdentity(target.scope),
+    ].join('\0');
+    const existing = this.#pendingApprovals.get(key);
+    if (existing) return waitForClientCapabilityApproval(existing, input.callerSignal);
+    const pending = this.#interactions
+      .requestClientCapabilityApproval({
+        sessionId: input.sessionId,
+        turnId: input.turnId,
+        runId: input.runId,
+        toolCallId: input.toolCallId,
+        target,
+        providerSignal: input.providerSignal,
+        ...(input.callerSignal ? { callerSignal: input.callerSignal } : {}),
+      })
+      .finally(() => {
+        if (this.#pendingApprovals.get(key) === pending) this.#pendingApprovals.delete(key);
+      });
+    this.#pendingApprovals.set(key, pending);
+    return pending;
+  }
+
   #provider(identity: ClientCapabilityConnectionIdentity): ClientProviderState {
-    const providerId = clientProviderId(identity.principalId, identity.clientInstanceId);
-    const trustedProvider = identity.principalKind === 'capability_provider';
-    if (identity.capabilityOwner && !trustedProvider) {
+    const providerId = clientCapabilityProviderId(identity);
+    const trustedProvider =
+      identity.principalKind === 'local_owner' || identity.principalKind === 'capability_provider';
+    if (identity.capabilityOwner && identity.principalKind !== 'capability_provider') {
       throw new Error('Only a capability provider may declare a Client Capability owner');
     }
     let provider = this.#providers.get(providerId);
@@ -1333,8 +1476,53 @@ function freezeRegistration(
   };
 }
 
-function clientProviderId(principalId: string, clientInstanceId: string): string {
-  return `${principalId}\0${clientInstanceId}`;
+function managedClientCapabilityGrantTarget(
+  contractId: string,
+  registration: CapabilityRegistration,
+  tool: FrozenToolBinding,
+  evidence: ClientCapabilityAdmissionEvidence,
+): ClientCapabilityGrantTarget | undefined {
+  const { serverId, name: toolName } = tool.descriptor;
+  if (!registration.trustedProvider) {
+    throw new Error('Managed Client Capability requires a trusted Desktop provider');
+  }
+  if (
+    tool.offerId === DESKTOP_SETTINGS_SERVER_ID &&
+    serverId === DESKTOP_SETTINGS_SERVER_ID &&
+    DESKTOP_SETTINGS_TOOLS.has(toolName)
+  ) {
+    if (evidence.kind !== 'none') {
+      throw new Error('Desktop Settings admission does not accept scope evidence');
+    }
+    return undefined;
+  }
+  if (
+    tool.offerId !== DESKTOP_BROWSER_SERVER_ID ||
+    serverId !== DESKTOP_BROWSER_SERVER_ID ||
+    !DESKTOP_BROWSER_TOOLS.has(toolName)
+  ) {
+    throw new Error(`Client Capability has no managed admission policy: ${serverId}/${toolName}`);
+  }
+  if (evidence.kind !== 'browser_url') {
+    throw new Error('Desktop Browser admission requires URL evidence');
+  }
+  let url: URL;
+  try {
+    url = new URL(evidence.url);
+  } catch {
+    throw new Error('Desktop Browser admission URL is invalid');
+  }
+  if (url.protocol !== 'http:' && url.protocol !== 'https:') {
+    throw new Error('Desktop Browser admission requires an HTTP origin');
+  }
+  return Object.freeze({
+    providerId: registration.providerId,
+    contractId,
+    serverId,
+    toolName,
+    capability: 'browser',
+    scope: Object.freeze({ kind: 'browser_origin', origin: url.origin }),
+  });
 }
 
 function serviceContract(serviceId: string, version: string): string {
@@ -1447,6 +1635,42 @@ function clientCapabilityOwnerIdentitiesEqual(
   );
 }
 
+function waitForClientCapabilityApproval(
+  approval: Promise<'allow' | 'deny'>,
+  callerSignal: AbortSignal | undefined,
+): Promise<'allow' | 'deny'> {
+  if (!callerSignal) return approval;
+  if (callerSignal.aborted) {
+    return Promise.reject(clientCapabilityCallerAbortError(callerSignal));
+  }
+  return new Promise((resolve, reject) => {
+    const cleanup = (): void => callerSignal.removeEventListener('abort', onAbort);
+    const onAbort = (): void => {
+      cleanup();
+      reject(clientCapabilityCallerAbortError(callerSignal));
+    };
+    callerSignal.addEventListener('abort', onAbort, { once: true });
+    void approval.then(
+      (decision) => {
+        cleanup();
+        resolve(decision);
+      },
+      (error) => {
+        cleanup();
+        reject(error);
+      },
+    );
+  });
+}
+
+function clientCapabilityCallerAbortError(signal: AbortSignal): Error {
+  const reason = signal.reason;
+  if (reason instanceof Error) return reason;
+  return new Error(typeof reason === 'string' ? reason : 'Client Capability caller cancelled', {
+    ...(reason === undefined ? {} : { cause: reason }),
+  });
+}
+
 function canonicalJson(value: unknown): string {
   if (value === null || typeof value === 'boolean' || typeof value === 'number') {
     return JSON.stringify(value);
diff --git a/packages/runtime-host/src/server/client-capability-invocation-broker.ts b/packages/runtime-host/src/server/client-capability-invocation-broker.ts
index c77bc98bf3..a4c7db15b3 100644
--- a/packages/runtime-host/src/server/client-capability-invocation-broker.ts
+++ b/packages/runtime-host/src/server/client-capability-invocation-broker.ts
@@ -24,6 +24,7 @@ import {
   CLIENT_CAPABILITY_RESULT_CHUNK_MAX_BYTES,
   decodeClientCapabilityResult,
   type ClientCapabilityCallResult,
+  type ClientCapabilityAdmissionEvidence,
   type ClientCapabilityClientFrame,
   type ClientCapabilityHostFrame,
   type ClientCapabilityHostPathAccess,
@@ -75,11 +76,16 @@ interface InvocationState<Registration extends ClientCapabilityInvocationRegistr
   readonly registration: Registration;
   readonly resolve: (result: ClientCapabilityCallResult) => void;
   readonly reject: (error: Error) => void;
+  readonly resolveAccepted: (evidence: ClientCapabilityAdmissionEvidence) => void;
+  readonly rejectAccepted: (error: Error) => void;
   readonly signal?: AbortSignal;
   readonly onAbort?: () => void;
-  readonly onProgress?: (current: number, total: number) => void;
-  readonly timer: NodeJS.Timeout;
-  phase: 'dispatched' | 'accepted' | 'chunks';
+  onProgress?: (current: number, total: number) => void;
+  readonly timeoutMs: number;
+  readonly providerAvailability: AbortController;
+  timer: NodeJS.Timeout | undefined;
+  acceptedSettled: boolean;
+  phase: 'dispatched' | 'accepted' | 'admitted' | 'chunks';
   progress?: { current: number; total: number };
   chunks?: {
     readonly byteLength: number;
@@ -89,6 +95,18 @@ interface InvocationState<Registration extends ClientCapabilityInvocationRegistr
   };
 }
 
+export interface PreparedClientCapabilityInvocation {
+  readonly invocationId: string;
+  /** Resolves once the provider has parsed the call and is waiting at its admission cut. */
+  waitUntilAccepted(): Promise<ClientCapabilityAdmissionEvidence>;
+  /** Crosses the admission cut and returns the provider result. */
+  admit(onProgress?: (current: number, total: number) => void): Promise<ClientCapabilityCallResult>;
+  /** Cancels an accepted call that will not cross the admission cut. */
+  cancel(): void;
+  /** Aborts when the provider connection disappears before this call is admitted. */
+  readonly providerSignal: AbortSignal;
+}
+
 export interface ClientCapabilityInvocationBrokerOptions<
   Registration extends ClientCapabilityInvocationRegistration,
 > {
@@ -109,7 +127,7 @@ export class ClientCapabilityInvocationBroker<
     this.#onRegistrationIdle = options.onRegistrationIdle;
   }
 
-  invoke(
+  async invoke(
     registration: Registration,
     binding: ClientCapabilityInvocationBinding,
     args: Record<string, unknown>,
@@ -118,7 +136,29 @@ export class ClientCapabilityInvocationBroker<
     timeoutMs: number,
     onProgress?: (current: number, total: number) => void,
   ): Promise<ClientCapabilityCallResult> {
-    return this.#invoke(registration, signal, timeoutMs, onProgress, (invocationId) => ({
+    const prepared = this.prepare(
+      registration,
+      binding,
+      args,
+      context,
+      signal,
+      timeoutMs,
+      onProgress,
+    );
+    await prepared.waitUntilAccepted();
+    return prepared.admit();
+  }
+
+  prepare(
+    registration: Registration,
+    binding: ClientCapabilityInvocationBinding,
+    args: Record<string, unknown>,
+    context: ClientCapabilityInvocationContext,
+    signal: AbortSignal | undefined,
+    timeoutMs: number,
+    onProgress?: (current: number, total: number) => void,
+  ): PreparedClientCapabilityInvocation {
+    return this.#prepare(registration, signal, timeoutMs, onProgress, (invocationId) => ({
       kind: 'client.capability.call',
       invocationId,
       registrationId: registration.registrationId,
@@ -133,7 +173,7 @@ export class ClientCapabilityInvocationBroker<
     }));
   }
 
-  invokeService(
+  async invokeService(
     registration: Registration,
     serviceId: string,
     version: string,
@@ -142,7 +182,29 @@ export class ClientCapabilityInvocationBroker<
     signal: AbortSignal | undefined,
     timeoutMs: number,
   ): Promise<ClientCapabilityCallResult> {
-    return this.#invoke(registration, signal, timeoutMs, undefined, (invocationId) => ({
+    const prepared = this.prepareService(
+      registration,
+      serviceId,
+      version,
+      method,
+      input,
+      signal,
+      timeoutMs,
+    );
+    await prepared.waitUntilAccepted();
+    return prepared.admit();
+  }
+
+  prepareService(
+    registration: Registration,
+    serviceId: string,
+    version: string,
+    method: string,
+    input: Record<string, unknown>,
+    signal: AbortSignal | undefined,
+    timeoutMs: number,
+  ): PreparedClientCapabilityInvocation {
+    return this.#prepare(registration, signal, timeoutMs, undefined, (invocationId) => ({
       kind: 'client.capability.service_call',
       invocationId,
       registrationId: registration.registrationId,
@@ -153,37 +215,39 @@ export class ClientCapabilityInvocationBroker<
     }));
   }
 
-  #invoke(
+  #prepare(
     registration: Registration,
     signal: AbortSignal | undefined,
     timeoutMs: number,
     onProgress: ((current: number, total: number) => void) | undefined,
     frameFor: (invocationId: string) => ClientCapabilityHostFrame,
-  ): Promise<ClientCapabilityCallResult> {
+  ): PreparedClientCapabilityInvocation {
     const sender = this.#senderFor(registration.connectionId);
     if (!sender) {
-      return Promise.reject(
-        new ClientCapabilityInvocationError(
-          'capability_lost',
-          'Client Capability provider is unavailable',
-        ),
+      throw new ClientCapabilityInvocationError(
+        'capability_lost',
+        'Client Capability provider is unavailable',
       );
     }
-    if (signal?.aborted) return Promise.reject(abortReason(signal));
+    if (signal?.aborted) throw abortReason(signal);
     const activeForConnection = [...this.#invocations.values()].filter(
       (invocation) => invocation.registration.connectionId === registration.connectionId,
     ).length;
     if (activeForConnection >= MAX_CONCURRENT_INVOCATIONS_PER_CONNECTION) {
-      return Promise.reject(
-        new ClientCapabilityInvocationError(
-          'provider_overloaded',
-          'Client Capability provider has too many active invocations',
-        ),
+      throw new ClientCapabilityInvocationError(
+        'provider_overloaded',
+        'Client Capability provider has too many active invocations',
       );
     }
 
     const invocationId = randomUUID();
-    return new Promise<ClientCapabilityCallResult>((resolve, reject) => {
+    let resolveAccepted!: (evidence: ClientCapabilityAdmissionEvidence) => void;
+    let rejectAccepted!: (error: Error) => void;
+    const accepted = new Promise<ClientCapabilityAdmissionEvidence>((resolve, reject) => {
+      resolveAccepted = resolve;
+      rejectAccepted = reject;
+    });
+    const result = new Promise<ClientCapabilityCallResult>((resolve, reject) => {
       const onAbort = signal
         ? () => {
             const invocation = this.#invocations.get(invocationId);
@@ -192,45 +256,33 @@ export class ClientCapabilityInvocationBroker<
             this.#settle(
               invocation,
               undefined,
-              invocation.phase === 'dispatched'
+              invocation.phase === 'dispatched' || invocation.phase === 'accepted'
                 ? asError(abortReason(signal))
                 : new ToolOutcomeUnknownError(
-                    'Client Capability invocation was cancelled after provider acceptance',
+                    'Client Capability invocation was cancelled after admission',
                   ),
               true,
             );
           }
         : undefined;
-      const timer = setTimeout(() => {
-        const invocation = this.#invocations.get(invocationId);
-        if (!invocation) return;
-        void sender.send({ kind: 'client.capability.cancel', invocationId }).catch(() => {});
-        this.#settle(
-          invocation,
-          undefined,
-          invocation.phase === 'dispatched'
-            ? new ClientCapabilityInvocationError(
-                'timed_out',
-                'Client Capability invocation timed out before provider acceptance',
-              )
-            : new ToolOutcomeUnknownError(
-                'Client Capability invocation timed out after provider acceptance',
-              ),
-          true,
-        );
-      }, timeoutMs);
       const invocation: InvocationState<Registration> = {
         invocationId,
         registration,
         resolve,
         reject,
+        resolveAccepted,
+        rejectAccepted,
         signal,
         onAbort,
         onProgress,
-        timer,
+        timeoutMs,
+        providerAvailability: new AbortController(),
+        timer: undefined,
+        acceptedSettled: false,
         phase: 'dispatched',
       };
       this.#invocations.set(invocationId, invocation);
+      this.#startTimeout(invocation);
       if (onAbort) signal?.addEventListener('abort', onAbort, { once: true });
       void sender.send(frameFor(invocationId)).catch(() => {
         const current = this.#invocations.get(invocationId);
@@ -246,6 +298,71 @@ export class ClientCapabilityInvocationBroker<
         );
       });
     });
+    // A caller waiting only for provider acceptance still receives the same
+    // failure through `accepted`; keep the result rejection from becoming
+    // an unrelated unhandled rejection.
+    void result.catch(() => {});
+    return {
+      invocationId,
+      providerSignal:
+        this.#invocations.get(invocationId)?.providerAvailability.signal ?? AbortSignal.abort(),
+      waitUntilAccepted: () => accepted,
+      admit: async (onProgress) => {
+        await accepted;
+        const invocation = this.#invocations.get(invocationId);
+        if (!invocation) return result;
+        if (invocation.phase === 'accepted') {
+          invocation.onProgress = onProgress ?? invocation.onProgress;
+          invocation.phase = 'admitted';
+          this.#startTimeout(invocation);
+          const currentSender = this.#senderFor(invocation.registration.connectionId);
+          if (!currentSender) {
+            this.#settle(
+              invocation,
+              undefined,
+              new ClientCapabilityInvocationError(
+                'capability_lost',
+                'Client Capability provider disappeared before admission',
+              ),
+              false,
+            );
+          } else {
+            void currentSender
+              .send({ kind: 'client.capability.admitted', invocationId })
+              .catch(() => {
+                const current = this.#invocations.get(invocationId);
+                if (!current) return;
+                this.#settle(
+                  current,
+                  undefined,
+                  new ToolOutcomeUnknownError(
+                    'Client Capability admission acknowledgement could not be delivered',
+                  ),
+                  false,
+                );
+              });
+          }
+        }
+        return result;
+      },
+      cancel: () => {
+        const invocation = this.#invocations.get(invocationId);
+        if (!invocation) return;
+        const currentSender = this.#senderFor(invocation.registration.connectionId);
+        void currentSender
+          ?.send({ kind: 'client.capability.cancel', invocationId })
+          .catch(() => {});
+        this.#settle(
+          invocation,
+          undefined,
+          new ClientCapabilityInvocationError(
+            'provider_rejected',
+            'Client Capability invocation was cancelled before admission',
+          ),
+          true,
+        );
+      },
+    };
   }
 
   accept(connectionId: string, frame: ClientCapabilityClientFrame): void {
@@ -263,36 +380,10 @@ export class ClientCapabilityInvocationBroker<
           throw new Error('Client Capability invocation was accepted more than once');
         }
         invocation.phase = 'accepted';
-        const sender = this.#senderFor(invocation.registration.connectionId);
-        if (!sender) {
-          this.#settle(
-            invocation,
-            undefined,
-            new ClientCapabilityInvocationError(
-              'capability_lost',
-              'Client Capability provider disappeared during acceptance',
-            ),
-            false,
-          );
-          return;
-        }
-        void sender
-          .send({
-            kind: 'client.capability.admitted',
-            invocationId: invocation.invocationId,
-          })
-          .catch(() => {
-            const current = this.#invocations.get(invocation.invocationId);
-            if (!current) return;
-            this.#settle(
-              current,
-              undefined,
-              new ToolOutcomeUnknownError(
-                'Client Capability acceptance acknowledgement could not be delivered',
-              ),
-              false,
-            );
-          });
+        if (invocation.timer) clearTimeout(invocation.timer);
+        invocation.timer = undefined;
+        invocation.acceptedSettled = true;
+        invocation.resolveAccepted(frame.admissionEvidence);
         return;
       }
       case 'client.capability.rejected':
@@ -307,8 +398,8 @@ export class ClientCapabilityInvocationBroker<
         );
         return;
       case 'client.capability.failed':
-        if (invocation.phase === 'dispatched') {
-          throw new Error('Client Capability failure arrived before acceptance');
+        if (invocation.phase !== 'admitted' && invocation.phase !== 'chunks') {
+          throw new Error('Client Capability failure arrived before admission');
         }
         this.#settle(
           invocation,
@@ -318,8 +409,8 @@ export class ClientCapabilityInvocationBroker<
         );
         return;
       case 'client.capability.progress':
-        if (invocation.phase !== 'accepted' && invocation.phase !== 'chunks') {
-          throw new Error('Client Capability progress arrived before acceptance');
+        if (invocation.phase !== 'admitted' && invocation.phase !== 'chunks') {
+          throw new Error('Client Capability progress arrived before admission');
         }
         if (
           invocation.progress &&
@@ -332,14 +423,14 @@ export class ClientCapabilityInvocationBroker<
         invocation.onProgress?.(frame.current, frame.total);
         return;
       case 'client.capability.result':
-        if (invocation.phase !== 'accepted') {
-          throw new Error('Client Capability result arrived outside the accepted phase');
+        if (invocation.phase !== 'admitted') {
+          throw new Error('Client Capability result arrived outside the admitted phase');
         }
         this.#settle(invocation, frame.result, undefined, true);
         return;
       case 'client.capability.result_start':
-        if (invocation.phase !== 'accepted') {
-          throw new Error('Client Capability result chunks started outside the accepted phase');
+        if (invocation.phase !== 'admitted') {
+          throw new Error('Client Capability result chunks started outside the admitted phase');
         }
         invocation.phase = 'chunks';
         invocation.chunks = {
@@ -357,13 +448,21 @@ export class ClientCapabilityInvocationBroker<
   releaseConnection(connectionId: string): void {
     for (const invocation of [...this.#invocations.values()]) {
       if (invocation.registration.connectionId !== connectionId) continue;
+      if (invocation.phase === 'dispatched' || invocation.phase === 'accepted') {
+        invocation.providerAvailability.abort(
+          new ClientCapabilityInvocationError(
+            'capability_lost',
+            'Client Capability provider disconnected before admission',
+          ),
+        );
+      }
       this.#settle(
         invocation,
         undefined,
-        invocation.phase === 'dispatched'
+        invocation.phase === 'dispatched' || invocation.phase === 'accepted'
           ? new ClientCapabilityInvocationError(
               'capability_lost',
-              'Client Capability provider disconnected before accepting the call',
+              'Client Capability provider disconnected before admission',
             )
           : new ToolOutcomeUnknownError(
               'Client Capability provider disconnected after accepting the call',
@@ -415,6 +514,29 @@ export class ClientCapabilityInvocationBroker<
     this.#settle(invocation, decodeClientCapabilityResult(decoded), undefined, true);
   }
 
+  #startTimeout(invocation: InvocationState<Registration>): void {
+    if (invocation.timer) clearTimeout(invocation.timer);
+    invocation.timer = setTimeout(() => {
+      const current = this.#invocations.get(invocation.invocationId);
+      if (!current) return;
+      const sender = this.#senderFor(current.registration.connectionId);
+      void sender
+        ?.send({ kind: 'client.capability.cancel', invocationId: current.invocationId })
+        .catch(() => {});
+      this.#settle(
+        current,
+        undefined,
+        current.phase === 'dispatched' || current.phase === 'accepted'
+          ? new ClientCapabilityInvocationError(
+              'timed_out',
+              'Client Capability invocation timed out before admission',
+            )
+          : new ToolOutcomeUnknownError('Client Capability invocation timed out after admission'),
+        true,
+      );
+    }, invocation.timeoutMs);
+  }
+
   #settle(
     invocation: InvocationState<Registration>,
     result: ClientCapabilityCallResult | undefined,
@@ -423,7 +545,7 @@ export class ClientCapabilityInvocationBroker<
   ): void {
     if (this.#invocations.get(invocation.invocationId) !== invocation) return;
     this.#invocations.delete(invocation.invocationId);
-    clearTimeout(invocation.timer);
+    if (invocation.timer) clearTimeout(invocation.timer);
     if (invocation.onAbort && invocation.signal) {
       invocation.signal.removeEventListener('abort', invocation.onAbort);
     }
@@ -437,6 +559,14 @@ export class ClientCapabilityInvocationBroker<
         })
         .catch(() => {});
     }
+    if (!invocation.acceptedSettled) {
+      invocation.acceptedSettled = true;
+      if (error) invocation.rejectAccepted(error);
+      else
+        invocation.rejectAccepted(
+          new Error('Client Capability invocation settled before provider acceptance'),
+        );
+    }
     if (error) invocation.reject(error);
     else if (result) invocation.resolve(result);
     else invocation.reject(new Error('Client Capability invocation settled without an outcome'));
diff --git a/packages/runtime-host/src/test-only/client-capability-host.ts b/packages/runtime-host/src/server/client-capability-provider-id.ts
similarity index 55%
copy from packages/runtime-host/src/test-only/client-capability-host.ts
copy to packages/runtime-host/src/server/client-capability-provider-id.ts
index 757ce9625c..501513ae13 100644
--- a/packages/runtime-host/src/test-only/client-capability-host.ts
+++ b/packages/runtime-host/src/server/client-capability-provider-id.ts
@@ -17,8 +17,26 @@
  * under the License.
  */
 
-export {
-  HostClientCapabilityCoordinator,
-  type ClientCapabilitySnapshot,
-} from '../server/client-capability-coordinator.js';
-export { RuntimePolicyActivationGate } from '../server/runtime-policy-activation-gate.js';
+import { createHash } from 'node:crypto';
+import type { ClientCapabilityConnectionIdentity } from './client-capability-service.js';
+
+const PROVIDER_ID_DOMAIN = 'maka.client-capability-provider.v1';
+
+export function clientCapabilityProviderId(
+  identity: Pick<
+    ClientCapabilityConnectionIdentity,
+    'principalKind' | 'principalId' | 'clientInstanceId'
+  >,
+): string {
+  const digest = createHash('sha256')
+    .update(
+      JSON.stringify([
+        PROVIDER_ID_DOMAIN,
+        identity.principalKind,
+        identity.principalId,
+        identity.clientInstanceId,
+      ]),
+    )
+    .digest('base64url');
+  return `cc_${digest}`;
+}
diff --git a/packages/runtime-host/src/server/execution-composition.ts b/packages/runtime-host/src/server/execution-composition.ts
index 4407a1b9a3..635c91675e 100644
--- a/packages/runtime-host/src/server/execution-composition.ts
+++ b/packages/runtime-host/src/server/execution-composition.ts
@@ -1073,6 +1073,8 @@ export async function createExecutionRuntimeHostComposition(
     clientCapabilities = new HostClientCapabilityCoordinator({
       activation: runtimePolicyActivation,
       onModelToolsChanged: registerBackendInvalidation,
+      interactions,
+      grants: stores.interactionStore,
     });
     oauth = new HostOAuthCoordinator({
       runtimePolicy: runtimePolicyStores,
diff --git a/packages/runtime-host/src/server/interaction-coordinator.ts b/packages/runtime-host/src/server/interaction-coordinator.ts
index 4ef15e9442..68b36e0dc5 100644
--- a/packages/runtime-host/src/server/interaction-coordinator.ts
+++ b/packages/runtime-host/src/server/interaction-coordinator.ts
@@ -17,13 +17,20 @@
  * under the License.
  */
 
+import { randomUUID } from 'node:crypto';
 import { isDeepStrictEqual } from 'node:util';
+import type {
+  ClientCapabilityGrantTarget,
+  ClientCapabilitySessionGrant,
+} from '@maka/core/client-capability-grant';
 import type { SandboxBoundaryRequestEvent, UserQuestionRequestEvent } from '@maka/core/events';
 import {
   isInteractionAnswerValidForRequest,
+  projectInteractionClientCapabilityRequest,
   projectInteractionSandboxBoundaryRequest,
   projectInteractionQuestionRequest,
   type InteractionCanonicalOutcome,
+  type InteractionClosureReason,
 } from '@maka/core/interaction';
 import type {
   SandboxBoundaryRequest,
@@ -58,6 +65,7 @@ import {
 } from '../protocol/index.js';
 import {
   answerOutcome,
+  clientCapabilityCanonicalOutcome,
   compareStoredInteractionRequests,
   projectInteractionRecord,
   projectSandboxBoundaryInteraction,
@@ -126,10 +134,22 @@ interface LiveSandboxBoundaryEntry extends LiveEntryBase {
   readonly continuation: RuntimeSandboxBoundaryContinuation;
 }
 
-type LiveEntry = LiveQuestionEntry | LiveSandboxBoundaryEntry;
+interface LiveClientCapabilityEntry extends LiveEntryBase {
+  readonly kind: 'client_capability';
+  readonly request: StoredInteractionRequest;
+  readonly decision: Promise<'allow' | 'deny'>;
+  readonly resolve: (decision: 'allow' | 'deny') => void;
+  readonly reject: (error: unknown) => void;
+}
+
+type LiveStoredEntry = LiveQuestionEntry | LiveClientCapabilityEntry;
+type LiveEntry = LiveStoredEntry | LiveSandboxBoundaryEntry;
+type LiveStoredCandidate =
+  | Omit<LiveQuestionEntry, 'run' | 'phase'>
+  | Omit<LiveClientCapabilityEntry, 'run' | 'phase'>;
 
 interface CommittedEntry {
-  readonly entry: LiveQuestionEntry;
+  readonly entry: LiveStoredEntry;
   readonly outcome: StoredInteractionOutcome;
 }
 
@@ -138,6 +158,23 @@ interface SettledSandboxBoundaryEntry {
   readonly settlement: SandboxBoundarySettlement;
 }
 
+export interface ClientCapabilityApprovalInput {
+  readonly sessionId: string;
+  readonly turnId: string;
+  readonly runId: string;
+  readonly toolCallId: string;
+  readonly target: ClientCapabilityGrantTarget;
+  readonly providerSignal?: AbortSignal;
+  readonly callerSignal?: AbortSignal;
+}
+
+export class ClientCapabilityApprovalClosedError extends Error {
+  constructor(readonly reason: InteractionClosureReason) {
+    super(`Client Capability approval closed: ${reason}`);
+    this.name = 'ClientCapabilityApprovalClosedError';
+  }
+}
+
 /** Host-epoch authority for durable Runtime Interactions. */
 export class HostInteractionCoordinator implements RuntimeInteractionAuthority {
   readonly handlers: InteractionOperationHandlerMap = {
@@ -208,6 +245,74 @@ export class HostInteractionCoordinator implements RuntimeInteractionAuthority {
     });
   }
 
+  async requestClientCapabilityApproval(
+    input: ClientCapabilityApprovalInput,
+  ): Promise<'allow' | 'deny'> {
+    this.#throwIfPoisoned();
+    const run = this.#runs.get(runKey(input));
+    if (!run || !run.bound || run.released) {
+      throw new RuntimeInteractionAdmissionRejectedError(input.toolCallId, 'invalid_request');
+    }
+    this.#assertRunOpen(run, input.toolCallId);
+    const requestId = randomUUID();
+    let resolveDecision!: (decision: 'allow' | 'deny') => void;
+    let rejectDecision!: (error: unknown) => void;
+    const decision = new Promise<'allow' | 'deny'>((resolve, reject) => {
+      resolveDecision = resolve;
+      rejectDecision = reject;
+    });
+    await this.#accept(run, {
+      kind: 'client_capability',
+      request: {
+        ...runIdentity(run),
+        requestId,
+        createdAt: this.#now(),
+        request: projectInteractionClientCapabilityRequest({
+          toolUseId: input.toolCallId,
+          target: input.target,
+        }),
+      },
+      decision,
+      resolve: resolveDecision,
+      reject: rejectDecision,
+    });
+    const close = (reason: InteractionClosureReason) => {
+      void this.#closeClientCapabilityApproval(requestId, reason).catch(rejectDecision);
+    };
+    const onProviderDisconnect = () => close('provider_disconnected');
+    const onCallerAbort = () => close(clientCapabilityCallerClosureReason(input.callerSignal));
+    if (input.providerSignal?.aborted) onProviderDisconnect();
+    else input.providerSignal?.addEventListener('abort', onProviderDisconnect, { once: true });
+    if (input.callerSignal?.aborted) onCallerAbort();
+    else input.callerSignal?.addEventListener('abort', onCallerAbort, { once: true });
+    try {
+      return await decision;
+    } finally {
+      input.providerSignal?.removeEventListener('abort', onProviderDisconnect);
+      input.callerSignal?.removeEventListener('abort', onCallerAbort);
+    }
+  }
+
+  async #closeClientCapabilityApproval(
+    requestId: string,
+    reason: InteractionClosureReason,
+  ): Promise<void> {
+    const candidate = this.#live.get(requestId);
+    if (!candidate || candidate.kind !== 'client_capability') return;
+    await this.#sessionAdmission.run(candidate.request.sessionId, async (admission) => {
+      const current = this.#live.get(requestId);
+      if (!current || current !== candidate || current.kind !== 'client_capability') return;
+      const outcome = await this.#commitClientCapabilityOutcome(candidate.request, {
+        kind: 'closure',
+        reason,
+        committedAt: this.#now(),
+      });
+      await this.#refreshCanonicalContinuity(candidate.request.sessionId, admission);
+      this.#throwIfPoisoned();
+      await this.#applyAndDelete(candidate, outcome);
+    });
+  }
+
   beginDrain(): void {
     this.#accepting = false;
   }
@@ -395,7 +500,7 @@ export class HostInteractionCoordinator implements RuntimeInteractionAuthority {
     }
   }
 
-  #accept(run: BoundRun, candidate: Omit<LiveQuestionEntry, 'run' | 'phase'>): Promise<void> {
+  #accept(run: BoundRun, candidate: LiveStoredCandidate): Promise<void> {
     this.#throwIfPoisoned();
     this.#assertRunOpen(run, candidate.request.requestId);
     if (!this.#accepting) {
@@ -413,7 +518,7 @@ export class HostInteractionCoordinator implements RuntimeInteractionAuthority {
         ),
       );
     }
-    const entry: LiveQuestionEntry = { ...candidate, run, phase: 'admitting' };
+    const entry: LiveStoredEntry = { ...candidate, run, phase: 'admitting' };
     this.#live.set(entry.request.requestId, entry);
     return observed(
       this.#sessionAdmission
@@ -426,7 +531,7 @@ export class HostInteractionCoordinator implements RuntimeInteractionAuthority {
   }
 
   async #establishAdmitted(
-    entry: LiveQuestionEntry,
+    entry: LiveStoredEntry,
     admission: SessionAdmissionLease,
   ): Promise<void> {
     this.#throwIfPoisoned();
@@ -636,7 +741,9 @@ export class HostInteractionCoordinator implements RuntimeInteractionAuthority {
           const record = await this.#readInteraction(input.interactionId);
           if (record) {
             if (record.request.sessionId !== input.sessionId) return interactionNotFound();
-            return this.#answerQuestion(record, input.answer, admission);
+            return record.request.request.kind === 'client_capability'
+              ? this.#answerClientCapability(record, input.answer, admission)
+              : this.#answerQuestion(record, input.answer, admission);
           }
           const sandboxBoundary = await this.#readSandboxBoundary(
             input.sessionId,
@@ -720,6 +827,39 @@ export class HostInteractionCoordinator implements RuntimeInteractionAuthority {
     return { ok: true, result } as const;
   }
 
+  async #answerClientCapability(
+    record: InteractionRecord,
+    answer: InteractionAnswerInput['answer'],
+    admission: SessionAdmissionLease,
+  ) {
+    if (record.outcome) return answerOutcome(recordWithOutcome(record), answer);
+    if (
+      record.request.request.kind !== 'client_capability' ||
+      answer.kind !== 'client_capability'
+    ) {
+      return operationConflict('Interaction answer does not match the pending request');
+    }
+    if (!isInteractionAnswerValidForRequest(record.request.request, answer)) {
+      return operationConflict('Interaction answer does not match the pending request');
+    }
+    const entry = this.#requireLiveClientCapability(record.request);
+    const canonical = clientCapabilityCanonicalOutcome(answer, this.#now());
+    const grant: ClientCapabilitySessionGrant | undefined =
+      answer.decision === 'allow'
+        ? {
+            version: 1,
+            sessionId: record.request.sessionId,
+            ...record.request.request.target,
+            grantedAt: canonical.committedAt,
+          }
+        : undefined;
+    const outcome = await this.#commitClientCapabilityOutcome(entry.request, canonical, grant);
+    await this.#refreshCanonicalContinuity(entry.request.sessionId, admission);
+    this.#throwIfPoisoned();
+    await this.#applyAndDelete(entry, outcome);
+    return answerOutcome({ request: record.request, outcome }, answer);
+  }
+
   async #commitAnswer(
     entry: LiveQuestionEntry,
     candidate: Extract<InteractionCanonicalOutcome, { kind: 'question_answer' }>,
@@ -795,25 +935,36 @@ export class HostInteractionCoordinator implements RuntimeInteractionAuthority {
       await Promise.all(
         [...this.#live.values()]
           .filter((entry) => entry.run === run && entry.phase === 'live')
-          .map((entry) => entry.continuation.waitForPublication()),
+          .map((entry) =>
+            entry.kind === 'client_capability'
+              ? Promise.resolve()
+              : entry.continuation.waitForPublication(),
+          ),
       );
       this.#throwIfPoisoned();
       for (const entry of [...this.#live.values()]) {
-        if (entry.kind === 'question' && entry.run === run && entry.phase === 'admitting') {
+        if (entry.kind !== 'sandbox_boundary' && entry.run === run && entry.phase === 'admitting') {
           this.#discardAdmitting(entry);
         }
       }
       const pending = await this.#readPending(runIdentity(run));
       const committed: CommittedEntry[] = [];
       for (const request of pending.sort(compareStoredInteractionRequests)) {
-        const entry = this.#requireLiveQuestion(request);
+        const entry =
+          request.request.kind === 'client_capability'
+            ? this.#requireLiveClientCapability(request)
+            : this.#requireLiveQuestion(request);
+        const closureOutcome = {
+          kind: 'closure' as const,
+          reason: closure.reason,
+          committedAt: this.#now(),
+        };
         committed.push({
           entry,
-          outcome: await this.#commitOutcome(request, {
-            kind: 'closure',
-            reason: closure.reason,
-            committedAt: this.#now(),
-          }),
+          outcome:
+            entry.kind === 'client_capability'
+              ? await this.#commitClientCapabilityOutcome(request, closureOutcome)
+              : await this.#commitOutcome(request, closureOutcome),
         });
       }
       const settledSandboxBoundaries: SettledSandboxBoundaryEntry[] = [];
@@ -933,11 +1084,16 @@ export class HostInteractionCoordinator implements RuntimeInteractionAuthority {
         }
         await this.#sessionAdmission.run(sessionId, async (admission) => {
           for (const request of requests.questions.sort(compareStoredInteractionRequests)) {
-            await this.#commitOutcome(request, {
-              kind: 'closure',
-              reason: 'host_restarted',
+            const closure = {
+              kind: 'closure' as const,
+              reason: 'host_restarted' as const,
               committedAt: this.#now(),
-            });
+            };
+            if (request.request.kind === 'client_capability') {
+              await this.#commitClientCapabilityOutcome(request, closure);
+            } else {
+              await this.#commitOutcome(request, closure);
+            }
           }
           for (const request of requests.sandboxBoundaries) {
             await this.#settleSandboxBoundary({
@@ -1003,6 +1159,28 @@ export class HostInteractionCoordinator implements RuntimeInteractionAuthority {
     return result.record.outcome;
   }
 
+  async #commitClientCapabilityOutcome(
+    request: StoredInteractionRequest,
+    candidate: Extract<
+      InteractionCanonicalOutcome,
+      { kind: 'client_capability_decision' | 'closure' }
+    >,
+    grant?: ClientCapabilitySessionGrant,
+  ): Promise<StoredInteractionOutcome> {
+    this.#throwIfPoisoned();
+    let result: CommitInteractionOutcomeResult;
+    try {
+      result = await this.#store.commitClientCapabilityOutcome(request.requestId, candidate, grant);
+    } catch (error) {
+      throw this.#poison(error);
+    }
+    this.#throwIfPoisoned();
+    if (result.status !== 'stable') throw this.#poison(result.failure);
+    this.#assertExactRequest(request, result.record.request);
+    this.#assertOutcomeIdentity(request, result.record.outcome);
+    return result.record.outcome;
+  }
+
   async #readInteraction(requestId: string): Promise<InteractionRecord | undefined> {
     this.#throwIfPoisoned();
     try {
@@ -1085,10 +1263,7 @@ export class HostInteractionCoordinator implements RuntimeInteractionAuthority {
     }
   }
 
-  async #applyAndDelete(
-    entry: LiveQuestionEntry,
-    outcome: StoredInteractionOutcome,
-  ): Promise<void> {
+  async #applyAndDelete(entry: LiveStoredEntry, outcome: StoredInteractionOutcome): Promise<void> {
     this.#throwIfPoisoned();
     if (this.#live.get(entry.request.requestId) !== entry || entry.phase !== 'live') {
       throw this.#poison(
@@ -1097,6 +1272,21 @@ export class HostInteractionCoordinator implements RuntimeInteractionAuthority {
         ),
       );
     }
+    if (entry.kind === 'client_capability') {
+      this.#live.delete(entry.request.requestId);
+      if (outcome.outcome.kind === 'closure') {
+        entry.reject(new ClientCapabilityApprovalClosedError(outcome.outcome.reason));
+      } else if (outcome.outcome.kind === 'client_capability_decision') {
+        entry.resolve(outcome.outcome.decision);
+      } else {
+        throw this.#poison(
+          new RuntimeInteractionInvariantError(
+            `Client Capability Interaction ${entry.request.requestId} has an invalid outcome`,
+          ),
+        );
+      }
+      return;
+    }
     try {
       const projected = runtimeQuestionOutcome(outcome.outcome);
       if (projected.kind === 'closure') {
@@ -1179,6 +1369,23 @@ export class HostInteractionCoordinator implements RuntimeInteractionAuthority {
     return entry;
   }
 
+  #requireLiveClientCapability(request: StoredInteractionRequest): LiveClientCapabilityEntry {
+    const entry = this.#live.get(request.requestId);
+    if (
+      !entry ||
+      entry.kind !== 'client_capability' ||
+      entry.phase !== 'live' ||
+      !isDeepStrictEqual(entry.request, request)
+    ) {
+      throw this.#poison(
+        new RuntimeInteractionInvariantError(
+          `Pending Client Capability Interaction ${request.requestId} has no exact live continuation`,
+        ),
+      );
+    }
+    return entry;
+  }
+
   #requireLiveSandboxBoundary(request: SandboxBoundaryRequest): LiveSandboxBoundaryEntry {
     const entry = this.#live.get(request.requestId);
     if (
@@ -1264,7 +1471,7 @@ export class HostInteractionCoordinator implements RuntimeInteractionAuthority {
     }
   }
 
-  #discardAdmitting(entry: LiveQuestionEntry): void {
+  #discardAdmitting(entry: LiveStoredEntry): void {
     if (entry.phase !== 'admitting' || this.#live.get(entry.request.requestId) !== entry) {
       throw this.#poison(
         new RuntimeInteractionInvariantError(
@@ -1275,7 +1482,7 @@ export class HostInteractionCoordinator implements RuntimeInteractionAuthority {
     this.#live.delete(entry.request.requestId);
   }
 
-  #discardClosedAdmission(entry: LiveQuestionEntry): void {
+  #discardClosedAdmission(entry: LiveStoredEntry): void {
     const owned = this.#live.get(entry.request.requestId);
     if (owned === undefined) return;
     if (owned !== entry) {
@@ -1319,6 +1526,22 @@ function interactionNotFound() {
   } as const;
 }
 
+function clientCapabilityCallerClosureReason(
+  signal: AbortSignal | undefined,
+): InteractionClosureReason {
+  const reason = signal?.reason;
+  if (
+    (reason instanceof Error && reason.name === 'TimeoutError') ||
+    (typeof reason === 'object' &&
+      reason !== null &&
+      'code' in reason &&
+      reason.code === 'CODE_MODE_TIMEOUT')
+  ) {
+    return 'timed_out';
+  }
+  return 'turn_stopped';
+}
+
 function operationConflict(message: string) {
   return {
     ok: false,
diff --git a/packages/runtime-host/src/server/interaction-projection.ts b/packages/runtime-host/src/server/interaction-projection.ts
index 672533b843..fe4e60a4ab 100644
--- a/packages/runtime-host/src/server/interaction-projection.ts
+++ b/packages/runtime-host/src/server/interaction-projection.ts
@@ -169,6 +169,13 @@ export function questionCanonicalOutcome(
   return { kind: 'question_answer', answers: [...answer.answers], committedAt };
 }
 
+export function clientCapabilityCanonicalOutcome(
+  answer: Extract<InteractionAnswer, { kind: 'client_capability' }>,
+  committedAt: number,
+): Extract<InteractionCanonicalOutcome, { kind: 'client_capability_decision' }> {
+  return { kind: 'client_capability_decision', decision: answer.decision, committedAt };
+}
+
 export function runtimeQuestionOutcome(
   outcome: InteractionCanonicalOutcome,
 ): RuntimeUserQuestionOutcome {
@@ -232,6 +239,9 @@ function canonicalOutcomeForHistoricalAnswer(
       'Sandbox boundary answers require their canonical boundary settlement',
     );
   }
+  if (answer.kind === 'client_capability') {
+    return clientCapabilityCanonicalOutcome(answer, committedAt);
+  }
   return answer.decision === 'deny'
     ? {
         kind: 'permission_answer',
diff --git a/packages/runtime-host/src/test-only/client-capability-host.ts b/packages/runtime-host/src/test-only/client-capability-host.ts
index 757ce9625c..c82d429b45 100644
--- a/packages/runtime-host/src/test-only/client-capability-host.ts
+++ b/packages/runtime-host/src/test-only/client-capability-host.ts
@@ -22,3 +22,27 @@ export {
   type ClientCapabilitySnapshot,
 } from '../server/client-capability-coordinator.js';
 export { RuntimePolicyActivationGate } from '../server/runtime-policy-activation-gate.js';
+
+export function clientCapabilityCoordinatorTestAdmission() {
+  return {
+    interactions: {
+      requestClientCapabilityApproval: async () => {
+        throw new Error('Unexpected Client Capability approval request');
+      },
+    },
+    grants: {
+      readClientCapabilitySessionGrant: async (key: {
+        sessionId: string;
+        providerId: string;
+        contractId: string;
+        serverId: string;
+        toolName: string;
+        capability: 'browser' | 'computer_use' | 'desktop_mcp';
+        scope:
+          | { kind: 'browser_origin'; origin: string }
+          | { kind: 'capability' }
+          | { kind: 'mcp_tool'; serverId: string; toolName: string };
+      }) => ({ version: 1 as const, ...key, grantedAt: 0 }),
+    },
+  };
+}
diff --git a/packages/runtime/src/__tests__/pre-dispatch-refusal-ledger.test.ts b/packages/runtime/src/__tests__/pre-dispatch-refusal-ledger.test.ts
index aa3eb75409..5207802f61 100644
--- a/packages/runtime/src/__tests__/pre-dispatch-refusal-ledger.test.ts
+++ b/packages/runtime/src/__tests__/pre-dispatch-refusal-ledger.test.ts
@@ -190,6 +190,20 @@ const swarmTool: MakaTool = {
   },
 };
 
+function clientCapabilityTool(): MakaTool {
+  return {
+    name: 'browser_click',
+    description: 'test',
+    categoryHint: 'client_capability',
+    hostAdmission: 'client_capability',
+    parameters: z.object({}),
+    prepareExecution: async () => {
+      assert.fail('a pre-dispatch refusal must not prepare Client Capability work');
+    },
+    impl: async () => ({ ok: true }),
+  };
+}
+
 /**
  * Every pre-dispatch refusal, not just the one that produced the bug report.
  *
@@ -298,14 +312,7 @@ const REFUSAL_PATHS: Array<{
           throw new Error('boundary read exploded');
         },
       });
-      const tool: MakaTool = {
-        name: 'browser_click',
-        description: 'test',
-        categoryHint: 'client_capability',
-        parameters: z.object({}),
-        impl: async () => ({ ok: true }),
-      };
-      return settle(h, tool, {}, { runtime, toolCallId: 'call_boundary_read' });
+      return settle(h, clientCapabilityTool(), {}, { runtime, toolCallId: 'call_boundary_read' });
     },
   },
   {
@@ -313,14 +320,7 @@ const REFUSAL_PATHS: Array<{
     expect: /require the Bypass execution boundary/,
     drive: (h) => {
       // The default test boundary is `external`, not `bypass`.
-      const tool: MakaTool = {
-        name: 'browser_click',
-        description: 'test',
-        categoryHint: 'client_capability',
-        parameters: z.object({}),
-        impl: async () => ({ ok: true }),
-      };
-      return settle(h, tool, {}, { toolCallId: 'call_boundary_blocked' });
+      return settle(h, clientCapabilityTool(), {}, { toolCallId: 'call_boundary_blocked' });
     },
   },
 ];
@@ -349,15 +349,7 @@ for (const path of REFUSAL_PATHS) {
 
 test('client-capability refusal carries actionable bypass metadata', async () => {
   const h = harness();
-  const tool: MakaTool = {
-    name: 'browser_click',
-    description: 'test',
-    categoryHint: 'client_capability',
-    parameters: z.object({}),
-    impl: async () => ({ ok: true }),
-  };
-
-  await settle(h, tool, {}, { toolCallId: 'call_boundary_metadata' });
+  await settle(h, clientCapabilityTool(), {}, { toolCallId: 'call_boundary_metadata' });
 
   const result = h.events.find(
     (event) => event.type === 'tool_result' && event.toolUseId === 'call_boundary_metadata',
diff --git a/packages/runtime/src/__tests__/session-event-runtime-mapper.test.ts b/packages/runtime/src/__tests__/session-event-runtime-mapper.test.ts
index 045f94d21b..d8e4c985fe 100644
--- a/packages/runtime/src/__tests__/session-event-runtime-mapper.test.ts
+++ b/packages/runtime/src/__tests__/session-event-runtime-mapper.test.ts
@@ -34,6 +34,7 @@ import {
   mapCompleteStopReason,
   mapSessionEventToRuntimeEvent,
   createSessionEventMapMemory,
+  isLiveBackendSessionEvent,
 } from '../session-event-runtime-mapper.js';
 import type { RuntimeEventMapContext } from '../session-event-runtime-mapper.js';
 import {
@@ -642,6 +643,36 @@ describe('SessionEvent projection coverage', () => {
     );
   });
 
+  test('keeps Host-owned Client Capability interactions out of backend projection', () => {
+    const request: SessionEvent = {
+      type: 'client_capability_request',
+      id: 'client-capability-request-1',
+      turnId: 'turn-1',
+      ts: 1,
+      requestId: 'request-1',
+      toolUseId: 'tool-1',
+      capability: 'browser',
+      scope: { kind: 'browser_origin', origin: 'https://example.com' },
+    };
+    const ack: SessionEvent = {
+      type: 'client_capability_decision_ack',
+      id: 'client-capability-ack-1',
+      turnId: 'turn-1',
+      ts: 2,
+      requestId: 'request-1',
+      toolUseId: 'tool-1',
+      decision: 'allow',
+    };
+
+    for (const event of [request, ack]) {
+      assert.equal(isLiveBackendSessionEvent(event), false);
+      assert.throws(
+        () => mapSessionEventToRuntimeEvent(event, ctx),
+        new RegExp(`${event.type} is not a backend event`, 'u'),
+      );
+    }
+  });
+
   // The contract is over what a reader can actually meet: every mapped event
   // AgentRun admits to the ledger has to project. It asserts on the unclaimed
   // codes at either severity, not on the hard one alone — a control fact whose
diff --git a/packages/runtime/src/__tests__/tool-runtime-settlement.test.ts b/packages/runtime/src/__tests__/tool-runtime-settlement.test.ts
index 65144a1b80..2540f554b2 100644
--- a/packages/runtime/src/__tests__/tool-runtime-settlement.test.ts
+++ b/packages/runtime/src/__tests__/tool-runtime-settlement.test.ts
@@ -32,13 +32,14 @@ import { buildForegroundBashTool, buildManagedBashTool } from '../shell-tools.js
 import { ToolRuntime, type MakaTool, type ToolRuntimeInput } from '../tool-runtime.js';
 
 describe('ToolRuntime settlement', () => {
-  it('requires the bypass boundary before dispatching Client Capability tools', async () => {
+  it('rejects Client Capability Host admission without preparation in every boundary', async () => {
     let calls = 0;
     const clientTool: MakaTool = {
       name: 'client_browser',
       description: 'client browser',
       parameters: {},
       categoryHint: 'client_capability',
+      hostAdmission: 'client_capability',
       impl: () => {
         calls += 1;
         return { ok: true };
@@ -67,7 +68,7 @@ describe('ToolRuntime settlement', () => {
     assert.equal(calls, 0);
     assert.match(
       String((blocked.result as { error?: unknown }).error),
-      /require the Bypass execution boundary/,
+      /missing its Host admission/u,
     );
 
     const allowed = await settle(
@@ -76,8 +77,115 @@ describe('ToolRuntime settlement', () => {
       }),
       'call-bypass',
     );
-    assert.equal(calls, 1);
-    assert.deepEqual(allowed.result, { ok: true });
+    assert.equal(calls, 0);
+    assert.match(
+      String((allowed.result as { error?: unknown }).error),
+      /missing its Host admission/u,
+    );
+  });
+
+  it('prepares Bypass Client Capability work before T1 and admits only after T1', async () => {
+    const order: string[] = [];
+    const clientTool: MakaTool = {
+      name: 'client_browser',
+      description: 'client browser',
+      parameters: {},
+      categoryHint: 'custom_tool',
+      hostAdmission: 'client_capability',
+      prepareExecution: async () => {
+        order.push('prepare');
+        return {
+          execute: async () => {
+            order.push('admit');
+            return { ok: true };
+          },
+          cancel: () => {
+            order.push('cancel');
+          },
+        };
+      },
+      impl: () => assert.fail('Bypass must not use the direct implementation'),
+    };
+    const runtime = makeRuntime({
+      readExecutionBoundary: async () => createBypassExecutionBoundary(0),
+      runId: 'run-1',
+      invocationId: 'invocation-1',
+      runtimeCommitSink: {
+        commitToolPrepared: async () => {
+          order.push('T1');
+          return { created: true, runtimeEventSeq: 1 };
+        },
+        commitToolOutcome: async () => ({ created: true, runtimeEventSeq: 2 }),
+      },
+    });
+
+    const settlement = await runtime.settleToolCall({
+      tool: clientTool,
+      turnId: 'turn-1',
+      stepId: 'step-1',
+      toolCallId: 'call-bypass-prepared',
+      input: {},
+      abortSignal: new AbortController().signal,
+      eventSink: {
+        push: () => undefined,
+        pushAndWaitUntilConsumed: async () => undefined,
+      },
+    });
+
+    assert.deepEqual(order, ['prepare', 'T1', 'admit']);
+    assert.deepEqual(settlement.result, { ok: true });
+  });
+
+  it('cancels prepared Client Capability work when T1 fails', async () => {
+    const order: string[] = [];
+    const clientTool: MakaTool = {
+      name: 'client_browser',
+      description: 'client browser',
+      parameters: {},
+      hostAdmission: 'client_capability',
+      prepareExecution: async () => {
+        order.push('prepare');
+        return {
+          execute: async () => {
+            order.push('admit');
+            return { ok: true };
+          },
+          cancel: () => {
+            order.push('cancel');
+          },
+        };
+      },
+      impl: () => assert.fail('T1 failure must not use the direct implementation'),
+    };
+    const runtime = makeRuntime({
+      readExecutionBoundary: async () => createBypassExecutionBoundary(0),
+      runId: 'run-1',
+      invocationId: 'invocation-1',
+      runtimeCommitSink: {
+        commitToolPrepared: async () => {
+          order.push('T1');
+          throw new Error('durable write failed');
+        },
+        commitToolOutcome: async () => assert.fail('T2 must not run'),
+      },
+    });
+
+    await assert.rejects(
+      runtime.settleToolCall({
+        tool: clientTool,
+        turnId: 'turn-1',
+        stepId: 'step-1',
+        toolCallId: 'call-bypass-t1-failure',
+        input: {},
+        abortSignal: new AbortController().signal,
+        eventSink: {
+          push: () => undefined,
+          pushAndWaitUntilConsumed: async () => undefined,
+        },
+      }),
+      /durable write failed/u,
+    );
+    assert.deepEqual(order, ['prepare', 'T1', 'cancel']);
   });
 
   it('keeps the durable Bash command while omitting it from the durable projection', async () => {
@@ -535,7 +643,10 @@ describe('ToolRuntime settlement', () => {
 
 function makeRuntime(
   overrides: Partial<
-    Pick<ToolRuntimeInput, 'readExecutionBoundary' | 'spawnChildSession' | 'runId'>
+    Pick<
+      ToolRuntimeInput,
+      'readExecutionBoundary' | 'spawnChildSession' | 'runId' | 'invocationId' | 'runtimeCommitSink'
+    >
   > = {},
 ): ToolRuntime {
   return createTestToolRuntime({
diff --git a/packages/runtime/src/__tests__/tool-runtime-sqlite-boundary.test.ts b/packages/runtime/src/__tests__/tool-runtime-sqlite-boundary.test.ts
index a15bd68793..6146bae39b 100644
--- a/packages/runtime/src/__tests__/tool-runtime-sqlite-boundary.test.ts
+++ b/packages/runtime/src/__tests__/tool-runtime-sqlite-boundary.test.ts
@@ -150,7 +150,11 @@ describe('ToolRuntime with real SQLite boundary', () => {
             return { content: [{ type: 'text', text: 'ok' }] };
           },
         },
-        { categoryHint: 'client_capability', recoveryMode: 'outcome_unknown' },
+        {
+          categoryHint: 'custom_tool',
+          hostAdmission: 'client_capability',
+          recoveryMode: 'outcome_unknown',
+        },
       );
       assert.ok(clientTool);
       const runtime = createTestToolRuntime({
@@ -183,7 +187,7 @@ describe('ToolRuntime with real SQLite boundary', () => {
       });
 
       assert.equal(implementationCalls, 0);
-      assert.match(JSON.stringify(result.result), /require the Bypass execution boundary/u);
+      assert.match(JSON.stringify(result.result), /missing its Host admission/u);
       const toolEvents = published.filter(
         (event) => event.type === 'tool_start' || event.type === 'tool_result',
       );
diff --git a/packages/runtime/src/mcp-tools.ts b/packages/runtime/src/mcp-tools.ts
index fce515ad42..a7c48941db 100644
--- a/packages/runtime/src/mcp-tools.ts
+++ b/packages/runtime/src/mcp-tools.ts
@@ -26,7 +26,8 @@ import type {
   McpToolDescriptor,
   McpToolSnapshot,
 } from '@maka/core/mcp';
-import type { ToolCategory } from '@maka/core/permission';
+import type { PermissionMode, ToolCategory } from '@maka/core/permission';
+import type { ExecutionBoundary } from '@maka/core/sandbox-boundary';
 import type { ToolRecoveryMode } from '@maka/core/runtime-event';
 import type { ToolResultContentPart, ToolResultOutput } from './model-protocol.js';
 import type { MakaTool } from './tool-runtime.js';
@@ -41,6 +42,11 @@ const TRUNCATION_MARKER = '\n…[truncated by Maka]';
 
 export interface McpToolProvider {
   toolSnapshot(): McpToolSnapshot;
+  prepareTool?(
+    binding: McpToolBinding,
+    args: Record<string, unknown>,
+    options: Omit<McpToolCallOptions, 'emitProgress'>,
+  ): Promise<McpPreparedToolCall>;
   callTool(
     binding: McpToolBinding,
     args: Record<string, unknown>,
@@ -48,6 +54,13 @@ export interface McpToolProvider {
   ): Promise<McpCallResult>;
 }
 
+export interface McpPreparedToolCall {
+  execute(options?: {
+    readonly emitProgress?: (current: number, total: number) => void;
+  }): Promise<McpCallResult>;
+  cancel(): Promise<void> | void;
+}
+
 export interface McpToolCallOptions {
   readonly signal?: AbortSignal;
   readonly timeoutMs?: number;
@@ -57,14 +70,18 @@ export interface McpToolCallOptions {
 
 export interface McpToolInvocationContext {
   readonly sessionId: string;
+  readonly runId?: string;
   readonly turnId: string;
   readonly toolCallId: string;
   readonly cwd: string;
+  readonly executionBoundary?: ExecutionBoundary;
+  readonly permissionMode?: PermissionMode;
 }
 
 export interface BuildMcpToolsOptions {
   callTimeoutMs?: number;
   categoryHint?: ToolCategory;
+  hostAdmission?: MakaTool['hostAdmission'];
   recoveryMode?: ToolRecoveryMode;
   executionLocation?: 'host' | 'remote';
   activityKindForDescriptor?: (descriptor: McpToolDescriptor) => ToolActivityKind | undefined;
@@ -95,8 +112,37 @@ export function buildMcpTools(
       // The trusted composition may select a stricter open-world category;
       // ordinary MCP servers retain the side-effecting network default.
       categoryHint: options.categoryHint ?? 'network_send',
+      ...(options.hostAdmission ? { hostAdmission: options.hostAdmission } : {}),
       ...(options.recoveryMode ? { recoveryMode: options.recoveryMode } : {}),
       parameters: jsonSchema(descriptor.inputSchema),
+      ...(provider.prepareTool
+        ? {
+            prepareExecution: async (args: unknown, context) => {
+              const prepared = await provider.prepareTool!(binding, asArguments(args), {
+                signal: context.abortSignal,
+                timeoutMs: options.callTimeoutMs,
+                context: {
+                  sessionId: context.sessionId,
+                  runId: context.runId,
+                  turnId: context.turnId,
+                  toolCallId: context.toolCallId,
+                  cwd: context.cwd,
+                  executionBoundary: context.executionBoundary,
+                  permissionMode: context.permissionMode,
+                },
+              });
+              return {
+                execute: (executionContext) =>
+                  prepared.execute({
+                    ...(executionContext.emitProgress
+                      ? { emitProgress: executionContext.emitProgress }
+                      : {}),
+                  }),
+                cancel: () => prepared.cancel(),
+              };
+            },
+          }
+        : {}),
       impl: async (args: unknown, context) => {
         // Managed network authority applies equally to Direct and nested CodeMode dispatch.
         if (
diff --git a/packages/runtime/src/session-event-runtime-mapper.ts b/packages/runtime/src/session-event-runtime-mapper.ts
index 63e251c84e..3f8240dc92 100644
--- a/packages/runtime/src/session-event-runtime-mapper.ts
+++ b/packages/runtime/src/session-event-runtime-mapper.ts
@@ -130,7 +130,7 @@ export function mapSessionEventToRuntimeEvent(
   ctx: RuntimeEventMapContext,
   memory: SessionEventMapMemory = createSessionEventMapMemory(),
 ): RuntimeEvent {
-  if (event.type === 'queue_update' || event.type === 'message_admission') {
+  if (isHostProjectionSessionEvent(event)) {
     // These are Host/kernel projection facts, not backend events. The live
     // ingress drops them, so reaching this line bypassed that authority boundary.
     throw new Error(`${event.type} is not a backend event`);
@@ -143,10 +143,24 @@ export function mapSessionEventToRuntimeEvent(
 }
 
 export function isLiveBackendSessionEvent(event: SessionEvent): event is BackendSessionEvent {
+  return !isHostProjectionSessionEvent(event) && !isLegacyPermissionSessionEvent(event);
+}
+
+function isHostProjectionSessionEvent(event: SessionEvent): event is Extract<
+  SessionEvent,
+  {
+    type:
+      | 'queue_update'
+      | 'message_admission'
+      | 'client_capability_request'
+      | 'client_capability_decision_ack';
+  }
+> {
   return (
-    event.type !== 'queue_update' &&
-    event.type !== 'message_admission' &&
-    !isLegacyPermissionSessionEvent(event)
+    event.type === 'queue_update' ||
+    event.type === 'message_admission' ||
+    event.type === 'client_capability_request' ||
+    event.type === 'client_capability_decision_ack'
   );
 }
 
diff --git a/packages/runtime/src/tool-runtime.ts b/packages/runtime/src/tool-runtime.ts
index 5fe9493be9..5214ffda49 100644
--- a/packages/runtime/src/tool-runtime.ts
+++ b/packages/runtime/src/tool-runtime.ts
@@ -151,6 +151,23 @@ export interface ToolSettlement {
   providerError?: string;
 }
 
+export type MakaToolPreparationContext = Pick<
+  MakaToolContext,
+  | 'sessionId'
+  | 'runId'
+  | 'turnId'
+  | 'cwd'
+  | 'executionBoundary'
+  | 'permissionMode'
+  | 'toolCallId'
+  | 'abortSignal'
+>;
+
+export interface PreparedMakaToolExecution<R = unknown> {
+  execute(context: MakaToolContext): Promise<R> | R;
+  cancel(): Promise<void> | void;
+}
+
 export interface MakaTool<P = any, R = unknown> {
   /** Canonical (Claude-SDK-style) name. Pi adapter translates to canonical. */
   name: string;
@@ -164,6 +181,8 @@ export interface MakaTool<P = any, R = unknown> {
   activityKind?: ToolActivityKind;
   /** Optional trusted category override for custom tools. */
   categoryHint?: ToolCategory;
+  /** Host-owned admission contract, independent of model/UI tool categorization. */
+  hostAdmission?: 'client_capability';
   /** Optional trusted facts about the executor that runs this tool. */
   executionFacts?: ToolExecutionFacts;
   /**
@@ -193,6 +212,11 @@ export interface MakaTool<P = any, R = unknown> {
     args: P,
     context: Pick<MakaToolContext, 'sessionId' | 'turnId' | 'toolCallId'>,
   ) => unknown;
+  /** Optional preparation that settles before ToolRuntime crosses its durable T1 cut. */
+  prepareExecution?: (
+    args: P,
+    context: MakaToolPreparationContext,
+  ) => Promise<PreparedMakaToolExecution<R>>;
   /**
    * Real tool implementation. Implementations must observe `ctx.abortSignal` and
    * settle promptly after it aborts. Runtime-owned nested calls await this
@@ -311,6 +335,8 @@ const SUBAGENT_TOOL_LIMIT_MESSAGE =
   '子代理并发过多:同一轮最多 5 个子代理。请等待已有任务完成后再继续。';
 const CLIENT_CAPABILITY_BOUNDARY_MESSAGE =
   'Client Capability tools require the Bypass execution boundary because their client-side effects cannot be sandboxed by the Host. Switch this Session to Bypass and retry.';
+const CLIENT_CAPABILITY_PREPARATION_MESSAGE =
+  'Client Capability tool is missing its Host admission preparation contract.';
 
 function composeChildAbortSignal(
   invocationSignal: AbortSignal,
@@ -1383,7 +1409,8 @@ export class ToolRuntime {
     }
 
     let clientCapabilityBoundary: ExecutionBoundary | undefined;
-    if (tool.categoryHint === 'client_capability') {
+    let preparedExecution: PreparedMakaToolExecution | undefined;
+    if (tool.hostAdmission === 'client_capability') {
       try {
         clientCapabilityBoundary = await this.readExecutionBoundary();
       } catch (error) {
@@ -1398,8 +1425,13 @@ export class ToolRuntime {
         this.recordLoopGateOutcome(callSignature, true);
         return this.errorReturn(reason);
       }
-      if (clientCapabilityBoundary.kind !== 'bypass') {
-        await refuseBeforeDispatch(CLIENT_CAPABILITY_BOUNDARY_MESSAGE, {
+      const admissionFailure = !tool.prepareExecution
+        ? CLIENT_CAPABILITY_PREPARATION_MESSAGE
+        : clientCapabilityBoundary.kind !== 'bypass' && this.input.header.permissionMode !== 'ask'
+          ? CLIENT_CAPABILITY_BOUNDARY_MESSAGE
+          : undefined;
+      if (admissionFailure) {
+        await refuseBeforeDispatch(admissionFailure, {
           reason: 'requires_bypass',
           source: 'client_capability',
         });
@@ -1410,7 +1442,37 @@ export class ToolRuntime {
           errorClass: 'ClientCapabilityBoundary',
         });
         this.recordLoopGateOutcome(callSignature, true);
-        return this.errorReturn(CLIENT_CAPABILITY_BOUNDARY_MESSAGE);
+        return this.errorReturn(admissionFailure);
+      }
+      // Narrowed by admissionFailure above; Bypass still prepares so the
+      // provider cannot regain a direct pre-T1 dispatch path.
+      const prepareExecution = tool.prepareExecution!;
+      const pauseTarget = this.input.getPermissionPauseTarget();
+      pauseTarget?.pause();
+      try {
+        preparedExecution = await prepareExecution(structuredClone(executionArgs) as never, {
+          sessionId: this.input.sessionId,
+          turnId,
+          ...(runId ? { runId } : {}),
+          cwd: this.input.header.cwd,
+          executionBoundary: clientCapabilityBoundary,
+          permissionMode: this.input.header.permissionMode,
+          toolCallId: toolUseId,
+          abortSignal: ctx.abortSignal,
+        });
+      } catch (error) {
+        const reason = formatSyntheticToolErrorText(error);
+        await refuseBeforeDispatch(reason);
+        trace?.emit('tool', 'tool_failed', 'Client Capability preparation failed', {
+          toolUseId,
+          toolName: tool.name,
+          status: 'error',
+          errorClass: 'ClientCapabilityPreparation',
+        });
+        this.recordLoopGateOutcome(callSignature, true);
+        return this.errorReturn(reason);
+      } finally {
+        pauseTarget?.resume();
       }
     }
 
@@ -1446,6 +1508,7 @@ export class ToolRuntime {
 
     const reservedSubagentSlot = this.reserveSubagentSlot(tool);
     if (!reservedSubagentSlot) {
+      await preparedExecution?.cancel();
       await disposeManagedMutationAdmission(managedMutationAdmission);
       trace?.emit('tool', 'tool_failed', 'Tool execution rejected by runtime limit', {
         toolUseId,
@@ -1473,6 +1536,7 @@ export class ToolRuntime {
         ...(runId ? { runId } : {}),
       });
     } catch (error) {
+      await preparedExecution?.cancel();
       if (reservedSubagentSlot) this.releaseSubagentSlot(tool);
       await disposeManagedMutationAdmission(managedMutationAdmission);
       throw error;
@@ -1514,82 +1578,85 @@ export class ToolRuntime {
       try {
         const runId = this.input.runId;
         const executionBoundary = clientCapabilityBoundary ?? (await this.readExecutionBoundary());
-        const invokeTool = () =>
-          tool.impl(structuredClone(executionArgs) as never, {
-            sessionId: this.input.sessionId,
+        const toolContext: MakaToolContext = {
+          sessionId: this.input.sessionId,
+          turnId,
+          ...(runId ? { runId } : {}),
+          ...(this.input.orchestrationMode
+            ? { orchestrationMode: this.input.orchestrationMode }
+            : {}),
+          cwd: this.input.header.cwd,
+          executionBoundary,
+          permissionMode: this.input.header.permissionMode,
+          toolCallId: toolUseId,
+          // The id the call event actually carries, not the candidate: by here
+          // `prepareDurableToolAttempt` has pushed it on the dispatch lane.
+          ...(callEvent?.operationId ? { operationId: callEvent.operationId } : {}),
+          abortSignal: ctx.abortSignal,
+          emitOutput: output.emit,
+          emitProgress: (current, total) => {
+            const chunk = encodeToolStepProgress({ current, total });
+            if (!chunk) return;
+            queue.push({
+              type: 'tool_progress',
+              id: this.input.newId(),
+              turnId,
+              ts: this.input.now(),
+              toolUseId,
+              chunk,
+              ...activityIdentity,
+            });
+          },
+          ...(trace
+            ? {
+                emitRunTrace: (
+                  type:
+                    | 'tool_started'
+                    | 'tool_searched'
+                    | 'tool_completed'
+                    | 'tool_failed'
+                    | 'skill_searched'
+                    | 'skill_loaded'
+                    | 'skill_load_failed',
+                  message: string,
+                  data?: Record<string, unknown>,
+                ) =>
+                  trace.emit(type.startsWith('skill_') ? 'skill' : 'tool', type, message, {
+                    toolUseId,
+                    toolName: tool.name,
+                    ...(data ?? {}),
+                  }),
+              }
+            : {}),
+          ...(this.input.listChildAgents ? { listChildAgents: this.input.listChildAgents } : {}),
+          ...(this.input.readChildAgentOutput
+            ? { readChildAgentOutput: this.input.readChildAgentOutput }
+            : {}),
+          ...this.buildChildAgentContext({
             turnId,
-            ...(runId ? { runId } : {}),
-            ...(this.input.orchestrationMode
-              ? { orchestrationMode: this.input.orchestrationMode }
-              : {}),
-            cwd: this.input.header.cwd,
-            executionBoundary,
-            permissionMode: this.input.header.permissionMode,
-            toolCallId: toolUseId,
-            // The id the call event actually carries, not the candidate: by here
-            // `prepareDurableToolAttempt` has pushed it on the dispatch lane.
-            ...(callEvent?.operationId ? { operationId: callEvent.operationId } : {}),
             abortSignal: ctx.abortSignal,
-            emitOutput: output.emit,
-            emitProgress: (current, total) => {
-              const chunk = encodeToolStepProgress({ current, total });
-              if (!chunk) return;
-              queue.push({
-                type: 'tool_progress',
-                id: this.input.newId(),
-                turnId,
-                ts: this.input.now(),
-                toolUseId,
-                chunk,
-                ...activityIdentity,
-              });
-            },
-            ...(trace
-              ? {
-                  emitRunTrace: (
-                    type:
-                      | 'tool_started'
-                      | 'tool_searched'
-                      | 'tool_completed'
-                      | 'tool_failed'
-                      | 'skill_searched'
-                      | 'skill_loaded'
-                      | 'skill_load_failed',
-                    message: string,
-                    data?: Record<string, unknown>,
-                  ) =>
-                    trace.emit(type.startsWith('skill_') ? 'skill' : 'tool', type, message, {
-                      toolUseId,
-                      toolName: tool.name,
-                      ...(data ?? {}),
-                    }),
-                }
-              : {}),
-            ...(this.input.listChildAgents ? { listChildAgents: this.input.listChildAgents } : {}),
-            ...(this.input.readChildAgentOutput
-              ? { readChildAgentOutput: this.input.readChildAgentOutput }
-              : {}),
-            ...this.buildChildAgentContext({
+            trace,
+            toolUseId,
+            toolName: tool.name,
+            queue,
+            activityIdentity,
+          }),
+          askUserQuestion: (questions) =>
+            this.askUserQuestion(turnId, toolUseId, questions, ctx.abortSignal, queue),
+          requestSandboxBoundary: (expansion, justification) =>
+            this.requestSandboxBoundary(
               turnId,
-              abortSignal: ctx.abortSignal,
-              trace,
               toolUseId,
-              toolName: tool.name,
+              expansion,
+              justification,
+              ctx.abortSignal,
               queue,
-              activityIdentity,
-            }),
-            askUserQuestion: (questions) =>
-              this.askUserQuestion(turnId, toolUseId, questions, ctx.abortSignal, queue),
-            requestSandboxBoundary: (expansion, justification) =>
-              this.requestSandboxBoundary(
-                turnId,
-                toolUseId,
-                expansion,
-                justification,
-                ctx.abortSignal,
-                queue,
-              ),
-          });
+            ),
+        };
+        const invokeTool = () =>
+          preparedExecution
+            ? preparedExecution.execute(toolContext)
+            : tool.impl(structuredClone(executionArgs) as never, toolContext);
         const invokeManagedTransform = () =>
           tool.managedMutationTransform!(structuredClone(executionArgs) as never);
         const prepareOperationValue = async (
diff --git a/packages/storage/src/__tests__/client-capability-session-grant-store.test.ts b/packages/storage/src/__tests__/client-capability-session-grant-store.test.ts
new file mode 100644
index 0000000000..888fecbbdb
--- /dev/null
+++ b/packages/storage/src/__tests__/client-capability-session-grant-store.test.ts
@@ -0,0 +1,105 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import assert from 'node:assert/strict';
+import { mkdtemp, rm } from 'node:fs/promises';
+import { tmpdir } from 'node:os';
+import { join } from 'node:path';
+import { after, test } from 'node:test';
+import { createConversationOperationalStateStore } from '../conversation-operational-state.js';
+import {
+  closeSqliteInteractionStoreFacade,
+  openSqliteInteractiveInteractionStoreForWrite,
+} from '../interaction-store.js';
+import { resolveStorageRoot, tryAcquireInteractiveRootOwner } from '../root-authority.js';
+import {
+  removeTrackedControlDirectories,
+  trackControlDirectory,
+} from './fixtures/control-directory-hygiene.js';
+
+after(removeTrackedControlDirectories);
+
+test('persists Client Capability grants for one Session and purges them with it', async () => {
+  const root = await mkdtemp(join(tmpdir(), 'maka-client-capability-grant-'));
+  try {
+    const capability = trackControlDirectory(
+      await resolveStorageRoot({ path: root, kind: 'interactive' }),
+    );
+    const owner = await tryAcquireInteractiveRootOwner(capability);
+    assert.ok(owner);
+    if (!owner) return;
+    const store = await openSqliteInteractiveInteractionStoreForWrite(owner.lease);
+    const key = {
+      sessionId: 'session-1',
+      providerId: 'provider-1',
+      contractId: 'contract-1',
+      serverId: 'desktop_browser',
+      toolName: 'browser_snapshot',
+      capability: 'browser' as const,
+      scope: { kind: 'browser_origin' as const, origin: 'https://example.com' },
+    };
+    try {
+      const grant = {
+        version: 1,
+        ...key,
+        grantedAt: 10,
+      } as const;
+      const established = await store.establishRequest({
+        sessionId: 'session-1',
+        turnId: 'turn-1',
+        runId: 'run-1',
+        requestId: 'request-1',
+        createdAt: 5,
+        request: {
+          kind: 'client_capability',
+          toolUseId: 'tool-call-1',
+          target: key,
+        },
+      });
+      assert.equal(established.status, 'stable');
+      const committed = await store.commitClientCapabilityOutcome(
+        'request-1',
+        { kind: 'client_capability_decision', decision: 'allow', committedAt: 10 },
+        grant,
+      );
+      assert.equal(committed.status, 'stable');
+      assert.deepEqual(await store.readClientCapabilitySessionGrant(key), grant);
+      assert.deepEqual(
+        await store.readClientCapabilitySessionGrant({
+          ...key,
+          toolName: 'browser_click',
+        }),
+        grant,
+      );
+
+      const operationalState = createConversationOperationalStateStore(root);
+      try {
+        await operationalState.purge('session-1');
+      } finally {
+        operationalState.close();
+      }
+      assert.equal(await store.readClientCapabilitySessionGrant(key), undefined);
+    } finally {
+      closeSqliteInteractionStoreFacade(store);
+      await owner.close();
+    }
+  } finally {
+    await rm(root, { recursive: true, force: true });
+  }
+});
diff --git a/packages/storage/src/conversation-operational-state.ts b/packages/storage/src/conversation-operational-state.ts
index 2f87bd8921..e1f1d36df0 100644
--- a/packages/storage/src/conversation-operational-state.ts
+++ b/packages/storage/src/conversation-operational-state.ts
@@ -86,6 +86,9 @@ class SqliteConversationOperationalStateStore implements ConversationOperational
         .prepare('DELETE FROM core_root_turn_start_rejections WHERE session_id = ?')
         .run(sessionId);
       database.prepare('DELETE FROM core_agent_runs WHERE session_id = ?').run(sessionId);
+      database
+        .prepare('DELETE FROM core_client_capability_session_grants WHERE session_id = ?')
+        .run(sessionId);
       database.prepare('DELETE FROM workflow_goal_authority WHERE session_id = ?').run(sessionId);
     });
   }
diff --git a/packages/storage/src/interaction-store-public.ts b/packages/storage/src/interaction-store-public.ts
index 7aa1973d29..d72040d5ce 100644
--- a/packages/storage/src/interaction-store-public.ts
+++ b/packages/storage/src/interaction-store-public.ts
@@ -25,6 +25,7 @@
 export {
   STORED_INTERACTION_OUTCOME_MAX_BYTES,
   STORED_INTERACTION_REQUEST_MAX_BYTES,
+  STORED_CLIENT_CAPABILITY_SESSION_GRANT_MAX_BYTES,
   InteractionStoreError,
   authenticateInteractionStoreReader,
   authenticateInteractionStoreWriter,
diff --git a/packages/storage/src/interaction-store.ts b/packages/storage/src/interaction-store.ts
index 1b53713d51..53b1260793 100644
--- a/packages/storage/src/interaction-store.ts
+++ b/packages/storage/src/interaction-store.ts
@@ -19,6 +19,13 @@
 
 import { resolve } from 'node:path';
 import { isDeepStrictEqual } from 'node:util';
+import {
+  clientCapabilityScopeIdentity,
+  decodeClientCapabilitySessionGrant,
+  decodeClientCapabilitySessionGrantKey,
+  type ClientCapabilitySessionGrant,
+  type ClientCapabilitySessionGrantKey,
+} from '@maka/core/client-capability-grant';
 import {
   decodeInteractionCanonicalOutcome,
   decodeInteractionRequest,
@@ -43,6 +50,7 @@ const SAFE_ID = /^[A-Za-z0-9_-]{1,128}$/;
 const REMEMBER_SCOPE_ID = /^[0-9a-f]{64}$/;
 export const STORED_INTERACTION_REQUEST_MAX_BYTES = 20 * 1024;
 export const STORED_INTERACTION_OUTCOME_MAX_BYTES = 12 * 1024;
+export const STORED_CLIENT_CAPABILITY_SESSION_GRANT_MAX_BYTES = 12 * 1024;
 
 export interface InteractionIdentity {
   readonly sessionId: string;
@@ -119,6 +127,9 @@ export interface InteractionStoreReader {
   readInteraction(requestId: string): Promise<InteractionRecord | undefined>;
   listSessionPending(sessionId: string): Promise<StoredInteractionRequest[]>;
   listPending(filter?: PendingInteractionFilter): Promise<StoredInteractionRequest[]>;
+  readClientCapabilitySessionGrant(
+    key: ClientCapabilitySessionGrantKey,
+  ): Promise<ClientCapabilitySessionGrant | undefined>;
 }
 
 export interface InteractionStoreWriter extends InteractionStoreReader {
@@ -127,6 +138,17 @@ export interface InteractionStoreWriter extends InteractionStoreReader {
     requestId: string,
     outcome: InteractionCanonicalOutcome,
   ): Promise<CommitInteractionOutcomeResult>;
+  commitClientCapabilitySessionGrant(
+    grant: ClientCapabilitySessionGrant,
+  ): Promise<ClientCapabilitySessionGrant>;
+  commitClientCapabilityOutcome(
+    requestId: string,
+    outcome: Extract<
+      InteractionCanonicalOutcome,
+      { kind: 'client_capability_decision' | 'closure' }
+    >,
+    grant?: ClientCapabilitySessionGrant,
+  ): Promise<CommitInteractionOutcomeResult>;
 }
 
 export interface InteractiveInteractionStoreReaderFacade extends InteractionStoreReader {
@@ -176,6 +198,8 @@ export async function openSqliteInteractiveInteractionStoreForRead(
     readInteraction: (requestId: string) => run(() => store.readInteraction(requestId)),
     listSessionPending: (sessionId: string) => run(() => store.listSessionPending(sessionId)),
     listPending: (filter?: PendingInteractionFilter) => run(() => store.listPending(filter)),
+    readClientCapabilitySessionGrant: (key: ClientCapabilitySessionGrantKey) =>
+      run(() => store.readClientCapabilitySessionGrant(key)),
   });
   readers.add(facade);
   sqliteFacadeClosers.set(facade, () => store.close());
@@ -206,10 +230,22 @@ export async function openSqliteInteractiveInteractionStoreForWrite(
       readInteraction: (requestId: string) => run(() => store.readInteraction(requestId)),
       listSessionPending: (sessionId: string) => run(() => store.listSessionPending(sessionId)),
       listPending: (filter?: PendingInteractionFilter) => run(() => store.listPending(filter)),
+      readClientCapabilitySessionGrant: (key: ClientCapabilitySessionGrantKey) =>
+        run(() => store.readClientCapabilitySessionGrant(key)),
       establishRequest: (input: StoredInteractionRequest) =>
         run(() => store.establishRequest(input)),
       commitOutcome: (requestId: string, outcome: InteractionCanonicalOutcome) =>
         run(() => store.commitOutcome(requestId, outcome)),
+      commitClientCapabilitySessionGrant: (grant: ClientCapabilitySessionGrant) =>
+        run(() => store.commitClientCapabilitySessionGrant(grant)),
+      commitClientCapabilityOutcome: (
+        requestId: string,
+        outcome: Extract<
+          InteractionCanonicalOutcome,
+          { kind: 'client_capability_decision' | 'closure' }
+        >,
+        grant?: ClientCapabilitySessionGrant,
+      ) => run(() => store.commitClientCapabilityOutcome(requestId, outcome, grant)),
     });
     writers.add(facade);
     sqliteWritersByLease.set(lease, facade);
@@ -337,6 +373,82 @@ class SqliteInteractionStore implements InteractionStoreWriter {
     });
   }
 
+  async commitClientCapabilityOutcome(
+    requestId: string,
+    outcome: Extract<
+      InteractionCanonicalOutcome,
+      { kind: 'client_capability_decision' | 'closure' }
+    >,
+    grant?: ClientCapabilitySessionGrant,
+  ): Promise<CommitInteractionOutcomeResult> {
+    assertId(requestId);
+    return this.#lease.transaction('write', () => {
+      const record = readSqliteInteraction(this.#lease, requestId);
+      if (!record) {
+        throw new InteractionStoreError(
+          'request_not_found',
+          `Interaction request '${requestId}' does not exist`,
+        );
+      }
+      if (record.request.request.kind !== 'client_capability') {
+        throw new InteractionStoreError(
+          'invalid_input',
+          'Client Capability outcome requires a Client Capability request',
+        );
+      }
+      const canonical = decodeClientCapabilityOutcome(outcome);
+      if (!isInteractionCanonicalOutcomeValidForRequest(record.request.request, canonical)) {
+        throw new InteractionStoreError('invalid_input', 'Outcome is not valid for its request');
+      }
+      const candidateGrant = grant === undefined ? undefined : decodeGrant(grant, 'input');
+      const shouldGrant =
+        canonical.kind === 'client_capability_decision' && canonical.decision === 'allow';
+      if (shouldGrant !== (candidateGrant !== undefined)) {
+        throw new InteractionStoreError(
+          'invalid_input',
+          'Allowed Client Capability outcome requires exactly one Session Grant',
+        );
+      }
+      if (
+        candidateGrant &&
+        (!isDeepStrictEqual(decodeGrantKey(candidateGrant, 'input'), {
+          sessionId: record.request.sessionId,
+          ...record.request.request.target,
+        }) ||
+          candidateGrant.grantedAt !== canonical.committedAt)
+      ) {
+        throw new InteractionStoreError(
+          'invalid_input',
+          'Client Capability Session Grant does not match its Interaction request',
+        );
+      }
+      const candidate: StoredInteractionOutcome = {
+        ...identity(record.request),
+        outcome: canonical,
+      };
+      const encoded = encode(candidate, STORED_INTERACTION_OUTCOME_MAX_BYTES)
+        .toString('utf8')
+        .trim();
+      this.#lease.database
+        .prepare(`
+          INSERT OR IGNORE INTO core_interaction_outcomes(request_id, record_json)
+          VALUES (?, ?)
+        `)
+        .run(requestId, encoded);
+      const settled = readSqliteInteraction(this.#lease, requestId);
+      if (!settled?.outcome) {
+        throw new InteractionStoreError('io_failed', 'Outcome publication produced no record');
+      }
+      const matches = interactionCanonicalOutcomesEquivalent(settled.outcome.outcome, canonical);
+      if (matches && candidateGrant) this.#commitClientCapabilitySessionGrant(candidateGrant);
+      return {
+        status: 'stable',
+        matches,
+        record: settled as InteractionRecord & { readonly outcome: StoredInteractionOutcome },
+      };
+    });
+  }
+
   async readInteraction(requestId: string): Promise<InteractionRecord | undefined> {
     assertId(requestId);
     return readSqliteInteraction(this.#lease, requestId);
@@ -372,11 +484,145 @@ class SqliteInteractionStore implements InteractionStoreWriter {
     return sortPending(requests);
   }
 
+  async readClientCapabilitySessionGrant(
+    key: ClientCapabilitySessionGrantKey,
+  ): Promise<ClientCapabilitySessionGrant | undefined> {
+    const candidate = decodeGrantKey(key, 'input');
+    const scope = clientCapabilityScopeIdentity(candidate.scope);
+    const row = this.#lease.database
+      .prepare(`
+        SELECT record_json
+        FROM core_client_capability_session_grants
+        WHERE session_id = ?
+          AND provider_id = ?
+          AND contract_id = ?
+          AND capability = ?
+          AND scope_kind = ?
+          AND scope_value = ?
+      `)
+      .get(
+        candidate.sessionId,
+        candidate.providerId,
+        candidate.contractId,
+        candidate.capability,
+        candidate.scope.kind,
+        scope,
+      ) as { record_json?: unknown } | undefined;
+    if (!row) return undefined;
+    if (typeof row.record_json !== 'string') {
+      throw new InteractionStoreError('invalid_record', 'Invalid Client Capability Session Grant');
+    }
+    const grant = decodeGrant(
+      parseJsonRecord(row.record_json, 'Client Capability Session Grant'),
+      'record',
+    );
+    if (!sameGrantAuthority(decodeGrantKey(grant, 'record'), candidate)) {
+      throw new InteractionStoreError(
+        'invalid_record',
+        'Client Capability Session Grant identity does not match row',
+      );
+    }
+    return deepFreeze(grant);
+  }
+
+  async commitClientCapabilitySessionGrant(
+    grant: ClientCapabilitySessionGrant,
+  ): Promise<ClientCapabilitySessionGrant> {
+    const candidate = decodeGrant(grant, 'input');
+    const scope = clientCapabilityScopeIdentity(candidate.scope);
+    const encoded = encode(candidate, STORED_CLIENT_CAPABILITY_SESSION_GRANT_MAX_BYTES)
+      .toString('utf8')
+      .trim();
+    return this.#lease.transaction('write', () => {
+      this.#insertClientCapabilitySessionGrant(candidate, scope, encoded);
+      const stored = this.#readClientCapabilitySessionGrant(candidate);
+      if (!stored) {
+        throw new InteractionStoreError(
+          'io_failed',
+          'Client Capability Session Grant publication produced no record',
+        );
+      }
+      return stored;
+    });
+  }
+
+  #commitClientCapabilitySessionGrant(grant: ClientCapabilitySessionGrant): void {
+    const scope = clientCapabilityScopeIdentity(grant.scope);
+    const encoded = encode(grant, STORED_CLIENT_CAPABILITY_SESSION_GRANT_MAX_BYTES)
+      .toString('utf8')
+      .trim();
+    this.#insertClientCapabilitySessionGrant(grant, scope, encoded);
+  }
+
+  #insertClientCapabilitySessionGrant(
+    grant: ClientCapabilitySessionGrant,
+    scope: string,
+    encoded: string,
+  ): void {
+    this.#lease.database
+      .prepare(`
+        INSERT OR IGNORE INTO core_client_capability_session_grants(
+          session_id, provider_id, contract_id, server_id, tool_name,
+          capability, scope_kind, scope_value, granted_at, record_json
+        ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+      `)
+      .run(
+        grant.sessionId,
+        grant.providerId,
+        grant.contractId,
+        grant.serverId,
+        grant.toolName,
+        grant.capability,
+        grant.scope.kind,
+        scope,
+        grant.grantedAt,
+        encoded,
+      );
+  }
+
+  #readClientCapabilitySessionGrant(
+    key: ClientCapabilitySessionGrantKey,
+  ): ClientCapabilitySessionGrant | undefined {
+    const scope = clientCapabilityScopeIdentity(key.scope);
+    const row = this.#lease.database
+      .prepare(`
+        SELECT record_json
+        FROM core_client_capability_session_grants
+        WHERE session_id = ? AND provider_id = ? AND contract_id = ?
+          AND capability = ?
+          AND scope_kind = ? AND scope_value = ?
+      `)
+      .get(key.sessionId, key.providerId, key.contractId, key.capability, key.scope.kind, scope) as
+      | { record_json?: unknown }
+      | undefined;
+    if (!row) return undefined;
+    if (typeof row.record_json !== 'string') {
+      throw new InteractionStoreError('invalid_record', 'Invalid Client Capability Session Grant');
+    }
+    return deepFreeze(
+      decodeGrant(parseJsonRecord(row.record_json, 'Client Capability Session Grant'), 'record'),
+    );
+  }
+
   close(): void {
     this.#lease.close();
   }
 }
 
+function sameGrantAuthority(
+  left: ClientCapabilitySessionGrantKey,
+  right: ClientCapabilitySessionGrantKey,
+): boolean {
+  return (
+    left.sessionId === right.sessionId &&
+    left.providerId === right.providerId &&
+    left.contractId === right.contractId &&
+    left.capability === right.capability &&
+    left.scope.kind === right.scope.kind &&
+    clientCapabilityScopeIdentity(left.scope) === clientCapabilityScopeIdentity(right.scope)
+  );
+}
+
 function readSqliteInteraction(
   lease: Pick<OperationalStateDatabaseLease, 'database'>,
   requestId: string,
@@ -486,6 +732,37 @@ function normalizeOutcome(
   return { ...identity(request), outcome };
 }
 
+function decodeGrant(value: unknown, source: DecodeSource): ClientCapabilitySessionGrant {
+  try {
+    return decodeClientCapabilitySessionGrant(value);
+  } catch (error) {
+    decodeFailure(source, 'Invalid Client Capability Session Grant', error);
+  }
+}
+
+function decodeGrantKey(value: unknown, source: DecodeSource): ClientCapabilitySessionGrantKey {
+  try {
+    return decodeClientCapabilitySessionGrantKey(value);
+  } catch (error) {
+    decodeFailure(source, 'Invalid Client Capability Session Grant key', error);
+  }
+}
+
+function decodeClientCapabilityOutcome(
+  value: unknown,
+): Extract<InteractionCanonicalOutcome, { kind: 'client_capability_decision' | 'closure' }> {
+  let outcome: InteractionCanonicalOutcome;
+  try {
+    outcome = decodeInteractionCanonicalOutcome(value);
+  } catch (error) {
+    decodeFailure('input', 'Invalid Client Capability Interaction outcome', error);
+  }
+  if (outcome.kind !== 'client_capability_decision' && outcome.kind !== 'closure') {
+    decodeFailure('input', 'Invalid Client Capability Interaction outcome kind');
+  }
+  return outcome;
+}
+
 function identity(value: InteractionIdentity): InteractionIdentity {
   return {
     sessionId: value.sessionId,
diff --git a/packages/storage/src/sqlite-core-execution-schema.ts b/packages/storage/src/sqlite-core-execution-schema.ts
index f6fea7c94a..9f49a23c19 100644
--- a/packages/storage/src/sqlite-core-execution-schema.ts
+++ b/packages/storage/src/sqlite-core-execution-schema.ts
@@ -112,6 +112,25 @@ export function migrateSqliteCoreExecutionDatabase(db: DatabaseSync): void {
         ON DELETE CASCADE
     );
 
+    CREATE TABLE IF NOT EXISTS core_client_capability_session_grants (
+      session_id TEXT NOT NULL,
+      provider_id TEXT NOT NULL,
+      contract_id TEXT NOT NULL,
+      server_id TEXT NOT NULL,
+      tool_name TEXT NOT NULL,
+      capability TEXT NOT NULL,
+      scope_kind TEXT NOT NULL,
+      scope_value TEXT NOT NULL,
+      granted_at INTEGER NOT NULL,
+      record_json TEXT NOT NULL,
+      PRIMARY KEY (
+        session_id, provider_id, contract_id, capability, scope_kind, scope_value
+      )
+    );
+
+    CREATE INDEX IF NOT EXISTS core_client_capability_session_grants_session
+      ON core_client_capability_session_grants(session_id, granted_at);
+
     CREATE TABLE IF NOT EXISTS core_shell_runs (
       session_id TEXT NOT NULL,
       shell_run_id TEXT NOT NULL,
diff --git a/packages/ui/src/__tests__/interaction-queue.test.ts b/packages/ui/src/__tests__/interaction-queue.test.ts
index 96b3d4e6b2..7a466d4b7f 100644
--- a/packages/ui/src/__tests__/interaction-queue.test.ts
+++ b/packages/ui/src/__tests__/interaction-queue.test.ts
@@ -31,6 +31,7 @@ import {
   dequeueInteractionByToolUseId,
   enqueueInteraction,
   reconcileInteractions,
+  reduceInteractionQueues,
   type InteractionQueues,
 } from '../interaction-queue.js';
 
@@ -74,6 +75,31 @@ describe('composer interaction queue', () => {
     assert.equal(activeInteractionFor(queues, 's')?.requestId, 'question');
   });
 
+  test('Client Capability requests enter and leave the shared queue', () => {
+    let queues = reduceInteractionQueues({}, 's', {
+      type: 'client_capability_request',
+      id: 'evt_capability',
+      turnId: 'turn_1',
+      ts: 0,
+      requestId: 'capability',
+      toolUseId: 'call_capability',
+      capability: 'browser',
+      scope: { kind: 'browser_origin', origin: 'https://example.com' },
+    });
+    assert.equal(activeInteractionFor(queues, 's')?.requestId, 'capability');
+
+    queues = reduceInteractionQueues(queues, 's', {
+      type: 'client_capability_decision_ack',
+      id: 'evt_ack',
+      turnId: 'turn_1',
+      ts: 1,
+      requestId: 'capability',
+      toolUseId: 'call_capability',
+      decision: 'allow',
+    });
+    assert.equal(activeInteractionFor(queues, 's'), undefined);
+  });
+
   test('deduplicates replays and isolates sessions', () => {
     let queues: InteractionQueues = {};
     queues = enqueueInteraction(queues, 's1', question('a'));
diff --git a/packages/ui/src/client-capability-prompt.tsx b/packages/ui/src/client-capability-prompt.tsx
new file mode 100644
index 0000000000..0e54cc1603
--- /dev/null
+++ b/packages/ui/src/client-capability-prompt.tsx
@@ -0,0 +1,113 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { Button } from '@astryxdesign/core';
+import type { ClientCapabilityRequestEvent } from '@maka/core/events';
+import { useEffect, useId, useRef, useState } from 'react';
+import { getConversationCopy } from './conversation-copy.js';
+import { useUiLocale } from './locale-context.js';
+import { useMountedRef } from './use-mounted-ref.js';
+
+export interface ClientCapabilityPromptProps {
+  request: ClientCapabilityRequestEvent;
+  onRespond(response: { requestId: string; decision: 'allow' | 'deny' }): void | Promise<void>;
+}
+
+export function ClientCapabilityPrompt({
+  request,
+  onRespond,
+}: ClientCapabilityPromptProps) {
+  const copy = getConversationCopy(useUiLocale()).clientCapability;
+  const titleId = useId();
+  const [responsePending, setResponsePending] = useState(false);
+  const responsePendingRef = useRef(false);
+  const activeRequestIdRef = useRef(request.requestId);
+  const rejectButtonRef = useRef<HTMLButtonElement>(null);
+  const mountedRef = useMountedRef();
+
+  useEffect(() => {
+    activeRequestIdRef.current = request.requestId;
+    responsePendingRef.current = false;
+    setResponsePending(false);
+    const frame = window.requestAnimationFrame(() => rejectButtonRef.current?.focus());
+    return () => window.cancelAnimationFrame(frame);
+  }, [request.requestId]);
+
+  async function respond(decision: 'allow' | 'deny'): Promise<void> {
+    if (responsePendingRef.current) return;
+    const requestId = request.requestId;
+    responsePendingRef.current = true;
+    setResponsePending(true);
+    try {
+      await onRespond({ requestId, decision });
+    } finally {
+      if (activeRequestIdRef.current === requestId) {
+        responsePendingRef.current = false;
+        if (mountedRef.current) setResponsePending(false);
+      }
+    }
+  }
+
+  return (
+    <section
+      className="maka-composer-interaction maka-sandbox-boundary-prompt composer"
+      aria-labelledby={titleId}
+    >
+      <div className="maka-composer-interaction-inner maka-sandbox-boundary-prompt-inner">
+        <div className="maka-sandbox-boundary-copy">
+          <h2 id={titleId}>{copy.title}</h2>
+          <p>{clientCapabilityLabel(request, copy)}</p>
+          <p>{copy.sessionNotice}</p>
+        </div>
+        <div className="maka-sandbox-boundary-actions">
+          <Button
+            ref={rejectButtonRef}
+            variant="secondary"
+            isDisabled={responsePending}
+            onClick={() => void respond('deny')}
+            label={copy.reject}
+          />
+          <Button
+            variant="primary"
+            isDisabled={responsePending}
+            onClick={() => void respond('allow')}
+            label={copy.allowSession}
+          />
+        </div>
+      </div>
+    </section>
+  );
+}
+
+function clientCapabilityLabel(
+  request: ClientCapabilityRequestEvent,
+  copy: ReturnType<typeof getConversationCopy>['clientCapability'],
+): string {
+  switch (request.capability) {
+    case 'browser':
+      if (request.scope.kind !== 'browser_origin') break;
+      return copy.browser(request.scope.origin);
+    case 'computer_use':
+      return copy.computerUse;
+    case 'desktop_mcp':
+      if (request.scope.kind !== 'mcp_tool') break;
+      return copy.desktopMcp(request.scope.serverId, request.scope.toolName);
+  }
+  throw new Error('Client Capability request has an invalid scope');
+}
diff --git a/packages/ui/src/components.tsx b/packages/ui/src/components.tsx
index e8d4c055be..bfd7ca1701 100644
--- a/packages/ui/src/components.tsx
+++ b/packages/ui/src/components.tsx
@@ -42,6 +42,7 @@ export { describeLoadToolResult, formatRedactedJson, formatToolIntent, loadToolD
 export { formatBytes, ToolCallDetail, ToolTrow } from './tool-activity.js';
 export { ToolResultPreview } from './tool-activity/tool-result-preview.js';
 export { SandboxBoundaryPrompt } from './sandbox-boundary-prompt.js';
+export { ClientCapabilityPrompt } from './client-capability-prompt.js';
 export { ChatSurfaceLayout } from './chat-surface-layout.js';
 export type { ChatSurfaceLayoutProps } from './chat-surface-layout.js';
 export {
diff --git a/packages/ui/src/conversation-copy.ts b/packages/ui/src/conversation-copy.ts
index 502b9aeb50..1046134007 100644
--- a/packages/ui/src/conversation-copy.ts
+++ b/packages/ui/src/conversation-copy.ts
@@ -216,6 +216,15 @@ export interface ConversationCopy {
     reject: string;
     allowSession: string;
   };
+  clientCapability: {
+    title: string;
+    browser: (origin: string) => string;
+    computerUse: string;
+    desktopMcp: (serverId: string, toolName: string) => string;
+    sessionNotice: string;
+    reject: string;
+    allowSession: string;
+  };
   questions: {
     other: string;
     otherDescription: string;
@@ -499,6 +508,15 @@ const CONVERSATION_COPY = {
       reject: '拒绝',
       allowSession: '本任务允许',
     },
+    clientCapability: {
+      title: '允许使用客户端能力?',
+      browser: (origin) => `允许 Browser 操作 ${origin}`,
+      computerUse: '允许 Computer Use 操作这台 Mac',
+      desktopMcp: (serverId, toolName) => `允许调用 ${serverId} 的 ${toolName} 工具`,
+      sessionNotice: '允许后,本任务中相同范围的后续操作将不再询问。',
+      reject: '拒绝',
+      allowSession: '本任务允许',
+    },
     questions: { other: '其他', otherDescription: '输入一个不同的答案。', otherAriaLabel: '其他答案', otherPlaceholder: '输入你的答案', stop: '停止', stopping: '停止中…', previous: '上一题', submitting: '正在提交…', submit: '提交答案', next: '下一题' },
     mentions: { noFiles: '未找到文件', noSkills: '暂无技能', noCommandsOrSkills: '没有匹配的命令或技能', filesAriaLabel: '工作区文件', skillsAriaLabel: '技能', commandsAndSkillsAriaLabel: '命令和技能', commandsGroup: '命令', skillsGroup: 'Skills', loading: '加载中…' },
     workspace: {
@@ -648,6 +666,15 @@ const CONVERSATION_COPY = {
       reject: 'Reject',
       allowSession: 'Allow for this task',
     },
+    clientCapability: {
+      title: 'Allow this client capability?',
+      browser: (origin) => `Allow Browser to operate ${origin}`,
+      computerUse: 'Allow Computer Use to operate this Mac',
+      desktopMcp: (serverId, toolName) => `Allow ${toolName} from ${serverId}`,
+      sessionNotice: 'Matching operations will be allowed for the rest of this task.',
+      reject: 'Reject',
+      allowSession: 'Allow for this task',
+    },
     questions: { other: 'Other', otherDescription: 'Enter a different answer.', otherAriaLabel: 'Other answer', otherPlaceholder: 'Enter your answer', stop: 'Stop', stopping: 'Stopping…', previous: 'Previous', submitting: 'Submitting…', submit: 'Submit answers', next: 'Next' },
     mentions: { noFiles: 'No files found', noSkills: 'No skills available', noCommandsOrSkills: 'No matching commands or skills', filesAriaLabel: 'Workspace files', skillsAriaLabel: 'Skills', commandsAndSkillsAriaLabel: 'Commands and skills', commandsGroup: 'Commands', skillsGroup: 'Skills', loading: 'Loading…' },
     workspace: {
diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts
index d9bf1e4f0d..b6a6757a5d 100644
--- a/packages/ui/src/index.ts
+++ b/packages/ui/src/index.ts
@@ -25,6 +25,7 @@ export * from './use-mounted-ref.js';
 export * from './components.js';
 export type { ComposerProps } from './components.js';
 export type { SandboxBoundaryPromptProps } from './sandbox-boundary-prompt.js';
+export type { ClientCapabilityPromptProps } from './client-capability-prompt.js';
 export type {
   ProjectRowActions,
   SessionHistoryGroup,
diff --git a/packages/ui/src/interaction-queue.ts b/packages/ui/src/interaction-queue.ts
index 915db71af3..5022da146a 100644
--- a/packages/ui/src/interaction-queue.ts
+++ b/packages/ui/src/interaction-queue.ts
@@ -17,7 +17,7 @@
  * under the License.
  */
 
-import type { ActiveInteractionRequestEvent } from '@maka/core/events';
+import type { ActiveInteractionRequestEvent, SessionEvent } from '@maka/core/events';
 
 export type ComposerInteraction = ActiveInteractionRequestEvent;
 export type InteractionQueues = Record<string, ComposerInteraction[]>;
@@ -57,6 +57,29 @@ export function clearInteractions(queues: InteractionQueues, sessionId: string):
   return { ...queues, [sessionId]: [] };
 }
 
+export function reduceInteractionQueues(
+  queues: InteractionQueues,
+  sessionId: string,
+  event: SessionEvent,
+): InteractionQueues {
+  switch (event.type) {
+    case 'sandbox_boundary_request':
+    case 'client_capability_request':
+    case 'user_question_request':
+      return enqueueInteraction(queues, sessionId, event);
+    case 'sandbox_boundary_decision_ack':
+    case 'client_capability_decision_ack':
+    case 'user_question_answer_ack':
+      return dequeueInteractionByRequestId(queues, sessionId, event.requestId);
+    case 'tool_result':
+      return dequeueInteractionByToolUseId(queues, sessionId, event.toolUseId);
+    case 'error':
+      return clearInteractions(queues, sessionId);
+    default:
+      return queues;
+  }
+}
+
 /**
  * Replace a session's queue with the runtime's live set of unanswered
  * requests, keeping the order the surface already shows. The runtime owns both


---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@maka.apache.org
For additional commands, e-mail: commits-help@maka.apache.org