docs: restore tool guidance into docstrings, drop prompt tool-format boilerplate

Port the prose guidance that previously lived in the deleted
*_actions_schema.xml files into per-tool docstrings, so the SDK's
auto-generated function schema carries the same domain knowledge
(HTTPQL syntax, Caido sitemap kinds, browser persistence/JS rules,
agent specialization caps, customer-facing report rules, CVSS/CWE
guidance, etc.) without any custom prompt scaffolding.

Strip the <tool_usage> block from system_prompt.jinja — XML format
guidance, the "CRITICAL RULES" 0-8 list, and the </function>
closing-tag reminder all contradicted the SDK's native JSON
function-calling protocol.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
0xallam
2026-04-25 11:48:41 -07:00
parent 6435e07dc2
commit e4be5f9588
13 changed files with 798 additions and 197 deletions
+128 -37
View File
@@ -57,11 +57,14 @@ def _dump(result: dict[str, Any]) -> str:
@strix_tool(timeout=30)
async def view_agent_graph(ctx: RunContextWrapper) -> str:
"""Render the multi-agent tree starting from each root.
"""Print the multi-agent tree — every agent, its parent, its status.
Output is a single string the model can parse: indented bullet list,
one line per agent, status in brackets. Roots are agents whose
``parent_of[id]`` is ``None``.
Use before spawning a new agent (don't duplicate work — check whether
something specialized for that task already exists) and any time you
want a snapshot of who's still ``running`` / ``waiting`` /
``completed`` / ``crashed`` / ``stopped``. Output is an indented
bullet list with status in brackets; the agent that called this tool
is marked ``← you``.
"""
inner = ctx.context if isinstance(ctx.context, dict) else {}
bus = inner.get("bus")
@@ -107,7 +110,17 @@ async def view_agent_graph(ctx: RunContextWrapper) -> str:
@strix_tool(timeout=30)
async def agent_status(ctx: RunContextWrapper, agent_id: str) -> str:
"""Inspect one agent's lifecycle state and pending message count."""
"""Look up one agent's lifecycle state + pending message count.
Use when you need precise state on a specific agent (e.g., "is the
XSS specialist still going?") rather than the full tree view.
Returns ``status`` (``running`` / ``waiting`` / ``completed`` /
``crashed`` / ``stopped``), ``parent_id``, and ``pending_messages``.
Args:
agent_id: The 8-char id from ``view_agent_graph`` /
``create_agent``.
"""
inner = ctx.context if isinstance(ctx.context, dict) else {}
bus = inner.get("bus")
if bus is None:
@@ -141,12 +154,29 @@ async def send_message_to_agent(
message_type: Literal["query", "instruction", "information"] = "information",
priority: Literal["low", "normal", "high", "urgent"] = "normal",
) -> str:
"""Queue a message for another agent's inbox.
"""Send a message to another agent's inbox — sparingly.
The target's next ``inject_messages_filter`` pass (top of its next LLM
turn) drains the inbox and surfaces the message wrapped in
``<inter_agent_message>``. Messages to a finalized agent are dropped
silently by the bus (C13).
Inter-agent messages are surfaced at the top of the target's next
LLM turn. Use only when essential:
- Sharing a discovered finding/credential another agent needs.
- Asking a specialist a focused question.
- Coordinating who covers what (avoid overlap).
- Telling a child to wrap up or change course.
**Don't** use for routine "hello/status" pings, for context the
target already has (children inherit parent history), or when
parent/child completion via ``agent_finish`` already covers the
flow. Messages to a finalized agent are dropped.
Args:
target_agent_id: Recipient's 8-char id.
message: The full message body. Be specific — include payloads,
URLs, or what you want them to do, not just headlines.
message_type: ``query`` (you want a reply), ``instruction``
(you're directing them), ``information`` (FYI, no reply
expected). Default ``information``.
priority: ``low`` / ``normal`` / ``high`` / ``urgent``.
"""
inner = ctx.context if isinstance(ctx.context, dict) else {}
bus = inner.get("bus")
@@ -205,16 +235,31 @@ async def wait_for_message(
reason: str = "Waiting for messages from other agents",
timeout_seconds: int = 600,
) -> str:
"""Block this agent's turn until a message arrives or ``timeout_seconds``.
"""Pause this agent until a message lands in its inbox (or timeout).
Implementation polls ``bus.inboxes`` once per second. Cheaper than an
asyncio.Event because the message bus already serializes through its
own lock — a missed wakeup on Event would be subtle to debug, while
polling is trivially correct.
Use when you have nothing useful to do until a child/peer responds
— typically after spawning subagents and you want to wait for
their completion reports. The agent automatically resumes when any
message arrives.
**Critical caveats:**
- **Never** call this if you finished your own task and have **no**
child agents running — that's a permanent stall. Call
``finish_scan`` (root) or ``agent_finish`` (subagent) instead.
- If you're waiting on an agent that **isn't your child**, message
it first asking it to ping you when done — otherwise it has no
reason to send to your inbox and you'll wait the full timeout.
- Children update the parent automatically via ``agent_finish``
→ no extra coordination needed.
Args:
reason: Human-readable note shown in graph snapshots while waiting.
timeout_seconds: Cap on the wait. 600s matches the legacy default.
reason: One-line note shown in graph snapshots while you're
waiting (helps a human or sibling agent debug who's stuck
on what).
timeout_seconds: Hard cap (default 600s). On timeout the tool
returns and you decide whether to keep working or wait
again.
"""
inner = ctx.context if isinstance(ctx.context, dict) else {}
bus = inner.get("bus")
@@ -268,22 +313,44 @@ async def create_agent(
inherit_context: bool = True,
skills: list[str] | None = None,
) -> str:
"""Spawn a child agent that runs in parallel via ``asyncio.create_task``.
"""Spawn a specialist child agent to run in parallel.
The child's ``Runner.run`` task handle is stored in ``bus.tasks[child_id]``
so a root-level cancel can cascade to descendants (C9). The child is
registered with the bus before the task starts so messages aimed at it
don't get dropped during the brief register→start window.
Decompose complex pentests by handing focused subtasks to dedicated
children. The child runs asynchronously — the parent continues
immediately and can ``wait_for_message`` later (or just keep
working in parallel). When the child calls ``agent_finish``, its
completion report lands in the parent's inbox.
**Before spawning, call ``view_agent_graph``** to confirm no
existing agent already covers this scope — duplicate specialists
waste turns and create coordination headaches.
**Specialization principles:**
- Most agents need at least one ``skill`` to be useful.
- Aim for **1-3 related skills** per agent. Up to 5 only when the
task genuinely spans them.
- One skill = most focused (e.g., XSS-only). Five skills = upper
bound.
- Match the ``name`` to the focus (``XSS Specialist``,
``SQLi Validator``, ``Auth Specialist``).
**When to spawn vs do it yourself:**
- Spawn when the subtask is large, parallelizable, or needs
different specialization than what you're already doing.
- Don't spawn for trivial one-shot probes — just run the tool
yourself.
Args:
name: Human-readable child name (also stored in ``bus.names``).
task: The task description handed to the child agent.
inherit_context: When True, the child receives a copy of the parent's
input items as background context, wrapped in
``<inherited_context_from_parent>``. Default True.
skills: Optional list of skill names the child should preload.
Returns a JSON-encoded ``{"success": ..., "agent_id": ...}``.
name: Human-readable child name (used in graph views and
``send_message_to_agent`` flows).
task: Specific objective. Be concrete — what to test, what
success looks like, any constraints.
inherit_context: Default ``True``. The child receives the
parent's input history as background; only set ``False``
when starting a clean-slate task.
skills: Comma-separated skill names. Max 5; prefer 1-3.
"""
inner = ctx.context if isinstance(ctx.context, dict) else {}
bus = inner.get("bus")
@@ -412,15 +479,39 @@ async def agent_finish(
report_to_parent: bool = True,
final_recommendations: list[str] | None = None,
) -> str:
"""Subagent-only termination: post a completion report and signal the SDK.
"""Subagent termination post a completion report to the parent.
Sets ``ctx.context['agent_finish_called'] = True`` so the on_agent_end
hook records "completed" rather than "crashed". The SDK terminates the
child's loop because every child is built with
``tool_use_behavior={"stop_at_tool_names": ["agent_finish"]}`` (C4).
**Subagents only.** Root agents must call ``finish_scan`` instead;
this tool refuses to run for root agents. Calling this:
Root agents must call ``finish_scan`` instead. This tool refuses to run
when ``parent_id`` is None.
1. Marks the subagent as ``completed``.
2. Posts a structured ``<agent_completion_report>`` to the
parent's inbox (when ``report_to_parent`` is true).
3. Stops this subagent's execution.
**Vulnerability findings must already be filed via
``create_vulnerability_report`` before calling this.** The
``findings`` field here is for narrative summary only — it does
not register vulns in the scan report.
Write the summary as if the parent has no idea what you were
doing: what did you test, what did you find/confirm/rule out,
what's still open.
Args:
result_summary: What you accomplished and discovered. Concrete
and specific (URLs, parameters, payloads that worked).
findings: Optional bullet list of confirmed observations. For
credit-bearing vulnerabilities, file
``create_vulnerability_report`` first; this is for
narrative.
success: Whether the assigned subtask was completed
successfully. Default ``True``.
report_to_parent: Whether to deliver the completion report to
the parent's inbox. Default ``True``.
final_recommendations: Optional next-step suggestions for the
parent (e.g., "prioritize testing X", "spawn an agent to
cover Y").
"""
inner = ctx.context if isinstance(ctx.context, dict) else {}
bus = inner.get("bus")
+64 -9
View File
@@ -67,20 +67,75 @@ async def browser_action(
file_path: str | None = None,
clear: bool = False,
) -> str:
"""Drive the sandboxed Playwright browser.
"""Drive the sandboxed Playwright browser (Chromium, headless).
The browser is **persistent** — state survives across calls and tabs
until you ``close``. Browser interaction must start with ``launch``
and end with ``close``. Multiple tabs are supported; the first tab
after ``launch`` is ``"tab_1"`` and new tabs are numbered
sequentially.
**Click coordinates** — derive them from the most recent screenshot.
Target the *center* of the element, not the edge. After clicking,
verify success against the next screenshot. Bad coordinates are the
most common reason clicks silently fail.
**JavaScript execution** (``execute_js``):
- Code runs in the page context with full DOM access.
- The **last evaluated expression is auto-returned** — do not use
``return`` (it breaks evaluation).
- For an object literal as the final expression, wrap in parentheses:
``({title: document.title, url: location.href})``.
- ``await`` is supported: ``await fetch(location.href).then(r => r.status)``.
- Variables from your tool context are NOT available — pass data
via the URL or DOM if you need to thread it through.
- The ``js_code`` parameter is executed as-is; no escaping needed,
single- or multi-line both work.
**Form filling** — click the field first, then ``type`` the text.
**Tabs** — actions affect the currently active tab unless ``tab_id``
is set. Always keep at least one tab open. Close tabs you don't need
with ``close_tab``, and ``close`` the browser when you're fully done.
**Concurrency** — the browser session can run alongside terminal /
python tool calls in subsequent turns; nothing in the browser is
serialized against other tools.
Special keys for ``press_key``: single chars ``a``-``z`` / ``0``-``9``,
``Enter`` / ``Escape`` / ``Tab`` / ``Space`` / ``ArrowLeft`` /
``ArrowRight`` / ``ArrowUp`` / ``ArrowDown``, modifiers ``Shift`` /
``Control`` / ``Alt`` / ``Meta``, function keys ``F1``-``F12``.
Returns: a JSON dict with ``screenshot`` (base64 PNG), ``url``,
``title``, ``viewport``, ``tab_id``, ``all_tabs``. Per-action extras:
``js_result`` for ``execute_js``, ``pdf_saved`` for ``save_pdf``,
``console_logs`` (≤50 KB / ≤200 most recent) for ``get_console_logs``,
``page_source`` (truncated to 100 KB) for ``view_source``.
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.
action: One of: ``launch``, ``goto``, ``click``, ``type``,
``scroll_down``, ``scroll_up``, ``back``, ``forward``,
``new_tab``, ``switch_tab``, ``close_tab``, ``list_tabs``,
``wait``, ``execute_js``, ``double_click``, ``hover``,
``press_key``, ``save_pdf``, ``get_console_logs``,
``view_source``, ``close``.
url: Required for ``launch`` / ``goto``; optional for
``new_tab``. Must include the protocol (e.g.
``https://...``, ``file://...``).
coordinate: ``"x,y"`` pixel target for ``click`` / ``double_click``
/ ``hover``. Format example: ``"432,321"``. Must be within
viewport.
text: Required for ``type``.
tab_id: Optional explicit tab targeting; defaults to the active tab.
tab_id: Required for ``switch_tab`` / ``close_tab``; optional
elsewhere to target a specific tab.
js_code: Required for ``execute_js``.
duration: Seconds to wait for ``wait`` action.
key: Required for ``press_key`` (e.g. ``"Enter"``, ``"Escape"``).
duration: Seconds for ``wait`` (fractional OK, e.g. ``0.5``).
key: Required for ``press_key``.
file_path: Required for ``save_pdf``.
clear: For ``type``, clears the field first.
clear: For ``get_console_logs``, clear logs after retrieval
(default False).
"""
return _dump(
await post_to_sandbox(
+39 -13
View File
@@ -37,16 +37,34 @@ async def str_replace_editor(
new_str: str | None = None,
insert_line: int | None = None,
) -> str:
"""View, create, or edit a file in the sandbox.
"""View, create, or edit a file in the sandbox filesystem.
Commands:
- ``view`` — show file contents. Optionally restrict to a line range
via ``view_range`` (1-indexed; ``[start, -1]`` for "from start to
end of file").
- ``create`` — write a new file with ``file_text``. Use this for
exploit scripts, PoCs, helper modules, etc.
- ``str_replace`` — find ``old_str`` in the file and replace with
``new_str``. ``old_str`` must be unique in the file; include
enough surrounding context to make it so.
- ``insert`` — insert ``new_str`` after line ``insert_line``.
- ``undo_edit`` — revert the most recent edit to ``path``.
Multi-line ``new_str`` / ``old_str`` / ``file_text`` use real
newlines, not literal ``\\n``.
Args:
command: One of ``"view" | "create" | "str_replace" | "insert" |
"undo_edit"``.
path: File path. Relative paths are anchored at ``/workspace``.
command: ``view`` / ``create`` / ``str_replace`` / ``insert`` /
``undo_edit``.
path: File path. Relative paths anchor at ``/workspace``.
file_text: Required for ``create``.
view_range: Optional ``[start, end]`` line range for ``view``.
old_str / new_str: Required for ``str_replace``.
insert_line: Required for ``insert``.
view_range: Optional ``[start, end]`` (1-indexed) for ``view``.
old_str: Required for ``str_replace`` — must be unique in file.
new_str: Required for ``str_replace`` and ``insert``.
insert_line: Required for ``insert``; new content goes AFTER
this line.
"""
return _dump(
await post_to_sandbox(
@@ -73,9 +91,12 @@ async def list_files(
) -> str:
"""List files and directories under a sandbox path.
Output is sorted alphabetically and capped at 500 entries to avoid
flooding the model with huge directory trees.
Args:
path: Directory path, relative paths anchored at ``/workspace``.
recursive: When True, walks subdirectories (capped at 500 entries).
path: Directory path; relative paths anchor at ``/workspace``.
recursive: When True, walks subdirectories.
"""
return _dump(
await post_to_sandbox(
@@ -93,12 +114,17 @@ async def search_files(
regex: str,
file_pattern: str = "*",
) -> str:
"""Recursively grep files in the sandbox using ripgrep.
"""Recursively regex-search files in the sandbox using ripgrep.
Fast — uses ``rg`` under the hood. Walks subdirectories. Use this
for code-pattern hunts (``def\\s+authenticate``, ``API_KEY``,
secrets, etc.) when you don't already know the file.
Args:
path: Root path to search; relative paths anchored at ``/workspace``.
regex: Pattern to match (passed straight to ``rg``).
file_pattern: Glob filter (e.g. ``"*.py"``). Defaults to all files.
path: Root path to search. Relative paths anchor at ``/workspace``.
regex: Pattern to match (PCRE-style; passed straight to ``rg``).
file_pattern: Glob filter (e.g. ``"*.py"``, ``"*.{js,ts}"``).
Defaults to all files.
"""
return _dump(
await post_to_sandbox(
+48 -7
View File
@@ -79,16 +79,57 @@ async def finish_scan(
technical_analysis: str,
recommendations: str,
) -> str:
"""Finalize the scan and persist the four executive summary sections.
"""Finalize the scan persist the customer-facing report.
Only the root agent should call this. Subagents must use
``agent_finish`` (from the multi-agent graph tools) instead.
**Root-agent only.** Subagents must call ``agent_finish`` from the
multi-agent graph tools instead. Calling this finalizes everything:
1. Verifies you are the root agent.
2. Writes the four narrative sections to the scan record.
3. Marks the scan completed and stops execution.
**Pre-flight checklist:**
- All vulnerabilities you found are filed via
``create_vulnerability_report`` (un-reported findings are not
tracked and not credited).
- All subagents have terminated. If any are still ``running`` /
``stopping``, message them or use ``wait_for_message``.
- Don't double-report — one report per distinct vulnerability.
**Calling this multiple times overwrites the previous report.**
Make the single call comprehensive.
**Customer-facing report rules** (this output is rendered into the
final PDF the client sees):
- Never mention internal infrastructure: no local/absolute paths
(``/workspace/...``), no agent names, no sandbox/orchestrator/
tooling references, no system prompts, no model-internal errors.
- Tone: formal, third-person, objective, concise. This is a
consultant deliverable, not an engineering log.
- Each section has a specific role:
- ``executive_summary`` — for non-technical leadership. Risk
posture, business impact (data exposure / compliance /
reputation), notable criticals, overarching remediation
theme.
- ``methodology`` — frameworks followed (OWASP WSTG, PTES,
OSSTMM, NIST), engagement type (black/gray/white box), scope
and constraints, categories of testing performed. **No**
internal execution detail.
- ``technical_analysis`` — consolidated findings overview with
severity model and systemic root causes. Reference individual
vuln reports for repro steps; don't duplicate raw evidence.
- ``recommendations`` — prioritized actions grouped by urgency
(Immediate / Short-term / Medium-term), each with concrete
remediation steps. End with retest/validation guidance.
Args:
executive_summary: High-level scan outcome.
methodology: Approach taken.
technical_analysis: Findings detail across the engagement.
recommendations: Prioritized fix list.
executive_summary: Business-level summary for leadership.
methodology: Frameworks, scope, and approach.
technical_analysis: Consolidated findings + systemic themes.
recommendations: Prioritized, actionable remediation.
"""
inner = ctx.context if isinstance(ctx.context, dict) else {}
result = await asyncio.to_thread(
+70 -8
View File
@@ -425,11 +425,37 @@ async def create_note(
category: str = "general",
tags: list[str] | None = None,
) -> str:
"""Create a note in the current run's notes store.
"""Document an observation, finding, methodology step, or research note.
Notes are persisted to ``run_dir/notes/notes.jsonl`` and (for the
``wiki`` category) rendered as Markdown to
``run_dir/wiki/<slug>.md``.
Notes are your **shared run memory** — they're visible to every
agent in the same scan and persist to ``run_dir/notes/notes.jsonl``
(replayable event log). Wiki-category notes are additionally
rendered as Markdown under ``run_dir/wiki/<slug>.md``.
For actionable tasks, use ``todo`` instead — notes are for capturing
information, todos are for tracking work.
Categories:
- ``general`` — default, anything that doesn't fit elsewhere.
- ``findings`` — confirmed vulnerabilities or weaknesses (write
these up promptly; you'll cite them when filing reports).
- ``methodology`` — what you tried, what worked, what didn't —
useful for the final scan report.
- ``questions`` — open questions / things to come back to.
- ``plan`` — multi-step plans you want to track.
- ``wiki`` — repository or target source maps shared across agents
in the same run. Use this for codebase architecture notes the
whole agent tree should see.
Tags are free-form (e.g. ``["sqli", "auth", "critical"]``) — useful
for later ``list_notes(tags=...)`` filtering.
Args:
title: Short headline.
content: Full note body. Markdown is preserved.
category: One of the categories above. Default ``"general"``.
tags: Optional free-form tags.
"""
del ctx
return _dump(
@@ -445,7 +471,24 @@ async def list_notes(
search: str | None = None,
include_content: bool = False,
) -> str:
"""List notes, optionally filtered by category / tags / substring."""
"""List existing notes — metadata-first by default.
Filters compose: passing ``category="findings"`` and
``tags=["sqli"]`` returns notes that are *both* in the findings
category AND have at least one of those tags.
By default each entry includes a ``content_preview`` (first 280
chars). Set ``include_content=True`` to get full bodies — useful
when you need to scan many notes; expensive in tokens for large
notes.
Args:
category: Filter by category.
tags: Filter to notes that have any of these tags.
search: Substring match against title and content.
include_content: When False (default) entries have a preview;
when True the full ``content`` is included.
"""
del ctx
return _dump(
await asyncio.to_thread(
@@ -460,7 +503,11 @@ async def list_notes(
@strix_tool(timeout=30)
async def get_note(ctx: RunContextWrapper, note_id: str) -> str:
"""Fetch one note by its 5-char ID. Returns full content."""
"""Fetch one note by its 5-char ID. Returns the full content.
Args:
note_id: Note id from ``create_note`` or a ``list_notes`` entry.
"""
del ctx
return _dump(await asyncio.to_thread(_get_note_impl, note_id))
@@ -473,7 +520,18 @@ async def update_note(
content: str | None = None,
tags: list[str] | None = None,
) -> str:
"""Update a note's title, content, or tags."""
"""Update a note's title, content, or tags.
Pass ``None`` for any field you want left unchanged. Replacing
``content`` is a full overwrite — to append, fetch first with
``get_note``, concat, and pass the result.
Args:
note_id: Target note's 5-char ID.
title: New title, or ``None`` to keep.
content: New content, or ``None`` to keep.
tags: New tags list, or ``None`` to keep.
"""
del ctx
return _dump(
await asyncio.to_thread(
@@ -488,6 +546,10 @@ async def update_note(
@strix_tool(timeout=30)
async def delete_note(ctx: RunContextWrapper, note_id: str) -> str:
"""Delete a note. For wiki notes, also removes the rendered Markdown file."""
"""Delete a note. For wiki notes, also removes the rendered Markdown file.
Args:
note_id: Note id to delete.
"""
del ctx
return _dump(await asyncio.to_thread(_delete_note_impl, note_id))
+155 -20
View File
@@ -50,15 +50,35 @@ async def list_requests(
sort_order: SortOrder = "desc",
scope_id: str | None = None,
) -> str:
"""List captured HTTP requests from the Caido proxy.
"""List captured HTTP requests from the Caido proxy with HTTPQL filtering.
Caido HTTPQL syntax (operators differ by field type):
- **Integer fields** (``resp.code``, ``req.port``, ``id``,
``roundtrip``) — ``eq``, ``gt``, ``gte``, ``lt``, ``lte``, ``ne``.
Examples: ``resp.code.eq:200``, ``resp.code.gte:400``,
``req.port.eq:443``.
- **Text/byte fields** (``req.method``, ``req.host``, ``req.path``,
``req.query``, ``req.ext``, ``req.raw``) — ``regex``, ``cont``
(substring), ``eq``. Examples: ``req.method.eq:"POST"``,
``req.path.cont:"/api/"``, ``req.host.regex:".*\\.example\\.com"``.
- **Date fields** (``req.created_at``) — ``gt``, ``lt`` with ISO
timestamps: ``req.created_at.gt:"2024-01-01T00:00:00Z"``.
- **Combine** with ``AND`` / ``OR``: ``req.method.eq:"POST" AND
resp.code.gte:400``.
- **Special**: ``source:intercept`` (only intercepted requests),
``preset:"name"``.
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.
httpql_filter: Caido HTTPQL query.
start_page: Starting page, 1-indexed.
end_page: Ending page (inclusive).
page_size: Entries per page (default 50).
sort_by: ``timestamp`` / ``host`` / ``method`` / ``path`` /
``status_code`` / ``response_time`` / ``response_size`` /
``source``.
sort_order: ``asc`` or ``desc``.
scope_id: Restrict to a scope (managed via ``scope_rules``).
"""
return _dump(
await post_to_sandbox(
@@ -86,7 +106,31 @@ async def view_request(
page: int = 1,
page_size: int = 50,
) -> str:
"""View a single captured request or its response, with optional regex highlight."""
"""View a captured request or its response, optionally regex-searched.
Two modes:
- **With** ``search_pattern`` (compact regex hits) — returns up to 20
matches with ``before`` / ``after`` context and position. Useful
for hunting reflected input, leaked URLs, hidden parameters.
- **Without** ``search_pattern`` (full content with pagination) —
returns the page of raw content plus ``has_more`` flag.
Common search patterns:
- API endpoints: ``/api/[a-zA-Z0-9._/-]+``
- URLs: ``https?://[^\\s<>"']+``
- Query parameters: ``[?&][a-zA-Z0-9_]+=([^&\\s<>"']+)``
- Specific input reflection: search for the value you submitted.
Args:
request_id: Request ID from ``list_requests``.
part: ``"request"`` or ``"response"``.
search_pattern: Optional regex; switches the response shape to
compact hits.
page: 1-indexed page number (only when no ``search_pattern``).
page_size: Lines per page.
"""
return _dump(
await post_to_sandbox(
ctx,
@@ -116,12 +160,17 @@ async def send_request(
) -> 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"``, etc.
url: Full URL.
method: ``"GET"`` / ``"POST"`` / ``"PUT"`` / ``"DELETE"`` / etc.
url: Full URL with protocol.
headers: Optional header dict.
body: Optional body string.
timeout: Per-request timeout in seconds.
body: Optional request body string.
timeout: Per-request timeout in seconds (default 30).
"""
return _dump(
await post_to_sandbox(
@@ -147,7 +196,31 @@ async def repeat_request(
request_id: str,
modifications: dict[str, Any] | None = None,
) -> str:
"""Repeat a captured request, optionally applying field modifications."""
"""Repeat a captured request, optionally patching individual fields.
The standard pentesting workflow with this tool:
1. ``browser_action`` (or live target traffic) → request gets
captured by Caido.
2. ``list_requests`` → find the request ID you want to manipulate.
3. ``repeat_request`` → send a modified version (auth-bypass test,
payload injection, parameter tampering).
Mirrors the manual "browse → capture → modify → test" flow used in
real pentesting. Inherits everything from the original request
(headers, cookies, auth, method, URL) and overlays only the fields
you specify in ``modifications``.
Args:
request_id: ID of the original request (from ``list_requests``).
modifications: Patch dict. Recognized keys:
- ``url`` — replace the URL.
- ``params`` — dict of query-string keys to add/update.
- ``headers`` — dict of headers to add/update.
- ``body`` — replace the body string entirely.
- ``cookies`` — dict of cookies to add/update.
"""
return _dump(
await post_to_sandbox(
ctx,
@@ -169,7 +242,44 @@ async def scope_rules(
scope_id: str | None = None,
scope_name: str | None = None,
) -> str:
"""CRUD on Caido scope rules (allow/deny lists)."""
"""CRUD on Caido scope rules (allow/deny patterns).
Scopes filter which traffic Caido tools see. Use them to focus on a
target, exclude noisy assets (CDNs, static files), or define a
bug-bounty allowlist.
Pattern semantics:
- Glob wildcards: ``*`` (any), ``?`` (single), ``[abc]`` (one of),
``[a-z]`` (range), ``[^abc]`` (none of).
- **Empty allowlist = allow all domains.**
- **Denylist always overrides allowlist.**
Common denylist for noisy static assets:
``["*.gif", "*.jpg", "*.png", "*.css", "*.js", "*.ico", "*.svg",
"*woff*", "*.ttf"]``.
Each scope has a unique id usable as ``scope_id`` in
``list_requests`` / ``list_sitemap`` / ``view_request``.
Args:
action:
- ``list`` — return all scopes.
- ``get`` — single scope by ``scope_id`` (or all when
omitted).
- ``create`` — needs ``scope_name``, optionally
``allowlist`` / ``denylist``.
- ``update`` — needs ``scope_id`` + ``scope_name``;
allowlist / denylist replace the previous values.
- ``delete`` — needs ``scope_id``.
allowlist: Domain patterns to include (e.g.
``["*.example.com", "api.test.com"]``).
denylist: Patterns to exclude.
scope_id: Required for ``get`` / ``update`` / ``delete``.
scope_name: Required for ``create`` / ``update``.
"""
return _dump(
await post_to_sandbox(
ctx,
@@ -193,13 +303,30 @@ async def list_sitemap(
depth: SitemapDepth = "DIRECT",
page: int = 1,
) -> str:
"""List Caido sitemap entries (proxied URL tree).
"""View the hierarchical sitemap of discovered attack surface.
The sitemap is built from proxied traffic — every URL the target
served gets indexed into a tree of domains → directories → request
leaves. Use it to understand application structure and find
interesting endpoints, hidden directories, parameter variations.
Entry kinds you'll encounter:
- ``DOMAIN`` — root host (``example.com``).
- ``DIRECTORY`` — path segment (``/api/``, ``/admin/``).
- ``REQUEST`` — a specific endpoint.
- ``REQUEST_BODY`` — POST/PUT body variations (different payloads
seen at the same URL).
- ``REQUEST_QUERY`` — query-string variations.
Each entry has ``hasDescendants`` — set ``parent_id`` to that
entry's id to drill in. Pages return 30 entries each.
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.
scope_id: Filter to a specific scope.
parent_id: Drill into a subtree. ``None`` returns root domains.
depth: ``"DIRECT"`` (immediate children) or ``"ALL"`` (recursive).
page: 1-indexed page (30 entries/page).
"""
return _dump(
await post_to_sandbox(
@@ -217,7 +344,15 @@ async def list_sitemap(
@strix_tool(timeout=60)
async def view_sitemap_entry(ctx: RunContextWrapper, entry_id: str) -> str:
"""Fetch a single sitemap entry's metadata + linked requests."""
"""Examine one sitemap entry — full metadata + every related request.
Use this after ``list_sitemap`` identifies an interesting directory
or endpoint to see all the requests captured under it (methods,
paths, response codes, timing).
Args:
entry_id: Sitemap entry id from ``list_sitemap``.
"""
return _dump(
await post_to_sandbox(ctx, "view_sitemap_entry", {"entry_id": entry_id}),
)
+45 -5
View File
@@ -31,13 +31,53 @@ async def python_action(
timeout: int = 30,
session_id: str | None = None,
) -> str:
"""Manage / execute code in a long-lived sandboxed IPython session.
"""Run Python code in a long-lived IPython session — preferred for any
Python work (payloads, exploit scripts, HTTP automation, log analysis,
crypto, data processing).
Pick this over ``terminal_execute`` whenever the work is Python.
Don't wrap Python in bash heredocs, ``python -c`` one-liners, or
interactive REPL sessions in the terminal — the structured,
persistent, debuggable execution lives here.
Sessions are **persistent** — variables, imports, and function
definitions survive between ``execute`` calls within the same
``session_id``. Each session has its own isolated namespace; multiple
sessions can run concurrently. Sessions stay alive until explicitly
``close``-d.
Caido proxy helpers are pre-imported into every session, so you can
correlate captured HTTP requests with custom analysis without any
setup: ``list_requests`` / ``view_request`` / ``send_request`` /
``repeat_request`` / ``scope_rules`` / ``list_sitemap`` /
``view_sitemap_entry`` are all available as bare names.
For large payload sprays / fuzzing loops, encapsulate the entire
loop inside a single ``python_action`` ``execute`` call (e.g.,
asyncio + aiohttp). Don't issue one tool call per payload — that
burns turns and is dramatically slower.
Code execution notes:
- Both expressions and statements are supported. Expressions auto-
return their result; ``print`` output is captured to stdout.
- IPython magics work: ``%pip install ...``, ``%time``, ``%whos``,
``%%writefile``, etc.
- Use real newlines in multi-line ``code``, not literal ``\\n``.
Workflow:
1. ``new_session`` (always first per ``session_id``) — optionally
pass ``code`` for an initial setup snippet (imports, helpers).
2. ``execute`` — run code. Variables persist across calls.
3. ``close`` — terminate the session and free memory.
4. ``list_sessions`` — inspect what's currently alive.
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).
action: ``"new_session"`` / ``"execute"`` / ``"close"`` /
``"list_sessions"``.
code: Required for ``execute``; optional initial code for
``new_session``.
timeout: Per-call execution budget in seconds. Default 30.
session_id: Required for ``execute`` / ``close``. Optional for
``new_session`` (auto-generated when omitted).
+71 -4
View File
@@ -333,11 +333,78 @@ async def create_vulnerability_report(
cwe: str | None = None,
code_locations: str | None = None,
) -> str:
"""File a vulnerability report against the active scan.
"""File a vulnerability report — one report per fully-verified finding.
The report is dedup-checked against existing reports (LLM-based
similarity); if it's a near-duplicate, the call returns a
``duplicate_of`` pointer instead of creating a new entry.
**When to file**: you have a concrete vulnerability with a working
proof-of-concept and you're 100% sure it's a real issue.
**When NOT to file**:
- General security observations without a specific vulnerability.
- Suspicions you haven't confirmed with a PoC.
- Tracking multiple vulnerabilities at once — one report per vuln.
- Re-reporting something you (or another agent) already filed.
Automatic LLM-based **deduplication** rejects reports that describe
the same root cause on the same asset as an existing report. If you
get a ``duplicate_of`` response, do NOT retry — move on to other
areas.
**Customer-facing report rules** (the report is PDF-rendered for
delivery):
- No internal/system details: never mention paths like
``/workspace``, internal tools, agents, sandboxes, models, system
prompts, internal errors / stack traces, or tester environment.
- Tone: formal, objective, third-person, vendor-neutral, concise.
- Standard finding structure: Overview → Severity & CVSS →
Affected assets → Technical details → PoC (steps + code) →
Impact → Remediation → Evidence (in technical_analysis).
- Numbered steps allowed only in PoC and Remediation sections.
- Avoid hedging language; be precise and non-vague.
**White-box requirement**: when source is available, you MUST
populate ``code_locations`` with nested XML including
``fix_before`` / ``fix_after`` for proposed fixes. The fix_before
must be a verbatim copy of source at the specified line range — it's
used as a literal GitHub/GitLab PR suggestion block.
**CVSS breakdown** is required as nested XML with all 8 metrics
(each a single uppercase letter):
- ``attack_vector``: ``N`` (Network), ``A`` (Adjacent), ``L``
(Local), ``P`` (Physical)
- ``attack_complexity``: ``L`` / ``H``
- ``privileges_required``: ``N`` / ``L`` / ``H``
- ``user_interaction``: ``N`` / ``R``
- ``scope``: ``U`` (Unchanged) / ``C`` (Changed)
- ``confidentiality`` / ``integrity`` / ``availability``: ``N`` /
``L`` / ``H``
**CVE / CWE rules**: pass the bare ID only (``CVE-2024-1234``,
``CWE-89``) — no name, no parenthetical. Be 100% certain; if
unsure, omit. Always prefer the most specific child CWE over a
broad parent (CWE-89 not CWE-74; CWE-78 not CWE-77).
Args:
title: Specific finding title (e.g.
``"SQL Injection in /api/users login parameter"``). Don't
include the CVE number in the title.
description: How the vuln was discovered + what it is.
impact: What an attacker achieves; business risk; data at risk.
target: Affected URL / domain / repository.
technical_analysis: The mechanism and root cause.
poc_description: Step-by-step reproduction.
poc_script_code: Working PoC (Python preferred).
remediation_steps: Specific, actionable fix.
cvss_breakdown: 8-metric XML block per the format above.
endpoint: API path / Git path (e.g. ``/api/login``).
method: HTTP method when relevant.
cve: ``CVE-YYYY-NNNNN`` if certain, else omit.
cwe: ``CWE-NNN`` (most specific child) if certain, else omit.
code_locations: Required for white-box findings; nested XML
list with ``file``, ``start_line``, ``end_line``,
``snippet``, ``fix_before``, ``fix_after``.
"""
del ctx
result = await asyncio.to_thread(
+58 -8
View File
@@ -31,16 +31,66 @@ async def terminal_execute(
) -> str:
"""Run a shell command in the sandboxed Kali tmux session.
The session is **persistent** — environment variables, current
directory, and running processes carry across calls keyed by
``terminal_id`` (default: ``"default"``). Use distinct ids to run
multiple concurrent sessions.
When to use this vs ``python_action``:
- Shell work: CLI tools (nmap, sqlmap, ffuf, nuclei), package
managers, file/system commands, services, process control. Use
``terminal_execute``.
- Python code, data processing, HTTP automation, iterative scripting:
use ``python_action`` instead — it's more structured and easier to
debug. Don't run embedded Python via ``python -c`` or heredocs
here.
Avoid long pipelines and complex bash one-liners; prefer multiple
simple calls for clarity and debugging. For multi-step shell work,
separate tool calls beat ``&& ; |``-chained commands.
Long-running commands:
- Commands are **never** killed automatically — they keep running
after the timeout fires.
- ``timeout`` (max 60s, capped) only controls how long to wait for
output before returning. On timeout the call returns
``status="running"``; on completion ``status="completed"``.
- For daemons / very long jobs, append ``&`` to background.
- Use an **empty command** to poll for new output from a running
process (the call waits ``timeout`` seconds collecting output).
- Use ``C-c`` / ``C-d`` / ``C-z`` to interrupt — special keys work
automatically without setting ``is_input``.
Interactive processes:
- ``is_input=True`` sends the command as input to a running foreground
process (REPL prompts, ``apt install`` y/n, etc.).
- ``no_enter=True`` sends keystrokes without a trailing newline —
useful for vim navigation (``gg``, ``5j``, ``i``), passwords, or
multi-step keybindings.
Special key support (tmux key names): ``C-c``, ``C-d``, ``Up``,
``Down``, ``F1``-``F12``, ``Enter``, ``Escape``, ``Tab``, ``Space``,
``BSpace``, ``M-f`` (alt), ``S-Tab`` (shift), and combinations like
``C-S-key``. Note: ``BSpace`` not ``Backspace``, ``Escape`` not
``Esc``.
Working directory is tracked across calls and returned in the
response. Large outputs are auto-truncated.
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"``.
command: Shell command, special key (``C-c``), or empty string
to poll a running process.
is_input: Treat ``command`` as input to a running foreground
process. Special keys auto-detect; you only need this for
regular text input.
timeout: Seconds to wait before returning partial output. Capped
at 60s. Defaults to 30s.
terminal_id: Persistent session selector. Use distinct ids for
concurrent sessions.
no_enter: When True, sends keystrokes without a trailing return.
Useful for sending raw ANSI control sequences.
"""
return _dump(
await post_to_sandbox(
+19 -6
View File
@@ -9,15 +9,28 @@ from strix.tools._decorator import strix_tool
@strix_tool(timeout=10)
async def think(thought: str) -> str:
"""Record a private chain-of-thought note without taking any action.
"""Record a private chain-of-thought note. No side effects, no new info.
The "think" tool is the planning escape hatch for situations where a
message-without-tool-call would otherwise halt the run (per the
interactive-mode tool-call requirement). The thought itself is
recorded but produces no side effects.
Use ``think`` when you need a dedicated space to reason before acting —
not as an output channel. It's particularly valuable for:
- **Tool output analysis** — carefully processing the output of a
previous tool call before deciding the next step.
- **Policy-heavy environments** — when you need to follow detailed
guidelines (engagement scope, auth boundaries) and verify compliance
before each action.
- **Sequential decision making** — when each action builds on previous
ones and mistakes are costly (e.g., destructive operations,
irreversible auth changes).
- **Multi-step exploit planning** — breaking down a complex chain into
manageable steps and tracking what's been confirmed vs. assumed.
Structure your thought to be useful: current state, what you've
confirmed, your next planned actions, risk assessment. Don't use
``think`` to chat — use it to plan.
Args:
thought: The agent's reasoning to record. Must be non-empty.
thought: The reasoning to record. Must be non-empty.
"""
if not thought or not thought.strip():
return json.dumps({"success": False, "message": "Thought cannot be empty"})
+69 -6
View File
@@ -211,7 +211,33 @@ async def create_todo(
priority: str = "normal",
todos: str | None = None,
) -> str:
"""Create one or many todos for the current agent."""
"""Create one or many todos for the current agent.
Each agent (including subagents) has its **own private todo list** —
your todos don't leak to other agents and vice versa.
When to use:
- Planning multi-step assessments with parallel workstreams.
- Tracking work you'll come back to later.
- Breaking down complex scopes (per-endpoint, per-target, per-vuln-class).
When NOT to use:
- Simple linear workflows where progress is obvious.
- Single quick task — just do it.
Batch related todos in one call via the ``todos`` bulk parameter
rather than firing many ``create_todo`` calls.
Args:
title: Short, actionable title (e.g., "Test /api/admin for IDOR").
description: Optional details / context for the single todo.
priority: ``"low"`` / ``"normal"`` / ``"high"`` / ``"critical"``.
todos: Bulk create — either JSON array of
``{"title": "...", "description": "...", "priority": "..."}``
objects, or a newline-separated bullet list (``- item\\n- item``).
"""
agent_id = _agent_id_from(ctx)
try:
default_priority = _normalize_priority(priority)
@@ -271,7 +297,16 @@ async def list_todos(
status: str | None = None,
priority: str | None = None,
) -> str:
"""List the current agent's todos, sorted by status then priority."""
"""List the current agent's todos, sorted by status then priority.
Sort order: status (done → in_progress → pending), then priority
within each status (critical → high → normal → low).
Args:
status: Filter — ``"pending"`` / ``"in_progress"`` / ``"done"``.
priority: Filter — ``"low"`` / ``"normal"`` / ``"high"`` /
``"critical"``.
"""
agent_id = _agent_id_from(ctx)
try:
agent_todos = _get_agent_todos(agent_id)
@@ -333,7 +368,19 @@ async def update_todo(
status: str | None = None,
updates: str | None = None,
) -> str:
"""Update one or many todos."""
"""Update one or many todos. Prefer the bulk form for multiple updates.
For toggling status only, use the dedicated ``mark_todo_done`` /
``mark_todo_pending`` tools — they're simpler and accept bulk
``todo_ids``.
Args:
todo_id: Single-todo target.
title / description / priority / status: New values for the
single todo. Omit to leave unchanged.
updates: Bulk form — JSON array like
``[{"todo_id": "abc", "status": "done"}, ...]``.
"""
agent_id = _agent_id_from(ctx)
try:
agent_todos = _get_agent_todos(agent_id)
@@ -437,7 +484,13 @@ async def mark_todo_done(
todo_id: str | None = None,
todo_ids: str | None = None,
) -> str:
"""Mark one (``todo_id``) or many (``todo_ids``) todos as done."""
"""Mark one or many todos as done.
Args:
todo_id: Single todo's ID.
todo_ids: Bulk form — JSON array, comma-separated string, or
single ID. Combinable with ``todo_id`` for one-off plus bulk.
"""
return _mark(
agent_id=_agent_id_from(ctx),
todo_id=todo_id,
@@ -452,7 +505,12 @@ async def mark_todo_pending(
todo_id: str | None = None,
todo_ids: str | None = None,
) -> str:
"""Mark one (``todo_id``) or many (``todo_ids``) todos as pending."""
"""Reset one or many todos to pending (e.g., to retry a failed task).
Args:
todo_id: Single todo's ID.
todo_ids: Bulk form — JSON array, comma-separated, or single ID.
"""
return _mark(
agent_id=_agent_id_from(ctx),
todo_id=todo_id,
@@ -467,7 +525,12 @@ async def delete_todo(
todo_id: str | None = None,
todo_ids: str | None = None,
) -> str:
"""Delete one (``todo_id``) or many (``todo_ids``) todos."""
"""Delete one or many todos. Removes them entirely (no soft-delete).
Args:
todo_id: Single todo's ID.
todo_ids: Bulk form — JSON array, comma-separated, or single ID.
"""
agent_id = _agent_id_from(ctx)
try:
agent_todos = _get_agent_todos(agent_id)
+32 -3
View File
@@ -86,11 +86,40 @@ def _do_search(query: str) -> dict[str, Any]:
# budget so the round-trip + JSON decode doesn't push us over.
@strix_tool(timeout=330)
async def web_search(ctx: RunContextWrapper, query: str) -> str:
"""Search the web with Perplexity, scoped to security-relevant content.
"""Real-time web search via Perplexity — your primary research tool.
Use it liberally for anything that's not in your training data:
- Current CVEs, advisories, and 0-days for a specific
service/version (``OpenSSH 9.6 RCE``, ``Jenkins 2.401.3 auth
bypass``).
- Latest WAF / EDR bypass techniques (``Cloudflare WAF SQLi
bypass 2025``, ``CrowdStrike Falcon evasion``).
- Tool documentation, flag references, payload galleries.
- Target reconnaissance / OSINT (company tech stack, leaked
credentials, exposed assets).
- Cloud-provider misconfiguration patterns
(Azure/AWS/GCP-specific attack paths).
- Bug-bounty writeups and security research papers.
- Compliance frameworks and CWE/CVSS guidance.
- Picking the right Python lib / Kali tool for a job (``best 2025
lib for JWT alg-confusion``).
- When stuck — looking up the exact error message, ``Access
denied`` quirks, kernel-specific local-privesc exploits.
Be specific: include version numbers, error messages, target
technology, and the exact problem you're stuck on. The more context
in the query, the more actionable the answer. Vague queries get
generic answers.
A security-focused system prompt biases responses toward CVEs,
exploits, Kali-compatible tooling, and concrete code/command
examples.
Args:
query: The search query. A security-focused system prompt biases
results toward CVEs, exploits, and Kali-compatible commands.
query: The search query — a full sentence with version numbers,
target tech, and the specific question. Treat it like a
ticket title for a senior security engineer.
"""
del ctx
result = await asyncio.to_thread(_do_search, query)