Compare commits

...
8 changed files with 298 additions and 18 deletions
+5 -3
View File
@@ -200,7 +200,9 @@ class AgentCoordinator:
logger.info("agent.status %s=%s", agent_id, status) logger.info("agent.status %s=%s", agent_id, status)
await self._maybe_snapshot() await self._maybe_snapshot()
async def send(self, target_agent_id: str, message: dict[str, Any]) -> bool: async def send(
self, target_agent_id: str, message: dict[str, Any], *, interrupt: bool = True
) -> bool:
"""Deliver a user/peer message by appending it to the target SDK session.""" """Deliver a user/peer message by appending it to the target SDK session."""
if message.get("from") == "user" and self._budget_paused: if message.get("from") == "user" and self._budget_paused:
await self.resume_from_budget_pause(exclude=target_agent_id) await self.resume_from_budget_pause(exclude=target_agent_id)
@@ -211,7 +213,7 @@ class AgentCoordinator:
runtime = self.runtimes.setdefault(target_agent_id, AgentRuntime()) runtime = self.runtimes.setdefault(target_agent_id, AgentRuntime())
session = runtime.session session = runtime.session
stream = runtime.stream stream = runtime.stream
interrupt = runtime.interrupt_on_message interrupt_on_message = runtime.interrupt_on_message
if session is None: if session is None:
logger.warning( logger.warning(
"agent.send dropped target=%s because its SDK session is not attached", "agent.send dropped target=%s because its SDK session is not attached",
@@ -230,7 +232,7 @@ class AgentCoordinator:
async with self._lock: async with self._lock:
self.pending_counts[target_agent_id] = self.pending_counts.get(target_agent_id, 0) + 1 self.pending_counts[target_agent_id] = self.pending_counts.get(target_agent_id, 0) + 1
self.runtimes.setdefault(target_agent_id, AgentRuntime()).wake.set() self.runtimes.setdefault(target_agent_id, AgentRuntime()).wake.set()
if stream is not None and interrupt: if stream is not None and interrupt and interrupt_on_message:
stream.cancel(mode="immediate") stream.cancel(mode="immediate")
await self._maybe_snapshot() await self._maybe_snapshot()
return True return True
+30 -1
View File
@@ -21,6 +21,7 @@ from openai import (
RateLimitError, RateLimitError,
) )
from strix.config import codex
from strix.core.hooks import ( from strix.core.hooks import (
BudgetExceededError, BudgetExceededError,
BudgetPausedError, BudgetPausedError,
@@ -88,6 +89,11 @@ async def _compact_session(
) )
_GUARDRAIL_PARK_ERROR = (
"Blocked by the model's content guardrail (flagged as a possible cybersecurity risk). "
"Set STRIX_LLM to a model that isn't blocked and resume the scan to continue."
)
_TRANSIENT_MODEL_STATUS_CODES = frozenset({408, 500, 502, 503, 504}) _TRANSIENT_MODEL_STATUS_CODES = frozenset({408, 500, 502, 503, 504})
_MAX_TRANSIENT_MODEL_RETRIES = 4 _MAX_TRANSIENT_MODEL_RETRIES = 4
_TRANSIENT_MODEL_RETRY_BASE_DELAY_S = 2.0 _TRANSIENT_MODEL_RETRY_BASE_DELAY_S = 2.0
@@ -304,6 +310,7 @@ async def respawn_subagents(
if coordinator.parent_of.get(aid) is None or aid == root_id: if coordinator.parent_of.get(aid) is None or aid == root_id:
continue continue
md["_restored_status"] = status md["_restored_status"] = status
md["_restored_error"] = coordinator.errors.get(aid)
candidates.append( candidates.append(
( (
aid, aid,
@@ -316,7 +323,8 @@ async def respawn_subagents(
for child_id, name, parent_id, md in candidates: for child_id, name, parent_id, md in candidates:
try: try:
restored_status = str(md.get("_restored_status") or "running") restored_status = str(md.get("_restored_status") or "running")
start_parked = interactive and restored_status != "running" recoverable_park = restored_status == "waiting" and bool(md.get("_restored_error"))
start_parked = interactive and restored_status != "running" and not recoverable_park
if start_parked: if start_parked:
logger.warning( logger.warning(
@@ -572,6 +580,10 @@ async def _run_cycle( # noqa: PLR0912, PLR0915
if session is not None: if session is not None:
input_data = [] input_data = []
continue continue
if codex.is_content_guardrail_error(exc):
return await _handle_content_guardrail(
coordinator, agent_id, exc, interactive=interactive
)
if not interactive: if not interactive:
raise raise
if isinstance(exc, MaxTurnsExceeded): if isinstance(exc, MaxTurnsExceeded):
@@ -589,6 +601,22 @@ async def _run_cycle( # noqa: PLR0912, PLR0915
return stream return stream
async def _handle_content_guardrail(
coordinator: AgentCoordinator,
agent_id: str,
exc: BaseException,
*,
interactive: bool,
) -> RunResultBase | None:
logger.warning("agent %s blocked by the model's content guardrail: %s", agent_id, exc)
if interactive:
await coordinator.set_status(agent_id, "waiting", error=_GUARDRAIL_PARK_ERROR)
return None
await coordinator.set_status(agent_id, "failed", error=_GUARDRAIL_PARK_ERROR)
await _notify_parent_on_terminal(coordinator, agent_id, "failed")
return None
async def _settle_run_result( async def _settle_run_result(
coordinator: AgentCoordinator, coordinator: AgentCoordinator,
agent_id: str, agent_id: str,
@@ -685,6 +713,7 @@ async def _notify_parent_on_terminal(
"priority": "high", "priority": "high",
"content": template.format(name=name, agent_id=agent_id), "content": template.format(name=name, agent_id=agent_id),
}, },
interrupt=False,
) )
+7 -1
View File
@@ -376,6 +376,12 @@ async def run_strix_scan(
async with coordinator._lock: async with coordinator._lock:
root_status = coordinator.statuses.get(root_id) root_status = coordinator.statuses.get(root_id)
root_error = coordinator.errors.get(root_id)
root_recoverable_park = root_status == "waiting" and bool(root_error)
root_start_parked = bool(
interactive and is_resume and root_status != "running" and not root_recoverable_park
)
result = await run_agent_loop( result = await run_agent_loop(
agent=root_agent, agent=root_agent,
@@ -387,7 +393,7 @@ async def run_strix_scan(
agent_id=root_id, agent_id=root_id,
interactive=interactive, interactive=interactive,
session=root_session, session=root_session,
start_parked=bool(interactive and is_resume and root_status != "running"), start_parked=root_start_parked,
event_sink=event_sink, event_sink=event_sink,
hooks=hooks, hooks=hooks,
) )
+1 -1
View File
@@ -884,7 +884,7 @@ def display_completion_message(args: argparse.Namespace, results_path: Path) ->
view_text = Text() view_text = Text()
view_text.append("\n") view_text.append("\n")
view_text.append("View", style="dim") view_text.append("View", style="dim")
view_text.append(" ") view_text.append(" ")
view_text.append(f"strix view {args.run_name}", style="#22c55e") view_text.append(f"strix view {args.run_name}", style="#22c55e")
panel_parts.extend(["\n", view_text]) panel_parts.extend(["\n", view_text])
+7 -5
View File
@@ -20,7 +20,7 @@ class TuiLiveView:
self.events: list[dict[str, Any]] = [] self.events: list[dict[str, Any]] = []
self._next_event_id = 1 self._next_event_id = 1
self._open_assistant_event_by_agent: dict[str, dict[str, Any]] = {} self._open_assistant_event_by_agent: dict[str, dict[str, Any]] = {}
self._tool_event_by_call_id: dict[str, dict[str, Any]] = {} self._tool_event_by_agent_and_call_id: dict[tuple[str, str], dict[str, Any]] = {}
def hydrate_from_run_dir(self, run_dir: Path) -> None: def hydrate_from_run_dir(self, run_dir: Path) -> None:
state_dir = runtime_state_dir(run_dir) state_dir = runtime_state_dir(run_dir)
@@ -223,7 +223,8 @@ class TuiLiveView:
timestamp: str | None = None, timestamp: str | None = None,
) -> None: ) -> None:
call_id = call["call_id"] call_id = call["call_id"]
existing = self._tool_event_by_call_id.get(call_id) event_key = (agent_id, call_id)
existing = self._tool_event_by_agent_and_call_id.get(event_key)
tool_data = { tool_data = {
"tool_name": call["tool_name"], "tool_name": call["tool_name"],
"args": call["args"], "args": call["args"],
@@ -233,7 +234,7 @@ class TuiLiveView:
} }
if existing is None: if existing is None:
event = self._append_event(agent_id, "tool", tool_data, timestamp=timestamp) event = self._append_event(agent_id, "tool", tool_data, timestamp=timestamp)
self._tool_event_by_call_id[call_id] = event self._tool_event_by_agent_and_call_id[event_key] = event
else: else:
existing["data"].update(tool_data) existing["data"].update(tool_data)
self._bump_event(existing, timestamp=timestamp) self._bump_event(existing, timestamp=timestamp)
@@ -249,7 +250,8 @@ class TuiLiveView:
timestamp: str | None = None, timestamp: str | None = None,
) -> None: ) -> None:
call_id = output["call_id"] call_id = output["call_id"]
event = self._tool_event_by_call_id.get(call_id) event_key = (agent_id, call_id)
event = self._tool_event_by_agent_and_call_id.get(event_key)
if event is None: if event is None:
event = self._append_event( event = self._append_event(
agent_id, agent_id,
@@ -263,7 +265,7 @@ class TuiLiveView:
}, },
timestamp=timestamp, timestamp=timestamp,
) )
self._tool_event_by_call_id[call_id] = event self._tool_event_by_agent_and_call_id[event_key] = event
result = _parse_json_value(output["output"]) result = _parse_json_value(output["output"])
event["data"]["result"] = result event["data"]["result"] = result
+11 -4
View File
@@ -107,8 +107,11 @@ def resolve_run_dir(base_dir: Path, run_param: str | None, default_run_dir: Path
return candidate return candidate
# Name of the cookie carrying the per-process session capability. # Prefix of the cookie carrying the per-process session capability. The bound
SESSION_COOKIE = "strix_viewer_session" # port is appended (``strix_viewer_session_<port>``) because browsers scope
# cookies by host only, never by port: concurrent viewers on 127.0.0.1 would
# otherwise share one cookie slot and clobber each other's session.
SESSION_COOKIE_PREFIX = "strix_viewer_session"
class _ViewerState: class _ViewerState:
@@ -135,6 +138,9 @@ class _ViewerState:
# enough to steer a live scan, trigger a report, or browse history -- # enough to steer a live scan, trigger a report, or browse history --
# the token is never handed to a caller who merely reaches ``/``. # the token is never handed to a caller who merely reaches ``/``.
self.session_token = secrets.token_urlsafe(32) self.session_token = secrets.token_urlsafe(32)
# Finalized in ``serve()`` once the port is known (the server binds
# after this state is constructed); see SESSION_COOKIE_PREFIX.
self.cookie_name = SESSION_COOKIE_PREFIX
def _make_handler(state: _ViewerState) -> type[BaseHTTPRequestHandler]: def _make_handler(state: _ViewerState) -> type[BaseHTTPRequestHandler]:
@@ -476,7 +482,7 @@ def _make_handler(state: _ViewerState) -> type[BaseHTTPRequestHandler]:
the browser this process handed the page to can pass. A direct the browser this process handed the page to can pass. A direct
caller on an exposed port has no cookie and is rejected. caller on an exposed port has no cookie and is rejected.
""" """
supplied = self._cookies().get(SESSION_COOKIE, "") supplied = self._cookies().get(state.cookie_name, "")
return bool(supplied) and secrets.compare_digest(supplied, state.session_token) return bool(supplied) and secrets.compare_digest(supplied, state.session_token)
def _token_presented(self, query: dict[str, list[str]]) -> bool: def _token_presented(self, query: dict[str, list[str]]) -> bool:
@@ -512,7 +518,7 @@ def _make_handler(state: _ViewerState) -> type[BaseHTTPRequestHandler]:
# SameSite=Strict (never sent from a cross-site context). # SameSite=Strict (never sent from a cross-site context).
self.send_header( self.send_header(
"Set-Cookie", "Set-Cookie",
f"{SESSION_COOKIE}={state.session_token}; Path=/; HttpOnly; SameSite=Strict", f"{state.cookie_name}={state.session_token}; Path=/; HttpOnly; SameSite=Strict",
) )
self.end_headers() self.end_headers()
self.wfile.write(content) self.wfile.write(content)
@@ -586,6 +592,7 @@ def serve(
httpd.daemon_threads = True httpd.daemon_threads = True
bound_port = int(httpd.server_address[1]) bound_port = int(httpd.server_address[1])
state.cookie_name = f"{SESSION_COOKIE_PREFIX}_{bound_port}"
url = f"http://{host}:{bound_port}" url = f"http://{host}:{bound_port}"
thread = threading.Thread(target=httpd.serve_forever, name="strix-viewer", daemon=True) thread = threading.Thread(target=httpd.serve_forever, name="strix-viewer", daemon=True)
+112 -1
View File
@@ -6,13 +6,21 @@ import asyncio
import contextlib import contextlib
import json import json
from typing import Any from typing import Any
from unittest.mock import MagicMock
import pytest import pytest
from agents.memory import SQLiteSession from agents.memory import SQLiteSession
from agents.tool_context import ToolContext from agents.tool_context import ToolContext
from strix.config import codex
from strix.core import execution
from strix.core.agents import AgentCoordinator from strix.core.agents import AgentCoordinator
from strix.core.execution import _notify_parent_on_terminal, _notify_root_on_budget_reserve from strix.core.execution import (
_handle_content_guardrail,
_notify_parent_on_terminal,
_notify_root_on_budget_reserve,
respawn_subagents,
)
from strix.tools.finish.tool import finish_scan from strix.tools.finish.tool import finish_scan
@@ -465,3 +473,106 @@ async def test_notify_parent_on_terminal_ignores_non_terminal_status(tmp_path: A
assert coordinator.pending_counts.get("root", 0) == 0 assert coordinator.pending_counts.get("root", 0) == 0
session.close() session.close()
class _RecordingStream:
def __init__(self) -> None:
self.cancelled = False
self.cancel_mode: str | None = None
def cancel(self, mode: str = "immediate") -> None:
self.cancelled = True
self.cancel_mode = mode
@pytest.mark.asyncio
async def test_terminal_notice_does_not_cancel_parent_stream(tmp_path: Any) -> None:
coordinator = AgentCoordinator()
await coordinator.register("root", "strix", parent_id=None)
await coordinator.register("child", "recon", parent_id="root")
session = SQLiteSession("root", tmp_path / "agents.db")
stream = _RecordingStream()
await coordinator.attach_runtime("root", session=session, interrupt_on_message=True)
await coordinator.attach_stream("root", stream)
await _notify_parent_on_terminal(coordinator, "child", "crashed")
assert stream.cancelled is False
assert coordinator.pending_counts.get("root", 0) > 0
session.close()
@pytest.mark.asyncio
async def test_guardrail_interactive_parks_agent_wakeable(tmp_path: Any) -> None:
coordinator = AgentCoordinator()
await coordinator.register("root", "strix", parent_id=None)
await coordinator.register("child", "recon", parent_id="root")
exc = codex.CodexContentGuardrailError("chatgpt/gpt-5.6-sol")
result = await _handle_content_guardrail(coordinator, "child", exc, interactive=True)
assert result is None
assert coordinator.statuses["child"] == "waiting"
assert "STRIX_LLM" in coordinator.errors["child"]
waiter = asyncio.create_task(coordinator.wait_for_message("child"))
await asyncio.sleep(0)
assert not waiter.done()
session = SQLiteSession("child", tmp_path / "agents.db")
await coordinator.attach_runtime("child", session=session)
await coordinator.send("child", {"from": "user", "content": "switched model, resume"})
await asyncio.wait_for(waiter, timeout=1.0)
session.close()
@pytest.mark.asyncio
async def test_guardrail_noninteractive_fails_only_blocked_agent(tmp_path: Any) -> None:
coordinator = AgentCoordinator()
await coordinator.register("root", "strix", parent_id=None)
await coordinator.register("child", "recon", parent_id="root")
session = SQLiteSession("root", tmp_path / "agents.db")
await coordinator.attach_runtime("root", session=session)
exc = codex.CodexContentGuardrailError("chatgpt/gpt-5.6-sol")
result = await _handle_content_guardrail(coordinator, "child", exc, interactive=False)
assert result is None
assert coordinator.statuses["child"] == "failed"
assert "STRIX_LLM" in coordinator.errors["child"]
assert coordinator.statuses["root"] == "running"
assert coordinator.pending_counts.get("root", 0) > 0
session.close()
@pytest.mark.asyncio
async def test_resume_revives_guardrail_parked_child_but_not_plain_waiting(
tmp_path: Any, monkeypatch: pytest.MonkeyPatch
) -> None:
coordinator = AgentCoordinator()
await coordinator.register("root", "strix", parent_id=None)
await coordinator.register("blocked", "recon", parent_id="root")
await coordinator.register("peer_waiter", "recon", parent_id="root")
await coordinator.set_status("blocked", "waiting", error="STRIX_LLM guardrail")
await coordinator.set_status("peer_waiter", "waiting")
parked: dict[str, bool] = {}
async def _fake_start_child_runner(**kwargs: Any) -> None:
parked[kwargs["child_id"]] = bool(kwargs["start_parked"])
monkeypatch.setattr(execution, "_start_child_runner", _fake_start_child_runner)
await respawn_subagents(
coordinator=coordinator,
factory=lambda **_kwargs: object(),
agents_db_path=tmp_path / "agents.db",
sessions_to_close=[],
run_config=MagicMock(),
max_turns=10,
interactive=True,
parent_ctx={"agent_id": "root", "parent_id": None},
root_id="root",
)
assert parked["blocked"] is False
assert parked["peer_waiter"] is True
+125 -2
View File
@@ -4,9 +4,11 @@ from __future__ import annotations
import json import json
import os import os
import sqlite3
import urllib.error import urllib.error
import urllib.request import urllib.request
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from urllib.parse import urlsplit
from strix.core.paths import latest_run_dir, runs_base_dir from strix.core.paths import latest_run_dir, runs_base_dir
from strix.interface.viewer.server import serve from strix.interface.viewer.server import serve
@@ -86,6 +88,75 @@ def test_build_run_state_from_agents_json(tmp_path: Path) -> None:
assert state["events"] == [] assert state["events"] == []
def test_build_run_state_keeps_same_call_id_separate_per_agent(tmp_path: Path) -> None:
run_dir = _make_run(tmp_path, "tools", status="completed", end_time=None)
agents_db = run_dir / ".state" / "agents.db"
rows = [
(
"root",
{
"type": "function_call",
"call_id": "exec_command_0",
"name": "exec_command",
"arguments": json.dumps({"cmd": "echo root"}),
},
),
(
"root",
{
"type": "function_call_output",
"call_id": "exec_command_0",
"output": json.dumps({"success": True, "output": "root"}),
},
),
(
"child",
{
"type": "function_call",
"call_id": "exec_command_0",
"name": "exec_command",
"arguments": json.dumps({"cmd": "echo child"}),
},
),
(
"child",
{
"type": "function_call_output",
"call_id": "exec_command_0",
"output": json.dumps({"success": True, "output": "child"}),
},
),
]
with sqlite3.connect(agents_db) as conn:
conn.execute(
"""
create table agent_messages (
id integer primary key,
session_id text not null,
message_data text not null,
created_at text not null
)
"""
)
conn.executemany(
"""
insert into agent_messages (session_id, message_data, created_at)
values (?, ?, '2026-01-01T00:00:00+00:00')
""",
[(agent_id, json.dumps(message)) for agent_id, message in rows],
)
state = build_run_state(run_dir)
tools = [event for event in state["events"] if event["type"] == "tool"]
assert len(tools) == 2
by_agent = {event["agent_id"]: event for event in tools}
assert by_agent["root"]["data"]["args"] == {"cmd": "echo root"}
assert by_agent["root"]["data"]["result"]["output"] == "root"
assert by_agent["child"]["data"]["args"] == {"cmd": "echo child"}
assert by_agent["child"]["data"]["result"]["output"] == "child"
def _get(url: str, *, cookie: str | None = None) -> tuple[int, str, bytes]: def _get(url: str, *, cookie: str | None = None) -> tuple[int, str, bytes]:
headers = {"Cookie": cookie} if cookie else {} headers = {"Cookie": cookie} if cookie else {}
req = urllib.request.Request(url, headers=headers) # noqa: S310 - localhost test server req = urllib.request.Request(url, headers=headers) # noqa: S310 - localhost test server
@@ -271,6 +342,11 @@ def _session_cookie(url: str, token: str) -> str:
return raw.split(";", 1)[0] return raw.split(";", 1)[0]
def _cookie_name(url: str) -> str:
"""The per-server session cookie name, derived from the bound port."""
return f"strix_viewer_session_{urlsplit(url).port}"
def _get_status(url: str, *, cookie: str | None = None) -> int: def _get_status(url: str, *, cookie: str | None = None) -> int:
headers = {"Cookie": cookie} if cookie else {} headers = {"Cookie": cookie} if cookie else {}
req = urllib.request.Request(url, headers=headers) # noqa: S310 - localhost test server req = urllib.request.Request(url, headers=headers) # noqa: S310 - localhost test server
@@ -311,7 +387,7 @@ def test_capability_issued_only_for_tokened_bootstrap(
# Only the correct bootstrap token mints the session cookie. # Only the correct bootstrap token mints the session cookie.
with urllib.request.urlopen(f"{url}/?token={token}") as resp: # noqa: S310 # nosec B310 with urllib.request.urlopen(f"{url}/?token={token}") as resp: # noqa: S310 # nosec B310
cookie = str(resp.headers.get("Set-Cookie", "")) cookie = str(resp.headers.get("Set-Cookie", ""))
assert "strix_viewer_session=" in cookie assert f"{_cookie_name(url)}=" in cookie
assert "HttpOnly" in cookie and "SameSite=Strict" in cookie assert "HttpOnly" in cookie and "SameSite=Strict" in cookie
# Static assets never carry it. # Static assets never carry it.
@@ -344,7 +420,7 @@ def test_unauthorized_client_cannot_acquire_capability(
url, url,
"/api/agents/steer", "/api/agents/steer",
{"agent_id": "root", "message": "pwn"}, {"agent_id": "root", "message": "pwn"},
cookie="strix_viewer_session=", cookie=f"{_cookie_name(url)}=",
) )
assert status == 403 assert status == 403
assert delivered == [] assert delivered == []
@@ -541,6 +617,53 @@ def test_runs_list_requires_session_and_verification(
httpd.server_close() httpd.server_close()
def test_concurrent_servers_use_distinct_cookies(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Cookies are host-scoped, not port-scoped: two viewers on 127.0.0.1 must
not share a cookie slot, and one server's cookie must not pass the other's
session gate."""
run_a = _make_run(tmp_path / "a", "run-a", status="running", end_time=None)
run_b = _make_run(tmp_path / "b", "run-b", status="running", end_time=None)
_bundle(tmp_path, monkeypatch)
monkeypatch.setattr(
"strix.interface.viewer.auth.read_auth", lambda: {"email": "a@b.com", "token": "t"}
)
monkeypatch.setattr("strix.interface.viewer.auth.is_verified", lambda: True)
httpd_a, url_a, token_a = serve(run_a, open_browser=False)
httpd_b, url_b, token_b = serve(run_b, open_browser=False)
try:
cookie_a = _session_cookie(url_a, token_a)
cookie_b = _session_cookie(url_b, token_b)
# The two servers mint differently named cookies, so a browser stores both.
assert cookie_a.split("=", 1)[0] == _cookie_name(url_a)
assert cookie_b.split("=", 1)[0] == _cookie_name(url_b)
assert cookie_a.split("=", 1)[0] != cookie_b.split("=", 1)[0]
def _status(url: str, cookie: str) -> dict[str, object]:
_, _, body = _get(f"{url}/api/auth/status", cookie=cookie)
return dict(json.loads(body))
# Each server honors its own cookie...
assert _status(url_a, cookie_a)["verified"] is True
assert _status(url_b, cookie_b)["verified"] is True
# ...but treats the other server's cookie as session-less.
assert _status(url_a, cookie_b)["verified"] is False
assert _status(url_b, cookie_a)["verified"] is False
# Even both cookies together (what a real browser would send) only
# match the token minted by the receiving server.
both = f"{cookie_a}; {cookie_b}"
assert _status(url_a, both)["verified"] is True
assert _status(url_b, both)["verified"] is True
finally:
httpd_a.shutdown()
httpd_a.server_close()
httpd_b.shutdown()
httpd_b.server_close()
def test_server_rejects_path_traversal(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: def test_server_rejects_path_traversal(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
run_dir = _make_run(tmp_path, "guard", status="completed", end_time="2026-01-01T00:00:00Z") run_dir = _make_run(tmp_path, "guard", status="completed", end_time="2026-01-01T00:00:00Z")
secret = tmp_path / "secret.txt" secret = tmp_path / "secret.txt"