mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-11-02 07:17:34 +00:00
security(新模块 internal/kernelmod/security)
- 凭据闸门:出向拦截成 @{别名}、入向清洗、执行前回填(internal/security/{gate,runtime}.go)
- 工具黑白名单 + 沙箱(读写根、网络、.. 逃逸):internal/security/policy.go
- 敏感词过滤 + 提示词注入预警(Finding 不带原文):internal/security/content.go
- 按主体授权(hard-private 对非内部主体永久拒绝):internal/security/principal.go
- 插件命名限式(可选档位,插件声明/内核执行,只能收窄):internal/security/profile.go
- 启动装配从 cmd/homed 迁入模块;启动顺序判据改盯真实装配点
device(新模块)
- 正式内核模块,Provides FacDevice,KernelState.Device
- 内置工具路由表下沉到 internal/toolroute(避免 core↔kernelboot 环),
device 保留对外入口;agent 未挂载时退化本地 switch
- Agent 侧只留 api.ToolHost/DeviceSurface 适配层
SDK
- InjectOptions.SecurityProfile:插件按 inputch 声明本轮安全档位(可选)
- InjectOptions.TurnHint:插件注入**本轮事实**,内核以独立 system 消息注入,
不混进用户正文(人设负责怎么说话,TurnHint 只放这一轮成立的事实)
- FacilitySecurity / FacilityDevice
提示词
- 内核自带 prompt/skill/工具返回全面英文化(中文提示会让模型行为漂移)
- 机制解释下沉到 4 个内置 skill(memory/output/tool-order/plugins),
skill 描述压到 80 runes 内(原来 94–108 全被索引截断)
- 默认人格模板英文化;[Voice] 段删除,规则并入 persona 块(单一段落)
- 历史记忆工具改名 coldcontext_query/commit,图记忆语义改称 stable memory
修复
- tracker: exec mount → unix.Mount(降权运行 + AmbientCapabilities 下才能挂)、
区分 EPERM 与不支持;启动日志说明后果与恢复方式
- healthcheck_kernel 输出瘦身:status.Tools 只给摘要(原 98,989 tokens/151% 预算)
- plugin_list 描述讲清 auto_start(按需)与 alive(此刻是否在跑),
明确不能由 alive=false 推断能力不存在
- pluginmgr/webui 去掉写死的 /home/newqqagent 回退路径(env → pluginDir 反推 → 报错)
- lua: 盘上适配器与内置版本不一致且无基线时响亮告警(deepseek 流式丢失
tool_calls 导致'模型不调工具'的真因)
测试:go build ./... 通过;go test ./... 60 包全绿
150 lines
5.1 KiB
Go
150 lines
5.1 KiB
Go
package security
|
||
|
||
// 安全策略:**工具黑白名单 + 沙箱约束**。
|
||
//
|
||
// ## 为什么它属于安全模块(用户裁定 2026-10-26)
|
||
//
|
||
// > 沙箱检查和工具的黑白名单也应该是安全模块负责,并通过 SDK 提供接口。
|
||
//
|
||
// 归属理由:允许/禁止某类能力是**安全决定**,不是执行细节。放在
|
||
// agent/core 会让每个调用点各自维护一份清单(本仓已有实测教训:
|
||
// healthcheck 自带一份只读名单,与工具真实能力靠人肉对齐);
|
||
// 放在插件里则等于“被约束者自己决定约束”。
|
||
//
|
||
// ## 与执行闸的分工
|
||
//
|
||
// 安全模块(本文件):能不能做 —— 策略、沙箱、名单
|
||
// agent/core :怎么做 —— 参数校验、授权范围、超时、panic 恢复
|
||
//
|
||
// 两者都必须过,不能互相顶替:策略放行不等于参数合法,
|
||
// 参数合法也不等于策略允许。
|
||
|
||
import (
|
||
"fmt"
|
||
"path/filepath"
|
||
"strings"
|
||
)
|
||
|
||
// Policy 是安全策略快照(启动时从配置读一次,重载时整体替换)。
|
||
//
|
||
// ★ 为什么用快照而不是每次查配置:策略会在热路径(每次工具调用)被查,
|
||
// 查配置要走 SQLite;而策略变更本身是低频的、本就该走重载路径。
|
||
type Policy struct {
|
||
// ToolDeny 是被**明确禁止**的工具名(支持 `*` 后缀前缀匹配,如 "cmd_*")。
|
||
//
|
||
// 名单语义(刻意如此,各有用途):
|
||
// · 在黑名单里 ⇒ 拒绝(先于白名单判定:禁止永远胜过允许)
|
||
// · 白名单非空且不在其中 ⇒ 拒绝(白名单是"只有这些")
|
||
// · 白名单为空 ⇒ 不限制(默认;存量行为不变)
|
||
ToolDeny []string
|
||
ToolAllow []string
|
||
|
||
// Sandbox 是执行侧约束。
|
||
Sandbox SandboxPolicy
|
||
}
|
||
|
||
// SandboxPolicy 是工具执行的沙箱约束。
|
||
//
|
||
// ★ 只放**可判定**的事实(路径前缀、是否允许网络、允许的二进制);
|
||
// 不做"猜测式"安全检查(如"这段 shell 看起来危险")—— 那类判定既误伤
|
||
// 又给不出可执行的修复动作。
|
||
type SandboxPolicy struct {
|
||
// Enabled 为 false 表示不施加额外约束(默认;行为与之前一致)。
|
||
Enabled bool
|
||
// ReadRoots / WriteRoots 是允许读/写的前缀(空 = 不限制该方向)。
|
||
ReadRoots []string
|
||
WriteRoots []string
|
||
// AllowNetwork 为 false 时拒绝带 URL / 网络意图的调用。
|
||
AllowNetwork bool
|
||
}
|
||
|
||
// Decision 是一次策略判定的结果。
|
||
//
|
||
// ★ 带 Reason:拒绝必须能回答"为什么",否则模型只会反复重试同一调用。
|
||
type Decision struct {
|
||
Allowed bool
|
||
Reason string
|
||
}
|
||
|
||
// Allow 表示放行。
|
||
func allowed() Decision { return Decision{Allowed: true} }
|
||
|
||
// deny 构造一次拒绝(reason 会原样回到模型,必须可执行)。
|
||
func deny(format string, args ...interface{}) Decision {
|
||
return Decision{Allowed: false, Reason: fmt.Sprintf(format, args...)}
|
||
}
|
||
|
||
// CheckTool 判定某工具是否被策略允许。
|
||
func (p Policy) CheckTool(name string) Decision {
|
||
if name == "" {
|
||
return deny("empty tool name")
|
||
}
|
||
// ① 黑名单优先:禁止永远胜过允许。
|
||
// 为什么先判黑名单:否则"白名单里含 cmd_run"和"黑名单里含 cmd_run"
|
||
// 同时存在时会放行 —— 那违背"安全策略里禁止项总是赢"的直觉。
|
||
for _, pat := range p.ToolDeny {
|
||
if matchTool(pat, name) {
|
||
return deny("tool %q is blocked by security policy (denylist entry %q)", name, pat)
|
||
}
|
||
}
|
||
// ② 白名单非空则是"只有这些"。
|
||
if len(p.ToolAllow) > 0 {
|
||
for _, pat := range p.ToolAllow {
|
||
if matchTool(pat, name) {
|
||
return allowed()
|
||
}
|
||
}
|
||
return deny("tool %q is not in the security allowlist", name)
|
||
}
|
||
return allowed()
|
||
}
|
||
|
||
// matchTool 做工具名匹配:精确,或 `前缀*` 形式。
|
||
//
|
||
// ★ 只支持尾部 `*`:中间的 `*` 会诱导写出难以推理的规则
|
||
// ("cmd_*_run" 这类),而安全规则必须一眼可判。
|
||
func matchTool(pattern, name string) bool {
|
||
if pattern == "" {
|
||
return false
|
||
}
|
||
if strings.HasSuffix(pattern, "*") {
|
||
return strings.HasPrefix(name, strings.TrimSuffix(pattern, "*"))
|
||
}
|
||
return pattern == name
|
||
}
|
||
|
||
// CheckPath 判定某路径在给定方向(read/write)是否被沙箱允许。
|
||
func (p Policy) CheckPath(direction, path string) Decision {
|
||
sb := p.Sandbox
|
||
if !sb.Enabled || path == "" {
|
||
return allowed()
|
||
}
|
||
roots := sb.ReadRoots
|
||
if direction == "write" {
|
||
roots = sb.WriteRoots
|
||
}
|
||
if len(roots) == 0 {
|
||
return allowed() // 该方向不限制
|
||
}
|
||
// 规范化:避免 `..` 绕过前缀匹配。
|
||
clean := filepath.Clean(path)
|
||
for _, root := range roots {
|
||
r := filepath.Clean(root)
|
||
if clean == r || strings.HasPrefix(clean, r+string(filepath.Separator)) {
|
||
return allowed()
|
||
}
|
||
}
|
||
return deny("%s path %q is outside the sandbox (%s roots: %v)", direction, path, direction, roots)
|
||
}
|
||
|
||
// CheckNetwork 判定是否允许网络访问。
|
||
func (p Policy) CheckNetwork() Decision {
|
||
if !p.Sandbox.Enabled || p.Sandbox.AllowNetwork {
|
||
return allowed()
|
||
}
|
||
return deny("network access is disabled by sandbox policy")
|
||
}
|
||
|
||
// DefaultPolicy 返回"不限制"的策略(未配置时的行为,与之前一致)。
|
||
func DefaultPolicy() Policy { return Policy{} }
|