Ask separately for confirmation (#6949)

Co-authored-by: Douwe Osinga <douwe@squareup.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
Douwe Osinga
2026-02-05 17:57:24 +01:00
committed by GitHub
parent b4e5d64c69
commit 24b3e0228c
11 changed files with 269 additions and 249 deletions
+2 -1
View File
@@ -6,7 +6,7 @@ use goose::config::ExtensionEntry;
use goose::conversation::Conversation;
use goose::dictation::download_manager::{DownloadProgress, DownloadStatus};
use goose::model::ModelConfig;
use goose::permission::permission_confirmation::PrincipalType;
use goose::permission::permission_confirmation::{Permission, PrincipalType};
use goose::providers::base::{ConfigKey, ModelInfo, ProviderMetadata, ProviderType};
use goose::session::{Session, SessionInsights, SessionType, SystemInfo};
use rmcp::model::{
@@ -498,6 +498,7 @@ derive_utoipa!(Icon as IconSchema);
ToolAnnotationsSchema,
ToolInfo,
PermissionLevel,
Permission,
PrincipalType,
ModelInfo,
ModelConfig,
@@ -14,7 +14,7 @@ pub struct ConfirmToolActionRequest {
id: String,
#[serde(default = "default_principal_type")]
principal_type: PrincipalType,
action: String,
action: Permission,
session_id: String,
}
@@ -37,19 +37,13 @@ pub async fn confirm_tool_action(
Json(request): Json<ConfirmToolActionRequest>,
) -> Result<Json<Value>, ErrorResponse> {
let agent = state.get_agent_for_route(request.session_id).await?;
let permission = match request.action.as_str() {
"always_allow" => Permission::AlwaysAllow,
"allow_once" => Permission::AllowOnce,
"deny" => Permission::DenyOnce,
_ => Permission::DenyOnce,
};
agent
.handle_confirmation(
request.id.clone(),
PermissionConfirmation {
principal_type: request.principal_type,
permission,
permission: request.action,
},
)
.await;
@@ -91,7 +85,7 @@ mod tests {
serde_json::to_string(&ConfirmToolActionRequest {
id: "test-id".to_string(),
principal_type: PrincipalType::Tool,
action: "allow_once".to_string(),
action: Permission::AllowOnce,
session_id: "test-session".to_string(),
})
.unwrap(),
@@ -1,7 +1,8 @@
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)]
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum Permission {
AlwaysAllow,
AllowOnce,
+11 -1
View File
@@ -3555,7 +3555,7 @@
],
"properties": {
"action": {
"type": "string"
"$ref": "#/components/schemas/Permission"
},
"id": {
"type": "string"
@@ -5240,6 +5240,16 @@
}
}
},
"Permission": {
"type": "string",
"enum": [
"always_allow",
"allow_once",
"cancel",
"deny_once",
"always_deny"
]
},
"PermissionLevel": {
"type": "string",
"description": "Enum representing the possible permission levels for a tool.",
File diff suppressed because one or more lines are too long
+3 -1
View File
@@ -112,7 +112,7 @@ export type ConfigResponse = {
};
export type ConfirmToolActionRequest = {
action: string;
action: Permission;
id: string;
principalType?: PrincipalType;
sessionId: string;
@@ -662,6 +662,8 @@ export type ParseRecipeResponse = {
recipe: Recipe;
};
export type Permission = 'always_allow' | 'allow_once' | 'cancel' | 'deny_once' | 'always_deny';
/**
* Enum representing the possible permission levels for a tool.
*/
+53 -18
View File
@@ -10,6 +10,8 @@ import {
getToolConfirmationContent,
getElicitationContent,
getPendingToolConfirmationIds,
getAnyToolConfirmationData,
ToolConfirmationData,
NotificationEvent,
} from '../types/message';
import { Message } from '../api';
@@ -26,7 +28,7 @@ interface GooseMessageProps {
metadata?: string[];
toolCallNotifications: Map<string, NotificationEvent[]>;
append: (value: string) => void;
isStreaming?: boolean; // Whether this message is currently being streamed
isStreaming: boolean;
submitElicitationResponse?: (
elicitationId: string,
userData: Record<string, unknown>
@@ -39,7 +41,7 @@ export default function GooseMessage({
messages,
toolCallNotifications,
append,
isStreaming = false,
isStreaming,
submitElicitationResponse,
}: GooseMessageProps) {
const contentRef = useRef<HTMLDivElement | null>(null);
@@ -69,6 +71,18 @@ export default function GooseMessage({
const messageIndex = messages.findIndex((msg) => msg.id === message.id);
const toolConfirmationContent = getToolConfirmationContent(message);
const elicitationContent = getElicitationContent(message);
const findConfirmationForToolAcrossMessages = (
toolRequestId: string
): ToolConfirmationData | undefined => {
for (const msg of messages) {
const confirmationData = getAnyToolConfirmationData(msg);
if (confirmationData && confirmationData.id === toolRequestId) {
return confirmationData;
}
}
return undefined;
};
const toolCallChains = useMemo(() => identifyConsecutiveToolCalls(messages), [messages]);
const hideTimestamp = useMemo(
() => shouldHideTimestamp(messageIndex, toolCallChains),
@@ -77,6 +91,20 @@ export default function GooseMessage({
const hasToolConfirmation = toolConfirmationContent !== undefined;
const hasElicitation = elicitationContent !== undefined;
const toolConfirmationShownInline = useMemo(() => {
if (!toolConfirmationContent) return false;
const confirmationData = getAnyToolConfirmationData(message);
if (!confirmationData) return false;
for (const msg of messages) {
const requests = getToolRequests(msg);
if (requests.some((req) => req.id === confirmationData.id)) {
return true;
}
}
return false;
}, [toolConfirmationContent, message, messages]);
const toolResponsesMap = useMemo(() => {
const responseMap = new Map();
@@ -149,20 +177,28 @@ export default function GooseMessage({
<div className={cn(displayText && 'mt-2')}>
<div className="relative flex flex-col w-full">
<div className="flex flex-col gap-3">
{toolRequests.map((toolRequest) => (
<div className="goose-message-tool" key={toolRequest.id}>
<ToolCallWithResponse
sessionId={sessionId}
isCancelledMessage={false}
toolRequest={toolRequest}
toolResponse={toolResponsesMap.get(toolRequest.id)}
notifications={toolCallNotifications.get(toolRequest.id)}
isStreamingMessage={isStreaming}
isPendingApproval={pendingConfirmationIds.has(toolRequest.id)}
append={append}
/>
</div>
))}
{toolRequests.map((toolRequest) => {
const hasResponse = toolResponsesMap.has(toolRequest.id);
const isPending = pendingConfirmationIds.has(toolRequest.id);
const confirmationContent = findConfirmationForToolAcrossMessages(toolRequest.id);
const isApprovalClicked = confirmationContent && !isPending && hasResponse;
return (
<div className="goose-message-tool" key={toolRequest.id}>
<ToolCallWithResponse
sessionId={sessionId}
isCancelledMessage={false}
toolRequest={toolRequest}
toolResponse={toolResponsesMap.get(toolRequest.id)}
notifications={toolCallNotifications.get(toolRequest.id)}
isStreamingMessage={isStreaming}
isPendingApproval={isPending}
append={append}
confirmationContent={confirmationContent}
isApprovalClicked={isApprovalClicked}
/>
</div>
);
})}
</div>
<div className="text-xs text-text-muted transition-all duration-200 group-hover:-translate-y-4 group-hover:opacity-0 pt-1">
{!isStreaming && !hideTimestamp && timestamp}
@@ -171,10 +207,9 @@ export default function GooseMessage({
</div>
)}
{hasToolConfirmation && (
{hasToolConfirmation && !toolConfirmationShownInline && (
<ToolCallConfirmation
sessionId={sessionId}
isCancelledMessage={false}
isClicked={false}
actionRequiredContent={toolConfirmationContent}
/>
@@ -0,0 +1,99 @@
import { useState, useEffect } from 'react';
import { Button } from './ui/button';
import { confirmToolAction, Permission } from '../api';
const globalApprovalState = new Map<
string,
{
decision: Permission | null;
isClicked: boolean;
}
>();
export interface ToolApprovalData {
id: string;
toolName: string;
prompt?: string;
sessionId: string;
isClicked?: boolean;
}
export default function ToolApprovalButtons({ data }: { data: ToolApprovalData }) {
const { id, toolName, prompt, sessionId, isClicked: initialIsClicked } = data;
const storedState = globalApprovalState.get(id);
const [decision, setDecision] = useState<Permission | null>(storedState?.decision ?? null);
const [isClicked, setIsClicked] = useState(storedState?.isClicked ?? initialIsClicked ?? false);
useEffect(() => {
const currentState = globalApprovalState.get(id);
if (currentState) {
setDecision(currentState.decision);
setIsClicked(currentState.isClicked);
}
}, [id]);
useEffect(() => {
globalApprovalState.set(id, { decision, isClicked });
}, [id, decision, isClicked]);
const handleAction = async (action: Permission) => {
setDecision(action);
setIsClicked(true);
try {
const response = await confirmToolAction({
body: {
sessionId,
id,
action,
principalType: 'Tool',
},
});
if (response.error) {
console.error('Failed to confirm tool action:', response.error);
}
} catch (err) {
console.error('Error confirming tool action:', err);
}
};
if (isClicked && decision) {
const statusMessages: Record<Permission, string> = {
allow_once: 'Allowed once',
always_allow: 'Always allowed',
always_deny: 'Denied',
deny_once: 'Denied once',
cancel: 'Cancelled',
};
return (
<p className="text-sm text-muted-foreground mt-2">
{toolName} - {statusMessages[decision]}
</p>
);
}
return (
<div className="flex items-center gap-2 mt-2">
<Button
className="rounded-full"
variant="secondary"
onClick={() => handleAction('allow_once')}
>
Allow Once
</Button>
{!prompt && (
<Button
className="rounded-full"
variant="secondary"
onClick={() => handleAction('always_allow')}
>
Always Allow
</Button>
)}
<Button className="rounded-full" variant="outline" onClick={() => handleAction('deny_once')}>
Deny
</Button>
</div>
);
}
@@ -1,231 +1,32 @@
import { useState, useEffect } from 'react';
import { snakeToTitleCase } from '../utils';
import PermissionModal from './settings/permission/PermissionModal';
import { ChevronRight } from 'lucide-react';
import { confirmToolAction, ActionRequired } from '../api';
import { Button } from './ui/button';
const ALLOW_ONCE = 'allow_once';
const ALWAYS_ALLOW = 'always_allow';
const DENY = 'deny';
// Global state to track tool confirmation decisions
// This persists across navigation within the same session
const toolConfirmationState = new Map<
string,
{
clicked: boolean;
status: string;
actionDisplay: string;
}
>();
import { ActionRequired } from '../api';
import ToolApprovalButtons from './ToolApprovalButtons';
type ToolConfirmationData = Extract<ActionRequired['data'], { actionType: 'toolConfirmation' }>;
interface ToolConfirmationProps {
sessionId: string;
isCancelledMessage: boolean;
isClicked: boolean;
actionRequiredContent: ActionRequired & { type: 'actionRequired' };
}
export default function ToolConfirmation({
sessionId,
isCancelledMessage,
isClicked,
actionRequiredContent,
}: ToolConfirmationProps) {
const data = actionRequiredContent.data as ToolConfirmationData;
const { id: toolConfirmationId, toolName, prompt } = data;
const { id, toolName, prompt } = data;
// Check if we have a stored state for this tool confirmation
const storedState = toolConfirmationState.get(toolConfirmationId);
// Initialize state from stored state if available, otherwise use props/defaults
const [clicked, setClicked] = useState(storedState?.clicked ?? isClicked);
const [status, setStatus] = useState(storedState?.status ?? 'unknown');
const [actionDisplay, setActionDisplay] = useState(storedState?.actionDisplay ?? '');
const [isModalOpen, setIsModalOpen] = useState(false);
// Sync internal state with stored state and props
useEffect(() => {
const currentStoredState = toolConfirmationState.get(toolConfirmationId);
// If we have stored state, use it
if (currentStoredState) {
setClicked(currentStoredState.clicked);
setStatus(currentStoredState.status);
setActionDisplay(currentStoredState.actionDisplay);
} else if (isClicked && !clicked) {
// Fallback to prop-based logic for historical confirmations
setClicked(isClicked);
if (status === 'unknown') {
setStatus('confirmed');
setActionDisplay('confirmed');
// Store this state for future renders
toolConfirmationState.set(toolConfirmationId, {
clicked: true,
status: 'confirmed',
actionDisplay: 'confirmed',
});
}
}
}, [isClicked, clicked, status, toolName, toolConfirmationId]);
const handleButtonClick = async (newStatus: string) => {
let newActionDisplay;
if (newStatus === ALWAYS_ALLOW) {
newActionDisplay = 'always allowed';
} else if (newStatus === ALLOW_ONCE) {
newActionDisplay = 'allowed once';
} else if (newStatus === DENY) {
newActionDisplay = 'denied';
} else {
newActionDisplay = 'denied';
}
// Update local state
setClicked(true);
setStatus(newStatus);
setActionDisplay(newActionDisplay);
// Store in global state for persistence across navigation
toolConfirmationState.set(toolConfirmationId, {
clicked: true,
status: newStatus,
actionDisplay: newActionDisplay,
});
try {
const response = await confirmToolAction({
body: {
sessionId: sessionId,
id: toolConfirmationId,
action: newStatus,
principalType: 'Tool',
},
});
if (response.error) {
console.error('Failed to confirm tool action:', response.error);
}
} catch (err) {
console.error('Error confirming tool action:', err);
}
};
const handleModalClose = () => {
setIsModalOpen(false);
};
function getExtensionName(toolName: string): string {
const parts = toolName.split('__');
return parts.length > 1 ? parts[0] : '';
}
return isCancelledMessage ? (
<div className="goose-message-content bg-background-muted rounded-2xl px-4 py-2 text-textStandard">
Tool call confirmation is cancelled.
</div>
) : (
<>
{/* Display security message if present */}
{prompt && (
<div className="goose-message-content bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-2xl px-4 py-2 mb-2 text-yellow-800 dark:text-gray-200">
{prompt}
</div>
)}
<div className="goose-message-content bg-background-muted rounded-2xl px-4 py-2 rounded-b-none text-textStandard">
return (
<div className="goose-message-content bg-background-default border border-borderSubtle rounded-2xl overflow-hidden">
<div className="bg-background-muted px-4 py-2 text-textStandard">
{prompt
? 'Do you allow this tool call?'
: 'Goose would like to call the above tool. Allow?'}
</div>
{clicked ? (
<div className="goose-message-tool bg-background-default border border-borderSubtle dark:border-gray-700 rounded-b-2xl px-4 pt-2 pb-2 flex items-center justify-between">
<div className="flex items-center">
{(status === 'allow_once' || status === 'always_allow') && (
<svg
className="w-5 h-5 text-gray-500"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
)}
{status === 'deny' && (
<svg
className="w-5 h-5 text-gray-500"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
)}
{status === 'confirmed' && (
<svg
className="w-5 h-5 text-gray-500"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
)}
<span className="ml-2 text-textStandard">
{isClicked
? 'Tool confirmation is not available'
: `${snakeToTitleCase(toolName.substring(toolName.lastIndexOf('__') + 2))} is ${actionDisplay}`}
</span>
</div>
<div className="flex items-center cursor-pointer" onClick={() => setIsModalOpen(true)}>
<span className="mr-1 text-textStandard">Change</span>
<ChevronRight className="w-4 h-4 ml-1 text-iconStandard" />
</div>
</div>
) : (
<div className="goose-message-tool bg-background-default border border-borderSubtle dark:border-gray-700 rounded-b-2xl px-4 pt-2 pb-2 flex gap-2 items-center">
<Button
className="rounded-full"
variant="secondary"
onClick={() => handleButtonClick(ALLOW_ONCE)}
>
Allow Once
</Button>
{/* Only show "Always Allow" if there's no security message (no security finding) */}
{!prompt && (
<Button
className="rounded-full"
variant="secondary"
onClick={() => handleButtonClick(ALWAYS_ALLOW)}
>
Always Allow
</Button>
)}
<Button
className="rounded-full"
variant="outline"
onClick={() => handleButtonClick(DENY)}
>
Deny
</Button>
</div>
)}
{/* Modal for updating tool permission */}
{isModalOpen && (
<PermissionModal onClose={handleModalClose} extensionName={getExtensionName(toolName)} />
)}
</>
<ToolApprovalButtons
data={{ id, toolName, prompt: prompt ?? undefined, sessionId, isClicked }}
/>
</div>
);
}
@@ -9,6 +9,7 @@ import {
ToolRequestMessageContent,
ToolResponseMessageContent,
NotificationEvent,
ToolConfirmationData,
} from '../types/message';
import { cn, snakeToTitleCase } from '../utils';
import { LoadingStatus } from './ui/Dot';
@@ -18,6 +19,7 @@ import MCPUIResourceRenderer from './MCPUIResourceRenderer';
import { isUIResource } from '@mcp-ui/client';
import { CallToolResponse, Content, EmbeddedResource } from '../api';
import McpAppRenderer from './McpApps/McpAppRenderer';
import ToolApprovalButtons from './ToolApprovalButtons';
interface ToolGraphNode {
tool: string;
@@ -58,6 +60,8 @@ interface ToolCallWithResponseProps {
isStreamingMessage?: boolean;
isPendingApproval: boolean;
append?: (value: string) => void;
confirmationContent?: ToolConfirmationData;
isApprovalClicked?: boolean;
}
function getToolResultContent(toolResult: Record<string, unknown>): Content[] {
@@ -155,6 +159,8 @@ export default function ToolCallWithResponse({
isStreamingMessage,
isPendingApproval,
append,
confirmationContent,
isApprovalClicked,
}: ToolCallWithResponseProps) {
// Handle both the wrapped ToolResult format and the unwrapped format
// The server serializes ToolResult<T> as { status: "success", value: T } or { status: "error", error: string }
@@ -176,11 +182,14 @@ export default function ToolCallWithResponse({
const shouldShowMcpContent = !isPendingApproval;
const showInlineApproval = isPendingApproval && confirmationContent && sessionId;
return (
<>
<div
className={cn(
'w-full text-sm font-sans rounded-lg overflow-hidden border-borderSubtle border'
'w-full text-sm font-sans rounded-lg overflow-hidden border',
showInlineApproval ? 'border-amber-500/50 bg-amber-50/5' : 'border-borderSubtle'
)}
>
<ToolCallView
@@ -192,6 +201,27 @@ export default function ToolCallWithResponse({
isStreamingMessage,
}}
/>
{/* Inline approval UI */}
{showInlineApproval && (
<div className="border-t border-amber-500/30">
{confirmationContent.prompt && (
<div className="px-4 py-2 text-sm text-amber-600 dark:text-amber-400 bg-amber-50/10">
{confirmationContent.prompt}
</div>
)}
<div className="px-4 pb-2">
<ToolApprovalButtons
data={{
id: confirmationContent.id,
toolName: confirmationContent.toolName,
prompt: confirmationContent.prompt ?? undefined,
sessionId,
isClicked: isApprovalClicked,
}}
/>
</div>
</div>
)}
</div>
{/* MCP UI — Inline */}
{shouldShowMcpContent &&
+54 -7
View File
@@ -1,7 +1,17 @@
import { Message, MessageEvent, ActionRequired, ToolRequest, ToolResponse } from '../api';
import {
Message,
MessageEvent,
ActionRequired,
ToolRequest,
ToolResponse,
ToolConfirmationRequest,
} from '../api';
export type ToolRequestMessageContent = ToolRequest & { type: 'toolRequest' };
export type ToolResponseMessageContent = ToolResponse & { type: 'toolResponse' };
export type ToolConfirmationRequestContent = ToolConfirmationRequest & {
type: 'toolConfirmationRequest';
};
export type NotificationEvent = Extract<MessageEvent, { type: 'Notification' }>;
// Compaction response message - must match backend constant
@@ -108,6 +118,46 @@ export function getToolConfirmationContent(
);
}
export function getToolConfirmationRequestContent(
message: Message
): ToolConfirmationRequestContent | undefined {
return message.content.find(
(content): content is ToolConfirmationRequestContent =>
content.type === 'toolConfirmationRequest'
);
}
export interface ToolConfirmationData {
id: string;
toolName: string;
arguments: Record<string, unknown>;
prompt?: string | null;
}
export function getAnyToolConfirmationData(message: Message): ToolConfirmationData | undefined {
const confirmationRequest = getToolConfirmationRequestContent(message);
if (confirmationRequest) {
return {
id: confirmationRequest.id,
toolName: confirmationRequest.toolName,
arguments: confirmationRequest.arguments,
prompt: confirmationRequest.prompt,
};
}
const actionRequired = getToolConfirmationContent(message);
if (actionRequired && actionRequired.data.actionType === 'toolConfirmation') {
return {
id: actionRequired.data.id,
toolName: actionRequired.data.toolName,
arguments: actionRequired.data.arguments,
prompt: actionRequired.data.prompt,
};
}
return undefined;
}
export function getToolConfirmationId(
content: ActionRequired & { type: 'actionRequired' }
): string | undefined {
@@ -129,12 +179,9 @@ export function getPendingToolConfirmationIds(messages: Message[]): Set<string>
}
for (const message of messages) {
const confirmation = getToolConfirmationContent(message);
if (confirmation) {
const confirmationId = getToolConfirmationId(confirmation);
if (confirmationId && !respondedIds.has(confirmationId)) {
pendingIds.add(confirmationId);
}
const confirmationData = getAnyToolConfirmationData(message);
if (confirmationData && !respondedIds.has(confirmationData.id)) {
pendingIds.add(confirmationData.id);
}
}