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
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: f478bf0ef01eea37428130547c4ac4f12c4eaa9748755fabc2ea50092995f313
|
|
4
|
+
data.tar.gz: 864c91ca3da1048139a7ec0393e5fb0b154221be7e95cb045d5815dd4e28cec5
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 9271fb5ad1490767a32d3fcc2d39c73535aee8ee4e1f15ab18ce81d79ab4d7ebed14474ad3e16f44f8e342e66d458a7b5dcebd06a313e2ce3897f2eadaf4d9b7
|
|
7
|
+
data.tar.gz: 23e18fdeb8810c78eb0614b2a096d00f72016772740ed34f4c7da76939393cfa5fb56a210b7e48e38fbb4041bd19752f34f4589f7d0fb3ca7912a6255224e275
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Terret
|
|
4
|
+
module ToolsStd
|
|
5
|
+
# `Bash` (docs/exec.md §5) — Claude Code's name over ctx[:shell]'s
|
|
6
|
+
# persistent per-session bash.
|
|
7
|
+
#
|
|
8
|
+
# Two things here are more than delegation. The first is approval. §13:
|
|
9
|
+
# outside a sandbox an agent that can run arbitrary shell commands needs
|
|
10
|
+
# a human every time; inside one the container is already the backstop
|
|
11
|
+
# and Bash is governed like any other mutating tool. That verdict is
|
|
12
|
+
# derived when the tool is REGISTERED and lives in a Definition nobody
|
|
13
|
+
# re-reads, so a hot sandbox swap would otherwise leave a stale value in
|
|
14
|
+
# front of a shell whose isolation had changed underneath it — which is
|
|
15
|
+
# what the config/updated listener below exists for.
|
|
16
|
+
#
|
|
17
|
+
# The second is what the seam hands back. Shell::Result carries facts the
|
|
18
|
+
# caller never asked for — the session restarted, output was dropped — in
|
|
19
|
+
# a `notice` field of its own, precisely so stdout stays exactly what the
|
|
20
|
+
# terminal carried. A tool rendering stdout alone would silently swallow
|
|
21
|
+
# both, so the notice is reported below a separator, where a model can
|
|
22
|
+
# tell it from something the command itself printed.
|
|
23
|
+
class Bash < Hames::Service
|
|
24
|
+
service_key :tools_std_bash
|
|
25
|
+
inject :tools, :shell, :sandbox
|
|
26
|
+
config_schema max_output: { type: Integer, default: 30_000,
|
|
27
|
+
doc: "bytes of Bash output returned to the model before truncation" }
|
|
28
|
+
|
|
29
|
+
# What one result may show. The seam has its own cap
|
|
30
|
+
# (Shell::DEFAULT_MAX_OUTPUT, a mebibyte) and it is a memory bound;
|
|
31
|
+
# this one is a display decision, the tool's own honest cap rather than
|
|
32
|
+
# policy's — a truncator listening on tools/post_execute is free to cut
|
|
33
|
+
# further, and this is what the model sees when none does.
|
|
34
|
+
DEFAULT_MAX_OUTPUT = 30_000
|
|
35
|
+
|
|
36
|
+
# The line between the command's bytes and this file's remarks. It is a
|
|
37
|
+
# fixed literal in a stream a command controls, so a command CAN print
|
|
38
|
+
# it — this is a readability device, not a security boundary, and it is
|
|
39
|
+
# not claimed as one. What it does deliver is that the genuine remarks
|
|
40
|
+
# are always the last thing in the result, appended after anything a
|
|
41
|
+
# command forged, and that the remarks are advisory data rather than
|
|
42
|
+
# instructions (docs/security.md): nothing downstream acts on them, so
|
|
43
|
+
# a forged line buys a confusing result and no authority.
|
|
44
|
+
LEDGER = "--- terret ---"
|
|
45
|
+
|
|
46
|
+
DESCRIPTION = "Run a command in this session's persistent bash. The same shell process " \
|
|
47
|
+
"serves every call in a session, so state persists: a `cd` or an `export` " \
|
|
48
|
+
"from one call is still in effect on the next. A command that hits its " \
|
|
49
|
+
"timeout is interrupted and its shell replaced, which resets the working " \
|
|
50
|
+
"directory and every variable — the result says so when that happens."
|
|
51
|
+
|
|
52
|
+
def start(ctx)
|
|
53
|
+
@ctx = ctx
|
|
54
|
+
# One effect frame, owned by this row (start runs under the loader's
|
|
55
|
+
# with_owner), disposing whichever registration is current. The
|
|
56
|
+
# indirection is load-bearing: the loader emits config/updated
|
|
57
|
+
# OUTSIDE with_owner, so a registration made from the listener below
|
|
58
|
+
# belongs to no row at all — and an ownerless Bash would outlive the
|
|
59
|
+
# row that mounted it, leaving a tool holding shell authority that
|
|
60
|
+
# unloading the plugin could no longer take away.
|
|
61
|
+
@ctx.effect do
|
|
62
|
+
register_bash
|
|
63
|
+
-> { @registration&.call }
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Which row carries the sandbox knob is not this service's business —
|
|
67
|
+
# it may be the sandbox row's own config, or a provider reading
|
|
68
|
+
# someone else's — so the verdict is re-derived on any row's swap
|
|
69
|
+
# rather than an id being guessed, and the tool is rebuilt only when
|
|
70
|
+
# the answer actually moved.
|
|
71
|
+
#
|
|
72
|
+
# A provider whose `isolated?` raises during that re-derivation is
|
|
73
|
+
# isolated by the bus (listener errors do not escape an emit), which
|
|
74
|
+
# leaves the PREVIOUS approval standing rather than a wrong one.
|
|
75
|
+
# That is the safe direction while `:policy` on a mutating tool and
|
|
76
|
+
# `:always` park identically (docs/exec.md §5), and it becomes
|
|
77
|
+
# load-bearing the day a consumer tells those two apart.
|
|
78
|
+
@ctx.on("config/updated") { |_id, _config| refresh! }
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# max_output is read at call time, so a swapped row governs the very
|
|
82
|
+
# next call with nothing to re-derive here. The approval IS a
|
|
83
|
+
# registration-time capture — the listener above, not a remount, is
|
|
84
|
+
# what keeps it current.
|
|
85
|
+
def reconfigure(_config); end
|
|
86
|
+
|
|
87
|
+
private
|
|
88
|
+
|
|
89
|
+
# `ctx:` is passed explicitly: the registry would otherwise record the
|
|
90
|
+
# frame on the context it was started in (the root), so a roster
|
|
91
|
+
# mounted into a forked agent scope would leave registrations behind
|
|
92
|
+
# that outlive the fork — a disposed agent with a tool of its own that
|
|
93
|
+
# still holds shell authority.
|
|
94
|
+
def tool(name, description, params, mutating:, approval:, concurrency:, &handler)
|
|
95
|
+
@ctx[:tools].register(name: name, description: description, params: params,
|
|
96
|
+
mutating: mutating, approval: approval,
|
|
97
|
+
concurrency: concurrency, ctx: @ctx, &handler)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def register_bash
|
|
101
|
+
@approval = derive_approval
|
|
102
|
+
params = {
|
|
103
|
+
type: "object",
|
|
104
|
+
properties: {
|
|
105
|
+
command: { type: "string", description: "The command to run in this session's bash" },
|
|
106
|
+
timeout: { type: "integer",
|
|
107
|
+
description: "Optional timeout in milliseconds; the shell session is " \
|
|
108
|
+
"replaced if it fires" }
|
|
109
|
+
},
|
|
110
|
+
required: ["command"]
|
|
111
|
+
}
|
|
112
|
+
@registration = tool("Bash", DESCRIPTION, params, mutating: true, approval: @approval,
|
|
113
|
+
concurrency: :serial) do |command:, session_id:, timeout: nil|
|
|
114
|
+
# session_id is the executing call's, handed to handlers that ask
|
|
115
|
+
# for it (Tools::Registry#handler_args). It is what keeps one
|
|
116
|
+
# agent's cwd and exported variables out of another's shell.
|
|
117
|
+
render(@ctx[:shell].run(command, session: session_id, timeout: seconds(timeout)))
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# §13. The sandbox's own verdict, never a config knob of this row's:
|
|
122
|
+
# an isolation claim belongs to the thing doing the isolating.
|
|
123
|
+
def derive_approval = @ctx[:sandbox].isolated? ? :policy : :always
|
|
124
|
+
|
|
125
|
+
def refresh!
|
|
126
|
+
return if derive_approval == @approval
|
|
127
|
+
|
|
128
|
+
@registration&.call
|
|
129
|
+
register_bash
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Claude Code's Bash takes its timeout in milliseconds and this seam
|
|
133
|
+
# takes seconds. Keeping CC's units in the argument (a model that has
|
|
134
|
+
# written this call before writes milliseconds) puts the conversion
|
|
135
|
+
# here, in one line, instead of leaving a units mismatch in the wild.
|
|
136
|
+
#
|
|
137
|
+
# Both branches below are about arguments a model actually writes.
|
|
138
|
+
# `"500"` is JSON it typed, so Integer() accepts the string forms and
|
|
139
|
+
# refuses the rest with a sentence rather than a NoMethodError. A zero
|
|
140
|
+
# or negative timeout falls back to the seam's default instead of
|
|
141
|
+
# firing at once: an immediate timeout would interrupt the command and
|
|
142
|
+
# kill the shell, so a model's own bad argument would cost it the cwd
|
|
143
|
+
# and variables it had built up — damage to the caller, from the
|
|
144
|
+
# caller, reported as a timeout nobody asked for.
|
|
145
|
+
def seconds(ms)
|
|
146
|
+
return nil if ms.nil?
|
|
147
|
+
|
|
148
|
+
ms = begin
|
|
149
|
+
Integer(ms)
|
|
150
|
+
rescue TypeError, ArgumentError
|
|
151
|
+
raise Terret::Tools::Failure, "timeout must be a whole number of milliseconds"
|
|
152
|
+
end
|
|
153
|
+
ms.positive? ? ms / 1000.0 : nil
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# Clamped rather than trusted: a row carrying a negative cap would
|
|
157
|
+
# otherwise byteslice its way to nil and raise on every single call,
|
|
158
|
+
# turning one bad config value into a tool that never works. Zero is
|
|
159
|
+
# then an honest answer — the result says it kept nothing and how much
|
|
160
|
+
# it dropped, which is visible in the very next tool result instead of
|
|
161
|
+
# in a crash a turn later.
|
|
162
|
+
def max_output = [config[:max_output] || DEFAULT_MAX_OUTPUT, 0].max
|
|
163
|
+
|
|
164
|
+
def render(result)
|
|
165
|
+
body, dropped = cap(scrub(result.stdout))
|
|
166
|
+
remarks = remarks_for(result, body, dropped)
|
|
167
|
+
return body.empty? ? "(no output)" : body if remarks.empty?
|
|
168
|
+
|
|
169
|
+
# The command's own bytes are never rewritten: the newline below only
|
|
170
|
+
# puts the separator on a line of its own, and output that already
|
|
171
|
+
# ended in one simply gets a blank line before the ledger.
|
|
172
|
+
"#{body.empty? ? '' : "#{body}\n"}#{LEDGER}\n#{remarks.join("\n")}"
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def remarks_for(result, body, dropped)
|
|
176
|
+
remarks = []
|
|
177
|
+
# A zero status is the silent case — announcing success on every call
|
|
178
|
+
# would be noise in every result a model reads. A nil status is not
|
|
179
|
+
# silent by luck: the only two paths that produce one (an interrupted
|
|
180
|
+
# command, a shell that ended) always carry a notice explaining it.
|
|
181
|
+
remarks << "exit status #{result.status}" if result.status && !result.status.zero?
|
|
182
|
+
# Both counts measure the RENDERED text — what a model would have
|
|
183
|
+
# been shown — not the command's raw bytes. Two things separate the
|
|
184
|
+
# two: scrubbing has already replaced anything that was not valid
|
|
185
|
+
# UTF-8 (a replacement character is three bytes where the original
|
|
186
|
+
# may have been one), and the seam's own megabyte cap may have
|
|
187
|
+
# dropped output before this one ever saw it, reporting that
|
|
188
|
+
# separately in the notice. Saying "of rendered output" is what keeps
|
|
189
|
+
# this line from implying it counted what the command wrote.
|
|
190
|
+
if dropped.positive?
|
|
191
|
+
remarks << "output truncated at max_output: kept the first #{body.bytesize} bytes " \
|
|
192
|
+
"of rendered output and dropped #{dropped} more"
|
|
193
|
+
end
|
|
194
|
+
remarks << "notice: #{result.notice}" if result.notice
|
|
195
|
+
remarks
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# Child bytes are not guaranteed to be text. The seam preserves
|
|
199
|
+
# whatever the command wrote (that is its job) and the session log
|
|
200
|
+
# refuses invalid UTF-8 at the durable append boundary, so this is the
|
|
201
|
+
# layer where they have to become storable — replacing what was never
|
|
202
|
+
# valid rather than dropping the whole result on the floor.
|
|
203
|
+
def scrub(stdout) = stdout.to_s.scrub
|
|
204
|
+
|
|
205
|
+
def cap(text)
|
|
206
|
+
limit = max_output
|
|
207
|
+
return [text, 0] if text.bytesize <= limit
|
|
208
|
+
|
|
209
|
+
kept = whole_characters(text.byteslice(0, limit))
|
|
210
|
+
[kept, text.bytesize - kept.bytesize]
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
# Cutting at a byte offset can split a character in half, and those
|
|
214
|
+
# halves are bytes this file manufactured — the child never wrote them,
|
|
215
|
+
# and a durable append JSON-encodes the payload, so a manufactured half
|
|
216
|
+
# raises a layer away from the code that broke it. At most three bytes
|
|
217
|
+
# come back off, the longest tail a split UTF-8 character can leave.
|
|
218
|
+
# Belt and braces after #scrub, and kept anyway: the same rule the seam
|
|
219
|
+
# holds itself to (Shell#whole_characters), for the same reason.
|
|
220
|
+
def whole_characters(text)
|
|
221
|
+
text = text.byteslice(0, text.bytesize - 1) until text.valid_encoding?
|
|
222
|
+
text
|
|
223
|
+
end
|
|
224
|
+
end
|
|
225
|
+
end
|
|
226
|
+
end
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Terret
|
|
4
|
+
module ToolsStd
|
|
5
|
+
# The standard file roster (docs/exec.md §5), carrying Claude Code's tool
|
|
6
|
+
# names verbatim because orchestrator allow lists are already written
|
|
7
|
+
# against those exact strings and `File.fnmatch` patterns have hardened
|
|
8
|
+
# around them — a Terret-native name would buy nothing but a permanent
|
|
9
|
+
# translation layer.
|
|
10
|
+
#
|
|
11
|
+
# Every handler reaches the filesystem through ctx[:fs] and nothing else.
|
|
12
|
+
# That is what makes the roster portable across the sandbox seam: when a
|
|
13
|
+
# config row swaps the sandbox provider (plan §12), these tools move with
|
|
14
|
+
# it untouched, because they never learned where the bytes actually live.
|
|
15
|
+
class Files < Hames::Service
|
|
16
|
+
service_key :tools_std_files
|
|
17
|
+
inject :tools, :fs
|
|
18
|
+
config_schema rg: { type: [TrueClass, FalseClass], default: true,
|
|
19
|
+
doc: "use ripgrep for Grep when it and a subprocess seam are available" }
|
|
20
|
+
|
|
21
|
+
DEFAULT_GLOB = "**/*"
|
|
22
|
+
|
|
23
|
+
# A grep that never returns is a turn that never ends.
|
|
24
|
+
RG_TIMEOUT = 30
|
|
25
|
+
|
|
26
|
+
# ripgrep takes its search paths as argv, and a large workspace can
|
|
27
|
+
# exceed the kernel's argv limit. Past this budget the scan stays
|
|
28
|
+
# in-process rather than risking Errno::E2BIG.
|
|
29
|
+
MAX_RG_ARGV_BYTES = 100_000
|
|
30
|
+
|
|
31
|
+
def start(ctx)
|
|
32
|
+
@ctx = ctx
|
|
33
|
+
register_read
|
|
34
|
+
register_write
|
|
35
|
+
register_edit
|
|
36
|
+
register_glob
|
|
37
|
+
register_grep
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# start captures nothing from config: `rg` is read at call time, so a
|
|
41
|
+
# swapped row governs the very next Grep and there is nothing here to
|
|
42
|
+
# re-derive. Saying so explicitly beats letting the base class warn that
|
|
43
|
+
# this service needs a remount when it does not.
|
|
44
|
+
def reconfigure(_config); end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
# `ctx:` is passed explicitly on every registration. The registry
|
|
49
|
+
# defaults it to the context IT was started in, which is the root — so a
|
|
50
|
+
# roster mounted into a forked agent scope would leave its registration
|
|
51
|
+
# frames on the root context, outliving the fork that made them. Handing
|
|
52
|
+
# register the ctx this service was started in puts each frame where
|
|
53
|
+
# this row's lifetime is: unload the row, or dispose the fork it was
|
|
54
|
+
# mounted into, and the tools go with it. That is what stands between a
|
|
55
|
+
# disposed agent and a tool of its own that still holds filesystem
|
|
56
|
+
# authority.
|
|
57
|
+
def tool(name, description, params, mutating:, approval:, concurrency:, &handler)
|
|
58
|
+
@ctx[:tools].register(name: name, description: description, params: params,
|
|
59
|
+
mutating: mutating, approval: approval,
|
|
60
|
+
concurrency: concurrency, ctx: @ctx, &handler)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def object_schema(properties, required)
|
|
64
|
+
{ type: "object", properties: properties, required: required }
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def path_property(purpose)
|
|
68
|
+
{ type: "string", description: "Absolute path to #{purpose}, inside the granted workspace" }
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def register_read
|
|
72
|
+
tool("Read", "Read a file's full contents.",
|
|
73
|
+
object_schema({ file_path: path_property("the file to read") }, ["file_path"]),
|
|
74
|
+
mutating: false, approval: :never, concurrency: :parallel) do |file_path:|
|
|
75
|
+
@ctx[:fs].read(file_path)
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def register_write
|
|
80
|
+
params = object_schema(
|
|
81
|
+
{ file_path: path_property("the file to write"),
|
|
82
|
+
content: { type: "string", description: "The file's full new contents" } },
|
|
83
|
+
%w[file_path content]
|
|
84
|
+
)
|
|
85
|
+
tool("Write", "Write a file, replacing it if it exists and creating parent directories as needed.",
|
|
86
|
+
params, mutating: true, approval: :policy, concurrency: :serial) do |file_path:, content:|
|
|
87
|
+
"Wrote #{content.to_s.bytesize} bytes to #{@ctx[:fs].write(file_path, content)}"
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def register_edit
|
|
92
|
+
params = object_schema(
|
|
93
|
+
{ file_path: path_property("the file to edit"),
|
|
94
|
+
old_string: { type: "string", description: "Exact text to replace; must appear exactly once" },
|
|
95
|
+
new_string: { type: "string", description: "Text to put in its place" } },
|
|
96
|
+
%w[file_path old_string new_string]
|
|
97
|
+
)
|
|
98
|
+
description = "Replace one exact occurrence of a string in a file. Refuses if it appears " \
|
|
99
|
+
"zero times or more than once, so include enough surrounding text to make it unique."
|
|
100
|
+
tool("Edit", description, params,
|
|
101
|
+
mutating: true, approval: :policy, concurrency: :serial) do |file_path:, old_string:, new_string:|
|
|
102
|
+
"Edited #{@ctx[:fs].edit(file_path, old_string, new_string)}"
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def register_glob
|
|
107
|
+
params = object_schema(
|
|
108
|
+
{ pattern: { type: "string", description: "Glob pattern, e.g. **/*.rb, matched in every workspace root" } },
|
|
109
|
+
["pattern"]
|
|
110
|
+
)
|
|
111
|
+
tool("Glob", "List workspace files matching a glob pattern, as absolute paths.",
|
|
112
|
+
params, mutating: false, approval: :never, concurrency: :parallel) do |pattern:|
|
|
113
|
+
listing(@ctx[:fs].glob(pattern), "No files matched")
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def register_grep
|
|
118
|
+
params = object_schema(
|
|
119
|
+
{ pattern: { type: "string", description: "Regular expression to search for" },
|
|
120
|
+
glob: { type: "string", description: "Glob limiting which files are searched (default #{DEFAULT_GLOB})" } },
|
|
121
|
+
["pattern"]
|
|
122
|
+
)
|
|
123
|
+
tool("Grep", "Search workspace file contents for a regular expression. Returns matching lines as " \
|
|
124
|
+
"absolute_path:line_number:line.",
|
|
125
|
+
params, mutating: false, approval: :never, concurrency: :parallel) do |pattern:, glob: DEFAULT_GLOB|
|
|
126
|
+
grep(pattern, glob)
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def listing(paths, empty_message) = paths.empty? ? empty_message : paths.join("\n")
|
|
131
|
+
|
|
132
|
+
def grep(pattern, glob)
|
|
133
|
+
files = searchable(glob)
|
|
134
|
+
return "No matches" if files.empty?
|
|
135
|
+
|
|
136
|
+
lines = (ripgrep(pattern, files) if use_rg?(files)) || scan(pattern, files)
|
|
137
|
+
listing(lines, "No matches")
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# Directories are dropped through the same seam that produced the
|
|
141
|
+
# listing rather than with a bare File.directory?: every path this tool
|
|
142
|
+
# touches passes ctx[:fs], so containment and the fs/authorize
|
|
143
|
+
# waterfall see all of it and not just the reads.
|
|
144
|
+
def searchable(glob)
|
|
145
|
+
@ctx[:fs].glob(glob).reject { |p| @ctx[:fs].stat(p)[:directory] }
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def use_rg?(files)
|
|
149
|
+
config.fetch(:rg, true) && @ctx.service?(:subprocess) &&
|
|
150
|
+
files.sum { |f| f.bytesize + 1 } <= MAX_RG_ARGV_BYTES && rg_on_path?
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# PATH is this process's own environment, not workspace content, so it
|
|
154
|
+
# is read directly — routing it through ctx[:fs] would (correctly) deny
|
|
155
|
+
# every directory on it.
|
|
156
|
+
def rg_on_path?
|
|
157
|
+
ENV.fetch("PATH", "").split(File::PATH_SEPARATOR).any? do |dir|
|
|
158
|
+
exe = File.join(dir, "rg")
|
|
159
|
+
File.file?(exe) && File.executable?(exe)
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# ripgrep is a fast path, never a different answer: it is handed exactly
|
|
164
|
+
# the file list the in-process scan would have walked, so the two paths
|
|
165
|
+
# agree on which files are searched and differ only in regex dialect.
|
|
166
|
+
# It runs through ctx[:subprocess] like every other spawn in Terret,
|
|
167
|
+
# which is what keeps it inside the sandbox once one is mounted.
|
|
168
|
+
#
|
|
169
|
+
# Returns nil when ripgrep proved unusable and the caller should scan
|
|
170
|
+
# in-process instead. There are two ways that happens, because
|
|
171
|
+
# rg_on_path? only ever probed THIS process's PATH and the spawn's own
|
|
172
|
+
# verdict outranks it: a local spawn that cannot find the binary raises
|
|
173
|
+
# Errno::ENOENT, while a sandbox running the argv somewhere else answers
|
|
174
|
+
# with a status and never raises — `docker exec` exits 127 for a command
|
|
175
|
+
# missing from the container, 126 for one that is there but not
|
|
176
|
+
# executable. A host with ripgrep and an image without it is the ordinary
|
|
177
|
+
# case, not an exotic one, so neither may fail the call.
|
|
178
|
+
def ripgrep(pattern, files)
|
|
179
|
+
# --no-ignore because which files get searched is ctx[:fs]'s decision,
|
|
180
|
+
# already made: the list below IS the answer, and a .gitignore in the
|
|
181
|
+
# workspace must not quietly shrink it out from under the in-process
|
|
182
|
+
# scan that knows nothing about ignore files.
|
|
183
|
+
argv = ["rg", "--line-number", "--no-heading", "--with-filename",
|
|
184
|
+
"--no-ignore", "--color", "never", "-e", pattern, *files]
|
|
185
|
+
# cwd is a workspace directory (files is non-empty here) so the
|
|
186
|
+
# sandboxed world always has one it can actually enter.
|
|
187
|
+
result = @ctx[:subprocess].spawn(argv, cwd: File.dirname(files.first), timeout: RG_TIMEOUT)
|
|
188
|
+
case result.status
|
|
189
|
+
when 0 then result.stdout.lines.map(&:chomp)
|
|
190
|
+
when 1 then [] # ripgrep's "no matches" — an answer, not a failure
|
|
191
|
+
when 126, 127 then nil # no usable rg where the argv actually ran
|
|
192
|
+
else
|
|
193
|
+
# A bad pattern is the caller's problem. Silently retrying it under
|
|
194
|
+
# Ruby's regex dialect would hide that the two engines disagree.
|
|
195
|
+
raise Terret::Tools::Failure, "ripgrep failed: #{failure_detail(result)}"
|
|
196
|
+
end
|
|
197
|
+
rescue Errno::ENOENT
|
|
198
|
+
nil # the local-spawn miss; the status branch above is the sandbox's
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def failure_detail(result)
|
|
202
|
+
detail = result.stderr.to_s.strip
|
|
203
|
+
detail = result.stdout.to_s.strip if detail.empty?
|
|
204
|
+
detail.empty? ? "exit status #{result.status.inspect}" : detail
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def scan(pattern, files)
|
|
208
|
+
rx = compile(pattern)
|
|
209
|
+
files.flat_map do |path|
|
|
210
|
+
body = @ctx[:fs].read(path)
|
|
211
|
+
# Matching a Regexp against invalid UTF-8 raises, and a binary file
|
|
212
|
+
# is not a grep target anyway; ripgrep skips them too, so skipping
|
|
213
|
+
# here keeps the two paths agreeing on the same set of files.
|
|
214
|
+
next [] unless body.valid_encoding?
|
|
215
|
+
|
|
216
|
+
body.each_line.with_index(1).filter_map do |line, n|
|
|
217
|
+
"#{path}:#{n}:#{line.chomp}" if rx.match?(line)
|
|
218
|
+
end
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def compile(pattern)
|
|
223
|
+
Regexp.new(pattern)
|
|
224
|
+
rescue RegexpError => e
|
|
225
|
+
raise Terret::Tools::Failure, "bad search pattern: #{e.message}"
|
|
226
|
+
end
|
|
227
|
+
end
|
|
228
|
+
end
|
|
229
|
+
end
|