refactor: flatten CaidoCapability into direct wiring

The custom ``Capability`` subclass was 207 LoC bundling four tiny
concerns (env-var injection, tool exposure, system-prompt block,
healthcheck) — and three of them were dead code: the SDK's
``SandboxRunConfig`` doesn't accept capabilities, so
``process_manifest``, ``tools()``, and ``instructions()`` were never
called. Only ``bind()`` ran, because we invoked it manually.

Replace each piece with the obvious direct equivalent:

- **Env vars**: inject ``http_proxy`` / ``https_proxy`` / ``ALL_PROXY``
  directly into the manifest in ``session_manager.create_or_reuse``.
  This *also fixes a latent bug* — the proxy env vars in
  ``CaidoCapability.process_manifest`` weren't being applied to live
  containers, so shelled-out HTTP traffic from terminal/python tools
  wasn't actually flowing through Caido.
- **Tool exposure**: add the seven Caido tools (``list_requests``,
  ``view_request``, ``send_request``, ``repeat_request``,
  ``scope_rules``, ``list_sitemap``, ``view_sitemap_entry``) to
  ``_BASE_TOOLS`` in ``agents/factory.py`` like every other sandbox
  tool. They were already defined in ``tools/proxy/tools.py``.
- **Healthcheck**: ``entry.py`` now ``await``s
  ``wait_for_http_ready`` + ``wait_for_tcp_ready`` inline after
  ``session_manager.create_or_reuse`` returns, before any agent runs.
  No more capability state, ``configure_host_ports`` plumbing, or
  ``on_agent_start`` await-the-task indirection.
- **Instructions block**: dropped. The seven proxy tools' docstrings
  cover the HTTPQL syntax and usage already; the duplicate prompt
  fragment was overhead.

Cascade cleanups:
- Drop ``caido_capability`` from the agent context (was passed to
  every ``make_agent_context`` call but only used by the now-deleted
  ``on_agent_start`` await).
- Strip the capability await branch from
  ``StrixOrchestrationHooks.on_agent_start``; that hook now does only
  the ``tracer.agents`` mirroring it always should have.
- Drop the ``capability`` key from the session bundle.
- Drop ``strix/sandbox/caido_capability.py`` — entire file (207 LoC).
- Drop the per-file ruff ignore for the deleted file.

mypy clean on every touched file. Net -217 LoC.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
0xallam
2026-04-25 13:32:11 -07:00
parent 12baf2d792
commit df51eeedd0
9 changed files with 55 additions and 272 deletions
-3
View File
@@ -249,9 +249,6 @@ ignore = [
"strix/tools/proxy/tools.py" = ["TC002"] "strix/tools/proxy/tools.py" = ["TC002"]
"strix/tools/agents_graph/tools.py" = ["TC002"] "strix/tools/agents_graph/tools.py" = ["TC002"]
"strix/agents/factory.py" = ["TC002"] "strix/agents/factory.py" = ["TC002"]
# CaidoCapability uses agents.tool.Tool at runtime — pydantic Field
# annotations and the cached _CAIDO_TOOLS tuple need it eagerly.
"strix/sandbox/caido_capability.py" = ["TC002"]
# Entry point: ``Path`` is used at runtime by the typing of the # Entry point: ``Path`` is used at runtime by the typing of the
# session_manager call; importing under TYPE_CHECKING would defer # session_manager call; importing under TYPE_CHECKING would defer
# resolution past where mypy needs it. ``_build_root_task`` legitimately # resolution past where mypy needs it. ``_build_root_task`` legitimately
+22 -9
View File
@@ -1,8 +1,7 @@
"""``build_strix_agent`` — assemble an ``agents.Agent`` for root or child. """``build_strix_agent`` — assemble an ``agents.Agent`` for root or child.
Wires the SDK function tools, multi-agent graph tools, Wires the SDK function tools, multi-agent graph tools, and the rendered
``CaidoCapability``, and the rendered Jinja prompt into one Jinja prompt into one ``agents.Agent`` ready for ``Runner.run``.
``agents.Agent`` ready for ``Runner.run``.
Two flavors: Two flavors:
@@ -13,9 +12,8 @@ Two flavors:
there — without ``stop_at_tool_names`` the SDK loop would keep there — without ``stop_at_tool_names`` the SDK loop would keep
running to ``max_turns`` even after the child reported back. running to ``max_turns`` even after the child reported back.
Caido tools come from ``CaidoCapability.tools()`` via the SDK's Skills are baked into the system prompt at scan bring-up; there's no
capability merge — we don't list them here. Skills are baked into the runtime skill-loading tool.
system prompt at scan bring-up; there's no runtime skill-loading tool.
""" """
from __future__ import annotations from __future__ import annotations
@@ -50,6 +48,15 @@ from strix.tools.notes.tools import (
list_notes, list_notes,
update_note, update_note,
) )
from strix.tools.proxy.tools import (
list_requests,
list_sitemap,
repeat_request,
scope_rules,
send_request,
view_request,
view_sitemap_entry,
)
from strix.tools.python.tool import python_action from strix.tools.python.tool import python_action
from strix.tools.reporting.tool import create_vulnerability_report from strix.tools.reporting.tool import create_vulnerability_report
from strix.tools.terminal.tool import terminal_execute from strix.tools.terminal.tool import terminal_execute
@@ -68,9 +75,7 @@ from strix.tools.web_search.tool import web_search
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Tools every Strix agent has, root or child. The Caido proxy tools # Tools every Strix agent has, root or child.
# (list_requests, view_request, send_request, ...) are NOT here —
# CaidoCapability.tools() returns them and the SDK merges them in.
_BASE_TOOLS: tuple[Tool, ...] = ( _BASE_TOOLS: tuple[Tool, ...] = (
# Thinking + planning # Thinking + planning
think, think,
@@ -101,6 +106,14 @@ _BASE_TOOLS: tuple[Tool, ...] = (
browser_action, browser_action,
terminal_execute, terminal_execute,
python_action, python_action,
# Caido HTTP/HTTPS proxy
list_requests,
view_request,
send_request,
repeat_request,
scope_rules,
list_sitemap,
view_sitemap_entry,
# Multi-agent graph tools (the bus is in ctx.context) # Multi-agent graph tools (the bus is in ctx.context)
view_agent_graph, view_agent_graph,
agent_status, agent_status,
+16 -1
View File
@@ -15,6 +15,7 @@
from __future__ import annotations from __future__ import annotations
import asyncio
import logging import logging
import uuid import uuid
from pathlib import Path from pathlib import Path
@@ -34,6 +35,7 @@ from strix.run_config_factory import (
make_run_config, make_run_config,
) )
from strix.sandbox import session_manager from strix.sandbox import session_manager
from strix.sandbox.healthcheck import wait_for_http_ready, wait_for_tcp_ready
from strix.telemetry.strix_processor import StrixTracingProcessor from strix.telemetry.strix_processor import StrixTracingProcessor
@@ -219,6 +221,20 @@ async def run_strix_scan(
sources_path=sources_path, sources_path=sources_path,
) )
# Wait for the in-container FastAPI tool server + Caido sidecar to
# come up before any agent fires its first tool call.
await asyncio.gather(
wait_for_http_ready(
f"http://127.0.0.1:{bundle['tool_server_host_port']}/health",
timeout=60.0,
),
wait_for_tcp_ready(
"127.0.0.1",
int(bundle["caido_host_port"]),
timeout=60.0,
),
)
try: try:
scan_mode = str(scan_config.get("scan_mode") or "deep") scan_mode = str(scan_config.get("scan_mode") or "deep")
is_whitebox = bool(scan_config.get("is_whitebox", False)) is_whitebox = bool(scan_config.get("is_whitebox", False))
@@ -254,7 +270,6 @@ async def run_strix_scan(
sandbox_token=bundle["bearer"], sandbox_token=bundle["bearer"],
tool_server_host_port=bundle["tool_server_host_port"], tool_server_host_port=bundle["tool_server_host_port"],
caido_host_port=bundle["caido_host_port"], caido_host_port=bundle["caido_host_port"],
caido_capability=bundle.get("capability"),
agent_id=root_id, agent_id=root_id,
parent_id=None, parent_id=None,
tracer=tracer, tracer=tracer,
+7 -22
View File
@@ -17,16 +17,13 @@ logger = logging.getLogger(__name__)
class StrixOrchestrationHooks(RunHooks[Any]): class StrixOrchestrationHooks(RunHooks[Any]):
"""Lifecycle hooks for Strix multi-agent runs. """Lifecycle hooks for Strix multi-agent runs.
Wires four concerns: Wires three concerns:
1. Turn-budget warnings injected into ``input_items`` at 85% and 1. Turn-budget warnings injected into ``input_items`` at 85% and
``N - 3`` of ``max_turns``. ``N - 3`` of ``max_turns``.
2. LLM usage recording into the bus + tracer. 2. LLM usage recording into the bus + tracer, plus mirroring the
3. Sandbox readiness: awaits the bus's agent tree into ``tracer.agents`` for the TUI.
``CaidoCapability._healthcheck_task`` on first agent start so 3. Subagent crash detection: if ``on_agent_end`` fires without
the agent doesn't fire tools before Caido and the tool server
are ready.
4. Subagent crash detection: if ``on_agent_end`` fires without
``agent_finish_called`` being set, posts a crash message to the ``agent_finish_called`` being set, posts a crash message to the
parent's inbox so the parent learns on its next turn instead of parent's inbox so the parent learns on its next turn instead of
waiting forever. waiting forever.
@@ -106,26 +103,14 @@ class StrixOrchestrationHooks(RunHooks[Any]):
context: AgentHookContext[Any], context: AgentHookContext[Any],
agent: Any, agent: Any,
) -> None: ) -> None:
# Two concerns wired together because they fire at the same # The TUI reads ``tracer.agents`` to render the agent tree;
# moment in the lifecycle: # mirror the bus state into the tracer here so the tree
# # populates as agents come online.
# 1. CaidoCapability is bound to the sandbox session, not the
# Agent (we use plain ``Agent``, not ``SandboxAgent``), so
# the healthcheck task gets stashed in the context dict at
# scan bring-up and we await it on first agent start.
# 2. The TUI reads ``tracer.agents`` to render the agent tree.
# We mirror the bus state into the tracer here so the tree
# actually shows live and historical agents.
del agent del agent
try: try:
ctx = context.context ctx = context.context
if not isinstance(ctx, dict): if not isinstance(ctx, dict):
return return
cap = ctx.get("caido_capability")
task = getattr(cap, "_healthcheck_task", None)
if task is not None:
await task
tracer = ctx.get("tracer") tracer = ctx.get("tracer")
bus = ctx.get("bus") bus = ctx.get("bus")
me = ctx.get("agent_id") me = ctx.get("agent_id")
-2
View File
@@ -126,7 +126,6 @@ def make_agent_context(
run_id: str | None = None, run_id: str | None = None,
sandbox_client: Any | None = None, sandbox_client: Any | None = None,
agent_factory: Any | None = None, agent_factory: Any | None = None,
caido_capability: Any | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Build the per-agent ``context`` dict passed to ``Runner.run(context=...)``. """Build the per-agent ``context`` dict passed to ``Runner.run(context=...)``.
@@ -146,7 +145,6 @@ def make_agent_context(
"sandbox_token": sandbox_token, "sandbox_token": sandbox_token,
"tool_server_host_port": tool_server_host_port, "tool_server_host_port": tool_server_host_port,
"caido_host_port": caido_host_port, "caido_host_port": caido_host_port,
"caido_capability": caido_capability,
"agent_id": agent_id, "agent_id": agent_id,
"parent_id": parent_id, "parent_id": parent_id,
"tracer": tracer, "tracer": tracer,
+1 -3
View File
@@ -1,8 +1,6 @@
"""Strix sandbox layer on top of OpenAI Agents SDK SandboxAgent / Manifest. """Strix sandbox layer on top of OpenAI Agents SDK SandboxAgent / Manifest.
- :mod:`.caido_capability` — Caido proxy + 7 GraphQL function tools - :mod:`.healthcheck` — ``wait_for_http_ready`` / ``wait_for_tcp_ready``.
+ system prompt block.
- :mod:`.healthcheck` — ``wait_for_ports_ready``.
- :mod:`.session_manager` — ``create_or_reuse`` / ``cleanup`` keyed - :mod:`.session_manager` — ``create_or_reuse`` / ``cleanup`` keyed
by scan id. by scan id.
""" """
-207
View File
@@ -1,207 +0,0 @@
"""CaidoCapability — sandbox capability for the Caido HTTP/HTTPS proxy.
Three concerns wired into the SDK's capability lifecycle:
1. **Manifest mutation** (``process_manifest``): inject ``http_proxy`` /
``https_proxy`` / ``ALL_PROXY`` env vars pointing at the in-container
Caido listener. Any tool that ultimately shells out (curl, requests,
etc.) now flows through the proxy automatically.
2. **Tool exposure** (``tools``): the seven Caido SDK function-tool
wrappers are returned here. The SDK runtime collects tools from
every capability and merges them with the agent's ``tools=[...]``
declaration, so agents don't have to redeclare them.
3. **Healthcheck task** (``bind``): when a session binds, we kick off
:func:`wait_for_http_ready` against the FastAPI tool server's
``/health`` endpoint and :func:`wait_for_tcp_ready` against the
Caido proxy port. The aggregated task handle is stored on
``self._healthcheck_task``, which the
:class:`StrixOrchestrationHooks.on_agent_start` hook awaits before
the first LLM call so the agent never hits a connection-refused
on its very first tool invocation.
"""
from __future__ import annotations
import asyncio
import logging
from typing import TYPE_CHECKING, ClassVar, Literal
from agents.sandbox.capabilities.capability import Capability
from agents.tool import Tool
from pydantic import PrivateAttr
from strix.sandbox.healthcheck import wait_for_http_ready, wait_for_tcp_ready
from strix.tools.proxy.tools import (
list_requests,
list_sitemap,
repeat_request,
scope_rules,
send_request,
view_request,
view_sitemap_entry,
)
if TYPE_CHECKING:
from agents.sandbox.manifest import Manifest
from agents.sandbox.session.base_sandbox_session import BaseSandboxSession
logger = logging.getLogger(__name__)
# Container-internal Caido listener. The in-container Caido sidecar binds
# on this port; the host gets a randomly mapped port we resolve at
# session create time and pass into the per-agent context as
# ``caido_host_port`` for the proxy SDK tools' dispatcher.
_CAIDO_INTERNAL_PORT = 48080
# Container-internal FastAPI tool server. Same shape as Caido — host
# port is resolved at session create.
_TOOL_SERVER_INTERNAL_PORT = 48081
# Probe URLs used inside ``bind``. ``host=127.0.0.1`` because the host
# port mapping is loopback-only.
_PROBE_HOST = "127.0.0.1"
# Cached tool list — building Tool instances has side effects via the
# function_tool decorator and we don't want re-instantiation each time
# the SDK calls ``tools()``.
_CAIDO_TOOLS: tuple[Tool, ...] = (
list_requests,
view_request,
send_request,
repeat_request,
scope_rules,
list_sitemap,
view_sitemap_entry,
)
class CaidoCapability(Capability):
"""Caido HTTP/HTTPS forward proxy + 7 GraphQL function tools.
Lifetime: one instance per scan. The SDK clones capabilities
per-run (see ``Capability.clone``); we accept that — each cloned
instance opens its own healthcheck task on ``bind``, which is
cheap and idempotent.
"""
type: Literal["caido"] = "caido"
# Pydantic ``PrivateAttr`` for runtime-only state. Pydantic forbids
# underscore-prefixed *fields*, but private attributes are first-class
# and cleanly excluded from model dumps and serialization.
_healthcheck_task: asyncio.Task[None] | None = PrivateAttr(default=None)
# The two ports the host needs to reach. Populated by the session
# manager *after* the SDK creates the container and we've resolved
# the random host-side mappings via ``session._resolve_exposed_port``.
_tool_server_host_port: int | None = PrivateAttr(default=None)
_caido_host_port: int | None = PrivateAttr(default=None)
# Per-capability healthcheck timeout. Long enough to cover image
# pulls on a cold cache plus tool-server boot, short enough that a
# mis-configured image fails the run inside a few minutes.
_HEALTHCHECK_TIMEOUT: ClassVar[float] = 60.0
def process_manifest(self, manifest: Manifest) -> Manifest:
"""Inject proxy env vars into the manifest's environment.
Mutates in place; returns the same manifest. Mirrors the SDK's
Capability protocol where ``process_manifest`` is the single
synchronous hook for changing what the container sees.
"""
env = dict(manifest.environment.value or {})
env.update(
{
"http_proxy": f"http://127.0.0.1:{_CAIDO_INTERNAL_PORT}",
"https_proxy": f"http://127.0.0.1:{_CAIDO_INTERNAL_PORT}",
"ALL_PROXY": f"http://127.0.0.1:{_CAIDO_INTERNAL_PORT}",
},
)
manifest.environment.value = env
return manifest
def tools(self) -> list[Tool]:
"""Return the seven Caido function tools.
The SDK runtime calls this at agent-build time and merges the
result with the agent's own tool list. Returning a fresh list
each call (rather than yielding the cached tuple directly) is
SDK convention.
"""
return list(_CAIDO_TOOLS)
async def instructions(self, manifest: Manifest) -> str | None: # noqa: ARG002
"""System-prompt fragment appended for every Caido-equipped agent."""
return (
"<caido_proxy>\n"
"All HTTP/HTTPS traffic in this sandbox is automatically captured "
f"by Caido (in-container at 127.0.0.1:{_CAIDO_INTERNAL_PORT}; "
"host_proxy / https_proxy env vars are pre-set).\n"
"Tools: list_requests, view_request, send_request, repeat_request, "
"scope_rules, list_sitemap, view_sitemap_entry.\n"
"HTTPQL filter examples: "
"'request.method == \"POST\"', "
"'response.status >= 400', "
"'request.host == \"target.com\"'.\n"
"</caido_proxy>"
)
def configure_host_ports(
self,
*,
tool_server_host_port: int,
caido_host_port: int,
) -> None:
"""Record the resolved host-side ports.
Called by the session manager after ``client.create(...)``
returns, before binding the session. The healthcheck task
reads these to know which mapped ports to probe.
"""
self._tool_server_host_port = tool_server_host_port
self._caido_host_port = caido_host_port
def bind(self, session: BaseSandboxSession) -> None:
"""Schedule a healthcheck task on session bind.
Stores the task handle so :class:`StrixOrchestrationHooks` can
await it on the first agent start. We never raise from here —
the healthcheck failure surfaces inside on_agent_start, which
is the right place to fail the run because by then we have a
live RunContextWrapper to log against.
"""
super().bind(session)
if self._tool_server_host_port is None or self._caido_host_port is None:
logger.warning(
"CaidoCapability.bind called before configure_host_ports; "
"skipping healthcheck task scheduling.",
)
return
self._healthcheck_task = asyncio.create_task(
self._run_healthcheck(),
name=f"caido-healthcheck-{self._tool_server_host_port}",
)
async def _run_healthcheck(self) -> None:
"""Probe both ports concurrently; raise on first failure."""
# Mypy sees these as Optional, but ``bind`` checks both before
# creating the task.
assert self._tool_server_host_port is not None
assert self._caido_host_port is not None
await asyncio.gather(
wait_for_http_ready(
f"http://{_PROBE_HOST}:{self._tool_server_host_port}/health",
timeout=self._HEALTHCHECK_TIMEOUT,
),
wait_for_tcp_ready(
_PROBE_HOST,
self._caido_host_port,
timeout=self._HEALTHCHECK_TIMEOUT,
),
)
+9 -24
View File
@@ -27,7 +27,6 @@ from agents.sandbox.manifest import Environment, Manifest
from agents.sandbox.sandboxes.docker import DockerSandboxClientOptions from agents.sandbox.sandboxes.docker import DockerSandboxClientOptions
from strix.runtime.strix_docker_client import StrixDockerSandboxClient from strix.runtime.strix_docker_client import StrixDockerSandboxClient
from strix.sandbox.caido_capability import CaidoCapability
if TYPE_CHECKING: if TYPE_CHECKING:
@@ -86,8 +85,7 @@ async def create_or_reuse(
Defaults to 120s, matching the legacy harness. Defaults to 120s, matching the legacy harness.
Returns the bundle dict containing ``client``, ``session``, Returns the bundle dict containing ``client``, ``session``,
``tool_server_host_port``, ``caido_host_port``, ``bearer``, ``tool_server_host_port``, ``caido_host_port``, and ``bearer``.
and ``capability`` (the live CaidoCapability instance).
""" """
cached = _SESSION_CACHE.get(scan_id) cached = _SESSION_CACHE.get(scan_id)
if cached is not None: if cached is not None:
@@ -96,9 +94,11 @@ async def create_or_reuse(
bearer = secrets.token_urlsafe(32) bearer = secrets.token_urlsafe(32)
capability = CaidoCapability() # Caido runs as an in-container sidecar on _CONTAINER_CAIDO_PORT and
# ``Manifest.environment`` is an ``Environment`` model — a bare dict # all HTTP(S) traffic from shelled-out tools (curl, Python requests,
# is silently dropped by Pydantic, so wrap explicitly. # etc.) needs to flow through it — set the conventional env vars so
# standard libraries pick them up automatically.
caido_proxy_url = f"http://127.0.0.1:{_CONTAINER_CAIDO_PORT}"
manifest = Manifest( manifest = Manifest(
entries={"sources": LocalDir(src=sources_path)}, entries={"sources": LocalDir(src=sources_path)},
environment=Environment( environment=Environment(
@@ -109,6 +109,9 @@ async def create_or_reuse(
"STRIX_SANDBOX_EXECUTION_TIMEOUT": str(execution_timeout), "STRIX_SANDBOX_EXECUTION_TIMEOUT": str(execution_timeout),
"PYTHONUNBUFFERED": "1", "PYTHONUNBUFFERED": "1",
"HOST_GATEWAY": "host.docker.internal", "HOST_GATEWAY": "host.docker.internal",
"http_proxy": caido_proxy_url,
"https_proxy": caido_proxy_url,
"ALL_PROXY": caido_proxy_url,
}, },
), ),
) )
@@ -130,26 +133,14 @@ async def create_or_reuse(
) )
session = await client.create(options=options, manifest=manifest) session = await client.create(options=options, manifest=manifest)
# Resolve the host-side mapped ports the SDK assigned. The capability
# needs these *before* it binds, so its healthcheck task probes the
# right ports.
tool_server_endpoint = await session._resolve_exposed_port( tool_server_endpoint = await session._resolve_exposed_port(
_CONTAINER_TOOL_SERVER_PORT, _CONTAINER_TOOL_SERVER_PORT,
) )
caido_endpoint = await session._resolve_exposed_port(_CONTAINER_CAIDO_PORT) caido_endpoint = await session._resolve_exposed_port(_CONTAINER_CAIDO_PORT)
capability.configure_host_ports(
tool_server_host_port=tool_server_endpoint.port,
caido_host_port=caido_endpoint.port,
)
# Bind the capability against the live session — this schedules the
# healthcheck task that on_agent_start awaits.
capability.bind(session)
bundle = { bundle = {
"client": client, "client": client,
"session": session, "session": session,
"capability": capability,
"tool_server_host_port": tool_server_endpoint.port, "tool_server_host_port": tool_server_endpoint.port,
"caido_host_port": caido_endpoint.port, "caido_host_port": caido_endpoint.port,
"bearer": bearer, "bearer": bearer,
@@ -171,12 +162,6 @@ async def cleanup(scan_id: str) -> None:
logger.debug("cleanup(%s): no cached session", scan_id) logger.debug("cleanup(%s): no cached session", scan_id)
return return
capability = bundle.get("capability")
if isinstance(capability, CaidoCapability):
task = capability._healthcheck_task
if task is not None and not task.done():
task.cancel()
try: try:
await bundle["client"].delete(bundle["session"]) await bundle["client"].delete(bundle["session"])
logger.info("Cleaned up sandbox session for scan %s", scan_id) logger.info("Cleaned up sandbox session for scan %s", scan_id)
-1
View File
@@ -406,7 +406,6 @@ async def create_agent(
sandbox_token=inner.get("sandbox_token"), sandbox_token=inner.get("sandbox_token"),
tool_server_host_port=inner.get("tool_server_host_port"), tool_server_host_port=inner.get("tool_server_host_port"),
caido_host_port=inner.get("caido_host_port"), caido_host_port=inner.get("caido_host_port"),
caido_capability=inner.get("caido_capability"),
agent_id=child_id, agent_id=child_id,
parent_id=parent_id, parent_id=parent_id,
tracer=inner.get("tracer"), tracer=inner.get("tracer"),