mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-11-01 14:57:38 +00:00
* 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>
909 lines
50 KiB
Markdown
909 lines
50 KiB
Markdown
# 内核安全模块(Secret Firewall)设计 · 计划追加
|
||
|
||
> **状态:已实现(持续审计中)。** 本文的安全模块、配置加密、变量库统一与 C1–C8 判据已落地;E-1/E-2/E-3/E-4 已完成;剩余项见文末实现边界。本文保留原始设计与逐条审计记录。
|
||
> 记录一条由用户纠正后确立的**架构决定**,以及它带来的简化与下沉。
|
||
>
|
||
> 撰写时间:2026-10-14。触发:用户在讨论 commondev 加密 shell 时提出两点纠正。
|
||
|
||
---
|
||
|
||
## 0. 用户的两条纠正(原话,不可转述走样)
|
||
|
||
> 「『拦截后让 agent 用变量读取』意味着被拦截的明文要能再取回。
|
||
> 并不是明文取回,而是**后续使用仅可使用变量替代**。
|
||
> 我觉得这部分需要**下沉到内核内部作为安全模块**存在了。
|
||
> (因为 llms 调用本身是不可信的)」
|
||
|
||
> 「这样插件设计就简单了,可以**直接将密钥明文投到消息内部**,
|
||
> 由**内核安全机制全权负责**」
|
||
|
||
**这两条纠正了一个错误方向**,本文先记录错在哪,再记录纠正后的设计。
|
||
|
||
---
|
||
|
||
## 1. 被纠正的错误方向(前稿的做法)
|
||
|
||
前稿(`commondev-agent-runtime-design.md` §2.7 之后那批讨论)里,我把
|
||
"敏感信息拦截"实现为 **commondev 插件内的 stage**:
|
||
|
||
```go
|
||
// 前稿:插件在 StageOnInput / StageAfterToolcall 上拦截
|
||
p.sdk.RegisterStage(sdk.StageOnInput, func(ctx *sdk.StageContext) error {
|
||
res := p.icept.Intercept(ctx.RawMessage, "input")
|
||
ctx.RawMessage = res.Text // 换成 @{sec_1} 提示
|
||
...
|
||
})
|
||
```
|
||
|
||
**错在两处**:
|
||
|
||
| # | 错误 | 为什么错 |
|
||
|---|---|---|
|
||
| 1 | **把拦截器放在插件里** | 插件在**信任边界之外**。它是独立进程,别的插件(或它自己崩溃/被替换)都能绕过。而"LLM 调用不可信"意味着**每个** LLM 调用都要过这道闸——插件的注册是**自愿的**,不是强制的 |
|
||
| 2 | **把"可再取回"当成需求** | 前稿甚至设计了 `enc_show` 之外的取回路径。但用户要的是**只能替代引用,永远拿不到明文** —— 一旦有取回路径,LLM 就能把明文写回上下文,整层作废 |
|
||
|
||
### 1.1 还暴露了两个真实缺陷 [已核 · 单测抓到]
|
||
|
||
即便只作为插件实现,前稿也有两个 bug(保留记录,因为它们说明"判据在起作用"):
|
||
|
||
1. **`*SensitiveInterceptor` nil 时 panic** —— `Intercept` 里 `si.enabled` 直接解引用。
|
||
已修(`si == nil ||`),这是真实健壮性问题:宿主可能在任何阶段调用。
|
||
2. **赋值形态丢失键名** —— `api_key = "xxx"` 被整体替成提示,人看不出发生了什么。
|
||
`splitKeyValue` 对 `assigned_secret` 的命中形态判断有误。**修法未完成**(本文第 5 节列为待办)。
|
||
|
||
> 教训:这两个缺陷都是**单测**发现的,不是读码。印证本仓纪律——判据必须跑。
|
||
|
||
---
|
||
|
||
## 2. 纠正后的架构:下沉为内核安全模块
|
||
|
||
### 2.1 出向落点:provider 层(实测推翻初稿) [已核 · 源码确认]
|
||
|
||
初稿写“唯一出网点是 `task.go:663`”。**实测扫全仓后这个结论是错的** —— 构造
|
||
`CompletionRequest` 的地方有 **7 处**(不含插件侧):
|
||
|
||
| # | 位置 | 用途 |
|
||
|---|---|---|
|
||
| 1 | `internal/agent/core/task.go:663` | 主对话轮次(最主要) |
|
||
| 2 | `internal/agent/core/spawn.go:176` | `spawn_child` 的工作式轻量子 |
|
||
| 3 | `internal/agent/core/spawn.go:321` | LLM 相关工具(`llm_*`) |
|
||
| 4 | `internal/agent/core/modalfallback.go:347` | 多模态回退调用 |
|
||
| 5 | `internal/agent/api/provider.go:1140` | `ProviderManager.QuickChat` |
|
||
| 6 | `internal/sdk/llm_impl.go:56` | 插件调 LLM(`s.LLM().Chat`) |
|
||
| 7 | `internal/plugins/*`(multimodal / healthcheck / remotedevice) | 插件自己发起的 LLM 调用 |
|
||
|
||
⇒ **在 task 层拦一道会漏掉 4–7 四大类**。其中 #6 尤其要紧:
|
||
插件经 `s.LLM().Chat` 发起的调用**完全绕过**主对话路径。
|
||
|
||
#### 正确的落点:**provider 层**
|
||
|
||
所有路径最终都会进某个 `Provider.Chat(ctx, *CompletionRequest)`。所以安全模块
|
||
应放在**provider 调用的统一层**(装饰器/包装器),而不是 task 层:
|
||
|
||
```
|
||
任何来源 ──► CompletionRequest ──► ★ 安全包装器(必经)──► 真正的 Provider
|
||
```
|
||
|
||
**待确认**:是否存在不经 `Provider.Chat` 的出网路径(如某 provider 内部直发 HTTP)。
|
||
这一条必须先验(§5.1),否则安全模块仍有洞。
|
||
|
||
### 2.2 数据模型:明文可以自由流动,只在出口被替换
|
||
|
||
用户第二条纠正的直接后果:**插件可以把密钥明文投到消息内部**。
|
||
|
||
```
|
||
插件/工具/任何来源 ──(明文,随便用)──► f.Msgs
|
||
│
|
||
┌───────────▼───────────┐
|
||
│ 内核安全模块(必经) │
|
||
│ 扫描 → 命中 → 换成别名 │
|
||
└───────────┬───────────┘
|
||
▼
|
||
真正发往 LLM 的 req
|
||
```
|
||
|
||
好处(这是简化,不是妥协):
|
||
- 插件不需要自己造加密库、别名表、stage 拦截(前稿那一整套)⇒ **插件变薄**
|
||
- 插件也不需要"安全地传输明文"的协议——**直接投,内核负责**
|
||
- 别名表归内核管理 ⇒ 全局唯一,且与 agent 实例解耦
|
||
|
||
**代价(必须写清)**:明文在**内核进程内存**里自由流动。
|
||
所以边界是"内核进程可信",不是"内存里不存在明文"。
|
||
|
||
---
|
||
|
||
### 2.3 ★ 不是“拦截”,而是“拦截 + 暂存 + 别名 + 回填” [用户 2026-10-14 纠正]
|
||
|
||
用户纠正了我“只做拦截”的单向理解,并给出**完整回路**:
|
||
|
||
> 「因为你只是简单的拦截的话,**绝大部分插件会失去原本的功能**。
|
||
> 安全模块是**拦截,加密暂存,提供调用别名,收到 toolcall 后自动将别名填充进去**。
|
||
> 因为内核 0 IO 设计,一切外部交互都是从插件走的,你只需要在 **toolcall 和
|
||
> outputch 部分有注入**就行了」
|
||
|
||
⇒ **为何只拦截不行**(这是本节的关键):
|
||
|
||
如果只在出向把明文换成 `@{sec_1}`,则插件再也**拿不到**密钥 ——
|
||
而插件恰恰是唯一能真正用密钥的地方(push / 调 API / 写文件)。
|
||
结果就是:**安全了,但活干不了了**。那等于把功能删掉,不是安全。
|
||
|
||
⇒ 必须是一个**闭环**:
|
||
|
||
```
|
||
① 拦截 LLM 出向前扫到明文 ──► 换成 @{别名}
|
||
② 加密暂存 把明文 AES-GCM **落盘**在内核(不落明文)
|
||
③ 别名 ★ 哈希键(由明文派生,见 §2.4)
|
||
④ 回填 插件调用(toolcall / outputch)前,把 @{别名} 换回明文
|
||
—— ● **插件无感知**:它只看到已经填好的明文
|
||
```
|
||
|
||
### 2.4 ★ 变量索引:**哈希键** —— 于是自动获得“同变量合并” [用户 2026-10-14 纠正]
|
||
|
||
用户纠正了两件事(我初稿用的是 `sec_1`、`sec_2` 顺序号,**那是错的**):
|
||
|
||
> 「落盘暂存,各个变量通过**哈希键索引**,这样自然获得了**同变量合并能力**。
|
||
> 同时变量**不自动淘汰**,但是 agent 可主动调用安全模块提供的工具**提交/删除**」
|
||
|
||
#### 为何哈希键是对的(顺序号错在哪)
|
||
|
||
| | 顺序号 `sec_<n>`(错) | **哈希键**(对) |
|
||
|---|---|---|
|
||
| 同一 secret 出现两次 | 得到 `sec_1` / `sec_2` **两个别名** | 得到**同一个**别名 ⇒ ★ **自动合并** |
|
||
| 合并需要额外逻辑吗 | 需要(要扫描所有别名比对明文) | **不需要**(哈希本身就是键) |
|
||
| 时序依赖 | 有(序号与拦截顺序绑定) | 无(键由内容唯一确定) |
|
||
| 重启后一致吗 | 依赖持久化的计数器 | 重算即可(同一明文 ⇒ 同一键) |
|
||
|
||
> ⇒ **“同变量合并”不是额外功能,而是哈希索引的天然性质** —— 这正是用户要的。
|
||
> 这也消除了一个隐患:同一把密钥在对话里出现 5 次,不应产生 5 个别名,
|
||
> 否则日志/记忆里会积起一堆指向同一密钥的不同名字。
|
||
|
||
#### 键的构造
|
||
|
||
```
|
||
alias = "sec_" + hex( HMAC-SHA256(masterKey, plaintext)[:8] )
|
||
```
|
||
|
||
**两个细节不能省**:
|
||
|
||
1. **必须用 HMAC(带 masterKey),不能裸 SHA256**。
|
||
裸哈希对短/低熵输入可被字典枚举 —— 而别名会出现在**上下文、日志、记忆**里,
|
||
等于把“这个 secret 是什么”泄露出去。
|
||
|
||
**实测演示**(本机跑过):取明文 `hunter2`,裸 SHA256 取前 8 字节作别名,
|
||
然后拿 5 个常见弱口令去试:
|
||
|
||
```
|
||
别名(裸哈希): f52fbd32b2b3b86f
|
||
★ 字典命中:'hunter2' → 别名直接暴露了 secret 内容
|
||
```
|
||
|
||
换成 HMAC(masterKey) 后,攻击者**没有 masterKey 就无法枚举**。
|
||
2. **8 字节(16 hex)** 足够:碰撞概率对本场景可忽略,而短别名省上下文。
|
||
实测别名形如 `sec_3718c87a11e9478b`(**20 字符**)。
|
||
|
||
> 为什么不加随机 salt:加了就**失去合并能力**(同一明文每次得到不同键)。
|
||
> 所以这里“确定性”是**特性**,不是缺陷;安全靠 HMAC 的密钥而不是 salt。
|
||
>
|
||
> **实测验证**(本机跑过):
|
||
>
|
||
> | 断言 | 结果 |
|
||
> |---|---|
|
||
> | 同明文两次 ⇒ 同一别名(自动合并) | ✅ 两次都是 `sec_3718c87a11e9478b` |
|
||
> | 不同明文 ⇒ 不同别名 | ✅ |
|
||
> | 别名长度(要进上下文) | ✅ 20 字符 |
|
||
> | 换 masterKey ⇒ 别名变(键依赖密钥) | ✅ |
|
||
|
||
#### 生命周期:不自动淘汰,但可**主动**提交/删除
|
||
|
||
| 行为 | 谁决定 | 说明 |
|
||
|---|---|---|
|
||
| **自动淘汰** | ❌ **不做** | 用户明确:变量不自动淘汰 |
|
||
| **主动提交** | agent 调安全模块的工具 | 把某些明文显式登记为变量(即使尚未出现过) |
|
||
| **主动删除** | agent 调安全模块的工具 | 明文用完即弃(如轮换后)
|
||
| **持久化** | 内核 | **落盘暂存**(加密),重启不丢 |
|
||
|
||
**为何“不自动淘汰”是合理的**:
|
||
|
||
- 任务可能跨很多轮、跨多个子 agent(§2.6 全局共享),早先登记的变量随时要能用;
|
||
- 自动淘汰需要定策略(按时间?按引用计数?)——而策略错了会导致“变量静默失效”、
|
||
任务在很后面才坏掉,且现象难归因;
|
||
- 反过来,**泄漏风险由“主动删除”控制**,而不是靠超时时间。
|
||
|
||
⚠ **待定(T9)**:不自动淘汰 ⇒ 落盘文件会只增不减(除非人主动删)。
|
||
需要定一个**可观测**的规模上限降级策略(如超 N 条时告警而非静默删)。
|
||
|
||
### 2.5 ★ 两个注入点就够了(内核 0 IO) [已核 · 源码确认]
|
||
|
||
用户指出:“内核 0 IO 设计,一切外部交互都是从插件走的”。
|
||
⇒ 于是**只需在两根咽喉上做替换**:
|
||
|
||
| 方向 | 咽喉 | 位置 | 作用 |
|
||
|---|---|---|---|
|
||
| **入向**(→插件) | `StageHost.ExecuteTool(name, args)` | `internal/agent/core/stages.go:140` | toolcall 参数里的 `@{别名}` → 明文 |
|
||
| **入向**(→插件) | `OutputChannel` 的 device `Execute` | `internal/agent/core/output.go:76` | 输出载荷里的 `@{别名}` → 明文 |
|
||
| **出向**(→LLM) | provider 层的 `Chat(req)` | `internal/agent/api/`(装饰器) | `f.Msgs` 里的明文 → `@{别名}` |
|
||
|
||
#### 入向覆盖确认(调插件工具的入口)
|
||
|
||
| 路径 | 位置 | 覆盖 |
|
||
|---|---|---|
|
||
| agent 发的 tool_call(主路径) | `toolcall.go:133` → `stages.go:140` | ✅ |
|
||
| 插件经 ToolAPI 调另一个插件 | `sdk/tool_impl.go:47` | ✅ |
|
||
| 设备/通道工具 | `io/channel.go:714/730` | ✅(走同一 `Execute`) |
|
||
| outputch 出向到插件 | `output.go:76` → `dev.Execute` | ✅ |
|
||
|
||
> ⇒ **只需两处**(`StageHost.ExecuteTool` + `IOManager` 的设备 `Execute`)
|
||
> 就覆盖全部插件交互 —— 因为**内核自己不碰外部世界**(零 IO)。
|
||
> 这是“零 IO”设计的一个**意外红利**:安全边界只有两个点。
|
||
|
||
### 2.6 为何“插件无感知”很重要(不是洁癖)
|
||
|
||
| 理由 | 说明 |
|
||
|---|---|
|
||
| **插件无法绕过** | 插件根本不知道有替换这回事 ⇒ 无法写“我自己解密” 的代码去绕开 |
|
||
| **存量插件无需改** | 所有已有插件(qq / browser / …)自动获得该能力 |
|
||
| **接口不变** | 不需要给 SDK 加任何参数 ⇒ 不破兼容(本仓“只增不减”) |
|
||
| **职责单一** | 安全归内核;插件只管业务 |
|
||
|
||
#### ⚠ 与“插件明文中转”的关系(用户拍板的简化)
|
||
|
||
用户已明确:“插件可以直接将密钥明文投到消息内部,由内核安全机制全权负责”。
|
||
|
||
⇒ 两者不矛盾,而是**分工**:
|
||
|
||
| 环节 | 谁负责 |
|
||
|---|---|
|
||
| 插件要向 LLM 交代“我用的是哪条密钥” | **直接写明文**(内核出向会把它变成 @{别名}) |
|
||
| 插件要实际**用**密钥(如 push) | 写 `@{别名}`,内核**入向**换成明文后再交给插件 |
|
||
| 别名表 / 加密存储 / 扦描 | **全部在内核**(插件不参与、不知情) |
|
||
|
||
**待确认**:入向替换也会换掉 `cmd_run` / `terminal_write` 的**参数**(它们是工具调用)。
|
||
这带来一个新问题 —— **agent 可以把 `@{别名}` 写进任意命令参数**,等于获得了
|
||
“在任意命令里使用明文”的能力。这正是用户要的(否则没法用密钥),
|
||
但它把“能做什么”的边界交回给了 **命令本身**(即 enc_shell 的安全边界问题)。详见 §5 T8。
|
||
|
||
### 2.7 ★ 变量系统是**全局通用**的(父子 agent 同一套) [用户 2026-10-14 追加]
|
||
|
||
用户明确:
|
||
|
||
> 「注意安全模块的变量系统是**全局通用**的,驻留式子 agent、子 agent、
|
||
> 主 agent 是**同一套变量系统**」
|
||
|
||
#### 为何必须全局(不能 per-agent) [已核 · 源码确认]
|
||
|
||
驻留子共享父的对象(`resident.go:184-185`):
|
||
|
||
```go
|
||
Provider: a.provider,
|
||
ProviderManager: a.providerManager, // ★ 共享同一个 ProviderManager
|
||
StageHost: a.stageHost,
|
||
```
|
||
|
||
而 `ProviderManager` **全进程只建一份**(`cmd/homed/bootstrap.go:249` 建一次;
|
||
`resident.go:185` 把它传给每个子)。⇒ 安全模块(作为 provider 装饰器)
|
||
**天然就是全进程单例**。
|
||
|
||
#### 为何“全局”是正确设计(而不只是实现便利)
|
||
|
||
| 理由 | 说明 |
|
||
|---|---|
|
||
| **否则无法跨 agent 协作** | 父拦截得到 `@{sec_1}`,要能交给子去干活;子的 `@{sec_1}` 若不同则断链 |
|
||
| **别名不泄露就能共享** | `sec_<n>` 本身不含明文,共享它不降低安全性 |
|
||
| **与 L4 归属正交** | 驻留子的 `KernelSource`/权限是 per-agent(隔离);变量系统是全局(共享)。**两件事,不要混** |
|
||
|
||
#### ⚠ 一个必须想清的后果:权限边界不因共享而放松
|
||
|
||
变量全局 ⇒ **任何 agent 都能写 `@{sec_1}`**。但回填后**能不能真的用**,取决于该
|
||
agent 的工具/通道授权:
|
||
|
||
| 层 | 作用域 | 管什么 |
|
||
|---|---|---|
|
||
| **变量系统** | ★ **全局** | 别名 ↔ 明文(不区分 agent) |
|
||
| `AllowedOutputs` / 工具授权 | **per-agent** | 该 agent 能不能调某个工具/通道 |
|
||
| `KernelSource`(L4) | **per-agent** | 该 agent 的上级是谁 |
|
||
|
||
⇒ 子 agent 拿到了明文,但**能否拿它去 push** 仍受它自己的授权限。
|
||
两者是**正交**的两道门(变量共享 ≠ 授权共享)。
|
||
|
||
#### 实现形态
|
||
|
||
⇒ 安全模块不能挂在 `*Agent` 上(那是 per-agent),而应:
|
||
|
||
```
|
||
internal/security/ 里的一个**进程级单例**(或挂在 ProviderManager 上)
|
||
├─ 别名表 alias → secret(全局)
|
||
├─ 出向扫描(provider 装饰器,天然单例)
|
||
└─ 入向回填(StageHost.ExecuteTool / 设备 Execute;两者也是共享对象)
|
||
```
|
||
|
||
> **恰好舒服的地方**:两个注入点(`StageHost` / `IOManager`)与出网点
|
||
> (`ProviderManager`)**都已经是共享对象** ⇒ 安全模块挂上去就是全局的,
|
||
> **不需要额外做“跨 agent 同步”**。
|
||
|
||
### 2.8 与"两阶段拦截"的关系
|
||
|
||
前稿用 `StageOnInput` + `StageAfterToolcall`。那两个点**仍然有意义,但不是安全边界**:
|
||
|
||
| 位置 | 性质 | 说明 |
|
||
|---|---|---|
|
||
| `StageOnInput` / `StageAfterToolcall` | **提示性、可绕过** | 提前替换能让日志/记忆干净一些,但**不构成安全** |
|
||
| **两根咽喉**(入向 `ExecuteTool`/`Execute`,出向 `Chat`) | ★ **强制的安全边界** | 无论前面漏了什么,这里都是最后一道且必经 |
|
||
|
||
⇒ 结论:**安全责任放在咽喉上**;前面的 stage 拦截降级为“卫生措施”(可选、可关)。
|
||
|
||
## 3. 硬约束(安全模块的不可让步项)
|
||
|
||
| # | 约束 | 理由 |
|
||
|---|---|---|
|
||
| **S1** | **不提供"取回明文"的任何接口** | 用户明确:只能**替代引用**。一旦有取回路径,LLM 就能把明文写回上下文,整层作废 |
|
||
| **S2** | 替换必须落在**必经点**上 | 插件是自愿注册的 ⇒ 插件侧拦不算边界。
|
||
已核两个必经点:出向 `Provider.Chat`、入向 `StageHost.ExecuteTool` + 设备 `Execute` |
|
||
| **S3** | 别名**全局唯一且不泄漏明文** | ★ **哈希键**:`sec_ + HMAC-SHA256(masterKey, plain)[:8]`。
|
||
**必须用 HMAC 而非裸哈希**(裸哈希可被字典枚举;而别名会出现在上下文/日志/记忆里);
|
||
**不加 salt**(加了就失去合并能力,确定性是特性) |
|
||
| **S4** | **不可误伤** | 误报会把正常内容换掉、搞坏任务。模式库保守优先——**宁可漏,不可误伤** |
|
||
| **S5** | **不得静默** | 每次拦截要可观测(别名、来源位置、长度、时间),否则出问题无从追查 |
|
||
| **S11** | ★ **同变量自动合并** | 哈希键的天然性质:同一明文多次出现 ⇒ **同一个别名**(不是多别名)。
|
||
不能退化成顺序号 —— 那会在日志/记忆里积起一堆指向同一密钥的名字 |
|
||
| **S12** | ★ **不自动淘汰**,但可**主动**提交/删除 | 用户明确:不自动淘汰(跨轮/跨子 agent 随时要用);
|
||
泄漏风险靠**主动删除**控制,不靠超时。自动淘汰错策略会导致“变量静默失效” |
|
||
| **S6** | 关闭开关是**显式特权** | 能关掉它的人必须知道自己关掉的是什么(与本仓 `offload_enabled` 同一条纪律) |
|
||
| **S7** | 替换后**仍要能干活** | 被替换掉的明文必须**可用别名引用**(否则任务直接坏掉)——这是 S1 与可用性的平衡点 |
|
||
| **S8** | ★ **回填必须成对**(只拦不填 = 删除功能) | 用户明确:只拦截会让“绝大部分插件失去原本功能” |
|
||
| **S9** | ★ **插件无感知** | 替换发生在路由到插件的**之前**;插件代码不改、不可分辨、无法绕过 |
|
||
| **S10** | ★ **变量系统全局唯一** | 主/子/驻留子 agent **同一套**别名表。理由:`ProviderManager` 全进程只建一份(`bootstrap.go:249`),且共享才能跨 agent 协作。且**与权限正交** —— 变量共享 ≠ 授权共享 |
|
||
|
||
> **S1 与 S7 不矛盾**:别名可**被引用**(用于命令填充),但**不可被读取**。
|
||
> 读接口与用接口是两件事 —— 这正是前稿没分清的地方。
|
||
|
||
---
|
||
|
||
## 4. 需要改哪些地方(下沉清单)
|
||
|
||
**总原则(用户 2026-10-14 拍板)**:安全模块 = **拦截 + 加密暂存 + 别名 + 回填**,
|
||
**插件完全无感知**。因为**内核零 IO**(一切外部交互都从插件走),
|
||
所以只需在**两根咽喉**上做替换即可覆盖全部。
|
||
|
||
| # | 改动 | 位置 | 性质 |
|
||
|---|---|---|---|
|
||
| **C1** | **出向拦截**(→LLM):包装 `Provider.Chat`,在 `CompletionRequest` 出网前扫到明文就换成 `@{别名}` + 加密暂存 | `internal/agent/api/`(装饰器) | 新增(核心) |
|
||
| **C2** | ★ **入向回填**(→插件,toolcall):`StageHost.ExecuteTool` 入口把 `args` 里的 `@{别名}` 换回明文 | `internal/agent/core/stages.go:140` | 新增(核心) |
|
||
| **C3** | ★ **入向回填**(→插件,outputch):`IOManager` 的设备 `Execute` 前把载荷里的 `@{别名}` 换回明文 | `internal/agent/io/channel.go` / `output.go:76` | 新增(核心) |
|
||
| **C4** | **变量库**:★ **哈希键索引 + 落盘加密暫存**(`internal/security/`) | 新建 `internal/security/` | 新增 |
|
||
| **C8** | ★ **供 agent 调用的提交/删除工具**(主动登记、主动删除) | 内核内置工具(`builtinToolOwner` 注册) | ✅ **已做**(`secret_submit/list/forget`)|
|
||
| **C5** | **LLM 返回也要扫**:LLM 可能把明文编造/输出出来 ⇒ 返回内容同样过一遍 | ✅ **已做**:拦在 `a.context.Append` 咽喉处(非调用点) |
|
||
| **C6** | **记忆写入前扫一次**:防明文被写进长期记忆 | ✅ **已做**:`Append`/`InsertByTimestamp` + `emitMemoryCandidate` |
|
||
| **C7** | 插件的加密 shell / enc_* 工具族**大幅删减**:不再自带加密库与别名表 | commondev 插件 | ✅ **已做**(比原计划**更保守**,见 §5.4)|
|
||
|
||
> **C2 + C3 是“插件无感知”的全部机制保证**:
|
||
> 插件代码**一行不改**,也无法分辨自己拿到的是明文还是被替换过的。
|
||
> 无需给 SDK 加任何参数 ⇒ **不破接口兼容**(本仓“只增不减”)。
|
||
>
|
||
> **C1 是对用户“两个终端工具需联动”那条要求的直接回答**:
|
||
> 不必改 `terminal_*` / `cmd_run` 这两个内置插件 —— 它们的**输出**最终进 `f.Msgs`
|
||
> (被 C1 截),它们的**参数**走 `ExecuteTool`(被 C2 填),双向都覆盖。
|
||
|
||
---
|
||
|
||
## 5. 待办与未决
|
||
|
||
| # | 事项 | 说明 |
|
||
|---|---|---|
|
||
| **T1** | ~~修 `splitKeyValue` 对 `assigned_secret` 的键名丢失~~ | ✅ **已修**(插件侧;迁内核时可一并搬)。根因:键名后常带空格(`api_key = "..."`),旧判据把“含空格”当成“不像键名”就放弃拆分 ⇒ 整段被当 secret。已配变异验证 |
|
||
| **T9** | **不自动淘汰的规模上限** | 落盘文件只增不减(除非人主动删)。需定一个**可观测**的降级策略(如超 N 条时**告警**,而不是静默删某条) |
|
||
| **T10** | **提交工具的泄露面** | “主动提交”要把明文交给内核 —— 与 `enc_store` 同问题(明文会进会话/日志)。
|
||
应优先支持 `${ENV}` / `@file:` 两条不明文入会话的路径 |
|
||
| **T2** | 把前稿的 `intercept.go` / `encstore.go` 从插件**迁到内核** | 模式库可复用,别名/存储需重写为内核形态。★ **迁后本插件的 `enc_*` 工具族与加密 shell 应大幅删减**(用户:插件对安全机制无感知) |
|
||
| **T3** | **密钥从哪来**(内核侧) | 内核是长驻进程;密钥须由部署方提供(systemd 环境变量)还是内核自持密钥文件?未定 |
|
||
| **T4** | 变量**持久化与生命周期** | ✅ **已定**:落盘加密持久化(重启不丢)、**不自动淘汰**、可主动删(§2.4)。剩余:落盘位置与 masterKey 来源(同 T3) |
|
||
| **T5** | 压缩(compaction)对别名的影响 | 摘要可能把 `@{sec_1}` 当成普通文本;压缩后别名是否仍有效? |
|
||
| **T6** | **明文在内存中的擦除** | Go 字符串不可变 ⇒ 只能擦可写副本。要定"努力擦除"还是"不承诺" |
|
||
| **T7** | 替代方案评估 | 内核是否可能拿不到 secret(如 SSH 密钥文件)⇒ 也许该支持"引用外部文件、内核只持路径" |
|
||
| **T8** | **入向回填的安全边界** | 回填后 agent 可把 `@{别名}` 写进**任意**命令参数 ⇒ 等于获得“在任意命令里用明文”的能力。这**正是必需的**(否则没法用密钥),但“能做什么”的边界因此回到**命令本身**。要定:要不要限制哪些工具允许触发回填? |
|
||
|
||
### 5.1 ★ 配置落盘加密 [已完成 · 真机验证]
|
||
|
||
**用户需求(2026-10-14)**:通用配置在数据库里全是明文;配置管理器应提供
|
||
加密选项,并为插件暴露加密存储能力(密钥由内核统一管理)。
|
||
|
||
#### 要解决的问题
|
||
|
||
`config.db` 是 **0644 全局可读**的(同目录还有 WAL/SHM),而里面装着
|
||
LLM API 密钥、WebUI 口令、插件凭据 —— 任何本机用户一条 sqlite3 查询
|
||
就能拿到全部明文。
|
||
|
||
#### 做法:在**存储边界**加密,读写接口不变
|
||
|
||
```
|
||
Set/Register/RegisterDef/seed → Seal → DB 里是 enc:v1:<base64>
|
||
Get/Dump/ToConfig/GetString → Open → 调用方永远只看到明文
|
||
```
|
||
|
||
★ **关键取舍:读接口保持「返回明文」**。
|
||
|
||
初稿曾想给 `Dump()` 返回掩码,但那会同时打断 `ToConfig()`
|
||
(它靠 Dump 重建 LLMSource,含 api_key)与所有内部读取方 —— 而它们
|
||
**本来就在可信边界内**。本需求防的是「静态文件泄漏」,不是「内部读取」。
|
||
已配判据 `TestToConfigStillGetsRealAPIKey` 钉死这一点。
|
||
|
||
#### 敏感判定(两种声明 + 键名兜底)
|
||
|
||
| 途径 | 说明 |
|
||
|---|---|
|
||
| `ConfigDef.Secret == true` | ★ **SDK 里早就有这个字段,但从未实现** |
|
||
| `ConfigDef.Type == "password"` | 旧写法,兼容保留 |
|
||
| 键名模式 | 无 def 的插件键靠它兜底 |
|
||
|
||
**键名匹配必须按「词段」而不是子串**:初版用 `strings.Contains(key,"token")`,
|
||
把 `core.llm.max_tokens`(输出上限)与 `core.agent.context.max_target_tokens`
|
||
(上下文预算)也加密了。真机跑出来才发现。现在改为切词后精确匹配,
|
||
`max_tokens` 的末词段是 `tokens`(复数)⇒ 不命中。
|
||
|
||
#### 密钥:域隔离派生
|
||
|
||
```
|
||
subKey = HMAC-SHA256(masterKey, "homeagent/config-at-rest/v1")
|
||
```
|
||
|
||
同一把主密钥服务多类用途(变量别名 HMAC、配置 AES、未来审计签名…)。
|
||
共用一把会在域之间产生攻击面(一处的输出变成另一处的合法输入),
|
||
所以每类用途派生独立子密钥。判据 `TestDomainSeparation` 钉住它。
|
||
|
||
密钥来源与安全模块一致:`HOMED_SECURITY_KEY` > `<dataDir>/security.key`(0600)。
|
||
|
||
#### 为插件暴露的加密存储能力
|
||
|
||
SDK 新增两个方法(`SettingsAPI`):
|
||
|
||
| 方法 | 语义 |
|
||
|---|---|
|
||
| `SetSecret(key, value)` | **强制加密**,不看键名(插件可能用 `blob`/`conn` 这类中性键名) |
|
||
| `GetSecret(key)` | 读回(透明解密) |
|
||
|
||
★ **无密钥时 `SetSecret` 拒绝写入**,不静默存明文 —— 插件以为存了密文
|
||
而实际磁盘上是明文,是最危险的静默降级。
|
||
|
||
#### 落点清单
|
||
|
||
| # | 改动 | 位置 |
|
||
|---|---|---|
|
||
| **E1** | 密钥域派生 + 编解码器 | `internal/security/codec.go` |
|
||
| **E2** | 敏感判定 + 迁移 + 加密写入 | `internal/config/secret.go` |
|
||
| **E3** | 读路径透明解密(Get/Dump/GetString/listPrefix/PluginSettings) | `internal/config/registry.go` |
|
||
| **E4** | ★ `Secret` 标志透传(**三处转换点都漏了它**) | `internal/sdk/settings_impl.go` |
|
||
| **E5** | 显式加密 API(`SetSecret`/`GetSecret`) | registry + sdk + **SDK 接口** |
|
||
| **E6** | ★ proc 桥:协议常量 + 3 处注册(路由/分发/能力表)+ 模板实现 | `internal/plugin/proc/`、`templates/proc_main.go.tmpl` |
|
||
| **E7** | WebUI:密码框 + 显示/隐藏按钮 | `internal/plugins/webui/dashboard.js` |
|
||
| **E8** | 启动装配 + 存量迁移 | `cmd/homed/main.go` |
|
||
|
||
#### ☆ 踩到的两个真坑
|
||
|
||
1. **`Secret` 字段从未生效的根因**:SDK 一直有 `Secret bool`,但内核侧
|
||
**三处**转换点(`RegisterDef`/`Defs`/`mapDefs`)全都漏了它。
|
||
插件声明了意图,前端拿到的 meta 里却没有 —— 于是敏感项被渲染成
|
||
普通文本框明文回显。已配判据 `TestSecretFlagReachesSettingsMeta`。
|
||
|
||
2. **新增 proc method 要改 4 个地方**:协议常量、外层路由、内层分发、
|
||
能力表。我只改了分发,真机 e2e 立刻报 `未知 method: settings.setSecret`
|
||
—— 这正是「真机端到端不可省」的实例。
|
||
|
||
3. **守卫判据本身有盲区**:`TestCapability_AllMethodsClassified` 号称
|
||
「本文件最重要的测试」,但它用手工维护的方法清单 —— 新方法不加进清单
|
||
就无人管(变异实验 V3 证实:删掉能力表条目,判据仍绿)。
|
||
已改为**从 protocol.go 源码正则提取**全部 method 常量,覆盖面自动跟上。
|
||
|
||
#### 验证
|
||
|
||
- `internal/config/secret_test.go` 17 条 + `internal/security/codec` 判据
|
||
- `internal/plugin/proc/e2e_secret_test.go` —— **真实 hmapdev 模板编译的插件**
|
||
跑完整链路:插件读回明文(27B)、磁盘存密文(83B)
|
||
- `internal/plugins/webui/secret_meta_test.go` 2 条(声明→meta、UI 明文/磁盘密文)
|
||
- 变异 6 条全部咬住(含 V3 暴露并修复的守卫盲区)
|
||
- 真机:3 条存量迁移、明文残留 0、`max_tokens` 未被误加密
|
||
- 全量回归 `./internal/... ./cmd/... ./pkg/...` 零失败
|
||
|
||
---
|
||
|
||
### 5.2 前提验证(安全模块本体) [已完成 · 全部通过]
|
||
|
||
**结论:安全模块可以稳在 provider 层。** 两条前提均已实测:
|
||
|
||
#### P1:所有出网均经 `Provider.Chat` / `ChatStream` [已核]
|
||
|
||
| 事实 | 依据 |
|
||
|---|---|
|
||
| `Provider` 是干净的两方法接口 | `internal/agent/api/provider.go:307`(`Chat` + `ChatStream` + `MaxContextTokens`) |
|
||
| 所有调用点都用接口方法 | `process.go:138/155/191`、`spawn.go:184/321`、`modalfallback.go:347`、`provider.go:1140` |
|
||
| 插件经 `s.LLM().Chat` 也回同一层 | `internal/sdk/llm_impl.go:48` → `l.mgr.Default()` |
|
||
| 唯一一处内部直发 HTTP(`provider.go:446`) | 在 `LuaAdaptedProvider` **实现内部**,不是绕过 |
|
||
| `lua/vm.go` 的 `CallTransformRequest` | 是适配器层的载荷改写(在 provider 内部调用),不是绕过 |
|
||
|
||
#### P2:`ProviderManager.Register` 是**唯一入口** [已核 · CodeGraph 交叉验证]
|
||
|
||
装饰 `Register` 即覆盖全部取用路径(`Get` / `Default` / `OrderedProviders` / `ResolveForModel` 都从同一 map 取)。
|
||
|
||
`NewLuaAdaptedProvider` 的全部调用者(CodeGraph 查得 **6 个**):
|
||
|
||
| 调用者 | 位置 | 是否经 `Register` |
|
||
|---|---|---|
|
||
| `initLLMProviders` | `cmd/homed/bootstrap.go:248` | ✅ 下一行就是 `providerMgr.Register` |
|
||
| `runFailbackRecovery` | `cmd/homed/failback.go:28` | ✅ `Register("rescue", …)` |
|
||
| `ReloadFromConfig` | `internal/sdk/llm_impl.go:115` | ✅ `l.mgr.Register` |
|
||
| 其余 3 个 | 测试文件 | — |
|
||
|
||
⇒ **无任何真实路径绕过 `Register`。**
|
||
|
||
#### 已落地的验证判据
|
||
|
||
`internal/agent/api/security_premise_test.go`(4 条,均配变异验证):
|
||
|
||
| 判据 | 验证什么 |
|
||
|---|---|
|
||
| `TestPremise_RegisterDecorateCoversAllAccessPaths` | 4 条取用路径都拿到装饰后实例 |
|
||
| `TestPremise_DecoratorSeesEveryChat` | `Chat` / `ChatStream` 都经装饰器 |
|
||
| `TestPremise_DecoratorCanRewriteMessages` | 装饰器**能改写**消息(不只是读) |
|
||
| `TestPremise_StreamAlsoDecorated` | 流式也被覆盖 |
|
||
|
||
变异实验(两个都咬住):不包裹 ⇒ “装饰器应看到 1 次 Chat,实际 0”;
|
||
只读不改 ⇒ “明文被原样透传,改写未生效”。
|
||
|
||
> **方法论教训**:这一步最初我用 `grep` 逐个查,漏了 `sdk/llm.go` 等入口,
|
||
> 也没发现 `lua/vm.go` 的变换链路。改用 **CodeGraph**(`codegraph_callers` / `codegraph_node`)
|
||
> 后,它一次列出 `Chat` 的 **9 个调用者**与 `NewLuaAdaptedProvider` 的 **6 个调用者**,
|
||
> 遗漏面直接归零。**查接口关系用 codegraph,不要用 grep 翻**。
|
||
|
||
---
|
||
|
||
### 5.3 ★ 持久化侧路的明文拦截(C5/C6) [已完成 · 真机验证]
|
||
|
||
### 发现的真实漏洞:出向拦截挡不住持久化
|
||
|
||
`§4` 的 C5/C6 原本是「计划项」。实测把它们变成了**必须做** —— 因为发现了
|
||
一条完整的泄漏侧路:
|
||
|
||
出向拦截(provider 装饰器)只改 `f.Msgs` —— **要发出去的那一份**。
|
||
但内核把同样内容**另存副本**送往持久化,它们不经过 provider:
|
||
|
||
| 持久化字段 | 来源副本 | 是否被 provider 拦住 |
|
||
|---|---|---|
|
||
| `ContextEvent.Input` | `f.CleanInput`(用户输入原文) | ❌ |
|
||
| `ContextEvent.Response` | `f.Response` | ❌ |
|
||
| `ContextEvent.ToolResults` | `f.ToolResults` | ❌ |
|
||
| `memory_candidate.payload` | 同上(走 `EmitOutput`,**不经 context**) | ❌ |
|
||
|
||
后果:明文被 LLM 看到之前虽已换成别名,但**完整地写进了 `context.json`
|
||
与长期记忆** —— 而记忆跨会话保留、每轮被召回。泄漏面比重放一次大得多。
|
||
|
||
### 修法:拦在**咽喉**,不拦在调用点
|
||
|
||
写 context 的地方有 **5 处**(`task.go` ×3、`toolcall.go` ×1,加插入变体),
|
||
`memory_candidate` 另走一条。逐个补必漏 —— 事实上第一版正是只补了
|
||
`turnEvt`,而漏掉了 `f.CleanInput`(判据实测确认泄漏仍在)。
|
||
|
||
最终落点:
|
||
|
||
| 咽喉 | 覆盖 | 文件 |
|
||
|---|---|---|
|
||
| `RelevanceContext.Append` | 全部追加写入 | `context.go` |
|
||
| `RelevanceContext.InsertByTimestamp` | 按时间插入(另一入口) | `context.go` |
|
||
| `Agent.emitMemoryCandidate` | 记忆候选(不经 context) | `distill.go` |
|
||
| 共用实现 | `scrubEventSecrets` / `scrubMemoryCandidate` | `memory_scrub.go` |
|
||
|
||
清除字段:`Input` / `Response` / `ToolResults[].Output` /
|
||
`Blocks[].Text`(**仅文本块**;图像/视频/音频的 `Data` 是二进制负载,
|
||
正则扫它既无意义又会损坏数据)。
|
||
|
||
### ☆ 一处必须记录的脆弱性
|
||
|
||
实测发现:`Append` 的 scrub 会**连带改掉** `f.ToolResults` —— 因为
|
||
`turnEvt.ToolResults = f.ToolResults` 是值切片,共享底层数组。
|
||
于是「去掉 `scrubMemoryCandidate`」的变异**不会被端到端判据发现**。
|
||
|
||
这是脆弱设计(隐式依赖别名)。所以:
|
||
|
||
1. `scrubMemoryCandidate` **显式保留**,不指望上游顺手改了;
|
||
2. 增加判据 `TestEmitMemoryCandidateScrubsPayload`,走**真实入口**
|
||
`a.emitMemoryCandidate` 而不是直接调底层函数 —— 初版直接调底层,
|
||
于是「去掉入口处的调用」这个变异**不报红**(判据测错了层次)。
|
||
|
||
> 通用教训:**判据必须走真实入口**。测底层函数只能证明「函数对」,
|
||
> 证明不了「入口调了它」。本次变异实验连续暴露了三次同类错误
|
||
> (M2 判据漏测 `Alias()`、C6-M2 漏测 `memory_candidate`、M-B 测错层次)。
|
||
|
||
### 与 `f.Msgs` 原地改写的**刻意不对称**
|
||
|
||
| 路径 | 语义 | 为何 |
|
||
|---|---|---|
|
||
| `f.Msgs` | **原地改写** | 它就是要发出去的那份;改掉后明文永久离开上下文 |
|
||
| `f.ToolResults` | **拷贝后改写** | 同一轮里还被其它代码读(诊断、状态面),不能就地改 |
|
||
|
||
这个不对称是刻意的,判据 `TestOutboundScrubIsInPlaceByDesign` 与
|
||
`TestToolResultsScrubDoesNotMutateSource` 分别钉住,免得后来者「统一」成一种写法。
|
||
|
||
### 验证
|
||
|
||
- `internal/agent/core/persist_leak_test.go` 4 条(输入侧 / 回复侧 / 记忆块 / 记忆候选入口)
|
||
- `internal/agent/core/toolresult_scrub_test.go` 3 条(含**真实调度循环**的 e2e)
|
||
- `internal/agent/core/inplace_scrub_test.go` 1 条(原地改写语义)
|
||
- 变异 4 条(含 2 次修好的判据盲区)
|
||
- 全量回归零失败
|
||
|
||
---
|
||
|
||
### 5.4 ★ C7:插件加密层的删减(比原计划更保守) [已完成]
|
||
|
||
#### 先验证前提,再动代码
|
||
|
||
C7 原计划是「插件不再自带加密库与别名表」。但「删掉插件那套,内核能顶上吗」
|
||
是个**必须先验的断言** —— 不成立的话就是削功能而非去冗余。验了三条:
|
||
|
||
| 判据 | 结论 |
|
||
|---|---|
|
||
| `TestKernelFillCoversBuiltinToolsToo` | 内核回填覆盖**所有**工具名(含内置 `cmd_run`) ⇒ 插件的**填充**功能冗余 |
|
||
| `TestKernelFillHappensBeforePluginHandler` | 内核在 handler **之前**回填 ⇒ 插件自己的别名表对内核别名是**死代码** |
|
||
| `TestKernelAndPluginAliasSyntaxDoNotCollide` | 内核只吃 `[A-Za-z0-9_]`,插件的 `@{gitea-local}`(含连字符)不受影响 |
|
||
|
||
#### 删了什么(691 行)
|
||
|
||
| 删除 | 理由 |
|
||
|---|---|
|
||
| `intercept.go` / `intercept_stage.go` / `intercept_test.go`(**691 行**) | 插件版拦截**覆盖面严格小于**内核版:它只能看 `RawMessage`/`ToolResults`,内核还能覆盖 `Input`/`Response`/`Blocks`/`memory_candidate` |
|
||
| `enc_intercepts` 工具 + `Ledger.Interceptions` | 拦截记录由内核拥有(内核日志 + `secret_list` 的命中计数) |
|
||
| `intercept_enabled` 配置项 | 全局开关是 `core.security.enabled`(在 WebUI 设置页)。插件留一个同名开关会让人以为它能控制内核行为 —— 那是误导 |
|
||
|
||
★ **两套并存的真实害处**:内核先回填,插件再扫一遍时明文已是别名 ⇒ 纯空转,
|
||
而且可能把别名**二次改写**。
|
||
|
||
#### ☆ 但有三样**不能删**(原计划漏判的)
|
||
|
||
| 保留 | 为什么内核顶不上 |
|
||
|---|---|
|
||
| `enc_shell` 的 **argv 保护**(脚本走 stdin) | 内核 `cmd_run` 是 `sh -c "<整条命令>"` ⇒ 回填后的明文**会进 argv**,本机任何进程 `/proc/<pid>/cmdline` 都能读到。实测确认 |
|
||
| `enc_shell` 的 **输出脱敏** | 防命令意外回显凭据(如 `git remote -v` 带出 token) |
|
||
| `enc_store` 的 **`${ENV}` / `@file:` 来源** | 这两条路径**明文从不进会话** —— 正是文档 T10 要求的更安全形态,而内核 `secret_submit` 目前只接受直传明文 |
|
||
|
||
⇒ **C7 的最终形态是「互补」而非「替代」**:
|
||
插件保留**执行侧**保护(argv / 脱敏)与**安全来源**(`${ENV}`/`@file:`),
|
||
内核负责**拦截 / 存储 / 回填 / 记忆侧路**。
|
||
|
||
#### 给内核的后续项(进 §6)
|
||
|
||
`secret_submit` 应支持 `${ENV_NAME}` 与 `@file:/path` 两种来源 ——
|
||
把「明文不进会话」的能力从插件上移到内核(T10)。
|
||
|
||
---
|
||
|
||
### 5.5 ★ 变量库统一(插件不再自建加密存储) [已完成]
|
||
|
||
**用户指出的问题(2026-10-14)**:
|
||
|
||
> 不对,你写了两遍变量,为什么不直接让安全模块暴露相关接口让插件使用?
|
||
|
||
**确认属实。** 插件自带了一整套与内核**完全重复**的加密存储:
|
||
|
||
| | 内核 `internal/security.Store` | 插件 `unifiedSecretFile` |
|
||
|---|---|---|
|
||
| 落盘 | `secrets.json` | `secrets.json` |
|
||
| 加密 | AES-256-GCM | AES-256-GCM |
|
||
| 别名 | HMAC 派生(自动合并)| 用户指定 |
|
||
| 引用 | `@{sec_xxx}` | `@{name}` |
|
||
|
||
★ 重复的真正代价不是代码量,而是**两套库互不可见**:
|
||
内核拦截时登记的变量插件看不到、插件登记的拦截又不认识,
|
||
同一凭据可能在两处各存一份而使用者不知哪个权威。
|
||
|
||
#### 做法:SDK 新增 `SecretsAPI`
|
||
|
||
```
|
||
Store(value, note) → alias 值交给内核,返回别名
|
||
List() 元信息(**不含明文**)
|
||
Forget(alias) 删除
|
||
Fill(text) → out, n, aliases 把 @{别名} 换成明文
|
||
Encrypt/Decrypt 给插件自己的配置文件用(密钥由内核管)
|
||
Available() 变量库是否可用
|
||
```
|
||
|
||
**去重后的分工**:
|
||
|
||
| 谁 | 存什么 |
|
||
|---|---|
|
||
| 内核 | **值**(加密)、派生别名、调用插件前回填 |
|
||
| 插件 | 「可读名 → 内核别名」**映射**(不含值)、来源解析、argv 保护 |
|
||
|
||
★ **为什么插件还需要一层映射**:内核别名是 HMAC 派生的
|
||
(`sec_0cb468e2…`),这是「同明文自动合并」的前提(S11),**不能改成用户自选**。
|
||
但人需要 `@{gitea-local}` 这种能读懂的名字 ⇒ 插件存一层名字映射。
|
||
**映射里只有名字,没有值** —— 它泄了也没用。
|
||
|
||
#### ☆ 安全性论证:为什么暴露 `Fill` 不破 S1
|
||
|
||
S1 说「不提供取回明文的接口」,理由是一旦 LLM 能取回,它就能把明文写回上下文。
|
||
那插件为什么可以拿明文?**因为边界不在插件侧**:
|
||
|
||
1. 内核在**插件 handler 之前**就把工具参数里的 `@{别名}` 填好了
|
||
(`StageHost.ExecuteTool` → `security.FillMap`)—— 插件**本来就能**
|
||
从 args 读到明文,这是设计使然。
|
||
2. 明文若被插件**返回**,会被两处拦回别名(`ContextEvent` scrub + provider 装饰器)。
|
||
|
||
两条都有判据钉住(`TestPremise_PluginReturnedPlaintext*`:插件故意回吐明文,
|
||
验证 context 侧与 provider 侧都拦得住)。所以:
|
||
|
||
> **暴露 `Fill` 不是"开了一个口子",而是把本来就存在的能力
|
||
> 从各插件自己实现改为统一接口。**
|
||
|
||
⚠️ 但**绝不能**暴露 `Reveal(alias) -> 明文` 这样的按名字直取接口 ——
|
||
那会让插件(以及操纵它的 LLM)枚举别名并逐个取值。本接口刻意没有这样的方法。
|
||
|
||
#### ☆☆ 实测踩到的坑
|
||
|
||
**`Available()` 不能用 `List` 推断。** 初版让模板的 `procSecrets.Available()`
|
||
调 `secrets.list`,看 RPC 是否成功。但**未启用时 List 返回空列表(不报错)**,
|
||
于是"库是空的"与"库不存在"在 RPC 层长得一样 ⇒ `Available()` 误判为 true,
|
||
接着 `Store` 才失败。错误信息于是变成"存储失败"而非"能力不可用",
|
||
**排查方向完全错**。修法:加 `secrets.available` 专门回答这个问题。
|
||
有判据 + 变异钉住。
|
||
|
||
**`go:embed` 第三次咬人。** `hmapdev` 用 `//go:embed` 把模板编进二进制。
|
||
我改了 `templates/proc_main.go.tmpl` 源码,但跑的是**旧二进制**,
|
||
于是生成出的 shim 缺方法、报 `missing method GetSecret` —— 而报错指向
|
||
生成文件,看不出真因是"模板没重新编译进二进制"。
|
||
修法:`go build ./tools/hmapdev/` 重建并安装。
|
||
|
||
**两处校验器字符集不一致。** 引用正则 `credRefPattern` 允许 `.`,
|
||
而别名校验器 `validName` 不允许 ⇒ 「能被引用却存不进去」。
|
||
修法:secret 别名用自己的校验器(`validSecretName`),
|
||
**不混用**项目/通道名的校验器 —— 两者是两个命名空间。
|
||
|
||
#### 验证
|
||
|
||
`internal/plugin/proc/e2e_secrets_test.go` 3 条(**真实模板**编译的插件跑全链路);
|
||
`internal/agent/core/leakback_test.go` 2 条(插件回吐明文被拦);
|
||
`internal/security/alias_shape_test.go` 1 条(别名是派生值、不能自选);
|
||
插件 `encstore_test.go` 重写为 7 条(含「值只存一处」不变量)。
|
||
三层零失败、`-race` 通过、`hmapdev build` 成功。
|
||
|
||
---
|
||
|
||
### 5.6 ★ 拦截范围盘点(「到底拦什么」) [实测 · 已固化判据]
|
||
|
||
判据:`internal/security/survey_test.go`(会拦 / 刻意不拦,两个清单)。
|
||
|
||
#### 会拦(→ 换成 `@{sec_xxx}`)
|
||
|
||
| 类别 | 形态 |
|
||
|---|---|
|
||
| GitHub token | `ghp_`/`gho_`/`ghu_`/`ghs_`/`ghr_` + 36 位 |
|
||
| GitHub fine-grained | `github_pat_` + 60 位 |
|
||
| GitLab | `glpat-` + 20 位 |
|
||
| OpenAI | `sk-`(含 `sk-proj-`)+ 20 位 |
|
||
| Anthropic | `sk-ant-` + 20 位 |
|
||
| AWS 访问密钥 | `AKIA`/`ASIA` + 16 位大写 |
|
||
| Slack | `xox[baprs]-` |
|
||
| Bearer 头 | `Bearer ` + 30 位以上 |
|
||
| **私钥块(7 类)** | `-----BEGIN PRIVATE KEY-----`(PKCS#8)|`RSA PRIVATE KEY`|`DSA`/`EC PRIVATE KEY`|**`OPENSSH PRIVATE KEY`**(ssh-keygen 现代默认)|`ENCRYPTED PRIVATE KEY`|**`PGP PRIVATE KEY BLOCK`**|**`SSH2 ENCRYPTED PRIVATE KEY`** |
|
||
| 赋值语句 | 键名含 `api_key`/`access_token`/`auth_token`/`secret_key`/`client_secret`/`password`/`passwd`,**分隔符 `=` 或 `:` 都可**,值 16 位以上 |
|
||
|
||
#### 刻意**不拦**(每条都有理由 —— 边界比能力更重要)
|
||
|
||
| 不拦 | 原因 |
|
||
|---|---|
|
||
| **40 位 hex(Gitea token 形态)** | 与 git commit SHA **形态完全相同** ⇒ 拦它会误伤每个 commit id(S4) |
|
||
| 裸 32 位十六进制 | 与 UUID / 会话 id / 哈希撞车,太泛 |
|
||
| JWT | 形态宽泛,误伤风险高于收益(带 `Bearer ` 前缀时那条会拦) |
|
||
| 密码短语 / 中文口令 | 无固定字符集,无法与正常文本区分 |
|
||
| PIN / 短口令 | 太短,任何数字都会撞 |
|
||
| 手机号 / 身份证 | PII 不是本模块目标,且误伤面极大 |
|
||
| **URL 里的密码**(`https://user:pass@host`) | ⚠️ **已知缺口**,需专门规则,当前未覆盖 |
|
||
| SSH 私钥**文件路径**(`.ssh/id_rsa`) | 拦路径不拦内容 —— 文件内容被读出时会命中私钥块规则 |
|
||
| **SSH 公钥**(`ssh-ed25519 AAAA…`、`ssh-rsa AAAA…`) | 公钥**不是秘密**(可公开)。拦它会让正常运维操作坏掉 |
|
||
| `authorized_keys` / `~/.ssh/config` / `known_hosts` | 不含密钥本体(config 只有路径;known_hosts 与正常 hostname 无区别) |
|
||
| `SSH_AUTH_SOCK` | 只是 ssh-agent 的 socket 路径,不含密钥 |
|
||
|
||
#### ☆ SSH / 密钥专项:实测补上两个漏报
|
||
|
||
用**本机真实密钥**(`~/.ssh/id_rsa`、`id_ed25519`)+ 各类 PEM 头验证时发现,
|
||
初版正则漏了 **两类真实存在的私钥格式**:
|
||
|
||
| 漏掉的 | 为什么 |
|
||
|---|---|
|
||
| `-----BEGIN PGP PRIVATE KEY BLOCK-----` | 尾部是 `KEY BLOCK`,不是 `PRIVATE KEY` |
|
||
| `-----BEGIN SSH2 ENCRYPTED PRIVATE KEY-----` | 头部是 `SSH2 ENCRYPTED PRIVATE KEY` |
|
||
|
||
**根因**:初版正则在 END 侧**硬写**了 `PRIVATE KEY`:
|
||
```
|
||
-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]{64,}?-----END [A-Z ]*PRIVATE KEY-----
|
||
^^^^^^^^^^^ 写死了
|
||
```
|
||
而真实密钥的尾部词因格式而异。修法:两侧都用 `[A-Z0-9 ]*PRIVATE KEY[A-Z ]*`。
|
||
|
||
**长度下界 64 的用意**(防误伤):真实私钥体远超 64 字节,而
|
||
「文档里只引用头尾两行做说明」的情形中间没有内容 ⇒ 不会被拦。
|
||
有判据双向钉住(长体必拦 / 短引用不拦)。
|
||
|
||
#### ☆ 盘点本身抓到一个真实漏报
|
||
|
||
写这份盘点时发现 `password: "hunter2hunter2hunter2"`(**冒号**形态)**没被拦**。
|
||
|
||
**根因是我加 prefilter 时引入的**:`assigned_secret` 的正则分隔符有
|
||
两个备选(`:` 与 `=`),而 prefilter 只写了 `"="` ⇒ 冒号形态被
|
||
**静默挡住**。
|
||
|
||
更值得记的是**为什么既有判据没发现**:样本恰好都用了 `=`,
|
||
测试全绿 —— 判据覆盖了"能用的形态",漏了"另一种同样合法的形态"。
|
||
|
||
修法:
|
||
1. `Prefilter` 支持**多候选**(`"a|b"`,任一命中即放行)
|
||
2. 新增 `TestPrefilterCoversAllAlternatives`:**逐形态**验证
|
||
「正则支持的每种写法,prefilter 都必须放行」
|
||
|
||
> 教训:**盘点"什么没被拦"比盘点"什么被拦了"更能发现问题**。
|
||
> 正向清单告诉你能力边界,负向清单才暴露洞。
|
||
|
||
---
|
||
|
||
## 6. 待办与未决(续)
|
||
|
||
> 上面 §5 的 T1–T10 是**安全模块本体**的遗留项;
|
||
> 本节记录**配置加密**(§5.2)落地后新暴露的项。
|
||
|
||
| # | 事项 | 说明 |
|
||
|---|---|---|
|
||
| **E-1** | 密钥轮换 | ✅ **已完成**(2026-10-15):`security.keyring`(0600)持久化「当前钥 + 历史只读钥」;Store 支持 `RotateKey`(**已有别名不变**、密文重加密落盘、旧钥留在环里);Codec 支持 `InitConfigCodecWithOldKeys` + `Rekey`/`NeedsRekey`;config 侧 `RekeySecrets` 收口。7 条判据 + 4 条变异 |
|
||
| **E-2** | 压缩/备份工具的兼容 | ✅ **已完成**(2026-10-15):`deploy-plan.sh backup` 打印密钥位置与「密钥须与数据分开存放」提示;内核启动时 `AuditSecrets` 审计密文可读性,发现不可解即告警并给出修复动作(3 条判据 + 2 条变异) |
|
||
| **E-3** | WebUI 的「密钥可见范围」 | ✅ **已完成**(2026-10-15):设置列表默认以占位符屏蔽敏感值;新增单键 `/api/v1/settings/reveal?key=`,核心/当前 WebUI 插件可显式揭示,其他插件一律 403;揭示动作留痕。2 条判据 + 2 条变异 |
|
||
| **E-4** | `HOMED_SECURITY_KEY` 生产方式 | ✅ **已完成**(2026-10-15):优先用 systemd `LoadCredential`(tmpfs、0400、**不进 environ**),降级 env,再降级磁盘文件。unit 已配 `Type=exec`。4 条判据 + 2 条变异,真实 systemd 实测 |
|
||
|
||
---
|
||
|
||
## 7. 与既有文档的关系
|
||
|
||
| 文档 | 关系 |
|
||
|---|---|
|
||
| `commondev-agent-runtime-design.md` | 前稿的插件侧拦截(`§2.7` 之后)**降级为卫生措施**;安全责任移到本文 |
|
||
| `resident-subagent-design.md` | 驻留子的 `KernelSource` L4 模型是**另一个**安全机制(权限隔离),与本文正交 |
|
||
| `kernel-modularization-design.md` | **★ 本文 §3 的装配点约束是该文档 §2.3「Phase 0」的实例**。安全模块必须在任何读 `core.llm.*` 的操作之前装配——实测踩过:装配晚了只会打一行 warning,然后 key 变空一路走到 401(症状指向"key 配错",实际是顺序问题)。该文档把这类约束从"注释"变成"机器校验" |
|
||
| 本仓 `internal/plugin/proc/corehandler.go:279` 的 L4 夹取 | 同属"内核不信插件自报"的**同一设计哲学**,可参考其实现风格 |
|
||
|
||
---
|
||
|
||
## 8. 实测教训:安全模块的装配顺序(2026-10-16)
|
||
|
||
> 这一条加到本文,因为它**直接暴露了"顺序只写在注释里"的危险**。
|
||
|
||
**故障**:安全模块装配晚于 `cfgReg.SetLLMSnapshotFile()`/`cfgReg.ToConfig()`,
|
||
而这两者启动即读 `core.llm.*`(需主密钥解密)。
|
||
|
||
**后果链**:
|
||
|
||
```
|
||
密钥未装配 → 解密失败(只打一行 warning)→ key 变空串
|
||
→ initLLMProviders 拿到空 key → 所有 LLM 调用 401 invalid api key
|
||
```
|
||
|
||
**为何难查**:401 看起来像"key 配错了"。而实际上:
|
||
- key 值在库里是**对的**
|
||
- 直连上游 llmsproxy **HTTP 200**
|
||
- 只有内核 agent 401
|
||
|
||
⇒ 查 key 值、改 key、重配 provider **全部无效**,因为问题是**启动顺序**。
|
||
|
||
**修法**:把整个安全块前移到*任何*读 `core.llm.*` 的操作之前,并加**结构性判据**
|
||
(`cmd/homed/bootstrap_order_test.go`,断言真实的相对行号)。
|
||
|
||
**★ 一个中途犯的错(值得记住)**:修复第一版只把安全块移到 `ToConfig()` 之前,
|
||
**漏了 `SetLLMSnapshotFile()` 也在它之前读密文** ⇒ 判据变绿而线上照旧报错。
|
||
教训:顺序类判据**必须覆盖所有读取点**,不能只盯一个。
|
||
|
||
完整设计见 `kernel-modularization-design.md` §1.2 与 §2.3。
|
||
|
||
---
|
||
|
||
## 8. 给接手者的三条提醒
|
||
|
||
1. **安全模块的位置决定它是不是安全**。初稿以为"唯一出网点"在 task 层,
|
||
实测扫出 **7 处**构造请求 —— 放在 task 层会漏 4 处。
|
||
教训:**落点要靠扫仓实测确定,不能靠读一处代码推断**。
|
||
2. **"能引用"与"能读取"必须分开**。前者是可用性所需,后者是泄露。
|
||
把它们混在一起,就是前稿最大的设计错误。
|
||
3. **模式库宁漏勿误伤**。误报会搞坏正常任务,而任务坏了人会来查——那时代价远大于漏报一次。
|