mirror of
https://github.com/Ed1s0nZ/CyberStrikeAI.git
synced 2026-09-14 21:55:31 +02:00
feat: add configurable tool call blocking and monitoring
This commit is contained in:
@@ -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); }
|
||||
|
||||
@@ -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
@@ -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
@@ -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": "工具",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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
@@ -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') {
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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('&', '&').replaceAll('<', '<'),
|
||||
};
|
||||
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);
|
||||
});
|
||||
@@ -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('');
|
||||
});
|
||||
})();
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user