feat(settings): surface approval history audit trail (#2949)

Co-authored-by: Steven Enamakel <enamakel@tinyhumans.ai>
This commit is contained in:
obchain
2026-05-30 08:35:33 -07:00
committed by GitHub
co-authored by Steven Enamakel
parent c70ef5c3ef
commit 46b6c1f95c
23 changed files with 725 additions and 1 deletions
@@ -38,6 +38,7 @@ export type SettingsRoute =
| 'mascot'
| 'persona'
| 'appearance'
| 'approval-history'
| 'intelligence'
| 'webhooks-triggers'
| 'composio-triggers'
@@ -124,6 +125,9 @@ export const useSettingsNavigation = (): SettingsNavigationHook => {
if (path.includes('/settings/mascot')) return 'mascot';
if (path.includes('/settings/persona')) return 'persona';
if (path.includes('/settings/appearance')) return 'appearance';
// `approval-history` is an explicit leaf route under Agent access; it has a
// distinct prefix from `agent-access`, so ordering between them is cosmetic.
if (path.includes('/settings/approval-history')) return 'approval-history';
// `agents-settings` (the Agents section page) must be checked before the
// shorter `agents` (the manage-agents registry panel) so it isn't swallowed.
if (path.includes('/settings/agents-settings')) return 'agents-settings';
@@ -187,6 +191,11 @@ export const useSettingsNavigation = (): SettingsNavigationHook => {
onClick: () => navigate('/settings/developer-options'),
};
const agentAccessCrumb: BreadcrumbItem = {
label: 'Agent access',
onClick: () => navigate('/settings/agent-access'),
};
const agentsCrumb: BreadcrumbItem = {
label: 'Agents',
onClick: () => navigate('/settings/agents-settings'),
@@ -273,6 +282,11 @@ export const useSettingsNavigation = (): SettingsNavigationHook => {
case 'appearance':
return [settingsCrumb];
// Approval history is a leaf under Agent access, which itself lives under
// the Agents section — so the trail is Settings → Agents → Agent access.
case 'approval-history':
return [settingsCrumb, agentsCrumb, agentAccessCrumb];
case 'home':
default:
return [];
@@ -26,7 +26,7 @@ interface PresetOption {
const AgentAccessPanel = () => {
const { t } = useT();
const { navigateBack, breadcrumbs } = useSettingsNavigation();
const { navigateBack, navigateToSettings, breadcrumbs } = useSettingsNavigation();
// Tier presets — built inside the component so titles/descriptions resolve
// through `t()` (i18n). Order matters: it's the display order.
@@ -391,6 +391,24 @@ const AgentAccessPanel = () => {
)}
</section>
{/* Approval history — read-only audit trail of past decisions,
backed by the gate's durable decided-rows store. */}
<section className="space-y-2">
<h2 className="text-sm font-semibold text-ink">
{t('settings.agentAccess.approvalHistory')}
</h2>
<p className="text-xs text-ink-soft">
{t('settings.agentAccess.approvalHistoryDesc')}
</p>
<button
type="button"
onClick={() => navigateToSettings('approval-history')}
data-testid="agent-access-approval-history-link"
className="rounded border border-line px-3 py-1 text-xs text-ink hover:border-primary-300">
{t('settings.agentAccess.viewApprovalHistory')}
</button>
</section>
{/* Auto-save status — changes persist on selection; no manual save. */}
<div className="min-h-[1.25rem] text-sm" aria-live="polite">
{error ? (
@@ -0,0 +1,155 @@
import debug from 'debug';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useT } from '../../../lib/i18n/I18nContext';
import {
type ApprovalAuditEntry,
type ApprovalDecision,
fetchRecentApprovalDecisions,
} from '../../../services/api/approvalApi';
import SettingsHeader from '../components/SettingsHeader';
import { useSettingsNavigation } from '../hooks/useSettingsNavigation';
const log = debug('ui:approval-history');
/** Render a decided timestamp as a locale string; fall back to the raw value. */
const formatDateTime = (value: string): string => {
const ts = Date.parse(value);
return Number.isNaN(ts) ? value : new Date(ts).toLocaleString();
};
/** Tailwind tone + i18n label key per decision variant. */
const DECISION_TONE: Record<ApprovalDecision, string> = {
approve_once: 'bg-sage-50 text-sage ring-sage-200',
approve_always_for_tool: 'bg-sage-50 text-sage ring-sage-200',
deny: 'bg-coral-50 text-coral ring-coral-200',
};
const DECISION_LABEL_KEY: Record<ApprovalDecision, string> = {
approve_once: 'settings.approvalHistory.decision.approveOnce',
approve_always_for_tool: 'settings.approvalHistory.decision.approveAlways',
deny: 'settings.approvalHistory.decision.deny',
};
const ApprovalHistoryPanel = () => {
const { t } = useT();
const { navigateBack, breadcrumbs } = useSettingsNavigation();
const [entries, setEntries] = useState<ApprovalAuditEntry[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// Monotonic guard so an out-of-order (slower) response can't clobber a
// fresher one when the user taps Refresh rapidly (last request wins).
const loadSeqRef = useRef(0);
// Runs the fetch and only ever calls setState AFTER the await, so it is safe
// to invoke straight from the mount effect without tripping
// react-hooks/set-state-in-effect. The synchronous spinner reset lives in the
// Refresh event handler below, where synchronous setState is expected.
const runLoad = useCallback(
async (seq: number) => {
log('load start %o', { seq });
try {
const rows = await fetchRecentApprovalDecisions();
if (seq !== loadSeqRef.current) {
log('stale response discarded %o', { seq, latest: loadSeqRef.current });
return;
}
setEntries(rows);
setError(null);
log('load ok %o', { seq, count: rows.length });
} catch (e) {
if (seq !== loadSeqRef.current) return;
// Never leak raw backend error text into the UI; localized fallback only.
log('load failed %o', e);
setError(t('settings.approvalHistory.errorGeneric'));
} finally {
if (seq === loadSeqRef.current) setIsLoading(false);
}
},
[t]
);
useEffect(() => {
void runLoad(++loadSeqRef.current);
}, [runLoad]);
const handleRefresh = () => {
setIsLoading(true);
setError(null);
void runLoad(++loadSeqRef.current);
};
return (
<div>
<SettingsHeader
title={t('settings.approvalHistory.title')}
showBackButton
onBack={navigateBack}
breadcrumbs={breadcrumbs}
/>
<div className="p-4 space-y-4" data-testid="approval-history-panel">
<div className="flex items-center justify-between">
<p className="text-xs text-ink-soft">{t('settings.approvalHistory.subtitle')}</p>
<button
type="button"
onClick={handleRefresh}
disabled={isLoading}
data-testid="approval-history-refresh"
className="rounded bg-primary-500 px-3 py-1 text-xs text-white hover:bg-primary-600 disabled:opacity-50">
{t('settings.approvalHistory.refresh')}
</button>
</div>
{isLoading ? (
<p className="text-sm text-ink-soft" data-testid="approval-history-loading">
{t('settings.approvalHistory.loading')}
</p>
) : error ? (
<div className="space-y-2" data-testid="approval-history-error">
<p className="text-sm text-coral">{error}</p>
<button
type="button"
onClick={handleRefresh}
className="text-xs text-primary-600 hover:underline">
{t('settings.approvalHistory.retry')}
</button>
</div>
) : entries.length === 0 ? (
<p className="text-sm text-ink-soft" data-testid="approval-history-empty">
{t('settings.approvalHistory.emptyState')}
</p>
) : (
<ul className="space-y-2" data-testid="approval-history-list">
{entries.map(entry => (
<li
key={entry.request_id}
className="rounded-lg border border-line p-3 space-y-1"
data-testid="approval-history-row">
<div className="flex items-center justify-between gap-2">
<span className="font-mono text-xs text-ink truncate">{entry.tool_name}</span>
<span
className={`inline-flex shrink-0 items-center rounded-full px-2 py-0.5 text-xs font-medium ring-1 ${DECISION_TONE[entry.decision]}`}
data-testid={`approval-history-decision-${entry.decision}`}>
{t(DECISION_LABEL_KEY[entry.decision])}
</span>
</div>
<p className="text-xs text-ink-soft">{entry.action_summary}</p>
<p className="text-[11px] text-ink-soft">
{t('settings.approvalHistory.decidedAt').replace(
'{date}',
formatDateTime(entry.decided_at)
)}
</p>
</li>
))}
</ul>
)}
</div>
</div>
);
};
export default ApprovalHistoryPanel;
@@ -0,0 +1,134 @@
import { fireEvent, screen, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import {
type ApprovalAuditEntry,
fetchRecentApprovalDecisions,
} from '../../../../services/api/approvalApi';
import { renderWithProviders } from '../../../../test/test-utils';
import ApprovalHistoryPanel from '../ApprovalHistoryPanel';
vi.mock('../../hooks/useSettingsNavigation', () => ({
useSettingsNavigation: () => ({
navigateBack: vi.fn(),
navigateToSettings: vi.fn(),
breadcrumbs: [],
}),
}));
vi.mock('../../../../services/api/approvalApi', () => ({ fetchRecentApprovalDecisions: vi.fn() }));
const mockFetch = vi.mocked(fetchRecentApprovalDecisions);
const auditRow = (overrides: Partial<ApprovalAuditEntry> = {}): ApprovalAuditEntry => ({
request_id: 'req-1',
tool_name: 'shell',
action_summary: 'run ls -la',
args_redacted: {},
session_id: 'sess-1',
created_at: '2026-05-29T10:00:00Z',
expires_at: null,
decided_at: '2026-05-29T10:00:05Z',
decision: 'approve_once',
...overrides,
});
describe('ApprovalHistoryPanel', () => {
beforeEach(() => {
mockFetch.mockReset();
});
it('renders the loaded list of decided approvals', async () => {
mockFetch.mockResolvedValueOnce([
auditRow({ request_id: 'a', tool_name: 'shell', decision: 'approve_once' }),
auditRow({ request_id: 'b', tool_name: 'curl', decision: 'deny' }),
]);
renderWithProviders(<ApprovalHistoryPanel />, {
initialEntries: ['/settings/approval-history'],
});
await screen.findByTestId('approval-history-list');
const rows = screen.getAllByTestId('approval-history-row');
expect(rows).toHaveLength(2);
expect(screen.getByText('shell')).toBeInTheDocument();
expect(screen.getByText('curl')).toBeInTheDocument();
});
it('renders a decision badge per row', async () => {
mockFetch.mockResolvedValueOnce([
auditRow({ request_id: 'a', decision: 'approve_always_for_tool' }),
auditRow({ request_id: 'b', decision: 'deny' }),
]);
renderWithProviders(<ApprovalHistoryPanel />, {
initialEntries: ['/settings/approval-history'],
});
await screen.findByTestId('approval-history-list');
expect(
screen.getByTestId('approval-history-decision-approve_always_for_tool')
).toBeInTheDocument();
expect(screen.getByTestId('approval-history-decision-deny')).toBeInTheDocument();
});
it('renders the empty state when there are no decisions', async () => {
mockFetch.mockResolvedValueOnce([]);
renderWithProviders(<ApprovalHistoryPanel />, {
initialEntries: ['/settings/approval-history'],
});
await screen.findByTestId('approval-history-empty');
expect(screen.queryByTestId('approval-history-list')).not.toBeInTheDocument();
});
it('renders a localized error state when the fetch rejects', async () => {
mockFetch.mockRejectedValueOnce(new Error('boom'));
renderWithProviders(<ApprovalHistoryPanel />, {
initialEntries: ['/settings/approval-history'],
});
const err = await screen.findByTestId('approval-history-error');
// Raw backend text must never leak into the UI.
expect(err.textContent).not.toContain('boom');
});
it('refetches when the Refresh button is clicked', async () => {
mockFetch.mockResolvedValue([auditRow()]);
renderWithProviders(<ApprovalHistoryPanel />, {
initialEntries: ['/settings/approval-history'],
});
await screen.findByTestId('approval-history-list');
expect(mockFetch).toHaveBeenCalledTimes(1);
fireEvent.click(screen.getByTestId('approval-history-refresh'));
await waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(2));
});
it('replaces the list with the refreshed result', async () => {
// The reachable refresh behavior: a completed load is replaced by the rows
// from a subsequent refresh. (The `loadSeqRef` last-request-wins guard
// protects against *overlapping* in-flight loads, but that race is not
// reachable from the UI — the Refresh button is `disabled` while a load is
// pending, so two concurrent fetches can never be initiated. The guard
// stays as defense against React concurrent/StrictMode double-invocation.)
mockFetch
.mockResolvedValueOnce([auditRow({ request_id: 'old', tool_name: 'old-tool' })])
.mockResolvedValueOnce([auditRow({ request_id: 'new', tool_name: 'new-tool' })]);
renderWithProviders(<ApprovalHistoryPanel />, {
initialEntries: ['/settings/approval-history'],
});
await screen.findByText('old-tool');
fireEvent.click(screen.getByTestId('approval-history-refresh'));
await screen.findByText('new-tool');
expect(screen.queryByText('old-tool')).not.toBeInTheDocument();
});
});
+15
View File
@@ -3459,6 +3459,21 @@ const messages: TranslationMap = {
'settings.agentAccess.add': 'مضاف',
'settings.agentAccess.saving': 'إنقاذ...',
'settings.agentAccess.changesApply': 'التغييرات تنطبق على رسالتك القادمة',
'settings.agentAccess.approvalHistory': 'Approval history',
'settings.agentAccess.approvalHistoryDesc':
'Review past Approve / Deny decisions the agent requested.',
'settings.agentAccess.viewApprovalHistory': 'View approval history',
'settings.approvalHistory.title': 'Approval history',
'settings.approvalHistory.subtitle': 'Recent tool-approval decisions, newest first.',
'settings.approvalHistory.refresh': 'Refresh',
'settings.approvalHistory.loading': 'Loading approval history…',
'settings.approvalHistory.retry': 'Retry',
'settings.approvalHistory.emptyState': 'No approval decisions recorded yet.',
'settings.approvalHistory.errorGeneric': 'Unable to load approval history. Try again.',
'settings.approvalHistory.decidedAt': 'Decided {date}',
'settings.approvalHistory.decision.approveOnce': 'Approved once',
'settings.approvalHistory.decision.approveAlways': 'Always allowed',
'settings.approvalHistory.decision.deny': 'Denied',
'settings.appearance.title': 'المظهر',
'settings.appearance.themeHeading': 'الموضوع',
'settings.appearance.themeAria': 'الموضوع',
+15
View File
@@ -3520,6 +3520,21 @@ const messages: TranslationMap = {
'settings.agentAccess.add': 'যোগ করুন',
'settings.agentAccess.saving': 'ইনস্টল করা হয়েছে...',
'settings.agentAccess.changesApply': 'পরবর্তী বার্তায় পরিবর্তন প্রয়োগ করা হবে।',
'settings.agentAccess.approvalHistory': 'Approval history',
'settings.agentAccess.approvalHistoryDesc':
'Review past Approve / Deny decisions the agent requested.',
'settings.agentAccess.viewApprovalHistory': 'View approval history',
'settings.approvalHistory.title': 'Approval history',
'settings.approvalHistory.subtitle': 'Recent tool-approval decisions, newest first.',
'settings.approvalHistory.refresh': 'Refresh',
'settings.approvalHistory.loading': 'Loading approval history…',
'settings.approvalHistory.retry': 'Retry',
'settings.approvalHistory.emptyState': 'No approval decisions recorded yet.',
'settings.approvalHistory.errorGeneric': 'Unable to load approval history. Try again.',
'settings.approvalHistory.decidedAt': 'Decided {date}',
'settings.approvalHistory.decision.approveOnce': 'Approved once',
'settings.approvalHistory.decision.approveAlways': 'Always allowed',
'settings.approvalHistory.decision.deny': 'Denied',
'settings.appearance.title': 'উপস্থিতি',
'settings.appearance.themeHeading': 'থিম',
'settings.appearance.themeAria': 'থিম',
+15
View File
@@ -3612,6 +3612,21 @@ const messages: TranslationMap = {
'settings.agentAccess.add': 'Hinzufügen',
'settings.agentAccess.saving': 'Sichern…',
'settings.agentAccess.changesApply': 'Änderungen gelten für deine nächste Nachricht.',
'settings.agentAccess.approvalHistory': 'Approval history',
'settings.agentAccess.approvalHistoryDesc':
'Review past Approve / Deny decisions the agent requested.',
'settings.agentAccess.viewApprovalHistory': 'View approval history',
'settings.approvalHistory.title': 'Approval history',
'settings.approvalHistory.subtitle': 'Recent tool-approval decisions, newest first.',
'settings.approvalHistory.refresh': 'Refresh',
'settings.approvalHistory.loading': 'Loading approval history…',
'settings.approvalHistory.retry': 'Retry',
'settings.approvalHistory.emptyState': 'No approval decisions recorded yet.',
'settings.approvalHistory.errorGeneric': 'Unable to load approval history. Try again.',
'settings.approvalHistory.decidedAt': 'Decided {date}',
'settings.approvalHistory.decision.approveOnce': 'Approved once',
'settings.approvalHistory.decision.approveAlways': 'Always allowed',
'settings.approvalHistory.decision.deny': 'Denied',
'settings.appearance.title': 'Aussehen',
'settings.appearance.themeHeading': 'Thema',
'settings.appearance.themeAria': 'Thema',
+15
View File
@@ -3730,6 +3730,21 @@ const en: TranslationMap = {
'settings.agentAccess.add': 'Add',
'settings.agentAccess.saving': 'Saving…',
'settings.agentAccess.changesApply': 'Changes apply on your next message.',
'settings.agentAccess.approvalHistory': 'Approval history',
'settings.agentAccess.approvalHistoryDesc':
'Review past Approve / Deny decisions the agent requested.',
'settings.agentAccess.viewApprovalHistory': 'View approval history',
'settings.approvalHistory.title': 'Approval history',
'settings.approvalHistory.subtitle': 'Recent tool-approval decisions, newest first.',
'settings.approvalHistory.refresh': 'Refresh',
'settings.approvalHistory.loading': 'Loading approval history…',
'settings.approvalHistory.retry': 'Retry',
'settings.approvalHistory.emptyState': 'No approval decisions recorded yet.',
'settings.approvalHistory.errorGeneric': 'Unable to load approval history. Try again.',
'settings.approvalHistory.decidedAt': 'Decided {date}',
'settings.approvalHistory.decision.approveOnce': 'Approved once',
'settings.approvalHistory.decision.approveAlways': 'Always allowed',
'settings.approvalHistory.decision.deny': 'Denied',
'settings.appearance.title': 'Appearance',
'settings.appearance.themeHeading': 'Theme',
'settings.appearance.themeAria': 'Theme',
+15
View File
@@ -3583,6 +3583,21 @@ const messages: TranslationMap = {
'settings.agentAccess.add': 'Añadir',
'settings.agentAccess.saving': 'Guardando…',
'settings.agentAccess.changesApply': 'Los cambios se aplican en tu próximo mensaje.',
'settings.agentAccess.approvalHistory': 'Approval history',
'settings.agentAccess.approvalHistoryDesc':
'Review past Approve / Deny decisions the agent requested.',
'settings.agentAccess.viewApprovalHistory': 'View approval history',
'settings.approvalHistory.title': 'Approval history',
'settings.approvalHistory.subtitle': 'Recent tool-approval decisions, newest first.',
'settings.approvalHistory.refresh': 'Refresh',
'settings.approvalHistory.loading': 'Loading approval history…',
'settings.approvalHistory.retry': 'Retry',
'settings.approvalHistory.emptyState': 'No approval decisions recorded yet.',
'settings.approvalHistory.errorGeneric': 'Unable to load approval history. Try again.',
'settings.approvalHistory.decidedAt': 'Decided {date}',
'settings.approvalHistory.decision.approveOnce': 'Approved once',
'settings.approvalHistory.decision.approveAlways': 'Always allowed',
'settings.approvalHistory.decision.deny': 'Denied',
'settings.appearance.title': 'Apariencia',
'settings.appearance.themeHeading': 'Tema',
'settings.appearance.themeAria': 'Tema',
+15
View File
@@ -3597,6 +3597,21 @@ const messages: TranslationMap = {
'settings.agentAccess.add': 'Ajouter',
'settings.agentAccess.saving': 'Enregistrement…',
'settings.agentAccess.changesApply': "Les modifications s'appliquent à votre prochain message.",
'settings.agentAccess.approvalHistory': 'Approval history',
'settings.agentAccess.approvalHistoryDesc':
'Review past Approve / Deny decisions the agent requested.',
'settings.agentAccess.viewApprovalHistory': 'View approval history',
'settings.approvalHistory.title': 'Approval history',
'settings.approvalHistory.subtitle': 'Recent tool-approval decisions, newest first.',
'settings.approvalHistory.refresh': 'Refresh',
'settings.approvalHistory.loading': 'Loading approval history…',
'settings.approvalHistory.retry': 'Retry',
'settings.approvalHistory.emptyState': 'No approval decisions recorded yet.',
'settings.approvalHistory.errorGeneric': 'Unable to load approval history. Try again.',
'settings.approvalHistory.decidedAt': 'Decided {date}',
'settings.approvalHistory.decision.approveOnce': 'Approved once',
'settings.approvalHistory.decision.approveAlways': 'Always allowed',
'settings.approvalHistory.decision.deny': 'Denied',
'settings.appearance.title': 'Apparence',
'settings.appearance.themeHeading': 'Thème',
'settings.appearance.themeAria': 'Thème',
+15
View File
@@ -3527,6 +3527,21 @@ const messages: TranslationMap = {
'settings.agentAccess.add': 'जोड़ें',
'settings.agentAccess.saving': 'बचत',
'settings.agentAccess.changesApply': 'परिवर्तन आपके अगले संदेश पर लागू होते हैं।',
'settings.agentAccess.approvalHistory': 'Approval history',
'settings.agentAccess.approvalHistoryDesc':
'Review past Approve / Deny decisions the agent requested.',
'settings.agentAccess.viewApprovalHistory': 'View approval history',
'settings.approvalHistory.title': 'Approval history',
'settings.approvalHistory.subtitle': 'Recent tool-approval decisions, newest first.',
'settings.approvalHistory.refresh': 'Refresh',
'settings.approvalHistory.loading': 'Loading approval history…',
'settings.approvalHistory.retry': 'Retry',
'settings.approvalHistory.emptyState': 'No approval decisions recorded yet.',
'settings.approvalHistory.errorGeneric': 'Unable to load approval history. Try again.',
'settings.approvalHistory.decidedAt': 'Decided {date}',
'settings.approvalHistory.decision.approveOnce': 'Approved once',
'settings.approvalHistory.decision.approveAlways': 'Always allowed',
'settings.approvalHistory.decision.deny': 'Denied',
'settings.appearance.title': 'दिखावट',
'settings.appearance.themeHeading': 'थीम',
'settings.appearance.themeAria': 'थीम',
+15
View File
@@ -3536,6 +3536,21 @@ const messages: TranslationMap = {
'settings.agentAccess.add': 'Tambah',
'settings.agentAccess.saving': 'Menyimpan...',
'settings.agentAccess.changesApply': 'Perubahan pada pesan berikutnya.',
'settings.agentAccess.approvalHistory': 'Approval history',
'settings.agentAccess.approvalHistoryDesc':
'Review past Approve / Deny decisions the agent requested.',
'settings.agentAccess.viewApprovalHistory': 'View approval history',
'settings.approvalHistory.title': 'Approval history',
'settings.approvalHistory.subtitle': 'Recent tool-approval decisions, newest first.',
'settings.approvalHistory.refresh': 'Refresh',
'settings.approvalHistory.loading': 'Loading approval history…',
'settings.approvalHistory.retry': 'Retry',
'settings.approvalHistory.emptyState': 'No approval decisions recorded yet.',
'settings.approvalHistory.errorGeneric': 'Unable to load approval history. Try again.',
'settings.approvalHistory.decidedAt': 'Decided {date}',
'settings.approvalHistory.decision.approveOnce': 'Approved once',
'settings.approvalHistory.decision.approveAlways': 'Always allowed',
'settings.approvalHistory.decision.deny': 'Denied',
'settings.appearance.title': 'Tampilan',
'settings.appearance.themeHeading': 'Tema',
'settings.appearance.themeAria': 'Tema',
+15
View File
@@ -3578,6 +3578,21 @@ const messages: TranslationMap = {
'settings.agentAccess.add': 'Aggiungi',
'settings.agentAccess.saving': 'Salvataggio…',
'settings.agentAccess.changesApply': 'Le modifiche verranno applicate al tuo prossimo messaggio.',
'settings.agentAccess.approvalHistory': 'Approval history',
'settings.agentAccess.approvalHistoryDesc':
'Review past Approve / Deny decisions the agent requested.',
'settings.agentAccess.viewApprovalHistory': 'View approval history',
'settings.approvalHistory.title': 'Approval history',
'settings.approvalHistory.subtitle': 'Recent tool-approval decisions, newest first.',
'settings.approvalHistory.refresh': 'Refresh',
'settings.approvalHistory.loading': 'Loading approval history…',
'settings.approvalHistory.retry': 'Retry',
'settings.approvalHistory.emptyState': 'No approval decisions recorded yet.',
'settings.approvalHistory.errorGeneric': 'Unable to load approval history. Try again.',
'settings.approvalHistory.decidedAt': 'Decided {date}',
'settings.approvalHistory.decision.approveOnce': 'Approved once',
'settings.approvalHistory.decision.approveAlways': 'Always allowed',
'settings.approvalHistory.decision.deny': 'Denied',
'settings.appearance.title': 'Aspetto',
'settings.appearance.themeHeading': 'Tema',
'settings.appearance.themeAria': 'Tema',
+15
View File
@@ -3491,6 +3491,21 @@ const messages: TranslationMap = {
'settings.agentAccess.add': '추가',
'settings.agentAccess.saving': '저장 중…',
'settings.agentAccess.changesApply': '변경 사항은 다음 메시지부터 적용됩니다.',
'settings.agentAccess.approvalHistory': 'Approval history',
'settings.agentAccess.approvalHistoryDesc':
'Review past Approve / Deny decisions the agent requested.',
'settings.agentAccess.viewApprovalHistory': 'View approval history',
'settings.approvalHistory.title': 'Approval history',
'settings.approvalHistory.subtitle': 'Recent tool-approval decisions, newest first.',
'settings.approvalHistory.refresh': 'Refresh',
'settings.approvalHistory.loading': 'Loading approval history…',
'settings.approvalHistory.retry': 'Retry',
'settings.approvalHistory.emptyState': 'No approval decisions recorded yet.',
'settings.approvalHistory.errorGeneric': 'Unable to load approval history. Try again.',
'settings.approvalHistory.decidedAt': 'Decided {date}',
'settings.approvalHistory.decision.approveOnce': 'Approved once',
'settings.approvalHistory.decision.approveAlways': 'Always allowed',
'settings.approvalHistory.decision.deny': 'Denied',
'settings.appearance.title': '외관',
'settings.appearance.themeHeading': '테마',
'settings.appearance.themeAria': '테마',
+15
View File
@@ -3582,6 +3582,21 @@ const messages: TranslationMap = {
'settings.agentAccess.add': 'Dodaj',
'settings.agentAccess.saving': 'Zapisywanie…',
'settings.agentAccess.changesApply': 'Zmiany zostaną zastosowane w następnej wiadomości.',
'settings.agentAccess.approvalHistory': 'Approval history',
'settings.agentAccess.approvalHistoryDesc':
'Review past Approve / Deny decisions the agent requested.',
'settings.agentAccess.viewApprovalHistory': 'View approval history',
'settings.approvalHistory.title': 'Approval history',
'settings.approvalHistory.subtitle': 'Recent tool-approval decisions, newest first.',
'settings.approvalHistory.refresh': 'Refresh',
'settings.approvalHistory.loading': 'Loading approval history…',
'settings.approvalHistory.retry': 'Retry',
'settings.approvalHistory.emptyState': 'No approval decisions recorded yet.',
'settings.approvalHistory.errorGeneric': 'Unable to load approval history. Try again.',
'settings.approvalHistory.decidedAt': 'Decided {date}',
'settings.approvalHistory.decision.approveOnce': 'Approved once',
'settings.approvalHistory.decision.approveAlways': 'Always allowed',
'settings.approvalHistory.decision.deny': 'Denied',
'settings.appearance.title': 'Wygląd',
'settings.appearance.themeHeading': 'Motyw',
'settings.appearance.themeAria': 'Motyw',
+15
View File
@@ -3580,6 +3580,21 @@ const messages: TranslationMap = {
'settings.agentAccess.add': 'Adicionar',
'settings.agentAccess.saving': 'Salvando…',
'settings.agentAccess.changesApply': 'As alterações serão aplicadas na sua próxima mensagem.',
'settings.agentAccess.approvalHistory': 'Approval history',
'settings.agentAccess.approvalHistoryDesc':
'Review past Approve / Deny decisions the agent requested.',
'settings.agentAccess.viewApprovalHistory': 'View approval history',
'settings.approvalHistory.title': 'Approval history',
'settings.approvalHistory.subtitle': 'Recent tool-approval decisions, newest first.',
'settings.approvalHistory.refresh': 'Refresh',
'settings.approvalHistory.loading': 'Loading approval history…',
'settings.approvalHistory.retry': 'Retry',
'settings.approvalHistory.emptyState': 'No approval decisions recorded yet.',
'settings.approvalHistory.errorGeneric': 'Unable to load approval history. Try again.',
'settings.approvalHistory.decidedAt': 'Decided {date}',
'settings.approvalHistory.decision.approveOnce': 'Approved once',
'settings.approvalHistory.decision.approveAlways': 'Always allowed',
'settings.approvalHistory.decision.deny': 'Denied',
'settings.appearance.title': 'Aparência',
'settings.appearance.themeHeading': 'Tema',
'settings.appearance.themeAria': 'Tema',
+15
View File
@@ -3549,6 +3549,21 @@ const messages: TranslationMap = {
'settings.agentAccess.add': 'Добавлять',
'settings.agentAccess.saving': 'Сохранение…',
'settings.agentAccess.changesApply': 'Изменения вступят в силу в следующем сообщении.',
'settings.agentAccess.approvalHistory': 'Approval history',
'settings.agentAccess.approvalHistoryDesc':
'Review past Approve / Deny decisions the agent requested.',
'settings.agentAccess.viewApprovalHistory': 'View approval history',
'settings.approvalHistory.title': 'Approval history',
'settings.approvalHistory.subtitle': 'Recent tool-approval decisions, newest first.',
'settings.approvalHistory.refresh': 'Refresh',
'settings.approvalHistory.loading': 'Loading approval history…',
'settings.approvalHistory.retry': 'Retry',
'settings.approvalHistory.emptyState': 'No approval decisions recorded yet.',
'settings.approvalHistory.errorGeneric': 'Unable to load approval history. Try again.',
'settings.approvalHistory.decidedAt': 'Decided {date}',
'settings.approvalHistory.decision.approveOnce': 'Approved once',
'settings.approvalHistory.decision.approveAlways': 'Always allowed',
'settings.approvalHistory.decision.deny': 'Denied',
'settings.appearance.title': 'Внешний вид',
'settings.appearance.themeHeading': 'Тема',
'settings.appearance.themeAria': 'Тема',
+15
View File
@@ -3357,6 +3357,21 @@ const messages: TranslationMap = {
'settings.agentAccess.add': '添加',
'settings.agentAccess.saving': '保存中…',
'settings.agentAccess.changesApply': '更改将在你的下一条消息后生效。',
'settings.agentAccess.approvalHistory': 'Approval history',
'settings.agentAccess.approvalHistoryDesc':
'Review past Approve / Deny decisions the agent requested.',
'settings.agentAccess.viewApprovalHistory': 'View approval history',
'settings.approvalHistory.title': 'Approval history',
'settings.approvalHistory.subtitle': 'Recent tool-approval decisions, newest first.',
'settings.approvalHistory.refresh': 'Refresh',
'settings.approvalHistory.loading': 'Loading approval history…',
'settings.approvalHistory.retry': 'Retry',
'settings.approvalHistory.emptyState': 'No approval decisions recorded yet.',
'settings.approvalHistory.errorGeneric': 'Unable to load approval history. Try again.',
'settings.approvalHistory.decidedAt': 'Decided {date}',
'settings.approvalHistory.decision.approveOnce': 'Approved once',
'settings.approvalHistory.decision.approveAlways': 'Always allowed',
'settings.approvalHistory.decision.deny': 'Denied',
'settings.appearance.title': '外观',
'settings.appearance.themeHeading': '主题',
'settings.appearance.themeAria': '主题',
+2
View File
@@ -10,6 +10,7 @@ import AgentEditorPage from '../components/settings/panels/AgentEditorPage';
import AgentsPanel from '../components/settings/panels/AgentsPanel';
import AIPanel from '../components/settings/panels/AIPanel';
import AppearancePanel from '../components/settings/panels/AppearancePanel';
import ApprovalHistoryPanel from '../components/settings/panels/ApprovalHistoryPanel';
import AutocompleteDebugPanel from '../components/settings/panels/AutocompleteDebugPanel';
import AutocompletePanel from '../components/settings/panels/AutocompletePanel';
import AutonomyPanel from '../components/settings/panels/AutonomyPanel';
@@ -512,6 +513,7 @@ const Settings = () => {
<Route path="persona" element={wrapSettingsPage(<PersonaPanel />)} />
<Route path="appearance" element={wrapSettingsPage(<AppearancePanel />)} />
<Route path="agent-access" element={wrapSettingsPage(<AgentAccessPanel />)} />
<Route path="approval-history" element={wrapSettingsPage(<ApprovalHistoryPanel />)} />
<Route path="agents" element={wrapSettingsPage(<AgentsPanel />)} />
<Route path="agents/new" element={wrapSettingsPage(<AgentEditorPage />)} />
<Route path="agents/edit/:id" element={wrapSettingsPage(<AgentEditorPage />)} />
+93
View File
@@ -0,0 +1,93 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import {
type ApprovalAuditEntry,
fetchPendingApprovals,
fetchRecentApprovalDecisions,
unwrapRows,
} from './approvalApi';
const mockCallCoreRpc = vi.fn();
vi.mock('../coreRpcClient', () => ({
callCoreRpc: (...args: unknown[]) => mockCallCoreRpc(...args),
}));
const auditRow = (overrides: Partial<ApprovalAuditEntry> = {}): ApprovalAuditEntry => ({
request_id: 'req-1',
tool_name: 'shell',
action_summary: 'run ls',
args_redacted: {},
session_id: 'sess-1',
created_at: '2026-05-29T10:00:00Z',
expires_at: null,
decided_at: '2026-05-29T10:00:05Z',
decision: 'approve_once',
...overrides,
});
describe('unwrapRows', () => {
it('returns a bare array as-is (gate absent path)', () => {
expect(unwrapRows([1, 2, 3])).toEqual([1, 2, 3]);
});
it('unwraps the {result, logs} envelope (gate installed path)', () => {
expect(unwrapRows({ result: [{ a: 1 }], logs: ['note'] })).toEqual([{ a: 1 }]);
});
it('returns [] for null / non-array / malformed shapes rather than throwing', () => {
expect(unwrapRows(null)).toEqual([]);
expect(unwrapRows(undefined)).toEqual([]);
expect(unwrapRows({ result: 'nope' })).toEqual([]);
expect(unwrapRows(42)).toEqual([]);
});
});
describe('fetchRecentApprovalDecisions', () => {
beforeEach(() => mockCallCoreRpc.mockReset());
it('calls the correct method with no params when limit omitted', async () => {
mockCallCoreRpc.mockResolvedValueOnce({ result: [auditRow()], logs: ['x'] });
const rows = await fetchRecentApprovalDecisions();
expect(mockCallCoreRpc).toHaveBeenCalledWith({
method: 'openhuman.approval_list_recent_decisions',
params: {},
});
expect(rows).toHaveLength(1);
expect(rows[0].decision).toBe('approve_once');
});
it('forwards an explicit limit', async () => {
mockCallCoreRpc.mockResolvedValueOnce([]);
await fetchRecentApprovalDecisions(10);
expect(mockCallCoreRpc).toHaveBeenCalledWith({
method: 'openhuman.approval_list_recent_decisions',
params: { limit: 10 },
});
});
it('normalizes a bare-array response (gate absent)', async () => {
mockCallCoreRpc.mockResolvedValueOnce([]);
expect(await fetchRecentApprovalDecisions()).toEqual([]);
});
});
describe('fetchPendingApprovals', () => {
beforeEach(() => mockCallCoreRpc.mockReset());
it('calls the pending method and unwraps the envelope', async () => {
mockCallCoreRpc.mockResolvedValueOnce({
result: [{ request_id: 'p-1', tool_name: 'curl' }],
logs: ['1 row'],
});
const rows = await fetchPendingApprovals();
expect(mockCallCoreRpc).toHaveBeenCalledWith({ method: 'openhuman.approval_list_pending' });
expect(rows[0].request_id).toBe('p-1');
});
});
+85
View File
@@ -0,0 +1,85 @@
import { callCoreRpc } from '../coreRpcClient';
// ---------------------------------------------------------------------------
// Approval audit / history read client.
//
// Surfaces the read paths added in PR #2335 (`approval_list_recent_decisions`)
// and the live `approval_list_pending` queue. Both are exposed by the core's
// approval gate through the controller registry; this client only READS them —
// decisions still flow through `openhuman.approval_decide` (ApprovalRequestCard).
//
// Wire-shape note: both RPCs return an `RpcOutcome` with a single diagnostic
// log line when the gate is installed, so the JSON-RPC `result` is the
// CLI-compatible envelope `{ result: [...rows], logs: [...] }`. When the gate
// is NOT installed the core returns a bare `[]`. `unwrapRows` normalizes both.
// ---------------------------------------------------------------------------
/** User's decision on a pending approval (mirrors Rust `ApprovalDecision`). */
export type ApprovalDecision = 'approve_once' | 'approve_always_for_tool' | 'deny';
/** A pending approval awaiting a decision (mirrors Rust `PendingApproval`). */
export interface PendingApproval {
request_id: string;
tool_name: string;
/** Short human-readable summary, scrubbed of PII / chat content. */
action_summary: string;
/** Redacted JSON arguments — counts/shape only, no raw message bodies. */
args_redacted: unknown;
session_id: string;
/** RFC3339 timestamp. */
created_at: string;
/** RFC3339 timestamp, or null when the request does not expire. */
expires_at: string | null;
}
/** A decided approval audit row (mirrors Rust `ApprovalAuditEntry`). */
export interface ApprovalAuditEntry {
request_id: string;
tool_name: string;
action_summary: string;
args_redacted: unknown;
session_id: string;
created_at: string;
expires_at: string | null;
/** RFC3339 timestamp the decision was recorded. */
decided_at: string;
decision: ApprovalDecision;
}
/**
* Normalize the two possible wire shapes into a plain row array:
* - gate installed → `{ result: T[], logs: string[] }`
* - gate absent → bare `T[]`
* Anything else (unexpected) collapses to an empty array rather than throwing,
* so a degraded core can never blank the whole settings screen.
*/
export const unwrapRows = <T>(raw: unknown): T[] => {
if (Array.isArray(raw)) return raw as T[];
if (raw && typeof raw === 'object' && Array.isArray((raw as { result?: unknown }).result)) {
return (raw as { result: T[] }).result;
}
return [];
};
/** Default page size matching the core's `list_recent_decisions` default. */
export const DEFAULT_APPROVAL_HISTORY_LIMIT = 50;
/**
* Fetch recently decided approval rows for the audit/history surface.
* `limit` is clamped core-side; omit to use the core default (50).
*/
export const fetchRecentApprovalDecisions = async (
limit?: number
): Promise<ApprovalAuditEntry[]> => {
const raw = await callCoreRpc<unknown>({
method: 'openhuman.approval_list_recent_decisions',
params: limit === undefined ? {} : { limit },
});
return unwrapRows<ApprovalAuditEntry>(raw);
};
/** Fetch the live queue of pending (undecided) approvals. */
export const fetchPendingApprovals = async (): Promise<PendingApproval[]> => {
const raw = await callCoreRpc<unknown>({ method: 'openhuman.approval_list_pending' });
return unwrapRows<PendingApproval>(raw);
};
+1
View File
@@ -469,6 +469,7 @@ Canonical mapping of every product feature to its test source(s). Drives gap-fil
| 13.1.2 | Linked Accounts | WD | `auth-access-control.spec.ts` | 🟡 | UI surface unasserted |
| 13.1.3 | Meet Handoff Prompt-Injection Guard | VU | `app/src/services/__tests__/webviewAccountService.meetPromptInjection.test.ts` (this PR) | ✅ | Was ❌ — guard blocks handoff on hostile transcripts and wraps non-blocked transcripts in `<meeting_transcript source="untrusted_external_audio">` delimiters (#1920) |
| 13.1.4 | Wallet Balances Panel | VU | `app/src/components/settings/panels/__tests__/WalletBalancesPanel.test.tsx`, `app/src/services/walletApi.test.ts` | ✅ | Loading/error/empty/loaded states; Retry + Refresh re-invocation; chain badges; truncated address; providerStatus chip |
| 13.1.5 | Approval History | VU | `app/src/components/settings/panels/__tests__/ApprovalHistoryPanel.test.tsx`, `app/src/services/api/approvalApi.test.ts` (this PR) | ✅ | Was ❌ — read-only audit surface over `approval_list_recent_decisions`; covers loaded/empty/error/refresh states, per-decision badge, and the bare-array vs `{result,logs}` envelope normalization |
### 13.2 Automation & Channels
+12
View File
@@ -1441,6 +1441,18 @@ pub(super) const CAPABILITIES: &[Capability] = &[
status: CapabilityStatus::Stable,
privacy: None,
},
Capability {
id: "security.approval_history",
name: "Approval History",
domain: "security",
category: CapabilityCategory::Settings,
description: "Review a read-only audit trail of past tool-approval decisions \
(Approve once / Always allow / Deny), newest first. Summaries are \
scrubbed of chat content and arguments are shown as redacted shape only.",
how_to: "Settings → Agent OS access → View approval history",
status: CapabilityStatus::Stable,
privacy: None,
},
Capability {
id: "tool.detect_tools",
name: "Detect Installed Tools",