Skip to content
Merged
231 changes: 211 additions & 20 deletions server/claude-sdk.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,134 @@
*/

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

// Session tracking: Map of session IDs to active query instances
const activeSessions = new Map();
// In-memory registry of pending tool approvals keyed by requestId.
// This does not persist approvals or share across processes; it exists so the
// SDK can pause tool execution while the UI decides what to do.
const pendingToolApprovals = new Map();

// Default approval timeout kept under the SDK's 60s control timeout.
// This does not change SDK limits; it only defines how long we wait for the UI,
// introduced to avoid hanging the run when no decision arrives.
const TOOL_APPROVAL_TIMEOUT_MS = parseInt(process.env.CLAUDE_TOOL_APPROVAL_TIMEOUT_MS, 10) || 55000;

// Generate a stable request ID for UI approval flows.
// This does not encode tool details or get shown to users; it exists so the UI
// can respond to the correct pending request without collisions.
function createRequestId() {
// if clause is used because randomUUID is not available in older Node.js versions
if (typeof crypto.randomUUID === 'function') {
return crypto.randomUUID();
}
return crypto.randomBytes(16).toString('hex');
}

// Wait for a UI approval decision, honoring SDK cancellation.
// This does not auto-approve or auto-deny; it only resolves with UI input,
// and it cleans up the pending map to avoid leaks, introduced to prevent
// replying after the SDK cancels the control request.
function waitForToolApproval(requestId, options = {}) {
const { timeoutMs = TOOL_APPROVAL_TIMEOUT_MS, signal, onCancel } = options;

return new Promise(resolve => {
let settled = false;

const finalize = (decision) => {
if (settled) return;
settled = true;
cleanup();
resolve(decision);
};

const cleanup = () => {
pendingToolApprovals.delete(requestId);
clearTimeout(timeout);
if (signal && abortHandler) {
signal.removeEventListener('abort', abortHandler);
}
};

// Timeout is local to this process; it does not override SDK timing.
// It exists to prevent the UI prompt from lingering indefinitely.
const timeout = setTimeout(() => {
onCancel?.('timeout');
finalize(null);
}, timeoutMs);

const abortHandler = () => {
// If the SDK cancels the control request, stop waiting to avoid
// replying after the process is no longer ready for writes.
onCancel?.('cancelled');
finalize({ cancelled: true });
};

if (signal) {
if (signal.aborted) {
onCancel?.('cancelled');
finalize({ cancelled: true });
return;
}
signal.addEventListener('abort', abortHandler, { once: true });
}

pendingToolApprovals.set(requestId, (decision) => {
finalize(decision);
});
});
}

// Resolve a pending approval. This does not validate the decision payload;
// validation and tool matching remain in canUseTool, which keeps this as a
// lightweight WebSocket -> SDK relay.
function resolveToolApproval(requestId, decision) {
const resolver = pendingToolApprovals.get(requestId);
if (resolver) {
resolver(decision);
}
}

// Match stored permission entries against a tool + input combo.
// This only supports exact tool names and the Bash(command:*) shorthand
// used by the UI; it intentionally does not implement full glob semantics,
// introduced to stay consistent with the UI's "Allow rule" format.
function matchesToolPermission(entry, toolName, input) {
if (!entry || !toolName) {
return false;
}

if (entry === toolName) {
return true;
}

const bashMatch = entry.match(/^Bash\((.+):\*\)$/);
if (toolName === 'Bash' && bashMatch) {
const allowedPrefix = bashMatch[1];
let command = '';

if (typeof input === 'string') {
command = input.trim();
} else if (input && typeof input === 'object' && typeof input.command === 'string') {
command = input.command.trim();
}

if (!command) {
return false;
}

return command.startsWith(allowedPrefix);
}

return false;
}

/**
* Maps CLI options to SDK-compatible options format
Expand Down Expand Up @@ -52,29 +173,28 @@ function mapCliOptionsToSDK(options = {}) {
if (settings.skipPermissions && permissionMode !== 'plan') {
// When skipping permissions, use bypassPermissions mode
sdkOptions.permissionMode = 'bypassPermissions';
} else {
// Map allowed tools
let allowedTools = [...(settings.allowedTools || [])];

// Add plan mode default tools
if (permissionMode === 'plan') {
const planModeTools = ['Read', 'Task', 'exit_plan_mode', 'TodoRead', 'TodoWrite', 'WebFetch', 'WebSearch'];
for (const tool of planModeTools) {
if (!allowedTools.includes(tool)) {
allowedTools.push(tool);
}
}

// Map allowed tools (always set to avoid implicit "allow all" defaults).
// This does not grant permissions by itself; it just configures the SDK,
// introduced because leaving it undefined made the SDK treat it as "all tools allowed."
let allowedTools = [...(settings.allowedTools || [])];

// Add plan mode default tools
if (permissionMode === 'plan') {
const planModeTools = ['Read', 'Task', 'exit_plan_mode', 'TodoRead', 'TodoWrite', 'WebFetch', 'WebSearch'];
for (const tool of planModeTools) {
if (!allowedTools.includes(tool)) {
allowedTools.push(tool);
}
}
}

if (allowedTools.length > 0) {
sdkOptions.allowedTools = allowedTools;
}
sdkOptions.allowedTools = allowedTools;

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

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

// Gate tool usage with explicit UI approval when not auto-approved.
// This does not render UI or persist permissions; it only bridges to the UI
// via WebSocket and waits for the response, introduced so tool calls pause
// instead of auto-running when the allowlist is empty.
sdkOptions.canUseTool = async (toolName, input, context) => {
if (sdkOptions.permissionMode === 'bypassPermissions') {
return { behavior: 'allow', updatedInput: input };
}

const isDisallowed = (sdkOptions.disallowedTools || []).some(entry =>
matchesToolPermission(entry, toolName, input)
);
if (isDisallowed) {
return { behavior: 'deny', message: 'Tool disallowed by settings' };
}

const isAllowed = (sdkOptions.allowedTools || []).some(entry =>
matchesToolPermission(entry, toolName, input)
);
if (isAllowed) {
return { behavior: 'allow', updatedInput: input };
}

const requestId = createRequestId();
ws.send({
type: 'claude-permission-request',
requestId,
toolName,
input,
sessionId: capturedSessionId || sessionId || null
});

// Wait for the UI; if the SDK cancels, notify the UI so it can dismiss the banner.
// This does not retry or resurface the prompt; it just reflects the cancellation.
const decision = await waitForToolApproval(requestId, {
signal: context?.signal,
onCancel: (reason) => {
ws.send({
type: 'claude-permission-cancelled',
requestId,
reason,
sessionId: capturedSessionId || sessionId || null
});
}
});
if (!decision) {
return { behavior: 'deny', message: 'Permission request timed out' };
}

if (decision.cancelled) {
return { behavior: 'deny', message: 'Permission request cancelled' };
}

if (decision.allow) {
// rememberEntry only updates this run's in-memory allowlist to prevent
// repeated prompts in the same session; persistence is handled by the UI.
if (decision.rememberEntry && typeof decision.rememberEntry === 'string') {
if (!sdkOptions.allowedTools.includes(decision.rememberEntry)) {
sdkOptions.allowedTools.push(decision.rememberEntry);
}
if (Array.isArray(sdkOptions.disallowedTools)) {
sdkOptions.disallowedTools = sdkOptions.disallowedTools.filter(entry => entry !== decision.rememberEntry);
}
}
return { behavior: 'allow', updatedInput: decision.updatedInput ?? input };
}

return { behavior: 'deny', message: decision.message ?? 'User denied tool use' };
};

// Create SDK query instance
const queryInstance = query({
prompt: finalCommand,
Expand Down Expand Up @@ -526,5 +716,6 @@ export {
queryClaudeSDK,
abortClaudeSDKSession,
isClaudeSDKSessionActive,
getActiveClaudeSDKSessions
getActiveClaudeSDKSessions,
resolveToolApproval
};
14 changes: 13 additions & 1 deletion server/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ import fetch from 'node-fetch';
import mime from 'mime-types';

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