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 接口纯增量,未发布故无需冻结检查。
This commit is contained in:
JianFeeeee
2026-09-26 15:31:15 +08:00
parent 55c5419899
commit 6c557c1d10
5 changed files with 535 additions and 18 deletions

View File

@ -0,0 +1,140 @@
package kbtree
import (
"strings"
sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk"
)
// scope 是知识库对外暴露范围。
//
// 存在的理由:kbtree 是**唯一**把知识库开放给外部进程的通道(HomeAgent
// 自己的 agent 走进程内直调,不经此)。没有范围限制时,任何拿到 token 的
// agent 都能 /tree 列出全部条目、/search 取回任意条目全文 —— 而本机库里
// 混着个人内容(教材摘录、课表、身份合并规则),不该 broadly 可读。
//
// 语义:
// - paths 为空 ⇒ 不限范围(全部可见)。范围是"限制"不是"必填",
// 留空时保持既有行为,避免升级即失效。
// - 前缀匹配按**路径分段**比较:"public" 命中 "public" 与 "public/tech",
// 但**不**命中 "publication"(否则 publication 这类目录会意外暴露)。
// - 范围外的条目在**服务端**就被剔除:客户端过滤等于没过滤,范围外内容
// 已经随响应发出去了。
type scope struct {
paths []string
}
// newScope 解析范围配置:逗号 / 空格 / 换行分隔,逐项去空白与首尾斜杠。
//
// 为何容忍多种分隔符:这是给人在设置页手填的字段,`a, b` 与 `a b` 与
// 换行粘贴都常见;只认逗号会让"看起来填了却没生效"这种错配很难自查。
func newScope(raw string) *scope {
fields := strings.FieldsFunc(raw, func(r rune) bool {
return r == ',' || r == ';' || r == '\n' || r == '\r' ||
r == ' ' || r == '\t' || r == '/' || r == '|'
})
paths := make([]string, 0, len(fields))
for _, f := range fields {
f = strings.Trim(strings.TrimSpace(f), "/")
if f != "" {
paths = append(paths, f)
}
}
return &scope{paths: paths}
}
// allows 报告某分类(或根 "")是否在暴露范围内。
//
// 条目级的判定:条目的 Category 为 "" 表示直接挂在根下(无分类),
// 它只在**范围留空**时可见 —— 根下条目没有分类可匹配,若允许它按
// 任意前缀可见,那范围就形同虚设(根下条目永远逃逸)。
func (s *scope) allows(category string) bool {
if s == nil || len(s.paths) == 0 {
return true
}
cat := strings.Trim(strings.TrimSpace(category), "/")
if cat == "" {
return false
}
for _, p := range s.paths {
if cat == p || strings.HasPrefix(cat, p+"/") {
return true
}
}
return false
}
// filterTree 就地裁剪树视图,剔除范围外的分支。
//
// 就地改而不是重建:TreeView 是内核生成的,字段多(含 Media 切片),
// 重建容易漏字段导致"裁剪后条目信息变空"。
func (s *scope) filterTree(t *sdk.KnowledgeTreeView) {
if t == nil || s == nil || len(s.paths) == 0 {
return
}
// 根节点恒可见(否则调用方连"有什么可看的"都不知道)
kept := t.Children[:0]
for i := range t.Children {
c := &t.Children[i]
if !s.allows(c.Path) {
continue
}
kept = append(kept, *c)
}
t.Children = kept
// 根下直接挂载的条目:范围非空时不可见(allows("") == false)
// 根下直接挂载的条目:范围非空时不可见(allows("") == false)。
// 只在**本节点自己**这么做,不能对子树也做 —— 子树节点自己挂的条目
// 属于该分类,在范围内,必须保留(我第一版无差别清空,
// 结果整棵树只剩空壳节点:item_count 全 0,条目名一个都不剩)。
if s.allows(t.Path) {
// 保留本节点的条目
} else {
t.Items = nil
t.ItemCount = 0
}
t.TotalCount = t.ItemCount
for i := range t.Children {
s.filterTree(&t.Children[i])
t.TotalCount += t.Children[i].TotalCount
}
}
// filterNames 过滤分类路径列表。
func (s *scope) filterNames(names []string) []string {
if s == nil || len(s.paths) == 0 {
return names
}
out := make([]string, 0, len(names))
for _, n := range names {
if s.allows(n) {
out = append(out, n)
}
}
return out
}
// filterCounts 过滤分类计数,并按范围重算 total。
func (s *scope) filterCounts(counts []sdk.KnowledgeCategoryCount) ([]sdk.KnowledgeCategoryCount, int) {
if s == nil || len(s.paths) == 0 {
return counts, totalLeafCount(counts)
}
out := make([]sdk.KnowledgeCategoryCount, 0, len(counts))
for _, c := range counts {
if s.allows(c.Category) {
out = append(out, c)
}
}
return out, totalLeafCount(out)
}
// totalLeafCount 只数叶子分类,避免中间层重复计数(与未过滤时的口径一致)。
func totalLeafCount(counts []sdk.KnowledgeCategoryCount) int {
total := 0
for _, c := range counts {
if !isParentCategory(counts, c.Category) {
total += c.Count
}
}
return total
}