fix: respect permission mode during recovery/desync approval handling (#286)

Co-authored-by: Letta <noreply@letta.com>
This commit is contained in:
Charles Packer
2025-12-17 21:44:54 -08:00
committed by GitHub
parent 0203854c2e
commit 79dfd91474

View File

@@ -982,6 +982,17 @@ export default function App({
return;
}
// Check if user cancelled before starting permission checks
if (
userCancelledRef.current ||
abortControllerRef.current?.signal.aborted
) {
setStreaming(false);
markIncompleteToolsAsCancelled(buffersRef.current);
refreshDerived();
return;
}
// Check permissions for all approvals (including fancy UI tools)
const approvalResults = await Promise.all(
approvalsToProcess.map(async (approvalItem) => {
@@ -1231,6 +1242,17 @@ export default function App({
return;
}
// Check if user cancelled before showing dialog
if (
userCancelledRef.current ||
abortControllerRef.current?.signal.aborted
) {
setStreaming(false);
markIncompleteToolsAsCancelled(buffersRef.current);
refreshDerived();
return;
}
// Show approval dialog for tools that need user input
setPendingApprovals(needsUserInput.map((ac) => ac.approval));
setApprovalContexts(
@@ -2832,48 +2854,246 @@ ${recentCommits}
}
if (existingApprovals && existingApprovals.length > 0) {
// There are pending approvals - show them and DON'T send the message yet
// The message will be restored to the input field for the user to decide
// Remove the optimistic user message from transcript to avoid duplication
buffersRef.current.byId.delete(userId);
const orderIndex = buffersRef.current.order.indexOf(userId);
if (orderIndex !== -1) {
buffersRef.current.order.splice(orderIndex, 1);
}
setStreaming(false); // Stop streaming indicator
setPendingApprovals(existingApprovals);
// Analyze approval contexts for ALL pending approvals
const contexts = await Promise.all(
existingApprovals.map(async (approval) => {
// There are pending approvals - check permissions first (respects yolo mode)
const approvalResults = await Promise.all(
existingApprovals.map(async (approvalItem) => {
if (!approvalItem.toolName) {
return {
approval: approvalItem,
permission: {
decision: "deny" as const,
reason: "Tool call incomplete - missing name",
},
context: null,
};
}
const parsedArgs = safeJsonParseOr<Record<string, unknown>>(
approval.toolArgs,
approvalItem.toolArgs,
{},
);
return await analyzeToolApproval(approval.toolName, parsedArgs);
const permission = await checkToolPermission(
approvalItem.toolName,
parsedArgs,
);
const context = await analyzeToolApproval(
approvalItem.toolName,
parsedArgs,
);
return { approval: approvalItem, permission, context };
}),
);
// Check again after async approval analysis
// Check if user cancelled during permission check
if (
userCancelledRef.current ||
abortControllerRef.current?.signal.aborted
) {
// User cancelled during analysis - don't show dialog
buffersRef.current.byId.delete(userId);
const orderIndex = buffersRef.current.order.indexOf(userId);
if (orderIndex !== -1) {
buffersRef.current.order.splice(orderIndex, 1);
}
setStreaming(false);
refreshDerived();
return { submitted: false };
}
setApprovalContexts(contexts);
// Categorize by permission decision
const needsUserInput: typeof approvalResults = [];
const autoAllowed: typeof approvalResults = [];
const autoDenied: typeof approvalResults = [];
// Refresh to remove the message from UI
refreshDerived();
for (const ac of approvalResults) {
const { approval, permission } = ac;
let decision = permission.decision;
// Return false = message NOT submitted, will be restored to input
return { submitted: false };
// Fancy tools always need user input (except if denied)
if (isFancyUITool(approval.toolName) && decision === "allow") {
decision = "ask";
}
if (decision === "ask") {
needsUserInput.push(ac);
} else if (decision === "deny") {
autoDenied.push(ac);
} else {
autoAllowed.push(ac);
}
}
// If all approvals can be auto-handled (yolo mode), process them immediately
if (needsUserInput.length === 0) {
// Execute auto-allowed tools
const autoAllowedResults = await Promise.all(
autoAllowed.map(async (ac) => {
const parsedArgs = safeJsonParseOr<Record<string, unknown>>(
ac.approval.toolArgs,
{},
);
const result = await executeTool(
ac.approval.toolName,
parsedArgs,
{ toolCallId: ac.approval.toolCallId },
);
// Update buffers with tool return for UI
onChunk(buffersRef.current, {
message_type: "tool_return_message",
id: "dummy",
date: new Date().toISOString(),
tool_call_id: ac.approval.toolCallId,
tool_return: result.toolReturn,
status: result.status,
stdout: result.stdout,
stderr: result.stderr,
});
return {
toolCallId: ac.approval.toolCallId,
result,
};
}),
);
// Create denial results for auto-denied and update UI
const autoDeniedResults = autoDenied.map((ac) => {
const reason =
"matchedRule" in ac.permission && ac.permission.matchedRule
? `Permission denied by rule: ${ac.permission.matchedRule}`
: `Permission denied: ${ac.permission.reason || "Unknown"}`;
// Update buffers with denial for UI
onChunk(buffersRef.current, {
message_type: "tool_return_message",
id: "dummy",
date: new Date().toISOString(),
tool_call_id: ac.approval.toolCallId,
tool_return: `Error: request to call tool denied. User reason: ${reason}`,
status: "error",
stdout: null,
stderr: null,
});
return {
type: "approval" as const,
tool_call_id: ac.approval.toolCallId,
approve: false,
reason,
};
});
refreshDerived();
// Combine results and send directly with the user's message
// (can't use state here as it won't be available until next render)
const recoveryApprovalResults = [
...autoAllowedResults.map((ar) => ({
type: "approval" as const,
tool_call_id: ar.toolCallId,
approve: true,
tool_return: ar.result.toolReturn,
})),
...autoDeniedResults,
];
// Build and send initialInput directly
const initialInput: Array<MessageCreate | ApprovalCreate> = [
{
type: "approval",
approvals: recoveryApprovalResults,
},
{
type: "message",
role: "user",
content:
messageContent as unknown as MessageCreate["content"],
},
];
await processConversation(initialInput);
clearPlaceholdersInText(msg);
return { submitted: true };
} else {
// Some approvals need user input - show dialog
// Remove the optimistic user message from transcript
buffersRef.current.byId.delete(userId);
const orderIndex = buffersRef.current.order.indexOf(userId);
if (orderIndex !== -1) {
buffersRef.current.order.splice(orderIndex, 1);
}
setStreaming(false);
setPendingApprovals(needsUserInput.map((ac) => ac.approval));
setApprovalContexts(
needsUserInput
.map((ac) => ac.context)
.filter(Boolean) as ApprovalContext[],
);
// Execute auto-allowed tools and store results
const autoAllowedWithResults = await Promise.all(
autoAllowed.map(async (ac) => {
const parsedArgs = safeJsonParseOr<Record<string, unknown>>(
ac.approval.toolArgs,
{},
);
const result = await executeTool(
ac.approval.toolName,
parsedArgs,
{ toolCallId: ac.approval.toolCallId },
);
// Update buffers with tool return for UI
onChunk(buffersRef.current, {
message_type: "tool_return_message",
id: "dummy",
date: new Date().toISOString(),
tool_call_id: ac.approval.toolCallId,
tool_return: result.toolReturn,
status: result.status,
stdout: result.stdout,
stderr: result.stderr,
});
return {
toolCallId: ac.approval.toolCallId,
result,
};
}),
);
// Create denial reasons for auto-denied and update UI
const autoDeniedWithReasons = autoDenied.map((ac) => {
const reason =
"matchedRule" in ac.permission && ac.permission.matchedRule
? `Permission denied by rule: ${ac.permission.matchedRule}`
: `Permission denied: ${ac.permission.reason || "Unknown"}`;
// Update buffers with denial for UI
onChunk(buffersRef.current, {
message_type: "tool_return_message",
id: "dummy",
date: new Date().toISOString(),
tool_call_id: ac.approval.toolCallId,
tool_return: `Error: request to call tool denied. User reason: ${reason}`,
status: "error",
stdout: null,
stderr: null,
});
return {
approval: ac.approval,
reason,
};
});
// Store auto-handled results to send along with user decisions
setAutoHandledResults(autoAllowedWithResults);
setAutoDeniedApprovals(autoDeniedWithReasons);
refreshDerived();
return { submitted: false };
}
}
} catch (_error) {
// If check fails, proceed anyway (don't block user)