mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-11-01 06:47:29 +00:00
记忆层: - 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 缺失。
152 lines
5.6 KiB
Go
152 lines
5.6 KiB
Go
package security
|
||
|
||
// 把**部署方配置**(`core.security.*`)装配成内核全局策略基线。
|
||
//
|
||
// ## 为什么全局基线需要有配置入口(用户 2026-10-29 裁定:选项 B)
|
||
//
|
||
// 策略有两层,职责不同:
|
||
//
|
||
// ① 全局基线 security.Policy ← 本文件负责:部署方兜底("这台机器不许跑 cmd_*")
|
||
// ② 档位 Profile / 作用域 Scope ← 插件声明:单次请求的收窄("这个 QQ 访客不行")
|
||
//
|
||
// ②是**声明式**且**只能收窄**(见 Runtime.CheckTool:全局黑名单先判,
|
||
// 档位绕不过去)。但②有它的结构性盲区:**部署方无法阻止一个恶意/写坏的
|
||
// 插件**——插件自己声明档位,等于被约束者决定约束。所以必须有一层
|
||
// 不受插件影响的基线,且它要能在不重编译的前提下改 —— 那就是①。
|
||
//
|
||
// ## 为什么不放在 internal/security 读配置
|
||
//
|
||
// `internal/security` 不 import `internal/config`(否则配置包与安全包
|
||
// 互相依赖,且 security 会被迫感知 SQLite)。装配放在本桥接层,
|
||
// security 保持纯策略/算法包。
|
||
//
|
||
// ## 为什么是启动期一次快照(不热重载)
|
||
//
|
||
// `Policy` 在**每次工具调用**的热路径上被查;查 SQLite 不可接受。
|
||
// 策略变更本就低频,且"安全策略在运行中被悄悄改变"本身是可疑行为。
|
||
// 因此:启动期读一次,改配置需重启(与 core.agent.offload_enabled 同纪律)。
|
||
|
||
import (
|
||
"fmt"
|
||
"log"
|
||
"strings"
|
||
|
||
sec "github.com/JianFeeeee/HologramAgent/internal/security"
|
||
)
|
||
|
||
// ConfigReader 是本桥接层需要的最小配置面(便于测试注入假实现)。
|
||
type ConfigReader interface {
|
||
GetString(key, defaultVal string) string
|
||
GetBool(key string, defaultVal bool) bool
|
||
}
|
||
|
||
// PolicyFromConfig 从配置读出一份全局策略快照。
|
||
//
|
||
// 缺省语义(重要):所有项留空/false 时返回**零值 Policy**,
|
||
// 即"什么都不限制",与加配置之前的存量行为逐字一致。
|
||
// 这条是升级安全的底线:老安装升级后行为不得改变。
|
||
func PolicyFromConfig(cfg ConfigReader) sec.Policy {
|
||
if cfg == nil {
|
||
return sec.DefaultPolicy()
|
||
}
|
||
p := sec.Policy{
|
||
ToolDeny: splitToolList(cfg.GetString("core.security.tool_deny", "")),
|
||
ToolAllow: splitToolList(cfg.GetString("core.security.tool_allow", "")),
|
||
Sandbox: sec.SandboxPolicy{
|
||
Enabled: cfg.GetBool("core.security.sandbox.enabled", false),
|
||
ReadRoots: splitPathList(cfg.GetString("core.security.sandbox.read_roots", "")),
|
||
WriteRoots: splitPathList(cfg.GetString("core.security.sandbox.write_roots", "")),
|
||
AllowNetwork: cfg.GetBool("core.security.sandbox.allow_network", true),
|
||
},
|
||
}
|
||
// 自相矛盾的配置(同名工具同时在黑白名单)不静默择一:
|
||
// CheckTool 的语义是 deny 优先,但那种配置通常意味着写错了,
|
||
// 而"写错的安全策略悄悄偏向一边"比启动失败更危险。这里只响亮记录。
|
||
if both := intersect(p.ToolDeny, p.ToolAllow); len(both) > 0 {
|
||
log.Printf("[security] WARNING: tool(s) %v appear in BOTH tool_deny and tool_allow; "+
|
||
"deny wins (denylist is evaluated first), but this configuration is likely a mistake", both)
|
||
}
|
||
return p
|
||
}
|
||
|
||
// ApplyPolicyFromConfig 读取配置并把策略装进运行时。
|
||
//
|
||
// 返回装好的策略,便于调用方记录/断言。rt 为 nil 时安全跳过
|
||
// (未装配安全模块的独立工具/测试)。
|
||
func ApplyPolicyFromConfig(rt *sec.Runtime, cfg ConfigReader) sec.Policy {
|
||
p := PolicyFromConfig(cfg)
|
||
if rt != nil {
|
||
rt.SetPolicy(p)
|
||
}
|
||
if !p.Sandbox.Enabled && len(p.ToolDeny) == 0 && len(p.ToolAllow) == 0 {
|
||
// 零值策略:不打印噪音日志(存量安装的常态)。
|
||
return p
|
||
}
|
||
log.Printf("[security] global policy applied: deny=%v allow=%v sandbox=%v (read=%d roots, write=%d roots, network=%v)",
|
||
p.ToolDeny, p.ToolAllow, p.Sandbox.Enabled,
|
||
len(p.Sandbox.ReadRoots), len(p.Sandbox.WriteRoots), p.Sandbox.AllowNetwork)
|
||
return p
|
||
}
|
||
|
||
// splitToolList 解析逗号分隔的工具名单。
|
||
//
|
||
// 空串 ⇒ nil(而不是 [""])—— 后者在 CheckTool 里会被当成"有一个空模式",
|
||
// 虽然 matchTool 对空模式返回 false 不会误伤,但 nil 语义更干净:
|
||
// "没配置"与"配置了一个空项"应当可区分。
|
||
func splitToolList(s string) []string {
|
||
return splitNonEmpty(s)
|
||
}
|
||
|
||
// splitPathList 解析逗号分隔的路径前缀。
|
||
//
|
||
// 去尾部分隔符:`/tmp/` 与 `/tmp` 应等价,否则 `filepath.HasPrefix` 式的
|
||
// 前缀匹配会把 `/tmpfoo` 误判进 `/tmp`。规范化交给 CheckPath(它会 Clean)。
|
||
func splitPathList(s string) []string {
|
||
out := splitNonEmpty(s)
|
||
for i, v := range out {
|
||
out[i] = strings.TrimRight(v, "/")
|
||
if out[i] == "" {
|
||
// "/" 被 TrimRight 成空 —— 根目录是个合法的意图,还原回去。
|
||
out[i] = "/"
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
func splitNonEmpty(s string) []string {
|
||
s = strings.TrimSpace(s)
|
||
if s == "" {
|
||
return nil
|
||
}
|
||
var out []string
|
||
for _, part := range strings.Split(s, ",") {
|
||
if v := strings.TrimSpace(part); v != "" {
|
||
out = append(out, v)
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
// intersect 返回两个名单的交集(用于诊断自相矛盾的配置)。
|
||
func intersect(a, b []string) []string {
|
||
if len(a) == 0 || len(b) == 0 {
|
||
return nil
|
||
}
|
||
set := make(map[string]bool, len(b))
|
||
for _, v := range b {
|
||
set[v] = true
|
||
}
|
||
var out []string
|
||
for _, v := range a {
|
||
if set[v] {
|
||
out = append(out, v)
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
// String 便于日志里一眼看出策略是否为空(诊断用)。
|
||
func DescribePolicy(p sec.Policy) string {
|
||
return fmt.Sprintf("deny=%d allow=%d sandbox=%v", len(p.ToolDeny), len(p.ToolAllow), p.Sandbox.Enabled)
|
||
}
|