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
This commit is contained in:
Manideep Malyala 2026-08-18 06:02:42 +05:30
parent d39735ba06
commit 0c69014f05
6 changed files with 137 additions and 9 deletions

View file

@ -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")

View file

@ -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 "

View file

@ -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,
}
)

View file

@ -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)

View file

@ -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:

33
tests/test_mount_free.py Normal file
View file

@ -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=[]))