Files
CyberStrikeAI/web/static/js/tool-guard.test.cjs
T

833 lines
40 KiB
JavaScript

const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const vm = require('node:vm');
const source = fs.readFileSync('web/static/js/tool-guard.js', 'utf8');
const translations = JSON.parse(fs.readFileSync('web/static/i18n/en-US.json', 'utf8')).toolGuard;
function harness(permissions = ['config:read', 'config:write']) {
const nodes = new Map();
class Element {
constructor(tag = 'div') {
this.tagName = tag.toUpperCase();
this.children = [];
this.listeners = {};
this.dataset = {};
this.style = {};
this.rect = { height: 180 };
this.attributes = new Map();
this.classList = { toggle: (name, enabled) => {
const names = new Set((this.className || '').split(' ').filter(Boolean));
if (enabled) names.add(name); else names.delete(name);
this.className = [...names].join(' ');
} };
this.value = '';
this.hidden = false;
this.open = false;
this.textContent = '';
}
set id(value) { this._id = value; nodes.set(value, this); }
get id() { return this._id; }
set innerHTML(_) { throw new Error('Untrusted values must never use innerHTML'); }
append(...children) { this.children.push(...children); }
replaceChildren(...children) {
const detach = (node) => { if (node.id) nodes.delete(node.id); node.children.forEach(detach); };
this.children.forEach(detach);
this.children = children;
}
addEventListener(type, fn) { this.listeners[type] = fn; }
setAttribute(name, value) { this.attributes.set(name, String(value)); }
getAttribute(name) { return this.attributes.get(name) ?? null; }
removeAttribute(name) { this.attributes.delete(name); }
focus() { if (!this.disabled) document.activeElement = this; }
showModal() { this.open = true; }
close() { this.open = false; if (this.listeners.close) this.listeners.close(); }
getBoundingClientRect() { return this.rect; }
scrollIntoView(options) { this.scrolledIntoView = options; }
querySelectorAll(selector) {
const tags = selector.split(',').map((tag) => tag.trim().toUpperCase());
return this.children.flatMap((child) => [
...(tags.includes(child.tagName) ? [child] : []), ...child.querySelectorAll(selector)
]);
}
}
const document = {
getElementById: (id) => nodes.get(id),
createElement: (tag) => new Element(tag),
activeElement: null,
addEventListener() {}
};
['rules', 'enabled', 'add', 'save', 'reset', 'test', 'save-state', 'feedback', 'test-result', 'test-tool', 'test-arguments',
'protection-status', 'rule-count', 'open-test', 'test-panel', 'add-dialog', 'add-confirm', 'add-feedback',
'add-fields', 'add-test', 'add-body'].forEach((id) => {
const node = new Element();
node.id = 'tool-guard-' + id;
});
nodes.get('tool-guard-test-tool').value = 'http_request';
nodes.get('tool-guard-test-arguments').value = '{"url":"https://example.gov.cn"}';
Object.assign(nodes.get('tool-guard-add-body'), { clientHeight: 500, scrollHeight: 500, scrollTop: 0 });
const calls = [];
const queue = [];
const window = { t: (key) => translations[key.slice('toolGuard.'.length)] || key };
let newRuleNumber = 0;
const context = vm.createContext({ window, document, TextEncoder, crypto: { randomUUID: () => 'new-rule-' + ++newRuleNumber },
hasPermission: (permission) => permissions.includes(permission),
apiFetch: async (url, options) => {
calls.push({ url, options });
if (!queue.length) throw new Error('Unexpected request');
return queue.shift()();
}
});
vm.runInContext(source, context);
const reply = (body, ok = true) => queue.push(async () => ({ ok, 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 };
}
function config() {
return { enabled: true, rules: [{ id: 'gov', name: 'Government domains', enabled: true,
pattern: '(?i)\\.gov\\b', message: 'Detected {match}' }] };
}
function fillField(h, id, value) {
const input = h.element(id);
assert.ok(input, 'Missing input: ' + id);
input.value = value;
input.listeners.input();
}
function fillDraft(h, values = {}) {
for (const [field, value] of Object.entries({ name: 'New protection', pattern: '(?i)\\.edu',
message: 'Detected {match} for {rule}', ...values })) {
fillField(h, 'rule-draft-' + field, value);
}
}
function runLocal(h, prefix) {
return h.element(prefix + '-test-run').listeners.click();
}
test('test uses the unsaved configuration and backend RE2 validation without saving or executing tools', async () => {
const h = harness();
h.reply(config());
await h.window.loadToolGuardConfig();
const pattern = h.element('rule-0-pattern');
pattern.value = '(?i)\\.gov'; // RE2 inline flag is not valid JavaScript RegExp syntax.
pattern.listeners.input();
h.reply({ blocked: true, match: { ruleId: 'gov', ruleName: 'Government domains', matchedText: '.gov', message: 'Detected .gov' } });
await h.window.testToolGuardConfig();
assert.equal(h.calls.length, 2);
assert.equal(h.calls[1].url, '/api/tool-guard/test');
const body = JSON.parse(h.calls[1].options.body);
assert.equal(body.config.rules[0].pattern, '(?i)\\.gov');
assert.deepEqual(body.arguments, { url: 'https://example.gov.cn' });
assert.match(h.text(h.element('test-result')), /Detected \.gov/);
assert.equal(h.element('save').disabled, false);
});
test('unsafe rule and match text is rendered as text; messages cannot inject markup', async () => {
const h = harness();
const attack = '<img src=x onerror=alert(1)>';
const initial = config();
initial.rules[0].name = attack;
initial.rules[0].message = attack;
h.reply(initial);
await h.window.loadToolGuardConfig();
assert.equal(h.element('rule-0-name').value, attack);
h.reply({ blocked: true, match: { ruleName: attack, matchedText: attack, message: attack } });
await h.window.testToolGuardConfig();
assert.match(h.text(h.element('test-result')), /<img src=x onerror=alert\(1\)>/);
assert.equal(h.element('test-result').querySelectorAll('img').length, 0);
});
test('draft survives navigation, save errors preserve it, and discard restores last server state', async () => {
const h = harness();
h.reply(config());
await h.window.loadToolGuardConfig();
h.window.changeToolGuardEnabled(false);
await h.window.loadToolGuardConfig();
assert.equal(h.calls.length, 1);
h.reply({ error: 'Invalid RE2 expression' }, false);
await h.window.saveToolGuardConfig();
assert.match(h.element('feedback').textContent, /Invalid RE2 expression/);
assert.equal(h.element('save').disabled, false);
h.window.resetToolGuardConfig();
assert.equal(h.element('enabled').checked, true);
assert.equal(h.element('save').disabled, true);
assert.equal(h.calls.length, 2);
});
test('successful save explicitly persists enabled and all rule fields through dedicated endpoint', async () => {
const h = harness();
h.reply(config());
await h.window.loadToolGuardConfig();
h.window.changeToolGuardEnabled(false);
const saved = config();
saved.enabled = false;
h.reply(saved);
await h.window.saveToolGuardConfig();
assert.equal(h.calls[1].url, '/api/tool-guard');
assert.equal(h.calls[1].options.method, 'PUT');
assert.deepEqual(JSON.parse(h.calls[1].options.body), saved);
assert.equal(h.element('save').disabled, true);
});
test('read-only users can test but cannot mutate the configuration', async () => {
const h = harness(['config:read']);
h.reply(config());
await h.window.loadToolGuardConfig();
assert.equal(h.element('enabled').disabled, true);
assert.equal(h.element('rule-0-name').disabled, true);
h.window.changeToolGuardEnabled(false);
h.window.addToolGuardRule();
await h.window.saveToolGuardConfig();
assert.equal(h.calls.length, 1);
h.reply({ blocked: false });
await h.window.testToolGuardConfig();
assert.equal(JSON.parse(h.calls[1].options.body).config.enabled, true);
});
test('test rejects non-object arguments before making an API request', async () => {
const h = harness();
h.reply(config());
await h.window.loadToolGuardConfig();
for (const input of ['[]', 'null', '"https://example.gov"', '{']) {
h.element('test-arguments').value = input;
await h.window.testToolGuardConfig();
assert.match(h.element('feedback').textContent, /valid JSON object/);
}
assert.equal(h.calls.length, 1);
});
test('stale dry-run responses cannot claim to describe edited rules', async () => {
const h = harness();
h.reply(config());
await h.window.loadToolGuardConfig();
let finish;
h.queue.push(() => new Promise((resolve) => { finish = resolve; }));
const pending = h.window.testToolGuardConfig();
h.window.changeToolGuardEnabled(false);
finish({ ok: true, json: async () => ({ blocked: true, match: { matchedText: '.gov' } }) });
await pending;
assert.equal(h.element('test-result').hidden, true);
});
test('UTF-8 byte limits are enforced before saving multi-byte rule names', async () => {
const h = harness();
h.reply(config());
await h.window.loadToolGuardConfig();
h.element('rule-0-name').value = '政'.repeat(67);
h.element('rule-0-name').listeners.input();
await h.window.saveToolGuardConfig();
assert.equal(h.calls.length, 1);
assert.match(h.element('feedback').textContent, /200 UTF-8 bytes/);
});
test('malformed dry-run responses report an error instead of claiming the call is allowed', async () => {
const h = harness();
h.reply(config());
await h.window.loadToolGuardConfig();
h.reply({});
await h.window.testToolGuardConfig();
assert.equal(h.element('test-result').hidden, true);
assert.match(h.element('feedback').textContent, /invalid test result/);
});
test('saved rules start collapsed behind native accessible buttons without making the configuration dirty', async () => {
const h = harness();
h.reply(config());
await h.window.loadToolGuardConfig();
const summary = h.element('rule-0-summary');
assert.equal(summary.tagName, 'BUTTON');
assert.equal(summary.type, 'button');
assert.equal(summary.getAttribute('aria-expanded'), 'false');
assert.equal(summary.getAttribute('aria-controls'), h.element('rule-0-editor').id);
assert.equal(h.element('rule-0-editor').hidden, true);
assert.equal(h.element('rule-0-title').textContent, 'Government domains');
assert.equal(h.element('rule-0-preview').textContent, 'Detected {match}');
assert.equal(h.element('rule-0-badge').hidden, true);
summary.listeners.click();
assert.equal(summary.getAttribute('aria-expanded'), 'true');
assert.equal(h.element('rule-0-editor').hidden, false);
summary.listeners.click();
assert.equal(h.element('rule-0-editor').hidden, true);
assert.equal(h.element('save').disabled, true);
assert.equal(h.calls.length, 1);
});
test('only one rule opens at a time and editing updates safe summaries without replacing inputs or losing drafts', async () => {
const h = harness();
const initial = config();
initial.rules.push({ ...initial.rules[0], id: 'second', name: 'Second rule' });
h.reply(initial);
await h.window.loadToolGuardConfig();
h.element('rule-0-summary').listeners.click();
const input = h.element('rule-0-name');
input.focus();
input.value = '<img src=x onerror=alert(1)>';
input.listeners.input();
assert.equal(h.element('rule-0-name'), input);
assert.equal(h.document.activeElement, input);
assert.equal(h.element('rule-0-title').textContent, input.value);
assert.equal(h.element('rule-0-badge').hidden, false);
const reminder = h.element('rule-0-message');
reminder.value = 'Updated {match} reminder';
reminder.listeners.input();
assert.equal(h.element('rule-0-preview').textContent, reminder.value);
h.element('rule-1-summary').listeners.click();
assert.equal(h.element('rule-0-editor').hidden, true);
assert.equal(h.element('rule-1-editor').hidden, false);
h.element('rule-0-summary').listeners.click();
assert.equal(h.element('rule-1-editor').hidden, true);
assert.equal(h.element('rule-0-name').value, input.value);
assert.equal(h.element('rules').querySelectorAll('img').length, 0);
h.element('rule-0-close').listeners.click();
assert.equal(h.document.activeElement, h.element('rule-0-summary'));
assert.equal(h.element('rule-0-editor').hidden, true);
const saved = config();
saved.rules = initial.rules.map((rule, index) => index === 0 ? { ...rule, name: input.value, message: reminder.value } : rule);
h.reply(saved);
await h.window.saveToolGuardConfig();
assert.equal(JSON.parse(h.calls[1].options.body).rules[0].name, input.value);
assert.equal(h.element('rule-0-editor').hidden, true);
assert.equal(h.element('rule-0-badge').hidden, true);
});
test('adding a rule opens an isolated dialog; cancellation leaves no phantom row or dirty configuration', async () => {
const h = harness();
h.reply(config());
await h.window.loadToolGuardConfig();
h.element('rule-0-summary').listeners.click();
h.window.addToolGuardRule();
assert.equal(h.element('add-dialog').open, true);
assert.equal(h.document.activeElement, h.element('rule-draft-name'));
assert.equal(h.element('rule-1-editor'), undefined);
assert.equal(h.element('save').disabled, true);
fillDraft(h);
assert.equal(h.element('save').disabled, true);
h.window.closeToolGuardRuleDialog();
assert.equal(h.element('add-dialog').open, false);
assert.equal(h.element('rule-1-editor'), undefined);
assert.equal(h.element('save').disabled, true);
assert.equal(h.calls.length, 1);
h.window.addToolGuardRule();
assert.equal(h.element('rule-draft-name').value, '');
assert.equal(h.element('rule-draft-pattern').value, '');
assert.equal(h.document.activeElement, h.element('rule-draft-name'));
});
test('read-only users can expand and close rule details while all mutation controls stay disabled', async () => {
const h = harness(['config:read']);
h.reply(config());
await h.window.loadToolGuardConfig();
assert.equal(h.element('rule-0-summary').disabled, false);
assert.equal(h.element('rule-0-close').disabled, false);
assert.equal(h.element('rule-0-delete').disabled, true);
assert.equal(h.element('rule-0-enabled').disabled, true);
h.element('rule-0-summary').listeners.click();
assert.equal(h.element('rule-0-editor').hidden, false);
h.element('rule-0-close').listeners.click();
assert.equal(h.element('rule-0-editor').hidden, true);
assert.equal(h.document.activeElement, h.element('rule-0-summary'));
});
test('saving an invalid collapsed draft opens and focuses the first invalid field before any request', async () => {
const h = harness();
h.reply(config());
await h.window.loadToolGuardConfig();
fillField(h, 'rule-0-name', '');
fillField(h, 'rule-0-pattern', '');
await h.window.saveToolGuardConfig();
assert.equal(h.element('rule-0-editor').hidden, false);
assert.equal(h.document.activeElement, h.element('rule-0-name'));
assert.equal(h.element('rule-0-name').getAttribute('aria-invalid'), 'true');
assert.equal(h.calls.length, 1);
fillField(h, 'rule-0-name', 'New protection');
assert.equal(h.element('rule-0-name').getAttribute('aria-invalid'), null);
h.element('rule-0-close').listeners.click();
await h.window.testToolGuardConfig();
assert.equal(h.element('rule-0-editor').hidden, false);
assert.equal(h.document.activeElement, h.element('rule-0-pattern'));
assert.equal(h.calls.length, 1);
});
test('backend RE2 errors reveal the affected collapsed rule after controls become writable again', async () => {
const h = harness();
h.reply(config());
await h.window.loadToolGuardConfig();
h.element('rule-0-pattern').value = '(';
h.element('rule-0-pattern').listeners.input();
h.reply({ error: 'tool guard rule 1 (gov): invalid regular expression: error parsing regexp: missing closing )' }, false);
await h.window.saveToolGuardConfig();
assert.equal(h.element('rule-0-editor').hidden, false);
assert.equal(h.document.activeElement, h.element('rule-0-pattern'));
assert.equal(h.element('rule-0-pattern').disabled, false);
assert.equal(h.element('rule-0-pattern').getAttribute('aria-invalid'), 'true');
assert.equal(h.element('rule-0-pattern').value, '(');
assert.equal(h.element('save').disabled, false);
});
test('deleting rules maintains the remaining row identity and gives focus to the next summary or add button', async () => {
const h = harness();
const initial = config();
initial.rules.push({ ...initial.rules[0], id: 'second', name: 'Second rule' });
h.reply(initial);
await h.window.loadToolGuardConfig();
h.element('rule-0-summary').listeners.click();
h.element('rule-0-delete').listeners.click();
assert.equal(h.element('rule-0-title').textContent, 'Second rule');
assert.equal(h.document.activeElement, h.element('rule-0-summary'));
assert.equal(h.element('rule-0-editor').hidden, true);
h.element('rule-0-summary').listeners.click();
h.element('rule-0-delete').listeners.click();
assert.equal(h.document.activeElement, h.element('add'));
assert.match(h.text(h.element('rules')), /No rules/);
});
test('compact status and enabled counts track draft toggles independently of accordion expansion', async () => {
const h = harness();
h.reply(config());
await h.window.loadToolGuardConfig();
assert.equal(h.element('protection-status').textContent, translations.protectionOn);
assert.equal(h.element('rule-count').textContent, translations.ruleCount.replace('{{enabled}}', '1').replace('{{total}}', '1'));
const toggle = h.element('rule-0-enabled');
assert.match(toggle.getAttribute('aria-label'), /Government domains/);
toggle.checked = false;
toggle.listeners.change();
assert.equal(h.element('rule-count').textContent, translations.ruleCount.replace('{{enabled}}', '0').replace('{{total}}', '1'));
assert.equal(h.element('rule-0-editor').hidden, true);
assert.equal(h.element('rule-0-badge').hidden, false);
h.window.changeToolGuardEnabled(false);
assert.equal(h.element('protection-status').textContent, translations.protectionOff);
});
test('single-rule validation opens next to its editor without redirecting to global validation', async () => {
const h = harness();
h.reply(config());
await h.window.loadToolGuardConfig();
h.element('rule-0-validate').listeners.click();
assert.equal(h.element('rule-0-editor').hidden, false);
assert.equal(h.element('rule-0-test-panel').hidden, false);
assert.equal(h.element('test-panel').open, false);
assert.equal(h.document.activeElement, h.element('rule-0-test-arguments'));
assert.equal(h.element('save').disabled, true);
assert.equal(h.calls.length, 1);
h.window.openToolGuardTest();
assert.equal(h.element('test-panel').open, true);
assert.equal(h.document.activeElement, h.element('test-arguments'));
assert.equal(h.element('rule-0-test-panel').hidden, false);
});
test('local validation isolates the current rule from unrelated invalid drafts and displays its result locally', async () => {
const h = harness();
const initial = config();
initial.rules.push({ ...initial.rules[0], id: 'second', name: 'Second rule' });
h.reply(initial);
await h.window.loadToolGuardConfig();
fillField(h, 'rule-0-name', '');
fillField(h, 'rule-1-pattern', '(?i)\\.edu');
h.window.openToolGuardTest('second');
fillField(h, 'rule-1-test-arguments', '{"url":"https://example.edu"}');
h.reply({ blocked: true, match: { ruleId: 'second', ruleName: 'Second rule', matchedText: '.edu', message: 'Detected .edu' } });
await runLocal(h, 'rule-1');
const body = JSON.parse(h.calls[1].options.body);
assert.deepEqual(body.config.rules, [{ ...initial.rules[1], pattern: '(?i)\\.edu' }]);
assert.deepEqual(body.arguments, { url: 'https://example.edu' });
assert.match(h.text(h.element('rule-1-test-result')), /Detected \.edu/);
assert.equal(h.element('rule-1-test-result').children[0].textContent, translations.singleMatched);
assert.match(h.element('rule-1-test-result').className, /is-single/);
assert.equal(h.element('test-result').hidden, true);
assert.equal(h.element('test-panel').open, false);
assert.equal(h.element('save').disabled, false);
await h.window.testToolGuardConfig();
assert.equal(h.calls.length, 2);
assert.equal(h.document.activeElement, h.element('rule-0-name'));
});
test('single validation forces flags only in its copied payload while global validation retains order and disabled state', async () => {
const h = harness();
const initial = config();
initial.enabled = false;
initial.rules[0].enabled = false;
initial.rules.push({ ...initial.rules[0], id: 'second', name: 'Second rule', enabled: true });
h.reply(initial);
await h.window.loadToolGuardConfig();
h.window.openToolGuardTest('gov');
h.reply({ blocked: false });
await runLocal(h, 'rule-0');
const singleBody = JSON.parse(h.calls[1].options.body);
assert.equal(singleBody.config.enabled, true);
assert.deepEqual(singleBody.config.rules, [{ ...initial.rules[0], enabled: true }]);
assert.equal(h.element('rule-0-test-result').children[0].textContent, translations.singleNotMatched);
assert.equal(h.element('enabled').checked, false);
assert.equal(h.element('rule-0-enabled').checked, false);
assert.equal(h.element('save').disabled, true);
h.window.openToolGuardTest();
h.reply({ blocked: false });
await h.window.testToolGuardConfig();
assert.deepEqual(JSON.parse(h.calls[2].options.body).config, initial);
assert.equal(h.element('test-result').children[0].textContent, translations.disabledResult);
assert.equal(h.element('rule-0-test-result').hidden, false);
assert.doesNotMatch(h.element('test-result').className, /is-single/);
});
test('local and global validations can run concurrently and keep distinct inputs and results', async () => {
const h = harness();
h.reply(config());
await h.window.loadToolGuardConfig();
h.window.openToolGuardTest('gov');
fillField(h, 'rule-0-test-tool', 'local_preview');
fillField(h, 'rule-0-test-arguments', '{"url":"https://example.com"}');
let finishLocal;
h.queue.push(() => new Promise((resolve) => { finishLocal = resolve; }));
const local = runLocal(h, 'rule-0');
assert.equal(h.element('rule-0-test-run').disabled, true);
assert.equal(h.element('test').disabled, false);
h.window.openToolGuardTest();
h.reply({ blocked: true, match: { ruleId: 'gov', ruleName: 'Government domains', matchedText: '.gov', message: 'Global result' } });
await h.window.testToolGuardConfig();
finishLocal({ ok: true, json: async () => ({ blocked: false }) });
await local;
assert.equal(JSON.parse(h.calls[1].options.body).toolName, 'local_preview');
assert.equal(JSON.parse(h.calls[2].options.body).toolName, 'http_request');
assert.equal(h.element('rule-0-test-result').children[0].textContent, translations.singleNotMatched);
assert.match(h.text(h.element('test-result')), /Global result/);
assert.equal(h.element('rule-0-test-run').disabled, false);
assert.equal(h.element('test').disabled, false);
});
test('editing a local rule or its sample ignores stale successful responses and RE2 errors', async () => {
for (const target of ['rule-0-pattern', 'rule-0-test-arguments']) {
for (const ok of [true, false]) {
const h = harness();
h.reply(config());
await h.window.loadToolGuardConfig();
h.window.openToolGuardTest('gov');
let finish;
h.queue.push(() => new Promise((resolve) => { finish = resolve; }));
const pending = runLocal(h, 'rule-0');
fillField(h, target, target.endsWith('pattern') ? '(?i)\\.edu' : '{"url":"https://example.edu"}');
finish({ ok, json: async () => ok ? { blocked: false } :
{ error: 'tool guard rule 1 (gov): invalid regular expression' } });
await pending;
assert.equal(h.element('rule-0-test-result').hidden, true);
assert.equal(h.element('rule-0-test-feedback').hidden, true);
assert.equal(h.element('rule-0-pattern').getAttribute('aria-invalid'), null);
assert.equal(h.element('feedback').hidden, true);
assert.equal(h.element('rule-0-test-run').disabled, false);
}
}
});
test('deleting a rule invalidates its pending local response without mislabeling the remaining row', async () => {
const h = harness();
const initial = config();
initial.rules.push({ ...initial.rules[0], id: 'second', name: 'Second rule' });
h.reply(initial);
await h.window.loadToolGuardConfig();
h.window.openToolGuardTest('gov');
let finish;
h.queue.push(() => new Promise((resolve) => { finish = resolve; }));
const pending = runLocal(h, 'rule-0');
h.element('rule-0-delete').listeners.click();
finish({ ok: false, json: async () => ({ error: 'tool guard rule 1 (gov): invalid regular expression' }) });
await pending;
assert.equal(h.element('rule-0-title').textContent, 'Second rule');
assert.equal(h.element('rule-0-pattern').getAttribute('aria-invalid'), null);
assert.equal(h.element('rule-0-test-feedback').hidden, true);
assert.equal(h.element('feedback').hidden, true);
assert.equal(h.element('test-panel').open, false);
});
test('local RE2 errors and local required fields focus the selected rule, with no global feedback', async () => {
const h = harness();
const initial = config();
initial.rules.push({ ...initial.rules[0], id: 'second', name: 'Second rule' });
h.reply(initial);
await h.window.loadToolGuardConfig();
fillField(h, 'rule-1-pattern', '(');
h.window.openToolGuardTest('second');
h.reply({ error: 'tool guard rule 1 (second): invalid regular expression: error parsing regexp: missing closing )' }, false);
await runLocal(h, 'rule-1');
assert.equal(h.element('rule-0-editor').hidden, true);
assert.equal(h.element('rule-1-editor').hidden, false);
assert.equal(h.document.activeElement, h.element('rule-1-pattern'));
assert.equal(h.element('rule-1-pattern').getAttribute('aria-invalid'), 'true');
assert.equal(h.element('rule-0-pattern').getAttribute('aria-invalid'), null);
assert.match(h.element('rule-1-test-feedback').textContent, /invalid regular expression/);
assert.equal(h.element('feedback').hidden, true);
fillField(h, 'rule-1-name', '');
await runLocal(h, 'rule-1');
assert.equal(h.calls.length, 2);
assert.equal(h.element('rule-1-editor').hidden, false);
assert.equal(h.document.activeElement, h.element('rule-1-name'));
assert.equal(h.element('rule-1-name').getAttribute('aria-invalid'), 'true');
});
test('read-only users can validate existing rules but cannot create or commit new rules', async () => {
const h = harness(['config:read']);
h.reply(config());
await h.window.loadToolGuardConfig();
assert.equal(h.element('rule-0-validate').disabled, false);
h.element('rule-0-validate').listeners.click();
h.reply({ blocked: false });
await runLocal(h, 'rule-0');
assert.equal(JSON.parse(h.calls[1].options.body).config.rules[0].id, 'gov');
assert.equal(h.element('rule-0-name').disabled, true);
h.window.addToolGuardRule();
await h.window.commitToolGuardRule();
assert.equal(h.element('add-dialog').open, false);
assert.equal(h.element('rule-1-summary'), undefined);
assert.equal(h.calls.length, 2);
const noRead = harness(['config:write']);
await noRead.window.loadToolGuardConfig();
noRead.window.openToolGuardTest('gov');
await noRead.window.testToolGuardConfig();
assert.equal(noRead.calls.length, 0);
assert.equal(noRead.element('test-panel').open, false);
});
test('new rule can be tested before adding without modifying, saving, or opening global validation', async () => {
const h = harness();
h.reply(config());
await h.window.loadToolGuardConfig();
h.window.addToolGuardRule();
fillDraft(h);
fillField(h, 'draft-test-arguments', '{"url":"https://example.edu"}');
h.reply({ blocked: true, match: { ruleId: 'new-rule-1', ruleName: 'New protection', matchedText: '.edu', message: 'Detected .edu for New protection' } });
await runLocal(h, 'draft');
assert.equal(h.calls[1].url, '/api/tool-guard/test');
assert.equal(h.calls[1].options.method, 'POST');
const body = JSON.parse(h.calls[1].options.body);
assert.equal(body.config.enabled, true);
assert.equal(body.config.rules.length, 1);
assert.equal(body.config.rules[0].name, 'New protection');
assert.equal(body.config.rules[0].pattern, '(?i)\\.edu');
assert.match(h.text(h.element('draft-test-result')), /Detected \.edu for New protection/);
assert.equal(h.element('save').disabled, true);
assert.equal(h.element('rule-1-summary'), undefined);
assert.equal(h.element('add-dialog').open, true);
assert.equal(h.element('test-panel').open, false);
});
test('confirming a new rule validates RE2 on the server and then appends a collapsed unsaved draft', async () => {
const h = harness();
h.reply(config());
await h.window.loadToolGuardConfig();
h.window.addToolGuardRule();
fillDraft(h);
// Test-sample mistakes must not prevent adding a valid rule.
fillField(h, 'draft-test-arguments', '{');
let finish;
h.queue.push(() => new Promise((resolve) => { finish = resolve; }));
const pending = h.window.commitToolGuardRule();
assert.equal(h.element('rule-1-summary'), undefined);
assert.equal(h.element('save').disabled, true);
assert.equal(h.calls[1].url, '/api/tool-guard/test');
assert.equal(h.calls[1].options.method, 'POST');
const body = JSON.parse(h.calls[1].options.body);
assert.equal(body.toolName, 'rule_validation');
assert.deepEqual(body.arguments, {});
assert.equal(body.config.rules.length, 1);
assert.equal(body.config.rules[0].name, 'New protection');
finish({ ok: true, json: async () => ({ blocked: false }) });
await pending;
assert.equal(h.element('add-dialog').open, false);
assert.equal(h.element('rule-1-title').textContent, 'New protection');
assert.equal(h.element('rule-1-editor').hidden, true);
assert.equal(h.element('rule-1-badge').textContent, translations.ruleNew);
assert.equal(h.element('rule-1-badge').hidden, false);
assert.equal(h.element('save').disabled, false);
assert.equal(h.calls.filter(({ options }) => options.method === 'PUT').length, 0);
h.window.resetToolGuardConfig();
assert.equal(h.element('rule-1-summary'), undefined);
assert.equal(h.element('save').disabled, true);
});
test('invalid new-rule RE2 stays in the dialog with focused field and does not append', async () => {
const h = harness();
h.reply(config());
await h.window.loadToolGuardConfig();
h.window.addToolGuardRule();
fillDraft(h, { pattern: '(' });
h.reply({ error: 'tool guard rule 1 (new-rule-1): invalid regular expression: error parsing regexp: missing closing )' }, false);
await h.window.commitToolGuardRule();
assert.equal(h.element('add-dialog').open, true);
assert.equal(h.element('rule-draft-pattern').value, '(');
assert.equal(h.element('rule-draft-pattern').getAttribute('aria-invalid'), 'true');
assert.equal(h.document.activeElement, h.element('rule-draft-pattern'));
assert.match(h.element('add-feedback').textContent, /invalid regular expression/);
assert.equal(h.element('rule-1-summary'), undefined);
assert.equal(h.element('save').disabled, true);
assert.equal(h.element('feedback').hidden, true);
});
test('new-rule required-field and byte-limit errors are shown locally before any request', async () => {
const h = harness();
h.reply(config());
await h.window.loadToolGuardConfig();
h.window.addToolGuardRule();
await h.window.commitToolGuardRule();
assert.equal(h.document.activeElement, h.element('rule-draft-name'));
assert.equal(h.element('rule-draft-name').getAttribute('aria-invalid'), 'true');
fillDraft(h, { name: '政'.repeat(67) });
await h.window.commitToolGuardRule();
assert.match(h.element('add-feedback').textContent, /200 UTF-8 bytes/);
assert.equal(h.calls.length, 1);
assert.equal(h.element('rule-1-summary'), undefined);
assert.equal(h.element('save').disabled, true);
});
test('canceling or editing a pending new-rule commit cannot append a stale draft', async () => {
for (const action of ['close', 'cancel', 'edit']) {
const h = harness();
h.reply(config());
await h.window.loadToolGuardConfig();
h.window.addToolGuardRule();
fillDraft(h);
let finish;
h.queue.push(() => new Promise((resolve) => { finish = resolve; }));
const pending = h.window.commitToolGuardRule();
if (action === 'close') h.window.closeToolGuardRuleDialog();
else if (action === 'cancel') h.element('add-dialog').listeners.cancel({ preventDefault() {} });
else fillField(h, 'rule-draft-name', 'Changed while validating');
finish({ ok: true, json: async () => ({ blocked: false }) });
await pending;
assert.equal(h.element('rule-1-summary'), undefined, action);
assert.equal(h.element('save').disabled, true, action);
assert.equal(h.element('add-dialog').open, action === 'edit', action);
if (action === 'edit') assert.equal(h.element('rule-draft-name').value, 'Changed while validating');
}
});
test('canceled dialog dry-run responses cannot contaminate a reopened new-rule dialog', async () => {
for (const ok of [true, false]) {
const h = harness();
h.reply(config());
await h.window.loadToolGuardConfig();
h.window.addToolGuardRule();
fillDraft(h);
let finish;
h.queue.push(() => new Promise((resolve) => { finish = resolve; }));
const pending = runLocal(h, 'draft');
h.window.closeToolGuardRuleDialog();
h.window.addToolGuardRule();
fillDraft(h, { name: 'Replacement draft', pattern: 'example' });
finish({ ok, json: async () => ok ? { blocked: false } :
{ error: 'tool guard rule 1 (new-rule-1): invalid regular expression' } });
await pending;
assert.equal(h.element('draft-test-result').hidden, true);
assert.equal(h.element('draft-test-feedback').hidden, true);
assert.equal(h.element('rule-draft-pattern').getAttribute('aria-invalid'), null);
assert.equal(h.element('rule-draft-name').value, 'Replacement draft');
assert.equal(h.element('draft-test-run').disabled, false);
assert.equal(h.element('save').disabled, true);
}
});
test('local test errors and unsafe result text stay inside their validation panel', async () => {
const h = harness();
h.reply(config());
await h.window.loadToolGuardConfig();
h.window.addToolGuardRule();
fillDraft(h);
for (const input of ['[]', 'null', '"target"', '{']) {
fillField(h, 'draft-test-arguments', input);
await runLocal(h, 'draft');
assert.match(h.element('draft-test-feedback').textContent, /valid JSON object/);
assert.equal(h.element('feedback').hidden, true);
}
assert.equal(h.calls.length, 1);
fillField(h, 'draft-test-arguments', '{}');
h.reply({});
await runLocal(h, 'draft');
assert.match(h.element('draft-test-feedback').textContent, /invalid test result/);
assert.equal(h.element('draft-test-result').hidden, true);
const unsafe = '<img src=x onerror=alert(1)>';
h.reply({ blocked: true, match: { ruleName: unsafe, matchedText: unsafe, message: unsafe } });
await runLocal(h, 'draft');
assert.match(h.text(h.element('draft-test-result')), /<img src=x onerror=alert\(1\)>/);
assert.equal(h.element('draft-test-result').querySelectorAll('img').length, 0);
});
test('dialog validation keeps one output region through waiting, completion, and stale responses after edits', async () => {
const h = harness();
h.reply(config());
await h.window.loadToolGuardConfig();
h.window.addToolGuardRule();
const output = h.element('draft-test-output');
const status = h.element('draft-test-status');
assert.ok(output);
assert.equal(status.textContent, translations.testReadyHint);
assert.equal(status.hidden, false);
assert.equal(output.getAttribute('aria-busy'), 'false');
fillDraft(h);
let finishFirst;
h.queue.push(() => new Promise((resolve) => { finishFirst = resolve; }));
const first = runLocal(h, 'draft');
assert.equal(h.element('draft-test-output'), output);
assert.equal(status.textContent, translations.testing);
assert.equal(status.hidden, false);
assert.equal(output.getAttribute('aria-busy'), 'true');
assert.equal(h.element('draft-test-result').hidden, true);
finishFirst({ ok: true, json: async () => ({ blocked: true,
match: { ruleName: 'New protection', matchedText: '.edu', message: 'Prior result' } }) });
await first;
assert.equal(h.element('draft-test-output'), output);
assert.equal(status.hidden, true);
assert.equal(output.getAttribute('aria-busy'), 'false');
assert.match(h.text(h.element('draft-test-result')), /Prior result/);
let finishStale;
h.queue.push(() => new Promise((resolve) => { finishStale = resolve; }));
const stale = runLocal(h, 'draft');
assert.equal(h.element('draft-test-output'), output);
assert.equal(status.textContent, translations.testing);
assert.equal(status.hidden, false);
assert.equal(h.element('draft-test-result').hidden, true);
assert.equal(h.element('draft-test-result').children.length, 0);
fillField(h, 'rule-draft-pattern', '(?i)\\.org');
assert.equal(status.textContent, translations.testChanged);
assert.equal(status.hidden, false);
finishStale({ ok: true, json: async () => ({ blocked: true,
match: { ruleName: 'New protection', matchedText: '.edu', message: 'Stale result' } }) });
await stale;
assert.equal(h.element('draft-test-output'), output);
assert.equal(status.textContent, translations.testChanged);
assert.equal(status.hidden, false);
assert.equal(output.getAttribute('aria-busy'), 'false');
assert.equal(h.element('draft-test-result').hidden, true);
assert.equal(h.element('draft-test-feedback').hidden, true);
});
test('clearing a tall dialog result preserves viewport space without retaining its entire height or a historical maximum', async () => {
const h = harness();
h.reply(config());
await h.window.loadToolGuardConfig();
h.window.addToolGuardRule();
fillDraft(h);
h.reply({ blocked: true, match: { ruleName: 'New protection', matchedText: '.edu', message: 'Long result' } });
await runLocal(h, 'draft');
const output = h.element('draft-test-output');
const body = h.element('add-body');
output.rect = { height: 1200 };
Object.assign(body, { clientHeight: 600, scrollHeight: 2000, scrollTop: 1000 });
fillField(h, 'rule-draft-pattern', '(?i)\\.org');
assert.equal(h.element('draft-test-result').hidden, true);
assert.equal(output.style.minHeight, '600px');
assert.equal(body.scrollTop, 1000);
// Once the shorter content has room below it, further edits release the reserved space.
output.rect = { height: 600 };
Object.assign(body, { scrollHeight: 1600, scrollTop: 400 });
fillField(h, 'draft-test-arguments', '{"url":"https://example.org"}');
assert.equal(output.style.minHeight, '180px');
assert.equal(body.scrollTop, 400);
assert.equal(h.element('draft-test-output'), output);
assert.equal(h.calls.length, 2);
});