Skip to content

feat(chat): Claude permission-mode selector with gated bypass#377

Merged
mbektas merged 7 commits into
plmbr:mainfrom
pjdoland:feat/359-permission-mode-selector
Jun 16, 2026
Merged

feat(chat): Claude permission-mode selector with gated bypass#377
mbektas merged 7 commits into
plmbr:mainfrom
pjdoland:feat/359-permission-mode-selector

Conversation

@pjdoland

@pjdoland pjdoland commented Jun 12, 2026

Copy link
Copy Markdown
Collaborator

Summary

Adds the Claude permission-mode selector requested in #359: a compact icon button next to the chat send button (Claude mode only) that opens a menu of Default / Accept Edits / Plan. The selected mode rides each request and is applied to the SDK client immediately. This replaces the /enter-plan-mode and /exit-plan-mode slash commands, which are kept working as hidden aliases for one release but no longer autocomplete. "Bypass Permissions", which removes NBI's tool-call confirmation entirely, is offered only behind a new admin policy and an explicit per-session arm step.

Solution

Selector + mode application. The frontend sends a permissionMode field on each chat request. Plan approval, the hidden aliases, and the per-request mode all route through one apply_permission_mode helper in claude.py, so the tracked mode and a new claude-permission-mode-change websocket notification stay in sync with what the SDK was actually told. The selector follows that notification (plan approval resetting to default, etc.).

Bypass is gated as a distinct armed mode, since in bypass the SDK skips can_use_tool and the CLI's built-in Bash/Write/Edit run unconfirmed as the Jupyter user:

  • New claude_bypass_permissions policy (NBI_CLAUDE_BYPASS_PERMISSIONS_POLICY) defaulting to force-off, the only policy whose default isn't user-choice. It threads through the existing seven-place policy framework. user-choice exposes the option; the user still arms it per session through a confirm-to-arm step, and it never survives a new session or a fresh SDK client.
  • The mode is clamped server-side at the websocket boundary (resolve_permission_mode), so a hand-rolled request can't escalate past what the selector offers. Unknown modes, and the confirmation-skipping dontAsk/auto, fail closed to default.
  • NBI defers to Claude Code's enterprise managed settings as defense in depth: permissions.disableBypassPermissionsMode refuses bypass regardless of the NBI policy (a corrupt managed-settings file fails closed), and permissions.defaultMode seeds the selector's starting mode (bypass excepted, so it never auto-arms).

UX / a11y. The armed state shows a red warning glyph plus an aria-label (not color alone, WCAG 1.4.1). The menu has roving Arrow-key focus, Escape to close, focus-move to the checked item on open, and focus restoration to the trigger. The confirm dialog is a focus-managed alertdialog with Escape-to-cancel.

bypass

Testing

  • tests/test_permission_modes.py (32): the clamp (fail-closed on unknown/dontAsk/None/bypass-when-forbidden), managed-settings reads (corrupt-fails-closed, defaultMode), apply_permission_mode tracking/notify, reset semantics, and the policy-response wiring.
  • tests/test_websocket_handler_integration.py (+6): the security-critical boundary, driving real on_message and asserting the clamped permission_mode on the resulting ChatRequest (bypass forbidden/allowed, unknown, missing, class-default-fails-closed).
  • tests/ts/permission-mode-select.test.tsx (13): menu listing by policy, immediate switching, confirm-to-arm, cancel, Escape, arrow-key roving, focus-move, armed aria-label, and the policy-revoked-but-active case.
  • A suite-wide conftest fixture points the managed-settings probe at an empty path so capability/policy tests aren't coupled to the host.
  • Full verification green: pytest 1211 (+57 claude_client), tsc --noEmit, lint:check, jest 365.
  • Live-verified in JupyterLab across both policy states: the compact icon-button menu (24x24, sparkle preserved), force-off hiding bypass, the confirm-to-arm flow with the red armed indicator, plan mode gating an agent turn until approval, Accept Edits letting the agent edit without confirmation, the hidden aliases still working while dropped from autocomplete, and the mode-change notification reaching the selector. Zero console errors.

Risks / follow-ups

  • The selector is Claude-mode only; non-Claude modes are unchanged.
  • Bypass is per-session and re-armed each time; there is intentionally no "remember bypass" persistence.
  • Screenshots of the compact footer and armed state can be added to this description on request (gh can't upload images, so they need a drag-drop).

Closes #359

Adds a compact permission-mode picker next to the chat send button in
Claude mode (issue plmbr#359): an icon button that opens a menu of Default /
Accept Edits / Plan, replacing the /enter-plan-mode and /exit-plan-mode
slash commands (kept as hidden, no-longer-autocompleted aliases for one
release). The selected mode rides each request and is applied to the SDK
client; plan approval and the aliases route through one apply helper so
the tracked mode and a new backend->frontend notification can't drift.

Bypass Permissions skips NBI's tool-call confirmation entirely, so it is
gated as its own armed mode rather than a peer of the other three:

- A new claude_bypass_permissions policy / NBI_CLAUDE_BYPASS_PERMISSIONS_POLICY
  defaulting to force-off (the only policy that does). user-choice exposes
  the option; the user still arms it per session through a confirm step,
  and it never survives a new session or a fresh SDK client.
- The mode is clamped server-side at the websocket boundary, so a
  hand-rolled request can't escalate past what the selector offers;
  unknown modes (and dontAsk/auto) fail closed to default.
- NBI defers to Claude Code's enterprise managed settings:
  permissions.disableBypassPermissionsMode refuses bypass regardless of
  the policy (corrupt file fails closed), and permissions.defaultMode
  seeds the selector's starting mode (bypass excepted).

The armed state shows a red warning glyph plus aria-label (not color
alone); the menu has roving arrow-key focus, Escape, and focus
restoration. Documented in the README policy table and admin guide.

Closes plmbr#359
@pjdoland pjdoland added the enhancement New feature or request label Jun 12, 2026
The admin guide and policy table covered the bypass gating, but nothing
described what each mode does, how the selector behaves, or the
slash-command change for end users. Add a Permission modes subsection to
the Claude mode docs: what Default / Accept Edits / Plan / Bypass do,
that only Default/Accept Edits/Plan switch immediately while Bypass needs
a confirm-to-arm step and shows a red indicator, that the mode rides each
request and never persists across a new session or a fresh client, the
managed-settings deference, and that the /enter-plan-mode and
/exit-plan-mode commands still work but are deprecated out of
autocomplete.
@mbektas

mbektas commented Jun 16, 2026

Copy link
Copy Markdown
Collaborator

@pjdoland this looks great overall. some issues I noticed.

  1. There isnt good indication of selected mode. should we instead use an explicit dropdown or icons / colors as indicator?
  2. I noticed an issue with the activation / reset of the selected tool. it is shown in the capture. selection updates with a delay.
mode-switch-delay

@mbektas

mbektas commented Jun 16, 2026

Copy link
Copy Markdown
Collaborator

on my machine I am getting this error in Bypass Permissions mode:

Error communicating with Claude agent: Cannot set permission mode to bypassPermissions because the session was not launched with --dangerously-skip-permissions

@mbektas

mbektas commented Jun 16, 2026

Copy link
Copy Markdown
Collaborator

can we make these buttons small?
bypass

pjdoland added 5 commits June 16, 2026 07:30
Addresses the maintainer's review feedback on the Claude permission-mode
selector (PR plmbr#377).

1. Selected-mode indication. The footer button showed the same shield for
   Default / Accept Edits / Plan, so the active mode wasn't visible at a
   glance. Each mode now has a distinct glyph (shield / pencil / eye /
   warning) on the compact icon button, and the menu echoes the same glyphs
   so the mapping is learnable. A text label was tried first but doesn't fit
   the narrow footer (it overlapped the mode toggle and send button).

2. Selection reset on reconnect. reset_permission_mode_tracking notified the
   UI on every (re)connect, clobbering the user's selection back to default
   when a background connect completed or a skills/config reload reconnected.
   The reset notification now carries a `reset` flag; the selector applies it
   only to retire bypass (which still never survives a reset) and otherwise
   keeps an explicit non-bypass selection. The decision is a pure helper,
   nextPermissionModeOnNotification, with unit coverage.

3. Bypass Permissions error. Switching to Bypass failed with "the session was
   not launched with --dangerously-skip-permissions". Launching with that
   flag was rejected: live testing showed it disables ALL permission prompts
   for the whole session (Default / Accept Edits / Plan silently ran tools
   with no confirmation), and it can't be scoped to just the bypass turns.
   Bypass is now realized in NBI's own permission handler: apply_permission_mode
   keeps the CLI in default and the handler auto-allows every tool while the
   tracked mode is bypass, so the gate stays fully under NBI's control and
   non-bypass modes keep prompting. The request-boundary clamp still keeps
   bypass unreachable unless the policy allows it.

4. Confirm-dialog buttons. The confirm-to-arm Cancel / Bypass buttons used
   full-size jp-Dialog-button sizing; they're now compact while staying at
   least 24px tall for touch targets.

Testing: pytest (incl. new TestBypassViaHandler and reset-flag assertions),
jest (selector glyphs + the reset-decision helper), tsc, lint. Verified live
in JupyterLab: Default and Accept Edits gate tool calls with a confirmation,
Bypass runs tools without confirmation and without the launch error, and
switching back to Default re-gates. The selector indicator updates instantly
and no longer overlaps the footer controls.
Follow-up to the permission-mode selector review.

Mode glyphs: Accept Edits now uses a filled check-circle (VscPassFilled,
"edits auto-approved") and Plan uses a brain/psychology glyph
(MdOutlinePsychology, "Claude strategizes before acting"), which read more
clearly than the earlier pencil and eye. Default (shield) and Bypass (red
warning) are unchanged.

Confirm-to-arm popover: it could render off the screen. As an absolutely
positioned child of the sidebar it was clipped by the icon rail on the left
and covered by the main content panel on the right (both out-paint it), and
at 280px it overran the left edge of a narrow left-docked sidebar. It now
renders in a body-level portal, positioned just above the button with an
inline fixed style and clamped to the viewport, so it stays fully visible
regardless of sidebar width or which side the panel is docked.

Testing: tsc, lint, jest. Verified live in JupyterLab that all four glyphs
render in the button and menu, and that the confirm popover is fully visible
(not clipped or occluded), with Cancel and Bypass both working from the
portal.
Swap the Plan-mode glyph from the Material psychology/brain icon to the
Codicon VscChecklist: a plan is a checklist of steps to approve, and it keeps
the selector consistent with the other Codicon glyphs (shield, check-circle,
warning) instead of mixing in a heavier Material icon. Drops the now-unused
MdOutlinePsychology re-export.

Testing: tsc, lint, jest. Verified live in JupyterLab that the checklist glyph
renders on the button and in the menu.
Remediation from a full review of the permission-mode selector PR.

Security: Bypass Permissions silently survived a "New chat session" / `/clear`.
The ClearChatHistory handler ran `/clear` on the persistent agent but never
realigned permission tracking, so `_current_permission_mode` stayed bypass and
the handler kept auto-allowing every tool in the supposedly fresh session,
contradicting the documented "never survives a new session" guarantee. The
handler now refreshes the websocket notifier and calls
reset_permission_mode_tracking() in its finally, mirroring a fresh connect: the
tracked mode drops to default (so the handler re-gates immediately) and the UI
gets a reset notification that retires bypass. Verified live: arm bypass, start
a new session, and the next tool call prompts for confirmation again.

Accessibility: the bypass confirm-to-arm alertdialog renders in a body portal,
so Tab could walk into the JupyterLab shell behind this security-gating dialog.
Added a focus trap (Tab/Shift+Tab wrap between Cancel and the arm button) plus
aria-modal. Gave the mode menu a single Tab stop via roving tabindex
(selected item 0, others -1; Arrow-key roving is unaffected). Added a 24px
min-height floor on the confirm buttons so a dense theme can't shrink the touch
target below WCAG 2.5.8.

Docs: corrected README's reset description (Bypass always drops on a reset and
must be re-armed; other modes carry over; a fresh client starts at Default or
the managed defaultMode) and the CHANGELOG's "dropdown" wording (it's an icon
button that opens a menu).

Testing: tsc, lint, jest (incl. new focus-trap and roving-tabindex tests),
pytest. Live JupyterLab pass: default/accept-edits/plan gate, bypass
auto-allows, new-session drops bypass and re-gates, popover stays on-screen and
modal, zero console errors.
Resolves conflicts from plmbr#370 (provider SDKs load on first use):
- CHANGELOG.md: keep both [Unreleased] entries, Added (permission-mode
  selector, plmbr#359) then Changed (lazy provider imports, plmbr#370).
- docs/admin-guide.md: keep the bypass-permissions exception on the
  NBI_*_POLICY row and re-add the LITELLM_LOCAL_MODEL_COST_MAP row from plmbr#370.
@pjdoland

Copy link
Copy Markdown
Collaborator Author

Thanks Mehmet! Both points in this comment are addressed.

  1. There isnt good indication of selected mode. should we instead use an explicit dropdown or icons / colors as indicator?

Went with the icons route you suggested. Each mode now has its own Codicon glyph on the footer button, echoed next to the label in the menu so the mapping is learnable:

  • Default: shield
  • Accept Edits: filled check-circle
  • Plan: checklist
  • Bypass Permissions: red warning triangle (kept as the persistent, non-color-only armed indicator)

(I first tried a text label, but it didn't fit the narrow input footer, it overlapped the mode toggle and the send button, so the per-mode icons are the better fit there.)

  1. I noticed an issue with the activation / reset of the selected tool ... selection updates with a delay.

Root cause was the backend emitting a permission-mode reset on every (re)connect (the background connect completing, a skills/config reload), which clobbered your selection back to Default a moment after you picked it. The reset notification now carries a reset flag: a reset only retires Bypass and preserves an explicit non-bypass selection, so the selector updates immediately and a reconnect no longer snaps it back.

@pjdoland

Copy link
Copy Markdown
Collaborator Author

on my machine I am getting this error in Bypass Permissions mode: Cannot set permission mode to bypassPermissions because the session was not launched with --dangerously-skip-permissions

Fixed. The CLI refuses set_permission_mode("bypassPermissions") unless the session was launched with --dangerously-skip-permissions. I tried launching with that flag, but live testing showed it disables every permission prompt for the whole session (Default / Accept Edits / Plan silently ran Write/Bash with no confirmation), and it can't be scoped to just the Bypass turns.

So Bypass is now realized in NBI's own permission handler rather than the CLI's bypass mode: when the selector is in Bypass the handler auto-allows every tool, and the CLI stays in default. No launch flag, the other modes still gate normally, and the error is gone. Verified live: Default/Accept Edits/Plan prompt for confirmation, Bypass runs without prompting, and switching back re-gates.

Related hardening while in here: Bypass now also correctly drops on a New chat session / /clear (previously the tracked mode could stay armed into the "fresh" session), so a new session always starts gated.

@pjdoland

Copy link
Copy Markdown
Collaborator Author

can we make these buttons small?

Done. The confirm-to-arm Cancel / Bypass buttons are now compact (dropped the full jp-Dialog-button sizing), with a 24px min-height floor so they stay a usable target. While testing this I also moved the confirm popover into a portal so it can no longer run off-screen in a narrow left-docked sidebar, and trapped focus within it (it is a security-gating dialog).

@mbektas mbektas merged commit fb3dd03 into plmbr:main Jun 16, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Claude permission mode selector on Chat UI

2 participants