Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions app/src/components/settings/panels/DeveloperOptionsPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,22 @@ const developerItems = [
</svg>
),
},
{
id: 'tool-policy-diagnostics',
titleKey: 'devOptions.diagnostics',
descriptionKey: 'devOptions.diagnosticsDesc',
route: 'tool-policy-diagnostics',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 17v-5a2 2 0 012-2h2a2 2 0 012 2v5m-8 0h8m-8 0H7a2 2 0 01-2-2V7a2 2 0 012-2h10a2 2 0 012 2v8a2 2 0 01-2 2h-2"
/>
</svg>
),
},
{
id: 'intelligence',
titleKey: 'settings.developerMenu.intelligence.title',
Expand Down
243 changes: 243 additions & 0 deletions app/src/components/settings/panels/ToolPolicyDiagnosticsPanel.tsx
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>
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
</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

View workflow job for this annotation

GitHub Actions / Frontend Coverage (Vitest)

src/components/settings/panels/__tests__/ToolPolicyDiagnosticsPanel.test.tsx > ToolPolicyDiagnosticsPanel > renders diagnostics from core RPC

TestingLibraryElementError: Unable to find an element with the text: /Recent \\(24h\\):/i. This could be because the text is broken up by multiple elements. In this case, you can provide a function for your text matcher to make your matcher more flexible. Ignored nodes: comments, script, style <body> <div> <div class="z-10 relative" > <div class="px-5 pt-5 pb-3 " > <div class="flex items-center" > <button aria-label="Back" class="w-6 h-6 flex items-center justify-center rounded-full hover:bg-stone-100 dark:hover:bg-neutral-800 dark:bg-neutral-800 dark:hover:bg-neutral-800 transition-colors mr-2" > <svg class="w-4 h-4 text-stone-500 dark:text-neutral-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" > <path d="M15 19l-7-7 7-7" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" /> </svg> </button> <h2 class="text-sm font-semibold text-stone-900 dark:text-neutral-100" > Diagnostics </h2> </div> </div> <div class="px-4 pt-3 pb-6 flex flex-col gap-3" > <div class="px-4 py-3 rounded-lg border border-sage-300 dark:border-sage-500/40 bg-sage-50 dark:bg-sage-500/10" > <div class="text-sm font-semibold text-sage-900 dark:text-sage-200" > Policy posture </div> <dl class="mt-2 grid grid-cols-[auto_1fr] gap-x-3 gap-y-0.5 text-xs" > <dt class="text-sage-700 dark:text-sage-300" > Autonomy: </dt> <dd class="font-mono text-sage-900 dark:text-sage-200" > supervised </dd> <dt class="text-sage-700 dark:text-sage-300" > Workspace only: </dt> <dd class="text-sage-900 dark:text-sage-200" > true </dd> <dt class="text-sage-700 dark:text-sage-300" > Max actions/hr: </dt> <dd class="font-mono text-sage-900 dark:text-sage-200" > 123 </dd> <dt class="text-sage-700 dark:text-sage-300" > Approval (medium risk): </dt> <dd class="text-sage-900 dark:text-sage-200" > true </dd> <dt class="text-sage-700 dark:text-sage-300" > Block high risk: </dt> <dd class="text-sage-900 dark:text-sage-200" > true </dd> </dl> </div> <div class="px-4 py-3 rounded-lg border border-sage-300 dark:border-sage-500/40 bg-white dark:bg-sage-900/20" > <div class="text-sm font-semibold text-sage-900 dark:text-sage-200" > Inventory </div> <dl class="mt-2 grid grid-cols-2 gap-x-6 gap-y-1 text-xs" > <div> <dt class="text-sage-700 dark:text-sage-300" > Total tools </dt> <dd class="font-mono text-sage-900 dark:text-sage-200" > 10 </dd> </div> <div> <dt class="text-sage-700 dark:text-sage-300" > Enabled tools </dt> <dd class="font-mono text-sage-900 dark:text-sage-200" > 10 </dd> </

Check failure on line 46 in app/src/components/settings/panels/__tests__/ToolPolicyDiagnosticsPanel.test.tsx

View workflow job for this annotation

GitHub Actions / test / Frontend Unit Tests

src/components/settings/panels/__tests__/ToolPolicyDiagnosticsPanel.test.tsx > ToolPolicyDiagnosticsPanel > renders diagnostics from core RPC

TestingLibraryElementError: Unable to find an element with the text: /Recent \\(24h\\):/i. This could be because the text is broken up by multiple elements. In this case, you can provide a function for your text matcher to make your matcher more flexible. Ignored nodes: comments, script, style <body> <div> <div class="z-10 relative" > <div class="px-5 pt-5 pb-3 " > <div class="flex items-center" > <button aria-label="Back" class="w-6 h-6 flex items-center justify-center rounded-full hover:bg-stone-100 dark:hover:bg-neutral-800 dark:bg-neutral-800 dark:hover:bg-neutral-800 transition-colors mr-2" > <svg class="w-4 h-4 text-stone-500 dark:text-neutral-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" > <path d="M15 19l-7-7 7-7" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" /> </svg> </button> <h2 class="text-sm font-semibold text-stone-900 dark:text-neutral-100" > Diagnostics </h2> </div> </div> <div class="px-4 pt-3 pb-6 flex flex-col gap-3" > <div class="px-4 py-3 rounded-lg border border-sage-300 dark:border-sage-500/40 bg-sage-50 dark:bg-sage-500/10" > <div class="text-sm font-semibold text-sage-900 dark:text-sage-200" > Policy posture </div> <dl class="mt-2 grid grid-cols-[auto_1fr] gap-x-3 gap-y-0.5 text-xs" > <dt class="text-sage-700 dark:text-sage-300" > Autonomy: </dt> <dd class="font-mono text-sage-900 dark:text-sage-200" > supervised </dd> <dt class="text-sage-700 dark:text-sage-300" > Workspace only: </dt> <dd class="text-sage-900 dark:text-sage-200" > true </dd> <dt class="text-sage-700 dark:text-sage-300" > Max actions/hr: </dt> <dd class="font-mono text-sage-900 dark:text-sage-200" > 123 </dd> <dt class="text-sage-700 dark:text-sage-300" > Approval (medium risk): </dt> <dd class="text-sage-900 dark:text-sage-200" > true </dd> <dt class="text-sage-700 dark:text-sage-300" > Block high risk: </dt> <dd class="text-sage-900 dark:text-sage-200" > true </dd> </dl> </div> <div class="px-4 py-3 rounded-lg border border-sage-300 dark:border-sage-500/40 bg-white dark:bg-sage-900/20" > <div class="text-sm font-semibold text-sage-900 dark:text-sage-200" > Inventory </div> <dl class="mt-2 grid grid-cols-2 gap-x-6 gap-y-1 text-xs" > <div> <dt class="text-sage-700 dark:text-sage-300" > Total tools </dt> <dd class="font-mono text-sage-900 dark:text-sage-200" > 10 </dd> </div> <div> <dt class="text-sage-700 dark:text-sage-300" > Enabled tools </dt> <dd class="font-mono text-sage-900 dark:text-sage-200" > 10 </dd> </
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
expect(screen.getAllByText('5').length).toBeGreaterThan(0);
expect(hoisted.callCoreRpc).toHaveBeenCalled();
});
});
5 changes: 5 additions & 0 deletions app/src/pages/Settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import TeamInvitesPanel from '../components/settings/panels/TeamInvitesPanel';
import TeamManagementPanel from '../components/settings/panels/TeamManagementPanel';
import TeamMembersPanel from '../components/settings/panels/TeamMembersPanel';
import TeamPanel from '../components/settings/panels/TeamPanel';
import ToolPolicyDiagnosticsPanel from '../components/settings/panels/ToolPolicyDiagnosticsPanel';
import ToolsPanel from '../components/settings/panels/ToolsPanel';
import VoiceDebugPanel from '../components/settings/panels/VoiceDebugPanel';
import VoicePanel from '../components/settings/panels/VoicePanel';
Expand Down Expand Up @@ -417,6 +418,10 @@ const Settings = () => {
<Route path="companion" element={wrapSettingsPage(<CompanionPanel />)} />
{/* Developer Options */}
<Route path="developer-options" element={wrapSettingsPage(<DeveloperOptionsPanel />)} />
<Route
path="tool-policy-diagnostics"
element={wrapSettingsPage(<ToolPolicyDiagnosticsPanel />)}
/>
<Route path="autonomy" element={wrapSettingsPage(<AutonomyPanel />)} />
<Route path="mcp-server" element={wrapSettingsPage(<McpServerPanel />)} />
{/* Legacy direct path for the routing tab — kept so existing links
Expand Down
6 changes: 6 additions & 0 deletions src/openhuman/agent/harness/session/turn.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Avoid persisting raw policy reasons into the denial registry.

This call forwards reason directly into a retained diagnostics buffer. Add redaction at this boundary (or pass a pre-redacted reason) so policy-block telemetry cannot retain sensitive text.

As per coding guidelines, “Never log secrets or full PII; always redact sensitive data in debug logs”.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/openhuman/agent/harness/session/turn.rs` around lines 1331 - 1336, The
call to crate::openhuman::tool_registry::denials::record currently forwards the
raw reason into durable telemetry (using call.name, self.tool_policy.name(),
blocked_action, reason); replace this by redacting sensitive content before the
boundary: obtain a sanitized_reason via an existing redaction helper (or add one
e.g., redact_reason(reason) that strips PII/secrets or returns a redaction
token) and pass sanitized_reason to denials::record instead of reason, ensuring
only the pre-redacted string is persisted.

tracing::debug!(
tool = call.name.as_str(),
policy = self.tool_policy.name(),
Expand Down
Loading
Loading