mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
This commit is contained in:
@@ -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' && (
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 কলে স্বয়ংক্রিয়ভাবে যোগ দিন',
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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(),
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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" })
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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 (1–3600). 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");
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user