mirror of
https://github.com/usestrix/strix.git
synced 2026-09-06 08:15:56 +00:00
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:
parent
d39735ba06
commit
0c69014f05
6 changed files with 137 additions and 9 deletions
|
|
@ -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")
|
||||
|
||||
|
|
|
|||
|
|
@ -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 "
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
33
tests/test_mount_free.py
Normal 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=[]))
|
||||
Loading…
Add table
Reference in a new issue