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,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
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user