Article
放宽权限的那一刻,正在跑的工具该不该变强?
四个小时里,同一个判断被收紧了六次。
起点是一次看起来很小的放宽:允许用户在任务运行中打开 full access。改动只有几个字段——approval mode 变成 FullAuto,execution profile 变成 TrustedHost,shell sandbox 放开,同时清掉会话上挂着的 permission profile。
麻烦出在最后一条。清空 profile 这个动作,后来被证明是一次隐式提权;而第一版拦截只堵住了一种形状,另一种隔了 44 分钟才被发现。

上一篇《权限系统真正要证明的,不是“允许”,而是“在哪个边界内执行”》解决了权限在哪个边界内执行的问题:能力求交集、broker 卡在 spawn 之前、执行结果以 receipt 为准,stderr 只能算诊断。这一篇要处理它的下半场:一个已经活着的任务,怎么被重新授权,以及这个授权凭什么算数。
一次 full-auto,四个字段同时变
先写清楚 full-auto 到底改变了什么。在 Orca 里,它是一组同时发生的执行权限变更——牵一发而动全身,这四个字段得一起生效:
- approval mode 变成
FullAuto; - execution profile 变成
TrustedHost; - 默认 shell sandbox 变成
DangerFullAccess; - 已激活的 permission profile 被清除,否则它会覆盖 Full Access。
这四条被写进了 docs/design/runtime-full-access-transition.md,作为契约而不是实现细节。需要分清的是它们并非都是这一批改动引入的:DangerFullAccess 在更早就已经是 FullAuto 的沙箱行为,本批真正动的是 execution profile:ExecutionProfile::for_approval_mode(FullAuto) 从 Workspace 改成了 TrustedHost,对应的测试也从 full_auto_disables_prompts_without_selecting_trusted_host 改名成了 full_auto_selects_trusted_host_execution。两个名字一进一出,恐怕比任何设计文档都更直白地说明了这次放宽到底动了什么。
最后一条尤其关键:bash_sandbox_for_cwd 会把 active_permission_profile 当作 thread 级 profile 传进去,而它的优先级高于 approval mode。也就是说,只要会话上还挂着一个 profile,full-auto 就宽不过它。清除 profile 是让 Full Access 真正生效的前提,不能当成顺手做的清理。
这些契约也决定了 UI 的约束:TUI 在 runtime 提交这次设置变更之前,不能显示 full-auto。
同时,从其他模式进入 full-auto 必须经过一次显式确认,取消确认不会向 runtime 发送任何 mutation。确认框默认停在 Cancel 上,四个入口:Shift+Tab 模式循环、/mode full-auto、/mode 子菜单、/config 设置面板,共用同一个确认 owner。/config 里挂起的模型和推理强度改动也会一起保留,取消 Full Access 时它们一个都不提交。
这几个约束看起来像交互细节,其实是在回答一个授权来源的问题:用户是在哪一个动作里同意放宽权限的? 如果这个问题没有答案,后面的所有检查都只是在检查一个状态值,而不是在检查一次授权。
通用 patch 不能用来扩权
既然 full-auto 只是把 approval mode 改成 FullAuto,最自然的实现就是复用已有的设置同步通道,发一个 SetApprovalMode(FullAuto)。
Orca 没有这么做。运行时权限放宽走的是专用变体:
RuntimeSettingsPatch::EnableFullAccess
契约里对这条规则的解释是:通用的 SetApprovalMode(FullAuto) 不能扩权一个已经激活的 operation。原因是确认来源必须和普通模式同步区分开:TUI 的确认是一次用户授权,而 server 端的配置同步、JSONL 启动适配、会话恢复都是另一回事。把两者塞进同一个 patch,就等于让“配置同步”拥有了“用户确认”的效力。
落到代码里,这条约束比文档描述的还要宽一些:真正的拦截是一个状态判定:只要一次未确认的变更会让生效设置进入 unprofiled FullAuto,就拒绝,与当前有没有活跃 operation 无关。而“已激活的 operation 不能被顶开”是另一条独立的检查,走 RuntimeUnavailable 这个错误码。两条叠起来,才同时盖住“空闲时不能偷偷进”和“运行中不能被顶开”。EnableFullAccess 也被限制不能和 SetApprovalMode、SetActivePermissionProfile { profile: Some(..) } 或 SetCwd 混在同一批 patch 里提交。
这条边界在 server/surface_adapter.rs 里也有对应实现。当启动适配器发现目标模式是 full-auto、而当前配置没有 permission profile 时,它不再发通用的模式同步,而是改发 EnableFullAccess:
if approval_mode == crate::surface::SurfaceApprovalMode::FullAuto
&& config.active_permission_profile.is_none()
{
patches.push(RuntimeSettingsPatch::EnableFullAccess);
} else {
patches.push(RuntimeSettingsPatch::SetApprovalMode { mode: approval_mode });
}
带 profile 的 full-auto 走另一条路:模式和显式 profile 分开同步。这个区分在后面变成了一个漏洞的入口。
清空 profile 本身就是一次提权
f44d68db 这个提交叫 keep profiled full-auto constrained,它同时改了两处,第二处才是真正的问题。
apply_runtime_settings_patch 在处理 SetActivePermissionProfile 时,需要根据当前 approval mode 决定 execution profile。改动前的写法是:
if config.approval_mode == ApprovalMode::FullAuto
&& config.active_permission_profile.is_some()
{
config.execution_profile = orca_core::capability::ExecutionProfile::Workspace;
}
只在“既是 FullAuto 又有 profile”时把它压回 Workspace,其他情况不动。问题出在 SetActivePermissionProfile { profile: None }——也就是清空 profile 这个动作上。
这里很容易看成一个“少算了一次、残留了一个更宽的值”的问题。但实际情况不是这样:在 profiled full-auto 的场景下,清空之前 execution profile 被压在 Workspace;清空之后它确实没被重算,保留下来的仍然是 Workspace。单看这个字段,一点都没有变宽。
真正变化的是语义状态:会话从“profiled full-auto”变成了“unprofiled FullAuto”。而 unprofiled FullAuto 在后来的 hydrate_run_config_from_surface_settings 里会被判定为 full access,物化成 TrustedHost。提权不是发生在那个残留字段上,而是发生在“这个会话现在算哪种模式”上。 约束被摘掉之后,系统对它的重新解释才是放宽的那一步。这也正是为什么“清空 profile”必须和“进入 full access”一样要求确认。
改动后的写法把两种情况都显式写出来:
config.execution_profile = if config.approval_mode == ApprovalMode::FullAuto
&& config.active_permission_profile.is_some()
{
orca_core::capability::ExecutionProfile::Workspace
} else {
orca_core::capability::ExecutionProfile::for_approval_mode(config.approval_mode)
};
注意这段“正确的重算”在 FullAuto 下算出来的正是 TrustedHost,它本身就是那次放宽。所以这个 if/else 不能单独上线,它必须和拦截同时进同一个提交:
同一个提交还在 thread_actor_operation.rs 里加了拦截,拒绝未确认的 full access 意图:
if !confirmed_full_access
&& current.effective.approval_mode == surface::SurfaceApprovalMode::FullAuto
&& current.effective.active_permission_profile.is_some()
&& next_settings.effective.approval_mode == surface::SurfaceApprovalMode::FullAuto
&& next_settings.effective.active_permission_profile.is_none()
{
return Err(surface::SurfaceClientCommandError::Unauthorized);
}
测试用例描述的正是这个场景:先通过 SetActivePermissionProfile 装上一个叫 legacy-restrictive 的 profile,然后尝试用同一个 patch 把 profile 设成 None,期望拿到 Unauthorized;只有走 EnableFullAccess 才能成功清空。
到这一步,漏洞看起来已经堵上了。
44 分钟后:拦截写错了范围
cde59409 出现在 f44d68db 之后 44 分钟,提交信息是 require explicit unprofiled full access。它把上面那段判断整个重写了一遍:
if !confirmed_full_access
&& next_settings.effective.approval_mode == surface::SurfaceApprovalMode::FullAuto
&& next_settings.effective.active_permission_profile.is_none()
&& (current.effective.approval_mode != surface::SurfaceApprovalMode::FullAuto
|| current.effective.active_permission_profile.is_some())
{
return Err(surface::SurfaceClientCommandError::Unauthorized);
}
两版条件要放在一起读,才看得出差别。
旧版要求当前状态同时满足“是 FullAuto”和“有 profile”,也就是说它只拦住了 profiled full-auto → unprofiled full-auto 这一种迁移。而新版拦住的是任何进入 unprofiled FullAuto 的未确认变更,唯一的例外是当前已经处于 unprofiled FullAuto(没有发生迁移,自然不算扩权)。
被旧版漏掉的是这条路径:从 Plan 或默认模式直接跳到 unprofiled full-auto。它不经过 profiled 状态,所以旧条件的第一项就不成立,拦截被跳过,一次没有确认的请求可以拿到 Full Access。

这个修复的测试断言也变了。改之前,同一个场景期望的错误是 RuntimeUnavailable;改之后变成 Unauthorized:
- Err(surface::SurfaceClientCommandError::RuntimeUnavailable)
+ Err(surface::SurfaceClientCommandError::Unauthorized)
错误类型不是被简单地“改对”的,它中间转过一次手。6c9f677c 最初在 apply_runtime_settings_patch 里加过一条 SetApprovalMode(FullAuto) → Unauthorized;1a5bb003 为了让模式和显式 profile 分开同步,把那一条删了,同一个场景于是掉到活跃 operation 那条分支上,拿到 RuntimeUnavailable;cde59409 再用新的状态判定把错误码改回 Unauthorized。
所以“这类请求之前没有走到授权判断”是事实,但让它掉出去的不是别人,正是中间那次“让同步更精确”的重构。顺手删掉的一条检查,两个小时后以一个错误码的形式回来了。 这未必是巧合。在权限这条链路上,任何一次以“更精确”为名的重构,或许都值得顺手确认一遍:它删掉的那些判断,是不是真能由别处接手。
同一提交还改了 server 适配器同步 profile 的方式,并更新了设计文档:通用的模式/profile 同步既不能进入 unprofiled FullAuto,也不能扩权一个已经激活的 operation;恢复一个无 profile 的 full-auto 会话时,它会被规范化为 TrustedHost,而遗留的显式 profile 保持权威。
权限提交之后,谁用新值、谁用旧值
前面解决的是“这次变更合不合法”。还有一个同样重要的问题:变更生效的边界在哪里。
每个激活的 operation 各自持有一个 RuntimeExecutionPolicyHandle。每次工具派发都从这个 handle 上取一份不可变的 RunConfig 快照,于是四种情况被明确区分:
| 对象 | 权限变更后看到什么 |
|---|---|
| 已经准入、正在运行的工具 | 保留启动时的快照,不受影响 |
| 下一次工具准入 | 看到刚提交的 Full Access 策略 |
| 新的子委派 | 捕获新的 TrustedHost 策略 |
| 已存在的委派子任务 | 保留自己那份不可变的 DelegationSnapshot |
这张表是整篇设计里我最想保留的部分。它允许用户主动放宽当前 operation,同时不允许一个正在运行的工具或子任务追溯性地获得新权限。
顺序也定死了:runtime 必须先提交 surface settings revision 和 policy epoch,然后才能发布新的 operation policy、才能确认这次 mutation。/status 会同时报告 approval mode、execution profile、解析出的 shell sandbox 和激活的 permission profile;backend 探测失败不能被误读成一次成功的 Full Access 切换。
交互也在这批改动里被收紧了
同一个下午还有一条并行的线:ask_user_question 和 request_permissions 的执行路径收敛,以及一次工具调用内支持 1–4 个问题的问卷。
设计文档 docs/design/inline-interaction-refactor.md 把这次重构的定位写得很克制:向已有的统一抽象收敛。surface 层的 SurfaceInteractionKind 早就是一个五元枚举:ToolApproval / PermissionRequest / UserInput / McpElicitation / BackgroundApproval,每一类都有自己的 durable 重启 capsule。真正的发散只在两处边缘:runtime 入口按工具名特判,以及 TUI 用盖屏弹窗渲染问卷、和 composer 互斥。
收敛之后的问卷载荷是结构化的,不再是压成字符串再由 TUI 按分隔符反解析:
pub struct SurfaceUserInputQuestion {
pub id: NonEmptyText,
pub header: NonEmptyText, // ask_user_question 层校验 ≤12 chars
pub question: NonEmptyText,
pub options: Vec<SurfaceUserInputOption>,
pub multi_select: bool,
}
pub struct SurfaceUserInputQuestionnaire {
pub questions: NonEmptyVec<SurfaceUserInputQuestion>, // ask_user_question 层校验 1..=4
}
一次工具调用的 1–4 个问题形成一份 typed questionnaire,只进入 UI 一次、只提交一次。durable continuation capsule 升级到 v3,decoder 继续接受 v1/v2,所以旧会话不会因为新载荷而失效。
工具层还有一套更细的形状校验:题数 1–4、header 不超过 12 个字符、每题 2–4 个选项、label 非空且互不相同、description 非空,而且 question 文本不能重复,因为最终输出是以 question 文本为 key 组织的,两条文本相同的问题会在展示层撞在一起。这些约束没有一条是“数据合法性”意义上的,它们全都是这份问卷能不能被正确渲染和回填的前提。
兼容层则刻意写得很窄。旧单题形状只能通过 RuntimeUserInputRequest::single() 构造,识别也只有一个入口:
/// Recognizes only the compatibility shape emitted by `single`.
pub(crate) fn legacy_single_question(&self) -> Option<&RuntimeUserInputQuestion> {
let [question] = self.questions.as_slice() else {
return None;
};
(question.id == "question-1"
&& question.header == "Question"
&& !question.multi_select
&& question
.options
.iter()
.all(|option| option.description.is_empty() && option.preview.is_none()))
.then_some(question)
}
只有恰好一个问题、id 是 question-1、header 是 Question、不是多选,并且所有 option 都没有 description 和 preview,才被当作旧形状。兼容识别的范围越窄,新旧协议混用时的歧义就越少。注意 single() 构造出的 option 本来就只填 label,所以这条额外条件并不会误伤真实的历史请求,它只是把“看起来像”和“确实是”分开了。
校验不能挂在模式匹配上
这批修复里最值得抄走的一条,在 8ee4c38b 里,提交信息是 validate questionnaire answer identity。
改动前,答案校验写在一个模式匹配里:
if let (
surface::SurfaceInteractionRequest::UserQuestionnaire { questionnaire },
surface::SurfaceClientInteractionAnswer::UserInput {
decision: surface::SurfaceUserInputDecision::Submitted(submitted),
},
) = (&interaction.record.request, response.answer())
&& let Err(message) = questionnaire.validate_response(submitted)
{
// ...
}
这段代码只有在“请求是问卷、且答案恰好是 Submitted”时才校验。任何落在这个形状之外的组合,都会绕过校验直接往下走。校验的覆盖面由一个模式匹配的形状决定,这是很容易漏的地方——它不是写错了检查逻辑,而是让检查逻辑根本没被调用。
新的做法是把这个判断交给请求对象自己:
impl SurfaceInteractionRequest {
pub(crate) fn validate_user_input_decision(
&self,
decision: &SurfaceUserInputDecision,
) -> Result<(), &'static str> {
match (self, decision) {
(
Self::UserQuestionnaire { questionnaire },
SurfaceUserInputDecision::Submitted(response),
) => questionnaire.validate_response(response),
(Self::UserInput { .. }, SurfaceUserInputDecision::Submitted(_)) => {
Err("legacy user-input interaction cannot accept a questionnaire submission")
}
_ => Ok(()),
}
}
}
调用点因此变成无条件校验,match 的穷尽性由编译器保证,而不是由调用点记得写对 if let。同时 validate_response 会先调用 validate(),拒绝包含重复 question id 的问卷;durable capsule 的 decoder 也会对恢复出来的问卷跑同样的校验,避免一个损坏的 continuation 把非法请求带回来。
这里的思路和上一篇是同一个:不要问“谁记得检查”,要问“这个对象能不能证明自己是合法的”。 权限侧的证据是 CapabilityReceipt,交互侧的证据是请求对象自己承担的 validate_*。
那次拦截只写对了一半
回头再看,两次收紧是被同一条路径牵着走的:先想到 profiled 到 unprofiled 这一条,隔了 44 分钟才想到 Plan 直跳这一条。补丁式的条件最容易犯的错就是这个——拦住 A→B,就以为拦住了所有通往 B 的路。
更可靠的做法,应该是先把「哪些 patch 能改动生效设置」枚举出来,再逐个确认它们能不能走到 unprofiled FullAuto。这一步我还没做,所以我不敢说现在这条判断已经穷尽。
这一版真正值得留下的,或许是那个提交顺序:先提交 settings revision 和 policy epoch,再发布新的 operation policy。已经准入的工具用旧快照跑完,下一个工具才看到新权限。至于还有没有第三条路能绕进来,得等下一次有人踩到才知道。
Keep Reading