Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 20 additions & 7 deletions app/src/components/chat/ApprovalRequestCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,16 @@ import { useAppDispatch } from '../../store/hooks';
import Button from '../ui/Button';

/**
* Binary v1 decision surface. The backend `approval.decide` RPC also accepts
* `approve_always_for_tool`, but the locked v1 contract is yes / no — a typed
* `yes`/`no` chat reply is the equivalent server-side path.
* Decision surface for a parked tool call. `approve_once` / `deny` decide the
* current call only; `approve_always_for_tool` additionally persists the tool
* onto the user's `autonomy.auto_approve` ("Always allow") list so the gate
* skips prompting for it on future turns (managed/removable in Settings → Agent
* access). A typed `yes`/`no` chat reply is the equivalent server-side path for
* the once/deny decisions.
*/
const log = debug('openhuman:chat:approval-card');

type BinaryDecision = 'approve_once' | 'deny';
type Decision = 'approve_once' | 'approve_always_for_tool' | 'deny';

interface Props {
threadId: string;
Expand All @@ -31,10 +34,10 @@ interface Props {
export const ApprovalRequestCard: React.FC<Props> = ({ threadId, approval }) => {
const { t } = useT();
const dispatch = useAppDispatch();
const [deciding, setDeciding] = useState<BinaryDecision | null>(null);
const [deciding, setDeciding] = useState<Decision | null>(null);
const [errorMsg, setErrorMsg] = useState<string | null>(null);

const decide = async (decision: BinaryDecision) => {
const decide = async (decision: Decision) => {
if (deciding) return;
setDeciding(decision);
setErrorMsg(null);
Expand Down Expand Up @@ -80,7 +83,7 @@ export const ApprovalRequestCard: React.FC<Props> = ({ threadId, approval }) =>

{errorMsg && <p className="mt-2 text-xs text-coral">⚠ {errorMsg}</p>}

<div className="mt-3 flex items-center gap-2">
<div className="mt-3 flex flex-wrap items-center gap-2">
<Button
variant="primary"
size="sm"
Expand All @@ -90,6 +93,16 @@ export const ApprovalRequestCard: React.FC<Props> = ({ threadId, approval }) =>
? t('chat.approval.deciding')
: t('chat.approval.approve')}
</Button>
<Button
variant="secondary"
size="sm"
onClick={() => void decide('approve_always_for_tool')}
disabled={deciding !== null}
title={t('chat.approval.alwaysAllowHint')}>
{deciding === 'approve_always_for_tool'
? t('chat.approval.deciding')
: t('chat.approval.alwaysAllow')}
</Button>
<Button
variant="secondary"
size="sm"
Expand Down
15 changes: 15 additions & 0 deletions app/src/components/chat/__tests__/ApprovalRequestCard.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,21 @@ describe('ApprovalRequestCard', () => {
});
});

it('Always allow routes approve_always_for_tool to approval_decide and clears the pending state', async () => {
vi.mocked(callCoreRpc).mockResolvedValueOnce({});
const { store } = renderCard();

fireEvent.click(screen.getByText('Always allow'));

expect(callCoreRpc).toHaveBeenCalledWith({
method: 'openhuman.approval_decide',
params: { request_id: 'req-approval-1', decision: 'approve_always_for_tool' },
});
await waitFor(() => {
expect(store.getState().chatRuntime.pendingApprovalByThread[THREAD]).toBeUndefined();
});
});

it('keeps the prompt and shows an error when the decide RPC fails', async () => {
vi.mocked(callCoreRpc).mockRejectedValueOnce(new Error('gate not installed'));
const { store } = renderCard();
Expand Down
45 changes: 45 additions & 0 deletions app/src/components/settings/panels/AgentAccessPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,9 @@ const AgentAccessPanel = () => {
const [level, setLevel] = useState<AutonomyLevel>('supervised');
const [workspaceOnly, setWorkspaceOnly] = useState(false);
const [trustedRoots, setTrustedRoots] = useState<TrustedRoot[]>([]);
// "Always allow" allowlist — populated by the in-chat "Always allow" button;
// shown here read-only with a Remove action (the re-protect path).
const [autoApprove, setAutoApprove] = useState<string[]>([]);

const [newRootPath, setNewRootPath] = useState('');
const [newRootAccess, setNewRootAccess] = useState<TrustedAccess>('read');
Expand All @@ -76,6 +79,7 @@ const AgentAccessPanel = () => {
setLevel(resp.result.level);
setWorkspaceOnly(resp.result.workspace_only);
setTrustedRoots(resp.result.trusted_roots ?? []);
setAutoApprove(resp.result.auto_approve ?? []);
} catch (e) {
if (!cancelled)
setError(e instanceof Error ? e.message : t('settings.agentAccess.loadError'));
Expand All @@ -97,6 +101,11 @@ const AgentAccessPanel = () => {
level: AutonomyLevel;
workspaceOnly: boolean;
trustedRoots: TrustedRoot[];
// Only sent when the allowlist itself is being changed. Omitting it leaves
// the server's `auto_approve` untouched (partial patch) — important so a
// tier/folder change here can't clobber a tool the user just added via the
// in-chat "Always allow" button.
autoApprove?: string[];
}) => {
const seq = ++persistSeqRef.current;
if (!isTauri()) return;
Expand All @@ -109,6 +118,7 @@ const AgentAccessPanel = () => {
workspace_only: next.workspaceOnly,
trusted_roots: next.trustedRoots,
allow_tool_install: ALLOW_TOOL_INSTALL,
...(next.autoApprove !== undefined ? { auto_approve: next.autoApprove } : {}),
});
// Only the most recent persist may write UI state back.
if (persistSeqRef.current === seq) {
Expand Down Expand Up @@ -155,6 +165,12 @@ const AgentAccessPanel = () => {
void persist({ level, workspaceOnly, trustedRoots: nextRoots });
};

const removeAutoApprove = (tool: string) => {
const nextList = autoApprove.filter(name => name !== tool);
setAutoApprove(nextList);
void persist({ level, workspaceOnly, trustedRoots, autoApprove: nextList });
};

return (
<div>
<SettingsHeader
Expand Down Expand Up @@ -290,6 +306,35 @@ const AgentAccessPanel = () => {
</div>
</section>

{/* "Always allow" allowlist — tools the user chose to stop being
prompted for, via the in-chat approval card. Read-only here with
a Remove action to re-enable prompting for a tool. */}
<section className="space-y-2">
<h2 className="text-sm font-semibold text-ink">
{t('settings.agentAccess.alwaysAllow')}
</h2>
<p className="text-xs text-ink-soft">{t('settings.agentAccess.alwaysAllowDesc')}</p>
{autoApprove.length === 0 ? (
<p className="text-xs text-ink-soft">{t('settings.agentAccess.alwaysAllowNone')}</p>
) : (
<ul className="space-y-1">
{autoApprove.map(tool => (
<li
key={tool}
className="flex items-center justify-between rounded border border-line px-2 py-1">
<span className="font-mono text-xs text-ink truncate">{tool}</span>
<button
type="button"
onClick={() => removeAutoApprove(tool)}
className="text-xs text-coral hover:underline">
{t('settings.agentAccess.remove')}
</button>
</li>
))}
</ul>
)}
</section>

{/* Auto-save status — changes persist on selection; no manual save. */}
<div className="min-h-[1.25rem] text-sm" aria-live="polite">
{error ? (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ const autonomy = (overrides: Partial<AutonomySettings> = {}): AutonomySettings =
trusted_roots: [],
allow_tool_install: true,
max_actions_per_hour: 0,
auto_approve: [],
...overrides,
});

Expand Down Expand Up @@ -113,6 +114,31 @@ describe('AgentAccessPanel', () => {
expect((screen.getByRole('checkbox') as HTMLInputElement).checked).toBe(true);
});

it('shows the empty "always-allow" state when no tools are allow-listed', async () => {
renderWithProviders(<AgentAccessPanel />);
expect(await screen.findByText('Always-allowed tools')).toBeInTheDocument();
expect(screen.getByText('No always-allowed tools yet.')).toBeInTheDocument();
});

it('lists always-allowed tools and removing one persists the trimmed list', async () => {
mockGet.mockResolvedValue({ result: autonomy({ auto_approve: ['shell', 'curl'] }), logs: [] });
renderWithProviders(<AgentAccessPanel />);

// The allowlist renders each tool name.
expect(await screen.findByText('shell')).toBeInTheDocument();
expect(screen.getByText('curl')).toBeInTheDocument();

// trusted_roots is empty, so the only Remove buttons belong to the
// allowlist. Removing the first entry persists the trimmed list via
// update_autonomy_settings (auto_approve only — other fields untouched).
fireEvent.click(screen.getAllByText('Remove')[0]);
await waitFor(() =>
expect(mockUpdate).toHaveBeenLastCalledWith(
expect.objectContaining({ auto_approve: ['curl'] })
)
);
});

it('surfaces a load error without crashing', async () => {
mockGet.mockRejectedValue(new Error('boom'));
renderWithProviders(<AgentAccessPanel />);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ const autonomy = (max_actions_per_hour: number): AutonomySettings => ({
trusted_roots: [],
allow_tool_install: false,
max_actions_per_hour,
auto_approve: [],
});

vi.mock('../../hooks/useSettingsNavigation', () => ({
Expand Down
2 changes: 2 additions & 0 deletions app/src/lib/i18n/chunks/ar-3.ts
Original file line number Diff line number Diff line change
Expand Up @@ -428,6 +428,8 @@ const ar3: TranslationMap = {
'channels.yuanbao.savedRestartRequired': 'Channel saved. Restart the app to activate it.',
'channels.yuanbao.unexpectedStatus': 'Unexpected connection status: {status}',
'chat.approval.approve': 'Approve',
'chat.approval.alwaysAllow': 'Always allow',
'chat.approval.alwaysAllowHint': 'Stop asking for this tool — add it to your Always-allow list',
'chat.approval.deciding': 'Working…',
'chat.approval.deny': 'Deny',
'chat.approval.error': 'Could not record your decision — try again.',
Expand Down
4 changes: 4 additions & 0 deletions app/src/lib/i18n/chunks/ar-5.ts
Original file line number Diff line number Diff line change
Expand Up @@ -729,6 +729,10 @@ const ar5: TranslationMap = {
'settings.agentAccess.confine.desc':
'Restrict the agent to the workspace directory (plus any granted folders), whichever access mode is selected. When off, it can reach anywhere your user can — except the always-blocked credential and system directories.',
'settings.agentAccess.grantedFolders': 'Granted folders',
'settings.agentAccess.alwaysAllow': 'Always-allowed tools',
'settings.agentAccess.alwaysAllowDesc':
'Tools you marked "Always allow" in chat run without asking. Remove one to be prompted again.',
'settings.agentAccess.alwaysAllowNone': 'No always-allowed tools yet.',
'settings.agentAccess.grantedDesc':
'Folders the agent may read and write, in addition to the workspace. Credential stores (~/.ssh, ~/.gnupg, ~/.aws, keychains) and system directories (/etc, /System, C:\\Windows, …) are always blocked, even inside a granted folder.',
'settings.agentAccess.noneGranted': 'No folders granted.',
Expand Down
2 changes: 2 additions & 0 deletions app/src/lib/i18n/chunks/bn-3.ts
Original file line number Diff line number Diff line change
Expand Up @@ -432,6 +432,8 @@ const bn3: TranslationMap = {
'channels.yuanbao.savedRestartRequired': 'Channel saved. Restart the app to activate it.',
'channels.yuanbao.unexpectedStatus': 'Unexpected connection status: {status}',
'chat.approval.approve': 'Approve',
'chat.approval.alwaysAllow': 'Always allow',
'chat.approval.alwaysAllowHint': 'Stop asking for this tool — add it to your Always-allow list',
'chat.approval.deciding': 'Working…',
'chat.approval.deny': 'Deny',
'chat.approval.error': 'Could not record your decision — try again.',
Expand Down
4 changes: 4 additions & 0 deletions app/src/lib/i18n/chunks/bn-5.ts
Original file line number Diff line number Diff line change
Expand Up @@ -742,6 +742,10 @@ const bn5: TranslationMap = {
'settings.agentAccess.confine.desc':
'Restrict the agent to the workspace directory (plus any granted folders), whichever access mode is selected. When off, it can reach anywhere your user can — except the always-blocked credential and system directories.',
'settings.agentAccess.grantedFolders': 'Granted folders',
'settings.agentAccess.alwaysAllow': 'Always-allowed tools',
'settings.agentAccess.alwaysAllowDesc':
'Tools you marked "Always allow" in chat run without asking. Remove one to be prompted again.',
'settings.agentAccess.alwaysAllowNone': 'No always-allowed tools yet.',
'settings.agentAccess.grantedDesc':
'Folders the agent may read and write, in addition to the workspace. Credential stores (~/.ssh, ~/.gnupg, ~/.aws, keychains) and system directories (/etc, /System, C:\\Windows, …) are always blocked, even inside a granted folder.',
'settings.agentAccess.noneGranted': 'No folders granted.',
Expand Down
2 changes: 2 additions & 0 deletions app/src/lib/i18n/chunks/de-3.ts
Original file line number Diff line number Diff line change
Expand Up @@ -444,6 +444,8 @@ const de3: TranslationMap = {
'channels.yuanbao.savedRestartRequired': 'Channel saved. Restart the app to activate it.',
'channels.yuanbao.unexpectedStatus': 'Unexpected connection status: {status}',
'chat.approval.approve': 'Approve',
'chat.approval.alwaysAllow': 'Always allow',
'chat.approval.alwaysAllowHint': 'Stop asking for this tool — add it to your Always-allow list',
'chat.approval.deciding': 'Working…',
'chat.approval.deny': 'Deny',
'chat.approval.error': 'Could not record your decision — try again.',
Expand Down
4 changes: 4 additions & 0 deletions app/src/lib/i18n/chunks/de-5.ts
Original file line number Diff line number Diff line change
Expand Up @@ -770,6 +770,10 @@ const de5: TranslationMap = {
'settings.agentAccess.confine.desc':
'Restrict the agent to the workspace directory (plus any granted folders), whichever access mode is selected. When off, it can reach anywhere your user can — except the always-blocked credential and system directories.',
'settings.agentAccess.grantedFolders': 'Granted folders',
'settings.agentAccess.alwaysAllow': 'Always-allowed tools',
'settings.agentAccess.alwaysAllowDesc':
'Tools you marked "Always allow" in chat run without asking. Remove one to be prompted again.',
'settings.agentAccess.alwaysAllowNone': 'No always-allowed tools yet.',
'settings.agentAccess.grantedDesc':
'Folders the agent may read and write, in addition to the workspace. Credential stores (~/.ssh, ~/.gnupg, ~/.aws, keychains) and system directories (/etc, /System, C:\\Windows, …) are always blocked, even inside a granted folder.',
'settings.agentAccess.noneGranted': 'No folders granted.',
Expand Down
2 changes: 2 additions & 0 deletions app/src/lib/i18n/chunks/en-3.ts
Original file line number Diff line number Diff line change
Expand Up @@ -401,6 +401,8 @@ const en3: TranslationMap = {
'channels.telegram.savedRestartRequired': 'Channel saved. Restart the app to activate it.',
'channels.web.alwaysAvailable': 'Always available',
'chat.approval.approve': 'Approve',
'chat.approval.alwaysAllow': 'Always allow',
'chat.approval.alwaysAllowHint': 'Stop asking for this tool — add it to your Always-allow list',
'chat.approval.deciding': 'Working…',
'chat.approval.deny': 'Deny',
'chat.approval.error': 'Could not record your decision — try again.',
Expand Down
4 changes: 4 additions & 0 deletions app/src/lib/i18n/chunks/en-5.ts
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,10 @@ const en5: TranslationMap = {
'settings.agentAccess.confine.desc':
'Restrict the agent to the workspace directory (plus any granted folders), whichever access mode is selected. When off, it can reach anywhere your user can — except the always-blocked credential and system directories.',
'settings.agentAccess.grantedFolders': 'Granted folders',
'settings.agentAccess.alwaysAllow': 'Always-allowed tools',
'settings.agentAccess.alwaysAllowDesc':
'Tools you marked "Always allow" in chat run without asking. Remove one to be prompted again.',
'settings.agentAccess.alwaysAllowNone': 'No always-allowed tools yet.',
'settings.agentAccess.grantedDesc':
'Folders the agent may read and write, in addition to the workspace. Credential stores (~/.ssh, ~/.gnupg, ~/.aws, keychains) and system directories (/etc, /System, C:\\Windows, …) are always blocked, even inside a granted folder.',
'settings.agentAccess.noneGranted': 'No folders granted.',
Expand Down
2 changes: 2 additions & 0 deletions app/src/lib/i18n/chunks/es-3.ts
Original file line number Diff line number Diff line change
Expand Up @@ -438,6 +438,8 @@ const es3: TranslationMap = {
'channels.yuanbao.savedRestartRequired': 'Channel saved. Restart the app to activate it.',
'channels.yuanbao.unexpectedStatus': 'Unexpected connection status: {status}',
'chat.approval.approve': 'Approve',
'chat.approval.alwaysAllow': 'Always allow',
'chat.approval.alwaysAllowHint': 'Stop asking for this tool — add it to your Always-allow list',
'chat.approval.deciding': 'Working…',
'chat.approval.deny': 'Deny',
'chat.approval.error': 'Could not record your decision — try again.',
Expand Down
4 changes: 4 additions & 0 deletions app/src/lib/i18n/chunks/es-5.ts
Original file line number Diff line number Diff line change
Expand Up @@ -756,6 +756,10 @@ const es5: TranslationMap = {
'settings.agentAccess.confine.desc':
'Restrict the agent to the workspace directory (plus any granted folders), whichever access mode is selected. When off, it can reach anywhere your user can — except the always-blocked credential and system directories.',
'settings.agentAccess.grantedFolders': 'Granted folders',
'settings.agentAccess.alwaysAllow': 'Always-allowed tools',
'settings.agentAccess.alwaysAllowDesc':
'Tools you marked "Always allow" in chat run without asking. Remove one to be prompted again.',
'settings.agentAccess.alwaysAllowNone': 'No always-allowed tools yet.',
'settings.agentAccess.grantedDesc':
'Folders the agent may read and write, in addition to the workspace. Credential stores (~/.ssh, ~/.gnupg, ~/.aws, keychains) and system directories (/etc, /System, C:\\Windows, …) are always blocked, even inside a granted folder.',
'settings.agentAccess.noneGranted': 'No folders granted.',
Expand Down
2 changes: 2 additions & 0 deletions app/src/lib/i18n/chunks/fr-3.ts
Original file line number Diff line number Diff line change
Expand Up @@ -441,6 +441,8 @@ const fr3: TranslationMap = {
'channels.yuanbao.savedRestartRequired': 'Channel saved. Restart the app to activate it.',
'channels.yuanbao.unexpectedStatus': 'Unexpected connection status: {status}',
'chat.approval.approve': 'Approve',
'chat.approval.alwaysAllow': 'Always allow',
'chat.approval.alwaysAllowHint': 'Stop asking for this tool — add it to your Always-allow list',
'chat.approval.deciding': 'Working…',
'chat.approval.deny': 'Deny',
'chat.approval.error': 'Could not record your decision — try again.',
Expand Down
4 changes: 4 additions & 0 deletions app/src/lib/i18n/chunks/fr-5.ts
Original file line number Diff line number Diff line change
Expand Up @@ -760,6 +760,10 @@ const fr5: TranslationMap = {
'settings.agentAccess.confine.desc':
'Restrict the agent to the workspace directory (plus any granted folders), whichever access mode is selected. When off, it can reach anywhere your user can — except the always-blocked credential and system directories.',
'settings.agentAccess.grantedFolders': 'Granted folders',
'settings.agentAccess.alwaysAllow': 'Always-allowed tools',
'settings.agentAccess.alwaysAllowDesc':
'Tools you marked "Always allow" in chat run without asking. Remove one to be prompted again.',
'settings.agentAccess.alwaysAllowNone': 'No always-allowed tools yet.',
'settings.agentAccess.grantedDesc':
'Folders the agent may read and write, in addition to the workspace. Credential stores (~/.ssh, ~/.gnupg, ~/.aws, keychains) and system directories (/etc, /System, C:\\Windows, …) are always blocked, even inside a granted folder.',
'settings.agentAccess.noneGranted': 'No folders granted.',
Expand Down
2 changes: 2 additions & 0 deletions app/src/lib/i18n/chunks/hi-3.ts
Original file line number Diff line number Diff line change
Expand Up @@ -436,6 +436,8 @@ const hi3: TranslationMap = {
'channels.yuanbao.savedRestartRequired': 'Channel saved. Restart the app to activate it.',
'channels.yuanbao.unexpectedStatus': 'Unexpected connection status: {status}',
'chat.approval.approve': 'Approve',
'chat.approval.alwaysAllow': 'Always allow',
'chat.approval.alwaysAllowHint': 'Stop asking for this tool — add it to your Always-allow list',
'chat.approval.deciding': 'Working…',
'chat.approval.deny': 'Deny',
'chat.approval.error': 'Could not record your decision — try again.',
Expand Down
Loading
Loading