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
+11
View File
@@ -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()
+16 -42
View File
@@ -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 <run_name> 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 <path> 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")
+18 -1
View File
@@ -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)
+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)
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