Class: Nexo::Tools::Fetch

Inherits:
RubyLLM::Tool
  • Object
show all
Defined in:
lib/nexo/tools/fetch.rb

Overview

Read-only HTTP(S) GET, gated by the :fetch capability (denied by default, like :shell) and a host allow-list (SSRF/egress guard). Follows the +ReadFile+/+WriteFile+ shape exactly: authorize first, act, and rescue Permissions::Denied into { error: ... } so the loop never crashes.

Deliberately minimal: a single stdlib GET. No POST/PUT/DELETE, no crawler, no cache, no rate limiter, no redirect-following. The only request header the model influences is a fixed User-Agent. Fetched bodies are UNTRUSTED model input (prompt-injection risk) and are returned raw (truncated), never parsed.

Return shape differs from ReadFile (which returns a bare String): success is { body: <string truncated to MAX_BYTES> }; any denial or error is { error: <message> }.

Constant Summary collapse

MAX_BYTES =

Bodies are truncated to this many bytes (byteslice) before returning.

200_000
UNSAFE_RANGES =

IP ranges IPAddr's loopback?/private?/link_local? predicates miss but that still reach host-local or carrier-internal services. Refused alongside them.

[
  IPAddr.new("0.0.0.0/8"),     # "this host" — 0.0.0.0 reaches localhost on Linux
  IPAddr.new("100.64.0.0/10"), # CGNAT (RFC 6598)
  IPAddr.new("::/128")         # unspecified IPv6
].freeze

Instance Method Summary collapse

Constructor Details

#initialize(sandbox:, permissions:, allow_hosts: []) ⇒ Fetch

allow_hosts scopes which hosts the GET may reach (subdomain-aware); permissions gates the :fetch capability. Both locks must open.



40
41
42
43
44
45
# File 'lib/nexo/tools/fetch.rb', line 40

def initialize(sandbox:, permissions:, allow_hosts: [])
  @sandbox = sandbox
  @permissions = permissions
  @allow_hosts = allow_hosts.map(&:to_s)
  super()
end

Instance Method Details

#execute(url:) ⇒ Object

Order of checks (every denial returns { error: }, never raises into the loop): capability gate → scheme → host allow-list → private-address guard → perform the GET. The private-address guard runs AFTER the allow-list and is never bypassed, so an allow-listed localhost is still refused.



51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
# File 'lib/nexo/tools/fetch.rb', line 51

def execute(url:)
  @permissions.authorize!(:fetch, url)

  uri = URI.parse(url)
  unless %w[http https].include?(uri.scheme)
    raise Permissions::Denied, "only http(s) URLs allowed"
  end
  unless host_allowed?(uri.host)
    raise Permissions::Denied, "host #{uri.host} not in fetch allow-list"
  end

  # Resolve ONCE: reject if any resolved address is unsafe, then connect
  # pinned to that same vetted IP so DNS can't rebind to a private address
  # between the check and the connect. Unresolvable hosts fail open to a
  # hostname connect (which then errors naturally), preserving prior behavior.
  addresses = resolve(uri.host)
  if addresses.any? { |ip| unsafe_ip?(ip) }
    raise Permissions::Denied, "private/loopback address refused: #{uri.host}"
  end

  {body: get(uri, addresses.first).byteslice(0, MAX_BYTES)}
rescue Permissions::Denied => e
  {error: e.message}
rescue Nexo::ApprovalRequired
  # Durable approval (Spec 16): the :approve gate on authorize!(:fetch, url)
  # must PAUSE the run, not become a tool error. Re-raise past the broad
  # rescue below so run_agent can catch it and suspend. (Denied above still
  # becomes {error:} — that is the intended deny contract.)
  raise
rescue => e
  {error: "fetch failed: #{e.message}"}
end