mirror of
https://github.com/usestrix/strix.git
synced 2026-08-20 02:23:35 +02:00
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").
455 lines
14 KiB
Python
455 lines
14 KiB
Python
"""Shared Caido proxy helpers and sandbox-importable ``caido_api`` module."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import json
|
|
import os
|
|
import time
|
|
import urllib.request
|
|
from typing import TYPE_CHECKING, Any, Literal
|
|
from urllib.parse import parse_qs, urlencode, urlparse, urlunparse
|
|
|
|
from caido_sdk_client import Client, TokenAuthOptions
|
|
from caido_sdk_client.types import (
|
|
ConnectionInfoInput,
|
|
CreateScopeOptions,
|
|
ReplaySendOptions,
|
|
RequestGetOptions,
|
|
UpdateScopeOptions,
|
|
)
|
|
|
|
|
|
if TYPE_CHECKING:
|
|
from caido_sdk_client import Client as CaidoClient
|
|
|
|
|
|
RequestPart = Literal["request", "response"]
|
|
SortBy = Literal[
|
|
"timestamp",
|
|
"host",
|
|
"method",
|
|
"path",
|
|
"status_code",
|
|
"response_time",
|
|
"response_size",
|
|
"source",
|
|
]
|
|
SortOrder = Literal["asc", "desc"]
|
|
ScopeAction = Literal["get", "list", "create", "update", "delete"]
|
|
|
|
_DEFAULT_CAIDO_URL = "http://127.0.0.1:48080"
|
|
_CLIENT_CACHE: dict[str, Client] = {}
|
|
_REQ_FIELD_MAP: dict[SortBy, tuple[str, str]] = {
|
|
"timestamp": ("req", "created_at"),
|
|
"host": ("req", "host"),
|
|
"method": ("req", "method"),
|
|
"path": ("req", "path"),
|
|
"source": ("req", "source"),
|
|
"status_code": ("resp", "code"),
|
|
"response_time": ("resp", "roundtrip"),
|
|
"response_size": ("resp", "length"),
|
|
}
|
|
|
|
|
|
def caido_url() -> str:
|
|
"""Return the in-sandbox Caido endpoint used by ``caido_api``."""
|
|
return os.environ.get("STRIX_CAIDO_URL", _DEFAULT_CAIDO_URL).rstrip("/")
|
|
|
|
|
|
def _graphql_url() -> str:
|
|
base_url = caido_url()
|
|
parsed = urlparse(base_url)
|
|
if parsed.scheme not in {"http", "https"} or not parsed.netloc:
|
|
raise ValueError(f"Invalid Caido URL: {base_url}")
|
|
return f"{base_url}/graphql"
|
|
|
|
|
|
def _login_as_guest() -> str:
|
|
body = json.dumps({"query": "mutation { loginAsGuest { token { accessToken } } }"}).encode(
|
|
"utf-8"
|
|
)
|
|
req = urllib.request.Request( # noqa: S310
|
|
_graphql_url(),
|
|
data=body,
|
|
headers={"Content-Type": "application/json"},
|
|
method="POST",
|
|
)
|
|
with urllib.request.urlopen(req, timeout=10) as resp: # noqa: S310 # nosec B310
|
|
payload = json.loads(resp.read())
|
|
return str(payload["data"]["loginAsGuest"]["token"]["accessToken"])
|
|
|
|
|
|
async def get_client() -> Client:
|
|
"""Return a connected Caido SDK client for the local sandbox sidecar."""
|
|
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 close_client() -> None:
|
|
"""Close the cached sandbox Caido client, if one was opened."""
|
|
client = _CLIENT_CACHE.pop("default", None)
|
|
if client is None:
|
|
return
|
|
await client.aclose()
|
|
|
|
|
|
async def list_requests_with_client(
|
|
client: CaidoClient,
|
|
*,
|
|
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,
|
|
) -> Any:
|
|
builder = client.request.list().first(first)
|
|
if httpql_filter:
|
|
builder = builder.filter(httpql_filter)
|
|
if after:
|
|
builder = builder.after(after)
|
|
if scope_id:
|
|
builder = builder.scope(scope_id)
|
|
target, field = _REQ_FIELD_MAP[sort_by]
|
|
builder = (builder.descending if sort_order == "desc" else builder.ascending)(target, field)
|
|
return await builder.execute()
|
|
|
|
|
|
async def get_request_with_client(
|
|
client: CaidoClient,
|
|
request_id: str,
|
|
*,
|
|
part: RequestPart = "request",
|
|
) -> Any:
|
|
# 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)
|
|
|
|
|
|
def build_raw_request(
|
|
*,
|
|
method: str,
|
|
url: str,
|
|
headers: dict[str, str],
|
|
body: str,
|
|
) -> tuple[ConnectionInfoInput, bytes]:
|
|
parsed = urlparse(url)
|
|
if not parsed.scheme or not parsed.netloc:
|
|
raise ValueError(f"Invalid URL: {url}")
|
|
is_tls = parsed.scheme.lower() == "https"
|
|
host = parsed.hostname or ""
|
|
port = parsed.port or (443 if is_tls else 80)
|
|
path = parsed.path or "/"
|
|
if parsed.query:
|
|
path = f"{path}?{parsed.query}"
|
|
|
|
final_headers = {**headers}
|
|
final_headers.setdefault("Host", parsed.netloc)
|
|
final_headers.setdefault("User-Agent", "strix")
|
|
if body and "Content-Length" not in {k.title() for k in final_headers}:
|
|
final_headers["Content-Length"] = str(len(body.encode("utf-8")))
|
|
|
|
lines = [f"{method.upper()} {path} HTTP/1.1"]
|
|
lines.extend(f"{k}: {v}" for k, v in final_headers.items())
|
|
raw = ("\r\n".join(lines) + "\r\n\r\n" + body).encode("utf-8")
|
|
return ConnectionInfoInput(host=host, port=port, is_tls=is_tls), raw
|
|
|
|
|
|
def parse_raw_request(raw_content: str) -> dict[str, Any]:
|
|
lines = raw_content.split("\n")
|
|
request_line = lines[0].strip().split(" ")
|
|
if len(request_line) < 2:
|
|
raise ValueError("Invalid request line format")
|
|
method, url_path = request_line[0], request_line[1]
|
|
|
|
parsed_headers: dict[str, str] = {}
|
|
body_start = 0
|
|
for i, line in enumerate(lines[1:], 1):
|
|
if line.strip() == "":
|
|
body_start = i + 1
|
|
break
|
|
if ":" in line:
|
|
key, value = line.split(":", 1)
|
|
parsed_headers[key.strip()] = value.strip()
|
|
|
|
body = "\n".join(lines[body_start:]).strip() if body_start < len(lines) else ""
|
|
return {"method": method, "url_path": url_path, "headers": parsed_headers, "body": body}
|
|
|
|
|
|
def full_url_from_components(
|
|
original: Any,
|
|
components: dict[str, Any],
|
|
modifications: dict[str, Any],
|
|
) -> str:
|
|
if "url" in modifications:
|
|
return str(modifications["url"])
|
|
headers = components["headers"]
|
|
host_header = headers.get("Host") or original.host
|
|
scheme = "https" if original.is_tls else "http"
|
|
return f"{scheme}://{host_header}{components['url_path']}"
|
|
|
|
|
|
def apply_modifications(
|
|
components: dict[str, Any],
|
|
modifications: dict[str, Any],
|
|
full_url: str,
|
|
) -> dict[str, Any]:
|
|
headers = dict(components["headers"])
|
|
body = components["body"]
|
|
final_url = full_url
|
|
|
|
if "params" in modifications:
|
|
parsed = urlparse(final_url)
|
|
existing = {k: v[0] if v else "" for k, v in parse_qs(parsed.query).items()}
|
|
existing.update(modifications["params"])
|
|
final_url = urlunparse(parsed._replace(query=urlencode(existing)))
|
|
if "headers" in modifications:
|
|
headers.update(modifications["headers"])
|
|
if "body" in modifications:
|
|
body = modifications["body"]
|
|
if "cookies" in modifications:
|
|
cookies: dict[str, str] = {}
|
|
if headers.get("Cookie"):
|
|
for cookie in headers["Cookie"].split(";"):
|
|
if "=" in cookie:
|
|
k, v = cookie.split("=", 1)
|
|
cookies[k.strip()] = v.strip()
|
|
cookies.update(modifications["cookies"])
|
|
headers["Cookie"] = "; ".join(f"{k}={v}" for k, v in cookies.items())
|
|
|
|
return {
|
|
"method": components["method"],
|
|
"url": final_url,
|
|
"headers": headers,
|
|
"body": body,
|
|
}
|
|
|
|
|
|
# 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,
|
|
*,
|
|
raw: bytes,
|
|
connection: ConnectionInfoInput,
|
|
) -> dict[str, Any]:
|
|
started = time.time()
|
|
# 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)
|
|
# ``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,
|
|
"error": result.error,
|
|
"elapsed_ms": elapsed_ms,
|
|
"response_raw": response_raw,
|
|
}
|
|
|
|
|
|
async def scope_list(client: CaidoClient) -> Any:
|
|
return await client.scope.list()
|
|
|
|
|
|
async def scope_get(client: CaidoClient, scope_id: str) -> Any:
|
|
return await client.scope.get(scope_id)
|
|
|
|
|
|
async def scope_create(
|
|
client: CaidoClient,
|
|
*,
|
|
name: str,
|
|
allowlist: list[str] | None = None,
|
|
denylist: list[str] | None = None,
|
|
) -> Any:
|
|
return await client.scope.create(
|
|
CreateScopeOptions(
|
|
name=name,
|
|
allowlist=list(allowlist or []),
|
|
denylist=list(denylist or []),
|
|
),
|
|
)
|
|
|
|
|
|
async def scope_update(
|
|
client: CaidoClient,
|
|
scope_id: str,
|
|
*,
|
|
name: str,
|
|
allowlist: list[str] | None = None,
|
|
denylist: list[str] | None = None,
|
|
) -> Any:
|
|
return await client.scope.update(
|
|
scope_id,
|
|
UpdateScopeOptions(
|
|
name=name,
|
|
allowlist=list(allowlist or []),
|
|
denylist=list(denylist or []),
|
|
),
|
|
)
|
|
|
|
|
|
async def scope_delete(client: CaidoClient, scope_id: str) -> None:
|
|
await client.scope.delete(scope_id)
|
|
|
|
|
|
async def list_requests(
|
|
*,
|
|
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,
|
|
) -> Any:
|
|
"""List captured HTTP requests from sandbox Python."""
|
|
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 one captured request/response from sandbox Python."""
|
|
return await get_request_with_client(await get_client(), request_id, part=part)
|
|
|
|
|
|
async def repeat_request(
|
|
request_id: str,
|
|
*,
|
|
modifications: dict[str, Any] | None = None,
|
|
) -> dict[str, Any]:
|
|
"""Replay a captured request after applying request modifications."""
|
|
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")
|
|
|
|
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(
|
|
action: ScopeAction,
|
|
*,
|
|
allowlist: list[str] | None = None,
|
|
denylist: list[str] | None = None,
|
|
scope_id: str | None = None,
|
|
scope_name: str | None = None,
|
|
) -> Any:
|
|
"""Manage Caido scope rules from sandbox Python."""
|
|
client = await get_client()
|
|
if action == "list":
|
|
result = await scope_list(client)
|
|
elif action == "get":
|
|
if not scope_id:
|
|
raise ValueError("scope_id required for get")
|
|
result = await scope_get(client, scope_id)
|
|
elif action == "create":
|
|
if not scope_name:
|
|
raise ValueError("scope_name required for create")
|
|
result = await scope_create(
|
|
client,
|
|
name=scope_name,
|
|
allowlist=allowlist,
|
|
denylist=denylist,
|
|
)
|
|
elif action == "update":
|
|
if not scope_id or not scope_name:
|
|
raise ValueError("scope_id and scope_name required for update")
|
|
result = await scope_update(
|
|
client,
|
|
scope_id,
|
|
name=scope_name,
|
|
allowlist=allowlist,
|
|
denylist=denylist,
|
|
)
|
|
elif action == "delete":
|
|
if not scope_id:
|
|
raise ValueError("scope_id required for delete")
|
|
await scope_delete(client, scope_id)
|
|
result = {"deleted": scope_id}
|
|
else:
|
|
raise ValueError(f"Unknown action: {action}")
|
|
return result
|
|
|
|
|
|
__all__ = [
|
|
"RequestPart",
|
|
"ScopeAction",
|
|
"SortBy",
|
|
"SortOrder",
|
|
"close_client",
|
|
"get_client",
|
|
"list_requests",
|
|
"repeat_request",
|
|
"scope_rules",
|
|
"view_request",
|
|
]
|