mirror of
https://github.com/Ed1s0nZ/CyberStrikeAI.git
synced 2026-09-08 18:59:09 +02:00
feat: add configurable tool call blocking and monitoring
This commit is contained in:
@@ -126,6 +126,7 @@ 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).
|
||||
|
||||
@@ -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)。
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"cyberstrike-ai/internal/logger"
|
||||
"cyberstrike-ai/internal/mcp"
|
||||
"cyberstrike-ai/internal/security"
|
||||
"cyberstrike-ai/internal/toolguard"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
@@ -28,6 +29,12 @@ func main() {
|
||||
|
||||
// 创建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)
|
||||
|
||||
@@ -130,6 +130,19 @@ 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
|
||||
|
||||
@@ -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`.
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
@@ -412,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)
|
||||
@@ -1054,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)
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"cyberstrike-ai/internal/termout"
|
||||
"cyberstrike-ai/internal/toolguard"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
@@ -30,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"`
|
||||
@@ -1439,6 +1441,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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1637,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"
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 = ©
|
||||
}
|
||||
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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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(),
|
||||
@@ -1746,6 +1748,8 @@ func (h *ConfigHandler) ApplyConfig(c *gin.Context) {
|
||||
|
||||
// saveConfig 保存配置到文件
|
||||
func (h *ConfigHandler) saveConfig() error {
|
||||
configFileMu.Lock()
|
||||
defer configFileMu.Unlock()
|
||||
h.config.NormalizeAIProviderProfiles()
|
||||
|
||||
// 读取现有配置文件并创建备份
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -129,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"):
|
||||
@@ -208,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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -46405,3 +46405,44 @@ html[data-theme="dark"] .hitl-inline-approval.hitl-inline-approval--merged {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* A policy block is an intentional refusal before execution, separate from a tool failure. */
|
||||
:root { --tool-blocked-color: #a65d08; }
|
||||
html[data-theme="dark"] { --tool-blocked-color: #f4bc62; }
|
||||
.tool-status-badge.tool-status-blocked,
|
||||
.status-chip.status-blocked,
|
||||
.monitor-status-chip.blocked,
|
||||
.mcp-stats-kpi__chip.is-blocked,
|
||||
.mcp-stats-tool-item__pill.is-blocked {
|
||||
color: var(--tool-blocked-color);
|
||||
background: rgba(217, 146, 36, .12);
|
||||
border: 1px solid rgba(217, 146, 36, .32);
|
||||
}
|
||||
.timeline-item-tool_call.tool-call-blocked,
|
||||
.timeline-item-tool_result.tool-call-blocked {
|
||||
border: 1px solid rgba(217, 146, 36, .32);
|
||||
border-left: 3px solid var(--tool-blocked-color);
|
||||
background: rgba(217, 146, 36, .065);
|
||||
}
|
||||
.tool-result-section.blocked .tool-result,
|
||||
#mcp-detail-modal .code-block.blocked,
|
||||
.mcp-detail-btn[data-status="blocked"] {
|
||||
color: var(--text-primary);
|
||||
border-color: rgba(217, 146, 36, .32);
|
||||
background: rgba(217, 146, 36, .055);
|
||||
}
|
||||
.message.assistant-turn-with-process .timeline-item.tool-call-blocked::before,
|
||||
.message.progress-message .timeline-item.tool-call-blocked::before {
|
||||
background: var(--tool-blocked-color);
|
||||
}
|
||||
.mcp-stats-blocked-note,
|
||||
.mcp-stats-timeline-moment__blocked {
|
||||
color: var(--tool-blocked-color);
|
||||
}
|
||||
.mcp-stats-blocked-note { display: inline-block; margin-left: 8px; font-size: .75rem; }
|
||||
.mcp-stats-timeline-bar-blocked { fill: #d99224; opacity: .8; }
|
||||
.mcp-stats-timeline-bar-blocked.is-hover { opacity: 1; }
|
||||
.mcp-stats-timeline-line--blocked { fill: none; stroke: #d99224; stroke-width: 1.5; }
|
||||
.mcp-stats-timeline__legend-item--blocked::before { background: #d99224; }
|
||||
.mcp-stats-rate.is-muted,
|
||||
.mcp-stats-tool-item__rate.is-muted { color: var(--text-muted); }
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
+101
-1
@@ -77,6 +77,8 @@
|
||||
"submit": "Sign in"
|
||||
},
|
||||
"nav": {
|
||||
"security": "Security",
|
||||
"toolGuard": "Call blocking",
|
||||
"dashboard": "Dashboard",
|
||||
"chat": "Chat",
|
||||
"assets": "Asset Management",
|
||||
@@ -544,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",
|
||||
@@ -821,6 +824,96 @@
|
||||
"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",
|
||||
"pageReviewerLabel": "Current reviewer",
|
||||
@@ -983,6 +1076,7 @@
|
||||
"peAgentReplanning": "Replanner"
|
||||
},
|
||||
"timeline": {
|
||||
"blocked": "Blocked",
|
||||
"params": "Parameters:",
|
||||
"executionResult": "Execution result:",
|
||||
"executionId": "Execution ID:",
|
||||
@@ -2242,6 +2336,11 @@
|
||||
}
|
||||
},
|
||||
"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",
|
||||
@@ -2316,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",
|
||||
@@ -3209,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",
|
||||
|
||||
+101
-1
@@ -77,6 +77,8 @@
|
||||
"submit": "登录"
|
||||
},
|
||||
"nav": {
|
||||
"security": "安全防护",
|
||||
"toolGuard": "调用拦截",
|
||||
"dashboard": "仪表盘",
|
||||
"chat": "对话",
|
||||
"assets": "资产管理",
|
||||
@@ -532,6 +534,7 @@
|
||||
"generatedFromFact": "由项目事实 {{factKey}} 生成"
|
||||
},
|
||||
"chat": {
|
||||
"toolExecBlocked": "工具 {{name}} 已拦截",
|
||||
"newChat": "新对话",
|
||||
"newTask": "新任务",
|
||||
"toggleConversationPanel": "折叠/展开对话列表",
|
||||
@@ -809,6 +812,96 @@
|
||||
"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": "人机协同审批",
|
||||
"pageReviewerLabel": "当前审批方",
|
||||
@@ -971,6 +1064,7 @@
|
||||
"peAgentReplanning": "重规划"
|
||||
},
|
||||
"timeline": {
|
||||
"blocked": "已拦截",
|
||||
"params": "参数:",
|
||||
"executionResult": "执行结果:",
|
||||
"executionId": "执行ID:",
|
||||
@@ -2230,6 +2324,11 @@
|
||||
}
|
||||
},
|
||||
"mcpMonitor": {
|
||||
"toolRowNoCompletedAriaLabel": "{{name}},{{total}} 次调用,暂无完成结果,点击查看执行记录",
|
||||
"rateExcludesBlocked": "成功率仅统计成功和失败的调用,不包含安全拦截和终止",
|
||||
"timelineBlockedLegend": "安全拦截",
|
||||
"blockedCount": "安全拦截 {{n}}",
|
||||
"statusBlocked": "已拦截",
|
||||
"deselectAll": "取消全选",
|
||||
"statusPending": "等待中",
|
||||
"statusQueued": "排队中",
|
||||
@@ -2304,7 +2403,7 @@
|
||||
"timelineLoadError": "无法加载调用趋势",
|
||||
"timelineTotalLegend": "总调用",
|
||||
"timelineFailedLegend": "失败",
|
||||
"timelineTooltip": "{{time}}:{{total}} 次(失败 {{failed}})",
|
||||
"timelineTooltip": "{{time}}:{{total}} 次(失败 {{failed}},安全拦截 {{blocked}})",
|
||||
"distTitle": "调用分布",
|
||||
"distLegend": "扇区面积为占全部调用比例",
|
||||
"distClickHint": "点击色条筛选执行记录",
|
||||
@@ -3197,6 +3296,7 @@
|
||||
"botCommandsFooter": "除以上命令外,直接输入内容将按绑定用户或服务账号的实时 RBAC 权限发送给 AI。Otherwise, text is sent to AI under the effective RBAC identity."
|
||||
},
|
||||
"mcpDetailModal": {
|
||||
"blockReason": "拦截原因",
|
||||
"title": "工具调用详情",
|
||||
"execInfo": "执行信息",
|
||||
"tool": "工具",
|
||||
|
||||
@@ -368,6 +368,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',
|
||||
@@ -613,10 +614,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;
|
||||
}
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
|
||||
+30
-8
@@ -1891,6 +1891,8 @@ function initChatPrimaryActionButton() {
|
||||
updateChatPrimaryActionState();
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', initChatPrimaryActionButton);
|
||||
|
||||
function closeChatReasoningPanel() {
|
||||
const wrap = document.getElementById('chat-reasoning-wrapper');
|
||||
const toggle = document.getElementById('conversation-reasoning-toggle');
|
||||
@@ -4355,8 +4357,10 @@ function renderProcessDetails(messageId, processDetails, options) {
|
||||
: { kind: (data.success !== false ? 'success' : 'error'), isError: data.success === false };
|
||||
const backgroundRunning = displayState.kind === 'background_running';
|
||||
const success = !displayState.isError && !backgroundRunning;
|
||||
const statusIcon = backgroundRunning ? '⏳' : (success ? '✅' : '❌');
|
||||
const execText = backgroundRunning
|
||||
const statusIcon = displayState.kind === 'blocked' ? '🛡' : (backgroundRunning ? '⏳' : (success ? '✅' : '❌'));
|
||||
const execText = displayState.kind === 'blocked'
|
||||
? (typeof window.t === 'function' ? window.t('chat.toolExecBlocked', { name: escapeHtml(toolName) }) : '工具 ' + escapeHtml(toolName) + ' 已拦截')
|
||||
: backgroundRunning
|
||||
? ((typeof window.getBackgroundRunningToolLabel === 'function' ? window.getBackgroundRunningToolLabel() : '后台执行中') + ': ' + escapeHtml(toolName))
|
||||
: (success ? (typeof window.t === 'function' ? window.t('chat.toolExecComplete', { name: escapeHtml(toolName) }) : '工具 ' + escapeHtml(toolName) + ' 执行完成') : (typeof window.t === 'function' ? window.t('chat.toolExecFailed', { name: escapeHtml(toolName) }) : '工具 ' + escapeHtml(toolName) + ' 执行失败'));
|
||||
let execLine = statusIcon + ' ' + execText;
|
||||
@@ -5399,7 +5403,7 @@ function normalizeToolExecutionSummary(raw) {
|
||||
if (raw && typeof raw === 'object') {
|
||||
return {
|
||||
toolName: raw.toolName || raw.name || '',
|
||||
status: raw.status || ''
|
||||
status: typeof window.getToolExecutionDisplayStatus === 'function' ? window.getToolExecutionDisplayStatus(raw) : (raw.status || '')
|
||||
};
|
||||
}
|
||||
return { toolName: '', status: '' };
|
||||
@@ -5411,6 +5415,7 @@ function getToolExecutionStatusLabel(status) {
|
||||
const keyMap = {
|
||||
completed: 'mcpMonitor.statusSuccess',
|
||||
failed: 'mcpMonitor.statusFailed',
|
||||
blocked: 'mcpMonitor.statusBlocked',
|
||||
running: 'mcpMonitor.statusRunning',
|
||||
cancelled: 'mcpMonitor.statusCancelled',
|
||||
pending: 'mcpMonitor.statusPending',
|
||||
@@ -5425,6 +5430,7 @@ function getToolExecutionStatusLabel(status) {
|
||||
const fallback = {
|
||||
completed: '成功',
|
||||
failed: '失败',
|
||||
blocked: '已拦截',
|
||||
running: '运行中',
|
||||
cancelled: '已取消',
|
||||
pending: '等待中',
|
||||
@@ -5504,7 +5510,8 @@ function formatMCPResultJsonForDisplay(result) {
|
||||
if (!result) return '{}';
|
||||
const payload = {
|
||||
content: result.content,
|
||||
isError: !!result.isError
|
||||
isError: !!result.isError,
|
||||
...(result.blocked === true ? { blocked: true } : {})
|
||||
};
|
||||
return JSON.stringify(payload, null, 2);
|
||||
}
|
||||
@@ -5552,12 +5559,13 @@ function renderMCPDetailModal(exec) {
|
||||
document.getElementById('detail-tool-name').textContent = exec.toolName || (typeof window.t === 'function' ? window.t('mcpDetailModal.unknown') : 'Unknown');
|
||||
document.getElementById('detail-execution-id').textContent = exec.id || 'N/A';
|
||||
const statusEl = document.getElementById('detail-status');
|
||||
const normalizedStatus = (exec.status || 'unknown').toLowerCase();
|
||||
statusEl.textContent = getStatusText(exec.status);
|
||||
const normalizedStatus = typeof window.getToolExecutionDisplayStatus === 'function' ? window.getToolExecutionDisplayStatus(exec) : (exec.status || 'unknown').toLowerCase();
|
||||
const blocked = normalizedStatus === 'blocked';
|
||||
statusEl.textContent = getStatusText(normalizedStatus);
|
||||
const statusClass = normalizedStatus === 'background_running' ? 'running' : normalizedStatus;
|
||||
statusEl.className = `status-chip status-${statusClass}`;
|
||||
try {
|
||||
statusEl.dataset.detailStatus = (exec.status || '') + '';
|
||||
statusEl.dataset.detailStatus = normalizedStatus;
|
||||
} catch (e) { /* ignore */ }
|
||||
const detailTimeLocale = (typeof window.__locale === 'string' && window.__locale.startsWith('zh')) ? 'zh-CN' : 'en-US';
|
||||
const detailTimeEl = document.getElementById('detail-time');
|
||||
@@ -5592,12 +5600,26 @@ function renderMCPDetailModal(exec) {
|
||||
errorElement.textContent = '';
|
||||
}
|
||||
setMCPResultDetailTabs('raw', false);
|
||||
const resultTabLabel = document.querySelector('#detail-result-tab-success [data-i18n]');
|
||||
if (resultTabLabel) {
|
||||
const key = blocked ? 'mcpDetailModal.blockReason' : 'mcpDetailModal.correctInfo';
|
||||
resultTabLabel.dataset.i18n = key;
|
||||
resultTabLabel.textContent = typeof window.t === 'function' ? window.t(key) : (blocked ? '拦截原因' : '正确信息');
|
||||
}
|
||||
|
||||
if (exec.result) {
|
||||
const agentVisibleText = formatMCPDetailText(extractMCPResultText(exec.result));
|
||||
const emptyText = typeof window.t === 'function' ? window.t('mcpDetailModal.execSuccessNoContent') : '执行成功,未返回可展示的文本内容。';
|
||||
|
||||
if (exec.result.isError) {
|
||||
if (blocked) {
|
||||
responseElement.className = 'code-block blocked';
|
||||
responseElement.textContent = formatMCPResultJsonForDisplay(exec.result);
|
||||
if (successElement) {
|
||||
successElement.className = 'code-block blocked';
|
||||
successElement.textContent = agentVisibleText || exec.error || getStatusText('blocked');
|
||||
}
|
||||
setMCPResultDetailTabs('success', true);
|
||||
} else if (exec.result.isError) {
|
||||
responseElement.className = 'code-block error';
|
||||
responseElement.textContent = formatMCPResultJsonForDisplay(exec.result);
|
||||
if (successElement) {
|
||||
|
||||
@@ -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('彻底停止始终使用弹窗锁定的会话且状态刷新后仍会取消', () => {
|
||||
|
||||
@@ -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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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/);
|
||||
});
|
||||
+122
-43
@@ -3640,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) + ' 执行失败'));
|
||||
|
||||
@@ -5887,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();
|
||||
@@ -5937,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';
|
||||
}
|
||||
|
||||
@@ -5961,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>' +
|
||||
@@ -6185,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;
|
||||
}
|
||||
@@ -6199,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');
|
||||
@@ -6228,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;
|
||||
}
|
||||
@@ -6368,6 +6403,7 @@ window.mergeToolResultIntoCallItem = mergeToolResultIntoCallItem;
|
||||
window.formatToolCallTimelineTitle = formatToolCallTimelineTitle;
|
||||
window.parseToolCallArgsFromData = parseToolCallArgsFromData;
|
||||
window.getToolResultDisplayState = getToolResultDisplayState;
|
||||
window.getToolExecutionDisplayStatus = getToolExecutionDisplayStatus;
|
||||
window.getBackgroundRunningToolLabel = getBackgroundRunningToolLabel;
|
||||
window.buildToolResultSectionHtml = buildToolResultSectionHtml;
|
||||
|
||||
@@ -6390,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: '⛔ ' };
|
||||
}
|
||||
@@ -6406,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) {
|
||||
@@ -6625,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) {
|
||||
@@ -6635,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');
|
||||
@@ -6745,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) {
|
||||
@@ -6818,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 += `
|
||||
@@ -7734,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,
|
||||
};
|
||||
}
|
||||
@@ -7767,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));
|
||||
@@ -7789,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;
|
||||
@@ -7823,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('');
|
||||
|
||||
@@ -7865,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>`;
|
||||
@@ -7893,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`;
|
||||
@@ -7919,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';
|
||||
});
|
||||
|
||||
@@ -7997,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
|
||||
@@ -8080,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
|
||||
@@ -8095,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>`;
|
||||
}
|
||||
|
||||
@@ -8635,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>`
|
||||
: '';
|
||||
@@ -8651,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>
|
||||
@@ -8658,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>
|
||||
@@ -8687,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)}"
|
||||
@@ -8712,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>`;
|
||||
});
|
||||
@@ -8766,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>`
|
||||
: '';
|
||||
@@ -8801,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('');
|
||||
@@ -8995,6 +9073,7 @@ function renderMonitorExecutions(executions = [], statusFilter = 'all') {
|
||||
running: 'statusRunning',
|
||||
completed: 'statusCompleted',
|
||||
failed: 'statusFailed',
|
||||
blocked: 'statusBlocked',
|
||||
cancelled: 'statusCancelled',
|
||||
hard_timeout: 'statusHardTimeout',
|
||||
orphaned: 'statusOrphaned'
|
||||
@@ -9002,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);
|
||||
@@ -9542,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') {
|
||||
|
||||
@@ -87,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
@@ -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);
|
||||
|
||||
@@ -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('&', '&').replaceAll('<', '<'),
|
||||
};
|
||||
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);
|
||||
});
|
||||
@@ -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('');
|
||||
});
|
||||
})();
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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>';
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
+107
-13
@@ -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">
|
||||
@@ -1352,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>
|
||||
@@ -1568,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>
|
||||
@@ -6695,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>
|
||||
@@ -6703,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>
|
||||
@@ -6719,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>
|
||||
@@ -6727,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>
|
||||
|
||||
Reference in New Issue
Block a user