Files
sundynix-agentix/sundynix-desktop/localexec.go
T
Blizzard 348f1e0249 feat(jarvis): 能动的手(写文件/执行命令,三道闸) + 定时任务调度
此前 JARVIS 只能看不能动(本地工具纯只读)、也不会调度,补齐这两块。

【能动的手】local_write_file / local_exec,在用户自选工作目录内动手:
- 独立开关:只开只读访问不给这能力,须单独勾「允许写文件/执行命令」
- 原生确认框逐次审批:展示命令原文,默认按钮=拒绝,60s 无人应答按拒绝
  (防无人值守被静默批准);可选「本次会话都允许」,关开关即失效
- 硬黑名单:删库/提权/管道下载执行/写系统路径/装开机项/摸凭据等,
  用户点同意也不执行,连审批框都不弹。20 条危险命令 + 10 条正常命令单测
- 命令 cwd 锁沙箱根、60s 超时、输出 16KB 截断;非零退出不算失败(编译/测试
  错误对模型是有用信息)

【定时任务】sundynix_schedule + leader 锁 ticker(30s 扫) + 三个平台工具:
- 存自然语言指令而非编排图,到点走语音同一条关卡(preflightCore/launchCore)
  执行,跑完经语音事件主动播报结果
- 先推进 NextRunAt 再提交:提交失败也不会下轮重复捞起反复烧钱
- 停机期间错过的不补跑(补一堆历史提醒是骚扰),直接顺推到下一个未来时刻

【顺带修一个必崩的 bug】dispatcher 工具超时硬编码 3 秒,而审批要等人点
(60s)+执行(60s)——local_exec 100% 超时。改成工具在 list_tools 自报
timeout_sec(不在 dispatcher 硬编码工具名),超时链外松内紧:
dispatcher 160s > 网关 150s > runner 转发 140s > 桌面端 60+60s。

live 验证:①「写个 hello.sh 打印日期然后跑一下」→ 写+执行两步,文件真落磁盘
②「建个定时任务 35 秒后跑 wc -l」→ 到点自动触发 → 自主调 local_exec → 出结果

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 13:57:49 +08:00

203 lines
7.5 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(root 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 := resolveInRoot(root, 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(root 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) // 硬拒:连审批框都不弹
}
if !askApproval("JARVIS 想在你的电脑上执行命令", "工作目录:"+root+"\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 = root // 工作目录锁在沙箱根
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}
}