mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-10-13 02:12:21 +02:00
merge(runtime): reconcile containment with frozen lab
This commit is contained in:
@@ -2,6 +2,33 @@
|
||||
from src.agent_runtime.journal import mark_dispatch, record_action
|
||||
|
||||
|
||||
def server_authorized_executor(executor):
|
||||
"""Give standalone dispatcher fixtures their explicit server grants.
|
||||
|
||||
These existing suites exercise handlers, policy, confinement and approvals.
|
||||
Their fixture grants cover the declared native tool registry, independently
|
||||
of the proposed block. Request-authority denial tests use the raw dispatcher.
|
||||
"""
|
||||
from functools import wraps
|
||||
from inspect import signature
|
||||
from src.agent_runtime.authority import OperationGrant, RequestAuthority
|
||||
from src.tool_policy import known_tool_names
|
||||
from src.turn_contract import canonical_tool
|
||||
call_signature = signature(executor)
|
||||
@wraps(executor)
|
||||
async def execute(*args, **kwargs):
|
||||
bound = call_signature.bind(*args, **kwargs)
|
||||
parameters = bound.arguments
|
||||
kwargs.setdefault("request_authority", RequestAuthority(
|
||||
"standalone-test-request", str(parameters.get("owner") or "").strip().casefold(),
|
||||
str(parameters.get("session_id") or ""), str(parameters.get("workspace") or ""),
|
||||
tuple(OperationGrant(name) for name in sorted(
|
||||
{canonical_tool(n) for n in known_tool_names()} | {"list_dir", "find_files"})),
|
||||
))
|
||||
return await executor(*args, **kwargs)
|
||||
return execute
|
||||
|
||||
|
||||
def authoritative_executor(function):
|
||||
@record_action
|
||||
async def execute(block, *args, **kwargs):
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"""Windows execution contract for the agent Bash tool."""
|
||||
|
||||
import asyncio
|
||||
|
||||
import pytest
|
||||
from types import SimpleNamespace
|
||||
|
||||
@@ -38,6 +40,84 @@ async def test_windows_bash_uses_git_bash_with_structural_cwd(monkeypatch):
|
||||
assert captured["kwargs"]["cwd"] == workspace
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_windows_bash_captures_output_instead_of_inheriting_server_handles(monkeypatch):
|
||||
captured = {}
|
||||
|
||||
monkeypatch.setattr(subprocess_tools, "IS_WINDOWS", True)
|
||||
monkeypatch.setattr(
|
||||
subprocess_tools, "find_bash", lambda: r"C:\Program Files\Git\bin\bash.exe"
|
||||
)
|
||||
|
||||
async def fake_exec(*_argv, **kwargs):
|
||||
captured.update(kwargs)
|
||||
return object()
|
||||
|
||||
monkeypatch.setattr(subprocess_tools.asyncio, "create_subprocess_exec", fake_exec)
|
||||
|
||||
await subprocess_tools._create_bash_subprocess("pwd", cwd=r"C:\Work")
|
||||
|
||||
assert captured["stdout"] == asyncio.subprocess.PIPE
|
||||
assert captured["stderr"] == asyncio.subprocess.PIPE
|
||||
assert captured["stdin"] == asyncio.subprocess.DEVNULL
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_windows_bash_applies_the_subprocess_env(monkeypatch):
|
||||
captured = {}
|
||||
env = {"PATH": r"C:\Odysseus\venv\Scripts", "HOME": r"C:\Odysseus\data"}
|
||||
|
||||
monkeypatch.setattr(subprocess_tools, "IS_WINDOWS", True)
|
||||
monkeypatch.setattr(
|
||||
subprocess_tools, "find_bash", lambda: r"C:\Program Files\Git\bin\bash.exe"
|
||||
)
|
||||
|
||||
async def fake_exec(*_argv, **kwargs):
|
||||
captured.update(kwargs)
|
||||
return object()
|
||||
|
||||
monkeypatch.setattr(subprocess_tools.asyncio, "create_subprocess_exec", fake_exec)
|
||||
|
||||
await subprocess_tools._create_bash_subprocess("pwd", cwd=r"C:\Work", env=env)
|
||||
|
||||
assert captured["env"] == env
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_windows_bash_tool_passes_ctx_env_through_to_the_child(monkeypatch):
|
||||
captured = {}
|
||||
env = {"PATH": r"C:\Odysseus\venv\Scripts", "VIRTUAL_ENV": r"C:\Odysseus\venv"}
|
||||
|
||||
monkeypatch.setattr(subprocess_tools, "IS_WINDOWS", True)
|
||||
monkeypatch.setattr(
|
||||
subprocess_tools, "find_bash", lambda: r"C:\Program Files\Git\bin\bash.exe"
|
||||
)
|
||||
monkeypatch.setattr("src.tool_execution.agent_cwd", lambda: r"D:\Workspaces\Project")
|
||||
|
||||
async def fake_exec(*argv, **kwargs):
|
||||
captured["argv"] = argv
|
||||
captured["kwargs"] = kwargs
|
||||
return SimpleNamespace(pid=4242)
|
||||
|
||||
async def fake_stream(_process, **_kwargs):
|
||||
return "ok", "", 0, False
|
||||
|
||||
monkeypatch.setattr(subprocess_tools.asyncio, "create_subprocess_exec", fake_exec)
|
||||
monkeypatch.setattr(subprocess_tools, "_run_subprocess_streaming", fake_stream)
|
||||
|
||||
result = await subprocess_tools.BashTool().execute(
|
||||
"pwd",
|
||||
{"subproc_env": env, "session_id": "chat-1"},
|
||||
)
|
||||
|
||||
assert result["output"] == "ok"
|
||||
assert result["exit_code"] == 0
|
||||
assert "containment" in result
|
||||
assert captured["kwargs"]["env"] == env
|
||||
assert captured["kwargs"]["stdout"] == asyncio.subprocess.PIPE
|
||||
assert captured["kwargs"]["stderr"] == asyncio.subprocess.PIPE
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_windows_bash_without_git_bash_fails_clearly(monkeypatch):
|
||||
monkeypatch.setattr(subprocess_tools, "IS_WINDOWS", True)
|
||||
|
||||
@@ -333,6 +333,7 @@ def test_external_tool_images_are_threaded_as_multimodal_evidence():
|
||||
|
||||
|
||||
def test_declared_external_call_reaches_scoped_bridge(monkeypatch):
|
||||
from src.agent_runtime.authority import OperationGrant, RequestAuthority
|
||||
bridge_calls = []
|
||||
round_no = 0
|
||||
monkeypatch.setattr(agent_loop, "get_setting", lambda key, default=None: default)
|
||||
@@ -367,6 +368,8 @@ def test_declared_external_call_reaches_scoped_bridge(monkeypatch):
|
||||
[{"role": "user", "content": "Perform the declared operation."}],
|
||||
max_rounds=2,
|
||||
owner="pewds",
|
||||
request_authority=RequestAuthority("declared-fixture", "pewds", "", "",
|
||||
(OperationGrant("inspect_state"),)),
|
||||
relevant_tools={"inspect_state"},
|
||||
forced_tools={"inspect_state"},
|
||||
fallbacks=[],
|
||||
@@ -389,6 +392,7 @@ def test_declared_external_call_reaches_scoped_bridge(monkeypatch):
|
||||
|
||||
|
||||
def test_known_native_tool_reaches_scoped_bridge_without_redeclared_schema(monkeypatch):
|
||||
from src.agent_runtime.authority import create_request_authority
|
||||
bridge_calls = []
|
||||
round_no = 0
|
||||
monkeypatch.setattr(agent_loop, "get_setting", lambda key, default=None: default)
|
||||
@@ -435,6 +439,7 @@ def test_known_native_tool_reaches_scoped_bridge_without_redeclared_schema(monke
|
||||
[{"role": "user", "content": "Search email for Project Alpha."}],
|
||||
max_rounds=2,
|
||||
owner="public-user",
|
||||
request_authority=create_request_authority("Search email for Project Alpha.", owner="public-user"),
|
||||
relevant_tools={"search_emails"},
|
||||
forced_tools={"search_emails"},
|
||||
fallbacks=[],
|
||||
|
||||
@@ -11,6 +11,9 @@ from src.agent_tools import ToolBlock, TOOL_TAGS # noqa: E402 (import first to
|
||||
from src.tool_execution import NO_TOOL_SECURITY_CONTEXT, execute_tool_block
|
||||
from src.tool_index import ALWAYS_AVAILABLE, BUILTIN_TOOL_DESCRIPTIONS
|
||||
from src.tool_security import is_public_blocked_tool
|
||||
from tests.runtime_evidence_helpers import server_authorized_executor
|
||||
|
||||
execute_tool_block = server_authorized_executor(execute_tool_block)
|
||||
|
||||
|
||||
def _run(content):
|
||||
|
||||
@@ -14,6 +14,7 @@ from routes.chat_routes import _agent_turn_cwd
|
||||
import routes.chat_routes as chat_routes
|
||||
from src import tool_execution as _te
|
||||
from src.agent_loop import _is_explicit_local_network_request
|
||||
from tests.runtime_evidence_helpers import server_authorized_executor
|
||||
|
||||
# Hold module-object references (not just from-imported names): other test
|
||||
# modules re-import src.tool_execution via sys.modules pops, so string-target
|
||||
@@ -28,7 +29,7 @@ _ROUTED_BRIDGE_TOOLS = _te._ROUTED_BRIDGE_TOOLS
|
||||
async def _execute_tool_block_for_unit_tests(*args, **kwargs):
|
||||
"""Use the explicit non-security-context test mode for dispatch tests."""
|
||||
kwargs.setdefault("security_context", _te.NO_TOOL_SECURITY_CONTEXT)
|
||||
return await _te.execute_tool_block(*args, **kwargs)
|
||||
return await server_authorized_executor(_te.execute_tool_block)(*args, **kwargs)
|
||||
|
||||
|
||||
execute_tool_block = _execute_tool_block_for_unit_tests
|
||||
|
||||
@@ -4,6 +4,14 @@ import os
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
from tests.runtime_evidence_helpers import server_authorized_executor
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def standalone_dispatch_authority(monkeypatch):
|
||||
from src import tool_execution
|
||||
monkeypatch.setattr(tool_execution, "execute_tool_block",
|
||||
server_authorized_executor(tool_execution.execute_tool_block))
|
||||
|
||||
from src import tool_security
|
||||
from src.tool_security import (
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import src.tool_execution as tool_execution
|
||||
from tests.runtime_evidence_helpers import server_authorized_executor
|
||||
|
||||
from src.tool_execution import (
|
||||
AgentExecutionBridge,
|
||||
@@ -11,6 +12,9 @@ from src.tool_execution import (
|
||||
)
|
||||
|
||||
|
||||
execute_tool_block = server_authorized_executor(execute_tool_block)
|
||||
|
||||
|
||||
class Block:
|
||||
tool_type = "host_shell"
|
||||
|
||||
|
||||
@@ -7,6 +7,14 @@ from collections import namedtuple
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from tests.runtime_evidence_helpers import server_authorized_executor
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def standalone_dispatch_authority(monkeypatch):
|
||||
from src import tool_execution
|
||||
monkeypatch.setattr(tool_execution, "execute_tool_block",
|
||||
server_authorized_executor(tool_execution.execute_tool_block))
|
||||
|
||||
from tests.helpers.document_source import document_source
|
||||
from tests.helpers.js_modules import email_library_paths
|
||||
|
||||
@@ -125,6 +125,8 @@ async def test_generation_dispatch_uses_owner_aware_backend(monkeypatch, exit_co
|
||||
from types import SimpleNamespace
|
||||
from src import ai_interaction, tool_execution
|
||||
from src.agent_runtime.journal import ActionJournal, bind_journal
|
||||
from tests.runtime_evidence_helpers import server_authorized_executor
|
||||
execute = server_authorized_executor(tool_execution.execute_tool_block)
|
||||
calls = []
|
||||
async def generate(content, **kwargs):
|
||||
calls.append((content, kwargs))
|
||||
@@ -140,9 +142,9 @@ async def test_generation_dispatch_uses_owner_aware_backend(monkeypatch, exit_co
|
||||
block = SimpleNamespace(tool_type='generate_image', content='{"prompt":"A city"}')
|
||||
journal = ActionJournal()
|
||||
with bind_journal(journal):
|
||||
_, denied = await tool_execution.execute_tool_block(block, owner='pewds', session_id='fixture',
|
||||
_, denied = await execute(block, owner='pewds', session_id='fixture',
|
||||
disabled_tools={'generate_image'}, security_context=tool_execution.NO_TOOL_SECURITY_CONTEXT)
|
||||
_, result = await tool_execution.execute_tool_block(block, owner='pewds', session_id='fixture',
|
||||
_, result = await execute(block, owner='pewds', session_id='fixture',
|
||||
security_context=tool_execution.NO_TOOL_SECURITY_CONTEXT)
|
||||
assert denied['exit_code'] != 0
|
||||
assert journal.actions[0].execution_id is None
|
||||
|
||||
@@ -0,0 +1,584 @@
|
||||
"""Request grants are independent of tool offerings and model proposals."""
|
||||
import asyncio
|
||||
from contextlib import nullcontext
|
||||
from dataclasses import replace
|
||||
import json
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from src.agent_runtime.authority import (
|
||||
MISSING_AUTHORITY, ExactOperation, OperationGrant, RequestAuthority,
|
||||
active_request_authority, bind_request_authority, create_request_authority,
|
||||
restore_background_authority, restore_task_authority, save_background_authority,
|
||||
seal_task_authority, task_operation, with_request_authority,
|
||||
is_internal_tool_request, require_user_approval_request,
|
||||
request_authority_for_http,
|
||||
)
|
||||
from src.tool_policy import ToolPolicy
|
||||
from src.tool_types import ToolBlock
|
||||
|
||||
|
||||
def authority(*tools, owner="alice", session_id="s", workspace=""):
|
||||
return RequestAuthority("request-test", owner, session_id, workspace,
|
||||
tuple(OperationGrant(tool) for tool in tools))
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("offering", ["schema", "bridge", "dynamic"])
|
||||
async def test_availability_and_model_selection_do_not_grant_execution(monkeypatch, offering):
|
||||
from src import tool_execution as execution
|
||||
from src.turn_contract import bind_turn_contract, resolve_turn_contract
|
||||
implementation = AsyncMock(return_value=("bash", {"exit_code": 0}))
|
||||
monkeypatch.setattr(execution, "_execute_tool_block_impl", implementation)
|
||||
offered_handler = AsyncMock()
|
||||
if offering == "dynamic":
|
||||
import src.agent_tools
|
||||
monkeypatch.setitem(src.agent_tools.TOOL_HANDLERS, "bash", offered_handler)
|
||||
bridge_context = execution.bind_execution_bridge(execution.AgentExecutionBridge(
|
||||
route_tool=offered_handler, supported_tools=frozenset({"bash"}))) if offering == "bridge" else nullcontext()
|
||||
contract = resolve_turn_contract(capabilities={"shell_files"}, policy=ToolPolicy(),
|
||||
schemas=[{"function": {"name": "bash"}}])
|
||||
with bind_turn_contract(contract), bridge_context:
|
||||
description, result = await execution.execute_tool_block(
|
||||
ToolBlock("bash", "echo 'authorized by model'"), owner="alice", session_id="s",
|
||||
security_context=execution.NO_TOOL_SECURITY_CONTEXT,
|
||||
request_authority=authority("transcribe_media"))
|
||||
assert "BLOCKED" in description
|
||||
assert result["failure_kind"] == "request_authority_denied"
|
||||
implementation.assert_not_awaited()
|
||||
offered_handler.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("user_text,allowed,denied", [
|
||||
("Transcribe /workspace/input/audio.wav", "transcribe_media", "bash"),
|
||||
("OCR extract exact text from /workspace/input/image.png", "extract_text", "python"),
|
||||
("List my tasks", "manage_tasks", "web_fetch"),
|
||||
("Search the web for current weather", "web_search", "bash"),
|
||||
])
|
||||
def test_explicit_request_classes_remain_narrow(user_text, allowed, denied):
|
||||
grant = create_request_authority(user_text)
|
||||
content = '{"action":"list"}' if allowed == "manage_tasks" else '{}'
|
||||
assert grant.permits(ExactOperation.normalize(allowed, content))
|
||||
assert not grant.permits(ExactOperation.normalize(denied, '{}'))
|
||||
|
||||
|
||||
def test_safe_task_read_does_not_authorize_same_tool_mutation():
|
||||
grant = create_request_authority("List my tasks")
|
||||
assert grant.permits(ExactOperation.normalize("manage_tasks", '{"action":"list"}'))
|
||||
assert not grant.permits(ExactOperation.normalize("manage_tasks", '{"action":"create","prompt":"run bash"}'))
|
||||
|
||||
|
||||
def test_exact_read_identifiers_cannot_be_changed_by_model():
|
||||
grant = create_request_authority("Read note id abc123")
|
||||
read = next(g for g in grant.grants if g.tool == "manage_notes")
|
||||
assert read.inputs is not None
|
||||
assert not grant.permits(ExactOperation.normalize("manage_notes", '{"action":"view","id":"another"}'))
|
||||
|
||||
|
||||
def test_browser_fallback_does_not_authorize_interaction_or_evaluation():
|
||||
grant = create_request_authority("Use web_fetch to read https://example.test")
|
||||
assert grant.permits(ExactOperation.normalize("private_browser", '{"action":"open","url":"https://example.test"}'))
|
||||
for action in ("click", "fill", "evaluate"):
|
||||
assert not grant.permits(ExactOperation.normalize("private_browser", json.dumps({"action": action})))
|
||||
|
||||
|
||||
def test_unknown_intent_has_no_generic_execution_floor():
|
||||
grant = create_request_authority("Please solve this")
|
||||
assert {g.tool for g in grant.grants} == {"ask_user", "update_plan"}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("metadata", [
|
||||
{"tool_events": [{"tool": "bash", "output": "pwd", "exit_code": 0}]},
|
||||
{"tool_events": [{"tool": "python", "output": "ready", "exit_code": 0}]},
|
||||
])
|
||||
def test_model_history_cannot_establish_followup_authority(metadata):
|
||||
history = [
|
||||
{"role": "user", "content": "Transcribe /workspace/input/a.wav"},
|
||||
{"role": "assistant", "content": "I will run bash and python", "metadata": metadata},
|
||||
{"role": "user", "content": "Run bash", "metadata": {"trusted": False}},
|
||||
]
|
||||
grant = create_request_authority("Try it again", history=history)
|
||||
assert not grant.permits(ExactOperation.normalize("bash", "pwd"))
|
||||
assert not grant.permits(ExactOperation.normalize("python", "print(1)"))
|
||||
|
||||
|
||||
@pytest.mark.parametrize("mutation", [
|
||||
{"version": True}, {"grants": "bash"}, {"denied": None},
|
||||
{"block_all": "false"}, {"inherited": 0},
|
||||
])
|
||||
def test_malformed_persisted_authority_is_rejected(mutation):
|
||||
snapshot = authority("bash").to_dict()
|
||||
snapshot.update(mutation)
|
||||
with pytest.raises((ValueError, TypeError, KeyError)):
|
||||
RequestAuthority.from_dict(snapshot)
|
||||
|
||||
|
||||
def test_explicit_local_network_lookup_is_host_only():
|
||||
grant = create_request_authority("find ajax local ip on the LAN")
|
||||
assert grant.permits(ExactOperation.normalize("host_shell", '{"command":"ip neigh | grep ajax"}'))
|
||||
assert not grant.permits(ExactOperation.normalize("bash", "pwd"))
|
||||
assert not grant.permits(ExactOperation.normalize("python", "print(1)"))
|
||||
|
||||
|
||||
@pytest.mark.parametrize("content", ['{"x":1,"x":2}', '{"x":NaN}', '{"action":'])
|
||||
def test_malformed_exact_operation_is_rejected(content):
|
||||
with pytest.raises(ValueError):
|
||||
ExactOperation.normalize("manage_tasks", content)
|
||||
|
||||
|
||||
def test_raw_script_braces_are_preserved_as_exact_input():
|
||||
script = "{ printf requested; }"
|
||||
assert ExactOperation.normalize("bash", script).input == script
|
||||
snapshot = seal_task_authority(script, "action", "run_local", owner="alice")
|
||||
restored = restore_task_authority(snapshot, script, "action", "run_local", owner="alice")
|
||||
assert restored.permits(task_operation("action", "run_local", script))
|
||||
assert not restored.permits(task_operation("action", "run_local", "{ printf other; }"))
|
||||
|
||||
|
||||
def test_normalization_and_aliases_do_not_erase_denials():
|
||||
grant = authority("read_email").restrict(disabled_tools={"mcp__email__read_email"})
|
||||
assert not grant.permits(ExactOperation.normalize("read_email", '{}'))
|
||||
grant = authority("read_email").restrict(ToolPolicy(disable_mcp=True))
|
||||
assert not grant.permits(ExactOperation.normalize("mcp__email__read_email", '{}'))
|
||||
assert ExactOperation.normalize("manage_tasks", '{ "action": "list" }').input == '{"action":"list"}'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("state", [MISSING_AUTHORITY, None, {}, "authorized"])
|
||||
async def test_missing_and_malformed_dispatch_authority_fail_closed(monkeypatch, state):
|
||||
from src import tool_execution as execution
|
||||
implementation = AsyncMock()
|
||||
monkeypatch.setattr(execution, "_execute_tool_block_impl", implementation)
|
||||
_, result = await execution.execute_tool_block(ToolBlock("bash", "pwd"),
|
||||
security_context=execution.NO_TOOL_SECURITY_CONTEXT, request_authority=state)
|
||||
assert result["blocked"] is True
|
||||
implementation.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dispatch_checks_grants_and_current_disabled_policy(monkeypatch):
|
||||
from src import tool_execution as execution
|
||||
implementation = AsyncMock(return_value=("bash", {"exit_code": 0}))
|
||||
monkeypatch.setattr(execution, "_execute_tool_block_impl", implementation)
|
||||
for disabled in (set(), {"bash"}):
|
||||
_, result = await execution.execute_tool_block(ToolBlock("bash", "pwd"),
|
||||
owner="alice", session_id="s", disabled_tools=disabled,
|
||||
security_context=execution.NO_TOOL_SECURITY_CONTEXT,
|
||||
request_authority=authority("bash"))
|
||||
assert result["exit_code"] == (1 if disabled else 0)
|
||||
assert implementation.await_count == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_nested_stream_intersects_and_restores_parent_on_close():
|
||||
seen = []
|
||||
@with_request_authority
|
||||
async def child(messages, request_authority=MISSING_AUTHORITY, owner="alice", session_id="s"):
|
||||
seen.append(active_request_authority())
|
||||
yield "child"
|
||||
parent = authority("transcribe_media")
|
||||
with bind_request_authority(parent):
|
||||
stream = child([{"role": "user", "content": "Run bash"}],
|
||||
request_authority=authority("bash", "transcribe_media"))
|
||||
assert await anext(stream) == "child"
|
||||
assert not seen[0].permits(ExactOperation.normalize("bash", "pwd"))
|
||||
assert seen[0].permits(ExactOperation.normalize("transcribe_media", '{}'))
|
||||
await stream.aclose()
|
||||
assert active_request_authority() is parent
|
||||
assert active_request_authority() is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_retries_and_provider_changes_do_not_recreate_authority():
|
||||
seen = []
|
||||
@with_request_authority
|
||||
async def run(messages, request_authority=MISSING_AUTHORITY, owner="alice", session_id="s"):
|
||||
for proposed in ("transcribe_media", "bash", "python"):
|
||||
seen.append(active_request_authority())
|
||||
yield active_request_authority().permits(ExactOperation.normalize(proposed, '{}'))
|
||||
assert [x async for x in run([{"role": "user", "content": "Transcribe /workspace/input/a.wav"}])] == [True, False, False]
|
||||
assert all(value is seen[0] for value in seen)
|
||||
|
||||
|
||||
def test_task_snapshot_caps_model_payload_and_rejects_changed_or_missing_state():
|
||||
parent = authority("manage_tasks")
|
||||
with bind_request_authority(parent):
|
||||
snapshot = seal_task_authority("Run bash in the workspace", "llm", None, owner="alice")
|
||||
restored = restore_task_authority(snapshot, "Run bash in the workspace", "llm", None, owner="alice")
|
||||
assert not restored.permits(ExactOperation.normalize("bash", "pwd"))
|
||||
for state in (None, "{}", snapshot):
|
||||
changed = restore_task_authority(state, "a different prompt", "llm", None, owner="alice")
|
||||
assert changed.grants == ()
|
||||
|
||||
|
||||
def test_direct_task_ingress_seals_only_exact_builtin_action():
|
||||
snapshot = seal_task_authority("printf requested", "action", "run_local", owner="alice")
|
||||
restored = restore_task_authority(snapshot, "printf requested", "action", "run_local", owner="alice")
|
||||
assert restored.permits(task_operation("action", "run_local", "printf requested"))
|
||||
assert not restored.permits(ExactOperation.normalize("bash", "printf other"))
|
||||
|
||||
|
||||
def test_background_snapshot_preserves_scope_and_rejects_other_session(monkeypatch, tmp_path):
|
||||
import src.constants
|
||||
monkeypatch.setattr(src.constants, "BG_JOBS_DIR", str(tmp_path))
|
||||
grant = authority("transcribe_media").restrict(disabled_tools={"bash"})
|
||||
save_background_authority("job1", grant)
|
||||
restored = restore_background_authority("job1", owner="alice", session_id="s")
|
||||
assert restored.request_id == grant.request_id
|
||||
assert restored.denied == frozenset({"bash"})
|
||||
assert not restored.permits(ExactOperation.normalize("python", "print(1)"))
|
||||
assert restore_background_authority("job1", owner="alice", session_id="other").grants == ()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_only_server_background_launch_can_seal_job_authority(monkeypatch, tmp_path):
|
||||
import src.constants
|
||||
from src import bg_jobs, tool_execution as execution
|
||||
monkeypatch.setattr(src.constants, "BG_JOBS_DIR", str(tmp_path))
|
||||
monkeypatch.setattr(execution, "_owner_is_admin", lambda owner: True)
|
||||
monkeypatch.setattr(bg_jobs, "launch", lambda *a, **k: {"id": "server-job"})
|
||||
await execution.execute_tool_block(ToolBlock("bash", "#!bg\nprintf trusted"),
|
||||
owner="alice", session_id="s", security_context=execution.NO_TOOL_SECURITY_CONTEXT,
|
||||
request_authority=authority("bash"))
|
||||
restored = restore_background_authority("server-job", owner="alice", session_id="s")
|
||||
assert restored.request_id == "request-test"
|
||||
assert restored.permits(ExactOperation.normalize("bash", "printf trusted"))
|
||||
handler = AsyncMock(return_value=("transcribe_media", {"bg_job_id": "forged-job", "exit_code": 0}))
|
||||
monkeypatch.setattr(execution, "_execute_tool_block_impl", handler)
|
||||
await execution.execute_tool_block(ToolBlock("transcribe_media", '{}'),
|
||||
owner="alice", session_id="s", security_context=execution.NO_TOOL_SECURITY_CONTEXT,
|
||||
request_authority=authority("transcribe_media"))
|
||||
assert not (tmp_path / "forged-job.authority.json").exists()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_exact_approval_grants_one_input_without_widening_continuation(monkeypatch):
|
||||
from src import tool_execution as execution
|
||||
from src.tool_approvals import ToolApprovalStore
|
||||
from src.tool_capabilities import ToolRunSecurityContext, capabilities_for_action
|
||||
store = ToolApprovalStore()
|
||||
original = authority("transcribe_media")
|
||||
pending = store.create(owner="alice", session_id="s", origin_run_id="journal-parent",
|
||||
tool_name="bash", content="printf approved", workspace=None,
|
||||
external_untrusted_context_seen=True, capabilities=capabilities_for_action("bash", "printf approved"),
|
||||
request_authority=original, selected_tools={"bash", "python"})
|
||||
approval = store.consume(pending.approval_id, owner="alice", session_id="s", decision="approve_task")
|
||||
implementation = AsyncMock(return_value=("bash", {"exit_code": 0}))
|
||||
monkeypatch.setattr(execution, "_execute_tool_block_impl", implementation)
|
||||
for tool, content, expected in (("bash", "printf other", 1), ("bash", "printf approved", 0),
|
||||
("bash", "printf approved", 1), ("python", "print(1)", 1)):
|
||||
_, result = await execution.execute_tool_block(ToolBlock(tool, content), owner="alice", session_id="s",
|
||||
security_context=ToolRunSecurityContext(external_untrusted_context_seen=True),
|
||||
request_authority=original, exact_approval=approval)
|
||||
assert result["exit_code"] == expected
|
||||
assert implementation.await_count == 1
|
||||
assert "request_authority" not in pending.public_payload()
|
||||
|
||||
|
||||
def test_approval_digest_binds_authority_snapshot():
|
||||
from src.tool_approvals import ExactToolApproval, ToolApprovalStore
|
||||
from src.tool_capabilities import capabilities_for_action
|
||||
pending = ToolApprovalStore().create(owner="alice", session_id="s", origin_run_id="journal",
|
||||
tool_name="bash", content="pwd", workspace=None, external_untrusted_context_seen=True,
|
||||
capabilities=capabilities_for_action("bash", "pwd"), request_authority=authority("transcribe_media"))
|
||||
forged = ExactToolApproval(replace(pending, request_authority=authority("bash", "python")))
|
||||
assert not forged.matches(owner="alice", session_id="s", workspace=None, tool_name="bash", content="pwd")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_nested_approval_cannot_cross_parent_class_ceiling(monkeypatch):
|
||||
from src import tool_execution as execution
|
||||
from src.tool_approvals import ToolApprovalStore
|
||||
from src.tool_capabilities import ToolRunSecurityContext, capabilities_for_action
|
||||
store = ToolApprovalStore()
|
||||
pending = store.create(owner="alice", session_id="s", origin_run_id="parent",
|
||||
tool_name="bash", content="pwd", workspace=None, external_untrusted_context_seen=True,
|
||||
capabilities=capabilities_for_action("bash", "pwd"))
|
||||
approval = store.consume(pending.approval_id, owner="alice", session_id="s", decision="approve")
|
||||
implementation = AsyncMock()
|
||||
monkeypatch.setattr(execution, "_execute_tool_block_impl", implementation)
|
||||
with bind_request_authority(authority("transcribe_media")):
|
||||
_, result = await execution.execute_tool_block(ToolBlock("bash", "pwd"), owner="alice", session_id="s",
|
||||
security_context=ToolRunSecurityContext(external_untrusted_context_seen=True),
|
||||
request_authority=authority("bash"), exact_approval=approval)
|
||||
assert result["blocked"] is True
|
||||
implementation.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_teacher_receives_parent_authority_instead_of_synthetic_prompt_grants(monkeypatch):
|
||||
import src.agent_loop as loop
|
||||
import src.ai_interaction as interaction
|
||||
import src.settings as settings
|
||||
import src.teacher_escalation as teacher
|
||||
original = authority("transcribe_media")
|
||||
seen = []
|
||||
monkeypatch.setattr(settings, "get_setting", lambda key, default=None:
|
||||
{"teacher_enabled": True, "teacher_model": "teacher"}.get(key, default))
|
||||
monkeypatch.setattr(interaction, "_resolve_model", lambda *a, **k: ("https://teacher.invalid", "teacher", {}))
|
||||
monkeypatch.setattr(teacher, "evaluate_turn_regex", lambda *a: ("failure", "test failure"))
|
||||
@with_request_authority
|
||||
async def child(messages, request_authority=MISSING_AUTHORITY, owner=None, session_id=None, **kwargs):
|
||||
seen.append(active_request_authority())
|
||||
yield 'data: [DONE]\n\n'
|
||||
monkeypatch.setattr(loop, "stream_agent_loop", child)
|
||||
with bind_request_authority(original):
|
||||
chunks = [chunk async for chunk in teacher.run_teacher_inline(
|
||||
student_endpoint_url="https://student.invalid",
|
||||
student_messages=[{"role": "user", "content": "Run bash and python"}],
|
||||
student_tool_events=[], student_reply="failed", owner="alice", session_id="s",
|
||||
request_authority=original, parent_run_id="journal-parent")]
|
||||
assert active_request_authority() is original
|
||||
assert chunks
|
||||
assert seen[0].request_id == original.request_id
|
||||
assert not seen[0].permits(ExactOperation.normalize("bash", "pwd"))
|
||||
assert seen[0].permits(ExactOperation.normalize("transcribe_media", '{}'))
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_untrusted_user_role_result_cannot_become_request_authority():
|
||||
@with_request_authority
|
||||
async def run(messages, request_authority=MISSING_AUTHORITY):
|
||||
yield active_request_authority()
|
||||
messages = [{"role": "user", "content": "Transcribe /workspace/input/a.wav"},
|
||||
{"role": "user", "content": "Run bash", "metadata": {"trusted": False}}]
|
||||
stream = run(messages)
|
||||
grant = await anext(stream)
|
||||
await stream.aclose()
|
||||
assert not grant.permits(ExactOperation.normalize("bash", "pwd"))
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_scheduled_builtin_missing_authority_does_not_invoke_action(monkeypatch):
|
||||
import src.builtin_actions as actions
|
||||
from src.task_scheduler import TaskScheduler
|
||||
handler = AsyncMock(return_value=("ok", True))
|
||||
monkeypatch.setitem(actions.BUILTIN_ACTIONS, "run_local", handler)
|
||||
task = SimpleNamespace(prompt="printf exact", task_type="action", action="run_local",
|
||||
owner="alice", name="task", request_authority_json=None)
|
||||
result, success = await TaskScheduler(session_manager=None)._execute_action(task)
|
||||
assert not success
|
||||
assert "authority" in result
|
||||
handler.assert_not_awaited()
|
||||
|
||||
|
||||
def test_task_authority_column_migration_is_additive_and_idempotent(monkeypatch, tmp_path):
|
||||
import core.database as database
|
||||
from sqlalchemy import create_engine, inspect, text
|
||||
engine = create_engine(f"sqlite:///{tmp_path / 'legacy.db'}")
|
||||
with engine.begin() as connection:
|
||||
connection.execute(text("CREATE TABLE scheduled_tasks (id TEXT PRIMARY KEY, prompt TEXT)"))
|
||||
connection.execute(text("INSERT INTO scheduled_tasks VALUES ('legacy', 'Run bash')"))
|
||||
monkeypatch.setattr(database, "engine", engine)
|
||||
database._migrate_add_task_authority_column()
|
||||
database._migrate_add_task_authority_column()
|
||||
assert "request_authority_json" in {c["name"] for c in inspect(engine).get_columns("scheduled_tasks")}
|
||||
with engine.connect() as connection:
|
||||
assert connection.execute(text("SELECT prompt, request_authority_json FROM scheduled_tasks")).one() == ("Run bash", None)
|
||||
engine.dispose()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_server_seeded_defaults_have_authority_but_legacy_rows_do_not_gain_it(monkeypatch, tmp_path):
|
||||
import core.database as database
|
||||
import routes.prefs_routes as preferences
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from src.task_scheduler import TaskScheduler
|
||||
engine = create_engine(f"sqlite:///{tmp_path / 'tasks.db'}")
|
||||
database.Base.metadata.create_all(engine)
|
||||
sessions = sessionmaker(bind=engine)
|
||||
monkeypatch.setattr(database, "SessionLocal", sessions)
|
||||
monkeypatch.setattr(preferences, "_load_for_user", lambda owner: {})
|
||||
scheduler = TaskScheduler(session_manager=None)
|
||||
monkeypatch.setattr(scheduler, "ensure_assistant_defaults", AsyncMock())
|
||||
with sessions() as db:
|
||||
db.add(database.ScheduledTask(id="legacy", owner="alice", name="Legacy housekeeping",
|
||||
task_type="action", action="tidy_sessions"))
|
||||
db.commit()
|
||||
await scheduler.ensure_defaults("alice")
|
||||
with sessions() as db:
|
||||
tasks = db.query(database.ScheduledTask).all()
|
||||
assert len(tasks) > 1
|
||||
for task in tasks:
|
||||
grant = restore_task_authority(task.request_authority_json, task.prompt,
|
||||
task.task_type, task.action, owner=task.owner)
|
||||
assert grant.permits(task_operation(task.task_type, task.action, task.prompt)) == (task.id != "legacy")
|
||||
engine.dispose()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dispatch_binds_explicit_authority_for_nested_handler(monkeypatch):
|
||||
from src import tool_execution as execution
|
||||
seen = []
|
||||
async def handler(*args, **kwargs):
|
||||
seen.append(active_request_authority())
|
||||
with bind_request_authority(authority("bash", "transcribe_media")) as child:
|
||||
assert not child.permits(ExactOperation.normalize("bash", "pwd"))
|
||||
return "transcribe_media", {"exit_code": 0}
|
||||
monkeypatch.setattr(execution, "_execute_tool_block_impl", handler)
|
||||
await execution.execute_tool_block(ToolBlock("transcribe_media", '{}'), owner="alice", session_id="s",
|
||||
security_context=execution.NO_TOOL_SECURITY_CONTEXT, request_authority=authority("transcribe_media"))
|
||||
assert seen
|
||||
assert active_request_authority() is None
|
||||
|
||||
|
||||
def test_tool_http_task_payload_is_not_new_user_authority():
|
||||
from core.middleware import INTERNAL_TOOL_HEADER, INTERNAL_TOOL_TOKEN
|
||||
from routes.task.task_routes import _seal_request_task_authority
|
||||
request = SimpleNamespace(headers={INTERNAL_TOOL_HEADER: INTERNAL_TOOL_TOKEN})
|
||||
snapshot = _seal_request_task_authority(request, "Run bash in workspace", "llm", None, "alice")
|
||||
restored = restore_task_authority(snapshot, "Run bash in workspace", "llm", None, owner="alice")
|
||||
assert not restored.permits(ExactOperation.normalize("bash", "pwd"))
|
||||
|
||||
|
||||
@pytest.mark.parametrize("marker", ["header", "middleware"])
|
||||
@pytest.mark.parametrize("owner", [None, "alice"])
|
||||
def test_internal_tool_requests_cannot_submit_user_approval(marker, owner):
|
||||
from core.middleware import INTERNAL_TOOL_HEADER, INTERNAL_TOOL_TOKEN
|
||||
from fastapi import HTTPException
|
||||
request = SimpleNamespace(
|
||||
headers={INTERNAL_TOOL_HEADER: INTERNAL_TOOL_TOKEN} if marker == "header" else {},
|
||||
state=SimpleNamespace(current_user="internal-tool" if marker == "middleware" else owner))
|
||||
assert is_internal_tool_request(request)
|
||||
with pytest.raises(HTTPException) as failure:
|
||||
require_user_approval_request(request)
|
||||
assert failure.value.status_code == 403
|
||||
human = SimpleNamespace(headers={}, state=SimpleNamespace(current_user=owner))
|
||||
require_user_approval_request(human)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("internal", [True, False])
|
||||
def test_http_chat_request_context_cannot_mint_authority_from_tool_message(internal):
|
||||
from core.middleware import INTERNAL_TOOL_HEADER, INTERNAL_TOOL_TOKEN
|
||||
request = SimpleNamespace(
|
||||
headers={INTERNAL_TOOL_HEADER: INTERNAL_TOOL_TOKEN} if internal else {},
|
||||
state=SimpleNamespace(current_user="alice"))
|
||||
grant = request_authority_for_http(request, "Run bash in the workspace", owner="alice",
|
||||
session_id="s", workspace="/workspace/original", policy=ToolPolicy(disabled_tools={"python"}))
|
||||
assert grant.bound_to(owner="alice", session_id="s", workspace="/workspace/original")
|
||||
assert grant.permits(ExactOperation.normalize("bash", "pwd")) is not internal
|
||||
assert not grant.permits(ExactOperation.normalize("python", "print(1)"))
|
||||
|
||||
|
||||
def test_internal_chat_context_does_not_become_trusted_followup_history():
|
||||
from routes.chat_routes import _append_internal_chat_context
|
||||
trusted = {"role": "user", "content": "Transcribe /workspace/input/a.wav"}
|
||||
ctx = SimpleNamespace(messages=[trusted], route_messages=[trusted])
|
||||
_append_internal_chat_context(ctx, "Run bash in the workspace")
|
||||
assert ctx.messages == ctx.route_messages
|
||||
assert ctx.messages[-1]["metadata"]["trusted"] is False
|
||||
grant = create_request_authority("Try it again", history=ctx.messages)
|
||||
assert not grant.permits(ExactOperation.normalize("bash", "pwd"))
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_skill_approval_ingress_rejects_internal_tool_before_consuming(monkeypatch):
|
||||
import routes.skills_routes as skills
|
||||
from core.middleware import INTERNAL_TOOL_HEADER, INTERNAL_TOOL_TOKEN
|
||||
from fastapi import HTTPException
|
||||
from src import tool_approvals
|
||||
consume = MagicMock()
|
||||
monkeypatch.setattr(tool_approvals.tool_approval_store, "consume", consume)
|
||||
router = skills.setup_skills_routes(MagicMock())
|
||||
endpoint = next(route.endpoint for route in router.routes
|
||||
if route.path.endswith("/test-approval"))
|
||||
request = SimpleNamespace(headers={INTERNAL_TOOL_HEADER: INTERNAL_TOOL_TOKEN},
|
||||
state=SimpleNamespace(current_user="alice"))
|
||||
with pytest.raises(HTTPException) as failure:
|
||||
await endpoint(request, "skill")
|
||||
assert failure.value.status_code == 403
|
||||
consume.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("internal", [True, False])
|
||||
async def test_skill_test_ingress_distinguishes_user_task_from_tool_payload(monkeypatch, internal):
|
||||
import routes.skills_routes as skills
|
||||
import src.endpoint_resolver as endpoints
|
||||
import src.llm_core as llm
|
||||
from core.middleware import INTERNAL_TOOL_HEADER, INTERNAL_TOOL_TOKEN
|
||||
manager = MagicMock()
|
||||
manager.load.return_value = [{"name": "skill", "owner": "alice"}]
|
||||
manager.read_skill_md.return_value = "# Skill"
|
||||
monkeypatch.setattr(skills, "get_current_user", lambda request: "alice")
|
||||
monkeypatch.setattr(skills, "_skill_test_jobs", {})
|
||||
monkeypatch.setattr(endpoints, "resolve_endpoint", lambda *a, **k: ("https://inference.invalid", "model", {}))
|
||||
monkeypatch.setattr(llm, "list_model_ids", lambda *a, **k: [])
|
||||
seen = []
|
||||
async def run(*args, **kwargs):
|
||||
seen.append(kwargs["request_authority"])
|
||||
monkeypatch.setattr(skills, "_run_skill_test_job", run)
|
||||
router = skills.setup_skills_routes(manager)
|
||||
endpoint = next(route.endpoint for route in router.routes if route.path.endswith("/{skill_id}/test"))
|
||||
request = SimpleNamespace(
|
||||
headers={INTERNAL_TOOL_HEADER: INTERNAL_TOOL_TOKEN} if internal else {},
|
||||
state=SimpleNamespace(current_user="alice"),
|
||||
json=AsyncMock(return_value={"task": "Run bash in the workspace"}))
|
||||
result = await endpoint(request, "skill")
|
||||
await asyncio.sleep(0)
|
||||
assert result["status"] == "running"
|
||||
assert len(seen) == 1
|
||||
assert seen[0].permits(ExactOperation.normalize("bash", "pwd")) is not internal
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_scheduled_override_cannot_replace_stored_authority(monkeypatch):
|
||||
from src import agent_loop
|
||||
from src.task_scheduler import TaskScheduler
|
||||
seen = []
|
||||
async def fake_stream(*args, **kwargs):
|
||||
seen.append(kwargs)
|
||||
yield 'data: {"delta":"done"}\n\n'
|
||||
yield 'data: [DONE]\n\n'
|
||||
monkeypatch.setattr(agent_loop, "stream_agent_loop", fake_stream)
|
||||
task = SimpleNamespace(prompt="Transcribe /workspace/input/a.wav", task_type="llm", action=None,
|
||||
owner="alice", name="test", max_steps=1)
|
||||
with bind_request_authority(authority("transcribe_media", workspace="/workspace/original")):
|
||||
task.request_authority_json = seal_task_authority(task.prompt, "llm", None, owner="alice")
|
||||
await TaskScheduler(session_manager=None)._run_agent_loop("https://inference.invalid", "test", task, "s",
|
||||
override_user_message="Run bash and python")
|
||||
grant = seen[0]["request_authority"]
|
||||
assert grant.permits(ExactOperation.normalize("transcribe_media", '{}'))
|
||||
assert not grant.permits(ExactOperation.normalize("bash", "pwd"))
|
||||
assert grant.workspace == "/workspace/original"
|
||||
assert seen[0]["workspace"] == grant.workspace
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_loop_retains_denial_before_offered_inventory_reconciliation(monkeypatch):
|
||||
from src import agent_loop as loop, tool_execution as execution
|
||||
from src.turn_contract import resolve_turn_contract
|
||||
implementation = AsyncMock(return_value=("bash", {"exit_code": 0, "output": "unexpected"}))
|
||||
monkeypatch.setattr(execution, "_execute_tool_block_impl", implementation)
|
||||
monkeypatch.setattr(loop, "get_setting", lambda key, default=None: default)
|
||||
monkeypatch.setattr(loop, "get_mcp_manager", lambda: None)
|
||||
monkeypatch.setattr(loop, "estimate_tokens", lambda *a, **k: 10)
|
||||
async def fake_stream(*args, **kwargs):
|
||||
yield 'data: ' + json.dumps({"delta": '```bash\npwd\n```'}) + '\n\n'
|
||||
monkeypatch.setattr(loop, "stream_llm_with_fallback", fake_stream)
|
||||
contract = resolve_turn_contract(capabilities={"shell_files"}, selected_tools={"bash"},
|
||||
schemas=[{"function": {"name": "bash"}}], policy=ToolPolicy())
|
||||
chunks = [chunk async for chunk in loop.stream_agent_loop(
|
||||
"https://inference.invalid", "test", [{"role": "user", "content": "Run bash pwd"}],
|
||||
owner="alice", session_id="s", max_rounds=1, turn_contract=contract, disabled_tools={"bash"})]
|
||||
implementation.assert_not_awaited()
|
||||
assert chunks[-1] == 'data: [DONE]\n\n'
|
||||
assert active_request_authority() is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_authority_denial_cannot_create_completion_receipt(monkeypatch):
|
||||
from src import tool_execution as execution
|
||||
from src.agent_runtime.journal import ActionJournal, bind_journal
|
||||
journal = ActionJournal()
|
||||
with bind_journal(journal):
|
||||
await execution.execute_tool_block(ToolBlock("bash", "pwd"), owner="alice", session_id="s",
|
||||
security_context=execution.NO_TOOL_SECURITY_CONTEXT,
|
||||
request_authority=authority("transcribe_media"))
|
||||
assert len(journal.actions) == 1
|
||||
assert journal.actions[0].execution_id is None
|
||||
assert journal.actions[0].outcome["authoritative"] is False
|
||||
assert journal.actions[0].outcome["blocked"] is True
|
||||
@@ -14,9 +14,10 @@ from src.preset_manager import PresetManager
|
||||
|
||||
async def _execute_without_run_context(execute_tool_block, *args, **kwargs):
|
||||
from src.tool_execution import NO_TOOL_SECURITY_CONTEXT
|
||||
from tests.runtime_evidence_helpers import server_authorized_executor
|
||||
|
||||
kwargs.setdefault("security_context", NO_TOOL_SECURITY_CONTEXT)
|
||||
return await execute_tool_block(*args, **kwargs)
|
||||
return await server_authorized_executor(execute_tool_block)(*args, **kwargs)
|
||||
|
||||
|
||||
class _FakeColumn:
|
||||
|
||||
@@ -6,6 +6,14 @@ import json
|
||||
import os
|
||||
|
||||
import pytest
|
||||
from tests.runtime_evidence_helpers import server_authorized_executor
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def standalone_dispatch_authority(monkeypatch):
|
||||
from src import tool_execution
|
||||
monkeypatch.setattr(tool_execution, "execute_tool_block",
|
||||
server_authorized_executor(tool_execution.execute_tool_block))
|
||||
|
||||
from src.agent_evidence import CompletionRequirements, EvidenceLedger, EvidenceKind
|
||||
from src.agent_runtime.completion import completion_answer, with_completion_gate
|
||||
|
||||
@@ -1,16 +1,21 @@
|
||||
"""Tests for shell_routes.py helpers."""
|
||||
|
||||
import asyncio
|
||||
import builtins
|
||||
import errno
|
||||
import importlib
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
import shlex
|
||||
import signal
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from core.platform_compat import pid_alive
|
||||
from routes.shell_routes import (
|
||||
_find_line_break,
|
||||
_host_docker_access_enabled,
|
||||
@@ -60,6 +65,29 @@ def test_shell_routes_import_without_posix_pty_modules(monkeypatch):
|
||||
assert module._find_line_break(b"ok\n") == (2, 1)
|
||||
|
||||
|
||||
def test_shell_routes_import_without_sigkill(monkeypatch):
|
||||
"""Native Windows has no signal.SIGKILL; app.py imports this module anyway.
|
||||
|
||||
The teardown escalation is resolved at import time, so naming SIGKILL
|
||||
unconditionally would stop the whole app from starting on Windows rather
|
||||
than only degrading PTY teardown there.
|
||||
"""
|
||||
monkeypatch.delattr(signal, "SIGKILL", raising=False)
|
||||
|
||||
module_path = Path(__file__).resolve().parents[1] / "routes" / "shell_routes.py"
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
"_shell_routes_without_sigkill", module_path
|
||||
)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
sys.modules[spec.name] = module
|
||||
try:
|
||||
spec.loader.exec_module(module)
|
||||
finally:
|
||||
sys.modules.pop(spec.name, None)
|
||||
|
||||
assert module.PTY_KILL_ESCALATION == (signal.SIGTERM,)
|
||||
|
||||
|
||||
async def test_generate_pty_reports_explicit_unsupported_error(monkeypatch):
|
||||
"""Clients can distinguish unsupported PTY mode from process failures."""
|
||||
import routes.shell_routes as shell_routes
|
||||
@@ -85,6 +113,273 @@ async def test_generate_pty_reports_explicit_unsupported_error(monkeypatch):
|
||||
]
|
||||
|
||||
|
||||
pty_session = pytest.mark.skipif(
|
||||
not hasattr(os, "setsid"), reason="process sessions are POSIX-only"
|
||||
)
|
||||
|
||||
|
||||
async def _spawn_pty_style_session(script: str):
|
||||
"""Spawn `script` the way _generate_pty does: its own session via setsid."""
|
||||
return await asyncio.create_subprocess_shell(
|
||||
script,
|
||||
stdout=asyncio.subprocess.DEVNULL,
|
||||
stderr=asyncio.subprocess.DEVNULL,
|
||||
preexec_fn=os.setsid,
|
||||
)
|
||||
|
||||
|
||||
def _stubborn_child(pid_file: Path, ignore: tuple[str, ...]) -> str:
|
||||
"""Shell snippet that starts a child ignoring `ignore`, then waits for it.
|
||||
|
||||
Killing a PTY session leader makes the kernel send SIGHUP to the
|
||||
terminal's foreground process group, so a plain `sleep` child looks
|
||||
contained even when nothing ever signalled the group. A child that ignores
|
||||
SIGHUP is what an admin actually runs into — a `nohup`ed job, a daemon,
|
||||
anything meant to outlive its terminal.
|
||||
|
||||
The child publishes its own pid only after installing the handlers, and
|
||||
the snippet blocks until it does, so a test can never signal it while it
|
||||
is still starting up and read that as teardown having worked.
|
||||
"""
|
||||
ignores = "".join(
|
||||
f"signal.signal(signal.{name}, signal.SIG_IGN); " for name in ignore
|
||||
)
|
||||
script = (
|
||||
f"import os, signal, time; {ignores}"
|
||||
f"open({str(pid_file)!r}, 'w').write(str(os.getpid())); "
|
||||
"time.sleep(120)"
|
||||
)
|
||||
return (
|
||||
f"{sys.executable} -c {shlex.quote(script)} & "
|
||||
f"while [ ! -s {pid_file} ]; do sleep 0.02; done"
|
||||
)
|
||||
|
||||
|
||||
async def _never_disconnected() -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def _reap_if_alive(pid: int) -> None:
|
||||
"""Clean up a descendant the code under test was supposed to have killed."""
|
||||
if pid_alive(pid):
|
||||
try:
|
||||
os.kill(pid, signal.SIGKILL)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
async def _read_pid(path: Path, timeout: float = 5.0) -> int:
|
||||
"""Wait for a child to publish its pid, then return it."""
|
||||
loop = asyncio.get_running_loop()
|
||||
deadline = loop.time() + timeout
|
||||
while loop.time() < deadline:
|
||||
if path.exists():
|
||||
text = path.read_text().strip()
|
||||
if text:
|
||||
return int(text)
|
||||
await asyncio.sleep(0.01)
|
||||
raise AssertionError(f"child never wrote its pid to {path}")
|
||||
|
||||
|
||||
@pty_session
|
||||
async def test_terminate_pty_session_kills_descendants(tmp_path):
|
||||
"""Tearing down a PTY command takes its children, not only the shell."""
|
||||
import routes.shell_routes as shell_routes
|
||||
|
||||
pid_file = tmp_path / "child.pid"
|
||||
proc = await _spawn_pty_style_session(
|
||||
f"sleep 120 & echo $! > {pid_file}; sleep 120"
|
||||
)
|
||||
try:
|
||||
child_pid = await _read_pid(pid_file)
|
||||
assert pid_alive(child_pid)
|
||||
|
||||
assert await shell_routes._terminate_pty_session(proc) is True
|
||||
|
||||
assert proc.returncode is not None
|
||||
assert not pid_alive(child_pid)
|
||||
finally:
|
||||
await shell_routes._terminate_pty_session(proc)
|
||||
|
||||
|
||||
@pty_session
|
||||
async def test_terminate_pty_session_escalates_past_ignored_sigterm(tmp_path):
|
||||
"""A child that ignores SIGTERM is still gone when teardown returns."""
|
||||
import routes.shell_routes as shell_routes
|
||||
|
||||
pid_file = tmp_path / "child.pid"
|
||||
child = _stubborn_child(pid_file, ("SIGHUP", "SIGTERM"))
|
||||
proc = await _spawn_pty_style_session(f"{child}; sleep 120")
|
||||
try:
|
||||
child_pid = await _read_pid(pid_file)
|
||||
assert pid_alive(child_pid)
|
||||
|
||||
assert await shell_routes._terminate_pty_session(proc) is True
|
||||
|
||||
assert not pid_alive(child_pid)
|
||||
finally:
|
||||
await shell_routes._terminate_pty_session(proc)
|
||||
|
||||
|
||||
@pty_session
|
||||
async def test_generate_pty_timeout_kills_the_whole_session(tmp_path):
|
||||
"""A timed-out PTY command leaves none of its children running."""
|
||||
import routes.shell_routes as shell_routes
|
||||
|
||||
pid_file = tmp_path / "child.pid"
|
||||
child = _stubborn_child(pid_file, ("SIGHUP",))
|
||||
cmd = f"{child}; echo ready; sleep 120"
|
||||
request = SimpleNamespace(is_disconnected=_never_disconnected)
|
||||
|
||||
events = [
|
||||
json.loads(chunk.removeprefix("data: ").strip())
|
||||
async for chunk in shell_routes._generate_pty(cmd, 1, request)
|
||||
]
|
||||
|
||||
child_pid = await _read_pid(pid_file)
|
||||
try:
|
||||
assert events[-1] == {"exit_code": -1}
|
||||
assert events[-2]["data"].startswith("Command timed out after 1s")
|
||||
assert not pid_alive(child_pid)
|
||||
finally:
|
||||
_reap_if_alive(child_pid)
|
||||
|
||||
|
||||
@pty_session
|
||||
async def test_generate_pty_disconnect_kills_the_whole_session(tmp_path):
|
||||
"""Abandoning the stream kills the command's children too."""
|
||||
import routes.shell_routes as shell_routes
|
||||
|
||||
pid_file = tmp_path / "child.pid"
|
||||
child = _stubborn_child(pid_file, ("SIGHUP",))
|
||||
cmd = f"{child}; echo ready; sleep 120"
|
||||
|
||||
polls = []
|
||||
|
||||
async def disconnect_after_first_poll() -> bool:
|
||||
polls.append(None)
|
||||
return len(polls) > 1
|
||||
|
||||
request = SimpleNamespace(is_disconnected=disconnect_after_first_poll)
|
||||
|
||||
async for _ in shell_routes._generate_pty(cmd, 0, request):
|
||||
pass
|
||||
|
||||
child_pid = await _read_pid(pid_file)
|
||||
try:
|
||||
assert not pid_alive(child_pid)
|
||||
finally:
|
||||
_reap_if_alive(child_pid)
|
||||
|
||||
|
||||
async def test_terminate_pty_session_reports_a_session_it_could_not_kill(
|
||||
monkeypatch,
|
||||
):
|
||||
"""Teardown returns False rather than claiming a surviving session died."""
|
||||
import routes.shell_routes as shell_routes
|
||||
|
||||
monkeypatch.setattr(shell_routes, "PTY_KILL_GRACE", 0.01)
|
||||
monkeypatch.setattr(shell_routes, "_signal_session", lambda *_: True)
|
||||
monkeypatch.setattr(shell_routes, "_session_alive", lambda *_: True)
|
||||
monkeypatch.setattr(shell_routes, "_session_pgid", lambda _: 4242)
|
||||
|
||||
proc = SimpleNamespace(pid=4242, returncode=0, wait=None)
|
||||
assert await shell_routes._terminate_pty_session(proc) is False
|
||||
|
||||
|
||||
async def test_terminate_pty_session_escalates_before_giving_up(monkeypatch):
|
||||
"""SIGTERM then SIGKILL — the group is never signalled only once."""
|
||||
import routes.shell_routes as shell_routes
|
||||
|
||||
sent = []
|
||||
monkeypatch.setattr(shell_routes, "PTY_KILL_GRACE", 0.01)
|
||||
monkeypatch.setattr(shell_routes, "_session_pgid", lambda _: 4242)
|
||||
monkeypatch.setattr(shell_routes, "_session_alive", lambda *_: True)
|
||||
monkeypatch.setattr(
|
||||
shell_routes,
|
||||
"_signal_session",
|
||||
lambda pgid, pid, sig: sent.append(sig) or True,
|
||||
)
|
||||
|
||||
proc = SimpleNamespace(pid=4242, returncode=0, wait=None)
|
||||
await shell_routes._terminate_pty_session(proc)
|
||||
|
||||
assert sent == [signal.SIGTERM, signal.SIGKILL]
|
||||
|
||||
|
||||
@pty_session
|
||||
async def test_generate_pty_timeout_says_so_when_the_session_survives(
|
||||
monkeypatch,
|
||||
):
|
||||
"""A timed-out command no longer reports clean termination it didn't get."""
|
||||
import routes.shell_routes as shell_routes
|
||||
|
||||
real_terminate = shell_routes._terminate_pty_session
|
||||
|
||||
async def terminate_but_report_failure(proc):
|
||||
await real_terminate(proc)
|
||||
return False
|
||||
|
||||
monkeypatch.setattr(
|
||||
shell_routes, "_terminate_pty_session", terminate_but_report_failure
|
||||
)
|
||||
|
||||
request = SimpleNamespace(is_disconnected=_never_disconnected)
|
||||
events = [
|
||||
json.loads(chunk.removeprefix("data: ").strip())
|
||||
async for chunk in shell_routes._generate_pty("echo ready; sleep 30", 1, request)
|
||||
]
|
||||
|
||||
assert events[-1] == {"exit_code": -1}
|
||||
timed_out = events[-2]
|
||||
assert timed_out["stream"] == "stderr"
|
||||
assert timed_out["data"] == (
|
||||
"Command timed out after 1s" + shell_routes.PTY_KILL_FAILED_HINT
|
||||
)
|
||||
|
||||
|
||||
def test_session_alive_treats_a_refused_probe_as_alive(monkeypatch):
|
||||
"""EPERM says the group exists but we may not signal it, not that it died.
|
||||
|
||||
Only ESRCH proves a process group is gone. Collapsing every OSError into
|
||||
"gone" is the one error that makes teardown report a surviving session as
|
||||
contained.
|
||||
"""
|
||||
import routes.shell_routes as shell_routes
|
||||
|
||||
def refuse(_pgid, _sig):
|
||||
raise PermissionError(errno.EPERM, "Operation not permitted")
|
||||
|
||||
monkeypatch.setattr(shell_routes.os, "killpg", refuse)
|
||||
assert shell_routes._session_alive(4242, 4242) is True
|
||||
|
||||
def gone(_pgid, _sig):
|
||||
raise ProcessLookupError(errno.ESRCH, "No such process")
|
||||
|
||||
monkeypatch.setattr(shell_routes.os, "killpg", gone)
|
||||
assert shell_routes._session_alive(4242, 4242) is False
|
||||
|
||||
|
||||
async def test_terminate_pty_session_reports_a_group_it_may_not_signal(monkeypatch):
|
||||
"""A session we cannot signal at all is reported as not contained.
|
||||
|
||||
Both the signal and the liveness probe are refused, so teardown has done
|
||||
nothing and must say so rather than infer death from its own failure.
|
||||
"""
|
||||
import routes.shell_routes as shell_routes
|
||||
|
||||
def refuse(*_args):
|
||||
raise PermissionError(errno.EPERM, "Operation not permitted")
|
||||
|
||||
monkeypatch.setattr(shell_routes, "PTY_KILL_GRACE", 0.01)
|
||||
monkeypatch.setattr(shell_routes, "_session_pgid", lambda _: 4242)
|
||||
monkeypatch.setattr(shell_routes.os, "killpg", refuse)
|
||||
monkeypatch.setattr(shell_routes.os, "kill", refuse)
|
||||
|
||||
proc = SimpleNamespace(pid=4242, returncode=0, wait=None)
|
||||
assert await shell_routes._terminate_pty_session(proc) is False
|
||||
|
||||
|
||||
class TestFindLineBreak:
|
||||
"""Test line-break detection in byte buffers."""
|
||||
|
||||
|
||||
@@ -88,7 +88,7 @@ def builtin_action_info(monkeypatch):
|
||||
|
||||
|
||||
def _req(user):
|
||||
return SimpleNamespace(state=SimpleNamespace(current_user=user))
|
||||
return SimpleNamespace(state=SimpleNamespace(current_user=user), headers={})
|
||||
|
||||
|
||||
def _endpoint(method, path):
|
||||
|
||||
@@ -25,6 +25,7 @@ def _setup_db(tmp_path, monkeypatch):
|
||||
next_run = Column(DateTime)
|
||||
last_run = Column(DateTime)
|
||||
prompt = Column(Text, default='')
|
||||
request_authority_json = Column(Text)
|
||||
trigger_type = Column(String, default='schedule')
|
||||
schedule = Column(String, default='daily')
|
||||
scheduled_time = Column(String, default='08:00')
|
||||
@@ -187,9 +188,11 @@ def test_running_task_cancel_keeps_event_loop_responsive_during_database_lock(tm
|
||||
from src.task_scheduler import TaskScheduler
|
||||
from src.builtin_actions import BUILTIN_ACTIONS
|
||||
monkeypatch.setenv('BACKGROUND_TASK_FOREGROUND_GATE', 'false')
|
||||
from src.agent_runtime.authority import seal_task_authority
|
||||
with session_local() as db:
|
||||
db.add(ScheduledTask(id='running-task', owner='alice', name='Fixture action',
|
||||
task_type='action', action='fixture_wait', status='active'))
|
||||
task_type='action', action='fixture_wait', status='active',
|
||||
request_authority_json=seal_task_authority('', 'action', 'fixture_wait', owner='alice')))
|
||||
db.commit()
|
||||
started = asyncio.Event()
|
||||
async def external_action(**kwargs):
|
||||
|
||||
@@ -4,6 +4,14 @@ import time
|
||||
from collections import namedtuple
|
||||
|
||||
import pytest
|
||||
from tests.runtime_evidence_helpers import server_authorized_executor
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def standalone_dispatch_authority(monkeypatch):
|
||||
from src import tool_execution
|
||||
monkeypatch.setattr(tool_execution, "execute_tool_block",
|
||||
server_authorized_executor(tool_execution.execute_tool_block))
|
||||
|
||||
from src.tool_approvals import ToolApprovalStore, document_content_digest
|
||||
from src.tool_capabilities import ToolRunSecurityContext, capabilities_for_action
|
||||
@@ -325,6 +333,8 @@ def test_approved_document_version_guard_rejects_changed_target():
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_missing_sealed_document_does_not_fall_back_to_another(monkeypatch):
|
||||
import sys
|
||||
from types import ModuleType
|
||||
import src.agent_tools.document_tools as document_tools
|
||||
|
||||
class FakeDb:
|
||||
@@ -334,7 +344,11 @@ async def test_missing_sealed_document_does_not_fall_back_to_another(monkeypatch
|
||||
def rollback(self):
|
||||
pass
|
||||
|
||||
monkeypatch.setattr("src.database.SessionLocal", lambda: FakeDb())
|
||||
database = ModuleType("src.database")
|
||||
database.SessionLocal = lambda: FakeDb()
|
||||
database.Document = object
|
||||
database.DocumentVersion = object
|
||||
monkeypatch.setitem(sys.modules, "src.database", database)
|
||||
monkeypatch.setattr(
|
||||
document_tools,
|
||||
"_get_owned_document",
|
||||
|
||||
@@ -18,6 +18,14 @@ from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from tests.runtime_evidence_helpers import server_authorized_executor
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def standalone_dispatch_authority(monkeypatch):
|
||||
from src import tool_execution
|
||||
monkeypatch.setattr(tool_execution, "execute_tool_block",
|
||||
server_authorized_executor(tool_execution.execute_tool_block))
|
||||
|
||||
|
||||
def _make_block(tool_type, content):
|
||||
|
||||
@@ -17,6 +17,9 @@ from src.tool_policy import (
|
||||
web_search_enabled_for_turn,
|
||||
)
|
||||
from src.turn_contract import requested_capabilities
|
||||
from tests.runtime_evidence_helpers import server_authorized_executor
|
||||
|
||||
execute_tool_block = server_authorized_executor(execute_tool_block)
|
||||
|
||||
|
||||
def _collect(gen):
|
||||
|
||||
@@ -1806,6 +1806,8 @@ def test_candidate_schema_changes_cannot_mutate_contract():
|
||||
async def test_dispatcher_enforces_bound_contract_without_external_mutations(monkeypatch):
|
||||
from src import tool_execution, tool_implementations
|
||||
from src.tool_execution import NO_TOOL_SECURITY_CONTEXT, execute_tool_block
|
||||
from tests.runtime_evidence_helpers import server_authorized_executor
|
||||
execute_tool_block = server_authorized_executor(execute_tool_block)
|
||||
|
||||
handler = AsyncMock(return_value={"events": [], "exit_code": 0})
|
||||
monkeypatch.setattr(tool_implementations, "do_manage_calendar", handler)
|
||||
|
||||
@@ -4,6 +4,14 @@ import json
|
||||
from unittest.mock import AsyncMock, Mock
|
||||
|
||||
import pytest
|
||||
from tests.runtime_evidence_helpers import server_authorized_executor
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def standalone_dispatch_authority(monkeypatch):
|
||||
from src import tool_execution
|
||||
monkeypatch.setattr(tool_execution, "execute_tool_block",
|
||||
server_authorized_executor(tool_execution.execute_tool_block))
|
||||
|
||||
from src.tool_policy import ToolPolicy
|
||||
from src.tool_schemas import FUNCTION_TOOL_SCHEMAS
|
||||
|
||||
@@ -11,6 +11,9 @@ from src.agent_tools import ToolBlock, TOOL_TAGS # import first to avoid circul
|
||||
from src.tool_execution import NO_TOOL_SECURITY_CONTEXT, execute_tool_block
|
||||
from src.tool_index import ALWAYS_AVAILABLE, BUILTIN_TOOL_DESCRIPTIONS
|
||||
from src.tool_security import is_public_blocked_tool
|
||||
from tests.runtime_evidence_helpers import server_authorized_executor
|
||||
|
||||
execute_tool_block = server_authorized_executor(execute_tool_block)
|
||||
|
||||
|
||||
def _run(content):
|
||||
|
||||
@@ -10,6 +10,9 @@ from src.tool_policy import WEB_TOOL_NAMES, ToolPolicy
|
||||
from src.tool_schemas import FUNCTION_TOOL_SCHEMAS
|
||||
from src.tool_types import ToolBlock
|
||||
from src.tool_execution import NO_TOOL_SECURITY_CONTEXT, execute_tool_block
|
||||
from tests.runtime_evidence_helpers import server_authorized_executor
|
||||
|
||||
execute_tool_block = server_authorized_executor(execute_tool_block)
|
||||
from src.turn_contract import FAMILY_TOOLS
|
||||
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ import tempfile
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
from tests.runtime_evidence_helpers import server_authorized_executor
|
||||
|
||||
from src.tool_execution import (
|
||||
NO_TOOL_SECURITY_CONTEXT,
|
||||
@@ -31,6 +32,9 @@ from src.tool_execution import (
|
||||
)
|
||||
|
||||
|
||||
_execute_tool_block = server_authorized_executor(_execute_tool_block)
|
||||
|
||||
|
||||
async def execute_tool_block(*args, **kwargs):
|
||||
kwargs.setdefault("security_context", NO_TOOL_SECURITY_CONTEXT)
|
||||
return await _execute_tool_block(*args, **kwargs)
|
||||
|
||||
Reference in New Issue
Block a user