feat(capability,gateway): 工具策略落盘 + 主代理代审真接线(裁决 1.1 核对/1.3/10.4.2/13.4.3)
- 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:
1 parent
ed5bd92e1b
commit
63236c9b00
20 files changed
+1709
-24
No files matched your search
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user