Просмотр исходного кода

feat(client): confirm before enabling full access

ZiyaZhang 1 месяц назад
Родитель
Сommit
9eb0ef4c3e

+ 1 - 1
packages/client/ui-conversation/README.md

@@ -8,7 +8,7 @@ The resident conversation shell survives no-session and session transitions. Wit
 
 The view ring IS a slot: the conversation registration declares the `'conversation.view'` list slot (session scope) in its `children` table, ConversationRoot renders the active entry through its renderSlot share (`only: <active id>`), and view tabs project from the ring ledger's registration options (`id`/`order`/`label`). The chat view is this package's own ring entry; other plugins (ui-trajectory) contribute tabs through plain `ctx.slots.register` — the former package-local view registry (`registerView`/`ViewEntry`/`ConversationViewMap` and the chrome attachment table) is retired, with per-view chrome dissolved into the view components themselves.
 
-Approvals take over the composer through the chain this package declares: `ApprovalPanel` registers as a selector-routed `'conversation.composer'` entry (the ui-question pattern) and occupies the composer in place of the InputBar while an approval wait is pending (amber strip, justification headline, paired command line from the running call's args, one-shot refuse/allow). The `PendingApproval` domain face in `contract/slots.ts` owns the wire encoding — the `ApprovalResponsePayload` value with the audit correlation — over the runtime's `PendingWait` carrier; the broadcast `approval/resolved` frame settles the wait and restores the composer. The sidebar mirrors the blocked state through the manager-tracked `waitingApproval` list bit (lit for uninstantiated sessions too), which outranks the running ring until the question resolves. Pending waits leave the message flow entirely: questions (ui-question) and approvals (ApprovalPanel) both answer through the composer takeover, so no display-only placeholder card remains. The composer's bottom-row Access seat mounts `PermissionSelect`, fed by the host-computed `permissions` projection through the standard-kit `useProjection` (key absence hides the chip); the chip opens a Menu-primitive dropdown whose kebab-case preset names render as title-case labels (the `/permission` popup's display transform twin), and a pick submits the `/permission <preset>` command line through the bar's injected `command` callback.
+Approvals take over the composer through the chain this package declares: `ApprovalPanel` registers as a selector-routed `'conversation.composer'` entry (the ui-question pattern) and occupies the composer in place of the InputBar while an approval wait is pending (amber strip, justification headline, paired command line from the running call's args, one-shot refuse/allow). The `PendingApproval` domain face in `contract/slots.ts` owns the wire encoding — the `ApprovalResponsePayload` value with the audit correlation — over the runtime's `PendingWait` carrier; the broadcast `approval/resolved` frame settles the wait and restores the composer. The sidebar mirrors the blocked state through the manager-tracked `waitingApproval` list bit (lit for uninstantiated sessions too), which outranks the running ring until the question resolves. Pending waits leave the message flow entirely: questions (ui-question) and approvals (ApprovalPanel) both answer through the composer takeover, so no display-only placeholder card remains. The composer's bottom-row Access seat mounts `PermissionSelect`, fed by the host-computed `permissions` projection through the standard-kit `useProjection` (key absence hides the chip); the chip opens a Menu-primitive dropdown whose kebab-case preset names render as title-case labels. Safe preset picks submit `/permission <preset>` immediately through the bar's injected `command` callback, while `danger-full-access` is presented as `Full access` and first opens an in-page Modal risk confirmation. The enabling action stays disabled until the user checks the acknowledgement; cancel, Escape, close, and mask click submit nothing.
 
 Logged non-user messages render as a default-collapsed `上下文注入` disclosure. It shares the Tool calls header geometry and interaction with `ToolRow` through the package-internal `DisclosureRow`, while retaining context semantics: the expanded 141px scrollport shows bounded inline JSON for both `content` and `source`, and no tool state, summary, or keyed toolview dispatch is synthesized ([decision](../../../.agents/notes/implemented/feature/2026-07-30-web-context-injection-disclosure.md)).
 

+ 1 - 1
packages/client/ui-conversation/README.zh.md

@@ -16,7 +16,7 @@
 
 工具行同样是 slot:独立工具环(`ToolViewRegistry`/`ctx.toolviews`/outlet)已经退役。聊天配置项声明键控的 `'conversation.chat.toolview'` 空位(Session scope;key 空间在运行时开放);其渲染点逐行通过 `entryKey: toolName` 分发,并以 `GenericToolCard` 作为调用点 `fallback`。owner 载荷是统一的 `ToolRowOwnerProps`(`callId`/`toolName`/`block`/`openFile`),`ToolRowProps` 则预先将其与 Session 标准工具包组合。注册方只是普通插件:`ctx.slots.register({ name: 'conversation.chat.toolview', key: '<tool>', inject? }, Row)`,以 `inject: ['slots', 'conversation']` 作为加载顺序 seam(apply 在聊天注册后挂载 ConversationService,因此服务存在即可保证 slot 已声明);Session 区分在组件内部完成(`useSessions` 读取 `parentId`,bash 示例是第三方姿态的范例)。Trajectory/waterfall 工具视图 slot 共享此形状,并随各自的渲染点落地(RendersCheck 会拒绝没有任何渲染方的声明)。
 
-审批经由本包声明的链接管编辑器:`ApprovalPanel` 注册为按选择器路由的 `'conversation.composer'` 配置项(ui-question 模式),在审批等待未决期间取代 InputBar 占据编辑器(琥珀色条、理由标题、来自运行中调用参数的配对命令行、一次性的拒绝/允许)。`contract/slots.ts` 中的 `PendingApproval` 领域面在运行时 `PendingWait` 载体之上拥有 wire 编码——带审计关联的 `ApprovalResponsePayload` 值;广播的 `approval/resolved` 帧使等待落定并恢复编辑器。侧边栏通过 manager 跟踪的 `waitingApproval` 列表位(未实例化会话同样点亮)镜像该阻塞状态,其优先级高于运行中圆环,直至问题解决。未决等待完全离开消息流:问题(ui-question)与审批(ApprovalPanel)都经编辑器接管作答,不再保留只读占位卡。编辑器底行的 Access 席位挂载 `PermissionSelect`,由 host 计算的 `permissions` 投影经标准工具包 `useProjection` 供数(key 缺席即隐藏 chip);chip 打开 Menu 原语下拉,kebab-case 预设名渲染为 Title Case 标签(与 `/permission` popup 的显示变换孪生),选中会经由输入栏注入的 `command` 回调提交 `/permission <preset>` 命令行。
+审批经由本包声明的链接管编辑器:`ApprovalPanel` 注册为按选择器路由的 `'conversation.composer'` 配置项(ui-question 模式),在审批等待未决期间取代 InputBar 占据编辑器(琥珀色条、理由标题、来自运行中调用参数的配对命令行、一次性的拒绝/允许)。`contract/slots.ts` 中的 `PendingApproval` 领域面在运行时 `PendingWait` 载体之上拥有 wire 编码——带审计关联的 `ApprovalResponsePayload` 值;广播的 `approval/resolved` 帧使等待落定并恢复编辑器。侧边栏通过 manager 跟踪的 `waitingApproval` 列表位(未实例化会话同样点亮)镜像该阻塞状态,其优先级高于运行中圆环,直至问题解决。未决等待完全离开消息流:问题(ui-question)与审批(ApprovalPanel)都经编辑器接管作答,不再保留只读占位卡。编辑器底行的 Access 席位挂载 `PermissionSelect`,由 host 计算的 `permissions` 投影经标准工具包 `useProjection` 供数(key 缺席即隐藏 chip);chip 打开 Menu 原语下拉,普通安全预设会立即经输入栏注入的 `command` 回调提交 `/permission <preset>`,而 `danger-full-access` 在界面中显示为 `Full access`,选择后先打开页面内的 Modal 风险确认。用户勾选确认项前启用按钮始终不可用;取消、Escape、关闭按钮与点击遮罩都不会提交命令。
 
 todo 两个面就是在该形状上的两个注册项,都是普通注册方插件,`inject: ['slots', 'conversation']`。`TodoRow` 占用 `'conversation.chat.toolview'` 的 `todo_write` key,摘要该次调用「试图写入」的内容(从其 args 解析出 `<已完成>/<总数> 已完成 · <进行中条目>`;模型 JSON 残缺或形状不对时回落到通用摘要;非 ok 执行状态保留通用状态点,使被取消的调用绝不读成一次已完成的更新)。`TodoDock` 以 `order: 10` 占用 `'conversation.input.dock'` 列表 slot(位于 Goal 和 Queue 之间),是计划条:它经 `useProjection` 读取 host 计算的 `todos` 投影(站立计划:其后没有更晚 `turn/start` 的最近一次 `todo/write`)并渲染 `TodoPanel`,后者接收纯列表,在列表为空时自我隐藏;列表非空时面板初始折叠,表头显示标题加 `"<已完成>/<总数> tasks · <n> in progress"`(状态图标为 figma 的勾选/进行中/虚线未开始一组)。选取由 dock 适配器负责,因此面板保持为其 props 的纯函数;站立列表放在此处而非行内,行才能保持单行。输入区 composer 链隐藏的一切(例如 ui-question 对 `conversation.composer` 的接管)也会隐藏整个 dock,包括这条计划条。
 

+ 24 - 0
packages/client/ui-conversation/src/client/apply.ts

@@ -95,6 +95,28 @@ export function apply(ctx: Context): void {
   }, 'ui-conversation: command hint dictionaries')
   const translateHint = ctx.locale.bind(HINT_NS)
 
+  const ACCESS_NS = 'conversation.access'
+  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-conversation: Access control dictionaries')
+  const translateAccess = ctx.locale.bind(ACCESS_NS)
+
   // Apply-time construction keeps store identity bound to this fiber.
   const chatStore = createChatStore()
 
@@ -199,6 +221,7 @@ export function apply(ctx: Context): void {
           stop: undefined,
           command: undefined,
           translateHint,
+          translateAccess,
           hooks: { notices: ABSENT_NOTICES, lexicon: ABSENT_LEXICON },
         }
       }
@@ -217,6 +240,7 @@ export function apply(ctx: Context): void {
           return result.ok && result.value.matched
         },
         translateHint,
+        translateAccess,
         hooks: { notices: shell.notices, lexicon: shell.lexicon },
       }
     },

+ 2 - 0
packages/client/ui-conversation/src/client/contract/slots.ts

@@ -284,6 +284,8 @@ export interface ComposerBarInjected {
   command: ((line: string) => Promise<boolean>) | undefined
   /** Locale-aware hint translator for claimed command placeholders (session-independent — always present). */
   translateHint: (key: string) => string
+  /** Locale-aware copy for the Access control's Full access risk confirmation. */
+  translateAccess: (key: string) => string
   /**
    * Registrant hooks compartment: the renderer binds these to
    * useNotices/useLexicon (static absent sources without a session — hook

+ 2 - 2
packages/client/ui-conversation/src/client/skeleton/InputBar.tsx

@@ -33,7 +33,7 @@ export interface InputBarError {
 export type InputBarProps = ComposerBarProps
 
 export function InputBar({
-  useSession, useInput, inputActions, keyboard, stop, command, translateHint, renderSlot, useNotices, useLexicon,
+  useSession, useInput, inputActions, keyboard, stop, command, translateHint, translateAccess, renderSlot, useNotices, useLexicon,
   useProjection, sessionId, variant, disabled: inert = false, placeholder, accessory, overlay, leftItems, rightItems, footer,
   onAdd, addLabel = 'Add attachment',
 }: InputBarProps) {
@@ -272,7 +272,7 @@ export function InputBar({
   // or while the command face is absent with the session).
   const accessSelect: ReactNode = command === undefined
     ? null
-    : <PermissionSelect value={permissions} locked={locked} command={command} />
+    : <PermissionSelect key={sessionId} value={permissions} locked={locked} command={command} t={translateAccess} />
 
   // Mirror-layer decorations: a visible backdrop with transparent text. The
   // claim token highlights through behind the textarea glyphs; each U+FFFC

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

@@ -41,3 +41,73 @@
   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;
+}

+ 98 - 30
packages/client/ui-conversation/src/client/skeleton/PermissionSelect.tsx

@@ -1,9 +1,11 @@
 import { useState } from 'react'
 import type { PermissionSelect as PermissionSelectValue } from '@deepseek-ai/dsh-permission/client'
-import { Menu } from '@deepseek-ai/dsh-client-ui-primitives'
+import { Button, IconWarningOutline16, Menu, Modal } from '@deepseek-ai/dsh-client-ui-primitives'
 import type { MenuEntry } from '@deepseek-ai/dsh-client-ui-primitives'
 import css from './PermissionSelect.module.css'
 
+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
@@ -15,58 +17,124 @@ function displayName(name: string): string {
   return name.split('-').map(word => word.charAt(0).toUpperCase() + word.slice(1)).join(' ')
 }
 
+function optionLabel(option: PermissionSelectValue['options'][number]): string {
+  return option.value === FULL_ACCESS ? 'Full access' : displayName(option.name)
+}
+
 export interface PermissionSelectProps {
   value: PermissionSelectValue | undefined
   locked: boolean
   command: (line: string) => Promise<boolean>
+  t: (key: string) => string
 }
 
-export function PermissionSelect({ value, locked, command }: PermissionSelectProps) {
+export function PermissionSelect({ value, locked, command, t }: PermissionSelectProps) {
   const [pick, setPick] = useState<string | null>(null)
   const [open, setOpen] = useState(false)
+  const [confirmation, setConfirmation] = useState<string | null>(null)
+  const [acknowledged, setAcknowledged] = useState(false)
 
   if (value === undefined) return null
 
   const currentValue = pick ?? value.currentValue
   const current = value.options.find(option => option.value === currentValue)
-  const busy = pick !== null
+  const busy = pick !== null || confirmation !== null
 
   const items: MenuEntry[] = value.options
     .filter(o => o.value !== 'custom')
-    .map(option => ({ id: option.value, label: displayName(option.name) }))
+    .map(option => ({ id: option.value, label: optionLabel(option) }))
 
-  const choose = (id: string): void => {
-    setOpen(false)
-    if (id === value.currentValue) return
+  const submit = (id: string): void => {
     setPick(id)
     void command(`/permission ${id}`)
       .catch(() => false)
       .then(() => { setPick(null) })
   }
 
+  const choose = (id: string): void => {
+    setOpen(false)
+    if (id === value.currentValue) return
+    if (id === FULL_ACCESS) {
+      setAcknowledged(false)
+      setConfirmation(id)
+      return
+    }
+    submit(id)
+  }
+
+  const closeConfirmation = (): void => {
+    setAcknowledged(false)
+    setConfirmation(null)
+  }
+
+  const confirmFullAccess = (): void => {
+    if (!acknowledged || confirmation === null) return
+    const id = confirmation
+    closeConfirmation()
+    submit(id)
+  }
+
   return (
-    <Menu
-      open={open}
-      items={items}
-      selectedId={currentValue}
-      onSelect={choose}
-      onClose={() => { setOpen(false) }}
-      side="top"
-      anchor={
-        <button
-          type="button"
-          className={css.trigger}
-          aria-label={`Access mode, current: ${displayName(current?.name ?? currentValue)}`}
-          title={current?.description}
-          disabled={locked || busy}
-          onClick={() => { setOpen(!open) }}
-        >
-          <span className={css.triggerLabel}>{displayName(current?.name ?? currentValue)}</span>
-          <svg className={css.chevron} viewBox="0 0 12 12" width="12" height="12" aria-hidden>
-            <path d="M3 4.5L6 7.5L9 4.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" fill="none" />
-          </svg>
-        </button>
-      }
-    />
+    <>
+      <Menu
+        open={open}
+        items={items}
+        selectedId={currentValue}
+        onSelect={choose}
+        onClose={() => { setOpen(false) }}
+        side="top"
+        anchor={
+          <button
+            type="button"
+            className={css.trigger}
+            aria-label={`Access mode, current: ${current === undefined ? displayName(currentValue) : optionLabel(current)}`}
+            title={current?.description}
+            disabled={locked || busy}
+            onClick={() => { setOpen(!open) }}
+          >
+            <span className={css.triggerLabel}>{current === undefined ? displayName(currentValue) : optionLabel(current)}</span>
+            <svg className={css.chevron} viewBox="0 0 12 12" width="12" height="12" aria-hidden>
+              <path d="M3 4.5L6 7.5L9 4.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" fill="none" />
+            </svg>
+          </button>
+        }
+      />
+      <Modal
+        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>
+    </>
   )
 }

+ 74 - 7
packages/client/ui-conversation/tests/input-bar.spec.tsx

@@ -43,6 +43,8 @@ interface BenchOptions {
   variant?: 'hero' | 'composer'
   placeholder?: string
   translateHint?: (key: string) => string
+  translateAccess?: (key: string) => string
+  command?: (line: string) => Promise<boolean>
   accessory?: React.ReactNode
   overlay?: React.ReactNode
   leftItems?: React.ReactNode
@@ -100,12 +102,19 @@ function bench(over?: BenchOptions) {
     useNotices: bindSnapshotSelector(shell.notices),
     useLexicon: bindSnapshotSelector(shell.lexicon),
     stop,
-    command: () => Promise.resolve(true),
+    command: over?.command ?? (() => Promise.resolve(true)),
     // Mirrors the en 'command.hint' locale entries the production apply wires in.
     translateHint: over?.translateHint ?? ((key: string) => ({
       'placeholder.default': 'Message the agent',
       'placeholder.plan': 'describe your task to generate plan',
     } as Record<string, string>)[key] ?? key),
+    translateAccess: over?.translateAccess ?? ((key: string) => ({
+      '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>)[key] ?? key),
     renderSlot,
     variant: over?.variant ?? 'composer',
     ...(over?.placeholder !== undefined ? { placeholder: over.placeholder } : {}),
@@ -450,30 +459,88 @@ describe('placeholder chrome and control seats', () => {
     expect(view.queryByLabelText('Model')).toBeNull()
   })
 
-  it('the Access chip renders the projection value and submits /permission on pick', async () => {
+  it('the Access chip renders the projection value and submits a non-Full-access pick directly', async () => {
+    const command = vi.fn(() => Promise.resolve(true))
     const permissions = {
       options: [
+        { value: 'read-only', name: 'read-only' },
         { value: 'workspace-write', name: 'workspace-write' },
         { value: 'danger-full-access', name: 'danger-full-access' },
       ],
-      currentValue: 'workspace-write',
+      currentValue: 'read-only',
     }
-    const { view } = bench({ permissions })
+    const { view } = bench({ permissions, command })
     const trigger = view.getByLabelText(/^Access mode/) as HTMLButtonElement
     // Title-case display is presentation only; the menu ids stay machine names.
-    expect(trigger.textContent).toBe('Workspace Write')
+    expect(trigger.textContent).toBe('Read Only')
     fireEvent.click(trigger)
     const items = view.getAllByRole('menuitem')
-    expect(items.map(o => o.textContent)).toEqual(['Workspace Write', 'Danger Full Access'])
+    expect(items.map(o => o.textContent)).toEqual(['Read Only', 'Workspace Write', 'Full access'])
     fireEvent.click(items[1]!)
     // Optimistic pick + disable until admission resolves (command stub resolves true).
     const busy = view.getByLabelText(/^Access mode/) as HTMLButtonElement
-    expect(busy.textContent).toBe('Danger Full Access')
+    expect(busy.textContent).toBe('Workspace Write')
     expect(busy.disabled).toBe(true)
+    expect(command).toHaveBeenCalledWith('/permission workspace-write')
     await act(async () => {})
     expect((view.getByLabelText(/^Access mode/) as HTMLButtonElement).disabled).toBe(false)
   })
 
+  it('requires explicit risk acknowledgement before submitting Full access', async () => {
+    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 } = bench({ permissions, command })
+    fireEvent.click(view.getByLabelText(/^Access mode/))
+    fireEvent.click(view.getByRole('menuitem', { name: 'Full access' }))
+
+    expect(command).not.toHaveBeenCalled()
+    expect(view.getByRole('dialog', { name: 'Enable Full access?' })).toBeTruthy()
+    const enable = view.getByRole('button', { name: 'Enable Full access' }) as HTMLButtonElement
+    expect(enable.disabled).toBe(true)
+
+    fireEvent.click(view.getByRole('checkbox', { name: 'I understand the risks and want to continue' }))
+    expect(enable.disabled).toBe(false)
+    fireEvent.click(enable)
+
+    expect(command).toHaveBeenCalledOnce()
+    expect(command).toHaveBeenCalledWith('/permission danger-full-access')
+    expect(view.queryByRole('dialog')).toBeNull()
+    expect((view.getByLabelText(/^Access mode/) as HTMLButtonElement).textContent).toBe('Full access')
+    await act(async () => {})
+  })
+
+  it('cancels a Full access selection without changing permission and resets acknowledgement', () => {
+    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 } = bench({ permissions, command })
+    const openConfirmation = () => {
+      fireEvent.click(view.getByLabelText(/^Access mode/))
+      fireEvent.click(view.getByRole('menuitem', { name: 'Full access' }))
+    }
+
+    openConfirmation()
+    fireEvent.click(view.getByRole('checkbox'))
+    fireEvent.click(view.getByRole('button', { name: 'Cancel' }))
+    expect(command).not.toHaveBeenCalled()
+    expect((view.getByLabelText(/^Access mode/) as HTMLButtonElement).textContent).toBe('Workspace Write')
+
+    openConfirmation()
+    expect((view.getByRole('checkbox') as HTMLInputElement).checked).toBe(false)
+    expect((view.getByRole('button', { name: 'Enable Full access' }) as HTMLButtonElement).disabled).toBe(true)
+  })
+
   it('a registered entry fills its seat and receives the locked owner prop', () => {
     const { view, slotCalls } = bench({
       disabled: true,

+ 1 - 0
packages/client/ui-conversation/tests/input-matrix.spec.tsx

@@ -49,6 +49,7 @@ function mountBar(shell: SessionInputShell, over?: { running?: boolean; disabled
     stop: vi.fn(),
     command: () => Promise.resolve(true),
     translateHint: (key: string) => key,
+    translateAccess: (key: string) => key,
     variant: 'composer',
   }
   return render(<InputBar {...props} />)

+ 1 - 0
packages/client/ui-conversation/tests/input-scenarios.spec.tsx

@@ -135,6 +135,7 @@ async function scopedBench(register?: (slash: SlashService) => void) {
     stop: vi.fn(),
     command: () => Promise.resolve(true),
     translateHint: (key: string) => key,
+    translateAccess: (key: string) => key,
     variant: 'composer',
   }
   const view = render(<InputBar {...barProps} />)

+ 1 - 0
packages/client/ui-conversation/tests/skeleton.spec.tsx

@@ -150,6 +150,7 @@ function mount(
           stop={stop}
           command={() => Promise.resolve(true)}
           translateHint={(key: string) => key}
+          translateAccess={(key: string) => key}
           renderSlot={(() => null) as InputBarProps['renderSlot']}
           {...bar}
         />

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

@@ -16,12 +16,13 @@ import css from './Modal.module.css'
  * @param props.description - optional supporting sentence under the title.
  * @param props.children - body (inputs, etc.).
  * @param props.footer - action row (Cancel / Create).
+ * @param props.contentClassName - optional class for a scrollable content region.
  * @param props.headless - render children directly in the card (no default
  * header/close/body chrome) for dialogs whose figma frame owns its own
  * header structure; mask, card, Escape, and aria-label remain.
  * @returns null when closed; otherwise the overlay tree.
  */
-export function Modal({ open, onClose, title, description, children, footer, className, headless = false }: {
+export function Modal({ open, onClose, title, description, children, footer, className, contentClassName, headless = false }: {
   open: boolean
   onClose: () => void
   title: string
@@ -29,6 +30,7 @@ export function Modal({ open, onClose, title, description, children, footer, cla
   children?: ReactNode
   footer?: ReactNode
   className?: string
+  contentClassName?: string
   headless?: boolean
 }) {
   useEffect(() => {
@@ -55,7 +57,7 @@ export function Modal({ open, onClose, title, description, children, footer, cla
           ? children
           : (
             <>
-              <div className={css.content}>
+              <div className={clsx(css.content, contentClassName)}>
                 <div className={css.header}>
                   <h2 className={css.title}>{title}</h2>
                   <button type="button" className={css.close} aria-label="Close" onClick={onClose}>

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

@@ -324,11 +324,12 @@ describe('Modal', () => {
       <Modal open={false} onClose={onClose} title="Create new workspace">body</Modal>)
     expect(screen.queryByRole('dialog')).toBeNull()
     rerender(
-      <Modal open onClose={onClose} title="Create new workspace" description="Name it." footer={<button type="button">Create</button>}>
+      <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()
     expect(screen.getByText('Name it.')).toBeDefined()
+    expect(screen.getByText('Name it.').parentElement?.className).toContain('scrolling-content')
     fireEvent.keyDown(document, { key: 'a' })
     expect(onClose).not.toHaveBeenCalled()
     fireEvent.keyDown(document, { key: 'Escape' })