feat(chat): Claude permission-mode selector with gated bypass#377
Conversation
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
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.
|
@pjdoland this looks great overall. some issues I noticed.
|
|
on my machine I am getting this error in Bypass Permissions mode: |
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.
|
Thanks Mehmet! Both points in this comment are addressed.
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:
(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.)
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 |
Fixed. The CLI refuses 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 Related hardening while in here: Bypass now also correctly drops on a New chat session / |
Done. The confirm-to-arm Cancel / Bypass buttons are now compact (dropped the full |


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-modeand/exit-plan-modeslash 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
permissionModefield on each chat request. Plan approval, the hidden aliases, and the per-request mode all route through oneapply_permission_modehelper inclaude.py, so the tracked mode and a newclaude-permission-mode-changewebsocket 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_tooland the CLI's built-in Bash/Write/Edit run unconfirmed as the Jupyter user:claude_bypass_permissionspolicy (NBI_CLAUDE_BYPASS_PERMISSIONS_POLICY) defaulting toforce-off, the only policy whose default isn'tuser-choice. It threads through the existing seven-place policy framework.user-choiceexposes 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.resolve_permission_mode), so a hand-rolled request can't escalate past what the selector offers. Unknown modes, and the confirmation-skippingdontAsk/auto, fail closed todefault.permissions.disableBypassPermissionsModerefuses bypass regardless of the NBI policy (a corrupt managed-settings file fails closed), andpermissions.defaultModeseeds 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-managedalertdialogwith Escape-to-cancel.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_modetracking/notify, reset semantics, and the policy-response wiring.tests/test_websocket_handler_integration.py(+6): the security-critical boundary, driving realon_messageand asserting the clampedpermission_modeon the resultingChatRequest(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.tsc --noEmit,lint:check, jest 365.Risks / follow-ups
Closes #359