first commit
This commit is contained in:
@@ -0,0 +1,11 @@
|
||||
"""Tool package.
|
||||
|
||||
Host-side SDK function tools live in ``<family>/tool[s].py`` and are
|
||||
imported directly by :mod:`strix.agents.factory`. The sandbox-bound
|
||||
shell + filesystem tools are emitted by the SDK's ``Shell`` and
|
||||
``Filesystem`` capabilities and bound to the live sandbox session
|
||||
per-run.
|
||||
|
||||
Import deeply so ``import strix.tools`` doesn't pull every submodule's
|
||||
deps in eagerly.
|
||||
"""
|
||||
@@ -0,0 +1,12 @@
|
||||
# agent-browser
|
||||
|
||||
Browser automation CLI installed in the sandbox image. Driven by the agent
|
||||
through `exec_command` (not a function tool).
|
||||
|
||||
- **Implementation:** sandbox CLI at
|
||||
`/home/pentester/.npm-global/bin/agent-browser` — npm package
|
||||
`agent-browser@0.26.0` (Vercel), driving Chromium directly.
|
||||
- **Strix config:** `containers/Dockerfile` sets `AGENT_BROWSER_*` env
|
||||
(executable path, UA, launch args, screenshot dir).
|
||||
- **Skill:** `strix/skills/tooling/agent_browser.md` — **always-loaded**
|
||||
into every agent prompt by `strix/agents/prompt.py:_resolve_skills`.
|
||||
@@ -0,0 +1,673 @@
|
||||
"""Multi-agent graph tools backed by AgentCoordinator."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import uuid
|
||||
from collections import Counter
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any, Literal, get_args
|
||||
|
||||
from agents import RunContextWrapper, function_tool
|
||||
|
||||
from strix.core.agents import Status, coordinator_from_context
|
||||
from strix.skills import validate_requested_skills
|
||||
|
||||
|
||||
_ACTIVE_STATUSES: frozenset[str] = frozenset({"running", "waiting"})
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _ctx(ctx: RunContextWrapper) -> dict[str, Any]:
|
||||
return ctx.context if isinstance(ctx.context, dict) else {}
|
||||
|
||||
|
||||
def _render_completion_report(
|
||||
*,
|
||||
agent_name: str,
|
||||
agent_id: str,
|
||||
task: str,
|
||||
success: bool,
|
||||
result_summary: str,
|
||||
findings: list[str],
|
||||
recommendations: list[str],
|
||||
) -> str:
|
||||
"""Render a child's completion report as plain structured text.
|
||||
|
||||
Goes into the parent's SDK session with coordinator-added sender
|
||||
metadata, so this body just carries the contents. No XML — no
|
||||
escaping concerns, no parser ambiguity.
|
||||
"""
|
||||
status = "SUCCESS" if success else "FAILED"
|
||||
completion_time = datetime.now(UTC).isoformat()
|
||||
|
||||
lines: list[str] = [
|
||||
f"== Completion report from {agent_name} ({agent_id}) ==",
|
||||
f"Status: {status}",
|
||||
f"Time: {completion_time}",
|
||||
]
|
||||
if task:
|
||||
lines.append(f"Task: {task}")
|
||||
lines.append("")
|
||||
lines.append("Summary:")
|
||||
lines.append(result_summary or "(none)")
|
||||
if findings:
|
||||
lines.append("")
|
||||
lines.append("Findings:")
|
||||
lines.extend(f"- {f}" for f in findings)
|
||||
if recommendations:
|
||||
lines.append("")
|
||||
lines.append("Recommendations:")
|
||||
lines.extend(f"- {r}" for r in recommendations)
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
@function_tool(timeout=30)
|
||||
async def view_agent_graph(ctx: RunContextWrapper) -> str:
|
||||
"""Print the multi-agent tree — every agent, its parent, its status.
|
||||
|
||||
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(ctx)
|
||||
coordinator = coordinator_from_context(inner)
|
||||
me = inner.get("agent_id")
|
||||
if coordinator is None:
|
||||
return json.dumps(
|
||||
{"success": False, "error": "Agent coordinator not initialized in context"},
|
||||
ensure_ascii=False,
|
||||
default=str,
|
||||
)
|
||||
|
||||
parent_of, statuses, names = await coordinator.graph_snapshot()
|
||||
|
||||
lines: list[str] = []
|
||||
|
||||
def render(aid: str, depth: int) -> None:
|
||||
status = statuses.get(aid, "?")
|
||||
marker = " ← you" if aid == me else ""
|
||||
lines.append(f"{' ' * depth}- {names.get(aid, aid)} ({aid}) [{status}]{marker}")
|
||||
for child, p in parent_of.items():
|
||||
if p == aid:
|
||||
render(child, depth + 1)
|
||||
|
||||
roots = [aid for aid, parent in parent_of.items() if parent is None]
|
||||
for root in roots:
|
||||
render(root, 0)
|
||||
|
||||
counts = Counter(statuses.values())
|
||||
summary: dict[str, int] = {"total": len(parent_of)}
|
||||
for status_name in get_args(Status):
|
||||
summary[status_name] = counts.get(status_name, 0)
|
||||
return json.dumps(
|
||||
{
|
||||
"success": True,
|
||||
"graph_structure": "\n".join(lines) or "(no agents)",
|
||||
"summary": summary,
|
||||
},
|
||||
ensure_ascii=False,
|
||||
default=str,
|
||||
)
|
||||
|
||||
|
||||
@function_tool(timeout=30)
|
||||
async def send_message_to_agent(
|
||||
ctx: RunContextWrapper,
|
||||
target_agent_id: str,
|
||||
message: str,
|
||||
message_type: Literal["query", "instruction", "information"] = "information",
|
||||
priority: Literal["low", "normal", "high", "urgent"] = "normal",
|
||||
) -> str:
|
||||
"""Send a message to another agent's inbox — sparingly.
|
||||
|
||||
Inter-agent messages are appended to the target's SDK session and
|
||||
interrupt any active target turn so the next run cycle sees them.
|
||||
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 any registered agent wake it, regardless of
|
||||
status, so a follow-up can restart a completed/stopped/failed agent.
|
||||
|
||||
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(ctx)
|
||||
coordinator = coordinator_from_context(inner)
|
||||
me = inner.get("agent_id")
|
||||
if coordinator is None or me is None:
|
||||
return json.dumps(
|
||||
{"success": False, "error": "Agent coordinator or agent_id missing in context"},
|
||||
ensure_ascii=False,
|
||||
default=str,
|
||||
)
|
||||
if target_agent_id == me:
|
||||
return json.dumps(
|
||||
{
|
||||
"success": False,
|
||||
"error": (
|
||||
"Cannot send a message to yourself; use `think` to record a "
|
||||
"private note, or `agent_finish` / `finish_scan` to terminate"
|
||||
),
|
||||
},
|
||||
ensure_ascii=False,
|
||||
default=str,
|
||||
)
|
||||
|
||||
msg_id = f"msg_{uuid.uuid4().hex[:8]}"
|
||||
delivered = await coordinator.send(
|
||||
target_agent_id,
|
||||
{
|
||||
"id": msg_id,
|
||||
"from": me,
|
||||
"content": message,
|
||||
"type": message_type,
|
||||
"priority": priority,
|
||||
},
|
||||
)
|
||||
if not delivered:
|
||||
return json.dumps(
|
||||
{
|
||||
"success": False,
|
||||
"error": f"Target agent '{target_agent_id}' not found or message delivery failed",
|
||||
},
|
||||
ensure_ascii=False,
|
||||
default=str,
|
||||
)
|
||||
return json.dumps(
|
||||
{
|
||||
"success": True,
|
||||
"message_id": msg_id,
|
||||
"target_agent_id": target_agent_id,
|
||||
"delivery_status": "delivered",
|
||||
},
|
||||
ensure_ascii=False,
|
||||
default=str,
|
||||
)
|
||||
|
||||
|
||||
def _session_items_payload(items: list[Any]) -> list[dict[str, Any]]:
|
||||
payload: list[dict[str, Any]] = []
|
||||
for item in items:
|
||||
if isinstance(item, dict):
|
||||
role = item.get("role")
|
||||
content = item.get("content")
|
||||
payload.append({"role": role, "content": content})
|
||||
else:
|
||||
payload.append({"content": str(item)})
|
||||
return payload
|
||||
|
||||
|
||||
@function_tool(timeout=601)
|
||||
async def wait_for_message( # noqa: PLR0911
|
||||
ctx: RunContextWrapper,
|
||||
reason: str = "Waiting for messages from other agents",
|
||||
timeout_seconds: int = 600,
|
||||
) -> str:
|
||||
"""Pause this agent until a message lands in its inbox (or timeout).
|
||||
|
||||
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: 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(ctx)
|
||||
coordinator = coordinator_from_context(inner)
|
||||
me = inner.get("agent_id")
|
||||
interactive = bool(inner.get("interactive", False))
|
||||
if coordinator is None or me is None:
|
||||
return json.dumps(
|
||||
{"success": False, "error": "Agent coordinator or agent_id missing in context"},
|
||||
ensure_ascii=False,
|
||||
default=str,
|
||||
)
|
||||
|
||||
async with coordinator._lock:
|
||||
stopped = coordinator.statuses.get(me) == "stopped"
|
||||
if stopped:
|
||||
return json.dumps(
|
||||
{
|
||||
"success": True,
|
||||
"wait_outcome": "stopped",
|
||||
"reason": reason,
|
||||
"note": "Wait ended because this agent is stopped.",
|
||||
},
|
||||
ensure_ascii=False,
|
||||
default=str,
|
||||
)
|
||||
|
||||
pending, items = await coordinator.consume_pending(me, include_items=True)
|
||||
if pending > 0:
|
||||
await coordinator.mark_running(me)
|
||||
return json.dumps(
|
||||
{
|
||||
"success": True,
|
||||
"wait_outcome": "message_arrived",
|
||||
"pending_messages": pending,
|
||||
"messages": _session_items_payload(items),
|
||||
"reason": reason,
|
||||
},
|
||||
ensure_ascii=False,
|
||||
default=str,
|
||||
)
|
||||
|
||||
if interactive:
|
||||
await coordinator.park_waiting(me)
|
||||
return json.dumps(
|
||||
{
|
||||
"success": True,
|
||||
"wait_outcome": "waiting",
|
||||
"reason": reason,
|
||||
"note": "Agent parked; execution will resume when a message arrives.",
|
||||
},
|
||||
ensure_ascii=False,
|
||||
default=str,
|
||||
)
|
||||
|
||||
await coordinator.park_waiting(me)
|
||||
try:
|
||||
await asyncio.wait_for(coordinator.wait_for_message(me), timeout_seconds)
|
||||
except TimeoutError:
|
||||
await coordinator.mark_running(me)
|
||||
return json.dumps(
|
||||
{
|
||||
"success": True,
|
||||
"wait_outcome": "timeout",
|
||||
"timeout_seconds": timeout_seconds,
|
||||
"reason": reason,
|
||||
"note": "No messages within timeout — continue work or call agent_finish.",
|
||||
},
|
||||
ensure_ascii=False,
|
||||
default=str,
|
||||
)
|
||||
|
||||
async with coordinator._lock:
|
||||
stopped = coordinator.statuses.get(me) == "stopped"
|
||||
if stopped:
|
||||
return json.dumps(
|
||||
{
|
||||
"success": True,
|
||||
"wait_outcome": "stopped",
|
||||
"reason": reason,
|
||||
"note": "Wait ended because this agent is stopped.",
|
||||
},
|
||||
ensure_ascii=False,
|
||||
default=str,
|
||||
)
|
||||
|
||||
pending, items = await coordinator.consume_pending(me, include_items=True)
|
||||
await coordinator.mark_running(me)
|
||||
|
||||
return json.dumps(
|
||||
{
|
||||
"success": True,
|
||||
"wait_outcome": "message_arrived",
|
||||
"pending_messages": pending,
|
||||
"messages": _session_items_payload(items),
|
||||
"reason": reason,
|
||||
},
|
||||
ensure_ascii=False,
|
||||
default=str,
|
||||
)
|
||||
|
||||
|
||||
@function_tool(timeout=120)
|
||||
async def create_agent(
|
||||
ctx: RunContextWrapper,
|
||||
name: str,
|
||||
task: str,
|
||||
inherit_context: bool = True,
|
||||
skills: list[str] | None = None,
|
||||
) -> str:
|
||||
"""Spawn a specialist child agent to run in parallel.
|
||||
|
||||
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 (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: List of skill names (e.g. ``["xss", "sql_injection"]``).
|
||||
Max 5; prefer 1-3.
|
||||
"""
|
||||
inner = _ctx(ctx)
|
||||
coordinator = coordinator_from_context(inner)
|
||||
parent_id = inner.get("agent_id")
|
||||
spawner = inner.get("spawn_child_agent")
|
||||
|
||||
if coordinator is None or parent_id is None:
|
||||
return json.dumps(
|
||||
{"success": False, "error": "Agent coordinator or agent_id missing in context"},
|
||||
ensure_ascii=False,
|
||||
default=str,
|
||||
)
|
||||
if not callable(spawner):
|
||||
return json.dumps(
|
||||
{
|
||||
"success": False,
|
||||
"error": "Scan runner did not provide a child-agent spawner in context",
|
||||
},
|
||||
ensure_ascii=False,
|
||||
default=str,
|
||||
)
|
||||
|
||||
skill_list = list(skills or [])
|
||||
skill_error = validate_requested_skills(skill_list)
|
||||
if skill_error:
|
||||
return json.dumps(
|
||||
{"success": False, "error": skill_error, "agent_id": None},
|
||||
ensure_ascii=False,
|
||||
default=str,
|
||||
)
|
||||
|
||||
parent_history = list(ctx.turn_input) if inherit_context and ctx.turn_input else []
|
||||
try:
|
||||
result = await spawner(
|
||||
parent_ctx=inner,
|
||||
name=name,
|
||||
task=task,
|
||||
skills=skill_list,
|
||||
parent_history=parent_history,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception("create_agent: scan runner failed to spawn child '%s'", name)
|
||||
return json.dumps(
|
||||
{"success": False, "error": f"child spawn failed: {e!s}"},
|
||||
ensure_ascii=False,
|
||||
default=str,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"create_agent: spawned %s (%s) parent=%s skills=%d task_len=%d",
|
||||
result.get("agent_id"),
|
||||
name,
|
||||
parent_id or "-",
|
||||
len(skill_list),
|
||||
len(task or ""),
|
||||
)
|
||||
|
||||
return json.dumps(
|
||||
result,
|
||||
ensure_ascii=False,
|
||||
default=str,
|
||||
)
|
||||
|
||||
|
||||
@function_tool(timeout=30)
|
||||
async def agent_finish(
|
||||
ctx: RunContextWrapper,
|
||||
result_summary: str,
|
||||
findings: list[str] | None = None,
|
||||
success: bool = True,
|
||||
report_to_parent: bool = True,
|
||||
final_recommendations: list[str] | None = None,
|
||||
) -> str:
|
||||
"""Subagent termination — post a completion report to the parent.
|
||||
|
||||
**Subagents only.** Root agents must call ``finish_scan`` instead;
|
||||
this tool refuses to run for root agents. Calling this:
|
||||
|
||||
1. Marks the subagent as ``completed``.
|
||||
2. Posts a structured 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(ctx)
|
||||
coordinator = coordinator_from_context(inner)
|
||||
me = inner.get("agent_id")
|
||||
if coordinator is None or me is None:
|
||||
return json.dumps(
|
||||
{"success": False, "error": "Agent coordinator or agent_id missing in context"},
|
||||
ensure_ascii=False,
|
||||
default=str,
|
||||
)
|
||||
|
||||
parent_id = inner.get("parent_id")
|
||||
if parent_id is None:
|
||||
return json.dumps(
|
||||
{
|
||||
"success": False,
|
||||
"error": (
|
||||
"agent_finish is for subagents. Root/main agents must call finish_scan instead"
|
||||
),
|
||||
},
|
||||
ensure_ascii=False,
|
||||
default=str,
|
||||
)
|
||||
|
||||
parent_notified = False
|
||||
if report_to_parent:
|
||||
async with coordinator._lock:
|
||||
agent_name = coordinator.names.get(me, me)
|
||||
report = _render_completion_report(
|
||||
agent_name=agent_name,
|
||||
agent_id=me,
|
||||
task=str(inner.get("task", "")),
|
||||
success=success,
|
||||
result_summary=result_summary,
|
||||
findings=list(findings or []),
|
||||
recommendations=list(final_recommendations or []),
|
||||
)
|
||||
await coordinator.send(
|
||||
parent_id,
|
||||
{
|
||||
"id": f"report_{uuid.uuid4().hex[:8]}",
|
||||
"from": me,
|
||||
"content": report,
|
||||
"type": "completion",
|
||||
"priority": "high",
|
||||
},
|
||||
)
|
||||
parent_notified = True
|
||||
|
||||
logger.info(
|
||||
"agent_finish: %s success=%s findings=%d parent_notified=%s",
|
||||
me,
|
||||
success,
|
||||
len(findings or []),
|
||||
parent_notified,
|
||||
)
|
||||
await coordinator.set_status(me, "completed")
|
||||
|
||||
return json.dumps(
|
||||
{
|
||||
"success": True,
|
||||
"agent_completed": True,
|
||||
"parent_notified": parent_notified,
|
||||
"agent_id": me,
|
||||
"summary": result_summary,
|
||||
"findings_count": len(findings or []),
|
||||
"has_recommendations": bool(final_recommendations),
|
||||
},
|
||||
ensure_ascii=False,
|
||||
default=str,
|
||||
)
|
||||
|
||||
|
||||
@function_tool(timeout=30)
|
||||
async def stop_agent(
|
||||
ctx: RunContextWrapper,
|
||||
target_agent_id: str,
|
||||
cascade: bool = True,
|
||||
reason: str = "",
|
||||
) -> str:
|
||||
"""Gracefully stop a running agent (and optionally its descendants).
|
||||
|
||||
Uses the SDK's ``RunResultStreaming.cancel(mode="after_turn")`` so the
|
||||
target's current turn finishes — including saving items to its
|
||||
session — before the run loop honors the cancel. The agent's
|
||||
interactive outer loop parks as ``stopped``; later user/peer
|
||||
messages can wake it again.
|
||||
|
||||
Use sparingly. Prefer ``send_message_to_agent`` (asking the agent
|
||||
to wrap up) for soft-stop scenarios. Reach for ``stop_agent`` when
|
||||
a child has gone off-track and won't self-correct.
|
||||
|
||||
Args:
|
||||
target_agent_id: The 8-char id from ``view_agent_graph`` /
|
||||
``create_agent``. Cannot stop yourself.
|
||||
cascade: If ``True`` (default), also stop every descendant of
|
||||
``target_agent_id`` leaves-first. ``False`` stops only the
|
||||
target.
|
||||
reason: Optional human-readable reason for the stop, surfaced
|
||||
in logs and telemetry.
|
||||
"""
|
||||
inner = _ctx(ctx)
|
||||
coordinator = coordinator_from_context(inner)
|
||||
me = inner.get("agent_id")
|
||||
if coordinator is None or me is None:
|
||||
return json.dumps(
|
||||
{"success": False, "error": "Agent coordinator or agent_id missing in context"},
|
||||
ensure_ascii=False,
|
||||
default=str,
|
||||
)
|
||||
if target_agent_id == me:
|
||||
return json.dumps(
|
||||
{
|
||||
"success": False,
|
||||
"error": "Cannot stop yourself; call agent_finish or finish_scan instead",
|
||||
},
|
||||
ensure_ascii=False,
|
||||
default=str,
|
||||
)
|
||||
_, statuses, _ = await coordinator.graph_snapshot()
|
||||
if target_agent_id not in statuses:
|
||||
return json.dumps(
|
||||
{"success": False, "error": f"Unknown agent_id: {target_agent_id}"},
|
||||
ensure_ascii=False,
|
||||
default=str,
|
||||
)
|
||||
|
||||
current_status = statuses[target_agent_id]
|
||||
if current_status not in _ACTIVE_STATUSES:
|
||||
return json.dumps(
|
||||
{
|
||||
"success": False,
|
||||
"error": (
|
||||
f"Agent {target_agent_id} is already '{current_status}'; "
|
||||
"stop_agent only acts on running/waiting agents — use "
|
||||
"view_agent_graph to find still-active descendants and "
|
||||
"stop them individually, or send_message_to_agent if you "
|
||||
"want to wake this one with new instructions"
|
||||
),
|
||||
"target_agent_id": target_agent_id,
|
||||
"current_status": current_status,
|
||||
},
|
||||
ensure_ascii=False,
|
||||
default=str,
|
||||
)
|
||||
|
||||
if cascade:
|
||||
await coordinator.cancel_descendants_graceful(target_agent_id)
|
||||
else:
|
||||
await coordinator.request_stop(target_agent_id)
|
||||
|
||||
logger.info(
|
||||
"stop_agent: target=%s cascade=%s reason=%r",
|
||||
target_agent_id,
|
||||
cascade,
|
||||
reason,
|
||||
)
|
||||
return json.dumps(
|
||||
{
|
||||
"success": True,
|
||||
"target_agent_id": target_agent_id,
|
||||
"cascade": cascade,
|
||||
"reason": reason,
|
||||
"note": "Cancellation is graceful — current turn completes first.",
|
||||
},
|
||||
ensure_ascii=False,
|
||||
default=str,
|
||||
)
|
||||
@@ -0,0 +1,10 @@
|
||||
# apply_patch
|
||||
|
||||
SDK-provided file patching tool — the agent's only first-class way to edit
|
||||
files in the sandbox. Surfaced to the model as `patch` (renamed via
|
||||
`_TOOL_NAME_OVERRIDES` in `strix/agents/factory.py`).
|
||||
|
||||
- **Implementation:** `agents.sandbox.capabilities.tools.apply_patch_tool.ApplyPatchTool`
|
||||
(upstream `agents` SDK)
|
||||
- **Wired in:** `strix/agents/factory.py` — added per-run via the SDK
|
||||
`Filesystem` capability.
|
||||
@@ -0,0 +1,188 @@
|
||||
"""``finish_scan`` — root-agent termination + executive report persistence."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from agents import RunContextWrapper, function_tool
|
||||
|
||||
from strix.core.agents import coordinator_from_context
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _do_finish(
|
||||
*,
|
||||
parent_id: str | None,
|
||||
executive_summary: str,
|
||||
methodology: str,
|
||||
technical_analysis: str,
|
||||
recommendations: str,
|
||||
) -> dict[str, Any]:
|
||||
if parent_id is not None:
|
||||
return {
|
||||
"success": False,
|
||||
"error": (
|
||||
"This tool can only be used by the root/main agent. "
|
||||
"If you are a subagent, use agent_finish instead"
|
||||
),
|
||||
}
|
||||
|
||||
errors: list[str] = []
|
||||
if not executive_summary.strip():
|
||||
errors.append("Executive summary cannot be empty")
|
||||
if not methodology.strip():
|
||||
errors.append("Methodology cannot be empty")
|
||||
if not technical_analysis.strip():
|
||||
errors.append("Technical analysis cannot be empty")
|
||||
if not recommendations.strip():
|
||||
errors.append("Recommendations cannot be empty")
|
||||
if errors:
|
||||
return {"success": False, "error": "Validation failed", "errors": errors}
|
||||
|
||||
try:
|
||||
from strix.report.state import get_global_report_state
|
||||
|
||||
report_state = get_global_report_state()
|
||||
if report_state is None:
|
||||
logger.warning("No global report state; scan results not persisted")
|
||||
return {
|
||||
"success": True,
|
||||
"scan_completed": True,
|
||||
"message": "Scan completed (not persisted)",
|
||||
"warning": "Results could not be persisted - report state unavailable",
|
||||
}
|
||||
report_state.update_scan_final_fields(
|
||||
executive_summary=executive_summary.strip(),
|
||||
methodology=methodology.strip(),
|
||||
technical_analysis=technical_analysis.strip(),
|
||||
recommendations=recommendations.strip(),
|
||||
)
|
||||
vuln_count = len(report_state.vulnerability_reports)
|
||||
except (ImportError, AttributeError) as e:
|
||||
logger.exception("finish_scan persistence failed")
|
||||
return {"success": False, "error": f"Failed to complete scan: {e!s}"}
|
||||
else:
|
||||
logger.info(
|
||||
"finish_scan: completed scan with %d vulnerability report(s)",
|
||||
vuln_count,
|
||||
)
|
||||
return {
|
||||
"success": True,
|
||||
"scan_completed": True,
|
||||
"message": "Scan completed successfully",
|
||||
"vulnerabilities_found": vuln_count,
|
||||
}
|
||||
|
||||
|
||||
@function_tool(timeout=60)
|
||||
async def finish_scan(
|
||||
ctx: RunContextWrapper,
|
||||
executive_summary: str,
|
||||
methodology: str,
|
||||
technical_analysis: str,
|
||||
recommendations: str,
|
||||
) -> str:
|
||||
"""Finalize the scan — persist the customer-facing report.
|
||||
|
||||
**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 (mandatory — do not skip):**
|
||||
|
||||
1. **Call ``view_agent_graph`` first.** Inspect every entry in the
|
||||
summary. If ANY agent is in ``running`` / ``waiting`` state,
|
||||
you MUST NOT call ``finish_scan`` yet —
|
||||
wrap them up first via ``send_message_to_agent`` (ask them to
|
||||
finish), ``wait_for_message`` (block until their report
|
||||
arrives), or ``stop_agent`` (graceful cancel). Only ``completed``
|
||||
/ ``crashed`` / ``stopped`` agents are safe to leave behind.
|
||||
Calling ``finish_scan`` while children are alive orphans their
|
||||
work and produces an incomplete report.
|
||||
2. All vulnerabilities you found are filed via
|
||||
``create_vulnerability_report`` (un-reported findings are not
|
||||
tracked and not credited).
|
||||
3. 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: 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 {}
|
||||
coordinator = coordinator_from_context(inner)
|
||||
me = inner.get("agent_id")
|
||||
parent_id = inner.get("parent_id")
|
||||
if coordinator is not None and parent_id is None and me is not None:
|
||||
active_agents = await coordinator.active_agents_except(me)
|
||||
else:
|
||||
active_agents = []
|
||||
|
||||
if active_agents:
|
||||
return json.dumps(
|
||||
{
|
||||
"success": False,
|
||||
"scan_completed": False,
|
||||
"error": (
|
||||
"Cannot finish scan while child agents are still active. "
|
||||
"Wait for completion, send them finish instructions, or stop them first"
|
||||
),
|
||||
"active_agents": active_agents,
|
||||
},
|
||||
ensure_ascii=False,
|
||||
default=str,
|
||||
)
|
||||
|
||||
result = await asyncio.to_thread(
|
||||
_do_finish,
|
||||
parent_id=parent_id,
|
||||
executive_summary=executive_summary,
|
||||
methodology=methodology,
|
||||
technical_analysis=technical_analysis,
|
||||
recommendations=recommendations,
|
||||
)
|
||||
if (
|
||||
result.get("success")
|
||||
and result.get("scan_completed")
|
||||
and coordinator is not None
|
||||
and isinstance(me, str)
|
||||
):
|
||||
await coordinator.set_status(me, "completed")
|
||||
return json.dumps(result, ensure_ascii=False, default=str)
|
||||
@@ -0,0 +1,36 @@
|
||||
"""``load_skill`` — fetch skill reference material into the conversation."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from agents import RunContextWrapper, function_tool
|
||||
|
||||
from strix.skills import load_skills, validate_requested_skills
|
||||
|
||||
|
||||
@function_tool(timeout=10)
|
||||
async def load_skill(ctx: RunContextWrapper, skills: list[str]) -> str:
|
||||
"""Return the markdown body of one or more skills as reference material.
|
||||
|
||||
Use this when you need exact syntax / workflow / payload guidance
|
||||
right before acting on a technology that wasn't preloaded for your
|
||||
agent. The skill content lands inline as a tool result — no
|
||||
permanent prompt change, just in-conversation reference.
|
||||
|
||||
For permanent skill assignment, pass ``skills=[…]`` to
|
||||
``create_agent`` when spawning a specialist child instead.
|
||||
|
||||
Args:
|
||||
skills: List of skill names (e.g. ``["xss", "sql_injection"]``).
|
||||
Max 5. Names match the bare files under
|
||||
``strix/skills/<category>/<name>.md``.
|
||||
"""
|
||||
del ctx
|
||||
requested = list(skills or [])
|
||||
err = validate_requested_skills(requested)
|
||||
if err:
|
||||
return f"load_skill: {err}"
|
||||
contents = load_skills(requested)
|
||||
if not contents:
|
||||
return "load_skill: no content loaded for requested skills."
|
||||
sections = [f"## Skill: {name}\n\n{body}" for name, body in contents.items()]
|
||||
return "\n\n---\n\n".join(sections)
|
||||
@@ -0,0 +1,417 @@
|
||||
"""Per-run notes storage — mirrored to {state_dir}/notes.json."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import tempfile
|
||||
import threading
|
||||
import uuid
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from agents import RunContextWrapper, function_tool
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
_notes_storage: dict[str, dict[str, Any]] = {}
|
||||
_VALID_NOTE_CATEGORIES = ["general", "findings", "methodology", "questions", "plan", "wiki"]
|
||||
_notes_lock = threading.RLock()
|
||||
_DEFAULT_CONTENT_PREVIEW_CHARS = 280
|
||||
|
||||
_notes_path: Path | None = None
|
||||
|
||||
|
||||
def hydrate_notes_from_disk(state_dir: Path) -> None:
|
||||
global _notes_path # noqa: PLW0603
|
||||
_notes_path = state_dir / "notes.json"
|
||||
with _notes_lock:
|
||||
_notes_storage.clear()
|
||||
if not _notes_path.exists():
|
||||
return
|
||||
try:
|
||||
data = json.loads(_notes_path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
logger.exception(
|
||||
"notes.json at %s is unreadable; starting with empty notes",
|
||||
_notes_path,
|
||||
)
|
||||
return
|
||||
if not isinstance(data, dict):
|
||||
return
|
||||
_notes_storage.update(
|
||||
{
|
||||
nid: note
|
||||
for nid, note in data.items()
|
||||
if isinstance(nid, str) and isinstance(note, dict)
|
||||
}
|
||||
)
|
||||
logger.info(
|
||||
"notes hydrated from %s (%d note(s))",
|
||||
_notes_path,
|
||||
len(_notes_storage),
|
||||
)
|
||||
|
||||
|
||||
def _persist() -> None:
|
||||
path = _notes_path
|
||||
if path is None:
|
||||
return
|
||||
try:
|
||||
payload = json.dumps(_notes_storage, ensure_ascii=False, default=str)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with (
|
||||
_notes_lock,
|
||||
tempfile.NamedTemporaryFile(
|
||||
mode="w",
|
||||
encoding="utf-8",
|
||||
dir=str(path.parent),
|
||||
prefix=f".{path.name}.",
|
||||
suffix=".tmp",
|
||||
delete=False,
|
||||
) as tmp,
|
||||
):
|
||||
tmp.write(payload)
|
||||
tmp_path = Path(tmp.name)
|
||||
tmp_path.replace(path)
|
||||
except Exception:
|
||||
logger.exception("notes persist to %s failed", path)
|
||||
|
||||
|
||||
def _filter_notes(
|
||||
category: str | None = None,
|
||||
tags: list[str] | None = None,
|
||||
search_query: str | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
filtered: list[dict[str, Any]] = []
|
||||
for note_id, note in _notes_storage.items():
|
||||
if category and note.get("category") != category:
|
||||
continue
|
||||
if tags:
|
||||
note_tags = note.get("tags", [])
|
||||
if not any(tag in note_tags for tag in tags):
|
||||
continue
|
||||
if search_query:
|
||||
search_lower = search_query.lower()
|
||||
title_match = search_lower in note.get("title", "").lower()
|
||||
content_match = search_lower in note.get("content", "").lower()
|
||||
if not (title_match or content_match):
|
||||
continue
|
||||
entry = note.copy()
|
||||
entry["note_id"] = note_id
|
||||
filtered.append(entry)
|
||||
filtered.sort(key=lambda x: x.get("created_at", ""), reverse=True)
|
||||
return filtered
|
||||
|
||||
|
||||
def _to_note_listing_entry(
|
||||
note: dict[str, Any],
|
||||
*,
|
||||
include_content: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
entry = {
|
||||
"note_id": note.get("note_id"),
|
||||
"title": note.get("title", ""),
|
||||
"category": note.get("category", "general"),
|
||||
"tags": note.get("tags", []),
|
||||
"created_at": note.get("created_at", ""),
|
||||
"updated_at": note.get("updated_at", ""),
|
||||
}
|
||||
content = str(note.get("content", ""))
|
||||
if include_content:
|
||||
entry["content"] = content
|
||||
elif content:
|
||||
if len(content) > _DEFAULT_CONTENT_PREVIEW_CHARS:
|
||||
entry["content_preview"] = f"{content[:_DEFAULT_CONTENT_PREVIEW_CHARS].rstrip()}..."
|
||||
else:
|
||||
entry["content_preview"] = content
|
||||
return entry
|
||||
|
||||
|
||||
def _create_note_impl(
|
||||
title: str,
|
||||
content: str,
|
||||
category: str = "general",
|
||||
tags: list[str] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
with _notes_lock:
|
||||
try:
|
||||
if not title or not title.strip():
|
||||
return {"success": False, "error": "Title cannot be empty", "note_id": None}
|
||||
if not content or not content.strip():
|
||||
return {"success": False, "error": "Content cannot be empty", "note_id": None}
|
||||
if category not in _VALID_NOTE_CATEGORIES:
|
||||
return {
|
||||
"success": False,
|
||||
"error": (
|
||||
f"Invalid category. Must be one of: {', '.join(_VALID_NOTE_CATEGORIES)}"
|
||||
),
|
||||
"note_id": None,
|
||||
}
|
||||
|
||||
note_id = str(uuid.uuid4())[:6]
|
||||
|
||||
timestamp = datetime.now(UTC).isoformat()
|
||||
note = {
|
||||
"title": title.strip(),
|
||||
"content": content.strip(),
|
||||
"category": category,
|
||||
"tags": tags or [],
|
||||
"created_at": timestamp,
|
||||
"updated_at": timestamp,
|
||||
}
|
||||
_notes_storage[note_id] = note
|
||||
except (ValueError, TypeError) as e:
|
||||
return {"success": False, "error": f"Failed to create note: {e}", "note_id": None}
|
||||
else:
|
||||
_persist()
|
||||
return {
|
||||
"success": True,
|
||||
"note_id": note_id,
|
||||
"message": f"Note '{title}' created successfully",
|
||||
"total_count": len(_notes_storage),
|
||||
}
|
||||
|
||||
|
||||
def _list_notes_impl(
|
||||
category: str | None = None,
|
||||
tags: list[str] | None = None,
|
||||
search: str | None = None,
|
||||
include_content: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
with _notes_lock:
|
||||
try:
|
||||
filtered = _filter_notes(category=category, tags=tags, search_query=search)
|
||||
notes = [_to_note_listing_entry(n, include_content=include_content) for n in filtered]
|
||||
except (ValueError, TypeError) as e:
|
||||
return {
|
||||
"success": False,
|
||||
"error": f"Failed to list notes: {e}",
|
||||
"notes": [],
|
||||
"filtered_count": 0,
|
||||
"total_count": 0,
|
||||
}
|
||||
return {
|
||||
"success": True,
|
||||
"notes": notes,
|
||||
"filtered_count": len(notes),
|
||||
"total_count": len(_notes_storage),
|
||||
}
|
||||
|
||||
|
||||
def _get_note_impl(note_id: str) -> dict[str, Any]:
|
||||
with _notes_lock:
|
||||
try:
|
||||
if not note_id or not note_id.strip():
|
||||
return {"success": False, "error": "Note ID cannot be empty", "note": None}
|
||||
note = _notes_storage.get(note_id)
|
||||
if note is None:
|
||||
return {
|
||||
"success": False,
|
||||
"error": f"Note with ID '{note_id}' not found",
|
||||
"note": None,
|
||||
}
|
||||
note_with_id = note.copy()
|
||||
note_with_id["note_id"] = note_id
|
||||
except (ValueError, TypeError) as e:
|
||||
return {"success": False, "error": f"Failed to get note: {e}", "note": None}
|
||||
else:
|
||||
return {"success": True, "note": note_with_id}
|
||||
|
||||
|
||||
def _update_note_impl(
|
||||
note_id: str,
|
||||
title: str | None = None,
|
||||
content: str | None = None,
|
||||
tags: list[str] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
with _notes_lock:
|
||||
try:
|
||||
if note_id not in _notes_storage:
|
||||
return {"success": False, "error": f"Note with ID '{note_id}' not found"}
|
||||
note = _notes_storage[note_id]
|
||||
if title is not None:
|
||||
if not title.strip():
|
||||
return {"success": False, "error": "Title cannot be empty"}
|
||||
note["title"] = title.strip()
|
||||
if content is not None:
|
||||
if not content.strip():
|
||||
return {"success": False, "error": "Content cannot be empty"}
|
||||
note["content"] = content.strip()
|
||||
if tags is not None:
|
||||
note["tags"] = tags
|
||||
note["updated_at"] = datetime.now(UTC).isoformat()
|
||||
except (ValueError, TypeError) as e:
|
||||
return {"success": False, "error": f"Failed to update note: {e}"}
|
||||
else:
|
||||
_persist()
|
||||
return {
|
||||
"success": True,
|
||||
"note_id": note_id,
|
||||
"message": f"Note '{note['title']}' updated successfully",
|
||||
"total_count": len(_notes_storage),
|
||||
}
|
||||
|
||||
|
||||
def _delete_note_impl(note_id: str) -> dict[str, Any]:
|
||||
with _notes_lock:
|
||||
try:
|
||||
if note_id not in _notes_storage:
|
||||
return {"success": False, "error": f"Note with ID '{note_id}' not found"}
|
||||
note = _notes_storage[note_id]
|
||||
note_title = note["title"]
|
||||
del _notes_storage[note_id]
|
||||
except (ValueError, TypeError) as e:
|
||||
return {"success": False, "error": f"Failed to delete note: {e}"}
|
||||
else:
|
||||
_persist()
|
||||
return {
|
||||
"success": True,
|
||||
"note_id": note_id,
|
||||
"message": f"Note '{note_title}' deleted successfully",
|
||||
"total_count": len(_notes_storage),
|
||||
}
|
||||
|
||||
|
||||
@function_tool(timeout=30)
|
||||
async def create_note(
|
||||
ctx: RunContextWrapper,
|
||||
title: str,
|
||||
content: str,
|
||||
category: str = "general",
|
||||
tags: list[str] | None = None,
|
||||
) -> str:
|
||||
"""Document an observation, finding, methodology step, or research note.
|
||||
|
||||
Notes are visible to every agent in the same scan for the lifetime
|
||||
of the run; they live in-memory only and are cleared when the
|
||||
process exits.
|
||||
|
||||
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`` — long-form repository or target maps.
|
||||
|
||||
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.
|
||||
"""
|
||||
return json.dumps(
|
||||
await asyncio.to_thread(_create_note_impl, title, content, category, tags),
|
||||
ensure_ascii=False,
|
||||
default=str,
|
||||
)
|
||||
|
||||
|
||||
@function_tool(timeout=30)
|
||||
async def list_notes(
|
||||
ctx: RunContextWrapper,
|
||||
category: str | None = None,
|
||||
tags: list[str] | None = None,
|
||||
search: str | None = None,
|
||||
include_content: bool = False,
|
||||
) -> str:
|
||||
"""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.
|
||||
"""
|
||||
return json.dumps(
|
||||
await asyncio.to_thread(
|
||||
_list_notes_impl,
|
||||
category=category,
|
||||
tags=tags,
|
||||
search=search,
|
||||
include_content=include_content,
|
||||
),
|
||||
ensure_ascii=False,
|
||||
default=str,
|
||||
)
|
||||
|
||||
|
||||
@function_tool(timeout=30)
|
||||
async def get_note(ctx: RunContextWrapper, note_id: str) -> str:
|
||||
"""Fetch one note by its 6-char ID. Returns the full content.
|
||||
|
||||
Args:
|
||||
note_id: Note id from ``create_note`` or a ``list_notes`` entry.
|
||||
"""
|
||||
return json.dumps(
|
||||
await asyncio.to_thread(_get_note_impl, note_id), ensure_ascii=False, default=str
|
||||
)
|
||||
|
||||
|
||||
@function_tool(timeout=30)
|
||||
async def update_note(
|
||||
ctx: RunContextWrapper,
|
||||
note_id: str,
|
||||
title: str | None = None,
|
||||
content: str | None = None,
|
||||
tags: list[str] | None = None,
|
||||
) -> str:
|
||||
"""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 6-char ID.
|
||||
title: New title, or ``None`` to keep.
|
||||
content: New content, or ``None`` to keep.
|
||||
tags: New tags list, or ``None`` to keep.
|
||||
"""
|
||||
return json.dumps(
|
||||
await asyncio.to_thread(
|
||||
_update_note_impl,
|
||||
note_id=note_id,
|
||||
title=title,
|
||||
content=content,
|
||||
tags=tags,
|
||||
),
|
||||
ensure_ascii=False,
|
||||
default=str,
|
||||
)
|
||||
|
||||
|
||||
@function_tool(timeout=30)
|
||||
async def delete_note(ctx: RunContextWrapper, note_id: str) -> str:
|
||||
"""Delete a note.
|
||||
|
||||
Args:
|
||||
note_id: Note id to delete.
|
||||
"""
|
||||
return json.dumps(
|
||||
await asyncio.to_thread(_delete_note_impl, note_id), ensure_ascii=False, default=str
|
||||
)
|
||||
@@ -0,0 +1,682 @@
|
||||
"""Shared Caido proxy helpers and sandbox-importable ``caido_api`` module."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
import urllib.request
|
||||
from typing import TYPE_CHECKING, Any, Literal
|
||||
from urllib.parse import parse_qs, urlencode, urlparse, urlunparse
|
||||
|
||||
from caido_sdk_client import Client, TokenAuthOptions
|
||||
from caido_sdk_client.types import (
|
||||
ConnectionInfoInput,
|
||||
CreateScopeOptions,
|
||||
ReplaySendOptions,
|
||||
RequestGetOptions,
|
||||
UpdateScopeOptions,
|
||||
)
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from caido_sdk_client import Client as CaidoClient
|
||||
|
||||
|
||||
RequestPart = Literal["request", "response"]
|
||||
SortBy = Literal[
|
||||
"timestamp",
|
||||
"host",
|
||||
"method",
|
||||
"path",
|
||||
"status_code",
|
||||
"response_time",
|
||||
"response_size",
|
||||
"source",
|
||||
]
|
||||
SortOrder = Literal["asc", "desc"]
|
||||
ScopeAction = Literal["get", "list", "create", "update", "delete"]
|
||||
SitemapDepth = Literal["DIRECT", "ALL"]
|
||||
_SITEMAP_PAGE_SIZE = 30
|
||||
|
||||
_DEFAULT_CAIDO_URL = "http://127.0.0.1:48080"
|
||||
_CLIENT_CACHE: dict[str, Client] = {}
|
||||
_REQ_FIELD_MAP: dict[SortBy, tuple[str, str]] = {
|
||||
"timestamp": ("req", "created_at"),
|
||||
"host": ("req", "host"),
|
||||
"method": ("req", "method"),
|
||||
"path": ("req", "path"),
|
||||
"source": ("req", "source"),
|
||||
"status_code": ("resp", "code"),
|
||||
"response_time": ("resp", "roundtrip"),
|
||||
"response_size": ("resp", "length"),
|
||||
}
|
||||
|
||||
|
||||
def caido_url() -> str:
|
||||
return os.environ.get("STRIX_CAIDO_URL", _DEFAULT_CAIDO_URL).rstrip("/")
|
||||
|
||||
|
||||
def _graphql_url() -> str:
|
||||
base_url = caido_url()
|
||||
parsed = urlparse(base_url)
|
||||
if parsed.scheme not in {"http", "https"} or not parsed.netloc:
|
||||
raise ValueError(f"Invalid Caido URL: {base_url}")
|
||||
return f"{base_url}/graphql"
|
||||
|
||||
|
||||
def _login_as_guest() -> str:
|
||||
body = json.dumps({"query": "mutation { loginAsGuest { token { accessToken } } }"}).encode(
|
||||
"utf-8"
|
||||
)
|
||||
req = urllib.request.Request( # noqa: S310
|
||||
_graphql_url(),
|
||||
data=body,
|
||||
headers={"Content-Type": "application/json"},
|
||||
method="POST",
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=10) as resp: # noqa: S310 # nosec B310
|
||||
payload = json.loads(resp.read())
|
||||
return str(payload["data"]["loginAsGuest"]["token"]["accessToken"])
|
||||
|
||||
|
||||
async def get_client() -> Client:
|
||||
if client := _CLIENT_CACHE.get("default"):
|
||||
return client
|
||||
|
||||
token = await asyncio.to_thread(_login_as_guest)
|
||||
client = Client(caido_url(), auth=TokenAuthOptions(token=token))
|
||||
await client.connect()
|
||||
_CLIENT_CACHE["default"] = client
|
||||
return client
|
||||
|
||||
|
||||
async def close_client() -> None:
|
||||
client = _CLIENT_CACHE.pop("default", None)
|
||||
if client is None:
|
||||
return
|
||||
await client.aclose()
|
||||
|
||||
|
||||
async def list_requests_with_client(
|
||||
client: CaidoClient,
|
||||
*,
|
||||
httpql_filter: str | None = None,
|
||||
first: int = 50,
|
||||
after: str | None = None,
|
||||
sort_by: SortBy = "timestamp",
|
||||
sort_order: SortOrder = "desc",
|
||||
scope_id: str | None = None,
|
||||
) -> Any:
|
||||
builder = client.request.list().first(first)
|
||||
if httpql_filter:
|
||||
builder = builder.filter(httpql_filter)
|
||||
if after:
|
||||
builder = builder.after(after)
|
||||
if scope_id:
|
||||
builder = builder.scope(scope_id)
|
||||
target, field = _REQ_FIELD_MAP[sort_by]
|
||||
builder = (builder.descending if sort_order == "desc" else builder.ascending)(target, field)
|
||||
return await builder.execute()
|
||||
|
||||
|
||||
async def get_request_with_client(
|
||||
client: CaidoClient,
|
||||
request_id: str,
|
||||
*,
|
||||
part: RequestPart = "request",
|
||||
) -> Any:
|
||||
# The Caido SDK's generated pydantic model marks Request.raw and
|
||||
# Response.raw as required strings even though the GraphQL fragment
|
||||
# makes them conditional via `@include(if: $includeRequestRaw)`.
|
||||
# Passing False for either causes pydantic validation to fail with
|
||||
# "Field required" on the missing raw field. Always request both —
|
||||
# the caller picks which one to surface via ``part``.
|
||||
opts = RequestGetOptions(request_raw=True, response_raw=True)
|
||||
return await client.request.get(request_id, opts)
|
||||
|
||||
|
||||
def build_raw_request(
|
||||
*,
|
||||
method: str,
|
||||
url: str,
|
||||
headers: dict[str, str],
|
||||
body: str,
|
||||
) -> tuple[ConnectionInfoInput, bytes]:
|
||||
parsed = urlparse(url)
|
||||
if not parsed.scheme or not parsed.netloc:
|
||||
raise ValueError(f"Invalid URL: {url}")
|
||||
is_tls = parsed.scheme.lower() == "https"
|
||||
host = parsed.hostname or ""
|
||||
port = parsed.port or (443 if is_tls else 80)
|
||||
path = parsed.path or "/"
|
||||
if parsed.query:
|
||||
path = f"{path}?{parsed.query}"
|
||||
|
||||
final_headers = {**headers}
|
||||
final_headers.setdefault("Host", parsed.netloc)
|
||||
final_headers.setdefault("User-Agent", "strix")
|
||||
if body and "Content-Length" not in {k.title() for k in final_headers}:
|
||||
final_headers["Content-Length"] = str(len(body.encode("utf-8")))
|
||||
|
||||
lines = [f"{method.upper()} {path} HTTP/1.1"]
|
||||
lines.extend(f"{k}: {v}" for k, v in final_headers.items())
|
||||
raw = ("\r\n".join(lines) + "\r\n\r\n" + body).encode("utf-8")
|
||||
return ConnectionInfoInput(host=host, port=port, is_tls=is_tls), raw
|
||||
|
||||
|
||||
_RESPONSE_BODY_MAX_CHARS = 8192
|
||||
|
||||
|
||||
def parse_raw_response(raw_bytes: bytes | None) -> dict[str, Any] | None:
|
||||
"""Parse a raw HTTP response into the same shape ``list_requests`` emits.
|
||||
|
||||
Returns ``None`` when ``raw_bytes`` is missing or unparseable. On
|
||||
success returns ``{status_code, length, headers, body, body_truncated}``
|
||||
where ``body`` is decoded as UTF-8 (replacement chars on invalid
|
||||
bytes) and clipped at :data:`_RESPONSE_BODY_MAX_CHARS`.
|
||||
"""
|
||||
if not raw_bytes:
|
||||
return None
|
||||
try:
|
||||
head, _, body_bytes = raw_bytes.partition(b"\r\n\r\n")
|
||||
lines = head.decode("iso-8859-1", errors="replace").split("\r\n")
|
||||
if not lines:
|
||||
return None
|
||||
status_parts = lines[0].split(" ", 2)
|
||||
if len(status_parts) < 2 or not status_parts[1].isdigit():
|
||||
return None
|
||||
status_code = int(status_parts[1])
|
||||
headers: dict[str, str] = {}
|
||||
for line in lines[1:]:
|
||||
if ":" not in line:
|
||||
continue
|
||||
k, v = line.split(":", 1)
|
||||
headers[k.strip()] = v.strip()
|
||||
body_text = body_bytes.decode("utf-8", errors="replace")
|
||||
body_truncated = len(body_text) > _RESPONSE_BODY_MAX_CHARS
|
||||
if body_truncated:
|
||||
body_text = body_text[:_RESPONSE_BODY_MAX_CHARS]
|
||||
return {
|
||||
"status_code": status_code,
|
||||
"length": len(body_bytes),
|
||||
"headers": headers,
|
||||
"body": body_text,
|
||||
"body_truncated": body_truncated,
|
||||
}
|
||||
except Exception: # noqa: BLE001 - tolerate any malformed raw bytes; None signals "unparseable" to the caller.
|
||||
return None
|
||||
|
||||
|
||||
def parse_raw_request(raw_content: str) -> dict[str, Any]:
|
||||
lines = raw_content.split("\n")
|
||||
request_line = lines[0].strip().split(" ")
|
||||
if len(request_line) < 2:
|
||||
raise ValueError("Invalid request line format")
|
||||
method, url_path = request_line[0], request_line[1]
|
||||
|
||||
parsed_headers: dict[str, str] = {}
|
||||
body_start = 0
|
||||
for i, line in enumerate(lines[1:], 1):
|
||||
if line.strip() == "":
|
||||
body_start = i + 1
|
||||
break
|
||||
if ":" in line:
|
||||
key, value = line.split(":", 1)
|
||||
parsed_headers[key.strip()] = value.strip()
|
||||
|
||||
body = "\n".join(lines[body_start:]).strip() if body_start < len(lines) else ""
|
||||
return {"method": method, "url_path": url_path, "headers": parsed_headers, "body": body}
|
||||
|
||||
|
||||
def full_url_from_components(
|
||||
original: Any,
|
||||
components: dict[str, Any],
|
||||
modifications: dict[str, Any],
|
||||
) -> str:
|
||||
if "url" in modifications:
|
||||
return str(modifications["url"])
|
||||
headers = components["headers"]
|
||||
host_header = headers.get("Host") or original.host
|
||||
scheme = "https" if original.is_tls else "http"
|
||||
return f"{scheme}://{host_header}{components['url_path']}"
|
||||
|
||||
|
||||
def apply_modifications(
|
||||
components: dict[str, Any],
|
||||
modifications: dict[str, Any],
|
||||
full_url: str,
|
||||
) -> dict[str, Any]:
|
||||
headers = dict(components["headers"])
|
||||
body = components["body"]
|
||||
final_url = full_url
|
||||
|
||||
if "params" in modifications:
|
||||
parsed = urlparse(final_url)
|
||||
existing = {k: v[0] if v else "" for k, v in parse_qs(parsed.query).items()}
|
||||
existing.update(modifications["params"])
|
||||
final_url = urlunparse(parsed._replace(query=urlencode(existing)))
|
||||
if "headers" in modifications:
|
||||
headers.update(modifications["headers"])
|
||||
if "body" in modifications:
|
||||
body = modifications["body"]
|
||||
if "cookies" in modifications:
|
||||
cookies: dict[str, str] = {}
|
||||
if headers.get("Cookie"):
|
||||
for cookie in headers["Cookie"].split(";"):
|
||||
if "=" in cookie:
|
||||
k, v = cookie.split("=", 1)
|
||||
cookies[k.strip()] = v.strip()
|
||||
cookies.update(modifications["cookies"])
|
||||
headers["Cookie"] = "; ".join(f"{k}={v}" for k, v in cookies.items())
|
||||
|
||||
return {
|
||||
"method": components["method"],
|
||||
"url": final_url,
|
||||
"headers": headers,
|
||||
"body": body,
|
||||
}
|
||||
|
||||
|
||||
_REPLAY_SEND_TIMEOUT_SECONDS = 30.0
|
||||
|
||||
|
||||
async def replay_send_raw(
|
||||
client: CaidoClient,
|
||||
*,
|
||||
raw: bytes,
|
||||
connection: ConnectionInfoInput,
|
||||
) -> dict[str, Any]:
|
||||
started = time.time()
|
||||
# Create an empty replay session, then dispatch via ``send()``.
|
||||
# Passing ``CreateReplaySessionFromRaw`` here would also seed a stored
|
||||
# entry on the server side, leading the caller to observe two history
|
||||
# rows per call (one without response from the create-step seed, one
|
||||
# with response from the actual send). The empty-create + send flow
|
||||
# produces exactly one dispatched request.
|
||||
session = await client.replay.sessions.create()
|
||||
try:
|
||||
result = await asyncio.wait_for(
|
||||
client.replay.send(
|
||||
session.id,
|
||||
ReplaySendOptions(raw=raw, connection=connection),
|
||||
),
|
||||
timeout=_REPLAY_SEND_TIMEOUT_SECONDS,
|
||||
)
|
||||
except TimeoutError:
|
||||
elapsed_ms = int((time.time() - started) * 1000)
|
||||
return {
|
||||
"session_id": str(session.id),
|
||||
"status": "ERROR",
|
||||
"error": (
|
||||
f"Caido replay dispatch did not complete within "
|
||||
f"{_REPLAY_SEND_TIMEOUT_SECONDS:.0f}s — the target may be "
|
||||
"unroutable from the sandbox, or Caido's outbound HTTP client "
|
||||
"is stalled; check the target host/port and retry"
|
||||
),
|
||||
"elapsed_ms": elapsed_ms,
|
||||
"response_raw": None,
|
||||
}
|
||||
elapsed_ms = int((time.time() - started) * 1000)
|
||||
response = getattr(result.entry, "response", None)
|
||||
response_raw = getattr(response, "raw", None) if response is not None else None
|
||||
return {
|
||||
"session_id": str(session.id),
|
||||
"status": result.status,
|
||||
"error": result.error,
|
||||
"elapsed_ms": elapsed_ms,
|
||||
"response_raw": response_raw,
|
||||
}
|
||||
|
||||
|
||||
async def scope_list(client: CaidoClient) -> Any:
|
||||
return await client.scope.list()
|
||||
|
||||
|
||||
async def scope_get(client: CaidoClient, scope_id: str) -> Any:
|
||||
return await client.scope.get(scope_id)
|
||||
|
||||
|
||||
async def scope_create(
|
||||
client: CaidoClient,
|
||||
*,
|
||||
name: str,
|
||||
allowlist: list[str] | None = None,
|
||||
denylist: list[str] | None = None,
|
||||
) -> Any:
|
||||
return await client.scope.create(
|
||||
CreateScopeOptions(
|
||||
name=name,
|
||||
allowlist=list(allowlist or []),
|
||||
denylist=list(denylist or []),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def scope_update(
|
||||
client: CaidoClient,
|
||||
scope_id: str,
|
||||
*,
|
||||
name: str,
|
||||
allowlist: list[str] | None = None,
|
||||
denylist: list[str] | None = None,
|
||||
) -> Any:
|
||||
return await client.scope.update(
|
||||
scope_id,
|
||||
UpdateScopeOptions(
|
||||
name=name,
|
||||
allowlist=list(allowlist or []),
|
||||
denylist=list(denylist or []),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def scope_delete(client: CaidoClient, scope_id: str) -> None:
|
||||
await client.scope.delete(scope_id)
|
||||
|
||||
|
||||
async def list_requests(
|
||||
*,
|
||||
httpql_filter: str | None = None,
|
||||
first: int = 50,
|
||||
after: str | None = None,
|
||||
sort_by: SortBy = "timestamp",
|
||||
sort_order: SortOrder = "desc",
|
||||
scope_id: str | None = None,
|
||||
) -> Any:
|
||||
return await list_requests_with_client(
|
||||
await get_client(),
|
||||
httpql_filter=httpql_filter,
|
||||
first=first,
|
||||
after=after,
|
||||
sort_by=sort_by,
|
||||
sort_order=sort_order,
|
||||
scope_id=scope_id,
|
||||
)
|
||||
|
||||
|
||||
async def view_request(request_id: str, *, part: RequestPart = "request") -> Any:
|
||||
return await get_request_with_client(await get_client(), request_id, part=part)
|
||||
|
||||
|
||||
async def repeat_request(
|
||||
request_id: str,
|
||||
*,
|
||||
modifications: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
mods = modifications or {}
|
||||
result = await get_request_with_client(await get_client(), request_id, part="request")
|
||||
if result is None or result.request.raw is None:
|
||||
raise ValueError(f"Request {request_id} not found")
|
||||
|
||||
original = result.request
|
||||
raw_str = result.request.raw.decode("utf-8", errors="replace")
|
||||
components = parse_raw_request(raw_str)
|
||||
full_url = full_url_from_components(original, components, mods)
|
||||
modified = apply_modifications(components, mods, full_url)
|
||||
connection, raw = build_raw_request(
|
||||
method=modified["method"],
|
||||
url=modified["url"],
|
||||
headers=modified["headers"],
|
||||
body=modified["body"],
|
||||
)
|
||||
return await replay_send_raw(await get_client(), raw=raw, connection=connection)
|
||||
|
||||
|
||||
async def scope_rules(
|
||||
action: ScopeAction,
|
||||
*,
|
||||
allowlist: list[str] | None = None,
|
||||
denylist: list[str] | None = None,
|
||||
scope_id: str | None = None,
|
||||
scope_name: str | None = None,
|
||||
) -> Any:
|
||||
client = await get_client()
|
||||
if action == "list":
|
||||
result = await scope_list(client)
|
||||
elif action == "get":
|
||||
if not scope_id:
|
||||
raise ValueError("scope_id required for get")
|
||||
result = await scope_get(client, scope_id)
|
||||
elif action == "create":
|
||||
if not scope_name:
|
||||
raise ValueError("scope_name required for create")
|
||||
result = await scope_create(
|
||||
client,
|
||||
name=scope_name,
|
||||
allowlist=allowlist,
|
||||
denylist=denylist,
|
||||
)
|
||||
elif action == "update":
|
||||
if not scope_id or not scope_name:
|
||||
raise ValueError("scope_id and scope_name required for update")
|
||||
result = await scope_update(
|
||||
client,
|
||||
scope_id,
|
||||
name=scope_name,
|
||||
allowlist=allowlist,
|
||||
denylist=denylist,
|
||||
)
|
||||
elif action == "delete":
|
||||
if not scope_id:
|
||||
raise ValueError("scope_id required for delete")
|
||||
await scope_delete(client, scope_id)
|
||||
result = {"deleted": scope_id}
|
||||
else:
|
||||
raise ValueError(f"Unknown action: {action}")
|
||||
return result
|
||||
|
||||
|
||||
_SITEMAP_ROOTS_QUERY = """
|
||||
query GetSitemapRoots($scopeId: ID) {
|
||||
sitemapRootEntries(scopeId: $scopeId) {
|
||||
edges { node {
|
||||
id kind label hasDescendants
|
||||
metadata { ... on SitemapEntryMetadataDomain { isTls port } }
|
||||
request { method path response { statusCode } }
|
||||
} }
|
||||
count { value }
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
_SITEMAP_DESCENDANTS_QUERY = """
|
||||
query GetSitemapDescendants($parentId: ID!, $depth: SitemapDescendantsDepth!) {
|
||||
sitemapDescendantEntries(parentId: $parentId, depth: $depth) {
|
||||
edges { node {
|
||||
id kind label hasDescendants
|
||||
request { method path response { statusCode } }
|
||||
} }
|
||||
count { value }
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
_SITEMAP_ENTRY_QUERY = """
|
||||
query GetSitemapEntry($id: ID!) {
|
||||
sitemapEntry(id: $id) {
|
||||
id kind label hasDescendants
|
||||
metadata { ... on SitemapEntryMetadataDomain { isTls port } }
|
||||
request { method path response { statusCode length roundtripTime } }
|
||||
requests(first: 30, order: {by: CREATED_AT, ordering: DESC}) {
|
||||
edges { node { method path response { statusCode length } } }
|
||||
count { value }
|
||||
}
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
|
||||
def _clean_sitemap_metadata(node: dict[str, Any]) -> dict[str, Any]:
|
||||
cleaned: dict[str, Any] = {
|
||||
"id": node["id"],
|
||||
"kind": node["kind"],
|
||||
"label": node["label"],
|
||||
"has_descendants": node["hasDescendants"],
|
||||
}
|
||||
meta = node.get("metadata")
|
||||
if isinstance(meta, dict) and (meta.get("isTls") is not None or meta.get("port")):
|
||||
meta_out: dict[str, Any] = {}
|
||||
if meta.get("isTls") is not None:
|
||||
meta_out["is_tls"] = meta["isTls"]
|
||||
if meta.get("port"):
|
||||
meta_out["port"] = meta["port"]
|
||||
cleaned["metadata"] = meta_out
|
||||
return cleaned
|
||||
|
||||
|
||||
def _clean_sitemap_request_summary(req: dict[str, Any] | None) -> dict[str, Any] | None:
|
||||
"""Same field names as ``list_requests`` emits for a request_summary."""
|
||||
if not req:
|
||||
return None
|
||||
out: dict[str, Any] = {}
|
||||
if req.get("method"):
|
||||
out["method"] = req["method"]
|
||||
if req.get("path"):
|
||||
out["path"] = req["path"]
|
||||
resp = req.get("response") or {}
|
||||
if resp.get("statusCode"):
|
||||
out["status_code"] = resp["statusCode"]
|
||||
return out or None
|
||||
|
||||
|
||||
def _clean_sitemap_response(resp: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Same field names as ``list_requests`` emits for a response_summary."""
|
||||
out: dict[str, Any] = {}
|
||||
if resp.get("statusCode"):
|
||||
out["status_code"] = resp["statusCode"]
|
||||
if resp.get("length"):
|
||||
out["length"] = resp["length"]
|
||||
if resp.get("roundtripTime"):
|
||||
out["roundtrip_ms"] = resp["roundtripTime"]
|
||||
return out
|
||||
|
||||
|
||||
async def list_sitemap_with_client(
|
||||
client: CaidoClient,
|
||||
*,
|
||||
scope_id: str | None = None,
|
||||
parent_id: str | None = None,
|
||||
depth: SitemapDepth = "DIRECT",
|
||||
page: int = 1,
|
||||
page_size: int = _SITEMAP_PAGE_SIZE,
|
||||
) -> dict[str, Any]:
|
||||
"""Browse Caido's discovered sitemap.
|
||||
|
||||
The Caido GraphQL ``sitemap*Entries`` operations don't support native
|
||||
pagination, so we fetch all edges for the requested level and slice
|
||||
client-side.
|
||||
"""
|
||||
if parent_id:
|
||||
raw = await client.graphql.query(
|
||||
_SITEMAP_DESCENDANTS_QUERY,
|
||||
variables={"parentId": parent_id, "depth": depth},
|
||||
)
|
||||
data = raw.get("sitemapDescendantEntries") or {}
|
||||
else:
|
||||
raw = await client.graphql.query(
|
||||
_SITEMAP_ROOTS_QUERY,
|
||||
variables={"scopeId": scope_id},
|
||||
)
|
||||
data = raw.get("sitemapRootEntries") or {}
|
||||
|
||||
edges = data.get("edges") or []
|
||||
total = (data.get("count") or {}).get("value", 0)
|
||||
skip = max(0, (page - 1) * page_size)
|
||||
sliced = [edge["node"] for edge in edges[skip : skip + page_size]]
|
||||
|
||||
cleaned: list[dict[str, Any]] = []
|
||||
for node in sliced:
|
||||
entry = _clean_sitemap_metadata(node)
|
||||
summary = _clean_sitemap_request_summary(node.get("request"))
|
||||
if summary:
|
||||
entry["request"] = summary
|
||||
cleaned.append(entry)
|
||||
|
||||
total_pages = (total + page_size - 1) // page_size if total else 0
|
||||
return {
|
||||
"success": True,
|
||||
"entries": cleaned,
|
||||
"page": page,
|
||||
"page_size": page_size,
|
||||
"total_pages": total_pages,
|
||||
"total_count": total,
|
||||
"has_more": page < total_pages,
|
||||
}
|
||||
|
||||
|
||||
async def view_sitemap_entry_with_client(
|
||||
client: CaidoClient,
|
||||
entry_id: str,
|
||||
) -> dict[str, Any]:
|
||||
raw = await client.graphql.query(_SITEMAP_ENTRY_QUERY, variables={"id": entry_id})
|
||||
entry = raw.get("sitemapEntry")
|
||||
if not entry:
|
||||
return {"success": False, "error": f"Sitemap entry {entry_id} not found"}
|
||||
|
||||
cleaned = _clean_sitemap_metadata(entry)
|
||||
primary = entry.get("request") or {}
|
||||
if primary:
|
||||
primary_clean: dict[str, Any] = {}
|
||||
if primary.get("method"):
|
||||
primary_clean["method"] = primary["method"]
|
||||
if primary.get("path"):
|
||||
primary_clean["path"] = primary["path"]
|
||||
if primary.get("response"):
|
||||
primary_clean["response"] = _clean_sitemap_response(primary["response"])
|
||||
if primary_clean:
|
||||
cleaned["request"] = primary_clean
|
||||
|
||||
related = entry.get("requests") or {}
|
||||
related_edges = related.get("edges") or []
|
||||
related_nodes = [edge["node"] for edge in related_edges]
|
||||
related_clean = [
|
||||
summary
|
||||
for summary in (_clean_sitemap_request_summary(n) for n in related_nodes)
|
||||
if summary is not None
|
||||
]
|
||||
cleaned["related_requests"] = {
|
||||
"requests": related_clean,
|
||||
"total_count": (related.get("count") or {}).get("value", 0),
|
||||
}
|
||||
return {"success": True, "entry": cleaned}
|
||||
|
||||
|
||||
async def list_sitemap(
|
||||
*,
|
||||
scope_id: str | None = None,
|
||||
parent_id: str | None = None,
|
||||
depth: SitemapDepth = "DIRECT",
|
||||
page: int = 1,
|
||||
page_size: int = _SITEMAP_PAGE_SIZE,
|
||||
) -> dict[str, Any]:
|
||||
return await list_sitemap_with_client(
|
||||
await get_client(),
|
||||
scope_id=scope_id,
|
||||
parent_id=parent_id,
|
||||
depth=depth,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
)
|
||||
|
||||
|
||||
async def view_sitemap_entry(entry_id: str) -> dict[str, Any]:
|
||||
return await view_sitemap_entry_with_client(await get_client(), entry_id)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"RequestPart",
|
||||
"ScopeAction",
|
||||
"SitemapDepth",
|
||||
"SortBy",
|
||||
"SortOrder",
|
||||
"close_client",
|
||||
"get_client",
|
||||
"list_requests",
|
||||
"list_sitemap",
|
||||
"repeat_request",
|
||||
"scope_rules",
|
||||
"view_request",
|
||||
"view_sitemap_entry",
|
||||
]
|
||||
@@ -0,0 +1,596 @@
|
||||
"""Caido proxy host-side @function_tool wrappers around caido_api.py."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import dataclasses
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from dataclasses import is_dataclass
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING, Any, Literal
|
||||
|
||||
from agents import RunContextWrapper, function_tool
|
||||
|
||||
from strix.tools.proxy import caido_api
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from caido_sdk_client import Client
|
||||
|
||||
from strix.tools.proxy.caido_api import (
|
||||
RequestPart,
|
||||
SitemapDepth,
|
||||
SortBy,
|
||||
SortOrder,
|
||||
)
|
||||
else:
|
||||
from strix.tools.proxy.caido_api import ( # noqa: TC001
|
||||
RequestPart,
|
||||
SitemapDepth,
|
||||
SortBy,
|
||||
SortOrder,
|
||||
)
|
||||
|
||||
|
||||
ScopeAction = Literal["get", "list", "create", "update", "delete"]
|
||||
|
||||
|
||||
def _ctx_client(ctx: RunContextWrapper) -> Client | None:
|
||||
inner = ctx.context if isinstance(ctx.context, dict) else {}
|
||||
return inner.get("caido_client")
|
||||
|
||||
|
||||
def _to_tool_json(value: Any) -> Any:
|
||||
"""Recursively convert SDK dataclasses/Pydantic objects to tool JSON values."""
|
||||
if value is None or isinstance(value, str | int | float | bool):
|
||||
return value
|
||||
if isinstance(value, bytes):
|
||||
try:
|
||||
return value.decode("utf-8", errors="replace")
|
||||
except Exception: # noqa: BLE001
|
||||
return value.hex()
|
||||
if isinstance(value, datetime):
|
||||
return value.isoformat()
|
||||
if is_dataclass(value) and not isinstance(value, type):
|
||||
return {k: _to_tool_json(v) for k, v in dataclasses.asdict(value).items()}
|
||||
if hasattr(value, "model_dump"):
|
||||
return _to_tool_json(value.model_dump())
|
||||
if isinstance(value, dict):
|
||||
return {str(k): _to_tool_json(v) for k, v in value.items()}
|
||||
if isinstance(value, list | tuple | set):
|
||||
return [_to_tool_json(v) for v in value]
|
||||
return str(value)
|
||||
|
||||
|
||||
def _no_client() -> str:
|
||||
return json.dumps(
|
||||
{"success": False, "error": "Caido client not available in run context"},
|
||||
ensure_ascii=False,
|
||||
default=str,
|
||||
)
|
||||
|
||||
|
||||
def _err(name: str, exc: Exception) -> str:
|
||||
logger.exception("%s failed", name)
|
||||
return json.dumps(
|
||||
{"success": False, "error": f"{name} failed: {exc}"},
|
||||
ensure_ascii=False,
|
||||
default=str,
|
||||
)
|
||||
|
||||
|
||||
@function_tool(timeout=120)
|
||||
async def list_requests(
|
||||
ctx: RunContextWrapper,
|
||||
httpql_filter: str | None = None,
|
||||
first: int = 50,
|
||||
after: str | None = None,
|
||||
sort_by: SortBy = "timestamp",
|
||||
sort_order: SortOrder = "desc",
|
||||
scope_id: str | None = None,
|
||||
) -> str:
|
||||
"""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"``.
|
||||
|
||||
For sitemap-style tree traversal use HTTPQL filters: drill into a
|
||||
host with ``req.host.eq:"example.com"`` then narrow paths with
|
||||
``req.path.cont:"/api/"``.
|
||||
|
||||
Pagination is cursor-based. Pass the ``end_cursor`` from the
|
||||
``page_info`` of one call as ``after`` to the next.
|
||||
|
||||
Notes:
|
||||
|
||||
- HTTPQL has **no ``NOT`` operator**. Use the negated form of the
|
||||
operator instead: ``ne``, ``ncont``, ``nlike``, ``nregex``
|
||||
(e.g. ``req.path.ncont:"/static"`` to exclude static paths).
|
||||
- String values **must be quoted**; integer values **must not**.
|
||||
``resp.code.eq:200`` is right; ``resp.code.eq:"200"`` is a parse
|
||||
error. Same rule for ``cont`` / ``regex`` strings.
|
||||
- A bare quoted string searches both ``req.raw`` and ``resp.raw``,
|
||||
handy for sensitive-data sweeps:
|
||||
``"password" OR "secret" OR "api_key"``.
|
||||
|
||||
Args:
|
||||
httpql_filter: Caido HTTPQL query (optional).
|
||||
first: Number of entries to return (default 50).
|
||||
after: Cursor from a previous response's ``page_info.end_cursor``.
|
||||
sort_by: One of ``timestamp`` / ``host`` / ``method`` / ``path``
|
||||
/ ``status_code`` / ``response_time`` / ``response_size``
|
||||
/ ``source``.
|
||||
sort_order: ``asc`` or ``desc``.
|
||||
scope_id: Restrict to a Caido scope (managed via ``scope_rules``).
|
||||
"""
|
||||
client = _ctx_client(ctx)
|
||||
if client is None:
|
||||
return _no_client()
|
||||
|
||||
try:
|
||||
connection = await caido_api.list_requests_with_client(
|
||||
client,
|
||||
httpql_filter=httpql_filter,
|
||||
first=first,
|
||||
after=after,
|
||||
sort_by=sort_by,
|
||||
sort_order=sort_order,
|
||||
scope_id=scope_id,
|
||||
)
|
||||
|
||||
entries = []
|
||||
for edge in connection.edges:
|
||||
req = edge.node.request
|
||||
resp = edge.node.response
|
||||
response_payload: dict[str, Any] | None = None
|
||||
if resp is not None:
|
||||
response_payload = {
|
||||
"id": resp.id,
|
||||
"status_code": resp.status_code,
|
||||
"length": resp.length,
|
||||
"created_at": resp.created_at.isoformat(),
|
||||
}
|
||||
# Caido populates ``roundtripTime`` for some traffic sources
|
||||
# and leaves it as ``0`` for others (notably proxy captures
|
||||
# of upstream env-routed traffic). Surface the value only
|
||||
# when it's actually measured so the model doesn't waste
|
||||
# tokens reading a zero field on every entry.
|
||||
if resp.roundtrip_time:
|
||||
response_payload["roundtrip_ms"] = resp.roundtrip_time
|
||||
entries.append(
|
||||
{
|
||||
"cursor": edge.cursor,
|
||||
"request": {
|
||||
"id": req.id,
|
||||
"host": req.host,
|
||||
"port": req.port,
|
||||
"method": req.method,
|
||||
"path": req.path,
|
||||
"query": req.query,
|
||||
"is_tls": req.is_tls,
|
||||
"created_at": req.created_at.isoformat(),
|
||||
},
|
||||
"response": response_payload,
|
||||
},
|
||||
)
|
||||
|
||||
return json.dumps(
|
||||
{
|
||||
"success": True,
|
||||
"entries": entries,
|
||||
"page_info": {
|
||||
"has_next_page": connection.page_info.has_next_page,
|
||||
"has_previous_page": connection.page_info.has_previous_page,
|
||||
"start_cursor": connection.page_info.start_cursor,
|
||||
"end_cursor": connection.page_info.end_cursor,
|
||||
},
|
||||
},
|
||||
ensure_ascii=False,
|
||||
default=str,
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return _err("list_requests", exc)
|
||||
|
||||
|
||||
@function_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 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 line 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.
|
||||
"""
|
||||
client = _ctx_client(ctx)
|
||||
if client is None:
|
||||
return _no_client()
|
||||
|
||||
try:
|
||||
result = await caido_api.get_request_with_client(client, request_id, part=part)
|
||||
if result is None:
|
||||
return json.dumps(
|
||||
{"success": False, "error": f"Request {request_id} not found"},
|
||||
ensure_ascii=False,
|
||||
default=str,
|
||||
)
|
||||
|
||||
raw_bytes = (
|
||||
result.request.raw
|
||||
if part == "request"
|
||||
else (result.response.raw if result.response is not None else None)
|
||||
)
|
||||
if raw_bytes is None:
|
||||
return json.dumps(
|
||||
{
|
||||
"success": False,
|
||||
"error": f"No raw {part} for {request_id}",
|
||||
},
|
||||
ensure_ascii=False,
|
||||
default=str,
|
||||
)
|
||||
content = raw_bytes.decode("utf-8", errors="replace")
|
||||
|
||||
if search_pattern:
|
||||
return json.dumps(
|
||||
_format_search_hits(content, search_pattern),
|
||||
ensure_ascii=False,
|
||||
default=str,
|
||||
)
|
||||
|
||||
return json.dumps(
|
||||
_format_text_page(content, page=page, page_size=page_size),
|
||||
ensure_ascii=False,
|
||||
default=str,
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return _err("view_request", exc)
|
||||
|
||||
|
||||
def _format_search_hits(content: str, pattern: str) -> dict[str, Any]:
|
||||
try:
|
||||
regex = re.compile(pattern)
|
||||
except re.error as exc:
|
||||
return {"success": False, "error": f"Invalid regex: {exc}"}
|
||||
|
||||
hits = []
|
||||
for match in regex.finditer(content):
|
||||
start, end = match.span()
|
||||
before = content[max(0, start - 40) : start]
|
||||
after = content[end : end + 40]
|
||||
hits.append(
|
||||
{
|
||||
"match": match.group(0),
|
||||
"position": start,
|
||||
"before": before,
|
||||
"after": after,
|
||||
},
|
||||
)
|
||||
if len(hits) >= 20:
|
||||
break
|
||||
|
||||
return {"success": True, "hits": hits, "total_hits": len(hits)}
|
||||
|
||||
|
||||
def _format_text_page(content: str, *, page: int, page_size: int) -> dict[str, Any]:
|
||||
lines = content.splitlines()
|
||||
start = max(0, (page - 1) * page_size)
|
||||
end = start + page_size
|
||||
return {
|
||||
"success": True,
|
||||
"content": "\n".join(lines[start:end]),
|
||||
"page": page,
|
||||
"page_size": page_size,
|
||||
"total_lines": len(lines),
|
||||
"has_more": end < len(lines),
|
||||
}
|
||||
|
||||
|
||||
@function_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 patching individual fields.
|
||||
|
||||
The standard pentesting workflow with this tool:
|
||||
|
||||
1. ``agent-browser`` (via ``exec_command``) 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.
|
||||
"""
|
||||
client = _ctx_client(ctx)
|
||||
if client is None:
|
||||
return _no_client()
|
||||
mods = modifications or {}
|
||||
|
||||
try:
|
||||
result = await caido_api.get_request_with_client(client, request_id, part="request")
|
||||
if result is None or result.request.raw is None:
|
||||
return json.dumps(
|
||||
{"success": False, "error": f"Request {request_id} not found"},
|
||||
ensure_ascii=False,
|
||||
default=str,
|
||||
)
|
||||
|
||||
original = result.request
|
||||
raw_str = result.request.raw.decode("utf-8", errors="replace")
|
||||
components = caido_api.parse_raw_request(raw_str)
|
||||
full_url = caido_api.full_url_from_components(original, components, mods)
|
||||
modified = caido_api.apply_modifications(components, mods, full_url)
|
||||
connection, raw = caido_api.build_raw_request(
|
||||
method=modified["method"],
|
||||
url=modified["url"],
|
||||
headers=modified["headers"],
|
||||
body=modified["body"],
|
||||
)
|
||||
replay = await caido_api.replay_send_raw(client, raw=raw, connection=connection)
|
||||
return _format_replay_tool_result(replay)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return _err("repeat_request", exc)
|
||||
|
||||
|
||||
def _format_replay_tool_result(replay: dict[str, Any]) -> str:
|
||||
response = caido_api.parse_raw_response(replay.get("response_raw"))
|
||||
payload: dict[str, Any] = {
|
||||
"success": replay["status"] == "DONE",
|
||||
"status": replay["status"],
|
||||
"session_id": replay["session_id"],
|
||||
"elapsed_ms": replay["elapsed_ms"],
|
||||
"response": response,
|
||||
}
|
||||
if replay.get("error"):
|
||||
payload["error"] = replay["error"]
|
||||
return json.dumps(payload, ensure_ascii=False, default=str)
|
||||
|
||||
|
||||
@function_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:
|
||||
"""Browse Caido's hierarchical sitemap of proxied traffic.
|
||||
|
||||
Caido aggregates every captured request into a tree:
|
||||
``DOMAIN`` → ``DIRECTORY`` (path segments) → ``REQUEST`` →
|
||||
``REQUEST_BODY`` / ``REQUEST_QUERY`` (variant per body/query shape).
|
||||
Use this to understand the discovered attack surface, locate
|
||||
promising directories, and pick endpoints worth deeper testing.
|
||||
|
||||
Workflow:
|
||||
- Start with no ``parent_id`` to list root domains (scoped by
|
||||
``scope_id`` if you only care about in-scope hosts).
|
||||
- Pick an entry where ``has_descendants=true`` and pass its ``id``
|
||||
as ``parent_id`` to drill in. ``depth="DIRECT"`` returns only
|
||||
immediate children; ``"ALL"`` flattens the full subtree.
|
||||
- Hand any ``id`` to ``view_sitemap_entry`` for the full record
|
||||
and recent matching requests.
|
||||
|
||||
Args:
|
||||
scope_id: Limit roots to a Caido scope (only used when
|
||||
``parent_id`` is omitted). Manage scopes via ``scope_rules``.
|
||||
parent_id: Entry ID to expand; omit for root domains.
|
||||
depth: ``"DIRECT"`` (immediate children) or ``"ALL"``
|
||||
(recursive subtree). Only meaningful with ``parent_id``.
|
||||
page: 1-indexed page (30 entries per page).
|
||||
"""
|
||||
client = _ctx_client(ctx)
|
||||
if client is None:
|
||||
return _no_client()
|
||||
try:
|
||||
payload = await caido_api.list_sitemap_with_client(
|
||||
client,
|
||||
scope_id=scope_id,
|
||||
parent_id=parent_id,
|
||||
depth=depth,
|
||||
page=page,
|
||||
)
|
||||
return json.dumps(payload, ensure_ascii=False, default=str)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return _err("list_sitemap", exc)
|
||||
|
||||
|
||||
@function_tool(timeout=60)
|
||||
async def view_sitemap_entry(
|
||||
ctx: RunContextWrapper,
|
||||
entry_id: str,
|
||||
) -> str:
|
||||
"""Get full detail for a sitemap entry plus its recent requests.
|
||||
|
||||
Returns the entry's metadata, the primary request shape
|
||||
(method/path/response if any), and the most recent 30 related
|
||||
requests that fall under this entry. Pair with ``list_sitemap`` to
|
||||
pick the ``entry_id``.
|
||||
|
||||
Args:
|
||||
entry_id: ID from ``list_sitemap`` (or any nested entry).
|
||||
"""
|
||||
client = _ctx_client(ctx)
|
||||
if client is None:
|
||||
return _no_client()
|
||||
try:
|
||||
payload = await caido_api.view_sitemap_entry_with_client(client, entry_id)
|
||||
return json.dumps(payload, ensure_ascii=False, default=str)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return _err("view_sitemap_entry", exc)
|
||||
|
||||
|
||||
@function_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 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``.
|
||||
|
||||
Args:
|
||||
action:
|
||||
|
||||
- ``list`` — return all scopes.
|
||||
- ``get`` — single scope by ``scope_id``.
|
||||
- ``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``.
|
||||
"""
|
||||
client = _ctx_client(ctx)
|
||||
if client is None:
|
||||
return _no_client()
|
||||
|
||||
try:
|
||||
if action == "list":
|
||||
scopes = await caido_api.scope_list(client)
|
||||
return json.dumps(
|
||||
{"success": True, "scopes": [_to_tool_json(s) for s in scopes]},
|
||||
ensure_ascii=False,
|
||||
default=str,
|
||||
)
|
||||
if action == "get":
|
||||
if not scope_id:
|
||||
return json.dumps(
|
||||
{"success": False, "error": "Scope_id is required for action='get'"},
|
||||
ensure_ascii=False,
|
||||
default=str,
|
||||
)
|
||||
scope = await caido_api.scope_get(client, scope_id)
|
||||
return json.dumps(
|
||||
{"success": True, "scope": _to_tool_json(scope)}, ensure_ascii=False, default=str
|
||||
)
|
||||
if action == "create":
|
||||
if not scope_name:
|
||||
return json.dumps(
|
||||
{"success": False, "error": "Scope_name is required for action='create'"},
|
||||
ensure_ascii=False,
|
||||
default=str,
|
||||
)
|
||||
scope = await caido_api.scope_create(
|
||||
client, name=scope_name, allowlist=allowlist, denylist=denylist
|
||||
)
|
||||
return json.dumps(
|
||||
{"success": True, "scope": _to_tool_json(scope)}, ensure_ascii=False, default=str
|
||||
)
|
||||
if action == "update":
|
||||
if not scope_id or not scope_name:
|
||||
return json.dumps(
|
||||
{
|
||||
"success": False,
|
||||
"error": "Scope_id and scope_name are required for action='update'",
|
||||
},
|
||||
ensure_ascii=False,
|
||||
default=str,
|
||||
)
|
||||
scope = await caido_api.scope_update(
|
||||
client, scope_id, name=scope_name, allowlist=allowlist, denylist=denylist
|
||||
)
|
||||
return json.dumps(
|
||||
{"success": True, "scope": _to_tool_json(scope)}, ensure_ascii=False, default=str
|
||||
)
|
||||
if not scope_id:
|
||||
return json.dumps(
|
||||
{"success": False, "error": "Scope_id is required for action='delete'"},
|
||||
ensure_ascii=False,
|
||||
default=str,
|
||||
)
|
||||
await caido_api.scope_delete(client, scope_id)
|
||||
return json.dumps(
|
||||
{
|
||||
"success": True,
|
||||
"deleted": scope_id,
|
||||
"message": f"Scope {scope_id} deleted",
|
||||
},
|
||||
ensure_ascii=False,
|
||||
default=str,
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return _err("scope_rules", exc)
|
||||
@@ -0,0 +1,515 @@
|
||||
"""``create_vulnerability_report`` — file a vuln finding with dedup + CVSS."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from pathlib import PurePosixPath
|
||||
from typing import Any
|
||||
|
||||
from agents import RunContextWrapper, function_tool
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
_CVSS_VALID = {
|
||||
"attack_vector": ["N", "A", "L", "P"],
|
||||
"attack_complexity": ["L", "H"],
|
||||
"privileges_required": ["N", "L", "H"],
|
||||
"user_interaction": ["N", "R"],
|
||||
"scope": ["U", "C"],
|
||||
"confidentiality": ["N", "L", "H"],
|
||||
"integrity": ["N", "L", "H"],
|
||||
"availability": ["N", "L", "H"],
|
||||
}
|
||||
|
||||
|
||||
_CODE_LOCATION_FIELDS = (
|
||||
"file",
|
||||
"start_line",
|
||||
"end_line",
|
||||
"snippet",
|
||||
"label",
|
||||
"fix_before",
|
||||
"fix_after",
|
||||
)
|
||||
|
||||
|
||||
def _validate_file_path(path: str) -> str | None:
|
||||
if not path or not path.strip():
|
||||
return "file path cannot be empty"
|
||||
p = PurePosixPath(path)
|
||||
if p.is_absolute():
|
||||
return f"file path must be relative, got absolute: '{path}'"
|
||||
if ".." in p.parts:
|
||||
return f"file path must not contain '..': '{path}'"
|
||||
return None
|
||||
|
||||
|
||||
def _normalize_code_locations(
|
||||
raw: list[dict[str, Any]] | None,
|
||||
) -> list[dict[str, Any]] | None:
|
||||
if not raw:
|
||||
return None
|
||||
cleaned: list[dict[str, Any]] = []
|
||||
for loc in raw:
|
||||
normalized: dict[str, Any] = {}
|
||||
for field in _CODE_LOCATION_FIELDS:
|
||||
if field not in loc or loc[field] is None:
|
||||
continue
|
||||
value = loc[field]
|
||||
if field in ("start_line", "end_line"):
|
||||
try:
|
||||
normalized[field] = int(value)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
else:
|
||||
text = (
|
||||
str(value).strip("\n")
|
||||
if field in ("snippet", "fix_before", "fix_after")
|
||||
else str(value).strip()
|
||||
)
|
||||
if text:
|
||||
normalized[field] = text
|
||||
if normalized.get("file") and normalized.get("start_line") is not None:
|
||||
cleaned.append(normalized)
|
||||
return cleaned or None
|
||||
|
||||
|
||||
def _validate_code_locations(locations: list[dict[str, Any]]) -> list[str]:
|
||||
errors: list[str] = []
|
||||
for i, loc in enumerate(locations):
|
||||
path_err = _validate_file_path(loc.get("file", ""))
|
||||
if path_err:
|
||||
errors.append(f"code_locations[{i}]: {path_err}")
|
||||
start = loc.get("start_line")
|
||||
if not isinstance(start, int) or start < 1:
|
||||
errors.append(f"code_locations[{i}]: start_line must be a positive integer")
|
||||
end = loc.get("end_line")
|
||||
if end is None:
|
||||
errors.append(f"code_locations[{i}]: end_line is required")
|
||||
elif not isinstance(end, int) or end < 1:
|
||||
errors.append(f"code_locations[{i}]: end_line must be a positive integer")
|
||||
elif isinstance(start, int) and end < start:
|
||||
errors.append(f"code_locations[{i}]: end_line ({end}) must be >= start_line ({start})")
|
||||
return errors
|
||||
|
||||
|
||||
def _extract_cve(cve: str) -> str:
|
||||
match = re.search(r"CVE-\d{4}-\d{4,}", cve)
|
||||
return match.group(0) if match else cve.strip()
|
||||
|
||||
|
||||
def _validate_cve(cve: str) -> str | None:
|
||||
if not re.match(r"^CVE-\d{4}-\d{4,}$", cve):
|
||||
return f"invalid CVE format: '{cve}' (expected 'CVE-YYYY-NNNNN')"
|
||||
return None
|
||||
|
||||
|
||||
def _extract_cwe(cwe: str) -> str:
|
||||
match = re.search(r"CWE-\d+", cwe)
|
||||
return match.group(0) if match else cwe.strip()
|
||||
|
||||
|
||||
def _validate_cwe(cwe: str) -> str | None:
|
||||
if not re.match(r"^CWE-\d+$", cwe):
|
||||
return f"invalid CWE format: '{cwe}' (expected 'CWE-NNN')"
|
||||
return None
|
||||
|
||||
|
||||
def _calculate_cvss(breakdown: dict[str, str]) -> tuple[float, str, str]:
|
||||
try:
|
||||
from cvss import CVSS3
|
||||
|
||||
vector = (
|
||||
f"CVSS:3.1/AV:{breakdown['attack_vector']}/AC:{breakdown['attack_complexity']}/"
|
||||
f"PR:{breakdown['privileges_required']}/UI:{breakdown['user_interaction']}/"
|
||||
f"S:{breakdown['scope']}/C:{breakdown['confidentiality']}/"
|
||||
f"I:{breakdown['integrity']}/A:{breakdown['availability']}"
|
||||
)
|
||||
c = CVSS3(vector)
|
||||
score = c.scores()[0]
|
||||
severity = c.severities()[0].lower()
|
||||
except Exception:
|
||||
logger.exception("Failed to calculate CVSS")
|
||||
return 7.5, "high", ""
|
||||
else:
|
||||
return score, severity, vector
|
||||
|
||||
|
||||
_REQUIRED_FIELDS = {
|
||||
"title": "Title cannot be empty",
|
||||
"description": "Description cannot be empty",
|
||||
"impact": "Impact cannot be empty",
|
||||
"target": "Target cannot be empty",
|
||||
"technical_analysis": "Technical analysis cannot be empty",
|
||||
"poc_description": "PoC description cannot be empty",
|
||||
"poc_script_code": "PoC script/code is REQUIRED - provide the actual exploit/payload",
|
||||
"remediation_steps": "Remediation steps cannot be empty",
|
||||
}
|
||||
|
||||
|
||||
async def _do_create( # noqa: PLR0912
|
||||
*,
|
||||
title: str,
|
||||
description: str,
|
||||
impact: str,
|
||||
target: str,
|
||||
technical_analysis: str,
|
||||
poc_description: str,
|
||||
poc_script_code: str,
|
||||
remediation_steps: str,
|
||||
cvss_breakdown: dict[str, str],
|
||||
endpoint: str | None,
|
||||
method: str | None,
|
||||
cve: str | None,
|
||||
cwe: str | None,
|
||||
code_locations: list[dict[str, Any]] | None,
|
||||
agent_id: str | None = None,
|
||||
agent_name: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
errors: list[str] = []
|
||||
fields = {
|
||||
"title": title,
|
||||
"description": description,
|
||||
"impact": impact,
|
||||
"target": target,
|
||||
"technical_analysis": technical_analysis,
|
||||
"poc_description": poc_description,
|
||||
"poc_script_code": poc_script_code,
|
||||
"remediation_steps": remediation_steps,
|
||||
}
|
||||
for name, msg in _REQUIRED_FIELDS.items():
|
||||
if not str(fields.get(name) or "").strip():
|
||||
errors.append(msg)
|
||||
|
||||
if not isinstance(cvss_breakdown, dict) or not cvss_breakdown:
|
||||
errors.append("cvss_breakdown: must be an object with the 8 CVSS metrics")
|
||||
cvss_breakdown = {}
|
||||
else:
|
||||
for name, valid in _CVSS_VALID.items():
|
||||
value = cvss_breakdown.get(name)
|
||||
if value not in valid:
|
||||
errors.append(f"Invalid {name}: {value}. Must be one of: {valid}")
|
||||
|
||||
parsed_locations = _normalize_code_locations(code_locations)
|
||||
if parsed_locations:
|
||||
errors.extend(_validate_code_locations(parsed_locations))
|
||||
if cve:
|
||||
cve = _extract_cve(cve)
|
||||
cve_err = _validate_cve(cve)
|
||||
if cve_err:
|
||||
errors.append(cve_err)
|
||||
if cwe:
|
||||
cwe = _extract_cwe(cwe)
|
||||
cwe_err = _validate_cwe(cwe)
|
||||
if cwe_err:
|
||||
errors.append(cwe_err)
|
||||
|
||||
if errors:
|
||||
return {"success": False, "error": "Validation failed", "errors": errors}
|
||||
|
||||
cvss_score, severity, _vector = _calculate_cvss(cvss_breakdown)
|
||||
|
||||
try:
|
||||
from strix.report.state import get_global_report_state
|
||||
|
||||
report_state = get_global_report_state()
|
||||
if report_state is None:
|
||||
logger.warning("No global report state; vulnerability report not persisted")
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"Vulnerability report '{title}' created (not persisted)",
|
||||
"warning": "Report could not be persisted - report state unavailable",
|
||||
}
|
||||
|
||||
from strix.report.dedupe import check_duplicate
|
||||
|
||||
existing = report_state.get_existing_vulnerabilities()
|
||||
candidate = {
|
||||
"title": title,
|
||||
"description": description,
|
||||
"impact": impact,
|
||||
"target": target,
|
||||
"technical_analysis": technical_analysis,
|
||||
"poc_description": poc_description,
|
||||
"poc_script_code": poc_script_code,
|
||||
"endpoint": endpoint,
|
||||
"method": method,
|
||||
}
|
||||
dedupe = await check_duplicate(candidate, existing)
|
||||
if dedupe.get("is_duplicate"):
|
||||
duplicate_id = dedupe.get("duplicate_id", "")
|
||||
duplicate_title = next(
|
||||
(r.get("title", "Unknown") for r in existing if r.get("id") == duplicate_id),
|
||||
"",
|
||||
)
|
||||
return {
|
||||
"success": False,
|
||||
"error": (
|
||||
f"Potential duplicate of '{duplicate_title}' "
|
||||
f"(id={duplicate_id[:8]}...) — do not re-report the same vulnerability"
|
||||
),
|
||||
"duplicate_of": duplicate_id,
|
||||
"duplicate_title": duplicate_title,
|
||||
"confidence": dedupe.get("confidence", 0.0),
|
||||
"reason": dedupe.get("reason", ""),
|
||||
}
|
||||
|
||||
report_id = report_state.add_vulnerability_report(
|
||||
title=title,
|
||||
description=description,
|
||||
severity=severity,
|
||||
impact=impact,
|
||||
target=target,
|
||||
technical_analysis=technical_analysis,
|
||||
poc_description=poc_description,
|
||||
poc_script_code=poc_script_code,
|
||||
remediation_steps=remediation_steps,
|
||||
cvss=cvss_score,
|
||||
cvss_breakdown=cvss_breakdown,
|
||||
endpoint=endpoint,
|
||||
method=method,
|
||||
cve=cve,
|
||||
cwe=cwe,
|
||||
code_locations=parsed_locations,
|
||||
agent_id=agent_id if isinstance(agent_id, str) else None,
|
||||
agent_name=agent_name if isinstance(agent_name, str) else None,
|
||||
)
|
||||
except (ImportError, AttributeError) as e:
|
||||
logger.exception("create_vulnerability_report persistence failed")
|
||||
return {"success": False, "error": f"Failed to create vulnerability report: {e!s}"}
|
||||
else:
|
||||
logger.info(
|
||||
"Vulnerability report created: id=%s severity=%s cvss=%.1f title=%s",
|
||||
report_id,
|
||||
severity,
|
||||
cvss_score,
|
||||
title,
|
||||
)
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"Vulnerability report '{title}' created successfully",
|
||||
"report_id": report_id,
|
||||
"severity": severity,
|
||||
"cvss_score": cvss_score,
|
||||
}
|
||||
|
||||
|
||||
@function_tool(timeout=180, strict_mode=False)
|
||||
async def create_vulnerability_report(
|
||||
ctx: RunContextWrapper,
|
||||
title: str,
|
||||
description: str,
|
||||
impact: str,
|
||||
target: str,
|
||||
technical_analysis: str,
|
||||
poc_description: str,
|
||||
poc_script_code: str,
|
||||
remediation_steps: str,
|
||||
cvss_breakdown: dict[str, str],
|
||||
endpoint: str | None = None,
|
||||
method: str | None = None,
|
||||
cve: str | None = None,
|
||||
cwe: str | None = None,
|
||||
code_locations: list[dict[str, Any]] | None = None,
|
||||
) -> str:
|
||||
"""File a vulnerability report — one report per fully-verified finding.
|
||||
|
||||
**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``. See the ``code_locations`` arg below
|
||||
for the full rules around ``fix_before`` / ``fix_after``,
|
||||
multi-part fixes, and informational-vs-actionable entries.
|
||||
|
||||
**CVSS breakdown** is an object 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``
|
||||
|
||||
Example::
|
||||
|
||||
{
|
||||
"attack_vector": "N",
|
||||
"attack_complexity": "L",
|
||||
"privileges_required": "N",
|
||||
"user_interaction": "N",
|
||||
"scope": "U",
|
||||
"confidentiality": "H",
|
||||
"integrity": "H",
|
||||
"availability": "H"
|
||||
}
|
||||
|
||||
**CVE / CWE rules**: pass the bare ID only (``CVE-2024-1234``,
|
||||
``CWE-89``) — no name, no parenthetical. Be 100% certain; if
|
||||
unsure, use ``web_search`` to verify the ID before passing, or omit
|
||||
the field entirely. Always prefer the most specific child CWE over
|
||||
a broad parent (CWE-89 not CWE-74; CWE-78 not CWE-77). Do NOT use
|
||||
broad/parent CWEs like CWE-74, CWE-20, CWE-200, CWE-284, or
|
||||
CWE-693.
|
||||
|
||||
Common CWE references (use the ID only — names are listed here
|
||||
just for your lookup):
|
||||
|
||||
- **Injection**: CWE-79 XSS, CWE-89 SQLi, CWE-78 OS Command
|
||||
Injection, CWE-94 Code Injection, CWE-77 Command Injection.
|
||||
- **Auth / Access**: CWE-287 Improper Authentication, CWE-862
|
||||
Missing Authorization, CWE-863 Incorrect Authorization, CWE-306
|
||||
Missing Auth for Critical Function, CWE-639 Authz Bypass via
|
||||
User-Controlled Key.
|
||||
- **Web**: CWE-352 CSRF, CWE-918 SSRF, CWE-601 Open Redirect,
|
||||
CWE-434 Unrestricted File Upload.
|
||||
- **Memory**: CWE-787 OOB Write, CWE-125 OOB Read, CWE-416 UAF,
|
||||
CWE-120 Classic Buffer Overflow.
|
||||
- **Data**: CWE-502 Deserialization of Untrusted Data, CWE-22
|
||||
Path Traversal, CWE-611 XXE.
|
||||
- **Crypto / Config**: CWE-798 Hard-coded Credentials, CWE-327
|
||||
Broken / Risky Crypto, CWE-311 Missing Encryption, CWE-916 Weak
|
||||
Password Hashing.
|
||||
|
||||
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 object 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: White-box findings — list of location objects.
|
||||
|
||||
**How ``fix_before`` / ``fix_after`` work**: they're used as
|
||||
literal GitHub/GitLab PR suggestion blocks. When a reviewer
|
||||
accepts the suggestion, the platform replaces the **exact
|
||||
lines from ``start_line`` to ``end_line``** with
|
||||
``fix_after``. Therefore:
|
||||
|
||||
1. ``fix_before`` must be a **VERBATIM** copy of the source
|
||||
at those lines — same whitespace, indentation, line
|
||||
breaks. If it doesn't match character-for-character, the
|
||||
suggestion will corrupt the code when accepted.
|
||||
2. ``fix_after`` is the COMPLETE replacement for that
|
||||
entire block (may be more or fewer lines).
|
||||
3. ``start_line`` / ``end_line`` must precisely cover the
|
||||
lines in ``fix_before`` — no more, no less.
|
||||
|
||||
**Multi-part fixes**: many fixes touch multiple
|
||||
non-contiguous parts of a file (e.g. add an import at the
|
||||
top AND change code lower down). Since each
|
||||
``fix_before`` / ``fix_after`` pair covers ONE contiguous
|
||||
block, create **separate location entries** for each
|
||||
non-contiguous part. Use ``label`` to describe each part's
|
||||
role (``"Add escape helper import"``, ``"Sanitize input
|
||||
before SQL"``). Order primary fix first, supporting
|
||||
changes (imports, config) after.
|
||||
|
||||
**Informational vs actionable**:
|
||||
- With ``fix_before`` / ``fix_after``: actionable fix
|
||||
(renders as a PR suggestion block).
|
||||
- Without them: informational context (e.g. showing the
|
||||
source of tainted data, or a sink that doesn't need
|
||||
direct editing).
|
||||
|
||||
**Per-location fields**:
|
||||
- ``file`` (REQUIRED): path **relative** to repo root. No
|
||||
leading slash, no ``..``, no ``/workspace/`` prefix.
|
||||
Right: ``"src/db/queries.ts"``. Wrong:
|
||||
``"/workspace/repo/src/db/queries.ts"``, ``"./src/x.py"``,
|
||||
``"../../etc/passwd"``.
|
||||
- ``start_line`` (REQUIRED): 1-based; positive integer.
|
||||
Verify against the actual file — do NOT guess.
|
||||
- ``end_line`` (REQUIRED): 1-based; ``>= start_line``.
|
||||
Only equal to ``start_line`` when the block truly is one
|
||||
line.
|
||||
- ``snippet`` (optional): verbatim source at this range.
|
||||
- ``label`` (optional): short role description; especially
|
||||
important for multi-part fixes.
|
||||
- ``fix_before`` (optional): verbatim copy of the
|
||||
vulnerable code, lines ``start_line``-``end_line``.
|
||||
- ``fix_after`` (optional): complete replacement for that
|
||||
block; syntactically valid.
|
||||
|
||||
**Common mistakes to avoid**:
|
||||
- Guessing line numbers instead of reading the file.
|
||||
- Paraphrasing / reformatting code in ``fix_before``.
|
||||
- Setting ``start_line == end_line`` when the vulnerable
|
||||
code spans multiple lines.
|
||||
- Bundling an import addition and a far-away code change
|
||||
into one location — split them.
|
||||
- Padding ``fix_before`` with surrounding context lines
|
||||
that aren't part of the fix.
|
||||
- Duplicating the same change across multiple locations.
|
||||
"""
|
||||
inner = ctx.context if isinstance(ctx.context, dict) else {}
|
||||
raw_agent_id = inner.get("agent_id")
|
||||
agent_id = raw_agent_id if isinstance(raw_agent_id, str) else None
|
||||
agent_name = None
|
||||
coordinator = inner.get("coordinator")
|
||||
if agent_id is not None and coordinator is not None:
|
||||
names = getattr(coordinator, "names", {})
|
||||
if isinstance(names, dict):
|
||||
raw_agent_name = names.get(agent_id)
|
||||
agent_name = raw_agent_name if isinstance(raw_agent_name, str) else None
|
||||
|
||||
result = await _do_create(
|
||||
title=title,
|
||||
description=description,
|
||||
impact=impact,
|
||||
target=target,
|
||||
technical_analysis=technical_analysis,
|
||||
poc_description=poc_description,
|
||||
poc_script_code=poc_script_code,
|
||||
remediation_steps=remediation_steps,
|
||||
cvss_breakdown=cvss_breakdown,
|
||||
endpoint=endpoint,
|
||||
method=method,
|
||||
cve=cve,
|
||||
cwe=cwe,
|
||||
code_locations=code_locations,
|
||||
agent_id=agent_id,
|
||||
agent_name=agent_name,
|
||||
)
|
||||
return json.dumps(result, ensure_ascii=False, default=str)
|
||||
@@ -0,0 +1,15 @@
|
||||
# shell — `exec_command` + `write_stdin`
|
||||
|
||||
SDK-provided shell tools wired per-run from the sandbox session. Every CLI
|
||||
invocation the agent makes (nmap, ffuf, agent-browser, python3, …) goes
|
||||
through `exec_command`. `write_stdin` streams input to a still-running
|
||||
process started by an earlier `exec_command` (for interactive prompts).
|
||||
|
||||
- **Implementation:** `agents.sandbox.capabilities.tools.shell_tool.ShellTool`
|
||||
(in the upstream `agents` SDK)
|
||||
- **Wired in:** `strix/agents/factory.py` — added per-run via the SDK
|
||||
`Shell` capability; `write_stdin` is wrapped to drop the SDK's `pid`
|
||||
arg from the function schema.
|
||||
- **Sandbox env:** `http_proxy` / `https_proxy` route every shell child
|
||||
through Caido; `AGENT_BROWSER_*`, `REQUESTS_CA_BUNDLE` etc. come from
|
||||
`containers/Dockerfile`.
|
||||
@@ -0,0 +1,37 @@
|
||||
"""``think`` — record a private chain-of-thought note with no side effects."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
from agents import function_tool
|
||||
|
||||
|
||||
@function_tool(timeout=10)
|
||||
async def think(thought: str) -> str:
|
||||
"""Record a private chain-of-thought note. No side effects, no new info.
|
||||
|
||||
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 reasoning to record. Must be non-empty.
|
||||
"""
|
||||
if not thought or not thought.strip():
|
||||
return json.dumps({"success": False, "error": "Thought cannot be empty"})
|
||||
return json.dumps({"success": True, "message": "Thought recorded"})
|
||||
@@ -0,0 +1,585 @@
|
||||
"""Per-agent todo tools — mirrored to {state_dir}/todos.json."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import tempfile
|
||||
import threading
|
||||
import uuid
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from agents import RunContextWrapper, function_tool
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
VALID_PRIORITIES = ["low", "normal", "high", "critical"]
|
||||
VALID_STATUSES = ["pending", "in_progress", "done"]
|
||||
|
||||
_PRIORITY_RANK = {"critical": 0, "high": 1, "normal": 2, "low": 3}
|
||||
_STATUS_RANK = {"done": 0, "in_progress": 1, "pending": 2}
|
||||
|
||||
|
||||
def _todo_sort_key(todo: dict[str, Any]) -> tuple[int, int, str]:
|
||||
return (
|
||||
_STATUS_RANK.get(todo.get("status", "pending"), 99),
|
||||
_PRIORITY_RANK.get(todo.get("priority", "normal"), 99),
|
||||
todo.get("created_at", ""),
|
||||
)
|
||||
|
||||
|
||||
_todos_storage: dict[str, dict[str, dict[str, Any]]] = {}
|
||||
|
||||
_todos_path: Path | None = None
|
||||
_todos_io_lock = threading.RLock()
|
||||
|
||||
|
||||
def hydrate_todos_from_disk(state_dir: Path) -> None:
|
||||
global _todos_path # noqa: PLW0603
|
||||
_todos_path = state_dir / "todos.json"
|
||||
with _todos_io_lock:
|
||||
_todos_storage.clear()
|
||||
if not _todos_path.exists():
|
||||
return
|
||||
try:
|
||||
data = json.loads(_todos_path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
logger.exception(
|
||||
"todos.json at %s is unreadable; starting with empty todos",
|
||||
_todos_path,
|
||||
)
|
||||
return
|
||||
if not isinstance(data, dict):
|
||||
return
|
||||
loaded = 0
|
||||
for aid, by_id in data.items():
|
||||
if not isinstance(aid, str) or not isinstance(by_id, dict):
|
||||
continue
|
||||
cleaned = {
|
||||
str(tid): t
|
||||
for tid, t in by_id.items()
|
||||
if isinstance(tid, str) and isinstance(t, dict)
|
||||
}
|
||||
if cleaned:
|
||||
_todos_storage[aid] = cleaned
|
||||
loaded += len(cleaned)
|
||||
logger.info(
|
||||
"todos hydrated from %s (%d agent(s), %d todo(s))",
|
||||
_todos_path,
|
||||
len(_todos_storage),
|
||||
loaded,
|
||||
)
|
||||
|
||||
|
||||
def _persist() -> None:
|
||||
path = _todos_path
|
||||
if path is None:
|
||||
return
|
||||
try:
|
||||
payload = json.dumps(_todos_storage, ensure_ascii=False, default=str)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with (
|
||||
_todos_io_lock,
|
||||
tempfile.NamedTemporaryFile(
|
||||
mode="w",
|
||||
encoding="utf-8",
|
||||
dir=str(path.parent),
|
||||
prefix=f".{path.name}.",
|
||||
suffix=".tmp",
|
||||
delete=False,
|
||||
) as tmp,
|
||||
):
|
||||
tmp.write(payload)
|
||||
tmp_path = Path(tmp.name)
|
||||
tmp_path.replace(path)
|
||||
except Exception:
|
||||
logger.exception("todos persist to %s failed", path)
|
||||
|
||||
|
||||
def _agent_id_from(ctx: RunContextWrapper) -> str:
|
||||
inner = ctx.context if isinstance(ctx.context, dict) else {}
|
||||
return str(inner.get("agent_id") or "default")
|
||||
|
||||
|
||||
def _get_agent_todos(agent_id: str) -> dict[str, dict[str, Any]]:
|
||||
return _todos_storage.setdefault(agent_id, {})
|
||||
|
||||
|
||||
def _normalize_priority(priority: str | None, default: str = "normal") -> str:
|
||||
candidate = (priority or default or "normal").lower()
|
||||
if candidate not in VALID_PRIORITIES:
|
||||
raise ValueError(f"Invalid priority. Must be one of: {', '.join(VALID_PRIORITIES)}")
|
||||
return candidate
|
||||
|
||||
|
||||
def _sorted_todos(agent_id: str) -> list[dict[str, Any]]:
|
||||
todos_list = [
|
||||
{**todo, "todo_id": todo_id} for todo_id, todo in _get_agent_todos(agent_id).items()
|
||||
]
|
||||
todos_list.sort(key=_todo_sort_key)
|
||||
return todos_list
|
||||
|
||||
|
||||
def _normalize_todo_ids(raw_ids: Any) -> list[str]:
|
||||
if raw_ids is None:
|
||||
return []
|
||||
if isinstance(raw_ids, str):
|
||||
stripped = raw_ids.strip()
|
||||
if not stripped:
|
||||
return []
|
||||
try:
|
||||
data = json.loads(stripped)
|
||||
except json.JSONDecodeError:
|
||||
data = stripped.split(",") if "," in stripped else [stripped]
|
||||
if isinstance(data, list):
|
||||
return [str(item).strip() for item in data if str(item).strip()]
|
||||
return [str(data).strip()]
|
||||
if isinstance(raw_ids, list):
|
||||
return [str(item).strip() for item in raw_ids if str(item).strip()]
|
||||
return [str(raw_ids).strip()]
|
||||
|
||||
|
||||
def _normalize_bulk_updates(raw_updates: Any) -> list[dict[str, Any]]:
|
||||
if raw_updates is None:
|
||||
return []
|
||||
data: Any = raw_updates
|
||||
if isinstance(raw_updates, str):
|
||||
stripped = raw_updates.strip()
|
||||
if not stripped:
|
||||
return []
|
||||
try:
|
||||
data = json.loads(stripped)
|
||||
except json.JSONDecodeError as e:
|
||||
raise ValueError("Updates must be valid JSON") from e
|
||||
|
||||
if isinstance(data, dict):
|
||||
data = [data]
|
||||
if not isinstance(data, list):
|
||||
raise TypeError("Updates must be a list of update objects")
|
||||
|
||||
normalized: list[dict[str, Any]] = []
|
||||
for item in data:
|
||||
if not isinstance(item, dict):
|
||||
raise TypeError("Each update must be an object with todo_id")
|
||||
todo_id = item.get("todo_id") or item.get("id")
|
||||
if not todo_id:
|
||||
raise ValueError("Each update must include 'todo_id'")
|
||||
normalized.append(
|
||||
{
|
||||
"todo_id": str(todo_id).strip(),
|
||||
"title": item.get("title"),
|
||||
"description": item.get("description"),
|
||||
"priority": item.get("priority"),
|
||||
"status": item.get("status"),
|
||||
},
|
||||
)
|
||||
return normalized
|
||||
|
||||
|
||||
def _normalize_bulk_todos(raw_todos: Any) -> list[dict[str, Any]]:
|
||||
if raw_todos is None:
|
||||
return []
|
||||
data: Any = raw_todos
|
||||
if isinstance(raw_todos, str):
|
||||
stripped = raw_todos.strip()
|
||||
if not stripped:
|
||||
return []
|
||||
try:
|
||||
data = json.loads(stripped)
|
||||
except json.JSONDecodeError:
|
||||
entries = [line.strip(" -*\t") for line in stripped.splitlines() if line.strip(" -*\t")]
|
||||
return [{"title": entry} for entry in entries]
|
||||
|
||||
if isinstance(data, dict):
|
||||
data = [data]
|
||||
if not isinstance(data, list):
|
||||
raise TypeError("Todos must be provided as a list, dict, or JSON string")
|
||||
|
||||
normalized: list[dict[str, Any]] = []
|
||||
for item in data:
|
||||
if isinstance(item, str):
|
||||
title = item.strip()
|
||||
if title:
|
||||
normalized.append({"title": title})
|
||||
continue
|
||||
if not isinstance(item, dict):
|
||||
raise TypeError("Each todo entry must be a string or object with a title")
|
||||
title = item.get("title", "")
|
||||
if not isinstance(title, str) or not title.strip():
|
||||
raise ValueError("Each todo entry must include a non-empty 'title'")
|
||||
normalized.append(
|
||||
{
|
||||
"title": title.strip(),
|
||||
"description": (item.get("description") or "").strip() or None,
|
||||
"priority": item.get("priority"),
|
||||
},
|
||||
)
|
||||
return normalized
|
||||
|
||||
|
||||
def _apply_single_update(
|
||||
agent_todos: dict[str, dict[str, Any]],
|
||||
todo_id: str,
|
||||
title: str | None = None,
|
||||
description: str | None = None,
|
||||
priority: str | None = None,
|
||||
status: str | None = None,
|
||||
) -> dict[str, Any] | None:
|
||||
if todo_id not in agent_todos:
|
||||
return {"todo_id": todo_id, "error": f"Todo with ID '{todo_id}' not found"}
|
||||
todo = agent_todos[todo_id]
|
||||
if title is not None:
|
||||
if not title.strip():
|
||||
return {"todo_id": todo_id, "error": "Title cannot be empty"}
|
||||
todo["title"] = title.strip()
|
||||
if description is not None:
|
||||
todo["description"] = description.strip() if description else None
|
||||
if priority is not None:
|
||||
try:
|
||||
todo["priority"] = _normalize_priority(priority, str(todo.get("priority", "normal")))
|
||||
except ValueError as exc:
|
||||
return {"todo_id": todo_id, "error": str(exc)}
|
||||
if status is not None:
|
||||
status_candidate = status.lower()
|
||||
if status_candidate not in VALID_STATUSES:
|
||||
return {
|
||||
"todo_id": todo_id,
|
||||
"error": f"Invalid status. Must be one of: {', '.join(VALID_STATUSES)}",
|
||||
}
|
||||
todo["status"] = status_candidate
|
||||
todo["completed_at"] = datetime.now(UTC).isoformat() if status_candidate == "done" else None
|
||||
todo["updated_at"] = datetime.now(UTC).isoformat()
|
||||
return None
|
||||
|
||||
|
||||
@function_tool(timeout=30)
|
||||
async def create_todo(ctx: RunContextWrapper, todos: str) -> str:
|
||||
"""Create one or many todos for the current agent.
|
||||
|
||||
Always pass a list, even for a single todo (wrap it in a one-item array).
|
||||
|
||||
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.
|
||||
|
||||
Args:
|
||||
todos: JSON array of todo objects. For one todo, pass a one-item
|
||||
list. Each object's fields:
|
||||
|
||||
- ``title`` (str, **required**): short actionable title,
|
||||
e.g. ``"Test /api/admin for IDOR"``.
|
||||
- ``description`` (str, optional): extra context or
|
||||
acceptance criteria.
|
||||
- ``priority`` (str, optional): one of ``"low"`` /
|
||||
``"normal"`` / ``"high"`` / ``"critical"``. Defaults to
|
||||
``"normal"``.
|
||||
|
||||
Example: ``[{"title": "Probe /admin", "priority": "high"},
|
||||
{"title": "Check JWT alg=none"}]``.
|
||||
"""
|
||||
agent_id = _agent_id_from(ctx)
|
||||
try:
|
||||
tasks = _normalize_bulk_todos(todos)
|
||||
if not tasks:
|
||||
return json.dumps(
|
||||
{"success": False, "error": "Provide a non-empty 'todos' list to create"},
|
||||
ensure_ascii=False,
|
||||
default=str,
|
||||
)
|
||||
|
||||
agent_todos = _get_agent_todos(agent_id)
|
||||
created: list[dict[str, Any]] = []
|
||||
for task in tasks:
|
||||
task_priority = _normalize_priority(task.get("priority"))
|
||||
todo_id = str(uuid.uuid4())[:6]
|
||||
timestamp = datetime.now(UTC).isoformat()
|
||||
agent_todos[todo_id] = {
|
||||
"title": task["title"],
|
||||
"description": task.get("description"),
|
||||
"priority": task_priority,
|
||||
"status": "pending",
|
||||
"created_at": timestamp,
|
||||
"updated_at": timestamp,
|
||||
"completed_at": None,
|
||||
}
|
||||
created.append({"todo_id": todo_id, "title": task["title"], "priority": task_priority})
|
||||
except (ValueError, TypeError) as e:
|
||||
return json.dumps(
|
||||
{"success": False, "error": f"Failed to create todo: {e}"},
|
||||
ensure_ascii=False,
|
||||
default=str,
|
||||
)
|
||||
|
||||
_persist()
|
||||
return json.dumps(
|
||||
{
|
||||
"success": True,
|
||||
"created": created,
|
||||
"created_count": len(created),
|
||||
"todos": _sorted_todos(agent_id),
|
||||
"total_count": len(_get_agent_todos(agent_id)),
|
||||
},
|
||||
ensure_ascii=False,
|
||||
default=str,
|
||||
)
|
||||
|
||||
|
||||
@function_tool(timeout=30)
|
||||
async def list_todos(
|
||||
ctx: RunContextWrapper,
|
||||
status: str | None = None,
|
||||
priority: str | None = None,
|
||||
) -> str:
|
||||
"""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)
|
||||
status_filter = status.lower() if isinstance(status, str) else None
|
||||
priority_filter = priority.lower() if isinstance(priority, str) else None
|
||||
|
||||
todos_list: list[dict[str, Any]] = []
|
||||
for todo_id, todo in agent_todos.items():
|
||||
if status_filter and todo.get("status") != status_filter:
|
||||
continue
|
||||
if priority_filter and todo.get("priority") != priority_filter:
|
||||
continue
|
||||
entry = todo.copy()
|
||||
entry["todo_id"] = todo_id
|
||||
todos_list.append(entry)
|
||||
|
||||
todos_list.sort(key=_todo_sort_key)
|
||||
|
||||
summary: dict[str, int] = {"pending": 0, "in_progress": 0, "done": 0}
|
||||
for todo in todos_list:
|
||||
sv = todo.get("status", "pending")
|
||||
summary[sv] = summary.get(sv, 0) + 1
|
||||
except (ValueError, TypeError) as e:
|
||||
return json.dumps(
|
||||
{
|
||||
"success": False,
|
||||
"error": f"Failed to list todos: {e}",
|
||||
"todos": [],
|
||||
"filtered_count": 0,
|
||||
"total_count": 0,
|
||||
"summary": {"pending": 0, "in_progress": 0, "done": 0},
|
||||
},
|
||||
ensure_ascii=False,
|
||||
default=str,
|
||||
)
|
||||
|
||||
return json.dumps(
|
||||
{
|
||||
"success": True,
|
||||
"todos": todos_list,
|
||||
"filtered_count": len(todos_list),
|
||||
"total_count": len(agent_todos),
|
||||
"summary": summary,
|
||||
},
|
||||
ensure_ascii=False,
|
||||
default=str,
|
||||
)
|
||||
|
||||
|
||||
@function_tool(timeout=30)
|
||||
async def update_todo(ctx: RunContextWrapper, updates: str) -> str:
|
||||
"""Update one or many todos.
|
||||
|
||||
Always pass a list, even for a single update (wrap it in a one-item
|
||||
array).
|
||||
|
||||
For toggling status only, prefer the dedicated ``mark_todo_done`` /
|
||||
``mark_todo_pending`` tools — they're simpler and accept the same
|
||||
list-of-ids form.
|
||||
|
||||
Args:
|
||||
updates: JSON array of update objects. For one update, pass a
|
||||
one-item list. Each object's fields:
|
||||
|
||||
- ``todo_id`` (str, **required**): ID returned by
|
||||
``create_todo``.
|
||||
- ``title`` (str, optional): new title.
|
||||
- ``description`` (str, optional): new description (empty
|
||||
string clears it).
|
||||
- ``priority`` (str, optional): one of ``"low"`` /
|
||||
``"normal"`` / ``"high"`` / ``"critical"``.
|
||||
- ``status`` (str, optional): one of ``"pending"`` /
|
||||
``"in_progress"`` / ``"done"``.
|
||||
|
||||
Omitted fields stay unchanged. Example:
|
||||
``[{"todo_id": "abc", "status": "in_progress",
|
||||
"priority": "high"}]``.
|
||||
"""
|
||||
agent_id = _agent_id_from(ctx)
|
||||
try:
|
||||
agent_todos = _get_agent_todos(agent_id)
|
||||
updates_to_apply = _normalize_bulk_updates(updates)
|
||||
if not updates_to_apply:
|
||||
return json.dumps(
|
||||
{"success": False, "error": "Provide a non-empty 'updates' list"},
|
||||
ensure_ascii=False,
|
||||
default=str,
|
||||
)
|
||||
|
||||
updated: list[str] = []
|
||||
errors: list[dict[str, Any]] = []
|
||||
for upd in updates_to_apply:
|
||||
err = _apply_single_update(
|
||||
agent_todos,
|
||||
upd["todo_id"],
|
||||
upd.get("title"),
|
||||
upd.get("description"),
|
||||
upd.get("priority"),
|
||||
upd.get("status"),
|
||||
)
|
||||
if err:
|
||||
errors.append(err)
|
||||
else:
|
||||
updated.append(upd["todo_id"])
|
||||
except (ValueError, TypeError) as e:
|
||||
return json.dumps({"success": False, "error": str(e)}, ensure_ascii=False, default=str)
|
||||
|
||||
if updated:
|
||||
_persist()
|
||||
response: dict[str, Any] = {
|
||||
"success": len(errors) == 0,
|
||||
"updated": updated,
|
||||
"updated_count": len(updated),
|
||||
"todos": _sorted_todos(agent_id),
|
||||
"total_count": len(agent_todos),
|
||||
}
|
||||
if errors:
|
||||
response["errors"] = errors
|
||||
return json.dumps(response, ensure_ascii=False, default=str)
|
||||
|
||||
|
||||
def _mark(*, agent_id: str, todo_ids: str, new_status: str) -> str:
|
||||
try:
|
||||
agent_todos = _get_agent_todos(agent_id)
|
||||
ids = _normalize_todo_ids(todo_ids)
|
||||
if not ids:
|
||||
msg = f"Provide a non-empty 'todo_ids' list to mark as {new_status}"
|
||||
return json.dumps({"success": False, "error": msg}, ensure_ascii=False, default=str)
|
||||
|
||||
marked: list[str] = []
|
||||
errors: list[dict[str, Any]] = []
|
||||
timestamp = datetime.now(UTC).isoformat()
|
||||
for tid in ids:
|
||||
if tid not in agent_todos:
|
||||
errors.append({"todo_id": tid, "error": f"Todo with ID '{tid}' not found"})
|
||||
continue
|
||||
todo = agent_todos[tid]
|
||||
todo["status"] = new_status
|
||||
todo["completed_at"] = timestamp if new_status == "done" else None
|
||||
todo["updated_at"] = timestamp
|
||||
marked.append(tid)
|
||||
except (ValueError, TypeError) as e:
|
||||
return json.dumps({"success": False, "error": str(e)}, ensure_ascii=False, default=str)
|
||||
|
||||
if marked:
|
||||
_persist()
|
||||
response: dict[str, Any] = {
|
||||
"success": len(errors) == 0,
|
||||
"marked": marked,
|
||||
"marked_count": len(marked),
|
||||
"new_status": new_status,
|
||||
"todos": _sorted_todos(agent_id),
|
||||
"total_count": len(agent_todos),
|
||||
}
|
||||
if errors:
|
||||
response["errors"] = errors
|
||||
return json.dumps(response, ensure_ascii=False, default=str)
|
||||
|
||||
|
||||
@function_tool(timeout=30)
|
||||
async def mark_todo_done(ctx: RunContextWrapper, todo_ids: str) -> str:
|
||||
"""Mark one or many todos as done.
|
||||
|
||||
Always pass a list, even for a single ID (wrap it in a one-item array).
|
||||
|
||||
Args:
|
||||
todo_ids: JSON array of todo IDs to mark done. For one todo,
|
||||
pass a one-item list.
|
||||
"""
|
||||
return _mark(agent_id=_agent_id_from(ctx), todo_ids=todo_ids, new_status="done")
|
||||
|
||||
|
||||
@function_tool(timeout=30)
|
||||
async def mark_todo_pending(ctx: RunContextWrapper, todo_ids: str) -> str:
|
||||
"""Reset one or many todos to pending (e.g., to retry a failed task).
|
||||
|
||||
Always pass a list, even for a single ID (wrap it in a one-item array).
|
||||
|
||||
Args:
|
||||
todo_ids: JSON array of todo IDs to reset to pending. For one
|
||||
todo, pass a one-item list.
|
||||
"""
|
||||
return _mark(agent_id=_agent_id_from(ctx), todo_ids=todo_ids, new_status="pending")
|
||||
|
||||
|
||||
@function_tool(timeout=30)
|
||||
async def delete_todo(ctx: RunContextWrapper, todo_ids: str) -> str:
|
||||
"""Delete one or many todos. Removes them entirely (no soft-delete).
|
||||
|
||||
Always pass a list, even for a single ID (wrap it in a one-item array).
|
||||
|
||||
Args:
|
||||
todo_ids: JSON array of todo IDs to delete. For one todo, pass
|
||||
a one-item list.
|
||||
"""
|
||||
agent_id = _agent_id_from(ctx)
|
||||
try:
|
||||
agent_todos = _get_agent_todos(agent_id)
|
||||
ids = _normalize_todo_ids(todo_ids)
|
||||
if not ids:
|
||||
return json.dumps(
|
||||
{"success": False, "error": "Provide a non-empty 'todo_ids' list to delete"},
|
||||
ensure_ascii=False,
|
||||
default=str,
|
||||
)
|
||||
|
||||
deleted: list[str] = []
|
||||
errors: list[dict[str, Any]] = []
|
||||
for tid in ids:
|
||||
if tid not in agent_todos:
|
||||
errors.append({"todo_id": tid, "error": f"Todo with ID '{tid}' not found"})
|
||||
continue
|
||||
del agent_todos[tid]
|
||||
deleted.append(tid)
|
||||
except (ValueError, TypeError) as e:
|
||||
return json.dumps({"success": False, "error": str(e)}, ensure_ascii=False, default=str)
|
||||
|
||||
if deleted:
|
||||
_persist()
|
||||
response: dict[str, Any] = {
|
||||
"success": len(errors) == 0,
|
||||
"deleted": deleted,
|
||||
"deleted_count": len(deleted),
|
||||
"todos": _sorted_todos(agent_id),
|
||||
"total_count": len(agent_todos),
|
||||
}
|
||||
if errors:
|
||||
response["errors"] = errors
|
||||
return json.dumps(response, ensure_ascii=False, default=str)
|
||||
@@ -0,0 +1,17 @@
|
||||
# view_image
|
||||
|
||||
SDK-provided tool that loads an image from the sandbox workspace and
|
||||
returns it as an image content block for vision-capable models.
|
||||
|
||||
- **Implementation:** `agents.sandbox.capabilities.tools.view_image.ViewImageTool`
|
||||
(upstream `agents` SDK)
|
||||
- **Wired in:** `strix/agents/factory.py` — added per-run via the SDK
|
||||
`Filesystem` capability.
|
||||
- **Strix defaults:** screenshots default to
|
||||
`/workspace/.agent-browser-screenshots/` via `AGENT_BROWSER_SCREENSHOT_DIR`
|
||||
(set in `containers/Dockerfile`; dir is created at container start in
|
||||
`containers/docker-entrypoint.sh`).
|
||||
- **Skill:** screenshot workflow lives in `strix/skills/tooling/agent_browser.md`.
|
||||
- **Recovery:** vision-not-supported model rejections are auto-recovered
|
||||
via `strix.core.sessions.strip_all_images_from_session`, invoked from
|
||||
`strix/core/execution.py`.
|
||||
@@ -0,0 +1,179 @@
|
||||
"""``web_search`` — Perplexity-backed security-focused web search."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
import requests
|
||||
from agents import RunContextWrapper, function_tool
|
||||
|
||||
from strix.config import load_settings
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
_SYSTEM_PROMPT = """You are assisting a cybersecurity agent specialized in vulnerability scanning
|
||||
and security assessment running on Kali Linux. When responding to search queries:
|
||||
|
||||
1. Prioritize cybersecurity-relevant information including:
|
||||
- Vulnerability details (CVEs, CVSS scores, impact)
|
||||
- Security tools, techniques, and methodologies
|
||||
- Exploit information and proof-of-concepts
|
||||
- Security best practices and mitigations
|
||||
- Penetration testing approaches
|
||||
- Web application security findings
|
||||
|
||||
2. Provide technical depth appropriate for security professionals
|
||||
3. Include specific versions, configurations, and technical details when available
|
||||
4. Focus on actionable intelligence for security assessment
|
||||
5. Cite reliable security sources (NIST, OWASP, CVE databases, security vendors)
|
||||
6. When providing commands or installation instructions, prioritize Kali Linux compatibility
|
||||
and use apt package manager or tools pre-installed in Kali
|
||||
7. Be detailed and specific - avoid general answers. Always include concrete code examples,
|
||||
command-line instructions, configuration snippets, or practical implementation steps
|
||||
when applicable
|
||||
|
||||
Structure your response to be comprehensive yet concise, emphasizing the most critical
|
||||
security implications and details."""
|
||||
|
||||
|
||||
def _do_search(query: str) -> dict[str, Any]: # noqa: PLR0911 - each error class needs its own sanitized return
|
||||
if not query or not query.strip():
|
||||
return {"success": False, "error": "Query cannot be empty"}
|
||||
|
||||
api_key = load_settings().integrations.perplexity_api_key
|
||||
if not api_key:
|
||||
logger.warning("web_search invoked without PERPLEXITY_API_KEY configured")
|
||||
return {
|
||||
"success": False,
|
||||
"error": (
|
||||
"Web search is not configured for this scan "
|
||||
"(operator needs to set PERPLEXITY_API_KEY). Proceed without it"
|
||||
),
|
||||
}
|
||||
logger.info("web_search query (len=%d): %s", len(query), query[:120])
|
||||
|
||||
url = "https://api.perplexity.ai/chat/completions"
|
||||
headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}
|
||||
payload = {
|
||||
"model": "sonar-reasoning-pro",
|
||||
"messages": [
|
||||
{"role": "system", "content": _SYSTEM_PROMPT},
|
||||
{"role": "user", "content": query},
|
||||
],
|
||||
}
|
||||
|
||||
try:
|
||||
response = requests.post(url, headers=headers, json=payload, timeout=300)
|
||||
response.raise_for_status()
|
||||
content = response.json()["choices"][0]["message"]["content"]
|
||||
except requests.exceptions.Timeout:
|
||||
logger.warning("web_search timed out")
|
||||
return {
|
||||
"success": False,
|
||||
"error": "Web search timed out. Try again or shorten the query",
|
||||
}
|
||||
except requests.exceptions.HTTPError as exc:
|
||||
status = exc.response.status_code if exc.response is not None else None
|
||||
logger.exception("web_search HTTP error status=%s", status)
|
||||
if status is not None and 400 <= status < 500:
|
||||
return {
|
||||
"success": False,
|
||||
"error": (
|
||||
"Web search rejected the query. Refine it "
|
||||
"(more specific, shorter, no unusual characters) and retry"
|
||||
),
|
||||
}
|
||||
return {
|
||||
"success": False,
|
||||
"error": "Web search service is unavailable. Try again later",
|
||||
}
|
||||
except requests.exceptions.RequestException:
|
||||
logger.exception("web_search network error")
|
||||
return {
|
||||
"success": False,
|
||||
"error": "Web search network error. Try again later",
|
||||
}
|
||||
except (KeyError, IndexError, ValueError):
|
||||
logger.exception("web_search response shape unexpected")
|
||||
return {
|
||||
"success": False,
|
||||
"error": "Web search returned an unexpected response. Try again",
|
||||
}
|
||||
except Exception:
|
||||
logger.exception("web_search failed")
|
||||
return {
|
||||
"success": False,
|
||||
"error": "Web search failed unexpectedly",
|
||||
}
|
||||
else:
|
||||
return {
|
||||
"success": True,
|
||||
"query": query,
|
||||
"content": content,
|
||||
}
|
||||
|
||||
|
||||
@function_tool(timeout=330)
|
||||
async def web_search(ctx: RunContextWrapper, query: str) -> str:
|
||||
"""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.
|
||||
|
||||
**Good example queries** (each is a full sentence, names a
|
||||
version/product, and asks one concrete thing):
|
||||
|
||||
- ``"Found OpenSSH 7.4 on port 22 — any known RCE or privesc for
|
||||
this exact version?"``
|
||||
- ``"Cloudflare WAF is blocking my sqlmap on a login form — what
|
||||
bypass techniques work in 2025?"``
|
||||
- ``"Target runs WordPress 5.8.3 + WooCommerce 6.1.1 — current
|
||||
RCE chains for this combo?"``
|
||||
- ``"Low-priv shell on Ubuntu 20.04 kernel 5.4.0-74-generic — what
|
||||
local privesc exploits hit this kernel?"``
|
||||
- ``"Compromised domain user on Windows Server 2019 AD — quietest
|
||||
paths to Domain Admin without tripping EDR?"``
|
||||
- ``"'Access denied' uploading a webshell to IIS 10.0 — alternate
|
||||
Windows IIS upload bypass techniques?"``
|
||||
- ``"Discovered Jenkins 2.401.3 on staging — current authn-bypass
|
||||
and RCE exploits for this version?"``
|
||||
- ``"Best 2025 Python lib for JWT algorithm-confusion + weak-secret
|
||||
cracking?"``
|
||||
|
||||
Args:
|
||||
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.
|
||||
"""
|
||||
result = await asyncio.to_thread(_do_search, query)
|
||||
return json.dumps(result, ensure_ascii=False, default=str)
|
||||
Reference in New Issue
Block a user