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
+113
-4
@@ -7,6 +7,8 @@ import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/restrict"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
|
||||
@@ -115,9 +117,11 @@ func (a *app) wireMemory(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// wireRuntime builds the model adapter, tool dispatcher, guardrails, the agent
|
||||
// and its loop, tracing, persistence and the orchestration scheduler.
|
||||
// wireRuntime 构建模型适配器、工具 dispatcher、护栏、agent 与其 loop、
|
||||
// 追踪、持久化与编排 scheduler。
|
||||
func (a *app) wireRuntime() error {
|
||||
a.attachToolPolicyStore()
|
||||
|
||||
// The LLM provider stack is hot-swappable: it loads models.json (or falls
|
||||
// back to .env) and the panel save endpoint reloads it without a restart
|
||||
// (design/04 §3.1). a.modelAdapter stays an Adapter (the runtime stack
|
||||
@@ -213,6 +217,13 @@ func (a *app) wireRuntime() error {
|
||||
// budget instead.
|
||||
thinking, reasoningEffort, thinkingShow := a.defaultThinkingOptions()
|
||||
|
||||
// 敏感操作代审(design/11 §4;裁决 13.4.3):terminal 这类敏感工具在 loop 的
|
||||
// 工具准入门上经主代理代审(复用 orchestration 的 Approver),再由网关把
|
||||
// 结果落进 audit_logs 并广播 design/11 §4 的小字。同一个 Approver 也交给
|
||||
// 编排层(子代理代审),使两条路径共用一处审计。
|
||||
approver := audit.NewApprover(a.log)
|
||||
approvalGate := a.buildApprovalGate(approver)
|
||||
|
||||
a.loop = loop.New(loop.Options{
|
||||
Agent: defaultAgent,
|
||||
Agents: agents,
|
||||
@@ -239,7 +250,10 @@ func (a *app) wireRuntime() error {
|
||||
Thinking: thinking,
|
||||
ReasoningEffort: reasoningEffort,
|
||||
ThinkingShowToUser: thinkingShow,
|
||||
Log: logger.WithSource(a.log, "runtime/loop"),
|
||||
// 按调用(而非整批)准入:敏感与否取决于该次调用的参数
|
||||
// (file 的 read 自主、write 代审),见 toolAdmissionHooks。
|
||||
Hooks: a.toolAdmissionHooks(approvalGate),
|
||||
Log: logger.WithSource(a.log, "runtime/loop"),
|
||||
})
|
||||
|
||||
a.scheduler = orchestration.New(orchestration.Options{
|
||||
@@ -258,13 +272,108 @@ func (a *app) wireRuntime() error {
|
||||
// session's live graph, so a branch shows up as a swimlane of that session's
|
||||
// flow instead of opening a second graph (design/19 §2).
|
||||
Flow: flowPub,
|
||||
Approver: audit.NewApprover(a.log),
|
||||
Approver: approver,
|
||||
Logger: logger.WithSource(a.log, "orchestration"),
|
||||
AgentID: defaultAgentID,
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
// attachToolPolicyStore 接上工具策略的落盘载体(design/11 §3;裁决 1.3/10.4.2)。
|
||||
//
|
||||
// 它必须在任何工具注册**之前**调用:registry 接到 store 时会立刻 Load 落盘
|
||||
// 策略并套到已注册能力上,之后 Register 进来的能力(MCP 热装、插件刷新)也会
|
||||
// 拿到落盘值——「重启后策略仍在」正是靠这两条路径合起来成立。
|
||||
//
|
||||
// 失败只记 WARN 并保留默认值:默认(两个布尔 false = owner-only)就是最严的
|
||||
// 一侧,读不出一份策略绝不放宽策略,也不因为策略存储不可用而拖垮启动
|
||||
// (写路径仍会诚实报错,不会假装保存成功)。
|
||||
func (a *app) attachToolPolicyStore() {
|
||||
log := logger.WithSource(a.log, "cmd/cyrene")
|
||||
store, err := registry.NewSQLitePolicyStore(a.db, a.log)
|
||||
if err != nil {
|
||||
log.Warn("tool policy store unavailable, keeping owner-only defaults", "error", err)
|
||||
return
|
||||
}
|
||||
if err := a.caps.AttachPolicyStore(context.Background(), store); err != nil {
|
||||
log.Warn("tool policy load failed, keeping owner-only defaults", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// buildApprovalGate 构建敏感操作代审门(design/11 §4;裁决 13.4.3)。
|
||||
//
|
||||
// 归属解析取 Run 上下文里的会话 id 与该 turn 的角色名,使前端小字既投到
|
||||
// 正确的会话、也写明是谁想动手(design/11 §4 的「xxx(角色名)」)。会话 id 用
|
||||
// loop.FlowOwnerFromContext:子代理的敏感调用归属到**派生它的那个会话**
|
||||
// (与 design/19 §2 的 flow 归属同一口径),所以小字出现在主人正在看的对话里,
|
||||
// 而不是一个没人查看的子代理 id 下。
|
||||
func (a *app) buildApprovalGate(approver *audit.Approver) *restrict.Gate {
|
||||
return restrict.NewGate(
|
||||
auditReviewer{approver: approver},
|
||||
func(ctx context.Context) (string, string) {
|
||||
return loop.FlowOwnerFromContext(ctx), a.approvalActor(ctx)
|
||||
},
|
||||
restrict.WithGateLogger(a.log),
|
||||
)
|
||||
}
|
||||
|
||||
// toolAdmissionHooks 构建 loop 的工具准入钩子(design/11 §4;裁决 13.4.3):
|
||||
// 敏感调用先经主代理代审,代审记录交给网关落 audit_logs 并广播前端小字;
|
||||
// 未获代审的调用以软结果回灌给模型(绝不执行)。非敏感调用直接放行。
|
||||
//
|
||||
// 只装 CallAdmit:敏感与否取决于**该次调用的参数**(file 的 read 自主、
|
||||
// write 代审),批量钩子(BatchAdmit)看不到参数,做不出这个区分。
|
||||
// 组合根按 loop 的既有契约装配,因此这条链也覆盖子代理的敏感调用
|
||||
// (编排层派生的 loop 共享同一组钩子)。
|
||||
func (a *app) toolAdmissionHooks(gate *restrict.Gate) loop.ToolLoopHooks {
|
||||
return loop.ToolLoopHooks{
|
||||
CallAdmit: func(ctx context.Context, call agent.ToolCall) *loop.ToolCallVeto {
|
||||
decision := gate.Admit(ctx, call.Name, call.Args)
|
||||
if decision.Approval != nil {
|
||||
// a.gw 在 wireFrontend 才建好;RecordApproval 对 nil 接收者安全。
|
||||
a.gw.RecordApproval(ctx, *decision.Approval)
|
||||
}
|
||||
if !decision.Allow {
|
||||
return &loop.ToolCallVeto{Block: true, Reason: decision.Reason}
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// auditReviewer 把 orchestration/audit 的 Approver 适配成代审门的 Reviewer
|
||||
// (design/11 §4;裁决 13.4.3 要求复用该 Approver,而不是另起一套代审)。
|
||||
// 它只把 Approval 收敛成 (allowed, error),前端小字文案仍由
|
||||
// restrict 复用 audit.Approval.Explain 的固定句式产出。
|
||||
type auditReviewer struct{ approver *audit.Approver }
|
||||
|
||||
// ReviewAndApprove 执行一次代审;approver 缺席时复用编排层的 ErrNoApprover
|
||||
// (门据此 fail-closed 拦截敏感操作,绝不静默放行)。
|
||||
func (r auditReviewer) ReviewAndApprove(ctx context.Context, actor, action, describe string) (bool, error) {
|
||||
if r.approver == nil {
|
||||
return false, fmt.Errorf("review-and-approve: %w", orchestration.ErrNoApprover)
|
||||
}
|
||||
ap, err := r.approver.ReviewAndApprove(ctx, actor, action, describe)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("review-and-approve: %w", err)
|
||||
}
|
||||
return ap.Allowed, nil
|
||||
}
|
||||
|
||||
// approvalActor 解析代审小字里的「谁想动手」:该 turn 绑定的角色名,
|
||||
// 未绑定时回落到默认角色;角色名解析不出来就用角色 id(小字宁可用 id
|
||||
// 也要说清是谁,但绝不编造一个名字)。
|
||||
func (a *app) approvalActor(ctx context.Context) string {
|
||||
pid := loop.SessionEnvFrom(ctx).PersonaID
|
||||
if pid == "" {
|
||||
pid = a.defaultPersonaID()
|
||||
}
|
||||
if name := a.personaName(pid); name != "" {
|
||||
return name
|
||||
}
|
||||
return pid
|
||||
}
|
||||
|
||||
// buildCapacity constructs the awakened-capacity decider when enabled, or nil
|
||||
// (which keeps the loop's default all-tools behavior). It is opt-in via
|
||||
// CAPACITY_ENABLED; the allowed-set mapping comes from CAPACITY_TOOL_SETS and
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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} />}
|
||||
|
||||
@@ -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,
|
||||
})),
|
||||
}),
|
||||
{
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
package registry
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
|
||||
)
|
||||
|
||||
// PolicyStore 持久化工具级可达性标记(design/11 §3、design/09 §2.1;裁决 1.3/10.4.2)。
|
||||
//
|
||||
// 载体是 SQLite 的 tool_policies 表(迁移 0012_tool_policy);*SQLitePolicyStore
|
||||
// 是生产实现,测试可换成内存桩。实现必须对并发使用安全。
|
||||
type PolicyStore interface {
|
||||
// Load 返回全部已落盘的策略,键为能力名。从未配置过的能力不出现在结果里
|
||||
// ——调用方按默认值(两个布尔均 false = owner-only)处理,即 fail-closed。
|
||||
Load(ctx context.Context) (map[string]ToolPolicy, error)
|
||||
// Save 写入一个能力的策略:不存在则插入,存在则更新(并把 updated_at
|
||||
// 推进到当前时间)。name 为空是非法调用,返回错误。
|
||||
Save(ctx context.Context, name string, p ToolPolicy) error
|
||||
}
|
||||
|
||||
// 策略持久化的 SQL(全参数化,项目规范 §十)。表由迁移 0012_tool_policy 建立;
|
||||
// upsert 以唯一索引 idx_tool_policies_name 为冲突目标,因此同一能力永远只有一行。
|
||||
const (
|
||||
selectToolPoliciesSQL = `SELECT name, allow_in_group, allow_in_stranger FROM tool_policies`
|
||||
upsertToolPolicySQL = `
|
||||
INSERT INTO tool_policies (name, allow_in_group, allow_in_stranger)
|
||||
VALUES (?, ?, ?)
|
||||
ON CONFLICT(name) DO UPDATE SET
|
||||
allow_in_group = excluded.allow_in_group,
|
||||
allow_in_stranger = excluded.allow_in_stranger,
|
||||
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now')`
|
||||
)
|
||||
|
||||
// AttachPolicyStore 把持久化载体接到注册表(design/11 §3;裁决 1.3/10.4.2):
|
||||
//
|
||||
// 1. 立即 Load 全部落盘策略,套用到**已注册**的能力上(重启后策略仍在);
|
||||
// 2. 记住这份快照与 store,之后 Register 进来的能力(MCP 热装、插件刷新)
|
||||
// 会用落盘值覆盖其初始标记;
|
||||
// 3. 此后 SetPolicy/SetAllowInGroup/SetAllowInStranger 先落库、成功后才改内存。
|
||||
//
|
||||
// store 为 nil 是合法的空操作(注册表保持纯内存,即既有行为)。Load 失败时
|
||||
// 返回错误且不动任何内存状态——调用方可以选择忽略(默认值就是最严的
|
||||
// owner-only,fail-closed),但绝不能把「没有读出策略」当成「策略全开」。
|
||||
func (r *Registry) AttachPolicyStore(ctx context.Context, store PolicyStore) error {
|
||||
if r == nil || store == nil {
|
||||
return nil
|
||||
}
|
||||
loaded, err := store.Load(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("registry: attach policy store: %w", err)
|
||||
}
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.store = store
|
||||
r.persisted = loaded
|
||||
for name, pol := range loaded {
|
||||
cap, ok := r.caps[name]
|
||||
if !ok {
|
||||
continue // 能力尚未注册:Register 时会用 r.persisted 套上
|
||||
}
|
||||
cap.Policy = pol
|
||||
r.caps[name] = cap
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// persist 把一条策略写进已装上的 store(未装时空操作)。它刻意在注册表锁之外
|
||||
// 执行 SQL,调用方只在它成功后才改内存——即「先落库、后改内存」。
|
||||
//
|
||||
// 它用 context.Background():策略写入口是管理端点经 CapabilityStore 窄接口
|
||||
// (gateway/contracts.go)调用的既有签名 SetPolicy(name, p),没有 ctx 可传;
|
||||
// 本地 SQLite 单行 upsert 是短操作,不值得为此改契约。
|
||||
func (r *Registry) persist(name string, p ToolPolicy) error {
|
||||
store := r.storeSnapshot()
|
||||
if store == nil {
|
||||
return nil
|
||||
}
|
||||
if err := store.Save(context.Background(), name, p); err != nil {
|
||||
return fmt.Errorf("registry: persist policy %q: %w", name, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// storeSnapshot 在锁内读一次 store 指针,避免与 AttachPolicyStore 的写入竞态。
|
||||
func (r *Registry) storeSnapshot() PolicyStore {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
return r.store
|
||||
}
|
||||
|
||||
// remember 记录刚落盘成功的策略(调用方必须已持有写锁)。它使「卸下再重注册」
|
||||
// 的能力(插件刷新)仍拿到最新标记,而不是注册时的零值。
|
||||
func (r *Registry) remember(name string, p ToolPolicy) {
|
||||
if r.store == nil {
|
||||
return
|
||||
}
|
||||
if r.persisted == nil {
|
||||
r.persisted = make(map[string]ToolPolicy)
|
||||
}
|
||||
r.persisted[name] = p
|
||||
}
|
||||
|
||||
// SQLitePolicyStore 把工具策略持久化在 SQLite 的 tool_policies 表上。
|
||||
// 它凭借底层的 *sql.DB 对并发使用安全。
|
||||
type SQLitePolicyStore struct {
|
||||
db *sql.DB
|
||||
log *slog.Logger
|
||||
}
|
||||
|
||||
// NewSQLitePolicyStore 在 db 上构建 SQLite 策略存储。db 为 nil 时返回错误。
|
||||
//
|
||||
// 它刻意不自建 DDL:迁移 0012_tool_policy 是这份 schema 的唯一权威路径,
|
||||
// 因此表缺失时 Load/Save 会返回明确的错误(调用方据此保持最严默认值),
|
||||
// 而不是悄悄降级成内存态、让管理面板的「保存成功」变成重启即复位的假成功。
|
||||
func NewSQLitePolicyStore(db *sql.DB, log *slog.Logger) (*SQLitePolicyStore, error) {
|
||||
if db == nil {
|
||||
return nil, fmt.Errorf("registry: policy store: nil database")
|
||||
}
|
||||
return &SQLitePolicyStore{db: db, log: logger.WithSource(log, "capability/registry")}, nil
|
||||
}
|
||||
|
||||
// Load 返回 tool_policies 的全部行。空表返回空 map(不是错误):那就是
|
||||
// 「从未配置过任何标记」的诚实状态,所有能力按默认 owner-only 处理。
|
||||
func (s *SQLitePolicyStore) Load(ctx context.Context) (map[string]ToolPolicy, error) {
|
||||
if s == nil || s.db == nil {
|
||||
return nil, fmt.Errorf("registry: policy store: not configured")
|
||||
}
|
||||
rows, err := s.db.QueryContext(ctx, selectToolPoliciesSQL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("registry: load tool policies: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
out := make(map[string]ToolPolicy)
|
||||
for rows.Next() {
|
||||
var (
|
||||
name string
|
||||
allowInGroup bool
|
||||
allowInStranger bool
|
||||
)
|
||||
if err := rows.Scan(&name, &allowInGroup, &allowInStranger); err != nil {
|
||||
return nil, fmt.Errorf("registry: scan tool policy: %w", err)
|
||||
}
|
||||
out[name] = ToolPolicy{AllowInGroup: allowInGroup, AllowInStranger: allowInStranger}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("registry: iterate tool policies: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Save upsert name 的策略。两条布尔标记本身不是敏感信息(能力名 + 两个布尔),
|
||||
// 因此错误信息只带能力名;日志也不打印任何参数外的内容。
|
||||
func (s *SQLitePolicyStore) Save(ctx context.Context, name string, p ToolPolicy) error {
|
||||
if s == nil || s.db == nil {
|
||||
return fmt.Errorf("registry: policy store: not configured")
|
||||
}
|
||||
if name == "" {
|
||||
return fmt.Errorf("registry: save tool policy: %w", ErrInvalidArgument)
|
||||
}
|
||||
if _, err := s.db.ExecContext(ctx, upsertToolPolicySQL, name, p.AllowInGroup, p.AllowInStranger); err != nil {
|
||||
return fmt.Errorf("registry: save tool policy %q: %w", name, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,293 @@
|
||||
package registry
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/db"
|
||||
)
|
||||
|
||||
// 本文件锁定裁决 1.3/10.4.2(工具策略落盘):迁移 0012_tool_policy 的 up/down
|
||||
// 可逆、以及「重启后策略仍在」——后者用「关连接、重建注册表、再 Load」模拟
|
||||
// 一次真实重启。
|
||||
|
||||
// errStoreDown 是内存桩注入的落盘失败。
|
||||
var errStoreDown = errors.New("policy store down")
|
||||
|
||||
// testStoreLogger 返回一个静默 logger:存储层测试不关心日志输出。
|
||||
func testStoreLogger() *slog.Logger {
|
||||
return slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
}
|
||||
|
||||
// openMigrated 开一个(文件或 :memory:)库并跑完全部迁移。
|
||||
// 用文件而非 :memory: 的用例是因为「重启仍在」要跨连接读同一份数据。
|
||||
func openMigrated(t *testing.T, path string) *sql.DB {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
conn, err := db.Open(ctx, path, testStoreLogger())
|
||||
if err != nil {
|
||||
t.Fatalf("db.Open(%s): %v", path, err)
|
||||
}
|
||||
if err := db.MigrateUp(ctx, conn, testStoreLogger()); err != nil {
|
||||
conn.Close()
|
||||
t.Fatalf("MigrateUp: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { conn.Close() })
|
||||
return conn
|
||||
}
|
||||
|
||||
// toolPoliciesTableExists 报告 tool_policies 表是否存在。
|
||||
func toolPoliciesTableExists(t *testing.T, ctx context.Context, conn *sql.DB) bool {
|
||||
t.Helper()
|
||||
var name string
|
||||
err := conn.QueryRowContext(ctx,
|
||||
`SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'tool_policies'`).Scan(&name)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return false
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("query sqlite_master: %v", err)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// copyFile 复制一个文件(副本迁移验证用)。
|
||||
func copyFile(t *testing.T, src, dst string) {
|
||||
t.Helper()
|
||||
data, err := os.ReadFile(src)
|
||||
if err != nil {
|
||||
t.Fatalf("read %s: %v", src, err)
|
||||
}
|
||||
if err := os.WriteFile(dst, data, 0o600); err != nil {
|
||||
t.Fatalf("write %s: %v", dst, err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestToolPolicyMigration_UpDownUpOnDataDirCopy 在 data/cyrene.db 的**副本**上
|
||||
// 跑迁移 0012 的 up → down → up,证明可逆且幂等(绝不碰本体)。
|
||||
// 副本不存在时跳过(例如干净检出的 CI),此时 :memory: 的用例仍然覆盖同一迁移。
|
||||
func TestToolPolicyMigration_UpDownUpOnDataDirCopy(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
src := filepath.Join("..", "..", "..", "data", "cyrene.db")
|
||||
if _, err := os.Stat(src); err != nil {
|
||||
t.Skipf("data/cyrene.db 不可读,跳过副本迁移验证: %v", err)
|
||||
}
|
||||
dst := filepath.Join(t.TempDir(), "cyrene-copy.db")
|
||||
copyFile(t, src, dst)
|
||||
// WAL 模式下已提交内容可能还在 -wal 里,一并带过去,副本才是完整快照。
|
||||
for _, suffix := range []string{"-wal", "-shm"} {
|
||||
if _, err := os.Stat(src + suffix); err == nil {
|
||||
copyFile(t, src+suffix, dst+suffix)
|
||||
}
|
||||
}
|
||||
|
||||
conn, err := db.Open(ctx, dst, testStoreLogger())
|
||||
if err != nil {
|
||||
t.Fatalf("db.Open(copy): %v", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
if err := db.MigrateUp(ctx, conn, testStoreLogger()); err != nil {
|
||||
t.Fatalf("MigrateUp(copy): %v", err)
|
||||
}
|
||||
if !toolPoliciesTableExists(t, ctx, conn) {
|
||||
t.Fatal("tool_policies 应在 MigrateUp 后存在")
|
||||
}
|
||||
if _, err := conn.ExecContext(ctx,
|
||||
`INSERT INTO tool_policies (name, allow_in_group) VALUES ('copied_tool', 1)`); err != nil {
|
||||
t.Fatalf("insert into copy: %v", err)
|
||||
}
|
||||
|
||||
// down:只回滚最后一步(= 0012)。
|
||||
if err := db.MigrateDown(ctx, conn, 1, testStoreLogger()); err != nil {
|
||||
t.Fatalf("MigrateDown(copy, 1): %v", err)
|
||||
}
|
||||
if toolPoliciesTableExists(t, ctx, conn) {
|
||||
t.Fatal("tool_policies 应在 MigrateDown 后消失")
|
||||
}
|
||||
|
||||
// 再 up:可逆且幂等。
|
||||
if err := db.MigrateUp(ctx, conn, testStoreLogger()); err != nil {
|
||||
t.Fatalf("MigrateUp(copy, 2nd): %v", err)
|
||||
}
|
||||
if !toolPoliciesTableExists(t, ctx, conn) {
|
||||
t.Fatal("tool_policies 应在第二次 MigrateUp 后重新出现")
|
||||
}
|
||||
var rows int
|
||||
if err := conn.QueryRowContext(ctx, `SELECT COUNT(*) FROM tool_policies`).Scan(&rows); err != nil {
|
||||
t.Fatalf("count rows: %v", err)
|
||||
}
|
||||
if rows != 0 {
|
||||
t.Fatalf("回滚再升级后应有 0 行,得到 %d 行(回滚未清空?)", rows)
|
||||
}
|
||||
}
|
||||
|
||||
// TestToolPolicyMigration_UpDownOnMemory 在干净库上锁定同一迁移的 up/down
|
||||
// (不依赖 data/ 目录是否存在)。
|
||||
func TestToolPolicyMigration_UpDownOnMemory(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
conn := openMigrated(t, ":memory:")
|
||||
|
||||
if !toolPoliciesTableExists(t, ctx, conn) {
|
||||
t.Fatal("tool_policies 应在 MigrateUp 后存在")
|
||||
}
|
||||
if err := db.MigrateDown(ctx, conn, 1, testStoreLogger()); err != nil {
|
||||
t.Fatalf("MigrateDown: %v", err)
|
||||
}
|
||||
if toolPoliciesTableExists(t, ctx, conn) {
|
||||
t.Fatal("tool_policies 应在 MigrateDown 后消失")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRegistry_PolicyStoreSurvivesRestart 锁定「重启后策略仍在」:
|
||||
// 第一次运行 Save,第二次运行(新连接 + 新注册表 + 新 store)Load 回来。
|
||||
func TestRegistry_PolicyStoreSurvivesRestart(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
path := filepath.Join(t.TempDir(), "cyrene.db")
|
||||
|
||||
first := openMigrated(t, path)
|
||||
store, err := NewSQLitePolicyStore(first, testStoreLogger())
|
||||
if err != nil {
|
||||
t.Fatalf("NewSQLitePolicyStore: %v", err)
|
||||
}
|
||||
reg := New()
|
||||
if err := reg.AttachPolicyStore(ctx, store); err != nil {
|
||||
t.Fatalf("AttachPolicyStore: %v", err)
|
||||
}
|
||||
for _, name := range []string{"group_tool", "other"} {
|
||||
if err := reg.RegisterTool(regTool{name: name}); err != nil {
|
||||
t.Fatalf("RegisterTool(%s): %v", name, err)
|
||||
}
|
||||
}
|
||||
// 初始值必须是默认(两个布尔 false = owner-only)。
|
||||
if pol, err := reg.Policy("group_tool"); err != nil || pol.AllowInGroup || pol.AllowInStranger {
|
||||
t.Fatalf("initial policy = %+v (err %v), want both false", pol, err)
|
||||
}
|
||||
if err := reg.SetPolicy("group_tool", ToolPolicy{AllowInGroup: true}); err != nil {
|
||||
t.Fatalf("SetPolicy: %v", err)
|
||||
}
|
||||
if err := first.Close(); err != nil { // 模拟进程退出
|
||||
t.Fatalf("close first connection: %v", err)
|
||||
}
|
||||
|
||||
second := openMigrated(t, path)
|
||||
store2, err := NewSQLitePolicyStore(second, testStoreLogger())
|
||||
if err != nil {
|
||||
t.Fatalf("NewSQLitePolicyStore(2): %v", err)
|
||||
}
|
||||
reg2 := New()
|
||||
if err := reg2.AttachPolicyStore(ctx, store2); err != nil {
|
||||
t.Fatalf("AttachPolicyStore(2): %v", err)
|
||||
}
|
||||
// 重启后注册表里还没有能力,只有 Load 出的策略快照……
|
||||
if _, err := reg2.Policy("group_tool"); !errors.Is(err, ErrNotFound) {
|
||||
t.Fatalf("Policy before re-register error = %v, want ErrNotFound", err)
|
||||
}
|
||||
// ……能力注册进来的那一刻套上落盘值(MCP 热装等晚注册路径同理)。
|
||||
if err := reg2.RegisterTool(regTool{name: "group_tool"}); err != nil {
|
||||
t.Fatalf("RegisterTool(2): %v", err)
|
||||
}
|
||||
pol, err := reg2.Policy("group_tool")
|
||||
if err != nil {
|
||||
t.Fatalf("Policy(2): %v", err)
|
||||
}
|
||||
if !pol.AllowInGroup || pol.AllowInStranger {
|
||||
t.Fatalf("policy after restart = %+v, want {AllowInGroup:true AllowInStranger:false}", pol)
|
||||
}
|
||||
// 没配过的能力仍是默认(tool_policies 里没有它的行)。
|
||||
if err := reg2.RegisterTool(regTool{name: "other"}); err != nil {
|
||||
t.Fatalf("RegisterTool(other): %v", err)
|
||||
}
|
||||
if pol, err := reg2.Policy("other"); err != nil || pol.AllowInGroup || pol.AllowInStranger {
|
||||
t.Fatalf("unconfigured policy = %+v (err %v), want both false", pol, err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRegistry_AttachPolicyStoreAppliesToRegisteredCaps 锁定反向顺序:
|
||||
// 能力先注册、策略后 Load(AttachPolicyStore 在 wireRuntime 才启用的场景),
|
||||
// 已注册能力也必须立刻拿到落盘值。
|
||||
func TestRegistry_AttachPolicyStoreAppliesToRegisteredCaps(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
seed := newMemoryPolicyStore()
|
||||
seed.marks["stranger_tool"] = ToolPolicy{AllowInStranger: true}
|
||||
|
||||
reg := New()
|
||||
if err := reg.RegisterTool(regTool{name: "stranger_tool"}); err != nil {
|
||||
t.Fatalf("RegisterTool: %v", err)
|
||||
}
|
||||
if err := reg.AttachPolicyStore(ctx, seed); err != nil {
|
||||
t.Fatalf("AttachPolicyStore: %v", err)
|
||||
}
|
||||
pol, err := reg.Policy("stranger_tool")
|
||||
if err != nil {
|
||||
t.Fatalf("Policy: %v", err)
|
||||
}
|
||||
if pol.AllowInGroup || !pol.AllowInStranger {
|
||||
t.Fatalf("policy after attach = %+v, want {AllowInGroup:false AllowInStranger:true}", pol)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRegistry_SetPolicyPersistFailureKeepsMemory 锁定「先落库、后改内存」:
|
||||
// 落盘失败时内存保持旧值且返回错误,绝不出现「面板说成功、重启却复位」。
|
||||
func TestRegistry_SetPolicyPersistFailureKeepsMemory(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
store := newMemoryPolicyStore()
|
||||
reg := New()
|
||||
if err := reg.RegisterTool(regTool{name: "calc"}); err != nil {
|
||||
t.Fatalf("RegisterTool: %v", err)
|
||||
}
|
||||
if err := reg.AttachPolicyStore(ctx, store); err != nil {
|
||||
t.Fatalf("AttachPolicyStore: %v", err)
|
||||
}
|
||||
store.failSave = true
|
||||
if err := reg.SetPolicy("calc", ToolPolicy{AllowInGroup: true}); err == nil {
|
||||
t.Fatal("SetPolicy with failing store = nil error, want error")
|
||||
}
|
||||
pol, err := reg.Policy("calc")
|
||||
if err != nil {
|
||||
t.Fatalf("Policy: %v", err)
|
||||
}
|
||||
if pol.AllowInGroup || pol.AllowInStranger {
|
||||
t.Fatalf("policy after failed persist = %+v, want unchanged (both false)", pol)
|
||||
}
|
||||
// 单布尔入口(SetAllowInGroup/SetAllowInStranger)走同一条落库路径。
|
||||
if err := reg.SetAllowInGroup("calc", true); err == nil {
|
||||
t.Fatal("SetAllowInGroup with failing store = nil error, want error")
|
||||
}
|
||||
}
|
||||
|
||||
// memoryPolicyStore 是 PolicyStore 的内存桩:Load/Save 语义与 SQLite 实现一致
|
||||
// (只保存写过的能力),failSave 用于注入落盘失败。
|
||||
type memoryPolicyStore struct {
|
||||
marks map[string]ToolPolicy
|
||||
failSave bool
|
||||
}
|
||||
|
||||
func newMemoryPolicyStore() *memoryPolicyStore {
|
||||
return &memoryPolicyStore{marks: make(map[string]ToolPolicy)}
|
||||
}
|
||||
|
||||
func (m *memoryPolicyStore) Load(_ context.Context) (map[string]ToolPolicy, error) {
|
||||
out := make(map[string]ToolPolicy, len(m.marks))
|
||||
for k, v := range m.marks {
|
||||
out[k] = v
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (m *memoryPolicyStore) Save(_ context.Context, name string, p ToolPolicy) error {
|
||||
if m.failSave {
|
||||
return errStoreDown
|
||||
}
|
||||
m.marks[name] = p
|
||||
return nil
|
||||
}
|
||||
@@ -68,6 +68,14 @@ type Capability struct {
|
||||
type Registry struct {
|
||||
mu sync.RWMutex
|
||||
caps map[string]Capability
|
||||
// store 是策略落盘载体(design/11 §3、裁决 10.4.2)。为 nil 时注册表
|
||||
// 退回纯内存态(既有行为);AttachPolicyStore 装上后,SetPolicy 系列
|
||||
// 先落库再改内存。
|
||||
store PolicyStore
|
||||
// persisted 是最近一次落盘成功的策略快照(能力名 → 标记)。注册表在
|
||||
// Register 时用它覆盖能力的初始 Policy,因此「策略在能力注册之前就已落盘」
|
||||
// 与「能力先注册、策略后落盘」两种顺序都得到同一个结果。
|
||||
persisted map[string]ToolPolicy
|
||||
}
|
||||
|
||||
// Compile-time assertion that *Registry satisfies the SDK Registrar interface.
|
||||
@@ -102,6 +110,11 @@ func (r *Registry) Register(cap Capability) error {
|
||||
if _, exists := r.caps[cap.Name]; exists {
|
||||
return fmt.Errorf("registry: register %q: %w", cap.Name, ErrDuplicate)
|
||||
}
|
||||
// 落盘策略覆盖注册时带的初始标记:DB 是策略的权威载体(裁决 10.4.2),
|
||||
// 因此重启后先 Load、再热注册(MCP 热装等)也能拿到已配置的值。
|
||||
if pol, ok := r.persisted[cap.Name]; ok {
|
||||
cap.Policy = pol
|
||||
}
|
||||
r.caps[cap.Name] = cap
|
||||
return nil
|
||||
}
|
||||
@@ -160,7 +173,17 @@ func (r *Registry) Policy(name string) (ToolPolicy, error) {
|
||||
|
||||
// SetPolicy replaces the reachability markers for the capability named name. It
|
||||
// returns ErrNotFound when no such capability is registered.
|
||||
//
|
||||
// 落盘(裁决 1.3/10.4.2):已装上 PolicyStore 时先写库,成功后才改内存——
|
||||
// 写库失败即返回错误、内存保持旧值,绝不产生「面板说保存成功、重启却复位」。
|
||||
// 顺序是「存在性读 → 落库(锁外做 SQL)→ 持锁套用」。
|
||||
func (r *Registry) SetPolicy(name string, p ToolPolicy) error {
|
||||
if _, err := r.Policy(name); err != nil {
|
||||
return fmt.Errorf("registry: set policy %q: %w", name, err)
|
||||
}
|
||||
if err := r.persist(name, p); err != nil {
|
||||
return err
|
||||
}
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
cap, ok := r.caps[name]
|
||||
@@ -169,34 +192,35 @@ func (r *Registry) SetPolicy(name string, p ToolPolicy) error {
|
||||
}
|
||||
cap.Policy = p
|
||||
r.caps[name] = cap
|
||||
r.remember(name, p)
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetAllowInGroup toggles the group-chat marker for the capability named name.
|
||||
// It returns ErrNotFound when no such capability is registered.
|
||||
// It returns ErrNotFound when no such capability is registered. 它复用
|
||||
// setPolicyField,因此同样先落库再改内存(裁决 10.4.2)。
|
||||
func (r *Registry) SetAllowInGroup(name string, allow bool) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
cap, ok := r.caps[name]
|
||||
if !ok {
|
||||
return fmt.Errorf("registry: set allow_in_group %q: %w", name, ErrNotFound)
|
||||
}
|
||||
cap.Policy.AllowInGroup = allow
|
||||
r.caps[name] = cap
|
||||
return nil
|
||||
return r.setPolicyField(name, "allow_in_group", func(p *ToolPolicy) { p.AllowInGroup = allow })
|
||||
}
|
||||
|
||||
// SetAllowInStranger toggles the stranger-chat marker for the capability named
|
||||
// name. It returns ErrNotFound when no such capability is registered.
|
||||
// name. It returns ErrNotFound when no such capability is registered. 同
|
||||
// SetAllowInGroup:先落库再改内存。
|
||||
func (r *Registry) SetAllowInStranger(name string, allow bool) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
cap, ok := r.caps[name]
|
||||
if !ok {
|
||||
return fmt.Errorf("registry: set allow_in_stranger %q: %w", name, ErrNotFound)
|
||||
return r.setPolicyField(name, "allow_in_stranger", func(p *ToolPolicy) { p.AllowInStranger = allow })
|
||||
}
|
||||
|
||||
// setPolicyField 是三个策略写入口共用的「读当前值 → 改一位 → SetPolicy」路径
|
||||
// (先落库再改内存,见 SetPolicy)。field 只用于错误消息里的操作名。
|
||||
func (r *Registry) setPolicyField(name, field string, apply func(*ToolPolicy)) error {
|
||||
cur, err := r.Policy(name)
|
||||
if err != nil {
|
||||
return fmt.Errorf("registry: set %s %q: %w", field, name, err)
|
||||
}
|
||||
apply(&cur)
|
||||
if err := r.SetPolicy(name, cur); err != nil {
|
||||
return fmt.Errorf("registry: set %s %q: %w", field, name, err)
|
||||
}
|
||||
cap.Policy.AllowInStranger = allow
|
||||
r.caps[name] = cap
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
package restrict
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/orchestration/audit"
|
||||
)
|
||||
|
||||
// 本文件是 design/11 §4「敏感操作代审」的准入侧实现(裁决 13.4.3):
|
||||
// loop 的工具钩子(ToolLoopHooks.CallAdmit / BatchAdmit)调用 Gate.Admit,
|
||||
// 敏感调用经主代理代审、非敏感调用自主放行。落库与前端小字广播不在本包
|
||||
// ——Gate 只产出记录(Decision.Approval),由组合根交给 gateway 落 audit_logs
|
||||
// 并广播 approval_notice 帧。
|
||||
|
||||
// Reviewer 执行一次主代理代审(design/11 §4)。
|
||||
//
|
||||
// 生产实现适配 *audit.Approver(组合根注入,见 cmd/cyrene/wire_runtime.go);
|
||||
// 用窄接口是为了本包能用桩测试,不必起编排层。
|
||||
type Reviewer interface {
|
||||
// ReviewAndApprove 审核归属 actor 的角色想做的 action,返回是否放行。
|
||||
// describe 是给审查者看的动作说明。空白 actor/action/describe 或 ctx 取消
|
||||
// 由实现返回错误;返回错误时门按 fail-closed 拦截。
|
||||
ReviewAndApprove(ctx context.Context, actor, action, describe string) (allowed bool, err error)
|
||||
}
|
||||
|
||||
// Resolver 从一次 Run 的上下文解析代审归属(design/11 §4 小字里的「xxx」):
|
||||
// sessionID = 该 Run 的会话(前端小字的投递目标,可为空表示不定位会话),
|
||||
// actor = 想动手的角色名。组合根用 loop.SessionFromContext + 该会话的角色解析。
|
||||
type Resolver func(ctx context.Context) (sessionID, actor string)
|
||||
|
||||
// ApprovalEvent 是一次代审的完整记录(design/11 §4 §8:who / what / when /
|
||||
// result + 前端小字)。组合根把它落进 audit_logs 并广播给前端。
|
||||
type ApprovalEvent struct {
|
||||
// SessionID 是该次 Run 的会话 id(可为空)。
|
||||
SessionID string
|
||||
// Actor 是想要动手的角色名(小字里的 xxx)。
|
||||
Actor string
|
||||
// Tool 是敏感工具名(如 terminal)。
|
||||
Tool string
|
||||
// Op 是敏感操作类别(执行命令 / 修改文件 / 网络访问)。
|
||||
Op SensitiveOp
|
||||
// Detail 是动作摘要(工具 + 关键参数,已截断)。
|
||||
Detail string
|
||||
// Explain 是前端小字文案(design/11 §4 的固定句式)。
|
||||
Explain string
|
||||
// Allowed 报告代审是否放行。
|
||||
Allowed bool
|
||||
// At 是代审发生时间。
|
||||
At time.Time
|
||||
}
|
||||
|
||||
// Decision 是一次工具调用准入的结论。
|
||||
type Decision struct {
|
||||
// Allow 报告该调用是否放行。
|
||||
Allow bool
|
||||
// Reason 是拦截时回灌给模型的软提示(放行时为空),体例与既有安全门的
|
||||
// "not_allowed: ..." 一致,使模型能重新评估而不是硬崩。
|
||||
Reason string
|
||||
// Approval 是本次代审的记录;非敏感调用为 nil。调用方负责记录它
|
||||
// (审计落库 + 前端小字),放行与拦截都要记(design/11 §8)。
|
||||
Approval *ApprovalEvent
|
||||
}
|
||||
|
||||
// Gate 是 loop 的工具准入门(design/11 §4;裁决 13.4.3):敏感操作走主代理
|
||||
// 代审,其余自主放行。它对并发使用安全(无可变状态)。
|
||||
type Gate struct {
|
||||
reviewer Reviewer
|
||||
resolve Resolver
|
||||
log *slog.Logger
|
||||
}
|
||||
|
||||
// GateOption 配置一个 Gate。
|
||||
type GateOption func(*Gate)
|
||||
|
||||
// WithGateLogger 让门把每次代审结论与拦截原因记到 l(来源标
|
||||
// capability/restrict)。为 nil 时不记日志。
|
||||
func WithGateLogger(l *slog.Logger) GateOption {
|
||||
return func(g *Gate) { g.log = logger.WithSource(l, "capability/restrict") }
|
||||
}
|
||||
|
||||
// NewGate 构建代审门。reviewer 为 nil 时门是 fail-closed 的:所有敏感调用
|
||||
// 一律拦截并留下一条「未获代审」的拒绝记录——绝不因为没有代审者就静默放行。
|
||||
// resolve 为 nil 时归属解析为空(actor 为空会让小字退化,但判定不受影响)。
|
||||
func NewGate(reviewer Reviewer, resolve Resolver, opts ...GateOption) *Gate {
|
||||
g := &Gate{reviewer: reviewer, resolve: resolve}
|
||||
for _, opt := range opts {
|
||||
opt(g)
|
||||
}
|
||||
return g
|
||||
}
|
||||
|
||||
// Admit 判定一次工具调用:非敏感立即放行(不产生代审记录),敏感调用先做
|
||||
// 主代理代审再决定放行或拦截。返回的 Decision.Approval 非 nil 时必须由调用方
|
||||
// 记录(design/11 §8:代审动作批准/拒绝各落一行)。
|
||||
func (g *Gate) Admit(ctx context.Context, name string, args map[string]any) Decision {
|
||||
op, detail, sensitive := SensitiveCall(name, args)
|
||||
if !sensitive {
|
||||
return Decision{Allow: true}
|
||||
}
|
||||
sessionID, actor := g.resolveCtx(ctx)
|
||||
action := string(op)
|
||||
if detail != "" {
|
||||
action += ":" + detail
|
||||
}
|
||||
ev := &ApprovalEvent{
|
||||
SessionID: sessionID,
|
||||
Actor: actor,
|
||||
Tool: name,
|
||||
Op: op,
|
||||
Detail: detail,
|
||||
At: time.Now(),
|
||||
}
|
||||
if g == nil || g.reviewer == nil {
|
||||
ev.Explain = approvalExplain(actor, false)
|
||||
g.logf("敏感操作无代审者,按 fail-closed 拦截", name, actor, nil)
|
||||
return Decision{Allow: false, Reason: approvalDeniedReason(name), Approval: ev}
|
||||
}
|
||||
allowed, err := g.reviewer.ReviewAndApprove(ctx, actor, action, detail)
|
||||
if err != nil {
|
||||
ev.Explain = approvalExplain(actor, false)
|
||||
g.logf("代审失败,按 fail-closed 拦截", name, actor, err)
|
||||
return Decision{Allow: false, Reason: approvalDeniedReason(name), Approval: ev}
|
||||
}
|
||||
ev.Allowed = allowed
|
||||
ev.Explain = approvalExplain(actor, allowed)
|
||||
g.logf("敏感操作代审", name, actor, nil)
|
||||
if !allowed {
|
||||
return Decision{Allow: false, Reason: approvalDeniedReason(name), Approval: ev}
|
||||
}
|
||||
return Decision{Allow: true, Approval: ev}
|
||||
}
|
||||
|
||||
// resolveCtx 解析归属;Resolver 为 nil 时返回两个空串(判定不依赖它们)。
|
||||
func (g *Gate) resolveCtx(ctx context.Context) (string, string) {
|
||||
if g == nil || g.resolve == nil {
|
||||
return "", ""
|
||||
}
|
||||
return g.resolve(ctx)
|
||||
}
|
||||
|
||||
// logf 记一条带来源的代审日志:只记工具名与角色名,**不记动作摘要**
|
||||
// (参数里可能有秘密;摘要只进本包的 ApprovalEvent 与审计 who/what/result 链)。
|
||||
func (g *Gate) logf(msg, tool, actor string, err error) {
|
||||
if g == nil || g.log == nil {
|
||||
return
|
||||
}
|
||||
args := []any{"tool", tool, "actor", actor}
|
||||
if err != nil {
|
||||
args = append(args, "error", err)
|
||||
}
|
||||
g.log.Info(msg, args...)
|
||||
}
|
||||
|
||||
// approvalDeniedReason 是敏感操作未获代审放行时回灌给模型的软提示,
|
||||
// 与 loop 安全门文案(not_allowed: ...)同一体例。
|
||||
func approvalDeniedReason(name string) string {
|
||||
return "not_approved: sensitive tool " + name + " requires the main agent's approval"
|
||||
}
|
||||
|
||||
// approvalExplain 产出前端小字文案。它复用 orchestration/audit 的固定句式
|
||||
// (design/11 §4:「xxx 想要干什么,我审查后批准了」),使代审成功与
|
||||
// 「无代审者/代审报错」两条路径的小字完全一致,不出现两份文案。
|
||||
func approvalExplain(actor string, allowed bool) string {
|
||||
return audit.Approval{Actor: actor, Allowed: allowed}.Explain()
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
package restrict
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// reviewStub 是 Reviewer 的桩:记录调用、按配置返回结论或错误。
|
||||
type reviewStub struct {
|
||||
allowed bool
|
||||
err error
|
||||
calls []reviewCall
|
||||
}
|
||||
|
||||
type reviewCall struct{ actor, action, describe string }
|
||||
|
||||
func (s *reviewStub) ReviewAndApprove(_ context.Context, actor, action, describe string) (bool, error) {
|
||||
s.calls = append(s.calls, reviewCall{actor: actor, action: action, describe: describe})
|
||||
if s.err != nil {
|
||||
return false, s.err
|
||||
}
|
||||
return s.allowed, nil
|
||||
}
|
||||
|
||||
// staticResolver 固定返回一遍归属。
|
||||
func staticResolver(sessionID, actor string) Resolver {
|
||||
return func(context.Context) (string, string) { return sessionID, actor }
|
||||
}
|
||||
|
||||
// TestGate_NonSensitivePassesWithoutApproval 锁定「非敏感自主」:读文件/检索
|
||||
// 不进代审、不产生记录。
|
||||
func TestGate_NonSensitivePassesWithoutApproval(t *testing.T) {
|
||||
t.Parallel()
|
||||
rv := &reviewStub{allowed: true}
|
||||
g := NewGate(rv, staticResolver("web", "昔涟"))
|
||||
d := g.Admit(context.Background(), "file", map[string]any{"action": "read", "path": "/data/a.md"})
|
||||
if !d.Allow {
|
||||
t.Fatalf("读文件应自主放行,得到 %+v", d)
|
||||
}
|
||||
if d.Approval != nil {
|
||||
t.Fatalf("非敏感调用不应产生代审记录: %+v", d.Approval)
|
||||
}
|
||||
if len(rv.calls) != 0 {
|
||||
t.Fatalf("非敏感调用不应触发代审,代审者被调用 %d 次", len(rv.calls))
|
||||
}
|
||||
}
|
||||
|
||||
// TestGate_SensitiveToolTriggersApproval 是裁决 13.4.3 的反向用例(loop 侧):
|
||||
// terminal 不再自主执行,而是触发主代理代审,并把记录交给调用方落审计。
|
||||
func TestGate_SensitiveToolTriggersApproval(t *testing.T) {
|
||||
t.Parallel()
|
||||
rv := &reviewStub{allowed: true}
|
||||
g := NewGate(rv, staticResolver("web", "昔涟"))
|
||||
d := g.Admit(context.Background(), "terminal", map[string]any{"command": "echo hi"})
|
||||
if !d.Allow {
|
||||
t.Fatalf("代审批准后应放行,得到 %+v", d)
|
||||
}
|
||||
if len(rv.calls) != 1 {
|
||||
t.Fatalf("代审者调用次数 = %d, want 1", len(rv.calls))
|
||||
}
|
||||
if rv.calls[0].actor != "昔涟" {
|
||||
t.Fatalf("代审归属 actor = %q, want 昔涟", rv.calls[0].actor)
|
||||
}
|
||||
if rv.calls[0].action != "执行命令:terminal echo hi" {
|
||||
t.Fatalf("代审动作 = %q, want 执行命令:terminal echo hi", rv.calls[0].action)
|
||||
}
|
||||
ev := d.Approval
|
||||
if ev == nil {
|
||||
t.Fatal("敏感调用放行也必须留下代审记录(design/11 §8:批准/拒绝各落一行)")
|
||||
}
|
||||
if !ev.Allowed || ev.SessionID != "web" || ev.Actor != "昔涟" || ev.Tool != "terminal" || ev.Op != SensitiveExecCommand {
|
||||
t.Fatalf("代审记录 = %+v", ev)
|
||||
}
|
||||
if ev.Explain != "「昔涟 想要干什么,我审查后批准了」" {
|
||||
t.Fatalf("前端小字 = %q, want design/11 §4 的固定句式", ev.Explain)
|
||||
}
|
||||
if ev.At.IsZero() {
|
||||
t.Fatal("代审记录应带时间戳")
|
||||
}
|
||||
}
|
||||
|
||||
// TestGate_DeniedSensitiveCallIsBlocked 锁定拒绝路径:代审不放行 → 调用被拦截、
|
||||
// 回灌软提示、记录里 Allowed=false 且小字换成「拒绝了」。
|
||||
func TestGate_DeniedSensitiveCallIsBlocked(t *testing.T) {
|
||||
t.Parallel()
|
||||
rv := &reviewStub{allowed: false}
|
||||
g := NewGate(rv, staticResolver("web", "昔涟"))
|
||||
d := g.Admit(context.Background(), "terminal", map[string]any{"command": "rm -rf /"})
|
||||
if d.Allow {
|
||||
t.Fatalf("代审未放行却被放行: %+v", d)
|
||||
}
|
||||
if d.Reason == "" {
|
||||
t.Fatal("拦截必须给出回灌给模型的说明")
|
||||
}
|
||||
if d.Approval == nil || d.Approval.Allowed {
|
||||
t.Fatalf("拒绝也要落记录且 Allowed=false: %+v", d.Approval)
|
||||
}
|
||||
if d.Approval.Explain != "「昔涟 想要干什么,我审查后拒绝了」" {
|
||||
t.Fatalf("拒绝小字 = %q", d.Approval.Explain)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGate_ReviewerFailureIsFailClosed 锁定 fail-closed:代审者报错(或压根
|
||||
// 没配)时敏感操作一律拦截并留下拒绝记录,绝不静默放行。
|
||||
func TestGate_ReviewerFailureIsFailClosed(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
for name, g := range map[string]*Gate{
|
||||
"reviewer error": NewGate(&reviewStub{err: errors.New("approver down")}, staticResolver("web", "昔涟")),
|
||||
"no reviewer": NewGate(nil, staticResolver("web", "昔涟")),
|
||||
} {
|
||||
d := g.Admit(ctx, "terminal", map[string]any{"command": "echo hi"})
|
||||
if d.Allow {
|
||||
t.Fatalf("%s: 敏感调用被放行,应 fail-closed 拦截", name)
|
||||
}
|
||||
if d.Approval == nil || d.Approval.Allowed {
|
||||
t.Fatalf("%s: 应留下 Allowed=false 的代审记录: %+v", name, d.Approval)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestGate_NilGateDeniesSensitive 锁定零值安全:未装配的门(nil 接收者)碰到
|
||||
// 敏感调用按拦截处理,而不是 panic 或放行。
|
||||
func TestGate_NilGateDeniesSensitive(t *testing.T) {
|
||||
t.Parallel()
|
||||
var g *Gate
|
||||
if d := g.Admit(context.Background(), "terminal", map[string]any{"command": "echo hi"}); d.Allow {
|
||||
t.Fatalf("nil 门应拦截敏感调用: %+v", d)
|
||||
}
|
||||
if d := g.Admit(context.Background(), "calculator", nil); !d.Allow {
|
||||
t.Fatalf("nil 门的非敏感调用仍应放行: %+v", d)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package restrict
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
// SensitiveOp 是 design/11 §4「敏感清单」里的操作类别。它只用于审计与前端
|
||||
// 小字(「xxx 想要干什么,我审查后批准了」),不参与身份/权限判定——身份始终
|
||||
// 由 channel 层 actor 元数据 + 白名单决定(design/11 §0 红线)。
|
||||
type SensitiveOp string
|
||||
|
||||
const (
|
||||
// SensitiveExecCommand 执行命令(terminal)。
|
||||
SensitiveExecCommand SensitiveOp = "执行命令"
|
||||
// SensitiveWriteFile 修改文件(file 工具的 write 动作)。
|
||||
SensitiveWriteFile SensitiveOp = "修改文件"
|
||||
// SensitiveNetwork 网络访问。只有会改变外部状态的写方法算敏感:
|
||||
// GET/HEAD/OPTIONS 属「检索」,可自主(design/11 §4)。
|
||||
SensitiveNetwork SensitiveOp = "网络访问"
|
||||
)
|
||||
|
||||
// sensitiveSummaryRunes 截断动作摘要:代审小字与审计只需一眼看出「想干什么」,
|
||||
// 同时限制进入前端/日志的文本块大小(也降低把长参数里的秘密整段搬进日志的机会)。
|
||||
const sensitiveSummaryRunes = 80
|
||||
|
||||
// SensitiveCall 判定一次工具调用是否属敏感操作(design/11 §4)。
|
||||
//
|
||||
// 判定完全结构化:只看工具名 + 参数,绝不由模型文本推断(design/11 §0 红线)。
|
||||
// 返回操作类别、动作摘要与是否敏感。清单与项目实际的工具面对应如下:
|
||||
//
|
||||
// terminal → 执行命令(命令原文进摘要)
|
||||
// file (action=write) → 修改文件(路径进摘要)
|
||||
// http (method=POST) → 网络访问(URL 进摘要)
|
||||
//
|
||||
// 其余情况一律**不敏感**:file 的 read/list/search、http 的 GET/HEAD/OPTIONS、
|
||||
// knowledge / web-search / web-fetch(检索)、calculator / datetime / text /
|
||||
// json / markdown / random(纯计算)、vision / ocr / session_read(只读)——
|
||||
// 与 design/11 §4「非敏感(读文件 / 检索 / 查记忆)可自主」一致。
|
||||
//
|
||||
// 未知工具(含 MCP 工具)同样不敏感:工具面已由 registry 的策略闸门管理,
|
||||
// 这里不新增「未知即拦截」的硬边界,否则任何新工具都会在主人会话里被拦死。
|
||||
func SensitiveCall(name string, args map[string]any) (SensitiveOp, string, bool) {
|
||||
switch name {
|
||||
case "terminal":
|
||||
return SensitiveExecCommand, summarize("terminal", argString(args, "command")), true
|
||||
case "file":
|
||||
if argString(args, "action") != "write" {
|
||||
return "", "", false
|
||||
}
|
||||
return SensitiveWriteFile, summarize("file write", argString(args, "path")), true
|
||||
case "http":
|
||||
method := strings.ToUpper(strings.TrimSpace(argString(args, "method")))
|
||||
if method == "" || method == "GET" || method == "HEAD" || method == "OPTIONS" {
|
||||
return "", "", false
|
||||
}
|
||||
return SensitiveNetwork, summarize("http "+method, argString(args, "url")), true
|
||||
default:
|
||||
return "", "", false
|
||||
}
|
||||
}
|
||||
|
||||
// summarize 拼接「动作 + 目标」摘要并按 sensitiveSummaryRunes 截断(不切开
|
||||
// 多字节字符);目标为空时只返回动作。
|
||||
func summarize(action, target string) string {
|
||||
target = strings.TrimSpace(target)
|
||||
if target == "" {
|
||||
return action
|
||||
}
|
||||
return action + " " + clipRunes(target, sensitiveSummaryRunes)
|
||||
}
|
||||
|
||||
// clipRunes 把 s 截到至多 max 个 rune,超出时追加省略号;max 非正时原样返回。
|
||||
func clipRunes(s string, max int) string {
|
||||
if max <= 0 || utf8.RuneCountInString(s) <= max {
|
||||
return s
|
||||
}
|
||||
return string([]rune(s)[:max]) + "…"
|
||||
}
|
||||
|
||||
// argString 从工具参数里取一个字符串字段;参数表缺失或类型不符返回 ""。
|
||||
func argString(args map[string]any, key string) string {
|
||||
if args == nil {
|
||||
return ""
|
||||
}
|
||||
s, _ := args[key].(string)
|
||||
return s
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package restrict
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestSensitiveCall 是 design/11 §4 敏感清单的判定表:执行命令/修改文件/
|
||||
// 网络写方法敏感,读文件/检索/纯计算自主。
|
||||
func TestSensitiveCall(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct {
|
||||
name string
|
||||
args map[string]any
|
||||
wantOp SensitiveOp
|
||||
wantIn string // 摘要应包含的片段(wantSensitive 时)
|
||||
sensitive bool
|
||||
}{
|
||||
{name: "terminal", args: map[string]any{"command": "rm -rf /tmp/x"}, wantOp: SensitiveExecCommand, wantIn: "rm -rf /tmp/x", sensitive: true},
|
||||
{name: "terminal", args: nil, wantOp: SensitiveExecCommand, wantIn: "terminal", sensitive: true},
|
||||
{name: "file", args: map[string]any{"action": "write", "path": "/data/a.md"}, wantOp: SensitiveWriteFile, wantIn: "/data/a.md", sensitive: true},
|
||||
{name: "file", args: map[string]any{"action": "read", "path": "/data/a.md"}},
|
||||
{name: "file", args: map[string]any{"action": "list"}},
|
||||
{name: "file", args: map[string]any{"action": "search", "query": "x"}},
|
||||
{name: "http", args: map[string]any{"method": "POST", "url": "https://example.com/hook"}, wantOp: SensitiveNetwork, wantIn: "example.com/hook", sensitive: true},
|
||||
{name: "http", args: map[string]any{"method": "post", "url": "https://example.com/hook"}, wantOp: SensitiveNetwork, sensitive: true},
|
||||
{name: "http", args: map[string]any{"url": "https://example.com"}}, // method 缺省 = GET
|
||||
{name: "http", args: map[string]any{"method": "GET", "url": "https://example.com"}},
|
||||
{name: "http", args: map[string]any{"method": "HEAD"}},
|
||||
{name: "knowledge", args: map[string]any{"query": "设计文档"}},
|
||||
{name: "web-search", args: map[string]any{"query": "x"}},
|
||||
{name: "web-fetch", args: map[string]any{"url": "https://example.com"}},
|
||||
{name: "session_read", args: map[string]any{"sessionId": "web"}},
|
||||
{name: "calculator", args: map[string]any{"expression": "1+1"}},
|
||||
{name: "mcp:some-server/tool", args: map[string]any{"anything": true}},
|
||||
{name: "unknown-tool", args: nil},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
op, detail, sensitive := SensitiveCall(tc.name, tc.args)
|
||||
if sensitive != tc.sensitive {
|
||||
t.Fatalf("SensitiveCall(%s, %v) sensitive = %v, want %v", tc.name, tc.args, sensitive, tc.sensitive)
|
||||
}
|
||||
if !tc.sensitive {
|
||||
if op != "" || detail != "" {
|
||||
t.Fatalf("SensitiveCall(%s) 非敏感时不应给出类别/摘要,得到 (%q, %q)", tc.name, op, detail)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if op != tc.wantOp {
|
||||
t.Fatalf("SensitiveCall(%s) op = %q, want %q", tc.name, op, tc.wantOp)
|
||||
}
|
||||
if tc.wantIn != "" && !strings.Contains(detail, tc.wantIn) {
|
||||
t.Fatalf("SensitiveCall(%s) detail = %q, want it to contain %q", tc.name, detail, tc.wantIn)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestSensitiveCall_SummaryIsClipped 锁定动作摘要的截断:长参数不许整段
|
||||
// 进入小字/审计(同时不切开多字节字符)。
|
||||
func TestSensitiveCall_SummaryIsClipped(t *testing.T) {
|
||||
t.Parallel()
|
||||
long := strings.Repeat("昔", 200)
|
||||
_, detail, sensitive := SensitiveCall("terminal", map[string]any{"command": long})
|
||||
if !sensitive {
|
||||
t.Fatal("terminal 应判定为敏感")
|
||||
}
|
||||
if got := len([]rune(detail)); got > sensitiveSummaryRunes+len("terminal ")+1 {
|
||||
t.Fatalf("摘要 rune 数 = %d,未按 %d 截断: %q", got, sensitiveSummaryRunes, detail)
|
||||
}
|
||||
if !strings.HasSuffix(detail, "…") {
|
||||
t.Fatalf("被截断的摘要应以省略号结尾: %q", detail)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
-- 0012_tool_policy down: 回滚落盘的工具策略表。
|
||||
--
|
||||
-- 回滚后的状态是「策略只活在内存、重启复位」(裁决 1.3/10.4.2 落地前的旧形态):
|
||||
-- 已配置的标记随表一起丢弃,重启后所有能力回到默认的两布尔 false(owner-only)。
|
||||
-- 丢弃是安全的——registry 的运行时内存副本与调用路径不读本表以外的东西,
|
||||
-- 且不存在引用它的外键(内部按能力名匹配,非外键约束)。
|
||||
DROP INDEX IF EXISTS idx_tool_policies_name;
|
||||
|
||||
DROP TABLE IF EXISTS tool_policies;
|
||||
@@ -0,0 +1,27 @@
|
||||
-- 0012_tool_policy: 工具级可达性标记落盘(design/11 §3、design/09 §2.1;裁决 1.3/10.4.2)。
|
||||
--
|
||||
-- 每个能力(工具/技能/MCP 等)两个独立布尔 allow_in_group / allow_in_stranger,
|
||||
-- 默认 0 = 仅主人(owner-only);群聊与陌生人环境只有标记为真的能力可被调用
|
||||
-- (策略引擎读值见 internal/capability/restrict)。
|
||||
--
|
||||
-- 为什么必须落盘:裁决 1.3「时 per-tool 为准且必须落盘」——此前标记只活在
|
||||
-- capability registry 的内存里,管理面板 POST /api/v1/admin/plugins/policy
|
||||
-- 改完一重启就复位。本表是那份策略的权威载体(registry 启动时 Load,
|
||||
-- SetPolicy 时先落库再改内存,见 internal/capability/registry/policy_store.go)。
|
||||
--
|
||||
-- 口径:一行 = 一个已配置过的能力名;没有行的能力按默认值(两布尔均 0)
|
||||
-- 处理,因此本表不需要为每个内置工具预填行——默认即最严(fail-closed)。
|
||||
-- name 是能力名(工具名 / "mcp:<server>" 等),唯一索引承载「一个能力一行」。
|
||||
-- 表名用复数,援引 AGENTS §10 已登记的例外(既有迁移口径一致)。
|
||||
CREATE TABLE IF NOT EXISTS tool_policies (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
allow_in_group INTEGER NOT NULL DEFAULT 0,
|
||||
allow_in_stranger INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
||||
);
|
||||
|
||||
-- 唯一索引同时承担 upsert 的冲突目标(ON CONFLICT(name) 需要唯一约束/索引)。
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_tool_policies_name
|
||||
ON tool_policies (name);
|
||||
@@ -0,0 +1,70 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/restrict"
|
||||
)
|
||||
|
||||
// 本文件是 design/11 §4「敏感操作代审」的对外侧实现(裁决 13.4.3):
|
||||
// loop 的工具准入钩子判定出「该敏感操作已获(或未获)代审」后,组合根
|
||||
// 把记录交给这里——① 落审计链 audit_logs(who/what/when/result),
|
||||
// ② 向 owner/admin 连接广播小字帧。判定与代审本身在
|
||||
// internal/capability/restrict(Gate);本文件只管「记录 + 呈现」。
|
||||
|
||||
// wsApprovalNotice 是主代理代审小字的 WS 事件类型(design/11 §4)。
|
||||
// 它携带内容(角色名、动作、文案),因此 design/11 §7.1 的帧门默认
|
||||
// fail-closed:只投递给 owner + admin 连接,viewer 收不到,也不进那 7 个
|
||||
// 状态机信号白名单。
|
||||
const wsApprovalNotice = "approval_notice"
|
||||
|
||||
// 代审在审计链里的 who/what/result 口径(design/11 §8):
|
||||
// who = 想动手的角色名,what = "approval.<工具名>",result = granted|denied。
|
||||
// 代审动作批准/拒绝**各落一行**,因此前缀固定、结果二分。
|
||||
const (
|
||||
approvalAuditPrefix = "approval."
|
||||
approvalResultGrant = "granted"
|
||||
approvalResultDeny = "denied"
|
||||
)
|
||||
|
||||
// RecordApproval 记录一次主代理代审(design/11 §4 §8;裁决 13.4.3)。
|
||||
//
|
||||
// ① 审计:把 who(角色名)/ what(approval.<工具名>)/ when / result
|
||||
// (granted / denied)追加进 audit_logs —— 代审动作批准与拒绝各落一行,
|
||||
// 供管理面板「可观测性 → 审计」与通知 feed 查询。
|
||||
// ② 前端小字:广播 approval_notice 帧,携带 design/11 §4 的固定文案
|
||||
// 「xxx(角色名) 想要干什么,我审查后批准了」(explain),以及工具名、
|
||||
// 操作类别与动作摘要(detail,客户端可作副文本或忽略)。
|
||||
//
|
||||
// 审计写入用 context.WithoutCancel:代审发生在一次 Run 内部,而 Run 的 ctx
|
||||
// 可能已被取消(用户中断)——已经发生的代审仍然必须留痕。审计存储坏掉时
|
||||
// gateway.audit 只记一条日志,不影响代审已经成立的结论。
|
||||
//
|
||||
// 方法对 nil 接收者安全(组合根在网关构建完成前调用它时不会 panic)。
|
||||
func (s *Server) RecordApproval(ctx context.Context, ev restrict.ApprovalEvent) {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
result := approvalResultDeny
|
||||
if ev.Allowed {
|
||||
result = approvalResultGrant
|
||||
}
|
||||
s.audit(context.WithoutCancel(ctx), ev.Actor, approvalAuditPrefix+ev.Tool, result)
|
||||
s.hub.broadcast(wsEvent{
|
||||
Type: wsApprovalNotice,
|
||||
SessionID: ev.SessionID,
|
||||
Data: map[string]any{
|
||||
"actor": ev.Actor,
|
||||
"tool": ev.Tool,
|
||||
"op": string(ev.Op),
|
||||
"detail": ev.Detail,
|
||||
"explain": ev.Explain,
|
||||
"allowed": ev.Allowed,
|
||||
"at": ev.At.UTC().Format(time.RFC3339Nano),
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log/slog"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/restrict"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/db"
|
||||
)
|
||||
|
||||
// 本文件锁定裁决 13.4.3 的对外侧:敏感操作经代审后**落 audit_logs**
|
||||
// (who/what/when/result)并向 owner/admin 广播 design/11 §4 的小字帧
|
||||
// `approval_notice`。判定/代审本身由 internal/capability/restrict 的 Gate
|
||||
// 钉住(restrict/approve_test.go),这里钉住「记录 + 呈现」,以及
|
||||
// design/11 §7.1 的帧门(viewer 收不到这条内容帧)。
|
||||
|
||||
// approvalReviewer 是 restrict.Reviewer 的桩:记录调用并固定批准。
|
||||
type approvalReviewer struct {
|
||||
calls int
|
||||
allowed bool
|
||||
}
|
||||
|
||||
func (r *approvalReviewer) ReviewAndApprove(context.Context, string, string, string) (bool, error) {
|
||||
r.calls++
|
||||
return r.allowed, nil
|
||||
}
|
||||
|
||||
// newApprovalEnv 接线一个带**真实 SQLite 审计存储**的网关 —— 用例要断言
|
||||
// audit_logs 里真的有行,因此不能用内存回退存储。
|
||||
func newApprovalEnv(t *testing.T) (*Server, string, *auth.JWTService, *sql.DB) {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
conn, err := db.Open(ctx, filepath.Join(t.TempDir(), "cyrene.db"), slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { conn.Close() })
|
||||
auditStore, err := NewSQLiteAuditStore(ctx, conn, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("audit store: %v", err)
|
||||
}
|
||||
srv, ts, jwt := buildEnv(t, nil, Deps{AuditStore: auditStore})
|
||||
return srv, ts.URL, jwt, conn
|
||||
}
|
||||
|
||||
// ownerWSConn 建立一条 owner 连接,并用一次 mode_switch 往返确认它已注册
|
||||
// 到 hub(与既有的 flow_update/role-gate 用例同法)。
|
||||
func ownerWSConn(t *testing.T, url, token string) *websocket.Conn {
|
||||
t.Helper()
|
||||
conn := dialWS(t, url, token)
|
||||
t.Cleanup(func() { conn.Close() })
|
||||
if err := conn.WriteJSON(wsInbound{Type: "mode_switch", Data: json.RawMessage(`{"mode":"chat"}`)}); err != nil {
|
||||
t.Fatalf("write mode_switch: %v", err)
|
||||
}
|
||||
if typ, raw := readWire(t, conn, "mode_switch ack"); typ != "mode_switch" {
|
||||
t.Fatalf("first frame = %q, want mode_switch (%s)", typ, raw)
|
||||
}
|
||||
return conn
|
||||
}
|
||||
|
||||
// TestRecordApproval_TerminalTriggersApprovalAndLandsInAuditLogs 是裁决 13.4.3
|
||||
// 的反向用例(gateway 侧):terminal 这类敏感调用**走代审**——判定敏感 →
|
||||
// 代审放行 → audit_logs 落一行 → 前端收到小字帧。此前 terminal 直接执行、
|
||||
// 审计链里没有「代审」痕迹,本用例就是把那条链钉死。
|
||||
func TestRecordApproval_TerminalTriggersApprovalAndLandsInAuditLogs(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
srv, url, jwt, conn := newApprovalEnv(t)
|
||||
wsConn := ownerWSConn(t, url, signAny(t, jwt, "acct", auth.RoleOwner))
|
||||
|
||||
args := map[string]any{"command": "echo hi"}
|
||||
if _, _, sensitive := restrict.SensitiveCall("terminal", args); !sensitive {
|
||||
t.Fatal("terminal 必须被判定为敏感操作(否则代审链根本不会触发)")
|
||||
}
|
||||
reviewer := &approvalReviewer{allowed: true}
|
||||
gate := restrict.NewGate(reviewer, func(context.Context) (string, string) { return "web", "昔涟" })
|
||||
decision := gate.Admit(ctx, "terminal", args)
|
||||
if !decision.Allow || decision.Approval == nil {
|
||||
t.Fatalf("代审批准后应放行并留下记录: %+v", decision)
|
||||
}
|
||||
if reviewer.calls != 1 {
|
||||
t.Fatalf("代审者调用次数 = %d, want 1", reviewer.calls)
|
||||
}
|
||||
|
||||
srv.RecordApproval(ctx, *decision.Approval)
|
||||
|
||||
// ① 前端小字帧(design/11 §4 的固定句式)。
|
||||
typ, raw := readWire(t, wsConn, "approval_notice")
|
||||
if typ != wsApprovalNotice {
|
||||
t.Fatalf("frame type = %q, want %q (%s)", typ, wsApprovalNotice, raw)
|
||||
}
|
||||
var frame struct {
|
||||
Type string `json:"type"`
|
||||
SessionID string `json:"sessionId"`
|
||||
Data struct {
|
||||
Actor string `json:"actor"`
|
||||
Tool string `json:"tool"`
|
||||
Op string `json:"op"`
|
||||
Detail string `json:"detail"`
|
||||
Explain string `json:"explain"`
|
||||
Allowed bool `json:"allowed"`
|
||||
At string `json:"at"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &frame); err != nil {
|
||||
t.Fatalf("decode frame %s: %v", raw, err)
|
||||
}
|
||||
if frame.SessionID != "web" {
|
||||
t.Fatalf("frame sessionId = %q, want web(小字要投到发生代审的那个会话)", frame.SessionID)
|
||||
}
|
||||
if frame.Data.Explain != "「昔涟 想要干什么,我审查后批准了」" {
|
||||
t.Fatalf("小字 = %q, want design/11 §4 的固定文案", frame.Data.Explain)
|
||||
}
|
||||
if frame.Data.Tool != "terminal" || frame.Data.Op != string(restrict.SensitiveExecCommand) || !frame.Data.Allowed {
|
||||
t.Fatalf("小字帧字段 = %+v", frame.Data)
|
||||
}
|
||||
if frame.Data.Detail == "" {
|
||||
t.Fatal("小字帧应带动作摘要(detail),前端可作副文本")
|
||||
}
|
||||
if _, err := time.Parse(time.RFC3339Nano, frame.Data.At); err != nil {
|
||||
t.Fatalf("帧时间戳 %q 不是 RFC3339Nano: %v", frame.Data.At, err)
|
||||
}
|
||||
|
||||
// ② 审计链里有行(design/11 §8:代审动作批准/拒绝各落一行)。
|
||||
rows := readAuditRows(t, ctx, conn)
|
||||
if len(rows) != 1 {
|
||||
t.Fatalf("audit_logs 行数 = %d, want 1 (%+v)", len(rows), rows)
|
||||
}
|
||||
got := rows[0]
|
||||
if got.who != "昔涟" || got.what != "approval.terminal" || got.result != approvalResultGrant {
|
||||
t.Fatalf("审计行 = %+v, want {昔涟 approval.terminal granted}", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRecordApproval_DeniedLandsAsDeniedRow 锁定拒绝路径同样落一行
|
||||
// (result=denied),且小字换成「我审查后拒绝了」。
|
||||
func TestRecordApproval_DeniedLandsAsDeniedRow(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
srv, url, jwt, conn := newApprovalEnv(t)
|
||||
wsConn := ownerWSConn(t, url, signAny(t, jwt, "acct", auth.RoleOwner))
|
||||
|
||||
gate := restrict.NewGate(&approvalReviewer{allowed: false},
|
||||
func(context.Context) (string, string) { return "web", "昔涟" })
|
||||
decision := gate.Admit(ctx, "terminal", map[string]any{"command": "rm -rf /"})
|
||||
if decision.Allow {
|
||||
t.Fatalf("代审拒绝却放行: %+v", decision)
|
||||
}
|
||||
srv.RecordApproval(ctx, *decision.Approval)
|
||||
|
||||
if typ, raw := readWire(t, wsConn, "denied notice"); typ != wsApprovalNotice {
|
||||
t.Fatalf("frame type = %q, want %q (%s)", typ, wsApprovalNotice, raw)
|
||||
}
|
||||
rows := readAuditRows(t, ctx, conn)
|
||||
if len(rows) != 1 || rows[0].result != approvalResultDeny {
|
||||
t.Fatalf("审计行 = %+v, want 一行 result=denied", rows)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRecordApproval_NoticeReachesOwnerNotViewer 锁定 design/11 §7.1 的帧门:
|
||||
// 代审小字是**内容帧**(带角色名与动作),因此只有 owner/admin 收得到,
|
||||
// viewer(只读监控档)收不到——新帧默认 fail-closed,未进状态机信号白名单。
|
||||
func TestRecordApproval_NoticeReachesOwnerNotViewer(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
srv, url, jwt, _ := newApprovalEnv(t)
|
||||
viewer := dialWS(t, url, signAny(t, jwt, "viewer", auth.RoleViewer))
|
||||
t.Cleanup(func() { viewer.Close() })
|
||||
owner := ownerWSConn(t, url, signAny(t, jwt, "acct", auth.RoleOwner))
|
||||
waitConnCount(t, srv, 2)
|
||||
|
||||
srv.RecordApproval(ctx, restrict.ApprovalEvent{
|
||||
SessionID: "web",
|
||||
Actor: "昔涟",
|
||||
Tool: "terminal",
|
||||
Op: restrict.SensitiveExecCommand,
|
||||
Detail: "terminal echo hi",
|
||||
Explain: "「昔涟 想要干什么,我审查后批准了」",
|
||||
Allowed: true,
|
||||
At: time.Now(),
|
||||
})
|
||||
|
||||
if typ, raw := readWire(t, owner, "owner notice"); typ != wsApprovalNotice {
|
||||
t.Fatalf("owner 未收到小字帧: %q (%s)", typ, raw)
|
||||
}
|
||||
// viewer 侧:本就不该有帧。给一个短窗口,任何帧都算失败
|
||||
// (连接在读超时后会失效,所以这必须是该连接的最后一次读)。
|
||||
_ = viewer.SetReadDeadline(time.Now().Add(wsRoleGateNoFrameWait))
|
||||
if _, msg, err := viewer.ReadMessage(); err == nil {
|
||||
t.Fatalf("viewer 收到了代审小字帧,违反 design/11 §7.1: %s", msg)
|
||||
}
|
||||
}
|
||||
|
||||
// auditRow 是 audit_logs 的一行(who/what/result)。
|
||||
type auditRow struct {
|
||||
who string
|
||||
what string
|
||||
result string
|
||||
}
|
||||
|
||||
// readAuditRows 读取 audit_logs 全部行(用例只写很少几行,无需分页)。
|
||||
func readAuditRows(t *testing.T, ctx context.Context, conn *sql.DB) []auditRow {
|
||||
t.Helper()
|
||||
rows, err := conn.QueryContext(ctx, `SELECT who, what, result FROM audit_logs ORDER BY id`)
|
||||
if err != nil {
|
||||
t.Fatalf("query audit_logs: %v", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
out := make([]auditRow, 0, 2)
|
||||
for rows.Next() {
|
||||
var r auditRow
|
||||
if err := rows.Scan(&r.who, &r.what, &r.result); err != nil {
|
||||
t.Fatalf("scan audit_logs: %v", err)
|
||||
}
|
||||
out = append(out, r)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
t.Fatalf("iterate audit_logs: %v", err)
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"io"
|
||||
"log/slog"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
|
||||
capreg "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/db"
|
||||
)
|
||||
|
||||
// 本文件锁定裁决 1.3/10.4.2 的管理面一侧:面板的「工具策略」保存
|
||||
// (POST /api/v1/admin/plugins/policy)真的**先落库再改内存**——重启后策略仍在。
|
||||
// 落盘载体是 tool_policies 表(迁移 0012_tool_policy),由 registry 的
|
||||
// PolicyStore 钩子写入;这里用真实 SQLite 库走完整 HTTP 路径。
|
||||
|
||||
// policyEnv 接线一个「策略已接存储」的能力注册表的网关,返回服务端地址、
|
||||
// JWT 服务与库连接(用例直接查 tool_policies 断言落盘)。
|
||||
func policyEnv(t *testing.T) (string, *auth.JWTService, *sql.DB) {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
log := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
conn, err := db.Open(ctx, filepath.Join(t.TempDir(), "cyrene.db"), log)
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { conn.Close() })
|
||||
if err := db.MigrateUp(ctx, conn, log); err != nil {
|
||||
t.Fatalf("MigrateUp: %v", err)
|
||||
}
|
||||
store, err := capreg.NewSQLitePolicyStore(conn, log)
|
||||
if err != nil {
|
||||
t.Fatalf("policy store: %v", err)
|
||||
}
|
||||
reg := capreg.New()
|
||||
if err := reg.AttachPolicyStore(ctx, store); err != nil {
|
||||
t.Fatalf("AttachPolicyStore: %v", err)
|
||||
}
|
||||
if err := reg.RegisterTool(stubTool{}); err != nil {
|
||||
t.Fatalf("register stub tool: %v", err)
|
||||
}
|
||||
_, ts, jwt := buildEnv(t, nil, Deps{Registry: reg})
|
||||
return ts.URL, jwt, conn
|
||||
}
|
||||
|
||||
// TestAdminSetPolicyPersistsToToolPolicies 锁定「面板保存 → 真落库」:
|
||||
// 走真实 HTTP 端点写入后,tool_policies 里有对应的行。
|
||||
func TestAdminSetPolicyPersistsToToolPolicies(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
url, jwt, conn := policyEnv(t)
|
||||
owner := signAny(t, jwt, "acct", auth.RoleOwner)
|
||||
|
||||
resp, body := doReq(t, "POST", url+"/api/v1/admin/plugins/policy",
|
||||
map[string]any{"name": "echo", "allowInGroup": true, "allowInStranger": false}, owner)
|
||||
if resp.StatusCode != 200 {
|
||||
t.Fatalf("POST policy status = %d, body = %s", resp.StatusCode, body)
|
||||
}
|
||||
var (
|
||||
group bool
|
||||
stranger bool
|
||||
)
|
||||
if err := conn.QueryRowContext(ctx,
|
||||
`SELECT allow_in_group, allow_in_stranger FROM tool_policies WHERE name = 'echo'`).
|
||||
Scan(&group, &stranger); err != nil {
|
||||
t.Fatalf("tool_policies 里没有 echo 的策略行(未落盘?): %v", err)
|
||||
}
|
||||
if !group || stranger {
|
||||
t.Fatalf("落盘策略 = {group:%v stranger:%v}, want {true false}", group, stranger)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdminSetPolicySurvivesRestart 跨「重启」验证:写完策略后另外构造一个
|
||||
// 注册表重新 Load,标记仍在(这正是裁决 1.3 要求消灭的「重启即失」)。
|
||||
func TestAdminSetPolicySurvivesRestart(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
url, jwt, conn := policyEnv(t)
|
||||
owner := signAny(t, jwt, "acct", auth.RoleOwner)
|
||||
|
||||
if resp, body := doReq(t, "POST", url+"/api/v1/admin/plugins/policy",
|
||||
map[string]any{"name": "echo", "allowInGroup": false, "allowInStranger": true}, owner); resp.StatusCode != 200 {
|
||||
t.Fatalf("POST policy status = %d, body = %s", resp.StatusCode, body)
|
||||
}
|
||||
|
||||
// 模拟重启:新注册表 + 新 store,只有 Load 一条路能拿到刚才的标记。
|
||||
log := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
store, err := capreg.NewSQLitePolicyStore(conn, log)
|
||||
if err != nil {
|
||||
t.Fatalf("policy store (2): %v", err)
|
||||
}
|
||||
rebooted := capreg.New()
|
||||
if err := rebooted.AttachPolicyStore(ctx, store); err != nil {
|
||||
t.Fatalf("AttachPolicyStore (2): %v", err)
|
||||
}
|
||||
if err := rebooted.RegisterTool(stubTool{}); err != nil {
|
||||
t.Fatalf("RegisterTool (2): %v", err)
|
||||
}
|
||||
pol, err := rebooted.Policy("echo")
|
||||
if err != nil {
|
||||
t.Fatalf("Policy (2): %v", err)
|
||||
}
|
||||
if pol.AllowInGroup || !pol.AllowInStranger {
|
||||
t.Fatalf("重启后策略 = %+v, want {group:false stranger:true}", pol)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user