Skip to content

Commit bfcd54e

Browse files
Ambient Code Botclaude
andcommitted
feat: add permission request UI for sensitive tool operations
When Claude Code SDK needs user approval for sensitive operations (e.g. editing .mcp.json), the can_use_tool callback now emits a synthetic PermissionRequest tool call that halts the stream and surfaces an interactive Allow/Deny UI in the frontend. Approved operations are tracked per-session so retries succeed automatically. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 013f33f commit bfcd54e

File tree

7 files changed

+411
-15
lines changed

7 files changed

+411
-15
lines changed

components/frontend/src/components/session/ask-user-question.tsx

Lines changed: 7 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,12 @@ import { Input } from "@/components/ui/input";
88
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
99
import { HelpCircle, CheckCircle2, Send, ChevronRight } from "lucide-react";
1010
import { formatTimestamp } from "@/lib/format-timestamp";
11-
import type {
12-
ToolUseBlock,
13-
ToolResultBlock,
14-
AskUserQuestionItem,
15-
AskUserQuestionInput,
11+
import {
12+
hasToolResult,
13+
type ToolUseBlock,
14+
type ToolResultBlock,
15+
type AskUserQuestionItem,
16+
type AskUserQuestionInput,
1617
} from "@/types/agentic-session";
1718

1819
export type AskUserQuestionMessageProps = {
@@ -41,13 +42,7 @@ function parseQuestions(input: Record<string, unknown>): AskUserQuestionItem[] {
4142
return [];
4243
}
4344

44-
function hasResult(resultBlock?: ToolResultBlock): boolean {
45-
if (!resultBlock) return false;
46-
const content = resultBlock.content;
47-
if (!content) return false;
48-
if (typeof content === "string" && content.trim() === "") return false;
49-
return true;
50-
}
45+
const hasResult = hasToolResult;
5146

5247
export const AskUserQuestionMessage: React.FC<AskUserQuestionMessageProps> = ({
5348
toolUseBlock,
Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
"use client";
2+
3+
import React, { useState } from "react";
4+
import { cn } from "@/lib/utils";
5+
import { Button } from "@/components/ui/button";
6+
import { ShieldCheck, ShieldX, ShieldAlert } from "lucide-react";
7+
import { formatTimestamp } from "@/lib/format-timestamp";
8+
import {
9+
hasToolResult,
10+
type ToolUseBlock,
11+
type ToolResultBlock,
12+
type PermissionRequestInput,
13+
} from "@/types/agentic-session";
14+
15+
export type PermissionRequestMessageProps = {
16+
toolUseBlock: ToolUseBlock;
17+
resultBlock?: ToolResultBlock;
18+
timestamp?: string;
19+
onSubmitAnswer?: (formattedAnswer: string) => Promise<void>;
20+
isNewest?: boolean;
21+
};
22+
23+
function isPermissionRequestInput(
24+
input: Record<string, unknown>
25+
): input is PermissionRequestInput {
26+
return "tool_name" in input && "key" in input;
27+
}
28+
29+
type PermissionStatus = "pending" | "approved" | "denied";
30+
31+
function deriveStatus(resultBlock?: ToolResultBlock): PermissionStatus {
32+
if (!hasToolResult(resultBlock)) return "pending";
33+
const content = resultBlock?.content;
34+
if (typeof content !== "string") return "denied";
35+
try {
36+
return JSON.parse(content).approved === true ? "approved" : "denied";
37+
} catch {
38+
return "denied";
39+
}
40+
}
41+
42+
const STATUS_CONFIG: Record<PermissionStatus, {
43+
icon: typeof ShieldCheck;
44+
avatarClass: string;
45+
borderClass: string;
46+
}> = {
47+
pending: {
48+
icon: ShieldAlert,
49+
avatarClass: "bg-amber-500",
50+
borderClass: "border-l-amber-500 bg-amber-50/30 dark:bg-amber-950/10",
51+
},
52+
approved: {
53+
icon: ShieldCheck,
54+
avatarClass: "bg-green-600",
55+
borderClass: "border-l-green-500 bg-green-50/30 dark:bg-green-950/10",
56+
},
57+
denied: {
58+
icon: ShieldX,
59+
avatarClass: "bg-red-600",
60+
borderClass: "border-l-red-500 bg-red-50/30 dark:bg-red-950/10",
61+
},
62+
};
63+
64+
export const PermissionRequestMessage: React.FC<
65+
PermissionRequestMessageProps
66+
> = ({ toolUseBlock, resultBlock, timestamp, onSubmitAnswer, isNewest = false }) => {
67+
const input = toolUseBlock.input;
68+
const status = deriveStatus(resultBlock);
69+
const formattedTime = formatTimestamp(timestamp);
70+
71+
const [submitted, setSubmitted] = useState(false);
72+
const [isSubmitting, setIsSubmitting] = useState(false);
73+
const disabled = status !== "pending" || submitted || isSubmitting || !isNewest;
74+
75+
if (!isPermissionRequestInput(input)) return null;
76+
77+
const handleResponse = async (allow: boolean) => {
78+
if (!onSubmitAnswer || disabled) return;
79+
80+
const response = JSON.stringify({
81+
approved: allow,
82+
tool_name: input.tool_name,
83+
key: input.key,
84+
});
85+
86+
try {
87+
setIsSubmitting(true);
88+
await onSubmitAnswer(response);
89+
setSubmitted(true);
90+
} finally {
91+
setIsSubmitting(false);
92+
}
93+
};
94+
95+
const config = STATUS_CONFIG[disabled && status !== "pending" ? status : "pending"];
96+
const resolvedConfig = STATUS_CONFIG[status];
97+
const activeConfig = disabled ? resolvedConfig : config;
98+
const Icon = activeConfig.icon;
99+
100+
return (
101+
<div className="mb-3">
102+
<div className="flex items-start gap-3">
103+
<div className="flex-shrink-0">
104+
<div
105+
className={cn(
106+
"w-8 h-8 rounded-full flex items-center justify-center",
107+
activeConfig.avatarClass
108+
)}
109+
>
110+
<Icon className="w-4 h-4 text-white" />
111+
</div>
112+
</div>
113+
114+
<div className="flex-1 min-w-0">
115+
{formattedTime && (
116+
<div className="text-[10px] text-muted-foreground/60 mb-0.5">
117+
{formattedTime}
118+
</div>
119+
)}
120+
121+
<div
122+
className={cn("rounded-lg border-l-3 pl-3 pr-3 py-2.5", activeConfig.borderClass)}
123+
>
124+
<p className="text-sm font-medium text-foreground mb-1">
125+
Permission Required
126+
</p>
127+
<p className="text-sm text-foreground/80 mb-2">
128+
{input.description}
129+
</p>
130+
131+
{(input.file_path || input.command) && (
132+
<div className="text-xs text-muted-foreground font-mono bg-muted/50 rounded px-2 py-1 mb-2 break-all">
133+
{input.file_path || input.command}
134+
</div>
135+
)}
136+
137+
{disabled && status !== "pending" && (
138+
<p className="text-xs text-muted-foreground">
139+
{status === "approved" ? "Approved" : "Denied"}
140+
</p>
141+
)}
142+
143+
{!disabled && (
144+
<div className="flex items-center gap-2 mt-2 pt-1.5 border-t border-border/40">
145+
<Button
146+
size="sm"
147+
className="h-7 text-xs gap-1 px-3 bg-green-600 hover:bg-green-700 text-white"
148+
onClick={() => handleResponse(true)}
149+
disabled={isSubmitting}
150+
>
151+
<ShieldCheck className="w-3 h-3" />
152+
Allow
153+
</Button>
154+
<Button
155+
size="sm"
156+
variant="outline"
157+
className="h-7 text-xs gap-1 px-3 text-red-600 border-red-200 hover:bg-red-50 dark:border-red-800 dark:hover:bg-red-950/30"
158+
onClick={() => handleResponse(false)}
159+
disabled={isSubmitting}
160+
>
161+
<ShieldX className="w-3 h-3" />
162+
Deny
163+
</Button>
164+
</div>
165+
)}
166+
</div>
167+
</div>
168+
</div>
169+
</div>
170+
);
171+
};
172+
173+
PermissionRequestMessage.displayName = "PermissionRequestMessage";

components/frontend/src/components/ui/stream-message.tsx

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { MessageObject, ToolUseMessages, HierarchicalToolMessage } from "@/types
55
import { LoadingDots, Message } from "@/components/ui/message";
66
import { ToolMessage } from "@/components/ui/tool-message";
77
import { AskUserQuestionMessage } from "@/components/session/ask-user-question";
8+
import { PermissionRequestMessage } from "@/components/session/permission-request";
89
import { ThinkingMessage } from "@/components/ui/thinking-message";
910
import { SystemMessage } from "@/components/ui/system-message";
1011
import { Button } from "@/components/ui/button";
@@ -20,9 +21,16 @@ export type StreamMessageProps = {
2021
currentUserId?: string;
2122
};
2223

24+
function normalizeToolName(name: string): string {
25+
return name.toLowerCase().replace(/[^a-z]/g, "");
26+
}
27+
2328
function isAskUserQuestionTool(name: string): boolean {
24-
const normalized = name.toLowerCase().replace(/[^a-z]/g, "");
25-
return normalized === "askuserquestion";
29+
return normalizeToolName(name) === "askuserquestion";
30+
}
31+
32+
function isPermissionRequestTool(name: string): boolean {
33+
return normalizeToolName(name) === "permissionrequest";
2634
}
2735

2836
const getRandomAgentMessage = () => {
@@ -59,6 +67,19 @@ export const StreamMessage: React.FC<StreamMessageProps> = ({ message, onGoToRes
5967
);
6068
}
6169

70+
// Render PermissionRequest with Allow/Deny buttons
71+
if (isPermissionRequestTool(message.toolUseBlock.name)) {
72+
return (
73+
<PermissionRequestMessage
74+
toolUseBlock={message.toolUseBlock}
75+
resultBlock={message.resultBlock}
76+
timestamp={message.timestamp}
77+
onSubmitAnswer={onSubmitAnswer}
78+
isNewest={isNewest}
79+
/>
80+
);
81+
}
82+
6283
// Check if this is a hierarchical message with children
6384
const hierarchical = message as HierarchicalToolMessage;
6485
return (

components/frontend/src/types/agentic-session.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,15 @@ export type AskUserQuestionInput = {
3131
questions: AskUserQuestionItem[];
3232
};
3333

34+
// PermissionRequest tool types (synthetic tool emitted by can_use_tool callback)
35+
export type PermissionRequestInput = {
36+
tool_name: string;
37+
file_path?: string;
38+
command?: string;
39+
description: string;
40+
key: string;
41+
};
42+
3443
export type LLMSettings = {
3544
model: string;
3645
temperature: number;
@@ -132,6 +141,15 @@ export type ToolResultBlock = {
132141

133142
export type ContentBlock = TextBlock | ReasoningBlock | ToolUseBlock | ToolResultBlock;
134143

144+
/** Check whether a ToolResultBlock contains a non-empty result. */
145+
export function hasToolResult(resultBlock?: ToolResultBlock): boolean {
146+
if (!resultBlock) return false;
147+
const content = resultBlock.content;
148+
if (!content) return false;
149+
if (typeof content === "string" && content.trim() === "") return false;
150+
return true;
151+
}
152+
135153
export type ToolUseMessages = {
136154
type: "tool_use_messages";
137155
toolUseBlock: ToolUseBlock;

0 commit comments

Comments
 (0)