mirror of
https://github.com/usestrix/strix.git
synced 2026-08-28 05:25:00 +00:00
Merge aab2439a51 into 717ffc8f4c
This commit is contained in:
commit
e4de41eb4f
40 changed files with 1494 additions and 745 deletions
|
|
@ -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
|
||||
|
|
|
|||
15
README.md
15
README.md
|
|
@ -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:**
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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**
|
||||
|
|
|
|||
|
|
@ -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`.
|
||||
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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
49
strix/core/targets.py
Normal 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
|
||||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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") {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
});
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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}]
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue