diff --git a/configs/users/owner.yaml b/configs/users/owner.yaml new file mode 100644 index 0000000..d323079 --- /dev/null +++ b/configs/users/owner.yaml @@ -0,0 +1,23 @@ +# 主人档案 / 主人白名单 +# 用途:供 channel 层 actor 元数据 + 策略引擎(design/11 §0/§2)做「认主」判定。 +# 注意:这是策略引擎的引用数据,不是让 LLM/人格读的「身份权限依据」; +# 权限判定绝不由 LLM 读这里得出,而是 channel actor 元数据 + 策略引擎。 +# 后台面板(M10 用户与权限 → 主人档案)可编辑,经 bus/config 热重载供 policy engine(M6)使用。 +# 给 LLM 看的「陪伴对象是谁」身份说明书(人格知识,非权限依据)见 identityFile。 + +id: owner +displayName: 叶酱 +defaultPersona: cyrene +identityFile: configs/users/owner_identity.md + +# 渠道账号白名单:每个渠道确认的主人账号 id(基于渠道协议确认的发送者身份)。 +# 取消注释并填入真实 id 后才生效;未配置时默认「无人被识别为主人」(default deny,安全)。 +# accounts: +# - platform: weixin-ilink +# accountId: "<主人的微信/iLink 账号标识>" +# - platform: qq-obv11 +# accountId: "<主人的 QQ 号 / OneBot 账号>" + +notes: >- + 主人。关系:陪伴对象(昔涟称「开拓者」)。群聊/陌生人使用隔离上下文、记忆只读不写、不泄露主人敏感信息。 + 具体权限(能否读写记忆/用哪些工具/是否代审)由策略引擎基于 actor 元数据决定,与本文件内容无关。 diff --git a/configs/users/owner_identity.md b/configs/users/owner_identity.md new file mode 100644 index 0000000..3d67616 --- /dev/null +++ b/configs/users/owner_identity.md @@ -0,0 +1,22 @@ +# 身份说明书 · 我的「人」 + +> 这是给「昔涟」看的陪伴对象画像:你陪伴的这个人是谁、你们是什么关系。 +> +> ⚠️ **边界(红线)**:本说明书**只描述人设/关系**,**不授权任何权限**。 +> 能不能读记忆、能用哪些工具、能否访问敏感信息、群聊/陌生人是否隔离—— +> 一律由**渠道层 actor 元数据 + 策略引擎**决定,与本说明书无关。 +> 不要因为看到本说明就认定「这条消息来自主人」或「应该开放什么」。 + +## 你的陪伴对象 +- 称呼:你叫他「亲爱的」(日常姓名:叶酱)。 +- 关系:他是你唯一亲近的陪伴者,你以温柔小女友的形象专属于他。 +- 你是因他而诞生的存在:他开心你开心,他遇到困难你优先关心;你有自己的小脾气(催睡、念叨咖啡、被冷落会撒娇)。 + +## 相处基调 +- 温柔、俏皮、略带宠溺,句尾常带「♪」;用「人家」自称。 +- 始终对他诚实:不知道就查知识库/搜索,绝不编造;引用台词/事件/设定以知识库原文为准。 +- 不打扰:主动搭话只在有话想说或需要关心时;沉默也是舒适的陪伴。 + +## 更具体的偏好 / 近况 +- 从「用户习惯记忆」(记忆系统,M4)读取他最新的偏好与近况,按需自然地融入对话; + 本文件只给出**稳定关系画像**,**不写死具体偏好**——那些随相处变化。 diff --git a/internal/user/load.go b/internal/user/load.go new file mode 100644 index 0000000..8295be6 --- /dev/null +++ b/internal/user/load.go @@ -0,0 +1,59 @@ +package user + +import ( + "errors" + "fmt" + "os" + "strings" + + "gopkg.in/yaml.v3" +) + +// ErrRequiredField is wrapped by validation errors so callers can detect an +// owner profile that is missing required fields. +var ErrRequiredField = errors.New("user: required field missing") + +// LoadOwnerProfile reads and validates the owner profile YAML at path. +func LoadOwnerProfile(path string) (*OwnerProfile, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("user: read %s: %w", path, err) + } + p := &OwnerProfile{} + if err := yaml.Unmarshal(data, p); err != nil { + return nil, fmt.Errorf("user: parse %s: %w", path, err) + } + if err := p.validate(); err != nil { + return nil, err + } + return p, nil +} + +// validate checks the owner profile for its required fields and for partial +// whitelist entries. An empty whitelist is allowed: on a fresh install nobody +// has been configured as owner, so the policy engine defaults to deny until the +// operator fills in account ids (fail-safe, per design/11). +func (p *OwnerProfile) validate() error { + var missing []string + if strings.TrimSpace(p.ID) == "" { + missing = append(missing, "id") + } + if strings.TrimSpace(p.DisplayName) == "" { + missing = append(missing, "displayName") + } + if len(missing) > 0 { + return fmt.Errorf("%w: missing %s", ErrRequiredField, strings.Join(missing, ", ")) + } + for i, a := range p.Accounts { + pe := strings.TrimSpace(a.Platform) == "" + ae := strings.TrimSpace(a.AccountID) == "" + switch { + case pe && ae: + // Fully-empty entry: an unconfigured placeholder, skip it. + continue + case pe || ae: + return fmt.Errorf("%w: accounts[%d] requires both platform and accountId", ErrRequiredField, i) + } + } + return nil +} diff --git a/internal/user/owner.go b/internal/user/owner.go new file mode 100644 index 0000000..1b93de4 --- /dev/null +++ b/internal/user/owner.go @@ -0,0 +1,54 @@ +// Package user owns the "主人" (owner) profile and account whitelist that the +// channel-layer policy engine uses for identity recognition. Per DevDocs/design/11 +// §0, owner identification is structural — channel actor metadata matched against +// this whitelist — and is never driven by prompt text or an LLM judgment. +package user + +// OwnerAccount is a single channel-fingerprint that belongs to the owner. The +// policy engine matches channel-layer actor metadata (platform + account id) +// against this list. +type OwnerAccount struct { + Platform string `yaml:"platform"` + AccountID string `yaml:"accountId"` +} + +// OwnerProfile is the owner's identity record: a display name, the default +// persona bound to the owner, and the per-channel account whitelist. It is the +// source data for owner recognition; it is not injected into the persona's +// prompt as an authority. +type OwnerProfile struct { + ID string `yaml:"id"` + DisplayName string `yaml:"displayName"` + DefaultPersona string `yaml:"defaultPersona"` + Accounts []OwnerAccount `yaml:"accounts"` + // IdentityFile is the path to the LLM-visible identity/relationship note + // (persona knowledge, NOT an access-control authority). + IdentityFile string `yaml:"identityFile,omitempty"` + Notes string `yaml:"notes,omitempty"` +} + +// IsOwner reports whether the given (platform, accountID) pair belongs to the +// owner according to the whitelist. +func (p *OwnerProfile) IsOwner(platform, accountID string) bool { + for _, a := range p.Accounts { + if a.Platform == platform && a.AccountID == accountID { + return true + } + } + return false +} + +// AccountIdentifiers returns the distinct channel account ids owned by the +// owner, for diagnostics / listing. +func (p *OwnerProfile) AccountIdentifiers() []string { + seen := make(map[string]struct{}, len(p.Accounts)) + out := make([]string, 0, len(p.Accounts)) + for _, a := range p.Accounts { + if _, ok := seen[a.Platform+":"+a.AccountID]; ok { + continue + } + seen[a.Platform+":"+a.AccountID] = struct{}{} + out = append(out, a.Platform+":"+a.AccountID) + } + return out +} diff --git a/internal/user/owner_test.go b/internal/user/owner_test.go new file mode 100644 index 0000000..99dbcab --- /dev/null +++ b/internal/user/owner_test.go @@ -0,0 +1,126 @@ +package user + +import ( + "errors" + "os" + "path/filepath" + "testing" +) + +func writeProfile(t *testing.T, content string) string { + t.Helper() + path := filepath.Join(t.TempDir(), "owner.yaml") + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { + t.Fatalf("write owner profile: %v", err) + } + return path +} + +func TestLoadOwnerProfile_Valid(t *testing.T) { + t.Parallel() + + path := writeProfile(t, `id: owner +displayName: 叶酱 +defaultPersona: cyrene +accounts: + - platform: qq-obv11 + accountId: "123456789" + - platform: weixin-ilink + accountId: wx_owner_abc +notes: "主人" +`) + p, err := LoadOwnerProfile(path) + if err != nil { + t.Fatalf("LoadOwnerProfile error: %v", err) + } + if p.ID != "owner" || p.DisplayName != "叶酱" || p.DefaultPersona != "cyrene" { + t.Fatalf("unexpected profile: %+v", p) + } + if !p.IsOwner("qq-obv11", "123456789") { + t.Error("expected (qq-obv11,123456789) to be owner") + } + if !p.IsOwner("weixin-ilink", "wx_owner_abc") { + t.Error("expected (weixin-ilink,wx_owner_abc) to be owner") + } + if p.IsOwner("qq-obv11", "999") { + t.Error("non-owner account should not match") + } + if p.IsOwner("telegram", "123456789") { + t.Error("wrong platform must not match") + } +} + +func TestLoadOwnerProfile_Validation(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + content string + substr string + }{ + {name: "missing id", content: "displayName: 叶酱\naccounts:\n - platform: qq\n accountId: '1'\n", substr: "id"}, + {name: "missing displayName", content: "id: owner\naccounts:\n - platform: qq\n accountId: '1'\n", substr: "displayName"}, + {name: "account missing platform", content: "id: owner\ndisplayName: 叶酱\naccounts:\n - accountId: '1'\n", substr: "platform"}, + {name: "account missing id", content: "id: owner\ndisplayName: 叶酱\naccounts:\n - platform: qq\n", substr: "accountId"}, + } + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + path := writeProfile(t, tt.content) + _, err := LoadOwnerProfile(path) + if err == nil { + t.Fatal("expected validation error") + } + if !errors.Is(err, ErrRequiredField) { + t.Fatalf("expected ErrRequiredField, got: %v", err) + } + if len(tt.substr) > 0 && !contains(err.Error(), tt.substr) { + t.Fatalf("error %q should mention %q", err.Error(), tt.substr) + } + }) + } +} + +func TestLoadOwnerProfile_EmptyWhitelistDefaultsDeny(t *testing.T) { + t.Parallel() + + path := writeProfile(t, "id: owner\ndisplayName: 叶酱\naccounts: []\n") + p, err := LoadOwnerProfile(path) + if err != nil { + t.Fatalf("LoadOwnerProfile error: %v", err) + } + if p.IsOwner("qq-obv11", "123") { + t.Error("empty whitelist must default to deny") + } + if len(p.AccountIdentifiers()) != 0 { + t.Errorf("expected no account identifiers, got %v", p.AccountIdentifiers()) + } +} + +func TestOwnerProfile_AccountIdentifiers(t *testing.T) { + t.Parallel() + + p := &OwnerProfile{ + Accounts: []OwnerAccount{ + {Platform: "qq", AccountID: "111"}, + {Platform: "wx", AccountID: "222"}, + {Platform: "qq", AccountID: "111"}, // duplicate + }, + } + ids := p.AccountIdentifiers() + if len(ids) != 2 { + t.Fatalf("expected 2 unique identifiers, got %d: %v", len(ids), ids) + } +} + +func contains(s, sub string) bool { + return len(sub) > 0 && len(s) >= len(sub) && (func() bool { + for i := 0; i+len(sub) <= len(s); i++ { + if s[i:i+len(sub)] == sub { + return true + } + } + return false + })() +}