Merge pull request #351 from AIPentest/codex/tool-guard-conflict-detection

fix: detect concurrent interception rule edits
This commit is contained in:
公明 authored and GitHub committed 2026-10-10 17:34:51 +08:00
commit 2335f1b639
14 files changed
+354 -19

No files matched your search

+2
View File
@@ -4,6 +4,8 @@
### Fixed
- 修复多人编辑调用拦截规则时旧草稿覆盖他人修改的问题:同一版本仅一次保存成功,冲突保留草稿并提示刷新;API 保存必须携带读取时的 ETag/If-Match。
- 修复平台角色更新/删除使全体用户掉线的问题:仅撤销受影响角色成员的会话;名称、描述及等价权限集合更新保持会话有效。
- 修复俄语等长文案导致的仪表盘状态卡片、风险徽标与队列标签溢出;侧栏及风险名称完整换行,布局随卡片可用宽度适配。
+4 -2
View File
@@ -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`.
@@ -0,0 +1,65 @@
# 调用拦截配置修改冲突检测
## F0 Intake / Gates
2026-10-10,用户要求修复已核实的多用户规则覆盖问题,并明确目标为当前开源版 CyberStrikeAI。main 起点 a8321c4b,工作目录干净;分支 codex/tool-guard-conflict-detection。Pro 版由其他聊天处理,本分支不编辑其文件。
Workflow Gate:P10 缺陷反馈 → P7 局部修复 → P9 验证。上游为用户问题、截图、已完成的源码核实;允许实现保存冲突检测,验收同版本只有一次成功,失败保留草稿并提示刷新。
Maintainability Gate:config.go 2940 行、tool_guard.go 175 行、tool-guard.js 732 行。风险高,按 narrow_fix 执行;config.go 只加一个受现有锁保护的版本字段,行为放在现有专项 handler;不新增全局配置职责,不做无关重构。前后端接口联动,需 handler race 测试和现有前端测试。
Lifecycle Report:F0–F6;影响 handler、调用拦截 JS 和文档;权限和业务规则保持原有约束。GET/PUT 增加版本头,旧无条件 PUT 将拒绝,这是用户要求的覆盖保护所必需;客户端须先读取版本。阶段文档使用本文件,逐阶段提交推送。未要求合并、发布或部署。
## F1 Requirements
依据:用户已明确提出只有一个用户修改成功、其他用户失败并提示,随后明确要求实施修复且指定开源仓库。此处记录已有授权,不将未被确认的新文档标成用户批准。
- REQ-001 / AC-001:两个或更多客户端基于同一配置版本保存,只有一次成功;失败请求不改变成功方配置。
- REQ-002 / AC-002:冲突有合理错误提示;本地草稿保留,刷新获取最新配置后可以重新编辑保存。
- REQ-003 / AC-003:输入校验、权限、试匹配、落盘失败保持原策略行为继续成立。
- 范围解释:现接口提交整个列表,按整份配置保护,避免不同规则的旧快照相互覆盖。无需等待用户退出编辑,正常刷新后的后续修改允许保存。
- 非目标:编辑期间占用租约、多实例共享配置支持、发布或部署。
## F2 Design
GET /api/tool-guard 的 JSON 不变,提供强 ETag 和 Cache-Control: no-store。版本由随机 nonce 和当前有效配置摘要组成;每次成功保存更换 nonce(包括无变化保存),直接更新 manager 也会改变摘要,防止 ABA 旧版本恢复。版本状态归 ConfigHandler,读取/比较/推进由 h.mu 保护。
PUT 必须携带完整精确匹配的 If-Match;未提供返回 428/tool_guard_version_required,不匹配返回 409/tool_guard_conflict。校验、落盘、策略发布、版本推进在同一个保存临界区,失败不推进版本、不返回成功;成功返回新 ETag。星号、弱标签不作为覆盖保护绕过方式。服务重启后旧版本失效。
前端将 ETag 独立保存,PUT 携带读取时版本,成功才更新;冲突不采用错误响应上的新版本、不自动重试,保留原输入。中英俄文提示:配置已被其他用户修改,本次修改未保存,请刷新后重试。试匹配不带 If-Match,不更新版本。缺少响应版本不能静默发无条件写入。
兼容:旧 API 客户端需先 GET 再使用返回 ETag 提交 PUT;前后端同时升级,更新脚本缓存版本。整个列表任意编辑触发版本变化,不同规则同时保存也会冲突。仅保证当前服务实例内协调;多实例共享配置需另行引入持久化条件更新。
## F3 Implementation Plan
修改 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 回归正在执行。
## F5 Verification / Documentation
- `go test -race ./internal/handler -run ToolGuard -count=1`:PASS,3.246s。读写和冲突比较均使用原锁,无数据竞争报告。
- `go test ./internal/handler -count=1`:PASS,20.512s。共享 ConfigHandler 和其他 handler 完整包回归通过。
- `node --test web/static/js/tool-guard.test.cjs`:36/36 PASS,135ms。
- `node --check web/static/js/tool-guard.js`、中英俄 JSON 解析与新提示存在校验、`git diff --check`:PASS。
- 中英调用拦截文档和 Unreleased changelog 已记录 GET/PUT 版本协议、错误码、草稿恢复和旧客户端迁移。
- 检查实际 apiFetch:使用 Headers 保留传入 If-Match,返回原生 Response,409/428 交由调用方处理。i18n 使用 no-cache;JS 模板缓存版本已更新。
- 未执行线上双账号写入、完整仓库套件或浏览器视觉检查:本次不部署、不改变视觉布局,测试使用临时文件、handler 请求上下文和现有前端 DOM fixture。多实例保证不在本次范围。
实现提交 `1ecbd405` 已推送;F0–F3 文档提交为 `65f947c9`、`7e6feeb9`、`23034ffa`、`4a1d0e11`,均已推送。
## F6 Review / Merge Readiness
局部复核:版本比较与文件写入、策略发布、版本推进在同一 h.mu 临界区;被拒绝请求在写入前返回;合法保存继续使用原 configFileMu 与原子 rename。前端仅成功 GET/PUT 更新版本,冲突或落盘失败不改变基线,不自动覆盖;无条件客户端无法绕过。HEAD 工作目录干净,阶段记录与实现均已推送,测试和稳定文档一致。
草稿 PR 描述:两名用户读取相同规则后分别保存时,原接口会接受旧草稿并覆盖首次保存。增加强 ETag/If-Match 保护,使同一版本仅一次成功,旧版本返回 409、缺版本返回 428;前端保留冲突草稿,并提供中英俄提示。验证:专项 race、完整 handler、36 项前端测试、JS syntax/JSON/diff 检查通过。API 调用方需先 GET 再携带 ETag 保存。整个规则列表按同一版本保护,需前后端同时升级。
代码修复完成,待 PR 审阅合并;未发布或部署,不声明线上已解决。多实例共享配置、编辑占用租约不在此修复范围。
草稿 PR:[AIPentest/CyberStrikeAI #351](https://github.com/AIPentest/CyberStrikeAI/pull/351)。F5 验证记录 7302c3d8、F6 审阅记录 8477bfcc 均已推送。远端镜像:未同步(本次未构建镜像)。
+3 -3
View File
@@ -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`。
+1
View File
@@ -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 // 上一次的嵌入模型配置(用于检测变更)
}
+28 -2
View File
@@ -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")
}
}
+4 -4
View File
@@ -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")
}
+2
View File
@@ -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",
+2
View File
@@ -1046,6 +1046,8 @@
"loadFirst": "Сначала загрузите конфигурацию блокировки",
"requiredFields": "Укажите имя и шаблон для каждого правила",
"saveSuccess": "Конфигурация блокировки вызовов сохранена. Дальнейшие вызовы инструментов будут использовать новую конфигурацию.",
"saveConflict": "Конфигурация перехвата вызовов изменена другим пользователем. Ваши изменения не сохранены. Обновите страницу и повторите попытку.",
"versionRequired": "Отсутствует версия конфигурации перехвата вызовов. Обновите страницу и повторите попытку.",
"saveFailed": "Не удалось сохранить",
"defaultMessage": "Обнаружено {match}. Правило «{rule}» заблокировало этот вызов. Проверьте цель и операцию, прежде чем повторять.",
"blocked": "Правило совпало: этот вызов будет заблокирован",
+2
View File
@@ -1022,6 +1022,8 @@
"loadFirst": "请先加载拦截配置",
"requiredFields": "请填写每条规则的名称和匹配正则",
"saveSuccess": "调用拦截配置已保存,后续工具调用将使用新配置。",
"saveConflict": "调用拦截配置已被其他用户修改,本次修改未保存。请刷新页面获取最新配置后重试。",
"versionRequired": "缺少调用拦截配置版本,请刷新页面后重试。",
"saveFailed": "保存失败",
"defaultMessage": "识别到 {match},调用被规则「{rule}」拦截。请检查目标与操作后重试。",
"blocked": "已命中规则:将阻止调用",
+12 -6
View File
@@ -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();
+72 -1
View File
@@ -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());
+1 -1
View File
@@ -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>