mirror of
https://github.com/usestrix/strix.git
synced 2026-08-28 05:25:00 +00:00
Merge be004af52e into 717ffc8f4c
This commit is contained in:
commit
ae7784a92a
7 changed files with 330 additions and 13 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
||||
|
|
|
|||
|
|
@ -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 "
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
148
tests/test_mount_free.py
Normal 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
|
||||
Loading…
Add table
Reference in a new issue