From ddba7945a4a1a112897e999e51d9a657bb843d38 Mon Sep 17 00:00:00 2001 From: oxoxDev <164490987+oxoxDev@users.noreply.github.com> Date: Fri, 3 Jul 2026 23:47:03 +0530 Subject: [PATCH] feat(privacy): Privacy Mode + local-only inference enforcement (#4435) (#4446) --- .../panels/PrivacyModeSection.test.tsx | 89 ++++++++++++ .../settings/panels/PrivacyModeSection.tsx | 131 +++++++++++++++++ .../settings/panels/PrivacyPanel.tsx | 4 + app/src/lib/i18n/ar.ts | 12 ++ app/src/lib/i18n/bn.ts | 12 ++ app/src/lib/i18n/de.ts | 14 ++ app/src/lib/i18n/en.ts | 13 ++ app/src/lib/i18n/es.ts | 14 ++ app/src/lib/i18n/fr.ts | 15 ++ app/src/lib/i18n/hi.ts | 12 ++ app/src/lib/i18n/id.ts | 13 ++ app/src/lib/i18n/it.ts | 13 ++ app/src/lib/i18n/ko.ts | 13 ++ app/src/lib/i18n/pl.ts | 14 ++ app/src/lib/i18n/pt.ts | 13 ++ app/src/lib/i18n/ru.ts | 13 ++ app/src/lib/i18n/zh-CN.ts | 11 ++ app/src/services/rpcMethods.ts | 2 + src/core/jsonrpc.rs | 13 +- src/openhuman/channels/runtime/startup.rs | 13 +- src/openhuman/config/mod.rs | 17 ++- src/openhuman/config/ops/mod.rs | 5 + src/openhuman/config/ops/privacy.rs | 137 ++++++++++++++++++ src/openhuman/config/schema/mod.rs | 2 + src/openhuman/config/schema/privacy.rs | 117 +++++++++++++++ src/openhuman/config/schema/types.rs | 7 + src/openhuman/config/schemas/controllers.rs | 24 ++- src/openhuman/config/schemas/helpers.rs | 6 + src/openhuman/config/schemas/schema_defs.rs | 16 ++ src/openhuman/inference/provider/factory.rs | 89 ++++++++++++ .../inference/provider/factory_tests.rs | 104 +++++++++++++ src/openhuman/security/live_policy.rs | 128 +++++++++++++++- src/openhuman/security/policy/enforcement.rs | 35 +++++ src/openhuman/security/policy/types.rs | 14 ++ 34 files changed, 1111 insertions(+), 24 deletions(-) create mode 100644 app/src/components/settings/panels/PrivacyModeSection.test.tsx create mode 100644 app/src/components/settings/panels/PrivacyModeSection.tsx create mode 100644 src/openhuman/config/ops/privacy.rs create mode 100644 src/openhuman/config/schema/privacy.rs diff --git a/app/src/components/settings/panels/PrivacyModeSection.test.tsx b/app/src/components/settings/panels/PrivacyModeSection.test.tsx new file mode 100644 index 000000000..a38d8ca6c --- /dev/null +++ b/app/src/components/settings/panels/PrivacyModeSection.test.tsx @@ -0,0 +1,89 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import PrivacyModeSection from './PrivacyModeSection'; + +const callCoreRpc = vi.fn(); +vi.mock('../../../services/coreRpcClient', () => ({ + callCoreRpc: (arg: { method: string; params: unknown }) => callCoreRpc(arg), +})); + +beforeEach(() => { + vi.clearAllMocks(); + callCoreRpc.mockImplementation((arg: { method: string; params: { mode?: string } }) => { + if (arg.method === 'openhuman.config_get_privacy_mode') { + return Promise.resolve({ result: { mode: 'standard' } }); + } + if (arg.method === 'openhuman.config_set_privacy_mode') { + return Promise.resolve({ result: { mode: arg.params.mode } }); + } + return Promise.reject(new Error(`unexpected method ${arg.method}`)); + }); +}); + +describe('PrivacyModeSection', () => { + it('renders the three privacy mode options', async () => { + render(); + await waitFor(() => + expect(screen.getByTestId('privacy-mode-option-standard')).toBeInTheDocument() + ); + expect(screen.getByTestId('privacy-mode-option-local_only')).toBeInTheDocument(); + expect(screen.getByTestId('privacy-mode-option-standard')).toBeInTheDocument(); + expect(screen.getByTestId('privacy-mode-option-sensitive')).toBeInTheDocument(); + }); + + it('marks the loaded mode as selected', async () => { + render(); + await waitFor(() => + expect(screen.getByTestId('privacy-mode-option-standard')).toHaveAttribute( + 'aria-checked', + 'true' + ) + ); + expect(screen.getByTestId('privacy-mode-option-local_only')).toHaveAttribute( + 'aria-checked', + 'false' + ); + }); + + it('calls the set RPC with the chosen mode on selection', async () => { + render(); + await waitFor(() => + expect(screen.getByTestId('privacy-mode-option-local_only')).toBeInTheDocument() + ); + + fireEvent.click(screen.getByTestId('privacy-mode-option-local_only')); + + await waitFor(() => + expect(callCoreRpc).toHaveBeenCalledWith({ + method: 'openhuman.config_set_privacy_mode', + params: { mode: 'local_only' }, + }) + ); + + // After the set resolves, the newly-chosen mode is selected. + await waitFor(() => + expect(screen.getByTestId('privacy-mode-option-local_only')).toHaveAttribute( + 'aria-checked', + 'true' + ) + ); + }); + + it('does not re-issue the set RPC when the current mode is clicked', async () => { + render(); + await waitFor(() => + expect(screen.getByTestId('privacy-mode-option-standard')).toHaveAttribute( + 'aria-checked', + 'true' + ) + ); + + fireEvent.click(screen.getByTestId('privacy-mode-option-standard')); + + // Only the initial get RPC should have fired — no set for an unchanged mode. + expect( + callCoreRpc.mock.calls.filter(c => c[0].method === 'openhuman.config_set_privacy_mode') + ).toHaveLength(0); + }); +}); diff --git a/app/src/components/settings/panels/PrivacyModeSection.tsx b/app/src/components/settings/panels/PrivacyModeSection.tsx new file mode 100644 index 000000000..3eb01ef83 --- /dev/null +++ b/app/src/components/settings/panels/PrivacyModeSection.tsx @@ -0,0 +1,131 @@ +import debug from 'debug'; +import { useCallback, useEffect, useState } from 'react'; + +import { useT } from '../../../lib/i18n/I18nContext'; +import { callCoreRpc } from '../../../services/coreRpcClient'; +import { CORE_RPC_METHODS } from '../../../services/rpcMethods'; +import { SettingsSection, SettingsStatusLine } from '../controls'; + +const log = debug('privacy-mode'); + +/** Privacy Mode values as serialized by the Rust core (snake_case). */ +export type PrivacyMode = 'local_only' | 'standard' | 'sensitive'; + +interface PrivacyModeResult { + mode: PrivacyMode; +} + +type Status = 'loading' | 'idle' | 'saving' | 'saved' | 'error'; + +const MODES: { value: PrivacyMode; labelKey: string; descKey: string }[] = [ + { + value: 'local_only', + labelKey: 'privacy.mode.localOnly', + descKey: 'privacy.mode.localOnlyDesc', + }, + { value: 'standard', labelKey: 'privacy.mode.standard', descKey: 'privacy.mode.standardDesc' }, + { value: 'sensitive', labelKey: 'privacy.mode.sensitive', descKey: 'privacy.mode.sensitiveDesc' }, +]; + +/** + * Privacy Mode selector (#4435). Reads and writes the data-egress posture + * (local_only | standard | sensitive) via the core RPCs. Distinct from the + * autonomy access mode. Rendered inside {@link PrivacyPanel}, but kept a + * standalone component so it can be unit-tested without the CoreStateProvider. + */ +const PrivacyModeSection = () => { + const { t } = useT(); + const [mode, setMode] = useState(null); + const [status, setStatus] = useState('loading'); + const [error, setError] = useState(null); + + useEffect(() => { + let cancelled = false; + log('[privacy-mode] fetching current mode'); + callCoreRpc<{ result: PrivacyModeResult }>({ + method: CORE_RPC_METHODS.configGetPrivacyMode, + params: {}, + }) + .then(resp => { + if (cancelled) return; + log('[privacy-mode] current mode', resp.result.mode); + setMode(resp.result.mode); + setStatus('idle'); + }) + .catch(err => { + if (cancelled) return; + console.warn('[privacy-mode] failed to load privacy mode:', err); + setError(err instanceof Error ? err.message : String(err)); + setStatus('error'); + }); + return () => { + cancelled = true; + }; + }, []); + + const handleSelect = useCallback( + async (next: PrivacyMode) => { + if (next === mode) return; + log('[privacy-mode] setting mode', next); + setStatus('saving'); + setError(null); + try { + const resp = await callCoreRpc<{ result: PrivacyModeResult }>({ + method: CORE_RPC_METHODS.configSetPrivacyMode, + params: { mode: next }, + }); + setMode(resp.result.mode); + setStatus('saved'); + setTimeout(() => setStatus('idle'), 2000); + } catch (err) { + console.warn('[privacy-mode] failed to set privacy mode:', err); + setError(err instanceof Error ? err.message : String(err)); + setStatus('error'); + } + }, + [mode] + ); + + return ( + + + + {t('privacy.mode.description')} + + + {MODES.map(({ value, labelKey, descKey }) => { + const isSelected = mode === value; + return ( + { + void handleSelect(value); + }} + data-testid={`privacy-mode-option-${value}`} + className={`w-full text-left px-4 py-3 rounded-lg border transition-colors ${ + isSelected + ? 'border-primary-500 bg-primary-50 dark:bg-primary-900/20' + : 'border-line bg-surface hover:border-line-strong dark:hover:border-line-strong' + } ${status === 'saving' ? 'opacity-50' : ''}`}> + {t(labelKey)} + {t(descKey)} + + ); + })} + + + + + ); +}; + +export default PrivacyModeSection; diff --git a/app/src/components/settings/panels/PrivacyPanel.tsx b/app/src/components/settings/panels/PrivacyPanel.tsx index 55e74d0d9..c9df24a8f 100644 --- a/app/src/components/settings/panels/PrivacyPanel.tsx +++ b/app/src/components/settings/panels/PrivacyPanel.tsx @@ -17,6 +17,7 @@ import { SettingsSwitch, } from '../controls'; import SettingsPanel from '../layout/SettingsPanel'; +import PrivacyModeSection from './PrivacyModeSection'; const log = debug('privacy-panel'); @@ -102,6 +103,9 @@ const PrivacyPanel = () => { testId="settings-privacy-panel" description={t('pages.settings.account.privacyDesc')}> <> + {/* Privacy Mode selector (#4435) — data-egress posture */} + + {/* What leaves my computer */} {loadState === 'loading' && ( diff --git a/app/src/lib/i18n/ar.ts b/app/src/lib/i18n/ar.ts index fbde7a517..768a3ce7c 100644 --- a/app/src/lib/i18n/ar.ts +++ b/app/src/lib/i18n/ar.ts @@ -3,6 +3,18 @@ import type { TranslationMap } from './types'; // Arabic (العربية) translations. Keys mirror en.ts; missing/ // English-identical values fall back to English via I18nContext.resolveEn(). const messages: TranslationMap = { + // Privacy Mode selector (#4435) — data-egress posture + 'privacy.mode.title': 'وضع الخصوصية', + 'privacy.mode.description': + 'يتحكم في مقدار بياناتك التي يمكن أن تغادر هذا الجهاز. منفصل عن وضع وصول الوكيل.', + 'privacy.mode.localOnly': 'محلي فقط', + 'privacy.mode.localOnlyDesc': 'النماذج على الجهاز فقط. يتم حظر استدعاءات النماذج الخارجية.', + 'privacy.mode.standard': 'قياسي', + 'privacy.mode.standardDesc': 'متوازن. تُستخدم النماذج الخارجية وفق الإعدادات.', + 'privacy.mode.sensitive': 'حساس', + 'privacy.mode.sensitiveDesc': 'عناية إضافية بالبيانات الحساسة. المزيد من الضوابط قريبًا.', + 'privacy.mode.saved': 'تم الحفظ', + 'privacy.mode.saveError': 'تعذّر تحديث وضع الخصوصية.', 'skills.recallCalendar.title': 'تقويم Google', 'skills.recallCalendar.description': 'الانضمام تلقائيًا إلى مكالمات Google Meet عبر Recall.ai', // Cross-host vault (#4278) diff --git a/app/src/lib/i18n/bn.ts b/app/src/lib/i18n/bn.ts index 3f9ed538d..75b346fcc 100644 --- a/app/src/lib/i18n/bn.ts +++ b/app/src/lib/i18n/bn.ts @@ -3,6 +3,18 @@ import type { TranslationMap } from './types'; // Bengali (বাংলা) translations. Keys mirror en.ts; missing/ // English-identical values fall back to English via I18nContext.resolveEn(). const messages: TranslationMap = { + // Privacy Mode selector (#4435) — data-egress posture + 'privacy.mode.title': 'গোপনীয়তা মোড', + 'privacy.mode.description': + 'আপনার কতটা ডেটা এই ডিভাইস থেকে বের হতে পারে তা নিয়ন্ত্রণ করে। এজেন্ট অ্যাক্সেস মোড থেকে আলাদা।', + 'privacy.mode.localOnly': 'শুধু লোকাল', + 'privacy.mode.localOnlyDesc': 'শুধুমাত্র ডিভাইসে থাকা মডেল। বাহ্যিক মডেল কল ব্লক করা হয়।', + 'privacy.mode.standard': 'স্ট্যান্ডার্ড', + 'privacy.mode.standardDesc': 'ভারসাম্যপূর্ণ। বাহ্যিক মডেল কনফিগার অনুযায়ী ব্যবহৃত হয়।', + 'privacy.mode.sensitive': 'সংবেদনশীল', + 'privacy.mode.sensitiveDesc': 'সংবেদনশীল ডেটার জন্য অতিরিক্ত যত্ন। আরও নিয়ন্ত্রণ শীঘ্রই আসছে।', + 'privacy.mode.saved': 'সংরক্ষিত', + 'privacy.mode.saveError': 'গোপনীয়তা মোড আপডেট করা যায়নি।', 'skills.recallCalendar.title': 'Google Calendar', 'skills.recallCalendar.description': 'Recall.ai-এর মাধ্যমে Google Meet কলে স্বয়ংক্রিয়ভাবে যোগ দিন', diff --git a/app/src/lib/i18n/de.ts b/app/src/lib/i18n/de.ts index 8d2ba158d..f62d8c2a1 100644 --- a/app/src/lib/i18n/de.ts +++ b/app/src/lib/i18n/de.ts @@ -3,6 +3,20 @@ import type { TranslationMap } from './types'; // German (Deutsch) translations. Keys mirror en.ts; missing/ // English-identical values fall back to English via I18nContext.resolveEn(). const messages: TranslationMap = { + // Privacy Mode selector (#4435) — data-egress posture + 'privacy.mode.title': 'Datenschutzmodus', + 'privacy.mode.description': + 'Steuert, wie viele Ihrer Daten dieses Gerät verlassen dürfen. Getrennt vom Agenten-Zugriffsmodus.', + 'privacy.mode.localOnly': 'Nur lokal', + 'privacy.mode.localOnlyDesc': + 'Nur Modelle auf dem Gerät. Externe Modellaufrufe werden blockiert.', + 'privacy.mode.standard': 'Standard', + 'privacy.mode.standardDesc': 'Ausgewogen. Externe Modelle werden wie konfiguriert verwendet.', + 'privacy.mode.sensitive': 'Sensibel', + 'privacy.mode.sensitiveDesc': + 'Besondere Vorsicht bei sensiblen Daten. Weitere Optionen folgen bald.', + 'privacy.mode.saved': 'Gespeichert', + 'privacy.mode.saveError': 'Datenschutzmodus konnte nicht aktualisiert werden.', 'skills.recallCalendar.title': 'Google Kalender', 'skills.recallCalendar.description': 'Google Meet-Anrufen automatisch über Recall.ai beitreten', // Cross-host vault (#4278) diff --git a/app/src/lib/i18n/en.ts b/app/src/lib/i18n/en.ts index 7111c751b..4d7723fd3 100644 --- a/app/src/lib/i18n/en.ts +++ b/app/src/lib/i18n/en.ts @@ -1460,6 +1460,19 @@ const en: TranslationMap = { 'privacy.analyticsDisclaimer': 'When enabled, Product Analytics and Diagnostics may include privacy-limited crash reports and usage events, a stable account ID, and app version metadata. Messages, wallet keys, API keys, and session tokens are never collected. You can change this setting at any time.', + // Privacy Mode selector (#4435) — data-egress posture + 'privacy.mode.title': 'Privacy Mode', + 'privacy.mode.description': + 'Controls how much of your data can leave this device. Separate from the agent access mode.', + 'privacy.mode.localOnly': 'Local-only', + 'privacy.mode.localOnlyDesc': 'Only on-device models. External model calls are blocked.', + 'privacy.mode.standard': 'Standard', + 'privacy.mode.standardDesc': 'Balanced. External models are used as configured.', + 'privacy.mode.sensitive': 'Sensitive', + 'privacy.mode.sensitiveDesc': 'Extra care for sensitive data. More controls coming soon.', + 'privacy.mode.saved': 'Saved', + 'privacy.mode.saveError': 'Could not update privacy mode.', + // Settings: About 'settings.about.version': 'Version', 'settings.about.updateAvailable': 'is available', diff --git a/app/src/lib/i18n/es.ts b/app/src/lib/i18n/es.ts index ca549b36c..2544b1140 100644 --- a/app/src/lib/i18n/es.ts +++ b/app/src/lib/i18n/es.ts @@ -3,6 +3,20 @@ import type { TranslationMap } from './types'; // Spanish (Español) translations. Keys mirror en.ts; missing/ // English-identical values fall back to English via I18nContext.resolveEn(). const messages: TranslationMap = { + // Privacy Mode selector (#4435) — data-egress posture + 'privacy.mode.title': 'Modo de privacidad', + 'privacy.mode.description': + 'Controla cuántos de tus datos pueden salir de este dispositivo. Independiente del modo de acceso del agente.', + 'privacy.mode.localOnly': 'Solo local', + 'privacy.mode.localOnlyDesc': + 'Solo modelos en el dispositivo. Se bloquean las llamadas a modelos externos.', + 'privacy.mode.standard': 'Estándar', + 'privacy.mode.standardDesc': 'Equilibrado. Los modelos externos se usan según la configuración.', + 'privacy.mode.sensitive': 'Sensible', + 'privacy.mode.sensitiveDesc': + 'Cuidado adicional con datos sensibles. Pronto habrá más controles.', + 'privacy.mode.saved': 'Guardado', + 'privacy.mode.saveError': 'No se pudo actualizar el modo de privacidad.', 'skills.recallCalendar.title': 'Google Calendar', 'skills.recallCalendar.description': 'Unirse automáticamente a las llamadas de Google Meet con Recall.ai', diff --git a/app/src/lib/i18n/fr.ts b/app/src/lib/i18n/fr.ts index 5caf845b2..85fbfd4bc 100644 --- a/app/src/lib/i18n/fr.ts +++ b/app/src/lib/i18n/fr.ts @@ -3,6 +3,21 @@ import type { TranslationMap } from './types'; // French (Français) translations. Keys mirror en.ts; missing/ // English-identical values fall back to English via I18nContext.resolveEn(). const messages: TranslationMap = { + // Privacy Mode selector (#4435) — data-egress posture + 'privacy.mode.title': 'Mode de confidentialité', + 'privacy.mode.description': + 'Contrôle la quantité de vos données pouvant quitter cet appareil. Distinct du mode d’accès de l’agent.', + 'privacy.mode.localOnly': 'Local uniquement', + 'privacy.mode.localOnlyDesc': + 'Uniquement les modèles sur l’appareil. Les appels aux modèles externes sont bloqués.', + 'privacy.mode.standard': 'Standard', + 'privacy.mode.standardDesc': + 'Équilibré. Les modèles externes sont utilisés selon la configuration.', + 'privacy.mode.sensitive': 'Sensible', + 'privacy.mode.sensitiveDesc': + 'Précautions supplémentaires pour les données sensibles. D’autres contrôles arrivent bientôt.', + 'privacy.mode.saved': 'Enregistré', + 'privacy.mode.saveError': 'Impossible de mettre à jour le mode de confidentialité.', 'skills.recallCalendar.title': 'Google Agenda', 'skills.recallCalendar.description': 'Rejoindre automatiquement les appels Google Meet via Recall.ai', diff --git a/app/src/lib/i18n/hi.ts b/app/src/lib/i18n/hi.ts index 7db36e667..d84d9f765 100644 --- a/app/src/lib/i18n/hi.ts +++ b/app/src/lib/i18n/hi.ts @@ -3,6 +3,18 @@ import type { TranslationMap } from './types'; // Hindi (हिन्दी) translations. Keys mirror en.ts; missing/ // English-identical values fall back to English via I18nContext.resolveEn(). const messages: TranslationMap = { + // Privacy Mode selector (#4435) — data-egress posture + 'privacy.mode.title': 'गोपनीयता मोड', + 'privacy.mode.description': + 'नियंत्रित करता है कि आपका कितना डेटा इस डिवाइस से बाहर जा सकता है। एजेंट एक्सेस मोड से अलग।', + 'privacy.mode.localOnly': 'केवल लोकल', + 'privacy.mode.localOnlyDesc': 'केवल डिवाइस पर मौजूद मॉडल। बाहरी मॉडल कॉल अवरुद्ध हैं।', + 'privacy.mode.standard': 'मानक', + 'privacy.mode.standardDesc': 'संतुलित। बाहरी मॉडल कॉन्फ़िगरेशन के अनुसार उपयोग किए जाते हैं।', + 'privacy.mode.sensitive': 'संवेदनशील', + 'privacy.mode.sensitiveDesc': 'संवेदनशील डेटा के लिए अतिरिक्त सावधानी। अधिक नियंत्रण जल्द ही।', + 'privacy.mode.saved': 'सहेजा गया', + 'privacy.mode.saveError': 'गोपनीयता मोड अपडेट नहीं किया जा सका।', 'skills.recallCalendar.title': 'Google Calendar', 'skills.recallCalendar.description': 'Recall.ai के ज़रिए Google Meet कॉल में अपने-आप शामिल हों', // Cross-host vault (#4278) diff --git a/app/src/lib/i18n/id.ts b/app/src/lib/i18n/id.ts index 76c8941be..580daaffa 100644 --- a/app/src/lib/i18n/id.ts +++ b/app/src/lib/i18n/id.ts @@ -3,6 +3,19 @@ import type { TranslationMap } from './types'; // Indonesian (Bahasa Indonesia) translations. Keys mirror en.ts; missing/ // English-identical values fall back to English via I18nContext.resolveEn(). const messages: TranslationMap = { + // Privacy Mode selector (#4435) — data-egress posture + 'privacy.mode.title': 'Mode Privasi', + 'privacy.mode.description': + 'Mengontrol seberapa banyak data Anda yang boleh keluar dari perangkat ini. Terpisah dari mode akses agen.', + 'privacy.mode.localOnly': 'Hanya lokal', + 'privacy.mode.localOnlyDesc': 'Hanya model di perangkat. Panggilan model eksternal diblokir.', + 'privacy.mode.standard': 'Standar', + 'privacy.mode.standardDesc': 'Seimbang. Model eksternal digunakan sesuai konfigurasi.', + 'privacy.mode.sensitive': 'Sensitif', + 'privacy.mode.sensitiveDesc': + 'Perhatian ekstra untuk data sensitif. Kontrol lainnya segera hadir.', + 'privacy.mode.saved': 'Tersimpan', + 'privacy.mode.saveError': 'Tidak dapat memperbarui mode privasi.', 'skills.recallCalendar.title': 'Google Kalender', 'skills.recallCalendar.description': 'Bergabung otomatis ke panggilan Google Meet melalui Recall.ai', diff --git a/app/src/lib/i18n/it.ts b/app/src/lib/i18n/it.ts index 613a8cbca..b89330a1d 100644 --- a/app/src/lib/i18n/it.ts +++ b/app/src/lib/i18n/it.ts @@ -3,6 +3,19 @@ import type { TranslationMap } from './types'; // Italian (Italiano) translations. Keys mirror en.ts; missing/ // English-identical values fall back to English via I18nContext.resolveEn(). const messages: TranslationMap = { + // Privacy Mode selector (#4435) — data-egress posture + 'privacy.mode.title': 'Modalità privacy', + 'privacy.mode.description': + 'Controlla quanti dei tuoi dati possono lasciare questo dispositivo. Distinta dalla modalità di accesso dell’agente.', + 'privacy.mode.localOnly': 'Solo locale', + 'privacy.mode.localOnlyDesc': + 'Solo modelli sul dispositivo. Le chiamate a modelli esterni sono bloccate.', + 'privacy.mode.standard': 'Standard', + 'privacy.mode.standardDesc': 'Bilanciata. I modelli esterni vengono usati come configurato.', + 'privacy.mode.sensitive': 'Sensibile', + 'privacy.mode.sensitiveDesc': 'Maggiore attenzione ai dati sensibili. Altri controlli in arrivo.', + 'privacy.mode.saved': 'Salvato', + 'privacy.mode.saveError': 'Impossibile aggiornare la modalità privacy.', 'skills.recallCalendar.title': 'Google Calendar', 'skills.recallCalendar.description': 'Partecipa automaticamente alle chiamate Google Meet tramite Recall.ai', diff --git a/app/src/lib/i18n/ko.ts b/app/src/lib/i18n/ko.ts index 0a5c175f4..79ce0da9e 100644 --- a/app/src/lib/i18n/ko.ts +++ b/app/src/lib/i18n/ko.ts @@ -3,6 +3,19 @@ import type { TranslationMap } from './types'; // Korean (한국어) translations. Keys mirror en.ts; missing/ // English-identical values fall back to English via I18nContext.resolveEn(). const messages: TranslationMap = { + // Privacy Mode selector (#4435) — data-egress posture + 'privacy.mode.title': '개인정보 보호 모드', + 'privacy.mode.description': + '이 기기에서 나갈 수 있는 데이터의 양을 제어합니다. 에이전트 접근 모드와 별개입니다.', + 'privacy.mode.localOnly': '로컬 전용', + 'privacy.mode.localOnlyDesc': '기기 내 모델만 사용합니다. 외부 모델 호출이 차단됩니다.', + 'privacy.mode.standard': '표준', + 'privacy.mode.standardDesc': '균형. 외부 모델은 설정에 따라 사용됩니다.', + 'privacy.mode.sensitive': '민감', + 'privacy.mode.sensitiveDesc': + '민감한 데이터에 대한 추가 주의. 더 많은 제어 기능이 곧 제공됩니다.', + 'privacy.mode.saved': '저장됨', + 'privacy.mode.saveError': '개인정보 보호 모드를 업데이트할 수 없습니다.', 'skills.recallCalendar.title': 'Google 캘린더', 'skills.recallCalendar.description': 'Recall.ai를 통해 Google Meet 통화에 자동 참여', // Cross-host vault (#4278) diff --git a/app/src/lib/i18n/pl.ts b/app/src/lib/i18n/pl.ts index 971aa3172..2e92baa75 100644 --- a/app/src/lib/i18n/pl.ts +++ b/app/src/lib/i18n/pl.ts @@ -3,6 +3,20 @@ import type { TranslationMap } from './types'; // Polish (Polski) translations. Keys mirror en.ts; missing/ // English-identical values fall back to English via I18nContext.resolveEn(). const messages: TranslationMap = { + // Privacy Mode selector (#4435) — data-egress posture + 'privacy.mode.title': 'Tryb prywatności', + 'privacy.mode.description': + 'Określa, ile Twoich danych może opuścić to urządzenie. Niezależny od trybu dostępu agenta.', + 'privacy.mode.localOnly': 'Tylko lokalnie', + 'privacy.mode.localOnlyDesc': + 'Tylko modele na urządzeniu. Wywołania modeli zewnętrznych są blokowane.', + 'privacy.mode.standard': 'Standardowy', + 'privacy.mode.standardDesc': 'Zrównoważony. Modele zewnętrzne są używane zgodnie z konfiguracją.', + 'privacy.mode.sensitive': 'Wrażliwy', + 'privacy.mode.sensitiveDesc': + 'Dodatkowa ostrożność w przypadku danych wrażliwych. Więcej opcji wkrótce.', + 'privacy.mode.saved': 'Zapisano', + 'privacy.mode.saveError': 'Nie można zaktualizować trybu prywatności.', 'skills.recallCalendar.title': 'Kalendarz Google', 'skills.recallCalendar.description': 'Automatyczne dołączanie do połączeń Google Meet przez Recall.ai', diff --git a/app/src/lib/i18n/pt.ts b/app/src/lib/i18n/pt.ts index 15755004d..9d644b81c 100644 --- a/app/src/lib/i18n/pt.ts +++ b/app/src/lib/i18n/pt.ts @@ -3,6 +3,19 @@ import type { TranslationMap } from './types'; // Portuguese (Português) translations. Keys mirror en.ts; missing/ // English-identical values fall back to English via I18nContext.resolveEn(). const messages: TranslationMap = { + // Privacy Mode selector (#4435) — data-egress posture + 'privacy.mode.title': 'Modo de privacidade', + 'privacy.mode.description': + 'Controla quanto dos seus dados pode sair deste dispositivo. Separado do modo de acesso do agente.', + 'privacy.mode.localOnly': 'Apenas local', + 'privacy.mode.localOnlyDesc': + 'Apenas modelos no dispositivo. Chamadas a modelos externos são bloqueadas.', + 'privacy.mode.standard': 'Padrão', + 'privacy.mode.standardDesc': 'Equilibrado. Modelos externos são usados conforme configurado.', + 'privacy.mode.sensitive': 'Sensível', + 'privacy.mode.sensitiveDesc': 'Cuidado extra com dados sensíveis. Mais controles em breve.', + 'privacy.mode.saved': 'Salvo', + 'privacy.mode.saveError': 'Não foi possível atualizar o modo de privacidade.', 'skills.recallCalendar.title': 'Google Agenda', 'skills.recallCalendar.description': 'Entrar automaticamente nas chamadas do Google Meet via Recall.ai', diff --git a/app/src/lib/i18n/ru.ts b/app/src/lib/i18n/ru.ts index b7a17b96e..f2ded5023 100644 --- a/app/src/lib/i18n/ru.ts +++ b/app/src/lib/i18n/ru.ts @@ -3,6 +3,19 @@ import type { TranslationMap } from './types'; // Russian (Русский) translations. Keys mirror en.ts; missing/ // English-identical values fall back to English via I18nContext.resolveEn(). const messages: TranslationMap = { + // Privacy Mode selector (#4435) — data-egress posture + 'privacy.mode.title': 'Режим конфиденциальности', + 'privacy.mode.description': + 'Определяет, какой объём ваших данных может покидать это устройство. Отдельно от режима доступа агента.', + 'privacy.mode.localOnly': 'Только локально', + 'privacy.mode.localOnlyDesc': 'Только модели на устройстве. Вызовы внешних моделей блокируются.', + 'privacy.mode.standard': 'Стандартный', + 'privacy.mode.standardDesc': 'Сбалансированный. Внешние модели используются согласно настройкам.', + 'privacy.mode.sensitive': 'Конфиденциальный', + 'privacy.mode.sensitiveDesc': + 'Повышенная осторожность с конфиденциальными данными. Больше настроек скоро.', + 'privacy.mode.saved': 'Сохранено', + 'privacy.mode.saveError': 'Не удалось обновить режим конфиденциальности.', 'skills.recallCalendar.title': 'Google Календарь', 'skills.recallCalendar.description': 'Автоматически подключаться к звонкам Google Meet через Recall.ai', diff --git a/app/src/lib/i18n/zh-CN.ts b/app/src/lib/i18n/zh-CN.ts index 78453ad35..08df99415 100644 --- a/app/src/lib/i18n/zh-CN.ts +++ b/app/src/lib/i18n/zh-CN.ts @@ -3,6 +3,17 @@ import type { TranslationMap } from './types'; // Simplified Chinese (简体中文) translations. Keys mirror en.ts; missing/ // English-identical values fall back to English via I18nContext.resolveEn(). const messages: TranslationMap = { + // Privacy Mode selector (#4435) — data-egress posture + 'privacy.mode.title': '隐私模式', + 'privacy.mode.description': '控制有多少数据可以离开此设备。与代理访问模式相互独立。', + 'privacy.mode.localOnly': '仅本地', + 'privacy.mode.localOnlyDesc': '仅使用设备端模型。外部模型调用将被阻止。', + 'privacy.mode.standard': '标准', + 'privacy.mode.standardDesc': '均衡。按配置使用外部模型。', + 'privacy.mode.sensitive': '敏感', + 'privacy.mode.sensitiveDesc': '对敏感数据格外谨慎。更多控制项即将推出。', + 'privacy.mode.saved': '已保存', + 'privacy.mode.saveError': '无法更新隐私模式。', 'skills.recallCalendar.title': 'Google 日历', 'skills.recallCalendar.description': '通过 Recall.ai 自动加入 Google Meet 通话', // Cross-host vault (#4278) diff --git a/app/src/services/rpcMethods.ts b/app/src/services/rpcMethods.ts index 5ca71cb7b..c75732386 100644 --- a/app/src/services/rpcMethods.ts +++ b/app/src/services/rpcMethods.ts @@ -8,9 +8,11 @@ export const CORE_RPC_METHODS = { configGetDashboardSettings: 'openhuman.config_get_dashboard_settings', configGetRuntimeFlags: 'openhuman.config_get_runtime_flags', configGetMemorySyncSettings: 'openhuman.config_get_memory_sync_settings', + configGetPrivacyMode: 'openhuman.config_get_privacy_mode', configGetSandboxSettings: 'openhuman.config_get_sandbox_settings', configGetSearchSettings: 'openhuman.config_get_search_settings', configGetSuperContextEnabled: 'openhuman.config_get_super_context_enabled', + configSetPrivacyMode: 'openhuman.config_set_privacy_mode', configSetSuperContextEnabled: 'openhuman.config_set_super_context_enabled', configUpdateSearchSettings: 'openhuman.config_update_search_settings', configSetBrowserAllowAll: 'openhuman.config_set_browser_allow_all', diff --git a/src/core/jsonrpc.rs b/src/core/jsonrpc.rs index a5dae3988..c864086c2 100644 --- a/src/core/jsonrpc.rs +++ b/src/core/jsonrpc.rs @@ -2545,11 +2545,14 @@ pub async fn bootstrap_core_runtime(host_kind: crate::core::types::HostKind) { // policy. let action_dir = cfg.action_dir.clone(); crate::openhuman::security::live_policy::install( - std::sync::Arc::new(crate::openhuman::security::SecurityPolicy::from_config( - &cfg.autonomy, - &workspace_dir, - &action_dir, - )), + std::sync::Arc::new( + crate::openhuman::security::SecurityPolicy::from_config( + &cfg.autonomy, + &workspace_dir, + &action_dir, + ) + .with_privacy_mode(cfg.privacy.mode), + ), workspace_dir.clone(), action_dir, ); diff --git a/src/openhuman/channels/runtime/startup.rs b/src/openhuman/channels/runtime/startup.rs index f337adc3f..9a5110505 100644 --- a/src/openhuman/channels/runtime/startup.rs +++ b/src/openhuman/channels/runtime/startup.rs @@ -264,11 +264,14 @@ pub async fn start_channels(mut config: Config) -> Result<()> { // (config.update_autonomy_settings) are reflected by `live_policy::current()` // and picked up by the next session. let security = crate::openhuman::security::live_policy::install( - Arc::new(SecurityPolicy::from_config( - &config.autonomy, - &config.workspace_dir, - &config.action_dir, - )), + Arc::new( + SecurityPolicy::from_config( + &config.autonomy, + &config.workspace_dir, + &config.action_dir, + ) + .with_privacy_mode(config.privacy.mode), + ), config.workspace_dir.clone(), config.action_dir.clone(), ); diff --git a/src/openhuman/config/mod.rs b/src/openhuman/config/mod.rs index dd722d2b2..3617993aa 100644 --- a/src/openhuman/config/mod.rs +++ b/src/openhuman/config/mod.rs @@ -40,14 +40,15 @@ pub use schema::{ LearningConfig, LlmBackend, LocalAiConfig, MatrixConfig, McpAuthConfig, McpClientConfig, McpClientIdentityConfig, McpServerConfig, MeetConfig, MemoryConfig, MemoryTreeConfig, ModelRouteConfig, MultimodalConfig, MultimodalFileConfig, ObservabilityConfig, - OrchestratorModelConfig, PolymarketClobCredentials, PolymarketConfig, ProxyConfig, ProxyScope, - ReflectionSource, ReliabilityConfig, ResourceLimitsConfig, RuntimeConfig, SandboxBackend, - SandboxConfig, SchedulerConfig, SchedulerGateConfig, SchedulerGateMode, - ScreenIntelligenceConfig, SearchConfig, SearchEngine, SearchEngineCredentials, SearxngConfig, - SecretsConfig, SecurityConfig, ShellConfig, SlackConfig, StorageConfig, StorageProviderConfig, - StorageProviderSection, StreamMode, TeamModelConfig, TelegramConfig, TokenjuiceConfig, - UpdateConfig, UpdateRestartStrategy, VoiceActivationMode, VoiceServerConfig, WebSearchConfig, - WebhookConfig, DEFAULT_CLOUD_LLM_MODEL, DEFAULT_MEMORY_SYNC_INTERVAL_SECS, DEFAULT_MODEL, + OrchestratorModelConfig, PolymarketClobCredentials, PolymarketConfig, PrivacyConfig, + PrivacyMode, ProxyConfig, ProxyScope, ReflectionSource, ReliabilityConfig, + ResourceLimitsConfig, RuntimeConfig, SandboxBackend, SandboxConfig, SchedulerConfig, + SchedulerGateConfig, SchedulerGateMode, ScreenIntelligenceConfig, SearchConfig, SearchEngine, + SearchEngineCredentials, SearxngConfig, SecretsConfig, SecurityConfig, ShellConfig, + SlackConfig, StorageConfig, StorageProviderConfig, StorageProviderSection, StreamMode, + TeamModelConfig, TelegramConfig, TokenjuiceConfig, UpdateConfig, UpdateRestartStrategy, + VoiceActivationMode, VoiceServerConfig, WebSearchConfig, WebhookConfig, + DEFAULT_CLOUD_LLM_MODEL, DEFAULT_MEMORY_SYNC_INTERVAL_SECS, DEFAULT_MODEL, MEMORY_SYNC_INTERVAL_PRESETS_SECS, MODEL_AGENTIC_V1, MODEL_BURST_V1, MODEL_CHAT_V1, MODEL_CODING_V1, MODEL_REASONING_QUICK_V1, MODEL_REASONING_V1, MODEL_SUMMARIZATION_V1, MODEL_VISION_V1, SEARCH_ENGINE_BRAVE, SEARCH_ENGINE_DISABLED, SEARCH_ENGINE_MANAGED, diff --git a/src/openhuman/config/ops/mod.rs b/src/openhuman/config/ops/mod.rs index 7f88d780e..53558d3ac 100644 --- a/src/openhuman/config/ops/mod.rs +++ b/src/openhuman/config/ops/mod.rs @@ -3,6 +3,7 @@ mod agent; mod loader; mod model; +mod privacy; mod sandbox; mod ui; @@ -47,6 +48,10 @@ pub use model::{ MemorySettingsPatch, ModelSettingsPatch, RuntimeSettingsPatch, }; +pub use privacy::{ + apply_privacy_settings, get_privacy_mode, load_and_apply_privacy_settings, PrivacySettingsPatch, +}; + pub use sandbox::{ apply_sandbox_settings, get_sandbox_settings, load_and_apply_sandbox_settings, SandboxSettingsPatch, diff --git a/src/openhuman/config/ops/privacy.rs b/src/openhuman/config/ops/privacy.rs new file mode 100644 index 000000000..feda324bd --- /dev/null +++ b/src/openhuman/config/ops/privacy.rs @@ -0,0 +1,137 @@ +//! Privacy Mode config operations (#4435). +//! +//! Mirrors the autonomy settings ops (`agent.rs`): a `get` that reads the +//! `[privacy]` block and a `set` that persists the new mode AND hot-swaps the +//! live `SecurityPolicy` so the inference chokepoint enforces the change +//! immediately, without a core restart. + +use crate::openhuman::config::{Config, PrivacyMode}; +use crate::rpc::RpcOutcome; + +use super::loader::load_config_with_timeout; + +/// Partial update for the `[privacy]` block. `None` leaves the mode unchanged. +#[derive(Debug, Clone, Default)] +pub struct PrivacySettingsPatch { + /// `"local_only" | "standard" | "sensitive"` (case-insensitive; a few + /// hyphen/space spellings are also accepted). + pub mode: Option, +} + +/// Parse a user-supplied privacy-mode string into [`PrivacyMode`]. +fn parse_privacy_mode(raw: &str) -> Result { + match raw + .trim() + .to_ascii_lowercase() + .replace(['-', ' '], "_") + .as_str() + { + "local_only" | "localonly" | "local" => Ok(PrivacyMode::LocalOnly), + "standard" => Ok(PrivacyMode::Standard), + "sensitive" => Ok(PrivacyMode::Sensitive), + other => Err(format!( + "invalid privacy mode '{other}' (expected local_only | standard | sensitive)" + )), + } +} + +/// Serializable view of the current privacy mode for RPC responses. +fn privacy_mode_value(mode: PrivacyMode) -> serde_json::Value { + serde_json::json!({ "mode": mode }) +} + +/// Returns the current `[privacy]` mode as `{ "mode": "" }`. +pub async fn get_privacy_mode() -> Result, String> { + let config = load_config_with_timeout().await?; + log::debug!( + "[privacy][rpc] get_privacy_mode -> {:?}", + config.privacy.mode + ); + Ok(RpcOutcome::single_log( + privacy_mode_value(config.privacy.mode), + "privacy mode read", + )) +} + +/// Applies a privacy-mode update to `config`, persists it, and hot-swaps the +/// live `SecurityPolicy` so the inference chokepoint enforces the new mode +/// immediately. Returns `{ "mode": "" }`. +pub async fn apply_privacy_settings( + config: &mut Config, + update: PrivacySettingsPatch, +) -> Result, String> { + if let Some(raw) = update.mode { + let mode = parse_privacy_mode(&raw)?; + log::debug!( + "[privacy][rpc] apply_privacy_settings: {:?} -> {:?}", + config.privacy.mode, + mode + ); + config.privacy.mode = mode; + } + + config.save().await.map_err(|e| e.to_string())?; + + // Hot-swap the live policy so enforcement takes effect without a restart. + // `Err` here just means no session runtime is installed yet (e.g. CLI) — the + // persisted value will be picked up on the next `install`; log and continue. + match crate::openhuman::security::live_policy::reload_privacy(config.privacy.mode) { + Ok(generation) => log::debug!( + "[privacy][rpc] live policy reloaded to {:?} (generation={generation})", + config.privacy.mode + ), + Err(e) => log::debug!( + "[privacy][rpc] live policy not reloaded ({e}); persisted value applies on next install" + ), + } + + Ok(RpcOutcome::new( + privacy_mode_value(config.privacy.mode), + vec![format!( + "privacy mode saved to {}", + config.config_path.display() + )], + )) +} + +/// Loads the configuration, applies the privacy-mode update, and saves it. +pub async fn load_and_apply_privacy_settings( + update: PrivacySettingsPatch, +) -> Result, String> { + let mut config = load_config_with_timeout().await?; + apply_privacy_settings(&mut config, update).await +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_privacy_mode_accepts_canonical_and_variants() { + assert_eq!( + parse_privacy_mode("local_only").unwrap(), + PrivacyMode::LocalOnly + ); + assert_eq!( + parse_privacy_mode("Local-Only").unwrap(), + PrivacyMode::LocalOnly + ); + assert_eq!( + parse_privacy_mode(" STANDARD ").unwrap(), + PrivacyMode::Standard + ); + assert_eq!( + parse_privacy_mode("sensitive").unwrap(), + PrivacyMode::Sensitive + ); + assert!(parse_privacy_mode("bogus").is_err()); + } + + #[test] + fn privacy_mode_value_shape() { + assert_eq!( + privacy_mode_value(PrivacyMode::LocalOnly), + serde_json::json!({ "mode": "local_only" }) + ); + } +} diff --git a/src/openhuman/config/schema/mod.rs b/src/openhuman/config/schema/mod.rs index 7bbb8b679..c76b7f285 100644 --- a/src/openhuman/config/schema/mod.rs +++ b/src/openhuman/config/schema/mod.rs @@ -35,6 +35,7 @@ mod meet; mod node; mod observability; mod orchestration; +mod privacy; mod proxy; mod routes; mod runtime; @@ -71,6 +72,7 @@ pub use meet::{AutoJoinPolicy, AutoSummarizePolicy, CalendarProvider, MeetConfig pub use node::NodeConfig; pub use observability::{AgentTracingBackend, AgentTracingConfig, ObservabilityConfig}; pub use orchestration::OrchestrationConfig; +pub use privacy::{PrivacyConfig, PrivacyMode}; pub use proxy::{ apply_runtime_proxy_to_builder, build_runtime_proxy_client, build_runtime_proxy_client_with_timeouts, runtime_proxy_config, set_runtime_proxy_config, diff --git a/src/openhuman/config/schema/privacy.rs b/src/openhuman/config/schema/privacy.rs new file mode 100644 index 000000000..0e0fea1dd --- /dev/null +++ b/src/openhuman/config/schema/privacy.rs @@ -0,0 +1,117 @@ +//! Privacy Mode configuration — the data-egress posture the agent operates +//! under. This is DISTINCT from [`AutonomyLevel`](crate::openhuman::security::AutonomyLevel): +//! autonomy governs how much *act* power the agent has (read-only / supervised / +//! full), whereas privacy mode governs how much of the user's data may *leave the +//! device*. +//! +//! Slice S1 (#4435) of the privacy epic (#4256) lands the foundation plus one +//! real behavior: `LocalOnly` blocks external model calls at the inference +//! chokepoint. Sensitive-mode approvals, PII detection, redaction, +//! destination-disclosure UI, and local-only enforcement for integrations / +//! network tools are later slices (S2/S4/S5/S6/S7) and are intentionally NOT +//! implemented here. + +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +/// The data-egress posture the agent operates under. +/// +/// Serializes as snake_case (`local_only` / `standard` / `sensitive`) to match +/// the repo convention. A config.toml missing the `[privacy]` block, or the +/// `mode` key, deserializes to [`PrivacyMode::Standard`] (the `#[default]`). +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum PrivacyMode { + /// No data may leave the device. External (cloud / managed / CLI-delegate) + /// model calls are blocked; only local model runtimes (Ollama, LM Studio, + /// MLX, local OpenAI-compatible) are permitted for inference. + LocalOnly, + /// Balanced default — external models and integrations are allowed as + /// otherwise configured. No extra egress restrictions. + #[default] + Standard, + /// Heightened caution for sensitive data. Foundation only in S1 — the + /// approval / redaction / disclosure behaviors that key off this mode land + /// in later slices (S2/S4/S7). Behaves like `Standard` for now. + Sensitive, +} + +/// The `[privacy]` config block. Kept a struct (rather than a bare enum field on +/// [`Config`](crate::openhuman::config::Config)) so later slices can add +/// per-mode knobs (redaction toggles, destination allow-lists, PII thresholds) +/// without another schema migration. +/// `Default` derives to `mode: PrivacyMode::default()` (= `Standard`), which is +/// exactly the back-compat behavior required for a config missing the +/// `[privacy]` block or its `mode` key. +#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, JsonSchema)] +#[serde(default)] +pub struct PrivacyConfig { + /// Active privacy mode. Missing key → [`PrivacyMode::Standard`] via serde. + pub mode: PrivacyMode, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn privacy_mode_serializes_snake_case() { + assert_eq!( + serde_json::to_string(&PrivacyMode::LocalOnly).unwrap(), + "\"local_only\"" + ); + assert_eq!( + serde_json::to_string(&PrivacyMode::Standard).unwrap(), + "\"standard\"" + ); + assert_eq!( + serde_json::to_string(&PrivacyMode::Sensitive).unwrap(), + "\"sensitive\"" + ); + } + + #[test] + fn privacy_mode_deserializes_snake_case_roundtrip() { + for (json, expected) in [ + ("\"local_only\"", PrivacyMode::LocalOnly), + ("\"standard\"", PrivacyMode::Standard), + ("\"sensitive\"", PrivacyMode::Sensitive), + ] { + let got: PrivacyMode = serde_json::from_str(json).unwrap(); + assert_eq!(got, expected); + } + } + + #[test] + fn privacy_mode_default_is_standard() { + assert_eq!(PrivacyMode::default(), PrivacyMode::Standard); + assert_eq!(PrivacyConfig::default().mode, PrivacyMode::Standard); + } + + #[test] + fn missing_privacy_block_defaults_to_standard() { + // A config fragment with no `[privacy]` table at all. + #[derive(serde::Deserialize)] + struct Fragment { + #[serde(default)] + privacy: PrivacyConfig, + } + let parsed: Fragment = toml::from_str("").expect("empty toml deserializes"); + assert_eq!(parsed.privacy.mode, PrivacyMode::Standard); + } + + #[test] + fn missing_mode_key_defaults_to_standard() { + // `[privacy]` table present but no `mode` key. + let parsed: PrivacyConfig = + toml::from_str("").expect("empty privacy block deserializes via serde(default)"); + assert_eq!(parsed.mode, PrivacyMode::Standard); + } + + #[test] + fn explicit_mode_key_parses() { + let parsed: PrivacyConfig = + toml::from_str("mode = \"local_only\"").expect("explicit mode parses"); + assert_eq!(parsed.mode, PrivacyMode::LocalOnly); + } +} diff --git a/src/openhuman/config/schema/types.rs b/src/openhuman/config/schema/types.rs index df3cfc9a6..ce7acea93 100644 --- a/src/openhuman/config/schema/types.rs +++ b/src/openhuman/config/schema/types.rs @@ -136,6 +136,12 @@ pub struct Config { #[serde(default)] pub autonomy: AutonomyConfig, + /// Data-egress posture (Privacy Mode). Distinct from `autonomy` (which + /// governs agent *act* power). Missing `[privacy]` block → `Standard` + /// (#4435, epic #4256). + #[serde(default)] + pub privacy: PrivacyConfig, + #[serde(default)] pub sandbox: SandboxConfig, @@ -739,6 +745,7 @@ impl Default for Config { observability: ObservabilityConfig::default(), dashboard: DashboardConfig::default(), autonomy: AutonomyConfig::default(), + privacy: PrivacyConfig::default(), sandbox: SandboxConfig::default(), runtime: RuntimeConfig::default(), shell: ShellConfig::default(), diff --git a/src/openhuman/config/schemas/controllers.rs b/src/openhuman/config/schemas/controllers.rs index 4fa458f06..fcbaacaea 100644 --- a/src/openhuman/config/schemas/controllers.rs +++ b/src/openhuman/config/schemas/controllers.rs @@ -11,7 +11,7 @@ use super::helpers::{ AgentSettingsUpdate, AnalyticsSettingsUpdate, AutonomySettingsUpdate, BrowserSettingsUpdate, ComposioTriggerSettingsUpdate, DictationSettingsUpdate, LocalAiSettingsUpdate, MeetSettingsUpdate, MemorySettingsUpdate, MemorySyncSettingsUpdate, ModelSettingsUpdate, - OnboardingCompletedSetParams, RuntimeSettingsUpdate, SandboxSettingsUpdate, + OnboardingCompletedSetParams, PrivacyModeUpdate, RuntimeSettingsUpdate, SandboxSettingsUpdate, ScreenIntelligenceSettingsUpdate, SearchSettingsUpdate, SetBrowserAllowAllParams, SuperContextSetParams, VoiceServerSettingsUpdate, WorkspaceOnboardingFlagParams, WorkspaceOnboardingFlagSetParams, DEFAULT_ONBOARDING_FLAG_NAME, @@ -55,6 +55,8 @@ pub fn all_controller_schemas() -> Vec { schemas("get_composio_trigger_settings"), schemas("get_autonomy_settings"), schemas("update_autonomy_settings"), + schemas("get_privacy_mode"), + schemas("set_privacy_mode"), schemas("get_agent_settings"), schemas("update_agent_settings"), schemas("update_search_settings"), @@ -210,6 +212,14 @@ pub fn all_registered_controllers() -> Vec { schema: schemas("update_autonomy_settings"), handler: handle_update_autonomy_settings, }, + RegisteredController { + schema: schemas("get_privacy_mode"), + handler: handle_get_privacy_mode, + }, + RegisteredController { + schema: schemas("set_privacy_mode"), + handler: handle_set_privacy_mode, + }, RegisteredController { schema: schemas("get_agent_settings"), handler: handle_get_agent_settings, @@ -461,6 +471,18 @@ pub(super) fn handle_update_autonomy_settings(params: Map) -> Con }) } +pub(super) fn handle_get_privacy_mode(_params: Map) -> ControllerFuture { + Box::pin(async move { to_json(config_rpc::get_privacy_mode().await?) }) +} + +pub(super) fn handle_set_privacy_mode(params: Map) -> ControllerFuture { + Box::pin(async move { + let update = deserialize_params::(params)?; + let patch = config_rpc::PrivacySettingsPatch { mode: update.mode }; + to_json(config_rpc::load_and_apply_privacy_settings(patch).await?) + }) +} + fn handle_get_agent_settings(_params: Map) -> ControllerFuture { Box::pin(async { log::debug!("[config][rpc] get_agent_settings enter"); diff --git a/src/openhuman/config/schemas/helpers.rs b/src/openhuman/config/schemas/helpers.rs index fd6cd072e..5a4057fdb 100644 --- a/src/openhuman/config/schemas/helpers.rs +++ b/src/openhuman/config/schemas/helpers.rs @@ -245,6 +245,12 @@ pub(super) struct AutonomySettingsUpdate { pub(super) require_task_plan_approval: Option, } +#[derive(Debug, Deserialize)] +pub(super) struct PrivacyModeUpdate { + /// `"local_only" | "standard" | "sensitive"` (case-insensitive). + pub(super) mode: Option, +} + #[derive(Debug, Deserialize)] pub(super) struct AgentSettingsUpdate { /// Tool/action wall-clock timeout in seconds (1–3600). Validated server-side. diff --git a/src/openhuman/config/schemas/schema_defs.rs b/src/openhuman/config/schemas/schema_defs.rs index 6fccdedae..c3ebb9bd4 100644 --- a/src/openhuman/config/schemas/schema_defs.rs +++ b/src/openhuman/config/schemas/schema_defs.rs @@ -227,6 +227,22 @@ pub fn schemas(function: &str) -> ControllerSchema { ], outputs: vec![json_output("snapshot", "Updated config snapshot.")], }, + "get_privacy_mode" => ControllerSchema { + namespace: "config", + function: "get_privacy_mode", + description: "Get the active Privacy Mode (data-egress posture): local_only | standard | sensitive. Distinct from the autonomy access mode.", + inputs: vec![], + outputs: vec![json_output("mode", "Current privacy mode: local_only | standard | sensitive.")], + }, + "set_privacy_mode" => ControllerSchema { + namespace: "config", + function: "set_privacy_mode", + description: "Set the Privacy Mode (data-egress posture). local_only blocks external model calls at the inference chokepoint. Applies live to active sessions without a restart.", + inputs: vec![ + optional_string("mode", "Privacy mode: local_only | standard | sensitive."), + ], + outputs: vec![json_output("mode", "Updated privacy mode.")], + }, "get_agent_settings" => ControllerSchema { namespace: "config", function: "get_agent_settings", diff --git a/src/openhuman/inference/provider/factory.rs b/src/openhuman/inference/provider/factory.rs index cea3af334..ec9274500 100644 --- a/src/openhuman/inference/provider/factory.rs +++ b/src/openhuman/inference/provider/factory.rs @@ -524,6 +524,89 @@ pub mod test_provider_override { } } +/// Human-readable label for an *external* provider string, used in the +/// LocalOnly privacy-mode block message so the user knows what was refused. +fn external_provider_label(provider: &str) -> String { + let p = provider.trim(); + if p == PROVIDER_OPENHUMAN { + return "OpenHuman (managed cloud)".to_string(); + } + if p == BYOK_INCOMPLETE_SENTINEL { + return "cloud (incomplete BYOK config)".to_string(); + } + if p == CLAUDE_AGENT_SDK_PROVIDER || p.starts_with(CLAUDE_AGENT_SDK_PREFIX) { + return "Claude Agent SDK".to_string(); + } + if p.starts_with(crate::openhuman::inference::provider::claude_code::PROVIDER_PREFIX) { + return "Claude Code CLI".to_string(); + } + // Concrete cloud slug ":" → surface just the slug. + match p.split_once(':') { + Some((slug, _)) if !slug.trim().is_empty() => slug.trim().to_string(), + _ => p.to_string(), + } +} + +/// Privacy Mode (#4435) pure decision: under `mode`, is constructing chat +/// provider `provider` a local-only violation? Returns `Some(label)` naming the +/// blocked external provider when refused, else `None`. +/// +/// Only `LocalOnly` restricts anything. Local runtimes (Ollama / LM Studio / MLX +/// / local-openai) are always permitted. Re-resolving sentinels (`""` / `"cloud"`) +/// return `None` here — they recurse through +/// [`create_chat_provider_from_string`] and are re-checked with the concrete +/// resolved string. Extracted as a pure fn so it is unit-testable without the +/// process-global live policy. +fn local_only_violation( + mode: crate::openhuman::config::PrivacyMode, + provider: &str, +) -> Option { + use crate::openhuman::config::PrivacyMode; + if mode != PrivacyMode::LocalOnly { + return None; + } + let p = provider.trim(); + if p.is_empty() || p == "cloud" { + // Deferred: re-resolves to a concrete string on the recursive call. + return None; + } + if crate::openhuman::inference::local::profile::is_local_provider_string(p) { + return None; + } + Some(external_provider_label(p)) +} + +/// Enforce Privacy Mode `LocalOnly` at the inference chokepoint: refuse to build +/// an external chat provider when the live policy is local-only. Reads the live +/// privacy mode (defaults to `Standard`/allow when no session policy is +/// installed). See [`local_only_violation`] for the pure decision. +fn enforce_local_only_inference(role: &str, provider: &str) -> anyhow::Result<()> { + let mode = crate::openhuman::security::live_policy::current_privacy_mode(); + match local_only_violation(mode, provider) { + None => { + log::debug!( + "[privacy][chat-factory] privacy_mode={:?} role={} provider='{}' — inference permitted", + mode, + role, + provider.trim() + ); + Ok(()) + } + Some(label) => { + log::warn!( + "[privacy][chat-factory] LocalOnly BLOCK: role={} external provider='{}' ({}) refused", + role, + provider.trim(), + label + ); + anyhow::bail!( + "Local-only privacy mode is active: this action needs external provider {label}. \ + Switch to a local model (Ollama/LM Studio/etc.) or change privacy mode in Settings." + ) + } + } +} + /// Build a `(Provider, model)` for the given workload role. pub fn create_chat_provider( role: &str, @@ -564,6 +647,12 @@ pub fn create_chat_provider_from_string( p ); + // Privacy Mode (#4435): in LocalOnly mode, refuse to construct any external + // provider here — the single inference chokepoint. Re-resolving sentinels + // ("" / "cloud") are allowed through and re-checked on the recursive call + // below with the concrete resolved provider string. + enforce_local_only_inference(role, p)?; + // Fail-closed: BYOK intent was detected upstream but no matching provider // entry was found. Surface a clear configuration error instead of silently // routing through the managed OpenHuman backend. diff --git a/src/openhuman/inference/provider/factory_tests.rs b/src/openhuman/inference/provider/factory_tests.rs index 27d089504..98e37f392 100644 --- a/src/openhuman/inference/provider/factory_tests.rs +++ b/src/openhuman/inference/provider/factory_tests.rs @@ -2573,3 +2573,107 @@ fn byo_route_without_usable_key_stays_gated() { store_byo_key(&config, "openai", "sk-byo-test"); assert!(role_bypasses_managed_credits("chat", &config)); } + +// ── Privacy Mode: local-only inference enforcement (#4435) ─────────────────── + +#[test] +fn local_only_blocks_external_cloud_slug() { + use crate::openhuman::config::PrivacyMode; + let v = local_only_violation(PrivacyMode::LocalOnly, "openai:gpt-4o"); + assert_eq!(v.as_deref(), Some("openai")); +} + +#[test] +fn local_only_blocks_managed_backend() { + use crate::openhuman::config::PrivacyMode; + let v = local_only_violation(PrivacyMode::LocalOnly, PROVIDER_OPENHUMAN); + assert_eq!(v.as_deref(), Some("OpenHuman (managed cloud)")); +} + +#[test] +fn local_only_blocks_claude_code_cli() { + use crate::openhuman::config::PrivacyMode; + let v = local_only_violation(PrivacyMode::LocalOnly, "claude-code:sonnet"); + assert_eq!(v.as_deref(), Some("Claude Code CLI")); +} + +#[test] +fn local_only_permits_local_runtimes() { + use crate::openhuman::config::PrivacyMode; + for local in [ + "ollama:llama3", + "lmstudio:qwen", + "mlx:phi", + "local-openai:foo", + ] { + assert_eq!( + local_only_violation(PrivacyMode::LocalOnly, local), + None, + "local provider '{local}' must be permitted in LocalOnly mode" + ); + } +} + +#[test] +fn local_only_defers_reresolving_sentinels() { + use crate::openhuman::config::PrivacyMode; + // Empty / "cloud" re-resolve to a concrete string and are re-checked on the + // recursive call — not blocked here. + assert_eq!(local_only_violation(PrivacyMode::LocalOnly, ""), None); + assert_eq!(local_only_violation(PrivacyMode::LocalOnly, "cloud"), None); +} + +#[test] +fn standard_mode_permits_external() { + use crate::openhuman::config::PrivacyMode; + assert_eq!( + local_only_violation(PrivacyMode::Standard, "openai:gpt-4o"), + None + ); + assert_eq!( + local_only_violation(PrivacyMode::Sensitive, "openai:gpt-4o"), + None, + "Sensitive mode has no egress enforcement in S1" + ); +} + +#[test] +fn enforce_local_only_inference_errors_on_external_when_local_only() { + // Drive the live-policy-backed wrapper: install a LocalOnly policy, then + // assert an external provider is refused with the privacy message and a + // local provider passes. + let _env = crate::openhuman::config::TEST_ENV_LOCK + .lock() + .unwrap_or_else(|e| e.into_inner()); + use crate::openhuman::config::PrivacyMode; + use crate::openhuman::security::SecurityPolicy; + let ws = std::env::temp_dir().join("openhuman_factory_privacy_test"); + let policy = std::sync::Arc::new( + SecurityPolicy { + workspace_dir: ws.clone(), + ..SecurityPolicy::default() + } + .with_privacy_mode(PrivacyMode::LocalOnly), + ); + crate::openhuman::security::live_policy::install(policy, ws.clone(), ws.clone()); + + let err = enforce_local_only_inference("chat", "openai:gpt-4o") + .expect_err("external provider must be refused in LocalOnly mode"); + let msg = err.to_string(); + assert!( + msg.contains("Local-only privacy mode is active"), + "unexpected error: {msg}" + ); + assert!( + msg.contains("openai"), + "error should name the provider: {msg}" + ); + + // Local provider passes. + enforce_local_only_inference("chat", "ollama:llama3") + .expect("local provider must be permitted in LocalOnly mode"); + + // Restore Standard so we don't leak LocalOnly into other serial tests. + crate::openhuman::security::live_policy::reload_privacy(PrivacyMode::Standard) + .expect("policy installed"); +} diff --git a/src/openhuman/security/live_policy.rs b/src/openhuman/security/live_policy.rs index eb1933a25..330e7b912 100644 --- a/src/openhuman/security/live_policy.rs +++ b/src/openhuman/security/live_policy.rs @@ -19,11 +19,16 @@ use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::{Arc, OnceLock, RwLock}; use super::SecurityPolicy; +use crate::openhuman::config::PrivacyMode; struct LiveState { policy: RwLock>, workspace_dir: RwLock, action_dir: RwLock, + /// Stored Privacy Mode so an autonomy-only [`reload_from`] preserves the + /// active mode (autonomy config carries no privacy field) and a later + /// [`reload_privacy`] can swap it without rebuilding from a full `Config`. + privacy_mode: RwLock, generation: AtomicU64, } @@ -42,6 +47,7 @@ pub fn install( policy: RwLock::new(Arc::clone(&policy)), workspace_dir: RwLock::new(workspace_dir.clone()), action_dir: RwLock::new(action_dir.clone()), + privacy_mode: RwLock::new(policy.privacy_mode), generation: AtomicU64::new(0), }); if let Ok(mut guard) = state.policy.write() { @@ -53,9 +59,27 @@ pub fn install( if let Ok(mut guard) = state.action_dir.write() { *guard = action_dir; } + // Seed the stored privacy mode from the installed policy so later + // autonomy-only reloads preserve it. `get_or_init` only runs the closure on + // first install, so re-seed here on every install too. + if let Ok(mut guard) = state.privacy_mode.write() { + *guard = policy.privacy_mode; + } + log::debug!( + "[privacy][live_policy] installed policy with privacy_mode={:?}", + policy.privacy_mode + ); policy } +/// The current live Privacy Mode, if a policy has been [`install`]ed. Falls back +/// to [`PrivacyMode::Standard`] when no policy is installed (e.g. a CLI +/// invocation that never started a session runtime) — i.e. no egress +/// restriction by default. +pub fn current_privacy_mode() -> PrivacyMode { + current().map(|p| p.privacy_mode).unwrap_or_default() +} + /// The current live policy, if one has been [`install`]ed this process. pub fn current() -> Option> { STATE @@ -134,21 +158,69 @@ pub fn reload_from(autonomy_config: &crate::openhuman::config::AutonomyConfig) { .read() .map(|g| g.clone()) .unwrap_or_default(); - let rebuilt = Arc::new(SecurityPolicy::from_config( - autonomy_config, - &workspace, - &action, - )); + // `from_config` builds with the `Standard` default; re-apply the stored + // privacy mode so an autonomy-only change does not silently reset egress + // posture (autonomy config carries no privacy field). + let stored_privacy = state.privacy_mode.read().map(|g| *g).unwrap_or_default(); + let rebuilt = Arc::new( + SecurityPolicy::from_config(autonomy_config, &workspace, &action) + .with_privacy_mode(stored_privacy), + ); if let Ok(mut guard) = state.policy.write() { *guard = rebuilt; } let gen = state.generation.fetch_add(1, Ordering::Relaxed) + 1; tracing::info!( generation = gen, + privacy_mode = ?stored_privacy, "[security:live_policy] SecurityPolicy reloaded after autonomy config change" ); } +/// Swap the active Privacy Mode on the process-global live policy and rebuild +/// the current policy around it, bumping the generation counter. Called by +/// `config.set_privacy_mode` (#4435) so a Settings-driven mode change takes +/// effect for the inference chokepoint immediately, without a core restart. +/// +/// Clones the in-flight policy and swaps only `privacy_mode`, preserving every +/// other access setting (mirrors [`set_action_dir`]). Also updates the stored +/// mode so a subsequent [`reload_from`] keeps it. Returns the new generation, or +/// `Err` if no policy is installed yet (typically a CLI-only invocation). +pub fn reload_privacy(new_mode: PrivacyMode) -> Result { + let Some(state) = STATE.get() else { + return Err( + "[security:live_policy] no policy installed yet — cannot update privacy_mode".into(), + ); + }; + { + let mut guard = state + .privacy_mode + .write() + .map_err(|e| format!("[security:live_policy] privacy_mode lock poisoned: {e}"))?; + *guard = new_mode; + } + let current_policy = state + .policy + .read() + .map(|g| Arc::clone(&g)) + .map_err(|e| format!("[security:live_policy] policy lock poisoned: {e}"))?; + let rebuilt = (*current_policy).clone().with_privacy_mode(new_mode); + { + let mut guard = state + .policy + .write() + .map_err(|e| format!("[security:live_policy] policy write lock poisoned: {e}"))?; + *guard = Arc::new(rebuilt); + } + let gen = state.generation.fetch_add(1, Ordering::Relaxed) + 1; + tracing::info!( + generation = gen, + privacy_mode = ?new_mode, + "[security:live_policy] SecurityPolicy reloaded after privacy mode change" + ); + Ok(gen) +} + /// Swap the agent action sandbox root on the process-global live policy. /// /// Updates the stored `action_dir` (so a subsequent [`reload_from`] keeps the @@ -250,6 +322,52 @@ mod tests { ); } + #[test] + fn reload_privacy_swaps_mode_and_survives_autonomy_reload() { + // Same process-global lock as the other live-policy tests. + let _env = crate::openhuman::config::TEST_ENV_LOCK + .lock() + .unwrap_or_else(|e| e.into_inner()); + let workspace = std::env::temp_dir().join("openhuman_privacy_live_test"); + let initial = Arc::new(SecurityPolicy { + autonomy: AutonomyLevel::Supervised, + privacy_mode: PrivacyMode::Standard, + workspace_dir: workspace.clone(), + ..SecurityPolicy::default() + }); + install(initial, workspace.clone(), workspace.clone()); + + assert_eq!(current_privacy_mode(), PrivacyMode::Standard); + + // Swap to LocalOnly — the live policy reflects it immediately. + let before = generation(); + reload_privacy(PrivacyMode::LocalOnly).expect("policy installed"); + assert!(generation() > before, "generation must increase"); + assert_eq!(current_privacy_mode(), PrivacyMode::LocalOnly); + assert_eq!( + current().expect("installed").privacy_mode, + PrivacyMode::LocalOnly + ); + + // An autonomy-only reload must PRESERVE the privacy mode (autonomy + // config carries no privacy field). + let cfg = AutonomyConfig { + level: AutonomyLevel::Full, + ..AutonomyConfig::default() + }; + reload_from(&cfg); + assert_eq!( + current().expect("installed").autonomy, + AutonomyLevel::Full, + "autonomy must update" + ); + assert_eq!( + current_privacy_mode(), + PrivacyMode::LocalOnly, + "privacy mode must survive an autonomy-only reload" + ); + } + #[test] fn set_action_dir_swaps_root_and_bumps_generation() { // Same process-global lock as the reload test — these install/swap the diff --git a/src/openhuman/security/policy/enforcement.rs b/src/openhuman/security/policy/enforcement.rs index c4f0e8a5b..ab6004bd0 100644 --- a/src/openhuman/security/policy/enforcement.rs +++ b/src/openhuman/security/policy/enforcement.rs @@ -143,6 +143,15 @@ impl SecurityPolicy { Self { autonomy: autonomy_config.level, + // Privacy mode is not carried on `AutonomyConfig` (it lives in the + // separate `[privacy]` block), and `from_config` has ~40 call sites + // that only hold the autonomy block — so we default to `Standard` + // here and let the install / reload chokepoints layer the real + // `config.privacy.mode` on via [`with_privacy_mode`]. The live-policy + // paths (`install` seeds from the built policy, `reload_from` + // re-applies the stored mode, `reload_privacy` swaps it) keep the + // effective enforcement mode correct without touching every caller. + privacy_mode: crate::openhuman::config::PrivacyMode::default(), workspace_dir: workspace_dir.to_path_buf(), action_dir: action_dir.to_path_buf(), workspace_only: autonomy_config.workspace_only, @@ -159,6 +168,32 @@ impl SecurityPolicy { canonical_workspace: Arc::new(OnceCell::new()), } } + + /// Return a copy of this policy with `privacy_mode` set. Used by the live- + /// policy install / reload chokepoints to layer `config.privacy.mode` onto a + /// policy that [`from_config`](Self::from_config) built with the `Standard` + /// default. Builder-style so call sites read as + /// `SecurityPolicy::from_config(..).with_privacy_mode(config.privacy.mode)`. + #[must_use] + pub fn with_privacy_mode( + mut self, + privacy_mode: crate::openhuman::config::PrivacyMode, + ) -> Self { + log::debug!( + "[privacy][policy] privacy_mode set on SecurityPolicy: {:?} (was {:?})", + privacy_mode, + self.privacy_mode + ); + self.privacy_mode = privacy_mode; + self + } + + /// The active data-egress posture (Privacy Mode). Read by the inference + /// chokepoint to enforce local-only model routing; later slices (S4/S7) also + /// branch on `Sensitive` here. + pub fn privacy_mode(&self) -> crate::openhuman::config::PrivacyMode { + self.privacy_mode + } } /// The dedicated, namespaced scratch directory granted to the agent so its diff --git a/src/openhuman/security/policy/types.rs b/src/openhuman/security/policy/types.rs index 1b938b100..e6f02ab7a 100644 --- a/src/openhuman/security/policy/types.rs +++ b/src/openhuman/security/policy/types.rs @@ -1,3 +1,4 @@ +use crate::openhuman::config::PrivacyMode; use parking_lot::Mutex; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; @@ -205,6 +206,18 @@ pub(super) const WORKSPACE_INTERNAL_FILES: &[&str] = &[ #[derive(Debug, Clone)] pub struct SecurityPolicy { pub autonomy: AutonomyLevel, + /// Data-egress posture (Privacy Mode) — DISTINCT from `autonomy`, which + /// governs act-power. `LocalOnly` blocks external model calls at the + /// inference chokepoint (see + /// [`create_chat_provider_from_string`](crate::openhuman::inference::provider::factory)). + /// Sourced from `config.privacy.mode` at policy-build time and hot-swapped + /// via [`live_policy::reload_privacy`](crate::openhuman::security::live_policy::reload_privacy). + /// + /// HOOK (later slices): `Sensitive` mode enforcement (approval / redaction / + /// destination disclosure — S2/S4/S7) and `LocalOnly` enforcement for + /// integrations / network tools (S5/S6) branch on this field. Those arms are + /// intentionally NOT implemented in S1 (#4435). + pub privacy_mode: PrivacyMode, pub workspace_dir: PathBuf, /// Agent action sandbox root — tools resolve relative paths and default /// their cwd here instead of `workspace_dir`. Kept separate so internal @@ -261,6 +274,7 @@ impl Default for SecurityPolicy { fn default() -> Self { Self { autonomy: AutonomyLevel::Supervised, + privacy_mode: PrivacyMode::Standard, workspace_dir: PathBuf::from("."), action_dir: PathBuf::from("."), workspace_only: true,
+ {t('privacy.mode.description')} +
{t(descKey)}