From 3ed755268b4523a5b194eedde3d2aac53ae0fb4e Mon Sep 17 00:00:00 2001 From: oyasumi Date: Wed, 19 Aug 2026 20:01:21 +0000 Subject: [PATCH] fix(scope): infer network targets from prompts --- README.md | 6 +- docs/usage/cli.mdx | 2 +- docs/usage/instructions.mdx | 4 +- strix/core/targets.py | 22 +- strix/interface/scan_setup.py | 17 ++ strix/interface/tui/backend/controller.py | 56 ++++- .../interface/tui/internal/app/model_test.go | 11 +- strix/interface/tui/internal/app/setup.go | 127 +++++++++- .../tui/internal/app/setup_prompt_test.go | 220 +++++++++++------- strix/interface/tui/runtime.py | 1 + tests/test_go_tui_runtime.py | 39 ++++ tests/test_inputs.py | 25 ++ tests/test_local_sources.py | 30 +++ tests/test_tui_backend_controller.py | 111 +++++++++ 14 files changed, 548 insertions(+), 123 deletions(-) diff --git a/README.md b/README.md index 2ab80a1f..a8629a1d 100644 --- a/README.md +++ b/README.md @@ -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) diff --git a/docs/usage/cli.mdx b/docs/usage/cli.mdx index eb2c5339..eff39c50 100644 --- a/docs/usage/cli.mdx +++ b/docs/usage/cli.mdx @@ -14,7 +14,7 @@ strix [(--target | --target-list )] [options] 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://`). 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. diff --git a/docs/usage/instructions.mdx b/docs/usage/instructions.mdx index a82da012..0d2d3299 100644 --- a/docs/usage/instructions.mdx +++ b/docs/usage/instructions.mdx @@ -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 diff --git a/strix/core/targets.py b/strix/core/targets.py index 09383aa6..8f27d6b1 100644 --- a/strix/core/targets.py +++ b/strix/core/targets.py @@ -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") diff --git a/strix/interface/scan_setup.py b/strix/interface/scan_setup.py index b655c2a2..3f0210d2 100644 --- a/strix/interface/scan_setup.py +++ b/strix/interface/scan_setup.py @@ -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. diff --git a/strix/interface/tui/backend/controller.py b/strix/interface/tui/backend/controller.py index ef983b06..4494f491 100644 --- a/strix/interface/tui/backend/controller.py +++ b/strix/interface/tui/backend/controller.py @@ -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: diff --git a/strix/interface/tui/internal/app/model_test.go b/strix/interface/tui/internal/app/model_test.go index 61c74755..7d08e5b7 100644 --- a/strix/interface/tui/internal/app/model_test.go +++ b/strix/interface/tui/internal/app/model_test.go @@ -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") { diff --git a/strix/interface/tui/internal/app/setup.go b/strix/interface/tui/internal/app/setup.go index 00830c76..e097d033 100644 --- a/strix/interface/tui/internal/app/setup.go +++ b/strix/interface/tui/internal/app/setup.go @@ -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 diff --git a/strix/interface/tui/internal/app/setup_prompt_test.go b/strix/interface/tui/internal/app/setup_prompt_test.go index d88e204e..93bacecd 100644 --- a/strix/interface/tui/internal/app/setup_prompt_test.go +++ b/strix/interface/tui/internal/app/setup_prompt_test.go @@ -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) } } diff --git a/strix/interface/tui/runtime.py b/strix/interface/tui/runtime.py index 427b7275..21f2c735 100644 --- a/strix/interface/tui/runtime.py +++ b/strix/interface/tui/runtime.py @@ -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 diff --git a/tests/test_go_tui_runtime.py b/tests/test_go_tui_runtime.py index d9d482a2..6cde4efa 100644 --- a/tests/test_go_tui_runtime.py +++ b/tests/test_go_tui_runtime.py @@ -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, diff --git a/tests/test_inputs.py b/tests/test_inputs.py index d26ad0bb..c44d260f 100644 --- a/tests/test_inputs.py +++ b/tests/test_inputs.py @@ -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( { diff --git a/tests/test_local_sources.py b/tests/test_local_sources.py index 01c19840..9b27ec1d 100644 --- a/tests/test_local_sources.py +++ b/tests/test_local_sources.py @@ -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}) diff --git a/tests/test_tui_backend_controller.py b/tests/test_tui_backend_controller.py index b1ee919d..47b33317 100644 --- a/tests/test_tui_backend_controller.py +++ b/tests/test_tui_backend_controller.py @@ -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