feat(capability,gateway): 工具策略落盘 + 主代理代审真接线(裁决 1.1 核对/1.3/10.4.2/13.4.3)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

- 1.1(核对):registry 默认两布尔 false 已一致,零代码改动(设计文档已按裁决改写)
- 1.3 + 10.4.2:新增迁移 0012_tool_policy(tool_policies 表,表名复数 + idx_tool_policies_name);
  新增 registry.PolicyStore/SQLitePolicyStore,启动 Load 覆盖注册初值、SetPolicy 先落库后改内存
  (落盘失败保持旧值并报错);面板保存路径不变但从此重启仍在
- 13.4.3:代审真接线 —— restrict.SensitiveCall 结构化判定(terminal / file write / http POST)、
  restrict.Gate/Reviewer/Resolver(无代审者或代审报错一律 fail-closed 拦截),
  组合根装进 loop.Options.Hooks 的 CallAdmit;gateway.RecordApproval 落 audit_logs
  (who=角色名 / what=approval.<工具> / result=granted|denied)并广播 approval_notice 帧
- 前端:代审小字「xxx 想要干什么,我审查后批准了」进聊天流顶部(帧→store→组件,文案单一来源
  复用 audit.Approval.Explain()),附纯 node 逻辑测试
- 复核:迁移在 data/cyrene.db **副本**上 up/down/up 各一次通过;TestRegistry_PolicyStoreSurvivesRestart
  与 TestAdminSetPolicySurvivesRestart 证明「重启后策略仍在」;terminal 触发代审 + 审计有行有端到端断言
This commit is contained in:
AskaEth committed 2026-10-04 12:30:16 +08:00
1 parent ed5bd92e1b
commit 63236c9b00
20 files changed
+1709 -24

No files matched your search

+24
View File
@@ -123,3 +123,27 @@ export function SessionStatusHint(): React.ReactNode | null {
</div>
);
}
/** 主代理代审小字(design/11 §4;approval_notice 帧,裁决 13.4.3):敏感操作
* (如 terminal 执行命令)经主代理代审后,在聊天流里显示一行小字
* 「xxx 想要干什么,我审查后批准了」;被拦截时同一处显示「我审查后拒绝了」。
* 文案由后端产出(前端不自己拼),`detail` 作为副文本展示「想干什么」。
* 纯展示、无副作用;未收到代审事件时不渲染。 */
export function ApprovalNoticeHint(): React.ReactNode | null {
const notice = useAppStore((s) => s.approvalNotice);
if (!notice) return null;
return (
<div className="mu-rise-in flex flex-wrap items-center gap-2 rounded-[var(--mu-radius)] border border-[var(--mu-glass-border)] bg-[var(--mu-surface-2)]/70 px-3 py-1.5 text-xs">
<Icon
name="shield"
size={13}
className={`shrink-0 ${notice.allowed ? "text-[var(--mu-accent)]" : "text-[var(--mu-warn)]"}`}
/>
<span className="min-w-0 text-[var(--mu-text-mid)]">{notice.explain}</span>
{notice.detail ? (
<span className="min-w-0 truncate text-[var(--mu-text-lo)]">{notice.detail}</span>
) : null}
</div>
);
}
+30 -1
View File
@@ -23,6 +23,34 @@ export interface SessionStatePayload {
pendingCount?: number;
}
/** approval_notice 下行事件 payload(design/11 §4 主代理代审,裁决 13.4.3):
* 敏感操作经代审后的记录。`explain` 是后端产出的固定文案
* 「xxx 想要干什么,我审查后批准了 / 拒绝了」,前端原样渲染成小字;
* `detail` 是动作摘要(如 "terminal echo hi"),可作副文本。`allowed`
* 为 false 表示该敏感调用被拦截(未执行)。这条帧属内容帧,只发给
* owner/admin 连接(design/11 §7.1 帧门),viewer 收不到。 */
export interface ApprovalNoticePayload {
actor?: string;
tool?: string;
op?: string;
detail?: string;
explain?: string;
allowed?: boolean;
at?: string;
}
/** 已归一化的代审记录(store 与组件用):`explain` 必定非空(后端给不出
* 文案时前端不渲染,也不编造一句)。 */
export interface ApprovalNotice {
explain: string;
actor: string;
tool: string;
op: string;
detail: string;
allowed: boolean;
at: string;
}
/**
* Downstream WS frame (server → client). `data` is intentionally `unknown`;
* every consumer narrows it. The backend emits `{type, data, turnId?, seq?,
@@ -31,7 +59,8 @@ export interface SessionStatePayload {
* (design/04 §1.1), plus the legacy stream_chunk / stream_end / thinking / mood /
* tool_used / subagent_event / graph_update / reminder / device_update / auth /
* error / task_output, and the capacity/session events from design/14 §5 + 15 §5:
* capacity_awaken (状态切换+点亮工具), session_busy / session_pending (她是否在忙+挂起消息数).
* capacity_awaken (状态切换+点亮工具), session_busy / session_pending (她是否在忙+挂起消息数),
* plus approval_notice (design/11 §4 主代理代审小字).
*
* `sessionId` (design/16 §4.2) scopes the frame to one conversation. The hub
* broadcasts globally — every connection of the owner sees every session — so the
+2
View File
@@ -10,6 +10,7 @@ import { handleMessageEvent } from "./ws/handlers/message";
import { handleAgentEvent } from "./ws/handlers/agent";
import { handleSessionEvent } from "./ws/handlers/session";
import { handleDeviceEvent } from "./ws/handlers/device";
import { handleApprovalEvent } from "./ws/handlers/approval";
/**
* Realtime client for `/ws/chat`. Reconnects with exponential backoff and
@@ -49,6 +50,7 @@ export function dispatchFrame(ev: { type: string; sessionId?: string; data: unkn
if (handleAgentEvent(ev, r, s)) return;
if (handleSessionEvent(ev, r, s)) return;
if (handleDeviceEvent(ev, r, s)) return;
if (handleApprovalEvent(ev, r, s)) return;
// Unknown future event: keep it in the context log so no frame is lost.
const text = typeof ev.data === "string" ? ev.data : JSON.stringify(ev.data);
@@ -0,0 +1,88 @@
// 代审小字的逻辑测试(design/11 §4;approval_notice 帧,裁决 13.4.3)。
// 项目没有前端测试框架/jsdom,所以这是一个零依赖的纯 node 检查:它驱动真实的
// 帧解码器(lib/ws.ts 的 dispatchFrame)与真实的 store,断言
// ApprovalNoticeHint 组件所读的那几个字段,并在第一个失败处抛出。
//
// 跑法(用 vite 自带的 esbuild,不新增依赖、不改 package.json;banner 补上
// store 的 persist 中间件在 node 下探测的 window.localStorage):
//
// cd frontend/web \
// && ./node_modules/.bin/esbuild src/lib/ws/handlers/approval.test.ts \
// --bundle --platform=node --format=esm \
// --banner:js='globalThis.window={localStorage:{getItem:()=>null,setItem:()=>{},removeItem:()=>{}}};' \
// --outfile=/tmp/cyrene-approval.test.mjs \
// && node /tmp/cyrene-approval.test.mjs
//
// `tsc --noEmit`(npm run build 的一部分)会对本文件做类型检查,因此它
// 只从项目内导入、不 import node:*。
import { dispatchFrame } from "../../ws";
import { useAppStore } from "../../../store/useAppStore";
let checks = 0;
function ok(cond: boolean, what: string): void {
checks += 1;
if (!cond) throw new Error(`FAILED: ${what}`);
}
function same<T>(got: T, want: T, what: string): void {
ok(JSON.stringify(got) === JSON.stringify(want), `${what} — got ${JSON.stringify(got)}, want ${JSON.stringify(want)}`);
}
const store = useAppStore.getState();
// 激活会话 + 一条代审帧(后端 RecordApproval 广播的形状)。
store.setActiveSession("web");
store.setApprovalNotice(null);
dispatchFrame({
type: "approval_notice",
sessionId: "web",
data: {
actor: "昔涟",
tool: "terminal",
op: "执行命令",
detail: "terminal echo hi",
explain: "「昔涟 想要干什么,我审查后批准了」",
allowed: true,
at: "2026-10-04T12:00:00Z",
},
});
const notice = useAppStore.getState().approvalNotice;
ok(notice !== null, "approval_notice 帧应写入 approvalNotice(组件据此渲染小字)");
same(notice?.explain, "「昔涟 想要干什么,我审查后批准了」", "小字文案原样来自后端 explain");
same(notice?.detail, "terminal echo hi", "动作摘要作为副文本");
same(notice?.allowed, true, "批准态");
same(notice?.tool, "terminal", "工具名");
// 别的会话发生的代审不写进当前会话的小字(与流式帧同一作用域规则)。
store.setApprovalNotice(null);
dispatchFrame({
type: "approval_notice",
sessionId: "web:other",
data: { explain: "「别的角色 想要干什么,我审查后批准了」" },
});
ok(useAppStore.getState().approvalNotice === null, "别的会话的代审不得写进当前会话");
// 没有文案的帧被丢掉(前端不自己拼文案)。
store.setActiveSession("web");
dispatchFrame({ type: "approval_notice", sessionId: "web", data: { tool: "terminal" } });
ok(useAppStore.getState().approvalNotice === null, "没有 explain 的帧应被丢弃");
// 拒绝态:小字保留「拒绝了」的文案,组件按 allowed=false 换色/换图标。
dispatchFrame({
type: "approval_notice",
sessionId: "web",
data: { explain: "「昔涟 想要干什么,我审查后拒绝了」", allowed: false, detail: "terminal rm -rf /" },
});
const denied = useAppStore.getState().approvalNotice;
same(denied?.allowed, false, "拒绝态");
same(denied?.explain, "「昔涟 想要干什么,我审查后拒绝了」", "拒绝小字");
// 会话切换后重置(resetChat 清掉小字,避免上一段对话的代审留在新会话里)。
useAppStore.getState().resetChat();
ok(useAppStore.getState().approvalNotice === null, "resetChat 应清掉代审小字");
console.log(`approval notice: ${checks} checks passed`);
@@ -0,0 +1,27 @@
import { streamFrameApplies } from "../../streamScope";
import type { FrameEvent, FramePayload, StoreState } from "./shared";
/** 代审帧处理组:approval_notice —— 主代理代审小字(design/11 §4,裁决 13.4.3)。
* 敏感操作(如 terminal)经代审后后端广播该帧,`explain` 是后端产出的固定文案
* 「xxx 想要干什么,我审查后批准了 / 拒绝了」,前端原样渲染成聊天流里的一行小字。
* 命中返回 true,未命中返回 false 交给下一组。 */
export function handleApprovalEvent(ev: FrameEvent, r: FramePayload, s: StoreState): boolean {
if (ev.type !== "approval_notice") return false;
// 与流式帧同一作用域规则:别的会话发生的代审不写进当前会话的小字。
if (!streamFrameApplies(ev, r, s.activeSessionId)) return true;
// 没有文案就没有可渲染的小字 —— 丢掉这帧,而不是编造一句。
const explain = typeof r?.explain === "string" ? r.explain : "";
if (!explain) return true;
s.setApprovalNotice({
explain,
actor: typeof r?.actor === "string" ? r.actor : "",
tool: typeof r?.tool === "string" ? r.tool : "",
op: typeof r?.op === "string" ? r.op : "",
detail: typeof r?.detail === "string" ? r.detail : "",
// 后端只在整条代审失败/拒绝时发 allowed=false;缺字段按「已批准」处理,
// 与后端 RecordApproval 写入的布尔一致。
allowed: r?.allowed !== false,
at: typeof r?.at === "string" ? r.at : "",
});
return true;
}
+2 -1
View File
@@ -8,7 +8,7 @@ import { HistoryList } from "../components/messages/HistoryList";
import { ChatInput } from "../components/ChatInput";
import { EmptyState } from "../components/primitives";
import { Icon } from "../components/Icon";
import { SessionStatusHint } from "../components/status";
import { SessionStatusHint, ApprovalNoticeHint } from "../components/status";
import { personaChatBackground, type Orientation } from "../lib/persona";
/**
@@ -76,6 +76,7 @@ export function ChatShell(): React.ReactNode {
<div className="relative h-full overflow-y-auto">
<div className="mx-auto flex max-w-2xl flex-col gap-4 p-4">
<SessionStatusHint />
<ApprovalNoticeHint />
{messages.length === 0 ? (
<EmptyState
icon={<Icon name="chat" size={24} />}
+10
View File
@@ -3,6 +3,7 @@ import { persist } from "zustand/middleware";
import type {
AdminChannelMessage,
AgentInfo,
ApprovalNotice,
AwakenState,
BackthinkItem,
ChatMessage,
@@ -51,6 +52,10 @@ export interface AppStore {
/** 会话编排(design/15 §5)——她是否在忙 + 挂起消息数(不阻塞输入框)。 */
sessionBusy: boolean;
pendingCount: number;
/** 最近一次主代理代审小字(design/11 §4;`approval_notice` 帧,裁决 13.4.3):
* 敏感操作经代审后显示一行「xxx 想要干什么,我审查后批准了」。同一时刻只留
* 最近一条(新的覆盖旧的),且**不持久化** —— 代审是实时事件,刷新后不重放。 */
approvalNotice: ApprovalNotice | null;
/** 适配器消息日志实时事件(`channel_persisted` WS 帧,design/16 §4.6 §4)——
* 隔离会话(群聊/陌生人)落库后秒推,供 IM 适配器日志页增量插入。最新在前,
@@ -124,6 +129,8 @@ export interface AppStore {
tool?: string | null
) => void;
setSessionBusy: (busy: boolean, pendingCount?: number) => void;
/** 记录一条主代理代审小字(`approval_notice` WS 帧)。null 表示清除。 */
setApprovalNotice: (notice: ApprovalNotice | null) => void;
taskLogs: Record<string, string>;
appendTaskLog: (taskId: string, text: string) => void;
@@ -152,6 +159,7 @@ export const useAppStore = create<AppStore>()(
awakenTool: null,
sessionBusy: false,
pendingCount: 0,
approvalNotice: null,
personas: [],
taskLogs: {},
channelEvents: [],
@@ -371,6 +379,7 @@ export const useAppStore = create<AppStore>()(
sessionBusy: busy,
pendingCount: pendingCount ?? s.pendingCount,
})),
setApprovalNotice: (notice) => set({ approvalNotice: notice }),
appendTaskLog: (taskId, text) =>
set((s) => ({
@@ -404,6 +413,7 @@ export const useAppStore = create<AppStore>()(
awakenTool: null,
sessionBusy: false,
pendingCount: 0,
approvalNotice: null,
})),
}),
{