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 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