refactor: inline non-sandbox actions, strip registry, drop schemas
Cleanup pass after the migration: #1 Inline ``*_actions.py`` into wrapper ``tool[s].py`` for the non-sandbox tools (think, todo, notes, reporting, web_search, finish_scan). One file per tool family now. Helpers + public function bodies live alongside the ``@strix_tool``-decorated wrappers that call them. For notes, the sync helpers are renamed to ``_create_note_impl`` / ``_list_notes_impl`` / etc. so the public names ``create_note`` / ``list_notes`` / etc. can be the FunctionTool instances the agent factory imports. ``append_note_content`` (used by the agents-graph wiki-update hook) calls the impl helpers directly. #2 Delete ``strix/tools/_state_adapter.py``. The ``AgentStateAdapter`` shim only existed to feed legacy ``*_actions.py`` functions a ``state.agent_id`` they could read. With the actions inlined, the wrappers read ``ctx.context['agent_id']`` directly. #3 Strip ``strix/tools/registry.py`` from ~250 LOC to ~110. Deleted: XML schema loading, ``_parse_param_schema``, ``get_tools_prompt``, ``get_tool_param_schema``, ``needs_agent_state``, ``should_execute_in_sandbox``, ``validate_tool_availability`` — all for the host-side legacy dispatcher path. Kept the ``register_tool`` decorator (sandbox side), ``get_tool_by_name``, ``get_tool_names``, ``tools`` list, ``clear_registry``. The Jinja prompt template's ``{{ get_tools_prompt() }}`` injection is dropped — the SDK auto-generates tool descriptions from function signatures, so the legacy XML tool block was redundant and stale. #4 Delete every ``*_actions_schema.xml`` (12 files). They were read by the now-removed ``_load_xml_schema`` to build the legacy prompt's tool descriptions. No consumer remains. Side fixes: - ``reporting_renderer.py`` updated to import ``_parse_*_xml`` from the new location with leading underscore. - ``test_local_tools.py``, ``test_notes_jsonl_concurrency.py``, ``test_notes_wiki.py`` updated to point at the new module paths and call the ``_*_impl`` sync helpers. Tests: 279/279 passing. ~1500 LOC of action files moved into the tool wrappers; ~140 LOC of registry boilerplate removed; ~400 lines of dead XML deleted. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -17,7 +17,6 @@ from typing import Any
|
||||
from jinja2 import Environment, FileSystemLoader, select_autoescape
|
||||
|
||||
from strix.skills import load_skills
|
||||
from strix.tools import get_tools_prompt
|
||||
from strix.utils.resource_paths import get_strix_resource_path
|
||||
|
||||
|
||||
@@ -103,7 +102,6 @@ def render_system_prompt(
|
||||
env.globals["get_skill"] = lambda name: skill_content.get(name, "")
|
||||
|
||||
rendered = env.get_template("system_prompt.jinja").render(
|
||||
get_tools_prompt=get_tools_prompt,
|
||||
loaded_skill_names=list(skill_content.keys()),
|
||||
interactive=interactive,
|
||||
system_prompt_context=system_prompt_context or {},
|
||||
|
||||
@@ -428,8 +428,6 @@ SPRAYING EXECUTION NOTE:
|
||||
- Favor batch-mode CLI tools (sqlmap, ffuf, nuclei, zaproxy, arjun) where appropriate and check traffic via the proxy when beneficial
|
||||
|
||||
REMINDER: Always close each tool call with </function> before going into the next. Incomplete tool calls will fail.
|
||||
|
||||
{{ get_tools_prompt() }}
|
||||
</tool_usage>
|
||||
|
||||
<environment>
|
||||
|
||||
@@ -6,9 +6,11 @@ from pygments.styles import get_style_by_name
|
||||
from rich.text import Text
|
||||
from textual.widgets import Static
|
||||
|
||||
from strix.tools.reporting.reporting_actions import (
|
||||
parse_code_locations_xml,
|
||||
parse_cvss_xml,
|
||||
from strix.tools.reporting.tool import (
|
||||
_parse_code_locations_xml as parse_code_locations_xml,
|
||||
)
|
||||
from strix.tools.reporting.tool import (
|
||||
_parse_cvss_xml as parse_cvss_xml,
|
||||
)
|
||||
|
||||
from .base_renderer import BaseToolRenderer
|
||||
|
||||
+7
-12
@@ -1,14 +1,13 @@
|
||||
"""Tool package.
|
||||
|
||||
The package init wires the in-container side: importing every tool
|
||||
sub-package triggers the ``@register_tool`` decorations that populate
|
||||
``strix.tools.registry.tools``, which the in-container FastAPI tool
|
||||
server (:mod:`strix.runtime.tool_server`) dispatches against.
|
||||
Importing every sub-package triggers the ``@register_tool``
|
||||
decorations that populate ``strix.tools.registry.tools``. The
|
||||
in-container FastAPI tool server (:mod:`strix.runtime.tool_server`)
|
||||
dispatches against that registry.
|
||||
|
||||
Host-side SDK function tools live in ``<family>/tool.py`` (or
|
||||
``tools.py``) and are imported directly by
|
||||
:mod:`strix.agents.factory` — they do not flow through this package
|
||||
init's ``register_tool`` registry.
|
||||
Host-side SDK function tools live in ``<family>/tool[s].py`` and are
|
||||
imported directly by :mod:`strix.agents.factory` — they don't flow
|
||||
through this registry.
|
||||
"""
|
||||
|
||||
from .agents_graph import * # noqa: F403
|
||||
@@ -22,8 +21,6 @@ from .registry import (
|
||||
ImplementedInClientSideOnlyError,
|
||||
get_tool_by_name,
|
||||
get_tool_names,
|
||||
get_tools_prompt,
|
||||
needs_agent_state,
|
||||
register_tool,
|
||||
tools,
|
||||
)
|
||||
@@ -38,8 +35,6 @@ __all__ = [
|
||||
"ImplementedInClientSideOnlyError",
|
||||
"get_tool_by_name",
|
||||
"get_tool_names",
|
||||
"get_tools_prompt",
|
||||
"needs_agent_state",
|
||||
"register_tool",
|
||||
"tools",
|
||||
]
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
"""Adapter exposing ``ctx.context['agent_id']`` as ``state.agent_id``.
|
||||
|
||||
Several tool implementations still take an ``agent_state`` argument
|
||||
that they read ``.agent_id`` off of for per-agent silo keying. The SDK
|
||||
keeps that same identity in ``ctx.context['agent_id']``. Rather than
|
||||
plumb a different parameter through every tool body, we build a tiny
|
||||
adapter object from the run context.
|
||||
|
||||
Used by:
|
||||
- ``tools/todo/tools.py``
|
||||
- ``tools/finish/tool.py``
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from agents import RunContextWrapper
|
||||
|
||||
|
||||
@dataclass
|
||||
class AgentStateAdapter:
|
||||
"""Just enough surface for tools that read ``state.agent_id``."""
|
||||
|
||||
agent_id: str
|
||||
|
||||
|
||||
def adapter_from_ctx(
|
||||
ctx: RunContextWrapper,
|
||||
default_agent_id: str = "default",
|
||||
) -> AgentStateAdapter:
|
||||
"""Build an ``AgentStateAdapter`` from an SDK run context.
|
||||
|
||||
Falls back to ``default_agent_id`` when context is missing or its
|
||||
``agent_id`` is unset — keeps tests and CLI dry-runs working without
|
||||
a fully-populated context.
|
||||
"""
|
||||
inner = getattr(ctx, "context", None)
|
||||
if isinstance(inner, dict):
|
||||
agent_id = inner.get("agent_id") or default_agent_id
|
||||
else:
|
||||
agent_id = default_agent_id
|
||||
return AgentStateAdapter(agent_id=str(agent_id))
|
||||
@@ -1,226 +0,0 @@
|
||||
<tools>
|
||||
<tool name="agent_finish">
|
||||
<description>Mark a subagent's task as completed and optionally report results to parent agent.
|
||||
|
||||
IMPORTANT: This tool can ONLY be used by subagents (agents with a parent).
|
||||
Root/main agents must use finish_scan instead.
|
||||
|
||||
This tool should be called when a subagent completes its assigned subtask to:
|
||||
- Mark the subagent's task as completed
|
||||
- Report findings back to the parent agent
|
||||
|
||||
Use this tool when:
|
||||
- You are a subagent working on a specific subtask
|
||||
- You have completed your assigned task
|
||||
- You want to report your findings to the parent agent
|
||||
- You are ready to terminate this subagent's execution</description>
|
||||
<details>This replaces the previous finish_scan tool and handles both sub-agent completion
|
||||
and main agent completion. When a sub-agent finishes, it can report its findings
|
||||
back to the parent agent for coordination.</details>
|
||||
<parameters>
|
||||
<parameter name="result_summary" type="string" required="true">
|
||||
<description>Summary of what the agent accomplished and discovered</description>
|
||||
</parameter>
|
||||
<parameter name="findings" type="string" required="false">
|
||||
<description>List of specific findings, vulnerabilities, or discoveries</description>
|
||||
</parameter>
|
||||
<parameter name="success" type="boolean" required="false">
|
||||
<description>Whether the agent's task completed successfully</description>
|
||||
</parameter>
|
||||
<parameter name="report_to_parent" type="boolean" required="false">
|
||||
<description>Whether to send results back to the parent agent</description>
|
||||
</parameter>
|
||||
<parameter name="final_recommendations" type="string" required="false">
|
||||
<description>Recommendations for next steps or follow-up actions</description>
|
||||
</parameter>
|
||||
</parameters>
|
||||
<returns type="Dict[str, Any]">
|
||||
<description>Response containing: - agent_completed: Whether the agent was marked as completed - parent_notified: Whether parent was notified (if applicable) - completion_summary: Summary of completion status</description>
|
||||
</returns>
|
||||
<examples>
|
||||
# Sub-agent completing subdomain enumeration task
|
||||
<function=agent_finish>
|
||||
<parameter=result_summary>Completed comprehensive subdomain enumeration for target.com.
|
||||
Discovered 47 subdomains including several interesting ones with admin/dev
|
||||
in the name. Found 3 subdomains with exposed services on non-standard
|
||||
ports.</parameter>
|
||||
<parameter=findings>["admin.target.com - exposed phpMyAdmin",
|
||||
"dev-api.target.com - unauth API endpoints",
|
||||
"staging.target.com - directory listing enabled",
|
||||
"mail.target.com - POP3/IMAP services"]</parameter>
|
||||
<parameter=success>true</parameter>
|
||||
<parameter=report_to_parent>true</parameter>
|
||||
<parameter=final_recommendations>["Prioritize testing admin.target.com for default creds",
|
||||
"Enumerate dev-api.target.com API endpoints",
|
||||
"Check staging.target.com for sensitive files"]</parameter>
|
||||
</function>
|
||||
</examples>
|
||||
</tool>
|
||||
<tool name="create_agent">
|
||||
<description>Create and spawn a new agent to handle a specific subtask.
|
||||
|
||||
Only create a new agent if no existing agent is handling the specific task.</description>
|
||||
<details>The new agent inherits the parent's conversation history and context up to the point
|
||||
of creation, then continues with its assigned subtask. This enables decomposition
|
||||
of complex penetration testing tasks into specialized sub-agents.
|
||||
|
||||
The agent runs asynchronously and independently, allowing the parent to continue
|
||||
immediately while the new agent executes its task in the background.
|
||||
|
||||
If you as a parent agent don't absolutely have anything to do while your subagents are running, you can use wait_for_message tool. The subagent will continue to run in the background, and update you when it's done.
|
||||
</details>
|
||||
<parameters>
|
||||
<parameter name="task" type="string" required="true">
|
||||
<description>The specific task/objective for the new agent to accomplish</description>
|
||||
</parameter>
|
||||
<parameter name="name" type="string" required="true">
|
||||
<description>Human-readable name for the agent (for tracking purposes)</description>
|
||||
</parameter>
|
||||
<parameter name="inherit_context" type="boolean" required="false">
|
||||
<description>Whether the new agent should inherit parent's conversation history and context</description>
|
||||
</parameter>
|
||||
<parameter name="skills" type="string" required="false">
|
||||
<description>Comma-separated list of skills to use for the agent (MAXIMUM 5 skills allowed). Most agents should have at least one skill in order to be useful. Agents should be highly specialized - use 1-3 related skills; up to 5 for complex contexts. {{DYNAMIC_SKILLS_DESCRIPTION}}</description>
|
||||
</parameter>
|
||||
</parameters>
|
||||
<returns type="Dict[str, Any]">
|
||||
<description>Response containing: - agent_id: Unique identifier for the created agent - success: Whether the agent was created successfully - message: Status message - agent_info: Details about the created agent</description>
|
||||
</returns>
|
||||
<examples>
|
||||
# After confirming no SQL testing agent exists, create agent for vulnerability validation
|
||||
<function=create_agent>
|
||||
<parameter=task>Validate and exploit the suspected SQL injection vulnerability found in
|
||||
the login form. Confirm exploitability and document proof of concept.</parameter>
|
||||
<parameter=name>SQLi Validator</parameter>
|
||||
<parameter=skills>sql_injection</parameter>
|
||||
</function>
|
||||
|
||||
<function=create_agent>
|
||||
<parameter=task>Test authentication mechanisms, JWT implementation, and session management
|
||||
for security vulnerabilities and bypass techniques.</parameter>
|
||||
<parameter=name>Auth Specialist</parameter>
|
||||
<parameter=skills>authentication_jwt, business_logic</parameter>
|
||||
</function>
|
||||
|
||||
# Example of single-skill specialization (most focused)
|
||||
<function=create_agent>
|
||||
<parameter=task>Perform comprehensive XSS testing including reflected, stored, and DOM-based
|
||||
variants across all identified input points.</parameter>
|
||||
<parameter=name>XSS Specialist</parameter>
|
||||
<parameter=skills>xss</parameter>
|
||||
</function>
|
||||
|
||||
# Example of up to 5 related skills (borderline acceptable)
|
||||
<function=create_agent>
|
||||
<parameter=task>Test for server-side vulnerabilities including SSRF, XXE, and potential
|
||||
RCE vectors in file upload and XML processing endpoints.</parameter>
|
||||
<parameter=name>Server-Side Attack Specialist</parameter>
|
||||
<parameter=skills>ssrf, xxe, rce</parameter>
|
||||
</function>
|
||||
</examples>
|
||||
</tool>
|
||||
<tool name="send_message_to_agent">
|
||||
<description>Send a message to another agent in the graph for coordination and communication.</description>
|
||||
<details>This enables agents to communicate with each other during execution, but should be used only when essential:
|
||||
- Sharing discovered information or findings
|
||||
- Asking questions or requesting assistance
|
||||
- Providing instructions or coordination
|
||||
- Reporting status or results
|
||||
|
||||
Best practices:
|
||||
- Avoid routine status updates; batch non-urgent information
|
||||
- Prefer parent/child completion flows (agent_finish)
|
||||
- Do not message when the context is already known</details>
|
||||
<parameters>
|
||||
<parameter name="target_agent_id" type="string" required="true">
|
||||
<description>ID of the agent to send the message to</description>
|
||||
</parameter>
|
||||
<parameter name="message" type="string" required="true">
|
||||
<description>The message content to send</description>
|
||||
</parameter>
|
||||
<parameter name="message_type" type="string" required="false">
|
||||
<description>Type of message being sent: - "query": Question requiring a response - "instruction": Command or directive for the target agent - "information": Informational message (findings, status, etc.)</description>
|
||||
</parameter>
|
||||
<parameter name="priority" type="string" required="false">
|
||||
<description>Priority level of the message</description>
|
||||
</parameter>
|
||||
</parameters>
|
||||
<returns type="Dict[str, Any]">
|
||||
<description>Response containing: - success: Whether the message was sent successfully - message_id: Unique identifier for the message - delivery_status: Status of message delivery</description>
|
||||
</returns>
|
||||
<examples>
|
||||
# Share discovered vulnerability information
|
||||
<function=send_message_to_agent>
|
||||
<parameter=target_agent_id>agent_abc123</parameter>
|
||||
<parameter=message>Found SQL injection vulnerability in /login.php parameter 'username'.
|
||||
Payload: admin' OR '1'='1' -- successfully bypassed authentication.
|
||||
You should focus your testing on the authenticated areas of the
|
||||
application.</parameter>
|
||||
<parameter=message_type>information</parameter>
|
||||
<parameter=priority>high</parameter>
|
||||
</function>
|
||||
|
||||
# Request assistance from specialist agent
|
||||
<function=send_message_to_agent>
|
||||
<parameter=target_agent_id>agent_def456</parameter>
|
||||
<parameter=message>I've identified what appears to be a custom encryption implementation
|
||||
in the API responses. Can you analyze the cryptographic strength and look
|
||||
for potential weaknesses?</parameter>
|
||||
<parameter=message_type>query</parameter>
|
||||
<parameter=priority>normal</parameter>
|
||||
</function>
|
||||
</examples>
|
||||
</tool>
|
||||
<tool name="view_agent_graph">
|
||||
<description>View the current agent graph showing all agents, their relationships, and status.</description>
|
||||
<details>This provides a comprehensive overview of the multi-agent system including:
|
||||
- All agent nodes with their tasks, status, and metadata
|
||||
- Parent-child relationships between agents
|
||||
- Message communication patterns
|
||||
- Current execution state</details>
|
||||
<returns type="Dict[str, Any]">
|
||||
<description>Response containing: - graph_structure: Human-readable representation of the agent graph - summary: High-level statistics about the graph</description>
|
||||
</returns>
|
||||
</tool>
|
||||
<tool name="wait_for_message">
|
||||
<description>Pause the agent loop indefinitely until receiving a message from another agent.
|
||||
|
||||
This tool puts the agent into a waiting state where it remains idle until it receives any form of communication. The agent will automatically resume execution when a message arrives.
|
||||
|
||||
IMPORTANT: This tool causes the agent to stop all activity until a message is received. Use it when you need to:
|
||||
- Wait for subagent completion reports
|
||||
- Coordinate with other agents before proceeding
|
||||
- Synchronize multi-agent workflows
|
||||
|
||||
NOTE: If you are waiting for an agent that is NOT your subagent, you first tell it to message you with updates before waiting for it. Otherwise, you will wait forever!
|
||||
</description>
|
||||
<details>When this tool is called, the agent (you) enters a waiting state and will not continue execution until:
|
||||
- Another agent sends a message via send_message_to_agent
|
||||
- Any other form of inter-agent communication occurs
|
||||
- Waiting timeout is reached
|
||||
|
||||
The agent will automatically resume from where it left off once a message is received.
|
||||
This is particularly useful for parent agents waiting for subagent results or for coordination points in multi-agent workflows.
|
||||
NOTE: If you finished your task, and you do NOT have any child agents running, you should NEVER use this tool, and just call finish tool instead.
|
||||
</details>
|
||||
<parameters>
|
||||
<parameter name="reason" type="string" required="false">
|
||||
<description>Explanation for why the agent is waiting (for logging and monitoring purposes)</description>
|
||||
</parameter>
|
||||
</parameters>
|
||||
<returns type="Dict[str, Any]">
|
||||
<description>Response containing: - success: Whether the agent successfully entered waiting state - status: Current agent status ("waiting") - reason: The reason for waiting - agent_info: Details about the waiting agent - resume_conditions: List of conditions that will resume the agent</description>
|
||||
</returns>
|
||||
<examples>
|
||||
# Wait for subagents to complete their tasks
|
||||
<function=wait_for_message>
|
||||
<parameter=reason>Waiting for subdomain enumeration and port scanning subagents to complete their tasks and report findings</parameter>
|
||||
</function>
|
||||
|
||||
# Coordinate with other agents
|
||||
<function=wait_for_message>
|
||||
<parameter=reason>Waiting for vulnerability assessment agent to share discovered attack vectors before proceeding with exploitation phase</parameter>
|
||||
</function>
|
||||
</examples>
|
||||
</tool>
|
||||
</tools>
|
||||
@@ -1,188 +0,0 @@
|
||||
<tools>
|
||||
<tool name="browser_action">
|
||||
<description>Perform browser actions using a Playwright-controlled browser with multiple tabs.
|
||||
The browser is PERSISTENT and remains active until explicitly closed, allowing for
|
||||
multi-step workflows and long-running processes across multiple tabs.</description>
|
||||
<parameters>
|
||||
<parameter name="action" type="string" required="true">
|
||||
</parameter>
|
||||
<parameter name="url" type="string" required="false">
|
||||
<description>Required for 'launch', 'goto', and optionally for 'new_tab' actions. The URL to launch the browser at, navigate to, or load in new tab. Must include appropriate protocol (e.g., http://, https://, file://).</description>
|
||||
</parameter>
|
||||
<parameter name="coordinate" type="string" required="false">
|
||||
<description>Required for 'click', 'double_click', and 'hover' actions. Format: "x,y" (e.g., "432,321"). Coordinates should target the center of elements (buttons, links, etc.). Must be within the browser viewport resolution. Be very careful to calculate the coordinates correctly based on the previous screenshot.</description>
|
||||
</parameter>
|
||||
<parameter name="text" type="string" required="false">
|
||||
<description>Required for 'type' action. The text to type in the field.</description>
|
||||
</parameter>
|
||||
<parameter name="tab_id" type="string" required="false">
|
||||
<description>Required for 'switch_tab' and 'close_tab' actions. Optional for other actions to specify which tab to operate on. The ID of the tab to operate on. The first tab created during 'launch' has ID "tab_1". If not provided, actions will operate on the currently active tab.</description>
|
||||
</parameter>
|
||||
<parameter name="js_code" type="string" required="false">
|
||||
<description>Required for 'execute_js' action. JavaScript code to execute in the page context. The code runs in the context of the current page and has access to the DOM and all page-defined variables and functions. The last evaluated expression's value is returned in the response.</description>
|
||||
</parameter>
|
||||
<parameter name="duration" type="string" required="false">
|
||||
<description>Required for 'wait' action. Number of seconds to pause execution. Can be fractional (e.g., 0.5 for half a second).</description>
|
||||
</parameter>
|
||||
<parameter name="key" type="string" required="false">
|
||||
<description>Required for 'press_key' action. The key to press. Valid values include: - Single characters: 'a'-'z', 'A'-'Z', '0'-'9' - Special keys: 'Enter', 'Escape', 'ArrowLeft', 'ArrowRight', etc. - Modifier keys: 'Shift', 'Control', 'Alt', 'Meta' - Function keys: 'F1'-'F12'</description>
|
||||
</parameter>
|
||||
<parameter name="file_path" type="string" required="false">
|
||||
<description>Required for 'save_pdf' action. The file path where to save the PDF.</description>
|
||||
</parameter>
|
||||
<parameter name="clear" type="boolean" required="false">
|
||||
<description>For 'get_console_logs' action: whether to clear console logs after retrieving them. Default is False (keep logs).</description>
|
||||
</parameter>
|
||||
</parameters>
|
||||
<returns type="Dict[str, Any]">
|
||||
<description>Response containing: - screenshot: Base64 encoded PNG of the current page state - url: Current page URL - title: Current page title - viewport: Current browser viewport dimensions - tab_id: ID of the current active tab - all_tabs: Dict of all open tab IDs and their URLs - message: Status message about the action performed - js_result: Result of JavaScript execution (for execute_js action) - pdf_saved: File path of saved PDF (for save_pdf action) - console_logs: Array of console messages (for get_console_logs action) Limited to 50KB total and 200 most recent logs. Individual messages truncated at 1KB. - page_source: HTML source code (for view_source action) Large pages are truncated to 100KB (keeping beginning and end sections).</description>
|
||||
</returns>
|
||||
<notes>
|
||||
Important usage rules:
|
||||
1. PERSISTENCE: The browser remains active and maintains its state until
|
||||
explicitly closed with the 'close' action. This allows for multi-step workflows
|
||||
across multiple tool calls and tabs.
|
||||
2. Browser interaction MUST start with 'launch' and end with 'close'.
|
||||
3. Only one action can be performed per call.
|
||||
4. To visit a new URL not reachable from current page, either:
|
||||
- Use 'goto' action
|
||||
- Open a new tab with the URL
|
||||
- Close browser and relaunch
|
||||
5. Click coordinates must be derived from the most recent screenshot.
|
||||
6. You MUST click on the center of the element, not the edge. You MUST calculate
|
||||
the coordinates correctly based on the previous screenshot, otherwise the click
|
||||
will fail. After clicking, check the new screenshot to verify the click was
|
||||
successful.
|
||||
7. Tab management:
|
||||
- First tab from 'launch' is "tab_1"
|
||||
- New tabs are numbered sequentially ("tab_2", "tab_3", etc.)
|
||||
- Must have at least one tab open at all times
|
||||
- Actions affect the currently active tab unless tab_id is specified
|
||||
8. JavaScript execution (following Playwright evaluation patterns):
|
||||
- Code runs in the browser page context, not the tool context
|
||||
- Has access to DOM (document, window, etc.) and page variables/functions
|
||||
- The LAST EVALUATED EXPRESSION is automatically returned - no return statement needed
|
||||
- For simple values: document.title (returns the title)
|
||||
- For objects: {title: document.title, url: location.href} (returns the object)
|
||||
- For async operations: Use await and the promise result will be returned
|
||||
- AVOID explicit return statements - they can break evaluation
|
||||
- object literals must be wrapped in paranthesis when they are the final expression
|
||||
- Variables from tool context are NOT available - pass data as parameters if needed
|
||||
- Examples of correct patterns:
|
||||
* Single value: document.querySelectorAll('img').length
|
||||
* Object result: {images: document.images.length, links: document.links.length}
|
||||
* Async operation: await fetch(location.href).then(r => r.status)
|
||||
* DOM manipulation: document.body.style.backgroundColor = 'red'; 'background changed'
|
||||
|
||||
9. Wait action:
|
||||
- Time is specified in seconds
|
||||
- Can be used to wait for page loads, animations, etc.
|
||||
- Can be fractional (e.g., 0.5 seconds)
|
||||
- Screenshot is captured after the wait
|
||||
10. The browser can operate concurrently with other tools. You may invoke
|
||||
terminal, python, or other tools (in separate assistant messages) while maintaining
|
||||
the active browser session, enabling sophisticated multi-tool workflows.
|
||||
11. Keyboard actions:
|
||||
- Use press_key for individual key presses
|
||||
- Use type for typing regular text
|
||||
- Some keys have special names based on Playwright's key documentation
|
||||
12. All code in the js_code parameter is executed as-is - there's no need to
|
||||
escape special characters or worry about formatting. Just write your JavaScript
|
||||
code normally. It can be single line or multi-line.
|
||||
13. For form filling, click on the field first, then use 'type' to enter text.
|
||||
14. The browser runs in headless mode using Chrome engine for security and performance.
|
||||
15. RESOURCE MANAGEMENT:
|
||||
- ALWAYS close tabs you no longer need using 'close_tab' action.
|
||||
- ALWAYS close the browser with 'close' action when you have completely finished
|
||||
all browser-related tasks. Do not leave the browser running if you're done with it.
|
||||
- If you opened multiple tabs, close them as soon as you've extracted the needed
|
||||
information from each one.
|
||||
</notes>
|
||||
<examples>
|
||||
# Launch browser at URL (creates tab_1)
|
||||
<function=browser_action>
|
||||
<parameter=action>launch</parameter>
|
||||
<parameter=url>https://example.com</parameter>
|
||||
</function>
|
||||
|
||||
# Navigate to different URL
|
||||
<function=browser_action>
|
||||
<parameter=action>goto</parameter>
|
||||
<parameter=url>https://github.com</parameter>
|
||||
</function>
|
||||
|
||||
# Open new tab with different URL
|
||||
<function=browser_action>
|
||||
<parameter=action>new_tab</parameter>
|
||||
<parameter=url>https://another-site.com</parameter>
|
||||
</function>
|
||||
|
||||
# Wait for page load
|
||||
<function=browser_action>
|
||||
<parameter=action>wait</parameter>
|
||||
<parameter=duration>2.5</parameter>
|
||||
</function>
|
||||
|
||||
# Click login button at coordinates from screenshot
|
||||
<function=browser_action>
|
||||
<parameter=action>click</parameter>
|
||||
<parameter=coordinate>450,300</parameter>
|
||||
</function>
|
||||
|
||||
# Click username field and type
|
||||
<function=browser_action>
|
||||
<parameter=action>click</parameter>
|
||||
<parameter=coordinate>400,200</parameter>
|
||||
</function>
|
||||
|
||||
<function=browser_action>
|
||||
<parameter=action>type</parameter>
|
||||
<parameter=text>user@example.com</parameter>
|
||||
</function>
|
||||
|
||||
# Click password field and type
|
||||
<function=browser_action>
|
||||
<parameter=action>click</parameter>
|
||||
<parameter=coordinate>400,250</parameter>
|
||||
</function>
|
||||
|
||||
<function=browser_action>
|
||||
<parameter=action>type</parameter>
|
||||
<parameter=text>mypassword123</parameter>
|
||||
</function>
|
||||
|
||||
# Press Enter key
|
||||
<function=browser_action>
|
||||
<parameter=action>press_key</parameter>
|
||||
<parameter=key>Enter</parameter>
|
||||
</function>
|
||||
|
||||
# Execute JavaScript to get page stats (correct pattern - no return statement)
|
||||
<function=browser_action>
|
||||
<parameter=action>execute_js</parameter>
|
||||
<parameter=js_code>const images = document.querySelectorAll('img');
|
||||
const links = document.querySelectorAll('a');
|
||||
{
|
||||
images: images.length,
|
||||
links: links.length,
|
||||
title: document.title
|
||||
}</parameter>
|
||||
</function>
|
||||
|
||||
# Scroll down
|
||||
<function=browser_action>
|
||||
<parameter=action>scroll_down</parameter>
|
||||
</function>
|
||||
|
||||
# Get console logs
|
||||
<function=browser_action>
|
||||
<parameter=action>get_console_logs</parameter>
|
||||
</function>
|
||||
|
||||
# View page source
|
||||
<function=browser_action>
|
||||
<parameter=action>view_source</parameter>
|
||||
</function>
|
||||
</examples>
|
||||
</tool>
|
||||
</tools>
|
||||
@@ -1,170 +0,0 @@
|
||||
<tools>
|
||||
<tool name="list_files">
|
||||
<description>List files and directories within the specified directory.</description>
|
||||
<parameters>
|
||||
<parameter name="path" type="string" required="true">
|
||||
<description>Directory path to list</description>
|
||||
</parameter>
|
||||
<parameter name="recursive" type="boolean" required="false">
|
||||
<description>Whether to list files recursively</description>
|
||||
</parameter>
|
||||
</parameters>
|
||||
<returns type="Dict[str, Any]">
|
||||
<description>Response containing: - files: List of files and directories - total_files: Total number of files found - total_dirs: Total number of directories found</description>
|
||||
</returns>
|
||||
<notes>
|
||||
- Lists contents alphabetically
|
||||
- Returns maximum 500 results to avoid overwhelming output
|
||||
</notes>
|
||||
<examples>
|
||||
# List directory contents
|
||||
<function=list_files>
|
||||
<parameter=path>/home/user/project/src</parameter>
|
||||
</function>
|
||||
|
||||
# Recursive listing
|
||||
<function=list_files>
|
||||
<parameter=path>/home/user/project/src</parameter>
|
||||
<parameter=recursive>true</parameter>
|
||||
</function>
|
||||
</examples>
|
||||
</tool>
|
||||
<tool name="search_files">
|
||||
<description>Perform a regex search across files in a directory.</description>
|
||||
<parameters>
|
||||
<parameter name="path" type="string" required="true">
|
||||
<description>Directory path to search</description>
|
||||
</parameter>
|
||||
<parameter name="regex" type="string" required="true">
|
||||
<description>Regular expression pattern to search for</description>
|
||||
</parameter>
|
||||
<parameter name="file_pattern" type="string" required="false">
|
||||
<description>File pattern to filter (e.g., "*.py", "*.js")</description>
|
||||
</parameter>
|
||||
</parameters>
|
||||
<returns type="Dict[str, Any]">
|
||||
<description>Response containing: - output: The search results as a string</description>
|
||||
</returns>
|
||||
<notes>
|
||||
- Searches recursively through subdirectories
|
||||
- Uses ripgrep for fast searching
|
||||
</notes>
|
||||
<examples>
|
||||
# Search Python files for a pattern
|
||||
<function=search_files>
|
||||
<parameter=path>/home/user/project/src</parameter>
|
||||
<parameter=regex>def\s+process_data</parameter>
|
||||
<parameter=file_pattern>*.py</parameter>
|
||||
</function>
|
||||
</examples>
|
||||
</tool>
|
||||
<tool name="str_replace_editor">
|
||||
<description>A text editor tool for viewing, creating and editing files.</description>
|
||||
<parameters>
|
||||
<parameter name="command" type="string" required="true">
|
||||
<description>Editor command to execute</description>
|
||||
</parameter>
|
||||
<parameter name="path" type="string" required="true">
|
||||
<description>Path to the file to edit</description>
|
||||
</parameter>
|
||||
<parameter name="file_text" type="string" required="false">
|
||||
<description>Required parameter of create command, with the content of the file to be created</description>
|
||||
</parameter>
|
||||
<parameter name="view_range" type="string" required="false">
|
||||
<description>Optional parameter of view command when path points to a file. If none is given, the full file is shown. If provided, the file will be shown in the indicated line number range, e.g. [11, 12] will show lines 11 and 12. Indexing at 1 to start. Setting [start_line, -1] shows all lines from start_line to the end of the file</description>
|
||||
</parameter>
|
||||
<parameter name="old_str" type="string" required="false">
|
||||
<description>Required parameter of str_replace command containing the string in path to replace</description>
|
||||
</parameter>
|
||||
<parameter name="new_str" type="string" required="false">
|
||||
<description>Optional parameter of str_replace command containing the new string (if not given, no string will be added). Required parameter of insert command containing the string to insert</description>
|
||||
</parameter>
|
||||
<parameter name="insert_line" type="string" required="false">
|
||||
<description>Required parameter of insert command. The new_str will be inserted AFTER the line insert_line of path</description>
|
||||
</parameter>
|
||||
</parameters>
|
||||
<returns type="Dict[str, Any]">
|
||||
<description>Response containing the result of the operation</description>
|
||||
</returns>
|
||||
<notes>
|
||||
Command details:
|
||||
- view: Show file contents, optionally with line range
|
||||
- create: Create a new file with given content
|
||||
- str_replace: Replace old_str with new_str in file
|
||||
- insert: Insert new_str after the specified line number
|
||||
- undo_edit: Revert the last edit made to the file
|
||||
</notes>
|
||||
<examples>
|
||||
# View a file
|
||||
<function=str_replace_editor>
|
||||
<parameter=command>view</parameter>
|
||||
<parameter=path>/home/user/project/file.py</parameter>
|
||||
</function>
|
||||
|
||||
# Create a file
|
||||
<function=str_replace_editor>
|
||||
<parameter=command>create</parameter>
|
||||
<parameter=path>/home/user/project/exploit.py</parameter>
|
||||
<parameter=file_text>#!/usr/bin/env python3
|
||||
"""SQL Injection exploit for Acme Corp login endpoint."""
|
||||
|
||||
import requests
|
||||
import sys
|
||||
|
||||
TARGET = "https://app.acme-corp.com/api/v1/auth/login"
|
||||
|
||||
def exploit(username: str) -> dict:
|
||||
payload = {
|
||||
"username": f"{username}'--",
|
||||
"password": "anything"
|
||||
}
|
||||
response = requests.post(TARGET, json=payload, timeout=10)
|
||||
return response.json()
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) < 2:
|
||||
print(f"Usage: {sys.argv[0]} <username>")
|
||||
sys.exit(1)
|
||||
|
||||
result = exploit(sys.argv[1])
|
||||
print(f"Result: {result}")</parameter>
|
||||
</function>
|
||||
|
||||
# Replace text in file
|
||||
<function=str_replace_editor>
|
||||
<parameter=command>str_replace</parameter>
|
||||
<parameter=path>/home/user/project/file.py</parameter>
|
||||
<parameter=old_str>old_function()</parameter>
|
||||
<parameter=new_str>new_function()</parameter>
|
||||
</function>
|
||||
|
||||
# Insert text after line 10
|
||||
<function=str_replace_editor>
|
||||
<parameter=command>insert</parameter>
|
||||
<parameter=path>/home/user/project/file.py</parameter>
|
||||
<parameter=insert_line>10</parameter>
|
||||
<parameter=new_str>def validate_input(user_input: str) -> bool:
|
||||
"""Validate user input to prevent injection attacks."""
|
||||
forbidden_chars = ["'", '"', ";", "--", "/*", "*/"]
|
||||
for char in forbidden_chars:
|
||||
if char in user_input:
|
||||
return False
|
||||
return True</parameter>
|
||||
</function>
|
||||
|
||||
# Replace code block
|
||||
<function=str_replace_editor>
|
||||
<parameter=command>str_replace</parameter>
|
||||
<parameter=path>/home/user/project/auth.py</parameter>
|
||||
<parameter=old_str>def authenticate(username, password):
|
||||
query = f"SELECT * FROM users WHERE username = '{username}'"
|
||||
result = db.execute(query)
|
||||
return result</parameter>
|
||||
<parameter=new_str>def authenticate(username, password):
|
||||
query = "SELECT * FROM users WHERE username = %s"
|
||||
result = db.execute(query, (username,))
|
||||
return result</parameter>
|
||||
</function>
|
||||
</examples>
|
||||
</tool>
|
||||
</tools>
|
||||
@@ -1,4 +1,4 @@
|
||||
from .finish_actions import finish_scan
|
||||
from .tool import finish_scan
|
||||
|
||||
|
||||
__all__ = ["finish_scan"]
|
||||
|
||||
@@ -1,92 +0,0 @@
|
||||
from typing import Any
|
||||
|
||||
from strix.tools.registry import register_tool
|
||||
|
||||
|
||||
def _validate_root_agent(agent_state: Any) -> dict[str, Any] | None:
|
||||
if agent_state and hasattr(agent_state, "parent_id") and agent_state.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 from agents_graph tool instead",
|
||||
}
|
||||
return None
|
||||
|
||||
|
||||
def _check_active_agents(_agent_state: Any = None) -> dict[str, Any] | None:
|
||||
"""Check whether sibling agents are still running before finishing.
|
||||
|
||||
The active-agent check now lives in the orchestration bus
|
||||
(:class:`strix.orchestration.bus.AgentMessageBus`); ``finish_scan``
|
||||
sees an empty world here and the bus's per-agent state is the
|
||||
source of truth. Returns ``None`` (no blockers) so the caller's
|
||||
field validation can run.
|
||||
"""
|
||||
return None
|
||||
|
||||
|
||||
@register_tool(sandbox_execution=False)
|
||||
def finish_scan(
|
||||
executive_summary: str,
|
||||
methodology: str,
|
||||
technical_analysis: str,
|
||||
recommendations: str,
|
||||
agent_state: Any = None,
|
||||
) -> dict[str, Any]:
|
||||
validation_error = _validate_root_agent(agent_state)
|
||||
if validation_error:
|
||||
return validation_error
|
||||
|
||||
active_agents_error = _check_active_agents(agent_state)
|
||||
if active_agents_error:
|
||||
return active_agents_error
|
||||
|
||||
validation_errors = []
|
||||
|
||||
if not executive_summary or not executive_summary.strip():
|
||||
validation_errors.append("Executive summary cannot be empty")
|
||||
if not methodology or not methodology.strip():
|
||||
validation_errors.append("Methodology cannot be empty")
|
||||
if not technical_analysis or not technical_analysis.strip():
|
||||
validation_errors.append("Technical analysis cannot be empty")
|
||||
if not recommendations or not recommendations.strip():
|
||||
validation_errors.append("Recommendations cannot be empty")
|
||||
|
||||
if validation_errors:
|
||||
return {"success": False, "message": "Validation failed", "errors": validation_errors}
|
||||
|
||||
try:
|
||||
from strix.telemetry.tracer import get_global_tracer
|
||||
|
||||
tracer = get_global_tracer()
|
||||
if tracer:
|
||||
tracer.update_scan_final_fields(
|
||||
executive_summary=executive_summary.strip(),
|
||||
methodology=methodology.strip(),
|
||||
technical_analysis=technical_analysis.strip(),
|
||||
recommendations=recommendations.strip(),
|
||||
)
|
||||
|
||||
vulnerability_count = len(tracer.vulnerability_reports)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"scan_completed": True,
|
||||
"message": "Scan completed successfully",
|
||||
"vulnerabilities_found": vulnerability_count,
|
||||
}
|
||||
|
||||
import logging
|
||||
|
||||
logging.warning("Current tracer not available - scan results not stored")
|
||||
|
||||
except (ImportError, AttributeError) as e:
|
||||
return {"success": False, "message": f"Failed to complete scan: {e!s}"}
|
||||
else:
|
||||
return {
|
||||
"success": True,
|
||||
"scan_completed": True,
|
||||
"message": "Scan completed (not persisted)",
|
||||
"warning": "Results could not be persisted - tracer unavailable",
|
||||
}
|
||||
@@ -1,167 +0,0 @@
|
||||
<tools>
|
||||
<tool name="finish_scan">
|
||||
<description>Complete the security scan by providing the final assessment fields as full penetration test report.
|
||||
|
||||
IMPORTANT: This tool can ONLY be used by the root/main agent.
|
||||
Subagents must use agent_finish from agents_graph tool instead.
|
||||
|
||||
IMPORTANT: This tool will NOT allow finishing if any agents are still running or stopping.
|
||||
You must wait for all agents to complete before using this tool.
|
||||
|
||||
This tool directly updates the scan report data:
|
||||
- executive_summary
|
||||
- methodology
|
||||
- technical_analysis
|
||||
- recommendations
|
||||
|
||||
All fields are REQUIRED and map directly to the final report.
|
||||
|
||||
This must be the last tool called in the scan. It will:
|
||||
1. Verify you are the root agent
|
||||
2. Check all subagents have completed
|
||||
3. Update the scan with your provided fields
|
||||
4. Mark the scan as completed
|
||||
5. Stop agent execution
|
||||
|
||||
Use this tool when:
|
||||
- You are the main/root agent conducting the security assessment
|
||||
- ALL subagents have completed their tasks (no agents are "running" or "stopping")
|
||||
- You have completed all testing phases
|
||||
- You are ready to conclude the entire security assessment
|
||||
|
||||
IMPORTANT: Calling this tool multiple times will OVERWRITE any previous scan report.
|
||||
Make sure you include ALL findings and details in a single comprehensive report.
|
||||
|
||||
If agents are still running, the tool will:
|
||||
- Show you which agents are still active
|
||||
- Suggest using wait_for_message to wait for completion
|
||||
- Suggest messaging agents if immediate completion is needed
|
||||
|
||||
NOTE: Make sure the vulnerabilities found were reported with create_vulnerability_report tool, otherwise they will not be tracked and you will not be rewarded.
|
||||
But make sure to not report the same vulnerability multiple times.
|
||||
|
||||
Professional, customer-facing penetration test report rules (PDF-ready):
|
||||
- Do NOT include internal or system details: never mention local/absolute paths (e.g., "/workspace"), internal tools, agents, orchestrators, sandboxes, models, system prompts/instructions, connection/tooling issues, or tester environment details.
|
||||
- Tone and style: formal, objective, third-person, concise. No internal checklists or engineering runbooks. Content must read as a polished client deliverable.
|
||||
- Structure across fields should align to standard pentest reports:
|
||||
- Executive summary: business impact, risk posture, notable criticals, remediation theme.
|
||||
- Methodology: industry-standard methods (e.g., OWASP, OSSTMM, NIST), scope, constraints—no internal execution notes.
|
||||
- Technical analysis: consolidated findings overview referencing created vulnerability reports; avoid raw logs.
|
||||
- Recommendations: prioritized, actionable, aligned to risk and best practices.
|
||||
</description>
|
||||
<parameters>
|
||||
<parameter name="executive_summary" type="string" required="true">
|
||||
<description>High-level summary for non-technical stakeholders. Include: risk posture assessment, key findings in business context, potential business impact (data exposure, compliance, reputation), and an overarching remediation theme. Write in clear, accessible language for executive leadership.</description>
|
||||
</parameter>
|
||||
<parameter name="methodology" type="string" required="true">
|
||||
<description>Testing methodology and scope. Include: frameworks and standards followed (e.g., OWASP WSTG, PTES), engagement type and approach (black-box, gray-box), in-scope assets and target environment, categories of testing activities performed, and evidence validation standards applied.</description>
|
||||
</parameter>
|
||||
<parameter name="technical_analysis" type="string" required="true">
|
||||
<description>Consolidated overview of confirmed findings and risk patterns. Include: severity model used, a high-level summary of each finding with its severity rating, and systemic root causes or recurring themes observed across findings. Reference individual vulnerability reports for reproduction details — do not duplicate full evidence here.</description>
|
||||
</parameter>
|
||||
<parameter name="recommendations" type="string" required="true">
|
||||
<description>Prioritized, actionable remediation guidance organized by urgency (Immediate, Short-term, Medium-term). Each recommendation should provide specific technical remediation steps. Conclude with retest and validation guidance.</description>
|
||||
</parameter>
|
||||
</parameters>
|
||||
<returns type="Dict[str, Any]">
|
||||
<description>Response containing success status, vulnerability count, and completion message. If agents are still running, returns details about active agents and suggested actions.</description>
|
||||
</returns>
|
||||
<examples>
|
||||
|
||||
<function=finish_scan>
|
||||
<parameter=executive_summary>An external penetration test of the Acme Customer Portal and associated API identified multiple security weaknesses that, if exploited, could result in unauthorized access to customer data, cross-tenant exposure, and access to internal network resources.
|
||||
|
||||
Overall risk posture: Elevated.
|
||||
|
||||
Key findings
|
||||
- Confirmed server-side request forgery (SSRF) in a URL preview capability that enables the application to initiate outbound requests to attacker-controlled destinations and internal network ranges.
|
||||
- Identified broken access control patterns in business-critical workflows that can enable cross-tenant data access (tenant isolation failures).
|
||||
- Observed session and authorization hardening gaps that materially increase risk when combined with other weaknesses.
|
||||
|
||||
Business impact
|
||||
- Increased likelihood of sensitive data exposure across customers/tenants, including invoices, orders, and account information.
|
||||
- Increased risk of internal service exposure through server-side outbound request functionality (including link-local and private network destinations).
|
||||
- Increased potential for account compromise and administrative abuse if tokens are stolen or misused.
|
||||
|
||||
Remediation theme
|
||||
Prioritize eliminating SSRF pathways and centralizing authorization enforcement (deny-by-default). Follow with session hardening and monitoring improvements, then validate with a focused retest.</parameter>
|
||||
<parameter=methodology>The assessment was conducted in accordance with the OWASP Web Security Testing Guide (WSTG) and aligned to industry-standard penetration testing methodology.
|
||||
|
||||
Engagement details
|
||||
- Assessment type: External penetration test (black-box with limited gray-box context)
|
||||
- Target environment: Production-equivalent staging
|
||||
|
||||
Scope (in-scope assets)
|
||||
- Web application: https://app.acme-corp.com
|
||||
- API base: https://app.acme-corp.com/api/v1/
|
||||
|
||||
High-level testing activities
|
||||
- Reconnaissance and attack-surface mapping (routes, parameters, workflows)
|
||||
- Authentication and session management review (token handling, session lifetime, sensitive actions)
|
||||
- Authorization and tenant-isolation testing (object access and privilege boundaries)
|
||||
- Input handling and server-side request testing (URL fetchers, imports, previews, callbacks)
|
||||
- File handling and content rendering review (uploads, previews, unsafe content types)
|
||||
- Configuration review (transport security, security headers, caching behavior, error handling)
|
||||
|
||||
Evidence handling and validation standard
|
||||
Only validated issues with reproducible impact were treated as findings. Each finding was documented with clear reproduction steps and sufficient evidence to support remediation and verification testing.</parameter>
|
||||
<parameter=technical_analysis>This section provides a consolidated view of the confirmed findings and observed risk patterns. Detailed reproduction steps and evidence are documented in the individual vulnerability reports.
|
||||
|
||||
Severity model
|
||||
Severity reflects a combination of exploitability and potential impact to confidentiality, integrity, and availability, considering realistic attacker capabilities.
|
||||
|
||||
Confirmed findings
|
||||
1) Server-side request forgery (SSRF) in URL preview (Critical)
|
||||
The application fetches user-supplied URLs server-side to generate previews. Validation controls were insufficient to prevent access to internal and link-local destinations. This creates a pathway to internal network enumeration and potential access to sensitive internal services. Redirect and DNS/normalization bypass risk must be assumed unless controls are comprehensive and applied on every request hop.
|
||||
|
||||
2) Broken tenant isolation in order/invoice workflows (High)
|
||||
Multiple endpoints accepted object identifiers without consistently enforcing tenant ownership. This is indicative of broken function- and object-level authorization checks. In practice, this can enable cross-tenant access to business-critical resources (viewing or modifying data outside the attacker’s tenant boundary).
|
||||
|
||||
3) Administrative action hardening gaps (Medium)
|
||||
Several sensitive actions lacked defense-in-depth controls (e.g., re-authentication for high-risk actions, consistent authorization checks across related endpoints, and protections against session misuse). While not all behaviors were immediately exploitable in isolation, they increase the likelihood and blast radius of account compromise when chained with other vulnerabilities.
|
||||
|
||||
4) Unsafe file preview/content handling patterns (Medium)
|
||||
File preview and rendering behaviors can create exposure to script execution or content-type confusion if unsafe formats are rendered inline. Controls should be consistent: strong content-type validation, forced download where appropriate, and hardening against active content.
|
||||
|
||||
Systemic themes and root causes
|
||||
- Authorization enforcement appears distributed and inconsistent across endpoints instead of centralized and testable.
|
||||
- Outbound request functionality lacks a robust, deny-by-default policy for destination validation.
|
||||
- Hardening controls (session lifetime, sensitive-action controls, logging) are applied unevenly, increasing the likelihood of successful attack chains.</parameter>
|
||||
<parameter=recommendations>The following recommendations are prioritized by urgency and potential risk reduction.
|
||||
|
||||
Immediate priority
|
||||
These items address the most severe confirmed risks and should be prioritized for immediate remediation.
|
||||
|
||||
1. Remediate server-side request forgery
|
||||
Implement a strict destination allowlist with a deny-by-default policy for all server-initiated outbound requests. Block private, loopback, and link-local address ranges (IPv4 and IPv6) at the application layer after DNS resolution. Re-validate destination addresses on every redirect hop. Apply URL normalization to prevent bypass via ambiguous encodings, alternate IP notations, or DNS rebinding.
|
||||
|
||||
2. Enforce network-level egress controls
|
||||
Restrict application runtime network egress to prevent outbound connections to internal and link-local address spaces at the network layer. Route legitimate outbound requests through a policy-enforcing egress proxy with request logging and alerting.
|
||||
|
||||
3. Enforce tenant-scoped authorization
|
||||
Implement consistent tenant-ownership validation on every read and write path for business-critical resources, including orders, invoices, and account data. Adopt centralized, deny-by-default authorization middleware that enforces object- and function-level access controls uniformly across all endpoints.
|
||||
|
||||
Short-term priority
|
||||
These items reduce residual risk and harden defensive controls.
|
||||
|
||||
4. Centralize and harden authorization logic
|
||||
Consolidate authorization enforcement into a centralized policy layer. Require re-authentication for high-risk administrative actions. Add regression tests covering cross-tenant access, privilege escalation, and negative authorization cases.
|
||||
|
||||
5. Harden session management
|
||||
Enforce secure cookie attributes (Secure, HttpOnly, SameSite). Implement session rotation after authentication events and privilege changes. Reduce session lifetime for privileged contexts. Apply consistent CSRF protections to all state-changing operations.
|
||||
|
||||
Medium-term priority
|
||||
These items strengthen defense-in-depth and improve operational visibility.
|
||||
|
||||
6. Harden file handling and content rendering
|
||||
Enforce strict content-type allowlists for file uploads and previews. Force download disposition for active content types. Implement content sanitization and scanning where applicable. Prevent inline rendering of potentially executable formats.
|
||||
|
||||
7. Improve security monitoring and detection
|
||||
Implement alerting for high-risk events: repeated authorization failures, anomalous outbound request patterns, sensitive administrative actions, and unusual access to business-critical resources. Ensure sufficient logging granularity to support incident investigation.
|
||||
|
||||
Retest and validation
|
||||
Conduct a focused retest after remediation of immediate-priority items to verify the effectiveness of SSRF controls, tenant isolation enforcement, and session hardening. Validate that no bypasses exist through redirect chains, DNS rebinding, or encoding edge cases. Repeat authorization regression tests to confirm consistent enforcement across all endpoints.</parameter>
|
||||
</function>
|
||||
</examples>
|
||||
</tool>
|
||||
</tools>
|
||||
+70
-35
@@ -1,38 +1,74 @@
|
||||
"""SDK function-tool wrapper for the legacy ``finish_scan`` tool.
|
||||
|
||||
The legacy function:
|
||||
|
||||
- Validates the caller is the root agent (``parent_id is None``).
|
||||
- Checks no other agents are still running (via the legacy
|
||||
``_agent_graph`` global).
|
||||
- Persists the four executive-summary fields via
|
||||
``get_global_tracer().update_scan_final_fields(...)``.
|
||||
- Reports the final vulnerability count.
|
||||
|
||||
Both the parent-id check and the agent-graph check rely on legacy
|
||||
multi-agent state that Phase 3 will reimplement on top of the SDK
|
||||
``RunContextWrapper`` + a per-run registry. Until Phase 3 lands, the
|
||||
legacy adapter returns an object with no ``parent_id`` attribute —
|
||||
``hasattr`` returns False, the validation skips, and the call proceeds
|
||||
as if invoked by a root agent. That's the correct degenerate behavior
|
||||
in single-agent mode, which is all Phase 2 ships.
|
||||
"""
|
||||
"""``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
|
||||
|
||||
from strix.tools._decorator import strix_tool
|
||||
from strix.tools._state_adapter import adapter_from_ctx
|
||||
from strix.tools.finish import finish_actions as _impl
|
||||
|
||||
|
||||
def _dump(result: dict[str, Any]) -> str:
|
||||
return json.dumps(result, ensure_ascii=False, default=str)
|
||||
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": "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",
|
||||
}
|
||||
|
||||
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, "message": "Validation failed", "errors": errors}
|
||||
|
||||
try:
|
||||
from strix.telemetry.tracer import get_global_tracer
|
||||
|
||||
tracer = get_global_tracer()
|
||||
if tracer is None:
|
||||
logger.warning("No global tracer; scan results not persisted")
|
||||
return {
|
||||
"success": True,
|
||||
"scan_completed": True,
|
||||
"message": "Scan completed (not persisted)",
|
||||
"warning": "Results could not be persisted - tracer unavailable",
|
||||
}
|
||||
tracer.update_scan_final_fields(
|
||||
executive_summary=executive_summary.strip(),
|
||||
methodology=methodology.strip(),
|
||||
technical_analysis=technical_analysis.strip(),
|
||||
recommendations=recommendations.strip(),
|
||||
)
|
||||
return {
|
||||
"success": True,
|
||||
"scan_completed": True,
|
||||
"message": "Scan completed successfully",
|
||||
"vulnerabilities_found": len(tracer.vulnerability_reports),
|
||||
}
|
||||
except (ImportError, AttributeError) as e:
|
||||
return {"success": False, "message": f"Failed to complete scan: {e!s}"}
|
||||
|
||||
|
||||
@strix_tool(timeout=60)
|
||||
@@ -45,8 +81,8 @@ async def finish_scan(
|
||||
) -> str:
|
||||
"""Finalize the scan and persist the four executive summary sections.
|
||||
|
||||
Only the root agent should call this. Subagents should use
|
||||
``agent_finish`` from the agents_graph tool family instead.
|
||||
Only the root agent should call this. Subagents must use
|
||||
``agent_finish`` (from the multi-agent graph tools) instead.
|
||||
|
||||
Args:
|
||||
executive_summary: High-level scan outcome.
|
||||
@@ -54,14 +90,13 @@ async def finish_scan(
|
||||
technical_analysis: Findings detail across the engagement.
|
||||
recommendations: Prioritized fix list.
|
||||
"""
|
||||
state = adapter_from_ctx(ctx)
|
||||
return _dump(
|
||||
await asyncio.to_thread(
|
||||
_impl.finish_scan,
|
||||
executive_summary=executive_summary,
|
||||
methodology=methodology,
|
||||
technical_analysis=technical_analysis,
|
||||
recommendations=recommendations,
|
||||
agent_state=state,
|
||||
),
|
||||
inner = ctx.context if isinstance(ctx.context, dict) else {}
|
||||
result = await asyncio.to_thread(
|
||||
_do_finish,
|
||||
parent_id=inner.get("parent_id"),
|
||||
executive_summary=executive_summary,
|
||||
methodology=methodology,
|
||||
technical_analysis=technical_analysis,
|
||||
recommendations=recommendations,
|
||||
)
|
||||
return json.dumps(result, ensure_ascii=False, default=str)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
from .notes_actions import (
|
||||
from .tools import (
|
||||
append_note_content,
|
||||
create_note,
|
||||
delete_note,
|
||||
get_note,
|
||||
@@ -8,6 +9,7 @@ from .notes_actions import (
|
||||
|
||||
|
||||
__all__ = [
|
||||
"append_note_content",
|
||||
"create_note",
|
||||
"delete_note",
|
||||
"get_note",
|
||||
|
||||
@@ -1,464 +0,0 @@
|
||||
import json
|
||||
import threading
|
||||
import uuid
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from strix.tools.registry import register_tool
|
||||
|
||||
|
||||
_notes_storage: dict[str, dict[str, Any]] = {}
|
||||
_VALID_NOTE_CATEGORIES = ["general", "findings", "methodology", "questions", "plan", "wiki"]
|
||||
_notes_lock = threading.RLock()
|
||||
_loaded_notes_run_dir: str | None = None
|
||||
_DEFAULT_CONTENT_PREVIEW_CHARS = 280
|
||||
|
||||
|
||||
def _get_run_dir() -> Path | None:
|
||||
try:
|
||||
from strix.telemetry.tracer import get_global_tracer
|
||||
|
||||
tracer = get_global_tracer()
|
||||
if not tracer:
|
||||
return None
|
||||
return tracer.get_run_dir()
|
||||
except (ImportError, OSError, RuntimeError):
|
||||
return None
|
||||
|
||||
|
||||
def _get_notes_jsonl_path() -> Path | None:
|
||||
run_dir = _get_run_dir()
|
||||
if not run_dir:
|
||||
return None
|
||||
|
||||
notes_dir = run_dir / "notes"
|
||||
notes_dir.mkdir(parents=True, exist_ok=True)
|
||||
return notes_dir / "notes.jsonl"
|
||||
|
||||
|
||||
def _append_note_event(op: str, note_id: str, note: dict[str, Any] | None = None) -> None:
|
||||
"""Append one note operation to the run's ``notes/notes.jsonl``.
|
||||
|
||||
C6 (AUDIT_R2.md §1.1): hold ``_notes_lock`` across the file open + write
|
||||
so two concurrent agents (or two parallel SDK tool calls in Phase 6)
|
||||
cannot interleave bytes mid-line and corrupt the JSONL.
|
||||
"""
|
||||
notes_path = _get_notes_jsonl_path()
|
||||
if not notes_path:
|
||||
return
|
||||
|
||||
event: dict[str, Any] = {
|
||||
"timestamp": datetime.now(UTC).isoformat(),
|
||||
"op": op,
|
||||
"note_id": note_id,
|
||||
}
|
||||
if note is not None:
|
||||
event["note"] = note
|
||||
|
||||
with _notes_lock, notes_path.open("a", encoding="utf-8") as f:
|
||||
f.write(f"{json.dumps(event, ensure_ascii=True)}\n")
|
||||
|
||||
|
||||
def _load_notes_from_jsonl(notes_path: Path) -> dict[str, dict[str, Any]]:
|
||||
hydrated: dict[str, dict[str, Any]] = {}
|
||||
if not notes_path.exists():
|
||||
return hydrated
|
||||
|
||||
with notes_path.open(encoding="utf-8") as f:
|
||||
for raw_line in f:
|
||||
line = raw_line.strip()
|
||||
if not line:
|
||||
continue
|
||||
|
||||
try:
|
||||
event = json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
|
||||
op = str(event.get("op", "")).strip().lower()
|
||||
note_id = str(event.get("note_id", "")).strip()
|
||||
if not note_id or op not in {"create", "update", "delete"}:
|
||||
continue
|
||||
|
||||
if op == "delete":
|
||||
hydrated.pop(note_id, None)
|
||||
continue
|
||||
|
||||
note = event.get("note")
|
||||
if not isinstance(note, dict):
|
||||
continue
|
||||
|
||||
existing = hydrated.get(note_id, {})
|
||||
existing.update(note)
|
||||
hydrated[note_id] = existing
|
||||
|
||||
return hydrated
|
||||
|
||||
|
||||
def _ensure_notes_loaded() -> None:
|
||||
global _loaded_notes_run_dir # noqa: PLW0603
|
||||
|
||||
run_dir = _get_run_dir()
|
||||
run_dir_key = str(run_dir.resolve()) if run_dir else "__no_run_dir__"
|
||||
if _loaded_notes_run_dir == run_dir_key:
|
||||
return
|
||||
|
||||
_notes_storage.clear()
|
||||
|
||||
notes_path = _get_notes_jsonl_path()
|
||||
if notes_path:
|
||||
_notes_storage.update(_load_notes_from_jsonl(notes_path))
|
||||
try:
|
||||
for note_id, note in _notes_storage.items():
|
||||
if note.get("category") == "wiki":
|
||||
_persist_wiki_note(note_id, note)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
_loaded_notes_run_dir = run_dir_key
|
||||
|
||||
|
||||
def _sanitize_wiki_title(title: str) -> str:
|
||||
cleaned = "".join(ch.lower() if ch.isalnum() else "-" for ch in title.strip())
|
||||
slug = "-".join(part for part in cleaned.split("-") if part)
|
||||
return slug or "wiki-note"
|
||||
|
||||
|
||||
def _get_wiki_directory() -> Path | None:
|
||||
try:
|
||||
run_dir = _get_run_dir()
|
||||
if not run_dir:
|
||||
return None
|
||||
|
||||
wiki_dir = run_dir / "wiki"
|
||||
wiki_dir.mkdir(parents=True, exist_ok=True)
|
||||
except OSError:
|
||||
return None
|
||||
else:
|
||||
return wiki_dir
|
||||
|
||||
|
||||
def _get_wiki_note_path(note_id: str, note: dict[str, Any]) -> Path | None:
|
||||
wiki_dir = _get_wiki_directory()
|
||||
if not wiki_dir:
|
||||
return None
|
||||
|
||||
wiki_filename = note.get("wiki_filename")
|
||||
if not isinstance(wiki_filename, str) or not wiki_filename.strip():
|
||||
title = note.get("title", "wiki-note")
|
||||
wiki_filename = f"{note_id}-{_sanitize_wiki_title(str(title))}.md"
|
||||
note["wiki_filename"] = wiki_filename
|
||||
|
||||
return wiki_dir / wiki_filename
|
||||
|
||||
|
||||
def _persist_wiki_note(note_id: str, note: dict[str, Any]) -> None:
|
||||
wiki_path = _get_wiki_note_path(note_id, note)
|
||||
if not wiki_path:
|
||||
return
|
||||
|
||||
tags = note.get("tags", [])
|
||||
tags_line = ", ".join(str(tag) for tag in tags) if isinstance(tags, list) and tags else "none"
|
||||
|
||||
content = (
|
||||
f"# {note.get('title', 'Wiki Note')}\n\n"
|
||||
f"**Note ID:** {note_id}\n"
|
||||
f"**Created:** {note.get('created_at', '')}\n"
|
||||
f"**Updated:** {note.get('updated_at', '')}\n"
|
||||
f"**Tags:** {tags_line}\n\n"
|
||||
"## Content\n\n"
|
||||
f"{note.get('content', '')}\n"
|
||||
)
|
||||
wiki_path.write_text(content, encoding="utf-8")
|
||||
|
||||
|
||||
def _remove_wiki_note(note_id: str, note: dict[str, Any]) -> None:
|
||||
wiki_path = _get_wiki_note_path(note_id, note)
|
||||
if not wiki_path:
|
||||
return
|
||||
|
||||
if wiki_path.exists():
|
||||
wiki_path.unlink()
|
||||
|
||||
|
||||
def _filter_notes(
|
||||
category: str | None = None,
|
||||
tags: list[str] | None = None,
|
||||
search_query: str | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
_ensure_notes_loaded()
|
||||
filtered_notes = []
|
||||
|
||||
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
|
||||
|
||||
note_with_id = note.copy()
|
||||
note_with_id["note_id"] = note_id
|
||||
filtered_notes.append(note_with_id)
|
||||
|
||||
filtered_notes.sort(key=lambda x: x.get("created_at", ""), reverse=True)
|
||||
return filtered_notes
|
||||
|
||||
|
||||
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", ""),
|
||||
}
|
||||
|
||||
wiki_filename = note.get("wiki_filename")
|
||||
if isinstance(wiki_filename, str) and wiki_filename:
|
||||
entry["wiki_filename"] = wiki_filename
|
||||
|
||||
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
|
||||
|
||||
|
||||
@register_tool(sandbox_execution=False)
|
||||
def create_note( # noqa: PLR0911
|
||||
title: str,
|
||||
content: str,
|
||||
category: str = "general",
|
||||
tags: list[str] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
with _notes_lock:
|
||||
try:
|
||||
_ensure_notes_loaded()
|
||||
|
||||
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 = ""
|
||||
for _ in range(20):
|
||||
candidate = str(uuid.uuid4())[:5]
|
||||
if candidate not in _notes_storage:
|
||||
note_id = candidate
|
||||
break
|
||||
if not note_id:
|
||||
return {"success": False, "error": "Failed to allocate note ID", "note_id": None}
|
||||
|
||||
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
|
||||
_append_note_event("create", note_id, note)
|
||||
if category == "wiki":
|
||||
_persist_wiki_note(note_id, note)
|
||||
|
||||
except (ValueError, TypeError) as e:
|
||||
return {"success": False, "error": f"Failed to create note: {e}", "note_id": None}
|
||||
except OSError as e:
|
||||
return {"success": False, "error": f"Failed to persist wiki note: {e}", "note_id": None}
|
||||
else:
|
||||
return {
|
||||
"success": True,
|
||||
"note_id": note_id,
|
||||
"message": f"Note '{title}' created successfully",
|
||||
}
|
||||
|
||||
|
||||
@register_tool(sandbox_execution=False)
|
||||
def list_notes(
|
||||
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_notes = _filter_notes(category=category, tags=tags, search_query=search)
|
||||
notes = [
|
||||
_to_note_listing_entry(note, include_content=include_content)
|
||||
for note in filtered_notes
|
||||
]
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"notes": notes,
|
||||
"total_count": len(notes),
|
||||
}
|
||||
|
||||
except (ValueError, TypeError) as e:
|
||||
return {
|
||||
"success": False,
|
||||
"error": f"Failed to list notes: {e}",
|
||||
"notes": [],
|
||||
"total_count": 0,
|
||||
}
|
||||
|
||||
|
||||
@register_tool(sandbox_execution=False)
|
||||
def get_note(note_id: str) -> dict[str, Any]:
|
||||
with _notes_lock:
|
||||
try:
|
||||
_ensure_notes_loaded()
|
||||
|
||||
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 append_note_content(note_id: str, delta: str) -> dict[str, Any]:
|
||||
with _notes_lock:
|
||||
try:
|
||||
_ensure_notes_loaded()
|
||||
|
||||
if note_id not in _notes_storage:
|
||||
return {"success": False, "error": f"Note with ID '{note_id}' not found"}
|
||||
|
||||
note = _notes_storage[note_id]
|
||||
existing_content = str(note.get("content") or "")
|
||||
updated_content = f"{existing_content.rstrip()}{delta}"
|
||||
result: dict[str, Any] = update_note(
|
||||
note_id=note_id,
|
||||
content=updated_content,
|
||||
)
|
||||
except (ValueError, TypeError) as e:
|
||||
return {"success": False, "error": f"Failed to append note content: {e}"}
|
||||
else:
|
||||
return result
|
||||
|
||||
|
||||
@register_tool(sandbox_execution=False)
|
||||
def update_note(
|
||||
note_id: str,
|
||||
title: str | None = None,
|
||||
content: str | None = None,
|
||||
tags: list[str] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
with _notes_lock:
|
||||
try:
|
||||
_ensure_notes_loaded()
|
||||
|
||||
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()
|
||||
_append_note_event("update", note_id, note)
|
||||
if note.get("category") == "wiki":
|
||||
_persist_wiki_note(note_id, note)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"Note '{note['title']}' updated successfully",
|
||||
}
|
||||
|
||||
except (ValueError, TypeError) as e:
|
||||
return {"success": False, "error": f"Failed to update note: {e}"}
|
||||
except OSError as e:
|
||||
return {"success": False, "error": f"Failed to persist wiki note: {e}"}
|
||||
|
||||
|
||||
@register_tool(sandbox_execution=False)
|
||||
def delete_note(note_id: str) -> dict[str, Any]:
|
||||
with _notes_lock:
|
||||
try:
|
||||
_ensure_notes_loaded()
|
||||
|
||||
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"]
|
||||
if note.get("category") == "wiki":
|
||||
_remove_wiki_note(note_id, note)
|
||||
del _notes_storage[note_id]
|
||||
_append_note_event("delete", note_id)
|
||||
|
||||
except (ValueError, TypeError) as e:
|
||||
return {"success": False, "error": f"Failed to delete note: {e}"}
|
||||
except OSError as e:
|
||||
return {"success": False, "error": f"Failed to delete wiki note: {e}"}
|
||||
else:
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"Note '{note_title}' deleted successfully",
|
||||
}
|
||||
@@ -1,180 +0,0 @@
|
||||
<tools>
|
||||
<tool name="create_note">
|
||||
<description>Create a personal note for observations, findings, and research during the scan.</description>
|
||||
<details>Use this tool for documenting discoveries, observations, methodology notes, and questions.
|
||||
This is your personal and shared run memory for recording information you want to remember or reference later.
|
||||
Use category "wiki" for repository source maps shared across agents in the same run.
|
||||
For tracking actionable tasks, use the todo tool instead.</details>
|
||||
<parameters>
|
||||
<parameter name="title" type="string" required="true">
|
||||
<description>Title of the note</description>
|
||||
</parameter>
|
||||
<parameter name="content" type="string" required="true">
|
||||
<description>Content of the note</description>
|
||||
</parameter>
|
||||
<parameter name="category" type="string" required="false">
|
||||
<description>Category to organize the note (default: "general", "findings", "methodology", "questions", "plan", "wiki")</description>
|
||||
</parameter>
|
||||
<parameter name="tags" type="string" required="false">
|
||||
<description>Tags for categorization</description>
|
||||
</parameter>
|
||||
</parameters>
|
||||
<returns type="Dict[str, Any]">
|
||||
<description>Response containing: - note_id: ID of the created note - success: Whether the note was created successfully</description>
|
||||
</returns>
|
||||
<examples>
|
||||
# Document an interesting finding
|
||||
<function=create_note>
|
||||
<parameter=title>Authentication Bypass Findings</parameter>
|
||||
<parameter=content>Discovered multiple authentication bypass vectors in the login system:
|
||||
|
||||
1. SQL Injection in username field
|
||||
- Payload: admin'--
|
||||
- Result: Full authentication bypass
|
||||
- Endpoint: POST /api/v1/auth/login
|
||||
|
||||
2. JWT Token Weakness
|
||||
- Algorithm confusion attack possible (RS256 -> HS256)
|
||||
- Token expiration is 24 hours but no refresh rotation
|
||||
- Token stored in localStorage (XSS risk)
|
||||
|
||||
3. Password Reset Flow
|
||||
- Reset tokens are only 6 digits (brute-forceable)
|
||||
- No rate limiting on reset attempts
|
||||
- Token valid for 48 hours
|
||||
|
||||
Next Steps:
|
||||
- Extract full database via SQL injection
|
||||
- Test JWT manipulation attacks
|
||||
- Attempt password reset brute force</parameter>
|
||||
<parameter=category>findings</parameter>
|
||||
<parameter=tags>["auth", "sqli", "jwt", "critical"]</parameter>
|
||||
</function>
|
||||
|
||||
# Methodology note
|
||||
<function=create_note>
|
||||
<parameter=title>API Endpoint Mapping Complete</parameter>
|
||||
<parameter=content>Completed comprehensive API enumeration using multiple techniques:
|
||||
|
||||
Discovered Endpoints:
|
||||
- /api/v1/auth/* - Authentication endpoints (login, register, reset)
|
||||
- /api/v1/users/* - User management (profile, settings, admin)
|
||||
- /api/v1/orders/* - Order management (IDOR vulnerability confirmed)
|
||||
- /api/v1/admin/* - Admin panel (403 but may be bypassable)
|
||||
- /api/internal/* - Internal APIs (should not be exposed)
|
||||
|
||||
Methods Used:
|
||||
- Analyzed JavaScript bundles for API calls
|
||||
- Bruteforced common paths with ffuf
|
||||
- Reviewed OpenAPI/Swagger documentation at /api/docs
|
||||
- Monitored traffic during normal application usage
|
||||
|
||||
Priority Targets:
|
||||
The /api/internal/* endpoints are high priority as they appear to lack authentication checks based on error message differences.</parameter>
|
||||
<parameter=category>methodology</parameter>
|
||||
<parameter=tags>["api", "enumeration", "recon"]</parameter>
|
||||
</function>
|
||||
</examples>
|
||||
</tool>
|
||||
<tool name="delete_note">
|
||||
<description>Delete a note.</description>
|
||||
<parameters>
|
||||
<parameter name="note_id" type="string" required="true">
|
||||
<description>ID of the note to delete</description>
|
||||
</parameter>
|
||||
</parameters>
|
||||
<returns type="Dict[str, Any]">
|
||||
<description>Response containing: - success: Whether the note was deleted successfully</description>
|
||||
</returns>
|
||||
<examples>
|
||||
<function=delete_note>
|
||||
<parameter=note_id>note_123</parameter>
|
||||
</function>
|
||||
</examples>
|
||||
</tool>
|
||||
<tool name="list_notes">
|
||||
<description>List existing notes with optional filtering and search (metadata-first by default).</description>
|
||||
<parameters>
|
||||
<parameter name="category" type="string" required="false">
|
||||
<description>Filter by category</description>
|
||||
</parameter>
|
||||
<parameter name="tags" type="string" required="false">
|
||||
<description>Filter by tags (returns notes with any of these tags)</description>
|
||||
</parameter>
|
||||
<parameter name="search" type="string" required="false">
|
||||
<description>Search query to find in note titles and content</description>
|
||||
</parameter>
|
||||
<parameter name="include_content" type="boolean" required="false">
|
||||
<description>Include full note content in each list item (default: false)</description>
|
||||
</parameter>
|
||||
</parameters>
|
||||
<returns type="Dict[str, Any]">
|
||||
<description>Response containing: - notes: List of matching notes (metadata + optional content/content_preview) - total_count: Total number of notes found</description>
|
||||
</returns>
|
||||
<examples>
|
||||
# List all findings
|
||||
<function=list_notes>
|
||||
<parameter=category>findings</parameter>
|
||||
</function>
|
||||
|
||||
# Search for SQL injection related notes
|
||||
<function=list_notes>
|
||||
<parameter=search>SQL injection</parameter>
|
||||
</function>
|
||||
|
||||
# Search within a specific category
|
||||
<function=list_notes>
|
||||
<parameter=search>admin</parameter>
|
||||
<parameter=category>findings</parameter>
|
||||
</function>
|
||||
|
||||
# Load shared repository wiki notes
|
||||
<function=list_notes>
|
||||
<parameter=category>wiki</parameter>
|
||||
</function>
|
||||
</examples>
|
||||
</tool>
|
||||
<tool name="get_note">
|
||||
<description>Get a single note by ID, including full content.</description>
|
||||
<parameters>
|
||||
<parameter name="note_id" type="string" required="true">
|
||||
<description>ID of the note to fetch</description>
|
||||
</parameter>
|
||||
</parameters>
|
||||
<returns type="Dict[str, Any]">
|
||||
<description>Response containing: - note: Note object including content - success: Whether note lookup succeeded</description>
|
||||
</returns>
|
||||
<examples>
|
||||
# Read a specific wiki note after listing note IDs
|
||||
<function=get_note>
|
||||
<parameter=note_id>abc12</parameter>
|
||||
</function>
|
||||
</examples>
|
||||
</tool>
|
||||
<tool name="update_note">
|
||||
<description>Update an existing note.</description>
|
||||
<parameters>
|
||||
<parameter name="note_id" type="string" required="true">
|
||||
<description>ID of the note to update</description>
|
||||
</parameter>
|
||||
<parameter name="title" type="string" required="false">
|
||||
<description>New title for the note</description>
|
||||
</parameter>
|
||||
<parameter name="content" type="string" required="false">
|
||||
<description>New content for the note</description>
|
||||
</parameter>
|
||||
<parameter name="tags" type="string" required="false">
|
||||
<description>New tags for the note</description>
|
||||
</parameter>
|
||||
</parameters>
|
||||
<returns type="Dict[str, Any]">
|
||||
<description>Response containing: - success: Whether the note was updated successfully</description>
|
||||
</returns>
|
||||
<examples>
|
||||
<function=update_note>
|
||||
<parameter=note_id>note_123</parameter>
|
||||
<parameter=content>Updated content with new findings...</parameter>
|
||||
</function>
|
||||
</examples>
|
||||
</tool>
|
||||
</tools>
|
||||
+431
-55
@@ -1,29 +1,422 @@
|
||||
"""SDK function-tool wrappers for the legacy notes tools.
|
||||
"""Per-run notes (shared across agents).
|
||||
|
||||
Five tools, all module-global (no per-agent silo). The legacy
|
||||
``notes_actions.py`` module already implements JSONL persistence and
|
||||
wiki Markdown rendering; these wrappers are pure delegation.
|
||||
|
||||
The C6 fix (lock-protected JSONL writes) was applied directly to the
|
||||
legacy module, so both code paths benefit.
|
||||
Persisted to ``run_dir/notes/notes.jsonl`` (replayable event log) and,
|
||||
for the ``wiki`` category, also rendered as Markdown to
|
||||
``run_dir/wiki/<slug>.md``. Concurrent appends are serialised by a
|
||||
threading.RLock so two agents writing simultaneously can't corrupt
|
||||
the JSONL.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from typing import Any
|
||||
import logging
|
||||
import threading
|
||||
import uuid
|
||||
from datetime import UTC, datetime
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
from agents import RunContextWrapper
|
||||
|
||||
from strix.tools._decorator import strix_tool
|
||||
from strix.tools.notes import notes_actions as _impl
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
_notes_storage: dict[str, dict[str, Any]] = {}
|
||||
_VALID_NOTE_CATEGORIES = ["general", "findings", "methodology", "questions", "plan", "wiki"]
|
||||
_notes_lock = threading.RLock()
|
||||
_loaded_notes_run_dir: str | None = None
|
||||
_DEFAULT_CONTENT_PREVIEW_CHARS = 280
|
||||
|
||||
|
||||
def _dump(result: dict[str, Any]) -> str:
|
||||
return json.dumps(result, ensure_ascii=False, default=str)
|
||||
|
||||
|
||||
def _get_run_dir() -> Path | None:
|
||||
try:
|
||||
from strix.telemetry.tracer import get_global_tracer
|
||||
|
||||
tracer = get_global_tracer()
|
||||
if not tracer:
|
||||
return None
|
||||
return tracer.get_run_dir()
|
||||
except (ImportError, OSError, RuntimeError):
|
||||
return None
|
||||
|
||||
|
||||
def _get_notes_jsonl_path() -> Path | None:
|
||||
run_dir = _get_run_dir()
|
||||
if not run_dir:
|
||||
return None
|
||||
notes_dir = run_dir / "notes"
|
||||
notes_dir.mkdir(parents=True, exist_ok=True)
|
||||
return notes_dir / "notes.jsonl"
|
||||
|
||||
|
||||
def _append_note_event(op: str, note_id: str, note: dict[str, Any] | None = None) -> None:
|
||||
"""Append one note operation to the run's ``notes/notes.jsonl``.
|
||||
|
||||
C6: hold ``_notes_lock`` across the file open + write so two
|
||||
concurrent agents can't interleave bytes mid-line.
|
||||
"""
|
||||
notes_path = _get_notes_jsonl_path()
|
||||
if not notes_path:
|
||||
return
|
||||
event: dict[str, Any] = {
|
||||
"timestamp": datetime.now(UTC).isoformat(),
|
||||
"op": op,
|
||||
"note_id": note_id,
|
||||
}
|
||||
if note is not None:
|
||||
event["note"] = note
|
||||
with _notes_lock, notes_path.open("a", encoding="utf-8") as f:
|
||||
f.write(f"{json.dumps(event, ensure_ascii=True)}\n")
|
||||
|
||||
|
||||
def _load_notes_from_jsonl(notes_path: Path) -> dict[str, dict[str, Any]]:
|
||||
hydrated: dict[str, dict[str, Any]] = {}
|
||||
if not notes_path.exists():
|
||||
return hydrated
|
||||
with notes_path.open(encoding="utf-8") as f:
|
||||
for raw_line in f:
|
||||
line = raw_line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
event = json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
op = str(event.get("op", "")).strip().lower()
|
||||
note_id = str(event.get("note_id", "")).strip()
|
||||
if not note_id or op not in {"create", "update", "delete"}:
|
||||
continue
|
||||
if op == "delete":
|
||||
hydrated.pop(note_id, None)
|
||||
continue
|
||||
note = event.get("note")
|
||||
if not isinstance(note, dict):
|
||||
continue
|
||||
existing = hydrated.get(note_id, {})
|
||||
existing.update(note)
|
||||
hydrated[note_id] = existing
|
||||
return hydrated
|
||||
|
||||
|
||||
def _ensure_notes_loaded() -> None:
|
||||
global _loaded_notes_run_dir # noqa: PLW0603
|
||||
run_dir = _get_run_dir()
|
||||
run_dir_key = str(run_dir.resolve()) if run_dir else "__no_run_dir__"
|
||||
if _loaded_notes_run_dir == run_dir_key:
|
||||
return
|
||||
_notes_storage.clear()
|
||||
notes_path = _get_notes_jsonl_path()
|
||||
if notes_path:
|
||||
_notes_storage.update(_load_notes_from_jsonl(notes_path))
|
||||
try:
|
||||
for note_id, note in _notes_storage.items():
|
||||
if note.get("category") == "wiki":
|
||||
_persist_wiki_note(note_id, note)
|
||||
except OSError:
|
||||
pass
|
||||
_loaded_notes_run_dir = run_dir_key
|
||||
|
||||
|
||||
def _sanitize_wiki_title(title: str) -> str:
|
||||
cleaned = "".join(ch.lower() if ch.isalnum() else "-" for ch in title.strip())
|
||||
slug = "-".join(part for part in cleaned.split("-") if part)
|
||||
return slug or "wiki-note"
|
||||
|
||||
|
||||
def _get_wiki_directory() -> Path | None:
|
||||
try:
|
||||
run_dir = _get_run_dir()
|
||||
if not run_dir:
|
||||
return None
|
||||
wiki_dir = run_dir / "wiki"
|
||||
wiki_dir.mkdir(parents=True, exist_ok=True)
|
||||
except OSError:
|
||||
return None
|
||||
else:
|
||||
return wiki_dir
|
||||
|
||||
|
||||
def _get_wiki_note_path(note_id: str, note: dict[str, Any]) -> Path | None:
|
||||
wiki_dir = _get_wiki_directory()
|
||||
if not wiki_dir:
|
||||
return None
|
||||
wiki_filename = note.get("wiki_filename")
|
||||
if not isinstance(wiki_filename, str) or not wiki_filename.strip():
|
||||
title = note.get("title", "wiki-note")
|
||||
wiki_filename = f"{note_id}-{_sanitize_wiki_title(str(title))}.md"
|
||||
note["wiki_filename"] = wiki_filename
|
||||
return wiki_dir / wiki_filename
|
||||
|
||||
|
||||
def _persist_wiki_note(note_id: str, note: dict[str, Any]) -> None:
|
||||
wiki_path = _get_wiki_note_path(note_id, note)
|
||||
if not wiki_path:
|
||||
return
|
||||
tags = note.get("tags", [])
|
||||
tags_line = ", ".join(str(tag) for tag in tags) if isinstance(tags, list) and tags else "none"
|
||||
content = (
|
||||
f"# {note.get('title', 'Wiki Note')}\n\n"
|
||||
f"**Note ID:** {note_id}\n"
|
||||
f"**Created:** {note.get('created_at', '')}\n"
|
||||
f"**Updated:** {note.get('updated_at', '')}\n"
|
||||
f"**Tags:** {tags_line}\n\n"
|
||||
"## Content\n\n"
|
||||
f"{note.get('content', '')}\n"
|
||||
)
|
||||
wiki_path.write_text(content, encoding="utf-8")
|
||||
|
||||
|
||||
def _remove_wiki_note(note_id: str, note: dict[str, Any]) -> None:
|
||||
wiki_path = _get_wiki_note_path(note_id, note)
|
||||
if not wiki_path:
|
||||
return
|
||||
if wiki_path.exists():
|
||||
wiki_path.unlink()
|
||||
|
||||
|
||||
def _filter_notes(
|
||||
category: str | None = None,
|
||||
tags: list[str] | None = None,
|
||||
search_query: str | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
_ensure_notes_loaded()
|
||||
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", ""),
|
||||
}
|
||||
wiki_filename = note.get("wiki_filename")
|
||||
if isinstance(wiki_filename, str) and wiki_filename:
|
||||
entry["wiki_filename"] = wiki_filename
|
||||
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( # noqa: PLR0911
|
||||
title: str,
|
||||
content: str,
|
||||
category: str = "general",
|
||||
tags: list[str] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Create one note. Public — used by ``append_note_content`` and tests."""
|
||||
with _notes_lock:
|
||||
try:
|
||||
_ensure_notes_loaded()
|
||||
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 = ""
|
||||
for _ in range(20):
|
||||
candidate = str(uuid.uuid4())[:5]
|
||||
if candidate not in _notes_storage:
|
||||
note_id = candidate
|
||||
break
|
||||
if not note_id:
|
||||
return {"success": False, "error": "Failed to allocate note ID", "note_id": None}
|
||||
|
||||
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
|
||||
_append_note_event("create", note_id, note)
|
||||
if category == "wiki":
|
||||
_persist_wiki_note(note_id, note)
|
||||
except (ValueError, TypeError) as e:
|
||||
return {"success": False, "error": f"Failed to create note: {e}", "note_id": None}
|
||||
except OSError as e:
|
||||
return {"success": False, "error": f"Failed to persist wiki note: {e}", "note_id": None}
|
||||
else:
|
||||
return {
|
||||
"success": True,
|
||||
"note_id": note_id,
|
||||
"message": f"Note '{title}' created successfully",
|
||||
}
|
||||
|
||||
|
||||
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": [],
|
||||
"total_count": 0,
|
||||
}
|
||||
return {"success": True, "notes": notes, "total_count": len(notes)}
|
||||
|
||||
|
||||
def _get_note_impl(note_id: str) -> dict[str, Any]:
|
||||
with _notes_lock:
|
||||
try:
|
||||
_ensure_notes_loaded()
|
||||
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:
|
||||
_ensure_notes_loaded()
|
||||
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()
|
||||
_append_note_event("update", note_id, note)
|
||||
if note.get("category") == "wiki":
|
||||
_persist_wiki_note(note_id, note)
|
||||
except (ValueError, TypeError) as e:
|
||||
return {"success": False, "error": f"Failed to update note: {e}"}
|
||||
except OSError as e:
|
||||
return {"success": False, "error": f"Failed to persist wiki note: {e}"}
|
||||
else:
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"Note '{note['title']}' updated successfully",
|
||||
}
|
||||
|
||||
|
||||
def _delete_note_impl(note_id: str) -> dict[str, Any]:
|
||||
with _notes_lock:
|
||||
try:
|
||||
_ensure_notes_loaded()
|
||||
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"]
|
||||
if note.get("category") == "wiki":
|
||||
_remove_wiki_note(note_id, note)
|
||||
del _notes_storage[note_id]
|
||||
_append_note_event("delete", note_id)
|
||||
except (ValueError, TypeError) as e:
|
||||
return {"success": False, "error": f"Failed to delete note: {e}"}
|
||||
except OSError as e:
|
||||
return {"success": False, "error": f"Failed to delete wiki note: {e}"}
|
||||
else:
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"Note '{note_title}' deleted successfully",
|
||||
}
|
||||
|
||||
|
||||
def append_note_content(note_id: str, delta: str) -> dict[str, Any]:
|
||||
"""Append text to an existing note's content. Used by the agents-graph
|
||||
wiki-update hook on agent_finish."""
|
||||
with _notes_lock:
|
||||
try:
|
||||
_ensure_notes_loaded()
|
||||
if note_id not in _notes_storage:
|
||||
return {"success": False, "error": f"Note with ID '{note_id}' not found"}
|
||||
note = _notes_storage[note_id]
|
||||
existing = str(note.get("content") or "")
|
||||
updated = f"{existing.rstrip()}{delta}"
|
||||
return _update_note_impl(note_id=note_id, content=updated)
|
||||
except (ValueError, TypeError) as e:
|
||||
return {"success": False, "error": f"Failed to append note content: {e}"}
|
||||
|
||||
|
||||
# --- public tools ---------------------------------------------------------
|
||||
|
||||
|
||||
@strix_tool(timeout=30)
|
||||
async def create_note(
|
||||
ctx: RunContextWrapper,
|
||||
@@ -35,26 +428,13 @@ async def create_note(
|
||||
"""Create a note in the current run's notes store.
|
||||
|
||||
Notes are persisted to ``run_dir/notes/notes.jsonl`` and (for the
|
||||
``wiki`` category) rendered as Markdown to ``run_dir/wiki/<slug>.md``.
|
||||
|
||||
Args:
|
||||
title: Required, non-empty title.
|
||||
content: Note body. Markdown is preserved.
|
||||
category: One of ``"general" | "findings" | "methodology" |
|
||||
"questions" | "plan" | "wiki"``.
|
||||
tags: Optional list of free-form tags.
|
||||
``wiki`` category) rendered as Markdown to
|
||||
``run_dir/wiki/<slug>.md``.
|
||||
"""
|
||||
# The legacy function does file I/O under a threading.RLock.
|
||||
# Wrap in to_thread so we don't block the event loop while waiting
|
||||
# on the lock or fsync.
|
||||
result = await asyncio.to_thread(
|
||||
_impl.create_note,
|
||||
title=title,
|
||||
content=content,
|
||||
category=category,
|
||||
tags=tags,
|
||||
del ctx
|
||||
return _dump(
|
||||
await asyncio.to_thread(_create_note_impl, title, content, category, tags),
|
||||
)
|
||||
return _dump(result)
|
||||
|
||||
|
||||
@strix_tool(timeout=30)
|
||||
@@ -65,30 +445,24 @@ async def list_notes(
|
||||
search: str | None = None,
|
||||
include_content: bool = False,
|
||||
) -> str:
|
||||
"""List notes, optionally filtered.
|
||||
|
||||
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 get a ``content_preview``;
|
||||
when True, full content is included.
|
||||
"""
|
||||
result = await asyncio.to_thread(
|
||||
_impl.list_notes,
|
||||
category=category,
|
||||
tags=tags,
|
||||
search=search,
|
||||
include_content=include_content,
|
||||
"""List notes, optionally filtered by category / tags / substring."""
|
||||
del ctx
|
||||
return _dump(
|
||||
await asyncio.to_thread(
|
||||
_list_notes_impl,
|
||||
category=category,
|
||||
tags=tags,
|
||||
search=search,
|
||||
include_content=include_content,
|
||||
),
|
||||
)
|
||||
return _dump(result)
|
||||
|
||||
|
||||
@strix_tool(timeout=30)
|
||||
async def get_note(ctx: RunContextWrapper, note_id: str) -> str:
|
||||
"""Fetch one note by its 5-char ID. Returns full content."""
|
||||
result = await asyncio.to_thread(_impl.get_note, note_id=note_id)
|
||||
return _dump(result)
|
||||
del ctx
|
||||
return _dump(await asyncio.to_thread(_get_note_impl, note_id))
|
||||
|
||||
|
||||
@strix_tool(timeout=30)
|
||||
@@ -99,19 +473,21 @@ async def update_note(
|
||||
content: str | None = None,
|
||||
tags: list[str] | None = None,
|
||||
) -> str:
|
||||
"""Update a note's title, content, or tags. Pass ``None`` to leave a field unchanged."""
|
||||
result = await asyncio.to_thread(
|
||||
_impl.update_note,
|
||||
note_id=note_id,
|
||||
title=title,
|
||||
content=content,
|
||||
tags=tags,
|
||||
"""Update a note's title, content, or tags."""
|
||||
del ctx
|
||||
return _dump(
|
||||
await asyncio.to_thread(
|
||||
_update_note_impl,
|
||||
note_id=note_id,
|
||||
title=title,
|
||||
content=content,
|
||||
tags=tags,
|
||||
),
|
||||
)
|
||||
return _dump(result)
|
||||
|
||||
|
||||
@strix_tool(timeout=30)
|
||||
async def delete_note(ctx: RunContextWrapper, note_id: str) -> str:
|
||||
"""Delete a note. For wiki notes, also removes the rendered Markdown file."""
|
||||
result = await asyncio.to_thread(_impl.delete_note, note_id=note_id)
|
||||
return _dump(result)
|
||||
del ctx
|
||||
return _dump(await asyncio.to_thread(_delete_note_impl, note_id))
|
||||
|
||||
@@ -1,266 +0,0 @@
|
||||
<tools>
|
||||
<tool name="list_requests">
|
||||
<description>List and filter proxy requests using HTTPQL with pagination.</description>
|
||||
<parameters>
|
||||
<parameter name="httpql_filter" type="string" required="false">
|
||||
<description>HTTPQL filter using Caido's syntax:
|
||||
|
||||
Integer fields (port, code, roundtrip, id) - eq, gt, gte, lt, lte, ne:
|
||||
- resp.code.eq:200, resp.code.gte:400, req.port.eq:443
|
||||
|
||||
Text/byte fields (ext, host, method, path, query, raw) - regex:
|
||||
- req.method.regex:"POST", req.path.regex:"/api/.*", req.host.regex:".*.com"
|
||||
|
||||
Date fields (created_at) - gt, lt with ISO formats:
|
||||
- req.created_at.gt:"2024-01-01T00:00:00Z"
|
||||
|
||||
Special: source:intercept, preset:"name"</description>
|
||||
</parameter>
|
||||
<parameter name="start_page" type="integer" required="false">
|
||||
<description>Starting page (1-based)</description>
|
||||
</parameter>
|
||||
<parameter name="end_page" type="integer" required="false">
|
||||
<description>Ending page (1-based, inclusive)</description>
|
||||
</parameter>
|
||||
<parameter name="page_size" type="integer" required="false">
|
||||
<description>Requests per page</description>
|
||||
</parameter>
|
||||
<parameter name="sort_by" type="string" required="false">
|
||||
<description>Sort field from: "timestamp", "host", "status_code", "response_time", "response_size"</description>
|
||||
</parameter>
|
||||
<parameter name="sort_order" type="string" required="false">
|
||||
<description>Sort direction ("asc" or "desc")</description>
|
||||
</parameter>
|
||||
<parameter name="scope_id" type="string" required="false">
|
||||
<description>Scope ID to filter requests (use scope_rules to manage scopes)</description>
|
||||
</parameter>
|
||||
</parameters>
|
||||
<returns type="Dict[str, Any]">
|
||||
<description>Response containing:
|
||||
- 'requests': Request objects for page range
|
||||
- 'total_count': Total matching requests
|
||||
- 'start_page', 'end_page', 'page_size': Query parameters
|
||||
- 'returned_count': Requests in response</description>
|
||||
</returns>
|
||||
<examples>
|
||||
# POST requests to API with 200 responses
|
||||
<function=list_requests>
|
||||
<parameter=httpql_filter>req.method.eq:"POST" AND req.path.cont:"/api/"</parameter>
|
||||
<parameter=sort_by>response_time</parameter>
|
||||
<parameter=scope_id>scope123</parameter>
|
||||
</function>
|
||||
|
||||
# Requests within specific scope
|
||||
<function=list_requests>
|
||||
<parameter=scope_id>scope123</parameter>
|
||||
<parameter=sort_by>timestamp</parameter>
|
||||
</function>
|
||||
</examples>
|
||||
</tool>
|
||||
|
||||
<tool name="view_request">
|
||||
<description>View request/response data with search and pagination.</description>
|
||||
<parameters>
|
||||
<parameter name="request_id" type="string" required="true">
|
||||
<description>Request ID</description>
|
||||
</parameter>
|
||||
<parameter name="part" type="string" required="false">
|
||||
<description>Which part to return ("request" or "response")</description>
|
||||
</parameter>
|
||||
<parameter name="search_pattern" type="string" required="false">
|
||||
<description>Regex pattern to search content. Common patterns:
|
||||
- API endpoints: r"/api/[a-zA-Z0-9._/-]+"
|
||||
- URLs: r"https?://[^\\s<>"\']+"
|
||||
- Parameters: r'[?&][a-zA-Z0-9_]+=([^&\\s<>"\']+)'
|
||||
- Reflections: input_value in content</description>
|
||||
</parameter>
|
||||
<parameter name="page" type="integer" required="false">
|
||||
<description>Page number for pagination</description>
|
||||
</parameter>
|
||||
<parameter name="page_size" type="integer" required="false">
|
||||
<description>Lines per page</description>
|
||||
</parameter>
|
||||
</parameters>
|
||||
<returns type="Dict[str, Any]">
|
||||
<description>With search_pattern (COMPACT):
|
||||
- 'matches': [{match, before, after, position}] - max 20
|
||||
- 'total_matches': Total found
|
||||
- 'truncated': If limited to 20
|
||||
|
||||
Without search_pattern (PAGINATION):
|
||||
- 'content': Page content
|
||||
- 'page': Current page
|
||||
- 'showing_lines': Range display
|
||||
- 'has_more': More pages available</description>
|
||||
</returns>
|
||||
<examples>
|
||||
# Find API endpoints in response
|
||||
<function=view_request>
|
||||
<parameter=request_id>123</parameter>
|
||||
<parameter=part>response</parameter>
|
||||
<parameter=search_pattern>/api/[a-zA-Z0-9._/-]+</parameter>
|
||||
</function>
|
||||
</examples>
|
||||
</tool>
|
||||
|
||||
<tool name="send_request">
|
||||
<description>Send a simple HTTP request through proxy.</description>
|
||||
<parameters>
|
||||
<parameter name="method" type="string" required="true">
|
||||
<description>HTTP method (GET, POST, etc.)</description>
|
||||
</parameter>
|
||||
<parameter name="url" type="string" required="true">
|
||||
<description>Target URL</description>
|
||||
</parameter>
|
||||
<parameter name="headers" type="dict" required="false">
|
||||
<description>Headers as {"key": "value"}</description>
|
||||
</parameter>
|
||||
<parameter name="body" type="string" required="false">
|
||||
<description>Request body</description>
|
||||
</parameter>
|
||||
<parameter name="timeout" type="integer" required="false">
|
||||
<description>Request timeout</description>
|
||||
</parameter>
|
||||
</parameters>
|
||||
</tool>
|
||||
|
||||
<tool name="repeat_request">
|
||||
<description>Repeat an existing proxy request with modifications for pentesting.
|
||||
|
||||
PROPER WORKFLOW:
|
||||
1. Use browser_action to browse the target application
|
||||
2. Use list_requests() to see captured proxy traffic
|
||||
3. Use repeat_request() to modify and test specific requests
|
||||
|
||||
This mirrors real pentesting: browse → capture → modify → test</description>
|
||||
<parameters>
|
||||
<parameter name="request_id" type="string" required="true">
|
||||
<description>ID of the original request to repeat (from list_requests)</description>
|
||||
</parameter>
|
||||
<parameter name="modifications" type="dict" required="false">
|
||||
<description>Changes to apply to the original request:
|
||||
- "url": New URL or modify existing one
|
||||
- "params": Dict to update query parameters
|
||||
- "headers": Dict to add/update headers
|
||||
- "body": New request body (replaces original)
|
||||
- "cookies": Dict to add/update cookies</description>
|
||||
</parameter>
|
||||
</parameters>
|
||||
<returns type="Dict[str, Any]">
|
||||
<description>Response data with status, headers, body, timing, and request details</description>
|
||||
</returns>
|
||||
<examples>
|
||||
# Modify POST body payload
|
||||
<function=repeat_request>
|
||||
<parameter=request_id>req_789</parameter>
|
||||
<parameter=modifications>{"body": "{\"username\":\"admin\",\"password\":\"admin\"}"}</parameter>
|
||||
</function>
|
||||
</examples>
|
||||
</tool>
|
||||
|
||||
<tool name="scope_rules">
|
||||
<description>Manage proxy scope patterns for domain/file filtering using Caido's scope system.</description>
|
||||
<parameters>
|
||||
<parameter name="action" type="string" required="true">
|
||||
<description>Scope action:
|
||||
- get: Get specific scope by ID or list all if no ID
|
||||
- update: Update existing scope (requires scope_id and scope_name)
|
||||
- list: List all available scopes
|
||||
- create: Create new scope (requires scope_name)
|
||||
- delete: Delete scope (requires scope_id)</description>
|
||||
</parameter>
|
||||
<parameter name="allowlist" type="list" required="false">
|
||||
<description>Domain patterns to include. Examples: ["*.example.com", "api.test.com"]</description>
|
||||
</parameter>
|
||||
<parameter name="denylist" type="list" required="false">
|
||||
<description>Patterns to exclude. Some common extensions:
|
||||
["*.gif", "*.jpg", "*.png", "*.css", "*.js", "*.ico", "*.svg", "*woff*", "*.ttf"]</description>
|
||||
</parameter>
|
||||
<parameter name="scope_id" type="string" required="false">
|
||||
<description>Specific scope ID to operate on (required for get, update, delete)</description>
|
||||
</parameter>
|
||||
<parameter name="scope_name" type="string" required="false">
|
||||
<description>Name for scope (required for create, update)</description>
|
||||
</parameter>
|
||||
</parameters>
|
||||
<returns type="Dict[str, Any]">
|
||||
<description>Depending on action:
|
||||
- get: Single scope object or error
|
||||
- list: {"scopes": [...], "count": N}
|
||||
- create/update: {"scope": {...}, "message": "..."}
|
||||
- delete: {"message": "...", "deletedId": "..."}</description>
|
||||
</returns>
|
||||
<notes>
|
||||
- Empty allowlist = allow all domains
|
||||
- Denylist overrides allowlist
|
||||
- Glob patterns: * (any), ? (single), [abc] (one of), [a-z] (range), [^abc] (none of)
|
||||
- Each scope has unique ID and can be used with list_requests(scopeId=...)
|
||||
</notes>
|
||||
<examples>
|
||||
# Create API-only scope
|
||||
<function=scope_rules>
|
||||
<parameter=action>create</parameter>
|
||||
<parameter=scope_name>API Testing</parameter>
|
||||
<parameter=allowlist>["api.example.com", "*.api.com"]</parameter>
|
||||
<parameter=denylist>["*.gif", "*.jpg", "*.png", "*.css", "*.js"]</parameter>
|
||||
</function>
|
||||
</examples>
|
||||
</tool>
|
||||
|
||||
<tool name="list_sitemap">
|
||||
<description>View hierarchical sitemap of discovered attack surface from proxied traffic.
|
||||
|
||||
Perfect for bug hunters to understand the application structure and identify
|
||||
interesting endpoints, directories, and entry points discovered during testing.</description>
|
||||
<parameters>
|
||||
<parameter name="scope_id" type="string" required="false">
|
||||
<description>Scope ID to filter sitemap entries (use scope_rules to get/create scope IDs)</description>
|
||||
</parameter>
|
||||
<parameter name="parent_id" type="string" required="false">
|
||||
<description>ID of parent entry to expand. If None, returns root domains.</description>
|
||||
</parameter>
|
||||
<parameter name="depth" type="string" required="false">
|
||||
<description>DIRECT: Only immediate children. ALL: All descendants recursively.</description>
|
||||
</parameter>
|
||||
<parameter name="page" type="integer" required="false">
|
||||
<description>Page number for pagination (30 entries per page)</description>
|
||||
</parameter>
|
||||
</parameters>
|
||||
<returns type="Dict[str, Any]">
|
||||
<description>Response containing:
|
||||
- 'entries': List of cleaned sitemap entries
|
||||
- 'page', 'total_pages', 'total_count': Pagination info
|
||||
- 'has_more': Whether more pages available
|
||||
- Each entry: id, kind, label, hasDescendants, request (method/path/status only)</description>
|
||||
</returns>
|
||||
<notes>
|
||||
Entry kinds:
|
||||
- DOMAIN: Root domains (example.com)
|
||||
- DIRECTORY: Path directories (/api/, /admin/)
|
||||
- REQUEST: Individual endpoints
|
||||
- REQUEST_BODY: POST/PUT body variations
|
||||
- REQUEST_QUERY: GET parameter variations
|
||||
|
||||
Check hasDescendants=true to identify entries worth expanding.
|
||||
Use parent_id from any entry to drill down into subdirectories.
|
||||
</notes>
|
||||
</tool>
|
||||
|
||||
<tool name="view_sitemap_entry">
|
||||
<description>Get detailed information about a specific sitemap entry and related requests.
|
||||
|
||||
Perfect for understanding what's been discovered under a specific directory
|
||||
or endpoint, including all related requests and response codes.</description>
|
||||
<parameters>
|
||||
<parameter name="entry_id" type="string" required="true">
|
||||
<description>ID of the sitemap entry to examine</description>
|
||||
</parameter>
|
||||
</parameters>
|
||||
<returns type="Dict[str, Any]">
|
||||
<description>Response containing:
|
||||
- 'entry': Complete entry details including metadata
|
||||
- Entry contains 'requests' with all related HTTP requests
|
||||
- Shows request methods, paths, response codes, timing</description>
|
||||
</returns>
|
||||
</tool>
|
||||
</tools>
|
||||
@@ -1,144 +0,0 @@
|
||||
<tools>
|
||||
<tool name="python_action">
|
||||
<description>Perform Python actions using persistent interpreter sessions for cybersecurity tasks. This is the PREFERRED tool for Python code because it provides structured execution, persistence, cleaner output, and easier debugging than embedding Python inside terminal commands.</description>
|
||||
<details>Common Use Cases:
|
||||
- Security script development and testing (payload generation, exploit scripts)
|
||||
- Data analysis of security logs, network traffic, or vulnerability scans
|
||||
- Cryptographic operations and security tool automation
|
||||
- Interactive penetration testing workflows and proof-of-concept development
|
||||
- Processing security data formats (JSON, XML, CSV from security tools)
|
||||
- HTTP proxy interaction for web security testing (all proxy functions are pre-imported)
|
||||
|
||||
Each session instance is PERSISTENT and maintains its own global and local namespaces
|
||||
until explicitly closed, allowing for multi-step security workflows and stateful computations.
|
||||
|
||||
PROXY FUNCTIONS PRE-IMPORTED:
|
||||
All proxy action functions are automatically imported into every Python session, enabling
|
||||
seamless HTTP traffic analysis and web security testing
|
||||
|
||||
This is particularly useful for:
|
||||
- Analyzing captured HTTP traffic during web application testing
|
||||
- Automating request manipulation and replay attacks
|
||||
- Building custom security testing workflows combining proxy data with Python analysis
|
||||
- Correlating multiple requests for advanced attack scenarios</details>
|
||||
<parameters>
|
||||
<parameter name="action" type="string" required="true">
|
||||
<description>The Python action to perform: - new_session: Create a new Python interpreter session. This MUST be the first action for each session. - execute: Execute Python code in the specified session. - close: Close the specified session instance. - list_sessions: List all active Python sessions.</description>
|
||||
</parameter>
|
||||
<parameter name="code" type="string" required="false">
|
||||
<description>Required for 'new_session' (as initial code) and 'execute' actions. The Python code to execute.</description>
|
||||
</parameter>
|
||||
<parameter name="timeout" type="integer" required="false">
|
||||
<description>Maximum execution time in seconds for code execution. Applies to both 'new_session' (when initial code is provided) and 'execute' actions. Default is 30 seconds.</description>
|
||||
</parameter>
|
||||
<parameter name="session_id" type="string" required="false">
|
||||
<description>Unique identifier for the Python session. If not provided, uses the default session ID.</description>
|
||||
</parameter>
|
||||
</parameters>
|
||||
<returns type="Dict[str, Any]">
|
||||
<description>Response containing: - session_id: the ID of the session that was operated on - stdout: captured standard output from code execution (for execute action) - stderr: any error message if execution failed - result: string representation of the last expression result - execution_time: time taken to execute the code - message: status message about the action performed - Various session info depending on the action</description>
|
||||
</returns>
|
||||
<notes>
|
||||
Important usage rules:
|
||||
1. PERSISTENCE: Session instances remain active and maintain their state (variables,
|
||||
imports, function definitions) until explicitly closed with the 'close' action.
|
||||
This allows for multi-step workflows across multiple tool calls.
|
||||
2. MULTIPLE SESSIONS: You can run multiple Python sessions concurrently by using
|
||||
different session_id values. Each session operates independently with its own
|
||||
namespace.
|
||||
3. Session interaction MUST begin with 'new_session' action for each session instance.
|
||||
4. Only one action can be performed per call.
|
||||
5. CODE EXECUTION:
|
||||
- Both expressions and statements are supported
|
||||
- Expressions automatically return their result
|
||||
- Print statements and stdout are captured
|
||||
- Variables persist between executions in the same session
|
||||
- Imports, function definitions, etc. persist in the session
|
||||
- IMPORTANT (multiline): Put real line breaks in your code. Do NOT emit literal "\n" sequences — use actual newlines.
|
||||
- IPython magic commands are fully supported (%pip, %time, %whos, %%writefile, etc.)
|
||||
- Line magics (%) and cell magics (%%) work as expected
|
||||
6. CLOSE: Terminates the session completely and frees memory
|
||||
7. PREFER THIS TOOL OVER TERMINAL FOR PYTHON:
|
||||
- If you are writing or running Python code, use python_action instead of terminal_execute
|
||||
- Do NOT wrap Python in bash heredocs, here-strings, python -c one-liners, or interactive REPL sessions when the Python tool can do the job
|
||||
- The Python tool exists so code execution is structured, stateful, easier to continue across calls, and easier to inspect/debug
|
||||
- Use terminal_execute for shell commands, package managers, non-Python CLIs, process control, and launching services
|
||||
8. The Python sessions can operate concurrently with other tools. You may invoke
|
||||
terminal, browser, or other tools while maintaining active Python sessions.
|
||||
9. Each session has its own isolated namespace - variables in one session don't
|
||||
affect others.
|
||||
</notes>
|
||||
<examples>
|
||||
# Create new session for security analysis (default session)
|
||||
<function=python_action>
|
||||
<parameter=action>new_session</parameter>
|
||||
<parameter=code>import hashlib
|
||||
import base64
|
||||
import json
|
||||
print("Security analysis session started")</parameter>
|
||||
</function>
|
||||
|
||||
<function=python_action>
|
||||
<parameter=action>execute</parameter>
|
||||
<parameter=code>import requests
|
||||
url = "https://example.com"
|
||||
resp = requests.get(url, timeout=10)
|
||||
print(resp.status_code)</parameter>
|
||||
</function>
|
||||
|
||||
# Analyze security data in the default session
|
||||
<function=python_action>
|
||||
<parameter=action>execute</parameter>
|
||||
<parameter=code>vulnerability_data = {"cve": "CVE-2024-1234", "severity": "high"}
|
||||
encoded_payload = base64.b64encode(json.dumps(vulnerability_data).encode())
|
||||
print(f"Encoded: {encoded_payload.decode()}")</parameter>
|
||||
</function>
|
||||
|
||||
# Long running security scan with custom timeout
|
||||
<function=python_action>
|
||||
<parameter=action>execute</parameter>
|
||||
<parameter=code>import time
|
||||
# Simulate long-running vulnerability scan
|
||||
time.sleep(45)
|
||||
print('Security scan completed!')</parameter>
|
||||
<parameter=timeout>50</parameter>
|
||||
</function>
|
||||
|
||||
# Use IPython magic commands for package management and profiling
|
||||
<function=python_action>
|
||||
<parameter=action>execute</parameter>
|
||||
<parameter=code>%pip install requests
|
||||
%time response = requests.get('https://httpbin.org/json')
|
||||
%whos</parameter>
|
||||
|
||||
# Analyze requests for potential vulnerabilities
|
||||
<function=python_action>
|
||||
<parameter=action>execute</parameter>
|
||||
<parameter=code># Filter for POST requests that might contain sensitive data
|
||||
post_requests = list_requests(
|
||||
httpql_filter="req.method.eq:POST",
|
||||
page_size=20
|
||||
)
|
||||
|
||||
# Analyze each POST request for potential issues
|
||||
for req in post_requests.get('requests', []):
|
||||
request_id = req['id']
|
||||
# View the request details
|
||||
request_details = view_request(request_id, part="request")
|
||||
|
||||
# Check for potential SQL injection points
|
||||
body = request_details.get('body', '')
|
||||
if any(keyword in body.lower() for keyword in ['select', 'union', 'insert', 'update']):
|
||||
print(f"Potential SQL injection in request {request_id}")
|
||||
|
||||
# Repeat the request with a test payload
|
||||
test_payload = repeat_request(request_id, {
|
||||
'body': body + "' OR '1'='1"
|
||||
})
|
||||
print(f"Test response status: {test_payload.get('status_code')}")
|
||||
|
||||
print("Security analysis complete!")</parameter>
|
||||
</function>
|
||||
</examples>
|
||||
</tool>
|
||||
</tools>
|
||||
+35
-221
@@ -1,24 +1,33 @@
|
||||
import inspect
|
||||
"""Minimal in-container tool registry.
|
||||
|
||||
Used inside the sandbox container by ``strix.runtime.tool_server`` to
|
||||
look up `@register_tool`-decorated functions by name. Sandbox-bound
|
||||
tools (browser, terminal, python, file_edit, proxy) live as legacy
|
||||
``*_actions.py`` modules with this decoration; the host POSTs to
|
||||
:func:`tool_server.execute_tool` which dispatches via
|
||||
:func:`get_tool_by_name`.
|
||||
|
||||
Host-side tools are pure SDK function tools wired through
|
||||
:mod:`strix.agents.factory` and don't touch this registry at all.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
from collections.abc import Callable
|
||||
from functools import wraps
|
||||
from inspect import signature
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import defusedxml.ElementTree as DefusedET
|
||||
|
||||
from strix.utils.resource_paths import get_strix_resource_path
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
tools: list[dict[str, Any]] = []
|
||||
_tools_by_name: dict[str, Callable[..., Any]] = {}
|
||||
_tool_param_schemas: dict[str, dict[str, Any]] = {}
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ImplementedInClientSideOnlyError(Exception):
|
||||
"""Raised by sandbox-side stubs whose real implementation lives host-side."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
message: str = "This tool is implemented in the client side only",
|
||||
@@ -27,149 +36,16 @@ class ImplementedInClientSideOnlyError(Exception):
|
||||
super().__init__(self.message)
|
||||
|
||||
|
||||
def _process_dynamic_content(content: str) -> str:
|
||||
if "{{DYNAMIC_SKILLS_DESCRIPTION}}" in content:
|
||||
try:
|
||||
from strix.skills import generate_skills_description
|
||||
|
||||
skills_description = generate_skills_description()
|
||||
content = content.replace("{{DYNAMIC_SKILLS_DESCRIPTION}}", skills_description)
|
||||
except ImportError:
|
||||
logger.warning("Could not import skills utilities for dynamic schema generation")
|
||||
content = content.replace(
|
||||
"{{DYNAMIC_SKILLS_DESCRIPTION}}",
|
||||
"List of skills to load for this agent (max 5). Skill discovery failed.",
|
||||
)
|
||||
|
||||
return content
|
||||
|
||||
|
||||
def _load_xml_schema(path: Path) -> Any:
|
||||
if not path.exists():
|
||||
return None
|
||||
try:
|
||||
content = path.read_text(encoding="utf-8")
|
||||
|
||||
content = _process_dynamic_content(content)
|
||||
|
||||
start_tag = '<tool name="'
|
||||
end_tag = "</tool>"
|
||||
tools_dict = {}
|
||||
|
||||
pos = 0
|
||||
while True:
|
||||
start_pos = content.find(start_tag, pos)
|
||||
if start_pos == -1:
|
||||
break
|
||||
|
||||
name_start = start_pos + len(start_tag)
|
||||
name_end = content.find('"', name_start)
|
||||
if name_end == -1:
|
||||
break
|
||||
tool_name = content[name_start:name_end]
|
||||
|
||||
end_pos = content.find(end_tag, name_end)
|
||||
if end_pos == -1:
|
||||
break
|
||||
end_pos += len(end_tag)
|
||||
|
||||
tool_element = content[start_pos:end_pos]
|
||||
tools_dict[tool_name] = tool_element
|
||||
|
||||
pos = end_pos
|
||||
|
||||
if pos >= len(content):
|
||||
break
|
||||
except (IndexError, ValueError, UnicodeError) as e:
|
||||
logger.warning(f"Error loading schema file {path}: {e}")
|
||||
return None
|
||||
else:
|
||||
return tools_dict
|
||||
|
||||
|
||||
def _parse_param_schema(tool_xml: str) -> dict[str, Any]:
|
||||
params: set[str] = set()
|
||||
required: set[str] = set()
|
||||
|
||||
params_start = tool_xml.find("<parameters>")
|
||||
params_end = tool_xml.find("</parameters>")
|
||||
|
||||
if params_start == -1 or params_end == -1:
|
||||
return {"params": set(), "required": set(), "has_params": False}
|
||||
|
||||
params_section = tool_xml[params_start : params_end + len("</parameters>")]
|
||||
|
||||
try:
|
||||
root = DefusedET.fromstring(params_section)
|
||||
except DefusedET.ParseError:
|
||||
return {"params": set(), "required": set(), "has_params": False}
|
||||
|
||||
for param in root.findall(".//parameter"):
|
||||
name = param.attrib.get("name")
|
||||
if not name:
|
||||
continue
|
||||
params.add(name)
|
||||
if param.attrib.get("required", "false").lower() == "true":
|
||||
required.add(name)
|
||||
|
||||
return {"params": params, "required": required, "has_params": bool(params or required)}
|
||||
|
||||
|
||||
def _get_module_name(func: Callable[..., Any]) -> str:
|
||||
module = inspect.getmodule(func)
|
||||
if not module:
|
||||
return "unknown"
|
||||
|
||||
module_name = module.__name__
|
||||
if ".tools." in module_name:
|
||||
parts = module_name.split(".tools.")[-1].split(".")
|
||||
if len(parts) >= 1:
|
||||
return parts[0]
|
||||
return "unknown"
|
||||
|
||||
|
||||
def _get_schema_path(func: Callable[..., Any]) -> Path | None:
|
||||
module = inspect.getmodule(func)
|
||||
if not module or not module.__name__:
|
||||
return None
|
||||
|
||||
module_name = module.__name__
|
||||
|
||||
if ".tools." not in module_name:
|
||||
return None
|
||||
|
||||
parts = module_name.split(".tools.")[-1].split(".")
|
||||
if len(parts) < 2:
|
||||
return None
|
||||
|
||||
folder = parts[0]
|
||||
file_stem = parts[1]
|
||||
schema_file = f"{file_stem}_schema.xml"
|
||||
|
||||
return get_strix_resource_path("tools", folder, schema_file)
|
||||
|
||||
|
||||
def _is_sandbox_mode() -> bool:
|
||||
return os.getenv("STRIX_SANDBOX_MODE", "false").lower() == "true"
|
||||
|
||||
|
||||
def _is_browser_disabled() -> bool:
|
||||
if os.getenv("STRIX_DISABLE_BROWSER", "").lower() == "true":
|
||||
return True
|
||||
|
||||
from strix.config import Config
|
||||
|
||||
val: str = Config.load().get("env", {}).get("STRIX_DISABLE_BROWSER", "")
|
||||
return str(val).lower() == "true"
|
||||
return os.getenv("STRIX_DISABLE_BROWSER", "").lower() == "true"
|
||||
|
||||
|
||||
def _has_perplexity_api() -> bool:
|
||||
if os.getenv("PERPLEXITY_API_KEY"):
|
||||
return True
|
||||
|
||||
from strix.config import Config
|
||||
|
||||
return bool(Config.load().get("env", {}).get("PERPLEXITY_API_KEY"))
|
||||
return bool(os.getenv("PERPLEXITY_API_KEY"))
|
||||
|
||||
|
||||
def _should_register_tool(
|
||||
@@ -178,6 +54,7 @@ def _should_register_tool(
|
||||
requires_browser_mode: bool,
|
||||
requires_web_search_mode: bool,
|
||||
) -> bool:
|
||||
"""In-container side only registers sandbox-execution tools."""
|
||||
sandbox_mode = _is_sandbox_mode()
|
||||
|
||||
if sandbox_mode and not sandbox_execution:
|
||||
@@ -194,6 +71,14 @@ def register_tool(
|
||||
requires_browser_mode: bool = False,
|
||||
requires_web_search_mode: bool = False,
|
||||
) -> Callable[..., Any]:
|
||||
"""Register a tool function for in-container dispatch.
|
||||
|
||||
Decorations are conditional on the env (``STRIX_SANDBOX_MODE``,
|
||||
``STRIX_DISABLE_BROWSER``, ``PERPLEXITY_API_KEY``) so the host
|
||||
side, which imports these modules but doesn't run sandbox-bound
|
||||
tools locally, doesn't accumulate dead registrations.
|
||||
"""
|
||||
|
||||
def decorator(f: Callable[..., Any]) -> Callable[..., Any]:
|
||||
if not _should_register_tool(
|
||||
sandbox_execution=sandbox_execution,
|
||||
@@ -202,42 +87,14 @@ def register_tool(
|
||||
):
|
||||
return f
|
||||
|
||||
sandbox_mode = _is_sandbox_mode()
|
||||
func_dict = {
|
||||
"name": f.__name__,
|
||||
"function": f,
|
||||
"module": _get_module_name(f),
|
||||
"sandbox_execution": sandbox_execution,
|
||||
}
|
||||
|
||||
if not sandbox_mode:
|
||||
try:
|
||||
schema_path = _get_schema_path(f)
|
||||
xml_tools = _load_xml_schema(schema_path) if schema_path else None
|
||||
|
||||
if xml_tools is not None and f.__name__ in xml_tools:
|
||||
func_dict["xml_schema"] = xml_tools[f.__name__]
|
||||
else:
|
||||
func_dict["xml_schema"] = (
|
||||
f'<tool name="{f.__name__}">'
|
||||
"<description>Schema not found for tool.</description>"
|
||||
"</tool>"
|
||||
)
|
||||
except (TypeError, FileNotFoundError) as e:
|
||||
logger.warning(f"Error loading schema for {f.__name__}: {e}")
|
||||
func_dict["xml_schema"] = (
|
||||
f'<tool name="{f.__name__}">'
|
||||
"<description>Error loading schema.</description>"
|
||||
"</tool>"
|
||||
)
|
||||
|
||||
if not sandbox_mode:
|
||||
xml_schema = func_dict.get("xml_schema")
|
||||
param_schema = _parse_param_schema(xml_schema if isinstance(xml_schema, str) else "")
|
||||
_tool_param_schemas[str(func_dict["name"])] = param_schema
|
||||
|
||||
tools.append(func_dict)
|
||||
_tools_by_name[str(func_dict["name"])] = f
|
||||
tools.append(
|
||||
{
|
||||
"name": f.__name__,
|
||||
"function": f,
|
||||
"sandbox_execution": sandbox_execution,
|
||||
},
|
||||
)
|
||||
_tools_by_name[f.__name__] = f
|
||||
|
||||
@wraps(f)
|
||||
def wrapper(*args: Any, **kwargs: Any) -> Any:
|
||||
@@ -258,49 +115,6 @@ def get_tool_names() -> list[str]:
|
||||
return list(_tools_by_name.keys())
|
||||
|
||||
|
||||
def get_tool_param_schema(name: str) -> dict[str, Any] | None:
|
||||
return _tool_param_schemas.get(name)
|
||||
|
||||
|
||||
def needs_agent_state(tool_name: str) -> bool:
|
||||
tool_func = get_tool_by_name(tool_name)
|
||||
if not tool_func:
|
||||
return False
|
||||
sig = signature(tool_func)
|
||||
return "agent_state" in sig.parameters
|
||||
|
||||
|
||||
def should_execute_in_sandbox(tool_name: str) -> bool:
|
||||
for tool in tools:
|
||||
if tool.get("name") == tool_name:
|
||||
return bool(tool.get("sandbox_execution", True))
|
||||
return True
|
||||
|
||||
|
||||
def get_tools_prompt() -> str:
|
||||
tools_by_module: dict[str, list[dict[str, Any]]] = {}
|
||||
for tool in tools:
|
||||
module = tool.get("module", "unknown")
|
||||
if module not in tools_by_module:
|
||||
tools_by_module[module] = []
|
||||
tools_by_module[module].append(tool)
|
||||
|
||||
xml_sections = []
|
||||
for module, module_tools in sorted(tools_by_module.items()):
|
||||
tag_name = f"{module}_tools"
|
||||
section_parts = [f"<{tag_name}>"]
|
||||
for tool in module_tools:
|
||||
tool_xml = tool.get("xml_schema", "")
|
||||
if tool_xml:
|
||||
indented_tool = "\n".join(f" {line}" for line in tool_xml.split("\n"))
|
||||
section_parts.append(indented_tool)
|
||||
section_parts.append(f"</{tag_name}>")
|
||||
xml_sections.append("\n".join(section_parts))
|
||||
|
||||
return "\n\n".join(xml_sections)
|
||||
|
||||
|
||||
def clear_registry() -> None:
|
||||
tools.clear()
|
||||
_tools_by_name.clear()
|
||||
_tool_param_schemas.clear()
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
from .reporting_actions import create_vulnerability_report
|
||||
from .tool import create_vulnerability_report
|
||||
|
||||
|
||||
__all__ = [
|
||||
"create_vulnerability_report",
|
||||
]
|
||||
__all__ = ["create_vulnerability_report"]
|
||||
|
||||
@@ -1,338 +0,0 @@
|
||||
import contextlib
|
||||
import re
|
||||
from pathlib import PurePosixPath
|
||||
from typing import Any
|
||||
|
||||
from strix.tools.registry import register_tool
|
||||
|
||||
|
||||
_CVSS_FIELDS = (
|
||||
"attack_vector",
|
||||
"attack_complexity",
|
||||
"privileges_required",
|
||||
"user_interaction",
|
||||
"scope",
|
||||
"confidentiality",
|
||||
"integrity",
|
||||
"availability",
|
||||
)
|
||||
|
||||
|
||||
def parse_cvss_xml(xml_str: str) -> dict[str, str] | None:
|
||||
if not xml_str or not xml_str.strip():
|
||||
return None
|
||||
result = {}
|
||||
for field in _CVSS_FIELDS:
|
||||
match = re.search(rf"<{field}>(.*?)</{field}>", xml_str, re.DOTALL)
|
||||
if match:
|
||||
result[field] = match.group(1).strip()
|
||||
return result if result else None
|
||||
|
||||
|
||||
def parse_code_locations_xml(xml_str: str) -> list[dict[str, Any]] | None:
|
||||
if not xml_str or not xml_str.strip():
|
||||
return None
|
||||
locations = []
|
||||
for loc_match in re.finditer(r"<location>(.*?)</location>", xml_str, re.DOTALL):
|
||||
loc: dict[str, Any] = {}
|
||||
loc_content = loc_match.group(1)
|
||||
for field in (
|
||||
"file",
|
||||
"start_line",
|
||||
"end_line",
|
||||
"snippet",
|
||||
"label",
|
||||
"fix_before",
|
||||
"fix_after",
|
||||
):
|
||||
field_match = re.search(rf"<{field}>(.*?)</{field}>", loc_content, re.DOTALL)
|
||||
if field_match:
|
||||
raw = field_match.group(1)
|
||||
value = (
|
||||
raw.strip("\n")
|
||||
if field in ("snippet", "fix_before", "fix_after")
|
||||
else raw.strip()
|
||||
)
|
||||
if field in ("start_line", "end_line"):
|
||||
with contextlib.suppress(ValueError, TypeError):
|
||||
loc[field] = int(value)
|
||||
elif value:
|
||||
loc[field] = value
|
||||
if loc.get("file") and loc.get("start_line") is not None:
|
||||
locations.append(loc)
|
||||
return locations if locations else None
|
||||
|
||||
|
||||
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 _validate_code_locations(locations: list[dict[str, Any]]) -> list[str]:
|
||||
errors = []
|
||||
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_and_severity(
|
||||
attack_vector: str,
|
||||
attack_complexity: str,
|
||||
privileges_required: str,
|
||||
user_interaction: str,
|
||||
scope: str,
|
||||
confidentiality: str,
|
||||
integrity: str,
|
||||
availability: str,
|
||||
) -> tuple[float, str, str]:
|
||||
try:
|
||||
from cvss import CVSS3
|
||||
|
||||
vector = (
|
||||
f"CVSS:3.1/AV:{attack_vector}/AC:{attack_complexity}/"
|
||||
f"PR:{privileges_required}/UI:{user_interaction}/S:{scope}/"
|
||||
f"C:{confidentiality}/I:{integrity}/A:{availability}"
|
||||
)
|
||||
|
||||
c = CVSS3(vector)
|
||||
scores = c.scores()
|
||||
severities = c.severities()
|
||||
|
||||
base_score = scores[0]
|
||||
base_severity = severities[0]
|
||||
|
||||
severity = base_severity.lower()
|
||||
|
||||
except Exception:
|
||||
import logging
|
||||
|
||||
logging.exception("Failed to calculate CVSS")
|
||||
return 7.5, "high", ""
|
||||
else:
|
||||
return base_score, severity, vector
|
||||
|
||||
|
||||
def _validate_required_fields(**kwargs: str | None) -> list[str]:
|
||||
validation_errors: list[str] = []
|
||||
|
||||
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",
|
||||
}
|
||||
|
||||
for field_name, error_msg in required_fields.items():
|
||||
value = kwargs.get(field_name)
|
||||
if not value or not str(value).strip():
|
||||
validation_errors.append(error_msg)
|
||||
|
||||
return validation_errors
|
||||
|
||||
|
||||
def _validate_cvss_parameters(**kwargs: str) -> list[str]:
|
||||
validation_errors: list[str] = []
|
||||
|
||||
cvss_validations = {
|
||||
"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"],
|
||||
}
|
||||
|
||||
for param_name, valid_values in cvss_validations.items():
|
||||
value = kwargs.get(param_name)
|
||||
if value not in valid_values:
|
||||
validation_errors.append(
|
||||
f"Invalid {param_name}: {value}. Must be one of: {valid_values}"
|
||||
)
|
||||
|
||||
return validation_errors
|
||||
|
||||
|
||||
@register_tool(sandbox_execution=False)
|
||||
def create_vulnerability_report( # 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: str,
|
||||
endpoint: str | None = None,
|
||||
method: str | None = None,
|
||||
cve: str | None = None,
|
||||
cwe: str | None = None,
|
||||
code_locations: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
validation_errors = _validate_required_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,
|
||||
)
|
||||
|
||||
parsed_cvss = parse_cvss_xml(cvss_breakdown)
|
||||
if not parsed_cvss:
|
||||
validation_errors.append("cvss: could not parse CVSS breakdown XML")
|
||||
else:
|
||||
validation_errors.extend(_validate_cvss_parameters(**parsed_cvss))
|
||||
|
||||
parsed_locations = parse_code_locations_xml(code_locations) if code_locations else None
|
||||
|
||||
if parsed_locations:
|
||||
validation_errors.extend(_validate_code_locations(parsed_locations))
|
||||
if cve:
|
||||
cve = _extract_cve(cve)
|
||||
cve_err = _validate_cve(cve)
|
||||
if cve_err:
|
||||
validation_errors.append(cve_err)
|
||||
if cwe:
|
||||
cwe = _extract_cwe(cwe)
|
||||
cwe_err = _validate_cwe(cwe)
|
||||
if cwe_err:
|
||||
validation_errors.append(cwe_err)
|
||||
|
||||
if validation_errors:
|
||||
return {"success": False, "message": "Validation failed", "errors": validation_errors}
|
||||
|
||||
assert parsed_cvss is not None
|
||||
cvss_score, severity, cvss_vector = calculate_cvss_and_severity(**parsed_cvss)
|
||||
|
||||
try:
|
||||
from strix.telemetry.tracer import get_global_tracer
|
||||
|
||||
tracer = get_global_tracer()
|
||||
if tracer:
|
||||
from strix.llm.dedupe import check_duplicate
|
||||
|
||||
existing_reports = tracer.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_result = check_duplicate(candidate, existing_reports)
|
||||
|
||||
if dedupe_result.get("is_duplicate"):
|
||||
duplicate_id = dedupe_result.get("duplicate_id", "")
|
||||
|
||||
duplicate_title = ""
|
||||
for report in existing_reports:
|
||||
if report.get("id") == duplicate_id:
|
||||
duplicate_title = report.get("title", "Unknown")
|
||||
break
|
||||
|
||||
return {
|
||||
"success": False,
|
||||
"message": (
|
||||
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_result.get("confidence", 0.0),
|
||||
"reason": dedupe_result.get("reason", ""),
|
||||
}
|
||||
|
||||
report_id = tracer.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=parsed_cvss,
|
||||
endpoint=endpoint,
|
||||
method=method,
|
||||
cve=cve,
|
||||
cwe=cwe,
|
||||
code_locations=parsed_locations,
|
||||
)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"Vulnerability report '{title}' created successfully",
|
||||
"report_id": report_id,
|
||||
"severity": severity,
|
||||
"cvss_score": cvss_score,
|
||||
}
|
||||
|
||||
import logging
|
||||
|
||||
logging.warning("Current tracer not available - vulnerability report not stored")
|
||||
|
||||
except (ImportError, AttributeError) as e:
|
||||
return {"success": False, "message": f"Failed to create vulnerability report: {e!s}"}
|
||||
else:
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"Vulnerability report '{title}' created (not persisted)",
|
||||
"warning": "Report could not be persisted - tracer unavailable",
|
||||
}
|
||||
@@ -1,370 +0,0 @@
|
||||
<tools>
|
||||
<tool name="create_vulnerability_report">
|
||||
<description>Create a vulnerability report for a discovered security issue.
|
||||
|
||||
IMPORTANT: This tool includes automatic LLM-based deduplication. Reports that describe the same vulnerability (same root cause on the same asset) as an existing report will be rejected.
|
||||
|
||||
Use this tool to document a specific fully verified security vulnerability.
|
||||
|
||||
DO NOT USE:
|
||||
- For general security observations without specific vulnerabilities
|
||||
- When you don't have concrete vulnerability details
|
||||
- When you don't have a proof of concept, or still not 100% sure if it's a vulnerability
|
||||
- For tracking multiple vulnerabilities (create separate reports)
|
||||
- For reporting multiple vulnerabilities at once. Use a separate create_vulnerability_report for each vulnerability.
|
||||
- To re-report a vulnerability that was already reported (even with different details)
|
||||
|
||||
White-box requirement (when you have access to the code): You MUST include code_locations with nested XML, including fix_before/fix_after on locations where a fix is proposed.
|
||||
|
||||
DEDUPLICATION: If this tool returns with success=false and mentions a duplicate, DO NOT attempt to re-submit. The vulnerability has already been reported. Move on to testing other areas.
|
||||
|
||||
Professional, customer-facing report rules (PDF-ready):
|
||||
- Do NOT include internal or system details: never mention local or absolute paths (e.g., "/workspace"), internal tools, agents, orchestrators, sandboxes, models, system prompts/instructions, connection issues, internal errors/logs/stack traces, or tester machine environment details.
|
||||
- Tone and style: formal, objective, third-person, vendor-neutral, concise. No runbooks, checklists, or engineering notes. Avoid headings like "QUICK", "Approach", or "Techniques" that read like internal guidance.
|
||||
- Use a standard penetration testing report structure per finding:
|
||||
1) Overview
|
||||
2) Severity and CVSS (vector only)
|
||||
3) Affected asset(s)
|
||||
4) Technical details
|
||||
5) Proof of concept (repro steps plus code)
|
||||
6) Impact
|
||||
7) Remediation
|
||||
8) Evidence (optional request/response excerpts, etc.) in the technical analysis field.
|
||||
- Numbered steps are allowed ONLY within the proof of concept and remediation sections. Elsewhere, use clear, concise paragraphs suitable for customer-facing reports.
|
||||
- Language must be precise and non-vague; avoid hedging.
|
||||
</description>
|
||||
<parameters>
|
||||
<parameter name="title" type="string" required="true">
|
||||
<description>Clear, specific title (e.g., "SQL Injection in /api/users Login Parameter"). But not too long. Don't mention CVE number in the title.</description>
|
||||
</parameter>
|
||||
<parameter name="description" type="string" required="true">
|
||||
<description>Comprehensive description of the vulnerability and how it was discovered</description>
|
||||
</parameter>
|
||||
<parameter name="impact" type="string" required="true">
|
||||
<description>Impact assessment: what attacker can do, business risk, data at risk</description>
|
||||
</parameter>
|
||||
<parameter name="target" type="string" required="true">
|
||||
<description>Affected target: URL, domain, or Git repository</description>
|
||||
</parameter>
|
||||
<parameter name="technical_analysis" type="string" required="true">
|
||||
<description>Technical explanation of the vulnerability mechanism and root cause</description>
|
||||
</parameter>
|
||||
<parameter name="poc_description" type="string" required="true">
|
||||
<description>Step-by-step instructions to reproduce the vulnerability</description>
|
||||
</parameter>
|
||||
<parameter name="poc_script_code" type="string" required="true">
|
||||
<description>Actual proof of concept code, exploit, payload, or script that demonstrates the vulnerability. Python code.</description>
|
||||
</parameter>
|
||||
<parameter name="remediation_steps" type="string" required="true">
|
||||
<description>Specific, actionable steps to fix the vulnerability</description>
|
||||
</parameter>
|
||||
<parameter name="cvss_breakdown" type="string" required="true">
|
||||
<description>CVSS 3.1 base score breakdown as nested XML. All 8 metrics are required.
|
||||
|
||||
Each metric element contains a single uppercase letter value:
|
||||
- attack_vector: N (Network), A (Adjacent), L (Local), P (Physical)
|
||||
- attack_complexity: L (Low), H (High)
|
||||
- privileges_required: N (None), L (Low), H (High)
|
||||
- user_interaction: N (None), R (Required)
|
||||
- scope: U (Unchanged), C (Changed)
|
||||
- confidentiality: N (None), L (Low), H (High)
|
||||
- integrity: N (None), L (Low), H (High)
|
||||
- availability: N (None), L (Low), H (High)</description>
|
||||
<format>
|
||||
<attack_vector>N</attack_vector>
|
||||
<attack_complexity>L</attack_complexity>
|
||||
<privileges_required>N</privileges_required>
|
||||
<user_interaction>N</user_interaction>
|
||||
<scope>U</scope>
|
||||
<confidentiality>H</confidentiality>
|
||||
<integrity>H</integrity>
|
||||
<availability>N</availability>
|
||||
</format>
|
||||
</parameter>
|
||||
<parameter name="endpoint" type="string" required="false">
|
||||
<description>API endpoint(s) or URL path(s) (e.g., "/api/login") - for web vulnerabilities, or Git repository path(s) - for code vulnerabilities</description>
|
||||
</parameter>
|
||||
<parameter name="method" type="string" required="false">
|
||||
<description>HTTP method(s) (GET, POST, etc.) - for web vulnerabilities.</description>
|
||||
</parameter>
|
||||
<parameter name="cve" type="string" required="false">
|
||||
<description>CVE identifier. ONLY the ID, e.g. "CVE-2024-1234" — do NOT include the name or description.
|
||||
You must be 100% certain of the exact CVE number. Do NOT guess, approximate, or hallucinate CVE IDs.
|
||||
If web_search is available, use it to verify the CVE exists and matches this vulnerability. If you cannot verify it, omit this field entirely.</description>
|
||||
</parameter>
|
||||
<parameter name="cwe" type="string" required="false">
|
||||
<description>CWE identifier. ONLY the ID, e.g. "CWE-89" — do NOT include the name or parenthetical (wrong: "CWE-89 (SQL Injection)").
|
||||
|
||||
You must be 100% certain of the exact CWE number. Do NOT guess or approximate.
|
||||
If web_search is available and you are unsure, use it to look up the correct CWE. If you cannot be certain, omit this field entirely.
|
||||
Always prefer the most specific child CWE over a broad parent.
|
||||
For example, use CWE-89 instead of CWE-74, or CWE-78 instead of CWE-77.
|
||||
|
||||
Reference (ID only — names here are just for your reference, do NOT include them in the value):
|
||||
- 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 Authentication for Critical Function, CWE-639 Authorization Bypass Through User-Controlled Key
|
||||
- Web: CWE-352 CSRF, CWE-918 SSRF, CWE-601 Open Redirect, CWE-434 Unrestricted Upload of File with Dangerous Type
|
||||
- Memory: CWE-787 Out-of-bounds Write, CWE-125 Out-of-bounds Read, CWE-416 Use After Free, CWE-120 Classic Buffer Overflow
|
||||
- Data: CWE-502 Deserialization of Untrusted Data, CWE-22 Path Traversal, CWE-611 XXE
|
||||
- Crypto/Config: CWE-798 Use of Hard-coded Credentials, CWE-327 Use of Broken or Risky Cryptographic Algorithm, CWE-311 Missing Encryption of Sensitive Data, CWE-916 Password Hash With Insufficient Computational Effort
|
||||
|
||||
Do NOT use broad/parent CWEs like CWE-74, CWE-20, CWE-200, CWE-284, or CWE-693.</description>
|
||||
</parameter>
|
||||
<parameter name="code_locations" type="string" required="false">
|
||||
<description>Nested XML list of code locations where the vulnerability exists. MANDATORY for white-box testing.
|
||||
|
||||
CRITICAL — HOW fix_before/fix_after WORK:
|
||||
fix_before and fix_after are LITERAL BLOCK-LEVEL REPLACEMENTS used directly for GitHub/GitLab PR suggestion blocks. When a reviewer clicks "Accept suggestion", the platform replaces the EXACT lines from start_line to end_line with the fix_after content. This means:
|
||||
|
||||
1. fix_before MUST be an EXACT, VERBATIM copy of the source code at lines start_line through end_line. Same whitespace, same indentation, same line breaks. If fix_before does not match the actual file content character-for-character, the suggestion will be wrong or will corrupt the code when accepted.
|
||||
|
||||
2. fix_after is the COMPLETE replacement for that entire block. It replaces ALL lines from start_line to end_line. It can be more lines, fewer lines, or the same number of lines as fix_before.
|
||||
|
||||
3. start_line and end_line define the EXACT line range being replaced. They must precisely cover the lines in fix_before — no more, no less. If the vulnerable code spans lines 45-48, then start_line=45 and end_line=48, and fix_before must contain all 4 lines exactly as they appear in the file.
|
||||
|
||||
MULTI-PART FIXES:
|
||||
Many fixes require changes in multiple non-contiguous parts of a file (e.g., adding an import at the top AND changing code lower down), or across multiple files. Since each fix_before/fix_after pair covers ONE contiguous block, you MUST create SEPARATE location entries for each part of the fix:
|
||||
|
||||
- Each location covers one contiguous block of lines to change
|
||||
- Use the label field to describe how each part relates to the overall fix (e.g., "Add import for parameterized query library", "Replace string interpolation with parameterized query")
|
||||
- Order fix locations logically: primary fix first (where the vulnerability manifests), then supporting changes (imports, config, etc.)
|
||||
|
||||
COMMON MISTAKES TO AVOID:
|
||||
- Do NOT guess line numbers. Read the file and verify the exact lines before reporting.
|
||||
- Do NOT paraphrase or reformat code in fix_before. It must be a verbatim copy.
|
||||
- Do NOT set start_line=end_line when the vulnerable code spans multiple lines. Cover the full range.
|
||||
- Do NOT put an import addition and a code change in the same fix_before/fix_after if they are not on adjacent lines. Split them into separate locations.
|
||||
- Do NOT include lines outside the vulnerable/fixed code in fix_before just to "pad" the range.
|
||||
- Do NOT duplicate changes across locations. Each location's fix_after must ONLY contain changes for its own line range. Never repeat a change that is already covered by another location.
|
||||
|
||||
Each location element fields:
|
||||
- file (REQUIRED): Path relative to repository root. No leading slash, no absolute paths, no ".." traversal.
|
||||
Correct: "src/db/queries.ts" or "app/routes/users.py"
|
||||
Wrong: "/workspace/repo/src/db/queries.ts", "./src/db/queries.ts", "../../etc/passwd"
|
||||
- start_line (REQUIRED): Exact 1-based line number where the vulnerable/affected code begins. Must be a positive integer. You must be certain of this number — go back and verify against the actual file content if needed.
|
||||
- end_line (REQUIRED): Exact 1-based line number where the vulnerable/affected code ends. Must be >= start_line. Set equal to start_line ONLY if the code is truly on a single line.
|
||||
- snippet (optional): The actual source code at this location, copied verbatim from the file.
|
||||
- label (optional): Short role description for this location. For multi-part fixes, use this to explain the purpose of each change (e.g., "Add import for escape utility", "Sanitize user input before SQL query").
|
||||
- fix_before (optional): The vulnerable code to be replaced — VERBATIM copy of lines start_line through end_line. Must match the actual source character-for-character including whitespace and indentation.
|
||||
- fix_after (optional): The corrected code that replaces the entire fix_before block. Must be syntactically valid and ready to apply as a direct replacement.
|
||||
|
||||
Locations without fix_before/fix_after are informational context (e.g. showing the source of tainted data).
|
||||
Locations with fix_before/fix_after are actionable fixes (used directly for PR suggestion blocks).</description>
|
||||
<format>
|
||||
<location>
|
||||
<file>src/db/queries.ts</file>
|
||||
<start_line>42</start_line>
|
||||
<end_line>45</end_line>
|
||||
<snippet>const query = (
|
||||
`SELECT * FROM users ` +
|
||||
`WHERE id = ${id}`
|
||||
);</snippet>
|
||||
<label>Unsanitized input used in SQL query (sink)</label>
|
||||
<fix_before>const query = (
|
||||
`SELECT * FROM users ` +
|
||||
`WHERE id = ${id}`
|
||||
);</fix_before>
|
||||
<fix_after>const query = 'SELECT * FROM users WHERE id = $1';
|
||||
const result = await db.query(query, [id]);</fix_after>
|
||||
</location>
|
||||
<location>
|
||||
<file>src/routes/users.ts</file>
|
||||
<start_line>15</start_line>
|
||||
<end_line>15</end_line>
|
||||
<snippet>const id = req.params.id</snippet>
|
||||
<label>User input from request parameter (source)</label>
|
||||
</location>
|
||||
</format>
|
||||
</parameter>
|
||||
</parameters>
|
||||
<returns type="Dict[str, Any]">
|
||||
<description>Response containing:
|
||||
- On success: success=true, message, report_id, severity, cvss_score
|
||||
- On duplicate detection: success=false, message (with duplicate info), duplicate_of (ID), duplicate_title, confidence (0-1), reason (why it's a duplicate)</description>
|
||||
</returns>
|
||||
|
||||
<examples>
|
||||
<function=create_vulnerability_report>
|
||||
<parameter=title>Server-Side Request Forgery (SSRF) via URL Preview Feature Enables Internal Network Access</parameter>
|
||||
<parameter=description>A server-side request forgery (SSRF) vulnerability was identified in the URL preview feature that generates rich previews for user-supplied links.
|
||||
|
||||
The application performs server-side HTTP requests to retrieve metadata (title, description, thumbnails). Insufficient validation of the destination allows an attacker to coerce the server into making requests to internal network hosts and link-local addresses that are not directly reachable from the internet.
|
||||
|
||||
This issue is particularly high risk in cloud-hosted environments where link-local metadata services may expose sensitive information (e.g., instance identifiers, temporary credentials) if reachable from the application runtime.</parameter>
|
||||
<parameter=impact>Successful exploitation may allow an attacker to:
|
||||
|
||||
- Reach internal-only services (admin panels, service discovery endpoints, unauthenticated microservices)
|
||||
- Enumerate internal network topology based on timing and response differences
|
||||
- Access link-local services that should never be reachable from user input paths
|
||||
- Potentially retrieve sensitive configuration data and temporary credentials in certain hosting environments
|
||||
|
||||
Business impact includes increased likelihood of lateral movement, data exposure from internal systems, and compromise of cloud resources if credentials are obtained.</parameter>
|
||||
<parameter=target>https://app.acme-corp.com</parameter>
|
||||
<parameter=technical_analysis>The vulnerable behavior occurs when the application accepts a user-controlled URL and fetches it server-side to generate a preview. The response body and/or selected metadata fields are then returned to the client.
|
||||
|
||||
Observed security gaps:
|
||||
- No robust allowlist of approved outbound domains
|
||||
- No effective blocking of private, loopback, and link-local address ranges
|
||||
- Redirect handling can be leveraged to reach disallowed destinations if not revalidated after following redirects
|
||||
- DNS resolution and IP validation appear to occur without normalization safeguards, creating bypass risk (e.g., encoded IPs, mixed IPv6 notation, DNS rebinding scenarios)
|
||||
|
||||
As a result, an attacker can supply a URL that resolves to an internal destination. The server performs the request from a privileged network position, and the attacker can infer results via returned preview content or measurable response differences.</parameter>
|
||||
<parameter=poc_description>To reproduce:
|
||||
|
||||
1. Authenticate to the application as a standard user.
|
||||
2. Navigate to the link preview feature (e.g., “Add Link”, “Preview URL”, or equivalent UI).
|
||||
3. Submit a URL pointing to an internal resource. Example payloads:
|
||||
|
||||
- http://127.0.0.1:80/
|
||||
- http://localhost:8080/
|
||||
- http://10.0.0.1:80/
|
||||
- http://169.254.169.254/ (link-local)
|
||||
|
||||
4. Observe that the server attempts to fetch the destination and returns either:
|
||||
- Preview content/metadata from the target, or
|
||||
- Error/timing differences that confirm network reachability.
|
||||
|
||||
Impact validation:
|
||||
- Use a controlled internal endpoint (or a benign endpoint that returns a distinct marker) to demonstrate that the request is performed by the server, not the client.
|
||||
- If the application follows redirects, validate whether an allowlisted URL can redirect to a disallowed destination, and whether the redirected-to destination is still fetched.</parameter>
|
||||
<parameter=poc_script_code>import json
|
||||
import time
|
||||
from urllib.parse import urljoin
|
||||
|
||||
import requests
|
||||
|
||||
BASE = "https://app.acme-corp.com"
|
||||
PREVIEW_ENDPOINT = urljoin(BASE, "/api/v1/link-preview")
|
||||
|
||||
SESSION_COOKIE = "" # Set to your authenticated session cookie value if needed
|
||||
|
||||
TARGETS = [
|
||||
"http://127.0.0.1:80/",
|
||||
"http://localhost:8080/",
|
||||
"http://10.0.0.1:80/",
|
||||
"http://169.254.169.254/",
|
||||
]
|
||||
|
||||
|
||||
def preview(url: str) -> tuple[int, float, str]:
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
cookies = {}
|
||||
if SESSION_COOKIE:
|
||||
cookies["session"] = SESSION_COOKIE
|
||||
|
||||
payload = {"url": url}
|
||||
start = time.time()
|
||||
resp = requests.post(PREVIEW_ENDPOINT, headers=headers, cookies=cookies, data=json.dumps(payload), timeout=15)
|
||||
elapsed = time.time() - start
|
||||
|
||||
body = resp.text
|
||||
snippet = body[:500]
|
||||
return resp.status_code, elapsed, snippet
|
||||
|
||||
|
||||
def main() -> int:
|
||||
print(f"Endpoint: {PREVIEW_ENDPOINT}")
|
||||
print("Testing SSRF candidates (server-side fetch behavior):")
|
||||
print()
|
||||
|
||||
for url in TARGETS:
|
||||
try:
|
||||
status, elapsed, snippet = preview(url)
|
||||
print(f"URL: {url}")
|
||||
print(f"Status: {status}")
|
||||
print(f"Elapsed: {elapsed:.2f}s")
|
||||
print("Body (first 500 chars):")
|
||||
print(snippet)
|
||||
print("-" * 60)
|
||||
except requests.RequestException as e:
|
||||
print(f"URL: {url}")
|
||||
print(f"Request failed: {e}")
|
||||
print("-" * 60)
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())</parameter>
|
||||
<parameter=remediation_steps>Implement layered SSRF defenses:
|
||||
|
||||
1. Explicit allowlist for outbound destinations
|
||||
- Only permit fetching from a maintained set of approved domains (and required schemes).
|
||||
- Reject all other destinations by default.
|
||||
|
||||
2. Robust IP range blocking after DNS resolution
|
||||
- Resolve the hostname and block private, loopback, link-local, and reserved ranges for both IPv4 and IPv6.
|
||||
- Re-validate on every redirect hop; do not follow redirects to disallowed destinations.
|
||||
|
||||
3. URL normalization and parser hardening
|
||||
- Normalize and validate the URL using a strict parser.
|
||||
- Reject ambiguous encodings and unusual notations that can bypass filters.
|
||||
|
||||
4. Network egress controls (defense in depth)
|
||||
- Enforce outbound firewall rules so the application runtime cannot reach sensitive internal ranges or link-local addresses.
|
||||
- If previews are required, route outbound requests through a dedicated egress proxy with policy enforcement and auditing.
|
||||
|
||||
5. Response handling hardening
|
||||
- Avoid returning raw response bodies from previews.
|
||||
- Strictly limit what metadata is returned and apply size/time limits to outbound fetches.
|
||||
|
||||
6. Monitoring and alerting
|
||||
- Log and alert on preview attempts to unusual destinations, repeated failures, high-frequency requests, or attempts to access blocked ranges.</parameter>
|
||||
<parameter=cvss_breakdown>
|
||||
<attack_vector>N</attack_vector>
|
||||
<attack_complexity>L</attack_complexity>
|
||||
<privileges_required>L</privileges_required>
|
||||
<user_interaction>N</user_interaction>
|
||||
<scope>C</scope>
|
||||
<confidentiality>H</confidentiality>
|
||||
<integrity>H</integrity>
|
||||
<availability>L</availability>
|
||||
</parameter>
|
||||
<parameter=endpoint>/api/v1/link-preview</parameter>
|
||||
<parameter=method>POST</parameter>
|
||||
<parameter=cwe>CWE-918</parameter>
|
||||
<parameter=code_locations>
|
||||
<location>
|
||||
<file>src/services/link-preview.ts</file>
|
||||
<start_line>45</start_line>
|
||||
<end_line>48</end_line>
|
||||
<snippet> const options = { timeout: 5000 };
|
||||
const response = await fetch(userUrl, options);
|
||||
const html = await response.text();
|
||||
return extractMetadata(html);</snippet>
|
||||
<label>Unvalidated user URL passed to server-side fetch (sink)</label>
|
||||
<fix_before> const options = { timeout: 5000 };
|
||||
const response = await fetch(userUrl, options);
|
||||
const html = await response.text();
|
||||
return extractMetadata(html);</fix_before>
|
||||
<fix_after> const validated = await validateAndResolveUrl(userUrl);
|
||||
if (!validated) throw new ForbiddenError('URL not allowed');
|
||||
const options = { timeout: 5000 };
|
||||
const response = await fetch(validated, options);
|
||||
const html = await response.text();
|
||||
return extractMetadata(html);</fix_after>
|
||||
</location>
|
||||
<location>
|
||||
<file>src/services/link-preview.ts</file>
|
||||
<start_line>2</start_line>
|
||||
<end_line>2</end_line>
|
||||
<snippet>import { extractMetadata } from '../utils/html';</snippet>
|
||||
<label>Add import for URL validation utility</label>
|
||||
<fix_before>import { extractMetadata } from '../utils/html';</fix_before>
|
||||
<fix_after>import { extractMetadata } from '../utils/html';
|
||||
import { validateAndResolveUrl } from '../utils/url-validator';</fix_after>
|
||||
</location>
|
||||
<location>
|
||||
<file>src/routes/api/v1/links.ts</file>
|
||||
<start_line>12</start_line>
|
||||
<end_line>12</end_line>
|
||||
<snippet>const userUrl = req.body.url</snippet>
|
||||
<label>User-controlled URL from request body (source)</label>
|
||||
</location>
|
||||
</parameter>
|
||||
</function>
|
||||
</examples>
|
||||
</tool>
|
||||
</tools>
|
||||
+319
-49
@@ -1,34 +1,320 @@
|
||||
"""SDK function-tool wrapper for the legacy ``create_vulnerability_report``.
|
||||
"""``create_vulnerability_report`` — file a vuln finding with dedup + CVSS.
|
||||
|
||||
One tool. Local execution (``sandbox_execution=False`` in the legacy
|
||||
registration). The legacy implementation handles XML parsing for the
|
||||
CVSS breakdown and code locations, runs LLM-based dedup against
|
||||
existing reports through ``strix.llm.dedupe.check_duplicate``, and
|
||||
persists via ``get_global_tracer().add_vulnerability_report``.
|
||||
|
||||
We wrap the synchronous legacy function in ``asyncio.to_thread`` because
|
||||
the dedup check makes a network call and we don't want to block the
|
||||
event loop while it waits.
|
||||
Validates required fields, parses the CVSS-3.1 XML breakdown into a
|
||||
score, runs LLM-based dedup against existing reports through
|
||||
``strix.llm.dedupe.check_duplicate``, and persists via the global
|
||||
:class:`strix.telemetry.tracer.Tracer` instance.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from pathlib import PurePosixPath
|
||||
from typing import Any
|
||||
|
||||
from agents import RunContextWrapper
|
||||
|
||||
from strix.tools._decorator import strix_tool
|
||||
from strix.tools.reporting import reporting_actions as _impl
|
||||
|
||||
|
||||
def _dump(result: dict[str, Any]) -> str:
|
||||
return json.dumps(result, ensure_ascii=False, default=str)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# Generous timeout: the dedup check makes a separate LLM call, and large
|
||||
# scans can have many existing reports to compare against.
|
||||
_CVSS_FIELDS = (
|
||||
"attack_vector",
|
||||
"attack_complexity",
|
||||
"privileges_required",
|
||||
"user_interaction",
|
||||
"scope",
|
||||
"confidentiality",
|
||||
"integrity",
|
||||
"availability",
|
||||
)
|
||||
|
||||
|
||||
def _parse_cvss_xml(xml_str: str) -> dict[str, str] | None:
|
||||
if not xml_str or not xml_str.strip():
|
||||
return None
|
||||
result: dict[str, str] = {}
|
||||
for field in _CVSS_FIELDS:
|
||||
match = re.search(rf"<{field}>(.*?)</{field}>", xml_str, re.DOTALL)
|
||||
if match:
|
||||
result[field] = match.group(1).strip()
|
||||
return result if result else None
|
||||
|
||||
|
||||
def _parse_code_locations_xml(xml_str: str) -> list[dict[str, Any]] | None:
|
||||
if not xml_str or not xml_str.strip():
|
||||
return None
|
||||
locations: list[dict[str, Any]] = []
|
||||
for loc_match in re.finditer(r"<location>(.*?)</location>", xml_str, re.DOTALL):
|
||||
loc: dict[str, Any] = {}
|
||||
loc_content = loc_match.group(1)
|
||||
for field in (
|
||||
"file",
|
||||
"start_line",
|
||||
"end_line",
|
||||
"snippet",
|
||||
"label",
|
||||
"fix_before",
|
||||
"fix_after",
|
||||
):
|
||||
field_match = re.search(rf"<{field}>(.*?)</{field}>", loc_content, re.DOTALL)
|
||||
if field_match:
|
||||
raw = field_match.group(1)
|
||||
value = (
|
||||
raw.strip("\n")
|
||||
if field in ("snippet", "fix_before", "fix_after")
|
||||
else raw.strip()
|
||||
)
|
||||
if field in ("start_line", "end_line"):
|
||||
with contextlib.suppress(ValueError, TypeError):
|
||||
loc[field] = int(value)
|
||||
elif value:
|
||||
loc[field] = value
|
||||
if loc.get("file") and loc.get("start_line") is not None:
|
||||
locations.append(loc)
|
||||
return locations if locations else None
|
||||
|
||||
|
||||
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 _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(**kwargs: str) -> tuple[float, str, str]:
|
||||
try:
|
||||
from cvss import CVSS3
|
||||
|
||||
vector = (
|
||||
f"CVSS:3.1/AV:{kwargs['attack_vector']}/AC:{kwargs['attack_complexity']}/"
|
||||
f"PR:{kwargs['privileges_required']}/UI:{kwargs['user_interaction']}/"
|
||||
f"S:{kwargs['scope']}/C:{kwargs['confidentiality']}/"
|
||||
f"I:{kwargs['integrity']}/A:{kwargs['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",
|
||||
}
|
||||
|
||||
|
||||
_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"],
|
||||
}
|
||||
|
||||
|
||||
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: str,
|
||||
endpoint: str | None,
|
||||
method: str | None,
|
||||
cve: str | None,
|
||||
cwe: str | None,
|
||||
code_locations: str | 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)
|
||||
|
||||
parsed_cvss = _parse_cvss_xml(cvss_breakdown)
|
||||
if not parsed_cvss:
|
||||
errors.append("cvss: could not parse CVSS breakdown XML")
|
||||
else:
|
||||
for name, valid in _CVSS_VALID.items():
|
||||
value = parsed_cvss.get(name)
|
||||
if value not in valid:
|
||||
errors.append(f"Invalid {name}: {value}. Must be one of: {valid}")
|
||||
|
||||
parsed_locations = _parse_code_locations_xml(code_locations) if code_locations else None
|
||||
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, "message": "Validation failed", "errors": errors}
|
||||
|
||||
assert parsed_cvss is not None
|
||||
cvss_score, severity, _vector = _calculate_cvss(**parsed_cvss)
|
||||
|
||||
try:
|
||||
from strix.telemetry.tracer import get_global_tracer
|
||||
|
||||
tracer = get_global_tracer()
|
||||
if tracer is None:
|
||||
logger.warning("No global tracer; vulnerability report not persisted")
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"Vulnerability report '{title}' created (not persisted)",
|
||||
"warning": "Report could not be persisted - tracer unavailable",
|
||||
}
|
||||
|
||||
from strix.llm.dedupe import check_duplicate
|
||||
|
||||
existing = tracer.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 = 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,
|
||||
"message": (
|
||||
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 = tracer.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=parsed_cvss,
|
||||
endpoint=endpoint,
|
||||
method=method,
|
||||
cve=cve,
|
||||
cwe=cwe,
|
||||
code_locations=parsed_locations,
|
||||
)
|
||||
except (ImportError, AttributeError) as e:
|
||||
return {"success": False, "message": f"Failed to create vulnerability report: {e!s}"}
|
||||
else:
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"Vulnerability report '{title}' created successfully",
|
||||
"report_id": report_id,
|
||||
"severity": severity,
|
||||
"cvss_score": cvss_score,
|
||||
}
|
||||
|
||||
|
||||
# Generous timeout: the dedup check makes a separate LLM call, and
|
||||
# large scans can have many existing reports to compare against.
|
||||
@strix_tool(timeout=180)
|
||||
async def create_vulnerability_report(
|
||||
ctx: RunContextWrapper,
|
||||
@@ -52,39 +338,23 @@ async def create_vulnerability_report(
|
||||
The report is dedup-checked against existing reports (LLM-based
|
||||
similarity); if it's a near-duplicate, the call returns a
|
||||
``duplicate_of`` pointer instead of creating a new entry.
|
||||
|
||||
Args:
|
||||
title: Short headline (e.g. ``"Reflected XSS in /search?q="``).
|
||||
description: What the vuln is.
|
||||
impact: Concrete impact statement.
|
||||
target: Affected URL / host / service.
|
||||
technical_analysis: How it works.
|
||||
poc_description: Reproduction summary.
|
||||
poc_script_code: Working PoC (curl, python, etc.).
|
||||
remediation_steps: Recommended fix.
|
||||
cvss_breakdown: CVSS 3.1 vector parameters as XML (legacy schema).
|
||||
endpoint: Optional endpoint path.
|
||||
method: Optional HTTP method.
|
||||
cve: Optional CVE identifier.
|
||||
cwe: Optional CWE identifier.
|
||||
code_locations: Optional XML list of file/line references.
|
||||
"""
|
||||
return _dump(
|
||||
await asyncio.to_thread(
|
||||
_impl.create_vulnerability_report,
|
||||
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,
|
||||
),
|
||||
del ctx
|
||||
result = await asyncio.to_thread(
|
||||
_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,
|
||||
)
|
||||
return json.dumps(result, ensure_ascii=False, default=str)
|
||||
|
||||
@@ -1,157 +0,0 @@
|
||||
<tools>
|
||||
<tool name="terminal_execute">
|
||||
<description>Execute a bash command in a persistent terminal session. The terminal maintains state (environment variables, current directory, running processes) between commands.</description>
|
||||
<parameters>
|
||||
<parameter name="command" type="string" required="true">
|
||||
<description>The bash command to execute. Can be empty to check output of running commands (will wait for timeout period to collect output).
|
||||
|
||||
Supported special keys and sequences (based on official tmux key names):
|
||||
- Control sequences: C-c, C-d, C-z, C-a, C-e, C-k, C-l, C-u, C-w, etc. (also ^c, ^d, etc.)
|
||||
- Navigation keys: Up, Down, Left, Right, Home, End
|
||||
- Page keys: PageUp, PageDown, PgUp, PgDn, PPage, NPage
|
||||
- Function keys: F1, F2, F3, F4, F5, F6, F7, F8, F9, F10, F11, F12
|
||||
- Special keys: Enter, Escape, Space, Tab, BTab, BSpace, DC, IC
|
||||
- Note: Use official tmux names (BSpace not Backspace, DC not Delete, IC not Insert, Escape not Esc)
|
||||
- Meta/Alt sequences: M-key (e.g., M-f, M-b) - tmux official modifier
|
||||
- Shift sequences: S-key (e.g., S-F6, S-Tab, S-Left)
|
||||
- Combined modifiers: C-S-key, C-M-key, S-M-key, etc.
|
||||
|
||||
Special keys work automatically - no need to set is_input=true for keys like C-c, C-d, etc.
|
||||
These are useful for interacting with vim, emacs, REPLs, and other interactive applications.</description>
|
||||
</parameter>
|
||||
<parameter name="is_input" type="boolean" required="false">
|
||||
<description>If true, the command is sent as input to a currently running process. If false (default), the command is executed as a new bash command.
|
||||
Note: Special keys (C-c, C-d, etc.) automatically work when a process is running - you don't need to set is_input=true for them.
|
||||
Use is_input=true for regular text input to running processes.</description>
|
||||
</parameter>
|
||||
<parameter name="timeout" type="number" required="false">
|
||||
<description>Optional timeout in seconds for command execution. CAPPED AT 60 SECONDS. If not provided, uses default wait (30s). On timeout, the command keeps running and the tool returns with status 'running'. For truly long-running tasks, prefer backgrounding with '&'.</description>
|
||||
</parameter>
|
||||
<parameter name="terminal_id" type="string" required="false">
|
||||
<description>Identifier for the terminal session. Defaults to "default". Use different IDs to manage multiple concurrent terminal sessions.</description>
|
||||
</parameter>
|
||||
<parameter name="no_enter" type="boolean" required="false">
|
||||
<description>If true, don't automatically add Enter/newline after the command. Useful for:
|
||||
- Interactive prompts where you want to send keys without submitting
|
||||
- Navigation keys in full-screen applications
|
||||
|
||||
Examples:
|
||||
- terminal_execute("gg", is_input=true, no_enter=true) # Vim: go to top
|
||||
- terminal_execute("5j", is_input=true, no_enter=true) # Vim: move down 5 lines
|
||||
- terminal_execute("i", is_input=true, no_enter=true) # Vim: insert mode</description>
|
||||
</parameter>
|
||||
</parameters>
|
||||
<returns type="Dict[str, Any]">
|
||||
<description>Response containing:
|
||||
- content: Command output
|
||||
- exit_code: Exit code of the command (only for completed commands)
|
||||
- command: The executed command
|
||||
- terminal_id: The terminal session ID
|
||||
- status: Command status ('completed' or 'running')
|
||||
- working_dir: Current working directory after command execution</description>
|
||||
</returns>
|
||||
<notes>
|
||||
Important usage rules:
|
||||
1. PERSISTENT SESSION: The terminal maintains state between commands. Environment variables,
|
||||
current directory, and running processes persist across multiple tool calls.
|
||||
|
||||
2. COMMAND EXECUTION:
|
||||
- AVOID: Long pipelines, complex bash scripts, or convoluted one-liners
|
||||
- Break complex operations into multiple simple tool calls for clarity and debugging
|
||||
- For multiple commands, prefer separate tool calls over chaining with && or ;
|
||||
- Do NOT use this tool to run embedded Python via heredocs, here-strings, python -c, or ad hoc Python REPL input when python_action can be used instead
|
||||
- If the task is primarily Python code execution, data processing, HTTP automation in Python, or iterative Python scripting, use python_action because it is persistent, structured, and easier to debug
|
||||
- Use terminal_execute for actual shell work: CLI tools, package managers, file/system commands, process control, and starting or supervising services
|
||||
- Before improvising a complex workflow, payload set, protocol sequence, or tool syntax from memory, consider calling load_skill to inject the exact specialized guidance you need
|
||||
- Prefer load_skill plus the right tool over ad hoc shell experimentation when a relevant skill exists
|
||||
|
||||
3. LONG-RUNNING COMMANDS:
|
||||
- Commands never get killed automatically - they keep running in background
|
||||
- Set timeout to control how long to wait for output before returning
|
||||
- For daemons/servers or very long jobs, append '&' to run in background
|
||||
- Use empty command "" to check progress (waits for timeout period to collect output)
|
||||
- Use C-c, C-d, C-z to interrupt processes (works automatically, no is_input needed)
|
||||
|
||||
4. TIMEOUT HANDLING:
|
||||
- Timeout controls how long to wait before returning current output (max 60s cap)
|
||||
- Commands are NEVER killed on timeout - they keep running
|
||||
- After timeout, you can run new commands or check progress with empty command
|
||||
- On timeout, status is 'running'; on completion, status is 'completed'
|
||||
|
||||
5. MULTIPLE TERMINALS: Use different terminal_id values to run multiple concurrent sessions.
|
||||
|
||||
6. INTERACTIVE PROCESSES:
|
||||
- Special keys (C-c, C-d, etc.) work automatically when a process is running
|
||||
- Use is_input=true for regular text input to running processes like:
|
||||
* Interactive shells, REPLs, or prompts
|
||||
* Long-running applications waiting for input
|
||||
* Background processes that need interaction
|
||||
- Use no_enter=true for stuff like Vim navigation, password typing, or multi-step commands
|
||||
|
||||
7. WORKING DIRECTORY: The terminal tracks and returns the current working directory.
|
||||
Use absolute paths or cd commands to change directories as needed.
|
||||
|
||||
8. OUTPUT HANDLING: Large outputs are automatically truncated. The tool provides
|
||||
the most relevant parts of the output for analysis.
|
||||
</notes>
|
||||
<examples>
|
||||
# Execute a simple command
|
||||
<function=terminal_execute>
|
||||
<parameter=command>ls -la</parameter>
|
||||
</function>
|
||||
|
||||
<function=terminal_execute>
|
||||
<parameter=command>cd /workspace
|
||||
pwd
|
||||
ls -la</parameter>
|
||||
</function>
|
||||
|
||||
# Run a command with custom timeout
|
||||
<function=terminal_execute>
|
||||
<parameter=command>npm install</parameter>
|
||||
<parameter=timeout>60</parameter>
|
||||
</function>
|
||||
|
||||
# Check progress of running command (waits for timeout to collect output)
|
||||
<function=terminal_execute>
|
||||
<parameter=command></parameter>
|
||||
<parameter=timeout>5</parameter>
|
||||
</function>
|
||||
|
||||
# Start a background service
|
||||
<function=terminal_execute>
|
||||
<parameter=command>python app.py > server.log 2>&1 &</parameter>
|
||||
</function>
|
||||
|
||||
# Interact with a running process
|
||||
<function=terminal_execute>
|
||||
<parameter=command>y</parameter>
|
||||
<parameter=is_input>true</parameter>
|
||||
</function>
|
||||
|
||||
# Interrupt a running process (special keys work automatically)
|
||||
<function=terminal_execute>
|
||||
<parameter=command>C-c</parameter>
|
||||
</function>
|
||||
|
||||
# Send Escape key (use official tmux name)
|
||||
<function=terminal_execute>
|
||||
<parameter=command>Escape</parameter>
|
||||
<parameter=is_input>true</parameter>
|
||||
</function>
|
||||
|
||||
# Use a different terminal session
|
||||
<function=terminal_execute>
|
||||
<parameter=command>python3</parameter>
|
||||
<parameter=terminal_id>python_session</parameter>
|
||||
</function>
|
||||
|
||||
# Send input to Python REPL in specific session
|
||||
<function=terminal_execute>
|
||||
<parameter=command>print("Hello World")</parameter>
|
||||
<parameter=is_input>true</parameter>
|
||||
<parameter=terminal_id>python_session</parameter>
|
||||
</function>
|
||||
</examples>
|
||||
</tool>
|
||||
</tools>
|
||||
@@ -1,4 +1,4 @@
|
||||
from .thinking_actions import think
|
||||
from .tool import think
|
||||
|
||||
|
||||
__all__ = ["think"]
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
from typing import Any
|
||||
|
||||
from strix.tools.registry import register_tool
|
||||
|
||||
|
||||
@register_tool(sandbox_execution=False)
|
||||
def think(thought: str) -> dict[str, Any]:
|
||||
try:
|
||||
if not thought or not thought.strip():
|
||||
return {"success": False, "message": "Thought cannot be empty"}
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"Thought recorded successfully with {len(thought.strip())} characters",
|
||||
}
|
||||
|
||||
except (ValueError, TypeError) as e:
|
||||
return {"success": False, "message": f"Failed to record thought: {e!s}"}
|
||||
@@ -1,54 +0,0 @@
|
||||
<tools>
|
||||
<tool name="think">
|
||||
<description>Use the tool to think about something. It will not obtain new information or change the
|
||||
database. Use it when complex reasoning or some cache memory is needed.</description>
|
||||
<details>This tool creates dedicated space for structured thinking during complex tasks,
|
||||
particularly useful for:
|
||||
- Tool output analysis: When you need to carefully process the output of previous tool calls
|
||||
- Policy-heavy environments: When you need to follow detailed guidelines and verify compliance
|
||||
- Sequential decision making: When each action builds on previous ones and mistakes are costly
|
||||
- Multi-step problem solving: When you need to break down complex problems into manageable steps</details>
|
||||
<parameters>
|
||||
<parameter name="thought" type="string" required="true">
|
||||
<description>The thought or reasoning to record</description>
|
||||
</parameter>
|
||||
</parameters>
|
||||
<returns type="Dict[str, Any]">
|
||||
<description>Response containing: - success: Whether the thought was recorded successfully - message: Confirmation message with character count or error details</description>
|
||||
</returns>
|
||||
<examples>
|
||||
# Planning and strategy
|
||||
<function=think>
|
||||
<parameter=thought>Analysis of the login endpoint SQL injection:
|
||||
|
||||
Current State:
|
||||
- Confirmed SQL injection in POST /api/v1/auth/login
|
||||
- Backend database is PostgreSQL 14.2
|
||||
- Application user has full CRUD privileges
|
||||
|
||||
Exploitation Strategy:
|
||||
1. First, enumerate database structure using UNION-based injection
|
||||
2. Extract user table schema and credentials
|
||||
3. Check for password hashing (MD5? bcrypt?)
|
||||
4. Look for admin accounts and API keys
|
||||
|
||||
Risk Assessment:
|
||||
- CVSS Base Score: 9.8 (Critical)
|
||||
- Attack Vector: Network (remotely exploitable)
|
||||
- Privileges Required: None
|
||||
- Impact: Full database compromise
|
||||
|
||||
Evidence Collected:
|
||||
- Error-based injection confirms PostgreSQL
|
||||
- Time-based payload: admin' AND pg_sleep(5)-- caused 5s delay
|
||||
- UNION injection reveals 8 columns in users table
|
||||
|
||||
Next Actions:
|
||||
1. Write PoC exploit script in Python
|
||||
2. Extract password hashes for analysis
|
||||
3. Create vulnerability report with full details
|
||||
4. Test if same vulnerability exists in other endpoints</parameter>
|
||||
</function>
|
||||
</examples>
|
||||
</tool>
|
||||
</tools>
|
||||
@@ -1,19 +1,10 @@
|
||||
"""SDK function-tool wrapper for the legacy ``think`` tool.
|
||||
|
||||
Pattern: thin async wrapper that delegates to the legacy implementation
|
||||
in :mod:`strix.tools.thinking.thinking_actions`. The legacy function is
|
||||
sync and pure (no I/O), so we don't even need ``asyncio.to_thread``.
|
||||
|
||||
Validates the simplest tool-port pattern: legacy function in, JSON string
|
||||
out, no sandbox involvement.
|
||||
"""
|
||||
"""``think`` — record a private chain-of-thought note with no side effects."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
from strix.tools._decorator import strix_tool
|
||||
from strix.tools.thinking.thinking_actions import think as _legacy_think
|
||||
|
||||
|
||||
@strix_tool(timeout=10)
|
||||
@@ -28,5 +19,12 @@ async def think(thought: str) -> str:
|
||||
Args:
|
||||
thought: The agent's reasoning to record. Must be non-empty.
|
||||
"""
|
||||
result = _legacy_think(thought)
|
||||
return json.dumps(result, ensure_ascii=False)
|
||||
if not thought or not thought.strip():
|
||||
return json.dumps({"success": False, "message": "Thought cannot be empty"})
|
||||
return json.dumps(
|
||||
{
|
||||
"success": True,
|
||||
"message": (f"Thought recorded successfully with {len(thought.strip())} characters"),
|
||||
},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from .todo_actions import (
|
||||
from .tools import (
|
||||
create_todo,
|
||||
delete_todo,
|
||||
list_todos,
|
||||
|
||||
@@ -1,568 +0,0 @@
|
||||
import json
|
||||
import uuid
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
from strix.tools.registry import register_tool
|
||||
|
||||
|
||||
VALID_PRIORITIES = ["low", "normal", "high", "critical"]
|
||||
VALID_STATUSES = ["pending", "in_progress", "done"]
|
||||
|
||||
_todos_storage: dict[str, dict[str, dict[str, Any]]] = {}
|
||||
|
||||
|
||||
def _get_agent_todos(agent_id: str) -> dict[str, dict[str, Any]]:
|
||||
if agent_id not in _todos_storage:
|
||||
_todos_storage[agent_id] = {}
|
||||
return _todos_storage[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]]:
|
||||
agent_todos = _get_agent_todos(agent_id)
|
||||
|
||||
todos_list: list[dict[str, Any]] = []
|
||||
for todo_id, todo in agent_todos.items():
|
||||
entry = todo.copy()
|
||||
entry["todo_id"] = todo_id
|
||||
todos_list.append(entry)
|
||||
|
||||
priority_order = {"critical": 0, "high": 1, "normal": 2, "low": 3}
|
||||
status_order = {"done": 0, "in_progress": 1, "pending": 2}
|
||||
|
||||
todos_list.sort(
|
||||
key=lambda x: (
|
||||
status_order.get(x.get("status", "pending"), 99),
|
||||
priority_order.get(x.get("priority", "normal"), 99),
|
||||
x.get("created_at", ""),
|
||||
)
|
||||
)
|
||||
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 = 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 = 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
|
||||
|
||||
|
||||
@register_tool(sandbox_execution=False)
|
||||
def create_todo(
|
||||
agent_state: Any,
|
||||
title: str | None = None,
|
||||
description: str | None = None,
|
||||
priority: str = "normal",
|
||||
todos: Any | None = None,
|
||||
) -> dict[str, Any]:
|
||||
try:
|
||||
agent_id = agent_state.agent_id
|
||||
default_priority = _normalize_priority(priority)
|
||||
|
||||
tasks_to_create: list[dict[str, Any]] = []
|
||||
|
||||
if todos is not None:
|
||||
tasks_to_create.extend(_normalize_bulk_todos(todos))
|
||||
|
||||
if title and title.strip():
|
||||
tasks_to_create.append(
|
||||
{
|
||||
"title": title.strip(),
|
||||
"description": description.strip() if description else None,
|
||||
"priority": default_priority,
|
||||
}
|
||||
)
|
||||
|
||||
if not tasks_to_create:
|
||||
return {
|
||||
"success": False,
|
||||
"error": "Provide a title or 'todos' list to create.",
|
||||
"todo_id": None,
|
||||
}
|
||||
|
||||
agent_todos = _get_agent_todos(agent_id)
|
||||
created: list[dict[str, Any]] = []
|
||||
|
||||
for task in tasks_to_create:
|
||||
task_priority = _normalize_priority(task.get("priority"), default_priority)
|
||||
todo_id = str(uuid.uuid4())[:6]
|
||||
timestamp = datetime.now(UTC).isoformat()
|
||||
|
||||
todo = {
|
||||
"title": task["title"],
|
||||
"description": task.get("description"),
|
||||
"priority": task_priority,
|
||||
"status": "pending",
|
||||
"created_at": timestamp,
|
||||
"updated_at": timestamp,
|
||||
"completed_at": None,
|
||||
}
|
||||
|
||||
agent_todos[todo_id] = todo
|
||||
created.append(
|
||||
{
|
||||
"todo_id": todo_id,
|
||||
"title": task["title"],
|
||||
"priority": task_priority,
|
||||
}
|
||||
)
|
||||
|
||||
except (ValueError, TypeError) as e:
|
||||
return {"success": False, "error": f"Failed to create todo: {e}", "todo_id": None}
|
||||
else:
|
||||
todos_list = _sorted_todos(agent_id)
|
||||
|
||||
response: dict[str, Any] = {
|
||||
"success": True,
|
||||
"created": created,
|
||||
"count": len(created),
|
||||
"todos": todos_list,
|
||||
"total_count": len(todos_list),
|
||||
}
|
||||
return response
|
||||
|
||||
|
||||
@register_tool(sandbox_execution=False)
|
||||
def list_todos(
|
||||
agent_state: Any,
|
||||
status: str | None = None,
|
||||
priority: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
try:
|
||||
agent_id = agent_state.agent_id
|
||||
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 = []
|
||||
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
|
||||
|
||||
todo_with_id = todo.copy()
|
||||
todo_with_id["todo_id"] = todo_id
|
||||
todos_list.append(todo_with_id)
|
||||
|
||||
priority_order = {"critical": 0, "high": 1, "normal": 2, "low": 3}
|
||||
status_order = {"done": 0, "in_progress": 1, "pending": 2}
|
||||
|
||||
todos_list.sort(
|
||||
key=lambda x: (
|
||||
status_order.get(x.get("status", "pending"), 99),
|
||||
priority_order.get(x.get("priority", "normal"), 99),
|
||||
x.get("created_at", ""),
|
||||
)
|
||||
)
|
||||
|
||||
summary_counts = {
|
||||
"pending": 0,
|
||||
"in_progress": 0,
|
||||
"done": 0,
|
||||
}
|
||||
for todo in todos_list:
|
||||
status_value = todo.get("status", "pending")
|
||||
if status_value not in summary_counts:
|
||||
summary_counts[status_value] = 0
|
||||
summary_counts[status_value] += 1
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"todos": todos_list,
|
||||
"total_count": len(todos_list),
|
||||
"summary": summary_counts,
|
||||
}
|
||||
|
||||
except (ValueError, TypeError) as e:
|
||||
return {
|
||||
"success": False,
|
||||
"error": f"Failed to list todos: {e}",
|
||||
"todos": [],
|
||||
"total_count": 0,
|
||||
"summary": {"pending": 0, "in_progress": 0, "done": 0},
|
||||
}
|
||||
|
||||
|
||||
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
|
||||
if status_candidate == "done":
|
||||
todo["completed_at"] = datetime.now(UTC).isoformat()
|
||||
else:
|
||||
todo["completed_at"] = None
|
||||
|
||||
todo["updated_at"] = datetime.now(UTC).isoformat()
|
||||
return None
|
||||
|
||||
|
||||
@register_tool(sandbox_execution=False)
|
||||
def update_todo(
|
||||
agent_state: Any,
|
||||
todo_id: str | None = None,
|
||||
title: str | None = None,
|
||||
description: str | None = None,
|
||||
priority: str | None = None,
|
||||
status: str | None = None,
|
||||
updates: Any | None = None,
|
||||
) -> dict[str, Any]:
|
||||
try:
|
||||
agent_id = agent_state.agent_id
|
||||
agent_todos = _get_agent_todos(agent_id)
|
||||
|
||||
updates_to_apply: list[dict[str, Any]] = []
|
||||
|
||||
if updates is not None:
|
||||
updates_to_apply.extend(_normalize_bulk_updates(updates))
|
||||
|
||||
if todo_id is not None:
|
||||
updates_to_apply.append(
|
||||
{
|
||||
"todo_id": todo_id,
|
||||
"title": title,
|
||||
"description": description,
|
||||
"priority": priority,
|
||||
"status": status,
|
||||
}
|
||||
)
|
||||
|
||||
if not updates_to_apply:
|
||||
return {
|
||||
"success": False,
|
||||
"error": "Provide todo_id or 'updates' list to update.",
|
||||
}
|
||||
|
||||
updated: list[str] = []
|
||||
errors: list[dict[str, Any]] = []
|
||||
|
||||
for update in updates_to_apply:
|
||||
error = _apply_single_update(
|
||||
agent_todos,
|
||||
update["todo_id"],
|
||||
update.get("title"),
|
||||
update.get("description"),
|
||||
update.get("priority"),
|
||||
update.get("status"),
|
||||
)
|
||||
if error:
|
||||
errors.append(error)
|
||||
else:
|
||||
updated.append(update["todo_id"])
|
||||
|
||||
todos_list = _sorted_todos(agent_id)
|
||||
|
||||
response: dict[str, Any] = {
|
||||
"success": len(errors) == 0,
|
||||
"updated": updated,
|
||||
"updated_count": len(updated),
|
||||
"todos": todos_list,
|
||||
"total_count": len(todos_list),
|
||||
}
|
||||
|
||||
if errors:
|
||||
response["errors"] = errors
|
||||
|
||||
except (ValueError, TypeError) as e:
|
||||
return {"success": False, "error": str(e)}
|
||||
else:
|
||||
return response
|
||||
|
||||
|
||||
@register_tool(sandbox_execution=False)
|
||||
def mark_todo_done(
|
||||
agent_state: Any,
|
||||
todo_id: str | None = None,
|
||||
todo_ids: Any | None = None,
|
||||
) -> dict[str, Any]:
|
||||
try:
|
||||
agent_id = agent_state.agent_id
|
||||
agent_todos = _get_agent_todos(agent_id)
|
||||
|
||||
ids_to_mark: list[str] = []
|
||||
if todo_ids is not None:
|
||||
ids_to_mark.extend(_normalize_todo_ids(todo_ids))
|
||||
if todo_id is not None:
|
||||
ids_to_mark.append(todo_id)
|
||||
|
||||
if not ids_to_mark:
|
||||
return {"success": False, "error": "Provide todo_id or todo_ids to mark as done."}
|
||||
|
||||
marked: list[str] = []
|
||||
errors: list[dict[str, Any]] = []
|
||||
timestamp = datetime.now(UTC).isoformat()
|
||||
|
||||
for tid in ids_to_mark:
|
||||
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"] = "done"
|
||||
todo["completed_at"] = timestamp
|
||||
todo["updated_at"] = timestamp
|
||||
marked.append(tid)
|
||||
|
||||
todos_list = _sorted_todos(agent_id)
|
||||
|
||||
response: dict[str, Any] = {
|
||||
"success": len(errors) == 0,
|
||||
"marked_done": marked,
|
||||
"marked_count": len(marked),
|
||||
"todos": todos_list,
|
||||
"total_count": len(todos_list),
|
||||
}
|
||||
|
||||
if errors:
|
||||
response["errors"] = errors
|
||||
|
||||
except (ValueError, TypeError) as e:
|
||||
return {"success": False, "error": str(e)}
|
||||
else:
|
||||
return response
|
||||
|
||||
|
||||
@register_tool(sandbox_execution=False)
|
||||
def mark_todo_pending(
|
||||
agent_state: Any,
|
||||
todo_id: str | None = None,
|
||||
todo_ids: Any | None = None,
|
||||
) -> dict[str, Any]:
|
||||
try:
|
||||
agent_id = agent_state.agent_id
|
||||
agent_todos = _get_agent_todos(agent_id)
|
||||
|
||||
ids_to_mark: list[str] = []
|
||||
if todo_ids is not None:
|
||||
ids_to_mark.extend(_normalize_todo_ids(todo_ids))
|
||||
if todo_id is not None:
|
||||
ids_to_mark.append(todo_id)
|
||||
|
||||
if not ids_to_mark:
|
||||
return {"success": False, "error": "Provide todo_id or todo_ids to mark as pending."}
|
||||
|
||||
marked: list[str] = []
|
||||
errors: list[dict[str, Any]] = []
|
||||
timestamp = datetime.now(UTC).isoformat()
|
||||
|
||||
for tid in ids_to_mark:
|
||||
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"] = "pending"
|
||||
todo["completed_at"] = None
|
||||
todo["updated_at"] = timestamp
|
||||
marked.append(tid)
|
||||
|
||||
todos_list = _sorted_todos(agent_id)
|
||||
|
||||
response: dict[str, Any] = {
|
||||
"success": len(errors) == 0,
|
||||
"marked_pending": marked,
|
||||
"marked_count": len(marked),
|
||||
"todos": todos_list,
|
||||
"total_count": len(todos_list),
|
||||
}
|
||||
|
||||
if errors:
|
||||
response["errors"] = errors
|
||||
|
||||
except (ValueError, TypeError) as e:
|
||||
return {"success": False, "error": str(e)}
|
||||
else:
|
||||
return response
|
||||
|
||||
|
||||
@register_tool(sandbox_execution=False)
|
||||
def delete_todo(
|
||||
agent_state: Any,
|
||||
todo_id: str | None = None,
|
||||
todo_ids: Any | None = None,
|
||||
) -> dict[str, Any]:
|
||||
try:
|
||||
agent_id = agent_state.agent_id
|
||||
agent_todos = _get_agent_todos(agent_id)
|
||||
|
||||
ids_to_delete: list[str] = []
|
||||
if todo_ids is not None:
|
||||
ids_to_delete.extend(_normalize_todo_ids(todo_ids))
|
||||
if todo_id is not None:
|
||||
ids_to_delete.append(todo_id)
|
||||
|
||||
if not ids_to_delete:
|
||||
return {"success": False, "error": "Provide todo_id or todo_ids to delete."}
|
||||
|
||||
deleted: list[str] = []
|
||||
errors: list[dict[str, Any]] = []
|
||||
|
||||
for tid in ids_to_delete:
|
||||
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)
|
||||
|
||||
todos_list = _sorted_todos(agent_id)
|
||||
|
||||
response: dict[str, Any] = {
|
||||
"success": len(errors) == 0,
|
||||
"deleted": deleted,
|
||||
"deleted_count": len(deleted),
|
||||
"todos": todos_list,
|
||||
"total_count": len(todos_list),
|
||||
}
|
||||
|
||||
if errors:
|
||||
response["errors"] = errors
|
||||
|
||||
except (ValueError, TypeError) as e:
|
||||
return {"success": False, "error": str(e)}
|
||||
else:
|
||||
return response
|
||||
@@ -1,225 +0,0 @@
|
||||
<tools>
|
||||
<important>
|
||||
The todo tool is available for organizing complex tasks when needed. Each subagent has their own
|
||||
separate todo list - your todos are private to you and do not interfere with other agents' todos.
|
||||
|
||||
WHEN TO USE TODOS:
|
||||
- Planning complex multi-step operations
|
||||
- Tracking multiple parallel workstreams
|
||||
- When you need to remember tasks to return to later
|
||||
- Organizing large-scope assessments with many components
|
||||
|
||||
WHEN NOT NEEDED:
|
||||
- Simple, straightforward tasks
|
||||
- Linear workflows where progress is obvious
|
||||
- Short tasks that can be completed quickly
|
||||
|
||||
If you do use todos, batch operations together to minimize tool calls.
|
||||
</important>
|
||||
|
||||
<tool name="create_todo">
|
||||
<description>Create a new todo item to track tasks, goals, and progress.</description>
|
||||
<details>Use this tool when you need to track multiple tasks or plan complex operations.
|
||||
Each subagent maintains their own independent todo list - your todos are yours alone.
|
||||
|
||||
Useful for breaking down complex tasks into smaller, manageable items when the workflow
|
||||
is non-trivial or when you need to track progress across multiple components.</details>
|
||||
<parameters>
|
||||
<parameter name="title" type="string" required="false">
|
||||
<description>Short, actionable title for the todo (e.g., "Test login endpoint for SQL injection")</description>
|
||||
</parameter>
|
||||
<parameter name="todos" type="string" required="false">
|
||||
<description>Create multiple todos at once. Provide a JSON array of {"title": "...", "description": "...", "priority": "..."} objects or a newline-separated bullet list.</description>
|
||||
</parameter>
|
||||
<parameter name="description" type="string" required="false">
|
||||
<description>Detailed description or notes about the task</description>
|
||||
</parameter>
|
||||
<parameter name="priority" type="string" required="false">
|
||||
<description>Priority level: "low", "normal", "high", "critical" (default: "normal")</description>
|
||||
</parameter>
|
||||
</parameters>
|
||||
<returns type="Dict[str, Any]">
|
||||
<description>Response containing: - created: List of created todos with their IDs - todos: Full sorted todo list - success: Whether the operation succeeded</description>
|
||||
</returns>
|
||||
<examples>
|
||||
# Create a high priority todo
|
||||
<function=create_todo>
|
||||
<parameter=title>Test authentication bypass on /api/admin</parameter>
|
||||
<parameter=description>The admin endpoint seems to have weak authentication. Try JWT manipulation, session fixation, and privilege escalation.</parameter>
|
||||
<parameter=priority>high</parameter>
|
||||
</function>
|
||||
|
||||
# Create a simple todo
|
||||
<function=create_todo>
|
||||
<parameter=title>Enumerate all API endpoints</parameter>
|
||||
</function>
|
||||
|
||||
# Bulk create todos (JSON array)
|
||||
<function=create_todo>
|
||||
<parameter=todos>[{"title": "Map all admin routes", "priority": "high"}, {"title": "Check forgotten password flow"}]</parameter>
|
||||
</function>
|
||||
|
||||
# Bulk create todos (bullet list)
|
||||
<function=create_todo>
|
||||
<parameter=todos>
|
||||
- Capture baseline traffic in proxy
|
||||
- Enumerate S3 buckets for leaked assets
|
||||
- Compare responses for timing differences
|
||||
</parameter>
|
||||
</function>
|
||||
</examples>
|
||||
</tool>
|
||||
|
||||
<tool name="list_todos">
|
||||
<description>List all todos with optional filtering by status or priority.</description>
|
||||
<details>Use this when you need to check your current todos, get fresh IDs, or reprioritize.
|
||||
The list is sorted: done first, then in_progress, then pending. Within each status, sorted by priority (critical > high > normal > low).
|
||||
Each subagent has their own independent todo list.</details>
|
||||
<parameters>
|
||||
<parameter name="status" type="string" required="false">
|
||||
<description>Filter by status: "pending", "in_progress", "done"</description>
|
||||
</parameter>
|
||||
<parameter name="priority" type="string" required="false">
|
||||
<description>Filter by priority: "low", "normal", "high", "critical"</description>
|
||||
</parameter>
|
||||
</parameters>
|
||||
<returns type="Dict[str, Any]">
|
||||
<description>Response containing: - todos: List of todo items - total_count: Total number of todos - summary: Count by status (pending, in_progress, done)</description>
|
||||
</returns>
|
||||
<examples>
|
||||
# List all todos
|
||||
<function=list_todos>
|
||||
</function>
|
||||
|
||||
# List only pending todos
|
||||
<function=list_todos>
|
||||
<parameter=status>pending</parameter>
|
||||
</function>
|
||||
|
||||
# List high priority items
|
||||
<function=list_todos>
|
||||
<parameter=priority>high</parameter>
|
||||
</function>
|
||||
</examples>
|
||||
</tool>
|
||||
|
||||
<tool name="update_todo">
|
||||
<description>Update one or multiple todo items. Prefer bulk updates in a single call when updating multiple items.</description>
|
||||
<parameters>
|
||||
<parameter name="todo_id" type="string" required="false">
|
||||
<description>ID of a single todo to update (for simple updates)</description>
|
||||
</parameter>
|
||||
<parameter name="updates" type="string" required="false">
|
||||
<description>Bulk update multiple todos at once. JSON array of objects with todo_id and fields to update: [{"todo_id": "abc", "status": "done"}, {"todo_id": "def", "priority": "high"}].</description>
|
||||
</parameter>
|
||||
<parameter name="title" type="string" required="false">
|
||||
<description>New title (used with todo_id)</description>
|
||||
</parameter>
|
||||
<parameter name="description" type="string" required="false">
|
||||
<description>New description (used with todo_id)</description>
|
||||
</parameter>
|
||||
<parameter name="priority" type="string" required="false">
|
||||
<description>New priority: "low", "normal", "high", "critical" (used with todo_id)</description>
|
||||
</parameter>
|
||||
<parameter name="status" type="string" required="false">
|
||||
<description>New status: "pending", "in_progress", "done" (used with todo_id)</description>
|
||||
</parameter>
|
||||
</parameters>
|
||||
<returns type="Dict[str, Any]">
|
||||
<description>Response containing: - updated: List of updated todo IDs - updated_count: Number updated - todos: Full sorted todo list - errors: Any failed updates</description>
|
||||
</returns>
|
||||
<examples>
|
||||
# Single update
|
||||
<function=update_todo>
|
||||
<parameter=todo_id>abc123</parameter>
|
||||
<parameter=status>in_progress</parameter>
|
||||
</function>
|
||||
|
||||
# Bulk update - mark multiple todos with different statuses in ONE call
|
||||
<function=update_todo>
|
||||
<parameter=updates>[{"todo_id": "abc123", "status": "done"}, {"todo_id": "def456", "status": "in_progress"}, {"todo_id": "ghi789", "priority": "critical"}]</parameter>
|
||||
</function>
|
||||
</examples>
|
||||
</tool>
|
||||
|
||||
<tool name="mark_todo_done">
|
||||
<description>Mark one or multiple todos as completed in a single call.</description>
|
||||
<details>Mark todos as done after completing them. Group multiple completions into one call using todo_ids when possible.</details>
|
||||
<parameters>
|
||||
<parameter name="todo_id" type="string" required="false">
|
||||
<description>ID of a single todo to mark as done</description>
|
||||
</parameter>
|
||||
<parameter name="todo_ids" type="string" required="false">
|
||||
<description>Mark multiple todos done at once. JSON array of IDs: ["abc123", "def456"] or comma-separated: "abc123, def456"</description>
|
||||
</parameter>
|
||||
</parameters>
|
||||
<returns type="Dict[str, Any]">
|
||||
<description>Response containing: - marked_done: List of IDs marked done - marked_count: Number marked - todos: Full sorted list - errors: Any failures</description>
|
||||
</returns>
|
||||
<examples>
|
||||
# Mark single todo done
|
||||
<function=mark_todo_done>
|
||||
<parameter=todo_id>abc123</parameter>
|
||||
</function>
|
||||
|
||||
# Mark multiple todos done in ONE call
|
||||
<function=mark_todo_done>
|
||||
<parameter=todo_ids>["abc123", "def456", "ghi789"]</parameter>
|
||||
</function>
|
||||
</examples>
|
||||
</tool>
|
||||
|
||||
<tool name="mark_todo_pending">
|
||||
<description>Mark one or multiple todos as pending (reopen completed tasks).</description>
|
||||
<details>Use this to reopen tasks that were marked done but need more work. Supports bulk operations.</details>
|
||||
<parameters>
|
||||
<parameter name="todo_id" type="string" required="false">
|
||||
<description>ID of a single todo to mark as pending</description>
|
||||
</parameter>
|
||||
<parameter name="todo_ids" type="string" required="false">
|
||||
<description>Mark multiple todos pending at once. JSON array of IDs: ["abc123", "def456"] or comma-separated: "abc123, def456"</description>
|
||||
</parameter>
|
||||
</parameters>
|
||||
<returns type="Dict[str, Any]">
|
||||
<description>Response containing: - marked_pending: List of IDs marked pending - marked_count: Number marked - todos: Full sorted list - errors: Any failures</description>
|
||||
</returns>
|
||||
<examples>
|
||||
# Mark single todo pending
|
||||
<function=mark_todo_pending>
|
||||
<parameter=todo_id>abc123</parameter>
|
||||
</function>
|
||||
|
||||
# Mark multiple todos pending in ONE call
|
||||
<function=mark_todo_pending>
|
||||
<parameter=todo_ids>["abc123", "def456"]</parameter>
|
||||
</function>
|
||||
</examples>
|
||||
</tool>
|
||||
|
||||
<tool name="delete_todo">
|
||||
<description>Delete one or multiple todos in a single call.</description>
|
||||
<details>Use this to remove todos that are no longer relevant. Supports bulk deletion to save tool calls.</details>
|
||||
<parameters>
|
||||
<parameter name="todo_id" type="string" required="false">
|
||||
<description>ID of a single todo to delete</description>
|
||||
</parameter>
|
||||
<parameter name="todo_ids" type="string" required="false">
|
||||
<description>Delete multiple todos at once. JSON array of IDs: ["abc123", "def456"] or comma-separated: "abc123, def456"</description>
|
||||
</parameter>
|
||||
</parameters>
|
||||
<returns type="Dict[str, Any]">
|
||||
<description>Response containing: - deleted: List of deleted IDs - deleted_count: Number deleted - todos: Remaining todos - errors: Any failures</description>
|
||||
</returns>
|
||||
<examples>
|
||||
# Delete single todo
|
||||
<function=delete_todo>
|
||||
<parameter=todo_id>abc123</parameter>
|
||||
</function>
|
||||
|
||||
# Delete multiple todos in ONE call
|
||||
<function=delete_todo>
|
||||
<parameter=todo_ids>["abc123", "def456", "ghi789"]</parameter>
|
||||
</function>
|
||||
</examples>
|
||||
</tool>
|
||||
</tools>
|
||||
+423
-67
@@ -1,32 +1,208 @@
|
||||
"""SDK function-tool wrappers for the legacy todo tools.
|
||||
"""Per-agent todo tools.
|
||||
|
||||
Six tools, all in-memory, all per-agent (keyed by ``ctx.context["agent_id"]``
|
||||
through :class:`LegacyAgentStateAdapter`). Bulk forms are preserved —
|
||||
``todos`` / ``updates`` / ``todo_ids`` accept JSON strings or comma-separated
|
||||
strings the same way the legacy XML schema documented.
|
||||
|
||||
Pattern: thin async wrappers that delegate to the legacy implementations
|
||||
in :mod:`strix.tools.todo.todo_actions`. Legacy code is untouched.
|
||||
In-memory only — todos live for the lifetime of one scan, scoped per
|
||||
agent via ``ctx.context['agent_id']``. Bulk forms are preserved so the
|
||||
prompt-template documentation still works (``todos`` / ``updates`` /
|
||||
``todo_ids`` accept JSON strings or comma-separated strings).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import uuid
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
from agents import RunContextWrapper
|
||||
|
||||
from strix.tools._decorator import strix_tool
|
||||
from strix.tools._state_adapter import adapter_from_ctx
|
||||
from strix.tools.todo import todo_actions as _impl
|
||||
|
||||
|
||||
VALID_PRIORITIES = ["low", "normal", "high", "critical"]
|
||||
VALID_STATUSES = ["pending", "in_progress", "done"]
|
||||
|
||||
|
||||
# Per-agent silo: ``_todos_storage[agent_id][todo_id] = todo_dict``.
|
||||
# Keyed by ``ctx.context['agent_id']`` so two agents in the same scan
|
||||
# don't see each other's lists.
|
||||
_todos_storage: dict[str, dict[str, dict[str, Any]]] = {}
|
||||
|
||||
|
||||
def _dump(result: dict[str, Any]) -> str:
|
||||
"""JSON-dump a legacy result dict for the model. ``ensure_ascii=False``
|
||||
so unicode flows through; ``default=str`` to handle stray datetimes."""
|
||||
return json.dumps(result, ensure_ascii=False, default=str)
|
||||
|
||||
|
||||
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]]:
|
||||
agent_todos = _get_agent_todos(agent_id)
|
||||
todos_list: list[dict[str, Any]] = []
|
||||
for todo_id, todo in agent_todos.items():
|
||||
entry = todo.copy()
|
||||
entry["todo_id"] = todo_id
|
||||
todos_list.append(entry)
|
||||
|
||||
priority_order = {"critical": 0, "high": 1, "normal": 2, "low": 3}
|
||||
status_order = {"done": 0, "in_progress": 1, "pending": 2}
|
||||
todos_list.sort(
|
||||
key=lambda x: (
|
||||
status_order.get(x.get("status", "pending"), 99),
|
||||
priority_order.get(x.get("priority", "normal"), 99),
|
||||
x.get("created_at", ""),
|
||||
),
|
||||
)
|
||||
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
|
||||
|
||||
|
||||
# --- public tools ---------------------------------------------------------
|
||||
|
||||
|
||||
@strix_tool(timeout=30)
|
||||
async def create_todo(
|
||||
ctx: RunContextWrapper,
|
||||
@@ -35,23 +211,57 @@ async def create_todo(
|
||||
priority: str = "normal",
|
||||
todos: str | None = None,
|
||||
) -> str:
|
||||
"""Create one or many todos for the current agent.
|
||||
"""Create one or many todos for the current agent."""
|
||||
agent_id = _agent_id_from(ctx)
|
||||
try:
|
||||
default_priority = _normalize_priority(priority)
|
||||
tasks: list[dict[str, Any]] = []
|
||||
if todos is not None:
|
||||
tasks.extend(_normalize_bulk_todos(todos))
|
||||
if title and title.strip():
|
||||
tasks.append(
|
||||
{
|
||||
"title": title.strip(),
|
||||
"description": description.strip() if description else None,
|
||||
"priority": default_priority,
|
||||
},
|
||||
)
|
||||
if not tasks:
|
||||
return _dump(
|
||||
{
|
||||
"success": False,
|
||||
"error": "Provide a title or 'todos' list to create.",
|
||||
"todo_id": None,
|
||||
},
|
||||
)
|
||||
|
||||
agent_todos = _get_agent_todos(agent_id)
|
||||
created: list[dict[str, Any]] = []
|
||||
for task in tasks:
|
||||
task_priority = _normalize_priority(task.get("priority"), default_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 _dump({"success": False, "error": f"Failed to create todo: {e}", "todo_id": None})
|
||||
|
||||
Args:
|
||||
title: Title of a single todo (alternative to bulk ``todos``).
|
||||
description: Optional details for the single todo.
|
||||
priority: ``"low" | "normal" | "high" | "critical"``.
|
||||
todos: Optional JSON string or comma-separated list for bulk create.
|
||||
"""
|
||||
state = adapter_from_ctx(ctx)
|
||||
return _dump(
|
||||
_impl.create_todo(
|
||||
agent_state=state,
|
||||
title=title,
|
||||
description=description,
|
||||
priority=priority,
|
||||
todos=todos,
|
||||
),
|
||||
{
|
||||
"success": True,
|
||||
"created": created,
|
||||
"count": len(created),
|
||||
"todos": _sorted_todos(agent_id),
|
||||
"total_count": len(_get_agent_todos(agent_id)),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -61,14 +271,56 @@ async def list_todos(
|
||||
status: str | None = None,
|
||||
priority: str | None = None,
|
||||
) -> str:
|
||||
"""List the current agent's todos, sorted by status then priority.
|
||||
"""List the current agent's todos, sorted by status then priority."""
|
||||
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
|
||||
|
||||
Args:
|
||||
status: Optional ``"pending" | "in_progress" | "done"`` filter.
|
||||
priority: Optional ``"low" | "normal" | "high" | "critical"`` filter.
|
||||
"""
|
||||
state = adapter_from_ctx(ctx)
|
||||
return _dump(_impl.list_todos(agent_state=state, status=status, priority=priority))
|
||||
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)
|
||||
|
||||
priority_order = {"critical": 0, "high": 1, "normal": 2, "low": 3}
|
||||
status_order = {"done": 0, "in_progress": 1, "pending": 2}
|
||||
todos_list.sort(
|
||||
key=lambda x: (
|
||||
status_order.get(x.get("status", "pending"), 99),
|
||||
priority_order.get(x.get("priority", "normal"), 99),
|
||||
x.get("created_at", ""),
|
||||
),
|
||||
)
|
||||
|
||||
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 _dump(
|
||||
{
|
||||
"success": False,
|
||||
"error": f"Failed to list todos: {e}",
|
||||
"todos": [],
|
||||
"total_count": 0,
|
||||
"summary": {"pending": 0, "in_progress": 0, "done": 0},
|
||||
},
|
||||
)
|
||||
|
||||
return _dump(
|
||||
{
|
||||
"success": True,
|
||||
"todos": todos_list,
|
||||
"total_count": len(todos_list),
|
||||
"summary": summary,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@strix_tool(timeout=30)
|
||||
@@ -81,26 +333,102 @@ async def update_todo(
|
||||
status: str | None = None,
|
||||
updates: str | None = None,
|
||||
) -> str:
|
||||
"""Update one or many todos.
|
||||
"""Update one or many todos."""
|
||||
agent_id = _agent_id_from(ctx)
|
||||
try:
|
||||
agent_todos = _get_agent_todos(agent_id)
|
||||
updates_to_apply: list[dict[str, Any]] = []
|
||||
if updates is not None:
|
||||
updates_to_apply.extend(_normalize_bulk_updates(updates))
|
||||
if todo_id is not None:
|
||||
updates_to_apply.append(
|
||||
{
|
||||
"todo_id": todo_id,
|
||||
"title": title,
|
||||
"description": description,
|
||||
"priority": priority,
|
||||
"status": status,
|
||||
},
|
||||
)
|
||||
if not updates_to_apply:
|
||||
return _dump(
|
||||
{"success": False, "error": "Provide todo_id or 'updates' list to update."},
|
||||
)
|
||||
|
||||
Args:
|
||||
todo_id: Single-todo target (alternative to bulk ``updates``).
|
||||
title / description / priority / status: New values for the single
|
||||
todo. Omit to leave unchanged.
|
||||
updates: Bulk form — JSON list of update dicts.
|
||||
"""
|
||||
state = adapter_from_ctx(ctx)
|
||||
return _dump(
|
||||
_impl.update_todo(
|
||||
agent_state=state,
|
||||
todo_id=todo_id,
|
||||
title=title,
|
||||
description=description,
|
||||
priority=priority,
|
||||
status=status,
|
||||
updates=updates,
|
||||
),
|
||||
)
|
||||
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 _dump({"success": False, "error": str(e)})
|
||||
|
||||
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 _dump(response)
|
||||
|
||||
|
||||
def _mark(
|
||||
*,
|
||||
agent_id: str,
|
||||
todo_id: str | None,
|
||||
todo_ids: str | None,
|
||||
new_status: str,
|
||||
) -> str:
|
||||
try:
|
||||
agent_todos = _get_agent_todos(agent_id)
|
||||
ids: list[str] = []
|
||||
if todo_ids is not None:
|
||||
ids.extend(_normalize_todo_ids(todo_ids))
|
||||
if todo_id is not None:
|
||||
ids.append(todo_id)
|
||||
if not ids:
|
||||
msg = f"Provide todo_id or todo_ids to mark as {new_status}."
|
||||
return _dump({"success": False, "error": msg})
|
||||
|
||||
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 _dump({"success": False, "error": str(e)})
|
||||
|
||||
key = "marked_done" if new_status == "done" else "marked_pending"
|
||||
response: dict[str, Any] = {
|
||||
"success": len(errors) == 0,
|
||||
key: marked,
|
||||
"marked_count": len(marked),
|
||||
"todos": _sorted_todos(agent_id),
|
||||
"total_count": len(agent_todos),
|
||||
}
|
||||
if errors:
|
||||
response["errors"] = errors
|
||||
return _dump(response)
|
||||
|
||||
|
||||
@strix_tool(timeout=30)
|
||||
@@ -110,9 +438,11 @@ async def mark_todo_done(
|
||||
todo_ids: str | None = None,
|
||||
) -> str:
|
||||
"""Mark one (``todo_id``) or many (``todo_ids``) todos as done."""
|
||||
state = adapter_from_ctx(ctx)
|
||||
return _dump(
|
||||
_impl.mark_todo_done(agent_state=state, todo_id=todo_id, todo_ids=todo_ids),
|
||||
return _mark(
|
||||
agent_id=_agent_id_from(ctx),
|
||||
todo_id=todo_id,
|
||||
todo_ids=todo_ids,
|
||||
new_status="done",
|
||||
)
|
||||
|
||||
|
||||
@@ -123,13 +453,11 @@ async def mark_todo_pending(
|
||||
todo_ids: str | None = None,
|
||||
) -> str:
|
||||
"""Mark one (``todo_id``) or many (``todo_ids``) todos as pending."""
|
||||
state = adapter_from_ctx(ctx)
|
||||
return _dump(
|
||||
_impl.mark_todo_pending(
|
||||
agent_state=state,
|
||||
todo_id=todo_id,
|
||||
todo_ids=todo_ids,
|
||||
),
|
||||
return _mark(
|
||||
agent_id=_agent_id_from(ctx),
|
||||
todo_id=todo_id,
|
||||
todo_ids=todo_ids,
|
||||
new_status="pending",
|
||||
)
|
||||
|
||||
|
||||
@@ -140,7 +468,35 @@ async def delete_todo(
|
||||
todo_ids: str | None = None,
|
||||
) -> str:
|
||||
"""Delete one (``todo_id``) or many (``todo_ids``) todos."""
|
||||
state = adapter_from_ctx(ctx)
|
||||
return _dump(
|
||||
_impl.delete_todo(agent_state=state, todo_id=todo_id, todo_ids=todo_ids),
|
||||
)
|
||||
agent_id = _agent_id_from(ctx)
|
||||
try:
|
||||
agent_todos = _get_agent_todos(agent_id)
|
||||
ids: list[str] = []
|
||||
if todo_ids is not None:
|
||||
ids.extend(_normalize_todo_ids(todo_ids))
|
||||
if todo_id is not None:
|
||||
ids.append(todo_id)
|
||||
if not ids:
|
||||
return _dump({"success": False, "error": "Provide todo_id or todo_ids to delete."})
|
||||
|
||||
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 _dump({"success": False, "error": str(e)})
|
||||
|
||||
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 _dump(response)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from .web_search_actions import web_search
|
||||
from .tool import web_search
|
||||
|
||||
|
||||
__all__ = ["web_search"]
|
||||
|
||||
@@ -1,42 +1,97 @@
|
||||
"""SDK function-tool wrapper for the legacy ``web_search`` tool.
|
||||
|
||||
The legacy ``web_search_actions.web_search`` is a synchronous Perplexity
|
||||
API call (300s timeout, ``requests``). We wrap it with
|
||||
``asyncio.to_thread`` so the call doesn't block the SDK event loop while
|
||||
the API responds — same parity for the model, no surprises.
|
||||
|
||||
Pattern matches notes/todo/think wrappers from Phase 2.3.
|
||||
"""
|
||||
"""``web_search`` — Perplexity-backed security-focused web search."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
import requests
|
||||
from agents import RunContextWrapper
|
||||
|
||||
from strix.tools._decorator import strix_tool
|
||||
from strix.tools.web_search import web_search_actions as _impl
|
||||
|
||||
|
||||
def _dump(result: dict[str, Any]) -> str:
|
||||
return json.dumps(result, ensure_ascii=False, default=str)
|
||||
_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."""
|
||||
|
||||
|
||||
# Perplexity request timeout in the legacy code is 300s; give the SDK
|
||||
# tool a slightly larger budget so the network round-trip + JSON decode
|
||||
# doesn't push us over the edge under load.
|
||||
def _do_search(query: str) -> dict[str, Any]:
|
||||
api_key = os.getenv("PERPLEXITY_API_KEY")
|
||||
if not api_key:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "PERPLEXITY_API_KEY environment variable not set",
|
||||
"results": [],
|
||||
}
|
||||
|
||||
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:
|
||||
return {"success": False, "message": "Request timed out", "results": []}
|
||||
except requests.exceptions.RequestException as e:
|
||||
return {"success": False, "message": f"API request failed: {e!s}", "results": []}
|
||||
except KeyError as e:
|
||||
return {
|
||||
"success": False,
|
||||
"message": f"Unexpected API response format: missing {e!s}",
|
||||
"results": [],
|
||||
}
|
||||
except Exception as e: # noqa: BLE001
|
||||
return {"success": False, "message": f"Web search failed: {e!s}", "results": []}
|
||||
else:
|
||||
return {
|
||||
"success": True,
|
||||
"query": query,
|
||||
"content": content,
|
||||
"message": "Web search completed successfully",
|
||||
}
|
||||
|
||||
|
||||
# Perplexity request timeout is 300s; give the SDK a slightly larger
|
||||
# budget so the round-trip + JSON decode doesn't push us over.
|
||||
@strix_tool(timeout=330)
|
||||
async def web_search(ctx: RunContextWrapper, query: str) -> str:
|
||||
"""Search the web with Perplexity, scoped to security-relevant content.
|
||||
|
||||
Returns a JSON-encoded ``{"success": bool, "content": str, ...}``
|
||||
dict matching the legacy shape exactly.
|
||||
|
||||
Args:
|
||||
query: The search query. The legacy tool prepends a security-focused
|
||||
system prompt to bias results toward CVEs, exploits, and Kali-
|
||||
compatible commands.
|
||||
query: The search query. A security-focused system prompt biases
|
||||
results toward CVEs, exploits, and Kali-compatible commands.
|
||||
"""
|
||||
return _dump(await asyncio.to_thread(_impl.web_search, query=query))
|
||||
del ctx
|
||||
result = await asyncio.to_thread(_do_search, query)
|
||||
return json.dumps(result, ensure_ascii=False, default=str)
|
||||
|
||||
@@ -1,80 +0,0 @@
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
import requests
|
||||
|
||||
from strix.tools.registry import register_tool
|
||||
|
||||
|
||||
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."""
|
||||
|
||||
|
||||
@register_tool(sandbox_execution=False, requires_web_search_mode=True)
|
||||
def web_search(query: str) -> dict[str, Any]:
|
||||
try:
|
||||
api_key = os.getenv("PERPLEXITY_API_KEY")
|
||||
if not api_key:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "PERPLEXITY_API_KEY environment variable not set",
|
||||
"results": [],
|
||||
}
|
||||
|
||||
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},
|
||||
],
|
||||
}
|
||||
|
||||
response = requests.post(url, headers=headers, json=payload, timeout=300)
|
||||
response.raise_for_status()
|
||||
|
||||
response_data = response.json()
|
||||
content = response_data["choices"][0]["message"]["content"]
|
||||
|
||||
except requests.exceptions.Timeout:
|
||||
return {"success": False, "message": "Request timed out", "results": []}
|
||||
except requests.exceptions.RequestException as e:
|
||||
return {"success": False, "message": f"API request failed: {e!s}", "results": []}
|
||||
except KeyError as e:
|
||||
return {
|
||||
"success": False,
|
||||
"message": f"Unexpected API response format: missing {e!s}",
|
||||
"results": [],
|
||||
}
|
||||
except Exception as e: # noqa: BLE001
|
||||
return {"success": False, "message": f"Web search failed: {e!s}", "results": []}
|
||||
else:
|
||||
return {
|
||||
"success": True,
|
||||
"query": query,
|
||||
"content": content,
|
||||
"message": "Web search completed successfully",
|
||||
}
|
||||
@@ -1,83 +0,0 @@
|
||||
<tools>
|
||||
<tool name="web_search">
|
||||
<description>Search the web using Perplexity AI for real-time information and current events.
|
||||
|
||||
This is your PRIMARY research tool - use it extensively and liberally for:
|
||||
- Current vulnerabilities, CVEs, and security advisories
|
||||
- Latest attack techniques, exploits, and proof-of-concepts
|
||||
- Technology-specific security research and documentation
|
||||
- Target reconnaissance and OSINT gathering
|
||||
- Security tool documentation and usage guides
|
||||
- Incident response and threat intelligence
|
||||
- Compliance frameworks and security standards
|
||||
- Bug bounty reports and security research findings
|
||||
- Security conference talks and research papers
|
||||
|
||||
The tool provides intelligent, contextual responses with current information that may not be in your training data. Use it early and often during security assessments to gather the most up-to-date factual information.</description>
|
||||
<details>This tool leverages Perplexity AI's sonar-reasoning model to search the web and provide intelligent, contextual responses to queries. It's essential for effective cybersecurity work as it provides access to the latest vulnerabilities, attack vectors, security tools, and defensive techniques. The AI understands security context and can synthesize information from multiple sources.</details>
|
||||
<parameters>
|
||||
<parameter name="query" type="string" required="true">
|
||||
<description>The search query or question you want to research. Be specific and include relevant technical terms, version numbers, or context for better results. Make it as detailed as possible, with the context of the current security assessment.</description>
|
||||
</parameter>
|
||||
</parameters>
|
||||
<returns type="Dict[str, Any]">
|
||||
<description>Response containing: - success: Whether the search was successful - query: The original search query - content: AI-generated response with current information - message: Status message</description>
|
||||
</returns>
|
||||
<examples>
|
||||
# Found specific service version during reconnaissance
|
||||
<function=web_search>
|
||||
<parameter=query>I found OpenSSH 7.4 running on port 22. Are there any known exploits or privilege escalation techniques for this specific version?</parameter>
|
||||
</function>
|
||||
|
||||
# Encountered WAF blocking attempts
|
||||
<function=web_search>
|
||||
<parameter=query>Cloudflare is blocking my SQLmap attempts on this login form. What are the latest bypass techniques for Cloudflare WAF in 2024?</parameter>
|
||||
</function>
|
||||
|
||||
# Need to exploit discovered CMS
|
||||
<function=web_search>
|
||||
<parameter=query>Target is running WordPress 5.8.3 with WooCommerce 6.1.1. What are the current RCE exploits for this combination?</parameter>
|
||||
</function>
|
||||
|
||||
# Stuck on privilege escalation
|
||||
<function=web_search>
|
||||
<parameter=query>I have low-privilege shell on Ubuntu 20.04 with kernel 5.4.0-74-generic. What local privilege escalation exploits work for this exact kernel version?</parameter>
|
||||
</function>
|
||||
|
||||
# Need lateral movement in Active Directory
|
||||
<function=web_search>
|
||||
<parameter=query>I compromised a domain user account in Windows Server 2019 AD environment. What are the best techniques to escalate to Domain Admin without triggering EDR?</parameter>
|
||||
</function>
|
||||
|
||||
# Encountered specific error during exploitation
|
||||
<function=web_search>
|
||||
<parameter=query>Getting "Access denied" when trying to upload webshell to IIS 10.0. What are alternative file upload bypass techniques for Windows IIS?</parameter>
|
||||
</function>
|
||||
|
||||
# Need to bypass endpoint protection
|
||||
<function=web_search>
|
||||
<parameter=query>Target has CrowdStrike Falcon running. What are the latest techniques to bypass this EDR for payload execution and persistence?</parameter>
|
||||
</function>
|
||||
|
||||
# Research target's infrastructure for attack surface
|
||||
<function=web_search>
|
||||
<parameter=query>I found target company "AcmeCorp" uses Office 365 and Azure. What are the common misconfigurations and attack vectors for this cloud setup?</parameter>
|
||||
</function>
|
||||
|
||||
# Found interesting subdomain during recon
|
||||
<function=web_search>
|
||||
<parameter=query>Discovered staging.target.com running Jenkins 2.401.3. What are the current authentication bypass and RCE exploits for this Jenkins version?</parameter>
|
||||
</function>
|
||||
|
||||
# Need alternative tools when primary fails
|
||||
<function=web_search>
|
||||
<parameter=query>Nmap is being detected and blocked by the target's IPS. What are stealthy alternatives for port scanning that evade modern intrusion prevention systems?</parameter>
|
||||
</function>
|
||||
|
||||
# Finding best security tools for specific tasks
|
||||
<function=web_search>
|
||||
<parameter=query>What is the best Python pip package in 2025 for JWT security testing and manipulation, including cracking weak secrets and algorithm confusion attacks?</parameter>
|
||||
</function>
|
||||
</examples>
|
||||
</tool>
|
||||
</tools>
|
||||
@@ -20,7 +20,7 @@ from unittest.mock import patch
|
||||
import pytest
|
||||
from agents.tool import FunctionTool
|
||||
|
||||
from strix.tools.notes import notes_actions as _notes_impl
|
||||
from strix.tools.notes import tools as _notes_impl
|
||||
from strix.tools.notes.tools import (
|
||||
create_note,
|
||||
delete_note,
|
||||
@@ -98,9 +98,9 @@ async def test_think_rejects_empty() -> None:
|
||||
@pytest.fixture(autouse=True)
|
||||
def _isolate_todo_storage() -> None:
|
||||
"""Each test starts with an empty todo store so tests don't bleed."""
|
||||
from strix.tools.todo import todo_actions
|
||||
from strix.tools.todo import tools as todo_module
|
||||
|
||||
todo_actions._todos_storage.clear()
|
||||
todo_module._todos_storage.clear()
|
||||
|
||||
|
||||
def test_todo_tools_are_function_tools() -> None:
|
||||
|
||||
@@ -17,7 +17,7 @@ from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from strix.tools.notes.notes_actions import _append_note_event
|
||||
from strix.tools.notes.tools import _append_note_event
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -27,7 +27,7 @@ def notes_path(tmp_path: Path) -> Iterator[Path]:
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
with patch(
|
||||
"strix.tools.notes.notes_actions._get_notes_jsonl_path",
|
||||
"strix.tools.notes.tools._get_notes_jsonl_path",
|
||||
return_value=target,
|
||||
):
|
||||
yield target
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from pathlib import Path
|
||||
|
||||
from strix.telemetry.tracer import Tracer, get_global_tracer, set_global_tracer
|
||||
from strix.tools.notes import notes_actions
|
||||
from strix.tools.notes import tools as notes_actions
|
||||
|
||||
|
||||
def _reset_notes_state() -> None:
|
||||
@@ -18,7 +18,7 @@ def test_wiki_notes_are_persisted_and_removed(tmp_path: Path, monkeypatch) -> No
|
||||
set_global_tracer(tracer)
|
||||
|
||||
try:
|
||||
created = notes_actions.create_note(
|
||||
created = notes_actions._create_note_impl(
|
||||
title="Repo Map",
|
||||
content="## Architecture\n- monolith",
|
||||
category="wiki",
|
||||
@@ -36,14 +36,14 @@ def test_wiki_notes_are_persisted_and_removed(tmp_path: Path, monkeypatch) -> No
|
||||
assert wiki_path.exists()
|
||||
assert "## Architecture" in wiki_path.read_text(encoding="utf-8")
|
||||
|
||||
updated = notes_actions.update_note(
|
||||
updated = notes_actions._update_note_impl(
|
||||
note_id=note_id,
|
||||
content="## Architecture\n- service-oriented",
|
||||
)
|
||||
assert updated["success"] is True
|
||||
assert "service-oriented" in wiki_path.read_text(encoding="utf-8")
|
||||
|
||||
deleted = notes_actions.delete_note(note_id=note_id)
|
||||
deleted = notes_actions._delete_note_impl(note_id=note_id)
|
||||
assert deleted["success"] is True
|
||||
assert wiki_path.exists() is False
|
||||
finally:
|
||||
@@ -60,7 +60,7 @@ def test_notes_jsonl_replay_survives_memory_reset(tmp_path: Path, monkeypatch) -
|
||||
set_global_tracer(tracer)
|
||||
|
||||
try:
|
||||
created = notes_actions.create_note(
|
||||
created = notes_actions._create_note_impl(
|
||||
title="Auth findings",
|
||||
content="initial finding",
|
||||
category="findings",
|
||||
@@ -74,24 +74,24 @@ def test_notes_jsonl_replay_survives_memory_reset(tmp_path: Path, monkeypatch) -
|
||||
assert notes_path.exists() is True
|
||||
|
||||
_reset_notes_state()
|
||||
listed = notes_actions.list_notes(category="findings")
|
||||
listed = notes_actions._list_notes_impl(category="findings")
|
||||
assert listed["success"] is True
|
||||
assert listed["total_count"] == 1
|
||||
assert listed["notes"][0]["note_id"] == note_id
|
||||
assert "content" not in listed["notes"][0]
|
||||
assert "content_preview" in listed["notes"][0]
|
||||
|
||||
updated = notes_actions.update_note(note_id=note_id, content="updated finding")
|
||||
updated = notes_actions._update_note_impl(note_id=note_id, content="updated finding")
|
||||
assert updated["success"] is True
|
||||
|
||||
_reset_notes_state()
|
||||
listed_after_update = notes_actions.list_notes(search="updated finding")
|
||||
listed_after_update = notes_actions._list_notes_impl(search="updated finding")
|
||||
assert listed_after_update["success"] is True
|
||||
assert listed_after_update["total_count"] == 1
|
||||
assert listed_after_update["notes"][0]["note_id"] == note_id
|
||||
assert listed_after_update["notes"][0]["content_preview"] == "updated finding"
|
||||
|
||||
listed_with_content = notes_actions.list_notes(
|
||||
listed_with_content = notes_actions._list_notes_impl(
|
||||
category="findings",
|
||||
include_content=True,
|
||||
)
|
||||
@@ -99,11 +99,11 @@ def test_notes_jsonl_replay_survives_memory_reset(tmp_path: Path, monkeypatch) -
|
||||
assert listed_with_content["total_count"] == 1
|
||||
assert listed_with_content["notes"][0]["content"] == "updated finding"
|
||||
|
||||
deleted = notes_actions.delete_note(note_id=note_id)
|
||||
deleted = notes_actions._delete_note_impl(note_id=note_id)
|
||||
assert deleted["success"] is True
|
||||
|
||||
_reset_notes_state()
|
||||
listed_after_delete = notes_actions.list_notes(category="findings")
|
||||
listed_after_delete = notes_actions._list_notes_impl(category="findings")
|
||||
assert listed_after_delete["success"] is True
|
||||
assert listed_after_delete["total_count"] == 0
|
||||
finally:
|
||||
@@ -120,7 +120,7 @@ def test_get_note_returns_full_note(tmp_path: Path, monkeypatch) -> None:
|
||||
set_global_tracer(tracer)
|
||||
|
||||
try:
|
||||
created = notes_actions.create_note(
|
||||
created = notes_actions._create_note_impl(
|
||||
title="Repo wiki",
|
||||
content="entrypoints and sinks",
|
||||
category="wiki",
|
||||
@@ -130,7 +130,7 @@ def test_get_note_returns_full_note(tmp_path: Path, monkeypatch) -> None:
|
||||
note_id = created["note_id"]
|
||||
assert isinstance(note_id, str)
|
||||
|
||||
result = notes_actions.get_note(note_id=note_id)
|
||||
result = notes_actions._get_note_impl(note_id=note_id)
|
||||
assert result["success"] is True
|
||||
assert result["note"]["note_id"] == note_id
|
||||
assert result["note"]["content"] == "entrypoints and sinks"
|
||||
@@ -148,7 +148,7 @@ def test_append_note_content_appends_delta(tmp_path: Path, monkeypatch) -> None:
|
||||
set_global_tracer(tracer)
|
||||
|
||||
try:
|
||||
created = notes_actions.create_note(
|
||||
created = notes_actions._create_note_impl(
|
||||
title="Repo wiki",
|
||||
content="base",
|
||||
category="wiki",
|
||||
@@ -164,7 +164,7 @@ def test_append_note_content_appends_delta(tmp_path: Path, monkeypatch) -> None:
|
||||
)
|
||||
assert appended["success"] is True
|
||||
|
||||
loaded = notes_actions.get_note(note_id=note_id)
|
||||
loaded = notes_actions._get_note_impl(note_id=note_id)
|
||||
assert loaded["success"] is True
|
||||
assert loaded["note"]["content"] == "base\n\n## Agent Update: worker\nSummary: done"
|
||||
finally:
|
||||
@@ -183,7 +183,7 @@ def test_list_and_get_note_handle_wiki_repersist_oserror_gracefully(
|
||||
set_global_tracer(tracer)
|
||||
|
||||
try:
|
||||
created = notes_actions.create_note(
|
||||
created = notes_actions._create_note_impl(
|
||||
title="Repo wiki",
|
||||
content="initial wiki content",
|
||||
category="wiki",
|
||||
@@ -200,12 +200,12 @@ def test_list_and_get_note_handle_wiki_repersist_oserror_gracefully(
|
||||
|
||||
monkeypatch.setattr(notes_actions, "_persist_wiki_note", _raise_oserror)
|
||||
|
||||
listed = notes_actions.list_notes(category="wiki")
|
||||
listed = notes_actions._list_notes_impl(category="wiki")
|
||||
assert listed["success"] is True
|
||||
assert listed["total_count"] == 1
|
||||
assert listed["notes"][0]["note_id"] == note_id
|
||||
|
||||
fetched = notes_actions.get_note(note_id=note_id)
|
||||
fetched = notes_actions._get_note_impl(note_id=note_id)
|
||||
assert fetched["success"] is True
|
||||
assert fetched["note"]["note_id"] == note_id
|
||||
assert fetched["note"]["content"] == "initial wiki content"
|
||||
|
||||
@@ -85,8 +85,8 @@ async def test_web_search_no_api_key_returns_structured_error(
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_web_search_delegates_to_impl(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Legacy ``web_search`` returns dict; wrapper JSON-encodes it."""
|
||||
async def test_web_search_delegates_to_perplexity(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""The wrapper invokes the Perplexity HTTP path on a thread."""
|
||||
monkeypatch.setenv("PERPLEXITY_API_KEY", "fake-key")
|
||||
|
||||
fake_result = {
|
||||
@@ -96,13 +96,13 @@ async def test_web_search_delegates_to_impl(monkeypatch: pytest.MonkeyPatch) ->
|
||||
"message": "Web search completed successfully",
|
||||
}
|
||||
with patch(
|
||||
"strix.tools.web_search.tool._impl.web_search",
|
||||
"strix.tools.web_search.tool._do_search",
|
||||
return_value=fake_result,
|
||||
) as legacy:
|
||||
) as do_search:
|
||||
out = await _invoke(web_search, _ctx_for(), query="xss techniques")
|
||||
|
||||
assert out == fake_result
|
||||
legacy.assert_called_once_with(query="xss techniques")
|
||||
do_search.assert_called_once_with("xss techniques")
|
||||
|
||||
|
||||
# --- file_edit (sandbox-bound) -------------------------------------------
|
||||
@@ -197,7 +197,7 @@ async def test_create_vulnerability_report_validates_required_fields() -> None:
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_vulnerability_report_delegates_to_impl() -> None:
|
||||
"""Verify the wrapper passes all params through to the legacy function."""
|
||||
"""Verify the wrapper threads its kwargs through to the implementation."""
|
||||
fake_result = {
|
||||
"success": True,
|
||||
"message": "Vulnerability report 'X' created successfully",
|
||||
@@ -206,9 +206,9 @@ async def test_create_vulnerability_report_delegates_to_impl() -> None:
|
||||
"cvss_score": 7.5,
|
||||
}
|
||||
with patch(
|
||||
"strix.tools.reporting.tool._impl.create_vulnerability_report",
|
||||
"strix.tools.reporting.tool._do_create",
|
||||
return_value=fake_result,
|
||||
) as legacy:
|
||||
) as do_create:
|
||||
out = await _invoke(
|
||||
create_vulnerability_report,
|
||||
_ctx_for(),
|
||||
@@ -225,7 +225,7 @@ async def test_create_vulnerability_report_delegates_to_impl() -> None:
|
||||
)
|
||||
|
||||
assert out == fake_result
|
||||
kwargs = legacy.call_args.kwargs
|
||||
kwargs = do_create.call_args.kwargs
|
||||
assert kwargs["title"] == "t"
|
||||
assert kwargs["cve"] == "CVE-2024-12345"
|
||||
# Optional params we didn't pass should still be forwarded as None.
|
||||
@@ -252,18 +252,40 @@ async def test_finish_scan_validates_empty_fields() -> None:
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_finish_scan_delegates_to_impl() -> None:
|
||||
"""Wrapper must pass the legacy adapter and the four sections through."""
|
||||
fake_result = {
|
||||
"success": True,
|
||||
"scan_completed": True,
|
||||
"message": "Scan completed successfully",
|
||||
"vulnerabilities_found": 3,
|
||||
}
|
||||
async def test_finish_scan_rejects_subagent() -> None:
|
||||
"""A subagent (parent_id is set) must not be able to finish the scan."""
|
||||
ctx = _Ctx(
|
||||
context={
|
||||
"agent_id": "child-1",
|
||||
"parent_id": "root-1",
|
||||
"tool_server_host_port": 12345,
|
||||
"sandbox_token": "test-token",
|
||||
},
|
||||
)
|
||||
out = await _invoke(
|
||||
finish_scan,
|
||||
ctx,
|
||||
executive_summary="es",
|
||||
methodology="m",
|
||||
technical_analysis="ta",
|
||||
recommendations="r",
|
||||
)
|
||||
assert out["success"] is False
|
||||
assert out["error"] == "finish_scan_wrong_agent"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_finish_scan_persists_via_tracer() -> None:
|
||||
"""When a global tracer exists, finish_scan should write the four sections."""
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
fake_tracer = MagicMock()
|
||||
fake_tracer.vulnerability_reports = [{}, {}, {}]
|
||||
|
||||
with patch(
|
||||
"strix.tools.finish.tool._impl.finish_scan",
|
||||
return_value=fake_result,
|
||||
) as legacy:
|
||||
"strix.telemetry.tracer.get_global_tracer",
|
||||
return_value=fake_tracer,
|
||||
):
|
||||
out = await _invoke(
|
||||
finish_scan,
|
||||
_ctx_for("root-agent"),
|
||||
@@ -273,7 +295,11 @@ async def test_finish_scan_delegates_to_impl() -> None:
|
||||
recommendations="r",
|
||||
)
|
||||
|
||||
assert out == fake_result
|
||||
kwargs = legacy.call_args.kwargs
|
||||
assert kwargs["executive_summary"] == "es"
|
||||
assert kwargs["agent_state"].agent_id == "root-agent"
|
||||
assert out["success"] is True
|
||||
assert out["vulnerabilities_found"] == 3
|
||||
fake_tracer.update_scan_final_fields.assert_called_once_with(
|
||||
executive_summary="es",
|
||||
methodology="m",
|
||||
technical_analysis="ta",
|
||||
recommendations="r",
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user