feat: add support for setup script execution before scans

This commit is contained in:
TheArtificialQ 2026-07-01 10:27:54 +02:00
parent f342808d2b
commit 7bbe862945
11 changed files with 519 additions and 0 deletions

View file

@ -177,6 +177,9 @@ strix --target api.your-app.com --instruction "Focus on business logic flaws and
# Provide detailed instructions through file (e.g., rules of engagement, scope, exclusions)
strix --target api.your-app.com --instruction-file ./instruction.md
# Prepare the sandbox before scanning (install dependencies, seed data, connect VPN, etc.)
strix --target ./app-directory --setup-script ./scripts/prepare-sandbox.sh
# Force PR diff-scope against a specific base branch
strix -n --target ./ --scan-mode quick --scope-mode diff --diff-base origin/main
```

View file

@ -29,6 +29,13 @@ strix --target <target> [options]
</Note>
</ParamField>
<ParamField path="--setup-script" type="string">
Path to a bash script to execute inside the Docker container as the first step before the scan begins.
Use this to install dependencies, seed databases, establish VPN connections, or perform other environment preparation.
The script is bind-mounted read-only into the sandbox and run with `bash`.
</ParamField>
<ParamField path="--instruction" type="string">
Custom instructions for the scan. Use for credentials, focus areas, or specific testing approaches.
</ParamField>
@ -98,6 +105,9 @@ strix -t https://github.com/org/app -t https://staging.example.com
# Large local repository — bind-mount instead of copying it in
strix --mount ./huge-monorepo
# Prepare the sandbox before scanning
strix --target ./app --setup-script ./scripts/prepare-sandbox.sh
```
## Exit Codes

View file

@ -49,6 +49,7 @@ if TYPE_CHECKING:
logger = logging.getLogger(__name__)
StreamEventSink = Callable[[str, Any], None]
SetupScriptEventSink = Callable[[dict[str, Any]], None]
async def run_strix_scan(
@ -64,6 +65,7 @@ async def run_strix_scan(
model: str | None = None,
cleanup_on_exit: bool = True,
event_sink: StreamEventSink | None = None,
setup_script_event_sink: SetupScriptEventSink | None = None,
) -> RunResultBase | None:
"""Run or resume one Strix scan against a sandbox."""
if scan_id is None:
@ -144,6 +146,8 @@ async def run_strix_scan(
scan_id,
image=image,
local_sources=local_sources or [],
setup_script=scan_config.get("setup_script"),
setup_script_event_sink=setup_script_event_sink,
)
logger.info("Sandbox ready for scan %s", scan_id)

View file

@ -386,6 +386,7 @@ VulnerabilityDetailScreen {
.browser-tool,
.terminal-tool,
.setup-script-tool,
.agents-graph-tool,
.file-edit-tool,
.proxy-tool,
@ -410,6 +411,8 @@ VulnerabilityDetailScreen {
.browser-tool.status-running,
.terminal-tool.status-completed,
.terminal-tool.status-running,
.setup-script-tool.status-completed,
.setup-script-tool.status-running,
.agents-graph-tool.status-completed,
.agents-graph-tool.status-running,
.file-edit-tool.status-completed,

View file

@ -94,6 +94,7 @@ async def run_cli(args: Any) -> None: # noqa: PLR0915
"scope_mode": getattr(args, "scope_mode", "auto"),
"diff_base": getattr(args, "diff_base", None),
"resume_instruction": getattr(args, "user_explicit_instruction", None) or "",
"setup_script": getattr(args, "setup_script", None),
}
report_state = ReportState(args.run_name)

View file

@ -315,6 +315,25 @@ def _positive_budget(value: str) -> float:
return budget
def _resolve_setup_script_path(
value: str | None,
parser: argparse.ArgumentParser,
) -> str | None:
if value is None:
return None
setup_script = Path(value).expanduser()
try:
resolved = setup_script.resolve(strict=True)
except OSError as exc:
parser.error(f"--setup-script path '{value}' is not readable: {exc}")
if not resolved.is_file():
parser.error(f"--setup-script requires a path to a file: {value}")
return str(resolved)
def parse_arguments() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Strix Multi-Agent Cybersecurity Penetration Testing Tool",
@ -334,6 +353,9 @@ Examples:
# Large local repository (bind-mounted read-only instead of copied)
strix --mount ./huge-monorepo
# Prepare the sandbox before scanning
strix --target ./my-project --setup-script ./scripts/prepare-sandbox.sh
# Domain penetration test
strix --target example.com
@ -378,6 +400,17 @@ Examples:
"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(
"--setup-script",
type=str,
metavar="PATH",
help=(
"Path to a bash script to execute inside the Docker container as the "
"first step before the scan begins. Useful for installing dependencies, "
"seeding databases, establishing VPN connections, or other environment "
"preparation."
),
)
parser.add_argument(
"--instruction",
type=str,
@ -548,6 +581,8 @@ Examples:
"--mount <path> to bind-mount the directory instead of copying it."
)
args.setup_script = _resolve_setup_script_path(args.setup_script, parser)
return args
@ -568,6 +603,7 @@ def _persist_run_record(args: argparse.Namespace) -> None:
"diff_scope": getattr(args, "diff_scope", {"active": False}),
"scope_mode": args.scope_mode,
"diff_base": args.diff_base,
"setup_script": args.setup_script,
}
write_run_record(run_dir, run_record)
@ -612,6 +648,8 @@ def _load_resume_state(args: argparse.Namespace, parser: argparse.ArgumentParser
args.local_sources = state.get("local_sources")
if state.get("diff_scope"):
args.diff_scope = state.get("diff_scope")
if args.setup_script is None and state.get("setup_script"):
args.setup_script = state.get("setup_script")
persisted_scan_mode = state.get("scan_mode")
if persisted_scan_mode and args.scan_mode == "deep":
args.scan_mode = persisted_scan_mode

View file

@ -745,6 +745,7 @@ class StrixTUIApp(App): # type: ignore[misc]
"scope_mode": getattr(args, "scope_mode", "auto"),
"diff_base": getattr(args, "diff_base", None),
"resume_instruction": getattr(args, "user_explicit_instruction", None) or "",
"setup_script": getattr(args, "setup_script", None),
}
def _setup_cleanup_handlers(self) -> None:
@ -1127,6 +1128,19 @@ class StrixTUIApp(App): # type: ignore[misc]
"completed": ("Agent completed", ""),
}
if agent_data.get("kind") == "setup_script":
if status == "running":
text = self._get_animated_verb_text(agent_id, "Running setup script")
return (text, keymap_styled([("ctrl-q", "quit")]), True)
if status == "completed":
text = Text()
text.append("Setup script completed")
return (text, Text(), False)
if status == "failed":
text = Text()
text.append("Setup script failed", style="red")
return (text, Text(), False)
if status in simple_statuses:
msg, _ = simple_statuses[status]
text = Text()
@ -1372,6 +1386,7 @@ class StrixTUIApp(App): # type: ignore[misc]
interactive=True,
max_budget_usd=getattr(self.args, "max_budget_usd", None),
event_sink=self._capture_sdk_event,
setup_script_event_sink=self._capture_setup_script_event,
),
)
@ -1415,6 +1430,18 @@ class StrixTUIApp(App): # type: ignore[misc]
def _record_sdk_event(self, agent_id: str, event: Any) -> None:
self.live_view.ingest_sdk_event(agent_id, event)
def _capture_setup_script_event(self, event: dict[str, Any]) -> None:
try:
self.call_from_thread(self._record_setup_script_event, event)
except RuntimeError:
self._record_setup_script_event(event)
def _record_setup_script_event(self, event: dict[str, Any]) -> None:
self.live_view.record_setup_script_event(event)
self._displayed_events.clear()
self._update_chat_view()
self._update_agent_status_display()
def _add_agent_node(self, agent_data: dict[str, Any]) -> None:
if len(self.screen_stack) > 1 or self.show_splash:
return
@ -1685,6 +1712,8 @@ class StrixTUIApp(App): # type: ignore[misc]
if self.selected_agent_id in self.live_view.agents:
agent_data = self.live_view.agents[self.selected_agent_id]
agent_name = agent_data.get("name", "Unknown Agent")
if agent_data.get("kind") == "setup_script":
return agent_name, False
agent_status = agent_data.get("status", "running")
if agent_status not in ["running", "waiting"]:

View file

@ -14,6 +14,10 @@ from strix.core.paths import runtime_state_dir
from strix.interface.tui.history import load_session_history
SETUP_SCRIPT_AGENT_ID = "setup-script"
_SETUP_SCRIPT_CALL_ID = "setup-script"
class TuiLiveView:
def __init__(self) -> None:
self.agents: dict[str, dict[str, Any]] = {}
@ -97,6 +101,48 @@ class TuiLiveView:
},
)
def record_setup_script_event(self, data: dict[str, Any]) -> None:
status = str(data.get("status") or "running")
agent_status = "failed" if status in {"failed", "error"} else status
self.upsert_agent(
SETUP_SCRIPT_AGENT_ID,
name="Setup Script",
parent_id=None,
status=agent_status,
)
self.agents[SETUP_SCRIPT_AGENT_ID]["kind"] = "setup_script"
self._record_tool_call_data(
SETUP_SCRIPT_AGENT_ID,
{
"call_id": _SETUP_SCRIPT_CALL_ID,
"tool_name": "setup_script",
"args": {
"script": data.get("source_path"),
"container_path": data.get("container_path"),
"command": data.get("command"),
},
},
)
if status not in {"completed", "failed", "error"}:
return
self._record_tool_output_data(
SETUP_SCRIPT_AGENT_ID,
{
"call_id": _SETUP_SCRIPT_CALL_ID,
"tool_name": "setup_script",
"output": {
"success": status == "completed",
"stdout": data.get("stdout", ""),
"stderr": data.get("stderr", ""),
"exit_code": data.get("exit_code"),
"duration_seconds": data.get("duration_seconds"),
},
},
)
def ingest_sdk_event(self, agent_id: str, event: Any) -> None:
event_type = getattr(event, "type", "")
if event_type == "raw_response_event":

View file

@ -64,6 +64,26 @@ def _parse_sdk_shell_result(result: Any) -> dict[str, Any]:
return parsed
def _parse_setup_script_result(result: Any) -> dict[str, Any]:
if not isinstance(result, dict):
return _parse_sdk_shell_result(result)
parts: list[str] = []
stdout = str(result.get("stdout") or "").strip()
stderr = str(result.get("stderr") or "").strip()
if stdout:
parts.append(f"stdout:\n{stdout}")
if stderr:
parts.append(f"stderr:\n{stderr}")
if not parts:
parts.append("(no output)")
parsed: dict[str, Any] = {"content": "\n\n".join(parts)}
if result.get("exit_code") is not None:
parsed["exit_code"] = result.get("exit_code")
return parsed
def _truncate_line(line: str) -> str:
if len(line) > MAX_LINE_LENGTH:
return line[: MAX_LINE_LENGTH - 3] + "..."
@ -237,6 +257,42 @@ class ExecCommandRenderer(BaseToolRenderer):
return Static(content, classes=cls.get_css_classes(status))
@register_tool_renderer
class SetupScriptRenderer(BaseToolRenderer):
tool_name: ClassVar[str] = "setup_script"
css_classes: ClassVar[list[str]] = ["tool-call", "terminal-tool", "setup-script-tool"]
@classmethod
def render(cls, tool_data: dict[str, Any]) -> Static:
args = tool_data.get("args", {})
status = tool_data.get("status", "unknown")
result = tool_data.get("result")
command = str(args.get("command") or "bash /tmp/strix-setup-script.sh")
script = args.get("script")
duration = result.get("duration_seconds") if isinstance(result, dict) else None
meta_parts: list[str] = []
if script:
meta_parts.append(f"script:{script}")
if isinstance(duration, int | float):
meta_parts.append(f"{duration:.1f}s")
meta = ", ".join(meta_parts) if meta_parts else None
parsed = _parse_setup_script_result(result) if result is not None else None
content = _build_terminal_content(
prompt="setup",
prompt_style="#22c55e",
command=command,
parsed_result=parsed,
tool_status=status,
meta=meta,
)
return Static(content, classes=cls.get_css_classes(status))
@register_tool_renderer
class WriteStdinRenderer(BaseToolRenderer):
tool_name: ClassVar[str] = "write_stdin"

View file

@ -2,7 +2,10 @@
from __future__ import annotations
import contextlib
import logging
import time
from collections.abc import Callable
from pathlib import Path
from typing import Any
@ -15,6 +18,7 @@ from strix.runtime.caido_bootstrap import bootstrap_caido
logger = logging.getLogger(__name__)
SetupScriptEventSink = Callable[[dict[str, Any]], None]
# In-container Caido sidecar port (matches the image's caido-cli bind).
@ -25,6 +29,8 @@ _SESSION_CACHE: dict[str, dict[str, Any]] = {}
# Manifest root inside the container; entry keys hang off this path.
_WORKSPACE_ROOT = "/workspace"
_SETUP_SCRIPT_CONTAINER_PATH = "/tmp/strix-setup-script.sh"
_SETUP_SCRIPT_TIMEOUT_SECONDS = 3600
def build_session_entries(
@ -58,11 +64,116 @@ def build_session_entries(
return entries, bind_mounts
def build_setup_script_mount(setup_script: str | None) -> dict[str, Any] | None:
"""Return the Docker bind-mount spec for a host setup script, if configured."""
if not setup_script:
return None
resolved = Path(setup_script).expanduser().resolve()
if not resolved.is_file():
raise FileNotFoundError(
f"Setup script does not exist or is not a file: {setup_script}"
)
return {
"source": str(resolved),
"target": _SETUP_SCRIPT_CONTAINER_PATH,
"read_only": True,
}
def _exec_text(value: Any) -> str:
if isinstance(value, bytes):
return value.decode("utf-8", errors="replace")
return str(value or "")
def _emit_setup_script_event(
event_sink: SetupScriptEventSink | None,
data: dict[str, Any],
) -> None:
if event_sink is None:
return
try:
event_sink(data)
except Exception:
logger.exception("Setup script event sink failed")
async def execute_setup_script(
session: Any,
*,
source_path: str,
event_sink: SetupScriptEventSink | None = None,
) -> None:
"""Run the configured setup script inside the already-started sandbox."""
logger.info("Running setup script inside sandbox: %s", _SETUP_SCRIPT_CONTAINER_PATH)
command = f"bash {_SETUP_SCRIPT_CONTAINER_PATH}"
started = time.perf_counter()
_emit_setup_script_event(
event_sink,
{
"status": "running",
"source_path": source_path,
"container_path": _SETUP_SCRIPT_CONTAINER_PATH,
"command": command,
},
)
result = await session.exec(
"bash",
_SETUP_SCRIPT_CONTAINER_PATH,
timeout=_SETUP_SCRIPT_TIMEOUT_SECONDS,
)
elapsed = time.perf_counter() - started
stdout = _exec_text(getattr(result, "stdout", "")).strip()
stderr = _exec_text(getattr(result, "stderr", "")).strip()
exit_code = getattr(result, "exit_code", "unknown")
if result.ok():
_emit_setup_script_event(
event_sink,
{
"status": "completed",
"source_path": source_path,
"container_path": _SETUP_SCRIPT_CONTAINER_PATH,
"command": command,
"stdout": stdout,
"stderr": stderr,
"exit_code": exit_code,
"duration_seconds": elapsed,
},
)
if stdout:
logger.info("Setup script completed successfully: %s", stdout[-1000:])
else:
logger.info("Setup script completed successfully")
return
_emit_setup_script_event(
event_sink,
{
"status": "failed",
"source_path": source_path,
"container_path": _SETUP_SCRIPT_CONTAINER_PATH,
"command": command,
"stdout": stdout,
"stderr": stderr,
"exit_code": exit_code,
"duration_seconds": elapsed,
},
)
raise RuntimeError(
"Setup script failed inside sandbox "
f"(exit {exit_code}). stdout: {stdout[-2000:]!r} stderr: {stderr[-2000:]!r}"
)
async def create_or_reuse(
scan_id: str,
*,
image: str,
local_sources: list[dict[str, Any]],
setup_script: str | None = None,
setup_script_event_sink: SetupScriptEventSink | None = None,
) -> dict[str, Any]:
"""Return the existing session bundle for ``scan_id`` or create a new one.
@ -76,6 +187,9 @@ async def create_or_reuse(
return cached
entries, bind_mounts = build_session_entries(local_sources)
setup_script_mount = build_setup_script_mount(setup_script)
if setup_script_mount is not None:
bind_mounts.append(setup_script_mount)
# Caido runs as an in-container sidecar; HTTP(S) traffic from any
# process started via ``session.exec`` (the SDK's Shell tool, etc.)
@ -123,6 +237,13 @@ async def create_or_reuse(
container_url=container_caido_url,
)
if setup_script_mount is not None:
await execute_setup_script(
session,
source_path=str(setup_script_mount["source"]),
event_sink=setup_script_event_sink,
)
bundle = {
"client": client,
"session": session,

208
tests/test_setup_script.py Normal file
View file

@ -0,0 +1,208 @@
"""Tests for sandbox setup-script mounting and execution."""
from __future__ import annotations
from types import SimpleNamespace
from typing import TYPE_CHECKING, Any
import pytest
from strix.interface.tui.live_view import SETUP_SCRIPT_AGENT_ID, TuiLiveView
from strix.runtime import session_manager
from strix.runtime.session_manager import (
build_setup_script_mount,
execute_setup_script,
)
if TYPE_CHECKING:
from pathlib import Path
class FakeExecResult:
def __init__(
self,
*,
exit_code: int = 0,
stdout: str | bytes = "",
stderr: str | bytes = "",
) -> None:
self.exit_code = exit_code
self.stdout = stdout
self.stderr = stderr
def ok(self) -> bool:
return self.exit_code == 0
class FakeSession:
def __init__(self, result: FakeExecResult | None = None) -> None:
self.result = result or FakeExecResult()
self.exec_calls: list[tuple[tuple[Any, ...], dict[str, Any]]] = []
async def exec(self, *args: Any, **kwargs: Any) -> FakeExecResult:
self.exec_calls.append((args, kwargs))
return self.result
async def resolve_exposed_port(self, _port: int) -> SimpleNamespace:
return SimpleNamespace(host="127.0.0.1", port=12345)
def test_build_setup_script_mount_resolves_file(tmp_path: Path) -> None:
script = tmp_path / "setup.sh"
script.write_text("#!/usr/bin/env bash\n", encoding="utf-8")
assert build_setup_script_mount(str(script)) == {
"source": str(script.resolve()),
"target": "/tmp/strix-setup-script.sh",
"read_only": True,
}
def test_build_setup_script_mount_rejects_missing_file(tmp_path: Path) -> None:
with pytest.raises(FileNotFoundError, match="Setup script does not exist"):
build_setup_script_mount(str(tmp_path / "missing.sh"))
def test_live_view_records_setup_script_output() -> None:
live_view = TuiLiveView()
live_view.record_setup_script_event(
{
"status": "running",
"source_path": "/host/setup.sh",
"container_path": "/tmp/strix-setup-script.sh",
"command": "bash /tmp/strix-setup-script.sh",
}
)
live_view.record_setup_script_event(
{
"status": "completed",
"source_path": "/host/setup.sh",
"container_path": "/tmp/strix-setup-script.sh",
"command": "bash /tmp/strix-setup-script.sh",
"stdout": "seeded database",
"stderr": "",
"exit_code": 0,
"duration_seconds": 1.25,
}
)
assert live_view.agents[SETUP_SCRIPT_AGENT_ID]["status"] == "completed"
assert live_view.agents[SETUP_SCRIPT_AGENT_ID]["kind"] == "setup_script"
events = live_view.events_for_agent(SETUP_SCRIPT_AGENT_ID)
assert len(events) == 1
assert events[0]["data"]["tool_name"] == "setup_script"
assert events[0]["data"]["status"] == "completed"
assert events[0]["data"]["result"]["stdout"] == "seeded database"
@pytest.mark.asyncio
async def test_execute_setup_script_runs_bash() -> None:
session = FakeSession(FakeExecResult(stdout="ready\n"))
events: list[dict[str, Any]] = []
await execute_setup_script(
session,
source_path="/host/setup.sh",
event_sink=events.append,
)
assert session.exec_calls == [
(("bash", "/tmp/strix-setup-script.sh"), {"timeout": 3600}),
]
assert [event["status"] for event in events] == ["running", "completed"]
assert events[0]["source_path"] == "/host/setup.sh"
assert events[0]["command"] == "bash /tmp/strix-setup-script.sh"
assert events[1]["stdout"] == "ready"
assert events[1]["stderr"] == ""
assert events[1]["exit_code"] == 0
@pytest.mark.asyncio
async def test_execute_setup_script_raises_on_failure() -> None:
events: list[dict[str, Any]] = []
session = FakeSession(FakeExecResult(exit_code=12, stderr=b"boom"))
with pytest.raises(RuntimeError, match="Setup script failed inside sandbox"):
await execute_setup_script(
session,
source_path="/host/setup.sh",
event_sink=events.append,
)
assert [event["status"] for event in events] == ["running", "failed"]
assert events[1]["stderr"] == "boom"
assert events[1]["exit_code"] == 12
@pytest.mark.asyncio
async def test_create_or_reuse_runs_setup_after_runtime_bootstrap(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
script = tmp_path / "setup.sh"
script.write_text("#!/usr/bin/env bash\n", encoding="utf-8")
session = FakeSession()
events: list[str] = []
captured_mounts: list[dict[str, Any]] = []
async def fake_backend(
*,
image: str,
manifest: Any,
exposed_ports: tuple[int, ...],
bind_mounts: list[dict[str, Any]] | None = None,
) -> tuple[object, FakeSession]:
del image, manifest, exposed_ports
events.append("backend")
captured_mounts.extend(bind_mounts or [])
return object(), session
async def fake_bootstrap_caido(
sandbox_session: FakeSession,
*,
host_url: str,
container_url: str,
) -> object:
del sandbox_session, host_url, container_url
events.append("caido")
return object()
original_execute_setup_script = session_manager.execute_setup_script
async def record_execute_setup_script(
sandbox_session: FakeSession,
*,
source_path: str,
event_sink: Any,
) -> None:
del event_sink
events.append("setup")
await original_execute_setup_script(sandbox_session, source_path=source_path)
monkeypatch.setattr(session_manager, "_SESSION_CACHE", {})
monkeypatch.setattr(
session_manager,
"load_settings",
lambda: SimpleNamespace(runtime=SimpleNamespace(backend="docker")),
)
monkeypatch.setattr(session_manager, "get_backend", lambda _name: fake_backend)
monkeypatch.setattr(session_manager, "bootstrap_caido", fake_bootstrap_caido)
monkeypatch.setattr(session_manager, "execute_setup_script", record_execute_setup_script)
await session_manager.create_or_reuse(
"scan-with-setup",
image="strix-test:latest",
local_sources=[],
setup_script=str(script),
)
assert events == ["backend", "caido", "setup"]
assert captured_mounts == [
{
"source": str(script.resolve()),
"target": "/tmp/strix-setup-script.sh",
"read_only": True,
}
]