mirror of
https://github.com/Ed1s0nZ/CyberStrikeAI.git
synced 2026-10-21 23:01:37 +02:00
fix: reject stale concurrent interception rule saves
This commit is contained in:
1 parent
4a1d0e114e
commit
1ecbd4054c
14 files changed
+295
-19
No files matched your search
@@ -1034,6 +1034,8 @@
|
||||
"loadFirst": "Load the blocking configuration first",
|
||||
"requiredFields": "Enter a name and pattern for every rule",
|
||||
"saveSuccess": "Call blocking configuration saved. Subsequent tool calls will use the new configuration.",
|
||||
"saveConflict": "Another user has modified the call interception configuration. Your changes were not saved. Refresh the page to load the latest configuration and try again.",
|
||||
"versionRequired": "The call interception configuration version is missing. Refresh the page and try again.",
|
||||
"saveFailed": "Save failed",
|
||||
"defaultMessage": "Detected {match}. Rule “{rule}” blocked this call. Review the target and operation before trying again.",
|
||||
"blocked": "Rule matched: this call will be blocked",
|
||||
|
||||
@@ -1046,6 +1046,8 @@
|
||||
"loadFirst": "Сначала загрузите конфигурацию блокировки",
|
||||
"requiredFields": "Укажите имя и шаблон для каждого правила",
|
||||
"saveSuccess": "Конфигурация блокировки вызовов сохранена. Дальнейшие вызовы инструментов будут использовать новую конфигурацию.",
|
||||
"saveConflict": "Конфигурация перехвата вызовов изменена другим пользователем. Ваши изменения не сохранены. Обновите страницу и повторите попытку.",
|
||||
"versionRequired": "Отсутствует версия конфигурации перехвата вызовов. Обновите страницу и повторите попытку.",
|
||||
"saveFailed": "Не удалось сохранить",
|
||||
"defaultMessage": "Обнаружено {match}. Правило «{rule}» заблокировало этот вызов. Проверьте цель и операцию, прежде чем повторять.",
|
||||
"blocked": "Правило совпало: этот вызов будет заблокирован",
|
||||
|
||||
@@ -1022,6 +1022,8 @@
|
||||
"loadFirst": "请先加载拦截配置",
|
||||
"requiredFields": "请填写每条规则的名称和匹配正则",
|
||||
"saveSuccess": "调用拦截配置已保存,后续工具调用将使用新配置。",
|
||||
"saveConflict": "调用拦截配置已被其他用户修改,本次修改未保存。请刷新页面获取最新配置后重试。",
|
||||
"versionRequired": "缺少调用拦截配置版本,请刷新页面后重试。",
|
||||
"saveFailed": "保存失败",
|
||||
"defaultMessage": "识别到 {match},调用被规则「{rule}」拦截。请检查目标与操作后重试。",
|
||||
"blocked": "已命中规则:将阻止调用",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
const state = { config: null, saved: null, busy: false, testing: false, revision: 0, openRuleId: null, addDraft: null };
|
||||
const state = { config: null, saved: null, etag: null, busy: false, testing: false, revision: 0, openRuleId: null, addDraft: null };
|
||||
const ruleViews = new Map();
|
||||
const el = (id) => document.getElementById('tool-guard-' + id);
|
||||
const canRead = () => typeof hasPermission !== 'function' || hasPermission('config:read');
|
||||
@@ -281,16 +281,18 @@
|
||||
updateControls();
|
||||
}
|
||||
|
||||
async function request(url, method, body) {
|
||||
async function request(url, method, body, versioned = false) {
|
||||
const options = { method: method || 'GET' };
|
||||
if (body !== undefined) {
|
||||
options.headers = { 'Content-Type': 'application/json' };
|
||||
if (versioned && method === 'PUT' && state.etag) options.headers['If-Match'] = state.etag;
|
||||
options.body = JSON.stringify(body);
|
||||
}
|
||||
const response = await apiFetch(url, options);
|
||||
const result = await response.json().catch(() => ({}));
|
||||
if (!response.ok) throw new Error(result.error || tr('requestFailed'));
|
||||
return result;
|
||||
if (!response.ok) throw new Error(result.code === 'tool_guard_conflict' ? tr('saveConflict') :
|
||||
result.code === 'tool_guard_version_required' ? tr('versionRequired') : result.error || tr('requestFailed'));
|
||||
return versioned ? { config: result, etag: response.headers.get('ETag') } : result;
|
||||
}
|
||||
|
||||
function normalizeConfig(config) {
|
||||
@@ -311,7 +313,9 @@
|
||||
feedback('');
|
||||
updateControls();
|
||||
try {
|
||||
const config = normalizeConfig(await request('/api/tool-guard'));
|
||||
const result = await request('/api/tool-guard', 'GET', undefined, true);
|
||||
const config = normalizeConfig(result.config);
|
||||
state.etag = result.etag;
|
||||
state.saved = copy(config);
|
||||
state.config = config;
|
||||
invalidateTest();
|
||||
@@ -377,7 +381,9 @@
|
||||
state.busy = true;
|
||||
feedback('');
|
||||
updateControls();
|
||||
const saved = normalizeConfig(await request('/api/tool-guard', 'PUT', config));
|
||||
const result = await request('/api/tool-guard', 'PUT', config, true);
|
||||
const saved = normalizeConfig(result.config);
|
||||
state.etag = result.etag;
|
||||
state.saved = copy(saved);
|
||||
state.config = saved;
|
||||
invalidateTest();
|
||||
|
||||
@@ -80,7 +80,7 @@ function harness(permissions = ['config:read', 'config:write']) {
|
||||
}
|
||||
});
|
||||
vm.runInContext(source, context);
|
||||
const reply = (body, ok = true) => queue.push(async () => ({ ok, json: async () => body }));
|
||||
const reply = (body, ok = true, etag = '"v1"') => queue.push(async () => ({ ok, headers: { get: () => etag }, json: async () => body }));
|
||||
const element = (id) => nodes.get('tool-guard-' + id);
|
||||
const text = (node) => [node.textContent, ...node.children.map(text)].join('\n');
|
||||
return { window, document, reply, queue, calls, element, text };
|
||||
@@ -109,6 +109,77 @@ function runLocal(h, prefix) {
|
||||
return h.element(prefix + '-test-run').listeners.click();
|
||||
}
|
||||
|
||||
test('two pages sharing a version have one winner and the loser retains its draft and version', async () => {
|
||||
const a = harness(), b = harness();
|
||||
let stored = config(), version = '"v1"';
|
||||
for (const h of [a, b]) {
|
||||
h.reply(stored, true, version);
|
||||
await h.window.loadToolGuardConfig();
|
||||
}
|
||||
fillField(a, 'rule-0-message', 'first user');
|
||||
fillField(b, 'rule-0-message', 'second user');
|
||||
const submit = async (h) => {
|
||||
h.queue.push(async () => {
|
||||
const call = h.calls.at(-1);
|
||||
if (call.options.headers['If-Match'] !== version) return {
|
||||
ok: false, headers: { get: () => version },
|
||||
json: async () => ({ code: 'tool_guard_conflict', error: 'conflict' })
|
||||
};
|
||||
stored = JSON.parse(call.options.body);
|
||||
version = '"v' + (Number(version.slice(2, -1)) + 1) + '"';
|
||||
return { ok: true, headers: { get: () => version }, json: async () => stored };
|
||||
});
|
||||
await h.window.saveToolGuardConfig();
|
||||
};
|
||||
await Promise.all([submit(a), submit(b)]);
|
||||
assert.equal(stored.rules[0].message, 'first user');
|
||||
assert.equal(a.element('save').disabled, true);
|
||||
assert.equal(b.element('save').disabled, false);
|
||||
assert.equal(b.element('rule-0-message').value, 'second user');
|
||||
assert.match(b.element('feedback').textContent, /Another user/);
|
||||
await submit(b);
|
||||
assert.equal(b.calls.at(-1).options.headers['If-Match'], '"v1"');
|
||||
assert.equal(stored.rules[0].message, 'first user');
|
||||
fillField(a, 'rule-0-name', 'fresh edit');
|
||||
await submit(a);
|
||||
assert.equal(a.calls.at(-1).options.headers['If-Match'], '"v2"');
|
||||
// Discard the stale draft, then reload to intentionally edit the new baseline.
|
||||
b.window.resetToolGuardConfig();
|
||||
b.reply(stored, true, version);
|
||||
await b.window.loadToolGuardConfig();
|
||||
fillField(b, 'rule-0-message', 'rebased edit');
|
||||
await submit(b);
|
||||
assert.equal(stored.rules[0].message, 'rebased edit');
|
||||
});
|
||||
|
||||
test('failed save keeps its version for retry, dry-run has no precondition, missing version fails visibly', async () => {
|
||||
const h = harness();
|
||||
h.reply(config(), true, '"original"');
|
||||
await h.window.loadToolGuardConfig();
|
||||
fillField(h, 'rule-0-name', 'edited');
|
||||
h.reply({ error: 'disk full' }, false, '"untrusted"');
|
||||
await h.window.saveToolGuardConfig();
|
||||
assert.equal(h.calls.at(-1).options.headers['If-Match'], '"original"');
|
||||
h.reply({ blocked: false });
|
||||
await h.window.testToolGuardConfig();
|
||||
assert.equal(h.calls.at(-1).options.headers['If-Match'], undefined);
|
||||
h.reply({ ...config(), rules: [{ ...config().rules[0], name: 'edited' }] }, true, '"updated"');
|
||||
await h.window.saveToolGuardConfig();
|
||||
assert.equal(h.calls.at(-1).options.headers['If-Match'], '"original"');
|
||||
fillField(h, 'rule-0-name', 'next');
|
||||
h.reply({ ...config(), rules: [{ ...config().rules[0], name: 'next' }] }, true, '"next"');
|
||||
await h.window.saveToolGuardConfig();
|
||||
assert.equal(h.calls.at(-1).options.headers['If-Match'], '"updated"');
|
||||
const old = harness();
|
||||
old.reply(config(), true, null);
|
||||
await old.window.loadToolGuardConfig();
|
||||
fillField(old, 'rule-0-name', 'draft');
|
||||
old.reply({ code: 'tool_guard_version_required' }, false);
|
||||
await old.window.saveToolGuardConfig();
|
||||
assert.match(old.element('feedback').textContent, /version is missing/);
|
||||
assert.equal(old.element('rule-0-name').value, 'draft');
|
||||
});
|
||||
|
||||
test('test uses the unsaved configuration and backend RE2 validation without saving or executing tools', async () => {
|
||||
const h = harness();
|
||||
h.reply(config());
|
||||
|
||||
@@ -7002,7 +7002,7 @@
|
||||
<script src="/static/js/chat.js?v=20261009-retest-1"></script>
|
||||
<script src="/static/js/chat-plan-progress.js?v=20260815-1"></script>
|
||||
<script src="/static/js/hitl.js?v=20260819-1"></script>
|
||||
<script src="/static/js/tool-guard.js?v=20260907-7"></script>
|
||||
<script src="/static/js/tool-guard.js?v=20261010-conflict"></script>
|
||||
<script src="/static/js/settings.js?v=20260717-1"></script>
|
||||
<script src="/static/js/audit-datetime-picker.js"></script>
|
||||
<script src="/static/js/audit.js"></script>
|
||||
|
||||
Reference in new issue
Block a user