Skip to content

Commit 42b2d5e

Browse files
authored
Merge pull request #289 from siteboon/feat/show-grant-permission-button-in-chat-for-claude
Add inline permission grant for Claude tool errors
2 parents 72c4b07 + d3c4821 commit 42b2d5e

4 files changed

Lines changed: 613 additions & 23 deletions

File tree

server/claude-sdk.js

Lines changed: 211 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -13,13 +13,134 @@
1313
*/
1414

1515
import { query } from '@anthropic-ai/claude-agent-sdk';
16+
// Used to mint unique approval request IDs when randomUUID is not available.
17+
// This keeps parallel tool approvals from colliding; it does not add any crypto/security guarantees.
18+
import crypto from 'crypto';
1619
import { promises as fs } from 'fs';
1720
import path from 'path';
1821
import os from 'os';
1922
import { CLAUDE_MODELS } from '../shared/modelConstants.js';
2023

2124
// Session tracking: Map of session IDs to active query instances
2225
const activeSessions = new Map();
26+
// In-memory registry of pending tool approvals keyed by requestId.
27+
// This does not persist approvals or share across processes; it exists so the
28+
// SDK can pause tool execution while the UI decides what to do.
29+
const pendingToolApprovals = new Map();
30+
31+
// Default approval timeout kept under the SDK's 60s control timeout.
32+
// This does not change SDK limits; it only defines how long we wait for the UI,
33+
// introduced to avoid hanging the run when no decision arrives.
34+
const TOOL_APPROVAL_TIMEOUT_MS = parseInt(process.env.CLAUDE_TOOL_APPROVAL_TIMEOUT_MS, 10) || 55000;
35+
36+
// Generate a stable request ID for UI approval flows.
37+
// This does not encode tool details or get shown to users; it exists so the UI
38+
// can respond to the correct pending request without collisions.
39+
function createRequestId() {
40+
// if clause is used because randomUUID is not available in older Node.js versions
41+
if (typeof crypto.randomUUID === 'function') {
42+
return crypto.randomUUID();
43+
}
44+
return crypto.randomBytes(16).toString('hex');
45+
}
46+
47+
// Wait for a UI approval decision, honoring SDK cancellation.
48+
// This does not auto-approve or auto-deny; it only resolves with UI input,
49+
// and it cleans up the pending map to avoid leaks, introduced to prevent
50+
// replying after the SDK cancels the control request.
51+
function waitForToolApproval(requestId, options = {}) {
52+
const { timeoutMs = TOOL_APPROVAL_TIMEOUT_MS, signal, onCancel } = options;
53+
54+
return new Promise(resolve => {
55+
let settled = false;
56+
57+
const finalize = (decision) => {
58+
if (settled) return;
59+
settled = true;
60+
cleanup();
61+
resolve(decision);
62+
};
63+
64+
const cleanup = () => {
65+
pendingToolApprovals.delete(requestId);
66+
clearTimeout(timeout);
67+
if (signal && abortHandler) {
68+
signal.removeEventListener('abort', abortHandler);
69+
}
70+
};
71+
72+
// Timeout is local to this process; it does not override SDK timing.
73+
// It exists to prevent the UI prompt from lingering indefinitely.
74+
const timeout = setTimeout(() => {
75+
onCancel?.('timeout');
76+
finalize(null);
77+
}, timeoutMs);
78+
79+
const abortHandler = () => {
80+
// If the SDK cancels the control request, stop waiting to avoid
81+
// replying after the process is no longer ready for writes.
82+
onCancel?.('cancelled');
83+
finalize({ cancelled: true });
84+
};
85+
86+
if (signal) {
87+
if (signal.aborted) {
88+
onCancel?.('cancelled');
89+
finalize({ cancelled: true });
90+
return;
91+
}
92+
signal.addEventListener('abort', abortHandler, { once: true });
93+
}
94+
95+
pendingToolApprovals.set(requestId, (decision) => {
96+
finalize(decision);
97+
});
98+
});
99+
}
100+
101+
// Resolve a pending approval. This does not validate the decision payload;
102+
// validation and tool matching remain in canUseTool, which keeps this as a
103+
// lightweight WebSocket -> SDK relay.
104+
function resolveToolApproval(requestId, decision) {
105+
const resolver = pendingToolApprovals.get(requestId);
106+
if (resolver) {
107+
resolver(decision);
108+
}
109+
}
110+
111+
// Match stored permission entries against a tool + input combo.
112+
// This only supports exact tool names and the Bash(command:*) shorthand
113+
// used by the UI; it intentionally does not implement full glob semantics,
114+
// introduced to stay consistent with the UI's "Allow rule" format.
115+
function matchesToolPermission(entry, toolName, input) {
116+
if (!entry || !toolName) {
117+
return false;
118+
}
119+
120+
if (entry === toolName) {
121+
return true;
122+
}
123+
124+
const bashMatch = entry.match(/^Bash\((.+):\*\)$/);
125+
if (toolName === 'Bash' && bashMatch) {
126+
const allowedPrefix = bashMatch[1];
127+
let command = '';
128+
129+
if (typeof input === 'string') {
130+
command = input.trim();
131+
} else if (input && typeof input === 'object' && typeof input.command === 'string') {
132+
command = input.command.trim();
133+
}
134+
135+
if (!command) {
136+
return false;
137+
}
138+
139+
return command.startsWith(allowedPrefix);
140+
}
141+
142+
return false;
143+
}
23144

24145
/**
25146
* Maps CLI options to SDK-compatible options format
@@ -52,29 +173,28 @@ function mapCliOptionsToSDK(options = {}) {
52173
if (settings.skipPermissions && permissionMode !== 'plan') {
53174
// When skipping permissions, use bypassPermissions mode
54175
sdkOptions.permissionMode = 'bypassPermissions';
55-
} else {
56-
// Map allowed tools
57-
let allowedTools = [...(settings.allowedTools || [])];
58-
59-
// Add plan mode default tools
60-
if (permissionMode === 'plan') {
61-
const planModeTools = ['Read', 'Task', 'exit_plan_mode', 'TodoRead', 'TodoWrite', 'WebFetch', 'WebSearch'];
62-
for (const tool of planModeTools) {
63-
if (!allowedTools.includes(tool)) {
64-
allowedTools.push(tool);
65-
}
176+
}
177+
178+
// Map allowed tools (always set to avoid implicit "allow all" defaults).
179+
// This does not grant permissions by itself; it just configures the SDK,
180+
// introduced because leaving it undefined made the SDK treat it as "all tools allowed."
181+
let allowedTools = [...(settings.allowedTools || [])];
182+
183+
// Add plan mode default tools
184+
if (permissionMode === 'plan') {
185+
const planModeTools = ['Read', 'Task', 'exit_plan_mode', 'TodoRead', 'TodoWrite', 'WebFetch', 'WebSearch'];
186+
for (const tool of planModeTools) {
187+
if (!allowedTools.includes(tool)) {
188+
allowedTools.push(tool);
66189
}
67190
}
191+
}
68192

69-
if (allowedTools.length > 0) {
70-
sdkOptions.allowedTools = allowedTools;
71-
}
193+
sdkOptions.allowedTools = allowedTools;
72194

73-
// Map disallowed tools
74-
if (settings.disallowedTools && settings.disallowedTools.length > 0) {
75-
sdkOptions.disallowedTools = settings.disallowedTools;
76-
}
77-
}
195+
// Map disallowed tools (always set so the SDK doesn't treat "undefined" as permissive).
196+
// This does not override allowlists; it only feeds the canUseTool gate.
197+
sdkOptions.disallowedTools = settings.disallowedTools || [];
78198

79199
// Map model (default to sonnet)
80200
// Valid models: sonnet, opus, haiku, opusplan, sonnet[1m]
@@ -370,6 +490,76 @@ async function queryClaudeSDK(command, options = {}, ws) {
370490
tempImagePaths = imageResult.tempImagePaths;
371491
tempDir = imageResult.tempDir;
372492

493+
// Gate tool usage with explicit UI approval when not auto-approved.
494+
// This does not render UI or persist permissions; it only bridges to the UI
495+
// via WebSocket and waits for the response, introduced so tool calls pause
496+
// instead of auto-running when the allowlist is empty.
497+
sdkOptions.canUseTool = async (toolName, input, context) => {
498+
if (sdkOptions.permissionMode === 'bypassPermissions') {
499+
return { behavior: 'allow', updatedInput: input };
500+
}
501+
502+
const isDisallowed = (sdkOptions.disallowedTools || []).some(entry =>
503+
matchesToolPermission(entry, toolName, input)
504+
);
505+
if (isDisallowed) {
506+
return { behavior: 'deny', message: 'Tool disallowed by settings' };
507+
}
508+
509+
const isAllowed = (sdkOptions.allowedTools || []).some(entry =>
510+
matchesToolPermission(entry, toolName, input)
511+
);
512+
if (isAllowed) {
513+
return { behavior: 'allow', updatedInput: input };
514+
}
515+
516+
const requestId = createRequestId();
517+
ws.send({
518+
type: 'claude-permission-request',
519+
requestId,
520+
toolName,
521+
input,
522+
sessionId: capturedSessionId || sessionId || null
523+
});
524+
525+
// Wait for the UI; if the SDK cancels, notify the UI so it can dismiss the banner.
526+
// This does not retry or resurface the prompt; it just reflects the cancellation.
527+
const decision = await waitForToolApproval(requestId, {
528+
signal: context?.signal,
529+
onCancel: (reason) => {
530+
ws.send({
531+
type: 'claude-permission-cancelled',
532+
requestId,
533+
reason,
534+
sessionId: capturedSessionId || sessionId || null
535+
});
536+
}
537+
});
538+
if (!decision) {
539+
return { behavior: 'deny', message: 'Permission request timed out' };
540+
}
541+
542+
if (decision.cancelled) {
543+
return { behavior: 'deny', message: 'Permission request cancelled' };
544+
}
545+
546+
if (decision.allow) {
547+
// rememberEntry only updates this run's in-memory allowlist to prevent
548+
// repeated prompts in the same session; persistence is handled by the UI.
549+
if (decision.rememberEntry && typeof decision.rememberEntry === 'string') {
550+
if (!sdkOptions.allowedTools.includes(decision.rememberEntry)) {
551+
sdkOptions.allowedTools.push(decision.rememberEntry);
552+
}
553+
if (Array.isArray(sdkOptions.disallowedTools)) {
554+
sdkOptions.disallowedTools = sdkOptions.disallowedTools.filter(entry => entry !== decision.rememberEntry);
555+
}
556+
}
557+
return { behavior: 'allow', updatedInput: decision.updatedInput ?? input };
558+
}
559+
560+
return { behavior: 'deny', message: decision.message ?? 'User denied tool use' };
561+
};
562+
373563
// Create SDK query instance
374564
const queryInstance = query({
375565
prompt: finalCommand,
@@ -526,5 +716,6 @@ export {
526716
queryClaudeSDK,
527717
abortClaudeSDKSession,
528718
isClaudeSDKSessionActive,
529-
getActiveClaudeSDKSessions
719+
getActiveClaudeSDKSessions,
720+
resolveToolApproval
530721
};

server/index.js

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ import fetch from 'node-fetch';
5858
import mime from 'mime-types';
5959

6060
import { getProjects, getSessions, getSessionMessages, renameProject, deleteSession, deleteProject, addProjectManually, extractProjectDirectory, clearProjectDirectoryCache } from './projects.js';
61-
import { queryClaudeSDK, abortClaudeSDKSession, isClaudeSDKSessionActive, getActiveClaudeSDKSessions } from './claude-sdk.js';
61+
import { queryClaudeSDK, abortClaudeSDKSession, isClaudeSDKSessionActive, getActiveClaudeSDKSessions, resolveToolApproval } from './claude-sdk.js';
6262
import { spawnCursor, abortCursorSession, isCursorSessionActive, getActiveCursorSessions } from './cursor-cli.js';
6363
import { queryCodex, abortCodexSession, isCodexSessionActive, getActiveCodexSessions } from './openai-codex.js';
6464
import gitRoutes from './routes/git.js';
@@ -804,6 +804,18 @@ function handleChatConnection(ws) {
804804
provider,
805805
success
806806
});
807+
} else if (data.type === 'claude-permission-response') {
808+
// Relay UI approval decisions back into the SDK control flow.
809+
// This does not persist permissions; it only resolves the in-flight request,
810+
// introduced so the SDK can resume once the user clicks Allow/Deny.
811+
if (data.requestId) {
812+
resolveToolApproval(data.requestId, {
813+
allow: Boolean(data.allow),
814+
updatedInput: data.updatedInput,
815+
message: data.message,
816+
rememberEntry: data.rememberEntry
817+
});
818+
}
807819
} else if (data.type === 'cursor-abort') {
808820
console.log('[DEBUG] Abort Cursor session:', data.sessionId);
809821
const success = abortCursorSession(data.sessionId);

0 commit comments

Comments
 (0)