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(
(l) => l.textContent,
);
expect(labels).toEqual(['Telegram: telegram_send_message']);
expect(labels).toEqual(['Telegram: Telegram send message']);
expect(
container
.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 { selectToken } from '../preferences/preferenceSlice';
import type { Agent, AgentConfig } from './types';
import { actionTitle } from '../connectors/i18n';
type WriteAction = {
entry: string;
@@ -148,7 +149,7 @@ export default function ApiWriteAllowlist({
<Label htmlFor={id} className="font-normal">
{t('modals.agentDetails.apiWrites.item', {
tool: item.tool,
action: item.action,
action: actionTitle(item.action),
interpolation: { escapeValue: false },
})}
</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 { useDispatch, useSelector } from 'react-redux';
import connectorsService from '../api/services/connectorsService';
import { Button } from '../components/ui/button';
import { Card } from '../components/ui/card';
import { SectionHeader } from '../components/ui/section-header';
import {
@@ -12,8 +13,11 @@ import {
SelectTrigger,
SelectValue,
} from '../components/ui/select';
import { ToggleGroup, ToggleGroupItem } from '../components/ui/toggle-group';
import { showActionToast } from '../notifications/actionToastSlice';
import { selectToken } from '../preferences/preferenceSlice';
import { formatCount } from '../utils/dateTimeUtils';
import { actionTitle } from './i18n';
import type {
ActionPermission,
ConnectionTool,
@@ -22,10 +26,14 @@ import type {
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 /
* Needs approval / Off. Changes save immediately; a failed save puts the
* previous choice back and says so.
* One tool's actions in two groups, what it looks up and what it does, each
* set at once to Allow / Ask first / Off; single actions can differ under
* Customize. Changes save immediately; a failed save puts the previous
* choices back and says so.
*/
export default function ToolPermissions({
connectionId,
@@ -45,15 +53,21 @@ export default function ToolPermissions({
const dispatch = useDispatch();
const token = useSelector(selectToken);
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 next = actions.map((action) =>
action.name === name ? { ...action, permission } : action,
setActions(
actions.map((action) =>
changes[action.name]
? { ...action, permission: changes[action.name] }
: action,
),
);
setActions(next);
connectorsService
.setToolPermissions(connectionId, tool.id, { [name]: permission }, token)
.setToolPermissions(connectionId, tool.id, changes, token)
.then((data) => {
if (!data?.success) throw new Error('save failed');
onChange?.(data.tool);
@@ -78,54 +92,147 @@ export default function ToolPermissions({
return (
<Card variant={variant} padding="sm" className="gap-4">
{groups.map((group) => (
<div key={group.access} className="flex flex-col gap-2">
<SectionHeader
as="h4"
size="sm"
title={t(`settings.connectors.capability.${group.access}`)}
/>
<ul className="flex flex-col gap-2">
{group.actions.map((action) => (
<li
key={action.name}
className="flex items-center justify-between gap-3"
>
<span
className="text-foreground min-w-0 truncate font-mono text-xs"
title={action.description || action.name}
>
{action.name}
</span>
<Select
value={action.permission}
{groups.map((group) => {
const title = t(`settings.connectors.capabilityPlain.${group.access}`);
const first = group.actions[0].permission;
// One choice for the group when its actions agree; none when mixed.
const common = group.actions.every(
(action) => action.permission === first,
)
? first
: '';
const foldable = group.actions.length > FOLD_AFTER;
const open = !foldable || unfolded[group.access];
return (
<div
key={group.access}
data-access={group.access}
className="flex flex-col gap-2"
>
<div className="flex flex-wrap items-center justify-between gap-2">
<SectionHeader
as="h4"
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}
aria-label={t('settings.connectors.permission.groupLabel', {
group: title,
})}
onValueChange={(value) =>
setPermission(action.name, value as ActionPermission)
value &&
setPermissions(
Object.fromEntries(
group.actions.map((action) => [
action.name,
value as ActionPermission,
]),
),
)
}
>
<SelectTrigger
size="sm"
className="w-40 shrink-0"
aria-label={t('settings.connectors.permission.label', {
action: action.name,
{PERMISSIONS.map((permission) => (
<ToggleGroupItem
key={permission}
value={permission}
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 />
</SelectTrigger>
<SelectContent>
{PERMISSIONS.map((permission) => (
<SelectItem key={permission} value={permission}>
{t(`settings.connectors.permission.${permission}`)}
</SelectItem>
))}
</SelectContent>
</Select>
</li>
))}
</ul>
</div>
))}
<div className="min-w-0">
<p
className="text-foreground truncate text-sm"
title={action.name}
>
{actionTitle(action.name)}
</p>
{action.description && (
<p
className="text-muted-foreground line-clamp-2 text-xs"
title={action.description}
>
{action.description}
</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>
);
}
+6
View File
@@ -17,6 +17,12 @@ export const connectorDescription = (
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. */
export const connectorName = (
t: TFunction,