package agent import ( "encoding/json" "strings" "git.gopher.ink/mirrors2/AutoZero/llm" ) // 动作级审批:识别「不可逆动作」,在需要人拍板的档位下强制确认。 // // 生效范围 = 除「完全访问」(ModeBypass) 与 dangerous=allow 之外的所有档位:bypass 是 // 人主动说的"全部自动放行,不再确认",这一档再暗中拒掉就等于把档位做成一个谎言(前端 // 文案已经这么写着,实测事故是 git push 被静默拒绝)。**凭据读取是例外**:它标着 // AlwaysAsk,任何档位都要问——"完全访问"授权的是干活,不是把 ~/.ssh 抄进回复。 // 无人可问的入口(-p / cron / 渠道)留在 ask 语义上 fail-closed。 // // ⚠ 这是**减速带,不是安全边界**。它拦的是「模型顺手敲了一条毁库/毁机命令」这类事故, // 不拦蓄意规避:`/bin/rm`、写个脚本再跑、`python -c "shutil.rmtree(...)"`、 // `printf|base64 -d|sh`、`C=rm; $C -rf /` 都能绕过。真正的边界需要沙箱/黑名单执行层 // (见 TODO 的 bash 沙箱项)。 // // 实现刻意**不用全文正则**——否则 `grep -r "rm -rf" /`、`git commit -m "fix rm -rf"` // 这类会把字符串当命令匹配而误报。做法是先在**命令位**切分与取词,再匹配规则。 // dangerHit 是一次危险判定结果。Reason 直接展示给用户。 type dangerHit struct { Rule string Reason string } // alwaysAskRules 是连「完全访问」(ModeBypass) 也不放行的规则:凭据读取。 // bypass 是人说的"别问我,把活干完",而把 ~/.ssh / API key 抄进回复不是他在授权的活, // 是提示注入的兑现方式——不需要人点头就能造成损失,所以不参与档位豁免。 // 新增凭据类规则时必须同时登记到这里,否则它会跟着 bypass 一起被放行。 var alwaysAskRules = map[string]bool{ "secret-read": true, "secret-env-dump": true, "secret-dump": true, } func (h dangerHit) alwaysAsk() bool { return alwaysAskRules[h.Rule] } // shellWrappers 是「前缀包装」,取其后的词作为真正的命令名。 var shellWrappers = map[string]bool{ "sudo": true, "doas": true, "env": true, "nohup": true, "command": true, "exec": true, "time": true, "timeout": true, "xargs": true, "nice": true, "ionice": true, "stdbuf": true, "setsid": true, } // detectDanger 判定一次工具调用是否为危险动作。目前只覆盖能取到命令串的执行类工具。 // // 顺序:先不可逆动作、后凭据读取——`rm ~/.aws/credentials` 这种既删又读的写法, // 展示给用户的理由应该是"删除不可恢复",而不是"读取凭据"。 func detectDanger(tc llm.ToolCall) (dangerHit, bool) { cmd := commandOf(tc.Name, tc.Input) if strings.TrimSpace(cmd) == "" { return dangerHit{}, false } if hit, ok := detectDangerInCommand(cmd, 0); ok { return hit, true } // 凭据读取不参与 bypass 豁免(见 alwaysAskRules)。 return detectSecretRead(cmd) } // maxShellDepth 限制 sh -c 递归层数,避免深层嵌套造成开销过大。 const maxShellDepth = 3 // detectDangerInCommand 在一条命令串里逐段检测。 func detectDangerInCommand(cmd string, depth int) (dangerHit, bool) { for _, seg := range splitSegments(cmd) { words := tokenize(seg) if len(words) == 0 { continue } if hit, ok := matchSegment(words, depth); ok { return hit, true } } // 管道把网络内容喂给解释器:整串匹配一次(跨段)。 if hit, ok := matchPipeToShell(cmd); ok { return hit, true } return dangerHit{}, false } // skipWrappers 返回真正的命令名所在下标:跳过前导的 `VAR=value` 赋值与包装命令 // (sudo/env/timeout 5s …)。matchSegment 与凭据判定共用同一套跳过规则。 func skipWrappers(words []string) int { i := 0 for i < len(words) { w := words[i] if strings.Contains(w, "=") && !strings.HasPrefix(w, "-") && !strings.Contains(w, "/") { i++ continue } if shellWrappers[w] { // 包装词后面没有真正的命令时,它自己就是被执行的程序: // 裸 `env`/`printenv` 是环境倾倒,不能被当包装词跳过(实测漏过)。 if i == len(words)-1 { break } i++ // timeout 需要跳过它的时长参数(如 timeout 5s cmd)。 if w == "timeout" && i < len(words) && !strings.HasPrefix(words[i], "-") { i++ } continue } break } return i } // matchSegment 跳过包装词后按 (prog, sub, args) 匹配规则表。 func matchSegment(words []string, depth int) (dangerHit, bool) { i := skipWrappers(words) if i >= len(words) { return dangerHit{}, false } prog := words[i] args := words[i+1:] sub := "" if len(args) > 0 && !strings.HasPrefix(args[0], "-") { sub = args[0] } // mkfs.ext4 / mkfs.xfs 等:带后缀的也算 mkfs。 if strings.HasPrefix(prog, "mkfs") { return dangerHit{"mkfs", "格式化/擦除文件系统,数据不可恢复"}, true } switch prog { case "rm": if hasShortFlag(args, 'r') || hasShortFlag(args, 'R') || hasAnyFlag(args, "--recursive") { return dangerHit{"rm-recursive", "递归删除文件(rm -r),删除内容不可恢复"}, true } // 仅 -f 且目标是根/家目录一类时才拦:`rm -f /tmp/x` 是正常操作,不能误报。 if hasShortFlag(args, 'f') || hasAnyFlag(args, "--force") { for _, a := range args { if strings.HasPrefix(a, "-") { continue } if isRootLikeTarget(a) { return dangerHit{"rm-force-path", "强制删除根/家目录一类路径,可能不可恢复"}, true } } } case "git": switch sub { case "push": if hasShortFlag(args, 'n') || hasAnyFlag(args, "--dry-run") { return dangerHit{}, false // 只演练,不推送 } if hasShortFlag(args, 'f') || hasAnyFlag(args, "--force", "--force-with-lease", "--delete") { return dangerHit{"git-push-force", "强制推送/删除远端分支,会覆盖远端历史且难以撤回"}, true } return dangerHit{"git-push", "推送到远端仓库,会发布本地提交"}, true case "reset": if hasAnyFlag(args, "--hard") { return dangerHit{"git-reset-hard", "git reset --hard 会丢弃未提交的改动"}, true } case "clean": if hasShortFlag(args, 'f') || hasAnyFlag(args, "--force") { return dangerHit{"git-clean", "git clean 会删除未跟踪的文件"}, true } } case "dd": // 只有写到**设备**才危险:`dd if=… of=/tmp/img` 是正常造镜像。 for _, a := range args { if strings.HasPrefix(a, "of=/dev/") || strings.HasPrefix(a, "of=/proc/") || strings.HasPrefix(a, "of=/sys/") { return dangerHit{"dd-of-device", "dd 会直接覆写设备,数据不可恢复"}, true } } case "mkfs", "wipefs": return dangerHit{"mkfs", "格式化/擦除文件系统,数据不可恢复"}, true case "shutdown", "reboot", "halt", "poweroff": return dangerHit{"host-control", "会关机/重启本机,影响所有正在运行的任务"}, true case "init": if len(args) > 0 && (args[0] == "0" || args[0] == "6") { return dangerHit{"host-control", "init 0/6 会关机或重启本机"}, true } case "docker": if sub == "system" && hasArg(args, "prune") { return dangerHit{"docker-prune", "docker system prune 会批量删除镜像/容器/网络"}, true } if sub == "volume" && hasArg(args, "prune") { return dangerHit{"docker-prune", "docker volume prune 会删除数据卷"}, true } if sub == "compose" && hasArg(args, "down") && (hasShortFlag(args, 'v') || hasAnyFlag(args, "--volumes")) { return dangerHit{"docker-compose-down-v", "compose down -v 会删除数据卷"}, true } case "kubectl": if sub == "delete" { return dangerHit{"kubectl-delete", "kubectl delete 会删除集群资源"}, true } case "find": if hasArg(args, "-delete") { return dangerHit{"find-delete", "find -delete 会批量删除匹配到的文件"}, true } if hasArg(args, "-exec") && hasArg(args, "rm") { return dangerHit{"find-exec-rm", "find -exec rm 会批量删除文件"}, true } case "chmod", "chown": if hasShortFlag(args, 'R') || hasAnyFlag(args, "--recursive") { for _, a := range args { if isSystemPathTarget(a) { return dangerHit{"perm-system", "递归修改系统路径的权限/属主,可能破坏系统"}, true } } } case "truncate": if hasArg(args, "-s") && hasArg(args, "0") { return dangerHit{"truncate-zero", "把文件截断为 0 字节,内容会丢失"}, true } case "psql", "mysql", "sqlite3", "mariadb": joined := strings.ToLower(strings.Join(args, " ")) if strings.Contains(joined, "drop table") || strings.Contains(joined, "drop database") || strings.Contains(joined, "truncate table") { return dangerHit{"sql-drop", "SQL drop/truncate 会不可恢复地删除数据"}, true } } // `sh -c "..."` / `bash -c '...'`:递归检查内层命令串。 if (prog == "sh" || prog == "bash" || prog == "zsh" || prog == "dash" || prog == "ksh") && (hasShortFlag(args, 'c') || hasAnyFlag(args, "--command")) && depth < maxShellDepth { for j, a := range args { if a == "-c" || a == "--command" { if j+1 < len(args) { return detectDangerInCommand(args[j+1], depth+1) } } } } return dangerHit{}, false } // matchPipeToShell 匹配「网络下载内容直接喂给解释器」(可能跨段)。 func matchPipeToShell(cmd string) (dangerHit, bool) { lower := strings.ToLower(cmd) if !strings.Contains(lower, "|") { return dangerHit{}, false } fetches := strings.Contains(lower, "curl") || strings.Contains(lower, "wget") if !fetches { return dangerHit{}, false } for _, interp := range []string{"| sh", "|sh", "| bash", "|bash", "| python", "|python", "| node", "|node", "| zsh"} { if strings.Contains(lower, interp) { return dangerHit{"pipe-to-shell", "把网络下载的内容直接交给解释器执行,等价于执行任意代码"}, true } } return dangerHit{}, false } // isRootLikeTarget 判断是否为根/家目录一类的高危删除目标。 func isRootLikeTarget(a string) bool { switch a { case "/", "/*", "~", "~/", "$HOME", "${HOME}", ".", "..": return true } return strings.HasPrefix(a, "/*") || strings.HasPrefix(a, "$HOME/") } // isSystemPathTarget 判断是否是系统级路径(用于 chmod/chown -R 的误报控制)。 func isSystemPathTarget(a string) bool { switch a { case "/", "/etc", "/usr", "/var", "/bin", "/sbin", "/lib", "/boot", "/root", "~", "$HOME": return true } return strings.HasPrefix(a, "/etc/") || strings.HasPrefix(a, "/usr/") || strings.HasPrefix(a, "/var/") } func hasAnyFlag(args []string, flags ...string) bool { for _, a := range args { for _, f := range flags { if a == f { return true } } } return false } // hasShortFlag 匹配短选项,**含合并写法**(-rf、-fdx、-Rv)——只做精确匹配会漏掉最常见的 // `rm -rf` 形态。 func hasShortFlag(args []string, letter byte) bool { for _, a := range args { if len(a) < 2 || a[0] != '-' || strings.HasPrefix(a, "--") { continue } if strings.IndexByte(a[1:], letter) >= 0 { return true } } return false } func hasArg(args []string, want string) bool { for _, a := range args { if a == want { return true } } return false } // splitSegments 按**未引用**的命令分隔符切段:; && || | 换行 &。 func splitSegments(cmd string) []string { var ( segs []string cur strings.Builder quote rune // 0=无,'\'' 或 '"' esc bool ) flush := func() { if s := strings.TrimSpace(cur.String()); s != "" { segs = append(segs, s) } cur.Reset() } for _, r := range cmd { switch { case esc: cur.WriteRune(r) esc = false case r == '\\' && quote != '\'': cur.WriteRune(r) esc = true case quote != 0: cur.WriteRune(r) if r == quote { quote = 0 } case r == '\'' || r == '"': quote = r cur.WriteRune(r) case r == ';' || r == '\n' || r == '&' || r == '|': flush() default: cur.WriteRune(r) } } flush() return segs } // tokenize 把一个命令段切成词:按空白切分,去掉成对的引号(保留引号内文本为一词)。 func tokenize(seg string) []string { var ( words []string cur strings.Builder quote rune ) flush := func() { if cur.Len() > 0 { words = append(words, cur.String()) cur.Reset() } } for _, r := range seg { switch { case quote != 0: if r == quote { quote = 0 } else { cur.WriteRune(r) } case r == '\'' || r == '"': quote = r case r == ' ' || r == '\t' || r == '\r': flush() case r == '(' || r == ')': flush() // 子 shell / 分组:只取其内的命令词 default: cur.WriteRune(r) } } flush() return words } // commandOf 从工具入参里取出命令串;非执行类工具返回 ""。 func commandOf(toolName, input string) string { if toolName != "bash" { return "" } var m map[string]any if err := json.Unmarshal([]byte(input), &m); err != nil { // 兼容 {"arguments":{...}} 包装的解析失败:退回整串(宁可多判一次)。 return input } if inner, ok := m["arguments"].(map[string]any); ok { m = inner } if s, ok := m["command"].(string); ok { return s } return "" }