Files
HomeAgent/internal/security/policy.go
JianFeeeee 55fa2685ae feat(security,device): security 成为独立内核模块(闸门+策略+限式),device 接管内置工具路由
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 包全绿
2026-10-26 20:55:25 +08:00

150 lines
5.1 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
// 安全策略:**工具黑白名单 + 沙箱约束**。
//
// ## 为什么它属于安全模块(用户裁定 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{} }