From 85abb90067bf578f2d6e01a4a8fb5ba4346084df Mon Sep 17 00:00:00 2001 From: AskaEth Date: Mon, 5 Oct 2026 11:53:17 +0800 Subject: [PATCH] =?UTF-8?q?feat(security):=20=E6=95=8F=E6=84=9F=E6=B8=85?= =?UTF-8?q?=E5=8D=95=E8=A1=A5=E9=BD=90=20design/11=20=C2=A74=20=E7=9A=84?= =?UTF-8?q?=207=20=E7=B1=BB=EF=BC=88=E5=86=99=E6=95=B0=E6=8D=AE/=E5=AF=B9?= =?UTF-8?q?=E5=A4=96=E5=8F=91=E6=B6=88=E6=81=AF/=E8=AE=BF=E9=97=AE?= =?UTF-8?q?=E5=AF=86=E9=92=A5/=E6=94=B9=E9=85=8D=E7=BD=AE=EF=BC=8C?= =?UTF-8?q?=E7=BB=93=E6=9E=84=E5=8C=96=E5=88=A4=E6=8D=AE=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/capability/restrict/approve.go | 3 +- internal/capability/restrict/sensitive.go | 107 ++++++- .../restrict/sensitive_categories_test.go | 295 ++++++++++++++++++ .../capability/restrict/sensitive_names.go | 240 ++++++++++++++ .../capability/restrict/sensitive_paths.go | 180 +++++++++++ .../capability/restrict/sensitive_test.go | 6 + 6 files changed, 813 insertions(+), 18 deletions(-) create mode 100644 internal/capability/restrict/sensitive_categories_test.go create mode 100644 internal/capability/restrict/sensitive_names.go create mode 100644 internal/capability/restrict/sensitive_paths.go diff --git a/internal/capability/restrict/approve.go b/internal/capability/restrict/approve.go index d6785e9..e58fd34 100644 --- a/internal/capability/restrict/approve.go +++ b/internal/capability/restrict/approve.go @@ -40,7 +40,8 @@ type ApprovalEvent struct { Actor string // Tool 是敏感工具名(如 terminal)。 Tool string - // Op 是敏感操作类别(执行命令 / 修改文件 / 网络访问)。 + // Op 是敏感操作类别(design/11 §4 的 7 类:执行命令 / 修改文件 / 写数据 / + // 对外发消息 / 访问密钥与隐私 / 改配置与权限 / 网络访问)。 Op SensitiveOp // Detail 是动作摘要(工具 + 关键参数,已截断)。 Detail string diff --git a/internal/capability/restrict/sensitive.go b/internal/capability/restrict/sensitive.go index 8bd0534..9a469bb 100644 --- a/internal/capability/restrict/sensitive.go +++ b/internal/capability/restrict/sensitive.go @@ -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 { diff --git a/internal/capability/restrict/sensitive_categories_test.go b/internal/capability/restrict/sensitive_categories_test.go new file mode 100644 index 0000000..e4fdb10 --- /dev/null +++ b/internal/capability/restrict/sensitive_categories_test.go @@ -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) + } +} diff --git a/internal/capability/restrict/sensitive_names.go b/internal/capability/restrict/sensitive_names.go new file mode 100644 index 0000000..4f1371e --- /dev/null +++ b/internal/capability/restrict/sensitive_names.go @@ -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 "" +} diff --git a/internal/capability/restrict/sensitive_paths.go b/internal/capability/restrict/sensitive_paths.go new file mode 100644 index 0000000..3aed719 --- /dev/null +++ b/internal/capability/restrict/sensitive_paths.go @@ -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:] +} diff --git a/internal/capability/restrict/sensitive_test.go b/internal/capability/restrict/sensitive_test.go index 9286966..a26fef4 100644 --- a/internal/capability/restrict/sensitive_test.go +++ b/internal/capability/restrict/sensitive_test.go @@ -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}, }