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

@@ -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 ""
}