// fs 工具族测试:沙箱隔离 / 路径穿越负例 / 大小上限 / Hidden 门禁 / 工件登记不变式。 package tools import ( "encoding/json" "os" "path/filepath" "strings" "testing" core "git-ssh.u1680832.nyat.app/weichen/Agent-System-Core.git" ) type fakeFSCtx struct { root string owner string conv string fsToolsOn bool written []string // 登记的工件名 lastContent []byte // 最近一次写入的工件字节 lastMime string } func (c *fakeFSCtx) ProjectID() string { return "p1" } func (c *fakeFSCtx) ConversationID() string { return c.conv } func (c *fakeFSCtx) OwnerID() string { return c.owner } func (c *fakeFSCtx) WorkspaceRoot() string { return c.root } func (c *fakeFSCtx) SettingBool(key string, def bool) bool { if key == "runtime.fsTools" { return c.fsToolsOn } return def } func (c *fakeFSCtx) Retrieve(q core.RetrievalQuery) ([]core.RetrievalHit, error) { return nil, nil } func (c *fakeFSCtx) WriteArtifact(name, kind, mime string, content []byte) (core.Artifact, error) { c.written = append(c.written, name) c.lastContent = content c.lastMime = mime return core.Artifact{ID: "art_" + name, Name: name, Kind: kind, MIME: mime, Size: int64(len(content))}, nil } func (c *fakeFSCtx) PeerConversations() ([]core.Conversation, error) { return nil, nil } func (c *fakeFSCtx) PeerConversationMessages(string, int) ([]core.Message, error) { return nil, nil } func newFSRegistry(t *testing.T) *Registry { t.Helper() r := NewRegistry() if err := r.RegisterBuiltin(); err != nil { t.Fatal(err) } if err := RegisterFSTools(r); err != nil { t.Fatal(err) } return r } func call(t *testing.T, r *Registry, rc *fakeFSCtx, name, args string) core.ToolCallResult { t.Helper() return r.Execute(rc, core.ToolCallRequest{CallID: "c1", Name: name, Arguments: json.RawMessage(args)}) } func TestFSToolsHiddenAndGated(t *testing.T) { r := newFSRegistry(t) // 可见性:Hidden 工具不在 List()(List 本身不过滤——过滤在 server/runmgr 层), // 但描述带 Hidden 标记。 for _, d := range r.List() { if strings.HasPrefix(d.Name, "fs_") && !d.Hidden { t.Errorf("%s should be Hidden", d.Name) } } // 执行门禁:runtime.fsTools 关闭时 fail-closed。 rc := &fakeFSCtx{root: t.TempDir(), owner: "o1", conv: "c1", fsToolsOn: false} res := call(t, r, rc, "fs_write_file", `{"path":"a.md","content":"x"}`) if res.Status != "error" || !strings.Contains(res.Output, "reserved capability") { t.Errorf("gate off should reject, got %s %s", res.Status, res.Output) } } func TestFSWriteReadListRoundtrip(t *testing.T) { r := newFSRegistry(t) root := t.TempDir() rc := &fakeFSCtx{root: root, owner: "o1", conv: "c1", fsToolsOn: true} res := call(t, r, rc, "fs_write_file", `{"path":"notes/纪要.md","content":"# 会议纪要\n内容"}`) if res.Status != "ok" { t.Fatalf("write failed: %s", res.Output) } // 不变式:写文件同步登记工件。 if len(rc.written) != 1 || rc.written[0] != "纪要.md" { t.Errorf("artifact registration missing: %v", rc.written) } // 落盘位置在会话沙箱内。 if _, err := os.Stat(filepath.Join(root, "o1", "c1", "notes", "纪要.md")); err != nil { t.Fatalf("sandbox file missing: %v", err) } res = call(t, r, rc, "fs_read_file", `{"path":"notes/纪要.md"}`) if res.Status != "ok" || !strings.Contains(res.Output, "会议纪要") { t.Fatalf("read failed: %s %s", res.Status, res.Output) } res = call(t, r, rc, "fs_list_dir", `{}`) if res.Status != "ok" || !strings.Contains(res.Output, "notes") { t.Fatalf("list failed: %s %s", res.Status, res.Output) } } func TestFSPathTraversalRejected(t *testing.T) { r := newFSRegistry(t) root := t.TempDir() if err := os.MkdirAll(filepath.Join(root, "outside"), 0o755); err != nil { t.Fatal(err) } rc := &fakeFSCtx{root: root, owner: "o1", conv: "c1", fsToolsOn: true} negatives := []string{ `{"path":"../escape.md","content":"x"}`, `{"path":"..\\..\\escape.md","content":"x"}`, `{"path":"/etc/passwd","content":"x"}`, `{"path":"a/../../escape.md","content":"x"}`, `{"path":"a/../../../o2/c2/steal.md","content":"x"}`, } for _, args := range negatives { if res := call(t, r, rc, "fs_write_file", args); res.Status != "error" { t.Errorf("traversal write should fail: %s -> %s", args, res.Output) } } // 读穿越同样拒绝。 if res := call(t, r, rc, "fs_read_file", `{"path":"../../secret"}`); res.Status != "error" { t.Errorf("traversal read should fail: %s", res.Output) } // 符号链接逃逸:沙箱内软链指向根外,拒绝覆写。 outside := filepath.Join(root, "outside", "target.txt") os.WriteFile(outside, []byte("keep"), 0o644) link := filepath.Join(root, "o1", "c1", "link.md") os.MkdirAll(filepath.Dir(link), 0o755) os.Symlink(outside, link) if res := call(t, r, rc, "fs_write_file", `{"path":"link.md","content":"pwn"}`); res.Status != "error" { t.Errorf("symlink escape should fail: %s", res.Output) } if b, _ := os.ReadFile(outside); string(b) != "keep" { t.Errorf("outside file was modified via symlink") } } func TestFSSizeLimits(t *testing.T) { r := newFSRegistry(t) rc := &fakeFSCtx{root: t.TempDir(), owner: "o1", conv: "c1", fsToolsOn: true} big := strings.Repeat("a", fsMaxWrite+1) args, _ := json.Marshal(map[string]string{"path": "big.txt", "content": big}) if res := call(t, r, rc, "fs_write_file", string(args)); res.Status != "error" { t.Errorf("oversize write should fail") } // 读上限:直接写一个大文件进沙箱再读。 os.MkdirAll(filepath.Join(rc.root, "o1", "c1"), 0o755) os.WriteFile(filepath.Join(rc.root, "o1", "c1", "big.bin"), make([]byte, fsMaxRead+1), 0o644) if res := call(t, r, rc, "fs_read_file", `{"path":"big.bin"}`); res.Status != "error" { t.Errorf("oversize read should fail") } } func TestFSUnconfiguredRoot(t *testing.T) { r := newFSRegistry(t) rc := &fakeFSCtx{root: "", owner: "o1", conv: "c1", fsToolsOn: true} if res := call(t, r, rc, "fs_list_dir", `{}`); res.Status != "error" { t.Errorf("unconfigured root should fail: %s", res.Output) } } func TestBinaryOfficeWriteRejected(t *testing.T) { r := newFSRegistry(t) rc := &fakeFSCtx{root: t.TempDir(), owner: "o1", conv: "c1", fsToolsOn: true} for _, args := range []struct{ name, body string }{ {"fs_write_file", `{"path":"a.docx","content":"plain"}`}, {"fs_write_file", `{"path":"a.pdf","content":"plain"}`}, {"write_artifact", `{"name":"a.xlsx","content":"plain"}`}, } { res := call(t, r, rc, args.name, args.body) if res.Status != "error" || !strings.Contains(res.Output, "run_python") { t.Errorf("%s %s: %s %s", args.name, args.body, res.Status, res.Output) } } if res := call(t, r, rc, "write_artifact", `{"name":"a.md","content":"plain"}`); res.Status != "ok" { t.Fatalf("markdown write should stay allowed: %s", res.Output) } }