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。
403 lines
13 KiB
Go
403 lines
13 KiB
Go
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 ""
|
||
}
|