This commit is contained in:
Manideep Malyala 2026-08-27 17:33:35 -05:00 committed by GitHub
commit ae7784a92a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 330 additions and 13 deletions

View file

@ -124,6 +124,10 @@ When remote vars are set, Strix dual-writes telemetry to both local JSONL and th
## Sandbox Configuration
<ParamField path="STRIX_REQUIRE_MOUNT_FREE" default="0" type="string">
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.
</ParamField>
<ParamField path="STRIX_SANDBOX_EXECUTION_TIMEOUT" default="120" type="integer">
Maximum execution time in seconds for sandbox operations.
</ParamField>

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

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

View file

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

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

View file

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

148
tests/test_mount_free.py Normal file
View file

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