mirror of
https://github.com/usestrix/strix.git
synced 2026-10-09 03:18:31 +00:00
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:
parent
5d015df6b1
commit
a1fdb98960
12 changed files with 650 additions and 32 deletions
|
|
@ -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}>
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 "",
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
411
tests/test_hard_containment.py
Normal file
411
tests/test_hard_containment.py
Normal 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)
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue