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"
|
||||
|
||||
Reference in New Issue
Block a user