90 lines
3.8 KiB
Markdown
90 lines
3.8 KiB
Markdown
# 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](questions.md) |
|
|
| `ExitPlanMode` | [Plan mode](plan-mode.md) |
|
|
| 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
|
|
|
|
```jsonc
|
|
{"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:
|
|
|
|
1. `diff.proposedChange()` reconstructs the file **as it would be after** the
|
|
change (applying `old_string`→`new_string`, or the `edits[]` in order).
|
|
2. That content is staged to
|
|
`<storage>/proposed/<last-8-of-request-id>/<basename>` and opened.
|
|
3. 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: true` does **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`/`deny` must not bypass the plan and question flows — a bare
|
|
allow on a plan would skip the `setMode` update and leave the session stuck in
|
|
plan mode. `decide()` routes those two back through `_present()`.
|