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>
This commit is contained in:
0xallam
2026-05-24 19:16:06 -07:00
parent 940319f28a
commit 5921fec16c
7 changed files with 85 additions and 169 deletions
+51 -34
View File
@@ -13,8 +13,6 @@ from urllib.parse import parse_qs, urlencode, urlparse, urlunparse
from caido_sdk_client import Client, TokenAuthOptions
from caido_sdk_client.types import (
ConnectionInfoInput,
CreateReplaySessionFromRaw,
CreateReplaySessionOptions,
CreateScopeOptions,
ReplaySendOptions,
RequestGetOptions,
@@ -130,10 +128,13 @@ async def get_request_with_client(
*,
part: RequestPart = "request",
) -> Any:
opts = RequestGetOptions(
request_raw=(part == "request"),
response_raw=(part == "response"),
)
# The Caido SDK's generated pydantic model marks Request.raw and
# Response.raw as required strings even though the GraphQL fragment
# makes them conditional via `@include(if: $includeRequestRaw)`.
# Passing False for either causes pydantic validation to fail with
# "Field required" on the missing raw field. Always request both —
# the caller picks which one to surface via ``part``.
opts = RequestGetOptions(request_raw=True, response_raw=True)
return await client.request.get(request_id, opts)
@@ -236,6 +237,15 @@ def apply_modifications(
}
# Hard wall-clock bound on a single replay dispatch. Caido's Replay
# API has no built-in send-side timeout, so a stalled connection
# (unroutable target, slow loopback, etc.) hangs the caller until the
# function_tool wrapper's 120s budget expires — by which point we've
# lost any useful error context. 30s is generous for legitimate HTTP
# and short enough that the model can decide to retry rather than wait.
_REPLAY_SEND_TIMEOUT_SECONDS = 30.0
async def replay_send_raw(
client: CaidoClient,
*,
@@ -243,17 +253,42 @@ async def replay_send_raw(
connection: ConnectionInfoInput,
) -> dict[str, Any]:
started = time.time()
session = await client.replay.sessions.create(
CreateReplaySessionOptions(
request_source=CreateReplaySessionFromRaw(raw=raw, connection=connection),
),
)
result = await client.replay.send(
session.id,
ReplaySendOptions(raw=raw, connection=connection),
)
# Create an empty replay session, then dispatch via ``send()``.
# Passing ``CreateReplaySessionFromRaw`` here would also seed a stored
# entry on the server side, leading the caller to observe two history
# rows per call (one without response from the create-step seed, one
# with response from the actual send). The empty-create + send flow
# produces exactly one dispatched request.
session = await client.replay.sessions.create()
try:
result = await asyncio.wait_for(
client.replay.send(
session.id,
ReplaySendOptions(raw=raw, connection=connection),
),
timeout=_REPLAY_SEND_TIMEOUT_SECONDS,
)
except TimeoutError:
elapsed_ms = int((time.time() - started) * 1000)
return {
"session_id": str(session.id),
"status": "ERROR",
"error": (
f"Caido replay dispatch did not complete within "
f"{_REPLAY_SEND_TIMEOUT_SECONDS:.0f}s. The target may be "
"unroutable from the sandbox, or Caido's outbound HTTP client "
"is stalled. Check the target host/port and retry."
),
"elapsed_ms": elapsed_ms,
"response_raw": None,
}
elapsed_ms = int((time.time() - started) * 1000)
response_raw = result.entry.response_raw if hasattr(result.entry, "response_raw") else None
# ``result.entry.response`` is the parsed Response (with ``.raw`` bytes
# when ``includeResponseRaw`` was True, which is the entries SDK's
# default). The previous ``result.entry.response_raw`` lookup matched
# no attribute on ReplayEntry and silently returned ``None``.
response = getattr(result.entry, "response", None)
response_raw = getattr(response, "raw", None) if response is not None else None
return {
"session_id": str(session.id),
"status": result.status,
@@ -335,23 +370,6 @@ async def view_request(request_id: str, *, part: RequestPart = "request") -> Any
return await get_request_with_client(await get_client(), request_id, part=part)
async def send_request(
method: str,
url: str,
*,
headers: dict[str, str] | None = None,
body: str = "",
) -> dict[str, Any]:
"""Send an arbitrary raw HTTP request through Caido Replay."""
connection, raw = build_raw_request(
method=method,
url=url,
headers=headers or {},
body=body,
)
return await replay_send_raw(await get_client(), raw=raw, connection=connection)
async def repeat_request(
request_id: str,
*,
@@ -432,6 +450,5 @@ __all__ = [
"list_requests",
"repeat_request",
"scope_rules",
"send_request",
"view_request",
]
+22 -55
View File
@@ -1,12 +1,15 @@
"""Caido proxy tools — host-side ``@function_tool`` wrappers.
The five tools delegate to :mod:`strix.tools.proxy.caido_api` for the actual
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``, ``send_request``,
``repeat_request``, ``scope_rules``.
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
@@ -160,6 +163,21 @@ async def list_requests(
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,
@@ -173,17 +191,7 @@ async def list_requests(
"is_tls": req.is_tls,
"created_at": req.created_at.isoformat(),
},
"response": (
{
"id": resp.id,
"status_code": resp.status_code,
"length": resp.length,
"roundtrip_ms": resp.roundtrip_time,
"created_at": resp.created_at.isoformat(),
}
if resp is not None
else None
),
"response": response_payload,
},
)
@@ -326,47 +334,6 @@ def _format_text_page(content: str, *, page: int, page_size: int) -> dict[str, A
}
# ----------------------------------------------------------------------
# send_request
# ----------------------------------------------------------------------
@function_tool(timeout=120, strict_mode=False)
async def send_request(
ctx: RunContextWrapper,
method: str,
url: str,
headers: dict[str, str] | None = None,
body: str = "",
timeout: int = 30,
) -> str:
"""Send an arbitrary HTTP request through the Caido proxy.
Use this for one-off probes (test endpoints, reach external APIs).
For modifying-and-replaying a request you've already captured, use
``repeat_request`` instead — it inherits the original headers /
cookies / auth and only patches the fields you specify.
Args:
method: ``"GET"`` / ``"POST"`` / ``"PUT"`` / ``"DELETE"`` / etc.
url: Full URL with protocol.
headers: Optional header dict.
body: Optional request body string.
timeout: Per-request timeout in seconds (default 30).
"""
del timeout # The SDK applies its own timeout via the GraphQL settings.
client = _ctx_client(ctx)
if client is None:
return _no_client()
try:
connection, raw = caido_api.build_raw_request(
method=method, url=url, headers=headers or {}, body=body
)
result = await caido_api.replay_send_raw(client, raw=raw, connection=connection)
return _format_replay_tool_result(result)
except Exception as exc: # noqa: BLE001
return _err("send_request", exc)
# ----------------------------------------------------------------------
# repeat_request
# ----------------------------------------------------------------------