From c0baa342fcd69c267d5bbd3cdb46bc7ddc744b4b Mon Sep 17 00:00:00 2001 From: weichen Date: Wed, 30 Sep 2026 16:38:36 +0000 Subject: [PATCH] Add url-preview endpoint and gated sandboxed fs tool family Why: the adjudicated preview plan needs server-side OG fetching for link cards, and the proposal's reserved-capability items call for the fs tool family to exist behind a default-off gate (implemented, not offered). Evidence: GET /api/url-preview fetches and parses og:title/description/ site_name/image/favicon with a 5s timeout, 256KB body cap, text/html content check, TTL cache, and SSRF guards enforced at dial time (loopback /private/link-local/multicast/reserved/CGNAT rejected per hop, anti DNS- rebind; AS_URL_PREVIEW_ALLOW_PRIVATE=1 exists for local testing only, default fail-closed; errors return sanitized cards, never internal addresses). fs_read_file/fs_write_file/fs_list_dir register as Hidden tools sandboxed under //: traversal, absolute paths, backslashes, and symlink escape rejected; 1MB/2MB size caps; writes register an artifact so workspace files stay visible in the panel. ToolDesc.Hidden gates both model visibility (runctx.Tools) and /api/tools listing on runtime.fsTools, and registry.Execute re-checks fail-closed. ToolExecCtx gains OwnerID/WorkspaceRoot/SettingBool. go test ./... green (fs negative matrix, SSRF negatives, endpoint fail-closed, cache). Impact: link previews work end to end once the frontend wires the fetcher; fs tools are inert until runtime.fsTools is enabled. Refs: task feature/file-preview-caps; core 4d29301. --- deps/agent-system-core | 2 +- internal/app/app.go | 6 + internal/runmgr/runmgr.go | 26 ++- internal/server/server.go | 20 ++- internal/server/urlpreview.go | 247 +++++++++++++++++++++++++++++ internal/server/urlpreview_test.go | 148 +++++++++++++++++ internal/tools/fs.go | 208 ++++++++++++++++++++++++ internal/tools/fs_test.go | 159 +++++++++++++++++++ internal/tools/http_test.go | 3 + internal/tools/peers_test.go | 3 + internal/tools/registry.go | 10 +- 11 files changed, 827 insertions(+), 5 deletions(-) create mode 100644 internal/server/urlpreview.go create mode 100644 internal/server/urlpreview_test.go create mode 100644 internal/tools/fs.go create mode 100644 internal/tools/fs_test.go diff --git a/deps/agent-system-core b/deps/agent-system-core index a64fb5a..6bdc4f2 160000 --- a/deps/agent-system-core +++ b/deps/agent-system-core @@ -1 +1 @@ -Subproject commit a64fb5a168f2e44358b186265b893c4eec5dd1a5 +Subproject commit 6bdc4f2adaf967fd03894019798c279715518e97 diff --git a/internal/app/app.go b/internal/app/app.go index f6d811f..1c4aaba 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -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 预留能力沙箱根:/workspaces///。 + mgr.SetWorkspaceRoot(filepath.Join(opt.DataDir, "workspaces")) // 收件箱任务卡:事件走会话事件管道(持久化/seq/重放);关闭即 T0 基线。 cardSvc := cardsvc.New(st, mgr.EmitCardEvent) diff --git a/internal/runmgr/runmgr.go b/internal/runmgr/runmgr.go index 432e2cf..01c7d7b 100644 --- a/internal/runmgr/runmgr.go +++ b/internal/runmgr/runmgr.go @@ -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 工具族)的沙箱根目录。 +// 实际会话沙箱为 ///;空值=未配置(工具执行报错)。 +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)---- // diff --git a/internal/server/server.go b/internal/server/server.go index 7e2f092..bff8558 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -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()), } diff --git a/internal/server/urlpreview.go b/internal/server/urlpreview.go new file mode 100644 index 0000000..8d64514 --- /dev/null +++ b/internal/server/urlpreview.go @@ -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(`]+property=["']og:title["'][^>]+content=["']([^"']*)["']`) + metaOGName = regexp.MustCompile(`]+name=["']og:title["'][^>]+content=["']([^"']*)["']`) + metaOGDesc = regexp.MustCompile(`]+property=["']og:description["'][^>]+content=["']([^"']*)["']`) + metaOGSite = regexp.MustCompile(`]+property=["']og:site_name["'][^>]+content=["']([^"']*)["']`) + metaOGImg = regexp.MustCompile(`]+property=["']og:image["'][^>]+content=["']([^"']*)["']`) + metaDesc = regexp.MustCompile(`]+name=["']description["'][^>]+content=["']([^"']*)["']`) + tagTitle = regexp.MustCompile(`]*>([^<]*)`) + linkIcon = regexp.MustCompile(`]+rel=["'][^"']*icon[^"']*["'][^>]+href=["']([^"']*)["']`) + tagScrip = regexp.MustCompile(`(?is)`) + tagStyle = regexp.MustCompile(`(?is)`) +) + +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( + "&", "&", "<", "<", ">", ">", """, `"`, "'", "'", " ", " ", + ) + 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 "无法访问目标站点" +} diff --git a/internal/server/urlpreview_test.go b/internal/server/urlpreview_test.go new file mode 100644 index 0000000..9f88c7d --- /dev/null +++ b/internal/server/urlpreview_test.go @@ -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, ` + + + + + + fallback + + hi`, "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, `ok`, "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, `t`, "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 +} diff --git a/internal/tools/fs.go b/internal/tools/fs.go new file mode 100644 index 0000000..4f4b57d --- /dev/null +++ b/internal/tools/fs.go @@ -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)。沙箱根 = ///, +// 路径规范化后必须仍在会话目录内;写文件同步登记 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" + } +} diff --git a/internal/tools/fs_test.go b/internal/tools/fs_test.go new file mode 100644 index 0000000..4827dab --- /dev/null +++ b/internal/tools/fs_test.go @@ -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) + } +} diff --git a/internal/tools/http_test.go b/internal/tools/http_test.go index 876de2e..295d883 100644 --- a/internal/tools/http_test.go +++ b/internal/tools/http_test.go @@ -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) { diff --git a/internal/tools/peers_test.go b/internal/tools/peers_test.go index 47056f1..9185e0e 100644 --- a/internal/tools/peers_test.go +++ b/internal/tools/peers_test.go @@ -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() diff --git a/internal/tools/registry.go b/internal/tools/registry.go index 1ffa522..00422e6 100644 --- a/internal/tools/registry.go +++ b/internal/tools/registry.go @@ -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} }