Tighten tool surface consistency
Four passes of audit-and-patch on the tool surface, condensed.
Tool API shape:
- Todo tools collapse to a single list-based form (one arg per tool,
always a list, no dual-mode validator). Result-field names line up
across the family — created_count / updated_count / marked_count /
deleted_count, and _mark returns a single "marked" key plus the new
status instead of marked_done / marked_pending.
- list_notes splits the overloaded total_count into filtered_count
(matches) and total_count (grand total), matching list_todos. All
three notes mutations now echo total_count and note_id.
- finish_scan drops the machine-code error strings; a single human
"error" key carries the reason on every failure path.
- scope_rules delete echoes a message so the renderer's success
branch has something to surface.
Failure-key unification: every tool now uses {"success": False,
"error": "..."} on failure paths. Touched thinking, web_search,
reporting, and finish. Trailing periods on error strings swept clean
across the whole tool tree.
Tool prompts (docstring re-imports vs main):
- create_vulnerability_report re-imports the CWE reference catalog,
multi-part fix rules, fix_before/fix_after PR-suggestion mechanics,
the COMMON MISTAKES list, the informational-vs-actionable
distinction, and file-path examples.
- web_search re-imports concrete example queries.
- list_sitemap docstring fixed hasDescendants -> has_descendants
(the camelCase reference never matched our snake_case schema).
- create_agent.skills description "Comma-separated" -> "List of".
- factory.py module docstring no longer claims there's no runtime
skill-loading tool. agents_graph module docstring lists stop_agent.
- system_prompt nudges loading the matching skill before guessing
payloads or syntax from memory.
TUI:
- proxy_renderer was reading stale field names from the pre-SDK
schema (requests / total_count / statusCode / matches /
showing_lines); now reads entries / page_info / status_code / hits
/ page+total_lines. Three proxy operations were rendering empty
before this.
- Idle-pane placeholder text trimmed to "Loading...".
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -11,8 +11,9 @@ Two flavors:
|
||||
``create_agent`` graph tool. Carries ``agent_finish`` and stops
|
||||
after that tool reports ``agent_completed``.
|
||||
|
||||
Skills are baked into the system prompt at scan bring-up; there's no
|
||||
runtime skill-loading tool.
|
||||
Skills are baked into the system prompt at scan bring-up. The
|
||||
``load_skill`` tool is also available for on-demand inline reference
|
||||
to any skill the agent didn't preload.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -149,6 +149,7 @@ OPERATIONAL PRINCIPLES:
|
||||
- Prefer established industry-standard tools already available in the sandbox before writing custom scripts
|
||||
- Do NOT reinvent the wheel with ad hoc Python or shell code when a suitable existing tool can do the job reliably
|
||||
- Skills relevant to your task are preloaded into this prompt at scan start; refer back to them when you need vulnerability-, protocol-, or tool-specific guidance
|
||||
- For skills not preloaded, use `load_skill` to pull them inline — prefer loading the matching skill before guessing payloads, workflows, or tool syntax from memory
|
||||
- Use custom Python or shell code when you want to dig deeper, automate custom workflows, batch operations, triage results, build target-specific validation, or do work that existing tools do not cover cleanly
|
||||
- Chain related weaknesses when needed to demonstrate real impact
|
||||
- Consider business logic and context in validation
|
||||
|
||||
@@ -998,9 +998,7 @@ class StrixTUIApp(App): # type: ignore[misc]
|
||||
self,
|
||||
) -> tuple[Any, str | None]:
|
||||
if not self.selected_agent_id:
|
||||
return self._get_chat_placeholder_content(
|
||||
"Select an agent from the tree to see its activity.", "placeholder-no-agent"
|
||||
)
|
||||
return self._get_chat_placeholder_content("Loading...", "placeholder-no-agent")
|
||||
|
||||
events = self._gather_agent_events(self.selected_agent_id)
|
||||
|
||||
@@ -1353,7 +1351,9 @@ class StrixTUIApp(App): # type: ignore[misc]
|
||||
|
||||
def _agent_vulnerability_count(self, agent_id: str) -> int:
|
||||
return sum(
|
||||
1 for vuln in self.report_state.vulnerability_reports if vuln.get("agent_id") == agent_id
|
||||
1
|
||||
for vuln in self.report_state.vulnerability_reports
|
||||
if vuln.get("agent_id") == agent_id
|
||||
)
|
||||
|
||||
def _gather_agent_events(self, agent_id: str) -> list[dict[str, Any]]:
|
||||
|
||||
@@ -42,7 +42,7 @@ class ListRequestsRenderer(BaseToolRenderer):
|
||||
css_classes: ClassVar[list[str]] = ["tool-call", "proxy-tool"]
|
||||
|
||||
@classmethod
|
||||
def render(cls, tool_data: dict[str, Any]) -> Static: # noqa: PLR0912 # noqa: PLR0912
|
||||
def render(cls, tool_data: dict[str, Any]) -> Static: # noqa: PLR0912, PLR0915
|
||||
args = tool_data.get("args", {})
|
||||
result = tool_data.get("result")
|
||||
status = tool_data.get("status", "running")
|
||||
@@ -73,21 +73,25 @@ class ListRequestsRenderer(BaseToolRenderer):
|
||||
if "error" in result:
|
||||
text.append(f" error: {_sanitize(str(result['error']), 150)}", style="#ef4444")
|
||||
else:
|
||||
total = result.get("total_count", 0)
|
||||
requests = result.get("requests", [])
|
||||
entries = result.get("entries", [])
|
||||
page_info = result.get("page_info") or {}
|
||||
has_more = (
|
||||
bool(page_info.get("has_next_page")) if isinstance(page_info, dict) else False
|
||||
)
|
||||
count_suffix = "+" if has_more else ""
|
||||
text.append(f" [{len(entries)}{count_suffix} found]", style="dim")
|
||||
|
||||
text.append(f" [{total} found]", style="dim")
|
||||
|
||||
if requests and isinstance(requests, list):
|
||||
if entries and isinstance(entries, list):
|
||||
text.append("\n")
|
||||
for i, req in enumerate(requests[:MAX_REQUESTS_DISPLAY]):
|
||||
if not isinstance(req, dict):
|
||||
for i, entry in enumerate(entries[:MAX_REQUESTS_DISPLAY]):
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
method = req.get("method", "?")
|
||||
host = req.get("host", "")
|
||||
path = req.get("path", "/")
|
||||
resp = req.get("response") or {}
|
||||
code = resp.get("statusCode") if isinstance(resp, dict) else None
|
||||
req = entry.get("request") or {}
|
||||
resp = entry.get("response") or {}
|
||||
method = req.get("method", "?") if isinstance(req, dict) else "?"
|
||||
host = req.get("host", "") if isinstance(req, dict) else ""
|
||||
path = req.get("path", "/") if isinstance(req, dict) else "/"
|
||||
code = resp.get("status_code") if isinstance(resp, dict) else None
|
||||
|
||||
text.append(" ")
|
||||
text.append(f"{method:6}", style="#a78bfa")
|
||||
@@ -95,13 +99,13 @@ class ListRequestsRenderer(BaseToolRenderer):
|
||||
if code:
|
||||
text.append(f" {code}", style=_status_style(code))
|
||||
|
||||
if i < min(len(requests), MAX_REQUESTS_DISPLAY) - 1:
|
||||
if i < min(len(entries), MAX_REQUESTS_DISPLAY) - 1:
|
||||
text.append("\n")
|
||||
|
||||
if len(requests) > MAX_REQUESTS_DISPLAY:
|
||||
if len(entries) > MAX_REQUESTS_DISPLAY:
|
||||
text.append("\n")
|
||||
text.append(
|
||||
f" ... +{len(requests) - MAX_REQUESTS_DISPLAY} more",
|
||||
f" ... +{len(entries) - MAX_REQUESTS_DISPLAY} more",
|
||||
style="dim italic",
|
||||
)
|
||||
|
||||
@@ -139,14 +143,14 @@ class ViewRequestRenderer(BaseToolRenderer):
|
||||
if status == "completed" and isinstance(result, dict):
|
||||
if "error" in result:
|
||||
text.append(f" error: {_sanitize(str(result['error']), 150)}", style="#ef4444")
|
||||
elif "matches" in result:
|
||||
matches = result.get("matches", [])
|
||||
total = result.get("total_matches", len(matches))
|
||||
elif "hits" in result:
|
||||
hits = result.get("hits", [])
|
||||
total = result.get("total_hits", len(hits))
|
||||
text.append(f" [{total} matches]", style="dim")
|
||||
|
||||
if matches and isinstance(matches, list):
|
||||
if hits and isinstance(hits, list):
|
||||
text.append("\n")
|
||||
for i, m in enumerate(matches[:5]):
|
||||
for i, m in enumerate(hits[:5]):
|
||||
if not isinstance(m, dict):
|
||||
continue
|
||||
before = m.get("before", "") or ""
|
||||
@@ -164,19 +168,20 @@ class ViewRequestRenderer(BaseToolRenderer):
|
||||
if after:
|
||||
text.append(f"{after}...", style="dim")
|
||||
|
||||
if i < min(len(matches), 5) - 1:
|
||||
if i < min(len(hits), 5) - 1:
|
||||
text.append("\n")
|
||||
|
||||
if len(matches) > 5:
|
||||
if len(hits) > 5:
|
||||
text.append("\n")
|
||||
text.append(f" ... +{len(matches) - 5} more matches", style="dim italic")
|
||||
text.append(f" ... +{len(hits) - 5} more matches", style="dim italic")
|
||||
|
||||
elif "content" in result:
|
||||
showing = result.get("showing_lines", "")
|
||||
page = result.get("page", 1)
|
||||
total_lines = result.get("total_lines", 0)
|
||||
has_more = result.get("has_more", False)
|
||||
content = result.get("content", "")
|
||||
|
||||
text.append(f" [{showing}]", style="dim")
|
||||
text.append(f" [page {page}, {total_lines} lines]", style="dim")
|
||||
|
||||
if content and isinstance(content, str):
|
||||
lines = content.split("\n")[:15]
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
- ``wait_for_message``: pause this agent until a message arrives or
|
||||
``timeout_seconds`` elapses.
|
||||
- ``create_agent``: asks the scan runner to spawn an addressable child.
|
||||
- ``stop_agent``: cancel a running agent (optionally cascading to its
|
||||
descendants).
|
||||
- ``agent_finish``: subagents only — posts a structured completion
|
||||
report to the parent's SDK session and returns a final-output marker.
|
||||
"""
|
||||
@@ -94,7 +96,7 @@ async def view_agent_graph(ctx: RunContextWrapper) -> str:
|
||||
me = inner.get("agent_id")
|
||||
if coordinator is None:
|
||||
return json.dumps(
|
||||
{"success": False, "error": "Agent coordinator not initialized in context."},
|
||||
{"success": False, "error": "Agent coordinator not initialized in context"},
|
||||
ensure_ascii=False,
|
||||
default=str,
|
||||
)
|
||||
@@ -172,7 +174,7 @@ async def send_message_to_agent(
|
||||
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."},
|
||||
{"success": False, "error": "Agent coordinator or agent_id missing in context"},
|
||||
ensure_ascii=False,
|
||||
default=str,
|
||||
)
|
||||
@@ -181,8 +183,8 @@ async def send_message_to_agent(
|
||||
{
|
||||
"success": False,
|
||||
"error": (
|
||||
"Cannot send a message to yourself. Use `think` to record a "
|
||||
"private note, or `agent_finish` / `finish_scan` to terminate."
|
||||
"Cannot send a message to yourself; use `think` to record a "
|
||||
"private note, or `agent_finish` / `finish_scan` to terminate"
|
||||
),
|
||||
},
|
||||
ensure_ascii=False,
|
||||
@@ -204,7 +206,7 @@ async def send_message_to_agent(
|
||||
return json.dumps(
|
||||
{
|
||||
"success": False,
|
||||
"error": f"Target agent '{target_agent_id}' not found or message delivery failed.",
|
||||
"error": f"Target agent '{target_agent_id}' not found or message delivery failed",
|
||||
},
|
||||
ensure_ascii=False,
|
||||
default=str,
|
||||
@@ -271,7 +273,7 @@ async def wait_for_message( # noqa: PLR0911
|
||||
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."},
|
||||
{"success": False, "error": "Agent coordinator or agent_id missing in context"},
|
||||
ensure_ascii=False,
|
||||
default=str,
|
||||
)
|
||||
@@ -410,7 +412,8 @@ async def create_agent(
|
||||
inherit_context: Default ``True``. The child receives the
|
||||
parent's input history as background; only set ``False``
|
||||
when starting a clean-slate task.
|
||||
skills: Comma-separated skill names. Max 5; prefer 1-3.
|
||||
skills: List of skill names (e.g. ``["xss", "sql_injection"]``).
|
||||
Max 5; prefer 1-3.
|
||||
"""
|
||||
inner = _ctx(ctx)
|
||||
coordinator = coordinator_from_context(inner)
|
||||
@@ -419,7 +422,7 @@ async def create_agent(
|
||||
|
||||
if coordinator is None or parent_id is None:
|
||||
return json.dumps(
|
||||
{"success": False, "error": "Agent coordinator or agent_id missing in context."},
|
||||
{"success": False, "error": "Agent coordinator or agent_id missing in context"},
|
||||
ensure_ascii=False,
|
||||
default=str,
|
||||
)
|
||||
@@ -427,7 +430,7 @@ async def create_agent(
|
||||
return json.dumps(
|
||||
{
|
||||
"success": False,
|
||||
"error": "Scan runner did not provide a child-agent spawner in context.",
|
||||
"error": "Scan runner did not provide a child-agent spawner in context",
|
||||
},
|
||||
ensure_ascii=False,
|
||||
default=str,
|
||||
@@ -525,7 +528,7 @@ async def agent_finish(
|
||||
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."},
|
||||
{"success": False, "error": "Agent coordinator or agent_id missing in context"},
|
||||
ensure_ascii=False,
|
||||
default=str,
|
||||
)
|
||||
@@ -536,7 +539,7 @@ async def agent_finish(
|
||||
{
|
||||
"success": False,
|
||||
"error": (
|
||||
"agent_finish is for subagents. Root/main agents must call finish_scan instead."
|
||||
"agent_finish is for subagents. Root/main agents must call finish_scan instead"
|
||||
),
|
||||
},
|
||||
ensure_ascii=False,
|
||||
@@ -625,7 +628,7 @@ async def stop_agent(
|
||||
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."},
|
||||
{"success": False, "error": "Agent coordinator or agent_id missing in context"},
|
||||
ensure_ascii=False,
|
||||
default=str,
|
||||
)
|
||||
@@ -633,7 +636,7 @@ async def stop_agent(
|
||||
return json.dumps(
|
||||
{
|
||||
"success": False,
|
||||
"error": "Cannot stop yourself; call agent_finish or finish_scan instead.",
|
||||
"error": "Cannot stop yourself; call agent_finish or finish_scan instead",
|
||||
},
|
||||
ensure_ascii=False,
|
||||
default=str,
|
||||
@@ -653,10 +656,10 @@ async def stop_agent(
|
||||
"success": False,
|
||||
"error": (
|
||||
f"Agent {target_agent_id} is already '{current_status}'; "
|
||||
"stop_agent only acts on running/waiting agents. Use "
|
||||
"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."
|
||||
"want to wake this one with new instructions"
|
||||
),
|
||||
"target_agent_id": target_agent_id,
|
||||
"current_status": current_status,
|
||||
|
||||
@@ -26,9 +26,10 @@ def _do_finish(
|
||||
if parent_id is not None:
|
||||
return {
|
||||
"success": False,
|
||||
"error": "finish_scan_wrong_agent",
|
||||
"message": "This tool can only be used by the root/main agent",
|
||||
"suggestion": "If you are a subagent, use agent_finish instead",
|
||||
"error": (
|
||||
"This tool can only be used by the root/main agent. "
|
||||
"If you are a subagent, use agent_finish instead"
|
||||
),
|
||||
}
|
||||
|
||||
errors: list[str] = []
|
||||
@@ -41,7 +42,7 @@ def _do_finish(
|
||||
if not recommendations.strip():
|
||||
errors.append("Recommendations cannot be empty")
|
||||
if errors:
|
||||
return {"success": False, "message": "Validation failed", "errors": errors}
|
||||
return {"success": False, "error": "Validation failed", "errors": errors}
|
||||
|
||||
try:
|
||||
from strix.report.state import get_global_report_state
|
||||
@@ -64,7 +65,7 @@ def _do_finish(
|
||||
vuln_count = len(report_state.vulnerability_reports)
|
||||
except (ImportError, AttributeError) as e:
|
||||
logger.exception("finish_scan persistence failed")
|
||||
return {"success": False, "message": f"Failed to complete scan: {e!s}"}
|
||||
return {"success": False, "error": f"Failed to complete scan: {e!s}"}
|
||||
else:
|
||||
logger.info(
|
||||
"finish_scan: completed scan with %d vulnerability report(s)",
|
||||
@@ -159,10 +160,9 @@ async def finish_scan(
|
||||
{
|
||||
"success": False,
|
||||
"scan_completed": False,
|
||||
"error": "active_agents_remaining",
|
||||
"message": (
|
||||
"error": (
|
||||
"Cannot finish scan while child agents are still active. "
|
||||
"Wait for completion, send them finish instructions, or stop them first."
|
||||
"Wait for completion, send them finish instructions, or stop them first"
|
||||
),
|
||||
"active_agents": active_agents,
|
||||
},
|
||||
|
||||
@@ -196,6 +196,7 @@ def _create_note_impl(
|
||||
"success": True,
|
||||
"note_id": note_id,
|
||||
"message": f"Note '{title}' created successfully",
|
||||
"total_count": len(_notes_storage),
|
||||
}
|
||||
|
||||
|
||||
@@ -214,9 +215,15 @@ def _list_notes_impl(
|
||||
"success": False,
|
||||
"error": f"Failed to list notes: {e}",
|
||||
"notes": [],
|
||||
"filtered_count": 0,
|
||||
"total_count": 0,
|
||||
}
|
||||
return {"success": True, "notes": notes, "total_count": len(notes)}
|
||||
return {
|
||||
"success": True,
|
||||
"notes": notes,
|
||||
"filtered_count": len(notes),
|
||||
"total_count": len(_notes_storage),
|
||||
}
|
||||
|
||||
|
||||
def _get_note_impl(note_id: str) -> dict[str, Any]:
|
||||
@@ -267,7 +274,9 @@ def _update_note_impl(
|
||||
_persist()
|
||||
return {
|
||||
"success": True,
|
||||
"note_id": note_id,
|
||||
"message": f"Note '{note['title']}' updated successfully",
|
||||
"total_count": len(_notes_storage),
|
||||
}
|
||||
|
||||
|
||||
@@ -285,7 +294,9 @@ def _delete_note_impl(note_id: str) -> dict[str, Any]:
|
||||
_persist()
|
||||
return {
|
||||
"success": True,
|
||||
"note_id": note_id,
|
||||
"message": f"Note '{note_title}' deleted successfully",
|
||||
"total_count": len(_notes_storage),
|
||||
}
|
||||
|
||||
|
||||
@@ -377,7 +388,7 @@ async def list_notes(
|
||||
|
||||
@function_tool(timeout=30)
|
||||
async def get_note(ctx: RunContextWrapper, note_id: str) -> str:
|
||||
"""Fetch one note by its 5-char ID. Returns the full content.
|
||||
"""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.
|
||||
@@ -402,7 +413,7 @@ async def update_note(
|
||||
``get_note``, concat, and pass the result.
|
||||
|
||||
Args:
|
||||
note_id: Target note's 5-char ID.
|
||||
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.
|
||||
|
||||
@@ -324,9 +324,9 @@ async def replay_send_raw(
|
||||
"status": "ERROR",
|
||||
"error": (
|
||||
f"Caido replay dispatch did not complete within "
|
||||
f"{_REPLAY_SEND_TIMEOUT_SECONDS:.0f}s. The target may be "
|
||||
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."
|
||||
"is stalled; check the target host/port and retry"
|
||||
),
|
||||
"elapsed_ms": elapsed_ms,
|
||||
"response_raw": None,
|
||||
|
||||
@@ -453,7 +453,7 @@ async def list_sitemap(
|
||||
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 ``hasDescendants=true`` and pass its ``id``
|
||||
- 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
|
||||
@@ -575,7 +575,7 @@ async def scope_rules(
|
||||
if action == "get":
|
||||
if not scope_id:
|
||||
return json.dumps(
|
||||
{"success": False, "error": "scope_id required for get"},
|
||||
{"success": False, "error": "Scope_id is required for action='get'"},
|
||||
ensure_ascii=False,
|
||||
default=str,
|
||||
)
|
||||
@@ -586,7 +586,7 @@ async def scope_rules(
|
||||
if action == "create":
|
||||
if not scope_name:
|
||||
return json.dumps(
|
||||
{"success": False, "error": "scope_name required for create"},
|
||||
{"success": False, "error": "Scope_name is required for action='create'"},
|
||||
ensure_ascii=False,
|
||||
default=str,
|
||||
)
|
||||
@@ -601,7 +601,7 @@ async def scope_rules(
|
||||
return json.dumps(
|
||||
{
|
||||
"success": False,
|
||||
"error": "scope_id and scope_name required for update",
|
||||
"error": "Scope_id and scope_name are required for action='update'",
|
||||
},
|
||||
ensure_ascii=False,
|
||||
default=str,
|
||||
@@ -615,11 +615,19 @@ async def scope_rules(
|
||||
# action == "delete" — exhaustive Literal
|
||||
if not scope_id:
|
||||
return json.dumps(
|
||||
{"success": False, "error": "scope_id required for delete"},
|
||||
{"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}, ensure_ascii=False, default=str)
|
||||
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)
|
||||
|
||||
@@ -209,7 +209,7 @@ async def _do_create( # noqa: PLR0912
|
||||
errors.append(cwe_err)
|
||||
|
||||
if errors:
|
||||
return {"success": False, "message": "Validation failed", "errors": errors}
|
||||
return {"success": False, "error": "Validation failed", "errors": errors}
|
||||
|
||||
cvss_score, severity, _vector = _calculate_cvss(cvss_breakdown)
|
||||
|
||||
@@ -248,9 +248,9 @@ async def _do_create( # noqa: PLR0912
|
||||
)
|
||||
return {
|
||||
"success": False,
|
||||
"message": (
|
||||
"error": (
|
||||
f"Potential duplicate of '{duplicate_title}' "
|
||||
f"(id={duplicate_id[:8]}...). Do not re-report the same vulnerability."
|
||||
f"(id={duplicate_id[:8]}...) — do not re-report the same vulnerability"
|
||||
),
|
||||
"duplicate_of": duplicate_id,
|
||||
"duplicate_title": duplicate_title,
|
||||
@@ -280,7 +280,7 @@ async def _do_create( # noqa: PLR0912
|
||||
)
|
||||
except (ImportError, AttributeError) as e:
|
||||
logger.exception("create_vulnerability_report persistence failed")
|
||||
return {"success": False, "message": f"Failed to create vulnerability report: {e!s}"}
|
||||
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",
|
||||
@@ -351,10 +351,9 @@ async def create_vulnerability_report(
|
||||
- Avoid hedging language; be precise and non-vague.
|
||||
|
||||
**White-box requirement**: when source is available, you MUST
|
||||
populate ``code_locations`` with one entry per affected line range.
|
||||
The ``fix_before`` field must be a verbatim copy of the source at
|
||||
the specified line range — it's used as a literal GitHub/GitLab
|
||||
PR suggestion block.
|
||||
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):
|
||||
@@ -383,8 +382,30 @@ async def create_vulnerability_report(
|
||||
|
||||
**CVE / CWE rules**: pass the bare ID only (``CVE-2024-1234``,
|
||||
``CWE-89``) — no name, no parenthetical. Be 100% certain; if
|
||||
unsure, omit. Always prefer the most specific child CWE over a
|
||||
broad parent (CWE-89 not CWE-74; CWE-78 not CWE-77).
|
||||
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.
|
||||
@@ -402,11 +423,69 @@ async def create_vulnerability_report(
|
||||
method: HTTP method when relevant.
|
||||
cve: ``CVE-YYYY-NNNNN`` if certain, else omit.
|
||||
cwe: ``CWE-NNN`` (most specific child) if certain, else omit.
|
||||
code_locations: Required for white-box findings. List of
|
||||
objects, each with ``file`` (relative path), ``start_line``,
|
||||
``end_line``, optional ``snippet``, ``label``,
|
||||
``fix_before`` (verbatim source), ``fix_after`` (suggested
|
||||
replacement).
|
||||
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")
|
||||
|
||||
@@ -33,5 +33,5 @@ async def think(thought: str) -> str:
|
||||
thought: The reasoning to record. Must be non-empty.
|
||||
"""
|
||||
if not thought or not thought.strip():
|
||||
return json.dumps({"success": False, "message": "Thought cannot be empty"})
|
||||
return json.dumps({"success": False, "error": "Thought cannot be empty"})
|
||||
return json.dumps({"success": True, "message": "Thought recorded"})
|
||||
|
||||
@@ -327,7 +327,7 @@ async def create_todo(ctx: RunContextWrapper, todos: str) -> str:
|
||||
tasks = _normalize_bulk_todos(todos)
|
||||
if not tasks:
|
||||
return json.dumps(
|
||||
{"success": False, "error": "Provide a non-empty 'todos' list to create."},
|
||||
{"success": False, "error": "Provide a non-empty 'todos' list to create"},
|
||||
ensure_ascii=False,
|
||||
default=str,
|
||||
)
|
||||
@@ -469,7 +469,7 @@ async def update_todo(ctx: RunContextWrapper, updates: str) -> str:
|
||||
updates_to_apply = _normalize_bulk_updates(updates)
|
||||
if not updates_to_apply:
|
||||
return json.dumps(
|
||||
{"success": False, "error": "Provide a non-empty 'updates' list."},
|
||||
{"success": False, "error": "Provide a non-empty 'updates' list"},
|
||||
ensure_ascii=False,
|
||||
default=str,
|
||||
)
|
||||
@@ -511,7 +511,7 @@ def _mark(*, agent_id: str, todo_ids: str, new_status: str) -> str:
|
||||
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}."
|
||||
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] = []
|
||||
@@ -586,7 +586,7 @@ async def delete_todo(ctx: RunContextWrapper, todo_ids: str) -> str:
|
||||
ids = _normalize_todo_ids(todo_ids)
|
||||
if not ids:
|
||||
return json.dumps(
|
||||
{"success": False, "error": "Provide a non-empty 'todo_ids' list to delete."},
|
||||
{"success": False, "error": "Provide a non-empty 'todo_ids' list to delete"},
|
||||
ensure_ascii=False,
|
||||
default=str,
|
||||
)
|
||||
|
||||
@@ -43,16 +43,16 @@ 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, "message": "Query cannot be empty."}
|
||||
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,
|
||||
"message": (
|
||||
"error": (
|
||||
"Web search is not configured for this scan "
|
||||
"(operator needs to set PERPLEXITY_API_KEY). Proceed without it."
|
||||
"(operator needs to set PERPLEXITY_API_KEY). Proceed without it"
|
||||
),
|
||||
}
|
||||
logger.info("web_search query (len=%d): %s", len(query), query[:120])
|
||||
@@ -78,7 +78,7 @@ def _do_search(query: str) -> dict[str, Any]: # noqa: PLR0911 - each error clas
|
||||
logger.warning("web_search timed out")
|
||||
return {
|
||||
"success": False,
|
||||
"message": "Web search timed out. Try again or shorten the query.",
|
||||
"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
|
||||
@@ -86,32 +86,32 @@ def _do_search(query: str) -> dict[str, Any]: # noqa: PLR0911 - each error clas
|
||||
if status is not None and 400 <= status < 500:
|
||||
return {
|
||||
"success": False,
|
||||
"message": (
|
||||
"error": (
|
||||
"Web search rejected the query. Refine it "
|
||||
"(more specific, shorter, no unusual characters) and retry."
|
||||
"(more specific, shorter, no unusual characters) and retry"
|
||||
),
|
||||
}
|
||||
return {
|
||||
"success": False,
|
||||
"message": "Web search service is unavailable. Try again later.",
|
||||
"error": "Web search service is unavailable. Try again later",
|
||||
}
|
||||
except requests.exceptions.RequestException:
|
||||
logger.exception("web_search network error")
|
||||
return {
|
||||
"success": False,
|
||||
"message": "Web search network error. Try again later.",
|
||||
"error": "Web search network error. Try again later",
|
||||
}
|
||||
except (KeyError, IndexError, ValueError):
|
||||
logger.exception("web_search response shape unexpected")
|
||||
return {
|
||||
"success": False,
|
||||
"message": "Web search returned an unexpected response. Try again.",
|
||||
"error": "Web search returned an unexpected response. Try again",
|
||||
}
|
||||
except Exception:
|
||||
logger.exception("web_search failed")
|
||||
return {
|
||||
"success": False,
|
||||
"message": "Web search failed unexpectedly.",
|
||||
"error": "Web search failed unexpectedly",
|
||||
}
|
||||
else:
|
||||
return {
|
||||
@@ -155,6 +155,26 @@ async def web_search(ctx: RunContextWrapper, query: str) -> str:
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user