5.5 权限交互:渐进式信任的 UI 设计
对应原书 :5.9 权限 UX:渐进式信任的交互设计(计划编号 5.5 权限交互)
辅助源码 :
components/permissions/(50 个文件,~5000 行)核心文件:PermissionRequest.tsx / PermissionDialog.tsx / PermissionPrompt.tsx / BashPermissionRequest.tsx / FileEditPermissionRequest.tsx / PermissionExplanation.tsx / hooks.ts / bashToolUseOptions.tsx
1. 设计哲学:按需打断、记住偏好、透明可控
权限系统的后端再精巧,如果 UX 设计不好,用户要么"一路狂点 Yes"(失去安全意义),要么"受够了弹框关掉 Agent"(失去产品价值)。Claude Code 的权限 UX 围绕三个原则设计:
| 原则 | 含义 | 源码体现 |
|---|---|---|
| 按需打断 | 只在真正需要用户决策时才弹出权限对话框 | 14步决策树中只有第3步(默认ASK)才触发UI;allow/deny 静默处理 |
| 记住偏好 | 提供"下次不再询问"机制,减少重复打断 | 可编辑前缀规则 yes-prefix-edited / 会话级允许 accept-session |
| 透明可控 | 解释为什么需要权限,允许事后管理规则 | PermissionRuleExplanation + Ctrl+E AI解释 + Ctrl+D 调试信息 + /permissions 命令 |
2. 三层组件架构
权限 UI 采用三层架构,将关注点清晰分离:
PermissionRequest(路由层)
├── permissionComponentForTool() → 按工具类型分发
│ ├── BashTool → BashPermissionRequest
│ ├── FileEditTool → FileEditPermissionRequest
│ ├── FileWriteTool → FileWritePermissionRequest
│ ├── GlobTool/GrepTool/FileReadTool → FilesystemPermissionRequest(共用)
│ ├── WebFetchTool → WebFetchPermissionRequest
│ ├── ExitPlanModeV2Tool → ExitPlanModePermissionRequest
│ ├── AskUserQuestionTool → AskUserQuestionPermissionRequest
│ ├── NotebookEditTool → NotebookEditPermissionRequest
│ ├── PowerShellTool → PowerShellPermissionRequest
│ ├── SkillTool → SkillPermissionRequest
│ ├── EnterPlanModeTool → EnterPlanModePermissionRequest
│ ├── ReviewArtifactTool → ReviewArtifactPermissionRequest (Feature Flag)
│ ├── WorkflowTool → WorkflowPermissionRequest (Feature Flag)
│ ├── MonitorTool → MonitorPermissionRequest (Feature Flag)
│ └── default → FallbackPermissionRequest(通用回退)
│
└── PermissionDialog(容器层)
├── PermissionRequestTitle(标题 + Worker Badge + 副标题)
├── children(工具特定内容区)
└── PermissionPrompt / Select(交互层)
2.1 路由层:PermissionRequest
源码位置 :PermissionRequest.tsx
typescript
function permissionComponentForTool(tool: Tool): React.ComponentType<PermissionRequestProps> {
switch (tool) {
case FileEditTool: return FileEditPermissionRequest;
case BashTool: return BashPermissionRequest;
case FileWriteTool: return FileWritePermissionRequest;
case GlobTool:
case GrepTool:
case FileReadTool: return FilesystemPermissionRequest; // 共用组件
case ExitPlanModeV2Tool: return ExitPlanModePermissionRequest;
case AskUserQuestionTool: return AskUserQuestionPermissionRequest;
// ... Feature Flag 门控的工具
case ReviewArtifactTool:
return ReviewArtifactPermissionRequest ?? FallbackPermissionRequest; // 降级
default:
return FallbackPermissionRequest;
}
}
关键设计:
- 一个工具一个 UI 策略------不同工具的风险谱和用户关注点完全不同
- Feature Flag 门控的工具使用
?? FallbackPermissionRequest降级,确保 Feature Flag 关闭时不崩溃 PermissionRequest组件本身做三件事:- 注册
app:interrupt快捷键(Esc 中断) - 调用
useNotifyAfterTimeout注册离席通知 - 渲染工具特定的权限组件
- 注册
2.2 容器层:PermissionDialog
源码位置 :PermissionDialog.tsx
typescript
type Props = {
title: string;
subtitle?: React.ReactNode; // 副标题(如分类器状态)
color?: keyof Theme; // 边框颜色,默认 "permission"
titleColor?: keyof Theme;
innerPaddingX?: number; // 内边距,默认 1
workerBadge?: WorkerBadgeProps; // 子 Agent 徽章
titleRight?: React.ReactNode; // 标题右侧内容
children: React.ReactNode;
};
视觉特征:
- 圆角顶部边框(
borderStyle="round"),仅保留顶边(borderLeft/Right/Bottom = false) marginTop={1}与上方内容分隔- 标题行使用
PermissionRequestTitle:粗体标题 + Worker Badge + 副标题 - 内容区
paddingX可配置
2.3 交互层:PermissionPrompt
源码位置 :PermissionPrompt.tsx
PermissionPrompt 是通用的选项+反馈输入组件,被 FallbackPermissionRequest 等组件使用:
typescript
export type PermissionPromptOption<T extends string> = {
value: T;
label: ReactNode;
feedbackConfig?: {
type: FeedbackType; // 'accept' | 'reject'
placeholder?: string;
};
keybinding?: KeybindingAction;
};
核心机制:
-
options数组动态生成,每个选项可携带feedbackConfig -
Tab 键展开反馈输入框 :聚焦在带
feedbackConfig的选项上时显示 "Tab to amend" 提示 -
上下文敏感占位符 :
typescriptconst DEFAULT_PLACEHOLDERS: Record<FeedbackType, string> = { accept: 'tell Claude what to do next', // 引导下一步 reject: 'tell Claude what to do differently' // 引导替代方案 }; -
空提交取消 :
allowEmptySubmitToCancel: true------在输入模式下空提交退出输入模式,而非提交空反馈 -
Esc 取消 :记录
tengu_permission_request_escape事件 + 增加escapeCount(用于归因追踪)
3. Bash 权限的渐进式信任
源码位置 :BashPermissionRequest/BashPermissionRequest.tsx(481 行,最复杂的权限组件)
3.1 四路选项体系
Bash 权限对话框是整个 UX 中最精心设计的部分,体现了"渐进式信任"的核心思想:
| 选项值 | 持久性 | 信任级别 | 场景 |
|---|---|---|---|
yes |
最低 | 仅本次 | 不确定的命令 |
yes-apply-suggestions |
中等 | 按后端建议 | 后端已分析的安全命令 |
yes-prefix-edited |
较高 | 持久化到设置 | npm run:*、git:* 等模式 |
yes-classifier-reviewed |
会话级 | AI 分类器描述 | 分类器判定为安全的命令 |
no |
--- | 拒绝 | 不允许执行 |
选项生成 :bashToolUseOptions.tsx
typescript
export type BashToolUseOption = 'yes' | 'yes-apply-suggestions' | 'yes-prefix-edited'
| 'yes-classifier-reviewed' | 'no';
export function bashToolUseOptions({...}): OptionWithDescription<BashToolUseOption>[] {
const options: OptionWithDescription<BashToolUseOption>[] = [];
// 1. Yes(带可选反馈输入)
options.push(yesInputMode
? { type: 'input', label: 'Yes', value: 'yes', placeholder: 'and tell Claude what to do next', ... }
: { label: 'Yes', value: 'yes' }
);
// 2. 持久化选项(受 shouldShowAlwaysAllowOptions() 门控)
if (shouldShowAlwaysAllowOptions()) {
// 2a. 可编辑前缀规则(优先)
if (editablePrefix !== undefined && !hasNonBashSuggestions && suggestions.length > 0) {
options.push({
type: 'input',
label: 'Yes, and don\u2019t ask again for',
value: 'yes-prefix-edited',
placeholder: 'command prefix (e.g., npm run:*)',
initialValue: editablePrefix,
showLabelWithValue: true,
...
});
}
// 2b. 后端建议(回退)
else if (suggestions.length > 0) {
options.push({ label: generateShellSuggestionsLabel(...), value: 'yes-apply-suggestions' });
}
// 2c. 分类器描述规则(仅 ant 构建 + Feature Flag)
if ("external" === 'ant' && !editablePrefixShown && isClassifierPermissionsEnabled() && ...) {
options.push({
type: 'input',
label: 'Yes, and don\u2019t ask again for',
value: 'yes-classifier-reviewed',
placeholder: 'describe what to allow...',
...
});
}
}
// 3. No(带可选反馈输入)
options.push(noInputMode
? { type: 'input', label: 'No', value: 'no', placeholder: 'and tell Claude what to do differently', ... }
: { label: 'No', value: 'no' }
);
return options;
}
3.2 可编辑前缀规则
核心设计 :当系统检测到命令符合"主命令+子命令"模式时(如 npm run build),预填充一个可编辑的通配符规则 npm run:*。
前缀提取算法 (BashPermissionRequestInner 中):
typescript
const [editablePrefix, setEditablePrefix] = useState<string | undefined>(() => {
if (isCompound) {
// 复合命令:后端建议是真相源
const backendBashRules = extractRules(suggestions).filter(r => r.toolName === BashTool.name);
return backendBashRules.length === 1 ? backendBashRules[0]!.ruleContent : undefined;
}
// 同步快速提取(不依赖 tree-sitter)
const two = getSimpleCommandPrefix(command); // 两词前缀:npm run → npm run:*
if (two) return `${two}:*`;
const one = getFirstWordPrefix(command); // 单词前缀:git → git:*
if (one) return `${one}:*`;
return command; // 回退到完整命令
});
安全防护 :BARE_SHELL_PREFIXES 集合阻止生成过于宽泛的规则。例如 bash -c "rm -rf /" 不会生成 bash:*,因为 bash:* 允许执行任何 shell 脚本,风险不可控。
异步精化(tree-sitter):
typescript
useEffect(() => {
if (isCompound) return; // 复合命令跳过,后端已分析
let cancelled = false;
getCompoundCommandPrefixesStatic(command, subcmd => BashTool.isReadOnly({ command: subcmd }))
.then(prefixes => {
if (cancelled || hasUserEditedPrefix.current) return; // 用户已手动编辑则不覆盖
if (prefixes.length > 0) setEditablePrefix(`${prefixes[0]}:*`);
}).catch(() => {});
return () => { cancelled = true; };
}, [command, isCompound]);
复合命令处理(GH#11380):
decisionReason.type === 'subcommandResults'标记复合命令路径- 同步前缀启发式(
getSimpleCommandPrefix)操作完整复合字符串,会产生死规则如Bash(cd src:*) - 解决方案:单规则 → 种子到可编辑输入;多规则 →
yes-apply-suggestions原子保存所有子命令规则
3.3 分类器自动审批的 UX 反馈
ClassifierCheckingSubtitle------独立组件隔离 20fps shimmer 时钟:
typescript
// 隔离前:useShimmerAnimation 在 535 行的 BashPermissionRequestInner 内
// 每 50ms 时钟 tick 重渲染整个对话框(PermissionDialog + Select + 所有子组件)
// Inner 有 Compiler bailout,无法自动 memoize → 完整 JSX 树重建 20-60 次/分类器检查
function ClassifierCheckingSubtitle() {
const [ref, glimmerIndex] = useShimmerAnimation("requesting", CHECKING_TEXT, false);
return (
<Box ref={ref}>
<Text>{CHECKING_TEXT.split("").map((char, i) =>
<ShimmerChar key={i} char={char} index={i} glimmerIndex={glimmerIndex}
messageColor="inactive" shimmerColor="subtle" />
)}</Text>
</Box>
);
}
三态显示:
typescript
const classifierSubtitle = feature('BASH_CLASSIFIER')
? toolUseConfirm.classifierAutoApproved
? <Text>
<Text color="success">{figures.tick} Auto-approved</Text>
{toolUseConfirm.classifierMatchedRule &&
<Text dimColor>{' \u00b7 matched "'}{toolUseConfirm.classifierMatchedRule}{'"'}</Text>}
</Text>
: toolUseConfirm.classifierCheckInProgress
? <ClassifierCheckingSubtitle /> // 等待中:shimmer 动画
: classifierWasChecking
? <Text dimColor>Requires manual approval</Text> // 分类器完成但仍需人工
: undefined // 未触发分类器
: undefined;
用户覆盖 :即使分类器自动通过(classifierAutoApproved=true),用户仍可按 Esc 关闭勾选标记:
typescript
useKeybinding('confirm:no', handleDismissCheckmark, {
context: 'Confirmation',
isActive: feature('BASH_CLASSIFIER') ? !!toolUseConfirm.classifierAutoApproved : false
});
自动通过时选项变为禁用状态:
typescript
<Select options={toolUseConfirm.classifierAutoApproved
? options.map(o => ({ ...o, disabled: true }))
: options}
isDisabled={toolUseConfirm.classifierAutoApproved} ... />
3.4 破坏性命令警告
typescript
const { destructiveWarning } = useMemo(() => {
const destructiveWarning = getFeatureValue_CACHED_MAY_BE_STALE('tengu_destructive_command_warning', false)
? getDestructiveCommandWarning(command)
: null;
return { destructiveWarning, ... };
}, [command, toolUseConfirm.input]);
// 渲染
{destructiveWarning_0 && (
<Box marginBottom={1}>
<Text color="warning" dimColor={toolUseConfirm.classifierAutoApproved}>
{destructiveWarning_0}
</Text>
</Box>
)}
3.5 Sed 编辑特殊路由
typescript
// BashPermissionRequest 入口处检测 sed -i 命令
const sedInfo = parseSedEditCommand(command);
if (sedInfo) {
return <SedEditPermissionRequest sedInfo={sedInfo} ... />;
}
// 否则继续到 BashPermissionRequestInner
sed 命令不以原始命令展示,而是路由到 SedEditPermissionRequest 以 diff 形式展示变更------体现"展示用户关心的信息,而非技术细节"。
4. Diff 驱动的文件权限决策
4.1 FileEditPermissionRequest
源码位置 :FileEditPermissionRequest/FileEditPermissionRequest.tsx
typescript
export function FileEditPermissionRequest(props) {
const parsed = FileEditTool.inputSchema.parse(props.toolUseConfirm.input);
const { file_path, old_string, new_string, replace_all } = parsed;
return (
<FilePermissionDialog
toolUseConfirm={...}
title="Edit file"
subtitle={relative(getCwd(), file_path)}
question={<Text>Do you want to make this edit to <Text bold>{basename(file_path)}</Text>?</Text>}
content={<FileEditToolDiff file_path={file_path} edits={[{ old_string, new_string, replace_all }]} />}
path={file_path}
completionType="str_replace_single"
parseInput={parseInput}
ideDiffSupport={ideDiffSupport} // IDE diff 集成
/>
);
}
4.2 IDE Diff 集成
typescript
const ideDiffSupport: IDEDiffSupport<FileEditInput> = {
getConfig: (input) => createSingleEditDiffConfig(
input.file_path, input.old_string, input.new_string, input.replace_all
),
applyChanges: (input, modifiedEdits) => {
// 用户在 IDE 中修改 Claude 的编辑后,更新 input
const firstEdit = modifiedEdits[0];
if (firstEdit) {
return {
...input,
old_string: firstEdit.old_string,
new_string: firstEdit.new_string,
replace_all: firstEdit.replace_all
};
}
return input;
}
};
设计意义:当用户在 VS Code/Cursor 等 IDE 中使用 Claude Code 时,文件编辑权限请求通过 RPC 自动在 IDE 中打开 diff 视图。用户可以在最熟悉的编辑器环境中审阅变更,甚至修改 Claude 的编辑后再接受------这让用户从"审批者"升级为"协作者"。
4.3 FilePermissionDialog Hook
源码位置 :FilePermissionDialog/useFilePermissionDialog.ts
typescript
export function useFilePermissionDialog<T>({...}): UseFilePermissionDialogResult<T> {
// 选项生成
const options = useMemo(() => getFilePermissionOptions({
filePath, toolPermissionContext, operationType, ...
}), [filePath, toolPermissionContext, operationType, yesInputMode, noInputMode]);
// 选项处理:使用共享的 PERMISSION_HANDLERS
const onChange = useCallback((option, input, feedback?) => {
// 关键:覆盖 onAllow 以传递修改后的 input(IDE diff 修改)
const originalOnAllow = toolUseConfirm.onAllow;
toolUseConfirm.onAllow = (_input, permissionUpdates, feedback) => {
originalOnAllow(input, permissionUpdates, feedback);
};
const handler = PERMISSION_HANDLERS[option.type];
handler(params, { feedback, hasFeedback, enteredFeedbackMode, scope });
}, [...]);
// confirm:cycleMode 快捷键:直接选择会话级允许
const handleCycleMode = useCallback(() => {
const sessionOption = options.find(o => o.option.type === 'accept-session');
if (sessionOption) onChange(sessionOption.option, parsedInput);
}, [options, parseInput, toolUseConfirm.input, onChange]);
useKeybindings({ 'confirm:cycleMode': handleCycleMode }, { context: 'Confirmation' });
return { options, onChange, acceptFeedback, rejectFeedback, ... };
}
5. FallbackPermissionRequest------通用回退
源码位置 :FallbackPermissionRequest.tsx
当工具没有专用权限组件时使用通用回退组件:
typescript
export function FallbackPermissionRequest(t0) {
const userFacingName = toolUseConfirm.tool.userFacingName(toolUseConfirm.input);
// MCP 工具特殊处理:去除 " (MCP)" 后缀
const cleanName = userFacingName.endsWith(" (MCP)")
? userFacingName.slice(0, -6)
: userFacingName;
const showAlwaysAllowOptions = shouldShowAlwaysAllowOptions();
const options = [
{ label: "Yes", value: "yes", feedbackConfig: { type: "accept" } },
];
if (showAlwaysAllowOptions) {
options.push({
label: <Text>Yes, and don't ask again for <Text bold>{cleanName}</Text> commands in <Text bold>{originalCwd}</Text></Text>,
value: "yes-dont-ask-again"
});
}
options.push({ label: "No", value: "no", feedbackConfig: { type: "reject" } });
return (
<PermissionDialog title="Tool use" workerBadge={workerBadge}>
{/* 工具名 + 渲染消息 + 描述 */}
<PermissionRuleExplanation permissionResult={...} toolType="tool" />
<PermissionPrompt options={options} onSelect={handleSelect} onCancel={handleCancel} />
</PermissionDialog>
);
}
三个选项的处理:
yes:onAllow(input, [], feedback)--- 不创建持久规则yes-dont-ask-again:onAllow(input, [{ type: 'addRules', rules: [{ toolName }], behavior: 'allow', destination: 'localSettings' }])--- 创建工具级 allow 规则no:onReject(feedback)--- 拒绝
6. AI 解释与透明度机制
6.1 Ctrl+E------AI 操作解释
源码位置 :PermissionExplanation.tsx
typescript
export function usePermissionExplainerUI(props) {
const enabled = isPermissionExplainerEnabled();
const [visible, setVisible] = useState(false);
const [promise, setPromise] = useState(null);
// 懒加载:仅在用户按 Ctrl+E 时创建 AI 请求
const toggle = () => {
if (!visible) {
logEvent("tengu_permission_explainer_shortcut_used", {});
if (!promise) {
setPromise(createExplanationPromise(props)); // 调用 Haiku 模型
}
}
setVisible(!visible);
};
useKeybinding("confirm:toggleExplanation", toggle, { context: "Confirmation", isActive: enabled });
return { visible, enabled, promise };
}
关键设计:
- 懒加载:仅在用户按 Ctrl+E 时才调用 AI,避免每次权限请求都消耗 token
- Promise 复用:创建后缓存到 state,再次展开不重新请求
- React 19 use() + Suspense:异步读取解释结果
typescript
function ExplanationResult({ promise }) {
const explanation = use(promise); // React 19 use() hook
return (
<Box flexDirection="column" marginTop={1}>
<Text>{explanation.explanation}</Text>
<Box marginTop={1}><Text>{explanation.reasoning}</Text></Box>
<Box marginTop={1}>
<Text color={getRiskColor(explanation.riskLevel)}>
{getRiskLabel(explanation.riskLevel)}:
</Text>
<Text> {explanation.risk}</Text>
</Box>
</Box>
);
}
function getRiskColor(riskLevel: RiskLevel) {
switch (riskLevel) {
case 'LOW': return 'success'; // 绿色
case 'MEDIUM': return 'warning'; // 黄色
case 'HIGH': return 'error'; // 红色
}
}
Shimmer 加载动画:
typescript
function ShimmerLoadingText() {
const [ref, glimmerIndex] = useShimmerAnimation("responding", LOADING_MESSAGE, false);
return (
<Box ref={ref}>
<Text>{LOADING_MESSAGE.split("").map((char, index) =>
<ShimmerChar key={index} char={char} index={index} glimmerIndex={glimmerIndex}
messageColor="inactive" shimmerColor="text" />
)}</Text>
</Box>
);
}
6.2 规则溯源展示
源码位置 :PermissionRuleExplanation.tsx
typescript
function stringsForDecisionReason(reason, toolType): DecisionReasonStrings | null {
switch (reason.type) {
case 'rule':
return {
reasonString: `Permission rule ${chalk.bold(permissionRuleValueToString(reason.rule.ruleValue))} requires confirmation for this ${toolType}.`,
configString: reason.rule.source === 'policySettings' ? undefined : '/permissions to update rules'
};
case 'hook':
return {
reasonString: `Hook ${chalk.bold(reason.hookName)} requires confirmation for this ${toolType}${hookReasonString}${sourceLabel}`,
configString: '/hooks to update'
};
case 'safetyCheck':
case 'other':
return { reasonString: reason.reason, configString: undefined };
case 'workingDir':
return { reasonString: reason.reason, configString: '/permissions to update rules' };
// ...
}
}
设计意义 :不仅告诉用户"需要确认",还告诉"为什么需要确认"和"如何修改这个行为"。/permissions 配置提示直接指向修改入口。policySettings 来源的规则不显示配置提示(策略规则不可由用户修改)。
6.3 Ctrl+D------调试信息
源码位置 :PermissionDecisionDebugInfo.tsx
typescript
export function PermissionDecisionDebugInfo({ permissionResult, toolName }) {
const toolPermissionContext = useAppState(s => s.toolPermissionContext);
const decisionReason = permissionResult.decisionReason;
const suggestions = "suggestions" in permissionResult ? permissionResult.suggestions : undefined;
// 阴影规则检测
const unreachableRules = useMemo(() => {
const all = detectUnreachableRules(toolPermissionContext, { sandboxAutoAllowEnabled });
if (suggestedRules.length > 0) {
return all.filter(u => suggestedRules.some(s => s.matches(u)));
}
if (toolName) return all.filter(u => u.rule.ruleValue.toolName === toolName);
return all;
}, [suggestions, toolName, toolPermissionContext]);
return (
<Box flexDirection="column">
<Text>Behavior {permissionResult.behavior}</Text>
{permissionResult.behavior !== "allow" && <Text>Message {permissionResult.message}</Text>}
<Text>Reason <PermissionDecisionInfoItem decisionReason={decisionReason} /></Text>
<SuggestionDisplay suggestions={suggestions} width={10} />
{unreachableRules.length > 0 && (
<Box marginTop={1}>
<Text color="warning">⚠ Unreachable Rules ({unreachableRules.length})</Text>
{unreachableRules.map(u => (
<Box>
<Text color="warning">{permissionRuleValueToString(u.rule.ruleValue)}</Text>
<Text dimColor>{u.reason}</Text>
<Text dimColor>Fix: {u.fix}</Text>
</Box>
))}
</Box>
)}
</Box>
);
}
调试信息内容:
- Behavior:allow / ask / deny / passthrough
- Message:决策消息(非 allow 时)
- Reason:完整决策原因链(支持 subcommandResults 递归展示)
- Suggestions:建议的规则/目录/模式
- Unreachable Rules:阴影规则检测------被更高优先级规则遮蔽的规则
subcommandResults 递归展示:
typescript
case "subcommandResults":
return Array.from(decisionReason.reasons.entries()).map(([subcommand, result]) => {
const icon = result.behavior === "allow" ? ✓ : ✗;
return (
<Box>
<Text>{icon} {subcommand}</Text>
{result.decisionReason?.type !== "subcommandResults" &&
<Text>⎿ {decisionReasonDisplayString(result.decisionReason)}</Text>}
{result.behavior === "ask" && <SuggestedRules suggestions={result.suggestions} />}
</Box>
);
});
7. Tab 键反馈:渐进式披露的典范
7.1 上下文敏感占位符
typescript
// PermissionPrompt.tsx
const DEFAULT_PLACEHOLDERS: Record<FeedbackType, string> = {
accept: 'tell Claude what to do next', // 接受时:引导下一步
reject: 'tell Claude what to do differently' // 拒绝时:引导替代方案
};
占位符的微妙措辞差异引导用户提供不同类型的反馈:接受时引导用户补充指令,拒绝时引导用户说明替代方案。
7.2 useShellPermissionFeedback Hook
源码位置 :useShellPermissionFeedback.ts
Bash 和 PowerShell 共享的反馈模式状态管理:
typescript
export function useShellPermissionFeedback({toolUseConfirm, onDone, onReject, explainerVisible}) {
const [rejectFeedback, setRejectFeedback] = useState('');
const [acceptFeedback, setAcceptFeedback] = useState('');
const [yesInputMode, setYesInputMode] = useState(false);
const [noInputMode, setNoInputMode] = useState(false);
const [focusedOption, setFocusedOption] = useState('yes');
const [yesFeedbackModeEntered, setYesFeedbackModeEntered] = useState(false); // 持久标记
const [noFeedbackModeEntered, setNoFeedbackModeEntered] = useState(false);
function handleInputModeToggle(option) {
toolUseConfirm.onUserInteraction(); // 通知系统用户正在交互(阻止分类器自动关闭)
// ... toggle input mode + log analytics
}
function handleReject(feedback?) {
if (!hasFeedback) {
logEvent('tengu_permission_request_escape', { explainer_visible: explainerVisible });
setAppState(prev => ({ ...prev, attribution: { ...prev.attribution, escapeCount: prev.attribution.escapeCount + 1 }}));
}
logUnaryPermissionEvent('tool_use_single', toolUseConfirm, 'reject', hasFeedback);
toolUseConfirm.onReject(trimmedFeedback || undefined);
onReject();
onDone();
}
function handleFocus(value) {
if (value !== focusedOption) toolUseConfirm.onUserInteraction(); // 焦点变化时通知
// 离开 Yes/No 时收起输入模式(仅当无文本时)
if (value !== 'yes' && yesInputMode && !acceptFeedback.trim()) setYesInputMode(false);
if (value !== 'no' && noInputMode && !rejectFeedback.trim()) setNoInputMode(false);
setFocusedOption(value);
}
return { yesInputMode, noInputMode, ... };
}
关键设计细节:
onUserInteraction()在 Tab 切换和焦点变化时调用------这阻止异步分类器在用户正在交互时自动关闭对话框feedbackModeEntered标记是持久的------即使用户收起输入框再提交,仍记录其曾进入反馈模式(用于分析用户行为)- 空文本时自动收起输入模式------防止意外保留展开状态
8. 离席通知
源码位置 :PermissionRequest.tsx
typescript
function getNotificationMessage(toolUseConfirm: ToolUseConfirm): string {
const toolName = toolUseConfirm.tool.userFacingName(toolUseConfirm.input);
if (toolUseConfirm.tool === ExitPlanModeV2Tool) {
return 'Claude Code needs your approval for the plan';
}
if (toolUseConfirm.tool === EnterPlanModeTool) {
return 'Claude Code wants to enter plan mode';
}
if (feature('REVIEW_ARTIFACT') && toolUseConfirm.tool === ReviewArtifactTool) {
return 'Claude needs your approval for a review artifact';
}
if (!toolName || toolName.trim() === '') {
return 'Claude Code needs your attention';
}
return `Claude needs your permission to use ${toolName}`;
}
// 在 PermissionRequest 组件中
useNotifyAfterTimeout(notificationMessage, "permission_prompt");
设计:
- 6 秒无用户交互时发送桌面通知(
DEFAULT_INTERACTION_THRESHOLD_MS = 6000) - 通知消息根据工具类型定制------Plan Mode 提示"needs your approval for the plan",其他工具提示"needs your permission to use {toolName}"
- 用户即使在其他应用中也能快速判断是否需要立即回来
9. 多 Agent 场景的权限 UI
9.1 WorkerBadge
源码位置 :WorkerBadge.tsx
typescript
export type WorkerBadgeProps = {
name: string; // Worker 名称
color: string; // Worker 颜色标识
};
export function WorkerBadge({ name, color }) {
const inkColor = toInkColor(color);
return (
<Box flexDirection="row" gap={1}>
<Text color={inkColor}>{BLACK_CIRCLE} <Text bold>@{name}</Text></Text>
</Box>
);
}
在 PermissionRequestTitle 中渲染:
typescript
export function PermissionRequestTitle({ title, subtitle, color, workerBadge }) {
return (
<Box flexDirection="column">
<Box flexDirection="row" gap={1}>
<Text bold color={color}>{title}</Text>
{workerBadge && <Text dimColor>{"· "}@{workerBadge.name}</Text>}
</Box>
{subtitle && <Text dimColor wrap="truncate-start">{subtitle}</Text>}
</Box>
);
}
当权限请求来自子 Agent 时,对话框标题区域显示 Worker 徽章:Bash command · @test-runner。
9.2 WorkerPendingPermission
源码位置 :WorkerPendingPermission.tsx
Swarm Worker 端的等待指示器:
typescript
export function WorkerPendingPermission({ toolName, description }) {
const teamName = getTeamName();
const agentName = getAgentName();
const agentColor = getTeammateColor();
return (
<Box flexDirection="column" borderStyle="round" borderColor="warning" paddingX={1}>
<Box marginBottom={1}>
<Spinner />
<Text color="warning" bold> Waiting for team lead approval</Text>
</Box>
{agentName && agentColor && <WorkerBadge name={agentName} color={agentColor} />}
<Text dimColor>Tool: </Text><Text>{toolName}</Text>
<Text dimColor>Action: </Text><Text>{description}</Text>
{teamName && <Text dimColor>Permission request sent to team "{teamName}" leader</Text>}
</Box>
);
}
Worker 端显示警告色边框 + Spinner + "Waiting for team lead approval"------让 Worker 用户知道权限请求已发出,等待 Leader 审批。
10. 权限请求日志
10.1 usePermissionRequestLogging
源码位置 :hooks.ts
typescript
export function usePermissionRequestLogging(toolUseConfirm, unaryEvent) {
const setAppState = useSetAppState();
const loggedToolUseID = useRef<string | null>(null); // 防止重复日志
useEffect(() => {
// 防止对象引用变化导致重复触发(会导致无限微任务循环)
if (loggedToolUseID.current === toolUseConfirm.toolUseID) return;
loggedToolUseID.current = toolUseConfirm.toolUseID;
// 1. 增加权限提示计数(归因追踪)
setAppState(prev => ({
...prev,
attribution: { ...prev.attribution, permissionPromptCount: prev.attribution.permissionPromptCount + 1 }
}));
// 2. 分析事件
logEvent('tengu_tool_use_show_permission_request', {
messageID: toolUseConfirm.assistantMessage.message.id,
toolName: sanitizeToolNameForAnalytics(toolUseConfirm.tool.name),
isMcp: toolUseConfirm.tool.isMcp ?? false,
decisionReasonType: toolUseConfirm.permissionResult.decisionReason?.type,
sandboxEnabled: SandboxManager.isSandboxingEnabled(),
});
// 3. [ANT-ONLY] 无 always-allow 建议的权限请求
if (process.env.USER_TYPE === 'ant') {
if (toolUseConfirm.tool.name === BashTool.name &&
permissionResult.behavior === 'ask' &&
!hasRules(permissionResult.suggestions)) {
logEvent('tengu_internal_tool_use_permission_request_no_always_allow', {...});
}
}
// 4. [ANT-ONLY] Bash 命令详细日志
if (process.env.USER_TYPE === 'ant') {
// 记录 Bash 命令拆分 + 完整输入 + 决策原因
logEvent('tengu_internal_bash_tool_use_permission_request', {...});
}
// 5. Unary 事件
void logUnaryEvent({ completion_type: unaryEvent.completion_type, event: 'response', metadata: {...} });
}, [toolUseConfirm, unaryEvent, setAppState]);
}
关键防御:
loggedToolUseIDref 防止重复日志------注释说明"不防护会导致 setAppState 级联 → 无限微任务循环 → CPU 100% + 500MB/min 内存泄漏"- 组件按
toolUseIDkeyed,ref 在 remount 时重置
10.2 日志分层
| 事件名 | 触发条件 | 用途 |
|---|---|---|
tengu_tool_use_show_permission_request |
每次显示权限请求 | 通用权限请求统计 |
tengu_internal_tool_use_permission_request_no_always_allow |
ant Bash 无 always-allow 建议 | 优化建议覆盖率 |
tengu_internal_bash_tool_use_permission_request |
ant Bash 命令详情 | 命令分类与减少不必要请求 |
tengu_permission_request_option_selected |
用户选择选项 | 选项使用分布 |
tengu_accept_submitted |
用户接受(带反馈上下文) | 反馈使用率 |
tengu_reject_submitted |
用户拒绝(带反馈上下文) | 反馈使用率 |
tengu_accept_feedback_mode_entered/collapsed |
Tab 反馈模式切换 | 渐进式披露使用率 |
tengu_permission_request_escape |
Esc 取消 | 取消行为追踪 |
tengu_permission_explainer_shortcut_used |
Ctrl+E AI 解释 | 解释功能使用率 |
11. Shell 权限建议标签生成
源码位置 :shellPermissionHelpers.tsx
generateShellSuggestionsLabel 函数根据建议类型生成人类可读的标签:
typescript
export function generateShellSuggestionsLabel(suggestions, shellToolName, commandTransform?) {
const allRules = suggestions.filter(s => s.type === 'addRules').flatMap(s => s.rules || []);
const readRules = allRules.filter(r => r.toolName === 'Read');
const shellRules = allRules.filter(r => r.toolName === shellToolName);
const directories = suggestions.filter(s => s.type === 'addDirectories').flatMap(s => s.directories || []);
const shellCommands = [...new Set(shellRules.flatMap(rule => {
const command = permissionRuleExtractPrefix(rule.ruleContent) ?? rule.ruleContent;
return commandTransform ? commandTransform(command) : command;
}))];
// 单类型标签
if (hasReadPaths && !hasDirectories && !hasCommands) {
return `Yes, allow reading from {dirName}/ from this project`;
}
if (hasDirectories && !hasReadPaths && !hasCommands) {
return `Yes, and always allow access to {dirName}/ from this project`;
}
if (hasCommands && !hasDirectories && !hasReadPaths) {
return `Yes, and don't ask again for {commands} commands in {cwd}`;
}
// 混合类型标签
if ((hasDirectories || hasReadPaths) && hasCommands) {
return `Yes, and allow access to {paths} and {commands} commands`;
}
return null;
}
智能截断:
typescript
function commandListDisplayTruncated(commands: string[]): ReactNode {
const plainText = commands.join(', ');
if (plainText.length > 50) return 'similar'; // 过长时用 "similar" 替代
return commandListDisplay(commands);
}
Bash 特殊处理 :stripBashRedirections 去除输出重定向,避免文件名显示为命令。
12. 源码 vs 原书对照
| 维度 | 原书描述 | 源码发现 |
|---|---|---|
| 三层架构 | PermissionRequest / PermissionDialog / PermissionPrompt | ✅ 一致,但源码中 PermissionPrompt 仅被 Fallback 使用,Bash/PowerShell 有独立的 Select 直接使用 |
| Bash 四选项 | Yes / Yes-apply-suggestions / Yes-prefix-edited / No | 源码有第五选项 yes-classifier-reviewed(Feature Flag + ant 构建独有) |
| 前缀提取 | getSimpleCommandPrefix + BARE_SHELL_PREFIXES | ✅ 一致,但源码还有 tree-sitter 异步精化 + 复合命令后端建议优先 |
| Tab 反馈 | 上下文敏感占位符 + 空提交取消 | ✅ 一致,源码还显示 onUserInteraction() 调用阻止分类器竞态 |
| Ctrl+E 解释 | Haiku 模型 + 三级风险着色 + shimmer 动画 | ✅ 一致,源码使用 React 19 use() + Suspense 实现异步 |
| Ctrl+D 调试 | 完整权限决策路径 | 源码额外包含 detectUnreachableRules 阴影规则检测 |
| 离席通知 | 6 秒阈值 + 定制消息 | ✅ 一致 |
| Worker Badge | 标题区域显示 @name | 源码有独立的 WorkerPendingPermission(Worker 端等待指示器) |
| Sed 编辑路由 | 路由到 SedEditPermissionRequest 以 diff 展示 | ✅ 一致,在 BashPermissionRequest 入口处检测 |
| IDE diff | VS Code/Cursor 中打开 diff 视图 | 源码有完整的 IDEDiffSupport 接口(getConfig + applyChanges) |
| ClassifierCheckingSubtitle | shimmer 提取到独立组件 | ✅ 一致,注释详细说明提取原因(20fps 时钟隔离) |
| 破坏性命令警告 | 未详细描述 | 源码:getDestructiveCommandWarning + Feature Flag tengu_destructive_command_warning |
| confirm:cycleMode | 未提及 | 源码:文件权限专属快捷键,直接选择会话级允许选项 |
源码独有发现
- 第五选项
yes-classifier-reviewed:原书只描述四种选项,源码中 ant 构建有第五种------基于分类器描述的持久规则 onUserInteraction机制 :Tab 切换和焦点变化时通知系统,阻止分类器在用户交互时自动关闭对话框------这是 5.1-5.4 笔记中interactiveHandler四路竞速的 UI 端配合feedbackModeEntered持久标记:即使用户收起输入框再提交,仍记录曾进入反馈模式------用于分析用户行为模式loggedToolUseID防无限循环:权限请求日志 hook 必须防止对象引用变化导致的无限 setAppState 级联PermissionDecisionDebugInfo的阴影规则检测 :Ctrl+D 不仅展示决策路径,还检测被遮蔽的规则------原书 5.10.3 描述的detectUnreachableRules在 UI 层的体现- Feature Flag 双层门控 :
feature('BASH_CLASSIFIER')+"external" === 'ant'双重检查------外部构建连分类器选项都不显示 - React Compiler 缓存 :所有组件使用
_c()编译器运行时进行手工 memoization,比useMemo更细粒度 useFilePermissionDialog的confirm:cycleMode:文件权限专属快捷键,直接选择会话级允许------键盘快捷操作
13. 设计模式提炼
模式1:工具特定 UI(Tool-Specific UI)
问题:不同工具的风险谱和用户关注点完全不同,通用确认框会导致权限疲劳。
方案 :permissionComponentForTool 路由分发,每个工具有专用权限组件。
收益:Bash 展示命令+前缀规则,文件编辑展示 diff,网络请求展示 URL------用户在充分信息下做决策。
模式2:渐进式披露(Progressive Disclosure)
问题:高级功能(反馈输入)不应打扰初级用户。
方案:默认展示简洁选项,Tab 键展开反馈输入框,Ctrl+E 展开 AI 解释,Ctrl+D 展开调试信息。
收益:发现性较弱但不碍事,高级用户自然发现,初级用户不被打扰。
模式3:懒加载 AI(Lazy AI Loading)
问题:AI 解释消耗 token,不应每次权限请求都调用。
方案 :usePermissionExplainerUI 仅在用户按 Ctrl+E 时创建 Promise,且 Promise 缓存到 state 供后续展开复用。
收益:按需消耗,零浪费。
模式4:时钟隔离(Clock Isolation)
问题:20fps shimmer 动画会导致整个对话框每 50ms 重渲染。
方案 :ClassifierCheckingSubtitle 独立组件封装 useShimmerAnimation,隔离时钟 tick。
收益:避免 20-60 次/分类器检查的完整 JSX 树重建。
模式5:用户交互感知(User Interaction Awareness)
问题:异步分类器可能在用户正在交互时自动关闭对话框。
方案 :onUserInteraction() 在 Tab 切换、焦点变化时调用,通知 interactiveHandler 取消异步自动审批。
收益:用户意图优先于自动化------即使用户只是聚焦到某个选项,也不会被自动关闭打断。
模式6:可编辑建议(Editable Suggestion)
问题:后端生成的规则建议可能不完全符合用户意图。
方案 :yes-prefix-edited 选项预填充可编辑的前缀规则,用户可缩小(npm run build)、扩大(npm:*)或保持默认(npm run:*)。
收益:用户主动参与安全决策,而非被动接受二选一。
模式7:协作者审批(Collaborative Approval)
问题:审批者只能接受或拒绝,无法修改 AI 的编辑。
方案 :IDEDiffSupport 接口------getConfig 生成 diff 配置,applyChanges 将用户修改后的编辑回写到 input。
收益:用户从"审批者"升级为"协作者"------可以修改 Claude 的编辑后再接受。
模式8:防御性日志(Defensive Logging)
问题:React 对象引用变化可能导致 Effect 重复触发,引发无限循环。
方案 :loggedToolUseID ref 去重------组件按 toolUseID keyed,ref 在 remount 时重置,只需在同一实例内去重。
收益:避免 CPU 100% + 500MB/min 内存泄漏。
14. 组件依赖关系图
PermissionRequest
├── useNotifyAfterTimeout (离席通知)
├── useKeybinding (app:interrupt)
└── permissionComponentForTool(tool) →
├── BashPermissionRequest
│ ├── parseSedEditCommand → SedEditPermissionRequest (路由)
│ ├── useShellPermissionFeedback (Tab 反馈状态)
│ ├── usePermissionExplainerUI (Ctrl+E AI 解释)
│ ├── bashToolUseOptions (选项生成)
│ ├── ClassifierCheckingSubtitle (shimmer 隔离)
│ ├── PermissionDialog (容器)
│ │ └── PermissionRequestTitle (标题 + WorkerBadge)
│ ├── PermissionRuleExplanation (规则溯源)
│ ├── PermissionExplainerContent (AI 解释内容)
│ ├── PermissionDecisionDebugInfo (Ctrl+D 调试)
│ ├── Select (选项交互)
│ └── usePermissionRequestLogging (日志)
│
├── FileEditPermissionRequest
│ ├── FileEditToolDiff (diff 展示)
│ └── FilePermissionDialog
│ ├── useFilePermissionDialog (状态管理)
│ │ ├── getFilePermissionOptions (选项生成)
│ │ ├── PERMISSION_HANDLERS (选项处理)
│ │ └── useKeybindings (confirm:cycleMode)
│ ├── permissionOptions (选项定义)
│ ├── usePermissionHandler (处理逻辑)
│ └── ideDiffConfig (IDE diff 集成)
│
├── FallbackPermissionRequest
│ ├── PermissionDialog (容器)
│ ├── PermissionRuleExplanation (规则溯源)
│ └── PermissionPrompt (通用选项+反馈)
│ ├── Select (选项交互)
│ └── useKeybindings (快捷键)
│
└── ... (其他工具特定组件)
15. 总结
Claude Code 的权限 UX 设计围绕"渐进式信任"展开,通过三层组件架构(路由→容器→交互)实现了关注点分离,通过工具特定 UI 避免了权限疲劳。核心设计亮点包括:
- Bash 可编辑前缀规则------让用户主动参与安全决策
- Tab 键渐进式披露------高级功能不碍事但可发现
- Ctrl+E 懒加载 AI 解释------按需消耗 token
- ClassifierCheckingSubtitle 时钟隔离------性能感知影响 UX
- onUserInteraction 用户交互感知------用户意图优先于自动化
- IDEDiffSupport 协作者审批------从审批者到协作者
- WorkerBadge 多 Agent 标识------子 Agent 权限透明化
- loggedToolUseID 防御性日志------防止无限循环
这些设计共同实现了"按需打断、记住偏好、透明可控"三大原则,使权限系统既安全又不令人沮丧。