Files
Agent-System-Backend/internal/tools/fs_test.go
T
weichen f1302ed5b3 Enable canonical versioned Go module consumption
Why: Allow consumers to download an immutable module revision without nested source checkouts or local path replacements.

Evidence: SELF package tests and the prior canonical-path rejection are recorded in the Dev dependency-workspace task card; remote consumption is replayed against the committed candidate.

Impact: Change the module identity and its Go imports while preserving API behavior. Existing historical submodule revisions remain buildable.

Refs: Versioned dependencies U1/U3; feature/dependency-workspace
2026-10-13 15:47:16 +00:00

182 lines
6.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.
// 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)
}
}