241 lines
9.0 KiB
Go
241 lines
9.0 KiB
Go
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 ""
|
||
}
|