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",