Files
sundynix-agentix/sundynix-desktop/localexec.go
T
Blizzard a16229573b feat(jarvis): 本地沙箱改多目录白名单 + 让它知道自己能操作这台电脑
单目录沙箱做不了"操作我电脑"——只能在一个文件夹里打转,跨目录整理直接没戏。
且模型压根不知道 shell 除了跑脚本还能开 App、控 App、触发快捷指令。

沙箱:单根 → 多根白名单
- 用户授权多个目录(设置里每行一个,可一键填入桌面/下载/文档),其余一律拒
- 路径改绝对路径(多根之下相对路径没有唯一含义),支持 ~ 展开,相对路径兜底按首个根解释
- local_list_dir 留空 path = 返回授权目录清单 → 模型据此自己发现"我能访问哪儿"
- local_exec 可指定 cwd(须在授权目录内)
- 防逃逸不变:软链解析后必须落在某个根内,越界即拒(单测覆盖 ../ 与软链逃逸)

告诉模型它能干什么
- local_exec 描述展开:文件整理(mv/cp/find)、mdfind 全盘搜、open 开应用/文件/网址、
  osascript 控制 Mac App、shortcuts run/list 触发快捷指令、系统信息
- 语音系统提示词把 JARVIS 定位成"这台电脑的操作者",并要求先想清用哪个工具再动手

live 验证(两个授权目录):
① "你能访问哪些目录,里面有什么" → 自主先查授权清单、再逐个列举,答全对
② "把下载里的图片挪到桌面" → 自主 mv,文件真的跨目录移动了

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 15:42:58 +08:00

215 lines
7.8 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 main
import (
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"regexp"
"strings"
"sync"
"time"
"github.com/wailsapp/wails/v3/pkg/application"
)
// 本地「能动的手」:写文件 + 执行命令(JARVIS_BRAIN_DESIGN P4 第二阶段)。
// 与只读那半(localrunner.go)共用沙箱与连接,但风险等级完全不同,因此叠三道闸:
//
// 1. 独立开关:用户必须**单独**打开"允许执行命令",只开只读访问不给这个能力;
// 2. 硬黑名单:删库/提权/管道下载执行/写系统路径等,**用户点同意也不执行**;
// 3. 强制审批:每次 exec / write 弹原生确认框,展示将要执行的原文,**默认按钮是拒绝**,
// 60 秒无人应答按拒绝处理(防无人值守时被静默批准)。
//
// 沙箱:写路径必须在 workdir 内(复用 resolveInRoot);命令的工作目录固定为 workdir。
const (
execTimeout = 60 * time.Second // 单条命令最长执行时间
execMaxOutput = 16 * 1024 // 回传输出上限(截断,防刷屏/撑爆 LLM 上下文)
approveWait = 60 * time.Second // 审批等待上限,超时按拒绝
)
// denyPatterns 是硬黑名单:命中即拒,**不弹审批框**(用户想同意也不给同意的机会)。
// 目标是那些"一旦跑了就没法回头"或"绕过本沙箱意义"的操作。
var denyPatterns = []*regexp.Regexp{
regexp.MustCompile(`(^|[\s;&|])rm\s+(-\w*\s+)*-\w*[rf]`), // rm -rf / rm -fr 等递归强删
regexp.MustCompile(`(^|[\s;&|])sudo(\s|$)`), // 提权
regexp.MustCompile(`(^|[\s;&|])su(\s|$)`),
regexp.MustCompile(`(^|[\s;&|])(shutdown|reboot|halt)(\s|$)`),
regexp.MustCompile(`(^|[\s;&|])(mkfs\S*|diskutil|fdisk)(\s|$)`), // 格式化/分区(mkfs.ext4 等带后缀变体)
regexp.MustCompile(`(^|[\s;&|])dd\s+.*of=/dev/`), // 裸写块设备
regexp.MustCompile(`(curl|wget)[^|;]*\|\s*(sh|bash|zsh|python)`), // 管道下载执行
regexp.MustCompile(`(^|[\s;&|])chmod\s+(-\w+\s+)*777`),
regexp.MustCompile(`>\s*/(etc|usr|bin|sbin|System|Library)/`), // 重定向写系统路径
regexp.MustCompile(`(^|[\s;&|])launchctl(\s|$)`), // 装/改开机项
regexp.MustCompile(`(^|[\s;&|])(crontab|at)\s`), // 装定时任务(绕过本审批)
regexp.MustCompile(`~/\.(ssh|aws|gnupg)`), // 摸凭据目录
regexp.MustCompile(`(^|[\s;&|])(security|keychain)`), // macOS 钥匙串
regexp.MustCompile(`:\(\)\s*\{.*\}\s*;\s*:`), // fork 炸弹
}
// checkDenied 命中黑名单返回原因;空串=未命中。
func checkDenied(cmd string) string {
low := strings.ToLower(cmd)
for _, re := range denyPatterns {
if re.MatchString(low) {
return "命令命中安全黑名单(删库/提权/写系统路径/装开机项/摸凭据等一律禁止),已拒绝执行"
}
}
return ""
}
// execGate 管"允许执行命令"开关 + 本次会话的批准记忆。
type execGate struct {
mu sync.Mutex
enabled bool
sessionAllow bool // 用户点过"本次会话都允许"
}
var gate execGate
// SetLocalExecEnabled 单独开/关"允许 JARVIS 执行命令与写文件"(前端设置项调用)。
// 关闭时同时清掉会话批准记忆——关了就是彻底关,不留后门。
func (a *App) SetLocalExecEnabled(on bool) {
gate.mu.Lock()
gate.enabled = on
if !on {
gate.sessionAllow = false
}
gate.mu.Unlock()
}
// LocalExecEnabled 供前端显示开关状态。
func (a *App) LocalExecEnabled() bool {
gate.mu.Lock()
defer gate.mu.Unlock()
return gate.enabled
}
func execAllowed() bool {
gate.mu.Lock()
defer gate.mu.Unlock()
return gate.enabled
}
// askApproval 弹原生确认框要用户批准一次危险操作。
// 默认按钮=拒绝;超时=拒绝;用户可选"本次会话都允许"(关开关即失效)。
func askApproval(title, detail string) bool {
gate.mu.Lock()
if gate.sessionAllow {
gate.mu.Unlock()
return true
}
gate.mu.Unlock()
ch := make(chan int, 1)
dlg := application.Get().Dialog.Question()
dlg.SetTitle(title)
dlg.SetMessage(detail + "\n\n只有你点「允许」才会执行。")
deny := dlg.AddButton("拒绝")
deny.OnClick(func() { ch <- 0 })
deny.SetAsCancel()
dlg.AddButton("允许这一次").OnClick(func() { ch <- 1 })
dlg.AddButton("本次会话都允许").OnClick(func() { ch <- 2 })
dlg.SetDefaultButton(deny) // 默认拒绝:手滑回车不会批准
dlg.Show()
var choice int
select {
case choice = <-ch:
case <-time.After(approveWait):
return false // 无人值守 → 拒绝
}
if choice == 2 {
gate.mu.Lock()
gate.sessionAllow = true
gate.mu.Unlock()
}
return choice > 0
}
// execWrite 写文件(沙箱内 + 审批)。
func execWrite(roots []string, req *runnerReq) *runnerResp {
fail := func(m string) *runnerResp { return &runnerResp{ID: req.ID, OK: false, Error: m} }
if !execAllowed() {
return fail("用户未开启「允许执行命令与写文件」。请如实告知用户需要在 JARVIS 设置里打开。")
}
rel, _ := req.Args["path"].(string)
content, _ := req.Args["content"].(string)
if strings.TrimSpace(rel) == "" {
return fail("缺少文件路径")
}
p, err := resolveAllowed(roots, rel)
if err != nil {
return fail(err.Error())
}
preview := content
if r := []rune(preview); len(r) > 400 {
preview = string(r[:400]) + "\n…(共 " + fmt.Sprint(len(r)) + " 字)"
}
if !askApproval("JARVIS 想写入文件", "文件:"+p+"\n\n内容预览:\n"+preview) {
return fail("用户拒绝了这次写入。")
}
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
return fail("建目录失败: " + err.Error())
}
if err := os.WriteFile(p, []byte(content), 0o644); err != nil {
return fail("写入失败: " + err.Error())
}
return &runnerResp{ID: req.ID, OK: true, Content: fmt.Sprintf("已写入 %s%d 字节)", rel, len(content))}
}
// execCommand 在沙箱工作目录里跑一条命令(黑名单 + 审批 + 超时 + 输出截断)。
func execCommand(roots []string, req *runnerReq) *runnerResp {
fail := func(m string) *runnerResp { return &runnerResp{ID: req.ID, OK: false, Error: m} }
if !execAllowed() {
return fail("用户未开启「允许执行命令与写文件」。请如实告知用户需要在 JARVIS 设置里打开。")
}
cmdStr, _ := req.Args["command"].(string)
cmdStr = strings.TrimSpace(cmdStr)
if cmdStr == "" {
return fail("缺少命令")
}
if reason := checkDenied(cmdStr); reason != "" {
return fail(reason) // 硬拒:连审批框都不弹
}
cwd := ""
if len(roots) > 0 {
cwd = roots[0]
}
// 模型可指定在哪个授权目录里跑(不给就用第一个);越界目录直接拒。
if d, _ := req.Args["cwd"].(string); strings.TrimSpace(d) != "" {
resolved, rerr := resolveAllowed(roots, d)
if rerr != nil {
return fail(rerr.Error())
}
cwd = resolved
}
if !askApproval("JARVIS 想在你的电脑上执行命令", "工作目录:"+cwd+"\n\n命令:\n"+cmdStr) {
return fail("用户拒绝了这次执行。")
}
ctx, cancel := context.WithTimeout(context.Background(), execTimeout)
defer cancel()
c := exec.CommandContext(ctx, "/bin/sh", "-c", cmdStr)
c.Dir = cwd // 工作目录锁在授权目录内
out, err := c.CombinedOutput()
text := string(out)
if len(text) > execMaxOutput {
text = text[:execMaxOutput] + "\n…(输出过长已截断)"
}
if ctx.Err() == context.DeadlineExceeded {
return fail("命令执行超时(超过 60 秒已终止)。输出片段:\n" + text)
}
if err != nil {
// 非零退出不算工具失败:把输出交给模型判断(编译错误/测试失败都是有用信息)。
return &runnerResp{ID: req.ID, OK: true, Content: fmt.Sprintf("命令退出码非零(%v)。输出:\n%s", err, text)}
}
if strings.TrimSpace(text) == "" {
text = "(命令执行成功,无输出)"
}
return &runnerResp{ID: req.ID, OK: true, Content: text}
}