Quellcode durchsuchen

fix(client): gate every full access picker

ZiyaZhang vor 1 Monat
Ursprung
Commit
6d416b06c5
36 geänderte Dateien mit 624 neuen und 195 gelöschten Zeilen
  1. 87 0
      apps/web/tests/access-confirmation.e2e.ts
  2. 10 0
      apps/web/tests/snapshots/access-confirmation/ui.expected.md
  3. 1 1
      apps/web/tests/snapshots/code-mode-round/ui.expected.md
  4. 1 1
      apps/web/tests/snapshots/cordis-tool-round/ui.expected.md
  5. 1 1
      apps/web/tests/snapshots/fresh-round-trip/ui.expected.md
  6. 1 1
      apps/web/tests/snapshots/lifecycle-chrome/hero.expected.md
  7. 1 1
      apps/web/tests/snapshots/lifecycle-chrome/reloaded.expected.md
  8. 1 1
      apps/web/tests/snapshots/live-interactions/cancel.expected.md
  9. 1 1
      apps/web/tests/snapshots/live-interactions/error-auth.expected.md
  10. 1 1
      apps/web/tests/snapshots/live-interactions/retry.expected.md
  11. 1 1
      apps/web/tests/snapshots/message-actions/ui.expected.md
  12. 1 1
      apps/web/tests/snapshots/question-composer/answered.expected.md
  13. 1 1
      apps/web/tests/snapshots/queue-actions/editing.expected.md
  14. 1 1
      apps/web/tests/snapshots/queue-actions/ui.expected.md
  15. 1 1
      apps/web/tests/snapshots/seeded-history/ui.expected.md
  16. 1 1
      apps/web/tests/snapshots/steering/settled.expected.md
  17. 2 1
      apps/web/tsconfig.json
  18. 70 51
      packages/client/ui-command/src/client/PopupSelectView.tsx
  19. 11 0
      packages/client/ui-command/src/client/contract.ts
  20. 1 1
      packages/client/ui-command/src/client/index.ts
  21. 47 6
      packages/client/ui-command/src/client/popup.ts
  22. 42 0
      packages/client/ui-command/tests/popup-view.spec.tsx
  23. 43 0
      packages/client/ui-command/tests/popup.spec.ts
  24. 0 70
      packages/client/ui-conversation/src/client/skeleton/PermissionSelect.module.css
  25. 24 39
      packages/client/ui-conversation/src/client/skeleton/PermissionSelect.tsx
  26. 36 0
      packages/client/ui-conversation/tests/input-bar.spec.tsx
  27. 3 0
      packages/client/ui-permission/package.json
  28. 45 7
      packages/client/ui-permission/src/client/index.ts
  29. 19 1
      packages/client/ui-permission/tests/browser-plugin.spec.ts
  30. 6 4
      packages/client/ui-primitives/src/Modal.tsx
  31. 73 0
      packages/client/ui-primitives/src/RiskConfirmation.module.css
  32. 80 0
      packages/client/ui-primitives/src/RiskConfirmation.tsx
  33. 2 0
      packages/client/ui-primitives/src/index.ts
  34. 5 1
      packages/client/ui-primitives/tests/atoms.spec.tsx
  35. 3 0
      pnpm-lock.yaml
  36. 1 0
      tsconfig.host.json

+ 87 - 0
apps/web/tests/access-confirmation.e2e.ts

@@ -0,0 +1,87 @@
+// Web e2e scenario: every visible permission picker gates Full access behind
+// the same locale-aware, in-page risk confirmation. Zero model calls: the
+// scenario boots the shipped Web composition and exercises the real
+// permission projection, client command path, HTTP RPC, and pushed update.
+import { fileURLToPath } from 'node:url'
+import { join } from 'node:path'
+import type { Browser, Page } from 'playwright'
+import { chromium } from 'playwright'
+import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
+import {
+  assertFixtureInventory, captureStableAria, compareOrRefreshGolden,
+  launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold,
+} from './scaffold.ts'
+import { connectFreshWorkspace, saveFailureShot } from './support.ts'
+
+const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/access-confirmation', import.meta.url))
+const UI_EXPECTED = join(SNAPSHOT_DIR, 'ui.expected.md')
+const MODE = webSnapshotMode()
+
+describe('web e2e: Full access confirmation', () => {
+  let scaffold: WebScaffold
+  let browser: Browser
+  let page: Page
+  let tripwire: ReturnType<typeof watchConsole>
+
+  beforeAll(async () => {
+    scaffold = await launchWebScaffold({})
+    // CI uses Playwright's pinned browser. A developer may point this one
+    // scenario at an installed Chromium when the matching browser download
+    // is temporarily unavailable.
+    const executablePath = process.env.DSH_PLAYWRIGHT_EXECUTABLE_PATH
+    browser = await chromium.launch(executablePath === undefined ? {} : { executablePath })
+    // Keep the product default Chinese locale: the golden pins the actual
+    // registered dictionary rather than a test-local translation callback.
+    page = await browser.newPage({ viewport: { width: 1680, height: 1000 } })
+    tripwire = watchConsole(page)
+    await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
+    await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
+    await connectFreshWorkspace(page)
+  }, 120_000)
+
+  afterAll(async () => {
+    await browser?.close()
+    await scaffold?.close()
+  })
+
+  it('requires acknowledgement before the composer picker can enable Full access', async () => {
+    onTestFailed(() => saveFailureShot(page, 'web-e2e-full-access-confirmation'))
+    const access = page.locator('button[aria-label^="Access mode"]').first()
+    await access.waitFor({ timeout: 10_000 })
+
+    // Normalize the starting preset through the real command path. The
+    // shipped web config may already start at Full access.
+    if ((await access.getAttribute('aria-label'))?.endsWith('Full access') === true) {
+      await access.click()
+      await page.getByRole('menuitem', { name: 'Workspace Write' }).click()
+      await expect.poll(() => access.getAttribute('aria-label'), { timeout: 10_000 })
+        .toBe('Access mode, current: Workspace Write')
+    }
+
+    await access.click()
+    await page.getByRole('menuitem', { name: 'Full access' }).click()
+    const dialog = page.getByRole('dialog', { name: '确认启用 Full access?' })
+    await dialog.waitFor({ timeout: 10_000 })
+    const enable = dialog.getByRole('button', { name: '启用 Full access' })
+    expect(await enable.isDisabled()).toBe(true)
+
+    // The modal is in this page's body (not a native/new window) and escapes
+    // the sticky composer's stacking context.
+    expect(await dialog.evaluate(node => node.parentElement?.parentElement === document.body)).toBe(true)
+    const snapshot = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd)
+    await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE)
+
+    await dialog.getByRole('checkbox', { name: '我已了解风险,并愿意继续' }).check()
+    expect(await enable.isEnabled()).toBe(true)
+    await enable.click()
+    await expect.poll(() => access.getAttribute('aria-label'), { timeout: 10_000 })
+      .toBe('Access mode, current: Full access')
+    expect(await dialog.count()).toBe(0)
+    expect(tripwire.pageErrors).toEqual([])
+  }, 60_000)
+
+  it('keeps its snapshot inventory closed', async () => {
+    expect(tripwire.warnings).toEqual([])
+    await assertFixtureInventory(SNAPSHOT_DIR, ['ui.expected.md'])
+  })
+})

+ 10 - 0
apps/web/tests/snapshots/access-confirmation/ui.expected.md

@@ -0,0 +1,10 @@
+- dialog "确认启用 Full access?":
+  - heading "确认启用 Full access?" [level=2]
+  - button "Close":
+    - img
+  - img
+  - paragraph: 启用 Full access 后,agent 将减少确认步骤,并且可以直接执行更多操作,包括敏感操作、文件修改或外部命令。仅建议在你信任当前任务时使用。
+  - checkbox "我已了解风险,并愿意继续"
+  - text: 我已了解风险,并愿意继续
+  - button "取消"
+  - button "启用 Full access" [disabled]

+ 1 - 1
apps/web/tests/snapshots/code-mode-round/ui.expected.md

@@ -35,7 +35,7 @@
 - textbox "Message the agent"
 - button "Add attachment":
   - img
-- 'button "Access mode, current: Danger Full Access"': Danger Full Access
+- 'button "Access mode, current: Full access"': Full access
 - button "Plan mode off, press to turn on": Plan off
 - button "Select model, current DeepSeek-V4-Flash":
   - text: DeepSeek-V4-Flash

+ 1 - 1
apps/web/tests/snapshots/cordis-tool-round/ui.expected.md

@@ -49,7 +49,7 @@
 - textbox "Message the agent"
 - button "Add attachment":
   - img
-- 'button "Access mode, current: Danger Full Access"': Danger Full Access
+- 'button "Access mode, current: Full access"': Full access
 - button "Plan mode off, press to turn on": Plan off
 - button "Select model, current DeepSeek-V4-Flash":
   - text: DeepSeek-V4-Flash

+ 1 - 1
apps/web/tests/snapshots/fresh-round-trip/ui.expected.md

@@ -32,7 +32,7 @@
 - textbox "Message the agent"
 - button "Add attachment":
   - img
-- 'button "Access mode, current: Danger Full Access"': Danger Full Access
+- 'button "Access mode, current: Full access"': Full access
 - button "Plan mode off, press to turn on": Plan off
 - button "Select model, current DeepSeek-V4-Flash":
   - text: DeepSeek-V4-Flash

+ 1 - 1
apps/web/tests/snapshots/lifecycle-chrome/hero.expected.md

@@ -28,7 +28,7 @@
 - textbox "Describe what you want to build"
 - button "Add attachment":
   - img
-- 'button "Access mode, current: Danger Full Access"': Danger Full Access
+- 'button "Access mode, current: Full access"': Full access
 - button "Plan mode off, press to turn on": Plan off
 - button "Select model, current DeepSeek-V4-Flash":
   - text: DeepSeek-V4-Flash

+ 1 - 1
apps/web/tests/snapshots/lifecycle-chrome/reloaded.expected.md

@@ -24,7 +24,7 @@
 - textbox "Message the agent"
 - button "Add attachment":
   - img
-- 'button "Access mode, current: Danger Full Access"': Danger Full Access
+- 'button "Access mode, current: Full access"': Full access
 - button "Plan mode off, press to turn on": Plan off
 - button "Select model, current DeepSeek-V4-Flash":
   - text: DeepSeek-V4-Flash

+ 1 - 1
apps/web/tests/snapshots/live-interactions/cancel.expected.md

@@ -21,7 +21,7 @@
 - textbox "Message the agent"
 - button "Add attachment":
   - img
-- 'button "Access mode, current: Danger Full Access"': Danger Full Access
+- 'button "Access mode, current: Full access"': Full access
 - button "Plan mode off, press to turn on": Plan off
 - button "Select model, current DeepSeek-V4-Flash":
   - text: DeepSeek-V4-Flash

+ 1 - 1
apps/web/tests/snapshots/live-interactions/error-auth.expected.md

@@ -14,7 +14,7 @@
 - textbox "Message the agent"
 - button "Add attachment":
   - img
-- 'button "Access mode, current: Danger Full Access"': Danger Full Access
+- 'button "Access mode, current: Full access"': Full access
 - button "Plan mode off, press to turn on": Plan off
 - button "Select model, current DeepSeek-V4-Flash":
   - text: DeepSeek-V4-Flash

+ 1 - 1
apps/web/tests/snapshots/live-interactions/retry.expected.md

@@ -24,7 +24,7 @@
 - textbox "Message the agent"
 - button "Add attachment":
   - img
-- 'button "Access mode, current: Danger Full Access"': Danger Full Access
+- 'button "Access mode, current: Full access"': Full access
 - button "Plan mode off, press to turn on": Plan off
 - button "Select model, current DeepSeek-V4-Flash":
   - text: DeepSeek-V4-Flash

+ 1 - 1
apps/web/tests/snapshots/message-actions/ui.expected.md

@@ -35,7 +35,7 @@
 - textbox "Message the agent"
 - button "Add attachment":
   - img
-- 'button "Access mode, current: Danger Full Access"': Danger Full Access
+- 'button "Access mode, current: Full access"': Full access
 - button "Plan mode off, press to turn on": Plan off
 - button "Select model, current deepseek-v4-flash":
   - text: deepseek-v4-flash

+ 1 - 1
apps/web/tests/snapshots/question-composer/answered.expected.md

@@ -32,7 +32,7 @@
 - textbox "Message the agent"
 - button "Add attachment":
   - img
-- 'button "Access mode, current: Danger Full Access"': Danger Full Access
+- 'button "Access mode, current: Full access"': Full access
 - button "Plan mode off, press to turn on": Plan off
 - button "Select model, current DeepSeek-V4-Flash":
   - text: DeepSeek-V4-Flash

+ 1 - 1
apps/web/tests/snapshots/queue-actions/editing.expected.md

@@ -28,7 +28,7 @@
 - textbox "Message the agent"
 - button "Add attachment":
   - img
-- 'button "Access mode, current: Danger Full Access"': Danger Full Access
+- 'button "Access mode, current: Full access"': Full access
 - button "Plan mode off, press to turn on": Plan off
 - button "Select model, current DeepSeek-V4-Flash":
   - text: DeepSeek-V4-Flash

+ 1 - 1
apps/web/tests/snapshots/queue-actions/ui.expected.md

@@ -22,7 +22,7 @@
 - textbox "Message the agent"
 - button "Add attachment":
   - img
-- 'button "Access mode, current: Danger Full Access"': Danger Full Access
+- 'button "Access mode, current: Full access"': Full access
 - button "Plan mode off, press to turn on": Plan off
 - button "Select model, current DeepSeek-V4-Flash":
   - text: DeepSeek-V4-Flash

+ 1 - 1
apps/web/tests/snapshots/seeded-history/ui.expected.md

@@ -38,7 +38,7 @@
 - textbox "Message the agent"
 - button "Add attachment":
   - img
-- 'button "Access mode, current: Danger Full Access"': Danger Full Access
+- 'button "Access mode, current: Full access"': Full access
 - button "Plan mode off, press to turn on": Plan off
 - button "Select model, current deepseek-v4-flash":
   - text: deepseek-v4-flash

+ 1 - 1
apps/web/tests/snapshots/steering/settled.expected.md

@@ -32,7 +32,7 @@
 - textbox "Message the agent"
 - button "Add attachment":
   - img
-- 'button "Access mode, current: Danger Full Access"': Danger Full Access
+- 'button "Access mode, current: Full access"': Full access
 - button "Plan mode off, press to turn on": Plan off
 - button "Select model, current DeepSeek-V4-Flash":
   - text: DeepSeek-V4-Flash

+ 2 - 1
apps/web/tsconfig.json

@@ -38,7 +38,8 @@
     "tests/cordis-tool-round.e2e.ts",
     "tests/message-actions.e2e.ts",
     "tests/queue-actions.e2e.ts",
-    "tests/skill-invocation-policy.e2e.ts"
+    "tests/skill-invocation-policy.e2e.ts",
+    "tests/access-confirmation.e2e.ts"
   ],
   "references": [
     {

+ 70 - 51
packages/client/ui-command/src/client/PopupSelectView.tsx

@@ -12,7 +12,7 @@
 import { useEffect, useRef } from 'react'
 import { useSyncExternalStore } from 'react'
 import clsx from 'clsx'
-import { IconCheckOutline16, useAnchoredMaxHeight } from '@deepseek-ai/dsh-client-ui-primitives'
+import { IconCheckOutline16, RiskConfirmation, useAnchoredMaxHeight } from '@deepseek-ai/dsh-client-ui-primitives'
 import { filterOptions } from './popup.ts'
 import type { PopupSelectController } from './popup.ts'
 import css from './PopupSelectView.module.css'
@@ -56,23 +56,24 @@ export function PopupSelectView({ popup }: PopupSelectInjected) {
   // closes the shell before its own handlers run; that click's target then
   // takes focus naturally, so no focusComposer here.
   useEffect(() => {
-    if (!state.open) return
+    if (!state.open || state.confirming !== null) return
     const onPointerDown = (ev: PointerEvent): void => {
       if (cardRef.current !== null && ev.target instanceof Node && cardRef.current.contains(ev.target)) return
       popup.dismiss()
     }
     document.addEventListener('pointerdown', onPointerDown, true)
     return () => { document.removeEventListener('pointerdown', onPointerDown, true) }
-  }, [state.open, popup])
+  }, [state.open, state.confirming, popup])
 
   // Focus the search input after it mounts (separate effect so the ref is populated).
   useEffect(() => {
-    if (state.open) searchRef.current?.focus()
-  }, [state.open])
+    if (state.open && state.confirming === null) searchRef.current?.focus()
+  }, [state.open, state.confirming])
 
   if (!state.open) return null
 
   const rows = filterOptions(state.options, state.search)
+  const confirmation = state.confirming?.confirmation
 
   const onKeyDown = (ev: React.KeyboardEvent<HTMLDivElement>): void => {
     // ArrowLeft/ArrowRight fall through on purpose: the search input keeps
@@ -99,55 +100,73 @@ export function PopupSelectView({ popup }: PopupSelectInjected) {
   }
 
   return (
-    <div
-      ref={cardRef}
-      className={css.card}
-      style={{ maxHeight }}
-      aria-label={`/${String(state.command)} options`}
-      onKeyDown={onKeyDown}
-    >
-      <input
-        ref={searchRef}
-        className={css.search}
-        type="text"
-        placeholder="Search…"
-        aria-label="Filter options"
-        value={state.search}
-        readOnly={state.submitting}
-        onChange={(ev) => { popup.setSearch(ev.currentTarget.value) }}
-      />
-      {state.error !== null && (
-        <div className={css.error} role="alert">
-          <span className={css.errorText}>{state.error}</span>
-          {state.status === 'failed' && (
-            <button type="button" className={css.retry} onClick={() => { popup.retry() }}>Retry</button>
+    <>
+      {state.confirming === null && (
+        <div
+          ref={cardRef}
+          className={css.card}
+          style={{ maxHeight }}
+          aria-label={`/${String(state.command)} options`}
+          onKeyDown={onKeyDown}
+        >
+          <input
+            ref={searchRef}
+            className={css.search}
+            type="text"
+            placeholder="Search…"
+            aria-label="Filter options"
+            value={state.search}
+            readOnly={state.submitting}
+            onChange={(ev) => { popup.setSearch(ev.currentTarget.value) }}
+          />
+          {state.error !== null && (
+            <div className={css.error} role="alert">
+              <span className={css.errorText}>{state.error}</span>
+              {state.status === 'failed' && (
+                <button type="button" className={css.retry} onClick={() => { popup.retry() }}>Retry</button>
+              )}
+            </div>
           )}
-        </div>
-      )}
-      {state.status === 'pending' && <div className={css.status}>Loading options…</div>}
-      {state.submitting && <div className={css.status}>Applying…</div>}
-      {state.status === 'ready' && rows.length === 0 && <div className={css.status}>No options</div>}
-      {state.status === 'ready' && (
-        <div role="listbox" aria-label={`/${String(state.command)} matches`} className={css.viewport}>
-          {rows.map((option, index) => (
-            <div
-              key={option.id}
-              role="option"
-              aria-selected={index === state.active}
-              className={clsx(css.row, index === state.active && css.rowActive)}
-              // mousedown would race the document capture listener; the shell
-              // owns focus anyway, so a plain click (inside the card → no
-              // dismiss) works.
-              onClick={() => { void popup.select(index) }}
-              onMouseEnter={() => { popup.highlight(index) }}
-            >
-              <span className={css.label}>{option.label}</span>
-              {option.detail !== undefined && <span className={css.detail}>{option.detail}</span>}
-              {option.active === true && <span className={css.check}><IconCheckOutline16 /></span>}
+          {state.status === 'pending' && <div className={css.status}>Loading options…</div>}
+          {state.submitting && <div className={css.status}>Applying…</div>}
+          {state.status === 'ready' && rows.length === 0 && <div className={css.status}>No options</div>}
+          {state.status === 'ready' && (
+            <div role="listbox" aria-label={`/${String(state.command)} matches`} className={css.viewport}>
+              {rows.map((option, index) => (
+                <div
+                  key={option.id}
+                  role="option"
+                  aria-selected={index === state.active}
+                  className={clsx(css.row, index === state.active && css.rowActive)}
+                  // mousedown would race the document capture listener; the shell
+                  // owns focus anyway, so a plain click (inside the card → no
+                  // dismiss) works.
+                  onClick={() => { void popup.select(index) }}
+                  onMouseEnter={() => { popup.highlight(index) }}
+                >
+                  <span className={css.label}>{option.label}</span>
+                  {option.detail !== undefined && <span className={css.detail}>{option.detail}</span>}
+                  {option.active === true && <span className={css.check}><IconCheckOutline16 /></span>}
+                </div>
+              ))}
             </div>
-          ))}
+          )}
         </div>
       )}
-    </div>
+      {confirmation !== undefined && (
+        <RiskConfirmation
+          open
+          title={confirmation.title}
+          description={confirmation.description}
+          acknowledgeLabel={confirmation.acknowledgeLabel}
+          cancelLabel={confirmation.cancelLabel}
+          confirmLabel={confirmation.confirmLabel}
+          acknowledged={state.acknowledged}
+          onAcknowledgedChange={(value) => { popup.acknowledge(value) }}
+          onCancel={() => { popup.cancelConfirmation() }}
+          onConfirm={() => { void popup.confirm() }}
+        />
+      )}
+    </>
   )
 }

+ 11 - 0
packages/client/ui-command/src/client/contract.ts

@@ -6,12 +6,23 @@
 import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
 import type { ClientSessionContext } from '@deepseek-ai/dsh-client-ui-slash/client'
 
+/** Copy for an option that must be acknowledged before onSelect can run. */
+export interface SelectConfirmation {
+  readonly title: string
+  readonly description: string
+  readonly acknowledgeLabel: string
+  readonly cancelLabel: string
+  readonly confirmLabel: string
+}
+
 /** One option row of a popupSelect shell. */
 export interface SelectOption {
   readonly id: string
   readonly label: string
   readonly detail?: string
   readonly active?: boolean
+  /** Optional in-page risk gate owned by the shared popup shell. */
+  readonly confirmation?: SelectConfirmation
 }
 
 /**

+ 1 - 1
packages/client/ui-command/src/client/index.ts

@@ -21,7 +21,7 @@ export { filterOptions, PopupSelectController } from './popup.ts'
 export type { PopupSelectDeps, PopupSpec, PopupState, TokenSegment } from './popup.ts'
 export type { PopupSelectInjected } from './PopupSelectView.tsx'
 export type {
-  CommandContribution, CommandDecoration, CommandServiceContract, CommandUiSpec, SelectOption,
+  CommandContribution, CommandDecoration, CommandServiceContract, CommandUiSpec, SelectConfirmation, SelectOption,
 } from './contract.ts'
 
 declare module 'cordis' {

+ 47 - 6
packages/client/ui-command/src/client/popup.ts

@@ -67,12 +67,17 @@ export interface PopupState {
   readonly active: number
   /** A select() settlement is in flight: further select/search/highlight no-op until it settles. */
   readonly submitting: boolean
+  /** Option waiting for explicit risk acknowledgement; null during normal selection. */
+  readonly confirming: SelectOption | null
+  /** Caller-controlled checkbox state for the pending confirmation. */
+  readonly acknowledged: boolean
   /** Surfaced settlement failure (options load or onSelect); null when none. */
   readonly error: string | null
 }
 
 const CLOSED: PopupState = {
-  open: false, command: null, status: 'pending', options: [], search: '', active: 0, submitting: false, error: null,
+  open: false, command: null, status: 'pending', options: [], search: '', active: 0,
+  submitting: false, confirming: null, acknowledged: false, error: null,
 }
 
 /**
@@ -166,7 +171,7 @@ export class PopupSelectController<TCtx = unknown> {
    */
   setSearch(search: string): void {
     const s = this.state.getSnapshot()
-    if (!s.open || s.submitting || search === s.search) return
+    if (!s.open || s.submitting || s.confirming !== null || search === s.search) return
     this.state.set({ ...s, search, active: 0 })
   }
 
@@ -177,7 +182,7 @@ export class PopupSelectController<TCtx = unknown> {
    */
   move(dir: 1 | -1): void {
     const s = this.state.getSnapshot()
-    if (!s.open || s.status !== 'ready' || s.submitting) return
+    if (!s.open || s.status !== 'ready' || s.submitting || s.confirming !== null) return
     const rows = filterOptions(s.options, s.search)
     if (rows.length === 0) return
     const active = (s.active + dir + rows.length) % rows.length
@@ -191,7 +196,7 @@ export class PopupSelectController<TCtx = unknown> {
    */
   highlight(index: number): void {
     const s = this.state.getSnapshot()
-    if (!s.open || s.status !== 'ready' || s.submitting) return
+    if (!s.open || s.status !== 'ready' || s.submitting || s.confirming !== null) return
     if (index < 0 || index >= filterOptions(s.options, s.search).length || index === s.active) return
     this.state.set({ ...s, active: index })
   }
@@ -209,10 +214,46 @@ export class PopupSelectController<TCtx = unknown> {
   async select(index: number): Promise<void> {
     const binding = this.binding
     const s = this.state.getSnapshot()
-    if (binding === null || !s.open || s.status !== 'ready' || s.submitting) return
+    if (binding === null || !s.open || s.status !== 'ready' || s.submitting || s.confirming !== null) return
     const option = filterOptions(s.options, s.search)[index]
     if (option === undefined) return
-    this.state.set({ ...s, submitting: true, error: null })
+    if (option.confirmation !== undefined) {
+      this.state.set({ ...s, confirming: option, acknowledged: false, error: null })
+      return
+    }
+    await this.settle(binding, option)
+  }
+
+  /**
+   * Update the explicit checkbox for the currently pending risk gate.
+   * @param acknowledged - whether the user has acknowledged the displayed risk.
+   */
+  acknowledge(acknowledged: boolean): void {
+    const s = this.state.getSnapshot()
+    if (!s.open || s.submitting || s.confirming === null || s.acknowledged === acknowledged) return
+    this.state.set({ ...s, acknowledged })
+  }
+
+  /** Cancel only the risk gate and return to the still-open option picker. */
+  cancelConfirmation(): void {
+    const s = this.state.getSnapshot()
+    if (!s.open || s.submitting || s.confirming === null) return
+    this.state.set({ ...s, confirming: null, acknowledged: false })
+  }
+
+  /** Settle the gated option only after the checkbox is acknowledged. */
+  async confirm(): Promise<void> {
+    const binding = this.binding
+    const s = this.state.getSnapshot()
+    if (binding === null || !s.open || s.submitting || s.confirming === null || !s.acknowledged) return
+    await this.settle(binding, s.confirming)
+  }
+
+  /** Run the business settlement for an already admitted option. */
+  private async settle(binding: OpenBinding<TCtx>, option: SelectOption): Promise<void> {
+    const s = this.state.getSnapshot()
+    if (this.binding !== binding || !s.open || s.submitting) return
+    this.state.set({ ...s, submitting: true, confirming: null, acknowledged: false, error: null })
     try {
       await binding.spec.onSelect(option, binding.context)
     } catch (error) {

+ 42 - 0
packages/client/ui-command/tests/popup-view.spec.tsx

@@ -32,6 +32,17 @@ const OPTIONS: SelectOption[] = [
   { id: 'light', label: 'Light', active: true },
   { id: 'sepia', label: 'Sepia', detail: 'warm' },
 ]
+const GATED: SelectOption = {
+  id: 'full',
+  label: 'Full access',
+  confirmation: {
+    title: 'Enable Full access?',
+    description: 'Sensitive operations.',
+    acknowledgeLabel: 'I understand the risks',
+    cancelLabel: 'Cancel',
+    confirmLabel: 'Enable Full access',
+  },
+}
 
 const SEGMENT: TokenSegment = { via: 'enter', token: '/theme' }
 
@@ -143,6 +154,37 @@ describe('PopupSelectView', () => {
     expect(view.container.childElementCount).toBe(0)
   })
 
+  it('renders a gated option as an in-page modal and requires the checkbox before onSelect', async () => {
+    const onSelect = vi.fn()
+    const { popup, consume } = await mountOpen({
+      options: () => Promise.resolve([GATED]),
+      onSelect,
+    })
+    await act(async () => { fireEvent.click(screen.getByRole('option', { name: 'Full access' })) })
+    expect(screen.queryByLabelText('/theme options')).toBeNull()
+    expect(screen.getByRole('dialog', { name: 'Enable Full access?' })).toBeTruthy()
+    const enable = screen.getByRole('button', { name: 'Enable Full access' }) as HTMLButtonElement
+    expect(enable.disabled).toBe(true)
+    expect(onSelect).not.toHaveBeenCalled()
+
+    fireEvent.click(screen.getByRole('checkbox', { name: 'I understand the risks' }))
+    expect(enable.disabled).toBe(false)
+    await act(async () => { fireEvent.click(enable) })
+    expect(onSelect).toHaveBeenCalledExactlyOnceWith(GATED, 'ctx-A')
+    expect(consume).toHaveBeenCalledExactlyOnceWith(SEGMENT)
+    expect(popup.state.getSnapshot().open).toBe(false)
+  })
+
+  it('canceling a gated option returns to the picker with acknowledgement reset', async () => {
+    await mountOpen({ options: () => Promise.resolve([GATED]) })
+    await act(async () => { fireEvent.click(screen.getByRole('option', { name: 'Full access' })) })
+    fireEvent.click(screen.getByRole('checkbox'))
+    fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
+    expect(screen.getByLabelText('/theme options')).toBeTruthy()
+    await act(async () => { fireEvent.click(screen.getByRole('option', { name: 'Full access' })) })
+    expect(screen.getByRole<HTMLInputElement>('checkbox').checked).toBe(false)
+  })
+
   it('submitting shows pending, locks the search input, and further Enter/click no-op', async () => {
     let release!: () => void
     const onSelect = vi.fn(() => new Promise<void>((resolve) => { release = resolve }))

+ 43 - 0
packages/client/ui-command/tests/popup.spec.ts

@@ -19,6 +19,17 @@ const OPTIONS: SelectOption[] = [
   { id: 'light', label: 'Light', active: true },
   { id: 'sepia', label: 'Sepia', detail: 'warm' },
 ]
+const GATED: SelectOption = {
+  id: 'full',
+  label: 'Full access',
+  confirmation: {
+    title: 'Enable Full access?',
+    description: 'Sensitive operations.',
+    acknowledgeLabel: 'I understand',
+    cancelLabel: 'Cancel',
+    confirmLabel: 'Enable Full access',
+  },
+}
 
 const SEGMENT: TokenSegment = { via: 'enter', token: '/theme' }
 
@@ -200,6 +211,38 @@ describe('search / move / highlight over the filtered list', () => {
 })
 
 describe('select', () => {
+  it('gates a confirmed option until acknowledgement, then settles through the original binding', async () => {
+    const onSelect = vi.fn()
+    const deps = makeDeps()
+    const { popup } = await readyPopup({ options: () => Promise.resolve([GATED]), onSelect }, deps)
+    await popup.select(0)
+    expect(popup.state.getSnapshot()).toMatchObject({
+      open: true, confirming: GATED, acknowledged: false, submitting: false,
+    })
+    expect(onSelect).not.toHaveBeenCalled()
+    await popup.confirm()
+    expect(onSelect).not.toHaveBeenCalled()
+    popup.acknowledge(true)
+    await popup.confirm()
+    expect(onSelect).toHaveBeenCalledExactlyOnceWith(GATED, CTX_A)
+    expect(deps.consume).toHaveBeenCalledExactlyOnceWith(SEGMENT)
+    expect(popup.state.getSnapshot().open).toBe(false)
+  })
+
+  it('cancels a confirmation back to the picker without selecting or consuming', async () => {
+    const onSelect = vi.fn()
+    const deps = makeDeps()
+    const { popup } = await readyPopup({ options: () => Promise.resolve([GATED]), onSelect }, deps)
+    await popup.select(0)
+    popup.acknowledge(true)
+    popup.cancelConfirmation()
+    expect(popup.state.getSnapshot()).toMatchObject({
+      open: true, confirming: null, acknowledged: false, submitting: false,
+    })
+    expect(onSelect).not.toHaveBeenCalled()
+    expect(deps.consume).not.toHaveBeenCalled()
+  })
+
   it('runs onSelect with the filtered option and the open-time context, consumes, closes, refocuses', async () => {
     const seen: Array<{ option: SelectOption; context: Ctx }> = []
     const deps = makeDeps()

+ 0 - 70
packages/client/ui-conversation/src/client/skeleton/PermissionSelect.module.css

@@ -41,73 +41,3 @@
   flex: 0 0 auto;
   color: var(--dsw-alias-label-caption);
 }
-
-.confirmation {
-  width: min(440px, 100%);
-  max-height: calc(100vh - 48px);
-  overflow: hidden;
-}
-
-.confirmationContent {
-  min-height: 0;
-  overflow-y: auto;
-  overscroll-behavior: contain;
-}
-
-@supports (height: 100dvh) {
-  .confirmation {
-    max-height: calc(100dvh - 48px);
-  }
-}
-
-.warning {
-  display: flex;
-  align-items: flex-start;
-  gap: 10px;
-  color: var(--dsw-alias-label-secondary);
-  font-size: 14px;
-  line-height: 22px;
-}
-
-.warning p {
-  margin: 0;
-}
-
-.warningIcon {
-  flex: none;
-  margin-top: 2px;
-  color: var(--dsw-alias-state-error-primary);
-}
-
-.acknowledgement {
-  display: flex;
-  align-items: flex-start;
-  gap: 10px;
-  margin-top: 20px;
-  color: var(--dsw-alias-label-primary);
-  font-size: 14px;
-  line-height: 22px;
-  cursor: pointer;
-}
-
-.acknowledgement input {
-  flex: none;
-  width: 16px;
-  height: 16px;
-  margin: 3px 0 0;
-  accent-color: var(--dsw-alias-button-primary-fill);
-  cursor: pointer;
-}
-
-.acknowledgement input:focus-visible {
-  outline: 2px solid var(--dsw-alias-border-l4);
-  outline-offset: 2px;
-}
-
-.modalAction {
-  min-width: 72px;
-}
-
-.confirmAction {
-  min-width: 136px;
-}

+ 24 - 39
packages/client/ui-conversation/src/client/skeleton/PermissionSelect.tsx

@@ -1,6 +1,6 @@
-import { useState } from 'react'
+import { useEffect, useState } from 'react'
 import type { PermissionSelect as PermissionSelectValue } from '@deepseek-ai/dsh-permission/client'
-import { Button, IconWarningOutline16, Menu, Modal } from '@deepseek-ai/dsh-client-ui-primitives'
+import { Menu, RiskConfirmation } from '@deepseek-ai/dsh-client-ui-primitives'
 import type { MenuEntry } from '@deepseek-ai/dsh-client-ui-primitives'
 import css from './PermissionSelect.module.css'
 
@@ -9,8 +9,9 @@ const FULL_ACCESS = 'danger-full-access'
 /**
  * Display transform: kebab-case machine names render as title-case labels
  * (`workspace-write` → `Workspace Write`); non-kebab host-configured names
- * pass through. Twin of the /permission popup's (client ui-permission) — the
- * two permission surfaces must show the same text.
+ * pass through. Full access intentionally overrides the machine-name
+ * transform so both permission surfaces use the product label `Full access`;
+ * the warning body remains locale-aware.
  */
 function displayName(name: string): string {
   if (!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(name)) return name
@@ -34,6 +35,13 @@ export function PermissionSelect({ value, locked, command, t }: PermissionSelect
   const [confirmation, setConfirmation] = useState<string | null>(null)
   const [acknowledged, setAcknowledged] = useState(false)
 
+  useEffect(() => {
+    if (!locked && value !== undefined) return
+    setOpen(false)
+    setAcknowledged(false)
+    setConfirmation(null)
+  }, [locked, value])
+
   if (value === undefined) return null
 
   const currentValue = pick ?? value.currentValue
@@ -68,7 +76,7 @@ export function PermissionSelect({ value, locked, command, t }: PermissionSelect
   }
 
   const confirmFullAccess = (): void => {
-    if (!acknowledged || confirmation === null) return
+    if (locked || !acknowledged || confirmation === null) return
     const id = confirmation
     closeConfirmation()
     submit(id)
@@ -99,42 +107,19 @@ export function PermissionSelect({ value, locked, command, t }: PermissionSelect
           </button>
         }
       />
-      <Modal
+      <RiskConfirmation
         open={confirmation !== null}
-        onClose={closeConfirmation}
         title={t('confirm.title')}
-        className={css.confirmation ?? ''}
-        contentClassName={css.confirmationContent ?? ''}
-        footer={(
-          <>
-            <Button variant="outline" className={css.modalAction} onClick={closeConfirmation}>
-              {t('confirm.cancel')}
-            </Button>
-            <Button
-              variant="primary"
-              className={css.confirmAction}
-              disabled={!acknowledged}
-              onClick={confirmFullAccess}
-            >
-              {t('confirm.enable')}
-            </Button>
-          </>
-        )}
-      >
-        <div className={css.warning}>
-          <IconWarningOutline16 size={18} className={css.warningIcon} />
-          <p>{t('confirm.description')}</p>
-        </div>
-        <label className={css.acknowledgement}>
-          <input
-            type="checkbox"
-            checked={acknowledged}
-            autoFocus
-            onChange={(event) => { setAcknowledged(event.currentTarget.checked) }}
-          />
-          <span>{t('confirm.acknowledge')}</span>
-        </label>
-      </Modal>
+        description={t('confirm.description')}
+        acknowledgeLabel={t('confirm.acknowledge')}
+        cancelLabel={t('confirm.cancel')}
+        confirmLabel={t('confirm.enable')}
+        acknowledged={acknowledged}
+        disabled={locked}
+        onAcknowledgedChange={setAcknowledged}
+        onCancel={closeConfirmation}
+        onConfirm={confirmFullAccess}
+      />
     </>
   )
 }

+ 36 - 0
packages/client/ui-conversation/tests/input-bar.spec.tsx

@@ -541,6 +541,42 @@ describe('placeholder chrome and control seats', () => {
     expect((view.getByRole('button', { name: 'Enable Full access' }) as HTMLButtonElement).disabled).toBe(true)
   })
 
+  it('revokes an open Full access confirmation when the task locks', () => {
+    const command = vi.fn(() => Promise.resolve(true))
+    const permissions = {
+      options: [
+        { value: 'workspace-write', name: 'workspace-write' },
+        { value: 'danger-full-access', name: 'danger-full-access' },
+      ],
+      currentValue: 'workspace-write',
+    }
+    const { view, session } = bench({ permissions, command })
+    fireEvent.click(view.getByLabelText(/^Access mode/))
+    fireEvent.click(view.getByRole('menuitem', { name: 'Full access' }))
+    fireEvent.click(view.getByRole('checkbox'))
+    act(() => { session.set(snapshotOf({ removed: true })) })
+    expect(view.queryByRole('dialog')).toBeNull()
+    expect(command).not.toHaveBeenCalled()
+  })
+
+  it('resets an open Full access confirmation when switching tasks', () => {
+    const command = vi.fn(() => Promise.resolve(true))
+    const permissions = {
+      options: [
+        { value: 'workspace-write', name: 'workspace-write' },
+        { value: 'danger-full-access', name: 'danger-full-access' },
+      ],
+      currentValue: 'workspace-write',
+    }
+    const { view, props } = bench({ permissions, command })
+    fireEvent.click(view.getByLabelText(/^Access mode/))
+    fireEvent.click(view.getByRole('menuitem', { name: 'Full access' }))
+    fireEvent.click(view.getByRole('checkbox'))
+    view.rerender(<InputBar {...props} sessionId={'s2' as SessionId} />)
+    expect(view.queryByRole('dialog')).toBeNull()
+    expect(command).not.toHaveBeenCalled()
+  })
+
   it('a registered entry fills its seat and receives the locked owner prop', () => {
     const { view, slotCalls } = bench({
       disabled: true,

+ 3 - 0
packages/client/ui-permission/package.json

@@ -24,6 +24,7 @@
   },
   "dshClient": {
     "inject": [
+      "@deepseek-ai/dsh-client-locale",
       "@deepseek-ai/dsh-client-runtime",
       "@deepseek-ai/dsh-client-ui-command"
     ],
@@ -35,6 +36,7 @@
   },
   "license": "BSD-3-Clause",
   "peerDependencies": {
+    "@deepseek-ai/dsh-client-locale": "^0.0.1",
     "@deepseek-ai/dsh-client-runtime": "^0.0.1",
     "@deepseek-ai/dsh-client-ui-command": "^0.0.1",
     "@deepseek-ai/dsh-client-ui-slash": "^0.0.1",
@@ -43,6 +45,7 @@
     "cordis": "^4.0.0-rc.7"
   },
   "devDependencies": {
+    "@deepseek-ai/dsh-client-locale": "workspace:^",
     "@deepseek-ai/dsh-client-runtime": "workspace:^",
     "@deepseek-ai/dsh-client-ui-command": "workspace:^",
     "@deepseek-ai/dsh-client-ui-slash": "workspace:^",

+ 45 - 7
packages/client/ui-permission/src/client/index.ts

@@ -8,15 +8,21 @@
  * projection (the same host-computed select the composer chip renders); a
  * pick submits the `/permission <preset>` command line, so both surfaces
  * write through one path and the pushed projection frame is the one
- * confirmation.
+ * confirmation. The Full access row carries the same explicit risk gate as
+ * the composer chip; the shared popup shell owns the modal mechanics.
  */
 import type { ClientContext, SessionFace } from '@deepseek-ai/dsh-client-runtime/client'
 import type { CommandServiceContract, SelectOption } from '@deepseek-ai/dsh-client-ui-command/client'
 import type { ClientSessionContext } from '@deepseek-ai/dsh-client-ui-slash/client'
+// Type-only: pulls the locale plugin's Context merge (ctx.locale).
+import type {} from '@deepseek-ai/dsh-client-locale/client'
 import type { PermissionSelect } from '@deepseek-ai/dsh-permission/client'
 
 /** Required services (cordis fiber inject). */
-export const inject = ['command', 'sessions']
+export const inject = ['command', 'sessions', 'locale']
+
+const FULL_ACCESS = 'danger-full-access'
+const ACCESS_NS = 'permission.access'
 
 /** Read one session's current permissions projection value (undefined = capability absent). */
 function selectOf(session: SessionFace | undefined): PermissionSelect | undefined {
@@ -26,8 +32,9 @@ function selectOf(session: SessionFace | undefined): PermissionSelect | undefine
 /**
  * Display transform twin of the composer chip's (ui-conversation
  * PermissionSelect): kebab-case machine names render as title-case labels
- * (`workspace-write` → `Workspace Write`) so both permission surfaces show
- * the same text; non-kebab host-configured names pass through.
+ * (`workspace-write` → `Workspace Write`); non-kebab host-configured names
+ * pass through. Full access intentionally uses the product label rather than
+ * a title-cased machine value; its warning body remains locale-aware.
  */
 function displayName(name: string): string {
   if (!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(name)) return name
@@ -35,14 +42,25 @@ function displayName(name: string): string {
 }
 
 /** Flatten the projection select into popup rows; `custom` is display state, never a target. */
-function optionsOf(value: PermissionSelect): SelectOption[] {
+function optionsOf(value: PermissionSelect, t: (key: string) => string): SelectOption[] {
   return value.options
     .filter(option => option.value !== 'custom')
     .map(option => ({
       id: option.value,
-      label: displayName(option.name),
+      label: option.value === FULL_ACCESS ? 'Full access' : displayName(option.name),
       ...(option.description !== undefined ? { detail: option.description } : {}),
       ...(option.value === value.currentValue ? { active: true } : {}),
+      ...(option.value === FULL_ACCESS
+        ? {
+          confirmation: {
+            title: t('confirm.title'),
+            description: t('confirm.description'),
+            acknowledgeLabel: t('confirm.acknowledge'),
+            cancelLabel: t('confirm.cancel'),
+            confirmLabel: t('confirm.enable'),
+          },
+        }
+        : {}),
     }))
 }
 
@@ -54,6 +72,26 @@ function optionsOf(value: PermissionSelect): SelectOption[] {
 export function apply(ctx: ClientContext): void {
   const command = ctx.get('command') as CommandServiceContract
   const sessions = ctx.sessions
+  ctx.effect(() => {
+    const disposers = [
+      ctx.locale.register(ACCESS_NS, 'zh', {
+        'confirm.title': '确认启用 Full access?',
+        'confirm.description': '启用 Full access 后,agent 将减少确认步骤,并且可以直接执行更多操作,包括敏感操作、文件修改或外部命令。仅建议在你信任当前任务时使用。',
+        'confirm.acknowledge': '我已了解风险,并愿意继续',
+        'confirm.cancel': '取消',
+        'confirm.enable': '启用 Full access',
+      }),
+      ctx.locale.register(ACCESS_NS, 'en', {
+        'confirm.title': 'Enable Full access?',
+        'confirm.description': 'Full access reduces confirmation steps and lets the agent perform more actions directly, including sensitive operations, file changes, or external commands. Only use it when you trust the current task.',
+        'confirm.acknowledge': 'I understand the risks and want to continue',
+        'confirm.cancel': 'Cancel',
+        'confirm.enable': 'Enable Full access',
+      }),
+    ]
+    return () => { for (const dispose of disposers) dispose() }
+  }, 'ui-permission: Full access confirmation dictionaries')
+  const t = ctx.locale.bind(ACCESS_NS)
   const sessionFor = (session: ClientSessionContext): SessionFace | undefined =>
     sessions.binding(session.sessionId)?.session
   ctx.effect(() => command.decorate({
@@ -67,7 +105,7 @@ export function apply(ctx: ClientContext): void {
       options: (session) => {
         const value = selectOf(sessionFor(session))
         if (value === undefined) throw new Error('permission presets are not available on this host')
-        return Promise.resolve(optionsOf(value))
+        return Promise.resolve(optionsOf(value, t))
       },
       onSelect: async (option, session) => {
         const live = sessionFor(session)

+ 19 - 1
packages/client/ui-permission/tests/browser-plugin.spec.ts

@@ -54,6 +54,17 @@ async function bench() {
   ctx.provide('sessions', {
     binding: (id: SessionId) => (values.has(id) ? { sessionId: id, session: session(id) } : undefined),
   })
+  const en = {
+    'confirm.title': 'Enable Full access?',
+    'confirm.description': 'Full access can perform sensitive operations.',
+    'confirm.acknowledge': 'I understand the risks and want to continue',
+    'confirm.cancel': 'Cancel',
+    'confirm.enable': 'Enable Full access',
+  } as Record<string, string>
+  ctx.provide('locale', {
+    register: () => () => {},
+    bind: () => (key: string) => en[key] ?? key,
+  })
   const fiber = ctx.plugin({ inject: [...inject], apply })
   await fiber.await()
   return {
@@ -86,7 +97,14 @@ describe('ui-permission browser plugin', () => {
     expect(again.find(option => option.id === 'workspace-write')?.active).toBe(true)
     expect(again.find(option => option.id === 'read-only')?.detail).toBe('Reads only.')
     // Kebab-case names title-case; non-kebab host-configured names pass through.
-    expect(again.map(option => option.label)).toEqual(['Read Only', 'Workspace Write', 'Danger Full Access'])
+    expect(again.map(option => option.label)).toEqual(['Read Only', 'Workspace Write', 'Full access'])
+    expect(again.find(option => option.id === 'danger-full-access')?.confirmation).toEqual({
+      title: 'Enable Full access?',
+      description: 'Full access can perform sensitive operations.',
+      acknowledgeLabel: 'I understand the risks and want to continue',
+      cancelLabel: 'Cancel',
+      confirmLabel: 'Enable Full access',
+    })
     b.values.set(sid('s1'), { ...SELECT, options: [{ value: 'plain', name: 'Ask Every Time' }] })
     const passthrough = await c.ui.options(proj, new AbortController().signal)
     expect(passthrough[0]?.label).toBe('Ask Every Time')

+ 6 - 4
packages/client/ui-primitives/src/Modal.tsx

@@ -1,9 +1,11 @@
 // Modal: controlled full-viewport dialog (create-workspace and similar).
-// Fixed overlay in the React tree (no react-dom portal) so ui-primitives
-// stays free of a react-dom dependency; mask tokens match figma 451:18655.
+// The overlay portals to this document's body so ancestor stacking contexts
+// cannot leave sticky page controls above the mask. This is still an in-page
+// WebUI dialog; it never creates or targets another browser/native window.
 
 import { useEffect } from 'react'
 import type { ReactNode } from 'react'
+import { createPortal } from 'react-dom'
 import clsx from 'clsx'
 import { IconCloseOutline16 } from './icons/index.tsx'
 import css from './Modal.module.css'
@@ -44,7 +46,7 @@ export function Modal({ open, onClose, title, description, children, footer, cla
 
   if (!open) return null
 
-  return (
+  return createPortal((
     <div className={css.root} role="presentation">
       <div className={css.mask} aria-hidden="true" onClick={onClose} />
       <div
@@ -74,5 +76,5 @@ export function Modal({ open, onClose, title, description, children, footer, cla
           )}
       </div>
     </div>
-  )
+  ), document.body)
 }

+ 73 - 0
packages/client/ui-primitives/src/RiskConfirmation.module.css

@@ -0,0 +1,73 @@
+.confirmation {
+  width: min(440px, 100%);
+  max-height: calc(100vh - 48px);
+  overflow: hidden;
+}
+
+.confirmationContent {
+  min-height: 0;
+  overflow-y: auto;
+  overscroll-behavior: contain;
+}
+
+@supports (height: 100dvh) {
+  .confirmation {
+    max-height: calc(100dvh - 48px);
+  }
+}
+
+.warning {
+  display: flex;
+  align-items: flex-start;
+  gap: 10px;
+  color: var(--dsw-alias-label-secondary);
+  font-size: 14px;
+  line-height: 22px;
+}
+
+.warning p {
+  margin: 0;
+}
+
+.warningIcon {
+  flex: none;
+  margin-top: 2px;
+  color: var(--dsw-alias-state-error-primary);
+}
+
+.acknowledgement {
+  display: flex;
+  align-items: flex-start;
+  gap: 10px;
+  margin-top: 20px;
+  color: var(--dsw-alias-label-primary);
+  font-size: 14px;
+  line-height: 22px;
+  cursor: pointer;
+}
+
+.acknowledgement input {
+  flex: none;
+  width: 16px;
+  height: 16px;
+  margin: 3px 0 0;
+  accent-color: var(--dsw-alias-button-primary-fill);
+  cursor: pointer;
+}
+
+.acknowledgement input:focus-visible {
+  outline: 2px solid var(--dsw-alias-border-l4);
+  outline-offset: 2px;
+}
+
+.acknowledgement input:disabled {
+  cursor: default;
+}
+
+.modalAction {
+  min-width: 72px;
+}
+
+.confirmAction {
+  min-width: 136px;
+}

+ 80 - 0
packages/client/ui-primitives/src/RiskConfirmation.tsx

@@ -0,0 +1,80 @@
+/**
+ * Controlled risk acknowledgement dialog shared by product surfaces that
+ * must gate a sensitive action behind an explicit checkbox.
+ */
+import { Button } from './Button.tsx'
+import { IconWarningOutline16 } from './icons/index.tsx'
+import { Modal } from './Modal.tsx'
+import css from './RiskConfirmation.module.css'
+
+export interface RiskConfirmationProps {
+  open: boolean
+  title: string
+  description: string
+  acknowledgeLabel: string
+  cancelLabel: string
+  confirmLabel: string
+  acknowledged: boolean
+  disabled?: boolean
+  onAcknowledgedChange: (acknowledged: boolean) => void
+  onCancel: () => void
+  onConfirm: () => void
+}
+
+/**
+ * Render one in-page confirmation whose primary action is unavailable until
+ * the caller-controlled acknowledgement is checked.
+ */
+export function RiskConfirmation({
+  open,
+  title,
+  description,
+  acknowledgeLabel,
+  cancelLabel,
+  confirmLabel,
+  acknowledged,
+  disabled = false,
+  onAcknowledgedChange,
+  onCancel,
+  onConfirm,
+}: RiskConfirmationProps) {
+  return (
+    <Modal
+      open={open}
+      onClose={onCancel}
+      title={title}
+      className={css.confirmation ?? ''}
+      contentClassName={css.confirmationContent ?? ''}
+      footer={(
+        <>
+          <Button variant="outline" className={css.modalAction} onClick={onCancel}>
+            {cancelLabel}
+          </Button>
+          <Button
+            variant="primary"
+            className={css.confirmAction}
+            disabled={disabled || !acknowledged}
+            onClick={onConfirm}
+          >
+            {confirmLabel}
+          </Button>
+        </>
+      )}
+    >
+      <div className={css.warning}>
+        <IconWarningOutline16 size={18} className={css.warningIcon} />
+        <p>{description}</p>
+      </div>
+      <label className={css.acknowledgement}>
+        <input
+          type="checkbox"
+          checked={acknowledged}
+          disabled={disabled}
+          autoFocus
+          onChange={(event) => { onAcknowledgedChange(event.currentTarget.checked) }}
+        />
+        <span>{acknowledgeLabel}</span>
+      </label>
+    </Modal>
+  )
+}

+ 2 - 0
packages/client/ui-primitives/src/index.ts

@@ -13,6 +13,8 @@ export type { MenuEntry, MenuItem, MenuSeparator, MenuLabel } from './Menu.tsx'
 export { useAnchoredMaxHeight } from './useAnchoredMaxHeight.ts'
 export { HoverCard } from './HoverCard.tsx'
 export { Modal } from './Modal.tsx'
+export { RiskConfirmation } from './RiskConfirmation.tsx'
+export type { RiskConfirmationProps } from './RiskConfirmation.tsx'
 export { ConnectionBanner } from './ConnectionBanner.tsx'
 export { FishLogo } from './FishLogo.tsx'
 export { BrandWordmark } from './BrandWordmark.tsx'

+ 5 - 1
packages/client/ui-primitives/tests/atoms.spec.tsx

@@ -327,7 +327,11 @@ describe('Modal', () => {
       <Modal open onClose={onClose} title="Create new workspace" description="Name it." contentClassName="scrolling-content" footer={<button type="button">Create</button>}>
         <input aria-label="name" />
       </Modal>)
-    expect(screen.getByRole('dialog', { name: 'Create new workspace' })).toBeDefined()
+    const dialog = screen.getByRole('dialog', { name: 'Create new workspace' })
+    expect(dialog).toBeDefined()
+    // The full-page layer escapes caller stacking contexts but remains in
+    // this document/current WebUI window.
+    expect(dialog.parentElement?.parentElement).toBe(document.body)
     expect(screen.getByText('Name it.')).toBeDefined()
     expect(screen.getByText('Name it.').parentElement?.className).toContain('scrolling-content')
     fireEvent.keyDown(document, { key: 'a' })

+ 3 - 0
pnpm-lock.yaml

@@ -1337,6 +1337,9 @@ importers:
 
   packages/client/ui-permission:
     devDependencies:
+      '@deepseek-ai/dsh-client-locale':
+        specifier: workspace:^
+        version: link:../locale
       '@deepseek-ai/dsh-client-runtime':
         specifier: workspace:^
         version: link:../runtime

+ 1 - 0
tsconfig.host.json

@@ -26,6 +26,7 @@
     "apps/web/tests/message-actions.e2e.ts",
     "apps/web/tests/queue-actions.e2e.ts",
     "apps/web/tests/skill-invocation-policy.e2e.ts",
+    "apps/web/tests/access-confirmation.e2e.ts",
     "apps/cli/tests/**/*.ts",
     "examples/*/src/**/*.ts",
     "examples/*/start.ts",