# 抵御误触的键盘审批快捷键

键盘审批控件属于授权边界的一部分，不是贴上安全标签的便利功能。如果终端中一次普通的按键，能在焦点切换、模态窗口刚打开或输入重复排队时穿透界面并授权代理调用，那么这个控件收集的就不是决定，而是时机。

这种问题很容易被带进正式版本，因为顺利流程体验很好。开发者触发操作，审批窗口出现，按 Return 确认，于是大家都觉得交互很快。然后同一个开发者开始阅读终端输出，切换窗口时一直按着某个修饰键，或者按 Return 关闭一个无关的提示。审批恰好在错误的时刻到达，操作因此得到了一份没人有意识给出的同意。

## 审批快捷键是一道安全边界

审批快捷键必须代表针对某个仍在进行的操作做出了一次新的、可以追溯到具体人的决定。用户拥有键盘，或者发起请求的进程有权提出请求，都不意味着每个字符都能代表批准。

团队经常把下面三个不同事实混为一谈：

- 某个进程可以请求执行操作。
- 机器前有人在场。
- 这个人此刻批准了这项具体操作。

第一个事实关乎进程身份，第二个关乎本地有人操作，第三个才是授权。经过签名的进程可以很好地证明第一个事实。可见的窗口或许能提示第二个事实，但两者都不能证明第三个事实。

把审批界面当成一个小型状态机，为进入已批准状态设置一条狭窄的路径。它需要知道当前显示的是哪个请求、自己现在是否拥有输入权、从什么时候起可以通过键盘激活，以及请求是否仍然有效。只要其中任何一项发生变化，就让原来的审批路径失效。

一种常见的实现错误，是把快捷键放进范围很大的命令处理器，只检查审批窗口是否存在。在演示中这看起来没什么问题。实际使用时，延迟到达的事件可能批准另一个请求、替换前的旧请求，或者刚从后台回来的窗口。用户按键的瞬间，按钮甚至不必处于可见状态。

解决办法不是移除所有快捷键。对于熟练使用键盘的人来说，只能用鼠标确认很不友好，还会让他们仓促地去点击指针。正确做法是让键盘激活比按钮标签需要满足更严格的条件。只有当前对话框、当前请求、当前焦点代次和当前实体按键动作彼此一致时，系统才能接受这次输入。

这个区别会改变代码审查的重点。与其问“按 Return 会不会点击按钮”，不如问“究竟是哪一种事件可以跨过这道边界？”好的答案应该说明事件来源、窗口状态、请求标识符以及会被拒绝的情况。“对话框打开了”不是答案。

## 焦点必须得到证明，不能靠猜测

对话框一旦失去焦点，就应立即取消键盘激活资格，即使它过了一会儿又重新获得焦点。焦点切换时，原本给某个应用界面的输入可能会被另一个界面解释。

考虑一个常见流程。代理正在终端中运行命令。用户输入命令后切换到文档页面，这时模态授权窗口出现。用户的手还在移动，操作系统已经切换了活动窗口。根据事件顺序和应用框架的不同，Return 可能在模态窗口取得焦点后才到达，即使用户在看到窗口前就已经形成了按键意图。

不安全的规则很简单：只要授权按钮是默认按钮，窗口又位于最前面，就接受 Return。这个规则把窗口位于最前面当成了用户看到并评估请求的证据。它只能证明调度事件时窗口位于窗口栈最前方。

可以改用焦点代次。对话框变为活动状态时，递增一个计数器，并将键盘激活标记为未武装。只有应用观察到当前代次的焦点已经稳定，并且看到了用户新按下的按键后，才开始接受符合条件的输入。失去焦点时立即清除武装状态。焦点回来后不要自动恢复它。

这是有意采用的保守设计。用户切走后再回来，必须重新按一次快捷键。这一次额外的按键代价很小，误发带凭据的 HTTP 调用或 SSH 命令则完全不是一回事。

系统显示另一个模态窗口、密码提示、辅助功能覆盖层、通知交互或工作区切换时，也应遵守同样的规则。应用无法知道焦点为什么移动，也不应擅自猜测。它只能知道先前的假设已经不再成立。

不要只依赖视觉焦点环。它能告诉用户输入看起来会进入哪里，但审批决定需要一个状态转换，用来检查应用实际的活动窗口和对话框当前代次。视觉状态和授权状态应该由同一个事实来源同步变化。

一个实用的手动测试是：打开审批对话框，按住某个修饰键，切换到其他窗口，再回来后立即按 Return。然后分别加入在对话框外点击指针、系统提示和快速切换应用等情况重复测试。每次尝试都应让请求保持待处理，直到用户重新进行明确操作。只要有一种变化会导致批准，键盘路径就过于宽泛。

## 按住的输入需要独立的状态机

对话框获得资格前就已经按下的按键，不能用来授权这个对话框。这个规则覆盖了一类常见问题：界面变化时，用户一直按着 Return、Space、Escape 或修饰键。

事件 API 通常会提供按下、重复和释放三个阶段，但应用代码往往把它们简化成“收到 Return”。这样就丢掉了真正重要的信息。在终端中开始的按键，与审批请求显示并获得焦点后才开始的按键，含义完全不同。

为每个接受的快捷键跟踪实体按键的完整生命周期。只有在对话框自己的激活时间点之后、对话框拥有焦点时，观察到一次非重复的按下事件，并完成平台要求的后续流程，对话框才能接受快捷键。如果对话框打开时按键已经处于按下状态，就为该按键设置阻塞标记，并等待它释放。不要从重复事件推断出新的按下动作。

Space 也需要和 Return 一样谨慎。许多控件使用 Space 进行键盘激活，而用户可能按住 Space 预览、翻页或执行辅助功能操作。Escape 同样需要仔细处理。它只能取消屏幕上当前仍有效的请求，不能取消用户按下它之后才出现的替代请求。

修饰键组合需要更严格的策略。不要把单独的修饰键状态当成许可，也要避开与终端习惯重叠的快捷键，例如 Control-C、Control-D 或 Control-R。如果选择组合键，要求完整组合在对话框完成武装后才开始，并且只要焦点在其中任何一个按键按下期间发生变化，就拒绝这次组合。始于另一个窗口的组合键，不是对当前对话框做出的决定。

一个小型内部记录就够用了：

```\nrequestId: 84f2\nfocusGeneration: 17\narmedAfterEvent: 912\nreturnIsBlockedUntilUp: true\napprovalState: pending\n```

名称并不重要，规则才重要：事件必须晚于对话框当前的焦点代次，并且属于一次全新的输入生命周期。团队往往要等到出现误批准报告后才补上这些状态。应该在添加快捷键之前就把它们设计进去。

## 模态窗口的时序会把普通输入变成授权

模态窗口如果在错误的时刻出现，就可能接收到用户原本要用于底层任务的输入。危险来自事件时序，而不是对话框是否有精心设计的确认文案。

有几处时序间隙值得明确指出。请求可能在按键按下和释放之间到达。界面可能已经渲染，但应用状态还没有完成请求标识符的绑定。上一个请求可能已经关闭，但排队的回调仍然持有它的完成处理器。新对话框可能复用同一个按钮对象，让旧的键盘操作指向新的内容。

常见的失败流程如下：

1. 用户在终端中按 Return 提交命令。
2. 代理进程在按键过程中请求权限。
3. 应用显示审批对话框，并将默认按钮设为活动状态。
4. 按键释放事件或重复事件到达新的响应者。
5. 应用把这个事件当成批准并发送操作。

用户可能根本没有读过对话框。应用却仍然写入“用户已批准”的审计记录，让问题更加严重。这条记录准确描述了代码路径，却错误描述了人的行为。

可以用激活屏障避免这种情况。对话框显示时，从事件系统记录一个单调递增的输入序列或时间戳，并拒绝那些在屏障前开始的事件。如果平台无法提供可靠的序列，就要求用户在对话框处于可见活动状态后完成一次全新的按下和释放。平台存在歧义时，应优先选择保守设计。

除了输入代次，还要使用请求代次。将每个按钮操作和键盘处理器绑定到不可变的请求令牌。对话框关闭时撤销令牌。另一个请求替代它时，创建新令牌，不要修改旧对象。即使此时恰好有新的对话框可见，携带旧令牌的回调也应该返回拒绝。

不要只用“忽略 Return 300 毫秒”这样的延迟来解决时序问题。延迟很受欢迎，因为容易解释，也容易编写。但它无法适应运行缓慢的机器、快速焦点切换、辅助功能输入以及确实需要更长时间的用户。延迟测量的是时间，而你需要测量输入事件与当前对话框状态之间的关系。

## 终端肌肉记忆属于不可信输入

终端操作会产生审批对话框必须警惕的按键。开发者按 Return 提交命令，按 Control-C 停止工作，按 Space 翻阅输出，按 Escape 放弃编辑。代理让终端活动更加频繁，因此授权窗口可能会出现在一串习惯性输入旁边。

不要以为终端一定位于另一个独立应用的后面。人们会使用嵌入式终端、分屏、远程 Shell、全屏窗口以及看起来像终端的工具面板。进程也可能在输出提示开发者按键后不久触发授权请求。视觉上的情况可能比用户的操作计划变化得更快。

安全的默认做法是让审批对话框不接收任何继承而来的输入。它可以在获得焦点并完成武装后接收一次新的 Return 按键，但必须拒绝提交 Shell 命令的 Return、用于翻页而按住的 Space，以及任一按键产生的重复事件。如果界面无法确定两者的区别，就移除快捷键，改用指针或生物识别确认。

不要在全局命令映射中把危险审批绑定到类似终端的命令上。全局监听器会看到模态窗口本地响应链之外的事件，也很难证明激活是从哪里开始的。让处理器只附着在当前有效的具体对话框上，然后要求对话框验证自己的请求令牌，再请求授权层执行操作。

复制和粘贴也值得关注。粘贴的换行符在终端中可能只是普通文本，在表单控件中却可能触发激活。审批控件应忽略没有符合条件的实体快捷键事件的文本插入和命令调度。粘贴的字符是数据，不是同意。

要用真实的终端流程测试，而不是只测试模拟窗口。启动一个会反复提示的命令，在按 Return 时触发请求，然后分别测试使用 Control-C 和 Space 后的情况。测试终端拥有焦点、文档窗口拥有焦点，以及授权窗口在窗口切换期间出现的情况。重点是重现始于其他界面的操作意图。

## 测试事件历史，而不是按钮外观

最有用的测试会向一个状态归约器输入恶意事件历史，并断言只有在完全正确的序列出现后，它才能发出批准。快照测试可以确认对话框看起来处于焦点状态，却无法告诉你是否有过期事件穿过了边界。

用明确的事件为对话框建模，例如 `present`、`focusGained`、`focusLost`、`keyDown`、`keyRepeat`、`keyUp`、`requestRevoked` 和 `approveClick`。为每个事件提供请求令牌和单调递增的输入序列。归约器应产生三种结果之一：保持待处理、取消，或为匹配的令牌发出批准。

这个测试样例足够小，可以直接放在交互代码旁边：

```json
[\n  {"seq": 41, "type": "keyDown", "key": "Return"},\n  {"seq": 42, "type": "present", "request": "r-19"},\n  {"seq": 43, "type": "focusGained", "request": "r-19"},\n  {"seq": 44, "type": "keyUp", "key": "Return"},\n  {"seq": 45, "type": "keyDown", "key": "Return"},\n  {"seq": 46, "type": "keyUp", "key": "Return"}\n]\n```

预期输出的形状同样重要：

```json
[\n  {"seq": 44, "decision": "pending", "reason": "inherited-input"},\n  {"seq": 46, "decision": "approved", "request": "r-19"}\n]\n```

如果实现会在序列 44 批准，就说明它接受了对话框出现前就开始的按键。这就是问题，不需要截图，也不必等待每周才出现一次的竞态。

围绕状态变化建立一个精简的测试矩阵，不要围绕标签建立。至少覆盖以下情况：

- 对话框显示前快捷键已经按下，焦点到达后才释放。
- 新按键开始后焦点离开，并在释放前回来。
- 请求仍在等待时收到重复事件。
- 请求 A 关闭，请求 B 打开，旧处理器随后触发。
- 对话框出现后，保险库或其他前置条件发生变化。

每种情况都要断言没有操作被发送，并根据你的设计确认审计记录了拒绝或没有决定。静默忽略事件可以接受，但归约器已经拒绝，记录却声称批准，这不可接受。

然后测试界面集成，因为许多本身设计良好的归约器会在这里被绕过。确认每条激活路径都经过同一个检查关口：指针点击、Return、Space、辅助功能操作、程序触发的默认按钮操作以及任何菜单命令。随着时间推移，单独调用操作的快捷键路径会逐渐偏离按钮路径。

在这些测试中使用依赖注入提供操作执行器。只有归约器针对当前令牌发出批准后，测试才应收到调用对象。统计调用次数，记录请求令牌，并在旧令牌到达执行器时让测试失败。不要通过发送真实 HTTP 请求或 SSH 命令来测试这一点。授权边界应该在不使用凭据或网络的情况下完成测试。

手动测试仍然重要，因为操作系统传递焦点和辅助功能事件的路径，可能是模拟对象无法覆盖的。用通俗语言记录一份简短的回归脚本，在支持的最旧系统和当前系统上运行，并使用实体键盘执行。加入快速切换窗口、按住按键、重复按键、系统提示、锁屏、从睡眠中唤醒，以及请求在可见时被撤销的情况。脚本应写明每一步操作后的预期决定，而不是只说对话框运行正常。

## 被拒绝的事件必须始终保持拒绝

一个事件一旦不符合审批条件，之后的界面变化就不能让它重新获得资格。这个道理听起来很明显，直到某个实现保存了一个通用的“等待批准”标志，并在焦点回来或动画结束后再次检查它。

让拒绝对这个事件成为终态。如果 Return 在未武装时到达，就把它记录为继承输入或直接丢弃，然后等待之后的新按键。如果组合键期间焦点发生变化，就让整个组合失效。如果请求发生变化，就让与前一个请求相关的所有事件和回调失效。代码不应保留一个候选批准，等待条件改善。

这个原则也能防止重复操作。双击、一次键盘操作后紧接一次指针操作，或事件重放，都不应造成两次外部调用。授权层接受一个批准令牌后，应在调度操作前将请求标记为已消费。之后针对该令牌的激活都应产生无害的重复拒绝。

把界面结果和外部操作结果分开。界面只有在关闭决定状态后，才能显示“已批准执行”。执行器随后可以独立地成功或失败。如果网络请求失败，不要重新激活旧批准令牌，也不要在之后某次按键到达时悄悄重试。如果用户必须为下一次尝试重新授权，就再次请求，尤其是在操作内容或目标可能已经改变时。

这正是审计设计发挥作用的地方。记录足够的上下文，让人能够重建某个输入是因为过期、重复、失去焦点、被撤销还是已经消费而遭到拒绝。不要把日志变成每个按键的记录。你需要的是决定依据，不是监控记录。

## 身份、授权和范围需要分别回答

可信的审批流程要回答谁发起了请求、对方想做什么，以及人是否批准了这项具体操作。不能用其中一个答案代替另一个。

进程身份很有用，因为它能帮助人判断请求者是否符合预期。在 macOS 上，代码签名权限比可变的进程名称更能提供有意义的信号。但符合预期的进程仍可能发出意外请求，用户也仍可能通过误触快捷键批准错误的请求。

范围又是另一回事。对于经过人工检查的低风险工作，会话权限可能很合理，但它不能把之后的每次调用都变成同一个决定。按操作确认有不同目的：当操作使用凭据或访问敏感目标时，要求人在当下重新判断。设计交互时，要让用户清楚自己正在改变哪一层权限。

如果请求会产生外部影响，不要使用“允许”这类含义模糊的标签。用操作人员能够核对的语言说明方法、目标和实际操作。也不要把对话框变成密集的原始数据包。先显示与决定直接相关的事实，同时让用户可以查看完整细节，不要让批准变成滚动阅读练习。

Sallyport 有意保持这些层次彼此独立：保险库锁定时拒绝所有操作，新代理进程默认请求会话授权，选定的凭据还可以要求每次使用前重新批准。这种设计本身不会让键盘交互变安全。快捷键仍然必须证明当前的人批准了当前调用。

## 只有当失败记录变得乏味时才能发布

只有当恶意输入只会产生普通且容易解释的拒绝时，才发布快捷键。用户应该可以在窗口出现时一直按住 Return，在组合键进行到一半时切换窗口，触发重复的 Space，或与替代请求竞速，而不会造成外部调用。

把测试样例和界面改动放在同一次代码审查中。要求审查者阅读能够证明过期按键无法跨过边界的事件序列。要求实现提供一个统一的授权函数，检查请求身份、焦点代次、输入是否新鲜以及是否已消费。便利处理器可以调用这个函数，但绝不能绕过它。

观察生产日志中可能表明操作不顺畅、但不应通过放宽条件来解决的模式。模态窗口出现后频繁发生继承输入拒绝，可能意味着请求出现的时机不合适。改进请求的展示时机和方式，或者提供指针与生物识别路径。不要为了让指标变好，就接受系统本来正确拒绝的事件。

用户查看过请求后，一个好的审批快捷键应该让整个过程平静而自然。这才是值得优化的速度。其他所有快速路径，都只是把终端肌肉记忆变成授权的另一种方式。
