fix(session): 群聊/陌生人记忆隔离——MemWritable 贯穿 loop(AGENTS.md 红线)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

群聊/陌生人查记忆只读不写、不泄露主人敏感;owner 默认行为完全不变。

- loop/sessionenv.go:SessionEnv 类型 + WithSessionEnv/sessionEnvFrom/OwnerEnv——每次 Run 携带会话
  隔离上下文,经 context.Context 传递(不改 Run 签名、不存 Loop/Options 字段,避免 16 并发渠道
  消息共享 Loop 的数据竞争;Chatter 接口与测试桩签名不变)。
- loop/memory.go 门控:injectMemory 读——MemWritable=true(owner)走原 Retriever.Inject 全量不变;
  false(群聊/陌生人)改 Retrieve 后只留 ScopeProject(人人可读)、剔除 ScopeUser(owner习惯私有)与
  ScopePersona(角色日常=owner敏感),块首加「群聊隔离」标记,无项目记忆不注入;extractMemory 写
  false 直接 return 跳过(不调用 extractor 不落库)。
- 渠道入站(Channelbridge)传真实会话 MemWritable/Env;Web/REST/WS/voice chat 绑 OwnerEnv(JWT=owner
  行为不变);backthink/automations/orchestration 默认 owner 全量。
- 测试:memory_gating_test 6 用例(owner/群聊只读不写/陌生人受限/无项目记忆不注入/默认owner全量)全绿;
  loop/session/gateway 既有测试无回归。go build/vet/test 47 包全绿。

遗留:ScopeProject 过滤在 loop 内后处理实现(未改 memory 包),若后续允许可在 Retriever 加 scope
过滤选项消除与 Inject 模板的重复(2处)。
This commit is contained in:
Aska committed 2026-09-06 20:34:06 +08:00
1 parent dbcf3df8a3
commit adad4e1f62
7 files changed
+297 -6

No files matched your search

+65 -2
View File
@@ -4,6 +4,7 @@ import (
"context"
"strings"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
)
@@ -13,13 +14,20 @@ import (
// retrieval reflects the latest conversation rather than a first-turn snapshot. It
// returns the input messages unchanged when no retriever/persona is configured,
// retrieval fails, or nothing relevant is found.
//
// The session env on ctx gates the retrieval: the owner-full env keeps the full
// recall (unchanged behavior); the restricted env (group chat / stranger,
// design/02 §3, design/11 §2) is read-only isolation — only non-owner-private
// project memory is injected so no owner-sensitive memory leaks to a group or
// stranger.
func (l *Loop) injectMemory(ctx context.Context, messages []model.Message) []model.Message {
if l.retriever == nil || l.personaID == "" {
return messages
}
block, err := l.retriever.Inject(ctx, l.personaID, queryFromMessages(messages), 0)
env := sessionEnvFrom(ctx)
block, err := l.retrieveMemoryBlock(ctx, env, queryFromMessages(messages))
if err != nil {
l.log.Warn("memory inject", "error", err)
l.log.Warn("memory inject", "error", err, "env", env.ActorEnv)
return messages
}
if block == "" {
@@ -31,6 +39,51 @@ func (l *Loop) injectMemory(ctx context.Context, messages []model.Message) []mod
return out
}
// retrieveMemoryBlock returns the memory injection block for env. The owner-full
// env keeps the pre-existing Inject behavior. The restricted env (group chat /
// stranger) injects only project-scope memory (人人可读, not owner-private) and
// marks the block as group-isolated so callers can tell an isolated read from the
// owner's full read.
func (l *Loop) retrieveMemoryBlock(ctx context.Context, env SessionEnv, query string) (string, error) {
if !env.Restricted() {
return l.retriever.Inject(ctx, l.personaID, query, 0)
}
results, err := l.retriever.Retrieve(ctx, l.personaID, query, 0)
if err != nil {
return "", err
}
shared := make([]memory.Result, 0, len(results))
for _, r := range results {
if r.Item.Scope == memory.ScopeProject {
shared = append(shared, r)
}
}
if len(shared) == 0 {
return "", nil
}
return formatMemoryBlock(shared, "群聊隔离"), nil
}
// formatMemoryBlock builds the leading system block from retrieved results. The
// non-empty marker prefixes the block so an isolated group read is distinguishable
// from the owner's full read.
func formatMemoryBlock(results []memory.Result, marker string) string {
var b strings.Builder
if marker != "" {
b.WriteString(marker)
b.WriteString(":")
}
b.WriteString("相关记忆(按相关度 × 衰减排序):")
for _, res := range results {
b.WriteByte('\n')
b.WriteString("- [")
b.WriteString(string(res.Item.Kind))
b.WriteString("] ")
b.WriteString(res.Item.Content)
}
return b.String()
}
// assembleContext builds the model-visible message list for one round (design
// §1.3 每轮 assemble, the loop's equivalent of OpenClaw's transformContext): it
// re-injects the persona's relevant long-term memory — retrieval is re-run every
@@ -78,7 +131,17 @@ func (l *Loop) compactIfNeeded(ctx context.Context, messages []model.Message) []
// extractMemory runs the extraction hook (design §6) over the current
// conversation and persists any derived items. Errors are logged, never fatal, so
// a bad extraction cannot abort the run.
//
// In a restricted context (group chat / stranger, !MemWritable) extraction is
// skipped entirely: the conversation may read shared memory read-only and may
// never persist anything that could leak owner-sensitive information (design/02
// §3, design/11 §2).
func (l *Loop) extractMemory(ctx context.Context, messages []model.Message, step int) {
env := sessionEnvFrom(ctx)
if env.Restricted() {
l.log.Debug("memory extract skipped: read-only context", "step", step, "env", env.ActorEnv)
return
}
if l.extractor == nil || l.memory == nil {
return
}
+153
View File
@@ -0,0 +1,153 @@
package loop
import (
"context"
"strings"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/restrict"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
)
// seedMemories fills the store with one memory per scope so a test can assert the
// scope filtering of the memory gate.
func seedMemories(t *testing.T, store *memory.Store) {
t.Helper()
ctx := context.Background()
seeds := []memory.MemoryItem{
{PersonaID: "persona-a", Content: "用户习惯:喜欢冰美式", Scope: memory.ScopeUser, Kind: memory.KindRelationship},
{PersonaID: "persona-a", Content: "角色日常:周末喜欢爬山", Scope: memory.ScopePersona, Kind: memory.KindRelationship},
{PersonaID: "persona-a", Content: "项目记忆:Cyrene 用 Go 编写", Scope: memory.ScopeProject, Kind: memory.KindWorldEvent},
}
for _, it := range seeds {
if _, err := store.Add(ctx, it); err != nil {
t.Fatalf("seed memory: %v", err)
}
}
}
// TestInjectMemory_OwnerEnv_InjectsAllScopes proves the owner-full env keeps the
// pre-existing full recall: user, persona and project memories are all injected.
func TestInjectMemory_OwnerEnv_InjectsAllScopes(t *testing.T) {
t.Parallel()
store := newTestMemoryStore(t)
seedMemories(t, store)
l := New(Options{Retriever: memory.NewRetriever(store), PersonaID: "persona-a"})
msgs := []model.Message{model.TextMessage(model.RoleUser, "go")}
ctx := WithSessionEnv(context.Background(), OwnerEnv())
view := l.assembleContext(ctx, msgs, 1)
if len(view) != 2 {
t.Fatalf("owner view = %d messages, want 2 (memory injected)", len(view))
}
block := messageText(view[0])
for _, want := range []string{"喜欢冰美式", "喜欢爬山", "Cyrene 用 Go 编写", "相关记忆"} {
if !strings.Contains(block, want) {
t.Fatalf("owner block missing %q:\n%s", want, block)
}
}
}
// TestInjectMemory_RestrictedEnv_OnlyProject proves the group-chat/stranger env is
// read-only isolation: only the non-owner-private project memory is injected, the
// owner's user/persona memory is excluded, and the block is marked 群聊隔离.
func TestInjectMemory_RestrictedEnv_OnlyProject(t *testing.T) {
t.Parallel()
store := newTestMemoryStore(t)
seedMemories(t, store)
l := New(Options{Retriever: memory.NewRetriever(store), PersonaID: "persona-a"})
msgs := []model.Message{model.TextMessage(model.RoleUser, "go")}
ctx := WithSessionEnv(context.Background(), SessionEnv{ActorEnv: restrict.ActorGroupMember, MemWritable: false})
view := l.assembleContext(ctx, msgs, 1)
if len(view) != 2 {
t.Fatalf("restricted view = %d messages, want 2 (project memory injected)", len(view))
}
block := messageText(view[0])
if !strings.Contains(block, "群聊隔离") || !strings.Contains(block, "Cyrene 用 Go 编写") {
t.Fatalf("restricted block missing project memory / marker:\n%s", block)
}
for _, banned := range []string{"喜欢冰美式", "喜欢爬山"} {
if strings.Contains(block, banned) {
t.Fatalf("restricted block leaked owner memory %q:\n%s", banned, block)
}
}
}
// TestInjectMemory_Restricted_NoProjectMemory_NoInject proves that when a group
// or stranger context has no non-owner-private (project) memory, nothing is
// injected — the conversation is not seeded with owner context.
func TestInjectMemory_Restricted_NoProjectMemory_NoInject(t *testing.T) {
t.Parallel()
store := newTestMemoryStore(t)
// Only owner-private (user/persona) memory exists.
for _, it := range []memory.MemoryItem{
{PersonaID: "persona-a", Content: "用户习惯:喜欢冰美式", Scope: memory.ScopeUser, Kind: memory.KindRelationship},
} {
if _, err := store.Add(context.Background(), it); err != nil {
t.Fatalf("seed memory: %v", err)
}
}
l := New(Options{Retriever: memory.NewRetriever(store), PersonaID: "persona-a"})
msgs := []model.Message{model.TextMessage(model.RoleUser, "go")}
ctx := WithSessionEnv(context.Background(), SessionEnv{ActorEnv: restrict.ActorStranger, MemWritable: false})
view := l.assembleContext(ctx, msgs, 1)
if len(view) != 1 {
t.Fatalf("restricted (no project) view = %d messages, want 1 (no injected memory)", len(view))
}
}
// TestExtractMemory_OwnerEnv_Writes proves the owner-full env persists extracted
// memory as before.
func TestExtractMemory_OwnerEnv_Writes(t *testing.T) {
t.Parallel()
store := newTestMemoryStore(t)
l := New(Options{Memory: store, Extractor: memory.NewManualExtractor("persona-a", "owner"), PersonaID: "persona-a"})
ctx := WithSessionEnv(context.Background(), OwnerEnv())
l.extractMemory(ctx, []model.Message{model.TextMessage(model.RoleUser, "记住 我爱喝冰美式")}, 1)
items, err := store.ListByPersona(context.Background(), "persona-a")
if err != nil {
t.Fatalf("list: %v", err)
}
if len(items) != 1 {
t.Fatalf("owner extract persisted %d items, want 1", len(items))
}
}
// TestExtractMemory_RestrictedEnv_SkipsWrite proves the group-chat/stranger env
// never writes shared memory: extraction is skipped, the store stays empty.
func TestExtractMemory_RestrictedEnv_SkipsWrite(t *testing.T) {
t.Parallel()
store := newTestMemoryStore(t)
l := New(Options{Memory: store, Extractor: memory.NewManualExtractor("persona-a", "owner"), PersonaID: "persona-a"})
ctx := WithSessionEnv(context.Background(), SessionEnv{ActorEnv: restrict.ActorGroupMember, MemWritable: false})
l.extractMemory(ctx, []model.Message{model.TextMessage(model.RoleUser, "记住 我爱喝冰美式")}, 1)
items, err := store.ListByPersona(context.Background(), "persona-a")
if err != nil {
t.Fatalf("list: %v", err)
}
if len(items) != 0 {
t.Fatalf("restricted extract wrote %d items, want 0 (read-only isolation)", len(items))
}
}
// TestSessionEnvFrom_DefaultOwnerFull locks the backward-compatible default: a
// context with no attached env resolves to the owner-full environment, so callers
// that do not set an env (Web main session, internal automated tasks) keep full
// memory read+write.
func TestSessionEnvFrom_DefaultOwnerFull(t *testing.T) {
t.Parallel()
env := sessionEnvFrom(context.Background())
if env.MemWritable != true || env.Restricted() {
t.Fatalf("default env = %+v, want owner-full (MemWritable=true, not restricted)", env)
}
if env.ActorEnv != restrict.ActorOwner {
t.Fatalf("default env actor = %q, want %q", env.ActorEnv, restrict.ActorOwner)
}
}
+62
View File
@@ -0,0 +1,62 @@
package loop
import (
"context"
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/restrict"
)
// SessionEnv is the per-conversation context that gates memory injection and
// extraction for one Run call. It travels on the call's context rather than on
// the shared Loop because Loop is reused across concurrent sessions (every
// channel/group/stranger message resolves its own isolation boundary); storing it
// on the Loop struct would race.
type SessionEnv struct {
// ActorEnv is the resolved actor environment: owner, group_member, stranger.
// It is the strongly-typed identity lane decided structurally by the channel
// layer (design/11 §0), never inferred from prompt text.
ActorEnv restrict.ActorEnv
// MemWritable reports whether this conversation may write shared memory. It is
// true only for the owner's private conversation; group chats and strangers
// are read-only (design/02 §3, design/11 §2).
MemWritable bool
}
// Restricted reports whether this is a group-chat or stranger conversation, i.e.
// the read-only memory isolation context. Because MemWritable is true only for
// the owner's private conversation, !MemWritable is exactly the restricted set.
func (e SessionEnv) Restricted() bool { return !e.MemWritable }
// OwnerEnv returns the full-access owner session environment (private
// conversation, memory read+write). It is the Web main-session and internal
// automated-task default, matching the pre-existing owner-full behavior.
func OwnerEnv() SessionEnv {
return SessionEnv{ActorEnv: restrict.ActorOwner, MemWritable: true}
}
// defaultSessionEnv is the owner-full environment used when a caller attaches no
// explicit env: full memory read and write, the pre-existing behavior.
func defaultSessionEnv() SessionEnv {
return OwnerEnv()
}
type sessionEnvKey struct{}
// WithSessionEnv attaches env to ctx so Run's memory gates honor the
// group/stranger read-only isolation per call. It is concurrency-safe: the env
// travels with the call's context and is never stored on the shared Loop.
func WithSessionEnv(ctx context.Context, env SessionEnv) context.Context {
return context.WithValue(ctx, sessionEnvKey{}, env)
}
// sessionEnvFrom returns the env attached to ctx, or the owner-full default when
// none was attached (backward compatible).
func sessionEnvFrom(ctx context.Context) SessionEnv {
if ctx == nil {
return defaultSessionEnv()
}
if env, ok := ctx.Value(sessionEnvKey{}).(SessionEnv); ok {
return env
}
return defaultSessionEnv()
}
@@ -313,7 +313,11 @@ func (b *Bridge) process(ctx context.Context, msg adapter.UnifiedMessage) {
"session", sess.SessionID, "actor_role", sess.ActorRole,
"env", sess.Env(), "mem_writable", sess.MemWritable)
res, err := b.run.Run(ctx, sess.SessionID, []model.Message{
// Thread the resolved isolation boundary into the loop's memory gates: the
// owner's private conversation reads+writes memory, group chats and strangers
// are read-only isolation (design/02 §3, design/11 §2).
runEnv := loop.SessionEnv{ActorEnv: sess.Env(), MemWritable: sess.MemWritable}
res, err := b.run.Run(loop.WithSessionEnv(ctx, runEnv), sess.SessionID, []model.Message{
model.TextMessage(model.RoleUser, text),
})
if err != nil {
+4 -1
View File
@@ -9,6 +9,7 @@ import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/session"
apperr "git.yeij.top/AskaEth/Cyrene_v2/internal/errors"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
)
@@ -127,7 +128,9 @@ func (s *Server) handleChat(w http.ResponseWriter, r *http.Request) {
if req.SessionID == "" {
req.SessionID = "web"
}
res, err := s.runner.Run(r.Context(), req.SessionID, userChatMessages(req.Text, req.Image))
// The Web main session is the owner's private conversation (JWT = owner), so
// it binds the full-access env: memory read+write, unchanged behavior.
res, err := s.runner.Run(loop.WithSessionEnv(r.Context(), loop.OwnerEnv()), req.SessionID, userChatMessages(req.Text, req.Image))
if err != nil {
s.log.Error("chat run", "err", err, "session", req.SessionID)
writeError(w, apperr.Wrap(apperr.ErrInternal, "chat_failed", "chat failed"))
+4 -1
View File
@@ -7,6 +7,7 @@ import (
"fmt"
"strings"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/multimodal/voice"
)
@@ -96,7 +97,9 @@ func (h *hub) runVoiceTurn(c *wsConn, req voiceInputRequest) {
h.voiceError(c, "chat service is not configured")
return
}
res, err := h.runner.Run(ctx, req.SessionID, []model.Message{model.TextMessage(model.RoleUser, transcript)})
// The Web main session is the owner's private conversation, so it binds the
// full-access env: memory read+write, unchanged behavior.
res, err := h.runner.Run(loop.WithSessionEnv(ctx, loop.OwnerEnv()), req.SessionID, []model.Message{model.TextMessage(model.RoleUser, transcript)})
if err != nil {
h.log.Warn("voice chat run", "err", err, "session", req.SessionID)
h.voiceError(c, "voice chat failed")
+4 -1
View File
@@ -17,6 +17,7 @@ import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
"git.yeij.top/AskaEth/Cyrene_v2/internal/bus"
apperr "git.yeij.top/AskaEth/Cyrene_v2/internal/errors"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
)
@@ -223,7 +224,9 @@ func (h *hub) streamChat(ctx context.Context, payload any) {
if req.SessionID == "" {
req.SessionID = "web"
}
res, err := h.runner.Run(ctx, req.SessionID, userChatMessages(req.Text, req.Image))
// The Web main session is the owner's private conversation (JWT = owner), so
// it binds the full-access env: memory read+write, unchanged behavior.
res, err := h.runner.Run(loop.WithSessionEnv(ctx, loop.OwnerEnv()), req.SessionID, userChatMessages(req.Text, req.Image))
if err != nil {
h.log.Error("ws chat run", "err", err, "session", req.SessionID)
h.broadcast(wsEvent{Type: "stream_end", TurnID: turnID, Seq: int(h.streamSeq.Add(1)), Data: map[string]any{"stopReason": "error", "error": err.Error()}})