first commit

This commit is contained in:
Zakaria
2026-07-02 12:31:49 -04:00
commit 1c7784cae9
189 changed files with 32427 additions and 0 deletions
+11
View File
@@ -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.
"""
+12
View File
@@ -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`.
+673
View File
@@ -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,
)
+10
View File
@@ -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.
View File
+188
View File
@@ -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)
View File
+36
View File
@@ -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)
View File
+417
View File
@@ -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
)
View File
+682
View File
@@ -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",
]
+596
View File
@@ -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)
View File
+515
View File
@@ -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)
+15
View File
@@ -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`.
View File
+37
View File
@@ -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"})
View File
+585
View File
@@ -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)
+17
View File
@@ -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`.
View File
+179
View File
@@ -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)