From 205b5fd8beaf8e32621086419da5a742aca5fb0f Mon Sep 17 00:00:00 2001 From: john Date: Thu, 23 Jul 2026 22:53:15 +0800 Subject: [PATCH] feat: add system disclosure policy gate --- admin-bootstrap.mjs | 6 + admin-routes.mjs | 29 ++ admin-routes.test.mjs | 73 ++++ admin-server.mjs | 1 + agent-run-gateway.mjs | 65 +++- agent-run-gateway.test.mjs | 124 +++++++ direct-chat-service.mjs | 53 ++- direct-chat-service.test.mjs | 79 +++++ docs/system-disclosure-policy.md | 70 ++++ ops/src/App.tsx | 3 + ops/src/api/admin.ts | 34 ++ ops/src/components/AdminLayout.tsx | 1 + ops/src/pages/admin/SystemPolicyPage.tsx | 235 +++++++++++++ server.mjs | 7 + system-disclosure-policy.mjs | 412 +++++++++++++++++++++++ system-disclosure-policy.test.mjs | 176 ++++++++++ tkmind-proxy.mjs | 28 ++ tkmind-proxy.test.mjs | 87 +++++ wechat-mp.mjs | 43 +++ wechat-mp.test.mjs | 61 ++++ 20 files changed, 1585 insertions(+), 2 deletions(-) create mode 100644 docs/system-disclosure-policy.md create mode 100644 ops/src/pages/admin/SystemPolicyPage.tsx create mode 100644 system-disclosure-policy.mjs create mode 100644 system-disclosure-policy.test.mjs diff --git a/admin-bootstrap.mjs b/admin-bootstrap.mjs index c9aa089..9a1575b 100644 --- a/admin-bootstrap.mjs +++ b/admin-bootstrap.mjs @@ -20,6 +20,7 @@ import { createAssetGatewayConfigService } from './asset-gateway.mjs'; import { createImageMakeAdminConfigService } from './image-make-admin-config.mjs'; import { createMemoryV2AdminConfigService } from './memory-v2-admin-config.mjs'; import { createSkillRuntimeAdminConfigService } from './skill-runtime-admin-config.mjs'; +import { createSystemDisclosurePolicyService } from './system-disclosure-policy.mjs'; import { createMindSearchConfigService } from './mindsearch-config.mjs'; import { createWechatScheduleLlmConfigService } from './wechat-schedule-llm-config.mjs'; import { createPlazaPostService, formatPostRow } from './plaza-posts.mjs'; @@ -111,6 +112,10 @@ export async function createAdminServices(env = {}) { const mindSearchConfigService = createMindSearchConfigService(pool); await mindSearchConfigService.ensureSchema(); const skillRuntimeConfigService = createSkillRuntimeAdminConfigService(pool, { h5Root }); + const systemDisclosurePolicyService = createSystemDisclosurePolicyService(pool, { + autoRefresh: false, + }); + await systemDisclosurePolicyService.initialize(); const wechatScheduleLlmConfigService = createWechatScheduleLlmConfigService(pool); const adminSystemTestService = createAdminSystemTestService({ pool, @@ -152,6 +157,7 @@ export async function createAdminServices(env = {}) { memoryV2ConfigService, mindSearchConfigService, skillRuntimeConfigService, + systemDisclosurePolicyService, wechatScheduleLlmConfigService, adminSystemTestService, plazaPosts, diff --git a/admin-routes.mjs b/admin-routes.mjs index 2042c56..0a9b58f 100644 --- a/admin-routes.mjs +++ b/admin-routes.mjs @@ -32,6 +32,7 @@ function plazaRouteError(res, req, error) { * @param {object|null} deps.llmProviderService * @param {object|null} deps.memoryV2ConfigService * @param {object|null} deps.skillRuntimeConfigService + * @param {object|null} deps.systemDisclosurePolicyService * @param {object|null} deps.adminSystemTestService * @param {object|null} deps.plazaPosts * @param {object|null} deps.plazaOps @@ -48,6 +49,7 @@ export function createAdminApi({ memoryV2ConfigService, mindSearchConfigService, skillRuntimeConfigService, + systemDisclosurePolicyService, adminSystemTestService, plazaPosts, plazaOps, @@ -234,6 +236,33 @@ export function createAdminApi({ adminApi.put('/skill-runtime/config', requireAdmin, updateSkillRuntimeConfig); adminApi.patch('/skill-runtime/config', requireAdmin, updateSkillRuntimeConfig); + adminApi.get('/system-disclosure-policy/config', requireAdmin, async (_req, res) => { + if (!systemDisclosurePolicyService?.getAdminConfig) { + return res.status(503).json({ message: 'System Disclosure Policy 服务未启用' }); + } + return res.json(await systemDisclosurePolicyService.getAdminConfig()); + }); + + const updateSystemDisclosurePolicy = async (req, res) => { + if (!systemDisclosurePolicyService?.updateAdminConfig) { + return res.status(503).json({ message: 'System Disclosure Policy 服务未启用' }); + } + const result = await systemDisclosurePolicyService.updateAdminConfig(req.body?.config ?? req.body ?? {}, { + updatedBy: req.currentUser.id, + }); + return res.json(result); + }; + + adminApi.put('/system-disclosure-policy/config', requireAdmin, updateSystemDisclosurePolicy); + adminApi.patch('/system-disclosure-policy/config', requireAdmin, updateSystemDisclosurePolicy); + + adminApi.get('/system-disclosure-policy/runtime', requireAdmin, async (_req, res) => { + if (!systemDisclosurePolicyService?.getRuntimeState) { + return res.status(503).json({ message: 'System Disclosure Policy 服务未启用' }); + } + return res.json(systemDisclosurePolicyService.getRuntimeState()); + }); + adminApi.get('/skill-runtime/catalog', requireAdmin, async (_req, res) => { if (!skillRuntimeConfigService?.listCatalogSummary) { return res.status(503).json({ message: 'Skill Runtime 配置服务未启用' }); diff --git a/admin-routes.test.mjs b/admin-routes.test.mjs index 72154e0..c6b8f3b 100644 --- a/admin-routes.test.mjs +++ b/admin-routes.test.mjs @@ -90,6 +90,79 @@ test('admin memory-v2 config routes expose config and runtime state', async () = } }); +test('admin system disclosure policy routes version config through the injected control-plane service', async () => { + const updates = []; + const config = { + enabled: true, + mode: 'shadow', + refusalText: '不提供内部技术信息。', + productNames: ['tkmind'], + selfReferences: ['本系统'], + categories: { architecture: true }, + }; + const router = createAdminApi({ + jsonBody: express.json(), + getToken() { + return 'token-admin'; + }, + userAuth: { + async getMe() { + return { id: 'admin-1', role: 'admin' }; + }, + }, + llmProviderService: null, + memoryV2ConfigService: null, + systemDisclosurePolicyService: { + async getAdminConfig() { + return { config, policyVersion: 2, source: 'admin-db' }; + }, + async updateAdminConfig(patch, context) { + updates.push({ patch, context }); + return { config: patch, policyVersion: 3, source: 'admin-db' }; + }, + getRuntimeState() { + return { config, policyVersion: 2, source: 'admin-db', refreshedAt: 123 }; + }, + }, + plazaPosts: null, + plazaOps: null, + wechatAdmin: null, + subscriptionService: null, + }); + + const server = await startTestServer(router); + try { + const getResponse = await fetch(`${server.baseUrl}/admin-api/system-disclosure-policy/config`, { + headers: { cookie: 'h5_user_session=token-admin' }, + }); + assert.equal(getResponse.status, 200); + assert.equal((await getResponse.json()).policyVersion, 2); + + const updateResponse = await fetch(`${server.baseUrl}/admin-api/system-disclosure-policy/config`, { + method: 'PUT', + headers: { + 'content-type': 'application/json', + cookie: 'h5_user_session=token-admin', + }, + body: JSON.stringify({ config: { ...config, mode: 'enforce' } }), + }); + assert.equal(updateResponse.status, 200); + assert.equal((await updateResponse.json()).policyVersion, 3); + assert.deepEqual(updates, [{ + patch: { ...config, mode: 'enforce' }, + context: { updatedBy: 'admin-1' }, + }]); + + const runtimeResponse = await fetch(`${server.baseUrl}/admin-api/system-disclosure-policy/runtime`, { + headers: { cookie: 'h5_user_session=token-admin' }, + }); + assert.equal(runtimeResponse.status, 200); + assert.equal((await runtimeResponse.json()).refreshedAt, 123); + } finally { + await server.close(); + } +}); + test('admin MindSearch routes persist only through injected control-plane service', async () => { const updates = []; const router = createAdminApi({ diff --git a/admin-server.mjs b/admin-server.mjs index 059c308..fc0edb2 100644 --- a/admin-server.mjs +++ b/admin-server.mjs @@ -80,6 +80,7 @@ const CONSOLES = { imageMakeAdminConfigService: services.imageMakeAdminConfigService, memoryV2ConfigService: services.memoryV2ConfigService, mindSearchConfigService: services.mindSearchConfigService, + systemDisclosurePolicyService: services.systemDisclosurePolicyService, adminSystemTestService: services.adminSystemTestService, plazaPosts: services.plazaPosts, plazaOps: services.plazaOps, diff --git a/agent-run-gateway.mjs b/agent-run-gateway.mjs index cfc44c8..f465954 100644 --- a/agent-run-gateway.mjs +++ b/agent-run-gateway.mjs @@ -283,6 +283,7 @@ export function createAgentRunGateway({ tkmindProxy, toolGateway = null, directChatService = null, + systemDisclosurePolicyService = null, chatIntentRouter = null, sessionSnapshotService = null, conversationMemoryService = null, @@ -596,6 +597,60 @@ export function createAgentRunGateway({ let userMessage = safeJsonParse(row.user_message_json, {}); const runOptions = getRunOptionsFromMessage(userMessage); const toolGatewayStatus = toolGateway?.getStatus ? toolGateway.getStatus() : null; + let disclosureDecision = null; + try { + disclosureDecision = systemDisclosurePolicyService?.evaluate?.({ + userMessage, + channel: 'h5', + userId: row.user_id, + sessionId: row.agent_session_id ?? null, + }) ?? null; + } catch (err) { + console.warn( + '[AgentRun] system disclosure policy evaluation failed open:', + err instanceof Error ? err.message : err, + ); + } + if (disclosureDecision?.enforced) { + if (!directChatService?.respondDeterministically) { + const error = new Error('System Disclosure Policy refusal service unavailable'); + error.code = 'SYSTEM_DISCLOSURE_REFUSAL_UNAVAILABLE'; + error.retryable = false; + throw error; + } + const result = await directChatService.respondDeterministically({ + userId: row.user_id, + sessionId: row.agent_session_id ?? null, + requestId: row.request_id, + userMessage, + reply: disclosureDecision.responseText, + metadata: { + policyId: disclosureDecision.policyId, + policyVersion: disclosureDecision.policyVersion, + policyReasonCode: disclosureDecision.reasonCode, + }, + onSessionReady: async (activeSessionId) => { + await pool.query( + `UPDATE h5_agent_runs SET agent_session_id = ?, updated_at = ? WHERE id = ?`, + [activeSessionId, nowMs(), runId], + ); + await appendRunSnapshot(runId); + }, + }); + await appendEvent(runId, 'system_disclosure_blocked', { + policyId: disclosureDecision.policyId, + policyVersion: disclosureDecision.policyVersion, + reasonCode: disclosureDecision.reasonCode, + categories: disclosureDecision.categories, + channel: 'h5', + }); + return { + sessionId: result.sessionId, + routing: null, + toolEvidence: null, + policyBlocked: true, + }; + } const routing = await resolveRunRouting(row, userMessage, runOptions); const routingDecision = resolveLegacyRouteFromClassification(routing) ?? routing?.route ?? null; let agentMemoryContext = null; @@ -867,8 +922,16 @@ export function createAgentRunGateway({ runId, row, sessionId, - { routing = null, toolEvidence = null } = {}, + { routing = null, toolEvidence = null, policyBlocked = false } = {}, ) { + if (policyBlocked) { + await markRun(runId, 'succeeded', { + agent_session_id: sessionId, + completed_at: nowMs(), + error_message: null, + }, { expectedStatus: 'running' }); + return; + } assertRequiredImageGenerationCompleted(row, routing, toolEvidence); // `row` was loaded before this worker claimed the run, so its started_at // can still be null. Refresh it before scoping workspace files to the diff --git a/agent-run-gateway.test.mjs b/agent-run-gateway.test.mjs index d115eee..6ec4ad4 100644 --- a/agent-run-gateway.test.mjs +++ b/agent-run-gateway.test.mjs @@ -401,6 +401,130 @@ test('agent run starts a session and marks submitted reply as succeeded', async assert.equal(pool.runs.get(run.id).attempts, 1); }); +test('agent run policy allow path preserves existing routing and submission behavior', async () => { + const pool = createFakePool(); + const submitted = []; + const evaluated = []; + const routed = []; + const gateway = createAgentRunGateway({ + pool, + userAuth: {}, + systemDisclosurePolicyService: { + evaluate(input) { + evaluated.push(input); + return { action: 'allow', matched: false, enforced: false }; + }, + }, + chatIntentRouter: { + async classify(input) { + routed.push(input); + return { route: 'agent_orchestration', reason: 'existing route' }; + }, + applyAgentOrchestration(message) { + return message; + }, + }, + tkmindProxy: { + async startSessionForUser() { + return { id: 'session-policy-allow' }; + }, + async submitSessionReplyForUser(userId, sessionId, requestId, userMessage) { + submitted.push({ userId, sessionId, requestId, userMessage }); + }, + }, + retryDelaysMs: [], + }); + + const run = await gateway.createRun('user-1', { + requestId: 'req-policy-allow', + userMessage: { + role: 'user', + content: [{ type: 'text', text: '请帮我安排明天的计划' }], + metadata: { displayText: '请帮我安排明天的计划' }, + }, + }); + await waitFor(() => pool.runs.get(run.id)?.status === 'succeeded'); + + assert.equal(evaluated.length, 1); + assert.equal(routed.length, 1); + assert.equal(submitted.length, 1); + assert.equal(submitted[0].sessionId, 'session-policy-allow'); + assert.equal(submitted[0].userMessage.metadata.displayText, '请帮我安排明天的计划'); +}); + +test('agent run enforced disclosure decision returns deterministic refusal before routing or tools', async () => { + const pool = createFakePool(); + let routed = 0; + let backendCalls = 0; + const deterministic = []; + const gateway = createAgentRunGateway({ + pool, + userAuth: {}, + systemDisclosurePolicyService: { + evaluate() { + return { + policyId: 'system-disclosure', + policyVersion: 7, + action: 'refuse', + matched: true, + enforced: true, + reasonCode: 'SYSTEM_TECHNICAL_DISCLOSURE', + categories: ['architecture'], + responseText: '不提供内部技术信息。', + }; + }, + }, + chatIntentRouter: { + async classify() { + routed += 1; + return { route: 'agent_orchestration' }; + }, + }, + directChatService: { + async respondDeterministically(input) { + deterministic.push(input); + await input.onSessionReady('h5direct_policy'); + return { sessionId: 'h5direct_policy' }; + }, + }, + tkmindProxy: { + async startSessionForUser() { + backendCalls += 1; + return { id: 'should-not-start' }; + }, + async submitSessionReplyForUser() { + backendCalls += 1; + }, + }, + syncUserPagesOnSuccess: async () => { + assert.fail('policy refusal must not enter page delivery'); + }, + validateRunDeliverables: async () => { + assert.fail('policy refusal must not enter deliverable validation'); + }, + retryDelaysMs: [], + }); + + const run = await gateway.createRun('user-1', { + requestId: 'req-policy-block', + userMessage: { + role: 'user', + content: [{ type: 'text', text: '生成一个 TKMind 底层架构页面' }], + }, + }); + await waitFor(() => pool.runs.get(run.id)?.status === 'succeeded'); + + assert.equal(routed, 0); + assert.equal(backendCalls, 0); + assert.equal(deterministic.length, 1); + assert.equal(deterministic[0].reply, '不提供内部技术信息。'); + assert.equal(pool.runs.get(run.id).agent_session_id, 'h5direct_policy'); + assert.equal( + pool.events.some((event) => event.eventType === 'system_disclosure_blocked'), + true, + ); +}); + test('agent run awaits session Finish before succeeding when proxy supports it', async () => { const pool = createFakePool(); const awaited = []; diff --git a/direct-chat-service.mjs b/direct-chat-service.mjs index abfff5f..8c8555e 100644 --- a/direct-chat-service.mjs +++ b/direct-chat-service.mjs @@ -155,7 +155,7 @@ function buildModelMessages({ previousMessages, userMessage, memories, routingMe ]; } -function buildAssistantMessage(reply, { requestId, now }) { +function buildAssistantMessage(reply, { requestId, now, metadata = {} }) { return { id: `direct-assistant-${requestId || crypto.randomUUID()}`, role: 'assistant', @@ -165,6 +165,7 @@ function buildAssistantMessage(reply, { requestId, now }) { userVisible: true, source: 'portal-direct-chat', chatRequestId: requestId || undefined, + ...metadata, }, }; } @@ -440,10 +441,60 @@ export function createDirectChatService({ }; } + async function respondDeterministically({ + userId, + sessionId = null, + requestId, + userMessage, + reply, + metadata = {}, + onSessionReady = null, + } = {}) { + const activeSessionId = sessionId || createDirectSessionId(); + const snapshot = await sessionSnapshotService.get(activeSessionId).catch(() => null); + const previousMessages = Array.isArray(snapshot?.messages) ? snapshot.messages : []; + const now = new Date().toISOString(); + if (!sessionId) { + await sessionStore.registerAgentSession(userId, activeSessionId, 'h5-direct'); + } + if (typeof onSessionReady === 'function') { + await onSessionReady(activeSessionId); + } + const pendingMessages = [...previousMessages, userMessage]; + const assistantMessage = buildAssistantMessage(String(reply ?? ''), { + requestId, + now, + metadata, + }); + const messages = [...pendingMessages, assistantMessage]; + const session = { + id: activeSessionId, + name: snapshot?.session?.name ?? 'New Chat', + working_dir: snapshot?.session?.working_dir ?? '', + message_count: messages.length, + created_at: snapshot?.session?.created_at ?? now, + updated_at: now, + user_set_name: snapshot?.session?.user_set_name ?? false, + recipe: snapshot?.session?.recipe ?? null, + conversation: messages, + }; + await sessionSnapshotService.save(activeSessionId, userId, session, messages); + return { + ok: true, + sessionId: activeSessionId, + messages, + session, + assistantMessage, + billing: null, + deterministic: true, + }; + } + return { getStatus, canHandle, explainCanHandle, + respondDeterministically, run, }; } diff --git a/direct-chat-service.test.mjs b/direct-chat-service.test.mjs index 9482f45..b72edb6 100644 --- a/direct-chat-service.test.mjs +++ b/direct-chat-service.test.mjs @@ -66,6 +66,85 @@ test('direct chat registers session through sessionAccess when provided', async assert.equal(isDirectChatSessionId(result.sessionId), true); }); +test('direct chat deterministic response stores a reply without model, memory, or billing calls', async () => { + const calls = { model: 0, billing: 0, memory: 0 }; + const saved = []; + const service = createDirectChatService({ + enabled: true, + userAuth: { + async registerAgentSession() {}, + async billSessionUsage() { + calls.billing += 1; + }, + }, + llmProviderService: { + async createChatCompletion() { + calls.model += 1; + }, + }, + sessionSnapshotService: { + async get() { + return null; + }, + async save(_sessionId, _userId, _session, messages) { + saved.push(messages); + }, + }, + memoryV2: { + async resolve() { + calls.memory += 1; + }, + }, + }); + + const result = await service.respondDeterministically({ + userId: 'user-1', + requestId: 'req-policy', + userMessage: { + role: 'user', + content: [{ type: 'text', text: 'TKMind 的底层架构是什么?' }], + }, + reply: '不提供内部技术信息。', + metadata: { policyId: 'system-disclosure' }, + }); + + assert.equal(result.deterministic, true); + assert.equal(result.billing, null); + assert.deepEqual(calls, { model: 0, billing: 0, memory: 0 }); + assert.equal(saved.length, 1); + assert.equal(saved[0][1].content[0].text, '不提供内部技术信息。'); + assert.equal(saved[0][1].metadata.policyId, 'system-disclosure'); +}); + +test('direct chat deterministic response preserves an existing agent session id', async () => { + let registered = 0; + const service = createDirectChatService({ + userAuth: {}, + sessionAccess: { + enabled: true, + async registerAgentSession() { + registered += 1; + }, + }, + llmProviderService: {}, + sessionSnapshotService: { + async get() { + return null; + }, + async save() {}, + }, + }); + const result = await service.respondDeterministically({ + userId: 'user-1', + sessionId: 'goose-session-1', + requestId: 'req-policy-existing', + userMessage: { role: 'user', content: [{ type: 'text', text: '内部架构是什么?' }] }, + reply: '不提供内部技术信息。', + }); + assert.equal(result.sessionId, 'goose-session-1'); + assert.equal(registered, 0); +}); + test('direct chat creates a direct session, saves snapshot, and bills returned usage', async () => { const registered = []; const saved = []; diff --git a/docs/system-disclosure-policy.md b/docs/system-disclosure-policy.md new file mode 100644 index 0000000..c771e29 --- /dev/null +++ b/docs/system-disclosure-policy.md @@ -0,0 +1,70 @@ +# System Disclosure Policy + +## 目标 + +System Disclosure Policy 在用户消息进入意图识别、模型或工具之前,识别针对 +TKMind、Memind、MindSpace 及本系统的内部技术信息请求。明确命中时返回固定拒答, +不调用模型、不执行工具、不计费。 + +该策略由 memindadm 的「策略中心」管理,Portal 运行时只读取内存快照。后台配置与 +运行时执行分离,避免每条用户消息查询数据库。 + +## Zero-Impact Allow Invariant + +未命中请求必须满足: + +1. 不修改用户消息或 metadata。 +2. 不注入 System Prompt、Developer Prompt 或路由前缀。 +3. 不改变意图识别、模型选择、Session、Agent Run、工具与发布流程。 +4. 不增加模型调用或计费。 +5. 策略判断异常时保留现有流程(fail open),配置刷新异常时继续使用最后一个有效快照。 + +任何修改策略接入点的提交,都必须用测试证明 allow 路径仍进入原路由和提交逻辑。 + +## 执行位置 + +- H5:`agent-run-gateway.mjs` 的 `executeRun`,位于 `resolveRunRouting` 之前。 +- 微信服务号:`wechat-mp.mjs`,位于日程分流和 Agent Session 创建之前。 +- Goose 后备闸门:`tkmind-proxy.mjs` 的 `prepareSessionReplyBody`,位于鉴权、 + 计费检查、Session reconcile 和模型调用之前。 + +H5 拒答通过 direct-chat snapshot 写入确定性助手消息,保持现有聊天事件与历史记录 +交付方式;拒答路径跳过页面交付检查和个人记忆观察。 + +## 运行模式 + +- `off`:关闭判断。 +- `shadow`:只计算命中,不改变结果。默认模式。 +- `enforce`:明确命中后固定拒答。 + +配置以版本号写入 `h5_system_disclosure_policy_config`。Portal 启动时加载,随后定时 +原子替换内存快照;刷新失败时继续使用 last-known-good。 + +## 当前规则边界 + +只有「本系统指向」和「内部技术类别」同时命中才拒答: + +- 本系统指向:产品名(TKMind、Memind、MindSpace、智趣)或“本系统”“你们平台”等自指。 +- 技术类别:架构实现、模型路由、提示词记忆、工具扩展、数据部署、安全边界。 + +一般性技术讨论以及公开功能使用问题继续放行,例如: + +- “如何设计一个多 Agent 系统架构?” +- “MindSpace 页面怎么发布和分享?” + +第一阶段不把法律合规规则混入本模块。后续可以在 Trust & Policy Center 增加独立 +policy domain,并复用相同的只读判断、版本、shadow、enforce 和回退契约。 + +## 验证 + +```bash +node --test \ + system-disclosure-policy.test.mjs \ + direct-chat-service.test.mjs \ + agent-run-gateway.test.mjs \ + admin-routes.test.mjs \ + wechat-mp.test.mjs \ + tkmind-proxy.test.mjs + +cd ops && npm run build +``` diff --git a/ops/src/App.tsx b/ops/src/App.tsx index ef25bc3..43138e3 100644 --- a/ops/src/App.tsx +++ b/ops/src/App.tsx @@ -11,6 +11,8 @@ import { ReviewPage } from './pages/ReviewPage'; import { SummaryPage } from './pages/admin/SummaryPage'; import { UsersPage } from './pages/admin/UsersPage'; import { BillingPage } from './pages/admin/BillingPage'; +import { WechatPage } from './pages/admin/WechatPage'; +import { SystemPolicyPage } from './pages/admin/SystemPolicyPage'; export function App() { return ( @@ -43,6 +45,7 @@ export function App() { } /> } /> } /> + } /> } /> diff --git a/ops/src/api/admin.ts b/ops/src/api/admin.ts index 330fbf7..68c7c16 100644 --- a/ops/src/api/admin.ts +++ b/ops/src/api/admin.ts @@ -177,12 +177,46 @@ export type CreateWechatNotificationPayload = { channels?: Array<'web' | 'wechat'>; }; +export type SystemDisclosureMode = 'off' | 'shadow' | 'enforce'; + +export type SystemDisclosurePolicyConfig = { + enabled: boolean; + mode: SystemDisclosureMode; + refusalText: string; + productNames: string[]; + selfReferences: string[]; + categories: Record; +}; + +export type SystemDisclosurePolicyState = { + config: SystemDisclosurePolicyConfig; + policyVersion: number; + updatedBy: string | null; + updatedAt: number | null; + source: string; + refreshedAt?: number | null; + lastRefreshError?: string | null; +}; + // ─── Summary ────────────────────────────────────────────────────────────────── export async function fetchAdminSummary() { return adminFetch<{ summary: AdminSummary }>('/admin-api/summary'); } +// ─── Trust & Policy ─────────────────────────────────────────────────────────── + +export async function fetchSystemDisclosurePolicy() { + return adminFetch('/admin-api/system-disclosure-policy/config'); +} + +export async function updateSystemDisclosurePolicy(config: SystemDisclosurePolicyConfig) { + return adminFetch('/admin-api/system-disclosure-policy/config', { + method: 'PUT', + body: JSON.stringify({ config }), + }); +} + // ─── Users ──────────────────────────────────────────────────────────────────── export async function fetchAdminUsers(params: { diff --git a/ops/src/components/AdminLayout.tsx b/ops/src/components/AdminLayout.tsx index 94ae4d2..31fe7d4 100644 --- a/ops/src/components/AdminLayout.tsx +++ b/ops/src/components/AdminLayout.tsx @@ -5,6 +5,7 @@ const links = [ { to: '/admin/users', label: '用户管理' }, { to: '/admin/billing', label: '账单记录' }, { to: '/admin/wechat', label: '服务号管理' }, + { to: '/admin/policy', label: '策略中心' }, ]; export function AdminLayout() { diff --git a/ops/src/pages/admin/SystemPolicyPage.tsx b/ops/src/pages/admin/SystemPolicyPage.tsx new file mode 100644 index 0000000..1823278 --- /dev/null +++ b/ops/src/pages/admin/SystemPolicyPage.tsx @@ -0,0 +1,235 @@ +import { useEffect, useMemo, useState } from 'react'; +import { + fetchSystemDisclosurePolicy, + updateSystemDisclosurePolicy, + type SystemDisclosurePolicyConfig, + type SystemDisclosurePolicyState, +} from '../../api/admin'; + +const CATEGORY_LABELS: Record = { + architecture: { + title: '架构与内部实现', + description: '底层架构、运行机制、服务设计与内部实现细节。', + }, + model_and_routing: { + title: '模型与路由', + description: '模型供应商、模型选择、意图路由与 Agent 编排。', + }, + prompts_and_memory: { + title: '提示词与记忆', + description: '系统提示词、上下文拼接、记忆注入与召回机制。', + }, + tools_and_extensions: { + title: '工具与扩展', + description: '内部工具、插件、Skill、MCP 清单和调用方式。', + }, + data_and_deployment: { + title: '数据与部署', + description: '数据库结构、存储位置、部署拓扑、主机与端口。', + }, + security_boundaries: { + title: '安全边界', + description: '沙箱、鉴权、权限策略、安全实现及绕过方式。', + }, +}; + +function listToText(values: string[]) { + return values.join('\n'); +} + +function textToList(value: string) { + return [...new Set(value.split(/\r?\n|,/).map((item) => item.trim()).filter(Boolean))]; +} + +function formatTime(value?: number | null) { + return value ? new Date(value).toLocaleString('zh-CN', { hour12: false }) : '尚未保存'; +} + +export function SystemPolicyPage() { + const [state, setState] = useState(null); + const [draft, setDraft] = useState(null); + const [productNames, setProductNames] = useState(''); + const [selfReferences, setSelfReferences] = useState(''); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + const [notice, setNotice] = useState(null); + + const load = async () => { + setLoading(true); + setError(null); + try { + const result = await fetchSystemDisclosurePolicy(); + setState(result); + setDraft(result.config); + setProductNames(listToText(result.config.productNames)); + setSelfReferences(listToText(result.config.selfReferences)); + } catch (err) { + setError(err instanceof Error ? err.message : '加载失败'); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + void load(); + }, []); + + const dirty = useMemo(() => { + if (!state || !draft) return false; + return JSON.stringify({ + ...draft, + productNames: textToList(productNames), + selfReferences: textToList(selfReferences), + }) !== JSON.stringify(state.config); + }, [draft, productNames, selfReferences, state]); + + const save = async () => { + if (!draft) return; + if ( + draft.mode === 'enforce' + && !window.confirm('确认进入强制执行模式?明确命中的请求将直接固定拒答,不再进入意图识别和模型。') + ) { + return; + } + setSaving(true); + setError(null); + setNotice(null); + try { + const result = await updateSystemDisclosurePolicy({ + ...draft, + productNames: textToList(productNames), + selfReferences: textToList(selfReferences), + }); + setState(result); + setDraft(result.config); + setProductNames(listToText(result.config.productNames)); + setSelfReferences(listToText(result.config.selfReferences)); + setNotice(`策略版本 v${result.policyVersion} 已保存。Portal 将通过内存快照自动刷新。`); + } catch (err) { + setError(err instanceof Error ? err.message : '保存失败'); + } finally { + setSaving(false); + } + }; + + if (loading) return

加载中…

; + if (!draft || !state) return

{error ?? '策略配置不可用'}

; + + return ( +
+
+

Trust & Policy Center

+

+ System Disclosure Policy 采用旁路式只读判断。未命中请求保持原消息、原意图识别和原执行流程不变。 +

+
+ + {error ?

{error}

: null} + {notice ?

{notice}

: null} + +
+
+ +
+ Zero-Impact Allow Invariant +

+ Allow 路径不修改消息、不注入 Prompt、不改变路由、不增加模型调用。策略配置异常时保留最后一个有效快照。 +

+
+
+
+ 当前版本:v{state.policyVersion} · 来源:{state.source} · 更新时间:{formatTime(state.updatedAt)} +
+
+ +
+

受保护内容类别

+ {Object.entries(draft.categories).map(([key, enabled]) => { + const label = CATEGORY_LABELS[key] ?? { title: key, description: '' }; + return ( + + ); + })} +
+ +
+