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