feat(permissions): /mode becomes /permissions, 3-level picker, Full Access by default - #768
Merged
Merged
Conversation
`appendImageChip` was loaded with a per-test `await import()`. That module pulls in a large graph, and a cold transform is billed against the 5s testTimeout — under a loaded full-suite run it blew the budget and failed whichever test happened to run first, while passing in isolation. Load it once in beforeAll with its own generous timeout. It stays out of the file's static imports on purpose: the tests above it are pure protocol checks that should not pay for (or run the module-scope side effects of) the composer. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ccess by default The permission UX was hard to discover and hard to set: `/mode` took a raw engine mode name (`default|plan|acceptEdits|dontAsk|bypassPermissions`) and every session started in `default`, so the practical advice was "always pass --dangerously-skip-permissions". - `/mode` -> `/permissions` (old name kept as an alias). Bare `/permissions` opens a picker over three levels — Ask for approval / Approve for me / Full Access — mapped onto EXISTING engine modes, so the permission engine itself is unchanged. `/permissions <raw-mode>` remains as a power-user escape hatch for `plan` / `dontAsk`. The level catalog lives in src/permissions/levels.py with a TS mirror pinned equal by a test; descriptions are written against what the engine does rather than copied from the tool this was modeled on. - A bare interactive `clawcodex` on a TTY now starts in Full Access. - The chosen level persists to `permissions.defaultMode` in `~/.clawcodex/settings.json`, so dialing permissions DOWN survives a relaunch. This wires the read side of a writer that had been write-only since the C1 review (updates.py's standing TODO). Keeping the new default defensible: - `bypass_selectable` is a NEW capability, separate from `is_bypass_permissions_mode_available`. The latter ALSO relaxes plan mode (check.py `should_bypass`), so reusing it to make the picker work would have turned `/plan` into full access in every session. `_build_runtime` no longer derives availability from the launch mode for the same reason. - The plan-approval dialog's elevated arm now keys off the same disjunction as the set_permission_mode gate. Before, approving a plan in a full-access session silently downgraded it to acceptEdits with no way back. - Shift+Tab can reach Full Access via selectability, so the default mode is not a one-way exit while the footer advertises "(shift+tab to cycle)". - Repository settings files (`settings.json` AND `settings.local.json` — both are committable; `.local` describes a .gitignore convention, not a trust boundary) may only supply `default` or `dontAsk`, and only when that does not loosen what applies anyway. `plan` is excluded because its looseness depends on bypass availability, which a ranking cannot express. - `permissions.allowBypassPermissionsMode` is read from the user config only. It was also read from `<git-root>/.clawcodex/config.local.json`, which is a committable repo file, so a checked-out repository could grant itself bypass availability and thereby turn `/plan` into a write-anywhere bypass. - Root outside a sandbox drops bypass availability and clamps an implicit or persisted Full Access; an explicit `--permission-mode` is still honored, so `docker run ... --permission-mode bypassPermissions -p` keeps working. - Persisting `defaultMode` goes through one policy (`_may_persist_mode`) on both doors: the `persist` flag and a `chosen_updates` setMode. Previously the latter was an ungated second path to `bypassPermissions` for any non-TUI agent-server client. - A saved Full Access does not arm `clawcodex -p`; persistence dials headless down, never up. Non-TTY and `--print` launches keep `default`, so CI and the Harbor eval harness are unaffected. Verified with the full Python suite, the ui-tui suite (typecheck clean, only the 8 pre-existing failures), and a live pyte end-to-end run against the real Ink client + agent-server covering the whole round trip in both directions. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Test Results 1 files 1 suites 7m 55s ⏱️ Results for commit e11c885. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
The permission setting was hard to discover and hard to set:
/modetook a raw engine mode name (default|plan|acceptEdits|dontAsk|bypassPermissions), and every session started indefault— so the practical advice was "always pass--dangerously-skip-permissions"./mode→/permissions(old name kept as an alias).Three options instead of five raw modes. Bare
/permissionsopens a picker:defaultacceptEditsbypassPermissionsA bare interactive
clawcodexon a TTY starts in Full Access — equivalent to--dangerously-skip-permissions./permissionsalways changes it, and the choice persists topermissions.defaultMode, so dialing permissions down survives a relaunch.The three levels map onto existing engine modes — the permission engine itself is unchanged.
plananddontAskare workflow/strict-deny modes, not points on a looseness ladder, so they stay off the picker and reachable via/plan,--permission-mode, and/permissions <raw-mode>.The level catalog is
src/permissions/levels.pywith a TS mirror pinned equal by a test. Descriptions are written against what the engine actually does rather than copied from the tool this was modeled on, whose wording describes internet gating clawcodex does not implement.Making the new default defensible
Full Access by default removes the approval gate for writes and shell commands in every interactive session, so most of this PR is the fence around that.
bypass_selectableis a new capability, separate fromis_bypass_permissions_mode_available. The existing flag also relaxes plan mode (check.pyshould_bypassismode == "bypassPermissions" or (mode == "plan" and is_bypass_permissions_mode_available)), so reusing it to make the picker work would have turned/planinto full access in every session._build_runtimeno longer derives availability from the launch mode for the same reason.Other holes closed on the way:
acceptEditswith no way back (chosen_updatessetMode pre-emptsExitPlanMode's restore).settings.jsonandsettings.local.json; both are committable,.localdescribes a.gitignoreconvention, not a trust boundary — may only supplydefaultordontAsk, and only when that doesn't loosen what applies anyway.planis excluded because its looseness depends on bypass availability, which a ranking cannot express: a repo shippingdefaultMode: "plan"could reach unconstrained write-anywhere.permissions.allowBypassPermissionsModeis now read from the user config only. It was also read from<git-root>/.clawcodex/config.local.json— a committable repo file — so a checked-out repository could grant itself bypass availability and thereby turn/planinto a write-anywhere bypass. Pre-existing, but this PR's guarantees depend on it. Operators keeping that key in a repo-local config must move it to~/.clawcodex/config.json.--permission-mode, sodocker run … --permission-mode bypassPermissions -pkeeps working, and plainsudo clawcodexstarts instead of aborting._may_persist_mode) on both doors: thepersistflag and achosen_updatessetMode with a non-session destination. The latter was an ungated second path tobypassPermissionsfor any non-TUI agent-server client.clawcodex -p. Persistence dials headless down, never up. Non-TTY and--printlaunches keepdefault, so CI and the Harbor eval harness are unaffected (their adapters pin--dangerously-skip-permissionsanyway).Precedence, first match wins:
--dangerously-skip-permissions→--permission-mode→ persisteddefaultMode→ implicit Full Access →default.--allow-dangerously-skip-permissions("available without starting in it") suppresses the implicit floor, and adisableBypassPermissionsModelockdown overrides everything.Testing
tsc --noEmitcleanlaunch shows
Perms Full Access · /permissionsand▶▶ full access on→/permissionsopens the 3-row picker withFull Access · current→ picking row 1 prints "Permissions: Ask for approval." and clears the badge →defaultModelands insettings.json→ Shift+Tab cycles back up → relaunch starts in Ask for approval (proving the cycle did not persist) →/permissions fullgoes back up and re-persists.defaultModecannot reachWrite /etc/evil.txtthrough the realhas_permissions_to_use_tool.Three rounds of adversarial review; the first two rounds each found real holes in my own work.
Upgrade note
Anyone upgrading gets Full Access without asking for it. To keep prompts, run
/permissionsonce and pick Ask for approval, or set:🤖 Generated with Claude Code