diff --git a/app/src/components/settings/panels/AgentAccessPanel.tsx b/app/src/components/settings/panels/AgentAccessPanel.tsx index 475c35253..33cb64dba 100644 --- a/app/src/components/settings/panels/AgentAccessPanel.tsx +++ b/app/src/components/settings/panels/AgentAccessPanel.tsx @@ -46,6 +46,11 @@ const AgentAccessPanel = () => { const [level, setLevel] = useState('supervised'); const [workspaceOnly, setWorkspaceOnly] = useState(false); const [requireTaskPlanApproval, setRequireTaskPlanApproval] = useState(true); + // Blanket "auto-approve everything" bypass — off by default. Hard security + // blocks (credential dirs, workspace-internal paths) and the + // subconscious-tainted / unlabelled-origin denials in the approval gate + // are unaffected by this setting; see `settings.agentAccess.autoApproveAll.desc`. + const [autoApproveAll, setAutoApproveAll] = useState(false); const [trustedRoots, setTrustedRoots] = useState([]); // "Always allow" allowlist — populated by the in-chat "Always allow" button; // shown here read-only with a Remove action (the re-protect path). @@ -97,6 +102,7 @@ const AgentAccessPanel = () => { setLevel(autonomyResp.result.level); setWorkspaceOnly(autonomyResp.result.workspace_only); setRequireTaskPlanApproval(autonomyResp.result.require_task_plan_approval ?? true); + setAutoApproveAll(autonomyResp.result.auto_approve_all ?? false); setTrustedRoots(autonomyResp.result.trusted_roots ?? []); setAutoApprove(autonomyResp.result.auto_approve ?? []); } catch (e) { @@ -143,16 +149,30 @@ const AgentAccessPanel = () => { // `allow_tool_install` is fixed; workspace_only, trusted_roots vary. // `level` is carried through from state (its UI lives in PermissionsPanel). // Pass explicit `next` values (setState is async). - const persist = async (next: { - workspaceOnly: boolean; - requireTaskPlanApproval: boolean; - trustedRoots: TrustedRoot[]; - // Only sent when the allowlist itself is being changed. Omitting it leaves - // the server's `auto_approve` untouched (partial patch) — important so a - // tier/folder change can't clobber a tool the user just added via the - // in-chat "Always allow" button. - autoApprove?: string[]; - }) => { + // + // `onError` lets a caller revert its own optimistic `setState` if the RPC + // fails (mirrors `toggleAutopilot`'s revert-on-failure below) — otherwise a + // failed save leaves the switch showing the new value locally while the + // server-side field silently kept its old one. + const persist = async ( + next: { + workspaceOnly: boolean; + requireTaskPlanApproval: boolean; + trustedRoots: TrustedRoot[]; + // Only sent when the allowlist itself is being changed. Omitting it leaves + // the server's `auto_approve` untouched (partial patch) — important so a + // tier/folder change can't clobber a tool the user just added via the + // in-chat "Always allow" button. + autoApprove?: string[]; + // Same partial-patch reasoning as `autoApprove` above: only + // `toggleAutoApproveAll` sets this. Every other caller must omit it so + // an unrelated autosave (folders, task-plan-approval, workspace + // confinement) can never rewrite `auto_approve_all` back to this + // panel's possibly-stale local value. + autoApproveAll?: boolean; + }, + onError?: () => void + ) => { const seq = ++persistSeqRef.current; if (!isTauri()) return; setError(null); @@ -166,6 +186,7 @@ const AgentAccessPanel = () => { allow_tool_install: ALLOW_TOOL_INSTALL, require_task_plan_approval: next.requireTaskPlanApproval, ...(next.autoApprove !== undefined ? { auto_approve: next.autoApprove } : {}), + ...(next.autoApproveAll !== undefined ? { auto_approve_all: next.autoApproveAll } : {}), }); // Only the most recent persist may write UI state back. if (persistSeqRef.current === seq) { @@ -174,6 +195,7 @@ const AgentAccessPanel = () => { } catch (e) { if (persistSeqRef.current === seq) { setError(e instanceof Error ? e.message : t('settings.agentAccess.saveError')); + onError?.(); } } finally { if (persistSeqRef.current === seq) { @@ -183,13 +205,28 @@ const AgentAccessPanel = () => { }; const toggleWorkspaceOnly = (next: boolean) => { + const prev = workspaceOnly; setWorkspaceOnly(next); - void persist({ workspaceOnly: next, requireTaskPlanApproval, trustedRoots }); + void persist({ workspaceOnly: next, requireTaskPlanApproval, trustedRoots }, () => + setWorkspaceOnly(prev) + ); }; const toggleTaskPlanApproval = (next: boolean) => { + const prev = requireTaskPlanApproval; setRequireTaskPlanApproval(next); - void persist({ workspaceOnly, requireTaskPlanApproval: next, trustedRoots }); + void persist({ workspaceOnly, requireTaskPlanApproval: next, trustedRoots }, () => + setRequireTaskPlanApproval(prev) + ); + }; + + const toggleAutoApproveAll = (next: boolean) => { + const prev = autoApproveAll; + setAutoApproveAll(next); + void persist( + { workspaceOnly, requireTaskPlanApproval, trustedRoots, autoApproveAll: next }, + () => setAutoApproveAll(prev) + ); }; // The autopilot is a cron job, not an autonomy field — flip its `enabled` @@ -226,18 +263,23 @@ const AgentAccessPanel = () => { setTrustedRoots(nextRoots); setNewRootPath(''); setNewRootAccess('read'); + // `autoApproveAll` intentionally omitted: this save is about the folder + // grant, not the auto-approve-all toggle, and the partial-patch RPC + // leaves omitted fields untouched server-side (see `persist` above). void persist({ workspaceOnly, requireTaskPlanApproval, trustedRoots: nextRoots }); }; const removeRoot = (path: string) => { const nextRoots = trustedRoots.filter(r => r.path !== path); setTrustedRoots(nextRoots); + // `autoApproveAll` intentionally omitted — see `addRoot` above. void persist({ workspaceOnly, requireTaskPlanApproval, trustedRoots: nextRoots }); }; const removeAutoApprove = (tool: string) => { const nextList = autoApprove.filter(name => name !== tool); setAutoApprove(nextList); + // `autoApproveAll` intentionally omitted — see `addRoot` above. void persist({ workspaceOnly, requireTaskPlanApproval, trustedRoots, autoApprove: nextList }); }; @@ -294,6 +336,32 @@ const AgentAccessPanel = () => {

{t('settings.agentAccess.loading')}

) : ( <> + {/* Auto-approve everything — blanket bypass of the approval + prompt. Security-sensitive: kept at the very top of the panel + with a persistent warning, visible regardless of toggle state, + so the user reads it before flipping the switch. */} + + + } + /> +
+

+ {t('settings.agentAccess.autoApproveAll.desc')} +

+
+
+ {/* Workspace confinement + task plan approval */} = {}): AutonomySettings = allow_tool_install: true, max_actions_per_hour: 0, auto_approve: [], + auto_approve_all: false, ...overrides, }); @@ -308,4 +309,66 @@ describe('AgentAccessPanel (advanced)', () => { await screen.findByText('Approval history'); expect(screen.getByTestId('agent-access-approval-history-link')).toBeInTheDocument(); }); + + // ── auto-approve-all bypass (security-sensitive) ──────────────────────── + + it('renders the auto-approve-all toggle OFF by default', async () => { + renderWithProviders(); + const sw = await screen.findByRole('switch', { name: /auto-approve all actions/i }); + expect(sw).toHaveAttribute('aria-checked', 'false'); + }); + + it('toggling auto-approve-all ON persists auto_approve_all: true', async () => { + renderWithProviders(); + const sw = await screen.findByRole('switch', { name: /auto-approve all actions/i }); + fireEvent.click(sw); + await waitFor(() => + expect(mockUpdate).toHaveBeenCalledWith(expect.objectContaining({ auto_approve_all: true })) + ); + }); + + it('shows the warning description regardless of toggle state', async () => { + renderWithProviders(); + const sw = await screen.findByRole('switch', { name: /auto-approve all actions/i }); + + // Off state: warning is already visible. + expect(sw).toHaveAttribute('aria-checked', 'false'); + expect(screen.getByTestId('auto-approve-all-warning')).toBeInTheDocument(); + expect(screen.getByTestId('auto-approve-all-warning')).toHaveTextContent( + /credential and system directories/i + ); + + // On state: toggling it on keeps the same warning mounted, still visible. + fireEvent.click(sw); + await waitFor(() => expect(sw).toHaveAttribute('aria-checked', 'true')); + expect(screen.getByTestId('auto-approve-all-warning')).toBeInTheDocument(); + }); + + it('reverts the auto-approve-all toggle when the save fails', async () => { + mockUpdate.mockRejectedValueOnce(new Error('boom')); + renderWithProviders(); + const sw = await screen.findByRole('switch', { name: /auto-approve all actions/i }); + fireEvent.click(sw); + // The RPC must actually be attempted (and fail)… + await waitFor(() => + expect(mockUpdate).toHaveBeenCalledWith(expect.objectContaining({ auto_approve_all: true })) + ); + // …then the optimistic flip reverts to off, so the switch never shows a + // falsely-safe "off" or falsely-enabled "on" state the server disagrees with. + await waitFor(() => expect(sw).toHaveAttribute('aria-checked', 'false')); + }); + + it('does not resend auto_approve_all when an unrelated field is saved', async () => { + // auto_approve_all starts true; toggling an unrelated switch (workspace + // confinement) must omit auto_approve_all from the patch entirely so a + // stale/loaded panel value can never clobber it back down. + mockGet.mockResolvedValue({ result: autonomy({ auto_approve_all: true }), logs: [] }); + renderWithProviders(); + fireEvent.click(await screen.findByRole('switch', { name: /confine to workspace/i })); + await waitFor(() => + expect(mockUpdate).toHaveBeenCalledWith(expect.objectContaining({ workspace_only: true })) + ); + const [[payload]] = mockUpdate.mock.calls; + expect(payload).not.toHaveProperty('auto_approve_all'); + }); }); diff --git a/app/src/components/settings/settingsRouteRegistry.ts b/app/src/components/settings/settingsRouteRegistry.ts index 3c4f7e68f..5e4e7ffef 100644 --- a/app/src/components/settings/settingsRouteRegistry.ts +++ b/app/src/components/settings/settingsRouteRegistry.ts @@ -423,6 +423,10 @@ export const SETTINGS_ROUTE_REGISTRY: SettingsRegistryEntry[] = [ 'autonomous', 'rate limit', 'actions per hour', + 'auto-approve', + 'auto approve', + 'full autonomy', + 'bypass approval', ], navParent: 'agents', }, diff --git a/app/src/lib/i18n/ar.ts b/app/src/lib/i18n/ar.ts index 807f6de84..c52595335 100644 --- a/app/src/lib/i18n/ar.ts +++ b/app/src/lib/i18n/ar.ts @@ -5437,6 +5437,9 @@ const messages: TranslationMap = { 'settings.agentAccess.requireTaskPlanApproval.label': 'الموافقة على خطة العمل المطلوبة', 'settings.agentAccess.requireTaskPlanApproval.desc': 'وقف أمام عميل معين يقوم بتنفيذ موجز عمل مشرف على عميل', + 'settings.agentAccess.autoApproveAll.label': 'الموافقة التلقائية على جميع الإجراءات', + 'settings.agentAccess.autoApproveAll.desc': + 'عند التفعيل، سينفذ الوكيل جميع الإجراءات المؤهلة دون طلب موافقتك أولاً. يشمل ذلك كتابة الملفات وتنفيذ أوامر الطرفية وطلبات الشبكة وأي تأثيرات جانبية أخرى. تظل الحواجز الأمنية الصارمة، مثل أدلة بيانات الاعتماد والأدلة النظامية، سارية المفعول، ولا تتم الموافقة التلقائية أبداً على الإجراءات ذات المصدر غير الموثوق أو غير المعروف.', 'settings.agentAccess.tinyplaceAutopilot.title': 'وكيل tiny.place المستقل', 'settings.agentAccess.tinyplaceAutopilot.desc': 'دع OpenHuman يتصرف على tiny.place بمفرده. وفق جدول زمني، يبحث عن عمل مجدٍ (المكافآت المفتوحة أولًا)، وينجز ما يناسب مهاراته ويتصرف من هويتك. يعمل دون إشراف ويمكنه الإنفاق، لذا أبقِه على devnet أثناء الاختبار. معطّل افتراضيًا.', diff --git a/app/src/lib/i18n/bn.ts b/app/src/lib/i18n/bn.ts index 2f4e0b343..53bec88ce 100644 --- a/app/src/lib/i18n/bn.ts +++ b/app/src/lib/i18n/bn.ts @@ -5565,6 +5565,9 @@ const messages: TranslationMap = { 'settings.agentAccess.requireTaskPlanApproval.label': 'কাজের পরিকল্পনা অনুমোদন প্রয়োজন', 'settings.agentAccess.requireTaskPlanApproval.desc': 'নির্ধারিত কর্মের পূর্বে একটি author-ed কর্মের সঞ্চালনার পূর্বে কর্ম স্থগিত করা হবে।', + 'settings.agentAccess.autoApproveAll.label': 'সব কাজ স্বয়ংক্রিয়ভাবে অনুমোদন করুন', + 'settings.agentAccess.autoApproveAll.desc': + 'সক্রিয় করা হলে, এজেন্ট আপনার অনুমতি না নিয়েই সব কাজ সম্পাদন করবে। এর মধ্যে রয়েছে ফাইল লেখা, শেল কমান্ড, নেটওয়ার্ক অনুরোধ এবং অন্যান্য যেকোনো পার্শ্বপ্রতিক্রিয়া। কঠোর নিরাপত্তা বাধা (ক্রেডেনশিয়াল ডিরেক্টরি, ওয়ার্কস্পেসের অভ্যন্তরীণ পাথ) তবুও কার্যকর থাকবে।', 'settings.agentAccess.tinyplaceAutopilot.title': 'স্বয়ংক্রিয় tiny.place এজেন্ট', 'settings.agentAccess.tinyplaceAutopilot.desc': 'OpenHuman-কে tiny.place-এ নিজে কাজ করতে দিন। এটি নির্ধারিত সময় অনুযায়ী মূল্যবান কাজ খোঁজে (প্রথমে খোলা বাউন্টি), নিজের দক্ষতার সাথে মানানসই কাজ করে এবং আপনার পরিচয় থেকে কাজ করে। এটি তত্ত্বাবধান ছাড়াই চলে এবং অর্থ ব্যয় করতে পারে, তাই পরীক্ষার সময় এটি devnet-এ রাখুন। ডিফল্টভাবে বন্ধ।', diff --git a/app/src/lib/i18n/de.ts b/app/src/lib/i18n/de.ts index 5a62c934c..3d08122eb 100644 --- a/app/src/lib/i18n/de.ts +++ b/app/src/lib/i18n/de.ts @@ -5728,6 +5728,9 @@ const messages: TranslationMap = { 'Erfordern Sie die Genehmigung des Aufgabenplans', 'settings.agentAccess.requireTaskPlanApproval.desc': 'Pausieren Sie, bevor ein zugewiesener Agent ein vom Agenten verfasstes Aufgaben-Briefing ausführt.', + 'settings.agentAccess.autoApproveAll.label': 'Alle Aktionen automatisch genehmigen', + 'settings.agentAccess.autoApproveAll.desc': + 'Wenn aktiviert, genehmigt der Agent automatisch alle zulässigen Aktionen, ohne vorher deine Zustimmung einzuholen. Dazu gehören Dateischreibvorgänge, Shell-Befehle, Netzwerkanfragen und andere Aktionen mit externen Auswirkungen. Feste Sicherheitssperren (Anmeldeinformationen und Systemverzeichnisse) gelten weiterhin, und Aktionen aus nicht vertrauenswürdigen oder unbekannten Quellen werden nie automatisch genehmigt.', 'settings.agentAccess.tinyplaceAutopilot.title': 'Autonomer tiny.place-Agent', 'settings.agentAccess.tinyplaceAutopilot.desc': 'Lass OpenHuman eigenständig auf tiny.place handeln: zeitgesteuert sucht es lohnende Arbeit – zuerst offene Bounties –, erledigt Passendes und handelt über deine Identität. Es läuft unbeaufsichtigt und kann Geld ausgeben; nutze beim Testen devnet. Standardmäßig aus.', diff --git a/app/src/lib/i18n/en.ts b/app/src/lib/i18n/en.ts index 9a599b25b..1369bcf3d 100644 --- a/app/src/lib/i18n/en.ts +++ b/app/src/lib/i18n/en.ts @@ -6274,6 +6274,9 @@ const en: TranslationMap = { 'settings.agentAccess.requireTaskPlanApproval.label': 'Require task plan approval', 'settings.agentAccess.requireTaskPlanApproval.desc': 'Pause before an assigned agent executes an agent-authored task brief.', + 'settings.agentAccess.autoApproveAll.label': 'Auto-approve all actions', + 'settings.agentAccess.autoApproveAll.desc': + 'When enabled, the agent executes all eligible actions without asking for your approval first. This includes file writes, shell commands, network requests, and any other side effects. Credential and system directories stay blocked, and actions from untrusted or unlabelled call origins are still denied.', 'settings.agentAccess.tinyplaceAutopilot.title': 'Autonomous tiny.place agent', 'settings.agentAccess.tinyplaceAutopilot.desc': 'Let OpenHuman act on tiny.place on its own. On a schedule, it finds worthwhile work (open bounties first), does what fits its skills, and acts from your identity. It runs unattended and can spend, so keep it on devnet while testing. Off by default.', diff --git a/app/src/lib/i18n/es.ts b/app/src/lib/i18n/es.ts index 9d4ae01c5..85b75a301 100644 --- a/app/src/lib/i18n/es.ts +++ b/app/src/lib/i18n/es.ts @@ -5666,6 +5666,9 @@ const messages: TranslationMap = { 'settings.agentAccess.requireTaskPlanApproval.label': 'Requerir la aprobación del plan de tareas', 'settings.agentAccess.requireTaskPlanApproval.desc': 'Pausa antes de que un agente asignado ejecute un breve tarea elaborada por el agente.', + 'settings.agentAccess.autoApproveAll.label': 'Aprobar automáticamente todas las acciones', + 'settings.agentAccess.autoApproveAll.desc': + 'Cuando está activado, el agente ejecutará todas las acciones elegibles sin pedir tu aprobación primero. Esto incluye escritura de archivos, comandos de shell, solicitudes de red y cualquier otro efecto secundario. Los bloqueos de seguridad estrictos, incluidos los directorios de credenciales y del sistema, siguen aplicándose, y las acciones con un origen no confiable o desconocido nunca se aprueban automáticamente.', 'settings.agentAccess.tinyplaceAutopilot.title': 'Agente autónomo de tiny.place', 'settings.agentAccess.tinyplaceAutopilot.desc': 'Deja que OpenHuman actúe en tiny.place por su cuenta. De forma programada, busca trabajo que valga la pena (primero las recompensas abiertas), hace lo que encaja con sus habilidades y actúa desde tu identidad. Funciona sin supervisión y puede gastar, así que mantenlo en devnet mientras pruebas. Desactivado por defecto.', diff --git a/app/src/lib/i18n/fr.ts b/app/src/lib/i18n/fr.ts index b05cdd4b7..a9ffe08f4 100644 --- a/app/src/lib/i18n/fr.ts +++ b/app/src/lib/i18n/fr.ts @@ -5698,6 +5698,9 @@ const messages: TranslationMap = { 'settings.agentAccess.requireTaskPlanApproval.label': "Exiger l'approbation du plan de tâche", 'settings.agentAccess.requireTaskPlanApproval.desc': "Pause avant qu'un agent assigné n'exécute un briefing de tâche rédigé par un agent.", + 'settings.agentAccess.autoApproveAll.label': 'Approuver automatiquement toutes les actions', + 'settings.agentAccess.autoApproveAll.desc': + "Une fois activé, l'agent exécutera toutes les actions sans demander votre approbation au préalable. Cela inclut l'écriture de fichiers, les commandes shell, les requêtes réseau et tout autre effet secondaire. Les blocages de sécurité stricts (répertoires d'identifiants, chemins internes de l'espace de travail) continuent de s'appliquer.", 'settings.agentAccess.tinyplaceAutopilot.title': 'Agent tiny.place autonome', 'settings.agentAccess.tinyplaceAutopilot.desc': 'Laissez OpenHuman agir seul sur tiny.place. De façon planifiée, il cherche du travail intéressant (les primes ouvertes d’abord), accomplit les tâches adaptées à ses compétences et agit avec votre identité. Il fonctionne sans surveillance et peut dépenser ; gardez-le sur devnet pendant vos tests. Désactivé par défaut.', diff --git a/app/src/lib/i18n/hi.ts b/app/src/lib/i18n/hi.ts index 1f0fbe493..ae6ae6c5b 100644 --- a/app/src/lib/i18n/hi.ts +++ b/app/src/lib/i18n/hi.ts @@ -5564,6 +5564,9 @@ const messages: TranslationMap = { 'settings.agentAccess.requireTaskPlanApproval.label': 'कार्य योजना अनुमोदन की आवश्यकता', 'settings.agentAccess.requireTaskPlanApproval.desc': 'एक निर्धारित एजेंट से पहले रोकें एक एजेंट-लेखित कार्य संक्षिप्त निष्पादित करता है।', + 'settings.agentAccess.autoApproveAll.label': 'सभी कार्रवाइयों को स्वतः स्वीकृत करें', + 'settings.agentAccess.autoApproveAll.desc': + 'सक्षम होने पर, एजेंट आपकी अनुमति मांगे बिना सभी कार्रवाइयां निष्पादित करेगा। इसमें फ़ाइल लेखन, शेल कमांड, नेटवर्क अनुरोध और अन्य कोई भी दुष्प्रभाव शामिल हैं। कठोर सुरक्षा अवरोध (क्रेडेंशियल डायरेक्टरी, वर्कस्पेस-आंतरिक पथ) अब भी लागू रहते हैं।', 'settings.agentAccess.tinyplaceAutopilot.title': 'स्वायत्त tiny.place एजेंट', 'settings.agentAccess.tinyplaceAutopilot.desc': 'OpenHuman को tiny.place पर स्वयं कार्य करने दें। यह निर्धारित समय पर सार्थक काम ढूँढता है (पहले खुली बाउंटी), अपनी क्षमताओं के अनुरूप काम करता है और आपकी पहचान से कार्य करता है। यह बिना निगरानी के चलता है और खर्च कर सकता है, इसलिए परीक्षण के दौरान इसे devnet पर रखें। डिफ़ॉल्ट रूप से बंद।', diff --git a/app/src/lib/i18n/id.ts b/app/src/lib/i18n/id.ts index 6c75fc93c..99b05238e 100644 --- a/app/src/lib/i18n/id.ts +++ b/app/src/lib/i18n/id.ts @@ -5590,6 +5590,9 @@ const messages: TranslationMap = { 'settings.agentAccess.requireTaskPlanApproval.label': 'Perlu persetujuan rencana tugas', 'settings.agentAccess.requireTaskPlanApproval.desc': 'Jeda sebelum agen yang ditugaskan mengeksekusi suatu tugas singkat.', + 'settings.agentAccess.autoApproveAll.label': 'Setujui semua tindakan secara otomatis', + 'settings.agentAccess.autoApproveAll.desc': + 'Jika diaktifkan, agen akan menjalankan semua tindakan yang memenuhi syarat tanpa meminta persetujuan Anda terlebih dahulu. Ini termasuk penulisan file, perintah shell, permintaan jaringan, dan efek samping lainnya. Batasan keamanan ketat (direktori kredensial dan sistem) tetap berlaku, dan tindakan dari sumber yang tidak tepercaya atau tidak diketahui tidak pernah disetujui secara otomatis.', 'settings.agentAccess.tinyplaceAutopilot.title': 'Agen tiny.place otonom', 'settings.agentAccess.tinyplaceAutopilot.desc': 'Biarkan OpenHuman bertindak sendiri di tiny.place. Sesuai jadwal, ia mencari pekerjaan yang bermanfaat (mendahulukan bounty terbuka), mengerjakan tugas yang sesuai dengan keahliannya, dan bertindak dengan identitas Anda. Ia berjalan tanpa pengawasan dan dapat membelanjakan dana, jadi gunakan devnet saat menguji. Nonaktif secara bawaan.', diff --git a/app/src/lib/i18n/it.ts b/app/src/lib/i18n/it.ts index 5e65a2c00..3e953b892 100644 --- a/app/src/lib/i18n/it.ts +++ b/app/src/lib/i18n/it.ts @@ -5660,6 +5660,9 @@ const messages: TranslationMap = { "Richiedere l'approvazione del piano di lavoro", 'settings.agentAccess.requireTaskPlanApproval.desc': "Pausa prima che un agente assegnato esegua un brief del compito scritto dall'agente.", + 'settings.agentAccess.autoApproveAll.label': 'Approva automaticamente tutte le azioni', + 'settings.agentAccess.autoApproveAll.desc': + "Se attivato, l'agente eseguirà tutte le azioni senza chiedere prima la tua approvazione. Questo include scritture di file, comandi shell, richieste di rete e qualsiasi altro effetto collaterale. I blocchi di sicurezza rigidi (directory delle credenziali, percorsi interni dell'area di lavoro) continuano ad applicarsi.", 'settings.agentAccess.tinyplaceAutopilot.title': 'Agente tiny.place autonomo', 'settings.agentAccess.tinyplaceAutopilot.desc': 'Lascia che OpenHuman agisca da solo su tiny.place. In base alla pianificazione, cerca lavoro utile (prima le taglie aperte), svolge ciò che si adatta alle sue competenze e agisce con la tua identità. Funziona senza supervisione e può spendere, quindi tienilo su devnet durante i test. Disattivato per impostazione predefinita.', diff --git a/app/src/lib/i18n/ko.ts b/app/src/lib/i18n/ko.ts index 0f31799b7..ec4316730 100644 --- a/app/src/lib/i18n/ko.ts +++ b/app/src/lib/i18n/ko.ts @@ -5497,6 +5497,9 @@ const messages: TranslationMap = { 'settings.agentAccess.requireTaskPlanApproval.label': '작업 계획 승인 필요', 'settings.agentAccess.requireTaskPlanApproval.desc': '할당된 에이전트가 에이전트가 작성한 작업 브리프를 실행하기 전에 일시 중지합니다.', + 'settings.agentAccess.autoApproveAll.label': '모든 작업 자동 승인', + 'settings.agentAccess.autoApproveAll.desc': + '활성화하면 에이전트가 먼저 승인을 요청하지 않고 해당되는 모든 작업을 실행합니다. 여기에는 파일 쓰기, 셸 명령, 네트워크 요청 및 기타 모든 부작용이 포함됩니다. 자격 증명 및 시스템 디렉터리는 계속 차단되며, 신뢰할 수 없거나 출처가 확인되지 않은 호출에서 비롯된 작업은 계속 거부됩니다.', 'settings.agentAccess.tinyplaceAutopilot.title': '자율 tiny.place 에이전트', 'settings.agentAccess.tinyplaceAutopilot.desc': 'OpenHuman이 tiny.place에서 스스로 행동하게 하세요: 일정에 따라 가치 있는 일을 찾고(열린 현상금 우선) 자신의 능력에 맞는 작업을 수행하며 당신의 신원으로 행동합니다. 감독 없이 작동하며 비용을 지출할 수 있으니 테스트 중에는 devnet을 사용하세요. 기본값은 꺼짐입니다.', diff --git a/app/src/lib/i18n/pl.ts b/app/src/lib/i18n/pl.ts index e6527f9aa..caa78f3ea 100644 --- a/app/src/lib/i18n/pl.ts +++ b/app/src/lib/i18n/pl.ts @@ -5652,6 +5652,9 @@ const messages: TranslationMap = { 'settings.agentAccess.requireTaskPlanApproval.label': 'Wymagaj zatwierdzenia planu zadania', 'settings.agentAccess.requireTaskPlanApproval.desc': 'Wstrzymaj, zanim przypisany agent wykona opis zadania utworzony przez agenta.', + 'settings.agentAccess.autoApproveAll.label': 'Automatycznie zatwierdzaj wszystkie działania', + 'settings.agentAccess.autoApproveAll.desc': + 'Po włączeniu agent będzie wykonywać wszystkie działania bez wcześniejszego proszenia o Twoją zgodę. Obejmuje to zapis plików, polecenia powłoki, żądania sieciowe i wszelkie inne efekty uboczne. Twarde blokady bezpieczeństwa (katalogi poświadczeń, wewnętrzne ścieżki przestrzeni roboczej) nadal obowiązują.', 'settings.agentAccess.tinyplaceAutopilot.title': 'Autonomiczny agent tiny.place', 'settings.agentAccess.tinyplaceAutopilot.desc': 'Pozwól OpenHuman działać samodzielnie na tiny.place. Zgodnie z harmonogramem szuka wartościowej pracy (najpierw otwartych nagród), wykonuje zadania pasujące do jego umiejętności i działa z użyciem Twojej tożsamości. Działa bez nadzoru i może wydawać środki, więc podczas testów używaj devnet. Domyślnie wyłączone.', diff --git a/app/src/lib/i18n/pt.ts b/app/src/lib/i18n/pt.ts index d5430789d..fe5ea7ef2 100644 --- a/app/src/lib/i18n/pt.ts +++ b/app/src/lib/i18n/pt.ts @@ -5649,6 +5649,9 @@ const messages: TranslationMap = { 'settings.agentAccess.requireTaskPlanApproval.label': 'Exigir aprovação do plano de tarefas', 'settings.agentAccess.requireTaskPlanApproval.desc': 'Pausa antes que um agente designado execute um briefing de tarefa elaborado pelo agente.', + 'settings.agentAccess.autoApproveAll.label': 'Aprovar automaticamente todas as ações', + 'settings.agentAccess.autoApproveAll.desc': + 'Quando ativado, o agente executará todas as ações sem pedir sua aprovação antes. Isso inclui gravação de arquivos, comandos de shell, solicitações de rede e qualquer outro efeito colateral. Os bloqueios de segurança rígidos (diretórios de credenciais, caminhos internos do espaço de trabalho) continuam em vigor.', 'settings.agentAccess.tinyplaceAutopilot.title': 'Agente autônomo do tiny.place', 'settings.agentAccess.tinyplaceAutopilot.desc': 'Deixe o OpenHuman agir sozinho no tiny.place. De forma agendada, ele busca trabalhos relevantes (priorizando recompensas abertas), executa o que combina com suas habilidades e age com a sua identidade. Funciona sem supervisão e pode gastar, então mantenha-o na devnet durante os testes. Desativado por padrão.', diff --git a/app/src/lib/i18n/ru.ts b/app/src/lib/i18n/ru.ts index 7f64955b7..43324a7a4 100644 --- a/app/src/lib/i18n/ru.ts +++ b/app/src/lib/i18n/ru.ts @@ -5622,6 +5622,9 @@ const messages: TranslationMap = { 'settings.agentAccess.requireTaskPlanApproval.label': 'Требовать утверждения плана задач', 'settings.agentAccess.requireTaskPlanApproval.desc': 'Сделайте паузу перед тем, как назначенный агент выполнит задание, созданное агентом.', + 'settings.agentAccess.autoApproveAll.label': 'Автоматически одобрять все действия', + 'settings.agentAccess.autoApproveAll.desc': + 'При включении агент будет выполнять все подходящие действия, не спрашивая вашего одобрения. Это включает запись файлов, команды оболочки, сетевые запросы и любые другие побочные эффекты. Жесткие блокировки безопасности (каталоги учетных данных и системные каталоги) продолжают действовать, а действия из ненадежных или неизвестных источников никогда не одобряются автоматически.', 'settings.agentAccess.tinyplaceAutopilot.title': 'Автономный агент tiny.place', 'settings.agentAccess.tinyplaceAutopilot.desc': 'Позвольте OpenHuman самостоятельно действовать в tiny.place. По расписанию он ищет подходящую работу (сначала открытые награды), выполняет задачи по своим навыкам и действует от вашего имени. Он работает без присмотра и может тратить средства, поэтому при тестировании используйте devnet. По умолчанию выключено.', diff --git a/app/src/lib/i18n/zh-CN.ts b/app/src/lib/i18n/zh-CN.ts index 8e1e26c85..acdc4cda0 100644 --- a/app/src/lib/i18n/zh-CN.ts +++ b/app/src/lib/i18n/zh-CN.ts @@ -5267,6 +5267,9 @@ const messages: TranslationMap = { 'settings.agentAccess.requireTaskPlanApproval.label': '要求批准任务计划', 'settings.agentAccess.requireTaskPlanApproval.desc': '在指定智能体执行由智能体编写的任务简报前暂停。', + 'settings.agentAccess.autoApproveAll.label': '自动批准所有操作', + 'settings.agentAccess.autoApproveAll.desc': + '启用后,智能体将自动执行所有符合条件的操作,无需事先征得你的批准。这包括文件写入、Shell 命令、网络请求以及任何其他副作用。严格的安全阻止措施(凭据目录和系统目录)仍然适用,来自不受信任或未知来源的操作永远不会被自动批准。', 'settings.agentAccess.tinyplaceAutopilot.title': '自主 tiny.place 代理', 'settings.agentAccess.tinyplaceAutopilot.desc': '让 OpenHuman 自行在 tiny.place 上行动:按计划寻找有价值的工作(优先开放的悬赏),完成符合其技能的任务,并以你的身份行动。它在无人监督下运行且可以花费资金,测试时请使用 devnet。默认关闭。', diff --git a/app/src/utils/tauriCommands/config.ts b/app/src/utils/tauriCommands/config.ts index 08ed3e5bb..af6a9f47e 100644 --- a/app/src/utils/tauriCommands/config.ts +++ b/app/src/utils/tauriCommands/config.ts @@ -489,6 +489,14 @@ export interface AutonomySettings { auto_approve: string[]; /** Require approval before an agent executes a task-board plan. */ require_task_plan_approval?: boolean; + /** + * When true, the approval gate auto-approves ALL tool calls without + * prompting — a blanket bypass, not just the `auto_approve` allowlist + * above. Subconscious-tainted and unlabelled origins are still denied by + * the gate regardless of this flag; hard security blocks are unaffected. + * Defaults to `false`. + */ + auto_approve_all?: boolean; } /** Partial update — omitted fields are left unchanged. */ @@ -503,6 +511,8 @@ export interface AutonomySettingsUpdate { /** Replaces the "Always allow" allowlist wholesale. */ auto_approve?: string[]; require_task_plan_approval?: boolean; + /** Blanket "auto-approve everything" bypass. See `AutonomySettings`. */ + auto_approve_all?: boolean; } export async function openhumanGetAutonomySettings(): Promise> { diff --git a/src/openhuman/agent/turn_origin.rs b/src/openhuman/agent/turn_origin.rs index 29e5e5548..7183b2876 100644 --- a/src/openhuman/agent/turn_origin.rs +++ b/src/openhuman/agent/turn_origin.rs @@ -103,6 +103,29 @@ pub enum TrustedAutomationSource { }, } +impl AgentTurnOrigin { + /// A PII-free classification label safe for `info`-level logs and audit + /// trails — the variant name (and, for `TrustedAutomation`, its `source` + /// sub-kind), never an identifying field. Use this instead of `{:?}` / + /// `?origin` anywhere the log line isn't gated to `debug`/`trace`: + /// `WebChat.thread_id`/`client_id`, `ExternalChannel.sender`/ + /// `reply_target`/`message_id`, and `TrustedAutomation.job_id` can carry + /// user- or channel-identifying data that must not land at `info`. + pub fn class(&self) -> String { + match self { + AgentTurnOrigin::WebChat { .. } => "WebChat".to_string(), + AgentTurnOrigin::ExternalChannel { channel, .. } => { + format!("ExternalChannel({channel})") + } + AgentTurnOrigin::TrustedAutomation { source, .. } => { + format!("TrustedAutomation({source:?})") + } + AgentTurnOrigin::Cli => "Cli".to_string(), + AgentTurnOrigin::Unknown => "Unknown".to_string(), + } + } +} + tokio::task_local! { /// Per-turn agent origin. Scoped by entry points (web channel, channel /// runtime dispatch, subconscious loop, cron scheduler, CLI) around the diff --git a/src/openhuman/approval/gate.rs b/src/openhuman/approval/gate.rs index 1a46e3676..c22723117 100644 --- a/src/openhuman/approval/gate.rs +++ b/src/openhuman/approval/gate.rs @@ -358,6 +358,23 @@ impl ApprovalGate { .any(|t| t == tool_name) } + /// Whether the user has opted into "auto-approve everything" — a + /// blanket bypass of the human approval prompt. Mirrors + /// [`Self::tool_is_auto_approved`]'s live-policy-first, boot-config- + /// fallback pattern so a toggle made this session (config save + live + /// policy reload) takes effect on the very next tool call. + /// + /// Callers MUST still exclude `TrustedAutomationSource::SubconsciousTainted` + /// and `AgentTurnOrigin::Unknown` before trusting this flag — see the + /// `matches!` guard at the call site below. This method only reports the + /// user's setting; it does not know about origin. + fn is_auto_approve_all_enabled(&self) -> bool { + if let Some(policy) = crate::openhuman::security::live_policy::current() { + return policy.auto_approve_all; + } + self.config.autonomy.auto_approve_all + } + /// Intercept a tool call. Blocks until the user decides or the /// TTL elapses (timeout → `Deny`). /// @@ -533,6 +550,45 @@ impl ApprovalGate { } ); + // Blanket "auto-approve everything" bypass (opt-in, off by default). + // Sits ABOVE the origin match below so it prevents parking entirely + // for every origin except the two that must never be silently + // allowed: a subconscious tick whose memory context is tainted by + // external-sync content (indirect prompt injection defense) and an + // unlabelled call site (fail-closed default). Both are excluded here + // so they still fall through to the origin match and hit their Deny + // arms unchanged. This check is independent of — and does not + // weaken — `is_always_forbidden`, `is_workspace_internal_path`, or + // `ToolPolicyMiddleware`, which all run inside the tool + // implementation itself, not the approval gate. + let auto_all = self.is_auto_approve_all_enabled() + && !matches!( + &origin, + AgentTurnOrigin::TrustedAutomation { + source: TrustedAutomationSource::SubconsciousTainted, + .. + } | AgentTurnOrigin::Unknown + ); + + if auto_all { + // `origin_class` is the sanitized variant label (no thread/client + // ids, channel sender, reply target, or message id) — safe at + // `info`. The full `?origin` (with those identifiers) is still + // available at `debug` for local troubleshooting. + tracing::info!( + tool = tool_name, + origin_class = %origin.class(), + auto_approved = true, + "[approval::gate] auto_approve_all enabled — auto-approving without prompt" + ); + tracing::debug!( + tool = tool_name, + origin = ?origin, + "[approval::gate] auto_approve_all full origin (debug-only)" + ); + return (GateOutcome::Allow, None); + } + // "Always allow" allowlist shortcut — the user's persisted // `autonomy.auto_approve` set. Read from the live policy first so a // grant made earlier in this session (which writes config + reloads the @@ -1832,6 +1888,234 @@ mod tests { ); } + /// With `auto_approve_all: true`, a WebChat-origin call resolves to + /// `Allow` immediately — no pending row is created and the chat context + /// is never consulted, proving the short-circuit fires above the park. + #[tokio::test] + async fn auto_approve_all_resolves_allow() { + let _env = crate::openhuman::config::TEST_ENV_LOCK + .lock() + .unwrap_or_else(|e| e.into_inner()); + let (gate, dir) = test_gate(); + let policy = crate::openhuman::security::SecurityPolicy { + auto_approve_all: true, + ..crate::openhuman::security::SecurityPolicy::default() + }; + // Scoped: restores whatever live_policy held before this test on drop + // (including on panic), so a leaked `auto_approve_all: true` can never + // reach a sibling gate test that doesn't hold `TEST_ENV_LOCK`. + let _policy_guard = crate::openhuman::security::live_policy::install_scoped( + Arc::new(policy), + dir.path().to_path_buf(), + dir.path().to_path_buf(), + ); + + let outcome = turn_origin::with_origin( + web_origin(), + gate.intercept("openhuman_test_aaa_webchat", "noop", serde_json::json!({})), + ) + .await; + + assert!(matches!(outcome, GateOutcome::Allow)); + assert!( + gate.list_pending().unwrap().is_empty(), + "auto_approve_all must short-circuit before any pending row is persisted" + ); + } + + /// Control test: with `auto_approve_all: false` (the default), a + /// WebChat-origin call parks normally — it does NOT resolve to `Allow` + /// until a decision is sent on the oneshot. + #[tokio::test] + async fn auto_approve_all_off_still_parks() { + let _env = crate::openhuman::config::TEST_ENV_LOCK + .lock() + .unwrap_or_else(|e| e.into_inner()); + let (gate, dir) = test_gate(); + let policy = crate::openhuman::security::SecurityPolicy { + auto_approve_all: false, + ..crate::openhuman::security::SecurityPolicy::default() + }; + let _policy_guard = crate::openhuman::security::live_policy::install_scoped( + Arc::new(policy), + dir.path().to_path_buf(), + dir.path().to_path_buf(), + ); + let gate = Arc::new(gate); + + let g = gate.clone(); + let handle = tokio::spawn(async move { + turn_origin::with_origin( + web_origin(), + APPROVAL_CHAT_CONTEXT.scope( + chat_ctx(), + g.intercept("openhuman_test_aaa_off", "noop", serde_json::json!({})), + ), + ) + .await + }); + + // The call must actually park: poll for the pending row instead of + // racing an immediate result. + let mut tries = 0; + let pending = loop { + let rows = gate.list_pending().unwrap(); + if let Some(p) = rows.into_iter().next() { + break p; + } + tries += 1; + assert!( + tries < 50, + "pending row never appeared — call resolved without parking" + ); + tokio::time::sleep(Duration::from_millis(10)).await; + }; + + gate.decide(&pending.request_id, ApprovalDecision::ApproveOnce) + .unwrap(); + let outcome = handle.await.unwrap(); + assert!(matches!(outcome, GateOutcome::Allow)); + } + + /// `auto_approve_all: true` must NOT override a `SubconsciousTainted` + /// origin — the gate still hard-denies it (indirect prompt injection + /// defense). + #[tokio::test] + async fn auto_approve_all_does_not_override_subconscioustainted() { + let _env = crate::openhuman::config::TEST_ENV_LOCK + .lock() + .unwrap_or_else(|e| e.into_inner()); + let (gate, dir) = test_gate(); + let policy = crate::openhuman::security::SecurityPolicy { + auto_approve_all: true, + ..crate::openhuman::security::SecurityPolicy::default() + }; + let _policy_guard = crate::openhuman::security::live_policy::install_scoped( + Arc::new(policy), + dir.path().to_path_buf(), + dir.path().to_path_buf(), + ); + + let origin = AgentTurnOrigin::TrustedAutomation { + job_id: "job-tainted".into(), + source: TrustedAutomationSource::SubconsciousTainted, + }; + let outcome = turn_origin::with_origin( + origin, + gate.intercept("openhuman_test_aaa_tainted", "noop", serde_json::json!({})), + ) + .await; + + match outcome { + GateOutcome::Deny { reason } => assert!(reason.contains("external-sync")), + other => panic!("expected deny, got {other:?}"), + } + } + + /// `auto_approve_all: true` must NOT override an `Unknown` origin — the + /// gate still fails closed for unlabelled call sites. + #[tokio::test] + async fn auto_approve_all_does_not_override_unknown() { + let _env = crate::openhuman::config::TEST_ENV_LOCK + .lock() + .unwrap_or_else(|e| e.into_inner()); + let (gate, dir) = test_gate(); + let policy = crate::openhuman::security::SecurityPolicy { + auto_approve_all: true, + ..crate::openhuman::security::SecurityPolicy::default() + }; + let _policy_guard = crate::openhuman::security::live_policy::install_scoped( + Arc::new(policy), + dir.path().to_path_buf(), + dir.path().to_path_buf(), + ); + + // No `with_origin` scope at all — mirrors an unlabelled call site, + // which `turn_origin::current()` maps to `AgentTurnOrigin::Unknown`. + let outcome = gate + .intercept("openhuman_test_aaa_unknown", "noop", serde_json::json!({})) + .await; + + match outcome { + GateOutcome::Deny { reason } => assert!(reason.contains("no origin label")), + other => panic!("expected deny, got {other:?}"), + } + } + + /// `auto_approve_all: true` overrides the `GoalContinuation` bypass — + /// normally that origin skips the per-tool allowlist and always parks, + /// but the blanket bypass sits above that check and allows immediately. + #[tokio::test] + async fn auto_approve_all_overrides_bypass_shortcut() { + let _env = crate::openhuman::config::TEST_ENV_LOCK + .lock() + .unwrap_or_else(|e| e.into_inner()); + let (gate, dir) = test_gate(); + let policy = crate::openhuman::security::SecurityPolicy { + auto_approve_all: true, + ..crate::openhuman::security::SecurityPolicy::default() + }; + let _policy_guard = crate::openhuman::security::live_policy::install_scoped( + Arc::new(policy), + dir.path().to_path_buf(), + dir.path().to_path_buf(), + ); + + let origin = AgentTurnOrigin::TrustedAutomation { + job_id: "goal-1".into(), + source: TrustedAutomationSource::GoalContinuation, + }; + let outcome = turn_origin::with_origin( + origin, + gate.intercept("openhuman_test_aaa_goal", "noop", serde_json::json!({})), + ) + .await; + + assert!(matches!(outcome, GateOutcome::Allow)); + assert!( + gate.list_pending().unwrap().is_empty(), + "auto_approve_all must short-circuit before any pending row is persisted" + ); + } + + /// `auto_approve_all: true` overrides a `Workflow { require_approval: true }` + /// origin — normally the user's per-flow "gate every action" choice forces + /// a park, but the blanket bypass sits above that check too. + #[tokio::test] + async fn auto_approve_all_overrides_require_approval_workflow() { + let _env = crate::openhuman::config::TEST_ENV_LOCK + .lock() + .unwrap_or_else(|e| e.into_inner()); + let (gate, dir) = test_gate(); + let policy = crate::openhuman::security::SecurityPolicy { + auto_approve_all: true, + ..crate::openhuman::security::SecurityPolicy::default() + }; + let _policy_guard = crate::openhuman::security::live_policy::install_scoped( + Arc::new(policy), + dir.path().to_path_buf(), + dir.path().to_path_buf(), + ); + + let origin = AgentTurnOrigin::TrustedAutomation { + job_id: "flow-1".into(), + source: TrustedAutomationSource::Workflow { + require_approval: true, + }, + }; + let outcome = turn_origin::with_origin( + origin, + gate.intercept("openhuman_test_aaa_workflow", "noop", serde_json::json!({})), + ) + .await; + + assert!(matches!(outcome, GateOutcome::Allow)); + assert!( + gate.list_pending().unwrap().is_empty(), + "auto_approve_all must short-circuit before any pending row is persisted" + ); + } + #[tokio::test] async fn timeout_returns_deny() { let (gate, _dir) = test_gate(); // TTL = 500ms diff --git a/src/openhuman/config/ops/agent.rs b/src/openhuman/config/ops/agent.rs index 1b06dcefa..29960326a 100644 --- a/src/openhuman/config/ops/agent.rs +++ b/src/openhuman/config/ops/agent.rs @@ -26,6 +26,10 @@ pub struct AutonomySettingsPatch { /// "Always allow" allowlist — tool names the gate skips prompting for. pub auto_approve: Option>, pub require_task_plan_approval: Option, + /// Blanket "auto-approve everything" bypass. `SubconsciousTainted` and + /// `Unknown` origins are still denied by the gate regardless of this + /// setting. + pub auto_approve_all: Option, } /// Partial update for the `[agent]` block. Currently carries the single @@ -122,6 +126,9 @@ pub async fn apply_autonomy_settings( if let Some(require_task_plan_approval) = update.require_task_plan_approval { config.autonomy.require_task_plan_approval = require_task_plan_approval; } + if let Some(auto_approve_all) = update.auto_approve_all { + config.autonomy.auto_approve_all = auto_approve_all; + } config.save().await.map_err(|e| e.to_string())?; diff --git a/src/openhuman/config/ops_tests.rs b/src/openhuman/config/ops_tests.rs index e4d391429..67424b37d 100644 --- a/src/openhuman/config/ops_tests.rs +++ b/src/openhuman/config/ops_tests.rs @@ -1748,6 +1748,67 @@ async fn apply_autonomy_settings_replaces_auto_approve() { ); } +#[tokio::test] +async fn autonomy_auto_approve_all_defaults_false() { + let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let tmp = tempdir().unwrap(); + let cfg = tmp_config(&tmp); + assert!( + !cfg.autonomy.auto_approve_all, + "fresh AutonomyConfig must default auto_approve_all to false" + ); +} + +#[tokio::test] +async fn autonomy_auto_approve_all_persists() { + // ENV_LOCK serializes the `live_policy::reload_from` triggered by + // `apply_autonomy_settings` against other live-policy-touching tests. + let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let tmp = tempdir().unwrap(); + let mut cfg = tmp_config(&tmp); + + apply_autonomy_settings( + &mut cfg, + AutonomySettingsPatch { + auto_approve_all: Some(true), + ..Default::default() + }, + ) + .await + .expect("apply auto_approve_all=true"); + assert!(cfg.autonomy.auto_approve_all); + let on_disk = tokio::fs::read_to_string(&cfg.config_path).await.unwrap(); + assert!( + on_disk.contains("auto_approve_all = true"), + "expected TOML to persist auto_approve_all = true, got:\n{on_disk}" + ); + + // Parse the saved TOML directly (rather than `load_config_with_timeout`, + // which resolves the workspace from `OPENHUMAN_WORKSPACE`/discovery and + // `tmp_config` doesn't point that at `tmp`) to confirm the value survives + // a fresh deserialize, then flip it back off and confirm that round-trips + // too. + let on_disk_cfg: crate::openhuman::config::Config = + toml::from_str(&on_disk).expect("parse saved TOML"); + assert!(on_disk_cfg.autonomy.auto_approve_all); + + apply_autonomy_settings( + &mut cfg, + AutonomySettingsPatch { + auto_approve_all: Some(false), + ..Default::default() + }, + ) + .await + .expect("apply auto_approve_all=false"); + assert!(!cfg.autonomy.auto_approve_all); + let on_disk_after = tokio::fs::read_to_string(&cfg.config_path).await.unwrap(); + assert!( + on_disk_after.contains("auto_approve_all = false"), + "expected TOML to persist auto_approve_all = false, got:\n{on_disk_after}" + ); +} + #[tokio::test] async fn add_auto_approve_tool_appends_then_dedupes() { let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); diff --git a/src/openhuman/config/schema/autonomy.rs b/src/openhuman/config/schema/autonomy.rs index 40790d141..3ed0c138d 100644 --- a/src/openhuman/config/schema/autonomy.rs +++ b/src/openhuman/config/schema/autonomy.rs @@ -31,6 +31,18 @@ pub struct AutonomyConfig { /// Settings → Agent access. Read live by the gate via `SecurityPolicy`. #[serde(default = "default_auto_approve")] pub auto_approve: Vec, + /// When true, the approval gate auto-approves ALL tool calls without + /// prompting the user — a blanket bypass of the interactive approval + /// flow, not just the per-tool `auto_approve` allowlist above. A + /// subconscious tick whose memory context is tainted by external-sync + /// content, and any unlabelled call site, are still hard-denied + /// regardless of this flag (see `ApprovalGate::intercept_audited_inner`). + /// Hard security blocks (`is_always_forbidden`, `is_workspace_internal_path`, + /// `ToolPolicyMiddleware`) live on independent code paths inside the tool + /// implementations themselves and are unaffected by this setting. + /// Defaults to `false` — existing users see no behavior change. + #[serde(default)] + pub auto_approve_all: bool, /// Directories outside the workspace the agent may access. Each entry grants /// read (or read+write) to its subtree, taking precedence over `workspace_only` /// and `forbidden_paths` — except credential stores (~/.ssh, ~/.gnupg, ~/.aws), @@ -163,6 +175,7 @@ impl Default for AutonomyConfig { require_approval_for_medium_risk: default_true(), block_high_risk_commands: default_true(), auto_approve: default_auto_approve(), + auto_approve_all: false, trusted_roots: Vec::new(), allow_tool_install: false, require_task_plan_approval: default_true(), diff --git a/src/openhuman/config/schemas/controllers.rs b/src/openhuman/config/schemas/controllers.rs index f02c1150c..538d27084 100644 --- a/src/openhuman/config/schemas/controllers.rs +++ b/src/openhuman/config/schemas/controllers.rs @@ -466,6 +466,7 @@ pub(super) fn handle_update_autonomy_settings(params: Map) -> Con .map(|v| u32::try_from(v).unwrap_or(u32::MAX)), auto_approve: update.auto_approve, require_task_plan_approval: update.require_task_plan_approval, + auto_approve_all: update.auto_approve_all, }; to_json(config_rpc::load_and_apply_autonomy_settings(patch).await?) }) diff --git a/src/openhuman/config/schemas/helpers.rs b/src/openhuman/config/schemas/helpers.rs index 5a4057fdb..355f6aa40 100644 --- a/src/openhuman/config/schemas/helpers.rs +++ b/src/openhuman/config/schemas/helpers.rs @@ -243,6 +243,10 @@ pub(super) struct AutonomySettingsUpdate { /// may run without an approval prompt. Empty list clears it. pub(super) auto_approve: Option>, pub(super) require_task_plan_approval: Option, + /// Blanket "auto-approve everything" bypass. `SubconsciousTainted` and + /// `Unknown` origins are still denied by the gate regardless of this + /// setting. + pub(super) auto_approve_all: Option, } #[derive(Debug, Deserialize)] diff --git a/src/openhuman/config/schemas/schema_defs.rs b/src/openhuman/config/schemas/schema_defs.rs index 8a2f71e61..6ad3c9b43 100644 --- a/src/openhuman/config/schemas/schema_defs.rs +++ b/src/openhuman/config/schemas/schema_defs.rs @@ -224,6 +224,7 @@ pub fn schemas(function: &str) -> ControllerSchema { required: false, }, optional_bool("require_task_plan_approval", "Require approval before an agent executes a task-board plan."), + optional_bool("auto_approve_all", "When true, auto-approve all tool calls without prompting. SubconsciousTainted and Unknown origins still denied. Hard security blocks unaffected."), ], outputs: vec![json_output("snapshot", "Updated config snapshot.")], }, diff --git a/src/openhuman/security/live_policy.rs b/src/openhuman/security/live_policy.rs index a163021ef..00f49bd46 100644 --- a/src/openhuman/security/live_policy.rs +++ b/src/openhuman/security/live_policy.rs @@ -88,6 +88,13 @@ thread_local! { /// on the same thread while sibling tests on their own threads are unaffected. static TEST_PRIVACY_MODE: std::cell::Cell> = const { std::cell::Cell::new(None) }; + + /// Test-only, thread-scoped live-policy override. Approval-gate tests run + /// in parallel on separate `#[tokio::test]` current-thread runtimes, so a + /// process-global override can otherwise make an unrelated test observe a + /// transient `auto_approve_all` value and skip the park it is waiting for. + static TEST_POLICY_OVERRIDE: std::cell::RefCell>> = + const { std::cell::RefCell::new(None) }; } /// RAII guard that restores the previous thread-local privacy override on drop. @@ -110,6 +117,38 @@ pub(crate) fn test_privacy_scope(mode: PrivacyMode) -> TestPrivacyGuard { TestPrivacyGuard(prev) } +/// RAII guard returned by [`install_scoped`]. Restores the calling test +/// thread's prior policy override on drop, including on panic/unwind. +#[cfg(test)] +pub(crate) struct TestPolicyGuard { + prev_policy: Option>, +} + +#[cfg(test)] +impl Drop for TestPolicyGuard { + fn drop(&mut self) { + TEST_POLICY_OVERRIDE.with(|current| { + current.replace(self.prev_policy.take()); + }); + } +} + +/// Override [`current`] for the calling test thread for the duration of the +/// returned guard. This deliberately does not mutate process-global state: +/// sibling tests that call [`current`] must never observe the scoped policy. +/// The path arguments mirror [`install`] so tests can use the same call shape; +/// paths are already carried by `policy` and do not need separate storage for +/// this read-only override. +#[cfg(test)] +pub(crate) fn install_scoped( + policy: Arc, + _workspace_dir: PathBuf, + _action_dir: PathBuf, +) -> TestPolicyGuard { + let prev_policy = TEST_POLICY_OVERRIDE.with(|current| current.replace(Some(policy))); + TestPolicyGuard { prev_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 @@ -126,6 +165,11 @@ pub fn current_privacy_mode() -> PrivacyMode { /// The current live policy, if one has been [`install`]ed this process. pub fn current() -> Option> { + #[cfg(test)] + if let Some(policy) = TEST_POLICY_OVERRIDE.with(|current| current.borrow().clone()) { + return Some(policy); + } + STATE .get() .and_then(|s| s.policy.read().ok().map(|g| Arc::clone(&g))) @@ -329,6 +373,45 @@ mod tests { use crate::openhuman::config::AutonomyConfig; use crate::openhuman::security::AutonomyLevel; + #[test] + fn scoped_policy_is_thread_local_and_restored() { + let _env = crate::openhuman::config::TEST_ENV_LOCK + .lock() + .unwrap_or_else(|e| e.into_inner()); + let workspace = std::env::temp_dir().join("openhuman_scoped_policy_test"); + install( + Arc::new(SecurityPolicy::default()), + workspace.clone(), + workspace.clone(), + ); + + let scoped = Arc::new(SecurityPolicy { + auto_approve_all: true, + ..SecurityPolicy::default() + }); + { + let _guard = install_scoped(scoped, workspace.clone(), workspace); + assert!(current().expect("scoped policy installed").auto_approve_all); + + let sibling_value = std::thread::spawn(|| { + current() + .expect("global policy remains installed") + .auto_approve_all + }) + .join() + .expect("sibling thread joined"); + assert!( + !sibling_value, + "a sibling test thread must not observe the scoped policy" + ); + } + + assert!( + !current().expect("global policy restored").auto_approve_all, + "dropping the guard must restore the calling thread's prior view" + ); + } + #[test] fn install_then_reload_swaps_policy_and_bumps_generation() { // Serialize against other tests that install/reload this process-global diff --git a/src/openhuman/security/policy/enforcement.rs b/src/openhuman/security/policy/enforcement.rs index ab6004bd0..6e1894496 100644 --- a/src/openhuman/security/policy/enforcement.rs +++ b/src/openhuman/security/policy/enforcement.rs @@ -77,11 +77,12 @@ impl SecurityPolicy { action_dir: &Path, ) -> Self { log::info!( - "[openhuman:policy] SecurityPolicy created: autonomy={:?}, workspace_only={}, allowed_cmds={}, max_actions/hr={}", + "[openhuman:policy] SecurityPolicy created: autonomy={:?}, workspace_only={}, allowed_cmds={}, max_actions/hr={}, auto_approve_all={}", autonomy_config.level, autonomy_config.workspace_only, autonomy_config.allowed_commands.len(), - autonomy_config.max_actions_per_hour + autonomy_config.max_actions_per_hour, + autonomy_config.auto_approve_all ); // `auto_approve` is the user's "Always allow" allowlist: the @@ -164,6 +165,7 @@ impl SecurityPolicy { trusted_roots, allow_tool_install: autonomy_config.allow_tool_install, auto_approve: autonomy_config.auto_approve.clone(), + auto_approve_all: autonomy_config.auto_approve_all, tracker: ActionTracker::new(), canonical_workspace: Arc::new(OnceCell::new()), } diff --git a/src/openhuman/security/policy/policy_tests.rs b/src/openhuman/security/policy/policy_tests.rs index 132ac71ea..1978eee17 100644 --- a/src/openhuman/security/policy/policy_tests.rs +++ b/src/openhuman/security/policy/policy_tests.rs @@ -1049,6 +1049,25 @@ fn from_config_maps_all_fields() { assert_eq!(policy.auto_approve, vec!["shell", "file_write"]); } +#[test] +fn policy_from_config_carries_auto_approve_all() { + let workspace = PathBuf::from("/tmp/test-workspace"); + + let enabled_config = crate::openhuman::config::AutonomyConfig { + auto_approve_all: true, + ..crate::openhuman::config::AutonomyConfig::default() + }; + let enabled_policy = SecurityPolicy::from_config(&enabled_config, &workspace, &workspace); + assert!(enabled_policy.auto_approve_all); + + let disabled_config = crate::openhuman::config::AutonomyConfig { + auto_approve_all: false, + ..crate::openhuman::config::AutonomyConfig::default() + }; + let disabled_policy = SecurityPolicy::from_config(&disabled_config, &workspace, &workspace); + assert!(!disabled_policy.auto_approve_all); +} + // -- Default policy ----------------------------------------------- #[test] diff --git a/src/openhuman/security/policy/types.rs b/src/openhuman/security/policy/types.rs index 317eb3bb7..a3a683ebd 100644 --- a/src/openhuman/security/policy/types.rs +++ b/src/openhuman/security/policy/types.rs @@ -244,6 +244,15 @@ pub struct SecurityPolicy { /// `autonomy.auto_approve`; populated/cleared via `config.update_autonomy_settings` /// (or an "Always allow" decision) and observed live via `live_policy`. pub auto_approve: Vec, + /// When true, the approval gate auto-approves ALL tool calls without + /// prompting — a blanket bypass, not just the `auto_approve` allowlist + /// above. `TrustedAutomationSource::SubconsciousTainted` and + /// `AgentTurnOrigin::Unknown` origins are still denied by the gate + /// regardless of this flag. Sourced from `autonomy.auto_approve_all`; + /// observed live via `live_policy`. Does not affect + /// `is_always_forbidden`, `is_workspace_internal_path`, or + /// `ToolPolicyMiddleware`, which are independent code paths. + pub auto_approve_all: bool, pub tracker: ActionTracker, /// Lazily-cached canonical form of [`workspace_dir`]. /// @@ -369,6 +378,7 @@ impl Default for SecurityPolicy { trusted_roots: Vec::new(), allow_tool_install: false, auto_approve: Vec::new(), + auto_approve_all: false, tracker: ActionTracker::new(), canonical_workspace: Arc::new(OnceCell::new()), } diff --git a/tests/json_rpc_e2e.rs b/tests/json_rpc_e2e.rs index 5db132b15..ade98ad76 100644 --- a/tests/json_rpc_e2e.rs +++ b/tests/json_rpc_e2e.rs @@ -11830,6 +11830,49 @@ async fn json_rpc_config_autonomy_settings_roundtrip() { "auto_approve allowlist should round-trip, got envelope: {after_allow_outer}" ); + // `auto_approve_all` (the blanket bypass) round-trips through the same + // update/get path, and defaults to `false`. + let initial_auto_all = initial_outer + .get("result") + .and_then(|r| r.get("auto_approve_all")) + .and_then(Value::as_bool); + assert_eq!( + initial_auto_all, + Some(false), + "auto_approve_all should default to false, got envelope: {initial_outer}" + ); + + let update_auto_all = post_json_rpc( + &rpc_base, + 7007, + "openhuman.config_update_autonomy_settings", + json!({ "auto_approve_all": true }), + ) + .await; + assert_no_jsonrpc_error( + &update_auto_all, + "update_autonomy_settings auto_approve_all", + ); + + let after_auto_all = post_json_rpc( + &rpc_base, + 7008, + "openhuman.config_get_autonomy_settings", + json!({}), + ) + .await; + let after_auto_all_outer = + assert_no_jsonrpc_error(&after_auto_all, "get_autonomy_settings auto_approve_all"); + let auto_all_value = after_auto_all_outer + .get("result") + .and_then(|r| r.get("auto_approve_all")) + .and_then(Value::as_bool); + assert_eq!( + auto_all_value, + Some(true), + "auto_approve_all should round-trip to true, got envelope: {after_auto_all_outer}" + ); + mock_join.abort(); rpc_join.abort(); }