fix(containment): isolate evidence from remediation

Add read-only local target mounts and a separate writable workspace mount.
Persist both contracts across fresh and resumed runs, reject host path aliases,
and fail closed when a sandbox backend cannot enforce immutable evidence.

DOPS-1172

👽 Directed by Chrispy <chris@akuru.com.au>
Authored by LLM gpt-5.6-sol -- Ranger
This commit is contained in:
👽 Chrispy 2026-09-03 09:53:51 +10:00
parent 5d015df6b1
commit a1fdb98960
No known key found for this signature in database
12 changed files with 650 additions and 32 deletions

View file

@ -67,6 +67,17 @@ strix -t https://github.com/org/repo -t https://your-app.com
strix --target-list ./targets.txt
```
## Keep Local Evidence Read-Only
Use a separate writable directory when the scanner may propose code changes:
```bash
mkdir -p ./remediation
strix --target ./evidence --read-only-local-targets --workspace-mount ./remediation
```
The evidence directory is mounted read-only. The remediation directory is writable but is not added to the scan's authorized targets.
## Next Steps
<CardGroup cols={2}>

View file

@ -105,6 +105,25 @@ def _render_workspace_files(scan_config: dict[str, Any]) -> list[str]:
]
def _render_local_code_target(
details: dict[str, Any], workspace_path: str, *, has_workspace_mount: bool
) -> str:
path = details.get("target_path", "unknown")
if details.get("read_only"):
remediation = (
" -- remediate in the writable working directory instead" if has_workspace_mount else ""
)
return (
f"- {path} (available at: {workspace_path}; "
"mounted read-only: this is immutable evidence and must not "
f"be modified{remediation})"
)
return (
f"- {path} (available at: {workspace_path}; "
"mounted live and writable -- .git/.agents/.codex are read-only)"
)
def build_root_task(scan_config: dict[str, Any]) -> str:
targets = scan_config.get("targets", []) or []
diff_scope = scan_config.get("diff_scope") or {}
@ -131,11 +150,12 @@ def build_root_task(scan_config: dict[str, Any]) -> str:
f"- {url} (available at: {workspace_path})" if cloned else f"- {url}",
)
elif ttype == "local_code":
path = details.get("target_path", "unknown")
sections["Local Codebases"].append(
f"- {path} (available at: {workspace_path}; "
"this is the user's real directory, mounted live and writable — "
".git/.agents/.codex are read-only)"
_render_local_code_target(
details,
workspace_path,
has_workspace_mount=bool(scan_config.get("workspace_mount")),
)
)
elif ttype == "web_application":
sections["URLs"].append(f"- {details.get('target_url', '')}")
@ -151,21 +171,24 @@ def build_root_task(scan_config: dict[str, Any]) -> str:
parts.extend(items)
# A workspace mount is a directory to work in, not an asset to test. It is
# listed apart from the targets so it never reads as scope.
# listed apart from the targets so it never reads as scope. With
# --read-only-local-targets the writable copy is the remediation area where
# fixes land; without targets it is simply the working directory.
if workspace_mount := scan_config.get("workspace_mount") or "":
subdir = scan_config.get("workspace_subdir") or ""
workspace_path = f"/workspace/{subdir}" if subdir else "/workspace"
parts.append("\n\nWorking Directory:")
parts.append(
f"- {workspace_mount} (available at: {workspace_path}; "
"this is the user's real directory, mounted live and writable — "
".git/.agents/.codex are read-only)"
)
parts.append(
"- No scan target was set. This directory is where you work, not a "
"target to assess: the instructions below are the only source of "
"truth for what to do."
"mounted live and writable -- this is the remediation area where "
"changes land; it is not an assessment target)"
)
if not targets:
parts.append(
"- No scan target was set. This directory is where you work, "
"not a target to assess: the instructions below are the only "
"source of truth for what to do."
)
# Whether anything above gave the run a scope. Workspace files never do, so
# this is read before they are listed.
has_scope = bool(parts)

View file

@ -95,6 +95,9 @@ async def run_cli(args: Any) -> None: # noqa: PLR0915
"non_interactive": bool(getattr(args, "non_interactive", False)),
"local_sources": getattr(args, "local_sources", None) or [],
"workspace_files": getattr(args, "workspace_files", None) or [],
"workspace_mount": getattr(args, "workspace_mount", None) or "",
"workspace_subdir": getattr(args, "workspace_subdir", None) or "",
"read_only_local_targets": bool(getattr(args, "read_only_local_targets", False)),
"scope_mode": getattr(args, "scope_mode", "auto"),
"diff_base": getattr(args, "diff_base", None),
"resume_instruction": getattr(args, "user_explicit_instruction", None) or "",

View file

@ -167,6 +167,31 @@ Examples:
"read-only inside the sandbox and lands outside every target directory.",
)
parser.add_argument(
"--read-only-local-targets",
action="store_true",
default=False,
help=(
"Mount every local-code target read-only inside the sandbox so the "
"scanner cannot modify the evidence tree. Pair with --workspace-mount "
"to give the agent a separate writable area for remediation."
),
)
parser.add_argument(
"--workspace-mount",
type=str,
metavar="DIR",
default=None,
help=(
"Mount a host directory into the sandbox as a writable working area, "
"separate from the scan targets. The directory is not an assessment "
"target -- the instructions are the only source of truth for what to "
"do with it. Pair with --read-only-local-targets for hard containment: "
"targets stay immutable, remediation lands here."
),
)
parser.add_argument(
"-n",
"--non-interactive",
@ -329,6 +354,15 @@ Examples:
except ValueError as error:
parser.error(f"--workspace-file: {error}")
# --workspace-mount is a fresh-cli directory the user wants writable inside
# the sandbox. It goes through the same mount guard as targets so a refused
# path (home, system tree, a credentials dir) fails before any API call.
if args.workspace_mount:
try:
check_mountable_dir(Path(args.workspace_mount).expanduser())
except ValueError as error:
parser.error(f"--workspace-mount: {error}")
args.user_explicit_instruction = args.instruction if args.resume else None
# What the user actually asked for, kept apart from args.instruction because
# prepare_run prepends the diff-scope preamble to that. This is the text the
@ -423,6 +457,10 @@ def _load_resume_state(args: argparse.Namespace, parser: argparse.ArgumentParser
if not getattr(args, "user_instruction", None):
args.user_instruction = state.get("user_instruction") or None
args.local_sources = collect_local_sources(args.targets_info)
# Restore hard containment: the flag is not re-derivable from the persisted
# details once this run is re-prepared, so carry it over from the record.
# The per-target details.read_only flags survive in targets_info themselves.
args.read_only_local_targets = bool(state.get("read_only_local_targets", False))
# Remount the workspace the run was started with. The user already confirmed
# this directory, so the target mount guard does not apply to it; it only has
# to still be there.
@ -450,7 +488,10 @@ def _load_resume_state(args: argparse.Namespace, parser: argparse.ArgumentParser
f"--resume {args.resume}: the working directory {workspace_mount} "
f"is missing. Restore it before resuming, or start a fresh run."
)
attach_workspace_mount(args)
try:
attach_workspace_mount(args)
except ValueError as error:
parser.error(f"--resume {args.resume}: {error}")
if state.get("diff_scope"):
args.diff_scope = state.get("diff_scope")
persisted_scan_mode = state.get("scan_mode")

View file

@ -12,6 +12,7 @@ from __future__ import annotations
import asyncio
import logging
from datetime import UTC, datetime
from pathlib import Path
from typing import TYPE_CHECKING, Any
from strix.config import Settings, codex, load_settings
@ -123,6 +124,12 @@ def build_targets_info(args: argparse.Namespace) -> None:
if target_type == "api_spec":
_resolve_api_spec(target, target_dict)
# Hard containment: with --read-only-local-targets the scanner must not
# be able to modify the evidence tree, so every local-code target is
# marked read-only here and the sandbox mounts it accordingly.
if target_type == "local_code" and getattr(args, "read_only_local_targets", False):
target_dict["read_only"] = True
args.targets_info.append(
{"type": target_type, "details": target_dict, "original": display_target}
)
@ -199,6 +206,32 @@ def prepare_run(args: argparse.Namespace) -> None:
_persist_run_record(args)
def _same_location(left: Path, right: Path) -> bool:
try:
return left.samefile(right)
except OSError:
return left == right
def _reject_workspace_overlap(workspace: Path, local_sources: list[dict[str, Any]]) -> None:
workspace = workspace.expanduser().resolve()
for source in local_sources:
if not source.get("read_only"):
continue
evidence = Path(str(source["source_path"])).expanduser().resolve()
workspace_inside_evidence = any(
_same_location(candidate, evidence) for candidate in (workspace, *workspace.parents)
)
evidence_inside_workspace = any(
_same_location(candidate, workspace) for candidate in (evidence, *evidence.parents)
)
if workspace_inside_evidence or evidence_inside_workspace:
raise ValueError(
f"Workspace mount '{workspace}' overlaps read-only target '{evidence}'. "
"Use disjoint directories so writable work cannot alias immutable evidence."
)
def attach_workspace_mount(args: argparse.Namespace) -> None:
"""Expose ``args.workspace_mount`` to the sandbox without making it a target.
@ -210,8 +243,19 @@ def attach_workspace_mount(args: argparse.Namespace) -> None:
mount = getattr(args, "workspace_mount", None)
if not mount:
return
args.workspace_subdir = derive_local_base_name(mount)
local_sources = list(getattr(args, "local_sources", None) or [])
_reject_workspace_overlap(Path(mount), local_sources)
base_subdir = derive_local_base_name(mount)
used_subdirs = {
str(source["workspace_subdir"])
for source in local_sources
if source.get("workspace_subdir")
}
args.workspace_subdir = base_subdir
suffix = 2
while args.workspace_subdir in used_subdirs:
args.workspace_subdir = f"{base_subdir}-{suffix}"
suffix += 1
local_sources.append(
{
"source_path": mount,
@ -261,6 +305,10 @@ def _persist_run_record(args: argparse.Namespace) -> None:
# Persisted so --resume can remount the workspace: it is not a target,
# so it cannot be rebuilt from targets_info.
"workspace_mount": getattr(args, "workspace_mount", None),
# Persisted so --resume keeps local-code targets read-only. The flag is
# baked into details.read_only at build_targets_info time; this is the
# user-facing record of that choice.
"read_only_local_targets": getattr(args, "read_only_local_targets", False),
"diff_scope": getattr(args, "diff_scope", {"active": False}),
"scope_mode": args.scope_mode,
"diff_base": args.diff_base,

View file

@ -93,13 +93,10 @@ class TuiController:
self.scope_mode = requested_scope if requested_scope in SCOPE_MODES else "auto"
raw_diff_base = args.diff_base
self.diff_base = raw_diff_base.strip() if isinstance(raw_diff_base, str) else None
# Host directory mounted for the agent to work in when the scan has no
# target, set only once the user confirms it. It is a workspace, not a
# target: it carries no scan scope, and the instruction is the only
# source of truth for what to do.
self.workspace_mount: str | None = None
# A target-less launch enters the live view and asks there before
# anything is prepared; this holds the directory awaiting that answer.
# Host directory mounted for the agent to work in. A CLI-provided path
# is already explicit; a target-less TUI launch without one asks before
# mounting the current directory.
self.workspace_mount: str | None = getattr(args, "workspace_mount", None) or None
self.pending_workspace_mount: str | None = None
self.messages: list[dict[str, str]] = []
self._next_message_id = 1
@ -340,12 +337,15 @@ class TuiController:
raise ValueError("No model configured. Set STRIX_LLM first.")
if self._on_start is None:
raise RuntimeError("Scan start is unavailable")
if not self.targets and not mount_working_dir:
if not self.targets and not mount_working_dir and not self.workspace_mount:
raise ValueError("No target set. Add a target first.")
# The model check runs while still on the start screen, for a bare
# prompt as much as for a named target, so a failure lands in the setup
# log where the user can fix it and retry rather than in a dead run.
await self._verify_model()
if not self.targets and self.workspace_mount:
await self._begin_scan()
return {"started": True}
if not self.targets:
# Mounting the working directory needs the user's confirmation, and
# that is asked in the live view. Enter it now and prepare nothing

View file

@ -91,6 +91,7 @@ class GoTuiRuntime:
"resume_instruction": self.args.user_explicit_instruction or "",
"workspace_mount": getattr(self.args, "workspace_mount", None) or "",
"workspace_subdir": getattr(self.args, "workspace_subdir", None) or "",
"read_only_local_targets": bool(getattr(self.args, "read_only_local_targets", False)),
}
self.report_state = ReportState(self.scan_config["run_name"])
self.report_state.hydrate_from_run_dir()

View file

@ -1129,7 +1129,7 @@ def _is_http_git_repo(url: str) -> bool:
return False
def infer_target_type(target: str) -> tuple[str, dict[str, str]]: # noqa: PLR0911
def infer_target_type(target: str) -> tuple[str, dict[str, Any]]: # noqa: PLR0911
if not target or not isinstance(target, str):
raise ValueError("Target must be a non-empty string")
@ -1317,13 +1317,14 @@ def collect_local_sources(targets_info: list[dict[str, Any]]) -> list[dict[str,
workspace_subdir = details.get("workspace_subdir")
if target_info["type"] == "local_code" and "target_path" in details:
local_sources.append(
{
"source_path": details["target_path"],
"workspace_subdir": workspace_subdir,
"protect_metadata": True,
}
)
source: dict[str, Any] = {
"source_path": details["target_path"],
"workspace_subdir": workspace_subdir,
"protect_metadata": True,
}
if details.get("read_only"):
source["read_only"] = True
local_sources.append(source)
elif target_info["type"] == "repository" and "cloned_repo_path" in details:
local_sources.append(

View file

@ -60,7 +60,16 @@ def build_bind_mounts(local_sources: list[dict[str, Any]]) -> list[dict[str, Any
continue
resolved = Path(host_path).expanduser().resolve()
target = f"{_WORKSPACE_ROOT}/{ws_subdir}"
bind_mounts.append({"source": str(resolved), "target": target, "read_only": False})
# A source carries read_only=True when its target was created with
# --read-only-local-targets (evidence trees stay immutable); workspace
# mounts and ordinary targets stay writable for the agent.
bind_mounts.append(
{
"source": str(resolved),
"target": target,
"read_only": bool(src.get("read_only", False)),
}
)
if src.get("protect_metadata"):
bind_mounts.extend(_metadata_mounts(resolved, target))
return bind_mounts
@ -291,9 +300,15 @@ async def create_or_reuse(
backend_name = load_settings().runtime.backend
backend = get_backend(backend_name)
supports_bind_mounts = backend_supports_bind_mounts(backend_name)
if not supports_bind_mounts and any(source.get("read_only") for source in local_sources):
raise RuntimeError(
f"Sandbox backend '{backend_name}' cannot enforce read-only local targets. "
"Use a bind-mount-capable backend or omit --read-only-local-targets."
)
staging_dir: Path | None = None
if backend_supports_bind_mounts(backend_name):
if supports_bind_mounts:
bind_mounts = build_bind_mounts(local_sources)
entries: dict[str | Path, BaseEntry] = {}
if extra_files:

View file

@ -128,6 +128,43 @@ def test_resume_restores_a_target_less_workspace_mount(
assert args.instruction == "audit the auth flow"
def test_resume_rejects_overlapping_workspace_without_traceback(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
) -> None:
evidence = tmp_path / "evidence"
workspace = evidence / "workspace"
workspace.mkdir(parents=True)
monkeypatch.chdir(tmp_path)
_write_run_record(
tmp_path / "strix_runs",
"pentest_overlap",
{
"run_name": "pentest_overlap",
"targets_info": [
{
"type": "local_code",
"original": str(evidence),
"details": {
"target_path": str(evidence),
"workspace_subdir": "evidence",
"read_only": True,
},
}
],
"workspace_mount": str(workspace),
"instruction": "audit the auth flow",
"scan_mode": "deep",
"read_only_local_targets": True,
},
)
monkeypatch.setattr(sys, "argv", ["strix", "--resume", "pentest_overlap"])
with pytest.raises(SystemExit):
cli_main.parse_arguments()
assert "overlaps read-only target" in capsys.readouterr().err
def test_resume_revalidates_persisted_workspace_files(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:

View file

@ -0,0 +1,411 @@
"""Tests for DOPS-1172 hard containment: read-only evidence targets plus a
writable, out-of-scope workspace mount.
The contract under test: ``--read-only-local-targets`` makes every local-code
target bind mount read-only; ``--workspace-mount`` stays writable, is not an
assessment target, and may coexist with real targets. Each test here would fail
if the target became writable, the workspace became read-only or in-scope, or
the root task lied about either.
"""
from __future__ import annotations
import argparse
import importlib
import sys
from pathlib import Path
from types import SimpleNamespace
from typing import Any
import pytest
from strix.core.inputs import build_root_task, build_scope_context
from strix.interface.scan_setup import attach_workspace_mount, build_targets_info
from strix.interface.utils import (
check_mountable_dir,
collect_local_sources,
)
from strix.runtime import session_manager
from strix.runtime.session_manager import build_bind_mounts
cli_main: Any = importlib.import_module("strix.interface.main")
def _stub_settings(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(
cli_main,
"load_settings",
lambda: SimpleNamespace(runtime=SimpleNamespace(max_local_copy_mb=1024)),
)
def _local_target(target_path: str, *, read_only: bool = False) -> dict[str, Any]:
details: dict[str, Any] = {"target_path": target_path, "workspace_subdir": "repo"}
if read_only:
details["read_only"] = True
return {"type": "local_code", "details": details, "original": target_path}
# --- Parser: the flags exist and propagate ----------------------------------
def test_parse_arguments_accepts_read_only_local_targets_and_workspace_mount(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
project = tmp_path / "project"
project.mkdir()
workspace = tmp_path / "workspace"
workspace.mkdir()
_stub_settings(monkeypatch)
monkeypatch.setattr(
sys,
"argv",
[
"strix",
"--target",
str(project),
"--read-only-local-targets",
"--workspace-mount",
str(workspace),
"-n",
],
)
args = cli_main.parse_arguments()
assert args.read_only_local_targets is True
assert args.workspace_mount == str(workspace)
def test_parse_arguments_keeps_targets_writable_by_default(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
project = tmp_path / "project"
project.mkdir()
_stub_settings(monkeypatch)
monkeypatch.setattr(sys, "argv", ["strix", "--target", str(project), "-n"])
args = cli_main.parse_arguments()
assert args.read_only_local_targets is False
target_details = args.targets_info[0]["details"]
assert not target_details.get("read_only")
def test_parse_arguments_stamps_local_code_targets_read_only(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
project = tmp_path / "project"
project.mkdir()
_stub_settings(monkeypatch)
monkeypatch.setattr(
sys,
"argv",
["strix", "--target", str(project), "--read-only-local-targets", "-n"],
)
args = cli_main.parse_arguments()
assert args.targets_info[0]["type"] == "local_code"
assert args.targets_info[0]["details"]["read_only"] is True
def test_parse_arguments_rejects_a_forbidden_workspace_mount(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
) -> None:
_stub_settings(monkeypatch)
forbidden = tmp_path / "home"
forbidden.mkdir()
monkeypatch.setattr(
sys,
"argv",
[
"strix",
"--target",
str(tmp_path / "whatever"),
"--workspace-mount",
str(forbidden),
"-n",
],
)
monkeypatch.setattr("pathlib.Path.home", classmethod(lambda _cls: forbidden))
with pytest.raises(SystemExit):
cli_main.parse_arguments()
assert "--workspace-mount" in capsys.readouterr().err
def test_parse_arguments_rejects_a_missing_workspace_mount(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
) -> None:
_stub_settings(monkeypatch)
monkeypatch.setattr(
sys,
"argv",
[
"strix",
"--target",
str(tmp_path / "whatever"),
"--workspace-mount",
str(tmp_path / "nope"),
"-n",
],
)
with pytest.raises(SystemExit):
cli_main.parse_arguments()
assert "--workspace-mount" in capsys.readouterr().err
# --- Propagation: read_only reaches the sandbox mounts -----------------------
def test_read_only_target_propagates_to_a_read_only_bind_mount(
tmp_path: Path,
) -> None:
sources = collect_local_sources([_local_target(str(tmp_path), read_only=True)])
assert sources[0]["read_only"] is True
mounts = build_bind_mounts(sources)
# Only the tree mount exists here (no .git), and it must be read-only.
assert len(mounts) == 1
assert mounts[0]["target"] == "/workspace/repo"
assert mounts[0]["read_only"] is True
def test_writable_target_stays_writable_without_the_flag(tmp_path: Path) -> None:
sources = collect_local_sources([_local_target(str(tmp_path), read_only=False)])
assert "read_only" not in sources[0]
mounts = build_bind_mounts(sources)
assert mounts[0]["read_only"] is False
@pytest.mark.asyncio
async def test_read_only_target_rejects_manifest_only_backend(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(
session_manager,
"load_settings",
lambda: SimpleNamespace(runtime=SimpleNamespace(backend="remote")),
)
monkeypatch.setattr(session_manager, "backend_supports_bind_mounts", lambda _name: False)
monkeypatch.setattr(session_manager, "get_backend", lambda _name: object())
with pytest.raises(RuntimeError, match="cannot enforce read-only local targets"):
await session_manager.create_or_reuse(
"manifest-read-only-rejected",
image="unused",
local_sources=[
{
"source_path": str(tmp_path),
"workspace_subdir": "evidence",
"read_only": True,
}
],
)
def test_workspace_mount_stays_writable_beside_read_only_targets(
tmp_path: Path,
) -> None:
evidence = tmp_path / "evidence"
evidence.mkdir()
workspace = tmp_path / "workspace"
workspace.mkdir()
args = argparse.Namespace(
target=[str(evidence)],
target_list=None,
targets_info=[],
local_sources=[],
workspace_mount=str(workspace),
read_only_local_targets=True,
)
build_targets_info(args)
# Match prepare_run's ordering: collect_local_sources first, then the
# workspace mount is appended to those sources.
args.local_sources = collect_local_sources(args.targets_info)
attach_workspace_mount(args)
evidence_subdir = args.targets_info[0]["details"]["workspace_subdir"]
assert evidence_subdir == "evidence"
mounts = build_bind_mounts(args.local_sources)
by_target = {m["target"]: m["read_only"] for m in mounts}
# Evidence target is read-only; the workspace remediation area is writable.
assert by_target[f"/workspace/{evidence_subdir}"] is True
workspace_mounts = [
m for m in mounts if m["target"].startswith(f"/workspace/{args.workspace_subdir}")
]
assert workspace_mounts, "workspace mount missing from bind mounts"
assert all(m["read_only"] is False for m in workspace_mounts)
def test_workspace_mount_gets_a_unique_container_path_when_basenames_collide(
tmp_path: Path,
) -> None:
evidence = tmp_path / "source" / "repo"
workspace = tmp_path / "remediation" / "repo"
evidence.mkdir(parents=True)
workspace.mkdir(parents=True)
args = argparse.Namespace(
workspace_mount=str(workspace),
local_sources=[
{
"source_path": str(evidence),
"workspace_subdir": "repo",
"read_only": True,
}
],
)
attach_workspace_mount(args)
assert args.workspace_subdir == "repo-2"
assert [source["workspace_subdir"] for source in args.local_sources] == ["repo", "repo-2"]
@pytest.mark.parametrize("relation", ["same", "workspace_child", "evidence_child"])
def test_workspace_mount_rejects_overlap_with_read_only_evidence(
tmp_path: Path, relation: str
) -> None:
if relation == "same":
evidence = workspace = tmp_path / "shared"
elif relation == "workspace_child":
evidence = tmp_path / "evidence"
workspace = evidence / "workspace"
else:
workspace = tmp_path / "workspace"
evidence = workspace / "evidence"
evidence.mkdir(parents=True, exist_ok=True)
workspace.mkdir(parents=True, exist_ok=True)
args = argparse.Namespace(
workspace_mount=str(workspace),
local_sources=[
{
"source_path": str(evidence),
"workspace_subdir": "evidence",
"read_only": True,
}
],
)
with pytest.raises(ValueError, match="overlaps read-only target"):
attach_workspace_mount(args)
def test_workspace_overlap_uses_filesystem_identity(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
evidence = tmp_path / "Evidence"
workspace = tmp_path / "evidence" / "workspace"
evidence.mkdir()
workspace.mkdir(parents=True)
monkeypatch.setattr(
Path,
"samefile",
lambda self, other: str(self).casefold() == str(other).casefold(),
)
args = argparse.Namespace(
workspace_mount=str(workspace),
local_sources=[
{
"source_path": str(evidence),
"workspace_subdir": "evidence",
"read_only": True,
}
],
)
with pytest.raises(ValueError, match="overlaps read-only target"):
attach_workspace_mount(args)
# --- Root task framing -------------------------------------------------------
def test_root_task_renders_a_read_only_target_as_immutable_evidence() -> None:
task = build_root_task(
{
"targets": [_local_target("/evidence", read_only=True)],
}
)
assert "Local Codebases:" in task
assert "mounted read-only" in task
assert "immutable evidence" in task
assert "remediate in the writable working directory" not in task
def test_root_task_renders_a_writable_target_without_the_read_only_claim() -> None:
task = build_root_task(
{
"targets": [_local_target("/codebase", read_only=False)],
}
)
assert "Local Codebases:" in task
assert "mounted live and writable" in task
assert "mounted read-only" not in task
def test_root_task_renders_the_workspace_as_a_writable_remediation_area() -> None:
task = build_root_task(
{
"targets": [_local_target("/evidence", read_only=True)],
"workspace_mount": "/remediation",
"workspace_subdir": "remediation",
}
)
assert "/workspace/remediation" in task
assert "writable" in task
# Targets exist, so the task must not claim there is no target.
assert "No scan target was set" not in task
assert "remediation" in task
assert "remediate in the writable working directory" in task
def test_root_task_claims_no_target_only_when_none_exist() -> None:
task = build_root_task(
{
"targets": [],
"workspace_mount": "/workspace-only",
"workspace_subdir": "workspace-only",
"user_instructions": "Do the thing",
}
)
assert "No scan target was set" in task
assert "/workspace/workspace-only" in task
def test_workspace_mount_grants_no_authorized_scope() -> None:
scope = build_scope_context(
{
"targets": [_local_target("/evidence", read_only=True)],
"workspace_mount": "/remediation",
"workspace_subdir": "remediation",
}
)
authorized = scope["authorized_targets"]
assert [t["value"] for t in authorized] == ["/evidence"]
assert all(
t["type"] != "local_code" or t["workspace_path"] != "/workspace/remediation"
for t in authorized
)
def test_check_mountable_dir_accepts_a_workspace_project_dir(tmp_path: Path) -> None:
project = tmp_path / "workspace-project"
project.mkdir()
check_mountable_dir(project)

View file

@ -233,6 +233,33 @@ async def test_target_less_start_enters_live_view_and_waits_for_the_mount() -> N
assert controller.snapshot()["pending_mount"] == str(Path.cwd())
@pytest.mark.asyncio
async def test_target_less_start_uses_explicit_cli_workspace_without_prompt(
tmp_path: Path,
) -> None:
started = False
async def start(_verify: bool = True) -> None:
nonlocal started
started = True
workspace = tmp_path / "remediation"
workspace.mkdir()
runtime_args = args()
runtime_args.workspace_mount = str(workspace)
os.environ["STRIX_LLM"] = "anthropic/claude-sonnet-4"
os.environ["ANTHROPIC_API_KEY"] = "test-key"
loader._cached = None
controller = TuiController(runtime_args, on_start=start)
result = await controller.handle("setup.start", {"verify": False})
assert result == {"started": True}
assert started is True
assert controller.workspace_mount == str(workspace)
assert controller.pending_workspace_mount is None
@pytest.mark.asyncio
async def test_confirming_the_mount_starts_the_scan_without_a_target() -> None:
started = False