Set a tool's permissions by group, with actions in words

What a tool looks up and what it does are each set at once to Allow, Ask
first or Off; long lists fold behind Customize, where each action shows its
name in words and its description. A note says what Ask first means over
an agent's API. The panel follows a refreshed tool's actions.
This commit is contained in:
arc53-machine committed 2026-09-29 00:54:07 +01:00
1 parent 5c2a45fb74
commit 212fa4f8dc
5 files changed
+303 -53

No files matched your search

@@ -98,7 +98,7 @@ describe('ApiWriteAllowlist', () => {
const labels = Array.from(container.querySelectorAll('label')).map( const labels = Array.from(container.querySelectorAll('label')).map(
(l) => l.textContent, (l) => l.textContent,
); );
expect(labels).toEqual(['Telegram: telegram_send_message']); expect(labels).toEqual(['Telegram: Telegram send message']);
expect( expect(
container container
.querySelector('button[role="checkbox"]')! .querySelector('button[role="checkbox"]')!
+2 -1
View File
@@ -10,6 +10,7 @@ import { SectionHeader } from '../components/ui/section-header';
import { showActionToast } from '../notifications/actionToastSlice'; import { showActionToast } from '../notifications/actionToastSlice';
import { selectToken } from '../preferences/preferenceSlice'; import { selectToken } from '../preferences/preferenceSlice';
import type { Agent, AgentConfig } from './types'; import type { Agent, AgentConfig } from './types';
import { actionTitle } from '../connectors/i18n';
type WriteAction = { type WriteAction = {
entry: string; entry: string;
@@ -148,7 +149,7 @@ export default function ApiWriteAllowlist({
<Label htmlFor={id} className="font-normal"> <Label htmlFor={id} className="font-normal">
{t('modals.agentDetails.apiWrites.item', { {t('modals.agentDetails.apiWrites.item', {
tool: item.tool, tool: item.tool,
action: item.action, action: actionTitle(item.action),
interpolation: { escapeValue: false }, interpolation: { escapeValue: false },
})} })}
</Label> </Label>
@@ -0,0 +1,136 @@
import { configureStore } from '@reduxjs/toolkit';
import { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { Provider } from 'react-redux';
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string, opts?: Record<string, unknown>) =>
opts?.count !== undefined ? `${key}:${opts.count}` : key,
}),
}));
const connectors = vi.hoisted(() => ({ setToolPermissions: vi.fn() }));
vi.mock('../api/services/connectorsService', () => ({ default: connectors }));
import actionToastReducer from '../notifications/actionToastSlice';
import ToolPermissions from './ToolPermissions';
import type { ConnectionTool } from './types';
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
const action = (
name: string,
access: 'read' | 'write',
permission: 'always' | 'ask' | 'off',
) => ({ name, access, permission, description: `Does ${name}.` });
const tool = (actions: ReturnType<typeof action>[]): ConnectionTool =>
({
id: 'tool-1',
name: 'mcp_tool',
display_name: 'Linear',
status: true,
credential_mode: 'owner',
actions,
}) as ConnectionTool;
const MANY_READS = Array.from({ length: 8 }, (_, i) =>
action(`get_issue_${i}`, 'read', 'always'),
);
describe('ToolPermissions', () => {
let container: HTMLDivElement;
let root: Root;
const store = () =>
configureStore({
reducer: {
actionToast: actionToastReducer,
preference: (state = { token: null }) => state,
},
});
beforeEach(() => {
connectors.setToolPermissions.mockReset();
connectors.setToolPermissions.mockImplementation(async () => ({
success: true,
tool: tool([]),
}));
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(async () => {
await act(async () => root.unmount());
container.remove();
});
const render = async (value: ConnectionTool) => {
const s = store();
await act(async () => {
root.render(
<Provider store={s}>
<ToolPermissions connectionId="conn-1" tool={value} />
</Provider>,
);
});
};
const group = (access: 'read' | 'write') =>
container.querySelector<HTMLElement>(`[data-access="${access}"]`)!;
const choice = (access: 'read' | 'write', permission: string) =>
group(access).querySelector<HTMLButtonElement>(
`[data-permission="${permission}"]`,
)!;
it('sets every read at once', async () => {
await render(tool([...MANY_READS, action('create_issue', 'write', 'ask')]));
await act(async () => choice('read', 'ask').click());
expect(connectors.setToolPermissions).toHaveBeenCalledWith(
'conn-1',
'tool-1',
Object.fromEntries(MANY_READS.map((a) => [a.name, 'ask'])),
null,
);
expect(choice('read', 'ask').getAttribute('data-state')).toBe('on');
// Writes are untouched.
expect(choice('write', 'ask').getAttribute('data-state')).toBe('on');
});
it('shows no group choice when its actions differ', async () => {
await render(
tool([action('a', 'write', 'ask'), action('b', 'write', 'always')]),
);
for (const permission of ['always', 'ask', 'off'])
expect(choice('write', permission).getAttribute('data-state')).toBe(
'off',
);
});
it('keeps a long list folded, then lists actions in words with what they do', async () => {
await render(tool(MANY_READS));
expect(container.textContent).not.toContain('Get issue 0');
const customize = Array.from(container.querySelectorAll('button')).find(
(b) =>
b.textContent?.startsWith('settings.connectors.permission.customize'),
)!;
await act(async () => customize.click());
expect(container.textContent).toContain('Get issue 0');
expect(container.textContent).toContain('Does get_issue_0.');
});
it('follows the tool when its actions change', async () => {
await render(tool([action('old_one', 'read', 'always')]));
await render(tool([action('new_one', 'read', 'always')]));
expect(container.textContent).toContain('New one');
expect(container.textContent).not.toContain('Old one');
});
it('says what Ask first means outside chat', async () => {
await render(tool([action('create_issue', 'write', 'ask')]));
expect(container.textContent).toContain(
'settings.connectors.permission.hint',
);
});
});
+158 -51
View File
@@ -1,8 +1,9 @@
import { useState } from 'react'; import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useDispatch, useSelector } from 'react-redux'; import { useDispatch, useSelector } from 'react-redux';
import connectorsService from '../api/services/connectorsService'; import connectorsService from '../api/services/connectorsService';
import { Button } from '../components/ui/button';
import { Card } from '../components/ui/card'; import { Card } from '../components/ui/card';
import { SectionHeader } from '../components/ui/section-header'; import { SectionHeader } from '../components/ui/section-header';
import { import {
@@ -12,8 +13,11 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from '../components/ui/select'; } from '../components/ui/select';
import { ToggleGroup, ToggleGroupItem } from '../components/ui/toggle-group';
import { showActionToast } from '../notifications/actionToastSlice'; import { showActionToast } from '../notifications/actionToastSlice';
import { selectToken } from '../preferences/preferenceSlice'; import { selectToken } from '../preferences/preferenceSlice';
import { formatCount } from '../utils/dateTimeUtils';
import { actionTitle } from './i18n';
import type { import type {
ActionPermission, ActionPermission,
ConnectionTool, ConnectionTool,
@@ -22,10 +26,14 @@ import type {
const PERMISSIONS: ActionPermission[] = ['always', 'ask', 'off']; const PERMISSIONS: ActionPermission[] = ['always', 'ask', 'off'];
/** Longer groups start folded to their one group choice. */
const FOLD_AFTER = 5;
/** /**
* One tool's actions, grouped Read and Write, each with Always allow / * One tool's actions in two groups, what it looks up and what it does, each
* Needs approval / Off. Changes save immediately; a failed save puts the * set at once to Allow / Ask first / Off; single actions can differ under
* previous choice back and says so. * Customize. Changes save immediately; a failed save puts the previous
* choices back and says so.
*/ */
export default function ToolPermissions({ export default function ToolPermissions({
connectionId, connectionId,
@@ -45,15 +53,21 @@ export default function ToolPermissions({
const dispatch = useDispatch(); const dispatch = useDispatch();
const token = useSelector(selectToken); const token = useSelector(selectToken);
const [actions, setActions] = useState<ConnectionToolAction[]>(tool.actions); const [actions, setActions] = useState<ConnectionToolAction[]>(tool.actions);
const [unfolded, setUnfolded] = useState<Record<string, boolean>>({});
// A refreshed tool brings its new list of actions.
useEffect(() => setActions(tool.actions), [tool.actions]);
const setPermission = (name: string, permission: ActionPermission) => { const setPermissions = (changes: Record<string, ActionPermission>) => {
const previous = actions; const previous = actions;
const next = actions.map((action) => setActions(
action.name === name ? { ...action, permission } : action, actions.map((action) =>
changes[action.name]
? { ...action, permission: changes[action.name] }
: action,
),
); );
setActions(next);
connectorsService connectorsService
.setToolPermissions(connectionId, tool.id, { [name]: permission }, token) .setToolPermissions(connectionId, tool.id, changes, token)
.then((data) => { .then((data) => {
if (!data?.success) throw new Error('save failed'); if (!data?.success) throw new Error('save failed');
onChange?.(data.tool); onChange?.(data.tool);
@@ -78,54 +92,147 @@ export default function ToolPermissions({
return ( return (
<Card variant={variant} padding="sm" className="gap-4"> <Card variant={variant} padding="sm" className="gap-4">
{groups.map((group) => ( {groups.map((group) => {
<div key={group.access} className="flex flex-col gap-2"> const title = t(`settings.connectors.capabilityPlain.${group.access}`);
<SectionHeader const first = group.actions[0].permission;
as="h4" // One choice for the group when its actions agree; none when mixed.
size="sm" const common = group.actions.every(
title={t(`settings.connectors.capability.${group.access}`)} (action) => action.permission === first,
/> )
<ul className="flex flex-col gap-2"> ? first
{group.actions.map((action) => ( : '';
<li const foldable = group.actions.length > FOLD_AFTER;
key={action.name} const open = !foldable || unfolded[group.access];
className="flex items-center justify-between gap-3" return (
> <div
<span key={group.access}
className="text-foreground min-w-0 truncate font-mono text-xs" data-access={group.access}
title={action.description || action.name} className="flex flex-col gap-2"
> >
{action.name} <div className="flex flex-wrap items-center justify-between gap-2">
</span> <SectionHeader
<Select as="h4"
value={action.permission} size="sm"
title={title}
description={t('settings.connectors.permission.actionCount', {
count: group.actions.length,
formatted: formatCount(group.actions.length),
})}
/>
<div className="bg-muted rounded-full p-1">
<ToggleGroup
type="single"
size="xs"
value={common}
disabled={readOnly} disabled={readOnly}
aria-label={t('settings.connectors.permission.groupLabel', {
group: title,
})}
onValueChange={(value) => onValueChange={(value) =>
setPermission(action.name, value as ActionPermission) value &&
setPermissions(
Object.fromEntries(
group.actions.map((action) => [
action.name,
value as ActionPermission,
]),
),
)
} }
> >
<SelectTrigger {PERMISSIONS.map((permission) => (
size="sm" <ToggleGroupItem
className="w-40 shrink-0" key={permission}
aria-label={t('settings.connectors.permission.label', { value={permission}
action: action.name, data-permission={permission}
>
{t(`settings.connectors.permission.${permission}`)}
</ToggleGroupItem>
))}
</ToggleGroup>
</div>
</div>
{foldable && (
<Button
type="button"
variant="link"
size="inline"
className="self-start"
aria-expanded={open}
onClick={() =>
setUnfolded((state) => ({
...state,
[group.access]: !open,
}))
}
>
{open
? t('settings.connectors.permission.fold')
: t('settings.connectors.permission.customize', {
count: group.actions.length,
formatted: formatCount(group.actions.length),
})} })}
</Button>
)}
{open && (
<ul className="flex flex-col gap-3">
{group.actions.map((action) => (
<li
key={action.name}
className="flex items-start justify-between gap-3"
> >
<SelectValue /> <div className="min-w-0">
</SelectTrigger> <p
<SelectContent> className="text-foreground truncate text-sm"
{PERMISSIONS.map((permission) => ( title={action.name}
<SelectItem key={permission} value={permission}> >
{t(`settings.connectors.permission.${permission}`)} {actionTitle(action.name)}
</SelectItem> </p>
))} {action.description && (
</SelectContent> <p
</Select> className="text-muted-foreground line-clamp-2 text-xs"
</li> title={action.description}
))} >
</ul> {action.description}
</div> </p>
))} )}
</div>
<Select
value={action.permission}
disabled={readOnly}
onValueChange={(value) =>
setPermissions({
[action.name]: value as ActionPermission,
})
}
>
<SelectTrigger
size="sm"
className="w-32 shrink-0"
aria-label={t('settings.connectors.permission.label', {
action: actionTitle(action.name),
})}
>
<SelectValue />
</SelectTrigger>
<SelectContent>
{PERMISSIONS.map((permission) => (
<SelectItem key={permission} value={permission}>
{t(`settings.connectors.permission.${permission}`)}
</SelectItem>
))}
</SelectContent>
</Select>
</li>
))}
</ul>
)}
</div>
);
})}
<p className="text-muted-foreground text-xs">
{t('settings.connectors.permission.hint')}
</p>
</Card> </Card>
); );
} }
+6
View File
@@ -17,6 +17,12 @@ export const connectorDescription = (
defaultValue: connector.description, defaultValue: connector.description,
}); });
/** An action's name in words: `get_triage_responsibility` → "Get triage responsibility". */
export const actionTitle = (name: string) => {
const words = name.replace(/[_-]+/g, ' ').trim();
return words.charAt(0).toUpperCase() + words.slice(1);
};
/** The connector's name; only the two custom kinds have translated names. */ /** The connector's name; only the two custom kinds have translated names. */
export const connectorName = ( export const connectorName = (
t: TFunction, t: TFunction,