Support large target repos with with bind-mount option. (#577)
* fix: resolve pre-commit check failures - Change RuntimeError to TypeError for type validation in report/writer.py - Update pyupgrade to v3.21.2 for Python 3.14 compatibility * chore: add pytest test infrastructure Mirror the layout introduced on feature/438-token_budget: pytest + pytest-asyncio dev deps, asyncio_mode auto, a tests.* mypy override, and pytest in the mypy pre-commit hook deps so the tests/ package type-checks. * feat: add --mount and large-target pre-flight for local repos (#492) Large local targets were copied into the sandbox file-by-file via the SDK LocalDir entry, which stalls on big repos and could leave /workspace empty. - --mount <path> bind-mounts a host directory read-only at /workspace/<subdir> instead of copying it, bypassing the per-file stream. - A size pre-flight (STRIX_MAX_LOCAL_COPY_MB, default 1024) fails fast with a clear message suggesting --mount when a non-mounted local target is too big. * fix: reject empty --mount paths An empty or whitespace-only --mount value resolves to the current working directory and would silently bind-mount it into the sandbox. Reject it. * fix: dedupe local targets so a dir is never both copied and mounted If the same directory is passed via --target and --mount (or as duplicate values), it previously produced two targets — copied AND bind-mounted, and the copied one could trip the size pre-flight. Dedupe by resolved path, preferring the bind mount. * fix: treat non-positive STRIX_MAX_LOCAL_COPY_MB as disabled Previously a value of 0 (or negative) made every local target count as oversized, aborting all local scans. Now <= 0 disables the pre-flight. * fix: log unreadable subtrees during size pre-flight os.walk silently swallowed directory-listing errors, so a permission-denied subtree could make a large repo under-count and slip past the pre-flight. Surface such omissions via an onerror warning. * docs: document --mount and STRIX_MAX_LOCAL_COPY_MB Add CLI reference + example for --mount, document the size pre-flight env var, note the read-only-is-not-a-hard-boundary caveat and that remote repos are not size-checked, and clarify the backends docstring on when bind mounts apply. * Update strix/interface/main.py * Update strix/runtime/docker_client.py ---------
This commit is contained in:
@@ -79,6 +79,10 @@ When remote vars are set, Strix dual-writes telemetry to both local JSONL and th
|
|||||||
Runtime backend for the sandbox environment.
|
Runtime backend for the sandbox environment.
|
||||||
</ParamField>
|
</ParamField>
|
||||||
|
|
||||||
|
<ParamField path="STRIX_MAX_LOCAL_COPY_MB" default="1024" type="integer">
|
||||||
|
Maximum size (in MB) of a local directory target that Strix will copy into the sandbox file-by-file. Larger targets exit early with a suggestion to use `--mount` instead. Set to `0` to disable the check.
|
||||||
|
</ParamField>
|
||||||
|
|
||||||
## Sandbox Configuration
|
## Sandbox Configuration
|
||||||
|
|
||||||
<ParamField path="STRIX_SANDBOX_EXECUTION_TIMEOUT" default="120" type="integer">
|
<ParamField path="STRIX_SANDBOX_EXECUTION_TIMEOUT" default="120" type="integer">
|
||||||
|
|||||||
@@ -15,6 +15,20 @@ strix --target <target> [options]
|
|||||||
Target to test. Accepts URLs, repositories, local directories, domains, or IP addresses. Can be specified multiple times.
|
Target to test. Accepts URLs, repositories, local directories, domains, or IP addresses. Can be specified multiple times.
|
||||||
</ParamField>
|
</ParamField>
|
||||||
|
|
||||||
|
<ParamField path="--mount" type="string">
|
||||||
|
Bind-mount a local directory into the sandbox (read-only) instead of copying it in file-by-file. Use this for large repositories that are too big to stream into the container. Can be specified multiple times.
|
||||||
|
|
||||||
|
Strix copies local `--target` directories into the sandbox one file at a time, which stalls on very large trees. When a local target exceeds the copy limit (see `STRIX_MAX_LOCAL_COPY_MB`, default 1024 MB) Strix exits early and asks you to re-run with `--mount`.
|
||||||
|
|
||||||
|
<Note>
|
||||||
|
The mount is read-only to protect your source from accidental modification. This is not a hard security boundary: a root process inside the container can remount it writable, so treat `--mount` as "scan my own code", not as isolation from untrusted code.
|
||||||
|
</Note>
|
||||||
|
|
||||||
|
<Note>
|
||||||
|
The size pre-flight only covers local directory targets. Remote repositories (cloned at scan time) are not size-checked.
|
||||||
|
</Note>
|
||||||
|
</ParamField>
|
||||||
|
|
||||||
<ParamField path="--instruction" type="string">
|
<ParamField path="--instruction" type="string">
|
||||||
Custom instructions for the scan. Use for credentials, focus areas, or specific testing approaches.
|
Custom instructions for the scan. Use for credentials, focus areas, or specific testing approaches.
|
||||||
</ParamField>
|
</ParamField>
|
||||||
@@ -81,6 +95,9 @@ strix -n --target ./ --scan-mode quick --scope-mode diff --diff-base origin/main
|
|||||||
|
|
||||||
# Multi-target white-box testing
|
# Multi-target white-box testing
|
||||||
strix -t https://github.com/org/app -t https://staging.example.com
|
strix -t https://github.com/org/app -t https://staging.example.com
|
||||||
|
|
||||||
|
# Large local repository — bind-mount instead of copying it in
|
||||||
|
strix --mount ./huge-monorepo
|
||||||
```
|
```
|
||||||
|
|
||||||
## Exit Codes
|
## Exit Codes
|
||||||
|
|||||||
@@ -47,6 +47,11 @@ class RuntimeSettings(BaseSettings):
|
|||||||
alias="STRIX_IMAGE",
|
alias="STRIX_IMAGE",
|
||||||
)
|
)
|
||||||
backend: str = Field(default="docker", alias="STRIX_RUNTIME_BACKEND")
|
backend: str = Field(default="docker", alias="STRIX_RUNTIME_BACKEND")
|
||||||
|
# Hard cap on a local target's size before we refuse to stream it into the
|
||||||
|
# sandbox file-by-file (the SDK copies every file individually, which stalls
|
||||||
|
# on large repos). Above this, the user must bind-mount via ``--mount``.
|
||||||
|
# Set to 0 (or less) to disable the pre-flight check entirely.
|
||||||
|
max_local_copy_mb: int = Field(default=1024, alias="STRIX_MAX_LOCAL_COPY_MB")
|
||||||
|
|
||||||
|
|
||||||
class TelemetrySettings(BaseSettings):
|
class TelemetrySettings(BaseSettings):
|
||||||
|
|||||||
@@ -44,7 +44,8 @@ def build_root_task(scan_config: dict[str, Any]) -> str:
|
|||||||
)
|
)
|
||||||
elif ttype == "local_code":
|
elif ttype == "local_code":
|
||||||
path = details.get("target_path", "unknown")
|
path = details.get("target_path", "unknown")
|
||||||
sections["Local Codebases"].append(f"- {path} (available at: {workspace_path})")
|
suffix = ", read-only mount" if details.get("mount") else ""
|
||||||
|
sections["Local Codebases"].append(f"- {path} (available at: {workspace_path}{suffix})")
|
||||||
elif ttype == "web_application":
|
elif ttype == "web_application":
|
||||||
sections["URLs"].append(f"- {details.get('target_url', '')}")
|
sections["URLs"].append(f"- {details.get('target_url', '')}")
|
||||||
elif ttype == "ip_address":
|
elif ttype == "ip_address":
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ async def run_strix_scan(
|
|||||||
scan_config: dict[str, Any],
|
scan_config: dict[str, Any],
|
||||||
scan_id: str | None = None,
|
scan_id: str | None = None,
|
||||||
image: str,
|
image: str,
|
||||||
local_sources: list[dict[str, str]] | None = None,
|
local_sources: list[dict[str, Any]] | None = None,
|
||||||
coordinator: AgentCoordinator | None = None,
|
coordinator: AgentCoordinator | None = None,
|
||||||
interactive: bool = False,
|
interactive: bool = False,
|
||||||
max_turns: int = DEFAULT_MAX_TURNS,
|
max_turns: int = DEFAULT_MAX_TURNS,
|
||||||
|
|||||||
+41
-5
@@ -33,9 +33,12 @@ from strix.interface.tui import run_tui
|
|||||||
from strix.interface.utils import (
|
from strix.interface.utils import (
|
||||||
assign_workspace_subdirs,
|
assign_workspace_subdirs,
|
||||||
build_final_stats_text,
|
build_final_stats_text,
|
||||||
|
build_mount_targets_info,
|
||||||
check_docker_connection,
|
check_docker_connection,
|
||||||
clone_repository,
|
clone_repository,
|
||||||
collect_local_sources,
|
collect_local_sources,
|
||||||
|
dedupe_local_targets,
|
||||||
|
find_oversized_local_targets,
|
||||||
generate_run_name,
|
generate_run_name,
|
||||||
image_exists,
|
image_exists,
|
||||||
infer_target_type,
|
infer_target_type,
|
||||||
@@ -328,6 +331,9 @@ Examples:
|
|||||||
# Local code analysis
|
# Local code analysis
|
||||||
strix --target ./my-project
|
strix --target ./my-project
|
||||||
|
|
||||||
|
# Large local repository (bind-mounted read-only instead of copied)
|
||||||
|
strix --mount ./huge-monorepo
|
||||||
|
|
||||||
# Domain penetration test
|
# Domain penetration test
|
||||||
strix --target example.com
|
strix --target example.com
|
||||||
|
|
||||||
@@ -363,6 +369,15 @@ Examples:
|
|||||||
"Can be specified multiple times for multi-target scans. "
|
"Can be specified multiple times for multi-target scans. "
|
||||||
"Required for fresh runs; loaded from disk when ``--resume`` is set.",
|
"Required for fresh runs; loaded from disk when ``--resume`` is set.",
|
||||||
)
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--mount",
|
||||||
|
type=str,
|
||||||
|
action="append",
|
||||||
|
metavar="PATH",
|
||||||
|
help="Bind-mount a local directory into the sandbox (read-only) instead of "
|
||||||
|
"copying it file-by-file. Use this for large repositories that are too big to "
|
||||||
|
"stream into the container. Can be specified multiple times.",
|
||||||
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--instruction",
|
"--instruction",
|
||||||
type=str,
|
type=str,
|
||||||
@@ -473,9 +488,9 @@ Examples:
|
|||||||
args.user_explicit_instruction = args.instruction if args.resume else None
|
args.user_explicit_instruction = args.instruction if args.resume else None
|
||||||
|
|
||||||
if args.resume:
|
if args.resume:
|
||||||
if args.target:
|
if args.target or args.mount:
|
||||||
parser.error(
|
parser.error(
|
||||||
"Cannot combine --resume with --target. --resume picks up where "
|
"Cannot combine --resume with --target/--mount. --resume picks up where "
|
||||||
"the prior run left off, including the original target list."
|
"the prior run left off, including the original target list."
|
||||||
)
|
)
|
||||||
_load_resume_state(args, parser)
|
_load_resume_state(args, parser)
|
||||||
@@ -488,13 +503,13 @@ Examples:
|
|||||||
f"or remove --resume to start over with the same targets."
|
f"or remove --resume to start over with the same targets."
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
if not args.target:
|
if not args.target and not args.mount:
|
||||||
parser.error(
|
parser.error(
|
||||||
"the following arguments are required: -t/--target "
|
"the following arguments are required: -t/--target or --mount "
|
||||||
"(or use --resume <run_name> to continue a prior scan)"
|
"(or use --resume <run_name> to continue a prior scan)"
|
||||||
)
|
)
|
||||||
args.targets_info = []
|
args.targets_info = []
|
||||||
for target in args.target:
|
for target in args.target or []:
|
||||||
try:
|
try:
|
||||||
target_type, target_dict = infer_target_type(target)
|
target_type, target_dict = infer_target_type(target)
|
||||||
|
|
||||||
@@ -509,9 +524,30 @@ Examples:
|
|||||||
except ValueError:
|
except ValueError:
|
||||||
parser.error(f"Invalid target '{target}'")
|
parser.error(f"Invalid target '{target}'")
|
||||||
|
|
||||||
|
try:
|
||||||
|
args.targets_info.extend(build_mount_targets_info(args.mount or []))
|
||||||
|
except ValueError as e:
|
||||||
|
parser.error(str(e))
|
||||||
|
|
||||||
|
args.targets_info = dedupe_local_targets(args.targets_info)
|
||||||
|
|
||||||
assign_workspace_subdirs(args.targets_info)
|
assign_workspace_subdirs(args.targets_info)
|
||||||
rewrite_localhost_targets(args.targets_info, HOST_GATEWAY_HOSTNAME)
|
rewrite_localhost_targets(args.targets_info, HOST_GATEWAY_HOSTNAME)
|
||||||
|
|
||||||
|
max_local_copy_mb = load_settings().runtime.max_local_copy_mb
|
||||||
|
max_copy_bytes = max_local_copy_mb * 1024 * 1024
|
||||||
|
oversized = find_oversized_local_targets(args.targets_info, max_copy_bytes)
|
||||||
|
if oversized:
|
||||||
|
details = "; ".join(
|
||||||
|
f"{path} ({size / (1024 * 1024):.0f} MB)" for path, size in oversized
|
||||||
|
)
|
||||||
|
parser.error(
|
||||||
|
f"Local target too large to stream into the sandbox: {details}. "
|
||||||
|
f"The limit is {max_local_copy_mb} MB "
|
||||||
|
"(set STRIX_MAX_LOCAL_COPY_MB to change it). Re-run with "
|
||||||
|
"--mount <path> to bind-mount the directory instead of copying it."
|
||||||
|
)
|
||||||
|
|
||||||
return args
|
return args
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+121
-2
@@ -1,5 +1,6 @@
|
|||||||
import ipaddress
|
import ipaddress
|
||||||
import json
|
import json
|
||||||
|
import logging
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import secrets
|
import secrets
|
||||||
@@ -23,6 +24,9 @@ from rich.text import Text
|
|||||||
from strix.config import load_settings
|
from strix.config import load_settings
|
||||||
|
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def get_severity_color(severity: str) -> str:
|
def get_severity_color(severity: str) -> str:
|
||||||
severity_colors = {
|
severity_colors = {
|
||||||
"critical": "#dc2626",
|
"critical": "#dc2626",
|
||||||
@@ -1185,8 +1189,8 @@ def is_whitebox_scan(targets_info: list[dict[str, Any]]) -> bool:
|
|||||||
return any(t.get("type") == "local_code" for t in targets_info or [])
|
return any(t.get("type") == "local_code" for t in targets_info or [])
|
||||||
|
|
||||||
|
|
||||||
def collect_local_sources(targets_info: list[dict[str, Any]]) -> list[dict[str, str]]:
|
def collect_local_sources(targets_info: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||||
local_sources: list[dict[str, str]] = []
|
local_sources: list[dict[str, Any]] = []
|
||||||
|
|
||||||
for target_info in targets_info:
|
for target_info in targets_info:
|
||||||
details = target_info["details"]
|
details = target_info["details"]
|
||||||
@@ -1197,6 +1201,7 @@ def collect_local_sources(targets_info: list[dict[str, Any]]) -> list[dict[str,
|
|||||||
{
|
{
|
||||||
"source_path": details["target_path"],
|
"source_path": details["target_path"],
|
||||||
"workspace_subdir": workspace_subdir,
|
"workspace_subdir": workspace_subdir,
|
||||||
|
"mount": bool(details.get("mount", False)),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1205,12 +1210,126 @@ def collect_local_sources(targets_info: list[dict[str, Any]]) -> list[dict[str,
|
|||||||
{
|
{
|
||||||
"source_path": details["cloned_repo_path"],
|
"source_path": details["cloned_repo_path"],
|
||||||
"workspace_subdir": workspace_subdir,
|
"workspace_subdir": workspace_subdir,
|
||||||
|
"mount": False,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
return local_sources
|
return local_sources
|
||||||
|
|
||||||
|
|
||||||
|
def directory_size_bytes(path: Path) -> int:
|
||||||
|
"""Total size in bytes of regular files under ``path`` (symlinks not followed).
|
||||||
|
|
||||||
|
Best-effort: files that disappear or can't be stat'd mid-walk are skipped.
|
||||||
|
Used as a cheap (stat-only) pre-flight to estimate the cost of streaming a
|
||||||
|
local target into the sandbox before we actually try to copy it.
|
||||||
|
|
||||||
|
Directories that can't be listed (e.g. permission denied) are logged and
|
||||||
|
skipped rather than silently dropped — so an under-count is at least
|
||||||
|
visible — but the returned total then excludes their contents.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def _on_walk_error(error: OSError) -> None:
|
||||||
|
logger.warning("Could not read %s while measuring size: %s", error.filename, error)
|
||||||
|
|
||||||
|
total = 0
|
||||||
|
for root, _dirs, files in os.walk(path, followlinks=False, onerror=_on_walk_error):
|
||||||
|
for name in files:
|
||||||
|
file_path = os.path.join(root, name) # noqa: PTH118
|
||||||
|
try:
|
||||||
|
if os.path.islink(file_path): # noqa: PTH114
|
||||||
|
continue
|
||||||
|
total += os.path.getsize(file_path) # noqa: PTH202
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
|
return total
|
||||||
|
|
||||||
|
|
||||||
|
def find_oversized_local_targets(
|
||||||
|
targets_info: list[dict[str, Any]], max_bytes: int
|
||||||
|
) -> list[tuple[str, int]]:
|
||||||
|
"""Return ``(path, size_bytes)`` for non-mounted local targets over ``max_bytes``.
|
||||||
|
|
||||||
|
Mounted targets are bind-mounted rather than copied, so their size is
|
||||||
|
irrelevant and they are excluded. A ``max_bytes`` of zero or less disables
|
||||||
|
the check entirely (returns no targets).
|
||||||
|
"""
|
||||||
|
if max_bytes <= 0:
|
||||||
|
return []
|
||||||
|
oversized: list[tuple[str, int]] = []
|
||||||
|
for target in targets_info:
|
||||||
|
if target.get("type") != "local_code":
|
||||||
|
continue
|
||||||
|
details = target.get("details") or {}
|
||||||
|
if details.get("mount"):
|
||||||
|
continue
|
||||||
|
target_path = details.get("target_path")
|
||||||
|
if not target_path:
|
||||||
|
continue
|
||||||
|
size = directory_size_bytes(Path(target_path))
|
||||||
|
if size > max_bytes:
|
||||||
|
oversized.append((target_path, size))
|
||||||
|
return oversized
|
||||||
|
|
||||||
|
|
||||||
|
def build_mount_targets_info(mount_paths: list[str]) -> list[dict[str, Any]]:
|
||||||
|
"""Build ``targets_info`` entries for ``--mount`` directories.
|
||||||
|
|
||||||
|
Each path must be an existing local directory; it is bind-mounted into the
|
||||||
|
sandbox (read-only) instead of being copied file-by-file. Raises
|
||||||
|
``ValueError`` for an empty path, or one that does not exist or is not a
|
||||||
|
directory.
|
||||||
|
"""
|
||||||
|
targets_info: list[dict[str, Any]] = []
|
||||||
|
for raw in mount_paths:
|
||||||
|
if not raw or not raw.strip():
|
||||||
|
raise ValueError("--mount path must not be empty.")
|
||||||
|
path = Path(raw).expanduser()
|
||||||
|
try:
|
||||||
|
resolved = path.resolve()
|
||||||
|
is_dir = resolved.is_dir()
|
||||||
|
except (OSError, RuntimeError) as e:
|
||||||
|
raise ValueError(f"Invalid mount path '{raw}': {e!s}") from e
|
||||||
|
if not is_dir:
|
||||||
|
raise ValueError(
|
||||||
|
f"Mount path '{raw}' is not an existing directory. "
|
||||||
|
"--mount requires a path to a local directory."
|
||||||
|
)
|
||||||
|
targets_info.append(
|
||||||
|
{
|
||||||
|
"type": "local_code",
|
||||||
|
"details": {"target_path": str(resolved), "mount": True},
|
||||||
|
"original": str(resolved),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return targets_info
|
||||||
|
|
||||||
|
|
||||||
|
def dedupe_local_targets(targets_info: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||||
|
"""Collapse local_code targets that resolve to the same path.
|
||||||
|
|
||||||
|
When a directory is supplied both as a copied ``--target`` and via
|
||||||
|
``--mount`` (or as duplicate values of either), keep one entry and prefer
|
||||||
|
the bind-mounted one — so the same tree is never both streamed in and
|
||||||
|
mounted. Order is preserved; non-local targets pass through untouched.
|
||||||
|
"""
|
||||||
|
result: list[dict[str, Any]] = []
|
||||||
|
index_by_path: dict[str, int] = {}
|
||||||
|
for target in targets_info:
|
||||||
|
details = target.get("details") or {}
|
||||||
|
path = details.get("target_path")
|
||||||
|
if target.get("type") != "local_code" or not path:
|
||||||
|
result.append(target)
|
||||||
|
continue
|
||||||
|
existing = index_by_path.get(path)
|
||||||
|
if existing is None:
|
||||||
|
index_by_path[path] = len(result)
|
||||||
|
result.append(target)
|
||||||
|
elif details.get("mount") and not (result[existing].get("details") or {}).get("mount"):
|
||||||
|
result[existing] = target # bind mount supersedes the copied entry
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
def _is_localhost_host(host: str) -> bool:
|
def _is_localhost_host(host: str) -> bool:
|
||||||
host_lower = host.lower().strip("[]")
|
host_lower = host.lower().strip("[]")
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ async def _docker_backend(
|
|||||||
image: str,
|
image: str,
|
||||||
manifest: Manifest,
|
manifest: Manifest,
|
||||||
exposed_ports: tuple[int, ...],
|
exposed_ports: tuple[int, ...],
|
||||||
|
bind_mounts: list[dict[str, Any]] | None = None,
|
||||||
) -> tuple[Any, Any]:
|
) -> tuple[Any, Any]:
|
||||||
"""Bring up a session backed by the local Docker daemon.
|
"""Bring up a session backed by the local Docker daemon.
|
||||||
|
|
||||||
@@ -31,11 +32,15 @@ async def _docker_backend(
|
|||||||
backend don't need the docker-py library installed.
|
backend don't need the docker-py library installed.
|
||||||
|
|
||||||
``session.start()`` is what materializes the manifest entries
|
``session.start()`` is what materializes the manifest entries
|
||||||
(LocalDir copies, mount setup, etc.) into the running container —
|
(LocalDir copies and manifest-declared volume/FUSE mounts) into the
|
||||||
the SDK's ``client.create()`` only builds the inner session object
|
running container — the SDK's ``client.create()`` only builds the inner
|
||||||
without applying the manifest. ``async with session:`` would call it
|
session object without applying the manifest. ``async with session:``
|
||||||
too, but Strix manages session lifetime explicitly via
|
would call it too, but Strix manages session lifetime explicitly via
|
||||||
``client.delete()`` so we trigger ``start()`` ourselves.
|
``client.delete()`` so we trigger ``start()`` ourselves.
|
||||||
|
|
||||||
|
``bind_mounts`` are host directories (e.g. large repos passed via
|
||||||
|
``--mount``) bind-mounted read-only; unlike manifest entries they are
|
||||||
|
applied by Docker at container-create time, not by ``start()``.
|
||||||
"""
|
"""
|
||||||
import docker
|
import docker
|
||||||
from agents.sandbox.sandboxes.docker import DockerSandboxClientOptions
|
from agents.sandbox.sandboxes.docker import DockerSandboxClientOptions
|
||||||
@@ -43,6 +48,7 @@ async def _docker_backend(
|
|||||||
from strix.runtime.docker_client import StrixDockerSandboxClient
|
from strix.runtime.docker_client import StrixDockerSandboxClient
|
||||||
|
|
||||||
client = StrixDockerSandboxClient(docker.from_env())
|
client = StrixDockerSandboxClient(docker.from_env())
|
||||||
|
client.strix_bind_mounts = bind_mounts or []
|
||||||
options = DockerSandboxClientOptions(image=image, exposed_ports=exposed_ports)
|
options = DockerSandboxClientOptions(image=image, exposed_ports=exposed_ports)
|
||||||
session = await client.create(options=options, manifest=manifest)
|
session = await client.create(options=options, manifest=manifest)
|
||||||
await session.start()
|
await session.start()
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ from agents.sandbox.sandboxes.docker import (
|
|||||||
from agents.sandbox.session.sandbox_session import SandboxSession
|
from agents.sandbox.session.sandbox_session import SandboxSession
|
||||||
from docker import errors as docker_errors # type: ignore[import-untyped, unused-ignore]
|
from docker import errors as docker_errors # type: ignore[import-untyped, unused-ignore]
|
||||||
from docker.models.containers import Container # type: ignore[import-untyped, unused-ignore]
|
from docker.models.containers import Container # type: ignore[import-untyped, unused-ignore]
|
||||||
|
from docker.types import Mount as DockerSDKMount # type: ignore[import-untyped, unused-ignore]
|
||||||
from docker.utils import parse_repository_tag # type: ignore[import-untyped, unused-ignore]
|
from docker.utils import parse_repository_tag # type: ignore[import-untyped, unused-ignore]
|
||||||
|
|
||||||
|
|
||||||
@@ -45,6 +46,10 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
|
|
||||||
class StrixDockerSandboxClient(DockerSandboxClient):
|
class StrixDockerSandboxClient(DockerSandboxClient):
|
||||||
|
# Host directories to bind-mount into the container, set by the docker
|
||||||
|
# backend before ``create()``. Each item is ``{source, target, read_only}``.
|
||||||
|
strix_bind_mounts: list[dict[str, Any]] = [] # overridden per-instance in backends.py
|
||||||
|
|
||||||
async def _create_container(
|
async def _create_container(
|
||||||
self,
|
self,
|
||||||
image: str,
|
image: str,
|
||||||
@@ -111,6 +116,21 @@ class StrixDockerSandboxClient(DockerSandboxClient):
|
|||||||
extra_hosts = create_kwargs.setdefault("extra_hosts", {})
|
extra_hosts = create_kwargs.setdefault("extra_hosts", {})
|
||||||
extra_hosts["host.docker.internal"] = "host-gateway"
|
extra_hosts["host.docker.internal"] = "host-gateway"
|
||||||
|
|
||||||
|
# Strix injection: host bind mounts (e.g. large repos passed via --mount)
|
||||||
|
# that bypass the SDK's file-by-file LocalDir copy.
|
||||||
|
bind_mounts = getattr(self, "strix_bind_mounts", ())
|
||||||
|
if bind_mounts:
|
||||||
|
mounts = create_kwargs.setdefault("mounts", [])
|
||||||
|
for spec in bind_mounts:
|
||||||
|
mounts.append(
|
||||||
|
DockerSDKMount(
|
||||||
|
target=spec["target"],
|
||||||
|
source=spec["source"],
|
||||||
|
type="bind",
|
||||||
|
read_only=spec.get("read_only", True),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
logger.debug(
|
logger.debug(
|
||||||
"Creating sandbox container: image=%s caps=%s exposed_ports=%s",
|
"Creating sandbox container: image=%s caps=%s exposed_ports=%s",
|
||||||
image,
|
image,
|
||||||
|
|||||||
@@ -23,30 +23,59 @@ _CONTAINER_CAIDO_PORT = 48080
|
|||||||
|
|
||||||
_SESSION_CACHE: dict[str, dict[str, Any]] = {}
|
_SESSION_CACHE: dict[str, dict[str, Any]] = {}
|
||||||
|
|
||||||
|
# Manifest root inside the container; entry keys hang off this path.
|
||||||
|
_WORKSPACE_ROOT = "/workspace"
|
||||||
|
|
||||||
|
|
||||||
|
def build_session_entries(
|
||||||
|
local_sources: list[dict[str, Any]],
|
||||||
|
) -> tuple[dict[str | Path, BaseEntry], list[dict[str, Any]]]:
|
||||||
|
"""Split local sources into copied manifest entries and host bind mounts.
|
||||||
|
|
||||||
|
Sources flagged ``mount`` are bind-mounted read-only at
|
||||||
|
``/workspace/<workspace_subdir>`` (not added to the manifest, so the SDK
|
||||||
|
does not stream them in file-by-file). Every other source becomes a
|
||||||
|
``LocalDir`` entry copied into the container as before.
|
||||||
|
"""
|
||||||
|
entries: dict[str | Path, BaseEntry] = {}
|
||||||
|
bind_mounts: list[dict[str, Any]] = []
|
||||||
|
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()
|
||||||
|
if src.get("mount"):
|
||||||
|
bind_mounts.append(
|
||||||
|
{
|
||||||
|
"source": str(resolved),
|
||||||
|
"target": f"{_WORKSPACE_ROOT}/{ws_subdir}",
|
||||||
|
"read_only": True,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
entries[ws_subdir] = LocalDir(src=resolved)
|
||||||
|
return entries, bind_mounts
|
||||||
|
|
||||||
|
|
||||||
async def create_or_reuse(
|
async def create_or_reuse(
|
||||||
scan_id: str,
|
scan_id: str,
|
||||||
*,
|
*,
|
||||||
image: str,
|
image: str,
|
||||||
local_sources: list[dict[str, str]],
|
local_sources: list[dict[str, Any]],
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Return the existing session bundle for ``scan_id`` or create a new one.
|
"""Return the existing session bundle for ``scan_id`` or create a new one.
|
||||||
|
|
||||||
Each ``local_sources`` entry mounts its host ``source_path`` at
|
Each ``local_sources`` entry exposes its host ``source_path`` at
|
||||||
``/workspace/<workspace_subdir>`` inside the container.
|
``/workspace/<workspace_subdir>`` inside the container — copied in, or
|
||||||
|
bind-mounted read-only when the entry is flagged ``mount``.
|
||||||
"""
|
"""
|
||||||
cached = _SESSION_CACHE.get(scan_id)
|
cached = _SESSION_CACHE.get(scan_id)
|
||||||
if cached is not None:
|
if cached is not None:
|
||||||
logger.info("Reusing existing sandbox session for scan %s", scan_id)
|
logger.info("Reusing existing sandbox session for scan %s", scan_id)
|
||||||
return cached
|
return cached
|
||||||
|
|
||||||
entries: dict[str | Path, BaseEntry] = {}
|
entries, bind_mounts = build_session_entries(local_sources)
|
||||||
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
|
|
||||||
entries[ws_subdir] = LocalDir(src=Path(host_path).expanduser().resolve())
|
|
||||||
|
|
||||||
# Caido runs as an in-container sidecar; HTTP(S) traffic from any
|
# Caido runs as an in-container sidecar; HTTP(S) traffic from any
|
||||||
# process started via ``session.exec`` (the SDK's Shell tool, etc.)
|
# process started via ``session.exec`` (the SDK's Shell tool, etc.)
|
||||||
@@ -81,6 +110,7 @@ async def create_or_reuse(
|
|||||||
image=image,
|
image=image,
|
||||||
manifest=manifest,
|
manifest=manifest,
|
||||||
exposed_ports=(_CONTAINER_CAIDO_PORT,),
|
exposed_ports=(_CONTAINER_CAIDO_PORT,),
|
||||||
|
bind_mounts=bind_mounts,
|
||||||
)
|
)
|
||||||
|
|
||||||
caido_endpoint = await session.resolve_exposed_port(_CONTAINER_CAIDO_PORT)
|
caido_endpoint = await session.resolve_exposed_port(_CONTAINER_CAIDO_PORT)
|
||||||
|
|||||||
@@ -0,0 +1,188 @@
|
|||||||
|
"""Tests for local-source sizing and ``--mount`` target helpers in interface.utils."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from strix.interface.utils import (
|
||||||
|
build_mount_targets_info,
|
||||||
|
collect_local_sources,
|
||||||
|
dedupe_local_targets,
|
||||||
|
directory_size_bytes,
|
||||||
|
find_oversized_local_targets,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _write_file(path: Path, size: int) -> None:
|
||||||
|
path.write_bytes(b"x" * size)
|
||||||
|
|
||||||
|
|
||||||
|
def _local_target(target_path: str, *, mount: bool = False) -> dict[str, Any]:
|
||||||
|
details: dict[str, Any] = {"target_path": target_path, "workspace_subdir": "repo"}
|
||||||
|
if mount:
|
||||||
|
details["mount"] = True
|
||||||
|
return {"type": "local_code", "details": details, "original": target_path}
|
||||||
|
|
||||||
|
|
||||||
|
def test_directory_size_empty_dir_is_zero(tmp_path: Path) -> None:
|
||||||
|
assert directory_size_bytes(tmp_path) == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_directory_size_sums_flat_and_nested_files(tmp_path: Path) -> None:
|
||||||
|
_write_file(tmp_path / "a.txt", 100)
|
||||||
|
nested = tmp_path / "sub" / "deep"
|
||||||
|
nested.mkdir(parents=True)
|
||||||
|
_write_file(nested / "b.txt", 250)
|
||||||
|
assert directory_size_bytes(tmp_path) == 350
|
||||||
|
|
||||||
|
|
||||||
|
def test_directory_size_skips_symlinks(tmp_path: Path) -> None:
|
||||||
|
_write_file(tmp_path / "real.txt", 100)
|
||||||
|
(tmp_path / "link.txt").symlink_to(tmp_path / "real.txt")
|
||||||
|
# The symlink target is counted once via the real file, not doubled.
|
||||||
|
assert directory_size_bytes(tmp_path) == 100
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skipif(sys.platform == "win32", reason="relies on POSIX permissions")
|
||||||
|
def test_directory_size_logs_and_skips_unreadable_subdir(
|
||||||
|
tmp_path: Path, caplog: pytest.LogCaptureFixture
|
||||||
|
) -> None:
|
||||||
|
if hasattr(os, "geteuid") and os.geteuid() == 0:
|
||||||
|
pytest.skip("root bypasses directory permissions")
|
||||||
|
_write_file(tmp_path / "top.txt", 100)
|
||||||
|
locked = tmp_path / "locked"
|
||||||
|
locked.mkdir()
|
||||||
|
_write_file(locked / "secret.bin", 9999)
|
||||||
|
locked.chmod(0o000)
|
||||||
|
try:
|
||||||
|
with caplog.at_level(logging.WARNING):
|
||||||
|
size = directory_size_bytes(tmp_path)
|
||||||
|
finally:
|
||||||
|
locked.chmod(0o755)
|
||||||
|
# The unreadable subtree is excluded (not silently treated as readable) and
|
||||||
|
# the omission is logged rather than vanishing without a trace.
|
||||||
|
assert size == 100
|
||||||
|
assert any("Could not read" in record.message for record in caplog.records)
|
||||||
|
|
||||||
|
|
||||||
|
def test_find_oversized_returns_nothing_under_limit(tmp_path: Path) -> None:
|
||||||
|
_write_file(tmp_path / "a.txt", 100)
|
||||||
|
targets = [_local_target(str(tmp_path))]
|
||||||
|
assert find_oversized_local_targets(targets, max_bytes=1000) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_find_oversized_returns_target_over_limit(tmp_path: Path) -> None:
|
||||||
|
_write_file(tmp_path / "big.bin", 500)
|
||||||
|
targets = [_local_target(str(tmp_path))]
|
||||||
|
result = find_oversized_local_targets(targets, max_bytes=100)
|
||||||
|
assert result == [(str(tmp_path), 500)]
|
||||||
|
|
||||||
|
|
||||||
|
def test_find_oversized_ignores_mounted_targets(tmp_path: Path) -> None:
|
||||||
|
_write_file(tmp_path / "big.bin", 500)
|
||||||
|
targets = [_local_target(str(tmp_path), mount=True)]
|
||||||
|
assert find_oversized_local_targets(targets, max_bytes=100) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_find_oversized_ignores_non_local_targets() -> None:
|
||||||
|
targets = [{"type": "web_application", "details": {"target_url": "https://x"}}]
|
||||||
|
assert find_oversized_local_targets(targets, max_bytes=1) == []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("disabled", [0, -1])
|
||||||
|
def test_find_oversized_disabled_for_non_positive_limit(tmp_path: Path, disabled: int) -> None:
|
||||||
|
_write_file(tmp_path / "big.bin", 500)
|
||||||
|
targets = [_local_target(str(tmp_path))]
|
||||||
|
assert find_oversized_local_targets(targets, max_bytes=disabled) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_collect_local_sources_propagates_mount_flag() -> None:
|
||||||
|
copied = _local_target("/copied")
|
||||||
|
copied["details"]["workspace_subdir"] = "copied"
|
||||||
|
mounted = _local_target("/mounted", mount=True)
|
||||||
|
mounted["details"]["workspace_subdir"] = "mounted"
|
||||||
|
|
||||||
|
sources = collect_local_sources([copied, mounted])
|
||||||
|
|
||||||
|
by_path = {s["source_path"]: s for s in sources}
|
||||||
|
assert by_path["/copied"]["mount"] is False
|
||||||
|
assert by_path["/mounted"]["mount"] is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_collect_local_sources_repository_is_never_mounted() -> None:
|
||||||
|
repo = {
|
||||||
|
"type": "repository",
|
||||||
|
"details": {"cloned_repo_path": "/clone", "workspace_subdir": "clone"},
|
||||||
|
}
|
||||||
|
sources = collect_local_sources([repo])
|
||||||
|
assert sources == [{"source_path": "/clone", "workspace_subdir": "clone", "mount": False}]
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_mount_targets_info_for_valid_dir(tmp_path: Path) -> None:
|
||||||
|
result = build_mount_targets_info([str(tmp_path)])
|
||||||
|
assert len(result) == 1
|
||||||
|
entry = result[0]
|
||||||
|
assert entry["type"] == "local_code"
|
||||||
|
assert entry["details"]["mount"] is True
|
||||||
|
assert entry["details"]["target_path"] == str(tmp_path.resolve())
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_mount_targets_info_rejects_missing_path(tmp_path: Path) -> None:
|
||||||
|
missing = tmp_path / "does-not-exist"
|
||||||
|
with pytest.raises(ValueError, match="not an existing directory"):
|
||||||
|
build_mount_targets_info([str(missing)])
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_mount_targets_info_rejects_file(tmp_path: Path) -> None:
|
||||||
|
file_path = tmp_path / "a-file.txt"
|
||||||
|
_write_file(file_path, 10)
|
||||||
|
with pytest.raises(ValueError, match="not an existing directory"):
|
||||||
|
build_mount_targets_info([str(file_path)])
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("empty", ["", " "])
|
||||||
|
def test_build_mount_targets_info_rejects_empty_path(empty: str) -> None:
|
||||||
|
# An empty path would otherwise resolve to the current working directory
|
||||||
|
# and silently bind-mount it into the sandbox.
|
||||||
|
with pytest.raises(ValueError, match="must not be empty"):
|
||||||
|
build_mount_targets_info([empty])
|
||||||
|
|
||||||
|
|
||||||
|
def test_dedupe_keeps_distinct_targets_in_order() -> None:
|
||||||
|
targets = [
|
||||||
|
_local_target("/a"),
|
||||||
|
{"type": "web_application", "details": {"target_url": "https://x"}},
|
||||||
|
_local_target("/b", mount=True),
|
||||||
|
]
|
||||||
|
assert dedupe_local_targets(targets) == targets
|
||||||
|
|
||||||
|
|
||||||
|
def test_dedupe_mount_supersedes_copied_same_path() -> None:
|
||||||
|
copied = _local_target("/repo")
|
||||||
|
mounted = _local_target("/repo", mount=True)
|
||||||
|
|
||||||
|
# Copied first, then mounted: the single surviving entry is the mount.
|
||||||
|
result = dedupe_local_targets([copied, mounted])
|
||||||
|
assert len(result) == 1
|
||||||
|
assert result[0]["details"]["mount"] is True
|
||||||
|
|
||||||
|
# Order-independent: mounted first, copied second also yields the mount.
|
||||||
|
result_rev = dedupe_local_targets([mounted, copied])
|
||||||
|
assert len(result_rev) == 1
|
||||||
|
assert result_rev[0]["details"]["mount"] is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_dedupe_collapses_duplicate_mounts() -> None:
|
||||||
|
result = dedupe_local_targets(
|
||||||
|
[_local_target("/repo", mount=True), _local_target("/repo", mount=True)]
|
||||||
|
)
|
||||||
|
assert len(result) == 1
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
"""Tests for build_session_entries: splitting copied vs bind-mounted sources."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
|
from agents.sandbox.entries import LocalDir
|
||||||
|
|
||||||
|
from strix.runtime.session_manager import build_session_entries
|
||||||
|
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
def _source(subdir: str, path: str, *, mount: bool = False) -> dict[str, Any]:
|
||||||
|
return {"source_path": path, "workspace_subdir": subdir, "mount": mount}
|
||||||
|
|
||||||
|
|
||||||
|
def test_copied_source_becomes_localdir_entry(tmp_path: Path) -> None:
|
||||||
|
entries, bind_mounts = build_session_entries([_source("repo", str(tmp_path))])
|
||||||
|
|
||||||
|
assert bind_mounts == []
|
||||||
|
assert isinstance(entries["repo"], LocalDir)
|
||||||
|
assert entries["repo"].src == tmp_path.resolve()
|
||||||
|
|
||||||
|
|
||||||
|
def test_mounted_source_becomes_bind_mount(tmp_path: Path) -> None:
|
||||||
|
entries, bind_mounts = build_session_entries([_source("repo", str(tmp_path), mount=True)])
|
||||||
|
|
||||||
|
assert entries == {}
|
||||||
|
assert bind_mounts == [
|
||||||
|
{
|
||||||
|
"source": str(tmp_path.resolve()),
|
||||||
|
"target": "/workspace/repo",
|
||||||
|
"read_only": True,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_mixed_sources_split_correctly(tmp_path: Path) -> None:
|
||||||
|
copied = tmp_path / "copied"
|
||||||
|
mounted = tmp_path / "mounted"
|
||||||
|
copied.mkdir()
|
||||||
|
mounted.mkdir()
|
||||||
|
|
||||||
|
entries, bind_mounts = build_session_entries(
|
||||||
|
[
|
||||||
|
_source("copied", str(copied)),
|
||||||
|
_source("mounted", str(mounted), mount=True),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
assert list(entries) == ["copied"]
|
||||||
|
assert isinstance(entries["copied"], LocalDir)
|
||||||
|
assert [m["target"] for m in bind_mounts] == ["/workspace/mounted"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_incomplete_sources_are_skipped() -> None:
|
||||||
|
entries, bind_mounts = build_session_entries(
|
||||||
|
[
|
||||||
|
{"source_path": "", "workspace_subdir": "x"},
|
||||||
|
{"source_path": "/p", "workspace_subdir": ""},
|
||||||
|
]
|
||||||
|
)
|
||||||
|
assert entries == {}
|
||||||
|
assert bind_mounts == []
|
||||||
Reference in New Issue
Block a user