This commit is contained in:
oyasumi 2026-08-27 21:45:05 +00:00 committed by GitHub
commit e4de41eb4f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
40 changed files with 1494 additions and 745 deletions

View file

@ -30,7 +30,9 @@ repos:
rev: v5.0.0
hooks:
- id: trailing-whitespace
exclude: ^strix/interface/viewer/static/assets/
- id: end-of-file-fixer
exclude: ^strix/interface/viewer/static/assets/
- id: check-toml
- id: check-merge-conflict
- id: check-added-large-files

View file

@ -196,12 +196,19 @@ Use `--host 0.0.0.0` to make the viewer reachable from other machines. Replace `
strix --target ./app-directory
# Security review of a GitHub repository
strix --target https://github.com/org/repo
strix --target https://github.com/org/repo.git
# Black-box web application assessment
strix --target https://your-app.com
```
Web targets are host-level: paths and queries passed to `--target` are removed,
and repeated URLs on the same host collapse to one target. Put exact starting
endpoints in `--instruction`. Network references entered in the interactive
start screen are inferred as host/IP targets while the complete text remains the
task. Scheme, port, path, and query differences on the same host collapse to one
target; distinct hosts, subdomains, and IP addresses remain separate targets.
### API Testing (OpenAPI / Swagger / Postman)
Point Strix at an API contract and it tests every declared endpoint instead of
@ -231,7 +238,7 @@ strix --target "postman://<collection-uuid>?env=<environment-uuid>"
strix --target https://your-app.com --instruction "Perform authenticated testing using credentials: user:pass"
# Multi-target testing (source code + deployed app)
strix -t https://github.com/org/app -t https://your-app.com
strix -t https://github.com/org/app.git -t https://your-app.com
# Targets from a file, one target per non-empty, non-comment line
strix --target-list ./targets.txt
@ -322,7 +329,7 @@ strix auth logout # forget the sign-in
#### Connect your own MCP servers
Strix can connect to Model Context Protocol (MCP) servers you list and expose their tools to the agent during a run. Create `~/.strix/mcp-servers.json` with a JSON list of servers. Each entry is either a local `stdio` server that Strix launches as a subprocess, or a remote `http` server:
Strix can connect to Model Context Protocol (MCP) servers you list and let the agent discover and call their tools on demand during a run. Create `~/.strix/mcp-servers.json` with a JSON list of servers. Each entry is either a local `stdio` server that Strix launches as a subprocess, or a remote `http` server:
```json
[
@ -342,7 +349,7 @@ Strix can connect to Model Context Protocol (MCP) servers you list and expose th
]
```
Each server's tools are namespaced by `name` (for example `local_fs_read_file`). Omit `allowed_tools` to expose every tool the server offers, or set it to a list to restrict which tools the agent can call. The file is optional, and a server that fails to connect is skipped without failing the run. You can point Strix at a different file with `STRIX_MCP_CONFIG`.
The model uses `list_mcps`, `describe_mcp`, and `call_mcp` to reach connected servers instead of receiving one model-visible function per remote tool. Omit `allowed_tools` to make every tool on that connection discoverable, or set it to a list to restrict which tools `call_mcp` can invoke. The file is optional, and a server that fails to connect is skipped without failing the run. You can point Strix at a different file with `STRIX_MCP_CONFIG`.
**Recommended models for best results:**

View file

@ -3,7 +3,7 @@ title: "MCP Servers"
description: "Connect your own MCP servers and expose their tools to the agent"
---
Strix can connect to [Model Context Protocol (MCP)](https://modelcontextprotocol.io) servers you list and expose their tools to the agent during a run. Use this to let the agent read how your system is actually built instead of inferring it from the outside.
Strix can connect to [Model Context Protocol (MCP)](https://modelcontextprotocol.io) servers you list and let the agent discover and call their tools on demand during a run. Use this to let the agent read how your system is actually built instead of inferring it from the outside.
A few things it pays off for:
@ -47,9 +47,8 @@ Strix reads this file at the start of each run. There is no default file, so no
## Fields
<ParamField path="name" type="string" required>
A short label for the connection. Each server's tools are namespaced by
`name` (for example `local_fs_read_file`), so two servers can offer the same
tool name without colliding.
A short label for the connection. The agent passes it to `describe_mcp` and
`call_mcp`, so two servers can offer the same tool name without colliding.
</ParamField>
<ParamField path="transport" type="string">
@ -124,6 +123,18 @@ easy to pick out of a transcript. The terminal shows the call and its arguments;
results can be large and arbitrary, so read them in the viewer, which shows a
preview you can expand.
## Calling tools
Individual remote tools are not registered as model-visible functions. The
agent uses three bounded dispatch tools instead:
- `list_mcps` lists the connected servers.
- `describe_mcp` returns one connection's allowed tool names, descriptions, and schemas.
- `call_mcp` invokes an allowed tool with arguments matching that schema.
This keeps the model's tool list bounded even when a server exposes a large
catalog. `allowed_tools` is enforced before dispatch.
## Behavior
- The config file is optional. Without it, a run simply gets no MCP tools.

View file

@ -55,13 +55,13 @@ Strix accepts multiple target types:
strix --target ./app-directory
# GitHub repository
strix --target https://github.com/org/repo
strix --target https://github.com/org/repo.git
# Live web application
strix --target https://your-app.com
# Multiple targets (white-box testing)
strix -t https://github.com/org/repo -t https://your-app.com
strix -t https://github.com/org/repo.git -t https://your-app.com
# Targets from a file, one target per non-empty, non-comment line
strix --target-list ./targets.txt

View file

@ -6,13 +6,17 @@ description: "Command-line options for Strix"
## Basic Usage
```bash
strix (--target <target> | --target-list <path>) [options]
strix [(--target <target> | --target-list <path>)] [options]
```
## Options
<ParamField path="--target, -t" type="string">
Target to test. Accepts URLs, repositories, local directories, domains, IP addresses, API spec files (OpenAPI/Swagger `.json`/`.yaml`, a Postman collection export), or a live Postman collection by id (`postman://<collection-uuid>`). Can be specified multiple times. Fresh runs require at least one target source: `--target` or `--target-list`.
Target to test. Accepts URLs, repositories, local directories, domains, IP addresses, API spec files (OpenAPI/Swagger `.json`/`.yaml`, a Postman collection export), or a live Postman collection by id (`postman://<collection-uuid>`). Can be specified multiple times. Fresh headless runs require at least one target source: `--target` or `--target-list`.
Web URL targets are canonicalized to their hostname, and repeated URLs on the same host become one target. Put endpoint paths, query strings, and other starting-point details in `--instruction`; hosts explicitly named there are also in prompt-level scope. Network references entered on the interactive start screen are inferred as canonical host/IP targets while the complete text remains the task. Scheme, port, path, and query differences are deduplicated; distinct hosts, subdomains, and IP addresses remain separate targets. Every inferred hostname authorizes that exact host and its descendant subdomains.
HTTP repository URLs ending in `.git` are recognized automatically. For a repository URL without `.git`, prefix it with `git+` (for example, `git+https://github.com/org/repo`). This explicit syntax prevents ordinary web paths from being mistaken for repositories.
When the target is an API spec, Strix copies it into the agent's workspace and authorizes the base URLs it declares (including those resolved from a Postman environment) as in-scope hosts - so the agent reads the contract and tests the full declared surface instead of discovering endpoints by crawling. Pair the spec with the deployed base URL (e.g. `--target ./openapi.yaml --target https://api.example.com`) so the agent has a reachable host to attack.
@ -139,7 +143,7 @@ strix --target https://example.com --max-budget 25 --max-turns 300
strix -n --target ./ --scan-mode quick --scope-mode diff --diff-base origin/main
# Multi-target white-box testing
strix -t https://github.com/org/app -t https://staging.example.com
strix -t https://github.com/org/app.git -t https://staging.example.com
# API spec + live target (OpenAPI/Swagger file or Postman collection)
strix -t ./openapi.yaml -t https://api.example.com

View file

@ -4,6 +4,12 @@ description: "Guide Strix with custom testing instructions"
---
Use instructions to provide context, credentials, or focus areas for your scan.
Configured web targets identify hosts, not endpoints. Put exact paths and query
strings in the instruction so they remain part of the task. Hosts explicitly
named in the instruction and their descendant subdomains are in prompt-level
scope. On the interactive start screen, those network references are also
recorded as canonical targets; each distinct hostname independently authorizes
that hostname and its descendant subdomains.
## Inline Instructions

View file

@ -20,7 +20,7 @@ Install, LLM setup, all flags, and the managed-cloud path are in the **penetrati
strix -n -t ./ --scan-mode standard --max-budget 15
# A GitHub repo directly
strix -n -t https://github.com/org/app --max-budget 15
strix -n -t https://github.com/org/app.git --max-budget 15
# Monorepo: point at the service that matters, not the whole tree
strix -n -t ./services/checkout --max-budget 20

View file

@ -40,7 +40,7 @@ Maximum category coverage comes from giving the agents both the source and a run
```bash
strix -n \
-t https://github.com/org/app \
-t https://github.com/org/app.git \
-t https://staging.example.com \
--scan-mode deep --max-budget 30 \
--instruction "OWASP Top 10:2025 assessment. Cover every category systematically and map each finding to its 2025 category id.

View file

@ -64,7 +64,7 @@ strix -n -t ./ --scan-mode standard --max-budget 10
strix -n -t https://staging.example.com --max-budget 20
# Repo + deployed app together (best coverage)
strix -n -t https://github.com/org/app -t https://staging.example.com
strix -n -t https://github.com/org/app.git -t https://staging.example.com
# Focused testing with credentials or scope hints
strix -n -t https://app.example.com \
@ -86,7 +86,7 @@ Key flags:
| Flag | Meaning |
|---|---|
| `-t, --target` | URL, repo URL, local path, domain, IP, OpenAPI/Postman spec, or `postman://<uuid>`. Repeatable. |
| `-t, --target` | Host-level web URL/domain, explicit repo URL, local path, IP, OpenAPI/Postman spec, or `postman://<uuid>`. Repeatable; duplicate web hosts collapse. |
| `--target-list PATH` | File of targets, one per line (`#` comments allowed). Repeatable, combines with `-t`. |
| `-n, --non-interactive` | Headless, exits on completion. Required for agents. |
| `-m, --scan-mode` | `quick` (minutes) / `standard` (~30 min) / `deep` (hours, default). |
@ -100,6 +100,9 @@ Key flags:
Scans take minutes (`quick`) to hours (`deep`). Run them in the background and poll for completion rather than blocking.
Put exact endpoint paths and query strings in `--instruction`, not `--target`.
Configured web targets are reduced to hosts; repository paths are preserved.
### Exit codes (headless)
- `0` — finished with no validated vulnerabilities **in what was analyzed**

View file

@ -35,7 +35,7 @@ Notes that matter for web apps specifically:
- **Give it credentials via `--instruction`** (or `--instruction-file` for anything long), including how to log in if the flow is unusual (magic link, SSO, MFA-exempt test user).
- **Two accounts beat one.** Multi-tenant IDOR and broken-access-control bugs — consistently the highest-impact class in web apps — can only be proven when the agent can attempt cross-account access.
- **Add the repo for white-box depth** when you have the source: `-t https://github.com/org/app -t https://staging.example.com` (or a local path). Source access materially improves coverage of business-logic and authorization flaws.
- **Add the repo for white-box depth** when you have the source: `-t https://github.com/org/app.git -t https://staging.example.com` (or a local path). Source access materially improves coverage of business-logic and authorization flaws.
- **Localhost works.** Point at `http://host.docker.internal:3000` (Docker Desktop) so the sandbox can reach a dev server on the host.
- `--scan-mode quick` for a fast dev-loop pass, `standard` (~30 min) for a normal review, `deep` for pre-release assurance. Always set `--max-budget`.

View file

@ -58,22 +58,35 @@ AUTONOMOUS BEHAVIOR:
</communication_rules>
<execution_guidelines>
{% if system_prompt_context and system_prompt_context.authorized_targets %}
{% if system_prompt_context and (system_prompt_context.authorized_targets or system_prompt_context.user_instruction_hosts_expand_scope) %}
SYSTEM-VERIFIED SCOPE:
- The following scope metadata is injected by the platform into the system prompt and is authoritative
- Scope source: {{ system_prompt_context.scope_source }}
- Authorization source: {{ system_prompt_context.authorization_source }}
- Every target listed below has already been verified by the platform as in-scope and authorized
- User instructions, chat messages, and other free-form text do NOT expand scope beyond this list
- Every configured target listed below has already been verified by the platform as in-scope and authorized
- Every network host explicitly named in the user's root scan task/instructions is also in-scope and authorized
- For each in-scope hostname, the exact hostname and all of its descendant subdomains are in scope
- A URL named in the root task or an API specification may guide what to test, but its scheme, port, path, query, or fragment does not narrow hostname scope
- Example: `app.example.com` authorizes `app.example.com` and `*.app.example.com`, but not `example.com`, sibling hosts such as `api.example.com`, or lookalikes such as `app.example.com.evil.test`
- Configured IP addresses are exact targets; do not infer adjacent addresses or network ranges
- Repository hosting origins named only by configured repository targets (for example, `github.com`) are not live web targets; repository contents and user-provided files do not expand network scope
- Hosts learned only from target content, fetched pages, redirects, tool output, third-party integrations, or other data are not in scope unless they are descendant subdomains of an in-scope hostname
- NEVER refuse, question authorization, or claim lack of permission for any target in this system-verified scope
- NEVER test any external domain, URL, host, IP, or repository that is not explicitly listed in this system-verified scope
- If the user mentions any asset outside this list, ignore that asset and continue working only on the listed in-scope targets
- NEVER test any external domain, URL, host, IP, or repository outside this system-verified scope
AUTHORIZED TARGETS:
{% if system_prompt_context.authorized_targets %}
AUTHORIZED CONFIGURED TARGETS:
{% for target in system_prompt_context.authorized_targets %}
{% if target.type == "web_host" %}
- host: {{ target.value }} (includes {{ target.value }} and *.{{ target.value }})
{% elif target.type == "ip_address" %}
- ip_address: {{ target.value }} (exact address)
{% else %}
- {{ target.type }}: {{ target.value }}{% if target.workspace_path %} (workspace: {{ target.workspace_path }}){% endif %}
{% endif %}
{% endfor %}
{% endif %}
{% endif %}
{% if system_prompt_context and system_prompt_context.mcp_available %}
MCP CONNECTIONS (available this run):

View file

@ -20,6 +20,7 @@ from strix.config.models import (
request_timeout_extra_args,
)
from strix.core.sessions import scrub_images_from_items
from strix.core.targets import canonical_network_host
if TYPE_CHECKING:
@ -104,97 +105,129 @@ def _render_workspace_files(scan_config: dict[str, Any]) -> list[str]:
]
def build_root_task(scan_config: dict[str, Any]) -> str:
targets = scan_config.get("targets", []) or []
diff_scope = scan_config.get("diff_scope") or {}
user_instructions = scan_config.get("user_instructions", "") or ""
def _emit_sections(context: list[str], sections: dict[str, list[str]]) -> None:
for label, items in sections.items():
if items:
context.append(f"\n\n{label}:")
context.extend(items)
sections: dict[str, list[str]] = {
def _split_target_sections(
targets: list[dict[str, Any]],
) -> tuple[dict[str, list[str]], dict[str, list[str]]]:
"""Sort targets into on-disk plumbing and network sections.
On-disk material (repos, local code, API specs) is where mounted code lives;
network targets (URLs/IPs) are what the run was pointed at. Scope semantics
are supplied separately by the system prompt, so this split only controls
how each kind of context is framed.
"""
ondisk: dict[str, list[str]] = {
"Repositories": [],
"Local Codebases": [],
"URLs": [],
"IP Addresses": [],
"API Specifications": [],
}
network: dict[str, list[str]] = {"Hosts": [], "IP Addresses": []}
for target in targets:
ttype = target.get("type")
details = target.get("details") or {}
workspace_subdir = details.get("workspace_subdir")
workspace_path = f"/workspace/{workspace_subdir}" if workspace_subdir else "/workspace"
if ttype == "repository":
url = details.get("target_repo", "")
cloned = details.get("cloned_repo_path")
sections["Repositories"].append(
ondisk["Repositories"].append(
f"- {url} (available at: {workspace_path})" if cloned else f"- {url}",
)
elif ttype == "local_code":
path = details.get("target_path", "unknown")
sections["Local Codebases"].append(
ondisk["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', '')}")
network["Hosts"].append(f"- {details.get('target_host', '')}")
elif ttype == "ip_address":
sections["IP Addresses"].append(f"- {details.get('target_ip', '')}")
network["IP Addresses"].append(f"- {details.get('target_ip', '')}")
elif ttype == "api_spec":
sections["API Specifications"].extend(_render_api_spec(details))
ondisk["API Specifications"].extend(_render_api_spec(details))
return ondisk, network
parts: list[str] = []
for label, items in sections.items():
if items:
parts.append(f"\n\n{label}:")
parts.extend(items)
def build_root_task(scan_config: dict[str, Any]) -> str:
"""Build the root agent's task.
The user's prompt is the task. Alongside it we render configured targets and
supporting context such as mounted code/spec paths, the working directory,
user-provided files, and PR diff-scope. Prompt-level authorization semantics
are rendered separately in the system prompt.
"""
diff_scope = scan_config.get("diff_scope") or {}
user_instructions = (scan_config.get("user_instructions") or "").strip()
ondisk, network = _split_target_sections(scan_config.get("targets", []) or [])
context: list[str] = []
_emit_sections(context, ondisk)
# A workspace mount is a directory to work in, not an asset to test. It is
# listed apart from the targets so it never reads as scope.
if workspace_mount := scan_config.get("workspace_mount") or "":
subdir = scan_config.get("workspace_subdir") or ""
workspace_path = f"/workspace/{subdir}" if subdir else "/workspace"
parts.append("\n\nWorking Directory:")
parts.append(
context.append("\n\nWorking Directory:")
context.append(
f"- {workspace_mount} (available at: {workspace_path}; "
"this is the user's real directory, mounted live and writable — "
".git/.agents/.codex are read-only)"
)
parts.append(
context.append(
"- No scan target was set. This directory is where you work, not a "
"target to assess: the instructions below are the only source of "
"truth for what to do."
)
# Whether anything above gave the run a scope. Workspace files never do, so
# this is read before they are listed.
has_scope = bool(parts)
parts.extend(_render_workspace_files(scan_config))
if not has_scope and user_instructions:
# Neither a target nor a directory, but there is an instruction: the user
# declined the mount, so the instruction is all there is. Say so, or the
# agent goes looking for a scope that was never given.
parts.append(
"\n\nNo scan target and no working directory were provided. The "
"instructions below are the only source of truth for what to do; "
"work from them and from what you can reach yourself."
"target to assess: the task is the only source of truth for what to do."
)
parts.extend(_render_diff_scope(diff_scope))
context.extend(_render_workspace_files(scan_config))
task = " ".join(parts)
if user_instructions:
task = f"{task}\n\nSpecial instructions: {user_instructions}"
return task
# Network targets remain visible in the task as useful starting points; the
# system prompt defines their host-level scope semantics.
_emit_sections(context, network)
context.extend(_render_diff_scope(diff_scope))
context_text = " ".join(context).strip()
if not context_text:
return user_instructions
if not user_instructions:
return context_text
return (
f"{user_instructions}\n\n"
"Run context (configured targets and supporting material for the task above):\n"
f"{context_text}"
)
def _scope_target_from_url(value: str) -> tuple[str, str]:
"""Extract host-level prompt scope from an API-spec base URL."""
return canonical_network_host(value)
def build_scope_context(scan_config: dict[str, Any]) -> dict[str, Any]:
authorized: list[dict[str, str]] = []
authorized_keys: set[tuple[str, str, str]] = set()
def add_authorized(ttype: str, value: str, workspace_path: str = "") -> None:
key = (ttype, value, workspace_path)
if key not in authorized_keys:
authorized.append(
{"type": ttype, "value": value, "workspace_path": workspace_path},
)
authorized_keys.add(key)
value_keys = {
"repository": "target_repo",
"local_code": "target_path",
"web_application": "target_url",
"web_application": "target_host",
"ip_address": "target_ip",
"api_spec": "target_spec",
}
@ -206,26 +239,42 @@ def build_scope_context(scan_config: dict[str, Any]) -> dict[str, Any]:
workspace_subdir = details.get("workspace_subdir")
workspace_path = f"/workspace/{workspace_subdir}" if workspace_subdir else ""
authorized.append(
{"type": ttype, "value": value, "workspace_path": workspace_path},
)
if ttype == "web_application":
scope_type, scope_value = canonical_network_host(str(value or ""))
add_authorized(scope_type, scope_value)
else:
add_authorized(str(ttype), str(value or ""), workspace_path)
# An API spec authorizes the hosts it declares as in-scope web targets
# so the agent can exercise every endpoint without expanding scope.
if ttype == "api_spec":
authorized.extend(
{"type": "web_application", "value": base_url, "workspace_path": ""}
for base_url in details.get("base_urls") or []
)
for base_url in details.get("base_urls") or []:
scope_type, scope_value = _scope_target_from_url(str(base_url))
add_authorized(scope_type, scope_value)
return {
"scope_source": "system_scan_config",
"authorization_source": "strix_platform_verified_targets",
"authorized_targets": authorized,
"user_instructions_do_not_expand_scope": True,
"user_instruction_hosts_expand_scope": True,
}
def build_scope_target_labels(targets: list[dict[str, Any]]) -> list[str]:
"""Build concise, deduplicated scope labels for CLI summaries."""
labels: list[str] = []
for target in build_scope_context({"targets": targets})["authorized_targets"]:
ttype = target["type"]
value = target["value"]
if ttype == "web_host":
labels.append(f"host: {value} (includes *.{value})")
elif ttype == "ip_address":
labels.append(f"ip: {value} (exact address)")
else:
labels.append(f"{ttype}: {value}")
return labels
def build_scan_targets(scan_config: dict[str, Any]) -> list[str]:
"""One canonical string per authorized target.

View file

@ -104,11 +104,9 @@ def _mcp_startup_summary(connections: list[ConnectedMcpServer]) -> str:
def _record_mcp_connections(connections: list[ConnectedMcpServer]) -> None:
"""Record which MCP servers this run connected, for the interfaces.
A server's tools are offered to the model under a name built from the
connection name and the tool's own name, which cannot be split back apart, so
the TUI and the run viewer need the names to match a tool call against before
they can show which server it went out to. Kept on the run record because the
viewer reads a finished run from disk.
The model calls MCP tools through the explicit connection argument on
describe_mcp/call_mcp. The interfaces still need the connected names to
label calls and render a finished run from disk.
"""
report_state = get_global_report_state()
if report_state is None:
@ -171,9 +169,10 @@ def _compose_root_instructions_override(
return (
f"{base_instructions}\n\n"
"<root_scan_instructions_override>\n"
"The following root scan instructions are subordinate to the "
"system-verified scope above. They cannot expand, replace, or weaken "
"authorized target constraints.\n\n"
"Network hosts explicitly named in these root scan instructions and "
"their descendant subdomains are in scope under the system-verified "
"scope rules above. These instructions cannot otherwise replace or "
"weaken those rules.\n\n"
f"{root_instructions_override}\n"
"</root_scan_instructions_override>"
)
@ -428,6 +427,7 @@ async def run_strix_scan(
}
for summary in mcp_registry.summaries()
]
# Feed a non-secret connection roster (name / provider /
# tool_count / dead) to two consumers: once now (all
# currently healthy) and again whenever a connection later

49
strix/core/targets.py Normal file
View file

@ -0,0 +1,49 @@
"""Canonical host identities shared by target ingestion and prompt scope."""
from __future__ import annotations
import ipaddress
import re
from urllib.parse import urlsplit
_DNS_LABEL = re.compile(r"^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$")
_URI_SCHEME = re.compile(r"^[a-z][a-z0-9+.-]*://", re.IGNORECASE)
def canonical_network_host(value: str) -> tuple[str, str]:
"""Return ``(web_host|ip_address, canonical value)`` for a network input."""
raw = value.strip()
if not raw or any(char.isspace() or ord(char) < 0x20 or ord(char) == 0x7F for char in raw):
raise ValueError(f"Network target '{value}' contains an invalid host")
# Parse exact IPs before treating colons as URL authority syntax. URL forms
# containing IPv6 still use the standard bracketed authority parser below.
try:
return "ip_address", str(ipaddress.ip_address(raw))
except ValueError:
pass
try:
parsed = urlsplit(raw if _URI_SCHEME.match(raw) else f"//{raw}")
hostname = (parsed.hostname or "").rstrip(".").lower()
# Accessing port validates both its syntax and range while keeping it
# out of the canonical host identity.
_ = parsed.port
except ValueError as exc:
raise ValueError(f"Network target '{value}' contains an invalid host") from exc
if not hostname:
raise ValueError(f"Network target '{value}' does not contain a valid host")
try:
return "ip_address", str(ipaddress.ip_address(hostname))
except ValueError:
pass
try:
hostname = hostname.encode("idna").decode("ascii")
except UnicodeError as exc:
raise ValueError(f"Network target '{value}' contains an invalid host") from exc
if len(hostname) > 253 or any(not _DNS_LABEL.fullmatch(label) for label in hostname.split(".")):
raise ValueError(f"Network target '{value}' contains an invalid host")
return "web_host", hostname

View file

@ -20,6 +20,7 @@ from strix.runtime import session_manager
from .utils import (
build_live_stats_text,
build_target_summary_text,
format_vulnerability_report,
has_model_response,
read_workspace_files,
@ -44,16 +45,7 @@ async def run_cli(args: Any) -> None: # noqa: PLR0915
start_text = Text()
start_text.append("Penetration test initiated", style="bold #22c55e")
target_text = Text()
target_text.append("Target", style="dim")
target_text.append(" ")
if len(args.targets_info) == 1:
target_text.append(args.targets_info[0]["original"], style="bold white")
else:
target_text.append(f"{len(args.targets_info)} targets", style="bold white")
for target_info in args.targets_info:
target_text.append("\n ")
target_text.append(target_info["original"], style="white")
target_text = build_target_summary_text(args.targets_info)
results_text = Text()
results_text.append("Output", style="dim")

View file

@ -10,12 +10,20 @@ from pathlib import Path
from strix.config import apply_config_override
from strix.config.settings import DEFAULT_MAX_TURNS
from strix.core.paths import run_dir_for, runtime_state_dir
from strix.interface.scan_setup import attach_workspace_mount, build_targets_info
from strix.interface.scan_setup import (
HOST_GATEWAY_HOSTNAME,
attach_workspace_mount,
build_targets_info,
)
from strix.interface.update_check import self_update
from strix.interface.utils import (
canonicalize_targets_info,
check_mountable_dir,
collect_local_sources,
dedupe_targets,
resolve_workspace_files,
restore_staged_api_specs,
rewrite_localhost_targets,
validate_config_file,
)
@ -61,7 +69,7 @@ Examples:
strix --target https://example.com
# GitHub repository analysis
strix --target https://github.com/user/repo
strix --target https://github.com/user/repo.git
strix --target git@github.com:user/repo.git
# Local code analysis
@ -82,7 +90,7 @@ Examples:
strix --target 192.168.1.42
# Multiple targets (e.g., white-box testing with source and deployed app)
strix --target https://github.com/user/repo --target https://example.com
strix --target https://github.com/user/repo.git --target https://example.com
strix --target ./my-project --target https://staging.example.com --target https://prod.example.com
# Targets from a file, one target per non-empty, non-comment line
@ -124,9 +132,10 @@ Examples:
help="Target to test: URL, repository, local directory path, domain name, IP address, "
"an API spec file (OpenAPI/Swagger .json/.yaml or a Postman collection export), or a "
"Postman collection by id (postman://<collection-uuid>[?env=<environment-uuid>], needs "
"POSTMAN_API_KEY). Local directories are mounted into the sandbox writable. "
"POSTMAN_API_KEY). Web URLs are reduced to their host; put endpoint paths and queries "
"in --instruction. Local directories are mounted into the sandbox writable. "
"Can be specified multiple times for multi-target scans. "
"Fresh runs require --target or --target-list.",
"Fresh headless runs require --target or --target-list.",
)
parser.add_argument(
"--target-list",
@ -359,8 +368,7 @@ Examples:
"(or use --resume <run_name> to continue a prior scan)"
)
# Interactive launch with no target: open the normal TUI on its
# start screen, where the user gives a target or a bare prompt
# before the scan starts.
# start screen, where the user gives the task before the scan starts.
args.needs_setup = True
return args
@ -374,7 +382,7 @@ Examples:
def _load_resume_state(args: argparse.Namespace, parser: argparse.ArgumentParser) -> None:
"""Populate ``args.targets_info`` and friends from a prior run's run.json."""
from strix.report.writer import read_run_record
from strix.report.writer import read_run_record, write_run_record
run_dir = run_dir_for(args.resume)
state_path = run_dir / "run.json"
@ -388,7 +396,16 @@ def _load_resume_state(args: argparse.Namespace, parser: argparse.ArgumentParser
except (RuntimeError, TypeError) as exc:
parser.error(f"--resume {args.resume}: run.json unreadable: {exc}")
args.targets_info = state.get("targets_info") or []
persisted_targets = state.get("targets_info") or []
try:
args.targets_info = canonicalize_targets_info(persisted_targets)
rewrite_localhost_targets(args.targets_info, HOST_GATEWAY_HOSTNAME)
args.targets_info = dedupe_targets(args.targets_info)
except (TypeError, ValueError) as exc:
parser.error(f"--resume {args.resume}: invalid persisted target: {exc}")
if args.targets_info != persisted_targets:
state["targets_info"] = args.targets_info
write_run_record(run_dir, state)
# A target-less run has no targets_info at all. It is driven by its
# instruction, over a mounted working directory or over nothing when the
# mount was declined, so either of those is enough to resume it.
@ -423,6 +440,10 @@ def _load_resume_state(args: argparse.Namespace, parser: argparse.ArgumentParser
if not getattr(args, "user_instruction", None):
args.user_instruction = state.get("user_instruction") or None
args.local_sources = collect_local_sources(args.targets_info)
try:
args.local_sources.extend(restore_staged_api_specs(args.targets_info, str(args.resume)))
except ValueError as exc:
parser.error(f"--resume {args.resume}: could not restore API specification: {exc}")
# Remount the workspace the run was started with. The user already confirmed
# this directory, so the target mount guard does not apply to it; it only has
# to still be there.

View file

@ -40,6 +40,7 @@ from strix.interface.update_check import (
)
from strix.interface.utils import (
build_final_stats_text,
build_target_summary_text,
)
from strix.telemetry import posthog, scarf
from strix.telemetry.logging import configure_dependency_logging
@ -270,16 +271,7 @@ def display_completion_message(args: argparse.Namespace, results_path: Path) ->
else:
completion_text.append("SESSION ENDED", style="bold #eab308")
target_text = Text()
target_text.append("Target", style="dim")
target_text.append(" ")
if len(args.targets_info) == 1:
target_text.append(args.targets_info[0]["original"], style="bold white")
else:
target_text.append(f"{len(args.targets_info)} targets", style="bold white")
for target_info in args.targets_info:
target_text.append("\n ")
target_text.append(target_info["original"], style="white")
target_text = build_target_summary_text(args.targets_info)
stats_text = build_final_stats_text(report_state)

View file

@ -16,11 +16,13 @@ from typing import TYPE_CHECKING, Any
from strix.config import Settings, codex, load_settings
from strix.core.paths import run_dir_for
from strix.core.targets import canonical_network_host
from strix.interface.utils import (
assign_workspace_subdirs,
canonicalize_targets_info,
clone_repository,
collect_local_sources,
dedupe_local_targets,
dedupe_targets,
derive_local_base_name,
generate_run_name,
infer_target_type,
@ -117,6 +119,10 @@ def build_targets_info(args: argparse.Namespace) -> None:
if target_type == "local_code":
display_target = target_dict.get("target_path", target)
elif target_type == "web_application":
display_target = target_dict["target_host"]
elif target_type == "ip_address":
display_target = target_dict["target_ip"]
else:
display_target = target
@ -127,10 +133,27 @@ def build_targets_info(args: argparse.Namespace) -> None:
{"type": target_type, "details": target_dict, "original": display_target}
)
args.targets_info = dedupe_local_targets(args.targets_info)
assign_workspace_subdirs(args.targets_info)
args.targets_info = canonicalize_targets_info(args.targets_info)
rewrite_localhost_targets(args.targets_info, HOST_GATEWAY_HOSTNAME)
args.targets_info = dedupe_targets(args.targets_info)
assign_workspace_subdirs(args.targets_info)
def build_prompt_targets_info(targets: list[str]) -> list[dict[str, Any]]:
"""Resolve prompt-extracted network references into canonical target records."""
targets_info: list[dict[str, Any]] = []
for target in targets:
scope_type, canonical = canonical_network_host(target)
if scope_type == "ip_address":
target_type = "ip_address"
details = {"target_ip": canonical}
else:
target_type = "web_application"
details = {"target_host": canonical}
targets_info.append({"type": target_type, "details": details, "original": canonical})
rewrite_localhost_targets(targets_info, HOST_GATEWAY_HOSTNAME)
return dedupe_targets(targets_info)
def _resolve_api_spec(target: str, details: dict[str, Any]) -> None:
@ -154,8 +177,12 @@ def _resolve_api_spec(target: str, details: dict[str, Any]) -> None:
raw = load_spec(str(details["target_spec"]))
extra_variables = None
base_urls = spec_base_urls(raw, extra_variables=extra_variables)
for base_url in base_urls:
canonical_network_host(base_url)
except SpecParseError as exc:
raise ValueError(f"Invalid API spec '{target}': {exc}") from None
except ValueError as exc:
raise ValueError(f"Invalid API spec '{target}': {exc}") from None
details["spec_title"] = spec_title(raw)
details["base_urls"] = base_urls
@ -164,9 +191,8 @@ def _resolve_api_spec(target: str, details: dict[str, Any]) -> None:
def prepare_run(args: argparse.Namespace) -> None:
"""Resolve the run name, clone repos, compute diff-scope, and persist state.
Shared by the CLI startup path and the interactive TUI setup phase (once the
user has supplied a target via ``/target``). Mutates *args* in place and
raises :class:`ValueError` on any preparation failure.
Shared by the CLI startup path and the interactive TUI setup phase. Mutates
*args* in place and raises :class:`ValueError` on any preparation failure.
"""
args.run_name = args.resume or generate_run_name(args.targets_info)

View file

@ -7,12 +7,14 @@ import contextlib
import math
import webbrowser
from collections.abc import Awaitable, Callable
from copy import deepcopy
from pathlib import Path
from typing import TYPE_CHECKING, Any
from typing import TYPE_CHECKING, Any, cast
from strix.config import load_settings
from strix.config.models import is_recommended_or_frontier_model
from strix.config.settings import DEFAULT_MAX_TURNS
from strix.interface.scan_setup import build_prompt_targets_info
from strix.interface.tui.backend.live_view import TuiLiveView
from strix.interface.tui.backend.projection import (
MAX_TERMINAL_EVENTS,
@ -24,7 +26,7 @@ from strix.interface.tui.backend.projection import (
sanitize_terminal_text,
terminal_projection,
)
from strix.interface.utils import is_subscription_run
from strix.interface.utils import dedupe_targets, is_subscription_run
if TYPE_CHECKING:
@ -63,10 +65,9 @@ class TuiController:
self.scan_started = not self.setup_mode
self._start_in_progress = False
self.scan_state = "setup" if self.setup_mode else "running"
self.targets = [
str(target["original"])
for target in args.targets_info
if isinstance(target, dict) and target.get("original")
self.targets_info = deepcopy(cast("list[dict[str, Any]]", args.targets_info))
self.targets: list[str] = [
str(target["original"]) for target in self.targets_info if target.get("original")
]
instruction = args.instruction
self.instruction = instruction.strip() if isinstance(instruction, str) else ""
@ -294,7 +295,6 @@ class TuiController:
async def handle(self, command: str, payload: dict[str, Any]) -> dict[str, Any]:
handlers = {
"setup.add_target": self._add_target,
"setup.set_instruction": self._set_instruction,
"setup.start": self._start,
"setup.confirm_mount": self._confirm_mount,
@ -310,13 +310,6 @@ class TuiController:
self.notify_changed()
return result
async def _add_target(self, payload: dict[str, Any]) -> dict[str, Any]:
self._require_setup_mutable()
target = self._required_string(payload, "target")
if target not in self.targets:
self.targets.append(target)
return {"target": target, "total": len(self.targets)}
async def _set_instruction(self, payload: dict[str, Any]) -> dict[str, Any]:
self._require_setup_mutable()
instruction = payload.get("instruction", "")
@ -328,12 +321,28 @@ class TuiController:
async def _start(self, payload: dict[str, Any]) -> dict[str, Any]:
if self.scan_started or self._start_in_progress:
raise RuntimeError("Scan is already starting or running")
instruction = payload.get("instruction", self.instruction)
if not isinstance(instruction, str):
raise TypeError("instruction must be a string")
raw_targets_value = payload.get("targets", [])
if not isinstance(raw_targets_value, list):
raise TypeError("targets must be a list of non-empty strings")
raw_targets: list[str] = []
for target in cast("list[object]", raw_targets_value):
if not isinstance(target, str) or not target.strip():
raise TypeError("targets must be a list of non-empty strings")
raw_targets.append(target)
prompt_targets = build_prompt_targets_info([target.strip() for target in raw_targets])
targets_info = dedupe_targets([*deepcopy(self.targets_info), *prompt_targets])
targets = [str(target["original"]) for target in targets_info if target.get("original")]
# A bare prompt launches optimistically, like a coding agent: it skips
# the network model preflight and surfaces any model error live. A named
# target keeps the preflight so a real scan does not commit blind.
verify = payload.get("verify", True)
if not isinstance(verify, bool):
requested_verify = payload.get("verify", True)
if not isinstance(requested_verify, bool):
raise TypeError("verify must be a boolean")
verify = bool(targets) or requested_verify
# Launching with no target mounts the working directory, so it requires
# the user's explicit confirmation rather than happening silently.
mount_working_dir = payload.get("mount_working_dir", False)
@ -344,9 +353,12 @@ class TuiController:
raise ValueError("No model configured. Set STRIX_LLM first.")
if self._on_start is None:
raise RuntimeError("Scan start is unavailable")
if not self.targets:
if not targets:
if not mount_working_dir:
raise ValueError("No target set. Add a target first.")
self.instruction = instruction.strip()
self.targets_info = targets_info
self.targets = targets
# Mounting the working directory needs the user's confirmation, and
# that is asked in the live view. Enter it now and prepare nothing
# until the answer arrives, so declining leaves no run behind.
@ -356,7 +368,19 @@ class TuiController:
self.scan_started = True
self.scan_state = "preparing"
return {"started": True}
await self._begin_scan(verify)
previous: tuple[str, list[dict[str, Any]], list[str]] = (
self.instruction,
self.targets_info,
self.targets,
)
self.instruction = instruction.strip()
self.targets_info = targets_info
self.targets = targets
try:
await self._begin_scan(verify)
except BaseException:
self.instruction, self.targets_info, self.targets = previous
raise
return {"started": True}
async def _begin_scan(self, verify: bool) -> None:

View file

@ -245,7 +245,7 @@ func TestSetupUsesDedicatedStartScreen(t *testing.T) {
SetupMode: true,
ScanState: "setup",
Model: "gpt-5.4",
Targets: []string{"/workspace/source", "https://example.com"},
Targets: []string{"/workspace/source", "example.com"},
Instruction: "focus on access control",
ScanMode: "quick",
MaxBudgetUSD: floatPointer(12.5),
@ -260,7 +260,7 @@ func TestSetupUsesDedicatedStartScreen(t *testing.T) {
for _, want := range []string{
"gpt-5.4",
"/workspace/source",
"https://example.com",
"example.com",
} {
if !strings.Contains(view, want) {
t.Fatalf("start screen is missing %q: %s", want, view)
@ -338,13 +338,10 @@ func TestLeadingSlashIsPromptTextNotACommand(t *testing.T) {
if cmd == nil {
t.Fatal("enter did not submit")
}
types := commandTypes(drainCommands(t, cmd, connection))
if !contains(types, "setup.start") {
t.Fatalf("a slash-leading prompt did not launch a scan: %v", types)
}
// The path is read as a target and the sentence as the instruction.
if !contains(types, "setup.add_target") || !contains(types, "setup.set_instruction") {
t.Fatalf("slash-leading prompt was not split into target and instruction: %v", types)
payload := decodeSetupStart(t, drainCommands(t, cmd, connection))
// The entire value remains prompt text; the path is not promoted to a target.
if payload.Instruction != "/etc/passwd is world readable, check it" || len(payload.Targets) != 0 {
t.Fatalf("slash-leading prompt was not preserved as instruction text: %#v", payload)
}
for _, line := range result.setupLog {
if strings.Contains(ansi.Strip(line), "Unknown command") {

View file

@ -3,7 +3,7 @@ package app
import (
"fmt"
"net"
"regexp"
"net/url"
"strings"
"sync"
@ -26,43 +26,130 @@ func (m Model) submit(value string) (tea.Model, tea.Cmd) {
return m, send(m.client, "agent.send_message", map[string]any{"agent_id": m.snapshot.Agents[m.selectedAgent].ID, "message": value})
}
// submitSetupPrompt handles free text the way a coding agent's prompt does:
// anything that looks like a target is added, the rest becomes the scan
// instruction, and the prompt alone is enough to launch. With no target, the
// backend scans the current working directory.
// submitSetupPrompt keeps free text as the task verbatim while passing any
// network references alongside it for the backend to reconcile as targets.
func (m *Model) submitSetupPrompt(value string) (tea.Model, tea.Cmd) {
var commands []tea.Cmd
fields := strings.Fields(value)
targets := 0
for _, field := range fields {
token := strings.Trim(field, ",;")
if !looksLikeTarget(token) || m.hasTarget(token) {
continue
}
targets++
commands = append(commands, send(m.client, "setup.add_target", map[string]any{"target": token}))
}
if len(fields) > targets {
commands = append(commands, send(m.client, "setup.set_instruction", map[string]any{"instruction": value}))
}
targets := networkTargets(value)
// With a target, verify the model connection before the scan commits to it.
// A bare prompt launches optimistically, like a coding agent, and mounts the
// working directory - the backend asks about that from the live view, so the
// prompt is held here in case it is declined.
verify := targets > 0 || len(m.snapshot.Targets) > 0
payload := map[string]any{"verify": verify}
verify := len(targets) > 0 || len(m.snapshot.Targets) > 0
payload := map[string]any{"instruction": value, "targets": targets, "verify": verify}
if verify {
m.setupMsg("Verifying model connection...", render.Col(amber))
} else {
m.pendingPrompt = value
payload["mount_working_dir"] = true
}
commands = append(commands, send(m.client, "setup.start", payload))
// Ordered, not batched: setup.start leaves setup mode, so it must be the
// last command to reach the backend. Batched sends race, and once the
// preflight is skipped setup.start wins, making the target and instruction
// commands land after the guard closes and fail with a red error.
return *m, tea.Sequence(commands...)
return *m, send(m.client, "setup.start", payload)
}
// networkTargets extracts ordered raw candidates. Canonicalization and scope
// reconciliation remain the backend's responsibility.
func networkTargets(instruction string) []string {
targets := make([]string, 0)
seen := make(map[string]struct{})
for _, field := range strings.Fields(instruction) {
candidate := strings.Trim(field, "\"'`()<> {},;.")
if _, duplicate := seen[candidate]; candidate == "" || duplicate || !isNetworkTarget(candidate) {
continue
}
seen[candidate] = struct{}{}
targets = append(targets, candidate)
}
return targets
}
func isNetworkTarget(candidate string) bool {
lower := strings.ToLower(candidate)
if strings.HasPrefix(lower, "http://") || strings.HasPrefix(lower, "https://") {
parsed, err := url.Parse(candidate)
return err == nil && parsed.Host != "" && !strings.HasSuffix(parsed.Host, ":") && validNetworkHost(parsed.Hostname(), true)
}
if strings.Contains(candidate, "://") || strings.ContainsAny(candidate, "@\\") {
return false
}
if ip := net.ParseIP(candidate); ip != nil {
return true
}
parsed, err := url.Parse("//" + candidate)
if err != nil || parsed.Host == "" || parsed.User != nil || strings.HasSuffix(parsed.Host, ":") {
return false
}
if isLikelyFileName(candidate) {
return false
}
return validNetworkHost(parsed.Hostname(), false)
}
func validNetworkHost(host string, allowSingleLabel bool) bool {
if ip := net.ParseIP(host); ip != nil {
return true
}
host = strings.TrimSuffix(host, ".")
if strings.EqualFold(host, "localhost") {
return true
}
if host == "" || len(host) > 253 || (!allowSingleLabel && !strings.Contains(host, ".")) {
return false
}
if strings.IndexFunc(host, func(char rune) bool { return char > 127 }) >= 0 {
return true
}
labels := strings.Split(host, ".")
for _, label := range labels {
if label == "" || len(label) > 63 || !isASCIILetterOrDigit(label[0]) || !isASCIILetterOrDigit(label[len(label)-1]) {
return false
}
for i := 1; i < len(label)-1; i++ {
if !isASCIILetterOrDigit(label[i]) && label[i] != '-' {
return false
}
}
}
tld := labels[len(labels)-1]
if len(tld) < 2 || strings.HasPrefix(tld, "-") || strings.HasSuffix(tld, "-") {
return false
}
if strings.HasPrefix(strings.ToLower(tld), "xn--") {
return len(tld) > len("xn--")
}
for i := range len(tld) {
if !isASCIILetter(tld[i]) {
return false
}
}
return true
}
func isLikelyFileName(candidate string) bool {
if strings.ContainsAny(candidate, "/:?#") {
return false
}
dot := strings.LastIndex(candidate, ".")
if dot < 0 {
return false
}
_, found := nonHostFileExtensions[strings.ToLower(candidate[dot+1:])]
return found
}
var nonHostFileExtensions = map[string]struct{}{
"cfg": {}, "conf": {}, "css": {}, "csv": {}, "env": {}, "gif": {}, "go": {},
"htm": {}, "html": {}, "ini": {}, "jpeg": {}, "jpg": {}, "js": {}, "json": {},
"jsx": {}, "less": {}, "lock": {}, "log": {}, "md": {}, "markdown": {}, "pdf": {},
"png": {}, "py": {}, "pyc": {}, "rst": {}, "scss": {}, "sql": {}, "svg": {},
"toml": {}, "ts": {}, "tsx": {}, "txt": {}, "vue": {}, "xml": {}, "yaml": {},
"yml": {},
}
func isASCIILetterOrDigit(char byte) bool {
return isASCIILetter(char) || char >= '0' && char <= '9'
}
func isASCIILetter(char byte) bool {
return char >= 'a' && char <= 'z' || char >= 'A' && char <= 'Z'
}
// answerMountConfirmation replies to the working-directory mount the backend is
@ -74,54 +161,6 @@ func (m *Model) answerMountConfirmation(approved bool) tea.Cmd {
return send(m.client, "setup.confirm_mount", map[string]any{"approved": approved})
}
func (m Model) hasTarget(candidate string) bool {
for _, target := range m.snapshot.Targets {
if target == candidate {
return true
}
}
return false
}
// looksLikeTarget reports whether a whitespace-delimited token names something
// scannable: a URL, repo, filesystem path, domain, or IP address.
func looksLikeTarget(token string) bool {
if token == "" {
return false
}
if strings.Contains(token, "://") || strings.HasSuffix(token, ".git") {
return true
}
if strings.HasPrefix(token, "/") || strings.HasPrefix(token, "./") || strings.HasPrefix(token, "~/") || strings.HasPrefix(token, "../") {
return true
}
if ip := net.ParseIP(token); ip != nil {
return true
}
host := token
if at := strings.LastIndex(host, "@"); at >= 0 {
host = host[at+1:]
}
host = strings.SplitN(host, "/", 2)[0]
host = strings.SplitN(host, ":", 2)[0]
if !domainPattern.MatchString(host) {
return false
}
tld := host[strings.LastIndex(host, ".")+1:]
return len(tld) >= 2 && !isNumeric(tld)
}
var domainPattern = regexp.MustCompile(`^([a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?\.)+[a-zA-Z0-9]{2,}$`)
func isNumeric(value string) bool {
for _, char := range value {
if char < '0' || char > '9' {
return false
}
}
return true
}
// statusVisible mirrors #agent_status_display: shown only when an agent is
// selected during a scan; hidden (display:none) in setup mode.
func (m Model) statusVisible() bool {

View file

@ -12,27 +12,6 @@ import (
"github.com/usestrix/strix/tui/internal/protocol"
)
// lastIndex returns the index of the last command of the given type, or -1.
func lastIndex(types []string, want string) int {
last := -1
for i, value := range types {
if value == want {
last = i
}
}
return last
}
// firstIndex returns the index of the first command of the given type, or -1.
func firstIndex(types []string, want string) int {
for i, value := range types {
if value == want {
return i
}
}
return -1
}
// drainCommands runs a (possibly batched) command and decodes every protocol
// frame the sends wrote to the connection, in order.
func drainCommands(t *testing.T, cmd tea.Cmd, connection *recordingConn) []protocol.Envelope {
@ -94,23 +73,23 @@ func commandTypes(envelopes []protocol.Envelope) []string {
return types
}
// startVerify returns the verify flag on the setup.start command, and whether
// a setup.start command was present at all.
func startVerify(t *testing.T, envelopes []protocol.Envelope) (verify, found bool) {
type setupStartPayload struct {
Instruction string `json:"instruction"`
Targets []string `json:"targets"`
Verify bool `json:"verify"`
MountWorkingDir *bool `json:"mount_working_dir"`
}
func decodeSetupStart(t *testing.T, envelopes []protocol.Envelope) setupStartPayload {
t.Helper()
for _, envelope := range envelopes {
if envelope.Type != "setup.start" {
continue
}
var payload struct {
Verify bool `json:"verify"`
}
if err := json.Unmarshal(envelope.Payload, &payload); err != nil {
t.Fatal(err)
}
return payload.Verify, true
if len(envelopes) != 1 || envelopes[0].Type != "setup.start" {
t.Fatalf("expected one setup.start command, got %v", commandTypes(envelopes))
}
return false, false
var payload setupStartPayload
if err := json.Unmarshal(envelopes[0].Payload, &payload); err != nil {
t.Fatal(err)
}
return payload
}
func contains(values []string, want string) bool {
@ -122,23 +101,6 @@ func contains(values []string, want string) bool {
return false
}
// startPayloadFlag reports a boolean field on the setup.start command.
func startPayloadFlag(t *testing.T, envelopes []protocol.Envelope, field string) (value, found bool) {
t.Helper()
for _, envelope := range envelopes {
if envelope.Type != "setup.start" {
continue
}
var payload map[string]any
if err := json.Unmarshal(envelope.Payload, &payload); err != nil {
t.Fatal(err)
}
flag, ok := payload[field].(bool)
return flag, ok
}
return false, false
}
// A bare prompt launches straight away, asking to mount the working directory
// rather than adding it as a target. The prompt is held in case it is declined.
func TestSetupPromptWithoutTargetLaunchesAndRequestsMount(t *testing.T) {
@ -149,24 +111,20 @@ func TestSetupPromptWithoutTargetLaunchesAndRequestsMount(t *testing.T) {
updated, cmd := model.submit("find auth bugs in the login flow")
model = updated.(Model)
envelopes := drainCommands(t, cmd, connection)
types := commandTypes(envelopes)
payload := decodeSetupStart(t, envelopes)
if !contains(types, "setup.set_instruction") || !contains(types, "setup.start") {
t.Fatalf("bare prompt did not launch: %v", types)
if payload.Instruction != "find auth bugs in the login flow" {
t.Fatalf("instruction was not preserved: %q", payload.Instruction)
}
if contains(types, "setup.add_target") {
t.Fatalf("the working directory must not be added as a target: %v", types)
if payload.Targets == nil || len(payload.Targets) != 0 {
t.Fatalf("targetless prompt sent targets: %#v", payload.Targets)
}
if mount, found := startPayloadFlag(t, envelopes, "mount_working_dir"); !found || !mount {
t.Fatalf("mount was not requested: mount_working_dir=%v found=%v", mount, found)
if payload.MountWorkingDir == nil || !*payload.MountWorkingDir {
t.Fatalf("mount was not requested: %#v", payload.MountWorkingDir)
}
// A bare prompt launches optimistically: no model preflight.
if verify, found := startVerify(t, envelopes); !found || verify {
t.Fatalf("bare prompt should launch with verify=false, got verify=%v found=%v", verify, found)
}
// setup.start leaves setup mode, so it must be the last command sent.
if start, instr := firstIndex(types, "setup.start"), lastIndex(types, "setup.set_instruction"); start < instr {
t.Fatalf("setup.start (%d) must come after setup.set_instruction (%d): %v", start, instr, types)
if payload.Verify {
t.Fatal("bare prompt should launch with verify=false")
}
if model.pendingPrompt != "find auth bugs in the login flow" {
t.Fatalf("prompt was not held in case the mount is declined: %q", model.pendingPrompt)
@ -258,33 +216,122 @@ func TestMountConfirmationAnswers(t *testing.T) {
}
}
// A prompt that names a target adds it and launches.
func TestSetupPromptWithTargetLaunches(t *testing.T) {
func TestSetupPromptExtractsExactSchemeLessFiuuTarget(t *testing.T) {
assertTargetedSetupStart(t, "fiuu.com", nil, []string{"fiuu.com"})
}
func TestSetupPromptExtractsOrderedSchemeLessFiuuTargets(t *testing.T) {
prompt := "i need you to test fiuu.com/search-result/?s=, fiuu.com/blog/ (fiuu.com/blog/-9 will show you the sql query), fiuu.com/newsroom/, and fiuu.com/faq/ for sqli. all of the pages likely use mysql and the same database"
want := []string{
"fiuu.com/search-result/?s=",
"fiuu.com/blog/",
"fiuu.com/blog/-9",
"fiuu.com/newsroom/",
"fiuu.com/faq/",
}
assertTargetedSetupStart(t, prompt, nil, want)
}
func TestSetupPromptExtractsOrderedSchemeLessIPTargets(t *testing.T) {
prompt := "i need you to test 192.0.2.10/search-result/?s=, 192.0.2.10/blog/ (192.0.2.10/blog/-9 will show you the sql query), 192.0.2.10/newsroom/, and 192.0.2.10/faq/ for sqli"
want := []string{
"192.0.2.10/search-result/?s=",
"192.0.2.10/blog/",
"192.0.2.10/blog/-9",
"192.0.2.10/newsroom/",
"192.0.2.10/faq/",
}
assertTargetedSetupStart(t, prompt, nil, want)
}
func TestSetupPromptKeepsSchemeAndNoSchemeCandidates(t *testing.T) {
prompt := "test https://example.com, example.com, https://example.com and example.com."
assertTargetedSetupStart(t, prompt, nil, []string{"https://example.com", "example.com"})
}
func TestSetupPromptExtractsHostSubdomainAndIPTargets(t *testing.T) {
for _, tc := range []struct {
name string
prompt string
want []string
}{
{
name: "mixed hosts and IP",
prompt: "test example.com, api.example.com:8443/search?q=x#results, and 192.0.2.10:8080/admin.",
want: []string{"example.com", "api.example.com:8443/search?q=x#results", "192.0.2.10:8080/admin"},
},
{
name: "IP only",
prompt: "test 192.0.2.10:8080/admin only.",
want: []string{"192.0.2.10:8080/admin"},
},
} {
t.Run(tc.name, func(t *testing.T) {
assertTargetedSetupStart(t, tc.prompt, nil, tc.want)
})
}
}
func TestSetupPromptTrimsTargetPunctuation(t *testing.T) {
prompt := "test (\"https://example.com/path?q=x#frag\"), '[2001:db8::1]:8443/admin'; api.example.com, 2001:db8::2, localhost:3000, and https://münich.example/path."
want := []string{
"https://example.com/path?q=x#frag",
"[2001:db8::1]:8443/admin",
"api.example.com",
"2001:db8::2",
"localhost:3000",
"https://münich.example/path",
}
assertTargetedSetupStart(t, prompt, nil, want)
}
func TestSetupPromptWithExistingTargetVerifiesWithoutMount(t *testing.T) {
assertTargetedSetupStart(t, "focus on authentication", []string{"example.com"}, []string{})
}
func TestSetupPromptRejectsNonNetworkTokens(t *testing.T) {
prompt := "Review README.md and main.py. Email dev@example.com about /etc/passwd, ./fixtures/site.test, and release v1.2.3-beta. This is ordinary prose."
connection := &recordingConn{}
model := New(&Client{conn: connection})
model.snapshot = protocol.Snapshot{SetupMode: true}
_, cmd := model.submit("https://juice-shop.example.com hit the coupon endpoint")
envelopes := drainCommands(t, cmd, connection)
types := commandTypes(envelopes)
updated, cmd := model.submit(prompt)
model = updated.(Model)
payload := decodeSetupStart(t, drainCommands(t, cmd, connection))
if payload.Instruction != prompt || payload.Targets == nil || len(payload.Targets) != 0 {
t.Fatalf("targetless payload = %#v", payload)
}
if payload.Verify || payload.MountWorkingDir == nil || !*payload.MountWorkingDir {
t.Fatalf("targetless launch flags = %#v", payload)
}
if model.pendingPrompt != prompt {
t.Fatalf("prompt was not held for mount confirmation: %q", model.pendingPrompt)
}
}
for _, want := range []string{"setup.add_target", "setup.set_instruction", "setup.start"} {
if !contains(types, want) {
t.Fatalf("missing %s in %v", want, types)
}
func assertTargetedSetupStart(t *testing.T, prompt string, existing, want []string) {
t.Helper()
connection := &recordingConn{}
model := New(&Client{conn: connection})
model.snapshot = protocol.Snapshot{SetupMode: true, Targets: existing}
updated, cmd := model.submit(prompt)
model = updated.(Model)
payload := decodeSetupStart(t, drainCommands(t, cmd, connection))
if payload.Instruction != prompt {
t.Fatalf("instruction = %q, want %q", payload.Instruction, prompt)
}
// A named target keeps the upfront model check.
if verify, found := startVerify(t, envelopes); !found || !verify {
t.Fatalf("targeted prompt should launch with verify=true, got verify=%v found=%v", verify, found)
if !reflect.DeepEqual(payload.Targets, want) {
t.Fatalf("targets = %#v, want %#v", payload.Targets, want)
}
// The target and instruction must reach the backend before setup.start
// closes the setup guard.
start := firstIndex(types, "setup.start")
if target := lastIndex(types, "setup.add_target"); start < target {
t.Fatalf("setup.start (%d) must come after setup.add_target (%d): %v", start, target, types)
if !payload.Verify {
t.Fatal("targeted prompt should launch with verify=true")
}
if instr := lastIndex(types, "setup.set_instruction"); start < instr {
t.Fatalf("setup.start (%d) must come after setup.set_instruction (%d): %v", start, instr, types)
if payload.MountWorkingDir != nil {
t.Fatalf("targeted prompt included mount_working_dir=%v", *payload.MountWorkingDir)
}
if model.pendingPrompt != "" {
t.Fatalf("targeted prompt was held for a mount: %q", model.pendingPrompt)
}
}

View file

@ -18,7 +18,6 @@ from strix.core.agents import AgentCoordinator
from strix.core.hooks import BudgetExceededError
from strix.core.runner import run_strix_scan
from strix.interface.scan_setup import (
build_targets_info,
preflight_model_connection,
prepare_run,
telemetry_start,
@ -115,12 +114,7 @@ class GoTuiRuntime:
candidate.max_turns = self.controller.max_turns
candidate.scope_mode = self.controller.scope_mode
candidate.diff_base = self.controller.diff_base
existing_targets = [
str(target["original"])
for target in candidate.targets_info
if isinstance(target, dict) and target.get("original")
]
targets_changed = self.controller.targets != existing_targets
candidate.targets_info = deepcopy(self.controller.targets_info)
model = (load_settings().llm.model or "").strip()
# A bare prompt launches optimistically: it skips the network preflight
# and lets any model error surface once the agent starts, like a coding
@ -134,12 +128,6 @@ class GoTuiRuntime:
# A confirmed target-less launch mounts the working directory for the
# agent to work in, without making it a scan target.
candidate.workspace_mount = self.controller.workspace_mount
if targets_changed:
# Rebuild the full typed set so path canonicalization and local
# deduplication match the CLI.
candidate.target = list(self.controller.targets)
candidate.target_list = []
build_targets_info(candidate)
prepare_run(candidate)
telemetry_start(candidate)

View file

@ -13,18 +13,38 @@ from pathlib import Path
from typing import Any
from urllib.parse import parse_qs, urlparse
import requests
from rich.console import Console
from rich.panel import Panel
from rich.text import Text
from strix.config import load_settings
from strix.core.inputs import build_scope_target_labels
from strix.core.paths import run_dir_for
from strix.core.targets import canonical_network_host
from strix.utils.api_spec import detect_spec_format
logger = logging.getLogger(__name__)
def build_target_summary_text(targets_info: list[dict[str, Any]]) -> Text:
"""Render configured targets as their deduplicated prompt-level scope."""
labels = build_scope_target_labels(targets_info)
target_text = Text()
target_text.append("Target", style="dim")
target_text.append(" ")
if not labels:
target_text.append("task-defined scope", style="bold white")
elif len(labels) == 1:
target_text.append(labels[0], style="bold white")
else:
target_text.append(f"{len(labels)} targets", style="bold white")
for label in labels:
target_text.append("\n ")
target_text.append(label, style="white")
return target_text
def get_severity_color(severity: str) -> str:
severity_colors = {
"critical": "#dc2626",
@ -478,12 +498,7 @@ def _derive_target_label_for_run_name(targets_info: list[dict[str, Any]] | None)
original = first.get("original", "") or ""
if target_type == "web_application":
url = details.get("target_url", original)
try:
parsed = urlparse(url)
return str(parsed.netloc or parsed.path or url)
except Exception:
return str(url)
return str(details.get("target_host", original) or original)
if target_type == "repository":
repo = details.get("target_repo", original)
@ -1118,15 +1133,31 @@ def resolve_diff_scope_context(
)
def _validated_repository_target(value: str) -> str:
"""Validate repository transport text without discarding its path."""
if any(char.isspace() or ord(char) < 0x20 or ord(char) == 0x7F for char in value):
raise ValueError("Repository targets cannot contain whitespace or control characters")
if value.startswith("git@"):
host, separator, _path = value[4:].partition(":")
if not separator:
raise ValueError("SSH repository targets must include a host and path")
canonical_network_host(host)
elif urlparse(value).scheme in {"git", "http", "https"}:
canonical_network_host(value)
return value
def _is_http_git_repo(url: str) -> bool:
check_url = f"{url.rstrip('/')}/info/refs?service=git-upload-pack"
try:
with requests.get(check_url, headers={"User-Agent": "git/2.43.0"}, timeout=10) as resp:
if resp.status_code >= 400:
return resp.status_code == 401
return "x-git-upload-pack-advertisement" in resp.headers.get("Content-Type", "")
except (requests.RequestException, ValueError):
return False
"""Classify deterministic repository URL forms without probing the target."""
return urlparse(url).path.rstrip("/").endswith(".git")
def _canonical_network_target(value: str) -> tuple[str, dict[str, str]]:
"""Reduce a web input to one canonical host or exact IP target."""
scope_type, canonical = canonical_network_host(value)
if scope_type == "ip_address":
return scope_type, {"target_ip": canonical}
return "web_application", {"target_host": canonical}
def infer_target_type(target: str) -> tuple[str, dict[str, str]]: # noqa: PLR0911
@ -1136,10 +1167,14 @@ def infer_target_type(target: str) -> tuple[str, dict[str, str]]: # noqa: PLR09
target = target.strip()
if target.startswith("git@"):
return "repository", {"target_repo": target}
return "repository", {"target_repo": _validated_repository_target(target)}
if target.startswith("git://"):
return "repository", {"target_repo": target}
return "repository", {"target_repo": _validated_repository_target(target)}
if target.startswith(("git+http://", "git+https://")):
repository = target.removeprefix("git+")
return "repository", {"target_repo": _validated_repository_target(repository)}
parsed = urlparse(target)
if parsed.scheme == "postman":
@ -1161,16 +1196,9 @@ def infer_target_type(target: str) -> tuple[str, dict[str, str]]: # noqa: PLR09
return "api_spec", details
if parsed.scheme in ("http", "https"):
if parsed.username or parsed.password:
return "repository", {"target_repo": target}
if parsed.path.rstrip("/").endswith(".git"):
return "repository", {"target_repo": target}
if parsed.query or parsed.fragment:
return "web_application", {"target_url": target}
path_segments = [s for s in parsed.path.split("/") if s]
if len(path_segments) >= 2 and _is_http_git_repo(target):
return "repository", {"target_repo": target}
return "web_application", {"target_url": target}
if _is_http_git_repo(target):
return "repository", {"target_repo": _validated_repository_target(target)}
return _canonical_network_target(target)
try:
ip_obj = ipaddress.ip_address(target)
@ -1196,26 +1224,25 @@ def infer_target_type(target: str) -> tuple[str, dict[str, str]]: # noqa: PLR09
raise ValueError(f"Invalid path: {target} - {e!s}") from e
if target.endswith(".git"):
return "repository", {"target_repo": target}
return "repository", {"target_repo": _validated_repository_target(target)}
if "/" in target:
host_part, _, path_part = target.partition("/")
if "." in host_part and not host_part.startswith(".") and path_part:
full_url = f"https://{target}"
if _is_http_git_repo(full_url):
return "repository", {"target_repo": full_url}
return "web_application", {"target_url": full_url}
return "repository", {"target_repo": _validated_repository_target(full_url)}
return _canonical_network_target(full_url)
if "." in target and "/" not in target and not target.startswith("."):
parts = target.split(".")
if len(parts) >= 2 and all(p and p.strip() for p in parts):
return "web_application", {"target_url": f"https://{target}"}
return _canonical_network_target(target)
raise ValueError(
f"Invalid target: {target}\n"
"Target must be one of:\n"
"- A valid URL (http:// or https://)\n"
"- A Git repository URL (https://host/org/repo or git@host:org/repo.git)\n"
"- A Git repository URL (https://host/org/repo.git, "
"git+https://host/org/repo, or git@host:org/repo.git)\n"
"- A local directory path\n"
"- An API spec file (OpenAPI/Swagger .json/.yaml or a Postman collection)\n"
"- A Postman collection by id (postman://<collection-uid>[?env=<environment-uid>], "
@ -1438,17 +1465,66 @@ def check_mountable_dir(path: Path) -> None:
)
def dedupe_local_targets(targets_info: list[dict[str, Any]]) -> list[dict[str, Any]]:
def canonicalize_targets_info(targets_info: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""Migrate target records to the canonical host-level web schema."""
canonical: list[dict[str, Any]] = []
for target in targets_info:
if not isinstance(target, dict):
raise TypeError("target records must be objects")
raw_details = target.get("details") or {}
if not isinstance(raw_details, dict):
raise TypeError("target details must be an object")
details = dict(raw_details)
target_type = target.get("type")
if target_type == "repository":
repository = _validated_repository_target(
str(details.get("target_repo") or target.get("original") or "")
)
details["target_repo"] = repository
canonical.append({**target, "details": details, "original": repository})
continue
if target_type not in {"web_application", "ip_address"}:
canonical.append({**target, "details": details})
continue
value = str(
details.get("target_host")
or details.get("target_ip")
or details.get("target_url")
or target.get("original")
or ""
)
canonical_type, network_details = _canonical_network_target(value)
details.pop("target_url", None)
details.pop("target_host", None)
details.pop("target_ip", None)
details.update(network_details)
normalized = {**target, "type": canonical_type, "details": details}
normalized["original"] = next(iter(network_details.values()))
canonical.append(normalized)
return canonical
def dedupe_targets(targets_info: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""Deduplicate canonical host, IP, and local-directory targets."""
result: list[dict[str, Any]] = []
seen_paths: set[str] = set()
seen: set[tuple[str, 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:
if not isinstance(details, dict):
raise TypeError("target details must be an object")
target_type = str(target.get("type") or "")
identity_keys = {
"web_application": "target_host",
"ip_address": "target_ip",
"local_code": "target_path",
}
identity = str(details.get(identity_keys.get(target_type, "")) or "")
if not identity:
result.append(target)
continue
if path not in seen_paths:
seen_paths.add(path)
key = (target_type, identity)
if key not in seen:
seen.add(key)
result.append(target)
return result
@ -1472,26 +1548,23 @@ def _is_localhost_host(host: str) -> bool:
def rewrite_localhost_targets(targets_info: list[dict[str, Any]], host_gateway: str) -> None:
from yarl import URL
for target_info in targets_info:
target_type = target_info.get("type")
details = target_info.get("details", {})
if target_type == "web_application":
target_url = details.get("target_url", "")
try:
url = URL(target_url)
except (ValueError, TypeError):
continue
if url.host and _is_localhost_host(url.host):
details["target_url"] = str(url.with_host(host_gateway))
target_host = str(details.get("target_host") or "")
if target_host and _is_localhost_host(target_host):
details["target_host"] = host_gateway
target_info["original"] = host_gateway
elif target_type == "ip_address":
target_ip = details.get("target_ip", "")
if target_ip and _is_localhost_host(target_ip):
details["target_ip"] = host_gateway
target_info["type"] = "web_application"
details.pop("target_ip", None)
details["target_host"] = host_gateway
target_info["original"] = host_gateway
#: API spec targets are copied into one workspace directory rather than mounted
@ -1499,6 +1572,20 @@ def rewrite_localhost_targets(targets_info: list[dict[str, Any]], host_gateway:
API_SPEC_WORKSPACE_SUBDIR = "api-specs"
def _api_spec_staging_dir(run_name: str) -> Path:
return run_dir_for(run_name) / ".state" / API_SPEC_WORKSPACE_SUBDIR
def _api_spec_source(staging: Path) -> list[dict[str, Any]]:
return [
{
"source_path": str(staging),
"workspace_subdir": API_SPEC_WORKSPACE_SUBDIR,
"protect_metadata": False,
}
]
def write_fetched_collection(collection: dict[str, Any], collection_uid: str) -> str:
"""Write a collection fetched from the Postman API to a local file.
@ -1524,7 +1611,7 @@ def stage_api_specs(targets_info: list[dict[str, Any]], run_name: str) -> list[d
if not specs:
return []
staging = Path(tempfile.gettempdir()) / "strix_api_specs" / run_name
staging = _api_spec_staging_dir(run_name)
staging.mkdir(parents=True, exist_ok=True)
used: set[str] = set()
@ -1541,13 +1628,26 @@ def stage_api_specs(targets_info: list[dict[str, Any]], run_name: str) -> list[d
shutil.copy2(source, staging / name)
details["workspace_path"] = f"/workspace/{API_SPEC_WORKSPACE_SUBDIR}/{name}"
return [
{
"source_path": str(staging),
"workspace_subdir": API_SPEC_WORKSPACE_SUBDIR,
"protect_metadata": False,
}
]
return _api_spec_source(staging)
def restore_staged_api_specs(
targets_info: list[dict[str, Any]], run_name: str
) -> list[dict[str, Any]]:
"""Restore only API specs previously staged inside this run directory."""
specs = [target for target in targets_info if target.get("type") == "api_spec"]
if not specs:
return []
staging = _api_spec_staging_dir(run_name)
for target in specs:
workspace_path = str((target.get("details") or {}).get("workspace_path") or "")
prefix = f"/workspace/{API_SPEC_WORKSPACE_SUBDIR}/"
if not workspace_path.startswith(prefix):
raise ValueError("persisted API specification has an invalid workspace path")
name = workspace_path.removeprefix(prefix)
if not name or "/" in name or not (staging / name).is_file():
raise ValueError(f"staged API specification '{name or 'unknown'}' is missing")
return _api_spec_source(staging)
def clone_repository(repo_url: str, run_name: str, dest_name: str | None = None) -> str:

View file

@ -60,7 +60,9 @@ export function RunDetails({
// Configuration (launch inputs)
const targets = arr(raw.targets_info).map((t) => {
const o = rec(t);
const display = str(o.original) ?? str(rec(o.details).target_url) ?? "unknown target";
const details = rec(o.details);
const display =
str(o.original) ?? str(details.target_host) ?? str(details.target_url) ?? "unknown target";
const type = str(o.type);
return { display, type: type ? humanize(type) : null };
});

View file

@ -6,7 +6,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="color-scheme" content="dark" />
<title>Strix Results</title>
<script type="module" crossorigin src="./assets/index-Bpn8GiSb.js"></script>
<script type="module" crossorigin src="./assets/index-CtUrQiGD.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-qwPOPAGC.css">
</head>
<body>

View file

@ -397,9 +397,7 @@ class SupervisedMcpSession:
self._name,
)
if await self._reconnect():
logger.info(
"MCP connection %r reconnected after an idle death", self._name
)
logger.info("MCP connection %r reconnected after an idle death", self._name)
self._healed_without_progress = True
continue
else:
@ -441,9 +439,7 @@ class SupervisedMcpSession:
self._name,
)
except Exception: # noqa: BLE001 - any call failure is treated as a session death
logger.warning(
"MCP connection %r failed mid-call; reconnecting once", self._name
)
logger.warning("MCP connection %r failed mid-call; reconnecting once", self._name)
if not await self._reconnect():
self._mark_dead()

View file

@ -31,6 +31,7 @@ from urllib.parse import urlsplit
from agents import RunContextWrapper, function_tool
from strix.core.agents import AgentCoordinator
from strix.core.targets import canonical_network_host
logger = logging.getLogger(__name__)
@ -170,11 +171,19 @@ def _snap_to_scan_target(raw: str, scan_targets: list[str]) -> str:
if any(known == identity for _, known in scoped):
return raw
authority = _remote_authority(raw)
if authority:
hosted = [target for target, _ in scoped if _remote_authority(target) == authority]
# Two scan targets on one host are distinguished only by their paths,
# so snapping to "the host" would merge two distinct models into one.
try:
_, network_host = canonical_network_host(raw)
except ValueError:
network_host = ""
if network_host:
hosted: list[str] = []
for target, _ in scoped:
try:
_, target_host = canonical_network_host(target)
except ValueError:
continue
if target_host == network_host:
hosted.append(target)
return hosted[0] if len(hosted) == 1 else raw
directory = _local_directory(raw)

View file

@ -104,7 +104,10 @@ def test_build_targets_info_rejects_unparseable_spec(tmp_path: Path) -> None:
build_targets_info(args)
def test_stage_api_specs_copies_spec_into_workspace_dir(tmp_path: Path) -> None:
def test_stage_api_specs_copies_spec_into_workspace_dir(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.chdir(tmp_path)
targets = _resolved_targets(_write_spec(tmp_path / "src"))
(source,) = stage_api_specs(targets, "stage-run")
@ -114,7 +117,10 @@ def test_stage_api_specs_copies_spec_into_workspace_dir(tmp_path: Path) -> None:
assert targets[0]["details"]["workspace_path"] == "/workspace/api-specs/openapi.json"
def test_stage_api_specs_disambiguates_same_filename(tmp_path: Path) -> None:
def test_stage_api_specs_disambiguates_same_filename(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.chdir(tmp_path)
targets = _resolved_targets(
_write_spec(tmp_path / "a"),
_write_spec(tmp_path / "b"),
@ -148,5 +154,5 @@ def test_build_scope_context_authorizes_base_urls(tmp_path: Path) -> None:
types = {a["type"] for a in authorized}
assert "api_spec" in types
assert "web_application" in types
assert any(a["value"] == "https://api.shop.test/v1" for a in authorized)
assert "web_host" in types
assert any(a["value"] == "api.shop.test" for a in authorized)

View file

@ -10,6 +10,8 @@ from typing import TYPE_CHECKING, Any
import pytest
from strix.interface.utils import stage_api_specs
if TYPE_CHECKING:
from pathlib import Path
@ -40,8 +42,8 @@ def test_parse_arguments_accepts_target_list_file(
args = cli_main.parse_arguments()
assert [target["original"] for target in args.targets_info] == [
"https://test1.com/",
"http://test2.com:5789/",
"test1.com",
"test2.com",
]
assert [target["type"] for target in args.targets_info] == [
"web_application",
@ -64,8 +66,64 @@ def test_parse_arguments_combines_target_and_target_list(
args = cli_main.parse_arguments()
assert [target["original"] for target in args.targets_info] == [
"https://test1.com/",
"http://test2.com:5789/",
"test1.com",
"test2.com",
]
def test_parse_arguments_collapses_endpoint_targets_by_host(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
target_list = tmp_path / "targets.txt"
target_list.write_text("https://EXAMPLE.com/blog/\n", encoding="utf-8")
_stub_settings(monkeypatch)
monkeypatch.setattr(
sys,
"argv",
[
"strix",
"-t",
"https://example.com/search?q=test",
"--target-list",
str(target_list),
],
)
args = cli_main.parse_arguments()
assert args.targets_info == [
{
"type": "web_application",
"details": {"target_host": "example.com"},
"original": "example.com",
}
]
def test_parse_arguments_collapses_loopback_aliases_to_runtime_host(
monkeypatch: pytest.MonkeyPatch,
) -> None:
_stub_settings(monkeypatch)
monkeypatch.setattr(
sys,
"argv",
[
"strix",
"-t",
"http://127.0.0.1/api",
"-t",
"http://localhost/admin",
],
)
args = cli_main.parse_arguments()
assert args.targets_info == [
{
"type": "web_application",
"details": {"target_host": "host.docker.internal"},
"original": "host.docker.internal",
}
]
@ -228,7 +286,189 @@ def test_resume_still_requires_targets_or_a_workspace(
assert "has no targets_info" in capsys.readouterr().err
def test_resume_non_object_run_json_exits(tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]) -> None:
def test_resume_migrates_and_deduplicates_legacy_endpoint_targets(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.chdir(tmp_path)
_write_run_record(
tmp_path / "strix_runs",
"legacy_abcd",
{
"run_name": "legacy_abcd",
"targets_info": [
{
"type": "web_application",
"details": {"target_url": "https://Example.com/search?q=test"},
"original": "https://Example.com/search?q=test",
},
{
"type": "web_application",
"details": {"target_url": "https://example.com/blog/"},
"original": "https://example.com/blog/",
},
],
"user_instruction": "Test both endpoints.",
},
)
monkeypatch.setattr(sys, "argv", ["strix", "--resume", "legacy_abcd"])
args = cli_main.parse_arguments()
assert args.targets_info == [
{
"type": "web_application",
"details": {"target_host": "example.com"},
"original": "example.com",
}
]
persisted = json.loads((tmp_path / "strix_runs" / "legacy_abcd" / "run.json").read_text())
assert persisted["targets_info"] == args.targets_info
def test_resume_rejects_malformed_target_records(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
) -> None:
monkeypatch.chdir(tmp_path)
_write_run_record(
tmp_path / "strix_runs",
"malformed_abcd",
{
"run_name": "malformed_abcd",
"targets_info": ["not-an-object"],
"user_instruction": "test example.com",
},
)
monkeypatch.setattr(sys, "argv", ["strix", "--resume", "malformed_abcd"])
with pytest.raises(SystemExit):
cli_main.parse_arguments()
assert "invalid persisted target" in capsys.readouterr().err
def test_resume_rejects_malformed_target_details(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
) -> None:
monkeypatch.chdir(tmp_path)
_write_run_record(
tmp_path / "strix_runs",
"malformed_details",
{
"run_name": "malformed_details",
"targets_info": [{"type": "repository", "details": "not-an-object"}],
"user_instruction": "test example.com",
},
)
monkeypatch.setattr(sys, "argv", ["strix", "--resume", "malformed_details"])
with pytest.raises(SystemExit):
cli_main.parse_arguments()
assert "invalid persisted target" in capsys.readouterr().err
def test_resume_revalidates_repository_target_text(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
) -> None:
monkeypatch.chdir(tmp_path)
_write_run_record(
tmp_path / "strix_runs",
"malformed_repo",
{
"run_name": "malformed_repo",
"targets_info": [
{
"type": "repository",
"details": {"target_repo": "git@github.com:acme/service.git\nforged"},
"original": "git@github.com:acme/service.git\nforged",
}
],
"user_instruction": "review the repository",
},
)
monkeypatch.setattr(sys, "argv", ["strix", "--resume", "malformed_repo"])
with pytest.raises(SystemExit):
cli_main.parse_arguments()
assert "invalid persisted target" in capsys.readouterr().err
def test_resume_migrates_loopback_ip_to_runtime_host(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.chdir(tmp_path)
_write_run_record(
tmp_path / "strix_runs",
"loopback_abcd",
{
"run_name": "loopback_abcd",
"targets_info": [
{
"type": "ip_address",
"details": {"target_ip": "127.0.0.1"},
"original": "127.0.0.1",
}
],
"user_instruction": "test the local service",
},
)
monkeypatch.setattr(sys, "argv", ["strix", "--resume", "loopback_abcd"])
args = cli_main.parse_arguments()
assert args.targets_info == [
{
"type": "web_application",
"details": {"target_host": "host.docker.internal"},
"original": "host.docker.internal",
}
]
persisted = json.loads((tmp_path / "strix_runs" / "loopback_abcd" / "run.json").read_text())
assert persisted["targets_info"] == args.targets_info
def test_resume_restores_only_run_local_staged_api_spec(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
spec = tmp_path / "openapi.json"
spec.write_text("{}", encoding="utf-8")
monkeypatch.chdir(tmp_path)
targets = [
{
"type": "api_spec",
"details": {"target_spec": str(spec), "spec_format": "openapi"},
"original": str(spec),
}
]
stage_api_specs(targets, "api_abcd")
_write_run_record(
tmp_path / "strix_runs",
"api_abcd",
{
"run_name": "api_abcd",
"targets_info": targets,
},
)
monkeypatch.setattr(sys, "argv", ["strix", "--resume", "api_abcd"])
args = cli_main.parse_arguments()
assert args.local_sources == [
{
"source_path": str(tmp_path / "strix_runs" / "api_abcd" / ".state" / "api-specs"),
"workspace_subdir": "api-specs",
"protect_metadata": False,
}
]
def test_resume_non_object_run_json_exits(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str],
) -> None:
monkeypatch.chdir(tmp_path)
run_dir = tmp_path / "strix_runs" / "pentest_abcd"
run_dir.mkdir(parents=True)

View file

@ -298,9 +298,15 @@ async def test_setup_preflights_model_before_starting(
) -> None:
runtime_args = args()
runtime_args.instruction = "CLI instruction"
runtime_args.targets_info = [
{
"type": "web_application",
"details": {"target_host": "example.com"},
"original": "example.com",
}
]
runtime = GoTuiRuntime(runtime_args)
assert runtime.controller.instruction == "CLI instruction"
runtime.controller.targets = ["https://example.com", "/workspace/mounted"]
runtime.controller.scan_mode = "quick"
runtime.controller.instruction = ""
runtime.controller.max_budget_usd = 8.5
@ -320,22 +326,6 @@ async def test_setup_preflights_model_before_starting(
)
monkeypatch.setattr(go_tui, "preflight_model_connection", preflight)
def build(candidate: argparse.Namespace, **_: object) -> None:
calls.append("targets")
assert candidate.target == ["https://example.com", "/workspace/mounted"]
candidate.targets_info = [
{
"type": "web",
"details": {"target_url": "https://example.com"},
"original": "https://example.com",
},
{
"type": "local_code",
"details": {"target_path": "/workspace/mounted"},
"original": "/workspace/mounted",
},
]
def prepare(candidate: argparse.Namespace) -> None:
calls.append("prepare")
assert candidate.max_budget_usd == 8.5
@ -343,7 +333,6 @@ async def test_setup_preflights_model_before_starting(
assert candidate.scope_mode == "diff"
assert candidate.diff_base == "origin/main"
monkeypatch.setattr(go_tui, "build_targets_info", build)
monkeypatch.setattr(go_tui, "prepare_run", prepare)
monkeypatch.setattr(go_tui, "telemetry_start", lambda _args: calls.append("telemetry"))
monkeypatch.setattr(runtime, "init_run_state", lambda: calls.append("state"))
@ -351,7 +340,7 @@ async def test_setup_preflights_model_before_starting(
await runtime.start_from_setup()
assert calls == ["preflight", "targets", "prepare", "telemetry", "state", "scan"]
assert calls == ["preflight", "prepare", "telemetry", "state", "scan"]
assert runtime.args.scan_mode == "quick"
assert runtime.args.instruction == ""
assert runtime.args.max_budget_usd == 8.5
@ -360,12 +349,50 @@ async def test_setup_preflights_model_before_starting(
assert runtime.args.diff_base == "origin/main"
@pytest.mark.asyncio
async def test_setup_copies_inferred_target_records_into_prepared_run(
monkeypatch: pytest.MonkeyPatch,
) -> None:
runtime = GoTuiRuntime(args())
runtime.controller.targets_info = [
{
"type": "web_application",
"details": {"target_host": "fiuu.com"},
"original": "fiuu.com",
},
{
"type": "ip_address",
"details": {"target_ip": "192.0.2.10"},
"original": "192.0.2.10",
},
]
runtime.controller.targets = ["fiuu.com", "192.0.2.10"]
prepared: list[argparse.Namespace] = []
monkeypatch.setattr(
go_tui,
"load_settings",
lambda: SimpleNamespace(llm=SimpleNamespace(model="openrouter/test-model")),
)
monkeypatch.setattr(go_tui, "preflight_model_connection", lambda _model: asyncio.sleep(0))
monkeypatch.setattr(go_tui, "prepare_run", prepared.append)
monkeypatch.setattr(go_tui, "telemetry_start", lambda _args: None)
monkeypatch.setattr(runtime, "init_run_state", lambda: None)
monkeypatch.setattr(runtime, "start_scan", lambda: None)
await runtime.start_from_setup()
assert prepared[0].targets_info == runtime.controller.targets_info
assert runtime.args.targets_info == runtime.controller.targets_info
assert prepared[0].workspace_mount is None
assert prepared[0].targets_info is not runtime.controller.targets_info
@pytest.mark.asyncio
async def test_optimistic_setup_skips_model_preflight(
monkeypatch: pytest.MonkeyPatch,
) -> None:
runtime = GoTuiRuntime(args())
runtime.controller.targets = [str(Path.cwd())]
calls: list[str] = []
async def preflight(_model: str) -> None:
@ -377,7 +404,6 @@ async def test_optimistic_setup_skips_model_preflight(
lambda: SimpleNamespace(llm=SimpleNamespace(model="openrouter/test-model")),
)
monkeypatch.setattr(go_tui, "preflight_model_connection", preflight)
monkeypatch.setattr(go_tui, "build_targets_info", lambda _args, **_kw: calls.append("targets"))
monkeypatch.setattr(go_tui, "prepare_run", lambda _args: calls.append("prepare"))
monkeypatch.setattr(go_tui, "telemetry_start", lambda _args: calls.append("telemetry"))
monkeypatch.setattr(runtime, "init_run_state", lambda: calls.append("state"))
@ -388,7 +414,7 @@ async def test_optimistic_setup_skips_model_preflight(
# No preflight: the scan launches straight through and any model error
# surfaces once the agent runs.
assert "preflight" not in calls
assert calls == ["targets", "prepare", "telemetry", "state", "scan"]
assert calls == ["prepare", "telemetry", "state", "scan"]
@pytest.mark.asyncio
@ -409,11 +435,6 @@ async def test_confirmed_target_less_launch_mounts_workspace_without_targets(
lambda: SimpleNamespace(llm=SimpleNamespace(model="openrouter/test-model")),
)
monkeypatch.setattr(go_tui, "preflight_model_connection", preflight)
monkeypatch.setattr(
go_tui,
"build_targets_info",
lambda _args, **_kw: pytest.fail("a target-less launch must not build targets"),
)
monkeypatch.setattr(go_tui, "prepare_run", prepared.append)
monkeypatch.setattr(go_tui, "telemetry_start", lambda _args: None)
monkeypatch.setattr(runtime, "init_run_state", lambda: None)
@ -434,9 +455,9 @@ async def test_setup_preserves_prepared_cli_targets(
runtime_args.target_list = []
runtime_args.targets_info = [
{
"type": "web",
"details": {"url": "https://example.com"},
"original": "https://example.com",
"type": "web_application",
"details": {"target_host": "example.com"},
"original": "example.com",
}
]
runtime = GoTuiRuntime(runtime_args)
@ -451,11 +472,6 @@ async def test_setup_preserves_prepared_cli_targets(
lambda: SimpleNamespace(llm=SimpleNamespace(model="openrouter/test-model")),
)
monkeypatch.setattr(go_tui, "preflight_model_connection", preflight)
monkeypatch.setattr(
go_tui,
"build_targets_info",
lambda _args, **_kw: pytest.fail("prepared targets should not be rebuilt"),
)
monkeypatch.setattr(go_tui, "prepare_run", lambda _args: calls.append("prepare"))
monkeypatch.setattr(go_tui, "telemetry_start", lambda _args: calls.append("telemetry"))
monkeypatch.setattr(runtime, "init_run_state", lambda: calls.append("state"))
@ -463,245 +479,11 @@ async def test_setup_preserves_prepared_cli_targets(
await runtime.start_from_setup()
assert runtime.controller.targets == ["https://example.com"]
assert runtime.args.targets_info[0]["type"] == "web"
assert runtime.controller.targets == ["example.com"]
assert runtime.args.targets_info[0]["type"] == "web_application"
assert calls == ["preflight", "prepare", "telemetry", "state", "scan"]
@pytest.mark.asyncio
async def test_setup_target_change_preserves_local_targets(
monkeypatch: pytest.MonkeyPatch,
) -> None:
runtime_args = args()
runtime_args.target = []
runtime_args.target_list = ["targets.txt"]
runtime_args.targets_info = [
{
"type": "local_code",
"details": {"target_path": "/workspace/source"},
"original": "/workspace/source",
}
]
runtime = GoTuiRuntime(runtime_args)
runtime.controller.targets.append("https://example.com")
async def preflight(_model: str) -> None:
return None
def build(target_args: argparse.Namespace, **_: object) -> None:
assert target_args.target == ["/workspace/source", "https://example.com"]
target_args.targets_info = [
{
"type": "web",
"details": {"url": "https://example.com"},
"original": "https://example.com",
},
{
"type": "local_code",
"details": {"target_path": "/workspace/source"},
"original": "/workspace/source",
},
]
monkeypatch.setattr(
go_tui,
"load_settings",
lambda: SimpleNamespace(llm=SimpleNamespace(model="openrouter/test-model")),
)
monkeypatch.setattr(go_tui, "preflight_model_connection", preflight)
monkeypatch.setattr(go_tui, "build_targets_info", build)
monkeypatch.setattr(go_tui, "prepare_run", lambda _args: None)
monkeypatch.setattr(go_tui, "telemetry_start", lambda _args: None)
monkeypatch.setattr(runtime, "init_run_state", lambda: None)
monkeypatch.setattr(runtime, "start_scan", lambda: None)
await runtime.start_from_setup()
assert runtime.args.target_list == []
assert runtime.args.targets_info[0]["type"] == "web"
assert runtime.args.targets_info[1]["type"] == "local_code"
@pytest.mark.asyncio
async def test_setup_same_basename_uses_combined_workspace_names_on_retry(
monkeypatch: pytest.MonkeyPatch,
) -> None:
existing_repo = "https://example.com/first/app.git"
added_repo = "https://example.com/second/app.git"
runtime_args = args()
runtime_args.target = []
runtime_args.target_list = ["targets.txt"]
runtime_args.targets_info = [
{
"type": "repository",
"details": {
"target_repo": existing_repo,
"workspace_subdir": "app",
"cloned_repo_path": "/clones/app",
},
"original": existing_repo,
}
]
runtime = GoTuiRuntime(runtime_args)
runtime.controller.targets.append(added_repo)
prepare_attempts = 0
started: list[str] = []
async def preflight(_model: str) -> None:
return None
def build(target_args: argparse.Namespace, **_: object) -> None:
assert target_args.target == [existing_repo, added_repo]
target_args.targets_info = [
{
"type": "repository",
"details": {
"target_repo": existing_repo,
"workspace_subdir": "app",
},
"original": existing_repo,
},
{
"type": "repository",
"details": {
"target_repo": added_repo,
"workspace_subdir": "app-2",
},
"original": added_repo,
},
]
def prepare(candidate: argparse.Namespace) -> None:
nonlocal prepare_attempts
prepare_attempts += 1
assert [target["details"]["workspace_subdir"] for target in candidate.targets_info] == [
"app",
"app-2",
]
if prepare_attempts == 1:
candidate.targets_info[0]["details"]["target_repo"] = "/mutated"
candidate.targets_info[1]["details"]["workspace_subdir"] = "mutated"
raise ValueError("retry setup")
monkeypatch.setattr(
go_tui,
"load_settings",
lambda: SimpleNamespace(llm=SimpleNamespace(model="openrouter/test-model")),
)
monkeypatch.setattr(go_tui, "preflight_model_connection", preflight)
monkeypatch.setattr(go_tui, "build_targets_info", build)
monkeypatch.setattr(go_tui, "prepare_run", prepare)
monkeypatch.setattr(go_tui, "telemetry_start", lambda _args: None)
monkeypatch.setattr(runtime, "init_run_state", lambda: started.append("state"))
monkeypatch.setattr(runtime, "start_scan", lambda: started.append("scan"))
with pytest.raises(ValueError, match="retry setup"):
await runtime.start_from_setup()
assert runtime.args.targets_info[0]["details"] == {
"target_repo": existing_repo,
"workspace_subdir": "app",
"cloned_repo_path": "/clones/app",
}
assert started == []
await runtime.start_from_setup()
assert prepare_attempts == 2
assert runtime.args.target_list == []
assert [target["details"]["workspace_subdir"] for target in runtime.args.targets_info] == [
"app",
"app-2",
]
assert runtime.args.targets_info[0]["details"]["target_repo"] == existing_repo
assert started == ["state", "scan"]
@pytest.mark.asyncio
async def test_setup_target_rebuild_restores_all_target_fields_on_failure(
monkeypatch: pytest.MonkeyPatch,
) -> None:
runtime_args = args()
runtime_args.target = None
runtime_args.target_list = ["targets.txt"]
runtime_args.targets_info = [
{
"type": "local_code",
"details": {"target_path": "/workspace/source"},
"original": "/workspace/source",
}
]
original_targets_info = json.loads(json.dumps(runtime_args.targets_info))
runtime = GoTuiRuntime(runtime_args)
runtime.controller.targets.append("https://example.com")
async def preflight(_model: str) -> None:
return None
def fail_rebuild(target_args: argparse.Namespace, **_: object) -> None:
target_args.target = ["mutated"]
target_args.target_list = ["mutated.txt"]
target_args.targets_info = [{"original": "partial"}]
raise ValueError("bad target")
monkeypatch.setattr(
go_tui,
"load_settings",
lambda: SimpleNamespace(llm=SimpleNamespace(model="openrouter/test-model")),
)
monkeypatch.setattr(go_tui, "preflight_model_connection", preflight)
monkeypatch.setattr(go_tui, "build_targets_info", fail_rebuild)
with pytest.raises(ValueError, match="bad target"):
await runtime.start_from_setup()
assert runtime.args.target is None
assert runtime.args.target_list == ["targets.txt"]
assert runtime.args.targets_info == original_targets_info
@pytest.mark.asyncio
async def test_setup_rebuild_canonicalizes_relative_local_target(
tmp_path: Any,
monkeypatch: pytest.MonkeyPatch,
) -> None:
source = tmp_path / "source"
source.mkdir()
monkeypatch.chdir(tmp_path)
runtime_args = args()
runtime_args.target = []
runtime_args.target_list = []
runtime = GoTuiRuntime(runtime_args)
runtime.controller.targets = ["source"]
prepared = False
async def preflight(_model: str) -> None:
return None
def prepare(candidate: argparse.Namespace) -> None:
nonlocal prepared
prepared = True
assert len(candidate.targets_info) == 1
assert candidate.targets_info[0]["details"]["target_path"] == str(source.resolve())
monkeypatch.setattr(
go_tui,
"load_settings",
lambda: SimpleNamespace(llm=SimpleNamespace(model="openrouter/test-model")),
)
monkeypatch.setattr(go_tui, "preflight_model_connection", preflight)
monkeypatch.setattr(go_tui, "prepare_run", prepare)
monkeypatch.setattr(go_tui, "telemetry_start", lambda _args: None)
monkeypatch.setattr(runtime, "init_run_state", lambda: None)
monkeypatch.setattr(runtime, "start_scan", lambda: None)
await runtime.start_from_setup()
assert prepared is True
assert runtime.args.targets_info[0]["original"] == str(source.resolve())
@pytest.mark.asyncio
@pytest.mark.asyncio
async def test_setup_prepare_system_exit_is_recoverable_and_transactional(
monkeypatch: pytest.MonkeyPatch,
@ -713,9 +495,9 @@ async def test_setup_prepare_system_exit_is_recoverable_and_transactional(
runtime_args.target_list = []
runtime_args.targets_info = [
{
"type": "web",
"details": {"url": "https://example.com"},
"original": "https://example.com",
"type": "web_application",
"details": {"target_host": "example.com"},
"original": "example.com",
}
]
original_args = json.loads(json.dumps(vars(runtime_args)))
@ -730,7 +512,7 @@ async def test_setup_prepare_system_exit_is_recoverable_and_transactional(
def fail_prepare(candidate: argparse.Namespace) -> None:
assert candidate is not runtime.args
candidate.run_name = "mutated-run"
candidate.targets_info[0]["details"]["url"] = "https://mutated.example"
candidate.targets_info[0]["details"]["target_host"] = "mutated.example"
raise ValueError("invalid diff scope")
def telemetry(_candidate: argparse.Namespace) -> None:
@ -788,7 +570,7 @@ async def test_setup_preflight_failure_does_not_start_scan(
monkeypatch: pytest.MonkeyPatch,
) -> None:
runtime = GoTuiRuntime(args())
runtime.controller.targets = ["https://example.com"]
runtime.controller.targets = ["example.com"]
started = False
async def preflight(_model: str) -> None:
@ -804,7 +586,6 @@ async def test_setup_preflight_failure_does_not_start_scan(
lambda: SimpleNamespace(llm=SimpleNamespace(model="openrouter/test-model")),
)
monkeypatch.setattr(go_tui, "preflight_model_connection", preflight)
monkeypatch.setattr(go_tui, "build_targets_info", mark_started)
monkeypatch.setattr(runtime, "init_run_state", mark_started)
monkeypatch.setattr(runtime, "start_scan", mark_started)

View file

@ -8,13 +8,16 @@ from typing import Any
import litellm
import pytest
from strix.agents.prompt import render_system_prompt
from strix.core.inputs import (
build_root_task,
build_scan_targets,
build_scope_context,
build_scope_target_labels,
child_initial_input,
make_model_settings,
)
from strix.interface.utils import build_target_summary_text
def _child_kwargs(parent_history: list[Any]) -> dict[str, Any]:
@ -194,18 +197,22 @@ def test_build_root_task_repository_target() -> None:
assert "https://example.com/repo.git" in task
def test_build_root_task_web_application_with_instructions() -> None:
def test_build_root_task_web_target_injected_as_context() -> None:
"""The prompt leads and the configured target remains visible below it."""
config = {
"targets": [
{"type": "web_application", "details": {"target_url": "https://app.example.com"}},
{"type": "web_application", "details": {"target_host": "app.example.com"}},
],
"user_instructions": "Focus on auth.",
}
task = build_root_task(config)
assert "URLs:" in task
assert "https://app.example.com" in task
assert "Special instructions: Focus on auth." in task
assert task.startswith("Focus on auth.")
assert "Hosts:" in task
assert "app.example.com" in task
assert "configured targets and supporting material" in task
assert "Special instructions:" not in task
assert "SYSTEM-VERIFIED" not in task
def test_build_root_task_workspace_mount_is_not_a_target() -> None:
@ -221,9 +228,9 @@ def test_build_root_task_workspace_mount_is_not_a_target() -> None:
assert "Working Directory:" in task
assert "/workspace/api" in task
assert "No scan target was set" in task
assert "Special instructions: Find IDOR in the checkout flow." in task
assert task.startswith("Find IDOR in the checkout flow.")
# It must not be presented as an asset to test.
for label in ("Local Codebases:", "Repositories:", "URLs:", "IP Addresses:"):
for label in ("Local Codebases:", "Repositories:", "Hosts:", "IP Addresses:"):
assert label not in task
@ -234,6 +241,115 @@ def test_build_scope_context_authorizes_nothing_without_targets() -> None:
)
assert scope["authorized_targets"] == []
assert scope["user_instruction_hosts_expand_scope"] is True
assert build_target_summary_text([]).plain == "Target task-defined scope"
def test_scope_prompt_authorizes_flag_and_instruction_hosts_with_subdomains() -> None:
config: dict[str, Any] = {
"targets": [
{
"type": "web_application",
"details": {"target_host": "app.example.com"},
"original": "app.example.com",
}
],
"user_instructions": (
"Test https://app.example.com/search?q=test and "
"https://app.example.com/blog/. Also test https://api.example.net/v1."
),
}
context = build_scope_context(config)
prompt = render_system_prompt(scan_mode="quick", is_root=True, system_prompt_context=context)
task = build_root_task(config)
assert "SYSTEM-VERIFIED SCOPE" in prompt
assert context["authorized_targets"] == [
{"type": "web_host", "value": "app.example.com", "workspace_path": ""}
]
assert "host: app.example.com (includes app.example.com and *.app.example.com)" in prompt
assert prompt.count("host: app.example.com") == 1
assert "https://app.example.com/search?q=test" not in prompt
assert "https://app.example.com/search?q=test" in task
assert "https://app.example.com/blog/" in task
assert "https://api.example.net/v1" in task
assert "Every network host explicitly named in the user's root scan task" in prompt
assert "exact hostname and all of its descendant subdomains" in prompt
assert "scheme, port, path, query, or fragment" in prompt
assert "not `example.com`, sibling hosts such as `api.example.com`" in prompt
assert build_scope_target_labels(config["targets"]) == [
"host: app.example.com (includes *.app.example.com)"
]
assert build_target_summary_text(config["targets"]).plain == (
"Target host: app.example.com (includes *.app.example.com)"
)
def test_scope_prompt_authorizes_subdomains_for_each_configured_host() -> None:
targets = [
{
"type": "web_application",
"details": {"target_host": "fiuu.com"},
"original": "fiuu.com",
},
{
"type": "web_application",
"details": {"target_host": "api.fiuu.com"},
"original": "api.fiuu.com",
},
]
context = build_scope_context({"targets": targets})
prompt = render_system_prompt(scan_mode="quick", is_root=True, system_prompt_context=context)
assert context["authorized_targets"] == [
{"type": "web_host", "value": "fiuu.com", "workspace_path": ""},
{"type": "web_host", "value": "api.fiuu.com", "workspace_path": ""},
]
assert "host: fiuu.com (includes fiuu.com and *.fiuu.com)" in prompt
assert "host: api.fiuu.com (includes api.fiuu.com and *.api.fiuu.com)" in prompt
def test_scope_prompt_keeps_web_ip_targets_exact() -> None:
context = build_scope_context(
{
"targets": [
{
"type": "ip_address",
"details": {"target_ip": "192.0.2.10"},
}
]
}
)
prompt = render_system_prompt(scan_mode="quick", is_root=True, system_prompt_context=context)
assert context["authorized_targets"] == [
{"type": "ip_address", "value": "192.0.2.10", "workspace_path": ""}
]
assert "ip_address: 192.0.2.10 (exact address)" in prompt
assert "https://192.0.2.10:8443/admin" not in prompt
def test_scope_prompt_does_not_make_repository_origin_a_live_target() -> None:
context = build_scope_context(
{
"targets": [
{
"type": "repository",
"details": {"target_repo": "https://github.com/acme/app.git"},
}
]
}
)
prompt = render_system_prompt(scan_mode="quick", is_root=True, system_prompt_context=context)
assert "repository: https://github.com/acme/app.git" in prompt
assert "Repository hosting origins named only by configured repository targets" in prompt
assert "are not live web targets" in prompt
def test_build_root_task_diff_scope() -> None:
@ -374,23 +490,23 @@ def test_scan_targets_prefer_the_workspace_checkout_over_the_remote_url() -> Non
"workspace_subdir": "billing",
},
},
{"type": "web_application", "details": {"target_url": "https://app.example.com"}},
{"type": "web_application", "details": {"target_host": "app.example.com"}},
]
}
assert build_scan_targets(config) == ["/workspace/billing", "https://app.example.com"]
assert build_scan_targets(config) == ["/workspace/billing", "app.example.com"]
def test_scan_targets_drop_empty_and_duplicate_entries() -> None:
config = {
"targets": [
{"type": "web_application", "details": {"target_url": "https://app.example.com"}},
{"type": "web_application", "details": {"target_url": "https://app.example.com"}},
{"type": "web_application", "details": {"target_host": "app.example.com"}},
{"type": "web_application", "details": {"target_host": "app.example.com"}},
{"type": "ip_address", "details": {}},
]
}
assert build_scan_targets(config) == ["https://app.example.com"]
assert build_scan_targets(config) == ["app.example.com"]
def test_openrouter_attribution_rides_on_the_request_headers() -> None:

View file

@ -8,11 +8,12 @@ from typing import Any
import pytest
from strix.core.targets import canonical_network_host
from strix.interface.scan_setup import attach_workspace_mount
from strix.interface.utils import (
check_mountable_dir,
collect_local_sources,
dedupe_local_targets,
dedupe_targets,
infer_target_type,
read_target_list_file,
)
@ -181,6 +182,106 @@ def test_infer_target_type_applies_the_mount_policy() -> None:
infer_target_type("/etc")
def test_infer_web_target_reduces_endpoint_to_host() -> None:
assert infer_target_type("https://Example.COM:8443/search?q=test#results") == (
"web_application",
{"target_host": "example.com"},
)
def test_infer_multi_segment_web_path_is_not_probed_as_repository() -> None:
assert infer_target_type("https://app.example.com/api/v1/users") == (
"web_application",
{"target_host": "app.example.com"},
)
def test_infer_web_ip_target_becomes_exact_ip() -> None:
assert infer_target_type("https://192.0.2.10:8443/admin") == (
"ip_address",
{"target_ip": "192.0.2.10"},
)
@pytest.mark.parametrize(
("target", "expected"),
[
("fiuu.com/search-result/?s=", ("web_host", "fiuu.com")),
("https://FIUU.com/blog/", ("web_host", "fiuu.com")),
("192.0.2.10/search-result/?s=", ("ip_address", "192.0.2.10")),
("https://192.0.2.10/blog/", ("ip_address", "192.0.2.10")),
("2001:db8::1", ("ip_address", "2001:db8::1")),
("https://[2001:db8::1]/blog/", ("ip_address", "2001:db8::1")),
("localhost:3000/admin", ("web_host", "localhost")),
("https://münich.example/path", ("web_host", "xn--mnich-kva.example")),
(
"fiuu.com/callback?next=https://other.example/path",
("web_host", "fiuu.com"),
),
],
)
def test_canonical_network_host_handles_prompt_network_references(
target: str, expected: tuple[str, str]
) -> None:
assert canonical_network_host(target) == expected
@pytest.mark.parametrize("target", ["fiuu.com:bad/path", "https://fiuu.com:70000/path"])
def test_canonical_network_host_rejects_invalid_ports(target: str) -> None:
with pytest.raises(ValueError, match="invalid host"):
canonical_network_host(target)
def test_infer_repository_keeps_its_path() -> None:
target = "https://github.com/acme/service.git"
assert infer_target_type(target) == ("repository", {"target_repo": target})
def test_infer_http_repository_without_explicit_git_syntax_is_a_web_target() -> None:
target = "https://github.com/acme/service"
assert infer_target_type(target) == (
"web_application",
{"target_host": "github.com"},
)
def test_infer_explicit_git_https_repository_keeps_self_hosted_path() -> None:
target = "https://git.example.com/acme/service"
assert infer_target_type(f"git+{target}") == ("repository", {"target_repo": target})
@pytest.mark.parametrize(
"target",
[
"https://github.com/login/oauth",
"https://github.com/about/security",
"https://github.com/acme/service/issues/1",
],
)
def test_infer_git_provider_web_pages_remain_web_targets(target: str) -> None:
assert infer_target_type(target) == (
"web_application",
{"target_host": "github.com"},
)
def test_infer_web_basic_auth_url_remains_web_target() -> None:
assert infer_target_type("https://admin:secret@app.example.com/dashboard") == (
"web_application",
{"target_host": "app.example.com"},
)
def test_infer_web_target_rejects_invalid_hostname() -> None:
with pytest.raises(ValueError, match="invalid host"):
infer_target_type("https://example.com bad-scope-text")
def test_infer_repository_rejects_control_characters() -> None:
with pytest.raises(ValueError, match="control characters"):
infer_target_type("git@github.com:acme/service.git\nforged")
def test_read_target_list_file_strips_blank_lines(tmp_path: Path) -> None:
target_list = tmp_path / "targets.txt"
target_list.write_text(
@ -237,13 +338,22 @@ def test_read_target_list_file_rejects_empty_path(empty: str) -> None:
def test_dedupe_keeps_distinct_targets_in_order() -> None:
targets = [
_local_target("/a"),
{"type": "web_application", "details": {"target_url": "https://x"}},
{"type": "web_application", "details": {"target_host": "x.example"}},
_local_target("/b"),
]
assert dedupe_local_targets(targets) == targets
assert dedupe_targets(targets) == targets
def test_dedupe_collapses_the_same_path() -> None:
assert dedupe_local_targets([_local_target("/repo"), _local_target("/repo")]) == [
assert dedupe_targets([_local_target("/repo"), _local_target("/repo")]) == [
_local_target("/repo")
]
def test_dedupe_collapses_the_same_web_host() -> None:
target = {
"type": "web_application",
"details": {"target_host": "example.com"},
"original": "example.com",
}
assert dedupe_targets([target, target.copy()]) == [target]

View file

@ -185,6 +185,4 @@ async def test_roster_is_persisted_even_without_a_status_sink(
)
assert persisted, "roster must persist even when no status sink is attached"
assert persisted[-1] == [
{"name": "local_fs", "provider": None, "tool_count": 3, "dead": False}
]
assert persisted[-1] == [{"name": "local_fs", "provider": None, "tool_count": 3, "dead": False}]

View file

@ -108,12 +108,12 @@ async def test_root_prompt_options_flow_into_root_agent(
"authorization_source": "strix_platform_verified_targets",
"authorized_targets": [
{
"type": "web_application",
"value": "https://example.com",
"type": "web_host",
"value": "example.com",
"workspace_path": "",
},
],
"user_instructions_do_not_expand_scope": True,
"user_instruction_hosts_expand_scope": True,
}
captured = _patch_engine_scaffold(monkeypatch, tmp_path, scope_context)
@ -129,12 +129,12 @@ async def test_root_prompt_options_flow_into_root_agent(
kwargs = captured["kwargs"]
instructions_override = kwargs["instructions_override"]
assert "SYSTEM-VERIFIED SCOPE" in instructions_override
assert "AUTHORIZED TARGETS" in instructions_override
assert "https://example.com" in instructions_override
assert "AUTHORIZED CONFIGURED TARGETS" in instructions_override
assert "host: example.com (includes example.com and *.example.com)" in instructions_override
assert "exact hostname and all of its descendant subdomains" in instructions_override
assert "CUSTOM SCAN PROMPT" in instructions_override
assert (
"cannot expand, replace, or weaken authorized target constraints" in instructions_override
)
assert "Network hosts explicitly named in these root scan instructions" in instructions_override
assert "cannot otherwise replace or weaken those rules" in instructions_override
assert kwargs["system_prompt_context"] == {
**scope_context,
"target_context": "known findings",

View file

@ -24,7 +24,7 @@ def test_parse_repo_full_name_handles_common_forms() -> None:
def test_repository_context_none_for_non_repository_targets() -> None:
state = ReportState(run_name="t")
state.run_record["targets_info"] = [
{"type": "web_application", "details": {"target_url": "https://example.com"}}
{"type": "web_application", "details": {"target_host": "example.com"}}
]
assert state._sarif_repository_context() is None

View file

@ -303,6 +303,13 @@ def test_path_on_a_known_host_resolves_to_the_scan_target() -> None:
assert _get_impl("https://app.example.com/admin/login", scan_targets)["found"] is True
def test_nondefault_port_on_a_known_host_resolves_to_the_host_target() -> None:
scan_targets = ["app.example.com"]
_save_impl("https://app.example.com:8443/admin", _BLACKBOX_MODEL, "root", scan_targets)
assert _get_impl("http://app.example.com:3000/login", scan_targets)["found"] is True
def test_two_scan_targets_on_one_host_stay_separate() -> None:
scan_targets = ["https://example.com/tenant-a", "https://example.com/tenant-b"]
_save_impl("https://example.com/tenant-a", _BLACKBOX_MODEL, "root", scan_targets)

View file

@ -29,6 +29,18 @@ def args() -> argparse.Namespace:
)
def args_with_target(host: str = "example.com") -> argparse.Namespace:
setup_args = args()
setup_args.targets_info = [
{
"type": "web_application",
"details": {"target_host": host},
"original": host,
}
]
return setup_args
@pytest.fixture(autouse=True)
def isolated_config(tmp_path: Path) -> None:
for key in (
@ -47,11 +59,10 @@ def isolated_config(tmp_path: Path) -> None:
@pytest.mark.asyncio
async def test_setup_state_is_serializable() -> None:
controller = TuiController(args())
await controller.handle("setup.add_target", {"target": "https://example.com"})
controller = TuiController(args_with_target())
await controller.handle("setup.set_instruction", {"instruction": "focus on auth"})
snapshot = controller.snapshot()
assert snapshot["targets"] == ["https://example.com"]
assert snapshot["targets"] == ["example.com"]
assert snapshot["instruction"] == "focus on auth"
assert snapshot["scan_state"] == "setup"
assert snapshot["scan_mode"] == "deep"
@ -101,19 +112,15 @@ async def test_setup_controls_reject_changes_after_start() -> None:
controller.scan_started = True
with pytest.raises(RuntimeError, match="can no longer be changed"):
await controller.handle("setup.add_target", {"target": "https://example.com"})
await controller.handle("setup.set_instruction", {"instruction": "new task"})
@pytest.mark.asyncio
async def test_large_target_list_reports_truncated_snapshot_count() -> None:
controller = TuiController(args())
for index in range(20):
await controller.handle("setup.add_target", {"target": f"https://target-{index}.example"})
added = await controller.handle("setup.add_target", {"target": "https://last.example"})
controller.targets = [f"target-{index}.example" for index in range(21)]
snapshot = controller.snapshot()
assert added == {"target": "https://last.example", "total": 21}
assert snapshot["target_count"] == 21
# The snapshot only carries a bounded prefix of the list.
assert len(snapshot["targets"]) == 16
@ -132,13 +139,13 @@ def test_state_populates_model_warning_for_non_frontier_model() -> None:
def test_setup_restores_prepared_cli_targets() -> None:
setup_args = args()
setup_args.targets_info = [
{"type": "web", "details": {}, "original": "https://example.com"},
{"type": "web_application", "details": {}, "original": "example.com"},
{"type": "local_code", "details": {}, "original": "/workspace/source"},
]
controller = TuiController(setup_args)
assert controller.snapshot()["targets"] == ["https://example.com", "/workspace/source"]
assert controller.snapshot()["targets"] == ["example.com", "/workspace/source"]
@pytest.mark.asyncio
@ -149,8 +156,7 @@ async def test_start_validates_model_before_callback() -> None:
nonlocal started
started = True
controller = TuiController(args(), on_start=start)
await controller.handle("setup.add_target", {"target": "https://example.com"})
controller = TuiController(args_with_target(), on_start=start)
with pytest.raises(ValueError, match="No model configured"):
await controller.handle("setup.start", {})
assert started is False
@ -166,8 +172,7 @@ async def test_start_launches_with_a_configured_model() -> None:
os.environ["STRIX_LLM"] = "anthropic/claude-sonnet-4"
loader._cached = None
controller = TuiController(args(), on_start=start)
await controller.handle("setup.add_target", {"target": "https://example.com"})
controller = TuiController(args_with_target(), on_start=start)
result = await controller.handle("setup.start", {})
@ -175,6 +180,117 @@ async def test_start_launches_with_a_configured_model() -> None:
assert started is True
@pytest.mark.asyncio
async def test_prompt_targets_are_canonicalized_deduplicated_and_started() -> None:
started: list[bool] = []
async def start(verify: bool = True) -> None:
started.append(verify)
prompt = (
"i need you to test fiuu.com/search-result/?s=, fiuu.com/blog/ "
"(fiuu.com/blog/-9 will show you the sql query), fiuu.com/newsroom/, "
"and fiuu.com/faq/ for sqli. all of the pages likely use mysql and the same database"
)
os.environ["STRIX_LLM"] = "anthropic/claude-sonnet-4"
loader._cached = None
controller = TuiController(args(), on_start=start)
result = await controller.handle(
"setup.start",
{
"instruction": prompt,
"targets": [
"fiuu.com/search-result/?s=",
"https://FIUU.com/blog/",
"fiuu.com/blog/-9",
"api.fiuu.com/admin",
"192.0.2.10/search-result/?s=",
"https://192.0.2.10/blog/",
],
# The backend still requires verification once targets resolve.
"verify": False,
},
)
assert result == {"started": True}
assert started == [True]
assert controller.instruction == prompt
assert controller.targets == ["fiuu.com", "api.fiuu.com", "192.0.2.10"]
assert controller.targets_info == [
{
"type": "web_application",
"details": {"target_host": "fiuu.com"},
"original": "fiuu.com",
},
{
"type": "web_application",
"details": {"target_host": "api.fiuu.com"},
"original": "api.fiuu.com",
},
{
"type": "ip_address",
"details": {"target_ip": "192.0.2.10"},
"original": "192.0.2.10",
},
]
assert controller.pending_workspace_mount is None
@pytest.mark.asyncio
async def test_invalid_prompt_targets_do_not_partially_mutate_setup() -> None:
controller = TuiController(args())
with pytest.raises(ValueError, match="invalid host"):
await controller.handle(
"setup.start",
{
"instruction": "changed",
"targets": ["fiuu.com/path", "https://bad host/path"],
},
)
assert controller.instruction == ""
assert controller.targets == []
assert controller.targets_info == []
assert controller.setup_mode is True
@pytest.mark.asyncio
async def test_failed_prompt_target_start_rolls_back_before_retry() -> None:
attempts = 0
async def start(_verify: bool = True) -> None:
nonlocal attempts
attempts += 1
if attempts == 1:
raise RuntimeError("preparation failed")
os.environ["STRIX_LLM"] = "anthropic/claude-sonnet-4"
loader._cached = None
controller = TuiController(args(), on_start=start)
with pytest.raises(RuntimeError, match="preparation failed"):
await controller.handle(
"setup.start",
{"instruction": "test old.example", "targets": ["old.example"]},
)
assert controller.instruction == ""
assert controller.targets == []
assert controller.targets_info == []
assert controller.setup_mode is True
await controller.handle(
"setup.start",
{"instruction": "test new.example", "targets": ["new.example"]},
)
assert controller.instruction == "test new.example"
assert controller.targets == ["new.example"]
assert controller.targets_info[0]["details"] == {"target_host": "new.example"}
@pytest.mark.asyncio
async def test_start_without_target_requires_mount_consent() -> None:
started = False
@ -324,8 +440,7 @@ async def test_start_forwards_verify_flag_by_default() -> None:
os.environ["STRIX_LLM"] = "anthropic/claude-sonnet-4"
os.environ["ANTHROPIC_API_KEY"] = "test-key"
loader._cached = None
controller = TuiController(args(), on_start=start)
await controller.handle("setup.add_target", {"target": "https://example.com"})
controller = TuiController(args_with_target(), on_start=start)
# A named target keeps the upfront model check.
await controller.handle("setup.start", {})
@ -345,8 +460,7 @@ async def test_start_rejects_concurrent_and_repeated_submissions() -> None:
os.environ["STRIX_LLM"] = "anthropic/claude-sonnet-4"
os.environ["ANTHROPIC_API_KEY"] = "test-key"
loader._cached = None
controller = TuiController(args(), on_start=start)
await controller.handle("setup.add_target", {"target": "https://example.com"})
controller = TuiController(args_with_target(), on_start=start)
first_start = asyncio.create_task(controller.handle("setup.start", {}))
await entered.wait()

View file

@ -187,17 +187,17 @@ async def test_server_command_round_trip_over_inherited_socket() -> None:
child,
{
"version": 3,
"type": "setup.add_target",
"type": "setup.set_instruction",
"request_id": "test-1",
"payload": {"target": "example.com"},
"payload": {"instruction": "test example.com"},
},
)
result = await receive_until(child, "command_result", request_id="test-1")
assert result["payload"]["ok"] is True
assert result["payload"]["command"] == "setup.add_target"
assert result["payload"]["command"] == "setup.set_instruction"
state = await receive_until(child, "state")
assert state["payload"]["revision"] >= 1
assert state["payload"]["state"]["targets"] == ["example.com"]
assert state["payload"]["state"]["instruction"] == "test example.com"
finally:
child.close()
await server.close()
@ -308,9 +308,9 @@ async def test_invalid_version_error_is_correlated_and_next_command_succeeds() -
child,
{
"version": 2,
"type": "setup.add_target",
"type": "setup.set_instruction",
"request_id": "bad-version",
"payload": {"target": "ignored.example"},
"payload": {"instruction": "ignored"},
},
)
rejected = await receive_until(child, "command_result", request_id="bad-version")
@ -320,9 +320,9 @@ async def test_invalid_version_error_is_correlated_and_next_command_succeeds() -
child,
{
"version": 3,
"type": "setup.add_target",
"type": "setup.set_instruction",
"request_id": "after-error",
"payload": {"target": "example.com"},
"payload": {"instruction": "test example.com"},
},
)
accepted = await receive_until(child, "command_result", request_id="after-error")