Add url-preview endpoint and gated sandboxed fs tool family #26

Merged
weichen merged 1 commits from feature/file-preview-caps into main 2026-09-30 17:20:25 +00:00
11 changed files with 827 additions and 5 deletions
+6
View File
@@ -71,6 +71,10 @@ func Run(opt Options) error {
if err := reg.RegisterBuiltin(); err != nil {
return fmt.Errorf("builtin tools: %w", err)
}
// fs 预留能力:工具族实现但 Hidden(runtime.fsTools 未开时模型与 /api/tools 均不可见)。
if err := tools.RegisterFSTools(reg); err != nil {
return fmt.Errorf("fs tools: %w", err)
}
setSvc := settings.NewService(st)
setSvc.SetSystemOverride(map[string]any{"storage": map[string]any{"dataDir": opt.DataDir}})
// 网络搜索(Tavily):配置密钥池才注册(对齐 agent-native 语义:未配置不静默降级)。
@@ -128,6 +132,8 @@ func Run(opt Options) error {
pipe.SetReranker(rag.NewHTTPReranker(pipe.RerankRoute))
mgr.SetRetriever(pipe)
mgr.SetIndexer(pipe)
// fs 预留能力沙箱根:<DataDir>/workspaces/<owner>/<conversationID>/。
mgr.SetWorkspaceRoot(filepath.Join(opt.DataDir, "workspaces"))
// 收件箱任务卡:事件走会话事件管道(持久化/seq/重放);关闭即 T0 基线。
cardSvc := cardsvc.New(st, mgr.EmitCardEvent)
+25 -1
View File
@@ -36,6 +36,8 @@ type Manager struct {
events *eventBuffer // 事件批处理(写放大治理;seq 权威仍在 store)
retriever core.Retriever // 可选混合检索(nil = store 全文)
indexer core.DocumentIndexer // 可选索引入口(nil = store 整文档单块)
// workspaceRoot fs 预留能力(Hidden 工具族)的沙箱根;空=未配置,工具执行时报错。
workspaceRoot string
mu sync.Mutex
active map[string]*runHandle
@@ -102,6 +104,10 @@ func (m *Manager) SetRetriever(r core.Retriever) { m.retriever = r }
// SetIndexer 注入检索索引入口(分块+全文+可选向量;nil = 整文档单块)。
func (m *Manager) SetIndexer(i core.DocumentIndexer) { m.indexer = i }
// SetWorkspaceRoot 注入 fs 预留能力(Hidden 工具族)的沙箱根目录。
// 实际会话沙箱为 <root>/<owner>/<conversationID>/;空值=未配置(工具执行报错)。
func (m *Manager) SetWorkspaceRoot(dir string) { m.workspaceRoot = dir }
// ---- runCtx 实现(runtime.RunCtx)----
type runCtx struct {
@@ -127,7 +133,22 @@ func (c *runCtx) History() []core.Message {
}
func (c *runCtx) Attachments() []core.ParsedDocument { return c.attachments }
func (c *runCtx) Subtask() string { return c.subTask }
func (c *runCtx) Tools() []core.ToolDesc { return c.mgr.registry.List() }
// Tools 模型可见工具清单:Hidden 工具(fs 预留能力)在 runtime.fsTools 未开时过滤
// (与 /api/tools 列表同门禁;registry.Execute 另有执行侧 fail-closed)。
func (c *runCtx) Tools() []core.ToolDesc {
list := c.mgr.registry.List()
if c.eff.GetBool("runtime.fsTools", false) {
return list
}
out := make([]core.ToolDesc, 0, len(list))
for _, t := range list {
if !t.Hidden {
out = append(out, t)
}
}
return out
}
func (c *runCtx) SetContextObservation(o core.ContextObservation) {
c.mgr.mu.Lock()
c.h.ctxObs = &o
@@ -342,6 +363,9 @@ func (t toolCtx) Retrieve(q core.RetrievalQuery) ([]core.RetrievalHit, error) {
func (t toolCtx) WriteArtifact(name, kind, mime string, content []byte) (core.Artifact, error) {
return t.rc.WriteArtifact(name, kind, mime, content)
}
func (t toolCtx) OwnerID() string { return t.rc.run.Owner }
func (t toolCtx) WorkspaceRoot() string { return t.rc.mgr.workspaceRoot }
func (t toolCtx) SettingBool(key string, def bool) bool { return t.rc.eff.GetBool(key, def) }
// ---- 跨会话读取门禁(提案 2026-09-30-cross-session-reading;fail-closed)----
//
+18 -2
View File
@@ -183,6 +183,7 @@ func (s *Server) Handler() http.Handler {
mux.HandleFunc("PUT /api/settings/user", s.putSettingsUser)
mux.HandleFunc("PUT /api/settings/project/{id}", s.putSettingsProject)
mux.HandleFunc("GET /api/tools", s.listTools)
mux.HandleFunc("GET /api/url-preview", s.urlPreview)
mux.HandleFunc("POST /api/tools", s.createUserTool)
mux.HandleFunc("DELETE /api/tools/{name}", s.deleteUserTool)
mux.HandleFunc("GET /api/plugins", s.listPlugins)
@@ -944,8 +945,23 @@ func (s *Server) putSettingsProject(w http.ResponseWriter, r *http.Request) {
// ---- 工具(用户声明式 HTTP 工具注册)----
// visibleTools Hidden(预留能力)工具按租户生效设置过滤:runtime.fsTools 未开不可见。
func (s *Server) visibleTools(owner string) []core.ToolDesc {
list := s.reg.List()
if s.set.Effective(owner, "").GetBool("runtime.fsTools", false) {
return list
}
out := make([]core.ToolDesc, 0, len(list))
for _, t := range list {
if !t.Hidden {
out = append(out, t)
}
}
return out
}
func (s *Server) listTools(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, s.reg.List())
writeJSON(w, http.StatusOK, s.visibleTools(s.owner(r)))
}
func (s *Server) createUserTool(w http.ResponseWriter, r *http.Request) {
@@ -1012,7 +1028,7 @@ func (s *Server) pluginSettingsSchema(w http.ResponseWriter, r *http.Request) {
// registry 统一注册表视图(来源标记 + 按来源启停入口)。
func (s *Server) registry(w http.ResponseWriter, r *http.Request) {
reg := map[string]any{
"tools": s.reg.List(),
"tools": s.visibleTools(s.owner(r)),
"parsers": ingest.ParsersDescription(),
"panels": flattenPanels(s.plugins.List()),
}
+247
View File
@@ -0,0 +1,247 @@
// 链接预览端点(提案 2026-09-30-file-preview-and-reserved-capabilities,裁定 D3):
// 服务端抓取目标页的 OG/HTML 元数据供前端渲染信息卡。SSRF 硬边界:
// 仅 http/https;连接建立前按解析后 IP 拒绝环回/私有/链路本地/多播/未指派地址;
// 重定向逐跳同校验;5s 超时;响应体 256KB 截断;Content-Type 必须 text/html。
// 开发/自测可用 AS_URL_PREVIEW_ALLOW_PRIVATE=1 放开私网(默认关,fail-closed)。
package server
import (
"context"
"fmt"
"io"
"net"
"net/http"
"net/url"
"os"
"regexp"
"strings"
"sync"
"time"
)
const (
previewTimeout = 5 * time.Second
previewMaxBody = 256 << 10
previewCacheTTL = 15 * time.Minute
previewCacheCap = 100
)
type linkPreview struct {
URL string `json:"url"`
Title string `json:"title,omitempty"`
Description string `json:"description,omitempty"`
SiteName string `json:"siteName,omitempty"`
Favicon string `json:"favicon,omitempty"`
Image string `json:"image,omitempty"`
FinalURL string `json:"finalUrl,omitempty"`
Error string `json:"error,omitempty"`
}
type previewCacheEntry struct {
data linkPreview
expiry time.Time
}
var (
previewCacheMu sync.Mutex
previewCache = map[string]previewCacheEntry{}
metaOGTitle = regexp.MustCompile(`<meta[^>]+property=["']og:title["'][^>]+content=["']([^"']*)["']`)
metaOGName = regexp.MustCompile(`<meta[^>]+name=["']og:title["'][^>]+content=["']([^"']*)["']`)
metaOGDesc = regexp.MustCompile(`<meta[^>]+property=["']og:description["'][^>]+content=["']([^"']*)["']`)
metaOGSite = regexp.MustCompile(`<meta[^>]+property=["']og:site_name["'][^>]+content=["']([^"']*)["']`)
metaOGImg = regexp.MustCompile(`<meta[^>]+property=["']og:image["'][^>]+content=["']([^"']*)["']`)
metaDesc = regexp.MustCompile(`<meta[^>]+name=["']description["'][^>]+content=["']([^"']*)["']`)
tagTitle = regexp.MustCompile(`<title[^>]*>([^<]*)</title>`)
linkIcon = regexp.MustCompile(`<link[^>]+rel=["'][^"']*icon[^"']*["'][^>]+href=["']([^"']*)["']`)
tagScrip = regexp.MustCompile(`(?is)<script.*?</script>`)
tagStyle = regexp.MustCompile(`(?is)<style.*?</style>`)
)
func (s *Server) urlPreview(w http.ResponseWriter, r *http.Request) {
raw := strings.TrimSpace(r.URL.Query().Get("url"))
if raw == "" {
http.Error(w, "url parameter required", http.StatusBadRequest)
return
}
if cached, ok := previewCacheGet(raw); ok {
writeJSON(w, http.StatusOK, cached)
return
}
data := fetchLinkPreview(raw, s.previewAllowPrivate())
previewCachePut(raw, data)
writeJSON(w, http.StatusOK, data)
}
// previewAllowPrivate 环境开关(默认 false):仅供开发/联调,生产 fail-closed。
func (s *Server) previewAllowPrivate() bool {
return os.Getenv("AS_URL_PREVIEW_ALLOW_PRIVATE") == "1"
}
func previewCacheGet(key string) (linkPreview, bool) {
previewCacheMu.Lock()
defer previewCacheMu.Unlock()
e, ok := previewCache[key]
if !ok || time.Now().After(e.expiry) {
return linkPreview{}, false
}
return e.data, true
}
func previewCachePut(key string, data linkPreview) {
previewCacheMu.Lock()
defer previewCacheMu.Unlock()
if len(previewCache) >= previewCacheCap {
oldest := ""
var oldestT time.Time
for k, e := range previewCache {
if oldest == "" || e.expiry.Before(oldestT) {
oldest, oldestT = k, e.expiry
}
}
if oldest != "" {
delete(previewCache, oldest)
}
}
previewCache[key] = previewCacheEntry{data: data, expiry: time.Now().Add(previewCacheTTL)}
}
// fetchLinkPreview 抓取并解析 OG 元数据;任何校验失败返回带 error 的结构(仍 200,
// 前端据此渲染不可用卡),网络错误不冒 5xx。
func fetchLinkPreview(raw string, allowPrivate bool) linkPreview {
out := linkPreview{URL: raw}
u, err := url.Parse(raw)
if err != nil || (u.Scheme != "http" && u.Scheme != "https") {
out.Error = "仅支持 http/https 链接"
return out
}
client := &http.Client{Timeout: previewTimeout}
if !allowPrivate {
dialer := &net.Dialer{Timeout: previewTimeout}
client.Transport = &http.Transport{DialContext: guardDial(dialer)}
// 重定向逐跳校验:每个 Location 重新走完整校验(含 IP 闸门)。
client.CheckRedirect = func(req *http.Request, via []*http.Request) error {
if len(via) >= 5 {
return fmt.Errorf("too many redirects")
}
if req.URL.Scheme != "http" && req.URL.Scheme != "https" {
return fmt.Errorf("redirect to non-http scheme rejected")
}
return nil
}
}
resp, err := client.Get(raw)
if err != nil {
out.Error = "抓取失败:" + ssrfSafeErr(err)
return out
}
defer resp.Body.Close()
if ct := resp.Header.Get("Content-Type"); !strings.Contains(ct, "text/html") {
out.Error = "目标不是 HTML 页面(Content-Type: " + ct + ")"
return out
}
body, err := io.ReadAll(io.LimitReader(resp.Body, previewMaxBody))
if err != nil {
out.Error = "读取失败:" + ssrfSafeErr(err)
return out
}
out.FinalURL = resp.Request.URL.String()
html := tagScrip.ReplaceAllString(string(body), "")
html = tagStyle.ReplaceAllString(html, "")
if m := metaOGTitle.FindStringSubmatch(html); m != nil {
out.Title = decodeHTMLEntities(m[1])
} else if m := metaOGName.FindStringSubmatch(html); m != nil {
out.Title = decodeHTMLEntities(m[1])
} else if m := tagTitle.FindStringSubmatch(html); m != nil {
out.Title = decodeHTMLEntities(m[1])
}
if m := metaOGDesc.FindStringSubmatch(html); m != nil {
out.Description = decodeHTMLEntities(m[1])
} else if m := metaDesc.FindStringSubmatch(html); m != nil {
out.Description = decodeHTMLEntities(m[1])
}
if m := metaOGSite.FindStringSubmatch(html); m != nil {
out.SiteName = decodeHTMLEntities(m[1])
}
if m := metaOGImg.FindStringSubmatch(html); m != nil {
out.Image = resolveRef(out.FinalURL, m[1])
}
if m := linkIcon.FindStringSubmatch(html); m != nil {
out.Favicon = resolveRef(out.FinalURL, m[1])
}
return out
}
// guardDial 在每个连接建立前校验实际要连的地址(DialContext 收到的是已解析 IP),
// 拒绝环回/私有/链路本地/多播/未指派/共享与保留段——校验发生在拨号时点,防 DNS
// 重绑定绕过(URL 字符串合法但解析到内网)。
func guardDial(d *net.Dialer) func(ctx context.Context, network, addr string) (net.Conn, error) {
return func(ctx context.Context, network, addr string) (net.Conn, error) {
if err := validateDialAddr(addr); err != nil {
return nil, err
}
return d.DialContext(ctx, network, addr)
}
}
func validateDialAddr(addr string) error {
host, _, err := net.SplitHostPort(addr)
if err != nil {
return fmt.Errorf("preview dial: bad address %q", addr)
}
ip := net.ParseIP(host)
if ip == nil {
return fmt.Errorf("preview dial: non-ip address %q rejected", host)
}
if ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() ||
ip.IsMulticast() || ip.IsUnspecified() {
return fmt.Errorf("preview dial: forbidden (private/loopback/link-local) address rejected")
}
if v4 := ip.To4(); v4 != nil {
if v4[0] == 240 || v4[0] == 0 { // 保留段 240/4 与 0/8
return fmt.Errorf("preview dial: reserved address rejected")
}
if v4[0] == 100 && v4[1] >= 64 && v4[1] <= 127 { // CGNAT 共享段 100.64/10
return fmt.Errorf("preview dial: shared address space rejected")
}
}
return nil
}
func decodeHTMLEntities(s string) string {
r := strings.NewReplacer(
"&amp;", "&", "&lt;", "<", "&gt;", ">", "&quot;", `"`, "&#39;", "'", "&nbsp;", " ",
)
return strings.TrimSpace(r.Replace(s))
}
// resolveRef 把 OG 图标/图片的相对引用解析为绝对 URL。
func resolveRef(base, ref string) string {
if ref == "" {
return ""
}
b, err := url.Parse(base)
if err != nil {
return ref
}
r, err := url.Parse(ref)
if err != nil {
return ref
}
return b.ResolveReference(r).String()
}
// ssrfSafeErr 只保留错误类别,不带内网地址细节回显给客户端。
func ssrfSafeErr(err error) string {
msg := err.Error()
if strings.Contains(msg, "private") || strings.Contains(msg, "loopback") || strings.Contains(msg, "forbidden") || strings.Contains(msg, "unacceptable") {
return "目标地址被安全策略拒绝(内网/保留地址不可抓取)"
}
if strings.Contains(msg, "timeout") || strings.Contains(msg, "context deadline") {
return "目标站点超时"
}
return "无法访问目标站点"
}
+148
View File
@@ -0,0 +1,148 @@
// 链接预览端点测试:OG 解析正例(httptest)+ SSRF 负例(协议/环回/私网/非 HTML)+ 缓存。
package server
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
)
func previewServer(t *testing.T, body, contentType string, hits *int32) *httptest.Server {
t.Helper()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if hits != nil {
atomic.AddInt32(hits, 1)
}
if contentType != "" {
w.Header().Set("Content-Type", contentType)
}
fmt.Fprint(w, body)
}))
t.Cleanup(srv.Close)
return srv
}
func TestURLPreviewParsesOG(t *testing.T) {
var hits int32
srv := previewServer(t, `<!doctype html><html><head>
<meta property="og:title" content="示例站点 &amp; 更多">
<meta property="og:description" content="这是描述">
<meta property="og:site_name" content="Example">
<meta property="og:image" content="/cover.png">
<link rel="icon" href="/favicon.ico">
<title>fallback</title>
<script>var x = "<meta property=\"og:title\" content=\"fake\">";</script>
</head><body>hi</body></html>`, "text/html; charset=utf-8", &hits)
// httptest 是 127.0.0.1——放开私网闸门走正例(生产默认关);
// 走 handler 路径才能验证缓存写入(fetchLinkPreview 直调不写缓存)。
t.Setenv("AS_URL_PREVIEW_ALLOW_PRIVATE", "1")
s := &Server{}
req := httptest.NewRequest(http.MethodGet, "/api/url-preview?url="+srv.URL, nil)
rec := httptest.NewRecorder()
s.urlPreview(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("handler status %d: %s", rec.Code, rec.Body.String())
}
d := decodePreview(t, rec.Body.String())
if d.Error != "" {
t.Fatalf("unexpected error: %s", d.Error)
}
if d.Title != "示例站点 & 更多" {
t.Errorf("title mismatch: %q", d.Title)
}
if d.Description != "这是描述" || d.SiteName != "Example" {
t.Errorf("meta mismatch: %+v", d)
}
if !strings.HasSuffix(d.Image, "/cover.png") || !strings.HasPrefix(d.Image, "http") {
t.Errorf("image should resolve to absolute: %q", d.Image)
}
if d.Favicon == "" {
t.Errorf("favicon missing")
}
if d.FinalURL != srv.URL {
t.Errorf("finalUrl mismatch: %q", d.FinalURL)
}
// 缓存:第二次同 URL 不再打上游。
before := atomic.LoadInt32(&hits)
c1, ok1 := previewCacheGet(srv.URL)
if !ok1 || c1.Title != d.Title {
t.Fatalf("cache miss after first fetch")
}
_ = before
}
func TestURLPreviewNegatives(t *testing.T) {
// 非 http 协议。
if d := fetchLinkPreview("javascript:alert(1)", false); d.Error == "" {
t.Errorf("javascript: should be rejected")
}
if d := fetchLinkPreview("ftp://example.com/x", false); d.Error == "" {
t.Errorf("ftp should be rejected")
}
// 环回/私网字面 IP(默认闸门关)。
for _, u := range []string{"http://127.0.0.1:1/x", "http://10.0.0.1/x", "http://192.168.1.1/", "http://[::1]:1/", "http://169.254.1.1/"} {
if d := fetchLinkPreview(u, false); d.Error == "" {
t.Errorf("%s should be rejected by SSRF guard", u)
} else if !strings.Contains(d.Error, "安全策略") {
t.Errorf("%s error should be sanitized, got %q", u, d.Error)
}
}
// 非 HTML。
srv := previewServer(t, `{"a":1}`, "application/json", nil)
if d := fetchLinkPreview(srv.URL, true); d.Error == "" || !strings.Contains(d.Error, "HTML") {
t.Errorf("non-html should be rejected: %+v", d)
}
}
func TestURLPreviewRedirectValidation(t *testing.T) {
// 跳转目标为环回:即便入口是 httptest(也是环回),这里直接验证非 http 重定向被拒。
target := previewServer(t, `<html><head><title>ok</title></head></html>`, "text/html", nil)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// 重定向到 file:// 形态(Location 非 http/https)。
w.Header().Set("Location", "file:///etc/passwd")
w.WriteHeader(http.StatusFound)
}))
defer srv.Close()
_ = target
if d := fetchLinkPreview(srv.URL, true); d.Error == "" {
t.Errorf("redirect to non-http scheme should be rejected: %+v", d)
}
}
func TestURLPreviewEndpoint(t *testing.T) {
srv, _ := newTestServer(t)
h := srv.Handler()
var hits int32
og := previewServer(t, `<html><head><meta property="og:title" content="端点标题"><title>t</title></head></html>`, "text/html", &hits)
// 端点默认私网拒绝(httptest=127.0.0.1)——断言 fail-closed 而非 5xx。
req := httptest.NewRequest(http.MethodGet, "/api/url-preview?url="+og.URL, nil)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("endpoint should 200 with error card, got %d", rec.Code)
}
if !strings.Contains(rec.Body.String(), "安全策略") && !strings.Contains(rec.Body.String(), "抓取失败") {
t.Errorf("loopback should be blocked by default, got %s", rec.Body.String())
}
// 缺参 400。
req = httptest.NewRequest(http.MethodGet, "/api/url-preview", nil)
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest {
t.Errorf("missing url should 400, got %d", rec.Code)
}
}
func decodePreview(t *testing.T, body string) linkPreview {
t.Helper()
var d linkPreview
if err := json.Unmarshal([]byte(body), &d); err != nil {
t.Fatalf("bad preview json: %v", err)
}
return d
}
+208
View File
@@ -0,0 +1,208 @@
// fs 工具族(提案 2026-09-30-file-preview-and-reserved-capabilities,裁定 2.3):
// 实现但不默认提供——Desc.Hidden=true,runtime.fsTools 开启前对模型与 /api/tools 不可见,
// registry.Execute 同门禁(fail-closed)。沙箱根 = <root>/<owner>/<conversationID>/,
// 路径规范化后必须仍在会话目录内;写文件同步登记 artifact,保住"工作区文件=工件面板可见"。
package tools
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
core "agent-system-core"
)
const (
fsMaxRead = 1 << 20 // 单次读上限 1MB
fsMaxWrite = 2 << 20 // 单次写上限 2MB
fsMaxList = 200 // list 条数上限
)
// RegisterFSTools 注册隐藏 fs 工具(read/write/list)。root 由宿主注入(空=未配置,执行时报错)。
func RegisterFSTools(r *Registry) error {
if err := r.Register(core.ToolDesc{
Name: "fs_read_file", Description: "读取会话工作区内的文本文件(≤1MB)", Source: core.SourceBuiltin, Hidden: true,
Parameters: json.RawMessage(`{"type":"object","properties":{"path":{"type":"string","description":"工作区内相对路径"}},"required":["path"]}`),
}, execFSRead); err != nil {
return err
}
if err := r.Register(core.ToolDesc{
Name: "fs_write_file", Description: "把文本写入会话工作区文件(≤2MB),并同步登记为可下载工件", Source: core.SourceBuiltin, Hidden: true,
Parameters: json.RawMessage(`{"type":"object","properties":{"path":{"type":"string"},"content":{"type":"string"}},"required":["path","content"]}`),
}, execFSWrite); err != nil {
return err
}
return r.Register(core.ToolDesc{
Name: "fs_list_dir", Description: "列出会话工作区目录(一层,≤200 条)", Source: core.SourceBuiltin, Hidden: true,
Parameters: json.RawMessage(`{"type":"object","properties":{"path":{"type":"string","description":"工作区内相对路径,空=根"}}}`),
}, execFSList)
}
// fsSandbox 返回会话沙箱目录(owner/conversation 隔离);root 未配置报错。
func fsSandbox(rc ToolExecCtx) (string, error) {
root := rc.WorkspaceRoot()
if root == "" {
return "", fmt.Errorf("fs tools: workspace root not configured")
}
return filepath.Join(root, rc.OwnerID(), rc.ConversationID()), nil
}
// fsResolve 路径安全解析:拒绝绝对路径、反斜杠与 ..,规范化后必须仍在沙箱内
// (防穿越/逃逸;`\` 一律拒绝——Linux 上虽是合法字符,但会携带 Windows 语义并破坏工件命名)。
func fsResolve(sandbox, rel string) (string, error) {
rel = strings.TrimSpace(rel)
if rel == "" {
rel = "."
}
if filepath.IsAbs(rel) {
return "", fmt.Errorf("fs tools: absolute path not allowed: %s", rel)
}
if strings.ContainsRune(rel, '\\') {
return "", fmt.Errorf("fs tools: backslash not allowed in paths: %s", rel)
}
abs := filepath.Clean(filepath.Join(sandbox, rel))
if abs != sandbox && !strings.HasPrefix(abs, sandbox+string(filepath.Separator)) {
return "", fmt.Errorf("fs tools: path escapes workspace sandbox: %s", rel)
}
return abs, nil
}
func execFSRead(rc ToolExecCtx, args json.RawMessage) (string, error) {
var a struct {
Path string `json:"path"`
}
if err := json.Unmarshal(args, &a); err != nil {
return "", err
}
sandbox, err := fsSandbox(rc)
if err != nil {
return "", err
}
abs, err := fsResolve(sandbox, a.Path)
if err != nil {
return "", err
}
st, err := os.Stat(abs)
if err != nil {
return "", fmt.Errorf("fs_read_file: %w", err)
}
if st.IsDir() {
return "", fmt.Errorf("fs_read_file: %s 是目录(用 fs_list_dir)", a.Path)
}
if st.Size() > fsMaxRead {
return "", fmt.Errorf("fs_read_file: 文件超过 1MB 上限(%d 字节)", st.Size())
}
b, err := os.ReadFile(abs)
if err != nil {
return "", err
}
return string(b), nil
}
func execFSWrite(rc ToolExecCtx, args json.RawMessage) (string, error) {
var a struct {
Path string `json:"path"`
Content string `json:"content"`
}
if err := json.Unmarshal(args, &a); err != nil {
return "", err
}
if a.Path == "" {
return "", fmt.Errorf("fs_write_file: path required")
}
if len(a.Content) > fsMaxWrite {
return "", fmt.Errorf("fs_write_file: 内容超过 2MB 上限")
}
sandbox, err := fsSandbox(rc)
if err != nil {
return "", err
}
abs, err := fsResolve(sandbox, a.Path)
if err != nil {
return "", err
}
if err := os.MkdirAll(filepath.Dir(abs), 0o755); err != nil {
return "", err
}
// 符号链接逃逸防护:已存在文件若是指向沙箱外的软链则拒绝覆写。
if fi, err := os.Lstat(abs); err == nil && fi.Mode()&os.ModeSymlink != 0 {
if real, err := filepath.EvalSymlinks(abs); err != nil || !strings.HasPrefix(real, sandbox+string(filepath.Separator)) {
return "", fmt.Errorf("fs_write_file: refusing to follow symlink out of sandbox: %s", a.Path)
}
}
if err := os.WriteFile(abs, []byte(a.Content), 0o644); err != nil {
return "", err
}
// 不变式:工作区文件 = 工件面板可见(内容同步入对象库,工件自包含可下载)。
base := filepath.Base(abs)
art, err := rc.WriteArtifact(base, "file", mimeOfExt(base), []byte(a.Content))
if err != nil {
return "", fmt.Errorf("fs_write_file: written %s but artifact registration failed: %w", abs, err)
}
return fmt.Sprintf("已写入 %s(%d 字节,工件 %s)", a.Path, len(a.Content), art.ID), nil
}
func execFSList(rc ToolExecCtx, args json.RawMessage) (string, error) {
var a struct {
Path string `json:"path"`
}
if len(args) > 0 {
if err := json.Unmarshal(args, &a); err != nil {
return "", err
}
}
sandbox, err := fsSandbox(rc)
if err != nil {
return "", err
}
abs, err := fsResolve(sandbox, a.Path)
if err != nil {
return "", err
}
ents, err := os.ReadDir(abs)
if err != nil {
return "", fmt.Errorf("fs_list_dir: %w", err)
}
sort.Slice(ents, func(i, j int) bool { return ents[i].Name() < ents[j].Name() })
var sb strings.Builder
n := 0
for _, e := range ents {
if n >= fsMaxList {
fmt.Fprintf(&sb, "…(超过 %d 条截断)\n", fsMaxList)
break
}
info, err := e.Info()
size := int64(-1)
if err == nil {
size = info.Size()
}
tag := "file"
if e.IsDir() {
tag = "dir"
}
fmt.Fprintf(&sb, "%s\t%s\t%d\n", tag, e.Name(), size)
n++
}
if sb.Len() == 0 {
return "(空目录)", nil
}
return sb.String(), nil
}
func mimeOfExt(name string) string {
switch strings.ToLower(filepath.Ext(name)) {
case ".md":
return "text/markdown"
case ".json":
return "application/json"
case ".csv":
return "text/csv"
case ".html":
return "text/html"
default:
return "text/plain; charset=utf-8"
}
}
+159
View File
@@ -0,0 +1,159 @@
// 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 // 登记的工件名
}
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)
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)
}
}
+3
View File
@@ -20,6 +20,9 @@ func (dummyCtx) PeerConversations() ([]core.Conversation, error) { return nil, n
func (dummyCtx) PeerConversationMessages(id string, limit int) ([]core.Message, error) {
return nil, nil
}
func (dummyCtx) OwnerID() string { return "o" }
func (dummyCtx) WorkspaceRoot() string { return "" }
func (dummyCtx) SettingBool(string, bool) bool { return false }
// TestHTTPExecutorTemplateAndPath:模板变量注入、结果路径裁剪、错误状态。
func TestHTTPExecutorTemplateAndPath(t *testing.T) {
+3
View File
@@ -33,6 +33,9 @@ func (f *fakePeerCtx) PeerConversationMessages(conversationID string, limit int)
f.gotID = conversationID
return f.msgs, f.err
}
func (f *fakePeerCtx) OwnerID() string { return "o1" }
func (f *fakePeerCtx) WorkspaceRoot() string { return "" }
func (f *fakePeerCtx) SettingBool(string, bool) bool { return false }
func execPeer(t *testing.T, ctx ToolExecCtx, name, args string) core.ToolCallResult {
t.Helper()
+9 -1
View File
@@ -27,6 +27,10 @@ type ToolExecCtx interface {
WriteArtifact(name, kind, mime string, content []byte) (core.Artifact, error)
PeerConversations() ([]core.Conversation, error)
PeerConversationMessages(conversationID string, limit int) ([]core.Message, error)
// ---- 预留能力(提案 2026-09-30-file-preview-and-reserved-capabilities)----
OwnerID() string // 租户(fs 沙箱隔离用)
WorkspaceRoot() string // 工作区根目录(空=未配置)
SettingBool(key string, def bool) bool // 生效设置读取(Hidden 工具门禁用)
}
type tool struct {
@@ -85,16 +89,20 @@ func (r *Registry) Get(name string) (core.ToolDesc, Executor, bool) {
}
// Execute 校验存在性并调用执行器;策略(allow/deny/confirm)与截断由 runmgr 负责。
// Hidden 工具(预留能力)在此再执行一道门禁:runtime.fsTools 未开即拒绝(fail-closed,
// 与可见性过滤双保险——列表过滤挡"看见",这里挡"直达调用")。
func (r *Registry) Execute(rc ToolExecCtx, call core.ToolCallRequest) core.ToolCallResult {
desc, exec, ok := r.Get(call.Name)
if !ok {
return core.ToolCallResult{CallID: call.CallID, Status: "error", Output: fmt.Sprintf("tool %q not found (registry miss)", call.Name)}
}
if desc.Hidden && !rc.SettingBool("runtime.fsTools", false) {
return core.ToolCallResult{CallID: call.CallID, Status: "error", Output: fmt.Sprintf("tool %q is a reserved capability (runtime.fsTools off)", call.Name)}
}
out, err := exec(rc, call.Arguments)
if err != nil {
return core.ToolCallResult{CallID: call.CallID, Status: "error", Output: err.Error()}
}
_ = desc
return core.ToolCallResult{CallID: call.CallID, Status: "ok", Output: out, Truncated: false, Artifact: nil}
}