mirror of
https://github.com/Ed1s0nZ/CyberStrikeAI.git
synced 2026-10-19 05:57:39 +02:00
fix: reject stale concurrent interception rule saves
This commit is contained in:
1 parent
4a1d0e114e
commit
1ecbd4054c
14 files changed
+295
-19
No files matched your search
@@ -4,6 +4,8 @@
|
||||
|
||||
### Fixed
|
||||
|
||||
- 修复多人编辑调用拦截规则时旧草稿覆盖他人修改的问题:同一版本仅一次保存成功,冲突保留草稿并提示刷新;API 保存必须携带读取时的 ETag/If-Match。
|
||||
|
||||
- 修复平台角色更新/删除使全体用户掉线的问题:仅撤销受影响角色成员的会话;名称、描述及等价权限集合更新保持会话有效。
|
||||
|
||||
- 修复俄语等长文案导致的仪表盘状态卡片、风险徽标与队列标签溢出;侧栏及风险名称完整换行,布局随卡片可用宽度适配。
|
||||
|
||||
@@ -12,6 +12,8 @@ Use **Test all rules** in the page header to check the configured order and enab
|
||||
|
||||
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.
|
||||
|
||||
When multiple users edit the same configuration version, only the first save succeeds. Stale saves report a conflict and retain the local draft. Copy any edits you need to keep, refresh to load the latest configuration, and edit again; repeating a stale save cannot overwrite another user's changes. Saving replaces the full rule list, so edits to different rules can also conflict.
|
||||
|
||||
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.
|
||||
@@ -22,6 +24,6 @@ Text matching cannot establish target ownership from IP addresses, DNS aliases,
|
||||
|
||||
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`.
|
||||
- `GET /api/tool-guard`: active `{enabled, rules}` configuration with an `ETag` response header and `Cache-Control: no-store`.
|
||||
- `PUT /api/tool-guard`: save that structure, explicitly providing both fields. Rules contain `id`, `name`, `enabled`, `pattern`, and `message`. Send the complete ETag from the read (including quotes) in `If-Match`. Missing versions return HTTP 428 / `tool_guard_version_required`; stale versions return HTTP 409 / `tool_guard_conflict`, without saving. Successful saves return a new ETag. Existing API clients must adopt this protocol; `*` cannot bypass it. Read a new version after a server restart. Coordination is scoped to the current service instance, not multiple instances sharing configuration.
|
||||
- `POST /api/tool-guard/test`: accepts `{config, toolName, arguments}` and returns `{blocked, match?}`. Match fields are `ruleId`, `ruleName`, `matchedText`, and `message`.
|
||||
@@ -35,3 +35,9 @@ PUT 必须携带完整精确匹配的 If-Match;未提供返回 428/tool_guard_
|
||||
修改 internal/handler/config.go 一个字段、tool_guard.go 的版本协议;扩展专项 handler 测试验证真实并发单赢家、顺序旧草稿、无条件写拒绝、无变化保存、失败版本不变、manager 直接更新。修改 tool-guard.js 的版本状态和请求头,中英俄语言提示及 index.html 的 JS 缓存版本;扩展现有 VM 前端测试验证两个页面冲突、草稿保留、重试不换版本、成功更新版本、试匹配无副作用。更新中英文调用拦截文档、Unreleased changelog。
|
||||
|
||||
验证命令:go test -race ./internal/handler -run ToolGuard;node --test web/static/js/tool-guard.test.cjs;node --check web/static/js/tool-guard.js;git diff --check。必要时完整 handler 测试确认共享 ConfigHandler 无回归。测试只用临时目录和请求 fixture,不触碰线上业务数据。回滚需要前后端一起回滚,但会恢复覆盖风险;不执行部署。
|
||||
|
||||
## F4 Implementation
|
||||
|
||||
已实现持锁 ETag 比较与版本推进,强制条件保存;无条件写入 428,旧版本 409,成功保存返回新版本。前端独立记录基线版本,错误不会采用响应新版本,保留草稿;只在 GET/PUT 处理版本,试匹配不参与。中英俄提示及脚本缓存版本已更新。
|
||||
|
||||
专项 race 测试通过(3.246s),覆盖 12 个并发请求仅 1 成功、落盘/运行策略与成功回执一致、顺序旧草稿失败、缺版本/星号/弱版本拒绝、无变化保存推进版本、落盘失败可安全重试、manager 直接更新冲突。前端 36/36 通过(135ms),覆盖两个页面竞争、重试不偷偷换版本、草稿保留、重新读取后保存、失败版本保留及试匹配无版本头。JS syntax/diff check 通过;完整 handler 回归正在执行。
|
||||
@@ -9,7 +9,7 @@
|
||||
1. 打开「安全防护 → 调用拦截」。默认开启「政府网站保护」,匹配 `.gov`、`.gov.cn` 等政府域名及通配符写法,忽略大小写。旧配置没有 `tool_guard` 时也启用默认保护。
|
||||
2. 规则默认折叠,列表展示名称、提醒摘要和启停开关。点击规则展开编辑;「添加规则」打开独立弹窗,可以填写并验证尚未添加的规则。点击「添加到列表」通过正则校验后加入页面草稿,取消不会留下空规则。校验失败会定位到对应字段。可以单独启停规则,也可以关闭总开关。
|
||||
3. 点击页面顶部「全部规则验证」按当前顺序及启停状态检查全部规则。在已有规则编辑区点击「验证本条」,就在该规则下方输入工具名和 JSON 参数、查看命中文本与最终提醒;新增规则的单条验证直接在弹窗中完成。单条验证忽略总开关和该规则的启停状态,适合调试未启用规则。单条和全部验证分别保留输入与结果,均使用未保存的表单配置,不执行工具,也不改变运行中的规则。
|
||||
4. 点击保存。服务端校验全部规则后写入 `config.yaml` 的 `tool_guard`,立即生效,无需重启。校验或写入失败会保留原有规则。直接编辑 YAML 后需要重启服务;非空 `tool_guard` 必须明确填写 `enabled` 和 `rules`,清空规则使用 `[]`。
|
||||
4. 点击保存。服务端校验全部规则后写入 `config.yaml` 的 `tool_guard`,立即生效,无需重启。校验或写入失败会保留原有规则。多人基于同一版本编辑时,只有首次保存成功;后续旧版本保存会提示配置已被其他用户修改,并保留当前草稿。请先复制需要保留的修改,再刷新页面获取最新配置后重新编辑保存;直接重复提交不会覆盖他人修改。由于保存整个规则列表,编辑不同规则也会触发版本冲突。直接编辑 YAML 后需要重启服务;非空 `tool_guard` 必须明确填写 `enabled` 和 `rules`,清空规则使用 `[]`。
|
||||
|
||||
规则按列表顺序检查,首先命中的启用规则决定提醒。检查对象包括工具名称、参数的 JSON 表示、嵌套字符串和键名,以及最多三轮常见 URL 百分号解码后的文本。使用 Go/RE2 正则语法,例如 `(?i)` 表示忽略大小写;不支持回溯引用和环视。最多 100 条规则,正则和提醒各最多 4096 字节。禁用的规则也须通过校验。
|
||||
|
||||
@@ -33,6 +33,6 @@
|
||||
|
||||
## API
|
||||
|
||||
- `GET /api/tool-guard`:返回生效配置 `{enabled, rules}`。
|
||||
- `PUT /api/tool-guard`:保存相同结构;每条规则含 `id`、`name`、`enabled`、`pattern`、`message`。必须明确提供总开关和规则数组。
|
||||
- `GET /api/tool-guard`:返回生效配置 `{enabled, rules}`,响应头包含 `ETag`,并禁止缓存。
|
||||
- `PUT /api/tool-guard`:保存相同结构;每条规则含 `id`、`name`、`enabled`、`pattern`、`message`。必须明确提供总开关和规则数组,并将读取时的完整 `ETag`(包含引号)原样放入 `If-Match` 请求头。缺少版本返回 HTTP 428 / `tool_guard_version_required`;版本过期返回 HTTP 409 / `tool_guard_conflict`,均不保存。成功返回新的 `ETag`;服务重启后须重新读取版本。旧客户端也必须遵循此协议,不能使用 `*` 绕过检测。版本保护适用于当前服务实例,不提供多实例共享配置协调。
|
||||
- `POST /api/tool-guard/test`:请求 `{config, toolName, arguments}`,响应 `{blocked, match?}`;`match` 含 `ruleId`、`ruleName`、`matchedText`、`message`。
|
||||
@@ -98,6 +98,7 @@ type ConfigHandler struct {
|
||||
logger *zap.Logger
|
||||
mu sync.RWMutex
|
||||
toolGuard *toolguard.Manager
|
||||
toolGuardRevision string // protected by mu; advanced only after a successful save
|
||||
lastEmbeddingConfig *config.EmbeddingConfig // 上一次的嵌入模型配置(用于检测变更)
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ package handler
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
@@ -12,6 +13,7 @@ import (
|
||||
"cyberstrike-ai/internal/toolguard"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
@@ -19,15 +21,28 @@ func (h *ConfigHandler) SetToolGuard(manager *toolguard.Manager) {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
h.toolGuard = manager
|
||||
h.toolGuardRevision = uuid.NewString()
|
||||
}
|
||||
|
||||
// Call with h.mu held. The nonce fences no-op saves and ABA; the hash detects
|
||||
// policy changes made directly through the manager as well.
|
||||
func (h *ConfigHandler) toolGuardETag() string {
|
||||
if h.toolGuardRevision == "" {
|
||||
h.toolGuardRevision = uuid.NewString()
|
||||
}
|
||||
data, _ := json.Marshal(h.toolGuard.Config())
|
||||
return fmt.Sprintf(`"%s-%x"`, h.toolGuardRevision, sha256.Sum256(data))
|
||||
}
|
||||
|
||||
func (h *ConfigHandler) GetToolGuard(c *gin.Context) {
|
||||
h.mu.RLock()
|
||||
defer h.mu.RUnlock()
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
if h.toolGuard == nil {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "调用拦截服务未初始化"})
|
||||
return
|
||||
}
|
||||
c.Header("ETag", h.toolGuardETag())
|
||||
c.Header("Cache-Control", "no-store")
|
||||
c.JSON(http.StatusOK, h.toolGuard.Config())
|
||||
}
|
||||
|
||||
@@ -72,6 +87,14 @@ func (h *ConfigHandler) UpdateToolGuard(c *gin.Context) {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "调用拦截服务未初始化"})
|
||||
return
|
||||
}
|
||||
if c.GetHeader("If-Match") == "" {
|
||||
c.JSON(http.StatusPreconditionRequired, gin.H{"code": "tool_guard_version_required", "error": "缺少调用拦截配置版本,请刷新页面后重试"})
|
||||
return
|
||||
}
|
||||
if c.GetHeader("If-Match") != h.toolGuardETag() {
|
||||
c.JSON(http.StatusConflict, gin.H{"code": "tool_guard_conflict", "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 {
|
||||
@@ -85,11 +108,14 @@ func (h *ConfigHandler) UpdateToolGuard(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
h.config.ToolGuard = &cfg
|
||||
h.toolGuardRevision = uuid.NewString()
|
||||
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.Header("ETag", h.toolGuardETag())
|
||||
c.Header("Cache-Control", "no-store")
|
||||
c.JSON(http.StatusOK, h.toolGuard.Config())
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"cyberstrike-ai/internal/config"
|
||||
"cyberstrike-ai/internal/toolguard"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func toolGuardVersionRequest(t *testing.T, h *ConfigHandler, body interface{}, etag string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
return toolGuardRequest(t, func(c *gin.Context) {
|
||||
c.Request.Header.Set("If-Match", etag)
|
||||
h.UpdateToolGuard(c)
|
||||
}, body)
|
||||
}
|
||||
|
||||
func toolGuardUpdateRequest(t *testing.T, h *ConfigHandler, body interface{}) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
etag := toolGuardRequest(t, h.GetToolGuard, nil).Header().Get("ETag")
|
||||
return toolGuardVersionRequest(t, h, body, etag)
|
||||
}
|
||||
|
||||
func TestToolGuardConcurrentSameVersionHasOneWinner(t *testing.T) {
|
||||
h := newToolGuardTestHandler(t)
|
||||
read := toolGuardRequest(t, h.GetToolGuard, nil)
|
||||
etag := read.Header().Get("ETag")
|
||||
if etag == "" || read.Header().Get("Cache-Control") != "no-store" {
|
||||
t.Fatal("GET omitted version or cache protection")
|
||||
}
|
||||
const users = 12
|
||||
start := make(chan struct{})
|
||||
results := make(chan *httptest.ResponseRecorder, users)
|
||||
for i := 0; i < users; i++ {
|
||||
go func(i int) {
|
||||
cfg := toolguard.DefaultConfig()
|
||||
cfg.Rules[0].Message = fmt.Sprintf("user-%d", i)
|
||||
<-start
|
||||
results <- toolGuardVersionRequest(t, h, cfg, etag)
|
||||
}(i)
|
||||
}
|
||||
close(start)
|
||||
var winner toolguard.Config
|
||||
var winnerVersion string
|
||||
wins := 0
|
||||
for i := 0; i < users; i++ {
|
||||
w := <-results
|
||||
switch w.Code {
|
||||
case http.StatusOK:
|
||||
wins++
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &winner); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
winnerVersion = w.Header().Get("ETag")
|
||||
if winnerVersion == "" || winnerVersion == etag {
|
||||
t.Fatal("successful save did not advance the version")
|
||||
}
|
||||
case http.StatusConflict:
|
||||
if !strings.Contains(w.Body.String(), "tool_guard_conflict") {
|
||||
t.Fatal(w.Body.String())
|
||||
}
|
||||
default:
|
||||
t.Fatalf("unexpected response: %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
if wins != 1 {
|
||||
t.Fatalf("got %d successful saves", wins)
|
||||
}
|
||||
loaded, err := config.Load(h.configPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !reflect.DeepEqual(winner, loaded.EffectiveToolGuard()) || !reflect.DeepEqual(winner, h.toolGuard.Config()) {
|
||||
t.Fatal("a losing request overwrote the winner")
|
||||
}
|
||||
if read := toolGuardRequest(t, h.GetToolGuard, nil); read.Header().Get("ETag") != winnerVersion {
|
||||
t.Fatal("save receipt and GET version differ")
|
||||
}
|
||||
// An old draft still fails even when its HTTP request is no longer concurrent.
|
||||
winner.Enabled = false
|
||||
if w := toolGuardVersionRequest(t, h, winner, etag); w.Code != http.StatusConflict {
|
||||
t.Fatal("sequential stale draft was accepted")
|
||||
}
|
||||
if w := toolGuardVersionRequest(t, h, winner, winnerVersion); w.Code != http.StatusOK {
|
||||
t.Fatal("fresh version could not save", w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestToolGuardPreconditionsAndNoOpFence(t *testing.T) {
|
||||
h := newToolGuardTestHandler(t)
|
||||
cfg := toolguard.DefaultConfig()
|
||||
before, _ := os.ReadFile(h.configPath)
|
||||
for _, etag := range []string{"", "*", `W/"old"`, `"unknown"`} {
|
||||
w := toolGuardVersionRequest(t, h, cfg, etag)
|
||||
want := http.StatusConflict
|
||||
if etag == "" {
|
||||
want = http.StatusPreconditionRequired
|
||||
}
|
||||
if w.Code != want {
|
||||
t.Fatalf("got %d, want %d", w.Code, want)
|
||||
}
|
||||
}
|
||||
after, _ := os.ReadFile(h.configPath)
|
||||
if !bytes.Equal(before, after) || h.config.ToolGuard != nil || !reflect.DeepEqual(cfg, h.toolGuard.Config()) {
|
||||
t.Fatal("rejected request changed file or runtime")
|
||||
}
|
||||
etag := toolGuardRequest(t, h.GetToolGuard, nil).Header().Get("ETag")
|
||||
if w := toolGuardVersionRequest(t, h, cfg, etag); w.Code != http.StatusOK {
|
||||
t.Fatal(w.Body.String())
|
||||
}
|
||||
if w := toolGuardVersionRequest(t, h, cfg, etag); w.Code != http.StatusConflict {
|
||||
t.Fatal("no-op save left old version valid")
|
||||
}
|
||||
}
|
||||
|
||||
func TestToolGuardFailedPersistenceRetainsVersion(t *testing.T) {
|
||||
h := newToolGuardTestHandler(t)
|
||||
etag := toolGuardRequest(t, h.GetToolGuard, nil).Header().Get("ETag")
|
||||
path := h.configPath
|
||||
h.configPath = path + "/missing.yaml"
|
||||
cfg := toolguard.DefaultConfig()
|
||||
cfg.Enabled = false
|
||||
if w := toolGuardVersionRequest(t, h, cfg, etag); w.Code != http.StatusInternalServerError {
|
||||
t.Fatal(w.Code, w.Body.String())
|
||||
}
|
||||
if toolGuardRequest(t, h.GetToolGuard, nil).Header().Get("ETag") != etag || !h.toolGuard.Config().Enabled {
|
||||
t.Fatal("failed persistence advanced version or changed policy")
|
||||
}
|
||||
h.configPath = path
|
||||
if w := toolGuardVersionRequest(t, h, cfg, etag); w.Code != http.StatusOK {
|
||||
t.Fatal("safe retry failed", w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestToolGuardManagerUpdateInvalidatesVersion(t *testing.T) {
|
||||
h := newToolGuardTestHandler(t)
|
||||
etag := toolGuardRequest(t, h.GetToolGuard, nil).Header().Get("ETag")
|
||||
cfg := toolguard.DefaultConfig()
|
||||
cfg.Enabled = false
|
||||
if err := h.toolGuard.Update(cfg); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if w := toolGuardVersionRequest(t, h, toolguard.DefaultConfig(), etag); w.Code != http.StatusConflict || h.toolGuard.Config().Enabled {
|
||||
t.Fatal("direct policy update was overwritten")
|
||||
}
|
||||
}
|
||||
@@ -56,7 +56,7 @@ func TestToolGuardSavePersistsAndAppliesWithoutChangingHITL(t *testing.T) {
|
||||
}
|
||||
cfg := toolguard.DefaultConfig()
|
||||
cfg.Rules[0].Message = "识别到 {match},禁止攻击政府网站,请检查目标。"
|
||||
w := toolGuardRequest(t, h.UpdateToolGuard, cfg)
|
||||
w := toolGuardUpdateRequest(t, h, cfg)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("save: %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
@@ -80,7 +80,7 @@ func TestToolGuardSavePersistsAndAppliesWithoutChangingHITL(t *testing.T) {
|
||||
t.Fatalf("updated message not applied: %+v", match)
|
||||
}
|
||||
cfg.Enabled = false
|
||||
w = toolGuardRequest(t, h.UpdateToolGuard, cfg)
|
||||
w = toolGuardUpdateRequest(t, h, 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")
|
||||
}
|
||||
@@ -93,7 +93,7 @@ func TestToolGuardInvalidAndFailedSaveKeepEffectivePolicy(t *testing.T) {
|
||||
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)
|
||||
w := toolGuardUpdateRequest(t, h, body)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("invalid update accepted: %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
@@ -105,7 +105,7 @@ func TestToolGuardInvalidAndFailedSaveKeepEffectivePolicy(t *testing.T) {
|
||||
h.configPath = filepath.Join(t.TempDir(), "missing", "config.yaml")
|
||||
cfg = toolguard.DefaultConfig()
|
||||
cfg.Enabled = false
|
||||
w := toolGuardRequest(t, h.UpdateToolGuard, cfg)
|
||||
w := toolGuardUpdateRequest(t, h, cfg)
|
||||
if w.Code != http.StatusInternalServerError || !h.toolGuard.Config().Enabled || h.config.ToolGuard != nil {
|
||||
t.Fatal("failed persistence changed live configuration")
|
||||
}
|
||||
|
||||
@@ -1034,6 +1034,8 @@
|
||||
"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.",
|
||||
"saveConflict": "Another user has modified the call interception configuration. Your changes were not saved. Refresh the page to load the latest configuration and try again.",
|
||||
"versionRequired": "The call interception configuration version is missing. Refresh the page and try again.",
|
||||
"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",
|
||||
|
||||
@@ -1046,6 +1046,8 @@
|
||||
"loadFirst": "Сначала загрузите конфигурацию блокировки",
|
||||
"requiredFields": "Укажите имя и шаблон для каждого правила",
|
||||
"saveSuccess": "Конфигурация блокировки вызовов сохранена. Дальнейшие вызовы инструментов будут использовать новую конфигурацию.",
|
||||
"saveConflict": "Конфигурация перехвата вызовов изменена другим пользователем. Ваши изменения не сохранены. Обновите страницу и повторите попытку.",
|
||||
"versionRequired": "Отсутствует версия конфигурации перехвата вызовов. Обновите страницу и повторите попытку.",
|
||||
"saveFailed": "Не удалось сохранить",
|
||||
"defaultMessage": "Обнаружено {match}. Правило «{rule}» заблокировало этот вызов. Проверьте цель и операцию, прежде чем повторять.",
|
||||
"blocked": "Правило совпало: этот вызов будет заблокирован",
|
||||
|
||||
@@ -1022,6 +1022,8 @@
|
||||
"loadFirst": "请先加载拦截配置",
|
||||
"requiredFields": "请填写每条规则的名称和匹配正则",
|
||||
"saveSuccess": "调用拦截配置已保存,后续工具调用将使用新配置。",
|
||||
"saveConflict": "调用拦截配置已被其他用户修改,本次修改未保存。请刷新页面获取最新配置后重试。",
|
||||
"versionRequired": "缺少调用拦截配置版本,请刷新页面后重试。",
|
||||
"saveFailed": "保存失败",
|
||||
"defaultMessage": "识别到 {match},调用被规则「{rule}」拦截。请检查目标与操作后重试。",
|
||||
"blocked": "已命中规则:将阻止调用",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
const state = { config: null, saved: null, busy: false, testing: false, revision: 0, openRuleId: null, addDraft: null };
|
||||
const state = { config: null, saved: null, etag: 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');
|
||||
@@ -281,16 +281,18 @@
|
||||
updateControls();
|
||||
}
|
||||
|
||||
async function request(url, method, body) {
|
||||
async function request(url, method, body, versioned = false) {
|
||||
const options = { method: method || 'GET' };
|
||||
if (body !== undefined) {
|
||||
options.headers = { 'Content-Type': 'application/json' };
|
||||
if (versioned && method === 'PUT' && state.etag) options.headers['If-Match'] = state.etag;
|
||||
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;
|
||||
if (!response.ok) throw new Error(result.code === 'tool_guard_conflict' ? tr('saveConflict') :
|
||||
result.code === 'tool_guard_version_required' ? tr('versionRequired') : result.error || tr('requestFailed'));
|
||||
return versioned ? { config: result, etag: response.headers.get('ETag') } : result;
|
||||
}
|
||||
|
||||
function normalizeConfig(config) {
|
||||
@@ -311,7 +313,9 @@
|
||||
feedback('');
|
||||
updateControls();
|
||||
try {
|
||||
const config = normalizeConfig(await request('/api/tool-guard'));
|
||||
const result = await request('/api/tool-guard', 'GET', undefined, true);
|
||||
const config = normalizeConfig(result.config);
|
||||
state.etag = result.etag;
|
||||
state.saved = copy(config);
|
||||
state.config = config;
|
||||
invalidateTest();
|
||||
@@ -377,7 +381,9 @@
|
||||
state.busy = true;
|
||||
feedback('');
|
||||
updateControls();
|
||||
const saved = normalizeConfig(await request('/api/tool-guard', 'PUT', config));
|
||||
const result = await request('/api/tool-guard', 'PUT', config, true);
|
||||
const saved = normalizeConfig(result.config);
|
||||
state.etag = result.etag;
|
||||
state.saved = copy(saved);
|
||||
state.config = saved;
|
||||
invalidateTest();
|
||||
|
||||
@@ -80,7 +80,7 @@ function harness(permissions = ['config:read', 'config:write']) {
|
||||
}
|
||||
});
|
||||
vm.runInContext(source, context);
|
||||
const reply = (body, ok = true) => queue.push(async () => ({ ok, json: async () => body }));
|
||||
const reply = (body, ok = true, etag = '"v1"') => queue.push(async () => ({ ok, headers: { get: () => etag }, 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 };
|
||||
@@ -109,6 +109,77 @@ function runLocal(h, prefix) {
|
||||
return h.element(prefix + '-test-run').listeners.click();
|
||||
}
|
||||
|
||||
test('two pages sharing a version have one winner and the loser retains its draft and version', async () => {
|
||||
const a = harness(), b = harness();
|
||||
let stored = config(), version = '"v1"';
|
||||
for (const h of [a, b]) {
|
||||
h.reply(stored, true, version);
|
||||
await h.window.loadToolGuardConfig();
|
||||
}
|
||||
fillField(a, 'rule-0-message', 'first user');
|
||||
fillField(b, 'rule-0-message', 'second user');
|
||||
const submit = async (h) => {
|
||||
h.queue.push(async () => {
|
||||
const call = h.calls.at(-1);
|
||||
if (call.options.headers['If-Match'] !== version) return {
|
||||
ok: false, headers: { get: () => version },
|
||||
json: async () => ({ code: 'tool_guard_conflict', error: 'conflict' })
|
||||
};
|
||||
stored = JSON.parse(call.options.body);
|
||||
version = '"v' + (Number(version.slice(2, -1)) + 1) + '"';
|
||||
return { ok: true, headers: { get: () => version }, json: async () => stored };
|
||||
});
|
||||
await h.window.saveToolGuardConfig();
|
||||
};
|
||||
await Promise.all([submit(a), submit(b)]);
|
||||
assert.equal(stored.rules[0].message, 'first user');
|
||||
assert.equal(a.element('save').disabled, true);
|
||||
assert.equal(b.element('save').disabled, false);
|
||||
assert.equal(b.element('rule-0-message').value, 'second user');
|
||||
assert.match(b.element('feedback').textContent, /Another user/);
|
||||
await submit(b);
|
||||
assert.equal(b.calls.at(-1).options.headers['If-Match'], '"v1"');
|
||||
assert.equal(stored.rules[0].message, 'first user');
|
||||
fillField(a, 'rule-0-name', 'fresh edit');
|
||||
await submit(a);
|
||||
assert.equal(a.calls.at(-1).options.headers['If-Match'], '"v2"');
|
||||
// Discard the stale draft, then reload to intentionally edit the new baseline.
|
||||
b.window.resetToolGuardConfig();
|
||||
b.reply(stored, true, version);
|
||||
await b.window.loadToolGuardConfig();
|
||||
fillField(b, 'rule-0-message', 'rebased edit');
|
||||
await submit(b);
|
||||
assert.equal(stored.rules[0].message, 'rebased edit');
|
||||
});
|
||||
|
||||
test('failed save keeps its version for retry, dry-run has no precondition, missing version fails visibly', async () => {
|
||||
const h = harness();
|
||||
h.reply(config(), true, '"original"');
|
||||
await h.window.loadToolGuardConfig();
|
||||
fillField(h, 'rule-0-name', 'edited');
|
||||
h.reply({ error: 'disk full' }, false, '"untrusted"');
|
||||
await h.window.saveToolGuardConfig();
|
||||
assert.equal(h.calls.at(-1).options.headers['If-Match'], '"original"');
|
||||
h.reply({ blocked: false });
|
||||
await h.window.testToolGuardConfig();
|
||||
assert.equal(h.calls.at(-1).options.headers['If-Match'], undefined);
|
||||
h.reply({ ...config(), rules: [{ ...config().rules[0], name: 'edited' }] }, true, '"updated"');
|
||||
await h.window.saveToolGuardConfig();
|
||||
assert.equal(h.calls.at(-1).options.headers['If-Match'], '"original"');
|
||||
fillField(h, 'rule-0-name', 'next');
|
||||
h.reply({ ...config(), rules: [{ ...config().rules[0], name: 'next' }] }, true, '"next"');
|
||||
await h.window.saveToolGuardConfig();
|
||||
assert.equal(h.calls.at(-1).options.headers['If-Match'], '"updated"');
|
||||
const old = harness();
|
||||
old.reply(config(), true, null);
|
||||
await old.window.loadToolGuardConfig();
|
||||
fillField(old, 'rule-0-name', 'draft');
|
||||
old.reply({ code: 'tool_guard_version_required' }, false);
|
||||
await old.window.saveToolGuardConfig();
|
||||
assert.match(old.element('feedback').textContent, /version is missing/);
|
||||
assert.equal(old.element('rule-0-name').value, 'draft');
|
||||
});
|
||||
|
||||
test('test uses the unsaved configuration and backend RE2 validation without saving or executing tools', async () => {
|
||||
const h = harness();
|
||||
h.reply(config());
|
||||
|
||||
@@ -7002,7 +7002,7 @@
|
||||
<script src="/static/js/chat.js?v=20261009-retest-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/tool-guard.js?v=20261010-conflict"></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>
|
||||
|
||||
Reference in new issue
Block a user