diff --git a/plugins/tools/io/knowledge.go b/plugins/tools/io/knowledge.go index 65a9e53..2741736 100644 --- a/plugins/tools/io/knowledge.go +++ b/plugins/tools/io/knowledge.go @@ -60,8 +60,11 @@ func (k *knowledgeTool) Parameters() (map[string]any, error) { return map[string]any{ "type": "object", "properties": map[string]any{ - "query": map[string]any{"type": "string", "description": "Query topic."}, - "persona": map[string]any{"type": "string", "description": "Persona scope id (optional; defaults to the persona of the current turn)."}, + "query": map[string]any{"type": "string", "description": "Query topic."}, + // persona 只作形状兼容:它必须**等于**本回合的角色,其他值一律被拒绝 + // (design/03 §7.1:检索他角色的 scope=persona 无路径)。说明里写死这 + // 一点,使合规模型根本不填它,而不是把它当成一个可自由选择的角色旋钮。 + "persona": map[string]any{"type": "string", "description": "Must equal the persona of the current turn; any other value is refused. Memory retrieval always runs as the persona of the current turn, so omit this field."}, "top_k": map[string]any{"type": "integer", "description": "Max results."}, }, "required": []any{"query"}, @@ -143,24 +146,41 @@ func (k *knowledgeTool) Execute(ctx context.Context, args any) (any, error) { return map[string]any{"query": query, "count": len(out), "results": out}, nil } -// resolvePersona 解析本次检索的角色作用域:显式参数 `persona` 优先;缺席时取 -// **本回合角色**(循环在 Run 顶部经 tool.WithTurnPersona 发布,见 -// internal/runtime/tool/turncontext.go)。 +// resolvePersona 解析本次检索的角色作用域:**只认本回合角色** +// (tool.TurnPersonaFrom;循环在 Run 顶部经 tool.WithTurnPersona 发布,见 +// internal/runtime/tool/turncontext.go)。显式参数 `persona` 只作形状兼容 —— +// 它**必须等于**本回合角色,不等即拒绝。 // -// 为什么必须解析:记忆检索按角色作用域隔离(design/03 §3/§4),拿不到角色就 -// 取不到正确的候选集。把空角色透给记忆存储只会换来它自己的 ErrMissingPersona +// 为什么角色不许由参数指定:检索按角色作用域隔离(design/03 §3/§4),而 +// 「直接检索他角色的 scope=persona(角色日常)」**没有路径**(design/03 §7.1 +// 的判据 + §7.6 R4 + retrieve.go 的 visibleCandidates:候选域 = ListByPersona +// ∪ ScopeUser ∪ ScopeProject)。本回合角色由编排层盖章、模型影响不到;`persona` +// 参数则来自模型输出(可被提示词注入操纵)—— 一旦认它,模型只要传 persona=B, +// 这条链就会以 **B 的角色**去检索,把 B 的角色日常读出来,直接越过 §7.1 的红线。 +// 跨角色读的**唯一**通路是把 B 当子代理派出去查(design/03 §7.2):那条通路上 +// B 本就是子代理回合的角色(orchestration 的 withPersonaEnv → personaFor → +// WithTurnPersona),TurnPersonaFrom(ctx) 天然是 B,因此这里不必也不许开任何口子。 +// +// 为什么是「校验 + 拒绝」而不是「直接删掉参数」:删掉参数后,模型传 persona=B +// 时会**静默**改用本回合角色 A —— 结果集虽不越权(A 只拿到 A 的可见域),但它 +// 会把 A 的记忆当成 B 的(归属错认),并且没有任何信号让它停止换角色重试。 +// 校验则给出可消化的拒绝(见 personaScopeError):写明不一致、要求去掉参数重试。 +// +// 为什么拿不到角色也要拒绝:记忆检索按角色作用域隔离(design/03 §3/§4),拿不到 +// 角色就取不到正确的候选集。把空角色透给记忆存储只会换来它自己的 ErrMissingPersona // (memory.Store.ListByPersona 的硬校验)—— 模型既不知道要填哪个角色 id,参数 -// 说明还写着 optional,于是只能对同一堵墙反复重试。两者都拿不到时返回可消化的 +// 说明还写着 optional,于是只能对同一堵墙反复重试。角色不可得时返回可消化的 // 拒绝,让模型改道(design/24 §122:knowledge 无库/不可用时**明确报错**, // 而不是从广告面消失)。 func (k *knowledgeTool) resolvePersona(ctx context.Context, m map[string]any) (string, error) { - if persona := strings.TrimSpace(argString(m, "persona")); persona != "" { - return persona, nil + persona := tool.TurnPersonaFrom(ctx) + if requested := strings.TrimSpace(argString(m, "persona")); requested != "" && requested != persona { + return "", personaScopeError(requested, persona) } - if persona := tool.TurnPersonaFrom(ctx); persona != "" { - return persona, nil + if persona == "" { + return "", unavailableKnowledgeError("本次会话没有解析出可用角色") } - return "", unavailableKnowledgeError("本次会话没有解析出可用角色") + return persona, nil } // unavailableKnowledgeError 构造「本工具不可用」这一类失败的可消化拒绝: @@ -170,3 +190,19 @@ func unavailableKnowledgeError(reason string) error { return fmt.Errorf("io: knowledge: %w(%s):重试不会成功,请改用其他工具或直接作答,不要重复调用本工具。", ErrKnowledgeUnavailable, reason) } + +// personaScopeError 构造「点名了别的角色」这一类**越权尝试**的拒绝。它与 +// unavailableKnowledgeError 同一口径(同样包住 ErrKnowledgeUnavailable,同样 +// 是「原因 + 明确要求改道 + 不许重复调用」),只是改道动作不同:本回合的角色 +// 仍然可用,模型去掉 persona 参数即可按本回合角色检索,不必放弃本工具。 +// +// 拒绝而不是静默改用本回合角色,以及为什么这类调用必须无处可去,见 +// resolvePersona 的注释与 design/03 §7.1/§7.6 R4。 +func personaScopeError(requested, turn string) error { + if turn == "" { + return fmt.Errorf("io: knowledge: %w(本回合没有解析出可用角色,不能以 persona=%q 的身份检索,记忆检索只以本回合角色的身份进行):去掉 persona 参数后重试,或改用其他工具;不要以其他角色重复调用本工具。", + ErrKnowledgeUnavailable, requested) + } + return fmt.Errorf("io: knowledge: %w(本回合的角色是 %q,persona=%q 与它不一致,记忆检索只以本回合角色的身份进行,不能以其他角色的身份检索):去掉 persona 参数后重试;不要以其他角色重复调用本工具。", + ErrKnowledgeUnavailable, turn, requested) +} diff --git a/plugins/tools/io/knowledge_test.go b/plugins/tools/io/knowledge_test.go index 5f49f71..0f79a2b 100644 --- a/plugins/tools/io/knowledge_test.go +++ b/plugins/tools/io/knowledge_test.go @@ -29,6 +29,49 @@ func (s *stubRetriever) Retrieve(ctx context.Context, personaID, query string, t return s.results, s.err } +// personaScopedRetriever 按**检索角色**返回不同的命中集:以 personaID 检索时才会 +// 拿到该角色的角色日常记忆。它使「越权」断言可检验 —— 只要检索器收到了 B,B 的 +// 记忆就一定出现在结果里;因此「B 的内容一条都不许出现」等价于「不许以 B 检索」。 +type personaScopedRetriever struct { + byPersona map[string][]memory.Result + personas []string +} + +func (s *personaScopedRetriever) Retrieve(_ context.Context, personaID, query string, topK int) ([]memory.Result, error) { + s.personas = append(s.personas, personaID) + return s.byPersona[personaID], nil +} + +// personaScopedResults 给 persona-scoped 替身造一份固定命中:每个角色各有一条 +// 只有它自己看得到的角色日常记忆(scope=persona)。 +func personaScopedResults(persona, content string) []memory.Result { + return []memory.Result{{ + Item: memory.MemoryItem{ID: persona + "1", Content: content, Kind: memory.KindRelationship, Scope: memory.ScopePersona}, + Score: 0.9, + }} +} + +// knowledgeContentsOrEmpty 尽力从工具返回里抽出 content 集合;返回不是结果对象 +// (例如越权被拒时 got 为 nil)时给空集,使「不得出现某条内容」的断言在成功与 +// 失败两条路径上都能成立。 +func knowledgeContentsOrEmpty(got any) map[string]bool { + out := map[string]bool{} + m, ok := got.(map[string]any) + if !ok { + return out + } + results, ok := m["results"].([]map[string]any) + if !ok { + return out + } + for _, r := range results { + if s, ok := r["content"].(string); ok { + out[s] = true + } + } + return out +} + // knowledgeToolForTest builds a tools set with opts and returns the knowledge // tool. func knowledgeToolForTest(t *testing.T, opts ...Option) *knowledgeTool { @@ -107,19 +150,154 @@ func TestKnowledge_UsesTurnPersonaFromContext(t *testing.T) { } } -// TestKnowledge_ExplicitPersonaWins 断言显式参数优先于本回合角色:模型点名角色时 -// 尊重模型的选择(既有语义不变)。 -func TestKnowledge_ExplicitPersonaWins(t *testing.T) { +// TestKnowledge_RejectsPersonaArgForAnotherRole 是跨角色检索(design/03 §7.1 判据 + +// §7.6 R4)的证据型测试:本回合角色是 A,模型(或提示词注入)在参数里指名 B 时, +// **绝不许**以 B 的身份去检索 —— 必须明确拒绝,且结果里不得出现任何只有 B 看得到 +// 的角色日常记忆。 +// +// 旧用例 TestKnowledge_ExplicitPersonaWins 断言的正是缺陷行为本身(「显式参数优先, +// 检索角色 = [persona-arg]」)—— 它把「模型点名即换角色检索」钉成了规格,本次缺陷 +// 修复把它替换为本用例。 +func TestKnowledge_RejectsPersonaArgForAnotherRole(t *testing.T) { t.Parallel() - sr := &stubRetriever{} + const ( + personaA = "persona-a" + personaB = "persona-b" + contentB = "角色日常:B 深夜写歌" + ) + sr := &personaScopedRetriever{byPersona: map[string][]memory.Result{ + personaA: personaScopedResults(personaA, "角色日常:A 周末喜欢爬山"), + personaB: personaScopedResults(personaB, contentB), + }} kt := knowledgeToolForTest(t, WithKnowledgeRetriever(sr)) - ctx := tool.WithTurnPersona(context.Background(), "persona-turn") - if _, err := kt.Execute(ctx, map[string]any{"query": "回忆", "persona": "persona-arg"}); err != nil { - t.Fatalf("Execute: %v", err) + ctx := tool.WithTurnPersona(context.Background(), personaA) + got, err := kt.Execute(ctx, map[string]any{"query": "回忆", "persona": personaB}) + + if err == nil { + t.Fatalf("以他角色的身份检索必须被拒绝,got %+v", got) } - if len(sr.personas) != 1 || sr.personas[0] != "persona-arg" { - t.Fatalf("检索角色 = %v, want [persona-arg]", sr.personas) + if !errors.Is(err, ErrKnowledgeUnavailable) { + t.Fatalf("err = %v, want ErrKnowledgeUnavailable(调用方要能以 errors.Is 分类)", err) + } + if len(sr.personas) != 0 { + t.Fatalf("被拒的调用绝不该触达检索器(否则 B 的记忆已被读出): %v", sr.personas) + } + // 硬断言:任何一条来自 persona=B 的内容都不许出现(成功路径也不许)。 + if contents := knowledgeContentsOrEmpty(got); contents[contentB] { + t.Fatalf("越权检索泄露了 persona=B 的记忆: %v", contents) + } + msg := err.Error() + for _, want := range []string{personaA, personaB, "不能以其他角色的身份检索", "去掉 persona 参数", "不要以其他角色重复调用本工具"} { + if !strings.Contains(msg, want) { + t.Fatalf("拒绝文案缺少 %q(可消化的拒绝要写明原因 + 改道动作): %q", want, msg) + } + } +} + +// TestKnowledge_RejectsPersonaArgWhenTurnHasNoPersona 钉住另一半:本回合 ctx 上 +// **没有**角色时,参数里指名一个角色同样被拒绝(旧行为是以该参数当角色去检索, +// 即「凭空颁一个身份」,同样是越权面)。 +func TestKnowledge_RejectsPersonaArgWhenTurnHasNoPersona(t *testing.T) { + t.Parallel() + sr := &personaScopedRetriever{byPersona: map[string][]memory.Result{ + "persona-b": personaScopedResults("persona-b", "角色日常:B 深夜写歌"), + }} + kt := knowledgeToolForTest(t, WithKnowledgeRetriever(sr)) + + got, err := kt.Execute(context.Background(), map[string]any{"query": "回忆", "persona": "persona-b"}) + if err == nil { + t.Fatalf("本回合没有角色时不许以参数里的角色检索,got %+v", got) + } + if !errors.Is(err, ErrKnowledgeUnavailable) { + t.Fatalf("err = %v, want ErrKnowledgeUnavailable", err) + } + if len(sr.personas) != 0 { + t.Fatalf("被拒的调用不该触达检索器: %v", sr.personas) + } + if !strings.Contains(err.Error(), "本回合没有解析出可用角色") { + t.Fatalf("拒绝文案没有说明原因: %q", err.Error()) + } +} + +// TestKnowledge_AcceptsPersonaArgMatchingTurn 钉住形状兼容:参数里的角色**等于** +// 本回合角色时照常检索(旧调用形状不被破坏),且检索角色始终是本回合角色。 +func TestKnowledge_AcceptsPersonaArgMatchingTurn(t *testing.T) { + t.Parallel() + const personaA = "persona-a" + for _, args := range []map[string]any{ + {"query": "回忆", "persona": personaA}, + {"query": "回忆", "persona": " " + personaA + " "}, // 前后空白等价 + {"query": "回忆"}, // 缺席 + } { + sr := &stubRetriever{} + kt := knowledgeToolForTest(t, WithKnowledgeRetriever(sr)) + ctx := tool.WithTurnPersona(context.Background(), personaA) + if _, err := kt.Execute(ctx, args); err != nil { + t.Fatalf("args=%v Execute: %v", args, err) + } + if len(sr.personas) != 1 || sr.personas[0] != personaA { + t.Fatalf("args=%v 检索角色 = %v, want [%s]", args, sr.personas, personaA) + } + } +} + +// TestKnowledge_SubagentTurnReadsThatRole 钉住设计要保留的**唯一跨角色通路** +// (design/03 §7.2「B 作子代理查 B 的记忆」):子代理回合的角色由编排层盖章 +// (orchestration withPersonaEnv → loop personaFor → WithTurnPersona),此时 ctx +// 里的本回合角色本来就是 B,因此去掉参数优先语义**不破坏**这条通路。 +func TestKnowledge_SubagentTurnReadsThatRole(t *testing.T) { + t.Parallel() + const ( + personaB = "persona-b" + contentB = "角色日常:B 深夜写歌" + ) + for _, args := range []map[string]any{ + {"query": "最近在忙什么"}, // 省略 persona + {"query": "最近在忙什么", "persona": personaB}, // 形状兼容:与本回合角色一致 + } { + sr := &personaScopedRetriever{byPersona: map[string][]memory.Result{ + personaB: personaScopedResults(personaB, contentB), + }} + kt := knowledgeToolForTest(t, WithKnowledgeRetriever(sr)) + ctx := tool.WithTurnPersona(context.Background(), personaB) + + got, err := kt.Execute(ctx, args) + if err != nil { + t.Fatalf("args=%v 子代理通路不该被拒: %v", args, err) + } + if len(sr.personas) != 1 || sr.personas[0] != personaB { + t.Fatalf("args=%v 检索角色 = %v, want [%s]", args, sr.personas, personaB) + } + if !knowledgeContentsOrEmpty(got)[contentB] { + t.Fatalf("args=%v 子代理查不到 B 自己的角色日常: %+v", args, got) + } + } +} + +// TestKnowledge_MalformedPersonaArgIsAbsent 钉住畸形输入落到「缺席」分支且不 panic: +// 空串 / 纯空白 / 非字符串(数字、nil、布尔)都不是「点名了别的角色」。 +func TestKnowledge_MalformedPersonaArgIsAbsent(t *testing.T) { + t.Parallel() + const personaA = "persona-a" + for _, persona := range []any{"", " ", 42, nil, true, map[string]any{}, []any{"persona-b"}} { + sr := &stubRetriever{} + kt := knowledgeToolForTest(t, WithKnowledgeRetriever(sr)) + ctx := tool.WithTurnPersona(context.Background(), personaA) + if _, err := kt.Execute(ctx, map[string]any{"query": "回忆", "persona": persona}); err != nil { + t.Fatalf("persona=%#v 应当按缺席处理: %v", persona, err) + } + if len(sr.personas) != 1 || sr.personas[0] != personaA { + t.Fatalf("persona=%#v 检索角色 = %v, want [%s]", persona, sr.personas, personaA) + } + } + // 没有任何角色可解析时,畸形参数同样落「缺席」分支 ⇒ 可消化的拒绝,而不是 panic。 + kt := knowledgeToolForTest(t, WithKnowledgeRetriever(&stubRetriever{})) + for _, persona := range []any{"", " ", 42, nil} { + _, err := kt.Execute(context.Background(), map[string]any{"query": "回忆", "persona": persona}) + if !errors.Is(err, ErrKnowledgeUnavailable) { + t.Fatalf("persona=%#v err = %v, want ErrKnowledgeUnavailable", persona, err) + } } } @@ -142,7 +320,7 @@ func TestKnowledge_ReturnsResults(t *testing.T) { }} kt := knowledgeToolForTest(t, WithKnowledgeRetriever(sr)) - got, err := kt.Execute(context.Background(), map[string]any{"query": "cyrene", "persona": "p1", "top_k": 5}) + got, err := kt.Execute(tool.WithTurnPersona(context.Background(), "p1"), map[string]any{"query": "cyrene", "top_k": 5}) if err != nil { t.Fatalf("Execute: %v", err) } @@ -252,6 +430,56 @@ func TestKnowledge_RestrictedEnv_OnlySharedVisible(t *testing.T) { } } +// TestKnowledge_RestrictedEnv_PersonaArgDoesNotWidenScope 钉住受限环境的**双层**防护 +// 在有 persona 参数时不塌陷:本回合角色 = A 的群聊/陌生人回合里, +// ① 参数点名 B ⇒ 越权拒绝(换了身份也不行); +// ② 参数等于 A(形状兼容)⇒ 照常检索,但返回仍被 loop.FilterSharedVisible 收窄 —— +// 角色日常与 L4 意图记录一条都不许出现(design/03 §5/§7.6 R7)。 +func TestKnowledge_RestrictedEnv_PersonaArgDoesNotWidenScope(t *testing.T) { + t.Parallel() + t.Run("他角色被拒", func(t *testing.T) { + t.Parallel() + sr := &personaScopedRetriever{byPersona: map[string][]memory.Result{ + "persona-b": personaScopedResults("persona-b", "角色日常:B 深夜写歌"), + }} + kt := knowledgeToolForTest(t, WithKnowledgeRetriever(sr)) + ctx := loop.WithSessionEnv( + tool.WithTurnPersona(context.Background(), "persona-a"), + loop.SessionEnv{ActorEnv: restrict.ActorStranger, MemWritable: false}, + ) + + got, err := kt.Execute(ctx, map[string]any{"query": "回忆", "persona": "persona-b"}) + if !errors.Is(err, ErrKnowledgeUnavailable) { + t.Fatalf("受限环境里的跨角色尝试必须被拒,got err=%v got=%+v", err, got) + } + if len(sr.personas) != 0 { + t.Fatalf("被拒的调用不该触达检索器: %v", sr.personas) + } + }) + t.Run("同角色仍过滤", func(t *testing.T) { + t.Parallel() + kt := knowledgeToolForTest(t, WithKnowledgeRetriever(&stubRetriever{results: restrictedKnowledgeResults()})) + ctx := loop.WithSessionEnv( + tool.WithTurnPersona(context.Background(), "persona-a"), + loop.SessionEnv{ActorEnv: restrict.ActorGroupMember, MemWritable: false}, + ) + + got, err := kt.Execute(ctx, map[string]any{"query": "回忆", "persona": "persona-a"}) + if err != nil { + t.Fatalf("Execute: %v", err) + } + if n := got.(map[string]any)["count"].(int); n != 2 { + t.Fatalf("受限环境 count = %d, want 2(只留共享域非敏感项)", n) + } + contents := knowledgeContents(t, got) + for _, leaked := range []string{"角色日常:周末喜欢爬山", "intent:isTask=true;taskKind=task;confidence=0.9;text=帮我订一杯冰美式"} { + if contents[leaked] { + t.Fatalf("受限环境泄露了敏感记忆 %q: %v", leaked, contents) + } + } + }) +} + // TestKnowledge_SurfacesLinkedProject 钉住审计 2026-10-05 §4:knowledge 工具此前 // 丢掉了 Result.Project,模型因此够不着「项目索引 → 项目描述」层级。修复后项目 // 作用域命中必须带出链接的项目条目(id/title/desc),非项目命中则不带该键。