From dbc427d8162008edd9e175224b6d1156577fb094 Mon Sep 17 00:00:00 2001 From: "devin-ai-integration[bot]" <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sun, 2 Aug 2026 07:45:10 -0700 Subject: [PATCH] feat(runtime): mount local targets instead of copying them in (#958) Co-authored-by: Ahmed Allam --- containers/docker-entrypoint.sh | 16 ++ docs/advanced/configuration.mdx | 6 +- docs/usage/cli.mdx | 25 +-- scripts/install.sh | 2 +- strix/config/settings.py | 7 +- strix/core/inputs.py | 7 +- strix/core/runner.py | 10 ++ strix/interface/cli.py | 11 ++ strix/interface/main.py | 58 ++----- strix/interface/tui/app.py | 19 ++- strix/interface/utils.py | 193 +++++++++++---------- strix/runtime/backends.py | 38 +++-- strix/runtime/docker_client.py | 10 +- strix/runtime/local_dir_staging.py | 120 ------------- strix/runtime/session_manager.py | 136 +++++++++------ strix/runtime/status.py | 8 + tests/test_cli_target_list.py | 9 +- tests/test_config_loader.py | 1 - tests/test_local_dir_staging.py | 143 ---------------- tests/test_local_sources.py | 262 ++++++++++++----------------- tests/test_session_entries.py | 201 ++++++++++++++++------ 21 files changed, 567 insertions(+), 715 deletions(-) delete mode 100644 strix/runtime/local_dir_staging.py create mode 100644 strix/runtime/status.py delete mode 100644 tests/test_local_dir_staging.py diff --git a/containers/docker-entrypoint.sh b/containers/docker-entrypoint.sh index d22cefdf..e4f472d6 100644 --- a/containers/docker-entrypoint.sh +++ b/containers/docker-entrypoint.sh @@ -1,6 +1,22 @@ #!/bin/bash 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_LOG="/tmp/caido_startup.log" diff --git a/docs/advanced/configuration.mdx b/docs/advanced/configuration.mdx index 027f6d47..de88f659 100644 --- a/docs/advanced/configuration.mdx +++ b/docs/advanced/configuration.mdx @@ -106,7 +106,7 @@ When remote vars are set, Strix dual-writes telemetry to both local JSONL and th ## Docker Configuration - + Docker image to use for the sandbox container. @@ -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. - - 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. - - ## Sandbox Configuration diff --git a/docs/usage/cli.mdx b/docs/usage/cli.mdx index d5acb402..47d29e82 100644 --- a/docs/usage/cli.mdx +++ b/docs/usage/cli.mdx @@ -6,33 +6,23 @@ description: "Command-line options for Strix" ## Basic Usage ```bash -strix (--target | --target-list | --mount ) [options] +strix (--target | --target-list ) [options] ``` ## Options - 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`. + + + A local directory is mounted into the sandbox live and **writable**, so the agent edits your real files (`.git` excepted). Commit or stash first. + 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`. - - 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`. - - - 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. - - - - The size pre-flight only covers local directory targets. Remote repositories (cloned at scan time) are not size-checked. - - - Custom instructions for the scan. Use for credentials, focus areas, or specific testing approaches. @@ -140,9 +130,6 @@ strix -t https://github.com/org/app -t https://staging.example.com # Targets from a file strix --target-list ./targets.txt - -# Large local repository — bind-mount instead of copying it in -strix --mount ./huge-monorepo ``` ## Exit Codes diff --git a/scripts/install.sh b/scripts/install.sh index cacbd9cb..aee906bb 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -4,7 +4,7 @@ set -euo pipefail APP=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' RED='\033[0;31m' diff --git a/strix/config/settings.py b/strix/config/settings.py index 60b0e321..ec9c1e10 100644 --- a/strix/config/settings.py +++ b/strix/config/settings.py @@ -97,15 +97,10 @@ class RuntimeSettings(BaseSettings): model_config = _BASE_CONFIG image: str = Field( - default="ghcr.io/usestrix/strix-sandbox:1.1.0", + default="ghcr.io/usestrix/strix-sandbox:1.2.0", alias="STRIX_IMAGE", ) 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_context_images: int = Field(default=3, ge=0, alias="STRIX_MAX_CONTEXT_IMAGES") diff --git a/strix/core/inputs.py b/strix/core/inputs.py index 26aebb39..328ad076 100644 --- a/strix/core/inputs.py +++ b/strix/core/inputs.py @@ -59,8 +59,11 @@ def build_root_task(scan_config: dict[str, Any]) -> str: ) elif ttype == "local_code": path = details.get("target_path", "unknown") - suffix = ", read-only mount" if details.get("mount") else "" - sections["Local Codebases"].append(f"- {path} (available at: {workspace_path}{suffix})") + sections["Local Codebases"].append( + f"- {path} (available at: {workspace_path}; " + "this is the user's real directory, mounted live and writable — " + ".git/.agents/.codex are read-only)" + ) elif ttype == "web_application": sections["URLs"].append(f"- {details.get('target_url', '')}") elif ttype == "ip_address": diff --git a/strix/core/runner.py b/strix/core/runner.py index ce099643..79e9fff2 100644 --- a/strix/core/runner.py +++ b/strix/core/runner.py @@ -53,6 +53,8 @@ if TYPE_CHECKING: from agents.memory import SQLiteSession from agents.result import RunResultBase + from strix.runtime.status import StatusSink + logger = logging.getLogger(__name__) @@ -120,6 +122,7 @@ async def run_strix_scan( event_sink: StreamEventSink | None = None, root_instructions_override: str | None = None, extra_system_prompt_context: dict[str, Any] | None = None, + status_sink: StatusSink | None = None, ) -> RunResultBase | None: """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 and context. """ + + def report(phase: str) -> None: + if status_sink is not None: + status_sink(phase) + if scan_id is None: scan_id = f"scan-{uuid.uuid4().hex[:8]}" @@ -219,7 +227,9 @@ async def run_strix_scan( scan_id, image=image, 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) sandbox_session = bundle["session"] diff --git a/strix/interface/cli.py b/strix/interface/cli.py index 9f8fdbdd..cc1059b1 100644 --- a/strix/interface/cli.py +++ b/strix/interface/cli.py @@ -21,6 +21,7 @@ from strix.runtime import session_manager from .utils import ( build_live_stats_text, 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) + startup_phase: list[str] = ["Starting up"] + def create_live_status() -> Panel: status_text = Text() status_text.append("Penetration test in progress", style="bold #22c55e") 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) if stats_text: status_text.append(stats_text) @@ -152,6 +159,9 @@ async def run_cli(args: Any) -> None: # noqa: PLR0915 padding=(1, 2), ) + def _note_startup_phase(phase: str) -> None: + startup_phase[:] = [phase] + try: console.print() @@ -186,6 +196,7 @@ async def run_cli(args: Any) -> None: # noqa: PLR0915 interactive=bool(getattr(args, "interactive", False)), max_budget_usd=getattr(args, "max_budget_usd", None), max_turns=getattr(args, "max_turns", DEFAULT_MAX_TURNS), + status_sink=_note_startup_phase, ) finally: stop_updates.set() diff --git a/strix/interface/main.py b/strix/interface/main.py index 6a5ebc4d..3f29603d 100644 --- a/strix/interface/main.py +++ b/strix/interface/main.py @@ -33,12 +33,11 @@ from strix.interface.update_check import ( from strix.interface.utils import ( assign_workspace_subdirs, build_final_stats_text, - build_mount_targets_info, check_docker_connection, + check_mountable_dir, clone_repository, collect_local_sources, dedupe_local_targets, - find_oversized_local_targets, generate_run_name, image_exists, infer_target_type, @@ -524,9 +523,6 @@ Examples: # Local code analysis strix --target ./my-project - # Large local repository (bind-mounted read-only instead of copied) - strix --mount ./huge-monorepo - # Domain penetration test strix --target example.com @@ -570,8 +566,9 @@ Examples: type=str, action="append", 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. " - "Fresh runs require at least one of --target, --target-list, or --mount.", + "Fresh runs require --target or --target-list.", ) parser.add_argument( "--target-list", @@ -581,15 +578,6 @@ Examples: help="Path to a file containing targets, one per non-empty, non-comment line. " "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( "--instruction", type=str, @@ -720,9 +708,9 @@ Examples: args.user_explicit_instruction = args.instruction if args.resume else None if args.resume: - if args.target or args.target_list or args.mount: + if args.target or args.target_list: 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 " "original target list." ) @@ -736,9 +724,9 @@ Examples: f"or remove --resume to start over with the same targets." ) 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( - "the following arguments are required: -t/--target, --target-list, or --mount " + "the following arguments are required: -t/--target or --target-list " "(or use --resume to continue a prior scan)" ) args.targets_info = [] @@ -761,33 +749,14 @@ Examples: args.targets_info.append( {"type": target_type, "details": target_dict, "original": display_target} ) - except ValueError: - 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)) + except ValueError as e: + parser.error(f"Invalid target '{target}': {e}") args.targets_info = dedupe_local_targets(args.targets_info) assign_workspace_subdirs(args.targets_info) 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 to bind-mount the directory instead of copying it." - ) - return args @@ -839,6 +808,12 @@ def _load_resume_state(args: argparse.Namespace, parser: argparse.ArgumentParser if not isinstance(target, dict): continue 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": continue 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: args.instruction = state.get("instruction") - if state.get("local_sources"): - args.local_sources = state.get("local_sources") + args.local_sources = collect_local_sources(args.targets_info) if state.get("diff_scope"): args.diff_scope = state.get("diff_scope") persisted_scan_mode = state.get("scan_mode") diff --git a/strix/interface/tui/app.py b/strix/interface/tui/app.py index d4c51b20..5c61ff77 100644 --- a/strix/interface/tui/app.py +++ b/strix/interface/tui/app.py @@ -815,6 +815,8 @@ class StrixTUIApp(App): # type: ignore[misc] self._scan_stop_event = threading.Event() self._scan_completed = threading.Event() self._scan_error: BaseException | None = None + self._startup_status = "Starting up" + self._startup_status_step = 0 self._error_noted_agents: set[str] = set() self._budget_pause_notified = False @@ -1111,7 +1113,9 @@ class StrixTUIApp(App): # type: ignore[misc] self, ) -> tuple[Any, str | None]: 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) @@ -1525,6 +1529,7 @@ class StrixTUIApp(App): # type: ignore[misc] max_budget_usd=getattr(self.args, "max_budget_usd", None), max_turns=getattr(self.args, "max_turns", DEFAULT_MAX_TURNS), 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.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: try: self.call_from_thread(self._record_sdk_event, agent_id, event) diff --git a/strix/interface/utils.py b/strix/interface/utils.py index 872c77fe..826a08ba 100644 --- a/strix/interface/utils.py +++ b/strix/interface/utils.py @@ -290,6 +290,11 @@ def _detail_value(usage: dict[str, Any], detail_key: str, value_key: str) -> int 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( stats_text: Text, report_state: Any, @@ -1131,6 +1136,7 @@ def infer_target_type(target: str) -> tuple[str, dict[str, str]]: # noqa: PLR09 try: if path.exists(): if path.is_dir(): + check_mountable_dir(path) return "local_code", {"target_path": str(path.resolve())} raise ValueError(f"Path exists but is not a directory: {target}") 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"], "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"], "workspace_subdir": workspace_subdir, - "mount": False, + "protect_metadata": False, } ) return local_sources -def directory_size_bytes(path: Path) -> int: - """Total size in bytes of regular files under ``path`` (symlinks not followed). +# Refused along with everything under them. +_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. - 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. +# Refused themselves, but they hold projects too, so their contents are fine. +_FORBIDDEN_MOUNT_ROOTS = frozenset( + { + "/", + "/private", + "/var", + "/opt", + "/home", + "/root", + "/srv", + "/Users", + "/Volumes", + } +) - 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. - """ +_FORBIDDEN_WINDOWS_TREE_NAMES = frozenset( + {"windows", "program files", "program files (x86)", "programdata"} +) - 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 +_FORBIDDEN_MOUNT_DIR_NAMES = frozenset( + { + ".ssh", + ".tsh", + ".brev", + ".gnupg", + ".aws", + ".azure", + ".kube", + ".docker", + ".config", + ".npm", + ".pki", + ".terraform.d", + } +) -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 _is_within(path: Path, ancestor: Path) -> bool: + ancestor_parts = [part.casefold() for part in ancestor.parts] + path_parts = [part.casefold() for part in path.parts] + return path_parts[: len(ancestor_parts)] == ancestor_parts -def build_mount_targets_info(mount_paths: list[str]) -> list[dict[str, Any]]: - """Build ``targets_info`` entries for ``--mount`` directories. +def check_mountable_dir(path: Path) -> None: + 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 - 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), - } + # Both the literal and the resolved form: macOS reaches /etc through the + # /private/etc symlink, and only the resolved path is compared below. + exact = {str(Path(root)).casefold() for root in _FORBIDDEN_MOUNT_ROOTS} + exact |= {str(Path(root).resolve()).casefold() for root in _FORBIDDEN_MOUNT_ROOTS} + exact.add(str(Path.home().resolve()).casefold()) + tree_roots = set(_FORBIDDEN_MOUNT_TREES) + if os.name == "nt": + drive = Path(resolved.anchor) + tree_roots |= {str(drive / name) for name in _FORBIDDEN_WINDOWS_TREE_NAMES} + exact.add(str(drive / "Users").casefold()) + trees = [Path(root) for root in tree_roots] + [Path(root).resolve() for root in tree_roots] + if ( + str(resolved).casefold() in exact + or resolved.parent == resolved + or any(_is_within(resolved, tree) for tree in trees) + ): + raise ValueError( + f"Refusing to mount '{resolved}' into the sandbox: it is a system " + "or home directory, not a codebase. Point the target at the " + "project directory you want tested." + ) + + credential = next( + (part for part in resolved.parts if part.casefold() in _FORBIDDEN_MOUNT_DIR_NAMES), None + ) + 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]]: - """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] = {} + seen_paths: set[str] = set() 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) + if path not in seen_paths: + seen_paths.add(path) 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 diff --git a/strix/runtime/backends.py b/strix/runtime/backends.py index d7eba335..ec49f7a7 100644 --- a/strix/runtime/backends.py +++ b/strix/runtime/backends.py @@ -31,16 +31,11 @@ async def _docker_backend( ``docker`` lazily so deployments that target a non-Docker backend don't need the docker-py library installed. - ``session.start()`` is what materializes the manifest entries - (LocalDir copies and manifest-declared volume/FUSE mounts) into the - running container — the SDK's ``client.create()`` only builds the inner - session object without applying the manifest. ``async with session:`` - would call it too, but Strix manages session lifetime explicitly via - ``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()``. + ``session.start()`` is what materializes the manifest into the running + container — the SDK's ``client.create()`` only builds the inner session + object without applying it. ``async with session:`` would call it too, but + Strix manages session lifetime explicitly via ``client.delete()`` so we + trigger ``start()`` ourselves. """ import docker from agents.sandbox.sandboxes.docker import DockerSandboxClientOptions @@ -59,6 +54,8 @@ _BACKENDS: dict[str, SandboxBackend] = { "docker": _docker_backend, } +_BIND_MOUNT_BACKENDS: set[str] = {"docker"} + def get_backend(name: str) -> SandboxBackend: """Return the backend factory for ``name`` or raise. @@ -78,15 +75,30 @@ def get_backend(name: str) -> SandboxBackend: 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``. Intended for downstream users who ship their own runtime — register before any ``session_manager.create_or_reuse`` call. Re-registering - an existing name overwrites the prior entry. + 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 - 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]: diff --git a/strix/runtime/docker_client.py b/strix/runtime/docker_client.py index 5aa4f7c7..969267d5 100644 --- a/strix/runtime/docker_client.py +++ b/strix/runtime/docker_client.py @@ -237,18 +237,18 @@ class StrixDockerSandboxClient(DockerSandboxClient): _apply_log_limits(create_kwargs) _apply_run_labels(create_kwargs) - # 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", ()) + # Strix injection: local source trees, sorted shallowest-first so a + # nested spec lands on top of the tree it covers. + bind_mounts = self.strix_bind_mounts or () if bind_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( DockerSDKMount( target=spec["target"], source=spec["source"], type="bind", - read_only=spec.get("read_only", True), + read_only=spec.get("read_only", False), ) ) diff --git a/strix/runtime/local_dir_staging.py b/strix/runtime/local_dir_staging.py deleted file mode 100644 index 600d1125..00000000 --- a/strix/runtime/local_dir_staging.py +++ /dev/null @@ -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 diff --git a/strix/runtime/session_manager.py b/strix/runtime/session_manager.py index cdc6d955..b4e06389 100644 --- a/strix/runtime/session_manager.py +++ b/strix/runtime/session_manager.py @@ -3,17 +3,21 @@ from __future__ import annotations import logging -import shutil +import os +import sys from pathlib import Path -from typing import Any +from typing import TYPE_CHECKING, Any from agents.sandbox.entries import BaseEntry, LocalDir from agents.sandbox.manifest import Environment, Manifest 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.local_dir_staging import stage_symlink_safe_dir + + +if TYPE_CHECKING: + from strix.runtime.status import StatusSink 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. _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 - ``/workspace/`` (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. Trees containing - 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. - """ - entries: dict[str | Path, BaseEntry] = {} +def _host_identity_env() -> dict[str, str]: + if sys.platform != "linux": + return {} + return {"STRIX_HOST_UID": str(os.getuid()), "STRIX_HOST_GID": str(os.getgid())} + + +def build_bind_mounts(local_sources: list[dict[str, Any]]) -> list[dict[str, Any]]: bind_mounts: list[dict[str, Any]] = [] - staged_dirs: list[Path] = [] 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, - } + target = f"{_WORKSPACE_ROOT}/{ws_subdir}" + bind_mounts.append({"source": str(resolved), "target": target, "read_only": False}) + if src.get("protect_metadata"): + bind_mounts.extend(_metadata_mounts(resolved, target)) + return bind_mounts + + +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: - upload_path, staged = stage_symlink_safe_dir(resolved) - if staged is not None: - staged_dirs.append(staged) - entries[ws_subdir] = LocalDir(src=upload_path) - return entries, bind_mounts, staged_dirs + return mounts + + +def _gitdir_from_pointer(git_file: Path) -> Path | None: + try: + 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( @@ -72,19 +105,32 @@ async def create_or_reuse( *, image: str, local_sources: list[dict[str, Any]], + status_sink: StatusSink | None = None, ) -> dict[str, Any]: """Return the existing session bundle for ``scan_id`` or create a new one. Each ``local_sources`` entry exposes its host ``source_path`` at - ``/workspace/`` inside the container — copied in, or - bind-mounted read-only when the entry is flagged ``mount``. + ``/workspace/`` inside the container. """ + + def report(phase: str) -> None: + if status_sink is not None: + status_sink(phase) + cached = _SESSION_CACHE.get(scan_id) if cached is not None: logger.info("Reusing existing sandbox session for scan %s", scan_id) 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 # process started via ``session.exec`` (the SDK's Shell tool, etc.) @@ -98,6 +144,7 @@ async def create_or_reuse( value={ "PYTHONUNBUFFERED": "1", "HOST_GATEWAY": "host.docker.internal", + **_host_identity_env(), "http_proxy": container_caido_url, "https_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( "Creating sandbox session for scan %s (backend=%s, image=%s)", scan_id, backend_name, image, ) - try: - client, session = await backend( - image=image, - manifest=manifest, - exposed_ports=(_CONTAINER_CAIDO_PORT,), - bind_mounts=bind_mounts, - ) - finally: - for staged in staged_dirs: - shutil.rmtree(staged, ignore_errors=True) + report("Starting sandbox container") + client, session = await backend( + image=image, + manifest=manifest, + exposed_ports=(_CONTAINER_CAIDO_PORT,), + bind_mounts=bind_mounts, + ) + report("Setting up the proxy") caido_endpoint = await session.resolve_exposed_port(_CONTAINER_CAIDO_PORT) scheme = "https" if caido_endpoint.tls else "http" host_caido_url = f"{scheme}://{caido_endpoint.host}:{caido_endpoint.port}" diff --git a/strix/runtime/status.py b/strix/runtime/status.py new file mode 100644 index 00000000..42717d12 --- /dev/null +++ b/strix/runtime/status.py @@ -0,0 +1,8 @@ +"""Startup phase reporting.""" + +from __future__ import annotations + +from collections.abc import Callable + + +StatusSink = Callable[[str], None] diff --git a/tests/test_cli_target_list.py b/tests/test_cli_target_list.py index 17b289d5..35982da3 100644 --- a/tests/test_cli_target_list.py +++ b/tests/test_cli_target_list.py @@ -30,9 +30,7 @@ def test_parse_arguments_accepts_target_list_file( ) -> None: target_list = tmp_path / "targets.txt" target_list.write_text( - "https://test1.com/\n" - "\n" - "http://test2.com:5789/\n", + "https://test1.com/\n\nhttp://test2.com:5789/\n", encoding="utf-8", ) _stub_settings(monkeypatch) @@ -84,7 +82,4 @@ def test_parse_arguments_rejects_resume_with_target_list( with pytest.raises(SystemExit): cli_main.parse_arguments() - assert ( - "Cannot combine --resume with --target/--target-list/--mount" - in capsys.readouterr().err - ) + assert "Cannot combine --resume with --target/--target-list" in capsys.readouterr().err diff --git a/tests/test_config_loader.py b/tests/test_config_loader.py index 7e14662a..e83ab119 100644 --- a/tests/test_config_loader.py +++ b/tests/test_config_loader.py @@ -33,7 +33,6 @@ _LLM_ENV_KEYS = [ # RuntimeSettings "STRIX_IMAGE", "STRIX_RUNTIME_BACKEND", - "STRIX_MAX_LOCAL_COPY_MB", # TelemetrySettings "STRIX_TELEMETRY", ] diff --git a/tests/test_local_dir_staging.py b/tests/test_local_dir_staging.py deleted file mode 100644 index 25e29391..00000000 --- a/tests/test_local_dir_staging.py +++ /dev/null @@ -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}" diff --git a/tests/test_local_sources.py b/tests/test_local_sources.py index 22ed4b9b..5c19b937 100644 --- a/tests/test_local_sources.py +++ b/tests/test_local_sources.py @@ -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 -import logging -import os -import sys -from typing import TYPE_CHECKING, Any +from pathlib import Path +from typing import Any import pytest - -if TYPE_CHECKING: - from pathlib import Path - from strix.interface.utils import ( - build_mount_targets_info, + check_mountable_dir, collect_local_sources, dedupe_local_targets, - directory_size_bytes, - find_oversized_local_targets, + infer_target_type, read_target_list_file, ) -def _write_file(path: Path, size: int) -> None: - path.write_bytes(b"x" * size) +def _local_target(target_path: str) -> dict[str, Any]: + 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]: - 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_collect_local_sources_protects_the_users_own_git() -> None: + sources = collect_local_sources([_local_target("/code")]) + assert sources == [ + {"source_path": "/code", "workspace_subdir": "repo", "protect_metadata": True} + ] -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: +def test_collect_local_sources_leaves_a_clone_writable() -> 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}] + assert sources == [ + {"source_path": "/clone", "workspace_subdir": "clone", "protect_metadata": 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_check_mountable_dir_accepts_a_project_dir(tmp_path: Path) -> None: + check_mountable_dir(tmp_path) -def test_build_mount_targets_info_rejects_missing_path(tmp_path: Path) -> None: - missing = tmp_path / "does-not-exist" +def test_check_mountable_dir_rejects_missing_path(tmp_path: Path) -> None: 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: - 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)]) +def test_check_mountable_dir_rejects_filesystem_root() -> None: + with pytest.raises(ValueError, match="Refusing to mount"): + check_mountable_dir(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_check_mountable_dir_rejects_home(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + home = tmp_path / "home" + home.mkdir() + monkeypatch.setenv("HOME", str(home)) + monkeypatch.setattr(Path, "home", classmethod(lambda _cls: home)) + + 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: target_list = tmp_path / "targets.txt" target_list.write_text( - "\n" - " https://test1.com/ \n" - "\n" - "http://test2.com:5789/\n" - " \n", + "\n https://test1.com/ \n\nhttp://test2.com:5789/\n \n", 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: target_list = tmp_path / "targets.txt" target_list.write_text( - "# production targets\n" - "https://test1.com/\n" - " # staging targets\n" - "http://test2.com:5789/\n", + "# production targets\nhttps://test1.com/\n # staging targets\nhttp://test2.com:5789/\n", encoding="utf-8", ) @@ -222,28 +186,12 @@ 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), + _local_target("/b"), ] 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 +def test_dedupe_collapses_the_same_path() -> None: + assert dedupe_local_targets([_local_target("/repo"), _local_target("/repo")]) == [ + _local_target("/repo") + ] diff --git a/tests/test_session_entries.py b/tests/test_session_entries.py index e08c59aa..787422a1 100644 --- a/tests/test_session_entries.py +++ b/tests/test_session_entries.py @@ -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 @@ -6,82 +6,175 @@ from typing import TYPE_CHECKING, Any 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: 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 _source(subdir: str, path: str, *, protect_metadata: bool = False) -> dict[str, Any]: + return {"source_path": path, "workspace_subdir": subdir, "protect_metadata": protect_metadata} -def test_copied_source_becomes_localdir_entry(tmp_path: Path) -> None: - entries, bind_mounts, staged_dirs = build_session_entries([_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 == [ +def test_source_becomes_writable_bind_mount(tmp_path: Path) -> None: + assert build_bind_mounts([_source("repo", str(tmp_path))]) == [ { "source": str(tmp_path.resolve()), "target": "/workspace/repo", - "read_only": True, + "read_only": False, } ] -def test_mixed_sources_split_correctly(tmp_path: Path) -> None: - copied = tmp_path / "copied" - mounted = tmp_path / "mounted" - copied.mkdir() - mounted.mkdir() +def test_git_dir_is_remounted_read_only_when_protected(tmp_path: Path) -> None: + (tmp_path / ".git").mkdir() - entries, bind_mounts, _staged = build_session_entries( - [ - _source("copied", str(copied)), - _source("mounted", str(mounted), mount=True), - ] - ) + mounts = build_bind_mounts([_source("repo", str(tmp_path), protect_metadata=True)]) - assert list(entries) == ["copied"] - assert isinstance(entries["copied"], LocalDir) - assert [m["target"] for m in bind_mounts] == ["/workspace/mounted"] + assert mounts == [ + {"source": str(tmp_path.resolve()), "target": "/workspace/repo", "read_only": False}, + { + "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: - entries, bind_mounts, staged_dirs = build_session_entries( - [ - {"source_path": "", "workspace_subdir": "x"}, - {"source_path": "/p", "workspace_subdir": ""}, - ] + assert ( + build_bind_mounts( + [ + {"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: - repo = tmp_path / "repo" - repo.mkdir() - (repo / "real.txt").write_text("content") - (repo / "link.txt").symlink_to(repo / "real.txt") +def test_manifest_entries_upload_sources_for_backends_without_bind_mounts( + tmp_path: Path, +) -> None: + entries = build_manifest_entries([_source("repo", str(tmp_path), protect_metadata=True)]) - entries, _mounts, staged_dirs = build_session_entries([_source("repo", str(repo))]) - - assert len(staged_dirs) == 1 + assert set(entries) == {"repo"} entry = entries["repo"] assert isinstance(entry, LocalDir) - assert entry.src == staged_dirs[0] - assert not (staged_dirs[0] / "link.txt").is_symlink() - assert (staged_dirs[0] / "link.txt").read_text() == "content" + assert entry.src == tmp_path.resolve() + + +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")