feat(migration): phase 2.5 — wrap sandbox-bound SDK tools

Ten tools ported, all pure pass-throughs to post_to_sandbox:

- browser_action (1 tool): the 21-action mega-tool dispatcher kept
  intact rather than fanned out, to preserve the legacy XML shape.
- terminal_execute (1 tool): tmux session driver.
- python_action (1 tool): IPython session manager.
- proxy / Caido (7 tools): list_requests, view_request, send_request,
  repeat_request, scope_rules, list_sitemap, view_sitemap_entry.

strix_tool decorator gains a strict_mode flag (default True, matching
the SDK default). send_request and repeat_request opt out of strict
mode because their headers / modifications dicts are free-form — the
SDK's strict JSON schema rejects dict[str, X] without enumerated keys.

Tests: 12 new tests in test_sdk_sandbox_tools.py covering registration,
strict-mode opt-out verification for the two free-form tools, and
dispatch shape verification (every wrapper is asserted to forward
its full kwarg surface to post_to_sandbox so the in-container handler
sees the same payload it always has).

Per-file ruff TC002 ignores added for the four new wrapper modules.

Phase 2 (tools) is now complete: 24 SDK function tools wrapped across
think/todo/notes/web_search/file_edit/reporting/load_skill/finish_scan/
browser/terminal/python/proxy. Total: 7 local + 17 sandbox-bound. Phase
3 (multi-agent orchestration) is next.

Refs: PLAYBOOK.md §3.6.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
0xallam
2026-04-25 00:26:30 -07:00
co-authored by Claude Opus 4.7
parent 57478e5d0d
commit 044e4e82ae
7 changed files with 804 additions and 0 deletions
+14
View File
@@ -40,6 +40,7 @@ def strix_tool(
timeout_behavior: _ToolBehavior = "error_as_result",
name_override: str | None = None,
description_override: str | None = None,
strict_mode: bool = True,
) -> Callable[[_ToolFn], FunctionTool]:
"""Wrap ``agents.function_tool`` with Strix defaults.
@@ -48,6 +49,13 @@ def strix_tool(
``async def``; sync libraries (libtmux, IPython) get wrapped in
``asyncio.to_thread`` inside the async tool body.
The SDK enforces ``strict_mode=True`` by default, which forbids
free-form ``dict[str, X]`` parameters (the strict JSON schema needs
``additionalProperties: false``). A handful of legacy tools
(``send_request``, ``repeat_request``) take arbitrary header /
modification dicts whose keys can't be enumerated, so they must
opt out of strict mode to preserve parity with the XML schema.
Usage::
@strix_tool()
@@ -55,10 +63,16 @@ def strix_tool(
@strix_tool(timeout=300, timeout_behavior="raise_exception")
async def critical_tool(ctx: RunContextWrapper, ...) -> str: ...
@strix_tool(strict_mode=False)
async def free_form_dict_tool(
ctx: RunContextWrapper, headers: dict[str, str],
) -> str: ...
"""
return function_tool(
timeout=timeout,
timeout_behavior=timeout_behavior,
name_override=name_override,
description_override=description_override,
strict_mode=strict_mode,
)
+102
View File
@@ -0,0 +1,102 @@
"""SDK function-tool wrapper for the legacy ``browser_action`` tool.
The browser is fully sandbox-bound — the legacy implementation runs
inside the container against a Playwright instance the tool server
manages. We delegate every action verbatim to ``post_to_sandbox``.
The legacy ``browser_action`` is a single mega-tool dispatching 21
discrete actions (launch, goto, click, scroll_*, new_tab, etc.). We
preserve that shape for parity rather than fanning out into 21
separate tools — that would balloon the system prompt and surprise
the model.
"""
from __future__ import annotations
import json
from typing import Any, Literal
from agents import RunContextWrapper
from strix.tools._decorator import strix_tool
from strix.tools._sandbox_dispatch import post_to_sandbox
def _dump(result: dict[str, Any]) -> str:
return json.dumps(result, ensure_ascii=False, default=str)
BrowserAction = Literal[
"launch",
"goto",
"click",
"type",
"scroll_down",
"scroll_up",
"back",
"forward",
"new_tab",
"switch_tab",
"close_tab",
"wait",
"execute_js",
"double_click",
"hover",
"press_key",
"save_pdf",
"get_console_logs",
"view_source",
"close",
"list_tabs",
]
# Browser actions can take time (page loads, navigation timeouts), so
# match the sandbox dispatch read budget rather than capping shorter.
@strix_tool(timeout=180)
async def browser_action(
ctx: RunContextWrapper,
action: BrowserAction,
url: str | None = None,
coordinate: str | None = None,
text: str | None = None,
tab_id: str | None = None,
js_code: str | None = None,
duration: float | None = None,
key: str | None = None,
file_path: str | None = None,
clear: bool = False,
) -> str:
"""Drive the sandboxed Playwright browser.
Args:
action: The browser action to dispatch — see ``BrowserAction``
literal for the full set.
url: Required for ``launch`` / ``goto`` / ``new_tab`` (with URL).
coordinate: ``"x,y"`` pixel target for click/hover/double_click.
text: Required for ``type``.
tab_id: Optional explicit tab targeting; defaults to the active tab.
js_code: Required for ``execute_js``.
duration: Seconds to wait for ``wait`` action.
key: Required for ``press_key`` (e.g. ``"Enter"``, ``"Escape"``).
file_path: Required for ``save_pdf``.
clear: For ``type``, clears the field first.
"""
return _dump(
await post_to_sandbox(
ctx,
"browser_action",
{
"action": action,
"url": url,
"coordinate": coordinate,
"text": text,
"tab_id": tab_id,
"js_code": js_code,
"duration": duration,
"key": key,
"file_path": file_path,
"clear": clear,
},
),
)
+223
View File
@@ -0,0 +1,223 @@
"""SDK function-tool wrappers for the seven Caido proxy tools.
All seven dispatch to the in-container Caido manager via the sandbox
tool server. Same pattern as browser/terminal/python — host wrapper is
pure pass-through, no logic of its own.
Tools: list_requests, view_request, send_request, repeat_request,
scope_rules, list_sitemap, view_sitemap_entry.
"""
from __future__ import annotations
import json
from typing import Any, Literal
from agents import RunContextWrapper
from strix.tools._decorator import strix_tool
from strix.tools._sandbox_dispatch import post_to_sandbox
def _dump(result: dict[str, Any]) -> str:
return json.dumps(result, ensure_ascii=False, default=str)
RequestPart = Literal["request", "response"]
SortBy = Literal[
"timestamp",
"host",
"method",
"path",
"status_code",
"response_time",
"response_size",
"source",
]
SortOrder = Literal["asc", "desc"]
SitemapDepth = Literal["DIRECT", "ALL"]
ScopeAction = Literal["get", "list", "create", "update", "delete"]
@strix_tool(timeout=120)
async def list_requests(
ctx: RunContextWrapper,
httpql_filter: str | None = None,
start_page: int = 1,
end_page: int = 1,
page_size: int = 50,
sort_by: SortBy = "timestamp",
sort_order: SortOrder = "desc",
scope_id: str | None = None,
) -> str:
"""List captured HTTP requests from the Caido proxy.
Args:
httpql_filter: Caido HTTPQL query (e.g. ``"resp.code:eq:500"``).
start_page / end_page: Inclusive page range to return.
page_size: Entries per page; default 50.
sort_by: Field to sort by.
sort_order: ``"asc"`` or ``"desc"``.
scope_id: Restrict to a specific scope.
"""
return _dump(
await post_to_sandbox(
ctx,
"list_requests",
{
"httpql_filter": httpql_filter,
"start_page": start_page,
"end_page": end_page,
"page_size": page_size,
"sort_by": sort_by,
"sort_order": sort_order,
"scope_id": scope_id,
},
),
)
@strix_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 single captured request or its response, with optional regex highlight."""
return _dump(
await post_to_sandbox(
ctx,
"view_request",
{
"request_id": request_id,
"part": part,
"search_pattern": search_pattern,
"page": page,
"page_size": page_size,
},
),
)
# strict_mode=False because ``headers`` is a free-form dict — the model
# can't enumerate all possible HTTP headers, and the SDK's strict JSON
# schema rejects ``additionalProperties: true``.
@strix_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.
Args:
method: ``"GET"``, ``"POST"``, etc.
url: Full URL.
headers: Optional header dict.
body: Optional body string.
timeout: Per-request timeout in seconds.
"""
return _dump(
await post_to_sandbox(
ctx,
"send_request",
{
"method": method,
"url": url,
"headers": headers or {},
"body": body,
"timeout": timeout,
},
),
)
# strict_mode=False because ``modifications`` is a free-form patch dict
# (header overrides, body replacements, query-string tweaks) the model
# composes per-call.
@strix_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 applying field modifications."""
return _dump(
await post_to_sandbox(
ctx,
"repeat_request",
{
"request_id": request_id,
"modifications": modifications or {},
},
),
)
@strix_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 lists)."""
return _dump(
await post_to_sandbox(
ctx,
"scope_rules",
{
"action": action,
"allowlist": allowlist,
"denylist": denylist,
"scope_id": scope_id,
"scope_name": scope_name,
},
),
)
@strix_tool(timeout=60)
async def list_sitemap(
ctx: RunContextWrapper,
scope_id: str | None = None,
parent_id: str | None = None,
depth: SitemapDepth = "DIRECT",
page: int = 1,
) -> str:
"""List Caido sitemap entries (proxied URL tree).
Args:
scope_id: Restrict to a scope.
parent_id: Drill into a specific subtree.
depth: ``"DIRECT"`` (direct children only) or ``"ALL"`` (recursive).
page: 1-indexed page number.
"""
return _dump(
await post_to_sandbox(
ctx,
"list_sitemap",
{
"scope_id": scope_id,
"parent_id": parent_id,
"depth": depth,
"page": page,
},
),
)
@strix_tool(timeout=60)
async def view_sitemap_entry(ctx: RunContextWrapper, entry_id: str) -> str:
"""Fetch a single sitemap entry's metadata + linked requests."""
return _dump(
await post_to_sandbox(ctx, "view_sitemap_entry", {"entry_id": entry_id}),
)
+56
View File
@@ -0,0 +1,56 @@
"""SDK function-tool wrapper for the legacy ``python_action`` tool.
Sandbox-bound. The in-container manager keeps long-lived IPython
sessions keyed by ``session_id`` so the model can build up state
across multiple ``execute`` calls. Pure pass-through wrapper.
"""
from __future__ import annotations
import json
from typing import Any, Literal
from agents import RunContextWrapper
from strix.tools._decorator import strix_tool
from strix.tools._sandbox_dispatch import post_to_sandbox
def _dump(result: dict[str, Any]) -> str:
return json.dumps(result, ensure_ascii=False, default=str)
PythonAction = Literal["new_session", "execute", "close", "list_sessions"]
@strix_tool(timeout=180)
async def python_action(
ctx: RunContextWrapper,
action: PythonAction,
code: str | None = None,
timeout: int = 30,
session_id: str | None = None,
) -> str:
"""Manage / execute code in a long-lived sandboxed IPython session.
Args:
action: ``"new_session"`` to spin one up, ``"execute"`` to run code,
``"close"`` to terminate, ``"list_sessions"`` to inspect.
code: Required for ``execute`` (and optional for ``new_session``
to run a setup snippet immediately).
timeout: Per-call execution budget in seconds. Default 30.
session_id: Required for ``execute`` / ``close``. Optional for
``new_session`` (auto-generated when omitted).
"""
return _dump(
await post_to_sandbox(
ctx,
"python_action",
{
"action": action,
"code": code,
"timeout": timeout,
"session_id": session_id,
},
),
)
+57
View File
@@ -0,0 +1,57 @@
"""SDK function-tool wrapper for the legacy ``terminal_execute`` tool.
The terminal lives in the sandbox container — each persistent tmux
session is keyed by ``terminal_id`` on the in-container manager. The
host-side wrapper is a thin pass-through.
"""
from __future__ import annotations
import json
from typing import Any
from agents import RunContextWrapper
from strix.tools._decorator import strix_tool
from strix.tools._sandbox_dispatch import post_to_sandbox
def _dump(result: dict[str, Any]) -> str:
return json.dumps(result, ensure_ascii=False, default=str)
@strix_tool(timeout=180)
async def terminal_execute(
ctx: RunContextWrapper,
command: str,
is_input: bool = False,
timeout: float | None = None,
terminal_id: str | None = None,
no_enter: bool = False,
) -> str:
"""Run a shell command in the sandboxed Kali tmux session.
Args:
command: Shell command (or input for an interactive prompt when
``is_input=True``).
is_input: Treat ``command`` as input to a running foreground process
(e.g., feeding y/n to ``apt install``).
timeout: Seconds to wait before returning partial output. Defaults
to the in-container manager's policy.
terminal_id: Persistent session selector. Defaults to ``"default"``.
no_enter: When True, sends keystrokes without a trailing return.
Useful for sending raw ANSI control sequences.
"""
return _dump(
await post_to_sandbox(
ctx,
"terminal_execute",
{
"command": command,
"is_input": is_input,
"timeout": timeout,
"terminal_id": terminal_id,
"no_enter": no_enter,
},
),
)