Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```
Expand Down
10 changes: 10 additions & 0 deletions docs/usage/cli.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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>
Expand Down Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions strix/core/runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@
logger = logging.getLogger(__name__)

StreamEventSink = Callable[[str, Any], None]
SetupScriptEventSink = Callable[[dict[str, Any]], None]


async def run_strix_scan(
Expand All @@ -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:
Expand Down Expand Up @@ -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)

Expand Down
3 changes: 3 additions & 0 deletions strix/interface/assets/tui_styles.tcss
Original file line number Diff line number Diff line change
Expand Up @@ -386,6 +386,7 @@ VulnerabilityDetailScreen {

.browser-tool,
.terminal-tool,
.setup-script-tool,
.agents-graph-tool,
.file-edit-tool,
.proxy-tool,
Expand All @@ -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,
Expand Down
1 change: 1 addition & 0 deletions strix/interface/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
38 changes: 38 additions & 0 deletions strix/interface/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -334,6 +353,9 @@ def parse_arguments() -> argparse.Namespace:
# 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

Expand Down Expand Up @@ -378,6 +400,17 @@ def parse_arguments() -> argparse.Namespace:
"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,
Expand Down Expand Up @@ -548,6 +581,8 @@ def parse_arguments() -> argparse.Namespace:
"--mount <path> to bind-mount the directory instead of copying it."
)

args.setup_script = _resolve_setup_script_path(args.setup_script, parser)

return args


Expand All @@ -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)

Expand Down Expand Up @@ -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")
Comment on lines +651 to +652

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Resumed setup-script path not re-validated

When a scan is resumed and the setup script path is loaded from persisted state, the value bypasses _resolve_setup_script_path. If the file was moved or deleted between runs, build_setup_script_mount raises a bare FileNotFoundError rather than a user-friendly parser.error() message. Consider calling _resolve_setup_script_path here (or at least catching FileNotFoundError at the call site and converting it to a parser.error).

Prompt To Fix With AI
This is a comment left during a code review.
Path: strix/interface/main.py
Line: 651-652

Comment:
**Resumed setup-script path not re-validated**

When a scan is resumed and the setup script path is loaded from persisted state, the value bypasses `_resolve_setup_script_path`. If the file was moved or deleted between runs, `build_setup_script_mount` raises a bare `FileNotFoundError` rather than a user-friendly `parser.error()` message. Consider calling `_resolve_setup_script_path` here (or at least catching `FileNotFoundError` at the call site and converting it to a `parser.error`).

How can I resolve this? If you propose a fix, please make it concise.

persisted_scan_mode = state.get("scan_mode")
if persisted_scan_mode and args.scan_mode == "deep":
args.scan_mode = persisted_scan_mode
Expand Down
29 changes: 29 additions & 0 deletions strix/interface/tui/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -745,6 +745,7 @@ def _build_scan_config(self, args: argparse.Namespace) -> dict[str, Any]:
"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:
Expand Down Expand Up @@ -1127,6 +1128,19 @@ def keymap_styled(keys: list[tuple[str, str]]) -> Text:
"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()
Expand Down Expand Up @@ -1372,6 +1386,7 @@ def scan_target() -> None:
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,
),
)

Expand Down Expand Up @@ -1415,6 +1430,18 @@ def _capture_sdk_event(self, agent_id: str, event: Any) -> None:
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
Expand Down Expand Up @@ -1685,6 +1712,8 @@ def _validate_agent_for_stopping(self) -> tuple[str, bool]:
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"]:
Expand Down
46 changes: 46 additions & 0 deletions strix/interface/tui/live_view.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@
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]] = {}
Expand Down Expand Up @@ -97,6 +101,48 @@ def record_user_message(self, agent_id: str, content: str) -> None:
},
)

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":
Expand Down
56 changes: 56 additions & 0 deletions strix/interface/tui/renderers/shell_renderer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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] + "..."
Expand Down Expand Up @@ -237,6 +257,42 @@ def render(cls, tool_data: dict[str, Any]) -> Static:
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"
Expand Down
Loading