Compare commits

...
12 Commits
97 changed files with 6691 additions and 5606 deletions
+1
View File
@@ -40,6 +40,7 @@ coverage.out
coverage.html
# Logs and temporary files
/log/
*.log
*.bak
*~
+2 -1
View File
@@ -126,13 +126,14 @@ CyberStrikeAI connects planning, execution, human oversight, evidence, and repla
### Governance and audit
- 🧑‍⚖️ **Human in the loop** provides approval modes, tool allowlists, audit-agent review, and traceable decisions.
- 🛡️ **Call blocking** under Security adds configurable regex checks before MCP execution, reminder templates, and dry runs, with government-domain protection enabled by default. See [Tool call blocking](docs/en-US/tool-call-guard.md).
- 🔐 **Platform RBAC** supports multiple users, system and custom roles, scoped permissions, ownership, and explicit assignments.
- 🔒 **Security and audit** provide authenticated access, audit logs, SQLite persistence, and operational evidence retention.
- 📄 **Result governance** stores the same capped tool result seen by the agent, protects resume paths from oversized historical output, and adds UI safeguards for large detail views. See [Tool Execution Governance](docs/en-US/tool-execution-governance.md).
### Security operations
- 📁 **Conversation management** provides grouping, pinning, renaming, and batch organization.
- 📁 **Conversation management** provides pinning, renaming, and batch organization.
- 📂 **Projects and attack chains** connect cross-session facts, risk scoring, graph views, and step-by-step replay.
- 🗂️ **Asset management** normalizes and deduplicates domains, IP addresses, ports, and services; supports XLSX/CSV import and export, advanced filters and saved views, ownership and business metadata, cross-page bulk maintenance, and duplicate merging; and tracks scan coverage, linked vulnerabilities, and risk state. See the [Asset Management guide](docs/en-US/asset-management.md).
- 🛡️ **Vulnerability management** provides severity classification, lifecycle tracking, filtering, and statistics.
+1
View File
@@ -125,6 +125,7 @@ CyberStrikeAI 将规划、执行、人工监督、证据与复盘连接在同一
### 安全治理与审计
- 🧑‍⚖️ **人机协同**:支持审批模式、工具白名单、审计 Agent 复核和决策追踪。
- 🛡️ **调用拦截**:「安全防护」下配置 MCP 执行前正则拦截、提醒模板和试匹配,默认启用政府域名保护。详见[调用拦截](docs/zh-CN/tool-call-guard.md)。
- 🔐 **平台 RBAC**:支持多用户、系统及自定义角色、权限 Scope、资源归属和显式授权。
- 🔒 **安全与审计**:提供登录保护、审计日志、SQLite 持久化和行动证据留存。
- 📄 **结果治理**:数据库保存与 Agent 实际看到的同一份兜底后工具结果,恢复路径会再次防御历史超大输出,前端详情也有展示保护。详见[工具执行治理](docs/zh-CN/tool-execution-governance.md)。
+13 -1
View File
@@ -5,6 +5,7 @@ import (
"cyberstrike-ai/internal/logger"
"cyberstrike-ai/internal/mcp"
"cyberstrike-ai/internal/security"
"cyberstrike-ai/internal/toolguard"
"flag"
"fmt"
"os"
@@ -24,10 +25,21 @@ func main() {
}
// 初始化日志(stdio 模式下使用 stderr 输出日志,避免干扰 JSON-RPC 通信)
log := logger.New(cfg.Log.Level, "stderr")
log := logger.New(cfg.Log.Level, "stderr", logger.DiagnosticOptions{
Dir: cfg.Log.DiagnosticDir,
Disabled: cfg.Log.DiagnosticDisabled,
RetentionDays: cfg.Log.DiagnosticRetentionDays,
})
defer log.Sync()
// 创建MCP服务器
mcpServer := mcp.NewServer(log.Logger)
guard, err := toolguard.NewManager(cfg.EffectiveToolGuard())
if err != nil {
fmt.Fprintf(os.Stderr, "初始化调用拦截失败: %v\n", err)
os.Exit(1)
}
mcpServer.SetToolGuard(guard)
// 创建安全工具执行器
executor := security.NewExecutor(&cfg.Security, mcpServer, log.Logger)
+6 -1
View File
@@ -101,7 +101,12 @@ func main() {
}
// 初始化日志
log := logger.New(cfg.Log.Level, cfg.Log.Output)
log := logger.New(cfg.Log.Level, cfg.Log.Output, logger.DiagnosticOptions{
Dir: cfg.Log.DiagnosticDir,
Disabled: cfg.Log.DiagnosticDisabled,
RetentionDays: cfg.Log.DiagnosticRetentionDays,
})
defer log.Sync()
// 创建可取消的根 context,用于优雅关闭
ctx, cancel := context.WithCancel(context.Background())
+22 -2
View File
@@ -10,7 +10,7 @@
# ============================================
# 前端显示的版本号(可选,不填则显示默认版本)
version: "v1.7.17"
version: "v1.7.18"
# 服务器配置
server:
host: 0.0.0.0 # 监听地址,0.0.0.0 表示监听所有网络接口
@@ -36,6 +36,9 @@ auth:
log:
level: info # 日志级别: debug(调试), info(信息), warn(警告), error(错误)
output: stdout # 日志输出位置: stdout(标准输出), stderr(标准错误), 或文件路径
diagnostic_dir: log # 额外保存 warn 及以上诊断日志,按本地日期拆分;相对于进程工作目录
diagnostic_retention_days: 14 # 保留天数(含当天);省略或 <= 0 使用 14 天
diagnostic_disabled: false # true 关闭额外诊断日志;修改后需重启
# 平台操作审计(系统设置 -> 日志审计;不记录对话正文与每次工具调用)
audit:
enabled: true
@@ -130,13 +133,30 @@ agent:
# system_prompt_path: prompts/single-agent.md # 可选:单代理系统提示文件(相对本配置文件所在目录);非空且可读时替换内置提示
system_prompt_path: ""
# 调用拦截:在「安全防护 → 调用拦截」编辑并保存后立即生效;与 HITL 审批白名单独立。
# 旧配置省略 tool_guard 时也默认启用政府域名保护。手动修改 YAML 后需重启。
tool_guard:
enabled: true
rules:
- id: government-domains
name: 政府网站保护
enabled: true
# Go/RE2 正则;命名捕获组 match 用于提醒中的 {match}。
pattern: '(?i)(?:^|[^\p{L}\p{M}\p{N}_.-])(?P<match>(?:(?:[\p{L}\p{M}\p{N}_*-]+\.)+gov(?:\.[\p{L}\p{M}\p{N}_*-]+)*|gov(?:\.[\p{L}\p{M}\p{N}_*-]+)+|\.gov(?:\.[\p{L}\p{M}\p{N}_*-]+)*)\.?)(?:$|[^\p{L}\p{M}\p{N}_.-])'
# 支持 {match}、{tool}、{rule};留空使用通用提醒。
message: '识别到 {match},禁止攻击政府网站。请检查目标与授权范围,并更换为已获授权的非政府目标。'
# 人机协同(HITL)全局白名单:此处列出的工具始终免审批,与对话页「白名单工具(免审批,逗号分隔)」合并为并集;侧栏「应用」可合并写入本列表并立即生效。
# 非白名单工具在审批方=审计 Agent 时,按会话 HITL 模式选用提示词:
# approval → audit_agent_prompt
# review_edit → audit_agent_prompt_review_edit(可改参后放行)
hitl:
# 全局默认审批方:human=人工审批,audit_agent=审计 Agent;未选会话时切换会写入本项,重启后仍生效
# 全局默认人机协同模式:off=关闭,approval=审批模式,review_edit=审查编辑;新建会话无独立配置时沿用
default_mode: off
# 全局默认审批方:human=人工审批,audit_agent=审计 Agent;新建会话无独立配置时沿用
default_reviewer: human
# 全局默认审批等待时限(秒):300=5分钟,0=不限时;新建会话无独立配置时沿用
default_timeout_seconds: 300
# 审计 Agent 专用模型;字段留空则复用上方 openai 配置。建议 model 填小模型,用于降低审批成本。
audit_model:
provider: "" # openai / claude;留空跟随 openai.provider
+6
View File
@@ -143,3 +143,9 @@ After changing, validate the specific subsystem rather than trusting the save me
- Config API and apply: `internal/handler/config.go`
- Route registration: `internal/app/app.go`
- C2 reconciliation: `internal/app/c2_lifecycle.go`
## Diagnostic logs
Alongside `log.output` (controlled by `log.level`), warnings and errors are saved as JSON Lines in `log/diagnostic-YYYY-MM-DD.log`, using the servers local date. This independent warn-and-above output preserves existing context, caller information, and error stack traces; ordinary info/debug records are excluded and no extra request bodies or tool output are collected.
Configure `log.diagnostic_dir` (default `log`, relative to the working directory), `log.diagnostic_retention_days` (default 14, including today; nonpositive values use the default), or `log.diagnostic_disabled: true` to disable it. Restart after changing these settings. Files are created only when a diagnostic record is written; the first write each day removes expired files matching `diagnostic-YYYY-MM-DD.log`. Cleanup does not run while no diagnostic records are written. Write failures are reported to stderr without interrupting the primary log output.
-1
View File
@@ -89,7 +89,6 @@ Permissions use `module:action`. Common actions are `read`, `write`, `delete`, a
| Attack chain | `attackchain:read`, `attackchain:write` |
| Network-space search / Reconnaissance | `fofa:execute` |
| OpenAPI | `openapi:read` |
| Chat groups | `group:read`, `group:write`, `group:delete` |
| Monitor | `monitor:read`, `monitor:write`, `monitor:delete` |
Important distinctions:
+27
View File
@@ -0,0 +1,27 @@
# Tool call blocking
The **Security** sidebar groups the existing **Human in the loop** page with **Call blocking**. Call blocking checks internal MCP tools, external MCP tools, and HTTP MCP calls immediately before execution, independently of HITL approvals and allowlists.
The standalone `cmd/mcp-stdio` service also loads these rules. As a separate process, it requires a restart to pick up settings saved by the web application.
Government-domain protection is enabled by default, including when an older config omits `tool_guard`. The default rule matches government-domain forms such as `.gov`, `.gov.cn`, and wildcards, ignoring case. Add, edit, enable, or delete rules on the new page. Test an unsaved configuration with a tool name and JSON arguments before saving; testing never executes tools or updates the active policy.
**Add rule** opens a dialog with an independent draft and its own test inputs and results. **Add to list** checks the rule's RE2 syntax before adding it to the page draft; canceling leaves the list unchanged. Use the page's save button to apply the added rule.
Use **Test all rules** in the page header to check the configured order and enabled states. **Test this rule** inside an expanded rule opens a test area directly below that editor. Single-rule tests ignore the global and individual enable switches so disabled drafts can be checked; other rules cannot claim the match first. Each test area keeps separate inputs and displays its own matched text and rendered reminder.
Saving validates every rule, including disabled rules, writes only the `tool_guard` YAML section, and applies the policy immediately. Validation or write failure preserves the existing protection. Manual YAML edits require a restart; a non-null `tool_guard` section must explicitly provide `enabled` and `rules` (use `[]` for no rules). The first matching enabled rule supplies the reminder. Rules use Go/RE2 syntax; lookarounds and backreferences are unsupported. Up to 100 rules are allowed, with patterns and reminder templates capped at 4096 bytes each.
Reminder placeholders are `{match}` (matched text), `{tool}` (tool name), and `{rule}` (rule name). An optional named group `(?P<match>...)` selects the matched text. Empty templates use a default reminder.
Checks inspect the tool name, serialized JSON, nested strings and keys, and up to three rounds of common URL percent decoding. Blocked calls use a separate **Blocked** status in the UI and execution records, with the reason preserved. Monitoring counts blocks separately and excludes them from failed calls and the success-rate denominator. At startup after an upgrade, clearly identifiable legacy guard-block records are migrated to this status. MCP results retain `isError: true` alongside `blocked: true` so the agent knows the request did not execute. External MCP policy blocks do not count as provider failures for circuit breaking. Updated rules cannot cancel calls already dispatched.
Viewing and testing require `config:read`. Saving requires `config:write` with global scope, and configuration changes are audited. HITL approvals, edited arguments, and approval allowlists cannot bypass the execution check.
Text matching cannot establish target ownership from IP addresses, DNS aliases, redirects, file contents, or arbitrary obfuscation. Independent execution paths such as direct terminals and optional agent local tools are outside the MCP guard. Benign references to a protected domain in parameter text may also be blocked. Retain HITL and maintain rules against the actual authorized scope.
API endpoints:
- `GET /api/tool-guard`: active `{enabled, rules}` configuration.
- `PUT /api/tool-guard`: save that structure, explicitly providing both fields. Rules contain `id`, `name`, `enabled`, `pattern`, and `message`.
- `POST /api/tool-guard/test`: accepts `{config, toolName, arguments}` and returns `{blocked, match?}`. Match fields are `ruleId`, `ruleName`, `matchedText`, and `message`.
+5 -1
View File
@@ -27,7 +27,11 @@ log:
- Chromium 浏览器插件的合法 `chrome-extension://<32位插件ID>` Origin 会被自动识别,无需配置。插件仍需按域授权,并使用密码登录与 Bearer Token 调用 API。
- `server.cors_allowed_origins`:仅供其他可信 Web 集成使用的额外 Origin 精确白名单;不支持 `*`,修改后需重启服务。
- `auth.session_duration_hours`:登录会话有效期(小时)。登录密码由 RBAC 用户管理,首次启动时在控制台输出 `admin` 初始密码。
- `log.output`:可以是 `stdout``stderr` 或文件路径。
- `log.output`:可以是 `stdout``stderr` 或文件路径,由 `log.level` 控制级别
- 额外诊断日志默认开启,仅记录 `warn` 及以上(包括重试、连接异常和错误),独立于 `log.level`,不保存普通 `info` / `debug` 日志。保留原有结构化字段、时间、代码位置和 Error 及以上堆栈,不额外采集请求正文或工具输出。
- `log.diagnostic_dir`:默认 `log`,相对于进程工作目录,文件名为 `diagnostic-YYYY-MM-DD.log`(JSON Lines,按服务器本地日期拆分)。只有出现诊断日志时才创建目录和文件;跨天后首次写入切换文件。
- `log.diagnostic_retention_days`:默认 14 天(含当天);省略或小于等于 0 时使用默认值。每天首次写入时清理此目录内过期的 `diagnostic-日期.log`,不删除其他文件;没有新诊断日志时不执行清理。
- `log.diagnostic_disabled: true`:关闭额外诊断落盘。以上日志配置修改后需重启;目录无法写入时保留原输出,并由 Zap 向 stderr 报告写入失败。
## AI 通道与模型配置
-1
View File
@@ -96,7 +96,6 @@ AI 测试角色不是安全授权边界。即使选择了“渗透测试”角
| 攻击链 | `attackchain:read``attackchain:write` |
| 网络空间测绘 / 信息收集 | `fofa:execute` |
| OpenAPI | `openapi:read` |
| 对话分组 | `group:read``group:write``group:delete` |
| 执行监控 | `monitor:read``monitor:write``monitor:delete` |
特殊权限说明:
+38
View File
@@ -0,0 +1,38 @@
# 调用拦截
侧边栏「安全防护」包含「人机协同」和「调用拦截」。人机协同保留原有审批、白名单、审计策略和日志功能;调用拦截对内部 MCP、外部 MCP 和 HTTP MCP 工具调用增加独立的执行前检查。
独立的 `cmd/mcp-stdio` 服务也会加载相同拦截规则;该服务是单独进程,网页保存后需重启它以加载更新。
## 使用
1. 打开「安全防护 → 调用拦截」。默认开启「政府网站保护」,匹配 `.gov``.gov.cn` 等政府域名及通配符写法,忽略大小写。旧配置没有 `tool_guard` 时也启用默认保护。
2. 规则默认折叠,列表展示名称、提醒摘要和启停开关。点击规则展开编辑;「添加规则」打开独立弹窗,可以填写并验证尚未添加的规则。点击「添加到列表」通过正则校验后加入页面草稿,取消不会留下空规则。校验失败会定位到对应字段。可以单独启停规则,也可以关闭总开关。
3. 点击页面顶部「全部规则验证」按当前顺序及启停状态检查全部规则。在已有规则编辑区点击「验证本条」,就在该规则下方输入工具名和 JSON 参数、查看命中文本与最终提醒;新增规则的单条验证直接在弹窗中完成。单条验证忽略总开关和该规则的启停状态,适合调试未启用规则。单条和全部验证分别保留输入与结果,均使用未保存的表单配置,不执行工具,也不改变运行中的规则。
4. 点击保存。服务端校验全部规则后写入 `config.yaml``tool_guard`,立即生效,无需重启。校验或写入失败会保留原有规则。直接编辑 YAML 后需要重启服务;非空 `tool_guard` 必须明确填写 `enabled``rules`,清空规则使用 `[]`
规则按列表顺序检查,首先命中的启用规则决定提醒。检查对象包括工具名称、参数的 JSON 表示、嵌套字符串和键名,以及最多三轮常见 URL 百分号解码后的文本。使用 Go/RE2 正则语法,例如 `(?i)` 表示忽略大小写;不支持回溯引用和环视。最多 100 条规则,正则和提醒各最多 4096 字节。禁用的规则也须通过校验。
提醒支持以下占位符,留空则使用通用提醒:
| 占位符 | 内容 |
| --- | --- |
| `{match}` | 匹配文本;正则含命名捕获组 `(?P<match>...)` 时使用该组 |
| `{tool}` | 工具名称 |
| `{rule}` | 规则名称 |
示例提醒:`识别到 {match},禁止攻击政府网站,请检查目标与授权范围。`
命中后,工具处理器或外部客户端不会执行该调用。界面和执行记录使用独立的「已拦截」状态,并保留拦截原因;监控单独统计拦截次数,不计入调用失败或成功率的分母。升级启动时,可明确识别的旧版安全规则拦截记录会自动归入此状态。返回给 Agent 的 MCP 结果仍保留 `isError: true`,同时携带 `blocked: true`,以明确表示请求未执行。外部 MCP 的规则拦截不会算作服务故障而触发熔断。
## 权限与边界
查看和试匹配需要 `config:read`;修改需要 `config:write` 和全局权限范围。规则配置变更写入系统审计日志。HITL 的关闭状态、免审批白名单和审批通过结果均不能覆盖调用拦截;审批后编辑的参数也会在实际执行入口检查。规则更新影响后续执行检查,不能撤销已经发出的调用。
这是文本规则防护,不能代替目标授权或网络隔离:它无法可靠识别仅以 IP 表示的政府目标、DNS 别名背后的机构、工具执行后的重定向、文件中才出现的目标或任意混淆编码。它只覆盖经过本应用 MCP 执行入口的调用;直接终端操作、可选的 Agent 本地执行工具等独立入口不在此范围内。参数中仅引用政府域名的说明文本也可能被保守拦截。请保留人机协同,并结合实际授权范围维护规则。
## API
- `GET /api/tool-guard`:返回生效配置 `{enabled, rules}`
- `PUT /api/tool-guard`:保存相同结构;每条规则含 `id``name``enabled``pattern``message`。必须明确提供总开关和规则数组。
- `POST /api/tool-guard/test`:请求 `{config, toolName, arguments}`,响应 `{blocked, match?}``match``ruleId``ruleName``matchedText``message`
+34 -5
View File
@@ -512,6 +512,7 @@ type ToolExecutionResult struct {
Result string
ExecutionID string
IsError bool
Blocked bool
}
func buildToolFailureMessage(toolName, detail string, err error) string {
@@ -612,6 +613,7 @@ func (a *Agent) executeToolViaMCP(ctx context.Context, toolName string, args map
Result: resultStr,
ExecutionID: executionID,
IsError: result != nil && result.IsError,
Blocked: result != nil && result.Blocked,
}, nil
}
@@ -815,6 +817,10 @@ func (a *Agent) UpdateMCPExecutionDisplayResult(executionID, resultText string)
tr := &mcp.ToolResult{
Content: []mcp.Content{{Type: "text", Text: text}},
}
if exec := a.mcpExecution(executionID); exec != nil && exec.Result != nil {
tr.IsError = exec.Result.IsError
tr.Blocked = exec.Result.Blocked
}
if a.mcpServer != nil {
_ = a.mcpServer.UpdateToolExecutionResult(executionID, tr)
}
@@ -823,16 +829,39 @@ func (a *Agent) UpdateMCPExecutionDisplayResult(executionID, resultText string)
// MCPExecutionResultText returns the monitor-facing result text after storage
// guards such as large-output spilling have been applied.
func (a *Agent) MCPExecutionResultText(executionID string) string {
if a == nil || a.mcpServer == nil || strings.TrimSpace(executionID) == "" {
return ""
}
exec, ok := a.mcpServer.GetExecution(executionID)
if !ok || exec == nil || exec.Result == nil {
exec := a.mcpExecution(executionID)
if exec == nil || exec.Result == nil {
return ""
}
return mcp.ToolResultPlainText(exec.Result)
}
// MCPExecutionStatus returns the recorded outcome independently of model-facing
// text reduction, which can remove the original refusal wording.
func (a *Agent) MCPExecutionStatus(executionID string) string {
if exec := a.mcpExecution(executionID); exec != nil {
return exec.Status
}
return ""
}
func (a *Agent) mcpExecution(executionID string) *mcp.ToolExecution {
if a == nil || strings.TrimSpace(executionID) == "" {
return nil
}
if a.mcpServer != nil {
if exec, ok := a.mcpServer.GetExecution(executionID); ok && exec != nil {
return exec
}
}
if a.externalMCPMgr != nil {
if exec, ok := a.externalMCPMgr.GetExecution(executionID); ok {
return exec
}
}
return nil
}
// CancelMCPToolExecutionWithNote 取消一次进行中的 MCP 工具(先内部后外部),与监控页「终止工具」一致;note 非空时合并进返回给模型的文本。
func (a *Agent) CancelMCPToolExecutionWithNote(executionID, note string) bool {
executionID = strings.TrimSpace(executionID)
+14 -17
View File
@@ -33,6 +33,7 @@ import (
"cyberstrike-ai/internal/robot"
"cyberstrike-ai/internal/security"
"cyberstrike-ai/internal/skillpackage"
"cyberstrike-ai/internal/toolguard"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
@@ -76,6 +77,10 @@ type App struct {
// New 创建新应用
func New(cfg *config.Config, log *logger.Logger, configPath string) (*App, error) {
toolGuard, err := toolguard.NewManager(cfg.EffectiveToolGuard())
if err != nil {
return nil, fmt.Errorf("初始化调用拦截规则: %w", err)
}
if err := multiagent.InitADK(); err != nil {
return nil, fmt.Errorf("初始化 Eino ADK: %w", err)
}
@@ -147,6 +152,7 @@ func New(cfg *config.Config, log *logger.Logger, configPath string) (*App, error
// 创建MCP服务器(带数据库持久化)
mcpServer := mcp.NewServerWithStorage(log.Logger, db)
mcpServer.SetToolAuthorizer(mcpToolAuthorizer(db))
mcpServer.SetToolGuard(toolGuard)
mcpServer.ConfigureHTTPToolCallTimeoutFromAgentMinutes(cfg.Agent.ToolTimeoutMinutes)
mcpServer.ConfigureToolWaitTimeoutSeconds(cfg.Agent.ToolWaitTimeoutSeconds)
mcpServer.ConfigureToolResultMaxBytes(cfg.MultiAgent.EinoMiddleware.ReductionMaxLengthForTruncEffective())
@@ -170,6 +176,7 @@ func New(cfg *config.Config, log *logger.Logger, configPath string) (*App, error
// 创建外部MCP管理器(使用与内部MCP服务器相同的存储)
externalMCPMgr := mcp.NewExternalMCPManagerWithStorage(log.Logger, db)
externalMCPMgr.SetToolAuthorizer(externalMCPToolAuthorizer())
externalMCPMgr.SetToolGuard(toolGuard)
externalMCPMgr.ConfigureToolWaitTimeoutSeconds(cfg.Agent.ToolWaitTimeoutSeconds)
externalMCPMgr.ConfigureToolResultMaxBytes(cfg.MultiAgent.EinoMiddleware.ReductionMaxLengthForTruncEffective())
externalMCPMgr.ConfigureToolResultSpillRoot(cfg.MultiAgent.EinoMiddleware.ReductionRootDir)
@@ -391,7 +398,6 @@ func New(cfg *config.Config, log *logger.Logger, configPath string) (*App, error
monitorHandler.SetTaskManager(agentHandler.TaskManager())
monitorHandler.SetAgentHandler(agentHandler)
notificationHandler := handler.NewNotificationHandler(db, agentHandler, log.Logger)
groupHandler := handler.NewGroupHandler(db, log.Logger)
authHandler := handler.NewAuthHandler(authManager, cfg, configPath, log.Logger)
authHandler.SetAudit(auditSvc)
attackChainHandler := handler.NewAttackChainHandler(db, &cfg.OpenAI, log.Logger)
@@ -413,6 +419,7 @@ func New(cfg *config.Config, log *logger.Logger, configPath string) (*App, error
registerWebshellManagementTools(mcpServer, db, webshellHandler, log.Logger)
configHandler := handler.NewConfigHandler(configPath, cfg, mcpServer, executor, agent, attackChainHandler, externalMCPMgr, log.Logger)
configHandler.SetDB(db)
configHandler.SetToolGuard(toolGuard)
configHandler.SetAudit(auditSvc)
agentHandler.SetHitlToolWhitelistSaver(configHandler)
agentHandler.SetHitlAuditStrategySaver(configHandler)
@@ -567,7 +574,6 @@ func New(cfg *config.Config, log *logger.Logger, configPath string) (*App, error
conversationHandler,
robotHandler,
wechatRobotHandler,
groupHandler,
configHandler,
externalMCPHandler,
attackChainHandler,
@@ -870,7 +876,6 @@ func setupRoutes(
conversationHandler *handler.ConversationHandler,
robotHandler *handler.RobotHandler,
wechatRobotHandler *handler.WechatRobotHandler,
groupHandler *handler.GroupHandler,
configHandler *handler.ConfigHandler,
externalMCPHandler *handler.ExternalMCPHandler,
attackChainHandler *handler.AttackChainHandler,
@@ -972,6 +977,8 @@ func setupRoutes(
protected.GET("/hitl/tool-whitelist", agentHandler.GetHITLGlobalToolWhitelist)
protected.PUT("/hitl/tool-whitelist", agentHandler.SetHITLGlobalToolWhitelist)
protected.POST("/hitl/tool-whitelist", agentHandler.MergeHITLGlobalToolWhitelist)
protected.GET("/hitl/default-config", agentHandler.GetHITLDefaultConfig)
protected.PUT("/hitl/default-config", agentHandler.UpdateHITLDefaultConfig)
protected.GET("/hitl/default-reviewer", agentHandler.GetHITLDefaultReviewer)
protected.PUT("/hitl/default-reviewer", agentHandler.UpdateHITLDefaultReviewer)
protected.GET("/hitl/audit-strategy", agentHandler.GetHITLAuditStrategy)
@@ -1039,20 +1046,7 @@ func setupRoutes(
protected.PUT("/conversations/:id/project", conversationHandler.SetConversationProject)
protected.DELETE("/conversations/:id", conversationHandler.DeleteConversation)
protected.POST("/conversations/:id/delete-turn", conversationHandler.DeleteConversationTurn)
protected.PUT("/conversations/:id/pinned", groupHandler.UpdateConversationPinned)
// 对话分组
protected.POST("/groups", groupHandler.CreateGroup)
protected.GET("/groups", groupHandler.ListGroups)
protected.GET("/groups/:id", groupHandler.GetGroup)
protected.PUT("/groups/:id", groupHandler.UpdateGroup)
protected.DELETE("/groups/:id", groupHandler.DeleteGroup)
protected.PUT("/groups/:id/pinned", groupHandler.UpdateGroupPinned)
protected.GET("/groups/:id/conversations", groupHandler.GetGroupConversations)
protected.GET("/groups/mappings", groupHandler.GetAllMappings)
protected.POST("/groups/conversations", groupHandler.AddConversationToGroup)
protected.DELETE("/groups/:id/conversations/:conversationId", groupHandler.RemoveConversationFromGroup)
protected.PUT("/groups/:id/conversations/:conversationId/pinned", groupHandler.UpdateConversationPinnedInGroup)
protected.PUT("/conversations/:id/pinned", conversationHandler.UpdateConversationPinned)
// 监控
protected.GET("/monitor", monitorHandler.Monitor)
@@ -1068,6 +1062,9 @@ func setupRoutes(
// 配置管理
protected.GET("/config", configHandler.GetConfig)
protected.GET("/tool-guard", configHandler.GetToolGuard)
protected.PUT("/tool-guard", configHandler.UpdateToolGuard)
protected.POST("/tool-guard/test", configHandler.TestToolGuard)
protected.GET("/config/tools", configHandler.GetTools)
protected.GET("/config/tools/:name/schema", configHandler.GetToolSchema)
protected.PUT("/config", configHandler.UpdateConfig)
+91 -3
View File
@@ -6,12 +6,14 @@ import (
"encoding/json"
"fmt"
"io/fs"
"net/url"
"os"
"path/filepath"
"strconv"
"strings"
"cyberstrike-ai/internal/termout"
"cyberstrike-ai/internal/toolguard"
"gopkg.in/yaml.v3"
)
@@ -29,6 +31,7 @@ type Config struct {
Shodan SpaceSearchConfig `yaml:"shodan,omitempty" json:"shodan,omitempty"`
Agent AgentConfig `yaml:"agent"`
Hitl HitlConfig `yaml:"hitl,omitempty" json:"hitl,omitempty"`
ToolGuard *toolguard.Config `yaml:"tool_guard,omitempty" json:"tool_guard,omitempty"`
Security SecurityConfig `yaml:"security"`
Database DatabaseConfig `yaml:"database"`
Auth AuthConfig `yaml:"auth"`
@@ -804,8 +807,11 @@ type ServerConfig struct {
}
type LogConfig struct {
Level string `yaml:"level"`
Output string `yaml:"output"`
Level string `yaml:"level"`
Output string `yaml:"output"`
DiagnosticDir string `yaml:"diagnostic_dir"`
DiagnosticDisabled bool `yaml:"diagnostic_disabled"`
DiagnosticRetentionDays int `yaml:"diagnostic_retention_days"`
}
type MCPConfig struct {
@@ -946,10 +952,12 @@ func (c *Config) ApplyDefaultAIChannel() {
if c == nil {
return
}
c.NormalizeAIProviderProfiles()
c.AI.EnsureDefaultFromOpenAI(c.OpenAI)
if oa, _, ok := c.AI.ResolveChannel(c.AI.DefaultChannel); ok {
c.OpenAI = oa
}
c.NormalizeAIProviderProfiles()
}
func (c OpenAIConfig) MaxCompletionTokensEffective() int {
@@ -968,6 +976,50 @@ func (c OpenAIConfig) IsDeepSeekEndpointOrModel() bool {
return strings.Contains(baseURL, "deepseek")
}
func (c OpenAIConfig) IsDeepSeekOfficialEndpoint() bool {
host := normalizedURLHost(c.BaseURL)
return host == "api.deepseek.com"
}
func normalizedURLHost(raw string) string {
raw = strings.TrimSpace(raw)
if raw == "" {
return ""
}
parsed, err := url.Parse(raw)
if err != nil || parsed.Host == "" {
parsed, err = url.Parse("https://" + strings.TrimLeft(raw, "/"))
if err != nil {
return ""
}
}
return strings.ToLower(strings.TrimPrefix(parsed.Hostname(), "www."))
}
func NormalizeOpenAIProviderProfile(oa *OpenAIConfig) {
if oa == nil {
return
}
if oa.IsDeepSeekOfficialEndpoint() {
oa.Reasoning.Profile = "deepseek"
}
}
func (c *Config) NormalizeAIProviderProfiles() {
if c == nil {
return
}
NormalizeOpenAIProviderProfile(&c.OpenAI)
if c.AI.Channels != nil {
for id, ch := range c.AI.Channels {
oa := ch.ToOpenAIConfig()
NormalizeOpenAIProviderProfile(&oa)
ch.Reasoning = oa.Reasoning
c.AI.Channels[id] = ch
}
}
}
// OpenAIReasoningConfig 全局默认与网关 profile(对话页可通过 ChatRequest.reasoning 覆盖,受 AllowClientReasoning 约束)。
type OpenAIReasoningConfig struct {
// Mode: auto(默认)| on | off | default(与 auto 相同)。
@@ -1062,8 +1114,24 @@ type HitlConfig struct {
AuditAgentPromptReviewEdit string `yaml:"audit_agent_prompt_review_edit,omitempty" json:"audit_agent_prompt_review_edit,omitempty"`
// RetentionDays 已决策审计日志(hitl_interrupts 非 pending)保留天数;省略时默认 90;0 表示不自动清理。
RetentionDays *int `yaml:"retention_days,omitempty" json:"retention_days,omitempty"`
// DefaultReviewer 全局默认审批方(human | audit_agent);未选会话时切换会写入 config.yaml;新建会话无独立配置时沿用。
// DefaultMode 全局默认人机协同模式(off | approval | review_edit;新建会话无独立配置时沿用。
DefaultMode string `yaml:"default_mode,omitempty" json:"default_mode,omitempty"`
// DefaultReviewer 全局默认审批方(human | audit_agent);新建会话无独立配置时沿用。
DefaultReviewer string `yaml:"default_reviewer,omitempty" json:"default_reviewer,omitempty"`
// DefaultTimeoutSeconds 全局默认审批等待秒数;nil 表示使用前端历史默认 300 秒,0 表示不限时。
DefaultTimeoutSeconds *int `yaml:"default_timeout_seconds,omitempty" json:"default_timeout_seconds,omitempty"`
}
// EffectiveDefaultMode returns off, approval, or review_edit; omitted or unknown values default to off.
func (h HitlConfig) EffectiveDefaultMode() string {
switch strings.ToLower(strings.TrimSpace(h.DefaultMode)) {
case "feedback", "followup":
return "approval"
case "approval", "review_edit":
return strings.ToLower(strings.TrimSpace(h.DefaultMode))
default:
return "off"
}
}
// EffectiveDefaultReviewer returns human or audit_agent; omitted or unknown values default to human.
@@ -1076,6 +1144,17 @@ func (h HitlConfig) EffectiveDefaultReviewer() string {
}
}
// EffectiveDefaultTimeoutSeconds returns the default HITL approval timeout; nil defaults to 5 minutes.
func (h HitlConfig) EffectiveDefaultTimeoutSeconds() int {
if h.DefaultTimeoutSeconds == nil {
return 300
}
if *h.DefaultTimeoutSeconds < 0 {
return 0
}
return *h.DefaultTimeoutSeconds
}
// RetentionDaysEffective returns retention; 0 means keep forever; omitted defaults to 90.
func (h HitlConfig) RetentionDaysEffective() int {
if h.RetentionDays == nil {
@@ -1365,6 +1444,14 @@ func Load(path string) (*Config, error) {
if err := yaml.Unmarshal(data, &cfg); err != nil {
return nil, fmt.Errorf("解析配置文件失败: %w", err)
}
if cfg.ToolGuard != nil {
if err := validateToolGuardYAML(data); err != nil {
return nil, fmt.Errorf("调用拦截配置无效: %w", err)
}
}
if _, err := toolguard.Compile(cfg.EffectiveToolGuard()); err != nil {
return nil, fmt.Errorf("调用拦截配置无效: %w", err)
}
if cfg.Auth.SessionDurationHours <= 0 {
cfg.Auth.SessionDurationHours = 12
@@ -1372,6 +1459,7 @@ func Load(path string) (*Config, error) {
if cfg.Audit.MaxDetailBytes <= 0 {
cfg.Audit.MaxDetailBytes = 8192
}
cfg.NormalizeAIProviderProfiles()
cfg.ApplyDefaultAIChannel()
if err := validateOpenAIOutputLimits(cfg.OpenAI); err != nil {
return nil, err
+101
View File
@@ -95,6 +95,29 @@ func TestHitlAuditModelEffectiveFallsBackToMainConfig(t *testing.T) {
}
}
func TestHitlDefaultConfigEffectiveValues(t *testing.T) {
if got := (HitlConfig{}).EffectiveDefaultMode(); got != "off" {
t.Fatalf("empty default mode = %q, want off", got)
}
if got := (HitlConfig{DefaultMode: "review-edit"}).EffectiveDefaultMode(); got != "off" {
t.Fatalf("unknown default mode = %q, want off", got)
}
if got := (HitlConfig{DefaultMode: "review_edit"}).EffectiveDefaultMode(); got != "review_edit" {
t.Fatalf("review_edit default mode = %q, want review_edit", got)
}
if got := (HitlConfig{}).EffectiveDefaultTimeoutSeconds(); got != 300 {
t.Fatalf("empty default timeout = %d, want 300", got)
}
zero := 0
if got := (HitlConfig{DefaultTimeoutSeconds: &zero}).EffectiveDefaultTimeoutSeconds(); got != 0 {
t.Fatalf("zero default timeout = %d, want 0", got)
}
neg := -1
if got := (HitlConfig{DefaultTimeoutSeconds: &neg}).EffectiveDefaultTimeoutSeconds(); got != 0 {
t.Fatalf("negative default timeout = %d, want 0", got)
}
}
func TestLoadUsesAIDefaultChannelAsRuntimeOpenAI(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "config.yaml")
@@ -137,6 +160,84 @@ func TestLoadUsesAIDefaultChannelAsRuntimeOpenAI(t *testing.T) {
}
}
func TestNormalizeAIProviderProfilesForOfficialDeepSeekEndpoint(t *testing.T) {
cfg := &Config{
OpenAI: OpenAIConfig{
BaseURL: "https://api.deepseek.com/v1",
Model: "deepseek-chat",
Reasoning: OpenAIReasoningConfig{
Profile: "openai_compat",
},
},
AI: AIConfig{
Channels: map[string]AIChannelConfig{
"official": {
BaseURL: "api.deepseek.com/v1",
Model: "deepseek-chat",
Reasoning: OpenAIReasoningConfig{
Profile: "auto",
},
},
"gateway": {
BaseURL: "https://compatible.example.com/v1",
Model: "deepseek-chat",
Reasoning: OpenAIReasoningConfig{
Profile: "openai_compat",
},
},
},
},
}
cfg.NormalizeAIProviderProfiles()
if cfg.OpenAI.Reasoning.Profile != "deepseek" {
t.Fatalf("openai profile = %q, want deepseek", cfg.OpenAI.Reasoning.Profile)
}
if got := cfg.AI.Channels["official"].Reasoning.Profile; got != "deepseek" {
t.Fatalf("official channel profile = %q, want deepseek", got)
}
if got := cfg.AI.Channels["gateway"].Reasoning.Profile; got != "openai_compat" {
t.Fatalf("gateway profile should be preserved, got %q", got)
}
}
func TestLoadNormalizesDefaultChannelForOfficialDeepSeekEndpoint(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "config.yaml")
initial := strings.Join([]string{
"ai:",
" default_channel: deepseek",
" channels:",
" deepseek:",
" name: DeepSeek",
" provider: openai_compatible",
" base_url: https://api.deepseek.com/v1",
" api_key: deepseek-key",
" model: deepseek-chat",
" reasoning:",
" profile: openai_compat",
"server:",
" host: 127.0.0.1",
" port: 8080",
"",
}, "\n")
if err := os.WriteFile(path, []byte(initial), 0644); err != nil {
t.Fatalf("write config: %v", err)
}
cfg, err := Load(path)
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.OpenAI.Reasoning.Profile != "deepseek" {
t.Fatalf("runtime OpenAI profile = %q, want deepseek", cfg.OpenAI.Reasoning.Profile)
}
if got := cfg.AI.Channels["deepseek"].Reasoning.Profile; got != "deepseek" {
t.Fatalf("channel profile = %q, want deepseek", got)
}
}
func TestSummarizationUserIntentLedgerRunesEffective(t *testing.T) {
var zero MultiAgentEinoMiddlewareConfig
if got := zero.SummarizationUserIntentLedgerMaxRunesEffective(); got != DefaultSummarizationUserIntentLedgerMaxRunes {
+39
View File
@@ -0,0 +1,39 @@
package config
import (
"fmt"
"cyberstrike-ai/internal/toolguard"
"gopkg.in/yaml.v3"
)
// EffectiveToolGuard enables the default government-domain protection for old
// configurations as well as new installs. An explicit config may disable it.
func (c *Config) EffectiveToolGuard() toolguard.Config {
if c.ToolGuard == nil {
return toolguard.DefaultConfig()
}
return *c.ToolGuard
}
// validateToolGuardYAML requires an explicit decision for both protection and
// its rules whenever a non-null section is supplied. Otherwise a typo or partial
// section could silently turn the enabled-by-default protection off. Pointer
// fields distinguish false/[] from omitted or null values, and the YAML decoder
// continues to support aliases and merged configuration mappings.
func validateToolGuardYAML(data []byte) error {
var document struct {
ToolGuard *struct {
Enabled *bool `yaml:"enabled"`
Rules *[]toolguard.Rule `yaml:"rules"`
} `yaml:"tool_guard"`
}
if err := yaml.Unmarshal(data, &document); err != nil {
return err
}
if section := document.ToolGuard; section != nil && (section.Enabled == nil || section.Rules == nil) {
return fmt.Errorf("tool_guard 必须明确提供 enabled 和 rules;清空规则请提供空数组")
}
return nil
}
+45
View File
@@ -0,0 +1,45 @@
package config
import (
"os"
"path/filepath"
"testing"
)
func TestLoadToolGuardDefaultsAndValidation(t *testing.T) {
for _, tc := range []struct {
name, yaml string
enabled, wantErr bool
}{
{"legacy config", "server: {port: 8080}\n", true, false},
{"null section", "tool_guard: null\n", true, false},
{"implicit null section", "tool_guard:\n", true, false},
{"explicit off", "tool_guard: {enabled: false, rules: []}\n", false, false},
{"explicit empty", "tool_guard: {enabled: true, rules: []}\n", true, false},
{"merged explicit config", "guard_defaults: &guard_defaults {enabled: false, rules: []}\ntool_guard: {<<: *guard_defaults}\n", false, false},
{"empty section", "tool_guard: {}\n", false, true},
{"missing enabled", "tool_guard: {rules: []}\n", false, true},
{"null enabled", "tool_guard: {enabled: null, rules: []}\n", false, true},
{"missing rules while off", "tool_guard: {enabled: false}\n", false, true},
{"missing rules while on", "tool_guard: {enabled: true}\n", false, true},
{"null rules", "tool_guard: {enabled: false, rules: null}\n", false, true},
{"mistyped enabled field", "tool_guard: {enable: false, rules: []}\n", false, true},
{"malformed rules while off", "tool_guard: {enabled: false, rules: disabled}\n", false, true},
{"malformed rule while off", "tool_guard: {enabled: false, rules: [invalid]}\n", false, true},
{"invalid pattern", "tool_guard:\n enabled: false\n rules:\n - {id: invalid, name: invalid, enabled: false, pattern: '['}\n", false, true},
} {
t.Run(tc.name, func(t *testing.T) {
path := filepath.Join(t.TempDir(), "config.yaml")
if err := os.WriteFile(path, []byte(tc.yaml), 0600); err != nil {
t.Fatal(err)
}
cfg, err := Load(path)
if (err != nil) != tc.wantErr {
t.Fatalf("load error: %v", err)
}
if err == nil && cfg.EffectiveToolGuard().Enabled != tc.enabled {
t.Fatal("wrong effective enabled state")
}
})
}
}
+120
View File
@@ -0,0 +1,120 @@
package database
import (
"fmt"
"path/filepath"
"testing"
"time"
"cyberstrike-ai/internal/mcp"
"go.uber.org/zap"
)
func TestBlockedExecutionPersistenceStatsAndReconciliation(t *testing.T) {
db, conversationID, _ := setupProcessDetailsSummaryTest(t)
now := time.Now()
for _, status := range []string{"completed", "failed", "blocked", "cancelled"} {
result := &mcp.ToolResult{Content: []mcp.Content{{Type: "text", Text: "policy message"}}, IsError: status != "completed", Blocked: status == "blocked"}
if err := db.SaveToolExecution(&mcp.ToolExecution{ID: status, ToolName: "test", Status: status, Result: result, StartTime: now.Add(-time.Minute), EndTime: &now, ConversationID: conversationID}); err != nil {
t.Fatal(err)
}
}
if err := db.UpdateToolStats("test", 4, 1, 1, &now); err != nil {
t.Fatal(err)
}
if err := db.UpdateToolExecutionResult("blocked", &mcp.ToolResult{Content: []mcp.Content{{Type: "text", Text: "reduced output"}}}); err != nil {
t.Fatal(err)
}
reloaded, err := db.GetToolExecution("blocked")
if err != nil || reloaded.Status != "blocked" || !reloaded.Result.Blocked || !reloaded.Result.IsError || reloaded.Result.Content[0].Text != "reduced output" {
t.Fatalf("reduction/storage lost blocked classification: %#v err=%v", reloaded, err)
}
count, err := db.CancelOrphanedRunningToolExecutions(now, "restart")
if err != nil || count != 0 {
t.Fatalf("terminal blocks reclassified as orphaned: count=%d err=%v", count, err)
}
page, err := db.LoadToolExecutionListPage(0, 10, "blocked", "")
if err != nil || len(page) != 1 || page[0].ID != "blocked" {
t.Fatalf("blocked status filter failed: %#v err=%v", page, err)
}
summary, err := db.LoadToolStatsSummary(1)
if err != nil {
t.Fatal(err)
}
if summary.Summary.TotalCalls != 4 || summary.Summary.SuccessCalls != 1 || summary.Summary.FailedCalls != 1 || summary.Summary.BlockedCalls != 1 || summary.TopTools[0].BlockedCalls != 1 {
t.Fatalf("incorrect summary: %#v top=%#v", summary.Summary, summary.TopTools)
}
stats, err := db.LoadToolStats()
if err != nil || stats["test"].BlockedCalls != 1 || stats["test"].FailedCalls != 1 {
t.Fatalf("incorrect legacy stats: %#v err=%v", stats, err)
}
for _, daily := range []bool{false, true} {
buckets, err := db.LoadCallsTimeline(now.Add(-time.Hour), daily)
if err != nil || len(buckets) != 1 || buckets[0].Total != 4 || buckets[0].Failed != 1 || buckets[0].Blocked != 1 {
t.Fatalf("incorrect timeline daily=%v: %#v err=%v", daily, buckets, err)
}
}
}
func TestLegacyToolGuardBlockMigrationIsStrictAndIdempotent(t *testing.T) {
path := filepath.Join(t.TempDir(), "legacy-guard.db")
db, err := NewDB(path, zap.NewNop())
if err != nil {
t.Fatal(err)
}
now := time.Now()
refusal := "工具调用已被安全规则拦截:识别到 example.gov,禁止操作。\n规则: 政府网站保护 (government-domains)\n匹配内容: \"example.gov\""
for i, reason := range []string{
refusal,
"upstream returned: " + refusal,
"工具调用已被安全规则拦截:regular error without the envelope",
"工具调用已被安全规则拦截:malformed match\n规则: Rule (id)\n匹配内容: unquoted",
} {
if err := db.SaveToolExecution(&mcp.ToolExecution{ID: fmt.Sprint(i), ToolName: "test", Status: "failed", Error: reason, StartTime: now, EndTime: &now}); err != nil {
t.Fatal(err)
}
}
if err := db.UpdateToolStats("test", 4, 0, 4, &now); err != nil {
t.Fatal(err)
}
if err := db.Close(); err != nil {
t.Fatal(err)
}
for run := 0; run < 2; run++ {
db, err = NewDB(path, zap.NewNop())
if err != nil {
t.Fatal(err)
}
exec, err := db.GetToolExecution("0")
if err != nil || exec.Status != "blocked" || !exec.Result.Blocked || !exec.Result.IsError || exec.Result.Content[0].Text != refusal {
t.Fatalf("migration did not retain refusal: %#v err=%v", exec, err)
}
stats, err := db.LoadToolStats()
if err != nil || stats["test"].TotalCalls != 4 || stats["test"].FailedCalls != 3 || stats["test"].BlockedCalls != 1 {
t.Fatalf("migration run=%d stats=%#v err=%v", run, stats, err)
}
count, err := db.CountToolExecutions("failed", "")
if err != nil || count != 3 {
t.Fatalf("migration changed unrelated failures: count=%d err=%v", count, err)
}
if err := db.Close(); err != nil {
t.Fatal(err)
}
}
}
func TestToolResultStatusFromPayloadDistinguishesBlocked(t *testing.T) {
for _, tc := range []struct {
payload map[string]interface{}
want string
}{
{map[string]interface{}{"blocked": true, "success": false, "isError": true}, "blocked"},
{map[string]interface{}{"status": "blocked", "success": false}, "blocked"},
{map[string]interface{}{"success": false, "isError": true, "result": "工具调用已被安全规则拦截"}, "failed"},
{map[string]interface{}{"success": true}, "completed"},
} {
if got := toolResultStatusFromPayload(tc.payload, "tool_result"); got != tc.want {
t.Fatalf("payload=%#v status=%s want=%s", tc.payload, got, tc.want)
}
}
}
+19 -76
View File
@@ -665,81 +665,6 @@ func scanConversationRows(rows *sql.Rows) ([]*Conversation, error) {
return conversations, rows.Err()
}
const ungroupedConversationsSQL = `
FROM conversations c
WHERE NOT EXISTS (
SELECT 1 FROM conversation_group_mappings cgm WHERE cgm.conversation_id = c.id
)`
// CountUngroupedConversations 统计不在任何分组中的对话数量。
func (db *DB) CountUngroupedConversations(projectID string) (int, error) {
where := ungroupedConversationsSQL
args := []interface{}{}
where, args = appendConversationProjectFilter(where, args, projectID, "c")
var count int
if err := db.QueryRow(`SELECT COUNT(*) `+where, args...).Scan(&count); err != nil {
return 0, fmt.Errorf("统计未分组对话失败: %w", err)
}
return count, nil
}
func (db *DB) CountUngroupedConversationsForAccess(projectID, userID, scope string) (int, error) {
where := ungroupedConversationsSQL
args := []interface{}{}
where, args = appendConversationProjectFilter(where, args, projectID, "c")
where, args = appendConversationAccessFilter(where, args, userID, scope, "c")
var count int
if err := db.QueryRow(`SELECT COUNT(*) `+where, args...).Scan(&count); err != nil {
return 0, fmt.Errorf("统计未分组对话失败: %w", err)
}
return count, nil
}
// ListUngroupedConversations 列出不在任何分组中的对话(最近对话侧栏)。
func (db *DB) ListUngroupedConversations(limit, offset int, sortBy, projectID string) ([]*Conversation, error) {
orderClause := conversationOrderClause(sortBy, "c")
where := ungroupedConversationsSQL
args := []interface{}{}
where, args = appendConversationProjectFilter(where, args, projectID, "c")
args = append(args, limit, offset)
rows, err := db.Query(
`SELECT c.id, c.title, COALESCE(c.pinned, 0), c.created_at, c.updated_at, c.project_id, c.role_name, c.agent_mode `+
where+`
`+orderClause+`
LIMIT ? OFFSET ?`,
args...,
)
if err != nil {
return nil, fmt.Errorf("查询未分组对话失败: %w", err)
}
defer rows.Close()
return scanConversationRows(rows)
}
func (db *DB) ListUngroupedConversationsForAccess(limit, offset int, sortBy, projectID, userID, scope string) ([]*Conversation, error) {
if scope == RBACScopeAll || strings.TrimSpace(userID) == "" {
return db.ListUngroupedConversations(limit, offset, sortBy, projectID)
}
orderClause := conversationOrderClause(sortBy, "c")
where := ungroupedConversationsSQL
args := []interface{}{}
where, args = appendConversationProjectFilter(where, args, projectID, "c")
where, args = appendConversationAccessFilter(where, args, userID, scope, "c")
args = append(args, limit, offset)
rows, err := db.Query(
`SELECT c.id, c.title, COALESCE(c.pinned, 0), c.created_at, c.updated_at, c.project_id, c.role_name, c.agent_mode `+
where+`
`+orderClause+`
LIMIT ? OFFSET ?`,
args...,
)
if err != nil {
return nil, fmt.Errorf("查询未分组对话失败: %w", err)
}
defer rows.Close()
return scanConversationRows(rows)
}
// GetConversationTitle 获取对话标题(轻量查询,不加载消息)
func (db *DB) GetConversationTitle(id string) (string, error) {
var title string
@@ -766,6 +691,22 @@ func (db *DB) UpdateConversationTitle(id, title string) error {
return nil
}
// UpdateConversationPinned 更新对话置顶状态
func (db *DB) UpdateConversationPinned(id string, pinned bool) error {
pinnedValue := 0
if pinned {
pinnedValue = 1
}
_, err := db.Exec(
"UPDATE conversations SET pinned = ?, updated_at = ? WHERE id = ?",
pinnedValue, time.Now(), id,
)
if err != nil {
return fmt.Errorf("更新对话置顶状态失败: %w", err)
}
return nil
}
// UpdateConversationTime 更新对话时间
func (db *DB) UpdateConversationTime(id string) error {
_, err := db.Exec(
@@ -784,7 +725,6 @@ func (db *DB) UpdateConversationTime(id string) error {
// - process_details(过程详情)
// - attack_chain_nodes(攻击链节点)
// - attack_chain_edges(攻击链边)
// - conversation_group_mappings(分组映射)
// 漏洞记录会保留:vulnerabilities.conversation_id 使用 ON DELETE SET NULL,仅解除与会话的关联。
// 注意:knowledge_retrieval_logs 在删除前会被显式清理。
func (db *DB) DeleteConversation(id string) error {
@@ -1697,6 +1637,9 @@ func toolResultStatusFromPayload(payload map[string]interface{}, eventType strin
if eventType != "tool_result" {
return ""
}
if blocked, _ := payload["blocked"].(bool); blocked || strings.EqualFold(processDetailString(payload, "status"), "blocked") {
return "blocked"
}
if status := processDetailString(payload, "status"); strings.EqualFold(status, "background_running") {
return "background_running"
}
+4 -90
View File
@@ -155,6 +155,10 @@ func NewDB(dbPath string, logger *zap.Logger) (*DB, error) {
_ = db.Close()
return nil, fmt.Errorf("初始化表失败: %w", err)
}
if err := database.migrateLegacyToolGuardBlocks(); err != nil {
_ = db.Close()
return nil, fmt.Errorf("迁移历史安全拦截记录失败: %w", err)
}
database.startPassiveCheckpointLoop("conversations")
return database, nil
@@ -329,29 +333,6 @@ func (db *DB) initTables() error {
FOREIGN KEY (message_id) REFERENCES messages(id) ON DELETE SET NULL
);`
// 创建对话分组表
createConversationGroupsTable := `
CREATE TABLE IF NOT EXISTS conversation_groups (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
icon TEXT,
owner_user_id TEXT,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL
);`
// 创建对话分组映射表
createConversationGroupMappingsTable := `
CREATE TABLE IF NOT EXISTS conversation_group_mappings (
id TEXT PRIMARY KEY,
conversation_id TEXT NOT NULL,
group_id TEXT NOT NULL,
created_at DATETIME NOT NULL,
FOREIGN KEY (conversation_id) REFERENCES conversations(id) ON DELETE CASCADE,
FOREIGN KEY (group_id) REFERENCES conversation_groups(id) ON DELETE CASCADE,
UNIQUE(conversation_id, group_id)
);`
// 机器人会话绑定表(用于跨重启保持「平台+租户+用户」到 conversation 的映射)
createRobotUserSessionsTable := `
CREATE TABLE IF NOT EXISTS robot_user_sessions (
@@ -759,8 +740,6 @@ func (db *DB) initTables() error {
CREATE INDEX IF NOT EXISTS idx_knowledge_retrieval_logs_conversation ON knowledge_retrieval_logs(conversation_id);
CREATE INDEX IF NOT EXISTS idx_knowledge_retrieval_logs_message ON knowledge_retrieval_logs(message_id);
CREATE INDEX IF NOT EXISTS idx_knowledge_retrieval_logs_created_at ON knowledge_retrieval_logs(created_at);
CREATE INDEX IF NOT EXISTS idx_conversation_group_mappings_conversation ON conversation_group_mappings(conversation_id);
CREATE INDEX IF NOT EXISTS idx_conversation_group_mappings_group ON conversation_group_mappings(group_id);
CREATE INDEX IF NOT EXISTS idx_robot_user_sessions_updated_at ON robot_user_sessions(updated_at);
CREATE INDEX IF NOT EXISTS idx_conversations_pinned ON conversations(pinned);
CREATE INDEX IF NOT EXISTS idx_vulnerabilities_conversation_id ON vulnerabilities(conversation_id);
@@ -864,13 +843,6 @@ func (db *DB) initTables() error {
return fmt.Errorf("创建knowledge_retrieval_logs表失败: %w", err)
}
if _, err := db.Exec(createConversationGroupsTable); err != nil {
return fmt.Errorf("创建conversation_groups表失败: %w", err)
}
if _, err := db.Exec(createConversationGroupMappingsTable); err != nil {
return fmt.Errorf("创建conversation_group_mappings表失败: %w", err)
}
if _, err := db.Exec(createRobotUserSessionsTable); err != nil {
return fmt.Errorf("创建robot_user_sessions表失败: %w", err)
}
@@ -966,16 +938,6 @@ func (db *DB) initTables() error {
// 不返回错误,允许继续运行
}
if err := db.migrateConversationGroupsTable(); err != nil {
db.logger.Warn("迁移conversation_groups表失败", zap.Error(err))
// 不返回错误,允许继续运行
}
if err := db.migrateConversationGroupMappingsTable(); err != nil {
db.logger.Warn("迁移conversation_group_mappings表失败", zap.Error(err))
// 不返回错误,允许继续运行
}
if err := db.migrateBatchTaskQueuesTable(); err != nil {
db.logger.Warn("迁移batch_task_queues表失败", zap.Error(err))
// 不返回错误,允许继续运行
@@ -1237,54 +1199,6 @@ func (db *DB) migrateConversationsTable() error {
return nil
}
// migrateConversationGroupsTable 迁移conversation_groups表,添加新字段
func (db *DB) migrateConversationGroupsTable() error {
// 检查pinned字段是否存在
var count int
err := db.QueryRow("SELECT COUNT(*) FROM pragma_table_info('conversation_groups') WHERE name='pinned'").Scan(&count)
if err != nil {
// 如果查询失败,尝试添加字段
if _, addErr := db.Exec("ALTER TABLE conversation_groups ADD COLUMN pinned INTEGER DEFAULT 0"); addErr != nil {
// 如果字段已存在,忽略错误
errMsg := strings.ToLower(addErr.Error())
if !strings.Contains(errMsg, "duplicate column") && !strings.Contains(errMsg, "already exists") {
db.logger.Warn("添加pinned字段失败", zap.Error(addErr))
}
}
} else if count == 0 {
// 字段不存在,添加它
if _, err := db.Exec("ALTER TABLE conversation_groups ADD COLUMN pinned INTEGER DEFAULT 0"); err != nil {
db.logger.Warn("添加pinned字段失败", zap.Error(err))
}
}
return nil
}
// migrateConversationGroupMappingsTable 迁移conversation_group_mappings表,添加新字段
func (db *DB) migrateConversationGroupMappingsTable() error {
// 检查pinned字段是否存在
var count int
err := db.QueryRow("SELECT COUNT(*) FROM pragma_table_info('conversation_group_mappings') WHERE name='pinned'").Scan(&count)
if err != nil {
// 如果查询失败,尝试添加字段
if _, addErr := db.Exec("ALTER TABLE conversation_group_mappings ADD COLUMN pinned INTEGER DEFAULT 0"); addErr != nil {
// 如果字段已存在,忽略错误
errMsg := strings.ToLower(addErr.Error())
if !strings.Contains(errMsg, "duplicate column") && !strings.Contains(errMsg, "already exists") {
db.logger.Warn("添加pinned字段失败", zap.Error(addErr))
}
}
} else if count == 0 {
// 字段不存在,添加它
if _, err := db.Exec("ALTER TABLE conversation_group_mappings ADD COLUMN pinned INTEGER DEFAULT 0"); err != nil {
db.logger.Warn("添加pinned字段失败", zap.Error(err))
}
}
return nil
}
// migrateBatchTaskQueuesTable 迁移batch_task_queues表,补充新字段
func (db *DB) migrateBatchTaskQueuesTable() error {
// 检查title字段是否存在
-486
View File
@@ -1,486 +0,0 @@
package database
import (
"database/sql"
"fmt"
"time"
"github.com/google/uuid"
)
// ConversationGroup 对话分组
type ConversationGroup struct {
ID string `json:"id"`
Name string `json:"name"`
Icon string `json:"icon"`
Pinned bool `json:"pinned"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
OwnerUserID string `json:"-"`
}
// GroupExistsByName 检查分组名称是否已存在
func (db *DB) GroupExistsByName(name string, excludeID string) (bool, error) {
return db.groupExistsByNameForOwner(name, excludeID, "")
}
func (db *DB) groupExistsByNameForOwner(name, excludeID, ownerUserID string) (bool, error) {
var count int
var err error
if ownerUserID != "" && excludeID != "" {
err = db.QueryRow("SELECT COUNT(*) FROM conversation_groups WHERE name = ? AND owner_user_id = ? AND id != ?", name, ownerUserID, excludeID).Scan(&count)
} else if ownerUserID != "" {
err = db.QueryRow("SELECT COUNT(*) FROM conversation_groups WHERE name = ? AND owner_user_id = ?", name, ownerUserID).Scan(&count)
} else if excludeID != "" {
err = db.QueryRow(
"SELECT COUNT(*) FROM conversation_groups WHERE name = ? AND id != ?",
name, excludeID,
).Scan(&count)
} else {
err = db.QueryRow(
"SELECT COUNT(*) FROM conversation_groups WHERE name = ?",
name,
).Scan(&count)
}
if err != nil {
return false, fmt.Errorf("检查分组名称失败: %w", err)
}
return count > 0, nil
}
// CreateGroup 创建分组
func (db *DB) CreateGroup(name, icon string, owners ...string) (*ConversationGroup, error) {
ownerUserID := ""
if len(owners) > 0 {
ownerUserID = owners[0]
}
// 检查名称是否已存在
exists, err := db.groupExistsByNameForOwner(name, "", ownerUserID)
if err != nil {
return nil, err
}
if exists {
return nil, fmt.Errorf("分组名称已存在")
}
id := uuid.New().String()
now := time.Now()
if icon == "" {
icon = "📁"
}
_, err = db.Exec(
"INSERT INTO conversation_groups (id, name, icon, pinned, owner_user_id, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)",
id, name, icon, 0, ownerUserID, now, now,
)
if err != nil {
return nil, fmt.Errorf("创建分组失败: %w", err)
}
return &ConversationGroup{
ID: id,
Name: name,
Icon: icon,
Pinned: false,
CreatedAt: now,
UpdatedAt: now,
OwnerUserID: ownerUserID,
}, nil
}
// ListGroups 列出所有分组
func (db *DB) ListGroups() ([]*ConversationGroup, error) {
return db.ListGroupsForAccess("", RBACScopeAll)
}
func (db *DB) ListGroupsForAccess(userID, scope string) ([]*ConversationGroup, error) {
query := "SELECT id, name, icon, COALESCE(pinned, 0), COALESCE(owner_user_id, ''), created_at, updated_at FROM conversation_groups"
args := []interface{}{}
if scope != RBACScopeAll {
query += " WHERE owner_user_id = ?"
args = append(args, userID)
}
query += " ORDER BY COALESCE(pinned, 0) DESC, created_at ASC"
rows, err := db.Query(
query, args...,
)
if err != nil {
return nil, fmt.Errorf("查询分组列表失败: %w", err)
}
defer rows.Close()
var groups []*ConversationGroup
for rows.Next() {
var group ConversationGroup
var createdAt, updatedAt string
var pinned int
if err := rows.Scan(&group.ID, &group.Name, &group.Icon, &pinned, &group.OwnerUserID, &createdAt, &updatedAt); err != nil {
return nil, fmt.Errorf("扫描分组失败: %w", err)
}
group.Pinned = pinned != 0
// 尝试多种时间格式解析
var err1, err2 error
group.CreatedAt, err1 = time.Parse("2006-01-02 15:04:05.999999999-07:00", createdAt)
if err1 != nil {
group.CreatedAt, err1 = time.Parse("2006-01-02 15:04:05", createdAt)
}
if err1 != nil {
group.CreatedAt, _ = time.Parse(time.RFC3339, createdAt)
}
group.UpdatedAt, err2 = time.Parse("2006-01-02 15:04:05.999999999-07:00", updatedAt)
if err2 != nil {
group.UpdatedAt, err2 = time.Parse("2006-01-02 15:04:05", updatedAt)
}
if err2 != nil {
group.UpdatedAt, _ = time.Parse(time.RFC3339, updatedAt)
}
groups = append(groups, &group)
}
return groups, nil
}
// GetGroup 获取分组
func (db *DB) GetGroup(id string) (*ConversationGroup, error) {
var group ConversationGroup
var createdAt, updatedAt string
var pinned int
err := db.QueryRow(
"SELECT id, name, icon, COALESCE(pinned, 0), COALESCE(owner_user_id, ''), created_at, updated_at FROM conversation_groups WHERE id = ?",
id,
).Scan(&group.ID, &group.Name, &group.Icon, &pinned, &group.OwnerUserID, &createdAt, &updatedAt)
if err != nil {
if err == sql.ErrNoRows {
return nil, fmt.Errorf("分组不存在")
}
return nil, fmt.Errorf("查询分组失败: %w", err)
}
// 尝试多种时间格式解析
var err1, err2 error
group.CreatedAt, err1 = time.Parse("2006-01-02 15:04:05.999999999-07:00", createdAt)
if err1 != nil {
group.CreatedAt, err1 = time.Parse("2006-01-02 15:04:05", createdAt)
}
if err1 != nil {
group.CreatedAt, _ = time.Parse(time.RFC3339, createdAt)
}
group.UpdatedAt, err2 = time.Parse("2006-01-02 15:04:05.999999999-07:00", updatedAt)
if err2 != nil {
group.UpdatedAt, err2 = time.Parse("2006-01-02 15:04:05", updatedAt)
}
if err2 != nil {
group.UpdatedAt, _ = time.Parse(time.RFC3339, updatedAt)
}
group.Pinned = pinned != 0
return &group, nil
}
func (db *DB) UserCanAccessGroup(userID, scope, groupID string) bool {
if scope == RBACScopeAll {
return true
}
var count int
err := db.QueryRow(`SELECT COUNT(*) FROM conversation_groups WHERE id = ? AND owner_user_id = ?`, groupID, userID).Scan(&count)
return err == nil && count > 0
}
// UpdateGroup 更新分组
func (db *DB) UpdateGroup(id, name, icon string) error {
existing, err := db.GetGroup(id)
if err != nil {
return err
}
// 检查名称是否已存在(排除当前分组)
exists, err := db.groupExistsByNameForOwner(name, id, existing.OwnerUserID)
if err != nil {
return err
}
if exists {
return fmt.Errorf("分组名称已存在")
}
_, err = db.Exec(
"UPDATE conversation_groups SET name = ?, icon = ?, updated_at = ? WHERE id = ?",
name, icon, time.Now(), id,
)
if err != nil {
return fmt.Errorf("更新分组失败: %w", err)
}
return nil
}
// DeleteGroup 删除分组
func (db *DB) DeleteGroup(id string) error {
_, err := db.Exec("DELETE FROM conversation_groups WHERE id = ?", id)
if err != nil {
return fmt.Errorf("删除分组失败: %w", err)
}
return nil
}
// AddConversationToGroup 将对话添加到分组
// 注意:一个对话只能属于一个分组,所以在添加新分组之前,会先删除该对话的所有旧分组关联
func (db *DB) AddConversationToGroup(conversationID, groupID string) error {
// 先删除该对话的所有旧分组关联,确保一个对话只属于一个分组
_, err := db.Exec(
"DELETE FROM conversation_group_mappings WHERE conversation_id = ?",
conversationID,
)
if err != nil {
return fmt.Errorf("删除对话旧分组关联失败: %w", err)
}
// 然后插入新的分组关联
id := uuid.New().String()
_, err = db.Exec(
"INSERT INTO conversation_group_mappings (id, conversation_id, group_id, created_at) VALUES (?, ?, ?, ?)",
id, conversationID, groupID, time.Now(),
)
if err != nil {
return fmt.Errorf("添加对话到分组失败: %w", err)
}
return nil
}
// RemoveConversationFromGroup 从分组中移除对话
func (db *DB) RemoveConversationFromGroup(conversationID, groupID string) error {
_, err := db.Exec(
"DELETE FROM conversation_group_mappings WHERE conversation_id = ? AND group_id = ?",
conversationID, groupID,
)
if err != nil {
return fmt.Errorf("从分组中移除对话失败: %w", err)
}
return nil
}
// GetConversationsByGroup 获取分组中的所有对话
func (db *DB) GetConversationsByGroup(groupID string) ([]*Conversation, error) {
rows, err := db.Query(
`SELECT c.id, c.title, COALESCE(c.pinned, 0), c.created_at, c.updated_at, COALESCE(cgm.pinned, 0) as group_pinned
FROM conversations c
INNER JOIN conversation_group_mappings cgm ON c.id = cgm.conversation_id
WHERE cgm.group_id = ?
ORDER BY COALESCE(cgm.pinned, 0) DESC, c.updated_at DESC`,
groupID,
)
if err != nil {
return nil, fmt.Errorf("查询分组对话失败: %w", err)
}
defer rows.Close()
var conversations []*Conversation
for rows.Next() {
var conv Conversation
var createdAt, updatedAt string
var pinned int
var groupPinned int
if err := rows.Scan(&conv.ID, &conv.Title, &pinned, &createdAt, &updatedAt, &groupPinned); err != nil {
return nil, fmt.Errorf("扫描对话失败: %w", err)
}
// 尝试多种时间格式解析
var err1, err2 error
conv.CreatedAt, err1 = time.Parse("2006-01-02 15:04:05.999999999-07:00", createdAt)
if err1 != nil {
conv.CreatedAt, err1 = time.Parse("2006-01-02 15:04:05", createdAt)
}
if err1 != nil {
conv.CreatedAt, _ = time.Parse(time.RFC3339, createdAt)
}
conv.UpdatedAt, err2 = time.Parse("2006-01-02 15:04:05.999999999-07:00", updatedAt)
if err2 != nil {
conv.UpdatedAt, err2 = time.Parse("2006-01-02 15:04:05", updatedAt)
}
if err2 != nil {
conv.UpdatedAt, _ = time.Parse(time.RFC3339, updatedAt)
}
conv.Pinned = pinned != 0
conversations = append(conversations, &conv)
}
return conversations, nil
}
// SearchConversationsByGroup 搜索分组中的对话(按标题和消息内容模糊匹配)
func (db *DB) SearchConversationsByGroup(groupID string, searchQuery string) ([]*Conversation, error) {
// 构建SQL查询,支持按标题和消息内容搜索
// 使用 DISTINCT 避免因为一个对话有多条匹配消息而重复
query := `SELECT DISTINCT c.id, c.title, COALESCE(c.pinned, 0), c.created_at, c.updated_at, COALESCE(cgm.pinned, 0) as group_pinned
FROM conversations c
INNER JOIN conversation_group_mappings cgm ON c.id = cgm.conversation_id
WHERE cgm.group_id = ?`
args := []interface{}{groupID}
// 如果有搜索关键词,添加标题和消息内容搜索条件
if searchQuery != "" {
searchPattern := "%" + searchQuery + "%"
// 搜索标题或消息内容
// 使用 LEFT JOIN 连接消息表,这样即使没有消息的对话也能被搜索到(通过标题)
query += ` AND (
LOWER(c.title) LIKE LOWER(?)
OR EXISTS (
SELECT 1 FROM messages m
WHERE m.conversation_id = c.id
AND LOWER(m.content) LIKE LOWER(?)
)
)`
args = append(args, searchPattern, searchPattern)
}
query += " ORDER BY COALESCE(cgm.pinned, 0) DESC, c.updated_at DESC"
rows, err := db.Query(query, args...)
if err != nil {
return nil, fmt.Errorf("搜索分组对话失败: %w", err)
}
defer rows.Close()
var conversations []*Conversation
for rows.Next() {
var conv Conversation
var createdAt, updatedAt string
var pinned int
var groupPinned int
if err := rows.Scan(&conv.ID, &conv.Title, &pinned, &createdAt, &updatedAt, &groupPinned); err != nil {
return nil, fmt.Errorf("扫描对话失败: %w", err)
}
// 尝试多种时间格式解析
var err1, err2 error
conv.CreatedAt, err1 = time.Parse("2006-01-02 15:04:05.999999999-07:00", createdAt)
if err1 != nil {
conv.CreatedAt, err1 = time.Parse("2006-01-02 15:04:05", createdAt)
}
if err1 != nil {
conv.CreatedAt, _ = time.Parse(time.RFC3339, createdAt)
}
conv.UpdatedAt, err2 = time.Parse("2006-01-02 15:04:05.999999999-07:00", updatedAt)
if err2 != nil {
conv.UpdatedAt, err2 = time.Parse("2006-01-02 15:04:05", updatedAt)
}
if err2 != nil {
conv.UpdatedAt, _ = time.Parse(time.RFC3339, updatedAt)
}
conv.Pinned = pinned != 0
conversations = append(conversations, &conv)
}
return conversations, nil
}
// GetGroupByConversation 获取对话所属的分组
func (db *DB) GetGroupByConversation(conversationID string) (string, error) {
var groupID string
err := db.QueryRow(
"SELECT group_id FROM conversation_group_mappings WHERE conversation_id = ? LIMIT 1",
conversationID,
).Scan(&groupID)
if err != nil {
if err == sql.ErrNoRows {
return "", nil // 没有分组
}
return "", fmt.Errorf("查询对话分组失败: %w", err)
}
return groupID, nil
}
// UpdateConversationPinned 更新对话置顶状态
func (db *DB) UpdateConversationPinned(id string, pinned bool) error {
pinnedValue := 0
if pinned {
pinnedValue = 1
}
// 注意:不更新 updated_at,因为置顶操作不应该改变对话的更新时间
_, err := db.Exec(
"UPDATE conversations SET pinned = ? WHERE id = ?",
pinnedValue, id,
)
if err != nil {
return fmt.Errorf("更新对话置顶状态失败: %w", err)
}
return nil
}
// UpdateGroupPinned 更新分组置顶状态
func (db *DB) UpdateGroupPinned(id string, pinned bool) error {
pinnedValue := 0
if pinned {
pinnedValue = 1
}
_, err := db.Exec(
"UPDATE conversation_groups SET pinned = ?, updated_at = ? WHERE id = ?",
pinnedValue, time.Now(), id,
)
if err != nil {
return fmt.Errorf("更新分组置顶状态失败: %w", err)
}
return nil
}
// GroupMapping 分组映射关系
type GroupMapping struct {
ConversationID string `json:"conversationId"`
GroupID string `json:"groupId"`
}
// GetAllGroupMappings 批量获取所有分组映射(消除 N+1 查询)
func (db *DB) GetAllGroupMappings() ([]GroupMapping, error) {
rows, err := db.Query("SELECT conversation_id, group_id FROM conversation_group_mappings")
if err != nil {
return nil, fmt.Errorf("查询分组映射失败: %w", err)
}
defer rows.Close()
var mappings []GroupMapping
for rows.Next() {
var m GroupMapping
if err := rows.Scan(&m.ConversationID, &m.GroupID); err != nil {
return nil, fmt.Errorf("扫描分组映射失败: %w", err)
}
mappings = append(mappings, m)
}
if mappings == nil {
mappings = []GroupMapping{}
}
return mappings, nil
}
// UpdateConversationPinnedInGroup 更新对话在分组中的置顶状态
func (db *DB) UpdateConversationPinnedInGroup(conversationID, groupID string, pinned bool) error {
pinnedValue := 0
if pinned {
pinnedValue = 1
}
_, err := db.Exec(
"UPDATE conversation_group_mappings SET pinned = ? WHERE conversation_id = ? AND group_id = ?",
pinnedValue, conversationID, groupID,
)
if err != nil {
return fmt.Errorf("更新分组对话置顶状态失败: %w", err)
}
return nil
}
+33 -9
View File
@@ -91,6 +91,15 @@ func (db *DB) UpdateToolExecutionResult(id string, result *mcp.ToolResult) error
if id == "" || result == nil {
return nil
}
var status string
if err := db.QueryRow(`SELECT status FROM tool_executions WHERE id = ?`, id).Scan(&status); err != nil && err != sql.ErrNoRows {
return err
}
if status == mcp.ToolExecutionStatusBlocked {
copy := *result
copy.Blocked, copy.IsError = true, true
result = &copy
}
resultBytes, err := json.Marshal(result)
if err != nil {
return err
@@ -276,6 +285,7 @@ type ToolStatsSummary struct {
TotalCalls int
SuccessCalls int
FailedCalls int
BlockedCalls int
LastCallTime *time.Time
ToolCount int
}
@@ -304,6 +314,7 @@ func (db *DB) LoadToolStatsSummary(topN int) (*ToolStatsSummaryResult, error) {
SELECT COUNT(*),
COALESCE(SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END), 0),
COALESCE(SUM(CASE WHEN status IN ('failed', 'hard_timeout', 'orphaned') THEN 1 ELSE 0 END), 0),
COALESCE(SUM(CASE WHEN status = 'blocked' THEN 1 ELSE 0 END), 0),
MAX(start_time),
COUNT(DISTINCT tool_name)
FROM tool_executions
@@ -313,6 +324,7 @@ func (db *DB) LoadToolStatsSummary(topN int) (*ToolStatsSummaryResult, error) {
&result.Summary.TotalCalls,
&result.Summary.SuccessCalls,
&result.Summary.FailedCalls,
&result.Summary.BlockedCalls,
&lastCallRaw,
&result.Summary.ToolCount,
)
@@ -334,6 +346,7 @@ func (db *DB) LoadToolStatsSummary(topN int) (*ToolStatsSummaryResult, error) {
COUNT(*) AS total_calls,
SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END) AS success_calls,
SUM(CASE WHEN status IN ('failed', 'hard_timeout', 'orphaned') THEN 1 ELSE 0 END) AS failed_calls,
SUM(CASE WHEN status = 'blocked' THEN 1 ELSE 0 END) AS blocked_calls,
MAX(start_time) AS last_call_time
FROM tool_executions
GROUP BY tool_name
@@ -354,6 +367,7 @@ func (db *DB) LoadToolStatsSummary(topN int) (*ToolStatsSummaryResult, error) {
&stat.TotalCalls,
&stat.SuccessCalls,
&stat.FailedCalls,
&stat.BlockedCalls,
&lastCallTime,
); err != nil {
db.logger.Warn("加载 Top 工具统计失败", zap.Error(err))
@@ -385,8 +399,9 @@ func (db *DB) LoadToolStatsSummaryForAccess(topN int, access RBACListAccess) (*T
err := db.QueryRow(`SELECT COUNT(*),
COALESCE(SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END), 0),
COALESCE(SUM(CASE WHEN status IN ('failed', 'hard_timeout', 'orphaned') THEN 1 ELSE 0 END), 0),
COALESCE(SUM(CASE WHEN status = 'blocked' THEN 1 ELSE 0 END), 0),
MAX(start_time), COUNT(DISTINCT tool_name)`+fromSQL, args...).Scan(
&result.Summary.TotalCalls, &result.Summary.SuccessCalls, &result.Summary.FailedCalls,
&result.Summary.TotalCalls, &result.Summary.SuccessCalls, &result.Summary.FailedCalls, &result.Summary.BlockedCalls,
&lastCall, &result.Summary.ToolCount,
)
if err != nil {
@@ -398,7 +413,8 @@ func (db *DB) LoadToolStatsSummaryForAccess(topN int, access RBACListAccess) (*T
}
rows, err := db.Query(`SELECT tool_name, COUNT(*),
SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END),
SUM(CASE WHEN status IN ('failed', 'hard_timeout', 'orphaned') THEN 1 ELSE 0 END), MAX(start_time)`+
SUM(CASE WHEN status IN ('failed', 'hard_timeout', 'orphaned') THEN 1 ELSE 0 END),
SUM(CASE WHEN status = 'blocked' THEN 1 ELSE 0 END), MAX(start_time)`+
fromSQL+` GROUP BY tool_name ORDER BY COUNT(*) DESC, tool_name ASC LIMIT ?`, append(args, topN)...)
if err != nil {
return nil, err
@@ -407,7 +423,7 @@ func (db *DB) LoadToolStatsSummaryForAccess(topN int, access RBACListAccess) (*T
for rows.Next() {
var stat mcp.ToolStats
var last sql.NullString
if err := rows.Scan(&stat.ToolName, &stat.TotalCalls, &stat.SuccessCalls, &stat.FailedCalls, &last); err != nil {
if err := rows.Scan(&stat.ToolName, &stat.TotalCalls, &stat.SuccessCalls, &stat.FailedCalls, &stat.BlockedCalls, &last); err != nil {
return nil, err
}
if last.Valid {
@@ -916,8 +932,11 @@ func (db *DB) SaveToolStats(toolName string, stats *mcp.ToolStats) error {
// LoadToolStats 加载所有工具统计信息
func (db *DB) LoadToolStats() (map[string]*mcp.ToolStats, error) {
query := `
SELECT tool_name, total_calls, success_calls, failed_calls, last_call_time
FROM tool_stats
SELECT stats.tool_name, total_calls, success_calls, failed_calls, last_call_time,
COALESCE(blocked.calls, 0)
FROM tool_stats stats
LEFT JOIN (SELECT tool_name, COUNT(*) AS calls FROM tool_executions WHERE status = 'blocked' GROUP BY tool_name) blocked
ON blocked.tool_name = stats.tool_name
`
rows, err := db.Query(query)
@@ -937,6 +956,7 @@ func (db *DB) LoadToolStats() (map[string]*mcp.ToolStats, error) {
&stat.SuccessCalls,
&stat.FailedCalls,
&lastCallTime,
&stat.BlockedCalls,
)
if err != nil {
db.logger.Warn("加载统计信息失败", zap.Error(err))
@@ -989,6 +1009,7 @@ type CallsTimelineBucket struct {
BucketTime time.Time
Total int
Failed int
Blocked int
}
// truncateCallsTimelineBucket 将时间截断到趋势图桶边界(本地时区,与 handler 侧 truncateToBucket 一致)
@@ -1008,7 +1029,8 @@ func (db *DB) LoadCallsTimeline(since time.Time, dailyBuckets bool) ([]CallsTime
query = `
SELECT date(start_time, 'localtime') AS bucket,
COUNT(*) AS total,
SUM(CASE WHEN status IN ('failed', 'hard_timeout', 'orphaned') THEN 1 ELSE 0 END) AS failed
SUM(CASE WHEN status IN ('failed', 'hard_timeout', 'orphaned') THEN 1 ELSE 0 END) AS failed,
SUM(CASE WHEN status = 'blocked' THEN 1 ELSE 0 END) AS blocked
FROM tool_executions
WHERE start_time >= ?
GROUP BY bucket
@@ -1018,7 +1040,8 @@ func (db *DB) LoadCallsTimeline(since time.Time, dailyBuckets bool) ([]CallsTime
query = `
SELECT strftime('%Y-%m-%d %H:00:00', start_time, 'localtime') AS bucket,
COUNT(*) AS total,
SUM(CASE WHEN status IN ('failed', 'hard_timeout', 'orphaned') THEN 1 ELSE 0 END) AS failed
SUM(CASE WHEN status IN ('failed', 'hard_timeout', 'orphaned') THEN 1 ELSE 0 END) AS failed,
SUM(CASE WHEN status = 'blocked' THEN 1 ELSE 0 END) AS blocked
FROM tool_executions
WHERE start_time >= ?
GROUP BY bucket
@@ -1035,8 +1058,8 @@ func (db *DB) LoadCallsTimeline(since time.Time, dailyBuckets bool) ([]CallsTime
buckets := make([]CallsTimelineBucket, 0)
for rows.Next() {
var bucketStr string
var total, failed int
if err := rows.Scan(&bucketStr, &total, &failed); err != nil {
var total, failed, blocked int
if err := rows.Scan(&bucketStr, &total, &failed, &blocked); err != nil {
db.logger.Warn("加载调用趋势失败", zap.Error(err))
continue
}
@@ -1049,6 +1072,7 @@ func (db *DB) LoadCallsTimeline(since time.Time, dailyBuckets bool) ([]CallsTime
BucketTime: bucketTime,
Total: total,
Failed: failed,
Blocked: blocked,
})
}
return buckets, nil
-1
View File
@@ -201,7 +201,6 @@ func (db *DB) migrateRBACOwnershipColumns() error {
{"webshell_connections", "owner_user_id", "ALTER TABLE webshell_connections ADD COLUMN owner_user_id TEXT"},
{"batch_task_queues", "owner_user_id", "ALTER TABLE batch_task_queues ADD COLUMN owner_user_id TEXT"},
{"c2_listeners", "owner_user_id", "ALTER TABLE c2_listeners ADD COLUMN owner_user_id TEXT"},
{"conversation_groups", "owner_user_id", "ALTER TABLE conversation_groups ADD COLUMN owner_user_id TEXT"},
{"tool_executions", "owner_user_id", "ALTER TABLE tool_executions ADD COLUMN owner_user_id TEXT"},
{"tool_executions", "conversation_id", "ALTER TABLE tool_executions ADD COLUMN conversation_id TEXT"},
} {
+1 -20
View File
@@ -58,27 +58,8 @@ func TestRBACToolExecutionOwnershipAccess(t *testing.T) {
}
}
func TestRBACGroupAndUploadOwnership(t *testing.T) {
func TestRBACUploadOwnership(t *testing.T) {
db := newRBACTestDB(t)
group1, err := db.CreateGroup("u1 group", "", "u1")
if err != nil {
t.Fatal(err)
}
group2, err := db.CreateGroup("u2 group", "", "u2")
if err != nil {
t.Fatal(err)
}
groups, err := db.ListGroupsForAccess("u1", RBACScopeAssigned)
if err != nil {
t.Fatal(err)
}
if len(groups) != 1 || groups[0].ID != group1.ID {
t.Fatalf("groups = %#v, want only %s (not %s)", groups, group1.ID, group2.ID)
}
if db.UserCanAccessGroup("u1", RBACScopeAssigned, group2.ID) {
t.Fatal("foreign group was accessible")
}
conversation, err := db.CreateConversation("upload", ConversationCreateMeta{})
if err != nil {
t.Fatal(err)
+84
View File
@@ -0,0 +1,84 @@
package database
import (
"encoding/json"
"strconv"
"strings"
"cyberstrike-ai/internal/mcp"
)
const legacyToolGuardPrefix = "工具调用已被安全规则拦截"
// Only the exact envelope emitted by the old local guard is recognized here.
// New executions use the structured marker and never infer policy from text.
func isLegacyToolGuardRefusal(text string) bool {
if !strings.HasPrefix(text, legacyToolGuardPrefix+"") && !strings.HasPrefix(text, legacyToolGuardPrefix+"\n规则: ") {
return false
}
matchIndex := strings.LastIndex(text, "\n匹配内容: ")
if matchIndex < 0 {
return false
}
if _, err := strconv.Unquote(text[matchIndex+len("\n匹配内容: "):]); err != nil {
return false
}
ruleIndex := strings.LastIndex(text[:matchIndex], "\n规则: ")
if ruleIndex < 0 {
return false
}
rule := text[ruleIndex+len("\n规则: ") : matchIndex]
idIndex := strings.LastIndex(rule, " (")
return idIndex > 0 && strings.HasSuffix(rule, ")") && len(rule[idIndex+2:len(rule)-1]) > 0 && !strings.Contains(rule, "\n")
}
// migrateLegacyToolGuardBlocks is idempotent because only failed records qualify.
// Keeping status and accumulated failure counts in one transaction makes monitor
// filters, badges and statistics agree immediately after upgrading.
func (db *DB) migrateLegacyToolGuardBlocks() error {
tx, err := db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
rows, err := tx.Query(`SELECT id, tool_name, error, COALESCE(result, '') FROM tool_executions WHERE status = 'failed' AND error LIKE ?`, legacyToolGuardPrefix+"%")
if err != nil {
return err
}
type record struct{ id, tool, reason, result string }
var records []record
for rows.Next() {
var r record
if err := rows.Scan(&r.id, &r.tool, &r.reason, &r.result); err != nil {
rows.Close()
return err
}
if isLegacyToolGuardRefusal(r.reason) {
records = append(records, r)
}
}
err = rows.Err()
rows.Close()
if err != nil {
return err
}
for _, r := range records {
var result mcp.ToolResult
_ = json.Unmarshal([]byte(r.result), &result)
if len(result.Content) == 0 {
result.Content = []mcp.Content{{Type: "text", Text: r.reason}}
}
result.Blocked, result.IsError = true, true
encoded, err := json.Marshal(result)
if err != nil {
return err
}
if _, err := tx.Exec(`UPDATE tool_executions SET status = 'blocked', result = ? WHERE id = ?`, string(encoded), r.id); err != nil {
return err
}
if _, err := tx.Exec(`UPDATE tool_stats SET failed_calls = MAX(0, failed_calls - 1) WHERE tool_name = ?`, r.tool); err != nil {
return err
}
}
return tx.Commit()
}
+33 -3
View File
@@ -315,12 +315,13 @@ func (h *AgentHandler) SetHitlToolWhitelistSaver(s HitlToolWhitelistSaver) {
h.hitlWhitelistSaver = s
}
// HitlDefaultReviewerSaver 持久化全局默认审批方到 config.yaml。
// HitlDefaultReviewerSaver 持久化全局默认人机协同配置到 config.yaml。
type HitlDefaultReviewerSaver interface {
UpdateHitlDefaultConfig(mode, reviewer string, timeoutSeconds int) error
UpdateHitlDefaultReviewer(reviewer string) error
}
// SetHitlDefaultReviewerSaver 设置 HITL 默认审批方落盘。
// SetHitlDefaultReviewerSaver 设置 HITL 默认配置落盘。
func (h *AgentHandler) SetHitlDefaultReviewerSaver(s HitlDefaultReviewerSaver) {
h.hitlDefaultReviewerSaver = s
}
@@ -332,6 +333,35 @@ func (h *AgentHandler) hitlEffectiveDefaultReviewer() string {
return "human"
}
func (h *AgentHandler) hitlEffectiveDefaultMode() string {
if h != nil && h.config != nil {
return normalizeHitlDefaultMode(h.config.Hitl.EffectiveDefaultMode())
}
return "off"
}
func (h *AgentHandler) hitlEffectiveDefaultTimeoutSeconds() int {
if h != nil && h.config != nil {
timeout := h.config.Hitl.EffectiveDefaultTimeoutSeconds()
if timeout < 0 {
return 0
}
return timeout
}
return 300
}
func (h *AgentHandler) hitlEffectiveDefaultRequest() *HITLRequest {
mode := h.hitlEffectiveDefaultMode()
return &HITLRequest{
Enabled: mode != "off",
Mode: mode,
Reviewer: h.hitlEffectiveDefaultReviewer(),
SensitiveTools: []string{},
TimeoutSeconds: h.hitlEffectiveDefaultTimeoutSeconds(),
}
}
// HITLNeedsToolApproval 供 C2 危险任务门控:与会话侧人机协同及免审批白名单判定一致。
func (h *AgentHandler) HITLNeedsToolApproval(conversationID, toolName string) bool {
if h == nil || h.hitlManager == nil {
@@ -717,7 +747,7 @@ func (h *AgentHandler) finalizeRobotAgentError(ctx context.Context, assistantMes
if shouldPersistEinoAgentTraceAfterRunError(ctx) {
h.persistEinoAgentTraceForResume(conversationID, resultMA)
}
errMsg := "执行失败: " + errMA.Error()
errMsg := "执行失败: " + multiagent.EinoClientRunErrorMessage(errMA)
if assistantMessageID != "" {
_, _ = h.db.Exec("UPDATE messages SET content = ?, updated_at = ? WHERE id = ?", errMsg, time.Now(), assistantMessageID)
_ = h.db.AddProcessDetail(assistantMessageID, conversationID, "error", errMsg, nil)
+3 -2
View File
@@ -391,7 +391,8 @@ func (h *AgentHandler) handleBatchSubTaskRunError(
}
h.logger.Error("批量任务执行失败", zap.String("queueId", queueID), zap.String("taskId", task.ID), zap.String("conversationId", conversationID), zap.Error(runErr))
errorMsg := "执行失败: " + runErr.Error()
clientErr := multiagent.EinoClientRunErrorMessage(runErr)
errorMsg := "执行失败: " + clientErr
if assistantMessageID != "" {
if _, updateErr := h.db.Exec(
"UPDATE messages SET content = ?, updated_at = ? WHERE id = ?",
@@ -404,5 +405,5 @@ func (h *AgentHandler) handleBatchSubTaskRunError(
h.logger.Warn("保存错误详情失败", zap.String("queueId", queueID), zap.String("taskId", task.ID), zap.Error(err))
}
}
h.batchTaskManager.UpdateTaskStatus(queueID, task.ID, BatchTaskStatusFailed, "", runErr.Error())
h.batchTaskManager.UpdateTaskStatus(queueID, task.ID, BatchTaskStatusFailed, "", clientErr)
}
+45 -7
View File
@@ -23,6 +23,7 @@ import (
"cyberstrike-ai/internal/mcp/builtin"
"cyberstrike-ai/internal/openai"
"cyberstrike-ai/internal/security"
"cyberstrike-ai/internal/toolguard"
"github.com/cloudwego/eino/schema"
"github.com/gin-gonic/gin"
@@ -95,6 +96,7 @@ type ConfigHandler struct {
db *database.DB
logger *zap.Logger
mu sync.RWMutex
toolGuard *toolguard.Manager
lastEmbeddingConfig *config.EmbeddingConfig // 上一次的嵌入模型配置(用于检测变更)
}
@@ -347,13 +349,13 @@ func (h *ConfigHandler) GetConfig(c *gin.Context) {
subAgentCount = len(agents.MergeYAMLAndMarkdown(h.config.MultiAgent.SubAgents, load.SubAgents))
}
multiPub := config.MultiAgentPublic{
Enabled: h.config.MultiAgent.Enabled,
RobotDefaultAgentMode: config.NormalizeRobotAgentMode(h.config.MultiAgent),
BatchUseMultiAgent: h.config.MultiAgent.BatchUseMultiAgent,
SubAgentCount: subAgentCount,
Orchestration: config.NormalizeMultiAgentOrchestration(h.config.MultiAgent.Orchestration),
PlanExecuteLoopMaxIterations: h.config.MultiAgent.PlanExecuteLoopMaxIterations,
SummarizationUserIntentLedgerMaxRunes: h.config.MultiAgent.EinoMiddleware.SummarizationUserIntentLedgerMaxRunesEffective(),
Enabled: h.config.MultiAgent.Enabled,
RobotDefaultAgentMode: config.NormalizeRobotAgentMode(h.config.MultiAgent),
BatchUseMultiAgent: h.config.MultiAgent.BatchUseMultiAgent,
SubAgentCount: subAgentCount,
Orchestration: config.NormalizeMultiAgentOrchestration(h.config.MultiAgent.Orchestration),
PlanExecuteLoopMaxIterations: h.config.MultiAgent.PlanExecuteLoopMaxIterations,
SummarizationUserIntentLedgerMaxRunes: h.config.MultiAgent.EinoMiddleware.SummarizationUserIntentLedgerMaxRunesEffective(),
SummarizationUserIntentLedgerEntryMaxRunes: h.config.MultiAgent.EinoMiddleware.SummarizationUserIntentLedgerEntryMaxRunesEffective(),
LatestUserMessageMaxRunes: h.config.MultiAgent.EinoMiddleware.LatestUserMessageMaxRunesEffective(),
LatestUserMessageHeadRunes: h.config.MultiAgent.EinoMiddleware.LatestUserMessageHeadRunesEffective(),
@@ -891,7 +893,14 @@ func (h *ConfigHandler) UpdateConfig(c *gin.Context) {
if req.Hitl != nil {
h.config.Hitl.AuditModel = req.Hitl.AuditModel
h.config.Hitl.ToolWhitelist = mergeHitlToolWhitelistSlice(nil, req.Hitl.ToolWhitelist)
if strings.TrimSpace(req.Hitl.DefaultMode) != "" {
h.config.Hitl.DefaultMode = req.Hitl.EffectiveDefaultMode()
}
h.config.Hitl.DefaultReviewer = req.Hitl.EffectiveDefaultReviewer()
if req.Hitl.DefaultTimeoutSeconds != nil {
v := req.Hitl.EffectiveDefaultTimeoutSeconds()
h.config.Hitl.DefaultTimeoutSeconds = &v
}
h.config.Hitl.AuditAgentPrompt = strings.TrimSpace(req.Hitl.AuditAgentPrompt)
h.config.Hitl.AuditAgentPromptReviewEdit = strings.TrimSpace(req.Hitl.AuditAgentPromptReviewEdit)
if req.Hitl.RetentionDays != nil {
@@ -1162,6 +1171,8 @@ func (h *ConfigHandler) UpdateConfig(c *gin.Context) {
}
}
h.config.NormalizeAIProviderProfiles()
// 保存配置到文件
if err := h.saveConfig(); err != nil {
h.logger.Error("保存配置失败", zap.Error(err))
@@ -1737,6 +1748,10 @@ func (h *ConfigHandler) ApplyConfig(c *gin.Context) {
// saveConfig 保存配置到文件
func (h *ConfigHandler) saveConfig() error {
configFileMu.Lock()
defer configFileMu.Unlock()
h.config.NormalizeAIProviderProfiles()
// 读取现有配置文件并创建备份
data, err := os.ReadFile(h.configPath)
if err != nil {
@@ -2141,12 +2156,35 @@ func updateHitlConfig(doc *yaml.Node, cfg config.HitlConfig) {
setStringInMap(auditModelNode, "model", cfg.AuditModel.Model)
// flow 样式 [a, b, c] 单行展示,工具多时比块序列省行数
setFlowStringSliceInMap(hitlNode, "tool_whitelist", cfg.ToolWhitelist)
setStringInMap(hitlNode, "default_mode", cfg.EffectiveDefaultMode())
setStringInMap(hitlNode, "default_reviewer", cfg.EffectiveDefaultReviewer())
setIntInMap(hitlNode, "default_timeout_seconds", cfg.EffectiveDefaultTimeoutSeconds())
setIntInMap(hitlNode, "retention_days", cfg.RetentionDaysEffective())
setStringInMap(hitlNode, "audit_agent_prompt", cfg.AuditAgentPrompt)
setStringInMap(hitlNode, "audit_agent_prompt_review_edit", cfg.AuditAgentPromptReviewEdit)
}
// UpdateHitlDefaultConfig 更新全局默认人机协同配置并写入 config.yaml。
func (h *ConfigHandler) UpdateHitlDefaultConfig(mode, reviewer string, timeoutSeconds int) error {
h.mu.Lock()
defer h.mu.Unlock()
h.config.Hitl.DefaultMode = config.HitlConfig{DefaultMode: mode}.EffectiveDefaultMode()
h.config.Hitl.DefaultReviewer = config.HitlConfig{DefaultReviewer: reviewer}.EffectiveDefaultReviewer()
if timeoutSeconds < 0 {
timeoutSeconds = 0
}
h.config.Hitl.DefaultTimeoutSeconds = &timeoutSeconds
if err := h.saveConfig(); err != nil {
return err
}
h.logger.Info("HITL 全局默认配置已写入配置文件",
zap.String("default_mode", h.config.Hitl.DefaultMode),
zap.String("default_reviewer", h.config.Hitl.DefaultReviewer),
zap.Int("default_timeout_seconds", timeoutSeconds),
)
return nil
}
// UpdateHitlDefaultReviewer 更新全局默认审批方并写入 config.yaml。
func (h *ConfigHandler) UpdateHitlDefaultReviewer(reviewer string) error {
h.mu.Lock()
+8
View File
@@ -0,0 +1,8 @@
package handler
import "sync"
// configFileMu serializes complete read-modify-write transactions across
// handlers that share config.yaml. Per-handler locks cannot prevent lost
// updates when another settings page saves a different YAML section.
var configFileMu sync.Mutex
+32 -12
View File
@@ -160,24 +160,15 @@ func (h *ConversationHandler) ListConversations(c *gin.Context) {
limit = 1000
}
excludeGrouped := strings.TrimSpace(search) == "" && projectID == "" &&
(c.Query("exclude_grouped") == "true" || c.Query("exclude_grouped") == "1")
sortBy := strings.TrimSpace(c.Query("sort_by"))
session, _ := security.CurrentSession(c)
var conversations []*database.Conversation
var total int
var err error
if excludeGrouped {
conversations, err = h.db.ListUngroupedConversationsForAccess(limit, offset, sortBy, projectID, session.UserID, session.Scope)
if err == nil {
total, err = h.db.CountUngroupedConversationsForAccess(projectID, session.UserID, session.Scope)
}
} else {
conversations, err = h.db.ListConversationsForAccess(limit, offset, search, sortBy, projectID, session.UserID, session.Scope)
if err == nil {
total, err = h.db.CountConversationsForAccess(search, projectID, session.UserID, session.Scope)
}
conversations, err = h.db.ListConversationsForAccess(limit, offset, search, sortBy, projectID, session.UserID, session.Scope)
if err == nil {
total, err = h.db.CountConversationsForAccess(search, projectID, session.UserID, session.Scope)
}
if err != nil {
h.logger.Error("获取对话列表失败", zap.Error(err))
@@ -195,6 +186,35 @@ func (h *ConversationHandler) ListConversations(c *gin.Context) {
})
}
// UpdateConversationPinnedRequest 更新对话置顶状态请求
type UpdateConversationPinnedRequest struct {
Pinned bool `json:"pinned"`
}
// UpdateConversationPinned 更新对话置顶状态
func (h *ConversationHandler) UpdateConversationPinned(c *gin.Context) {
conversationID := c.Param("id")
session, ok := security.CurrentSession(c)
if !ok || !h.db.UserCanAccessResource(session.UserID, session.Scope, "conversation", conversationID) {
c.JSON(http.StatusForbidden, gin.H{"error": "无权访问该资源"})
return
}
var req UpdateConversationPinnedRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := h.db.UpdateConversationPinned(conversationID, req.Pinned); err != nil {
h.logger.Error("更新对话置顶状态失败", zap.Error(err))
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "更新成功"})
}
// GetConversation 获取对话
func (h *ConversationHandler) GetConversation(c *gin.Context) {
id := c.Param("id")
+7 -5
View File
@@ -371,15 +371,17 @@ func (h *AgentHandler) EinoSingleAgentLoopStream(c *gin.Context) {
h.logger.Error("Eino ADK 单代理执行失败", zap.Error(runErr))
taskStatus = "failed"
h.tasks.UpdateTaskStatus(conversationID, taskStatus)
errMsg := "执行失败: " + runErr.Error()
clientErr := multiagent.EinoClientRunErrorMessage(runErr)
errMsg := "执行失败: " + clientErr
if assistantMessageID != "" {
_, _ = h.db.Exec("UPDATE messages SET content = ?, updated_at = ? WHERE id = ?", errMsg, time.Now(), assistantMessageID)
_ = h.db.AddProcessDetail(assistantMessageID, conversationID, "error", errMsg, nil)
}
sendEvent("error", errMsg, map[string]interface{}{
"conversationId": conversationID,
"messageId": assistantMessageID,
})
errData := multiagent.EinoClientRunErrorFields(runErr)
errData["conversationId"] = conversationID
errData["messageId"] = assistantMessageID
errData["error"] = errMsg
sendEvent("error", errMsg, errData)
sendEvent("done", "", map[string]interface{}{"conversationId": conversationID})
timeoutCancel()
return
+2
View File
@@ -379,6 +379,8 @@ func (h *ExternalMCPHandler) isEnabled(cfg config.ExternalMCPServerConfig) bool
// saveConfig 保存配置到文件
func (h *ExternalMCPHandler) saveConfig() error {
configFileMu.Lock()
defer configFileMu.Unlock()
data, err := os.ReadFile(h.configPath)
if err != nil {
return fmt.Errorf("读取配置文件失败: %w", err)
-438
View File
@@ -1,438 +0,0 @@
package handler
import (
"errors"
"net/http"
"strings"
"time"
"unicode/utf8"
"cyberstrike-ai/internal/database"
"cyberstrike-ai/internal/security"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
)
// GroupHandler 分组处理器
type GroupHandler struct {
db *database.DB
logger *zap.Logger
}
const (
maxGroupNameRunes = 64
maxGroupIconRunes = 16
)
// NewGroupHandler 创建新的分组处理器
func NewGroupHandler(db *database.DB, logger *zap.Logger) *GroupHandler {
return &GroupHandler{
db: db,
logger: logger,
}
}
func validateGroupTextField(field, value string, maxRunes int, required bool) (string, error) {
value = strings.TrimSpace(value)
if value == "" {
if required {
return "", errors.New(field + "不能为空")
}
return "", nil
}
if utf8.RuneCountInString(value) > maxRunes {
return "", errors.New(field + "过长")
}
for _, r := range value {
switch r {
case '<', '>', '"', '\'', '`':
return "", errors.New(field + "包含非法字符")
}
if r < 0x20 || r == 0x7f {
return "", errors.New(field + "包含非法控制字符")
}
}
return value, nil
}
func validateGroupFields(name, icon string) (string, string, error) {
validName, err := validateGroupTextField("分组名称", name, maxGroupNameRunes, true)
if err != nil {
return "", "", err
}
validIcon, err := validateGroupTextField("分组图标", icon, maxGroupIconRunes, false)
if err != nil {
return "", "", err
}
return validName, validIcon, nil
}
// CreateGroupRequest 创建分组请求
type CreateGroupRequest struct {
Name string `json:"name"`
Icon string `json:"icon"`
}
// CreateGroup 创建分组
func (h *GroupHandler) CreateGroup(c *gin.Context) {
var req CreateGroupRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
name, icon, err := validateGroupFields(req.Name, req.Icon)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
session, _ := security.CurrentSession(c)
group, err := h.db.CreateGroup(name, icon, session.UserID)
if err != nil {
h.logger.Error("创建分组失败", zap.Error(err))
// 如果是名称重复错误,返回400状态码
if err.Error() == "分组名称已存在" {
c.JSON(http.StatusBadRequest, gin.H{"error": "分组名称已存在"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, group)
}
// ListGroups 列出所有分组
func (h *GroupHandler) ListGroups(c *gin.Context) {
session, _ := security.CurrentSession(c)
groups, err := h.db.ListGroupsForAccess(session.UserID, session.Scope)
if err != nil {
h.logger.Error("获取分组列表失败", zap.Error(err))
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, groups)
}
// GetGroup 获取分组
func (h *GroupHandler) GetGroup(c *gin.Context) {
id := c.Param("id")
if !h.groupAllowed(c, id) {
c.JSON(http.StatusForbidden, gin.H{"error": "无权访问该资源"})
return
}
group, err := h.db.GetGroup(id)
if err != nil {
h.logger.Error("获取分组失败", zap.Error(err))
c.JSON(http.StatusNotFound, gin.H{"error": "分组不存在"})
return
}
c.JSON(http.StatusOK, group)
}
// UpdateGroupRequest 更新分组请求
type UpdateGroupRequest struct {
Name string `json:"name"`
Icon string `json:"icon"`
}
// UpdateGroup 更新分组
func (h *GroupHandler) UpdateGroup(c *gin.Context) {
id := c.Param("id")
if !h.groupAllowed(c, id) {
c.JSON(http.StatusForbidden, gin.H{"error": "无权访问该资源"})
return
}
var req UpdateGroupRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
name, icon, err := validateGroupFields(req.Name, req.Icon)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := h.db.UpdateGroup(id, name, icon); err != nil {
h.logger.Error("更新分组失败", zap.Error(err))
// 如果是名称重复错误,返回400状态码
if err.Error() == "分组名称已存在" {
c.JSON(http.StatusBadRequest, gin.H{"error": "分组名称已存在"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
group, err := h.db.GetGroup(id)
if err != nil {
h.logger.Error("获取更新后的分组失败", zap.Error(err))
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, group)
}
// DeleteGroup 删除分组
func (h *GroupHandler) DeleteGroup(c *gin.Context) {
id := c.Param("id")
if !h.groupAllowed(c, id) {
c.JSON(http.StatusForbidden, gin.H{"error": "无权访问该资源"})
return
}
if err := h.db.DeleteGroup(id); err != nil {
h.logger.Error("删除分组失败", zap.Error(err))
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "删除成功"})
}
// AddConversationToGroupRequest 添加对话到分组请求
type AddConversationToGroupRequest struct {
ConversationID string `json:"conversationId"`
GroupID string `json:"groupId"`
}
// AddConversationToGroup 将对话添加到分组
func (h *GroupHandler) AddConversationToGroup(c *gin.Context) {
var req AddConversationToGroupRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if !h.groupConversationAllowed(c, req.ConversationID) {
c.JSON(http.StatusForbidden, gin.H{"error": "无权访问该资源"})
return
}
if !h.groupAllowed(c, req.GroupID) {
c.JSON(http.StatusForbidden, gin.H{"error": "无权访问该分组"})
return
}
if err := h.db.AddConversationToGroup(req.ConversationID, req.GroupID); err != nil {
h.logger.Error("添加对话到分组失败", zap.Error(err))
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "添加成功"})
}
// RemoveConversationFromGroup 从分组中移除对话
func (h *GroupHandler) RemoveConversationFromGroup(c *gin.Context) {
conversationID := c.Param("conversationId")
groupID := c.Param("id")
if !h.groupAllowed(c, groupID) {
c.JSON(http.StatusForbidden, gin.H{"error": "无权访问该分组"})
return
}
if !h.groupConversationAllowed(c, conversationID) {
c.JSON(http.StatusForbidden, gin.H{"error": "无权访问该资源"})
return
}
if err := h.db.RemoveConversationFromGroup(conversationID, groupID); err != nil {
h.logger.Error("从分组中移除对话失败", zap.Error(err))
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "移除成功"})
}
// GroupConversation 分组对话响应结构
type GroupConversation struct {
ID string `json:"id"`
Title string `json:"title"`
Pinned bool `json:"pinned"`
GroupPinned bool `json:"groupPinned"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
// GetGroupConversations 获取分组中的所有对话
func (h *GroupHandler) GetGroupConversations(c *gin.Context) {
groupID := c.Param("id")
if !h.groupAllowed(c, groupID) {
c.JSON(http.StatusForbidden, gin.H{"error": "无权访问该分组"})
return
}
searchQuery := c.Query("search") // 获取搜索参数
var conversations []*database.Conversation
var err error
// 如果有搜索关键词,使用搜索方法;否则使用普通方法
if searchQuery != "" {
conversations, err = h.db.SearchConversationsByGroup(groupID, searchQuery)
} else {
conversations, err = h.db.GetConversationsByGroup(groupID)
}
if err != nil {
h.logger.Error("获取分组对话失败", zap.Error(err))
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
// 获取每个对话在分组中的置顶状态
groupConvs := make([]GroupConversation, 0, len(conversations))
for _, conv := range conversations {
if conv == nil || !h.groupConversationAllowed(c, conv.ID) {
continue
}
// 查询分组内置顶状态
var groupPinned int
err := h.db.QueryRow(
"SELECT COALESCE(pinned, 0) FROM conversation_group_mappings WHERE conversation_id = ? AND group_id = ?",
conv.ID, groupID,
).Scan(&groupPinned)
if err != nil {
h.logger.Warn("查询分组内置顶状态失败", zap.String("conversationId", conv.ID), zap.Error(err))
groupPinned = 0
}
groupConvs = append(groupConvs, GroupConversation{
ID: conv.ID,
Title: conv.Title,
Pinned: conv.Pinned,
GroupPinned: groupPinned != 0,
CreatedAt: conv.CreatedAt,
UpdatedAt: conv.UpdatedAt,
})
}
c.JSON(http.StatusOK, groupConvs)
}
// GetAllMappings 批量获取所有分组映射(消除前端 N+1 请求)
func (h *GroupHandler) GetAllMappings(c *gin.Context) {
mappings, err := h.db.GetAllGroupMappings()
if err != nil {
h.logger.Error("获取分组映射失败", zap.Error(err))
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
filtered := mappings[:0]
for _, mapping := range mappings {
if h.groupConversationAllowed(c, mapping.ConversationID) && h.groupAllowed(c, mapping.GroupID) {
filtered = append(filtered, mapping)
}
}
c.JSON(http.StatusOK, filtered)
}
// UpdateConversationPinnedRequest 更新对话置顶状态请求
type UpdateConversationPinnedRequest struct {
Pinned bool `json:"pinned"`
}
// UpdateConversationPinned 更新对话置顶状态
func (h *GroupHandler) UpdateConversationPinned(c *gin.Context) {
conversationID := c.Param("id")
if !h.groupConversationAllowed(c, conversationID) {
c.JSON(http.StatusForbidden, gin.H{"error": "无权访问该资源"})
return
}
var req UpdateConversationPinnedRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := h.db.UpdateConversationPinned(conversationID, req.Pinned); err != nil {
h.logger.Error("更新对话置顶状态失败", zap.Error(err))
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "更新成功"})
}
// UpdateGroupPinnedRequest 更新分组置顶状态请求
type UpdateGroupPinnedRequest struct {
Pinned bool `json:"pinned"`
}
// UpdateGroupPinned 更新分组置顶状态
func (h *GroupHandler) UpdateGroupPinned(c *gin.Context) {
groupID := c.Param("id")
if !h.groupAllowed(c, groupID) {
c.JSON(http.StatusForbidden, gin.H{"error": "无权访问该分组"})
return
}
var req UpdateGroupPinnedRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := h.db.UpdateGroupPinned(groupID, req.Pinned); err != nil {
h.logger.Error("更新分组置顶状态失败", zap.Error(err))
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "更新成功"})
}
// UpdateConversationPinnedInGroupRequest 更新分组对话置顶状态请求
type UpdateConversationPinnedInGroupRequest struct {
Pinned bool `json:"pinned"`
}
// UpdateConversationPinnedInGroup 更新对话在分组中的置顶状态
func (h *GroupHandler) UpdateConversationPinnedInGroup(c *gin.Context) {
groupID := c.Param("id")
conversationID := c.Param("conversationId")
if !h.groupAllowed(c, groupID) {
c.JSON(http.StatusForbidden, gin.H{"error": "无权访问该分组"})
return
}
if !h.groupConversationAllowed(c, conversationID) {
c.JSON(http.StatusForbidden, gin.H{"error": "无权访问该资源"})
return
}
var req UpdateConversationPinnedInGroupRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := h.db.UpdateConversationPinnedInGroup(conversationID, groupID, req.Pinned); err != nil {
h.logger.Error("更新分组对话置顶状态失败", zap.Error(err))
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "更新成功"})
}
func (h *GroupHandler) groupConversationAllowed(c *gin.Context, conversationID string) bool {
session, ok := security.CurrentSession(c)
if !ok {
return false
}
return h.db.UserCanAccessResource(session.UserID, session.Scope, "conversation", conversationID)
}
func (h *GroupHandler) groupAllowed(c *gin.Context, groupID string) bool {
session, ok := security.CurrentSession(c)
return ok && h.db.UserCanAccessGroup(session.UserID, session.Scope, groupID)
}
-41
View File
@@ -1,41 +0,0 @@
package handler
import (
"strings"
"testing"
)
func TestValidateGroupFieldsAllowsNormalNamesAndIcons(t *testing.T) {
name, icon, err := validateGroupFields(" 日常安全巡检 ", " 📁 ")
if err != nil {
t.Fatalf("validateGroupFields returned error: %v", err)
}
if name != "日常安全巡检" {
t.Fatalf("name = %q, want trimmed normal name", name)
}
if icon != "📁" {
t.Fatalf("icon = %q, want trimmed icon", icon)
}
}
func TestValidateGroupFieldsRejectsStoredXSSPayloads(t *testing.T) {
tests := []struct {
name string
icon string
}{
{name: `<img src=x onerror="alert(1)">`, icon: "📁"},
{name: "日常安全巡检", icon: `<svg onload=alert(1)>`},
{name: "日常安全巡检`onmouseover=alert(1)", icon: "📁"},
{name: "日常安全巡检\x00", icon: "📁"},
{name: strings.Repeat("分", maxGroupNameRunes+1), icon: "📁"},
{name: "日常安全巡检", icon: strings.Repeat("📁", maxGroupIconRunes+1)},
}
for _, tt := range tests {
t.Run(tt.name+"/"+tt.icon, func(t *testing.T) {
if _, _, err := validateGroupFields(tt.name, tt.icon); err == nil {
t.Fatal("validateGroupFields returned nil error for unsafe input")
}
})
}
}
+74 -8
View File
@@ -289,6 +289,18 @@ func normalizeHitlMode(mode string) string {
}
}
func normalizeHitlDefaultMode(mode string) string {
v := strings.ToLower(strings.TrimSpace(mode))
switch v {
case "feedback", "followup":
return "approval"
case "approval", "review_edit":
return v
default:
return "off"
}
}
func (m *HITLManager) ActivateConversation(conversationID string, req *HITLRequest) {
if req == nil || !req.Enabled {
m.DeactivateConversation(conversationID)
@@ -629,7 +641,7 @@ func (h *AgentHandler) loadHITLConversationConfig(conversationID string) (*HITLR
return nil, err
}
if !has {
cfg.Reviewer = h.hitlEffectiveDefaultReviewer()
return h.hitlEffectiveDefaultRequest(), nil
}
return cfg, nil
}
@@ -994,7 +1006,9 @@ func (h *AgentHandler) GetHITLConversationConfig(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"conversationId": conversationID,
"hitl": cfg,
"defaultMode": h.hitlEffectiveDefaultMode(),
"defaultReviewer": h.hitlEffectiveDefaultReviewer(),
"defaultTimeoutSeconds": h.hitlEffectiveDefaultTimeoutSeconds(),
"hitlGlobalToolWhitelist": h.hitlConfigGlobalToolWhitelist(),
})
}
@@ -1051,11 +1065,64 @@ type setHitlDefaultReviewerReq struct {
Reviewer string `json:"reviewer"`
}
type setHitlDefaultConfigReq struct {
Mode string `json:"mode"`
Reviewer string `json:"reviewer"`
TimeoutSeconds int `json:"timeoutSeconds"`
}
func (h *AgentHandler) hitlDefaultConfigResponse() gin.H {
return gin.H{
"defaultMode": h.hitlEffectiveDefaultMode(),
"defaultReviewer": h.hitlEffectiveDefaultReviewer(),
"defaultTimeoutSeconds": h.hitlEffectiveDefaultTimeoutSeconds(),
"hitlGlobalToolWhitelist": h.hitlConfigGlobalToolWhitelist(),
}
}
// GetHITLDefaultConfig 返回 config.yaml 中的全局默认人机协同配置。
func (h *AgentHandler) GetHITLDefaultConfig(c *gin.Context) {
c.JSON(http.StatusOK, h.hitlDefaultConfigResponse())
}
// UpdateHITLDefaultConfig 将全局默认人机协同配置写入 config.yaml。
func (h *AgentHandler) UpdateHITLDefaultConfig(c *gin.Context) {
if h.hitlDefaultReviewerSaver == nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "HITL 配置持久化不可用"})
return
}
var req setHitlDefaultConfigReq
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
mode := normalizeHitlDefaultMode(req.Mode)
reviewer := normalizeHitlReviewer(req.Reviewer)
timeoutSeconds := req.TimeoutSeconds
if timeoutSeconds < 0 {
timeoutSeconds = 0
}
if err := h.hitlDefaultReviewerSaver.UpdateHitlDefaultConfig(mode, reviewer, timeoutSeconds); err != nil {
h.logger.Warn("写入 HITL 默认配置到 config.yaml 失败", zap.Error(err))
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if h.config != nil {
h.config.Hitl.DefaultMode = mode
h.config.Hitl.DefaultReviewer = reviewer
h.config.Hitl.DefaultTimeoutSeconds = &timeoutSeconds
}
if h.audit != nil {
h.audit.RecordOK(c, "hitl", "default_config_update", "HITL 全局默认配置更新", "hitl_config", "default", nil)
}
out := h.hitlDefaultConfigResponse()
out["ok"] = true
c.JSON(http.StatusOK, out)
}
// GetHITLDefaultReviewer 返回 config.yaml 中的全局默认审批方。
func (h *AgentHandler) GetHITLDefaultReviewer(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"defaultReviewer": h.hitlEffectiveDefaultReviewer(),
})
c.JSON(http.StatusOK, h.hitlDefaultConfigResponse())
}
// UpdateHITLDefaultReviewer 将全局默认审批方写入 config.yaml(未选会话时切换审批方)。
@@ -1081,10 +1148,9 @@ func (h *AgentHandler) UpdateHITLDefaultReviewer(c *gin.Context) {
if h.audit != nil {
h.audit.RecordOK(c, "hitl", "default_reviewer_update", "HITL 全局默认审批方更新", "hitl_config", "default_reviewer", nil)
}
c.JSON(http.StatusOK, gin.H{
"ok": true,
"defaultReviewer": reviewer,
})
out := h.hitlDefaultConfigResponse()
out["ok"] = true
c.JSON(http.StatusOK, out)
}
// SetHITLGlobalToolWhitelist 整表替换 config.yaml 中的全局免审批工具白名单。
+19 -8
View File
@@ -76,6 +76,7 @@ type MonitorStatsSummary struct {
TotalCalls int `json:"totalCalls"`
SuccessCalls int `json:"successCalls"`
FailedCalls int `json:"failedCalls"`
BlockedCalls int `json:"blockedCalls"`
LastCallTime *time.Time `json:"lastCallTime,omitempty"`
ToolCount int `json:"toolCount"`
}
@@ -171,6 +172,8 @@ func summarizeAccessibleExecutionPage(executions []*mcp.ToolExecution, topN int)
stat.FailedCalls++
} else if exec.Status == "completed" {
stat.SuccessCalls++
} else if exec.Status == mcp.ToolExecutionStatusBlocked {
stat.BlockedCalls++
}
started := exec.StartTime
if stat.LastCallTime == nil || started.After(*stat.LastCallTime) {
@@ -448,6 +451,7 @@ func dbStatsSummaryToMonitor(result *database.ToolStatsSummaryResult) *MonitorSt
TotalCalls: result.Summary.TotalCalls,
SuccessCalls: result.Summary.SuccessCalls,
FailedCalls: result.Summary.FailedCalls,
BlockedCalls: result.Summary.BlockedCalls,
ToolCount: result.Summary.ToolCount,
}
if result.Summary.LastCallTime != nil {
@@ -472,6 +476,7 @@ func summarizeToolStats(stats map[string]*mcp.ToolStats, topN int) (*MonitorStat
summary.TotalCalls += stat.TotalCalls
summary.SuccessCalls += stat.SuccessCalls
summary.FailedCalls += stat.FailedCalls
summary.BlockedCalls += stat.BlockedCalls
if stat.LastCallTime != nil && (summary.LastCallTime == nil || stat.LastCallTime.After(*summary.LastCallTime)) {
t := *stat.LastCallTime
summary.LastCallTime = &t
@@ -528,6 +533,7 @@ func (h *MonitorHandler) loadStatsMap() map[string]*mcp.ToolStats {
existing.TotalCalls += v.TotalCalls
existing.SuccessCalls += v.SuccessCalls
existing.FailedCalls += v.FailedCalls
existing.BlockedCalls += v.BlockedCalls
// 使用最新的调用时间
if v.LastCallTime != nil && (existing.LastCallTime == nil || v.LastCallTime.After(*existing.LastCallTime)) {
existing.LastCallTime = v.LastCallTime
@@ -734,9 +740,10 @@ func (h *MonitorHandler) GetStats(c *gin.Context) {
// CallsTimelinePoint 调用趋势数据点
type CallsTimelinePoint struct {
T time.Time `json:"t"`
Total int `json:"total"`
Failed int `json:"failed"`
T time.Time `json:"t"`
Total int `json:"total"`
Failed int `json:"failed"`
Blocked int `json:"blocked"`
}
// CallsTimelineSummary 调用趋势汇总
@@ -778,7 +785,7 @@ func truncateToBucket(t time.Time, bucketSize time.Duration, dailyBuckets bool)
return t.Truncate(bucketSize)
}
func buildCallsTimelinePoints(cfg callsTimelineConfig, buckets map[time.Time]struct{ total, failed int }) []CallsTimelinePoint {
func buildCallsTimelinePoints(cfg callsTimelineConfig, buckets map[time.Time]struct{ total, failed, blocked int }) []CallsTimelinePoint {
now := time.Now()
start := truncateToBucket(now.Add(-cfg.duration), cfg.bucketSize, cfg.dailyBuckets)
end := truncateToBucket(now, cfg.bucketSize, cfg.dailyBuckets)
@@ -787,9 +794,10 @@ func buildCallsTimelinePoints(cfg callsTimelineConfig, buckets map[time.Time]str
for current := start; !current.After(end); current = current.Add(cfg.bucketSize) {
val := buckets[current]
points = append(points, CallsTimelinePoint{
T: current,
Total: val.total,
Failed: val.failed,
T: current,
Total: val.total,
Failed: val.failed,
Blocked: val.blocked,
})
}
return points
@@ -797,7 +805,7 @@ func buildCallsTimelinePoints(cfg callsTimelineConfig, buckets map[time.Time]str
func (h *MonitorHandler) loadCallsTimeline(cfg callsTimelineConfig) []CallsTimelinePoint {
since := time.Now().Add(-cfg.duration)
bucketMap := make(map[time.Time]struct{ total, failed int })
bucketMap := make(map[time.Time]struct{ total, failed, blocked int })
if h.db != nil {
dbBuckets, err := h.db.LoadCallsTimeline(since, cfg.dailyBuckets)
@@ -809,6 +817,7 @@ func (h *MonitorHandler) loadCallsTimeline(cfg callsTimelineConfig) []CallsTimel
entry := bucketMap[key]
entry.total += b.Total
entry.failed += b.Failed
entry.blocked += b.Blocked
bucketMap[key] = entry
}
return buildCallsTimelinePoints(cfg, bucketMap)
@@ -824,6 +833,8 @@ func (h *MonitorHandler) loadCallsTimeline(cfg callsTimelineConfig) []CallsTimel
entry.total++
if monitorStatusCountsAsFailed(exec.Status) {
entry.failed++
} else if exec.Status == mcp.ToolExecutionStatusBlocked {
entry.blocked++
}
bucketMap[key] = entry
}
+12 -7
View File
@@ -385,15 +385,17 @@ func (h *AgentHandler) MultiAgentLoopStream(c *gin.Context) {
h.logger.Error("Eino DeepAgent 执行失败", zap.Error(runErr))
taskStatus = "failed"
h.tasks.UpdateTaskStatus(conversationID, taskStatus)
errMsg := "执行失败: " + runErr.Error()
clientErr := multiagent.EinoClientRunErrorMessage(runErr)
errMsg := "执行失败: " + clientErr
if assistantMessageID != "" {
_, _ = h.db.Exec("UPDATE messages SET content = ?, updated_at = ? WHERE id = ?", errMsg, time.Now(), assistantMessageID)
_ = h.db.AddProcessDetail(assistantMessageID, conversationID, "error", errMsg, nil)
}
sendEvent("error", errMsg, map[string]interface{}{
"conversationId": conversationID,
"messageId": assistantMessageID,
})
errData := multiagent.EinoClientRunErrorFields(runErr)
errData["conversationId"] = conversationID
errData["messageId"] = assistantMessageID
errData["error"] = errMsg
sendEvent("error", errMsg, errData)
sendEvent("done", "", map[string]interface{}{"conversationId": conversationID})
timeoutCancel()
return
@@ -513,11 +515,14 @@ func (h *AgentHandler) MultiAgentLoop(c *gin.Context) {
h.persistEinoAgentTraceForResume(prep.ConversationID, result)
}
h.logger.Error("Eino DeepAgent 执行失败", zap.Error(runErr))
errMsg := "执行失败: " + runErr.Error()
clientErr := multiagent.EinoClientRunErrorMessage(runErr)
errMsg := "执行失败: " + clientErr
if prep.AssistantMessageID != "" {
_, _ = h.db.Exec("UPDATE messages SET content = ?, updated_at = ? WHERE id = ?", errMsg, time.Now(), prep.AssistantMessageID)
}
c.JSON(http.StatusInternalServerError, gin.H{"error": errMsg})
errData := multiagent.EinoClientRunErrorFields(runErr)
errData["error"] = errMsg
c.JSON(http.StatusInternalServerError, errData)
return
}
mw := &h.config.MultiAgent.EinoMiddleware
-497
View File
@@ -456,75 +456,6 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
},
},
},
"Group": map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"id": map[string]interface{}{
"type": "string",
"description": "分组ID",
},
"name": map[string]interface{}{
"type": "string",
"description": "分组名称",
},
"icon": map[string]interface{}{
"type": "string",
"description": "分组图标",
},
"createdAt": map[string]interface{}{
"type": "string",
"format": "date-time",
"description": "创建时间",
},
"updatedAt": map[string]interface{}{
"type": "string",
"format": "date-time",
"description": "更新时间",
},
},
},
"CreateGroupRequest": map[string]interface{}{
"type": "object",
"required": []string{"name"},
"properties": map[string]interface{}{
"name": map[string]interface{}{
"type": "string",
"description": "分组名称",
},
"icon": map[string]interface{}{
"type": "string",
"description": "分组图标(可选)",
},
},
},
"UpdateGroupRequest": map[string]interface{}{
"type": "object",
"required": []string{"name"},
"properties": map[string]interface{}{
"name": map[string]interface{}{
"type": "string",
"description": "分组名称",
},
"icon": map[string]interface{}{
"type": "string",
"description": "分组图标",
},
},
},
"AddConversationToGroupRequest": map[string]interface{}{
"type": "object",
"required": []string{"conversationId", "groupId"},
"properties": map[string]interface{}{
"conversationId": map[string]interface{}{
"type": "string",
"description": "对话ID",
},
"groupId": map[string]interface{}{
"type": "string",
"description": "分组ID",
},
},
},
"BatchTaskRequest": map[string]interface{}{
"type": "object",
"required": []string{"tasks"},
@@ -1401,15 +1332,6 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
"type": "string",
},
},
{
"name": "exclude_grouped",
"in": "query",
"required": false,
"description": "为 true 时排除已加入分组的对话(默认在未搜索且未按项目筛选时启用)",
"schema": map[string]interface{}{
"type": "boolean",
},
},
{
"name": "sort_by",
"in": "query",
@@ -2315,290 +2237,6 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
},
},
},
"/api/groups": map[string]interface{}{
"post": map[string]interface{}{
"tags": []string{"对话分组"},
"summary": "创建分组",
"description": "创建一个新的对话分组",
"operationId": "createGroup",
"requestBody": map[string]interface{}{
"required": true,
"content": map[string]interface{}{
"application/json": map[string]interface{}{
"schema": map[string]interface{}{
"$ref": "#/components/schemas/CreateGroupRequest",
},
},
},
},
"responses": map[string]interface{}{
"200": map[string]interface{}{
"description": "创建成功",
"content": map[string]interface{}{
"application/json": map[string]interface{}{
"schema": map[string]interface{}{
"$ref": "#/components/schemas/Group",
},
},
},
},
"400": map[string]interface{}{
"description": "请求参数错误或分组名称已存在",
},
"401": map[string]interface{}{
"description": "未授权",
},
},
},
"get": map[string]interface{}{
"tags": []string{"对话分组"},
"summary": "列出分组",
"description": "获取所有对话分组",
"operationId": "listGroups",
"responses": map[string]interface{}{
"200": map[string]interface{}{
"description": "获取成功",
"content": map[string]interface{}{
"application/json": map[string]interface{}{
"schema": map[string]interface{}{
"type": "array",
"items": map[string]interface{}{
"$ref": "#/components/schemas/Group",
},
},
},
},
},
"401": map[string]interface{}{
"description": "未授权",
},
},
},
},
"/api/groups/{id}": map[string]interface{}{
"get": map[string]interface{}{
"tags": []string{"对话分组"},
"summary": "获取分组",
"description": "获取指定分组的详细信息",
"operationId": "getGroup",
"parameters": []map[string]interface{}{
{
"name": "id",
"in": "path",
"required": true,
"description": "分组ID",
"schema": map[string]interface{}{
"type": "string",
},
},
},
"responses": map[string]interface{}{
"200": map[string]interface{}{
"description": "获取成功",
"content": map[string]interface{}{
"application/json": map[string]interface{}{
"schema": map[string]interface{}{
"$ref": "#/components/schemas/Group",
},
},
},
},
"404": map[string]interface{}{
"description": "分组不存在",
},
"401": map[string]interface{}{
"description": "未授权",
},
},
},
"put": map[string]interface{}{
"tags": []string{"对话分组"},
"summary": "更新分组",
"description": "更新分组信息",
"operationId": "updateGroup",
"parameters": []map[string]interface{}{
{
"name": "id",
"in": "path",
"required": true,
"description": "分组ID",
"schema": map[string]interface{}{
"type": "string",
},
},
},
"requestBody": map[string]interface{}{
"required": true,
"content": map[string]interface{}{
"application/json": map[string]interface{}{
"schema": map[string]interface{}{
"$ref": "#/components/schemas/UpdateGroupRequest",
},
},
},
},
"responses": map[string]interface{}{
"200": map[string]interface{}{
"description": "更新成功",
"content": map[string]interface{}{
"application/json": map[string]interface{}{
"schema": map[string]interface{}{
"$ref": "#/components/schemas/Group",
},
},
},
},
"400": map[string]interface{}{
"description": "请求参数错误或分组名称已存在",
},
"404": map[string]interface{}{
"description": "分组不存在",
},
"401": map[string]interface{}{
"description": "未授权",
},
},
},
"delete": map[string]interface{}{
"tags": []string{"对话分组"},
"summary": "删除分组",
"description": "删除指定分组",
"operationId": "deleteGroup",
"parameters": []map[string]interface{}{
{
"name": "id",
"in": "path",
"required": true,
"description": "分组ID",
"schema": map[string]interface{}{
"type": "string",
},
},
},
"responses": map[string]interface{}{
"200": map[string]interface{}{
"description": "删除成功",
},
"404": map[string]interface{}{
"description": "分组不存在",
},
"401": map[string]interface{}{
"description": "未授权",
},
},
},
},
"/api/groups/{id}/conversations": map[string]interface{}{
"get": map[string]interface{}{
"tags": []string{"对话分组"},
"summary": "获取分组中的对话",
"description": "获取指定分组中的所有对话",
"operationId": "getGroupConversations",
"parameters": []map[string]interface{}{
{
"name": "id",
"in": "path",
"required": true,
"description": "分组ID",
"schema": map[string]interface{}{
"type": "string",
},
},
},
"responses": map[string]interface{}{
"200": map[string]interface{}{
"description": "获取成功",
"content": map[string]interface{}{
"application/json": map[string]interface{}{
"schema": map[string]interface{}{
"type": "array",
"items": map[string]interface{}{
"$ref": "#/components/schemas/Conversation",
},
},
},
},
},
"404": map[string]interface{}{
"description": "分组不存在",
},
"401": map[string]interface{}{
"description": "未授权",
},
},
},
},
"/api/groups/conversations": map[string]interface{}{
"post": map[string]interface{}{
"tags": []string{"对话分组"},
"summary": "添加对话到分组",
"description": "将对话添加到指定分组",
"operationId": "addConversationToGroup",
"requestBody": map[string]interface{}{
"required": true,
"content": map[string]interface{}{
"application/json": map[string]interface{}{
"schema": map[string]interface{}{
"$ref": "#/components/schemas/AddConversationToGroupRequest",
},
},
},
},
"responses": map[string]interface{}{
"200": map[string]interface{}{
"description": "添加成功",
},
"400": map[string]interface{}{
"description": "请求参数错误",
},
"404": map[string]interface{}{
"description": "对话或分组不存在",
},
"401": map[string]interface{}{
"description": "未授权",
},
},
},
},
"/api/groups/{id}/conversations/{conversationId}": map[string]interface{}{
"delete": map[string]interface{}{
"tags": []string{"对话分组"},
"summary": "从分组移除对话",
"description": "从指定分组中移除对话",
"operationId": "removeConversationFromGroup",
"parameters": []map[string]interface{}{
{
"name": "id",
"in": "path",
"required": true,
"description": "分组ID",
"schema": map[string]interface{}{
"type": "string",
},
},
{
"name": "conversationId",
"in": "path",
"required": true,
"description": "对话ID",
"schema": map[string]interface{}{
"type": "string",
},
},
},
"responses": map[string]interface{}{
"200": map[string]interface{}{
"description": "移除成功",
},
"404": map[string]interface{}{
"description": "对话或分组不存在",
},
"401": map[string]interface{}{
"description": "未授权",
},
},
},
},
"/api/assets/import": map[string]interface{}{
"post": map[string]interface{}{
"tags": []string{"资产管理"},
@@ -4266,109 +3904,6 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
},
},
},
"/api/groups/{id}/pinned": map[string]interface{}{
"put": map[string]interface{}{
"tags": []string{"对话分组"},
"summary": "设置分组置顶",
"description": "设置或取消分组的置顶状态",
"operationId": "updateGroupPinned",
"parameters": []map[string]interface{}{
{
"name": "id",
"in": "path",
"required": true,
"description": "分组ID",
"schema": map[string]interface{}{
"type": "string",
},
},
},
"requestBody": map[string]interface{}{
"required": true,
"content": map[string]interface{}{
"application/json": map[string]interface{}{
"schema": map[string]interface{}{
"type": "object",
"required": []string{"pinned"},
"properties": map[string]interface{}{
"pinned": map[string]interface{}{
"type": "boolean",
"description": "是否置顶",
},
},
},
},
},
},
"responses": map[string]interface{}{
"200": map[string]interface{}{
"description": "更新成功",
},
"404": map[string]interface{}{
"description": "分组不存在",
},
"401": map[string]interface{}{
"description": "未授权",
},
},
},
},
"/api/groups/{id}/conversations/{conversationId}/pinned": map[string]interface{}{
"put": map[string]interface{}{
"tags": []string{"对话分组"},
"summary": "设置分组中对话的置顶",
"description": "设置或取消分组中对话的置顶状态",
"operationId": "updateConversationPinnedInGroup",
"parameters": []map[string]interface{}{
{
"name": "id",
"in": "path",
"required": true,
"description": "分组ID",
"schema": map[string]interface{}{
"type": "string",
},
},
{
"name": "conversationId",
"in": "path",
"required": true,
"description": "对话ID",
"schema": map[string]interface{}{
"type": "string",
},
},
},
"requestBody": map[string]interface{}{
"required": true,
"content": map[string]interface{}{
"application/json": map[string]interface{}{
"schema": map[string]interface{}{
"type": "object",
"required": []string{"pinned"},
"properties": map[string]interface{}{
"pinned": map[string]interface{}{
"type": "boolean",
"description": "是否置顶",
},
},
},
},
},
},
"responses": map[string]interface{}{
"200": map[string]interface{}{
"description": "更新成功",
},
"404": map[string]interface{}{
"description": "对话或分组不存在",
},
"401": map[string]interface{}{
"description": "未授权",
},
},
},
},
"/api/knowledge/categories": map[string]interface{}{
"get": map[string]interface{}{
"tags": []string{"知识库"},
@@ -5194,38 +4729,6 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
},
},
},
// ==================== 对话分组 - 缺失端点 ====================
"/api/groups/mappings": map[string]interface{}{
"get": map[string]interface{}{
"tags": []string{"对话分组"},
"summary": "获取所有分组映射",
"description": "获取所有对话与分组之间的映射关系列表。",
"operationId": "getAllGroupMappings",
"responses": map[string]interface{}{
"200": map[string]interface{}{
"description": "获取成功",
"content": map[string]interface{}{
"application/json": map[string]interface{}{
"schema": map[string]interface{}{
"type": "array",
"items": map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"conversation_id": map[string]interface{}{"type": "string", "description": "对话ID"},
"group_id": map[string]interface{}{"type": "string", "description": "分组ID"},
"pinned": map[string]interface{}{"type": "boolean", "description": "是否置顶"},
},
},
},
},
},
},
"401": map[string]interface{}{"description": "未授权"},
},
},
},
// ==================== FOFA信息收集 ====================
"/api/fofa/search": map[string]interface{}{
"post": map[string]interface{}{
+6 -11
View File
@@ -5,7 +5,7 @@ package handler
var apiDocI18nTagToKey = map[string]string{
"认证": "auth", "对话管理": "conversationManagement", "对话交互": "conversationInteraction",
"批量任务": "batchTasks", "对话分组": "conversationGroups", "漏洞管理": "vulnerabilityManagement",
"批量任务": "batchTasks", "漏洞管理": "vulnerabilityManagement",
"角色管理": "roleManagement", "Skills管理": "skillsManagement", "监控": "monitoring",
"配置管理": "configManagement", "外部MCP管理": "externalMCPManagement", "攻击链": "attackChain",
"知识库": "knowledgeBase", "MCP": "mcp",
@@ -24,10 +24,7 @@ var apiDocI18nSummaryToKey = map[string]string{
"删除批量任务队列": "deleteBatchQueue", "启动批量任务队列": "startBatchQueue", "暂停批量任务队列": "pauseBatchQueue",
"添加任务到队列": "addTaskToQueue", "SQL注入扫描": "sqlInjectionScan", "端口扫描": "portScan",
"更新批量任务": "updateBatchTask", "删除批量任务": "deleteBatchTask",
"创建分组": "createGroup", "列出分组": "listGroups", "获取分组": "getGroup", "更新分组": "updateGroup",
"删除分组": "deleteGroup", "获取分组中的对话": "getGroupConversations", "添加对话到分组": "addConversationToGroup",
"从分组移除对话": "removeConversationFromGroup",
"列出漏洞": "listVulnerabilities", "创建漏洞": "createVulnerability", "获取漏洞统计": "getVulnerabilityStats",
"列出漏洞": "listVulnerabilities", "创建漏洞": "createVulnerability", "获取漏洞统计": "getVulnerabilityStats",
"获取漏洞": "getVulnerability", "更新漏洞": "updateVulnerability", "删除漏洞": "deleteVulnerability",
"列出角色": "listRoles", "创建角色": "createRole", "获取角色": "getRole", "更新角色": "updateRole", "删除角色": "deleteRole",
"获取可用Skills列表": "getAvailableSkills", "列出Skills": "listSkills", "创建Skill": "createSkill",
@@ -40,8 +37,8 @@ var apiDocI18nSummaryToKey = map[string]string{
"添加或更新外部MCP": "addOrUpdateExternalMCP", "stdio模式配置": "stdioModeConfig", "SSE模式配置": "sseModeConfig",
"删除外部MCP": "deleteExternalMCP", "启动外部MCP": "startExternalMCP", "停止外部MCP": "stopExternalMCP",
"获取攻击链": "getAttackChain", "重新生成攻击链": "regenerateAttackChain",
"设置对话置顶": "pinConversation", "设置分组置顶": "pinGroup", "设置分组中对话的置顶": "pinGroupConversation",
"获取分类": "getCategories", "列出知识项": "listKnowledgeItems", "创建知识项": "createKnowledgeItem",
"设置对话置顶": "pinConversation",
"获取分类": "getCategories", "列出知识项": "listKnowledgeItems", "创建知识项": "createKnowledgeItem",
"获取知识项": "getKnowledgeItem", "更新知识项": "updateKnowledgeItem", "删除知识项": "deleteKnowledgeItem",
"获取索引状态": "getIndexStatus", "构建索引": "startKnowledgeIndex", "扫描知识库": "scanKnowledgeBase",
"搜索知识库": "searchKnowledgeBase", "基础搜索": "basicSearch", "按风险类型搜索": "searchByRiskType",
@@ -52,8 +49,7 @@ var apiDocI18nSummaryToKey = map[string]string{
"删除对话轮次": "deleteConversationTurn", "获取消息过程详情": "getMessageProcessDetails",
"重跑批量任务队列": "rerunBatchQueue", "修改队列元数据": "updateBatchQueueMetadata",
"修改队列调度配置": "updateBatchQueueSchedule", "开关Cron自动调度": "setBatchQueueScheduleEnabled",
"获取所有分组映射": "getAllGroupMappings",
"FOFA搜索": "fofaSearch", "自然语言解析为FOFA语法": "fofaParse",
"FOFA搜索": "fofaSearch", "自然语言解析为FOFA语法": "fofaParse",
"测试OpenAI API连接": "testOpenAI",
"执行终端命令": "terminalRun", "流式执行终端命令": "terminalRunStream", "WebSocket终端": "terminalWS",
"列出WebShell连接": "listWebshellConnections", "创建WebShell连接": "createWebshellConnection",
@@ -84,7 +80,6 @@ var apiDocI18nResponseDescToKey = map[string]string{
"获取成功": "getSuccess", "未授权": "unauthorized", "未授权,需要有效的Token": "unauthorizedToken",
"创建成功": "createSuccess", "请求参数错误": "badRequest", "对话不存在": "conversationNotFound",
"对话不存在或结果不存在": "conversationOrResultNotFound", "请求参数错误(如task为空)": "badRequestTaskEmpty",
"请求参数错误或分组名称已存在": "badRequestGroupNameExists", "分组不存在": "groupNotFound",
"请求参数错误(如配置格式不正确、缺少必需字段等)": "badRequestConfig",
"请求参数错误(如query为空)": "badRequestQueryEmpty", "方法不允许(仅支持POST请求)": "methodNotAllowed",
"登录成功": "loginSuccess", "密码错误": "invalidPassword", "登出成功": "logoutSuccess",
@@ -92,7 +87,7 @@ var apiDocI18nResponseDescToKey = map[string]string{
"对话创建成功": "conversationCreated", "服务器内部错误": "internalError", "更新成功": "updateSuccess",
"删除成功": "deleteSuccess", "队列不存在": "queueNotFound", "启动成功": "startSuccess",
"暂停成功": "pauseSuccess", "添加成功": "addSuccess",
"任务不存在": "taskNotFound", "对话或分组不存在": "conversationOrGroupNotFound",
"任务不存在": "taskNotFound",
"取消请求已提交": "cancelSubmitted", "未找到正在执行的任务": "noRunningTask",
"消息发送成功,返回AI回复": "messageSent", "流式响应(Server-Sent Events": "streamResponse",
// 新增缺失端点响应
+175
View File
@@ -0,0 +1,175 @@
package handler
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"cyberstrike-ai/internal/toolguard"
"github.com/gin-gonic/gin"
"gopkg.in/yaml.v3"
)
func (h *ConfigHandler) SetToolGuard(manager *toolguard.Manager) {
h.mu.Lock()
defer h.mu.Unlock()
h.toolGuard = manager
}
func (h *ConfigHandler) GetToolGuard(c *gin.Context) {
h.mu.RLock()
defer h.mu.RUnlock()
if h.toolGuard == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "调用拦截服务未初始化"})
return
}
c.JSON(http.StatusOK, h.toolGuard.Config())
}
// decodeToolGuardRequest bounds both config and dry-run inputs, rejects unknown
// fields and trailing JSON, and never invokes an actual tool.
func decodeToolGuardRequest(c *gin.Context, dst interface{}) error {
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 1<<20)
decoder := json.NewDecoder(c.Request.Body)
decoder.DisallowUnknownFields()
decoder.UseNumber()
if err := decoder.Decode(dst); err != nil {
return err
}
if err := decoder.Decode(new(interface{})); err != io.EOF {
return fmt.Errorf("请求必须只包含一个 JSON 对象")
}
return nil
}
func (h *ConfigHandler) UpdateToolGuard(c *gin.Context) {
var req struct {
Enabled *bool `json:"enabled"`
Rules *[]toolguard.Rule `json:"rules"`
}
if err := decodeToolGuardRequest(c, &req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的调用拦截配置: " + err.Error()})
return
}
if req.Enabled == nil || req.Rules == nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "必须明确提供 enabled 和 rules;清空规则请提供空数组"})
return
}
cfg := toolguard.Config{Enabled: *req.Enabled, Rules: *req.Rules}
if _, err := toolguard.Compile(cfg); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
h.mu.Lock()
defer h.mu.Unlock()
if h.toolGuard == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "调用拦截服务未初始化"})
return
}
// Commit the file first; a validation/write failure must leave the current
// effective policy and in-memory config intact.
if err := h.saveToolGuardConfig(cfg); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "保存调用拦截配置失败: " + err.Error()})
return
}
if err := h.toolGuard.Update(cfg); err != nil {
// The same immutable input was compiled above, so this cannot fail
// unless validation gains an additional runtime dependency.
c.JSON(http.StatusInternalServerError, gin.H{"error": "应用调用拦截配置失败: " + err.Error()})
return
}
h.config.ToolGuard = &cfg
if h.audit != nil {
h.audit.RecordOK(c, "config", "tool_guard_update", "更新调用拦截规则", "config", "tool_guard", map[string]interface{}{
"enabled": cfg.Enabled, "rule_count": len(cfg.Rules),
})
}
c.JSON(http.StatusOK, h.toolGuard.Config())
}
func (h *ConfigHandler) TestToolGuard(c *gin.Context) {
var req struct {
Config *toolguard.Config `json:"config"`
ToolName string `json:"toolName"`
Arguments map[string]interface{} `json:"arguments"`
}
if err := decodeToolGuardRequest(c, &req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的试匹配参数: " + err.Error()})
return
}
if req.Config == nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "请提供待测试的 config"})
return
}
policy, err := toolguard.Compile(*req.Config)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if match := policy.Check(req.ToolName, req.Arguments); match != nil {
c.JSON(http.StatusOK, gin.H{"blocked": true, "match": match})
return
}
c.JSON(http.StatusOK, gin.H{"blocked": false})
}
// saveToolGuardConfig changes only this YAML section, preserving unrelated
// settings/comments and file permissions. Rename makes the write atomic.
// h.mu protects the runtime configuration; configFileMu also covers independent
// writers such as ExternalMCPHandler.
func (h *ConfigHandler) saveToolGuardConfig(cfg toolguard.Config) error {
configFileMu.Lock()
defer configFileMu.Unlock()
path, err := filepath.EvalSymlinks(h.configPath)
if err != nil {
return err
}
doc, err := loadYAMLDocument(path)
if err != nil {
return err
}
var node yaml.Node
if err := node.Encode(cfg); err != nil {
return err
}
_, value := ensureKeyValue(doc.Content[0], "tool_guard")
*value = node
var buf bytes.Buffer
encoder := yaml.NewEncoder(&buf)
encoder.SetIndent(2)
if err := encoder.Encode(doc); err != nil {
return err
}
if err := encoder.Close(); err != nil {
return err
}
info, err := os.Stat(path)
if err != nil {
return err
}
tmp, err := os.CreateTemp(filepath.Dir(path), ".tool-guard-*.yaml")
if err != nil {
return err
}
defer os.Remove(tmp.Name())
defer tmp.Close()
if err := tmp.Chmod(info.Mode().Perm()); err != nil {
return err
}
if _, err := tmp.Write(buf.Bytes()); err != nil {
return err
}
if err := tmp.Sync(); err != nil {
return err
}
if err := tmp.Close(); err != nil {
return err
}
return os.Rename(tmp.Name(), path)
}
+193
View File
@@ -0,0 +1,193 @@
package handler
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"reflect"
"strings"
"sync"
"testing"
"cyberstrike-ai/internal/config"
"cyberstrike-ai/internal/database"
"cyberstrike-ai/internal/security"
"cyberstrike-ai/internal/toolguard"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
)
func newToolGuardTestHandler(t *testing.T) *ConfigHandler {
t.Helper()
path := filepath.Join(t.TempDir(), "config.yaml")
if err := os.WriteFile(path, []byte("# keep this comment\nserver:\n port: 8123\nhitl:\n tool_whitelist: [read_file]\n"), 0600); err != nil {
t.Fatal(err)
}
manager, err := toolguard.NewManager(toolguard.DefaultConfig())
if err != nil {
t.Fatal(err)
}
return &ConfigHandler{configPath: path, config: &config.Config{}, toolGuard: manager}
}
func toolGuardRequest(t *testing.T, handler gin.HandlerFunc, body interface{}) *httptest.ResponseRecorder {
t.Helper()
data, err := json.Marshal(body)
if err != nil {
t.Fatal(err)
}
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest(http.MethodPut, "/api/tool-guard", bytes.NewReader(data))
c.Request.Header.Set("Content-Type", "application/json")
handler(c)
return w
}
func TestToolGuardSavePersistsAndAppliesWithoutChangingHITL(t *testing.T) {
h := newToolGuardTestHandler(t)
cfg := toolguard.DefaultConfig()
cfg.Rules[0].Message = "识别到 {match},禁止攻击政府网站,请检查目标。"
w := toolGuardRequest(t, h.UpdateToolGuard, cfg)
if w.Code != http.StatusOK {
t.Fatalf("save: %d %s", w.Code, w.Body.String())
}
loaded, err := config.Load(h.configPath)
if err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(loaded.EffectiveToolGuard(), cfg) || !reflect.DeepEqual(h.toolGuard.Config(), cfg) {
t.Fatal("saved and effective policies differ")
}
if loaded.Server.Port != 8123 || !reflect.DeepEqual(loaded.Hitl.ToolWhitelist, []string{"read_file"}) {
t.Fatal("unrelated configuration was changed")
}
info, _ := os.Stat(h.configPath)
data, _ := os.ReadFile(h.configPath)
if info.Mode().Perm() != 0600 || !strings.Contains(string(data), "# keep this comment") {
t.Fatal("file permissions or comments were lost")
}
match := h.toolGuard.Check("scan", map[string]interface{}{"target": "agency.gov.cn"})
if match == nil || !strings.Contains(match.Message, "agency.gov.cn") {
t.Fatalf("updated message not applied: %+v", match)
}
cfg.Enabled = false
w = toolGuardRequest(t, h.UpdateToolGuard, cfg)
if w.Code != http.StatusOK || h.toolGuard.Check("scan", map[string]interface{}{"target": "agency.gov"}) != nil {
t.Fatal("explicitly disabling protection did not apply")
}
}
func TestToolGuardInvalidAndFailedSaveKeepEffectivePolicy(t *testing.T) {
h := newToolGuardTestHandler(t)
before, _ := os.ReadFile(h.configPath)
cfg := toolguard.DefaultConfig()
cfg.Enabled = false
cfg.Rules[0].Pattern = "["
for _, body := range []interface{}{cfg, map[string]interface{}{}, nil, map[string]interface{}{"enabled": false, "rules": nil}} {
w := toolGuardRequest(t, h.UpdateToolGuard, body)
if w.Code != http.StatusBadRequest {
t.Fatalf("invalid update accepted: %d %s", w.Code, w.Body.String())
}
}
after, _ := os.ReadFile(h.configPath)
if !bytes.Equal(before, after) || !h.toolGuard.Config().Enabled {
t.Fatal("invalid input changed protection")
}
h.configPath = filepath.Join(t.TempDir(), "missing", "config.yaml")
cfg = toolguard.DefaultConfig()
cfg.Enabled = false
w := toolGuardRequest(t, h.UpdateToolGuard, cfg)
if w.Code != http.StatusInternalServerError || !h.toolGuard.Config().Enabled || h.config.ToolGuard != nil {
t.Fatal("failed persistence changed live configuration")
}
}
func TestToolGuardDryRunUsesUnsavedPolicyWithoutMutation(t *testing.T) {
h := newToolGuardTestHandler(t)
cfg := toolguard.DefaultConfig()
cfg.Rules[0].Pattern = "example\\.org"
w := toolGuardRequest(t, h.TestToolGuard, map[string]interface{}{
"config": cfg, "toolName": "scan", "arguments": map[string]interface{}{"target": "example.org"},
})
var got struct {
Blocked bool `json:"blocked"`
Match *toolguard.Match `json:"match"`
}
if w.Code != http.StatusOK || json.Unmarshal(w.Body.Bytes(), &got) != nil || !got.Blocked || got.Match == nil || got.Match.MatchedText != "example.org" {
t.Fatalf("dry run failed: %s", w.Body.String())
}
if !reflect.DeepEqual(h.toolGuard.Config(), toolguard.DefaultConfig()) || h.config.ToolGuard != nil {
t.Fatal("dry run changed live configuration")
}
w = toolGuardRequest(t, h.TestToolGuard, map[string]interface{}{"config": cfg, "arguments": []string{"example.org"}})
if w.Code != http.StatusBadRequest {
t.Fatal("non-object tool arguments accepted")
}
}
func TestToolGuardRoutesEnforceConfigurationPermissions(t *testing.T) {
gin.SetMode(gin.TestMode)
for _, tc := range []struct {
method, path, permission, scope string
want int
}{
{"GET", "/api/tool-guard", "hitl:read", database.RBACScopeAll, 403},
{"PUT", "/api/tool-guard", "hitl:write", database.RBACScopeAll, 403},
{"GET", "/api/tool-guard", "config:read", database.RBACScopeAll, 200},
{"POST", "/api/tool-guard/test", "config:read", database.RBACScopeAll, 200},
{"PUT", "/api/tool-guard", "config:write", database.RBACScopeAll, 200},
{"PUT", "/api/tool-guard", "config:write", database.RBACScopeOwn, 403},
} {
t.Run(tc.method+tc.permission+tc.scope, func(t *testing.T) {
r := gin.New()
r.Use(func(c *gin.Context) {
c.Set(security.ContextSessionKey, security.Session{UserID: "test", Permissions: map[string]bool{tc.permission: true}, Scope: tc.scope})
})
r.Use(security.RBACMiddleware(&database.DB{}))
r.Handle(tc.method, tc.path, func(c *gin.Context) { c.Status(200) })
w := httptest.NewRecorder()
r.ServeHTTP(w, httptest.NewRequest(tc.method, tc.path, nil))
if w.Code != tc.want {
t.Fatalf("got %d, want %d: %s", w.Code, tc.want, w.Body.String())
}
})
}
}
func TestToolGuardConcurrentOtherSettingsSavePreservesPolicy(t *testing.T) {
h := newToolGuardTestHandler(t)
external := &ExternalMCPHandler{configPath: h.configPath, config: h.config, logger: zap.NewNop()}
cfg := toolguard.DefaultConfig()
cfg.Rules[0].Message = "持久化策略 {match}"
var wg sync.WaitGroup
errors := make(chan error, 2)
for _, save := range []func() error{func() error { return h.saveToolGuardConfig(cfg) }, external.saveConfig} {
wg.Add(1)
go func(save func() error) {
defer wg.Done()
for i := 0; i < 20; i++ {
if err := save(); err != nil {
errors <- err
return
}
}
}(save)
}
wg.Wait()
close(errors)
for err := range errors {
t.Fatal(err)
}
loaded, err := config.Load(h.configPath)
if err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(loaded.EffectiveToolGuard(), cfg) {
t.Fatal("another settings save overwrote the tool guard policy")
}
}
+88
View File
@@ -0,0 +1,88 @@
package logger
import (
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"sync"
"time"
)
// DiagnosticOptions controls the additional warn-and-above diagnostic output.
type DiagnosticOptions struct {
Dir string
Disabled bool
RetentionDays int // Values <= 0 use the default of 14 calendar days.
}
// dailyWriter opens lazily: healthy runs create no diagnostic files. Opening
// per write also avoids keeping descriptors open across rotation or shutdown.
type dailyWriter struct {
mu sync.Mutex
dir string
retentionDays int
cleanedDay string
now func() time.Time
}
func newDailyWriter(options DiagnosticOptions) *dailyWriter {
if options.Dir == "" {
options.Dir = "log"
}
if options.RetentionDays <= 0 {
options.RetentionDays = 14
}
return &dailyWriter{dir: options.Dir, retentionDays: options.RetentionDays, now: time.Now}
}
func (w *dailyWriter) Write(p []byte) (int, error) {
w.mu.Lock()
defer w.mu.Unlock()
now := w.now()
day := now.Format(time.DateOnly)
if err := os.MkdirAll(w.dir, 0700); err != nil {
return 0, fmt.Errorf("create diagnostic log directory: %w", err)
}
f, err := os.OpenFile(filepath.Join(w.dir, "diagnostic-"+day+".log"), os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0600)
if err != nil {
return 0, fmt.Errorf("open diagnostic log: %w", err)
}
n, writeErr := f.Write(p)
closeErr := f.Close()
var cleanupErr error
if w.cleanedDay != day {
cleanupErr = w.cleanup(now)
if cleanupErr == nil {
w.cleanedDay = day
}
}
return n, errors.Join(writeErr, closeErr, cleanupErr)
}
// Writes are unbuffered and files are closed before Write returns.
func (w *dailyWriter) Sync() error { return nil }
func (w *dailyWriter) cleanup(now time.Time) error {
entries, err := os.ReadDir(w.dir)
if err != nil {
return err
}
cutoff := now.AddDate(0, 0, -(w.retentionDays - 1)).Format(time.DateOnly)
var errs []error
for _, entry := range entries {
name := entry.Name()
if !entry.Type().IsRegular() || !strings.HasPrefix(name, "diagnostic-") || !strings.HasSuffix(name, ".log") {
continue
}
day := strings.TrimSuffix(strings.TrimPrefix(name, "diagnostic-"), ".log")
if _, err := time.Parse(time.DateOnly, day); err != nil || day >= cutoff {
continue
}
if err := os.Remove(filepath.Join(w.dir, name)); err != nil && !os.IsNotExist(err) {
errs = append(errs, fmt.Errorf("remove expired diagnostic log %s: %w", name, err))
}
}
return errors.Join(errs...)
}
+15 -1
View File
@@ -11,7 +11,7 @@ type Logger struct {
*zap.Logger
}
func New(level, output string) *Logger {
func New(level, output string, diagnostics ...DiagnosticOptions) *Logger {
var zapLevel zapcore.Level
switch level {
case "debug":
@@ -34,6 +34,8 @@ func New(level, output string) *Logger {
var writeSyncer zapcore.WriteSyncer
if output == "stdout" {
writeSyncer = zapcore.AddSync(os.Stdout)
} else if output == "stderr" {
writeSyncer = zapcore.AddSync(os.Stderr)
} else {
file, err := os.OpenFile(output, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0666)
if err != nil {
@@ -49,6 +51,18 @@ func New(level, output string) *Logger {
zapLevel,
)
options := DiagnosticOptions{}
if len(diagnostics) > 0 {
options = diagnostics[0]
}
if !options.Disabled {
// The diagnostic threshold is independent of the primary output level.
core = zapcore.NewTee(core, zapcore.NewCore(
zapcore.NewJSONEncoder(config.EncoderConfig),
newDailyWriter(options), zapcore.WarnLevel,
))
}
logger := zap.New(core, zap.AddCaller(), zap.AddStacktrace(zapcore.ErrorLevel))
return &Logger{Logger: logger}
+120
View File
@@ -0,0 +1,120 @@
package logger
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"time"
"go.uber.org/zap"
)
func TestDiagnosticFiltering(t *testing.T) {
for _, level := range []string{"debug", "error"} {
t.Run(level, func(t *testing.T) {
root := t.TempDir()
dir := filepath.Join(root, "log")
log := New(level, filepath.Join(root, "primary.log"), DiagnosticOptions{Dir: dir})
log.Debug("debug")
log.Info("info")
if _, err := os.Stat(dir); !os.IsNotExist(err) {
t.Fatalf("ordinary logs created diagnostic directory: %v", err)
}
child := log.With(zap.String("conversation_id", "test-id"))
child.Warn("retry", zap.Int("attempt", 2))
child.Error("failed", zap.Error(fmt.Errorf("test failure")))
files, _ := filepath.Glob(filepath.Join(dir, "*.log"))
if len(files) != 1 {
t.Fatalf("files: %v", files)
}
data, err := os.ReadFile(files[0])
if err != nil {
t.Fatal(err)
}
lines := strings.Split(strings.TrimSpace(string(data)), "\n")
if len(lines) != 2 {
t.Fatalf("unexpected diagnostic records: %s", data)
}
for i, line := range lines {
var record map[string]interface{}
if err := json.Unmarshal([]byte(line), &record); err != nil {
t.Fatal(err)
}
if record["conversation_id"] != "test-id" || record["timestamp"] == nil || record["caller"] == nil {
t.Fatalf("missing diagnostic context: %v", record)
}
if i == 1 && (record["stacktrace"] == nil || record["error"] != "test failure") {
t.Fatalf("missing error details: %v", record)
}
}
})
}
}
func TestDiagnosticDisabled(t *testing.T) {
dir := filepath.Join(t.TempDir(), "log")
log := New("error", os.DevNull, DiagnosticOptions{Dir: dir, Disabled: true})
log.Error("failure")
if _, err := os.Stat(dir); !os.IsNotExist(err) {
t.Fatalf("disabled diagnostics wrote files: %v", err)
}
}
func TestDailyRotationRetentionAndConcurrency(t *testing.T) {
dir := t.TempDir()
w := newDailyWriter(DiagnosticOptions{Dir: dir, RetentionDays: 2})
now := time.Date(2026, 9, 8, 23, 59, 59, 0, time.Local)
w.now = func() time.Time { return now }
for _, name := range []string{"diagnostic-2026-09-06.log", "diagnostic-2026-09-07.log", "other.log", "diagnostic-invalid.log"} {
if err := os.WriteFile(filepath.Join(dir, name), nil, 0600); err != nil {
t.Fatal(err)
}
}
if _, err := w.Write([]byte("before midnight\n")); err != nil {
t.Fatal(err)
}
if _, err := os.Stat(filepath.Join(dir, "diagnostic-2026-09-06.log")); !os.IsNotExist(err) {
t.Fatal("expired file remains")
}
now = now.Add(2 * time.Second)
var wg sync.WaitGroup
for i := 0; i < 50; i++ {
wg.Add(1)
go func() {
defer wg.Done()
if _, err := w.Write([]byte("after midnight\n")); err != nil {
t.Error(err)
}
}()
}
wg.Wait()
for name, count := range map[string]int{"diagnostic-2026-09-08.log": 1, "diagnostic-2026-09-09.log": 50} {
data, err := os.ReadFile(filepath.Join(dir, name))
if err != nil || strings.Count(string(data), "\n") != count {
t.Fatalf("%s: %q, %v", name, data, err)
}
}
if _, err := os.Stat(filepath.Join(dir, "diagnostic-2026-09-07.log")); !os.IsNotExist(err) {
t.Fatal("rotation did not expire old file")
}
for _, name := range []string{"other.log", "diagnostic-invalid.log"} {
if _, err := os.Stat(filepath.Join(dir, name)); err != nil {
t.Fatal(err)
}
}
}
func TestDiagnosticWriteFailureKeepsPrimaryOutput(t *testing.T) {
root := t.TempDir()
primary := filepath.Join(root, "primary.log")
log := New("info", primary, DiagnosticOptions{Dir: filepath.Join(primary, "invalid")})
log.Error("still visible")
data, err := os.ReadFile(primary)
if err != nil || !strings.Contains(string(data), "still visible") {
t.Fatalf("primary output lost: %s, %v", data, err)
}
}
+86
View File
@@ -0,0 +1,86 @@
package mcp
import (
"context"
"encoding/json"
"strings"
"testing"
"time"
sdkmcp "github.com/modelcontextprotocol/go-sdk/mcp"
)
func TestBlockedExecutionIsTerminalAndNotFailed(t *testing.T) {
for _, blocked := range []bool{true, false} {
name := "error"
want := ToolExecutionStatusFailed
if blocked {
name, want = "blocked", ToolExecutionStatusBlocked
}
t.Run(name, func(t *testing.T) {
service := NewExecutionService(nil, nil)
handle, err := service.Submit(context.Background(), ExecutionRequest{
ToolName: "test",
Run: func(context.Context) (*ToolResult, error) {
// Identical text must not turn ordinary failures into policy blocks.
return &ToolResult{Content: []Content{{Type: "text", Text: toolGuardBlockedPrefix}}, IsError: true, Blocked: blocked}, nil
},
})
if err != nil {
t.Fatal(err)
}
snap, err := service.Wait(context.Background(), handle.ID, time.Second)
if err != nil || snap.Execution.Status != want || snap.Execution.Result.Blocked != blocked || snap.Execution.Error == "" {
t.Fatalf("incorrect classification: snapshot=%#v err=%v", snap, err)
}
if !isExecutionTerminal(want) || executionStatusCountsAsFailed(want) == blocked {
t.Fatalf("incorrect terminal/failure classification for %s", want)
}
if service.Cancel(handle.ID, "cancel after completion") {
t.Fatal("terminal execution must not be cancellable")
}
after, _ := service.Get(handle.ID)
if after.Execution.Status != want {
t.Fatalf("cancel reclassified terminal execution: %s", after.Execution.Status)
}
})
}
}
func TestBlockedMarkerSurvivesNormalizationAndMCPProtocol(t *testing.T) {
original := &ToolResult{Content: []Content{{Type: "text", Text: strings.Repeat("refused ", 2000)}}, IsError: true, Blocked: true}
bounded := NormalizeToolResultForStorageWithSpill(original, 1000, ToolResultSpillConfig{RootDir: t.TempDir(), ExecutionID: "blocked"})
if !bounded.Blocked || !bounded.IsError || ToolResultPlainText(bounded) == ToolResultPlainText(original) {
t.Fatal("normalization must retain classification while bounding long output")
}
wire, err := json.Marshal(CallToolResponse{Content: bounded.Content, IsError: bounded.IsError, Blocked: bounded.Blocked, Meta: toolResultProtocolMeta(bounded)})
if err != nil {
t.Fatal(err)
}
var decoded ToolResult
if err := json.Unmarshal(wire, &decoded); err != nil || !decoded.Blocked || !decoded.IsError {
t.Fatalf("application protocol lost block marker: %#v err=%v", decoded, err)
}
var sdkResult sdkmcp.CallToolResult
if err := json.Unmarshal(wire, &sdkResult); err != nil {
t.Fatal(err)
}
converted := sdkCallToolResultToOurs(&sdkResult)
if !converted.Blocked || !converted.IsError {
t.Fatalf("SDK round trip lost block marker: %#v", converted)
}
}
func TestToolStatsSeparateBlockedFromFailures(t *testing.T) {
server := NewServer(nil)
manager := NewExternalMCPManager(nil)
for _, status := range []string{ToolExecutionStatusCompleted, ToolExecutionStatusFailed, ToolExecutionStatusBlocked, ToolExecutionStatusCancelled} {
server.updateStats("test", status)
manager.updateStats("test", status)
}
for name, stat := range map[string]*ToolStats{"internal": server.stats["test"], "external": manager.stats["test"]} {
if stat.TotalCalls != 4 || stat.SuccessCalls != 1 || stat.FailedCalls != 1 || stat.BlockedCalls != 1 {
t.Fatalf("%s stats = %#v", name, stat)
}
}
}
+2
View File
@@ -308,9 +308,11 @@ func sdkCallToolResultToOurs(res *mcp.CallToolResult) *ToolResult {
return &ToolResult{Content: []Content{}}
}
content := sdkContentToOurs(res.Content)
blocked, _ := res.Meta[toolGuardBlockedMetaKey].(bool)
return &ToolResult{
Content: content,
IsError: res.IsError,
Blocked: blocked,
}
}
+3
View File
@@ -190,6 +190,9 @@ func formatExecutionForModel(exec *ToolExecution, opts executionFormatOptions) s
if exec.Result != nil {
payload["result"] = ToolResultPlainText(exec.Result)
payload["is_error"] = exec.Result.IsError
if exec.Result.Blocked {
payload["blocked"] = true
}
}
if opts.includePartialOutput && exec.PartialOutput != "" {
partial := tailStringBytes(exec.PartialOutput, opts.partialMaxBytes)
+38 -8
View File
@@ -18,6 +18,7 @@ const (
ToolExecutionStatusQueued = "queued"
ToolExecutionStatusRunning = "running"
ToolExecutionStatusCompleted = "completed"
ToolExecutionStatusBlocked = "blocked"
ToolExecutionStatusFailed = "failed"
ToolExecutionStatusCancelled = "cancelled"
ToolExecutionStatusHardTimeout = "hard_timeout"
@@ -224,6 +225,10 @@ func (s *ExecutionService) markEntryRunning(entry *executionEntry) {
func (s *ExecutionService) finishEntry(ctx context.Context, entry *executionEntry, result *ToolResult, err error, onDone ExecutionDoneFunc) {
id := entry.exec.ID
var blockedErr *toolGuardBlockError
if errors.As(err, &blockedErr) {
result, err = blockedErr.result, nil
}
cancelledWithUserNote := s.applyAbortUserNoteToCancelledToolResult(id, &result, &err)
now := time.Now()
@@ -258,6 +263,10 @@ func (s *ExecutionService) finishEntry(ctx context.Context, entry *executionEntr
entry.exec.Status = ToolExecutionStatusFailed
entry.exec.Error = err.Error()
}
} else if result != nil && result.Blocked {
entry.exec.Status = ToolExecutionStatusBlocked
entry.exec.Error = firstToolResultText(result, "工具调用已被安全规则拦截")
entry.exec.Result = result
} else if result != nil && result.IsError {
if cancelledWithUserNote {
entry.exec.Status = ToolExecutionStatusCancelled
@@ -318,10 +327,11 @@ func (s *ExecutionService) Wait(ctx context.Context, executionID string, timeout
if entry == nil {
return s.getPersistedSnapshot(executionID)
}
if isExecutionTerminal(entry.exec.Status) {
return &ExecutionSnapshot{Execution: cloneToolExecution(entry.exec)}, nil
select {
case <-entry.done:
return s.snapshotEntry(entry), nil
default:
}
var timeoutCh <-chan time.Time
var timer *time.Timer
if timeout > 0 {
@@ -332,18 +342,26 @@ func (s *ExecutionService) Wait(ctx context.Context, executionID string, timeout
select {
case <-entry.done:
return &ExecutionSnapshot{Execution: cloneToolExecution(entry.exec)}, nil
return s.snapshotEntry(entry), nil
case <-timeoutCh:
return &ExecutionSnapshot{Execution: cloneToolExecution(entry.exec)}, ErrExecutionWaitTimeout
return s.snapshotEntry(entry), ErrExecutionWaitTimeout
case <-ctxDone(ctx):
return &ExecutionSnapshot{Execution: cloneToolExecution(entry.exec)}, ctx.Err()
return s.snapshotEntry(entry), ctx.Err()
}
}
// snapshotEntry synchronizes snapshots with worker state and partial output
// updates. Wait uses done to also observe persistence and completion callbacks.
func (s *ExecutionService) snapshotEntry(entry *executionEntry) *ExecutionSnapshot {
s.mu.Lock()
defer s.mu.Unlock()
return &ExecutionSnapshot{Execution: cloneToolExecution(entry.exec)}
}
func (s *ExecutionService) Get(executionID string) (*ExecutionSnapshot, error) {
entry := s.getEntry(executionID)
if entry != nil {
return &ExecutionSnapshot{Execution: cloneToolExecution(entry.exec)}, nil
return s.snapshotEntry(entry), nil
}
return s.getPersistedSnapshot(executionID)
}
@@ -464,6 +482,9 @@ func (s *ExecutionService) applyAbortUserNoteToCancelledToolResult(executionID s
}
hasErr := err != nil && *err != nil
hasRes := result != nil && *result != nil
if hasRes && (*result).Blocked {
return false
}
if !hasErr && !hasRes {
return false
}
@@ -549,7 +570,16 @@ func isBackgroundWaitToolResult(result *ToolResult) bool {
func isExecutionTerminal(status string) bool {
switch strings.TrimSpace(strings.ToLower(status)) {
case ToolExecutionStatusCompleted, ToolExecutionStatusFailed, ToolExecutionStatusCancelled, ToolExecutionStatusHardTimeout, ToolExecutionStatusOrphaned:
case ToolExecutionStatusCompleted, ToolExecutionStatusBlocked, ToolExecutionStatusFailed, ToolExecutionStatusCancelled, ToolExecutionStatusHardTimeout, ToolExecutionStatusOrphaned:
return true
default:
return false
}
}
func executionStatusCountsAsFailed(status string) bool {
switch status {
case ToolExecutionStatusFailed, ToolExecutionStatusHardTimeout, ToolExecutionStatusOrphaned:
return true
default:
return false
+44 -8
View File
@@ -11,6 +11,7 @@ import (
"cyberstrike-ai/internal/authctx"
"cyberstrike-ai/internal/config"
"cyberstrike-ai/internal/toolguard"
"go.uber.org/zap"
)
@@ -74,6 +75,7 @@ type ExternalMCPManager struct {
reconnectLastTry map[string]time.Time
reconnectAttempts map[string]int
toolAuthorizer func(context.Context, string, map[string]interface{}) error
toolGuard *toolguard.Manager
executionService *ExecutionService
toolWaitTimeout time.Duration
toolResultMaxBytes int
@@ -96,6 +98,23 @@ func (m *ExternalMCPManager) SetToolAuthorizer(authorizer func(context.Context,
m.mu.Unlock()
}
// SetToolGuard installs safety rules evaluated before dispatch to external MCPs.
func (m *ExternalMCPManager) SetToolGuard(guard *toolguard.Manager) {
if m == nil {
return
}
m.mu.Lock()
m.toolGuard = guard
m.mu.Unlock()
}
func (m *ExternalMCPManager) checkToolGuard(toolName string, args map[string]interface{}) *ToolResult {
m.mu.RLock()
guard := m.toolGuard
m.mu.RUnlock()
return toolGuardBlockedResult(guard, toolName, args)
}
// NewExternalMCPManagerWithStorage 创建外部MCP管理器(带持久化存储)
func NewExternalMCPManagerWithStorage(logger *zap.Logger, storage MonitorStorage) *ExternalMCPManager {
manager := &ExternalMCPManager{
@@ -685,6 +704,7 @@ func (m *ExternalMCPManager) CallTool(ctx context.Context, toolName string, args
}
var mcpName, actualToolName string
var client ExternalMCPClient
var blockedByGuard bool
handle, err := m.executionService.Submit(ctx, ExecutionRequest{
ToolName: toolName,
Arguments: args,
@@ -702,6 +722,10 @@ func (m *ExternalMCPManager) CallTool(ctx context.Context, toolName string, args
} else if authenticated {
return nil, fmt.Errorf("external tool authorization policy is not configured")
}
if blocked := m.checkToolGuard(toolName, args); blocked != nil {
blockedByGuard = true
return nil, &toolGuardBlockError{result: blocked}
}
// 解析工具名称:name::toolName
if idx := findSubstring(toolName, "::"); idx > 0 {
@@ -741,6 +765,11 @@ func (m *ExternalMCPManager) CallTool(ctx context.Context, toolName string, args
return release, nil
},
Run: func(runCtx context.Context) (*ToolResult, error) {
// Rules may have changed while this execution waited for a slot.
if blocked := m.checkToolGuard(toolName, args); blocked != nil {
blockedByGuard = true
return blocked, nil
}
result, callErr := client.CallTool(runCtx, actualToolName, args)
if callErr != nil {
m.handleConnectionDead(mcpName, client, callErr)
@@ -748,11 +777,13 @@ func (m *ExternalMCPManager) CallTool(ctx context.Context, toolName string, args
return result, callErr
},
OnDone: func(exec *ToolExecution) {
failed := exec != nil && exec.Status != ToolExecutionStatusCompleted && exec.Status != ToolExecutionStatusCancelled
if mcpName != "" {
failed := exec != nil && executionStatusCountsAsFailed(exec.Status)
if mcpName != "" && !blockedByGuard && (exec == nil || exec.Status != ToolExecutionStatusBlocked) {
m.recordExternalMCPResult(mcpName, failed)
}
m.updateStats(toolName, failed)
if exec != nil {
m.updateStats(toolName, exec.Status)
}
},
})
if err != nil {
@@ -941,6 +972,9 @@ func (m *ExternalMCPManager) applyAbortUserNoteToCancelledToolResult(executionID
}
hasErr := err != nil && *err != nil
hasRes := result != nil && *result != nil
if hasRes && (*result).Blocked {
return false
}
if !hasErr && !hasRes {
return false
}
@@ -1098,15 +1132,15 @@ func (m *ExternalMCPManager) ActiveRunningExecutionIDs() map[string]struct{} {
}
// updateStats 更新统计信息
func (m *ExternalMCPManager) updateStats(toolName string, failed bool) {
func (m *ExternalMCPManager) updateStats(toolName string, status string) {
now := time.Now()
if m.storage != nil {
totalCalls := 1
successCalls := 0
failedCalls := 0
if failed {
if executionStatusCountsAsFailed(status) {
failedCalls = 1
} else {
} else if status == ToolExecutionStatusCompleted {
successCalls = 1
}
if err := m.storage.UpdateToolStats(toolName, totalCalls, successCalls, failedCalls, &now); err != nil {
@@ -1128,10 +1162,12 @@ func (m *ExternalMCPManager) updateStats(toolName string, failed bool) {
stats.TotalCalls++
stats.LastCallTime = &now
if failed {
if executionStatusCountsAsFailed(status) {
stats.FailedCalls++
} else {
} else if status == ToolExecutionStatusCompleted {
stats.SuccessCalls++
} else if status == ToolExecutionStatusBlocked {
stats.BlockedCalls++
}
}
@@ -72,7 +72,9 @@ func TestExternalMCPManager_CallToolBoundedWaitThenContinue(t *testing.T) {
manager.ConfigureToolWaitTimeoutSeconds(1)
manager.toolWaitTimeout = 10 * time.Millisecond
client := newBlockingExternalMCPClient("slow result ready")
manager.mu.Lock()
manager.clients["lab"] = client
manager.mu.Unlock()
callCtx, callCancel := context.WithCancel(context.Background())
result, executionID, err := manager.CallTool(callCtx, "lab::slow_tool", map[string]interface{}{"target": "example"})
@@ -117,7 +119,9 @@ func TestExecutionControlWaitToolReturnsCompletedResult(t *testing.T) {
manager := NewExternalMCPManager(zap.NewNop())
manager.toolWaitTimeout = 10 * time.Millisecond
client := newBlockingExternalMCPClient("control wait result")
manager.mu.Lock()
manager.clients["lab"] = client
manager.mu.Unlock()
result, executionID, err := manager.CallTool(context.Background(), "lab::slow_tool", nil)
if err != nil {
@@ -157,7 +161,9 @@ func TestExternalMCPManager_PerServerConcurrencyLimitsWorkers(t *testing.T) {
CircuitCooldown: time.Second,
})
client := newBlockingExternalMCPClient("ok")
manager.mu.Lock()
manager.clients["lab"] = client
manager.mu.Unlock()
done1 := make(chan struct{})
go func() {
@@ -217,7 +223,9 @@ func TestExternalMCPManager_CircuitBreakerOpensAfterFailures(t *testing.T) {
CircuitFailureThreshold: 1,
CircuitCooldown: time.Minute,
})
manager.mu.Lock()
manager.clients["lab"] = &failingExternalMCPClient{}
manager.mu.Unlock()
_, _, err := manager.CallTool(context.Background(), "lab::fail_tool", nil)
if err == nil || !strings.Contains(err.Error(), "boom") {
+54 -16
View File
@@ -16,6 +16,7 @@ import (
"cyberstrike-ai/internal/authctx"
"cyberstrike-ai/internal/mcp/builtin"
"cyberstrike-ai/internal/toolguard"
"github.com/google/uuid"
"go.uber.org/zap"
@@ -53,6 +54,7 @@ type Server struct {
httpToolTimeoutMinutes *int
httpToolTimeoutMu sync.RWMutex
toolAuthorizer func(context.Context, string, map[string]interface{}) error
toolGuard *toolguard.Manager
executionService *ExecutionService
toolWaitTimeout time.Duration
toolResultMaxBytes int
@@ -72,6 +74,23 @@ func (s *Server) SetToolAuthorizer(authorizer func(context.Context, string, map[
s.mu.Unlock()
}
// SetToolGuard installs the runtime safety rules shared by HTTP and internal calls.
func (s *Server) SetToolGuard(guard *toolguard.Manager) {
if s == nil {
return
}
s.mu.Lock()
s.toolGuard = guard
s.mu.Unlock()
}
func (s *Server) checkToolGuard(toolName string, args map[string]interface{}) *ToolResult {
s.mu.RLock()
guard := s.toolGuard
s.mu.RUnlock()
return toolGuardBlockedResult(guard, toolName, args)
}
type sseClient struct {
id string
send chan []byte
@@ -566,7 +585,7 @@ func (s *Server) handleCallTool(requestCtx context.Context, msg *Message) *Messa
s.mu.Unlock()
}
s.updateStats(req.Name, true)
s.updateStats(req.Name, ToolExecutionStatusFailed)
return &Message{
ID: msg.ID,
@@ -590,10 +609,13 @@ func (s *Server) handleCallTool(requestCtx context.Context, msg *Message) *Messa
zap.Any("arguments", req.Arguments),
)
result, err := handler(execCtx, req.Arguments)
result := s.checkToolGuard(req.Name, req.Arguments)
var err error
if result == nil {
result, err = handler(execCtx, req.Arguments)
}
cancelledWithUserNote := s.applyAbortUserNoteToCancelledToolResult(executionID, &result, &err)
now := time.Now()
var failed bool
var finalResult *ToolResult
s.mu.Lock()
@@ -604,13 +626,15 @@ func (s *Server) handleCallTool(requestCtx context.Context, msg *Message) *Messa
st, msg := executionStatusAndMessage(err)
execution.Status = st
execution.Error = msg
failed = st != "cancelled"
} else if result != nil && result.Blocked {
execution.Status = ToolExecutionStatusBlocked
execution.Error = firstToolResultText(result, toolGuardBlockedPrefix)
execution.Result = result
} else if result != nil && result.IsError {
if cancelledWithUserNote {
execution.Status = "cancelled"
execution.Error = ""
execution.Result = result
failed = false
} else {
execution.Status = "failed"
if len(result.Content) > 0 {
@@ -619,7 +643,6 @@ func (s *Server) handleCallTool(requestCtx context.Context, msg *Message) *Messa
execution.Error = "工具执行返回错误结果"
}
execution.Result = result
failed = true
}
} else {
execution.Status = "completed"
@@ -631,7 +654,6 @@ func (s *Server) handleCallTool(requestCtx context.Context, msg *Message) *Messa
}
}
execution.Result = result
failed = false
}
finalResult = execution.Result
@@ -643,7 +665,7 @@ func (s *Server) handleCallTool(requestCtx context.Context, msg *Message) *Messa
}
}
s.updateStats(req.Name, failed)
s.updateStats(req.Name, execution.Status)
if s.storage != nil {
s.mu.Lock()
@@ -683,6 +705,8 @@ func (s *Server) handleCallTool(requestCtx context.Context, msg *Message) *Messa
errorResult, _ := json.Marshal(CallToolResponse{
Content: finalResult.Content,
IsError: true,
Blocked: finalResult.Blocked,
Meta: toolResultProtocolMeta(finalResult),
})
return &Message{
ID: msg.ID,
@@ -719,15 +743,15 @@ func (s *Server) handleCallTool(requestCtx context.Context, msg *Message) *Messa
}
// updateStats 更新统计信息
func (s *Server) updateStats(toolName string, failed bool) {
func (s *Server) updateStats(toolName string, status string) {
now := time.Now()
if s.storage != nil {
totalCalls := 1
successCalls := 0
failedCalls := 0
if failed {
if executionStatusCountsAsFailed(status) {
failedCalls = 1
} else {
} else if status == ToolExecutionStatusCompleted {
successCalls = 1
}
if err := s.storage.UpdateToolStats(toolName, totalCalls, successCalls, failedCalls, &now); err != nil {
@@ -749,10 +773,12 @@ func (s *Server) updateStats(toolName string, failed bool) {
stats.TotalCalls++
stats.LastCallTime = &now
if failed {
if executionStatusCountsAsFailed(status) {
stats.FailedCalls++
} else {
} else if status == ToolExecutionStatusCompleted {
stats.SuccessCalls++
} else if status == ToolExecutionStatusBlocked {
stats.BlockedCalls++
}
}
@@ -925,11 +951,15 @@ func (s *Server) CallTool(ctx context.Context, toolName string, args map[string]
if !exists {
return nil, fmt.Errorf("工具 %s 未找到", toolName)
}
if blocked := s.checkToolGuard(toolName, args); blocked != nil {
return blocked, nil
}
return handler(runCtx, args)
},
OnDone: func(exec *ToolExecution) {
failed := exec != nil && exec.Status != ToolExecutionStatusCompleted && exec.Status != ToolExecutionStatusCancelled
s.updateStats(toolName, failed)
if exec != nil {
s.updateStats(toolName, exec.Status)
}
},
})
if err != nil {
@@ -1111,7 +1141,7 @@ func (s *Server) FinishToolExecution(ctx context.Context, executionID, toolName
}
}
s.updateStats(exec.ToolName, failed)
s.updateStats(exec.ToolName, exec.Status)
if s.storage != nil {
s.mu.Lock()
@@ -1155,6 +1185,11 @@ func (s *Server) UpdateToolExecutionResult(executionID string, result *ToolResul
if executionID == "" || result == nil {
return nil
}
if previous, ok := s.GetExecution(executionID); ok && previous != nil &&
(previous.Status == ToolExecutionStatusBlocked || previous.Result != nil && previous.Result.Blocked) {
result = cloneToolResult(result)
result.Blocked, result.IsError = true, true
}
s.mu.Lock()
spill := ToolResultSpillConfig{
RootDir: s.spillRootDir,
@@ -1270,6 +1305,9 @@ func (s *Server) applyAbortUserNoteToCancelledToolResult(executionID string, res
}
hasErr := err != nil && *err != nil
hasRes := result != nil && *result != nil
if hasRes && (*result).Blocked {
return false
}
if !hasErr && !hasRes {
return false
}
+41
View File
@@ -0,0 +1,41 @@
package mcp
import (
"fmt"
"strings"
"cyberstrike-ai/internal/toolguard"
)
const toolGuardBlockedPrefix = "工具调用已被安全规则拦截"
const toolGuardBlockedMetaKey = "cyberstrike.ai/blocked"
// toolGuardBlockError carries structured policy results through pre-run hooks.
type toolGuardBlockError struct{ result *ToolResult }
func (e *toolGuardBlockError) Error() string { return ToolResultPlainText(e.result) }
func toolResultProtocolMeta(result *ToolResult) map[string]interface{} {
if result != nil && result.Blocked {
return map[string]interface{}{toolGuardBlockedMetaKey: true}
}
return nil
}
// toolGuardBlockedResult uses the standard MCP error result so the refusal is
// visible both to the model and in persisted execution monitoring records.
func toolGuardBlockedResult(guard *toolguard.Manager, toolName string, args map[string]interface{}) *ToolResult {
if guard == nil {
return nil
}
match := guard.Check(toolName, args)
if match == nil {
return nil
}
message := toolGuardBlockedPrefix
if custom := strings.TrimSpace(match.Message); custom != "" {
message += "" + custom
}
message += fmt.Sprintf("\n规则: %s (%s)\n匹配内容: %q", match.RuleName, match.RuleID, match.MatchedText)
return &ToolResult{Content: []Content{{Type: "text", Text: message}}, IsError: true, Blocked: true}
}
+239
View File
@@ -0,0 +1,239 @@
package mcp
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"time"
"cyberstrike-ai/internal/toolguard"
"go.uber.org/zap"
)
func testToolGuard(t *testing.T, enabled bool) *toolguard.Manager {
t.Helper()
guard, err := toolguard.NewManager(toolguard.DefaultConfig())
if err != nil {
t.Fatal(err)
}
if err := guard.Update(toolguard.Config{Enabled: enabled, Rules: []toolguard.Rule{{
ID: "government", Name: "政府网站保护", Enabled: true,
Pattern: `(?i)[a-z0-9.-]+\.gov(?:\.[a-z0-9.-]+)?`,
Message: "识别到 {match},禁止攻击政府网站,请检查目标授权。",
}}}); err != nil {
t.Fatal(err)
}
return guard
}
func assertGuardRefusal(t *testing.T, result *ToolResult, err error) {
t.Helper()
message := ToolResultPlainText(result)
if err != nil {
t.Fatalf("expected structured refusal, got error: %v", err)
} else if result == nil || !result.IsError || !result.Blocked {
t.Fatalf("expected tool error result, got %#v", result)
}
for _, text := range []string{toolGuardBlockedPrefix, "禁止攻击政府网站", "agency.gov.cn", "government"} {
if !strings.Contains(message, text) {
t.Errorf("refusal %q missing %q", message, text)
}
}
}
func TestServerToolGuardBlocksBeforeHandlerAndUpdatesLive(t *testing.T) {
storage := newInMemoryMonitorStorage()
server := NewServerWithStorage(zap.NewNop(), storage)
guard := testToolGuard(t, true)
server.SetToolGuard(guard)
var calls, authorized atomic.Int32
server.SetToolAuthorizer(func(context.Context, string, map[string]interface{}) error {
authorized.Add(1)
return nil
})
server.RegisterTool(Tool{Name: "scan"}, func(context.Context, map[string]interface{}) (*ToolResult, error) {
calls.Add(1)
return &ToolResult{Content: []Content{{Type: "text", Text: "ok"}}}, nil
})
args := map[string]interface{}{"command": "scan https://agency.gov.cn"}
result, executionID, err := server.CallTool(context.Background(), "scan", args)
assertGuardRefusal(t, result, err)
if calls.Load() != 0 || authorized.Load() != 1 {
t.Fatalf("calls=%d authorized=%d, want 0 and 1", calls.Load(), authorized.Load())
}
execution, err := storage.GetToolExecution(executionID)
if err != nil || execution == nil || execution.Status != ToolExecutionStatusBlocked || !strings.Contains(execution.Error, toolGuardBlockedPrefix) {
t.Fatalf("expected persisted blocked execution, got %#v, err=%v", execution, err)
}
result, _, err = server.CallTool(context.Background(), "scan", map[string]interface{}{"target": "example.org"})
if err != nil || result.IsError || calls.Load() != 1 {
t.Fatalf("allowed target did not execute: result=%#v calls=%d err=%v", result, calls.Load(), err)
}
cfg := guard.Config()
cfg.Rules[0].Enabled = false
if err := guard.Update(cfg); err != nil {
t.Fatal(err)
}
result, _, err = server.CallTool(context.Background(), "scan", args)
if err != nil || result.IsError || calls.Load() != 2 {
t.Fatalf("disabled rule did not take effect: result=%#v calls=%d err=%v", result, calls.Load(), err)
}
}
func TestHTTPToolGuardReturnsMCPErrorAndPersistsRefusal(t *testing.T) {
storage := newInMemoryMonitorStorage()
server := NewServerWithStorage(zap.NewNop(), storage)
server.SetToolGuard(testToolGuard(t, true))
var calls int
server.RegisterTool(Tool{Name: "scan"}, func(context.Context, map[string]interface{}) (*ToolResult, error) {
calls++
return &ToolResult{Content: []Content{{Type: "text", Text: "ok"}}}, nil
})
for _, tc := range []struct {
target string
blocked bool
}{
{target: "https://agency.gov.cn", blocked: true},
{target: "https://example.org", blocked: false},
} {
body, err := json.Marshal(map[string]interface{}{
"jsonrpc": "2.0", "id": 1, "method": "tools/call",
"params": map[string]interface{}{"name": "scan", "arguments": map[string]interface{}{"target": tc.target}},
})
if err != nil {
t.Fatal(err)
}
recorder := httptest.NewRecorder()
server.HandleHTTP(recorder, httptest.NewRequest(http.MethodPost, "/api/mcp", strings.NewReader(string(body))))
var response Message
if err := json.Unmarshal(recorder.Body.Bytes(), &response); err != nil {
t.Fatal(err)
}
if recorder.Code != http.StatusOK || response.Error != nil {
t.Fatalf("expected MCP tool result, status=%d body=%s", recorder.Code, recorder.Body)
}
var result ToolResult
if err := json.Unmarshal(response.Result, &result); err != nil {
t.Fatal(err)
}
if tc.blocked {
assertGuardRefusal(t, &result, nil)
if calls != 0 {
t.Fatal("HTTP tool handler ran for a blocked target")
}
executions, err := storage.LoadToolExecutions()
if err != nil || len(executions) != 1 || executions[0].Status != ToolExecutionStatusBlocked || !strings.Contains(executions[0].Error, toolGuardBlockedPrefix) {
t.Fatalf("expected persisted HTTP refusal, got %#v err=%v", executions, err)
}
} else if result.IsError || calls != 1 {
t.Fatalf("allowed HTTP target did not execute: result=%#v calls=%d", result, calls)
}
}
}
func TestExternalToolGuardBlocksBeforeClientAndUpdatesLive(t *testing.T) {
manager := NewExternalMCPManager(zap.NewNop())
t.Cleanup(manager.StopAll)
guard := testToolGuard(t, true)
manager.SetToolGuard(guard)
client := newBlockingExternalMCPClient("ok")
close(client.release)
manager.mu.Lock()
manager.clients["lab"] = client
manager.mu.Unlock()
args := map[string]interface{}{"target": "https://agency.gov.cn"}
result, executionID, err := manager.CallTool(context.Background(), "lab::slow_tool", args)
assertGuardRefusal(t, result, err)
if client.count.Load() != 0 {
t.Fatal("external client ran for a blocked target")
}
execution, ok := manager.GetExecution(executionID)
if !ok || execution.Status != ToolExecutionStatusBlocked || !strings.Contains(execution.Error, toolGuardBlockedPrefix) {
t.Fatalf("expected blocked external execution, got %#v", execution)
}
cfg := guard.Config()
cfg.Enabled = false
if err := guard.Update(cfg); err != nil {
t.Fatal(err)
}
result, _, err = manager.CallTool(context.Background(), "lab::slow_tool", args)
if err != nil || result.IsError || client.count.Load() != 1 {
t.Fatalf("disabled guard did not take effect: result=%#v calls=%d err=%v", result, client.count.Load(), err)
}
}
func TestExternalToolGuardRechecksQueuedCallsWithoutTrippingCircuit(t *testing.T) {
manager := NewExternalMCPManager(zap.NewNop())
t.Cleanup(manager.StopAll)
manager.toolWaitTimeout = 10 * time.Millisecond
manager.ConfigureResilience(ExternalMCPResilienceConfig{
MaxConcurrentPerServer: 1, MaxConcurrentTotal: 4,
CircuitFailureThreshold: 1, CircuitCooldown: time.Minute,
})
guard := testToolGuard(t, false)
manager.SetToolGuard(guard)
client := newBlockingExternalMCPClient("ok")
close(client.release)
manager.mu.Lock()
manager.clients["lab"] = client
manager.mu.Unlock()
// Occupy the provider slot so the call passes its initial policy check and
// remains queued until a live rule update is applied.
release, err := manager.acquireExternalMCPCallSlot(context.Background(), "lab")
if err != nil {
t.Fatal(err)
}
released := false
t.Cleanup(func() {
if !released {
release()
}
})
_, executionID, err := manager.CallTool(context.Background(), "lab::slow_tool", map[string]interface{}{"target": "agency.gov.cn"})
if err != nil || executionID == "" {
t.Fatalf("failed to queue external call: id=%q err=%v", executionID, err)
}
deadline := time.After(time.Second)
ticker := time.NewTicker(time.Millisecond)
defer ticker.Stop()
for len(manager.globalSemaphore) != 2 {
select {
case <-deadline:
t.Fatal("execution did not reach the provider slot queue")
case <-ticker.C:
}
}
cfg := guard.Config()
cfg.Enabled = true
if err := guard.Update(cfg); err != nil {
t.Fatal(err)
}
release()
released = true
snapshot, err := manager.executionService.Wait(context.Background(), executionID, time.Second)
if err != nil || snapshot == nil || snapshot.Execution == nil || snapshot.Execution.Status != ToolExecutionStatusBlocked {
t.Fatalf("expected queued execution to be blocked on policy recheck, got %#v err=%v", snapshot, err)
}
assertGuardRefusal(t, snapshot.Execution.Result, nil)
if client.count.Load() != 0 {
t.Fatal("queued call bypassed the updated guard")
}
manager.mu.RLock()
runtime := manager.serverRuntimes["lab"]
failures, openUntil := runtime.consecutiveFailures, runtime.circuitOpenUntil
manager.mu.RUnlock()
if failures != 0 || !openUntil.IsZero() {
t.Fatalf("local policy refusal affected provider circuit: failures=%d openUntil=%v", failures, openUntil)
}
result, _, err := manager.CallTool(context.Background(), "lab::slow_tool", map[string]interface{}{"target": "example.org"})
if err != nil || result.IsError || client.count.Load() != 1 {
t.Fatalf("allowed call failed after policy refusal: result=%#v calls=%d err=%v", result, client.count.Load(), err)
}
}
+9 -3
View File
@@ -116,6 +116,9 @@ type ToolCall struct {
type ToolResult struct {
Content []Content `json:"content"`
IsError bool `json:"isError,omitempty"`
// Blocked means policy stopped the call before execution. IsError remains
// true for MCP/model handling, while monitoring uses a distinct status.
Blocked bool `json:"blocked,omitempty"`
}
// Content 表示内容
@@ -184,8 +187,10 @@ type CallToolRequest struct {
// CallToolResponse 调用工具响应
type CallToolResponse struct {
Content []Content `json:"content"`
IsError bool `json:"isError,omitempty"`
Content []Content `json:"content"`
IsError bool `json:"isError,omitempty"`
Blocked bool `json:"blocked,omitempty"`
Meta map[string]interface{} `json:"_meta,omitempty"`
}
// ToolExecution 工具执行记录
@@ -193,7 +198,7 @@ type ToolExecution struct {
ID string `json:"id"`
ToolName string `json:"toolName"`
Arguments map[string]interface{} `json:"arguments"`
Status string `json:"status"` // pending, running, completed, failed, cancelled
Status string `json:"status"` // queued, running, completed, blocked, failed, cancelled, hard_timeout, orphaned
Result *ToolResult `json:"result,omitempty"`
Error string `json:"error,omitempty"`
StartTime time.Time `json:"startTime"`
@@ -216,6 +221,7 @@ type ToolStats struct {
TotalCalls int `json:"totalCalls"`
SuccessCalls int `json:"successCalls"`
FailedCalls int `json:"failedCalls"`
BlockedCalls int `json:"blockedCalls"`
LastCallTime *time.Time `json:"lastCallTime,omitempty"`
}
@@ -12,15 +12,24 @@ import (
)
type capturingAgenticChatModel struct {
mu sync.Mutex
inputs [][]*schema.AgenticMessage
output *schema.AgenticMessage
mu sync.Mutex
inputs [][]*schema.AgenticMessage
output *schema.AgenticMessage
outputs []*schema.AgenticMessage
}
func (m *capturingAgenticChatModel) Generate(_ context.Context, input []*schema.AgenticMessage, _ ...model.Option) (*schema.AgenticMessage, error) {
m.mu.Lock()
m.inputs = append(m.inputs, input)
callNo := len(m.inputs)
m.mu.Unlock()
if len(m.outputs) > 0 {
idx := callNo - 1
if idx >= len(m.outputs) {
idx = len(m.outputs) - 1
}
return m.outputs[idx], nil
}
if m.output != nil {
return m.output, nil
}
@@ -10,7 +10,6 @@ import (
"cyberstrike-ai/internal/config"
"cyberstrike-ai/internal/database"
einoopenai "github.com/cloudwego/eino-ext/components/model/openai"
"github.com/cloudwego/eino/adk"
"github.com/cloudwego/eino/adk/middlewares/summarization"
"github.com/cloudwego/eino/components/model"
@@ -109,12 +108,10 @@ func newEinoAgenticSummarizationMiddleware(
retryPolicy := einoTransientRunRetryPolicyFromMW(mwCfg)
retryMax := retryPolicy.maxAttempts
var summaryOverflowRetries int
summaryModelOpts := []model.Option{
einoopenai.WithMaxCompletionTokens(outputReserve),
}
summaryModelOpts := newEinoSummarizationModelOptions(outputReserve, modelName, "agentic", &appCfg.OpenAI, logger)
mw, err := summarization.NewTyped[*schema.AgenticMessage](ctx, &summarization.TypedConfig[*schema.AgenticMessage]{
Model: summaryModel,
Model: newNonEmptyAgenticSummaryModel(summaryModel),
ModelOptions: summaryModelOpts,
GenModelInput: func(ctx context.Context, sysInstruction, userInstruction *schema.AgenticMessage, originalMsgs []*schema.AgenticMessage) ([]*schema.AgenticMessage, error) {
classicOriginal := AgenticMessagesToEino(originalMsgs)
@@ -171,6 +171,59 @@ func TestEinoAgenticChatModelAgentCompactsContextBeforeBusinessModel(t *testing.
}
}
func TestEinoAgenticSummarizationMiddlewareRetriesWhenSummaryModelReturnsEmpty(t *testing.T) {
t.Parallel()
ctx := context.Background()
emit := false
summaryModel := &capturingAgenticChatModel{
outputs: []*schema.AgenticMessage{
agenticAssistantTextMessage(""),
agenticAssistantTextMessage("<summary>有效摘要:继续验证 SQL 注入路径</summary>"),
},
}
appCfg := &config.Config{}
appCfg.OpenAI.Model = "gpt-4o"
appCfg.OpenAI.MaxTotalTokens = 5000
appCfg.Database.Path = filepath.Join(t.TempDir(), "cyberstrike.db")
mwCfg := &config.MultiAgentEinoMiddlewareConfig{
SummarizationEmitInternalEvents: &emit,
SummarizationOutputReserveTokens: 1024,
}
mw, err := newEinoAgenticSummarizationMiddleware(ctx, summaryModel, appCfg, mwCfg, "conv-agentic-empty-summary", nil, "", nil)
if err != nil {
t.Fatalf("newEinoAgenticSummarizationMiddleware: %v", err)
}
state := &adk.TypedChatModelAgentState[*schema.AgenticMessage]{
Messages: []*schema.AgenticMessage{
schema.SystemAgenticMessage("system root"),
schema.UserAgenticMessage("授权范围 example.com\n" + strings.Repeat("历史扫描输出 ", 12000)),
agenticAssistantTextMessage("已记录范围"),
schema.UserAgenticMessage("继续验证 SQL 注入路径"),
},
}
_, after, err := mw.BeforeModelRewriteState(ctx, state, nil)
if err != nil {
t.Fatalf("BeforeModelRewriteState should retry instead of failing on empty summary: %v", err)
}
if after == nil {
t.Fatal("after state is nil")
}
if inputs := summaryModel.snapshotInputs(); len(inputs) < 2 {
t.Fatalf("summary model calls=%d, want retry after empty output", len(inputs))
}
joined := joinClassicMessageContent(AgenticMessagesToEino(after.Messages))
for _, want := range []string{"有效摘要", "继续验证 SQL 注入路径", "原始用户输入与约束账本"} {
if !strings.Contains(joined, want) {
t.Fatalf("retried compacted context missing %q:\n%s", want, joined)
}
}
if strings.Contains(joined, "本地压缩摘要") {
t.Fatalf("local fallback should not be used:\n%s", joined)
}
}
func TestAppendEinoAgenticChatModelTailMiddlewaresIncludesTypedSummarization(t *testing.T) {
t.Parallel()
mw := newAgenticSystemMessageNormalizerMiddleware(nil, "summary")
+111 -5
View File
@@ -115,11 +115,7 @@ func (h *einoRunErrorHandler) emitError(err error, kind string) {
} else if userErr.retryExhausted {
data["hasModelOriginalError"] = false
}
message := err.Error()
if userErr.message != "" {
message = userErr.message
}
h.progress("error", message, data)
h.progress("error", EinoClientRunErrorMessage(err), data)
}
type einoRunUserError struct {
@@ -131,6 +127,7 @@ type einoRunUserError struct {
retryExhausted bool
totalRetries int
hasModelOriginalError bool
summarizationModelErr bool
}
func einoUserFacingRunError(err error) einoRunUserError {
@@ -152,6 +149,10 @@ func einoUserFacingRunError(err error) einoRunUserError {
return out
}
out.rawLastError = strings.TrimSpace(lastErr.Error())
if raw, ok := einoSummarizationModelRawErrorText(lastErr); ok {
out.rawLastError = raw
out.summarizationModelErr = true
}
if isEinoShouldRetryOutputRejected(lastErr) {
out.kind = "model_output_rejected"
out.summary = "模型未返回原始错误;输出被重试策略拒绝。"
@@ -163,6 +164,9 @@ func einoUserFacingRunError(err error) einoRunUserError {
if strings.TrimSpace(summary) == "" {
summary = einoTrimRetryErrorSummary(lastErr.Error())
}
if out.summarizationModelErr {
summary = einoTrimRetryErrorSummary(out.rawLastError)
}
if kind == "" {
kind = "model_retry_exhausted"
}
@@ -190,3 +194,105 @@ func formatEinoRetryExhaustedMessage(summary string, totalRetries int) string {
}
return "模型调用重试已耗尽:" + summary
}
// EinoClientRunErrorMessage returns the error text that should be shown directly
// to clients. When native retry hides the final provider failure behind a retry
// wrapper, prefer the original last model error so summarization/model issues are
// diagnosable from the frontend without opening server logs.
func EinoClientRunErrorMessage(err error) string {
if err == nil {
return ""
}
userErr := einoUserFacingRunError(err)
if userErr.retryExhausted {
if userErr.hasModelOriginalError && userErr.rawLastError != "" {
if userErr.summarizationModelErr {
return formatEinoSummarizationRetryExhaustedRawModelMessage(userErr.rawLastError, userErr.totalRetries)
}
return formatEinoRetryExhaustedRawModelMessage(userErr.rawLastError, userErr.totalRetries)
}
if userErr.message != "" {
return userErr.message
}
}
if raw, ok := einoSummarizationModelRawErrorText(err); ok {
return formatEinoSummarizationRawModelMessage(raw)
}
return err.Error()
}
func formatEinoRetryExhaustedRawModelMessage(raw string, totalRetries int) string {
raw = strings.TrimSpace(raw)
prefix := "模型调用重试已耗尽"
if totalRetries > 0 {
prefix = fmt.Sprintf("模型调用重试已耗尽(已重试 %d 次)", totalRetries)
}
if raw == "" {
return prefix
}
return prefix + ",最后一次模型原始错误:\n" + raw
}
func formatEinoSummarizationRetryExhaustedRawModelMessage(raw string, totalRetries int) string {
raw = strings.TrimSpace(raw)
prefix := "摘要阶段大模型调用失败,模型调用重试已耗尽"
if totalRetries > 0 {
prefix = fmt.Sprintf("摘要阶段大模型调用失败,模型调用重试已耗尽(已重试 %d 次)", totalRetries)
}
if raw == "" {
return prefix
}
return prefix + ",最后一次大模型报错原文:\n" + raw
}
func formatEinoSummarizationRawModelMessage(raw string) string {
raw = strings.TrimSpace(raw)
if raw == "" {
return "摘要阶段大模型调用失败。"
}
return "摘要阶段大模型调用失败,大模型报错原文:\n" + raw
}
// EinoClientRunErrorFields returns structured diagnostic fields that handlers can
// attach to their final error event in addition to the visible message.
func EinoClientRunErrorFields(err error) map[string]interface{} {
fields := make(map[string]interface{})
if err == nil {
return fields
}
userErr := einoUserFacingRunError(err)
if userErr.kind != "" {
fields["errorKind"] = userErr.kind
}
if userErr.summary != "" {
fields["errorSummary"] = userErr.summary
}
if userErr.retryExhausted {
fields["retryExhausted"] = true
if userErr.totalRetries > 0 {
fields["totalRetries"] = userErr.totalRetries
}
}
if userErr.rawLastError != "" {
fields["lastError"] = userErr.rawLastError
}
if userErr.technicalError != "" {
fields["technicalError"] = userErr.technicalError
}
if userErr.hasModelOriginalError {
fields["modelOriginalError"] = userErr.rawLastError
} else if userErr.retryExhausted {
fields["hasModelOriginalError"] = false
}
if userErr.summarizationModelErr {
fields["errorPhase"] = "summarization"
fields["summarizationModelError"] = true
fields["modelOriginalError"] = userErr.rawLastError
} else if raw, ok := einoSummarizationModelRawErrorText(err); ok {
fields["errorPhase"] = "summarization"
fields["summarizationModelError"] = true
fields["modelOriginalError"] = raw
fields["lastError"] = raw
}
return fields
}
@@ -155,6 +155,84 @@ func TestEinoRunErrorHandlerRetryExhaustedOriginalErrorProgress(t *testing.T) {
}
}
func TestEinoClientRunErrorMessageUsesRawRetryExhaustedModelError(t *testing.T) {
raw := "summary content is empty: role=assistant content_runes=0 reasoning_runes=42\nprovider request id: req_123"
err := &adk.RetryExhaustedError{
LastErr: errors.New(raw),
TotalRetries: 4,
}
got := EinoClientRunErrorMessage(err)
if !strings.Contains(got, "模型调用重试已耗尽(已重试 4 次)") {
t.Fatalf("message missing retry prefix: %q", got)
}
if !strings.Contains(got, raw) {
t.Fatalf("message should include raw model error:\n%s", got)
}
fields := EinoClientRunErrorFields(err)
if fields["modelOriginalError"] != raw || fields["lastError"] != raw {
t.Fatalf("raw fields = %#v", fields)
}
}
func TestEinoClientRunErrorMessageMarksSummarizationModelRetryError(t *testing.T) {
raw := `POST "https://api.deepseek.com/v1/chat/completions": 429 Too Many Requests: {"error":{"message":"Rate limit reached"}}`
err := &adk.RetryExhaustedError{
LastErr: newEinoSummarizationModelError(errors.New(raw)),
TotalRetries: 4,
}
got := EinoClientRunErrorMessage(err)
for _, want := range []string{
"摘要阶段大模型调用失败",
"模型调用重试已耗尽(已重试 4 次)",
"最后一次大模型报错原文",
raw,
} {
if !strings.Contains(got, want) {
t.Fatalf("message missing %q:\n%s", want, got)
}
}
if strings.Contains(got, "summarization model error") {
t.Fatalf("message leaked internal wrapper:\n%s", got)
}
fields := EinoClientRunErrorFields(err)
if fields["errorPhase"] != "summarization" || fields["summarizationModelError"] != true {
t.Fatalf("phase fields = %#v", fields)
}
if fields["modelOriginalError"] != raw || fields["lastError"] != raw {
t.Fatalf("raw fields = %#v", fields)
}
}
func TestEinoClientRunErrorMessageMarksDirectSummarizationModelError(t *testing.T) {
raw := `POST "https://api.deepseek.com/v1/chat/completions": 400 Bad Request: {"error":{"message":"invalid thinking parameter"}}`
err := newEinoSummarizationModelError(errors.New(raw))
got := EinoClientRunErrorMessage(err)
for _, want := range []string{
"摘要阶段大模型调用失败",
"大模型报错原文",
raw,
} {
if !strings.Contains(got, want) {
t.Fatalf("message missing %q:\n%s", want, got)
}
}
if strings.Contains(got, "summarization model error") {
t.Fatalf("message leaked internal wrapper:\n%s", got)
}
fields := EinoClientRunErrorFields(err)
if fields["errorPhase"] != "summarization" ||
fields["modelOriginalError"] != raw ||
fields["lastError"] != raw {
t.Fatalf("fields = %#v", fields)
}
}
func TestEinoRunErrorHandlerIterationLimitProgress(t *testing.T) {
var events []string
var errorKind interface{}
+30 -19
View File
@@ -164,27 +164,10 @@ func newEinoSummarizationMiddleware(
retryMax := retryPolicy.maxAttempts
var summaryOverflowRetries int
// ModelOptions apply only to summarization Generate (same ChatModel instance as the agent).
// Strip thinking/reasoning on this call path; mark requests for empty-choices diagnostics.
summaryModelOpts := []model.Option{
einoopenai.WithMaxCompletionTokens(outputReserve),
einoopenai.WithExtraHeader(map[string]string{
copenai.SummarizationRequestHeader: "1",
}),
einoopenai.WithRequestPayloadModifier(func(_ context.Context, in []*schema.Message, rawBody []byte) ([]byte, error) {
if logger != nil {
logger.Info("eino summarization generate request",
zap.Int("input_messages", len(in)),
zap.Int("payload_bytes", len(rawBody)),
zap.String("model", modelName),
)
}
return stripReasoningFromSummarizationPayload(rawBody)
}),
}
summaryModelOpts := newEinoSummarizationModelOptions(outputReserve, modelName, "classic", &appCfg.OpenAI, logger)
mw, err := summarization.New(ctx, &summarization.Config{
Model: summaryModel,
Model: newNonEmptySummaryChatModel(summaryModel),
ModelOptions: summaryModelOpts,
GenModelInput: func(ctx context.Context, sysInstruction, userInstruction adk.Message, originalMsgs []adk.Message) ([]adk.Message, error) {
if transcriptPath != "" && len(originalMsgs) > 0 {
@@ -308,6 +291,34 @@ func newEinoSummarizationMiddleware(
return mw, nil
}
// newEinoSummarizationModelOptions applies only to summarization Generate calls
// on the shared main model. Summary generation should be plain-text and cheap:
// strip provider reasoning/thinking controls so DeepSeek/OpenAI-compatible
// endpoints do not spend the reserved output budget on invisible reasoning.
func newEinoSummarizationModelOptions(outputReserve int, modelName, kind string, oa *config.OpenAIConfig, logger *zap.Logger) []model.Option {
label := "eino summarization generate request"
if strings.TrimSpace(kind) != "" && kind != "classic" {
label = "eino " + kind + " summarization generate request"
}
return []model.Option{
model.WithMaxTokens(outputReserve),
einoopenai.WithMaxCompletionTokens(outputReserve),
einoopenai.WithExtraHeader(map[string]string{
copenai.SummarizationRequestHeader: "1",
}),
einoopenai.WithRequestPayloadModifier(func(_ context.Context, in []*schema.Message, rawBody []byte) ([]byte, error) {
if logger != nil {
logger.Info(label,
zap.Int("input_messages", len(in)),
zap.Int("payload_bytes", len(rawBody)),
zap.String("model", modelName),
)
}
return stripReasoningFromSummarizationPayload(rawBody, oa)
}),
}
}
// summarizationInputBudgetOpts controls spill/truncation behavior when a round alone exceeds budget.
type summarizationInputBudgetOpts struct {
toolMaxBytes int
@@ -0,0 +1,214 @@
package multiagent
import (
"context"
"errors"
"fmt"
"strings"
"github.com/cloudwego/eino/components/model"
"github.com/cloudwego/eino/schema"
)
type einoSummarizationModelError struct {
err error
}
func newEinoSummarizationModelError(err error) error {
if err == nil {
return nil
}
var existing *einoSummarizationModelError
if errors.As(err, &existing) {
return err
}
return &einoSummarizationModelError{err: err}
}
func (e *einoSummarizationModelError) Error() string {
if e == nil || e.err == nil {
return "summarization model error"
}
return "summarization model error: " + e.err.Error()
}
func (e *einoSummarizationModelError) Unwrap() error {
if e == nil {
return nil
}
return e.err
}
func einoSummarizationModelRawErrorText(err error) (string, bool) {
var summaryErr *einoSummarizationModelError
if !errors.As(err, &summaryErr) || summaryErr == nil || summaryErr.err == nil {
return "", false
}
return strings.TrimSpace(summaryErr.err.Error()), true
}
type nonEmptySummaryChatModel struct {
base model.BaseChatModel
}
func newNonEmptySummaryChatModel(base model.BaseChatModel) model.BaseChatModel {
return &nonEmptySummaryChatModel{base: base}
}
func (m *nonEmptySummaryChatModel) Generate(ctx context.Context, input []*schema.Message, opts ...model.Option) (*schema.Message, error) {
out, err := m.base.Generate(ctx, input, opts...)
if err != nil {
return out, newEinoSummarizationModelError(err)
}
if strings.TrimSpace(classicAssistantTextContent(out)) == "" {
return out, newEinoSummarizationModelError(fmt.Errorf("summary content is empty: %s", classicSummaryEmptyDiagnostics(out)))
}
return out, nil
}
func (m *nonEmptySummaryChatModel) Stream(ctx context.Context, input []*schema.Message, opts ...model.Option) (*schema.StreamReader[*schema.Message], error) {
return m.base.Stream(ctx, input, opts...)
}
type nonEmptyAgenticSummaryModel struct {
base model.BaseModel[*schema.AgenticMessage]
}
func newNonEmptyAgenticSummaryModel(base model.BaseModel[*schema.AgenticMessage]) model.BaseModel[*schema.AgenticMessage] {
return &nonEmptyAgenticSummaryModel{base: base}
}
func (m *nonEmptyAgenticSummaryModel) Generate(ctx context.Context, input []*schema.AgenticMessage, opts ...model.Option) (*schema.AgenticMessage, error) {
out, err := m.base.Generate(ctx, input, opts...)
if err != nil {
return out, newEinoSummarizationModelError(err)
}
if strings.TrimSpace(agenticAssistantTextContent(out)) == "" {
return out, newEinoSummarizationModelError(fmt.Errorf("summary content is empty: %s", agenticSummaryEmptyDiagnostics(out)))
}
return out, nil
}
func (m *nonEmptyAgenticSummaryModel) Stream(ctx context.Context, input []*schema.AgenticMessage, opts ...model.Option) (*schema.StreamReader[*schema.AgenticMessage], error) {
return m.base.Stream(ctx, input, opts...)
}
func classicAssistantTextContent(msg *schema.Message) string {
if msg == nil || msg.Role != schema.Assistant {
return ""
}
parts := make([]string, 0, len(msg.AssistantGenMultiContent))
for _, part := range msg.AssistantGenMultiContent {
if part.Type == schema.ChatMessagePartTypeText && part.Text != "" {
parts = append(parts, part.Text)
}
}
if len(parts) > 0 {
return strings.Join(parts, "\n")
}
return msg.Content
}
func agenticAssistantTextContent(msg *schema.AgenticMessage) string {
if msg == nil || msg.Role != schema.AgenticRoleTypeAssistant {
return ""
}
parts := make([]string, 0, len(msg.ContentBlocks))
for _, block := range msg.ContentBlocks {
if block != nil && block.AssistantGenText != nil {
parts = append(parts, block.AssistantGenText.Text)
}
}
return strings.Join(parts, "\n")
}
func classicSummaryEmptyDiagnostics(msg *schema.Message) string {
if msg == nil {
return "model returned nil message"
}
var textParts, reasoningParts, otherParts int
var multiTextRunes, multiReasoningRunes int
for _, part := range msg.AssistantGenMultiContent {
switch part.Type {
case schema.ChatMessagePartTypeText:
textParts++
multiTextRunes += len([]rune(strings.TrimSpace(part.Text)))
case schema.ChatMessagePartTypeReasoning:
reasoningParts++
if part.Reasoning != nil {
multiReasoningRunes += len([]rune(strings.TrimSpace(part.Reasoning.Text)))
}
default:
otherParts++
}
}
reasoningRunes := len([]rune(strings.TrimSpace(msg.ReasoningContent))) + multiReasoningRunes
fields := []string{
fmt.Sprintf("role=%s", msg.Role),
fmt.Sprintf("content_runes=%d", len([]rune(strings.TrimSpace(msg.Content)))+multiTextRunes),
fmt.Sprintf("reasoning_runes=%d", reasoningRunes),
fmt.Sprintf("text_parts=%d", textParts),
fmt.Sprintf("reasoning_parts=%d", reasoningParts),
fmt.Sprintf("other_parts=%d", otherParts),
fmt.Sprintf("tool_calls=%d", len(msg.ToolCalls)),
}
if msg.ResponseMeta != nil {
fields = append(fields, fmt.Sprintf("finish_reason=%q", msg.ResponseMeta.FinishReason))
if usage := msg.ResponseMeta.Usage; usage != nil {
fields = append(fields,
fmt.Sprintf("prompt_tokens=%d", usage.PromptTokens),
fmt.Sprintf("completion_tokens=%d", usage.CompletionTokens),
fmt.Sprintf("total_tokens=%d", usage.TotalTokens),
fmt.Sprintf("reasoning_tokens=%d", usage.CompletionTokensDetails.ReasoningTokens),
)
}
}
if reasoningRunes > 0 {
fields = append(fields, "hint=模型返回了 reasoning_content 但没有返回可作为摘要正文的 content;请检查 DeepSeek thinking 是否已在摘要请求中关闭")
}
return strings.Join(fields, " ")
}
func agenticSummaryEmptyDiagnostics(msg *schema.AgenticMessage) string {
if msg == nil {
return "model returned nil agentic message"
}
var textBlocks, reasoningBlocks, otherBlocks int
var textRunes, reasoningRunes int
for _, block := range msg.ContentBlocks {
if block == nil {
continue
}
switch {
case block.AssistantGenText != nil:
textBlocks++
textRunes += len([]rune(strings.TrimSpace(block.AssistantGenText.Text)))
case block.Reasoning != nil:
reasoningBlocks++
reasoningRunes += len([]rune(strings.TrimSpace(block.Reasoning.Text)))
default:
otherBlocks++
}
}
fields := []string{
fmt.Sprintf("role=%s", msg.Role),
fmt.Sprintf("content_runes=%d", textRunes),
fmt.Sprintf("reasoning_runes=%d", reasoningRunes),
fmt.Sprintf("text_blocks=%d", textBlocks),
fmt.Sprintf("reasoning_blocks=%d", reasoningBlocks),
fmt.Sprintf("other_blocks=%d", otherBlocks),
}
if msg.ResponseMeta != nil && msg.ResponseMeta.TokenUsage != nil {
usage := msg.ResponseMeta.TokenUsage
fields = append(fields,
fmt.Sprintf("prompt_tokens=%d", usage.PromptTokens),
fmt.Sprintf("completion_tokens=%d", usage.CompletionTokens),
fmt.Sprintf("total_tokens=%d", usage.TotalTokens),
fmt.Sprintf("reasoning_tokens=%d", usage.CompletionTokensDetails.ReasoningTokens),
)
}
if reasoningRunes > 0 {
fields = append(fields, "hint=模型返回了 reasoning block 但没有返回可作为摘要正文的 text block;请检查 DeepSeek thinking 是否已在摘要请求中关闭")
}
return strings.Join(fields, " ")
}
@@ -0,0 +1,101 @@
package multiagent
import (
"context"
"strings"
"testing"
"github.com/cloudwego/eino/components/model"
"github.com/cloudwego/eino/schema"
)
type guardClassicSummaryModel struct {
out *schema.Message
}
func (m *guardClassicSummaryModel) Generate(context.Context, []*schema.Message, ...model.Option) (*schema.Message, error) {
return m.out, nil
}
func (m *guardClassicSummaryModel) Stream(context.Context, []*schema.Message, ...model.Option) (*schema.StreamReader[*schema.Message], error) {
return schema.StreamReaderFromArray([]*schema.Message{m.out}), nil
}
func TestNonEmptySummaryChatModelReportsEmptyContentDiagnostics(t *testing.T) {
msg := schema.AssistantMessage("", nil)
msg.ReasoningContent = "只返回了思考,没有最终摘要"
msg.ResponseMeta = &schema.ResponseMeta{
FinishReason: "stop",
Usage: &schema.TokenUsage{
PromptTokens: 10,
CompletionTokens: 3,
TotalTokens: 13,
CompletionTokensDetails: schema.CompletionTokensDetails{
ReasoningTokens: 3,
},
},
}
_, err := newNonEmptySummaryChatModel(&guardClassicSummaryModel{out: msg}).Generate(context.Background(), nil)
if err == nil {
t.Fatal("expected empty summary error")
}
text := err.Error()
for _, want := range []string{
"summary content is empty",
"reasoning_runes=",
`finish_reason="stop"`,
"reasoning_tokens=3",
"DeepSeek thinking",
} {
if !strings.Contains(text, want) {
t.Fatalf("error missing %q:\n%s", want, text)
}
}
}
type guardAgenticSummaryModel struct {
out *schema.AgenticMessage
}
func (m *guardAgenticSummaryModel) Generate(context.Context, []*schema.AgenticMessage, ...model.Option) (*schema.AgenticMessage, error) {
return m.out, nil
}
func (m *guardAgenticSummaryModel) Stream(context.Context, []*schema.AgenticMessage, ...model.Option) (*schema.StreamReader[*schema.AgenticMessage], error) {
return schema.StreamReaderFromArray([]*schema.AgenticMessage{m.out}), nil
}
func TestNonEmptyAgenticSummaryModelReportsEmptyContentDiagnostics(t *testing.T) {
msg := &schema.AgenticMessage{
Role: schema.AgenticRoleTypeAssistant,
ContentBlocks: []*schema.ContentBlock{
schema.NewContentBlock(&schema.Reasoning{Text: "只返回了思考,没有最终摘要"}),
},
ResponseMeta: &schema.AgenticResponseMeta{
TokenUsage: &schema.TokenUsage{
PromptTokens: 10,
CompletionTokens: 3,
TotalTokens: 13,
CompletionTokensDetails: schema.CompletionTokensDetails{
ReasoningTokens: 3,
},
},
},
}
_, err := newNonEmptyAgenticSummaryModel(&guardAgenticSummaryModel{out: msg}).Generate(context.Background(), nil)
if err == nil {
t.Fatal("expected empty summary error")
}
text := err.Error()
for _, want := range []string{
"summary content is empty",
"reasoning_runes=",
"reasoning_blocks=1",
"reasoning_tokens=3",
"DeepSeek thinking",
} {
if !strings.Contains(text, want) {
t.Fatalf("error missing %q:\n%s", want, text)
}
}
}
+25 -1
View File
@@ -1,12 +1,36 @@
package multiagent
import (
"strings"
"cyberstrike-ai/internal/config"
copenai "cyberstrike-ai/internal/openai"
)
// stripReasoningFromSummarizationPayload removes thinking / reasoning fields from a
// chat-completions JSON body. Applied only to summarization Generate calls via
// model.ModelOptions on the shared ChatModel — main-agent requests are unchanged.
func stripReasoningFromSummarizationPayload(rawBody []byte) ([]byte, error) {
func stripReasoningFromSummarizationPayload(rawBody []byte, oa *config.OpenAIConfig) ([]byte, error) {
if shouldDisableDeepSeekThinkingForSummarization(oa) {
return copenai.DisableThinkingForChatCompletionBody(rawBody)
}
return copenai.StripReasoningFromChatCompletionBody(rawBody)
}
func shouldDisableDeepSeekThinkingForSummarization(oa *config.OpenAIConfig) bool {
if oa == nil {
return false
}
if oa.IsDeepSeekEndpointOrModel() {
return true
}
profile := strings.ToLower(strings.TrimSpace(oa.Reasoning.ProfileEffective()))
switch profile {
case "deepseek", "deepseek_compat":
return true
case "", "auto":
return false
default:
return false
}
}
@@ -3,11 +3,15 @@ package multiagent
import (
"strings"
"testing"
"cyberstrike-ai/internal/config"
"github.com/cloudwego/eino/components/model"
)
func TestStripReasoningFromSummarizationPayload(t *testing.T) {
in := []byte(`{"model":"deepseek-chat","messages":[],"thinking":{"type":"enabled"},"reasoning_effort":"high"}`)
out, err := stripReasoningFromSummarizationPayload(in)
out, err := stripReasoningFromSummarizationPayload(in, nil)
if err != nil {
t.Fatal(err)
}
@@ -20,7 +24,7 @@ func TestStripReasoningFromSummarizationPayload(t *testing.T) {
}
plain := []byte(`{"model":"gpt-4o","messages":[]}`)
out2, err := stripReasoningFromSummarizationPayload(plain)
out2, err := stripReasoningFromSummarizationPayload(plain, nil)
if err != nil {
t.Fatal(err)
}
@@ -28,3 +32,75 @@ func TestStripReasoningFromSummarizationPayload(t *testing.T) {
t.Fatalf("expected unchanged payload, got %s", out2)
}
}
func TestStripReasoningFromSummarizationPayloadDisablesDeepSeekThinking(t *testing.T) {
in := []byte(`{"model":"deepseek-v4-flash","messages":[],"thinking":{"type":"enabled"},"reasoning_effort":"high"}`)
oa := &config.OpenAIConfig{
BaseURL: "https://api.deepseek.com/v1",
Model: "deepseek-v4-flash",
}
out, err := stripReasoningFromSummarizationPayload(in, oa)
if err != nil {
t.Fatal(err)
}
s := string(out)
if strings.Contains(s, "reasoning_effort") {
t.Fatalf("expected reasoning_effort stripped, got %s", s)
}
if !strings.Contains(s, `"thinking":{"type":"disabled"}`) {
t.Fatalf("expected DeepSeek thinking disabled, got %s", s)
}
}
func TestStripReasoningFromSummarizationPayloadDisablesDeepSeekEndpointEvenWithOpenAICompatProfile(t *testing.T) {
in := []byte(`{"model":"deepseek-v4-flash","messages":[],"thinking":{"type":"enabled"},"reasoning_effort":"high"}`)
oa := &config.OpenAIConfig{
BaseURL: "https://api.deepseek.com/v1",
Model: "deepseek-v4-flash",
Reasoning: config.OpenAIReasoningConfig{
Profile: "openai_compat",
},
}
out, err := stripReasoningFromSummarizationPayload(in, oa)
if err != nil {
t.Fatal(err)
}
s := string(out)
if strings.Contains(s, "reasoning_effort") {
t.Fatalf("expected reasoning_effort stripped, got %s", s)
}
if !strings.Contains(s, `"thinking":{"type":"disabled"}`) {
t.Fatalf("expected official DeepSeek endpoint thinking disabled, got %s", s)
}
}
func TestStripReasoningFromSummarizationPayloadHonorsOpenAICompatProfileForNonDeepSeekEndpoint(t *testing.T) {
in := []byte(`{"model":"deepseek-v4-flash","messages":[],"thinking":{"type":"enabled"},"reasoning_effort":"high"}`)
oa := &config.OpenAIConfig{
BaseURL: "https://compatible.example.com/v1",
Model: "deepseek-v4-flash",
Reasoning: config.OpenAIReasoningConfig{
Profile: "openai_compat",
},
}
out, err := stripReasoningFromSummarizationPayload(in, oa)
if err != nil {
t.Fatal(err)
}
s := string(out)
if strings.Contains(s, "thinking") || strings.Contains(s, "reasoning_effort") {
t.Fatalf("expected non-DeepSeek OpenAI-compatible endpoint to strip reasoning fields, got %s", s)
}
}
func TestEinoSummarizationModelOptionsSetCommonMaxTokens(t *testing.T) {
const outputReserve = 4096
opts := newEinoSummarizationModelOptions(outputReserve, "minimax-m3", "agentic", nil, nil)
common := model.GetCommonOptions(nil, opts...)
if common == nil || common.MaxTokens == nil {
t.Fatal("expected summarization options to set common max_tokens")
}
if *common.MaxTokens != outputReserve {
t.Fatalf("max_tokens = %d, want %d", *common.MaxTokens, outputReserve)
}
}
@@ -13,6 +13,7 @@ import (
"github.com/cloudwego/eino/adk"
"github.com/cloudwego/eino/adk/middlewares/summarization"
"github.com/cloudwego/eino/components/model"
"github.com/cloudwego/eino/schema"
"go.uber.org/zap"
)
@@ -385,6 +386,84 @@ func TestSummarizeFinalize_MergesSystemMessages(t *testing.T) {
}
}
func TestEinoSummarizationMiddlewareRetriesWhenSummaryModelReturnsEmpty(t *testing.T) {
t.Parallel()
ctx := context.Background()
emit := false
summaryModel := &capturingClassicChatModel{outputs: []*schema.Message{
schema.AssistantMessage("", nil),
schema.AssistantMessage("<summary>有效摘要:继续验证 SQL 注入路径</summary>", nil),
}}
appCfg := &config.Config{}
appCfg.OpenAI.Model = "gpt-4o"
appCfg.OpenAI.MaxTotalTokens = 5000
appCfg.Database.Path = filepath.Join(t.TempDir(), "cyberstrike.db")
mwCfg := &config.MultiAgentEinoMiddlewareConfig{
SummarizationEmitInternalEvents: &emit,
SummarizationOutputReserveTokens: 1024,
}
mw, err := newEinoSummarizationMiddleware(ctx, summaryModel, appCfg, mwCfg, "conv-empty-summary", nil, "", nil)
if err != nil {
t.Fatalf("newEinoSummarizationMiddleware: %v", err)
}
state := &adk.ChatModelAgentState{Messages: []adk.Message{
schema.SystemMessage("system root"),
schema.UserMessage("授权范围 example.com\n" + strings.Repeat("历史扫描输出 ", 12000)),
schema.AssistantMessage("已记录范围", nil),
schema.UserMessage("继续验证 SQL 注入路径"),
}}
_, after, err := mw.BeforeModelRewriteState(ctx, state, nil)
if err != nil {
t.Fatalf("BeforeModelRewriteState should retry instead of failing on empty summary: %v", err)
}
if after == nil {
t.Fatal("after state is nil")
}
if summaryModel.calls < 2 {
t.Fatalf("summary model calls=%d, want retry after empty output", summaryModel.calls)
}
joined := joinClassicMessageContent(after.Messages)
for _, want := range []string{"有效摘要", "继续验证 SQL 注入路径", "原始用户输入与约束账本"} {
if !strings.Contains(joined, want) {
t.Fatalf("retried compacted context missing %q:\n%s", want, joined)
}
}
if strings.Contains(joined, "本地压缩摘要") {
t.Fatalf("local fallback should not be used:\n%s", joined)
}
}
type capturingClassicChatModel struct {
output *schema.Message
outputs []*schema.Message
calls int
}
func (m *capturingClassicChatModel) Generate(_ context.Context, _ []*schema.Message, _ ...model.Option) (*schema.Message, error) {
m.calls++
if len(m.outputs) > 0 {
idx := m.calls - 1
if idx >= len(m.outputs) {
idx = len(m.outputs) - 1
}
return m.outputs[idx], nil
}
if m.output != nil {
return m.output, nil
}
return schema.AssistantMessage("classic answer", nil), nil
}
func (m *capturingClassicChatModel) Stream(ctx context.Context, input []*schema.Message, opts ...model.Option) (*schema.StreamReader[*schema.Message], error) {
msg, err := m.Generate(ctx, input, opts...)
if err != nil {
return nil, err
}
return schema.StreamReaderFromArray([]*schema.Message{msg}), nil
}
// assertNoOrphanTool 断言消息列表里的每个 role=tool 消息都能在更前面找到一个
// assistant(tool_calls) 提供相同 ID,否则说明产生了孤儿(触发 LLM 400 的根因)。
func assertNoOrphanTool(t *testing.T, msgs []adk.Message) {
@@ -0,0 +1,87 @@
package multiagent
import (
"context"
"testing"
"cyberstrike-ai/internal/agent"
"cyberstrike-ai/internal/config"
"cyberstrike-ai/internal/mcp"
"cyberstrike-ai/internal/toolguard"
"go.uber.org/zap"
)
func TestEinoToolResultPreservesBlockedOutcomeAfterTextReduction(t *testing.T) {
for _, blocked := range []bool{true, false} {
name := "execution_error"
if blocked {
name = "safety_block"
}
t.Run(name, func(t *testing.T) {
ctx := context.Background()
logger := zap.NewNop()
server := mcp.NewServer(logger)
guard, err := toolguard.NewManager(toolguard.DefaultConfig())
if err != nil {
t.Fatal(err)
}
server.SetToolGuard(guard)
calls := 0
server.RegisterTool(mcp.Tool{Name: "inspect"}, func(context.Context, map[string]interface{}) (*mcp.ToolResult, error) {
calls++
return &mcp.ToolResult{Content: []mcp.Content{{Type: "text", Text: "execution failed"}}, IsError: true}, nil
})
ag := agent.NewAgent(&config.OpenAIConfig{}, &config.AgentConfig{}, server, nil, logger, 1)
target := "example.org"
if blocked {
target = "example.gov"
}
result, err := ag.ExecuteMCPToolForConversation(ctx, "conv-block", "inspect", map[string]interface{}{"target": target})
if err != nil || result == nil || !result.IsError || result.Blocked != blocked {
t.Fatalf("agent result = %#v, error = %v", result, err)
}
if blocked && calls != 0 || !blocked && calls != 1 {
t.Fatalf("handler calls = %d, blocked = %v", calls, blocked)
}
binder := NewMCPExecutionBinder()
binder.Bind("call-block", result.ExecutionID)
var event map[string]interface{}
emitter := newEinoToolResultProgressEmitter(einoToolResultProgressEmitterConfig{
FilesystemMonitorAgent: ag,
MCPExecutionBinder: binder,
Progress: func(eventType, _ string, data interface{}) {
if eventType == "tool_result" {
event = data.(map[string]interface{})
}
},
})
// The reduced text deliberately contains no refusal wording. A blocked
// outcome must survive even if reduction also loses the error prefix.
const reduced = "The request did not run."
if !emitter.Emit(ctx, "inspect", reduced, "call-block", !blocked, "worker") {
t.Fatal("missing tool result event")
}
if event["success"] != false || event["isError"] != true || event["result"] != reduced {
t.Fatalf("event = %#v", event)
}
if blocked && (event["blocked"] != true || event["status"] != "blocked" || event["executionId"] != result.ExecutionID) {
t.Fatalf("blocked event lost its classification: %#v", event)
}
if !blocked && event["blocked"] != nil {
t.Fatalf("ordinary failure was classified as blocked: %#v", event)
}
exec, ok := server.GetExecution(result.ExecutionID)
if !ok || exec.Result == nil || !exec.Result.IsError || exec.Result.Blocked != blocked {
t.Fatalf("display update lost result flags: %#v", exec)
}
wantStatus := "failed"
if blocked {
wantStatus = "blocked"
}
if ag.MCPExecutionStatus(result.ExecutionID) != wantStatus {
t.Fatalf("execution status = %q, want %q", exec.Status, wantStatus)
}
})
}
}
@@ -8,6 +8,7 @@ import (
"cyberstrike-ai/internal/agent"
"cyberstrike-ai/internal/einomcp"
"cyberstrike-ai/internal/mcp"
"github.com/cloudwego/eino/adk"
)
@@ -133,6 +134,15 @@ func (e *einoToolResultProgressEmitter) Emit(ctx context.Context, toolName, cont
}
if e.filesystemMonitorAgent != nil && e.mcpExecutionBinder != nil {
if execID := e.mcpExecutionBinder.ExecutionID(toolCallID); execID != "" {
// Use the execution record rather than parsing the rendered result:
// reduction can rewrite text without changing the safety decision.
if e.filesystemMonitorAgent.MCPExecutionStatus(execID) == mcp.ToolExecutionStatusBlocked {
data["blocked"] = true
data["status"] = mcp.ToolExecutionStatusBlocked
data["success"] = false
data["isError"] = true
data["executionId"] = execID
}
e.filesystemMonitorAgent.UpdateMCPExecutionDisplayResult(execID, content)
}
}
@@ -52,6 +52,9 @@ func isEinoTransientRunError(err error) bool {
if msg == "" {
return false
}
if isEinoEmptySummaryContentErrorText(msg) {
return true
}
if status := httpStatusFromErrorText(msg); status > 0 {
return isRetryableHTTPStatus(status)
}
@@ -94,6 +97,11 @@ func isEinoTransientRunError(err error) bool {
return false
}
func isEinoEmptySummaryContentErrorText(msg string) bool {
return strings.Contains(msg, "summary content is empty") ||
strings.Contains(msg, "agentic summarization returned empty summary")
}
func isRetryableHTTPStatus(status int) bool {
switch status {
case 408, 409, 425, 429:
@@ -36,6 +36,7 @@ func TestIsEinoTransientRunError(t *testing.T) {
{"http2 goaway", errors.New("failed to receive stream chunk: error, http2: server sent GOAWAY and closed the connection; LastStreamID=791, ErrCode=NO_ERROR"), true},
{"unexpected internal stream chunk", errors.New("failed to receive stream chunk: error, The service encountered an unexpected internal error. Request id: 0217851391106464f01ec66621d0980a42fd45436ed75957a6a0a"), true},
{"unexpected eof", errors.New("unexpected EOF"), true},
{"empty summarization output", errors.New("[NodeRunError] summary content is empty\nnode path: [node_1, ChatModel]"), true},
{"503", errors.New("upstream returned 503"), true},
{"iteration limit", errors.New("max iteration reached"), false},
{"canceled", context.Canceled, false},
+17
View File
@@ -32,6 +32,23 @@ func StripReasoningFromChatCompletionBody(rawBody []byte) ([]byte, error) {
return out, nil
}
// DisableThinkingForChatCompletionBody removes generic reasoning controls and
// explicitly disables DeepSeek-style thinking. Use only for providers where
// omitting the field would leave thinking enabled by default.
func DisableThinkingForChatCompletionBody(rawBody []byte) ([]byte, error) {
var payload map[string]any
if err := sonic.Unmarshal(rawBody, &payload); err != nil {
return rawBody, nil
}
stripReasoningFields(payload)
payload["thinking"] = map[string]any{"type": "disabled"}
out, err := sonic.Marshal(payload)
if err != nil {
return rawBody, err
}
return out, nil
}
// StripReasoningIfForcedToolChoice removes thinking / reasoning fields when the
// request sets tool_choice to "required" or an object. Several providers reject
// that combination (e.g. DashScope: "tool_choice does not support being set to
-3
View File
@@ -78,9 +78,6 @@ var PermissionCatalog = map[string]string{
"attackchain:write": "Regenerate attack chains",
"fofa:execute": "Run FOFA searches and query parsing",
"openapi:read": "Read OpenAPI aggregation results",
"group:read": "View conversation groups",
"group:write": "Create and update conversation groups",
"group:delete": "Delete conversation groups",
"monitor:read": "View execution monitor",
"monitor:write": "Cancel monitor executions",
"monitor:delete": "Delete monitor executions",
+7 -3
View File
@@ -122,8 +122,6 @@ func permissionForRequest(method, fullPath string) string {
return "dashboard:read"
case strings.HasPrefix(path, "/conversations"), strings.HasPrefix(path, "/messages"), strings.HasPrefix(path, "/process-details"):
return crudPermission(method, "chat")
case strings.HasPrefix(path, "/groups"):
return crudPermission(method, "group")
case strings.HasPrefix(path, "/monitor"):
return crudPermission(method, "monitor")
case strings.HasPrefix(path, "/notifications"):
@@ -131,6 +129,10 @@ func permissionForRequest(method, fullPath string) string {
return "notification:read"
}
return "notification:write"
case path == "/tool-guard/test" && method == http.MethodPost:
return "config:read"
case path == "/tool-guard":
return crudPermission(method, "config")
case strings.HasPrefix(path, "/config"):
return crudPermission(method, "config")
case strings.HasPrefix(path, "/terminal"):
@@ -210,6 +212,8 @@ func resourceAllowed(c *gin.Context, db *database.DB) bool {
}
path := strings.TrimPrefix(c.FullPath(), "/api")
switch {
case path == "/tool-guard" && isMutationMethod(c.Request.Method):
return session.Scope == database.RBACScopeAll
case path == "/monitor/stats", path == "/monitor/calls-timeline":
// These APIs currently operate on process-global state. Until every MCP
// invocation and persisted execution record carries an immutable owner,
@@ -217,7 +221,7 @@ func resourceAllowed(c *gin.Context, db *database.DB) bool {
return session.Scope == database.RBACScopeAll
case strings.HasPrefix(path, "/c2/profiles") && c.Request.Method != http.MethodGet:
return session.Scope == database.RBACScopeAll
case (strings.HasPrefix(path, "/hitl/tool-whitelist") || strings.HasPrefix(path, "/hitl/default-reviewer") || strings.HasPrefix(path, "/hitl/audit-strategy")) && c.Request.Method != http.MethodGet:
case (strings.HasPrefix(path, "/hitl/tool-whitelist") || strings.HasPrefix(path, "/hitl/default-config") || strings.HasPrefix(path, "/hitl/default-reviewer") || strings.HasPrefix(path, "/hitl/audit-strategy")) && c.Request.Method != http.MethodGet:
return session.Scope == database.RBACScopeAll
case isMutationMethod(c.Request.Method) && isProcessGlobalMutationPath(path):
// These definitions/configurations are shared by every user and do not
+291
View File
@@ -0,0 +1,291 @@
// Package toolguard applies configurable blocking rules before tool execution.
// It inspects tool names and arguments, including JSON string values and common
// percent escapes. It does not resolve hosts or inspect redirects, files, or
// arbitrary encoded payloads, and is not an exhaustive target authorization check.
package toolguard
import (
"encoding/json"
"fmt"
"regexp"
"sort"
"strings"
"sync/atomic"
)
const (
MaxRules = 100
MaxIDLength = 128
MaxNameLength = 200
MaxPatternLength = 4096
MaxMessageLength = 4096
// The named group keeps surrounding boundary punctuation out of the reminder.
governmentDomainPattern = `(?i)(?:^|[^\p{L}\p{M}\p{N}_.-])(?P<match>(?:(?:[\p{L}\p{M}\p{N}_*-]+\.)+gov(?:\.[\p{L}\p{M}\p{N}_*-]+)*|gov(?:\.[\p{L}\p{M}\p{N}_*-]+)+|\.gov(?:\.[\p{L}\p{M}\p{N}_*-]+)*)\.?)(?:$|[^\p{L}\p{M}\p{N}_.-])`
defaultMessage = "识别到 {match},工具调用已被安全规则「{rule}」拦截,请检查目标与授权范围后再试。"
)
type Config struct {
Enabled bool `json:"enabled" yaml:"enabled"`
Rules []Rule `json:"rules" yaml:"rules"`
}
type Rule struct {
ID string `json:"id" yaml:"id"`
Name string `json:"name" yaml:"name"`
Enabled bool `json:"enabled" yaml:"enabled"`
Pattern string `json:"pattern" yaml:"pattern"`
Message string `json:"message" yaml:"message"`
}
type Match struct {
RuleID string `json:"ruleId"`
RuleName string `json:"ruleName"`
MatchedText string `json:"matchedText"`
Message string `json:"message"`
}
type compiledRule struct {
rule Rule
pattern *regexp.Regexp
matchGroup int
}
// Policy is an immutable snapshot safe for concurrent checks.
type Policy struct {
config Config
rules []compiledRule
}
// DefaultConfig enables a conservative government-domain rule, including .gov,
// .gov.cn and wildcard forms such as *.gov.*. Additional rules can be configured.
func DefaultConfig() Config {
return Config{
Enabled: true,
Rules: []Rule{{
ID: "government-domains",
Name: "政府网站保护",
Enabled: true,
Pattern: governmentDomainPattern,
Message: "识别到 {match},禁止攻击政府网站。请检查目标与授权范围,并更换为已获授权的非政府目标。",
}},
}
}
// Compile validates every rule, including disabled ones. Limits are byte counts.
// Rule order determines precedence. An optional named (?P<match>...) group selects
// the text inserted into {match}; otherwise the entire regex match is used.
func Compile(config Config) (*Policy, error) {
if len(config.Rules) > MaxRules {
return nil, fmt.Errorf("tool guard: at most %d rules are allowed", MaxRules)
}
policy := &Policy{config: cloneConfig(config)}
ids := make(map[string]struct{}, len(config.Rules))
for i, rule := range policy.config.Rules {
prefix := fmt.Sprintf("tool guard rule %d", i+1)
if strings.TrimSpace(rule.ID) == "" || len(rule.ID) > MaxIDLength {
return nil, fmt.Errorf("%s: id must be nonempty and at most %d bytes", prefix, MaxIDLength)
}
if rule.ID != strings.TrimSpace(rule.ID) {
return nil, fmt.Errorf("%s: id must not have surrounding whitespace", prefix)
}
if _, exists := ids[rule.ID]; exists {
return nil, fmt.Errorf("%s: duplicate id %q", prefix, rule.ID)
}
ids[rule.ID] = struct{}{}
if strings.TrimSpace(rule.Name) == "" || len(rule.Name) > MaxNameLength {
return nil, fmt.Errorf("%s: name must be nonempty and at most %d bytes", prefix, MaxNameLength)
}
if strings.TrimSpace(rule.Pattern) == "" || len(rule.Pattern) > MaxPatternLength {
return nil, fmt.Errorf("%s: pattern must be nonempty and at most %d bytes", prefix, MaxPatternLength)
}
if len(rule.Message) > MaxMessageLength {
return nil, fmt.Errorf("%s: message must be at most %d bytes", prefix, MaxMessageLength)
}
pattern, err := regexp.Compile(rule.Pattern)
if err != nil {
return nil, fmt.Errorf("%s (%s): invalid regular expression: %w", prefix, rule.ID, err)
}
if rule.Enabled {
policy.rules = append(policy.rules, compiledRule{rule: rule, pattern: pattern, matchGroup: pattern.SubexpIndex("match")})
}
}
return policy, nil
}
// Check returns the first blocking rule, or nil when the call is allowed. Args
// should be JSON-compatible and must not be mutated while Check is running.
func (p *Policy) Check(toolName string, args map[string]interface{}) *Match {
if p == nil || !p.config.Enabled || len(p.rules) == 0 {
return nil
}
candidates := candidateTexts(toolName, args)
for _, rule := range p.rules {
for _, candidate := range candidates {
indices := rule.pattern.FindStringSubmatchIndex(candidate)
if indices == nil {
continue
}
start, end := indices[0], indices[1]
if group := rule.matchGroup; group > 0 && indices[group*2] >= 0 {
start, end = indices[group*2], indices[group*2+1]
}
matched := candidate[start:end]
message := rule.rule.Message
if strings.TrimSpace(message) == "" {
message = defaultMessage
}
// A single replacement pass prevents matched text from introducing
// additional template substitutions.
message = strings.NewReplacer("{match}", matched, "{tool}", toolName, "{rule}", rule.rule.Name).Replace(message)
return &Match{RuleID: rule.rule.ID, RuleName: rule.rule.Name, MatchedText: matched, Message: message}
}
}
return nil
}
// Manager atomically replaces validated policy snapshots for live settings.
type Manager struct {
policy atomic.Pointer[Policy]
}
func NewManager(config Config) (*Manager, error) {
m := &Manager{}
if err := m.Update(config); err != nil {
return nil, err
}
return m, nil
}
// Update retains the active policy if validation fails.
func (m *Manager) Update(config Config) error {
policy, err := Compile(config)
if err != nil {
return err
}
m.policy.Store(policy)
return nil
}
func (m *Manager) Config() Config {
if m == nil {
return Config{}
}
if policy := m.policy.Load(); policy != nil {
return cloneConfig(policy.config)
}
return Config{}
}
func (m *Manager) Check(toolName string, args map[string]interface{}) *Match {
if m == nil {
return nil
}
return m.policy.Load().Check(toolName, args)
}
func cloneConfig(config Config) Config {
if config.Rules != nil {
rules := make([]Rule, len(config.Rules))
copy(rules, config.Rules)
config.Rules = rules
}
return config
}
func candidateTexts(toolName string, args map[string]interface{}) []string {
var candidates []string
seen := make(map[string]struct{})
add := func(value string) {
// Decode at most three rounds to cover common nested URL escaping
// without claiming support for arbitrarily encoded tool inputs.
for round := 0; round <= 3; round++ {
if _, exists := seen[value]; !exists {
seen[value] = struct{}{}
candidates = append(candidates, value)
}
decoded := decodePercentEscapes(value)
if decoded == value {
break
}
value = decoded
}
}
add(toolName)
var walk func(interface{}, int)
walk = func(value interface{}, remainingDepth int) {
if remainingDepth == 0 {
return
}
switch value := value.(type) {
case string:
add(value)
case map[string]interface{}:
keys := make([]string, 0, len(value))
for key := range value {
keys = append(keys, key)
}
sort.Strings(keys)
for _, key := range keys {
add(key)
walk(value[key], remainingDepth-1)
}
case []interface{}:
for _, item := range value {
walk(item, remainingDepth-1)
}
}
}
// Unmarshaling also normalizes typed slices/maps, json.RawMessage and
// escaped JSON keys/values into the recursive representation above.
if raw, err := json.Marshal(args); err == nil {
var value interface{}
if json.Unmarshal(raw, &value) == nil {
// encoding/json accepts at most 10,000 nesting levels. The decoded
// tree is acyclic, so inspect every accepted string/key at that depth.
walk(value, 10001)
}
add(string(raw))
} else {
// MCP rejects invalid JSON arguments independently; still inspect the
// ordinary values if a caller supplies a non-JSON value alongside them.
walk(args, 128)
}
return candidates
}
// Decode valid percent triplets even if another part of the string has a stray
// percent sign. Whole-string URL unescaping otherwise misses such mixed inputs.
func decodePercentEscapes(value string) string {
if !strings.Contains(value, "%") {
return value
}
var out strings.Builder
out.Grow(len(value))
for i := 0; i < len(value); i++ {
if value[i] == '%' && i+2 < len(value) {
hi, okHi := hexValue(value[i+1])
lo, okLo := hexValue(value[i+2])
if okHi && okLo {
out.WriteByte(hi<<4 | lo)
i += 2
continue
}
}
out.WriteByte(value[i])
}
return out.String()
}
func hexValue(value byte) (byte, bool) {
switch {
case value >= '0' && value <= '9':
return value - '0', true
case value >= 'a' && value <= 'f':
return value - 'a' + 10, true
case value >= 'A' && value <= 'F':
return value - 'A' + 10, true
default:
return 0, false
}
}
+231
View File
@@ -0,0 +1,231 @@
package toolguard
import (
"encoding/json"
"strings"
"sync"
"testing"
)
func TestDefaultGovernmentProtection(t *testing.T) {
policy, err := Compile(DefaultConfig())
if err != nil {
t.Fatal(err)
}
for _, test := range []struct {
input string
match string
}{
{"https://agency.gov/login", "agency.gov"},
{"https://www.agency.gov.cn:443/login", "www.agency.gov.cn"},
{"curl https://EXAMPLE.GOV.UK/a", "EXAMPLE.GOV.UK"},
{"*.gov", "*.gov"},
{"*.gov.*", "*.gov.*"},
{".gov", ".gov"},
{".gov.*", ".gov.*"},
{"https://政务.gov.cn/", "政务.gov.cn"},
{"https://gov.cn/", "gov.cn"},
{"https://agency.gov./", "agency.gov."},
{"https://agency%2egov/a", "agency.gov"},
{"https://agency%252Egov/a", "agency.gov"},
{"echo 100% && curl https://agency%2egov/a", "agency.gov"},
} {
t.Run(test.input, func(t *testing.T) {
match := policy.Check("http_request", map[string]interface{}{"target": test.input})
if match == nil || match.MatchedText != test.match {
t.Fatalf("Check = %+v, want match %q", match, test.match)
}
if !strings.Contains(match.Message, test.match) || !strings.Contains(match.Message, "禁止攻击政府网站") {
t.Fatalf("unexpected reminder: %q", match.Message)
}
})
}
for _, input := range []string{
"https://example.com/", "https://government.example/", "https://agency.govt/",
"https://agency.gov-example.com/", "https://agency.gov_cn/", "governance", "gov",
".government", ".govx",
} {
t.Run("allowed "+input, func(t *testing.T) {
if match := policy.Check("http_request", map[string]interface{}{"target": input}); match != nil {
t.Fatalf("unexpected match: %+v", match)
}
})
}
}
func TestNestedArgumentsAndJSONEscapes(t *testing.T) {
policy, _ := Compile(DefaultConfig())
for _, args := range []map[string]interface{}{
{"targets": []interface{}{map[string]interface{}{"target": "https://agency.gov"}}},
{"targets": []string{"https://agency.gov"}},
{"targets": map[string]string{"target": "https://agency.gov"}},
{"https://agency.gov": true},
{"payload": json.RawMessage(`{"target":"https://agency\u002egov"}`)},
{"payload": json.RawMessage(`{"https://agency\u002egov":true}`)},
{"bad_value": make(chan string), "target": "https://agency.gov"},
} {
if match := policy.Check("request", args); match == nil || match.MatchedText != "agency.gov" {
t.Fatalf("Check(%v) = %+v", args, match)
}
}
}
func TestDeepNestedJSONEscapes(t *testing.T) {
policy, _ := Compile(DefaultConfig())
// Deep nesting must not hide a domain represented with JSON Unicode escapes.
payload := strings.Repeat("[", 200) + `"https://agency\u002egov"` + strings.Repeat("]", 200)
match := policy.Check("request", map[string]interface{}{"payload": json.RawMessage(payload)})
if match == nil || match.MatchedText != "agency.gov" {
t.Fatalf("deep JSON value was not checked: %+v", match)
}
}
func TestRuleOrderingAndInputCoverage(t *testing.T) {
config := Config{Enabled: true, Rules: []Rule{
{ID: "first", Name: "First", Enabled: true, Pattern: "payload-risk", Message: "{rule}/{tool}/{match}"},
{ID: "second", Name: "Second", Enabled: true, Pattern: "tool-risk"},
}}
policy, _ := Compile(config)
match := policy.Check("tool-risk", map[string]interface{}{"value": "payload-risk"})
if match == nil || match.RuleID != "first" || match.Message != "First/tool-risk/payload-risk" {
t.Fatalf("rule order or reminder incorrect: %+v", match)
}
if match = policy.Check("tool-risk", nil); match == nil || match.RuleID != "second" || match.Message == "" {
t.Fatalf("tool name not checked: %+v", match)
}
config.Rules[0].Pattern = `"port":443`
policy, _ = Compile(config)
if match = policy.Check("request", map[string]interface{}{"port": 443}); match == nil || match.MatchedText != `"port":443` {
t.Fatalf("serialized arguments not checked: %+v", match)
}
config.Rules[0].Pattern = `^risk.+$`
policy, _ = Compile(config)
if match = policy.Check("request", map[string]interface{}{"z": "risk-z", "a": "risk-a"}); match == nil || match.MatchedText != "risk-a" {
t.Fatalf("field traversal is not deterministic: %+v", match)
}
}
func TestTemplateReplacementDoesNotExpandMatchedText(t *testing.T) {
config := Config{Enabled: true, Rules: []Rule{{
ID: "template", Name: "Rule", Enabled: true, Pattern: `\{tool\}`, Message: "{match}; {tool}; {rule}",
}}}
policy, _ := Compile(config)
match := policy.Check("request", map[string]interface{}{"value": "{tool}"})
if match == nil || match.Message != "{tool}; request; Rule" {
t.Fatalf("template expansion was recursive: %+v", match)
}
}
func TestPercentDecodingBudgetAppliesToEachInput(t *testing.T) {
config := Config{Enabled: true, Rules: []Rule{{
ID: "domain", Name: "Domain", Enabled: true, Pattern: `^agency\.gov$`,
}}}
policy, _ := Compile(config)
// A deeply encoded value may finish its decoding budget at an intermediate
// string, but must not prevent an independent field from decoding further.
match := policy.Check("request", map[string]interface{}{
"a": "agency%2525252egov", "b": "agency%2egov",
})
if match == nil || match.MatchedText != "agency.gov" {
t.Fatalf("an earlier value suppressed decoding of another field: %+v", match)
}
}
func TestDisabledSettings(t *testing.T) {
config := DefaultConfig()
config.Enabled = false
policy, _ := Compile(config)
args := map[string]interface{}{"target": "agency.gov"}
if policy.Check("request", args) != nil {
t.Fatal("disabled policy blocked the call")
}
config.Enabled = true
config.Rules[0].Enabled = false
policy, _ = Compile(config)
if policy.Check("request", args) != nil {
t.Fatal("disabled rule blocked the call")
}
config.Rules = []Rule{}
policy, _ = Compile(config)
if policy.Check("request", args) != nil {
t.Fatal("empty policy blocked the call")
}
}
func TestCompileValidation(t *testing.T) {
for _, test := range []struct {
name string
change func(*Config)
}{
{"invalid regex", func(c *Config) { c.Rules[0].Pattern = "[" }},
{"disabled invalid regex", func(c *Config) { c.Enabled = false; c.Rules[0].Enabled = false; c.Rules[0].Pattern = "[" }},
{"unsupported lookahead", func(c *Config) { c.Rules[0].Pattern = "x(?=y)" }},
{"empty regex", func(c *Config) { c.Rules[0].Pattern = " " }},
{"oversized regex", func(c *Config) { c.Rules[0].Pattern = strings.Repeat("a", MaxPatternLength+1) }},
{"empty id", func(c *Config) { c.Rules[0].ID = " " }},
{"padded id", func(c *Config) { c.Rules[0].ID = " id" }},
{"oversized id", func(c *Config) { c.Rules[0].ID = strings.Repeat("a", MaxIDLength+1) }},
{"duplicate id", func(c *Config) { c.Rules = append(c.Rules, c.Rules[0]) }},
{"empty name", func(c *Config) { c.Rules[0].Name = " " }},
{"oversized name", func(c *Config) { c.Rules[0].Name = strings.Repeat("a", MaxNameLength+1) }},
{"oversized message", func(c *Config) { c.Rules[0].Message = strings.Repeat("a", MaxMessageLength+1) }},
{"too many rules", func(c *Config) { c.Rules = make([]Rule, MaxRules+1) }},
} {
t.Run(test.name, func(t *testing.T) {
config := DefaultConfig()
test.change(&config)
if _, err := Compile(config); err == nil {
t.Fatal("expected validation error")
}
})
}
}
func TestManagerUsesImmutableValidatedSnapshots(t *testing.T) {
config := DefaultConfig()
manager, err := NewManager(config)
if err != nil {
t.Fatal(err)
}
args := map[string]interface{}{"target": "agency.gov"}
config.Rules[0].Pattern = "safe"
snapshot := manager.Config()
snapshot.Rules[0].Enabled = false
if manager.Check("request", args) == nil {
t.Fatal("external config mutation changed the active policy")
}
invalid := DefaultConfig()
invalid.Rules[0].Pattern = "["
if err := manager.Update(invalid); err == nil || manager.Check("request", args) == nil {
t.Fatal("invalid update did not preserve protection")
}
disabled := DefaultConfig()
disabled.Enabled = false
if err := manager.Update(disabled); err != nil || manager.Check("request", args) != nil {
t.Fatal("valid update did not take effect")
}
}
func TestManagerConcurrentUpdatesAndChecks(t *testing.T) {
manager, _ := NewManager(DefaultConfig())
var workers sync.WaitGroup
for worker := 0; worker < 4; worker++ {
workers.Add(1)
go func() {
defer workers.Done()
for i := 0; i < 100; i++ {
if match := manager.Check("request", map[string]interface{}{"target": "agency.gov"}); match == nil {
t.Error("an update created an unprotected interval")
return
}
config := manager.Config()
config.Rules[0].Message = "Block {match}"
if err := manager.Update(config); err != nil {
t.Error(err)
return
}
}
}()
}
workers.Wait()
}
+50 -888
View File
File diff suppressed because it is too large Load Diff
+279
View File
@@ -0,0 +1,279 @@
.tool-guard-content {
display: flex;
flex-direction: column;
gap: 16px;
max-width: 1200px;
width: 100%;
margin: 0 auto;
}
.tool-guard-page [hidden] { display: none !important; }
.tool-guard-card {
background: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: 12px;
padding: 20px;
min-width: 0;
}
.tool-guard-policy,
.tool-guard-policy-info,
.tool-guard-section-header,
.tool-guard-heading-line,
.tool-guard-rule-header,
.tool-guard-rule-actions,
.tool-guard-editor-footer {
display: flex;
align-items: center;
gap: 12px;
}
.tool-guard-policy,
.tool-guard-section-header,
.tool-guard-editor-footer { justify-content: space-between; }
.tool-guard-policy { padding: 18px 20px; }
.tool-guard-policy-info { min-width: 0; }
.tool-guard-policy-copy h3,
.tool-guard-section-header h3 { font-size: 15px; margin: 0; }
.tool-guard-policy-copy p,
.tool-guard-section-header p,
.tool-guard-help-body,
.tool-guard-test-hint,
.tool-guard-empty {
color: var(--text-secondary);
font-size: 12px;
line-height: 1.7;
}
.tool-guard-policy-copy p { margin: 3px 0 0; }
.tool-guard-policy-icon,
.tool-guard-test-icon {
display: grid;
place-items: center;
width: 38px;
height: 38px;
border-radius: 10px;
flex-shrink: 0;
color: var(--accent-color);
background: color-mix(in srgb, var(--accent-color) 8%, transparent);
}
.tool-guard-policy-icon svg,
.tool-guard-test-icon svg { width: 21px; height: 21px; }
.tool-guard-policy:has(#tool-guard-enabled:not(:checked)) .tool-guard-policy-icon { color: var(--text-secondary); background: var(--bg-secondary); }
.tool-guard-section-header { margin-bottom: 16px; }
.tool-guard-section-header p { margin: 4px 0 0; }
.tool-guard-heading-line { gap: 10px; flex-wrap: wrap; }
.tool-guard-rule-count { font-size: 12px; color: var(--text-secondary); font-weight: 400; }
.tool-guard-section-header > button { flex-shrink: 0; }
.tool-guard-add { display: inline-flex; align-items: center; gap: 5px; }
.tool-guard-add > span:first-child { font-size: 19px; line-height: 1; }
.tool-guard-toggle { display: inline-flex; align-items: center; gap: 8px; font-size: 12px; cursor: pointer; flex-shrink: 0; white-space: nowrap; }
.tool-guard-page .tool-guard-switch input[type="checkbox"] {
appearance: none;
-webkit-appearance: none;
width: 30px;
height: 18px;
min-width: 30px;
border: 1px solid var(--border-color);
border-radius: 12px;
background: var(--text-secondary);
position: relative;
cursor: pointer;
margin: 0;
transition: background .15s;
}
.tool-guard-page .tool-guard-switch input[type="checkbox"]::after {
content: '';
position: absolute;
width: 12px;
height: 12px;
top: 2px;
left: 2px;
border-radius: 50%;
background: #fff;
transition: transform .15s;
}
.tool-guard-page .tool-guard-switch input[type="checkbox"]:checked { background: var(--accent-color); border-color: var(--accent-color); }
.tool-guard-page .tool-guard-switch input[type="checkbox"]:checked::after { transform: translateX(12px); }
.tool-guard-page .tool-guard-switch input[type="checkbox"]::before { display: none; }
.tool-guard-rules { display: grid; gap: 8px; }
.tool-guard-rule { border-radius: 9px; border: 1px solid var(--border-color); min-width: 0; overflow: hidden; }
.tool-guard-rule:has(.tool-guard-rule-summary[aria-expanded="true"]) { border-color: color-mix(in srgb, var(--accent-color) 40%, var(--border-color)); }
.tool-guard-rule-header { gap: 8px; padding-right: 16px; min-width: 0; border-radius: 8px; transition: background-color .15s; }
.tool-guard-rule-header:hover,
.tool-guard-rule-header:has(> .tool-guard-rule-summary:focus-visible) { background: color-mix(in srgb, var(--accent-color) 5%, var(--card-bg)); }
.tool-guard-rule-header:has(> .tool-guard-rule-summary:focus-visible) { box-shadow: inset 0 0 0 2px var(--accent-color); }
.tool-guard-rule.is-expanded .tool-guard-rule-header { border-radius: 8px 8px 0 0; }
.tool-guard-rule-summary {
display: flex;
align-items: center;
gap: 12px;
min-width: 0;
flex: 1;
width: 100%;
border: 0;
background: transparent;
color: var(--text-primary);
padding: 14px 10px 14px 16px;
text-align: left;
cursor: pointer;
font: inherit;
}
.tool-guard-rule-number { flex-shrink: 0; color: var(--text-secondary); font-size: 12px; font-variant-numeric: tabular-nums; }
.tool-guard-rule-overview { min-width: 0; flex: 1; display: flex; flex-direction: column; gap: 4px; }
.tool-guard-rule-name { color: var(--text-primary); font-weight: 600; font-size: 13px; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
.tool-guard-rule-preview { color: var(--text-secondary); font-size: 12px; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
.tool-guard-rule-badge { flex-shrink: 0; color: var(--accent-color); background: color-mix(in srgb, var(--accent-color) 8%, transparent); border-radius: 4px; padding: 2px 6px; font-size: 11px; font-weight: 500; white-space: nowrap; }
.tool-guard-chevron { flex-shrink: 0; color: var(--text-secondary); font-size: 23px; font-weight: 400; line-height: 1; transition: transform .15s; }
.tool-guard-rule-summary[aria-expanded="true"] .tool-guard-chevron,
.tool-guard-help[open] > summary .tool-guard-chevron,
.tool-guard-test-panel[open] > summary .tool-guard-chevron { transform: rotate(90deg); }
.tool-guard-rule-editor { padding: 18px; border-top: 1px solid var(--border-color); background: var(--bg-secondary); }
.tool-guard-editor-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 14px 18px; }
.tool-guard-editor-grid > .tool-guard-field--name { grid-column: 1 / -1; max-width: 480px; width: 100%; }
.tool-guard-editor-grid .tool-guard-field { margin: 0; }
.tool-guard-editor-grid textarea { flex: 1; min-height: 108px; }
.tool-guard-editor-footer { margin-top: 16px; }
.tool-guard-delete { color: var(--error-color); }
.tool-guard-field { display: flex; flex-direction: column; gap: 6px; margin-bottom: 14px; min-width: 0; }
.tool-guard-field:last-child { margin-bottom: 0; }
.tool-guard-field label { font-size: 12px; font-weight: 500; }
.tool-guard-field-hint { color: var(--text-secondary); font-size: 11px; line-height: 1.6; }
.tool-guard-field input,
.tool-guard-field textarea {
width: 100%;
padding: 9px 11px;
border: 1px solid var(--border-color);
border-radius: 6px;
background: var(--input-bg);
color: var(--text-primary);
font: inherit;
font-size: 13px;
line-height: 1.6;
}
.tool-guard-field textarea { resize: vertical; min-height: 66px; }
.tool-guard-field [aria-invalid="true"] { border-color: var(--error-color); }
.tool-guard-page :is(button, input, textarea, summary):focus-visible { outline: 2px solid var(--accent-color); outline-offset: 2px; }
.tool-guard-page .tool-guard-rule-summary:focus-visible { outline: none; }
.tool-guard-field .tool-guard-pattern,
#tool-guard-test-arguments,
.tool-guard-test-arguments,
.tool-guard-result-value { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
.tool-guard-page :is(button, input, textarea):disabled { opacity: .55; cursor: not-allowed; }
.tool-guard-save-state { font-size: 12px; color: var(--text-secondary); }
.tool-guard-save-state.is-dirty { color: var(--accent-color); }
.tool-guard-feedback,
.tool-guard-test-result { padding: 14px 16px; border-radius: 8px; background: var(--bg-secondary); border: 1px solid var(--border-color); font-size: 13px; overflow-wrap: anywhere; }
.tool-guard-feedback.is-error,
.tool-guard-test-result.is-blocked { border-color: var(--error-color); }
.tool-guard-feedback.is-error,
.tool-guard-test-result.is-blocked > strong { color: var(--error-color); }
.tool-guard-result-label { margin-top: 12px; margin-bottom: 4px; color: var(--text-secondary); font-size: 12px; }
.tool-guard-result-value { white-space: pre-wrap; overflow-wrap: anywhere; max-height: 200px; overflow-y: auto; font-size: 13px; }
.tool-guard-help { border-top: 1px solid var(--border-color); margin-top: 16px; padding-top: 12px; }
.tool-guard-help > summary,
.tool-guard-test-panel > summary { display: flex; align-items: center; gap: 10px; cursor: pointer; list-style: none; }
.tool-guard-help > summary::-webkit-details-marker,
.tool-guard-test-panel > summary::-webkit-details-marker { display: none; }
.tool-guard-help > summary { color: var(--text-secondary); width: fit-content; font-size: 12px; }
.tool-guard-help > summary .tool-guard-chevron { font-size: 18px; }
.tool-guard-help-body { margin-top: 10px; }
.tool-guard-help-body p + p { margin-top: 6px; }
.tool-guard-test-panel { padding: 0; scroll-margin-top: 80px; }
.tool-guard-test-panel > summary { padding: 18px 20px; border-radius: 12px; }
.tool-guard-test-panel > summary:hover { background: var(--bg-secondary); }
.tool-guard-disclosure-copy { min-width: 0; flex: 1; display: flex; flex-direction: column; gap: 4px; }
.tool-guard-disclosure-copy strong { font-size: 14px; font-weight: 600; }
.tool-guard-disclosure-copy > span { color: var(--text-secondary); font-size: 12px; }
.tool-guard-test-icon { color: var(--text-secondary); background: var(--bg-secondary); }
.tool-guard-test-body { padding: 18px 20px 20px; border-top: 1px solid var(--border-color); }
.tool-guard-test-hint { margin-bottom: 14px; }
.tool-guard-test-actions { display: flex; justify-content: flex-end; margin-bottom: 14px; }
.tool-guard-editor-actions { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.tool-guard-rule-validate { color: var(--accent-color); }
.tool-guard-test-result.is-single.is-blocked { border-color: var(--accent-color); }
.tool-guard-test-result.is-single.is-blocked > strong { color: var(--accent-color); }
.tool-guard-single-result-hint { color: var(--text-secondary); font-size: 12px; line-height: 1.6; margin-top: 8px; }
.tool-guard-hint { margin: 0; color: var(--text-secondary); font-size: 12px; line-height: 1.7; }
.tool-guard-inline-test { margin-top: 18px; padding-top: 18px; border-top: 1px solid var(--border-color); scroll-margin-top: 90px; }
.tool-guard-local-test { padding: 18px; background: var(--card-bg); border: 1px solid var(--border-color); border-radius: 8px; min-width: 0; }
.tool-guard-local-test h4 { margin: 0; font-size: 14px; font-weight: 600; }
.tool-guard-local-test > .tool-guard-hint { margin: 5px 0 16px; }
.tool-guard-local-output { margin-top: 14px; min-width: 0; }
.tool-guard-test-placeholder { padding: 16px; color: var(--text-secondary); font-size: 12px; line-height: 1.7; }
.tool-guard-test-run { min-width: 120px; }
.tool-guard-local-test .tool-guard-test-arguments { min-height: 108px; }
.tool-guard-add-dialog {
width: min(1040px, calc(100vw - 40px));
max-width: none;
height: min(90vh, 900px);
height: min(90dvh, 900px);
max-height: min(90vh, 900px);
max-height: min(90dvh, 900px);
margin: auto;
padding: 0;
overflow: hidden;
border: 1px solid var(--border-color);
border-radius: 14px;
background: var(--card-bg);
color: var(--text-primary);
box-shadow: 0 24px 80px rgba(0, 0, 0, .25);
}
.tool-guard-add-dialog::backdrop { background: rgba(5, 10, 20, .58); }
.tool-guard-add-dialog > form { display: flex; flex-direction: column; height: 100%; min-width: 0; }
.tool-guard-add-header,
.tool-guard-add-footer { display: flex; justify-content: space-between; align-items: center; gap: 20px; padding: 20px 24px; flex-shrink: 0; }
.tool-guard-add-header { border-bottom: 1px solid var(--border-color); align-items: flex-start; }
.tool-guard-add-header h3 { margin: 0 0 5px; font-size: 18px; line-height: 1.5; }
.tool-guard-dialog-close { display: grid; place-items: center; flex-shrink: 0; width: 32px; height: 32px; border: 0; border-radius: 6px; background: transparent; color: var(--text-secondary); cursor: pointer; }
.tool-guard-dialog-close:hover { background: var(--bg-secondary); color: var(--text-primary); }
.tool-guard-dialog-close svg { width: 20px; height: 20px; }
.tool-guard-add-body { display: grid; flex: 1; grid-template-columns: minmax(0, 1.1fr) minmax(0, 1fr); align-items: start; align-content: start; gap: 20px 24px; padding: 24px; overflow-y: auto; overscroll-behavior: contain; scrollbar-gutter: stable; min-height: 0; }
.tool-guard-add-fields { min-width: 0; }
.tool-guard-add-fields .tool-guard-field { margin-bottom: 20px; }
.tool-guard-add-fields .tool-guard-field:last-child { margin-bottom: 0; }
.tool-guard-add-fields textarea { min-height: 116px; }
.tool-guard-add-test { padding: 18px; border: 1px solid var(--border-color); border-radius: 10px; background: var(--bg-secondary); min-width: 0; }
.tool-guard-add-test .tool-guard-local-test { padding: 0; border: 0; background: transparent; }
.tool-guard-add-test .tool-guard-local-output { min-height: 180px; border: 1px solid var(--border-color); border-radius: 8px; background: var(--card-bg); }
.tool-guard-add-test .tool-guard-local-output:has(.is-single.is-blocked:not([hidden])) { border-color: var(--accent-color); }
.tool-guard-add-test .tool-guard-local-output:has(.is-error:not([hidden])) { border-color: var(--error-color); }
.tool-guard-add-test .tool-guard-result-value { max-height: none; overflow: visible; }
.tool-guard-add-test .tool-guard-test-result,
.tool-guard-add-test .tool-guard-feedback { border: 0; background: transparent; }
#tool-guard-add-feedback { grid-column: 1 / -1; }
.tool-guard-add-footer { border-top: 1px solid var(--border-color); }
.tool-guard-add-footer > .tool-guard-hint { max-width: 540px; }
.tool-guard-dialog-actions { display: flex; align-items: center; gap: 10px; flex-shrink: 0; }
@media (max-width: 760px) {
.tool-guard-card { padding: 16px; }
.tool-guard-policy { flex-wrap: wrap; }
.tool-guard-section-header { align-items: flex-start; flex-wrap: wrap; }
.tool-guard-page .page-header { flex-wrap: wrap; gap: 12px; }
.tool-guard-rule-header { padding-right: 10px; gap: 4px; }
.tool-guard-rule-summary { padding: 12px 8px 12px 10px; gap: 8px; }
.tool-guard-rule-number { display: none; }
.tool-guard-rule-actions { gap: 6px; }
.tool-guard-rule-actions .tool-guard-toggle > span { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
.tool-guard-editor-grid { grid-template-columns: minmax(0, 1fr); }
.tool-guard-editor-footer { flex-wrap: wrap; }
.tool-guard-editor-grid > .tool-guard-field--name { max-width: none; }
.tool-guard-rule-editor { padding: 14px; }
.tool-guard-rule-badge { padding: 2px 4px; font-size: 10px; }
.tool-guard-test-panel { padding: 0; }
.tool-guard-test-panel > summary,
.tool-guard-test-body { padding: 16px; }
.tool-guard-add-dialog { width: calc(100vw - 24px); height: calc(100vh - 24px); height: calc(100dvh - 24px); max-height: calc(100vh - 24px); max-height: calc(100dvh - 24px); border-radius: 12px; }
.tool-guard-add-header,
.tool-guard-add-footer { padding: 16px; gap: 12px; }
.tool-guard-add-header h3 { font-size: 16px; }
.tool-guard-add-body { grid-template-columns: minmax(0, 1fr); padding: 16px; gap: 20px; }
.tool-guard-add-test,
.tool-guard-local-test { padding: 14px; }
.tool-guard-add-footer { flex-wrap: wrap; }
.tool-guard-dialog-actions { width: 100%; justify-content: flex-end; }
}
@media (prefers-reduced-motion: reduce) {
.tool-guard-rule-header,
.tool-guard-chevron,
.tool-guard-page .tool-guard-switch input[type="checkbox"],
.tool-guard-page .tool-guard-switch input[type="checkbox"]::after { transition: none; }
}
+153 -114
View File
@@ -10,6 +10,7 @@
"close": "Close",
"edit": "Edit",
"delete": "Delete",
"remove": "Remove",
"save": "Save",
"loading": "Loading…",
"search": "Search",
@@ -76,6 +77,8 @@
"submit": "Sign in"
},
"nav": {
"security": "Security",
"toolGuard": "Call blocking",
"dashboard": "Dashboard",
"chat": "Chat",
"assets": "Asset Management",
@@ -543,6 +546,7 @@
"generatedFromFact": "Generated from project fact {{factKey}}"
},
"chat": {
"toolExecBlocked": "Tool {{name}} blocked",
"newChat": "New chat",
"newTask": "New task",
"toggleConversationPanel": "Collapse/expand conversation list",
@@ -554,10 +558,10 @@
"projectPreviewNoDescription": "No project description",
"projectPreviewScope": "Test scope: {{scope}}",
"projectPreviewEdit": "Edit project",
"conversationPreviewJustNow": "Now",
"conversationPreviewJustNow": "Just now",
"conversationPreviewMinutes": "{{count}} min",
"conversationPreviewHours": "{{count}}h",
"conversationPreviewDays": "{{count}}d",
"conversationPreviewHours": "{{count}} hr",
"conversationPreviewDays": "{{count}} days",
"conversationPreviewDateTime": "{{year}}-{{month}}-{{day}} {{hour}}:{{minute}}",
"conversationPreviewNoProject": "No project",
"conversationPreviewDefaultMode": "Default",
@@ -579,8 +583,6 @@
"renameConversationSubtitle": "The name will update in project folders and recent conversations",
"conversationTitleLabel": "Conversation name",
"conversationTitlePlaceholder": "Enter a conversation name",
"conversationGroups": "Conversation groups",
"addGroup": "New group",
"recentConversations": "Recent conversations",
"toggleRecentConversations": "Expand/collapse recent conversations",
"filterByProject": "Filter by project",
@@ -622,7 +624,6 @@
"attachmentUploadFailed": "Failed",
"attachmentUploadAlert": "Upload failed: {{name}}",
"send": "Send",
"searchInGroup": "Search in group...",
"loadingTools": "Loading tools...",
"noMatchTools": "No matching tools",
"penetrationTestDetail": "Task execution details",
@@ -656,11 +657,7 @@
"deleteTurnTitle": "Delete this turn",
"deleteTurnConfirm": "Delete this entire turn (user message and assistant reply)? This cannot be undone. The next reply will use only the remaining messages; saved context snapshots will be cleared.",
"deleteTurnFailed": "Failed to delete turn",
"emptyGroupConversations": "This group has no conversations yet.",
"noMatchingConversationsInGroup": "No matching conversations found.",
"noHistoryConversations": "No conversation history yet",
"renameGroupPrompt": "Please enter new name:",
"deleteGroupConfirm": "Are you sure you want to delete this group? Conversations in the group will not be deleted, but will be removed from the group.",
"deleteConversationConfirm": "Delete this conversation? Chat messages cannot be recovered, but recorded vulnerabilities will remain in the vulnerability library.",
"renameFailed": "Rename failed",
"downloadConversationFailed": "Failed to download conversation",
@@ -675,7 +672,6 @@
"projectWelcomeTitleSuffix": "?",
"noProjectWelcomeTitle": "What should be tested?",
"welcomeSubtitle": "Enter your test requirements and the system will automatically run the corresponding security tests.",
"addNewGroup": "+ New group",
"callNumber": "Call #{{n}}",
"iterationRound": "Iteration {{n}}",
"einoOrchestratorRound": "Orchestrator · round {{n}}",
@@ -747,10 +743,6 @@
"historyGroupToday": "Today",
"historyGroupLast7Days": "Past 7 days",
"historyGroupEarlier": "Older",
"conversationPreviewJustNow": "Just now",
"conversationPreviewMinutes": "{{count}} min",
"conversationPreviewHours": "{{count}} hr",
"conversationPreviewDays": "{{count}} days",
"agentModeSelectAria": "Choose conversation execution mode",
"agentModePanelTitle": "Conversation mode",
"agentModeEinoSingle": "Eino single (ADK)",
@@ -819,6 +811,7 @@
"hitlWhitelistHint": "Separate with commas or new lines; shown merged with the global allowlist in config.",
"hitlApply": "Apply",
"hitlApplyOkSync": "HITL settings saved and synced to the server.",
"hitlApplyOkDefaultConfig": "Default HITL settings saved to config.yaml and activated.",
"hitlApplyOkWhitelistYaml": "Tool whitelist merged into config.yaml and active. Session settings are saved automatically.",
"hitlApplyOkLocal": "Saved in this browser.",
"hitlApplyFail": "Failed to sync to server",
@@ -828,7 +821,98 @@
"hitlTimeoutTenMinutes": "10 minutes",
"hitlTimeoutUnlimited": "No limit",
"hitlTimeoutHint": "Unanswered requests are rejected automatically when time expires; approval cards show the countdown.",
"hitlStatusOff": "Human-in-the-loop: Off"
"hitlStatusOff": "Human-in-the-loop: Off",
"rolePanelTitle": "Select role"
},
"toolGuard": {
"invalidTestResponse": "The server returned an invalid test result",
"fieldTooLong": "Rule names are limited to 200 UTF-8 bytes; patterns and messages to 4096 UTF-8 bytes each. Shorten the content and try again.",
"tooManyRules": "You can configure up to 100 blocking rules.",
"title": "Call blocking",
"description": "Check MCP tool names and arguments before execution and stop matching calls. Human-in-the-loop approvals and tool whitelists do not bypass these checks.",
"reset": "Discard changes",
"save": "Save and apply",
"policyTitle": "Blocking policy",
"enableHint": "Checks for risks before tool execution. Save to apply changes.",
"enabled": "Enable call blocking",
"scopeHint": "Rules inspect visible call text only. They do not resolve IP ownership, redirect destinations, or file contents read by tools. Domain rules cannot cover every indirect access.",
"rulesTitle": "Blocking rules",
"rulesHint": "Enabled rules are checked in order; the first match supplies the blocking message. Patterns use RE2 syntax and are validated by the server when saved or tested.",
"addRule": "Add rule",
"addDialogTitle": "Add blocking rule",
"addDialogHint": "Configure your rule and try sample arguments before adding it to the list.",
"addToList": "Add to list",
"addToListHint": "After adding the rule, select “Save and apply” at the top of the page to activate it.",
"cancelAdd": "Cancel",
"closeDialog": "Close add rule dialog",
"singleTestTitle": "Test this rule",
"closeRuleTest": "Hide rule test",
"singleTestHint": "Test the current draft independently before saving. No tools are executed.",
"testing": "Testing…",
"testReadyHint": "Click Test match to see the result here.",
"testResultLabel": "Test result",
"testChanged": "The content has changed. Run the test again.",
"ruleAdded": "Rule added to the list. Save to apply it.",
"addingRule": "Checking rule…",
"addFailed": "Failed to add rule",
"messageHint": "Messages support {match} (matched text), {tool} (tool name), and {rule} (rule name).",
"testTitle": "Test all rules",
"testHint": "Check the current page configuration, including unsaved changes. This test does not execute any tools.",
"test": "Test match",
"testTool": "Tool name",
"testArguments": "Tool arguments (JSON object)",
"loading": "Processing…",
"unsaved": "Unsaved changes",
"savedState": "Synced with server",
"emptyRules": "No rules yet. Add and save a rule to enable its checks.",
"rule": "Rule",
"ruleEnabled": "Enabled",
"deleteRule": "Delete",
"ruleName": "Rule name",
"pattern": "Pattern (RE2)",
"message": "Blocking message",
"requestFailed": "Request failed",
"invalidResponse": "The server returned an invalid configuration",
"loadFailed": "Failed to load blocking configuration",
"loadFirst": "Load the blocking configuration first",
"requiredFields": "Enter a name and pattern for every rule",
"saveSuccess": "Call blocking configuration saved. Subsequent tool calls will use the new configuration.",
"saveFailed": "Save failed",
"defaultMessage": "Detected {match}. Rule “{rule}” blocked this call. Review the target and operation before trying again.",
"blocked": "Rule matched: this call will be blocked",
"notBlocked": "No enabled rule matched",
"disabledResult": "Call blocking is disabled: this configuration will not block calls",
"matchedRule": "Matched rule",
"matchedText": "Matched text",
"matchedMessage": "Returned blocking message",
"toolRequired": "Enter a tool name to test",
"invalidArguments": "Tool arguments must be a valid JSON object",
"testFailed": "Test failed",
"listHint": "Select a rule to view or edit. Rules are checked in list order.",
"helpTitle": "Rule syntax and matching scope",
"testSummary": "Check overall blocking with the current rule order and enabled states",
"unnamedRule": "Untitled rule",
"expandRule": "Expand rule",
"collapseRule": "Collapse rule",
"ruleOn": "Enabled",
"ruleOff": "Disabled",
"ruleModified": "Modified",
"ruleNew": "New",
"defaultPreview": "Uses the default blocking message when matched",
"protectionOn": "Call blocking is on",
"protectionOff": "Call blocking is off",
"ruleCount": "{{enabled}} of {{total}} rules enabled",
"closeEditor": "Close editor",
"testEntry": "Test all rules",
"validateRule": "Test this rule",
"testAll": "Test all rules",
"testScopeAll": "All rules",
"testScopeSingle": "Single rule: {{name}}",
"testSingleHint": "Tests only this rule, ignoring global and rule switches. Does not save or execute tools.",
"testAllHint": "Tests all rules in their current order and enabled states. Does not save or execute tools.",
"singleMatched": "This rule matched",
"singleNotMatched": "This rule did not match",
"singleResultHint": "This is a single-rule test. Actual blocking also depends on the global switch, enabled rules, and matching order."
},
"hitl": {
"pageTitle": "HITL approvals",
@@ -869,7 +953,7 @@
"viewEditedArgs": "View edited parameters",
"reviewArgs": "Review parameters (JSON)",
"commentOptional": "Comment (optional)",
"commentPlaceholder": "For example: read-only operations only",
"commentPlaceholder": "e.g. allow read-only command",
"reject": "Reject",
"allowOnce": "Allow once",
"saveEditedAndAllow": "Save edits and allow",
@@ -970,8 +1054,6 @@
"reviewEditHelp": "Review & edit mode: provide a JSON object to override tool arguments. Example: {\"command\":\"ls -la\"}",
"approvalHelp": "Approval mode: only approve/reject, argument editing is disabled.",
"commentHelp": "Comment (optional): briefly note the approval reason.",
"commentPlaceholder": "e.g. allow read-only command",
"reject": "Reject",
"approve": "Approve",
"loadFailed": "Failed to load",
"invalidJson": "Invalid JSON arguments",
@@ -994,6 +1076,7 @@
"peAgentReplanning": "Replanner"
},
"timeline": {
"blocked": "Blocked",
"params": "Parameters:",
"executionResult": "Execution result:",
"executionId": "Execution ID:",
@@ -2018,7 +2101,6 @@
"conversationManagement": "Conversation Management",
"conversationInteraction": "Conversation Interaction",
"batchTasks": "Batch Tasks",
"conversationGroups": "Conversation Groups",
"vulnerabilityManagement": "Vulnerability Management",
"roleManagement": "Role Management",
"skillsManagement": "Skills Management",
@@ -2064,14 +2146,6 @@
"portScan": "Port scan",
"updateBatchTask": "Update batch task",
"deleteBatchTask": "Delete batch task",
"createGroup": "Create group",
"listGroups": "List groups",
"getGroup": "Get group",
"updateGroup": "Update group",
"deleteGroup": "Delete group",
"getGroupConversations": "Get conversations in group",
"addConversationToGroup": "Add conversation to group",
"removeConversationFromGroup": "Remove conversation from group",
"listVulnerabilities": "List vulnerabilities",
"createVulnerability": "Create vulnerability",
"getVulnerabilityStats": "Get vulnerability statistics",
@@ -2114,8 +2188,6 @@
"getAttackChain": "Get attack chain",
"regenerateAttackChain": "Regenerate attack chain",
"pinConversation": "Pin conversation",
"pinGroup": "Pin group",
"pinGroupConversation": "Pin conversation in group",
"getCategories": "Get categories",
"listKnowledgeItems": "List knowledge items",
"createKnowledgeItem": "Create knowledge item",
@@ -2142,7 +2214,6 @@
"updateBatchQueueMetadata": "Update queue metadata",
"updateBatchQueueSchedule": "Update queue schedule",
"setBatchQueueScheduleEnabled": "Toggle cron auto-schedule",
"getAllGroupMappings": "Get all group mappings",
"fofaSearch": "FOFA search",
"fofaParse": "Parse natural language to FOFA syntax",
"testOpenAI": "Test OpenAI API connection",
@@ -2206,8 +2277,6 @@
"conversationNotFound": "Conversation not found",
"conversationOrResultNotFound": "Conversation or result not found",
"badRequestTaskEmpty": "Bad request (e.g. task is empty)",
"badRequestGroupNameExists": "Bad request or group name already exists",
"groupNotFound": "Group not found",
"badRequestConfig": "Bad request (e.g. invalid config or missing required fields)",
"badRequestQueryEmpty": "Bad request (e.g. query is empty)",
"methodNotAllowed": "Method not allowed (POST only)",
@@ -2226,7 +2295,6 @@
"pauseSuccess": "Paused successfully",
"addSuccess": "Added successfully",
"taskNotFound": "Task not found",
"conversationOrGroupNotFound": "Conversation or group not found",
"cancelSubmitted": "Cancel request submitted",
"noRunningTask": "No running task found",
"messageSent": "Message sent, AI reply returned",
@@ -2267,24 +2335,12 @@
"assetImportTransactionFailed": "Import transaction failed"
}
},
"chatGroup": {
"search": "Search",
"edit": "Edit",
"delete": "Delete",
"clearSearch": "Clear search",
"searchInGroupPlaceholder": "Search in group...",
"attackChain": "Attack chain",
"viewAttackChain": "View attack chain",
"selectRole": "Select role",
"close": "Close",
"selectFile": "Select file",
"uploadFile": "Upload file (multi-select or drag & drop)",
"send": "Send",
"rolePanelTitle": "Select role",
"copyMessage": "Copy message",
"remove": "Remove"
},
"mcpMonitor": {
"toolRowNoCompletedAriaLabel": "{{name}}, {{total}} calls, no completed outcomes; view execution records",
"rateExcludesBlocked": "Success rate includes successful and failed calls, excluding blocked and stopped calls",
"timelineBlockedLegend": "Blocked",
"blockedCount": "Blocked {{n}}",
"statusBlocked": "Blocked",
"deselectAll": "Deselect all",
"statusPending": "Pending",
"statusQueued": "Queued",
@@ -2359,7 +2415,7 @@
"timelineLoadError": "Failed to load call trend",
"timelineTotalLegend": "Total calls",
"timelineFailedLegend": "Failed",
"timelineTooltip": "{{time}}: {{total}} calls ({{failed}} failed)",
"timelineTooltip": "{{time}}: {{total}} calls ({{failed}} failed, {{blocked}} blocked)",
"distTitle": "Call distribution",
"distLegend": "Slice area shows share of all calls",
"distClickHint": "Click a bar segment to filter records",
@@ -2578,16 +2634,16 @@
"errorGeneric": "Something went wrong. Please try again."
},
"vulnerabilityPage": {
"alertTitle": "Robot vulnerability alerts",
"alertDescription": "Push newly discovered vulnerabilities to robots bound to this account, filtered by severity.",
"alertHint": "Send newly discovered vulnerabilities at or above this severity to your bound robot accounts.",
"alertConfiguredNotBound": "{{platforms}} is enabled, but this Web account is not bound to a recipient identity.",
"alertNoBinding": "No proactive robot is both enabled and bound. Your settings are still saved.",
"alertBindAction": "Bind recipient account",
"alertMinimum": "Minimum severity",
"alertEnabled": "Enable alerts",
"alertSaved": "Vulnerability alert settings saved",
"alertSaveFailed": "Failed to save vulnerability alert settings",
"alertTitle": "Robot vulnerability alerts",
"alertDescription": "Push newly discovered vulnerabilities to robots bound to this account, filtered by severity.",
"alertHint": "Send newly discovered vulnerabilities at or above this severity to your bound robot accounts.",
"alertConfiguredNotBound": "{{platforms}} is enabled, but this Web account is not bound to a recipient identity.",
"alertNoBinding": "No proactive robot is both enabled and bound. Your settings are still saved.",
"alertBindAction": "Bind recipient account",
"alertMinimum": "Minimum severity",
"alertEnabled": "Enable alerts",
"alertSaved": "Vulnerability alert settings saved",
"alertSaveFailed": "Failed to save vulnerability alert settings",
"statTotal": "Total",
"statClickAll": "View all (clear severity filter)",
"statClickFilter": "Click to filter by this severity; click again to clear",
@@ -3252,6 +3308,7 @@
"botCommandsFooter": "Otherwise, text is sent to AI under the bound user or service account's current RBAC permissions."
},
"mcpDetailModal": {
"blockReason": "Block reason",
"title": "Tool call details",
"execInfo": "Execution info",
"tool": "Tool",
@@ -3375,50 +3432,18 @@
"searchPlaceholder": "Search history",
"conversationName": "Conversation name",
"project": "Project",
"group": "Group",
"noProject": "No project",
"unknownProject": "Unknown project",
"noGroup": "Ungrouped",
"unknownGroup": "Unknown group",
"filterByProject": "Filter by project",
"filterByGroup": "Filter by group",
"filterAllGroups": "All groups",
"filterUngrouped": "Ungrouped",
"lastTime": "Last activity",
"action": "Action",
"selectAll": "Select all",
"setGroup": "Set group",
"noGroupOption": "Ungrouped",
"deleteSelected": "Delete selected",
"confirmDeleteNone": "Please select at least one conversation to delete",
"confirmDeleteN": "Delete {{count}} selected conversation(s)? Chat messages cannot be recovered, but recorded vulnerabilities will remain in the vulnerability library.",
"confirmGroupChangeNone": "Please select at least one conversation",
"confirmMoveN": "Move {{count}} selected conversation(s) to \"{{group}}\"?",
"confirmRemoveNoGroup": "None of the selected conversations belong to a group",
"confirmRemoveN": "Remove {{count}} selected conversation(s) from their group(s)?",
"removeFailed": "Remove failed",
"moveFailed": "Move failed",
"deleteFailed": "Delete failed",
"unnamedConversation": "Unnamed conversation"
},
"createGroupModal": {
"title": "Create group",
"description": "Group conversations for easier management.",
"selectIcon": "Click to choose icon",
"groupNamePlaceholder": "Enter group name",
"pickIcon": "Pick icon",
"customIcon": "Custom",
"confirmIcon": "OK",
"create": "Create",
"cancel": "Cancel",
"suggestionPenetrationTest": "Penetration Testing",
"suggestionCtf": "CTF",
"suggestionRedTeam": "Red Team",
"suggestionVulnerabilityMining": "Vulnerability Mining",
"nameExists": "Group name already exists, please use another name.",
"createFailed": "Create failed",
"unknownError": "Unknown error"
},
"contextMenu": {
"viewAttackChain": "View attack chain",
"viewVulnerabilities": "View vulnerabilities",
@@ -3429,11 +3454,7 @@
"pinConversation": "Pin conversation",
"unpinConversation": "Unpin",
"batchManage": "Batch manage",
"moveToGroup": "Move to group",
"deleteConversation": "Delete conversation",
"pinGroup": "Pin group",
"unpinGroup": "Unpin",
"deleteGroup": "Delete group"
"deleteConversation": "Delete conversation"
},
"batchImportModal": {
"title": "New task",
@@ -4564,10 +4585,22 @@
}
},
"systemRoles": {
"admin": { "name": "Administrator", "description": "Full platform administration access" },
"operator": { "name": "Operator", "description": "Run daily security workflows without account or core configuration management" },
"auditor": { "name": "Auditor", "description": "Read-only access to audits, monitoring, and assets" },
"viewer": { "name": "Read-only User", "description": "Read-only access to explicitly granted resources" }
"admin": {
"name": "Administrator",
"description": "Full platform administration access"
},
"operator": {
"name": "Operator",
"description": "Run daily security workflows without account or core configuration management"
},
"auditor": {
"name": "Auditor",
"description": "Read-only access to audits, monitoring, and assets"
},
"viewer": {
"name": "Read-only User",
"description": "Read-only access to explicitly granted resources"
}
},
"empty": {
"noMatchingUsers": "No matching members",
@@ -4623,7 +4656,6 @@
"dashboard": "Dashboard",
"files": "Files",
"fofa": "FOFA",
"group": "Conversation Groups",
"hitl": "Human-in-the-loop",
"knowledge": "Knowledge Base",
"mcp": "MCP",
@@ -4642,14 +4674,20 @@
"workflow": "Workflows"
},
"permissionDescriptions": {
"auth": { "self": "Manage own session and password" },
"dashboard": { "read": "View dashboard summaries" },
"auth": {
"self": "Manage own session and password"
},
"dashboard": {
"read": "View dashboard summaries"
},
"chat": {
"read": "View conversations",
"write": "Create and update conversations",
"delete": "Delete conversations and turns"
},
"agent": { "execute": "Run AI agents and workflows" },
"agent": {
"execute": "Run AI agents and workflows"
},
"hitl": {
"read": "View human-in-the-loop queues and logs",
"write": "Approve, dismiss, and configure human-in-the-loop requests"
@@ -4712,7 +4750,9 @@
"read": "View system configuration",
"write": "Update and apply system configuration"
},
"terminal": { "execute": "Execute terminal commands" },
"terminal": {
"execute": "Execute terminal commands"
},
"audit": {
"read": "View and export audit logs",
"delete": "Delete audit logs"
@@ -4738,12 +4778,11 @@
"read": "View attack chains",
"write": "Regenerate attack chains"
},
"fofa": { "execute": "Run FOFA searches and parse queries" },
"openapi": { "read": "Read OpenAPI aggregation results" },
"group": {
"read": "View conversation groups",
"write": "Create and update conversation groups",
"delete": "Delete conversation groups"
"fofa": {
"execute": "Run FOFA searches and parse queries"
},
"openapi": {
"read": "Read OpenAPI aggregation results"
},
"monitor": {
"read": "View the execution monitor",
+150 -111
View File
@@ -10,6 +10,7 @@
"close": "关闭",
"edit": "编辑",
"delete": "删除",
"remove": "移除",
"save": "保存",
"loading": "加载中…",
"search": "搜索",
@@ -76,6 +77,8 @@
"submit": "登录"
},
"nav": {
"security": "安全防护",
"toolGuard": "调用拦截",
"dashboard": "仪表盘",
"chat": "对话",
"assets": "资产管理",
@@ -531,6 +534,7 @@
"generatedFromFact": "由项目事实 {{factKey}} 生成"
},
"chat": {
"toolExecBlocked": "工具 {{name}} 已拦截",
"newChat": "新对话",
"newTask": "新任务",
"toggleConversationPanel": "折叠/展开对话列表",
@@ -567,8 +571,6 @@
"renameConversationSubtitle": "修改后会同步更新项目文件夹和最近对话中的名称",
"conversationTitleLabel": "对话名称",
"conversationTitlePlaceholder": "请输入对话名称",
"conversationGroups": "对话分组",
"addGroup": "新建分组",
"recentConversations": "最近对话",
"toggleRecentConversations": "展开/折叠最近对话",
"filterByProject": "按项目筛选",
@@ -610,7 +612,6 @@
"attachmentUploadFailed": "失败",
"attachmentUploadAlert": "上传失败:{{name}}",
"send": "发送",
"searchInGroup": "搜索分组中的对话...",
"loadingTools": "正在加载工具...",
"noMatchTools": "没有匹配的工具",
"penetrationTestDetail": "任务执行详情",
@@ -644,11 +645,7 @@
"deleteTurnTitle": "删除本轮对话",
"deleteTurnConfirm": "确定删除本轮对话?将同时删除该轮用户消息与助手回复,且无法恢复;下次模型回复将仅基于剩余消息(已保存的上下文快照会清空并按剩余内容重建)。",
"deleteTurnFailed": "删除本轮失败",
"emptyGroupConversations": "该分组暂无对话",
"noMatchingConversationsInGroup": "未找到匹配的对话",
"noHistoryConversations": "暂无历史对话",
"renameGroupPrompt": "请输入新名称:",
"deleteGroupConfirm": "确定要删除此分组吗?分组中的对话不会被删除,但会从分组中移除。",
"deleteConversationConfirm": "确定要删除此对话吗?对话消息将不可恢复,但已记录的漏洞会保留在漏洞库中。",
"renameFailed": "重命名失败",
"downloadConversationFailed": "下载对话失败",
@@ -663,7 +660,6 @@
"projectWelcomeTitleSuffix": " 项目中测试什么?",
"noProjectWelcomeTitle": "要测试什么?",
"welcomeSubtitle": "请输入您的测试需求,系统将自动执行相应的安全测试。",
"addNewGroup": "+ 新增分组",
"callNumber": "调用 #{{n}}",
"iterationRound": "第 {{n}} 轮迭代",
"einoOrchestratorRound": "主代理 · 第 {{n}} 轮",
@@ -735,10 +731,6 @@
"historyGroupToday": "今天",
"historyGroupLast7Days": "过去七天",
"historyGroupEarlier": "更早",
"conversationPreviewJustNow": "刚刚",
"conversationPreviewMinutes": "{{count}} 分钟",
"conversationPreviewHours": "{{count}} 小时",
"conversationPreviewDays": "{{count}} 天",
"agentModeSelectAria": "选择对话执行模式",
"agentModePanelTitle": "对话模式",
"agentModeEinoSingle": "Eino 单代理(ADK",
@@ -807,6 +799,7 @@
"hitlWhitelistHint": "白名单内工具免审批;每行一个或逗号分隔,与 config 全局白名单合并。",
"hitlApply": "应用",
"hitlApplyOkSync": "人机协同配置已保存并同步到服务器。",
"hitlApplyOkDefaultConfig": "人机协同默认配置已写入 config.yaml 并生效。",
"hitlApplyOkWhitelistYaml": "免审批工具已合并进 config.yaml 并生效。会话配置会自动保存。",
"hitlApplyOkLocal": "已保存到本浏览器。",
"hitlApplyFail": "同步到服务器失败",
@@ -816,7 +809,98 @@
"hitlTimeoutTenMinutes": "10 分钟",
"hitlTimeoutUnlimited": "不限制",
"hitlTimeoutHint": "到期未处理将自动拒绝;审批卡片会显示倒计时。",
"hitlStatusOff": "人机协同:关闭"
"hitlStatusOff": "人机协同:关闭",
"rolePanelTitle": "选择角色"
},
"toolGuard": {
"invalidTestResponse": "服务端返回的测试结果格式不正确",
"fieldTooLong": "规则名称最多 200 个 UTF-8 字节,正则和提醒各最多 4096 个 UTF-8 字节;请缩短内容。",
"tooManyRules": "最多可配置 100 条拦截规则。",
"title": "调用拦截",
"description": "在 MCP 工具执行前检查工具名称和参数,命中规则立即阻止调用。人机协同审批和工具白名单不会跳过此检查。",
"reset": "撤销修改",
"save": "保存并生效",
"policyTitle": "拦截策略",
"enableHint": "在工具执行前检查风险,修改后保存生效。",
"enabled": "启用调用拦截",
"scopeHint": "正则仅检查调用中可见的文本;不会解析 IP 归属、重定向目标或工具读取的文件内容。域名规则不能保证覆盖所有间接访问。",
"rulesTitle": "拦截规则",
"rulesHint": "按顺序匹配已启用的规则,使用首条命中规则的提醒。正则采用 RE2 语法,保存和试运行时由服务端校验。",
"addRule": "添加规则",
"addDialogTitle": "添加拦截规则",
"addDialogHint": "先配置规则,再用示例参数验证效果,确认后添加到列表。",
"addToList": "添加到列表",
"addToListHint": "添加到列表后,点击页面顶部「保存并生效」应用规则。",
"cancelAdd": "取消",
"closeDialog": "关闭添加规则",
"singleTestTitle": "验证本条规则",
"closeRuleTest": "收起验证",
"singleTestHint": "使用当前填写内容独立验证,无需保存;不会执行工具。",
"testing": "验证中…",
"testReadyHint": "点击「测试匹配」,在这里查看验证结果。",
"testResultLabel": "验证结果",
"testChanged": "内容已修改,请重新测试匹配。",
"ruleAdded": "规则已添加到列表,保存后生效。",
"addingRule": "正在检查规则…",
"addFailed": "添加规则失败",
"messageHint": "拦截提醒可使用 {match}(匹配文本)、{tool}(工具名称)、{rule}(规则名称)。",
"testTitle": "全部规则验证",
"testHint": "使用页面上的当前配置(包括未保存修改),仅检查匹配结果,不执行任何工具。",
"test": "测试匹配",
"testTool": "工具名称",
"testArguments": "工具参数(JSON 对象)",
"loading": "处理中…",
"unsaved": "有未保存修改",
"savedState": "已与服务端同步",
"emptyRules": "暂无规则。添加规则后保存,即可启用对应的拦截措施。",
"rule": "规则",
"ruleEnabled": "启用",
"deleteRule": "删除",
"ruleName": "规则名称",
"pattern": "匹配正则(RE2",
"message": "拦截提醒",
"requestFailed": "请求失败",
"invalidResponse": "服务端返回的配置格式不正确",
"loadFailed": "加载拦截配置失败",
"loadFirst": "请先加载拦截配置",
"requiredFields": "请填写每条规则的名称和匹配正则",
"saveSuccess": "调用拦截配置已保存,后续工具调用将使用新配置。",
"saveFailed": "保存失败",
"defaultMessage": "识别到 {match},调用被规则「{rule}」拦截。请检查目标与操作后重试。",
"blocked": "已命中规则:将阻止调用",
"notBlocked": "未命中已启用的规则",
"disabledResult": "调用拦截已关闭:此配置不会阻止调用",
"matchedRule": "命中规则",
"matchedText": "匹配文本",
"matchedMessage": "返回的拦截提醒",
"toolRequired": "请填写测试工具名称",
"invalidArguments": "工具参数必须是有效的 JSON 对象",
"testFailed": "测试失败",
"listHint": "点击规则查看和编辑,按列表顺序匹配。",
"helpTitle": "规则说明与匹配范围",
"testSummary": "按规则顺序和启停状态,检查整体拦截效果",
"unnamedRule": "未命名规则",
"expandRule": "展开规则",
"collapseRule": "收起规则",
"ruleOn": "已启用",
"ruleOff": "已停用",
"ruleModified": "已修改",
"ruleNew": "新增",
"defaultPreview": "命中后使用默认拦截提醒",
"protectionOn": "调用拦截已开启",
"protectionOff": "调用拦截已关闭",
"ruleCount": "{{enabled}} / {{total}} 条规则已启用",
"closeEditor": "收起编辑",
"testEntry": "全部规则验证",
"validateRule": "验证本条",
"testAll": "验证全部规则",
"testScopeAll": "全部规则验证",
"testScopeSingle": "单条验证:{{name}}",
"testSingleHint": "仅验证本条规则,忽略总开关和规则启停;不保存、不执行工具。",
"testAllHint": "按当前顺序与启停状态验证全部规则,不保存、不执行工具。",
"singleMatched": "本条规则匹配成功",
"singleNotMatched": "本条规则未匹配",
"singleResultHint": "这是单条规则验证结果,实际拦截还取决于总开关、规则启停和匹配顺序。"
},
"hitl": {
"pageTitle": "人机协同审批",
@@ -857,7 +941,7 @@
"viewEditedArgs": "查看修改后的参数",
"reviewArgs": "审查参数(JSON",
"commentOptional": "备注(可选)",
"commentPlaceholder": "例如:允许只读操作",
"commentPlaceholder": "例如:允许只读命令",
"reject": "拒绝",
"allowOnce": "允许一次",
"saveEditedAndAllow": "保存修改并允许",
@@ -958,8 +1042,6 @@
"reviewEditHelp": "审查编辑模式:可填写 JSON 对象覆盖参数。示例:{\"command\":\"ls -la\"}",
"approvalHelp": "审批模式:仅通过/拒绝,不支持改参。",
"commentHelp": "备注(可选):建议写审批依据。",
"commentPlaceholder": "例如:允许只读命令",
"reject": "拒绝",
"approve": "通过",
"loadFailed": "加载失败",
"invalidJson": "JSON 参数格式错误",
@@ -982,6 +1064,7 @@
"peAgentReplanning": "重规划"
},
"timeline": {
"blocked": "已拦截",
"params": "参数:",
"executionResult": "执行结果:",
"executionId": "执行ID:",
@@ -2006,7 +2089,6 @@
"conversationManagement": "对话管理",
"conversationInteraction": "对话交互",
"batchTasks": "批量任务",
"conversationGroups": "对话分组",
"vulnerabilityManagement": "漏洞管理",
"roleManagement": "角色管理",
"skillsManagement": "Skills管理",
@@ -2052,14 +2134,6 @@
"portScan": "端口扫描",
"updateBatchTask": "更新批量任务",
"deleteBatchTask": "删除批量任务",
"createGroup": "创建分组",
"listGroups": "列出分组",
"getGroup": "获取分组",
"updateGroup": "更新分组",
"deleteGroup": "删除分组",
"getGroupConversations": "获取分组中的对话",
"addConversationToGroup": "添加对话到分组",
"removeConversationFromGroup": "从分组移除对话",
"listVulnerabilities": "列出漏洞",
"createVulnerability": "创建漏洞",
"getVulnerabilityStats": "获取漏洞统计",
@@ -2102,8 +2176,6 @@
"getAttackChain": "获取攻击链",
"regenerateAttackChain": "重新生成攻击链",
"pinConversation": "设置对话置顶",
"pinGroup": "设置分组置顶",
"pinGroupConversation": "设置分组中对话的置顶",
"getCategories": "获取分类",
"listKnowledgeItems": "列出知识项",
"createKnowledgeItem": "创建知识项",
@@ -2130,7 +2202,6 @@
"updateBatchQueueMetadata": "修改队列元数据",
"updateBatchQueueSchedule": "修改队列调度配置",
"setBatchQueueScheduleEnabled": "开关Cron自动调度",
"getAllGroupMappings": "获取所有分组映射",
"fofaSearch": "FOFA搜索",
"fofaParse": "自然语言解析为FOFA语法",
"testOpenAI": "测试OpenAI API连接",
@@ -2194,8 +2265,6 @@
"conversationNotFound": "对话不存在",
"conversationOrResultNotFound": "对话不存在或结果不存在",
"badRequestTaskEmpty": "请求参数错误(如task为空)",
"badRequestGroupNameExists": "请求参数错误或分组名称已存在",
"groupNotFound": "分组不存在",
"badRequestConfig": "请求参数错误(如配置格式不正确、缺少必需字段等)",
"badRequestQueryEmpty": "请求参数错误(如query为空)",
"methodNotAllowed": "方法不允许(仅支持POST请求)",
@@ -2214,7 +2283,6 @@
"pauseSuccess": "暂停成功",
"addSuccess": "添加成功",
"taskNotFound": "任务不存在",
"conversationOrGroupNotFound": "对话或分组不存在",
"cancelSubmitted": "取消请求已提交",
"noRunningTask": "未找到正在执行的任务",
"messageSent": "消息发送成功,返回AI回复",
@@ -2255,24 +2323,12 @@
"assetImportTransactionFailed": "导入事务失败"
}
},
"chatGroup": {
"search": "搜索",
"edit": "编辑",
"delete": "删除",
"clearSearch": "清除搜索",
"searchInGroupPlaceholder": "搜索分组中的对话...",
"attackChain": "攻击链",
"viewAttackChain": "查看攻击链",
"selectRole": "选择角色",
"close": "关闭",
"selectFile": "选择文件",
"uploadFile": "上传文件(可多选或拖拽到此处)",
"send": "发送",
"rolePanelTitle": "选择角色",
"copyMessage": "复制消息内容",
"remove": "移除"
},
"mcpMonitor": {
"toolRowNoCompletedAriaLabel": "{{name}}{{total}} 次调用,暂无完成结果,点击查看执行记录",
"rateExcludesBlocked": "成功率仅统计成功和失败的调用,不包含安全拦截和终止",
"timelineBlockedLegend": "安全拦截",
"blockedCount": "安全拦截 {{n}}",
"statusBlocked": "已拦截",
"deselectAll": "取消全选",
"statusPending": "等待中",
"statusQueued": "排队中",
@@ -2347,7 +2403,7 @@
"timelineLoadError": "无法加载调用趋势",
"timelineTotalLegend": "总调用",
"timelineFailedLegend": "失败",
"timelineTooltip": "{{time}}{{total}} 次(失败 {{failed}}",
"timelineTooltip": "{{time}}{{total}} 次(失败 {{failed}},安全拦截 {{blocked}}",
"distTitle": "调用分布",
"distLegend": "扇区面积为占全部调用比例",
"distClickHint": "点击色条筛选执行记录",
@@ -2566,16 +2622,16 @@
"errorGeneric": "操作失败,请稍后重试。"
},
"vulnerabilityPage": {
"alertTitle": "机器人漏洞提醒",
"alertDescription": "按严重级别将新发现的漏洞推送至当前账号绑定的机器人。",
"alertHint": "发现符合级别的新漏洞后,通过已绑定机器人账号推送。",
"alertConfiguredNotBound": "{{platforms}}已启用,但当前 Web 账号尚未绑定对应的接收身份。",
"alertNoBinding": "尚未启用并绑定支持主动推送的机器人。设置仍会保存。",
"alertBindAction": "绑定接收账号",
"alertMinimum": "最低级别",
"alertEnabled": "启用提醒",
"alertSaved": "漏洞提醒设置已保存",
"alertSaveFailed": "保存漏洞提醒设置失败",
"alertTitle": "机器人漏洞提醒",
"alertDescription": "按严重级别将新发现的漏洞推送至当前账号绑定的机器人。",
"alertHint": "发现符合级别的新漏洞后,通过已绑定机器人账号推送。",
"alertConfiguredNotBound": "{{platforms}}已启用,但当前 Web 账号尚未绑定对应的接收身份。",
"alertNoBinding": "尚未启用并绑定支持主动推送的机器人。设置仍会保存。",
"alertBindAction": "绑定接收账号",
"alertMinimum": "最低级别",
"alertEnabled": "启用提醒",
"alertSaved": "漏洞提醒设置已保存",
"alertSaveFailed": "保存漏洞提醒设置失败",
"statTotal": "总漏洞数",
"statClickAll": "查看全部(清除严重度筛选)",
"statClickFilter": "点击按此严重度筛选;再次点击清除",
@@ -3240,6 +3296,7 @@
"botCommandsFooter": "除以上命令外,直接输入内容将按绑定用户或服务账号的实时 RBAC 权限发送给 AI。Otherwise, text is sent to AI under the effective RBAC identity."
},
"mcpDetailModal": {
"blockReason": "拦截原因",
"title": "工具调用详情",
"execInfo": "执行信息",
"tool": "工具",
@@ -3363,50 +3420,18 @@
"searchPlaceholder": "搜索历史记录",
"conversationName": "对话名称",
"project": "项目",
"group": "对话分组",
"noProject": "无项目",
"unknownProject": "未知项目",
"noGroup": "无分组",
"unknownGroup": "未知分组",
"filterByProject": "按项目筛选",
"filterByGroup": "按分组筛选",
"filterAllGroups": "全部分组",
"filterUngrouped": "无分组",
"lastTime": "最近一次对话时间",
"action": "操作",
"selectAll": "全选",
"setGroup": "设置分组",
"noGroupOption": "无分组",
"deleteSelected": "删除所选",
"confirmDeleteNone": "请先选择要删除的对话",
"confirmDeleteN": "确定要删除选中的 {{count}} 条对话吗?对话消息将不可恢复,但已记录的漏洞会保留在漏洞库中。",
"confirmGroupChangeNone": "请先选择要操作的对话",
"confirmMoveN": "确定将选中的 {{count}} 条对话移动到「{{group}}」吗?",
"confirmRemoveNoGroup": "所选对话均未归属分组",
"confirmRemoveN": "确定将选中的 {{count}} 条对话移出分组吗?",
"removeFailed": "移出失败",
"moveFailed": "移动失败",
"deleteFailed": "删除失败",
"unnamedConversation": "未命名对话"
},
"createGroupModal": {
"title": "创建分组",
"description": "分组功能可将对话集中归类管理,让对话更加井然有序。",
"selectIcon": "点击选择图标",
"groupNamePlaceholder": "请输入分组名称",
"pickIcon": "选择图标",
"customIcon": "自定义",
"confirmIcon": "确定",
"create": "创建",
"cancel": "取消",
"suggestionPenetrationTest": "渗透测试",
"suggestionCtf": "CTF",
"suggestionRedTeam": "红队",
"suggestionVulnerabilityMining": "漏洞挖掘",
"nameExists": "分组名称已存在,请使用其他名称",
"createFailed": "创建失败",
"unknownError": "未知错误"
},
"contextMenu": {
"viewAttackChain": "查看攻击链",
"viewVulnerabilities": "查看漏洞",
@@ -3417,11 +3442,7 @@
"pinConversation": "置顶此对话",
"unpinConversation": "取消置顶",
"batchManage": "批量管理",
"moveToGroup": "移动到分组",
"deleteConversation": "删除此对话",
"pinGroup": "置顶此分组",
"unpinGroup": "取消置顶",
"deleteGroup": "删除此分组"
"deleteConversation": "删除此对话"
},
"batchImportModal": {
"title": "新建任务",
@@ -4552,10 +4573,22 @@
}
},
"systemRoles": {
"admin": { "name": "管理员", "description": "全局管理权限" },
"operator": { "name": "操作员", "description": "可执行日常安全工作流,不能管理账号与核心配置" },
"auditor": { "name": "审计员", "description": "只读查看审计、监控与资产" },
"viewer": { "name": "只读用户", "description": "只读查看被授权资源" }
"admin": {
"name": "管理员",
"description": "全局管理权限"
},
"operator": {
"name": "操作员",
"description": "可执行日常安全工作流,不能管理账号与核心配置"
},
"auditor": {
"name": "审计员",
"description": "只读查看审计、监控与资产"
},
"viewer": {
"name": "只读用户",
"description": "只读查看被授权资源"
}
},
"empty": {
"noMatchingUsers": "没有匹配的成员",
@@ -4611,7 +4644,6 @@
"dashboard": "仪表盘",
"files": "文件",
"fofa": "FOFA",
"group": "对话分组",
"hitl": "人机协同",
"knowledge": "知识库",
"mcp": "MCP",
@@ -4630,14 +4662,20 @@
"workflow": "工作流"
},
"permissionDescriptions": {
"auth": { "self": "管理自己的会话和密码" },
"dashboard": { "read": "查看仪表盘汇总" },
"auth": {
"self": "管理自己的会话和密码"
},
"dashboard": {
"read": "查看仪表盘汇总"
},
"chat": {
"read": "查看对话",
"write": "创建和更新对话",
"delete": "删除对话和消息轮次"
},
"agent": { "execute": "运行 AI 智能体和工作流" },
"agent": {
"execute": "运行 AI 智能体和工作流"
},
"hitl": {
"read": "查看人机协同队列和日志",
"write": "审批、驳回和配置人机协同"
@@ -4700,7 +4738,9 @@
"read": "查看系统配置",
"write": "更新并应用系统配置"
},
"terminal": { "execute": "执行终端命令" },
"terminal": {
"execute": "执行终端命令"
},
"audit": {
"read": "查看和导出审计日志",
"delete": "删除审计日志"
@@ -4726,12 +4766,11 @@
"read": "查看攻击链",
"write": "重新生成攻击链"
},
"fofa": { "execute": "执行 FOFA 搜索和查询解析" },
"openapi": { "read": "读取 OpenAPI 聚合结果" },
"group": {
"read": "查看对话分组",
"write": "创建和更新对话分组",
"delete": "删除对话分组"
"fofa": {
"execute": "执行 FOFA 搜索和查询解析"
},
"openapi": {
"read": "读取 OpenAPI 聚合结果"
},
"monitor": {
"read": "查看执行监控",
+10 -2
View File
@@ -47,6 +47,7 @@ function clearAuthStorage() {
authRoles = [];
authPermissions = new Set();
authScope = '';
applyRBACToUI();
try {
localStorage.removeItem(AUTH_STORAGE_KEY);
} catch (error) {
@@ -368,6 +369,7 @@ const PAGE_PERMISSION_MAP = {
dashboard: 'dashboard:read',
chat: 'chat:read',
hitl: 'hitl:read',
'tool-guard': 'config:read',
'info-collect': 'fofa:execute',
assets: 'asset:read',
'asset-overview': 'asset:read',
@@ -504,6 +506,12 @@ function installPermissionClickGuard() {
function applyRBACToUI(root) {
installPermissionClickGuard();
document.querySelectorAll('[data-page]').forEach((el) => {
// Navigation permissions must also be refreshed during scoped renders.
// Explicit rules take precedence over the fallback page permission map.
if (el.hasAttribute('data-require-permission') || el.hasAttribute('data-require-permission-any')) {
applyPermissionElement(el);
return;
}
const page = el.getAttribute('data-page');
const permission = PAGE_PERMISSION_MAP[page];
if (!permission) return;
@@ -613,10 +621,10 @@ function setUserMenuOpen(open) {
function getStatusText(status) {
const s = (status && String(status).toLowerCase()) || '';
if (typeof window.t !== 'function') {
const fallback = { pending: '等待中', queued: '排队中', running: '执行中', background_running: '后台执行中', completed: '已完成', failed: '失败', cancelled: '已终止', hard_timeout: '硬超时', orphaned: '孤儿记录' };
const fallback = { pending: '等待中', queued: '排队中', running: '执行中', background_running: '后台执行中', completed: '已完成', failed: '失败', blocked: '已拦截', cancelled: '已终止', hard_timeout: '硬超时', orphaned: '孤儿记录' };
return fallback[s] || status;
}
const keyMap = { pending: 'mcpDetailModal.statusPending', queued: 'mcpDetailModal.statusQueued', running: 'mcpDetailModal.statusRunning', background_running: 'timeline.backgroundRunning', completed: 'mcpDetailModal.statusCompleted', failed: 'mcpDetailModal.statusFailed', cancelled: 'mcpDetailModal.statusCancelled', hard_timeout: 'mcpMonitor.statusHardTimeout', orphaned: 'mcpMonitor.statusOrphaned' };
const keyMap = { pending: 'mcpDetailModal.statusPending', queued: 'mcpDetailModal.statusQueued', running: 'mcpDetailModal.statusRunning', background_running: 'timeline.backgroundRunning', completed: 'mcpDetailModal.statusCompleted', failed: 'mcpDetailModal.statusFailed', blocked: 'mcpMonitor.statusBlocked', cancelled: 'mcpDetailModal.statusCancelled', hard_timeout: 'mcpMonitor.statusHardTimeout', orphaned: 'mcpMonitor.statusOrphaned' };
const key = keyMap[s];
return key ? window.t(key) : status;
}
+5 -5
View File
@@ -158,7 +158,7 @@ test('登录成功后重新加载曾因未授权失败的项目侧栏', () => {
assert.notEqual(conversationsIndex, -1);
assert.ok(projectRetryIndex > conversationsIndex);
assert.match(refreshSource, /typeof window\.refreshChatProjectSelector === 'function'/);
assert.match(html, /\/static\/js\/auth\.js\?v=20260813-1/);
assert.match(html, /\/static\/js\/auth\.js\?v=20260907-blocked-1/);
});
test('用户真正滑到底部后恢复自动跟随且不会提前强制跳底', () => {
@@ -341,7 +341,7 @@ test('消息气泡内部流式增高时仅在跟随模式继续粘底', () => {
test('页面在任务补流脚本之前加载智能滚动控制器', () => {
const scrollIndex = html.indexOf('/static/js/chat-scroll.js?v=20260815-1');
const monitorIndex = html.indexOf('/static/js/monitor.js?v=20260819-3');
const monitorIndex = html.indexOf('/static/js/monitor.js?v=20260907-blocked-1');
assert.notEqual(scrollIndex, -1);
assert.notEqual(monitorIndex, -1);
@@ -468,8 +468,8 @@ test('刷新指定对话时立即恢复且加载完成前不闪出无项目状
assert.match(loadSource, /finally \{[\s\S]*?finishChatConversationRestore\(conversationId\)/);
assert.match(css, /\.chat-container\.is-conversation-restoring #chat-messages/);
assert.match(css, /\.chat-container\.is-conversation-restoring #chat-input-container/);
assert.match(html, /router\.js\?v=20260819-3/);
assert.match(html, /chat\.js\?v=20260819-5/);
assert.match(html, /router\.js\?v=20260907-1/);
assert.match(html, /chat\.js\?v=20260907-blocked-1/);
});
test('刷新运行中回复会复用已持久化 planning 并继续追加未来增量', () => {
@@ -533,5 +533,5 @@ test('暗色模式对话三点悬浮不会触发浅色父行背景', () => {
const css = fs.readFileSync('web/static/css/style.css', 'utf8');
assert.match(css, /html\[data-theme="dark"\] \.project-conversation-row:hover \.project-conversation-item/);
assert.match(css, /html\[data-theme="dark"\] \.project-folder-action:hover,[\s\S]*?background: rgba\(71, 85, 105, 0\.28\);[\s\S]*?box-shadow: none;/);
assert.match(html, /style\.css\?v=20260819-4/);
assert.match(html, /style\.css\?v=20260907-blocked-1/);
});
+336 -2376
View File
File diff suppressed because it is too large Load Diff
@@ -8,18 +8,20 @@ const template = fs.readFileSync('web/templates/index.html', 'utf8');
function functionSource(source, name, nextName) {
const start = source.indexOf(`function ${name}(`);
const end = source.indexOf(`function ${nextName}(`, start);
const end = nextName ? source.indexOf(`function ${nextName}(`, start) : source.length;
assert.notEqual(start, -1, `${name} should exist`);
assert.notEqual(end, -1, `${nextName} should follow ${name}`);
if (nextName) {
assert.notEqual(end, -1, `${nextName} should follow ${name}`);
}
return source.slice(start, end);
}
test('全局置顶检查接口结果并即时通知项目文件夹', () => {
const source = functionSource(chat, 'pinConversation', 'showMoveToGroupSubmenu');
const source = functionSource(chat, 'pinConversation');
assert.match(source, /assertConversationActionResponse\(updateResponse, '更新置顶状态失败'\)/);
assert.match(source, /notifyConversationPinnedChanged\(convId, newPinned\)/);
assert.match(source, /loadConversationsWithGroups\(\)/);
assert.match(source, /loadConversations\(\)/);
});
test('项目文件夹内置顶对话优先排序并显示图钉', () => {
@@ -58,17 +60,20 @@ test('项目文件夹菜单可以置顶并立即更新排序', () => {
assert.match(projects, /\[\.\.\.pinnedProjects, unassignedProject, \.\.\.regularProjects\]/);
});
test('对话侧栏不再显示对话分组区域', () => {
test('对话侧栏只保留最近对话区域', () => {
assert.doesNotMatch(template, /class="conversation-groups-section"/);
assert.doesNotMatch(template, /id="conversation-groups-list"/);
});
test('删除对话分组检查接口结果并先清理本地状态', () => {
const deleteSource = functionSource(chat, 'deleteConversationGroupById', 'deleteGroup');
const contextSource = functionSource(chat, 'deleteGroupFromContext', 'closeGroupContextMenu');
test('对话三点菜单仍绑定打开上下文菜单', () => {
const itemSource = functionSource(chat, 'createConversationListItemWithMenu', 'openConversationContextMenuForId');
const menuSource = functionSource(chat, 'showConversationContextMenu', 'ensureConversationRenameModal');
assert.match(deleteSource, /assertConversationActionResponse\(deleteResponse, '删除分组失败'\)/);
assert.match(deleteSource, /removeConversationGroupFromLocalState\(groupId\)/);
assert.match(deleteSource, /if \(currentGroupId === groupId\) exitGroupDetail\(\)/);
assert.match(contextSource, /deleteConversationGroupById\(groupId, \{ closeContextMenu: true \}\)/);
assert.match(itemSource, /menuBtn\.onclick = \(e\) => openConversationContextMenuForId\(e, conversation\.id, conversation\.title \|\| ''\)/);
assert.match(menuSource, /const menu = document\.getElementById\('conversation-context-menu'\)/);
assert.match(menuSource, /menu\.style\.display = 'block'/);
assert.match(chat, /function clearDownloadMarkdownSubmenuHideTimeout\(/);
assert.match(chat, /function handleDownloadMarkdownSubmenuEnter\(/);
assert.match(chat, /function handleDownloadMarkdownSubmenuLeave\(/);
assert.match(chat, /function hideDownloadMarkdownSubmenu\(/);
});
+3 -3
View File
@@ -291,10 +291,10 @@ test('多对话并发时释放隐藏主流且旧请求不能覆盖新对话状
assert.match(chat, /let loadConversationAbortController = null/);
assert.match(chat, /cancelPendingConversationLoad\(\);[\s\S]{0,900}const conversationLoadController = new AbortController\(\)/);
assert.match(chat, /signal: conversationLoadController\.signal/);
assert.match(template, /monitor\.js\?v=20260819-3/);
assert.match(template, /monitor\.js\?v=20260907-blocked-1/);
assert.match(template, /chat-scroll\.js\?v=20260815-1/);
assert.match(template, /chat\.js\?v=20260819-5/);
assert.match(template, /style\.css\?v=20260819-4/);
assert.match(template, /chat\.js\?v=20260907-blocked-1/);
assert.match(template, /style\.css\?v=20260907-blocked-1/);
});
test('彻底停止始终使用弹窗锁定的会话且状态刷新后仍会取消', () => {
+67 -19
View File
@@ -248,47 +248,89 @@ async function fetchHitlConversationConfig(conversationId) {
if (!data || !data.hitl) return null;
return {
hitl: data.hitl,
defaultMode: hitlModeNormalize(data.defaultMode || 'off'),
defaultReviewer: hitlReviewerNormalize(data.defaultReviewer || 'human'),
defaultTimeoutSeconds: normalizeHitlTimeoutSeconds(data.defaultTimeoutSeconds, 300),
hitlGlobalToolWhitelist: Array.isArray(data.hitlGlobalToolWhitelist) ? data.hitlGlobalToolWhitelist : []
};
}
function applyHitlDefaultReviewerFromServer(reviewer) {
const v = hitlReviewerNormalize(reviewer);
return applyHitlDefaultConfigFromServer({ defaultReviewer: reviewer });
}
function applyHitlDefaultConfigFromServer(data) {
const src = data && typeof data === 'object' ? data : {};
const mode = hitlModeNormalize(src.defaultMode || src.mode || 'off');
const reviewer = hitlReviewerNormalize(src.defaultReviewer || src.reviewer || 'human');
const timeoutSeconds = normalizeHitlTimeoutSeconds(
src.defaultTimeoutSeconds != null ? src.defaultTimeoutSeconds : src.timeoutSeconds,
300
);
const out = {
mode: mode,
reviewer: reviewer,
timeoutSeconds: timeoutSeconds
};
if (typeof window !== 'undefined') {
window.csaiHitlDefaultReviewer = v;
window.csaiHitlDefaultConfig = out;
window.csaiHitlDefaultReviewer = reviewer;
if (Array.isArray(src.hitlGlobalToolWhitelist)) {
window.csaiHitlGlobalToolWhitelist = src.hitlGlobalToolWhitelist;
}
}
return v;
return out;
}
async function fetchHitlDefaultConfig() {
const resp = await hitlApiFetch('/api/hitl/default-config', { credentials: 'same-origin' });
if (!resp.ok) {
return applyHitlDefaultConfigFromServer({ defaultMode: 'off', defaultReviewer: 'human', defaultTimeoutSeconds: 300 });
}
const data = await resp.json();
return applyHitlDefaultConfigFromServer(data);
}
async function fetchHitlDefaultReviewer() {
const resp = await hitlApiFetch('/api/hitl/default-reviewer', { credentials: 'same-origin' });
if (!resp.ok) {
return applyHitlDefaultReviewerFromServer('human');
}
const data = await resp.json();
return applyHitlDefaultReviewerFromServer(data && data.defaultReviewer);
const cfg = await fetchHitlDefaultConfig();
return hitlReviewerNormalize(cfg && cfg.reviewer);
}
async function putHitlDefaultReviewer(reviewer) {
const normalized = hitlReviewerNormalize(reviewer);
const resp = await hitlApiFetch('/api/hitl/default-reviewer', {
async function putHitlDefaultConfig(config) {
const current = (typeof window !== 'undefined' && window.csaiHitlDefaultConfig && typeof window.csaiHitlDefaultConfig === 'object')
? window.csaiHitlDefaultConfig
: { mode: 'off', reviewer: 'human', timeoutSeconds: 300 };
const cfg = config && typeof config === 'object' ? config : {};
const payload = {
mode: hitlModeNormalize(cfg.mode != null ? cfg.mode : current.mode),
reviewer: hitlReviewerNormalize(cfg.reviewer != null ? cfg.reviewer : current.reviewer),
timeoutSeconds: normalizeHitlTimeoutSeconds(
cfg.timeoutSeconds != null ? cfg.timeoutSeconds : current.timeoutSeconds,
300
)
};
const resp = await hitlApiFetch('/api/hitl/default-config', {
method: 'PUT',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ reviewer: normalized })
body: JSON.stringify(payload)
});
if (!resp.ok) {
const msg = await readHitlApiError(resp);
throw new Error(msg || ('HTTP ' + resp.status));
}
const data = await resp.json();
return applyHitlDefaultReviewerFromServer(data && data.defaultReviewer);
return applyHitlDefaultConfigFromServer(data);
}
async function putHitlDefaultReviewer(reviewer) {
const cfg = await putHitlDefaultConfig({ reviewer: reviewer });
return hitlReviewerNormalize(cfg && cfg.reviewer);
}
async function initHitlDefaultReviewerFromServer() {
try {
await fetchHitlDefaultReviewer();
await fetchHitlDefaultConfig();
if (!getCurrentConversationIdForHitl() && typeof window.refreshHitlConfigByCurrentConversation === 'function') {
window.refreshHitlConfigByCurrentConversation();
}
@@ -535,10 +577,13 @@ async function syncHitlConfigFromServer(conversationId) {
const pack = await fetchHitlConversationConfig(conversationId);
if (!pack || !pack.hitl) return;
const cfg = pack.hitl;
if (pack.defaultReviewer) {
applyHitlDefaultReviewerFromServer(pack.defaultReviewer);
}
const globalWL = pack.hitlGlobalToolWhitelist || [];
applyHitlDefaultConfigFromServer({
defaultMode: pack.defaultMode,
defaultReviewer: pack.defaultReviewer,
defaultTimeoutSeconds: pack.defaultTimeoutSeconds,
hitlGlobalToolWhitelist: globalWL
});
if (typeof window !== 'undefined') {
window.csaiHitlGlobalToolWhitelist = globalWL;
}
@@ -1820,7 +1865,8 @@ document.addEventListener('DOMContentLoaded', function () {
if (typeof window.bindHitlReviewerToggleListeners === 'function') {
window.bindHitlReviewerToggleListeners();
}
window.csaiHitlDefaultReviewerReady = initHitlDefaultReviewerFromServer();
window.csaiHitlDefaultConfigReady = initHitlDefaultReviewerFromServer();
window.csaiHitlDefaultReviewerReady = window.csaiHitlDefaultConfigReady;
setTimeout(reconcileHitlUiState, 0);
});
@@ -1836,6 +1882,8 @@ document.addEventListener('languagechange', function () {
window.syncHitlConfigToServerByCurrentConversation = syncHitlConfigToServerByCurrentConversation;
window.saveHitlConversationConfig = saveHitlConversationConfig;
window.mergeHitlGlobalToolWhitelist = mergeHitlGlobalToolWhitelist;
window.fetchHitlDefaultConfig = fetchHitlDefaultConfig;
window.putHitlDefaultConfig = putHitlDefaultConfig;
// 由 chat.js 在 loadConversation 内 await 调用;挂到 window 供其它入口显式触发
window.syncHitlConfigFromServer = syncHitlConfigFromServer;
-1
View File
@@ -12,7 +12,6 @@
'skill-modal',
'agent-md-modal',
'batch-manage-modal',
'create-group-modal',
'workflow-meta-modal',
'workflow-dry-run-modal',
'login-overlay',
@@ -0,0 +1,81 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const vm = require('node:vm');
const source = fs.readFileSync('web/static/js/monitor.js', 'utf8');
const statsSource = source.slice(source.indexOf('const MCP_STATS_TOP_N'), source.indexOf('function renderMonitorExecutions('));
function harness() {
const container = { innerHTML: '' };
const escapeHtml = (value) => String(value).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
const context = vm.createContext({
window: { __locale: 'zh-CN' },
document: { getElementById: (id) => id === 'monitor-stats' ? container : null },
localStorage: { getItem: () => null },
monitorState: {},
escapeHtml,
escapeAttrLocal: escapeHtml,
formatMonitorToolName: (name) => name,
monitorToolNamesEqual: (a, b) => a === b,
});
vm.runInContext(statsSource, context);
Object.assign(context, {
bindMonitorStatsPanelEvents() {},
bindMcpStatsTimelineEvents() {},
updateMonitorStatsSubtitle() {},
});
return { context, container };
}
test('安全拦截计入总调用量,但不归入失败或终止', () => {
const { context } = harness();
const totals = context.buildMonitorTotals({ totalCalls: 10, successCalls: 4, failedCalls: 1, blockedCalls: 3 });
assert.deepEqual({ ...totals }, { total: 10, success: 4, failed: 1, blocked: 3, neutral: 2, lastCallTime: null });
assert.equal(context.buildMonitorTotals({ totalCalls: 3, blockedCalls: 3 }).neutral, 0);
assert.equal(context.buildMonitorTotals({ totalCalls: 2, successCalls: 1, failedCalls: 1 }).blocked, 0);
});
test('概览成功率排除安全拦截,只有拦截时不显示失败率或终止标签', () => {
const { context, container } = harness();
context.renderMonitorStats({ totalCalls: 5, successCalls: 1, failedCalls: 1, blockedCalls: 3 });
assert.match(container.innerHTML, />50\.0%<\/span>/);
assert.match(container.innerHTML, /is-blocked">安全拦截 3<\/span>/);
assert.doesNotMatch(container.innerHTML, /is-neutral/);
context.renderMonitorStats({ totalCalls: 3, blockedCalls: 3 });
assert.match(container.innerHTML, /value--rate is-muted">-<\/span>/);
assert.match(container.innerHTML, /is-fail">失败 0<\/span>/);
assert.doesNotMatch(container.innerHTML, /is-danger|is-neutral|0\.0%/);
});
test('工具统计独立展示安全拦截,拦截不降低工具成功率', () => {
const { context } = harness();
for (const render of [context.renderMcpStatsToolTable, context.renderMcpStatsToolsPanel]) {
const blockedOnly = render([{ toolName: 'safe-tool', totalCalls: 4, blockedCalls: 4 }], { total: 4 });
assert.match(blockedOnly, /安全拦截 4/);
assert.match(blockedOnly, /is-muted">-<\/span>/);
assert.doesNotMatch(blockedOnly, /is-danger|>0\.0%<\/span>/);
const mixed = render([{ toolName: 'safe-tool', totalCalls: 10, successCalls: 3, failedCalls: 1, blockedCalls: 6 }], { total: 10 });
assert.match(mixed, /75\.0%/);
assert.match(mixed, /安全拦截 6/);
assert.match(mixed, /失败 1/);
}
});
test('趋势图区分安全拦截和失败,并保留悬停的独立计数', () => {
const { context } = harness();
const points = [{ t: '2026-09-07T00:00:00Z', total: 3, failed: 0, blocked: 3 }];
const html = context.renderMcpStatsTimelineBody({ range: '24h', points, summary: { totalCalls: 3, peak: 3 } });
assert.match(html, /legend-item--blocked">安全拦截/);
assert.match(html, /mcp-stats-timeline-bar-blocked/);
assert.match(html, /mcp-stats-timeline-line--blocked/);
assert.match(html, /data-total="3" data-failed="0" data-blocked="3"/);
assert.doesNotMatch(html, /legend-item--fail|mcp-stats-timeline-bar-fail/);
const mixed = context.buildMcpTimelineSvg([{ ...points[0], total: 4, failed: 1, blocked: 2 }], '24h');
assert.match(mixed, /data-total="4" data-failed="1" data-blocked="2"/);
assert.match(mixed, /mcp-stats-timeline-bar-fail/);
assert.match(mixed, /mcp-stats-timeline-bar-blocked/);
});
+124 -47
View File
@@ -2999,11 +2999,9 @@ function handleStreamEvent(event, progressElement, progressId,
loadActiveTasks();
// 延迟刷新对话列表,确保用户消息已保存,updated_at已更新
// 这样新对话才能正确显示在最近对话列表的顶部
// 使用loadConversationsWithGroups确保分组映射缓存正确加载,无论是否有分组都能立即显示
// 刷新最近对话列表
setTimeout(() => {
if (typeof loadConversationsWithGroups === 'function') {
loadConversationsWithGroups();
} else if (typeof loadConversations === 'function') {
if (typeof loadConversations === 'function') {
loadConversations();
}
if (typeof window.refreshChatProjectFolders === 'function') {
@@ -3642,13 +3640,15 @@ function handleStreamEvent(event, progressElement, progressId,
case 'tool_result':
const resultInfo = event.data || {};
const resultToolName = resultInfo.toolName || (typeof window.t === 'function' ? window.t('chat.unknownTool') : '未知工具');
const success = resultInfo.success !== false;
const success = getToolResultDisplayState(resultInfo).success;
const resultDisplayState = getToolResultDisplayState(resultInfo, { rawText: event.message || '' });
const backgroundRunning = resultDisplayState.kind === 'background_running';
const statusIcon = backgroundRunning ? '⏳' : (success ? '✅' : '❌');
const statusIcon = resultDisplayState.kind === 'blocked' ? '🛡' : (backgroundRunning ? '⏳' : (success ? '✅' : '❌'));
const resultToolCallId = resultInfo.toolCallId || null;
const resultStatusForCall = backgroundRunning ? 'background_running' : (success ? 'completed' : 'failed');
const resultExecText = backgroundRunning
const resultStatusForCall = toolDisplayStatusFromState(resultDisplayState);
const resultExecText = resultDisplayState.kind === 'blocked'
? (typeof window.t === 'function' ? window.t('chat.toolExecBlocked', { name: escapeHtml(resultToolName) }) : '工具 ' + escapeHtml(resultToolName) + ' 已拦截')
: backgroundRunning
? (getBackgroundRunningToolLabel() + ': ' + escapeHtml(resultToolName))
: (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) + ' 执行失败'));
@@ -5889,9 +5889,41 @@ function collectToolResultTextParts(value, parts, depth) {
if (value.content != null) collectToolResultTextParts(value.content, parts, depth + 1);
}
// Older records did not have a structured marker. Only recognize the exact
// guard prefix at the start of a result, never a quoted mention in ordinary output.
function isToolGuardBlockedResult(value, depth, allowLegacy) {
depth = depth || 0;
allowLegacy = allowLegacy !== false;
if (value == null || depth > 5) return false;
if (typeof value === 'string') {
const text = value.trimStart();
if (allowLegacy && /^工具调用已被安全规则拦截(?:[:\r\n]|$)/.test(text)) return true;
if (text.startsWith('{')) {
try { return isToolGuardBlockedResult(JSON.parse(text), depth + 1, allowLegacy); } catch (e) { /* plain text */ }
}
return false;
}
if (typeof value !== 'object') return false;
if (Array.isArray(value)) return value.some(function (part) { return isToolGuardBlockedResult(part, depth + 1, allowLegacy); });
if (value.blocked === true || value.status === 'blocked' || value.displayStatus === 'blocked') return true;
if (value._meta && value._meta['cyberstrike.ai/blocked'] === true) return true;
if (value.success === true || value.isError === false || value.status === 'completed') allowLegacy = false;
return ['result', 'error', 'content', 'text', 'resultPreview'].some(function (key) {
return isToolGuardBlockedResult(value[key], depth + 1, allowLegacy);
});
}
function getToolExecutionDisplayStatus(execution) {
return isToolGuardBlockedResult(execution) ? 'blocked' : String(execution && execution.status || 'unknown').toLowerCase();
}
function getToolResultDisplayState(data, opts) {
opts = opts || {};
data = data || {};
const allowLegacyBlock = data.success !== true && data.isError !== false && data.status !== 'completed';
if (isToolGuardBlockedResult(data) || isToolGuardBlockedResult(opts.rawText, 0, allowLegacyBlock)) {
return { kind: 'blocked', isError: true, success: false };
}
const toolName = String(data.toolName || data.name || '').trim().toLowerCase();
const isObservationTool = toolName === 'wait_tool_execution' || toolName === 'get_tool_execution';
const explicitStatus = String(data.displayStatus || data.status || '').toLowerCase();
@@ -5939,6 +5971,7 @@ function toolDisplayStatusFromState(displayState) {
if (!displayState) return 'completed';
if (displayState.kind === 'background_running') return 'background_running';
if (displayState.kind === 'cancelled') return 'cancelled';
if (displayState.kind === 'blocked') return 'blocked';
return displayState.isError ? 'failed' : 'completed';
}
@@ -5963,7 +5996,7 @@ function buildToolResultSectionHtml(data, opts) {
const resultStr = typeof result === 'string' ? result : JSON.stringify(result);
const rawText = opts.rawText != null ? String(opts.rawText) : resultStr;
const displayState = getToolResultDisplayState(data, { rawText: rawText });
const sectionClass = displayState.kind === 'background_running' ? 'pending' : (displayState.isError ? 'error' : 'success');
const sectionClass = displayState.kind === 'blocked' ? 'blocked' : (displayState.kind === 'background_running' ? 'pending' : (displayState.isError ? 'error' : 'success'));
return (
'<div class="tool-result-section ' + sectionClass + '">' +
'<strong data-i18n="timeline.executionResult">' + escapeHtml(execResultLabel) + '</strong>' +
@@ -6187,8 +6220,8 @@ function mergeToolResultIntoCallItem(item, data, options) {
if (data.executionId != null && String(data.executionId).trim() !== '') {
item.dataset.toolExecutionId = String(data.executionId).trim();
}
item.classList.remove('tool-call-running', 'tool-call-completed', 'tool-call-failed');
item.classList.add(backgroundRunning ? 'tool-call-running' : (displayState.isError ? 'tool-call-failed' : 'tool-call-completed'));
item.classList.remove('tool-call-running', 'tool-call-completed', 'tool-call-failed', 'tool-call-blocked');
item.classList.add(getToolCallStatusPresentation(toolDisplayStatusFromState(displayState)).itemClass);
applyToolCallStatus(item, item.dataset.toolDisplayStatus);
return true;
}
@@ -6201,7 +6234,7 @@ function mergeToolResultIntoCallItem(item, data, options) {
if (!section) return false;
section.classList.remove('pending');
section.className = 'tool-result-section ' + (backgroundRunning ? 'pending' : (displayState.isError ? 'error' : 'success'));
section.className = 'tool-result-section ' + (displayState.kind === 'blocked' ? 'blocked' : (backgroundRunning ? 'pending' : (displayState.isError ? 'error' : 'success')));
const pre = section.querySelector('pre.tool-result');
if (pre) {
pre.classList.remove('tool-result-pending');
@@ -6230,8 +6263,8 @@ function mergeToolResultIntoCallItem(item, data, options) {
if (data.executionId != null && String(data.executionId).trim() !== '') {
item.dataset.toolExecutionId = String(data.executionId).trim();
}
item.classList.remove('tool-call-running', 'tool-call-completed', 'tool-call-failed');
item.classList.add(backgroundRunning ? 'tool-call-running' : (displayState.isError ? 'tool-call-failed' : 'tool-call-completed'));
item.classList.remove('tool-call-running', 'tool-call-completed', 'tool-call-failed', 'tool-call-blocked');
item.classList.add(getToolCallStatusPresentation(toolDisplayStatusFromState(displayState)).itemClass);
applyToolCallStatus(item, item.dataset.toolDisplayStatus);
return true;
}
@@ -6370,6 +6403,7 @@ window.mergeToolResultIntoCallItem = mergeToolResultIntoCallItem;
window.formatToolCallTimelineTitle = formatToolCallTimelineTitle;
window.parseToolCallArgsFromData = parseToolCallArgsFromData;
window.getToolResultDisplayState = getToolResultDisplayState;
window.getToolExecutionDisplayStatus = getToolExecutionDisplayStatus;
window.getBackgroundRunningToolLabel = getBackgroundRunningToolLabel;
window.buildToolResultSectionHtml = buildToolResultSectionHtml;
@@ -6392,6 +6426,9 @@ function getToolCallStatusPresentation(status) {
if (normalized === 'failed') {
return { status: normalized, itemClass: 'tool-call-failed', badgeClass: 'tool-status-failed', label: translate('timeline.execFailed', '执行失败'), icon: '❌ ' };
}
if (normalized === 'blocked') {
return { status: normalized, itemClass: 'tool-call-blocked', badgeClass: 'tool-status-blocked', label: translate('timeline.blocked', '已拦截'), icon: '🛡 ' };
}
if (normalized === 'cancelled' || normalized === 'canceled') {
return { status: 'cancelled', itemClass: 'tool-call-failed', badgeClass: 'tool-status-failed', label: translate('tasks.statusCancelled', '已取消'), icon: '⛔ ' };
}
@@ -6408,7 +6445,7 @@ function applyToolCallStatus(item, status) {
const titleElement = item.querySelector('.timeline-item-title');
if (!titleElement) return;
item.classList.remove('tool-call-running', 'tool-call-completed', 'tool-call-failed', 'tool-call-incomplete');
item.classList.remove('tool-call-running', 'tool-call-completed', 'tool-call-failed', 'tool-call-blocked', 'tool-call-incomplete');
const previousBadge = titleElement.querySelector('.tool-status-badge');
if (previousBadge) previousBadge.remove();
if (!presentation) {
@@ -6627,7 +6664,7 @@ function addTimelineItem(timeline, type, options) {
const mergedDisplayState = merged ? getToolResultDisplayState(merged) : null;
const mergedBackgroundRunning = mergedDisplayState && mergedDisplayState.kind === 'background_running';
const terminalStatus = String(options.toolStatus || '').toLowerCase();
const forcedStatus = (terminalStatus === 'completed' || terminalStatus === 'failed' || terminalStatus === 'cancelled' || terminalStatus === 'canceled')
const forcedStatus = mergedDisplayState && mergedDisplayState.kind === 'blocked' ? 'blocked' : (terminalStatus === 'completed' || terminalStatus === 'blocked' || terminalStatus === 'failed' || terminalStatus === 'cancelled' || terminalStatus === 'canceled')
? (terminalStatus === 'canceled' ? 'cancelled' : terminalStatus)
: '';
if (merged) {
@@ -6637,14 +6674,14 @@ function addTimelineItem(timeline, type, options) {
if (merged.executionId != null && String(merged.executionId).trim() !== '') {
item.dataset.toolExecutionId = String(merged.executionId).trim();
}
item.classList.add(item.dataset.toolDisplayStatus === 'background_running' ? 'tool-call-running' : (item.dataset.toolDisplayStatus === 'completed' ? 'tool-call-completed' : 'tool-call-failed'));
item.classList.add(getToolCallStatusPresentation(item.dataset.toolDisplayStatus).itemClass);
if (d._mergedResultDetailId) {
item.dataset.toolResultDetailId = String(d._mergedResultDetailId);
}
} else if (terminalStatus === 'completed' || terminalStatus === 'failed' || terminalStatus === 'cancelled' || terminalStatus === 'canceled') {
} else if (terminalStatus === 'completed' || terminalStatus === 'blocked' || terminalStatus === 'failed' || terminalStatus === 'cancelled' || terminalStatus === 'canceled') {
item.dataset.toolSuccess = terminalStatus === 'completed' ? '1' : '0';
item.dataset.toolDisplayStatus = terminalStatus === 'canceled' ? 'cancelled' : terminalStatus;
item.classList.add(terminalStatus === 'completed' ? 'tool-call-completed' : 'tool-call-failed');
item.classList.add(getToolCallStatusPresentation(terminalStatus).itemClass);
} else if (terminalStatus === 'result_missing') {
item.dataset.toolDisplayStatus = 'result_missing';
item.classList.add('tool-call-incomplete');
@@ -6747,16 +6784,16 @@ function addTimelineItem(timeline, type, options) {
const mergedDisplayState = merged ? getToolResultDisplayState(merged) : null;
const mergedBackgroundRunning = mergedDisplayState && mergedDisplayState.kind === 'background_running';
const terminalStatus = String(options.toolStatus || '').toLowerCase();
const forcedStatus = (terminalStatus === 'completed' || terminalStatus === 'failed' || terminalStatus === 'cancelled' || terminalStatus === 'canceled')
const forcedStatus = mergedDisplayState && mergedDisplayState.kind === 'blocked' ? 'blocked' : (terminalStatus === 'completed' || terminalStatus === 'blocked' || terminalStatus === 'failed' || terminalStatus === 'cancelled' || terminalStatus === 'canceled')
? (terminalStatus === 'canceled' ? 'cancelled' : terminalStatus)
: '';
const hasTerminalStatus = terminalStatus === 'completed' || terminalStatus === 'failed' || terminalStatus === 'cancelled' || terminalStatus === 'canceled';
const hasTerminalStatus = terminalStatus === 'completed' || terminalStatus === 'blocked' || terminalStatus === 'failed' || terminalStatus === 'cancelled' || terminalStatus === 'canceled';
const hasHistoricalStatus = hasTerminalStatus || terminalStatus === 'result_missing';
if (merged) {
const statusForClass = forcedStatus || toolDisplayStatusFromState(mergedDisplayState);
item.classList.add(statusForClass === 'background_running' ? 'tool-call-running' : (statusForClass === 'completed' ? 'tool-call-completed' : 'tool-call-failed'));
item.classList.add(getToolCallStatusPresentation(statusForClass).itemClass);
} else if (hasTerminalStatus) {
item.classList.add(terminalStatus === 'completed' ? 'tool-call-completed' : 'tool-call-failed');
item.classList.add(getToolCallStatusPresentation(terminalStatus).itemClass);
} else if (terminalStatus === 'result_missing') {
item.classList.add('tool-call-incomplete');
} else if (!options.skipPendingResult) {
@@ -6820,7 +6857,7 @@ function addTimelineItem(timeline, type, options) {
if (data.executionId != null && String(data.executionId).trim() !== '') {
item.dataset.toolExecutionId = String(data.executionId).trim();
}
item.classList.add(displayState.kind === 'background_running' ? 'tool-call-running' : (displayState.isError ? 'tool-call-failed' : 'tool-call-completed'));
item.classList.add(getToolCallStatusPresentation(toolDisplayStatusFromState(displayState)).itemClass);
} else if (type === 'cancelled') {
const taskCancelledLabel = typeof window.t === 'function' ? window.t('chat.taskCancelled') : '任务已取消';
content += `
@@ -7736,11 +7773,13 @@ function buildMonitorTotals(summary) {
const total = s.totalCalls || 0;
const success = s.successCalls || 0;
const failed = s.failedCalls || 0;
const blocked = s.blockedCalls || 0;
return {
total,
success,
failed,
neutral: Math.max(0, total - success - failed),
blocked,
neutral: Math.max(0, total - success - failed - blocked),
lastCallTime: s.lastCallTime ? new Date(s.lastCallTime) : null,
};
}
@@ -7769,6 +7808,7 @@ function buildMcpTimelineSvg(points, rangeKey) {
const plotH = H - padT - padB;
const maxVal = Math.max(1, ...points.map((p) => p.total || 0));
const hasFailed = points.some((p) => (p.failed || 0) > 0);
const hasBlocked = points.some((p) => (p.blocked || 0) > 0);
const locale = (typeof window.__locale === 'string' && window.__locale.startsWith('zh')) ? 'zh-CN' : 'en-US';
const barGap = points.length > 48 ? 1 : 2;
const barW = Math.max(1.6, Math.min(8, (plotW / Math.max(1, points.length)) - barGap));
@@ -7791,6 +7831,11 @@ function buildMcpTimelineSvg(points, rangeKey) {
}).join(' ');
}
const blockedPath = hasBlocked ? coords.map((c, i) => {
const y = padT + plotH - ((c.p.blocked || 0) / maxVal) * plotH;
return `${i === 0 ? 'M' : 'L'} ${c.x.toFixed(2)} ${y.toFixed(2)}`;
}).join(' ') : '';
let peakIdx = 0;
points.forEach((p, i) => {
if ((p.total || 0) >= (points[peakIdx].total || 0)) peakIdx = i;
@@ -7825,21 +7870,26 @@ function buildMcpTimelineSvg(points, rangeKey) {
return `<circle class="${dotClass}" cx="${c.x.toFixed(2)}" cy="${c.y.toFixed(2)}" r="${isPeak ? 2 : 1.5}"
data-time="${escapeAttrLocal(tipTime)}"
data-total="${c.p.total || 0}"
data-failed="${c.p.failed || 0}" />`;
data-failed="${c.p.failed || 0}"
data-blocked="${c.p.blocked || 0}" />`;
}).join('');
const bars = coords.map((c) => {
const total = c.p.total || 0;
const failed = c.p.failed || 0;
const blocked = c.p.blocked || 0;
const h = total > 0 ? Math.max(3, (total / maxVal) * plotH) : 1;
const y = baseY - h;
const failedH = failed > 0 ? Math.max(2, (failed / maxVal) * plotH) : 0;
const failedH = total > 0 ? h * (failed / total) : 0;
const blockedH = total > 0 ? h * (blocked / total) : 0;
const tipTime = formatMcpTimelineLabel(c.p.t, rangeKey, locale);
return `<g class="mcp-stats-timeline-bar-group">
<rect class="mcp-stats-timeline-bar${total > 0 ? ' is-active' : ''}" x="${(c.x - barW / 2).toFixed(2)}" y="${y.toFixed(2)}" width="${barW.toFixed(2)}" height="${h.toFixed(2)}" rx="1.6"
data-time="${escapeAttrLocal(tipTime)}" data-total="${total}" data-failed="${failed}" />
data-time="${escapeAttrLocal(tipTime)}" data-total="${total}" data-failed="${failed}" data-blocked="${blocked}" />
${failedH > 0 ? `<rect class="mcp-stats-timeline-bar-fail" x="${(c.x - barW / 2).toFixed(2)}" y="${(baseY - failedH).toFixed(2)}" width="${barW.toFixed(2)}" height="${failedH.toFixed(2)}" rx="1.6"
data-time="${escapeAttrLocal(tipTime)}" data-total="${total}" data-failed="${failed}" />` : ''}
data-time="${escapeAttrLocal(tipTime)}" data-total="${total}" data-failed="${failed}" data-blocked="${blocked}" />` : ''}
${blockedH > 0 ? `<rect class="mcp-stats-timeline-bar-blocked" x="${(c.x - barW / 2).toFixed(2)}" y="${(baseY - failedH - blockedH).toFixed(2)}" width="${barW.toFixed(2)}" height="${blockedH.toFixed(2)}" rx="1.6"
data-time="${escapeAttrLocal(tipTime)}" data-total="${total}" data-failed="${failed}" data-blocked="${blocked}" />` : ''}
</g>`;
}).join('');
@@ -7867,6 +7917,7 @@ function buildMcpTimelineSvg(points, rangeKey) {
${peakMarker}
<path class="mcp-stats-timeline-line" d="${linePath}" stroke="url(#mcpTimelineLineStroke)" />
${hasFailed ? `<path class="mcp-stats-timeline-line mcp-stats-timeline-line--fail" d="${failPath}" />` : ''}
${hasBlocked ? `<path class="mcp-stats-timeline-line mcp-stats-timeline-line--blocked" d="${blockedPath}" />` : ''}
${dots}
${xLabels}
</svg>`;
@@ -7895,22 +7946,23 @@ function bindMcpStatsTimelineEvents() {
}
root.addEventListener('mousemove', function (e) {
const dot = e.target.closest('.mcp-stats-timeline-dot, .mcp-stats-timeline-bar, .mcp-stats-timeline-bar-fail');
const dot = e.target.closest('.mcp-stats-timeline-dot, .mcp-stats-timeline-bar, .mcp-stats-timeline-bar-fail, .mcp-stats-timeline-bar-blocked');
if (!dot || !mcpTimelineTooltipEl) {
root.querySelectorAll('.mcp-stats-timeline-dot.is-active').forEach((d) => d.classList.remove('is-active'));
root.querySelectorAll('.mcp-stats-timeline-bar.is-hover, .mcp-stats-timeline-bar-fail.is-hover').forEach((d) => d.classList.remove('is-hover'));
root.querySelectorAll('.mcp-stats-timeline-bar.is-hover, .mcp-stats-timeline-bar-fail.is-hover, .mcp-stats-timeline-bar-blocked.is-hover').forEach((d) => d.classList.remove('is-hover'));
mcpTimelineTooltipEl.style.display = 'none';
return;
}
root.querySelectorAll('.mcp-stats-timeline-dot.is-active').forEach((d) => d.classList.remove('is-active'));
root.querySelectorAll('.mcp-stats-timeline-bar.is-hover, .mcp-stats-timeline-bar-fail.is-hover').forEach((d) => d.classList.remove('is-hover'));
root.querySelectorAll('.mcp-stats-timeline-bar.is-hover, .mcp-stats-timeline-bar-fail.is-hover, .mcp-stats-timeline-bar-blocked.is-hover').forEach((d) => d.classList.remove('is-hover'));
dot.classList.add('is-active');
dot.classList.add('is-hover');
const time = dot.getAttribute('data-time') || '';
const total = dot.getAttribute('data-total') || '0';
const failed = dot.getAttribute('data-failed') || '0';
const tip = mcpMonitorT('timelineTooltip', { time, total, failed })
|| `${time}${total} 次(失败 ${failed}`;
const blocked = dot.getAttribute('data-blocked') || '0';
const tip = mcpMonitorT('timelineTooltip', { time, total, failed, blocked })
|| monitorFallback(`${time}${total} 次(失败 ${failed},安全拦截 ${blocked}`, `${time}: ${total} calls (${failed} failed, ${blocked} blocked)`);
mcpTimelineTooltipEl.textContent = tip;
mcpTimelineTooltipEl.style.display = 'block';
mcpTimelineTooltipEl.style.left = `${e.clientX}px`;
@@ -7921,7 +7973,7 @@ function bindMcpStatsTimelineEvents() {
if (!e.target.closest || !e.target.closest('.mcp-stats-combined__timeline, .mcp-stats-timeline')) return;
if (e.relatedTarget && root.contains(e.relatedTarget)) return;
root.querySelectorAll('.mcp-stats-timeline-dot.is-active').forEach((d) => d.classList.remove('is-active'));
root.querySelectorAll('.mcp-stats-timeline-bar.is-hover, .mcp-stats-timeline-bar-fail.is-hover').forEach((d) => d.classList.remove('is-hover'));
root.querySelectorAll('.mcp-stats-timeline-bar.is-hover, .mcp-stats-timeline-bar-fail.is-hover, .mcp-stats-timeline-bar-blocked.is-hover').forEach((d) => d.classList.remove('is-hover'));
if (mcpTimelineTooltipEl) mcpTimelineTooltipEl.style.display = 'none';
});
@@ -7999,10 +8051,13 @@ function renderMcpTimelineActiveMoments(points, rangeKey) {
const time = formatMcpTimelineLabel(p.t, rangeKey, locale);
const failed = p.failed || 0;
const failedLabel = mcpMonitorT('failedCount', { n: failed }) || `失败 ${failed}`;
const blocked = p.blocked || 0;
const blockedLabel = mcpMonitorT('blockedCount', { n: blocked }) || monitorFallback(`安全拦截 ${blocked}`, `Blocked ${blocked}`);
return `<span class="mcp-stats-timeline-moment" title="${escapeHtml(time)}">
<span class="mcp-stats-timeline-moment__time">${escapeHtml(time)}</span>
<span class="mcp-stats-timeline-moment__count">${p.total || 0}</span>
${failed > 0 ? `<span class="mcp-stats-timeline-moment__fail">${escapeHtml(failedLabel)}</span>` : ''}
${blocked > 0 ? `<span class="mcp-stats-timeline-moment__blocked">${escapeHtml(blockedLabel)}</span>` : ''}
</span>`;
}).join('');
const moreChip = hiddenCount > 0
@@ -8082,7 +8137,9 @@ function renderMcpStatsTimelineBody(timeline, timelineError, compactEmpty, loadi
const chartSvg = buildMcpTimelineSvg(points, rangeKey);
const totalLegend = mcpMonitorT('timelineTotalLegend') || '总调用';
const failLegend = mcpMonitorT('timelineFailedLegend') || '失败';
const blockedLegend = mcpMonitorT('timelineBlockedLegend') || monitorFallback('安全拦截', 'Blocked');
const hasFailed = points.some((p) => (p.failed || 0) > 0);
const hasBlocked = points.some((p) => (p.blocked || 0) > 0);
const sparseHint = buildTimelineSparseHint(points, timeline);
const momentsHtml = renderMcpTimelineActiveMoments(points, rangeKey);
const sparseHtml = sparseHint
@@ -8097,6 +8154,7 @@ function renderMcpStatsTimelineBody(timeline, timelineError, compactEmpty, loadi
<div class="mcp-stats-timeline__legend">
<span class="mcp-stats-timeline__legend-item">${escapeHtml(totalLegend)}</span>
${hasFailed ? `<span class="mcp-stats-timeline__legend-item mcp-stats-timeline__legend-item--fail">${escapeHtml(failLegend)}</span>` : ''}
${hasBlocked ? `<span class="mcp-stats-timeline__legend-item mcp-stats-timeline__legend-item--blocked">${escapeHtml(blockedLegend)}</span>` : ''}
</div>`;
}
@@ -8637,8 +8695,13 @@ function renderMcpStatsMetricsBar(totals, successRate, rateTone, rateSubText, la
const lastCallLabel = mcpMonitorT('lastCall') || monitorFallback('最近一次调用', 'Last call');
const successPill = mcpMonitorT('successCount', { n: totals.success }) || monitorFallback(`成功 ${totals.success}`, `Success ${totals.success}`);
const failedPill = mcpMonitorT('failedCount', { n: totals.failed }) || monitorFallback(`失败 ${totals.failed}`, `Failed ${totals.failed}`);
const blockedPill = mcpMonitorT('blockedCount', { n: totals.blocked }) || monitorFallback(`安全拦截 ${totals.blocked}`, `Blocked ${totals.blocked}`);
const neutralPill = mcpMonitorT('neutralCount', { n: totals.neutral }) || monitorFallback(`终止 ${totals.neutral}`, `Stopped ${totals.neutral}`);
const rateHint = mcpMonitorT('rateExcludesBlocked') || monitorFallback('成功率仅统计成功和失败的调用,不包含安全拦截和终止', 'Success rate includes only successful and failed calls; blocked and stopped calls are excluded');
const rateValue = hasCalls ? `${successRate}%` : successRate;
const blockedChip = totals.blocked > 0
? `<span class="mcp-stats-kpi__chip is-blocked">${escapeHtml(blockedPill)}</span>`
: '';
const neutralChip = totals.neutral > 0
? `<span class="mcp-stats-kpi__chip is-neutral">${escapeHtml(neutralPill)}</span>`
: '';
@@ -8653,6 +8716,7 @@ function renderMcpStatsMetricsBar(totals, successRate, rateTone, rateSubText, la
<div class="mcp-stats-kpi__meta">
<span class="mcp-stats-kpi__chip is-ok">${escapeHtml(successPill)}</span>
<span class="mcp-stats-kpi__chip is-fail">${escapeHtml(failedPill)}</span>
${blockedChip}
${neutralChip}
</div>
</div>
@@ -8660,7 +8724,7 @@ function renderMcpStatsMetricsBar(totals, successRate, rateTone, rateSubText, la
<article class="mcp-stats-kpi__item mcp-stats-kpi__item--rate">
<span class="mcp-stats-kpi__accent" aria-hidden="true"></span>
<div class="mcp-stats-kpi__content">
<span class="mcp-stats-kpi__label">${escapeHtml(successRateLabel)}</span>
<span class="mcp-stats-kpi__label" title="${escapeAttrLocal(rateHint)}">${escapeHtml(successRateLabel)}</span>
<span class="mcp-stats-kpi__value mcp-stats-kpi__value--rate ${rateTone}">${rateValue}</span>
<span class="mcp-stats-kpi__status ${rateTone}">${escapeHtml(rateSubText)}</span>
</div>
@@ -8689,16 +8753,21 @@ function renderMcpStatsToolTable(topTools, totals, activeToolFilter = '') {
const total = tool.totalCalls || 0;
const success = tool.successCalls || 0;
const failed = tool.failedCalls || 0;
const blocked = tool.blockedCalls || 0;
const effectiveTotal = success + failed;
const toolRateNum = effectiveTotal > 0 ? (success / effectiveTotal) * 100 : 0;
const toolRate = toolRateNum.toFixed(1);
const rateText = effectiveTotal > 0 ? `${toolRate}%` : '-';
const sharePct = totals.total > 0 ? ((total / totals.total) * 100).toFixed(1) : '0.0';
const dotColor = MCP_STATS_DIST_COLORS[index % MCP_STATS_DIST_COLORS.length];
const isActive = activeToolFilter && monitorToolNamesEqual(activeToolFilter, rawName);
const rateClass = getMcpToolRateClass(toolRateNum);
const rateClass = effectiveTotal > 0 ? getMcpToolRateClass(toolRateNum) : 'is-muted';
const rankClass = index === 0 ? ' rank-1' : index === 1 ? ' rank-2' : index === 2 ? ' rank-3' : '';
const rowAria = mcpMonitorT('toolRowAriaLabel', { name, total, rate: toolRate })
|| `${name}${total} 次调用,成功率 ${toolRate}%`;
const blockedLabel = mcpMonitorT('blockedCount', { n: blocked }) || monitorFallback(`安全拦截 ${blocked}`, `Blocked ${blocked}`);
const rowAria = (effectiveTotal > 0
? (mcpMonitorT('toolRowAriaLabel', { name, total, rate: toolRate }) || `${name}${total} 次调用,成功率 ${toolRate}%`)
: (mcpMonitorT('toolRowNoCompletedAriaLabel', { name, total }) || monitorFallback(`${name}${total} 次调用,暂无完成结果,点击查看执行记录`, `${name}, ${total} calls, no completed outcomes, click to view records`)))
+ (blocked > 0 ? ` · ${blockedLabel}` : '');
rowsHtml += `
<tr class="mcp-stats-tool-row${isActive ? ' is-active' : ''}"
data-tool-name="${escapeAttrLocal(rawName)}"
@@ -8714,8 +8783,9 @@ function renderMcpStatsToolTable(topTools, totals, activeToolFilter = '') {
<td class="col-num">${total}</td>
<td class="col-share">${sharePct}%</td>
<td class="col-rate">
<span class="mcp-stats-rate ${rateClass}">${toolRate}%</span>
<span class="mcp-stats-rate ${rateClass}">${rateText}</span>
${failed > 0 ? `<span class="mcp-stats-fail-note">${escapeHtml(mcpMonitorT('failedCount', { n: failed }) || `失败 ${failed}`)}</span>` : ''}
${blocked > 0 ? `<span class="mcp-stats-blocked-note">${escapeHtml(blockedLabel)}</span>` : ''}
</td>
</tr>`;
});
@@ -8768,17 +8838,22 @@ function renderMcpStatsToolsPanel(topTools, totals, activeToolFilter = '') {
const total = tool.totalCalls || 0;
const success = tool.successCalls || 0;
const failed = tool.failedCalls || 0;
const blocked = tool.blockedCalls || 0;
const effectiveTotal = success + failed;
const toolRateNum = effectiveTotal > 0 ? (success / effectiveTotal) * 100 : 0;
const toolRate = toolRateNum.toFixed(1);
const rateText = effectiveTotal > 0 ? `${toolRate}%` : '-';
const sharePct = totals.total > 0 ? ((total / totals.total) * 100).toFixed(1) : '0.0';
const color = MCP_STATS_DIST_COLORS[index % MCP_STATS_DIST_COLORS.length];
const barPct = maxCalls > 0 ? ((total / maxCalls) * 100).toFixed(1) : '0';
const isActive = activeToolFilter && monitorToolNamesEqual(activeToolFilter, rawName);
const rateClass = getMcpToolRateClass(toolRateNum);
const rateClass = effectiveTotal > 0 ? getMcpToolRateClass(toolRateNum) : 'is-muted';
const rankClass = index === 0 ? ' rank-1' : index === 1 ? ' rank-2' : index === 2 ? ' rank-3' : '';
const rowAria = mcpMonitorT('toolRowAriaLabel', { name, total, rate: toolRate })
|| `${name}${total} 次,成功率 ${toolRate}%`;
const blockedLabel = mcpMonitorT('blockedCount', { n: blocked }) || monitorFallback(`安全拦截 ${blocked}`, `Blocked ${blocked}`);
const rowAria = (effectiveTotal > 0
? (mcpMonitorT('toolRowAriaLabel', { name, total, rate: toolRate }) || `${name}${total} 次,成功率 ${toolRate}%`)
: (mcpMonitorT('toolRowNoCompletedAriaLabel', { name, total }) || monitorFallback(`${name}${total} 次调用,暂无完成结果,点击查看执行记录`, `${name}, ${total} calls, no completed outcomes, click to view records`)))
+ (blocked > 0 ? ` · ${blockedLabel}` : '');
const failNote = failed > 0
? `<span class="mcp-stats-tool-item__fail">${escapeHtml(mcpMonitorT('failedCount', { n: failed }) || `失败 ${failed}`)}</span>`
: '';
@@ -8803,7 +8878,8 @@ function renderMcpStatsToolsPanel(topTools, totals, activeToolFilter = '') {
<div class="mcp-stats-tool-item__bottom">
<span class="mcp-stats-tool-item__pill is-success">${escapeHtml(successLabel)}</span>
<span class="mcp-stats-tool-item__pill${failed > 0 ? ' is-danger' : ''}">${escapeHtml(failedLabel)}</span>
<span class="mcp-stats-tool-item__rate ${rateClass}">${toolRate}%${failNote}</span>
${blocked > 0 ? `<span class="mcp-stats-tool-item__pill is-blocked">${escapeHtml(blockedLabel)}</span>` : ''}
<span class="mcp-stats-tool-item__rate ${rateClass}">${rateText}${failNote}</span>
</div>
</li>`;
}).join('');
@@ -8997,6 +9073,7 @@ function renderMonitorExecutions(executions = [], statusFilter = 'all') {
running: 'statusRunning',
completed: 'statusCompleted',
failed: 'statusFailed',
blocked: 'statusBlocked',
cancelled: 'statusCancelled',
hard_timeout: 'statusHardTimeout',
orphaned: 'statusOrphaned'
@@ -9004,7 +9081,7 @@ function renderMonitorExecutions(executions = [], statusFilter = 'all') {
const locale = (typeof window.__locale === 'string' && window.__locale.startsWith('zh')) ? 'zh-CN' : undefined;
const rowEntries = executions
.map(exec => {
const status = (exec.status || 'unknown').toLowerCase();
const status = getToolExecutionDisplayStatus(exec);
const statusClass = `monitor-status-chip ${status}`;
const statusKey = statusKeyMap[status];
const statusLabel = (typeof window.t === 'function' && statusKey) ? window.t('mcpMonitor.' + statusKey) : getStatusText(status);
@@ -9544,8 +9621,8 @@ function refreshProgressAndTimelineI18n() {
const displayStatus = item.dataset.toolDisplayStatus || '';
const backgroundRunning = displayStatus === 'background_running';
const success = item.dataset.toolSuccess === '1';
const icon = backgroundRunning ? '\u23F3 ' : (success ? '\u2705 ' : '\u274C ');
titleSpan.textContent = ap + icon + (backgroundRunning ? (getBackgroundRunningToolLabel() + ': ' + name) : (success ? _t('chat.toolExecComplete', { name: name }) : _t('chat.toolExecFailed', { name: name })));
const icon = displayStatus === 'blocked' ? '🛡 ' : (backgroundRunning ? '\u23F3 ' : (success ? '\u2705 ' : '\u274C '));
titleSpan.textContent = ap + icon + (displayStatus === 'blocked' ? _t('chat.toolExecBlocked', { name: name }) : backgroundRunning ? (getBackgroundRunningToolLabel() + ': ' + name) : (success ? _t('chat.toolExecComplete', { name: name }) : _t('chat.toolExecFailed', { name: name })));
} else if (type === 'eino_agent_reply') {
titleSpan.textContent = ap + '\uD83D\uDCAC ' + _t('chat.einoAgentReplyTitle');
} else if (type === 'eino_usage_summary') {
+5 -10
View File
@@ -6,7 +6,7 @@
'use strict';
const GLOBAL_WRITE_HANDLER_PERMISSIONS = {
// 对话 / 分组
// 对话
sendMessage: 'chat:write',
startNewConversation: 'chat:write',
deleteConversation: 'chat:delete',
@@ -16,15 +16,6 @@
deleteSelectedConversations: 'chat:delete',
renameConversation: 'chat:write',
pinConversation: 'chat:write',
showCreateGroupModal: 'group:write',
createGroup: 'group:write',
editGroup: 'group:write',
deleteGroup: 'group:delete',
deleteGroupFromContext: 'group:delete',
pinGroupFromContext: 'group:write',
renameGroupFromContext: 'group:write',
applyBatchGroupChange: 'group:write',
applyCustomIcon: 'group:write',
// 人机协同
applyHitlSidebarConfig: 'hitl:write',
@@ -96,6 +87,10 @@
deleteRetrievalLog: 'knowledge:delete',
// 设置 / MCP
saveToolGuardConfig: 'config:write',
addToolGuardRule: 'config:write',
resetToolGuardConfig: 'config:write',
changeToolGuardEnabled: 'config:write',
applySettings: 'config:write',
saveToolsConfig: 'config:write',
saveExternalMCP: 'mcp:write',
+15 -3
View File
@@ -110,7 +110,7 @@ function initRouter() {
const hashParts = hash.split('?');
let pageId = hashParts[0];
if (pageId === 'c2') pageId = 'c2-listeners';
if (pageId && ['dashboard', 'chat', 'hitl', 'asset-overview', 'asset-library', 'info-collect', 'projects', 'vulnerabilities', 'webshell', 'chat-files', 'mcp-monitor', 'mcp-management', 'knowledge-management', 'knowledge-retrieval-logs', 'roles-management', 'platform-rbac', 'workflows', 'skills-monitor', 'skills-management', 'agents-management', 'settings', 'tasks', 'c2-listeners', 'c2-sessions', 'c2-tasks', 'c2-payloads', 'c2-events', 'c2-profiles'].includes(pageId)) {
if (pageId && ['dashboard', 'chat', 'hitl', 'tool-guard', 'asset-overview', 'asset-library', 'info-collect', 'projects', 'vulnerabilities', 'webshell', 'chat-files', 'mcp-monitor', 'mcp-management', 'knowledge-management', 'knowledge-retrieval-logs', 'roles-management', 'platform-rbac', 'workflows', 'skills-monitor', 'skills-management', 'agents-management', 'settings', 'tasks', 'c2-listeners', 'c2-sessions', 'c2-tasks', 'c2-payloads', 'c2-events', 'c2-profiles'].includes(pageId)) {
switchPage(pageId);
if (pageId === 'chat') {
scheduleChatConversationFromHash(0);
@@ -186,7 +186,15 @@ function updateNavState(pageId) {
});
// 设置活动状态
if (pageId === 'asset-overview' || pageId === 'asset-library' || pageId === 'info-collect') {
if (pageId === 'hitl' || pageId === 'tool-guard') {
const securityItem = document.querySelector('.nav-item[data-page="security"]');
if (securityItem) {
securityItem.classList.add('active');
securityItem.classList.add('expanded');
}
const submenuItem = document.querySelector(`.nav-submenu-item[data-page="${pageId}"]`);
if (submenuItem) submenuItem.classList.add('active');
} else if (pageId === 'asset-overview' || pageId === 'asset-library' || pageId === 'info-collect') {
const assetItem = document.querySelector('.nav-item[data-page="assets"]');
if (assetItem) {
assetItem.classList.add('active');
@@ -348,6 +356,7 @@ function showSubmenuPopup(navItem, menuId) {
// 复制子菜单项到弹出菜单
const submenuItems = submenu.querySelectorAll('.nav-submenu-item');
submenuItems.forEach(item => {
if (item.hidden || (typeof permissionAllowedForElement === 'function' && !permissionAllowedForElement(item))) return;
const popupItem = document.createElement('div');
popupItem.className = 'submenu-popup-item';
popupItem.textContent = item.textContent.trim();
@@ -414,6 +423,9 @@ async function initPage(pageId) {
refreshChatProjectSelector();
}
break;
case 'tool-guard':
if (typeof loadToolGuardConfig === 'function') loadToolGuardConfig();
break;
case 'hitl':
if (typeof refreshHitlActivePanel === 'function') {
refreshHitlActivePanel();
@@ -609,7 +621,7 @@ document.addEventListener('DOMContentLoaded', function() {
let pageId = hashParts[0];
if (pageId === 'c2') pageId = 'c2-listeners';
if (pageId && ['dashboard', 'chat', 'hitl', 'asset-overview', 'asset-library', 'info-collect', 'projects', 'tasks', 'workflows', 'vulnerabilities', 'webshell', 'chat-files', 'mcp-monitor', 'mcp-management', 'knowledge-management', 'knowledge-retrieval-logs', 'roles-management', 'platform-rbac', 'skills-monitor', 'skills-management', 'agents-management', 'settings', 'c2-listeners', 'c2-sessions', 'c2-tasks', 'c2-payloads', 'c2-events', 'c2-profiles'].includes(pageId)) {
if (pageId && ['dashboard', 'chat', 'hitl', 'tool-guard', 'asset-overview', 'asset-library', 'info-collect', 'projects', 'tasks', 'workflows', 'vulnerabilities', 'webshell', 'chat-files', 'mcp-monitor', 'mcp-management', 'knowledge-management', 'knowledge-retrieval-logs', 'roles-management', 'platform-rbac', 'skills-monitor', 'skills-management', 'agents-management', 'settings', 'c2-listeners', 'c2-sessions', 'c2-tasks', 'c2-payloads', 'c2-events', 'c2-profiles'].includes(pageId)) {
switchPage(pageId);
if (pageId === 'chat') {
scheduleChatConversationFromHash(0);
@@ -51,3 +51,18 @@ test('Eino 模型 retry/failover 设置项有中英文文案', () => {
assert.ok(en.settingsBasic[key].length > 0, `en ${key} is empty`);
});
});
test('AI 通道保存前会自动识别 DeepSeek 官方线路', () => {
assert.match(settings, /function\s+isOfficialDeepSeekBaseURL/);
assert.match(settings, /api\.deepseek\.com/);
assert.match(settings, /profile:\s*'deepseek'/);
assert.match(settings, /normalizeAIChannelProviderProfile\(\{/);
assert.match(settings, /normalizeAIConfigProviderProfiles\(currentConfig\.ai\)/);
assert.match(template, /<option value="deepseek">deepseek<\/option>/);
});
test('切换或新增 AI 通道时会刷新推理线路下拉显示', () => {
assert.match(settings, /const profileEl = document\.getElementById\('openai-reasoning-profile'\);/);
assert.match(settings, /syncSettingsCustomSelect\(profileEl\);/);
assert.match(settings, /reasoning:\s*\{\s*mode:\s*'auto',\s*effort:\s*'',\s*profile:\s*'auto'/);
});
+57 -7
View File
@@ -1985,6 +1985,7 @@ async function applySettings() {
const activeChannelId = normalizeAIChannelId(selectedAIChannelId || currentConfig.ai.default_channel || 'default');
currentConfig.ai.channels[activeChannelId] = readAIChannelFromMainForm(activeChannelId);
currentConfig.ai.default_channel = activeChannelId;
currentConfig.ai = normalizeAIConfigProviderProfiles(currentConfig.ai);
renderAIChannelSelect();
const activeChannel = currentConfig.ai.channels[activeChannelId] || {};
const prevOpenai = activeChannel;
@@ -1999,7 +2000,7 @@ async function applySettings() {
return String(s || '').split(/[\n,]/).map(v => v.trim()).filter(Boolean);
};
const config = {
ai: currentConfig.ai,
ai: normalizeAIConfigProviderProfiles(currentConfig.ai),
vision: visionPayload,
fofa: {
api_key: document.getElementById('fofa-api-key')?.value.trim() || '',
@@ -2533,6 +2534,43 @@ function normalizeAIChannelId(name) {
return id || 'default';
}
function aiChannelBaseURLHost(baseUrl) {
const raw = String(baseUrl || '').trim();
if (!raw) return '';
try {
return new URL(raw).hostname.toLowerCase().replace(/^www\./, '');
} catch (e) {
try {
return new URL(`https://${raw.replace(/^\/+/, '')}`).hostname.toLowerCase().replace(/^www\./, '');
} catch (_) {
return '';
}
}
}
function isOfficialDeepSeekBaseURL(baseUrl) {
return aiChannelBaseURLHost(baseUrl) === 'api.deepseek.com';
}
function normalizeAIChannelProviderProfile(channel) {
if (!channel || typeof channel !== 'object') return channel;
if (isOfficialDeepSeekBaseURL(channel.base_url)) {
channel.reasoning = {
...(channel.reasoning || {}),
profile: 'deepseek'
};
}
return channel;
}
function normalizeAIConfigProviderProfiles(ai) {
if (!ai || typeof ai !== 'object' || !ai.channels || typeof ai.channels !== 'object') return ai;
Object.keys(ai.channels).forEach((id) => {
ai.channels[id] = normalizeAIChannelProviderProfile(ai.channels[id] || {});
});
return ai;
}
function escapeAIChannelHtml(value) {
return String(value == null ? '' : value)
.replace(/&/g, '&amp;')
@@ -2559,13 +2597,13 @@ function ensureAIConfigShape(cfg) {
reasoning: oa.reasoning || {}
};
}
return { default_channel: def, channels };
return normalizeAIConfigProviderProfiles({ default_channel: def, channels });
}
function readAIChannelFromMainForm(id) {
const prev = currentConfig?.ai?.channels?.[id] || {};
const maxCompletionTokens = parseInt(document.getElementById('openai-max-completion-tokens')?.value, 10) || 32768;
return {
return normalizeAIChannelProviderProfile({
...prev,
name: (document.getElementById('ai-channel-name')?.value || '').trim() || prev.name || id,
provider: document.getElementById('openai-provider')?.value || 'openai',
@@ -2581,7 +2619,7 @@ function readAIChannelFromMainForm(id) {
profile: document.getElementById('openai-reasoning-profile')?.value || 'auto',
allow_client_reasoning: document.getElementById('openai-reasoning-allow-client')?.checked !== false
}
};
});
}
function writeAIChannelToMainForm(id) {
@@ -2594,6 +2632,7 @@ function writeAIChannelToMainForm(id) {
if (providerEl) {
const provider = (ch.provider === 'openai' || !ch.provider) ? 'openai_compatible' : ch.provider;
providerEl.value = provider;
syncSettingsCustomSelect(providerEl);
}
const keyEl = document.getElementById('openai-api-key');
if (keyEl) keyEl.value = ch.api_key || '';
@@ -2607,11 +2646,20 @@ function writeAIChannelToMainForm(id) {
if (maxCompletionTokensEl) maxCompletionTokensEl.value = ch.max_completion_tokens || 32768;
const r = ch.reasoning || {};
const modeEl = document.getElementById('openai-reasoning-mode');
if (modeEl) modeEl.value = ['auto', 'on', 'off'].includes(String(r.mode || '').toLowerCase()) ? String(r.mode).toLowerCase() : 'auto';
if (modeEl) {
modeEl.value = ['auto', 'on', 'off'].includes(String(r.mode || '').toLowerCase()) ? String(r.mode).toLowerCase() : 'auto';
syncSettingsCustomSelect(modeEl);
}
const effEl = document.getElementById('openai-reasoning-effort');
if (effEl) effEl.value = ['', 'low', 'medium', 'high', 'max', 'xhigh'].includes(String(r.effort || '').toLowerCase()) ? String(r.effort || '').toLowerCase() : '';
if (effEl) {
effEl.value = ['', 'low', 'medium', 'high', 'max', 'xhigh'].includes(String(r.effort || '').toLowerCase()) ? String(r.effort || '').toLowerCase() : '';
syncSettingsCustomSelect(effEl);
}
const profileEl = document.getElementById('openai-reasoning-profile');
if (profileEl) profileEl.value = ['auto', 'deepseek_compat', 'openai_compat', 'output_config_effort'].includes(String(r.profile || '').toLowerCase()) ? String(r.profile || '').toLowerCase() : 'auto';
if (profileEl) {
profileEl.value = ['auto', 'deepseek', 'deepseek_compat', 'openai_compat', 'output_config_effort'].includes(String(r.profile || '').toLowerCase()) ? String(r.profile || '').toLowerCase() : 'auto';
syncSettingsCustomSelect(profileEl);
}
const allowEl = document.getElementById('openai-reasoning-allow-client');
if (allowEl) allowEl.checked = r.allow_client_reasoning !== false;
syncModelListFetchButtons();
@@ -2943,6 +2991,7 @@ async function persistAIChannelsToServer(successMessage, options = {}) {
currentConfig.ai.default_channel = latestAI.default_channel || id;
}
}
currentConfig.ai = normalizeAIConfigProviderProfiles(currentConfig.ai);
const updateResponse = await apiFetch('/api/config', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
@@ -2971,6 +3020,7 @@ async function persistAIConfigOnlyToServer(successMessage) {
if (typeof requirePermission === 'function' && !requirePermission('config:write')) return false;
if (!currentConfig) return false;
currentConfig.ai = ensureAIConfigShape(currentConfig);
currentConfig.ai = normalizeAIConfigProviderProfiles(currentConfig.ai);
showAIChannelSaveHint(settingsT('settingsBasic.aiChannelSaving', '正在保存通道...'), true);
try {
const updateResponse = await apiFetch('/api/config', {
+139
View File
@@ -0,0 +1,139 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const vm = require('node:vm');
const monitor = fs.readFileSync('web/static/js/monitor.js', 'utf8');
const chat = fs.readFileSync('web/static/js/chat.js', 'utf8');
const reason = '工具调用已被安全规则拦截:识别到 example.gov,禁止访问。\n规则: 政府网站保护';
function sourceFunction(source, name) {
const start = source.indexOf(`function ${name}(`);
assert.notEqual(start, -1, name);
const rest = source.slice(start);
const next = rest.slice(1).search(/\n(?:async )?function /);
return (next === -1 ? rest : rest.slice(0, next + 1)).split(/\nwindow\.|\nconst toolCallDetailStateByItemId/)[0];
}
class Element {
constructor() {
this.dataset = {};
this.children = [];
this.className = '';
this.classList = {
contains: (value) => this.className.split(' ').includes(value),
add: (...values) => { this.className = [...new Set([...this.className.split(' ').filter(Boolean), ...values])].join(' '); },
remove: (...values) => { this.className = this.className.split(' ').filter((value) => !values.includes(value)).join(' '); }
};
}
set innerHTML(value) {
this.html = value;
this.title = new Element();
this.title.className = 'timeline-item-title';
}
get innerHTML() { return this.html; }
appendChild(child) { child.parent = this; this.children.push(child); }
remove() { if (this.parent) this.parent.children = this.parent.children.filter((child) => child !== this); }
querySelector(selector) {
if (selector === '.timeline-item-title') return this.title || null;
if (selector === '.tool-status-badge') return this.children.find((child) => child.classList.contains('tool-status-badge')) || null;
return null;
}
}
function runtime() {
const ctx = {
window: {}, document: { createElement: () => new Element() },
toolCallDetailStateByItemId: new Map(),
updateToolDetailToggleLabel() {}, applyEinoTimelineRole() {}, pruneLiveTimelineIfNeeded() {},
getCurrentTimeLocale: () => 'en-US', getTimeFormatOptions: () => ({}),
escapeHtml: (value) => String(value).replaceAll('&', '&amp;').replaceAll('<', '&lt;'),
};
const funcs = ['collectToolResultTextParts', 'isToolGuardBlockedResult', 'getToolExecutionDisplayStatus',
'getToolResultDisplayState', 'toolDisplayStatusFromState', 'getBackgroundRunningToolLabel',
'getToolCallStatusPresentation', 'applyToolCallStatus', 'parseToolCallArgsFromData', 'toolCallArgsEmpty',
'setToolCallDetailState', 'mergeToolResultIntoCallItem', 'coalesceProcessDetailsToolPairs',
'buildToolResultSectionHtml', 'addTimelineItem'];
vm.createContext(ctx);
vm.runInContext(funcs.map((name) => sourceFunction(monitor, name)).join('\n'), ctx);
ctx.window.getToolExecutionDisplayStatus = ctx.getToolExecutionDisplayStatus;
vm.runInContext(['normalizeToolExecutionSummary', 'getToolExecutionStatusLabel', 'formatMCPResultJsonForDisplay'].map((name) => sourceFunction(chat, name)).join('\n'), ctx);
return ctx;
}
test('structured block markers have priority over generic failure and running states', () => {
const ctx = runtime();
for (const payload of [
{ blocked: true, success: false, isError: true },
{ status: 'blocked', isError: true },
{ success: false, result: { blocked: true, isError: true, content: [] } },
{ success: false, result: JSON.stringify({ _meta: { 'cyberstrike.ai/blocked': true }, isError: true, content: [] }) },
{ blocked: true, displayStatus: 'background_running', success: true },
]) {
const state = ctx.getToolResultDisplayState(payload);
assert.equal(state.kind, 'blocked');
assert.equal(state.success, false);
assert.equal(ctx.toolDisplayStatusFromState(state), 'blocked');
}
});
test('legacy guard failures are recognized in raw, nested, serialized and deferred history results', () => {
const ctx = runtime();
for (const result of [reason, { isError: true, content: [{ type: 'text', text: reason }] }, JSON.stringify({ isError: true, content: [{ type: 'text', text: reason }] })]) {
assert.equal(ctx.getToolResultDisplayState({ result, success: false }).kind, 'blocked');
}
assert.equal(ctx.getToolResultDisplayState({ resultPreview: reason, success: false, _payloadDeferred: true }).kind, 'blocked');
assert.equal(ctx.getToolResultDisplayState({ success: false }, { rawText: reason }).kind, 'blocked');
});
test('quoted mentions, prefix lookalikes and explicitly successful output stay ordinary results', () => {
const ctx = runtime();
for (const result of ['示例:' + reason, '"' + reason + '"', '工具调用已被安全规则拦截说明文档']) {
assert.equal(ctx.getToolResultDisplayState({ result, success: false }).kind, 'error');
}
for (const data of [{ success: true, result: reason }, { isError: false, content: [{ text: reason }] }, { status: 'completed', result: reason }]) {
assert.equal(ctx.getToolResultDisplayState(data, { rawText: reason }).kind, 'success');
}
assert.equal(ctx.getToolResultDisplayState({ success: false, result: 'connection refused' }).kind, 'error');
});
test('live merge replaces a red failure badge with the distinct block badge and keeps the reason', () => {
const ctx = runtime();
const timeline = new Element();
ctx.addTimelineItem(timeline, 'tool_call', { title: 'http_request', data: { toolName: 'http_request' }, toolStatus: 'failed' });
const item = timeline.children[0];
assert.equal(item.classList.contains('tool-call-failed'), true);
ctx.mergeToolResultIntoCallItem(item, { blocked: true, success: false, result: reason });
assert.equal(item.dataset.toolDisplayStatus, 'blocked');
assert.equal(item.dataset.toolSuccess, '0');
assert.equal(item.classList.contains('tool-call-blocked'), true);
assert.equal(item.classList.contains('tool-call-failed'), false);
assert.equal(item.title.children.length, 1);
assert.match(item.title.children[0].textContent, /已拦截/);
assert.equal(ctx.toolCallDetailStateByItemId.get(item.id).rawText, reason);
});
test('refresh coalescing preserves blocks even when the old execution summary says failed', () => {
const ctx = runtime();
const details = ctx.coalesceProcessDetailsToolPairs([
{ id: 'call', eventType: 'tool_call', data: { toolCallId: 'id', toolName: 'http_request' } },
{ id: 'result', eventType: 'tool_result', data: { toolCallId: 'id', success: false, result: reason } }
]);
assert.equal(details.length, 1);
const timeline = new Element();
ctx.addTimelineItem(timeline, 'tool_call', { data: details[0].data, toolStatus: 'failed' });
const item = timeline.children[0];
assert.equal(item.dataset.toolDisplayStatus, 'blocked');
assert.match(item.title.children[0].className, /tool-status-blocked/);
assert.equal(item.classList.contains('tool-call-failed'), false);
const resultData = ctx.toolCallDetailStateByItemId.get(item.id).resultData;
assert.match(ctx.buildToolResultSectionHtml(resultData), /tool-result-section blocked/);
assert.doesNotMatch(ctx.buildToolResultSectionHtml(resultData), /tool-result-section error/);
});
test('execution summary buttons and raw detail preserve the separate blocked status', () => {
const ctx = runtime();
assert.equal(ctx.normalizeToolExecutionSummary({ toolName: 'http_request', status: 'blocked' }).status, 'blocked');
assert.equal(ctx.normalizeToolExecutionSummary({ toolName: 'http_request', status: 'failed', error: reason }).status, 'blocked');
assert.equal(ctx.getToolExecutionStatusLabel('blocked'), '已拦截');
assert.equal(JSON.parse(ctx.formatMCPResultJsonForDisplay({ blocked: true, isError: true, content: [] })).blocked, true);
});
+732
View File
@@ -0,0 +1,732 @@
(function () {
'use strict';
const state = { config: null, saved: null, busy: false, testing: false, revision: 0, openRuleId: null, addDraft: null };
const ruleViews = new Map();
const el = (id) => document.getElementById('tool-guard-' + id);
const canRead = () => typeof hasPermission !== 'function' || hasPermission('config:read');
const canWrite = () => typeof hasPermission !== 'function' || hasPermission('config:write');
const copy = (value) => JSON.parse(JSON.stringify(value));
function tr(key, params) {
const fullKey = 'toolGuard.' + key;
const value = typeof window.t === 'function' ? window.t(fullKey, params) : fullKey;
return String(value).replace(/\{\{(\w+)\}\}/g, (match, name) => params && params[name] !== undefined ? String(params[name]) : match);
}
function dirty() {
return state.config && JSON.stringify(state.config) !== JSON.stringify(state.saved);
}
function feedback(message, error) {
const target = el('feedback');
if (!target) return;
target.textContent = message || '';
target.hidden = !message;
target.classList.toggle('is-error', !!error);
}
function updateControls() {
const writable = canWrite() && !!state.config && !state.busy;
const readable = canRead() && !!state.config && !state.busy;
const isDirty = !!dirty();
if (el('enabled')) el('enabled').disabled = !writable;
if (el('add')) el('add').disabled = !writable || state.config.rules.length >= 100;
if (el('save')) el('save').disabled = !writable || !isDirty;
if (el('reset')) el('reset').disabled = !writable || !isDirty;
if (el('test')) el('test').disabled = !canRead() || !state.config || state.busy || state.testing;
if (el('open-test')) el('open-test').disabled = !readable;
if (el('save-state')) {
el('save-state').textContent = state.busy ? tr('loading') : state.config ? tr(isDirty ? 'unsaved' : 'savedState') : '';
el('save-state').classList.toggle('is-dirty', isDirty);
}
if (el('protection-status') && state.config) {
el('protection-status').textContent = tr(state.config.enabled ? 'protectionOn' : 'protectionOff');
el('protection-status').classList.toggle('is-off', !state.config.enabled);
}
if (el('rule-count') && state.config) el('rule-count').textContent = tr('ruleCount', {
enabled: state.config.rules.filter((rule) => rule.enabled).length,
total: state.config.rules.length
});
ruleViews.forEach((view) => {
[view.checkbox, view.remove, ...Object.values(view.fields)].forEach((input) => { input.disabled = !writable; });
// Reading and collapsing an editor never requires write permission.
view.summary.disabled = false;
view.close.disabled = false;
view.validate.disabled = !readable;
updateLocalTestControls(view.tester);
updateRuleView(view);
});
if (state.addDraft) {
const draft = state.addDraft;
Object.values(draft.fields).forEach((input) => { input.disabled = !writable; });
el('add-confirm').disabled = !writable || !canRead() || draft.adding;
el('add-confirm').textContent = tr(draft.adding ? 'addingRule' : 'addToList');
updateLocalTestControls(draft.tester);
}
}
function invalidateTest() {
state.revision += 1;
if (el('test-result')) {
el('test-result').hidden = true;
el('test-result').replaceChildren();
}
}
function openTest(ruleId) {
if (!canRead() || !state.config || state.busy) return;
if (ruleId !== undefined && ruleId !== null) {
const view = ruleViews.get(ruleId);
if (!view) return;
openRule(ruleId);
view.tester.root.hidden = false;
view.validate.setAttribute('aria-expanded', 'true');
updateRuleView(view);
view.tester.args.focus();
return;
}
const panel = el('test-panel');
if (panel) {
panel.open = true;
panel.scrollIntoView({ behavior: 'auto', block: 'start' });
}
if (el('test-arguments')) el('test-arguments').focus({ preventScroll: true });
}
function changed(ruleId) {
invalidateTest();
const view = ruleViews.get(ruleId);
if (view) invalidateLocalTest(view.tester);
feedback('');
ruleViews.forEach((view) => Object.values(view.fields).forEach((input) => input.removeAttribute('aria-invalid')));
updateControls();
}
function textElement(tag, text, className) {
const node = document.createElement(tag);
node.textContent = text == null ? '' : String(text);
if (className) node.className = className;
return node;
}
function ruleField(rule, index, key, label, multiline, maxLength, fields, onChange) {
const field = document.createElement('div');
field.className = 'tool-guard-field tool-guard-field--' + key;
const input = document.createElement(multiline ? 'textarea' : 'input');
input.id = 'tool-guard-rule-' + index + '-' + key;
input.value = rule[key] || '';
input.maxLength = maxLength;
input.spellcheck = false;
if (multiline) input.rows = 3;
else input.type = 'text';
if (key === 'pattern') input.className = 'tool-guard-pattern';
const labelNode = textElement('label', tr(label));
labelNode.htmlFor = input.id;
input.addEventListener('input', () => {
if (!canWrite() || state.busy) return;
rule[key] = input.value;
if (onChange) onChange();
else changed(rule.id);
});
fields[key] = input;
field.append(labelNode, input);
if (key === 'message') {
const hint = textElement('p', tr('messageHint'), 'tool-guard-hint tool-guard-field-hint');
hint.id = input.id + '-hint';
input.setAttribute('aria-describedby', hint.id);
field.append(hint);
}
return field;
}
function updateRuleView(view) {
const { rule, card, summary, editor, title, preview, badge, status, checkbox } = view;
const expanded = state.openRuleId === rule.id;
const name = rule.name.trim() || tr('unnamedRule');
title.textContent = name;
preview.textContent = rule.message.trim() || tr('defaultPreview');
summary.setAttribute('aria-expanded', String(expanded));
summary.setAttribute('aria-label', tr(expanded ? 'collapseRule' : 'expandRule') + ': ' + name);
editor.hidden = !expanded;
card.classList.toggle('is-expanded', expanded);
card.classList.toggle('is-disabled', !rule.enabled);
checkbox.checked = !!rule.enabled;
checkbox.setAttribute('aria-label', tr('ruleEnabled') + ': ' + name);
status.textContent = tr(rule.enabled ? 'ruleOn' : 'ruleOff');
const savedRule = state.saved && state.saved.rules.find((saved) => saved.id === rule.id);
const modified = !!savedRule && JSON.stringify(savedRule) !== JSON.stringify(rule);
badge.hidden = !!savedRule && !modified;
badge.textContent = tr(savedRule ? 'ruleModified' : 'ruleNew');
view.validate.textContent = tr(view.tester.root.hidden ? 'validateRule' : 'closeRuleTest');
}
function openRule(id) {
state.openRuleId = id;
ruleViews.forEach(updateRuleView);
}
function renderRules() {
const target = el('rules');
if (!target || !state.config) return;
ruleViews.forEach((view) => disposeLocalTest(view.tester));
target.replaceChildren();
ruleViews.clear();
if (!state.config.rules.some((rule) => rule.id === state.openRuleId)) state.openRuleId = null;
if (!state.config.rules.length) {
target.append(textElement('p', tr('emptyRules'), 'tool-guard-empty'));
}
state.config.rules.forEach((rule, index) => {
const card = document.createElement('article');
card.className = 'tool-guard-rule';
card.id = 'tool-guard-rule-' + index;
const header = document.createElement('div');
header.className = 'tool-guard-rule-header';
const summary = document.createElement('button');
summary.type = 'button';
summary.id = card.id + '-summary';
summary.className = 'tool-guard-rule-summary';
summary.setAttribute('aria-controls', card.id + '-editor');
summary.addEventListener('click', () => openRule(state.openRuleId === rule.id ? null : rule.id));
const overview = document.createElement('span');
overview.className = 'tool-guard-rule-overview';
const title = textElement('span', '', 'tool-guard-rule-name');
title.id = card.id + '-title';
const preview = textElement('span', '', 'tool-guard-rule-preview');
preview.id = card.id + '-preview';
overview.append(title, preview);
const badge = textElement('span', '', 'tool-guard-rule-badge');
badge.id = card.id + '-badge';
const chevron = textElement('span', '', 'tool-guard-chevron');
chevron.setAttribute('aria-hidden', 'true');
summary.append(textElement('span', String(index + 1).padStart(2, '0'), 'tool-guard-rule-number'), overview, badge, chevron);
const actions = document.createElement('div');
actions.className = 'tool-guard-rule-actions';
const toggle = document.createElement('label');
toggle.className = 'tool-guard-toggle tool-guard-switch';
const checkbox = document.createElement('input');
checkbox.id = card.id + '-enabled';
checkbox.type = 'checkbox';
checkbox.className = 'theme-checkbox';
checkbox.checked = !!rule.enabled;
checkbox.addEventListener('change', () => {
if (!canWrite() || state.busy) return;
rule.enabled = checkbox.checked;
changed(rule.id);
});
const status = textElement('span', '');
toggle.append(checkbox, status);
actions.append(toggle);
const editor = document.createElement('div');
editor.className = 'tool-guard-rule-editor';
editor.id = card.id + '-editor';
editor.setAttribute('aria-labelledby', title.id);
const fields = {};
const grid = document.createElement('div');
grid.className = 'tool-guard-editor-grid';
grid.append(ruleField(rule, index, 'name', 'ruleName', false, 200, fields),
ruleField(rule, index, 'pattern', 'pattern', true, 4096, fields),
ruleField(rule, index, 'message', 'message', true, 4096, fields));
const footer = document.createElement('div');
footer.className = 'tool-guard-editor-footer';
const remove = textElement('button', tr('deleteRule'), 'btn-secondary tool-guard-delete');
remove.id = card.id + '-delete';
remove.type = 'button';
remove.addEventListener('click', () => {
if (!canWrite() || state.busy) return;
if (state.openRuleId === rule.id) state.openRuleId = null;
state.config.rules.splice(index, 1);
renderRules();
changed();
const next = state.config.rules[Math.min(index, state.config.rules.length - 1)];
const focusTarget = next ? ruleViews.get(next.id).summary : el('add');
if (focusTarget) focusTarget.focus();
});
const close = textElement('button', tr('closeEditor'), 'btn-secondary tool-guard-close');
close.id = card.id + '-close';
close.type = 'button';
close.addEventListener('click', () => { openRule(null); summary.focus(); });
const validate = textElement('button', tr('validateRule'), 'btn-secondary tool-guard-rule-validate');
validate.id = card.id + '-validate';
validate.type = 'button';
const tester = createLocalTest(rule, 'rule-' + index + '-test', fields);
tester.root.id = card.id + '-test-panel';
tester.root.classList.toggle('tool-guard-inline-test', true);
tester.root.hidden = true;
validate.setAttribute('aria-controls', tester.root.id);
validate.setAttribute('aria-expanded', 'false');
validate.addEventListener('click', () => {
if (!tester.root.hidden) {
tester.root.hidden = true;
validate.setAttribute('aria-expanded', 'false');
updateRuleView(ruleViews.get(rule.id));
} else openTest(rule.id);
});
const editorActions = document.createElement('div');
editorActions.className = 'tool-guard-editor-actions';
editorActions.append(validate, close);
footer.append(remove, editorActions);
editor.append(grid, footer, tester.root);
header.append(summary, actions);
card.append(header, editor);
ruleViews.set(rule.id, { rule, card, summary, editor, title, preview, badge, status, checkbox, remove, close, validate, fields, tester });
target.append(card);
});
updateControls();
}
function render() {
if (state.config && el('enabled')) el('enabled').checked = !!state.config.enabled;
renderRules();
updateControls();
}
async function request(url, method, body) {
const options = { method: method || 'GET' };
if (body !== undefined) {
options.headers = { 'Content-Type': 'application/json' };
options.body = JSON.stringify(body);
}
const response = await apiFetch(url, options);
const result = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(result.error || tr('requestFailed'));
return result;
}
function normalizeConfig(config) {
if (!config || typeof config.enabled !== 'boolean' || (config.rules != null && !Array.isArray(config.rules))) {
throw new Error(tr('invalidResponse'));
}
return { enabled: config.enabled, rules: (config.rules || []).map((rule) => ({
id: String(rule.id || ''), name: String(rule.name || ''), enabled: !!rule.enabled,
pattern: String(rule.pattern || ''), message: String(rule.message || '')
})) };
}
async function loadConfig() {
if (!el('rules') || !canRead() || state.busy) return;
// Retain the user's draft when navigating away and back.
if (dirty() || state.addDraft) { updateControls(); return; }
state.busy = true;
feedback('');
updateControls();
try {
const config = normalizeConfig(await request('/api/tool-guard'));
state.saved = copy(config);
state.config = config;
invalidateTest();
render();
} catch (error) {
feedback(tr('loadFailed') + ': ' + error.message, true);
} finally {
state.busy = false;
updateControls();
}
}
function configForRequest(selected = null) {
if (!state.config) throw new Error(tr('loadFirst'));
const config = selected ? { enabled: true, rules: [{ ...copy(selected), enabled: true }] } : copy(state.config);
const utf8 = new TextEncoder();
for (const [index, rule] of config.rules.entries()) {
for (const key of ['name', 'pattern', 'message']) {
let message;
if (key !== 'message' && !rule[key].trim()) message = tr('requiredFields');
else if (utf8.encode(rule[key]).length > (key === 'name' ? 200 : 4096)) message = tr('fieldTooLong');
if (message) {
const error = new Error(message);
error.ruleIndex = index;
error.ruleId = rule.id;
error.ruleField = key;
throw error;
}
}
}
// RE2 validation belongs to the backend; JavaScript RegExp has different semantics.
return config;
}
function revealValidationError(error, requestRuleIds) {
let index = error.ruleIndex;
let field = error.ruleField;
if (!Number.isInteger(index)) {
// The server reports the 1-based rule number for RE2 validation errors.
const match = /tool guard rule (\d+)(?: \([^\n]*\))?: (.*)/.exec(error.message);
if (!match) return;
index = Number(match[1]) - 1;
field = /^name\b/.test(match[2]) ? 'name' : /^message\b/.test(match[2]) ? 'message' : 'pattern';
}
const ruleId = error.ruleId || (requestRuleIds && requestRuleIds[index]);
const rule = state.config && (ruleId ? state.config.rules.find((item) => item.id === ruleId) : state.config.rules[index]);
const view = rule && ruleViews.get(rule.id);
if (!view) return;
openRule(rule.id);
const input = view.fields[field];
if (input) {
input.setAttribute('aria-invalid', 'true');
if (input.disabled) view.summary.focus();
else input.focus();
}
}
async function saveConfig() {
if (!canWrite() || state.busy || !dirty()) return;
let failure;
try {
const config = configForRequest();
state.busy = true;
feedback('');
updateControls();
const saved = normalizeConfig(await request('/api/tool-guard', 'PUT', config));
state.saved = copy(saved);
state.config = saved;
invalidateTest();
render();
feedback(tr('saveSuccess'));
} catch (error) {
failure = error;
feedback(tr('saveFailed') + ': ' + error.message, true);
} finally {
state.busy = false;
updateControls();
if (failure) revealValidationError(failure);
}
}
function addRule() {
if (!canWrite() || !state.config || state.busy) return;
const dialog = el('add-dialog');
if (!dialog || state.addDraft) return;
if (state.config.rules.length >= 100) { feedback(tr('tooManyRules'), true); return; }
const id = typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function'
? crypto.randomUUID() : 'rule-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2);
const rule = { id, name: '', enabled: true, pattern: '', message: tr('defaultMessage') };
const draft = { rule, fields: {}, adding: false, revision: 0, returnFocus: document.activeElement || el('add') };
state.addDraft = draft;
const onChange = () => {
draft.revision += 1;
invalidateLocalTest(draft.tester);
showLocalFeedback(el('add-feedback'), '');
Object.values(draft.fields).forEach((input) => input.removeAttribute('aria-invalid'));
};
el('add-fields').replaceChildren(
ruleField(rule, 'draft', 'name', 'ruleName', false, 200, draft.fields, onChange),
ruleField(rule, 'draft', 'pattern', 'pattern', true, 4096, draft.fields, onChange),
ruleField(rule, 'draft', 'message', 'message', true, 4096, draft.fields, onChange)
);
draft.tester = createLocalTest(rule, 'draft-test', draft.fields);
el('add-test').replaceChildren(draft.tester.root);
showLocalFeedback(el('add-feedback'), '');
if (!dialog.dataset.guardBound) {
dialog.dataset.guardBound = 'true';
dialog.addEventListener('cancel', (event) => { event.preventDefault(); closeRuleDialog(); });
dialog.addEventListener('close', () => { if (!dialog.open) closeRuleDialog(); });
dialog.addEventListener('keydown', (event) => {
if (event.key !== 'Tab') return;
const controls = Array.from(dialog.querySelectorAll('button, input, textarea'))
.filter((input) => !input.disabled && !input.hidden);
const first = controls[0];
const last = controls[controls.length - 1];
if (first && event.shiftKey && document.activeElement === first) {
event.preventDefault();
last.focus();
} else if (last && !event.shiftKey && document.activeElement === last) {
event.preventDefault();
first.focus();
}
});
}
updateControls();
dialog.showModal();
draft.fields.name.focus();
}
function closeRuleDialog() {
const draft = state.addDraft;
if (!draft) return;
state.addDraft = null;
disposeLocalTest(draft.tester);
const dialog = el('add-dialog');
if (dialog.open) dialog.close();
el('add-fields').replaceChildren();
el('add-test').replaceChildren();
showLocalFeedback(el('add-feedback'), '');
updateControls();
if (draft.returnFocus) draft.returnFocus.focus();
}
async function commitRule() {
const draft = state.addDraft;
if (!draft || !canWrite() || !canRead() || state.busy || draft.adding) return;
const revision = draft.revision;
try {
if (state.config.rules.length >= 100) throw new Error(tr('tooManyRules'));
const config = configForRequest(draft.rule);
draft.adding = true;
showLocalFeedback(el('add-feedback'), '');
updateControls();
// Validate with the same RE2 engine as saving, independently of the optional test inputs.
const result = await request('/api/tool-guard/test', 'POST', { config, toolName: 'rule_validation', arguments: {} });
if (state.addDraft !== draft || revision !== draft.revision) return;
validateTestResult(result);
state.config.rules.push(copy(draft.rule));
closeRuleDialog();
state.openRuleId = null;
renderRules();
changed();
feedback(tr('ruleAdded'));
const view = ruleViews.get(draft.rule.id);
if (view) view.summary.focus();
} catch (error) {
if (state.addDraft !== draft || revision !== draft.revision) return;
showLocalFeedback(el('add-feedback'), tr('addFailed') + ': ' + error.message);
revealLocalError(draft.tester, error);
} finally {
draft.adding = false;
updateControls();
}
}
function resetConfig() {
if (!canWrite() || state.busy || !state.saved) return;
closeRuleDialog();
state.config = copy(state.saved);
render();
changed();
}
function changeEnabled(enabled) {
if (!canWrite() || !state.config || state.busy) return;
state.config.enabled = !!enabled;
changed();
}
function renderTestResult(result, enabled, single, target = el('test-result')) {
if (!target) return;
target.replaceChildren();
target.hidden = false;
target.classList.toggle('is-blocked', !!result.blocked);
target.classList.toggle('is-single', !!single);
target.append(textElement('strong', tr(single ? (result.blocked ? 'singleMatched' : 'singleNotMatched') :
result.blocked ? 'blocked' : enabled ? 'notBlocked' : 'disabledResult')));
if (single) target.append(textElement('p', tr('singleResultHint'), 'tool-guard-single-result-hint'));
if (result.blocked && result.match) {
const fields = [
['matchedRule', result.match.ruleName || result.match.ruleId],
['matchedText', result.match.matchedText],
['matchedMessage', result.match.message]
];
fields.forEach(([key, value]) => {
target.append(textElement('p', tr(key), 'tool-guard-result-label'));
target.append(textElement('pre', value, 'tool-guard-result-value'));
});
}
}
function showLocalFeedback(target, message) {
target.textContent = message || '';
target.hidden = !message;
target.classList.toggle('is-error', !!message);
}
function reserveDialogTestSpace(tester) {
if (!state.addDraft || state.addDraft.tester !== tester) return;
const body = el('add-body');
const bottomSlack = Math.max(0, body.scrollHeight - body.clientHeight - body.scrollTop);
// Keep only the space needed to avoid clamping the current scroll position.
// Do not retain an entire long result or accumulate its height across tests.
const height = Math.max(180, Math.min(body.clientHeight, tester.output.getBoundingClientRect().height - bottomSlack));
tester.output.style.minHeight = Math.ceil(height) + 'px';
}
function invalidateLocalTest(tester, statusKey = 'testChanged') {
if (!tester) return;
reserveDialogTestSpace(tester);
tester.revision += 1;
tester.result.hidden = true;
tester.result.replaceChildren();
showLocalFeedback(tester.feedback, '');
tester.placeholder.textContent = tr(statusKey);
tester.placeholder.hidden = false;
}
function disposeLocalTest(tester) {
if (!tester) return;
tester.disposed = true;
invalidateLocalTest(tester);
}
function updateLocalTestControls(tester) {
if (!tester) return;
tester.run.disabled = !canRead() || state.busy || tester.testing || tester.disposed;
tester.run.textContent = tr(tester.testing ? 'testing' : 'test');
tester.output.setAttribute('aria-busy', String(tester.testing));
tester.tool.disabled = !canRead() || state.busy;
tester.args.disabled = !canRead() || state.busy;
}
function createLocalTest(rule, prefix, fields) {
const root = document.createElement('section');
root.className = 'tool-guard-local-test';
const title = textElement('h4', tr('singleTestTitle'));
title.id = 'tool-guard-' + prefix + '-title';
root.setAttribute('aria-labelledby', title.id);
root.append(title, textElement('p', tr('singleTestHint'), 'tool-guard-hint'));
const tester = { rule, fields, root, revision: 0, testing: false, disposed: false };
for (const [key, suffix, label, value] of [
['tool', 'tool', 'testTool', 'http_request'],
['args', 'arguments', 'testArguments', '{"url": "https://example.gov.cn"}']
]) {
const field = document.createElement('div');
field.className = 'tool-guard-field';
const input = document.createElement(key === 'tool' ? 'input' : 'textarea');
input.id = 'tool-guard-' + prefix + '-' + suffix;
input.value = value;
input.spellcheck = false;
if (key === 'tool') { input.type = 'text'; input.maxLength = 512; input.autocomplete = 'off'; }
else { input.rows = 4; input.className = 'tool-guard-test-arguments'; }
const labelNode = textElement('label', tr(label));
labelNode.htmlFor = input.id;
input.addEventListener('input', () => {
input.removeAttribute('aria-invalid');
invalidateLocalTest(tester);
});
tester[key] = input;
field.append(labelNode, input);
root.append(field);
}
tester.run = textElement('button', tr('test'), 'btn-secondary tool-guard-test-run');
tester.run.id = 'tool-guard-' + prefix + '-run';
tester.run.type = 'button';
tester.run.addEventListener('click', () => testLocalRule(tester));
tester.feedback = textElement('div', '', 'tool-guard-feedback');
tester.feedback.id = 'tool-guard-' + prefix + '-feedback';
tester.feedback.hidden = true;
tester.feedback.setAttribute('role', 'status');
tester.result = textElement('div', '', 'tool-guard-test-result');
tester.result.id = 'tool-guard-' + prefix + '-result';
tester.result.hidden = true;
tester.result.setAttribute('role', 'status');
tester.output = textElement('div', '', 'tool-guard-local-output');
tester.output.id = 'tool-guard-' + prefix + '-output';
if (prefix === 'draft-test') {
tester.output.setAttribute('role', 'region');
tester.output.setAttribute('aria-label', tr('testResultLabel'));
}
tester.placeholder = textElement('p', tr('testReadyHint'), 'tool-guard-test-placeholder');
tester.placeholder.id = 'tool-guard-' + prefix + '-status';
tester.placeholder.setAttribute('role', 'status');
tester.output.append(tester.placeholder, tester.feedback, tester.result);
root.append(tester.run, tester.output);
updateLocalTestControls(tester);
return tester;
}
function revealLocalError(tester, error) {
const match = /tool guard rule \d+(?: \([^\n]*\))?: (.*)/.exec(error.message);
const field = error.ruleField || (match ? /^name\b/.test(match[1]) ? 'name' :
/^message\b/.test(match[1]) ? 'message' : 'pattern' : null);
const input = error.input || tester.fields[field];
if (!input) return;
input.setAttribute('aria-invalid', 'true');
// Async feedback stays with its rule instead of reopening another editor.
if ((state.addDraft && state.addDraft.tester === tester) || state.openRuleId === tester.rule.id) {
if (!input.disabled) input.focus();
}
}
function validateTestResult(result) {
if (!result || typeof result.blocked !== 'boolean' || (result.blocked && (!result.match ||
typeof result.match.matchedText !== 'string' || typeof result.match.message !== 'string'))) {
throw new Error(tr('invalidTestResponse'));
}
}
async function testLocalRule(tester) {
if (!canRead() || state.busy || tester.testing || tester.disposed || !state.config) return;
invalidateLocalTest(tester, 'testing');
const revision = tester.revision;
try {
const config = configForRequest(tester.rule);
const toolName = tester.tool.value.trim();
if (!toolName) {
const error = new Error(tr('toolRequired'));
error.input = tester.tool;
throw error;
}
let args;
try {
args = JSON.parse(tester.args.value);
if (!args || Array.isArray(args) || typeof args !== 'object') throw new Error();
} catch (_) {
const error = new Error(tr('invalidArguments'));
error.input = tester.args;
throw error;
}
tester.testing = true;
updateLocalTestControls(tester);
const result = await request('/api/tool-guard/test', 'POST', { config, toolName, arguments: args });
if (tester.disposed || revision !== tester.revision) return;
validateTestResult(result);
tester.placeholder.hidden = true;
renderTestResult(result, true, true, tester.result);
} catch (error) {
if (tester.disposed || revision !== tester.revision) return;
tester.placeholder.hidden = true;
showLocalFeedback(tester.feedback, tr('testFailed') + ': ' + error.message);
revealLocalError(tester, error);
} finally {
tester.testing = false;
updateLocalTestControls(tester);
}
}
async function testConfig() {
if (!canRead() || state.busy || state.testing || !state.config) return;
let revision;
let requestRuleIds;
try {
invalidateTest();
const config = configForRequest();
requestRuleIds = config.rules.map((rule) => rule.id);
const toolName = el('test-tool').value.trim();
if (!toolName) throw new Error(tr('toolRequired'));
let args;
try { args = JSON.parse(el('test-arguments').value); }
catch (_) { throw new Error(tr('invalidArguments')); }
if (!args || Array.isArray(args) || typeof args !== 'object') throw new Error(tr('invalidArguments'));
revision = state.revision;
state.testing = true;
feedback('');
updateControls();
const result = await request('/api/tool-guard/test', 'POST', { config, toolName, arguments: args });
if (state.revision !== revision) return;
validateTestResult(result);
renderTestResult(result, config.enabled, false);
} catch (error) {
if (revision === undefined || revision === state.revision) {
feedback(tr('testFailed') + ': ' + error.message, true);
revealValidationError(error, requestRuleIds);
}
} finally {
state.testing = false;
updateControls();
}
}
window.loadToolGuardConfig = loadConfig;
window.saveToolGuardConfig = saveConfig;
window.addToolGuardRule = addRule;
window.closeToolGuardRuleDialog = closeRuleDialog;
window.commitToolGuardRule = commitRule;
window.resetToolGuardConfig = resetConfig;
window.changeToolGuardEnabled = changeEnabled;
window.openToolGuardTest = openTest;
window.testToolGuardConfig = testConfig;
window.invalidateToolGuardTest = invalidateTest;
document.addEventListener('languagechange', () => {
render();
invalidateTest();
feedback('');
});
})();
+832
View File
@@ -0,0 +1,832 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const vm = require('node:vm');
const source = fs.readFileSync('web/static/js/tool-guard.js', 'utf8');
const translations = JSON.parse(fs.readFileSync('web/static/i18n/en-US.json', 'utf8')).toolGuard;
function harness(permissions = ['config:read', 'config:write']) {
const nodes = new Map();
class Element {
constructor(tag = 'div') {
this.tagName = tag.toUpperCase();
this.children = [];
this.listeners = {};
this.dataset = {};
this.style = {};
this.rect = { height: 180 };
this.attributes = new Map();
this.classList = { toggle: (name, enabled) => {
const names = new Set((this.className || '').split(' ').filter(Boolean));
if (enabled) names.add(name); else names.delete(name);
this.className = [...names].join(' ');
} };
this.value = '';
this.hidden = false;
this.open = false;
this.textContent = '';
}
set id(value) { this._id = value; nodes.set(value, this); }
get id() { return this._id; }
set innerHTML(_) { throw new Error('Untrusted values must never use innerHTML'); }
append(...children) { this.children.push(...children); }
replaceChildren(...children) {
const detach = (node) => { if (node.id) nodes.delete(node.id); node.children.forEach(detach); };
this.children.forEach(detach);
this.children = children;
}
addEventListener(type, fn) { this.listeners[type] = fn; }
setAttribute(name, value) { this.attributes.set(name, String(value)); }
getAttribute(name) { return this.attributes.get(name) ?? null; }
removeAttribute(name) { this.attributes.delete(name); }
focus() { if (!this.disabled) document.activeElement = this; }
showModal() { this.open = true; }
close() { this.open = false; if (this.listeners.close) this.listeners.close(); }
getBoundingClientRect() { return this.rect; }
scrollIntoView(options) { this.scrolledIntoView = options; }
querySelectorAll(selector) {
const tags = selector.split(',').map((tag) => tag.trim().toUpperCase());
return this.children.flatMap((child) => [
...(tags.includes(child.tagName) ? [child] : []), ...child.querySelectorAll(selector)
]);
}
}
const document = {
getElementById: (id) => nodes.get(id),
createElement: (tag) => new Element(tag),
activeElement: null,
addEventListener() {}
};
['rules', 'enabled', 'add', 'save', 'reset', 'test', 'save-state', 'feedback', 'test-result', 'test-tool', 'test-arguments',
'protection-status', 'rule-count', 'open-test', 'test-panel', 'add-dialog', 'add-confirm', 'add-feedback',
'add-fields', 'add-test', 'add-body'].forEach((id) => {
const node = new Element();
node.id = 'tool-guard-' + id;
});
nodes.get('tool-guard-test-tool').value = 'http_request';
nodes.get('tool-guard-test-arguments').value = '{"url":"https://example.gov.cn"}';
Object.assign(nodes.get('tool-guard-add-body'), { clientHeight: 500, scrollHeight: 500, scrollTop: 0 });
const calls = [];
const queue = [];
const window = { t: (key) => translations[key.slice('toolGuard.'.length)] || key };
let newRuleNumber = 0;
const context = vm.createContext({ window, document, TextEncoder, crypto: { randomUUID: () => 'new-rule-' + ++newRuleNumber },
hasPermission: (permission) => permissions.includes(permission),
apiFetch: async (url, options) => {
calls.push({ url, options });
if (!queue.length) throw new Error('Unexpected request');
return queue.shift()();
}
});
vm.runInContext(source, context);
const reply = (body, ok = true) => queue.push(async () => ({ ok, json: async () => body }));
const element = (id) => nodes.get('tool-guard-' + id);
const text = (node) => [node.textContent, ...node.children.map(text)].join('\n');
return { window, document, reply, queue, calls, element, text };
}
function config() {
return { enabled: true, rules: [{ id: 'gov', name: 'Government domains', enabled: true,
pattern: '(?i)\\.gov\\b', message: 'Detected {match}' }] };
}
function fillField(h, id, value) {
const input = h.element(id);
assert.ok(input, 'Missing input: ' + id);
input.value = value;
input.listeners.input();
}
function fillDraft(h, values = {}) {
for (const [field, value] of Object.entries({ name: 'New protection', pattern: '(?i)\\.edu',
message: 'Detected {match} for {rule}', ...values })) {
fillField(h, 'rule-draft-' + field, value);
}
}
function runLocal(h, prefix) {
return h.element(prefix + '-test-run').listeners.click();
}
test('test uses the unsaved configuration and backend RE2 validation without saving or executing tools', async () => {
const h = harness();
h.reply(config());
await h.window.loadToolGuardConfig();
const pattern = h.element('rule-0-pattern');
pattern.value = '(?i)\\.gov'; // RE2 inline flag is not valid JavaScript RegExp syntax.
pattern.listeners.input();
h.reply({ blocked: true, match: { ruleId: 'gov', ruleName: 'Government domains', matchedText: '.gov', message: 'Detected .gov' } });
await h.window.testToolGuardConfig();
assert.equal(h.calls.length, 2);
assert.equal(h.calls[1].url, '/api/tool-guard/test');
const body = JSON.parse(h.calls[1].options.body);
assert.equal(body.config.rules[0].pattern, '(?i)\\.gov');
assert.deepEqual(body.arguments, { url: 'https://example.gov.cn' });
assert.match(h.text(h.element('test-result')), /Detected \.gov/);
assert.equal(h.element('save').disabled, false);
});
test('unsafe rule and match text is rendered as text; messages cannot inject markup', async () => {
const h = harness();
const attack = '<img src=x onerror=alert(1)>';
const initial = config();
initial.rules[0].name = attack;
initial.rules[0].message = attack;
h.reply(initial);
await h.window.loadToolGuardConfig();
assert.equal(h.element('rule-0-name').value, attack);
h.reply({ blocked: true, match: { ruleName: attack, matchedText: attack, message: attack } });
await h.window.testToolGuardConfig();
assert.match(h.text(h.element('test-result')), /<img src=x onerror=alert\(1\)>/);
assert.equal(h.element('test-result').querySelectorAll('img').length, 0);
});
test('draft survives navigation, save errors preserve it, and discard restores last server state', async () => {
const h = harness();
h.reply(config());
await h.window.loadToolGuardConfig();
h.window.changeToolGuardEnabled(false);
await h.window.loadToolGuardConfig();
assert.equal(h.calls.length, 1);
h.reply({ error: 'Invalid RE2 expression' }, false);
await h.window.saveToolGuardConfig();
assert.match(h.element('feedback').textContent, /Invalid RE2 expression/);
assert.equal(h.element('save').disabled, false);
h.window.resetToolGuardConfig();
assert.equal(h.element('enabled').checked, true);
assert.equal(h.element('save').disabled, true);
assert.equal(h.calls.length, 2);
});
test('successful save explicitly persists enabled and all rule fields through dedicated endpoint', async () => {
const h = harness();
h.reply(config());
await h.window.loadToolGuardConfig();
h.window.changeToolGuardEnabled(false);
const saved = config();
saved.enabled = false;
h.reply(saved);
await h.window.saveToolGuardConfig();
assert.equal(h.calls[1].url, '/api/tool-guard');
assert.equal(h.calls[1].options.method, 'PUT');
assert.deepEqual(JSON.parse(h.calls[1].options.body), saved);
assert.equal(h.element('save').disabled, true);
});
test('read-only users can test but cannot mutate the configuration', async () => {
const h = harness(['config:read']);
h.reply(config());
await h.window.loadToolGuardConfig();
assert.equal(h.element('enabled').disabled, true);
assert.equal(h.element('rule-0-name').disabled, true);
h.window.changeToolGuardEnabled(false);
h.window.addToolGuardRule();
await h.window.saveToolGuardConfig();
assert.equal(h.calls.length, 1);
h.reply({ blocked: false });
await h.window.testToolGuardConfig();
assert.equal(JSON.parse(h.calls[1].options.body).config.enabled, true);
});
test('test rejects non-object arguments before making an API request', async () => {
const h = harness();
h.reply(config());
await h.window.loadToolGuardConfig();
for (const input of ['[]', 'null', '"https://example.gov"', '{']) {
h.element('test-arguments').value = input;
await h.window.testToolGuardConfig();
assert.match(h.element('feedback').textContent, /valid JSON object/);
}
assert.equal(h.calls.length, 1);
});
test('stale dry-run responses cannot claim to describe edited rules', async () => {
const h = harness();
h.reply(config());
await h.window.loadToolGuardConfig();
let finish;
h.queue.push(() => new Promise((resolve) => { finish = resolve; }));
const pending = h.window.testToolGuardConfig();
h.window.changeToolGuardEnabled(false);
finish({ ok: true, json: async () => ({ blocked: true, match: { matchedText: '.gov' } }) });
await pending;
assert.equal(h.element('test-result').hidden, true);
});
test('UTF-8 byte limits are enforced before saving multi-byte rule names', async () => {
const h = harness();
h.reply(config());
await h.window.loadToolGuardConfig();
h.element('rule-0-name').value = '政'.repeat(67);
h.element('rule-0-name').listeners.input();
await h.window.saveToolGuardConfig();
assert.equal(h.calls.length, 1);
assert.match(h.element('feedback').textContent, /200 UTF-8 bytes/);
});
test('malformed dry-run responses report an error instead of claiming the call is allowed', async () => {
const h = harness();
h.reply(config());
await h.window.loadToolGuardConfig();
h.reply({});
await h.window.testToolGuardConfig();
assert.equal(h.element('test-result').hidden, true);
assert.match(h.element('feedback').textContent, /invalid test result/);
});
test('saved rules start collapsed behind native accessible buttons without making the configuration dirty', async () => {
const h = harness();
h.reply(config());
await h.window.loadToolGuardConfig();
const summary = h.element('rule-0-summary');
assert.equal(summary.tagName, 'BUTTON');
assert.equal(summary.type, 'button');
assert.equal(summary.getAttribute('aria-expanded'), 'false');
assert.equal(summary.getAttribute('aria-controls'), h.element('rule-0-editor').id);
assert.equal(h.element('rule-0-editor').hidden, true);
assert.equal(h.element('rule-0-title').textContent, 'Government domains');
assert.equal(h.element('rule-0-preview').textContent, 'Detected {match}');
assert.equal(h.element('rule-0-badge').hidden, true);
summary.listeners.click();
assert.equal(summary.getAttribute('aria-expanded'), 'true');
assert.equal(h.element('rule-0-editor').hidden, false);
summary.listeners.click();
assert.equal(h.element('rule-0-editor').hidden, true);
assert.equal(h.element('save').disabled, true);
assert.equal(h.calls.length, 1);
});
test('only one rule opens at a time and editing updates safe summaries without replacing inputs or losing drafts', async () => {
const h = harness();
const initial = config();
initial.rules.push({ ...initial.rules[0], id: 'second', name: 'Second rule' });
h.reply(initial);
await h.window.loadToolGuardConfig();
h.element('rule-0-summary').listeners.click();
const input = h.element('rule-0-name');
input.focus();
input.value = '<img src=x onerror=alert(1)>';
input.listeners.input();
assert.equal(h.element('rule-0-name'), input);
assert.equal(h.document.activeElement, input);
assert.equal(h.element('rule-0-title').textContent, input.value);
assert.equal(h.element('rule-0-badge').hidden, false);
const reminder = h.element('rule-0-message');
reminder.value = 'Updated {match} reminder';
reminder.listeners.input();
assert.equal(h.element('rule-0-preview').textContent, reminder.value);
h.element('rule-1-summary').listeners.click();
assert.equal(h.element('rule-0-editor').hidden, true);
assert.equal(h.element('rule-1-editor').hidden, false);
h.element('rule-0-summary').listeners.click();
assert.equal(h.element('rule-1-editor').hidden, true);
assert.equal(h.element('rule-0-name').value, input.value);
assert.equal(h.element('rules').querySelectorAll('img').length, 0);
h.element('rule-0-close').listeners.click();
assert.equal(h.document.activeElement, h.element('rule-0-summary'));
assert.equal(h.element('rule-0-editor').hidden, true);
const saved = config();
saved.rules = initial.rules.map((rule, index) => index === 0 ? { ...rule, name: input.value, message: reminder.value } : rule);
h.reply(saved);
await h.window.saveToolGuardConfig();
assert.equal(JSON.parse(h.calls[1].options.body).rules[0].name, input.value);
assert.equal(h.element('rule-0-editor').hidden, true);
assert.equal(h.element('rule-0-badge').hidden, true);
});
test('adding a rule opens an isolated dialog; cancellation leaves no phantom row or dirty configuration', async () => {
const h = harness();
h.reply(config());
await h.window.loadToolGuardConfig();
h.element('rule-0-summary').listeners.click();
h.window.addToolGuardRule();
assert.equal(h.element('add-dialog').open, true);
assert.equal(h.document.activeElement, h.element('rule-draft-name'));
assert.equal(h.element('rule-1-editor'), undefined);
assert.equal(h.element('save').disabled, true);
fillDraft(h);
assert.equal(h.element('save').disabled, true);
h.window.closeToolGuardRuleDialog();
assert.equal(h.element('add-dialog').open, false);
assert.equal(h.element('rule-1-editor'), undefined);
assert.equal(h.element('save').disabled, true);
assert.equal(h.calls.length, 1);
h.window.addToolGuardRule();
assert.equal(h.element('rule-draft-name').value, '');
assert.equal(h.element('rule-draft-pattern').value, '');
assert.equal(h.document.activeElement, h.element('rule-draft-name'));
});
test('read-only users can expand and close rule details while all mutation controls stay disabled', async () => {
const h = harness(['config:read']);
h.reply(config());
await h.window.loadToolGuardConfig();
assert.equal(h.element('rule-0-summary').disabled, false);
assert.equal(h.element('rule-0-close').disabled, false);
assert.equal(h.element('rule-0-delete').disabled, true);
assert.equal(h.element('rule-0-enabled').disabled, true);
h.element('rule-0-summary').listeners.click();
assert.equal(h.element('rule-0-editor').hidden, false);
h.element('rule-0-close').listeners.click();
assert.equal(h.element('rule-0-editor').hidden, true);
assert.equal(h.document.activeElement, h.element('rule-0-summary'));
});
test('saving an invalid collapsed draft opens and focuses the first invalid field before any request', async () => {
const h = harness();
h.reply(config());
await h.window.loadToolGuardConfig();
fillField(h, 'rule-0-name', '');
fillField(h, 'rule-0-pattern', '');
await h.window.saveToolGuardConfig();
assert.equal(h.element('rule-0-editor').hidden, false);
assert.equal(h.document.activeElement, h.element('rule-0-name'));
assert.equal(h.element('rule-0-name').getAttribute('aria-invalid'), 'true');
assert.equal(h.calls.length, 1);
fillField(h, 'rule-0-name', 'New protection');
assert.equal(h.element('rule-0-name').getAttribute('aria-invalid'), null);
h.element('rule-0-close').listeners.click();
await h.window.testToolGuardConfig();
assert.equal(h.element('rule-0-editor').hidden, false);
assert.equal(h.document.activeElement, h.element('rule-0-pattern'));
assert.equal(h.calls.length, 1);
});
test('backend RE2 errors reveal the affected collapsed rule after controls become writable again', async () => {
const h = harness();
h.reply(config());
await h.window.loadToolGuardConfig();
h.element('rule-0-pattern').value = '(';
h.element('rule-0-pattern').listeners.input();
h.reply({ error: 'tool guard rule 1 (gov): invalid regular expression: error parsing regexp: missing closing )' }, false);
await h.window.saveToolGuardConfig();
assert.equal(h.element('rule-0-editor').hidden, false);
assert.equal(h.document.activeElement, h.element('rule-0-pattern'));
assert.equal(h.element('rule-0-pattern').disabled, false);
assert.equal(h.element('rule-0-pattern').getAttribute('aria-invalid'), 'true');
assert.equal(h.element('rule-0-pattern').value, '(');
assert.equal(h.element('save').disabled, false);
});
test('deleting rules maintains the remaining row identity and gives focus to the next summary or add button', async () => {
const h = harness();
const initial = config();
initial.rules.push({ ...initial.rules[0], id: 'second', name: 'Second rule' });
h.reply(initial);
await h.window.loadToolGuardConfig();
h.element('rule-0-summary').listeners.click();
h.element('rule-0-delete').listeners.click();
assert.equal(h.element('rule-0-title').textContent, 'Second rule');
assert.equal(h.document.activeElement, h.element('rule-0-summary'));
assert.equal(h.element('rule-0-editor').hidden, true);
h.element('rule-0-summary').listeners.click();
h.element('rule-0-delete').listeners.click();
assert.equal(h.document.activeElement, h.element('add'));
assert.match(h.text(h.element('rules')), /No rules/);
});
test('compact status and enabled counts track draft toggles independently of accordion expansion', async () => {
const h = harness();
h.reply(config());
await h.window.loadToolGuardConfig();
assert.equal(h.element('protection-status').textContent, translations.protectionOn);
assert.equal(h.element('rule-count').textContent, translations.ruleCount.replace('{{enabled}}', '1').replace('{{total}}', '1'));
const toggle = h.element('rule-0-enabled');
assert.match(toggle.getAttribute('aria-label'), /Government domains/);
toggle.checked = false;
toggle.listeners.change();
assert.equal(h.element('rule-count').textContent, translations.ruleCount.replace('{{enabled}}', '0').replace('{{total}}', '1'));
assert.equal(h.element('rule-0-editor').hidden, true);
assert.equal(h.element('rule-0-badge').hidden, false);
h.window.changeToolGuardEnabled(false);
assert.equal(h.element('protection-status').textContent, translations.protectionOff);
});
test('single-rule validation opens next to its editor without redirecting to global validation', async () => {
const h = harness();
h.reply(config());
await h.window.loadToolGuardConfig();
h.element('rule-0-validate').listeners.click();
assert.equal(h.element('rule-0-editor').hidden, false);
assert.equal(h.element('rule-0-test-panel').hidden, false);
assert.equal(h.element('test-panel').open, false);
assert.equal(h.document.activeElement, h.element('rule-0-test-arguments'));
assert.equal(h.element('save').disabled, true);
assert.equal(h.calls.length, 1);
h.window.openToolGuardTest();
assert.equal(h.element('test-panel').open, true);
assert.equal(h.document.activeElement, h.element('test-arguments'));
assert.equal(h.element('rule-0-test-panel').hidden, false);
});
test('local validation isolates the current rule from unrelated invalid drafts and displays its result locally', async () => {
const h = harness();
const initial = config();
initial.rules.push({ ...initial.rules[0], id: 'second', name: 'Second rule' });
h.reply(initial);
await h.window.loadToolGuardConfig();
fillField(h, 'rule-0-name', '');
fillField(h, 'rule-1-pattern', '(?i)\\.edu');
h.window.openToolGuardTest('second');
fillField(h, 'rule-1-test-arguments', '{"url":"https://example.edu"}');
h.reply({ blocked: true, match: { ruleId: 'second', ruleName: 'Second rule', matchedText: '.edu', message: 'Detected .edu' } });
await runLocal(h, 'rule-1');
const body = JSON.parse(h.calls[1].options.body);
assert.deepEqual(body.config.rules, [{ ...initial.rules[1], pattern: '(?i)\\.edu' }]);
assert.deepEqual(body.arguments, { url: 'https://example.edu' });
assert.match(h.text(h.element('rule-1-test-result')), /Detected \.edu/);
assert.equal(h.element('rule-1-test-result').children[0].textContent, translations.singleMatched);
assert.match(h.element('rule-1-test-result').className, /is-single/);
assert.equal(h.element('test-result').hidden, true);
assert.equal(h.element('test-panel').open, false);
assert.equal(h.element('save').disabled, false);
await h.window.testToolGuardConfig();
assert.equal(h.calls.length, 2);
assert.equal(h.document.activeElement, h.element('rule-0-name'));
});
test('single validation forces flags only in its copied payload while global validation retains order and disabled state', async () => {
const h = harness();
const initial = config();
initial.enabled = false;
initial.rules[0].enabled = false;
initial.rules.push({ ...initial.rules[0], id: 'second', name: 'Second rule', enabled: true });
h.reply(initial);
await h.window.loadToolGuardConfig();
h.window.openToolGuardTest('gov');
h.reply({ blocked: false });
await runLocal(h, 'rule-0');
const singleBody = JSON.parse(h.calls[1].options.body);
assert.equal(singleBody.config.enabled, true);
assert.deepEqual(singleBody.config.rules, [{ ...initial.rules[0], enabled: true }]);
assert.equal(h.element('rule-0-test-result').children[0].textContent, translations.singleNotMatched);
assert.equal(h.element('enabled').checked, false);
assert.equal(h.element('rule-0-enabled').checked, false);
assert.equal(h.element('save').disabled, true);
h.window.openToolGuardTest();
h.reply({ blocked: false });
await h.window.testToolGuardConfig();
assert.deepEqual(JSON.parse(h.calls[2].options.body).config, initial);
assert.equal(h.element('test-result').children[0].textContent, translations.disabledResult);
assert.equal(h.element('rule-0-test-result').hidden, false);
assert.doesNotMatch(h.element('test-result').className, /is-single/);
});
test('local and global validations can run concurrently and keep distinct inputs and results', async () => {
const h = harness();
h.reply(config());
await h.window.loadToolGuardConfig();
h.window.openToolGuardTest('gov');
fillField(h, 'rule-0-test-tool', 'local_preview');
fillField(h, 'rule-0-test-arguments', '{"url":"https://example.com"}');
let finishLocal;
h.queue.push(() => new Promise((resolve) => { finishLocal = resolve; }));
const local = runLocal(h, 'rule-0');
assert.equal(h.element('rule-0-test-run').disabled, true);
assert.equal(h.element('test').disabled, false);
h.window.openToolGuardTest();
h.reply({ blocked: true, match: { ruleId: 'gov', ruleName: 'Government domains', matchedText: '.gov', message: 'Global result' } });
await h.window.testToolGuardConfig();
finishLocal({ ok: true, json: async () => ({ blocked: false }) });
await local;
assert.equal(JSON.parse(h.calls[1].options.body).toolName, 'local_preview');
assert.equal(JSON.parse(h.calls[2].options.body).toolName, 'http_request');
assert.equal(h.element('rule-0-test-result').children[0].textContent, translations.singleNotMatched);
assert.match(h.text(h.element('test-result')), /Global result/);
assert.equal(h.element('rule-0-test-run').disabled, false);
assert.equal(h.element('test').disabled, false);
});
test('editing a local rule or its sample ignores stale successful responses and RE2 errors', async () => {
for (const target of ['rule-0-pattern', 'rule-0-test-arguments']) {
for (const ok of [true, false]) {
const h = harness();
h.reply(config());
await h.window.loadToolGuardConfig();
h.window.openToolGuardTest('gov');
let finish;
h.queue.push(() => new Promise((resolve) => { finish = resolve; }));
const pending = runLocal(h, 'rule-0');
fillField(h, target, target.endsWith('pattern') ? '(?i)\\.edu' : '{"url":"https://example.edu"}');
finish({ ok, json: async () => ok ? { blocked: false } :
{ error: 'tool guard rule 1 (gov): invalid regular expression' } });
await pending;
assert.equal(h.element('rule-0-test-result').hidden, true);
assert.equal(h.element('rule-0-test-feedback').hidden, true);
assert.equal(h.element('rule-0-pattern').getAttribute('aria-invalid'), null);
assert.equal(h.element('feedback').hidden, true);
assert.equal(h.element('rule-0-test-run').disabled, false);
}
}
});
test('deleting a rule invalidates its pending local response without mislabeling the remaining row', async () => {
const h = harness();
const initial = config();
initial.rules.push({ ...initial.rules[0], id: 'second', name: 'Second rule' });
h.reply(initial);
await h.window.loadToolGuardConfig();
h.window.openToolGuardTest('gov');
let finish;
h.queue.push(() => new Promise((resolve) => { finish = resolve; }));
const pending = runLocal(h, 'rule-0');
h.element('rule-0-delete').listeners.click();
finish({ ok: false, json: async () => ({ error: 'tool guard rule 1 (gov): invalid regular expression' }) });
await pending;
assert.equal(h.element('rule-0-title').textContent, 'Second rule');
assert.equal(h.element('rule-0-pattern').getAttribute('aria-invalid'), null);
assert.equal(h.element('rule-0-test-feedback').hidden, true);
assert.equal(h.element('feedback').hidden, true);
assert.equal(h.element('test-panel').open, false);
});
test('local RE2 errors and local required fields focus the selected rule, with no global feedback', async () => {
const h = harness();
const initial = config();
initial.rules.push({ ...initial.rules[0], id: 'second', name: 'Second rule' });
h.reply(initial);
await h.window.loadToolGuardConfig();
fillField(h, 'rule-1-pattern', '(');
h.window.openToolGuardTest('second');
h.reply({ error: 'tool guard rule 1 (second): invalid regular expression: error parsing regexp: missing closing )' }, false);
await runLocal(h, 'rule-1');
assert.equal(h.element('rule-0-editor').hidden, true);
assert.equal(h.element('rule-1-editor').hidden, false);
assert.equal(h.document.activeElement, h.element('rule-1-pattern'));
assert.equal(h.element('rule-1-pattern').getAttribute('aria-invalid'), 'true');
assert.equal(h.element('rule-0-pattern').getAttribute('aria-invalid'), null);
assert.match(h.element('rule-1-test-feedback').textContent, /invalid regular expression/);
assert.equal(h.element('feedback').hidden, true);
fillField(h, 'rule-1-name', '');
await runLocal(h, 'rule-1');
assert.equal(h.calls.length, 2);
assert.equal(h.element('rule-1-editor').hidden, false);
assert.equal(h.document.activeElement, h.element('rule-1-name'));
assert.equal(h.element('rule-1-name').getAttribute('aria-invalid'), 'true');
});
test('read-only users can validate existing rules but cannot create or commit new rules', async () => {
const h = harness(['config:read']);
h.reply(config());
await h.window.loadToolGuardConfig();
assert.equal(h.element('rule-0-validate').disabled, false);
h.element('rule-0-validate').listeners.click();
h.reply({ blocked: false });
await runLocal(h, 'rule-0');
assert.equal(JSON.parse(h.calls[1].options.body).config.rules[0].id, 'gov');
assert.equal(h.element('rule-0-name').disabled, true);
h.window.addToolGuardRule();
await h.window.commitToolGuardRule();
assert.equal(h.element('add-dialog').open, false);
assert.equal(h.element('rule-1-summary'), undefined);
assert.equal(h.calls.length, 2);
const noRead = harness(['config:write']);
await noRead.window.loadToolGuardConfig();
noRead.window.openToolGuardTest('gov');
await noRead.window.testToolGuardConfig();
assert.equal(noRead.calls.length, 0);
assert.equal(noRead.element('test-panel').open, false);
});
test('new rule can be tested before adding without modifying, saving, or opening global validation', async () => {
const h = harness();
h.reply(config());
await h.window.loadToolGuardConfig();
h.window.addToolGuardRule();
fillDraft(h);
fillField(h, 'draft-test-arguments', '{"url":"https://example.edu"}');
h.reply({ blocked: true, match: { ruleId: 'new-rule-1', ruleName: 'New protection', matchedText: '.edu', message: 'Detected .edu for New protection' } });
await runLocal(h, 'draft');
assert.equal(h.calls[1].url, '/api/tool-guard/test');
assert.equal(h.calls[1].options.method, 'POST');
const body = JSON.parse(h.calls[1].options.body);
assert.equal(body.config.enabled, true);
assert.equal(body.config.rules.length, 1);
assert.equal(body.config.rules[0].name, 'New protection');
assert.equal(body.config.rules[0].pattern, '(?i)\\.edu');
assert.match(h.text(h.element('draft-test-result')), /Detected \.edu for New protection/);
assert.equal(h.element('save').disabled, true);
assert.equal(h.element('rule-1-summary'), undefined);
assert.equal(h.element('add-dialog').open, true);
assert.equal(h.element('test-panel').open, false);
});
test('confirming a new rule validates RE2 on the server and then appends a collapsed unsaved draft', async () => {
const h = harness();
h.reply(config());
await h.window.loadToolGuardConfig();
h.window.addToolGuardRule();
fillDraft(h);
// Test-sample mistakes must not prevent adding a valid rule.
fillField(h, 'draft-test-arguments', '{');
let finish;
h.queue.push(() => new Promise((resolve) => { finish = resolve; }));
const pending = h.window.commitToolGuardRule();
assert.equal(h.element('rule-1-summary'), undefined);
assert.equal(h.element('save').disabled, true);
assert.equal(h.calls[1].url, '/api/tool-guard/test');
assert.equal(h.calls[1].options.method, 'POST');
const body = JSON.parse(h.calls[1].options.body);
assert.equal(body.toolName, 'rule_validation');
assert.deepEqual(body.arguments, {});
assert.equal(body.config.rules.length, 1);
assert.equal(body.config.rules[0].name, 'New protection');
finish({ ok: true, json: async () => ({ blocked: false }) });
await pending;
assert.equal(h.element('add-dialog').open, false);
assert.equal(h.element('rule-1-title').textContent, 'New protection');
assert.equal(h.element('rule-1-editor').hidden, true);
assert.equal(h.element('rule-1-badge').textContent, translations.ruleNew);
assert.equal(h.element('rule-1-badge').hidden, false);
assert.equal(h.element('save').disabled, false);
assert.equal(h.calls.filter(({ options }) => options.method === 'PUT').length, 0);
h.window.resetToolGuardConfig();
assert.equal(h.element('rule-1-summary'), undefined);
assert.equal(h.element('save').disabled, true);
});
test('invalid new-rule RE2 stays in the dialog with focused field and does not append', async () => {
const h = harness();
h.reply(config());
await h.window.loadToolGuardConfig();
h.window.addToolGuardRule();
fillDraft(h, { pattern: '(' });
h.reply({ error: 'tool guard rule 1 (new-rule-1): invalid regular expression: error parsing regexp: missing closing )' }, false);
await h.window.commitToolGuardRule();
assert.equal(h.element('add-dialog').open, true);
assert.equal(h.element('rule-draft-pattern').value, '(');
assert.equal(h.element('rule-draft-pattern').getAttribute('aria-invalid'), 'true');
assert.equal(h.document.activeElement, h.element('rule-draft-pattern'));
assert.match(h.element('add-feedback').textContent, /invalid regular expression/);
assert.equal(h.element('rule-1-summary'), undefined);
assert.equal(h.element('save').disabled, true);
assert.equal(h.element('feedback').hidden, true);
});
test('new-rule required-field and byte-limit errors are shown locally before any request', async () => {
const h = harness();
h.reply(config());
await h.window.loadToolGuardConfig();
h.window.addToolGuardRule();
await h.window.commitToolGuardRule();
assert.equal(h.document.activeElement, h.element('rule-draft-name'));
assert.equal(h.element('rule-draft-name').getAttribute('aria-invalid'), 'true');
fillDraft(h, { name: '政'.repeat(67) });
await h.window.commitToolGuardRule();
assert.match(h.element('add-feedback').textContent, /200 UTF-8 bytes/);
assert.equal(h.calls.length, 1);
assert.equal(h.element('rule-1-summary'), undefined);
assert.equal(h.element('save').disabled, true);
});
test('canceling or editing a pending new-rule commit cannot append a stale draft', async () => {
for (const action of ['close', 'cancel', 'edit']) {
const h = harness();
h.reply(config());
await h.window.loadToolGuardConfig();
h.window.addToolGuardRule();
fillDraft(h);
let finish;
h.queue.push(() => new Promise((resolve) => { finish = resolve; }));
const pending = h.window.commitToolGuardRule();
if (action === 'close') h.window.closeToolGuardRuleDialog();
else if (action === 'cancel') h.element('add-dialog').listeners.cancel({ preventDefault() {} });
else fillField(h, 'rule-draft-name', 'Changed while validating');
finish({ ok: true, json: async () => ({ blocked: false }) });
await pending;
assert.equal(h.element('rule-1-summary'), undefined, action);
assert.equal(h.element('save').disabled, true, action);
assert.equal(h.element('add-dialog').open, action === 'edit', action);
if (action === 'edit') assert.equal(h.element('rule-draft-name').value, 'Changed while validating');
}
});
test('canceled dialog dry-run responses cannot contaminate a reopened new-rule dialog', async () => {
for (const ok of [true, false]) {
const h = harness();
h.reply(config());
await h.window.loadToolGuardConfig();
h.window.addToolGuardRule();
fillDraft(h);
let finish;
h.queue.push(() => new Promise((resolve) => { finish = resolve; }));
const pending = runLocal(h, 'draft');
h.window.closeToolGuardRuleDialog();
h.window.addToolGuardRule();
fillDraft(h, { name: 'Replacement draft', pattern: 'example' });
finish({ ok, json: async () => ok ? { blocked: false } :
{ error: 'tool guard rule 1 (new-rule-1): invalid regular expression' } });
await pending;
assert.equal(h.element('draft-test-result').hidden, true);
assert.equal(h.element('draft-test-feedback').hidden, true);
assert.equal(h.element('rule-draft-pattern').getAttribute('aria-invalid'), null);
assert.equal(h.element('rule-draft-name').value, 'Replacement draft');
assert.equal(h.element('draft-test-run').disabled, false);
assert.equal(h.element('save').disabled, true);
}
});
test('local test errors and unsafe result text stay inside their validation panel', async () => {
const h = harness();
h.reply(config());
await h.window.loadToolGuardConfig();
h.window.addToolGuardRule();
fillDraft(h);
for (const input of ['[]', 'null', '"target"', '{']) {
fillField(h, 'draft-test-arguments', input);
await runLocal(h, 'draft');
assert.match(h.element('draft-test-feedback').textContent, /valid JSON object/);
assert.equal(h.element('feedback').hidden, true);
}
assert.equal(h.calls.length, 1);
fillField(h, 'draft-test-arguments', '{}');
h.reply({});
await runLocal(h, 'draft');
assert.match(h.element('draft-test-feedback').textContent, /invalid test result/);
assert.equal(h.element('draft-test-result').hidden, true);
const unsafe = '<img src=x onerror=alert(1)>';
h.reply({ blocked: true, match: { ruleName: unsafe, matchedText: unsafe, message: unsafe } });
await runLocal(h, 'draft');
assert.match(h.text(h.element('draft-test-result')), /<img src=x onerror=alert\(1\)>/);
assert.equal(h.element('draft-test-result').querySelectorAll('img').length, 0);
});
test('dialog validation keeps one output region through waiting, completion, and stale responses after edits', async () => {
const h = harness();
h.reply(config());
await h.window.loadToolGuardConfig();
h.window.addToolGuardRule();
const output = h.element('draft-test-output');
const status = h.element('draft-test-status');
assert.ok(output);
assert.equal(status.textContent, translations.testReadyHint);
assert.equal(status.hidden, false);
assert.equal(output.getAttribute('aria-busy'), 'false');
fillDraft(h);
let finishFirst;
h.queue.push(() => new Promise((resolve) => { finishFirst = resolve; }));
const first = runLocal(h, 'draft');
assert.equal(h.element('draft-test-output'), output);
assert.equal(status.textContent, translations.testing);
assert.equal(status.hidden, false);
assert.equal(output.getAttribute('aria-busy'), 'true');
assert.equal(h.element('draft-test-result').hidden, true);
finishFirst({ ok: true, json: async () => ({ blocked: true,
match: { ruleName: 'New protection', matchedText: '.edu', message: 'Prior result' } }) });
await first;
assert.equal(h.element('draft-test-output'), output);
assert.equal(status.hidden, true);
assert.equal(output.getAttribute('aria-busy'), 'false');
assert.match(h.text(h.element('draft-test-result')), /Prior result/);
let finishStale;
h.queue.push(() => new Promise((resolve) => { finishStale = resolve; }));
const stale = runLocal(h, 'draft');
assert.equal(h.element('draft-test-output'), output);
assert.equal(status.textContent, translations.testing);
assert.equal(status.hidden, false);
assert.equal(h.element('draft-test-result').hidden, true);
assert.equal(h.element('draft-test-result').children.length, 0);
fillField(h, 'rule-draft-pattern', '(?i)\\.org');
assert.equal(status.textContent, translations.testChanged);
assert.equal(status.hidden, false);
finishStale({ ok: true, json: async () => ({ blocked: true,
match: { ruleName: 'New protection', matchedText: '.edu', message: 'Stale result' } }) });
await stale;
assert.equal(h.element('draft-test-output'), output);
assert.equal(status.textContent, translations.testChanged);
assert.equal(status.hidden, false);
assert.equal(output.getAttribute('aria-busy'), 'false');
assert.equal(h.element('draft-test-result').hidden, true);
assert.equal(h.element('draft-test-feedback').hidden, true);
});
test('clearing a tall dialog result preserves viewport space without retaining its entire height or a historical maximum', async () => {
const h = harness();
h.reply(config());
await h.window.loadToolGuardConfig();
h.window.addToolGuardRule();
fillDraft(h);
h.reply({ blocked: true, match: { ruleName: 'New protection', matchedText: '.edu', message: 'Long result' } });
await runLocal(h, 'draft');
const output = h.element('draft-test-output');
const body = h.element('add-body');
output.rect = { height: 1200 };
Object.assign(body, { clientHeight: 600, scrollHeight: 2000, scrollTop: 1000 });
fillField(h, 'rule-draft-pattern', '(?i)\\.org');
assert.equal(h.element('draft-test-result').hidden, true);
assert.equal(output.style.minHeight, '600px');
assert.equal(body.scrollTop, 1000);
// Once the shorter content has room below it, further edits release the reserved space.
output.rect = { height: 600 };
Object.assign(body, { scrollHeight: 1600, scrollTop: 400 });
fillField(h, 'draft-test-arguments', '{"url":"https://example.org"}');
assert.equal(output.style.minHeight, '180px');
assert.equal(body.scrollTop, 400);
assert.equal(h.element('draft-test-output'), output);
assert.equal(h.calls.length, 2);
});
+14 -10
View File
@@ -2240,8 +2240,10 @@ function buildWebshellTimelineItemFromDetail(detail) {
: { kind: ((data.isError || data.success === false) ? 'error' : 'success'), isError: (data.isError || data.success === false) };
var wsBackgroundRunning = wsDisplayState.kind === 'background_running';
var success = !wsDisplayState.isError && !wsBackgroundRunning;
var wsIcon = wsBackgroundRunning ? '⏳ ' : (success ? '✅ ' : '❌ ');
var wsLabel = wsBackgroundRunning
var wsIcon = wsDisplayState.kind === 'blocked' ? '🛡 ' : (wsBackgroundRunning ? '⏳ ' : (success ? '✅ ' : '❌ '));
var wsLabel = wsDisplayState.kind === 'blocked'
? ((typeof window.t === 'function') ? window.t('chat.toolExecBlocked', { name: tname }) : tname + ' 已拦截')
: wsBackgroundRunning
? (((typeof window.getBackgroundRunningToolLabel === 'function') ? window.getBackgroundRunningToolLabel() : '后台执行中') + ': ' + tname)
: ((typeof window.t === 'function') ? (success ? window.t('chat.toolExecComplete', { name: tname }) : window.t('chat.toolExecFailed', { name: tname })) : (tname + (success ? ' 执行完成' : ' 执行失败')));
title = ap + wsIcon + wsLabel;
@@ -2286,7 +2288,7 @@ function buildWebshellTimelineItemFromDetail(detail) {
: { kind: ((data.isError || data.success === false) ? 'error' : 'success'), isError: (data.isError || data.success === false) };
var execResultLabel = (typeof window.t === 'function') ? window.t('timeline.executionResult') : '执行结果:';
var execIdLabel = (typeof window.t === 'function') ? window.t('timeline.executionId') : '执行ID:';
var sectionClass = displayState.kind === 'background_running' ? 'pending' : (displayState.isError ? 'error' : 'success');
var sectionClass = displayState.kind === 'blocked' ? 'blocked' : (displayState.kind === 'background_running' ? 'pending' : (displayState.isError ? 'error' : 'success'));
html += '<div class="webshell-ai-timeline-msg"><div class="tool-result-section ' + sectionClass + '"><strong>' + escapeHtml(execResultLabel) + '</strong><pre class="tool-result">' + escapeHtml(resultStr) + '</pre>' + (data.executionId ? '<div class="tool-execution-id"><span>' + escapeHtml(execIdLabel) + '</span> <code>' + escapeHtml(String(data.executionId)) + '</code></div>' : '') + '</div></div>';
} else if (eventType !== 'eino_usage_summary' && detail.message && detail.message !== title) {
html += '<div class="webshell-ai-timeline-msg">' + escapeHtml(detail.message) + '</div>';
@@ -2557,7 +2559,7 @@ function selectWebshell(id, stateReady) {
'<svg class="role-selector-arrow" width="10" height="10" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M6 9l6 6 6-6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>' +
'</button>' +
'<div id="ws-role-selection-panel" class="role-selection-panel" style="display:none;">' +
'<div class="role-selection-panel-header"><h3 class="role-selection-panel-title">' + (wsT('chatGroup.rolePanelTitle') || '选择角色') + '</h3>' +
'<div class="role-selection-panel-header"><h3 class="role-selection-panel-title">' + (wsT('chat.rolePanelTitle') || '选择角色') + '</h3>' +
'<button type="button" class="role-selection-panel-close" onclick="wsCloseRolePanel()"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M18 6L6 18M6 6l12 12" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg></button>' +
'</div><div id="ws-role-selection-list" class="role-selection-list-main"></div></div>' +
'</div>' +
@@ -3454,7 +3456,7 @@ function runWebshellAiSend(conn, inputEl, sendBtn, messagesContainer) {
: { kind: ((data.isError || data.success === false) ? 'error' : 'success'), isError: (data.isError || data.success === false) };
var execResultLabel = (typeof window.t === 'function') ? window.t('timeline.executionResult') : '执行结果:';
var execIdLabel = (typeof window.t === 'function') ? window.t('timeline.executionId') : '执行ID:';
var sectionClass = displayState.kind === 'background_running' ? 'pending' : (displayState.isError ? 'error' : 'success');
var sectionClass = displayState.kind === 'blocked' ? 'blocked' : (displayState.kind === 'background_running' ? 'pending' : (displayState.isError ? 'error' : 'success'));
html += '<div class="webshell-ai-timeline-msg"><div class="tool-result-section ' +
sectionClass +
'"><strong>' + escapeHtml(execResultLabel) + '</strong><pre class="tool-result">' +
@@ -3758,7 +3760,9 @@ function runWebshellAiSend(conn, inputEl, sendBtn, messagesContainer) {
// ─── Tool result (final) ───
} else if (_et === 'tool_result' && _ed) {
var success = _ed.success !== false;
var wsLiveState = typeof window.getToolResultDisplayState === 'function' ? window.getToolResultDisplayState(_ed) : { success: _ed.success !== false };
var blocked = wsLiveState.kind === 'blocked';
var success = wsLiveState.success;
var tname = _ed.toolName || '工具';
var merged = false;
if (_ed.toolCallId) {
@@ -3772,12 +3776,12 @@ function runWebshellAiSend(conn, inputEl, sendBtn, messagesContainer) {
}
}
if (!merged) {
var titleText = wsTOr(success ? 'chat.toolExecComplete' : 'chat.toolExecFailed', '') ||
(tname + (success ? ' 执行完成' : ' 执行失败'));
var titleText = wsTOr(blocked ? 'chat.toolExecBlocked' : (success ? 'chat.toolExecComplete' : 'chat.toolExecFailed'), '') ||
(tname + (blocked ? ' 已拦截' : (success ? ' 执行完成' : ' 执行失败')));
if (typeof window.t === 'function') {
try { titleText = window.t(success ? 'chat.toolExecComplete' : 'chat.toolExecFailed', { name: tname }); } catch (e) { /* */ }
try { titleText = window.t(blocked ? 'chat.toolExecBlocked' : (success ? 'chat.toolExecComplete' : 'chat.toolExecFailed'), { name: tname }); } catch (e) { /* */ }
}
var title = webshellAgentPx(_ed) + (success ? '✅ ' : '❌ ') + titleText;
var title = webshellAgentPx(_ed) + (blocked ? '🛡 ' : (success ? '✅ ' : '❌ ')) + titleText;
var sub = _em || (_ed.result ? String(_ed.result).slice(0, 300) : '');
appendTimelineItem('tool_result', title, sub, _ed);
}
+110 -167
View File
@@ -31,11 +31,12 @@
}
})();
</script>
<link rel="stylesheet" href="/static/css/style.css?v=20260819-4">
<link rel="stylesheet" href="/static/css/style.css?v=20260907-blocked-1">
<link rel="stylesheet" href="/static/css/tool-guard.css?v=20260907-7">
<link rel="stylesheet" href="/static/css/chat-plan-progress.css?v=20260813-4">
<link rel="stylesheet" href="/static/css/c2.css">
<link rel="stylesheet" href="/static/vendor/xterm.css">
<script src="/static/js/router.js?v=20260819-3"></script>
<script src="/static/js/router.js?v=20260907-1"></script>
</head>
<body>
<div id="login-overlay" class="login-overlay" style="display: none;">
@@ -210,14 +211,18 @@
<span data-i18n="nav.chat">对话</span>
</div>
</div>
<div class="nav-item" data-page="hitl">
<div class="nav-item-content" data-title="人机协同" onclick="switchPage('hitl')">
<div class="nav-item nav-item-has-submenu" data-page="security" data-require-permission-any="hitl:read config:read">
<div class="nav-item-content" data-title="安全防护" onclick="window.toggleSubmenu('security')" data-i18n="nav.security" 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" aria-hidden="true">
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"></path>
<circle cx="9" cy="7" r="4"></circle>
<path d="m16 11 2 2 4-4"></path>
<rect x="4" y="10" width="16" height="11" rx="2"></rect>
<path d="M8 10V7a4 4 0 0 1 8 0v3M12 14v3"></path>
</svg>
<span data-i18n="nav.hitl">人机协同</span>
<span data-i18n="nav.security">安全防护</span>
<svg class="submenu-arrow" width="16" height="16" viewBox="0 0 24 24" fill="none"><path d="m9 18 6-6-6-6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>
</div>
<div class="nav-submenu">
<div class="nav-submenu-item" data-page="hitl" data-require-permission="hitl:read" onclick="switchPage('hitl')" data-i18n="nav.hitl">人机协同</div>
<div class="nav-submenu-item" data-page="tool-guard" data-require-permission="config:read" onclick="switchPage('tool-guard')" data-i18n="nav.toolGuard">调用拦截</div>
</div>
</div>
<div class="nav-section-label" role="presentation">
@@ -1084,51 +1089,6 @@
</div>
</aside>
<!-- 分组详情页面 -->
<div id="group-detail-page" class="group-detail-page" style="display: none;">
<div class="group-detail-header">
<button class="back-btn" onclick="exitGroupDetail()">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M19 12H5M12 19l-7-7 7-7" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</button>
<h2 id="group-detail-title" class="group-detail-title"></h2>
<div class="group-detail-actions">
<button class="group-action-btn" onclick="toggleGroupSearch()" data-i18n="chatGroup.search" data-i18n-attr="title" title="搜索" id="group-search-toggle-btn">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="11" cy="11" r="8" stroke="currentColor" stroke-width="2"/>
<path d="m21 21-4.35-4.35" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
</svg>
</button>
<button class="group-action-btn" data-require-permission="group:write" onclick="editGroup()" data-i18n="chatGroup.edit" data-i18n-attr="title" title="编辑">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</button>
<button class="group-action-btn delete-btn" data-require-permission="group:delete" onclick="deleteGroup()" data-i18n="chatGroup.delete" data-i18n-attr="title" title="删除">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M3 6h18M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2m3 0v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6h14z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</button>
</div>
</div>
<div id="group-search-container" class="group-search-container" style="display: none;">
<div class="group-search-input-wrapper">
<input type="text" id="group-search-input" class="group-search-input" data-i18n="chat.searchInGroup" data-i18n-attr="placeholder" placeholder="搜索分组中的对话..." onkeyup="handleGroupSearchInput(event)" oninput="handleGroupSearchInput(event)">
<button class="group-search-clear-btn" onclick="clearGroupSearch()" data-i18n="common.clearSearch" data-i18n-attr="title" title="清除搜索" id="group-search-clear-btn" style="display: none;">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="2"/>
<path d="m8 8 8 8M16 8l-8 8" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
</svg>
</button>
</div>
</div>
<div class="group-detail-content">
<div id="group-conversations-list" class="group-conversations-list"></div>
</div>
</div>
<!-- 对话界面 -->
<div class="chat-container">
<!-- 会话顶部栏(只在有会话选中时显示) -->
@@ -1210,7 +1170,7 @@
<!-- 角色选择下拉面板 -->
<div id="role-selection-panel" class="role-selection-panel" style="display: none;">
<div class="role-selection-panel-header">
<h3 class="role-selection-panel-title" data-i18n="chatGroup.rolePanelTitle">选择角色</h3>
<h3 class="role-selection-panel-title" data-i18n="chat.rolePanelTitle">选择角色</h3>
<button class="role-selection-panel-close" onclick="closeRoleSelectionPanel()" data-i18n="common.close" data-i18n-attr="title" title="关闭">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M18 6L6 18M6 6l12 12" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
@@ -1397,6 +1357,93 @@
</div>
</div>
<div id="page-tool-guard" class="page tool-guard-page">
<div class="page-header">
<h2 data-i18n="toolGuard.title">调用拦截</h2>
<div class="page-header-actions">
<span id="tool-guard-save-state" class="tool-guard-save-state" aria-live="polite"></span>
<button type="button" id="tool-guard-open-test" class="btn-secondary" data-require-permission="config:read" onclick="openToolGuardTest()" data-i18n="toolGuard.testEntry" disabled>全部规则验证</button>
<button type="button" id="tool-guard-reset" class="btn-secondary" data-require-permission="config:write" onclick="resetToolGuardConfig()" data-i18n="toolGuard.reset" disabled>撤销修改</button>
<button type="button" id="tool-guard-save" class="btn-primary" data-require-permission="config:write" onclick="saveToolGuardConfig()" data-i18n="toolGuard.save" disabled>保存并生效</button>
</div>
</div>
<div class="page-content tool-guard-content">
<div id="tool-guard-feedback" class="tool-guard-feedback" role="status" aria-live="polite" hidden></div>
<section class="tool-guard-card tool-guard-policy" aria-labelledby="tool-guard-protection-status">
<div class="tool-guard-policy-info">
<span class="tool-guard-policy-icon" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7"><path d="M12 3 4 6v6c0 4 5 7 8 9 3-2 8-5 8-9V6l-8-3Z"/><path d="m8 12 3 3 5-6"/></svg></span>
<div class="tool-guard-policy-copy">
<h3 id="tool-guard-protection-status" data-i18n="toolGuard.policyTitle">拦截策略</h3>
<p data-i18n="toolGuard.enableHint">保存后立即作用于后续工具调用,已开始的调用不受影响。</p>
</div>
</div>
<label class="tool-guard-toggle tool-guard-switch"><input id="tool-guard-enabled" type="checkbox" onchange="changeToolGuardEnabled(this.checked)" disabled><span data-i18n="toolGuard.enabled">启用调用拦截</span></label>
</section>
<section class="tool-guard-card tool-guard-rules-card" aria-labelledby="tool-guard-rules-title">
<div class="tool-guard-section-header">
<div>
<div class="tool-guard-heading-line"><h3 id="tool-guard-rules-title" data-i18n="toolGuard.rulesTitle">拦截规则</h3><span id="tool-guard-rule-count" class="tool-guard-rule-count" aria-live="polite"></span></div>
<p data-i18n="toolGuard.listHint">点击规则查看和编辑,按列表顺序匹配。</p>
</div>
<button type="button" id="tool-guard-add" class="btn-secondary tool-guard-add" data-require-permission="config:write" onclick="addToolGuardRule()" disabled><span aria-hidden="true"></span><span data-i18n="toolGuard.addRule">添加规则</span></button>
</div>
<div id="tool-guard-rules" class="tool-guard-rules"></div>
<details class="tool-guard-help">
<summary><span data-i18n="toolGuard.helpTitle">规则说明与匹配范围</span><span class="tool-guard-chevron" aria-hidden="true"></span></summary>
<div class="tool-guard-help-body">
<p data-i18n="toolGuard.rulesHint">按顺序匹配已启用的规则,使用首条命中规则的提醒。正则采用 RE2 语法,保存和试运行时由服务端校验。</p>
<p data-i18n="toolGuard.messageHint">拦截提醒可使用 {match}(匹配文本)、{tool}(工具名称)、{rule}(规则名称)。</p>
<p data-i18n="toolGuard.description">在 MCP 工具执行前检查工具名称和参数,命中规则立即阻止调用。人机协同审批和工具白名单不会跳过此检查。</p>
<p data-i18n="toolGuard.scopeHint">正则仅检查调用中可见的文本;不会解析 IP 归属、重定向目标或工具读取的文件内容。域名规则不能保证覆盖所有间接访问。</p>
</div>
</details>
</section>
<details id="tool-guard-test-panel" class="tool-guard-card tool-guard-test-panel">
<summary>
<span class="tool-guard-test-icon" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7"><path d="M9 3h6M10 3v6l-6 9a2 2 0 0 0 2 3h12a2 2 0 0 0 2-3l-6-9V3M7 15h10"/></svg></span>
<span class="tool-guard-disclosure-copy"><strong id="tool-guard-test-title" data-i18n="toolGuard.testTitle">全部规则验证</strong><span id="tool-guard-test-scope-summary" data-i18n="toolGuard.testSummary">按规则顺序和启停状态,检查整体拦截效果</span></span>
<span class="tool-guard-chevron" aria-hidden="true"></span>
</summary>
<div class="tool-guard-test-body">
<p class="tool-guard-test-hint" data-i18n="toolGuard.testHint">使用页面上的当前配置(包括未保存修改),仅检查匹配结果,不执行任何工具。</p>
<div class="tool-guard-field">
<label for="tool-guard-test-tool" data-i18n="toolGuard.testTool">工具名称</label>
<input id="tool-guard-test-tool" type="text" value="http_request" maxlength="512" oninput="invalidateToolGuardTest()" autocomplete="off" spellcheck="false">
</div>
<div class="tool-guard-field">
<label for="tool-guard-test-arguments" data-i18n="toolGuard.testArguments">工具参数(JSON 对象)</label>
<textarea id="tool-guard-test-arguments" rows="3" oninput="invalidateToolGuardTest()" spellcheck="false">{"url": "https://example.gov.cn"}</textarea>
</div>
<div class="tool-guard-test-actions"><button type="button" id="tool-guard-test" class="btn-secondary" data-require-permission="config:read" onclick="testToolGuardConfig()" data-i18n="toolGuard.test" disabled>测试匹配</button></div>
<div id="tool-guard-test-result" class="tool-guard-test-result" role="status" aria-live="polite" hidden></div>
</div>
</details>
</div>
<dialog id="tool-guard-add-dialog" class="tool-guard-add-dialog" aria-labelledby="tool-guard-add-title" aria-describedby="tool-guard-add-description">
<form novalidate onsubmit="event.preventDefault();commitToolGuardRule()">
<header class="tool-guard-add-header">
<div>
<h3 id="tool-guard-add-title" data-i18n="toolGuard.addDialogTitle">添加拦截规则</h3>
<p id="tool-guard-add-description" class="tool-guard-hint" data-i18n="toolGuard.addDialogHint">先配置规则,再用示例参数验证效果,确认后添加到列表。</p>
</div>
<button type="button" class="tool-guard-dialog-close" onclick="closeToolGuardRuleDialog()" data-i18n="toolGuard.closeDialog" data-i18n-attr="aria-label,title" data-i18n-skip-text="true" aria-label="关闭添加规则" title="关闭添加规则"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" aria-hidden="true"><path d="m6 6 12 12M18 6 6 18"/></svg></button>
</header>
<div id="tool-guard-add-body" class="tool-guard-add-body">
<div id="tool-guard-add-fields" class="tool-guard-add-fields"></div>
<aside id="tool-guard-add-test" class="tool-guard-add-test"></aside>
<div id="tool-guard-add-feedback" class="tool-guard-feedback" role="status" aria-live="polite" hidden></div>
</div>
<footer class="tool-guard-add-footer">
<p class="tool-guard-hint" data-i18n="toolGuard.addToListHint">添加到列表后,点击页面顶部「保存并生效」应用规则。</p>
<div class="tool-guard-dialog-actions">
<button type="button" class="btn-secondary" onclick="closeToolGuardRuleDialog()" data-i18n="toolGuard.cancelAdd">取消</button>
<button type="submit" id="tool-guard-add-confirm" class="btn-primary" data-require-permission="config:write" data-i18n="toolGuard.addToList">添加到列表</button>
</div>
</footer>
</form>
</dialog>
</div>
<div id="page-hitl" class="page">
<div class="page-header">
<h2 data-i18n="hitl.pageTitle">人机协同审批</h2>
@@ -1613,6 +1660,7 @@
<option value="running" data-i18n="mcpMonitor.statusRunning">执行中</option>
<option value="queued" data-i18n="mcpMonitor.statusQueued">排队中</option>
<option value="failed" data-i18n="mcpMonitor.statusFailed">失败</option>
<option value="blocked" data-i18n="mcpMonitor.statusBlocked">已拦截</option>
<option value="hard_timeout" data-i18n="mcpMonitor.statusHardTimeout">执行超时</option>
<option value="cancelled" data-i18n="mcpMonitor.statusCancelled">已终止</option>
<option value="orphaned" data-i18n="mcpMonitor.statusOrphaned">孤儿任务</option>
@@ -3695,6 +3743,7 @@
<label for="openai-reasoning-profile" data-i18n="settingsBasic.openaiReasoningProfile">线路</label>
<select id="openai-reasoning-profile">
<option value="auto">auto</option>
<option value="deepseek">deepseek</option>
<option value="deepseek_compat">deepseek_compat</option>
<option value="openai_compat">openai_compat</option>
<option value="output_config_effort">output_config_effort</option>
@@ -5922,10 +5971,6 @@
<option value="" data-i18n="chat.filterAllProjects">全部项目</option>
<option value="__none__" data-i18n="chat.filterUnboundProjects">未绑定项目</option>
</select>
<select id="batch-group-filter" class="conversation-project-filter-native" onchange="applyBatchConversationFilters()" data-i18n="batchManageModal.filterByGroup" data-i18n-attr="title" title="按分组筛选">
<option value="" data-i18n="batchManageModal.filterAllGroups">全部分组</option>
<option value="__none__" data-i18n="batchManageModal.filterUngrouped">无分组</option>
</select>
<div class="batch-search-box">
<input type="text" id="batch-search-input" data-i18n="batchManageModal.searchPlaceholder" data-i18n-attr="placeholder" placeholder="搜索历史记录" oninput="filterBatchConversations(this.value)" />
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
@@ -5944,7 +5989,6 @@
</div>
<div class="batch-table-col-name" data-i18n="batchManageModal.conversationName">对话名称</div>
<div class="batch-table-col-project" data-i18n="batchManageModal.project">项目</div>
<div class="batch-table-col-group" data-i18n="batchManageModal.group">对话分组</div>
<div class="batch-table-col-time" data-i18n="batchManageModal.lastTime">最近一次对话时间</div>
<div class="batch-table-col-action" data-i18n="batchManageModal.action">操作</div>
</div>
@@ -5952,12 +5996,6 @@
</div>
</div>
<div class="modal-footer batch-manage-footer">
<div class="batch-footer-move">
<select id="batch-move-group-select" class="batch-move-group-select conversation-project-filter-native" data-i18n="batchManageModal.setGroup" data-i18n-attr="title" title="设置分组">
<option value="__none__" data-i18n="batchManageModal.noGroupOption">无分组</option>
</select>
<button type="button" class="btn-secondary" onclick="applyBatchGroupChange()" data-i18n="batchManageModal.setGroup">设置分组</button>
</div>
<div class="batch-footer-actions">
<button class="btn-secondary" onclick="closeBatchManageModal()" data-i18n="common.cancel">取消</button>
<button class="btn-primary" data-require-permission="chat:delete" onclick="deleteSelectedConversations()" data-i18n="batchManageModal.deleteSelected">删除所选</button>
@@ -5966,69 +6004,6 @@
</div>
</div>
<!-- 创建分组模态框 -->
<div id="create-group-modal" class="modal">
<div class="modal-content create-group-modal-content">
<div class="modal-header">
<h2 data-i18n="createGroupModal.title">创建分组</h2>
<span class="modal-close" onclick="closeCreateGroupModal()">&times;</span>
</div>
<div class="modal-body create-group-body">
<p class="create-group-description" data-i18n="createGroupModal.description">分组功能可将对话集中归类管理,让对话更加井然有序。</p>
<div class="create-group-input-wrapper">
<button type="button" class="group-icon-input" id="create-group-icon-btn" onclick="toggleGroupIconPicker()" data-i18n="createGroupModal.selectIcon" data-i18n-attr="title" title="点击选择图标">📁</button>
<input type="text" id="create-group-name-input" data-i18n="createGroupModal.groupNamePlaceholder" data-i18n-attr="placeholder" placeholder="请输入分组名称" />
<!-- Emoji选择器面板 -->
<div id="group-icon-picker" class="group-icon-picker" style="display: none;">
<div class="icon-picker-header">
<span data-i18n="createGroupModal.pickIcon">选择图标</span>
<div class="icon-picker-custom">
<input type="text" id="custom-icon-input" class="custom-icon-input" data-i18n="createGroupModal.customIcon" data-i18n-attr="placeholder" placeholder="自定义" maxlength="2" />
<button type="button" class="custom-icon-btn" onclick="applyCustomIcon()" data-i18n="createGroupModal.confirmIcon">确定</button>
</div>
</div>
<div class="icon-picker-grid">
<span class="icon-option" onclick="selectGroupIcon('📁')">📁</span>
<span class="icon-option" onclick="selectGroupIcon('🔒')">🔒</span>
<span class="icon-option" onclick="selectGroupIcon('🛡️')">🛡️</span>
<span class="icon-option" onclick="selectGroupIcon('⚔️')">⚔️</span>
<span class="icon-option" onclick="selectGroupIcon('🎯')">🎯</span>
<span class="icon-option" onclick="selectGroupIcon('🔍')">🔍</span>
<span class="icon-option" onclick="selectGroupIcon('💻')">💻</span>
<span class="icon-option" onclick="selectGroupIcon('🐛')">🐛</span>
<span class="icon-option" onclick="selectGroupIcon('🚀')">🚀</span>
<span class="icon-option" onclick="selectGroupIcon('⚡')"></span>
<span class="icon-option" onclick="selectGroupIcon('🔥')">🔥</span>
<span class="icon-option" onclick="selectGroupIcon('💡')">💡</span>
<span class="icon-option" onclick="selectGroupIcon('🎮')">🎮</span>
<span class="icon-option" onclick="selectGroupIcon('🏴‍☠️')">🏴‍☠️</span>
<span class="icon-option" onclick="selectGroupIcon('🕵️')">🕵️</span>
<span class="icon-option" onclick="selectGroupIcon('🔑')">🔑</span>
<span class="icon-option" onclick="selectGroupIcon('📡')">📡</span>
<span class="icon-option" onclick="selectGroupIcon('🌐')">🌐</span>
<span class="icon-option" onclick="selectGroupIcon('📊')">📊</span>
<span class="icon-option" onclick="selectGroupIcon('📝')">📝</span>
<span class="icon-option" onclick="selectGroupIcon('🗂️')">🗂️</span>
<span class="icon-option" onclick="selectGroupIcon('📌')">📌</span>
<span class="icon-option" onclick="selectGroupIcon('⭐')"></span>
<span class="icon-option" onclick="selectGroupIcon('💎')">💎</span>
</div>
</div>
</div>
<div class="create-group-suggestions">
<div class="suggestion-tag" data-i18n="createGroupModal.suggestionPenetrationTest" onclick="selectSuggestionByKey('createGroupModal.suggestionPenetrationTest')">渗透测试</div>
<div class="suggestion-tag" data-i18n="createGroupModal.suggestionCtf" onclick="selectSuggestionByKey('createGroupModal.suggestionCtf')">CTF</div>
<div class="suggestion-tag" data-i18n="createGroupModal.suggestionRedTeam" onclick="selectSuggestionByKey('createGroupModal.suggestionRedTeam')">红队</div>
<div class="suggestion-tag" data-i18n="createGroupModal.suggestionVulnerabilityMining" onclick="selectSuggestionByKey('createGroupModal.suggestionVulnerabilityMining')">漏洞挖掘</div>
</div>
</div>
<div class="modal-footer">
<button class="btn-secondary" onclick="closeCreateGroupModal()" data-i18n="createGroupModal.cancel">取消</button>
<button class="btn-primary" data-require-permission="group:write" onclick="createGroup(event)" data-i18n="createGroupModal.create">创建</button>
</div>
</div>
</div>
<!-- 上下文菜单 -->
<div id="conversation-context-menu" class="context-menu" style="display: none;">
<div id="attack-chain-menu-item" class="context-menu-item" onclick="showAttackChainFromContext()">
@@ -6087,16 +6062,6 @@
</svg>
<span data-i18n="contextMenu.batchManage">批量管理</span>
</div>
<div class="context-menu-item context-menu-item-has-submenu" onmouseenter="handleMoveToGroupSubmenuEnter()" onmouseleave="handleMoveToGroupSubmenuLeave(event)">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
<span data-i18n="contextMenu.moveToGroup">移动到分组</span>
<svg class="submenu-arrow" width="12" height="12" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M9 18l6-6-6-6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
<div id="move-to-group-submenu" class="context-submenu" style="display: none;" onmouseenter="clearSubmenuHideTimeout()" onmouseleave="hideMoveToGroupSubmenu()"></div>
</div>
<div class="context-menu-divider"></div>
<div class="context-menu-item context-menu-item-danger" data-require-permission="chat:delete" onclick="deleteConversationFromContext()">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
@@ -6106,29 +6071,6 @@
</div>
</div>
<!-- 分组上下文菜单 -->
<div id="group-context-menu" class="context-menu" style="display: none;">
<div class="context-menu-item" onclick="renameGroupFromContext()">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
<span data-i18n="contextMenu.rename">重命名</span>
</div>
<div class="context-menu-item" onclick="pinGroupFromContext()">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12 17v5M5 17h14l-1-7H6l-1 7zM9 10V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
<span id="pin-group-menu-text" data-i18n="contextMenu.pinGroup">置顶此分组</span>
</div>
<div class="context-menu-item context-menu-item-danger" data-require-permission="group:delete" onclick="deleteGroupFromContext()">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M3 6h18M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2m3 0v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6h14z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
<span data-i18n="contextMenu.deleteGroup">删除此分组</span>
</div>
</div>
<!-- 项目列表操作菜单 -->
<div id="projects-list-action-menu" class="context-menu" style="display: none;" role="menu">
<div id="projects-list-menu-edit" class="context-menu-item" data-require-permission="project:write" onclick="editProjectFromListMenu()">
@@ -6442,7 +6384,7 @@
<div id="role-select-modal" class="modal">
<div class="modal-content role-select-modal-content">
<div class="modal-header">
<h2 data-i18n="chatGroup.rolePanelTitle">选择角色</h2>
<h2 data-i18n="chat.rolePanelTitle">选择角色</h2>
<span class="modal-close" onclick="closeRoleSelectModal()">&times;</span>
</div>
<div class="modal-body role-select-body">
@@ -6846,7 +6788,7 @@
<script src="/static/js/i18n.js"></script>
<script src="/static/js/theme.js"></script>
<script src="/static/js/builtin-tools.js"></script>
<script src="/static/js/auth.js?v=20260813-1"></script>
<script src="/static/js/auth.js?v=20260907-blocked-1"></script>
<script src="/static/js/modal.js"></script>
<script src="/static/js/notifications.js"></script>
<script src="/static/js/info-collect.js?v=20260717-1"></script>
@@ -6854,10 +6796,11 @@
<script src="/static/js/agents.js"></script>
<script src="/static/js/dashboard.js"></script>
<script src="/static/js/chat-scroll.js?v=20260815-1"></script>
<script src="/static/js/monitor.js?v=20260819-3"></script>
<script src="/static/js/chat.js?v=20260819-5"></script>
<script src="/static/js/monitor.js?v=20260907-blocked-1"></script>
<script src="/static/js/chat.js?v=20260907-blocked-1"></script>
<script src="/static/js/chat-plan-progress.js?v=20260815-1"></script>
<script src="/static/js/hitl.js?v=20260819-1"></script>
<script src="/static/js/tool-guard.js?v=20260907-7"></script>
<script src="/static/js/settings.js?v=20260717-1"></script>
<script src="/static/js/audit-datetime-picker.js"></script>
<script src="/static/js/audit.js"></script>
@@ -6870,7 +6813,7 @@
<script src="/static/js/fact-graph.js"></script>
<script src="/static/js/projects.js?v=20260819-1"></script>
<script src="/static/js/vulnerability.js?v=14"></script>
<script src="/static/js/webshell.js"></script>
<script src="/static/js/webshell.js?v=20260907-blocked-1"></script>
<script src="/static/js/chat-files.js"></script>
<script src="/static/js/tasks.js"></script>
<script src="/static/js/workflow-package-client.js"></script>
@@ -6878,6 +6821,6 @@
<script src="/static/js/roles.js"></script>
<script src="/static/js/rbac.js"></script>
<script src="/static/js/c2.js"></script>
<script src="/static/js/rbac-guards.js?v=20260717-1"></script>
<script src="/static/js/rbac-guards.js?v=20260907-1"></script>
</body>
</html>