feat(privacy): Privacy Mode + local-only inference enforcement (#4435) (#4446)

This commit is contained in:
oxoxDev
2026-07-03 11:17:03 -07:00
committed by GitHub
parent afb33ac19c
commit ddba7945a4
34 changed files with 1111 additions and 24 deletions
@@ -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(<PrivacyModeSection />);
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(<PrivacyModeSection />);
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(<PrivacyModeSection />);
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(<PrivacyModeSection />);
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);
});
});
@@ -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<PrivacyMode | null>(null);
const [status, setStatus] = useState<Status>('loading');
const [error, setError] = useState<string | null>(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 (
<SettingsSection title={t('privacy.mode.title')}>
<div className="p-4 flex flex-col gap-3">
<p className="text-xs text-content-muted leading-relaxed">
{t('privacy.mode.description')}
</p>
<div className="flex flex-col gap-2" data-testid="privacy-mode-options">
{MODES.map(({ value, labelKey, descKey }) => {
const isSelected = mode === value;
return (
<button
key={value}
type="button"
role="radio"
aria-checked={isSelected}
disabled={status === 'saving' || status === 'loading'}
onClick={() => {
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' : ''}`}>
<span className="text-sm font-semibold text-content">{t(labelKey)}</span>
<p className="text-xs text-content-muted mt-0.5">{t(descKey)}</p>
</button>
);
})}
</div>
<SettingsStatusLine
saving={status === 'saving'}
savedNote={status === 'saved' ? t('privacy.mode.saved') : null}
error={status === 'error' ? (error ?? t('privacy.mode.saveError')) : null}
savingLabel={t('autonomy.statusSaving')}
/>
</div>
</SettingsSection>
);
};
export default PrivacyModeSection;
@@ -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 */}
<PrivacyModeSection />
{/* What leaves my computer */}
<SettingsSection title={t('privacy.whatLeavesComputer')}>
{loadState === 'loading' && (
+12
View File
@@ -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)
+12
View File
@@ -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 কলে স্বয়ংক্রিয়ভাবে যোগ দিন',
+14
View File
@@ -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)
+13
View File
@@ -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',
+14
View File
@@ -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',
+15
View File
@@ -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 daccès de lagent.',
'privacy.mode.localOnly': 'Local uniquement',
'privacy.mode.localOnlyDesc':
'Uniquement les modèles sur lappareil. 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. Dautres 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',
+12
View File
@@ -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)
+13
View File
@@ -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',
+13
View File
@@ -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 dellagente.',
'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',
+13
View File
@@ -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)
+14
View File
@@ -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',
+13
View File
@@ -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',
+13
View File
@@ -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',
+11
View File
@@ -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)
+2
View File
@@ -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',
+8 -5
View File
@@ -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,
);
+8 -5
View File
@@ -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(),
);
+9 -8
View File
@@ -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,
+5
View File
@@ -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,
+137
View File
@@ -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<String>,
}
/// Parse a user-supplied privacy-mode string into [`PrivacyMode`].
fn parse_privacy_mode(raw: &str) -> Result<PrivacyMode, String> {
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": "<snake_case>" }`.
pub async fn get_privacy_mode() -> Result<RpcOutcome<serde_json::Value>, 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": "<snake_case>" }`.
pub async fn apply_privacy_settings(
config: &mut Config,
update: PrivacySettingsPatch,
) -> Result<RpcOutcome<serde_json::Value>, 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<RpcOutcome<serde_json::Value>, 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" })
);
}
}
+2
View File
@@ -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,
+117
View File
@@ -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);
}
}
+7
View File
@@ -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(),
+23 -1
View File
@@ -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<ControllerSchema> {
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<RegisteredController> {
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<String, Value>) -> Con
})
}
pub(super) fn handle_get_privacy_mode(_params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move { to_json(config_rpc::get_privacy_mode().await?) })
}
pub(super) fn handle_set_privacy_mode(params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
let update = deserialize_params::<PrivacyModeUpdate>(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<String, Value>) -> ControllerFuture {
Box::pin(async {
log::debug!("[config][rpc] get_agent_settings enter");
+6
View File
@@ -245,6 +245,12 @@ pub(super) struct AutonomySettingsUpdate {
pub(super) require_task_plan_approval: Option<bool>,
}
#[derive(Debug, Deserialize)]
pub(super) struct PrivacyModeUpdate {
/// `"local_only" | "standard" | "sensitive"` (case-insensitive).
pub(super) mode: Option<String>,
}
#[derive(Debug, Deserialize)]
pub(super) struct AgentSettingsUpdate {
/// Tool/action wall-clock timeout in seconds (13600). Validated server-side.
@@ -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",
@@ -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 "<slug>:<model>" → 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<String> {
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.
@@ -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");
}
+123 -5
View File
@@ -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<Arc<SecurityPolicy>>,
workspace_dir: RwLock<PathBuf>,
action_dir: RwLock<PathBuf>,
/// 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<PrivacyMode>,
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<Arc<SecurityPolicy>> {
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<u64, String> {
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
@@ -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
+14
View File
@@ -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,