Files
AutoZero/agent/secrets.go
T
Sakurasan d5dac5a6fc feat: AutoZero 通用 Agent 框架(从 AutoGo 五阶段拆分完成)
llm(三协议 Provider)/ session(JSONL 持久化)/ tools(Registry、围栏、
builtin、computer)/ cdp(browser)/ skills / mcp / agent(循环、两级压缩、
编排、审批门控)+ 底座 jsonl/textx/execx/diag/scratch/spill/imagecache。

产品装配代码(config 桥接、schedule 工具、memory/plan/retro/prompt)
留在 AutoGo,宿主接入方式见 MIGRATION.md。
2026-10-26 00:54:46 +08:00

187 lines
6.7 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 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
}