// // 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 = "\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("