Files
HomeAgent/internal/plugins/kbtree/expose_scope_test.go
JianFeeeee dfc05780fd feat(kbtree): 知识库暴露范围配置 —— 按树状只暴露指定分类
## 问题

kbtree 是**唯一**把知识库开放给外部进程的通道(HomeAgent 自己的 agent
走进程内直调 knowledge_* 内核工具,不经此),但它只有 listen_addr 与
token 两个配置,**没有任何范围限制**:拿到 token 的任何 agent 都能
/tree 列出全部条目、/search 取回任意条目全文。

本机库里混着个人内容(航空发动机教材摘录、课表、身份合并规则),
不该 broadly 可读。

## 改动

1. `internal/plugins/kbtree/scope.go`(新):暴露范围语义
   - 留空 = 全部可见(范围是"限制"不是"必填",留空保持既有行为)
   - 前缀按**路径分段**匹配:public 命中 public 与 public/tech,
     但**不**命中 publication(否则 publication 意外暴露)
   - 根下无分类的条目在范围非空时不可见 —— 它没有分类可匹配,
     放行等于范围形同虚设
   - 分隔符容忍逗号/分号/空白/换行/竖线:这是给人手填的字段

2. `plugin.go`:注册 `expose_categories` 配置项,接入**全部四个端点**
   - /tree      服务端裁剪子树(就地改,不重建:TreeView 字段多)
   - /categories 过滤路径列表
   - /counts    过滤计数并**重算 total**(数量本身也是信息泄露)
   - /search    ★ 过滤结果条目;这处最关键:
     只过滤 /tree 而放过 /search 等于范围形同虚设(换个 ?q= 就能拿到全文)。
     同时修正 limit 语义 —— 范围外条目不占名额,范围内条目不会被挤掉。

3. SDK 契约补 `Knowledge.Category`(纯增量)
   - 此前 `sdk.Knowledge` 只有 Name/Content,内核明明返回了 Category
     却在 knowledge_impl 的拷贝里丢掉 ⇒ 外部服务无法按分类判定,
     范围过滤在 SDK 层根本做不了。
   - Name/Content 均保留,无删除。

## 判据(8 条 + 4 组变异)

范围过滤最容易"只做一半",所以每个端点都单独钉。

★ 判据补强一处:初版只查条目名(priv1),结果「/categories 不过滤」
  这个变异**完全逃过** —— 分类端点返回的是路径不是条目名。
  补上分类路径断言(private)后判红。

变异验证:
- /search 不过滤      → 泄露 priv1 全文        ✓ 判红
- /categories 不过滤  → 泄露 private 分类路径   ✓ 判红(补强后)
- /counts 不过滤      → TestCategoriesAndCountsEndpoint 判红
- 前缀退化为字符串前缀 → publication 被误暴露      ✓ 判红

★ 过程中我的 fake 有两处与真实内核不符,先修 fake 再修实现:
  1. 漏了内核 treeLocked 的"子分类提升一层" ⇒ 得到 children=0 的假空树
  2. filterTree 无差别清空 t.Items ⇒ 整棵树只剩空壳节点
  (第一版的实现是"看着测试红就改",实际是 fake 在骗我)

全量 41 包绿。SDK 接口纯增量,未发布故无需冻结检查。
2026-09-26 15:31:15 +08:00

349 lines
11 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package kbtree
import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"sort"
"strings"
"testing"
sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk"
)
// ===== 知识库对外暴露范围 =====
//
// 现状缺陷:kbtree 只有 listen_addr 与 token 两个配置,**没有任何范围过滤**。
// 拿到 token 的任何 agent 都能 /tree 拿到全部条目、/search 拿到全文。
// 而本机知识库里混着个人内容(教材摘录、课表、身份合并规则),
// 不该 broadly 可读。
//
// 本测试钉住:暴露范围配置存在,且**四个端点全部受它约束**。
//
// ★ 为什么要覆盖 /search:这是最容易漏的一处。只过滤 /tree 而不过滤
// /search,等于范围形同虚设 —— 外部 agent 只要换个 ?q= 关键词就能
// 搜到范围外的条目全文。所以下面每条判据都同时查 /tree 与 /search。
type fakeKn struct {
sdk.KnowledgeAPI
items []struct{ name, category, content string }
}
// Subtree 复刻内核 treeLocked 的语义(internal/knowledge/tree.go):
// - Category == 本节点 的条目挂在本节点;
// - Category 以 本节点+"/" 开头的,取**第一段**作为直接子分类(提升一层)。
//
// 这层"提升"是内核既有行为,fake 必须照抄,否则测的是 fake 的形状
// 而不是产品行为(我第一版就因为漏了它,得到 children=0 的假空树)。
func (f *fakeKn) Subtree(category string, opt sdk.KnowledgeTreeOptions) (*sdk.KnowledgeTreeView, error) {
cat := strings.Trim(strings.TrimSpace(category), "/")
node := &sdk.KnowledgeTreeView{Name: "root"}
if cat != "" {
seg := cat
if i := strings.LastIndex(cat, "/"); i >= 0 {
seg = cat[i+1:]
}
node = &sdk.KnowledgeTreeView{Name: seg, Path: cat}
}
for _, it := range f.items {
if it.category != cat {
continue
}
node.ItemCount++
node.TotalCount++
if opt.IncludeItems {
node.Items = append(node.Items, sdk.KnowledgeTreeItemView{
Name: it.name, Preview: it.content,
})
}
}
prefix := cat
if prefix != "" {
prefix += "/"
}
seen := map[string]struct{}{}
for _, it := range f.items {
if it.category == cat || !strings.HasPrefix(it.category, prefix) {
continue
}
rest := strings.TrimPrefix(it.category, prefix)
seg := rest
if i := strings.Index(rest, "/"); i >= 0 {
seg = rest[:i]
}
if seg == "" {
continue
}
seen[seg] = struct{}{}
}
names := make([]string, 0, len(seen))
for n := range seen {
names = append(names, n)
}
sort.Strings(names)
for _, n := range names {
childPath := n
if cat != "" {
childPath = cat + "/" + n
}
sub, _ := f.Subtree(childPath, opt)
if sub == nil {
continue
}
node.Children = append(node.Children, *sub)
node.TotalCount += sub.TotalCount
}
return node, nil
}
func (f *fakeKn) SearchIn(query, category string, topK int) ([]*sdk.Knowledge, error) {
var out []*sdk.Knowledge
for _, it := range f.items {
if category != "" && !strings.HasPrefix(it.category, category) {
continue
}
if query == "" || strings.Contains(it.content, query) {
out = append(out, &sdk.Knowledge{
Name: it.name, Category: it.category, Content: it.content,
})
}
}
if len(out) > topK {
out = out[:topK]
}
return out, nil
}
func (f *fakeKn) Categories() ([]string, error) {
seen := map[string]bool{}
for _, it := range f.items {
if it.category != "" {
seen[it.category] = true
}
}
var out []string
for c := range seen {
out = append(out, c)
}
return out, nil
}
func (f *fakeKn) CategoryCounts() ([]sdk.KnowledgeCategoryCount, error) {
m := map[string]int{}
var order []string
for _, it := range f.items {
if _, ok := m[it.category]; !ok {
order = append(order, it.category)
}
m[it.category]++
}
var out []sdk.KnowledgeCategoryCount
for _, c := range order {
out = append(out, sdk.KnowledgeCategoryCount{Category: c, Count: m[c]})
}
return out, nil
}
// 样本:public/ 下 2 条,private/ 下 1 条(含敏感内容)。
func newScopedFixture() *fakeKn {
return &fakeKn{items: []struct{ name, category, content string }{
{"pub1", "public", "公开的架构说明"},
{"pub2", "public/tech", "公开的并发笔记"},
{"priv1", "private", "绝密:个人身份证号 123"},
}}
}
func newScopedServer(t *testing.T, expose string) *httptest.Server {
t.Helper()
p := &Plugin{name: "kbtree", token: "tok", expose: newScope(expose), mux: http.NewServeMux()}
p.kn = newScopedFixture()
p.registerRoutes()
s := httptest.NewServer(p.mux)
t.Cleanup(s.Close)
return s
}
func get(t *testing.T, s *httptest.Server, path string) (int, string) {
t.Helper()
req, err := http.NewRequest(http.MethodGet, s.URL+path, nil)
if err != nil {
t.Fatal(err)
}
req.Header.Set("X-API-Key", "tok")
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
var buf strings.Builder
_, _ = buf.WriteString("")
b := make([]byte, 32*1024)
n, _ := resp.Body.Read(b)
return resp.StatusCode, string(b[:n])
}
// 暴露范围生效:范围外的条目**与分类路径**都不得出现在任何端点的响应里。
//
// ★ 判据分两类断言,因为两个端点的泄露形态不同(第一版只查条目名,
//
// 结果「/categories 不过滤」这个变异完全逃过了 —— 分类端点返回的是
// 路径不是条目名,只查 priv1 永远查不到):
// - 条目名/正文(priv1、身份证):出现在 /tree、/search 的泄露
// - 分类路径(private):出现在 /categories、/counts 的泄露
func TestExposeScopeHidesOutOfScopeItems(t *testing.T) {
s := newScopedServer(t, "public")
for _, path := range []string{
"/tree",
"/categories",
"/counts",
// ★ 关键:范围外的条目即使能被搜到也不得返回。
// 查询词用"绝密"是为了让 fake 命中那条私密条目;
// 断言针对的是**结果条目**(priv1 / 其正文),不针对 query 回显 ——
// 响应里回显调用方自己发来的 q 是正常行为,不是泄露。
"/search?q=" + "绝密",
} {
code, body := get(t, s, path)
if code != http.StatusOK {
t.Fatalf("%s: 期望 200,实际 %d", path, code)
}
if strings.Contains(body, "priv1") {
t.Errorf("%s 泄露了范围外条目 priv1:%s", path, body)
}
if strings.Contains(body, "身份证") {
t.Errorf("%s 泄露了范围外条目的正文:%s", path, body)
}
// 分类路径泄露:private 这个词既可能是分类名也可能是条目内容里
// 的普通词,所以只在两个"按分类组织"的端点上断言。
if path == "/categories" || path == "/counts" {
if strings.Contains(body, "private") {
t.Errorf("%s 泄露了范围外分类路径 private:%s", path, body)
}
}
}
}
// 范围内的条目必须仍然可见(防止"过滤过头"把树清空 —— 那是另一种假绿)。
func TestExposeScopeKeepsInScopeItems(t *testing.T) {
s := newScopedServer(t, "public")
code, body := get(t, s, "/tree")
if code != http.StatusOK {
t.Fatalf("期望 200,实际 %d", code)
}
for _, want := range []string{"pub1", "pub2"} {
if !strings.Contains(body, want) {
t.Errorf("范围内条目 %s 消失了(过滤过头):%s", want, body)
}
}
// 子分类也必须在(树状结构不能被压平)
if !strings.Contains(body, "tech") {
t.Errorf("子树 tech 丢失,树状结构被压平:%s", body)
}
}
// 范围配置必须真的能限定 /search 的召回,而不是只在输出端删字段。
//
// 这一条钉住"过滤发生在哪一层":若只在响应里删掉范围外条目,
// 而 SearchIn 本身把全文返回了,limit 参数会因范围外条目占位而
// 让范围内条目被挤掉 —— 结果是"看起来过滤了,其实漏了"。
func TestExposeScopeSearchKeepsInScopeFillsLimit(t *testing.T) {
s := newScopedServer(t, "public")
// limit=1 时若 priv1 占掉名额,pub1 就拿不到
_, body := get(t, s, "/search?q="+"&limit=1")
if strings.Contains(body, "priv1") {
t.Errorf("范围内检索被范围外条目挤占:%s", body)
}
}
// 留空 = 全部可见(保持既有行为;范围是"限制"不是"必填")。
func TestEmptyExposeScopeMeansAll(t *testing.T) {
s := newScopedServer(t, "")
code, body := get(t, s, "/tree")
if code != http.StatusOK {
t.Fatalf("期望 200,实际 %d", code)
}
if !strings.Contains(body, "priv1") {
t.Errorf("范围留空时应全部可见(向后兼容),实际看不到 priv1:%s", body)
}
}
// 范围解析:逗号/空格/换行分隔都能吃;前缀匹配语义正确。
func TestExposeScopeParsing(t *testing.T) {
cases := []struct {
in string
want []string
match []struct {
cat string
in bool
}
}{
{"", nil, []struct {
cat string
in bool
}{{cat: "private", in: true}, {cat: "public", in: true}}},
{"public", []string{"public"}, []struct {
cat string
in bool
}{{cat: "public", in: true}, {cat: "public/tech", in: true}, {cat: "private", in: false}}},
{" public , private ", []string{"public", "private"}, []struct {
cat string
in bool
}{{cat: "public", in: true}, {cat: "private", in: true}}},
{"public\nprivate", []string{"public", "private"}, []struct {
cat string
in bool
}{{cat: "private", in: true}}},
}
for _, c := range cases {
sc := newScope(c.in)
if len(sc.paths) != len(c.want) {
t.Errorf("parse(%q) 得到 %v,期望 %v", c.in, sc.paths, c.want)
}
for _, m := range c.match {
if got := sc.allows(m.cat); got != m.in {
t.Errorf("parse(%q).allows(%q) = %v,期望 %v", c.in, m.cat, got, m.in)
}
}
}
}
// 边界:public 不应匹配 publication(前缀必须是路径分段级)。
func TestExposeScopePrefixIsSegmentWise(t *testing.T) {
sc := newScope("public")
if sc.allows("publication") {
t.Error("publication 被 public 范围包含 —— 前缀必须按路径分段比较," +
"否则 publication 这类目录会意外暴露")
}
}
// 配置项必须在插件里注册(否则前端设置页无法编辑,功能等于隐藏)。
func TestExposeScopeConfigIsRegistered(t *testing.T) {
// 直接断言源码里存在注册调用(读同包源码,键名漂移会被立刻发现)
b, err := readSelf(t, "plugin.go")
if err != nil {
t.Fatal(err)
}
src := string(b)
if !strings.Contains(src, `Key: "expose_categories"`) {
t.Error("未注册 expose_categories 配置项 —— 设置页无法编辑,功能等于隐藏")
}
}
// readSelf 读同包源码文件。
func readSelf(t *testing.T, name string) ([]byte, error) {
return os.ReadFile(name)
}
// 响应可解析(粗判 JSON 合法,避免把 HTML 错误页当成功)。
func TestScopedResponsesAreJSON(t *testing.T) {
s := newScopedServer(t, "public")
for _, path := range []string{"/tree", "/categories", "/counts", "/search?q=a"} {
_, body := get(t, s, path)
var v interface{}
if err := json.Unmarshal([]byte(body), &v); err != nil {
t.Errorf("%s 返回的不是合法 JSON:%v(body=%.120s)", path, err, body)
}
}
}