Files
strix/strix/tools/proxy/tools.py
T
0xallam 5921fec16c Proxy tool sweep: drop send_request, fix Caido SDK gotchas
send_request was a thin wrapper over the Caido Replay API that the model
could replicate with a one-liner `curl` via exec_command. The sandbox's
HTTP_PROXY env captures all such traffic for free, so the tool was
adding bugs (duplicate dispatch, dropped responses) without adding
capability. Removed across factory, tools module, sandbox-importable
caido_api helper, TUI renderer, prompt template, skill doc, and public
docs. repeat_request stays — it operates on captured request IDs with
structured modifications, which curl can't replicate cleanly.

Three caido-sdk-client workarounds that were hitting us through both
send_request and repeat_request:

- replay_send_raw used to pass CreateReplaySessionFromRaw to
  sessions.create(), which seeds a stored entry server-side, then
  called send() — producing two history rows per call. Empty-create +
  send produces one dispatched request.
- The same helper read result.entry.response_raw, an attribute that
  doesn't exist on ReplayEntry, so response bytes were silently
  dropped. Fixed to walk result.entry.response.raw with proper None
  guards.
- get_request_with_client passed include_request_raw / include_response_raw
  based on the requested part, but the SDK's generated pydantic models
  declare raw as required even though the GraphQL fragment makes it
  conditional via @include. Passing False crashed view_request with a
  pydantic validation error. Always request both raw bodies; the caller
  picks which to surface.

Also wrapped replay.send() in asyncio.wait_for(30s) so a stalled Caido
dispatch (notably loopback targets that don't route cleanly through the
sandbox proxy) fails fast with a model-readable error instead of
hanging the agent until the function_tool 120s budget expires.

Finally, list_requests now omits the roundtrip_ms field when Caido
reports 0 — proxy-captured unscoped traffic consistently reports 0
while scoped/replay traffic carries real measurements, so the absence
of the field is now informative ("Caido didn't measure this") rather
than misleading ("this request took 0ms").

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 19:16:06 -07:00

533 lines
19 KiB
Python

"""Caido proxy tools — host-side ``@function_tool`` wrappers.
The four tools delegate to :mod:`strix.tools.proxy.caido_api` for the actual
caido-sdk-client work and add LLM-friendly JSON serialization + error
wrapping on top. The delegated ``caido_api.py`` module is also copied into
the sandbox image as the importable ``caido_api`` Python module.
Tools: ``list_requests``, ``view_request``, ``repeat_request``,
``scope_rules``. Arbitrary one-off requests should be made through
``exec_command`` (e.g. ``curl``) — they're captured automatically via
the sandbox's ``HTTP_PROXY`` env, so wrapping them in a Strix tool only
adds an extra layer of indirection.
"""
from __future__ import annotations
import dataclasses
import json
import logging
import re
from dataclasses import is_dataclass
from datetime import datetime
from typing import TYPE_CHECKING, Any, Literal
from agents import RunContextWrapper, function_tool
from strix.tools.proxy import caido_api
logger = logging.getLogger(__name__)
if TYPE_CHECKING:
from caido_sdk_client import Client
from strix.tools.proxy.caido_api import RequestPart, SortBy, SortOrder
else:
# Runtime import: ``function_tool`` resolves the annotations via
# ``typing.get_type_hints`` so the Literal aliases must be reachable
# in module globals at decoration time even though they're "only"
# used in annotations.
from strix.tools.proxy.caido_api import ( # noqa: TC001
RequestPart,
SortBy,
SortOrder,
)
ScopeAction = Literal["get", "list", "create", "update", "delete"]
def _ctx_client(ctx: RunContextWrapper) -> Client | None:
inner = ctx.context if isinstance(ctx.context, dict) else {}
return inner.get("caido_client")
# Tool-output formatting. Caido SDK returns typed Python objects; function
# tools need compact JSON-safe values for the model and TUI.
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):
return value
if isinstance(value, bytes):
try:
return value.decode("utf-8", errors="replace")
except Exception: # noqa: BLE001
return value.hex()
if isinstance(value, datetime):
return value.isoformat()
if is_dataclass(value) and not isinstance(value, type):
return {k: _to_tool_json(v) for k, v in dataclasses.asdict(value).items()}
if hasattr(value, "model_dump"):
return _to_tool_json(value.model_dump())
if isinstance(value, dict):
return {str(k): _to_tool_json(v) for k, v in value.items()}
if isinstance(value, list | tuple | set):
return [_to_tool_json(v) for v in value]
return str(value)
def _no_client() -> str:
return json.dumps(
{"success": False, "error": "Caido client not available in run context"},
ensure_ascii=False,
default=str,
)
def _err(name: str, exc: Exception) -> str:
logger.exception("%s failed", name)
return json.dumps(
{"success": False, "error": f"{name} failed: {exc}"},
ensure_ascii=False,
default=str,
)
# ----------------------------------------------------------------------
# list_requests
# ----------------------------------------------------------------------
@function_tool(timeout=120)
async def list_requests(
ctx: RunContextWrapper,
httpql_filter: str | None = None,
first: int = 50,
after: str | None = None,
sort_by: SortBy = "timestamp",
sort_order: SortOrder = "desc",
scope_id: str | None = None,
) -> str:
"""List captured HTTP requests from the Caido proxy with HTTPQL filtering.
Caido HTTPQL syntax (operators differ by field type):
- **Integer fields** (``resp.code``, ``req.port``, ``id``,
``roundtrip``) — ``eq``, ``gt``, ``gte``, ``lt``, ``lte``, ``ne``.
Examples: ``resp.code.eq:200``, ``resp.code.gte:400``,
``req.port.eq:443``.
- **Text/byte fields** (``req.method``, ``req.host``, ``req.path``,
``req.query``, ``req.ext``, ``req.raw``) — ``regex``, ``cont``
(substring), ``eq``. Examples: ``req.method.eq:"POST"``,
``req.path.cont:"/api/"``, ``req.host.regex:".*\\.example\\.com"``.
- **Date fields** (``req.created_at``) — ``gt``, ``lt`` with ISO
timestamps: ``req.created_at.gt:"2024-01-01T00:00:00Z"``.
- **Combine** with ``AND`` / ``OR``: ``req.method.eq:"POST" AND
resp.code.gte:400``.
- **Special**: ``source:intercept`` (only intercepted requests),
``preset:"name"``.
For sitemap-style tree traversal use HTTPQL filters: drill into a
host with ``req.host.eq:"example.com"`` then narrow paths with
``req.path.cont:"/api/"``.
Pagination is cursor-based. Pass the ``end_cursor`` from the
``page_info`` of one call as ``after`` to the next.
Args:
httpql_filter: Caido HTTPQL query (optional).
first: Number of entries to return (default 50).
after: Cursor from a previous response's ``page_info.end_cursor``.
sort_by: One of ``timestamp`` / ``host`` / ``method`` / ``path``
/ ``status_code`` / ``response_time`` / ``response_size``
/ ``source``.
sort_order: ``asc`` or ``desc``.
scope_id: Restrict to a Caido scope (managed via ``scope_rules``).
"""
client = _ctx_client(ctx)
if client is None:
return _no_client()
try:
connection = await 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,
)
entries = []
for edge in connection.edges:
req = edge.node.request
resp = edge.node.response
response_payload: dict[str, Any] | None = None
if resp is not None:
response_payload = {
"id": resp.id,
"status_code": resp.status_code,
"length": resp.length,
"created_at": resp.created_at.isoformat(),
}
# Caido populates ``roundtripTime`` for some traffic sources
# and leaves it as ``0`` for others (notably proxy captures
# of upstream env-routed traffic). Surface the value only
# when it's actually measured so the model doesn't waste
# tokens reading a zero field on every entry.
if resp.roundtrip_time:
response_payload["roundtrip_ms"] = resp.roundtrip_time
entries.append(
{
"cursor": edge.cursor,
"request": {
"id": req.id,
"host": req.host,
"port": req.port,
"method": req.method,
"path": req.path,
"query": req.query,
"is_tls": req.is_tls,
"created_at": req.created_at.isoformat(),
},
"response": response_payload,
},
)
return json.dumps(
{
"success": True,
"entries": entries,
"page_info": {
"has_next_page": connection.page_info.has_next_page,
"has_previous_page": connection.page_info.has_previous_page,
"start_cursor": connection.page_info.start_cursor,
"end_cursor": connection.page_info.end_cursor,
},
},
ensure_ascii=False,
default=str,
)
except Exception as exc: # noqa: BLE001
return _err("list_requests", exc)
# ----------------------------------------------------------------------
# view_request
# ----------------------------------------------------------------------
@function_tool(timeout=60)
async def view_request(
ctx: RunContextWrapper,
request_id: str,
part: RequestPart = "request",
search_pattern: str | None = None,
page: int = 1,
page_size: int = 50,
) -> str:
"""View a captured request or its response, optionally regex-searched.
Two modes:
- **With** ``search_pattern`` (compact regex hits) — returns up to 20
matches with ``before`` / ``after`` context and position. Useful
for hunting reflected input, leaked URLs, hidden parameters.
- **Without** ``search_pattern`` (full content with line pagination)
— returns the page of raw content plus ``has_more`` flag.
Common search patterns:
- API endpoints: ``/api/[a-zA-Z0-9._/-]+``
- URLs: ``https?://[^\\s<>"']+``
- Query parameters: ``[?&][a-zA-Z0-9_]+=([^&\\s<>"']+)``
- Specific input reflection: search for the value you submitted.
Args:
request_id: Request ID from ``list_requests``.
part: ``"request"`` or ``"response"``.
search_pattern: Optional regex; switches the response shape to
compact hits.
page: 1-indexed page number (only when no ``search_pattern``).
page_size: Lines per page.
"""
client = _ctx_client(ctx)
if client is None:
return _no_client()
try:
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"},
ensure_ascii=False,
default=str,
)
raw_bytes = (
result.request.raw
if part == "request"
else (result.response.raw if result.response is not None else None)
)
if raw_bytes is None:
return json.dumps(
{
"success": False,
"error": f"No raw {part} for {request_id}",
},
ensure_ascii=False,
default=str,
)
content = raw_bytes.decode("utf-8", errors="replace")
if search_pattern:
return json.dumps(
_format_search_hits(content, search_pattern),
ensure_ascii=False,
default=str,
)
return json.dumps(
_format_text_page(content, page=page, page_size=page_size),
ensure_ascii=False,
default=str,
)
except Exception as exc: # noqa: BLE001
return _err("view_request", exc)
def _format_search_hits(content: str, pattern: str) -> dict[str, Any]:
try:
regex = re.compile(pattern)
except re.error as exc:
return {"success": False, "error": f"Invalid regex: {exc}"}
hits = []
for match in regex.finditer(content):
start, end = match.span()
before = content[max(0, start - 40) : start]
after = content[end : end + 40]
hits.append(
{
"match": match.group(0),
"position": start,
"before": before,
"after": after,
},
)
if len(hits) >= 20:
break
return {"success": True, "hits": hits, "total_hits": len(hits)}
def _format_text_page(content: str, *, page: int, page_size: int) -> dict[str, Any]:
lines = content.splitlines()
start = max(0, (page - 1) * page_size)
end = start + page_size
return {
"success": True,
"content": "\n".join(lines[start:end]),
"page": page,
"page_size": page_size,
"total_lines": len(lines),
"has_more": end < len(lines),
}
# ----------------------------------------------------------------------
# repeat_request
# ----------------------------------------------------------------------
@function_tool(timeout=120, strict_mode=False)
async def repeat_request(
ctx: RunContextWrapper,
request_id: str,
modifications: dict[str, Any] | None = None,
) -> str:
"""Repeat a captured request, optionally patching individual fields.
The standard pentesting workflow with this tool:
1. ``agent-browser`` (via ``exec_command``) or live target traffic
→ request gets captured by Caido.
2. ``list_requests`` → find the request ID you want to manipulate.
3. ``repeat_request`` → send a modified version (auth-bypass test,
payload injection, parameter tampering).
Mirrors the manual "browse → capture → modify → test" flow used in
real pentesting. Inherits everything from the original request
(headers, cookies, auth, method, URL) and overlays only the fields
you specify in ``modifications``.
Args:
request_id: ID of the original request (from ``list_requests``).
modifications: Patch dict. Recognized keys:
- ``url`` — replace the URL.
- ``params`` — dict of query-string keys to add/update.
- ``headers`` — dict of headers to add/update.
- ``body`` — replace the body string entirely.
- ``cookies`` — dict of cookies to add/update.
"""
client = _ctx_client(ctx)
if client is None:
return _no_client()
mods = modifications or {}
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 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)
full_url = caido_api.full_url_from_components(original, components, mods)
modified = caido_api.apply_modifications(components, mods, full_url)
connection, raw = caido_api.build_raw_request(
method=modified["method"],
url=modified["url"],
headers=modified["headers"],
body=modified["body"],
)
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)
def _format_replay_tool_result(replay: dict[str, Any]) -> str:
response_raw = replay.get("response_raw")
response: dict[str, Any] | None = None
if response_raw is not None:
response = {"raw": response_raw.decode("utf-8", errors="replace")}
return json.dumps(
{
"success": replay["status"] == "DONE",
"status": replay["status"],
"error": replay["error"],
"session_id": replay["session_id"],
"elapsed_ms": replay["elapsed_ms"],
"response": response,
},
ensure_ascii=False,
default=str,
)
# ----------------------------------------------------------------------
# scope_rules
# ----------------------------------------------------------------------
@function_tool(timeout=60)
async def scope_rules(
ctx: RunContextWrapper,
action: ScopeAction,
allowlist: list[str] | None = None,
denylist: list[str] | None = None,
scope_id: str | None = None,
scope_name: str | None = None,
) -> str:
"""CRUD on Caido scope rules (allow/deny patterns).
Scopes filter which traffic Caido tools see. Use them to focus on a
target, exclude noisy assets (CDNs, static files), or define a
bug-bounty allowlist.
Pattern semantics:
- Glob wildcards: ``*`` (any), ``?`` (single), ``[abc]`` (one of),
``[a-z]`` (range), ``[^abc]`` (none of).
- **Empty allowlist = allow all domains.**
- **Denylist always overrides allowlist.**
Common denylist for noisy static assets:
``["*.gif", "*.jpg", "*.png", "*.css", "*.js", "*.ico", "*.svg",
"*woff*", "*.ttf"]``.
Each scope has a unique id usable as ``scope_id`` in
``list_requests``.
Args:
action:
- ``list`` — return all scopes.
- ``get`` — single scope by ``scope_id``.
- ``create`` — needs ``scope_name``, optionally
``allowlist`` / ``denylist``.
- ``update`` — needs ``scope_id`` + ``scope_name``;
allowlist / denylist replace the previous values.
- ``delete`` — needs ``scope_id``.
allowlist: Domain patterns to include (e.g.
``["*.example.com", "api.test.com"]``).
denylist: Patterns to exclude.
scope_id: Required for ``get`` / ``update`` / ``delete``.
scope_name: Required for ``create`` / ``update``.
"""
client = _ctx_client(ctx)
if client is None:
return _no_client()
try:
if action == "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,
default=str,
)
if action == "get":
if not scope_id:
return json.dumps(
{"success": False, "error": "scope_id required for get"},
ensure_ascii=False,
default=str,
)
scope = await caido_api.scope_get(client, scope_id)
return json.dumps(
{"success": True, "scope": _to_tool_json(scope)}, ensure_ascii=False, default=str
)
if action == "create":
if not scope_name:
return json.dumps(
{"success": False, "error": "scope_name required for create"},
ensure_ascii=False,
default=str,
)
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
)
if action == "update":
if not scope_id or not scope_name:
return json.dumps(
{
"success": False,
"error": "scope_id and scope_name required for update",
},
ensure_ascii=False,
default=str,
)
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
)
# action == "delete" — exhaustive Literal
if not scope_id:
return json.dumps(
{"success": False, "error": "scope_id required for delete"},
ensure_ascii=False,
default=str,
)
await caido_api.scope_delete(client, scope_id)
return json.dumps({"success": True, "deleted": scope_id}, ensure_ascii=False, default=str)
except Exception as exc: # noqa: BLE001
return _err("scope_rules", exc)