feat: support tool level permission control in ui (#2133)

This commit is contained in:
Yingjie He
2025-04-11 12:19:08 -07:00
committed by GitHub
parent cb32160a49
commit df7f2b8ab9
14 changed files with 1387 additions and 113 deletions
+12 -1
View File
@@ -1,7 +1,7 @@
// This file is auto-generated by @hey-api/openapi-ts
import type { Options as ClientOptions, TDataShape, Client } from '@hey-api/client-fetch';
import type { GetToolsData, GetToolsResponse, ReadAllConfigData, ReadAllConfigResponse, GetExtensionsData, GetExtensionsResponse, AddExtensionData, AddExtensionResponse, RemoveExtensionData, RemoveExtensionResponse, InitConfigData, InitConfigResponse, ProvidersData, ProvidersResponse2, ReadConfigData, RemoveConfigData, RemoveConfigResponse, UpsertConfigData, UpsertConfigResponse } from './types.gen';
import type { GetToolsData, GetToolsResponse, ReadAllConfigData, ReadAllConfigResponse, GetExtensionsData, GetExtensionsResponse, AddExtensionData, AddExtensionResponse, RemoveExtensionData, RemoveExtensionResponse, InitConfigData, InitConfigResponse, UpsertPermissionsData, UpsertPermissionsResponse, ProvidersData, ProvidersResponse2, ReadConfigData, RemoveConfigData, RemoveConfigResponse, UpsertConfigData, UpsertConfigResponse } from './types.gen';
import { client as _heyApiClient } from './client.gen';
export type Options<TData extends TDataShape = TDataShape, ThrowOnError extends boolean = boolean> = ClientOptions<TData, ThrowOnError> & {
@@ -64,6 +64,17 @@ export const initConfig = <ThrowOnError extends boolean = false>(options?: Optio
});
};
export const upsertPermissions = <ThrowOnError extends boolean = false>(options: Options<UpsertPermissionsData, ThrowOnError>) => {
return (options.client ?? _heyApiClient).post<UpsertPermissionsResponse, unknown, ThrowOnError>({
url: '/config/permissions',
...options,
headers: {
'Content-Type': 'application/json',
...options?.headers
}
});
};
export const providers = <ThrowOnError extends boolean = false>(options?: Options<ProvidersData, ThrowOnError>) => {
return (options?.client ?? _heyApiClient).get<ProvidersResponse2, unknown, ThrowOnError>({
url: '/config/providers',
+36 -1
View File
@@ -235,12 +235,24 @@ export type ToolInfo = {
permission?: PermissionLevel | null;
};
export type ToolPermission = {
permission: PermissionLevel;
/**
* Unique identifier and name of the tool, format <extension_name>__<tool_name>
*/
tool_name: string;
};
export type UpsertConfigQuery = {
is_secret: boolean;
key: string;
value: unknown;
};
export type UpsertPermissionsQuery = {
tool_permissions: Array<ToolPermission>;
};
export type GetToolsData = {
body?: never;
path?: never;
@@ -272,7 +284,7 @@ export type GetToolsResponses = {
/**
* Tools retrieved successfully
*/
200: Array<Tool>;
200: Array<ToolInfo>;
};
export type GetToolsResponse = GetToolsResponses[keyof GetToolsResponses];
@@ -399,6 +411,29 @@ export type InitConfigResponses = {
export type InitConfigResponse = InitConfigResponses[keyof InitConfigResponses];
export type UpsertPermissionsData = {
body: UpsertPermissionsQuery;
path?: never;
query?: never;
url: '/config/permissions';
};
export type UpsertPermissionsErrors = {
/**
* Invalid request
*/
400: unknown;
};
export type UpsertPermissionsResponses = {
/**
* Permission update completed
*/
200: string;
};
export type UpsertPermissionsResponse = UpsertPermissionsResponses[keyof UpsertPermissionsResponses];
export type ProvidersData = {
body?: never;
path?: never;
@@ -1,22 +1,53 @@
import React, { useState } from 'react';
import { useState } from 'react';
import { ConfirmToolRequest } from '../utils/toolConfirm';
import { snakeToTitleCase } from '../utils';
import PermissionModal from './settings_v2/permission/PermissionModal';
import { ChevronRight } from 'lucide-react';
const ALWAYS_ALLOW = 'always_allow';
const ALLOW_ONCE = 'allow_once';
const DENY = 'deny';
interface ToolConfirmationProps {
isCancelledMessage: boolean;
isClicked: boolean;
toolConfirmationId: string;
toolName: string;
}
export default function ToolConfirmation({
isCancelledMessage,
isClicked,
toolConfirmationId,
toolName,
}) {
}: ToolConfirmationProps) {
const [clicked, setClicked] = useState(isClicked);
const [status, setStatus] = useState('unknown');
const [actionDisplay, setActionDisplay] = useState('');
const [isModalOpen, setIsModalOpen] = useState(false);
const handleButtonClick = (confirmed) => {
const handleButtonClick = (action: string) => {
setClicked(true);
setStatus(confirmed ? 'approved' : 'denied');
ConfirmToolRequest(toolConfirmationId, confirmed);
setStatus(action);
if (action === ALWAYS_ALLOW) {
setActionDisplay('always allowed');
} else if (action === ALLOW_ONCE) {
setActionDisplay('allowed once');
} else {
setActionDisplay('denied');
}
ConfirmToolRequest(toolConfirmationId, action);
};
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-bgSubtle rounded-2xl px-4 py-2 text-textStandard">
Tool call confirmation is cancelled.
@@ -27,9 +58,9 @@ export default function ToolConfirmation({
Goose would like to call the above tool. Allow?
</div>
{clicked ? (
<div className="goose-message-tool bg-bgApp border border-borderSubtle dark:border-gray-700 rounded-b-2xl px-4 pt-4 pb-2 flex gap-4 mt-1">
<div className="goose-message-tool bg-bgApp 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 === 'approved' && (
{status === 'always_allow' && (
<svg
className="w-5 h-5 text-gray-500"
xmlns="http://www.w3.org/2000/svg"
@@ -41,7 +72,19 @@ export default function ToolConfirmation({
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
)}
{status === 'denied' && (
{status === 'allow_once' && (
<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"
@@ -56,30 +99,48 @@ export default function ToolConfirmation({
<span className="ml-2 text-textStandard">
{isClicked
? 'Tool confirmation is not available'
: `${snakeToTitleCase(toolName.substring(toolName.lastIndexOf('__') + 2))} is ${status}`}
: `${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-bgApp border border-borderSubtle dark:border-gray-700 rounded-b-2xl px-4 pt-4 pb-2 flex gap-4 mt-1">
<div className="goose-message-tool bg-bgApp border border-borderSubtle dark:border-gray-700 rounded-b-2xl px-4 pt-2 pb-2 flex gap-2 items-center">
<button
className={
'bg-black text-white dark:bg-white dark:text-black rounded-full px-6 py-2 transition'
}
onClick={() => handleButtonClick(true)}
onClick={() => handleButtonClick(ALWAYS_ALLOW)}
>
Allow tool
Always Allow
</button>
<button
className={
'bg-bgProminent text-white dark:text-white rounded-full px-6 py-2 transition'
}
onClick={() => handleButtonClick(ALLOW_ONCE)}
>
Allow Once
</button>
<button
className={
'bg-white text-black dark:bg-black dark:text-white border border-gray-300 dark:border-gray-700 rounded-full px-6 py-2 transition'
}
onClick={() => handleButtonClick(false)}
onClick={() => handleButtonClick(DENY)}
>
Deny
</button>
</div>
)}
{/* Modal for updating tool permission */}
{isModalOpen && (
<PermissionModal onClose={handleModalClose} extensionName={getExtensionName(toolName)} />
)}
</>
);
}
@@ -0,0 +1,192 @@
import { useEffect, useState } from 'react';
import { Button } from '../../ui/button';
import { ChevronDownIcon, SlidersHorizontal } from 'lucide-react';
import { getTools, PermissionLevel, ToolInfo, upsertPermissions } from '../../../api';
import * as Dialog from '@radix-ui/react-dialog';
import * as DropdownMenu from '@radix-ui/react-dropdown-menu';
function getFirstSentence(text: string): string {
const match = text.match(/^([^.?!]+[.?!])/);
return match ? match[0] : '';
}
interface PermissionModalProps {
extensionName: string;
onClose: () => void;
}
export default function PermissionModal({ extensionName, onClose }: PermissionModalProps) {
const permissionOptions = [
{ value: 'always_allow', label: 'Always allow' },
{ value: 'ask_before', label: 'Ask before' },
{ value: 'never_allow', label: 'Never allow' },
] as { value: PermissionLevel; label: string }[];
const [tools, setTools] = useState<ToolInfo[]>([]);
const [updatedPermissions, setUpdatedPermissions] = useState<Record<string, string>>({});
useEffect(() => {
const fetchTools = async () => {
try {
const response = await getTools({ query: { extension_name: extensionName } });
if (response.error) {
console.error('Failed to get tools');
} else {
setTools(response.data || []);
}
} catch (err) {
console.error('Error fetching tools:', err);
}
};
fetchTools();
}, [extensionName]);
const handleSettingChange = (toolName: string, newPermission: PermissionLevel) => {
setUpdatedPermissions((prev) => ({
...prev,
[toolName]: newPermission,
}));
};
const handleSave = async () => {
try {
const payload = {
tool_permissions: Object.entries(updatedPermissions).map(([toolName, permission]) => ({
tool_name: toolName,
permission: permission as PermissionLevel,
})),
};
if (payload.tool_permissions.length === 0) {
onClose();
return;
}
const response = await upsertPermissions({
body: payload,
});
if (response.error) {
console.error('Failed to save permissions:', response.error);
} else {
console.log('Permissions updated successfully');
onClose();
}
} catch (err) {
console.error('Error saving permissions:', err);
}
};
const footerContent = (
<>
<Button
onClick={handleSave}
className="w-full h-[60px] rounded-none border-b border-borderSubtle bg-transparent hover:bg-bgSubtle text-textProminent font-medium text-md"
>
Save Changes
</Button>
<Button
onClick={onClose}
variant="ghost"
className="w-full h-[60px] rounded-none hover:bg-bgSubtle text-textSubtle hover:text-textStandard text-md font-regular"
>
Cancel
</Button>
</>
);
return (
<Dialog.Root open={true} onOpenChange={(open) => !open && onClose()}>
<Dialog.Portal>
<Dialog.Overlay className="fixed inset-0 bg-black/30 dark:bg-white/20 transition-colors animate-[fadein_200ms_ease-in_forwards]" />
<Dialog.Content className="fixed top-[50%] left-[50%] translate-x-[-50%] translate-y-[-50%] w-[500px] max-w-[90vw] bg-bgApp rounded-xl shadow-xl max-h-[90vh] flex flex-col">
<div className="p-6">
<Dialog.Title className="DialogTitle flex justify-start items-center mb-6">
<SlidersHorizontal className="text-iconStandard" size={24} />
<p className="ml-2 text-2xl font-semibold text-textStandard">{extensionName}</p>
</Dialog.Title>
<Dialog.Description className="DialogDescription"></Dialog.Description>
<div className="flex justify-center items-center h-full">
{tools.length === 0 ? (
<div className="flex items-center justify-center">
{/* Loading spinner */}
<svg
className="animate-spin h-8 w-8 text-grey-50 dark:text-white"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
></circle>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8v8H4z"
></path>
</svg>
</div>
) : (
<div>
{tools.map((tool) => (
<div
key={tool.name}
className="mb-4 flex items-center justify-between grid grid-cols-12"
>
<div className="flex flex-col col-span-8">
<label className="block text-sm font-medium text-textStandard">
{tool.name}
</label>
<p className="text-sm text-gray-500 mb-2">
{getFirstSentence(tool.description)}
</p>
</div>
<DropdownMenu.Root>
<DropdownMenu.Trigger className="flex col-span-4 items-center justify-center bg-bgSubtle text-textStandard rounded-full px-3 py-2">
<span>
{permissionOptions.find(
(option) =>
option.value === (updatedPermissions[tool.name] || tool.permission)
)?.label || 'Ask Before'}
</span>
<ChevronDownIcon className="ml-2 h-4 w-4" />
</DropdownMenu.Trigger>
<DropdownMenu.Portal>
<DropdownMenu.Content className="bg-white dark:bg-bgProminent rounded-lg shadow-md">
{permissionOptions.map((option) => (
<DropdownMenu.Item
key={option.value}
className="px-8 py-2 rounded-lg hover:cursor-pointer hover:bg-bgSubtle text-textStandard"
onSelect={() =>
handleSettingChange(tool.name, option.value as PermissionLevel)
}
>
{option.label}
</DropdownMenu.Item>
))}
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu.Root>
</div>
))}
</div>
)}
</div>
</div>
{footerContent && (
<div className="border-t border-borderSubtle bg-bgApp w-full rounded-b-xl overflow-hidden">
{footerContent}
</div>
)}
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
);
}
+2 -2
View File
@@ -1,6 +1,6 @@
import { getApiUrl, getSecretKey } from '../config';
export async function ConfirmToolRequest(requesyId: string, confirmed: boolean) {
export async function ConfirmToolRequest(requesyId: string, action: string) {
try {
const response = await fetch(getApiUrl('/confirm'), {
method: 'POST',
@@ -10,7 +10,7 @@ export async function ConfirmToolRequest(requesyId: string, confirmed: boolean)
},
body: JSON.stringify({
id: requesyId,
confirmed,
action,
}),
});