Add url-preview endpoint and gated sandboxed fs tool family #26
Vendored
+1
-1
Submodule deps/agent-system-core updated: a64fb5a168...6bdc4f2ada
@@ -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)
|
||||
|
||||
@@ -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)----
|
||||
//
|
||||
|
||||
@@ -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()),
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
"&", "&", "<", "<", ">", ">", """, `"`, "'", "'", " ", " ",
|
||||
)
|
||||
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 "无法访问目标站点"
|
||||
}
|
||||
@@ -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="示例站点 & 更多">
|
||||
<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
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user