From bc9e223306441ad2e02ab476ec4e449057742bdf Mon Sep 17 00:00:00 2001 From: Jwalin Shah Date: Wed, 22 Apr 2026 14:27:47 -0700 Subject: [PATCH] feat(privacy): backend-backed capability privacy metadata + PrivacyPanel (#760) * feat(about_app): add capability privacy metadata Adds optional `CapabilityPrivacy { leaves_device, data_kind, destinations }` to the about_app capability catalog so the in-app Privacy surface can be backend-backed instead of hand-maintained. Twelve representative capabilities are annotated for the first audited set (raw/local, derived/backend, credentials, diagnostics, model download); remaining entries default to None and are simply not surfaced. Wire format stays backward compatible via skip_serializing_if. * feat(settings): drive privacy panel from about_app capabilities Replaces the hand-maintained privacy rows with data fetched from openhuman.about_app_list. Only capabilities that ship privacy metadata are rendered; loading and RPC failure both degrade gracefully and the analytics toggle plus explanatory copy remain intact. Adds a small typed client (utils/tauriCommands/aboutApp.ts) and focused vitest coverage for render, omission of unannotated entries, and RPC failure. --------- Co-authored-by: Jwalin Shah --- .../settings/panels/PrivacyPanel.tsx | 116 +++++++++++++++ .../panels/__tests__/PrivacyPanel.test.tsx | 99 +++++++++++++ app/src/utils/tauriCommands/aboutApp.ts | 61 ++++++++ app/src/utils/tauriCommands/index.ts | 1 + src/openhuman/about_app/catalog.rs | 138 +++++++++++++++++- src/openhuman/about_app/mod.rs | 4 +- src/openhuman/about_app/types.rs | 33 +++++ 7 files changed, 450 insertions(+), 2 deletions(-) create mode 100644 app/src/components/settings/panels/__tests__/PrivacyPanel.test.tsx create mode 100644 app/src/utils/tauriCommands/aboutApp.ts diff --git a/app/src/components/settings/panels/PrivacyPanel.tsx b/app/src/components/settings/panels/PrivacyPanel.tsx index e11bad2da..2dc8136de 100644 --- a/app/src/components/settings/panels/PrivacyPanel.tsx +++ b/app/src/components/settings/panels/PrivacyPanel.tsx @@ -1,12 +1,69 @@ +import debug from 'debug'; +import { useEffect, useState } from 'react'; + import { useCoreState } from '../../../providers/CoreStateProvider'; +import { + type Capability, + type CapabilityPrivacy, + type PrivacyDataKind, + listCapabilities, +} from '../../../utils/tauriCommands/aboutApp'; import SettingsHeader from '../components/SettingsHeader'; import { useSettingsNavigation } from '../hooks/useSettingsNavigation'; +const log = debug('privacy-panel'); + +interface AnnotatedCapability extends Capability { + privacy: CapabilityPrivacy; +} + +const KIND_LABEL: Record = { + raw: 'Raw user content', + derived: 'Derived signals', + credentials: 'Credentials', + diagnostics: 'Diagnostics', + metadata: 'Metadata', +}; + +const KIND_BADGE_CLASS: Record = { + raw: 'bg-sage-50 text-sage-700 border-sage-200', + derived: 'bg-amber-50 text-amber-700 border-amber-200', + credentials: 'bg-stone-100 text-stone-700 border-stone-200', + diagnostics: 'bg-primary-50 text-primary-700 border-primary-200', + metadata: 'bg-stone-50 text-stone-600 border-stone-200', +}; + const PrivacyPanel = () => { const { navigateBack, breadcrumbs } = useSettingsNavigation(); const { snapshot, setAnalyticsEnabled } = useCoreState(); const analyticsEnabled = snapshot.analyticsEnabled; + const [capabilities, setCapabilities] = useState([]); + const [loadState, setLoadState] = useState<'loading' | 'ready' | 'error'>('loading'); + + useEffect(() => { + let cancelled = false; + log('[privacy] fetching capability catalog'); + listCapabilities() + .then(items => { + if (cancelled) return; + const annotated = items.filter( + (c): c is AnnotatedCapability => c.privacy !== undefined && c.privacy !== null + ); + log('[privacy] catalog ready', { total: items.length, annotated: annotated.length }); + setCapabilities(annotated); + setLoadState('ready'); + }) + .catch(err => { + if (cancelled) return; + console.warn('[privacy] failed to load capability catalog:', err); + setLoadState('error'); + }); + return () => { + cancelled = true; + }; + }, []); + const handleToggleAnalytics = async () => { const newValue = !analyticsEnabled; try { @@ -27,6 +84,65 @@ const PrivacyPanel = () => {
+ {/* What leaves my computer */} +
+

+ What leaves your computer +

+
+ {loadState === 'loading' && ( +

Loading privacy details…

+ )} + {loadState === 'error' && ( +

+ Couldn’t load the live privacy list. Analytics controls below still work. +

+ )} + {loadState === 'ready' && capabilities.length === 0 && ( +

+ No capabilities currently disclose data movement. +

+ )} + {loadState === 'ready' && capabilities.length > 0 && ( +
    + {capabilities.map(cap => ( +
  • +
    +
    +

    {cap.name}

    +

    + {cap.description} +

    + {cap.privacy.destinations.length > 0 && ( +

    + Sent to: {cap.privacy.destinations.join(', ')} +

    + )} +
    +
    + + {KIND_LABEL[cap.privacy.data_kind]} + + + {cap.privacy.leaves_device ? 'Leaves device' : 'Stays local'} + +
    +
    +
  • + ))} +
+ )} +
+
+ {/* Analytics Section */}

diff --git a/app/src/components/settings/panels/__tests__/PrivacyPanel.test.tsx b/app/src/components/settings/panels/__tests__/PrivacyPanel.test.tsx new file mode 100644 index 000000000..f42be3690 --- /dev/null +++ b/app/src/components/settings/panels/__tests__/PrivacyPanel.test.tsx @@ -0,0 +1,99 @@ +import { screen, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { renderWithProviders } from '../../../../test/test-utils'; +import { type Capability, listCapabilities } from '../../../../utils/tauriCommands/aboutApp'; +import PrivacyPanel from '../PrivacyPanel'; + +vi.mock('../../../../utils/tauriCommands/aboutApp', () => ({ + listCapabilities: vi.fn(), +})); + +vi.mock('../../../../providers/CoreStateProvider', () => ({ + useCoreState: () => ({ + snapshot: { analyticsEnabled: false }, + setAnalyticsEnabled: vi.fn(), + }), +})); + +vi.mock('../../hooks/useSettingsNavigation', () => ({ + useSettingsNavigation: () => ({ navigateBack: vi.fn(), breadcrumbs: [] }), +})); + +const annotated: Capability = { + id: 'conversation.send_text', + name: 'Send Text Messages', + domain: 'conversation', + category: 'conversation', + description: 'Send typed messages to the assistant in a conversation.', + how_to: 'Conversations > Message composer', + status: 'stable', + privacy: { + leaves_device: true, + data_kind: 'derived', + destinations: ['OpenHuman backend', 'TinyHumans Neocortex'], + }, +}; + +const localOnly: Capability = { + id: 'local_ai.embed_text', + name: 'Embed Text', + domain: 'local_ai', + category: 'local_ai', + description: 'Generate embeddings locally.', + how_to: 'Local AI', + status: 'stable', + privacy: { leaves_device: false, data_kind: 'raw', destinations: [] }, +}; + +const unannotated: Capability = { + id: 'conversation.create', + name: 'Create Conversations', + domain: 'conversation', + category: 'conversation', + description: 'Start a new conversation thread.', + how_to: 'Conversations', + status: 'stable', +}; + +describe('PrivacyPanel', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('renders annotated capabilities returned by about_app.list', async () => { + vi.mocked(listCapabilities).mockResolvedValue([annotated, localOnly]); + renderWithProviders(); + + await waitFor(() => { + expect(screen.getByTestId('privacy-capability-list')).toBeTruthy(); + }); + + expect(screen.getByTestId('privacy-row-conversation.send_text')).toBeTruthy(); + expect(screen.getByTestId('privacy-row-local_ai.embed_text')).toBeTruthy(); + expect(screen.getByText(/OpenHuman backend, TinyHumans Neocortex/)).toBeTruthy(); + expect(screen.getByText('Stays local')).toBeTruthy(); + }); + + it('omits capabilities without privacy metadata', async () => { + vi.mocked(listCapabilities).mockResolvedValue([annotated, unannotated]); + renderWithProviders(); + + await waitFor(() => { + expect(screen.getByTestId('privacy-row-conversation.send_text')).toBeTruthy(); + }); + expect(screen.queryByTestId('privacy-row-conversation.create')).toBeNull(); + }); + + it('shows graceful fallback when the RPC fails and keeps analytics toggle visible', async () => { + vi.mocked(listCapabilities).mockRejectedValue(new Error('boom')); + renderWithProviders(); + + await waitFor(() => { + expect(screen.getByTestId('privacy-load-error')).toBeTruthy(); + }); + expect(screen.queryByTestId('privacy-capability-list')).toBeNull(); + // Analytics toggle still rendered + expect(screen.getByRole('switch')).toBeTruthy(); + }); +}); diff --git a/app/src/utils/tauriCommands/aboutApp.ts b/app/src/utils/tauriCommands/aboutApp.ts new file mode 100644 index 000000000..f0607259d --- /dev/null +++ b/app/src/utils/tauriCommands/aboutApp.ts @@ -0,0 +1,61 @@ +/** + * About-app capability catalog client. + * + * Thin wrapper around the `openhuman.about_app_*` JSON-RPC methods exposed by + * the Rust core (`src/openhuman/about_app/schemas.rs`). The Privacy surface is + * the first consumer; future panels can reuse the same types. + */ +import { callCoreRpc } from '../../services/coreRpcClient'; + +import { CommandResponse } from './common'; + +export type CapabilityCategory = + | 'conversation' + | 'intelligence' + | 'skills' + | 'local_ai' + | 'team' + | 'settings' + | 'auth' + | 'screen_intelligence' + | 'channels' + | 'automation'; + +export type CapabilityStatus = 'stable' | 'beta' | 'coming_soon' | 'deprecated'; + +export type PrivacyDataKind = + | 'raw' + | 'derived' + | 'credentials' + | 'diagnostics' + | 'metadata'; + +export interface CapabilityPrivacy { + leaves_device: boolean; + data_kind: PrivacyDataKind; + destinations: string[]; +} + +export interface Capability { + id: string; + name: string; + domain: string; + category: CapabilityCategory; + description: string; + how_to: string; + status: CapabilityStatus; + privacy?: CapabilityPrivacy; +} + +export async function listCapabilities(category?: CapabilityCategory): Promise { + const response = await callCoreRpc | Capability[]>({ + method: 'openhuman.about_app_list', + params: category ? { category } : {}, + }); + // RpcOutcome::single_log emits {result, logs}; bare arrays are handled too + // for forward-compat if logs ever go away. + if (Array.isArray(response)) { + return response; + } + return response.result; +} diff --git a/app/src/utils/tauriCommands/index.ts b/app/src/utils/tauriCommands/index.ts index c5724022a..37a969c8b 100644 --- a/app/src/utils/tauriCommands/index.ts +++ b/app/src/utils/tauriCommands/index.ts @@ -17,3 +17,4 @@ export * from './service'; export * from './accessibility'; export * from './autocomplete'; export * from './voice'; +export * from './aboutApp'; diff --git a/src/openhuman/about_app/catalog.rs b/src/openhuman/about_app/catalog.rs index a92bf5c27..9c5afa203 100644 --- a/src/openhuman/about_app/catalog.rs +++ b/src/openhuman/about_app/catalog.rs @@ -1,7 +1,39 @@ use std::collections::BTreeSet; use std::sync::OnceLock; -use super::types::{Capability, CapabilityCategory, CapabilityStatus}; +use super::types::{ + Capability, CapabilityCategory, CapabilityPrivacy, CapabilityStatus, PrivacyDataKind, +}; + +const LOCAL_RAW: Option = Some(CapabilityPrivacy { + leaves_device: false, + data_kind: PrivacyDataKind::Raw, + destinations: &[], +}); + +const DERIVED_TO_BACKEND: Option = Some(CapabilityPrivacy { + leaves_device: true, + data_kind: PrivacyDataKind::Derived, + destinations: &["OpenHuman backend", "TinyHumans Neocortex"], +}); + +const LOCAL_CREDENTIALS: Option = Some(CapabilityPrivacy { + leaves_device: false, + data_kind: PrivacyDataKind::Credentials, + destinations: &[], +}); + +const DIAGNOSTICS_TO_BACKEND: Option = Some(CapabilityPrivacy { + leaves_device: true, + data_kind: PrivacyDataKind::Diagnostics, + destinations: &["OpenHuman backend"], +}); + +const MODEL_DOWNLOAD: Option = Some(CapabilityPrivacy { + leaves_device: true, + data_kind: PrivacyDataKind::Metadata, + destinations: &["Hugging Face"], +}); const CAPABILITIES: &[Capability] = &[ Capability { @@ -12,6 +44,7 @@ const CAPABILITIES: &[Capability] = &[ description: "Start a new conversation thread with the assistant.", how_to: "Conversations", status: CapabilityStatus::Stable, + privacy: None, }, Capability { id: "conversation.send_text", @@ -21,6 +54,7 @@ const CAPABILITIES: &[Capability] = &[ description: "Send typed messages to the assistant in a conversation.", how_to: "Conversations > Message composer", status: CapabilityStatus::Stable, + privacy: DERIVED_TO_BACKEND, }, Capability { id: "conversation.send_voice", @@ -30,6 +64,7 @@ const CAPABILITIES: &[Capability] = &[ description: "Record or attach voice input and send it as a message.", how_to: "Conversations > Voice input", status: CapabilityStatus::Beta, + privacy: DERIVED_TO_BACKEND, }, Capability { id: "conversation.inline_autocomplete", @@ -39,6 +74,7 @@ const CAPABILITIES: &[Capability] = &[ description: "Show predictive inline text suggestions while you type.", how_to: "Settings > Inline Autocomplete", status: CapabilityStatus::Beta, + privacy: None, }, Capability { id: "conversation.copy_messages", @@ -48,6 +84,7 @@ const CAPABILITIES: &[Capability] = &[ description: "Copy individual assistant or user messages for reuse elsewhere.", how_to: "Conversations > Message actions", status: CapabilityStatus::Stable, + privacy: None, }, Capability { id: "conversation.delete_conversations", @@ -57,6 +94,7 @@ const CAPABILITIES: &[Capability] = &[ description: "Remove existing conversation threads from the app.", how_to: "Conversations > Thread actions", status: CapabilityStatus::Stable, + privacy: None, }, Capability { id: "conversation.suggested_questions", @@ -66,6 +104,7 @@ const CAPABILITIES: &[Capability] = &[ description: "Offer prompt suggestions to help continue a conversation.", how_to: "Home or Conversations > Suggested prompts", status: CapabilityStatus::Beta, + privacy: None, }, Capability { id: "conversation.tool_execution_timeline", @@ -75,6 +114,7 @@ const CAPABILITIES: &[Capability] = &[ description: "Show the sequence of tool calls and actions used to answer a request.", how_to: "Conversations > Tool timeline", status: CapabilityStatus::Beta, + privacy: None, }, Capability { id: "intelligence.analyze_actionable_items", @@ -84,6 +124,7 @@ const CAPABILITIES: &[Capability] = &[ description: "Extract and summarize actionable items from your activity and conversations.", how_to: "Intelligence", status: CapabilityStatus::Stable, + privacy: DERIVED_TO_BACKEND, }, Capability { id: "intelligence.filter_actionable_items", @@ -93,6 +134,7 @@ const CAPABILITIES: &[Capability] = &[ description: "Search and filter actionable items to focus on what matters now.", how_to: "Intelligence > Filters and search", status: CapabilityStatus::Stable, + privacy: None, }, Capability { id: "intelligence.mark_actionable_item_complete", @@ -102,6 +144,7 @@ const CAPABILITIES: &[Capability] = &[ description: "Mark an actionable item as completed.", how_to: "Intelligence > Item actions", status: CapabilityStatus::Stable, + privacy: None, }, Capability { id: "intelligence.dismiss_actionable_item", @@ -111,6 +154,7 @@ const CAPABILITIES: &[Capability] = &[ description: "Dismiss irrelevant or already handled actionable items.", how_to: "Intelligence > Item actions", status: CapabilityStatus::Stable, + privacy: None, }, Capability { id: "intelligence.snooze_actionable_item", @@ -120,6 +164,7 @@ const CAPABILITIES: &[Capability] = &[ description: "Temporarily hide an actionable item until later.", how_to: "Intelligence > Item actions", status: CapabilityStatus::Stable, + privacy: None, }, Capability { id: "intelligence.undo_action", @@ -129,6 +174,7 @@ const CAPABILITIES: &[Capability] = &[ description: "Undo a recent complete, dismiss, or snooze action.", how_to: "Intelligence > Undo snackbar or item history", status: CapabilityStatus::Beta, + privacy: None, }, Capability { id: "intelligence.memory_workspace", @@ -138,6 +184,7 @@ const CAPABILITIES: &[Capability] = &[ description: "Inspect or debug the app's memory workspace and stored knowledge.", how_to: "Settings > Memory Debug", status: CapabilityStatus::Beta, + privacy: None, }, Capability { id: "skills.discover", @@ -147,6 +194,7 @@ const CAPABILITIES: &[Capability] = &[ description: "Browse available skills that can extend the app.", how_to: "Skills", status: CapabilityStatus::Stable, + privacy: None, }, Capability { id: "skills.install", @@ -156,6 +204,7 @@ const CAPABILITIES: &[Capability] = &[ description: "Install a skill into the local workspace.", how_to: "Skills > Install", status: CapabilityStatus::Stable, + privacy: None, }, Capability { id: "skills.configure", @@ -165,6 +214,7 @@ const CAPABILITIES: &[Capability] = &[ description: "Open skill setup and update skill-specific configuration.", how_to: "Skills > Setup or Settings > Connections", status: CapabilityStatus::Stable, + privacy: None, }, Capability { id: "skills.connection_status", @@ -174,6 +224,7 @@ const CAPABILITIES: &[Capability] = &[ description: "See whether a skill-backed integration is connected, offline, or needs setup.", how_to: "Skills or Settings > Connections", status: CapabilityStatus::Beta, + privacy: None, }, Capability { id: "skills.sync_manual", @@ -183,6 +234,7 @@ const CAPABILITIES: &[Capability] = &[ description: "Trigger a manual data sync for a skill integration.", how_to: "Skills > Skill card > Sync", status: CapabilityStatus::Beta, + privacy: DERIVED_TO_BACKEND, }, Capability { id: "skills.run_apify_actors", @@ -192,6 +244,7 @@ const CAPABILITIES: &[Capability] = &[ description: "Launch Apify scrapers and automation actors, then inspect run status and collected results.", how_to: "Conversations > Ask the assistant to run an Apify actor", status: CapabilityStatus::Beta, + privacy: None, }, Capability { id: "skills.toggle_enabled", @@ -201,6 +254,7 @@ const CAPABILITIES: &[Capability] = &[ description: "Turn individual skills on or off without uninstalling them.", how_to: "Settings > Developer Options > Skills", status: CapabilityStatus::Stable, + privacy: None, }, Capability { id: "skills.open_connections_hub", @@ -210,6 +264,7 @@ const CAPABILITIES: &[Capability] = &[ description: "Browse the dedicated connections hub for external skill-backed integrations.", how_to: "Settings > Connections", status: CapabilityStatus::Beta, + privacy: None, }, Capability { id: "skills.connect_google", @@ -219,6 +274,7 @@ const CAPABILITIES: &[Capability] = &[ description: "Connect Google services for email, contacts, and calendar workflows.", how_to: "Settings > Connections", status: CapabilityStatus::ComingSoon, + privacy: LOCAL_CREDENTIALS, }, Capability { id: "skills.connect_notion", @@ -228,6 +284,7 @@ const CAPABILITIES: &[Capability] = &[ description: "Connect Notion for workspace sync and productivity workflows.", how_to: "Settings > Connections", status: CapabilityStatus::ComingSoon, + privacy: LOCAL_CREDENTIALS, }, Capability { id: "skills.connect_web3_wallet", @@ -237,6 +294,7 @@ const CAPABILITIES: &[Capability] = &[ description: "Connect a wallet for crypto workflows and onchain actions.", how_to: "Settings > Connections", status: CapabilityStatus::ComingSoon, + privacy: None, }, Capability { id: "skills.connect_crypto_exchange", @@ -246,6 +304,7 @@ const CAPABILITIES: &[Capability] = &[ description: "Connect supported exchanges for trading and portfolio workflows.", how_to: "Settings > Connections", status: CapabilityStatus::ComingSoon, + privacy: None, }, Capability { id: "local_ai.download_model", @@ -255,6 +314,7 @@ const CAPABILITIES: &[Capability] = &[ description: "Download and bootstrap local AI runtimes and model bundles.", how_to: "Settings > Local AI Model", status: CapabilityStatus::Beta, + privacy: MODEL_DOWNLOAD, }, Capability { id: "local_ai.manage_model_assets", @@ -264,6 +324,7 @@ const CAPABILITIES: &[Capability] = &[ description: "Inspect asset status and download specific chat, vision, embedding, STT, or TTS assets.", how_to: "Settings > Local AI Model > Advanced > Capability Assets", status: CapabilityStatus::Beta, + privacy: None, }, Capability { id: "local_ai.embed_text", @@ -273,6 +334,7 @@ const CAPABILITIES: &[Capability] = &[ description: "Create local vector embeddings for text input.", how_to: "Settings > Local AI Model > Advanced > Test Embeddings", status: CapabilityStatus::Beta, + privacy: LOCAL_RAW, }, Capability { id: "local_ai.speech_to_text", @@ -282,6 +344,7 @@ const CAPABILITIES: &[Capability] = &[ description: "Transcribe audio into text using local speech recognition.", how_to: "Settings > Local AI Model > Advanced > Test Voice Input", status: CapabilityStatus::Beta, + privacy: None, }, Capability { id: "local_ai.text_to_speech", @@ -291,6 +354,7 @@ const CAPABILITIES: &[Capability] = &[ description: "Synthesize speech from text using local voice models.", how_to: "Settings > Local AI Model > Advanced > Test Voice Output", status: CapabilityStatus::Beta, + privacy: None, }, Capability { id: "local_ai.vision_processing", @@ -300,6 +364,7 @@ const CAPABILITIES: &[Capability] = &[ description: "Run vision prompts against images using a local multimodal model.", how_to: "Settings > Local AI Model > Advanced > Test Vision Prompt", status: CapabilityStatus::Beta, + privacy: None, }, Capability { id: "local_ai.direct_prompting", @@ -309,6 +374,7 @@ const CAPABILITIES: &[Capability] = &[ description: "Send a direct prompt to the local model without using the cloud API.", how_to: "Settings > Local AI Model > Advanced > Test Custom Prompt", status: CapabilityStatus::Beta, + privacy: None, }, Capability { id: "team.create", @@ -318,6 +384,7 @@ const CAPABILITIES: &[Capability] = &[ description: "Create a team and start collaborating with shared billing and members.", how_to: "Settings > Team", status: CapabilityStatus::Stable, + privacy: None, }, Capability { id: "team.join_via_invite_code", @@ -327,6 +394,7 @@ const CAPABILITIES: &[Capability] = &[ description: "Join an existing team using an invite code.", how_to: "Invites > Redeem an Invite Code", status: CapabilityStatus::Stable, + privacy: None, }, Capability { id: "team.switch_active_team", @@ -336,6 +404,7 @@ const CAPABILITIES: &[Capability] = &[ description: "Switch which team is currently active in the app.", how_to: "Settings > Team", status: CapabilityStatus::Stable, + privacy: None, }, Capability { id: "team.leave", @@ -345,6 +414,7 @@ const CAPABILITIES: &[Capability] = &[ description: "Leave a team that you no longer want to participate in.", how_to: "Settings > Team", status: CapabilityStatus::Stable, + privacy: None, }, Capability { id: "team.manage_members", @@ -354,6 +424,7 @@ const CAPABILITIES: &[Capability] = &[ description: "Review members and change team roles when you have permission.", how_to: "Settings > Team > Manage team > Members", status: CapabilityStatus::Stable, + privacy: None, }, Capability { id: "team.generate_invite_codes", @@ -363,6 +434,7 @@ const CAPABILITIES: &[Capability] = &[ description: "Create invite codes to bring new members into a team.", how_to: "Settings > Team > Manage team > Invites", status: CapabilityStatus::Stable, + privacy: None, }, Capability { id: "team.track_invite_usage", @@ -372,6 +444,7 @@ const CAPABILITIES: &[Capability] = &[ description: "View invite usage counts, limits, and revoke team invites.", how_to: "Settings > Team > Manage team > Invites", status: CapabilityStatus::Stable, + privacy: None, }, Capability { id: "auth.login_oauth", @@ -381,6 +454,7 @@ const CAPABILITIES: &[Capability] = &[ description: "Sign in with the app's supported provider-based authentication flow.", how_to: "Welcome", status: CapabilityStatus::Stable, + privacy: None, }, Capability { id: "auth.onboarding_setup", @@ -390,6 +464,7 @@ const CAPABILITIES: &[Capability] = &[ description: "Walk through onboarding to configure initial permissions and preferences.", how_to: "Onboarding", status: CapabilityStatus::Stable, + privacy: None, }, Capability { id: "auth.configure_tool_access", @@ -399,6 +474,7 @@ const CAPABILITIES: &[Capability] = &[ description: "Choose which built-in tools OpenHuman can use on your behalf during setup.", how_to: "Onboarding > Enable Tools", status: CapabilityStatus::Stable, + privacy: None, }, Capability { id: "auth.backup_recovery_phrase", @@ -408,6 +484,7 @@ const CAPABILITIES: &[Capability] = &[ description: "Generate and save a recovery phrase used to secure and restore encrypted app data.", how_to: "Onboarding > Recovery Phrase", status: CapabilityStatus::Stable, + privacy: None, }, Capability { id: "auth.import_recovery_phrase", @@ -417,6 +494,7 @@ const CAPABILITIES: &[Capability] = &[ description: "Import an existing recovery phrase to restore encrypted app data.", how_to: "Onboarding > Recovery Phrase > I already have a recovery phrase", status: CapabilityStatus::Stable, + privacy: None, }, Capability { id: "auth.logout", @@ -426,6 +504,7 @@ const CAPABILITIES: &[Capability] = &[ description: "Sign out of the current session.", how_to: "Settings > Log out", status: CapabilityStatus::Stable, + privacy: None, }, Capability { id: "screen_intelligence.toggle_monitoring", @@ -435,6 +514,7 @@ const CAPABILITIES: &[Capability] = &[ description: "Turn desktop screen intelligence capture on or off.", how_to: "Settings > Screen Intelligence", status: CapabilityStatus::Beta, + privacy: LOCAL_RAW, }, Capability { id: "screen_intelligence.manage_accessibility_permissions", @@ -444,6 +524,7 @@ const CAPABILITIES: &[Capability] = &[ description: "Review and grant the accessibility permissions required for desktop assistance.", how_to: "Onboarding > Screen permissions or Settings > Accessibility Automation", status: CapabilityStatus::Stable, + privacy: None, }, Capability { id: "screen_intelligence.review_vision_data", @@ -453,6 +534,7 @@ const CAPABILITIES: &[Capability] = &[ description: "Inspect the captured screen intelligence and related vision summaries.", how_to: "Settings > Screen Intelligence", status: CapabilityStatus::Beta, + privacy: LOCAL_RAW, }, Capability { id: "screen_intelligence.configure_capture_fps", @@ -462,6 +544,7 @@ const CAPABILITIES: &[Capability] = &[ description: "Tune the screen capture frame rate used by screen intelligence.", how_to: "Settings > Screen Intelligence", status: CapabilityStatus::Beta, + privacy: None, }, Capability { id: "screen_intelligence.app_whitelist", @@ -471,6 +554,7 @@ const CAPABILITIES: &[Capability] = &[ description: "Allow screen intelligence only for selected applications.", how_to: "Settings > Screen Intelligence", status: CapabilityStatus::Beta, + privacy: None, }, Capability { id: "screen_intelligence.app_blacklist", @@ -480,6 +564,7 @@ const CAPABILITIES: &[Capability] = &[ description: "Exclude selected applications from screen intelligence capture.", how_to: "Settings > Screen Intelligence", status: CapabilityStatus::Beta, + privacy: None, }, Capability { id: "channels.connect_platform", @@ -489,6 +574,7 @@ const CAPABILITIES: &[Capability] = &[ description: "Connect supported messaging platforms such as Telegram, Discord, or Slack.", how_to: "Settings > Messaging Channels", status: CapabilityStatus::Beta, + privacy: None, }, Capability { id: "channels.disconnect_platform", @@ -498,6 +584,7 @@ const CAPABILITIES: &[Capability] = &[ description: "Disconnect a previously configured messaging platform.", how_to: "Settings > Messaging Channels", status: CapabilityStatus::Beta, + privacy: None, }, Capability { id: "channels.test_credentials", @@ -507,6 +594,7 @@ const CAPABILITIES: &[Capability] = &[ description: "Validate platform credentials or connection state before using a channel.", how_to: "Settings > Messaging Channels", status: CapabilityStatus::Beta, + privacy: None, }, Capability { id: "channels.set_default_channel", @@ -516,6 +604,7 @@ const CAPABILITIES: &[Capability] = &[ description: "Choose which messaging channel should be used by default.", how_to: "Settings > Messaging Channels", status: CapabilityStatus::Beta, + privacy: None, }, Capability { id: "settings.configure_ai", @@ -525,6 +614,7 @@ const CAPABILITIES: &[Capability] = &[ description: "Adjust AI-related settings and agent behavior preferences.", how_to: "Settings > Developer Options > AI Configuration", status: CapabilityStatus::Stable, + privacy: None, }, Capability { id: "settings.manage_privacy_analytics", @@ -534,6 +624,7 @@ const CAPABILITIES: &[Capability] = &[ description: "Control privacy, analytics, and related data handling preferences.", how_to: "Settings > Privacy (direct route)", status: CapabilityStatus::Stable, + privacy: DIAGNOSTICS_TO_BACKEND, }, Capability { id: "settings.view_billing", @@ -543,6 +634,7 @@ const CAPABILITIES: &[Capability] = &[ description: "Open subscription, included usage, and pay-as-you-go billing views for your active team.", how_to: "Settings > Billing & Usage", status: CapabilityStatus::Stable, + privacy: None, }, Capability { id: "settings.manage_subscription_plan", @@ -552,6 +644,7 @@ const CAPABILITIES: &[Capability] = &[ description: "Upgrade plans or open the billing portal to manage subscription-backed usage tiers.", how_to: "Settings > Billing & Usage", status: CapabilityStatus::Stable, + privacy: None, }, Capability { id: "settings.manage_credits", @@ -561,6 +654,7 @@ const CAPABILITIES: &[Capability] = &[ description: "View pay-as-you-go credit balances, top up overage credits, and configure auto-recharge.", how_to: "Settings > Billing & Usage", status: CapabilityStatus::Stable, + privacy: None, }, Capability { id: "settings.add_payment_methods", @@ -570,6 +664,7 @@ const CAPABILITIES: &[Capability] = &[ description: "Add or manage saved payment methods for billing and auto-recharge.", how_to: "Settings > Billing & Usage > Payment Methods", status: CapabilityStatus::Stable, + privacy: None, }, Capability { id: "settings.developer_options", @@ -579,6 +674,7 @@ const CAPABILITIES: &[Capability] = &[ description: "Open developer-focused panels for diagnostics, skills, AI config, and memory tools.", how_to: "Settings > Developer Options", status: CapabilityStatus::Beta, + privacy: None, }, Capability { id: "settings.debug_webhooks", @@ -589,6 +685,7 @@ const CAPABILITIES: &[Capability] = &[ "Inspect Composio trigger history and find the daily JSONL archive files stored by the app.", how_to: "Settings > Developer Options > Webhooks", status: CapabilityStatus::Beta, + privacy: None, }, Capability { id: "settings.manage_service", @@ -598,6 +695,7 @@ const CAPABILITIES: &[Capability] = &[ description: "Install, start, stop, restart, uninstall, or inspect the optional desktop background service.", how_to: "Settings > Developer Options > Tauri Commands", status: CapabilityStatus::Stable, + privacy: None, }, Capability { id: "settings.clear_app_data", @@ -607,6 +705,7 @@ const CAPABILITIES: &[Capability] = &[ description: "Sign out and permanently clear local app data, including skills data.", how_to: "Settings > Log Out & Clear App Data", status: CapabilityStatus::Stable, + privacy: None, }, Capability { id: "settings.delete_all_data", @@ -616,6 +715,7 @@ const CAPABILITIES: &[Capability] = &[ description: "Delete all local data and reset the app from the destructive settings section.", how_to: "Settings > Delete All Data", status: CapabilityStatus::ComingSoon, + privacy: None, }, Capability { id: "automation.view_cron_jobs", @@ -625,6 +725,7 @@ const CAPABILITIES: &[Capability] = &[ description: "Review scheduled jobs available to the runtime.", how_to: "Settings > Cron Jobs", status: CapabilityStatus::Stable, + privacy: None, }, Capability { id: "automation.set_job_intervals", @@ -634,6 +735,7 @@ const CAPABILITIES: &[Capability] = &[ description: "Configure how often a scheduled job should run.", how_to: "Settings > Cron Jobs", status: CapabilityStatus::Stable, + privacy: None, }, Capability { id: "automation.view_execution_history", @@ -643,6 +745,7 @@ const CAPABILITIES: &[Capability] = &[ description: "Inspect past runs and results for scheduled jobs.", how_to: "Settings > Cron Jobs", status: CapabilityStatus::Beta, + privacy: None, }, // ── Proactive agents ───────────────────────────────────────────────────── Capability { @@ -653,6 +756,7 @@ const CAPABILITIES: &[Capability] = &[ description: "Daily proactive agent that reviews calendar, tasks, emails, and market context to deliver a morning summary.", how_to: "Automatic after onboarding (runs daily at 7 AM). Adjust schedule via Settings > Cron Jobs.", status: CapabilityStatus::Beta, + privacy: None, }, Capability { id: "automation.welcome_agent", @@ -662,6 +766,7 @@ const CAPABILITIES: &[Capability] = &[ description: "One-shot agent that delivers a personalized, witty welcome message after onboarding completes.", how_to: "Automatic — triggered once after onboarding.", status: CapabilityStatus::Beta, + privacy: None, }, // ── Update ────────────────────────────────────────────────────────────── Capability { @@ -672,6 +777,7 @@ const CAPABILITIES: &[Capability] = &[ description: "Query GitHub Releases to see if a newer core binary is available.", how_to: "Settings > Developer Options > Check for Updates", status: CapabilityStatus::Beta, + privacy: DIAGNOSTICS_TO_BACKEND, }, Capability { id: "update.apply", @@ -681,6 +787,7 @@ const CAPABILITIES: &[Capability] = &[ description: "Download and stage a newer core binary, then restart the sidecar.", how_to: "Settings > Developer Options > Apply Update", status: CapabilityStatus::Beta, + privacy: None, }, ]; @@ -798,6 +905,35 @@ mod tests { assert!(!capabilities.is_empty()); } + #[test] + fn annotated_capability_exposes_privacy_metadata() { + let cap = lookup("conversation.send_text").expect("capability exists"); + let privacy = cap.privacy.expect("conversation.send_text annotated"); + assert!(privacy.leaves_device); + assert_eq!(privacy.data_kind, PrivacyDataKind::Derived); + assert!(privacy.destinations.contains(&"OpenHuman backend")); + } + + #[test] + fn local_only_capability_marks_no_destinations() { + let cap = lookup("local_ai.embed_text").expect("capability exists"); + let privacy = cap.privacy.expect("local_ai.embed_text annotated"); + assert!(!privacy.leaves_device); + assert_eq!(privacy.data_kind, PrivacyDataKind::Raw); + assert!(privacy.destinations.is_empty()); + } + + #[test] + fn unannotated_capability_serializes_without_privacy_field() { + let cap = lookup("conversation.create").expect("capability exists"); + assert!(cap.privacy.is_none()); + let json = serde_json::to_value(cap).expect("serialize capability"); + assert!( + json.get("privacy").is_none(), + "privacy field must be omitted when None: {json}" + ); + } + #[test] fn catalog_includes_additional_user_facing_surfaces() { let ids: BTreeSet<&str> = all_capabilities() diff --git a/src/openhuman/about_app/mod.rs b/src/openhuman/about_app/mod.rs index fa548b3a6..b06758c19 100644 --- a/src/openhuman/about_app/mod.rs +++ b/src/openhuman/about_app/mod.rs @@ -14,4 +14,6 @@ pub use ops::{list_capabilities, lookup_capability, search_capabilities}; pub use schemas::{ about_app_schemas, all_about_app_controller_schemas, all_about_app_registered_controllers, }; -pub use types::{Capability, CapabilityCategory, CapabilityStatus}; +pub use types::{ + Capability, CapabilityCategory, CapabilityPrivacy, CapabilityStatus, PrivacyDataKind, +}; diff --git a/src/openhuman/about_app/types.rs b/src/openhuman/about_app/types.rs index 8b9b87776..74b8bb3d1 100644 --- a/src/openhuman/about_app/types.rs +++ b/src/openhuman/about_app/types.rs @@ -118,6 +118,39 @@ pub struct Capability { pub description: &'static str, pub how_to: &'static str, pub status: CapabilityStatus, + /// Optional privacy disclosure metadata. `None` means the capability has not + /// been annotated yet — UI should treat absence as "unknown", not "safe". + #[serde(skip_serializing_if = "Option::is_none")] + pub privacy: Option, +} + +/// Per-capability privacy disclosure consumed by the in-app Privacy surface. +/// +/// Source of truth for "what leaves my computer" — kept narrow on purpose so +/// every field is something the UI can render directly without further mapping. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +pub struct CapabilityPrivacy { + /// True when invoking this capability sends *some* data off the device. + pub leaves_device: bool, + /// Classifies what kind of data leaves (or stays). + pub data_kind: PrivacyDataKind, + /// Stable, human-readable destinations data may flow to (empty when local-only). + pub destinations: &'static [&'static str], +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum PrivacyDataKind { + /// Raw user content (messages, screen frames, audio) — kept local. + Raw, + /// Derived signals (embeddings, summaries, prompts) — may be sent to backends. + Derived, + /// OAuth tokens, API keys, wallet connections — stored locally, never logged. + Credentials, + /// Anonymous analytics, crash reports, version pings. + Diagnostics, + /// Non-sensitive metadata (capability ids, feature flags, settings shape). + Metadata, } #[cfg(test)]