Files
Cyrene_v2/internal/capability/registry/registry.go
T
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

275 lines
9.6 KiB
Go

// Package registry provides the global capability registry. Every capability a
// character can use — tools, skills, channels, MCP servers and IoT providers —
// is registered here so it is globally shared and always available to any
// character and mode (DevDocs/design/01-角色人格与行为模式.md §4).
//
// It is the single source of truth that tool dispatch (M2) and plugin/skill/MCP
// wiring (M5) read from. The registry is safe for concurrent use; it implements
// the pkg/plugins/sdk Registrar interface so plugins register through the SDK
// helpers without knowing this concrete type.
package registry
import (
"errors"
"fmt"
"sync"
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
)
// Kind classifies a registered capability.
type Kind string
const (
// KindTool is a callable function with a JSON Schema (sdk.Tool).
KindTool Kind = "tool"
// KindSkill is a reusable workflow with steps, prompts, refs and guardrails
// (sdk.Skill).
KindSkill Kind = "skill"
// KindChannel is a platform adapter (sdk.Channel).
KindChannel Kind = "channel"
// KindMCP is a capability exposed by an MCP server (subsystem-owned).
KindMCP Kind = "mcp"
// KindIoT is an IoT device or provider capability (subsystem-owned).
KindIoT Kind = "iot"
)
// ToolPolicy holds the editable Boolean markers that gate a tool/capability in
// non-owner environments (DevDocs/design/11 §3). Both fields default to false,
// so a capability is owner-only until a marker is explicitly enabled. Policies
// are per-capability and editable at runtime (the admin panel, M10).
type ToolPolicy struct {
// AllowInGroup permits invocation from a group chat environment.
AllowInGroup bool
// AllowInStranger permits invocation from a stranger's private chat
// environment.
AllowInStranger bool
}
// Capability is a globally shared, always-available capability registered in
// the registry.
type Capability struct {
// Name is the unique capability identifier.
Name string
// Kind classifies the capability.
Kind Kind
// Description is a human-readable summary used for selection and display.
Description string
// Policy is the editable reachability markers for this capability. It
// defaults to zero, which the policy engine reads as owner-only.
Policy ToolPolicy
// Value is the underlying capability object. For KindTool/KindSkill/
// KindChannel it is the SDK interface from pkg/plugins/sdk; for KindMCP and
// KindIoT it is owned by the relevant subsystem.
Value any
}
// Registry is the thread-safe global capability registry.
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.
var _ sdk.Registrar = (*Registry)(nil)
// Sentinel errors returned by the registry. Use errors.Is to test for them.
var (
// ErrNotFound is returned by Get when no capability has the given name.
ErrNotFound = errors.New("registry: capability not found")
// ErrDuplicate is returned by Register when a capability with the same name
// is already registered.
ErrDuplicate = errors.New("registry: capability already registered")
// ErrInvalidArgument is returned when a capability has an empty name, an
// unknown kind, or a nil Value.
ErrInvalidArgument = errors.New("registry: invalid capability")
)
// New returns an empty, concurrency-safe registry.
func New() *Registry {
return &Registry{caps: make(map[string]Capability)}
}
// Register adds cap to the registry. It returns ErrInvalidArgument when the
// name is empty, the kind is unknown or the Value is nil, and ErrDuplicate when
// a capability with the same name already exists.
func (r *Registry) Register(cap Capability) error {
if cap.Name == "" || !validKind(cap.Kind) || cap.Value == nil {
return fmt.Errorf("registry: register %q: %w", cap.Name, ErrInvalidArgument)
}
r.mu.Lock()
defer r.mu.Unlock()
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
}
// Get returns the capability registered under name. It returns ErrNotFound when
// no such capability is registered.
func (r *Registry) Get(name string) (Capability, error) {
r.mu.RLock()
defer r.mu.RUnlock()
cap, ok := r.caps[name]
if !ok {
return Capability{}, fmt.Errorf("registry: get %q: %w", name, ErrNotFound)
}
return cap, nil
}
// Remove deletes the capability registered under name. It returns ErrNotFound
// when no such capability is registered.
func (r *Registry) Remove(name string) error {
r.mu.Lock()
defer r.mu.Unlock()
if _, ok := r.caps[name]; !ok {
return fmt.Errorf("registry: remove %q: %w", name, ErrNotFound)
}
delete(r.caps, name)
return nil
}
// List returns a copy of the capabilities whose Kind matches kind. An empty
// kind returns every registered capability. The returned slice is owned by the
// caller and safe to mutate.
func (r *Registry) List(kind Kind) []Capability {
r.mu.RLock()
defer r.mu.RUnlock()
out := make([]Capability, 0, len(r.caps))
for _, cap := range r.caps {
if kind != "" && cap.Kind != kind {
continue
}
out = append(out, cap)
}
return out
}
// Policy returns the editable reachability markers for the capability named
// name. It returns ErrNotFound when no such capability is registered.
func (r *Registry) Policy(name string) (ToolPolicy, error) {
r.mu.RLock()
defer r.mu.RUnlock()
cap, ok := r.caps[name]
if !ok {
return ToolPolicy{}, fmt.Errorf("registry: policy %q: %w", name, ErrNotFound)
}
return cap.Policy, nil
}
// 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]
if !ok {
return fmt.Errorf("registry: set policy %q: %w", name, ErrNotFound)
}
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. 它复用
// setPolicyField,因此同样先落库再改内存(裁决 10.4.2)。
func (r *Registry) SetAllowInGroup(name string, allow bool) error {
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. 同
// SetAllowInGroup:先落库再改内存。
func (r *Registry) SetAllowInStranger(name string, allow bool) error {
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)
}
return nil
}
// RegisterTool registers t as a KindTool capability.
func (r *Registry) RegisterTool(t sdk.Tool) error {
if t == nil {
return fmt.Errorf("registry: register tool: %w", ErrInvalidArgument)
}
return r.Register(Capability{
Name: t.Name(),
Kind: KindTool,
Description: t.Description(),
Value: t,
})
}
// RegisterSkill registers s as a KindSkill capability.
func (r *Registry) RegisterSkill(s sdk.Skill) error {
if s == nil {
return fmt.Errorf("registry: register skill: %w", ErrInvalidArgument)
}
return r.Register(Capability{
Name: s.Name(),
Kind: KindSkill,
Description: s.Description(),
Value: s,
})
}
// RegisterChannel registers c as a KindChannel capability.
func (r *Registry) RegisterChannel(c sdk.Channel) error {
if c == nil {
return fmt.Errorf("registry: register channel: %w", ErrInvalidArgument)
}
return r.Register(Capability{
Name: c.Name(),
Kind: KindChannel,
Description: c.Name(),
Value: c,
})
}
// validKind reports whether k is one of the known capability kinds.
func validKind(k Kind) bool {
switch k {
case KindTool, KindSkill, KindChannel, KindMCP, KindIoT:
return true
default:
return false
}
}