Files
Agent-Native/runtime/tools/fetch.go
T
weichenandCursor 694b08cdbe
ci / vet-and-test (pull_request) Failing after 2s
ci / docker-checkout-hostgateway (pull_request) Successful in 2s
Pair skipped tool calls and close the stdio and fetch gaps
Why: a tool-budget stop left tool calls without results, host callbacks raced, a canceled MCP read stole the next response, and fetch re-resolved after the SSRF check.
Evidence: GOWORK=off go vet ./... && go test -count=1 ./... && go test -race ./runtime/... ./adapters/native/... passed, including TestLoopBudgetToolCallCap, TestParallelHostCallbacksAreSerial, TestStdioSlowCancelThenEcho, TestPresetsAndClientForChatCompletions, TestFetchResolveChangesToLoopbackRejected, and TestFetchHostCheckRedirectStillRejectsPrivate.
Impact: over-budget calls get an error result and no tool event; one Run serializes host callbacks; MCP dispatches by id; anthropic and google presets use OpenAI-compatible chat endpoints; fetch dials the address it just checked. deps/agent-intent is 3130190; error-code-system and logging-system are a3ee67b and 49b5d8b.
Refs: N1, N2, N3, N4, N13, N14
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-03 01:10:29 +00:00

276 lines
8.4 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package tools
import (
"context"
"encoding/json"
"fmt"
"html"
"io"
"net"
"net/http"
"net/url"
"regexp"
"strings"
"time"
)
// FetchConfig 声明 fetch_url 的运行条件;未注册(组合根未提供)则工具不存在。
type FetchConfig struct {
// Client 可选注入(测试替身);缺省构造 20s 超时客户端。
// Transport 为 nil 或 *http.Transport 时,Clone 后替换 DialContext:
// 拨号时再次解析,只连接通过 SSRF 校验的 IP。
// 自定义 RoundTripper 无法替换拨号,只做请求前预校验;实现不得在
// RoundTrip 里重新解析到未校验的地址。
Client *http.Client
// MaxBytes 响应体上限(默认 2 MiB)。
MaxBytes int
// AllowedHosts 非空时仅允许这些主机(域名或 host:port);空 = 公网放开,
// 但私网/回环/链路本地地址在任何模式下都被拒绝(SSRF 硬边界)。
AllowedHosts []string
// Resolve 可选注入主机解析(测试替身);缺省 net.LookupIP。
Resolve func(host string) ([]net.IP, error)
}
const (
fetchTimeout = 20 * time.Second
fetchMaxBytes = 2 << 20
)
func (c *FetchConfig) resolver() func(string) ([]net.IP, error) {
if c.Resolve != nil {
return c.Resolve
}
return net.LookupIP
}
// RegisterFetch 注册 fetch_url 工具(cfg 必须非 nil,由组合根显式提供)。
func RegisterFetch(r *Registry, cfg FetchConfig) {
r.Register(&fetchTool{cfg: cfg})
}
type fetchTool struct {
cfg FetchConfig
}
func (t *fetchTool) Name() string { return "fetch_url" }
func (t *fetchTool) Description() string {
return "Fetch an http(s) URL and return readable text (HTML stripped, up to 2 MiB, 20s timeout). Private/loopback network targets are always rejected."
}
func (t *fetchTool) Parameters() json.RawMessage {
return json.RawMessage(`{"type":"object","required":["url"],"properties":{"url":{"type":"string"}}}`)
}
func (t *fetchTool) Execute(ctx context.Context, args json.RawMessage) (Result, error) {
var obj struct {
URL string `json:"url"`
}
if err := json.Unmarshal(args, &obj); err != nil {
return Result{}, err
}
raw := strings.TrimSpace(obj.URL)
if raw == "" {
return Result{Content: "url is empty", IsError: true}, nil
}
client := t.cfg.Client
if client == nil {
client = &http.Client{Timeout: fetchTimeout}
}
allowed := make(map[string]bool, len(t.cfg.AllowedHosts))
for _, h := range t.cfg.AllowedHosts {
allowed[strings.ToLower(h)] = true
}
maxBytes := t.cfg.MaxBytes
if maxBytes <= 0 {
maxBytes = fetchMaxBytes
}
reqCtx, cancel := context.WithTimeout(ctx, fetchTimeout)
defer cancel()
req, err := http.NewRequestWithContext(reqCtx, http.MethodGet, raw, nil)
if err != nil {
return Result{Content: err.Error(), IsError: true}, nil
}
req.Header.Set("User-Agent", "agent-native-fetch/1.0")
var finalURL string
resolve := t.cfg.resolver()
hostRedirect := client.CheckRedirect
transport, _ := pinFetchTransport(client.Transport, resolve)
client2 := &http.Client{
Transport: transport,
Jar: client.Jar,
Timeout: client.Timeout,
CheckRedirect: func(req *http.Request, via []*http.Request) error {
if len(via) >= 5 {
return fmt.Errorf("stopped after 5 redirects")
}
if err := validateFetchTarget(req.URL, allowed, resolve); err != nil {
return err
}
if hostRedirect != nil {
return hostRedirect(req, via)
}
return nil
},
}
if err := validateFetchTarget(req.URL, allowed, resolve); err != nil {
return Result{Content: err.Error(), IsError: true}, nil
}
resp, err := client2.Do(req)
if err != nil {
return Result{Content: err.Error(), IsError: true}, nil
}
defer resp.Body.Close()
finalURL = resp.Request.URL.String()
body, err := io.ReadAll(io.LimitReader(resp.Body, int64(maxBytes)+1))
if err != nil {
return Result{Content: err.Error(), IsError: true}, nil
}
if len(body) > maxBytes {
body = body[:maxBytes]
}
ct := resp.Header.Get("Content-Type")
text, ok := fetchReadableText(ct, string(body))
if !ok {
return Result{Content: fmt.Sprintf("unsupported content type %q at %s (only text/html/json/xml/yaml/csv are extracted)", ct, finalURL), IsError: true}, nil
}
return Result{Content: "url: " + finalURL + "\ncontent-type: " + ct + "\n\n" + text}, nil
}
// pinFetchTransport 在默认或 *http.Transport 上安装「解析并拨号已校验 IP」的 DialContext。
// 自定义 RoundTripper 原样返回,调用方保留预校验。
func pinFetchTransport(base http.RoundTripper, resolve func(string) ([]net.IP, error)) (http.RoundTripper, bool) {
var tr *http.Transport
switch t := base.(type) {
case nil:
tr = http.DefaultTransport.(*http.Transport).Clone()
case *http.Transport:
tr = t.Clone()
default:
return base, false
}
// 不走环境变量里的代理:代理拨号会绕开「只连接已校验 IP」。
tr.Proxy = nil
dialer := &net.Dialer{Timeout: 10 * time.Second, KeepAlive: 30 * time.Second}
tr.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) {
host, port, err := net.SplitHostPort(addr)
if err != nil {
return nil, err
}
ips, err := resolve(host)
if err != nil {
return nil, fmt.Errorf("resolve %q: %w", host, err)
}
var chosen net.IP
for _, ip := range ips {
if ipIsPrivate(ip) {
return nil, fmt.Errorf("target %q resolves to private/loopback address %s (SSRF guard)", host, ip)
}
if chosen == nil {
chosen = ip
}
}
if chosen == nil {
return nil, fmt.Errorf("resolve %q: no addresses", host)
}
return dialer.DialContext(ctx, network, net.JoinHostPort(chosen.String(), port))
}
return tr, true
}
// validateFetchTarget 校验 scheme、主机策略与 SSRF 硬边界(私网/回环/链路本地)。
func validateFetchTarget(u *url.URL, allowed map[string]bool, resolve func(string) ([]net.IP, error)) error {
if u.Scheme != "http" && u.Scheme != "https" {
return fmt.Errorf("scheme %q not allowed (http/https only)", u.Scheme)
}
host := u.Hostname()
if host == "" {
return fmt.Errorf("empty host")
}
if len(allowed) > 0 && !allowed[strings.ToLower(host)] {
return fmt.Errorf("host %q not in allowed hosts", host)
}
// 解析全部地址:任一命中私网段即拒绝(域名可能同时解析到公私网地址)。
addrs, err := resolve(host)
if err != nil {
return fmt.Errorf("resolve %q: %w", host, err)
}
for _, ip := range addrs {
if ipIsPrivate(ip) {
return fmt.Errorf("target %q resolves to private/loopback address %s (SSRF guard)", host, ip)
}
}
return nil
}
var fetchPrivateNets = mustParseCIDRs(
"0.0.0.0/8", "10.0.0.0/8", "127.0.0.0/8", "169.254.0.0/16",
"172.16.0.0/12", "192.168.0.0/16", "100.64.0.0/10",
"::1/128", "fc00::/7", "fe80::/10",
)
func mustParseCIDRs(specs ...string) []*net.IPNet {
var out []*net.IPNet
for _, s := range specs {
_, n, err := net.ParseCIDR(s)
if err != nil {
panic("fetch: bad cidr " + s)
}
out = append(out, n)
}
return out
}
func ipIsPrivate(ip net.IP) bool {
// IPv4(含 Go 内部的 ::ffff:a.b.c.d 映射形式)先归一化成 4 字节,
// 否则 ::ffff:0:0/96 这类映射网段会误吞所有 IPv4 地址。
if v4 := ip.To4(); v4 != nil {
ip = v4
}
if ip.IsLoopback() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() || ip.IsUnspecified() {
return true
}
for _, n := range fetchPrivateNets {
if n.Contains(ip) {
return true
}
}
return false
}
// fetchReadableText 按内容类型抽取可读文本;HTML 剥脚本样式与标签。
func fetchReadableText(contentType, body string) (string, bool) {
ct := strings.ToLower(strings.TrimSpace(strings.Split(contentType, ";")[0]))
if ct == "" {
ct = "text/plain" // 无声明按纯文本处理
}
switch {
case ct == "text/html":
return htmlToText(body), true
case strings.HasPrefix(ct, "text/"),
ct == "application/json",
ct == "application/xml",
strings.HasSuffix(ct, "+json"),
strings.HasSuffix(ct, "+xml"),
ct == "application/yaml" || ct == "text/yaml",
ct == "application/csv" || ct == "text/csv":
return body, true
default:
return "", false
}
}
var (
fetchScriptRe = regexp.MustCompile(`(?is)<(script|style|noscript)[^>]*>.*?</(script|style|noscript)>`)
fetchTagRe = regexp.MustCompile(`(?s)<[^>]*>`)
fetchSpaceRe = regexp.MustCompile(`\n{3,}`)
)
func htmlToText(body string) string {
s := fetchScriptRe.ReplaceAllString(body, "")
s = fetchTagRe.ReplaceAllString(s, "\n")
s = html.UnescapeString(s)
s = strings.ReplaceAll(s, "\r", "")
s = fetchSpaceRe.ReplaceAllString(s, "\n\n")
return strings.TrimSpace(s)
}