From 7b41840e4d2fff84a36b7edf30484a7cfe4d4bec Mon Sep 17 00:00:00 2001 From: oyasumi Date: Wed, 19 Aug 2026 00:44:25 +0000 Subject: [PATCH] fix(scope): render configured URLs as hosts --- strix/agents/prompts/system_prompt.jinja | 6 ++++ strix/core/inputs.py | 39 +++++++++++++++++++----- tests/test_api_spec_targets.py | 4 +-- tests/test_inputs.py | 35 +++++++++++++++++++-- tests/test_runner_root_prompt.py | 6 ++-- 5 files changed, 76 insertions(+), 14 deletions(-) diff --git a/strix/agents/prompts/system_prompt.jinja b/strix/agents/prompts/system_prompt.jinja index cf463ca5..36800a8f 100644 --- a/strix/agents/prompts/system_prompt.jinja +++ b/strix/agents/prompts/system_prompt.jinja @@ -76,7 +76,13 @@ SYSTEM-VERIFIED SCOPE: AUTHORIZED TARGETS: {% for target in system_prompt_context.authorized_targets %} +{% if target.type == "web_host" %} +- web_host: {{ target.value }} (includes {{ target.value }} and *.{{ target.value }}) +{% elif target.type == "ip_address" %} +- ip_address: {{ target.value }} (exact address) +{% else %} - {{ target.type }}: {{ target.value }}{% if target.workspace_path %} (workspace: {{ target.workspace_path }}){% endif %} +{% endif %} {% endfor %} {% endif %} diff --git a/strix/core/inputs.py b/strix/core/inputs.py index c73e6cd9..3206350e 100644 --- a/strix/core/inputs.py +++ b/strix/core/inputs.py @@ -2,8 +2,10 @@ from __future__ import annotations +import ipaddress import json from typing import TYPE_CHECKING, Any +from urllib.parse import urlsplit from agents.model_settings import ModelSettings from openai.types.shared import Reasoning @@ -206,8 +208,30 @@ def build_root_task(scan_config: dict[str, Any]) -> str: ) +def _network_scope_target(value: str) -> tuple[str, str]: + """Reduce a web URL to its hostname-level prompt scope.""" + hostname = (urlsplit(value).hostname or "").rstrip(".").lower() + if not hostname: + return "web_application", value + try: + ipaddress.ip_address(hostname) + except ValueError: + return "web_host", hostname + return "ip_address", hostname + + def build_scope_context(scan_config: dict[str, Any]) -> dict[str, Any]: authorized: list[dict[str, str]] = [] + authorized_keys: set[tuple[str, str, str]] = set() + + def add_authorized(ttype: str, value: str, workspace_path: str = "") -> None: + key = (ttype, value, workspace_path) + if key not in authorized_keys: + authorized.append( + {"type": ttype, "value": value, "workspace_path": workspace_path}, + ) + authorized_keys.add(key) + value_keys = { "repository": "target_repo", "local_code": "target_path", @@ -223,17 +247,18 @@ def build_scope_context(scan_config: dict[str, Any]) -> dict[str, Any]: workspace_subdir = details.get("workspace_subdir") workspace_path = f"/workspace/{workspace_subdir}" if workspace_subdir else "" - authorized.append( - {"type": ttype, "value": value, "workspace_path": workspace_path}, - ) + if ttype == "web_application": + scope_type, scope_value = _network_scope_target(str(value or "")) + add_authorized(scope_type, scope_value) + else: + add_authorized(str(ttype), str(value or ""), workspace_path) # An API spec authorizes the hosts it declares as in-scope web targets # so the agent can exercise every endpoint without expanding scope. if ttype == "api_spec": - authorized.extend( - {"type": "web_application", "value": base_url, "workspace_path": ""} - for base_url in details.get("base_urls") or [] - ) + for base_url in details.get("base_urls") or []: + scope_type, scope_value = _network_scope_target(str(base_url)) + add_authorized(scope_type, scope_value) return { "scope_source": "system_scan_config", diff --git a/tests/test_api_spec_targets.py b/tests/test_api_spec_targets.py index bba0aba5..56a9cfd9 100644 --- a/tests/test_api_spec_targets.py +++ b/tests/test_api_spec_targets.py @@ -148,5 +148,5 @@ def test_build_scope_context_authorizes_base_urls(tmp_path: Path) -> None: types = {a["type"] for a in authorized} assert "api_spec" in types - assert "web_application" in types - assert any(a["value"] == "https://api.shop.test/v1" for a in authorized) + assert "web_host" in types + assert any(a["value"] == "api.shop.test" for a in authorized) diff --git a/tests/test_inputs.py b/tests/test_inputs.py index 5cb97f64..aa43be1a 100644 --- a/tests/test_inputs.py +++ b/tests/test_inputs.py @@ -246,7 +246,11 @@ def test_scope_prompt_authorizes_flag_and_instruction_hosts_with_subdomains() -> { "type": "web_application", "details": {"target_url": "https://app.example.com/search?q=test"}, - } + }, + { + "type": "web_application", + "details": {"target_url": "https://app.example.com/blog/"}, + }, ], "user_instructions": "Also test https://api.example.net/v1.", } @@ -256,7 +260,13 @@ def test_scope_prompt_authorizes_flag_and_instruction_hosts_with_subdomains() -> task = build_root_task(config) assert "SYSTEM-VERIFIED SCOPE" in prompt - assert "https://app.example.com/search?q=test" in prompt + assert context["authorized_targets"] == [ + {"type": "web_host", "value": "app.example.com", "workspace_path": ""} + ] + assert "web_host: app.example.com (includes app.example.com and *.app.example.com)" in prompt + assert prompt.count("web_host: app.example.com") == 1 + assert "https://app.example.com/search?q=test" not in prompt + assert "https://app.example.com/search?q=test" in task assert "https://api.example.net/v1" in task assert "Every network host explicitly named in the user's root scan task" in prompt assert "exact hostname and all of its descendant subdomains" in prompt @@ -264,6 +274,27 @@ def test_scope_prompt_authorizes_flag_and_instruction_hosts_with_subdomains() -> assert "not `example.com`, sibling hosts such as `api.example.com`" in prompt +def test_scope_prompt_keeps_web_ip_targets_exact() -> None: + context = build_scope_context( + { + "targets": [ + { + "type": "web_application", + "details": {"target_url": "https://192.0.2.10:8443/admin"}, + } + ] + } + ) + + prompt = render_system_prompt(scan_mode="quick", is_root=True, system_prompt_context=context) + + assert context["authorized_targets"] == [ + {"type": "ip_address", "value": "192.0.2.10", "workspace_path": ""} + ] + assert "ip_address: 192.0.2.10 (exact address)" in prompt + assert "https://192.0.2.10:8443/admin" not in prompt + + def test_scope_prompt_does_not_make_repository_origin_a_live_target() -> None: context = build_scope_context( { diff --git a/tests/test_runner_root_prompt.py b/tests/test_runner_root_prompt.py index 1b3d83ab..ba59ca7d 100644 --- a/tests/test_runner_root_prompt.py +++ b/tests/test_runner_root_prompt.py @@ -106,8 +106,8 @@ async def test_root_prompt_options_flow_into_root_agent( "authorization_source": "strix_platform_verified_targets", "authorized_targets": [ { - "type": "web_application", - "value": "https://example.com", + "type": "web_host", + "value": "example.com", "workspace_path": "", }, ], @@ -128,7 +128,7 @@ async def test_root_prompt_options_flow_into_root_agent( instructions_override = kwargs["instructions_override"] assert "SYSTEM-VERIFIED SCOPE" in instructions_override assert "AUTHORIZED TARGETS" in instructions_override - assert "https://example.com" in instructions_override + assert "web_host: example.com (includes example.com and *.example.com)" in instructions_override assert "exact hostname and all of its descendant subdomains" in instructions_override assert "CUSTOM SCAN PROMPT" in instructions_override assert "Network hosts explicitly named in these root scan instructions" in instructions_override