fix(scope): infer network targets from prompts

This commit is contained in:
oyasumi 2026-08-19 20:01:21 +00:00
parent 011ca8ff47
commit 3ed755268b
14 changed files with 548 additions and 123 deletions

View file

@ -199,8 +199,10 @@ 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.
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)

View file

@ -14,7 +14,7 @@ strix [(--target <target> | --target-list <path>)] [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 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.
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.

View file

@ -7,7 +7,9 @@ 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.
scope. On the interactive start screen, those network references are also
recorded as canonical targets; each distinct hostname independently authorizes
that hostname and its descendant subdomains.
## Inline Instructions

View file

@ -8,12 +8,30 @@ 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."""
parsed = urlsplit(value if "://" in value else f"//{value}")
hostname = (parsed.hostname or "").rstrip(".").lower()
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")

View file

@ -139,6 +139,23 @@ def build_targets_info(args: argparse.Namespace) -> None:
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:
"""Read the spec up front so bad input fails before the run starts.

View file

@ -7,12 +7,14 @@ import contextlib
import math
import webbrowser
from collections.abc import Awaitable, Callable
from copy import deepcopy
from pathlib import Path
from typing import TYPE_CHECKING, Any
from typing import TYPE_CHECKING, Any, cast
from strix.config import load_settings
from strix.config.models import is_recommended_or_frontier_model
from strix.config.settings import DEFAULT_MAX_TURNS
from strix.interface.scan_setup import build_prompt_targets_info
from strix.interface.tui.backend.live_view import TuiLiveView
from strix.interface.tui.backend.projection import (
MAX_TERMINAL_EVENTS,
@ -24,7 +26,7 @@ from strix.interface.tui.backend.projection import (
sanitize_terminal_text,
terminal_projection,
)
from strix.interface.utils import is_subscription_run
from strix.interface.utils import dedupe_targets, is_subscription_run
if TYPE_CHECKING:
@ -63,10 +65,11 @@ 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 = [
self.targets_info = deepcopy(cast("list[dict[str, Any]]", args.targets_info))
self.targets: list[str] = [
str(target["original"])
for target in args.targets_info
if isinstance(target, dict) and target.get("original")
for target in self.targets_info
if target.get("original")
]
instruction = args.instruction
self.instruction = instruction.strip() if isinstance(instruction, str) else ""
@ -289,12 +292,32 @@ 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)
@ -305,9 +328,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.
@ -317,7 +343,19 @@ class TuiController:
self.scan_started = True
self.scan_state = "preparing"
return {"started": True}
await self._begin_scan(verify)
previous: tuple[str, list[dict[str, Any]], list[str]] = (
self.instruction,
self.targets_info,
self.targets,
)
self.instruction = instruction.strip()
self.targets_info = targets_info
self.targets = targets
try:
await self._begin_scan(verify)
except BaseException:
self.instruction, self.targets_info, self.targets = previous
raise
return {"started": True}
async def _begin_scan(self, verify: bool) -> None:

View file

@ -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 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)
payload := decodeSetupStart(t, drainCommands(t, cmd, connection))
// The entire value remains prompt text; the path is not promoted to a target.
if payload.Instruction != "/etc/passwd is world readable, check it" || len(payload.Targets) != 0 {
t.Fatalf("slash-leading prompt was not preserved as instruction text: %#v", payload)
}
for _, line := range result.setupLog {
if strings.Contains(ansi.Strip(line), "Unknown command") {

View file

@ -2,6 +2,8 @@ package app
import (
"fmt"
"net"
"net/url"
"strings"
"sync"
@ -24,29 +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 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.
// 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) {
commands := []tea.Cmd{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 := 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

View file

@ -12,27 +12,6 @@ import (
"github.com/usestrix/strix/tui/internal/protocol"
)
// lastIndex returns the index of the last command of the given type, or -1.
func lastIndex(types []string, want string) int {
last := -1
for i, value := range types {
if value == want {
last = i
}
}
return last
}
// firstIndex returns the index of the first command of the given type, or -1.
func firstIndex(types []string, want string) int {
for i, value := range types {
if value == want {
return i
}
}
return -1
}
// drainCommands runs a (possibly batched) command and decodes every protocol
// frame the sends wrote to the connection, in order.
func drainCommands(t *testing.T, cmd tea.Cmd, connection *recordingConn) []protocol.Envelope {
@ -94,23 +73,23 @@ func commandTypes(envelopes []protocol.Envelope) []string {
return types
}
// startVerify returns the verify flag on the setup.start command, and whether
// a setup.start command was present at all.
func startVerify(t *testing.T, envelopes []protocol.Envelope) (verify, found bool) {
type setupStartPayload struct {
Instruction string `json:"instruction"`
Targets []string `json:"targets"`
Verify bool `json:"verify"`
MountWorkingDir *bool `json:"mount_working_dir"`
}
func decodeSetupStart(t *testing.T, envelopes []protocol.Envelope) setupStartPayload {
t.Helper()
for _, envelope := range envelopes {
if envelope.Type != "setup.start" {
continue
}
var payload struct {
Verify bool `json:"verify"`
}
if err := json.Unmarshal(envelope.Payload, &payload); err != nil {
t.Fatal(err)
}
return payload.Verify, true
if len(envelopes) != 1 || envelopes[0].Type != "setup.start" {
t.Fatalf("expected one setup.start command, got %v", commandTypes(envelopes))
}
return false, false
var payload setupStartPayload
if err := json.Unmarshal(envelopes[0].Payload, &payload); err != nil {
t.Fatal(err)
}
return payload
}
func contains(values []string, want string) bool {
@ -122,23 +101,6 @@ func contains(values []string, want string) bool {
return false
}
// startPayloadFlag reports a boolean field on the setup.start command.
func startPayloadFlag(t *testing.T, envelopes []protocol.Envelope, field string) (value, found bool) {
t.Helper()
for _, envelope := range envelopes {
if envelope.Type != "setup.start" {
continue
}
var payload map[string]any
if err := json.Unmarshal(envelope.Payload, &payload); err != nil {
t.Fatal(err)
}
flag, ok := payload[field].(bool)
return flag, ok
}
return false, false
}
// A bare prompt launches straight away, asking to mount the working directory
// rather than adding it as a target. The prompt is held in case it is declined.
func TestSetupPromptWithoutTargetLaunchesAndRequestsMount(t *testing.T) {
@ -149,24 +111,20 @@ func TestSetupPromptWithoutTargetLaunchesAndRequestsMount(t *testing.T) {
updated, cmd := model.submit("find auth bugs in the login flow")
model = updated.(Model)
envelopes := drainCommands(t, cmd, connection)
types := commandTypes(envelopes)
payload := decodeSetupStart(t, envelopes)
if !contains(types, "setup.set_instruction") || !contains(types, "setup.start") {
t.Fatalf("bare prompt did not launch: %v", types)
if payload.Instruction != "find auth bugs in the login flow" {
t.Fatalf("instruction was not preserved: %q", payload.Instruction)
}
if contains(types, "setup.add_target") {
t.Fatalf("the working directory must not be added as a target: %v", types)
if payload.Targets == nil || len(payload.Targets) != 0 {
t.Fatalf("targetless prompt sent targets: %#v", payload.Targets)
}
if mount, found := startPayloadFlag(t, envelopes, "mount_working_dir"); !found || !mount {
t.Fatalf("mount was not requested: mount_working_dir=%v found=%v", mount, found)
if payload.MountWorkingDir == nil || !*payload.MountWorkingDir {
t.Fatalf("mount was not requested: %#v", payload.MountWorkingDir)
}
// A bare prompt launches optimistically: no model preflight.
if verify, found := startVerify(t, envelopes); !found || verify {
t.Fatalf("bare prompt should launch with verify=false, got verify=%v found=%v", verify, found)
}
// setup.start leaves setup mode, so it must be the last command sent.
if start, instr := firstIndex(types, "setup.start"), lastIndex(types, "setup.set_instruction"); start < instr {
t.Fatalf("setup.start (%d) must come after setup.set_instruction (%d): %v", start, instr, types)
if payload.Verify {
t.Fatal("bare prompt should launch with verify=false")
}
if model.pendingPrompt != "find auth bugs in the login flow" {
t.Fatalf("prompt was not held in case the mount is declined: %q", model.pendingPrompt)
@ -258,38 +216,122 @@ func TestMountConfirmationAnswers(t *testing.T) {
}
}
// URLs in free-form setup text remain task text rather than becoming targets.
func TestSetupPromptWithURLsLaunchesAsInstructionOnly(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}
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.set_instruction", "setup.start"} {
if !contains(types, want) {
t.Fatalf("missing %s in %v", want, types)
}
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 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)
}
start := firstIndex(types, "setup.start")
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.Verify || payload.MountWorkingDir == nil || !*payload.MountWorkingDir {
t.Fatalf("targetless launch flags = %#v", payload)
}
if model.pendingPrompt != prompt {
t.Fatalf("prompt was not preserved verbatim: %q", model.pendingPrompt)
t.Fatalf("prompt was not held for mount confirmation: %q", model.pendingPrompt)
}
}
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)
}
if !reflect.DeepEqual(payload.Targets, want) {
t.Fatalf("targets = %#v, want %#v", payload.Targets, want)
}
if !payload.Verify {
t.Fatal("targeted prompt should launch with verify=true")
}
if payload.MountWorkingDir != nil {
t.Fatalf("targeted prompt included mount_working_dir=%v", *payload.MountWorkingDir)
}
if model.pendingPrompt != "" {
t.Fatalf("targeted prompt was held for a mount: %q", model.pendingPrompt)
}
}

View file

@ -114,6 +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
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

View file

@ -349,6 +349,45 @@ 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,

View file

@ -286,6 +286,31 @@ def test_scope_prompt_authorizes_flag_and_instruction_hosts_with_subdomains() ->
)
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(
{

View file

@ -8,6 +8,7 @@ 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,
@ -202,6 +203,35 @@ def test_infer_web_ip_target_becomes_exact_ip() -> None:
)
@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})

View file

@ -161,6 +161,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