mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-11-01 06:47:29 +00:00
feat: 模块化内核(编译期依赖图定序)+ 密钥防火墙 + 插件三态生命周期 (#10)
* feat(kernel): 模块化内核 + 编译期依赖图定序
把单体 init 序列拆成「模块自声明依赖 + 编译期生成依赖图 + 运行期只读图」。
## 为什么
原 cmd/homed/main.go 里 17 步 init 是**手写顺序**,依赖关系只存在于
注释和人脑里。实测踩到的坑(见 docs/zh/kernel-modularization-design.md):
· nlp.defaultParser 被 initONNXParser 写、被 distiller 读,两者相距 60 行,
只靠 ticker 10min 才没炸(隐式依赖,无声明)
· 加一个模块要人肉找"该插在哪",插错的表现是运行期 nil 解引用
· 关停是 defer ks.CloseAll(),而热更新路径 os.Exit(42) **跳过 defer**
⇒ docStore/distiller/textMem 的 flush 全部丢失(每次热更新都发生)
## 怎么改
- internal/kernelboot/:KernelState(一个结构体收全部包级共享变量,字段
nil 安全)、Module 接口、graphfile(依赖图编解码 + 与二进制 hash 对账)、
scheduler(按 BFS 层并行 Init)、syncbus(层边界写通知)、
lifecycle(Quiesce/Resume 可逆伪关机,区别于一次性 CloseAll)
- internal/kernelmod/<name>/:每个模块一个文件夹,Requires/Provides 自声明;
all/all.go 是唯一的 import 清单(Go 循环导入约束)
- cmd/homed-depgraph:编译期生成依赖图,Makefile 注入 digest
- 运行期只**读**图,不计算顺序(顺序是编译期决定的,源码里没有中央清单)
## 判据
kernelboot 8 条 + 图失败模式 7 条(全部拒绝并给准确错误)+ 模块目录数
与注册表一致性。变异测试 15/15 咬住,含:
· 删掉 AddCloser 的锁 → -race 报数据竞争
· Scheduler 同批跑去重 → 203 vs 200
· 图 hash 不匹配 → 拒绝启动
## 顺带修的缺陷
热更新路径漏调模块关闭 ⇒ 显式 stopKernelModules(),并用 AST 判据钉住
"必须在 os.Exit 之前"(字符串检查在本仓已骗过我三次)。
实测:冷启动 1630ms(5 层/17 模块,原 17 步串行);生产 22 插件全部建链。
* feat(security): 密钥防火墙 + 配置加密 + close 纪律
## 密钥防火墙
模型只见别名、不见明文;插件工具调用入口(StageHost.ExecuteTool)做入向
回填。三维能力:
· 出向:工具输出里的密钥被替换成 [[secret:name]] 别名后才进上下文
· 入向:模型传别名,入口处填回真值(插件完全无感知)
· 存储:config.db 里不存明文(0644 的库可读),敏感值独立 0600 文件
模式库「宁漏勿误伤」:宁可漏掉一次替换,也不把正常文本误当密钥。
## 配置加密
- internal/security/{codec,key,keyring,store,global}.go:AES-256-GCM,
enc:v1: 前缀,密钥轮换(OldKeys 解码 + 新钥加密)
- harden.go:数据目录权限收紧
- embedded.go:支持内嵌主密钥(可选,仍保留外部 key 文件路径)
- EncryptExistingSecrets / AuditSecrets:启动时幂等迁移 + 审计
## close 纪律(用户明确要求:只要调 close 就要有前置检查)
internal/safeclose:Safe / SafeFunc。全仓 37 处裸 close(chan) → 0。
关键教训(都写进注释和判据):
· `select { case <-ch: default: close(ch) }` **不是**并发安全的
(两个 goroutine 可同时命中 default)—— healthcheck 原先是这种写法
· `sync.Once` **不可重入**:X.Do(func(){ Safe(&X, ...) }) 会自锁,
症状是 go test 直接挂住(3 个包 600s 超时),栈里只有嵌套的 Once.Do
· map 里按请求持有的 channel(mcp/stdio.go、clawhubadapter/sidecar.go)
改成 *pendingRPC 包装,每通道一个 once
## 判据
security 全量 + TestNoBareCloseOutsideSafeclose(AST 级扫描,含注释剥离)
+ TestNoNestedSelfOnceDeadlock(正则修正:\w+ 不匹配点号,捕获组要含 '.')。
变异 2/2 咬住。全量 52 包 ok。
* feat(plugin+deploy): 插件三态生命周期、数据目录迁移、发行包修复
## 插件三态生命周期(用户要求:自动启动 / 启用但不自动启动 / 禁用)
- Manifest 新增 requires_facilities / auto_start(*bool,用于区分
"未声明"与"显式 false")
- plugin-host 模块(internal/kernelmod/pluginhost):用 kernelboot.Scheduler
按依赖时机拉起,**不再手写两阶段**
- 运行态停启 StopPlugin/StartPlugin **可逆**:不写 disabled_plugins
(不改用户决定)、不置 shuttingDown(之后还能加载)、会摘掉注册面
- 与 StopAll 的区别写进注释 —— StopAll 置 shuttingDown 后**永久无法加载**,
那是进程退出用的,误用于"暂停"就是关了就死
## 数据目录迁移(伪关机 → 迁移 → 唤醒)
- kernelboot/lifecycle.go:Quiesce/Resume(可逆,区别于一次性 CloseAll),
逆序暂停、逆序恢复、Pause 失败回滚
- kernelboot/migrate.go:BeginMigration/End 编排(先停插件 → 再停内核写者
→ 等在途归零)
- distiller/textmem 增 Pause/Resume 门(不是 cancel —— cancel 不可逆,
恢复要重建整个对象)
- 模块 migration:体检 + 生成可执行迁移脚本
★ 迁移脚本的三个实测教训(都写成了判据):
1. 无条件改 /etc/systemd/system ⇒ 演练把**生产** -data 改成了演练目录。
加 --dry-run(零改动)+ 强制校验 systemd 当前 -data == OLD
2. 本机没装 rsync,脚本在"服务已停、数据未搬"处硬失败 ⇒ cp -a 回退,
并明确警告不保硬链接/ACL(生产 plugindev 有大量 git 硬链接)
## 发行包修复(全部经实测确认)
1. package-linux.sh **完全漏打 depgraph.bin** ⇒ 内核 exit 1
("生产构建(内嵌 hash 非空)但读不到依赖图")
2. build_homed 不走 Makefile ⇒ 单独修 Makefile 无效,必须同时生成图+注入
3. build.sh 的 LDFLAGS 用**历史模块路径 gitcode.com** ⇒ -X 静默 no-op
⇒ --version 显示 unknown
4. Makefile -ldflags 用**单引号** ⇒ 注入了字面 $GRAPH_HASH
## ★ build.sh 的版本号必须校验(真实事故)
原 `VERSION="$(git describe --tags --dirty)"`。无 tag 分支上它返回**分支名**
`backup/pre-identity-rewrite-20261005-51-g03d87a4e-dirty`,被注入 meta.Version
⇒ 插件握手判据 MajorVersion() 把整串当主版本 ⇒ **22 个子进程插件全部
握手失败**("主版本不匹配")。内核活着、systemd active、日志每条都像
插件的问题 —— 实际是发行包带着非法版本号。
修法:git describe 只在形如 vX.Y.Z 时采用,否则回退 meta.Version(权威源),
并对最终值做形态守卫。deploy/packaging/version_guard_test.go 3 条判据,
变异 2/2 咬住。
## deb 的 systemd unit 改为 guard 模式
ExecStart=/usr/bin/homed --role=guard --data /var/lib/homeagent
(worker exit 42 → guard 重拉,不经过 systemd、不计失败)
含 RestartSteps/RestartMaxDelaySec/TimeoutStopSec + 硬化
(NoNewPrivileges/ProtectSystem/ProtectHome/DevicePolicy/ReadWritePaths)。
## 其他
- internal/plugin/proc:非法 JSON 帧的报错**可定位**(内容摘要 + 可能原因 +
修法)。生产实测 1038 条非法帧(插件往 stdout 打日志 = 污染协议通道)
- 示例插件 4 个 14 处 fmt.Printf → log.Printf
- hmapdev 模板同步修复;scaffold 判据剥离注释(模板注释里就含 fmt.Printf)
* chore: 收尾(主密钥轮换命令、IPC/知识库加固、SDK 对齐)
- cmd/homed-key-rotate:把 security 里的 RotateKey/RotateKeyFile/RekeySecrets
串成一条**可执行**的运维路径。没有它,E-1 就只是库里的能力而不是
"出事时能做的事",而轮换的价值恰恰在于能执行。
- cmd/homed:kmod_runtime(模块装配入口)、plugins_scheduled(failback
白名单)、memcandidate、bootstrap_order_test / shutdown_flush_test
- internal/ipc、internal/devicebridge、internal/knowledge:配套加固
- third_party/homeagent-sdk/sdk:PluginMgrAPI 增三态能力(mgrStop/mgrStart/
mgrIsRuntimeStopped)+ settings 对齐
判据:bootstrap_order_test(启动顺序不依赖手写序列)、
shutdown_flush_test(停机的两条路径必须对称 —— exit 42 会跳过 defer,
热更新路径下模块关闭/flush 曾全部丢失)。
* test(security): 测试夹具改用不可误认的占位值(修 push protection 拦截)
GitHub secret scanning 拦住了推送:三处测试夹具用了"看起来像真密钥"的
字面量:
internal/security/prefilter_test.go:39
internal/security/store_test.go:177
internal/security/survey_test.go:20
它们全是**假密钥**(`AKIAIOSFODNN7EXAMPLE` 是 AWS 官方文档的标准示例,
其余是 `abcdefghij...` 这类可读占位符),但扫描器无法区分 —— 它只看形态。
★ 不绕过(不用 unblock URL、不加 allowlist):直接改夹具。
原因:一旦允许"假密钥可以通过",真密钥混进来时就没有最后一道门了。
改法:保留**模式库匹配所需的结构**,把可识别内容换成 FAKE:
glpat-abcdefghijklmnopqrst → glpat-FAKE-TEST-TOKEN-NOT-REAL
sk-abcdefghij...012345 → sk-FAKE-TEST-KEY-NOT-REAL-0000000
AKIAIOSFODNN7EXAMPLE → AKIA0000FAKE0000TEST (保持 20 字符)
ASIAIOSFODNN7EXAMPLE → ASIA0000FAKE0000TEST
xoxb-123456789012-abcdefghijklmnop → xoxb-FAKE-TEST-TOKEN-NOT-REAL
★ 中途踩到一次:AWS 键的模式是 `\b(AKIA|ASIA)[0-9A-Z]{16}\b`(共 20 字符),
我第一次替换成 19 字符的 `AKIAFAKETESTKEY0000` ⇒ 模式匹配不上 ⇒
TestSurvey_WhatIsCaught / TestRecognizesCommonShapes /
TestPrefilterNeverBlocksRealMatches 三条当场变红。
判据正确地拒绝了"为了过扫描器而削弱覆盖"。
顺带核实:全仓 grep 无任何真实密钥泄漏。
* fix(packaging): package-linux.sh 的版本解析与 build.sh 统一
上一版只修了 build.sh,漏了 package-linux.sh —— 它仍只看 git describe:
VERSION="${VERSION:-$(git describe --tags --dirty)}"
无 tag 分支上 git describe 返回**分支名**
(backup/pre-identity-rewrite-20261005-56-g98850e1),守卫会正确拒绝,
但要求人**手动传 VERSION** 才能打包。而 build.sh 早已能自动回退 meta.Version
⇒ 同一个版本,走 build.sh 打得出来、走 package-linux.sh 打不出来。
危害不只是"要手动传参":运维为了跑通会去掉守卫,而分支名一旦进
meta.Version 就是**插件协议主版本**的来源 ⇒ 22 个子进程插件全部握手失败
(这是真实发生过的事故,见 3efa4e5)。
修法:两个脚本用同一套规则(git describe 仅在 vX.Y.Z 形态时采用,
否则回退 meta.Version),并对最终值做形态守卫。
判据:TestBothPackagingScriptsShareVersionRules —— 逐个读两个脚本,
要求都能从 meta/meta.go 取权威版本、都校验 git describe 形态。
变异(把 package-linux.sh 改回旧规则)咬住并准确指出是哪个脚本。
实测:不传 VERSION 跑 package-linux.sh,输出
[PKG] warn: git describe 给出非版本串(...),改用 meta.Version=1.4.0
Version: 1.4.0
* fix(test): 跨仓判据在 CI 上应 skip 而非 fail(修 PR 双红)
CI 上 "Go build / vet / test" 与 "Build linux/amd64" 双红:
--- FAIL: TestHmapdevTemplateImplementsAllPluginMgrMethods
bridge_sync_test.go:170: 读模板: open
../../../third_party/homeagent-sdk/tools/hmapdev/templates/proc_main.go.tmpl:
no such file or directory
## 根因:判据依赖了一个**被 gitignore 的路径**
`.gitignore:33` 有 `third_party/homeagent-sdk/tools/`(SDK 仓自治,本仓不跟踪)。
于是:
· 本机(完整 SDK 检出)有该文件 ⇒ 判据通过
· CI(只 checkout 本仓)没有该文件 ⇒ Fatalf ⇒ **必红**
我提交时只在本机跑测试、没看 CI,所以这个问题拖到 PR 才暴露。
## 修法
文件缺失时 `t.Skipf` 而不是 `t.Fatalf`。
★ 这不是"放弃检查":
· 跨仓一致性由 **SDK 仓自己的判据**守着
(tools/hmapdev/scaffold_stdout_test.go 等),那里才是模板的真实归属地
· 本判据在本机开发时**仍然生效**(已验证:有文件时 PASS,缺文件时 SKIP)
## 验证
· 本机(有文件) → PASS(判据真跑)
· 模拟 CI(移走 tools/)→ SKIP(不再 fail)
· 全量 53 包 ok / 0 fail
## 顺带
同目录还有 e2e_template_test.go 也读 tools/,但它是 `//go:build` 门控的
e2e 判据,CI 默认不跑,无需处理。
---------
Co-authored-by: JianFeeeee <JianFeeeee@users.noreply.gitcode.com>
This commit is contained in:
@ -258,10 +258,10 @@ type AgentConfig struct {
|
||||
Indexer *memory.Indexer
|
||||
Tracker *tracker.Tracker
|
||||
|
||||
DocStore *document.Store
|
||||
Knowledge *knowledge.Store
|
||||
SocialStore *social.SocialStore
|
||||
TextMemory *text.Memory
|
||||
DocStore *document.Store
|
||||
Knowledge *knowledge.Store
|
||||
SocialStore *social.SocialStore
|
||||
TextMemory *text.Memory
|
||||
// 媒体存储句柄已随 CAS 淘汰(块自带负载,见 plan.md)。
|
||||
MultimodalSpace vector.MultimodalEmbedder
|
||||
// EmbeddingProvider / EmbeddingError 是向量空间的配置身份与打开失败原因,
|
||||
@ -393,27 +393,27 @@ func New(cfg AgentConfig) *Agent {
|
||||
}
|
||||
|
||||
a := &Agent{
|
||||
id: cfg.ID,
|
||||
startTime: time.Now(),
|
||||
provider: cfg.Provider,
|
||||
providerManager: cfg.ProviderManager,
|
||||
io: cfg.IO,
|
||||
memory: cfg.Memory,
|
||||
graph: graphMemoryOf(cfg),
|
||||
kernelSource: cfg.KernelSource,
|
||||
parentID: cfg.ParentID,
|
||||
taskPrompt: cfg.TaskPrompt,
|
||||
dataDir: cfg.DataDir,
|
||||
indexer: cfg.Indexer,
|
||||
tracker: cfg.Tracker,
|
||||
context: rc,
|
||||
systemPrompt: cfg.SystemPrompt,
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
docStore: cfg.DocStore,
|
||||
knowledge: cfg.Knowledge,
|
||||
social: cfg.SocialStore,
|
||||
textMem: cfg.TextMemory,
|
||||
id: cfg.ID,
|
||||
startTime: time.Now(),
|
||||
provider: cfg.Provider,
|
||||
providerManager: cfg.ProviderManager,
|
||||
io: cfg.IO,
|
||||
memory: cfg.Memory,
|
||||
graph: graphMemoryOf(cfg),
|
||||
kernelSource: cfg.KernelSource,
|
||||
parentID: cfg.ParentID,
|
||||
taskPrompt: cfg.TaskPrompt,
|
||||
dataDir: cfg.DataDir,
|
||||
indexer: cfg.Indexer,
|
||||
tracker: cfg.Tracker,
|
||||
context: rc,
|
||||
systemPrompt: cfg.SystemPrompt,
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
docStore: cfg.DocStore,
|
||||
knowledge: cfg.Knowledge,
|
||||
social: cfg.SocialStore,
|
||||
textMem: cfg.TextMemory,
|
||||
// mediaStore 已随 CAS 淘汰:块自带负载。知识层的媒体也已自带字节
|
||||
// (见 knowledge 的媒体侧车),不再需要外部 getter/putter 接线。
|
||||
personality: cfg.Personality,
|
||||
|
||||
96
internal/agent/core/comefrom.go
Normal file
96
internal/agent/core/comefrom.go
Normal file
@ -0,0 +1,96 @@
|
||||
package core
|
||||
|
||||
// 工具调用帧的**来源标识**(`comefrom`):让插件能精确判定
|
||||
// "这次工具调用是谁发起的",而不是只看"输入来自哪个通道"。
|
||||
//
|
||||
// ## 为什么必须加这个(真实故障,2026-10-16 现场实证)
|
||||
//
|
||||
// 现象:驻留子 `dev-br-jianf-NextAgent-fix_abs-path` 自报 running 但**零产出**,
|
||||
// 那一轮所有工具被权限策略挡下。它自己的处理表原文:
|
||||
//
|
||||
// 本轮所有需要的工具都被权限策略挡下了,实测被拒的有:
|
||||
// - cmd_run —— 无法进入工作目录、无法看 git 状态
|
||||
//
|
||||
// 日志实证(同一时段,09:21:25~09:21:40):
|
||||
//
|
||||
// [agent] executing tool: cmd_run (plugin=cmd)
|
||||
// [proc] qq stage before_toolcall 改写了 1 个字段 ← 插件写入了拒绝响应
|
||||
// [agent] executing tool: files_ls ...
|
||||
// [proc] qq stage before_toolcall 改写了 1 个字段
|
||||
// (而同一时段父 main 的工具调用**有 result**,驻留子的**零 result**)
|
||||
//
|
||||
// ## 根因
|
||||
//
|
||||
// 通道插件的权限门是 `StageScopeGlobal` —— 它对**所有 agent** 的每一轮都生效。
|
||||
// 而它手里只有"输入来源"(`Extra["input_source"]`),**没有"执行者是谁"**:
|
||||
//
|
||||
// · 父:输入来自 qq ⇒ 按那位 QQ 用户判权限 ✅ 正确
|
||||
// · 驻留子:输入**继承自父**(QQ 来源),但执行者**不是那位用户**
|
||||
// ⇒ 插件仍按"那个 QQ 用户"判 ⇒ 开发工具不在白名单 ⇒ 全被拒 ❌
|
||||
//
|
||||
// 关键在于:**用户请求已经过了门**(父按真实身份判定过),驻留子做的是
|
||||
// 父自己的内部委派决定。对已经准入的工作再做一次"用户白名单"检查,
|
||||
// 既拦掉了合法工作,又不产生任何安全增益(同一次请求,父已判定)。
|
||||
//
|
||||
// ## 契约(插件侧读法)
|
||||
//
|
||||
// comefrom == "user" 该调用代表**用户请求**在系统边界上发起 ⇒ 走用户权限模型
|
||||
// comefrom == "internal" 该调用是 agent 体系内部的(委派/转投/自主续跑)
|
||||
// ⇒ 不套"用户白名单";是否放行由插件按自身策略决定
|
||||
//
|
||||
// 缺省(键不存在)时插件应**按旧行为**处理(= "user" 语义):
|
||||
// 这条是为了不破坏已发布的插件 —— 它们不认识这个键,行为必须逐字不变。
|
||||
//
|
||||
// ## 为什么放 Extra 而不是给 StageContext 加具名字段
|
||||
//
|
||||
// `Extra` 已有**通用袋**(proc 桥的 `fExtraBag`)能跨进程/跨 stage 往返
|
||||
// (见 internal/plugin/proc/shm.go 的注释,那正是为"插件自建键"加的)。
|
||||
// 走 Extra 不需要改 SDK 结构体、不需要动 shm 的具名字段表,
|
||||
// 且插件侧 `ctx.Extra["comefrom"]` 与既有 `input_source` 读法一致。
|
||||
//
|
||||
// ★ 一旦某天需要它**强类型**(编译期保证),再提升为具名字段;
|
||||
// 现在 Extra 是成本最低且与既有惯例一致的选择。
|
||||
const extraKeyComeFrom = "comefrom"
|
||||
|
||||
// comefrom 的两个取值。
|
||||
const (
|
||||
// comeFromUser:用户请求在边界上发起(默认语义)。
|
||||
comeFromUser = "user"
|
||||
// comeFromInternal:agent 体系内部发起(委派、转投、自主续跑)。
|
||||
comeFromInternal = "internal"
|
||||
)
|
||||
|
||||
// stampComeFrom 把来源标识写进帧的 Extra(幂等;不覆盖已有值)。
|
||||
//
|
||||
// 不覆盖是有意的:更内层(知道更准)的调用点先写了就以它为准;
|
||||
// 外层只在"没人标过"时补一个保守值。
|
||||
func stampComeFrom(extra map[string]interface{}, v string) {
|
||||
if extra == nil {
|
||||
return
|
||||
}
|
||||
if _, exists := extra[extraKeyComeFrom]; exists {
|
||||
return
|
||||
}
|
||||
extra[extraKeyComeFrom] = v
|
||||
}
|
||||
|
||||
// comefromForAgent 由 agent 的层级身份推导来源标识。
|
||||
//
|
||||
// 规则(就这么简单,不需要更多分支):
|
||||
//
|
||||
// 驻留子(有 parentID)⇒ internal 它不是用户请求的边界,是父的委派体
|
||||
// 根 agent(无 parentID)⇒ user 它才是用户请求的落点
|
||||
//
|
||||
// 为什么不去看 `input_source`:来源通道说明"消息从哪来",不说明"谁在执行"。
|
||||
// 父处理 QQ 消息时来源是 qq(user);把这条工作转投给驻留子后,
|
||||
// 驻留子的来源标签**仍是 qq**(转投刻意保留原事件),但执行者变了。
|
||||
// 只看来源必然判错 —— 这正是本缺陷的成因。
|
||||
func (a *Agent) comefromForAgent() string {
|
||||
if a == nil {
|
||||
return comeFromUser
|
||||
}
|
||||
if a.parentID != "" {
|
||||
return comeFromInternal
|
||||
}
|
||||
return comeFromUser
|
||||
}
|
||||
165
internal/agent/core/comefrom_test.go
Normal file
165
internal/agent/core/comefrom_test.go
Normal file
@ -0,0 +1,165 @@
|
||||
package core
|
||||
|
||||
// 工具调用帧的「来源标识」判据(2026-10-16)。
|
||||
//
|
||||
// 要证明的核心是:**驻留子的工具调用能被识别为"内部委派",
|
||||
// 而不是被当成"那位用户发起的请求"** —— 后者会让通道插件的
|
||||
// 用户权限模型去拦一份已经准入的工作(现场实证:驻留子零产出)。
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
agentAPI "github.com/JianFeeeee/HomeAgent/internal/agent/api"
|
||||
agentIO "github.com/JianFeeeee/HomeAgent/internal/agent/io"
|
||||
sdk "github.com/JianFeeeee/HomeAgent/internal/sdk"
|
||||
"github.com/JianFeeeee/HomeAgent/pkg/types"
|
||||
)
|
||||
|
||||
// ★★★ 判据:来源标识**必须在帧的起点就写入**(不能只在工具帧上)。
|
||||
//
|
||||
// 为什么:插件可能在 on_input 阶段就据此决定是否介入,
|
||||
// 而那个阶段**早于**工具帧构造。只在工具帧写会漏掉这条路径。
|
||||
func TestComeFromIsStampedAtFrameOrigin(t *testing.T) {
|
||||
root := newBareAgent("main", "")
|
||||
ctx := root.stageCtxFromInput("hi", "", "")
|
||||
got, ok := ctx.Extra[extraKeyComeFrom]
|
||||
if !ok {
|
||||
t.Fatal("★ 帧起点未写 comefrom(插件在 on_input 阶段拿不到来源标识)")
|
||||
}
|
||||
if got != comeFromUser {
|
||||
t.Errorf("根 agent 的帧应为 %q,实际 %q", comeFromUser, got)
|
||||
}
|
||||
|
||||
// 驻留子:有 parentID ⇒ internal
|
||||
child := newBareAgent("worker-1", "main")
|
||||
cctx := child.stageCtxFromInput("hi", "", "")
|
||||
if got := cctx.Extra[extraKeyComeFrom]; got != comeFromInternal {
|
||||
t.Errorf("★ 驻留子的帧应为 %q(它是父的委派体,不是用户请求的边界),实际 %q",
|
||||
comeFromInternal, got)
|
||||
}
|
||||
}
|
||||
|
||||
// ★★★ **最关键的一条**:工具调用帧必须带标识,且驻留子的是 internal。
|
||||
//
|
||||
// 这是本次缺陷的直接回归判据:缺了它,通道插件的 before_toolcall
|
||||
// 会把驻留子的调用按"非所有者用户"判定 ⇒ 开发工具全被拒。
|
||||
func TestToolFrameCarriesComeFrom(t *testing.T) {
|
||||
// 驻留子(有父)⇒ 工具帧必须是 internal
|
||||
child := newBareAgent("worker-1", "main")
|
||||
base := child.stageCtxFromInput("do work", "", "")
|
||||
f := &TaskFrame{StageCtx: base, PendingTools: []agentAPI.ToolCall{
|
||||
{Name: "cmd_run"}, {Name: "files_ls"},
|
||||
}}
|
||||
f.buildToolContexts()
|
||||
if len(f.toolCtxs) != 2 {
|
||||
t.Fatalf("工具帧数量不对: %d", len(f.toolCtxs))
|
||||
}
|
||||
for i, name := range []string{"cmd_run", "files_ls"} {
|
||||
got, ok := f.toolCtxs[i].Extra[extraKeyComeFrom]
|
||||
if !ok {
|
||||
t.Fatalf("★ 工具 %s 的帧没有 comefrom(缺陷回归)", name)
|
||||
}
|
||||
if got != comeFromInternal {
|
||||
t.Errorf("★ 驻留子的工具 %s 应为 %q,实际 %q —— "+
|
||||
"会被通道插件按用户权限模型拦住", name, comeFromInternal, got)
|
||||
}
|
||||
}
|
||||
|
||||
// 根 agent ⇒ 工具帧必须是 user(不能因为加了这个就放宽用户权限)
|
||||
root := newBareAgent("main", "")
|
||||
rbase := root.stageCtxFromInput("hi", "", "")
|
||||
rf := &TaskFrame{StageCtx: rbase, PendingTools: []agentAPI.ToolCall{{Name: "cmd_run"}}}
|
||||
rf.buildToolContexts()
|
||||
if got := rf.toolCtxs[0].Extra[extraKeyComeFrom]; got != comeFromUser {
|
||||
t.Errorf("★ 根 agent 的工具帧应为 %q(否则会放宽用户权限门),实际 %q",
|
||||
comeFromUser, got)
|
||||
}
|
||||
}
|
||||
|
||||
// ★★★ 判据:即使 base 帧**没有**标识,工具帧也必须带上(不变量兜底)。
|
||||
//
|
||||
// 为什么这条必须独立存在(本会话第 9 次同类教训):
|
||||
// 初版判据只验证"走 stageCtxFromInput 的正常路径",而 copyExtraMap
|
||||
// 已经把 comefrom 复制进工具帧 —— 于是**删掉工具帧里那行 stampComeFrom
|
||||
// 判据依然全绿**(变异不咬)。那说明判据测的是"继承",没测"兜底"。
|
||||
//
|
||||
// 而兜底是有意义的:TaskFrame 可以被手搓(新内部路径、测试替身),
|
||||
// 那种帧的 StageCtx 不经 stageCtxFromInput。没有兜底就会出现
|
||||
// "某些路径的工具帧没有来源标识" —— 而缺标识时插件按旧语义(用户)
|
||||
// 判定 ⇒ 驻留子又被拦。不变量必须由**工具帧自己**保证。
|
||||
func TestToolFrameStampsComeFromEvenWhenBaseLacksIt(t *testing.T) {
|
||||
// 手搓一个**没有** comefrom 的 base(模拟不经 stageCtxFromInput 的路径)
|
||||
base := &sdk.StageContext{Extra: map[string]interface{}{}, Phase: sdk.StageOnInput}
|
||||
if _, ok := base.Extra[extraKeyComeFrom]; ok {
|
||||
t.Fatal("前置条件错:这个 base 不该有标识")
|
||||
}
|
||||
f := &TaskFrame{StageCtx: base, PendingTools: []agentAPI.ToolCall{{Name: "cmd_run"}}}
|
||||
f.buildToolContexts()
|
||||
got, ok := f.toolCtxs[0].Extra[extraKeyComeFrom]
|
||||
if !ok {
|
||||
t.Fatal("★ 工具帧未兜底写入 comefrom —— base 没标时就完全没标识了")
|
||||
}
|
||||
// 兜底值必须是**保守**的 user(宁可多查一次,不可静默放宽权限)
|
||||
if got != comeFromUser {
|
||||
t.Errorf("兜底值应为保守的 %q,实际 %q", comeFromUser, got)
|
||||
}
|
||||
}
|
||||
|
||||
// ★★★ 判据:工具帧的 Extra 仍然**逐份独立**(不能因为加键而退化成共享 map)。
|
||||
//
|
||||
// 既有纪律(task.go 注释):Extra 被 stage handler 写入,若多份共享同一
|
||||
// map,一个工具的身份绑定会串到同批其他工具上 —— 那是越权。
|
||||
func TestToolFramesHaveIndependentExtra(t *testing.T) {
|
||||
a := newBareAgent("worker", "main")
|
||||
base := a.stageCtxFromInput("x", "", "")
|
||||
f := &TaskFrame{StageCtx: base, PendingTools: []agentAPI.ToolCall{{Name: "t1"}, {Name: "t2"}}}
|
||||
f.buildToolContexts()
|
||||
if len(f.toolCtxs) != 2 {
|
||||
t.Fatal("工具帧数量不对")
|
||||
}
|
||||
f.toolCtxs[0].Extra["probe"] = "only-first"
|
||||
if _, leaked := f.toolCtxs[1].Extra["probe"]; leaked {
|
||||
t.Fatal("★ 工具帧共享了 Extra(一个工具的改写会串到别的工具上 = 越权风险)")
|
||||
}
|
||||
}
|
||||
|
||||
// ★★★ 判据:**不覆盖**已有标识(更内层知道更准)。
|
||||
func TestStampComeFromDoesNotOverwrite(t *testing.T) {
|
||||
m := map[string]interface{}{extraKeyComeFrom: comeFromInternal}
|
||||
stampComeFrom(m, comeFromUser)
|
||||
if m[extraKeyComeFrom] != comeFromInternal {
|
||||
t.Errorf("★ 不该覆盖已有值(内层更准),实际被改成 %v", m[extraKeyComeFrom])
|
||||
}
|
||||
// nil map 不得 panic
|
||||
stampComeFrom(nil, comeFromUser)
|
||||
}
|
||||
|
||||
// ★★★ 判据:缺标识时的语义 = 旧行为(user)。
|
||||
//
|
||||
// 已发布插件不认识 comefrom。内核必须保证:**缺失时插件看到的行为
|
||||
// 与改动前完全一致**,否则这个改动对存量插件是破坏性的。
|
||||
func TestMissingComeFromKeepsLegacySemantics(t *testing.T) {
|
||||
// 一个没有任何标识的帧(模拟旧路径)
|
||||
ctx := &sdk.StageContext{Extra: map[string]interface{}{}}
|
||||
if _, ok := ctx.Extra[extraKeyComeFrom]; ok {
|
||||
t.Fatal("前置条件错:这个帧不该有标识")
|
||||
}
|
||||
// 内核侧的保守默认就是 comeFromUser —— 插件缺失时按旧语义(用户)处理
|
||||
if comeFromUser != "user" {
|
||||
t.Errorf("默认值必须是与旧行为等价的 %q,实际 %q", "user", comeFromUser)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 测试脚手架 ----
|
||||
|
||||
// newBareAgent 造一个只有身份(id + parentID)的最小 Agent。
|
||||
//
|
||||
// 不装配 io/provider:判据只验证"来源标识怎么算、怎么落到帧上",
|
||||
// 装重型依赖会让判据变慢且掩盖真正的断言点。
|
||||
func newBareAgent(id, parent string) *Agent {
|
||||
return New(AgentConfig{
|
||||
ID: types.AgentID(id),
|
||||
ParentID: parent,
|
||||
IO: agentIO.NewIOManager(),
|
||||
})
|
||||
}
|
||||
229
internal/agent/core/commondev_premise_test.go
Normal file
229
internal/agent/core/commondev_premise_test.go
Normal file
@ -0,0 +1,229 @@
|
||||
package core
|
||||
|
||||
// 刀 0:验证 commondev 架构赖以成立的前提。
|
||||
//
|
||||
// 设计 docs/zh/commondev-agent-runtime-design.md §2.2 / §2.3 / §2.5。
|
||||
//
|
||||
// 整套「插件注册通道 → 主 agent 建驻留子并划入 → 子调 output_send__ 下任务」
|
||||
// 的架构,建立在**两个**此前只有读码结论、从未跑过的事实上:
|
||||
//
|
||||
// P1. 驻留子能调到**外部插件**注册的工具(若不能,子无法下任务给 pi)
|
||||
// P2. 驻留子能用**外部插件**注册的输出门 output_send__<ch>(若不能,通道白注册)
|
||||
//
|
||||
// 这两条不成立,上层机制全是纸面的 —— 所以先验它,再写插件。
|
||||
//
|
||||
// 注册路径与真实插件一致:工具走 StageHost,输出通道走 IOManager.RegisterDevice
|
||||
// (插件经 proc 桥最终也落到这两处,见 internal/plugin/registry.go:305-320)。
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
agentAPI "github.com/JianFeeeee/HomeAgent/internal/agent/api"
|
||||
agentIO "github.com/JianFeeeee/HomeAgent/internal/agent/io"
|
||||
sdk "github.com/JianFeeeee/HomeAgent/internal/sdk"
|
||||
)
|
||||
|
||||
// 与 commondev 真实用法一致的通道名:inputch 可含斜杠,outputch 必须合规
|
||||
// (会被拼成 output_send__<name>,见 SDK RegisterOutputChannel 注释)。
|
||||
const (
|
||||
probeInputCh = "commondev/br-1"
|
||||
probeOutCh = "commondev_br-1"
|
||||
probeTool = "commondev_probe"
|
||||
probeOutGate = "output_send__" + probeOutCh
|
||||
)
|
||||
|
||||
// probeDevice 模拟插件注册的输出通道(与内部 channelDevice 同构)。
|
||||
type probeDevice struct {
|
||||
name string
|
||||
caps agentIO.OutputCapability
|
||||
handler sdk.ToolHandler
|
||||
got *string
|
||||
}
|
||||
|
||||
func (d *probeDevice) Name() string { return d.name }
|
||||
func (d *probeDevice) Type() agentIO.DeviceType { return agentIO.DeviceOutput }
|
||||
func (d *probeDevice) Description() string { return "探针通道:给 pi 投递任务" }
|
||||
func (d *probeDevice) Start() error { return nil }
|
||||
func (d *probeDevice) Stop() error { return nil }
|
||||
func (d *probeDevice) OutputCapabilities() agentIO.OutputCapability { return d.caps }
|
||||
func (d *probeDevice) Tools() []agentIO.ToolDef { return nil }
|
||||
func (d *probeDevice) ChannelDef() agentIO.ChannelDef { return agentIO.ChannelDef{NoMemory: true} }
|
||||
func (d *probeDevice) Execute(tool string, args map[string]interface{}) (interface{}, error) {
|
||||
payload, _ := args["payload"].(string)
|
||||
if d.got != nil {
|
||||
*d.got = payload // 记录插件 handler 真的收到了什么
|
||||
}
|
||||
return d.handler(args)
|
||||
}
|
||||
|
||||
// registerProbePlugin 模拟 commondev 插件注册工具与通道。
|
||||
// delivered 非 nil 时记录插件 handler 实际收到的 payload(证明"包一下"真的发生)。
|
||||
func registerProbePlugin(t *testing.T, a *Agent, delivered *string) {
|
||||
t.Helper()
|
||||
|
||||
// ① 插件工具:子要能调它(P1)
|
||||
if err := a.stageHost.RegisterTool(probeTool, sdk.ToolDef{
|
||||
Name: probeTool,
|
||||
Description: "探针:插件注册的普通工具",
|
||||
Parameters: map[string]interface{}{"type": "object", "properties": map[string]interface{}{}},
|
||||
}, func(args map[string]interface{}) (interface{}, error) {
|
||||
return "plugin-tool-ok", nil
|
||||
}); err != nil {
|
||||
t.Fatalf("插件工具注册失败: %v", err)
|
||||
}
|
||||
|
||||
// ② 插件输出通道:子要能用 output_send__commondev_br-1(P2)
|
||||
if err := a.io.RegisterDevice(&probeDevice{
|
||||
name: probeOutCh,
|
||||
caps: agentIO.CapText,
|
||||
handler: func(args map[string]interface{}) (interface{}, error) {
|
||||
return "已投递", nil
|
||||
},
|
||||
got: delivered,
|
||||
}); err != nil {
|
||||
t.Fatalf("插件输出通道注册失败: %v", err)
|
||||
}
|
||||
|
||||
// ③ inputch:pi → 子 的回投入口
|
||||
if err := a.io.RegisterInputChannelFrom("commondev", probeInputCh, agentIO.ChannelDef{NoMemory: true}); err != nil {
|
||||
t.Fatalf("inputch 注册失败: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestP1_ResidentSeesExternalPluginTools 钉住 P1:
|
||||
// 驻留子的工具表里必须有**外部插件**注册的工具,且真的能执行。
|
||||
//
|
||||
// 若不成立:子无法下任务给 pi ⇒ 整套架构作废(必须改为父代投)。
|
||||
func TestP1_ResidentSeesExternalPluginTools(t *testing.T) {
|
||||
parent, _, dir := newRootForResidents(t)
|
||||
registerProbePlugin(t, parent, nil)
|
||||
|
||||
info := spawnTestResident(t, parent, dir, "dev-br-1", probeInputCh)
|
||||
child := residentOf(t, parent, info.ID)
|
||||
|
||||
// ① 工具表(模型看得到的)里要有
|
||||
names := childToolNames(child)
|
||||
if !hasToolNamed(names, probeTool) {
|
||||
t.Fatalf("P1 不成立:驻留子看不到外部插件工具 %s\n看到的是: %v", probeTool, names)
|
||||
}
|
||||
t.Logf("P1a ✅ 驻留子工具表含插件工具(工具总数 %d)", len(names))
|
||||
|
||||
// ② 且真的能执行(能看到 ≠ 能调到)
|
||||
got := child.executeToolCall(agentAPI.ToolCall{
|
||||
ID: "p1", Name: probeTool, Arguments: map[string]interface{}{},
|
||||
}, probeInputCh)
|
||||
if !strings.Contains(got, "plugin-tool-ok") {
|
||||
t.Fatalf("P1 不成立:工具在表里但调不通: %s", got)
|
||||
}
|
||||
t.Log("P1b ✅ 且可执行(plugin-tool-ok)")
|
||||
}
|
||||
|
||||
// TestP2_ResidentCanUseExternalPluginOutputGate 钉住 P2:
|
||||
// 驻留子必须能通过 output_send__commondev_br-1 打到**插件注册的通道 handler**。
|
||||
//
|
||||
// 若不成立:通道注册了但子用不了 ⇒ 投递路径断掉。
|
||||
func TestP2_ResidentCanUseExternalPluginOutputGate(t *testing.T) {
|
||||
parent, _, dir := newRootForResidents(t)
|
||||
var delivered string
|
||||
registerProbePlugin(t, parent, &delivered)
|
||||
|
||||
info := spawnTestResident(t, parent, dir, "dev-br-1", probeInputCh)
|
||||
child := residentOf(t, parent, info.ID)
|
||||
|
||||
// ① 输出门工具存在(未授权则工具表里根本不会有)
|
||||
names := childToolNames(child)
|
||||
if !hasToolNamed(names, probeOutGate) {
|
||||
t.Fatalf("P2 不成立:驻留子看不到 %s\n看到的是: %v", probeOutGate, names)
|
||||
}
|
||||
t.Logf("P2a ✅ 驻留子有 %s", probeOutGate)
|
||||
|
||||
// ② 调用它 —— 必须真的打到插件 handler
|
||||
//
|
||||
// 期望返回 "ok":`output.go:94` 只在 `dev.Execute()` **成功**时返回它。
|
||||
// ⚠️ 不能断言返回插件 handler 的文本:内核对成功的回执刻意**极简**
|
||||
//(rich 回执会驱动模型继续调 output_send,是 output loop 的根源)。
|
||||
// 所以"插件真的收到了"要由 handler 自己记下来(delivered)来证。
|
||||
got := child.executeToolCall(agentAPI.ToolCall{
|
||||
ID: "p2", Name: probeOutGate,
|
||||
Arguments: map[string]interface{}{"payload": "给 pi 的任务:修 bug", "type": "text"},
|
||||
}, probeInputCh)
|
||||
if strings.Contains(got, "不存在") || strings.Contains(got, "未授权") {
|
||||
t.Fatalf("P2 不成立:输出门不可用: %s", got)
|
||||
}
|
||||
if got != "ok" {
|
||||
t.Fatalf("P2 不成立:输出门应返回 ok(dev.Execute 成功),实际: %q", got)
|
||||
}
|
||||
// ★ 真正的判据:插件 handler 收到的 payload
|
||||
if delivered != "给 pi 的任务:修 bug" {
|
||||
t.Fatalf("P2 不成立:插件 handler 收到的 payload 不符: %q", delivered)
|
||||
}
|
||||
t.Logf("P2b ✅ 输出门打到插件 handler,payload 完整传达(%d 字节)", len(delivered))
|
||||
}
|
||||
|
||||
// TestP3_ChannelNameSlashIsIllegalForOutputGate 钉住命名约束(文档 §2.2):
|
||||
// outputch 带斜杠会让 output_send__<name> 不合法 ⇒ 工具名违规。
|
||||
//
|
||||
// 这条是**反例**:证明"inputch 可用斜杠、outputch 不行"不是猜想。
|
||||
func TestP3_ChannelNameSlashIsIllegalForOutputGate(t *testing.T) {
|
||||
parent, _, dir := newRootForResidents(t)
|
||||
registerProbePlugin(t, parent, nil)
|
||||
|
||||
info := spawnTestResident(t, parent, dir, "dev-br-1", probeInputCh)
|
||||
child := residentOf(t, parent, info.ID)
|
||||
names := childToolNames(child)
|
||||
|
||||
// 合法命名:有
|
||||
if !hasToolNamed(names, probeOutGate) {
|
||||
t.Fatalf("下划线命名应可用,但没有 %s", probeOutGate)
|
||||
}
|
||||
// 非法命名:绝不能出现在工具表里
|
||||
for _, n := range names {
|
||||
if strings.HasPrefix(n, "output_send__") && strings.Contains(n, "/") {
|
||||
t.Fatalf("发现带斜杠的输出门工具名 %q —— 会触发上游 400", n)
|
||||
}
|
||||
}
|
||||
t.Log("P3 ✅ 输出门命名合规(无斜杠)")
|
||||
}
|
||||
|
||||
// ---------------- 测试辅助 ----------------
|
||||
|
||||
// residentOf 取父登记表里的驻留子(不存在则 fatal)。
|
||||
func residentOf(t *testing.T, parent *Agent, id string) *Agent {
|
||||
t.Helper()
|
||||
parent.residentMu.Lock()
|
||||
rc, ok := parent.residents[id]
|
||||
parent.residentMu.Unlock()
|
||||
if !ok || rc == nil || rc.agent == nil {
|
||||
t.Fatalf("驻留子 %s 不存在", id)
|
||||
}
|
||||
return rc.agent
|
||||
}
|
||||
|
||||
// childToolNames 取某个 agent 当前工具表里的名字。
|
||||
func childToolNames(a *Agent) []string {
|
||||
var out []string
|
||||
for _, raw := range a.buildToolDefs() {
|
||||
m, ok := raw.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
fn, _ := m["function"].(map[string]interface{})
|
||||
if fn == nil {
|
||||
continue
|
||||
}
|
||||
if name, _ := fn["name"].(string); name != "" {
|
||||
out = append(out, name)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func hasToolNamed(list []string, want string) bool {
|
||||
for _, s := range list {
|
||||
if s == want {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@ -351,10 +351,17 @@ func (c *RelevanceContext) Save() error {
|
||||
return os.WriteFile(c.savePath, data, 0644)
|
||||
}
|
||||
|
||||
// Append 追加一条事件。
|
||||
//
|
||||
// ★ 这里是**持久化记忆的唯一咽喉**:全部 5 处写 context 的调用都经过它
|
||||
// (另一处 InsertByTimestamp 同样处理)。所以明文拦截放在这里,
|
||||
// 而不是在每个调用点 —— 逐个补必漏(实测踩到:只拦了 f.Msgs,
|
||||
// 而 f.CleanInput / f.ToolResults / f.Response 都是独立副本)。
|
||||
func (c *RelevanceContext) Append(evt ContextEvent) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
scrubEventSecrets(&evt)
|
||||
c.computeVector(&evt)
|
||||
c.events = append(c.events, &evt)
|
||||
|
||||
@ -365,6 +372,7 @@ func (c *RelevanceContext) InsertByTimestamp(evt ContextEvent) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
scrubEventSecrets(&evt) // 同 Append:咽喉处拦截
|
||||
c.computeVector(&evt)
|
||||
|
||||
idx := sort.Search(len(c.events), func(i int) bool {
|
||||
|
||||
@ -6,6 +6,7 @@ import (
|
||||
agentAPI "github.com/JianFeeeee/HomeAgent/internal/agent/api"
|
||||
"github.com/JianFeeeee/HomeAgent/internal/memory"
|
||||
)
|
||||
|
||||
// 这组判据把「上下文管理的阈值」从硬编码变成可配置,并守住两条纪律:
|
||||
//
|
||||
// 1. **零值等价**:ContextTuning 零值必须与硬编码时代**逐值相同**。
|
||||
|
||||
@ -19,11 +19,11 @@ import (
|
||||
// memory.RecallBlocks(读块自己的向量),因此不存在「召回已无处可归的
|
||||
// 内容」这回事——能召回的必然是被某层持有的块。
|
||||
type CrossModalHit struct {
|
||||
Doc *document.Doc // 文本路命中的文档;视觉路命中时为 nil
|
||||
Media *memory.MemoryBlock // 视觉路命中的媒体块;文本路命中时也可能带关联块
|
||||
MediaScore float64 // 视觉路原始 cosine(无则 0)
|
||||
DocScore float64 // 文本路原始 cosine(无则 0)
|
||||
Fused float64 // 归一化加权融合分,供最终排序
|
||||
Doc *document.Doc // 文本路命中的文档;视觉路命中时为 nil
|
||||
Media *memory.MemoryBlock // 视觉路命中的媒体块;文本路命中时也可能带关联块
|
||||
MediaScore float64 // 视觉路原始 cosine(无则 0)
|
||||
DocScore float64 // 文本路原始 cosine(无则 0)
|
||||
Fused float64 // 归一化加权融合分,供最终排序
|
||||
// 该媒体同时被两路命中时 DoubleHit=true —— 双信号确认,应排在只被
|
||||
// 一路命中的候选之前。
|
||||
DoubleHit bool
|
||||
|
||||
@ -605,7 +605,7 @@ func isTemplateSummary(s string) bool {
|
||||
}
|
||||
|
||||
func (a *Agent) emitMemoryCandidate(source, input, response string, toolResults []ToolResultItem, toolsUsed []string) {
|
||||
a.io.EmitOutput("memory", "memory_candidate", map[string]interface{}{
|
||||
payload := map[string]interface{}{
|
||||
"source": source,
|
||||
"input": input,
|
||||
"response": response,
|
||||
@ -613,7 +613,11 @@ func (a *Agent) emitMemoryCandidate(source, input, response string, toolResults
|
||||
"tools_used": toolsUsed,
|
||||
"agent_id": string(a.id),
|
||||
"timestamp": time.Now().Unix(),
|
||||
})
|
||||
}
|
||||
// ★ 记忆候选是**不经 context** 的另一条持久化侧路(走 EmitOutput)。
|
||||
// 不在这里拦,明文会直接进长期记忆 —— 而记忆跨会话保留、每轮被召回。
|
||||
scrubMemoryCandidate(payload)
|
||||
a.io.EmitOutput("memory", "memory_candidate", payload)
|
||||
}
|
||||
|
||||
func (a *Agent) processConsolidation(evt *agentIO.InputEvent, input string) {
|
||||
|
||||
54
internal/agent/core/encshell_premise_test.go
Normal file
54
internal/agent/core/encshell_premise_test.go
Normal file
@ -0,0 +1,54 @@
|
||||
package core
|
||||
|
||||
// 前提验证:插件 enc_shell 的**命令参数**是否已被内核填好明文?
|
||||
//
|
||||
// 若已填 ⇒ 插件自己的 lookupAnySecret 对内核别名是**死代码**,
|
||||
// 且「保留 enc_shell」的理由只剩 argv 保护(脚本走 stdin)。
|
||||
//
|
||||
// 这决定了 SDK 该不该暴露 Fill():
|
||||
// · 若插件本来就能从 args 拿到明文 ⇒ 暴露 Fill 不新增任何权限
|
||||
// · 若拿不到 ⇒ 暴露 Fill 会削弱边界(插件可枚举 List 再逐个 Fill)
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
sdk "github.com/JianFeeeee/HomeAgent/internal/sdk"
|
||||
)
|
||||
|
||||
func TestPremise_EncShellCommandPrefilledByKernel(t *testing.T) {
|
||||
store := newScrubStore(t)
|
||||
a, _, _ := newRootForResidents(t)
|
||||
|
||||
const tok = "ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||
alias, err := store.Submit(tok, "测")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var cmdSaw string
|
||||
if err := a.stageHost.RegisterTool("probe_encsh", sdk.ToolDef{
|
||||
Name: "probe_encsh", Description: "模拟 enc_shell",
|
||||
Parameters: map[string]interface{}{"type": "object"},
|
||||
}, func(args map[string]interface{}) (interface{}, error) {
|
||||
cmdSaw, _ = args["command"].(string)
|
||||
return "ok", nil
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if _, err := a.stageHost.ExecuteTool("probe_encsh", map[string]interface{}{
|
||||
"command": "curl -H 'Authorization: token @{" + alias + "}'",
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if strings.Contains(cmdSaw, "@{"+alias+"}") {
|
||||
t.Fatal("handler 仍看到别名 ⇒ 内核没填,插件需要自己的填充逻辑")
|
||||
}
|
||||
if !strings.Contains(cmdSaw, tok) {
|
||||
t.Fatalf("handler 应看到明文,实际 %q", cmdSaw)
|
||||
}
|
||||
t.Logf("✅ 内核已填 ⇒ 插件对**内核别名**的填充是死代码;" +
|
||||
"插件仍需自己解析的是**平台凭据**(@ {gitea-local},含连字符,内核不认)")
|
||||
}
|
||||
154
internal/agent/core/encshell_redundancy_test.go
Normal file
154
internal/agent/core/encshell_redundancy_test.go
Normal file
@ -0,0 +1,154 @@
|
||||
package core
|
||||
|
||||
// 前提验证(C7):内核的 `@{别名}` 回填是否让插件的 `enc_shell` 变得**多余**。
|
||||
//
|
||||
// ## 为什么要先验这个再删代码
|
||||
//
|
||||
// C7 要「大幅删减插件的加密 shell 工具族」。删的依据是一个断言:
|
||||
// 用户可以用**任何**工具(如内置的 cmd_run)配合 `@{别名}`,
|
||||
// 由内核对所有工具参数统一回填 —— 而不必依赖插件专有的 enc_shell。
|
||||
//
|
||||
// 这个断言不成立的话,删掉 enc_shell 就是**削功能**而非去冗余。
|
||||
//
|
||||
// 已验证过 `TestFirewall_ToolArgsFilledBeforePlugin`(插件工具收到明文)。
|
||||
// 这里补的是**决定性**的那一条:用**内置工具名**(cmd_run)也走同一条回填路径。
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
sdk "github.com/JianFeeeee/HomeAgent/internal/sdk"
|
||||
)
|
||||
|
||||
// ★★ 判据:`cmd_run`(内置终端工具,非插件)的参数同样被内核回填。
|
||||
//
|
||||
// 这条成立 ⇒ 用户不必用插件专有的 enc_shell:
|
||||
//
|
||||
// enc_shell(command="git push https://x:{token}@host/r.git fav")
|
||||
// ↓ 等价于
|
||||
// cmd_run(command="git push https://x:@{sec_xxx}@host/r.git fav")
|
||||
//
|
||||
// 后者完全走内核机制,插件无需自带加密库与别名表。
|
||||
func TestKernelFillCoversBuiltinToolsToo(t *testing.T) {
|
||||
store := newScrubStore(t)
|
||||
a, _, _ := newRootForResidents(t)
|
||||
|
||||
const tok = "ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||
alias, err := store.Submit(tok, "测试")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// 注册一个**模拟 cmd_run 的内置工具**(不真的执行 shell),
|
||||
// 验证它通过 StageHost.ExecuteTool 拿到的是明文。
|
||||
var seen string
|
||||
if err := a.stageHost.RegisterTool("cmd_run", sdk.ToolDef{
|
||||
Name: "cmd_run", Description: "模拟内置终端工具",
|
||||
Parameters: map[string]interface{}{"type": "object"},
|
||||
}, func(args map[string]interface{}) (interface{}, error) {
|
||||
seen, _ = args["command"].(string)
|
||||
return "ok", nil
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if _, err := a.stageHost.ExecuteTool("cmd_run", map[string]interface{}{
|
||||
"command": "git push https://x:@" + "{" + alias + "}@host/r.git feat/x",
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if seen == "" {
|
||||
t.Fatal("工具未被调用")
|
||||
}
|
||||
if !strings.Contains(seen, tok) {
|
||||
t.Fatalf("★ 内核回填未覆盖内置工具名 —— 那 enc_shell 就有存在理由(不能删)\n实际: %q", seen)
|
||||
}
|
||||
t.Logf("✅ 内置工具名的参数也被回填 ⇒ enc_shell 的填充功能是冗余的: %q", seen)
|
||||
}
|
||||
|
||||
// ★★ 判据:内核回填**发生在插件 handler 之前** ⇒ 插件自己的别名表对
|
||||
// 内核别名是**死代码**(永远不会命中)。
|
||||
//
|
||||
// 这个顺序很关键:
|
||||
//
|
||||
// StageHost.ExecuteTool → security.FillMap(args) → handler(args)
|
||||
//
|
||||
// 所以当内核安全模块启用时,插件的 enc_shell 收到的 command 里
|
||||
// `@{sec_xxx}` **已经变成明文**了;插件自己的别名正则再扫也扫不到。
|
||||
//
|
||||
// ⇒ 结论:插件的「填充 @{别名}」这部分是**冗余**的(内核先做了)。
|
||||
//
|
||||
// 但它的 argv 保护(脚本走 stdin)与输出脱敏**仍然独有**,不可删。
|
||||
func TestKernelFillHappensBeforePluginHandler(t *testing.T) {
|
||||
store := newScrubStore(t)
|
||||
a, _, _ := newRootForResidents(t)
|
||||
|
||||
const tok = "ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||
alias, err := store.Submit(tok, "测试")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var handlerSaw string
|
||||
if err := a.stageHost.RegisterTool("probe_encshell", sdk.ToolDef{
|
||||
Name: "probe_encshell", Description: "模拟 enc_shell",
|
||||
Parameters: map[string]interface{}{"type": "object"},
|
||||
}, func(args map[string]interface{}) (interface{}, error) {
|
||||
handlerSaw, _ = args["command"].(string)
|
||||
return "ok", nil
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if _, err := a.stageHost.ExecuteTool("probe_encshell", map[string]interface{}{
|
||||
"command": "curl -H 'Authorization: token @{" + alias + "}'",
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// handler 里看到的**已经没有 @{别名}** 了(已被内核换掉)
|
||||
if strings.Contains(handlerSaw, "@{"+alias+"}") {
|
||||
t.Fatalf("内核回填应在 handler 之前完成;实际 handler 仍看到别名: %q", handlerSaw)
|
||||
}
|
||||
if !strings.Contains(handlerSaw, tok) {
|
||||
t.Fatalf("handler 应看到明文: %q", handlerSaw)
|
||||
}
|
||||
t.Logf("✅ 内核先回填,插件看不到别名 ⇒ 插件自带别名表对内核别名是死代码")
|
||||
}
|
||||
|
||||
// ★★ 判据:内核与插件的 `@{...}` 引用**语法不冲突**(带连字符的名字归插件)。
|
||||
//
|
||||
// 这是个**偶然但真实**的兼容,必须钉住,否则将来有人"统一语法"就炸:
|
||||
//
|
||||
// 内核 refPattern: @\{([A-Za-z0-9_]{1,64})\} ← 不含 - 和 .
|
||||
// 插件 credRefPattern: @\{([A-Za-z0-9_.-]{1,64})\} ← 含 - 和 .
|
||||
//
|
||||
// 于是:
|
||||
//
|
||||
// @{sec_3718c87a11e9478b} 内核吃(无连字符)
|
||||
// @{gitea-local} 内核**不吃**(有连字符)⇒ 留给插件解析
|
||||
//
|
||||
// ★ 若把内核的字符集放宽到含 `-`,插件自己的凭据(gitea-local)会被内核
|
||||
//
|
||||
// 先吃掉 —— 那些名字不在内核变量库里,内核会原样保留(认识不到),
|
||||
// 看起来"没坏",但只要恰好有一个同名内核变量,就会静默换错值。
|
||||
func TestKernelAndPluginAliasSyntaxDoNotCollide(t *testing.T) {
|
||||
store := newScrubStore(t)
|
||||
const tok = "ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||
alias, err := store.Submit(tok, "内核变量")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// 内核别名(无连字符)→ 被回填
|
||||
if got, n, _ := store.Fill("x:@" + "{" + alias + "}"); n != 1 || !strings.Contains(got, tok) {
|
||||
t.Fatalf("内核别名应被回填: n=%d got=%q", n, got)
|
||||
}
|
||||
// 插件风格的名字(含连字符)→ **不被内核碰**
|
||||
pluginStyle := "git push https://x:@" + "{gitea-local}@host/r.git"
|
||||
if got, n, _ := store.Fill(pluginStyle); n != 0 || !strings.Contains(got, "{gitea-local}") {
|
||||
t.Fatalf("含连字符的引用内核不应碰(那是插件的凭据): n=%d got=%q", n, got)
|
||||
}
|
||||
t.Logf("✅ 内核只吃 [A-Za-z0-9_],@{gitea-local} 留给插件")
|
||||
}
|
||||
284
internal/agent/core/firewall_e2e_test.go
Normal file
284
internal/agent/core/firewall_e2e_test.go
Normal file
@ -0,0 +1,284 @@
|
||||
package core
|
||||
|
||||
// 刀 S:**真机端到端**验证安全模块。
|
||||
//
|
||||
// 前面的判据都跑在 mock provider 上。这里用**真实的 Agent**(与生产同一套
|
||||
// StageHost / IOManager / 设备出口),验证三件事真的连通:
|
||||
//
|
||||
// ① 出向:Agent 发往 provider 的消息里,明文已变成别名
|
||||
// ② 落盘:变量库有记录,且磁盘上没有明文
|
||||
// ③ 入向:插件工具收到的参数是**明文**(回填生效,插件无感知)
|
||||
// ④ 入向:设备通道收到的 payload 是**明文**
|
||||
//
|
||||
// 为什么必须做真机验证:mock 判据只能证明"装饰器逻辑对",
|
||||
// 证明不了"装饰器挂在正确的点位上"。位置错(比如挂在 stage 之后)
|
||||
// 在 mock 里完全看不出来。
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
agentAPI "github.com/JianFeeeee/HomeAgent/internal/agent/api"
|
||||
agentIO "github.com/JianFeeeee/HomeAgent/internal/agent/io"
|
||||
sdk "github.com/JianFeeeee/HomeAgent/internal/sdk"
|
||||
"github.com/JianFeeeee/HomeAgent/internal/security"
|
||||
)
|
||||
|
||||
const firewallToken = "ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||
|
||||
// fwToolDef 构造一个最小工具声明(本文件专用,避开与既有测试的同名辅助冲突)。
|
||||
func fwToolDef(name string) sdk.ToolDef {
|
||||
return sdk.ToolDef{
|
||||
Name: name,
|
||||
Description: "安全模块测试工具",
|
||||
Parameters: map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{"token": map[string]interface{}{"type": "string"}},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func setupFirewall(t *testing.T) (*security.Store, string) {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
store, err := security.InitGlobal(security.Options{
|
||||
Path: filepath.Join(dir, "vars.json"),
|
||||
KeyFn: func() ([]byte, error) { return []byte("0123456789abcdef0123456789abcdef"), nil },
|
||||
Enabled: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { security.SetGlobal(nil) })
|
||||
return store, dir
|
||||
}
|
||||
|
||||
// ★★ 判据:插件工具收到的参数是**明文**(回填生效)。
|
||||
//
|
||||
// 这是整个设计的核心目的:模型手上只有别名,插件拿到真凭据,
|
||||
// 而**插件对替换完全无感知**。
|
||||
func TestFirewall_ToolArgsFilledBeforePlugin(t *testing.T) {
|
||||
store, _ := setupFirewall(t)
|
||||
a, _, _ := newRootForResidents(t)
|
||||
|
||||
// 插件工具:记录它实际收到的参数
|
||||
var seenToken string
|
||||
const toolName = "plugintest_store_secret"
|
||||
if err := a.stageHost.RegisterTool(toolName, fwToolDef(toolName),
|
||||
func(args map[string]interface{}) (interface{}, error) {
|
||||
seenToken, _ = args["token"].(string)
|
||||
return "ok", nil
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// 模型只知道别名(它从未见过明文)
|
||||
alias, err := store.Submit(firewallToken, "测试")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := a.stageHost.ExecuteTool(toolName, map[string]interface{}{
|
||||
"token": "@{" + alias + "}",
|
||||
"note": "保持不变",
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// ★ 插件看到的是明文
|
||||
if seenToken != firewallToken {
|
||||
t.Fatalf("插件应收到明文,实际收到 %q(回填未生效 ⇒ 插件拿不到真凭据)", seenToken)
|
||||
}
|
||||
t.Logf("✅ 插件工具收到的 token 是明文(%d 字节),别名 %s 已回填", len(seenToken), alias)
|
||||
}
|
||||
|
||||
// ★★ 判据:设备通道收到的 payload 是**明文**。
|
||||
//
|
||||
// 这条验证回填插在了正确位置 —— 在插件 stage **之后**、设备 Execute **之前**。
|
||||
// 若插错(比如 stage 之前),插件会看到明文;
|
||||
// 若压根没插,设备会收到字面的 "@{sec_xxx}"。
|
||||
func TestFirewall_DevicePayloadFilledBeforeExecute(t *testing.T) {
|
||||
store, _ := setupFirewall(t)
|
||||
a, _, _ := newRootForResidents(t)
|
||||
|
||||
var delivered string
|
||||
const outCh = "plugintest_secch"
|
||||
if err := a.io.RegisterDevice(&probeDevice{
|
||||
name: outCh,
|
||||
caps: agentIO.CapText,
|
||||
handler: func(args map[string]interface{}) (interface{}, error) {
|
||||
return "已发送", nil
|
||||
},
|
||||
got: &delivered,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
alias, err := store.Submit(firewallToken, "测试")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// 模型发出的 payload 里只有别名
|
||||
got := a.executeOutputSendTool(agentAPI.ToolCall{
|
||||
Name: "output_send__" + outCh,
|
||||
Arguments: map[string]interface{}{
|
||||
"payload": "我的 token 是 @{" + alias + "} 请使用",
|
||||
},
|
||||
})
|
||||
|
||||
// ★ 设备收到明文
|
||||
if !strings.Contains(delivered, firewallToken) {
|
||||
t.Fatalf("设备应收到明文,实际 %q(回填位置错或未生效)\n工具返回: %s",
|
||||
delivered, got)
|
||||
}
|
||||
if strings.Contains(delivered, alias) {
|
||||
t.Errorf("别名不应出现在设备载荷里: %q", delivered)
|
||||
}
|
||||
t.Logf("✅ 设备通道收到明文(%d 字节)", len(delivered))
|
||||
}
|
||||
|
||||
// ★ 判据:出向拦截真的发生在真实 provider 调用上(用真实 ProviderManager)。
|
||||
func TestFirewall_OutboundScrubbedOnRealProviderPath(t *testing.T) {
|
||||
setupFirewall(t)
|
||||
var captured string
|
||||
mgr := agentAPI.NewProviderManager()
|
||||
mgr.Register("probe", &fwCaptureProvider{name: "probe", capture: &captured})
|
||||
|
||||
_, err := mgr.Default().Chat(context.Background(), &agentAPI.CompletionRequest{
|
||||
Messages: []agentAPI.Message{
|
||||
{Role: "user", Content: "这是我的凭据: " + firewallToken},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(captured, firewallToken) {
|
||||
t.Fatalf("明文泄漏到 provider: %s", captured)
|
||||
}
|
||||
if !strings.Contains(captured, "安全模块自动拦截") {
|
||||
t.Errorf("未见拦截提示: %s", captured)
|
||||
}
|
||||
t.Logf("✅ 出向拦截生效,provider 收到: %s", captured)
|
||||
}
|
||||
|
||||
// ★ 判据:磁盘上**没有明文**(加密落盘),且权限 0600。
|
||||
func TestFirewall_NoPlaintextOnDisk(t *testing.T) {
|
||||
store, dir := setupFirewall(t)
|
||||
if _, err := store.Submit(firewallToken, "落盘"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// 扫变量库目录下所有文件
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, e := range entries {
|
||||
b, err := os.ReadFile(filepath.Join(dir, e.Name()))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if strings.Contains(string(b), firewallToken) {
|
||||
t.Fatalf("磁盘文件 %s 出现明文", e.Name())
|
||||
}
|
||||
fi, _ := e.Info()
|
||||
if perm := fi.Mode().Perm(); perm&0o077 != 0 {
|
||||
t.Errorf("文件 %s 权限过宽: %04o", e.Name(), perm)
|
||||
}
|
||||
}
|
||||
t.Logf("✅ 目录下 %d 个文件均无明文,权限均受限", len(entries))
|
||||
}
|
||||
|
||||
// ★ 判据:**同明文合并**(同一次对话里多个通道用同一个 secret ⇒ 同一别名)。
|
||||
func TestFirewall_SameSecretMergesAcrossChannels(t *testing.T) {
|
||||
store, _ := setupFirewall(t)
|
||||
|
||||
// 两处独立出现同一明文(模拟两个插件都用同一个 token)
|
||||
alias1, _ := store.Submit(firewallToken, "通道A")
|
||||
alias2, _ := store.Submit(firewallToken, "通道B")
|
||||
|
||||
if alias1 != alias2 {
|
||||
t.Fatalf("同一明文必须合并到同一别名: %s vs %s", alias1, alias2)
|
||||
}
|
||||
if store.Count() != 1 {
|
||||
t.Errorf("应只有 1 条记录(合并),实际 %d", store.Count())
|
||||
}
|
||||
t.Logf("✅ 同一明文跨通道合并到 %s(记录数 1)", alias1)
|
||||
}
|
||||
|
||||
// ★ 判据:端到端一轮 —— 出向后别名回填,模型可用同一别名再次调用。
|
||||
//
|
||||
// 这是完整闭环:拦截 → 别名进上下文 → 模型用别名 → 回填 → 插件拿到明文。
|
||||
func TestFirewall_FullRoundTrip(t *testing.T) {
|
||||
store, _ := setupFirewall(t)
|
||||
a, _, _ := newRootForResidents(t)
|
||||
|
||||
var pluginSaw string
|
||||
const toolName = "plugintest_roundtrip"
|
||||
if err := a.stageHost.RegisterTool(toolName, fwToolDef(toolName),
|
||||
func(args map[string]interface{}) (interface{}, error) {
|
||||
pluginSaw, _ = args["secret"].(string)
|
||||
return "ok", nil
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// ① 第一步:模型输出的文本里带明文 ⇒ 被内核拦截成别名
|
||||
// (模拟真实链路:模型回复 → 出向扫描)
|
||||
scrubbed, evts := store.Replace("用这个部署: "+firewallToken, "llm_out:assistant", security.Patterns)
|
||||
if len(evts) != 1 {
|
||||
t.Fatalf("应拦截 1 处,实际 %d", len(evts))
|
||||
}
|
||||
alias := evts[0].Alias
|
||||
t.Logf("① 拦截: 明文 → %s", alias)
|
||||
|
||||
// ② 模型后续调用工具时只能引用别名(它没见过明文)
|
||||
if _, err := a.stageHost.ExecuteTool(toolName, map[string]interface{}{
|
||||
"secret": "@{" + alias + "}",
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// ③ 插件拿到真凭据
|
||||
if pluginSaw != firewallToken {
|
||||
t.Fatalf("插件应拿到明文,实际 %q", pluginSaw)
|
||||
}
|
||||
|
||||
// ④ 上下文中始终只有别名(模型的视野里无明文)
|
||||
if strings.Contains(scrubbed, firewallToken) {
|
||||
t.Fatal("上下文里出现明文")
|
||||
}
|
||||
// ⑤ 变量记录里也无明文
|
||||
b, _ := json.Marshal(store.List())
|
||||
if strings.Contains(string(b), firewallToken) {
|
||||
t.Fatal("变量记录泄露明文")
|
||||
}
|
||||
t.Logf("② 模型用 @{%s} 调用 → ③ 插件收到明文 → ④⑤ 上下文与记录均无明文", alias)
|
||||
}
|
||||
|
||||
// fwCaptureProvider 记录收到的消息内容(模拟真实 provider 收到的请求)。
|
||||
type fwCaptureProvider struct {
|
||||
name string
|
||||
capture *string
|
||||
}
|
||||
|
||||
func (c *fwCaptureProvider) Name() string { return c.name }
|
||||
func (c *fwCaptureProvider) Chat(_ context.Context, req *agentAPI.CompletionRequest) (*agentAPI.CompletionResponse, error) {
|
||||
if req != nil && len(req.Messages) > 0 {
|
||||
*c.capture = req.Messages[0].Content
|
||||
}
|
||||
return &agentAPI.CompletionResponse{}, nil
|
||||
}
|
||||
func (c *fwCaptureProvider) ChatStream(_ context.Context, req *agentAPI.CompletionRequest) (<-chan agentAPI.StreamChunk, error) {
|
||||
if req != nil && len(req.Messages) > 0 {
|
||||
*c.capture = req.Messages[0].Content
|
||||
}
|
||||
ch := make(chan agentAPI.StreamChunk)
|
||||
close(ch)
|
||||
return ch, nil
|
||||
}
|
||||
func (c *fwCaptureProvider) MaxContextTokens() int { return 1000 }
|
||||
72
internal/agent/core/inplace_scrub_test.go
Normal file
72
internal/agent/core/inplace_scrub_test.go
Normal file
@ -0,0 +1,72 @@
|
||||
package core
|
||||
|
||||
// 判据:出向拦截的**原地改写**语义(共享底层数组)。
|
||||
//
|
||||
// ## 为什么这条值得单独立判据
|
||||
//
|
||||
// `task.go:663` 构造请求时是 `Messages: f.Msgs` —— **值切片,无拷贝**。
|
||||
// 所以装饰器在出网前改写消息内容时,改的是 `f.Msgs` 的元素本身。
|
||||
//
|
||||
// 这个语义是**有意的**,且必须钉住:
|
||||
//
|
||||
// 好处:明文在第一次出网时就永久离开上下文。
|
||||
// 若不共享,明文会一直躺在 f.Msgs 里,每一轮都要重新拦截,
|
||||
// 并且任何读 f.Msgs 的地方(记忆写入、会话持久化、WebUI 回显)
|
||||
// 都会看到明文 —— 泄漏面大得多。
|
||||
//
|
||||
// 代价:改写会渗进**非 LLM 路径**(同上那几处)。这是刻意的取舍:
|
||||
// 宁可让这些地方看到别名(一致、可诊断),也不要让明文到处躺着。
|
||||
//
|
||||
// ⇒ 若有人把 `Messages: f.Msgs` 改成深拷贝,本判据会报红,
|
||||
// 提醒他同时要在「记忆写入前」和「持久化前」各加一道拦截。
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
agentAPI "github.com/JianFeeeee/HomeAgent/internal/agent/api"
|
||||
"github.com/JianFeeeee/HomeAgent/internal/security"
|
||||
)
|
||||
|
||||
// ★★ 判据:装饰器原地改写 ⇒ 原始消息切片也变成别名。
|
||||
func TestOutboundScrubIsInPlaceByDesign(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if _, err := security.InitGlobal(security.Options{
|
||||
Path: dir + "/v.json",
|
||||
KeyFn: func() ([]byte, error) { return []byte("0123456789abcdef0123456789abcdef"), nil },
|
||||
Enabled: true,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { security.SetGlobal(nil) })
|
||||
|
||||
const tok = "ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||
|
||||
// 与 task.go:663 同构:Messages 直接赋 f.Msgs(值切片共享底层数组)
|
||||
msgs := []agentAPI.Message{{Role: "user", Content: "token=" + tok}}
|
||||
|
||||
var captured string
|
||||
mgr := agentAPI.NewProviderManager()
|
||||
mgr.Register("p", &fwCaptureProvider{name: "p", capture: &captured})
|
||||
|
||||
if _, err := mgr.Default().Chat(context.Background(),
|
||||
&agentAPI.CompletionRequest{Messages: msgs}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// ① 出网安全
|
||||
if strings.Contains(captured, tok) {
|
||||
t.Fatal("明文泄漏到 provider")
|
||||
}
|
||||
// ② 原地改写:原始切片也变了(**这是刻意的**)
|
||||
if strings.Contains(msgs[0].Content, tok) {
|
||||
t.Fatal("原始消息仍含明文 —— 说明 Messages 被深拷贝了。" +
|
||||
"若确实要改成拷贝,必须同时在记忆写入与持久化前各加一道拦截," +
|
||||
"否则明文会一直躺在 f.Msgs 里被反复读出")
|
||||
}
|
||||
if !strings.Contains(msgs[0].Content, "@{sec_") {
|
||||
t.Fatalf("原始消息应含别名,实际 %q", msgs[0].Content)
|
||||
}
|
||||
t.Logf("原地改写生效:原始 f.Msgs 元素已变别名 %q", msgs[0].Content)
|
||||
}
|
||||
119
internal/agent/core/leakback_test.go
Normal file
119
internal/agent/core/leakback_test.go
Normal file
@ -0,0 +1,119 @@
|
||||
package core
|
||||
|
||||
// ★★ 决定性的安全前提:**插件把明文返回时,内核能否拦住**?
|
||||
//
|
||||
// 这决定了「让安全模块向插件暴露 Fill()」是否安全:
|
||||
//
|
||||
// 若拦得住 ⇒ 暴露 Fill 不新增泄漏面(明文回到内核时会被换回别名)
|
||||
// 若拦不住 ⇒ 暴露 Fill 会让 LLM 通过插件读到任意明文,S1 作废
|
||||
//
|
||||
// 链路:插件 handler 返回明文 → ToolResults → ①context scrub(C5/C6)
|
||||
// → ②provider 装饰器(C1)
|
||||
// 两处都必须拦住。
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
agentAPI "github.com/JianFeeeee/HomeAgent/internal/agent/api"
|
||||
agentIO "github.com/JianFeeeee/HomeAgent/internal/agent/io"
|
||||
sdk "github.com/JianFeeeee/HomeAgent/internal/sdk"
|
||||
)
|
||||
|
||||
func TestPremise_PluginReturnedPlaintextIsScrubbed(t *testing.T) {
|
||||
store := newScrubStore(t)
|
||||
const tok = "ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||
alias, err := store.Submit(tok, "测")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// 插件工具:**故意**把明文当返回值吐出来(模拟"恶意/粗糙插件")
|
||||
a := New(AgentConfig{
|
||||
ID: "leakback",
|
||||
Provider: &echoTokenProvider{reply: "收到"},
|
||||
ProviderManager: agentAPI.NewProviderManager(),
|
||||
IO: agentIO.NewIOManager(),
|
||||
StageHost: NewStageHost(),
|
||||
})
|
||||
if err := a.stageHost.RegisterTool("leaky", sdk.ToolDef{
|
||||
Name: "leaky", Description: "故意回吐明文",
|
||||
Parameters: map[string]interface{}{"type": "object"},
|
||||
}, func(args map[string]interface{}) (interface{}, error) {
|
||||
return "凭据是 " + tok, nil // ★ 直接回吐明文
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
a.Start()
|
||||
defer a.Stop()
|
||||
|
||||
// 走真实调度循环:模型调 leaky,结果进 context
|
||||
a.io.InjectTextSync("http", "查一下")
|
||||
|
||||
// ① context 侧必须已换成别名
|
||||
for _, evt := range a.context.Recent(0) {
|
||||
for _, tr := range evt.ToolResults {
|
||||
if strings.Contains(tr.Output, tok) {
|
||||
t.Errorf("★ context 侧未拦住:%q", tr.Output)
|
||||
}
|
||||
}
|
||||
}
|
||||
// ② 记录里也不能有明文
|
||||
if b := store.List(); len(b) == 0 {
|
||||
t.Fatal("应登记变量")
|
||||
}
|
||||
_ = alias
|
||||
t.Log("✅ 插件回吐的明文被内核拦下(context 侧)")
|
||||
}
|
||||
|
||||
// ★★ 同上的 provider 侧:明文不得到达 LLM。
|
||||
func TestPremise_PluginReturnedPlaintextNeverReachesLLM(t *testing.T) {
|
||||
store := newScrubStore(t)
|
||||
const tok = "ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||
if _, err := store.Submit(tok, "测"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// 用已有的 captureProvider 看它实际收到什么
|
||||
cp := &captureProvider{}
|
||||
a := New(AgentConfig{
|
||||
ID: "leakback2",
|
||||
Provider: cp,
|
||||
ProviderManager: agentAPI.NewProviderManager(),
|
||||
IO: agentIO.NewIOManager(),
|
||||
StageHost: NewStageHost(),
|
||||
})
|
||||
if err := a.stageHost.RegisterTool("leaky2", sdk.ToolDef{
|
||||
Name: "leaky2", Description: "故意回吐明文",
|
||||
Parameters: map[string]interface{}{"type": "object"},
|
||||
}, func(args map[string]interface{}) (interface{}, error) {
|
||||
return "凭据是 " + tok, nil
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
a.Start()
|
||||
defer a.Stop()
|
||||
a.io.InjectTextSync("http", "查一下")
|
||||
|
||||
// provider 收到的**全部**消息里都不能有明文
|
||||
cp.mu.Lock()
|
||||
msgs := append([]agentAPI.Message(nil), cp.messages...)
|
||||
cp.mu.Unlock()
|
||||
for _, m := range msgs {
|
||||
if strings.Contains(m.Content, tok) {
|
||||
t.Errorf("★ 明文到达了 provider(LLM 能看到): %q", m.Content)
|
||||
}
|
||||
for _, tr := range m.ToolCalls {
|
||||
if strings.Contains(fmt.Sprint(tr.Arguments), tok) {
|
||||
t.Errorf("★ 工具参数里的明文到达 provider: %v", tr.Arguments)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(msgs) == 0 {
|
||||
t.Fatal("provider 一条消息都没收到 —— 判据无效")
|
||||
}
|
||||
t.Logf("✅ provider 侧未见明文(看了 %d 条消息)", len(msgs))
|
||||
_ = context.Background
|
||||
}
|
||||
@ -7,6 +7,7 @@ import (
|
||||
"sync/atomic"
|
||||
|
||||
"github.com/JianFeeeee/HomeAgent/internal/memory"
|
||||
safeclose "github.com/JianFeeeee/HomeAgent/internal/safeclose"
|
||||
)
|
||||
|
||||
// 媒体与记忆块的生命周期辅助。
|
||||
@ -64,6 +65,7 @@ func (a *Agent) reembedStaleMedia() {
|
||||
log.Printf("[media] 启动向量迁移: %d 个图像块 → fp=%s dim=%d workers=%d",
|
||||
len(targets), shortFP(fp), a.multimodalSpace.Dim(), workers)
|
||||
|
||||
var jobsOnce sync.Once
|
||||
jobs := make(chan memory.MemoryBlock, workers*2)
|
||||
var done, failed int64
|
||||
var wg sync.WaitGroup
|
||||
@ -89,7 +91,7 @@ func (a *Agent) reembedStaleMedia() {
|
||||
i+1, len(targets), atomic.LoadInt64(&done), failed)
|
||||
}
|
||||
}
|
||||
close(jobs)
|
||||
safeclose.Safe(&jobsOnce, jobs)
|
||||
wg.Wait()
|
||||
log.Printf("[media] 向量迁移完成: 成功=%d 失败=%d 总计=%d fp=%s",
|
||||
done, failed, len(targets), shortFP(fp))
|
||||
|
||||
158
internal/agent/core/memory_scrub.go
Normal file
158
internal/agent/core/memory_scrub.go
Normal file
@ -0,0 +1,158 @@
|
||||
package core
|
||||
|
||||
// 持久化侧路的明文拦截(「记忆防火墙」)。
|
||||
//
|
||||
// ## 为什么必须单独拦(而不是只靠 provider 装饰器)
|
||||
//
|
||||
// 出向拦截(`api/provider.go` 的装饰器)只改 `f.Msgs` —— 那是**要发出去的
|
||||
// 那一份**。但内核把同一批内容**另存副本**送往持久化存储,那些副本
|
||||
// 不经过 provider:
|
||||
//
|
||||
// ContextEvent.Input ← f.CleanInput (用户输入原文)
|
||||
// ContextEvent.Response ← f.Response (模型回复)
|
||||
// ContextEvent.ToolResults ← f.ToolResults (工具输出)
|
||||
// emitMemoryCandidate.payload.tool_results ← 同上,走 EmitOutput 不经 context
|
||||
//
|
||||
// 实测确认(变异实验 C6-M1 / C6-M2 / 输入侧探针):
|
||||
// 只拦 `f.Msgs` 时,明文会完整地写进 `context.json` **与长期记忆**,
|
||||
// 而后者跨会话保留、每轮被召回 —— 泄漏面比"重放一次"大得多。
|
||||
//
|
||||
// ## 为什么放在 Append/InsertByTimestamp 而不是各调用点
|
||||
//
|
||||
// 写 context 的地方有 5 处(task.go 3 处、toolcall.go 1 处,以及插入变体)。
|
||||
// 逐个补必然漏 —— 本文件的前身就是这么漏掉 `f.CleanInput` 的。
|
||||
// `Append` / `InsertByTimestamp` 是全部写入的唯一咽喉,拦这里一处即全覆盖。
|
||||
//
|
||||
// `emitMemoryCandidate` 不经 context(走 `EmitOutput`),所以单独处理。
|
||||
|
||||
import (
|
||||
"log"
|
||||
|
||||
"github.com/JianFeeeee/HomeAgent/internal/security"
|
||||
)
|
||||
|
||||
// scrubEventSecrets 就地清洗一条待持久化事件里的明文。
|
||||
//
|
||||
// 失败策略:Store 未装配(未启用安全模块)时**原样返回**,零成本。
|
||||
func scrubEventSecrets(evt *ContextEvent) {
|
||||
if evt == nil {
|
||||
return
|
||||
}
|
||||
s := security.Global()
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
if out, evts := s.Replace(evt.Input, "ctx_input", security.Patterns); len(evts) > 0 {
|
||||
evt.Input = out
|
||||
logEvtHits("上下文输入", evts)
|
||||
}
|
||||
if out, evts := s.Replace(evt.Response, "ctx_response", security.Patterns); len(evts) > 0 {
|
||||
evt.Response = out
|
||||
logEvtHits("上下文回复", evts)
|
||||
}
|
||||
for i := range evt.ToolResults {
|
||||
if evt.ToolResults[i].Output == "" {
|
||||
continue
|
||||
}
|
||||
if out, evts := s.Replace(evt.ToolResults[i].Output,
|
||||
"ctx_tool:"+evt.ToolResults[i].Name, security.Patterns); len(evts) > 0 {
|
||||
evt.ToolResults[i].Output = out
|
||||
logEvtHits("上下文工具结果 "+evt.ToolResults[i].Name, evts)
|
||||
}
|
||||
}
|
||||
// 一等记忆块:**文本**块同样可能带凭据(截图描述、文档正文、转写稿)。
|
||||
// 媒体块(image/video/audio 的 Data)不处理 —— 那是二进制负载,
|
||||
// 正则扫它既无意义也会损坏数据。
|
||||
for i := range evt.Blocks {
|
||||
if evt.Blocks[i].Text == "" {
|
||||
continue
|
||||
}
|
||||
if out, evts := s.Replace(evt.Blocks[i].Text, "ctx_block", security.Patterns); len(evts) > 0 {
|
||||
evt.Blocks[i].Text = out
|
||||
logEvtHits("上下文记忆块", evts)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// scrubMemoryCandidate 清洗记忆候选载荷(不经 context 的那条侧路)。
|
||||
//
|
||||
// 就地改写 map:调用方传进来的是一次性的快照,改它没有副作用。
|
||||
func scrubMemoryCandidate(payload map[string]interface{}) {
|
||||
if payload == nil {
|
||||
return
|
||||
}
|
||||
s := security.Global()
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
for _, key := range []string{"input", "response"} {
|
||||
str, ok := payload[key].(string)
|
||||
if !ok || str == "" {
|
||||
continue
|
||||
}
|
||||
if out, evts := s.Replace(str, "memory:"+key, security.Patterns); len(evts) > 0 {
|
||||
payload[key] = out
|
||||
logEvtHits("记忆候选 "+key, evts)
|
||||
}
|
||||
}
|
||||
switch trs := payload["tool_results"].(type) {
|
||||
case []ToolResultItem:
|
||||
for i := range trs {
|
||||
if trs[i].Output == "" {
|
||||
continue
|
||||
}
|
||||
if out, evts := s.Replace(trs[i].Output,
|
||||
"memory:tool:"+trs[i].Name, security.Patterns); len(evts) > 0 {
|
||||
trs[i].Output = out
|
||||
logEvtHits("记忆候选工具结果 "+trs[i].Name, evts)
|
||||
}
|
||||
}
|
||||
payload["tool_results"] = trs
|
||||
case []interface{}:
|
||||
// 有的调用方传的是未类型化的切片(如经 JSON 中转)。
|
||||
for i := range trs {
|
||||
m, ok := trs[i].(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
out, ok := m["output"].(string)
|
||||
if !ok || out == "" {
|
||||
continue
|
||||
}
|
||||
if scrubbed, evts := s.Replace(out, "memory:tool", security.Patterns); len(evts) > 0 {
|
||||
m["output"] = scrubbed
|
||||
logEvtHits("记忆候选工具结果", evts)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func logEvtHits(where string, evts []security.Evt) {
|
||||
for _, e := range evts {
|
||||
log.Printf("[security] %s 拦截 %s(模式 %s,%d 字节,合并=%v)",
|
||||
where, e.Alias, e.Kind, e.Len, e.Merged)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------- 两处拦截的关系(重要,别删任一处) ----------------
|
||||
//
|
||||
// 实测确认:`Append` 里的 `scrubEventSecrets` 会**连带改掉** `f.ToolResults` ——
|
||||
// 因为 `turnEvt.ToolResults = f.ToolResults` 是值切片,共享底层数组,
|
||||
// 而 scrub 对 `evt.ToolResults[i].Output` 是就地赋值。
|
||||
//
|
||||
// 于是 `emitMemoryCandidate` 收到时往往已是别名 ⇒ 单独去掉
|
||||
// `scrubMemoryCandidate` 的变异实验**不会报红**(已验证)。
|
||||
//
|
||||
// ## 那为什么还要保留 scrubMemoryCandidate
|
||||
//
|
||||
// 因为「隐式依赖别名」是**脆弱**的:只要有人
|
||||
// · 把 `turnEvt.ToolResults` 改成深拷贝(很自然的"防御性拷贝"改动),
|
||||
// · 或调整 Append 与 emitMemoryCandidate 的先后顺序,
|
||||
// · 或新增一条不经过 Append 的写入路径,
|
||||
// 记忆侧就会静默恢复泄漏,而没有任何判据会报红。
|
||||
//
|
||||
// `scrubMemoryCandidate` 是**显式契约**:无论上游怎么排版,记忆候选本身
|
||||
// 必须自己清洗。幂等(已是别名的不会再命中),代价只是一次正则扫描。
|
||||
//
|
||||
// 同理 `Append` 与 `InsertByTimestamp` 两处都要调 `scrubEventSecrets`:
|
||||
// 它们是两个独立入口,不能只保一处。
|
||||
@ -2,11 +2,13 @@ package core
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
|
||||
agentAPI "github.com/JianFeeeee/HomeAgent/internal/agent/api"
|
||||
agentIO "github.com/JianFeeeee/HomeAgent/internal/agent/io"
|
||||
sdk "github.com/JianFeeeee/HomeAgent/internal/sdk"
|
||||
"github.com/JianFeeeee/HomeAgent/internal/security"
|
||||
)
|
||||
|
||||
func (a *Agent) executeOutputSendTool(tc agentAPI.ToolCall) string {
|
||||
@ -73,6 +75,20 @@ func (a *Agent) executeOutputSendTool(tc agentAPI.ToolCall) string {
|
||||
args["payload"] = stageCtx.FinalText
|
||||
|
||||
if dev := a.io.GetDevice(channel); dev != nil {
|
||||
// ★ 安全模块入向回填:把 @{别名} 换回明文。
|
||||
//
|
||||
// 位置在**插件 stage 之后、设备 Execute 之前**是有讲究的:
|
||||
// · 放在 stage 之前 ⇒ 插件会看到明文(不可,插件在信任边界外)
|
||||
// · 放在 Execute 之后 ⇒ 设备已收到带别名的载荷(功能坏了)
|
||||
// 所以只能夹在中间:插件看不到,设备拿到真的。
|
||||
//
|
||||
// 注意单独处理 payload:args 里的其它字段(channel 等)不需要回填。
|
||||
if s := security.Global(); s != nil {
|
||||
if filled, n, used := s.Fill(args["payload"].(string)); n > 0 {
|
||||
args["payload"] = filled
|
||||
log.Printf("[security] 通道 %s 载荷回填 %d 处(别名 %v)", channel, n, used)
|
||||
}
|
||||
}
|
||||
result, err := dev.Execute("output", args)
|
||||
if err != nil {
|
||||
return fmt.Sprintf("通过 [%s] 通道发送失败: %v", channel, err)
|
||||
|
||||
@ -232,7 +232,7 @@ func (a *Agent) raiseContextOverflow(pruned int) {
|
||||
// overflowNotice 构造超页中断消息。
|
||||
//
|
||||
// 抽成纯函数是为了可测:这条消息的措辞不是装饰,它是 agent 唯一的认知输入
|
||||
//(L4 任务按调度器设计拿不到被打断者的上下文)。「查不到不等于不存在」这句
|
||||
// (L4 任务按调度器设计拿不到被打断者的上下文)。「查不到不等于不存在」这句
|
||||
// 直接对应 v4 实测的一类真实失败 —— HA 对检索不到的事实回答
|
||||
// 「库里根本没有 X,任何数字都是编的」,因为它不知道有内容被移走了。
|
||||
func overflowNotice(pruned int) string {
|
||||
@ -267,4 +267,4 @@ func (a *Agent) contextLenSafe() int {
|
||||
return a.context.Len()
|
||||
}
|
||||
|
||||
// overflowStat 字段定义在 Agent 上(见 agent.go),此处只做访问辅助。
|
||||
// overflowStat 字段定义在 Agent 上(见 agent.go),此处只做访问辅助。
|
||||
|
||||
@ -221,6 +221,7 @@ func TestOverflow_并发安全(t *testing.T) {
|
||||
}
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
// ── T5(变异项):帧内恢复预算耗尽 ⇒ 终止而非死循环 ──
|
||||
//
|
||||
// 为什么要单独测:这个防护一度是**死代码**(TaskFrame.OverflowRecover 声明了
|
||||
@ -309,8 +310,8 @@ func TestOverflow_T9_topK随窗口变化(t *testing.T) {
|
||||
return newOverflowAgent(window, 30, 20, 400) // maxContextSize=30(默认)
|
||||
}
|
||||
|
||||
small := build(10_000) // 小窗口
|
||||
large := build(1_000_000) // 大窗口(用户关心的 1M 场景)
|
||||
small := build(10_000) // 小窗口
|
||||
large := build(1_000_000) // 大窗口(用户关心的 1M 场景)
|
||||
|
||||
topKSmall := small.contextTopK()
|
||||
topKLarge := large.contextTopK()
|
||||
|
||||
221
internal/agent/core/persist_leak_test.go
Normal file
221
internal/agent/core/persist_leak_test.go
Normal file
@ -0,0 +1,221 @@
|
||||
package core
|
||||
|
||||
// 判据:**持久化侧路的明文拦截**(文档 C5/C6)。
|
||||
//
|
||||
// ## 背景:出向拦截挡不住持久化
|
||||
//
|
||||
// provider 装饰器只改 `f.Msgs`(要发出去的那一份)。但内核把同样的内容
|
||||
// **另存副本**送往持久化存储,它们不经过 provider:
|
||||
//
|
||||
// ContextEvent.Input ← f.CleanInput 用户输入原文
|
||||
// ContextEvent.Response ← f.Response 模型回复
|
||||
// ContextEvent.ToolResults ← f.ToolResults 工具输出
|
||||
// memory_candidate.payload 走 EmitOutput,不经 context
|
||||
//
|
||||
// 实测确认(本文件的判据原本都是探针,先量出泄漏再修):
|
||||
// 只拦 f.Msgs 时明文完整写进 context.json **与长期记忆** —— 而记忆
|
||||
// 跨会话保留、每轮被召回,泄漏面比重放一次大得多。
|
||||
//
|
||||
// ## 为什么拦在 Append/InsertByTimestamp 而不是各调用点
|
||||
//
|
||||
// 写 context 有 5 处;逐个补必漏(本文件的前身就漏掉了 f.CleanInput)。
|
||||
// 那两处是唯一咽喉,拦一处即全覆盖。
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/JianFeeeee/HomeAgent/internal/memory"
|
||||
|
||||
agentAPI "github.com/JianFeeeee/HomeAgent/internal/agent/api"
|
||||
agentIO "github.com/JianFeeeee/HomeAgent/internal/agent/io"
|
||||
)
|
||||
|
||||
// ★★ 判据:**用户输入**里的明文不得进 context(此前完全未被覆盖)。
|
||||
func TestE2E_InputSecretNeverReachesContext(t *testing.T) {
|
||||
store := newScrubStore(t)
|
||||
const tok = "ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||
|
||||
a := New(AgentConfig{
|
||||
ID: "e2e-inputscrub",
|
||||
Provider: &plainReplyProvider{},
|
||||
ProviderManager: agentAPI.NewProviderManager(),
|
||||
IO: agentIO.NewIOManager(),
|
||||
StageHost: NewStageHost(),
|
||||
})
|
||||
a.Start()
|
||||
defer a.Stop()
|
||||
|
||||
a.io.InjectTextSync("http", "我的 token 是 "+tok+" 请记住")
|
||||
|
||||
for _, evt := range a.context.Recent(0) {
|
||||
if strings.Contains(evt.Input, tok) {
|
||||
t.Errorf("用户输入明文进了 a.context: %q", evt.Input)
|
||||
}
|
||||
if strings.Contains(evt.Response, tok) {
|
||||
t.Errorf("回复里的明文进了 a.context: %q", evt.Response)
|
||||
}
|
||||
}
|
||||
if store.Count() == 0 {
|
||||
t.Fatal("变量库为空 —— 输入侧没被扫,判据无效")
|
||||
}
|
||||
t.Logf("✅ 输入侧明文未进 context;变量库 %d 条", store.Count())
|
||||
}
|
||||
|
||||
// ★★ 判据:**模型回复**里的明文不得进 context。
|
||||
//
|
||||
// 模型看不到明文(出向已拦),所以它只能在**被喂了明文**时才回吐明文 ——
|
||||
// 这里用脚本 provider 直接造出那个回复,验证回复侧拦截生效。
|
||||
func TestE2E_ResponseSecretNeverReachesContext(t *testing.T) {
|
||||
store := newScrubStore(t)
|
||||
const tok = "ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||
|
||||
a := New(AgentConfig{
|
||||
ID: "e2e-respscrub",
|
||||
Provider: &echoTokenProvider{reply: "你的凭据是 " + tok},
|
||||
ProviderManager: agentAPI.NewProviderManager(),
|
||||
IO: agentIO.NewIOManager(),
|
||||
StageHost: NewStageHost(),
|
||||
})
|
||||
a.Start()
|
||||
defer a.Stop()
|
||||
|
||||
a.io.InjectTextSync("http", "把凭据告诉我")
|
||||
|
||||
for _, evt := range a.context.Recent(0) {
|
||||
if strings.Contains(evt.Response, tok) {
|
||||
t.Errorf("模型回复明文进了 a.context: %q", evt.Response)
|
||||
}
|
||||
}
|
||||
if store.Count() == 0 {
|
||||
t.Fatal("变量库为空 —— 回复侧没被扫,判据无效")
|
||||
}
|
||||
t.Logf("✅ 回复侧明文未进 context;变量库 %d 条", store.Count())
|
||||
}
|
||||
|
||||
// ★ 判据:**记忆块**(一等记忆的文本负载)也要过一遍。
|
||||
func TestScrubEventCoversMemoryBlocks(t *testing.T) {
|
||||
store := newScrubStore(t)
|
||||
const tok = "ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||
|
||||
evt := &ContextEvent{
|
||||
Input: "带着 " + tok,
|
||||
Response: "也带着 " + tok,
|
||||
ToolResults: []ToolResultItem{
|
||||
{Name: "cmd", Output: "还有 " + tok},
|
||||
},
|
||||
}
|
||||
evt.Blocks = append(evt.Blocks, memoryBlockWithText("转写稿里有 "+tok))
|
||||
|
||||
scrubEventSecrets(evt)
|
||||
|
||||
for name, got := range map[string]string{
|
||||
"input": evt.Input,
|
||||
"response": evt.Response,
|
||||
"tool": evt.ToolResults[0].Output,
|
||||
"block": evt.Blocks[0].Text,
|
||||
} {
|
||||
if strings.Contains(got, tok) {
|
||||
t.Errorf("%s 仍含明文: %q", name, got)
|
||||
}
|
||||
}
|
||||
if store.Count() == 0 {
|
||||
t.Error("应登记变量")
|
||||
}
|
||||
}
|
||||
|
||||
// memoryBlockWithText 造一个文本记忆块。
|
||||
func memoryBlockWithText(text string) memory.MemoryBlock {
|
||||
return memory.MemoryBlock{ID: "b1", Modality: memory.BlockText, Text: text}
|
||||
}
|
||||
|
||||
// plainReplyProvider 给一句不含凭据的回复。
|
||||
type plainReplyProvider struct{}
|
||||
|
||||
func (p *plainReplyProvider) Name() string { return "plainReply" }
|
||||
func (p *plainReplyProvider) MaxContextTokens() int { return 8192 }
|
||||
func (p *plainReplyProvider) Chat(_ context.Context, _ *agentAPI.CompletionRequest) (*agentAPI.CompletionResponse, error) {
|
||||
return &agentAPI.CompletionResponse{Content: "好的,记住了"}, nil
|
||||
}
|
||||
func (p *plainReplyProvider) ChatStream(_ context.Context, _ *agentAPI.CompletionRequest) (<-chan agentAPI.StreamChunk, error) {
|
||||
ch := make(chan agentAPI.StreamChunk, 1)
|
||||
ch <- agentAPI.StreamChunk{Content: "好的,记住了", Done: true}
|
||||
close(ch)
|
||||
return ch, nil
|
||||
}
|
||||
|
||||
// echoTokenProvider 回吐一段固定文本(用于造出"回复含明文"的场景)。
|
||||
type echoTokenProvider struct{ reply string }
|
||||
|
||||
func (p *echoTokenProvider) Name() string { return "echoToken" }
|
||||
func (p *echoTokenProvider) MaxContextTokens() int { return 8192 }
|
||||
func (p *echoTokenProvider) Chat(_ context.Context, _ *agentAPI.CompletionRequest) (*agentAPI.CompletionResponse, error) {
|
||||
return &agentAPI.CompletionResponse{Content: p.reply}, nil
|
||||
}
|
||||
func (p *echoTokenProvider) ChatStream(_ context.Context, _ *agentAPI.CompletionRequest) (<-chan agentAPI.StreamChunk, error) {
|
||||
ch := make(chan agentAPI.StreamChunk, 1)
|
||||
ch <- agentAPI.StreamChunk{Content: p.reply, Done: true}
|
||||
close(ch)
|
||||
return ch, nil
|
||||
}
|
||||
|
||||
// ★★ 判据:`emitMemoryCandidate` **自己**清洗载荷,不依赖 Append 的隐含行为。
|
||||
//
|
||||
// 背景(实测):`Append` 的 scrub 经共享底层数组会连带改掉 `f.ToolResults`,
|
||||
// 所以"去掉 scrubMemoryCandidate"的变异**不会**被走 a.context 的端到端判据发现。
|
||||
// 那意味着记忆侧防护是隐式依赖别名的 —— 一旦有人给 turnEvt.ToolResults
|
||||
// 加个深拷贝(很自然的"防御性拷贝"),记忆侧就静默泄漏。
|
||||
//
|
||||
// ❗本判据必须走 `a.emitMemoryCandidate` 这个**真实入口**,而不是直接调
|
||||
//
|
||||
// `scrubMemoryCandidate` —— 初版直接调底层函数,于是"去掉入口处的调用"
|
||||
// 这个变异**不会报红**(判据测错了层次)。走真实入口才能钉住"入口必须调它"。
|
||||
func TestEmitMemoryCandidateScrubsPayload(t *testing.T) {
|
||||
store := newScrubStore(t)
|
||||
const tok = "ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||
|
||||
a := New(AgentConfig{
|
||||
ID: "e2e-memcand",
|
||||
Provider: &plainReplyProvider{},
|
||||
ProviderManager: agentAPI.NewProviderManager(),
|
||||
IO: agentIO.NewIOManager(),
|
||||
StageHost: NewStageHost(),
|
||||
})
|
||||
|
||||
// 故意传**未经过任何上游清洗**的原始值
|
||||
a.emitMemoryCandidate("http", "输入 "+tok, "回复 "+tok,
|
||||
[]ToolResultItem{{Name: "cmd", Output: "输出 " + tok}}, []string{"cmd"})
|
||||
|
||||
var seen map[string]interface{}
|
||||
drain:
|
||||
for {
|
||||
select {
|
||||
case evt := <-a.io.OutputChan():
|
||||
if evt != nil && evt.Type == "memory_candidate" {
|
||||
seen = evt.Payload
|
||||
}
|
||||
default:
|
||||
break drain
|
||||
}
|
||||
}
|
||||
if seen == nil {
|
||||
t.Fatal("没有观察到 memory_candidate 输出")
|
||||
}
|
||||
if s, _ := seen["input"].(string); strings.Contains(s, tok) {
|
||||
t.Errorf("记忆候选 input 含明文: %q", s)
|
||||
}
|
||||
if s, _ := seen["response"].(string); strings.Contains(s, tok) {
|
||||
t.Errorf("记忆候选 response 含明文: %q", s)
|
||||
}
|
||||
if trs, ok := seen["tool_results"].([]ToolResultItem); ok {
|
||||
for _, tr := range trs {
|
||||
if strings.Contains(tr.Output, tok) {
|
||||
t.Errorf("记忆候选 tool_results 含明文: %q", tr.Output)
|
||||
}
|
||||
}
|
||||
}
|
||||
if store.Count() == 0 {
|
||||
t.Error("应登记变量")
|
||||
}
|
||||
}
|
||||
@ -80,6 +80,8 @@ var builtinToolOwner = map[string]string{
|
||||
"llm_list_sources": "core", "llm_set_source": "core", "llm_current_source": "core",
|
||||
"plgreload": "core", "get_plugin_tools": "core", "list_devices": "core",
|
||||
"devicedetect": "core", "device_ctl_status": "core", "device_ctl_cmdrun": "core",
|
||||
// 安全模块的变量库(内核能力,不是插件)
|
||||
"secret_submit": "core", "secret_list": "core", "secret_forget": "core",
|
||||
"device_ctl_cmdresult": "core", "screensee": "core",
|
||||
}
|
||||
|
||||
|
||||
154
internal/agent/core/secret_tools.go
Normal file
154
internal/agent/core/secret_tools.go
Normal file
@ -0,0 +1,154 @@
|
||||
package core
|
||||
|
||||
// 安全模块的**面向 agent 的工具族**(文档 C8)。
|
||||
//
|
||||
// ## 为什么需要这几个工具
|
||||
//
|
||||
// 用户拍板的模型是:
|
||||
//
|
||||
// 变量 = 内核全局库;**不自动淘汰**,但 agent 可以**主动提交/删除**。
|
||||
//
|
||||
// 「不自动淘汰」意味着泄漏控制不能靠超时,只能靠 agent(或人)主动删。
|
||||
// 那就必须有工具能做这两件事 —— 否则「可主动删除」只是文档里的一句空话。
|
||||
//
|
||||
// ## ★ 设计红线:**永不返回明文**
|
||||
//
|
||||
// 这几个工具**只能引用别名,不能读回明文**(设计文档 S1)。一旦提供
|
||||
// 「取回明文」的接口,模型就能把明文写回上下文,整层防护作废 ——
|
||||
// 因为它会把明文交给 LLM,而那正是要避免的。
|
||||
//
|
||||
// 所以:
|
||||
// · `secret_submit` 接受明文(这是唯一入口,且必须如此才能登记)
|
||||
// · `secret_list` 只给元信息(别名、来源、命中次数)
|
||||
// · `secret_forget` 按别名删除
|
||||
// · **没有** `secret_get`
|
||||
//
|
||||
// ## 关于 secret_submit 的明文暴露面(文档 T10)
|
||||
//
|
||||
// 提交时明文会经过 context(模型输出的参数)。这是**已知且可接受**的:
|
||||
// 提交的目的是「让内核记住这个凭据」,而模型本来就是从这里知道的。
|
||||
// 真正要防的是「凭据被送到 LLM 之外或写进持久化」—— 那由
|
||||
// memory_scrub 与 provider 装饰器覆盖。
|
||||
//
|
||||
// 更安全的替代路径(`${ENV}` / `@file:`)留给后续(T10),
|
||||
// 当前先提供最直接的形态。
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
agentAPI "github.com/JianFeeeee/HomeAgent/internal/agent/api"
|
||||
"github.com/JianFeeeee/HomeAgent/internal/security"
|
||||
)
|
||||
|
||||
func (a *Agent) executeSecretTool(tc agentAPI.ToolCall) string {
|
||||
s := security.Global()
|
||||
if s == nil {
|
||||
return "安全模块未启用(core.security.enabled=false),变量库不可用"
|
||||
}
|
||||
switch tc.Name {
|
||||
case "secret_submit":
|
||||
return a.execSecretSubmit(s, tc)
|
||||
case "secret_list":
|
||||
return a.execSecretList(s, tc)
|
||||
case "secret_forget":
|
||||
return a.execSecretForget(s, tc)
|
||||
}
|
||||
return "未知的 secret 工具: " + tc.Name
|
||||
}
|
||||
|
||||
// execSecretSubmit 主动登记一条凭据,返回其别名。
|
||||
//
|
||||
// 用途:agent 知道后面要用某个凭据(如刚读到的 token),
|
||||
// 先登记它,后续在工具参数里用 @{别名} 引用。
|
||||
func (a *Agent) execSecretSubmit(s *security.Store, tc agentAPI.ToolCall) string {
|
||||
value, _ := tc.Arguments["value"].(string)
|
||||
if strings.TrimSpace(value) == "" {
|
||||
return "value 不能为空"
|
||||
}
|
||||
note, _ := tc.Arguments["note"].(string)
|
||||
alias, err := s.Submit(value, note)
|
||||
if err != nil {
|
||||
// 无密钥等情况:**响亮失败**,不静默存明文。
|
||||
return fmt.Sprintf("登记失败(未以明文降级存储): %v", err)
|
||||
}
|
||||
// ★ 只回别名,绝不回明文 —— 否则这个工具就成了"明文读回接口"。
|
||||
return fmt.Sprintf("已登记为变量 @{%s}。后续调用请用 @{%s} 引用,不要直接写明文。", alias, alias)
|
||||
}
|
||||
|
||||
// execSecretList 列出变量元信息(**不含明文**)。
|
||||
func (a *Agent) execSecretList(s *security.Store, tc agentAPI.ToolCall) string {
|
||||
infos := s.List()
|
||||
if len(infos) == 0 {
|
||||
return "变量库为空。用 secret_submit 登记凭据。"
|
||||
}
|
||||
// 稳定排序:便于人/模型对照(map 遍历顺序是随机的)。
|
||||
sort.Slice(infos, func(i, j int) bool { return infos[i].Alias < infos[j].Alias })
|
||||
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "共 %d 个变量(只显示元信息,明文不可读回):\n", len(infos))
|
||||
for _, in := range infos {
|
||||
fmt.Fprintf(&b, " @{%s}", in.Alias)
|
||||
if in.Note != "" {
|
||||
fmt.Fprintf(&b, " %s", in.Note)
|
||||
}
|
||||
fmt.Fprintf(&b, " [来源 %s, 命中 %d]", in.Source, in.Hits)
|
||||
if in.CreatedAt != "" {
|
||||
fmt.Fprintf(&b, " 建于 %s", in.CreatedAt)
|
||||
}
|
||||
b.WriteByte('\n')
|
||||
}
|
||||
b.WriteString("\n在工具参数里用 @{别名} 引用即可(内核会换回真实值,插件无感知)。")
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// execSecretForget 按别名删除一个变量。
|
||||
func (a *Agent) execSecretForget(s *security.Store, tc agentAPI.ToolCall) string {
|
||||
alias, _ := tc.Arguments["alias"].(string)
|
||||
alias = strings.TrimSpace(alias)
|
||||
// 宽容一点:模型可能把 @{sec_xxx} 整个传进来
|
||||
alias = strings.TrimPrefix(alias, "@{")
|
||||
alias = strings.TrimSuffix(alias, "}")
|
||||
if alias == "" {
|
||||
return "alias 不能为空"
|
||||
}
|
||||
if err := s.Forget(alias); err != nil {
|
||||
if err == security.ErrUnknownAlias {
|
||||
return fmt.Sprintf("未找到变量 %s(用 secret_list 查看现有变量)", alias)
|
||||
}
|
||||
return fmt.Sprintf("删除失败: %v", err)
|
||||
}
|
||||
return fmt.Sprintf("已删除 %s。该别名不再可用。", alias)
|
||||
}
|
||||
|
||||
// secretToolDefs 返回这几个工具的定义(供 buildToolDefs 注入)。
|
||||
//
|
||||
// 直接用 toolDef 构造,与其它内置工具同形 —— 免得两处各写一份 schema。
|
||||
func secretToolDefs() []interface{} {
|
||||
return []interface{}{
|
||||
toolDef("secret_submit",
|
||||
"登记一条凭据(token/密钥/口令)到内核变量库,返回别名。之后在工具参数里用 @{别名} 引用它 —— 内核会在调用插件前把别名换回真实值,插件完全无感知。用于:你刚读到一个凭据、后面要用它,但不想让明文反复出现在对话里。注意:明文只应在本次登记时出现一次。",
|
||||
map[string]interface{}{
|
||||
"value": map[string]interface{}{
|
||||
"type": "string",
|
||||
"description": "凭据原文(只需在这里出现一次)",
|
||||
},
|
||||
"note": map[string]interface{}{
|
||||
"type": "string",
|
||||
"description": "可选:给人看的用途说明(如「Gitea 推送令牌」)",
|
||||
},
|
||||
}, "value"),
|
||||
toolDef("secret_list",
|
||||
"列出内核变量库里的全部变量(只给别名与元信息,**不含明文** —— 明文无法读回,这是刻意设计)。用于查看有哪些可用变量、某变量是否已登记。",
|
||||
map[string]interface{}{}),
|
||||
toolDef("secret_forget",
|
||||
"删除一个变量(按别名)。变量库**不会自动淘汰** —— 泄漏控制靠人/agent 主动删,所以凭据用完后应主动删除。",
|
||||
map[string]interface{}{
|
||||
"alias": map[string]interface{}{
|
||||
"type": "string",
|
||||
"description": "要删除的别名(sec_xxx 或 @{sec_xxx} 均可)",
|
||||
},
|
||||
}, "alias"),
|
||||
}
|
||||
}
|
||||
191
internal/agent/core/secret_tools_test.go
Normal file
191
internal/agent/core/secret_tools_test.go
Normal file
@ -0,0 +1,191 @@
|
||||
package core
|
||||
|
||||
// 判据:agent 面向的变量库工具(文档 C8)。
|
||||
//
|
||||
// 设计红线(文档 S1):**永不返回明文**。
|
||||
// 一旦有"取回明文"的路径,模型就能把明文写回上下文,整层防护作废。
|
||||
// 下面的判据把这条红线钉死。
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
agentAPI "github.com/JianFeeeee/HomeAgent/internal/agent/api"
|
||||
"github.com/JianFeeeee/HomeAgent/internal/security"
|
||||
)
|
||||
|
||||
const c8Token = "ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||
|
||||
func newSecretToolAgent(t *testing.T) (*Agent, *security.Store) {
|
||||
t.Helper()
|
||||
store := newScrubStore(t) // 复用已有的测试夹具
|
||||
a := New(AgentConfig{
|
||||
ID: "c8",
|
||||
ProviderManager: agentAPI.NewProviderManager(),
|
||||
StageHost: NewStageHost(),
|
||||
})
|
||||
return a, store
|
||||
}
|
||||
|
||||
func callSecret(a *Agent, name string, args map[string]interface{}) string {
|
||||
return a.executeSecretTool(agentAPI.ToolCall{Name: name, Arguments: args})
|
||||
}
|
||||
|
||||
// ★★ 判据:secret_submit 登记后**只返回别名,不回显明文**。
|
||||
func TestSecretSubmitReturnsAliasNotPlaintext(t *testing.T) {
|
||||
a, store := newSecretToolAgent(t)
|
||||
|
||||
out := callSecret(a, "secret_submit", map[string]interface{}{
|
||||
"value": c8Token, "note": "测试令牌",
|
||||
})
|
||||
if strings.Contains(out, c8Token) {
|
||||
t.Fatalf("★ 违反 S1:secret_submit 回显了明文: %q", out)
|
||||
}
|
||||
if !strings.Contains(out, "@{sec_") {
|
||||
t.Fatalf("应返回别名: %q", out)
|
||||
}
|
||||
if store.Count() != 1 {
|
||||
t.Fatalf("应登记 1 条,实际 %d", store.Count())
|
||||
}
|
||||
// 别名可用:回填能换回明文
|
||||
alias := store.List()[0].Alias
|
||||
if got, _, _ := store.Fill("@" + "{" + alias + "}"); !strings.Contains(got, c8Token) {
|
||||
t.Error("别名应能回填出明文(这是唯一通道)")
|
||||
}
|
||||
}
|
||||
|
||||
// ★★ 判据:**secret_list 绝不泄露明文**(S1 的核心)。
|
||||
func TestSecretListNeverLeaksPlaintext(t *testing.T) {
|
||||
a, _ := newSecretToolAgent(t)
|
||||
callSecret(a, "secret_submit", map[string]interface{}{"value": c8Token, "note": "n"})
|
||||
|
||||
out := callSecret(a, "secret_list", nil)
|
||||
if strings.Contains(out, c8Token) {
|
||||
t.Fatalf("★ 违反 S1:secret_list 泄露了明文: %q", out)
|
||||
}
|
||||
if !strings.Contains(out, "@{sec_") {
|
||||
t.Errorf("应列出别名: %q", out)
|
||||
}
|
||||
if !strings.Contains(out, "明文不可读回") {
|
||||
t.Error("应明确告知明文不可读回(防模型反复尝试)")
|
||||
}
|
||||
}
|
||||
|
||||
// ★ 判据:**不存在 secret_get**(防回归:有人加了它,这条会指出为什么不行)。
|
||||
func TestNoSecretGetTool(t *testing.T) {
|
||||
a, _ := newSecretToolAgent(t)
|
||||
callSecret(a, "secret_submit", map[string]interface{}{"value": c8Token})
|
||||
|
||||
out := callSecret(a, "secret_get", map[string]interface{}{"alias": "sec_x"})
|
||||
if !strings.Contains(out, "未知的 secret 工具") {
|
||||
t.Fatalf("不应存在 secret_get(会把明文交回模型): %q", out)
|
||||
}
|
||||
// 工具表里也不能有
|
||||
for _, d := range secretToolDefs() {
|
||||
m, _ := d.(map[string]interface{})
|
||||
name, _ := m["name"].(string)
|
||||
if name == "secret_get" || name == "secret_show" || name == "secret_reveal" {
|
||||
t.Fatalf("工具表里出现了明文读回工具 %q —— 违反 S1", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ★★ 判据:secret_forget 删除后别名失效,且**不自动淘汰**。
|
||||
func TestSecretForgetRemovesAndNoAutoEviction(t *testing.T) {
|
||||
a, store := newSecretToolAgent(t)
|
||||
callSecret(a, "secret_submit", map[string]interface{}{"value": c8Token})
|
||||
alias := store.List()[0].Alias
|
||||
|
||||
// 宽容传参:@{sec_xxx} 与裸别名都可
|
||||
if out := callSecret(a, "secret_forget", map[string]interface{}{
|
||||
"alias": "@{" + alias + "}",
|
||||
}); !strings.Contains(out, "已删除") {
|
||||
t.Fatalf("删除应成功: %q", out)
|
||||
}
|
||||
if store.Count() != 0 {
|
||||
t.Fatalf("删除后应为空,实际 %d", store.Count())
|
||||
}
|
||||
if got, n, _ := store.Fill("@" + "{" + alias + "}"); n != 0 || strings.Contains(got, c8Token) {
|
||||
t.Error("删除后别名不应再回填")
|
||||
}
|
||||
// 重复删 → 明确提示(不是静默成功)
|
||||
if out := callSecret(a, "secret_forget", map[string]interface{}{"alias": alias}); !strings.Contains(out, "未找到") {
|
||||
t.Errorf("重复删除应明确提示: %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
// ★ 判据:空值提交被拒(不是静默登记一个空变量)。
|
||||
func TestSecretSubmitRejectsEmpty(t *testing.T) {
|
||||
a, store := newSecretToolAgent(t)
|
||||
for _, v := range []string{"", " "} {
|
||||
if out := callSecret(a, "secret_submit", map[string]interface{}{"value": v}); !strings.Contains(out, "不能为空") {
|
||||
t.Errorf("空值应被拒: %q", out)
|
||||
}
|
||||
}
|
||||
if store.Count() != 0 {
|
||||
t.Error("空值不应入库")
|
||||
}
|
||||
}
|
||||
|
||||
// ★ 判据:安全模块未启用时,工具明确报不可用(不静默失败)。
|
||||
func TestSecretToolsReportUnavailable(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
_ = dir
|
||||
security.SetGlobal(nil)
|
||||
defer newScrubStore(t) // 复原给后续判据
|
||||
|
||||
a := New(AgentConfig{ID: "c8-off", StageHost: NewStageHost()})
|
||||
out := callSecret(a, "secret_list", nil)
|
||||
if !strings.Contains(out, "未启用") {
|
||||
t.Fatalf("未启用时应明确报不可用: %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
// ★ 判据:未启用时**不向模型暴露**这些工具(避免无谓往返)。
|
||||
func TestSecretToolsHiddenWhenDisabled(t *testing.T) {
|
||||
security.SetGlobal(nil)
|
||||
a := New(AgentConfig{ID: "c8-hidden", StageHost: NewStageHost()})
|
||||
|
||||
var names []string
|
||||
for _, d := range a.buildToolDefs() {
|
||||
m, ok := d.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
fn, _ := m["function"].(map[string]interface{})
|
||||
if fn == nil {
|
||||
fn = m
|
||||
}
|
||||
if n, _ := fn["name"].(string); strings.HasPrefix(n, "secret_") {
|
||||
names = append(names, n)
|
||||
}
|
||||
}
|
||||
if len(names) > 0 {
|
||||
t.Fatalf("未启用安全模块时不应暴露 secret_* 工具: %v", names)
|
||||
}
|
||||
}
|
||||
|
||||
// ★ 判据:启用时**要暴露**(否则模型不知道能用)。
|
||||
func TestSecretToolsExposedWhenEnabled(t *testing.T) {
|
||||
newScrubStore(t)
|
||||
a := New(AgentConfig{ID: "c8-shown", StageHost: NewStageHost()})
|
||||
|
||||
got := map[string]bool{}
|
||||
for _, d := range a.buildToolDefs() {
|
||||
m, ok := d.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
// toolDef 返回的是 {"type":"function","function":{...}}
|
||||
if fn, ok := m["function"].(map[string]interface{}); ok {
|
||||
if n, _ := fn["name"].(string); strings.HasPrefix(n, "secret_") {
|
||||
got[n] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, want := range []string{"secret_submit", "secret_list", "secret_forget"} {
|
||||
if !got[want] {
|
||||
t.Errorf("启用后应暴露 %s;实际得到 %v", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
287
internal/agent/core/security_concurrency_test.go
Normal file
287
internal/agent/core/security_concurrency_test.go
Normal file
@ -0,0 +1,287 @@
|
||||
package core
|
||||
|
||||
// 安全模块的**端到端并发压测**。
|
||||
//
|
||||
// ## 为什么必须做
|
||||
//
|
||||
// 安全模块挂在每次 LLM 调用与每次工具调用的必经路径上,而内核是
|
||||
// **并发**的:多 agent、多任务、调度器并行、插件回调各自的 goroutine。
|
||||
//
|
||||
// security 包自己的并发判据只验了 Store 内部;
|
||||
// **Store 的使用方**(provider 装饰器、ExecuteTool、Append、emitMemoryCandidate)
|
||||
// 是否并发安全,只有让真实 Agent 并跑才看得出来。
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
agentAPI "github.com/JianFeeeee/HomeAgent/internal/agent/api"
|
||||
agentIO "github.com/JianFeeeee/HomeAgent/internal/agent/io"
|
||||
sdk "github.com/JianFeeeee/HomeAgent/internal/sdk"
|
||||
"github.com/JianFeeeee/HomeAgent/internal/security"
|
||||
)
|
||||
|
||||
// ★★ 判据:**多 agent 并发出向** —— 明文一律不得泄漏到 provider。
|
||||
//
|
||||
// 每个 agent 有自己的 provider 实例,但它们共享**同一个**
|
||||
// ProviderManager 装饰器与变量库单例(这正是设计要验的点)。
|
||||
func TestConcurrency_MultiAgentOutboundScrub(t *testing.T) {
|
||||
store := newScrubStore(t)
|
||||
const tok = "ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||
|
||||
const agents = 16
|
||||
const rounds = 40
|
||||
|
||||
var leaked atomic.Int64
|
||||
var wg sync.WaitGroup
|
||||
|
||||
for a := 0; a < agents; a++ {
|
||||
wg.Add(1)
|
||||
go func(a int) {
|
||||
defer wg.Done()
|
||||
var captured string
|
||||
mgr := agentAPI.NewProviderManager()
|
||||
mgr.Register("p", &fwCaptureProvider{name: "p", capture: &captured})
|
||||
p := mgr.Default()
|
||||
|
||||
for r := 0; r < rounds; r++ {
|
||||
// 并发登记(同明文 ⇒ 应合并到同一别名)
|
||||
if _, err := store.Submit(tok, "conc"); err != nil {
|
||||
t.Errorf("Submit: %v", err)
|
||||
return
|
||||
}
|
||||
req := &agentAPI.CompletionRequest{Messages: []agentAPI.Message{
|
||||
{Role: "user", Content: fmt.Sprintf("agent%d round%d token=%s", a, r, tok)},
|
||||
}}
|
||||
if _, err := p.Chat(nil, req); err != nil { //nolint:staticcheck
|
||||
t.Errorf("Chat: %v", err)
|
||||
return
|
||||
}
|
||||
if strings.Contains(captured, tok) {
|
||||
leaked.Add(1)
|
||||
}
|
||||
}
|
||||
}(a)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
if leaked.Load() > 0 {
|
||||
t.Fatalf("★ 并发下 %d 次出向泄漏明文(装饰器不是并发安全的)", leaked.Load())
|
||||
}
|
||||
// 同明文必须合并成一条
|
||||
if got := store.Count(); got != 1 {
|
||||
t.Errorf("★ 同明文并发登记应合并为 1 条,实际 %d", got)
|
||||
}
|
||||
t.Logf("✅ %d agent × %d 轮并发出向,零泄漏;变量库 1 条", agents, rounds)
|
||||
}
|
||||
|
||||
// ★★ 判据:**并发工具调用** —— 参数回填不串号。
|
||||
//
|
||||
// 真实风险:A 的工具参数里的别名被填成 B 的凭据(串号),
|
||||
// 这会造成"用了错的凭据"且**不报错**。
|
||||
func TestConcurrency_ToolArgsDoNotCrossTalk(t *testing.T) {
|
||||
store := newScrubStore(t)
|
||||
a, _, _ := newRootForResidents(t)
|
||||
|
||||
// 每个 goroutine 用**自己**的凭据与别名
|
||||
const workers = 12
|
||||
aliases := make([]string, workers)
|
||||
plains := make([]string, workers)
|
||||
for i := 0; i < workers; i++ {
|
||||
plains[i] = fmt.Sprintf("distinct-secret-%02d-aaaaaaaaaaaaaaaaaaaa", i)
|
||||
al, err := store.Submit(plains[i], fmt.Sprintf("w%d", i))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
aliases[i] = al
|
||||
}
|
||||
|
||||
var mixed atomic.Int64
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < workers; i++ {
|
||||
wg.Add(1)
|
||||
go func(i int) {
|
||||
defer wg.Done()
|
||||
var seen string
|
||||
tool := fmt.Sprintf("conc_tool_%d", i)
|
||||
// 每个 goroutine 注册自己的工具(StageHost 的注册是加锁的)
|
||||
if err := a.stageHost.RegisterTool(tool, sdk.ToolDef{
|
||||
Name: tool, Description: "并发压测",
|
||||
Parameters: map[string]interface{}{"type": "object"},
|
||||
}, func(args map[string]interface{}) (interface{}, error) {
|
||||
seen, _ = args["secret"].(string)
|
||||
return "ok", nil
|
||||
}); err != nil {
|
||||
t.Errorf("RegisterTool: %v", err)
|
||||
return
|
||||
}
|
||||
for r := 0; r < 60; r++ {
|
||||
seen = ""
|
||||
if _, err := a.stageHost.ExecuteTool(tool, map[string]interface{}{
|
||||
"secret": "@{" + aliases[i] + "}",
|
||||
}); err != nil {
|
||||
t.Errorf("ExecuteTool: %v", err)
|
||||
return
|
||||
}
|
||||
// ★ 必须填成**自己的**那个,不能是别人的
|
||||
if seen != plains[i] {
|
||||
mixed.Add(1)
|
||||
return
|
||||
}
|
||||
}
|
||||
}(i)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
if mixed.Load() > 0 {
|
||||
t.Fatal("★ 并发工具调用发生**串号**(填成了别的凭据)—— 用错凭据且不报错")
|
||||
}
|
||||
t.Logf("✅ %d goroutine × 60 轮并发工具调用,零串号", workers)
|
||||
}
|
||||
|
||||
// ★★ 判据:**并发上下文写入**(Append 咽喉的 scrub)不丢不串。
|
||||
func TestConcurrency_ConcurrentContextScrub(t *testing.T) {
|
||||
store := newScrubStore(t)
|
||||
const tok = "ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||
if _, err := store.Submit(tok, "ctx"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
a, _, _ := newRootForResidents(t)
|
||||
|
||||
const writers = 10
|
||||
const perWriter = 50
|
||||
var wg sync.WaitGroup
|
||||
for w := 0; w < writers; w++ {
|
||||
wg.Add(1)
|
||||
go func(w int) {
|
||||
defer wg.Done()
|
||||
for i := 0; i < perWriter; i++ {
|
||||
a.context.Append(ContextEvent{
|
||||
Source: fmt.Sprintf("w%d", w),
|
||||
Input: fmt.Sprintf("in %d token=%s", i, tok),
|
||||
Response: "out contains " + tok,
|
||||
ToolResults: []ToolResultItem{
|
||||
{Name: "t", Output: "tool " + tok},
|
||||
},
|
||||
})
|
||||
}
|
||||
}(w)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
// 全部事件都不该含明文
|
||||
bad := 0
|
||||
for _, evt := range a.context.Recent(0) {
|
||||
if strings.Contains(evt.Input, tok) || strings.Contains(evt.Response, tok) {
|
||||
bad++
|
||||
}
|
||||
for _, tr := range evt.ToolResults {
|
||||
if strings.Contains(tr.Output, tok) {
|
||||
bad++
|
||||
}
|
||||
}
|
||||
}
|
||||
if bad > 0 {
|
||||
t.Fatalf("★ 并发写入下有 %d 处明文未被拦下", bad)
|
||||
}
|
||||
if n := a.context.Len(); n < writers*perWriter {
|
||||
t.Errorf("并发写入丢事件:期望至少 %d,实际 %d", writers*perWriter, n)
|
||||
}
|
||||
t.Logf("✅ %d goroutine × %d 次并发写入全部拦下(事件数 %d)",
|
||||
writers, perWriter, a.context.Len())
|
||||
}
|
||||
|
||||
// ★★ 判据:**并发记忆候选**(emitMemoryCandidate 的 scrub)。
|
||||
func TestConcurrency_ConcurrentMemoryCandidate(t *testing.T) {
|
||||
store := newScrubStore(t)
|
||||
const tok = "ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||
if _, err := store.Submit(tok, "mem"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
a := New(AgentConfig{
|
||||
ID: "conc-mem",
|
||||
Provider: &plainReplyProvider{},
|
||||
ProviderManager: agentAPI.NewProviderManager(),
|
||||
IO: agentIO.NewIOManager(),
|
||||
StageHost: NewStageHost(),
|
||||
})
|
||||
|
||||
const n = 200
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < n; i++ {
|
||||
wg.Add(1)
|
||||
go func(i int) {
|
||||
defer wg.Done()
|
||||
a.emitMemoryCandidate("src",
|
||||
fmt.Sprintf("in %d %s", i, tok),
|
||||
"resp "+tok,
|
||||
[]ToolResultItem{{Name: "t", Output: "tool " + tok}},
|
||||
[]string{"t"})
|
||||
}(i)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
// 排空输出,逐条检查载荷
|
||||
leaked := 0
|
||||
got := 0
|
||||
for {
|
||||
select {
|
||||
case evt := <-a.io.OutputChan():
|
||||
if evt == nil || evt.Type != "memory_candidate" {
|
||||
continue
|
||||
}
|
||||
got++
|
||||
if s, _ := evt.Payload["input"].(string); strings.Contains(s, tok) {
|
||||
leaked++
|
||||
}
|
||||
if s, _ := evt.Payload["response"].(string); strings.Contains(s, tok) {
|
||||
leaked++
|
||||
}
|
||||
if trs, ok := evt.Payload["tool_results"].([]ToolResultItem); ok {
|
||||
for _, tr := range trs {
|
||||
if strings.Contains(tr.Output, tok) {
|
||||
leaked++
|
||||
}
|
||||
}
|
||||
}
|
||||
default:
|
||||
goto done
|
||||
}
|
||||
}
|
||||
done:
|
||||
if leaked > 0 {
|
||||
t.Fatalf("★ 并发记忆候选中 %d 处含明文(会写进长期记忆)", leaked)
|
||||
}
|
||||
if got == 0 {
|
||||
t.Fatal("没有观察到记忆候选 —— 判据无效")
|
||||
}
|
||||
t.Logf("✅ %d 条并发记忆候选全部已清洗", got)
|
||||
}
|
||||
|
||||
// ★ 判据:并发守卫(未启用安全模块)时零成本且不 panic。
|
||||
func TestConcurrency_DisabledIsSafe(t *testing.T) {
|
||||
security.SetGlobal(nil)
|
||||
t.Cleanup(func() { newScrubStore(t) })
|
||||
|
||||
a, _, _ := newRootForResidents(t)
|
||||
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < 20; i++ {
|
||||
wg.Add(1)
|
||||
go func(i int) {
|
||||
defer wg.Done()
|
||||
a.context.Append(ContextEvent{
|
||||
Source: "s", Input: "in", Response: "out",
|
||||
ToolResults: []ToolResultItem{{Name: "t", Output: "o"}},
|
||||
})
|
||||
a.emitMemoryCandidate("s", "in", "out", nil, nil)
|
||||
}(i)
|
||||
}
|
||||
wg.Wait()
|
||||
t.Log("✅ 未启用时并发调用不 panic")
|
||||
}
|
||||
@ -53,13 +53,18 @@ func (a *Agent) publishEvent(evtType events.EventType, payload map[string]interf
|
||||
}
|
||||
|
||||
func (a *Agent) stageCtxFromInput(input, userID, groupID string) *sdk.StageContext {
|
||||
return &sdk.StageContext{
|
||||
ctx := &sdk.StageContext{
|
||||
RawMessage: input,
|
||||
UserID: userID,
|
||||
GroupID: groupID,
|
||||
Phase: sdk.StageOnInput,
|
||||
Extra: make(map[string]interface{}),
|
||||
}
|
||||
// ★ 来源标识必须在**帧的起点**就写入,不能只写在工具帧上:
|
||||
// 插件可能在 on_input 阶段就据此决定是否介入(而那个阶段早于工具帧构造)。
|
||||
// 见 comefrom.go 的完整说明。
|
||||
stampComeFrom(ctx.Extra, a.comefromForAgent())
|
||||
return ctx
|
||||
}
|
||||
|
||||
func (a *Agent) injectSourceContext(stageCtx *sdk.StageContext, evt *agentIO.InputEvent) {
|
||||
|
||||
@ -7,10 +7,15 @@ import (
|
||||
"sync"
|
||||
|
||||
agentIO "github.com/JianFeeeee/HomeAgent/internal/agent/io"
|
||||
safeclose "github.com/JianFeeeee/HomeAgent/internal/safeclose"
|
||||
sdk "github.com/JianFeeeee/HomeAgent/internal/sdk"
|
||||
"github.com/JianFeeeee/HomeAgent/internal/security"
|
||||
)
|
||||
|
||||
type StageHost struct {
|
||||
// frozenFn 报告内核是否处于伪关机(迁移中)。nil = 永不冻结。
|
||||
// 用函数而不是直接引用 plugin.Registry,避免 agent/core 反向依赖 plugin 包。
|
||||
frozenFn func() bool
|
||||
mu sync.RWMutex
|
||||
toolDefs []sdk.ToolDef
|
||||
tools map[string]sdk.ToolHandler
|
||||
@ -156,6 +161,16 @@ func (h *StageHost) ExecuteTool(name string, args map[string]interface{}) (ret i
|
||||
if handler == nil {
|
||||
return nil, fmt.Errorf("tool %s has nil handler", name)
|
||||
}
|
||||
// ★ 安全模块入向回填:这里是**插件工具调用的唯一入口**,
|
||||
// 填在这里就覆盖了全部插件 —— 插件本身完全无感知。
|
||||
//
|
||||
// 为何必须填而不是拒绝:模型手上只有别名(它从未见过明文),
|
||||
// 而插件需要真凭据。回填是二者之间唯一的通道。
|
||||
if s := security.Global(); s != nil && args != nil {
|
||||
if n, used := s.FillMap(args); n > 0 {
|
||||
log.Printf("[security] 工具 %s 参数回填 %d 处(别名 %v)", name, n, used)
|
||||
}
|
||||
}
|
||||
return handler(args)
|
||||
}
|
||||
|
||||
@ -237,6 +252,7 @@ func (h *StageHost) RunStage(stage sdk.Stage, ctx *sdk.StageContext) {
|
||||
}
|
||||
var wg sync.WaitGroup
|
||||
errCh := make(chan error, len(entries))
|
||||
var errChOnce sync.Once
|
||||
for _, entry := range entries {
|
||||
wg.Add(1)
|
||||
go func(fn sdk.StageHandler) {
|
||||
@ -252,7 +268,7 @@ func (h *StageHost) RunStage(stage sdk.Stage, ctx *sdk.StageContext) {
|
||||
}(entry.fn)
|
||||
}
|
||||
wg.Wait()
|
||||
close(errCh)
|
||||
safeclose.Safe(&errChOnce, errCh)
|
||||
|
||||
var errs []string
|
||||
for err := range errCh {
|
||||
|
||||
@ -534,6 +534,8 @@ func (a *Agent) finishInputTask(f *TaskFrame, out stepOutcome) {
|
||||
ToolResults: f.ToolResults,
|
||||
}
|
||||
a.bindEventMedia(&turnEvt, a.drainMediaBlocks())
|
||||
// ★ 明文拦截发生在 a.context.Append **内部**(见 context.go 的 Append),
|
||||
// 不是在每个调用点 —— 写 context 的地方有 5 处,逐个补必漏。
|
||||
a.context.Append(turnEvt)
|
||||
|
||||
a.emitResponse(evt, f.Response, f.turnUsage)
|
||||
@ -1353,6 +1355,15 @@ func (f *TaskFrame) buildToolContexts() {
|
||||
Extra: copyExtraMap(base.Extra),
|
||||
NoMemory: base.NoMemory,
|
||||
}
|
||||
// ★ **工具调用帧必须带来源标识**(comefrom):
|
||||
// 通道插件的权限门(before_toolcall)靠它区分"这是用户请求"还是
|
||||
// "这是 agent 内部委派"。缺了它,驻留子的工具调用会被按**用户**
|
||||
// 权限模型判定 ⇒ 合法工作全被拦(2026-10-16 现场实证,见 comefrom.go)。
|
||||
//
|
||||
// 从 base 继承(StageCtx 已由 stageCtxFromInput 标过);copyExtraMap
|
||||
// 会把该键一并复制,这里只在因某种路径缺失时补一个**保守值**
|
||||
// (保守 = 按旧语义当作用户请求,宁可多查一次也不再出现"静默越权")。
|
||||
stampComeFrom(f.toolCtxs[i].Extra, comeFromUser)
|
||||
// Phase 由 runStage 每次调用时设置,此处不预置。
|
||||
}
|
||||
}
|
||||
|
||||
@ -122,6 +122,8 @@ func (a *Agent) executeToolCallInner(tc agentAPI.ToolCall, channel string, turnS
|
||||
return toolOutcome{Text: a.executeChildResultTool(tc)}
|
||||
case strings.HasPrefix(tc.Name, "llm_"):
|
||||
return toolOutcome{Text: a.executeLLMTool(tc)}
|
||||
case strings.HasPrefix(tc.Name, "secret_"):
|
||||
return toolOutcome{Text: a.executeSecretTool(tc)}
|
||||
// ★★ 2026-10-06:describe_image / transcribe_audio / ocr_image 已移出内核,
|
||||
// 改为内置插件 multimodal(见 internal/plugins/multimodal)。
|
||||
// 理由:它们要读本地文件(IO),而内核承诺零 IO。
|
||||
|
||||
@ -9,6 +9,7 @@ import (
|
||||
agentIO "github.com/JianFeeeee/HomeAgent/internal/agent/io"
|
||||
"github.com/JianFeeeee/HomeAgent/internal/meta"
|
||||
"github.com/JianFeeeee/HomeAgent/internal/sdk"
|
||||
"github.com/JianFeeeee/HomeAgent/internal/security"
|
||||
sdkmeta "github.com/JianFeeeee/homeagentsdk/meta"
|
||||
)
|
||||
|
||||
@ -356,6 +357,8 @@ func init() {
|
||||
"get_plugin_tools", "llm_list_sources",
|
||||
// 文档:检索
|
||||
"doc_query",
|
||||
// 安全模块:列举变量(只读,无共享写)
|
||||
"secret_list",
|
||||
} {
|
||||
declareParallelTool(name)
|
||||
}
|
||||
@ -403,6 +406,14 @@ func (a *Agent) buildToolDefs() []interface{} {
|
||||
}, "mode"))
|
||||
}
|
||||
|
||||
// 安全模块的变量库工具(文档 C8)。
|
||||
//
|
||||
// 仅当安全模块真的启用时才暴露:未启用时变量库不可用,
|
||||
// 把工具给模型只会让它反复撞到"安全模块未启用"(无谓往返)。
|
||||
if security.Global() != nil {
|
||||
tools = append(tools, secretToolDefs()...)
|
||||
}
|
||||
|
||||
if a.memory != nil {
|
||||
tools = append(tools, toolDef("memory_merge", "【记忆清理】合并两个同义实体。将所有关系从 source 重定向到 target,然后彻底删除 source。注意:实体删除后不可恢复,合并前请确认语义一致。", map[string]interface{}{
|
||||
"source": map[string]interface{}{"type": "string", "description": "被合并的实体名(合并后消失)"},
|
||||
|
||||
222
internal/agent/core/toolresult_scrub_test.go
Normal file
222
internal/agent/core/toolresult_scrub_test.go
Normal file
@ -0,0 +1,222 @@
|
||||
package core
|
||||
|
||||
// 判据:工具结果侧路的明文拦截(文档 C6)。
|
||||
//
|
||||
// ## 为什么这条侧路必须单独拦
|
||||
//
|
||||
// 出向拦截(provider 装饰器)只改 `f.Msgs`。但工具结果另有一条路经
|
||||
//
|
||||
// turnEvt.ToolResults = f.ToolResults → a.context.Append → 持久化历史
|
||||
// ↘ emitMemoryCandidate → 长期记忆
|
||||
//
|
||||
// 到达**持久化存储**。先前实测确认这条路**不经过** provider 装饰器
|
||||
// (`TestToolResultsPathBypassesScrub` 的原始探针给出了阳性证据)。
|
||||
//
|
||||
// 后果:凭据被 LLM 看到之前虽然换成了别名,但早已从侧路进了跨会话保留、
|
||||
// 每轮被召回的长期记忆 —— 泄漏面比重放一次大得多。
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
agentAPI "github.com/JianFeeeee/HomeAgent/internal/agent/api"
|
||||
agentIO "github.com/JianFeeeee/HomeAgent/internal/agent/io"
|
||||
sdk "github.com/JianFeeeee/HomeAgent/internal/sdk"
|
||||
"github.com/JianFeeeee/HomeAgent/internal/security"
|
||||
)
|
||||
|
||||
func newScrubStore(t *testing.T) *security.Store {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
s, err := security.InitGlobal(security.Options{
|
||||
Path: dir + "/v.json",
|
||||
KeyFn: func() ([]byte, error) { return []byte("0123456789abcdef0123456789abcdef"), nil },
|
||||
Enabled: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { security.SetGlobal(nil) })
|
||||
return s
|
||||
}
|
||||
|
||||
// ★★ 判据:进 a.context 的工具结果不含明文(走 agent 的真实代码路径)。
|
||||
func TestToolResultsScrubbedBeforeHistory(t *testing.T) {
|
||||
store := newScrubStore(t)
|
||||
a, _, _ := newRootForResidents(t)
|
||||
|
||||
const tok = "ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||
|
||||
// 直接验证 scrub 逻辑本身(与 task.go 里那段同构)。
|
||||
// 用真实 Store 而不是 mock,确保别名形态/落盘都一致。
|
||||
items := []ToolResultItem{
|
||||
{Name: "cmd_run", Output: "部署用的 token 是 " + tok + " ,请使用"},
|
||||
{Name: "read_file", Output: "普通内容,不该被改"},
|
||||
}
|
||||
scrubbed := make([]ToolResultItem, len(items))
|
||||
copy(scrubbed, items)
|
||||
for i := range scrubbed {
|
||||
if out, evts := store.Replace(scrubbed[i].Output,
|
||||
"tool_result:"+scrubbed[i].Name, security.Patterns); len(evts) > 0 {
|
||||
scrubbed[i].Output = out
|
||||
}
|
||||
}
|
||||
|
||||
if strings.Contains(scrubbed[0].Output, tok) {
|
||||
t.Fatalf("工具结果仍含明文,会进持久化记忆: %q", scrubbed[0].Output)
|
||||
}
|
||||
if !strings.Contains(scrubbed[0].Output, "@{sec_") {
|
||||
t.Errorf("应含别名提示: %q", scrubbed[0].Output)
|
||||
}
|
||||
if scrubbed[1].Output != items[1].Output {
|
||||
t.Errorf("无关结果不应被改动: %q", scrubbed[1].Output)
|
||||
}
|
||||
// 原始切片不受影响(我们显式拷贝 —— 与 Msgs 的原地改写不同,见下)
|
||||
if !strings.Contains(items[0].Output, tok) {
|
||||
t.Error("不应改动调用方的原始切片(f.ToolResults 仍被其它路径读取)")
|
||||
}
|
||||
_ = a
|
||||
}
|
||||
|
||||
// ★ 判据:**工具结果路径用拷贝而非原地改写** —— 与 f.Msgs 的取舍相反。
|
||||
//
|
||||
// 为何不同:`f.ToolResults` 在同一轮里可能被其它代码读到(如
|
||||
// `f.ToolsUsed` 之外的诊断、状态面渲染),原地改会让它们看到替换后的文本;
|
||||
// 而 `f.Msgs` 本来就是「要发出去的那一份」,原地改正是我们要的。
|
||||
//
|
||||
// 这个不对称是刻意的,判据把它钉住,免得后来者「统一」成一种写法。
|
||||
func TestToolResultsScrubDoesNotMutateSource(t *testing.T) {
|
||||
store := newScrubStore(t)
|
||||
const tok = "ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||
|
||||
src := []ToolResultItem{{Name: "t", Output: tok}}
|
||||
dst := make([]ToolResultItem, len(src))
|
||||
copy(dst, src)
|
||||
for i := range dst {
|
||||
dst[i].Output, _ = store.Replace(dst[i].Output, "tool_result", security.Patterns)
|
||||
}
|
||||
if dst[0].Output == src[0].Output {
|
||||
t.Fatal("拷贝出来的应被改写(否则拦截没生效)")
|
||||
}
|
||||
if src[0].Output != tok {
|
||||
t.Fatal("原始 f.ToolResults 不应被改动 —— 它还被其它路径读取")
|
||||
}
|
||||
}
|
||||
|
||||
// ★★ 决定性判据:**走真实调度循环**,工具返回的明文不得进 a.context。
|
||||
//
|
||||
// 上面两条判据复刻了 scrub 逻辑。这条不同 —— 它跑真实的
|
||||
// `a.Start()` + 注入 + 工具回环 + finishInputTask,然后读 `a.context`
|
||||
// 看实际落进去的是什么。若我只在 task.go 里写错位置(比如放在
|
||||
// Append 之后、或忘了给 emitMemoryCandidate 用过滤后的副本),
|
||||
// 只有这条会报红。
|
||||
func TestE2E_ToolResultSecretNeverReachesContext(t *testing.T) {
|
||||
store := newScrubStore(t)
|
||||
const tok = "ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||
|
||||
a := New(AgentConfig{
|
||||
ID: "e2e-trscrub",
|
||||
Provider: &secretToolProvider{},
|
||||
ProviderManager: agentAPI.NewProviderManager(),
|
||||
IO: agentIO.NewIOManager(),
|
||||
StageHost: NewStageHost(),
|
||||
})
|
||||
|
||||
// 注册一个会吐出凭据的工具(模拟 cmd_run 读了 .env)
|
||||
if err := a.stageHost.RegisterTool("secretecho", sdk.ToolDef{
|
||||
Name: "secretecho", Description: "回显凭据",
|
||||
Parameters: map[string]interface{}{"type": "object"},
|
||||
}, func(map[string]interface{}) (interface{}, error) {
|
||||
return "credential=" + tok, nil
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
a.Start()
|
||||
defer a.Stop()
|
||||
|
||||
if out := a.io.InjectTextSync("http", "查一下凭据"); out == nil {
|
||||
t.Fatal("同步注入没有回执")
|
||||
}
|
||||
|
||||
// ★ 读真实 context:明文不得出现在任何事件里
|
||||
found := false
|
||||
for _, evt := range a.context.Recent(0) {
|
||||
for _, tr := range evt.ToolResults {
|
||||
if strings.Contains(tr.Output, tok) {
|
||||
found = true
|
||||
t.Errorf("工具结果明文进了 a.context(持久化记忆的源头): %q", tr.Output)
|
||||
}
|
||||
}
|
||||
if strings.Contains(evt.Response, tok) || strings.Contains(evt.Input, tok) {
|
||||
t.Errorf("事件正文含明文: input=%q response=%q", evt.Input, evt.Response)
|
||||
}
|
||||
}
|
||||
if found {
|
||||
t.Fatal("★ 侧路泄漏:出向拦截挡不住工具结果进记忆")
|
||||
}
|
||||
|
||||
// ★ 第二条侧路:memory_candidate。它经 EmitOutput 把 tool_results
|
||||
// 交给记忆管线(distill.go:607),**不经过 a.context**。
|
||||
// 只看 a.context 会漏掉这条(变异实验 C6-M2 证实了这个盲区)。
|
||||
memLeak := false
|
||||
drain:
|
||||
for {
|
||||
select {
|
||||
case evt := <-a.io.OutputChan():
|
||||
if evt == nil || evt.Type != "memory_candidate" {
|
||||
continue
|
||||
}
|
||||
if trs, ok := evt.Payload["tool_results"].([]ToolResultItem); ok {
|
||||
for _, tr := range trs {
|
||||
if strings.Contains(tr.Output, tok) {
|
||||
memLeak = true
|
||||
t.Errorf("记忆候选里的工具结果含明文(会写进长期记忆): %q", tr.Output)
|
||||
}
|
||||
}
|
||||
}
|
||||
default:
|
||||
break drain
|
||||
}
|
||||
}
|
||||
if memLeak {
|
||||
t.Fatal("★ 记忆侧路泄漏")
|
||||
}
|
||||
// 确认确实发生了拦截(否则可能是工具压根没被调用,判据假绿)
|
||||
if store.Count() == 0 {
|
||||
t.Fatal("变量库为空 —— 说明工具没被调用或没拦截,本判据无效")
|
||||
}
|
||||
t.Logf("✅ 工具回环后 a.context 无明文;变量库已登记 %d 条", store.Count())
|
||||
}
|
||||
|
||||
// secretToolProvider 第一轮请求调工具,第二轮给最终答复。
|
||||
type secretToolProvider struct{ round int }
|
||||
|
||||
func (p *secretToolProvider) Name() string { return "secretTool" }
|
||||
func (p *secretToolProvider) MaxContextTokens() int { return 8192 }
|
||||
|
||||
func (p *secretToolProvider) next() *agentAPI.CompletionResponse {
|
||||
p.round++
|
||||
if p.round == 1 {
|
||||
return &agentAPI.CompletionResponse{
|
||||
Content: "让我查一下",
|
||||
ToolCalls: []agentAPI.ToolCall{
|
||||
{ID: "c1", Name: "secretecho", Arguments: map[string]interface{}{}},
|
||||
},
|
||||
}
|
||||
}
|
||||
return &agentAPI.CompletionResponse{Content: "查完了"}
|
||||
}
|
||||
|
||||
func (p *secretToolProvider) Chat(_ context.Context, _ *agentAPI.CompletionRequest) (*agentAPI.CompletionResponse, error) {
|
||||
return p.next(), nil
|
||||
}
|
||||
|
||||
func (p *secretToolProvider) ChatStream(_ context.Context, _ *agentAPI.CompletionRequest) (<-chan agentAPI.StreamChunk, error) {
|
||||
ch := make(chan agentAPI.StreamChunk, 1)
|
||||
r := p.next()
|
||||
ch <- agentAPI.StreamChunk{Content: r.Content, ToolCalls: r.ToolCalls, Done: true}
|
||||
close(ch)
|
||||
return ch, nil
|
||||
}
|
||||
Reference in New Issue
Block a user