Compare commits

..
14 changed files with 11 additions and 4858 deletions
-17
View File
@@ -264,20 +264,3 @@ jobs:
ODYSSEUS_TEST_PYTHON=python ODYSSEUS_LAYOUT_BROWSER=chromium npx --no-install playwright test \ ODYSSEUS_TEST_PYTHON=python ODYSSEUS_LAYOUT_BROWSER=chromium npx --no-install playwright test \
--config tests/e2e/ios-layout.config.cjs \ --config tests/e2e/ios-layout.config.cjs \
--reporter=line --reporter=line
benchmark-gfm-security:
name: Benchmark GFM rendering security
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: "3.11"
# Test-only renderer, pinned independently of application requirements.
- name: Install GitHub Flavored Markdown renderer
run: |
python -m pip install --disable-pip-version-check --only-binary=:all: "cmarkgfm==2025.10.22"
- name: Verify rendered benchmark report security
run: python tests/test_audit_c5_benchmark_evidence.py --gfm
+1 -1
View File
@@ -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. `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`). 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. 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.
Three exemptions apply even when the gate is on, all deliberate: Three exemptions apply even when the gate is on, all deliberate:
-18
View File
@@ -18,7 +18,6 @@ from src.constants import DEEP_RESEARCH_DIR, MEMORY_FILE, PASSWORD_MIN_LENGTH, S
from src.auth_helpers import _auth_disabled from src.auth_helpers import _auth_disabled
from src.rate_limiter import RateLimiter from src.rate_limiter import RateLimiter
from src.settings_scrub import scrub_settings from src.settings_scrub import scrub_settings
from src import tool_capabilities
from src.settings import ( from src.settings import (
load_settings as _load_settings, load_settings as _load_settings,
save_settings as _save_settings, save_settings as _save_settings,
@@ -754,11 +753,6 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter:
user = _get_current_user(request) user = _get_current_user(request)
settings = without_retired_settings(_load_settings()) settings = without_retired_settings(_load_settings())
if _auth_disabled() or (user and auth_manager.is_admin(user)): 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 settings
return scrub_settings(settings) return scrub_settings(settings)
@@ -775,9 +769,6 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter:
"agent_max_tool_calls": (0, 1000), # 0 = unlimited "agent_max_tool_calls": (0, 1000), # 0 = unlimited
"auto_compact_threshold_percent": (50, 95), "auto_compact_threshold_percent": (50, 95),
} }
_VALID_VALUES = {
"tool_approval_mode": {"ask", "off"},
}
for key in DEFAULT_SETTINGS: for key in DEFAULT_SETTINGS:
if key in RETIRED_SETTING_KEYS: if key in RETIRED_SETTING_KEYS:
continue continue
@@ -791,15 +782,6 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter:
except (TypeError, ValueError): except (TypeError, ValueError):
raise HTTPException(400, f"{key} must be an integer") raise HTTPException(400, f"{key} must be an integer")
val = max(lo, min(val, hi)) 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 current[key] = val
_save_settings(current) _save_settings(current)
return without_retired_settings(current) return without_retired_settings(current)
File diff suppressed because it is too large Load Diff
+4 -16
View File
@@ -14,12 +14,12 @@ import csv
import difflib import difflib
import html import html
import json import json
import logging
import os import os
import re import re
import shlex import shlex
import shutil import shutil
import time import time
import logging
import hashlib import hashlib
from src.web_recovery import WebRecoveryBudget from src.web_recovery import WebRecoveryBudget
from itertools import count from itertools import count
@@ -16994,20 +16994,6 @@ def _detect_runaway_call(call_freq, threshold=15):
sig = next((s for s, n in call_freq.items() if n >= threshold), None) sig = next((s for s, n in call_freq.items() if n >= threshold), None)
return sig.split(":", 1)[0] if sig else 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: def _tool_result_signature(tool_result_records: list[dict]) -> str:
"""Return a bounded fingerprint of the observable result of a tool batch. """Return a bounded fingerprint of the observable result of a tool batch.
@@ -21451,7 +21437,9 @@ async def stream_agent_loop(
) )
or messages_contain_external_untrusted_context(messages) or messages_contain_external_untrusted_context(messages)
), ),
approval_gate_bypassed=_tool_approval_gate_bypassed(exact_approval), approval_gate_bypassed=bool(
exact_approval and exact_approval.allow_remaining_actions
),
delegated_credential=bool(delegated_credential), delegated_credential=bool(delegated_credential),
) )
_has_tui_host_bridge = _tui_host_bridge_is_usable(client_runtime_context) _has_tui_host_bridge = _tui_host_bridge_is_usable(client_runtime_context)
+5 -6
View File
@@ -47,7 +47,6 @@ EFFECTS_DIR = os.path.join(DATA_DIR, "effects")
_RUN_ID = re.compile(r"[a-f0-9]{32}") _RUN_ID = re.compile(r"[a-f0-9]{32}")
_TYPES = {"claim": EffectClaim, "outcome": EffectOutcome, "observation": Observation} _TYPES = {"claim": EffectClaim, "outcome": EffectOutcome, "observation": Observation}
_VERSION = 1 _VERSION = 1
_O_BINARY = getattr(os, "O_BINARY", 0) # Windows: raw os.open() is text-mode without it
def _fsync_directory(directory: str | os.PathLike) -> None: def _fsync_directory(directory: str | os.PathLike) -> None:
@@ -244,7 +243,7 @@ class EffectLog:
"""Hold the file exclusively with every durable record merged.""" """Hold the file exclusively with every durable record merged."""
assert self.path is not None assert self.path is not None
_ensure_directory(self.path.parent) _ensure_directory(self.path.parent)
flags = os.O_RDWR | os.O_APPEND | os.O_CREAT | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_CLOEXEC", 0) | _O_BINARY flags = os.O_RDWR | os.O_APPEND | os.O_CREAT | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_CLOEXEC", 0)
descriptor = os.open(self.path, flags, 0o600) descriptor = os.open(self.path, flags, 0o600)
try: try:
if fcntl is not None: if fcntl is not None:
@@ -359,7 +358,7 @@ class EffectLog:
with self._lock: with self._lock:
try: try:
descriptor = os.open(self.path, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0) descriptor = os.open(self.path, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0)
| getattr(os, "O_CLOEXEC", 0) | _O_BINARY) | getattr(os, "O_CLOEXEC", 0))
except FileNotFoundError: except FileNotFoundError:
return return
except OSError: except OSError:
@@ -395,7 +394,7 @@ class EffectLog:
log = cls(run_id, directory=directory) log = cls(run_id, directory=directory)
assert log.path is not None assert log.path is not None
try: try:
descriptor = os.open(log.path, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_CLOEXEC", 0) | _O_BINARY) descriptor = os.open(log.path, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_CLOEXEC", 0))
except FileNotFoundError: except FileNotFoundError:
return log return log
except OSError as error: except OSError as error:
@@ -461,7 +460,7 @@ class EffectLog:
temporary = target.with_suffix(".tmp") temporary = target.with_suffix(".tmp")
data = json.dumps({"run_id": self.run_id, "effect_id": effect_id}, sort_keys=True).encode() data = json.dumps({"run_id": self.run_id, "effect_id": effect_id}, sort_keys=True).encode()
_ensure_directory(target.parent) _ensure_directory(target.parent)
flags = os.O_WRONLY | os.O_CREAT | os.O_TRUNC | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_CLOEXEC", 0) | _O_BINARY flags = os.O_WRONLY | os.O_CREAT | os.O_TRUNC | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_CLOEXEC", 0)
descriptor = os.open(temporary, flags, 0o600) descriptor = os.open(temporary, flags, 0o600)
try: try:
view = memoryview(data) view = memoryview(data)
@@ -479,7 +478,7 @@ class EffectLog:
return None return None
base = Path(directory) if directory is not None else effects_dir() base = Path(directory) if directory is not None else effects_dir()
try: try:
descriptor = os.open(base / f"launch-{generation}.json", os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0) | _O_BINARY) descriptor = os.open(base / f"launch-{generation}.json", os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0))
with os.fdopen(descriptor, "rb") as stream: with os.fdopen(descriptor, "rb") as stream:
if os.fstat(stream.fileno()).st_nlink != 1: if os.fstat(stream.fileno()).st_nlink != 1:
return None return None
-1
View File
@@ -120,7 +120,6 @@ DEFAULT_SETTINGS = {
# Tune via Settings or by editing data/settings.json. # Tune via Settings or by editing data/settings.json.
"research_run_timeout_seconds": 1800, "research_run_timeout_seconds": 1800,
"agent_max_tool_calls": 0, "agent_max_tool_calls": 0,
"tool_approval_mode": "ask",
"agent_max_rounds": 20, # per-message agent step cap (clamped 1..200) "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 # 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" # "auto" sentinel: it means "scale the budget to the model's context window"
-16
View File
@@ -2463,22 +2463,6 @@
<label class="settings-label">Max steps per message</label> <label class="settings-label">Max steps per message</label>
<input id="set-agentMaxRounds" type="text" inputmode="numeric" placeholder="20" class="settings-select" style="width:120px;"> <input id="set-agentMaxRounds" type="text" inputmode="numeric" placeholder="20" class="settings-select" style="width:120px;">
</div> </div>
<div class="settings-row">
<label class="settings-label">Tool approval</label>
<select id="set-toolApprovalMode" class="settings-select">
<option value="ask">Always ask</option>
<option value="off">Off (YOLO)</option>
</select>
</div>
<div class="admin-toggle-sub" style="margin-top:4px;">
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.
</div>
<div id="set-toolApprovalEnvNote" class="admin-toggle-sub" style="margin-top:4px;display:none;color:var(--red);"></div>
<div id="set-agentMsg" style="font-size:11px;color:color-mix(in srgb, var(--fg) 45%, transparent);"></div> <div id="set-agentMsg" style="font-size:11px;color:color-mix(in srgb, var(--fg) 45%, transparent);"></div>
</div> </div>
</div> </div>
-21
View File
@@ -10,7 +10,6 @@ export async function initAgentSettings() {
var toolsInput = el('set-agentMaxTools'); var toolsInput = el('set-agentMaxTools');
var roundsInput = el('set-agentMaxRounds'); var roundsInput = el('set-agentMaxRounds');
var supInput = el('set-agentSupervisorLadder'); var supInput = el('set-agentSupervisorLadder');
var approvalModeInput = el('set-toolApprovalMode');
var msg = el('set-agentMsg'); var msg = el('set-agentMsg');
if (!toolsInput) return; if (!toolsInput) return;
@@ -20,21 +19,6 @@ export async function initAgentSettings() {
if (settings.agent_max_tool_calls) toolsInput.value = settings.agent_max_tool_calls; 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 (roundsInput && settings.agent_max_rounds) roundsInput.value = settings.agent_max_rounds;
if (supInput) supInput.checked = !!settings.agent_supervisor_ladder; 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) {} } catch (e) {}
// Clamp + coerce a raw input to an int in [lo, hi]; falls back to `dflt` // Clamp + coerce a raw input to an int in [lo, hi]; falls back to `dflt`
@@ -53,10 +37,6 @@ export async function initAgentSettings() {
var payload = { agent_max_tool_calls: tools }; var payload = { agent_max_tool_calls: tools };
if (rounds != null) payload.agent_max_rounds = rounds; if (rounds != null) payload.agent_max_rounds = rounds;
if (supInput) payload.agent_supervisor_ladder = !!supInput.checked; if (supInput) payload.agent_supervisor_ladder = !!supInput.checked;
if (approvalModeInput) {
payload.tool_approval_mode =
approvalModeInput.value === 'off' ? 'off' : 'ask';
}
try { try {
await _postSettings(payload); await _postSettings(payload);
msg.textContent = (tools > 0 ? 'Limit: ' + tools + ' tool calls' : 'Unlimited tool calls') + msg.textContent = (tools > 0 ? 'Limit: ' + tools + ' tool calls' : 'Unlimited tool calls') +
@@ -69,7 +49,6 @@ export async function initAgentSettings() {
toolsInput.addEventListener('change', save); toolsInput.addEventListener('change', save);
if (roundsInput) roundsInput.addEventListener('change', save); if (roundsInput) roundsInput.addEventListener('change', save);
if (supInput) supInput.addEventListener('change', save); if (supInput) supInput.addEventListener('change', save);
if (approvalModeInput) approvalModeInput.addEventListener('change', save);
var cur = parseInt(toolsInput.value, 10) || 0; var cur = parseInt(toolsInput.value, 10) || 0;
var curR = roundsInput ? (parseInt(roundsInput.value, 10) || 20) : null; var curR = roundsInput ? (parseInt(roundsInput.value, 10) || 20) : null;
msg.textContent = (cur > 0 ? 'Limit: ' + cur + ' tool calls' : 'Unlimited tool calls') + msg.textContent = (cur > 0 ? 'Limit: ' + cur + ' tool calls' : 'Unlimited tool calls') +
File diff suppressed because it is too large Load Diff
-10
View File
@@ -344,13 +344,3 @@ def test_hardlinked_effect_state_is_control_plane_without_scanning_the_store(tmp
elsewhere.write_text("y") elsewhere.write_text("y")
os.link(elsewhere, workspace / "pnpm-style.txt") os.link(elsewhere, workspace / "pnpm-style.txt")
assert resources._control_plane_path(str(workspace / "pnpm-style.txt")) is True assert resources._control_plane_path(str(workspace / "pnpm-style.txt")) is True
def test_consecutive_claims_write_lf_only_records(tmp_path, target):
# Raw os.open() is text-mode on Windows unless O_BINARY is set; the \r\n it
# wrote made the next append read a stray empty line as a corrupt tail.
log = EffectLog(RUN, directory=tmp_path / "fx")
claim(log, target, "e1", "act-1")
claim(log, target, "e2", "act-2")
assert b"\r" not in log.path.read_bytes()
assert len(records(log.path)) == 2
-60
View File
@@ -3,7 +3,6 @@
import asyncio import asyncio
import json import json
from types import SimpleNamespace from types import SimpleNamespace
from fastapi import HTTPException
import pytest import pytest
@@ -118,62 +117,3 @@ def test_manage_settings_tombstones_legacy_fallback_key(monkeypatch):
assert save_calls == [] assert save_calls == []
assert store["default_model_fallbacks"] == LEGACY_VALUE 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
+1 -1
View File
@@ -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 "_without_latest_matching_user_message(" not in helpers
assert "selected_tools=approval_selected_tools" in agent assert "selected_tools=approval_selected_tools" in agent
assert "continuation_query=_retrieval_query or _last_user" in agent assert "continuation_query=_retrieval_query or _last_user" in agent
assert "approval_gate_bypassed=_tool_approval_gate_bypassed(" in agent assert "approval_gate_bypassed=bool(" in agent
assert "['approve', 'approve_task', 'deny']" in frontend assert "['approve', 'approve_task', 'deny']" in frontend
assert "input.value = label" not in frontend assert "input.value = label" not in frontend
assert "const msg = approvalForSend ? '' : el('message').value;" in frontend assert "const msg = approvalForSend ? '' : el('message').value;" in frontend
-34
View File
@@ -500,37 +500,3 @@ async def test_dispatcher_revalidates_sealed_workspace(monkeypatch, tmp_path):
assert result["blocked"] is True assert result["blocked"] is True
assert result["policy"] == "exact_tool_approval" 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