package agent import ( "path/filepath" "strings" ) // 凭据读取拦截。 // // 注入内容最直接的收益不是删库,而是"把 ~/.autogo/config.json 读出来贴进回复"—— // 密钥于是进入 transcript 与外发通道(bot 聊天、WebUI、子 agent 结果)。路径围栏只 // 管 read/write/edit/ls/grep,**bash 不受围栏约束**(见 internal/tools/guard.go), // 所以这里挂在和不可逆动作同一个强制审批门上:任何档位都要确认,没有交互审批人 // (bot 渠道、定时任务)直接拒绝。 // // ⚠ 与 dangerous.go 一样,这是减速带不是安全边界:变量间接、脚本里 open()、分块 base64 // 都可能绕过名字匹配。所以还有第二层 redactSecrets——本进程配置里的真实密钥字面量 // 永远不许出现在工具结果里,绕过匹配读到了也念不出来。 // secretPathHints 是凭据/密钥文件的特征路径(小写子串比较,任一参数里出现即算)。 // 目录类提示不带结尾斜杠:`tar czf k.tgz ~/.ssh`、`grep -r . ~/.ssh` 这类写法里 // 路径以目录名结尾,带斜杠就匹配不到(实测漏过)。 var secretPathHints = []string{ ".aws", ".ssh", ".gnupg", ".kube/config", ".docker/config.json", ".config/gcloud/", ".config/gh/hosts.yml", ".netrc", ".pypirc", ".npmrc", ".git-credentials", ".vault-token", "/etc/shadow", "/etc/gshadow", "id_rsa", "id_ed25519", "id_ecdsa", "id_dsa", "credentials.json", "credentials.properties", "service_account", "secrets.yaml", "secrets.yml", "autogo/config.json", // 本产品自己的 API key 就在这份配置里 } // secretPathExts 按扩展名判定密钥载体(证书/私钥/密钥库)。 var secretPathExts = map[string]bool{ ".pem": true, ".key": true, ".p12": true, ".pfx": true, ".kdbx": true, } // secretEnvDumpProgs:整份导出环境变量等于把 AUTOGO_API_KEY 之类摊开在对话里。 var secretEnvDumpProgs = map[string]bool{"env": true, "printenv": true, "set": true, "export": true} // secretNameHints:printenv/env 指名取变量时的密钥类命名。 var secretNameHints = []string{"api_key", "apikey", "token", "secret", "password", "passwd", "credential"} // detectSecretRead 判定一条命令是否在读取凭据/密钥。 func detectSecretRead(cmd string) (dangerHit, bool) { for _, seg := range splitSegments(cmd) { words := tokenize(seg) if len(words) == 0 { continue } if hit, ok := matchSecretRead(words); ok { return hit, true } } return dangerHit{}, false } // matchSecretRead 对一个命令段做三类判定:路径特征、整份环境倾倒、密钥管理类子命令。 func matchSecretRead(words []string) (dangerHit, bool) { i := skipWrappers(words) if i >= len(words) { return dangerHit{}, false } prog, args := words[i], words[i+1:] // 1) 任何位置出现凭据路径/密钥文件名(cat/less/head/jq/cp/scp/tar/curl -d@… 通用)。 for _, w := range words { low := strings.ToLower(w) for _, hint := range secretPathHints { if strings.Contains(low, hint) { return dangerHit{"secret-read", "读取凭据/密钥文件(内容会进入对话与外发通道)"}, true } } if isSecretCarrierName(baseNameOf(low)) { return dangerHit{"secret-read", "读取凭据/密钥文件(内容会进入对话与外发通道)"}, true } } // 2) 整份导出环境变量。 if secretEnvDumpProgs[prog] { if len(args) == 0 { return dangerHit{"secret-env-dump", "整份导出环境变量(里面通常有 API key/token)"}, true } for _, a := range args { low := strings.ToLower(a) if strings.HasPrefix(a, "-") { continue } for _, hint := range secretNameHints { if strings.Contains(low, hint) { return dangerHit{"secret-env-dump", "导出密钥类环境变量(" + a + ")"}, true } } } } // 3) 明确"把密钥取出来"的子命令。 if hit, ok := matchSecretDump(prog, args); ok { return hit, true } return dangerHit{}, false } // matchSecretDump 识别 keychain/vault/1password/gh 这类取值子命令。 func matchSecretDump(prog string, args []string) (dangerHit, bool) { sub, next := "", "" if len(args) > 0 && !strings.HasPrefix(args[0], "-") { sub = args[0] } if len(args) > 1 && !strings.HasPrefix(args[1], "-") { next = args[1] } switch prog { case "security": if strings.HasPrefix(sub, "find-") { return dangerHit{"secret-dump", "从 macOS 钥匙串取密码"}, true } case "secret-tool": if sub == "lookup" { return dangerHit{"secret-dump", "从 secret-service 取密码"}, true } case "gh": if sub == "auth" && next == "token" { return dangerHit{"secret-dump", "取出 GitHub CLI 的访问 token"}, true } case "vault": if sub == "kv" && (next == "get" || next == "lookup") { return dangerHit{"secret-dump", "从 Vault 读取密钥"}, true } case "op": if sub == "read" || sub == "reveal" { return dangerHit{"secret-dump", "从 1Password 读取凭据"}, true } case "aws": if sub == "configure" && next == "get" { return dangerHit{"secret-dump", "读出 aws 配置里的凭据"}, true } } return dangerHit{}, false } // baseNameOf 从参数里取出可能的文件名部分:处理 `--file=/path/.env`、`@/path/key`、 // `"$HOME/.env"` 这类带前缀的写法。 func baseNameOf(token string) string { s := strings.Trim(token, `"'`+"`") if i := strings.LastIndexAny(s, "=@:"); i >= 0 { s = s[i+1:] } return filepath.Base(strings.ToLower(s)) } // isSecretCarrierName 判断文件名是不是密钥载体:私钥/证书扩展名,或 .env 系列 // (.env.example/.sample/.template/.dist 这类模板不含真凭据,不算)。 func isSecretCarrierName(base string) bool { if secretPathExts[filepath.Ext(base)] { return true } if base == ".env" { return true } if strings.HasPrefix(base, ".env.") { for _, tpl := range []string{".example", ".sample", ".template", ".dist", ".tpl", ".in", ".md", ".save", ".bak"} { if strings.HasSuffix(base, tpl) { return false } } return true } return false } // redactSecrets 把文本里出现的自有密钥字面量换成占位符。 // // 这是第二层:路径匹配能被绕过(变量间接、脚本里 open()、分块 base64),但绕过去 // 读到的密钥值仍然进不了 transcript 与外发通道。副作用是用户即使批准了一次凭据读取, // 模型也看不到密钥明文——它不需要明文就能完成任务(改配置、比对、引用文件名)。 func (a *Agent) redactSecrets(s string) string { for _, sec := range a.Secrets { // 太短的值会误伤普通文本(比如 "token" 这类常见词),只脱敏有长度的密钥串。 if len(sec) < 8 || !strings.Contains(s, sec) { continue } s = strings.ReplaceAll(s, sec, "[REDACTED api_key]") } return s }