From a1fdb9896092cac24c72d14796c0428b311657d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9F=91=BD=20Chrispy?= Date: Thu, 3 Sep 2026 09:53:51 +1000 Subject: [PATCH] fix(containment): isolate evidence from remediation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Authored by LLM gpt-5.6-sol -- Ranger --- docs/quickstart.mdx | 11 + strix/core/inputs.py | 47 ++- strix/interface/cli.py | 3 + strix/interface/cli_args.py | 43 ++- strix/interface/scan_setup.py | 50 ++- strix/interface/tui/backend/controller.py | 16 +- strix/interface/tui/runtime.py | 1 + strix/interface/utils.py | 17 +- strix/runtime/session_manager.py | 19 +- tests/test_cli_target_list.py | 37 ++ tests/test_hard_containment.py | 411 ++++++++++++++++++++++ tests/test_tui_backend_controller.py | 27 ++ 12 files changed, 650 insertions(+), 32 deletions(-) create mode 100644 tests/test_hard_containment.py diff --git a/docs/quickstart.mdx b/docs/quickstart.mdx index dcd2e7c5..d2043d3c 100644 --- a/docs/quickstart.mdx +++ b/docs/quickstart.mdx @@ -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 diff --git a/strix/core/inputs.py b/strix/core/inputs.py index 3dd0d701..13c98515 100644 --- a/strix/core/inputs.py +++ b/strix/core/inputs.py @@ -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) diff --git a/strix/interface/cli.py b/strix/interface/cli.py index 684805d0..f1fb3e4e 100644 --- a/strix/interface/cli.py +++ b/strix/interface/cli.py @@ -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 "", diff --git a/strix/interface/cli_args.py b/strix/interface/cli_args.py index dbb1ebdf..b0bffec2 100644 --- a/strix/interface/cli_args.py +++ b/strix/interface/cli_args.py @@ -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") diff --git a/strix/interface/scan_setup.py b/strix/interface/scan_setup.py index ae7caf2f..7410b514 100644 --- a/strix/interface/scan_setup.py +++ b/strix/interface/scan_setup.py @@ -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, diff --git a/strix/interface/tui/backend/controller.py b/strix/interface/tui/backend/controller.py index 6f3b3fb3..94b04cba 100644 --- a/strix/interface/tui/backend/controller.py +++ b/strix/interface/tui/backend/controller.py @@ -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 diff --git a/strix/interface/tui/runtime.py b/strix/interface/tui/runtime.py index 728e7023..f40021d7 100644 --- a/strix/interface/tui/runtime.py +++ b/strix/interface/tui/runtime.py @@ -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() diff --git a/strix/interface/utils.py b/strix/interface/utils.py index faab1772..69872264 100644 --- a/strix/interface/utils.py +++ b/strix/interface/utils.py @@ -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( diff --git a/strix/runtime/session_manager.py b/strix/runtime/session_manager.py index 3c95d31a..7d640c24 100644 --- a/strix/runtime/session_manager.py +++ b/strix/runtime/session_manager.py @@ -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: diff --git a/tests/test_cli_target_list.py b/tests/test_cli_target_list.py index d20230b1..9c93ea17 100644 --- a/tests/test_cli_target_list.py +++ b/tests/test_cli_target_list.py @@ -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: diff --git a/tests/test_hard_containment.py b/tests/test_hard_containment.py new file mode 100644 index 00000000..c63e080c --- /dev/null +++ b/tests/test_hard_containment.py @@ -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) diff --git a/tests/test_tui_backend_controller.py b/tests/test_tui_backend_controller.py index 3a28d020..4ddbd700 100644 --- a/tests/test_tui_backend_controller.py +++ b/tests/test_tui_backend_controller.py @@ -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