mirror of
https://github.com/usestrix/strix.git
synced 2026-08-24 20:02:39 +02:00
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>
117 lines
4.2 KiB
Python
117 lines
4.2 KiB
Python
"""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()
|
|
|
|
|
|
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())
|