feat(security-audit): allow admin-managed audit node targets and polish pool UI

Let admins configure private/intranet Guard endpoints without destination-class blocking, and fix prompt-audit switch layout so thumbs and labels no longer overlap.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
mt21625457
2026-07-17 11:45:14 +08:00
co-authored by Cursor
parent df9d9e2e40
commit 18e698bed6
16 changed files with 342 additions and 345 deletions
@@ -1,13 +1,9 @@
package securityaudit
import (
"context"
"crypto/tls"
"errors"
"fmt"
"net"
"net/http"
"net/netip"
"net/url"
"strings"
"time"
@@ -17,40 +13,6 @@ import (
const maxGuardResponseBytes int64 = 256 * 1024
var (
errRedirectBlocked = errors.New("prompt guard redirect blocked")
metadataHosts = map[string]struct{}{
"metadata": {}, "metadata.google.internal": {}, "metadata.azure.internal": {},
"instance-data": {}, "instance-data.ec2.internal": {},
}
blockedPrefixes = []netip.Prefix{
netip.MustParsePrefix("0.0.0.0/8"),
netip.MustParsePrefix("100.64.0.0/10"),
netip.MustParsePrefix("169.254.0.0/16"),
netip.MustParsePrefix("192.0.0.0/24"),
netip.MustParsePrefix("192.0.2.0/24"),
netip.MustParsePrefix("198.18.0.0/15"),
netip.MustParsePrefix("198.51.100.0/24"),
netip.MustParsePrefix("203.0.113.0/24"),
netip.MustParsePrefix("224.0.0.0/4"),
netip.MustParsePrefix("240.0.0.0/4"),
netip.MustParsePrefix("::/128"),
netip.MustParsePrefix("fe80::/10"),
netip.MustParsePrefix("ff00::/8"),
netip.MustParsePrefix("2001:db8::/32"),
}
)
type DNSResolver interface {
LookupNetIP(ctx context.Context, network, host string) ([]netip.Addr, error)
}
type netResolver struct{ resolver *net.Resolver }
func (r netResolver) LookupNetIP(ctx context.Context, network, host string) ([]netip.Addr, error) {
return r.resolver.LookupNetIP(ctx, network, host)
}
func NormalizeBaseURL(raw string) (string, error) {
raw = strings.TrimSpace(raw)
parsed, err := url.Parse(raw)
@@ -64,29 +26,10 @@ func NormalizeBaseURL(raw string) (string, error) {
if parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" {
return "", infraerrors.BadRequest("prompt_audit_unsafe_base_url", "审计节点地址不能包含凭据、查询参数或片段")
}
host := strings.ToLower(strings.TrimSuffix(parsed.Hostname(), "."))
host := strings.TrimSpace(parsed.Hostname())
if host == "" {
return "", infraerrors.BadRequest("prompt_audit_invalid_base_url", "审计节点地址无效")
}
if _, blocked := metadataHosts[host]; blocked || strings.HasSuffix(host, ".metadata.google.internal") {
return "", infraerrors.BadRequest("prompt_audit_unsafe_base_url", "审计节点地址不在允许范围")
}
allowPrivate := isExplicitPrivateHost(host)
if addr, err := netip.ParseAddr(host); err == nil {
if isBlockedAddress(addr) {
return "", infraerrors.BadRequest("prompt_audit_unsafe_base_url", "审计节点地址不在允许范围")
}
// Loopback literals remain available for local Guard nodes and tests.
// RFC1918 literals are rejected so an admin session cannot pivot into
// arbitrary private-network services; use a hostname allowlist instead.
if addr.IsPrivate() {
return "", infraerrors.BadRequest("prompt_audit_unsafe_base_url", "审计节点地址不在允许范围")
}
allowPrivate = addr.IsLoopback()
}
if parsed.Scheme == "http" && !allowPrivate {
return "", infraerrors.BadRequest("prompt_audit_https_required", "公网审计节点必须使用 HTTPS")
}
path := strings.TrimRight(parsed.EscapedPath(), "/")
if strings.EqualFold(path, "/v1") {
path = ""
@@ -113,17 +56,10 @@ func ModelsURL(base string) (string, error) {
}
func NewSecureHTTPClient(endpoint ActiveEndpoint) (*http.Client, error) {
normalized, err := NormalizeBaseURL(endpoint.BaseURL)
_, err := NormalizeBaseURL(endpoint.BaseURL)
if err != nil {
return nil, err
}
parsed, _ := url.Parse(normalized)
host := strings.ToLower(strings.TrimSuffix(parsed.Hostname(), "."))
allowPrivate := isExplicitPrivateHost(host)
if addr, parseErr := netip.ParseAddr(host); parseErr == nil {
allowPrivate = addr.IsLoopback()
}
resolver := netResolver{resolver: net.DefaultResolver}
dialer := &net.Dialer{Timeout: 3 * time.Second, KeepAlive: 30 * time.Second}
transport := &http.Transport{
// Do not inherit HTTP(S)_PROXY. A proxy would move the actual destination
@@ -138,7 +74,10 @@ func NewSecureHTTPClient(endpoint ActiveEndpoint) (*http.Client, error) {
ExpectContinueTimeout: time.Second,
TLSClientConfig: &tls.Config{MinVersion: tls.VersionTLS12},
}
transport.DialContext = secureDialContext(dialer, resolver, allowPrivate)
// Endpoint ownership and destination trust are administrator concerns.
// Use the standard dialer so configured private, loopback, reserved, and
// DNS-resolved addresses are all reachable from the service environment.
transport.DialContext = dialer.DialContext
timeout := time.Duration(endpoint.TimeoutMS) * time.Millisecond
if timeout <= 0 {
timeout = DefaultTimeoutMS * time.Millisecond
@@ -146,71 +85,5 @@ func NewSecureHTTPClient(endpoint ActiveEndpoint) (*http.Client, error) {
return &http.Client{
Transport: transport,
Timeout: timeout,
CheckRedirect: func(_ *http.Request, _ []*http.Request) error {
return errRedirectBlocked
},
}, nil
}
func secureDialContext(dialer *net.Dialer, resolver DNSResolver, allowPrivate bool) func(context.Context, string, string) (net.Conn, error) {
return func(ctx context.Context, network, address string) (net.Conn, error) {
host, port, err := net.SplitHostPort(address)
if err != nil {
return nil, fmt.Errorf("prompt guard dial address invalid")
}
addresses, err := resolver.LookupNetIP(ctx, "ip", host)
if err != nil || len(addresses) == 0 {
return nil, fmt.Errorf("prompt guard dns unavailable")
}
var lastErr error
for _, addr := range addresses {
if isBlockedAddress(addr) {
lastErr = fmt.Errorf("prompt guard resolved address blocked")
continue
}
if allowPrivate {
// localhost / *.localhost may only resolve to loopback. A hosts or
// DNS mapping from localhost to RFC1918 must not become an SSRF pivot.
if !addr.IsLoopback() {
lastErr = fmt.Errorf("prompt guard resolved address blocked")
continue
}
} else if addr.IsPrivate() || addr.IsLoopback() {
lastErr = fmt.Errorf("prompt guard resolved address blocked")
continue
}
if !addr.IsGlobalUnicast() && !addr.IsLoopback() {
lastErr = fmt.Errorf("prompt guard resolved address blocked")
continue
}
conn, dialErr := dialer.DialContext(ctx, network, net.JoinHostPort(addr.String(), port))
if dialErr == nil {
return conn, nil
}
lastErr = dialErr
}
if lastErr == nil {
lastErr = fmt.Errorf("prompt guard no allowed resolved address")
}
return nil, lastErr
}
}
func isExplicitPrivateHost(host string) bool {
// Only the localhost name family is trusted for private/loopback dials.
// A bare "*.local" suffix is too broad (mDNS/intranet names) and would
// re-open RFC1918 SSRF after literal private IPs were rejected.
return host == "localhost" || strings.HasSuffix(host, ".localhost")
}
func isBlockedAddress(addr netip.Addr) bool {
if !addr.IsValid() || addr.IsUnspecified() || addr.IsMulticast() || addr.IsLinkLocalUnicast() || addr.IsLinkLocalMulticast() {
return true
}
for _, prefix := range blockedPrefixes {
if prefix.Contains(addr) {
return true
}
}
return false
}
@@ -5,7 +5,6 @@ import (
"encoding/json"
"net/http"
"net/http/httptest"
"net/netip"
"strings"
"sync/atomic"
"testing"
@@ -14,25 +13,20 @@ import (
"github.com/stretchr/testify/require"
)
type staticResolver struct{ addresses []netip.Addr }
func (r staticResolver) LookupNetIP(context.Context, string, string) ([]netip.Addr, error) {
return r.addresses, nil
}
func TestNormalizeBaseURLSecurity(t *testing.T) {
allowed := []string{"https://guard.example.com", "https://guard.example.com/v1", "http://127.0.0.1:8080", "http://localhost:8080"}
func TestNormalizeBaseURLAllowsAdministratorConfiguredDestinations(t *testing.T) {
allowed := []string{
"https://guard.example.com", "https://guard.example.com/v1", "http://guard.example.com",
"http://127.0.0.1:8080", "http://10.0.0.8:8080", "https://172.16.0.5",
"http://169.254.169.254", "https://metadata.google.internal", "https://192.0.2.1",
"http://internal-admin.local", "http://guard.local:8080",
}
for _, raw := range allowed {
_, err := NormalizeBaseURL(raw)
require.NoError(t, err, raw)
}
blocked := []string{
"ftp://guard.example.com", "http://guard.example.com", "https://user:pass@guard.example.com",
"https://guard.example.com?q=secret", "https://guard.example.com/#fragment", "http://169.254.169.254",
"https://metadata.google.internal", "https://0.0.0.0", "https://224.0.0.1", "https://192.0.2.1",
"https://[::]", "https://[fe80::1]", "https://[ff02::1]", "https://[2001:db8::1]",
"http://10.0.0.8:8080", "http://192.168.1.10:8080", "https://172.16.0.5",
"http://internal-admin.local", "http://guard.local:8080",
"ftp://guard.example.com", "https://user:pass@guard.example.com",
"https://guard.example.com?q=secret", "https://guard.example.com/#fragment",
}
for _, raw := range blocked {
_, err := NormalizeBaseURL(raw)
@@ -43,24 +37,13 @@ func TestNormalizeBaseURLSecurity(t *testing.T) {
require.Equal(t, "https://guard.example.com/v1/chat/completions", url)
}
func TestSecureDialRejectsDNSRebindingToPrivateAddress(t *testing.T) {
dial := secureDialContext(nil, staticResolver{addresses: []netip.Addr{netip.MustParseAddr("127.0.0.1")}}, false)
_, err := dial(context.Background(), "tcp", "guard.example.com:443")
require.Error(t, err)
}
func TestSecureDialLocalhostAllowlistRejectsRFC1918Resolution(t *testing.T) {
dial := secureDialContext(nil, staticResolver{addresses: []netip.Addr{netip.MustParseAddr("10.0.0.8")}}, true)
_, err := dial(context.Background(), "tcp", "localhost:8080")
require.Error(t, err)
}
func TestSecureHTTPClientDoesNotBypassDestinationValidationThroughEnvironmentProxy(t *testing.T) {
func TestHTTPClientUsesDirectStandardDialer(t *testing.T) {
client, err := NewSecureHTTPClient(ActiveEndpoint{BaseURL: "https://guard.example.com", TimeoutMS: 1000})
require.NoError(t, err)
transport, ok := client.Transport.(*http.Transport)
require.True(t, ok)
require.Nil(t, transport.Proxy)
require.NotNil(t, transport.DialContext)
}
func TestOpenAICompatibleScannerRequestContract(t *testing.T) {
@@ -83,13 +66,16 @@ func TestOpenAICompatibleScannerRequestContract(t *testing.T) {
require.Equal(t, EventPass, result.Decision)
}
func TestOpenAICompatibleScannerRejectsRedirectAndOversize(t *testing.T) {
redirect := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "http://127.0.0.1/other", http.StatusFound)
func TestOpenAICompatibleScannerFollowsRedirectAndRejectsOversize(t *testing.T) {
target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte(`{"choices":[{"message":{"content":"Safety: Safe\nCategories: None"}}]}`))
}))
defer target.Close()
redirect := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, target.URL, http.StatusFound) }))
defer redirect.Close()
_, err := NewOpenAICompatibleScanner().Scan(context.Background(), ActiveEndpoint{ID: "redirect", BaseURL: redirect.URL, Model: DefaultGuardModel, TimeoutMS: 1000}, "hello", AllScannerIDs)
require.Error(t, err)
result, err := NewOpenAICompatibleScanner().Scan(context.Background(), ActiveEndpoint{ID: "redirect", BaseURL: redirect.URL, Model: DefaultGuardModel, TimeoutMS: 1000}, "hello", AllScannerIDs)
require.NoError(t, err)
require.Equal(t, EventPass, result.Decision)
oversize := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte(strings.Repeat("x", int(maxGuardResponseBytes)+1)))
}))
Regular → Executable
View File
+1 -1
View File
@@ -16,7 +16,7 @@ services:
# Sub2API Application
# ===========================================================================
sub2api:
image: weishaw/sub2api:latest
image: sub2api:latest
container_name: sub2api
restart: unless-stopped
ulimits:
@@ -1,6 +1,6 @@
<template>
<AppLayout>
<div class="mx-auto max-w-[1600px] pb-28">
<div class="mx-auto max-w-[1600px]" :class="activeTab === 'config' && draft ? 'pb-28' : 'pb-8'">
<header class="mb-6 flex flex-wrap items-end justify-between gap-4">
<div>
<p class="text-xs font-semibold uppercase tracking-[0.16em] text-primary-600 dark:text-primary-400">{{ t('nav.securityAudit') }}</p>
@@ -18,44 +18,79 @@
<button type="button" class="btn btn-secondary btn-sm mt-3" @click="loadConfig">{{ t('admin.promptAudit.actions.retry') }}</button>
</div>
<main v-else class="rounded-2xl border border-gray-200 bg-white px-4 shadow-sm dark:border-dark-700 dark:bg-dark-850 sm:px-6 lg:px-8">
<RuntimeOverview :runtime="runtime" :loading="loading.runtime" :error="loadErrors.runtime" @refresh="loadRuntime" />
<template v-else>
<div class="mb-4" role="tablist" :aria-label="t('admin.promptAudit.title')">
<div class="tabs inline-flex">
<button
v-for="tab in pageTabs"
:key="tab.id"
type="button"
role="tab"
class="tab"
:class="{ 'tab-active': activeTab === tab.id }"
:aria-selected="activeTab === tab.id"
:data-test="`tab-${tab.id}`"
@click="activeTab = tab.id"
>
{{ tab.label }}
</button>
</div>
</div>
<template v-if="draft">
<EndpointPool
:endpoints="draft.endpoints"
:probe-results="probeResults"
:probing-ids="probingIds"
@update:endpoints="updateEndpoints"
@probe="runProbe"
/>
<div v-if="loadErrors.groups" role="alert" class="mt-5 rounded-lg bg-amber-50 px-4 py-3 text-sm text-amber-800 dark:bg-amber-950/30 dark:text-amber-200">{{ loadErrors.groups }}</div>
<PolicyPanel :draft="draft" :groups="groups" @update:draft="replaceDraft" />
</template>
<main class="card px-4 sm:px-6 lg:px-8">
<div v-show="activeTab === 'config'" data-test="tab-panel-config">
<RuntimeOverview :runtime="runtime" :loading="loading.runtime" :error="loadErrors.runtime" @refresh="loadRuntime" />
<EventWorkspace
:events="events.items"
:total="events.total"
:page="events.page"
:page-size="events.page_size"
:filters="filters"
:selected-ids="selectedEventIds"
:loading="loading.events"
:error="loadErrors.events"
@filters-change="handleFiltersChanged"
@search="applyEventFilters"
@selection="selectedEventIds = $event"
@page="changePage"
@page-size="changePageSize"
@view="openEvent"
@delete="requestSingleDelete"
@batch-delete="requestBatchDelete"
@preview-delete="requestFilterDeletePreview"
/>
</main>
<template v-if="draft">
<EndpointPool
:endpoints="draft.endpoints"
:probe-results="probeResults"
:probing-ids="probingIds"
@update:endpoints="updateEndpoints"
@probe="runProbe"
/>
<div v-if="loadErrors.groups" role="alert" class="mt-5 rounded-lg bg-amber-50 px-4 py-3 text-sm text-amber-800 dark:bg-amber-950/30 dark:text-amber-200">{{ loadErrors.groups }}</div>
<PolicyPanel :draft="draft" :groups="groups" @update:draft="replaceDraft" />
</template>
</div>
<div v-show="activeTab === 'events'" data-test="tab-panel-events">
<div
v-if="draft?.enabled && !draft.store_pass_events"
data-test="pass-events-disabled-notice"
role="status"
class="mt-6 flex flex-wrap items-center justify-between gap-3 rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-900 dark:border-amber-900/70 dark:bg-amber-950/30 dark:text-amber-200"
>
<span>{{ t('admin.promptAudit.events.passEventsDisabled') }}</span>
<button type="button" class="btn btn-secondary btn-sm" @click="activeTab = 'config'">
{{ t('admin.promptAudit.events.openConfiguration') }}
</button>
</div>
<EventWorkspace
:events="events.items"
:total="events.total"
:page="events.page"
:page-size="events.page_size"
:filters="filters"
:selected-ids="selectedEventIds"
:loading="loading.events"
:error="loadErrors.events"
@filters-change="handleFiltersChanged"
@search="applyEventFilters"
@selection="selectedEventIds = $event"
@page="changePage"
@page-size="changePageSize"
@view="openEvent"
@delete="requestSingleDelete"
@batch-delete="requestBatchDelete"
@preview-delete="requestFilterDeletePreview"
/>
</div>
</main>
</template>
</div>
<div v-if="draft" class="fixed inset-x-0 bottom-0 z-30 border-t border-gray-200 bg-white/95 px-4 py-3 shadow-[0_-12px_35px_rgba(15,23,42,0.08)] backdrop-blur dark:border-dark-700 dark:bg-dark-900/95 lg:left-64">
<div v-if="draft && activeTab === 'config'" class="fixed inset-x-0 bottom-0 z-30 border-t border-gray-200 bg-white/95 px-4 py-3 shadow-[0_-12px_35px_rgba(15,23,42,0.08)] backdrop-blur dark:border-dark-700/80 dark:bg-dark-900/95 dark:shadow-[0_-12px_35px_rgba(0,0,0,0.35)] lg:left-64">
<div class="mx-auto flex max-w-[1600px] flex-wrap items-center justify-between gap-3">
<div class="flex flex-wrap items-center gap-x-5 gap-y-2">
<SaveToggle :label="t('admin.promptAudit.saveBar.enabled')" :model-value="draft.enabled" data-test="enabled-toggle" @update:model-value="setEnabled" />
@@ -143,6 +178,12 @@ import { buildUpdateRequest, cloneData, configToDraft, draftFingerprint, emptyEv
const { t, locale } = useI18n()
const appStore = useAppStore()
type PromptAuditPageTab = 'config' | 'events'
const activeTab = ref<PromptAuditPageTab>('config')
const pageTabs = computed(() => [
{ id: 'events' as const, label: t('admin.promptAudit.tabs.events') },
{ id: 'config' as const, label: t('admin.promptAudit.tabs.config') },
])
const serverConfig = ref<PromptAuditDraft | null>(null)
const draft = ref<PromptAuditDraft | null>(null)
const runtime = ref<PromptAuditRuntime | null>(null)
@@ -167,13 +208,32 @@ const SaveToggle = defineComponent({
props: { label: { type: String, required: true }, modelValue: { type: Boolean, required: true }, disabled: { type: Boolean, default: false } },
emits: ['update:modelValue'],
setup(props, { emit, attrs }) {
return () => h('label', { class: ['flex items-center gap-2 text-sm', props.disabled ? 'cursor-not-allowed opacity-50' : 'cursor-pointer'] }, [
return () => h('label', { class: ['flex items-center gap-2.5 text-sm', props.disabled ? 'cursor-not-allowed opacity-50' : 'cursor-pointer'] }, [
h('button', {
...attrs, type: 'button', role: 'switch', 'aria-checked': props.modelValue, 'aria-label': props.label, disabled: props.disabled,
class: ['relative h-6 w-11 rounded-full transition-colors', props.modelValue ? 'bg-primary-600' : 'bg-gray-300 dark:bg-dark-600'],
onClick: () => !props.disabled && emit('update:modelValue', !props.modelValue),
}, [h('span', { class: ['absolute top-0.5 h-5 w-5 rounded-full bg-white shadow transition-transform', props.modelValue ? 'translate-x-5' : 'translate-x-0.5'] })]),
h('span', { class: 'text-gray-700 dark:text-dark-200' }, props.label),
...attrs,
type: 'button',
role: 'switch',
'aria-checked': props.modelValue,
'aria-label': props.label,
disabled: props.disabled,
class: [
'relative inline-flex h-6 w-11 shrink-0 items-center rounded-full border-2 border-transparent transition-colors duration-200 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 focus-visible:ring-offset-2',
props.modelValue ? 'bg-primary-600' : 'bg-gray-300 dark:bg-dark-600',
props.disabled ? 'cursor-not-allowed' : 'cursor-pointer',
],
onClick: (event: MouseEvent) => {
event.preventDefault()
if (!props.disabled) emit('update:modelValue', !props.modelValue)
},
}, [
h('span', {
class: [
'pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transition-transform duration-200 ease-in-out',
props.modelValue ? 'translate-x-5' : 'translate-x-0',
],
}),
]),
h('span', { class: 'select-none text-gray-700 dark:text-dark-200' }, props.label),
])
},
})
@@ -82,6 +82,36 @@ describe('PromptAuditView', () => {
expect(wrapper.find('[data-test="events"]').exists()).toBe(true)
})
it('separates configuration and audit events into page tabs', async () => {
const wrapper = mountView()
await flushPromises()
expect(wrapper.get('[data-test="tab-config"]').attributes('aria-selected')).toBe('true')
expect(wrapper.get('[data-test="tab-events"]').attributes('aria-selected')).toBe('false')
expect(wrapper.get('[data-test="tab-panel-config"]').attributes('style') || '').not.toContain('display: none')
expect(wrapper.get('[data-test="tab-panel-events"]').attributes('style') || '').toContain('display: none')
expect(wrapper.find('[data-test="save-config"]').exists()).toBe(true)
expect(wrapper.get('[data-test="tab-events"]').text()).toContain('admin.promptAudit.tabs.events')
expect(wrapper.get('[data-test="tab-config"]').text()).toContain('admin.promptAudit.tabs.config')
await wrapper.get('[data-test="tab-events"]').trigger('click')
await flushPromises()
expect(wrapper.get('[data-test="tab-events"]').attributes('aria-selected')).toBe('true')
expect(wrapper.get('[data-test="tab-panel-config"]').attributes('style') || '').toContain('display: none')
expect(wrapper.get('[data-test="tab-panel-events"]').attributes('style') || '').not.toContain('display: none')
expect(wrapper.find('[data-test="save-config"]').exists()).toBe(false)
expect(wrapper.find('[data-test="events"]').exists()).toBe(true)
expect(wrapper.find('[data-test="pass-events-disabled-notice"]').exists()).toBe(true)
await wrapper.get('[data-test="pass-events-disabled-notice"] button').trigger('click')
expect(wrapper.get('[data-test="tab-config"]').attributes('aria-selected')).toBe('true')
await wrapper.get('[data-test="tab-config"]').trigger('click')
await flushPromises()
expect(wrapper.find('[data-test="save-config"]').exists()).toBe(true)
expect(wrapper.get('[data-test="tab-panel-config"]').attributes('style') || '').not.toContain('display: none')
})
it('requires confirmation for blocking and disables it when audit is turned off', async () => {
const wrapper = mountView()
await flushPromises()
@@ -1,5 +1,5 @@
<template>
<section aria-labelledby="prompt-pool-title" class="border-b border-gray-200 py-6 dark:border-dark-700">
<section aria-labelledby="prompt-pool-title" class="border-b border-gray-200 py-6 dark:border-dark-700/60">
<div class="flex flex-wrap items-start justify-between gap-3">
<div>
<h2 id="prompt-pool-title" class="text-base font-semibold text-gray-950 dark:text-white">{{ t('admin.promptAudit.pool.title') }}</h2>
@@ -10,65 +10,86 @@
</button>
</div>
<div v-if="endpoints.length === 0" class="mt-5 rounded-lg border border-dashed border-gray-300 px-5 py-8 text-center text-sm text-gray-500 dark:border-dark-600 dark:text-dark-300">
<div v-if="endpoints.length === 0" class="mt-5 rounded-xl border border-dashed border-gray-300 px-5 py-10 text-center text-sm text-gray-500 dark:border-dark-600 dark:bg-dark-900/20 dark:text-dark-300">
{{ t('admin.promptAudit.pool.empty') }}
</div>
<div v-else class="mt-5 overflow-x-auto">
<table class="min-w-full text-left text-sm">
<thead class="border-b border-gray-200 text-xs uppercase tracking-wide text-gray-500 dark:border-dark-700 dark:text-dark-400">
<tr>
<th class="px-3 py-2 font-medium">{{ t('admin.promptAudit.pool.node') }}</th>
<th class="px-3 py-2 font-medium">{{ t('admin.promptAudit.pool.model') }}</th>
<th class="px-3 py-2 font-medium">{{ t('admin.promptAudit.pool.limits') }}</th>
<th class="px-3 py-2 font-medium">{{ t('admin.promptAudit.pool.credential') }}</th>
<th class="px-3 py-2 text-right font-medium">{{ t('admin.promptAudit.common.actions') }}</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100 dark:divide-dark-800">
<tr v-for="endpoint in endpoints" :key="endpoint.id" :data-test="`endpoint-${endpoint.id}`">
<td class="px-3 py-3">
<div class="flex items-center gap-2">
<button
type="button"
role="switch"
:aria-checked="endpoint.enabled"
:aria-label="t('admin.promptAudit.pool.toggleNode', { name: endpoint.name })"
class="relative h-5 w-9 rounded-full transition-colors"
:class="endpoint.enabled ? 'bg-primary-600' : 'bg-gray-300 dark:bg-dark-600'"
@click="toggleEndpoint(endpoint.id)"
>
<span class="absolute top-0.5 h-4 w-4 rounded-full bg-white transition-transform" :class="endpoint.enabled ? 'translate-x-4' : 'translate-x-0.5'" />
</button>
<div class="min-w-0">
<p class="font-medium text-gray-900 dark:text-white">{{ endpoint.name }}</p>
<p class="max-w-xs truncate text-xs text-gray-500 dark:text-dark-400">{{ endpoint.base_url }}</p>
</div>
<div v-else class="mt-5 overflow-hidden rounded-xl border border-gray-200 bg-white dark:border-dark-700/60 dark:bg-dark-900/20">
<div class="hidden grid-cols-[minmax(260px,1.45fr)_minmax(210px,1fr)_minmax(190px,.8fr)_minmax(230px,1.15fr)_auto] gap-5 border-b border-l-[3px] border-b-gray-200 border-l-transparent bg-gray-50/80 px-5 py-2.5 text-[11px] font-semibold uppercase tracking-[0.08em] text-gray-500 dark:border-b-dark-700/60 dark:bg-dark-900/70 dark:text-dark-400 xl:grid">
<span>{{ t('admin.promptAudit.pool.node') }}</span>
<span>{{ t('admin.promptAudit.pool.model') }}</span>
<span>{{ t('admin.promptAudit.pool.limits') }}</span>
<span>{{ t('admin.promptAudit.pool.credential') }}</span>
<span class="text-right">{{ t('admin.promptAudit.common.actions') }}</span>
</div>
<div class="divide-y divide-gray-100 dark:divide-dark-800">
<article
v-for="endpoint in endpoints"
:key="endpoint.id"
:data-test="`endpoint-${endpoint.id}`"
class="group grid gap-4 border-l-[3px] border-l-transparent px-4 py-4 transition-[background-color,border-color] duration-200 hover:border-l-primary-500 hover:bg-gray-50/80 dark:hover:bg-dark-800/55 sm:px-5 xl:grid-cols-[minmax(260px,1.45fr)_minmax(210px,1fr)_minmax(190px,.8fr)_minmax(230px,1.15fr)_auto] xl:items-center xl:gap-5"
>
<div class="flex min-w-0 items-center gap-3">
<button
type="button"
role="switch"
:aria-checked="endpoint.enabled"
:aria-label="t('admin.promptAudit.pool.toggleNode', { name: endpoint.name })"
class="relative inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors duration-200 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 focus-visible:ring-offset-2"
:class="endpoint.enabled ? 'bg-primary-600' : 'bg-gray-200 dark:bg-dark-600'"
@click="toggleEndpoint(endpoint.id)"
>
<span
class="pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transition-transform duration-200 ease-in-out"
:class="endpoint.enabled ? 'translate-x-5' : 'translate-x-0'"
/>
</button>
<div class="min-w-0">
<div class="flex min-w-0 items-center gap-2">
<p class="truncate font-semibold text-gray-950 dark:text-white">{{ endpoint.name }}</p>
<span class="h-1.5 w-1.5 shrink-0 rounded-full" :class="endpoint.enabled ? 'bg-emerald-500' : 'bg-gray-300 dark:bg-dark-500'" aria-hidden="true" />
</div>
</td>
<td class="px-3 py-3 text-gray-700 dark:text-dark-200">{{ endpoint.model }}</td>
<td class="whitespace-nowrap px-3 py-3 text-gray-600 dark:text-dark-300">{{ endpoint.timeout_ms }} ms · {{ endpoint.input_limit }} chars</td>
<td class="px-3 py-3">
<span :class="hasCredential(endpoint) ? 'text-emerald-600 dark:text-emerald-300' : 'text-gray-500 dark:text-dark-400'">
{{ hasCredential(endpoint) ? t('admin.promptAudit.pool.configured') : t('admin.promptAudit.pool.missing') }}
</span>
<p v-if="probingIds.includes(endpoint.id)" class="mt-1 text-xs text-primary-600 dark:text-primary-300">
{{ t('admin.promptAudit.pool.probeProgress') }}
</p>
<p v-if="probeResults[endpoint.id]" class="mt-1 text-xs" :class="probeResults[endpoint.id].ok ? 'text-emerald-600' : 'text-red-600'">
{{ t('admin.promptAudit.pool.probeResult', { status: probeResults[endpoint.id].status, http: probeResults[endpoint.id].http_status || '—', latency: probeResults[endpoint.id].latency_ms }) }}
· {{ probeResults[endpoint.id].message }}
</p>
</td>
<td class="whitespace-nowrap px-3 py-3 text-right">
<button type="button" class="btn btn-ghost btn-sm" :disabled="probingIds.includes(endpoint.id)" @click="$emit('probe', endpoint)">
{{ probingIds.includes(endpoint.id) ? t('admin.promptAudit.pool.probing') : t('admin.promptAudit.pool.probe') }}
</button>
<button type="button" class="btn btn-ghost btn-sm" @click="openEdit(endpoint)">{{ t('common.edit') }}</button>
<button type="button" class="btn btn-ghost btn-sm text-red-600" @click="removeEndpoint(endpoint)">{{ t('common.delete') }}</button>
</td>
</tr>
</tbody>
</table>
<p class="mt-0.5 truncate font-mono text-[11px] text-gray-500 dark:text-dark-400" :title="endpoint.base_url">{{ endpoint.base_url }}</p>
</div>
</div>
<div class="min-w-0 xl:block">
<p class="mb-1 text-[10px] font-semibold uppercase tracking-wider text-gray-400 xl:hidden">{{ t('admin.promptAudit.pool.model') }}</p>
<p class="truncate text-sm font-medium text-gray-700 dark:text-dark-200" :title="endpoint.model">{{ endpoint.model }}</p>
</div>
<div>
<p class="mb-1 text-[10px] font-semibold uppercase tracking-wider text-gray-400 xl:hidden">{{ t('admin.promptAudit.pool.limits') }}</p>
<div class="flex flex-wrap gap-1.5 text-xs text-gray-600 dark:text-dark-300">
<span class="rounded-md bg-gray-100 px-2 py-1 tabular-nums dark:bg-dark-800">{{ endpoint.timeout_ms }} ms</span>
<span class="rounded-md bg-gray-100 px-2 py-1 tabular-nums dark:bg-dark-800">{{ endpoint.input_limit }} chars</span>
</div>
</div>
<div class="min-w-0">
<p class="mb-1 text-[10px] font-semibold uppercase tracking-wider text-gray-400 xl:hidden">{{ t('admin.promptAudit.pool.credential') }}</p>
<div class="flex items-center gap-1.5 text-xs font-medium" :class="hasCredential(endpoint) ? 'text-emerald-700 dark:text-emerald-300' : 'text-gray-500 dark:text-dark-400'">
<span class="h-1.5 w-1.5 rounded-full" :class="hasCredential(endpoint) ? 'bg-emerald-500' : 'bg-gray-300 dark:bg-dark-500'" aria-hidden="true" />
{{ hasCredential(endpoint) ? t('admin.promptAudit.pool.configured') : t('admin.promptAudit.pool.missing') }}
</div>
<p v-if="probingIds.includes(endpoint.id)" class="mt-1.5 text-xs text-primary-600 dark:text-primary-300">
{{ t('admin.promptAudit.pool.probeProgress') }}
</p>
<p v-if="probeResults[endpoint.id]" class="mt-1.5 line-clamp-2 text-xs leading-5" :class="probeResults[endpoint.id].ok ? 'text-emerald-600 dark:text-emerald-300' : 'text-red-600 dark:text-red-300'">
{{ t('admin.promptAudit.pool.probeResult', { status: probeResults[endpoint.id].status, http: probeResults[endpoint.id].http_status || '—', latency: probeResults[endpoint.id].latency_ms }) }}
· {{ probeResults[endpoint.id].message }}
</p>
</div>
<div class="flex flex-wrap items-center justify-end gap-1 border-t border-gray-100 pt-3 dark:border-dark-800 xl:flex-nowrap xl:border-0 xl:pt-0">
<button type="button" class="btn btn-secondary btn-sm" :disabled="probingIds.includes(endpoint.id)" @click="$emit('probe', endpoint)">
{{ probingIds.includes(endpoint.id) ? t('admin.promptAudit.pool.probing') : t('admin.promptAudit.pool.probe') }}
</button>
<button type="button" class="btn btn-ghost btn-sm" @click="openEdit(endpoint)">{{ t('common.edit') }}</button>
<button type="button" class="btn btn-ghost btn-sm text-red-600 hover:bg-red-50 dark:text-red-300 dark:hover:bg-red-950/30" @click="removeEndpoint(endpoint)">{{ t('common.delete') }}</button>
</div>
</article>
</div>
</div>
<BaseDialog :show="Boolean(editing)" :title="editingIndex < 0 ? t('admin.promptAudit.pool.add') : t('admin.promptAudit.pool.edit')" width="wide" @close="closeEditor">
@@ -16,13 +16,13 @@
</div>
<form class="mt-5 grid gap-3 sm:grid-cols-2 lg:grid-cols-4 xl:grid-cols-5" @submit.prevent="applyFilters">
<label class="text-xs text-gray-600 dark:text-dark-300">
<label class="text-xs text-gray-600 dark:text-dark-200">
<span>{{ t('admin.promptAudit.events.decision') }}</span>
<select v-model="localFilters.decision" class="input mt-1 w-full" :aria-label="t('admin.promptAudit.events.decision')" @change="filtersChanged">
<option value="">{{ t('common.all') }}</option><option value="pass">pass</option><option value="flag">flag</option><option value="critical">critical</option>
</select>
</label>
<label class="text-xs text-gray-600 dark:text-dark-300">
<label class="text-xs text-gray-600 dark:text-dark-200">
<span>{{ t('admin.promptAudit.events.risk') }}</span>
<select v-model="localFilters.risk_level" class="input mt-1 w-full" :aria-label="t('admin.promptAudit.events.risk')" @change="filtersChanged">
<option value="">{{ t('common.all') }}</option><option value="low">low</option><option value="medium">medium</option><option value="high">high</option><option value="critical">critical</option>
@@ -35,11 +35,11 @@
<FilterInput v-model="localFilters.request_id" label="Request ID" @change="filtersChanged" />
<FilterInput v-model="localFilters.prompt_hash" label="Prompt SHA-256" @change="filtersChanged" />
<FilterInput v-model="localFilters.keyword" :label="t('admin.promptAudit.events.keyword')" @change="filtersChanged" />
<label class="text-xs text-gray-600 dark:text-dark-300">
<label class="text-xs text-gray-600 dark:text-dark-200">
<span>{{ t('admin.promptAudit.events.startAt') }}</span>
<input v-model="localFilters.start_at" type="datetime-local" class="input mt-1 w-full" :aria-label="t('admin.promptAudit.events.startAt')" @change="filtersChanged" />
</label>
<label class="text-xs text-gray-600 dark:text-dark-300">
<label class="text-xs text-gray-600 dark:text-dark-200">
<span>{{ t('admin.promptAudit.events.endAt') }}</span>
<input v-model="localFilters.end_at" type="datetime-local" class="input mt-1 w-full" :aria-label="t('admin.promptAudit.events.endAt')" @change="filtersChanged" />
</label>
@@ -51,9 +51,9 @@
<p class="mt-2 text-xs text-gray-500 dark:text-dark-400">{{ t('admin.promptAudit.events.deleteRangeHint') }}</p>
<div v-if="error" role="alert" class="mt-4 rounded-lg bg-red-50 px-4 py-3 text-sm text-red-700 dark:bg-red-950/30 dark:text-red-300">{{ error }}</div>
<div class="mt-5 overflow-x-auto rounded-lg border border-gray-200 dark:border-dark-700">
<div class="mt-5 overflow-x-auto rounded-xl border border-gray-200 dark:border-dark-700/60">
<table class="min-w-[1120px] w-full text-left text-sm">
<thead class="bg-gray-50 text-xs uppercase tracking-wide text-gray-500 dark:bg-dark-900 dark:text-dark-400">
<thead class="bg-gray-50 text-xs uppercase tracking-wide text-gray-500 dark:bg-dark-900/70 dark:text-dark-400">
<tr>
<th class="w-10 px-3 py-3"><input type="checkbox" :checked="allSelected" :aria-label="t('admin.promptAudit.events.selectAll')" @change="toggleAll" /></th>
<th class="px-3 py-3 font-medium">{{ t('admin.promptAudit.events.time') }}</th>
@@ -65,7 +65,7 @@
<th class="px-3 py-3 text-right font-medium">{{ t('admin.promptAudit.common.actions') }}</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100 bg-white dark:divide-dark-800 dark:bg-dark-850">
<tbody class="divide-y divide-gray-100 bg-white dark:divide-dark-700 dark:bg-transparent">
<tr v-if="loading"><td colspan="8" class="px-4 py-12 text-center text-gray-500" aria-busy="true">{{ t('common.loading') }}</td></tr>
<tr v-else-if="events.length === 0"><td colspan="8" class="px-4 py-12 text-center text-gray-500">{{ t('admin.promptAudit.events.empty') }}</td></tr>
<tr v-for="event in events" v-else :key="event.id" :data-test="`event-${event.id}`" class="align-top hover:bg-gray-50/70 dark:hover:bg-dark-800/70">
@@ -129,7 +129,7 @@ const FilterInput = defineComponent({
props: { modelValue: { type: String, required: true }, label: { type: String, required: true }, type: { type: String, default: 'text' } },
emits: ['update:modelValue', 'change'],
setup(componentProps, { emit: componentEmit }) {
return () => h('label', { class: 'text-xs text-gray-600 dark:text-dark-300' }, [
return () => h('label', { class: 'text-xs text-gray-600 dark:text-dark-200' }, [
h('span', componentProps.label),
h('input', {
value: componentProps.modelValue, type: componentProps.type, class: 'input mt-1 w-full', 'aria-label': componentProps.label,
@@ -144,7 +144,7 @@ const CopyLine = defineComponent({
props: { label: { type: String, required: true }, value: { type: String, default: '' } },
setup(componentProps) {
return () => h('div', { class: 'flex max-w-56 items-center gap-1 text-xs' }, [
h('span', { class: 'w-16 flex-none text-gray-500' }, componentProps.label),
h('span', { class: 'w-16 flex-none text-gray-500 dark:text-dark-400' }, componentProps.label),
h('span', { class: 'min-w-0 flex-1 truncate text-gray-800 dark:text-dark-100' }, componentProps.value || '—'),
componentProps.value ? h('button', {
type: 'button', class: 'text-primary-600 hover:underline', 'aria-label': `${t('common.copy')} ${componentProps.label}`,
@@ -1,12 +1,12 @@
<template>
<section aria-labelledby="prompt-policy-title" class="border-b border-gray-200 py-6 dark:border-dark-700">
<section aria-labelledby="prompt-policy-title" class="py-6">
<div>
<h2 id="prompt-policy-title" class="text-base font-semibold text-gray-950 dark:text-white">{{ t('admin.promptAudit.policy.title') }}</h2>
<p class="mt-1 text-sm text-gray-500 dark:text-dark-300">{{ t('admin.promptAudit.policy.description') }}</p>
</div>
<div class="mt-5 grid gap-7 lg:grid-cols-[minmax(0,1fr)_minmax(280px,0.55fr)]">
<div>
<div class="mt-5 grid gap-4 lg:grid-cols-[minmax(0,1fr)_minmax(260px,0.45fr)]">
<div class="rounded-xl border border-gray-200 p-4 dark:border-dark-700/60 dark:bg-dark-900/20 sm:p-5">
<fieldset>
<legend class="text-sm font-medium text-gray-900 dark:text-white">{{ t('admin.promptAudit.policy.scope') }}</legend>
<div class="mt-3 flex flex-wrap gap-5 text-sm text-gray-700 dark:text-dark-200">
@@ -42,7 +42,7 @@
<p class="mt-2 text-xs text-gray-500 dark:text-dark-400">{{ t('admin.promptAudit.policy.selectedCount', { count: draft.group_ids.length }) }}</p>
</div>
<fieldset class="mt-6">
<fieldset class="mt-5 border-t border-gray-100 pt-5 dark:border-dark-800">
<legend class="text-sm font-medium text-gray-900 dark:text-white">{{ t('admin.promptAudit.policy.scanners') }}</legend>
<div class="mt-3 grid gap-2 sm:grid-cols-2">
<label v-for="scanner in SCANNER_CATALOG" :key="scanner.id" class="flex items-center gap-2 rounded-md px-2 py-1.5 text-sm text-gray-700 hover:bg-gray-50 dark:text-dark-200 dark:hover:bg-dark-800">
@@ -53,7 +53,7 @@
</fieldset>
</div>
<div class="space-y-5 border-gray-200 lg:border-l lg:pl-7 dark:border-dark-700">
<div class="space-y-4 rounded-xl border border-gray-200 p-4 dark:border-dark-700/60 dark:bg-dark-900/20 sm:p-5">
<label class="block text-sm text-gray-700 dark:text-dark-200">
<span>{{ t('admin.promptAudit.policy.workerCount') }}</span>
<input :value="draft.worker_count" type="number" min="1" max="32" class="input mt-1.5 w-full" :aria-label="t('admin.promptAudit.policy.workerCount')" @input="patch({ worker_count: Number(($event.target as HTMLInputElement).value) })" />
@@ -62,7 +62,7 @@
<span>{{ t('admin.promptAudit.policy.queueCapacity') }}</span>
<input :value="draft.queue_capacity" type="number" min="1" max="100000" class="input mt-1.5 w-full" :aria-label="t('admin.promptAudit.policy.queueCapacity')" @input="patch({ queue_capacity: Number(($event.target as HTMLInputElement).value) })" />
</label>
<div class="rounded-lg bg-gray-50 px-4 py-3 text-sm text-gray-600 dark:bg-dark-800 dark:text-dark-300">
<div class="rounded-lg bg-gray-50 px-4 py-3 text-sm text-gray-600 dark:bg-dark-900/50 dark:text-dark-300">
<p class="font-medium text-gray-800 dark:text-dark-100">{{ t('admin.promptAudit.policy.strategy') }}</p>
<p class="mt-1">priority · {{ t('admin.promptAudit.policy.strategyHint') }}</p>
</div>
@@ -1,5 +1,5 @@
<template>
<section aria-labelledby="prompt-runtime-title" class="border-b border-gray-200 pb-6 dark:border-dark-700">
<section aria-labelledby="prompt-runtime-title" class="border-b border-gray-200 py-6 dark:border-dark-700/60">
<div class="flex flex-wrap items-start justify-between gap-3">
<div>
<h2 id="prompt-runtime-title" class="text-base font-semibold text-gray-950 dark:text-white">
@@ -14,59 +14,33 @@
</button>
</div>
<div v-if="error" role="alert" class="mt-4 rounded-lg bg-red-50 px-4 py-3 text-sm text-red-700 dark:bg-red-950/30 dark:text-red-300">
<div v-if="error" role="alert" class="mt-5 rounded-lg bg-red-50 px-4 py-3 text-sm text-red-700 dark:bg-red-950/30 dark:text-red-300">
{{ error }}
</div>
<div v-else-if="loading && !runtime" class="mt-5 grid grid-cols-2 gap-4 lg:grid-cols-6" aria-busy="true">
<div v-for="index in 6" :key="index" class="h-14 animate-pulse rounded-lg bg-gray-100 dark:bg-dark-800" />
<div v-else-if="loading && !runtime" class="mt-5 grid grid-cols-2 gap-3 md:grid-cols-3 xl:grid-cols-6" aria-busy="true">
<div v-for="index in 6" :key="index" class="h-16 animate-pulse rounded-xl bg-gray-100 dark:bg-dark-800" />
</div>
<template v-else-if="runtime">
<dl class="mt-5 grid grid-cols-2 gap-x-6 gap-y-5 lg:grid-cols-6">
<div>
<dt class="text-xs uppercase tracking-wide text-gray-500 dark:text-dark-400">{{ t('admin.promptAudit.runtime.process') }}</dt>
<dd class="mt-1 flex items-center gap-2 text-sm font-semibold text-gray-900 dark:text-white">
<span class="h-2 w-2 rounded-full" :class="statusDot(runtime.process_status)" />
{{ t(`admin.promptAudit.status.${runtime.process_status}`) }}
<dl class="mt-5 grid grid-cols-2 gap-3 md:grid-cols-3 xl:grid-cols-6">
<div v-for="item in statusItems" :key="item.label" class="rounded-xl border border-gray-100 bg-gray-50/80 px-3 py-3 dark:border-dark-700/60 dark:bg-dark-900/40">
<dt class="text-xs text-gray-500 dark:text-dark-400">{{ item.label }}</dt>
<dd class="mt-1.5 flex items-center gap-2 text-sm font-semibold text-gray-900 dark:text-white">
<span v-if="item.dot" class="h-2 w-2 shrink-0 rounded-full" :class="item.dot" />
<span class="min-w-0 truncate">{{ item.value }}</span>
</dd>
</div>
<div>
<dt class="text-xs uppercase tracking-wide text-gray-500 dark:text-dark-400">{{ t('admin.promptAudit.runtime.mode') }}</dt>
<dd class="mt-1 text-sm font-semibold text-gray-900 dark:text-white">{{ t(`admin.promptAudit.mode.${runtime.effective_mode}`) }}</dd>
</div>
<div>
<dt class="text-xs uppercase tracking-wide text-gray-500 dark:text-dark-400">{{ t('admin.promptAudit.runtime.version') }}</dt>
<dd class="mt-1 text-sm font-semibold text-gray-900 dark:text-white">
{{ runtime.active_config_version }} / {{ runtime.expected_config_version }}
</dd>
</div>
<div>
<dt class="text-xs uppercase tracking-wide text-gray-500 dark:text-dark-400">{{ t('admin.promptAudit.runtime.workers') }}</dt>
<dd class="mt-1 text-sm font-semibold text-gray-900 dark:text-white">{{ runtime.worker_active }} / {{ runtime.worker_total }}</dd>
</div>
<div>
<dt class="text-xs uppercase tracking-wide text-gray-500 dark:text-dark-400">{{ t('admin.promptAudit.runtime.queue') }}</dt>
<dd class="mt-1 text-sm font-semibold text-gray-900 dark:text-white">{{ runtime.queue.active }} / {{ runtime.queue_capacity }}</dd>
</div>
<div>
<dt class="text-xs uppercase tracking-wide text-gray-500 dark:text-dark-400">{{ t('admin.promptAudit.runtime.dependencies') }}</dt>
<dd class="mt-1 text-sm font-semibold text-gray-900 dark:text-white">DB {{ runtime.database_status }} · Redis {{ runtime.redis_status }}</dd>
</div>
</dl>
<div class="mt-5 grid gap-4 border-t border-gray-100 pt-5 dark:border-dark-800 lg:grid-cols-[1.2fr_1fr]">
<div class="min-w-0">
<div class="mt-4 grid gap-3 lg:grid-cols-[minmax(0,1.4fr)_minmax(220px,0.6fr)]">
<div class="rounded-xl border border-gray-100 px-4 py-3 dark:border-dark-700/60 dark:bg-dark-900/20">
<h3 class="text-sm font-medium text-gray-900 dark:text-white">{{ t('admin.promptAudit.runtime.guardMetrics') }}</h3>
<div class="mt-3 flex flex-wrap gap-x-5 gap-y-2 text-sm text-gray-600 dark:text-dark-300">
<span>{{ t('admin.promptAudit.metrics.total') }} <strong>{{ runtime.guard_metrics.total }}</strong></span>
<span>{{ t('admin.promptAudit.metrics.allowed') }} <strong>{{ runtime.guard_metrics.allowed }}</strong></span>
<span>{{ t('admin.promptAudit.metrics.flagged') }} <strong>{{ runtime.guard_metrics.flagged }}</strong></span>
<span>{{ t('admin.promptAudit.metrics.blocked') }} <strong>{{ runtime.guard_metrics.blocked }}</strong></span>
<span>{{ t('admin.promptAudit.metrics.unavailable') }} <strong>{{ runtime.guard_metrics.unavailable }}</strong></span>
<span>{{ t('admin.promptAudit.metrics.timeouts') }} <strong>{{ runtime.guard_metrics.timeouts }}</strong></span>
<span>{{ t('admin.promptAudit.metrics.failovers') }} <strong>{{ runtime.guard_metrics.failovers }}</strong></span>
<span v-if="runtime.guard_metrics.latency_p95_ms != null">P95 <strong>{{ runtime.guard_metrics.latency_p95_ms }} ms</strong></span>
<div class="mt-3 grid grid-cols-2 gap-2 sm:grid-cols-4">
<div v-for="metric in guardMetricItems" :key="metric.label" class="rounded-lg bg-gray-50 px-2.5 py-2 dark:bg-dark-900/60">
<p class="text-[11px] text-gray-500 dark:text-dark-400">{{ metric.label }}</p>
<p class="mt-0.5 text-sm font-semibold tabular-nums text-gray-900 dark:text-white">{{ metric.value }}</p>
</div>
</div>
<p class="mt-3 text-xs text-gray-500 dark:text-dark-400">
<p class="mt-3 text-xs leading-5 text-gray-500 dark:text-dark-400">
{{ t('admin.promptAudit.runtime.queueBreakdown', {
queued: runtime.queue.queued,
processing: runtime.queue.processing,
@@ -74,12 +48,11 @@
done: runtime.queue.done,
failed: runtime.queue.failed,
}) }}
</p>
<p class="mt-1 text-xs text-gray-500 dark:text-dark-400">
<span class="mx-1.5 text-gray-300 dark:text-dark-600">·</span>
{{ t('admin.promptAudit.runtime.deliveryTotals', { enqueued: runtime.enqueued_total, dropped: runtime.dropped_total, processed: runtime.processed_total, failed: runtime.failed_total }) }}
</p>
</div>
<div>
<div class="rounded-xl border border-gray-100 px-4 py-3 dark:border-dark-700/60 dark:bg-dark-900/20">
<h3 class="text-sm font-medium text-gray-900 dark:text-white">{{ t('admin.promptAudit.runtime.latest') }}</h3>
<p class="mt-2 text-sm text-gray-600 dark:text-dark-300">
{{ runtime.last_processed_at ? formatDate(runtime.last_processed_at) : t('admin.promptAudit.common.never') }}
@@ -88,7 +61,7 @@
{{ runtime.last_error_code }}<span v-if="runtime.last_error_message"> · {{ runtime.last_error_message }}</span>
</p>
<div v-if="Object.keys(runtime.endpoints).length" class="mt-3 flex flex-wrap gap-2">
<span v-for="(probe, id) in runtime.endpoints" :key="id" class="rounded-full px-2 py-1 text-xs" :class="probe.ok ? 'bg-emerald-50 text-emerald-700 dark:bg-emerald-950/40 dark:text-emerald-300' : 'bg-red-50 text-red-700 dark:bg-red-950/40 dark:text-red-300'">
<span v-for="(probe, id) in runtime.endpoints" :key="id" class="rounded-md px-2 py-1 text-xs" :class="probe.ok ? 'bg-emerald-50 text-emerald-700 dark:bg-emerald-950/40 dark:text-emerald-300' : 'bg-red-50 text-red-700 dark:bg-red-950/40 dark:text-red-300'">
{{ id }} · {{ probe.status }} · {{ probe.latency_ms }} ms
</span>
</div>
@@ -99,13 +72,42 @@
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import type { PromptAuditRuntime } from '../types'
defineProps<{ runtime: PromptAuditRuntime | null; loading: boolean; error: string }>()
const props = defineProps<{ runtime: PromptAuditRuntime | null; loading: boolean; error: string }>()
defineEmits<{ (event: 'refresh'): void }>()
const { t, locale } = useI18n()
const statusItems = computed(() => {
const runtime = props.runtime
if (!runtime) return []
return [
{ label: t('admin.promptAudit.runtime.process'), value: t(`admin.promptAudit.status.${runtime.process_status}`), dot: statusDot(runtime.process_status) },
{ label: t('admin.promptAudit.runtime.mode'), value: t(`admin.promptAudit.mode.${runtime.effective_mode}`) },
{ label: t('admin.promptAudit.runtime.version'), value: `${runtime.active_config_version} / ${runtime.expected_config_version}` },
{ label: t('admin.promptAudit.runtime.workers'), value: `${runtime.worker_active} / ${runtime.worker_total}` },
{ label: t('admin.promptAudit.runtime.queue'), value: `${runtime.queue.active} / ${runtime.queue_capacity}` },
{ label: t('admin.promptAudit.runtime.dependencies'), value: `DB ${runtime.database_status} · Redis ${runtime.redis_status}` },
]
})
const guardMetricItems = computed(() => {
const metrics = props.runtime?.guard_metrics
if (!metrics) return []
return [
{ label: t('admin.promptAudit.metrics.total'), value: metrics.total },
{ label: t('admin.promptAudit.metrics.allowed'), value: metrics.allowed },
{ label: t('admin.promptAudit.metrics.flagged'), value: metrics.flagged },
{ label: t('admin.promptAudit.metrics.blocked'), value: metrics.blocked },
{ label: t('admin.promptAudit.metrics.unavailable'), value: metrics.unavailable },
{ label: t('admin.promptAudit.metrics.timeouts'), value: metrics.timeouts },
{ label: t('admin.promptAudit.metrics.failovers'), value: metrics.failovers },
{ label: 'P95', value: metrics.latency_p95_ms != null ? `${metrics.latency_p95_ms} ms` : '—' },
]
})
function formatDate(value: string): string {
return new Intl.DateTimeFormat(locale.value, { dateStyle: 'medium', timeStyle: 'medium' }).format(new Date(value))
}
@@ -3,6 +3,7 @@ export default {
title: 'Prompt Audit',
description: 'Review user input asynchronously or block it synchronously through OpenAI-compatible Qwen3Guard nodes. Full prompts never enter the database or UI.',
configVersion: 'Config version v{version}',
tabs: { config: 'Configuration', events: 'Events' },
actions: { refresh: 'Refresh runtime', retry: 'Retry' },
common: { actions: 'Actions', never: 'Never' },
mode: { off: 'Off', async_audit: 'Async audit only', blocking: 'Synchronous audit and block' },
@@ -40,6 +41,7 @@ export default {
title: 'Audit events', description: 'Review redacted events by identity, route, risk, hash, and time.', decision: 'Decision', risk: 'Risk level', endpoint: 'Endpoint', groupId: 'Group ID', userId: 'User ID', apiKeyId: 'API Key ID', keyword: 'Keyword',
startAt: 'Start time', endAt: 'End time', deleteSelected: 'Delete selected ({count})', deleteByFilter: 'Delete by filter', deleteRangeHint: 'Filter deletion requires explicit start and end times and a server-generated preview.',
selectAll: 'Select all events on this page', selectEvent: 'Select event {id}', time: 'Time', identity: 'User / email / API Key', user: 'Username', email: 'User email', apiKey: 'API Key name', group: 'Group', route: 'Endpoint / model', result: 'Decision / risk', preview: 'Redacted preview', empty: 'No matching events.',
passEventsDisabled: '“Store Pass events” is off. Safe requests are still audited, but they do not appear in this list. Enable and save that option in Configuration to review redacted audit content for every request.', openConfiguration: 'Open configuration',
detailTitle: 'Prompt audit event details', tabs: { summary: 'Audit summary', risks: 'Specific risks', technical: 'Technical details' }, redactedPreview: 'Irreversible redacted preview', categories: 'Categories', model: 'Model', stage: 'Request stage', noRisks: 'No derived risk summaries for this event.',
deleteConfirmTitle: 'Delete audit events?', deleteConfirmMessage: 'This permanently deletes {count} events and eligible orphan jobs.', filterDeleteTitle: 'Confirm filter deletion', filterDeleteCount: 'The server snapshot matches {count} events.', snapshotMax: 'Snapshot maximum event ID', expiresAt: 'Confirmation token expires', filterDeleteWarning: 'Only events at or below the preview high-water mark are deleted. Newer events survive. Any filter change requires a new preview.', confirmFilterDelete: 'Permanently delete',
},
@@ -3,6 +3,7 @@ export default {
title: '提示词审计',
description: '通过 OpenAI 兼容 Qwen3Guard 节点异步复核或同步阻止用户输入;完整提示词不会进入数据库或页面。',
configVersion: '配置版本 v{version}',
tabs: { config: '配置', events: '事件' },
actions: { refresh: '刷新运行态', retry: '重试' },
common: { actions: '操作', never: '从未' },
mode: { off: '已关闭', async_audit: '异步只审计', blocking: '同步审计并阻止' },
@@ -40,6 +41,7 @@ export default {
title: '审计事件', description: '按身份、入口、风险、Hash 和时间复核脱敏事件。', decision: '判定', risk: '风险等级', endpoint: '入口', groupId: '分组 ID', userId: '用户 ID', apiKeyId: 'API Key ID', keyword: '关键词',
startAt: '开始时间', endAt: '结束时间', deleteSelected: '删除选中项({count})', deleteByFilter: '按筛选删除', deleteRangeHint: '按筛选删除必须明确选择开始和结束时间,并先取得服务端删除预览。',
selectAll: '选择当前页全部事件', selectEvent: '选择事件 {id}', time: '时间', identity: '用户 / 邮箱 / API Key', user: '用户名', email: '用户邮箱', apiKey: 'API Key 名称', group: '分组', route: '入口 / 模型', result: '判定 / 风险', preview: '脱敏预览', empty: '没有符合条件的事件。',
passEventsDisabled: '当前未开启“保存 Pass 事件”:安全请求仍会完成审计,但不会出现在事件列表中。若要查看每次请求的脱敏审计内容,请在配置中开启该选项并保存。', openConfiguration: '前往配置',
detailTitle: '提示词审计事件详情', tabs: { summary: '审计摘要', risks: '具体风险', technical: '技术信息' }, redactedPreview: '不可逆脱敏预览', categories: '分类', model: '模型', stage: '请求阶段', noRisks: '本事件没有派生风险摘要。',
deleteConfirmTitle: '删除审计事件?', deleteConfirmMessage: '将永久删除 {count} 条事件及符合条件的孤立任务。', filterDeleteTitle: '确认按筛选删除', filterDeleteCount: '服务端快照匹配 {count} 条事件。', snapshotMax: '快照最大事件 ID', expiresAt: '确认令牌过期时间', filterDeleteWarning: '只删除预览高水位内的事件;预览后产生的新事件会保留。筛选一旦变化,必须重新预览。', confirmFilterDelete: '确认永久删除',
},
+8
View File
@@ -170,6 +170,14 @@
@apply disabled:cursor-not-allowed disabled:bg-gray-100 dark:disabled:bg-dark-900;
}
.dark .input[type='date'],
.dark .input[type='time'],
.dark .input[type='datetime-local'],
.dark .input[type='month'],
.dark .input[type='week'] {
color-scheme: dark;
}
.input-error {
@apply border-red-500 focus:border-red-500 focus:ring-red-500/30;
}
@@ -493,16 +493,14 @@ scanner score 只用于展示排序,不得被解释为真实置信度阈值。
第一版不使用熔断器外部依赖;连续失败健康状态和冻结窗口可用模块内小状态机实现。若后续数据证明需要通用熔断库,另起 change。
### 14. 出站 HTTP Client 必须抵抗 SSRF 和重定向
### 14. 出站 HTTP Client 使用管理员配置的网络目标
保存、探测和实际调用共用同一校验:
- 仅 http/https。
- 禁止 userinfo、query、fragment。
- 禁止 link-local、multicast、unspecified、metadata host 和保留地址。
- 公网必须 HTTPS;HTTP 只允许 localhost 或显式私网 IP/受控内网域名。
- DNS 解析后在 DialContext 再检查每个 IP,降低 DNS rebinding 风险。
- 默认不跟随重定向。
- 仅接受结构有效的 http/https Base URL,并禁止会破坏固定 API 路径拼接的 userinfo、query、fragment。
- 私网、回环、link-local、metadata、保留地址及域名解析结果均不做目标类别拦截。
- HTTP 与 HTTPS 均可由管理员选择;使用标准 DialContext 和标准重定向行为。
- 节点目标的可信性、网络可达性和协议安全由管理员负责。
- 独立连接池、Dial/TLS/Header timeout、响应上限。
- 日志只记录 endpoint ID,不记录完整 URL。
@@ -688,7 +686,7 @@ prompt_audit.events_filter_deleted
- [PostgreSQL/Redis 非事务导致悬挂状态] → staging → Redis SET → queued 发布协议;staging 回收和 TTL 清理。
- [多实例重复消费或旧 Worker 覆盖新结果] → `FOR UPDATE SKIP LOCKED` 原子领取、递增 claim_version fencing token、processing 租约和带版本条件更新。
- [长提示词导致超时] → Unicode 分片、总 deadline、最新输入优先;Allow 必须完整覆盖,禁止部分结果放行。
- [SSRF 或凭据泄露] → 保存/探测/调用共用校验、DNS 后复检、禁止重定向、加密密文、日志/API allowlist、canary 泄露测试。
- [管理员配置的节点可访问服务端可达的任意网络目标] → 产品明确由管理员负责节点目标;继续使用加密密文、日志/API allowlist、响应上限和 canary 泄露测试保护凭据与数据。
- [手工接入多个 Handler 造成漏路由] → 将现有调用统一替换为 Coordinator 并增加静态/结构路由矩阵测试。
- [新模块仍反向侵入现有 service] → 新模块依赖现有端口;现有 ContentModerationService 不导入新模块,Handler 仅注入 Coordinator。
- [事件量过大] → 默认不保存 Pass,分页索引、分批删除;后续根据真实规模单独设计自动保留期。
@@ -32,22 +32,24 @@
- **THEN** 响应 MUST 包含成功状态、稳定错误码、HTTP 状态、耗时、是否可重试和检查时间
- **THEN** 响应 MUST NOT 回显 API Key
### Requirement: 审计节点凭据和出站地址必须受到安全保护
系统 MUST 使用现有 SecretEncryptor 加密持久化节点 API Key,并 MUST 对节点地址实施 SSRF、防重定向和响应体大小限制。完整凭据只允许短暂存在于管理员写入请求、前端未持久化输入内存、服务端解密内存和发往 Guard 的 Authorization Header;它们以及 URL query、提示词正文 MUST NOT 出现在日志、错误响应、管理读取响应或前端持久化/调试状态中。
### Requirement: 审计节点凭据必须受到安全保护且出站目标由管理员负责
系统 MUST 使用现有 SecretEncryptor 加密持久化节点 API Key,并 MUST 对响应体实施大小限制。节点地址及其网络目标由管理员自行配置和负责;系统 MUST NOT 按公网、私网、回环、link-local、元数据、保留地址或 DNS 解析结果阻止保存、探测和实际调用,也 MUST NOT 禁止 HTTP 或正常 HTTP 重定向。完整凭据只允许短暂存在于管理员写入请求、前端未持久化输入内存、服务端解密内存和发往 Guard 的 Authorization Header;它们以及 URL query、提示词正文 MUST NOT 出现在日志、错误响应、管理读取响应或前端持久化/调试状态中。
#### Scenario: 保存带 API Key 的节点
- **WHEN** 管理员保存一个包含 API Key 的节点
- **THEN** settings 中 MUST 只保存加密密文和是否已配置标记
- **THEN** 后续读取配置 MUST 只返回 `has_token=true` 或等价状态
#### Scenario: 保存受限出站地址
- **WHEN** Base URL 使用非 HTTP(S) scheme、包含 userinfo/query/fragment、指向 link-local/元数据/未指定/保留地址,或公网地址使用不安全 HTTP
- **THEN** 系统 MUST 拒绝保存和探测该节点
- **THEN** 系统 MUST 返回稳定且不包含敏感地址细节的校验错误码
#### Scenario: 保存管理员配置的内网或特殊地址
- **WHEN** Base URL 使用 HTTP(S) 且指向私网、回环、link-local、元数据、保留地址或解析到这些地址的域名
- **THEN** 系统 MUST 接受该节点配置并从服务端网络环境执行探测和实际调用
- **THEN** 系统 MUST NOT 对 DNS 结果进行地址类别拦截
#### Scenario: 节点返回重定向或超大响应
- **WHEN** Guard 返回 HTTP 重定向或超过配置上限的响应体
- **THEN** 系统 MUST 不跟随重定向并将响应判定为无效或不可用
- **WHEN** Guard 返回正常 HTTP 重定向
- **THEN** 系统 MUST 使用标准 HTTP 客户端行为跟随重定向
- **WHEN** Guard 返回超过配置上限的响应体
- **THEN** 系统 MUST 将响应判定为无效或不可用
### Requirement: 系统必须按协议提取用户输入提示词快照
系统 SHALL 从目标项目所有已支持、包含用户文本的模型入口提取提示词快照。快照 MUST 包含 request ID、user ID、用户名、用户邮箱、API key ID/名称、group ID/名称、provider、endpoint、protocol、model、提示词 Hash、脱敏预览、Unicode 字符数和消息数量;文本审计 MUST 优先扫描最新用户输入,同时完整覆盖需要审计的历史用户文本。
@@ -185,3 +185,16 @@
- [x] 14.12 定义 blocking 灰度准入阈值、告警阈值、值班检查步骤和一键关闭 blocking 的回滚手册
- [x] 14.13 更新 `verification.md`,为每条验收 Requirement 关联测试、日志、指标、SQL 或截图证据
- [x] 14.14 在实现偏离设计时先回写 proposal/design/specs/tasks,再继续编码,禁止让 OpenSpec 落后于代码
## 15. 管理员自主管理审计节点网络目标
- [x] 15.1 更新规格与设计,明确节点目标安全由管理员负责,不再实施地址类别、DNS 结果、HTTP 或重定向拦截
- [x] 15.2 移除 Base URL 私网/特殊地址限制和 DialContext DNS/IP 二次拦截,恢复标准重定向行为
- [x] 15.3 更新出站客户端测试,覆盖 HTTP、私网/特殊地址配置和重定向,并回归响应上限与凭据保护
- [x] 15.4 运行 OpenSpec 严格校验和 securityaudit 测试,并用真实内网节点探测验证
## 16. 优化审计池节点列表并重新部署
- [x] 16.1 将审计池改为紧凑、响应式的节点列表,修复开关与节点名称拥挤并强化状态、限制和操作层级
- [x] 16.2 回归 Prompt Audit 前端组件测试、类型检查和生产构建
- [x] 16.3 在 deploy 目录按现有 Compose 配置重建镜像、重启容器并验证页面和节点探测