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