青雲的博客

Article

放宽权限的那一刻,正在跑的工具该不该变强?

· 20 分钟阅读

四个小时里,同一个判断被收紧了六次。

起点是一次看起来很小的放宽:允许用户在任务运行中打开 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 也被限制不能和 SetApprovalModeSetActivePermissionProfile { 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。

通往 unprofiled full-auto 的几条路:旧拦截只看住了其中一条

这个修复的测试断言也变了。改之前,同一个场景期望的错误是 RuntimeUnavailable;改之后变成 Unauthorized

- Err(surface::SurfaceClientCommandError::RuntimeUnavailable)
+ Err(surface::SurfaceClientCommandError::Unauthorized)

错误类型不是被简单地“改对”的,它中间转过一次手。6c9f677c 最初在 apply_runtime_settings_patch 里加过一条 SetApprovalMode(FullAuto) → Unauthorized1a5bb003 为了让模式和显式 profile 分开同步,把那一条删了,同一个场景于是掉到活跃 operation 那条分支上,拿到 RuntimeUnavailablecde59409 再用新的状态判定把错误码改回 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_questionrequest_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

相关文章

评论