fix(scope): reconcile web targets as hosts

This commit is contained in:
oyasumi 2026-08-19 18:31:20 +00:00
parent 00593da239
commit 011ca8ff47
28 changed files with 706 additions and 561 deletions

View file

@ -191,12 +191,17 @@ strix view my-run-name
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`. Free-form text entered in the interactive start
screen remains the task and is not split into inferred targets.
### API Testing (OpenAPI / Swagger / Postman)
Point Strix at an API contract and it tests every declared endpoint instead of
@ -226,7 +231,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

View file

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

View file

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

View file

@ -4,6 +4,10 @@ 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.
## Inline Instructions

View file

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

View file

@ -66,7 +66,7 @@ SYSTEM-VERIFIED SCOPE:
- 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's scheme, port, path, query, or fragment may guide what to test but does not narrow its hostname 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
@ -74,7 +74,8 @@ SYSTEM-VERIFIED SCOPE:
- 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 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 }})
@ -85,6 +86,7 @@ AUTHORIZED TARGETS:
{% endif %}
{% endfor %}
{% endif %}
{% endif %}
AUTHORIZATION STATUS:
- You have FULL AUTHORIZATION for authorized security validation on in-scope targets to help secure the target systems/app

View file

@ -2,10 +2,8 @@
from __future__ import annotations
import ipaddress
import json
from typing import TYPE_CHECKING, Any
from urllib.parse import urlsplit
from agents.model_settings import ModelSettings
from openai.types.shared import Reasoning
@ -22,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:
@ -128,7 +127,7 @@ def _split_target_sections(
"Local Codebases": [],
"API Specifications": [],
}
network: dict[str, list[str]] = {"URLs": [], "IP Addresses": []}
network: dict[str, list[str]] = {"Hosts": [], "IP Addresses": []}
for target in targets:
ttype = target.get("type")
details = target.get("details") or {}
@ -148,7 +147,7 @@ def _split_target_sections(
".git/.agents/.codex are read-only)"
)
elif ttype == "web_application":
network["URLs"].append(f"- {details.get('target_url', '')}")
network["Hosts"].append(f"- {details.get('target_host', '')}")
elif ttype == "ip_address":
network["IP Addresses"].append(f"- {details.get('target_ip', '')}")
elif ttype == "api_spec":
@ -208,16 +207,9 @@ def build_root_task(scan_config: dict[str, Any]) -> str:
)
def _network_scope_target(value: str) -> tuple[str, str]:
"""Reduce a web URL to its hostname-level prompt scope."""
hostname = (urlsplit(value).hostname or "").rstrip(".").lower()
if not hostname:
return "web_application", value
try:
ipaddress.ip_address(hostname)
except ValueError:
return "web_host", hostname
return "ip_address", hostname
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]:
@ -235,7 +227,7 @@ def build_scope_context(scan_config: dict[str, Any]) -> dict[str, Any]:
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",
}
@ -248,7 +240,7 @@ 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 ""
if ttype == "web_application":
scope_type, scope_value = _network_scope_target(str(value or ""))
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)
@ -257,7 +249,7 @@ def build_scope_context(scan_config: dict[str, Any]) -> dict[str, Any]:
# so the agent can exercise every endpoint without expanding scope.
if ttype == "api_spec":
for base_url in details.get("base_urls") or []:
scope_type, scope_value = _network_scope_target(str(base_url))
scope_type, scope_value = _scope_target_from_url(str(base_url))
add_authorized(scope_type, scope_value)
return {

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

@ -0,0 +1,31 @@
"""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])?$")
def canonical_network_host(value: str) -> tuple[str, str]:
"""Return ``(web_host|ip_address, canonical value)`` for a network input."""
parsed = urlsplit(value if "://" in value else f"//{value}")
hostname = (parsed.hostname or "").rstrip(".").lower()
if not hostname:
raise ValueError(f"Network target '{value}' does not contain a valid host")
try:
return "ip_address", str(ipaddress.ip_address(hostname))
except ValueError:
pass
try:
hostname = hostname.encode("idna").decode("ascii")
except UnicodeError as exc:
raise ValueError(f"Network target '{value}' contains an invalid host") from exc
if len(hostname) > 253 or any(not _DNS_LABEL.fullmatch(label) for label in hostname.split(".")):
raise ValueError(f"Network target '{value}' contains an invalid host")
return "web_host", hostname

View file

@ -9,12 +9,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,
)
@ -60,7 +68,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
@ -81,7 +89,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
@ -123,9 +131,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",
@ -320,8 +329,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
@ -335,7 +343,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"
@ -346,10 +354,19 @@ def _load_resume_state(args: argparse.Namespace, parser: argparse.ArgumentParser
)
try:
state = read_run_record(run_dir)
except RuntimeError as exc:
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.
@ -384,6 +401,10 @@ def _load_resume_state(args: argparse.Namespace, parser: argparse.ArgumentParser
if not getattr(args, "user_instruction", None):
args.user_instruction = state.get("user_instruction") or None
args.local_sources = collect_local_sources(args.targets_info)
try:
args.local_sources.extend(restore_staged_api_specs(args.targets_info, str(args.resume)))
except ValueError as exc:
parser.error(f"--resume {args.resume}: could not restore API specification: {exc}")
# Remount the workspace the run was started with. The user already confirmed
# this directory, so the target mount guard does not apply to it; it only has
# to still be there.

View file

@ -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,10 @@ 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 _resolve_api_spec(target: str, details: dict[str, Any]) -> None:
@ -154,8 +160,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 +174,8 @@ def _resolve_api_spec(target: str, details: dict[str, Any]) -> None:
def prepare_run(args: argparse.Namespace) -> None:
"""Resolve the run name, clone repos, compute diff-scope, and persist state.
Shared by the CLI startup path and the interactive TUI setup phase (once the
user has supplied a target via ``/target``). Mutates *args* in place and
raises :class:`ValueError` on any preparation failure.
Shared by the CLI startup path and the interactive TUI setup phase. Mutates
*args* in place and raises :class:`ValueError` on any preparation failure.
"""
args.run_name = args.resume or generate_run_name(args.targets_info)

View file

@ -263,7 +263,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,
@ -279,13 +278,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", "")

View file

@ -245,7 +245,7 @@ func TestSetupUsesDedicatedStartScreen(t *testing.T) {
SetupMode: true,
ScanState: "setup",
Model: "gpt-5.4",
Targets: []string{"/workspace/source", "https://example.com"},
Targets: []string{"/workspace/source", "example.com"},
Instruction: "focus on access control",
ScanMode: "quick",
MaxBudgetUSD: floatPointer(12.5),
@ -260,7 +260,7 @@ func TestSetupUsesDedicatedStartScreen(t *testing.T) {
for _, want := range []string{
"gpt-5.4",
"/workspace/source",
"https://example.com",
"example.com",
} {
if !strings.Contains(view, want) {
t.Fatalf("start screen is missing %q: %s", want, view)
@ -342,9 +342,9 @@ func TestLeadingSlashIsPromptTextNotACommand(t *testing.T) {
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)
// The entire value remains prompt text; no token is promoted to a target.
if contains(types, "setup.add_target") || !contains(types, "setup.set_instruction") {
t.Fatalf("slash-leading prompt was not preserved as instruction text: %v", types)
}
for _, line := range result.setupLog {
if strings.Contains(ansi.Strip(line), "Unknown command") {

View file

@ -2,8 +2,6 @@ package app
import (
"fmt"
"net"
"regexp"
"strings"
"sync"
@ -26,30 +24,16 @@ 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 treats free text as the task verbatim. Target-looking words
// are not promoted into configured targets: hosts named in the task receive
// prompt-level scope, while configured targets come only from explicit flags.
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}))
}
commands := []tea.Cmd{send(m.client, "setup.set_instruction", map[string]any{"instruction": 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
verify := len(m.snapshot.Targets) > 0
payload := map[string]any{"verify": verify}
if verify {
m.setupMsg("Verifying model connection...", render.Col(amber))
@ -74,54 +58,6 @@ func (m *Model) answerMountConfirmation(approved bool) tea.Cmd {
return send(m.client, "setup.confirm_mount", map[string]any{"approved": approved})
}
func (m Model) hasTarget(candidate string) bool {
for _, target := range m.snapshot.Targets {
if target == candidate {
return true
}
}
return false
}
// looksLikeTarget reports whether a whitespace-delimited token names something
// scannable: a URL, repo, filesystem path, domain, or IP address.
func looksLikeTarget(token string) bool {
if token == "" {
return false
}
if strings.Contains(token, "://") || strings.HasSuffix(token, ".git") {
return true
}
if strings.HasPrefix(token, "/") || strings.HasPrefix(token, "./") || strings.HasPrefix(token, "~/") || strings.HasPrefix(token, "../") {
return true
}
if ip := net.ParseIP(token); ip != nil {
return true
}
host := token
if at := strings.LastIndex(host, "@"); at >= 0 {
host = host[at+1:]
}
host = strings.SplitN(host, "/", 2)[0]
host = strings.SplitN(host, ":", 2)[0]
if !domainPattern.MatchString(host) {
return false
}
tld := host[strings.LastIndex(host, ".")+1:]
return len(tld) >= 2 && !isNumeric(tld)
}
var domainPattern = regexp.MustCompile(`^([a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?\.)+[a-zA-Z0-9]{2,}$`)
func isNumeric(value string) bool {
for _, char := range value {
if char < '0' || char > '9' {
return false
}
}
return true
}
// statusVisible mirrors #agent_status_display: shown only when an agent is
// selected during a scan; hidden (display:none) in setup mode.
func (m Model) statusVisible() bool {

View file

@ -258,34 +258,39 @@ func TestMountConfirmationAnswers(t *testing.T) {
}
}
// A prompt that names a target adds it and launches.
func TestSetupPromptWithTargetLaunches(t *testing.T) {
// URLs in free-form setup text remain task text rather than becoming targets.
func TestSetupPromptWithURLsLaunchesAsInstructionOnly(t *testing.T) {
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")
prompt := "test https://example.com/search?q=x and https://example.com/blog/"
updated, cmd := model.submit(prompt)
model = updated.(Model)
envelopes := drainCommands(t, cmd, connection)
types := commandTypes(envelopes)
for _, want := range []string{"setup.add_target", "setup.set_instruction", "setup.start"} {
for _, want := range []string{"setup.set_instruction", "setup.start"} {
if !contains(types, want) {
t.Fatalf("missing %s in %v", want, types)
}
}
// 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 contains(types, "setup.add_target") {
t.Fatalf("free-form URLs were promoted into targets: %v", types)
}
if verify, found := startVerify(t, envelopes); !found || verify {
t.Fatalf("instruction-only prompt should launch with verify=false, got verify=%v found=%v", verify, found)
}
if mount, found := startPayloadFlag(t, envelopes, "mount_working_dir"); !found || !mount {
t.Fatalf("instruction-only prompt did not request mount choice: mount=%v found=%v", mount, found)
}
// 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 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 model.pendingPrompt != prompt {
t.Fatalf("prompt was not preserved verbatim: %q", model.pendingPrompt)
}
}
// The prompt's buttons are buttons: clicking Cancel has to answer the backend,

View file

@ -18,7 +18,6 @@ from strix.core.agents import AgentCoordinator
from strix.core.hooks import BudgetExceededError
from strix.core.runner import run_strix_scan
from strix.interface.scan_setup import (
build_targets_info,
preflight_model_connection,
prepare_run,
telemetry_start,
@ -115,12 +114,6 @@ 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
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 +127,6 @@ class GoTuiRuntime:
# A confirmed target-less launch mounts the working directory for the
# agent to work in, without making it a scan target.
candidate.workspace_mount = self.controller.workspace_mount
if targets_changed:
# Rebuild the full typed set so path canonicalization and local
# deduplication match the CLI.
candidate.target = list(self.controller.targets)
candidate.target_list = []
build_targets_info(candidate)
prepare_run(candidate)
telemetry_start(candidate)

View file

@ -14,7 +14,6 @@ from typing import Any
from urllib.parse import parse_qs, urlparse
import docker
import requests
from docker.errors import DockerException, ImageNotFound
from rich.console import Console
from rich.panel import Panel
@ -22,6 +21,8 @@ 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
@ -34,7 +35,9 @@ def build_target_summary_text(targets_info: list[dict[str, Any]]) -> Text:
target_text = Text()
target_text.append("Target", style="dim")
target_text.append(" ")
if len(labels) == 1:
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")
@ -497,12 +500,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)
@ -1137,15 +1135,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
@ -1155,10 +1169,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":
@ -1180,16 +1198,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)
@ -1215,26 +1226,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>], "
@ -1457,17 +1467,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
@ -1491,26 +1550,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
@ -1518,6 +1574,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.
@ -1543,7 +1613,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()
@ -1560,13 +1630,26 @@ def stage_api_specs(targets_info: list[dict[str, Any]], run_name: str) -> list[d
shutil.copy2(source, staging / name)
details["workspace_path"] = f"/workspace/{API_SPEC_WORKSPACE_SUBDIR}/{name}"
return [
{
"source_path": str(staging),
"workspace_subdir": API_SPEC_WORKSPACE_SUBDIR,
"protect_metadata": False,
}
]
return _api_spec_source(staging)
def restore_staged_api_specs(
targets_info: list[dict[str, Any]], run_name: str
) -> list[dict[str, Any]]:
"""Restore only API specs previously staged inside this run directory."""
specs = [target for target in targets_info if target.get("type") == "api_spec"]
if not specs:
return []
staging = _api_spec_staging_dir(run_name)
for target in specs:
workspace_path = str((target.get("details") or {}).get("workspace_path") or "")
prefix = f"/workspace/{API_SPEC_WORKSPACE_SUBDIR}/"
if not workspace_path.startswith(prefix):
raise ValueError("persisted API specification has an invalid workspace path")
name = workspace_path.removeprefix(prefix)
if not name or "/" in name or not (staging / name).is_file():
raise ValueError(f"staged API specification '{name or 'unknown'}' is missing")
return _api_spec_source(staging)
def clone_repository(repo_url: str, run_name: str, dest_name: str | None = None) -> str:

View file

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

View file

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

View file

@ -104,7 +104,10 @@ def test_build_targets_info_rejects_unparseable_spec(tmp_path: Path) -> None:
build_targets_info(args)
def test_stage_api_specs_copies_spec_into_workspace_dir(tmp_path: Path) -> None:
def test_stage_api_specs_copies_spec_into_workspace_dir(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.chdir(tmp_path)
targets = _resolved_targets(_write_spec(tmp_path / "src"))
(source,) = stage_api_specs(targets, "stage-run")
@ -114,7 +117,10 @@ def test_stage_api_specs_copies_spec_into_workspace_dir(tmp_path: Path) -> None:
assert targets[0]["details"]["workspace_path"] == "/workspace/api-specs/openapi.json"
def test_stage_api_specs_disambiguates_same_filename(tmp_path: Path) -> None:
def test_stage_api_specs_disambiguates_same_filename(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.chdir(tmp_path)
targets = _resolved_targets(
_write_spec(tmp_path / "a"),
_write_spec(tmp_path / "b"),

View file

@ -10,6 +10,8 @@ from typing import TYPE_CHECKING, Any
import pytest
from strix.interface.utils import stage_api_specs
if TYPE_CHECKING:
from pathlib import Path
@ -40,8 +42,8 @@ def test_parse_arguments_accepts_target_list_file(
args = cli_main.parse_arguments()
assert [target["original"] for target in args.targets_info] == [
"https://test1.com/",
"http://test2.com:5789/",
"test1.com",
"test2.com",
]
assert [target["type"] for target in args.targets_info] == [
"web_application",
@ -64,8 +66,64 @@ def test_parse_arguments_combines_target_and_target_list(
args = cli_main.parse_arguments()
assert [target["original"] for target in args.targets_info] == [
"https://test1.com/",
"http://test2.com:5789/",
"test1.com",
"test2.com",
]
def test_parse_arguments_collapses_endpoint_targets_by_host(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
target_list = tmp_path / "targets.txt"
target_list.write_text("https://EXAMPLE.com/blog/\n", encoding="utf-8")
_stub_settings(monkeypatch)
monkeypatch.setattr(
sys,
"argv",
[
"strix",
"-t",
"https://example.com/search?q=test",
"--target-list",
str(target_list),
],
)
args = cli_main.parse_arguments()
assert args.targets_info == [
{
"type": "web_application",
"details": {"target_host": "example.com"},
"original": "example.com",
}
]
def test_parse_arguments_collapses_loopback_aliases_to_runtime_host(
monkeypatch: pytest.MonkeyPatch,
) -> None:
_stub_settings(monkeypatch)
monkeypatch.setattr(
sys,
"argv",
[
"strix",
"-t",
"http://127.0.0.1/api",
"-t",
"http://localhost/admin",
],
)
args = cli_main.parse_arguments()
assert args.targets_info == [
{
"type": "web_application",
"details": {"target_host": "host.docker.internal"},
"original": "host.docker.internal",
}
]
@ -227,3 +285,180 @@ def test_resume_still_requires_targets_or_a_workspace(
cli_main.parse_arguments()
assert "has no targets_info" in capsys.readouterr().err
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,
}
]

View file

@ -298,9 +298,15 @@ async def test_setup_preflights_model_before_starting(
) -> None:
runtime_args = args()
runtime_args.instruction = "CLI instruction"
runtime_args.targets_info = [
{
"type": "web_application",
"details": {"target_host": "example.com"},
"original": "example.com",
}
]
runtime = GoTuiRuntime(runtime_args)
assert runtime.controller.instruction == "CLI instruction"
runtime.controller.targets = ["https://example.com", "/workspace/mounted"]
runtime.controller.scan_mode = "quick"
runtime.controller.instruction = ""
runtime.controller.max_budget_usd = 8.5
@ -320,22 +326,6 @@ async def test_setup_preflights_model_before_starting(
)
monkeypatch.setattr(go_tui, "preflight_model_connection", preflight)
def build(candidate: argparse.Namespace, **_: object) -> None:
calls.append("targets")
assert candidate.target == ["https://example.com", "/workspace/mounted"]
candidate.targets_info = [
{
"type": "web",
"details": {"target_url": "https://example.com"},
"original": "https://example.com",
},
{
"type": "local_code",
"details": {"target_path": "/workspace/mounted"},
"original": "/workspace/mounted",
},
]
def prepare(candidate: argparse.Namespace) -> None:
calls.append("prepare")
assert candidate.max_budget_usd == 8.5
@ -343,7 +333,6 @@ async def test_setup_preflights_model_before_starting(
assert candidate.scope_mode == "diff"
assert candidate.diff_base == "origin/main"
monkeypatch.setattr(go_tui, "build_targets_info", build)
monkeypatch.setattr(go_tui, "prepare_run", prepare)
monkeypatch.setattr(go_tui, "telemetry_start", lambda _args: calls.append("telemetry"))
monkeypatch.setattr(runtime, "init_run_state", lambda: calls.append("state"))
@ -351,7 +340,7 @@ async def test_setup_preflights_model_before_starting(
await runtime.start_from_setup()
assert calls == ["preflight", "targets", "prepare", "telemetry", "state", "scan"]
assert calls == ["preflight", "prepare", "telemetry", "state", "scan"]
assert runtime.args.scan_mode == "quick"
assert runtime.args.instruction == ""
assert runtime.args.max_budget_usd == 8.5
@ -365,7 +354,6 @@ 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 +365,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 +375,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 +396,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 +416,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 +433,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 +440,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 +456,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 +473,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 +531,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 +547,6 @@ async def test_setup_preflight_failure_does_not_start_scan(
lambda: SimpleNamespace(llm=SimpleNamespace(model="openrouter/test-model")),
)
monkeypatch.setattr(go_tui, "preflight_model_connection", preflight)
monkeypatch.setattr(go_tui, "build_targets_info", mark_started)
monkeypatch.setattr(runtime, "init_run_state", mark_started)
monkeypatch.setattr(runtime, "start_scan", mark_started)

View file

@ -200,14 +200,15 @@ 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 task.startswith("Focus on auth.")
assert "https://app.example.com" in task
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
@ -228,7 +229,7 @@ def test_build_root_task_workspace_mount_is_not_a_target() -> None:
assert "No scan target was set" 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
@ -240,6 +241,7 @@ 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:
@ -247,14 +249,14 @@ def test_scope_prompt_authorizes_flag_and_instruction_hosts_with_subdomains() ->
"targets": [
{
"type": "web_application",
"details": {"target_url": "https://app.example.com/search?q=test"},
},
{
"type": "web_application",
"details": {"target_url": "https://app.example.com/blog/"},
},
"details": {"target_host": "app.example.com"},
"original": "app.example.com",
}
],
"user_instructions": "Also test https://api.example.net/v1.",
"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)
@ -269,6 +271,7 @@ def test_scope_prompt_authorizes_flag_and_instruction_hosts_with_subdomains() ->
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
@ -288,8 +291,8 @@ def test_scope_prompt_keeps_web_ip_targets_exact() -> None:
{
"targets": [
{
"type": "web_application",
"details": {"target_url": "https://192.0.2.10:8443/admin"},
"type": "ip_address",
"details": {"target_ip": "192.0.2.10"},
}
]
}

View file

@ -12,7 +12,7 @@ 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 +181,77 @@ 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"},
)
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 +308,22 @@ def test_read_target_list_file_rejects_empty_path(empty: str) -> None:
def test_dedupe_keeps_distinct_targets_in_order() -> None:
targets = [
_local_target("/a"),
{"type": "web_application", "details": {"target_url": "https://x"}},
{"type": "web_application", "details": {"target_host": "x.example"}},
_local_target("/b"),
]
assert dedupe_local_targets(targets) == targets
assert dedupe_targets(targets) == targets
def test_dedupe_collapses_the_same_path() -> None:
assert dedupe_local_targets([_local_target("/repo"), _local_target("/repo")]) == [
assert dedupe_targets([_local_target("/repo"), _local_target("/repo")]) == [
_local_target("/repo")
]
def test_dedupe_collapses_the_same_web_host() -> None:
target = {
"type": "web_application",
"details": {"target_host": "example.com"},
"original": "example.com",
}
assert dedupe_targets([target, target.copy()]) == [target]

View file

@ -127,7 +127,7 @@ 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 "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

View file

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

View file

@ -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"
@ -82,19 +93,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
@ -113,13 +120,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
@ -130,8 +137,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
@ -147,8 +153,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", {})
@ -305,8 +310,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", {})
@ -326,8 +330,7 @@ async def test_start_rejects_concurrent_and_repeated_submissions() -> None:
os.environ["STRIX_LLM"] = "anthropic/claude-sonnet-4"
os.environ["ANTHROPIC_API_KEY"] = "test-key"
loader._cached = None
controller = TuiController(args(), on_start=start)
await controller.handle("setup.add_target", {"target": "https://example.com"})
controller = TuiController(args_with_target(), on_start=start)
first_start = asyncio.create_task(controller.handle("setup.start", {}))
await entered.wait()

View file

@ -187,17 +187,17 @@ async def test_server_command_round_trip_over_inherited_socket() -> None:
child,
{
"version": 3,
"type": "setup.add_target",
"type": "setup.set_instruction",
"request_id": "test-1",
"payload": {"target": "example.com"},
"payload": {"instruction": "test example.com"},
},
)
result = await receive_until(child, "command_result", request_id="test-1")
assert result["payload"]["ok"] is True
assert result["payload"]["command"] == "setup.add_target"
assert result["payload"]["command"] == "setup.set_instruction"
state = await receive_until(child, "state")
assert state["payload"]["revision"] >= 1
assert state["payload"]["state"]["targets"] == ["example.com"]
assert state["payload"]["state"]["instruction"] == "test example.com"
finally:
child.close()
await server.close()
@ -284,9 +284,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")
@ -296,9 +296,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")