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

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

No files matched your search

@@ -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
}
+42 -18
View File
@@ -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
}
+168
View File
@@ -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)
}
}
+88
View File
@@ -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)
}
}