Files
Cyrene_v2/internal/capability/restrict/sensitive_names.go
T
AskaEth 85abb90067
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(security): 敏感清单补齐 design/11 §4 的 7 类(写数据/对外发消息/访问密钥/改配置,结构化判据)
2026-10-05 11:53:17 +08:00

241 lines
9.0 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 ""
}