mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-10-04 00:03:59 +00:00
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:
@ -49,6 +49,10 @@ type Plugin struct {
|
||||
sdkRef *sdk.PluginSDK
|
||||
addr string
|
||||
token string
|
||||
// expose 是暴露范围(nil/空 = 全部可见)。见 scope.go。
|
||||
expose *scope
|
||||
// kn 是知识库句柄的测试注入口(生产从 sdkRef 取)。
|
||||
kn sdk.KnowledgeAPI
|
||||
server *http.Server
|
||||
mux *http.ServeMux
|
||||
// 启动时未显式配置 token 则自动生成(与 remotedevice 同策略)
|
||||
@ -85,6 +89,16 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
||||
DisplayName: "访问令牌", Category: "kbtree",
|
||||
Description: "外部 agent 访问本服务所需的令牌;留空则启动时随机生成(仅本次运行有效)",
|
||||
})
|
||||
set.RegisterDef(sdk.ConfigDef{
|
||||
Key: "expose_categories", Default: "", Type: "string",
|
||||
DisplayName: "暴露范围", Category: "kbtree",
|
||||
Description: "逗号分隔的分类路径,只暴露这些分类及其子树(如 \"public,tech/go\")。留空=全部可见。前缀按路径分段匹配:public 不会匹配 publication。根下无分类的条目在范围非空时不可见。",
|
||||
})
|
||||
if v, _ := set.Get("expose_categories"); v != nil {
|
||||
if str, ok := v.(string); ok {
|
||||
p.expose = newScope(str)
|
||||
}
|
||||
}
|
||||
if v, _ := set.Get("listen_addr"); v != nil {
|
||||
if a, ok := v.(string); ok && strings.TrimSpace(a) != "" {
|
||||
p.addr = strings.TrimSpace(a)
|
||||
@ -218,6 +232,9 @@ func (p *Plugin) handleTree(w http.ResponseWriter, r *http.Request) {
|
||||
})
|
||||
return
|
||||
}
|
||||
// 范围裁剪在服务端做:裁剪后响应里根本不含范围外条目,
|
||||
// 客户端无从察觉它们存在。
|
||||
p.expose.filterTree(view)
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{"tree": view})
|
||||
}
|
||||
|
||||
@ -227,7 +244,7 @@ func (p *Plugin) handleCategories(w http.ResponseWriter, r *http.Request) {
|
||||
if kn == nil {
|
||||
return
|
||||
}
|
||||
names := p.safeCategories(kn)
|
||||
names := p.expose.filterNames(p.safeCategories(kn))
|
||||
if names == nil {
|
||||
names = []string{}
|
||||
}
|
||||
@ -248,17 +265,9 @@ func (p *Plugin) handleCounts(w http.ResponseWriter, r *http.Request) {
|
||||
if counts == nil {
|
||||
counts = []sdk.KnowledgeCategoryCount{}
|
||||
}
|
||||
// 附总量:只数叶子分类,避免中间层重复计数
|
||||
total := 0
|
||||
byCat := make(map[string]int, len(counts))
|
||||
for _, c := range counts {
|
||||
byCat[c.Category] = c.Count
|
||||
}
|
||||
for c := range byCat {
|
||||
if !isParentCategory(counts, c) {
|
||||
total += byCat[c]
|
||||
}
|
||||
}
|
||||
// 附总量:只数叶子分类,避免中间层重复计数(范围过滤后按过滤结果重算,
|
||||
// 否则 total 会把范围外的条目数也报出去 —— 数量本身也是信息泄露)
|
||||
counts, total := p.expose.filterCounts(counts)
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{"counts": counts, "total": total})
|
||||
}
|
||||
|
||||
@ -284,11 +293,19 @@ func (p *Plugin) handleSearch(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
// ★ 范围过滤必须在这里(服务端)。只在前端/客户端过滤等于没过滤:
|
||||
// 范围外条目全文已经随响应发出去了。
|
||||
// 同时它也修正了 limit 语义 —— 范围外条目不占名额,范围内的
|
||||
// 条目不会因为 limit 被范围外条目挤掉而漏掉。
|
||||
items := make([]map[string]interface{}, 0, len(results))
|
||||
for _, k := range results {
|
||||
if !p.expose.allows(k.Category) {
|
||||
continue
|
||||
}
|
||||
items = append(items, map[string]interface{}{
|
||||
"name": k.Name,
|
||||
"content": k.Content,
|
||||
"name": k.Name,
|
||||
"category": k.Category,
|
||||
"content": k.Content,
|
||||
})
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
@ -301,6 +318,10 @@ func (p *Plugin) handleSearch(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// knowledge 取知识库;不可用时写 503 并返回 nil。
|
||||
func (p *Plugin) knowledge(w http.ResponseWriter) sdk.KnowledgeAPI {
|
||||
// kn 是测试注入口,优先于 sdkRef(它在 sdkRef 之前就已经有值了)
|
||||
if p.kn != nil {
|
||||
return p.kn
|
||||
}
|
||||
if p.sdkRef == nil {
|
||||
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "plugin not started"})
|
||||
return nil
|
||||
|
||||
Reference in New Issue
Block a user