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
@@ -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>