Files
AskaEth 63236c9b00
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
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 触发代审 + 审计有行有端到端断言
2026-10-04 12:30:16 +08:00

170 lines
6.6 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}