Files
HomeAgent/internal/agent/core/security_toolgate_test.go
JianFeeeee b850feaef2 feat: 段页式记忆流转、coldcontext 重命名与安全治理
记忆层:
- MemoryBlock 承载 FrameTS/PageSeq/PageKind/BlockRole/CallID
- coldcontext 归档与回填按完整页传递,工具调用参数和配对键不丢失
- 召回先估算总 token,超预算时按全局页相似度标记 inactive
- inactive 仅是 context 渲染期状态,不落盘、不进入 coldcontext,Prune 后重置
- Peek/确认/Remove 两阶段召回,失败保留冷上下文副本
- stablemem 记录 last_recalled_at,删块连带清理边

命名:
- internal/memory/document → internal/memory/coldcontext
- Doc/DocEvent/DocHit → ColdDoc/ColdEvent/ColdHit
- ContextToColdContext/RecentColdContexts/FindColdContexts
- 保留既有 JSON 标签、doc_ 文件前缀与 doc_count 状态键,兼容存量数据

安全治理:
- 工具策略门禁、安全配置、事件总线发布者身份与入站脱敏
- 声明式 SecurityAPI 与 QQ owner/guest profile
- 日志保留策略与相关 e2e/回归判据

验证:go build ./...、go vet ./...、go test ./...(62 包);
生产副本 e2e:7013 块迁移后 ID/向量/指纹 0 变化、0 缺失。
2026-10-29 23:53:48 +08:00

127 lines
4.8 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 core
// 安全策略闸的**接线**判据。
//
// ## 为什么需要这个文件(2026-10-29)
//
// `Policy.CheckTool` 此前是完美的机制 + 零个生产调用点:
//
// CheckTool / CheckToolFor / ScanContent / CheckPath / CheckNetwork
// → 除了 security 包自己的测试,全仓 0 个调用方
//
// 更糟的是 `Agent.security` 字段**从未被赋值**(只有测试赋过),
// 所以 `securityDecisionFor` 恒走 nil 分支放行。两种故障叠加的结果是:
// 策略表存在、判定函数正确、日志里一条拒绝记录都没有 —— 看起来"策略已启用"。
//
// 本文件的判据钉的是**接线**,不是机制:
// - 机制(deny 命中返回 !Allowed):security/policy_test.go 已经钉过
// - 接线(!Allowed 时工具真的不执行):本文件钉
//
// ## 判据必须能红
//
// 如果把 `executeToolCallInner` 里的策略闸整段删掉,
// `TestToolDeniedByPolicyNeverExecutes` 必须失败。
// 这一点比"测试通过"更重要 —— 否则它只是又一份自我确认。
import (
"strings"
"testing"
agentAPI "github.com/JianFeeeee/HologramAgent/internal/agent/api"
sdk "github.com/JianFeeeee/HologramAgent/internal/sdk"
"github.com/JianFeeeee/HologramAgent/internal/security"
)
// toolCallTo 构造一个最小可用的工具调用。
func toolCallTo(name string) agentAPI.ToolCall {
return agentAPI.ToolCall{Name: name, Arguments: map[string]interface{}{}}
}
// policyRuntime 构造一个带工具黑名单的独立运行时(不碰全局单例)。
func policyRuntime(t *testing.T, deny ...string) *security.Runtime {
t.Helper()
rt := security.NewRuntime(nil)
rt.SetPolicy(security.Policy{ToolDeny: deny})
return rt
}
// ① 未注入安全运行时 ⇒ 全部放行(独立工具/测试的既有行为不变)。
func TestToolGateAbsentAllowsEverything(t *testing.T) {
a := newPreemptAgent(t, newPreemptProvider())
a.SetSecurity(nil)
if d := a.securityDecisionFor("cmd_run"); !d.Allowed {
t.Fatalf("未注入时应放行: %s", d.Reason)
}
}
// ② SetSecurity 真的接上了(此前该字段只有测试赋过值)。
func TestSetSecurityWiresDecision(t *testing.T) {
a := newPreemptAgent(t, newPreemptProvider())
a.SetSecurity(policyRuntime(t, "cmd_*"))
if d := a.securityDecisionFor("cmd_run"); d.Allowed {
t.Fatal("SetSecurity 注入后 cmd_* 必须被拒 —— 否则注入通道是假的")
}
if d := a.securityDecisionFor("stablemem_recall"); !d.Allowed {
t.Fatalf("未命中黑名单的工具必须放行: %s", d.Reason)
}
}
// ③ ★ 核心判据:被策略拒绝的工具**不执行**,且返回结构化拒绝。
//
// "不执行" 通过让工具注册一个必被调用的探针来证明:
// 若闸门缺失,探针会被调用(hit=true),判据失败。
func TestToolDeniedByPolicyNeverExecutes(t *testing.T) {
a := newPreemptAgent(t, newPreemptProvider())
const toolName = "probe_tool"
hit := false
// 插件工具经 StageHost 注册(内置工具不走这条路,见 tooldefs.go 的说明)。
if err := a.stageHost.RegisterTool(toolName, sdk.ToolDef{
Name: toolName,
Description: "probe",
Parameters: map[string]interface{}{"type": "object", "properties": map[string]interface{}{}},
}, func(args map[string]interface{}) (interface{}, error) {
hit = true
return "should not run", nil
}); err != nil {
t.Fatalf("注册探针工具失败: %v", err)
}
// 先证明探针本身可用(否则"没执行"可能只是工具不存在)。
a.SetSecurity(nil)
if out := a.executeToolCall(toolCallTo(toolName), "test"); !strings.Contains(out, "should not run") {
t.Fatalf("前置条件不成立:策略未启用时探针应正常执行,实际返回 %q", out)
}
if !hit {
t.Fatal("前置条件不成立:探针未被调用")
}
// 再打开黑名单 ⇒ 探针必须**一次都不再被调用**。
hit = false
a.SetSecurity(policyRuntime(t, toolName))
out := a.executeToolCall(toolCallTo(toolName), "test")
if hit {
t.Fatal("★ 被策略拒绝的工具仍被执行了 —— 策略闸没接在实际执行链上")
}
if !strings.Contains(out, "not permitted") {
t.Fatalf("拒绝必须以结构化文案回给模型(便于它换工具),实际 %q", out)
}
}
// ④ 拒绝原因必须**可执行**:带上策略给的理由,而不是泛泛的 "denied"。
func TestToolDenialCarriesActionableReason(t *testing.T) {
a := newPreemptAgent(t, newPreemptProvider())
a.SetSecurity(policyRuntime(t, "cmd_*"))
out := a.executeToolCall(toolCallTo("cmd_run"), "test")
if !strings.Contains(out, "cmd_*") {
t.Fatalf("拒绝文案应包含命中的黑名单条目(模型据此才知道边界),实际 %q", out)
}
if !strings.Contains(out, "denied") {
t.Fatalf("应带 denied 原因码(与 unauthorized 区分),实际 %q", out)
}
}