diff --git a/docs/advanced/configuration.mdx b/docs/advanced/configuration.mdx index f1542b75..80fdd5ff 100644 --- a/docs/advanced/configuration.mdx +++ b/docs/advanced/configuration.mdx @@ -124,6 +124,10 @@ When remote vars are set, Strix dual-writes telemetry to both local JSONL and th ## Sandbox Configuration + + Enforces mount-free (bind-mount-free) transport for local-code Docker scans. Set to `1`, `true`, `yes`, or `on` to ensure no host directories are bind-mounted into the sandbox container. Local project files are transferred as an isolated snapshot payload. The snapshot lives in the container's writable layer and is discarded with it; in-tree directory symlinks are omitted; symlinked projects are read into memory. + + Maximum execution time in seconds for sandbox operations. diff --git a/strix/config/settings.py b/strix/config/settings.py index 42a2c97e..b807d52b 100644 --- a/strix/config/settings.py +++ b/strix/config/settings.py @@ -110,6 +110,10 @@ class RuntimeSettings(BaseSettings): alias="STRIX_IMAGE", ) backend: str = Field(default="docker", alias="STRIX_RUNTIME_BACKEND") + require_mount_free: bool = Field( + default=False, + alias="STRIX_REQUIRE_MOUNT_FREE", + ) # Max screenshot/image tool outputs kept live per agent context (0 = none). max_context_images: int = Field(default=3, ge=0, alias="STRIX_MAX_CONTEXT_IMAGES") diff --git a/strix/core/inputs.py b/strix/core/inputs.py index f383261e..9406069d 100644 --- a/strix/core/inputs.py +++ b/strix/core/inputs.py @@ -8,6 +8,7 @@ from typing import TYPE_CHECKING, Any from agents.model_settings import ModelSettings from openai.types.shared import Reasoning +from strix.config.loader import load_settings from strix.config.models import ( DEFAULT_MODEL_RETRY, OPENROUTER_ATTRIBUTION_HEADERS, @@ -20,6 +21,7 @@ from strix.config.models import ( request_timeout_extra_args, ) from strix.core.sessions import scrub_images_from_items +from strix.runtime.backends import backend_supports_bind_mounts if TYPE_CHECKING: @@ -131,10 +133,23 @@ def build_root_task(scan_config: dict[str, Any]) -> str: ) elif ttype == "local_code": path = details.get("target_path", "unknown") + backend_name = load_settings().runtime.backend + use_bind_mounts = ( + backend_supports_bind_mounts(backend_name) + and not load_settings().runtime.require_mount_free + ) + + if use_bind_mounts: + desc = "this is the user's real directory, mounted live and writable" + else: + desc = ( + "this is a bounded snapshot of the user's directory, " + "isolated from the live host system" + ) + 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)" + f"{desc} — .git/.agents/.codex are read-only)" ) elif ttype == "web_application": sections["URLs"].append(f"- {details.get('target_url', '')}") @@ -154,11 +169,25 @@ def build_root_task(scan_config: dict[str, Any]) -> str: 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" + + backend_name = load_settings().runtime.backend + use_bind_mounts = ( + backend_supports_bind_mounts(backend_name) + and not load_settings().runtime.require_mount_free + ) + + if use_bind_mounts: + desc = "this is the user's real directory, mounted live and writable" + else: + desc = ( + "this is a bounded snapshot of the user's directory, " + "isolated from the live host system" + ) + 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)" + f"{desc} — .git/.agents/.codex are read-only)" ) parts.append( "- No scan target was set. This directory is where you work, not a " diff --git a/strix/report/state.py b/strix/report/state.py index 623cb9e9..744521a8 100644 --- a/strix/report/state.py +++ b/strix/report/state.py @@ -24,6 +24,7 @@ from strix.report.writer import ( write_run_record, write_vulnerabilities, ) +from strix.runtime.backends import backend_supports_bind_mounts from strix.telemetry import posthog, scarf @@ -440,6 +441,17 @@ class ReportState: self.end_time = None self.scan_results = None self.final_scan_result = None + local_sources = config.get("local_sources", []) + if not local_sources: + transport = None + else: + backend_name = load_settings().runtime.backend + use_bind_mounts = ( + backend_supports_bind_mounts(backend_name) + and not load_settings().runtime.require_mount_free + ) + transport = "bind-mount" if use_bind_mounts else "mount-free" + self.run_record.update( { "targets_info": config.get("targets", []), @@ -450,6 +462,7 @@ class ReportState: "local_sources": config.get("local_sources", []), "scope_mode": config.get("scope_mode", "auto"), "diff_base": config.get("diff_base"), + "transport": transport, } ) diff --git a/strix/runtime/backends.py b/strix/runtime/backends.py index ec49f7a7..a5a4b8c4 100644 --- a/strix/runtime/backends.py +++ b/strix/runtime/backends.py @@ -55,6 +55,7 @@ _BACKENDS: dict[str, SandboxBackend] = { } _BIND_MOUNT_BACKENDS: set[str] = {"docker"} +_MOUNT_FREE_BACKENDS: set[str] = {"docker"} def get_backend(name: str) -> SandboxBackend: @@ -80,26 +81,43 @@ def register_backend( backend: SandboxBackend, *, supports_bind_mounts: bool = False, + supports_mount_free: bool | None = None, ) -> None: """Register a custom backend under ``name``. Intended for downstream users who ship their own runtime — register before any ``session_manager.create_or_reuse`` call. Re-registering - an existing name overwrites the prior entry. ``supports_bind_mounts`` - defaults to False: a remote runtime cannot see the caller's filesystem, so - it is handed local sources as manifest entries to upload instead. + an existing name overwrites the prior entry. Remote backends without bind + mounts default to mount-free support, while bind-mount backends must explicitly opt in. """ + if supports_mount_free is None: + supports_mount_free = not supports_bind_mounts _BACKENDS[name] = backend if supports_bind_mounts: _BIND_MOUNT_BACKENDS.add(name) else: _BIND_MOUNT_BACKENDS.discard(name) - logger.info("Registered sandbox backend: %s (bind mounts: %s)", name, supports_bind_mounts) + + if supports_mount_free: + _MOUNT_FREE_BACKENDS.add(name) + else: + _MOUNT_FREE_BACKENDS.discard(name) + + logger.info( + "Registered sandbox backend: %s (bind mounts: %s, mount-free: %s)", + name, + supports_bind_mounts, + supports_mount_free, + ) def backend_supports_bind_mounts(name: str) -> bool: return name in _BIND_MOUNT_BACKENDS +def backend_supports_mount_free(name: str) -> bool: + return name in _MOUNT_FREE_BACKENDS + + def supported_backends() -> list[str]: return sorted(_BACKENDS) diff --git a/strix/runtime/session_manager.py b/strix/runtime/session_manager.py index e8b3a279..94875a6d 100644 --- a/strix/runtime/session_manager.py +++ b/strix/runtime/session_manager.py @@ -9,12 +9,16 @@ import sys from pathlib import Path from typing import TYPE_CHECKING, Any -from agents.sandbox.entries import BaseEntry, File, LocalDir -from agents.sandbox.manifest import Environment, Manifest +from agents.sandbox.entries import BaseEntry, Dir, File, LocalDir +from agents.sandbox.manifest import Environment, Manifest, SandboxPathGrant from strix.config import load_settings from strix.core.paths import run_dir_for, runtime_state_dir -from strix.runtime.backends import backend_supports_bind_mounts, get_backend +from strix.runtime.backends import ( + backend_supports_bind_mounts, + backend_supports_mount_free, + get_backend, +) from strix.runtime.caido_bootstrap import bootstrap_caido from strix.runtime.caido_handle import CaidoBootstrapHandle @@ -65,6 +69,73 @@ def build_bind_mounts(local_sources: list[dict[str, Any]]) -> list[dict[str, Any return bind_mounts +def _symlink_safe_dir_entry( + root: Path, + *, + _source_root: Path | None = None, + _visited_dirs: set[Path] | None = None, +) -> Dir: + """Walk *root* recursively and build a ``Dir`` entry tree. + + Symlinks are resolved only when their real target stays inside *_source_root*. + Out-of-tree symlinks, dangling symlinks, and directory symlink loops are + skipped with a warning to preserve sandbox containment. + """ + + def _process_symlink( + item: Path, _source_root: Path, children: dict[str | Path, BaseEntry] + ) -> None: + try: + target = item.resolve() + except RuntimeError: + logger.warning("mount-free: skipping symlink loop at %s", item) + return + + if not target.exists(): + logger.warning("mount-free: skipping dangling symlink %s", item) + return + + try: + target.relative_to(_source_root) + except ValueError: + logger.warning("mount-free: skipping out-of-tree symlink %s -> %s", item, target) + return + + if target.is_dir(): + logger.warning("mount-free: skipping directory symlink %s -> %s", item, target) + elif target.is_file(): + try: + children[item.name] = File(content=target.read_bytes()) + except OSError: + logger.warning("mount-free: could not read symlink target %s -> %s", item, target) + + if _source_root is None: + _source_root = root.resolve() + if _visited_dirs is None: + _visited_dirs = set() + + real_root = root.resolve() + if real_root in _visited_dirs: + logger.warning("mount-free: skipping directory loop at %s", root) + return Dir(children={}) + _visited_dirs.add(real_root) + + children: dict[str | Path, BaseEntry] = {} + for item in sorted(root.iterdir()): + if item.is_symlink(): + _process_symlink(item, _source_root, children) + elif item.is_dir(): + children[item.name] = _symlink_safe_dir_entry( + item, _source_root=_source_root, _visited_dirs=_visited_dirs.copy() + ) + elif item.is_file(): + try: + children[item.name] = File(content=item.read_bytes()) + except OSError: + logger.warning("mount-free: could not read file %s", item) + return Dir(children=children) + + def build_manifest_entries(local_sources: list[dict[str, Any]]) -> dict[str | Path, BaseEntry]: entries: dict[str | Path, BaseEntry] = {} for src in local_sources: @@ -72,10 +143,27 @@ def build_manifest_entries(local_sources: list[dict[str, Any]]) -> dict[str | Pa host_path = src.get("source_path") or "" if not ws_subdir or not host_path: continue - entries[ws_subdir] = LocalDir(src=Path(host_path).expanduser().resolve()) + resolved = Path(host_path).expanduser().resolve() + has_symlinks = any(p.is_symlink() for p in resolved.rglob("*")) + if has_symlinks: + entries[ws_subdir] = _symlink_safe_dir_entry(resolved) + else: + entries[ws_subdir] = LocalDir(src=resolved) return entries +def build_manifest_grants(local_sources: list[dict[str, Any]]) -> list[SandboxPathGrant]: + grants: list[SandboxPathGrant] = [] + for src in local_sources: + ws_subdir = src.get("workspace_subdir") or "" + host_path = src.get("source_path") or "" + if not ws_subdir or not host_path: + continue + resolved = Path(host_path).expanduser().resolve() + grants.append(SandboxPathGrant(path=str(resolved), read_only=True)) + return grants + + def _extra_file_rel_path(workspace_path: str) -> str | None: """Validate an extra-file target path and return it relative to /workspace. @@ -279,10 +367,21 @@ async def create_or_reuse( backend_name = load_settings().runtime.backend backend = get_backend(backend_name) + require_mount_free = load_settings().runtime.require_mount_free + use_bind_mounts = backend_supports_bind_mounts(backend_name) - if backend_supports_bind_mounts(backend_name): + if require_mount_free: + if not backend_supports_mount_free(backend_name): + raise RuntimeError( + f"Sandbox backend {backend_name!r} does not support mount-free transport." + ) + logger.info("Mount-free transport required; disabling bind mounts.") + use_bind_mounts = False + + if use_bind_mounts: bind_mounts = build_bind_mounts(local_sources) entries: dict[str | Path, BaseEntry] = {} + grants: list[SandboxPathGrant] = [] if extra_files: staging_dir = runtime_state_dir(run_dir_for(scan_id)) / "extra_files" bind_mounts.extend( @@ -291,6 +390,7 @@ async def create_or_reuse( else: bind_mounts = [] entries = build_manifest_entries(local_sources) + grants = build_manifest_grants(local_sources) if extra_files: entries.update(build_extra_file_entries(extra_files, local_sources)) @@ -302,6 +402,7 @@ async def create_or_reuse( container_caido_url = f"http://127.0.0.1:{_CONTAINER_CAIDO_PORT}" manifest = Manifest( entries=entries, + extra_path_grants=tuple(grants), environment=Environment( value={ "PYTHONUNBUFFERED": "1", diff --git a/tests/test_mount_free.py b/tests/test_mount_free.py new file mode 100644 index 00000000..ebf93511 --- /dev/null +++ b/tests/test_mount_free.py @@ -0,0 +1,148 @@ +"""Tests for mount-free transport fail-closed semantics and symlink containment.""" + +from pathlib import Path +from typing import Any + +import pytest + +import strix.config.loader +from strix.runtime.backends import ( + _BACKENDS, + _BIND_MOUNT_BACKENDS, + _MOUNT_FREE_BACKENDS, + register_backend, +) +from strix.runtime.session_manager import _symlink_safe_dir_entry, create_or_reuse + + +async def _dummy_backend(*args: Any, **kwargs: Any) -> tuple[Any, Any]: + # ruff: noqa: ARG001 + return None, None + + +@pytest.mark.asyncio +async def test_mount_free_transport_refusal_raises_error(monkeypatch: pytest.MonkeyPatch) -> None: + try: + register_backend( + "stub_legacy", + _dummy_backend, + supports_bind_mounts=True, + supports_mount_free=False, + ) + + monkeypatch.setenv("STRIX_RUNTIME_BACKEND", "stub_legacy") + monkeypatch.setenv("STRIX_REQUIRE_MOUNT_FREE", "1") + monkeypatch.setattr(strix.config.loader, "_cached", None) + + with pytest.raises( + RuntimeError, + match="Sandbox backend 'stub_legacy' does not support mount-free transport", + ): + await create_or_reuse("scan_123", image="dummy_image", local_sources=[]) + finally: + _BACKENDS.pop("stub_legacy", None) + _BIND_MOUNT_BACKENDS.discard("stub_legacy") + _MOUNT_FREE_BACKENDS.discard("stub_legacy") + + +@pytest.mark.asyncio +async def test_mount_free_success_path(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + captured: dict[str, Any] = {} + + async def _stub_backend( + image: str, manifest: Any, exposed_ports: Any, bind_mounts: Any + ) -> tuple[Any, Any]: + captured["image"] = image + captured["manifest"] = manifest + captured["bind_mounts"] = bind_mounts + # ruff: noqa: ARG001 + + class FakeEndpoint: + host = "127.0.0.1" + port = 48080 + tls = False + + class FakeSession: + async def resolve_exposed_port(self, port: int) -> FakeEndpoint: + # ruff: noqa: ARG002 + return FakeEndpoint() + + return None, FakeSession() + + async def _fake_bootstrap_caido(*args: Any, **kwargs: Any) -> Any: + # ruff: noqa: ARG001 + return None + + try: + register_backend( + "stub_mount_free", + _stub_backend, + supports_bind_mounts=True, + supports_mount_free=True, + ) + + monkeypatch.setenv("STRIX_RUNTIME_BACKEND", "stub_mount_free") + monkeypatch.setenv("STRIX_REQUIRE_MOUNT_FREE", "1") + monkeypatch.setattr(strix.config.loader, "_cached", None) + monkeypatch.setattr("strix.runtime.session_manager.bootstrap_caido", _fake_bootstrap_caido) + + source_dir = tmp_path / "my_project" + source_dir.mkdir() + (source_dir / "app.py").write_text("print('test')") + + local_sources = [{"workspace_subdir": "repo", "source_path": str(source_dir)}] + bundle = await create_or_reuse( + "scan_success_123", image="dummy_image", local_sources=local_sources + ) + + assert bundle["session"] is not None + assert captured["bind_mounts"] == [] + assert "repo" in captured["manifest"].entries + + grants = captured["manifest"].extra_path_grants + assert len(grants) == 1 + assert str(grants[0].path) == str(source_dir.resolve()) + assert grants[0].read_only is True + finally: + _BACKENDS.pop("stub_mount_free", None) + _BIND_MOUNT_BACKENDS.discard("stub_mount_free") + _MOUNT_FREE_BACKENDS.discard("stub_mount_free") + + +def test_symlink_safe_dir_entry_skips_out_of_tree_symlinks(tmp_path: Path) -> None: + outside_dir = tmp_path / "outside" + outside_dir.mkdir() + secret_file = outside_dir / "id_rsa" + secret_file.write_bytes(b"SECRET_KEY_DATA") + + repo_dir = tmp_path / "repo" + repo_dir.mkdir() + (repo_dir / "app.py").write_text("print('hello')") + + # Create symlink pointing outside the source root tree + leak_symlink = repo_dir / "leak_key" + leak_symlink.symlink_to(secret_file) + + dir_entry = _symlink_safe_dir_entry(repo_dir) + + # Valid files are included, out-of-tree symlinks are skipped! + assert "app.py" in dir_entry.children + assert "leak_key" not in dir_entry.children + + +def test_symlink_safe_dir_entry_prevents_directory_symlink_loops(tmp_path: Path) -> None: + repo_dir = tmp_path / "repo" + repo_dir.mkdir() + (repo_dir / "main.py").write_text("import sys") + + sub_dir = repo_dir / "sub" + sub_dir.mkdir() + + # Create directory symlink loop (sub/up -> repo) + (sub_dir / "up").symlink_to(repo_dir, target_is_directory=True) + + # Must complete without RecursionError or infinite loop + dir_entry = _symlink_safe_dir_entry(repo_dir) + + assert "main.py" in dir_entry.children + assert "sub" in dir_entry.children