-
Notifications
You must be signed in to change notification settings - Fork 2.7k
feat(tool_policy): diagnostics RPC and Developer Options panel #2715
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 2 commits
01eb678
3c63b46
4dacb92
4e7a457
a739c7d
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,243 @@ | ||
| import { useEffect, useMemo, useState } from 'react'; | ||
|
|
||
| import { useT } from '../../../lib/i18n/I18nContext'; | ||
| import { callCoreRpc } from '../../../services/coreRpcClient'; | ||
| import SettingsHeader from '../components/SettingsHeader'; | ||
| import { useSettingsNavigation } from '../hooks/useSettingsNavigation'; | ||
|
|
||
| type ToolPolicyDiagnostics = { | ||
| total_tools: number; | ||
| enabled_tools: number; | ||
| mcp_stdio_tools: number; | ||
| json_rpc_tools: number; | ||
| possible_write_surfaces: string[]; | ||
| policy_surfaces: string[]; | ||
| posture: { | ||
| autonomy_level: string; | ||
| workspace_only: boolean; | ||
| max_actions_per_hour: number; | ||
| require_approval_for_medium_risk: boolean; | ||
| block_high_risk_commands: boolean; | ||
| }; | ||
| mcp_allowlists: { | ||
| enabled: boolean; | ||
| server_count: number; | ||
| enabled_server_count: number; | ||
| servers: { | ||
| name: string; | ||
| enabled: boolean; | ||
| allowed_tools_count: number; | ||
| disallowed_tools_count: number; | ||
| has_allowlist: boolean; | ||
| has_denylist: boolean; | ||
| }[]; | ||
| }; | ||
| mcp_write_audit: { enabled: boolean; recent_rows: number | null; last_error: string | null }; | ||
| recent_denials: { | ||
| timestamp_ms: number; | ||
| tool_name: string; | ||
| policy: string; | ||
| action: string; | ||
| reason: string; | ||
| }[]; | ||
| }; | ||
|
|
||
| const ToolPolicyDiagnosticsPanel = () => { | ||
| const { t } = useT(); | ||
| const { navigateBack, breadcrumbs } = useSettingsNavigation(); | ||
|
|
||
| const [status, setStatus] = useState< | ||
| | { kind: 'loading' } | ||
| | { kind: 'ready'; diagnostics: ToolPolicyDiagnostics } | ||
| | { kind: 'error'; message: string } | ||
| >({ kind: 'loading' }); | ||
|
|
||
| useEffect(() => { | ||
| let cancelled = false; | ||
| (async () => { | ||
| try { | ||
| const diagnostics = await callCoreRpc<ToolPolicyDiagnostics>({ | ||
| method: 'tool_registry.diagnostics', | ||
| params: {}, | ||
| timeoutMs: 10_000, | ||
| }); | ||
| if (cancelled) return; | ||
| setStatus({ kind: 'ready', diagnostics }); | ||
| } catch (err) { | ||
| if (cancelled) return; | ||
| setStatus({ kind: 'error', message: err instanceof Error ? err.message : String(err) }); | ||
| } | ||
| })(); | ||
| return () => { | ||
| cancelled = true; | ||
| }; | ||
| }, []); | ||
|
|
||
| const body = useMemo(() => { | ||
| if (status.kind === 'loading') { | ||
| return <div className="px-4 py-3 text-sm text-sage-700 dark:text-sage-200">Loading…</div>; | ||
| } | ||
| if (status.kind === 'error') { | ||
| return ( | ||
| <div className="px-4 py-3 rounded-lg border border-coral-300 dark:border-coral-500/40 bg-coral-50 dark:bg-coral-500/10"> | ||
| <div className="text-sm font-semibold text-coral-900 dark:text-coral-200"> | ||
| Diagnostics unavailable | ||
| </div> | ||
| <div className="text-xs text-coral-800 dark:text-coral-200 mt-1 font-mono break-words"> | ||
| {status.message} | ||
| </div> | ||
| </div> | ||
| ); | ||
| } | ||
|
|
||
| const d = status.diagnostics; | ||
| return ( | ||
| <div className="px-4 pt-3 pb-6 flex flex-col gap-3"> | ||
| <div className="px-4 py-3 rounded-lg border border-sage-300 dark:border-sage-500/40 bg-sage-50 dark:bg-sage-500/10"> | ||
| <div className="text-sm font-semibold text-sage-900 dark:text-sage-200"> | ||
| Policy posture | ||
| </div> | ||
| <dl className="mt-2 grid grid-cols-[auto_1fr] gap-x-3 gap-y-0.5 text-xs"> | ||
| <dt className="text-sage-700 dark:text-sage-300">Autonomy:</dt> | ||
| <dd className="font-mono text-sage-900 dark:text-sage-200"> | ||
| {d.posture.autonomy_level} | ||
| </dd> | ||
| <dt className="text-sage-700 dark:text-sage-300">Workspace only:</dt> | ||
| <dd className="text-sage-900 dark:text-sage-200">{String(d.posture.workspace_only)}</dd> | ||
| <dt className="text-sage-700 dark:text-sage-300">Max actions/hr:</dt> | ||
| <dd className="font-mono text-sage-900 dark:text-sage-200"> | ||
| {d.posture.max_actions_per_hour} | ||
| </dd> | ||
| <dt className="text-sage-700 dark:text-sage-300">Approval (medium risk):</dt> | ||
| <dd className="text-sage-900 dark:text-sage-200"> | ||
| {String(d.posture.require_approval_for_medium_risk)} | ||
| </dd> | ||
| <dt className="text-sage-700 dark:text-sage-300">Block high risk:</dt> | ||
| <dd className="text-sage-900 dark:text-sage-200"> | ||
| {String(d.posture.block_high_risk_commands)} | ||
| </dd> | ||
| </dl> | ||
| </div> | ||
|
|
||
| <div className="px-4 py-3 rounded-lg border border-sage-300 dark:border-sage-500/40 bg-white dark:bg-sage-900/20"> | ||
| <div className="text-sm font-semibold text-sage-900 dark:text-sage-200">Inventory</div> | ||
| <dl className="mt-2 grid grid-cols-2 gap-x-6 gap-y-1 text-xs"> | ||
| <div> | ||
| <dt className="text-sage-700 dark:text-sage-300">Total tools</dt> | ||
| <dd className="font-mono text-sage-900 dark:text-sage-200">{d.total_tools}</dd> | ||
| </div> | ||
| <div> | ||
| <dt className="text-sage-700 dark:text-sage-300">Enabled tools</dt> | ||
| <dd className="font-mono text-sage-900 dark:text-sage-200">{d.enabled_tools}</dd> | ||
| </div> | ||
| <div> | ||
| <dt className="text-sage-700 dark:text-sage-300">MCP stdio tools</dt> | ||
| <dd className="font-mono text-sage-900 dark:text-sage-200">{d.mcp_stdio_tools}</dd> | ||
| </div> | ||
| <div> | ||
| <dt className="text-sage-700 dark:text-sage-300">JSON-RPC tools</dt> | ||
| <dd className="font-mono text-sage-900 dark:text-sage-200">{d.json_rpc_tools}</dd> | ||
| </div> | ||
| </dl> | ||
| </div> | ||
|
|
||
| <div className="px-4 py-3 rounded-lg border border-sage-300 dark:border-sage-500/40 bg-white dark:bg-sage-900/20"> | ||
| <div className="text-sm font-semibold text-sage-900 dark:text-sage-200"> | ||
| MCP allowlists | ||
| </div> | ||
| <div className="mt-1 text-xs text-sage-700 dark:text-sage-300"> | ||
| Enabled: <span className="font-mono">{String(d.mcp_allowlists.enabled)}</span> · | ||
| Servers: <span className="font-mono">{d.mcp_allowlists.enabled_server_count}</span>/ | ||
| <span className="font-mono">{d.mcp_allowlists.server_count}</span> | ||
| </div> | ||
| {d.mcp_allowlists.servers.length > 0 && ( | ||
| <ul className="mt-2 text-xs space-y-1"> | ||
| {d.mcp_allowlists.servers.slice(0, 10).map(s => ( | ||
| <li key={s.name} className="flex items-center justify-between gap-3"> | ||
| <span | ||
| className="font-mono text-sage-900 dark:text-sage-200 truncate" | ||
| title={s.name}> | ||
| {s.name || '<unnamed>'} | ||
| </span> | ||
| <span className="text-sage-700 dark:text-sage-300 font-mono"> | ||
| allow={s.allowed_tools_count} deny={s.disallowed_tools_count} | ||
| </span> | ||
| </li> | ||
| ))} | ||
| </ul> | ||
| )} | ||
| </div> | ||
|
|
||
| <div className="px-4 py-3 rounded-lg border border-sage-300 dark:border-sage-500/40 bg-white dark:bg-sage-900/20"> | ||
| <div className="text-sm font-semibold text-sage-900 dark:text-sage-200"> | ||
| MCP write audit | ||
| </div> | ||
| <div className="mt-1 text-xs text-sage-700 dark:text-sage-300"> | ||
| Enabled: <span className="font-mono">{String(d.mcp_write_audit.enabled)}</span> · Recent | ||
| (24h): <span className="font-mono">{d.mcp_write_audit.recent_rows ?? '—'}</span> | ||
| </div> | ||
| {d.mcp_write_audit.last_error && ( | ||
| <div className="mt-2 text-xs text-coral-700 dark:text-coral-200 font-mono break-words"> | ||
| {d.mcp_write_audit.last_error} | ||
| </div> | ||
| )} | ||
| </div> | ||
|
|
||
| <div className="px-4 py-3 rounded-lg border border-sage-300 dark:border-sage-500/40 bg-white dark:bg-sage-900/20"> | ||
| <div className="text-sm font-semibold text-sage-900 dark:text-sage-200"> | ||
| Recent blocked calls | ||
| </div> | ||
| {d.recent_denials.length === 0 ? ( | ||
| <div className="mt-1 text-xs text-sage-700 dark:text-sage-300"> | ||
| No blocked calls recorded. | ||
| </div> | ||
| ) : ( | ||
| <ul className="mt-2 text-xs space-y-1"> | ||
| {d.recent_denials.slice(0, 10).map(entry => ( | ||
| <li | ||
| key={`${entry.timestamp_ms}:${entry.tool_name}`} | ||
| className="flex flex-col gap-0.5"> | ||
| <div className="flex items-center justify-between gap-3"> | ||
| <span | ||
| className="font-mono text-sage-900 dark:text-sage-200 truncate" | ||
| title={entry.tool_name}> | ||
| {entry.tool_name} | ||
| </span> | ||
| <span className="text-sage-700 dark:text-sage-300 font-mono"> | ||
| {entry.policy}:{entry.action} | ||
| </span> | ||
| </div> | ||
| <div className="text-sage-700 dark:text-sage-300 break-words">{entry.reason}</div> | ||
| </li> | ||
| ))} | ||
| </ul> | ||
| )} | ||
| </div> | ||
|
|
||
| <div className="px-4 py-3 rounded-lg border border-sage-300 dark:border-sage-500/40 bg-white dark:bg-sage-900/20"> | ||
| <div className="text-sm font-semibold text-sage-900 dark:text-sage-200"> | ||
| Redacted surfaces | ||
| </div> | ||
| <div className="mt-1 text-xs text-sage-700 dark:text-sage-300"> | ||
| Write-capable: <span className="font-mono">{d.possible_write_surfaces.length}</span> · | ||
| Policy surfaces: <span className="font-mono">{d.policy_surfaces.length}</span> | ||
| </div> | ||
| </div> | ||
| </div> | ||
| ); | ||
| }, [status]); | ||
|
|
||
| return ( | ||
| <div className="z-10 relative"> | ||
| <SettingsHeader | ||
| title={t('devOptions.diagnostics')} | ||
| showBackButton={true} | ||
| onBack={navigateBack} | ||
| breadcrumbs={breadcrumbs} | ||
| /> | ||
| {body} | ||
| </div> | ||
| ); | ||
| }; | ||
|
|
||
| export default ToolPolicyDiagnosticsPanel; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,50 @@ | ||
| import { screen, waitFor } from '@testing-library/react'; | ||
| import { describe, expect, test, vi } from 'vitest'; | ||
|
|
||
| import { renderWithProviders } from '../../../../test/test-utils'; | ||
|
|
||
| const hoisted = vi.hoisted(() => ({ callCoreRpc: vi.fn() })); | ||
|
|
||
| vi.mock('../../../../services/coreRpcClient', () => ({ | ||
| callCoreRpc: (...args: unknown[]) => hoisted.callCoreRpc(...args), | ||
| })); | ||
|
|
||
| vi.mock('../../hooks/useSettingsNavigation', () => ({ | ||
| useSettingsNavigation: () => ({ navigateBack: vi.fn(), breadcrumbs: [] }), | ||
| })); | ||
|
|
||
| describe('ToolPolicyDiagnosticsPanel', () => { | ||
| test('renders diagnostics from core RPC', async () => { | ||
| hoisted.callCoreRpc.mockResolvedValue({ | ||
| total_tools: 10, | ||
| enabled_tools: 10, | ||
| mcp_stdio_tools: 3, | ||
| json_rpc_tools: 7, | ||
| possible_write_surfaces: ['tools.composio_execute'], | ||
| policy_surfaces: ['security.policy_info'], | ||
| posture: { | ||
| autonomy_level: 'supervised', | ||
| workspace_only: true, | ||
| max_actions_per_hour: 123, | ||
| require_approval_for_medium_risk: true, | ||
| block_high_risk_commands: true, | ||
| }, | ||
| mcp_allowlists: { enabled: true, server_count: 0, enabled_server_count: 0, servers: [] }, | ||
| mcp_write_audit: { enabled: true, recent_rows: 5, last_error: null }, | ||
| recent_denials: [], | ||
| }); | ||
|
|
||
| const Panel = (await import('../ToolPolicyDiagnosticsPanel')).default; | ||
| renderWithProviders(<Panel />); | ||
|
|
||
| await waitFor(() => { | ||
| expect(screen.getByText(/Policy posture/i)).toBeInTheDocument(); | ||
| }); | ||
| expect(screen.getByText('supervised')).toBeInTheDocument(); | ||
| expect(screen.getByText(/Total tools/i)).toBeInTheDocument(); | ||
| expect(screen.getAllByText('10').length).toBeGreaterThan(0); | ||
| expect(screen.getByText(/Recent \\(24h\\):/i)).toBeInTheDocument(); | ||
|
Check failure on line 46 in app/src/components/settings/panels/__tests__/ToolPolicyDiagnosticsPanel.test.tsx
|
||
|
coderabbitai[bot] marked this conversation as resolved.
Outdated
|
||
| expect(screen.getAllByText('5').length).toBeGreaterThan(0); | ||
| expect(hoisted.callCoreRpc).toHaveBeenCalled(); | ||
| }); | ||
| }); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -1328,6 +1328,12 @@ impl Agent { | |
| ToolPolicyDecision::Deny { .. } => "denied", | ||
| ToolPolicyDecision::Allow => "allowed", | ||
| }; | ||
| crate::openhuman::tool_registry::denials::record( | ||
| call.name.as_str(), | ||
| self.tool_policy.name(), | ||
| blocked_action, | ||
| reason, | ||
| ); | ||
|
Comment on lines
+1331
to
+1336
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Avoid persisting raw policy reasons into the denial registry. This call forwards As per coding guidelines, “Never log secrets or full PII; always redact sensitive data in debug logs”. 🤖 Prompt for AI Agents |
||
| tracing::debug!( | ||
| tool = call.name.as_str(), | ||
| policy = self.tool_policy.name(), | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.