terret-tools-std 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/lib/terret/tools_std/bash.rb +226 -0
- data/lib/terret/tools_std/files.rb +229 -0
- data/lib/terret/tools_std/jobs.rb +255 -0
- data/lib/terret/tools_std/task.rb +127 -0
- data/lib/terret/tools_std/terminals.rb +195 -0
- data/lib/terret/tools_std/todo.rb +158 -0
- data/lib/terret/tools_std/web_fetch.rb +472 -0
- data/lib/terret/tools_std.rb +15 -0
- metadata +86 -0
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Terret
|
|
4
|
+
module ToolsStd
|
|
5
|
+
# `TodoWrite` (docs/subagents.md §7) — Claude Code's name and Claude Code's
|
|
6
|
+
# parameter shape, verbatim, per the M7 rule.
|
|
7
|
+
#
|
|
8
|
+
# This is the smallest illustration in the codebase of what "model-visible
|
|
9
|
+
# means logged" actually buys. The handler validates the statuses, renders
|
|
10
|
+
# the list back as the tool result, and holds NO state at all — that echo
|
|
11
|
+
# is its only storage. The list is durable because the tool result is
|
|
12
|
+
# durable; `derive_messages` projects it into the next request the same way
|
|
13
|
+
# it projects every other result; a restart replays it for free; and
|
|
14
|
+
# `resume_turn` re-derives it with no special case, because there is no
|
|
15
|
+
# special case. A todo SERVICE would have been a second source of truth to
|
|
16
|
+
# reconcile with the log after every crash, and it would disagree with it
|
|
17
|
+
# eventually.
|
|
18
|
+
#
|
|
19
|
+
# One honest limit: compaction can erase it. A `session/compacted` boundary
|
|
20
|
+
# that swallows the last TodoWrite result replaces it with a summary, and
|
|
21
|
+
# whether the plan survives depends on whether the summarizer kept it. The
|
|
22
|
+
# model writes a new list; nothing is corrupted.
|
|
23
|
+
#
|
|
24
|
+
# `concurrency: :serial` because the semantics are order-dependent — two
|
|
25
|
+
# writes in one message mean the last one wins, and "last" should be a
|
|
26
|
+
# property of the message rather than of which fiber returned first.
|
|
27
|
+
class Todo < Hames::Service
|
|
28
|
+
service_key :tools_std_todo
|
|
29
|
+
inject :tools
|
|
30
|
+
config_schema({}) # the TodoWrite tool takes no config
|
|
31
|
+
|
|
32
|
+
# The rendering, and the whole of this tool's vocabulary. An unknown
|
|
33
|
+
# status is refused against exactly this set rather than coerced to
|
|
34
|
+
# something plausible: a coerced status makes the list say a thing the
|
|
35
|
+
# model did not say, in the one place the model keeps its plan.
|
|
36
|
+
BOXES = { "pending" => "[ ]", "in_progress" => "[~]", "completed" => "[x]" }.freeze
|
|
37
|
+
|
|
38
|
+
FIELDS = %w[content status activeForm].freeze
|
|
39
|
+
|
|
40
|
+
DESCRIPTION = "Write the task list for this session. Send the WHOLE list every time: " \
|
|
41
|
+
"what this call does not mention is gone, because the result is the only " \
|
|
42
|
+
"place the list is kept. Mark exactly one item in_progress while you work " \
|
|
43
|
+
"on it, and complete it before starting the next."
|
|
44
|
+
|
|
45
|
+
def start(ctx)
|
|
46
|
+
@ctx = ctx
|
|
47
|
+
register_todo_write
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Nothing is captured from config; there is no knob on this row, and
|
|
51
|
+
# there is no state for one to govern.
|
|
52
|
+
def reconfigure(_config); end
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
# `ctx:` is passed explicitly, like the rest of the roster: the registry
|
|
57
|
+
# would otherwise record the frame on the context it was started in (the
|
|
58
|
+
# root), so a roster mounted into a forked agent scope would leave a
|
|
59
|
+
# registration behind that outlives the fork.
|
|
60
|
+
def register_todo_write
|
|
61
|
+
item = {
|
|
62
|
+
type: "object",
|
|
63
|
+
properties: {
|
|
64
|
+
content: { type: "string", description: "The task, as an imperative: \"Run the tests\"" },
|
|
65
|
+
status: { type: "string", enum: BOXES.keys,
|
|
66
|
+
description: "pending, in_progress, or completed" },
|
|
67
|
+
activeForm: { type: "string",
|
|
68
|
+
description: "The present continuous form shown while the task is " \
|
|
69
|
+
"in progress: \"Running the tests\"" }
|
|
70
|
+
},
|
|
71
|
+
required: FIELDS
|
|
72
|
+
}
|
|
73
|
+
params = {
|
|
74
|
+
type: "object",
|
|
75
|
+
properties: { todos: { type: "array", items: item,
|
|
76
|
+
description: "The complete list, every item, every time" } },
|
|
77
|
+
required: %w[todos]
|
|
78
|
+
}
|
|
79
|
+
# `todos` is required in the schema and defaulted here: an omitted
|
|
80
|
+
# keyword would cost a whole turn to an ArgumentError, where a
|
|
81
|
+
# defaulted one costs a result the model can read and correct.
|
|
82
|
+
@ctx[:tools].register(name: "TodoWrite", description: DESCRIPTION, params: params,
|
|
83
|
+
mutating: false, approval: :never, concurrency: :serial,
|
|
84
|
+
ctx: @ctx) do |todos: nil|
|
|
85
|
+
render(items!(todos))
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def items!(todos)
|
|
90
|
+
unless todos.is_a?(Array)
|
|
91
|
+
raise Terret::Tools::Failure,
|
|
92
|
+
"TodoWrite needs `todos`: the whole list, as an array of " \
|
|
93
|
+
"{content, status, activeForm} objects"
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
todos.each_with_index.map { |item, index| item!(item, index + 1) }
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# A model writes JSON, and which of the two key shapes an item arrives in
|
|
100
|
+
# depends on the adapter that parsed it. Normalizing once here is cheaper
|
|
101
|
+
# than a validation error nobody can act on for a list that was written
|
|
102
|
+
# correctly.
|
|
103
|
+
#
|
|
104
|
+
# Every refusal names the item's POSITION, because in a list of todos
|
|
105
|
+
# nothing else tells them apart — and a hole in the list has no content
|
|
106
|
+
# to be named by at all. `nil` is the case that needs saying twice: it
|
|
107
|
+
# answers `respond_to?(:to_h)` and `nil.to_h` is `{}`, so a missing item
|
|
108
|
+
# used to be reported back as an empty object the model never wrote.
|
|
109
|
+
def item!(item, position)
|
|
110
|
+
unless !item.nil? && item.respond_to?(:to_h) && !item.is_a?(Array)
|
|
111
|
+
raise Terret::Tools::Failure,
|
|
112
|
+
"every todo must be an object with content, status and activeForm; " \
|
|
113
|
+
"item #{position} is #{item.inspect}"
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
item = item.to_h.transform_keys(&:to_s)
|
|
117
|
+
|
|
118
|
+
# `status` is deliberately not checked for being text here. A status
|
|
119
|
+
# that arrived as something else is a WRONG status rather than an
|
|
120
|
+
# absent one, and the branch below is the one that can say so while
|
|
121
|
+
# naming what the model actually wrote.
|
|
122
|
+
missing = %w[content activeForm].reject { |f| item[f].is_a?(String) }
|
|
123
|
+
unless missing.empty?
|
|
124
|
+
raise Terret::Tools::Failure,
|
|
125
|
+
"every todo needs #{FIELDS.join(', ')}; item #{position} is missing " \
|
|
126
|
+
"#{missing.join(', ')}: #{item.inspect}"
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
unless BOXES.key?(item["status"])
|
|
130
|
+
raise Terret::Tools::Failure,
|
|
131
|
+
"#{item['status'].inspect} is not a todo status (item #{position}); use one " \
|
|
132
|
+
"of #{BOXES.keys.join(', ')}"
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
item
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def render(items)
|
|
139
|
+
return "(the todo list is empty)" if items.empty?
|
|
140
|
+
|
|
141
|
+
items.map { |item| "- #{BOXES.fetch(item['status'])} #{line(item)}" }.join("\n")
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# One item, one line. This is the forgeability register the LEDGER
|
|
145
|
+
# literal keeps in tools_std/jobs.rb, and it is the one place in the
|
|
146
|
+
# roster where forging matters rather than merely misleading: the
|
|
147
|
+
# rendered list IS the state, so a content string carrying a newline
|
|
148
|
+
# could write a checkbox line of its own and hand the model back a task
|
|
149
|
+
# it never completed as done. Escaped rather than dropped — what the
|
|
150
|
+
# model wrote stays legible, it just cannot be a line.
|
|
151
|
+
def line(item) = label(item).gsub(/[\r\n]/) { |c| c == "\n" ? "\\n" : "\\r" }
|
|
152
|
+
|
|
153
|
+
# The running item is shown in its active form, which is the one thing
|
|
154
|
+
# activeForm is for; everything else is shown as what it is.
|
|
155
|
+
def label(item) = item["status"] == "in_progress" ? item["activeForm"] : item["content"]
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
end
|
|
@@ -0,0 +1,472 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "uri"
|
|
5
|
+
require "resolv"
|
|
6
|
+
require "ipaddr"
|
|
7
|
+
|
|
8
|
+
module Terret
|
|
9
|
+
module ToolsStd
|
|
10
|
+
# `WebFetch` (docs/exec.md §5) — Claude Code's name over an ordinary GET,
|
|
11
|
+
# behind §13's "web_fetch gets an allow and deny domain policy row". The
|
|
12
|
+
# row is this service's own config: `{ allow: [globs], deny: [globs] }`.
|
|
13
|
+
#
|
|
14
|
+
# Three things here are more than a GET.
|
|
15
|
+
#
|
|
16
|
+
# The policy is DENY-BY-DEFAULT. An unconfigured row fetches nothing at
|
|
17
|
+
# all, because the failure mode of the other default is a model reading an
|
|
18
|
+
# attacker's page on its first turn, and there is no configuration mistake
|
|
19
|
+
# that produces that from an empty allow list.
|
|
20
|
+
#
|
|
21
|
+
# Every REDIRECT HOP is re-checked against that policy. A policy applied
|
|
22
|
+
# only to the URL a model typed is a policy any allowed host can launder a
|
|
23
|
+
# fetch through by answering `301 Location: https://anywhere/`, so the
|
|
24
|
+
# check lives on the loop rather than in front of it — and the default
|
|
25
|
+
# transport deliberately does not follow redirects itself, because a
|
|
26
|
+
# transport that followed them would do it without ever consulting the
|
|
27
|
+
# policy.
|
|
28
|
+
#
|
|
29
|
+
# And the policy matches a HOSTNAME STRING, not an IP. An IP literal is
|
|
30
|
+
# matched as its own text (`10.0.0.1` matches the glob `10.0.0.1`, and
|
|
31
|
+
# nothing under an empty allow list), and a name on the allow list is
|
|
32
|
+
# admitted no matter where it resolves.
|
|
33
|
+
#
|
|
34
|
+
# That last part is why WebFetch does NOT lean on the sandbox for network
|
|
35
|
+
# safety the way the rest of §6.6 does: this GET runs HOST-side through
|
|
36
|
+
# Net::HTTP, so `network: none` on the sandbox row constrains the container
|
|
37
|
+
# and not this tool. An allowlisted name resolving to 127.0.0.1 or to the
|
|
38
|
+
# 169.254.169.254 cloud-metadata endpoint would reach host-local services
|
|
39
|
+
# and instance credentials with nothing in the way. So the tool carries its
|
|
40
|
+
# own SSRF floor: #check_address! resolves the host and refuses loopback and
|
|
41
|
+
# link-local targets before any connection, on the model's URL and on every
|
|
42
|
+
# redirect hop. It is a FLOOR, not full SSRF control — private ranges
|
|
43
|
+
# (10/8, 172.16/12, 192.168/16) stay reachable by default because a
|
|
44
|
+
# deployment may legitimately fetch internal services, and blocking them is
|
|
45
|
+
# a documented M8 config knob. It is also not DNS-rebinding protection: the
|
|
46
|
+
# address is resolved once for the check and the connection re-resolves, so
|
|
47
|
+
# a name that answers differently between the two still connects to the
|
|
48
|
+
# second answer. Pinning the connection to the checked IP would mean
|
|
49
|
+
# threading it through the injectable transport seam, whose contract is a
|
|
50
|
+
# bare `call(url)`; the honest floor keeps that seam intact and closes the
|
|
51
|
+
# static-record and misconfigured-allowlist vectors, which are the ones a
|
|
52
|
+
# deployment actually hits.
|
|
53
|
+
class WebFetch < Hames::Service
|
|
54
|
+
service_key :tools_std_web_fetch
|
|
55
|
+
inject :tools
|
|
56
|
+
# resolver: and transport: are injectable seams (tests pass callables),
|
|
57
|
+
# not YAML config, so they are deliberately absent from the schema.
|
|
58
|
+
config_schema allow: { type: Array, default: [],
|
|
59
|
+
doc: "host globs WebFetch may reach; empty denies every host" },
|
|
60
|
+
deny: { type: Array, default: [], doc: "host globs WebFetch may never reach" },
|
|
61
|
+
timeout: { type: Numeric, default: 30.0, doc: "seconds a fetch may run" },
|
|
62
|
+
max_bytes: { type: Integer, default: 100_000,
|
|
63
|
+
doc: "bytes of a response body read before truncation" }
|
|
64
|
+
|
|
65
|
+
# What one result may show. A display decision, the tool's own honest
|
|
66
|
+
# cap rather than policy's — a truncator listening on tools/post_execute
|
|
67
|
+
# is free to cut further, and this is what the model sees when none does.
|
|
68
|
+
DEFAULT_MAX_BYTES = 100_000
|
|
69
|
+
|
|
70
|
+
# timeout=0 must not mean "no timeout" (terret-morph's lesson): anything
|
|
71
|
+
# non-positive floors back to this.
|
|
72
|
+
DEFAULT_TIMEOUT = 30.0
|
|
73
|
+
|
|
74
|
+
# A server that redirects to itself is the cheapest denial of service
|
|
75
|
+
# there is; this is what makes the chain terminate.
|
|
76
|
+
MAX_REDIRECTS = 5
|
|
77
|
+
|
|
78
|
+
# Deliberately the same literal `Bash` separates its output with, so a
|
|
79
|
+
# model learns the convention once. Same caveats as there: a server CAN
|
|
80
|
+
# print this line, so it is a readability device and not a security
|
|
81
|
+
# boundary. What it does deliver is that the genuine remarks are always
|
|
82
|
+
# last, after anything the page forged, and that they are advisory data
|
|
83
|
+
# rather than instructions (docs/security.md) — nothing downstream acts
|
|
84
|
+
# on them, so a forged line buys a confusing result and no authority.
|
|
85
|
+
LEDGER = "--- terret ---"
|
|
86
|
+
|
|
87
|
+
USER_AGENT = "terret"
|
|
88
|
+
|
|
89
|
+
DESCRIPTION = "Fetch an http or https URL and return the page as text. Only hosts this " \
|
|
90
|
+
"deployment allows can be fetched, and redirects are followed only while " \
|
|
91
|
+
"every hop stays allowed. The page comes back whole rather than summarized."
|
|
92
|
+
|
|
93
|
+
def start(ctx)
|
|
94
|
+
@ctx = ctx
|
|
95
|
+
register_web_fetch
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Nothing is captured here: the policy, the cap and the timeout are all
|
|
99
|
+
# read at call time, so a swapped row governs the very next fetch and
|
|
100
|
+
# there is nothing to re-derive. Saying so beats letting the base class
|
|
101
|
+
# warn that this row needs a remount when it does not.
|
|
102
|
+
def reconfigure(_config); end
|
|
103
|
+
|
|
104
|
+
private
|
|
105
|
+
|
|
106
|
+
# `ctx:` is passed explicitly: the registry would otherwise record the
|
|
107
|
+
# frame on the context it was started in (the root), so a roster mounted
|
|
108
|
+
# into a forked agent scope would leave registrations behind that
|
|
109
|
+
# outlive the fork — a disposed agent with a tool of its own that can
|
|
110
|
+
# still reach the network.
|
|
111
|
+
def tool(name, description, params, mutating:, approval:, concurrency:, &handler)
|
|
112
|
+
@ctx[:tools].register(name: name, description: description, params: params,
|
|
113
|
+
mutating: mutating, approval: approval,
|
|
114
|
+
concurrency: concurrency, ctx: @ctx, &handler)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def register_web_fetch
|
|
118
|
+
params = {
|
|
119
|
+
type: "object",
|
|
120
|
+
properties: {
|
|
121
|
+
url: { type: "string", description: "Absolute http or https URL to fetch" }
|
|
122
|
+
},
|
|
123
|
+
required: ["url"]
|
|
124
|
+
}
|
|
125
|
+
# `prompt:` is accepted and ignored. Claude Code's WebFetch takes one
|
|
126
|
+
# and runs a small model over the page; this tool hands the page back
|
|
127
|
+
# whole instead, so there is nothing for a prompt to do — but a model
|
|
128
|
+
# that has written that call before writes one anyway, and an unknown
|
|
129
|
+
# keyword would cost a whole turn to an ArgumentError. It stays out of
|
|
130
|
+
# the schema so nothing advertises a knob that does not exist.
|
|
131
|
+
tool("WebFetch", DESCRIPTION, params, mutating: false, approval: :policy,
|
|
132
|
+
concurrency: :serial) do |url:, prompt: nil|
|
|
133
|
+
fetch_url(url)
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def fetch_url(url)
|
|
138
|
+
uri = admit!(parse!(url))
|
|
139
|
+
hops = 0
|
|
140
|
+
loop do
|
|
141
|
+
status, headers, body = answer!(transport.call(uri.to_s))
|
|
142
|
+
location = header(headers, "location")
|
|
143
|
+
# A 3xx with nothing to follow is not a redirect, it is a status,
|
|
144
|
+
# and gets reported like any other rather than looping.
|
|
145
|
+
return render(status, body, hops, uri) unless redirect?(status) && location
|
|
146
|
+
|
|
147
|
+
if hops >= MAX_REDIRECTS
|
|
148
|
+
raise Terret::Tools::Failure,
|
|
149
|
+
"gave up after more than #{MAX_REDIRECTS} redirects, last at #{uri}"
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
hops += 1
|
|
153
|
+
uri = admit!(resolve!(uri, location))
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# -- what a URL may be ---------------------------------------------------
|
|
158
|
+
|
|
159
|
+
# The offending string is never echoed back. A malformed URL may still
|
|
160
|
+
# carry a password, and the message goes straight into the durable
|
|
161
|
+
# session log; what the model wrote is already in its own tool call,
|
|
162
|
+
# where redaction can see it, so repeating it here would only add a
|
|
163
|
+
# second copy in a place nothing scrubs.
|
|
164
|
+
def parse!(url)
|
|
165
|
+
unless url.is_a?(String)
|
|
166
|
+
raise Terret::Tools::Failure, "url must be a string; got #{url.class}"
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
URI(url)
|
|
170
|
+
rescue URI::Error, ArgumentError
|
|
171
|
+
raise Terret::Tools::Failure, "url could not be parsed as a URL; nothing was fetched"
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def resolve!(base, location)
|
|
175
|
+
base.merge(location.to_s)
|
|
176
|
+
rescue URI::Error, ArgumentError
|
|
177
|
+
raise Terret::Tools::Failure,
|
|
178
|
+
"#{base.host} redirected to a location that could not be parsed as a URL"
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
# Every check a URL has to pass before it may be handed to the transport,
|
|
182
|
+
# applied to the model's own URL and to every redirect target alike.
|
|
183
|
+
def admit!(uri)
|
|
184
|
+
scheme = uri.scheme&.downcase
|
|
185
|
+
unless %w[http https].include?(scheme)
|
|
186
|
+
raise Terret::Tools::Failure,
|
|
187
|
+
"WebFetch speaks http and https only, not #{scheme.inspect}; nothing was fetched"
|
|
188
|
+
end
|
|
189
|
+
# Refused rather than stripped: stripping would fetch something the
|
|
190
|
+
# caller did not ask for. A credential belongs in a header a
|
|
191
|
+
# deployment controls, not in an argument a model writes.
|
|
192
|
+
if uri.userinfo
|
|
193
|
+
raise Terret::Tools::Failure,
|
|
194
|
+
"refusing a URL that carries credentials in its userinfo; nothing was fetched"
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# `File.fnmatch("*", "")` is true, so a hostless URL would sail
|
|
198
|
+
# straight through a permissive allow list into a transport with
|
|
199
|
+
# nothing to connect to.
|
|
200
|
+
host = uri.host.to_s.downcase
|
|
201
|
+
raise Terret::Tools::Failure, "url names no host; nothing was fetched" if host.empty?
|
|
202
|
+
|
|
203
|
+
check_policy!(host)
|
|
204
|
+
# The SSRF floor resolves the bracket-STRIPPED hostname. `uri.host` keeps
|
|
205
|
+
# the brackets on an IPv6 literal ("[::1]"), which no resolver recognizes
|
|
206
|
+
# — the floor would see no address and connect straight through to the
|
|
207
|
+
# loopback the brackets name. `uri.hostname` strips them ("::1"), which is
|
|
208
|
+
# what resolves and what IPAddr classifies as loopback/link-local. (IPv4
|
|
209
|
+
# `127.0.0.1` has no brackets, so it was caught either way; this closes
|
|
210
|
+
# the IPv6 hole the bracket form opened.)
|
|
211
|
+
check_address!(uri.hostname.to_s.downcase)
|
|
212
|
+
uri
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
# -- the SSRF floor ------------------------------------------------------
|
|
216
|
+
|
|
217
|
+
# The address check the domain policy cannot make: a name the allow list
|
|
218
|
+
# admits is still refused if it resolves to a loopback or link-local
|
|
219
|
+
# address, so an allowlisted host cannot launder a fetch to 127.0.0.1 or
|
|
220
|
+
# to 169.254.169.254 (see the class comment for why this lives in the tool
|
|
221
|
+
# rather than the sandbox, and for the DNS-rebinding boundary it accepts).
|
|
222
|
+
# An unresolvable name answers no addresses and passes here — there is no
|
|
223
|
+
# internal target to refuse, and the connection fails on its own.
|
|
224
|
+
def check_address!(host)
|
|
225
|
+
addresses(host).each do |ip|
|
|
226
|
+
next unless forbidden_address?(ip)
|
|
227
|
+
|
|
228
|
+
# The IP is named so a model reading the refusal can see WHY, but it
|
|
229
|
+
# is the resolved address, never a secret the caller wrote.
|
|
230
|
+
raise Terret::Tools::Failure,
|
|
231
|
+
"#{host} resolves to #{ip}, a loopback or link-local address WebFetch " \
|
|
232
|
+
"refuses; nothing was fetched"
|
|
233
|
+
end
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
# The resolution seam, injectable like the transport so this gem's unit
|
|
237
|
+
# tests need no DNS: a callable taking a host and answering an array of
|
|
238
|
+
# address strings. Resolv.getaddresses answers [] rather than raising on a
|
|
239
|
+
# name that does not resolve, which is exactly the fail-open-safe shape
|
|
240
|
+
# here — nothing to refuse.
|
|
241
|
+
def resolver = config[:resolver] || Resolv.method(:getaddresses)
|
|
242
|
+
|
|
243
|
+
def addresses(host) = Array(resolver.call(host))
|
|
244
|
+
|
|
245
|
+
# Loopback (127.0.0.0/8, ::1) and link-local (169.254.0.0/16, fe80::/10),
|
|
246
|
+
# exactly the two ranges IPAddr's own predicates name. A string that does
|
|
247
|
+
# not parse as an IP is treated as forbidden: a resolver answer this tool
|
|
248
|
+
# cannot verify fails closed rather than being connected to blind.
|
|
249
|
+
def forbidden_address?(ip)
|
|
250
|
+
addr = IPAddr.new(ip.to_s)
|
|
251
|
+
addr.loopback? || addr.link_local?
|
|
252
|
+
rescue IPAddr::InvalidAddressError
|
|
253
|
+
true
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
# -- the domain policy ---------------------------------------------------
|
|
257
|
+
|
|
258
|
+
# Globs are `File.fnmatch` patterns, the same dialect and the same flags
|
|
259
|
+
# the tool AllowList uses (Terret::Tools::AllowList): case-sensitive, and
|
|
260
|
+
# `*` does not match a leading dot. Both fail closed.
|
|
261
|
+
#
|
|
262
|
+
# The HOST is lowered first, and that is not cosmetic. DNS hostnames are
|
|
263
|
+
# case-insensitive while `fnmatch` is not, so matching the raw host
|
|
264
|
+
# string would let `EVIL.EXAMPLE` walk past a deny rule spelled in
|
|
265
|
+
# lowercase — a fail-OPEN hole, the one direction this policy may not
|
|
266
|
+
# fail in. Patterns themselves are left exactly as written, so a pattern
|
|
267
|
+
# spelled with capitals simply never matches, which fails closed.
|
|
268
|
+
#
|
|
269
|
+
# A port is not part of what is matched. This is a hostname policy:
|
|
270
|
+
# `allow: ["internal.example.com"]` permits that host on any port. One
|
|
271
|
+
# matching rule is worth more than a second one that only half the
|
|
272
|
+
# patterns would be written against, and port-level egress control is
|
|
273
|
+
# the sandbox row's business rather than this glob's.
|
|
274
|
+
def check_policy!(host)
|
|
275
|
+
if deny_patterns.any? { |p| File.fnmatch(p, host) }
|
|
276
|
+
raise Terret::Tools::Failure,
|
|
277
|
+
"#{host} is denied by the WebFetch domain policy; nothing was fetched"
|
|
278
|
+
end
|
|
279
|
+
return if allow_patterns.any? { |p| File.fnmatch(p, host) }
|
|
280
|
+
|
|
281
|
+
# Deny-by-default: an empty allow list is not "unconfigured, so
|
|
282
|
+
# permit", it is "nobody has said this deployment may reach the web".
|
|
283
|
+
raise Terret::Tools::Failure,
|
|
284
|
+
"#{host} is not on the WebFetch domain allow list; nothing was fetched"
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
def allow_patterns = Array(config[:allow]).map(&:to_s)
|
|
288
|
+
def deny_patterns = Array(config[:deny]).map(&:to_s)
|
|
289
|
+
|
|
290
|
+
# -- the transport -------------------------------------------------------
|
|
291
|
+
|
|
292
|
+
# The seam every fetch reaches the wire through, injectable so this
|
|
293
|
+
# gem's unit tests need no network (terret-morph's pattern): a callable
|
|
294
|
+
# taking the URL and answering `[status, headers, body]` — a status that
|
|
295
|
+
# responds to #to_i, a Hash of response headers in whatever spelling the
|
|
296
|
+
# server used (#header folds the case), and the body as a String.
|
|
297
|
+
#
|
|
298
|
+
# Three fields rather than the two the plan sketched, because the
|
|
299
|
+
# per-hop policy check needs the Location header out here. A transport
|
|
300
|
+
# that answered a body alone would leave this tool either not following
|
|
301
|
+
# redirects at all, or following them down inside the transport — which
|
|
302
|
+
# is exactly where the domain policy cannot see them.
|
|
303
|
+
def transport = config[:transport] || method(:http_get)
|
|
304
|
+
|
|
305
|
+
# A transport is a deployment's own wiring, so a wrong shape is a
|
|
306
|
+
# configuration bug rather than a tool outcome. It keeps its class name
|
|
307
|
+
# (Registry#execute renders a Failure message-only, and here the class
|
|
308
|
+
# IS the diagnosis) and states the contract, instead of destructuring
|
|
309
|
+
# into a silent "(no content)" on every single fetch.
|
|
310
|
+
def answer!(answer)
|
|
311
|
+
return answer if answer.is_a?(Array) && answer.length == 3
|
|
312
|
+
|
|
313
|
+
raise TypeError,
|
|
314
|
+
"the WebFetch transport must answer [status, headers, body]; got #{answer.class}"
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
def redirect?(status) = (300..399).cover?(status.to_i)
|
|
318
|
+
|
|
319
|
+
# Header names are case-insensitive on the wire and a transport hands
|
|
320
|
+
# back whatever spelling it saw, so the lookup does the folding rather
|
|
321
|
+
# than the contract. An Array value is tolerated because that is the
|
|
322
|
+
# shape `Net::HTTPResponse#to_hash` produces, and a transport built from
|
|
323
|
+
# one should not have to remember which of the two accessors to use.
|
|
324
|
+
def header(headers, name)
|
|
325
|
+
return nil unless headers.respond_to?(:each_pair)
|
|
326
|
+
|
|
327
|
+
_, value = headers.each_pair.find { |k, _| k.to_s.downcase == name }
|
|
328
|
+
value.is_a?(Array) ? value.first : value
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
# The default transport: one GET, no redirect following. Following them
|
|
332
|
+
# here would be following them without the policy, which is the whole
|
|
333
|
+
# thing #fetch_url's loop exists to prevent.
|
|
334
|
+
def http_get(url)
|
|
335
|
+
uri = URI(url)
|
|
336
|
+
request = Net::HTTP::Get.new(uri.request_uri, "User-Agent" => USER_AGENT)
|
|
337
|
+
connection(uri).start do |http|
|
|
338
|
+
# Captured rather than returned: `Net::HTTP#request` hands back the
|
|
339
|
+
# RESPONSE, not its block's value, so returning the tuple from the
|
|
340
|
+
# inner block would send a Net::HTTPOK where a status was expected.
|
|
341
|
+
# Nothing driving a stub transport can see that — the live lane is
|
|
342
|
+
# what this line was written against.
|
|
343
|
+
answer = nil
|
|
344
|
+
http.request(request) do |response|
|
|
345
|
+
answer = [response.code.to_i, response.each_header.to_h, read_bounded(response)]
|
|
346
|
+
end
|
|
347
|
+
answer
|
|
348
|
+
end
|
|
349
|
+
end
|
|
350
|
+
|
|
351
|
+
# A server answering with a terabyte must cost this process max_bytes and
|
|
352
|
+
# not a terabyte, so chunks past the cap are dropped as they arrive.
|
|
353
|
+
#
|
|
354
|
+
# Dropped, and NOT `break`-ed out of: abandoning Net::HTTP's read_body
|
|
355
|
+
# mid-body leaves the socket positioned inside the response, and the
|
|
356
|
+
# `self.body` in Net::HTTPResponse#reading_body's ensure then reads the
|
|
357
|
+
# remaining payload as a fresh chunk-size line — "Net::HTTPBadResponse:
|
|
358
|
+
# wrong chunk size line" on roughly every other chunked, gzipped page,
|
|
359
|
+
# depending on whether the leftover bytes happened to parse as hex. The
|
|
360
|
+
# stream is therefore drained to its end; what is bounded here is memory,
|
|
361
|
+
# and duration stays the read timeout's job.
|
|
362
|
+
def read_bounded(response)
|
|
363
|
+
limit = max_bytes
|
|
364
|
+
# A binary buffer: chunks arrive as bytes, and appending them to a
|
|
365
|
+
# UTF-8 string raises the moment one is not ASCII. #scrub is the layer
|
|
366
|
+
# that turns them into text.
|
|
367
|
+
body = +"".b
|
|
368
|
+
# `<=`, so a body exactly the size of the cap still takes on the chunk
|
|
369
|
+
# that makes the truncation visible. `<` would truncate silently at
|
|
370
|
+
# the boundary.
|
|
371
|
+
response.read_body { |chunk| body << chunk if body.bytesize <= limit }
|
|
372
|
+
body
|
|
373
|
+
end
|
|
374
|
+
|
|
375
|
+
def connection(uri)
|
|
376
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
377
|
+
http.use_ssl = uri.scheme == "https"
|
|
378
|
+
http.open_timeout = timeout
|
|
379
|
+
http.read_timeout = timeout
|
|
380
|
+
http.write_timeout = timeout
|
|
381
|
+
http
|
|
382
|
+
end
|
|
383
|
+
|
|
384
|
+
def timeout
|
|
385
|
+
configured = config[:timeout].to_f
|
|
386
|
+
configured.positive? ? configured : DEFAULT_TIMEOUT
|
|
387
|
+
end
|
|
388
|
+
|
|
389
|
+
# -- rendering -----------------------------------------------------------
|
|
390
|
+
|
|
391
|
+
# Clamped rather than trusted: a row carrying a negative cap would
|
|
392
|
+
# otherwise byteslice its way to nil and raise on every single call,
|
|
393
|
+
# turning one bad config value into a tool that never works. Zero is
|
|
394
|
+
# then an honest answer — the result says it kept nothing and how much
|
|
395
|
+
# it dropped, which is visible in the very next tool result instead of
|
|
396
|
+
# in a crash a turn later.
|
|
397
|
+
def max_bytes = [config[:max_bytes] || DEFAULT_MAX_BYTES, 0].max
|
|
398
|
+
|
|
399
|
+
def render(status, body, hops, uri)
|
|
400
|
+
text, dropped = cap(scrub(body))
|
|
401
|
+
remarks = remarks_for(status, text, dropped, hops, uri)
|
|
402
|
+
return text.empty? ? "(no content)" : text if remarks.empty?
|
|
403
|
+
|
|
404
|
+
# The page's own bytes are never rewritten: the newline below only
|
|
405
|
+
# puts the separator on a line of its own, and a body that already
|
|
406
|
+
# ended in one simply gets a blank line before the ledger.
|
|
407
|
+
"#{text.empty? ? '' : "#{text}\n"}#{LEDGER}\n#{remarks.join("\n")}"
|
|
408
|
+
end
|
|
409
|
+
|
|
410
|
+
def remarks_for(status, text, dropped, hops, uri)
|
|
411
|
+
remarks = []
|
|
412
|
+
# 2xx is the silent case — announcing success on every call would be
|
|
413
|
+
# noise in every result a model reads. Everything else is reported: a
|
|
414
|
+
# 404 is an answer the server gave, not a crash, and a model that is
|
|
415
|
+
# shown the error page without the status will read it as content.
|
|
416
|
+
remarks << "HTTP #{status}" unless (200..299).cover?(status.to_i)
|
|
417
|
+
# The model asked for one URL and is looking at another's bytes. It
|
|
418
|
+
# has to be told which, or every relative link on the page resolves
|
|
419
|
+
# against the wrong base.
|
|
420
|
+
remarks << "followed #{hops} redirect#{'s' unless hops == 1} to #{uri}" if hops.positive?
|
|
421
|
+
# Both counts measure the RENDERED text — what a model would have been
|
|
422
|
+
# shown — rather than what the server sent. Two things separate them:
|
|
423
|
+
# scrubbing has already replaced anything that was not valid UTF-8 (a
|
|
424
|
+
# replacement character is three bytes where the original may have
|
|
425
|
+
# been one), and the default transport stops KEEPING bytes past the
|
|
426
|
+
# cap, so a page much larger than max_bytes reports the bytes it
|
|
427
|
+
# actually held on to. "of rendered text" is what keeps this line from
|
|
428
|
+
# claiming it measured the page.
|
|
429
|
+
if dropped.positive?
|
|
430
|
+
remarks << "content truncated at max_bytes: kept the first #{text.bytesize} bytes " \
|
|
431
|
+
"of rendered text and dropped #{dropped} more"
|
|
432
|
+
end
|
|
433
|
+
remarks
|
|
434
|
+
end
|
|
435
|
+
|
|
436
|
+
# A server's body is whatever bytes it felt like sending, and the
|
|
437
|
+
# session log refuses invalid UTF-8 at the durable append boundary, so
|
|
438
|
+
# this is the layer where they have to become storable — replacing what
|
|
439
|
+
# was never valid rather than dropping the whole answer on the floor.
|
|
440
|
+
#
|
|
441
|
+
# No transcoding by declared charset: a page labelled iso-8859-1 comes
|
|
442
|
+
# back with its non-ASCII bytes replaced rather than converted. Guessing
|
|
443
|
+
# an encoding from a header the server may have got wrong is a second
|
|
444
|
+
# way to corrupt text, and the honest note beats the half-measure.
|
|
445
|
+
def scrub(body)
|
|
446
|
+
text = body.to_s
|
|
447
|
+
text = text.dup.force_encoding(Encoding::UTF_8) unless text.encoding == Encoding::UTF_8
|
|
448
|
+
text.scrub
|
|
449
|
+
end
|
|
450
|
+
|
|
451
|
+
def cap(text)
|
|
452
|
+
limit = max_bytes
|
|
453
|
+
return [text, 0] if text.bytesize <= limit
|
|
454
|
+
|
|
455
|
+
kept = whole_characters(text.byteslice(0, limit))
|
|
456
|
+
[kept, text.bytesize - kept.bytesize]
|
|
457
|
+
end
|
|
458
|
+
|
|
459
|
+
# Cutting at a byte offset can split a character in half, and those
|
|
460
|
+
# halves are bytes this file manufactured — the server never sent them,
|
|
461
|
+
# and a durable append JSON-encodes the payload, so a manufactured half
|
|
462
|
+
# raises a layer away from the code that broke it. At most three bytes
|
|
463
|
+
# come back off, the longest tail a split UTF-8 character can leave.
|
|
464
|
+
# Belt and braces after #scrub, and kept anyway: the same rule Bash
|
|
465
|
+
# holds itself to, for the same reason.
|
|
466
|
+
def whole_characters(text)
|
|
467
|
+
text = text.byteslice(0, text.bytesize - 1) until text.valid_encoding?
|
|
468
|
+
text
|
|
469
|
+
end
|
|
470
|
+
end
|
|
471
|
+
end
|
|
472
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
begin
|
|
4
|
+
require "terret"
|
|
5
|
+
rescue LoadError
|
|
6
|
+
require_relative "../../../terret-core/lib/terret" # monorepo path source
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
require_relative "tools_std/files"
|
|
10
|
+
require_relative "tools_std/bash"
|
|
11
|
+
require_relative "tools_std/terminals"
|
|
12
|
+
require_relative "tools_std/web_fetch"
|
|
13
|
+
require_relative "tools_std/task"
|
|
14
|
+
require_relative "tools_std/jobs"
|
|
15
|
+
require_relative "tools_std/todo"
|