Files
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

403 lines
13 KiB
Go
Raw Permalink 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 (
"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 ""
}