Files
claude-nova/Scripts/permissions.js
T
2026-08-11 12:38:02 -03:00

939 lines
31 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//
// permissions.js — answering the CLI's `can_use_tool` requests.
//
// Every prompt is registered in the sidebar first and only then surfaced as a
// panel. Dismissing the panel therefore does not strand the session: the
// request stays in "Needs Approval" until it is answered from the context
// menu, or the CLI withdraws it.
//
// Two tools are their own interaction surface rather than a yes/no gate, and
// get purpose-built handling:
//
// AskUserQuestion — the host collects the answers and returns them by
// rewriting the tool input (`updatedInput.answers`).
// ExitPlanMode — the host shows the plan and either approves it (moving
// the session out of plan mode) or sends back feedback.
//
const { log, oneLine, stripAnsi, warn, conf, basename } = require("./util.js");
const { summarizeTool, friendlyToolName, planTitle } = require("./session.js");
const diff = require("./diff.js");
const ALLOW = "Allow";
const ALLOW_ALWAYS = "Allow for Session";
const DENY = "Deny";
const REVIEW = "Review Change…";
const OTHER = "Other…";
const FEEDBACK_HEADER =
"<!-- Feedback on Claude's plan.\n" +
" Write below, then choose “Send Feedback” in the Claude sidebar.\n" +
" Editing the plan itself works too — approving sends your version. -->\n\n";
/**
* The actions a pending request offers, rendered as rows under it in the
* sidebar. Ids are handed straight back to `decide()`.
*/
function actionsFor(approval) {
const name = approval.request.tool_name;
if (name === "ExitPlanMode") {
return [
{ id: "approve-auto", label: "Approve — auto-accept edits" },
{ id: "approve-manual", label: "Approve — ask before each edit" },
approval.feedbackEditor
? { id: "send-feedback", label: "Send Feedback" }
: { id: "write-feedback", label: "Write Feedback…" },
{ id: "show", label: "Open Plan" },
];
}
if (name === "AskUserQuestion") {
return [
{ id: "answer", label: "Answer…" },
{ id: "deny", label: "Dismiss" },
];
}
if (approval.proposalEditor) {
const actions = [
{ id: "apply", label: "Apply Change" },
{ id: "deny", label: "Reject" },
{ id: "open-proposal", label: "Open Proposal" },
{ id: "review", label: "Show Diff" },
];
if (!approval.request.suppress_always_allow_rule) {
actions.push({ id: "always", label: "Allow for Session" });
}
return actions;
}
const actions = [{ id: "allow", label: "Allow" }];
if (!approval.request.suppress_always_allow_rule) {
actions.push({ id: "always", label: "Allow for Session" });
}
if (approval.preview) {
actions.push({ id: "review", label: "Review Change…" });
}
actions.push({ id: "deny", label: "Deny" });
return actions;
}
/** Quote a passage so it reads as a citation in the message back to Claude. */
function quote(text) {
return String(text)
.split("\n")
.map((line) => "> " + line)
.join("\n");
}
/** Promise wrappers around Nova's callback-style panels. */
function choicePalette(choices, placeholder) {
return new Promise((resolve) => {
nova.workspace.showChoicePalette(choices, { placeholder: placeholder }, (choice, index) => {
resolve(index === null || index === undefined ? null : index);
});
});
}
function inputPanel(message, options) {
return new Promise((resolve) => {
nova.workspace.showInputPanel(message, options || {}, (value) => resolve(value));
});
}
function actionPanel(message, buttons) {
return new Promise((resolve) => {
nova.workspace.showActionPanel(message, { buttons: buttons }, (index) => {
resolve(index === null || index === undefined ? null : index);
});
});
}
class PermissionBroker {
constructor(session, transcript) {
this.session = session;
this.transcript = transcript;
}
/**
* Handle one `can_use_tool` request.
* Resolves with a PermissionResult once the user decides.
*/
handle(request, requestId) {
return new Promise((resolve) => {
const summary = summarizeTool(request.tool_name, request.input);
const preview = diff.previewForTool(request.tool_name, request.input);
const approval = {
requestId: requestId,
request: request,
summary: summary,
preview: preview,
settled: false,
resolve: (result) => {
if (approval.settled) return;
approval.settled = true;
this.session.removeApproval(requestId);
resolve(result);
},
};
this.session.addApproval(approval);
this.transcript.update();
this._present(approval);
});
}
/** Route a request to the surface that fits it. */
_present(approval) {
const name = approval.request.tool_name;
if (name === "AskUserQuestion") {
this._askQuestions(approval);
return;
}
if (name === "ExitPlanMode") {
this._reviewPlan(approval);
return;
}
// A file change gets an editable proposal rather than a yes/no panel,
// so it can be amended before it lands.
if (approval.preview && conf("claudenova.reviewEdits", "editor") === "editor") {
this._reviewChange(approval);
return;
}
if (approval.request.requires_user_interaction) {
approval.resolve({
behavior: "deny",
message:
`${name} needs its own interactive prompt, which Nova cannot display. ` +
"Run it from `claude` in a terminal, or take a different approach.",
});
return;
}
this._prompt(approval);
}
/** Withdraw a prompt the CLI no longer needs an answer for. */
cancel(requestId) {
const approval = this.session.findApproval(requestId);
if (!approval) return;
approval.settled = true;
this.session.removeApproval(requestId);
log("permission request withdrawn:", requestId);
}
/** Answer from the sidebar: "allow" | "always" | "deny" | "review". */
async decide(approval, choice) {
if (!approval || approval.settled) return;
// Plan actions, chosen from the sidebar rather than a panel.
switch (choice) {
case "approve-auto":
await this._approvePlan(approval, "acceptEdits");
return;
case "approve-manual":
await this._approvePlan(approval, "default");
return;
case "write-feedback":
await this.writeFeedback(approval);
return;
case "send-feedback":
await this.sendFeedback(approval);
return;
case "show":
await this._showPlan(approval.request.input || {});
return;
case "answer":
await this._askQuestions(approval);
return;
case "apply":
await this.applyChange(approval);
return;
case "open-proposal":
if (approval.proposalPath) await nova.workspace.openFile(approval.proposalPath);
return;
default:
break;
}
// These two carry their own decision surface; a bare allow would skip
// the mode change a plan needs, and a bare deny would drop an answer.
const name = approval.request.tool_name;
if (name === "AskUserQuestion" || name === "ExitPlanMode") {
if (choice === "deny") {
approval.resolve({
behavior: "deny",
message: "The user declined this in Nova.",
});
return;
}
this._present(approval);
return;
}
switch (choice) {
case "allow":
approval.resolve({ behavior: "allow" });
return;
case "always":
approval.resolve(this._allowAlways(approval.request));
return;
case "deny":
approval.resolve({
behavior: "deny",
message: "The user declined this action in Nova.",
});
return;
case "review":
await this.review(approval);
return;
default:
return;
}
}
/**
* Re-open whatever surface this request needs. For a plain tool that is the
* change preview; for questions and plans it is the flow itself.
*/
async review(approval) {
if (approval.settled) return;
const name = approval.request.tool_name;
if (name === "AskUserQuestion" || name === "ExitPlanMode") {
this._present(approval);
return;
}
if (approval.preview) {
await diff.showPreview(approval.preview);
} else if (approval.request.input && approval.request.input.file_path) {
await nova.workspace.openFile(approval.request.input.file_path);
} else {
nova.workspace.showInformativeMessage(
"There is nothing to preview for this request:\n\n" +
oneLine(JSON.stringify(approval.request.input || {}), 400)
);
}
if (!approval.settled) this._prompt(approval);
}
// -- AskUserQuestion ---------------------------------------------------
/**
* Ask each question in turn and hand the answers back as `updatedInput`.
* The CLI feeds them to the tool, which reports them to Claude.
*/
async _askQuestions(approval) {
const input = approval.request.input || {};
const questions = Array.isArray(input.questions) ? input.questions : [];
if (questions.length === 0) {
approval.resolve({ behavior: "allow" });
return;
}
const answers = {};
for (const question of questions) {
const answer = question.multiSelect
? await this._askMultiple(question)
: await this._askOne(question);
if (answer === null) {
approval.resolve({
behavior: "deny",
message: "The user dismissed the question without answering.",
});
return;
}
answers[question.question] = answer;
}
approval.resolve({
behavior: "allow",
updatedInput: Object.assign({}, input, { answers: answers }),
});
}
_optionLabels(question) {
return (question.options || []).map((option) => {
const description = option.description ? ` — ${oneLine(option.description, 70)}` : "";
return `${option.label}${description}`;
});
}
async _askOne(question) {
const choices = this._optionLabels(question).concat([OTHER]);
const index = await choicePalette(choices, oneLine(question.question, 90));
if (index === null) return null;
if (index === choices.length - 1) {
const custom = await inputPanel(question.question, {
label: question.header || "Answer",
placeholder: "Your answer",
prompt: "Answer",
});
if (custom === null || !custom.trim()) return null;
return custom.trim();
}
return question.options[index].label;
}
async _askMultiple(question) {
const chosen = new Set();
for (;;) {
const choices = (question.options || []).map((option, i) => {
const mark = chosen.has(i) ? "✓ " : " ";
const description = option.description ? ` — ${oneLine(option.description, 60)}` : "";
return `${mark}${option.label}${description}`;
});
choices.push(chosen.size > 0 ? `Done — ${chosen.size} selected` : OTHER);
const index = await choicePalette(
choices,
`${oneLine(question.question, 70)} (choose any)`
);
if (index === null) return null;
if (index === choices.length - 1) {
if (chosen.size > 0) break;
const custom = await inputPanel(question.question, {
label: question.header || "Answer",
placeholder: "Your answer",
prompt: "Answer",
});
if (custom === null || !custom.trim()) return null;
return custom.trim();
}
if (chosen.has(index)) chosen.delete(index);
else chosen.add(index);
}
return Array.from(chosen)
.sort((a, b) => a - b)
.map((i) => question.options[i].label)
.join(", ");
}
// -- ExitPlanMode ------------------------------------------------------
/**
* Show the plan, then either approve it — which also chooses how the rest
* of the session handles edits — or send Claude back to planning.
*
* The plan opens as an ordinary editable document, and edits made there are
* the primary way to give feedback: an approved plan is handed back through
* `updatedInput.plan`, which the CLI writes to the plan file, so the plan of
* record becomes the user's version rather than Claude's draft.
*/
async _reviewPlan(approval) {
const input = approval.request.input || {};
await this._showPlan(input);
// A notification rather than a panel: it does not block the window, so
// the plan stays scrollable and editable while the decision is open,
// and the same choices sit under the request in the sidebar.
const request = new NotificationRequest("claudenova-plan-" + approval.requestId);
request.title = "Claude finished planning";
request.body =
(planTitle(input.plan) || "The plan is ready to review.") +
"\n\nEdit the plan in its tab to change it — approving sends your version back.";
request.actions = ["Approve", "Auto-accept edits", "Write Feedback…"];
let response = null;
try {
response = await nova.notifications.add(request);
} catch (err) {
warn("could not post the plan notification:", err);
await this._reviewPlanFallback(approval);
return;
}
if (!response || response.actionIdx === null || response.actionIdx === undefined) {
// Dismissed — the request stays in the sidebar with its actions.
this.transcript.update();
return;
}
if (response.actionIdx === 2) {
await this.writeFeedback(approval);
return;
}
await this._approvePlan(approval, response.actionIdx === 1 ? "acceptEdits" : "default");
}
/** Panel version, for when notifications are unavailable. */
async _reviewPlanFallback(approval) {
const buttons = [
"Approve — ask before each edit",
"Approve — auto-accept edits",
"Write Feedback…",
];
const index = await actionPanel(
"Claude finished planning.\n\n" +
(planTitle((approval.request.input || {}).plan) || "The plan is ready to review."),
buttons
);
if (index === null) {
this.transcript.update();
return;
}
if (index === 2) {
await this.writeFeedback(approval);
return;
}
await this._approvePlan(approval, index === 1 ? "acceptEdits" : "default");
}
/**
* Approve the plan, carrying any edits the user made in its tab, and set
* how the rest of the session handles edits.
*/
async _approvePlan(approval, mode) {
if (approval.settled) return;
const input = approval.request.input || {};
const proposed = String(input.plan || "");
const current = this._currentPlanText(input);
const edited = current.trim() !== proposed.trim() && current.trim().length > 0;
this.session.permissionMode = mode;
this.session.changed();
const result = {
behavior: "allow",
updatedPermissions: [{ type: "setMode", mode: mode, destination: "session" }],
};
if (edited) {
result.updatedInput = Object.assign({}, input, { plan: current });
this.session.addNotice("Approved your edited version of the plan.", "info");
}
approval.resolve(result);
}
/**
* Open a scratch document for feedback. Nova has no multi-line field an
* extension can put in a sidebar, so the editor itself is the text box —
* write as much as you like, then choose Send Feedback.
*/
async writeFeedback(approval) {
if (approval.settled) return;
if (approval.feedbackEditor && !approval.feedbackEditor.document.isClosed) {
await nova.workspace.openFile(approval.feedbackEditor.document.uri);
this.session.changed();
return;
}
try {
const editor = await nova.workspace.openNewTextDocument({
content: FEEDBACK_HEADER,
syntax: "markdown",
line: 5,
});
if (editor) {
approval.feedbackEditor = editor;
this.session.addNotice(
"Write your feedback in the new tab, then choose Send Feedback in the Claude sidebar.",
"info"
);
this.session.changed();
return;
}
} catch (err) {
warn("could not open a feedback document:", err);
}
// No scratch tab available — fall back to the one-line panel.
const notes = await inputPanel("What should Claude change about the plan?", {
label: "Notes",
placeholder: "Your feedback",
prompt: "Send",
});
if (notes === null) return;
this._sendFeedbackText(approval, notes);
}
/** Read the feedback document and send it back to Claude. */
async sendFeedback(approval) {
if (approval.settled) return;
let text = "";
const editor = approval.feedbackEditor;
if (editor && editor.document && !editor.document.isClosed) {
const document = editor.document;
text = document.getTextInRange(new Range(0, document.length));
const end = text.indexOf("-->");
if (text.trimStart().startsWith("<!--") && end >= 0) {
text = text.slice(end + 3);
}
}
this._sendFeedbackText(approval, text);
}
/** Combine notes, a highlighted passage, and plan edits into one reply. */
_sendFeedbackText(approval, notes) {
const input = approval.request.input || {};
const proposed = String(input.plan || "");
const current = this._currentPlanText(input);
const edited = current.trim() !== proposed.trim() && current.trim().length > 0;
const highlighted = this._planSelection(input);
const trimmed = String(notes || "").trim();
const parts = ["The user is not ready to proceed and wants to keep planning."];
if (highlighted) {
parts.push("", "They highlighted this part of the plan:", "", quote(highlighted));
}
if (trimmed) {
parts.push("", "Their feedback:", "", trimmed);
}
if (edited) {
parts.push(
"",
"They also edited the plan directly. This is their version — treat it as authoritative:",
"",
current.trim()
);
}
if (!highlighted && !trimmed && !edited) {
parts.push("Revise the plan before proceeding.");
}
approval.resolve({ behavior: "deny", message: parts.join("\n") });
}
/**
* The plan as it stands: the open document (which may hold unsaved edits),
* else the file on disk, else what Claude proposed.
*/
_currentPlanText(input) {
const path = input.planFilePath;
if (path) {
const editor = (nova.workspace.textEditors || []).find(
(candidate) => candidate.document && candidate.document.path === path
);
if (editor) {
return editor.document.getTextInRange(new Range(0, editor.document.length));
}
const onDisk = diff.readFile(path);
if (onDisk !== null) return onDisk;
}
return String(input.plan || "");
}
/** Text the user highlighted in the plan document, if any. */
_planSelection(input) {
const editor = nova.workspace.activeTextEditor;
if (!editor || !editor.document) return null;
const path = input.planFilePath;
if (path && editor.document.path !== path) return null;
const range = editor.selectedRange;
if (!range || range.empty) return null;
const text = editor.document.getTextInRange(range).trim();
return text || null;
}
async _showPlan(input) {
const path = input.planFilePath;
if (path && nova.fs.access(path, nova.fs.R_OK)) {
try {
await nova.workspace.openFile(path);
return;
} catch (err) {
warn("could not open the plan file:", err);
}
}
if (!input.plan) return;
try {
await nova.workspace.openNewTextDocument({
content: String(input.plan),
syntax: "markdown",
});
} catch (err) {
warn("could not show the plan:", err);
nova.workspace.showInformativeMessage(oneLine(input.plan, 800));
}
}
// -- editable change proposals -----------------------------------------
/**
* Open the file as it would be after the change, and let ordinary editor
* gestures settle it: Save applies, Close rejects. Anything the user edits
* in that tab is what gets written — the change can be amended, not just
* accepted or refused.
*/
async _reviewChange(approval) {
const preview = approval.preview;
const path = await this._writeProposal(approval, preview);
if (!path) {
// No scratch file — fall back to the panel with a diff.
this._prompt(approval);
return;
}
let editor = null;
try {
editor = await nova.workspace.openFile(path);
} catch (err) {
warn("could not open the proposed change:", err);
}
if (!editor) {
this._prompt(approval);
return;
}
approval.proposalEditor = editor;
this.session.changed();
// Save applies, close rejects — the same gestures the editor already
// trains, and the reason the tab is a real file rather than a scratch
// document (an untitled document cannot be saved without a dialog).
const disposables = [];
const cleanup = () => {
for (const d of disposables) {
try {
d.dispose();
} catch (_) {
/* already disposed */
}
}
};
disposables.push(
editor.onDidSave(() => {
if (approval.settled) return;
cleanup();
this.applyChange(approval);
})
);
disposables.push(
editor.onDidDestroy(() => {
if (approval.settled) return;
cleanup();
approval.resolve({
behavior: "deny",
message: "The user closed the proposed change without applying it.",
});
this.session.addNotice(`Rejected the change to ${basename(preview.path)}.`, "info");
})
);
const request = new NotificationRequest("claudenova-change-" + approval.requestId);
request.title = preview.title;
request.body =
`+${preview.additions} ${preview.deletions}\n\n` +
"Edit the proposal if you want changes, then save to apply it. " +
"Closing the tab rejects it.";
request.actions = ["Apply", "Reject", "Show Diff"];
let response = null;
try {
response = await nova.notifications.add(request);
} catch (err) {
warn("could not post the change notification:", err);
return; // the tab and its gestures are still live
}
if (approval.settled) return;
if (response && response.actionIdx === 0) {
cleanup();
await this.applyChange(approval);
} else if (response && response.actionIdx === 1) {
cleanup();
approval.resolve({
behavior: "deny",
message: "The user rejected the proposed change in Nova.",
});
} else if (response && response.actionIdx === 2) {
await diff.showPreview(preview);
}
}
/** Write the proposed file contents somewhere Nova can open and save. */
async _writeProposal(approval, preview) {
try {
// One directory per request: two files with the same name can be
// under review at once, and the tab should still read `index.js`.
const root = nova.path.join(
nova.workspace.path
? nova.extension.workspaceStoragePath
: nova.extension.globalStoragePath,
"proposed"
);
nova.fs.mkdir(root);
const directory = nova.path.join(root, String(approval.requestId).slice(-8));
nova.fs.mkdir(directory);
// Keep the extension so the proposal is syntax-highlighted.
const path = nova.path.join(directory, basename(preview.path));
const file = nova.fs.open(path, "w");
file.write(preview.after);
file.close();
approval.proposalPath = path;
return path;
} catch (err) {
warn("could not stage the proposed change:", err);
return null;
}
}
/** Apply the proposal, carrying any edits the user made to it. */
async applyChange(approval) {
if (approval.settled) return;
const preview = approval.preview;
const current = this._proposalText(approval);
const amended = current !== null && current !== preview.after;
const result = { behavior: "allow" };
if (amended) {
const updated = this._inputForContent(approval.request, preview, current);
if (updated) {
result.updatedInput = updated;
this.session.addNotice(
`Applied your amended version of ${basename(preview.path)}.`,
"info"
);
} else {
warn("could not express the amended change; applying Claude's version");
}
}
approval.resolve(result);
}
/**
* Rewrite the tool input so it produces `content`.
*
* A whole-file replacement is used rather than trying to recover a minimal
* edit: the old text is unique in the file by construction, so it always
* matches, and the result is exactly what the user saw.
*/
_inputForContent(request, preview, content) {
const input = request.input || {};
if (request.tool_name === "Write") {
return Object.assign({}, input, { content: content });
}
if (!preview.exists || typeof preview.before !== "string") return null;
if (request.tool_name === "Edit" || request.tool_name === "Update") {
return Object.assign({}, input, {
old_string: preview.before,
new_string: content,
replace_all: false,
});
}
if (request.tool_name === "MultiEdit") {
return Object.assign({}, input, {
edits: [{ old_string: preview.before, new_string: content, replace_all: false }],
});
}
return null;
}
/** The proposal as it stands, preferring unsaved editor content. */
_proposalText(approval) {
const editor = approval.proposalEditor;
if (editor && editor.document && !editor.document.isClosed) {
const document = editor.document;
return document.getTextInRange(new Range(0, document.length));
}
if (approval.proposalPath) {
return diff.readFile(approval.proposalPath);
}
return null;
}
// -- plain tool approvals ----------------------------------------------
_allowAlways(request) {
return {
behavior: "allow",
updatedPermissions: [
{
type: "addRules",
rules: [{ toolName: request.tool_name }],
behavior: "allow",
destination: "session",
},
],
};
}
_message(approval) {
const request = approval.request;
const lines = [];
const title = request.title || `Claude wants to use ${friendlyToolName(request.tool_name)}`;
lines.push(title);
if (approval.summary) {
lines.push("");
lines.push(approval.summary);
}
if (approval.preview) {
const { additions, deletions } = approval.preview;
lines.push("");
lines.push(`${approval.preview.title} · +${additions} ${deletions}`);
}
const reason = stripAnsi(request.decision_reason || request.description || "");
if (reason) {
lines.push("");
lines.push(oneLine(reason, 300));
}
if (request.blocked_path) {
lines.push("");
lines.push(`Outside the workspace: ${request.blocked_path}`);
}
return lines.join("\n");
}
_prompt(approval) {
const buttons = [ALLOW];
if (!approval.request.suppress_always_allow_rule) {
buttons.push(ALLOW_ALWAYS);
}
if (approval.preview) {
buttons.push(REVIEW);
}
buttons.push(DENY);
nova.workspace.showActionPanel(
this._message(approval),
{ buttons: buttons },
(index) => {
if (index === null || index === undefined) {
// Dismissed — leave it pending in the sidebar.
this.transcript.update();
return;
}
const choice = buttons[index];
if (choice === ALLOW) this.decide(approval, "allow");
else if (choice === ALLOW_ALWAYS) this.decide(approval, "always");
else if (choice === REVIEW) this.decide(approval, "review");
else this.decide(approval, "deny");
}
);
}
}
module.exports = { PermissionBroker, actionsFor };