Files
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

106 lines
3.6 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 main
import (
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
)
// 沙箱是本地执行的安全命门:路径清洗/软链逃逸/越界读全都要拒——这里逐项钉死。
func newSandbox(t *testing.T) string {
t.Helper()
root := t.TempDir()
if err := os.WriteFile(filepath.Join(root, "a.txt"), []byte("hello"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(filepath.Join(root, "sub"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(root, "sub", "b.txt"), []byte("world"), 0o644); err != nil {
t.Fatal(err)
}
return root
}
func TestResolveInRootBlocksEscape(t *testing.T) {
root := newSandbox(t)
// 断言基准要用软链解析后的 rootmacOS 的 TempDir 在 /var(→/private/var 软链)下,
// resolveInRoot 返回的是解析后的绝对路径,拿未解析 root 做前缀比较会误报。
rootR, err := filepath.EvalSymlinks(root)
if err != nil {
rootR = root
}
for _, rel := range []string{"..", "../..", "../../etc/passwd", "sub/../../outside", "/etc/passwd"} {
p, err := resolveAllowed([]string{root}, rel)
// 前置 "/"+Clean 把绝对路径/.. 都钉回 root 下(如 root/etc/passwd,不算逃逸);
// 无论哪种形式,成功解析的结果都必须仍在 root(解析后)之内。
if err == nil && p != rootR && !strings.HasPrefix(p, rootR+string(filepath.Separator)) {
t.Fatalf("路径 %q 逃出了沙箱: %s", rel, p)
}
}
}
func TestResolveInRootBlocksSymlinkEscape(t *testing.T) {
root := newSandbox(t)
outside := t.TempDir()
if err := os.WriteFile(filepath.Join(outside, "secret.txt"), []byte("secret"), 0o644); err != nil {
t.Fatal(err)
}
link := filepath.Join(root, "evil")
if err := os.Symlink(outside, link); err != nil {
t.Skip("无法创建软链,跳过")
}
if _, err := resolveAllowed([]string{root}, "evil/secret.txt"); err == nil {
t.Fatal("软链逃逸未被拦截")
}
}
func TestExecLocalListAndRead(t *testing.T) {
root := newSandbox(t)
// list_dir 空路径 = 列出授权目录清单(模型据此发现能访问哪里)
resp := execLocal([]string{root}, &runnerReq{ID: "0", Tool: "local_list_dir", Args: map[string]any{"path": ""}})
if !resp.OK || !strings.Contains(resp.Content, "authorized_dirs") {
t.Fatalf("空路径应返回授权目录清单: ok=%v content=%s", resp.OK, resp.Content)
}
// list_dir 指定目录
resp = execLocal([]string{root}, &runnerReq{ID: "1", Tool: "local_list_dir", Args: map[string]any{"path": root}})
if !resp.OK {
t.Fatalf("list_dir 失败: %s", resp.Error)
}
var listing struct {
Entries []struct {
Name string `json:"name"`
Dir bool `json:"dir"`
} `json:"entries"`
}
if err := json.Unmarshal([]byte(resp.Content), &listing); err != nil {
t.Fatal(err)
}
if len(listing.Entries) != 2 {
t.Fatalf("期望 2 项,得 %d", len(listing.Entries))
}
// read_file 子目录文件
resp = execLocal([]string{root}, &runnerReq{ID: "2", Tool: "local_read_file", Args: map[string]any{"path": "sub/b.txt"}})
if !resp.OK || resp.Content != "world" {
t.Fatalf("read_file 失败: ok=%v content=%q err=%s", resp.OK, resp.Content, resp.Error)
}
// read_file 越界必须拒
resp = execLocal([]string{root}, &runnerReq{ID: "3", Tool: "local_read_file", Args: map[string]any{"path": "/etc/passwd"}})
if resp.OK {
t.Fatal("越界读未被拦截")
}
// 未知工具(写/exec 都不在只读版里)必须拒
resp = execLocal([]string{root}, &runnerReq{ID: "4", Tool: "local_write_file", Args: map[string]any{"path": "a.txt"}})
if resp.OK {
t.Fatal("未注册操作未被拦截")
}
}