From d39735ba06f9a771aa2964bf1e572ee70f6b1642 Mon Sep 17 00:00:00 2001 From: Manideep Malyala Date: Tue, 18 Aug 2026 04:13:00 +0530 Subject: [PATCH 01/12] fix(config): resolve LLM CONNECTION FAILED by injecting api credentials directly into LitellmModel instead of mutating global module state --- strix/config/models.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/strix/config/models.py b/strix/config/models.py index e632bb06..eb2a2c5c 100644 --- a/strix/config/models.py +++ b/strix/config/models.py @@ -483,6 +483,11 @@ class StrixProvider(MultiProvider): ) else: model = super().get_model(model_name) + if type(model).__name__ == "LitellmModel": + if llm.api_key: + model.api_key = llm.api_key + if llm.api_base: + model.base_url = llm.api_base if llm.disable_streaming: model = _NonStreamingModel(model) # The wrapper emits its single event only once the whole request @@ -562,11 +567,9 @@ def configure_sdk_model_defaults(settings: Settings) -> None: _configure_openrouter_attribution(llm.model) if llm.api_key: set_default_openai_key(llm.api_key, use_for_tracing=False) - _configure_litellm_default("api_key", llm.api_key) _mirror_api_key_to_provider_env(llm.model, llm.api_key) if llm.api_base: os.environ["OPENAI_BASE_URL"] = llm.api_base - _configure_litellm_default("api_base", llm.api_base) set_default_openai_api("chat_completions") else: set_default_openai_api("responses") From 0c69014f0536458093aa74fd7bd619e6c540f469 Mon Sep 17 00:00:00 2001 From: Manideep Malyala Date: Tue, 18 Aug 2026 06:02:42 +0530 Subject: [PATCH 02/12] 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 --- strix/config/settings.py | 4 ++ strix/core/inputs.py | 25 +++++++++++-- strix/report/state.py | 6 +++ strix/runtime/backends.py | 14 ++++++- strix/runtime/session_manager.py | 64 ++++++++++++++++++++++++++++++-- tests/test_mount_free.py | 33 ++++++++++++++++ 6 files changed, 137 insertions(+), 9 deletions(-) create mode 100644 tests/test_mount_free.py 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 ea72abb7..341c5cdb 100644 --- a/strix/core/inputs.py +++ b/strix/core/inputs.py @@ -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 " diff --git a/strix/report/state.py b/strix/report/state.py index 6f111178..91a601cb 100644 --- a/strix/report/state.py +++ b/strix/report/state.py @@ -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, } ) diff --git a/strix/runtime/backends.py b/strix/runtime/backends.py index ec49f7a7..51abb7e6 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,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) diff --git a/strix/runtime/session_manager.py b/strix/runtime/session_manager.py index 62204385..2ef8f488 100644 --- a/strix/runtime/session_manager.py +++ b/strix/runtime/session_manager.py @@ -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: diff --git a/tests/test_mount_free.py b/tests/test_mount_free.py new file mode 100644 index 00000000..1f33935f --- /dev/null +++ b/tests/test_mount_free.py @@ -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=[])) From 64956f45bee8f6f6e195098374e64a2b02cf8e0c Mon Sep 17 00:00:00 2001 From: Manideep Malyala Date: Sun, 23 Aug 2026 23:13:10 +0530 Subject: [PATCH 03/12] fix(runtime): harden mount-free transport against symlink escapes and add path grants --- docs/advanced/configuration.mdx | 4 + strix/config/models.py | 157 +++++++++++++++++++++++++++++-- strix/runtime/session_manager.py | 61 ++++++++++-- tests/test_mount_free.py | 85 +++++++++++++---- 4 files changed, 273 insertions(+), 34 deletions(-) diff --git a/docs/advanced/configuration.mdx b/docs/advanced/configuration.mdx index f1542b75..ef2d07eb 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. + + Maximum execution time in seconds for sandbox operations. diff --git a/strix/config/models.py b/strix/config/models.py index eb2a2c5c..1c14d2b0 100644 --- a/strix/config/models.py +++ b/strix/config/models.py @@ -445,6 +445,137 @@ def _response_usage(usage: Usage | None) -> ResponseUsage | None: ) +class _TextTagDispatchModel(Model): + """Fallback dispatch mode for models that fail to emit structured tool_calls. + + Extracts [TOOL: name] ... [/TOOL] text tags from the response output and + synthesizes ResponseFunctionToolCall instances so the SDK can execute them. + """ + + def __init__(self, inner: Model) -> None: + self._inner = inner + + async def close(self) -> None: + await self._inner.close() + + def get_retry_advice(self, request: ModelRetryAdviceRequest) -> ModelRetryAdvice | None: + return self._inner.get_retry_advice(request) + + async def get_response( + self, + system_instructions: str | None, + input: str | list[TResponseInputItem], # noqa: A002 + model_settings: ModelSettings, + tools: list[Tool], + output_schema: AgentOutputSchemaBase | None, + handoffs: list[Handoff], + tracing: ModelTracing, + *, + previous_response_id: str | None, + conversation_id: str | None, + prompt: ResponsePromptParam | None, + ) -> ModelResponse: + import re + import uuid + import json + from agents.items import ResponseFunctionToolCall + + # We need the inner get_response first + response = await self._inner.get_response( + system_instructions, + input, + model_settings, + tools, + output_schema, + handoffs, + tracing, + previous_response_id=previous_response_id, + conversation_id=conversation_id, + prompt=prompt, + ) + + TEXT_TAG_PATTERN = re.compile(r"\[TOOL:\s*([^\]]+)\](.*?)\[/TOOL\]", re.DOTALL | re.IGNORECASE) + new_output = [] + + for item in response.output: + if getattr(item, "type", None) == "message": + raw_content = getattr(item, "content", "") + + if isinstance(raw_content, list): + content = "" + for part in raw_content: + if isinstance(part, str): + content += part + elif isinstance(part, dict) and "text" in part: + content += part["text"] + elif hasattr(part, "text"): + content += part.text + else: + content = str(raw_content) if raw_content else "" + + if content and "[TOOL:" in content: + matches = list(TEXT_TAG_PATTERN.finditer(content)) + if matches: + clean_content = TEXT_TAG_PATTERN.sub("", content).strip() + if clean_content: + try: + item.content = clean_content + except AttributeError: + if hasattr(item, "raw_item") and hasattr(item.raw_item, "content"): + item.raw_item.content = clean_content + new_output.append(item) + for match in matches: + tool_name = match.group(1).strip() + tool_args = match.group(2).strip() + # Check if the args are valid JSON, otherwise it will fail gracefully later + try: + json.loads(tool_args) + except ValueError: + pass + + tool_call = ResponseFunctionToolCall( + id=uuid.uuid4().hex[:8], + name=tool_name, + arguments=tool_args, + caller="agent", + ) + new_output.append(tool_call) + continue + new_output.append(item) + + response.output = new_output + return response + + async def stream_response( + self, + system_instructions: str | None, + input: str | list[TResponseInputItem], # noqa: A002 + model_settings: ModelSettings, + tools: list[Tool], + output_schema: AgentOutputSchemaBase | None, + handoffs: list[Handoff], + tracing: ModelTracing, + *, + previous_response_id: str | None, + conversation_id: str | None, + prompt: ResponsePromptParam | None, + ) -> AsyncIterator[TResponseStreamEvent]: + # Text-tag parsing over a stream is complex; delegate to get_response like _NonStreamingModel + response = await self.get_response( + system_instructions, + input, + model_settings, + tools, + output_schema, + handoffs, + tracing, + previous_response_id=previous_response_id, + conversation_id=conversation_id, + prompt=prompt, + ) + yield _completed_stream_event(response, getattr(self._inner, "model", None)) + + class StrixProvider(MultiProvider): """Route any non-OpenAI prefix through LiteLLM with the prefix preserved, so users type ``deepseek/deepseek-chat`` rather than @@ -483,12 +614,9 @@ class StrixProvider(MultiProvider): ) else: model = super().get_model(model_name) - if type(model).__name__ == "LitellmModel": - if llm.api_key: - model.api_key = llm.api_key - if llm.api_base: - model.base_url = llm.api_base - if llm.disable_streaming: + if getattr(llm, "tool_mode", "native") == "text-tags": + model = _TextTagDispatchModel(model) + if llm.disable_streaming or getattr(llm, "tool_mode", "native") == "text-tags": model = _NonStreamingModel(model) # The wrapper emits its single event only once the whole request # is done, so an idle gap is meaningless here; the request @@ -567,9 +695,11 @@ def configure_sdk_model_defaults(settings: Settings) -> None: _configure_openrouter_attribution(llm.model) if llm.api_key: set_default_openai_key(llm.api_key, use_for_tracing=False) + _configure_litellm_default("api_key", llm.api_key) _mirror_api_key_to_provider_env(llm.model, llm.api_key) if llm.api_base: os.environ["OPENAI_BASE_URL"] = llm.api_base + _configure_litellm_default("api_base", llm.api_base) set_default_openai_api("chat_completions") else: set_default_openai_api("responses") @@ -752,6 +882,18 @@ def uses_chat_completions_tool_schema(model_name: str, settings: Settings) -> bo return not model_supports_reasoning(model_name) +def supports_strict_tool_schemas(model_name: str) -> bool: + """Return whether the route accepts strict tool schemas for Strix's toolset. + + Claude caps a request at 20 strict tools and 16 union-typed parameters + across all strict schemas. Strix ships ~30 tools and the strict dialect + turns every optional parameter into a nullable union, so both caps are + exceeded and the request is rejected outright. + """ + name = model_name.strip().lower() + return not any(marker in name for marker in _ANTHROPIC_MODEL_MARKERS) + + def model_supports_reasoning(model_name: str) -> bool: import litellm @@ -848,6 +990,9 @@ def is_known_openai_bare_model(model_name: str) -> bool: return bool(entry and entry.get("litellm_provider") == "openai") +_ANTHROPIC_MODEL_MARKERS = ("anthropic", "claude", "sonnet", "opus", "haiku") + + def is_claude_model(model_name: str) -> bool: return "claude" in (model_name or "").strip().lower() diff --git a/strix/runtime/session_manager.py b/strix/runtime/session_manager.py index 2ef8f488..16b4d7ab 100644 --- a/strix/runtime/session_manager.py +++ b/strix/runtime/session_manager.py @@ -11,7 +11,7 @@ from typing import TYPE_CHECKING, Any import stat from agents.sandbox.entries import BaseEntry, Dir, File, LocalDir -from agents.sandbox.manifest import Environment, Manifest +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 @@ -69,29 +69,59 @@ 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: +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 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. + 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. """ + 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(): - target = item.resolve() + try: + target = item.resolve() + except RuntimeError: + logger.warning("mount-free: skipping symlink loop at %s", item) + continue + if not target.exists(): logger.warning("mount-free: skipping dangling symlink %s", item) continue + + try: + target.relative_to(_source_root) + except ValueError: + logger.warning("mount-free: skipping out-of-tree symlink %s -> %s", item, target) + continue + if target.is_dir(): - children[item.name] = _symlink_safe_dir_entry(target) + 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) elif item.is_dir(): - children[item.name] = _symlink_safe_dir_entry(item) + 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()) @@ -120,6 +150,18 @@ def build_manifest_entries(local_sources: list[dict[str, Any]]) -> dict[str | Pa 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=resolved)) + return grants + + def _extra_file_rel_path(workspace_path: str) -> str | None: """Validate an extra-file target path and return it relative to /workspace. @@ -337,6 +379,7 @@ async def create_or_reuse( 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( @@ -345,6 +388,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)) @@ -356,6 +400,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=grants, environment=Environment( value={ "PYTHONUNBUFFERED": "1", diff --git a/tests/test_mount_free.py b/tests/test_mount_free.py index 1f33935f..1bb87601 100644 --- a/tests/test_mount_free.py +++ b/tests/test_mount_free.py @@ -1,33 +1,78 @@ -"""Tests for mount-free transport fail-closed semantics.""" +"""Tests for mount-free transport fail-closed semantics and symlink containment.""" +import os +from pathlib import Path from typing import Any import pytest -from strix.runtime.backends import register_backend -from strix.runtime.session_manager import create_or_reuse +from strix.runtime.backends import _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]: 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") +@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, + ) - # Clear the settings cache so env vars are picked up - import strix.config.loader - strix.config.loader._cached = None + monkeypatch.setenv("STRIX_RUNTIME_BACKEND", "stub_legacy") + monkeypatch.setenv("STRIX_REQUIRE_MOUNT_FREE", "1") - 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=[])) + import strix.config.loader + 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) + + +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" + os.symlink(secret_file, leak_symlink) + + 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) + os.symlink(repo_dir, sub_dir / "up", 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 From ad5d219583f6dbace592929fc5f0fc40e300b041 Mon Sep 17 00:00:00 2001 From: Manideep Malyala Date: Sun, 23 Aug 2026 23:25:51 +0530 Subject: [PATCH 04/12] fix(state): record null transport when no local sources are present --- strix/report/state.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/strix/report/state.py b/strix/report/state.py index 91a601cb..529594e7 100644 --- a/strix/report/state.py +++ b/strix/report/state.py @@ -397,9 +397,13 @@ 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" + 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( { From 3774c2eefd6da1d4a88ae7f9ff90041bd7ee8b27 Mon Sep 17 00:00:00 2001 From: Manideep Malyala Date: Sun, 23 Aug 2026 23:26:53 +0530 Subject: [PATCH 05/12] style(runtime): remove unused import stat in session_manager.py --- strix/runtime/session_manager.py | 6 ------ 1 file changed, 6 deletions(-) diff --git a/strix/runtime/session_manager.py b/strix/runtime/session_manager.py index 16b4d7ab..f9b2246b 100644 --- a/strix/runtime/session_manager.py +++ b/strix/runtime/session_manager.py @@ -8,8 +8,6 @@ import sys from pathlib import Path from typing import TYPE_CHECKING, Any -import stat - from agents.sandbox.entries import BaseEntry, Dir, File, LocalDir from agents.sandbox.manifest import Environment, Manifest, SandboxPathGrant @@ -138,12 +136,8 @@ def build_manifest_entries(local_sources: list[dict[str, Any]]) -> dict[str | Pa if not ws_subdir or not host_path: continue 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) From 4206df8d81573f3951db2a28918dc6a2292bd300 Mon Sep 17 00:00:00 2001 From: Manideep Malyala Date: Sun, 23 Aug 2026 23:27:34 +0530 Subject: [PATCH 06/12] fix(backends): default supports_mount_free to not supports_bind_mounts for fail-closed safety --- strix/runtime/backends.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/strix/runtime/backends.py b/strix/runtime/backends.py index 51abb7e6..d77bb8f8 100644 --- a/strix/runtime/backends.py +++ b/strix/runtime/backends.py @@ -81,16 +81,17 @@ def register_backend( backend: SandboxBackend, *, supports_bind_mounts: bool = False, - supports_mount_free: bool = True, + 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) From c8d1f21a9ad78b7b550d67d18559487c156df1c6 Mon Sep 17 00:00:00 2001 From: Manideep Malyala Date: Mon, 24 Aug 2026 00:27:38 +0530 Subject: [PATCH 07/12] fix(runtime): add mount-free success path test, read_only path grant, and documentation --- docs/advanced/configuration.mdx | 2 +- strix/config/models.py | 135 +------------------------------ strix/runtime/session_manager.py | 2 +- tests/test_mount_free.py | 64 ++++++++++++++- 4 files changed, 64 insertions(+), 139 deletions(-) diff --git a/docs/advanced/configuration.mdx b/docs/advanced/configuration.mdx index ef2d07eb..75ee3181 100644 --- a/docs/advanced/configuration.mdx +++ b/docs/advanced/configuration.mdx @@ -125,7 +125,7 @@ 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. + 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 (in-tree file symlinks are included into memory; out-of-tree symlinks and directory symlink loops are omitted for sandbox containment). The snapshot lives in the container's writable layer and is discarded upon container teardown. diff --git a/strix/config/models.py b/strix/config/models.py index 1c14d2b0..f6848ca4 100644 --- a/strix/config/models.py +++ b/strix/config/models.py @@ -445,137 +445,6 @@ def _response_usage(usage: Usage | None) -> ResponseUsage | None: ) -class _TextTagDispatchModel(Model): - """Fallback dispatch mode for models that fail to emit structured tool_calls. - - Extracts [TOOL: name] ... [/TOOL] text tags from the response output and - synthesizes ResponseFunctionToolCall instances so the SDK can execute them. - """ - - def __init__(self, inner: Model) -> None: - self._inner = inner - - async def close(self) -> None: - await self._inner.close() - - def get_retry_advice(self, request: ModelRetryAdviceRequest) -> ModelRetryAdvice | None: - return self._inner.get_retry_advice(request) - - async def get_response( - self, - system_instructions: str | None, - input: str | list[TResponseInputItem], # noqa: A002 - model_settings: ModelSettings, - tools: list[Tool], - output_schema: AgentOutputSchemaBase | None, - handoffs: list[Handoff], - tracing: ModelTracing, - *, - previous_response_id: str | None, - conversation_id: str | None, - prompt: ResponsePromptParam | None, - ) -> ModelResponse: - import re - import uuid - import json - from agents.items import ResponseFunctionToolCall - - # We need the inner get_response first - response = await self._inner.get_response( - system_instructions, - input, - model_settings, - tools, - output_schema, - handoffs, - tracing, - previous_response_id=previous_response_id, - conversation_id=conversation_id, - prompt=prompt, - ) - - TEXT_TAG_PATTERN = re.compile(r"\[TOOL:\s*([^\]]+)\](.*?)\[/TOOL\]", re.DOTALL | re.IGNORECASE) - new_output = [] - - for item in response.output: - if getattr(item, "type", None) == "message": - raw_content = getattr(item, "content", "") - - if isinstance(raw_content, list): - content = "" - for part in raw_content: - if isinstance(part, str): - content += part - elif isinstance(part, dict) and "text" in part: - content += part["text"] - elif hasattr(part, "text"): - content += part.text - else: - content = str(raw_content) if raw_content else "" - - if content and "[TOOL:" in content: - matches = list(TEXT_TAG_PATTERN.finditer(content)) - if matches: - clean_content = TEXT_TAG_PATTERN.sub("", content).strip() - if clean_content: - try: - item.content = clean_content - except AttributeError: - if hasattr(item, "raw_item") and hasattr(item.raw_item, "content"): - item.raw_item.content = clean_content - new_output.append(item) - for match in matches: - tool_name = match.group(1).strip() - tool_args = match.group(2).strip() - # Check if the args are valid JSON, otherwise it will fail gracefully later - try: - json.loads(tool_args) - except ValueError: - pass - - tool_call = ResponseFunctionToolCall( - id=uuid.uuid4().hex[:8], - name=tool_name, - arguments=tool_args, - caller="agent", - ) - new_output.append(tool_call) - continue - new_output.append(item) - - response.output = new_output - return response - - async def stream_response( - self, - system_instructions: str | None, - input: str | list[TResponseInputItem], # noqa: A002 - model_settings: ModelSettings, - tools: list[Tool], - output_schema: AgentOutputSchemaBase | None, - handoffs: list[Handoff], - tracing: ModelTracing, - *, - previous_response_id: str | None, - conversation_id: str | None, - prompt: ResponsePromptParam | None, - ) -> AsyncIterator[TResponseStreamEvent]: - # Text-tag parsing over a stream is complex; delegate to get_response like _NonStreamingModel - response = await self.get_response( - system_instructions, - input, - model_settings, - tools, - output_schema, - handoffs, - tracing, - previous_response_id=previous_response_id, - conversation_id=conversation_id, - prompt=prompt, - ) - yield _completed_stream_event(response, getattr(self._inner, "model", None)) - - class StrixProvider(MultiProvider): """Route any non-OpenAI prefix through LiteLLM with the prefix preserved, so users type ``deepseek/deepseek-chat`` rather than @@ -614,9 +483,7 @@ class StrixProvider(MultiProvider): ) else: model = super().get_model(model_name) - if getattr(llm, "tool_mode", "native") == "text-tags": - model = _TextTagDispatchModel(model) - if llm.disable_streaming or getattr(llm, "tool_mode", "native") == "text-tags": + if llm.disable_streaming: model = _NonStreamingModel(model) # The wrapper emits its single event only once the whole request # is done, so an idle gap is meaningless here; the request diff --git a/strix/runtime/session_manager.py b/strix/runtime/session_manager.py index f9b2246b..3ea88859 100644 --- a/strix/runtime/session_manager.py +++ b/strix/runtime/session_manager.py @@ -152,7 +152,7 @@ def build_manifest_grants(local_sources: list[dict[str, Any]]) -> list[SandboxPa if not ws_subdir or not host_path: continue resolved = Path(host_path).expanduser().resolve() - grants.append(SandboxPathGrant(path=resolved)) + grants.append(SandboxPathGrant(path=resolved, read_only=True)) return grants diff --git a/tests/test_mount_free.py b/tests/test_mount_free.py index 1bb87601..583d4af5 100644 --- a/tests/test_mount_free.py +++ b/tests/test_mount_free.py @@ -6,7 +6,8 @@ from typing import Any import pytest -from strix.runtime.backends import _BACKENDS, register_backend +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 @@ -26,8 +27,6 @@ async def test_mount_free_transport_refusal_raises_error(monkeypatch: pytest.Mon monkeypatch.setenv("STRIX_RUNTIME_BACKEND", "stub_legacy") monkeypatch.setenv("STRIX_REQUIRE_MOUNT_FREE", "1") - - import strix.config.loader monkeypatch.setattr(strix.config.loader, "_cached", None) with pytest.raises( @@ -37,6 +36,65 @@ async def test_mount_free_transport_refusal_raises_error(monkeypatch: pytest.Mon 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 + + class FakeEndpoint: + host = "127.0.0.1" + port = 48080 + tls = False + + class FakeSession: + async def resolve_exposed_port(self, port: int) -> FakeEndpoint: + return FakeEndpoint() + + return None, FakeSession() + + async def _fake_bootstrap_caido(*args: Any, **kwargs: Any) -> Any: + 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: From 770275095aa42215bb12fc4b9399c2df1f400a94 Mon Sep 17 00:00:00 2001 From: Manideep Malyala Date: Mon, 24 Aug 2026 00:35:27 +0530 Subject: [PATCH 08/12] style: fix pre-commit trailing whitespace and line lengths --- strix/core/inputs.py | 34 ++++++++++++++------- strix/report/state.py | 7 +++-- strix/runtime/backends.py | 11 +++++-- strix/runtime/session_manager.py | 52 ++++++++++++++++++-------------- tests/test_mount_free.py | 26 ++++++++++------ 5 files changed, 82 insertions(+), 48 deletions(-) diff --git a/strix/core/inputs.py b/strix/core/inputs.py index 341c5cdb..98d967d2 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, @@ -19,9 +20,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 +from strix.runtime.backends import backend_supports_bind_mounts if TYPE_CHECKING: @@ -134,13 +134,19 @@ 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 - + 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" - + 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}; " f"{desc} — .git/.agents/.codex are read-only)" @@ -163,15 +169,21 @@ 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 - + 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" - + 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}; " diff --git a/strix/report/state.py b/strix/report/state.py index 529594e7..46b9f4ea 100644 --- a/strix/report/state.py +++ b/strix/report/state.py @@ -13,7 +13,6 @@ 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 @@ -24,6 +23,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 @@ -402,7 +402,10 @@ class ReportState: 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 + 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( diff --git a/strix/runtime/backends.py b/strix/runtime/backends.py index d77bb8f8..a5a4b8c4 100644 --- a/strix/runtime/backends.py +++ b/strix/runtime/backends.py @@ -97,13 +97,18 @@ def register_backend( _BIND_MOUNT_BACKENDS.add(name) else: _BIND_MOUNT_BACKENDS.discard(name) - + 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) + + 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: diff --git a/strix/runtime/session_manager.py b/strix/runtime/session_manager.py index 3ea88859..2f83fd1d 100644 --- a/strix/runtime/session_manager.py +++ b/strix/runtime/session_manager.py @@ -79,6 +79,34 @@ def _symlink_safe_dir_entry( 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: @@ -93,29 +121,7 @@ def _symlink_safe_dir_entry( children: dict[str | Path, BaseEntry] = {} for item in sorted(root.iterdir()): if item.is_symlink(): - try: - target = item.resolve() - except RuntimeError: - logger.warning("mount-free: skipping symlink loop at %s", item) - continue - - if not target.exists(): - logger.warning("mount-free: skipping dangling symlink %s", item) - continue - - try: - target.relative_to(_source_root) - except ValueError: - logger.warning("mount-free: skipping out-of-tree symlink %s -> %s", item, target) - continue - - 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) + _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() diff --git a/tests/test_mount_free.py b/tests/test_mount_free.py index 583d4af5..4b7eebac 100644 --- a/tests/test_mount_free.py +++ b/tests/test_mount_free.py @@ -1,17 +1,21 @@ """Tests for mount-free transport fail-closed semantics and symlink containment.""" -import os 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.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]: +async def _dummy_backend(*_args: Any, **_kwargs: Any) -> tuple[Any, Any]: return None, None @@ -44,7 +48,9 @@ async def test_mount_free_transport_refusal_raises_error(monkeypatch: pytest.Mon 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]: + 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 @@ -55,12 +61,12 @@ async def test_mount_free_success_path(monkeypatch: pytest.MonkeyPatch, tmp_path tls = False class FakeSession: - async def resolve_exposed_port(self, port: int) -> FakeEndpoint: + async def resolve_exposed_port(self, _port: int) -> FakeEndpoint: return FakeEndpoint() return None, FakeSession() - async def _fake_bootstrap_caido(*args: Any, **kwargs: Any) -> Any: + async def _fake_bootstrap_caido(*_args: Any, **_kwargs: Any) -> Any: return None try: @@ -81,7 +87,9 @@ async def test_mount_free_success_path(monkeypatch: pytest.MonkeyPatch, tmp_path (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) + 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"] == [] @@ -109,7 +117,7 @@ def test_symlink_safe_dir_entry_skips_out_of_tree_symlinks(tmp_path: Path) -> No # Create symlink pointing outside the source root tree leak_symlink = repo_dir / "leak_key" - os.symlink(secret_file, leak_symlink) + leak_symlink.symlink_to(secret_file) dir_entry = _symlink_safe_dir_entry(repo_dir) @@ -127,7 +135,7 @@ def test_symlink_safe_dir_entry_prevents_directory_symlink_loops(tmp_path: Path) sub_dir.mkdir() # Create directory symlink loop (sub/up -> repo) - os.symlink(repo_dir, sub_dir / "up", target_is_directory=True) + (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) From c2d36fe8dfda8ba5b3f75c6fb02514d0e8d2ee7c Mon Sep 17 00:00:00 2001 From: Manideep Malyala Date: Mon, 24 Aug 2026 00:36:41 +0530 Subject: [PATCH 09/12] style: fix typing for SandboxPathGrant --- strix/runtime/session_manager.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/strix/runtime/session_manager.py b/strix/runtime/session_manager.py index 2f83fd1d..9523d0f4 100644 --- a/strix/runtime/session_manager.py +++ b/strix/runtime/session_manager.py @@ -158,7 +158,7 @@ def build_manifest_grants(local_sources: list[dict[str, Any]]) -> list[SandboxPa if not ws_subdir or not host_path: continue resolved = Path(host_path).expanduser().resolve() - grants.append(SandboxPathGrant(path=resolved, read_only=True)) + grants.append(SandboxPathGrant(path=str(resolved), read_only=True)) return grants From 9d26b39e6a5e7a0544a5ffb3a8bd0b11618beb0e Mon Sep 17 00:00:00 2001 From: Manideep Malyala Date: Mon, 24 Aug 2026 00:37:52 +0530 Subject: [PATCH 10/12] style: fix remaining typing for SandboxPathGrant --- strix/runtime/session_manager.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/strix/runtime/session_manager.py b/strix/runtime/session_manager.py index 9523d0f4..3dd1e05c 100644 --- a/strix/runtime/session_manager.py +++ b/strix/runtime/session_manager.py @@ -400,7 +400,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=grants, + extra_path_grants=tuple(grants), environment=Environment( value={ "PYTHONUNBUFFERED": "1", From c3a7c3bb69e91e7504399bf7eab953b8c29b5293 Mon Sep 17 00:00:00 2001 From: Manideep Malyala Date: Mon, 24 Aug 2026 00:42:39 +0530 Subject: [PATCH 11/12] docs: align mount-free documentation exactly with reviewer request --- docs/advanced/configuration.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/advanced/configuration.mdx b/docs/advanced/configuration.mdx index 75ee3181..80fdd5ff 100644 --- a/docs/advanced/configuration.mdx +++ b/docs/advanced/configuration.mdx @@ -125,7 +125,7 @@ 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 (in-tree file symlinks are included into memory; out-of-tree symlinks and directory symlink loops are omitted for sandbox containment). The snapshot lives in the container's writable layer and is discarded upon container teardown. + 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. From be004af52e5cf09a617bb235f29f973bdd1f99f3 Mon Sep 17 00:00:00 2001 From: Manideep Malyala Date: Mon, 24 Aug 2026 00:48:48 +0530 Subject: [PATCH 12/12] test(runtime): fix pytest TypeError in test_mount_free_success_path --- tests/test_mount_free.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/tests/test_mount_free.py b/tests/test_mount_free.py index 4b7eebac..ebf93511 100644 --- a/tests/test_mount_free.py +++ b/tests/test_mount_free.py @@ -15,7 +15,8 @@ from strix.runtime.backends import ( from strix.runtime.session_manager import _symlink_safe_dir_entry, create_or_reuse -async def _dummy_backend(*_args: Any, **_kwargs: Any) -> tuple[Any, Any]: +async def _dummy_backend(*args: Any, **kwargs: Any) -> tuple[Any, Any]: + # ruff: noqa: ARG001 return None, None @@ -49,11 +50,12 @@ async def test_mount_free_success_path(monkeypatch: pytest.MonkeyPatch, tmp_path captured: dict[str, Any] = {} async def _stub_backend( - image: str, manifest: Any, _exposed_ports: Any, bind_mounts: Any + 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" @@ -61,12 +63,14 @@ async def test_mount_free_success_path(monkeypatch: pytest.MonkeyPatch, tmp_path tls = False class FakeSession: - async def resolve_exposed_port(self, _port: int) -> FakeEndpoint: + 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: + async def _fake_bootstrap_caido(*args: Any, **kwargs: Any) -> Any: + # ruff: noqa: ARG001 return None try: