feat(security): 敏感清单补齐 design/11 §4 的 7 类(写数据/对外发消息/访问密钥/改配置,结构化判据)
This commit is contained in:
1 parent
509dc412eb
commit
85abb90067
6 files changed
+813
-18
No files matched your search
@@ -40,7 +40,8 @@ type ApprovalEvent struct {
|
|||||||
Actor string
|
Actor string
|
||||||
// Tool 是敏感工具名(如 terminal)。
|
// Tool 是敏感工具名(如 terminal)。
|
||||||
Tool string
|
Tool string
|
||||||
// Op 是敏感操作类别(执行命令 / 修改文件 / 网络访问)。
|
// Op 是敏感操作类别(design/11 §4 的 7 类:执行命令 / 修改文件 / 写数据 /
|
||||||
|
// 对外发消息 / 访问密钥与隐私 / 改配置与权限 / 网络访问)。
|
||||||
Op SensitiveOp
|
Op SensitiveOp
|
||||||
// Detail 是动作摘要(工具 + 关键参数,已截断)。
|
// Detail 是动作摘要(工具 + 关键参数,已截断)。
|
||||||
Detail string
|
Detail string
|
||||||
|
|||||||
@@ -15,51 +15,124 @@ const (
|
|||||||
SensitiveExecCommand SensitiveOp = "执行命令"
|
SensitiveExecCommand SensitiveOp = "执行命令"
|
||||||
// SensitiveWriteFile 修改文件(file 工具的 write 动作)。
|
// SensitiveWriteFile 修改文件(file 工具的 write 动作)。
|
||||||
SensitiveWriteFile SensitiveOp = "修改文件"
|
SensitiveWriteFile SensitiveOp = "修改文件"
|
||||||
|
// SensitiveWriteData 写数据:对数据存储的写入(数据库/存储文件的写、
|
||||||
|
// 记忆与索引类工具的写动作)。
|
||||||
|
SensitiveWriteData SensitiveOp = "写数据"
|
||||||
|
// SensitiveOutboundMessage 对外发消息:把内容投出本进程的发送类工具
|
||||||
|
// (邮件/短信/渠道消息/Webhook)。
|
||||||
|
SensitiveOutboundMessage SensitiveOp = "对外发消息"
|
||||||
|
// SensitiveSecretAccess 访问密钥与隐私:读写凭据、密钥材料与隐私数据
|
||||||
|
// (`.env` / `models.json` / 渠道 token / SSH 私钥 / 本地数据库)。
|
||||||
|
SensitiveSecretAccess SensitiveOp = "访问密钥与隐私"
|
||||||
|
// SensitiveConfigChange 改配置与权限:改动实例配置与权限边界
|
||||||
|
// (模型与渠道配置、MCP/插件清单、策略与角色定义)。
|
||||||
|
SensitiveConfigChange SensitiveOp = "改配置与权限"
|
||||||
// SensitiveNetwork 网络访问。只有会改变外部状态的写方法算敏感:
|
// SensitiveNetwork 网络访问。只有会改变外部状态的写方法算敏感:
|
||||||
// GET/HEAD/OPTIONS 属「检索」,可自主(design/11 §4)。
|
// GET/HEAD/OPTIONS 属「检索」,可自主(design/11 §4)。
|
||||||
SensitiveNetwork SensitiveOp = "网络访问"
|
SensitiveNetwork SensitiveOp = "网络访问"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// SensitiveOps 按 design/11 §4 的顺序列出**全部 7 类**敏感操作。它是该清单在
|
||||||
|
// 代码里的对账口径:测试断言 7 类齐全、且每一类都至少有一条结构化判据能命中。
|
||||||
|
var SensitiveOps = []SensitiveOp{
|
||||||
|
SensitiveExecCommand,
|
||||||
|
SensitiveWriteFile,
|
||||||
|
SensitiveWriteData,
|
||||||
|
SensitiveOutboundMessage,
|
||||||
|
SensitiveSecretAccess,
|
||||||
|
SensitiveConfigChange,
|
||||||
|
SensitiveNetwork,
|
||||||
|
}
|
||||||
|
|
||||||
// sensitiveSummaryRunes 截断动作摘要:代审小字与审计只需一眼看出「想干什么」,
|
// sensitiveSummaryRunes 截断动作摘要:代审小字与审计只需一眼看出「想干什么」,
|
||||||
// 同时限制进入前端/日志的文本块大小(也降低把长参数里的秘密整段搬进日志的机会)。
|
// 同时限制进入前端/日志的文本块大小(也降低把长参数里的秘密整段搬进日志的机会)。
|
||||||
const sensitiveSummaryRunes = 80
|
const sensitiveSummaryRunes = 80
|
||||||
|
|
||||||
// SensitiveCall 判定一次工具调用是否属敏感操作(design/11 §4)。
|
// SensitiveCall 判定一次工具调用是否属敏感操作(design/11 §4)。
|
||||||
//
|
//
|
||||||
// 判定完全结构化:只看工具名 + 参数,绝不由模型文本推断(design/11 §0 红线)。
|
// 判定完全结构化:只看工具名 + 参数(以及参数里的路径落点),绝不由模型文本
|
||||||
// 返回操作类别、动作摘要与是否敏感。清单与项目实际的工具面对应如下:
|
// 推断(design/11 §0 红线)。判据分三层:
|
||||||
|
//
|
||||||
|
// 1. 具名工具的参数判据(`file` / `http` / `terminal`,见下表);
|
||||||
|
// 2. 按**路径落点**细化 `file` 的类别(配置件 / 数据存储件 / 密钥件,
|
||||||
|
// 见 sensitive_paths.go);
|
||||||
|
// 3. 其余工具(MCP / 插件 / 生态新增)按**工具名**的名词+动词词族判定,
|
||||||
|
// 并对「存储类工具 + 写动作参数」补一条参数判据(见 sensitive_names.go),
|
||||||
|
// 判不出即非敏感。
|
||||||
|
//
|
||||||
|
// 具名工具的参数判据:
|
||||||
//
|
//
|
||||||
// terminal → 执行命令(命令原文进摘要)
|
// terminal → 执行命令(命令原文进摘要)
|
||||||
// file (action=write) → 修改文件(路径进摘要)
|
// file (action=write) → 落点是配置件 ⇒ 改配置与权限;落点是数据存储件 ⇒
|
||||||
|
// 写数据;其余 ⇒ 修改文件(路径进摘要)
|
||||||
|
// file (read/list/search) → 落点是密钥件或数据存储件 ⇒ 访问密钥与隐私;
|
||||||
|
// 其余 ⇒ 非敏感(design/11 §4「读文件可自主」)
|
||||||
// http (method=POST) → 网络访问(URL 进摘要)
|
// http (method=POST) → 网络访问(URL 进摘要)
|
||||||
//
|
//
|
||||||
// 其余情况一律**不敏感**:file 的 read/list/search、http 的 GET/HEAD/OPTIONS、
|
// 其余情况一律**不敏感**:knowledge / web-search / web-fetch(检索)、
|
||||||
// knowledge / web-search / web-fetch(检索)、calculator / datetime / text /
|
// calculator / datetime / text / json / markdown / random(纯计算)、
|
||||||
// json / markdown / random(纯计算)、vision / ocr / session_read(只读)——
|
// vision / ocr / session_read(只读)——与 design/11 §4「非敏感(读文件 /
|
||||||
// 与 design/11 §4「非敏感(读文件 / 检索 / 查记忆)可自主」一致。
|
// 检索 / 查记忆)可自主」一致。
|
||||||
//
|
//
|
||||||
// 未知工具(含 MCP 工具)同样不敏感:工具面已由 registry 的策略闸门管理,
|
// 判不准的新工具同样不敏感:工具面已由 registry 的策略闸门管理,这里不新增
|
||||||
// 这里不新增「未知即拦截」的硬边界,否则任何新工具都会在主人会话里被拦死。
|
// 「未知即拦截」的硬边界,否则任何新工具都会在主人会话里被拦死。判据刻意保守
|
||||||
|
// 的理由同上:误判把正常工具拉进代审会污染审计与前端小字。
|
||||||
func SensitiveCall(name string, args map[string]any) (SensitiveOp, string, bool) {
|
func SensitiveCall(name string, args map[string]any) (SensitiveOp, string, bool) {
|
||||||
switch name {
|
switch name {
|
||||||
case "terminal":
|
case "terminal":
|
||||||
return SensitiveExecCommand, summarize("terminal", argString(args, "command")), true
|
return SensitiveExecCommand, summarize("terminal", argString(args, "command")), true
|
||||||
case "file":
|
case "file":
|
||||||
if argString(args, "action") != "write" {
|
return fileCall(args)
|
||||||
return "", "", false
|
|
||||||
}
|
|
||||||
return SensitiveWriteFile, summarize("file write", argString(args, "path")), true
|
|
||||||
case "http":
|
case "http":
|
||||||
method := strings.ToUpper(strings.TrimSpace(argString(args, "method")))
|
return httpCall(args)
|
||||||
if method == "" || method == "GET" || method == "HEAD" || method == "OPTIONS" {
|
}
|
||||||
return "", "", false
|
if op, ok := matchNameRule(name); ok {
|
||||||
|
return op, summarize(name, argTarget(args)), true
|
||||||
|
}
|
||||||
|
if op, ok := matchStoreAction(name, args); ok {
|
||||||
|
return op, summarize(name, argTarget(args)), true
|
||||||
|
}
|
||||||
|
return "", "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
// fileCall 按 `file` 的动作与**落点路径**给出类别(design/11 §4 的四类判据:
|
||||||
|
// 修改文件 / 写数据 / 改配置与权限 / 访问密钥与隐私)。
|
||||||
|
//
|
||||||
|
// 未知或缺失 action 保持「不敏感」:真实工具会直接拒绝这种调用(
|
||||||
|
// plugins/tools/io/file.go 的 switch 落到 default),它不是一次真实的副作用。
|
||||||
|
func fileCall(args map[string]any) (SensitiveOp, string, bool) {
|
||||||
|
action := argString(args, "action")
|
||||||
|
target := argString(args, "path")
|
||||||
|
switch action {
|
||||||
|
case "write":
|
||||||
|
switch {
|
||||||
|
case isConfigArtifact(target):
|
||||||
|
return SensitiveConfigChange, summarize("file write", target), true
|
||||||
|
case isDataStoreArtifact(target):
|
||||||
|
return SensitiveWriteData, summarize("file write", target), true
|
||||||
|
default:
|
||||||
|
return SensitiveWriteFile, summarize("file write", target), true
|
||||||
}
|
}
|
||||||
return SensitiveNetwork, summarize("http "+method, argString(args, "url")), true
|
case "read", "list", "search":
|
||||||
|
if isSecretArtifact(target) || isDataStoreArtifact(target) {
|
||||||
|
return SensitiveSecretAccess, summarize("file "+action, target), true
|
||||||
|
}
|
||||||
|
return "", "", false
|
||||||
default:
|
default:
|
||||||
return "", "", false
|
return "", "", false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// httpCall 按 HTTP 方法给出类别(design/11 §4):只有会改变外部状态的写方法
|
||||||
|
// 算敏感,GET/HEAD/OPTIONS 与缺省方法(=GET)属检索,可自主。
|
||||||
|
func httpCall(args map[string]any) (SensitiveOp, string, bool) {
|
||||||
|
method := strings.ToUpper(strings.TrimSpace(argString(args, "method")))
|
||||||
|
if method == "" || method == "GET" || method == "HEAD" || method == "OPTIONS" {
|
||||||
|
return "", "", false
|
||||||
|
}
|
||||||
|
return SensitiveNetwork, summarize("http "+method, argString(args, "url")), true
|
||||||
|
}
|
||||||
|
|
||||||
// summarize 拼接「动作 + 目标」摘要并按 sensitiveSummaryRunes 截断(不切开
|
// summarize 拼接「动作 + 目标」摘要并按 sensitiveSummaryRunes 截断(不切开
|
||||||
// 多字节字符);目标为空时只返回动作。
|
// 多字节字符);目标为空时只返回动作。
|
||||||
func summarize(action, target string) string {
|
func summarize(action, target string) string {
|
||||||
|
|||||||
@@ -0,0 +1,295 @@
|
|||||||
|
package restrict
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestSensitiveOps_CoverDesignChecklist 锁定 design/11 §4 的**七类清单齐全**:
|
||||||
|
// 常量表与设计文档的顺序逐项对应,且每一类都至少有一条结构化判据能命中——
|
||||||
|
// 防止「文档列 7 类、代码只判 3 类」这种静默缺口复发。
|
||||||
|
func TestSensitiveOps_CoverDesignChecklist(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
want := []SensitiveOp{
|
||||||
|
SensitiveExecCommand, SensitiveWriteFile, SensitiveWriteData,
|
||||||
|
SensitiveOutboundMessage, SensitiveSecretAccess, SensitiveConfigChange,
|
||||||
|
SensitiveNetwork,
|
||||||
|
}
|
||||||
|
if len(SensitiveOps) != len(want) {
|
||||||
|
t.Fatalf("SensitiveOps 长度 = %d, want %d(design/11 §4 的 7 类)", len(SensitiveOps), len(want))
|
||||||
|
}
|
||||||
|
for i, op := range want {
|
||||||
|
if SensitiveOps[i] != op {
|
||||||
|
t.Fatalf("SensitiveOps[%d] = %q, want %q(顺序须与 design/11 §4 一致)", i, SensitiveOps[i], op)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 每一类给一个代表调用;命中类别必须与期望一致。
|
||||||
|
reps := []struct {
|
||||||
|
op SensitiveOp
|
||||||
|
name string
|
||||||
|
args map[string]any
|
||||||
|
}{
|
||||||
|
{SensitiveExecCommand, "terminal", map[string]any{"command": "ls"}},
|
||||||
|
{SensitiveWriteFile, "file", map[string]any{"action": "write", "path": "/data/notes/a.md"}},
|
||||||
|
{SensitiveWriteData, "file", map[string]any{"action": "write", "path": "/data/cyrene.db"}},
|
||||||
|
{SensitiveOutboundMessage, "send_email", map[string]any{"to": "a@example.com"}},
|
||||||
|
{SensitiveSecretAccess, "file", map[string]any{"action": "read", "path": "/data/.env"}},
|
||||||
|
{SensitiveConfigChange, "file", map[string]any{"action": "write", "path": "/data/models.json"}},
|
||||||
|
{SensitiveNetwork, "http", map[string]any{"method": "POST", "url": "https://example.com"}},
|
||||||
|
}
|
||||||
|
covered := map[SensitiveOp]bool{}
|
||||||
|
for _, r := range reps {
|
||||||
|
op, _, sensitive := SensitiveCall(r.name, r.args)
|
||||||
|
if !sensitive {
|
||||||
|
t.Fatalf("%s 的代表调用 %s 未被判为敏感,该类别是空判据", r.op, r.name)
|
||||||
|
}
|
||||||
|
if op != r.op {
|
||||||
|
t.Fatalf("%s 代表调用被判成 %q, want %q", r.name, op, r.op)
|
||||||
|
}
|
||||||
|
covered[op] = true
|
||||||
|
}
|
||||||
|
for _, op := range want {
|
||||||
|
if !covered[op] {
|
||||||
|
t.Fatalf("类别 %q 没有任何命中判据", op)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSensitiveCall_FileTargetsByPath 是 `file` 落点分类的判定表(design/11 §4
|
||||||
|
// 的四类:修改文件 / 写数据 / 改配置与权限 / 访问密钥与隐私)。
|
||||||
|
func TestSensitiveCall_FileTargetsByPath(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
args map[string]any
|
||||||
|
op SensitiveOp
|
||||||
|
}{
|
||||||
|
// 写:配置件 ⇒ 改配置与权限(含 Windows 反斜杠、相对路径与 .env.* 变体)。
|
||||||
|
{"file", map[string]any{"action": "write", "path": `D:\Cyrene\data\models.json`}, SensitiveConfigChange},
|
||||||
|
{"file", map[string]any{"action": "write", "path": "data/channels.json"}, SensitiveConfigChange},
|
||||||
|
{"file", map[string]any{"action": "write", "path": "/repo/.env"}, SensitiveConfigChange},
|
||||||
|
{"file", map[string]any{"action": "write", "path": "/repo/.env.production"}, SensitiveConfigChange},
|
||||||
|
{"file", map[string]any{"action": "write", "path": "/repo/configs/personas/cyrene.yaml"}, SensitiveConfigChange},
|
||||||
|
{"file", map[string]any{"action": "write", "path": "/repo/configs/plugins.json"}, SensitiveConfigChange},
|
||||||
|
// 写:数据存储件 ⇒ 写数据(含 SQLite 的 WAL/SHM 同伴文件)。
|
||||||
|
{"file", map[string]any{"action": "write", "path": "/data/cyrene.db"}, SensitiveWriteData},
|
||||||
|
{"file", map[string]any{"action": "write", "path": "/data/cyrene.db-wal"}, SensitiveWriteData},
|
||||||
|
{"file", map[string]any{"action": "write", "path": "/data/notes.sqlite3"}, SensitiveWriteData},
|
||||||
|
// 写:其余 ⇒ 修改文件。
|
||||||
|
{"file", map[string]any{"action": "write", "path": "/data/uploads/a.md"}, SensitiveWriteFile},
|
||||||
|
{"file", map[string]any{"action": "write", "path": "data/notes.json"}, SensitiveWriteFile},
|
||||||
|
// 读:密钥件 ⇒ 访问密钥与隐私。
|
||||||
|
{"file", map[string]any{"action": "read", "path": "/repo/.env"}, SensitiveSecretAccess},
|
||||||
|
{"file", map[string]any{"action": "read", "path": "/data/models.json"}, SensitiveSecretAccess},
|
||||||
|
{"file", map[string]any{"action": "list", "path": "/data/secrets"}, SensitiveSecretAccess},
|
||||||
|
{"file", map[string]any{"action": "read", "path": "/home/u/.ssh/id_rsa"}, SensitiveSecretAccess},
|
||||||
|
{"file", map[string]any{"action": "read", "path": "/data/cert.pem"}, SensitiveSecretAccess},
|
||||||
|
// 读:数据存储件 ⇒ 访问密钥与隐私(库里是主人的会话/记忆/审计数据)。
|
||||||
|
{"file", map[string]any{"action": "read", "path": "/data/cyrene.db"}, SensitiveSecretAccess},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
op, detail, sensitive := SensitiveCall(tc.name, tc.args)
|
||||||
|
if !sensitive {
|
||||||
|
t.Fatalf("SensitiveCall(%v) 应判为敏感(want %s)", tc.args, tc.op)
|
||||||
|
}
|
||||||
|
if op != tc.op {
|
||||||
|
t.Fatalf("SensitiveCall(%v) op = %q, want %q(摘要 %q)", tc.args, op, tc.op, detail)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSensitiveCall_ReadStaysAutonomousByDefault 锁定 design/11 §4 的「读文件可
|
||||||
|
// 自主」:普通落点的读/列/搜仍不进代审——只有密钥与数据存储落点例外。
|
||||||
|
func TestSensitiveCall_ReadStaysAutonomousByDefault(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
for _, args := range []map[string]any{
|
||||||
|
{"action": "read", "path": "/data/a.md"},
|
||||||
|
{"action": "read", "path": "data/uploads/report.md"},
|
||||||
|
{"action": "read", "path": "/repo/configs/personas/cyrene.yaml"}, // 读配置不算改配置
|
||||||
|
{"action": "read", "path": "/repo/README.md"},
|
||||||
|
{"action": "list", "path": "/data/uploads"},
|
||||||
|
{"action": "search", "path": "/data", "query": "x"},
|
||||||
|
} {
|
||||||
|
if op, detail, sensitive := SensitiveCall("file", args); sensitive {
|
||||||
|
t.Fatalf("SensitiveCall(file, %v) = (%q, %q, true), want 自主放行", args, op, detail)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSensitiveCall_NameRules 是「按工具名」判据的判定表:对外发消息 / 访问密钥
|
||||||
|
// 与隐私 / 改配置与权限 / 写数据四类的正例,以及**刻意的反例**(读配置、数 token
|
||||||
|
// 一类近邻名字不得被误判)。
|
||||||
|
func TestSensitiveCall_NameRules(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
wantOp SensitiveOp
|
||||||
|
sensitive bool
|
||||||
|
}{
|
||||||
|
// 对外发消息(含驼峰与连字符命名)。
|
||||||
|
{"send_email", SensitiveOutboundMessage, true},
|
||||||
|
{"send-message", SensitiveOutboundMessage, true},
|
||||||
|
{"sendEmail", SensitiveOutboundMessage, true},
|
||||||
|
{"slack_post_message", SensitiveOutboundMessage, true},
|
||||||
|
{"slackPostMessage", SensitiveOutboundMessage, true},
|
||||||
|
{"notify_user", SensitiveOutboundMessage, true},
|
||||||
|
{"send", SensitiveOutboundMessage, true},
|
||||||
|
// 访问密钥与隐私。
|
||||||
|
{"get_secret", SensitiveSecretAccess, true},
|
||||||
|
{"list_credentials", SensitiveSecretAccess, true},
|
||||||
|
{"read_env", SensitiveSecretAccess, true},
|
||||||
|
{"get_api_key", SensitiveSecretAccess, true},
|
||||||
|
{"vault_read", SensitiveSecretAccess, true},
|
||||||
|
// 改配置与权限(写动作 + 配置对象,或本身即权限变更的动词)。
|
||||||
|
{"set_policy", SensitiveConfigChange, true},
|
||||||
|
{"update_config", SensitiveConfigChange, true},
|
||||||
|
{"updateSettingsFile", SensitiveConfigChange, true},
|
||||||
|
{"grant_permission", SensitiveConfigChange, true},
|
||||||
|
{"assign_role", SensitiveConfigChange, true},
|
||||||
|
{"chmod", SensitiveConfigChange, true},
|
||||||
|
{"grant_access", SensitiveConfigChange, true},
|
||||||
|
// 写数据(写动作 + 存储对象,或记忆写入的独立命名)。
|
||||||
|
{"memory_write", SensitiveWriteData, true},
|
||||||
|
{"write_memory", SensitiveWriteData, true},
|
||||||
|
{"writeMemory", SensitiveWriteData, true},
|
||||||
|
{"remember", SensitiveWriteData, true},
|
||||||
|
{"save_note", SensitiveWriteData, true},
|
||||||
|
{"insert_record", SensitiveWriteData, true},
|
||||||
|
{"update_index", SensitiveWriteData, true},
|
||||||
|
// 反例:只读配置 / 只读记忆 / 纯计算 / 量词近邻 / 只读目录,一律自主。
|
||||||
|
{"get_config", "", false},
|
||||||
|
{"memory_search", "", false},
|
||||||
|
{"count_tokens", "", false},
|
||||||
|
{"get_weather", "", false},
|
||||||
|
{"list_directory", "", false},
|
||||||
|
{"read_file", "", false},
|
||||||
|
{"asset_metadata_loader", "", false},
|
||||||
|
{"", "", false},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
op, _, sensitive := SensitiveCall(tc.name, map[string]any{"path": "/data/x"})
|
||||||
|
if sensitive != tc.sensitive {
|
||||||
|
t.Fatalf("SensitiveCall(%q) sensitive = %v, want %v(op=%q)", tc.name, sensitive, tc.sensitive, op)
|
||||||
|
}
|
||||||
|
if !tc.sensitive {
|
||||||
|
if op != "" {
|
||||||
|
t.Fatalf("SensitiveCall(%q) 非敏感时不应给出类别,得到 %q", tc.name, op)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if op != tc.wantOp {
|
||||||
|
t.Fatalf("SensitiveCall(%q) op = %q, want %q", tc.name, op, tc.wantOp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSensitiveCall_RealToolSurfaceStaysAutonomous 是本轮判据扩展的**假阳性
|
||||||
|
// 守卫**:项目当前真实注册的工具(plugins/tools/basic、plugins/tools/io、
|
||||||
|
// terminal/file/http、session_read、subagent_spawn)在正常调用参数下一律**不**
|
||||||
|
// 被判成敏感(terminal/file/http 的敏感参数另见上面的表)。任何新判据若把它们
|
||||||
|
// 拉进代审,就是误伤正常功能,此测试必须变红。
|
||||||
|
//
|
||||||
|
// subagent_spawn 刻意留在「非敏感」一侧:它不是 design/11 §4 七类清单中的任何
|
||||||
|
// 一类(派发本身不执行命令/不改文件/不发消息/不写数据),而子代理内部真正的敏感
|
||||||
|
// 调用与该派生它的回合共用同一组准入钩子,已在既有判据里被代审(见
|
||||||
|
// cmd/cyrene/wire_runtime.go 的 toolAdmissionHooks 与共享的 loop 实例)。
|
||||||
|
func TestSensitiveCall_RealToolSurfaceStaysAutonomous(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
args map[string]any
|
||||||
|
wantOp SensitiveOp
|
||||||
|
sensitive bool
|
||||||
|
}{
|
||||||
|
{"calculator", map[string]any{"expression": "1+1"}, "", false},
|
||||||
|
{"datetime", map[string]any{"format": "2006"}, "", false},
|
||||||
|
{"json", map[string]any{"operation": "format", "value": "{}"}, "", false},
|
||||||
|
{"markdown", map[string]any{"operation": "list", "items": []any{"a"}}, "", false},
|
||||||
|
{"random", map[string]any{"min": 1, "max": 2}, "", false},
|
||||||
|
{"text", map[string]any{"operation": "replace", "old": "a", "new": "b"}, "", false},
|
||||||
|
{"knowledge", map[string]any{"query": "x"}, "", false},
|
||||||
|
{"web-search", map[string]any{"query": "x"}, "", false},
|
||||||
|
{"web-fetch", map[string]any{"url": "https://example.com"}, "", false},
|
||||||
|
{"image-crop", map[string]any{"path": "/data/a.png"}, "", false},
|
||||||
|
{"document-read", map[string]any{"path": "/data/a.pdf"}, "", false},
|
||||||
|
{"session_read", map[string]any{"sessionId": "web"}, "", false},
|
||||||
|
{"subagent_spawn", map[string]any{"task": "查一下"}, "", false},
|
||||||
|
{"file", map[string]any{"action": "read", "path": "/data/uploads/a.md"}, "", false},
|
||||||
|
{"file", map[string]any{"action": "write", "path": "/data/uploads/a.md"}, SensitiveWriteFile, true},
|
||||||
|
{"http", map[string]any{"method": "GET", "url": "https://example.com"}, "", false},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
op, detail, sensitive := SensitiveCall(tc.name, tc.args)
|
||||||
|
if sensitive != tc.sensitive {
|
||||||
|
t.Fatalf("%s %v 被判为 sensitive=%v (op=%q, 摘要=%q),want %v:判据误伤/漏判",
|
||||||
|
tc.name, tc.args, sensitive, op, detail, tc.sensitive)
|
||||||
|
}
|
||||||
|
if sensitive && op != tc.wantOp {
|
||||||
|
t.Fatalf("%s %v op = %q, want %q", tc.name, tc.args, op, tc.wantOp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSensitiveCall_UnknownToolNeverSensitiveOnEmptyArgs 锁定「未知工具不新造硬
|
||||||
|
// 边界」:名字判不出且没有可判参数时一律自主(新工具不会在主人会话里被拦死)。
|
||||||
|
func TestSensitiveCall_UnknownToolNeverSensitiveOnEmptyArgs(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
for _, name := range []string{"some-new-tool", "mcp:server/tool", "foo-bar-baz"} {
|
||||||
|
if op, detail, sensitive := SensitiveCall(name, nil); sensitive {
|
||||||
|
t.Fatalf("SensitiveCall(%q, nil) = (%q, %q, true), want 自主", name, op, detail)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSensitiveCall_StoreActionParam 是「存储类工具 + 写动作参数」判据的判定表:
|
||||||
|
// 它让「写数据」不依赖具体工具名——将来落的记忆写入工具无论叫什么,只要名字带
|
||||||
|
// 存储对象词且用参数表达写动作,就会被代审覆盖。同时锁定**参数动词不许外溢**:
|
||||||
|
// 纯计算工具(text/json/markdown)的 operation 值再怎么像写动词也不敏感。
|
||||||
|
func TestSensitiveCall_StoreActionParam(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
args map[string]any
|
||||||
|
wantOp SensitiveOp
|
||||||
|
sensitive bool
|
||||||
|
}{
|
||||||
|
// 正例:存储类工具 + 写动作参数。
|
||||||
|
{"knowledge", map[string]any{"action": "write", "query": "a"}, SensitiveWriteData, true},
|
||||||
|
{"knowledge", map[string]any{"action": "add", "query": "a"}, SensitiveWriteData, true},
|
||||||
|
{"memory", map[string]any{"op": "insert", "text": "a"}, SensitiveWriteData, true},
|
||||||
|
{"notes", map[string]any{"mode": "update", "id": "1"}, SensitiveWriteData, true},
|
||||||
|
{"db", map[string]any{"command": "DELETE FROM memories"}, SensitiveWriteData, true},
|
||||||
|
// 反例:存储类工具但动作是读,或压根没有动作参数。
|
||||||
|
{"knowledge", map[string]any{"query": "a"}, "", false},
|
||||||
|
{"knowledge", map[string]any{"action": "read", "query": "a"}, "", false},
|
||||||
|
{"knowledge", map[string]any{"action": "search", "query": "a"}, "", false},
|
||||||
|
{"db", map[string]any{"action": "select"}, "", false},
|
||||||
|
// 反例(关键的假阳性守卫):名字里没有存储对象词,参数里的写动词不算数。
|
||||||
|
{"text", map[string]any{"operation": "replace", "old": "a", "new": "b"}, "", false},
|
||||||
|
{"json", map[string]any{"operation": "stringify", "value": "a"}, "", false},
|
||||||
|
{"markdown", map[string]any{"operation": "table"}, "", false},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
op, detail, sensitive := SensitiveCall(tc.name, tc.args)
|
||||||
|
if sensitive != tc.sensitive {
|
||||||
|
t.Fatalf("SensitiveCall(%s, %v) sensitive = %v (op=%q, 摘要=%q), want %v",
|
||||||
|
tc.name, tc.args, sensitive, op, detail, tc.sensitive)
|
||||||
|
}
|
||||||
|
if sensitive && op != tc.wantOp {
|
||||||
|
t.Fatalf("SensitiveCall(%s) op = %q, want %q", tc.name, op, tc.wantOp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSensitiveCall_NameRuleSummaryCarriesTarget 锁定按名判据的摘要也带目标:
|
||||||
|
// 审计与小字要能看出「想干什么」,且与既有 summarize 同一截断口径。
|
||||||
|
func TestSensitiveCall_NameRuleSummaryCarriesTarget(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
_, detail, sensitive := SensitiveCall("send_email", map[string]any{"to": "a@example.com"})
|
||||||
|
if !sensitive {
|
||||||
|
t.Fatal("send_email 应判为敏感")
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(detail, "send_email ") || !strings.Contains(detail, "a@example.com") {
|
||||||
|
t.Fatalf("摘要 = %q, want 工具名 + 目标", detail)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,240 @@
|
|||||||
|
package restrict
|
||||||
|
|
||||||
|
import "strings"
|
||||||
|
|
||||||
|
// 本文件是 design/11 §4 敏感清单里**按工具名/参数**分类的结构化判据,用于 `file` /
|
||||||
|
// `http` / `terminal` 之外的工具:MCP server 暴露的工具名由对端任意给出,插件的
|
||||||
|
// 工具名也随生态增长,因此这里按**名词词族 + 动词词族**做交集判定,而不是穷举名字。
|
||||||
|
//
|
||||||
|
// 判据只有工具名与调用参数两个输入,绝不含模型文本(design/11 §0 红线)。刻意
|
||||||
|
// 保持**保守**:宁可漏判也不误判——误判会把正常工具拉进代审、污染审计与前端小字,
|
||||||
|
// 而危险动作另有沙箱、策略闸门与具名工具的参数判据(`file`/`http`/`terminal`)兜底。
|
||||||
|
|
||||||
|
// writeNameWords 是写动作词族:工具名里出现它,才可能被判成「写」类操作
|
||||||
|
// (改配置与权限 / 写数据)。
|
||||||
|
var writeNameWords = []string{
|
||||||
|
"write", "add", "create", "update", "set", "save", "insert", "upsert",
|
||||||
|
"delete", "remove", "patch", "apply", "grant", "revoke", "reset", "clear",
|
||||||
|
"drop", "modify", "change", "edit", "mutate", "publish", "persist", "commit",
|
||||||
|
"enable", "disable", "register", "assign", "chmod", "chown", "append",
|
||||||
|
"exec", "execute",
|
||||||
|
}
|
||||||
|
|
||||||
|
// configNameWords 是「配置与权限」的对象词族:与 writeNameWords 同时命中 ⇒
|
||||||
|
// 改配置与权限。只读配置不算改配置,故这里要求动词也在场。
|
||||||
|
var configNameWords = []string{
|
||||||
|
"config", "setting", "preference", "policy", "permission", "acl",
|
||||||
|
"role", "privilege", "flag",
|
||||||
|
}
|
||||||
|
|
||||||
|
// permissionVerbWords 是「本身即权限变更」的动词:单独出现就足以判成改配置与
|
||||||
|
// 权限(grant/revoke/chmod 一类没有别的解释)。
|
||||||
|
var permissionVerbWords = []string{
|
||||||
|
"grant", "revoke", "chmod", "chown", "chgrp", "setfacl", "icacls", "cacls",
|
||||||
|
"umask", "setuid", "setgid",
|
||||||
|
}
|
||||||
|
|
||||||
|
// storeNameWords 是「数据存储」的对象词族:与 writeNameWords 同时命中 ⇒ 写数据
|
||||||
|
// (记忆 / 数据库 / 向量索引 / 结构化记录等持久化写入)。
|
||||||
|
var storeNameWords = []string{
|
||||||
|
"memory", "memories", "note", "record", "db", "database", "sql", "sqlite",
|
||||||
|
"store", "storage", "knowledge", "vector", "index", "collection", "table",
|
||||||
|
"metadata", "dataset", "data", "kv", "cache",
|
||||||
|
}
|
||||||
|
|
||||||
|
// actionArgKeys 是按参数表达动作的工具用的字段名(`knowledge` 之类工具若将来
|
||||||
|
// 加写入动作,多半会落在其中之一)。带 `command` 是为了 `db`/`sql` 一类「以命令
|
||||||
|
// 字符串表达操作」的存储工具。
|
||||||
|
var actionArgKeys = []string{"action", "op", "operation", "mode", "cmd", "command"}
|
||||||
|
|
||||||
|
// nameRule 是一条按名判据。exact 命中即中(用于 `send` / `remember` 这类单词
|
||||||
|
// 名);allOf 是**组间 AND、组内 OR** 的词族集合(用于「写动作 + 对象」交集)。
|
||||||
|
type nameRule struct {
|
||||||
|
op SensitiveOp
|
||||||
|
exact []string
|
||||||
|
allOf [][]string
|
||||||
|
}
|
||||||
|
|
||||||
|
// sensitiveNameRules 按优先级排列:先判对外发消息与密钥访问(只要名字沾边就是
|
||||||
|
// 敏感,不看动词),再判「写动作 + 对象」的交集,最后是本身即敏感的单动词。
|
||||||
|
var sensitiveNameRules = []nameRule{
|
||||||
|
{
|
||||||
|
op: SensitiveOutboundMessage,
|
||||||
|
exact: []string{
|
||||||
|
"send", "reply", "notify", "broadcast", "push", "publish",
|
||||||
|
},
|
||||||
|
// 对外发出内容:邮件/短信/渠道消息/Webhook 投递。词表按「按词重组后的
|
||||||
|
// 规范名」匹配,因此 `send_` / `_send_` 同时覆盖 send_email 与
|
||||||
|
// send-message / sendEmail(slack_send_message 命中 `_send_`)。
|
||||||
|
allOf: [][]string{{"send_", "_send_", "sendmail", "send_mail", "notify", "broadcast", "post_message", "message_send", "channel_send", "reply_message", "push_message"}},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
op: SensitiveSecretAccess,
|
||||||
|
// 密钥/凭据/口令/隐私:命中任一即敏感(读凭据本身就是敏感操作)。
|
||||||
|
allOf: [][]string{{"secret", "credential", "password", "passwd", "passphrase", "apikey", "api_key", "access_key", "private_key", "vault", "keychain", "dotenv", "read_env"}},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
op: SensitiveConfigChange,
|
||||||
|
allOf: [][]string{
|
||||||
|
permissionVerbWords,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
op: SensitiveConfigChange,
|
||||||
|
allOf: [][]string{writeNameWords, configNameWords},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
op: SensitiveWriteData,
|
||||||
|
exact: []string{"remember", "memorize"},
|
||||||
|
allOf: [][]string{writeNameWords, storeNameWords},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// matchNameRule 返回工具名命中的第一条判据;都不命中时 ok=false(非敏感)。
|
||||||
|
// 名字为空或纯空白一律不命中。判定只看工具名,与参数无关。
|
||||||
|
//
|
||||||
|
// 匹配口径:**按词**匹配(`_`/`-`/`.`/`:`/`/` 与驼峰切词),并要求词族是整词或
|
||||||
|
// 复数/派生形式——因此 `text` 的 `operation=replace`、`asset_metadata_loader`、
|
||||||
|
// `get_config`、`count_tokens` 这类近邻名字不会被带跑。
|
||||||
|
func matchNameRule(name string) (SensitiveOp, bool) {
|
||||||
|
n := strings.ToLower(strings.TrimSpace(name))
|
||||||
|
if n == "" {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
tokens := nameTokens(name)
|
||||||
|
for _, rule := range sensitiveNameRules {
|
||||||
|
for _, e := range rule.exact {
|
||||||
|
if n == e {
|
||||||
|
return rule.op, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if matchesAllOf(tokens, rule.allOf) {
|
||||||
|
return rule.op, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
// matchStoreAction 是「存储类工具 + 写动作参数」的结构化判据:工具名带存储对象
|
||||||
|
// 词,且调用参数里的动作字段是写动词 ⇒ 写数据。它覆盖**以参数表达动作**的
|
||||||
|
// 记忆/存储工具——design/11 §4 的「写数据」因此不依赖某个具体工具名,将来新增的
|
||||||
|
// 记忆写入工具(无论叫 `knowledge` 加 `action`、还是别的存储工具)都会被纳入。
|
||||||
|
func matchStoreAction(name string, args map[string]any) (SensitiveOp, bool) {
|
||||||
|
n := strings.ToLower(strings.TrimSpace(name))
|
||||||
|
if n == "" || !containsAnyToken(nameTokens(n), storeNameWords) {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
verb := strings.ToLower(strings.TrimSpace(actionArgValue(args)))
|
||||||
|
if verb == "" {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
for _, word := range writeNameWords {
|
||||||
|
if strings.Contains(verb, word) {
|
||||||
|
return SensitiveWriteData, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
// actionArgValue 取动作字段的值(actionArgKeys 里第一个非空字符串)。
|
||||||
|
func actionArgValue(args map[string]any) string {
|
||||||
|
for _, key := range actionArgKeys {
|
||||||
|
if v := argString(args, key); strings.TrimSpace(v) != "" {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// matchesAllOf 报告名字是否命中每一组(组内 OR、组间 AND);组为空视为不命中。
|
||||||
|
func matchesAllOf(tokens []string, groups [][]string) bool {
|
||||||
|
if len(groups) == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, group := range groups {
|
||||||
|
if !containsAnyToken(tokens, group) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// containsAnyToken 报告词表里是否有任一词命中。词表里的多词短语(`send_` /
|
||||||
|
// `api_key` / `post_message` 一类,自带分隔符)按**子串**匹配;其余按**整词**
|
||||||
|
// (含复数与 `config`→`configure` 这类派生)匹配。两种匹配都跑在「按词重组」的
|
||||||
|
// 规范名上,因此 `sendEmail`(驼峰)与 `send-email`(连字符)等价。
|
||||||
|
func containsAnyToken(tokens []string, words []string) bool {
|
||||||
|
joined := strings.Join(tokens, "_")
|
||||||
|
for _, word := range words {
|
||||||
|
if strings.ContainsAny(word, "_-") {
|
||||||
|
if strings.Contains(joined, word) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, token := range tokens {
|
||||||
|
if tokenMatches(token, word) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// tokenMatches 判定一个名字词是否等于词表里的词:整词、复数、或(词长 ≥5 时)
|
||||||
|
// 派生形式(`configuration` 命中 `config`)。
|
||||||
|
func tokenMatches(token, word string) bool {
|
||||||
|
switch {
|
||||||
|
case token == word, token == word+"s":
|
||||||
|
return true
|
||||||
|
case len(word) >= 5 && strings.HasPrefix(token, word):
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// nameTokens 把工具名切成小写词:分隔符 `_`/`-`/`.`/`:`/`/`/空格,并在驼峰边界
|
||||||
|
// 处再切一刀(`writeMemory` → write / memory)。
|
||||||
|
func nameTokens(name string) []string {
|
||||||
|
fields := strings.FieldsFunc(name, func(r rune) bool {
|
||||||
|
switch r {
|
||||||
|
case '_', '-', '.', ':', '/', ' ', '\t':
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
tokens := make([]string, 0, len(fields))
|
||||||
|
for _, f := range fields {
|
||||||
|
for _, part := range splitCamel(f) {
|
||||||
|
tokens = append(tokens, strings.ToLower(part))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return tokens
|
||||||
|
}
|
||||||
|
|
||||||
|
// splitCamel 在小写↔大写边界切开一个驼峰词;非驼峰词原样返回一个元素。
|
||||||
|
func splitCamel(s string) []string {
|
||||||
|
out := make([]string, 0, 2)
|
||||||
|
start := 0
|
||||||
|
for i := 1; i < len(s); i++ {
|
||||||
|
if s[i-1] >= 'a' && s[i-1] <= 'z' && s[i] >= 'A' && s[i] <= 'Z' {
|
||||||
|
out = append(out, s[start:i])
|
||||||
|
start = i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return append(out, s[start:])
|
||||||
|
}
|
||||||
|
|
||||||
|
// argTarget 从参数里挑一个「动作目标」进摘要(路径/地址/收件人/名字),使审计与
|
||||||
|
// 前端小字能一眼看出「想干什么」。取不到时返回 "",摘要退化为纯工具名。
|
||||||
|
// 与 summarize 一样会截断,避免把长参数或秘密整段搬进日志。
|
||||||
|
func argTarget(args map[string]any) string {
|
||||||
|
for _, key := range []string{"path", "target", "url", "to", "recipient", "channel", "name", "id", "query"} {
|
||||||
|
if v := argString(args, key); strings.TrimSpace(v) != "" {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
package restrict
|
||||||
|
|
||||||
|
import "strings"
|
||||||
|
|
||||||
|
// 本文件是 design/11 §4 敏感清单里**按文件落点**分类的结构化判据表:同一个
|
||||||
|
// `file` 调用,落点不同则操作类别不同——写配置件是「改配置与权限」、写数据
|
||||||
|
// 存储件是「写数据」、写别处是「修改文件」;读密钥件是「访问密钥与隐私」,
|
||||||
|
// 读别处仍可自主(design/11 §4「非敏感(读文件 / 检索 / 查记忆)可自主」)。
|
||||||
|
//
|
||||||
|
// 判据只有**路径字符串**这一个输入(工具名 + 参数),不含任何模型文本
|
||||||
|
// (design/11 §0 红线:身份与权限永不来自提示词)。
|
||||||
|
|
||||||
|
// 配置与权限的落点(改配置与权限)。这些文件承载模型/渠道/工具策略,改动它们
|
||||||
|
// 等于改动实例的行为与权限边界,因此比普通「修改文件」更敏感:
|
||||||
|
// - `.env`(含 `.env.*`):实例级开关与凭据,design/04 §4 的「.env.example 同步」对象;
|
||||||
|
// - `models.json` / `channels.json`:模型路由与渠道凭据(design/04 §7、design/08);
|
||||||
|
// - `mcp.json` / `plugins.json` / `policy.json` / `permissions.json` / `acl.json`:
|
||||||
|
// MCP server、插件与工具策略(design/11 §3 策略落盘的相邻载体)。
|
||||||
|
var configArtifactBases = map[string]bool{
|
||||||
|
".env": true,
|
||||||
|
"models.json": true,
|
||||||
|
"channels.json": true,
|
||||||
|
"mcp.json": true,
|
||||||
|
"plugins.json": true,
|
||||||
|
"policy.json": true,
|
||||||
|
"permissions.json": true,
|
||||||
|
"acl.json": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
// configDirSegments 是「目录即配置区」的判据:这些目录下的 `.yaml/.yml/.toml/
|
||||||
|
// .ini/.conf` 都是实例配置(角色定义、技能定义、用户档),改动同样归入
|
||||||
|
// 改配置与权限,而不是普通改文件。
|
||||||
|
var configDirSegments = map[string]bool{
|
||||||
|
"configs": true,
|
||||||
|
"config": true,
|
||||||
|
"personas": true,
|
||||||
|
"users": true,
|
||||||
|
"skills": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
// configFileExts 是配置区里作为配置对待的扩展名。
|
||||||
|
var configFileExts = map[string]bool{
|
||||||
|
".yaml": true,
|
||||||
|
".yml": true,
|
||||||
|
".toml": true,
|
||||||
|
".ini": true,
|
||||||
|
".conf": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
// 密钥与隐私的落点(访问密钥与隐私)。这些文件里的内容**不许进日志、不许进
|
||||||
|
// 模型上下文**(design/04 §4「models.json/channels.json 含 token/key(敏感)→
|
||||||
|
// 不打印、不进日志」),所以连「读」都算敏感:
|
||||||
|
// - `.env`(含 `.env.*`):凭据;
|
||||||
|
// - `models.json` / `channels.json`:供应商与渠道 token/key;
|
||||||
|
// - 密钥材料:`*.pem` / `*.key` / `*.p12` / `*.pfx` / `*.jks` / `*.keystore` /
|
||||||
|
// `*.ppk`,以及 SSH 私钥 `id_rsa*` / `id_dsa*` / `id_ecdsa*` / `id_ed25519*`;
|
||||||
|
// - 凭据库:`.netrc` / `.npmrc` / `.git-credentials` / `credentials*.json` /
|
||||||
|
// `secrets*.json`,以及 `secrets` / `credentials` 目录下的任何文件。
|
||||||
|
var secretArtifactBases = map[string]bool{
|
||||||
|
".env": true,
|
||||||
|
"models.json": true,
|
||||||
|
"channels.json": true,
|
||||||
|
".netrc": true,
|
||||||
|
".npmrc": true,
|
||||||
|
".git-credentials": true,
|
||||||
|
"credentials": true,
|
||||||
|
"credentials.json": true,
|
||||||
|
"credential.json": true,
|
||||||
|
"secrets.json": true,
|
||||||
|
"secret.json": true,
|
||||||
|
"passwd": true,
|
||||||
|
"shadow": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
// secretArtifactPrefixes 按前缀命中的密钥件(如 `id_rsa` / `id_ed25519.pub` 之外
|
||||||
|
// 的私钥族)——`id_rsa.pub` 是公钥,不含秘密,故单独排除。
|
||||||
|
var secretArtifactPrefixes = []string{"id_rsa", "id_dsa", "id_ecdsa", "id_ed25519"}
|
||||||
|
|
||||||
|
// secretArtifactExts 是密钥材料的扩展名。
|
||||||
|
var secretArtifactExts = map[string]bool{
|
||||||
|
".pem": true,
|
||||||
|
".key": true,
|
||||||
|
".p12": true,
|
||||||
|
".pfx": true,
|
||||||
|
".jks": true,
|
||||||
|
".keystore": true,
|
||||||
|
".ppk": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
// secretDirSegments 是「目录本身即密钥区」的判据。
|
||||||
|
var secretDirSegments = map[string]bool{"secrets": true, "credentials": true}
|
||||||
|
|
||||||
|
// dataStoreSuffixes 是数据存储的落点(写=写数据,读=访问密钥与隐私:库里是
|
||||||
|
// 主人的会话、记忆与审计数据,design/16 §5「敏感:本地 SQLite」)。SQLite 的
|
||||||
|
// WAL/SHM 同伴文件同属该存储。
|
||||||
|
var dataStoreSuffixes = []string{".db", ".sqlite", ".sqlite3", ".db-wal", ".db-shm", ".sqlite-wal", ".sqlite-shm"}
|
||||||
|
|
||||||
|
// isConfigArtifact 报告路径是否落在配置与权限的载体上(design/11 §4)。
|
||||||
|
func isConfigArtifact(p string) bool {
|
||||||
|
segs, base := splitPath(p)
|
||||||
|
if base == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if base == ".env" || strings.HasPrefix(base, ".env.") || configArtifactBases[base] {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return hasSegment(segs, configDirSegments) && extOf(base) != "" && configFileExts[extOf(base)]
|
||||||
|
}
|
||||||
|
|
||||||
|
// isSecretArtifact 报告路径是否落在密钥与隐私的载体上(design/11 §4)。
|
||||||
|
func isSecretArtifact(p string) bool {
|
||||||
|
segs, base := splitPath(p)
|
||||||
|
if base == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if base == ".env" || strings.HasPrefix(base, ".env.") || secretArtifactBases[base] {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if secretArtifactExts[extOf(base)] {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
for _, prefix := range secretArtifactPrefixes {
|
||||||
|
if strings.HasPrefix(base, prefix) && !strings.HasSuffix(base, ".pub") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return hasSegment(segs, secretDirSegments)
|
||||||
|
}
|
||||||
|
|
||||||
|
// isDataStoreArtifact 报告路径是否是数据存储文件(SQLite 及同伴文件)。
|
||||||
|
func isDataStoreArtifact(p string) bool {
|
||||||
|
_, base := splitPath(p)
|
||||||
|
if base == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, suffix := range dataStoreSuffixes {
|
||||||
|
if strings.HasSuffix(base, suffix) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// splitPath 把路径规范化成小写分段与末段:同时接受 Windows 反斜杠与 POSIX
|
||||||
|
// 斜杠,丢弃空段(因此 `data//models.json` 与 `/data/models.json` 判成同一类)。
|
||||||
|
// 这里刻意只做**词法**规范化,与 file 工具的沙箱收敛/文件级互斥同口径:不做
|
||||||
|
// 符号链接解析,也不假装能识别 8.3 短名等物理同一写法。
|
||||||
|
func splitPath(p string) ([]string, string) {
|
||||||
|
s := strings.ToLower(strings.TrimSpace(p))
|
||||||
|
s = strings.ReplaceAll(s, "\\", "/")
|
||||||
|
segs := make([]string, 0, 4)
|
||||||
|
for _, seg := range strings.Split(s, "/") {
|
||||||
|
if seg != "" {
|
||||||
|
segs = append(segs, seg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(segs) == 0 {
|
||||||
|
return nil, ""
|
||||||
|
}
|
||||||
|
return segs, segs[len(segs)-1]
|
||||||
|
}
|
||||||
|
|
||||||
|
// hasSegment 报告分段里是否出现集合中的任一名字(目录判据用)。
|
||||||
|
func hasSegment(segs []string, set map[string]bool) bool {
|
||||||
|
for _, seg := range segs {
|
||||||
|
if set[seg] {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// extOf 返回小写扩展名(含点);无扩展名返回 ""。
|
||||||
|
func extOf(base string) string {
|
||||||
|
i := strings.LastIndex(base, ".")
|
||||||
|
if i <= 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return base[i:]
|
||||||
|
}
|
||||||
@@ -19,6 +19,9 @@ func TestSensitiveCall(t *testing.T) {
|
|||||||
{name: "terminal", args: map[string]any{"command": "rm -rf /tmp/x"}, wantOp: SensitiveExecCommand, wantIn: "rm -rf /tmp/x", sensitive: true},
|
{name: "terminal", args: map[string]any{"command": "rm -rf /tmp/x"}, wantOp: SensitiveExecCommand, wantIn: "rm -rf /tmp/x", sensitive: true},
|
||||||
{name: "terminal", args: nil, wantOp: SensitiveExecCommand, wantIn: "terminal", sensitive: true},
|
{name: "terminal", args: nil, wantOp: SensitiveExecCommand, wantIn: "terminal", sensitive: true},
|
||||||
{name: "file", args: map[string]any{"action": "write", "path": "/data/a.md"}, wantOp: SensitiveWriteFile, wantIn: "/data/a.md", sensitive: true},
|
{name: "file", args: map[string]any{"action": "write", "path": "/data/a.md"}, wantOp: SensitiveWriteFile, wantIn: "/data/a.md", sensitive: true},
|
||||||
|
{name: "file", args: map[string]any{"action": "write", "path": "/data/models.json"}, wantOp: SensitiveConfigChange, wantIn: "models.json", sensitive: true},
|
||||||
|
{name: "file", args: map[string]any{"action": "write", "path": "/data/cyrene.db"}, wantOp: SensitiveWriteData, wantIn: "cyrene.db", sensitive: true},
|
||||||
|
{name: "file", args: map[string]any{"action": "read", "path": "/data/.env"}, wantOp: SensitiveSecretAccess, wantIn: "/data/.env", sensitive: true},
|
||||||
{name: "file", args: map[string]any{"action": "read", "path": "/data/a.md"}},
|
{name: "file", args: map[string]any{"action": "read", "path": "/data/a.md"}},
|
||||||
{name: "file", args: map[string]any{"action": "list"}},
|
{name: "file", args: map[string]any{"action": "list"}},
|
||||||
{name: "file", args: map[string]any{"action": "search", "query": "x"}},
|
{name: "file", args: map[string]any{"action": "search", "query": "x"}},
|
||||||
@@ -32,6 +35,9 @@ func TestSensitiveCall(t *testing.T) {
|
|||||||
{name: "web-fetch", args: map[string]any{"url": "https://example.com"}},
|
{name: "web-fetch", args: map[string]any{"url": "https://example.com"}},
|
||||||
{name: "session_read", args: map[string]any{"sessionId": "web"}},
|
{name: "session_read", args: map[string]any{"sessionId": "web"}},
|
||||||
{name: "calculator", args: map[string]any{"expression": "1+1"}},
|
{name: "calculator", args: map[string]any{"expression": "1+1"}},
|
||||||
|
// 纯计算工具的参数里也出现写动作词(text 的 operation=replace),
|
||||||
|
// 判据必须只看**工具名**,绝不能被参数里的动词带跑。
|
||||||
|
{name: "text", args: map[string]any{"operation": "replace", "old": "a", "new": "b"}},
|
||||||
{name: "mcp:some-server/tool", args: map[string]any{"anything": true}},
|
{name: "mcp:some-server/tool", args: map[string]any{"anything": true}},
|
||||||
{name: "unknown-tool", args: nil},
|
{name: "unknown-tool", args: nil},
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user