feat(iot): IoT 插件化(Provider/Manager/mock 8设备/能力约束/bus 事件);feat(capability/restrict): 工具级布尔标记(群聊/陌生人可用,默认关,策略引擎雏形)+ registry ToolPolicy
This commit is contained in:
1 parent
915aa8a100
commit
b3c61aa1ff
9 files changed
+1358
No files matched your search
@@ -34,6 +34,18 @@ const (
|
||||
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 {
|
||||
@@ -43,6 +55,9 @@ type Capability struct {
|
||||
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.
|
||||
@@ -103,6 +118,18 @@ func (r *Registry) Get(name string) (Capability, error) {
|
||||
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.
|
||||
@@ -119,6 +146,60 @@ func (r *Registry) List(kind Kind) []Capability {
|
||||
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.
|
||||
func (r *Registry) SetPolicy(name string, p ToolPolicy) error {
|
||||
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
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetAllowInGroup toggles the group-chat marker for the capability named name.
|
||||
// It returns ErrNotFound when no such capability is registered.
|
||||
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
|
||||
}
|
||||
|
||||
// SetAllowInStranger toggles the stranger-chat marker for the capability named
|
||||
// name. It returns ErrNotFound when no such capability is registered.
|
||||
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)
|
||||
}
|
||||
cap.Policy.AllowInStranger = allow
|
||||
r.caps[name] = cap
|
||||
return nil
|
||||
}
|
||||
|
||||
// RegisterTool registers t as a KindTool capability.
|
||||
func (r *Registry) RegisterTool(t sdk.Tool) error {
|
||||
if t == nil {
|
||||
|
||||
@@ -246,3 +246,70 @@ func TestRegistry_ConcurrentSafety(t *testing.T) {
|
||||
func kn(w, i int) string {
|
||||
return "cap" + strconv.Itoa(w) + "-" + strconv.Itoa(i)
|
||||
}
|
||||
|
||||
func TestRegistry_ToolPolicyDefaultsFalse(t *testing.T) {
|
||||
t.Parallel()
|
||||
r := New()
|
||||
if err := r.RegisterTool(regTool{name: "calc"}); err != nil {
|
||||
t.Fatalf("RegisterTool: %v", err)
|
||||
}
|
||||
pol, err := r.Policy("calc")
|
||||
if err != nil {
|
||||
t.Fatalf("Policy: %v", err)
|
||||
}
|
||||
if pol.AllowInGroup || pol.AllowInStranger {
|
||||
t.Fatalf("default policy = %+v, want both markers false", pol)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegistry_SetPolicyAndSetters(t *testing.T) {
|
||||
t.Parallel()
|
||||
r := New()
|
||||
if err := r.RegisterTool(regTool{name: "calc"}); err != nil {
|
||||
t.Fatalf("RegisterTool: %v", err)
|
||||
}
|
||||
|
||||
if err := r.SetPolicy("calc", ToolPolicy{AllowInGroup: true, AllowInStranger: true}); err != nil {
|
||||
t.Fatalf("SetPolicy: %v", err)
|
||||
}
|
||||
pol, _ := r.Policy("calc")
|
||||
if !pol.AllowInGroup || !pol.AllowInStranger {
|
||||
t.Fatalf("after SetPolicy = %+v, want both true", pol)
|
||||
}
|
||||
|
||||
if err := r.SetAllowInGroup("calc", false); err != nil {
|
||||
t.Fatalf("SetAllowInGroup: %v", err)
|
||||
}
|
||||
pol, _ = r.Policy("calc")
|
||||
if pol.AllowInGroup {
|
||||
t.Fatalf("SetAllowInGroup(false) did not clear group marker: %+v", pol)
|
||||
}
|
||||
if !pol.AllowInStranger {
|
||||
t.Fatalf("SetAllowInGroup unexpectedly cleared stranger marker: %+v", pol)
|
||||
}
|
||||
|
||||
if err := r.SetAllowInStranger("calc", false); err != nil {
|
||||
t.Fatalf("SetAllowInStranger: %v", err)
|
||||
}
|
||||
pol, _ = r.Policy("calc")
|
||||
if pol.AllowInGroup || pol.AllowInStranger {
|
||||
t.Fatalf("after clearing both = %+v, want both false", pol)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegistry_Policy_NotFound(t *testing.T) {
|
||||
t.Parallel()
|
||||
r := New()
|
||||
if _, err := r.Policy("missing"); !errors.Is(err, ErrNotFound) {
|
||||
t.Fatalf("Policy(missing) error = %v, want ErrNotFound", err)
|
||||
}
|
||||
if err := r.SetPolicy("missing", ToolPolicy{}); !errors.Is(err, ErrNotFound) {
|
||||
t.Fatalf("SetPolicy(missing) error = %v, want ErrNotFound", err)
|
||||
}
|
||||
if err := r.SetAllowInGroup("missing", true); !errors.Is(err, ErrNotFound) {
|
||||
t.Fatalf("SetAllowInGroup(missing) error = %v, want ErrNotFound", err)
|
||||
}
|
||||
if err := r.SetAllowInStranger("missing", true); !errors.Is(err, ErrNotFound) {
|
||||
t.Fatalf("SetAllowInStranger(missing) error = %v, want ErrNotFound", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
// Package restrict provides the tool-level security policy engine prototype
|
||||
// (DevDocs/design/11 §3). It decides whether a tool/capability may be invoked in
|
||||
// a given actor environment by reading the capability's editable Boolean markers
|
||||
// AllowInGroup / AllowInStranger, which default to false so a capability is
|
||||
// owner-only until explicitly allowed.
|
||||
//
|
||||
// M5 delivers the marker data structure (the ToolPolicy on
|
||||
// internal/capability/registry.Capability) and the decision function. The
|
||||
// authoritative actor tagging happens in the channel/session layer in M6 — here
|
||||
// an ActorEnv is supplied directly, and the engine reads the registry's markers
|
||||
// to allow or deny. The engine is fail-closed: an unknown tool, an unknown
|
||||
// environment, or a nil registry all deny.
|
||||
package restrict
|
||||
|
||||
import (
|
||||
"io"
|
||||
"log/slog"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
|
||||
)
|
||||
|
||||
// ActorEnv is the environment in which a tool may be invoked, derived by the
|
||||
// channel/session layer from protocol-confirmed sender identity plus the owner's
|
||||
// whitelist — never from prompt inference.
|
||||
type ActorEnv string
|
||||
|
||||
const (
|
||||
// ActorOwner is the primary owner's private session.
|
||||
ActorOwner ActorEnv = "owner"
|
||||
// ActorGroupMember is a chat group where the owner is present.
|
||||
ActorGroupMember ActorEnv = "group_member"
|
||||
// ActorStranger is a direct message from an unrecognized sender.
|
||||
ActorStranger ActorEnv = "stranger"
|
||||
)
|
||||
|
||||
// Valid reports whether e is one of the known actor environments.
|
||||
func (e ActorEnv) Valid() bool {
|
||||
switch e {
|
||||
case ActorOwner, ActorGroupMember, ActorStranger:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Matcher is the subset of a capability registry the policy engine reads. It is
|
||||
// implemented by *registry.Registry.
|
||||
type Matcher interface {
|
||||
// Policy returns the editable reachability markers for the named capability.
|
||||
Policy(name string) (registry.ToolPolicy, error)
|
||||
}
|
||||
|
||||
// Compile-time assertion that the registry satisfies Matcher, expressed here so
|
||||
// a breaking registry change fails at the restrict build.
|
||||
var _ Matcher = (*registry.Registry)(nil)
|
||||
|
||||
// Policy is an actor-environment decision engine. It is immutable after it is
|
||||
// built, so it is safe for concurrent use.
|
||||
type Policy struct {
|
||||
reg Matcher
|
||||
env ActorEnv
|
||||
log *slog.Logger
|
||||
}
|
||||
|
||||
// SetContext builds a Policy scoped to env, reading tool/capability markers from
|
||||
// reg. The runtime (M6) binds an actor environment to an engine through this
|
||||
// entry point. A nil reg yields a fail-closed policy that denies every tool.
|
||||
func SetContext(reg Matcher, env ActorEnv) *Policy {
|
||||
return &Policy{
|
||||
reg: reg,
|
||||
env: env,
|
||||
log: slog.New(slog.NewTextHandler(io.Discard, nil)),
|
||||
}
|
||||
}
|
||||
|
||||
// WithLogger returns a copy of p that logs denials through l with the
|
||||
// capability/restrict source tag. It does not mutate p, so it is safe to call
|
||||
// alongside concurrent Allow calls on p.
|
||||
func (p *Policy) WithLogger(l *slog.Logger) *Policy {
|
||||
if p == nil || l == nil {
|
||||
return p
|
||||
}
|
||||
cp := *p
|
||||
cp.log = logger.WithSource(l, "capability/restrict")
|
||||
return &cp
|
||||
}
|
||||
|
||||
// Env returns the actor environment the policy was built for.
|
||||
func (p *Policy) Env() ActorEnv {
|
||||
if p == nil {
|
||||
return ""
|
||||
}
|
||||
return p.env
|
||||
}
|
||||
|
||||
// Allow reports whether the tool/capability named name may be invoked in p's
|
||||
// environment. It reads the capability's editable markers. The owner environment
|
||||
// always allows; a nil registry, an unknown tool, an unknown environment, or a
|
||||
// false marker all deny (fail closed).
|
||||
func (p *Policy) Allow(name string) bool {
|
||||
if p == nil {
|
||||
return false
|
||||
}
|
||||
if p.env == ActorOwner {
|
||||
return true
|
||||
}
|
||||
if p.reg == nil {
|
||||
p.logDeny(name, "nil registry", "")
|
||||
return false
|
||||
}
|
||||
pol, err := p.reg.Policy(name)
|
||||
if err != nil {
|
||||
p.logDeny(name, "policy error", err.Error())
|
||||
return false
|
||||
}
|
||||
switch p.env {
|
||||
case ActorGroupMember:
|
||||
if !pol.AllowInGroup {
|
||||
p.logDeny(name, "denied in group", "allow_in_group=false")
|
||||
return false
|
||||
}
|
||||
return true
|
||||
case ActorStranger:
|
||||
if !pol.AllowInStranger {
|
||||
p.logDeny(name, "denied to stranger", "allow_in_stranger=false")
|
||||
return false
|
||||
}
|
||||
return true
|
||||
default:
|
||||
p.logDeny(name, "unknown env", string(p.env))
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Deny is the inverse of Allow.
|
||||
func (p *Policy) Deny(name string) bool { return !p.Allow(name) }
|
||||
|
||||
func (p *Policy) logDeny(name, reason, detail string) {
|
||||
if p == nil || p.log == nil {
|
||||
return
|
||||
}
|
||||
args := []any{"tool", name, "reason", reason}
|
||||
if detail != "" {
|
||||
args = append(args, "detail", detail)
|
||||
}
|
||||
p.log.Warn("tool invocation denied by policy", args...)
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
package restrict
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry"
|
||||
)
|
||||
|
||||
// toolStub is a mock Tool used only to register a capability by name.
|
||||
type toolStub struct{ name string }
|
||||
|
||||
func (t toolStub) Name() string { return t.name }
|
||||
func (t toolStub) Description() string { return "desc " + t.name }
|
||||
func (t toolStub) Parameters() (map[string]any, error) { return map[string]any{}, nil }
|
||||
func (t toolStub) Execute(_ context.Context, _ any) (any, error) { return nil, nil }
|
||||
|
||||
func mustRegTool(t *testing.T, r *registry.Registry, name string) {
|
||||
t.Helper()
|
||||
if err := r.RegisterTool(toolStub{name: name}); err != nil {
|
||||
t.Fatalf("RegisterTool(%s): %v", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetContext_OwnerAllowsAll(t *testing.T) {
|
||||
t.Parallel()
|
||||
r := registry.New()
|
||||
mustRegTool(t, r, "calc")
|
||||
p := SetContext(r, ActorOwner)
|
||||
if !p.Allow("calc") {
|
||||
t.Fatal("owner should allow an owner-only tool")
|
||||
}
|
||||
if !p.Allow("missing") {
|
||||
t.Fatal("owner should allow a tool that is not registered")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAllow_GroupAndStrangerMarkers(t *testing.T) {
|
||||
t.Parallel()
|
||||
r := registry.New()
|
||||
mustRegTool(t, r, "groupOnly")
|
||||
mustRegTool(t, r, "strangerOnly")
|
||||
mustRegTool(t, r, "both")
|
||||
mustRegTool(t, r, "ownerOnly")
|
||||
if err := r.SetPolicy("groupOnly", registry.ToolPolicy{AllowInGroup: true}); err != nil {
|
||||
t.Fatalf("SetPolicy groupOnly: %v", err)
|
||||
}
|
||||
if err := r.SetPolicy("strangerOnly", registry.ToolPolicy{AllowInStranger: true}); err != nil {
|
||||
t.Fatalf("SetPolicy strangerOnly: %v", err)
|
||||
}
|
||||
if err := r.SetPolicy("both", registry.ToolPolicy{AllowInGroup: true, AllowInStranger: true}); err != nil {
|
||||
t.Fatalf("SetPolicy both: %v", err)
|
||||
}
|
||||
|
||||
group := SetContext(r, ActorGroupMember)
|
||||
stranger := SetContext(r, ActorStranger)
|
||||
|
||||
if !group.Allow("groupOnly") || !group.Allow("both") {
|
||||
t.Fatal("group member should be allowed groupOnly and both")
|
||||
}
|
||||
if group.Allow("strangerOnly") {
|
||||
t.Fatal("group member should be denied strangerOnly")
|
||||
}
|
||||
if group.Allow("ownerOnly") {
|
||||
t.Fatal("group member should be denied ownerOnly")
|
||||
}
|
||||
|
||||
if !stranger.Allow("strangerOnly") || !stranger.Allow("both") {
|
||||
t.Fatal("stranger should be allowed strangerOnly and both")
|
||||
}
|
||||
if stranger.Allow("groupOnly") {
|
||||
t.Fatal("stranger should be denied groupOnly")
|
||||
}
|
||||
if stranger.Allow("ownerOnly") {
|
||||
t.Fatal("stranger should be denied ownerOnly")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAllow_FailClosed(t *testing.T) {
|
||||
t.Parallel()
|
||||
// A nil registry denies every tool in a non-owner environment.
|
||||
if p := SetContext(nil, ActorStranger); p.Allow("x") {
|
||||
t.Fatal("nil reg should deny in stranger env")
|
||||
}
|
||||
if p := SetContext(nil, ActorGroupMember); p.Allow("x") {
|
||||
t.Fatal("nil reg should deny in group env")
|
||||
}
|
||||
|
||||
r := registry.New()
|
||||
mustRegTool(t, r, "t")
|
||||
|
||||
// An unknown environment denies even a registered tool.
|
||||
if p := SetContext(r, ActorEnv("bogus")); p.Allow("t") {
|
||||
t.Fatal("unknown env should deny")
|
||||
}
|
||||
// An unregistered tool denies.
|
||||
if p := SetContext(r, ActorGroupMember); p.Allow("unknown") {
|
||||
t.Fatal("unknown tool should deny")
|
||||
}
|
||||
// A nil Policy receiver denies.
|
||||
var nilPolicy *Policy
|
||||
if nilPolicy.Allow("t") {
|
||||
t.Fatal("nil Policy receiver should deny")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeny_IsInverse(t *testing.T) {
|
||||
t.Parallel()
|
||||
r := registry.New()
|
||||
mustRegTool(t, r, "yes")
|
||||
if err := r.SetAllowInGroup("yes", true); err != nil {
|
||||
t.Fatalf("SetAllowInGroup: %v", err)
|
||||
}
|
||||
mustRegTool(t, r, "no")
|
||||
|
||||
p := SetContext(r, ActorGroupMember)
|
||||
if p.Allow("yes") != !p.Deny("yes") {
|
||||
t.Fatal("Allow and Deny are not inverses for an allowed tool")
|
||||
}
|
||||
if !p.Deny("no") {
|
||||
t.Fatal("Deny(no) should be true because no is owner-only")
|
||||
}
|
||||
}
|
||||
|
||||
func TestActorEnv_Valid(t *testing.T) {
|
||||
t.Parallel()
|
||||
for _, env := range []ActorEnv{ActorOwner, ActorGroupMember, ActorStranger} {
|
||||
if !env.Valid() {
|
||||
t.Fatalf("%q should be valid", env)
|
||||
}
|
||||
}
|
||||
if ActorEnv("bogus").Valid() {
|
||||
t.Fatal("bogus env should be invalid")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPolicy_EnvAndWithLogger(t *testing.T) {
|
||||
t.Parallel()
|
||||
r := registry.New()
|
||||
mustRegTool(t, r, "t")
|
||||
|
||||
p := SetContext(r, ActorStranger)
|
||||
if p.Env() != ActorStranger {
|
||||
t.Fatalf("Env() = %q, want stranger", p.Env())
|
||||
}
|
||||
// WithLogger returns a copy and leaves the original unflagged, so the
|
||||
// original's discarding logger still admits no output.
|
||||
cp := p.WithLogger(nil)
|
||||
if cp == nil {
|
||||
t.Fatal("WithLogger(nil) returned nil")
|
||||
}
|
||||
if cp.Env() != ActorStranger {
|
||||
t.Fatalf("copy Env() = %q, want stranger", cp.Env())
|
||||
}
|
||||
// The copy still denies and reports the same decision.
|
||||
if cp.Allow("t") {
|
||||
t.Fatal("stranger t should be denied without allow_in_stranger")
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user