Skip to content

feat(permissions): /mode becomes /permissions, 3-level picker, Full Access by default - #768

Merged
ericleepi314 merged 2 commits into
mainfrom
feat/permissions-command-loose-default
Jul 29, 2026
Merged

feat(permissions): /mode becomes /permissions, 3-level picker, Full Access by default#768
ericleepi314 merged 2 commits into
mainfrom
feat/permissions-command-loose-default

Conversation

@ericleepi314

Copy link
Copy Markdown
Collaborator

What

The permission setting 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".

  1. /mode/permissions (old name kept as an alias).

  2. Three options instead of five raw modes. Bare /permissions opens a picker:

    Level Mode What it allows
    Ask for approval default Read files and run read-only commands freely. Editing files, running other commands, or touching anything outside the workspace asks first.
    Approve for me acceptEdits Auto-accept file edits in this workspace and a small set of safe shell commands. Everything else still asks.
    Full Access bypassPermissions Edit any file and run any command without asking, except where you configured a deny or ask rule.
  3. A bare interactive clawcodex on a TTY starts in Full Access — equivalent to --dangerously-skip-permissions.

  4. /permissions always changes it, and the choice persists to permissions.defaultMode, so dialing permissions down survives a relaunch.

The three levels map onto existing engine modes — the permission engine itself is unchanged. plan and dontAsk are 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.py with 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_selectable is a new capability, separate from is_bypass_permissions_mode_available. The existing flag also relaxes plan mode (check.py should_bypass is mode == "bypassPermissions" or (mode == "plan" and is_bypass_permissions_mode_available)), 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.

Other holes closed on the way:

  • The plan-approval dialog read the old flag, so approving a plan in a full-access session silently downgraded it to acceptEdits with no way back (chosen_updates setMode pre-empts ExitPlanMode's restore).
  • Shift+Tab could not return to Full Access, while the footer kept advertising "(shift+tab to cycle)".
  • Repository settings filessettings.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 doesn't loosen what applies anyway. plan is excluded because its looseness depends on bypass availability, which a ranking cannot express: a repo shipping defaultMode: "plan" could reach unconstrained write-anywhere.
  • permissions.allowBypassPermissionsMode is 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 /plan into 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.
  • Root outside a sandbox drops bypass availability and clamps an implicit or persisted Full Access — but still honors an explicit --permission-mode, so docker run … --permission-mode bypassPermissions -p keeps working, and plain sudo clawcodex starts instead of aborting.
  • Persistence goes through one policy (_may_persist_mode) on both doors: the persist flag and a chosen_updates setMode with a non-session destination. 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 (their adapters pin --dangerously-skip-permissions anyway).

Precedence, first match wins: --dangerously-skip-permissions--permission-mode → persisted defaultMode → implicit Full Access → default. --allow-dangerously-skip-permissions ("available without starting in it") suppresses the implicit floor, and a disableBypassPermissionsMode lockdown overrides everything.

Testing

  • Full Python suite: 9062 passed, 3 skipped, 0 failed
  • ui-tui vitest: only the 8 pre-existing failures (proven against a stashed baseline); tsc --noEmit clean
  • Live end-to-end under a pty with pyte, against the real Ink client + agent-server child — 17/17:
    launch shows Perms Full Access · /permissions and ▶▶ full access on/permissions opens the 3-row picker with Full Access · current → picking row 1 prints "Permissions: Ask for approval." and clears the badge → defaultMode lands in settings.json → Shift+Tab cycles back up → relaunch starts in Ask for approval (proving the cycle did not persist) → /permissions full goes back up and re-persists.
  • Every security fix mutation-tested: reverting each one fails a specific test, including an end-to-end check that a repo-supplied defaultMode cannot reach Write /etc/evil.txt through the real has_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 /permissions once and pick Ask for approval, or set:

// ~/.clawcodex/settings.json
{ "permissions": { "defaultMode": "default" } }

🤖 Generated with Claude Code

ericleepi314 and others added 2 commits July 29, 2026 02:39
`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>
@ericleepi314
ericleepi314 merged commit a3fd2e7 into main Jul 29, 2026
3 checks passed
@github-actions

Copy link
Copy Markdown

Test Results

    1 files      1 suites   7m 55s ⏱️
9 065 tests 9 059 ✅ 6 💤 0 ❌
9 311 runs  9 305 ✅ 6 💤 0 ❌

Results for commit e11c885.

@ericleepi314
ericleepi314 deleted the feat/permissions-command-loose-default branch July 29, 2026 09:58
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant