feat(security): 敏感清单补齐 design/11 §4 的 7 类(写数据/对外发消息/访问密钥/改配置,结构化判据)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

This commit is contained in:
AskaEth committed 2026-10-05 11:53:17 +08:00
1 parent 509dc412eb
commit 85abb90067
6 files changed
+813 -18

No files matched your search

+2 -1
View File
@@ -40,7 +40,8 @@ type ApprovalEvent struct {
Actor string
// Tool 是敏感工具名(如 terminal)。
Tool string
// Op 是敏感操作类别(执行命令 / 修改文件 / 网络访问)。
// Op 是敏感操作类别(design/11 §4 的 7 类:执行命令 / 修改文件 / 写数据 /
// 对外发消息 / 访问密钥与隐私 / 改配置与权限 / 网络访问)。
Op SensitiveOp
// Detail 是动作摘要(工具 + 关键参数,已截断)。
Detail string
+90 -17
View File
@@ -15,51 +15,124 @@ const (
SensitiveExecCommand SensitiveOp = "执行命令"
// SensitiveWriteFile 修改文件(file 工具的 write 动作)。
SensitiveWriteFile SensitiveOp = "修改文件"
// SensitiveWriteData 写数据:对数据存储的写入(数据库/存储文件的写、
// 记忆与索引类工具的写动作)。
SensitiveWriteData SensitiveOp = "写数据"
// SensitiveOutboundMessage 对外发消息:把内容投出本进程的发送类工具
// (邮件/短信/渠道消息/Webhook)。
SensitiveOutboundMessage SensitiveOp = "对外发消息"
// SensitiveSecretAccess 访问密钥与隐私:读写凭据、密钥材料与隐私数据
// (`.env` / `models.json` / 渠道 token / SSH 私钥 / 本地数据库)。
SensitiveSecretAccess SensitiveOp = "访问密钥与隐私"
// SensitiveConfigChange 改配置与权限:改动实例配置与权限边界
// (模型与渠道配置、MCP/插件清单、策略与角色定义)。
SensitiveConfigChange SensitiveOp = "改配置与权限"
// SensitiveNetwork 网络访问。只有会改变外部状态的写方法算敏感:
// GET/HEAD/OPTIONS 属「检索」,可自主(design/11 §4)。
SensitiveNetwork SensitiveOp = "网络访问"
)
// SensitiveOps 按 design/11 §4 的顺序列出**全部 7 类**敏感操作。它是该清单在
// 代码里的对账口径:测试断言 7 类齐全、且每一类都至少有一条结构化判据能命中。
var SensitiveOps = []SensitiveOp{
SensitiveExecCommand,
SensitiveWriteFile,
SensitiveWriteData,
SensitiveOutboundMessage,
SensitiveSecretAccess,
SensitiveConfigChange,
SensitiveNetwork,
}
// sensitiveSummaryRunes 截断动作摘要:代审小字与审计只需一眼看出「想干什么」,
// 同时限制进入前端/日志的文本块大小(也降低把长参数里的秘密整段搬进日志的机会)。
const sensitiveSummaryRunes = 80
// 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 → 执行命令(命令原文进摘要)
// file (action=write) → 修改文件(路径进摘要)
// file (action=write) → 落点是配置件 ⇒ 改配置与权限;落点是数据存储件 ⇒
// 写数据;其余 ⇒ 修改文件(路径进摘要)
// file (read/list/search) → 落点是密钥件或数据存储件 ⇒ 访问密钥与隐私;
// 其余 ⇒ 非敏感(design/11 §4「读文件可自主」)
// http (method=POST) → 网络访问(URL 进摘要)
//
// 其余情况一律**不敏感**:file 的 read/list/search、http 的 GET/HEAD/OPTIONS、
// knowledge / web-search / web-fetch(检索)、calculator / datetime / text /
// json / markdown / random(纯计算)、vision / ocr / session_read(只读)——
// 与 design/11 §4「非敏感(读文件 / 检索 / 查记忆)可自主」一致。
// 其余情况一律**不敏感**:knowledge / web-search / web-fetch(检索)、
// calculator / datetime / text / json / markdown / random(纯计算)、
// vision / ocr / session_read(只读)——与 design/11 §4「非敏感(读文件 /
// 检索 / 查记忆)可自主」一致。
//
// 未知工具(含 MCP 工具)同样不敏感:工具面已由 registry 的策略闸门管理,
// 这里不新增「未知即拦截」的硬边界,否则任何新工具都会在主人会话里被拦死。
// 判不准的新工具同样不敏感:工具面已由 registry 的策略闸门管理,这里不新增
// 「未知即拦截」的硬边界,否则任何新工具都会在主人会话里被拦死。判据刻意保守
// 的理由同上:误判把正常工具拉进代审会污染审计与前端小字。
func SensitiveCall(name string, args map[string]any) (SensitiveOp, string, bool) {
switch name {
case "terminal":
return SensitiveExecCommand, summarize("terminal", argString(args, "command")), true
case "file":
if argString(args, "action") != "write" {
return "", "", false
}
return SensitiveWriteFile, summarize("file write", argString(args, "path")), true
return fileCall(args)
case "http":
method := strings.ToUpper(strings.TrimSpace(argString(args, "method")))
if method == "" || method == "GET" || method == "HEAD" || method == "OPTIONS" {
return "", "", false
return httpCall(args)
}
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:
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 截断(不切开
// 多字节字符);目标为空时只返回动作。
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: 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/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": "list"}},
{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: "session_read", args: map[string]any{"sessionId": "web"}},
{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: "unknown-tool", args: nil},
}