mirror of
https://github.com/usestrix/strix.git
synced 2026-08-28 05:25:00 +00:00
fix(scope): infer network targets from prompts
This commit is contained in:
parent
011ca8ff47
commit
3ed755268b
14 changed files with 548 additions and 123 deletions
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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") {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue