diff --git a/.env.example b/.env.example index edb147aaf..f1b396a47 100644 --- a/.env.example +++ b/.env.example @@ -101,9 +101,9 @@ SEARXNG_INSTANCE=http://localhost:8080 # Skip the external-context exact-approval pause for unattended local agents. # Keep false for shared or internet-exposed deployments. -# Optional post-external-context tool approval gate. Off by default because it -# can block normal agent work; enable only for deployments that want this fence. -# ODYSSEUS_TOOL_APPROVAL_GATE=0 +# Post-external-context tool approval gate. On by default; set to 0 only for +# trusted single-user setups that accept injected content reaching side-effect tools. +# ODYSSEUS_TOOL_APPROVAL_GATE=1 # Mark session cookies Secure. Left unset, this follows the request scheme: # an HTTPS login gets a Secure cookie, a plain-HTTP one does not. Set true to diff --git a/THREAT_MODEL.md b/THREAT_MODEL.md index d63d1335b..ba4999157 100644 --- a/THREAT_MODEL.md +++ b/THREAT_MODEL.md @@ -60,13 +60,11 @@ External content that reaches the LLM is treated as untrusted via `src/prompt_se **Untrusted surfaces that must go through this wrapper:** web search results, fetched URLs, emails (read), saved memories, skill text, notes, and any tool output sourced from outside the server. Injecting untrusted content directly into the system role is a security bug. -### Post-external-context tool approval gate — off by default +### Post-external-context tool approval gate — on by default `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 disabled unless `ODYSSEUS_TOOL_APPROVAL_GATE` is set** (`1`/`true`/`yes`/`on`). The default is off because the gate is conservative enough to interrupt ordinary agent work. That is a deliberate usability trade, and it means a default deployment relies on the wrapper above — not on the gate — to contain injected instructions. - -Operators who run the agent against untrusted web or email content with side-effecting tools enabled should turn it on. With the gate off, a successful injection can reach `bash`, `host_shell`, `send_email` and `delete_email` without a separate confirmation; with it on, each of those is refused until approved. +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. Two exemptions apply even when the gate is on, both deliberate: @@ -85,7 +83,7 @@ Two exemptions apply even when the gate is on, both deliberate: These are open, acknowledged, and contributor help is welcome: -1. **No shell/filesystem sandbox.** The agent `bash` and `read_file`/`write_file` tools run as the app process user with no network egress filtering or filesystem confinement. A successful prompt-injection reaching a shell-enabled admin session can make outbound requests to internal services. See #1058 for the sandbox proposal. The tool approval gate above is the compensating control, and it is off by default — so on a default deployment this gap is unmitigated beyond the untrusted-context wrapper. +1. **No shell/filesystem sandbox.** The agent `bash` and `read_file`/`write_file` tools run as the app process user with no network egress filtering or filesystem confinement. A successful prompt-injection reaching a shell-enabled admin session can make outbound requests to internal services. See #1058 for the sandbox proposal. The tool approval gate above is the compensating control and is on by default; turning it off leaves this gap unmitigated beyond the untrusted-context wrapper. 2. **SSRF via `/api/v1/chat` `base_url` parameter.** A chat-scoped API token can supply an arbitrary `base_url`; the server forwards the LLM request to that host without validating the scheme or address. PR #1039 fixes this. diff --git a/scripts/generate_env_reference.py b/scripts/generate_env_reference.py index b6edad2a6..d3372fbc7 100644 --- a/scripts/generate_env_reference.py +++ b/scripts/generate_env_reference.py @@ -488,8 +488,9 @@ VARIABLE_NOTES: dict[str, tuple[str, str, str]] = { # -- Agent loop and tool execution ------------------------------------- "ODYSSEUS_TOOL_APPROVAL_GATE": ( "Agent loop and tool execution", USER, - "Security-relevant. Truthy makes tool calls pass through the approval gate. " - "Off by default.", + "Security-relevant. On by default: after external content enters a run, " + "tools that execute code, mutate state or cause external side effects need " + "a separate approval. Set to 0 to opt out.", ), "ODYSSEUS_MCP_ALLOWED_COMMANDS": ( "Agent loop and tool execution", USER, diff --git a/src/tool_capabilities.py b/src/tool_capabilities.py index 89bba9596..f0ba4b3b1 100644 --- a/src/tool_capabilities.py +++ b/src/tool_capabilities.py @@ -678,9 +678,13 @@ POST_EXTERNAL_BLOCKED_EFFECTS = frozenset( ) +# On by default: until agent processes run without network and side-effecting +# non-process tools have their own exact-approval boundary, this gate is the +# only check between injected external content and those tools. Set it to a +# falsy value to opt out. TOOL_APPROVAL_GATE_ENABLED = ( - str(os.getenv("ODYSSEUS_TOOL_APPROVAL_GATE", "0")).strip().lower() - in {"1", "true", "yes", "on"} + str(os.getenv("ODYSSEUS_TOOL_APPROVAL_GATE", "1")).strip().lower() + not in {"0", "false", "no", "off"} ) diff --git a/tests/test_agent_runtime_context.py b/tests/test_agent_runtime_context.py index 403835231..ebb793f9f 100644 --- a/tests/test_agent_runtime_context.py +++ b/tests/test_agent_runtime_context.py @@ -1109,6 +1109,9 @@ def test_tui_local_workspace_turn_hides_backend_file_tools(monkeypatch): def test_native_host_shell_call_runs_through_bridge_and_threads_result(monkeypatch): + # Routing test written for the opt-out posture; the gate default is pinned in + # tests/test_tool_approval_gate_default.py. + monkeypatch.setattr("src.tool_capabilities.TOOL_APPROVAL_GATE_ENABLED", False) monkeypatch.setattr(al, "get_setting", lambda key, default=None: default, raising=False) monkeypatch.setattr(al, "get_mcp_manager", lambda: None, raising=False) monkeypatch.setattr(al, "estimate_tokens", lambda *args, **kwargs: 10, raising=False) diff --git a/tests/test_tool_approval_gate_default.py b/tests/test_tool_approval_gate_default.py new file mode 100644 index 000000000..372b56ed7 --- /dev/null +++ b/tests/test_tool_approval_gate_default.py @@ -0,0 +1,52 @@ +"""Pin the production default of the post-external-context approval gate. + +Every other gate test sets ``TOOL_APPROVAL_GATE_ENABLED`` explicitly, so none of +them notices if the default flips. The flag is read once at import, so each +case imports the module in a fresh interpreter with a controlled environment. +""" +import json +import os +import subprocess +import sys +from pathlib import Path + +import pytest + +REPO = Path(__file__).resolve().parents[1] + +_PROBE = """ +import json +from src.tool_capabilities import TOOL_APPROVAL_GATE_ENABLED, ToolRunSecurityContext +context = ToolRunSecurityContext(external_untrusted_context_seen=True, external_sources=["read_email"]) +print(json.dumps({ + "enabled": TOOL_APPROVAL_GATE_ENABLED, + "allowed": {tool: context.decision_for(tool, "{}").allowed + for tool in ("bash", "send_email", "delete_email", "read_file")}, +})) +""" + + +def _probe(gate_value): + env = {key: value for key, value in os.environ.items() if key != "ODYSSEUS_TOOL_APPROVAL_GATE"} + if gate_value is not None: + env["ODYSSEUS_TOOL_APPROVAL_GATE"] = gate_value + out = subprocess.run( + [sys.executable, "-c", _PROBE], cwd=REPO, env=env, + capture_output=True, text=True, timeout=60, check=True, + ) + return json.loads(out.stdout.strip().splitlines()[-1]) + + +def test_gate_is_on_when_the_variable_is_unset(): + result = _probe(None) + assert result["enabled"] is True + assert result["allowed"] == { + "bash": False, "send_email": False, "delete_email": False, "read_file": True, + } + + +@pytest.mark.parametrize("value", ["0", "false", "no", "off", " OFF "]) +def test_gate_can_be_turned_off_explicitly(value): + result = _probe(value) + assert result["enabled"] is False + assert all(result["allowed"].values()) diff --git a/tests/test_tool_policy.py b/tests/test_tool_policy.py index 40d875cb2..d9e9c75f8 100644 --- a/tests/test_tool_policy.py +++ b/tests/test_tool_policy.py @@ -1613,6 +1613,9 @@ def test_agent_loop_synthesizes_web_answer_after_tool_preamble(monkeypatch): def test_open_calendar_request_uses_ui_control_panel_not_event_dump(monkeypatch): + # Routing test written for the opt-out posture; the gate default is pinned in + # tests/test_tool_approval_gate_default.py. + monkeypatch.setattr("src.tool_capabilities.TOOL_APPROVAL_GATE_ENABLED", False) _patch_loop_basics(monkeypatch) src = Path(__file__).resolve().parent.parent.joinpath("src", "agent_loop.py").read_text(encoding="utf-8") assert 'if isinstance(_ev, dict) and _ev.get("context_only"):' in src @@ -1679,6 +1682,9 @@ def test_open_calendar_request_uses_ui_control_panel_not_event_dump(monkeypatch) def test_calendar_create_response_includes_persistent_event_link(monkeypatch): + # Routing test written for the opt-out posture; the gate default is pinned in + # tests/test_tool_approval_gate_default.py. + monkeypatch.setattr("src.tool_capabilities.TOOL_APPROVAL_GATE_ENABLED", False) _patch_loop_basics(monkeypatch) from src.user_time import clear_user_time_context, set_user_timezone @@ -3505,6 +3511,9 @@ def test_notes_about_calendar_context_still_route_to_notes(): def test_notes_panel_open_plus_create_keeps_both_tool_calls(monkeypatch): + # Routing test written for the opt-out posture; the gate default is pinned in + # tests/test_tool_approval_gate_default.py. + monkeypatch.setattr("src.tool_capabilities.TOOL_APPROVAL_GATE_ENABLED", False) _patch_loop_basics(monkeypatch) seen_blocks = [] diff --git a/website/configuration-reference.md b/website/configuration-reference.md index f94dd2e66..98ff1f6b4 100644 --- a/website/configuration-reference.md +++ b/website/configuration-reference.md @@ -77,7 +77,7 @@ The source tree reads **117** `ODYSSEUS_*` variables: 81 an operator may want to | `ODYSSEUS_MCP_ALLOWED_COMMANDS` | `''` | `src/agent_tools/admin_tools.py:140` | Security-relevant. Comma-separated allowlist of MCP launcher basenames the agent may start. Empty by default, and the deny list still wins. | | `ODYSSEUS_PYTHON_TOOL_SITE_PACKAGES` | `''` | `src/agent_runtime/process_resources.py:59` (+2 more) | Security-relevant. Absolute package roots, separated by the platform path separator, exposed to the sandboxed Python tool. Empty exposes none. | | `ODYSSEUS_SCRIPT_HOST` | `'localhost'` | `src/builtin_actions.py:925` | Default host for the run-script action. `localhost`, `127.0.0.1`, `local` and empty run locally; any other value runs over SSH. | -| `ODYSSEUS_TOOL_APPROVAL_GATE` | `'0'` | `src/tool_capabilities.py:682` | Security-relevant. Truthy makes tool calls pass through the approval gate. Off by default. | +| `ODYSSEUS_TOOL_APPROVAL_GATE` | `'1'` | `src/tool_capabilities.py:686` | Security-relevant. On by default: after external content enters a run, tools that execute code, mutate state or cause external side effects need a separate approval. Set to 0 to opt out. | ### Browser automation