Files
Agent-System-Backend/internal/tools/fs_test.go
T
weichenandCursor 3e5699c595 Reject pseudo-binary Office writes and index extracted text
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>
2026-10-01 04:12:12 +00:00

182 lines
6.7 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 "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)
}
}