Why: string writes of .docx/.xlsx/.pdf produce files that will not open, and generated binary artifacts were skipped by search entirely. Evidence: go test ./... 19 packages ok. Office extensions are refused with a pointer to run_python plus register_artifact. A docgen docx is searchable after WriteArtifact; unparseable bytes are not indexed. Impact: write_artifact and fs_write_file no longer accept those extensions. Text artifacts index as before. Merge after the interpreter PR so the error text names tools that exist. Refs: task feature/binary-doc-index; supersedes Dev #99 / Backend #27 Co-authored-by: Cursor <cursoragent@cursor.com>
182 lines
6.7 KiB
Go
182 lines
6.7 KiB
Go
// fs 工具族测试:沙箱隔离 / 路径穿越负例 / 大小上限 / Hidden 门禁 / 工件登记不变式。
|
||
package tools
|
||
|
||
import (
|
||
"encoding/json"
|
||
"os"
|
||
"path/filepath"
|
||
"strings"
|
||
"testing"
|
||
|
||
core "agent-system-core"
|
||
)
|
||
|
||
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)
|
||
}
|
||
}
|