From 65f947c9df2aaeac3ebadc062d989a3c6c73dc2b Mon Sep 17 00:00:00 2001 From: Ed1s0nZ Date: Sat, 10 Oct 2026 17:14:13 +0800 Subject: [PATCH 1/8] docs: record interception conflict fix intake --- docs/feature/tool-guard-conflict-detection.md | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 docs/feature/tool-guard-conflict-detection.md diff --git a/docs/feature/tool-guard-conflict-detection.md b/docs/feature/tool-guard-conflict-detection.md new file mode 100644 index 00000000..47d08956 --- /dev/null +++ b/docs/feature/tool-guard-conflict-detection.md @@ -0,0 +1,11 @@ +# 调用拦截配置修改冲突检测 + +## 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 将拒绝,这是用户要求的覆盖保护所必需;客户端须先读取版本。阶段文档使用本文件,逐阶段提交推送。未要求合并、发布或部署。 From 7e6feeb96bd31c4fff059b7adae9ed1035a1b5b0 Mon Sep 17 00:00:00 2001 From: Ed1s0nZ Date: Sat, 10 Oct 2026 17:14:29 +0800 Subject: [PATCH 2/8] docs: record authorized conflict protection requirements --- docs/feature/tool-guard-conflict-detection.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/docs/feature/tool-guard-conflict-detection.md b/docs/feature/tool-guard-conflict-detection.md index 47d08956..2a085eea 100644 --- a/docs/feature/tool-guard-conflict-detection.md +++ b/docs/feature/tool-guard-conflict-detection.md @@ -9,3 +9,13 @@ 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:输入校验、权限、试匹配、落盘失败保持原策略行为继续成立。 +- 范围解释:现接口提交整个列表,按整份配置保护,避免不同规则的旧快照相互覆盖。无需等待用户退出编辑,正常刷新后的后续修改允许保存。 +- 非目标:编辑期间占用租约、多实例共享配置支持、发布或部署。 From 23034ffa45c9471cacdabf8294a37d631ad78e85 Mon Sep 17 00:00:00 2001 From: Ed1s0nZ Date: Sat, 10 Oct 2026 17:14:48 +0800 Subject: [PATCH 3/8] docs: design interception optimistic concurrency contract --- docs/feature/tool-guard-conflict-detection.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/docs/feature/tool-guard-conflict-detection.md b/docs/feature/tool-guard-conflict-detection.md index 2a085eea..9866d468 100644 --- a/docs/feature/tool-guard-conflict-detection.md +++ b/docs/feature/tool-guard-conflict-detection.md @@ -19,3 +19,13 @@ Lifecycle Report:F0–F6;影响 handler、调用拦截 JS 和文档;权限 - 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;前后端同时升级,更新脚本缓存版本。整个列表任意编辑触发版本变化,不同规则同时保存也会冲突。仅保证当前服务实例内协调;多实例共享配置需另行引入持久化条件更新。 From 4a1d0e114e20f3b6460154f8aae907c04eebf32d Mon Sep 17 00:00:00 2001 From: Ed1s0nZ Date: Sat, 10 Oct 2026 17:15:05 +0800 Subject: [PATCH 4/8] docs: plan interception conflict fix validation --- docs/feature/tool-guard-conflict-detection.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/feature/tool-guard-conflict-detection.md b/docs/feature/tool-guard-conflict-detection.md index 9866d468..31a71519 100644 --- a/docs/feature/tool-guard-conflict-detection.md +++ b/docs/feature/tool-guard-conflict-detection.md @@ -29,3 +29,9 @@ PUT 必须携带完整精确匹配的 If-Match;未提供返回 428/tool_guard_ 前端将 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,不触碰线上业务数据。回滚需要前后端一起回滚,但会恢复覆盖风险;不执行部署。 From 1ecbd4054c80f3fbdd44010505632f7b2c9e616c Mon Sep 17 00:00:00 2001 From: Ed1s0nZ Date: Sat, 10 Oct 2026 17:18:13 +0800 Subject: [PATCH 5/8] fix: reject stale concurrent interception rule saves --- CHANGELOG.md | 2 + docs/en-US/tool-call-guard.md | 6 +- docs/feature/tool-guard-conflict-detection.md | 6 + docs/zh-CN/tool-call-guard.md | 6 +- internal/handler/config.go | 1 + internal/handler/tool_guard.go | 30 +++- internal/handler/tool_guard_conflict_test.go | 156 ++++++++++++++++++ internal/handler/tool_guard_test.go | 8 +- web/static/i18n/en-US.json | 2 + web/static/i18n/ru-RU.json | 2 + web/static/i18n/zh-CN.json | 2 + web/static/js/tool-guard.js | 18 +- web/static/js/tool-guard.test.cjs | 73 +++++++- web/templates/index.html | 2 +- 14 files changed, 295 insertions(+), 19 deletions(-) create mode 100644 internal/handler/tool_guard_conflict_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index b00937e0..2851d45c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ ### Fixed +- 修复多人编辑调用拦截规则时旧草稿覆盖他人修改的问题:同一版本仅一次保存成功,冲突保留草稿并提示刷新;API 保存必须携带读取时的 ETag/If-Match。 + - 修复平台角色更新/删除使全体用户掉线的问题:仅撤销受影响角色成员的会话;名称、描述及等价权限集合更新保持会话有效。 - 修复俄语等长文案导致的仪表盘状态卡片、风险徽标与队列标签溢出;侧栏及风险名称完整换行,布局随卡片可用宽度适配。 diff --git a/docs/en-US/tool-call-guard.md b/docs/en-US/tool-call-guard.md index 243d6a4a..27ba9696 100644 --- a/docs/en-US/tool-call-guard.md +++ b/docs/en-US/tool-call-guard.md @@ -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...)` 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`. diff --git a/docs/feature/tool-guard-conflict-detection.md b/docs/feature/tool-guard-conflict-detection.md index 31a71519..59bb7ee3 100644 --- a/docs/feature/tool-guard-conflict-detection.md +++ b/docs/feature/tool-guard-conflict-detection.md @@ -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 回归正在执行。 diff --git a/docs/zh-CN/tool-call-guard.md b/docs/zh-CN/tool-call-guard.md index c6233830..6eab899f 100644 --- a/docs/zh-CN/tool-call-guard.md +++ b/docs/zh-CN/tool-call-guard.md @@ -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`。 diff --git a/internal/handler/config.go b/internal/handler/config.go index 7176b959..d36c8e55 100644 --- a/internal/handler/config.go +++ b/internal/handler/config.go @@ -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 // 上一次的嵌入模型配置(用于检测变更) } diff --git a/internal/handler/tool_guard.go b/internal/handler/tool_guard.go index 7fcbfd6f..892e7b64 100644 --- a/internal/handler/tool_guard.go +++ b/internal/handler/tool_guard.go @@ -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()) } diff --git a/internal/handler/tool_guard_conflict_test.go b/internal/handler/tool_guard_conflict_test.go new file mode 100644 index 00000000..89bd1342 --- /dev/null +++ b/internal/handler/tool_guard_conflict_test.go @@ -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") + } +} diff --git a/internal/handler/tool_guard_test.go b/internal/handler/tool_guard_test.go index e2d77cd2..ef5e478b 100644 --- a/internal/handler/tool_guard_test.go +++ b/internal/handler/tool_guard_test.go @@ -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") } diff --git a/web/static/i18n/en-US.json b/web/static/i18n/en-US.json index da7465bd..14e72eb0 100644 --- a/web/static/i18n/en-US.json +++ b/web/static/i18n/en-US.json @@ -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", diff --git a/web/static/i18n/ru-RU.json b/web/static/i18n/ru-RU.json index e244f0c1..02370429 100644 --- a/web/static/i18n/ru-RU.json +++ b/web/static/i18n/ru-RU.json @@ -1046,6 +1046,8 @@ "loadFirst": "Сначала загрузите конфигурацию блокировки", "requiredFields": "Укажите имя и шаблон для каждого правила", "saveSuccess": "Конфигурация блокировки вызовов сохранена. Дальнейшие вызовы инструментов будут использовать новую конфигурацию.", + "saveConflict": "Конфигурация перехвата вызовов изменена другим пользователем. Ваши изменения не сохранены. Обновите страницу и повторите попытку.", + "versionRequired": "Отсутствует версия конфигурации перехвата вызовов. Обновите страницу и повторите попытку.", "saveFailed": "Не удалось сохранить", "defaultMessage": "Обнаружено {match}. Правило «{rule}» заблокировало этот вызов. Проверьте цель и операцию, прежде чем повторять.", "blocked": "Правило совпало: этот вызов будет заблокирован", diff --git a/web/static/i18n/zh-CN.json b/web/static/i18n/zh-CN.json index a668de79..6cf0dd9a 100644 --- a/web/static/i18n/zh-CN.json +++ b/web/static/i18n/zh-CN.json @@ -1022,6 +1022,8 @@ "loadFirst": "请先加载拦截配置", "requiredFields": "请填写每条规则的名称和匹配正则", "saveSuccess": "调用拦截配置已保存,后续工具调用将使用新配置。", + "saveConflict": "调用拦截配置已被其他用户修改,本次修改未保存。请刷新页面获取最新配置后重试。", + "versionRequired": "缺少调用拦截配置版本,请刷新页面后重试。", "saveFailed": "保存失败", "defaultMessage": "识别到 {match},调用被规则「{rule}」拦截。请检查目标与操作后重试。", "blocked": "已命中规则:将阻止调用", diff --git a/web/static/js/tool-guard.js b/web/static/js/tool-guard.js index 322964fb..3761e107 100644 --- a/web/static/js/tool-guard.js +++ b/web/static/js/tool-guard.js @@ -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(); diff --git a/web/static/js/tool-guard.test.cjs b/web/static/js/tool-guard.test.cjs index 7653e9a6..3bfe9f66 100644 --- a/web/static/js/tool-guard.test.cjs +++ b/web/static/js/tool-guard.test.cjs @@ -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()); diff --git a/web/templates/index.html b/web/templates/index.html index bbbb0409..85ed32fb 100644 --- a/web/templates/index.html +++ b/web/templates/index.html @@ -7002,7 +7002,7 @@ - + From 7302c3d822c33fdfb56d2d62067348b6329883c7 Mon Sep 17 00:00:00 2001 From: Ed1s0nZ Date: Sat, 10 Oct 2026 17:18:51 +0800 Subject: [PATCH 6/8] docs: record interception conflict fix verification --- docs/feature/tool-guard-conflict-detection.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/docs/feature/tool-guard-conflict-detection.md b/docs/feature/tool-guard-conflict-detection.md index 59bb7ee3..75eb6781 100644 --- a/docs/feature/tool-guard-conflict-detection.md +++ b/docs/feature/tool-guard-conflict-detection.md @@ -41,3 +41,15 @@ PUT 必须携带完整精确匹配的 If-Match;未提供返回 428/tool_guard_ 已实现持锁 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`,均已推送。 From 8477bfcc749d24e66815b8999bc8c5be72f097e3 Mon Sep 17 00:00:00 2001 From: Ed1s0nZ Date: Sat, 10 Oct 2026 17:19:28 +0800 Subject: [PATCH 7/8] docs: finalize interception conflict fix review record --- docs/feature/tool-guard-conflict-detection.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/docs/feature/tool-guard-conflict-detection.md b/docs/feature/tool-guard-conflict-detection.md index 75eb6781..4d15ad43 100644 --- a/docs/feature/tool-guard-conflict-detection.md +++ b/docs/feature/tool-guard-conflict-detection.md @@ -53,3 +53,11 @@ PUT 必须携带完整精确匹配的 If-Match;未提供返回 428/tool_guard_ - 未执行线上双账号写入、完整仓库套件或浏览器视觉检查:本次不部署、不改变视觉布局,测试使用临时文件、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 审阅合并;未发布或部署,不声明线上已解决。多实例共享配置、编辑占用租约不在此修复范围。 From 5d44b59c2fbd8ec2df27949daa791f503cdbe988 Mon Sep 17 00:00:00 2001 From: Ed1s0nZ Date: Sat, 10 Oct 2026 17:19:54 +0800 Subject: [PATCH 8/8] docs: link interception conflict fix pull request --- docs/feature/tool-guard-conflict-detection.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/feature/tool-guard-conflict-detection.md b/docs/feature/tool-guard-conflict-detection.md index 4d15ad43..93253001 100644 --- a/docs/feature/tool-guard-conflict-detection.md +++ b/docs/feature/tool-guard-conflict-detection.md @@ -61,3 +61,5 @@ PUT 必须携带完整精确匹配的 If-Match;未提供返回 428/tool_guard_ 草稿 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 均已推送。远端镜像:未同步(本次未构建镜像)。