Files
HomeAgent/internal/security/round.go
JianFeeeee 53cdc7f2a6 refactor(test): 肃清全部白盒测试 —— 源码目录零 _test.go,判据只走模块导出接口
## 用户裁定(本次唯一方向)

1. 「测试不应该面向过程而是面向模块的输入和输出」
2. 「每个模块下应该使用 test 目录搜集所有测试文件,而不是和源码混在一起」
3. 「现在 test 都超过源码数量了,这很明显不符合模块接口黑盒化的要求」
4. 「ast判包依赖?这不是工具链该做的吗?为什么在测试里」
5. 「抽行为判据?只需要面向模块输入接受模块输出就行」
6. 「不管ci,直接把所有白盒测试全清理了再回去一鼓作气改ci」

## 实测起点

    537 个测试文件 / 377 个源码文件 / 2853 条 Test
    523 / 537(97%)直接调用私有函数
    514 个是 in-package 白盒(package xxx,与源码同目录)
     88 个读 .go/.md 源码做断言
     94 个断言私有字段(f.Step / r.buf / a.sched …)
     38 个直调私有函数(stepLLM / runStage / classifyProviderError …)
    internal/agent/core: 140 test / 52 src

## 做了什么

### ① 删除 514 个 in-package 白盒测试

不做「抽行为判据」,也不做逐文件搬运。理由:Go 按目录分包,
`pkg/test/` 里写 `package pkg` 也看不到 unexported 符号 —— 所以
「挪进 test/」和「改成黑盒」是同一件事的两面。把 514 个白盒文件
搬走只会得到一堆编译不过的文件,或者为了迁就它们把内部全导出。

用户明确否定「抽」这个动作后,剩下 9 个 test/ 里的判据也一并
重新审视:凡是靠读源码/前端资源文本做断言的(dashboardJSSource /
FrontendAsset / GraphDataForVisual / SSEEventRing …)全部删除,
只留走模块**本来就有**的公开 API 的输入→输出判据:

    internal/agent/core/child/test/     Registry 的 Add/Get/Stop/Remove 输出
    internal/agent/core/test/           StageHost 的 Register/Run 输出
    internal/plugins/files/test/        工具 handler 的 {path} → {content/error}
    internal/plugins/webui/test/        HTTP 请求 → 响应(NewHandler/RegisterRoutes)
    internal/tfidf/test/                文本 → 向量/检索结果

现存 9 个测试文件全部在 `<module>/test/`,源码目录零 `_test.go`。

### ② 回退「为测试而导出」的接口

这些是前几轮为了把白盒判据挪出源码目录而导出的,属于白盒残留,
现在没有调用方了,全部收回私有:

    webui.FrontendAsset / DashboardPage        删除
    webui.GraphDataForVisual → graphDataForVisual
    webui.SSEEventRing/Record → sseEventRing/sseEventRecord(含字段)
    webui.NewSSEEventRing                       删除
    kstate.MountForTest                         删除
    security.ClassifyPrincipalForTest           删除

生产代码对它们的内部调用同步改回私有名,不改任何行为。

### ③ CI 一鼓作气改到位(用户指定顺序)

**删**:`整机启动图判据` 单列步骤(它跑的 bootgraph_test.go 已删),
以及 `check-ci-gates.py` 里盯着那个步骤的断言 —— 继续盯着一个
不存在的步骤就是永远红的空转。

**加两道构建期静态门**(原先这些约束错放在 go test 里):

- `测试归拢(源码目录不得有 _test.go)`
  `find internal cmd pkg providers deploy -name '*_test.go' -not -path '*/test/*'`
  非空即红。物理上挡住白盒回潮。
- `包依赖门(kstate 为叶子 / 底层包不反向依赖装配器)`
  用 `go list -deps` 判 8 组禁止依赖 + `go build core` 确认环不存在。
  这是**工具链**的职责,不该由 `go test` 兼职。

`check-ci-gates.py` 同步改为盯这两道门;ci.yml 里 13 处既有
>80 字符行一并折行(门禁标了 STOP,不留尾巴)。

### ④ 顺带(上一批未提交的)

`scripts/make-package.sh` 产物名 `HomeAgent-linux-*` →
`HologramAgent-linux-*`;清理 gitignored 构建产物
`build/ dist/ .go/ .npm-cache/` 共 20G(工作区 21G → 1.7G)。
运行契约(~/.homeagent/cli.sock、线上域名、兼容别名)未动。

## 验证

    go build ./...                     PASS
    go vet ./...                       PASS
    gofmt -l                           干净
    python3 .github/scripts/check-ci-gates.py   PASS
    go test ./... -count=1             5 个测试包全过 / 100 个包无测试 / 0 FAIL
    源码目录 _test.go 数              0
    测试文件数                         9(全部在 <module>/test/)

## 后续

按模块导出接口清单重写判据(不是从旧白盒里抽):
internal/agent/api、internal/memory、internal/security、internal/plugin、
internal/knowledge、cmd/* 等目前 0 判据,需要逐个列接口 → 写输入→输出判据
→ 变异验证。这是新一轮工作,本提交不含。
2026-11-09 11:13:56 +08:00

357 lines
14 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package security
// 轮次上下文(RoundContext):**security 对外的输入接入点**。
//
// ## 为什么需要这一层(2026-10-31 收敛)
//
// 收敛前的形态:security 只暴露细粒度判定原语(`CheckTool` / `CheckToolFor` /
// `ActivateProfile`),于是**每个消费方都得自己理解策略语义**:
//
// agent/core/security_profile.go 里曾这样写:
//
// name, _ := payload["security_profile"].(string) // 自己抠字段
// a.security.ActivateProfile(name, 0) // 再告诉 security
// if a.parentID != "" { // 自己判断走哪条链
// return a.security.CheckToolFor(Principal{Kind: "resident"}, tool)
// }
// return a.security.CheckTool(tool)
//
// 三个问题全出在这段:
//
// ① `security_profile` 是 security 自己的协议概念,解析却在外人手里
// ② “我是父还是子”是策略语义,判断却在 agent 手里
// ③ `Kind: "resident"` 是调用方**声明自己的类别** ⇒ 说自己是内部就享受
// 内部豁免,那是提权口子
//
// ## 本文件确立的形态
//
// 调用方只提供**事实**(谁在跑、有没有父、原始载荷)
// security 自己做**结论**(类别、档位、走哪条判定链)
//
// 对应 EventBus 那个已经跑通的范例:总线只交事件,guard 自己判。
//
// events.Bus.Publish → Guard.AllowPublish(Publisher, EventType) → 判定在 guard 内
//
// ## 生命周期
//
// rc, err := rt.BeginRound(RoundInput{...}) // 输入到达
// defer rc.End() // 轮次结束(归位,防跨轮泄漏)
// rc.CheckTool(name) // 本轮的判定(只含本轮的档位/主体)
//
// `End` 必须调:档位在 `Runtime` 上是**进程级状态**,不归位就会泄漏到下一轮
// (guest 的收窄误伤后续输入;方向相反就是提权)。
import (
"fmt"
"log"
"strings"
)
// RoundInput 是一次「输入到达」时调用方能提供的**全部事实**。
//
// ★ 这里刻意**不含 Kind**:类别由 security 自己从 AgentID/IsChild 推。
// 调用方给不出“我是内部主体”这种结论 —— 它只能说自己是谁。
type RoundInput struct {
// AgentID 是执行本次输入的执行体标识(主 agent = "main",子 = 子的 id)。
AgentID string
// IsChild 表示本次输入由**某个父 agent 派出的子执行体**处理。
//
// 这是调用方能提供的事实(驻留子/rtchild 自己知道有父),
// 而“子属于哪一类主体、该不该豁免 HardPrivate”由 security 决定。
IsChild bool
// Payload 是**原始输入载荷**(`InputEvent.Payload`)。
//
// ★ security 自己从中解析 `security_profile` —— 那是 security 的协议,
// 不该让每个消费方各写一遍解析。字段名定义见 `profileKey`。
Payload map[string]interface{}
// Source 是来源通道名(诊断与审计用,不参与判定)。
Source string
}
// profileKey 是输入载荷里携带命名档位的键名。
//
// 与公开 SDK 的 `InjectOptions.SecurityProfile`(JSON 名 `security_profile`)
// **逐字一致** —— 那是插件与 security 之间的协议,解析权属于 security。
const profileKey = "security_profile"
// Round 是一轮的策略上下文句柄。
//
// 它把「本轮生效的档位」与「本轮的主体」绑在一起,使判定成为**纯函数式**的
// 一次调用,调用方不再需要知道任何组合规则。
type Round struct {
rt *Runtime
// scope 是**该 agent 独有的**策略执行视图(2026-10-31 起的正路)。
//
// 为 nil 表示走旧的进程级单例路径(Round.End 会恢复 Runtime.active)。
scope *AgentScope
// principal 是 security **自己判定**出来的主体(不是调用方声明的)。
principal Principal
// profile 是本轮生效的档位名(空 = 未使用档位)。
profile string
// restored 仅在 scope == nil 时使用:记录进程级 active 的进入值。
//
// ★ 为什么旧的进程级路径要"恢复"而不是"清空":那时多个 agent
// 共享一个 active,清空会把别处正在用的档抹掉。per-agent 视图
// 没有这个问题(每个 agent 自己一份),所以走 scope 时直接清 session。
restored string
ended bool
}
// CheckToolForAgent 按**执行体身份**判定工具是否可用(无轮次句柄时的入口)。
//
// ## 什么时候用它
//
// 工具执行链(`executeToolCallInner`)只持有执行体身份(id + 有无父),
// 不持有本轮句柄。此时由 security **自己**从事实推出主体类别,
// 而不是让调用方声明 `Kind` —— 后者是提权口子。
//
// 与 `BeginRound` + `Round.CheckTool` 的区别:
//
// BeginRound 路线 —— 带档位(本轮激活的 profile 参与判定)
// 本函数 —— 不带档位处理,只看全局策略 + 主体策略
//
// ★ 为什么不用把档位也算进来:档位是**输入**携带的(`security_profile`),
// 它属于某一轮的事实。工具执行链上没有轮次上下文,硬塞一个“进程当前档”
// 才是不对的(那正是收敛前 `ActivateProfile` 全局单例的毛病)。
// 真正的修法是让执行链拿到本轮句柄 —— 见 future-plan。
func (r *Runtime) CheckToolForAgent(agentID string, isChild bool, tool string) Decision {
if r == nil {
return allowed()
}
return r.CheckToolFor(r.classifyPrincipal(RoundInput{
AgentID: agentID,
IsChild: isChild,
}), tool)
}
// BeginRound 在**一条输入开始处理时**调用,由 security 完成:
//
// ① 判定主体类别(据 AgentID + IsChild,不信调用方声明)
// ② 从原始 Payload 解析 `security_profile`
// ③ 激活档位(未声明/拼错时**响亮降级**,不静默沿用上一轮的档)
//
// 返回的 `Round` 必须 `End()`(defer 即可)。
//
// ★ 为什么档位解析在这里而不是在调用方:
// 档位名是 `security` 的协议(`Profile.Name` 约定 `<owner>.<档位>`)。
// 把它交给调用方解析,等于让 N 个调用方各写一遍,且每写一遍都可能漏。
func (r *Runtime) BeginRound(in RoundInput) (*Round, error) {
if r == nil {
return &Round{}, nil // 未装配 security:零成本句柄
}
// ★ 正路:per-agent 视图(每个 agent 一份 session 档,互不干扰)。
//
// 2026-10-31 用户裁定「每个 agent 一个,分为 sessionProfile 和
// longtimeProfile」后,这里的档位状态落在 `AgentScope` 上,
// 不再写进程级 `Runtime.active`。
sc := r.ForAgent(in.AgentID, in.IsChild)
return sc.BeginRound(in)
}
// roundProfileName 从原始载荷里取档位名。
//
// 独立成小函数是为了让“解析规则只有一处”这件事在代码上可见:
// 任何调用方都不该再写 `payload["security_profile"]`。
func roundProfileName(payload map[string]interface{}) string {
if payload == nil {
return ""
}
s, _ := payload[profileKey].(string)
return s
}
// classifyPrincipal 由 security **自己**判定主体类别。
//
// ## 判定规则(唯一事实来源)
//
// IsChild ⇒ KindSubAgent (内核派出的子执行体)
// AgentID == "" ⇒ KindInternal (内核自身发起的自循环任务)
// 其他 ⇒ KindUser (经插件通道进来的终端用户)
//
// ★ 为什么"子"优先于"内核自身":
// 子执行体也是内部委派(共享 HardPrivate 豁免),但它**可以被父收窄**。
// 若先判成 KindInternal,父在创建时声明的 Allow/Deny 就落不到它头上 ——
// 那正是收敛前 `Kind: "resident"` 掩盖的问题(它同时意味着
// “我是内部”和“我不受收窄”)。
func (r *Runtime) classifyPrincipal(in RoundInput) Principal {
p := Principal{ID: in.AgentID}
switch {
case in.IsChild:
p.Kind = KindSubAgent
case strings.TrimSpace(in.AgentID) == "":
p.Kind = KindInternal
default:
p.Kind = KindUser
}
return p
}
// Principal 返回本轮的主体(security 判定出的,只读)。
func (rc *Round) Principal() Principal {
if rc == nil {
return Principal{}
}
return rc.principal
}
// EffectiveProfile 返回本轮**实际生效**的档位名。
//
// per-agent 视图下它是 `session > longtime > 空` 的结果;
// 旧路径下等于 Round.Profile()。
func (rc *Round) EffectiveProfile() string {
if rc == nil {
return ""
}
if rc.scope != nil {
return rc.scope.EffectiveProfile()
}
return rc.profile
}
// Profile 返回本轮生效的档位名(空 = 未使用档位)。
func (rc *Round) Profile() string {
if rc == nil {
return ""
}
return rc.profile
}
// CheckTool 判定本轮的某个工具是否可用。
//
// 这就是工具执行链**唯一**需要的调用:调用方不再需要知道
// “我是父还是子、该走 CheckTool 还是 CheckToolFor”。
func (rc *Round) CheckTool(name string) Decision {
if rc == nil || rc.rt == nil {
return allowed()
}
// per-agent 视图:用**本 agent 的**生效档位(session > longtime)。
if rc.scope != nil {
return rc.rt.checkToolWithProfile(rc.principal, rc.scope.EffectiveProfile(), name)
}
// 旧路径:进程级档位。
return rc.rt.checkToolWithProfile(rc.principal, rc.rt.ActiveProfile(), name)
}
// End 结束本轮:恢复进入前的档位。
//
// 幂等:重复调用无副作用(defer + 显式调用并存是安全的)。
func (rc *Round) End() {
if rc == nil || rc.ended {
return
}
rc.ended = true
// per-agent 视图:只清**自己的** session 档,longtime 档保留
//(它是该 agent 的生命状态,不是本轮事实)。
if rc.scope != nil {
rc.scope.clearSession()
return
}
// 旧的进程级路径:恢复进入前的值(那时多个 agent 共享一个 active)。
if rc.rt == nil || rc.rt.active == nil {
return
}
if rc.restored != "" {
rc.rt.active.Activate(rc.restored, scopeTTLDefault)
return
}
rc.rt.active.Clear()
}
// DeclareChildPolicy 让**父**为刚创建的子执行体声明主体策略。
//
// ## 为什么这个入口也在 security 里(而不是 agent 组装结构体)
//
// 收敛前 `agent/core` 自己拼 `PrincipalPolicy{AllowTools: …, DenyTools: …}`
// 字面量 —— 那是策略结构,属于 security 的领域。调用方只该说
// “这个子允许这些、禁止那些”,由 security 负责把语义落到正确的作用域。
//
// 语义约束(security 保证):声明只能**收窄**。`CheckToolFor` 内部先跑
// `CheckTool`(全局 deny 优先),所以父的黑名单对子依然有效,子无法提权。
func (r *Runtime) DeclareChildPolicy(childID string, allow, deny []string) error {
if r == nil {
return nil // 未装配 security:不为子声明,保持无限制(与之前一致)
}
childID = strings.TrimSpace(childID)
if childID == "" {
return fmt.Errorf("security: declare child policy requires a non-empty child id")
}
if len(allow) == 0 && len(deny) == 0 {
return nil // 父选择不给额外限制
}
return r.DeclarePrincipalPolicy("id", childID, PrincipalPolicy{
AllowTools: allow,
DenyTools: deny,
})
}
// RevokeChildPolicy 在子执行体销毁时撤销父为它声明的策略。
//
// ★ 必须撤销:主体 ID 是调用方给的名字,子回收后**同名重建**是常规操作。
// 旧策略留着 ⇒ 新一代用上一代父留的约束运行(且那个父可能已不存在),
// 主体表还会随 create/destroy 单调增长。
// 这与 rtchild 留档串线(`rtClosedTranscripts` 以模型给的名字为键)同一形态。
func (r *Runtime) RevokeChildPolicy(childID string) {
if r == nil || strings.TrimSpace(childID) == "" {
return
}
_ = r.RevokePrincipalPolicy("id", strings.TrimSpace(childID))
}
// classifyScopePrincipal 按 per-agent 视图的事实判定主体类别。
//
// 与 classifyPrincipal 同一套规则(**唯一事实来源**),
// 只是入参来自 AgentScope 而不是 RoundInput。
func (r *Runtime) classifyScopePrincipal(agentID string, isChild bool) Principal {
return r.classifyPrincipal(RoundInput{AgentID: agentID, IsChild: isChild})
}
// CheckContent 审查本轮的一段文本,返回命中(**纯标记,不阻断**)。
//
// ## 与 ScanContent 的分工(2026-11-02 接入)
//
// ScanContent(ins, text) —— 无轮次上下文时的入口(读进程级档位)
// Round.CheckContent(ins, text) —— 正路:用**本轮档位**(session > longtime)
//
// 与 CheckTool 完全对称:调用方不再需要问“本轮档位是什么”。
//
// ★ 为什么不能让调用方自己取 EffectiveProfile() 再传给 ScanContentFor:
//
// 那是把「本轮该用哪个档位」这条策略语义又交回调用方手里 ——
// 正是 §1.1 收敛掉的形态(agent 曾经自己抠 security_profile 字段)。
// 档位是 security 的协议,解析与使用都必须留在 security 内部。
//
// ## 语义:标记,不阻断(用户裁定 2026-11-02)
//
// 敏感词是**插件按自己通道声明**的(Profile.SensitiveWords),不是部署方的
// 全局红线;词表难免偏宽("内部代号"在正常对话里也可能出现),
// 直接阻断会误杀正常消息。所以返回 findings 供调用方自行处置
// (主 agent 的处置是"正文前追加警示语",见 ReviewInputContent)。
//
// 第二个返回值 blocked 仍按策略计算(敏感词默认即阻断、BlockInjection 可配),
// **但当前接线刻意忽略它** —— 保留是为了将来接 A 档时不必改接口。
func (rc *Round) CheckContent(ins Inspection, text string) ([]Finding, bool) {
if rc == nil || rc.rt == nil || text == "" {
return nil, false
}
var name string
if rc.scope != nil {
name = rc.scope.EffectiveProfile()
} else {
name = rc.rt.ActiveProfile()
}
// 工具名从 Inspection 自取:工具结果路径会把 Tool 填好,
// 档位可为该工具单独追加敏感词(用户裁定 2026-11-03)。
findings, blocked, reason := rc.rt.ScanContentForTool(ins, text, name, ins.Tool)
if blocked && reason != "" {
// reason 已由 security 脱敏(不含命中原文),可直接进日志。
log.Printf("[security] content review hit: %s (profile=%q tool=%q, B 档:仅标记不阻断)",
reason, name, ins.Tool)
}
return findings, blocked
}