mirror of
https://github.com/usestrix/strix.git
synced 2026-08-25 04:12:37 +02:00
feat(safety): add contextual action review with guarded and observe modes
Introduce a pre-execution safety layer that reviews effectful agent actions against compiled, frozen evidence before they run. `--safety-mode guarded` allows non-destructive interaction after review; `--safety-mode observe` permits passive target interaction only. `off` stays the default, so existing runs are unchanged. Deterministic rules decide what they can on their own: destructive commands, code-loading environment overrides, blocked browser actions, and mutating requests in observe mode are refused without a model call, and a small set of read-only commands is allowed outright. Everything else compiles an evidence packet — command, scope, script source and its local import closure, prior tool-call evidence, and browser snapshot context — for a bounded reviewer that may make one isolated inspection call. Incomplete evidence fails closed. In safety modes, user-owned local directories are copied into the run directory so the originals are never mounted writable, while `.git`, `.agents`, and `.codex` inside the copy stay read-only. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -4,13 +4,19 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
from types import SimpleNamespace
|
||||
from typing import Any, cast
|
||||
from typing import TYPE_CHECKING, Any, cast
|
||||
|
||||
import pytest
|
||||
from agents.tool import CustomTool, FunctionTool
|
||||
|
||||
from strix.agents import factory
|
||||
from strix.config import load_settings
|
||||
from strix.config.settings import SafetySettings
|
||||
from strix.safety.runtime import SafetyRuntime
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def _capturing_exec_tool(captured: dict[str, str]) -> FunctionTool:
|
||||
@@ -115,3 +121,68 @@ def test_function_tools_are_result_bounded() -> None:
|
||||
by_name = {t.name: t for t in agent.tools}
|
||||
|
||||
assert getattr(by_name["think"], "_strix_bounded", False) is True
|
||||
|
||||
|
||||
def _capturing_stdin_tool(captured: dict[str, str]) -> FunctionTool:
|
||||
async def invoke(_ctx: Any, raw_input: str) -> str:
|
||||
captured["raw_input"] = raw_input
|
||||
return "typed"
|
||||
|
||||
return FunctionTool(
|
||||
name="write_stdin",
|
||||
description="test tool",
|
||||
params_json_schema={"type": "object", "properties": {}},
|
||||
on_invoke_tool=invoke,
|
||||
)
|
||||
|
||||
|
||||
class _InspectionRunner:
|
||||
async def run(self, *, evidence_dir: str, script: str) -> str:
|
||||
return f"unused: {evidence_dir} {script}"
|
||||
|
||||
|
||||
def _guarded_runtime(tmp_path: Path) -> SafetyRuntime:
|
||||
return SafetyRuntime(
|
||||
scan_id="scan-1",
|
||||
mode="guarded",
|
||||
scope={},
|
||||
user_instruction="",
|
||||
settings=SafetySettings(),
|
||||
run_dir=tmp_path,
|
||||
sandbox_image="image",
|
||||
inspection_runner=_InspectionRunner(),
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_write_stdin_is_routed_through_the_safety_runtime(tmp_path: Path) -> None:
|
||||
captured: dict[str, str] = {}
|
||||
wrapped = factory._wrap_write_stdin(_capturing_stdin_tool(captured))
|
||||
ctx = SimpleNamespace(
|
||||
context={"safety_runtime": _guarded_runtime(tmp_path), "agent_id": "agent-1"},
|
||||
tool_call_id="call-1",
|
||||
)
|
||||
|
||||
result = await wrapped.on_invoke_tool(
|
||||
cast("Any", ctx),
|
||||
json.dumps({"session_id": "s", "chars": "rm -rf /workspace\\n"}),
|
||||
)
|
||||
|
||||
payload = json.loads(result)
|
||||
assert payload["status"] == "blocked"
|
||||
assert "write_stdin is blocked" in payload["safety"]["reason"]
|
||||
assert captured == {}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_write_stdin_runs_directly_without_a_safety_runtime() -> None:
|
||||
captured: dict[str, str] = {}
|
||||
wrapped = factory._wrap_write_stdin(_capturing_stdin_tool(captured))
|
||||
ctx = SimpleNamespace(context={}, tool_call_id="call-1")
|
||||
|
||||
result = await wrapped.on_invoke_tool(
|
||||
cast("Any", ctx), json.dumps({"session_id": "s", "chars": "y\\n"})
|
||||
)
|
||||
|
||||
assert result == "typed"
|
||||
assert json.loads(captured["raw_input"])["chars"] == "y\n"
|
||||
|
||||
@@ -33,6 +33,10 @@ _LLM_ENV_KEYS = [
|
||||
# RuntimeSettings
|
||||
"STRIX_IMAGE",
|
||||
"STRIX_RUNTIME_BACKEND",
|
||||
# SafetySettings
|
||||
"STRIX_SAFETY_MODE",
|
||||
"STRIX_SAFETY_MODEL",
|
||||
"STRIX_SAFETY_TIMEOUT",
|
||||
# TelemetrySettings
|
||||
"STRIX_TELEMETRY",
|
||||
]
|
||||
@@ -177,6 +181,29 @@ def test_apply_override_and_load_settings_round_trip(tmp_path: Path) -> None:
|
||||
assert loader.load_settings() is settings
|
||||
|
||||
|
||||
def test_safety_settings_load_from_config(tmp_path: Path) -> None:
|
||||
path = tmp_path / "cli-config.json"
|
||||
path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"env": {
|
||||
"STRIX_SAFETY_MODE": "guarded",
|
||||
"STRIX_SAFETY_MODEL": "openai/safety-model",
|
||||
"STRIX_SAFETY_TIMEOUT": "12",
|
||||
}
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
loader.apply_config_override(path)
|
||||
settings = loader.load_settings().safety
|
||||
|
||||
assert settings.mode == "guarded"
|
||||
assert settings.model == "openai/safety-model"
|
||||
assert settings.timeout == 12
|
||||
|
||||
|
||||
def test_apply_config_override_invalidates_cache(tmp_path: Path) -> None:
|
||||
first = tmp_path / "first.json"
|
||||
first.write_text(json.dumps({"env": {"STRIX_LLM": "first-model"}}), encoding="utf-8")
|
||||
|
||||
@@ -0,0 +1,539 @@
|
||||
"""Deterministic safety evidence compilation."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
from types import SimpleNamespace
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import pytest
|
||||
|
||||
from strix.config.settings import SafetySettings
|
||||
from strix.safety.evidence import compile_evidence, parse_command
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
# Marks a path that exists but cannot be read, which must not look like an absent module.
|
||||
_UNREADABLE = "<unreadable>"
|
||||
|
||||
|
||||
class _Sandbox:
|
||||
def __init__(self, files: dict[str, str]) -> None:
|
||||
self.files = files
|
||||
|
||||
async def read(self, path: Path) -> io.BytesIO:
|
||||
key = path.as_posix()
|
||||
if key not in self.files:
|
||||
raise FileNotFoundError(key)
|
||||
if self.files[key] == _UNREADABLE:
|
||||
raise PermissionError(key)
|
||||
return io.BytesIO(self.files[key].encode())
|
||||
|
||||
|
||||
def _ctx(files: dict[str, str], *, turn_input: list[Any] | None = None) -> Any:
|
||||
return SimpleNamespace(
|
||||
context={"agent_id": "agent-1", "sandbox_session": _Sandbox(files)},
|
||||
tool_call_id="call-1",
|
||||
turn_input=turn_input or [],
|
||||
)
|
||||
|
||||
|
||||
async def _compile(
|
||||
command: str,
|
||||
files: dict[str, str] | None = None,
|
||||
*,
|
||||
turn_input: list[Any] | None = None,
|
||||
workdir: str | None = None,
|
||||
mode: str = "guarded",
|
||||
) -> Any:
|
||||
arguments: dict[str, Any] = {"cmd": command}
|
||||
if workdir is not None:
|
||||
arguments["workdir"] = workdir
|
||||
return await compile_evidence(
|
||||
case_id="case",
|
||||
ctx=_ctx(files or {}, turn_input=turn_input),
|
||||
arguments=arguments,
|
||||
mode=mode,
|
||||
scope={},
|
||||
user_instruction="",
|
||||
settings=SafetySettings(),
|
||||
)
|
||||
|
||||
|
||||
def test_parse_command_identifies_direct_browser_action() -> None:
|
||||
plan = parse_command("agent-browser click @e3")
|
||||
|
||||
assert plan.browser is True
|
||||
assert plan.browser_action == "click"
|
||||
assert plan.compound is False
|
||||
|
||||
|
||||
def test_parse_command_marks_browser_chaining_compound() -> None:
|
||||
plan = parse_command("agent-browser click @e3 && agent-browser snapshot -i")
|
||||
|
||||
assert plan.browser is True
|
||||
assert plan.compound is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_python_script_collects_local_dependency_source() -> None:
|
||||
bundle = await compile_evidence(
|
||||
case_id="case-1",
|
||||
ctx=_ctx(
|
||||
{
|
||||
"/workspace/check.py": "from helper import target\nprint(target)\n",
|
||||
"/workspace/helper.py": 'target = "https://example.test/health"\n',
|
||||
}
|
||||
),
|
||||
arguments={"cmd": "python /workspace/check.py"},
|
||||
mode="guarded",
|
||||
scope={"authorized_targets": [{"value": "https://example.test"}]},
|
||||
user_instruction="Inspect the test target.",
|
||||
settings=SafetySettings(),
|
||||
)
|
||||
try:
|
||||
paths = {item["path"] for item in bundle.packet["artifacts"]}
|
||||
assert paths == {"/workspace/check.py", "/workspace/helper.py"}
|
||||
assert bundle.complete is True
|
||||
assert bundle.deterministic_block is None
|
||||
finally:
|
||||
bundle.cleanup()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_browser_automation_inside_script_is_blocked() -> None:
|
||||
bundle = await compile_evidence(
|
||||
case_id="case-2",
|
||||
ctx=_ctx(
|
||||
{
|
||||
"/workspace/browser.py": (
|
||||
'import subprocess\nsubprocess.run(["agent-browser", "click", "@e3"])\n'
|
||||
)
|
||||
}
|
||||
),
|
||||
arguments={"cmd": "python /workspace/browser.py"},
|
||||
mode="guarded",
|
||||
scope={},
|
||||
user_instruction="",
|
||||
settings=SafetySettings(),
|
||||
)
|
||||
try:
|
||||
assert bundle.deterministic_block is not None
|
||||
assert "direct agent-browser commands" in bundle.deterministic_block
|
||||
finally:
|
||||
bundle.cleanup()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_browser_library_import_inside_script_is_blocked() -> None:
|
||||
bundle = await compile_evidence(
|
||||
case_id="case-browser-import",
|
||||
ctx=_ctx({"/workspace/browser.py": "from playwright.async_api import Browser\n"}),
|
||||
arguments={"cmd": "python /workspace/browser.py"},
|
||||
mode="guarded",
|
||||
scope={},
|
||||
user_instruction="",
|
||||
settings=SafetySettings(),
|
||||
)
|
||||
try:
|
||||
assert bundle.deterministic_block is not None
|
||||
assert "direct agent-browser commands" in bundle.deterministic_block
|
||||
finally:
|
||||
bundle.cleanup()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dynamic_exec_makes_script_evidence_incomplete() -> None:
|
||||
bundle = await compile_evidence(
|
||||
case_id="case-3",
|
||||
ctx=_ctx({"/workspace/dynamic.py": "exec(input())\n"}),
|
||||
arguments={"cmd": "python /workspace/dynamic.py"},
|
||||
mode="guarded",
|
||||
scope={},
|
||||
user_instruction="",
|
||||
settings=SafetySettings(),
|
||||
)
|
||||
try:
|
||||
assert bundle.complete is False
|
||||
assert any("exec" in reason for reason in bundle.incomplete_reasons)
|
||||
finally:
|
||||
bundle.cleanup()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dynamic_network_destination_is_incomplete() -> None:
|
||||
bundle = await compile_evidence(
|
||||
case_id="case-dynamic-network",
|
||||
ctx=_ctx(
|
||||
{"/workspace/network.py": ("import requests\nimport sys\nrequests.get(sys.argv[1])\n")}
|
||||
),
|
||||
arguments={"cmd": "python /workspace/network.py https://example.test"},
|
||||
mode="guarded",
|
||||
scope={},
|
||||
user_instruction="",
|
||||
settings=SafetySettings(),
|
||||
)
|
||||
try:
|
||||
assert bundle.complete is False
|
||||
assert any("dynamic network destination" in item for item in bundle.incomplete_reasons)
|
||||
finally:
|
||||
bundle.cleanup()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_creation_and_execution_chain_must_be_split() -> None:
|
||||
bundle = await compile_evidence(
|
||||
case_id="case-chain",
|
||||
ctx=_ctx({}),
|
||||
arguments={"cmd": "curl https://example.test/x.py -o x.py && python x.py"},
|
||||
mode="guarded",
|
||||
scope={},
|
||||
user_instruction="",
|
||||
settings=SafetySettings(),
|
||||
)
|
||||
try:
|
||||
assert bundle.deterministic_block is not None
|
||||
assert "split" in bundle.deterministic_block
|
||||
finally:
|
||||
bundle.cleanup()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_browser_ref_requires_prior_snapshot() -> None:
|
||||
bundle = await compile_evidence(
|
||||
case_id="case-4",
|
||||
ctx=_ctx({}),
|
||||
arguments={"cmd": "agent-browser click @e3"},
|
||||
mode="guarded",
|
||||
scope={},
|
||||
user_instruction="",
|
||||
settings=SafetySettings(),
|
||||
)
|
||||
try:
|
||||
assert bundle.complete is False
|
||||
assert "prior snapshot" in bundle.incomplete_reasons[0]
|
||||
finally:
|
||||
bundle.cleanup()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_browser_ref_uses_prior_snapshot_output() -> None:
|
||||
history = [
|
||||
{
|
||||
"type": "function_call",
|
||||
"name": "exec_command",
|
||||
"call_id": "snapshot-1",
|
||||
"arguments": '{"cmd":"agent-browser snapshot -i"}',
|
||||
},
|
||||
{
|
||||
"type": "function_call_output",
|
||||
"call_id": "snapshot-1",
|
||||
"output": '@e3 [button type="submit"] "Search"',
|
||||
},
|
||||
]
|
||||
bundle = await compile_evidence(
|
||||
case_id="case-5",
|
||||
ctx=_ctx({}, turn_input=history),
|
||||
arguments={"cmd": "agent-browser click @e3"},
|
||||
mode="guarded",
|
||||
scope={},
|
||||
user_instruction="",
|
||||
settings=SafetySettings(),
|
||||
)
|
||||
try:
|
||||
assert bundle.complete is True
|
||||
assert bundle.packet["browser"]["latest_snapshot"]["call_id"] == "snapshot-1"
|
||||
finally:
|
||||
bundle.cleanup()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"command",
|
||||
[
|
||||
"ls -la\nrm -rf /workspace/app",
|
||||
"ls & rm -rf /workspace/app",
|
||||
"ls -la; rm -rf /workspace/app",
|
||||
],
|
||||
)
|
||||
def test_separators_beyond_double_operators_are_compound(command: str) -> None:
|
||||
plan = parse_command(command)
|
||||
|
||||
assert plan.compound is True
|
||||
assert plan.read_only is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
"command",
|
||||
["ls -la\nrm -rf /workspace/app", "ls & rm -rf /workspace/app"],
|
||||
)
|
||||
async def test_destructive_command_chained_to_a_read_command_is_blocked(command: str) -> None:
|
||||
bundle = await _compile(command)
|
||||
try:
|
||||
assert bundle.deterministic_allow is None
|
||||
assert bundle.deterministic_block is not None
|
||||
assert "destructive" in bundle.deterministic_block
|
||||
finally:
|
||||
bundle.cleanup()
|
||||
|
||||
|
||||
def test_quoted_separator_is_not_compound() -> None:
|
||||
assert parse_command("curl 'https://example.test/?a=1&b=2'").compound is False
|
||||
assert parse_command('agent-browser open "https://example.test/?a=1&b=2"').compound is False
|
||||
|
||||
|
||||
def test_read_only_fast_path_inspects_options() -> None:
|
||||
assert parse_command("rg -n --json needle /workspace").read_only is True
|
||||
assert parse_command("ls -la /workspace").read_only is True
|
||||
# `--pre` hands ripgrep an arbitrary program to run on every matched file.
|
||||
assert parse_command("rg --pre /workspace/payload.sh -e . /workspace").read_only is False
|
||||
assert parse_command("rg --search-zip needle /workspace").read_only is False
|
||||
assert parse_command("file -C -m /workspace/magic /workspace/x").read_only is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_inline_python_source_collects_local_dependencies() -> None:
|
||||
bundle = await _compile(
|
||||
'python -c "import wipe; wipe.go()"',
|
||||
{"/workspace/wipe.py": "import shutil\n\n\ndef go():\n shutil.rmtree('/workspace')\n"},
|
||||
workdir="/workspace",
|
||||
)
|
||||
try:
|
||||
artifacts = bundle.packet["artifacts"]
|
||||
assert [item["path"] for item in artifacts] == ["<inline>", "/workspace/wipe.py"]
|
||||
dependency = bundle.root / artifacts[1]["evidence_path"]
|
||||
assert "shutil.rmtree" in dependency.read_text(encoding="utf-8")
|
||||
finally:
|
||||
bundle.cleanup()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_inline_python_dynamic_feature_is_incomplete() -> None:
|
||||
bundle = await _compile('python -c "exec(input())"', workdir="/workspace")
|
||||
try:
|
||||
assert bundle.complete is False
|
||||
assert any("exec" in reason for reason in bundle.incomplete_reasons)
|
||||
finally:
|
||||
bundle.cleanup()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_relative_imports_are_collected() -> None:
|
||||
bundle = await _compile(
|
||||
"python /workspace/main.py",
|
||||
{
|
||||
"/workspace/main.py": "import pkg.mod\n",
|
||||
"/workspace/pkg/__init__.py": "",
|
||||
"/workspace/pkg/mod.py": "from . import payload\nfrom ..sibling import helper\n",
|
||||
"/workspace/pkg/payload.py": "import shutil\nshutil.rmtree('/workspace/app')\n",
|
||||
"/workspace/sibling.py": "helper = 1\n",
|
||||
},
|
||||
)
|
||||
try:
|
||||
paths = {item["path"] for item in bundle.packet["artifacts"]}
|
||||
assert "/workspace/pkg/payload.py" in paths
|
||||
assert "/workspace/sibling.py" in paths
|
||||
finally:
|
||||
bundle.cleanup()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_import_path_mutation_makes_evidence_incomplete() -> None:
|
||||
bundle = await _compile(
|
||||
"python /workspace/run.py",
|
||||
{
|
||||
"/workspace/run.py": (
|
||||
"import sys\nsys.path.insert(0, '/workspace/lib')\nimport payload\npayload.main()\n"
|
||||
)
|
||||
},
|
||||
)
|
||||
try:
|
||||
assert bundle.complete is False
|
||||
assert any("search path" in reason for reason in bundle.incomplete_reasons)
|
||||
finally:
|
||||
bundle.cleanup()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unreadable_local_module_is_reported() -> None:
|
||||
bundle = await _compile(
|
||||
"python /workspace/run.py",
|
||||
{"/workspace/run.py": "import payload\n", "/workspace/payload.py": _UNREADABLE},
|
||||
)
|
||||
try:
|
||||
assert bundle.complete is False
|
||||
assert any("cannot read local module" in reason for reason in bundle.incomplete_reasons)
|
||||
finally:
|
||||
bundle.cleanup()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_interpreter_environment_override_is_blocked() -> None:
|
||||
bundle = await _compile("PYTHONPATH=/workspace/lib python /workspace/run.py")
|
||||
try:
|
||||
assert bundle.deterministic_block is not None
|
||||
assert "PYTHONPATH" in bundle.deterministic_block
|
||||
finally:
|
||||
bundle.cleanup()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_parent_traversal_leaves_the_workspace() -> None:
|
||||
bundle = await _compile("python ../../opt/staged/run.py", workdir="/workspace")
|
||||
try:
|
||||
assert bundle.complete is False
|
||||
assert "outside the inspectable workspace" in bundle.incomplete_reasons[0]
|
||||
finally:
|
||||
bundle.cleanup()
|
||||
|
||||
|
||||
def test_env_wrapper_resolves_the_real_executable() -> None:
|
||||
plan = parse_command("/usr/bin/env agent-browser click @e5")
|
||||
|
||||
assert plan.browser is True
|
||||
assert plan.browser_action == "click"
|
||||
|
||||
|
||||
def test_opaque_wrapper_fails_closed() -> None:
|
||||
assert parse_command("timeout 5 rm -rf /workspace").parse_error is not None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_browser_session_env_override_is_blocked() -> None:
|
||||
bundle = await _compile("AGENT_BROWSER_SESSION=shared agent-browser click @e3")
|
||||
try:
|
||||
assert bundle.deterministic_block is not None
|
||||
assert "AGENT_BROWSER_SESSION" in bundle.deterministic_block
|
||||
finally:
|
||||
bundle.cleanup()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unknown_browser_option_cannot_mask_the_action() -> None:
|
||||
bundle = await _compile("agent-browser --timeout 5000 eval \"fetch('/x')\"")
|
||||
try:
|
||||
assert bundle.packet["pending_action"]["browser_action"] == "eval"
|
||||
assert bundle.deterministic_block is not None
|
||||
assert "eval" in bundle.deterministic_block
|
||||
finally:
|
||||
bundle.cleanup()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unparseable_browser_option_fails_closed() -> None:
|
||||
bundle = await _compile("agent-browser --unknown-flag value click @e3")
|
||||
try:
|
||||
assert bundle.complete is False
|
||||
assert any("unrecognized" in reason for reason in bundle.incomplete_reasons)
|
||||
finally:
|
||||
bundle.cleanup()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_attached_browser_session_override_is_blocked() -> None:
|
||||
bundle = await _compile("agent-browser --session=evil click @e3")
|
||||
try:
|
||||
assert bundle.deterministic_block is not None
|
||||
assert "overrides are blocked" in bundle.deterministic_block
|
||||
finally:
|
||||
bundle.cleanup()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_snapshot_taken_before_a_navigation_is_stale() -> None:
|
||||
history = [
|
||||
{
|
||||
"type": "function_call",
|
||||
"name": "exec_command",
|
||||
"call_id": "snapshot-1",
|
||||
"arguments": '{"cmd":"agent-browser snapshot -i"}',
|
||||
},
|
||||
{
|
||||
"type": "function_call_output",
|
||||
"call_id": "snapshot-1",
|
||||
"output": '@e3 [button] "Search"',
|
||||
},
|
||||
{
|
||||
"type": "function_call",
|
||||
"name": "exec_command",
|
||||
"call_id": "navigate-1",
|
||||
"arguments": '{"cmd":"agent-browser navigate https://example.test/admin"}',
|
||||
},
|
||||
{"type": "function_call_output", "call_id": "navigate-1", "output": "ok"},
|
||||
]
|
||||
bundle = await _compile("agent-browser click @e3", turn_input=history)
|
||||
try:
|
||||
assert bundle.complete is False
|
||||
assert any("predates" in reason for reason in bundle.incomplete_reasons)
|
||||
finally:
|
||||
bundle.cleanup()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reading_the_page_does_not_stale_a_snapshot() -> None:
|
||||
history = [
|
||||
{
|
||||
"type": "function_call",
|
||||
"name": "exec_command",
|
||||
"call_id": "snapshot-1",
|
||||
"arguments": '{"cmd":"agent-browser snapshot -i"}',
|
||||
},
|
||||
{
|
||||
"type": "function_call_output",
|
||||
"call_id": "snapshot-1",
|
||||
"output": '@e3 [button] "Search"',
|
||||
},
|
||||
{
|
||||
"type": "function_call",
|
||||
"name": "exec_command",
|
||||
"call_id": "get-1",
|
||||
"arguments": '{"cmd":"agent-browser get text @e3"}',
|
||||
},
|
||||
{"type": "function_call_output", "call_id": "get-1", "output": "Search"},
|
||||
]
|
||||
bundle = await _compile("agent-browser click @e3", turn_input=history)
|
||||
try:
|
||||
assert bundle.complete is True
|
||||
assert bundle.packet["browser"]["latest_snapshot"]["stale"] is False
|
||||
finally:
|
||||
bundle.cleanup()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dependency_closure_may_exceed_one_file_limit() -> None:
|
||||
settings = SafetySettings()
|
||||
filler = "#" * (settings.max_artifact_bytes - 64)
|
||||
bundle = await _compile(
|
||||
"python /workspace/run.py",
|
||||
{
|
||||
"/workspace/run.py": f"import first\nimport second\n{filler}",
|
||||
"/workspace/first.py": filler,
|
||||
"/workspace/second.py": filler,
|
||||
},
|
||||
)
|
||||
try:
|
||||
assert bundle.complete is True
|
||||
assert len(bundle.packet["artifacts"]) == 3
|
||||
finally:
|
||||
bundle.cleanup()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("command", "expected"),
|
||||
[
|
||||
("curl -X DELETE https://example.test/users/1", "DELETE"),
|
||||
("curl --request PUT https://example.test/users/1", "PUT"),
|
||||
("curl -d payload https://example.test/users", "-d"),
|
||||
("wget --post-data=x https://example.test/users", "--post-data"),
|
||||
],
|
||||
)
|
||||
def test_mutating_http_requests_are_recognized(command: str, expected: str) -> None:
|
||||
assert expected in (parse_command(command).mutating_request or "")
|
||||
|
||||
|
||||
def test_passive_http_requests_are_not_flagged() -> None:
|
||||
assert parse_command("curl https://example.test/users").mutating_request is None
|
||||
assert parse_command("curl -X GET https://example.test/users").mutating_request is None
|
||||
@@ -0,0 +1,184 @@
|
||||
"""The safety model may decide immediately or use one inspection call."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import pytest
|
||||
from agents.tool_context import ToolContext
|
||||
|
||||
import strix.safety.reviewer as reviewer_module
|
||||
from strix.config.settings import SafetySettings
|
||||
from strix.safety.evidence import EvidenceBundle
|
||||
from strix.safety.reviewer import SafetyReviewer, run_inspection
|
||||
from strix.safety.types import InspectionContext, SafetyVerdict
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pytest import MonkeyPatch
|
||||
|
||||
|
||||
class _InspectionRunner:
|
||||
def __init__(self) -> None:
|
||||
self.calls = 0
|
||||
|
||||
async def run(self, *, evidence_dir: str, script: str) -> str:
|
||||
self.calls += 1
|
||||
return f"inspected {Path(evidence_dir).name}: {script}"
|
||||
|
||||
|
||||
class _Result:
|
||||
def __init__(self, verdict: SafetyVerdict) -> None:
|
||||
self._verdict = verdict
|
||||
self.context_wrapper = SimpleNamespace(usage=SimpleNamespace())
|
||||
|
||||
def final_output_as(self, _cls: type[Any], *, raise_if_incorrect_type: bool) -> SafetyVerdict:
|
||||
assert raise_if_incorrect_type is True
|
||||
return self._verdict
|
||||
|
||||
|
||||
def _settings() -> Any:
|
||||
return SimpleNamespace(
|
||||
safety=SafetySettings(model="test-model"),
|
||||
llm=SimpleNamespace(
|
||||
model="main-model",
|
||||
extra_headers=None,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reviewer_is_capped_at_two_turns_and_zero_retries(
|
||||
tmp_path: Path,
|
||||
monkeypatch: MonkeyPatch,
|
||||
) -> None:
|
||||
captured: dict[str, Any] = {}
|
||||
|
||||
async def fake_run(agent: Any, *, input: str, context: Any, max_turns: int) -> _Result: # noqa: A002
|
||||
captured.update(agent=agent, input=input, context=context, max_turns=max_turns)
|
||||
return _Result(
|
||||
SafetyVerdict(
|
||||
decision="allow",
|
||||
risk="low",
|
||||
categories=[],
|
||||
reason="read only",
|
||||
confidence=0.99,
|
||||
)
|
||||
)
|
||||
|
||||
monkeypatch.setattr(reviewer_module, "load_settings", _settings)
|
||||
monkeypatch.setattr(reviewer_module, "configure_sdk_model_defaults", lambda _settings: None)
|
||||
monkeypatch.setattr(
|
||||
reviewer_module.StrixProvider, "get_model", lambda _self, _name: "test-model"
|
||||
)
|
||||
monkeypatch.setattr(reviewer_module.Runner, "run", fake_run)
|
||||
monkeypatch.setattr(reviewer_module, "get_global_report_state", lambda: None)
|
||||
bundle = EvidenceBundle(
|
||||
case_id="case-1",
|
||||
root=tmp_path,
|
||||
packet={"completeness": {"status": "complete"}},
|
||||
complete=True,
|
||||
incomplete_reasons=[],
|
||||
)
|
||||
|
||||
decision = await SafetyReviewer(inspection_runner=_InspectionRunner()).review(bundle)
|
||||
|
||||
assert decision.allowed is True
|
||||
assert captured["max_turns"] == 2
|
||||
assert [tool.name for tool in captured["agent"].tools] == ["run_inspection"]
|
||||
assert captured["agent"].model_settings.retry.max_retries == 0
|
||||
# The cap also covers reasoning tokens; a verdict-sized budget would truncate the
|
||||
# structured output on a reasoning model and fail every review closed.
|
||||
assert captured["agent"].model_settings.max_tokens == SafetySettings().max_output_tokens
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_review_budget_covers_both_turns_and_the_inspection(
|
||||
tmp_path: Path,
|
||||
monkeypatch: MonkeyPatch,
|
||||
) -> None:
|
||||
captured: dict[str, Any] = {}
|
||||
|
||||
async def fake_wait_for(awaitable: Any, *, timeout: float) -> Any:
|
||||
captured["timeout"] = timeout
|
||||
return await awaitable
|
||||
|
||||
async def fake_run(_agent: Any, **_kwargs: Any) -> _Result:
|
||||
return _Result(
|
||||
SafetyVerdict(
|
||||
decision="allow",
|
||||
risk="low",
|
||||
categories=[],
|
||||
reason="read only",
|
||||
confidence=0.99,
|
||||
)
|
||||
)
|
||||
|
||||
monkeypatch.setattr(reviewer_module, "load_settings", _settings)
|
||||
monkeypatch.setattr(reviewer_module, "configure_sdk_model_defaults", lambda _settings: None)
|
||||
monkeypatch.setattr(
|
||||
reviewer_module.StrixProvider, "get_model", lambda _self, _name: "test-model"
|
||||
)
|
||||
monkeypatch.setattr(reviewer_module.Runner, "run", fake_run)
|
||||
monkeypatch.setattr(reviewer_module, "get_global_report_state", lambda: None)
|
||||
monkeypatch.setattr(reviewer_module.asyncio, "wait_for", fake_wait_for)
|
||||
bundle = EvidenceBundle(
|
||||
case_id="case-budget",
|
||||
root=tmp_path,
|
||||
packet={"completeness": {"status": "complete"}},
|
||||
complete=True,
|
||||
incomplete_reasons=[],
|
||||
)
|
||||
|
||||
await SafetyReviewer(inspection_runner=_InspectionRunner()).review(bundle)
|
||||
|
||||
safety = SafetySettings()
|
||||
assert captured["timeout"] == 2 * safety.timeout + safety.inspection_timeout
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_inspection_tool_can_only_run_once(tmp_path: Path) -> None:
|
||||
runner = _InspectionRunner()
|
||||
state = InspectionContext(evidence_dir=str(tmp_path), runner=runner)
|
||||
ctx = ToolContext(
|
||||
context=state,
|
||||
tool_name="run_inspection",
|
||||
tool_call_id="inspect-1",
|
||||
tool_arguments="{}",
|
||||
)
|
||||
raw = json.dumps({"reason": "correlate files", "script": "print('ok')"})
|
||||
|
||||
first = await run_inspection.on_invoke_tool(ctx, raw)
|
||||
second = await run_inspection.on_invoke_tool(ctx, raw)
|
||||
|
||||
assert "inspected" in first
|
||||
assert "already used" in second
|
||||
assert runner.calls == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reviewer_failure_blocks(tmp_path: Path, monkeypatch: MonkeyPatch) -> None:
|
||||
async def fail(*_args: Any, **_kwargs: Any) -> Any:
|
||||
raise RuntimeError("provider down")
|
||||
|
||||
monkeypatch.setattr(reviewer_module, "load_settings", _settings)
|
||||
monkeypatch.setattr(reviewer_module, "configure_sdk_model_defaults", lambda _settings: None)
|
||||
monkeypatch.setattr(
|
||||
reviewer_module.StrixProvider, "get_model", lambda _self, _name: "test-model"
|
||||
)
|
||||
monkeypatch.setattr(reviewer_module.Runner, "run", fail)
|
||||
bundle = EvidenceBundle(
|
||||
case_id="case-2",
|
||||
root=tmp_path,
|
||||
packet={"completeness": {"status": "complete"}},
|
||||
complete=True,
|
||||
incomplete_reasons=[],
|
||||
)
|
||||
|
||||
decision = await SafetyReviewer(inspection_runner=_InspectionRunner()).review(bundle)
|
||||
|
||||
assert decision.allowed is False
|
||||
assert decision.source == "review_error"
|
||||
@@ -0,0 +1,303 @@
|
||||
"""Run-scoped safety enforcement."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import io
|
||||
import json
|
||||
from types import SimpleNamespace
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import pytest
|
||||
|
||||
from strix.config.settings import SafetySettings
|
||||
from strix.safety.runtime import SafetyRuntime
|
||||
from strix.safety.types import SafetyDecision
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
class _InspectionRunner:
|
||||
async def run(self, *, evidence_dir: str, script: str) -> str:
|
||||
return f"unused: {evidence_dir} {script}"
|
||||
|
||||
|
||||
def _runtime(tmp_path: Path, mode: str) -> SafetyRuntime:
|
||||
return SafetyRuntime(
|
||||
scan_id="scan-1",
|
||||
mode=mode, # type: ignore[arg-type]
|
||||
scope={},
|
||||
user_instruction="",
|
||||
settings=SafetySettings(),
|
||||
run_dir=tmp_path,
|
||||
sandbox_image="image",
|
||||
inspection_runner=_InspectionRunner(),
|
||||
)
|
||||
|
||||
|
||||
def _ctx() -> Any:
|
||||
return SimpleNamespace(
|
||||
context={"agent_id": "agent-1", "sandbox_session": object()},
|
||||
tool_call_id="call-1",
|
||||
turn_input=[],
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_known_read_command_executes_without_model_review(tmp_path: Path) -> None:
|
||||
seen: list[dict[str, Any]] = []
|
||||
|
||||
async def invoke(_ctx: Any, raw_input: str) -> str:
|
||||
seen.append(json.loads(raw_input))
|
||||
return "ok"
|
||||
|
||||
result = await _runtime(tmp_path, "guarded").invoke_exec(
|
||||
ctx=_ctx(),
|
||||
arguments={"cmd": "ls /workspace"},
|
||||
invoke_tool=invoke,
|
||||
)
|
||||
|
||||
assert result == "ok"
|
||||
assert seen == [{"cmd": "ls /workspace"}]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_direct_browser_command_gets_per_agent_session(tmp_path: Path) -> None:
|
||||
seen: list[str] = []
|
||||
|
||||
async def invoke(_ctx: Any, raw_input: str) -> str:
|
||||
seen.append(json.loads(raw_input)["cmd"])
|
||||
return "snapshot"
|
||||
|
||||
result = await _runtime(tmp_path, "guarded").invoke_exec(
|
||||
ctx=_ctx(),
|
||||
arguments={"cmd": "agent-browser snapshot -i"},
|
||||
invoke_tool=invoke,
|
||||
)
|
||||
|
||||
assert result == "snapshot"
|
||||
assert seen == ["AGENT_BROWSER_SESSION=strix-scan-1-agent-1 agent-browser snapshot -i"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_observe_mode_blocks_browser_click(tmp_path: Path) -> None:
|
||||
invoked = False
|
||||
|
||||
async def invoke(_ctx: Any, _raw_input: str) -> str:
|
||||
nonlocal invoked
|
||||
invoked = True
|
||||
return "bad"
|
||||
|
||||
result = await _runtime(tmp_path, "observe").invoke_exec(
|
||||
ctx=_ctx(),
|
||||
arguments={"cmd": "agent-browser click @e3"},
|
||||
invoke_tool=invoke,
|
||||
)
|
||||
|
||||
payload = json.loads(result)
|
||||
assert payload["status"] == "blocked"
|
||||
assert invoked is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_guarded_repeat_request_fails_closed(tmp_path: Path) -> None:
|
||||
async def invoke(_ctx: Any, _raw_input: str) -> str:
|
||||
return "bad"
|
||||
|
||||
result = await _runtime(tmp_path, "guarded").invoke_mutating_tool(
|
||||
ctx=_ctx(),
|
||||
tool_name="repeat_request",
|
||||
raw_input="{}",
|
||||
invoke_tool=invoke,
|
||||
)
|
||||
|
||||
payload = json.loads(result)
|
||||
assert payload["status"] == "blocked"
|
||||
assert "effective method" in payload["safety"]["reason"]
|
||||
|
||||
|
||||
class _Sandbox:
|
||||
async def read(self, path: Path) -> io.BytesIO:
|
||||
if path.as_posix() == "/workspace/app.py":
|
||||
return io.BytesIO(b"print(1)\n")
|
||||
raise FileNotFoundError(path)
|
||||
|
||||
|
||||
def _script_ctx() -> Any:
|
||||
return SimpleNamespace(
|
||||
context={"agent_id": "agent-1", "sandbox_session": _Sandbox()},
|
||||
tool_call_id="call-1",
|
||||
turn_input=[],
|
||||
)
|
||||
|
||||
|
||||
class _StubReviewer:
|
||||
def __init__(self, on_review: Any = None) -> None:
|
||||
self.on_review = on_review
|
||||
self.calls = 0
|
||||
|
||||
async def review(self, bundle: Any) -> SafetyDecision:
|
||||
self.calls += 1
|
||||
if self.on_review is not None:
|
||||
await self.on_review()
|
||||
return SafetyDecision(
|
||||
allowed=True,
|
||||
source="reviewer",
|
||||
reason="allowed",
|
||||
case_id=bundle.case_id,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_write_stdin_is_blocked_in_guarded_mode(tmp_path: Path) -> None:
|
||||
invoked = False
|
||||
|
||||
async def invoke(_ctx: Any, _raw_input: str) -> str:
|
||||
nonlocal invoked
|
||||
invoked = True
|
||||
return "bad"
|
||||
|
||||
result = await _runtime(tmp_path, "guarded").invoke_write_stdin(
|
||||
ctx=_ctx(),
|
||||
arguments={"session_id": "s", "chars": "rm -rf /workspace/app\n"},
|
||||
invoke_tool=invoke,
|
||||
)
|
||||
|
||||
payload = json.loads(result)
|
||||
assert payload["status"] == "blocked"
|
||||
assert "write_stdin is blocked" in payload["safety"]["reason"]
|
||||
assert invoked is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_write_stdin_allows_an_interrupt(tmp_path: Path) -> None:
|
||||
async def invoke(_ctx: Any, _raw_input: str) -> str:
|
||||
return "interrupted"
|
||||
|
||||
result = await _runtime(tmp_path, "guarded").invoke_write_stdin(
|
||||
ctx=_ctx(),
|
||||
arguments={"session_id": "s", "chars": "\x03"},
|
||||
invoke_tool=invoke,
|
||||
)
|
||||
|
||||
assert result == "interrupted"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_write_stdin_is_untouched_when_safety_is_off(tmp_path: Path) -> None:
|
||||
async def invoke(_ctx: Any, _raw_input: str) -> str:
|
||||
return "typed"
|
||||
|
||||
result = await _runtime(tmp_path, "off").invoke_write_stdin(
|
||||
ctx=_ctx(),
|
||||
arguments={"session_id": "s", "chars": "anything\n"},
|
||||
invoke_tool=invoke,
|
||||
)
|
||||
|
||||
assert result == "typed"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_observe_mode_blocks_a_mutating_request_without_the_reviewer(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
runtime = _runtime(tmp_path, "observe")
|
||||
reviewer = _StubReviewer()
|
||||
runtime._reviewer = reviewer
|
||||
invoked = False
|
||||
|
||||
async def invoke(_ctx: Any, _raw_input: str) -> str:
|
||||
nonlocal invoked
|
||||
invoked = True
|
||||
return "bad"
|
||||
|
||||
result = await runtime.invoke_exec(
|
||||
ctx=_ctx(),
|
||||
arguments={"cmd": "curl -X DELETE https://example.test/v1/users/1042"},
|
||||
invoke_tool=invoke,
|
||||
)
|
||||
|
||||
payload = json.loads(result)
|
||||
assert payload["status"] == "blocked"
|
||||
assert payload["safety"]["source"] == "deterministic"
|
||||
assert "DELETE" in payload["safety"]["reason"]
|
||||
assert reviewer.calls == 0
|
||||
assert invoked is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_review_does_not_hold_the_workspace_lock(tmp_path: Path) -> None:
|
||||
runtime = _runtime(tmp_path, "guarded")
|
||||
concurrent = 0
|
||||
peak = 0
|
||||
|
||||
async def on_review() -> None:
|
||||
nonlocal concurrent, peak
|
||||
concurrent += 1
|
||||
peak = max(peak, concurrent)
|
||||
await asyncio.sleep(0.05)
|
||||
concurrent -= 1
|
||||
|
||||
runtime._reviewer = _StubReviewer(on_review)
|
||||
|
||||
async def invoke(_ctx: Any, _raw_input: str) -> str:
|
||||
return "ok"
|
||||
|
||||
await asyncio.gather(
|
||||
*[
|
||||
runtime.invoke_exec(
|
||||
ctx=_ctx(),
|
||||
arguments={"cmd": f"nmap -sV host{index}"},
|
||||
invoke_tool=invoke,
|
||||
)
|
||||
for index in range(3)
|
||||
]
|
||||
)
|
||||
|
||||
assert peak == 3
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_workspace_change_during_review_invalidates_the_decision(tmp_path: Path) -> None:
|
||||
runtime = _runtime(tmp_path, "guarded")
|
||||
|
||||
async def on_review() -> None:
|
||||
runtime._workspace_epoch += 1
|
||||
|
||||
runtime._reviewer = _StubReviewer(on_review)
|
||||
invoked = False
|
||||
|
||||
async def invoke(_ctx: Any, _raw_input: str) -> str:
|
||||
nonlocal invoked
|
||||
invoked = True
|
||||
return "bad"
|
||||
|
||||
result = await runtime.invoke_exec(
|
||||
ctx=_script_ctx(),
|
||||
arguments={"cmd": "python /workspace/app.py"},
|
||||
invoke_tool=invoke,
|
||||
)
|
||||
|
||||
payload = json.loads(result)
|
||||
assert payload["status"] == "blocked"
|
||||
assert payload["safety"]["categories"] == ["stale_evidence"]
|
||||
assert invoked is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unchanged_workspace_executes_after_review(tmp_path: Path) -> None:
|
||||
runtime = _runtime(tmp_path, "guarded")
|
||||
runtime._reviewer = _StubReviewer()
|
||||
|
||||
async def invoke(_ctx: Any, _raw_input: str) -> str:
|
||||
return "ran"
|
||||
|
||||
result = await runtime.invoke_exec(
|
||||
ctx=_script_ctx(),
|
||||
arguments={"cmd": "python /workspace/app.py"},
|
||||
invoke_tool=invoke,
|
||||
)
|
||||
|
||||
assert result == "ran"
|
||||
@@ -0,0 +1,79 @@
|
||||
"""Safety-mode local workspace isolation."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from strix.runtime.local_dir_staging import materialize_isolated_sources
|
||||
from strix.runtime.session_manager import build_bind_mounts
|
||||
|
||||
|
||||
def test_isolated_copy_does_not_modify_original(tmp_path: Path) -> None:
|
||||
source = tmp_path / "source"
|
||||
source.mkdir()
|
||||
original = source / "app.py"
|
||||
original.write_text("before\n", encoding="utf-8")
|
||||
run_dir = tmp_path / "runs" / "scan"
|
||||
|
||||
[staged] = materialize_isolated_sources(
|
||||
[
|
||||
{
|
||||
"source_path": str(source),
|
||||
"workspace_subdir": "source",
|
||||
"protect_metadata": True,
|
||||
}
|
||||
],
|
||||
run_dir=run_dir,
|
||||
)
|
||||
staged_file = Path(staged["source_path"]) / "app.py"
|
||||
staged_file.write_text("after\n", encoding="utf-8")
|
||||
|
||||
assert original.read_text(encoding="utf-8") == "before\n"
|
||||
assert staged_file.read_text(encoding="utf-8") == "after\n"
|
||||
assert staged["original_source_path"] == str(source.resolve())
|
||||
assert staged["workspace_mode"] == "isolated_copy"
|
||||
|
||||
|
||||
def test_isolated_copy_keeps_metadata_read_only(tmp_path: Path) -> None:
|
||||
source = tmp_path / "source"
|
||||
(source / ".git").mkdir(parents=True)
|
||||
(source / ".git" / "config").write_text("[core]\n", encoding="utf-8")
|
||||
(source / ".agents").mkdir()
|
||||
(source / ".agents" / "rules.md").write_text("instructions\n", encoding="utf-8")
|
||||
|
||||
[staged] = materialize_isolated_sources(
|
||||
[
|
||||
{
|
||||
"source_path": str(source),
|
||||
"workspace_subdir": "source",
|
||||
"protect_metadata": True,
|
||||
}
|
||||
],
|
||||
run_dir=tmp_path / "runs" / "scan",
|
||||
)
|
||||
|
||||
assert staged["protect_metadata"] is True
|
||||
read_only = {mount["target"] for mount in build_bind_mounts([staged]) if mount.get("read_only")}
|
||||
assert "/workspace/source/.git" in read_only
|
||||
assert "/workspace/source/.agents" in read_only
|
||||
|
||||
|
||||
def test_isolated_copy_drops_out_of_tree_symlink(tmp_path: Path) -> None:
|
||||
source = tmp_path / "source"
|
||||
source.mkdir()
|
||||
secret = tmp_path / "secret.txt"
|
||||
secret.write_text("secret", encoding="utf-8")
|
||||
(source / "escape").symlink_to(secret)
|
||||
|
||||
[staged] = materialize_isolated_sources(
|
||||
[
|
||||
{
|
||||
"source_path": str(source),
|
||||
"workspace_subdir": "source",
|
||||
"protect_metadata": True,
|
||||
}
|
||||
],
|
||||
run_dir=tmp_path / "runs" / "scan",
|
||||
)
|
||||
|
||||
assert not (Path(staged["source_path"]) / "escape").exists()
|
||||
Reference in New Issue
Block a user