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/agents_graph/tools.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
# session_manager call; importing under TYPE_CHECKING would defer
# 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.
Wires the SDK function tools, multi-agent graph tools,
``CaidoCapability``, and the rendered Jinja prompt into one
``agents.Agent`` ready for ``Runner.run``.
Wires the SDK function tools, multi-agent graph tools, and the rendered
Jinja prompt into one ``agents.Agent`` ready for ``Runner.run``.
Two flavors:
@@ -13,9 +12,8 @@ Two flavors:
there — without ``stop_at_tool_names`` the SDK loop would keep
running to ``max_turns`` even after the child reported back.
Caido tools come from ``CaidoCapability.tools()`` via the SDK's
capability merge — we don't list them here. Skills are baked into the
system prompt at scan bring-up; there's no runtime skill-loading tool.
Skills are baked into the system prompt at scan bring-up; there's no
runtime skill-loading tool.
"""
from __future__ import annotations
@@ -50,6 +48,15 @@ from strix.tools.notes.tools import (
list_notes,
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.reporting.tool import create_vulnerability_report
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__)
# Tools every Strix agent has, root or child. The Caido proxy tools
# (list_requests, view_request, send_request, ...) are NOT here —
# CaidoCapability.tools() returns them and the SDK merges them in.
# Tools every Strix agent has, root or child.
_BASE_TOOLS: tuple[Tool, ...] = (
# Thinking + planning
think,
@@ -101,6 +106,14 @@ _BASE_TOOLS: tuple[Tool, ...] = (
browser_action,
terminal_execute,
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)
view_agent_graph,
agent_status,
+16 -1
View File
@@ -15,6 +15,7 @@
from __future__ import annotations
import asyncio
import logging
import uuid
from pathlib import Path
@@ -34,6 +35,7 @@ from strix.run_config_factory import (
make_run_config,
)
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
@@ -219,6 +221,20 @@ async def run_strix_scan(
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:
scan_mode = str(scan_config.get("scan_mode") or "deep")
is_whitebox = bool(scan_config.get("is_whitebox", False))
@@ -254,7 +270,6 @@ async def run_strix_scan(
sandbox_token=bundle["bearer"],
tool_server_host_port=bundle["tool_server_host_port"],
caido_host_port=bundle["caido_host_port"],
caido_capability=bundle.get("capability"),
agent_id=root_id,
parent_id=None,
tracer=tracer,
+7 -22
View File
@@ -17,16 +17,13 @@ logger = logging.getLogger(__name__)
class StrixOrchestrationHooks(RunHooks[Any]):
"""Lifecycle hooks for Strix multi-agent runs.
Wires four concerns:
Wires three concerns:
1. Turn-budget warnings injected into ``input_items`` at 85% and
``N - 3`` of ``max_turns``.
2. LLM usage recording into the bus + tracer.
3. Sandbox readiness: awaits the
``CaidoCapability._healthcheck_task`` on first agent start so
the agent doesn't fire tools before Caido and the tool server
are ready.
4. Subagent crash detection: if ``on_agent_end`` fires without
2. LLM usage recording into the bus + tracer, plus mirroring the
bus's agent tree into ``tracer.agents`` for the TUI.
3. Subagent crash detection: if ``on_agent_end`` fires without
``agent_finish_called`` being set, posts a crash message to the
parent's inbox so the parent learns on its next turn instead of
waiting forever.
@@ -106,26 +103,14 @@ class StrixOrchestrationHooks(RunHooks[Any]):
context: AgentHookContext[Any],
agent: Any,
) -> None:
# Two concerns wired together because they fire at the same
# moment in the lifecycle:
#
# 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.
# The TUI reads ``tracer.agents`` to render the agent tree;
# mirror the bus state into the tracer here so the tree
# populates as agents come online.
del agent
try:
ctx = context.context
if not isinstance(ctx, dict):
return
cap = ctx.get("caido_capability")
task = getattr(cap, "_healthcheck_task", None)
if task is not None:
await task
tracer = ctx.get("tracer")
bus = ctx.get("bus")
me = ctx.get("agent_id")
-2
View File
@@ -126,7 +126,6 @@ def make_agent_context(
run_id: str | None = None,
sandbox_client: Any | None = None,
agent_factory: Any | None = None,
caido_capability: Any | None = None,
) -> dict[str, Any]:
"""Build the per-agent ``context`` dict passed to ``Runner.run(context=...)``.
@@ -146,7 +145,6 @@ def make_agent_context(
"sandbox_token": sandbox_token,
"tool_server_host_port": tool_server_host_port,
"caido_host_port": caido_host_port,
"caido_capability": caido_capability,
"agent_id": agent_id,
"parent_id": parent_id,
"tracer": tracer,
+1 -3
View File
@@ -1,8 +1,6 @@
"""Strix sandbox layer on top of OpenAI Agents SDK SandboxAgent / Manifest.
- :mod:`.caido_capability` — Caido proxy + 7 GraphQL function tools
+ system prompt block.
- :mod:`.healthcheck` — ``wait_for_ports_ready``.
- :mod:`.healthcheck` — ``wait_for_http_ready`` / ``wait_for_tcp_ready``.
- :mod:`.session_manager` — ``create_or_reuse`` / ``cleanup`` keyed
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 strix.runtime.strix_docker_client import StrixDockerSandboxClient
from strix.sandbox.caido_capability import CaidoCapability
if TYPE_CHECKING:
@@ -86,8 +85,7 @@ async def create_or_reuse(
Defaults to 120s, matching the legacy harness.
Returns the bundle dict containing ``client``, ``session``,
``tool_server_host_port``, ``caido_host_port``, ``bearer``,
and ``capability`` (the live CaidoCapability instance).
``tool_server_host_port``, ``caido_host_port``, and ``bearer``.
"""
cached = _SESSION_CACHE.get(scan_id)
if cached is not None:
@@ -96,9 +94,11 @@ async def create_or_reuse(
bearer = secrets.token_urlsafe(32)
capability = CaidoCapability()
# ``Manifest.environment`` is an ``Environment`` model — a bare dict
# is silently dropped by Pydantic, so wrap explicitly.
# Caido runs as an in-container sidecar on _CONTAINER_CAIDO_PORT and
# all HTTP(S) traffic from shelled-out tools (curl, Python requests,
# 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(
entries={"sources": LocalDir(src=sources_path)},
environment=Environment(
@@ -109,6 +109,9 @@ async def create_or_reuse(
"STRIX_SANDBOX_EXECUTION_TIMEOUT": str(execution_timeout),
"PYTHONUNBUFFERED": "1",
"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)
# 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(
_CONTAINER_TOOL_SERVER_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 = {
"client": client,
"session": session,
"capability": capability,
"tool_server_host_port": tool_server_endpoint.port,
"caido_host_port": caido_endpoint.port,
"bearer": bearer,
@@ -171,12 +162,6 @@ async def cleanup(scan_id: str) -> None:
logger.debug("cleanup(%s): no cached session", scan_id)
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:
await bundle["client"].delete(bundle["session"])
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"),
tool_server_host_port=inner.get("tool_server_host_port"),
caido_host_port=inner.get("caido_host_port"),
caido_capability=inner.get("caido_capability"),
agent_id=child_id,
parent_id=parent_id,
tracer=inner.get("tracer"),