Compare commits

..
34 changed files with 507 additions and 1271 deletions
-3
View File
@@ -24,7 +24,6 @@ RUN apt-get update && \
python3 python3-pip python3-dev python3-venv python3-setuptools \
golang-go \
net-tools dnsutils whois \
file xxd \
jq parallel ripgrep grep \
less man-db procps htop \
iproute2 iputils-ping netcat-traditional \
@@ -193,8 +192,6 @@ RUN mkdir -p /workspace && chown -R pentester:pentester /workspace /app
USER pentester
RUN python3 -m venv /app/.venv && \
/app/.venv/bin/pip install --no-cache-dir caido-sdk-client && \
/app/.venv/bin/pip install --no-cache-dir \
requests httpx beautifulsoup4 lxml pyjwt cryptography && \
/app/.venv/bin/pip install --no-cache-dir -r /home/pentester/tools/jwt_tool/requirements.txt && \
printf '%s\n' \
'#!/bin/bash' \
+3 -6
View File
@@ -91,13 +91,10 @@ http_proxy=http://127.0.0.1:${CAIDO_PORT}
https_proxy=http://127.0.0.1:${CAIDO_PORT}
EOF
# Use POSIX `.` (not the bashism `source`) so these lines are safe when the rc
# files are read by a POSIX shell (e.g. `sh -lc`), which otherwise fails with
# "source: not found". `.` is understood by bash, zsh, and dash alike.
echo ". /etc/profile.d/proxy.sh" >> ~/.bashrc
echo ". /etc/profile.d/proxy.sh" >> ~/.zshrc
echo "source /etc/profile.d/proxy.sh" >> ~/.bashrc
echo "source /etc/profile.d/proxy.sh" >> ~/.zshrc
. /etc/profile.d/proxy.sh
source /etc/profile.d/proxy.sh
echo "✅ System-wide proxy configuration complete"
-8
View File
@@ -81,14 +81,6 @@ Protocol-specific testing techniques.
| --------- | ------------------------------------------------ |
| `graphql` | GraphQL introspection, batching, resolver issues |
### Reconnaissance
Passive discovery and attack-surface mapping techniques.
| Skill | Coverage |
| ----------------- | --------------------------------------------------------------- |
| `asset_discovery` | CT, TLS SAN pivoting, passive DNS, and ASN/IP asset enumeration |
### Tooling
Sandbox CLI playbooks for core recon and scanning tools.
+5 -27
View File
@@ -168,24 +168,9 @@ EFFICIENCY TACTICS:
- Download additional tools as needed for specific tasks
- Run multiple scans in parallel when possible
- Load the most relevant skill before starting a specialized testing workflow if doing so will improve accuracy, speed, or tool usage
- Use `exec_command` for Python code: write reusable scripts to a file and
run them with `python3 script.py`. For one-off snippets, `python3 -c` or a
here-document is acceptable, but avoid deeply nested quotes/parentheses — if
a snippet needs complex quoting or is more than a few lines, write it to a
file first to prevent syntax errors.
- Before importing a third-party Python library, make sure it is installed. The
sandbox's `python3` runs inside a preconfigured virtualenv that ships
`requests`, `httpx`, `beautifulsoup4` (bs4), `lxml`, `pyjwt`, and
`cryptography`; for anything else prefer the stdlib or run `pip install <pkg>`
(it installs into that active venv) before importing, rather than letting the
script fail with `ModuleNotFoundError`.
- `exec_command` runs each command in a fresh non-interactive shell (plain
pipes, no TTY). To drive an interactive or long-running process with
`write_stdin` — REPLs, `ssh`/`nc`/`ftp`, `msfconsole`, or to send Ctrl-C —
you MUST start it with `exec_command(cmd="...", tty=true)` and then
`write_stdin(session_id=<id>, chars="...")`. Calling `write_stdin` on a
default (non-TTY) command or on a process that has already exited fails with
"stdin is not available".
- Use `exec_command` for Python code: write reusable scripts under
`/workspace/scratch/` and run them with `python3`. For one-off snippets,
`python3 -c` or a here-document is acceptable.
- For Caido proxy automation inside Python, explicitly import from
`caido_api`:
`from caido_api import list_requests, view_request, repeat_request, list_sitemap, view_sitemap_entry, scope_rules`
@@ -201,7 +186,7 @@ EFFICIENCY TACTICS:
VALIDATION REQUIREMENTS:
- Full validation required - no assumptions
- Demonstrate concrete impact with evidence
- Consider business context for severity assessment — check whether the target is a demo/sandbox environment or content meant to be public, and factor that in
- Consider business context for severity assessment
- Independent verification through subagent
- Document complete attack chain
- Keep going until you find something that matters
@@ -255,18 +240,12 @@ AGENT ISOLATION & SANDBOXING:
- All agents share the same /workspace directory and proxy history
- Agents can see each other's files and proxy traffic for better collaboration
DISK & SCRATCH HYGIENE:
- /workspace is a shared, finite disk used by all agents at once — be a considerate tenant
- Prefer bounded recon: scope crawls and scans by depth, duration, and target rather than "collect everything"
- Redirect large tool output to a file, and once you've extracted what you need (e.g. a URL/endpoint list), remove the raw output
- If disk gets tight or a write fails for space, check what's large under /workspace and clean up files from your own task; leave another agent's files unless you've confirmed they're no longer in use
MANDATORY INITIAL PHASES:
BLACK-BOX TESTING - PHASE 1 (RECON & MAPPING):
- COMPLETE full reconnaissance: subdomain enumeration, port scanning, service detection
- MAP entire attack surface: all endpoints, parameters, APIs, forms, inputs
- CRAWL thoroughly: spider all pages (authenticated and unauthenticated), discover hidden paths, analyze JS files — keep each crawl bounded by depth/duration, and tidy up raw output once endpoints are extracted
- CRAWL thoroughly: spider all pages (authenticated and unauthenticated), discover hidden paths, analyze JS files
- ENUMERATE technologies: frameworks, libraries, versions, dependencies
- Reconnaissance should normally happen before targeted vulnerability discovery unless the correct next move is already obvious or the user/system explicitly asks to prioritize a specific area first
- ONLY AFTER comprehensive mapping → proceed to vulnerability testing
@@ -434,7 +413,6 @@ SPECIALIZED TOOLS:
PROXY & INTERCEPTION:
- Caido CLI - Modern web proxy (already running). Use the proxy tools
directly, or import `caido_api` from sandbox Python scripts.
- HTTPQL filters (for `list_requests`): quote string values, leave integers unquoted (`resp.code.eq:200`, not `"200"`); combine terms with `AND`/`OR` (there is no `NOT` — use the negated operator `ne`/`ncont`/`nregex`). Numeric fields (`resp.code`, `req.port`) use `eq`/`ne`/`gt`/`gte`/`lt`/`lte`; text fields (`req.host`, `req.path`, `req.method`, `req.raw`) use `cont`/`ncont`/`eq`/`regex`. Example: `resp.code.gte:200 AND resp.code.lt:300 AND req.host.cont:"api"`.
- NOTE: If you are seeing proxy errors when sending requests, it usually means you are not sending requests to a correct url/host/port.
- Ignore Caido proxy-generated 50x HTML error pages; these are proxy issues (might happen when requesting a wrong host or SSL/TLS issues, etc).
-17
View File
@@ -10,7 +10,6 @@ from agents.models.multi_provider import MultiProvider
from agents.retry import (
ModelRetryBackoffSettings,
ModelRetrySettings,
RetryPolicyContext,
retry_policies,
)
@@ -21,21 +20,6 @@ if TYPE_CHECKING:
from strix.config.settings import Settings
def request_timeout_extra_args(timeout_s: float | None) -> dict[str, float] | None:
"""Per-request model timeout; a plain float so ``ModelSettings.to_json_dict()`` stays serializable.""" # noqa: E501
if not timeout_s or timeout_s <= 0:
return None
return {"timeout": timeout_s}
def _retry_statusless_provider_errors(context: RetryPolicyContext) -> bool:
"""Retry statusless provider errors (e.g. mid-stream quota/billing), but not aborts."""
normalized = context.normalized
if normalized.is_abort:
return False
return normalized.status_code is None
class StrixProvider(MultiProvider):
"""Route any non-OpenAI prefix through LiteLLM with the prefix preserved,
so users type ``deepseek/deepseek-chat`` rather than
@@ -72,7 +56,6 @@ DEFAULT_MODEL_RETRY = ModelRetrySettings(
retry_policies.provider_suggested(),
retry_policies.network_error(),
retry_policies.http_status((429, 500, 502, 503, 504)),
_retry_statusless_provider_errors,
),
)
-2
View File
@@ -56,8 +56,6 @@ class RuntimeSettings(BaseSettings):
# on large repos). Above this, the user must bind-mount via ``--mount``.
# Set to 0 (or less) to disable the pre-flight check entirely.
max_local_copy_mb: int = Field(default=1024, alias="STRIX_MAX_LOCAL_COPY_MB")
# Max screenshot/image tool outputs kept live per agent context (0 = none).
max_context_images: int = Field(default=3, ge=0, alias="STRIX_MAX_CONTEXT_IMAGES")
class TelemetrySettings(BaseSettings):
+1 -4
View File
@@ -10,8 +10,6 @@ from dataclasses import dataclass, field
from pathlib import Path
from typing import TYPE_CHECKING, Any, Literal, cast
from strix.core.sessions import session_write_lock
if TYPE_CHECKING:
from agents.items import TResponseInputItem
@@ -139,8 +137,7 @@ class AgentCoordinator:
)
return False
try:
async with session_write_lock(session):
await session.add_items([self._message_to_session_item(message)])
await session.add_items([self._message_to_session_item(message)])
except Exception:
logger.exception(
"agent.send failed to append to SDK session target=%s",
+1 -12
View File
@@ -17,11 +17,7 @@ from openai import APIError
from strix.core.hooks import BudgetExceededError
from strix.core.inputs import child_initial_input
from strix.core.sessions import (
enforce_image_budget,
open_agent_session,
strip_all_images_from_session,
)
from strix.core.sessions import open_agent_session, strip_all_images_from_session
if TYPE_CHECKING:
@@ -353,13 +349,6 @@ async def _run_cycle( # noqa: PLR0912, PLR0915
while True:
try:
await coordinator.mark_running(agent_id)
if session is not None:
max_images = context.get("max_context_images")
if isinstance(max_images, int):
try:
await enforce_image_budget(session, max_images)
except Exception:
logger.exception("image-budget enforcement failed for %s", agent_id)
stream = Runner.run_streamed(
agent,
input=input_data,
+2 -1
View File
@@ -3,7 +3,6 @@
from __future__ import annotations
import logging
import math
from typing import TYPE_CHECKING, Any
from agents.lifecycle import RunHooks
@@ -28,6 +27,8 @@ class ReportUsageHooks(RunHooks[dict[str, Any]]):
"""Persist SDK-native usage after every model response."""
def __init__(self, *, model: str, max_budget_usd: float | None = None) -> None:
import math
if max_budget_usd is not None and (
not math.isfinite(max_budget_usd) or max_budget_usd <= 0
):
+1 -9
View File
@@ -12,9 +12,7 @@ from strix.config.models import (
DEFAULT_MODEL_RETRY,
is_known_openai_bare_model,
model_supports_reasoning,
request_timeout_extra_args,
)
from strix.core.sessions import scrub_images_from_items
if TYPE_CHECKING:
@@ -127,13 +125,11 @@ def make_model_settings(
*,
model_name: str,
force_required_tool_choice: bool = False,
request_timeout: float | None = None,
) -> ModelSettings:
model_settings = ModelSettings(
parallel_tool_calls=False,
retry=DEFAULT_MODEL_RETRY,
include_usage=True,
extra_args=request_timeout_extra_args(request_timeout),
)
if (
reasoning_effort is not None
@@ -165,11 +161,7 @@ def child_initial_input(
"""
parts: list[str] = []
if parent_history:
rendered = json.dumps(
scrub_images_from_items(parent_history),
ensure_ascii=False,
default=str,
)
rendered = json.dumps(parent_history, ensure_ascii=False, default=str)
parts.append(
"== Inherited context from parent (background only) ==\n"
f"{rendered}\n"
-2
View File
@@ -215,7 +215,6 @@ async def run_strix_scan(
settings.llm.reasoning_effort,
model_name=resolved_model,
force_required_tool_choice=settings.llm.force_required_tool_choice,
request_timeout=settings.llm.timeout,
)
run_config = RunConfig(
model=resolved_model,
@@ -288,7 +287,6 @@ async def run_strix_scan(
"parent_id": None,
"interactive": interactive,
"spawn_child_agent": spawn_child_agent,
"max_context_images": settings.runtime.max_context_images,
}
root_session = open_agent_session(root_id, agents_db)
+36 -121
View File
@@ -2,149 +2,64 @@
from __future__ import annotations
import asyncio
import logging
import contextlib
from typing import TYPE_CHECKING, Any, cast
from weakref import WeakKeyDictionary
from agents.memory import SQLiteSession
if TYPE_CHECKING:
from collections.abc import Callable
from pathlib import Path
from agents.items import TResponseInputItem
from agents.memory import Session
logger = logging.getLogger(__name__)
def open_agent_session(agent_id: str, path: Path) -> SQLiteSession:
path.parent.mkdir(parents=True, exist_ok=True)
return SQLiteSession(session_id=agent_id, db_path=path)
_IMAGE_REJECTED_TEXT = "[image rejected by the model]"
_IMAGE_ELIDED_TEXT = "[older screenshot elided to bound context memory]"
_INHERITED_IMAGE_TEXT = "[screenshot omitted from inherited context]"
def _output_has_image(item_dict: dict[str, Any]) -> bool:
return (
item_dict.get("type") == "function_call_output"
and isinstance(item_dict.get("output"), list)
and any(isinstance(b, dict) and b.get("type") == "input_image" for b in item_dict["output"])
)
def _elided_output(item_dict: dict[str, Any], text: str) -> dict[str, Any]:
# Replace only image blocks; sibling text blocks are preserved.
output = item_dict.get("output")
blocks = output if isinstance(output, list) else []
return {
"type": "function_call_output",
"call_id": item_dict.get("call_id"),
"output": [
{"type": "input_text", "text": text}
if isinstance(block, dict) and block.get("type") == "input_image"
else block
for block in blocks
],
}
_session_write_locks: WeakKeyDictionary[Session, asyncio.Lock] = WeakKeyDictionary()
def session_write_lock(session: Session) -> asyncio.Lock:
"""Lock serialising all out-of-band writes to ``session``."""
lock = _session_write_locks.get(session)
if lock is None:
lock = asyncio.Lock()
_session_write_locks[session] = lock
return lock
async def _rewrite_session(
session: Session,
transform: Callable[[list[Any]], tuple[list[Any], bool]],
) -> bool:
"""Read-modify-write a session under its write lock, restoring on failure."""
async with session_write_lock(session):
items = await session.get_items()
if not items:
return False
rebuilt, changed = transform(list(items))
if not changed:
return False
rebuilt_items = cast("list[TResponseInputItem]", rebuilt)
original_items = cast("list[TResponseInputItem]", list(items))
await session.clear_session()
try:
await session.add_items(rebuilt_items)
except Exception:
logger.exception("session rewrite failed; restoring original items")
await session.clear_session()
await session.add_items(original_items)
raise
return True
async def strip_all_images_from_session(session: Session) -> bool:
"""Replace every image tool output with a text placeholder (rejection recovery)."""
def _transform(items: list[Any]) -> tuple[list[Any], bool]:
rebuilt: list[Any] = []
changed = False
for item in items:
item_dict = cast("dict[str, Any]", item) if isinstance(item, dict) else None
if item_dict is not None and _output_has_image(item_dict):
rebuilt.append(_elided_output(item_dict, _IMAGE_REJECTED_TEXT))
changed = True
else:
rebuilt.append(item)
return rebuilt, changed
return await _rewrite_session(session, _transform)
async def enforce_image_budget(session: Session, max_images: int) -> bool:
"""Keep only the most recent ``max_images`` image outputs; elide older ones."""
if max_images < 0:
items = await session.get_items()
if not items:
return False
def _transform(items: list[Any]) -> tuple[list[Any], bool]:
image_indices = [
i
for i, item in enumerate(items)
if isinstance(item, dict) and _output_has_image(cast("dict[str, Any]", item))
]
if len(image_indices) <= max_images:
return items, False
to_elide = set(image_indices[: len(image_indices) - max_images])
rebuilt = [
_elided_output(cast("dict[str, Any]", item), _IMAGE_ELIDED_TEXT)
if i in to_elide
else item
for i, item in enumerate(items)
]
return rebuilt, True
rebuilt: list[Any] = []
changed = False
for item in items:
item_dict = cast("dict[str, Any]", item) if isinstance(item, dict) else None
if (
item_dict is not None
and item_dict.get("type") == "function_call_output"
and isinstance(item_dict.get("output"), list)
and any(
isinstance(b, dict) and b.get("type") == "input_image" for b in item_dict["output"]
)
):
rebuilt.append(
{
"type": "function_call_output",
"call_id": item_dict.get("call_id"),
"output": [{"type": "input_text", "text": _IMAGE_REJECTED_TEXT}],
},
)
changed = True
else:
rebuilt.append(item)
return await _rewrite_session(session, _transform)
if not changed:
return False
def scrub_images_from_items(items: list[Any]) -> list[Any]:
"""Return a copy of ``items`` with every image block replaced by text."""
def _scrub(obj: Any) -> Any:
if isinstance(obj, dict):
if obj.get("type") == "input_image":
return {"type": "input_text", "text": _INHERITED_IMAGE_TEXT}
return {k: _scrub(v) for k, v in obj.items()}
if isinstance(obj, list):
return [_scrub(v) for v in obj]
return obj
return [_scrub(item) for item in items]
rebuilt_items = cast("list[TResponseInputItem]", rebuilt)
await session.clear_session()
try:
await session.add_items(rebuilt_items)
except Exception:
with contextlib.suppress(Exception):
await session.add_items(rebuilt_items)
raise
return True
+1 -6
View File
@@ -16,7 +16,6 @@ from strix.config.models import (
DEFAULT_MODEL_RETRY,
StrixProvider,
configure_sdk_model_defaults,
request_timeout_extra_args,
)
from strix.report.state import get_global_report_state
@@ -311,11 +310,7 @@ async def check_duplicate(
response = await model.get_response(
system_instructions=DEDUPE_SYSTEM_PROMPT,
input=user_msg,
model_settings=ModelSettings(
retry=DEFAULT_MODEL_RETRY,
include_usage=True,
extra_args=request_timeout_extra_args(settings.llm.timeout),
),
model_settings=ModelSettings(retry=DEFAULT_MODEL_RETRY, include_usage=True),
tools=[],
output_schema=None,
handoffs=[],
+10 -102
View File
@@ -534,10 +534,16 @@ def litellm_cost_callback(
cost = value
if cost is None:
cost = _usage_reported_cost(completion_response)
if cost is None:
cost = _estimate_response_cost(kwargs, completion_response)
usage: Any = getattr(completion_response, "usage", None)
if usage is None and isinstance(completion_response, dict):
usage = cast("dict[str, Any]", completion_response).get("usage")
usage_cost: Any
if isinstance(usage, dict):
usage_cost = cast("dict[str, Any]", usage).get("cost")
else:
usage_cost = getattr(usage, "cost", None)
if isinstance(usage_cost, int | float) and usage_cost > 0:
cost = float(usage_cost)
if cost is None or cost <= 0:
return
@@ -548,101 +554,3 @@ def litellm_cost_callback(
report_state.record_observed_llm_cost(cost)
except Exception:
logger.exception("Failed to record observed LiteLLM cost")
def _usage_reported_cost(completion_response: Any) -> float | None:
"""Provider-reported cost from the ``usage`` block (e.g. OpenRouter).
Non-BYOK responses charge everything to ``usage.cost``. BYOK responses
charge only the OpenRouter fee to ``usage.cost`` (often 0) and report the
provider charge in ``usage.cost_details.upstream_inference_cost``, so the
true BYOK total is the sum of the two.
"""
usage: Any = getattr(completion_response, "usage", None)
if usage is None and isinstance(completion_response, dict):
usage = cast("dict[str, Any]", completion_response).get("usage")
if usage is None:
return None
def _field(container: Any, name: str) -> Any:
if isinstance(container, dict):
return cast("dict[str, Any]", container).get(name)
return getattr(container, name, None)
total = 0.0
usage_cost = _field(usage, "cost")
if isinstance(usage_cost, int | float) and usage_cost > 0:
total += float(usage_cost)
if bool(_field(usage, "is_byok")):
upstream = _field(_field(usage, "cost_details"), "upstream_inference_cost")
if isinstance(upstream, int | float) and upstream > 0:
total += float(upstream)
return total if total > 0 else None
def _estimate_response_cost(kwargs: Any, completion_response: Any) -> float | None:
"""Best-effort LiteLLM cost-map estimate when no provider-reported cost exists.
LiteLLM strips provider cost fields when rebuilding streamed responses and
returns no ``response_cost`` for models missing from its cost map, so try
the provider-prefixed name, the raw name, and the bare model name.
"""
from litellm import completion_cost
model = kwargs.get("model") if isinstance(kwargs, dict) else None
if not isinstance(model, str) or not model:
if isinstance(completion_response, dict):
model = cast("dict[str, Any]", completion_response).get("model")
else:
model = getattr(completion_response, "model", None)
if not isinstance(model, str) or not model:
return None
provider = None
litellm_params = kwargs.get("litellm_params") if isinstance(kwargs, dict) else None
if isinstance(litellm_params, dict):
provider = litellm_params.get("custom_llm_provider")
usage_payload = _usage_payload(completion_response)
if usage_payload is None:
return None
candidates: list[str] = []
if isinstance(provider, str) and provider and not model.startswith(f"{provider}/"):
candidates.append(f"{provider}/{model}")
candidates.append(model)
if "/" in model:
candidates.append(model.rsplit("/", 1)[-1])
for candidate in candidates:
try:
value = completion_cost(
completion_response={"model": candidate, "usage": usage_payload},
model=candidate,
)
except Exception: # nosec B112 # noqa: BLE001, S112
continue
if isinstance(value, int | float) and value > 0:
return float(value)
return None
def _usage_payload(completion_response: Any) -> dict[str, Any] | None:
"""Token counts as a plain dict, detached from the response's provider metadata."""
usage: Any = getattr(completion_response, "usage", None)
if usage is None and isinstance(completion_response, dict):
usage = cast("dict[str, Any]", completion_response).get("usage")
if usage is None:
return None
if hasattr(usage, "model_dump"):
usage = usage.model_dump()
if not isinstance(usage, dict):
return None
payload = cast("dict[str, Any]", usage)
if not payload.get("total_tokens") and not (
payload.get("prompt_tokens") or payload.get("completion_tokens")
):
return None
return payload
+131 -3
View File
@@ -2,7 +2,9 @@
from __future__ import annotations
import asyncio
import logging
import os
from collections.abc import Awaitable, Callable
from typing import TYPE_CHECKING, Any
@@ -16,6 +18,128 @@ logger = logging.getLogger(__name__)
SandboxBackend = Callable[..., Awaitable[tuple[Any, Any]]]
_DEFAULT_START_ATTEMPTS = 3
_START_BACKOFF_SECONDS = 2.0
_TRANSIENT_TIMEOUT_NAMES = {
"ConnectTimeout",
"PoolTimeout",
"ReadTimeout",
"TimeoutError",
"TimeoutException",
"WriteTimeout",
}
_TRANSIENT_CONNECTION_NAMES = {
"ConnectError",
"ConnectionError",
"ConnectionResetError",
"ReadError",
"WriteError",
}
def _start_attempts() -> int:
raw = os.environ.get("STRIX_SANDBOX_START_ATTEMPTS")
if raw is None:
return _DEFAULT_START_ATTEMPTS
try:
attempts = int(raw)
except ValueError:
logger.warning(
"Invalid STRIX_SANDBOX_START_ATTEMPTS=%r; using %d",
raw,
_DEFAULT_START_ATTEMPTS,
)
return _DEFAULT_START_ATTEMPTS
if attempts < 1:
logger.warning(
"STRIX_SANDBOX_START_ATTEMPTS must be positive; using %d",
_DEFAULT_START_ATTEMPTS,
)
return _DEFAULT_START_ATTEMPTS
return attempts
def _exception_chain(error: BaseException) -> list[BaseException]:
chain: list[BaseException] = []
pending: list[BaseException | None] = [error]
seen: set[int] = set()
while pending:
current = pending.pop()
if current is None or id(current) in seen:
continue
seen.add(id(current))
chain.append(current)
pending.extend(
(
current.__cause__,
current.__context__,
getattr(current, "cause", None),
)
)
return chain
def _is_transient_start_error(error: BaseException) -> bool:
for cause in _exception_chain(error):
name = type(cause).__name__
module = type(cause).__module__
if isinstance(cause, TimeoutError | ConnectionError | ConnectionResetError):
return True
if name in _TRANSIENT_TIMEOUT_NAMES:
return True
if name in _TRANSIENT_CONNECTION_NAMES and (
module.startswith(("httpcore", "httpx", "agents"))
or name in {"ConnectionError", "ConnectionResetError"}
):
return True
return False
async def start_session_with_retry(
client: Any,
create_session: Callable[[], Awaitable[Any]],
*,
attempts: int | None = None,
) -> Any:
"""Start a sandbox session, retrying transient transport failures.
Backend implementations should use this helper when they own both session
creation and ``session.start()`` so failed starts can be torn down before a
retry. The caller owns the manifest and any temporary source directories
until this helper returns.
"""
max_attempts = attempts if attempts is not None else _start_attempts()
for attempt in range(1, max_attempts + 1):
session: Any | None = None
try:
session = await create_session()
assert session is not None
await session.start()
except Exception as exc:
if session is not None:
try:
await client.delete(session)
except Exception as teardown_error:
logger.warning(
"Failed to tear down sandbox after start failure; aborting retry",
exc_info=True,
)
raise exc from teardown_error
transient = _is_transient_start_error(exc)
if not transient or attempt == max_attempts:
raise
delay = _START_BACKOFF_SECONDS * (2 ** (attempt - 1))
logger.warning(
"Transient sandbox start failure; retrying attempt %d/%d in %.1fs",
attempt + 1,
max_attempts,
delay,
)
await asyncio.sleep(delay)
else:
return session
raise AssertionError("sandbox start retry loop completed without returning or raising")
async def _docker_backend(
*,
@@ -50,8 +174,10 @@ async def _docker_backend(
client = StrixDockerSandboxClient(docker.from_env())
client.strix_bind_mounts = bind_mounts or []
options = DockerSandboxClientOptions(image=image, exposed_ports=exposed_ports)
session = await client.create(options=options, manifest=manifest)
await session.start()
session = await start_session_with_retry(
client,
lambda: client.create(options=options, manifest=manifest),
)
return client, session
@@ -83,7 +209,9 @@ def register_backend(name: str, backend: SandboxBackend) -> None:
Intended for downstream users who ship their own runtime — register
before any ``session_manager.create_or_reuse`` call. Re-registering
an existing name overwrites the prior entry.
an existing name overwrites the prior entry. Backends that own both
session creation and ``session.start()`` should use
:func:`start_session_with_retry`.
"""
_BACKENDS[name] = backend
logger.info("Registered sandbox backend: %s", name)
+4 -12
View File
@@ -10,7 +10,6 @@ exposed-port URL for all subsequent SDK calls.
from __future__ import annotations
import asyncio
import contextlib
import json
import logging
from typing import TYPE_CHECKING
@@ -94,16 +93,9 @@ async def bootstrap_caido(
client = Client(host_url, auth=TokenAuthOptions(token=access_token))
await client.connect()
try:
project = await client.project.create(
CreateProjectOptions(name="sandbox", temporary=True),
)
await client.project.select(project.id)
except BaseException:
# The connected client never reaches the session bundle if project
# setup fails, so close it here to avoid leaking the transport.
with contextlib.suppress(Exception):
await client.aclose()
raise
project = await client.project.create(
CreateProjectOptions(name="sandbox", temporary=True),
)
await client.project.select(project.id)
logger.info("Caido project selected: %s", project.id)
return client
+1 -116
View File
@@ -24,25 +24,20 @@ from __future__ import annotations
import contextlib
import logging
import os
import uuid
from typing import Any, cast
from typing import Any
from agents.sandbox.errors import ExposedPortUnavailableError
from agents.sandbox.manifest import Manifest
from agents.sandbox.sandboxes.docker import (
DockerSandboxClient,
DockerSandboxSession,
_build_docker_volume_mounts,
_docker_port_key,
_manifest_requires_fuse,
_manifest_requires_sys_admin,
)
from agents.sandbox.session.sandbox_session import SandboxSession
from agents.sandbox.types import ExposedPortEndpoint
from docker import errors as docker_errors # type: ignore[import-untyped, unused-ignore]
from docker.models.containers import Container # type: ignore[import-untyped, unused-ignore]
from docker.types import LogConfig # type: ignore[import-untyped, unused-ignore]
from docker.types import Mount as DockerSDKMount # type: ignore[import-untyped, unused-ignore]
from docker.utils import parse_repository_tag # type: ignore[import-untyped, unused-ignore]
from requests.exceptions import RequestException
@@ -51,103 +46,6 @@ from requests.exceptions import RequestException
logger = logging.getLogger(__name__)
_SANDBOX_NETWORK_ENV = "STRIX_DOCKER_SANDBOX_NETWORK"
def _sandbox_network() -> str | None:
value = os.environ.get(_SANDBOX_NETWORK_ENV, "").strip()
return value or None
def _apply_sandbox_network(create_kwargs: dict[str, Any]) -> None:
network = _sandbox_network()
if network:
create_kwargs["network"] = network
create_kwargs.pop("ports", None)
def _apply_resource_limits(create_kwargs: dict[str, Any]) -> None:
"""Apply optional cgroup resource caps from the environment. Unset/blank
values leave docker's default (unbounded), so this is opt-in per host."""
mem_limit = os.environ.get("STRIX_SANDBOX_MEM_LIMIT", "").strip()
if mem_limit:
create_kwargs["mem_limit"] = mem_limit
shm_size = os.environ.get("STRIX_SANDBOX_SHM_SIZE", "").strip()
if shm_size:
create_kwargs["shm_size"] = shm_size
cpus = os.environ.get("STRIX_SANDBOX_CPUS", "").strip()
if cpus:
with contextlib.suppress(ValueError, OverflowError):
nano_cpus = int(float(cpus) * 1_000_000_000)
if 0 < nano_cpus <= 2**63 - 1:
create_kwargs["nano_cpus"] = nano_cpus
pids_limit = os.environ.get("STRIX_SANDBOX_PIDS_LIMIT", "").strip()
if pids_limit:
with contextlib.suppress(ValueError):
create_kwargs["pids_limit"] = int(pids_limit)
def _apply_log_limits(create_kwargs: dict[str, Any]) -> None:
"""Bound the container's json-file log so a runaway process in the sandbox
(e.g. a tool that busy-loops writing to stdout) cannot fill the host disk
and take the Docker daemon down with it.
Unlike the cgroup caps above, this defaults **on** — docker's own default
is an unbounded json-file, which is unsafe for an autonomous agent that
executes arbitrary commands. ``max-file`` rotation means the on-disk cap is
``max-size * max-file``. Set ``STRIX_SANDBOX_LOG_MAX_SIZE`` to ``0``/``off``
to opt back out to docker's default."""
max_size = os.environ.get("STRIX_SANDBOX_LOG_MAX_SIZE", "50m").strip()
if max_size.lower() in ("0", "off", "none", "unlimited"):
return
max_file = os.environ.get("STRIX_SANDBOX_LOG_MAX_FILE", "3").strip() or "3"
create_kwargs["log_config"] = LogConfig(
type=LogConfig.types.JSON,
config={"max-size": max_size, "max-file": max_file},
)
class StrixDockerSandboxSession(DockerSandboxSession):
sandbox_network: str = ""
async def _resolve_exposed_port(self, port: int) -> ExposedPortEndpoint:
try:
self._container.reload()
except docker_errors.APIError as e:
raise ExposedPortUnavailableError(
port=port,
exposed_ports=self.state.exposed_ports,
reason="backend_unavailable",
context={
"backend": "docker",
"detail": "container_reload_failed",
"network": self.sandbox_network,
},
cause=e,
) from e
attrs = getattr(self._container, "attrs", {}) or {}
networks = attrs.get("NetworkSettings", {}).get("Networks", {})
endpoint = networks.get(self.sandbox_network) or {}
ip = endpoint.get("IPAddress") or endpoint.get("GlobalIPv6Address")
if not isinstance(ip, str) or not ip:
raise ExposedPortUnavailableError(
port=port,
exposed_ports=self.state.exposed_ports,
reason="backend_unavailable",
context={
"backend": "docker",
"detail": "container_not_on_network",
"network": self.sandbox_network,
},
)
host = f"[{ip}]" if ":" in ip else ip
return ExposedPortEndpoint(host=host, port=port, tls=False)
class StrixDockerSandboxClient(DockerSandboxClient):
# Host directories to bind-mount into the container, set by the docker
# backend before ``create()``. Each item is ``{source, target, read_only}``.
@@ -219,10 +117,6 @@ class StrixDockerSandboxClient(DockerSandboxClient):
extra_hosts = create_kwargs.setdefault("extra_hosts", {})
extra_hosts["host.docker.internal"] = "host-gateway"
_apply_sandbox_network(create_kwargs)
_apply_resource_limits(create_kwargs)
_apply_log_limits(create_kwargs)
# Strix injection: host bind mounts (e.g. large repos passed via --mount)
# that bypass the SDK's file-by-file LocalDir copy.
bind_mounts = getattr(self, "strix_bind_mounts", ())
@@ -252,15 +146,6 @@ class StrixDockerSandboxClient(DockerSandboxClient):
)
return container
async def create(self, **kwargs: Any) -> SandboxSession:
session = await super().create(**kwargs)
network = _sandbox_network()
inner = session._inner
if network and isinstance(inner, DockerSandboxSession):
inner.__class__ = StrixDockerSandboxSession
cast("StrixDockerSandboxSession", inner).sandbox_network = network
return session
async def delete(self, session: SandboxSession) -> SandboxSession:
container_id = getattr(getattr(session._inner, "state", None), "container_id", None)
if container_id:
+1 -9
View File
@@ -167,19 +167,11 @@ async def cleanup(scan_id: str) -> None:
except Exception: # noqa: BLE001
logger.debug("cleanup(%s): caido_client.aclose() raised", scan_id, exc_info=True)
client = bundle["client"]
try:
await client.delete(bundle["session"])
await bundle["client"].delete(bundle["session"])
logger.info("Cleaned up sandbox session for scan %s", scan_id)
except Exception:
logger.exception(
"cleanup(%s): client.delete raised; container may need manual reaping",
scan_id,
)
docker_client = getattr(client, "docker_client", None)
if docker_client is not None:
try:
docker_client.close()
except Exception: # noqa: BLE001
logger.debug("cleanup(%s): docker_client.close() raised", scan_id, exc_info=True)
@@ -1,151 +0,0 @@
---
name: asset-discovery
description: Passive asset and attack-surface discovery via certificate transparency, TLS SAN pivoting, passive DNS, and ASN/IP enumeration to find hosts beyond subdomain brute force
---
# Asset Discovery
Most engagements start from a small seed (one domain, one org name) but the real attack surface is far larger: forgotten hosts, staging/internal-named services, acquisitions, and infrastructure that never appears in a wordlist. Build a broad, deduplicated inventory using passive intelligence — certificate transparency, TLS certificate metadata, passive DNS, and ASN/IP data — then collapse it into a probed, classified attack surface. The aim is coverage and pivoting: every certificate, DNS record, and IP is a lead to more assets.
Only use this skill when all subdomains and related assets of the target are in scope — broad discovery pulls in hosts far beyond the seed.
## Attack Surface
- Hosts discoverable via issued certificates (CT logs) but absent from DNS brute force
- Internal/staging/pre-prod hostnames leaked in certificate SAN lists
- Sibling and acquisition domains sharing certificates, ASNs, or IP ranges with the seed
- Wildcard and short-lived certs revealing naming conventions (`*.internal.example.com`, `k8s-*`, `argocd.*`)
- ASN-owned IP ranges hosting services with no DNS name at all
- Virtual hosts co-located on shared IPs (multiple apps behind one address)
- Non-HTTP services on discovered hosts (databases, brokers, admin ports)
## High-Value Sources
### Certificate Transparency (CT)
CT logs record nearly every publicly-trusted certificate. Query by domain (matches SAN/CN) and by organization name.
- **crt.sh** (free, no key):
- By domain incl. subdomains: `curl -s 'https://crt.sh/?q=%25.example.com&output=json' | jq -r '.[].name_value' | sed 's/^\*\.//' | sort -u`
- By organization: `https://crt.sh/?O=Example+Inc&output=json`
- **Censys / Shodan / Fofa** (API keys): search certs by `parsed.names`, `parsed.subject.organization`, or a specific `fingerprint_sha256`, then pivot to every host serving that cert.
- Cross-check multiple indexes (`certspotter`, Google CT, `chaos`) — no single log is complete.
- **Wildcards** (`*.corp.example.com`) reveal internal naming schemes even when individual hosts resolve privately; use them to seed targeted guesses (`grafana.corp`, `ci.corp`, `vault.corp`).
### TLS Certificate SAN/CN
- **SAN expansion**: one cert often lists many hostnames (marketing + api + admin + internal) — extract every SAN, not just the queried name.
- **Shared-cert pivot**: the same cert fingerprint served on multiple IPs ties disparate assets to one owner.
- **Issuer/org pivot**: certs sharing `subject.organization`/`organizationalUnit` frequently belong to the same target.
- **Active read** catches names never submitted to public CT: `echo | openssl s_client -connect HOST:443 -servername HOST 2>/dev/null | openssl x509 -noout -text | grep -A1 'Subject Alternative Name'`
- **Internal leak signal**: SANs like `localhost`, `*.internal`, `*.svc.cluster.local`, `*.local`, or RFC1918-style names on a public cert expose internal naming and sometimes internal services fronted publicly.
### Passive DNS
- Forward-resolve every name (A/AAAA/CNAME); keep CNAME chains — they reveal third-party providers and CDNs.
- **Reverse DNS (PTR)** on discovered IPs surfaces co-located hostnames.
- **Historical/passive DNS** (SecurityTrails, VirusTotal, `chaos`, passivedns providers) recovers names that no longer resolve but may still front live infra.
### ASN & IP Ranges
- Map a known IP to its ASN and netblock: `whois -h whois.cymru.com " -v <IP>"` or a BGP/ASN lookup.
- If the org runs its own ASN, enumerate all announced prefixes and treat them as candidate assets.
- For cloud-hosted targets the IP belongs to the provider, not the org — pivot via cert/vhost instead of netblock.
## Recommended Tooling
Prefer the projectdiscovery suite (already available in the sandbox and pipeline-friendly with JSON output):
- **`subfinder`** — passive subdomain aggregation across many sources incl. CT: `subfinder -d example.com -all -recursive -silent -oJ -o subs.jsonl`
- **`tlsx`** — TLS/cert data at scale; grab SANs and issuer/org to pivot: `tlsx -l hosts.txt -san -cn -tls-version -json -o tls.jsonl`
- **`uncover`** — query Shodan/Censys/Fofa/Quake/crt.sh engines from one CLI: `uncover -q 'ssl:"Example Inc"' -e shodan,censys,fofa -json`
- **`asnmap`** — org/domain/ASN → CIDR ranges: `asnmap -d example.com -json` / `asnmap -org "Example Inc"`
- **`mapcidr`** — expand/aggregate CIDRs into host lists for probing: `mapcidr -cidr 192.0.2.0/24 -o hosts.txt`
- **`dnsx`** — fast resolution, PTR, and wildcard filtering: `dnsx -l names.txt -a -aaaa -cname -ptr -resp -json -o dns.jsonl`
- **`httpx`** — live probing + cert grab in one pass (see methodology).
- **`naabu`** — port sweep for non-HTTP services: `naabu -list hosts.txt -top-ports 100 -verify -silent`
Also useful: **`amass`** (`amass intel`/`enum` for ASN, cert, and passive sources), **`cero`** (bulk SAN extraction from IPs/ranges), and direct **crt.sh** JSON queries when no keys are configured. Cross-source results — CT + passive DNS + `subfinder` together beat any single source.
## Key Techniques
### Iterative Seed Expansion
Every new name, PTR result, CNAME target, and cert SAN becomes a fresh seed. Loop CT → SAN extraction → passive DNS → ASN/range expansion until the asset set stops growing.
### Cert-Fingerprint Pivoting
Search Censys/Shodan (or `uncover`) by a cert's `fingerprint_sha256` to find every other host presenting the same certificate — the strongest cross-asset link for tying acquisitions and shadow infra to the target.
### Naming-Convention Inference
Wildcard SANs and observed hostnames expose the org's naming scheme; generate targeted candidates from it (`<service>.<env>.example.com`) rather than blind brute force.
### IP-First Discovery
For ASN-owned ranges, sweep IPs directly with `naabu`/`httpx` and read served certs (`tlsx`) to find services that have no DNS name at all.
## Advanced Techniques
- **Active SAN harvesting** across whole ranges with `tlsx`/`cero` recovers internal hostnames never logged to public CT.
- **Favicon and response hashing** (`httpx -favicon`, hash pivots in Shodan) clusters instances of the same app across unrelated hostnames.
- **Vhost differentials**: probe a single IP with multiple `Host:` values to unmask co-located apps behind one address.
- **Historical CT/DNS diffing** highlights recently issued certs and newly appearing hosts — high-signal for fresh or misconfigured deployments.
## Consolidation & Probing
1. **Dedupe** names and IPs into one inventory; record source(s) per asset for confidence.
2. **Live probe** with `httpx`, capturing status/title/tech/server and cert SANs in one pass — each grabbed SAN feeds back as a new seed:
`httpx -l hosts.txt -sc -title -server -td -tls-grab -json -o assets.jsonl`
3. **Classify** assets by function from title/tech/path signals: app, API, marketing, auth, CI/CD, observability, storage, admin, VCS, mail. Cluster by role, not by a specific product.
4. **Port sweep** interesting hosts with `naabu` for non-HTTP services (DBs, caches, brokers, mgmt ports).
5. **Prioritize** by exposure and value, then hand each finding to the right specialist skill:
- Exposed dashboards / debug / observability / metadata leaks → `information_disclosure`
- Login/admin panels with default or weak creds → `weak_password_detection`
- Dangling DNS / unclaimed provider resources → `subdomain_takeover`
- Cloud consoles/metadata surfaces → `aws` / `gcp` / `kubernetes`
## Testing Methodology
1. **Seed** - domains, org/legal names, known IPs, email domains, code-host org
2. **Certificate transparency** - pull all logged certs per seed domain and org name (crt.sh, `uncover`)
3. **SAN/CN extraction** - parse every Subject CN and SAN with `tlsx`; each new name is a new seed
4. **Passive DNS** - resolve forward and reverse with `dnsx`; harvest historical records
5. **ASN/IP mapping** - `asnmap``mapcidr` to expand owned ranges, then sweep for live hosts
6. **Active TLS pivot** - `tlsx`/`cero` on live IPs/ports to grab SANs missing from public CT
7. **Consolidate & probe** - dedupe, `httpx` probe, classify, and route to specialists
## Validation
1. Confirm each discovered asset actually resolves and serves content (live `httpx` result, not just a passive hit)
2. Attribute assets to the target via matching cert org, shared cert fingerprint, or DNS under a seed domain
3. Deduplicate vhost aliases and CDN edges down to distinct origins so the surface is not inflated
4. Record provenance (which source produced each asset) for reproducibility
## False Positives
- CDN/edge hostnames and provider default names that are not org-owned
- Shared-hosting neighbors on the same IP (vhost co-tenancy, not the target's asset)
- Stale historical DNS entries pointing at reassigned infrastructure
- Wildcard-cert-implied hostnames that never actually resolve or serve content
## Impact
- Expanded attack surface: forgotten, staging, and internal-named hosts brute force misses
- Discovery of misconfigured or unauthenticated services fronted by leaked internal hostnames
- Attribution of shadow infra, acquisitions, and sibling domains to the target
- A prioritized, classified inventory that feeds every downstream specialist skill
## Pro Tips
1. Loop the pipeline — every SAN, PTR, and CNAME target is a new seed until the set converges.
2. crt.sh is the cheapest high-yield source (no key); Censys/Shodan via `uncover` add cert-fingerprint and vhost pivoting when keys exist.
3. Always cert-grab live hosts with `tlsx` — active SANs catch internal hostnames never sent to public CT.
4. Internal-looking SANs (`*.internal`, `*.svc.cluster.local`, staging names) are the highest-signal leads.
5. Wildcard SANs reveal naming conventions — seed targeted guesses instead of blind brute force.
6. Cluster by function, not product name, so the workflow generalizes to any exposed service.
7. Keep JSON output throughout so stages chain cleanly (`subfinder``dnsx``httpx``naabu`).
## Summary
Broad passive discovery — CT + TLS SAN pivoting + passive DNS + ASN/IP mapping, looped until convergence — finds the assets brute force misses, especially internal-named and forgotten services leaked through certificates. Build the inventory with the projectdiscovery suite, probe and classify it generically, then route each interesting asset to the specialist skill for its class.
-17
View File
@@ -365,23 +365,6 @@ agent-browser dialog accept "text" # accept with prompt input
agent-browser dialog dismiss # cancel
```
## Readiness & recovery
The first `agent-browser open` in a session launches the headless-Chrome
daemon; later commands reuse it. Distinguish the two failure modes and react
differently — do **not** blindly re-run the same failing command in a loop:
- **Daemon / connection failure** (`Failed to connect`, `connection refused`,
socket missing, `browser not running`): the daemon isn't up or has died. Run
`agent-browser doctor` (add `--fix` if it reports repairable problems), then
re-open the page. Retrying the original command unchanged will keep failing.
- **Malformed command** (`Unknown command`, `Ref not found`, bad flag): fix the
command itself — re-snapshot for fresh refs, or correct the syntax.
Invoke `agent-browser` directly through `exec_command`; there is no need to wrap
it in an extra `sh -c "..."` / `bash -lc "..."` layer, which only adds shell
quoting and startup-file pitfalls.
## Diagnosing install issues
If a command fails unexpectedly (`Unknown command`, `Failed to connect`,
+3 -18
View File
@@ -24,15 +24,7 @@ High-signal flags:
- `-p, -parallelism <n>` concurrent input targets
- `-rl, -rate-limit <n>` request rate limit
- `-timeout <seconds>` request timeout
- `-ct, -crawl-duration <s|m|h|d>` maximum time to crawl the target
- `-retry <n>` retry count
- `-mdp, -max-domain-pages <n>` cap pages crawled per domain (default: unlimited)
- `-fsu, -filter-similar` collapse similar URLs (e.g. /users/123 and /users/456)
- `-fs, -field-scope <dn|rdn|fqdn|regex>` crawl scope (default `rdn` = root domain + ALL subdomains)
- `-f, -field <url|path|...>` emit only one field (e.g. `-f url` for a plain URL list)
- `-or, -omit-raw` omit raw request/response from JSONL output
- `-ob, -omit-body` omit response body from JSONL output
- `-mrs, -max-response-size <bytes>` cap per-response bytes read (default 4194304)
- `-ef, -extension-filter <list>` extension exclusions
- `-tlsi, -tls-impersonate` experimental JA3/TLS impersonation
- `-hl, -headless` enable hybrid headless crawling
@@ -45,13 +37,13 @@ High-signal flags:
- `-silent`, `-j, -jsonl`, `-o <file>` output controls
Agent-safe baseline for automation:
`mkdir -p crawl && katana -u https://target.tld -d 3 -ct 10m -mdp 2000 -fsu -jc -kf robotstxt -c 10 -p 10 -rl 50 -timeout 10 -retry 1 -ef png,jpg,jpeg,gif,svg,css,woff,woff2,ttf,eot,map -silent -j -o crawl/katana.jsonl`
`mkdir -p crawl && katana -u https://target.tld -d 3 -jc -kf robotstxt -c 10 -p 10 -rl 50 -timeout 10 -retry 1 -ef png,jpg,jpeg,gif,svg,css,woff,woff2,ttf,eot,map -silent -j -o crawl/katana.jsonl`
Common patterns:
- Fast crawl baseline:
`katana -u https://target.tld -d 3 -jc -silent`
- Deeper JS-aware crawl (narrowed target; keep it time-bounded):
`katana -u https://target.tld -d 5 -ct 15m -jc -jsl -kf all -c 10 -p 10 -rl 50 -o katana_urls.txt`
- Deeper JS-aware crawl:
`katana -u https://target.tld -d 5 -jc -jsl -kf all -c 10 -p 10 -rl 50 -o katana_urls.txt`
- Multi-target run with JSONL output:
`katana -list urls.txt -d 3 -jc -silent -j -o katana.jsonl`
- Headless crawl with local Chrome:
@@ -67,13 +59,6 @@ Critical correctness rules:
- For `-kf`, keep depth at least `-d 3` so known files are fully covered.
- If writing to a file, ensure parent directory exists before `-o`.
Keeping output small (katana has NO default page cap, so plan for volume):
- Bound scope and volume: `-fs fqdn` (or `-cs`/`-cos` regex) so the crawl doesn't wander across every subdomain, `-mdp <n>` to cap pages per domain, `-fsu` to collapse near-identical URLs, and `-ct`/`-d` to bound time and depth.
- Shrink each record: default JSONL is verbose. If you only need endpoints, emit a plain URL list with `-f url` instead of `-j`. If you need JSONL, drop the heavy parts with `-or` (omit raw) and `-ob` (omit body), and lower `-mrs` to cap per-response bytes.
- Reserve `-jsl` / `-kf all` / higher `-d` for a specific narrowed target — they multiply output fast on large sites.
- Reduce, then delete: once the crawl finishes, extract just what you need (e.g. `katana ... -f url -o urls.txt` or `sort -u` a URL list, or a short note of interesting paths) and remove the raw crawl file/dir. Don't keep large raw crawls around after you've distilled them.
- Sanity-check size (`du -sh <out>`); if it's outsized for the scope, tighten `-fs`/`-mdp`/`-fsu`/`-d`/`-ct` and re-run rather than keeping it.
Usage rules:
- Keep `-d`, `-c`, `-p`, and `-rl` explicit for reproducible runs.
- Use `-ef` early to reduce static-file noise before fuzzing.
+8 -17
View File
@@ -7,9 +7,9 @@ description: Run Python through exec_command in the SDK sandbox. Use the image-b
Use `exec_command` for Python. There is no separate Strix Python executor.
Prefer writing reusable scripts to a `.py` file and running them with
`python3 <name>.py`. For short one-off transformations, `python3 -c` or a
small here-document is fine.
Prefer writing reusable scripts to `/workspace/scratch/<name>.py` and
running them with `python3 /workspace/scratch/<name>.py`. For short
one-off transformations, `python3 -c` or a small here-document is fine.
The `shell` parameter on `exec_command` is for swapping POSIX shells
(`bash`/`zsh`/`sh`), not for picking interpreters. Put the interpreter
@@ -84,26 +84,17 @@ automatically, so it shows up in `list_requests` and you can use
For iterative exploit work, put code in a file:
```text
1. Create or edit a task-unique script (e.g. `poc_<task-id>.py`, so it can't
clobber a project file or another agent's script) with `apply_patch`.
2. Run it with `exec_command`: `python3 poc_<task-id>.py`.
1. Create or edit `/workspace/scratch/exploit.py` with `apply_patch`.
2. Run it with `exec_command`: `python3 /workspace/scratch/exploit.py`.
3. Edit and rerun until the proof-of-concept is reliable.
```
## Installing extra packages
The sandbox's Python lives in `/app/.venv`, and it is the active virtualenv
(`python3` / `pip` already resolve to it). The following common libraries are
**pre-installed** — import them directly, no install step needed:
`requests`, `httpx`, `beautifulsoup4` (`bs4`), `lxml`, `pyjwt` (`jwt`),
`cryptography`.
To add a one-off dependency for an exploit script, use `uv` (already in the
image and much faster than pip):
The sandbox's Python lives in `/app/.venv`. To add a one-off dependency
for an exploit script, use `uv` (already in the image and much faster
than pip):
```bash
uv pip install --python /app/.venv/bin/python <package>
```
Plain `pip install <package>` also works because the venv is active. Install
before you import, so scripts don't fail with `ModuleNotFoundError`.
+4 -15
View File
@@ -229,8 +229,7 @@ async def wait_for_message( # noqa: PLR0911
Use when you have nothing useful to do until a child/peer responds
— typically after spawning subagents and you want to wait for
their completion reports. The agent automatically resumes when any
message arrives, so pick a ``timeout_seconds`` proportional to the
work you're awaiting.
message arrives.
**Critical caveats:**
@@ -247,19 +246,9 @@ async def wait_for_message( # noqa: PLR0911
reason: One-line note shown in graph snapshots while you're
waiting (helps a human or sibling agent debug who's stuck
on what).
timeout_seconds: Max seconds to wait (default 600). This is only
a cap — the tool returns the INSTANT a message arrives, so a
larger value never makes you wait longer when the reply does
come. Right-size it to what you're waiting on: a short wait
(e.g. 10-60s) for a quick ack or a small/fast subtask, and a
longer one (e.g. ~100-200s) only for genuinely long-running
work (deep recon, exploitation, a full sub-scan). The cap only
bites when the expected message never arrives — so an oversized
timeout on a trivial wait just strands you idle until it
elapses. On timeout the tool returns and you decide whether to
keep working or wait again. (Applies to autonomous multi-agent
runs; in interactive/chat sessions the agent instead parks until
a message arrives and this cap is not enforced.)
timeout_seconds: Hard cap (default 600s). On timeout the tool
returns and you decide whether to keep working or wait
again.
"""
inner = _ctx(ctx)
coordinator = coordinator_from_context(inner)
+39 -100
View File
@@ -21,8 +21,6 @@ from caido_sdk_client.types import (
if TYPE_CHECKING:
from collections.abc import Awaitable, Callable
from caido_sdk_client import Client as CaidoClient
@@ -44,7 +42,6 @@ _SITEMAP_PAGE_SIZE = 30
_DEFAULT_CAIDO_URL = "http://127.0.0.1:48080"
_CLIENT_CACHE: dict[str, Client] = {}
_CLIENT_LOCK = asyncio.Lock()
_REQ_FIELD_MAP: dict[SortBy, tuple[str, str]] = {
"timestamp": ("req", "created_at"),
"host": ("req", "host"),
@@ -84,46 +81,19 @@ def _login_as_guest() -> str:
return str(payload["data"]["loginAsGuest"]["token"]["accessToken"])
async def _new_client() -> Client:
async def get_client() -> Client:
if client := _CLIENT_CACHE.get("default"):
return client
token = await asyncio.to_thread(_login_as_guest)
client = Client(caido_url(), auth=TokenAuthOptions(token=token))
await client.connect()
_CLIENT_CACHE["default"] = client
return client
async def get_client() -> Client:
"""Return the shared Caido client, creating it under a lock if needed.
The lock prevents two concurrent callers from each building a client and
racing ``connect()`` on the same transport ("Transport is already
connected").
"""
async with _CLIENT_LOCK:
client = _CLIENT_CACHE.get("default")
if client is None:
client = await _new_client()
_CLIENT_CACHE["default"] = client
return client
async def call_with_client[T](fn: Callable[[Client], Awaitable[T]]) -> T:
"""Run ``fn`` against the shared client, serialized through ``_CLIENT_LOCK``.
The Caido GraphQL transport is not safe for concurrent use: two in-flight
requests race and raise "Transport is already connected". Serializing every
proxy call through the lock prevents that.
"""
async with _CLIENT_LOCK:
client = _CLIENT_CACHE.get("default")
if client is None:
client = await _new_client()
_CLIENT_CACHE["default"] = client
return await fn(client)
async def close_client() -> None:
async with _CLIENT_LOCK:
client = _CLIENT_CACHE.pop("default", None)
client = _CLIENT_CACHE.pop("default", None)
if client is None:
return
await client.aclose()
@@ -415,23 +385,19 @@ async def list_requests(
sort_order: SortOrder = "desc",
scope_id: str | None = None,
) -> Any:
return await call_with_client(
lambda client: list_requests_with_client(
client,
httpql_filter=httpql_filter,
first=first,
after=after,
sort_by=sort_by,
sort_order=sort_order,
scope_id=scope_id,
)
return await list_requests_with_client(
await get_client(),
httpql_filter=httpql_filter,
first=first,
after=after,
sort_by=sort_by,
sort_order=sort_order,
scope_id=scope_id,
)
async def view_request(request_id: str, *, part: RequestPart = "request") -> Any:
return await call_with_client(
lambda client: get_request_with_client(client, request_id, part=part)
)
return await get_request_with_client(await get_client(), request_id, part=part)
async def repeat_request(
@@ -440,26 +406,22 @@ async def repeat_request(
modifications: dict[str, Any] | None = None,
) -> dict[str, Any]:
mods = modifications or {}
result = await get_request_with_client(await get_client(), request_id, part="request")
if result is None or result.request.raw is None:
raise ValueError(f"Request {request_id} not found")
async def _run(client: CaidoClient) -> dict[str, Any]:
result = await get_request_with_client(client, request_id, part="request")
if result is None or result.request.raw is None:
raise ValueError(f"Request {request_id} not found")
original = result.request
raw_str = result.request.raw.decode("utf-8", errors="replace")
components = parse_raw_request(raw_str)
full_url = full_url_from_components(original, components, mods)
modified = apply_modifications(components, mods, full_url)
connection, raw = build_raw_request(
method=modified["method"],
url=modified["url"],
headers=modified["headers"],
body=modified["body"],
)
return await replay_send_raw(client, raw=raw, connection=connection)
return await call_with_client(_run)
original = result.request
raw_str = result.request.raw.decode("utf-8", errors="replace")
components = parse_raw_request(raw_str)
full_url = full_url_from_components(original, components, mods)
modified = apply_modifications(components, mods, full_url)
connection, raw = build_raw_request(
method=modified["method"],
url=modified["url"],
headers=modified["headers"],
body=modified["body"],
)
return await replay_send_raw(await get_client(), raw=raw, connection=connection)
async def scope_rules(
@@ -470,28 +432,7 @@ async def scope_rules(
scope_id: str | None = None,
scope_name: str | None = None,
) -> Any:
async def _run(client: CaidoClient) -> Any:
return await _scope_rules_with_client(
client,
action,
allowlist=allowlist,
denylist=denylist,
scope_id=scope_id,
scope_name=scope_name,
)
return await call_with_client(_run)
async def _scope_rules_with_client(
client: CaidoClient,
action: ScopeAction,
*,
allowlist: list[str] | None = None,
denylist: list[str] | None = None,
scope_id: str | None = None,
scope_name: str | None = None,
) -> Any:
client = await get_client()
if action == "list":
result = await scope_list(client)
elif action == "get":
@@ -710,20 +651,18 @@ async def list_sitemap(
page: int = 1,
page_size: int = _SITEMAP_PAGE_SIZE,
) -> dict[str, Any]:
return await call_with_client(
lambda client: list_sitemap_with_client(
client,
scope_id=scope_id,
parent_id=parent_id,
depth=depth,
page=page,
page_size=page_size,
)
return await list_sitemap_with_client(
await get_client(),
scope_id=scope_id,
parent_id=parent_id,
depth=depth,
page=page,
page_size=page_size,
)
async def view_sitemap_entry(entry_id: str) -> dict[str, Any]:
return await call_with_client(lambda client: view_sitemap_entry_with_client(client, entry_id))
return await view_sitemap_entry_with_client(await get_client(), entry_id)
__all__ = [
+32 -74
View File
@@ -2,7 +2,6 @@
from __future__ import annotations
import asyncio
import dataclasses
import json
import logging
@@ -20,8 +19,6 @@ logger = logging.getLogger(__name__)
if TYPE_CHECKING:
from collections.abc import Awaitable, Callable
from caido_sdk_client import Client
from strix.tools.proxy.caido_api import (
@@ -41,23 +38,12 @@ else:
ScopeAction = Literal["get", "list", "create", "update", "delete"]
# All agents in a scan share one host-side Caido client whose GraphQL transport
# is not concurrency-safe (parallel calls raise "Transport is already
# connected"). Serialize every host-side proxy call through this lock.
_CAIDO_CALL_LOCK = asyncio.Lock()
def _ctx_client(ctx: RunContextWrapper) -> Client | None:
inner = ctx.context if isinstance(ctx.context, dict) else {}
return inner.get("caido_client")
async def _call[T](client: Client, fn: Callable[[Client], Awaitable[T]]) -> T:
"""Run ``fn`` against the shared client, serialized under ``_CAIDO_CALL_LOCK``."""
async with _CAIDO_CALL_LOCK:
return await fn(client)
def _to_tool_json(value: Any) -> Any:
"""Recursively convert SDK dataclasses/Pydantic objects to tool JSON values."""
if value is None or isinstance(value, str | int | float | bool):
@@ -160,17 +146,14 @@ async def list_requests(
return _no_client()
try:
connection = await _call(
connection = await caido_api.list_requests_with_client(
client,
lambda client: caido_api.list_requests_with_client(
client,
httpql_filter=httpql_filter,
first=first,
after=after,
sort_by=sort_by,
sort_order=sort_order,
scope_id=scope_id,
),
httpql_filter=httpql_filter,
first=first,
after=after,
sort_by=sort_by,
sort_order=sort_order,
scope_id=scope_id,
)
entries = []
@@ -266,10 +249,7 @@ async def view_request(
return _no_client()
try:
result = await _call(
client,
lambda client: caido_api.get_request_with_client(client, request_id, part=part),
)
result = await caido_api.get_request_with_client(client, request_id, part=part)
if result is None:
return json.dumps(
{"success": False, "error": f"Request {request_id} not found"},
@@ -384,10 +364,15 @@ async def repeat_request(
return _no_client()
mods = modifications or {}
async def _do(client: Client) -> dict[str, Any] | None:
try:
result = await caido_api.get_request_with_client(client, request_id, part="request")
if result is None or result.request.raw is None:
return None
return json.dumps(
{"success": False, "error": f"Request {request_id} not found"},
ensure_ascii=False,
default=str,
)
original = result.request
raw_str = result.request.raw.decode("utf-8", errors="replace")
components = caido_api.parse_raw_request(raw_str)
@@ -399,16 +384,7 @@ async def repeat_request(
headers=modified["headers"],
body=modified["body"],
)
return await caido_api.replay_send_raw(client, raw=raw, connection=connection)
try:
replay = await _call(client, _do)
if replay is None:
return json.dumps(
{"success": False, "error": f"Request {request_id} not found"},
ensure_ascii=False,
default=str,
)
replay = await caido_api.replay_send_raw(client, raw=raw, connection=connection)
return _format_replay_tool_result(replay)
except Exception as exc: # noqa: BLE001
return _err("repeat_request", exc)
@@ -465,15 +441,12 @@ async def list_sitemap(
if client is None:
return _no_client()
try:
payload = await _call(
payload = await caido_api.list_sitemap_with_client(
client,
lambda client: caido_api.list_sitemap_with_client(
client,
scope_id=scope_id,
parent_id=parent_id,
depth=depth,
page=page,
),
scope_id=scope_id,
parent_id=parent_id,
depth=depth,
page=page,
)
return json.dumps(payload, ensure_ascii=False, default=str)
except Exception as exc: # noqa: BLE001
@@ -499,10 +472,7 @@ async def view_sitemap_entry(
if client is None:
return _no_client()
try:
payload = await _call(
client,
lambda client: caido_api.view_sitemap_entry_with_client(client, entry_id),
)
payload = await caido_api.view_sitemap_entry_with_client(client, entry_id)
return json.dumps(payload, ensure_ascii=False, default=str)
except Exception as exc: # noqa: BLE001
return _err("view_sitemap_entry", exc)
@@ -560,7 +530,7 @@ async def scope_rules(
try:
if action == "list":
scopes = await _call(client, caido_api.scope_list)
scopes = await caido_api.scope_list(client)
return json.dumps(
{"success": True, "scopes": [_to_tool_json(s) for s in scopes]},
ensure_ascii=False,
@@ -573,11 +543,9 @@ async def scope_rules(
ensure_ascii=False,
default=str,
)
scope = await _call(client, lambda client: caido_api.scope_get(client, scope_id))
scope = await caido_api.scope_get(client, scope_id)
return json.dumps(
{"success": True, "scope": _to_tool_json(scope)},
ensure_ascii=False,
default=str,
{"success": True, "scope": _to_tool_json(scope)}, ensure_ascii=False, default=str
)
if action == "create":
if not scope_name:
@@ -586,16 +554,11 @@ async def scope_rules(
ensure_ascii=False,
default=str,
)
scope = await _call(
client,
lambda client: caido_api.scope_create(
client, name=scope_name, allowlist=allowlist, denylist=denylist
),
scope = await caido_api.scope_create(
client, name=scope_name, allowlist=allowlist, denylist=denylist
)
return json.dumps(
{"success": True, "scope": _to_tool_json(scope)},
ensure_ascii=False,
default=str,
{"success": True, "scope": _to_tool_json(scope)}, ensure_ascii=False, default=str
)
if action == "update":
if not scope_id or not scope_name:
@@ -607,16 +570,11 @@ async def scope_rules(
ensure_ascii=False,
default=str,
)
scope = await _call(
client,
lambda client: caido_api.scope_update(
client, scope_id, name=scope_name, allowlist=allowlist, denylist=denylist
),
scope = await caido_api.scope_update(
client, scope_id, name=scope_name, allowlist=allowlist, denylist=denylist
)
return json.dumps(
{"success": True, "scope": _to_tool_json(scope)},
ensure_ascii=False,
default=str,
{"success": True, "scope": _to_tool_json(scope)}, ensure_ascii=False, default=str
)
if not scope_id:
return json.dumps(
@@ -624,7 +582,7 @@ async def scope_rules(
ensure_ascii=False,
default=str,
)
await _call(client, lambda client: caido_api.scope_delete(client, scope_id))
await caido_api.scope_delete(client, scope_id)
return json.dumps(
{
"success": True,
-17
View File
@@ -5,23 +5,6 @@ invocation the agent makes (nmap, ffuf, agent-browser, python3, …) goes
through `exec_command`. `write_stdin` streams input to a still-running
process started by an earlier `exec_command` (for interactive prompts).
## `write_stdin` requires a TTY-backed process
`exec_command` runs each command in a fresh **non-interactive** shell (plain
pipes, no TTY) by default. `write_stdin` only works against a process that is
still running **and** was started with a PTY. The canonical sequence is:
```text
exec_command(cmd="python3", tty=true) # start a PTY-backed process
write_stdin(session_id=<id>, chars="print(1)\n")
```
Calling `write_stdin` on a command started with the default `tty=false`, or on
a process that has already exited, fails with
`stdin is not available for this process. Start the command with 'tty=true' in
'exec_command' before using 'write_stdin'.` Use `tty=true` for REPLs,
`ssh`/`nc`/`ftp`, `msfconsole`, or to deliver a Ctrl-C to a long-running job.
- **Implementation:** `agents.sandbox.capabilities.tools.shell_tool.ShellTool`
(in the upstream `agents` SDK)
- **Wired in:** `strix/agents/factory.py` — added per-run via the SDK
+218
View File
@@ -0,0 +1,218 @@
"""Tests for transient sandbox start retries."""
from __future__ import annotations
import shutil
from pathlib import Path
from types import SimpleNamespace
from typing import Any
import pytest
from agents.sandbox.errors import (
LocalDirReadError,
WorkspaceArchiveWriteError,
WorkspaceStartError,
)
from strix.runtime import session_manager
from strix.runtime.backends import start_session_with_retry
class _FakeSession:
def __init__(self, failures: list[BaseException]) -> None:
self._failures = iter(failures)
async def start(self) -> None:
try:
raise next(self._failures)
except StopIteration:
return
async def resolve_exposed_port(self, _port: int) -> SimpleNamespace:
return SimpleNamespace(tls=False, host="127.0.0.1", port=48080)
class _FakeClient:
def __init__(self, *, delete_error: BaseException | None = None) -> None:
self.created = 0
self.deleted: list[_FakeSession] = []
self.delete_error = delete_error
async def create(self) -> _FakeSession:
self.created += 1
failures: list[BaseException] = []
if self.created == 1:
failures = [
WorkspaceStartError(
path=Path("/workspace"),
cause=WorkspaceArchiveWriteError(
path=Path("/workspace"),
cause=TimeoutError("transient transport timeout"),
),
)
]
return _FakeSession(failures)
async def delete(self, session: _FakeSession) -> None:
self.deleted.append(session)
if self.delete_error is not None:
raise self.delete_error
async def test_transient_workspace_failure_retries_and_tears_down(
monkeypatch: pytest.MonkeyPatch,
) -> None:
client = _FakeClient()
sleeps: list[float] = []
async def record_sleep(delay: float) -> None:
sleeps.append(delay)
monkeypatch.setattr("strix.runtime.backends.asyncio.sleep", record_sleep)
session = await start_session_with_retry(client, client.create, attempts=3)
assert isinstance(session, _FakeSession)
assert client.created == 2
assert len(client.deleted) == 1
assert sleeps == [2.0]
async def test_non_transient_workspace_failure_does_not_retry() -> None:
client = _FakeClient()
session = _FakeSession([LocalDirReadError(src=Path("/workspace/repo"))])
async def create_session() -> _FakeSession:
client.created += 1
return session
with pytest.raises(LocalDirReadError):
await start_session_with_retry(client, create_session, attempts=3)
assert client.created == 1
assert client.deleted == [session]
async def test_teardown_failure_raises_original_error_without_retry() -> None:
start_error = WorkspaceStartError(
path=Path("/workspace"),
cause=TimeoutError("transient transport timeout"),
)
teardown_error = RuntimeError("teardown failed")
client = _FakeClient(delete_error=teardown_error)
session = _FakeSession([start_error])
async def create_session() -> _FakeSession:
client.created += 1
return session
with pytest.raises(WorkspaceStartError) as caught:
await start_session_with_retry(client, create_session, attempts=3)
assert caught.value is start_error
assert caught.value.__cause__ is teardown_error
assert client.created == 1
assert client.deleted == [session]
async def test_each_transient_attempt_is_torn_down(monkeypatch: pytest.MonkeyPatch) -> None:
client = _FakeClient()
client.created = 0
sessions: list[_FakeSession] = []
sleeps: list[float] = []
async def record_sleep(delay: float) -> None:
sleeps.append(delay)
monkeypatch.setattr("strix.runtime.backends.asyncio.sleep", record_sleep)
async def create_session() -> _FakeSession:
client.created += 1
failures: list[BaseException] = []
if client.created < 3:
failures = [
WorkspaceStartError(
path=Path("/workspace"),
cause=TimeoutError("transient transport timeout"),
)
]
session = _FakeSession(failures)
sessions.append(session)
return session
result = await start_session_with_retry(client, create_session, attempts=3)
assert result is sessions[2]
assert client.deleted == sessions[:2]
assert sleeps == [2.0, 4.0]
async def test_staged_dirs_survive_retries_and_cleanup_once(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
repo = tmp_path / "repo"
repo.mkdir()
(repo / "real.txt").write_text("content")
(repo / "link.txt").symlink_to(repo / "real.txt")
client = _FakeClient()
observed_paths: list[Path] = []
sleeps: list[float] = []
original_rmtree = shutil.rmtree # pyright: ignore[reportDeprecated]
removed_paths: list[Path] = []
async def record_sleep(delay: float) -> None:
sleeps.append(delay)
monkeypatch.setattr("strix.runtime.backends.asyncio.sleep", record_sleep)
def record_rmtree(path: str | Path, **kwargs: Any) -> None:
removed_paths.append(Path(path))
original_rmtree(path, **kwargs) # pyright: ignore[reportDeprecated]
monkeypatch.setattr("strix.runtime.session_manager.shutil.rmtree", record_rmtree)
monkeypatch.setattr(
session_manager,
"load_settings",
lambda: SimpleNamespace(runtime=SimpleNamespace(backend="fake")),
)
monkeypatch.setattr(session_manager, "bootstrap_caido", _bootstrap_caido)
async def fake_backend(**kwargs: Any) -> tuple[_FakeClient, _FakeSession]:
staged_path = kwargs["manifest"].entries["repo"].src
async def create_session() -> _FakeSession:
observed_paths.append(Path(staged_path))
return await client.create()
session = await start_session_with_retry(client, create_session, attempts=3)
return client, session
def fake_get_backend(_name: str) -> Any:
return fake_backend
monkeypatch.setattr(session_manager, "get_backend", fake_get_backend)
try:
await session_manager.create_or_reuse(
"retry-test",
image="test-image",
local_sources=[
{
"source_path": str(repo),
"workspace_subdir": "repo",
}
],
)
finally:
await session_manager.cleanup("retry-test")
assert len(observed_paths) == 2
assert observed_paths[0] == observed_paths[1]
assert observed_paths[0] in removed_paths
assert not observed_paths[0].exists()
async def _bootstrap_caido(*_args: Any, **_kwargs: Any) -> object:
return object()
-109
View File
@@ -6,7 +6,6 @@ from types import SimpleNamespace
from unittest.mock import MagicMock, patch
import litellm
import pytest
from strix.config.models import _configure_litellm_compatibility
from strix.report.state import litellm_cost_callback
@@ -43,111 +42,3 @@ def test_cost_callback_reads_usage_cost_from_mapping_response() -> None:
litellm_cost_callback({}, response)
report_state.record_observed_llm_cost.assert_called_once_with(0.125)
def test_cost_callback_reads_byok_upstream_inference_cost() -> None:
report_state = MagicMock()
response = SimpleNamespace(
usage=SimpleNamespace(
cost=0,
is_byok=True,
cost_details=SimpleNamespace(upstream_inference_cost=6.75e-06),
),
_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(6.75e-06)
def test_cost_callback_sums_usage_cost_and_upstream_inference_cost() -> None:
report_state = MagicMock()
response = {
"usage": {
"cost": 0.01,
"is_byok": True,
"cost_details": {"upstream_inference_cost": 0.2},
}
}
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(pytest.approx(0.21))
def test_cost_callback_ignores_upstream_cost_for_non_byok_responses() -> None:
report_state = MagicMock()
response = {
"usage": {
"cost": 0.05,
"is_byok": False,
"cost_details": {"upstream_inference_cost": 0.04},
}
}
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.05)
def test_cost_callback_estimates_cost_with_provider_prefixed_model() -> None:
report_state = MagicMock()
response = {"usage": {"prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15}}
kwargs = {
"response_cost": None,
"model": "anthropic/claude-sonnet-4.5",
"litellm_params": {"custom_llm_provider": "openrouter"},
}
def fake_completion_cost(**kwargs: object) -> float:
if kwargs["model"] == "openrouter/anthropic/claude-sonnet-4.5":
return 0.5
raise ValueError(kwargs["model"])
with (
patch("strix.report.state.get_global_report_state", return_value=report_state),
patch("litellm.completion_cost", side_effect=fake_completion_cost),
):
litellm_cost_callback(kwargs, response)
report_state.record_observed_llm_cost.assert_called_once_with(0.5)
def test_cost_callback_estimates_cost_with_bare_model_fallback() -> None:
report_state = MagicMock()
response = {"usage": {"prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15}}
kwargs = {
"response_cost": None,
"model": "openai/gpt-4o-mini",
"litellm_params": {"custom_llm_provider": "openrouter"},
}
def fake_completion_cost(**kwargs: object) -> float:
if kwargs["model"] == "gpt-4o-mini":
return 0.025
raise ValueError(kwargs["model"])
with (
patch("strix.report.state.get_global_report_state", return_value=report_state),
patch("litellm.completion_cost", side_effect=fake_completion_cost),
):
litellm_cost_callback(kwargs, response)
report_state.record_observed_llm_cost.assert_called_once_with(0.025)
def test_cost_callback_records_nothing_when_no_cost_available() -> None:
report_state = MagicMock()
response = {"usage": {"prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15}}
with (
patch("strix.report.state.get_global_report_state", return_value=report_state),
patch("litellm.completion_cost", side_effect=ValueError("unknown model")),
):
litellm_cost_callback({"response_cost": None, "model": "x/y"}, response)
report_state.record_observed_llm_cost.assert_not_called()
-30
View File
@@ -155,33 +155,3 @@ def test_make_model_settings_forces_required_for_anyllm_routed_openai_model() ->
)
assert settings.tool_choice == "required"
def test_make_model_settings_sets_request_timeout() -> None:
settings = make_model_settings(
"none",
model_name="gpt-4o",
request_timeout=300.0,
)
assert settings.extra_args is not None
assert settings.extra_args["timeout"] == 300.0
def test_make_model_settings_omits_timeout_when_unset() -> None:
settings = make_model_settings("none", model_name="gpt-4o")
assert settings.extra_args is None
def test_make_model_settings_timeout_survives_reasoning_resolve() -> None:
# Reasoning is resolved via ModelSettings.resolve(); the timeout in extra_args
# must not be dropped when a reasoning override is merged in.
settings = make_model_settings(
"high",
model_name="openai/o3",
request_timeout=120.0,
)
assert settings.extra_args is not None
assert settings.extra_args["timeout"] == 120.0
-77
View File
@@ -1,77 +0,0 @@
"""Tests for the model retry policy used by every agent model call.
The SDK's built-in ``http_status`` policy only retries errors that carry a known
HTTP status code. Quota/billing (and other provider-side) failures often surface
*inside* a streamed response as a bare error with no status code, so Strix adds a
statusless retry policy to ``DEFAULT_MODEL_RETRY`` to keep them recoverable the
behavior the pre-SDK engine had.
"""
from __future__ import annotations
import asyncio
from agents.retry import ModelRetryNormalizedError, RetryPolicyContext
from strix.config.models import DEFAULT_MODEL_RETRY, _retry_statusless_provider_errors
def _context(normalized: ModelRetryNormalizedError) -> RetryPolicyContext:
return RetryPolicyContext(
error=RuntimeError("boom"),
attempt=1,
max_retries=5,
stream=True,
normalized=normalized,
provider_advice=None,
)
def _retries(normalized: ModelRetryNormalizedError) -> bool:
"""Evaluate the composed DEFAULT_MODEL_RETRY policy for a normalized error."""
policy = DEFAULT_MODEL_RETRY.policy
assert policy is not None
decision = asyncio.run(policy(_context(normalized)))
return bool(getattr(decision, "retry", decision))
def test_statusless_error_is_retried() -> None:
# A mid-stream quota/billing error arrives with no HTTP status code.
assert _retries(ModelRetryNormalizedError(status_code=None)) is True
def test_statusless_abort_is_not_retried() -> None:
# A user/client cancellation must never be retried.
assert _retries(ModelRetryNormalizedError(status_code=None, is_abort=True)) is False
def test_client_error_is_not_retried() -> None:
# A definitive 4xx client error (bad request/auth) is not recoverable.
assert _retries(ModelRetryNormalizedError(status_code=400)) is False
def test_rate_limit_and_server_errors_are_retried() -> None:
for status in (429, 500, 502, 503, 504):
assert _retries(ModelRetryNormalizedError(status_code=status)) is True
def test_timeout_error_is_retried() -> None:
# A stalled model stream trips the per-request read/inactivity timeout, which
# the SDK normalizes as a timeout. DEFAULT_MODEL_RETRY must retry it so a hung
# turn recovers instead of silently wedging the agent.
assert _retries(ModelRetryNormalizedError(is_timeout=True)) is True
assert _retries(ModelRetryNormalizedError(is_network_error=True)) is True
def test_policy_helper_matches_statusless_only() -> None:
assert _retry_statusless_provider_errors(_context(ModelRetryNormalizedError())) is True
assert (
_retry_statusless_provider_errors(_context(ModelRetryNormalizedError(status_code=400)))
is False
)
assert (
_retry_statusless_provider_errors(
_context(ModelRetryNormalizedError(status_code=None, is_abort=True))
)
is False
)
+1 -23
View File
@@ -3,13 +3,8 @@
from __future__ import annotations
import pytest
from agents.model_settings import ModelSettings
from strix.config.models import (
RECOMMENDED_MODEL_NAMES,
is_recommended_or_frontier_model,
request_timeout_extra_args,
)
from strix.config.models import RECOMMENDED_MODEL_NAMES, is_recommended_or_frontier_model
@pytest.mark.parametrize("model_name", RECOMMENDED_MODEL_NAMES)
@@ -17,23 +12,6 @@ def test_recommended_models_are_accepted(model_name: str) -> None:
assert is_recommended_or_frontier_model(model_name)
def test_request_timeout_extra_args_positive() -> None:
assert request_timeout_extra_args(300) == {"timeout": 300}
assert request_timeout_extra_args(10) == {"timeout": 10}
def test_request_timeout_extra_args_survives_model_settings_json_dump() -> None:
"""The Chat Completions and LiteLLM paths pydantic-serialize ModelSettings for
their tracing span; a non-JSON-serializable timeout fails every turn there."""
settings = ModelSettings(extra_args=request_timeout_extra_args(300))
assert settings.to_json_dict()["extra_args"] == {"timeout": 300}
@pytest.mark.parametrize("value", [None, 0, -1])
def test_request_timeout_extra_args_disabled(value: float | None) -> None:
assert request_timeout_extra_args(value) is None
def test_recommended_models_are_matched_case_insensitively() -> None:
assert is_recommended_or_frontier_model("Vertex_AI/Gemini-3-Pro-Preview")
-156
View File
@@ -1,156 +0,0 @@
"""Tests for the shared Caido client lifecycle and proxy call serialization.
Covers the caching + serialization guarantees of ``caido_api.call_with_client``
(the sandbox-imported path) and ``proxy.tools._call`` (the host-side path). The
Caido GraphQL transport is not concurrency-safe, so both paths must run one
call at a time against the shared client.
"""
from __future__ import annotations
import asyncio
from typing import TYPE_CHECKING, Any, cast
import pytest
from strix.tools.proxy import caido_api, tools
if TYPE_CHECKING:
from collections.abc import Iterator
class _FakeClient:
def __init__(self, name: str) -> None:
self.name = name
self.closed = False
async def aclose(self) -> None:
self.closed = True
@pytest.fixture(autouse=True)
def _clear_cache() -> Iterator[None]:
caido_api._CLIENT_CACHE.clear()
yield
caido_api._CLIENT_CACHE.clear()
async def test_call_with_client_reuses_cached_client(monkeypatch: pytest.MonkeyPatch) -> None:
cached = _FakeClient("cached")
caido_api._CLIENT_CACHE["default"] = cast("Any", cached)
async def _new() -> Any:
raise AssertionError("_new_client must not run when a client is cached")
monkeypatch.setattr(caido_api, "_new_client", _new)
seen: dict[str, Any] = {}
async def fn(client: Any) -> str:
seen["client"] = client
return "ok"
assert await caido_api.call_with_client(fn) == "ok"
assert seen["client"] is cached
async def test_call_with_client_creates_and_caches_when_empty(
monkeypatch: pytest.MonkeyPatch,
) -> None:
created = _FakeClient("fresh")
async def _new() -> Any:
return created
monkeypatch.setattr(caido_api, "_new_client", _new)
seen: dict[str, Any] = {}
async def fn(client: Any) -> str:
seen["client"] = client
return "ok"
assert await caido_api.call_with_client(fn) == "ok"
assert seen["client"] is created
assert caido_api._CLIENT_CACHE["default"] is created
async def test_failed_init_does_not_poison_cache(monkeypatch: pytest.MonkeyPatch) -> None:
async def _new() -> Any:
raise ConnectionRefusedError("caido not up yet")
monkeypatch.setattr(caido_api, "_new_client", _new)
async def fn(_client: Any) -> str:
return "unreachable"
with pytest.raises(ConnectionRefusedError):
await caido_api.call_with_client(fn)
assert "default" not in caido_api._CLIENT_CACHE
async def test_call_with_client_propagates_errors() -> None:
cached = _FakeClient("cached")
caido_api._CLIENT_CACHE["default"] = cast("Any", cached)
async def fn(_client: Any) -> str:
raise ValueError("Invalid HTTPQL filter")
with pytest.raises(ValueError, match="Invalid HTTPQL"):
await caido_api.call_with_client(fn)
assert caido_api._CLIENT_CACHE["default"] is cached
async def test_call_with_client_serializes_concurrent_calls(
monkeypatch: pytest.MonkeyPatch,
) -> None:
caido_api._CLIENT_CACHE["default"] = cast("Any", _FakeClient("shared"))
async def _new() -> Any:
raise AssertionError("no new client expected")
monkeypatch.setattr(caido_api, "_new_client", _new)
state = {"active": 0, "max": 0}
async def fn(_client: Any) -> str:
state["active"] += 1
state["max"] = max(state["max"], state["active"])
await asyncio.sleep(0.01)
state["active"] -= 1
return "ok"
await asyncio.gather(*(caido_api.call_with_client(fn) for _ in range(6)))
assert state["max"] == 1
async def test_host_call_serializes_concurrent_calls() -> None:
client = _FakeClient("host")
state = {"active": 0, "max": 0}
async def fn(_client: Any) -> str:
state["active"] += 1
state["max"] = max(state["max"], state["active"])
await asyncio.sleep(0.01)
state["active"] -= 1
return "ok"
await asyncio.gather(*(tools._call(cast("Any", client), fn) for _ in range(6)))
assert state["max"] == 1
class _Ctx:
def __init__(self, context: Any) -> None:
self.context = context
def test_ctx_client_returns_client_when_present() -> None:
client = _FakeClient("host")
got = tools._ctx_client(cast("Any", _Ctx({"caido_client": client})))
assert got is client
def test_ctx_client_returns_none_without_client() -> None:
assert tools._ctx_client(cast("Any", _Ctx({}))) is None
assert tools._ctx_client(cast("Any", _Ctx(None))) is None
+3 -5
View File
@@ -37,9 +37,7 @@ async def test_persistent_rate_limit_stops_gracefully(
model="openai/gpt-4o",
reasoning_effort="high",
force_required_tool_choice=False,
timeout=300,
),
runtime=types.SimpleNamespace(max_context_images=3),
)
)
monkeypatch.setattr(runner, "load_settings", lambda: settings)
monkeypatch.setattr(runner, "configure_sdk_model_defaults", lambda _settings: None)
@@ -56,8 +54,8 @@ async def test_persistent_rate_limit_stops_gracefully(
async def _cleanup(*_args: Any, **_kwargs: Any) -> None:
return None
monkeypatch.setattr(runner.session_manager, "create_or_reuse", _create_or_reuse) # type: ignore[attr-defined]
monkeypatch.setattr(runner.session_manager, "cleanup", _cleanup) # type: ignore[attr-defined]
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: "")
+2 -2
View File
@@ -45,7 +45,6 @@ def _patch_engine_scaffold(
model="openai/gpt-4o",
reasoning_effort="high",
force_required_tool_choice=False,
timeout=300,
)
)
monkeypatch.setattr(runner, "load_settings", lambda: settings)
@@ -125,7 +124,8 @@ async def test_root_prompt_options_flow_into_root_agent(
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
"cannot expand, replace, or weaken authorized target constraints"
in instructions_override
)
assert kwargs["system_prompt_context"] == {
**scope_context,