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
+41
View File
@@ -46405,3 +46405,44 @@ html[data-theme="dark"] .hitl-inline-approval.hitl-inline-approval--merged {
animation: none;
}
}
/* A policy block is an intentional refusal before execution, separate from a tool failure. */
:root { --tool-blocked-color: #a65d08; }
html[data-theme="dark"] { --tool-blocked-color: #f4bc62; }
.tool-status-badge.tool-status-blocked,
.status-chip.status-blocked,
.monitor-status-chip.blocked,
.mcp-stats-kpi__chip.is-blocked,
.mcp-stats-tool-item__pill.is-blocked {
color: var(--tool-blocked-color);
background: rgba(217, 146, 36, .12);
border: 1px solid rgba(217, 146, 36, .32);
}
.timeline-item-tool_call.tool-call-blocked,
.timeline-item-tool_result.tool-call-blocked {
border: 1px solid rgba(217, 146, 36, .32);
border-left: 3px solid var(--tool-blocked-color);
background: rgba(217, 146, 36, .065);
}
.tool-result-section.blocked .tool-result,
#mcp-detail-modal .code-block.blocked,
.mcp-detail-btn[data-status="blocked"] {
color: var(--text-primary);
border-color: rgba(217, 146, 36, .32);
background: rgba(217, 146, 36, .055);
}
.message.assistant-turn-with-process .timeline-item.tool-call-blocked::before,
.message.progress-message .timeline-item.tool-call-blocked::before {
background: var(--tool-blocked-color);
}
.mcp-stats-blocked-note,
.mcp-stats-timeline-moment__blocked {
color: var(--tool-blocked-color);
}
.mcp-stats-blocked-note { display: inline-block; margin-left: 8px; font-size: .75rem; }
.mcp-stats-timeline-bar-blocked { fill: #d99224; opacity: .8; }
.mcp-stats-timeline-bar-blocked.is-hover { opacity: 1; }
.mcp-stats-timeline-line--blocked { fill: none; stroke: #d99224; stroke-width: 1.5; }
.mcp-stats-timeline__legend-item--blocked::before { background: #d99224; }
.mcp-stats-rate.is-muted,
.mcp-stats-tool-item__rate.is-muted { color: var(--text-muted); }
+279
View File
@@ -0,0 +1,279 @@
.tool-guard-content {
display: flex;
flex-direction: column;
gap: 16px;
max-width: 1200px;
width: 100%;
margin: 0 auto;
}
.tool-guard-page [hidden] { display: none !important; }
.tool-guard-card {
background: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: 12px;
padding: 20px;
min-width: 0;
}
.tool-guard-policy,
.tool-guard-policy-info,
.tool-guard-section-header,
.tool-guard-heading-line,
.tool-guard-rule-header,
.tool-guard-rule-actions,
.tool-guard-editor-footer {
display: flex;
align-items: center;
gap: 12px;
}
.tool-guard-policy,
.tool-guard-section-header,
.tool-guard-editor-footer { justify-content: space-between; }
.tool-guard-policy { padding: 18px 20px; }
.tool-guard-policy-info { min-width: 0; }
.tool-guard-policy-copy h3,
.tool-guard-section-header h3 { font-size: 15px; margin: 0; }
.tool-guard-policy-copy p,
.tool-guard-section-header p,
.tool-guard-help-body,
.tool-guard-test-hint,
.tool-guard-empty {
color: var(--text-secondary);
font-size: 12px;
line-height: 1.7;
}
.tool-guard-policy-copy p { margin: 3px 0 0; }
.tool-guard-policy-icon,
.tool-guard-test-icon {
display: grid;
place-items: center;
width: 38px;
height: 38px;
border-radius: 10px;
flex-shrink: 0;
color: var(--accent-color);
background: color-mix(in srgb, var(--accent-color) 8%, transparent);
}
.tool-guard-policy-icon svg,
.tool-guard-test-icon svg { width: 21px; height: 21px; }
.tool-guard-policy:has(#tool-guard-enabled:not(:checked)) .tool-guard-policy-icon { color: var(--text-secondary); background: var(--bg-secondary); }
.tool-guard-section-header { margin-bottom: 16px; }
.tool-guard-section-header p { margin: 4px 0 0; }
.tool-guard-heading-line { gap: 10px; flex-wrap: wrap; }
.tool-guard-rule-count { font-size: 12px; color: var(--text-secondary); font-weight: 400; }
.tool-guard-section-header > button { flex-shrink: 0; }
.tool-guard-add { display: inline-flex; align-items: center; gap: 5px; }
.tool-guard-add > span:first-child { font-size: 19px; line-height: 1; }
.tool-guard-toggle { display: inline-flex; align-items: center; gap: 8px; font-size: 12px; cursor: pointer; flex-shrink: 0; white-space: nowrap; }
.tool-guard-page .tool-guard-switch input[type="checkbox"] {
appearance: none;
-webkit-appearance: none;
width: 30px;
height: 18px;
min-width: 30px;
border: 1px solid var(--border-color);
border-radius: 12px;
background: var(--text-secondary);
position: relative;
cursor: pointer;
margin: 0;
transition: background .15s;
}
.tool-guard-page .tool-guard-switch input[type="checkbox"]::after {
content: '';
position: absolute;
width: 12px;
height: 12px;
top: 2px;
left: 2px;
border-radius: 50%;
background: #fff;
transition: transform .15s;
}
.tool-guard-page .tool-guard-switch input[type="checkbox"]:checked { background: var(--accent-color); border-color: var(--accent-color); }
.tool-guard-page .tool-guard-switch input[type="checkbox"]:checked::after { transform: translateX(12px); }
.tool-guard-page .tool-guard-switch input[type="checkbox"]::before { display: none; }
.tool-guard-rules { display: grid; gap: 8px; }
.tool-guard-rule { border-radius: 9px; border: 1px solid var(--border-color); min-width: 0; overflow: hidden; }
.tool-guard-rule:has(.tool-guard-rule-summary[aria-expanded="true"]) { border-color: color-mix(in srgb, var(--accent-color) 40%, var(--border-color)); }
.tool-guard-rule-header { gap: 8px; padding-right: 16px; min-width: 0; border-radius: 8px; transition: background-color .15s; }
.tool-guard-rule-header:hover,
.tool-guard-rule-header:has(> .tool-guard-rule-summary:focus-visible) { background: color-mix(in srgb, var(--accent-color) 5%, var(--card-bg)); }
.tool-guard-rule-header:has(> .tool-guard-rule-summary:focus-visible) { box-shadow: inset 0 0 0 2px var(--accent-color); }
.tool-guard-rule.is-expanded .tool-guard-rule-header { border-radius: 8px 8px 0 0; }
.tool-guard-rule-summary {
display: flex;
align-items: center;
gap: 12px;
min-width: 0;
flex: 1;
width: 100%;
border: 0;
background: transparent;
color: var(--text-primary);
padding: 14px 10px 14px 16px;
text-align: left;
cursor: pointer;
font: inherit;
}
.tool-guard-rule-number { flex-shrink: 0; color: var(--text-secondary); font-size: 12px; font-variant-numeric: tabular-nums; }
.tool-guard-rule-overview { min-width: 0; flex: 1; display: flex; flex-direction: column; gap: 4px; }
.tool-guard-rule-name { color: var(--text-primary); font-weight: 600; font-size: 13px; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
.tool-guard-rule-preview { color: var(--text-secondary); font-size: 12px; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
.tool-guard-rule-badge { flex-shrink: 0; color: var(--accent-color); background: color-mix(in srgb, var(--accent-color) 8%, transparent); border-radius: 4px; padding: 2px 6px; font-size: 11px; font-weight: 500; white-space: nowrap; }
.tool-guard-chevron { flex-shrink: 0; color: var(--text-secondary); font-size: 23px; font-weight: 400; line-height: 1; transition: transform .15s; }
.tool-guard-rule-summary[aria-expanded="true"] .tool-guard-chevron,
.tool-guard-help[open] > summary .tool-guard-chevron,
.tool-guard-test-panel[open] > summary .tool-guard-chevron { transform: rotate(90deg); }
.tool-guard-rule-editor { padding: 18px; border-top: 1px solid var(--border-color); background: var(--bg-secondary); }
.tool-guard-editor-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 14px 18px; }
.tool-guard-editor-grid > .tool-guard-field--name { grid-column: 1 / -1; max-width: 480px; width: 100%; }
.tool-guard-editor-grid .tool-guard-field { margin: 0; }
.tool-guard-editor-grid textarea { flex: 1; min-height: 108px; }
.tool-guard-editor-footer { margin-top: 16px; }
.tool-guard-delete { color: var(--error-color); }
.tool-guard-field { display: flex; flex-direction: column; gap: 6px; margin-bottom: 14px; min-width: 0; }
.tool-guard-field:last-child { margin-bottom: 0; }
.tool-guard-field label { font-size: 12px; font-weight: 500; }
.tool-guard-field-hint { color: var(--text-secondary); font-size: 11px; line-height: 1.6; }
.tool-guard-field input,
.tool-guard-field textarea {
width: 100%;
padding: 9px 11px;
border: 1px solid var(--border-color);
border-radius: 6px;
background: var(--input-bg);
color: var(--text-primary);
font: inherit;
font-size: 13px;
line-height: 1.6;
}
.tool-guard-field textarea { resize: vertical; min-height: 66px; }
.tool-guard-field [aria-invalid="true"] { border-color: var(--error-color); }
.tool-guard-page :is(button, input, textarea, summary):focus-visible { outline: 2px solid var(--accent-color); outline-offset: 2px; }
.tool-guard-page .tool-guard-rule-summary:focus-visible { outline: none; }
.tool-guard-field .tool-guard-pattern,
#tool-guard-test-arguments,
.tool-guard-test-arguments,
.tool-guard-result-value { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
.tool-guard-page :is(button, input, textarea):disabled { opacity: .55; cursor: not-allowed; }
.tool-guard-save-state { font-size: 12px; color: var(--text-secondary); }
.tool-guard-save-state.is-dirty { color: var(--accent-color); }
.tool-guard-feedback,
.tool-guard-test-result { padding: 14px 16px; border-radius: 8px; background: var(--bg-secondary); border: 1px solid var(--border-color); font-size: 13px; overflow-wrap: anywhere; }
.tool-guard-feedback.is-error,
.tool-guard-test-result.is-blocked { border-color: var(--error-color); }
.tool-guard-feedback.is-error,
.tool-guard-test-result.is-blocked > strong { color: var(--error-color); }
.tool-guard-result-label { margin-top: 12px; margin-bottom: 4px; color: var(--text-secondary); font-size: 12px; }
.tool-guard-result-value { white-space: pre-wrap; overflow-wrap: anywhere; max-height: 200px; overflow-y: auto; font-size: 13px; }
.tool-guard-help { border-top: 1px solid var(--border-color); margin-top: 16px; padding-top: 12px; }
.tool-guard-help > summary,
.tool-guard-test-panel > summary { display: flex; align-items: center; gap: 10px; cursor: pointer; list-style: none; }
.tool-guard-help > summary::-webkit-details-marker,
.tool-guard-test-panel > summary::-webkit-details-marker { display: none; }
.tool-guard-help > summary { color: var(--text-secondary); width: fit-content; font-size: 12px; }
.tool-guard-help > summary .tool-guard-chevron { font-size: 18px; }
.tool-guard-help-body { margin-top: 10px; }
.tool-guard-help-body p + p { margin-top: 6px; }
.tool-guard-test-panel { padding: 0; scroll-margin-top: 80px; }
.tool-guard-test-panel > summary { padding: 18px 20px; border-radius: 12px; }
.tool-guard-test-panel > summary:hover { background: var(--bg-secondary); }
.tool-guard-disclosure-copy { min-width: 0; flex: 1; display: flex; flex-direction: column; gap: 4px; }
.tool-guard-disclosure-copy strong { font-size: 14px; font-weight: 600; }
.tool-guard-disclosure-copy > span { color: var(--text-secondary); font-size: 12px; }
.tool-guard-test-icon { color: var(--text-secondary); background: var(--bg-secondary); }
.tool-guard-test-body { padding: 18px 20px 20px; border-top: 1px solid var(--border-color); }
.tool-guard-test-hint { margin-bottom: 14px; }
.tool-guard-test-actions { display: flex; justify-content: flex-end; margin-bottom: 14px; }
.tool-guard-editor-actions { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.tool-guard-rule-validate { color: var(--accent-color); }
.tool-guard-test-result.is-single.is-blocked { border-color: var(--accent-color); }
.tool-guard-test-result.is-single.is-blocked > strong { color: var(--accent-color); }
.tool-guard-single-result-hint { color: var(--text-secondary); font-size: 12px; line-height: 1.6; margin-top: 8px; }
.tool-guard-hint { margin: 0; color: var(--text-secondary); font-size: 12px; line-height: 1.7; }
.tool-guard-inline-test { margin-top: 18px; padding-top: 18px; border-top: 1px solid var(--border-color); scroll-margin-top: 90px; }
.tool-guard-local-test { padding: 18px; background: var(--card-bg); border: 1px solid var(--border-color); border-radius: 8px; min-width: 0; }
.tool-guard-local-test h4 { margin: 0; font-size: 14px; font-weight: 600; }
.tool-guard-local-test > .tool-guard-hint { margin: 5px 0 16px; }
.tool-guard-local-output { margin-top: 14px; min-width: 0; }
.tool-guard-test-placeholder { padding: 16px; color: var(--text-secondary); font-size: 12px; line-height: 1.7; }
.tool-guard-test-run { min-width: 120px; }
.tool-guard-local-test .tool-guard-test-arguments { min-height: 108px; }
.tool-guard-add-dialog {
width: min(1040px, calc(100vw - 40px));
max-width: none;
height: min(90vh, 900px);
height: min(90dvh, 900px);
max-height: min(90vh, 900px);
max-height: min(90dvh, 900px);
margin: auto;
padding: 0;
overflow: hidden;
border: 1px solid var(--border-color);
border-radius: 14px;
background: var(--card-bg);
color: var(--text-primary);
box-shadow: 0 24px 80px rgba(0, 0, 0, .25);
}
.tool-guard-add-dialog::backdrop { background: rgba(5, 10, 20, .58); }
.tool-guard-add-dialog > form { display: flex; flex-direction: column; height: 100%; min-width: 0; }
.tool-guard-add-header,
.tool-guard-add-footer { display: flex; justify-content: space-between; align-items: center; gap: 20px; padding: 20px 24px; flex-shrink: 0; }
.tool-guard-add-header { border-bottom: 1px solid var(--border-color); align-items: flex-start; }
.tool-guard-add-header h3 { margin: 0 0 5px; font-size: 18px; line-height: 1.5; }
.tool-guard-dialog-close { display: grid; place-items: center; flex-shrink: 0; width: 32px; height: 32px; border: 0; border-radius: 6px; background: transparent; color: var(--text-secondary); cursor: pointer; }
.tool-guard-dialog-close:hover { background: var(--bg-secondary); color: var(--text-primary); }
.tool-guard-dialog-close svg { width: 20px; height: 20px; }
.tool-guard-add-body { display: grid; flex: 1; grid-template-columns: minmax(0, 1.1fr) minmax(0, 1fr); align-items: start; align-content: start; gap: 20px 24px; padding: 24px; overflow-y: auto; overscroll-behavior: contain; scrollbar-gutter: stable; min-height: 0; }
.tool-guard-add-fields { min-width: 0; }
.tool-guard-add-fields .tool-guard-field { margin-bottom: 20px; }
.tool-guard-add-fields .tool-guard-field:last-child { margin-bottom: 0; }
.tool-guard-add-fields textarea { min-height: 116px; }
.tool-guard-add-test { padding: 18px; border: 1px solid var(--border-color); border-radius: 10px; background: var(--bg-secondary); min-width: 0; }
.tool-guard-add-test .tool-guard-local-test { padding: 0; border: 0; background: transparent; }
.tool-guard-add-test .tool-guard-local-output { min-height: 180px; border: 1px solid var(--border-color); border-radius: 8px; background: var(--card-bg); }
.tool-guard-add-test .tool-guard-local-output:has(.is-single.is-blocked:not([hidden])) { border-color: var(--accent-color); }
.tool-guard-add-test .tool-guard-local-output:has(.is-error:not([hidden])) { border-color: var(--error-color); }
.tool-guard-add-test .tool-guard-result-value { max-height: none; overflow: visible; }
.tool-guard-add-test .tool-guard-test-result,
.tool-guard-add-test .tool-guard-feedback { border: 0; background: transparent; }
#tool-guard-add-feedback { grid-column: 1 / -1; }
.tool-guard-add-footer { border-top: 1px solid var(--border-color); }
.tool-guard-add-footer > .tool-guard-hint { max-width: 540px; }
.tool-guard-dialog-actions { display: flex; align-items: center; gap: 10px; flex-shrink: 0; }
@media (max-width: 760px) {
.tool-guard-card { padding: 16px; }
.tool-guard-policy { flex-wrap: wrap; }
.tool-guard-section-header { align-items: flex-start; flex-wrap: wrap; }
.tool-guard-page .page-header { flex-wrap: wrap; gap: 12px; }
.tool-guard-rule-header { padding-right: 10px; gap: 4px; }
.tool-guard-rule-summary { padding: 12px 8px 12px 10px; gap: 8px; }
.tool-guard-rule-number { display: none; }
.tool-guard-rule-actions { gap: 6px; }
.tool-guard-rule-actions .tool-guard-toggle > span { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
.tool-guard-editor-grid { grid-template-columns: minmax(0, 1fr); }
.tool-guard-editor-footer { flex-wrap: wrap; }
.tool-guard-editor-grid > .tool-guard-field--name { max-width: none; }
.tool-guard-rule-editor { padding: 14px; }
.tool-guard-rule-badge { padding: 2px 4px; font-size: 10px; }
.tool-guard-test-panel { padding: 0; }
.tool-guard-test-panel > summary,
.tool-guard-test-body { padding: 16px; }
.tool-guard-add-dialog { width: calc(100vw - 24px); height: calc(100vh - 24px); height: calc(100dvh - 24px); max-height: calc(100vh - 24px); max-height: calc(100dvh - 24px); border-radius: 12px; }
.tool-guard-add-header,
.tool-guard-add-footer { padding: 16px; gap: 12px; }
.tool-guard-add-header h3 { font-size: 16px; }
.tool-guard-add-body { grid-template-columns: minmax(0, 1fr); padding: 16px; gap: 20px; }
.tool-guard-add-test,
.tool-guard-local-test { padding: 14px; }
.tool-guard-add-footer { flex-wrap: wrap; }
.tool-guard-dialog-actions { width: 100%; justify-content: flex-end; }
}
@media (prefers-reduced-motion: reduce) {
.tool-guard-rule-header,
.tool-guard-chevron,
.tool-guard-page .tool-guard-switch input[type="checkbox"],
.tool-guard-page .tool-guard-switch input[type="checkbox"]::after { transition: none; }
}
+101 -1
View File
@@ -77,6 +77,8 @@
"submit": "Sign in"
},
"nav": {
"security": "Security",
"toolGuard": "Call blocking",
"dashboard": "Dashboard",
"chat": "Chat",
"assets": "Asset Management",
@@ -544,6 +546,7 @@
"generatedFromFact": "Generated from project fact {{factKey}}"
},
"chat": {
"toolExecBlocked": "Tool {{name}} blocked",
"newChat": "New chat",
"newTask": "New task",
"toggleConversationPanel": "Collapse/expand conversation list",
@@ -821,6 +824,96 @@
"hitlStatusOff": "Human-in-the-loop: Off",
"rolePanelTitle": "Select role"
},
"toolGuard": {
"invalidTestResponse": "The server returned an invalid test result",
"fieldTooLong": "Rule names are limited to 200 UTF-8 bytes; patterns and messages to 4096 UTF-8 bytes each. Shorten the content and try again.",
"tooManyRules": "You can configure up to 100 blocking rules.",
"title": "Call blocking",
"description": "Check MCP tool names and arguments before execution and stop matching calls. Human-in-the-loop approvals and tool whitelists do not bypass these checks.",
"reset": "Discard changes",
"save": "Save and apply",
"policyTitle": "Blocking policy",
"enableHint": "Checks for risks before tool execution. Save to apply changes.",
"enabled": "Enable call blocking",
"scopeHint": "Rules inspect visible call text only. They do not resolve IP ownership, redirect destinations, or file contents read by tools. Domain rules cannot cover every indirect access.",
"rulesTitle": "Blocking rules",
"rulesHint": "Enabled rules are checked in order; the first match supplies the blocking message. Patterns use RE2 syntax and are validated by the server when saved or tested.",
"addRule": "Add rule",
"addDialogTitle": "Add blocking rule",
"addDialogHint": "Configure your rule and try sample arguments before adding it to the list.",
"addToList": "Add to list",
"addToListHint": "After adding the rule, select “Save and apply” at the top of the page to activate it.",
"cancelAdd": "Cancel",
"closeDialog": "Close add rule dialog",
"singleTestTitle": "Test this rule",
"closeRuleTest": "Hide rule test",
"singleTestHint": "Test the current draft independently before saving. No tools are executed.",
"testing": "Testing…",
"testReadyHint": "Click Test match to see the result here.",
"testResultLabel": "Test result",
"testChanged": "The content has changed. Run the test again.",
"ruleAdded": "Rule added to the list. Save to apply it.",
"addingRule": "Checking rule…",
"addFailed": "Failed to add rule",
"messageHint": "Messages support {match} (matched text), {tool} (tool name), and {rule} (rule name).",
"testTitle": "Test all rules",
"testHint": "Check the current page configuration, including unsaved changes. This test does not execute any tools.",
"test": "Test match",
"testTool": "Tool name",
"testArguments": "Tool arguments (JSON object)",
"loading": "Processing…",
"unsaved": "Unsaved changes",
"savedState": "Synced with server",
"emptyRules": "No rules yet. Add and save a rule to enable its checks.",
"rule": "Rule",
"ruleEnabled": "Enabled",
"deleteRule": "Delete",
"ruleName": "Rule name",
"pattern": "Pattern (RE2)",
"message": "Blocking message",
"requestFailed": "Request failed",
"invalidResponse": "The server returned an invalid configuration",
"loadFailed": "Failed to load blocking configuration",
"loadFirst": "Load the blocking configuration first",
"requiredFields": "Enter a name and pattern for every rule",
"saveSuccess": "Call blocking configuration saved. Subsequent tool calls will use the new configuration.",
"saveFailed": "Save failed",
"defaultMessage": "Detected {match}. Rule “{rule}” blocked this call. Review the target and operation before trying again.",
"blocked": "Rule matched: this call will be blocked",
"notBlocked": "No enabled rule matched",
"disabledResult": "Call blocking is disabled: this configuration will not block calls",
"matchedRule": "Matched rule",
"matchedText": "Matched text",
"matchedMessage": "Returned blocking message",
"toolRequired": "Enter a tool name to test",
"invalidArguments": "Tool arguments must be a valid JSON object",
"testFailed": "Test failed",
"listHint": "Select a rule to view or edit. Rules are checked in list order.",
"helpTitle": "Rule syntax and matching scope",
"testSummary": "Check overall blocking with the current rule order and enabled states",
"unnamedRule": "Untitled rule",
"expandRule": "Expand rule",
"collapseRule": "Collapse rule",
"ruleOn": "Enabled",
"ruleOff": "Disabled",
"ruleModified": "Modified",
"ruleNew": "New",
"defaultPreview": "Uses the default blocking message when matched",
"protectionOn": "Call blocking is on",
"protectionOff": "Call blocking is off",
"ruleCount": "{{enabled}} of {{total}} rules enabled",
"closeEditor": "Close editor",
"testEntry": "Test all rules",
"validateRule": "Test this rule",
"testAll": "Test all rules",
"testScopeAll": "All rules",
"testScopeSingle": "Single rule: {{name}}",
"testSingleHint": "Tests only this rule, ignoring global and rule switches. Does not save or execute tools.",
"testAllHint": "Tests all rules in their current order and enabled states. Does not save or execute tools.",
"singleMatched": "This rule matched",
"singleNotMatched": "This rule did not match",
"singleResultHint": "This is a single-rule test. Actual blocking also depends on the global switch, enabled rules, and matching order."
},
"hitl": {
"pageTitle": "HITL approvals",
"pageReviewerLabel": "Current reviewer",
@@ -983,6 +1076,7 @@
"peAgentReplanning": "Replanner"
},
"timeline": {
"blocked": "Blocked",
"params": "Parameters:",
"executionResult": "Execution result:",
"executionId": "Execution ID:",
@@ -2242,6 +2336,11 @@
}
},
"mcpMonitor": {
"toolRowNoCompletedAriaLabel": "{{name}}, {{total}} calls, no completed outcomes; view execution records",
"rateExcludesBlocked": "Success rate includes successful and failed calls, excluding blocked and stopped calls",
"timelineBlockedLegend": "Blocked",
"blockedCount": "Blocked {{n}}",
"statusBlocked": "Blocked",
"deselectAll": "Deselect all",
"statusPending": "Pending",
"statusQueued": "Queued",
@@ -2316,7 +2415,7 @@
"timelineLoadError": "Failed to load call trend",
"timelineTotalLegend": "Total calls",
"timelineFailedLegend": "Failed",
"timelineTooltip": "{{time}}: {{total}} calls ({{failed}} failed)",
"timelineTooltip": "{{time}}: {{total}} calls ({{failed}} failed, {{blocked}} blocked)",
"distTitle": "Call distribution",
"distLegend": "Slice area shows share of all calls",
"distClickHint": "Click a bar segment to filter records",
@@ -3209,6 +3308,7 @@
"botCommandsFooter": "Otherwise, text is sent to AI under the bound user or service account's current RBAC permissions."
},
"mcpDetailModal": {
"blockReason": "Block reason",
"title": "Tool call details",
"execInfo": "Execution info",
"tool": "Tool",
+101 -1
View File
@@ -77,6 +77,8 @@
"submit": "登录"
},
"nav": {
"security": "安全防护",
"toolGuard": "调用拦截",
"dashboard": "仪表盘",
"chat": "对话",
"assets": "资产管理",
@@ -532,6 +534,7 @@
"generatedFromFact": "由项目事实 {{factKey}} 生成"
},
"chat": {
"toolExecBlocked": "工具 {{name}} 已拦截",
"newChat": "新对话",
"newTask": "新任务",
"toggleConversationPanel": "折叠/展开对话列表",
@@ -809,6 +812,96 @@
"hitlStatusOff": "人机协同:关闭",
"rolePanelTitle": "选择角色"
},
"toolGuard": {
"invalidTestResponse": "服务端返回的测试结果格式不正确",
"fieldTooLong": "规则名称最多 200 个 UTF-8 字节,正则和提醒各最多 4096 个 UTF-8 字节;请缩短内容。",
"tooManyRules": "最多可配置 100 条拦截规则。",
"title": "调用拦截",
"description": "在 MCP 工具执行前检查工具名称和参数,命中规则立即阻止调用。人机协同审批和工具白名单不会跳过此检查。",
"reset": "撤销修改",
"save": "保存并生效",
"policyTitle": "拦截策略",
"enableHint": "在工具执行前检查风险,修改后保存生效。",
"enabled": "启用调用拦截",
"scopeHint": "正则仅检查调用中可见的文本;不会解析 IP 归属、重定向目标或工具读取的文件内容。域名规则不能保证覆盖所有间接访问。",
"rulesTitle": "拦截规则",
"rulesHint": "按顺序匹配已启用的规则,使用首条命中规则的提醒。正则采用 RE2 语法,保存和试运行时由服务端校验。",
"addRule": "添加规则",
"addDialogTitle": "添加拦截规则",
"addDialogHint": "先配置规则,再用示例参数验证效果,确认后添加到列表。",
"addToList": "添加到列表",
"addToListHint": "添加到列表后,点击页面顶部「保存并生效」应用规则。",
"cancelAdd": "取消",
"closeDialog": "关闭添加规则",
"singleTestTitle": "验证本条规则",
"closeRuleTest": "收起验证",
"singleTestHint": "使用当前填写内容独立验证,无需保存;不会执行工具。",
"testing": "验证中…",
"testReadyHint": "点击「测试匹配」,在这里查看验证结果。",
"testResultLabel": "验证结果",
"testChanged": "内容已修改,请重新测试匹配。",
"ruleAdded": "规则已添加到列表,保存后生效。",
"addingRule": "正在检查规则…",
"addFailed": "添加规则失败",
"messageHint": "拦截提醒可使用 {match}(匹配文本)、{tool}(工具名称)、{rule}(规则名称)。",
"testTitle": "全部规则验证",
"testHint": "使用页面上的当前配置(包括未保存修改),仅检查匹配结果,不执行任何工具。",
"test": "测试匹配",
"testTool": "工具名称",
"testArguments": "工具参数(JSON 对象)",
"loading": "处理中…",
"unsaved": "有未保存修改",
"savedState": "已与服务端同步",
"emptyRules": "暂无规则。添加规则后保存,即可启用对应的拦截措施。",
"rule": "规则",
"ruleEnabled": "启用",
"deleteRule": "删除",
"ruleName": "规则名称",
"pattern": "匹配正则(RE2",
"message": "拦截提醒",
"requestFailed": "请求失败",
"invalidResponse": "服务端返回的配置格式不正确",
"loadFailed": "加载拦截配置失败",
"loadFirst": "请先加载拦截配置",
"requiredFields": "请填写每条规则的名称和匹配正则",
"saveSuccess": "调用拦截配置已保存,后续工具调用将使用新配置。",
"saveFailed": "保存失败",
"defaultMessage": "识别到 {match},调用被规则「{rule}」拦截。请检查目标与操作后重试。",
"blocked": "已命中规则:将阻止调用",
"notBlocked": "未命中已启用的规则",
"disabledResult": "调用拦截已关闭:此配置不会阻止调用",
"matchedRule": "命中规则",
"matchedText": "匹配文本",
"matchedMessage": "返回的拦截提醒",
"toolRequired": "请填写测试工具名称",
"invalidArguments": "工具参数必须是有效的 JSON 对象",
"testFailed": "测试失败",
"listHint": "点击规则查看和编辑,按列表顺序匹配。",
"helpTitle": "规则说明与匹配范围",
"testSummary": "按规则顺序和启停状态,检查整体拦截效果",
"unnamedRule": "未命名规则",
"expandRule": "展开规则",
"collapseRule": "收起规则",
"ruleOn": "已启用",
"ruleOff": "已停用",
"ruleModified": "已修改",
"ruleNew": "新增",
"defaultPreview": "命中后使用默认拦截提醒",
"protectionOn": "调用拦截已开启",
"protectionOff": "调用拦截已关闭",
"ruleCount": "{{enabled}} / {{total}} 条规则已启用",
"closeEditor": "收起编辑",
"testEntry": "全部规则验证",
"validateRule": "验证本条",
"testAll": "验证全部规则",
"testScopeAll": "全部规则验证",
"testScopeSingle": "单条验证:{{name}}",
"testSingleHint": "仅验证本条规则,忽略总开关和规则启停;不保存、不执行工具。",
"testAllHint": "按当前顺序与启停状态验证全部规则,不保存、不执行工具。",
"singleMatched": "本条规则匹配成功",
"singleNotMatched": "本条规则未匹配",
"singleResultHint": "这是单条规则验证结果,实际拦截还取决于总开关、规则启停和匹配顺序。"
},
"hitl": {
"pageTitle": "人机协同审批",
"pageReviewerLabel": "当前审批方",
@@ -971,6 +1064,7 @@
"peAgentReplanning": "重规划"
},
"timeline": {
"blocked": "已拦截",
"params": "参数:",
"executionResult": "执行结果:",
"executionId": "执行ID:",
@@ -2230,6 +2324,11 @@
}
},
"mcpMonitor": {
"toolRowNoCompletedAriaLabel": "{{name}}{{total}} 次调用,暂无完成结果,点击查看执行记录",
"rateExcludesBlocked": "成功率仅统计成功和失败的调用,不包含安全拦截和终止",
"timelineBlockedLegend": "安全拦截",
"blockedCount": "安全拦截 {{n}}",
"statusBlocked": "已拦截",
"deselectAll": "取消全选",
"statusPending": "等待中",
"statusQueued": "排队中",
@@ -2304,7 +2403,7 @@
"timelineLoadError": "无法加载调用趋势",
"timelineTotalLegend": "总调用",
"timelineFailedLegend": "失败",
"timelineTooltip": "{{time}}{{total}} 次(失败 {{failed}}",
"timelineTooltip": "{{time}}{{total}} 次(失败 {{failed}},安全拦截 {{blocked}}",
"distTitle": "调用分布",
"distLegend": "扇区面积为占全部调用比例",
"distClickHint": "点击色条筛选执行记录",
@@ -3197,6 +3296,7 @@
"botCommandsFooter": "除以上命令外,直接输入内容将按绑定用户或服务账号的实时 RBAC 权限发送给 AI。Otherwise, text is sent to AI under the effective RBAC identity."
},
"mcpDetailModal": {
"blockReason": "拦截原因",
"title": "工具调用详情",
"execInfo": "执行信息",
"tool": "工具",
+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);
}
+107 -13
View File
@@ -31,11 +31,12 @@
}
})();
</script>
<link rel="stylesheet" href="/static/css/style.css?v=20260819-4">
<link rel="stylesheet" href="/static/css/style.css?v=20260907-blocked-1">
<link rel="stylesheet" href="/static/css/tool-guard.css?v=20260907-7">
<link rel="stylesheet" href="/static/css/chat-plan-progress.css?v=20260813-4">
<link rel="stylesheet" href="/static/css/c2.css">
<link rel="stylesheet" href="/static/vendor/xterm.css">
<script src="/static/js/router.js?v=20260819-3"></script>
<script src="/static/js/router.js?v=20260907-1"></script>
</head>
<body>
<div id="login-overlay" class="login-overlay" style="display: none;">
@@ -210,14 +211,18 @@
<span data-i18n="nav.chat">对话</span>
</div>
</div>
<div class="nav-item" data-page="hitl">
<div class="nav-item-content" data-title="人机协同" onclick="switchPage('hitl')">
<div class="nav-item nav-item-has-submenu" data-page="security" data-require-permission-any="hitl:read config:read">
<div class="nav-item-content" data-title="安全防护" onclick="window.toggleSubmenu('security')" data-i18n="nav.security" data-i18n-attr="data-title" data-i18n-skip-text="true">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"></path>
<circle cx="9" cy="7" r="4"></circle>
<path d="m16 11 2 2 4-4"></path>
<rect x="4" y="10" width="16" height="11" rx="2"></rect>
<path d="M8 10V7a4 4 0 0 1 8 0v3M12 14v3"></path>
</svg>
<span data-i18n="nav.hitl">人机协同</span>
<span data-i18n="nav.security">安全防护</span>
<svg class="submenu-arrow" width="16" height="16" viewBox="0 0 24 24" fill="none"><path d="m9 18 6-6-6-6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>
</div>
<div class="nav-submenu">
<div class="nav-submenu-item" data-page="hitl" data-require-permission="hitl:read" onclick="switchPage('hitl')" data-i18n="nav.hitl">人机协同</div>
<div class="nav-submenu-item" data-page="tool-guard" data-require-permission="config:read" onclick="switchPage('tool-guard')" data-i18n="nav.toolGuard">调用拦截</div>
</div>
</div>
<div class="nav-section-label" role="presentation">
@@ -1352,6 +1357,93 @@
</div>
</div>
<div id="page-tool-guard" class="page tool-guard-page">
<div class="page-header">
<h2 data-i18n="toolGuard.title">调用拦截</h2>
<div class="page-header-actions">
<span id="tool-guard-save-state" class="tool-guard-save-state" aria-live="polite"></span>
<button type="button" id="tool-guard-open-test" class="btn-secondary" data-require-permission="config:read" onclick="openToolGuardTest()" data-i18n="toolGuard.testEntry" disabled>全部规则验证</button>
<button type="button" id="tool-guard-reset" class="btn-secondary" data-require-permission="config:write" onclick="resetToolGuardConfig()" data-i18n="toolGuard.reset" disabled>撤销修改</button>
<button type="button" id="tool-guard-save" class="btn-primary" data-require-permission="config:write" onclick="saveToolGuardConfig()" data-i18n="toolGuard.save" disabled>保存并生效</button>
</div>
</div>
<div class="page-content tool-guard-content">
<div id="tool-guard-feedback" class="tool-guard-feedback" role="status" aria-live="polite" hidden></div>
<section class="tool-guard-card tool-guard-policy" aria-labelledby="tool-guard-protection-status">
<div class="tool-guard-policy-info">
<span class="tool-guard-policy-icon" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7"><path d="M12 3 4 6v6c0 4 5 7 8 9 3-2 8-5 8-9V6l-8-3Z"/><path d="m8 12 3 3 5-6"/></svg></span>
<div class="tool-guard-policy-copy">
<h3 id="tool-guard-protection-status" data-i18n="toolGuard.policyTitle">拦截策略</h3>
<p data-i18n="toolGuard.enableHint">保存后立即作用于后续工具调用,已开始的调用不受影响。</p>
</div>
</div>
<label class="tool-guard-toggle tool-guard-switch"><input id="tool-guard-enabled" type="checkbox" onchange="changeToolGuardEnabled(this.checked)" disabled><span data-i18n="toolGuard.enabled">启用调用拦截</span></label>
</section>
<section class="tool-guard-card tool-guard-rules-card" aria-labelledby="tool-guard-rules-title">
<div class="tool-guard-section-header">
<div>
<div class="tool-guard-heading-line"><h3 id="tool-guard-rules-title" data-i18n="toolGuard.rulesTitle">拦截规则</h3><span id="tool-guard-rule-count" class="tool-guard-rule-count" aria-live="polite"></span></div>
<p data-i18n="toolGuard.listHint">点击规则查看和编辑,按列表顺序匹配。</p>
</div>
<button type="button" id="tool-guard-add" class="btn-secondary tool-guard-add" data-require-permission="config:write" onclick="addToolGuardRule()" disabled><span aria-hidden="true"></span><span data-i18n="toolGuard.addRule">添加规则</span></button>
</div>
<div id="tool-guard-rules" class="tool-guard-rules"></div>
<details class="tool-guard-help">
<summary><span data-i18n="toolGuard.helpTitle">规则说明与匹配范围</span><span class="tool-guard-chevron" aria-hidden="true"></span></summary>
<div class="tool-guard-help-body">
<p data-i18n="toolGuard.rulesHint">按顺序匹配已启用的规则,使用首条命中规则的提醒。正则采用 RE2 语法,保存和试运行时由服务端校验。</p>
<p data-i18n="toolGuard.messageHint">拦截提醒可使用 {match}(匹配文本)、{tool}(工具名称)、{rule}(规则名称)。</p>
<p data-i18n="toolGuard.description">在 MCP 工具执行前检查工具名称和参数,命中规则立即阻止调用。人机协同审批和工具白名单不会跳过此检查。</p>
<p data-i18n="toolGuard.scopeHint">正则仅检查调用中可见的文本;不会解析 IP 归属、重定向目标或工具读取的文件内容。域名规则不能保证覆盖所有间接访问。</p>
</div>
</details>
</section>
<details id="tool-guard-test-panel" class="tool-guard-card tool-guard-test-panel">
<summary>
<span class="tool-guard-test-icon" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7"><path d="M9 3h6M10 3v6l-6 9a2 2 0 0 0 2 3h12a2 2 0 0 0 2-3l-6-9V3M7 15h10"/></svg></span>
<span class="tool-guard-disclosure-copy"><strong id="tool-guard-test-title" data-i18n="toolGuard.testTitle">全部规则验证</strong><span id="tool-guard-test-scope-summary" data-i18n="toolGuard.testSummary">按规则顺序和启停状态,检查整体拦截效果</span></span>
<span class="tool-guard-chevron" aria-hidden="true"></span>
</summary>
<div class="tool-guard-test-body">
<p class="tool-guard-test-hint" data-i18n="toolGuard.testHint">使用页面上的当前配置(包括未保存修改),仅检查匹配结果,不执行任何工具。</p>
<div class="tool-guard-field">
<label for="tool-guard-test-tool" data-i18n="toolGuard.testTool">工具名称</label>
<input id="tool-guard-test-tool" type="text" value="http_request" maxlength="512" oninput="invalidateToolGuardTest()" autocomplete="off" spellcheck="false">
</div>
<div class="tool-guard-field">
<label for="tool-guard-test-arguments" data-i18n="toolGuard.testArguments">工具参数(JSON 对象)</label>
<textarea id="tool-guard-test-arguments" rows="3" oninput="invalidateToolGuardTest()" spellcheck="false">{"url": "https://example.gov.cn"}</textarea>
</div>
<div class="tool-guard-test-actions"><button type="button" id="tool-guard-test" class="btn-secondary" data-require-permission="config:read" onclick="testToolGuardConfig()" data-i18n="toolGuard.test" disabled>测试匹配</button></div>
<div id="tool-guard-test-result" class="tool-guard-test-result" role="status" aria-live="polite" hidden></div>
</div>
</details>
</div>
<dialog id="tool-guard-add-dialog" class="tool-guard-add-dialog" aria-labelledby="tool-guard-add-title" aria-describedby="tool-guard-add-description">
<form novalidate onsubmit="event.preventDefault();commitToolGuardRule()">
<header class="tool-guard-add-header">
<div>
<h3 id="tool-guard-add-title" data-i18n="toolGuard.addDialogTitle">添加拦截规则</h3>
<p id="tool-guard-add-description" class="tool-guard-hint" data-i18n="toolGuard.addDialogHint">先配置规则,再用示例参数验证效果,确认后添加到列表。</p>
</div>
<button type="button" class="tool-guard-dialog-close" onclick="closeToolGuardRuleDialog()" data-i18n="toolGuard.closeDialog" data-i18n-attr="aria-label,title" data-i18n-skip-text="true" aria-label="关闭添加规则" title="关闭添加规则"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" aria-hidden="true"><path d="m6 6 12 12M18 6 6 18"/></svg></button>
</header>
<div id="tool-guard-add-body" class="tool-guard-add-body">
<div id="tool-guard-add-fields" class="tool-guard-add-fields"></div>
<aside id="tool-guard-add-test" class="tool-guard-add-test"></aside>
<div id="tool-guard-add-feedback" class="tool-guard-feedback" role="status" aria-live="polite" hidden></div>
</div>
<footer class="tool-guard-add-footer">
<p class="tool-guard-hint" data-i18n="toolGuard.addToListHint">添加到列表后,点击页面顶部「保存并生效」应用规则。</p>
<div class="tool-guard-dialog-actions">
<button type="button" class="btn-secondary" onclick="closeToolGuardRuleDialog()" data-i18n="toolGuard.cancelAdd">取消</button>
<button type="submit" id="tool-guard-add-confirm" class="btn-primary" data-require-permission="config:write" data-i18n="toolGuard.addToList">添加到列表</button>
</div>
</footer>
</form>
</dialog>
</div>
<div id="page-hitl" class="page">
<div class="page-header">
<h2 data-i18n="hitl.pageTitle">人机协同审批</h2>
@@ -1568,6 +1660,7 @@
<option value="running" data-i18n="mcpMonitor.statusRunning">执行中</option>
<option value="queued" data-i18n="mcpMonitor.statusQueued">排队中</option>
<option value="failed" data-i18n="mcpMonitor.statusFailed">失败</option>
<option value="blocked" data-i18n="mcpMonitor.statusBlocked">已拦截</option>
<option value="hard_timeout" data-i18n="mcpMonitor.statusHardTimeout">执行超时</option>
<option value="cancelled" data-i18n="mcpMonitor.statusCancelled">已终止</option>
<option value="orphaned" data-i18n="mcpMonitor.statusOrphaned">孤儿任务</option>
@@ -6695,7 +6788,7 @@
<script src="/static/js/i18n.js"></script>
<script src="/static/js/theme.js"></script>
<script src="/static/js/builtin-tools.js"></script>
<script src="/static/js/auth.js?v=20260813-1"></script>
<script src="/static/js/auth.js?v=20260907-blocked-1"></script>
<script src="/static/js/modal.js"></script>
<script src="/static/js/notifications.js"></script>
<script src="/static/js/info-collect.js?v=20260717-1"></script>
@@ -6703,10 +6796,11 @@
<script src="/static/js/agents.js"></script>
<script src="/static/js/dashboard.js"></script>
<script src="/static/js/chat-scroll.js?v=20260815-1"></script>
<script src="/static/js/monitor.js?v=20260819-3"></script>
<script src="/static/js/chat.js?v=20260819-5"></script>
<script src="/static/js/monitor.js?v=20260907-blocked-1"></script>
<script src="/static/js/chat.js?v=20260907-blocked-1"></script>
<script src="/static/js/chat-plan-progress.js?v=20260815-1"></script>
<script src="/static/js/hitl.js?v=20260819-1"></script>
<script src="/static/js/tool-guard.js?v=20260907-7"></script>
<script src="/static/js/settings.js?v=20260717-1"></script>
<script src="/static/js/audit-datetime-picker.js"></script>
<script src="/static/js/audit.js"></script>
@@ -6719,7 +6813,7 @@
<script src="/static/js/fact-graph.js"></script>
<script src="/static/js/projects.js?v=20260819-1"></script>
<script src="/static/js/vulnerability.js?v=14"></script>
<script src="/static/js/webshell.js"></script>
<script src="/static/js/webshell.js?v=20260907-blocked-1"></script>
<script src="/static/js/chat-files.js"></script>
<script src="/static/js/tasks.js"></script>
<script src="/static/js/workflow-package-client.js"></script>
@@ -6727,6 +6821,6 @@
<script src="/static/js/roles.js"></script>
<script src="/static/js/rbac.js"></script>
<script src="/static/js/c2.js"></script>
<script src="/static/js/rbac-guards.js?v=20260717-1"></script>
<script src="/static/js/rbac-guards.js?v=20260907-1"></script>
</body>
</html>