fix: reject stale concurrent interception rule saves

This commit is contained in:
Ed1s0nZ committed 2026-10-10 17:18:13 +08:00
1 parent 4a1d0e114e
commit 1ecbd4054c
14 files changed
+295 -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`.
@@ -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 回归正在执行。
+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>