Class: Nexo::Tools::Fetch
- Inherits:
-
RubyLLM::Tool
- Object
- RubyLLM::Tool
- Nexo::Tools::Fetch
- 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
-
#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. -
#initialize(sandbox:, permissions:, allow_hosts: []) ⇒ Fetch
constructor
allow_hostsscopes which hosts the GET may reach (subdomain-aware);permissionsgates the:fetchcapability.
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 = @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.(: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.} 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.}"} end |