diff --git a/THREAT_MODEL.md b/THREAT_MODEL.md index bf3bad9bb..27131390f 100644 --- a/THREAT_MODEL.md +++ b/THREAT_MODEL.md @@ -64,7 +64,7 @@ External content that reaches the LLM is treated as untrusted via `src/prompt_se `src/tool_capabilities.py` carries a second layer: once untrusted content has entered a run, `ToolRunSecurityContext.decision_for()` blocks tools that execute code, mutate state, or cause external side effects until the user authorises the action separately. -It is on unless `ODYSSEUS_TOOL_APPROVAL_GATE` is set to a falsy value (`0`/`false`/`no`/`off`). Request authority narrows which tool families a turn may use, but within an admitted family it does not bind the exact action: a request to read email admits `send_email` and `delete_email`, and agent processes inherit the host network. Until those are covered by their own boundaries, this gate is what stops an injected instruction from reaching them. Turning it off lets a successful injection reach `bash`, `host_shell`, `send_email` and `delete_email` without a separate confirmation. +It is on unless `ODYSSEUS_TOOL_APPROVAL_GATE` is set to a falsy value (`0`/`false`/`no`/`off`). The admin-only `tool_approval_mode` setting (Settings → Agent Tools, `off` = "Off (YOLO)") is the same knob exposed in the UI: with `off`, every run sets `approval_gate_bypassed` and this gate stops asking, exactly as if the env var were off. Request authority narrows which tool families a turn may use, but within an admitted family it does not bind the exact action: a request to read email admits `send_email` and `delete_email`, and agent processes inherit the host network. Until those are covered by their own boundaries, this gate is what stops an injected instruction from reaching them. Turning it off — by either knob — lets a successful injection reach `bash`, `host_shell`, `send_email` and `delete_email` without a separate confirmation. Three exemptions apply even when the gate is on, all deliberate: diff --git a/routes/auth_routes.py b/routes/auth_routes.py index 378f22afa..fc47719b7 100644 --- a/routes/auth_routes.py +++ b/routes/auth_routes.py @@ -18,6 +18,7 @@ from src.constants import DEEP_RESEARCH_DIR, MEMORY_FILE, PASSWORD_MIN_LENGTH, S from src.auth_helpers import _auth_disabled from src.rate_limiter import RateLimiter from src.settings_scrub import scrub_settings +from src import tool_capabilities from src.settings import ( load_settings as _load_settings, save_settings as _save_settings, @@ -753,6 +754,11 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter: user = _get_current_user(request) settings = without_retired_settings(_load_settings()) if _auth_disabled() or (user and auth_manager.is_admin(user)): + # Deployment-level truth for the Tool approval row: a falsy + # ODYSSEUS_TOOL_APPROVAL_GATE disables the approval gate for every + # run regardless of the stored tool_approval_mode, so surface it to + # admins instead of letting the row mislead them the other way. + settings["tool_approval_gate_env_disabled"] = not tool_capabilities.TOOL_APPROVAL_GATE_ENABLED return settings return scrub_settings(settings) @@ -769,6 +775,9 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter: "agent_max_tool_calls": (0, 1000), # 0 = unlimited "auto_compact_threshold_percent": (50, 95), } + _VALID_VALUES = { + "tool_approval_mode": {"ask", "off"}, + } for key in DEFAULT_SETTINGS: if key in RETIRED_SETTING_KEYS: continue @@ -782,6 +791,15 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter: except (TypeError, ValueError): raise HTTPException(400, f"{key} must be an integer") val = max(lo, min(val, hi)) + + if key in _VALID_VALUES: + allowed = _VALID_VALUES[key] + if not isinstance(val, str) or val not in allowed: + raise HTTPException( + 400, + f"{key} must be one of {sorted(allowed)}", + ) + current[key] = val _save_settings(current) return without_retired_settings(current) diff --git a/src/agent_loop.py b/src/agent_loop.py index 93943f98d..6c03df34c 100644 --- a/src/agent_loop.py +++ b/src/agent_loop.py @@ -14,12 +14,12 @@ import csv import difflib import html import json +import logging import os import re import shlex import shutil import time -import logging import hashlib from src.web_recovery import WebRecoveryBudget from itertools import count @@ -16994,6 +16994,20 @@ def _detect_runaway_call(call_freq, threshold=15): sig = next((s for s, n in call_freq.items() if n >= threshold), None) return sig.split(":", 1)[0] if sig else None +def _tool_approval_gate_bypassed( + exact_approval: Optional[ExactToolApproval] = None, +) -> bool: + """Return whether the post-external-context tool approval gate is bypassed. + + ``tool_approval_mode=off`` (admin setting, Settings → Agent Tools) bypasses + the gate on every run, the same effect as ``ODYSSEUS_TOOL_APPROVAL_GATE=0``. + A chat-session grant bypasses it only within the scope of its own task. + """ + return ( + get_setting("tool_approval_mode", "ask") == "off" + or bool(exact_approval and exact_approval.allow_remaining_actions) + ) + def _tool_result_signature(tool_result_records: list[dict]) -> str: """Return a bounded fingerprint of the observable result of a tool batch. @@ -21437,9 +21451,7 @@ async def stream_agent_loop( ) or messages_contain_external_untrusted_context(messages) ), - approval_gate_bypassed=bool( - exact_approval and exact_approval.allow_remaining_actions - ), + approval_gate_bypassed=_tool_approval_gate_bypassed(exact_approval), delegated_credential=bool(delegated_credential), ) _has_tui_host_bridge = _tui_host_bridge_is_usable(client_runtime_context) diff --git a/src/settings.py b/src/settings.py index 11f7dc462..23245c22e 100644 --- a/src/settings.py +++ b/src/settings.py @@ -120,6 +120,7 @@ DEFAULT_SETTINGS = { # Tune via Settings or by editing data/settings.json. "research_run_timeout_seconds": 1800, "agent_max_tool_calls": 0, + "tool_approval_mode": "ask", "agent_max_rounds": 20, # per-message agent step cap (clamped 1..200) # Soft input-token budget for the agent loop. The DEFAULT value (6000) is the # "auto" sentinel: it means "scale the budget to the model's context window" diff --git a/static/index.html b/static/index.html index 5521d4b85..b1d2031df 100644 --- a/static/index.html +++ b/static/index.html @@ -2463,6 +2463,22 @@ +
+ + +
+ +
+ Auto-approve turns off the tool approval gate (same control as the + ODYSSEUS_TOOL_APPROVAL_GATE env var). Bash, host_shell, send_email and + delete_email then run without a confirmation even after the agent has + seen untrusted external content (email, web pages, documents), so a + prompt-injected instruction can reach them directly. +
+
diff --git a/static/js/settings/agent.js b/static/js/settings/agent.js index 7c48e9831..2b27c5768 100644 --- a/static/js/settings/agent.js +++ b/static/js/settings/agent.js @@ -10,6 +10,7 @@ export async function initAgentSettings() { var toolsInput = el('set-agentMaxTools'); var roundsInput = el('set-agentMaxRounds'); var supInput = el('set-agentSupervisorLadder'); + var approvalModeInput = el('set-toolApprovalMode'); var msg = el('set-agentMsg'); if (!toolsInput) return; @@ -19,6 +20,21 @@ export async function initAgentSettings() { if (settings.agent_max_tool_calls) toolsInput.value = settings.agent_max_tool_calls; if (roundsInput && settings.agent_max_rounds) roundsInput.value = settings.agent_max_rounds; if (supInput) supInput.checked = !!settings.agent_supervisor_ladder; + if (approvalModeInput) { + approvalModeInput.value = + settings.tool_approval_mode === 'off' ? 'off' : 'ask'; + } + var approvalEnvNote = el('set-toolApprovalEnvNote'); + if (approvalEnvNote) { + if (settings.tool_approval_gate_env_disabled) { + approvalEnvNote.textContent = + 'Forced by deployment: ODYSSEUS_TOOL_APPROVAL_GATE is set to a falsy value on this server, so the approval gate is off for every run regardless of this setting.'; + approvalEnvNote.style.display = 'block'; + } else { + approvalEnvNote.textContent = ''; + approvalEnvNote.style.display = 'none'; + } + } } catch (e) {} // Clamp + coerce a raw input to an int in [lo, hi]; falls back to `dflt` @@ -37,6 +53,10 @@ export async function initAgentSettings() { var payload = { agent_max_tool_calls: tools }; if (rounds != null) payload.agent_max_rounds = rounds; if (supInput) payload.agent_supervisor_ladder = !!supInput.checked; + if (approvalModeInput) { + payload.tool_approval_mode = + approvalModeInput.value === 'off' ? 'off' : 'ask'; + } try { await _postSettings(payload); msg.textContent = (tools > 0 ? 'Limit: ' + tools + ' tool calls' : 'Unlimited tool calls') + @@ -49,6 +69,7 @@ export async function initAgentSettings() { toolsInput.addEventListener('change', save); if (roundsInput) roundsInput.addEventListener('change', save); if (supInput) supInput.addEventListener('change', save); + if (approvalModeInput) approvalModeInput.addEventListener('change', save); var cur = parseInt(toolsInput.value, 10) || 0; var curR = roundsInput ? (parseInt(roundsInput.value, 10) || 20) : null; msg.textContent = (cur > 0 ? 'Limit: ' + cur + ' tool calls' : 'Unlimited tool calls') + diff --git a/tests/test_retired_settings_interfaces.py b/tests/test_retired_settings_interfaces.py index d11e3afb8..7c7820aa5 100644 --- a/tests/test_retired_settings_interfaces.py +++ b/tests/test_retired_settings_interfaces.py @@ -3,6 +3,7 @@ import asyncio import json from types import SimpleNamespace +from fastapi import HTTPException import pytest @@ -117,3 +118,62 @@ def test_manage_settings_tombstones_legacy_fallback_key(monkeypatch): assert save_calls == [] assert store["default_model_fallbacks"] == LEGACY_VALUE + +@pytest.mark.asyncio +async def test_tool_approval_mode_accepts_only_supported_values(monkeypatch): + store = dict(settings_mod.DEFAULT_SETTINGS) + + monkeypatch.setattr(auth_routes, "migrate_from_settings", lambda: None) + monkeypatch.setattr(auth_routes, "_load_settings", lambda: dict(store)) + + def save_settings(updated): + store.clear() + store.update(updated) + + monkeypatch.setattr(auth_routes, "_save_settings", save_settings) + + router = auth_routes.setup_auth_routes(_AuthManager()) + set_settings = _route(router, "/api/auth/settings", "POST") + + response = await set_settings( + _Request({"tool_approval_mode": "off"}, admin=True) + ) + assert response["tool_approval_mode"] == "off" + assert store["tool_approval_mode"] == "off" + + response = await set_settings( + _Request({"tool_approval_mode": "ask"}, admin=True) + ) + assert response["tool_approval_mode"] == "ask" + assert store["tool_approval_mode"] == "ask" + + with pytest.raises(HTTPException) as exc_info: + await set_settings( + _Request({"tool_approval_mode": "banana"}, admin=True) + ) + + assert exc_info.value.status_code == 400 + +@pytest.mark.asyncio +async def test_settings_expose_gate_env_kill_switch_to_admins_only(monkeypatch): + store = dict(settings_mod.DEFAULT_SETTINGS) + + monkeypatch.setattr(auth_routes, "migrate_from_settings", lambda: None) + monkeypatch.setattr(auth_routes, "_load_settings", lambda: dict(store)) + + router = auth_routes.setup_auth_routes(_AuthManager()) + get_settings = _route(router, "/api/auth/settings", "GET") + + monkeypatch.setattr(auth_routes.tool_capabilities, "TOOL_APPROVAL_GATE_ENABLED", True) + response = await get_settings(_Request(admin=True)) + assert response["tool_approval_gate_env_disabled"] is False + + monkeypatch.setattr(auth_routes.tool_capabilities, "TOOL_APPROVAL_GATE_ENABLED", False) + response = await get_settings(_Request(admin=True)) + assert response["tool_approval_gate_env_disabled"] is True + # The stored setting itself is untouched by the env kill-switch. + assert response["tool_approval_mode"] == "ask" + + # Unauthenticated/scrubbed callers must not learn the deployment gate state. + response = await get_settings(_Request()) + assert "tool_approval_gate_env_disabled" not in response diff --git a/tests/test_tool_approval_task_scope.py b/tests/test_tool_approval_task_scope.py index 951bac0be..b1340318a 100644 --- a/tests/test_tool_approval_task_scope.py +++ b/tests/test_tool_approval_task_scope.py @@ -340,7 +340,7 @@ def test_route_context_agent_frontend_and_cache_bust_wire_the_contract(): assert "_without_latest_matching_user_message(" not in helpers assert "selected_tools=approval_selected_tools" in agent assert "continuation_query=_retrieval_query or _last_user" in agent - assert "approval_gate_bypassed=bool(" in agent + assert "approval_gate_bypassed=_tool_approval_gate_bypassed(" in agent assert "['approve', 'approve_task', 'deny']" in frontend assert "input.value = label" not in frontend assert "const msg = approvalForSend ? '' : el('message').value;" in frontend diff --git a/tests/test_tool_approvals.py b/tests/test_tool_approvals.py index 9ad1837b1..99924feaf 100644 --- a/tests/test_tool_approvals.py +++ b/tests/test_tool_approvals.py @@ -500,3 +500,37 @@ async def test_dispatcher_revalidates_sealed_workspace(monkeypatch, tmp_path): assert result["blocked"] is True assert result["policy"] == "exact_tool_approval" + +def test_tool_approval_mode_defaults_to_ask(monkeypatch): + import src.agent_loop as agent_loop + + monkeypatch.setattr( + agent_loop, + "get_setting", + lambda key, default=None: ( + "ask" if key == "tool_approval_mode" else default + ), + ) + + assert agent_loop._tool_approval_gate_bypassed() is False + +def test_tool_approval_gate_respects_setting(monkeypatch): + import src.agent_loop as agent_loop + + monkeypatch.setattr( + agent_loop, + "get_setting", + lambda key, default=None: ( + "ask" if key == "tool_approval_mode" else default + ), + ) + assert agent_loop._tool_approval_gate_bypassed() is False + + monkeypatch.setattr( + agent_loop, + "get_setting", + lambda key, default=None: ( + "off" if key == "tool_approval_mode" else default + ), + ) + assert agent_loop._tool_approval_gate_bypassed() is True