3.8 KiB
Tool approvals
Status: Implemented · Code: Scripts/permissions.js, Scripts/diff.js
Every can_use_tool control request becomes a decision surface. The request is
registered in the sidebar first, then surfaced — so dismissing a
notification or panel never strands the session.
Routing
_present(approval) picks the surface:
| Request | Surface |
|---|---|
AskUserQuestion |
Questions |
ExitPlanMode |
Plan mode |
A file change (preview reconstructed) |
Editable proposal, below |
Other requires_user_interaction |
Denied with an explanation |
| Anything else | Action panel (Allow / Allow for Session / Review / Deny) |
PermissionResult shapes
{"behavior":"allow"}
{"behavior":"allow","updatedInput":{…}} // run with different input
{"behavior":"allow","updatedPermissions":[{…}]} // + change rules or mode
{"behavior":"deny","message":"…"} // message is shown to Claude
updatedPermissions entries used here: {type:"addRules", rules:[{toolName}], behavior:"allow", destination:"session"} for Allow for Session, and
{type:"setMode", mode, destination:"session"} for plan approval.
Editable proposals
The claudecode.nvim lesson: a proposal should be a document you can amend, not a yes/no gate. When Claude wants to edit or create a file:
diff.proposedChange()reconstructs the file as it would be after the change (applyingold_string→new_string, or theedits[]in order).- That content is staged to
<storage>/proposed/<last-8-of-request-id>/<basename>and opened. - Editor gestures settle it:
| Gesture | Result |
|---|---|
Save (onDidSave) |
Apply — with any edits the user made |
Close (onDidDestroy) |
Deny |
A notification and the sidebar rows carry the same choices.
Why a staged real file, not an untitled document: an untitled document
cannot be saved without a Save dialog, which would break the gesture. Keeping
the original file extension also preserves syntax highlighting. Each request
stages into its own directory so two files with the same basename can be under
review at once while the tab still reads index.js. Stale proposals are cleared
on new session (Controller.clearStagedProposals).
Expressing an amendment. _inputForContent() rewrites the tool input as a
whole-file replacement rather than trying to recover a minimal edit:
| Tool | Amended input |
|---|---|
Write |
{…input, content} |
Edit / Update |
{…input, old_string: <entire original file>, new_string: <content>, replace_all: false} |
MultiEdit |
{…input, edits:[{old_string: <entire original file>, new_string: <content>}]} |
The whole original file is unique within itself by construction, so old_string
always matches exactly once — and the result is precisely what the user saw.
Set claudenova.reviewEdits to panel for the older quick yes/no.
Diff rendering
diff.js is an LCS line diff with common prefix/suffix trimming and a
1,000,000-cell guard beyond which it degrades to a plain replacement. Rendered
unified with 3 lines of context, capped at 400 lines. splitLines drops the
final empty element after a trailing newline — otherwise every diff carried a
phantom blank line and a new file showed -1.
Gotchas
requires_user_interaction: truedoes not mean "cannot be handled" — it means one-tap approve/deny is wrong because the tool's own card is the interaction surface. Both tools that set it are handled natively; blanket denying it (the first implementation) made plan mode a dead end.- Sidebar
allow/denymust not bypass the plan and question flows — a bare allow on a plan would skip thesetModeupdate and leave the session stuck in plan mode.decide()routes those two back through_present().