mirror of
https://github.com/usestrix/strix.git
synced 2026-08-24 20:02:39 +02:00
fix(safety): close three evidence bypasses and make workspace staging idempotent
Grouped browser verbs were classified by their verb alone. `tab` and `session` sit in the passive set, so `tab new <url>` — documented as navigating — and `session clear` earned a deterministic allow and executed unreviewed in guarded mode and unblocked in observe, while `open <url>`, the same navigation, was reviewed. Passivity is now decided from verb plus subcommand, and the packet carries the result so observe mode stops maintaining a second copy of the rule that could drift more permissive than guarded. The blocked-action list still matches on the bare verb, so `auth login` keeps matching `auth`. Interpreters were a seven-name allowlist, so `python3.12`, `/usr/bin/python3`, `php`, and `deno` set no script path and produced a packet with no artifacts that was still stamped complete — the exact shape the reviewer is told it may allow. Recognize versioned and common interpreters so their sources are actually collected, and fail closed when a command runs code that cannot be resolved to an inspectable script. `from pkg import payload` collected only the package initializer, because an imported name was treated as an attribute and never as a submodule. Effectful code in `pkg/payload.py` executed without appearing in the evidence. Workspace staging runs twice per run and was not idempotent: the second pass read the origin from `source_path`, which the first pass had already rewritten to the copy. With the completion marker absent it cleared the destination and then copied from that same emptied directory, silently handing the agent an empty workspace. The origin is now read back from `original_source_path`. Each fix is covered by a test that fails when the fix is reverted. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -47,6 +47,11 @@ read-only commands is allowed outright, but only when its options are also
|
||||
read-only: `rg --pre` and anything else that hands the command another program
|
||||
to run goes to review instead.
|
||||
|
||||
Browser observation commands are allowed outright only in the form that just
|
||||
reads: `tab` lists tabs, but `tab new <url>` navigates and `tab close` discards
|
||||
page state, so a grouped verb with a subcommand goes to review and is blocked
|
||||
in `observe`.
|
||||
|
||||
Commands that wrap another program (`sudo`, `timeout`, `xargs`, `nohup`, and
|
||||
similar) and interactive `write_stdin` payloads cannot be resolved to a single
|
||||
effective action before dispatch, so they are blocked. Issue the command as its
|
||||
@@ -57,12 +62,17 @@ own `exec_command` call.
|
||||
When a command executes a script, Strix reads the current entrypoint and local
|
||||
Python imports without importing or running them. Inline `python -c` source is
|
||||
analyzed the same way. Absolute imports resolve against the entrypoint's
|
||||
directory and relative imports against the importing module's package, so the
|
||||
whole local closure is inspected. Decisions bind to content hashes. Dynamic
|
||||
code execution, import-path mutation, unresolved generated commands, oversized
|
||||
directory and relative imports against the importing module's package, and an
|
||||
imported name is followed as a submodule as well as an attribute, so the whole
|
||||
local closure is inspected. Decisions bind to content hashes. Dynamic code
|
||||
execution, import-path mutation, unresolved generated commands, oversized
|
||||
dependency closures, entrypoints outside `/workspace`, and unsupported evidence
|
||||
block the action.
|
||||
|
||||
A command that runs code Strix cannot resolve to an inspectable script — an
|
||||
unrecognized interpreter, or an interpreter given no script — is blocked rather
|
||||
than reviewed against an empty evidence packet.
|
||||
|
||||
Browser automation inside scripts is blocked in safety modes. Issue browser
|
||||
operations as individual raw `agent-browser` commands so each action can be
|
||||
reviewed against the current snapshot and element references.
|
||||
|
||||
@@ -75,7 +75,16 @@ def materialize_isolated_sources(
|
||||
if not item.get("protect_metadata"):
|
||||
result.append(item)
|
||||
continue
|
||||
source_path = Path(str(item.get("source_path") or "")).expanduser().resolve()
|
||||
# Staging runs once in `prepare_run` and again in `run_strix_scan`, and `--resume`
|
||||
# rehydrates already-staged entries, so the origin is read back from
|
||||
# `original_source_path` once set. Taking it from `source_path` every time would
|
||||
# make the copy its own origin on the second pass: a re-copy would then read the
|
||||
# destination it had just cleared and leave an empty workspace behind.
|
||||
origin = (
|
||||
Path(str(item.get("original_source_path") or item.get("source_path") or ""))
|
||||
.expanduser()
|
||||
.resolve()
|
||||
)
|
||||
subdir = str(item.get("workspace_subdir") or "workspace")
|
||||
destination = (workspace_root / subdir).resolve()
|
||||
complete_marker = workspace_root / f".{subdir}.complete"
|
||||
@@ -84,19 +93,19 @@ def materialize_isolated_sources(
|
||||
if not destination.exists():
|
||||
try:
|
||||
_copy_tree(
|
||||
source_path,
|
||||
origin,
|
||||
destination,
|
||||
root=source_path,
|
||||
root=origin,
|
||||
excluded=(run_dir.resolve(), destination),
|
||||
seen=frozenset({source_path}),
|
||||
seen=frozenset({origin}),
|
||||
)
|
||||
except Exception:
|
||||
shutil.rmtree(destination, ignore_errors=True)
|
||||
complete_marker.unlink(missing_ok=True)
|
||||
raise
|
||||
complete_marker.write_text(str(source_path), encoding="utf-8")
|
||||
logger.info("materialized isolated workspace %s -> %s", source_path, destination)
|
||||
item["original_source_path"] = str(source_path)
|
||||
complete_marker.write_text(str(origin), encoding="utf-8")
|
||||
logger.info("materialized isolated workspace %s -> %s", origin, destination)
|
||||
item["original_source_path"] = str(origin)
|
||||
item["source_path"] = str(destination)
|
||||
item["workspace_mode"] = "isolated_copy"
|
||||
# `protect_metadata` is deliberately preserved: the copy's `.git`, `.agents`, and
|
||||
|
||||
+119
-22
@@ -28,14 +28,41 @@ _URL_RE = re.compile(r"https?://[^\s'\"<>]+", re.IGNORECASE)
|
||||
_SHELL_OPERATOR_CHARS = frozenset(";&|\n\r<>")
|
||||
_SHELL_SEPARATOR_CHARS = frozenset(";&|\n\r")
|
||||
_SCRIPT_SUFFIXES = (".py", ".sh", ".bash", ".js", ".mjs", ".rb", ".pl")
|
||||
_INTERPRETERS = {
|
||||
"python",
|
||||
"python3",
|
||||
"bash",
|
||||
"sh",
|
||||
"node",
|
||||
"ruby",
|
||||
"perl",
|
||||
_INTERPRETERS = frozenset(
|
||||
{
|
||||
"awk",
|
||||
"bash",
|
||||
"bun",
|
||||
"dash",
|
||||
"deno",
|
||||
"fish",
|
||||
"gawk",
|
||||
"ksh",
|
||||
"lua",
|
||||
"node",
|
||||
"osascript",
|
||||
"perl",
|
||||
"php",
|
||||
"pwsh",
|
||||
"python",
|
||||
"python3",
|
||||
"ruby",
|
||||
"Rscript",
|
||||
"sh",
|
||||
"tclsh",
|
||||
"zsh",
|
||||
}
|
||||
)
|
||||
# Versioned names (`python3.12`, `node20`) are the same interpreters. Matching them here
|
||||
# rather than enumerating versions keeps a new point release from silently becoming an
|
||||
# unrecognized executable whose script is never inspected.
|
||||
_VERSIONED_INTERPRETER_RE = re.compile(
|
||||
r"^(?:python|node|ruby|perl|php|lua|bash|sh|deno|bun|pypy)[\d.]*$"
|
||||
)
|
||||
# Interpreters that take a subcommand before the script path.
|
||||
_INTERPRETER_SUBCOMMANDS: dict[str, frozenset[str]] = {
|
||||
"deno": frozenset({"run"}),
|
||||
"bun": frozenset({"run"}),
|
||||
}
|
||||
# Commands that run another command supplied in their own arguments. Resolving the
|
||||
# effective program through them is not attempted; they fail closed instead.
|
||||
@@ -107,6 +134,10 @@ _BROWSER_MARKERS = (
|
||||
"remote-debugging-port",
|
||||
)
|
||||
_BROWSER_READ_ACTIONS = frozenset({"snapshot", "get", "is", "tab", "session", "cookies", "storage"})
|
||||
# Reading stored credentials is not something to wave through on the verb alone.
|
||||
_BROWSER_PASSIVE_ACTIONS = _BROWSER_READ_ACTIONS - {"cookies", "storage"}
|
||||
# Verbs whose subcommands are not all reads, so the verb alone does not settle passivity.
|
||||
_BROWSER_GROUPED_VERBS = frozenset({"tab", "session"})
|
||||
_BROWSER_BLOCKED_ACTIONS = frozenset(
|
||||
{"eval", "upload", "drag", "auth", "state", "pushstate", "dialog", "network"}
|
||||
)
|
||||
@@ -526,6 +557,7 @@ class CommandPlan:
|
||||
compound: bool = False
|
||||
browser: bool = False
|
||||
browser_action: str | None = None
|
||||
browser_subcommand: str | None = None
|
||||
script_path: str | None = None
|
||||
inline_source: str | None = None
|
||||
inline_python: bool = False
|
||||
@@ -555,6 +587,27 @@ class EvidenceBundle:
|
||||
self._tmp = None
|
||||
|
||||
|
||||
def _is_interpreter(executable: str) -> bool:
|
||||
return executable in _INTERPRETERS or bool(_VERSIONED_INTERPRETER_RE.match(executable))
|
||||
|
||||
|
||||
def _unresolved_execution(executable: str, args: list[str]) -> str | None:
|
||||
"""Report a command that runs code no artifact in the packet would describe.
|
||||
|
||||
Without this, an executable outside the interpreter set produces a packet that is
|
||||
empty but still stamped complete — the exact shape the reviewer is told it may allow.
|
||||
"""
|
||||
if _is_interpreter(executable):
|
||||
return f"{executable} was given no script or inline source that can be inspected"
|
||||
scripts = [arg for arg in args if arg.endswith(_SCRIPT_SUFFIXES)]
|
||||
if scripts:
|
||||
return (
|
||||
f"{executable} is not a recognized interpreter, so the script it is given "
|
||||
f"({scripts[0]}) cannot be resolved for inspection"
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def _is_env_assignment(token: str) -> bool:
|
||||
name, separator, _ = token.partition("=")
|
||||
return bool(separator) and name.isidentifier()
|
||||
@@ -598,6 +651,10 @@ def _skip_env_prefix(plan: CommandPlan, tokens: list[str]) -> int:
|
||||
|
||||
|
||||
def _parse_interpreter(plan: CommandPlan, executable: str, args: list[str]) -> None:
|
||||
# `deno run x.ts` names the script one token later than every other interpreter, so
|
||||
# without this the subcommand itself is read as the entrypoint.
|
||||
if args[:1] and args[0] in _INTERPRETER_SUBCOMMANDS.get(executable, frozenset()):
|
||||
args = args[1:]
|
||||
for position, arg in enumerate(args):
|
||||
if not arg.startswith("-") or arg == "-":
|
||||
plan.script_path = arg
|
||||
@@ -685,13 +742,17 @@ def parse_command(command: str) -> CommandPlan:
|
||||
return plan
|
||||
if executable == "agent-browser":
|
||||
plan.browser = True
|
||||
plan.browser_action, plan.parse_error = _browser_action(args)
|
||||
plan.read_only = plan.parse_error is None and plan.browser_action in _BROWSER_READ_ACTIONS
|
||||
plan.browser_action, plan.browser_subcommand, plan.parse_error = _browser_action(args)
|
||||
plan.read_only = plan.parse_error is None and _browser_is_passive(
|
||||
plan.browser_action, plan.browser_subcommand
|
||||
)
|
||||
return plan
|
||||
if executable in _INTERPRETERS:
|
||||
if _is_interpreter(executable):
|
||||
_parse_interpreter(plan, executable, args)
|
||||
elif executable.endswith(_SCRIPT_SUFFIXES):
|
||||
plan.script_path = tokens[index]
|
||||
if plan.script_path is None and plan.inline_source is None and plan.parse_error is None:
|
||||
plan.parse_error = _unresolved_execution(executable, args)
|
||||
|
||||
plan.mutating_request = _mutating_request(executable, args)
|
||||
plan.read_only = (
|
||||
@@ -702,8 +763,8 @@ def parse_command(command: str) -> CommandPlan:
|
||||
return plan
|
||||
|
||||
|
||||
def _browser_action(args: list[str]) -> tuple[str | None, str | None]:
|
||||
"""Return the action word, or the reason the argument vector is unreadable.
|
||||
def _browser_action(args: list[str]) -> tuple[str | None, str | None, str | None]:
|
||||
"""Return ``(action, subcommand, error)`` for an agent-browser argument vector.
|
||||
|
||||
An unknown option may or may not consume the token after it, so guessing would
|
||||
let an option value stand in for the action and defeat the blocked-action list.
|
||||
@@ -712,19 +773,40 @@ def _browser_action(args: list[str]) -> tuple[str | None, str | None]:
|
||||
while index < len(args):
|
||||
option = args[index]
|
||||
if option == "--":
|
||||
return (args[index + 1] if index + 1 < len(args) else None), None
|
||||
index += 1
|
||||
break
|
||||
if not option.startswith("-") or option == "-":
|
||||
return option, None
|
||||
break
|
||||
name, separator, _ = option.partition("=")
|
||||
if name not in _BROWSER_VALUE_OPTIONS and name not in _BROWSER_FLAG_OPTIONS:
|
||||
return None, f"unrecognized agent-browser option {name} before the action"
|
||||
return None, None, f"unrecognized agent-browser option {name} before the action"
|
||||
index += 1 if separator or name in _BROWSER_FLAG_OPTIONS else 2
|
||||
return None, None
|
||||
if index >= len(args):
|
||||
return None, None, None
|
||||
subcommand = args[index + 1] if index + 1 < len(args) else None
|
||||
if subcommand is not None and subcommand.startswith("-"):
|
||||
subcommand = None
|
||||
return args[index], subcommand, None
|
||||
|
||||
|
||||
def _browser_is_passive(action: str | None, subcommand: str | None) -> bool:
|
||||
"""Whether an action only observes the page.
|
||||
|
||||
A grouped verb is passive only in its bare listing form: `tab` lists tabs, but
|
||||
`tab new <url>` navigates and `tab close 2` destroys page state, and both would
|
||||
otherwise be waved through on the strength of the verb alone.
|
||||
"""
|
||||
if action not in _BROWSER_PASSIVE_ACTIONS:
|
||||
return False
|
||||
return not (action in _BROWSER_GROUPED_VERBS and subcommand is not None)
|
||||
|
||||
|
||||
class _PythonFacts(ast.NodeVisitor):
|
||||
def __init__(self) -> None:
|
||||
self.imports: set[str] = set()
|
||||
# Resolution-only candidates; kept apart from `imports` so the packet still shows
|
||||
# the reviewer the import statements as written.
|
||||
self.submodule_imports: set[str] = set()
|
||||
self.relative_imports: set[tuple[int, str]] = set()
|
||||
self.calls: list[dict[str, Any]] = []
|
||||
self.urls: set[str] = set()
|
||||
@@ -751,12 +833,18 @@ class _PythonFacts(ast.NodeVisitor):
|
||||
self.generic_visit(node)
|
||||
|
||||
def visit_ImportFrom(self, node: ast.ImportFrom) -> None:
|
||||
# An imported name may be a submodule rather than an attribute, so `from pkg import
|
||||
# payload` has to resolve `pkg/payload.py` as well as the package initializer.
|
||||
module = node.module or ""
|
||||
names = tuple(alias.name for alias in node.names if alias.name != "*")
|
||||
if node.level:
|
||||
names = (module,) if module else tuple(alias.name for alias in node.names)
|
||||
self.relative_imports.update((node.level, name) for name in names if name)
|
||||
targets = (module,) if module else names
|
||||
self.relative_imports.update((node.level, name) for name in targets if name)
|
||||
if module:
|
||||
self.relative_imports.update((node.level, f"{module}.{name}") for name in names)
|
||||
elif module:
|
||||
self.imports.add(module)
|
||||
self.submodule_imports.update(f"{module}.{name}" for name in names)
|
||||
self._note_browser(module)
|
||||
self.generic_visit(node)
|
||||
|
||||
@@ -826,7 +914,7 @@ def _script_posix_path(script_path: str, workdir: str | None) -> PurePosixPath:
|
||||
|
||||
|
||||
def _import_targets(facts: _PythonFacts) -> list[tuple[int, str]]:
|
||||
absolute = [(0, module) for module in sorted(facts.imports)]
|
||||
absolute = [(0, module) for module in sorted(facts.imports | facts.submodule_imports)]
|
||||
return absolute + sorted(facts.relative_imports)
|
||||
|
||||
|
||||
@@ -1050,15 +1138,24 @@ def _browser_rules(
|
||||
block: str | None = None
|
||||
allow: str | None = None
|
||||
action = plan.browser_action
|
||||
passive = _browser_is_passive(action, plan.browser_subcommand)
|
||||
packet["pending_action"]["browser_action"] = action
|
||||
packet["pending_action"]["browser_subcommand"] = plan.browser_subcommand
|
||||
if any(word.partition("=")[0] in _BROWSER_OVERRIDE_OPTIONS for word in plan.tokens):
|
||||
block = "Browser session/profile/CDP overrides are blocked in safety modes."
|
||||
if action in _BROWSER_READ_ACTIONS and action not in {"cookies", "storage"}:
|
||||
if passive:
|
||||
allow = f"Known browser observation command: {action}."
|
||||
if action in _BROWSER_BLOCKED_ACTIONS:
|
||||
block = f"Composite or privileged browser action {action!r} is blocked."
|
||||
snapshot = _latest_browser_snapshot(list(getattr(ctx, "turn_input", []) or []))
|
||||
packet["browser"] = {"action": action, "latest_snapshot": snapshot}
|
||||
# `passive` is the single source of truth for observe mode too, so the two modes
|
||||
# cannot drift into disagreeing about what counts as an observation.
|
||||
packet["browser"] = {
|
||||
"action": action,
|
||||
"subcommand": plan.browser_subcommand,
|
||||
"passive": passive,
|
||||
"latest_snapshot": snapshot,
|
||||
}
|
||||
if action in _BROWSER_CONTEXT_ACTIONS:
|
||||
if snapshot is None:
|
||||
incomplete.append("browser interaction has no matching prior snapshot evidence")
|
||||
|
||||
@@ -31,7 +31,6 @@ if TYPE_CHECKING:
|
||||
|
||||
InvokeTool = Callable[[Any, str], Awaitable[Any]]
|
||||
|
||||
_PASSIVE_BROWSER_ACTIONS = frozenset({"snapshot", "get", "is", "tab", "session"})
|
||||
# ETX only: it discards the terminal's line buffer instead of submitting it, so it
|
||||
# cannot smuggle a command through a session that was approved for something else.
|
||||
_INTERRUPT_CHARS = frozenset({"\x03"})
|
||||
@@ -310,9 +309,12 @@ class SafetyRuntime:
|
||||
@staticmethod
|
||||
def _observe_mode_block(bundle: EvidenceBundle, case_id: str) -> SafetyDecision | None:
|
||||
"""Enforce the passive-only contract without relying on the model reviewer."""
|
||||
if bundle.packet.get("browser") is not None:
|
||||
action = bundle.packet.get("pending_action", {}).get("browser_action")
|
||||
if action not in _PASSIVE_BROWSER_ACTIONS:
|
||||
browser = bundle.packet.get("browser")
|
||||
if browser is not None:
|
||||
if not browser.get("passive"):
|
||||
action = " ".join(
|
||||
part for part in (browser.get("action"), browser.get("subcommand")) if part
|
||||
)
|
||||
return SafetyDecision(
|
||||
allowed=False,
|
||||
source="deterministic",
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
import io
|
||||
from types import SimpleNamespace
|
||||
from typing import TYPE_CHECKING, Any
|
||||
@@ -9,7 +10,7 @@ from typing import TYPE_CHECKING, Any
|
||||
import pytest
|
||||
|
||||
from strix.config.settings import SafetySettings
|
||||
from strix.safety.evidence import compile_evidence, parse_command
|
||||
from strix.safety.evidence import _PythonFacts, compile_evidence, parse_command
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -33,6 +34,12 @@ class _Sandbox:
|
||||
return io.BytesIO(self.files[key].encode())
|
||||
|
||||
|
||||
def _facts(source: str) -> _PythonFacts:
|
||||
facts = _PythonFacts()
|
||||
facts.visit(ast.parse(source))
|
||||
return facts
|
||||
|
||||
|
||||
def _ctx(files: dict[str, str], *, turn_input: list[Any] | None = None) -> Any:
|
||||
return SimpleNamespace(
|
||||
context={"agent_id": "agent-1", "sandbox_session": _Sandbox(files)},
|
||||
@@ -624,3 +631,169 @@ async def test_oversized_dependency_closure_makes_evidence_incomplete() -> None:
|
||||
assert any("total byte limit" in reason for reason in bundle.incomplete_reasons)
|
||||
finally:
|
||||
bundle.cleanup()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
("command", "action", "subcommand"),
|
||||
[
|
||||
("agent-browser tab new https://example.test/admin", "tab", "new"),
|
||||
("agent-browser tab close 2", "tab", "close"),
|
||||
("agent-browser session clear", "session", "clear"),
|
||||
],
|
||||
)
|
||||
async def test_grouped_browser_verbs_are_not_passive(
|
||||
command: str,
|
||||
action: str,
|
||||
subcommand: str,
|
||||
) -> None:
|
||||
"""`tab new <url>` navigates and `tab close` destroys page state, so the bare verb
|
||||
must not be enough to earn the observation fast path."""
|
||||
plan = parse_command(command)
|
||||
assert plan.browser_action == action
|
||||
assert plan.browser_subcommand == subcommand
|
||||
assert plan.read_only is False
|
||||
|
||||
bundle = await _compile(command)
|
||||
try:
|
||||
assert bundle.deterministic_allow is None
|
||||
assert bundle.packet["browser"]["passive"] is False
|
||||
finally:
|
||||
bundle.cleanup()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("command", ["agent-browser tab", "agent-browser snapshot -i"])
|
||||
async def test_bare_listing_verbs_keep_the_observation_fast_path(command: str) -> None:
|
||||
bundle = await _compile(command)
|
||||
try:
|
||||
assert bundle.deterministic_allow is not None
|
||||
assert bundle.packet["browser"]["passive"] is True
|
||||
finally:
|
||||
bundle.cleanup()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_grouped_blocked_verbs_still_match_on_the_verb() -> None:
|
||||
"""The blocked list keys off the bare verb, so qualifying the action must not stop
|
||||
`auth login` from matching `auth`."""
|
||||
bundle = await _compile("agent-browser auth login my-app")
|
||||
try:
|
||||
assert bundle.deterministic_block is not None
|
||||
assert "auth" in bundle.deterministic_block
|
||||
finally:
|
||||
bundle.cleanup()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
"command",
|
||||
[
|
||||
"python3.12 /workspace/run.py",
|
||||
"/usr/bin/python3.12 /workspace/run.py",
|
||||
"pypy3 /workspace/run.py",
|
||||
],
|
||||
)
|
||||
async def test_versioned_interpreters_are_inspected(command: str) -> None:
|
||||
bundle = await _compile(
|
||||
command,
|
||||
{"/workspace/run.py": "import helper\n", "/workspace/helper.py": "value = 1\n"},
|
||||
)
|
||||
try:
|
||||
paths = {item["path"] for item in bundle.packet["artifacts"]}
|
||||
assert paths == {"/workspace/run.py", "/workspace/helper.py"}
|
||||
assert bundle.complete is True
|
||||
finally:
|
||||
bundle.cleanup()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_non_python_interpreter_source_is_inspected() -> None:
|
||||
bundle = await _compile(
|
||||
"php /workspace/app.php",
|
||||
{"/workspace/app.php": "<?php unlink('/workspace/data'); ?>\n"},
|
||||
)
|
||||
try:
|
||||
[artifact] = bundle.packet["artifacts"]
|
||||
assert artifact["path"] == "/workspace/app.php"
|
||||
assert "unlink" in artifact["source"]
|
||||
finally:
|
||||
bundle.cleanup()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
"command",
|
||||
[
|
||||
"python3.12",
|
||||
"node",
|
||||
"mystery-runner /workspace/run.py",
|
||||
"./vendored-tool /workspace/run.sh",
|
||||
],
|
||||
)
|
||||
async def test_unresolvable_code_execution_fails_closed(command: str) -> None:
|
||||
"""A packet with no artifacts must never be stamped complete just because the
|
||||
executable fell outside the interpreter set."""
|
||||
bundle = await _compile(command, {"/workspace/run.py": "import os\n"})
|
||||
try:
|
||||
assert bundle.complete is False
|
||||
assert bundle.packet["artifacts"] == []
|
||||
finally:
|
||||
bundle.cleanup()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("command", ["nmap -sV example.test", "ls /workspace", "whoami"])
|
||||
async def test_commands_without_a_script_are_not_forced_incomplete(command: str) -> None:
|
||||
"""Fail-closed on unresolved script execution must not swallow ordinary tools."""
|
||||
bundle = await _compile(command)
|
||||
try:
|
||||
assert bundle.complete is True
|
||||
finally:
|
||||
bundle.cleanup()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_absolute_submodule_import_is_collected() -> None:
|
||||
"""`from pkg import payload` may name a submodule, not an attribute of the package."""
|
||||
bundle = await _compile(
|
||||
"python /workspace/main.py",
|
||||
{
|
||||
"/workspace/main.py": "from pkg import payload\npayload.go()\n",
|
||||
"/workspace/pkg/__init__.py": "",
|
||||
"/workspace/pkg/payload.py": "import shutil\n\n\ndef go():\n shutil.rmtree('/x')\n",
|
||||
},
|
||||
)
|
||||
try:
|
||||
paths = {item["path"] for item in bundle.packet["artifacts"]}
|
||||
assert "/workspace/pkg/payload.py" in paths
|
||||
finally:
|
||||
bundle.cleanup()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_relative_submodule_import_is_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 .inner import payload\n",
|
||||
"/workspace/pkg/inner/__init__.py": "",
|
||||
"/workspace/pkg/inner/payload.py": "import shutil\nshutil.rmtree('/x')\n",
|
||||
},
|
||||
)
|
||||
try:
|
||||
paths = {item["path"] for item in bundle.packet["artifacts"]}
|
||||
assert "/workspace/pkg/inner/payload.py" in paths
|
||||
finally:
|
||||
bundle.cleanup()
|
||||
|
||||
|
||||
def test_imported_attributes_do_not_pollute_the_reported_imports() -> None:
|
||||
"""Submodule candidates are resolution-only; the packet still shows the statements
|
||||
as the author wrote them."""
|
||||
facts = _facts("from os import path\nfrom mypkg import CONSTANT\n")
|
||||
|
||||
assert facts.imports == {"os", "mypkg"}
|
||||
assert facts.submodule_imports == {"os.path", "mypkg.CONSTANT"}
|
||||
|
||||
@@ -477,3 +477,71 @@ async def test_a_read_only_command_leaves_the_epoch_alone(tmp_path: Path) -> Non
|
||||
)
|
||||
|
||||
assert runtime._workspace_epoch == before
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
"command",
|
||||
[
|
||||
"agent-browser tab new https://example.test/admin",
|
||||
"agent-browser tab close 2",
|
||||
"agent-browser session clear",
|
||||
],
|
||||
)
|
||||
async def test_observe_mode_blocks_grouped_browser_verbs(tmp_path: Path, command: str) -> None:
|
||||
"""The bare verb sits in the passive set, so these are the commands that would slip
|
||||
through if passivity were decided on the verb alone."""
|
||||
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": command},
|
||||
invoke_tool=invoke,
|
||||
)
|
||||
|
||||
payload = json.loads(result)
|
||||
assert payload["status"] == "blocked"
|
||||
assert payload["safety"]["categories"] == ["target_mutation"]
|
||||
assert reviewer.calls == 0
|
||||
assert invoked is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_guarded_grouped_browser_verb_reaches_the_reviewer(tmp_path: Path) -> None:
|
||||
"""In guarded mode it loses only the fast path; the reviewer still gets to decide."""
|
||||
runtime = _runtime(tmp_path, "guarded")
|
||||
reviewer = _StubReviewer()
|
||||
runtime._reviewer = reviewer
|
||||
|
||||
result = await runtime.invoke_exec(
|
||||
ctx=_ctx(),
|
||||
arguments={"cmd": "agent-browser tab new https://example.test/admin"},
|
||||
invoke_tool=_noop_invoke,
|
||||
)
|
||||
|
||||
assert result == "patched"
|
||||
assert reviewer.calls == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bare_tab_listing_keeps_the_fast_path(tmp_path: Path) -> None:
|
||||
runtime = _runtime(tmp_path, "observe")
|
||||
reviewer = _StubReviewer()
|
||||
runtime._reviewer = reviewer
|
||||
|
||||
result = await runtime.invoke_exec(
|
||||
ctx=_ctx(),
|
||||
arguments={"cmd": "agent-browser tab"},
|
||||
invoke_tool=_noop_invoke,
|
||||
)
|
||||
|
||||
assert result == "patched"
|
||||
assert reviewer.calls == 0
|
||||
|
||||
@@ -77,3 +77,40 @@ def test_isolated_copy_drops_out_of_tree_symlink(tmp_path: Path) -> None:
|
||||
)
|
||||
|
||||
assert not (Path(staged["source_path"]) / "escape").exists()
|
||||
|
||||
|
||||
def test_repeated_materialization_preserves_the_true_origin(tmp_path: Path) -> None:
|
||||
source = tmp_path / "source"
|
||||
source.mkdir()
|
||||
(source / "app.py").write_text("code\n", encoding="utf-8")
|
||||
run_dir = tmp_path / "runs" / "scan"
|
||||
sources = [{"source_path": str(source), "workspace_subdir": "source", "protect_metadata": True}]
|
||||
|
||||
# Staging runs in prepare_run and again in run_strix_scan on the same entries.
|
||||
staged = materialize_isolated_sources(sources, run_dir=run_dir)
|
||||
[restaged] = materialize_isolated_sources(staged, run_dir=run_dir)
|
||||
|
||||
assert restaged["original_source_path"] == str(source.resolve())
|
||||
assert restaged["source_path"] == staged[0]["source_path"]
|
||||
assert restaged["source_path"] != restaged["original_source_path"]
|
||||
|
||||
|
||||
def test_restaging_without_a_completion_marker_recopies_the_source(tmp_path: Path) -> None:
|
||||
"""A second pass that treats the copy as its own origin clears the destination and
|
||||
then reads it back empty, silently handing the agent an empty workspace."""
|
||||
source = tmp_path / "source"
|
||||
source.mkdir()
|
||||
(source / "app.py").write_text("code\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,
|
||||
)
|
||||
destination = Path(staged["source_path"])
|
||||
(destination.parent / f".{destination.name}.complete").unlink()
|
||||
|
||||
[restaged] = materialize_isolated_sources([staged], run_dir=run_dir)
|
||||
|
||||
assert (Path(restaged["source_path"]) / "app.py").read_text(encoding="utf-8") == "code\n"
|
||||
assert restaged["original_source_path"] == str(source.resolve())
|
||||
|
||||
Reference in New Issue
Block a user