Files
HomeAgent/internal/plugins/kbtree/scope.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

141 lines
4.6 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 (
"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
}