feat: add configurable tool call blocking and monitoring

This commit is contained in:
Ed1s0nZ
2026-09-08 09:44:32 +08:00
parent c70da22de7
commit 6ad9ea2d13
54 changed files with 4635 additions and 152 deletions
+3 -2
View File
@@ -368,6 +368,7 @@ const PAGE_PERMISSION_MAP = {
dashboard: 'dashboard:read',
chat: 'chat:read',
hitl: 'hitl:read',
'tool-guard': 'config:read',
'info-collect': 'fofa:execute',
assets: 'asset:read',
'asset-overview': 'asset:read',
@@ -613,10 +614,10 @@ function setUserMenuOpen(open) {
function getStatusText(status) {
const s = (status && String(status).toLowerCase()) || '';
if (typeof window.t !== 'function') {
const fallback = { pending: '等待中', queued: '排队中', running: '执行中', background_running: '后台执行中', completed: '已完成', failed: '失败', cancelled: '已终止', hard_timeout: '硬超时', orphaned: '孤儿记录' };
const fallback = { pending: '等待中', queued: '排队中', running: '执行中', background_running: '后台执行中', completed: '已完成', failed: '失败', blocked: '已拦截', cancelled: '已终止', hard_timeout: '硬超时', orphaned: '孤儿记录' };
return fallback[s] || status;
}
const keyMap = { pending: 'mcpDetailModal.statusPending', queued: 'mcpDetailModal.statusQueued', running: 'mcpDetailModal.statusRunning', background_running: 'timeline.backgroundRunning', completed: 'mcpDetailModal.statusCompleted', failed: 'mcpDetailModal.statusFailed', cancelled: 'mcpDetailModal.statusCancelled', hard_timeout: 'mcpMonitor.statusHardTimeout', orphaned: 'mcpMonitor.statusOrphaned' };
const keyMap = { pending: 'mcpDetailModal.statusPending', queued: 'mcpDetailModal.statusQueued', running: 'mcpDetailModal.statusRunning', background_running: 'timeline.backgroundRunning', completed: 'mcpDetailModal.statusCompleted', failed: 'mcpDetailModal.statusFailed', blocked: 'mcpMonitor.statusBlocked', cancelled: 'mcpDetailModal.statusCancelled', hard_timeout: 'mcpMonitor.statusHardTimeout', orphaned: 'mcpMonitor.statusOrphaned' };
const key = keyMap[s];
return key ? window.t(key) : status;
}
+5 -5
View File
@@ -158,7 +158,7 @@ test('登录成功后重新加载曾因未授权失败的项目侧栏', () => {
assert.notEqual(conversationsIndex, -1);
assert.ok(projectRetryIndex > conversationsIndex);
assert.match(refreshSource, /typeof window\.refreshChatProjectSelector === 'function'/);
assert.match(html, /\/static\/js\/auth\.js\?v=20260813-1/);
assert.match(html, /\/static\/js\/auth\.js\?v=20260907-blocked-1/);
});
test('用户真正滑到底部后恢复自动跟随且不会提前强制跳底', () => {
@@ -341,7 +341,7 @@ test('消息气泡内部流式增高时仅在跟随模式继续粘底', () => {
test('页面在任务补流脚本之前加载智能滚动控制器', () => {
const scrollIndex = html.indexOf('/static/js/chat-scroll.js?v=20260815-1');
const monitorIndex = html.indexOf('/static/js/monitor.js?v=20260819-3');
const monitorIndex = html.indexOf('/static/js/monitor.js?v=20260907-blocked-1');
assert.notEqual(scrollIndex, -1);
assert.notEqual(monitorIndex, -1);
@@ -468,8 +468,8 @@ test('刷新指定对话时立即恢复且加载完成前不闪出无项目状
assert.match(loadSource, /finally \{[\s\S]*?finishChatConversationRestore\(conversationId\)/);
assert.match(css, /\.chat-container\.is-conversation-restoring #chat-messages/);
assert.match(css, /\.chat-container\.is-conversation-restoring #chat-input-container/);
assert.match(html, /router\.js\?v=20260819-3/);
assert.match(html, /chat\.js\?v=20260819-5/);
assert.match(html, /router\.js\?v=20260907-1/);
assert.match(html, /chat\.js\?v=20260907-blocked-1/);
});
test('刷新运行中回复会复用已持久化 planning 并继续追加未来增量', () => {
@@ -533,5 +533,5 @@ test('暗色模式对话三点悬浮不会触发浅色父行背景', () => {
const css = fs.readFileSync('web/static/css/style.css', 'utf8');
assert.match(css, /html\[data-theme="dark"\] \.project-conversation-row:hover \.project-conversation-item/);
assert.match(css, /html\[data-theme="dark"\] \.project-folder-action:hover,[\s\S]*?background: rgba\(71, 85, 105, 0\.28\);[\s\S]*?box-shadow: none;/);
assert.match(html, /style\.css\?v=20260819-4/);
assert.match(html, /style\.css\?v=20260907-blocked-1/);
});
+30 -8
View File
@@ -1891,6 +1891,8 @@ function initChatPrimaryActionButton() {
updateChatPrimaryActionState();
}
document.addEventListener('DOMContentLoaded', initChatPrimaryActionButton);
function closeChatReasoningPanel() {
const wrap = document.getElementById('chat-reasoning-wrapper');
const toggle = document.getElementById('conversation-reasoning-toggle');
@@ -4355,8 +4357,10 @@ function renderProcessDetails(messageId, processDetails, options) {
: { kind: (data.success !== false ? 'success' : 'error'), isError: data.success === false };
const backgroundRunning = displayState.kind === 'background_running';
const success = !displayState.isError && !backgroundRunning;
const statusIcon = backgroundRunning ? '⏳' : (success ? '✅' : '❌');
const execText = backgroundRunning
const statusIcon = displayState.kind === 'blocked' ? '🛡' : (backgroundRunning ? '⏳' : (success ? '✅' : '❌'));
const execText = displayState.kind === 'blocked'
? (typeof window.t === 'function' ? window.t('chat.toolExecBlocked', { name: escapeHtml(toolName) }) : '工具 ' + escapeHtml(toolName) + ' 已拦截')
: backgroundRunning
? ((typeof window.getBackgroundRunningToolLabel === 'function' ? window.getBackgroundRunningToolLabel() : '后台执行中') + ': ' + escapeHtml(toolName))
: (success ? (typeof window.t === 'function' ? window.t('chat.toolExecComplete', { name: escapeHtml(toolName) }) : '工具 ' + escapeHtml(toolName) + ' 执行完成') : (typeof window.t === 'function' ? window.t('chat.toolExecFailed', { name: escapeHtml(toolName) }) : '工具 ' + escapeHtml(toolName) + ' 执行失败'));
let execLine = statusIcon + ' ' + execText;
@@ -5399,7 +5403,7 @@ function normalizeToolExecutionSummary(raw) {
if (raw && typeof raw === 'object') {
return {
toolName: raw.toolName || raw.name || '',
status: raw.status || ''
status: typeof window.getToolExecutionDisplayStatus === 'function' ? window.getToolExecutionDisplayStatus(raw) : (raw.status || '')
};
}
return { toolName: '', status: '' };
@@ -5411,6 +5415,7 @@ function getToolExecutionStatusLabel(status) {
const keyMap = {
completed: 'mcpMonitor.statusSuccess',
failed: 'mcpMonitor.statusFailed',
blocked: 'mcpMonitor.statusBlocked',
running: 'mcpMonitor.statusRunning',
cancelled: 'mcpMonitor.statusCancelled',
pending: 'mcpMonitor.statusPending',
@@ -5425,6 +5430,7 @@ function getToolExecutionStatusLabel(status) {
const fallback = {
completed: '成功',
failed: '失败',
blocked: '已拦截',
running: '运行中',
cancelled: '已取消',
pending: '等待中',
@@ -5504,7 +5510,8 @@ function formatMCPResultJsonForDisplay(result) {
if (!result) return '{}';
const payload = {
content: result.content,
isError: !!result.isError
isError: !!result.isError,
...(result.blocked === true ? { blocked: true } : {})
};
return JSON.stringify(payload, null, 2);
}
@@ -5552,12 +5559,13 @@ function renderMCPDetailModal(exec) {
document.getElementById('detail-tool-name').textContent = exec.toolName || (typeof window.t === 'function' ? window.t('mcpDetailModal.unknown') : 'Unknown');
document.getElementById('detail-execution-id').textContent = exec.id || 'N/A';
const statusEl = document.getElementById('detail-status');
const normalizedStatus = (exec.status || 'unknown').toLowerCase();
statusEl.textContent = getStatusText(exec.status);
const normalizedStatus = typeof window.getToolExecutionDisplayStatus === 'function' ? window.getToolExecutionDisplayStatus(exec) : (exec.status || 'unknown').toLowerCase();
const blocked = normalizedStatus === 'blocked';
statusEl.textContent = getStatusText(normalizedStatus);
const statusClass = normalizedStatus === 'background_running' ? 'running' : normalizedStatus;
statusEl.className = `status-chip status-${statusClass}`;
try {
statusEl.dataset.detailStatus = (exec.status || '') + '';
statusEl.dataset.detailStatus = normalizedStatus;
} catch (e) { /* ignore */ }
const detailTimeLocale = (typeof window.__locale === 'string' && window.__locale.startsWith('zh')) ? 'zh-CN' : 'en-US';
const detailTimeEl = document.getElementById('detail-time');
@@ -5592,12 +5600,26 @@ function renderMCPDetailModal(exec) {
errorElement.textContent = '';
}
setMCPResultDetailTabs('raw', false);
const resultTabLabel = document.querySelector('#detail-result-tab-success [data-i18n]');
if (resultTabLabel) {
const key = blocked ? 'mcpDetailModal.blockReason' : 'mcpDetailModal.correctInfo';
resultTabLabel.dataset.i18n = key;
resultTabLabel.textContent = typeof window.t === 'function' ? window.t(key) : (blocked ? '拦截原因' : '正确信息');
}
if (exec.result) {
const agentVisibleText = formatMCPDetailText(extractMCPResultText(exec.result));
const emptyText = typeof window.t === 'function' ? window.t('mcpDetailModal.execSuccessNoContent') : '执行成功,未返回可展示的文本内容。';
if (exec.result.isError) {
if (blocked) {
responseElement.className = 'code-block blocked';
responseElement.textContent = formatMCPResultJsonForDisplay(exec.result);
if (successElement) {
successElement.className = 'code-block blocked';
successElement.textContent = agentVisibleText || exec.error || getStatusText('blocked');
}
setMCPResultDetailTabs('success', true);
} else if (exec.result.isError) {
responseElement.className = 'code-block error';
responseElement.textContent = formatMCPResultJsonForDisplay(exec.result);
if (successElement) {
+3 -3
View File
@@ -291,10 +291,10 @@ test('多对话并发时释放隐藏主流且旧请求不能覆盖新对话状
assert.match(chat, /let loadConversationAbortController = null/);
assert.match(chat, /cancelPendingConversationLoad\(\);[\s\S]{0,900}const conversationLoadController = new AbortController\(\)/);
assert.match(chat, /signal: conversationLoadController\.signal/);
assert.match(template, /monitor\.js\?v=20260819-3/);
assert.match(template, /monitor\.js\?v=20260907-blocked-1/);
assert.match(template, /chat-scroll\.js\?v=20260815-1/);
assert.match(template, /chat\.js\?v=20260819-5/);
assert.match(template, /style\.css\?v=20260819-4/);
assert.match(template, /chat\.js\?v=20260907-blocked-1/);
assert.match(template, /style\.css\?v=20260907-blocked-1/);
});
test('彻底停止始终使用弹窗锁定的会话且状态刷新后仍会取消', () => {
@@ -0,0 +1,81 @@
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/monitor.js', 'utf8');
const statsSource = source.slice(source.indexOf('const MCP_STATS_TOP_N'), source.indexOf('function renderMonitorExecutions('));
function harness() {
const container = { innerHTML: '' };
const escapeHtml = (value) => String(value).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
const context = vm.createContext({
window: { __locale: 'zh-CN' },
document: { getElementById: (id) => id === 'monitor-stats' ? container : null },
localStorage: { getItem: () => null },
monitorState: {},
escapeHtml,
escapeAttrLocal: escapeHtml,
formatMonitorToolName: (name) => name,
monitorToolNamesEqual: (a, b) => a === b,
});
vm.runInContext(statsSource, context);
Object.assign(context, {
bindMonitorStatsPanelEvents() {},
bindMcpStatsTimelineEvents() {},
updateMonitorStatsSubtitle() {},
});
return { context, container };
}
test('安全拦截计入总调用量,但不归入失败或终止', () => {
const { context } = harness();
const totals = context.buildMonitorTotals({ totalCalls: 10, successCalls: 4, failedCalls: 1, blockedCalls: 3 });
assert.deepEqual({ ...totals }, { total: 10, success: 4, failed: 1, blocked: 3, neutral: 2, lastCallTime: null });
assert.equal(context.buildMonitorTotals({ totalCalls: 3, blockedCalls: 3 }).neutral, 0);
assert.equal(context.buildMonitorTotals({ totalCalls: 2, successCalls: 1, failedCalls: 1 }).blocked, 0);
});
test('概览成功率排除安全拦截,只有拦截时不显示失败率或终止标签', () => {
const { context, container } = harness();
context.renderMonitorStats({ totalCalls: 5, successCalls: 1, failedCalls: 1, blockedCalls: 3 });
assert.match(container.innerHTML, />50\.0%<\/span>/);
assert.match(container.innerHTML, /is-blocked">安全拦截 3<\/span>/);
assert.doesNotMatch(container.innerHTML, /is-neutral/);
context.renderMonitorStats({ totalCalls: 3, blockedCalls: 3 });
assert.match(container.innerHTML, /value--rate is-muted">-<\/span>/);
assert.match(container.innerHTML, /is-fail">失败 0<\/span>/);
assert.doesNotMatch(container.innerHTML, /is-danger|is-neutral|0\.0%/);
});
test('工具统计独立展示安全拦截,拦截不降低工具成功率', () => {
const { context } = harness();
for (const render of [context.renderMcpStatsToolTable, context.renderMcpStatsToolsPanel]) {
const blockedOnly = render([{ toolName: 'safe-tool', totalCalls: 4, blockedCalls: 4 }], { total: 4 });
assert.match(blockedOnly, /安全拦截 4/);
assert.match(blockedOnly, /is-muted">-<\/span>/);
assert.doesNotMatch(blockedOnly, /is-danger|>0\.0%<\/span>/);
const mixed = render([{ toolName: 'safe-tool', totalCalls: 10, successCalls: 3, failedCalls: 1, blockedCalls: 6 }], { total: 10 });
assert.match(mixed, /75\.0%/);
assert.match(mixed, /安全拦截 6/);
assert.match(mixed, /失败 1/);
}
});
test('趋势图区分安全拦截和失败,并保留悬停的独立计数', () => {
const { context } = harness();
const points = [{ t: '2026-09-07T00:00:00Z', total: 3, failed: 0, blocked: 3 }];
const html = context.renderMcpStatsTimelineBody({ range: '24h', points, summary: { totalCalls: 3, peak: 3 } });
assert.match(html, /legend-item--blocked">安全拦截/);
assert.match(html, /mcp-stats-timeline-bar-blocked/);
assert.match(html, /mcp-stats-timeline-line--blocked/);
assert.match(html, /data-total="3" data-failed="0" data-blocked="3"/);
assert.doesNotMatch(html, /legend-item--fail|mcp-stats-timeline-bar-fail/);
const mixed = context.buildMcpTimelineSvg([{ ...points[0], total: 4, failed: 1, blocked: 2 }], '24h');
assert.match(mixed, /data-total="4" data-failed="1" data-blocked="2"/);
assert.match(mixed, /mcp-stats-timeline-bar-fail/);
assert.match(mixed, /mcp-stats-timeline-bar-blocked/);
});
+122 -43
View File
@@ -3640,13 +3640,15 @@ function handleStreamEvent(event, progressElement, progressId,
case 'tool_result':
const resultInfo = event.data || {};
const resultToolName = resultInfo.toolName || (typeof window.t === 'function' ? window.t('chat.unknownTool') : '未知工具');
const success = resultInfo.success !== false;
const success = getToolResultDisplayState(resultInfo).success;
const resultDisplayState = getToolResultDisplayState(resultInfo, { rawText: event.message || '' });
const backgroundRunning = resultDisplayState.kind === 'background_running';
const statusIcon = backgroundRunning ? '⏳' : (success ? '✅' : '❌');
const statusIcon = resultDisplayState.kind === 'blocked' ? '🛡' : (backgroundRunning ? '⏳' : (success ? '✅' : '❌'));
const resultToolCallId = resultInfo.toolCallId || null;
const resultStatusForCall = backgroundRunning ? 'background_running' : (success ? 'completed' : 'failed');
const resultExecText = backgroundRunning
const resultStatusForCall = toolDisplayStatusFromState(resultDisplayState);
const resultExecText = resultDisplayState.kind === 'blocked'
? (typeof window.t === 'function' ? window.t('chat.toolExecBlocked', { name: escapeHtml(resultToolName) }) : '工具 ' + escapeHtml(resultToolName) + ' 已拦截')
: backgroundRunning
? (getBackgroundRunningToolLabel() + ': ' + escapeHtml(resultToolName))
: (success ? (typeof window.t === 'function' ? window.t('chat.toolExecComplete', { name: escapeHtml(resultToolName) }) : '工具 ' + escapeHtml(resultToolName) + ' 执行完成') : (typeof window.t === 'function' ? window.t('chat.toolExecFailed', { name: escapeHtml(resultToolName) }) : '工具 ' + escapeHtml(resultToolName) + ' 执行失败'));
@@ -5887,9 +5889,41 @@ function collectToolResultTextParts(value, parts, depth) {
if (value.content != null) collectToolResultTextParts(value.content, parts, depth + 1);
}
// Older records did not have a structured marker. Only recognize the exact
// guard prefix at the start of a result, never a quoted mention in ordinary output.
function isToolGuardBlockedResult(value, depth, allowLegacy) {
depth = depth || 0;
allowLegacy = allowLegacy !== false;
if (value == null || depth > 5) return false;
if (typeof value === 'string') {
const text = value.trimStart();
if (allowLegacy && /^工具调用已被安全规则拦截(?:[:\r\n]|$)/.test(text)) return true;
if (text.startsWith('{')) {
try { return isToolGuardBlockedResult(JSON.parse(text), depth + 1, allowLegacy); } catch (e) { /* plain text */ }
}
return false;
}
if (typeof value !== 'object') return false;
if (Array.isArray(value)) return value.some(function (part) { return isToolGuardBlockedResult(part, depth + 1, allowLegacy); });
if (value.blocked === true || value.status === 'blocked' || value.displayStatus === 'blocked') return true;
if (value._meta && value._meta['cyberstrike.ai/blocked'] === true) return true;
if (value.success === true || value.isError === false || value.status === 'completed') allowLegacy = false;
return ['result', 'error', 'content', 'text', 'resultPreview'].some(function (key) {
return isToolGuardBlockedResult(value[key], depth + 1, allowLegacy);
});
}
function getToolExecutionDisplayStatus(execution) {
return isToolGuardBlockedResult(execution) ? 'blocked' : String(execution && execution.status || 'unknown').toLowerCase();
}
function getToolResultDisplayState(data, opts) {
opts = opts || {};
data = data || {};
const allowLegacyBlock = data.success !== true && data.isError !== false && data.status !== 'completed';
if (isToolGuardBlockedResult(data) || isToolGuardBlockedResult(opts.rawText, 0, allowLegacyBlock)) {
return { kind: 'blocked', isError: true, success: false };
}
const toolName = String(data.toolName || data.name || '').trim().toLowerCase();
const isObservationTool = toolName === 'wait_tool_execution' || toolName === 'get_tool_execution';
const explicitStatus = String(data.displayStatus || data.status || '').toLowerCase();
@@ -5937,6 +5971,7 @@ function toolDisplayStatusFromState(displayState) {
if (!displayState) return 'completed';
if (displayState.kind === 'background_running') return 'background_running';
if (displayState.kind === 'cancelled') return 'cancelled';
if (displayState.kind === 'blocked') return 'blocked';
return displayState.isError ? 'failed' : 'completed';
}
@@ -5961,7 +5996,7 @@ function buildToolResultSectionHtml(data, opts) {
const resultStr = typeof result === 'string' ? result : JSON.stringify(result);
const rawText = opts.rawText != null ? String(opts.rawText) : resultStr;
const displayState = getToolResultDisplayState(data, { rawText: rawText });
const sectionClass = displayState.kind === 'background_running' ? 'pending' : (displayState.isError ? 'error' : 'success');
const sectionClass = displayState.kind === 'blocked' ? 'blocked' : (displayState.kind === 'background_running' ? 'pending' : (displayState.isError ? 'error' : 'success'));
return (
'<div class="tool-result-section ' + sectionClass + '">' +
'<strong data-i18n="timeline.executionResult">' + escapeHtml(execResultLabel) + '</strong>' +
@@ -6185,8 +6220,8 @@ function mergeToolResultIntoCallItem(item, data, options) {
if (data.executionId != null && String(data.executionId).trim() !== '') {
item.dataset.toolExecutionId = String(data.executionId).trim();
}
item.classList.remove('tool-call-running', 'tool-call-completed', 'tool-call-failed');
item.classList.add(backgroundRunning ? 'tool-call-running' : (displayState.isError ? 'tool-call-failed' : 'tool-call-completed'));
item.classList.remove('tool-call-running', 'tool-call-completed', 'tool-call-failed', 'tool-call-blocked');
item.classList.add(getToolCallStatusPresentation(toolDisplayStatusFromState(displayState)).itemClass);
applyToolCallStatus(item, item.dataset.toolDisplayStatus);
return true;
}
@@ -6199,7 +6234,7 @@ function mergeToolResultIntoCallItem(item, data, options) {
if (!section) return false;
section.classList.remove('pending');
section.className = 'tool-result-section ' + (backgroundRunning ? 'pending' : (displayState.isError ? 'error' : 'success'));
section.className = 'tool-result-section ' + (displayState.kind === 'blocked' ? 'blocked' : (backgroundRunning ? 'pending' : (displayState.isError ? 'error' : 'success')));
const pre = section.querySelector('pre.tool-result');
if (pre) {
pre.classList.remove('tool-result-pending');
@@ -6228,8 +6263,8 @@ function mergeToolResultIntoCallItem(item, data, options) {
if (data.executionId != null && String(data.executionId).trim() !== '') {
item.dataset.toolExecutionId = String(data.executionId).trim();
}
item.classList.remove('tool-call-running', 'tool-call-completed', 'tool-call-failed');
item.classList.add(backgroundRunning ? 'tool-call-running' : (displayState.isError ? 'tool-call-failed' : 'tool-call-completed'));
item.classList.remove('tool-call-running', 'tool-call-completed', 'tool-call-failed', 'tool-call-blocked');
item.classList.add(getToolCallStatusPresentation(toolDisplayStatusFromState(displayState)).itemClass);
applyToolCallStatus(item, item.dataset.toolDisplayStatus);
return true;
}
@@ -6368,6 +6403,7 @@ window.mergeToolResultIntoCallItem = mergeToolResultIntoCallItem;
window.formatToolCallTimelineTitle = formatToolCallTimelineTitle;
window.parseToolCallArgsFromData = parseToolCallArgsFromData;
window.getToolResultDisplayState = getToolResultDisplayState;
window.getToolExecutionDisplayStatus = getToolExecutionDisplayStatus;
window.getBackgroundRunningToolLabel = getBackgroundRunningToolLabel;
window.buildToolResultSectionHtml = buildToolResultSectionHtml;
@@ -6390,6 +6426,9 @@ function getToolCallStatusPresentation(status) {
if (normalized === 'failed') {
return { status: normalized, itemClass: 'tool-call-failed', badgeClass: 'tool-status-failed', label: translate('timeline.execFailed', '执行失败'), icon: '❌ ' };
}
if (normalized === 'blocked') {
return { status: normalized, itemClass: 'tool-call-blocked', badgeClass: 'tool-status-blocked', label: translate('timeline.blocked', '已拦截'), icon: '🛡 ' };
}
if (normalized === 'cancelled' || normalized === 'canceled') {
return { status: 'cancelled', itemClass: 'tool-call-failed', badgeClass: 'tool-status-failed', label: translate('tasks.statusCancelled', '已取消'), icon: '⛔ ' };
}
@@ -6406,7 +6445,7 @@ function applyToolCallStatus(item, status) {
const titleElement = item.querySelector('.timeline-item-title');
if (!titleElement) return;
item.classList.remove('tool-call-running', 'tool-call-completed', 'tool-call-failed', 'tool-call-incomplete');
item.classList.remove('tool-call-running', 'tool-call-completed', 'tool-call-failed', 'tool-call-blocked', 'tool-call-incomplete');
const previousBadge = titleElement.querySelector('.tool-status-badge');
if (previousBadge) previousBadge.remove();
if (!presentation) {
@@ -6625,7 +6664,7 @@ function addTimelineItem(timeline, type, options) {
const mergedDisplayState = merged ? getToolResultDisplayState(merged) : null;
const mergedBackgroundRunning = mergedDisplayState && mergedDisplayState.kind === 'background_running';
const terminalStatus = String(options.toolStatus || '').toLowerCase();
const forcedStatus = (terminalStatus === 'completed' || terminalStatus === 'failed' || terminalStatus === 'cancelled' || terminalStatus === 'canceled')
const forcedStatus = mergedDisplayState && mergedDisplayState.kind === 'blocked' ? 'blocked' : (terminalStatus === 'completed' || terminalStatus === 'blocked' || terminalStatus === 'failed' || terminalStatus === 'cancelled' || terminalStatus === 'canceled')
? (terminalStatus === 'canceled' ? 'cancelled' : terminalStatus)
: '';
if (merged) {
@@ -6635,14 +6674,14 @@ function addTimelineItem(timeline, type, options) {
if (merged.executionId != null && String(merged.executionId).trim() !== '') {
item.dataset.toolExecutionId = String(merged.executionId).trim();
}
item.classList.add(item.dataset.toolDisplayStatus === 'background_running' ? 'tool-call-running' : (item.dataset.toolDisplayStatus === 'completed' ? 'tool-call-completed' : 'tool-call-failed'));
item.classList.add(getToolCallStatusPresentation(item.dataset.toolDisplayStatus).itemClass);
if (d._mergedResultDetailId) {
item.dataset.toolResultDetailId = String(d._mergedResultDetailId);
}
} else if (terminalStatus === 'completed' || terminalStatus === 'failed' || terminalStatus === 'cancelled' || terminalStatus === 'canceled') {
} else if (terminalStatus === 'completed' || terminalStatus === 'blocked' || terminalStatus === 'failed' || terminalStatus === 'cancelled' || terminalStatus === 'canceled') {
item.dataset.toolSuccess = terminalStatus === 'completed' ? '1' : '0';
item.dataset.toolDisplayStatus = terminalStatus === 'canceled' ? 'cancelled' : terminalStatus;
item.classList.add(terminalStatus === 'completed' ? 'tool-call-completed' : 'tool-call-failed');
item.classList.add(getToolCallStatusPresentation(terminalStatus).itemClass);
} else if (terminalStatus === 'result_missing') {
item.dataset.toolDisplayStatus = 'result_missing';
item.classList.add('tool-call-incomplete');
@@ -6745,16 +6784,16 @@ function addTimelineItem(timeline, type, options) {
const mergedDisplayState = merged ? getToolResultDisplayState(merged) : null;
const mergedBackgroundRunning = mergedDisplayState && mergedDisplayState.kind === 'background_running';
const terminalStatus = String(options.toolStatus || '').toLowerCase();
const forcedStatus = (terminalStatus === 'completed' || terminalStatus === 'failed' || terminalStatus === 'cancelled' || terminalStatus === 'canceled')
const forcedStatus = mergedDisplayState && mergedDisplayState.kind === 'blocked' ? 'blocked' : (terminalStatus === 'completed' || terminalStatus === 'blocked' || terminalStatus === 'failed' || terminalStatus === 'cancelled' || terminalStatus === 'canceled')
? (terminalStatus === 'canceled' ? 'cancelled' : terminalStatus)
: '';
const hasTerminalStatus = terminalStatus === 'completed' || terminalStatus === 'failed' || terminalStatus === 'cancelled' || terminalStatus === 'canceled';
const hasTerminalStatus = terminalStatus === 'completed' || terminalStatus === 'blocked' || terminalStatus === 'failed' || terminalStatus === 'cancelled' || terminalStatus === 'canceled';
const hasHistoricalStatus = hasTerminalStatus || terminalStatus === 'result_missing';
if (merged) {
const statusForClass = forcedStatus || toolDisplayStatusFromState(mergedDisplayState);
item.classList.add(statusForClass === 'background_running' ? 'tool-call-running' : (statusForClass === 'completed' ? 'tool-call-completed' : 'tool-call-failed'));
item.classList.add(getToolCallStatusPresentation(statusForClass).itemClass);
} else if (hasTerminalStatus) {
item.classList.add(terminalStatus === 'completed' ? 'tool-call-completed' : 'tool-call-failed');
item.classList.add(getToolCallStatusPresentation(terminalStatus).itemClass);
} else if (terminalStatus === 'result_missing') {
item.classList.add('tool-call-incomplete');
} else if (!options.skipPendingResult) {
@@ -6818,7 +6857,7 @@ function addTimelineItem(timeline, type, options) {
if (data.executionId != null && String(data.executionId).trim() !== '') {
item.dataset.toolExecutionId = String(data.executionId).trim();
}
item.classList.add(displayState.kind === 'background_running' ? 'tool-call-running' : (displayState.isError ? 'tool-call-failed' : 'tool-call-completed'));
item.classList.add(getToolCallStatusPresentation(toolDisplayStatusFromState(displayState)).itemClass);
} else if (type === 'cancelled') {
const taskCancelledLabel = typeof window.t === 'function' ? window.t('chat.taskCancelled') : '任务已取消';
content += `
@@ -7734,11 +7773,13 @@ function buildMonitorTotals(summary) {
const total = s.totalCalls || 0;
const success = s.successCalls || 0;
const failed = s.failedCalls || 0;
const blocked = s.blockedCalls || 0;
return {
total,
success,
failed,
neutral: Math.max(0, total - success - failed),
blocked,
neutral: Math.max(0, total - success - failed - blocked),
lastCallTime: s.lastCallTime ? new Date(s.lastCallTime) : null,
};
}
@@ -7767,6 +7808,7 @@ function buildMcpTimelineSvg(points, rangeKey) {
const plotH = H - padT - padB;
const maxVal = Math.max(1, ...points.map((p) => p.total || 0));
const hasFailed = points.some((p) => (p.failed || 0) > 0);
const hasBlocked = points.some((p) => (p.blocked || 0) > 0);
const locale = (typeof window.__locale === 'string' && window.__locale.startsWith('zh')) ? 'zh-CN' : 'en-US';
const barGap = points.length > 48 ? 1 : 2;
const barW = Math.max(1.6, Math.min(8, (plotW / Math.max(1, points.length)) - barGap));
@@ -7789,6 +7831,11 @@ function buildMcpTimelineSvg(points, rangeKey) {
}).join(' ');
}
const blockedPath = hasBlocked ? coords.map((c, i) => {
const y = padT + plotH - ((c.p.blocked || 0) / maxVal) * plotH;
return `${i === 0 ? 'M' : 'L'} ${c.x.toFixed(2)} ${y.toFixed(2)}`;
}).join(' ') : '';
let peakIdx = 0;
points.forEach((p, i) => {
if ((p.total || 0) >= (points[peakIdx].total || 0)) peakIdx = i;
@@ -7823,21 +7870,26 @@ function buildMcpTimelineSvg(points, rangeKey) {
return `<circle class="${dotClass}" cx="${c.x.toFixed(2)}" cy="${c.y.toFixed(2)}" r="${isPeak ? 2 : 1.5}"
data-time="${escapeAttrLocal(tipTime)}"
data-total="${c.p.total || 0}"
data-failed="${c.p.failed || 0}" />`;
data-failed="${c.p.failed || 0}"
data-blocked="${c.p.blocked || 0}" />`;
}).join('');
const bars = coords.map((c) => {
const total = c.p.total || 0;
const failed = c.p.failed || 0;
const blocked = c.p.blocked || 0;
const h = total > 0 ? Math.max(3, (total / maxVal) * plotH) : 1;
const y = baseY - h;
const failedH = failed > 0 ? Math.max(2, (failed / maxVal) * plotH) : 0;
const failedH = total > 0 ? h * (failed / total) : 0;
const blockedH = total > 0 ? h * (blocked / total) : 0;
const tipTime = formatMcpTimelineLabel(c.p.t, rangeKey, locale);
return `<g class="mcp-stats-timeline-bar-group">
<rect class="mcp-stats-timeline-bar${total > 0 ? ' is-active' : ''}" x="${(c.x - barW / 2).toFixed(2)}" y="${y.toFixed(2)}" width="${barW.toFixed(2)}" height="${h.toFixed(2)}" rx="1.6"
data-time="${escapeAttrLocal(tipTime)}" data-total="${total}" data-failed="${failed}" />
data-time="${escapeAttrLocal(tipTime)}" data-total="${total}" data-failed="${failed}" data-blocked="${blocked}" />
${failedH > 0 ? `<rect class="mcp-stats-timeline-bar-fail" x="${(c.x - barW / 2).toFixed(2)}" y="${(baseY - failedH).toFixed(2)}" width="${barW.toFixed(2)}" height="${failedH.toFixed(2)}" rx="1.6"
data-time="${escapeAttrLocal(tipTime)}" data-total="${total}" data-failed="${failed}" />` : ''}
data-time="${escapeAttrLocal(tipTime)}" data-total="${total}" data-failed="${failed}" data-blocked="${blocked}" />` : ''}
${blockedH > 0 ? `<rect class="mcp-stats-timeline-bar-blocked" x="${(c.x - barW / 2).toFixed(2)}" y="${(baseY - failedH - blockedH).toFixed(2)}" width="${barW.toFixed(2)}" height="${blockedH.toFixed(2)}" rx="1.6"
data-time="${escapeAttrLocal(tipTime)}" data-total="${total}" data-failed="${failed}" data-blocked="${blocked}" />` : ''}
</g>`;
}).join('');
@@ -7865,6 +7917,7 @@ function buildMcpTimelineSvg(points, rangeKey) {
${peakMarker}
<path class="mcp-stats-timeline-line" d="${linePath}" stroke="url(#mcpTimelineLineStroke)" />
${hasFailed ? `<path class="mcp-stats-timeline-line mcp-stats-timeline-line--fail" d="${failPath}" />` : ''}
${hasBlocked ? `<path class="mcp-stats-timeline-line mcp-stats-timeline-line--blocked" d="${blockedPath}" />` : ''}
${dots}
${xLabels}
</svg>`;
@@ -7893,22 +7946,23 @@ function bindMcpStatsTimelineEvents() {
}
root.addEventListener('mousemove', function (e) {
const dot = e.target.closest('.mcp-stats-timeline-dot, .mcp-stats-timeline-bar, .mcp-stats-timeline-bar-fail');
const dot = e.target.closest('.mcp-stats-timeline-dot, .mcp-stats-timeline-bar, .mcp-stats-timeline-bar-fail, .mcp-stats-timeline-bar-blocked');
if (!dot || !mcpTimelineTooltipEl) {
root.querySelectorAll('.mcp-stats-timeline-dot.is-active').forEach((d) => d.classList.remove('is-active'));
root.querySelectorAll('.mcp-stats-timeline-bar.is-hover, .mcp-stats-timeline-bar-fail.is-hover').forEach((d) => d.classList.remove('is-hover'));
root.querySelectorAll('.mcp-stats-timeline-bar.is-hover, .mcp-stats-timeline-bar-fail.is-hover, .mcp-stats-timeline-bar-blocked.is-hover').forEach((d) => d.classList.remove('is-hover'));
mcpTimelineTooltipEl.style.display = 'none';
return;
}
root.querySelectorAll('.mcp-stats-timeline-dot.is-active').forEach((d) => d.classList.remove('is-active'));
root.querySelectorAll('.mcp-stats-timeline-bar.is-hover, .mcp-stats-timeline-bar-fail.is-hover').forEach((d) => d.classList.remove('is-hover'));
root.querySelectorAll('.mcp-stats-timeline-bar.is-hover, .mcp-stats-timeline-bar-fail.is-hover, .mcp-stats-timeline-bar-blocked.is-hover').forEach((d) => d.classList.remove('is-hover'));
dot.classList.add('is-active');
dot.classList.add('is-hover');
const time = dot.getAttribute('data-time') || '';
const total = dot.getAttribute('data-total') || '0';
const failed = dot.getAttribute('data-failed') || '0';
const tip = mcpMonitorT('timelineTooltip', { time, total, failed })
|| `${time}${total} 次(失败 ${failed}`;
const blocked = dot.getAttribute('data-blocked') || '0';
const tip = mcpMonitorT('timelineTooltip', { time, total, failed, blocked })
|| monitorFallback(`${time}${total} 次(失败 ${failed},安全拦截 ${blocked}`, `${time}: ${total} calls (${failed} failed, ${blocked} blocked)`);
mcpTimelineTooltipEl.textContent = tip;
mcpTimelineTooltipEl.style.display = 'block';
mcpTimelineTooltipEl.style.left = `${e.clientX}px`;
@@ -7919,7 +7973,7 @@ function bindMcpStatsTimelineEvents() {
if (!e.target.closest || !e.target.closest('.mcp-stats-combined__timeline, .mcp-stats-timeline')) return;
if (e.relatedTarget && root.contains(e.relatedTarget)) return;
root.querySelectorAll('.mcp-stats-timeline-dot.is-active').forEach((d) => d.classList.remove('is-active'));
root.querySelectorAll('.mcp-stats-timeline-bar.is-hover, .mcp-stats-timeline-bar-fail.is-hover').forEach((d) => d.classList.remove('is-hover'));
root.querySelectorAll('.mcp-stats-timeline-bar.is-hover, .mcp-stats-timeline-bar-fail.is-hover, .mcp-stats-timeline-bar-blocked.is-hover').forEach((d) => d.classList.remove('is-hover'));
if (mcpTimelineTooltipEl) mcpTimelineTooltipEl.style.display = 'none';
});
@@ -7997,10 +8051,13 @@ function renderMcpTimelineActiveMoments(points, rangeKey) {
const time = formatMcpTimelineLabel(p.t, rangeKey, locale);
const failed = p.failed || 0;
const failedLabel = mcpMonitorT('failedCount', { n: failed }) || `失败 ${failed}`;
const blocked = p.blocked || 0;
const blockedLabel = mcpMonitorT('blockedCount', { n: blocked }) || monitorFallback(`安全拦截 ${blocked}`, `Blocked ${blocked}`);
return `<span class="mcp-stats-timeline-moment" title="${escapeHtml(time)}">
<span class="mcp-stats-timeline-moment__time">${escapeHtml(time)}</span>
<span class="mcp-stats-timeline-moment__count">${p.total || 0}</span>
${failed > 0 ? `<span class="mcp-stats-timeline-moment__fail">${escapeHtml(failedLabel)}</span>` : ''}
${blocked > 0 ? `<span class="mcp-stats-timeline-moment__blocked">${escapeHtml(blockedLabel)}</span>` : ''}
</span>`;
}).join('');
const moreChip = hiddenCount > 0
@@ -8080,7 +8137,9 @@ function renderMcpStatsTimelineBody(timeline, timelineError, compactEmpty, loadi
const chartSvg = buildMcpTimelineSvg(points, rangeKey);
const totalLegend = mcpMonitorT('timelineTotalLegend') || '总调用';
const failLegend = mcpMonitorT('timelineFailedLegend') || '失败';
const blockedLegend = mcpMonitorT('timelineBlockedLegend') || monitorFallback('安全拦截', 'Blocked');
const hasFailed = points.some((p) => (p.failed || 0) > 0);
const hasBlocked = points.some((p) => (p.blocked || 0) > 0);
const sparseHint = buildTimelineSparseHint(points, timeline);
const momentsHtml = renderMcpTimelineActiveMoments(points, rangeKey);
const sparseHtml = sparseHint
@@ -8095,6 +8154,7 @@ function renderMcpStatsTimelineBody(timeline, timelineError, compactEmpty, loadi
<div class="mcp-stats-timeline__legend">
<span class="mcp-stats-timeline__legend-item">${escapeHtml(totalLegend)}</span>
${hasFailed ? `<span class="mcp-stats-timeline__legend-item mcp-stats-timeline__legend-item--fail">${escapeHtml(failLegend)}</span>` : ''}
${hasBlocked ? `<span class="mcp-stats-timeline__legend-item mcp-stats-timeline__legend-item--blocked">${escapeHtml(blockedLegend)}</span>` : ''}
</div>`;
}
@@ -8635,8 +8695,13 @@ function renderMcpStatsMetricsBar(totals, successRate, rateTone, rateSubText, la
const lastCallLabel = mcpMonitorT('lastCall') || monitorFallback('最近一次调用', 'Last call');
const successPill = mcpMonitorT('successCount', { n: totals.success }) || monitorFallback(`成功 ${totals.success}`, `Success ${totals.success}`);
const failedPill = mcpMonitorT('failedCount', { n: totals.failed }) || monitorFallback(`失败 ${totals.failed}`, `Failed ${totals.failed}`);
const blockedPill = mcpMonitorT('blockedCount', { n: totals.blocked }) || monitorFallback(`安全拦截 ${totals.blocked}`, `Blocked ${totals.blocked}`);
const neutralPill = mcpMonitorT('neutralCount', { n: totals.neutral }) || monitorFallback(`终止 ${totals.neutral}`, `Stopped ${totals.neutral}`);
const rateHint = mcpMonitorT('rateExcludesBlocked') || monitorFallback('成功率仅统计成功和失败的调用,不包含安全拦截和终止', 'Success rate includes only successful and failed calls; blocked and stopped calls are excluded');
const rateValue = hasCalls ? `${successRate}%` : successRate;
const blockedChip = totals.blocked > 0
? `<span class="mcp-stats-kpi__chip is-blocked">${escapeHtml(blockedPill)}</span>`
: '';
const neutralChip = totals.neutral > 0
? `<span class="mcp-stats-kpi__chip is-neutral">${escapeHtml(neutralPill)}</span>`
: '';
@@ -8651,6 +8716,7 @@ function renderMcpStatsMetricsBar(totals, successRate, rateTone, rateSubText, la
<div class="mcp-stats-kpi__meta">
<span class="mcp-stats-kpi__chip is-ok">${escapeHtml(successPill)}</span>
<span class="mcp-stats-kpi__chip is-fail">${escapeHtml(failedPill)}</span>
${blockedChip}
${neutralChip}
</div>
</div>
@@ -8658,7 +8724,7 @@ function renderMcpStatsMetricsBar(totals, successRate, rateTone, rateSubText, la
<article class="mcp-stats-kpi__item mcp-stats-kpi__item--rate">
<span class="mcp-stats-kpi__accent" aria-hidden="true"></span>
<div class="mcp-stats-kpi__content">
<span class="mcp-stats-kpi__label">${escapeHtml(successRateLabel)}</span>
<span class="mcp-stats-kpi__label" title="${escapeAttrLocal(rateHint)}">${escapeHtml(successRateLabel)}</span>
<span class="mcp-stats-kpi__value mcp-stats-kpi__value--rate ${rateTone}">${rateValue}</span>
<span class="mcp-stats-kpi__status ${rateTone}">${escapeHtml(rateSubText)}</span>
</div>
@@ -8687,16 +8753,21 @@ function renderMcpStatsToolTable(topTools, totals, activeToolFilter = '') {
const total = tool.totalCalls || 0;
const success = tool.successCalls || 0;
const failed = tool.failedCalls || 0;
const blocked = tool.blockedCalls || 0;
const effectiveTotal = success + failed;
const toolRateNum = effectiveTotal > 0 ? (success / effectiveTotal) * 100 : 0;
const toolRate = toolRateNum.toFixed(1);
const rateText = effectiveTotal > 0 ? `${toolRate}%` : '-';
const sharePct = totals.total > 0 ? ((total / totals.total) * 100).toFixed(1) : '0.0';
const dotColor = MCP_STATS_DIST_COLORS[index % MCP_STATS_DIST_COLORS.length];
const isActive = activeToolFilter && monitorToolNamesEqual(activeToolFilter, rawName);
const rateClass = getMcpToolRateClass(toolRateNum);
const rateClass = effectiveTotal > 0 ? getMcpToolRateClass(toolRateNum) : 'is-muted';
const rankClass = index === 0 ? ' rank-1' : index === 1 ? ' rank-2' : index === 2 ? ' rank-3' : '';
const rowAria = mcpMonitorT('toolRowAriaLabel', { name, total, rate: toolRate })
|| `${name}${total} 次调用,成功率 ${toolRate}%`;
const blockedLabel = mcpMonitorT('blockedCount', { n: blocked }) || monitorFallback(`安全拦截 ${blocked}`, `Blocked ${blocked}`);
const rowAria = (effectiveTotal > 0
? (mcpMonitorT('toolRowAriaLabel', { name, total, rate: toolRate }) || `${name}${total} 次调用,成功率 ${toolRate}%`)
: (mcpMonitorT('toolRowNoCompletedAriaLabel', { name, total }) || monitorFallback(`${name}${total} 次调用,暂无完成结果,点击查看执行记录`, `${name}, ${total} calls, no completed outcomes, click to view records`)))
+ (blocked > 0 ? ` · ${blockedLabel}` : '');
rowsHtml += `
<tr class="mcp-stats-tool-row${isActive ? ' is-active' : ''}"
data-tool-name="${escapeAttrLocal(rawName)}"
@@ -8712,8 +8783,9 @@ function renderMcpStatsToolTable(topTools, totals, activeToolFilter = '') {
<td class="col-num">${total}</td>
<td class="col-share">${sharePct}%</td>
<td class="col-rate">
<span class="mcp-stats-rate ${rateClass}">${toolRate}%</span>
<span class="mcp-stats-rate ${rateClass}">${rateText}</span>
${failed > 0 ? `<span class="mcp-stats-fail-note">${escapeHtml(mcpMonitorT('failedCount', { n: failed }) || `失败 ${failed}`)}</span>` : ''}
${blocked > 0 ? `<span class="mcp-stats-blocked-note">${escapeHtml(blockedLabel)}</span>` : ''}
</td>
</tr>`;
});
@@ -8766,17 +8838,22 @@ function renderMcpStatsToolsPanel(topTools, totals, activeToolFilter = '') {
const total = tool.totalCalls || 0;
const success = tool.successCalls || 0;
const failed = tool.failedCalls || 0;
const blocked = tool.blockedCalls || 0;
const effectiveTotal = success + failed;
const toolRateNum = effectiveTotal > 0 ? (success / effectiveTotal) * 100 : 0;
const toolRate = toolRateNum.toFixed(1);
const rateText = effectiveTotal > 0 ? `${toolRate}%` : '-';
const sharePct = totals.total > 0 ? ((total / totals.total) * 100).toFixed(1) : '0.0';
const color = MCP_STATS_DIST_COLORS[index % MCP_STATS_DIST_COLORS.length];
const barPct = maxCalls > 0 ? ((total / maxCalls) * 100).toFixed(1) : '0';
const isActive = activeToolFilter && monitorToolNamesEqual(activeToolFilter, rawName);
const rateClass = getMcpToolRateClass(toolRateNum);
const rateClass = effectiveTotal > 0 ? getMcpToolRateClass(toolRateNum) : 'is-muted';
const rankClass = index === 0 ? ' rank-1' : index === 1 ? ' rank-2' : index === 2 ? ' rank-3' : '';
const rowAria = mcpMonitorT('toolRowAriaLabel', { name, total, rate: toolRate })
|| `${name}${total} 次,成功率 ${toolRate}%`;
const blockedLabel = mcpMonitorT('blockedCount', { n: blocked }) || monitorFallback(`安全拦截 ${blocked}`, `Blocked ${blocked}`);
const rowAria = (effectiveTotal > 0
? (mcpMonitorT('toolRowAriaLabel', { name, total, rate: toolRate }) || `${name}${total} 次,成功率 ${toolRate}%`)
: (mcpMonitorT('toolRowNoCompletedAriaLabel', { name, total }) || monitorFallback(`${name}${total} 次调用,暂无完成结果,点击查看执行记录`, `${name}, ${total} calls, no completed outcomes, click to view records`)))
+ (blocked > 0 ? ` · ${blockedLabel}` : '');
const failNote = failed > 0
? `<span class="mcp-stats-tool-item__fail">${escapeHtml(mcpMonitorT('failedCount', { n: failed }) || `失败 ${failed}`)}</span>`
: '';
@@ -8801,7 +8878,8 @@ function renderMcpStatsToolsPanel(topTools, totals, activeToolFilter = '') {
<div class="mcp-stats-tool-item__bottom">
<span class="mcp-stats-tool-item__pill is-success">${escapeHtml(successLabel)}</span>
<span class="mcp-stats-tool-item__pill${failed > 0 ? ' is-danger' : ''}">${escapeHtml(failedLabel)}</span>
<span class="mcp-stats-tool-item__rate ${rateClass}">${toolRate}%${failNote}</span>
${blocked > 0 ? `<span class="mcp-stats-tool-item__pill is-blocked">${escapeHtml(blockedLabel)}</span>` : ''}
<span class="mcp-stats-tool-item__rate ${rateClass}">${rateText}${failNote}</span>
</div>
</li>`;
}).join('');
@@ -8995,6 +9073,7 @@ function renderMonitorExecutions(executions = [], statusFilter = 'all') {
running: 'statusRunning',
completed: 'statusCompleted',
failed: 'statusFailed',
blocked: 'statusBlocked',
cancelled: 'statusCancelled',
hard_timeout: 'statusHardTimeout',
orphaned: 'statusOrphaned'
@@ -9002,7 +9081,7 @@ function renderMonitorExecutions(executions = [], statusFilter = 'all') {
const locale = (typeof window.__locale === 'string' && window.__locale.startsWith('zh')) ? 'zh-CN' : undefined;
const rowEntries = executions
.map(exec => {
const status = (exec.status || 'unknown').toLowerCase();
const status = getToolExecutionDisplayStatus(exec);
const statusClass = `monitor-status-chip ${status}`;
const statusKey = statusKeyMap[status];
const statusLabel = (typeof window.t === 'function' && statusKey) ? window.t('mcpMonitor.' + statusKey) : getStatusText(status);
@@ -9542,8 +9621,8 @@ function refreshProgressAndTimelineI18n() {
const displayStatus = item.dataset.toolDisplayStatus || '';
const backgroundRunning = displayStatus === 'background_running';
const success = item.dataset.toolSuccess === '1';
const icon = backgroundRunning ? '\u23F3 ' : (success ? '\u2705 ' : '\u274C ');
titleSpan.textContent = ap + icon + (backgroundRunning ? (getBackgroundRunningToolLabel() + ': ' + name) : (success ? _t('chat.toolExecComplete', { name: name }) : _t('chat.toolExecFailed', { name: name })));
const icon = displayStatus === 'blocked' ? '🛡 ' : (backgroundRunning ? '\u23F3 ' : (success ? '\u2705 ' : '\u274C '));
titleSpan.textContent = ap + icon + (displayStatus === 'blocked' ? _t('chat.toolExecBlocked', { name: name }) : backgroundRunning ? (getBackgroundRunningToolLabel() + ': ' + name) : (success ? _t('chat.toolExecComplete', { name: name }) : _t('chat.toolExecFailed', { name: name })));
} else if (type === 'eino_agent_reply') {
titleSpan.textContent = ap + '\uD83D\uDCAC ' + _t('chat.einoAgentReplyTitle');
} else if (type === 'eino_usage_summary') {
+4
View File
@@ -87,6 +87,10 @@
deleteRetrievalLog: 'knowledge:delete',
// 设置 / MCP
saveToolGuardConfig: 'config:write',
addToolGuardRule: 'config:write',
resetToolGuardConfig: 'config:write',
changeToolGuardEnabled: 'config:write',
applySettings: 'config:write',
saveToolsConfig: 'config:write',
saveExternalMCP: 'mcp:write',
+15 -3
View File
@@ -110,7 +110,7 @@ function initRouter() {
const hashParts = hash.split('?');
let pageId = hashParts[0];
if (pageId === 'c2') pageId = 'c2-listeners';
if (pageId && ['dashboard', 'chat', 'hitl', 'asset-overview', 'asset-library', 'info-collect', 'projects', 'vulnerabilities', 'webshell', 'chat-files', 'mcp-monitor', 'mcp-management', 'knowledge-management', 'knowledge-retrieval-logs', 'roles-management', 'platform-rbac', 'workflows', 'skills-monitor', 'skills-management', 'agents-management', 'settings', 'tasks', 'c2-listeners', 'c2-sessions', 'c2-tasks', 'c2-payloads', 'c2-events', 'c2-profiles'].includes(pageId)) {
if (pageId && ['dashboard', 'chat', 'hitl', 'tool-guard', 'asset-overview', 'asset-library', 'info-collect', 'projects', 'vulnerabilities', 'webshell', 'chat-files', 'mcp-monitor', 'mcp-management', 'knowledge-management', 'knowledge-retrieval-logs', 'roles-management', 'platform-rbac', 'workflows', 'skills-monitor', 'skills-management', 'agents-management', 'settings', 'tasks', 'c2-listeners', 'c2-sessions', 'c2-tasks', 'c2-payloads', 'c2-events', 'c2-profiles'].includes(pageId)) {
switchPage(pageId);
if (pageId === 'chat') {
scheduleChatConversationFromHash(0);
@@ -186,7 +186,15 @@ function updateNavState(pageId) {
});
// 设置活动状态
if (pageId === 'asset-overview' || pageId === 'asset-library' || pageId === 'info-collect') {
if (pageId === 'hitl' || pageId === 'tool-guard') {
const securityItem = document.querySelector('.nav-item[data-page="security"]');
if (securityItem) {
securityItem.classList.add('active');
securityItem.classList.add('expanded');
}
const submenuItem = document.querySelector(`.nav-submenu-item[data-page="${pageId}"]`);
if (submenuItem) submenuItem.classList.add('active');
} else if (pageId === 'asset-overview' || pageId === 'asset-library' || pageId === 'info-collect') {
const assetItem = document.querySelector('.nav-item[data-page="assets"]');
if (assetItem) {
assetItem.classList.add('active');
@@ -348,6 +356,7 @@ function showSubmenuPopup(navItem, menuId) {
// 复制子菜单项到弹出菜单
const submenuItems = submenu.querySelectorAll('.nav-submenu-item');
submenuItems.forEach(item => {
if (item.hidden || (typeof permissionAllowedForElement === 'function' && !permissionAllowedForElement(item))) return;
const popupItem = document.createElement('div');
popupItem.className = 'submenu-popup-item';
popupItem.textContent = item.textContent.trim();
@@ -414,6 +423,9 @@ async function initPage(pageId) {
refreshChatProjectSelector();
}
break;
case 'tool-guard':
if (typeof loadToolGuardConfig === 'function') loadToolGuardConfig();
break;
case 'hitl':
if (typeof refreshHitlActivePanel === 'function') {
refreshHitlActivePanel();
@@ -609,7 +621,7 @@ document.addEventListener('DOMContentLoaded', function() {
let pageId = hashParts[0];
if (pageId === 'c2') pageId = 'c2-listeners';
if (pageId && ['dashboard', 'chat', 'hitl', 'asset-overview', 'asset-library', 'info-collect', 'projects', 'tasks', 'workflows', 'vulnerabilities', 'webshell', 'chat-files', 'mcp-monitor', 'mcp-management', 'knowledge-management', 'knowledge-retrieval-logs', 'roles-management', 'platform-rbac', 'skills-monitor', 'skills-management', 'agents-management', 'settings', 'c2-listeners', 'c2-sessions', 'c2-tasks', 'c2-payloads', 'c2-events', 'c2-profiles'].includes(pageId)) {
if (pageId && ['dashboard', 'chat', 'hitl', 'tool-guard', 'asset-overview', 'asset-library', 'info-collect', 'projects', 'tasks', 'workflows', 'vulnerabilities', 'webshell', 'chat-files', 'mcp-monitor', 'mcp-management', 'knowledge-management', 'knowledge-retrieval-logs', 'roles-management', 'platform-rbac', 'skills-monitor', 'skills-management', 'agents-management', 'settings', 'c2-listeners', 'c2-sessions', 'c2-tasks', 'c2-payloads', 'c2-events', 'c2-profiles'].includes(pageId)) {
switchPage(pageId);
if (pageId === 'chat') {
scheduleChatConversationFromHash(0);
+139
View File
@@ -0,0 +1,139 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const vm = require('node:vm');
const monitor = fs.readFileSync('web/static/js/monitor.js', 'utf8');
const chat = fs.readFileSync('web/static/js/chat.js', 'utf8');
const reason = '工具调用已被安全规则拦截:识别到 example.gov,禁止访问。\n规则: 政府网站保护';
function sourceFunction(source, name) {
const start = source.indexOf(`function ${name}(`);
assert.notEqual(start, -1, name);
const rest = source.slice(start);
const next = rest.slice(1).search(/\n(?:async )?function /);
return (next === -1 ? rest : rest.slice(0, next + 1)).split(/\nwindow\.|\nconst toolCallDetailStateByItemId/)[0];
}
class Element {
constructor() {
this.dataset = {};
this.children = [];
this.className = '';
this.classList = {
contains: (value) => this.className.split(' ').includes(value),
add: (...values) => { this.className = [...new Set([...this.className.split(' ').filter(Boolean), ...values])].join(' '); },
remove: (...values) => { this.className = this.className.split(' ').filter((value) => !values.includes(value)).join(' '); }
};
}
set innerHTML(value) {
this.html = value;
this.title = new Element();
this.title.className = 'timeline-item-title';
}
get innerHTML() { return this.html; }
appendChild(child) { child.parent = this; this.children.push(child); }
remove() { if (this.parent) this.parent.children = this.parent.children.filter((child) => child !== this); }
querySelector(selector) {
if (selector === '.timeline-item-title') return this.title || null;
if (selector === '.tool-status-badge') return this.children.find((child) => child.classList.contains('tool-status-badge')) || null;
return null;
}
}
function runtime() {
const ctx = {
window: {}, document: { createElement: () => new Element() },
toolCallDetailStateByItemId: new Map(),
updateToolDetailToggleLabel() {}, applyEinoTimelineRole() {}, pruneLiveTimelineIfNeeded() {},
getCurrentTimeLocale: () => 'en-US', getTimeFormatOptions: () => ({}),
escapeHtml: (value) => String(value).replaceAll('&', '&amp;').replaceAll('<', '&lt;'),
};
const funcs = ['collectToolResultTextParts', 'isToolGuardBlockedResult', 'getToolExecutionDisplayStatus',
'getToolResultDisplayState', 'toolDisplayStatusFromState', 'getBackgroundRunningToolLabel',
'getToolCallStatusPresentation', 'applyToolCallStatus', 'parseToolCallArgsFromData', 'toolCallArgsEmpty',
'setToolCallDetailState', 'mergeToolResultIntoCallItem', 'coalesceProcessDetailsToolPairs',
'buildToolResultSectionHtml', 'addTimelineItem'];
vm.createContext(ctx);
vm.runInContext(funcs.map((name) => sourceFunction(monitor, name)).join('\n'), ctx);
ctx.window.getToolExecutionDisplayStatus = ctx.getToolExecutionDisplayStatus;
vm.runInContext(['normalizeToolExecutionSummary', 'getToolExecutionStatusLabel', 'formatMCPResultJsonForDisplay'].map((name) => sourceFunction(chat, name)).join('\n'), ctx);
return ctx;
}
test('structured block markers have priority over generic failure and running states', () => {
const ctx = runtime();
for (const payload of [
{ blocked: true, success: false, isError: true },
{ status: 'blocked', isError: true },
{ success: false, result: { blocked: true, isError: true, content: [] } },
{ success: false, result: JSON.stringify({ _meta: { 'cyberstrike.ai/blocked': true }, isError: true, content: [] }) },
{ blocked: true, displayStatus: 'background_running', success: true },
]) {
const state = ctx.getToolResultDisplayState(payload);
assert.equal(state.kind, 'blocked');
assert.equal(state.success, false);
assert.equal(ctx.toolDisplayStatusFromState(state), 'blocked');
}
});
test('legacy guard failures are recognized in raw, nested, serialized and deferred history results', () => {
const ctx = runtime();
for (const result of [reason, { isError: true, content: [{ type: 'text', text: reason }] }, JSON.stringify({ isError: true, content: [{ type: 'text', text: reason }] })]) {
assert.equal(ctx.getToolResultDisplayState({ result, success: false }).kind, 'blocked');
}
assert.equal(ctx.getToolResultDisplayState({ resultPreview: reason, success: false, _payloadDeferred: true }).kind, 'blocked');
assert.equal(ctx.getToolResultDisplayState({ success: false }, { rawText: reason }).kind, 'blocked');
});
test('quoted mentions, prefix lookalikes and explicitly successful output stay ordinary results', () => {
const ctx = runtime();
for (const result of ['示例:' + reason, '"' + reason + '"', '工具调用已被安全规则拦截说明文档']) {
assert.equal(ctx.getToolResultDisplayState({ result, success: false }).kind, 'error');
}
for (const data of [{ success: true, result: reason }, { isError: false, content: [{ text: reason }] }, { status: 'completed', result: reason }]) {
assert.equal(ctx.getToolResultDisplayState(data, { rawText: reason }).kind, 'success');
}
assert.equal(ctx.getToolResultDisplayState({ success: false, result: 'connection refused' }).kind, 'error');
});
test('live merge replaces a red failure badge with the distinct block badge and keeps the reason', () => {
const ctx = runtime();
const timeline = new Element();
ctx.addTimelineItem(timeline, 'tool_call', { title: 'http_request', data: { toolName: 'http_request' }, toolStatus: 'failed' });
const item = timeline.children[0];
assert.equal(item.classList.contains('tool-call-failed'), true);
ctx.mergeToolResultIntoCallItem(item, { blocked: true, success: false, result: reason });
assert.equal(item.dataset.toolDisplayStatus, 'blocked');
assert.equal(item.dataset.toolSuccess, '0');
assert.equal(item.classList.contains('tool-call-blocked'), true);
assert.equal(item.classList.contains('tool-call-failed'), false);
assert.equal(item.title.children.length, 1);
assert.match(item.title.children[0].textContent, /已拦截/);
assert.equal(ctx.toolCallDetailStateByItemId.get(item.id).rawText, reason);
});
test('refresh coalescing preserves blocks even when the old execution summary says failed', () => {
const ctx = runtime();
const details = ctx.coalesceProcessDetailsToolPairs([
{ id: 'call', eventType: 'tool_call', data: { toolCallId: 'id', toolName: 'http_request' } },
{ id: 'result', eventType: 'tool_result', data: { toolCallId: 'id', success: false, result: reason } }
]);
assert.equal(details.length, 1);
const timeline = new Element();
ctx.addTimelineItem(timeline, 'tool_call', { data: details[0].data, toolStatus: 'failed' });
const item = timeline.children[0];
assert.equal(item.dataset.toolDisplayStatus, 'blocked');
assert.match(item.title.children[0].className, /tool-status-blocked/);
assert.equal(item.classList.contains('tool-call-failed'), false);
const resultData = ctx.toolCallDetailStateByItemId.get(item.id).resultData;
assert.match(ctx.buildToolResultSectionHtml(resultData), /tool-result-section blocked/);
assert.doesNotMatch(ctx.buildToolResultSectionHtml(resultData), /tool-result-section error/);
});
test('execution summary buttons and raw detail preserve the separate blocked status', () => {
const ctx = runtime();
assert.equal(ctx.normalizeToolExecutionSummary({ toolName: 'http_request', status: 'blocked' }).status, 'blocked');
assert.equal(ctx.normalizeToolExecutionSummary({ toolName: 'http_request', status: 'failed', error: reason }).status, 'blocked');
assert.equal(ctx.getToolExecutionStatusLabel('blocked'), '已拦截');
assert.equal(JSON.parse(ctx.formatMCPResultJsonForDisplay({ blocked: true, isError: true, content: [] })).blocked, true);
});
+732
View File
@@ -0,0 +1,732 @@
(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('');
});
})();
+832
View File
@@ -0,0 +1,832 @@
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);
});
+13 -9
View File
@@ -2240,8 +2240,10 @@ function buildWebshellTimelineItemFromDetail(detail) {
: { kind: ((data.isError || data.success === false) ? 'error' : 'success'), isError: (data.isError || data.success === false) };
var wsBackgroundRunning = wsDisplayState.kind === 'background_running';
var success = !wsDisplayState.isError && !wsBackgroundRunning;
var wsIcon = wsBackgroundRunning ? '⏳ ' : (success ? '✅ ' : '❌ ');
var wsLabel = wsBackgroundRunning
var wsIcon = wsDisplayState.kind === 'blocked' ? '🛡 ' : (wsBackgroundRunning ? '⏳ ' : (success ? '✅ ' : '❌ '));
var wsLabel = wsDisplayState.kind === 'blocked'
? ((typeof window.t === 'function') ? window.t('chat.toolExecBlocked', { name: tname }) : tname + ' 已拦截')
: wsBackgroundRunning
? (((typeof window.getBackgroundRunningToolLabel === 'function') ? window.getBackgroundRunningToolLabel() : '后台执行中') + ': ' + tname)
: ((typeof window.t === 'function') ? (success ? window.t('chat.toolExecComplete', { name: tname }) : window.t('chat.toolExecFailed', { name: tname })) : (tname + (success ? ' 执行完成' : ' 执行失败')));
title = ap + wsIcon + wsLabel;
@@ -2286,7 +2288,7 @@ function buildWebshellTimelineItemFromDetail(detail) {
: { kind: ((data.isError || data.success === false) ? 'error' : 'success'), isError: (data.isError || data.success === false) };
var execResultLabel = (typeof window.t === 'function') ? window.t('timeline.executionResult') : '执行结果:';
var execIdLabel = (typeof window.t === 'function') ? window.t('timeline.executionId') : '执行ID:';
var sectionClass = displayState.kind === 'background_running' ? 'pending' : (displayState.isError ? 'error' : 'success');
var sectionClass = displayState.kind === 'blocked' ? 'blocked' : (displayState.kind === 'background_running' ? 'pending' : (displayState.isError ? 'error' : 'success'));
html += '<div class="webshell-ai-timeline-msg"><div class="tool-result-section ' + sectionClass + '"><strong>' + escapeHtml(execResultLabel) + '</strong><pre class="tool-result">' + escapeHtml(resultStr) + '</pre>' + (data.executionId ? '<div class="tool-execution-id"><span>' + escapeHtml(execIdLabel) + '</span> <code>' + escapeHtml(String(data.executionId)) + '</code></div>' : '') + '</div></div>';
} else if (eventType !== 'eino_usage_summary' && detail.message && detail.message !== title) {
html += '<div class="webshell-ai-timeline-msg">' + escapeHtml(detail.message) + '</div>';
@@ -3454,7 +3456,7 @@ function runWebshellAiSend(conn, inputEl, sendBtn, messagesContainer) {
: { kind: ((data.isError || data.success === false) ? 'error' : 'success'), isError: (data.isError || data.success === false) };
var execResultLabel = (typeof window.t === 'function') ? window.t('timeline.executionResult') : '执行结果:';
var execIdLabel = (typeof window.t === 'function') ? window.t('timeline.executionId') : '执行ID:';
var sectionClass = displayState.kind === 'background_running' ? 'pending' : (displayState.isError ? 'error' : 'success');
var sectionClass = displayState.kind === 'blocked' ? 'blocked' : (displayState.kind === 'background_running' ? 'pending' : (displayState.isError ? 'error' : 'success'));
html += '<div class="webshell-ai-timeline-msg"><div class="tool-result-section ' +
sectionClass +
'"><strong>' + escapeHtml(execResultLabel) + '</strong><pre class="tool-result">' +
@@ -3758,7 +3760,9 @@ function runWebshellAiSend(conn, inputEl, sendBtn, messagesContainer) {
// ─── Tool result (final) ───
} else if (_et === 'tool_result' && _ed) {
var success = _ed.success !== false;
var wsLiveState = typeof window.getToolResultDisplayState === 'function' ? window.getToolResultDisplayState(_ed) : { success: _ed.success !== false };
var blocked = wsLiveState.kind === 'blocked';
var success = wsLiveState.success;
var tname = _ed.toolName || '工具';
var merged = false;
if (_ed.toolCallId) {
@@ -3772,12 +3776,12 @@ function runWebshellAiSend(conn, inputEl, sendBtn, messagesContainer) {
}
}
if (!merged) {
var titleText = wsTOr(success ? 'chat.toolExecComplete' : 'chat.toolExecFailed', '') ||
(tname + (success ? ' 执行完成' : ' 执行失败'));
var titleText = wsTOr(blocked ? 'chat.toolExecBlocked' : (success ? 'chat.toolExecComplete' : 'chat.toolExecFailed'), '') ||
(tname + (blocked ? ' 已拦截' : (success ? ' 执行完成' : ' 执行失败')));
if (typeof window.t === 'function') {
try { titleText = window.t(success ? 'chat.toolExecComplete' : 'chat.toolExecFailed', { name: tname }); } catch (e) { /* */ }
try { titleText = window.t(blocked ? 'chat.toolExecBlocked' : (success ? 'chat.toolExecComplete' : 'chat.toolExecFailed'), { name: tname }); } catch (e) { /* */ }
}
var title = webshellAgentPx(_ed) + (success ? '✅ ' : '❌ ') + titleText;
var title = webshellAgentPx(_ed) + (blocked ? '🛡 ' : (success ? '✅ ' : '❌ ')) + titleText;
var sub = _em || (_ed.result ? String(_ed.result).slice(0, 300) : '');
appendTimelineItem('tool_result', title, sub, _ed);
}