Skip to content

Commit 8bf961c

Browse files
committed
ai-plugin: Add ability to approve a tool request
Signed-off-by: Ashu Ghildiyal <aghildiyal@microsoft.com>
1 parent b039a0c commit 8bf961c

File tree

7 files changed

+832
-15
lines changed

7 files changed

+832
-15
lines changed
Lines changed: 307 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,307 @@
1+
import React, { useState } from 'react';
2+
import {
3+
Dialog,
4+
DialogTitle,
5+
DialogContent,
6+
DialogActions,
7+
Button,
8+
Typography,
9+
Box,
10+
Chip,
11+
Alert,
12+
Accordion,
13+
AccordionSummary,
14+
AccordionDetails,
15+
List,
16+
ListItem,
17+
ListItemText,
18+
IconButton,
19+
Tooltip,
20+
FormGroup,
21+
FormControlLabel,
22+
Checkbox,
23+
} from '@mui/material';
24+
import { Icon } from '@iconify/react';
25+
26+
interface ToolCall {
27+
id: string;
28+
name: string;
29+
description?: string;
30+
arguments: Record<string, any>;
31+
type: 'mcp' | 'regular';
32+
}
33+
34+
interface ToolApprovalDialogProps {
35+
open: boolean;
36+
toolCalls: ToolCall[];
37+
onApprove: (approvedToolIds: string[]) => void;
38+
onDeny: () => void;
39+
onClose: () => void;
40+
loading?: boolean;
41+
}
42+
43+
const ToolApprovalDialog: React.FC<ToolApprovalDialogProps> = ({
44+
open,
45+
toolCalls,
46+
onApprove,
47+
onDeny,
48+
onClose,
49+
loading = false,
50+
}) => {
51+
const [selectedToolIds, setSelectedToolIds] = useState<string[]>(
52+
toolCalls.map(tool => tool.id)
53+
);
54+
const [rememberChoice, setRememberChoice] = useState(false);
55+
56+
// Reset selection when toolCalls change
57+
React.useEffect(() => {
58+
if (open) {
59+
setSelectedToolIds(toolCalls.map(tool => tool.id));
60+
setRememberChoice(false);
61+
}
62+
}, [toolCalls, open]);
63+
64+
const handleSelectAll = () => {
65+
setSelectedToolIds(toolCalls.map(tool => tool.id));
66+
};
67+
68+
const handleDeselectAll = () => {
69+
setSelectedToolIds([]);
70+
};
71+
72+
const handleToolToggle = (toolId: string) => {
73+
setSelectedToolIds(prev =>
74+
prev.includes(toolId)
75+
? prev.filter(id => id !== toolId)
76+
: [...prev, toolId]
77+
);
78+
};
79+
80+
const handleApprove = () => {
81+
onApprove(selectedToolIds);
82+
};
83+
84+
const mcpTools = toolCalls.filter(tool => tool.type === 'mcp');
85+
const regularTools = toolCalls.filter(tool => tool.type === 'regular');
86+
87+
const getToolIcon = (toolName: string, toolType: 'mcp' | 'regular') => {
88+
if (toolType === 'mcp') {
89+
return 'mdi:docker'; // Inspektor Gadget runs in Docker
90+
}
91+
92+
// Regular Kubernetes tools
93+
if (toolName.includes('kubernetes') || toolName.includes('k8s')) {
94+
return 'mdi:kubernetes';
95+
}
96+
return 'mdi:tool';
97+
};
98+
99+
const formatArguments = (args: Record<string, any>) => {
100+
return Object.entries(args)
101+
.filter(([_, value]) => value !== undefined && value !== null && value !== '')
102+
.map(([key, value]) => (
103+
<ListItem key={key} dense>
104+
<ListItemText
105+
primary={key}
106+
secondary={
107+
typeof value === 'object'
108+
? JSON.stringify(value, null, 2)
109+
: String(value)
110+
}
111+
/>
112+
</ListItem>
113+
));
114+
};
115+
116+
const renderToolSection = (tools: ToolCall[], title: string, color: 'primary' | 'secondary') => {
117+
if (tools.length === 0) return null;
118+
119+
return (
120+
<Box sx={{ mb: 2 }}>
121+
<Typography variant="h6" gutterBottom sx={{ display: 'flex', alignItems: 'center' }}>
122+
{title}
123+
<Chip
124+
label={tools.length}
125+
size="small"
126+
color={color}
127+
sx={{ ml: 1 }}
128+
/>
129+
</Typography>
130+
{tools.map(tool => (
131+
<Accordion key={tool.id} sx={{ mb: 1 }}>
132+
<AccordionSummary
133+
expandIcon={<Icon icon="mdi:chevron-down" />}
134+
sx={{
135+
'& .MuiAccordionSummary-content': {
136+
alignItems: 'center'
137+
}
138+
}}
139+
>
140+
<FormControlLabel
141+
control={
142+
<Checkbox
143+
checked={selectedToolIds.includes(tool.id)}
144+
onChange={() => handleToolToggle(tool.id)}
145+
onClick={(e) => e.stopPropagation()}
146+
/>
147+
}
148+
label=""
149+
sx={{ mr: 1 }}
150+
onClick={(e) => e.stopPropagation()}
151+
/>
152+
<Icon
153+
icon={getToolIcon(tool.name, tool.type)}
154+
style={{ marginRight: 8 }}
155+
/>
156+
<Box>
157+
<Typography variant="subtitle1" sx={{ fontWeight: 'bold' }}>
158+
{tool.name}
159+
</Typography>
160+
{tool.description && (
161+
<Typography variant="caption" color="text.secondary">
162+
{tool.description}
163+
</Typography>
164+
)}
165+
</Box>
166+
{tool.type === 'mcp' && (
167+
<Chip
168+
label="MCP Tool"
169+
size="small"
170+
color="info"
171+
variant="outlined"
172+
sx={{ ml: 'auto', mr: 1 }}
173+
/>
174+
)}
175+
</AccordionSummary>
176+
<AccordionDetails>
177+
<Typography variant="body2" gutterBottom color="text.secondary">
178+
Arguments to be passed:
179+
</Typography>
180+
<List dense>
181+
{formatArguments(tool.arguments)}
182+
</List>
183+
</AccordionDetails>
184+
</Accordion>
185+
))}
186+
</Box>
187+
);
188+
};
189+
190+
return (
191+
<Dialog
192+
open={open}
193+
onClose={loading ? undefined : onClose}
194+
maxWidth="md"
195+
fullWidth
196+
PaperProps={{
197+
sx: { minHeight: '50vh' }
198+
}}
199+
>
200+
<DialogTitle sx={{
201+
display: 'flex',
202+
alignItems: 'center',
203+
justifyContent: 'space-between'
204+
}}>
205+
<Box sx={{ display: 'flex', alignItems: 'center' }}>
206+
<Icon icon="mdi:security" style={{ marginRight: 8 }} />
207+
Tool Execution Approval Required
208+
</Box>
209+
{!loading && (
210+
<IconButton onClick={onClose} size="small">
211+
<Icon icon="mdi:close" />
212+
</IconButton>
213+
)}
214+
</DialogTitle>
215+
216+
<DialogContent>
217+
<Alert severity="info" sx={{ mb: 3 }}>
218+
The AI Assistant wants to execute {toolCalls.length} tool{toolCalls.length > 1 ? 's' : ''}
219+
to complete your request. Please review and approve the tools you want to allow.
220+
</Alert>
221+
222+
{mcpTools.length > 0 && (
223+
<Alert severity="warning" sx={{ mb: 3 }}>
224+
<Typography variant="subtitle2" gutterBottom>
225+
MCP Tools (Inspektor Gadget)
226+
</Typography>
227+
<Typography variant="body2">
228+
These tools will execute debugging commands in your Kubernetes clusters through
229+
Inspektor Gadget containers. They provide deep system-level insights but require
230+
elevated permissions.
231+
</Typography>
232+
</Alert>
233+
)}
234+
235+
<Box sx={{ mb: 3, display: 'flex', gap: 1 }}>
236+
<Button
237+
variant="outlined"
238+
size="small"
239+
onClick={handleSelectAll}
240+
startIcon={<Icon icon="mdi:check-all" />}
241+
>
242+
Select All
243+
</Button>
244+
<Button
245+
variant="outlined"
246+
size="small"
247+
onClick={handleDeselectAll}
248+
startIcon={<Icon icon="mdi:close-box-multiple" />}
249+
>
250+
Deselect All
251+
</Button>
252+
</Box>
253+
254+
{renderToolSection(regularTools, 'Kubernetes Tools', 'primary')}
255+
{renderToolSection(mcpTools, 'MCP Tools (Inspektor Gadget)', 'secondary')}
256+
257+
<FormGroup sx={{ mt: 2 }}>
258+
<FormControlLabel
259+
control={
260+
<Checkbox
261+
checked={rememberChoice}
262+
onChange={(e) => setRememberChoice(e.target.checked)}
263+
/>
264+
}
265+
label={
266+
<Typography variant="body2">
267+
Remember my choice for this session (auto-approve similar tools)
268+
</Typography>
269+
}
270+
/>
271+
</FormGroup>
272+
</DialogContent>
273+
274+
<DialogActions sx={{ px: 3, pb: 2 }}>
275+
<Box sx={{
276+
display: 'flex',
277+
justifyContent: 'space-between',
278+
alignItems: 'center',
279+
width: '100%'
280+
}}>
281+
<Typography variant="body2" color="text.secondary">
282+
{selectedToolIds.length} of {toolCalls.length} tools selected
283+
</Typography>
284+
<Box sx={{ display: 'flex', gap: 1 }}>
285+
<Button
286+
onClick={onDeny}
287+
disabled={loading}
288+
color="error"
289+
>
290+
Deny All
291+
</Button>
292+
<Button
293+
onClick={handleApprove}
294+
variant="contained"
295+
disabled={loading || selectedToolIds.length === 0}
296+
startIcon={loading ? <Icon icon="mdi:loading" className="animate-spin" /> : <Icon icon="mdi:check" />}
297+
>
298+
{loading ? 'Executing...' : `Approve ${selectedToolIds.length > 0 ? `(${selectedToolIds.length})` : ''}`}
299+
</Button>
300+
</Box>
301+
</Box>
302+
</DialogActions>
303+
</Dialog>
304+
);
305+
};
306+
307+
export default ToolApprovalDialog;
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
export { default as ApiConfirmationDialog } from './ApiConfirmationDialog';
22
export { default as LogsButton } from './LogsButton';
33
export { default as LogsDialog } from './LogsDialog';
4+
export { default as ToolApprovalDialog } from './ToolApprovalDialog';
45
export { default as YamlDisplay } from './YamlDisplay';
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import { useCallback, useEffect, useState } from 'react';
2+
import { toolApprovalManager, ToolApprovalRequest } from '../utils/ToolApprovalManager';
3+
4+
export interface UseToolApprovalResult {
5+
showApprovalDialog: boolean;
6+
pendingRequest: ToolApprovalRequest | null;
7+
handleApprove: (approvedToolIds: string[], rememberChoice?: boolean) => void;
8+
handleDeny: () => void;
9+
handleClose: () => void;
10+
isProcessing: boolean;
11+
}
12+
13+
export const useToolApproval = (): UseToolApprovalResult => {
14+
const [showApprovalDialog, setShowApprovalDialog] = useState(false);
15+
const [pendingRequest, setPendingRequest] = useState<ToolApprovalRequest | null>(null);
16+
const [isProcessing, setIsProcessing] = useState(false);
17+
18+
// Listen for approval requests from the manager
19+
useEffect(() => {
20+
const handleApprovalRequest = (request: ToolApprovalRequest) => {
21+
setPendingRequest(request);
22+
setShowApprovalDialog(true);
23+
setIsProcessing(false);
24+
};
25+
26+
toolApprovalManager.on('approval-requested', handleApprovalRequest);
27+
28+
return () => {
29+
toolApprovalManager.off('approval-requested', handleApprovalRequest);
30+
};
31+
}, []);
32+
33+
const handleApprove = useCallback((approvedToolIds: string[], rememberChoice = false) => {
34+
if (!pendingRequest) return;
35+
36+
setIsProcessing(true);
37+
toolApprovalManager.approveTools(pendingRequest.requestId, approvedToolIds, rememberChoice);
38+
39+
// Close dialog after a brief delay to show processing state
40+
setTimeout(() => {
41+
setShowApprovalDialog(false);
42+
setPendingRequest(null);
43+
setIsProcessing(false);
44+
}, 500);
45+
}, [pendingRequest]);
46+
47+
const handleDeny = useCallback(() => {
48+
if (!pendingRequest) return;
49+
50+
toolApprovalManager.denyTools(pendingRequest.requestId);
51+
setShowApprovalDialog(false);
52+
setPendingRequest(null);
53+
setIsProcessing(false);
54+
}, [pendingRequest]);
55+
56+
const handleClose = useCallback(() => {
57+
// Close is essentially a denial - user dismissed the dialog
58+
handleDeny();
59+
}, [handleDeny]);
60+
61+
return {
62+
showApprovalDialog,
63+
pendingRequest,
64+
handleApprove,
65+
handleDeny,
66+
handleClose,
67+
isProcessing,
68+
};
69+
};

0 commit comments

Comments
 (0)