Repository navigation
refactor(runtime): make the permission privilege ladder an explicit rank - #3303
Conversation
PermissionMode derives Ord from declaration order, placing Prompt (and Allow)
above the real privilege ladder (ReadOnly < WorkspaceWrite < DangerFullAccess).
In PermissionPolicy::authorize_with_context the ladder check
'current_mode >= required_mode' therefore evaluates true for every tool when the
active mode is Prompt (3 >= {0,1,2}), returning Allow and making the subsequent
'if current_mode == PermissionMode::Prompt' branch that routes to prompt_or_deny
unreachable.
Effect: a session configured with the 'prompt' permission mode silently
auto-allows every tool and never calls the prompter (reached via
ConversationRuntime::run_turn with an interactive prompter), defeating the mode's
entire purpose. The sibling PermissionEnforcer already special-cases Prompt
before its own 'active_mode >= required_mode' check, confirming Prompt is not
meant to be part of the ordered ladder.
Fix: exclude Prompt from the ordered-ladder comparison so Prompt mode falls
through to the interactive prompt path. Allow remains handled by its explicit
equality check. The same comparison appears twice in authorize_with_context —
once in the shared tail and once inside the 'Some(PermissionOverride::Allow)'
arm, which is reached when a hook emits permissionDecision "allow" — so both
copies carry the guard; guarding only the tail still left a hook able to
auto-allow every tool in Prompt mode without ever consulting the prompter.
Adds regression tests covering Prompt mode with a prompter, without a prompter,
and with a hook allow override.
`PermissionMode` derived `PartialOrd`/`Ord`, so the ladder deciding whether a tool runs without asking was declaration order. A variant inserted in the middle of the enum re-ranked it silently, and the difference between "this tool may run" and "this tool may run after a prompt" was a property of where an `enum` line happened to sit. That is how `Prompt` came to outrank `DangerFullAccess`, satisfy `current_mode >= required_mode` for every tool, and skip the prompter. Replace the derive with `privilege_rank`, an exhaustive `match` naming every variant, so inserting one is a build failure rather than a silent re-ranking. `Ord` is deliberately not derived, so a stray `>=` on two modes no longer compiles; the two comparisons outside this module now say which rank they read (`PermissionEnforcer::check_with_required_mode` and the `claw doctor` permission check). Both keep their previous answers, since `privilege_rank` reproduces the old declaration order exactly, including `Prompt` and `Allow` above the tiers. `Prompt` and `Allow` are modes of operation, not privilege tiers, so they are never ranked *against* a tool requirement. The new `covers` predicate states that by name rather than by number: `Allow` always covers, `Prompt` never does (it asks), and only the three tiers compare ranks. That also collapses the duplicated ladder in `authorize_with_context`. The `Some(PermissionOverride::Allow)` arm carried a second, identical copy of the ask-rule check and the ladder; it now falls through to the single copy below, so a hook `allow` is an override rather than a bypass and the two copies can no longer drift apart. Behaviour is unchanged for every (mode, requirement, rule, override) combination. The tests pin the `Prompt` routes — plain and hook-`allow` — across all three tiers, plus the rank ordering itself.
|
正是 review 时担心的 hazard 的根治:去掉 PartialOrd/Ord 派生后,privilege_rank 的穷举 match 让插入新 variant 变成编译错误,stray 的 active_mode >= required_mode 也无法再编译——静默重排和隐式比较两条路都堵死了。等 #3274 合入后 rebase 到 main 即可。 |
|
显式 rank + 穷举 match 的方向完全正确——比起依赖 PartialOrd 派生,编译期强制处理新 variant 是更稳的约束,stray 的 active_mode >= required_mode 也彻底无法编译。建议补一个单元测试把 privilege_rank 的单调性钉死(如 assert!(rank(Read) < rank(Write))),防止未来误改 rank 映射导致权限悄悄收缩。等 #3274 合入后 rebase 到 main 即可,届时 CI 也能在非 fork base 上正常跑。 |
Yeachan-Heo
left a comment
There was a problem hiding this comment.
Reviewed the refactoring of the permission privilege ladder.
Summary
This PR successfully fixes a critical bug in the permission system where derived from enum declaration order, causing mode to incorrectly rank higher than . This led to the permission check silently allowing all tools in Prompt mode, bypassing the intended prompter logic.
Changes Analyzed
rust/crates/runtime/src/permissions.rs
- Removed automatic / derives from
- Added explicit method: an exhaustive match that names every variant, making enum reordering a build failure instead of a silent re-ranking
- Added method: distinguishes between operating modes (Prompt/Allow) and privilege tiers (ReadOnly/WorkspaceWrite/DangerFullAccess), preventing invalid rank comparisons
- Consolidated duplicated ladder logic in
authorize_with_context- the duplicate check in theSome(PermissionOverride::Allow)arm is eliminated - Comprehensive tests cover rank ordering, the
coverspredicate, and the original regression (Prompt mode now properly invokes the prompter)
rust/crates/runtime/src/permission_enforcer.rs
- Updated
check_with_required_modeto explicitly callprivilege_rank()instead of implicit operator ordering
rust/crates/rusty-claude-cli/src/main.rs
- Updated
claw doctorpermission check to use explicitprivilege_rank()calls
Verification
- All 50 permission-related tests pass
- All 630 runtime package tests pass
- Build succeeds with no new warnings
- Behavior is unchanged for every (mode, requirement, rule, override) combination
- The fix prevents future silent re-rankings by making enum changes a compile error rather than a behavioral change
—
[repo owner's gaebal-gajae (clawdbot) 🦞]
Yeachan-Heo
left a comment
There was a problem hiding this comment.
Reviewed the refactoring of the permission privilege ladder.
Summary
This PR successfully fixes a critical bug in the permission system where PermissionMode derived Ord from enum declaration order, causing Prompt mode to incorrectly rank higher than DangerFullAccess. This led to the permission check silently allowing all tools in Prompt mode, bypassing the intended prompter logic.
Changes Analyzed
rust/crates/runtime/src/permissions.rs
- Removed automatic PartialOrd/Ord derives from PermissionMode
- Added explicit privilege_rank() method: an exhaustive match that names every variant, making enum reordering a build failure instead of a silent re-ranking
- Added covers() method: distinguishes between operating modes (Prompt/Allow) and privilege tiers (ReadOnly/WorkspaceWrite/DangerFullAccess), preventing invalid rank comparisons
- Consolidated duplicated ladder logic in authorize_with_context - the duplicate check in the Some(PermissionOverride::Allow) arm is eliminated
- Comprehensive tests cover rank ordering, the covers predicate, and the original regression (Prompt mode now properly invokes the prompter)
rust/crates/runtime/src/permission_enforcer.rs
- Updated check_with_required_mode to explicitly call privilege_rank() instead of implicit operator ordering
rust/crates/rusty-claude-cli/src/main.rs
- Updated claw doctor permission check to use explicit privilege_rank() calls
Verification
- All 50 permission-related tests pass
- All 630 runtime package tests pass
- Build succeeds with no new warnings
- Behavior is unchanged for every (mode, requirement, rule, override) combination
- The fix prevents future silent re-rankings by making enum changes a compile error rather than a behavioral change
—
[repo owner's gaebal-gajae (clawdbot) 🦞]
|
Merged as 86428ab — removing the PartialOrd/Ord derives for the explicit privilege_rank match closes the enum-order pitfall for good. One follow-up still worth adding: a unit test pinning the rank monotonicity (e.g. assert!(rank(Read) < rank(Write))) so a future rank reorder can never silently shrink permissions. |
Follow-up to #3274, following the ordering agreed in this thread: land the
Promptguard there, make the rank explicit here, and treat splitting the mode-of-operation variants out ofPermissionModeas its own change.Stacked on #3274. The branch is cut from #3274's head (
ea7afee), and its only commit is the refactor — but GitHub can only target a base branch that exists in this repository, and #3274's branch lives on the fork, sobasehere ismain. The diff therefore shows #3274's change plus this one until #3274 merges; it collapses to the refactor alone once it does. Please merge #3274 first, and I'll rebase this ontomainas soon as it lands.Why
The reviewer's summary of the hazard, which is what this PR is for:
What changed
1. The rank is explicit, and
Ordis gone.PermissionModeno longer derivesPartialOrd/Ord;privilege_rankis an exhaustivematchnaming every variant, so inserting one is a compile error instead of a silent re-ranking. Because the derive is gone, a strayactive_mode >= required_modeno longer compiles either — which is what made the hazard possible in the first place.That forced the two comparisons living outside this module to name what they compare:
PermissionEnforcer::check_with_required_mode(crates/runtime/src/permission_enforcer.rs:85)claw doctorpermission check (crates/rusty-claude-cli/src/main.rs:4228)Both keep the answers they had, because
privilege_rankreproduces the old declaration order exactly —ReadOnly0,WorkspaceWrite1,DangerFullAccess2,Prompt3,Allow4.2.
PromptandAlloware never ranked against a tier. This is the caveat from the #3274 thread: ranking them aboveDangerFullAccessmakes>=produce the right answer, but it is comparing a mode of operation to a privilege tier. The newcoverspredicate says so by name —Allowalways covers,Promptnever does (it asks), and only the three tiers compare ranks:3. The duplicated ladder is one predicate. As you mapped,
authorize_with_contexthad the ladder twice — once inside theSome(PermissionOverride::Allow)arm and once on the main path. The arm now falls through, so the ask-rule check and the ladder run once each, for both routes:A hook
allowstays an override rather than a bypass, and the two copies can no longer drift apart.Behaviour
Unchanged for every (mode, requirement, rule, override) combination. The
Some(PermissionOverride::Allow)arm previously fell through to the identical condition below whenever it did not return early, and the ask-rule check it duplicated is byte-identical to the one below it; the rank numbers are the old declaration order. The tests cover thePromptroutes — plain and hook-allow— across all three tiers, plus the rank ordering, thecoverstruth table, and the 12 pre-existing cases.Verification
cargo fmt --all -- --check— clean.cargo clippy -p runtime -p rusty-claude-cli --all-targets— no findings in any of the three touched files.cargo check --workspace --all-targets— clean except the pre-existing unix-only test code inmcp_stdio.rsandmcp_tool_bridge.rs(std::os::unix::fs::PermissionsExt::set_mode), which does not build on Windows with or without this change. CI is Linux.runtimelib-test target on Windows, I ran thepermissionstest module in isolation — the file copied verbatim into a scratch crate, with a stub forRuntimePermissionRuleConfig, its only crate dependency — 16 passed, 0 failed (12 pre-existing + 4 new). A fullcargo testwas not run in this environment.Out of scope, noted for whoever picks it up
claw doctor's permission check reports aPrompt-mode session as satisfying every tool's requirement, becausePromptranks aboveDangerFullAccessand that check compares ranks. InPromptmode nothing runs without asking, so "satisfied" is arguably the wrong word there — but changing it changes user-visibledoctoroutput, so this PR preserves it deliberately. It would be fixed by the mode split you suggested, where a session mode would stop being a thing that has a rank at all.