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

View File

@@ -21,6 +21,7 @@ use utoipa::OpenApi;
super::routes::config_management::get_extensions, super::routes::config_management::get_extensions,
super::routes::config_management::read_all_config, super::routes::config_management::read_all_config,
super::routes::config_management::providers, super::routes::config_management::providers,
super::routes::config_management::upsert_permissions,
super::routes::agent::get_tools, super::routes::agent::get_tools,
), ),
components(schemas( components(schemas(
@@ -31,6 +32,8 @@ use utoipa::OpenApi;
super::routes::config_management::ProviderDetails, super::routes::config_management::ProviderDetails,
super::routes::config_management::ExtensionResponse, super::routes::config_management::ExtensionResponse,
super::routes::config_management::ExtensionQuery, super::routes::config_management::ExtensionQuery,
super::routes::config_management::ToolPermission,
super::routes::config_management::UpsertPermissionsQuery,
ProviderMetadata, ProviderMetadata,
ExtensionEntry, ExtensionEntry,
ExtensionConfig, ExtensionConfig,

View File

@@ -5,10 +5,13 @@ use axum::{
routing::{get, post}, routing::{get, post},
Json, Router, Json, Router,
}; };
use goose::agents::{extension::ToolInfo, extension_manager::get_parameter_names};
use goose::config::Config; use goose::config::Config;
use goose::config::PermissionManager; use goose::config::PermissionManager;
use goose::{agents::Agent, model::ModelConfig, providers}; use goose::{agents::Agent, model::ModelConfig, providers};
use goose::{
agents::{extension::ToolInfo, extension_manager::get_parameter_names},
config::permission::PermissionLevel,
};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::collections::HashMap; use std::collections::HashMap;
use std::env; use std::env;
@@ -173,7 +176,7 @@ async fn list_providers() -> Json<Vec<ProviderList>> {
("extension_name" = Option<String>, Query, description = "Optional extension name to filter tools") ("extension_name" = Option<String>, Query, description = "Optional extension name to filter tools")
), ),
responses( responses(
(status = 200, description = "Tools retrieved successfully", body = Vec<Tool>), (status = 200, description = "Tools retrieved successfully", body = Vec<ToolInfo>),
(status = 401, description = "Unauthorized - invalid secret key"), (status = 401, description = "Unauthorized - invalid secret key"),
(status = 424, description = "Agent not initialized"), (status = 424, description = "Agent not initialized"),
(status = 500, description = "Internal server error") (status = 500, description = "Internal server error")
@@ -193,11 +196,13 @@ async fn get_tools(
return Err(StatusCode::UNAUTHORIZED); return Err(StatusCode::UNAUTHORIZED);
} }
let config = Config::global();
let goose_mode = config.get_param("GOOSE_MODE").unwrap_or("auto".to_string());
let mut agent = state.agent.write().await; let mut agent = state.agent.write().await;
let agent = agent.as_mut().ok_or(StatusCode::PRECONDITION_REQUIRED)?; let agent = agent.as_mut().ok_or(StatusCode::PRECONDITION_REQUIRED)?;
let permission_manager = PermissionManager::default(); let permission_manager = PermissionManager::default();
let tools = agent let mut tools: Vec<ToolInfo> = agent
.list_tools() .list_tools()
.await .await
.into_iter() .into_iter()
@@ -210,14 +215,27 @@ async fn get_tools(
} }
}) })
.map(|tool| { .map(|tool| {
let permission = permission_manager
.get_user_permission(&tool.name)
.or_else(|| {
if goose_mode == "smart_approve" {
permission_manager.get_smart_approve_permission(&tool.name)
} else if goose_mode == "approve" {
Some(PermissionLevel::AskBefore)
} else {
None
}
});
ToolInfo::new( ToolInfo::new(
&tool.name, &tool.name,
&tool.description, &tool.description,
get_parameter_names(&tool), get_parameter_names(&tool),
permission_manager.get_user_permission(&tool.name), permission,
) )
}) })
.collect(); .collect::<Vec<ToolInfo>>();
tools.sort_by(|a, b| a.name.cmp(&b.name));
Ok(Json(tools)) Ok(Json(tools))
} }

View File

@@ -5,12 +5,12 @@ use axum::{
routing::{delete, get, post}, routing::{delete, get, post},
Json, Router, Json, Router,
}; };
use goose::agents::ExtensionConfig;
use goose::config::extensions::name_to_key;
use goose::config::Config; use goose::config::Config;
use goose::config::{extensions::name_to_key, PermissionManager};
use goose::config::{ExtensionConfigManager, ExtensionEntry}; use goose::config::{ExtensionConfigManager, ExtensionEntry};
use goose::providers::base::ProviderMetadata; use goose::providers::base::ProviderMetadata;
use goose::providers::providers as get_providers; use goose::providers::providers as get_providers;
use goose::{agents::ExtensionConfig, config::permission::PermissionLevel};
use http::{HeaderMap, StatusCode}; use http::{HeaderMap, StatusCode};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_json::Value; use serde_json::Value;
@@ -78,6 +78,18 @@ pub struct ProvidersResponse {
pub providers: Vec<ProviderDetails>, pub providers: Vec<ProviderDetails>,
} }
#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct ToolPermission {
/// Unique identifier and name of the tool, format <extension_name>__<tool_name>
pub tool_name: String,
pub permission: PermissionLevel,
}
#[derive(Deserialize, ToSchema)]
pub struct UpsertPermissionsQuery {
pub tool_permissions: Vec<ToolPermission>,
}
#[utoipa::path( #[utoipa::path(
post, post,
path = "/config/upsert", path = "/config/upsert",
@@ -389,6 +401,34 @@ pub async fn init_config(
} }
} }
#[utoipa::path(
post,
path = "/config/permissions",
request_body = UpsertPermissionsQuery,
responses(
(status = 200, description = "Permission update completed", body = String),
(status = 400, description = "Invalid request"),
)
)]
pub async fn upsert_permissions(
State(state): State<AppState>,
headers: HeaderMap,
Json(query): Json<UpsertPermissionsQuery>,
) -> Result<Json<String>, StatusCode> {
verify_secret_key(&headers, &state)?;
let mut permission_manager = PermissionManager::default();
// Iterate over each tool permission and update permissions
for tool_permission in &query.tool_permissions {
permission_manager.update_user_permission(
&tool_permission.tool_name,
tool_permission.permission.clone(),
);
}
Ok(Json("Permissions updated successfully".to_string()))
}
pub fn routes(state: AppState) -> Router { pub fn routes(state: AppState) -> Router {
Router::new() Router::new()
.route("/config", get(read_all_config)) .route("/config", get(read_all_config))
@@ -400,5 +440,6 @@ pub fn routes(state: AppState) -> Router {
.route("/config/extensions/:name", delete(remove_extension)) .route("/config/extensions/:name", delete(remove_extension))
.route("/config/providers", get(providers)) .route("/config/providers", get(providers))
.route("/config/init", post(init_config)) .route("/config/init", post(init_config))
.route("/config/permissions", post(upsert_permissions))
.with_state(state) .with_state(state)
} }

View File

@@ -368,7 +368,7 @@ async fn ask_handler(
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
struct ToolConfirmationRequest { struct ToolConfirmationRequest {
id: String, id: String,
confirmed: bool, action: String,
} }
async fn confirm_handler( async fn confirm_handler(
@@ -389,11 +389,14 @@ async fn confirm_handler(
let agent = state.agent.clone(); let agent = state.agent.clone();
let agent = agent.read().await; let agent = agent.read().await;
let agent = agent.as_ref().ok_or(StatusCode::NOT_FOUND)?; let agent = agent.as_ref().ok_or(StatusCode::NOT_FOUND)?;
let permission = if request.confirmed {
Permission::AllowOnce let permission = match request.action.as_str() {
} else { "always_allow" => Permission::AlwaysAllow,
Permission::DenyOnce "allow_once" => Permission::AllowOnce,
"deny" => Permission::DenyOnce,
_ => Permission::DenyOnce,
}; };
agent agent
.handle_confirmation( .handle_confirmation(
request.id.clone(), request.id.clone(),

View File

@@ -24,6 +24,8 @@ pub enum ExtensionError {
Transport(#[from] mcp_client::transport::Error), Transport(#[from] mcp_client::transport::Error),
#[error("Environment variable `{0}` is not allowed to be overridden.")] #[error("Environment variable `{0}` is not allowed to be overridden.")]
InvalidEnvVar(String), InvalidEnvVar(String),
#[error("Join error occurred during task execution: {0}")]
TaskJoinError(#[from] tokio::task::JoinError),
} }
pub type ExtensionResult<T> = Result<T, ExtensionError>; pub type ExtensionResult<T> = Result<T, ExtensionError>;

View File

@@ -1,5 +1,6 @@
use anyhow::Result; use anyhow::Result;
use chrono::{DateTime, TimeZone, Utc}; use chrono::{DateTime, TimeZone, Utc};
use futures::future;
use futures::stream::{FuturesUnordered, StreamExt}; use futures::stream::{FuturesUnordered, StreamExt};
use mcp_client::McpService; use mcp_client::McpService;
use mcp_core::protocol::GetPromptResult; use mcp_core::protocol::GetPromptResult;
@@ -8,6 +9,7 @@ use std::sync::Arc;
use std::sync::LazyLock; use std::sync::LazyLock;
use std::time::Duration; use std::time::Duration;
use tokio::sync::Mutex; use tokio::sync::Mutex;
use tokio::task;
use tracing::debug; use tracing::debug;
use super::extension::{ExtensionConfig, ExtensionError, ExtensionInfo, ExtensionResult, ToolInfo}; use super::extension::{ExtensionConfig, ExtensionError, ExtensionInfo, ExtensionResult, ToolInfo};
@@ -230,29 +232,47 @@ impl ExtensionManager {
/// Get all tools from all clients with proper prefixing /// Get all tools from all clients with proper prefixing
pub async fn get_prefixed_tools(&self) -> ExtensionResult<Vec<Tool>> { pub async fn get_prefixed_tools(&self) -> ExtensionResult<Vec<Tool>> {
let client_futures = self.clients.iter().map(|(name, client)| {
let name = name.clone();
let client = client.clone();
task::spawn(async move {
let mut tools = Vec::new();
let client_guard = client.lock().await;
let mut client_tools = client_guard.list_tools(None).await?;
loop {
for tool in client_tools.tools {
tools.push(Tool::new(
format!("{}__{}", name, tool.name),
&tool.description,
tool.input_schema,
tool.annotations,
));
}
// Exit loop when there are no more pages
if client_tools.next_cursor.is_none() {
break;
}
client_tools = client_guard.list_tools(client_tools.next_cursor).await?;
}
Ok::<Vec<Tool>, ExtensionError>(tools)
})
});
// Collect all results concurrently
let results = future::join_all(client_futures).await;
// Aggregate tools and handle errors
let mut tools = Vec::new(); let mut tools = Vec::new();
for result in results {
// Add tools from MCP extensions with prefixing match result {
for (name, client) in &self.clients { Ok(Ok(client_tools)) => tools.extend(client_tools),
let client_guard = client.lock().await; Ok(Err(err)) => return Err(err),
let mut client_tools = client_guard.list_tools(None).await?; Err(join_err) => return Err(ExtensionError::from(join_err)),
loop {
for tool in client_tools.tools {
tools.push(Tool::new(
format!("{}__{}", name, tool.name),
&tool.description,
tool.input_schema,
tool.annotations,
));
}
// exit loop when there are no more pages
if client_tools.next_cursor.is_none() {
break;
}
client_tools = client_guard.list_tools(client_tools.next_cursor).await?;
} }
} }

View File

@@ -39,7 +39,7 @@
"schema": { "schema": {
"type": "array", "type": "array",
"items": { "items": {
"$ref": "#/components/schemas/Tool" "$ref": "#/components/schemas/ToolInfo"
} }
} }
} }
@@ -196,6 +196,39 @@
} }
} }
}, },
"/config/permissions": {
"post": {
"tags": [
"super::routes::config_management"
],
"operationId": "upsert_permissions",
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UpsertPermissionsQuery"
}
}
},
"required": true
},
"responses": {
"200": {
"description": "Permission update completed",
"content": {
"text/plain": {
"schema": {
"type": "string"
}
}
}
},
"400": {
"description": "Invalid request"
}
}
}
},
"/config/providers": { "/config/providers": {
"get": { "get": {
"tags": [ "tags": [
@@ -783,6 +816,22 @@
} }
} }
}, },
"ToolPermission": {
"type": "object",
"required": [
"tool_name",
"permission"
],
"properties": {
"permission": {
"$ref": "#/components/schemas/PermissionLevel"
},
"tool_name": {
"type": "string",
"description": "Unique identifier and name of the tool, format <extension_name>__<tool_name>"
}
}
},
"UpsertConfigQuery": { "UpsertConfigQuery": {
"type": "object", "type": "object",
"required": [ "required": [
@@ -799,6 +848,20 @@
}, },
"value": {} "value": {}
} }
},
"UpsertPermissionsQuery": {
"type": "object",
"required": [
"tool_permissions"
],
"properties": {
"tool_permissions": {
"type": "array",
"items": {
"$ref": "#/components/schemas/ToolPermission"
}
}
}
} }
} }
} }

File diff suppressed because it is too large Load Diff

View File

@@ -82,12 +82,12 @@
"@hey-api/client-fetch": "^0.8.1", "@hey-api/client-fetch": "^0.8.1",
"@radix-ui/react-accordion": "^1.2.2", "@radix-ui/react-accordion": "^1.2.2",
"@radix-ui/react-avatar": "^1.1.1", "@radix-ui/react-avatar": "^1.1.1",
"@radix-ui/react-dialog": "^1.1.4", "@radix-ui/react-dialog": "^1.1.7",
"@radix-ui/react-icons": "^1.3.1", "@radix-ui/react-icons": "^1.3.1",
"@radix-ui/react-popover": "^1.1.6", "@radix-ui/react-popover": "^1.1.6",
"@radix-ui/react-radio-group": "^1.2.3", "@radix-ui/react-radio-group": "^1.2.3",
"@radix-ui/react-scroll-area": "^1.2.0", "@radix-ui/react-scroll-area": "^1.2.0",
"@radix-ui/react-select": "^2.1.5", "@radix-ui/react-select": "^2.1.7",
"@radix-ui/react-slot": "^1.1.1", "@radix-ui/react-slot": "^1.1.1",
"@radix-ui/react-tabs": "^1.1.1", "@radix-ui/react-tabs": "^1.1.1",
"@radix-ui/themes": "^3.1.5", "@radix-ui/themes": "^3.1.5",

View File

@@ -1,7 +1,7 @@
// This file is auto-generated by @hey-api/openapi-ts // This file is auto-generated by @hey-api/openapi-ts
import type { Options as ClientOptions, TDataShape, Client } from '@hey-api/client-fetch'; 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'; import { client as _heyApiClient } from './client.gen';
export type Options<TData extends TDataShape = TDataShape, ThrowOnError extends boolean = boolean> = ClientOptions<TData, ThrowOnError> & { 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>) => { export const providers = <ThrowOnError extends boolean = false>(options?: Options<ProvidersData, ThrowOnError>) => {
return (options?.client ?? _heyApiClient).get<ProvidersResponse2, unknown, ThrowOnError>({ return (options?.client ?? _heyApiClient).get<ProvidersResponse2, unknown, ThrowOnError>({
url: '/config/providers', url: '/config/providers',

View File

@@ -235,12 +235,24 @@ export type ToolInfo = {
permission?: PermissionLevel | null; 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 = { export type UpsertConfigQuery = {
is_secret: boolean; is_secret: boolean;
key: string; key: string;
value: unknown; value: unknown;
}; };
export type UpsertPermissionsQuery = {
tool_permissions: Array<ToolPermission>;
};
export type GetToolsData = { export type GetToolsData = {
body?: never; body?: never;
path?: never; path?: never;
@@ -272,7 +284,7 @@ export type GetToolsResponses = {
/** /**
* Tools retrieved successfully * Tools retrieved successfully
*/ */
200: Array<Tool>; 200: Array<ToolInfo>;
}; };
export type GetToolsResponse = GetToolsResponses[keyof GetToolsResponses]; export type GetToolsResponse = GetToolsResponses[keyof GetToolsResponses];
@@ -399,6 +411,29 @@ export type InitConfigResponses = {
export type InitConfigResponse = InitConfigResponses[keyof 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 = { export type ProvidersData = {
body?: never; body?: never;
path?: never; path?: never;

View File

@@ -1,22 +1,53 @@
import React, { useState } from 'react'; import { useState } from 'react';
import { ConfirmToolRequest } from '../utils/toolConfirm'; import { ConfirmToolRequest } from '../utils/toolConfirm';
import { snakeToTitleCase } from '../utils'; 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({ export default function ToolConfirmation({
isCancelledMessage, isCancelledMessage,
isClicked, isClicked,
toolConfirmationId, toolConfirmationId,
toolName, toolName,
}) { }: ToolConfirmationProps) {
const [clicked, setClicked] = useState(isClicked); const [clicked, setClicked] = useState(isClicked);
const [status, setStatus] = useState('unknown'); const [status, setStatus] = useState('unknown');
const [actionDisplay, setActionDisplay] = useState('');
const [isModalOpen, setIsModalOpen] = useState(false);
const handleButtonClick = (confirmed) => { const handleButtonClick = (action: string) => {
setClicked(true); setClicked(true);
setStatus(confirmed ? 'approved' : 'denied'); setStatus(action);
ConfirmToolRequest(toolConfirmationId, confirmed); 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 ? ( return isCancelledMessage ? (
<div className="goose-message-content bg-bgSubtle rounded-2xl px-4 py-2 text-textStandard"> <div className="goose-message-content bg-bgSubtle rounded-2xl px-4 py-2 text-textStandard">
Tool call confirmation is cancelled. Tool call confirmation is cancelled.
@@ -27,9 +58,9 @@ export default function ToolConfirmation({
Goose would like to call the above tool. Allow? Goose would like to call the above tool. Allow?
</div> </div>
{clicked ? ( {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"> <div className="flex items-center">
{status === 'approved' && ( {status === 'always_allow' && (
<svg <svg
className="w-5 h-5 text-gray-500" className="w-5 h-5 text-gray-500"
xmlns="http://www.w3.org/2000/svg" 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" /> <path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg> </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 <svg
className="w-5 h-5 text-gray-500" className="w-5 h-5 text-gray-500"
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
@@ -56,30 +99,48 @@ export default function ToolConfirmation({
<span className="ml-2 text-textStandard"> <span className="ml-2 text-textStandard">
{isClicked {isClicked
? 'Tool confirmation is not available' ? 'Tool confirmation is not available'
: `${snakeToTitleCase(toolName.substring(toolName.lastIndexOf('__') + 2))} is ${status}`} : `${snakeToTitleCase(toolName.substring(toolName.lastIndexOf('__') + 2))} is ${actionDisplay}`}
</span> </span>
</div> </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>
) : ( ) : (
<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 <button
className={ className={
'bg-black text-white dark:bg-white dark:text-black rounded-full px-6 py-2 transition' '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>
<button <button
className={ 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' '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 Deny
</button> </button>
</div> </div>
)} )}
{/* Modal for updating tool permission */}
{isModalOpen && (
<PermissionModal onClose={handleModalClose} extensionName={getExtensionName(toolName)} />
)}
</> </>
); );
} }

View File

@@ -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>
);
}

View File

@@ -1,6 +1,6 @@
import { getApiUrl, getSecretKey } from '../config'; import { getApiUrl, getSecretKey } from '../config';
export async function ConfirmToolRequest(requesyId: string, confirmed: boolean) { export async function ConfirmToolRequest(requesyId: string, action: string) {
try { try {
const response = await fetch(getApiUrl('/confirm'), { const response = await fetch(getApiUrl('/confirm'), {
method: 'POST', method: 'POST',
@@ -10,7 +10,7 @@ export async function ConfirmToolRequest(requesyId: string, confirmed: boolean)
}, },
body: JSON.stringify({ body: JSON.stringify({
id: requesyId, id: requesyId,
confirmed, action,
}), }),
}); });