fix(io): knowledge 工具不再接受「以他角色身份检索」(红线越权读)
design/03 §7.1 写死:跨角色读只有一条通路(把 B 当子代理派出去查); 「自己的检索能拿到 B 的角色日常记忆 = 已经越权(不是灰区)」。 缺陷:resolvePersona 让显式 persona 参数优先且零校验 ⇒ 模型(或提示词注入) 传 persona=B 即走 ListByPersona(B),读到 B 的角色日常记忆。 - 角色改为只能来自当前回合(tool.TurnPersonaFrom(ctx));显式 persona 非空 且不等于本回合角色 ⇒ 明确拒绝(可消化:原因 + 改道 + 不许重复调用) - 选「严格校验」而非「删参数」:删参数会让模型静默改用本回合角色,把 A 的 记忆当成 B 的(归属错认)且毫无信号;删键亦属对外可见的 schema 变化 - 工具 schema 键集合与必填项不变(只改 description 文案)⇒ 契约零变化 - 子代理通路未破(子代理回合的 TurnPersonaFrom 本来就是 B,已加用例钉住) - 顺带核对:FilterSharedVisible 仍在,受限环境过滤未因地塌陷 点名改掉两条「依赖缺陷行为」的既有用例: - TestKnowledge_ExplicitPersonaWins(原断言把「模型点名即换角色」写成规格) - TestKnowledge_ReturnsResults(机械依赖,只改调用形状,断言一字未动)
This commit is contained in:
1 parent
811e463cea
commit
6deab2e836
2 files changed
+287
-23
No files matched your search
@@ -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),非项目命中则不带该键。
|
||||
|
||||
Reference in new issue
Block a user