feat(runtime): mount local targets instead of copying them in (#958)

Co-authored-by: Ahmed Allam <ahmed39652003@gmail.com>
This commit is contained in:
devin-ai-integration[bot]
2026-08-02 07:45:10 -07:00
committed by GitHub
co-authored by Ahmed Allam
parent b6cf156e95
commit dbc427d816
21 changed files with 567 additions and 715 deletions
+16
View File
@@ -1,6 +1,22 @@
#!/bin/bash #!/bin/bash
set -e set -e
if [ -n "${STRIX_HOST_UID:-}" ] && [ "${STRIX_HOST_UID}" != "0" ] && [ "${STRIX_HOST_UID}" != "$(id -u)" ]; then
exec sudo -E -- bash -c '
set -e
gid="${STRIX_HOST_GID:-$STRIX_HOST_UID}"
old_uid="$1"
old_gid="$2"
export PATH="$3"
shift 3
sed -i "s|^pentester:x:${old_uid}:${old_gid}:|pentester:x:${STRIX_HOST_UID}:${gid}:|" /etc/passwd
sed -i "s|^pentester:x:${old_gid}:|pentester:x:${gid}:|" /etc/group
chown -R "${STRIX_HOST_UID}:${gid}" /home/pentester /app/certs
chown "${STRIX_HOST_UID}:${gid}" /workspace
exec setpriv --reuid "${STRIX_HOST_UID}" --regid "${gid}" --init-groups "$0" "$@"
' "$0" "$(id -u)" "$(id -g)" "$PATH" "$@"
fi
CAIDO_PORT=48080 CAIDO_PORT=48080
CAIDO_LOG="/tmp/caido_startup.log" CAIDO_LOG="/tmp/caido_startup.log"
+1 -5
View File
@@ -106,7 +106,7 @@ When remote vars are set, Strix dual-writes telemetry to both local JSONL and th
## Docker Configuration ## Docker Configuration
<ParamField path="STRIX_IMAGE" default="ghcr.io/usestrix/strix-sandbox:1.0.0" type="string"> <ParamField path="STRIX_IMAGE" default="ghcr.io/usestrix/strix-sandbox:1.2.0" type="string">
Docker image to use for the sandbox container. Docker image to use for the sandbox container.
</ParamField> </ParamField>
@@ -118,10 +118,6 @@ 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">
+6 -19
View File
@@ -6,33 +6,23 @@ description: "Command-line options for Strix"
## Basic Usage ## Basic Usage
```bash ```bash
strix (--target <target> | --target-list <path> | --mount <path>) [options] strix (--target <target> | --target-list <path>) [options]
``` ```
## Options ## Options
<ParamField path="--target, -t" type="string"> <ParamField path="--target, -t" type="string">
Target to test. Accepts URLs, repositories, local directories, domains, or IP addresses. Can be specified multiple times. Fresh runs require at least one target source: `--target`, `--target-list`, or `--mount`. Target to test. Accepts URLs, repositories, local directories, domains, or IP addresses. Can be specified multiple times. Fresh runs require at least one target source: `--target` or `--target-list`.
<Note>
A local directory is mounted into the sandbox live and **writable**, so the agent edits your real files (`.git` excepted). Commit or stash first.
</Note>
</ParamField> </ParamField>
<ParamField path="--target-list" type="string"> <ParamField path="--target-list" type="string">
Path to a file containing targets, one per non-empty, non-comment line. Lines starting with `#` are ignored. Can be specified multiple times and combined with `--target`. Path to a file containing targets, one per non-empty, non-comment line. Lines starting with `#` are ignored. Can be specified multiple times and combined with `--target`.
</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>
@@ -140,9 +130,6 @@ strix -t https://github.com/org/app -t https://staging.example.com
# Targets from a file # Targets from a file
strix --target-list ./targets.txt strix --target-list ./targets.txt
# Large local repository — bind-mount instead of copying it in
strix --mount ./huge-monorepo
``` ```
## Exit Codes ## Exit Codes
+1 -1
View File
@@ -4,7 +4,7 @@ set -euo pipefail
APP=strix APP=strix
REPO="usestrix/strix" REPO="usestrix/strix"
STRIX_IMAGE="ghcr.io/usestrix/strix-sandbox:1.1.0" STRIX_IMAGE="ghcr.io/usestrix/strix-sandbox:1.2.0"
MUTED='\033[0;2m' MUTED='\033[0;2m'
RED='\033[0;31m' RED='\033[0;31m'
+1 -6
View File
@@ -97,15 +97,10 @@ class RuntimeSettings(BaseSettings):
model_config = _BASE_CONFIG model_config = _BASE_CONFIG
image: str = Field( image: str = Field(
default="ghcr.io/usestrix/strix-sandbox:1.1.0", default="ghcr.io/usestrix/strix-sandbox:1.2.0",
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")
# Max screenshot/image tool outputs kept live per agent context (0 = none). # 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") max_context_images: int = Field(default=3, ge=0, alias="STRIX_MAX_CONTEXT_IMAGES")
+5 -2
View File
@@ -59,8 +59,11 @@ 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")
suffix = ", read-only mount" if details.get("mount") else "" sections["Local Codebases"].append(
sections["Local Codebases"].append(f"- {path} (available at: {workspace_path}{suffix})") f"- {path} (available at: {workspace_path}; "
"this is the user's real directory, mounted live and writable — "
".git/.agents/.codex are read-only)"
)
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":
+10
View File
@@ -53,6 +53,8 @@ if TYPE_CHECKING:
from agents.memory import SQLiteSession from agents.memory import SQLiteSession
from agents.result import RunResultBase from agents.result import RunResultBase
from strix.runtime.status import StatusSink
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -120,6 +122,7 @@ async def run_strix_scan(
event_sink: StreamEventSink | None = None, event_sink: StreamEventSink | None = None,
root_instructions_override: str | None = None, root_instructions_override: str | None = None,
extra_system_prompt_context: dict[str, Any] | None = None, extra_system_prompt_context: dict[str, Any] | None = None,
status_sink: StatusSink | None = None,
) -> RunResultBase | None: ) -> RunResultBase | None:
"""Run or resume one Strix scan against a sandbox. """Run or resume one Strix scan against a sandbox.
@@ -129,6 +132,11 @@ async def run_strix_scan(
context before prompt rendering. Child agents keep the standard scan prompt context before prompt rendering. Child agents keep the standard scan prompt
and context. and context.
""" """
def report(phase: str) -> None:
if status_sink is not None:
status_sink(phase)
if scan_id is None: if scan_id is None:
scan_id = f"scan-{uuid.uuid4().hex[:8]}" scan_id = f"scan-{uuid.uuid4().hex[:8]}"
@@ -219,7 +227,9 @@ async def run_strix_scan(
scan_id, scan_id,
image=image, image=image,
local_sources=local_sources or [], local_sources=local_sources or [],
status_sink=status_sink,
) )
report("Waiting for the first model response")
logger.info("Sandbox ready for scan %s", scan_id) logger.info("Sandbox ready for scan %s", scan_id)
sandbox_session = bundle["session"] sandbox_session = bundle["session"]
+11
View File
@@ -21,6 +21,7 @@ from strix.runtime import session_manager
from .utils import ( from .utils import (
build_live_stats_text, build_live_stats_text,
format_vulnerability_report, format_vulnerability_report,
has_model_response,
) )
@@ -135,11 +136,17 @@ async def run_cli(args: Any) -> None: # noqa: PLR0915
set_global_report_state(report_state) set_global_report_state(report_state)
startup_phase: list[str] = ["Starting up"]
def create_live_status() -> Panel: def create_live_status() -> Panel:
status_text = Text() status_text = Text()
status_text.append("Penetration test in progress", style="bold #22c55e") status_text.append("Penetration test in progress", style="bold #22c55e")
status_text.append("\n\n") status_text.append("\n\n")
if not has_model_response(report_state):
status_text.append(f"{startup_phase[0]}...", style="dim")
status_text.append("\n\n")
stats_text = build_live_stats_text(report_state) stats_text = build_live_stats_text(report_state)
if stats_text: if stats_text:
status_text.append(stats_text) status_text.append(stats_text)
@@ -152,6 +159,9 @@ async def run_cli(args: Any) -> None: # noqa: PLR0915
padding=(1, 2), padding=(1, 2),
) )
def _note_startup_phase(phase: str) -> None:
startup_phase[:] = [phase]
try: try:
console.print() console.print()
@@ -186,6 +196,7 @@ async def run_cli(args: Any) -> None: # noqa: PLR0915
interactive=bool(getattr(args, "interactive", False)), interactive=bool(getattr(args, "interactive", False)),
max_budget_usd=getattr(args, "max_budget_usd", None), max_budget_usd=getattr(args, "max_budget_usd", None),
max_turns=getattr(args, "max_turns", DEFAULT_MAX_TURNS), max_turns=getattr(args, "max_turns", DEFAULT_MAX_TURNS),
status_sink=_note_startup_phase,
) )
finally: finally:
stop_updates.set() stop_updates.set()
+16 -42
View File
@@ -33,12 +33,11 @@ from strix.interface.update_check import (
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,
check_mountable_dir,
clone_repository, clone_repository,
collect_local_sources, collect_local_sources,
dedupe_local_targets, dedupe_local_targets,
find_oversized_local_targets,
generate_run_name, generate_run_name,
image_exists, image_exists,
infer_target_type, infer_target_type,
@@ -524,9 +523,6 @@ 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
@@ -570,8 +566,9 @@ Examples:
type=str, type=str,
action="append", action="append",
help="Target to test (URL, repository, local directory path, domain name, or IP address). " help="Target to test (URL, repository, local directory path, domain name, or IP address). "
"Local directories are mounted into the sandbox writable. "
"Can be specified multiple times for multi-target scans. " "Can be specified multiple times for multi-target scans. "
"Fresh runs require at least one of --target, --target-list, or --mount.", "Fresh runs require --target or --target-list.",
) )
parser.add_argument( parser.add_argument(
"--target-list", "--target-list",
@@ -581,15 +578,6 @@ Examples:
help="Path to a file containing targets, one per non-empty, non-comment line. " help="Path to a file containing targets, one per non-empty, non-comment line. "
"Can be specified multiple times and combined with --target.", "Can be specified multiple times and combined with --target.",
) )
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,
@@ -720,9 +708,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 or args.target_list or args.mount: if args.target or args.target_list:
parser.error( parser.error(
"Cannot combine --resume with --target/--target-list/--mount. " "Cannot combine --resume with --target/--target-list. "
"--resume picks up where the prior run left off, including the " "--resume picks up where the prior run left off, including the "
"original target list." "original target list."
) )
@@ -736,9 +724,9 @@ 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 and not args.target_list and not args.mount: if not args.target and not args.target_list:
parser.error( parser.error(
"the following arguments are required: -t/--target, --target-list, or --mount " "the following arguments are required: -t/--target or --target-list "
"(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 = []
@@ -761,33 +749,14 @@ Examples:
args.targets_info.append( args.targets_info.append(
{"type": target_type, "details": target_dict, "original": display_target} {"type": target_type, "details": target_dict, "original": display_target}
) )
except ValueError: except ValueError as e:
parser.error(f"Invalid target '{target}'") parser.error(f"Invalid target '{target}': {e}")
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) 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
@@ -839,6 +808,12 @@ def _load_resume_state(args: argparse.Namespace, parser: argparse.ArgumentParser
if not isinstance(target, dict): if not isinstance(target, dict):
continue continue
details = target.get("details") or {} details = target.get("details") or {}
if target.get("type") == "local_code" and details.get("target_path"):
try:
check_mountable_dir(Path(details["target_path"]).expanduser())
except ValueError as exc:
parser.error(f"--resume {args.resume}: {exc}")
continue
if target.get("type") != "repository": if target.get("type") != "repository":
continue continue
cloned = details.get("cloned_repo_path") cloned = details.get("cloned_repo_path")
@@ -853,8 +828,7 @@ def _load_resume_state(args: argparse.Namespace, parser: argparse.ArgumentParser
if args.instruction is None: if args.instruction is None:
args.instruction = state.get("instruction") args.instruction = state.get("instruction")
if state.get("local_sources"): args.local_sources = collect_local_sources(args.targets_info)
args.local_sources = state.get("local_sources")
if state.get("diff_scope"): if state.get("diff_scope"):
args.diff_scope = state.get("diff_scope") args.diff_scope = state.get("diff_scope")
persisted_scan_mode = state.get("scan_mode") persisted_scan_mode = state.get("scan_mode")
+18 -1
View File
@@ -815,6 +815,8 @@ class StrixTUIApp(App): # type: ignore[misc]
self._scan_stop_event = threading.Event() self._scan_stop_event = threading.Event()
self._scan_completed = threading.Event() self._scan_completed = threading.Event()
self._scan_error: BaseException | None = None self._scan_error: BaseException | None = None
self._startup_status = "Starting up"
self._startup_status_step = 0
self._error_noted_agents: set[str] = set() self._error_noted_agents: set[str] = set()
self._budget_pause_notified = False self._budget_pause_notified = False
@@ -1111,7 +1113,9 @@ class StrixTUIApp(App): # type: ignore[misc]
self, self,
) -> tuple[Any, str | None]: ) -> tuple[Any, str | None]:
if not self.selected_agent_id: if not self.selected_agent_id:
return self._get_chat_placeholder_content("Loading...", "placeholder-no-agent") return self._get_chat_placeholder_content(
f"{self._startup_status}...", f"placeholder-no-agent-{self._startup_status_step}"
)
events = self._gather_agent_events(self.selected_agent_id) events = self._gather_agent_events(self.selected_agent_id)
@@ -1525,6 +1529,7 @@ class StrixTUIApp(App): # type: ignore[misc]
max_budget_usd=getattr(self.args, "max_budget_usd", None), max_budget_usd=getattr(self.args, "max_budget_usd", None),
max_turns=getattr(self.args, "max_turns", DEFAULT_MAX_TURNS), max_turns=getattr(self.args, "max_turns", DEFAULT_MAX_TURNS),
event_sink=self._capture_sdk_event, event_sink=self._capture_sdk_event,
status_sink=self._capture_startup_status,
), ),
) )
@@ -1556,6 +1561,18 @@ class StrixTUIApp(App): # type: ignore[misc]
self._scan_thread = threading.Thread(target=scan_target, daemon=True) self._scan_thread = threading.Thread(target=scan_target, daemon=True)
self._scan_thread.start() self._scan_thread.start()
def _capture_startup_status(self, phase: str) -> None:
try:
self.call_from_thread(self._record_startup_status, phase)
except RuntimeError:
self._record_startup_status(phase)
def _record_startup_status(self, phase: str) -> None:
self._startup_status = phase
self._startup_status_step += 1
if not self.show_splash and not self.selected_agent_id:
self.call_later(self._update_chat_view)
def _capture_sdk_event(self, agent_id: str, event: Any) -> None: def _capture_sdk_event(self, agent_id: str, event: Any) -> None:
try: try:
self.call_from_thread(self._record_sdk_event, agent_id, event) self.call_from_thread(self._record_sdk_event, agent_id, event)
+101 -92
View File
@@ -290,6 +290,11 @@ def _detail_value(usage: dict[str, Any], detail_key: str, value_key: str) -> int
return _int_stat(details, value_key) return _int_stat(details, value_key)
def has_model_response(report_state: Any) -> bool:
usage = _llm_usage(report_state)
return bool(usage) and _int_stat(usage, "requests") > 0
def _build_llm_usage_stats( def _build_llm_usage_stats(
stats_text: Text, stats_text: Text,
report_state: Any, report_state: Any,
@@ -1131,6 +1136,7 @@ def infer_target_type(target: str) -> tuple[str, dict[str, str]]: # noqa: PLR09
try: try:
if path.exists(): if path.exists():
if path.is_dir(): if path.is_dir():
check_mountable_dir(path)
return "local_code", {"target_path": str(path.resolve())} return "local_code", {"target_path": str(path.resolve())}
raise ValueError(f"Path exists but is not a directory: {target}") raise ValueError(f"Path exists but is not a directory: {target}")
except (OSError, RuntimeError) as e: except (OSError, RuntimeError) as e:
@@ -1259,7 +1265,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)), "protect_metadata": True,
} }
) )
@@ -1268,123 +1274,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, "protect_metadata": False,
} }
) )
return local_sources return local_sources
def directory_size_bytes(path: Path) -> int: # Refused along with everything under them.
"""Total size in bytes of regular files under ``path`` (symlinks not followed). _FORBIDDEN_MOUNT_TREES = frozenset(
{
"/bin",
"/sbin",
"/usr",
"/etc",
"/lib",
"/lib64",
"/nix/store",
"/run/current-system/sw",
"/Applications",
"/Library",
"/System",
"/dev",
"/boot",
"/proc",
"/sys",
}
)
Best-effort: files that disappear or can't be stat'd mid-walk are skipped. # Refused themselves, but they hold projects too, so their contents are fine.
Used as a cheap (stat-only) pre-flight to estimate the cost of streaming a _FORBIDDEN_MOUNT_ROOTS = frozenset(
local target into the sandbox before we actually try to copy it. {
"/",
"/private",
"/var",
"/opt",
"/home",
"/root",
"/srv",
"/Users",
"/Volumes",
}
)
Directories that can't be listed (e.g. permission denied) are logged and _FORBIDDEN_WINDOWS_TREE_NAMES = frozenset(
skipped rather than silently dropped so an under-count is at least {"windows", "program files", "program files (x86)", "programdata"}
visible but the returned total then excludes their contents. )
"""
def _on_walk_error(error: OSError) -> None: _FORBIDDEN_MOUNT_DIR_NAMES = frozenset(
logger.warning("Could not read %s while measuring size: %s", error.filename, error) {
".ssh",
total = 0 ".tsh",
for root, _dirs, files in os.walk(path, followlinks=False, onerror=_on_walk_error): ".brev",
for name in files: ".gnupg",
file_path = os.path.join(root, name) # noqa: PTH118 ".aws",
try: ".azure",
if os.path.islink(file_path): # noqa: PTH114 ".kube",
continue ".docker",
total += os.path.getsize(file_path) # noqa: PTH202 ".config",
except OSError: ".npm",
continue ".pki",
return total ".terraform.d",
}
)
def find_oversized_local_targets( def _is_within(path: Path, ancestor: Path) -> bool:
targets_info: list[dict[str, Any]], max_bytes: int ancestor_parts = [part.casefold() for part in ancestor.parts]
) -> list[tuple[str, int]]: path_parts = [part.casefold() for part in path.parts]
"""Return ``(path, size_bytes)`` for non-mounted local targets over ``max_bytes``. return path_parts[: len(ancestor_parts)] == ancestor_parts
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]]: def check_mountable_dir(path: Path) -> None:
"""Build ``targets_info`` entries for ``--mount`` directories. resolved = path.resolve()
if not resolved.is_dir():
raise ValueError(f"'{path}' is not an existing directory.")
Each path must be an existing local directory; it is bind-mounted into the # Both the literal and the resolved form: macOS reaches /etc through the
sandbox (read-only) instead of being copied file-by-file. Raises # /private/etc symlink, and only the resolved path is compared below.
``ValueError`` for an empty path, or one that does not exist or is not a exact = {str(Path(root)).casefold() for root in _FORBIDDEN_MOUNT_ROOTS}
directory. exact |= {str(Path(root).resolve()).casefold() for root in _FORBIDDEN_MOUNT_ROOTS}
""" exact.add(str(Path.home().resolve()).casefold())
targets_info: list[dict[str, Any]] = [] tree_roots = set(_FORBIDDEN_MOUNT_TREES)
for raw in mount_paths: if os.name == "nt":
if not raw or not raw.strip(): drive = Path(resolved.anchor)
raise ValueError("--mount path must not be empty.") tree_roots |= {str(drive / name) for name in _FORBIDDEN_WINDOWS_TREE_NAMES}
path = Path(raw).expanduser() exact.add(str(drive / "Users").casefold())
try: trees = [Path(root) for root in tree_roots] + [Path(root).resolve() for root in tree_roots]
resolved = path.resolve() if (
is_dir = resolved.is_dir() str(resolved).casefold() in exact
except (OSError, RuntimeError) as e: or resolved.parent == resolved
raise ValueError(f"Invalid mount path '{raw}': {e!s}") from e or any(_is_within(resolved, tree) for tree in trees)
if not is_dir: ):
raise ValueError( raise ValueError(
f"Mount path '{raw}' is not an existing directory. " f"Refusing to mount '{resolved}' into the sandbox: it is a system "
"--mount requires a path to a local directory." "or home directory, not a codebase. Point the target at the "
) "project directory you want tested."
targets_info.append( )
{
"type": "local_code", credential = next(
"details": {"target_path": str(resolved), "mount": True}, (part for part in resolved.parts if part.casefold() in _FORBIDDEN_MOUNT_DIR_NAMES), None
"original": str(resolved), )
} if credential is not None:
raise ValueError(
f"Refusing to mount '{resolved}' into the sandbox: '{credential}' "
"holds credentials, not code."
) )
return targets_info
def dedupe_local_targets(targets_info: list[dict[str, Any]]) -> list[dict[str, Any]]: 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]] = [] result: list[dict[str, Any]] = []
index_by_path: dict[str, int] = {} seen_paths: set[str] = set()
for target in targets_info: for target in targets_info:
details = target.get("details") or {} details = target.get("details") or {}
path = details.get("target_path") path = details.get("target_path")
if target.get("type") != "local_code" or not path: if target.get("type") != "local_code" or not path:
result.append(target) result.append(target)
continue continue
existing = index_by_path.get(path) if path not in seen_paths:
if existing is None: seen_paths.add(path)
index_by_path[path] = len(result)
result.append(target) 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 return result
+25 -13
View File
@@ -31,16 +31,11 @@ async def _docker_backend(
``docker`` lazily so deployments that target a non-Docker ``docker`` lazily so deployments that target a non-Docker
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 into the running
(LocalDir copies and manifest-declared volume/FUSE mounts) into the container the SDK's ``client.create()`` only builds the inner session
running container the SDK's ``client.create()`` only builds the inner object without applying it. ``async with session:`` would call it too, but
session object without applying the manifest. ``async with session:`` Strix manages session lifetime explicitly via ``client.delete()`` so we
would call it too, but Strix manages session lifetime explicitly via 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
@@ -59,6 +54,8 @@ _BACKENDS: dict[str, SandboxBackend] = {
"docker": _docker_backend, "docker": _docker_backend,
} }
_BIND_MOUNT_BACKENDS: set[str] = {"docker"}
def get_backend(name: str) -> SandboxBackend: def get_backend(name: str) -> SandboxBackend:
"""Return the backend factory for ``name`` or raise. """Return the backend factory for ``name`` or raise.
@@ -78,15 +75,30 @@ def get_backend(name: str) -> SandboxBackend:
return backend return backend
def register_backend(name: str, backend: SandboxBackend) -> None: def register_backend(
name: str,
backend: SandboxBackend,
*,
supports_bind_mounts: bool = False,
) -> None:
"""Register a custom backend under ``name``. """Register a custom backend under ``name``.
Intended for downstream users who ship their own runtime register Intended for downstream users who ship their own runtime register
before any ``session_manager.create_or_reuse`` call. Re-registering before any ``session_manager.create_or_reuse`` call. Re-registering
an existing name overwrites the prior entry. 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.
""" """
_BACKENDS[name] = backend _BACKENDS[name] = backend
logger.info("Registered sandbox backend: %s", name) if supports_bind_mounts:
_BIND_MOUNT_BACKENDS.add(name)
else:
_BIND_MOUNT_BACKENDS.discard(name)
logger.info("Registered sandbox backend: %s (bind mounts: %s)", name, supports_bind_mounts)
def backend_supports_bind_mounts(name: str) -> bool:
return name in _BIND_MOUNT_BACKENDS
def supported_backends() -> list[str]: def supported_backends() -> list[str]:
+5 -5
View File
@@ -237,18 +237,18 @@ class StrixDockerSandboxClient(DockerSandboxClient):
_apply_log_limits(create_kwargs) _apply_log_limits(create_kwargs)
_apply_run_labels(create_kwargs) _apply_run_labels(create_kwargs)
# Strix injection: host bind mounts (e.g. large repos passed via --mount) # Strix injection: local source trees, sorted shallowest-first so a
# that bypass the SDK's file-by-file LocalDir copy. # nested spec lands on top of the tree it covers.
bind_mounts = getattr(self, "strix_bind_mounts", ()) bind_mounts = self.strix_bind_mounts or ()
if bind_mounts: if bind_mounts:
mounts = create_kwargs.setdefault("mounts", []) mounts = create_kwargs.setdefault("mounts", [])
for spec in bind_mounts: for spec in sorted(bind_mounts, key=lambda s: str(s["target"]).count("/")):
mounts.append( mounts.append(
DockerSDKMount( DockerSDKMount(
target=spec["target"], target=spec["target"],
source=spec["source"], source=spec["source"],
type="bind", type="bind",
read_only=spec.get("read_only", True), read_only=spec.get("read_only", False),
) )
) )
-120
View File
@@ -1,120 +0,0 @@
"""Symlink-safe staging for ``LocalDir`` manifest uploads.
The sandbox SDK's ``LocalDir`` walker refuses to copy symlinks at all — it
raises ``LocalDirReadError(reason="symlink_not_supported")`` on the first one
as a path-escape / TOCTOU safeguard. Real source trees (especially JS/TS
monorepos with workspace or shared-config links) routinely commit symlinks, so
handing such a tree straight to ``LocalDir`` aborts the upload before the agent
even starts.
:func:`stage_symlink_safe_dir` returns a path that is always safe to hand to
``LocalDir``:
* a tree with no symlinks is used as-is (no copy);
* otherwise the tree is copied into a temp directory with symlinks resolved:
- a link whose target stays inside the tree is *dereferenced* (its target
content is materialized in place), so the agent still sees the file;
- a link that escapes the tree, dangles, or forms a cycle is *dropped* and
never followed. Refusing to follow out-of-tree links preserves the walker's
path-escape safety and keeps host/out-of-tree content from leaking into the
(hostile) sandbox.
Regular files are hard-linked when possible (falling back to a copy across
devices), so the staged tree adds negligible disk for the non-symlink bulk.
"""
from __future__ import annotations
import logging
import os
import shutil
import tempfile
from pathlib import Path
logger = logging.getLogger(__name__)
_STAGING_PREFIX = "strix-localdir-"
def _is_within(target: Path, root: Path) -> bool:
"""Return whether ``target`` is ``root`` itself or nested under it."""
if target == root:
return True
try:
target.relative_to(root)
except ValueError:
return False
return True
def tree_has_symlink(root: Path) -> bool:
"""Return whether ``root`` contains any symlink (file or directory)."""
for dirpath, dirnames, filenames in os.walk(root, followlinks=False):
base = Path(dirpath)
for name in (*dirnames, *filenames):
if (base / name).is_symlink():
return True
return False
def _link_or_copy(src: Path, dst: Path) -> None:
"""Hard-link ``src`` to ``dst``, falling back to a content copy."""
try:
os.link(src, dst)
except OSError:
shutil.copy2(src, dst, follow_symlinks=True)
def _stage_dir(src: Path, dst: Path, root: Path, seen: frozenset[Path]) -> None:
dst.mkdir(parents=True, exist_ok=True)
for entry in os.scandir(src):
entry_path = Path(entry.path)
dest_path = dst / entry.name
if entry.is_symlink():
target = Path(os.path.realpath(entry_path))
if not _is_within(target, root):
logger.warning("staging: dropping out-of-tree symlink %s -> %s", entry_path, target)
continue
if not target.exists():
logger.warning("staging: dropping dangling symlink %s", entry_path)
continue
if target in seen:
logger.warning("staging: dropping cyclic symlink %s -> %s", entry_path, target)
continue
if target.is_dir():
_stage_dir(target, dest_path, root, seen | {target})
else:
_link_or_copy(target, dest_path)
elif entry.is_dir(follow_symlinks=False):
_stage_dir(entry_path, dest_path, root, seen)
elif entry.is_file(follow_symlinks=False):
_link_or_copy(entry_path, dest_path)
else:
# Sockets, FIFOs, devices — not part of a source tree; skip.
logger.debug("staging: skipping non-regular entry %s", entry_path)
def stage_symlink_safe_dir(src_root: Path) -> tuple[Path, Path | None]:
"""Return ``(upload_path, staged_temp)`` for uploading ``src_root``.
``upload_path`` is safe to hand to ``LocalDir``. When the tree contains no
symlinks it is ``src_root`` itself and ``staged_temp`` is ``None``.
Otherwise a symlink-safe copy is materialized in a temp directory and both
returned values point at it; the caller owns removing ``staged_temp`` once
the upload completes.
"""
root = src_root.resolve()
if not tree_has_symlink(root):
return root, None
staged = Path(tempfile.mkdtemp(prefix=_STAGING_PREFIX)).resolve()
try:
_stage_dir(root, staged, root, frozenset({root}))
except OSError:
shutil.rmtree(staged, ignore_errors=True)
raise
logger.info("staging: materialized symlink-safe copy of %s at %s", root, staged)
return staged, staged
+89 -47
View File
@@ -3,17 +3,21 @@
from __future__ import annotations from __future__ import annotations
import logging import logging
import shutil import os
import sys
from pathlib import Path from pathlib import Path
from typing import Any from typing import TYPE_CHECKING, Any
from agents.sandbox.entries import BaseEntry, LocalDir from agents.sandbox.entries import BaseEntry, LocalDir
from agents.sandbox.manifest import Environment, Manifest from agents.sandbox.manifest import Environment, Manifest
from strix.config import load_settings from strix.config import load_settings
from strix.runtime.backends import get_backend from strix.runtime.backends import backend_supports_bind_mounts, get_backend
from strix.runtime.caido_bootstrap import bootstrap_caido from strix.runtime.caido_bootstrap import bootstrap_caido
from strix.runtime.local_dir_staging import stage_symlink_safe_dir
if TYPE_CHECKING:
from strix.runtime.status import StatusSink
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -28,43 +32,72 @@ _SESSION_CACHE: dict[str, dict[str, Any]] = {}
# Manifest root inside the container; entry keys hang off this path. # Manifest root inside the container; entry keys hang off this path.
_WORKSPACE_ROOT = "/workspace" _WORKSPACE_ROOT = "/workspace"
_PROTECTED_METADATA_NAMES = (".git", ".agents", ".codex")
def build_session_entries(
local_sources: list[dict[str, Any]],
) -> tuple[dict[str | Path, BaseEntry], list[dict[str, Any]], list[Path]]:
"""Split local sources into copied manifest entries and host bind mounts.
Sources flagged ``mount`` are bind-mounted read-only at def _host_identity_env() -> dict[str, str]:
``/workspace/<workspace_subdir>`` (not added to the manifest, so the SDK if sys.platform != "linux":
does not stream them in file-by-file). Every other source becomes a return {}
``LocalDir`` entry copied into the container as before. Trees containing return {"STRIX_HOST_UID": str(os.getuid()), "STRIX_HOST_GID": str(os.getgid())}
symlinks (which the SDK's ``LocalDir`` walker refuses outright) are first
staged into a symlink-safe temp copy; those temp dirs are returned so the
caller can remove them once the upload completes. def build_bind_mounts(local_sources: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""
entries: dict[str | Path, BaseEntry] = {}
bind_mounts: list[dict[str, Any]] = [] bind_mounts: list[dict[str, Any]] = []
staged_dirs: list[Path] = []
for src in local_sources: for src in local_sources:
ws_subdir = src.get("workspace_subdir") or "" ws_subdir = src.get("workspace_subdir") or ""
host_path = src.get("source_path") or "" host_path = src.get("source_path") or ""
if not ws_subdir or not host_path: if not ws_subdir or not host_path:
continue continue
resolved = Path(host_path).expanduser().resolve() resolved = Path(host_path).expanduser().resolve()
if src.get("mount"): target = f"{_WORKSPACE_ROOT}/{ws_subdir}"
bind_mounts.append( bind_mounts.append({"source": str(resolved), "target": target, "read_only": False})
{ if src.get("protect_metadata"):
"source": str(resolved), bind_mounts.extend(_metadata_mounts(resolved, target))
"target": f"{_WORKSPACE_ROOT}/{ws_subdir}", return bind_mounts
"read_only": True,
}
def build_manifest_entries(local_sources: list[dict[str, Any]]) -> dict[str | Path, BaseEntry]:
entries: dict[str | Path, BaseEntry] = {}
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())
return entries
def _metadata_mounts(tree: Path, target: str) -> list[dict[str, Any]]:
mounts: list[dict[str, Any]] = []
for name in _PROTECTED_METADATA_NAMES:
metadata = tree / name
if not metadata.is_dir() and not metadata.is_file():
continue
if not metadata.resolve().is_relative_to(tree):
continue
mounts.append({"source": str(metadata), "target": f"{target}/{name}", "read_only": True})
gitdir = _gitdir_from_pointer(metadata) if metadata.is_file() else None
if gitdir is not None and gitdir.exists() and gitdir.is_relative_to(tree):
relative = gitdir.relative_to(tree).as_posix()
mounts.append(
{"source": str(gitdir), "target": f"{target}/{relative}", "read_only": True}
) )
else: return mounts
upload_path, staged = stage_symlink_safe_dir(resolved)
if staged is not None:
staged_dirs.append(staged) def _gitdir_from_pointer(git_file: Path) -> Path | None:
entries[ws_subdir] = LocalDir(src=upload_path) try:
return entries, bind_mounts, staged_dirs content = git_file.read_text(encoding="utf-8", errors="replace")
except OSError:
return None
for line in content.splitlines():
prefix, _, value = line.partition(":")
if prefix.strip() == "gitdir" and value.strip():
candidate = Path(value.strip()).expanduser()
if not candidate.is_absolute():
candidate = git_file.parent / candidate
return candidate.resolve()
return None
async def create_or_reuse( async def create_or_reuse(
@@ -72,19 +105,32 @@ async def create_or_reuse(
*, *,
image: str, image: str,
local_sources: list[dict[str, Any]], local_sources: list[dict[str, Any]],
status_sink: StatusSink | None = None,
) -> 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 exposes its host ``source_path`` at Each ``local_sources`` entry exposes its host ``source_path`` at
``/workspace/<workspace_subdir>`` inside the container copied in, or ``/workspace/<workspace_subdir>`` inside the container.
bind-mounted read-only when the entry is flagged ``mount``.
""" """
def report(phase: str) -> None:
if status_sink is not None:
status_sink(phase)
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, bind_mounts, staged_dirs = build_session_entries(local_sources) backend_name = load_settings().runtime.backend
backend = get_backend(backend_name)
if backend_supports_bind_mounts(backend_name):
bind_mounts = build_bind_mounts(local_sources)
entries: dict[str | Path, BaseEntry] = {}
else:
bind_mounts = []
entries = build_manifest_entries(local_sources)
# 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.)
@@ -98,6 +144,7 @@ async def create_or_reuse(
value={ value={
"PYTHONUNBUFFERED": "1", "PYTHONUNBUFFERED": "1",
"HOST_GATEWAY": "host.docker.internal", "HOST_GATEWAY": "host.docker.internal",
**_host_identity_env(),
"http_proxy": container_caido_url, "http_proxy": container_caido_url,
"https_proxy": container_caido_url, "https_proxy": container_caido_url,
"ALL_PROXY": container_caido_url, "ALL_PROXY": container_caido_url,
@@ -106,26 +153,21 @@ async def create_or_reuse(
), ),
) )
backend_name = load_settings().runtime.backend
backend = get_backend(backend_name)
logger.info( logger.info(
"Creating sandbox session for scan %s (backend=%s, image=%s)", "Creating sandbox session for scan %s (backend=%s, image=%s)",
scan_id, scan_id,
backend_name, backend_name,
image, image,
) )
try: report("Starting sandbox container")
client, session = await backend( client, session = await backend(
image=image, image=image,
manifest=manifest, manifest=manifest,
exposed_ports=(_CONTAINER_CAIDO_PORT,), exposed_ports=(_CONTAINER_CAIDO_PORT,),
bind_mounts=bind_mounts, bind_mounts=bind_mounts,
) )
finally:
for staged in staged_dirs:
shutil.rmtree(staged, ignore_errors=True)
report("Setting up the proxy")
caido_endpoint = await session.resolve_exposed_port(_CONTAINER_CAIDO_PORT) caido_endpoint = await session.resolve_exposed_port(_CONTAINER_CAIDO_PORT)
scheme = "https" if caido_endpoint.tls else "http" scheme = "https" if caido_endpoint.tls else "http"
host_caido_url = f"{scheme}://{caido_endpoint.host}:{caido_endpoint.port}" host_caido_url = f"{scheme}://{caido_endpoint.host}:{caido_endpoint.port}"
+8
View File
@@ -0,0 +1,8 @@
"""Startup phase reporting."""
from __future__ import annotations
from collections.abc import Callable
StatusSink = Callable[[str], None]
+2 -7
View File
@@ -30,9 +30,7 @@ def test_parse_arguments_accepts_target_list_file(
) -> None: ) -> None:
target_list = tmp_path / "targets.txt" target_list = tmp_path / "targets.txt"
target_list.write_text( target_list.write_text(
"https://test1.com/\n" "https://test1.com/\n\nhttp://test2.com:5789/\n",
"\n"
"http://test2.com:5789/\n",
encoding="utf-8", encoding="utf-8",
) )
_stub_settings(monkeypatch) _stub_settings(monkeypatch)
@@ -84,7 +82,4 @@ def test_parse_arguments_rejects_resume_with_target_list(
with pytest.raises(SystemExit): with pytest.raises(SystemExit):
cli_main.parse_arguments() cli_main.parse_arguments()
assert ( assert "Cannot combine --resume with --target/--target-list" in capsys.readouterr().err
"Cannot combine --resume with --target/--target-list/--mount"
in capsys.readouterr().err
)
-1
View File
@@ -33,7 +33,6 @@ _LLM_ENV_KEYS = [
# RuntimeSettings # RuntimeSettings
"STRIX_IMAGE", "STRIX_IMAGE",
"STRIX_RUNTIME_BACKEND", "STRIX_RUNTIME_BACKEND",
"STRIX_MAX_LOCAL_COPY_MB",
# TelemetrySettings # TelemetrySettings
"STRIX_TELEMETRY", "STRIX_TELEMETRY",
] ]
-143
View File
@@ -1,143 +0,0 @@
"""Tests for symlink-safe LocalDir staging."""
from __future__ import annotations
from typing import TYPE_CHECKING
from strix.runtime.local_dir_staging import stage_symlink_safe_dir, tree_has_symlink
if TYPE_CHECKING:
from pathlib import Path
def _make_repo(tmp_path: Path) -> Path:
repo = tmp_path / "repo"
(repo / "pkg").mkdir(parents=True)
(repo / "pkg" / "mod.py").write_text("x = 1\n")
(repo / "README.md").write_text("readme\n")
return repo
def test_tree_without_symlinks_used_as_is(tmp_path: Path) -> None:
repo = _make_repo(tmp_path)
upload_path, staged = stage_symlink_safe_dir(repo)
assert staged is None
assert upload_path == repo.resolve()
assert not tree_has_symlink(repo)
def test_in_tree_file_symlink_is_dereferenced(tmp_path: Path) -> None:
repo = _make_repo(tmp_path)
(repo / "link.py").symlink_to(repo / "pkg" / "mod.py")
upload_path, staged = stage_symlink_safe_dir(repo)
assert staged is not None
assert upload_path == staged
assert not (staged / "link.py").is_symlink()
assert (staged / "link.py").read_text() == "x = 1\n"
assert (staged / "pkg" / "mod.py").read_text() == "x = 1\n"
assert not tree_has_symlink(staged)
def test_in_tree_relative_dir_symlink_is_dereferenced(tmp_path: Path) -> None:
repo = _make_repo(tmp_path)
(repo / "pkg_alias").symlink_to("pkg")
_upload, staged = stage_symlink_safe_dir(repo)
assert staged is not None
assert (staged / "pkg_alias" / "mod.py").read_text() == "x = 1\n"
assert not tree_has_symlink(staged)
def test_out_of_tree_symlink_is_dropped(tmp_path: Path) -> None:
repo = _make_repo(tmp_path)
outside = tmp_path / "outside.txt"
outside.write_text("secret\n")
(repo / "escape.txt").symlink_to(outside)
(repo / "abs_escape").symlink_to("/etc")
_upload, staged = stage_symlink_safe_dir(repo)
assert staged is not None
assert not (staged / "escape.txt").exists()
assert not (staged / "abs_escape").exists()
assert (staged / "README.md").exists()
def test_dangling_symlink_is_dropped(tmp_path: Path) -> None:
repo = _make_repo(tmp_path)
(repo / "dangling").symlink_to(repo / "does-not-exist")
_upload, staged = stage_symlink_safe_dir(repo)
assert staged is not None
assert not (staged / "dangling").exists()
assert not (staged / "dangling").is_symlink()
def test_cyclic_symlink_terminates(tmp_path: Path) -> None:
repo = _make_repo(tmp_path)
(repo / "self").symlink_to(repo)
(repo / "pkg" / "up").symlink_to("..")
_upload, staged = stage_symlink_safe_dir(repo)
assert staged is not None
assert (staged / "README.md").exists()
assert not tree_has_symlink(staged)
def test_nested_symlinks_inside_linked_dir(tmp_path: Path) -> None:
repo = _make_repo(tmp_path)
shared = repo / "shared"
shared.mkdir()
(shared / "conf.json").write_text("{}\n")
(shared / "escape").symlink_to("/etc/passwd")
(repo / "pkg" / "shared_link").symlink_to(shared)
_upload, staged = stage_symlink_safe_dir(repo)
assert staged is not None
assert (staged / "pkg" / "shared_link" / "conf.json").read_text() == "{}\n"
assert not (staged / "pkg" / "shared_link" / "escape").exists()
assert not (staged / "shared" / "escape").exists()
def test_staged_path_has_no_symlink_ancestor(tmp_path: Path, monkeypatch) -> None: # noqa: ANN001
"""The staging directory itself must never sit behind a symlink.
``tempfile.mkdtemp()`` honors ``$TMPDIR``, and on macOS the default
``$TMPDIR`` resolves through ``/var``, which is itself a symlink to
``/private/var``. ``LocalDir`` rejects any symlink component in its
source path, so returning the raw ``mkdtemp()`` result breaks every
local-dir upload on macOS whenever the source tree contains a symlink.
This reproduces that shape without depending on the host OS layout.
"""
repo = _make_repo(tmp_path)
(repo / "link.py").symlink_to(repo / "pkg" / "mod.py")
real_tmp_root = tmp_path / "real_tmp"
real_tmp_root.mkdir()
symlinked_tmp_root = tmp_path / "tmp_symlink"
symlinked_tmp_root.symlink_to(real_tmp_root)
def fake_mkdtemp(prefix: str = "") -> str:
real_dir = real_tmp_root / f"{prefix}fake"
real_dir.mkdir()
return str(symlinked_tmp_root / real_dir.name)
monkeypatch.setattr(
"strix.runtime.local_dir_staging.tempfile.mkdtemp", fake_mkdtemp
)
upload_path, staged = stage_symlink_safe_dir(repo)
assert staged is not None
assert upload_path == staged
for path in (staged, *staged.parents):
assert not path.is_symlink(), f"staged path has a symlink ancestor: {path}"
+105 -157
View File
@@ -1,171 +1,138 @@
"""Tests for local-source sizing and ``--mount`` target helpers in interface.utils.""" """Tests for local-source collection and mount policy in interface.utils."""
from __future__ import annotations from __future__ import annotations
import logging from pathlib import Path
import os from typing import Any
import sys
from typing import TYPE_CHECKING, Any
import pytest import pytest
if TYPE_CHECKING:
from pathlib import Path
from strix.interface.utils import ( from strix.interface.utils import (
build_mount_targets_info, check_mountable_dir,
collect_local_sources, collect_local_sources,
dedupe_local_targets, dedupe_local_targets,
directory_size_bytes, infer_target_type,
find_oversized_local_targets,
read_target_list_file, read_target_list_file,
) )
def _write_file(path: Path, size: int) -> None: def _local_target(target_path: str) -> dict[str, Any]:
path.write_bytes(b"x" * size) return {
"type": "local_code",
"details": {"target_path": target_path, "workspace_subdir": "repo"},
"original": target_path,
}
def _local_target(target_path: str, *, mount: bool = False) -> dict[str, Any]: def test_collect_local_sources_protects_the_users_own_git() -> None:
details: dict[str, Any] = {"target_path": target_path, "workspace_subdir": "repo"} sources = collect_local_sources([_local_target("/code")])
if mount: assert sources == [
details["mount"] = True {"source_path": "/code", "workspace_subdir": "repo", "protect_metadata": True}
return {"type": "local_code", "details": details, "original": target_path} ]
def test_directory_size_empty_dir_is_zero(tmp_path: Path) -> None: def test_collect_local_sources_leaves_a_clone_writable() -> 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 = { repo = {
"type": "repository", "type": "repository",
"details": {"cloned_repo_path": "/clone", "workspace_subdir": "clone"}, "details": {"cloned_repo_path": "/clone", "workspace_subdir": "clone"},
} }
sources = collect_local_sources([repo]) sources = collect_local_sources([repo])
assert sources == [{"source_path": "/clone", "workspace_subdir": "clone", "mount": False}] assert sources == [
{"source_path": "/clone", "workspace_subdir": "clone", "protect_metadata": False}
]
def test_build_mount_targets_info_for_valid_dir(tmp_path: Path) -> None: def test_check_mountable_dir_accepts_a_project_dir(tmp_path: Path) -> None:
result = build_mount_targets_info([str(tmp_path)]) check_mountable_dir(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: def test_check_mountable_dir_rejects_missing_path(tmp_path: Path) -> None:
missing = tmp_path / "does-not-exist"
with pytest.raises(ValueError, match="not an existing directory"): with pytest.raises(ValueError, match="not an existing directory"):
build_mount_targets_info([str(missing)]) check_mountable_dir(tmp_path / "nope")
def test_build_mount_targets_info_rejects_file(tmp_path: Path) -> None: def test_check_mountable_dir_rejects_filesystem_root() -> None:
file_path = tmp_path / "a-file.txt" with pytest.raises(ValueError, match="Refusing to mount"):
_write_file(file_path, 10) check_mountable_dir(Path("/"))
with pytest.raises(ValueError, match="not an existing directory"):
build_mount_targets_info([str(file_path)])
@pytest.mark.parametrize("empty", ["", " "]) def test_check_mountable_dir_rejects_home(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
def test_build_mount_targets_info_rejects_empty_path(empty: str) -> None: home = tmp_path / "home"
# An empty path would otherwise resolve to the current working directory home.mkdir()
# and silently bind-mount it into the sandbox. monkeypatch.setenv("HOME", str(home))
with pytest.raises(ValueError, match="must not be empty"): monkeypatch.setattr(Path, "home", classmethod(lambda _cls: home))
build_mount_targets_info([empty])
with pytest.raises(ValueError, match="Refusing to mount"):
check_mountable_dir(home)
def test_check_mountable_dir_rejects_system_root() -> None:
etc = Path("/etc")
if not etc.is_dir():
pytest.skip("no /etc on this platform")
with pytest.raises(ValueError, match="Refusing to mount"):
check_mountable_dir(etc)
def test_check_mountable_dir_rejects_the_shared_home_root() -> None:
home_root = Path("/home")
if not home_root.is_dir():
pytest.skip("no /home on this platform")
with pytest.raises(ValueError, match="Refusing to mount"):
check_mountable_dir(home_root)
def test_check_mountable_dir_matches_forbidden_names_case_insensitively(tmp_path: Path) -> None:
ssh_dir = tmp_path / ".SSH"
ssh_dir.mkdir()
with pytest.raises(ValueError, match="holds credentials"):
check_mountable_dir(ssh_dir)
def test_check_mountable_dir_rejects_credential_dirs(tmp_path: Path) -> None:
ssh_dir = tmp_path / ".ssh"
ssh_dir.mkdir()
with pytest.raises(ValueError, match="holds credentials"):
check_mountable_dir(ssh_dir)
def test_check_mountable_dir_rejects_credential_subdirs(tmp_path: Path) -> None:
keys = tmp_path / ".ssh" / "keys"
keys.mkdir(parents=True)
with pytest.raises(ValueError, match="holds credentials"):
check_mountable_dir(keys)
def test_check_mountable_dir_rejects_system_subdirs() -> None:
system_subdir = next((p for p in (Path("/etc/ssl"), Path("/usr/bin")) if p.is_dir()), None)
if system_subdir is None:
pytest.skip("no system subdirectory on this platform")
with pytest.raises(ValueError, match="Refusing to mount"):
check_mountable_dir(system_subdir)
def test_check_mountable_dir_accepts_a_project_under_the_home_root(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
project = tmp_path / "home" / "dev" / "project"
project.mkdir(parents=True)
monkeypatch.setattr(Path, "home", classmethod(lambda _cls: tmp_path / "home" / "dev"))
check_mountable_dir(project)
def test_infer_target_type_applies_the_mount_policy() -> None:
with pytest.raises(ValueError, match="Refusing to mount"):
infer_target_type("/etc")
def test_read_target_list_file_strips_blank_lines(tmp_path: Path) -> None: def test_read_target_list_file_strips_blank_lines(tmp_path: Path) -> None:
target_list = tmp_path / "targets.txt" target_list = tmp_path / "targets.txt"
target_list.write_text( target_list.write_text(
"\n" "\n https://test1.com/ \n\nhttp://test2.com:5789/\n \n",
" https://test1.com/ \n"
"\n"
"http://test2.com:5789/\n"
" \n",
encoding="utf-8", encoding="utf-8",
) )
@@ -178,10 +145,7 @@ def test_read_target_list_file_strips_blank_lines(tmp_path: Path) -> None:
def test_read_target_list_file_ignores_comment_lines(tmp_path: Path) -> None: def test_read_target_list_file_ignores_comment_lines(tmp_path: Path) -> None:
target_list = tmp_path / "targets.txt" target_list = tmp_path / "targets.txt"
target_list.write_text( target_list.write_text(
"# production targets\n" "# production targets\nhttps://test1.com/\n # staging targets\nhttp://test2.com:5789/\n",
"https://test1.com/\n"
" # staging targets\n"
"http://test2.com:5789/\n",
encoding="utf-8", encoding="utf-8",
) )
@@ -222,28 +186,12 @@ def test_dedupe_keeps_distinct_targets_in_order() -> None:
targets = [ targets = [
_local_target("/a"), _local_target("/a"),
{"type": "web_application", "details": {"target_url": "https://x"}}, {"type": "web_application", "details": {"target_url": "https://x"}},
_local_target("/b", mount=True), _local_target("/b"),
] ]
assert dedupe_local_targets(targets) == targets assert dedupe_local_targets(targets) == targets
def test_dedupe_mount_supersedes_copied_same_path() -> None: def test_dedupe_collapses_the_same_path() -> None:
copied = _local_target("/repo") assert dedupe_local_targets([_local_target("/repo"), _local_target("/repo")]) == [
mounted = _local_target("/repo", mount=True) _local_target("/repo")
]
# 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
+147 -54
View File
@@ -1,4 +1,4 @@
"""Tests for build_session_entries: splitting copied vs bind-mounted sources.""" """Tests for how local sources reach the sandbox: bind mounts or manifest upload."""
from __future__ import annotations from __future__ import annotations
@@ -6,82 +6,175 @@ from typing import TYPE_CHECKING, Any
from agents.sandbox.entries import LocalDir from agents.sandbox.entries import LocalDir
from strix.runtime.session_manager import build_session_entries from strix.runtime.backends import (
_BACKENDS,
_BIND_MOUNT_BACKENDS,
backend_supports_bind_mounts,
register_backend,
)
from strix.runtime.session_manager import build_bind_mounts, build_manifest_entries
if TYPE_CHECKING: if TYPE_CHECKING:
from pathlib import Path from pathlib import Path
def _source(subdir: str, path: str, *, mount: bool = False) -> dict[str, Any]: def _source(subdir: str, path: str, *, protect_metadata: bool = False) -> dict[str, Any]:
return {"source_path": path, "workspace_subdir": subdir, "mount": mount} return {"source_path": path, "workspace_subdir": subdir, "protect_metadata": protect_metadata}
def test_copied_source_becomes_localdir_entry(tmp_path: Path) -> None: def test_source_becomes_writable_bind_mount(tmp_path: Path) -> None:
entries, bind_mounts, staged_dirs = build_session_entries([_source("repo", str(tmp_path))]) assert build_bind_mounts([_source("repo", str(tmp_path))]) == [
assert bind_mounts == []
assert staged_dirs == []
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, _staged = build_session_entries(
[_source("repo", str(tmp_path), mount=True)]
)
assert entries == {}
assert bind_mounts == [
{ {
"source": str(tmp_path.resolve()), "source": str(tmp_path.resolve()),
"target": "/workspace/repo", "target": "/workspace/repo",
"read_only": True, "read_only": False,
} }
] ]
def test_mixed_sources_split_correctly(tmp_path: Path) -> None: def test_git_dir_is_remounted_read_only_when_protected(tmp_path: Path) -> None:
copied = tmp_path / "copied" (tmp_path / ".git").mkdir()
mounted = tmp_path / "mounted"
copied.mkdir()
mounted.mkdir()
entries, bind_mounts, _staged = build_session_entries( mounts = build_bind_mounts([_source("repo", str(tmp_path), protect_metadata=True)])
[
_source("copied", str(copied)),
_source("mounted", str(mounted), mount=True),
]
)
assert list(entries) == ["copied"] assert mounts == [
assert isinstance(entries["copied"], LocalDir) {"source": str(tmp_path.resolve()), "target": "/workspace/repo", "read_only": False},
assert [m["target"] for m in bind_mounts] == ["/workspace/mounted"] {
"source": str((tmp_path / ".git").resolve()),
"target": "/workspace/repo/.git",
"read_only": True,
},
]
def test_agent_instruction_dirs_are_protected_too(tmp_path: Path) -> None:
(tmp_path / ".agents").mkdir()
(tmp_path / ".codex").mkdir()
mounts = build_bind_mounts([_source("repo", str(tmp_path), protect_metadata=True)])
assert [(m["target"], m["read_only"]) for m in mounts] == [
("/workspace/repo", False),
("/workspace/repo/.agents", True),
("/workspace/repo/.codex", True),
]
def test_worktree_git_pointer_file_is_protected(tmp_path: Path) -> None:
gitdir = tmp_path / "nested" / "gitdir"
gitdir.mkdir(parents=True)
(tmp_path / ".git").write_text(f"gitdir: {gitdir}\n", encoding="utf-8")
mounts = build_bind_mounts([_source("repo", str(tmp_path), protect_metadata=True)])
assert [(m["target"], m["read_only"]) for m in mounts] == [
("/workspace/repo", False),
("/workspace/repo/.git", True),
("/workspace/repo/nested/gitdir", True),
]
def test_git_pointer_to_a_missing_gitdir_is_not_mounted(tmp_path: Path) -> None:
(tmp_path / ".git").write_text(f"gitdir: {tmp_path / 'gone'}\n", encoding="utf-8")
mounts = build_bind_mounts([_source("repo", str(tmp_path), protect_metadata=True)])
assert [m["target"] for m in mounts] == ["/workspace/repo", "/workspace/repo/.git"]
def test_git_pointer_outside_the_tree_needs_no_nested_mount(tmp_path: Path) -> None:
tree = tmp_path / "worktree"
tree.mkdir()
(tree / ".git").write_text(f"gitdir: {tmp_path / 'main' / '.git'}\n", encoding="utf-8")
mounts = build_bind_mounts([_source("repo", str(tree), protect_metadata=True)])
assert [m["target"] for m in mounts] == ["/workspace/repo", "/workspace/repo/.git"]
def test_metadata_symlinked_outside_the_tree_is_not_mounted(tmp_path: Path) -> None:
outside = tmp_path / "elsewhere"
outside.mkdir()
tree = tmp_path / "repo"
tree.mkdir()
(tree / ".git").symlink_to(outside, target_is_directory=True)
mounts = build_bind_mounts([_source("repo", str(tree), protect_metadata=True)])
assert [m["target"] for m in mounts] == ["/workspace/repo"]
def test_no_git_guard_without_a_git_dir(tmp_path: Path) -> None:
mounts = build_bind_mounts([_source("repo", str(tmp_path), protect_metadata=True)])
assert [m["target"] for m in mounts] == ["/workspace/repo"]
def test_clone_keeps_its_git_writable(tmp_path: Path) -> None:
(tmp_path / ".git").mkdir()
mounts = build_bind_mounts([_source("clone", str(tmp_path), protect_metadata=False)])
assert [m["target"] for m in mounts] == ["/workspace/clone"]
def test_multiple_sources_each_get_a_mount(tmp_path: Path) -> None:
first = tmp_path / "first"
second = tmp_path / "second"
first.mkdir()
second.mkdir()
mounts = build_bind_mounts([_source("first", str(first)), _source("second", str(second))])
assert [m["target"] for m in mounts] == ["/workspace/first", "/workspace/second"]
assert all(m["read_only"] is False for m in mounts)
def test_incomplete_sources_are_skipped() -> None: def test_incomplete_sources_are_skipped() -> None:
entries, bind_mounts, staged_dirs = build_session_entries( assert (
[ build_bind_mounts(
{"source_path": "", "workspace_subdir": "x"}, [
{"source_path": "/p", "workspace_subdir": ""}, {"source_path": "", "workspace_subdir": "x"},
] {"source_path": "/p", "workspace_subdir": ""},
]
)
== []
) )
assert entries == {}
assert bind_mounts == []
assert staged_dirs == []
def test_symlink_tree_is_staged(tmp_path: Path) -> None: def test_manifest_entries_upload_sources_for_backends_without_bind_mounts(
repo = tmp_path / "repo" tmp_path: Path,
repo.mkdir() ) -> None:
(repo / "real.txt").write_text("content") entries = build_manifest_entries([_source("repo", str(tmp_path), protect_metadata=True)])
(repo / "link.txt").symlink_to(repo / "real.txt")
entries, _mounts, staged_dirs = build_session_entries([_source("repo", str(repo))]) assert set(entries) == {"repo"}
assert len(staged_dirs) == 1
entry = entries["repo"] entry = entries["repo"]
assert isinstance(entry, LocalDir) assert isinstance(entry, LocalDir)
assert entry.src == staged_dirs[0] assert entry.src == tmp_path.resolve()
assert not (staged_dirs[0] / "link.txt").is_symlink()
assert (staged_dirs[0] / "link.txt").read_text() == "content"
def test_manifest_entries_skip_incomplete_sources() -> None:
assert (
build_manifest_entries(
[
{"source_path": "", "workspace_subdir": "x"},
{"source_path": "/p", "workspace_subdir": ""},
]
)
== {}
)
def test_only_bind_mount_capable_backends_are_registered_as_such() -> None:
assert backend_supports_bind_mounts("docker")
assert not backend_supports_bind_mounts("e2b")
async def _remote_backend(**_kwargs: Any) -> tuple[Any, Any]:
return object(), object()
try:
register_backend("e2b", _remote_backend)
assert not backend_supports_bind_mounts("e2b")
register_backend("e2b", _remote_backend, supports_bind_mounts=True)
assert backend_supports_bind_mounts("e2b")
finally:
_BACKENDS.pop("e2b", None)
_BIND_MOUNT_BACKENDS.discard("e2b")