diff --git a/app/src/components/settings/hooks/useSettingsNavigation.ts b/app/src/components/settings/hooks/useSettingsNavigation.ts
index 3859a0626e..4664f67bfe 100644
--- a/app/src/components/settings/hooks/useSettingsNavigation.ts
+++ b/app/src/components/settings/hooks/useSettingsNavigation.ts
@@ -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 [];
diff --git a/app/src/components/settings/panels/AgentAccessPanel.tsx b/app/src/components/settings/panels/AgentAccessPanel.tsx
index 8cd1e522c8..476c94e0db 100644
--- a/app/src/components/settings/panels/AgentAccessPanel.tsx
+++ b/app/src/components/settings/panels/AgentAccessPanel.tsx
@@ -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 = () => {
)}
+ {/* Approval history — read-only audit trail of past decisions,
+ backed by the gate's durable decided-rows store. */}
+
+
+ {t('settings.agentAccess.approvalHistory')}
+
+
+ {t('settings.agentAccess.approvalHistoryDesc')}
+
+
+
+
{/* Auto-save status — changes persist on selection; no manual save. */}
{error ? (
diff --git a/app/src/components/settings/panels/ApprovalHistoryPanel.tsx b/app/src/components/settings/panels/ApprovalHistoryPanel.tsx
new file mode 100644
index 0000000000..beac6d4a70
--- /dev/null
+++ b/app/src/components/settings/panels/ApprovalHistoryPanel.tsx
@@ -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
= {
+ 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 = {
+ 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([]);
+ const [isLoading, setIsLoading] = useState(true);
+ const [error, setError] = useState(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 (
+
+
+
+
+
+
{t('settings.approvalHistory.subtitle')}
+
+
+
+ {isLoading ? (
+
+ {t('settings.approvalHistory.loading')}
+
+ ) : error ? (
+
+
{error}
+
+
+ ) : entries.length === 0 ? (
+
+ {t('settings.approvalHistory.emptyState')}
+
+ ) : (
+
+ {entries.map(entry => (
+ -
+
+ {entry.tool_name}
+
+ {t(DECISION_LABEL_KEY[entry.decision])}
+
+
+ {entry.action_summary}
+
+ {t('settings.approvalHistory.decidedAt').replace(
+ '{date}',
+ formatDateTime(entry.decided_at)
+ )}
+
+
+ ))}
+
+ )}
+
+
+ );
+};
+
+export default ApprovalHistoryPanel;
diff --git a/app/src/components/settings/panels/__tests__/ApprovalHistoryPanel.test.tsx b/app/src/components/settings/panels/__tests__/ApprovalHistoryPanel.test.tsx
new file mode 100644
index 0000000000..2b2c2d8589
--- /dev/null
+++ b/app/src/components/settings/panels/__tests__/ApprovalHistoryPanel.test.tsx
@@ -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 => ({
+ 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(, {
+ 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(, {
+ 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(, {
+ 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(, {
+ 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(, {
+ 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(, {
+ 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();
+ });
+});
diff --git a/app/src/lib/i18n/ar.ts b/app/src/lib/i18n/ar.ts
index 09282256f4..166f34bca9 100644
--- a/app/src/lib/i18n/ar.ts
+++ b/app/src/lib/i18n/ar.ts
@@ -3387,6 +3387,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': 'الموضوع',
diff --git a/app/src/lib/i18n/bn.ts b/app/src/lib/i18n/bn.ts
index 366c43c2aa..7aaa85f268 100644
--- a/app/src/lib/i18n/bn.ts
+++ b/app/src/lib/i18n/bn.ts
@@ -3448,6 +3448,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': 'থিম',
diff --git a/app/src/lib/i18n/de.ts b/app/src/lib/i18n/de.ts
index 07e6088b09..c421bdfafe 100644
--- a/app/src/lib/i18n/de.ts
+++ b/app/src/lib/i18n/de.ts
@@ -3540,6 +3540,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',
diff --git a/app/src/lib/i18n/en.ts b/app/src/lib/i18n/en.ts
index 3b11f550b9..11f2bc1760 100644
--- a/app/src/lib/i18n/en.ts
+++ b/app/src/lib/i18n/en.ts
@@ -3658,6 +3658,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',
diff --git a/app/src/lib/i18n/es.ts b/app/src/lib/i18n/es.ts
index 7303dcf87f..18162ea045 100644
--- a/app/src/lib/i18n/es.ts
+++ b/app/src/lib/i18n/es.ts
@@ -3509,6 +3509,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',
diff --git a/app/src/lib/i18n/fr.ts b/app/src/lib/i18n/fr.ts
index fcf71877bd..8278e375f3 100644
--- a/app/src/lib/i18n/fr.ts
+++ b/app/src/lib/i18n/fr.ts
@@ -3524,6 +3524,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',
diff --git a/app/src/lib/i18n/hi.ts b/app/src/lib/i18n/hi.ts
index cc1119df6b..2761081b10 100644
--- a/app/src/lib/i18n/hi.ts
+++ b/app/src/lib/i18n/hi.ts
@@ -3455,6 +3455,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': 'थीम',
diff --git a/app/src/lib/i18n/id.ts b/app/src/lib/i18n/id.ts
index 376a423f32..19baa92b3d 100644
--- a/app/src/lib/i18n/id.ts
+++ b/app/src/lib/i18n/id.ts
@@ -3464,6 +3464,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',
diff --git a/app/src/lib/i18n/it.ts b/app/src/lib/i18n/it.ts
index 93882adb50..226eaff2b7 100644
--- a/app/src/lib/i18n/it.ts
+++ b/app/src/lib/i18n/it.ts
@@ -3505,6 +3505,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',
diff --git a/app/src/lib/i18n/ko.ts b/app/src/lib/i18n/ko.ts
index 6ae6993367..1eb8d7b9de 100644
--- a/app/src/lib/i18n/ko.ts
+++ b/app/src/lib/i18n/ko.ts
@@ -3419,6 +3419,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': '테마',
diff --git a/app/src/lib/i18n/pl.ts b/app/src/lib/i18n/pl.ts
index 91efc8cab8..29a2895290 100644
--- a/app/src/lib/i18n/pl.ts
+++ b/app/src/lib/i18n/pl.ts
@@ -3510,6 +3510,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',
diff --git a/app/src/lib/i18n/pt.ts b/app/src/lib/i18n/pt.ts
index d36a9fa1e7..de47651c86 100644
--- a/app/src/lib/i18n/pt.ts
+++ b/app/src/lib/i18n/pt.ts
@@ -3508,6 +3508,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',
diff --git a/app/src/lib/i18n/ru.ts b/app/src/lib/i18n/ru.ts
index 3fafacff91..594a042358 100644
--- a/app/src/lib/i18n/ru.ts
+++ b/app/src/lib/i18n/ru.ts
@@ -3477,6 +3477,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': 'Тема',
diff --git a/app/src/lib/i18n/zh-CN.ts b/app/src/lib/i18n/zh-CN.ts
index 2afeb6d642..9cf265ecb1 100644
--- a/app/src/lib/i18n/zh-CN.ts
+++ b/app/src/lib/i18n/zh-CN.ts
@@ -3285,6 +3285,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': '主题',
diff --git a/app/src/pages/Settings.tsx b/app/src/pages/Settings.tsx
index 7ff1a5849c..370de0c84a 100644
--- a/app/src/pages/Settings.tsx
+++ b/app/src/pages/Settings.tsx
@@ -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 = () => {
)} />
)} />
)} />
+ )} />
)} />
)} />
)} />
diff --git a/app/src/services/api/approvalApi.test.ts b/app/src/services/api/approvalApi.test.ts
new file mode 100644
index 0000000000..fa6722ba9e
--- /dev/null
+++ b/app/src/services/api/approvalApi.test.ts
@@ -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 => ({
+ 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');
+ });
+});
diff --git a/app/src/services/api/approvalApi.ts b/app/src/services/api/approvalApi.ts
new file mode 100644
index 0000000000..b24eef7750
--- /dev/null
+++ b/app/src/services/api/approvalApi.ts
@@ -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 = (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 => {
+ const raw = await callCoreRpc({
+ method: 'openhuman.approval_list_recent_decisions',
+ params: limit === undefined ? {} : { limit },
+ });
+ return unwrapRows(raw);
+};
+
+/** Fetch the live queue of pending (undecided) approvals. */
+export const fetchPendingApprovals = async (): Promise => {
+ const raw = await callCoreRpc({ method: 'openhuman.approval_list_pending' });
+ return unwrapRows(raw);
+};
diff --git a/docs/TEST-COVERAGE-MATRIX.md b/docs/TEST-COVERAGE-MATRIX.md
index b75fbd4989..ea5066dbf8 100644
--- a/docs/TEST-COVERAGE-MATRIX.md
+++ b/docs/TEST-COVERAGE-MATRIX.md
@@ -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 `` 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
diff --git a/src/openhuman/about_app/catalog_data.rs b/src/openhuman/about_app/catalog_data.rs
index 455d0e1d58..fdd115b4b5 100644
--- a/src/openhuman/about_app/catalog_data.rs
+++ b/src/openhuman/about_app/catalog_data.rs
@@ -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",