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

733 lines
34 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
(function () {
'use strict';
const state = { config: null, saved: 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');
const canWrite = () => typeof hasPermission !== 'function' || hasPermission('config:write');
const copy = (value) => JSON.parse(JSON.stringify(value));
function tr(key, params) {
const fullKey = 'toolGuard.' + key;
const value = typeof window.t === 'function' ? window.t(fullKey, params) : fullKey;
return String(value).replace(/\{\{(\w+)\}\}/g, (match, name) => params && params[name] !== undefined ? String(params[name]) : match);
}
function dirty() {
return state.config && JSON.stringify(state.config) !== JSON.stringify(state.saved);
}
function feedback(message, error) {
const target = el('feedback');
if (!target) return;
target.textContent = message || '';
target.hidden = !message;
target.classList.toggle('is-error', !!error);
}
function updateControls() {
const writable = canWrite() && !!state.config && !state.busy;
const readable = canRead() && !!state.config && !state.busy;
const isDirty = !!dirty();
if (el('enabled')) el('enabled').disabled = !writable;
if (el('add')) el('add').disabled = !writable || state.config.rules.length >= 100;
if (el('save')) el('save').disabled = !writable || !isDirty;
if (el('reset')) el('reset').disabled = !writable || !isDirty;
if (el('test')) el('test').disabled = !canRead() || !state.config || state.busy || state.testing;
if (el('open-test')) el('open-test').disabled = !readable;
if (el('save-state')) {
el('save-state').textContent = state.busy ? tr('loading') : state.config ? tr(isDirty ? 'unsaved' : 'savedState') : '';
el('save-state').classList.toggle('is-dirty', isDirty);
}
if (el('protection-status') && state.config) {
el('protection-status').textContent = tr(state.config.enabled ? 'protectionOn' : 'protectionOff');
el('protection-status').classList.toggle('is-off', !state.config.enabled);
}
if (el('rule-count') && state.config) el('rule-count').textContent = tr('ruleCount', {
enabled: state.config.rules.filter((rule) => rule.enabled).length,
total: state.config.rules.length
});
ruleViews.forEach((view) => {
[view.checkbox, view.remove, ...Object.values(view.fields)].forEach((input) => { input.disabled = !writable; });
// Reading and collapsing an editor never requires write permission.
view.summary.disabled = false;
view.close.disabled = false;
view.validate.disabled = !readable;
updateLocalTestControls(view.tester);
updateRuleView(view);
});
if (state.addDraft) {
const draft = state.addDraft;
Object.values(draft.fields).forEach((input) => { input.disabled = !writable; });
el('add-confirm').disabled = !writable || !canRead() || draft.adding;
el('add-confirm').textContent = tr(draft.adding ? 'addingRule' : 'addToList');
updateLocalTestControls(draft.tester);
}
}
function invalidateTest() {
state.revision += 1;
if (el('test-result')) {
el('test-result').hidden = true;
el('test-result').replaceChildren();
}
}
function openTest(ruleId) {
if (!canRead() || !state.config || state.busy) return;
if (ruleId !== undefined && ruleId !== null) {
const view = ruleViews.get(ruleId);
if (!view) return;
openRule(ruleId);
view.tester.root.hidden = false;
view.validate.setAttribute('aria-expanded', 'true');
updateRuleView(view);
view.tester.args.focus();
return;
}
const panel = el('test-panel');
if (panel) {
panel.open = true;
panel.scrollIntoView({ behavior: 'auto', block: 'start' });
}
if (el('test-arguments')) el('test-arguments').focus({ preventScroll: true });
}
function changed(ruleId) {
invalidateTest();
const view = ruleViews.get(ruleId);
if (view) invalidateLocalTest(view.tester);
feedback('');
ruleViews.forEach((view) => Object.values(view.fields).forEach((input) => input.removeAttribute('aria-invalid')));
updateControls();
}
function textElement(tag, text, className) {
const node = document.createElement(tag);
node.textContent = text == null ? '' : String(text);
if (className) node.className = className;
return node;
}
function ruleField(rule, index, key, label, multiline, maxLength, fields, onChange) {
const field = document.createElement('div');
field.className = 'tool-guard-field tool-guard-field--' + key;
const input = document.createElement(multiline ? 'textarea' : 'input');
input.id = 'tool-guard-rule-' + index + '-' + key;
input.value = rule[key] || '';
input.maxLength = maxLength;
input.spellcheck = false;
if (multiline) input.rows = 3;
else input.type = 'text';
if (key === 'pattern') input.className = 'tool-guard-pattern';
const labelNode = textElement('label', tr(label));
labelNode.htmlFor = input.id;
input.addEventListener('input', () => {
if (!canWrite() || state.busy) return;
rule[key] = input.value;
if (onChange) onChange();
else changed(rule.id);
});
fields[key] = input;
field.append(labelNode, input);
if (key === 'message') {
const hint = textElement('p', tr('messageHint'), 'tool-guard-hint tool-guard-field-hint');
hint.id = input.id + '-hint';
input.setAttribute('aria-describedby', hint.id);
field.append(hint);
}
return field;
}
function updateRuleView(view) {
const { rule, card, summary, editor, title, preview, badge, status, checkbox } = view;
const expanded = state.openRuleId === rule.id;
const name = rule.name.trim() || tr('unnamedRule');
title.textContent = name;
preview.textContent = rule.message.trim() || tr('defaultPreview');
summary.setAttribute('aria-expanded', String(expanded));
summary.setAttribute('aria-label', tr(expanded ? 'collapseRule' : 'expandRule') + ': ' + name);
editor.hidden = !expanded;
card.classList.toggle('is-expanded', expanded);
card.classList.toggle('is-disabled', !rule.enabled);
checkbox.checked = !!rule.enabled;
checkbox.setAttribute('aria-label', tr('ruleEnabled') + ': ' + name);
status.textContent = tr(rule.enabled ? 'ruleOn' : 'ruleOff');
const savedRule = state.saved && state.saved.rules.find((saved) => saved.id === rule.id);
const modified = !!savedRule && JSON.stringify(savedRule) !== JSON.stringify(rule);
badge.hidden = !!savedRule && !modified;
badge.textContent = tr(savedRule ? 'ruleModified' : 'ruleNew');
view.validate.textContent = tr(view.tester.root.hidden ? 'validateRule' : 'closeRuleTest');
}
function openRule(id) {
state.openRuleId = id;
ruleViews.forEach(updateRuleView);
}
function renderRules() {
const target = el('rules');
if (!target || !state.config) return;
ruleViews.forEach((view) => disposeLocalTest(view.tester));
target.replaceChildren();
ruleViews.clear();
if (!state.config.rules.some((rule) => rule.id === state.openRuleId)) state.openRuleId = null;
if (!state.config.rules.length) {
target.append(textElement('p', tr('emptyRules'), 'tool-guard-empty'));
}
state.config.rules.forEach((rule, index) => {
const card = document.createElement('article');
card.className = 'tool-guard-rule';
card.id = 'tool-guard-rule-' + index;
const header = document.createElement('div');
header.className = 'tool-guard-rule-header';
const summary = document.createElement('button');
summary.type = 'button';
summary.id = card.id + '-summary';
summary.className = 'tool-guard-rule-summary';
summary.setAttribute('aria-controls', card.id + '-editor');
summary.addEventListener('click', () => openRule(state.openRuleId === rule.id ? null : rule.id));
const overview = document.createElement('span');
overview.className = 'tool-guard-rule-overview';
const title = textElement('span', '', 'tool-guard-rule-name');
title.id = card.id + '-title';
const preview = textElement('span', '', 'tool-guard-rule-preview');
preview.id = card.id + '-preview';
overview.append(title, preview);
const badge = textElement('span', '', 'tool-guard-rule-badge');
badge.id = card.id + '-badge';
const chevron = textElement('span', '', 'tool-guard-chevron');
chevron.setAttribute('aria-hidden', 'true');
summary.append(textElement('span', String(index + 1).padStart(2, '0'), 'tool-guard-rule-number'), overview, badge, chevron);
const actions = document.createElement('div');
actions.className = 'tool-guard-rule-actions';
const toggle = document.createElement('label');
toggle.className = 'tool-guard-toggle tool-guard-switch';
const checkbox = document.createElement('input');
checkbox.id = card.id + '-enabled';
checkbox.type = 'checkbox';
checkbox.className = 'theme-checkbox';
checkbox.checked = !!rule.enabled;
checkbox.addEventListener('change', () => {
if (!canWrite() || state.busy) return;
rule.enabled = checkbox.checked;
changed(rule.id);
});
const status = textElement('span', '');
toggle.append(checkbox, status);
actions.append(toggle);
const editor = document.createElement('div');
editor.className = 'tool-guard-rule-editor';
editor.id = card.id + '-editor';
editor.setAttribute('aria-labelledby', title.id);
const fields = {};
const grid = document.createElement('div');
grid.className = 'tool-guard-editor-grid';
grid.append(ruleField(rule, index, 'name', 'ruleName', false, 200, fields),
ruleField(rule, index, 'pattern', 'pattern', true, 4096, fields),
ruleField(rule, index, 'message', 'message', true, 4096, fields));
const footer = document.createElement('div');
footer.className = 'tool-guard-editor-footer';
const remove = textElement('button', tr('deleteRule'), 'btn-secondary tool-guard-delete');
remove.id = card.id + '-delete';
remove.type = 'button';
remove.addEventListener('click', () => {
if (!canWrite() || state.busy) return;
if (state.openRuleId === rule.id) state.openRuleId = null;
state.config.rules.splice(index, 1);
renderRules();
changed();
const next = state.config.rules[Math.min(index, state.config.rules.length - 1)];
const focusTarget = next ? ruleViews.get(next.id).summary : el('add');
if (focusTarget) focusTarget.focus();
});
const close = textElement('button', tr('closeEditor'), 'btn-secondary tool-guard-close');
close.id = card.id + '-close';
close.type = 'button';
close.addEventListener('click', () => { openRule(null); summary.focus(); });
const validate = textElement('button', tr('validateRule'), 'btn-secondary tool-guard-rule-validate');
validate.id = card.id + '-validate';
validate.type = 'button';
const tester = createLocalTest(rule, 'rule-' + index + '-test', fields);
tester.root.id = card.id + '-test-panel';
tester.root.classList.toggle('tool-guard-inline-test', true);
tester.root.hidden = true;
validate.setAttribute('aria-controls', tester.root.id);
validate.setAttribute('aria-expanded', 'false');
validate.addEventListener('click', () => {
if (!tester.root.hidden) {
tester.root.hidden = true;
validate.setAttribute('aria-expanded', 'false');
updateRuleView(ruleViews.get(rule.id));
} else openTest(rule.id);
});
const editorActions = document.createElement('div');
editorActions.className = 'tool-guard-editor-actions';
editorActions.append(validate, close);
footer.append(remove, editorActions);
editor.append(grid, footer, tester.root);
header.append(summary, actions);
card.append(header, editor);
ruleViews.set(rule.id, { rule, card, summary, editor, title, preview, badge, status, checkbox, remove, close, validate, fields, tester });
target.append(card);
});
updateControls();
}
function render() {
if (state.config && el('enabled')) el('enabled').checked = !!state.config.enabled;
renderRules();
updateControls();
}
async function request(url, method, body) {
const options = { method: method || 'GET' };
if (body !== undefined) {
options.headers = { 'Content-Type': 'application/json' };
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;
}
function normalizeConfig(config) {
if (!config || typeof config.enabled !== 'boolean' || (config.rules != null && !Array.isArray(config.rules))) {
throw new Error(tr('invalidResponse'));
}
return { enabled: config.enabled, rules: (config.rules || []).map((rule) => ({
id: String(rule.id || ''), name: String(rule.name || ''), enabled: !!rule.enabled,
pattern: String(rule.pattern || ''), message: String(rule.message || '')
})) };
}
async function loadConfig() {
if (!el('rules') || !canRead() || state.busy) return;
// Retain the user's draft when navigating away and back.
if (dirty() || state.addDraft) { updateControls(); return; }
state.busy = true;
feedback('');
updateControls();
try {
const config = normalizeConfig(await request('/api/tool-guard'));
state.saved = copy(config);
state.config = config;
invalidateTest();
render();
} catch (error) {
feedback(tr('loadFailed') + ': ' + error.message, true);
} finally {
state.busy = false;
updateControls();
}
}
function configForRequest(selected = null) {
if (!state.config) throw new Error(tr('loadFirst'));
const config = selected ? { enabled: true, rules: [{ ...copy(selected), enabled: true }] } : copy(state.config);
const utf8 = new TextEncoder();
for (const [index, rule] of config.rules.entries()) {
for (const key of ['name', 'pattern', 'message']) {
let message;
if (key !== 'message' && !rule[key].trim()) message = tr('requiredFields');
else if (utf8.encode(rule[key]).length > (key === 'name' ? 200 : 4096)) message = tr('fieldTooLong');
if (message) {
const error = new Error(message);
error.ruleIndex = index;
error.ruleId = rule.id;
error.ruleField = key;
throw error;
}
}
}
// RE2 validation belongs to the backend; JavaScript RegExp has different semantics.
return config;
}
function revealValidationError(error, requestRuleIds) {
let index = error.ruleIndex;
let field = error.ruleField;
if (!Number.isInteger(index)) {
// The server reports the 1-based rule number for RE2 validation errors.
const match = /tool guard rule (\d+)(?: \([^\n]*\))?: (.*)/.exec(error.message);
if (!match) return;
index = Number(match[1]) - 1;
field = /^name\b/.test(match[2]) ? 'name' : /^message\b/.test(match[2]) ? 'message' : 'pattern';
}
const ruleId = error.ruleId || (requestRuleIds && requestRuleIds[index]);
const rule = state.config && (ruleId ? state.config.rules.find((item) => item.id === ruleId) : state.config.rules[index]);
const view = rule && ruleViews.get(rule.id);
if (!view) return;
openRule(rule.id);
const input = view.fields[field];
if (input) {
input.setAttribute('aria-invalid', 'true');
if (input.disabled) view.summary.focus();
else input.focus();
}
}
async function saveConfig() {
if (!canWrite() || state.busy || !dirty()) return;
let failure;
try {
const config = configForRequest();
state.busy = true;
feedback('');
updateControls();
const saved = normalizeConfig(await request('/api/tool-guard', 'PUT', config));
state.saved = copy(saved);
state.config = saved;
invalidateTest();
render();
feedback(tr('saveSuccess'));
} catch (error) {
failure = error;
feedback(tr('saveFailed') + ': ' + error.message, true);
} finally {
state.busy = false;
updateControls();
if (failure) revealValidationError(failure);
}
}
function addRule() {
if (!canWrite() || !state.config || state.busy) return;
const dialog = el('add-dialog');
if (!dialog || state.addDraft) return;
if (state.config.rules.length >= 100) { feedback(tr('tooManyRules'), true); return; }
const id = typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function'
? crypto.randomUUID() : 'rule-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2);
const rule = { id, name: '', enabled: true, pattern: '', message: tr('defaultMessage') };
const draft = { rule, fields: {}, adding: false, revision: 0, returnFocus: document.activeElement || el('add') };
state.addDraft = draft;
const onChange = () => {
draft.revision += 1;
invalidateLocalTest(draft.tester);
showLocalFeedback(el('add-feedback'), '');
Object.values(draft.fields).forEach((input) => input.removeAttribute('aria-invalid'));
};
el('add-fields').replaceChildren(
ruleField(rule, 'draft', 'name', 'ruleName', false, 200, draft.fields, onChange),
ruleField(rule, 'draft', 'pattern', 'pattern', true, 4096, draft.fields, onChange),
ruleField(rule, 'draft', 'message', 'message', true, 4096, draft.fields, onChange)
);
draft.tester = createLocalTest(rule, 'draft-test', draft.fields);
el('add-test').replaceChildren(draft.tester.root);
showLocalFeedback(el('add-feedback'), '');
if (!dialog.dataset.guardBound) {
dialog.dataset.guardBound = 'true';
dialog.addEventListener('cancel', (event) => { event.preventDefault(); closeRuleDialog(); });
dialog.addEventListener('close', () => { if (!dialog.open) closeRuleDialog(); });
dialog.addEventListener('keydown', (event) => {
if (event.key !== 'Tab') return;
const controls = Array.from(dialog.querySelectorAll('button, input, textarea'))
.filter((input) => !input.disabled && !input.hidden);
const first = controls[0];
const last = controls[controls.length - 1];
if (first && event.shiftKey && document.activeElement === first) {
event.preventDefault();
last.focus();
} else if (last && !event.shiftKey && document.activeElement === last) {
event.preventDefault();
first.focus();
}
});
}
updateControls();
dialog.showModal();
draft.fields.name.focus();
}
function closeRuleDialog() {
const draft = state.addDraft;
if (!draft) return;
state.addDraft = null;
disposeLocalTest(draft.tester);
const dialog = el('add-dialog');
if (dialog.open) dialog.close();
el('add-fields').replaceChildren();
el('add-test').replaceChildren();
showLocalFeedback(el('add-feedback'), '');
updateControls();
if (draft.returnFocus) draft.returnFocus.focus();
}
async function commitRule() {
const draft = state.addDraft;
if (!draft || !canWrite() || !canRead() || state.busy || draft.adding) return;
const revision = draft.revision;
try {
if (state.config.rules.length >= 100) throw new Error(tr('tooManyRules'));
const config = configForRequest(draft.rule);
draft.adding = true;
showLocalFeedback(el('add-feedback'), '');
updateControls();
// Validate with the same RE2 engine as saving, independently of the optional test inputs.
const result = await request('/api/tool-guard/test', 'POST', { config, toolName: 'rule_validation', arguments: {} });
if (state.addDraft !== draft || revision !== draft.revision) return;
validateTestResult(result);
state.config.rules.push(copy(draft.rule));
closeRuleDialog();
state.openRuleId = null;
renderRules();
changed();
feedback(tr('ruleAdded'));
const view = ruleViews.get(draft.rule.id);
if (view) view.summary.focus();
} catch (error) {
if (state.addDraft !== draft || revision !== draft.revision) return;
showLocalFeedback(el('add-feedback'), tr('addFailed') + ': ' + error.message);
revealLocalError(draft.tester, error);
} finally {
draft.adding = false;
updateControls();
}
}
function resetConfig() {
if (!canWrite() || state.busy || !state.saved) return;
closeRuleDialog();
state.config = copy(state.saved);
render();
changed();
}
function changeEnabled(enabled) {
if (!canWrite() || !state.config || state.busy) return;
state.config.enabled = !!enabled;
changed();
}
function renderTestResult(result, enabled, single, target = el('test-result')) {
if (!target) return;
target.replaceChildren();
target.hidden = false;
target.classList.toggle('is-blocked', !!result.blocked);
target.classList.toggle('is-single', !!single);
target.append(textElement('strong', tr(single ? (result.blocked ? 'singleMatched' : 'singleNotMatched') :
result.blocked ? 'blocked' : enabled ? 'notBlocked' : 'disabledResult')));
if (single) target.append(textElement('p', tr('singleResultHint'), 'tool-guard-single-result-hint'));
if (result.blocked && result.match) {
const fields = [
['matchedRule', result.match.ruleName || result.match.ruleId],
['matchedText', result.match.matchedText],
['matchedMessage', result.match.message]
];
fields.forEach(([key, value]) => {
target.append(textElement('p', tr(key), 'tool-guard-result-label'));
target.append(textElement('pre', value, 'tool-guard-result-value'));
});
}
}
function showLocalFeedback(target, message) {
target.textContent = message || '';
target.hidden = !message;
target.classList.toggle('is-error', !!message);
}
function reserveDialogTestSpace(tester) {
if (!state.addDraft || state.addDraft.tester !== tester) return;
const body = el('add-body');
const bottomSlack = Math.max(0, body.scrollHeight - body.clientHeight - body.scrollTop);
// Keep only the space needed to avoid clamping the current scroll position.
// Do not retain an entire long result or accumulate its height across tests.
const height = Math.max(180, Math.min(body.clientHeight, tester.output.getBoundingClientRect().height - bottomSlack));
tester.output.style.minHeight = Math.ceil(height) + 'px';
}
function invalidateLocalTest(tester, statusKey = 'testChanged') {
if (!tester) return;
reserveDialogTestSpace(tester);
tester.revision += 1;
tester.result.hidden = true;
tester.result.replaceChildren();
showLocalFeedback(tester.feedback, '');
tester.placeholder.textContent = tr(statusKey);
tester.placeholder.hidden = false;
}
function disposeLocalTest(tester) {
if (!tester) return;
tester.disposed = true;
invalidateLocalTest(tester);
}
function updateLocalTestControls(tester) {
if (!tester) return;
tester.run.disabled = !canRead() || state.busy || tester.testing || tester.disposed;
tester.run.textContent = tr(tester.testing ? 'testing' : 'test');
tester.output.setAttribute('aria-busy', String(tester.testing));
tester.tool.disabled = !canRead() || state.busy;
tester.args.disabled = !canRead() || state.busy;
}
function createLocalTest(rule, prefix, fields) {
const root = document.createElement('section');
root.className = 'tool-guard-local-test';
const title = textElement('h4', tr('singleTestTitle'));
title.id = 'tool-guard-' + prefix + '-title';
root.setAttribute('aria-labelledby', title.id);
root.append(title, textElement('p', tr('singleTestHint'), 'tool-guard-hint'));
const tester = { rule, fields, root, revision: 0, testing: false, disposed: false };
for (const [key, suffix, label, value] of [
['tool', 'tool', 'testTool', 'http_request'],
['args', 'arguments', 'testArguments', '{"url": "https://example.gov.cn"}']
]) {
const field = document.createElement('div');
field.className = 'tool-guard-field';
const input = document.createElement(key === 'tool' ? 'input' : 'textarea');
input.id = 'tool-guard-' + prefix + '-' + suffix;
input.value = value;
input.spellcheck = false;
if (key === 'tool') { input.type = 'text'; input.maxLength = 512; input.autocomplete = 'off'; }
else { input.rows = 4; input.className = 'tool-guard-test-arguments'; }
const labelNode = textElement('label', tr(label));
labelNode.htmlFor = input.id;
input.addEventListener('input', () => {
input.removeAttribute('aria-invalid');
invalidateLocalTest(tester);
});
tester[key] = input;
field.append(labelNode, input);
root.append(field);
}
tester.run = textElement('button', tr('test'), 'btn-secondary tool-guard-test-run');
tester.run.id = 'tool-guard-' + prefix + '-run';
tester.run.type = 'button';
tester.run.addEventListener('click', () => testLocalRule(tester));
tester.feedback = textElement('div', '', 'tool-guard-feedback');
tester.feedback.id = 'tool-guard-' + prefix + '-feedback';
tester.feedback.hidden = true;
tester.feedback.setAttribute('role', 'status');
tester.result = textElement('div', '', 'tool-guard-test-result');
tester.result.id = 'tool-guard-' + prefix + '-result';
tester.result.hidden = true;
tester.result.setAttribute('role', 'status');
tester.output = textElement('div', '', 'tool-guard-local-output');
tester.output.id = 'tool-guard-' + prefix + '-output';
if (prefix === 'draft-test') {
tester.output.setAttribute('role', 'region');
tester.output.setAttribute('aria-label', tr('testResultLabel'));
}
tester.placeholder = textElement('p', tr('testReadyHint'), 'tool-guard-test-placeholder');
tester.placeholder.id = 'tool-guard-' + prefix + '-status';
tester.placeholder.setAttribute('role', 'status');
tester.output.append(tester.placeholder, tester.feedback, tester.result);
root.append(tester.run, tester.output);
updateLocalTestControls(tester);
return tester;
}
function revealLocalError(tester, error) {
const match = /tool guard rule \d+(?: \([^\n]*\))?: (.*)/.exec(error.message);
const field = error.ruleField || (match ? /^name\b/.test(match[1]) ? 'name' :
/^message\b/.test(match[1]) ? 'message' : 'pattern' : null);
const input = error.input || tester.fields[field];
if (!input) return;
input.setAttribute('aria-invalid', 'true');
// Async feedback stays with its rule instead of reopening another editor.
if ((state.addDraft && state.addDraft.tester === tester) || state.openRuleId === tester.rule.id) {
if (!input.disabled) input.focus();
}
}
function validateTestResult(result) {
if (!result || typeof result.blocked !== 'boolean' || (result.blocked && (!result.match ||
typeof result.match.matchedText !== 'string' || typeof result.match.message !== 'string'))) {
throw new Error(tr('invalidTestResponse'));
}
}
async function testLocalRule(tester) {
if (!canRead() || state.busy || tester.testing || tester.disposed || !state.config) return;
invalidateLocalTest(tester, 'testing');
const revision = tester.revision;
try {
const config = configForRequest(tester.rule);
const toolName = tester.tool.value.trim();
if (!toolName) {
const error = new Error(tr('toolRequired'));
error.input = tester.tool;
throw error;
}
let args;
try {
args = JSON.parse(tester.args.value);
if (!args || Array.isArray(args) || typeof args !== 'object') throw new Error();
} catch (_) {
const error = new Error(tr('invalidArguments'));
error.input = tester.args;
throw error;
}
tester.testing = true;
updateLocalTestControls(tester);
const result = await request('/api/tool-guard/test', 'POST', { config, toolName, arguments: args });
if (tester.disposed || revision !== tester.revision) return;
validateTestResult(result);
tester.placeholder.hidden = true;
renderTestResult(result, true, true, tester.result);
} catch (error) {
if (tester.disposed || revision !== tester.revision) return;
tester.placeholder.hidden = true;
showLocalFeedback(tester.feedback, tr('testFailed') + ': ' + error.message);
revealLocalError(tester, error);
} finally {
tester.testing = false;
updateLocalTestControls(tester);
}
}
async function testConfig() {
if (!canRead() || state.busy || state.testing || !state.config) return;
let revision;
let requestRuleIds;
try {
invalidateTest();
const config = configForRequest();
requestRuleIds = config.rules.map((rule) => rule.id);
const toolName = el('test-tool').value.trim();
if (!toolName) throw new Error(tr('toolRequired'));
let args;
try { args = JSON.parse(el('test-arguments').value); }
catch (_) { throw new Error(tr('invalidArguments')); }
if (!args || Array.isArray(args) || typeof args !== 'object') throw new Error(tr('invalidArguments'));
revision = state.revision;
state.testing = true;
feedback('');
updateControls();
const result = await request('/api/tool-guard/test', 'POST', { config, toolName, arguments: args });
if (state.revision !== revision) return;
validateTestResult(result);
renderTestResult(result, config.enabled, false);
} catch (error) {
if (revision === undefined || revision === state.revision) {
feedback(tr('testFailed') + ': ' + error.message, true);
revealValidationError(error, requestRuleIds);
}
} finally {
state.testing = false;
updateControls();
}
}
window.loadToolGuardConfig = loadConfig;
window.saveToolGuardConfig = saveConfig;
window.addToolGuardRule = addRule;
window.closeToolGuardRuleDialog = closeRuleDialog;
window.commitToolGuardRule = commitRule;
window.resetToolGuardConfig = resetConfig;
window.changeToolGuardEnabled = changeEnabled;
window.openToolGuardTest = openTest;
window.testToolGuardConfig = testConfig;
window.invalidateToolGuardTest = invalidateTest;
document.addEventListener('languagechange', () => {
render();
invalidateTest();
feedback('');
});
})();