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