agent-cli-runtime 0.1.1 → 0.2.4
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 +4 -4
- data/CHANGELOG.md +87 -0
- data/README.md +177 -9
- data/agent-cli-runtime.gemspec +7 -5
- data/lib/agent_cli_runtime/error_extractors.rb +95 -0
- data/lib/agent_cli_runtime/errors.rb +7 -0
- data/lib/agent_cli_runtime/opencode/inspection.rb +32 -0
- data/lib/agent_cli_runtime/opencode/overlay.rb +488 -0
- data/lib/agent_cli_runtime/opencode/permissions.rb +144 -0
- data/lib/agent_cli_runtime/opencode/probe.rb +304 -0
- data/lib/agent_cli_runtime/opencode/result_parser.rb +416 -0
- data/lib/agent_cli_runtime/profile.rb +60 -11
- data/lib/agent_cli_runtime/profiles.rb +98 -3
- data/lib/agent_cli_runtime/runtime.rb +118 -13
- data/lib/agent_cli_runtime/usage_extractors.rb +119 -32
- data/lib/agent_cli_runtime/values.rb +478 -0
- data/lib/agent_cli_runtime/version.rb +1 -1
- data/lib/agent_cli_runtime.rb +29 -3
- metadata +13 -6
|
@@ -0,0 +1,488 @@
|
|
|
1
|
+
require "fileutils"
|
|
2
|
+
require "json"
|
|
3
|
+
|
|
4
|
+
module AgentCliRuntime
|
|
5
|
+
module OpenCode
|
|
6
|
+
module Overlay
|
|
7
|
+
MAX_CONFIGURATION_BYTES = 1_048_576
|
|
8
|
+
SECRET_KEY_PATTERN = /(?:api[_-]?key|token|secret|password|credential)/i
|
|
9
|
+
ENV_PLACEHOLDER_PATTERN = /\A\{env:[A-Z][A-Z0-9_]*\}\z/
|
|
10
|
+
private_constant :SECRET_KEY_PATTERN, :ENV_PLACEHOLDER_PATTERN
|
|
11
|
+
|
|
12
|
+
class Cleanup
|
|
13
|
+
def initialize(root)
|
|
14
|
+
stat = File.lstat(root)
|
|
15
|
+
@root = root.freeze
|
|
16
|
+
@device = stat.dev
|
|
17
|
+
@inode = stat.ino
|
|
18
|
+
@owner = stat.uid
|
|
19
|
+
@mutex = Mutex.new
|
|
20
|
+
@cleaned = false
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def call
|
|
24
|
+
@mutex.synchronize do
|
|
25
|
+
return if @cleaned
|
|
26
|
+
unless File.exist?(@root) || File.symlink?(@root)
|
|
27
|
+
@cleaned = true
|
|
28
|
+
return
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
stat = File.lstat(@root)
|
|
32
|
+
if stat.symlink? || !stat.directory? || stat.dev != @device ||
|
|
33
|
+
stat.ino != @inode || stat.uid != @owner
|
|
34
|
+
raise UnsafePathError,
|
|
35
|
+
"refusing to clean a replaced OpenCode invocation root"
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
FileUtils.remove_entry_secure(@root)
|
|
39
|
+
@cleaned = true
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
private_constant :Cleanup
|
|
44
|
+
|
|
45
|
+
module_function
|
|
46
|
+
|
|
47
|
+
def prepare!(preparation, env: ENV)
|
|
48
|
+
unless preparation.is_a?(OpenCodePreparationRequest)
|
|
49
|
+
raise ArgumentError,
|
|
50
|
+
"request must be an AgentCliRuntime::OpenCodePreparationRequest"
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
profile = Profiles.resolve(preparation.request.profile)
|
|
54
|
+
unless profile.name == :opencode
|
|
55
|
+
raise ConfigurationError,
|
|
56
|
+
"OpenCode preparation requires profile :opencode"
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
source_config, source_label = load_configuration(preparation)
|
|
60
|
+
requested_route = resolve_route(preparation.request, source_config)
|
|
61
|
+
validate_provider!(source_config, requested_route.provider)
|
|
62
|
+
validate_nonsecret!(source_config)
|
|
63
|
+
roots = resolve_roots(preparation)
|
|
64
|
+
credential_environment_keys = provider_credential_environment_keys(
|
|
65
|
+
source_config, requested_route.provider,
|
|
66
|
+
preparation.credential_environment_keys
|
|
67
|
+
)
|
|
68
|
+
plugins = selected_plugins(source_config, preparation.plugins)
|
|
69
|
+
|
|
70
|
+
root = create_root!(preparation.invocation_root)
|
|
71
|
+
cleanup = Cleanup.new(root)
|
|
72
|
+
begin
|
|
73
|
+
paths = create_directories(root)
|
|
74
|
+
permission = Permissions.compile(
|
|
75
|
+
permission_mode: preparation.request.permission_mode,
|
|
76
|
+
permission_policy: preparation.permission_policy,
|
|
77
|
+
working_directory: roots.fetch(:working),
|
|
78
|
+
additional_read_roots: roots.fetch(:read),
|
|
79
|
+
additional_write_roots: roots.fetch(:write),
|
|
80
|
+
edit_patterns: preparation.edit_patterns,
|
|
81
|
+
bash_patterns: preparation.bash_patterns,
|
|
82
|
+
plugins: plugins,
|
|
83
|
+
runtime_write_roots: [ paths.fetch(:temporary) ]
|
|
84
|
+
)
|
|
85
|
+
unless permission
|
|
86
|
+
raise ConfigurationError,
|
|
87
|
+
"OpenCode requires an explicit OpenCode permission policy when permission_mode is nil"
|
|
88
|
+
end
|
|
89
|
+
pure = preparation.pure && plugins.empty?
|
|
90
|
+
config = generated_configuration(
|
|
91
|
+
source_config, plugins, requested_route, permission
|
|
92
|
+
)
|
|
93
|
+
configuration_path = File.join(paths.fetch(:source), "opencode.json")
|
|
94
|
+
write_private_file(configuration_path, JSON.pretty_generate(config) + "\n")
|
|
95
|
+
generated_paths = [ root, *paths.values, configuration_path ]
|
|
96
|
+
|
|
97
|
+
staged_credential = stage_credential_file(
|
|
98
|
+
preparation.credential_file,
|
|
99
|
+
File.join(paths.fetch(:data), "opencode", "auth.json")
|
|
100
|
+
)
|
|
101
|
+
generated_paths.concat(staged_credential ? staged_credential : [])
|
|
102
|
+
|
|
103
|
+
environment = overlay_environment(
|
|
104
|
+
paths, configuration_path, pure: pure
|
|
105
|
+
)
|
|
106
|
+
executable =
|
|
107
|
+
preparation.request.executable || profile.bin(env: env)
|
|
108
|
+
probe_request = ProbeRequest.new(
|
|
109
|
+
profile: profile,
|
|
110
|
+
route: requested_route,
|
|
111
|
+
variant: preparation.request.effort,
|
|
112
|
+
environment: environment,
|
|
113
|
+
credential_environment_keys: credential_environment_keys,
|
|
114
|
+
credential_file_staged:
|
|
115
|
+
staged_credential && credential_file_supports_provider?(
|
|
116
|
+
staged_credential.last, requested_route.provider
|
|
117
|
+
),
|
|
118
|
+
configured_variants:
|
|
119
|
+
configured_variants(source_config, requested_route),
|
|
120
|
+
executable: executable
|
|
121
|
+
)
|
|
122
|
+
probe_result = OpenCode::Probe.call!(probe_request, env: env)
|
|
123
|
+
invocation = compile_invocation(
|
|
124
|
+
preparation, profile, requested_route, roots,
|
|
125
|
+
executable:, pure: pure,
|
|
126
|
+
probe_result:
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
PreparedInvocation.new(
|
|
130
|
+
invocation: invocation,
|
|
131
|
+
environment: environment,
|
|
132
|
+
credential_environment_keys: credential_environment_keys,
|
|
133
|
+
invocation_root: root,
|
|
134
|
+
generated_paths: generated_paths.uniq,
|
|
135
|
+
configuration_path: configuration_path,
|
|
136
|
+
requested_route: requested_route,
|
|
137
|
+
configuration_source: source_label,
|
|
138
|
+
probe_result: probe_result,
|
|
139
|
+
cleanup: cleanup,
|
|
140
|
+
executable: executable
|
|
141
|
+
)
|
|
142
|
+
rescue Exception
|
|
143
|
+
cleanup.call
|
|
144
|
+
raise
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def load_configuration(preparation)
|
|
149
|
+
if preparation.configuration_path
|
|
150
|
+
path = safe_source_file!(
|
|
151
|
+
preparation.configuration_path, label: "OpenCode configuration"
|
|
152
|
+
)
|
|
153
|
+
if File.size(path) > MAX_CONFIGURATION_BYTES
|
|
154
|
+
raise ConfigurationError,
|
|
155
|
+
"OpenCode configuration exceeds the bounded input size"
|
|
156
|
+
end
|
|
157
|
+
[ JSON.parse(File.read(path)), path ]
|
|
158
|
+
elsif preparation.configuration
|
|
159
|
+
[ JSON.parse(JSON.generate(preparation.configuration)), "inline" ]
|
|
160
|
+
else
|
|
161
|
+
raise ConfigurationError,
|
|
162
|
+
"OpenCode preparation requires an explicit configuration source"
|
|
163
|
+
end
|
|
164
|
+
rescue JSON::ParserError
|
|
165
|
+
raise ConfigurationError,
|
|
166
|
+
"OpenCode configuration must be valid JSON"
|
|
167
|
+
end
|
|
168
|
+
private_class_method :load_configuration
|
|
169
|
+
|
|
170
|
+
def resolve_route(request, config)
|
|
171
|
+
value = request.model
|
|
172
|
+
value = config["model"] if value.to_s.empty?
|
|
173
|
+
if value.to_s.empty?
|
|
174
|
+
raise RouteUnavailable,
|
|
175
|
+
"OpenCode requires an exact provider/model route or explicit overlay default"
|
|
176
|
+
end
|
|
177
|
+
Route.parse(value)
|
|
178
|
+
rescue ArgumentError => e
|
|
179
|
+
raise RouteUnavailable, Redactor.diagnostic(e)
|
|
180
|
+
end
|
|
181
|
+
private_class_method :resolve_route
|
|
182
|
+
|
|
183
|
+
def validate_provider!(config, provider)
|
|
184
|
+
definitions = config["provider"]
|
|
185
|
+
unless definitions.is_a?(Hash) && definitions[provider].is_a?(Hash)
|
|
186
|
+
raise ConfigurationError,
|
|
187
|
+
"requested OpenCode provider is absent from the selected configuration"
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
private_class_method :validate_provider!
|
|
191
|
+
|
|
192
|
+
# A selected OpenCode configuration may declare a custom model that is
|
|
193
|
+
# newer than the CLI's bundled provider catalog. With model fetching
|
|
194
|
+
# disabled for hermetic launches, that declaration is durable route
|
|
195
|
+
# evidence; the large `models --verbose` inventory is complementary,
|
|
196
|
+
# not its replacement.
|
|
197
|
+
def configured_variants(config, route)
|
|
198
|
+
models = config.dig("provider", route.provider, "models")
|
|
199
|
+
return nil unless models.is_a?(Hash) && models.key?(route.model)
|
|
200
|
+
|
|
201
|
+
definition = models.fetch(route.model)
|
|
202
|
+
variants = definition.is_a?(Hash) ? definition["variants"] : nil
|
|
203
|
+
variants.is_a?(Hash) ? variants.keys.sort.freeze : [].freeze
|
|
204
|
+
end
|
|
205
|
+
private_class_method :configured_variants
|
|
206
|
+
|
|
207
|
+
def validate_nonsecret!(value, key = nil)
|
|
208
|
+
case value
|
|
209
|
+
when Hash
|
|
210
|
+
value.each { |child_key, child| validate_nonsecret!(child, child_key) }
|
|
211
|
+
when Array
|
|
212
|
+
value.each { |child| validate_nonsecret!(child, key) }
|
|
213
|
+
when String
|
|
214
|
+
if key.to_s.match?(SECRET_KEY_PATTERN) &&
|
|
215
|
+
!value.match?(ENV_PLACEHOLDER_PATTERN)
|
|
216
|
+
raise ConfigurationError,
|
|
217
|
+
"OpenCode provider definitions cannot contain credential values"
|
|
218
|
+
end
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
private_class_method :validate_nonsecret!
|
|
222
|
+
|
|
223
|
+
PROVIDER_ENVIRONMENT_KEY_ALIASES = {
|
|
224
|
+
"anthropic" => %w[ANTHROPIC CLAUDE],
|
|
225
|
+
"google" => %w[GOOGLE GEMINI],
|
|
226
|
+
"gemini" => %w[GOOGLE GEMINI],
|
|
227
|
+
"xai" => %w[XAI GROK],
|
|
228
|
+
"github-copilot" => %w[COPILOT GITHUB],
|
|
229
|
+
"opencode" => %w[OPENCODE]
|
|
230
|
+
}.freeze
|
|
231
|
+
private_constant :PROVIDER_ENVIRONMENT_KEY_ALIASES
|
|
232
|
+
|
|
233
|
+
def provider_credential_environment_keys(config, provider, configured_keys)
|
|
234
|
+
definition = config.fetch("provider").fetch(provider)
|
|
235
|
+
referenced = environment_placeholders(definition)
|
|
236
|
+
aliases = PROVIDER_ENVIRONMENT_KEY_ALIASES.fetch(
|
|
237
|
+
provider, [ provider.upcase.gsub(/[^A-Z0-9]+/, "_") ]
|
|
238
|
+
)
|
|
239
|
+
configured_keys.select do |key|
|
|
240
|
+
referenced.include?(key) || aliases.any? { |prefix| key.start_with?("#{prefix}_") }
|
|
241
|
+
end.freeze
|
|
242
|
+
end
|
|
243
|
+
private_class_method :provider_credential_environment_keys
|
|
244
|
+
|
|
245
|
+
def environment_placeholders(value)
|
|
246
|
+
case value
|
|
247
|
+
when Hash
|
|
248
|
+
value.values.flat_map { |child| environment_placeholders(child) }
|
|
249
|
+
when Array
|
|
250
|
+
value.flat_map { |child| environment_placeholders(child) }
|
|
251
|
+
when String
|
|
252
|
+
match = /\A\{env:(?<key>[A-Z][A-Z0-9_]*)\}\z/.match(value)
|
|
253
|
+
match ? [ match[:key] ] : []
|
|
254
|
+
else
|
|
255
|
+
[]
|
|
256
|
+
end.uniq
|
|
257
|
+
end
|
|
258
|
+
private_class_method :environment_placeholders
|
|
259
|
+
|
|
260
|
+
def resolve_roots(preparation)
|
|
261
|
+
working = safe_directory!(preparation.working_directory, label: "working directory")
|
|
262
|
+
reads = preparation.additional_read_roots.map do |path|
|
|
263
|
+
safe_directory!(path, label: "additional read root")
|
|
264
|
+
end
|
|
265
|
+
writes = preparation.additional_write_roots.map do |path|
|
|
266
|
+
safe_directory!(path, label: "additional write root")
|
|
267
|
+
end
|
|
268
|
+
{
|
|
269
|
+
working: working,
|
|
270
|
+
read: reads.uniq.freeze,
|
|
271
|
+
write: writes.uniq.freeze
|
|
272
|
+
}.freeze
|
|
273
|
+
end
|
|
274
|
+
private_class_method :resolve_roots
|
|
275
|
+
|
|
276
|
+
def create_root!(value)
|
|
277
|
+
path = File.expand_path(value)
|
|
278
|
+
unless File.absolute_path?(value.to_s)
|
|
279
|
+
raise UnsafePathError,
|
|
280
|
+
"OpenCode invocation root must be absolute"
|
|
281
|
+
end
|
|
282
|
+
parent = File.realpath(File.dirname(path))
|
|
283
|
+
path = File.join(parent, File.basename(path))
|
|
284
|
+
if File.exist?(path) || File.symlink?(path)
|
|
285
|
+
raise UnsafePathError,
|
|
286
|
+
"OpenCode invocation root must not already exist"
|
|
287
|
+
end
|
|
288
|
+
validate_ancestors!(parent)
|
|
289
|
+
Dir.mkdir(path, 0o700)
|
|
290
|
+
path.freeze
|
|
291
|
+
rescue Errno::EEXIST, Errno::ENOENT, Errno::EACCES => e
|
|
292
|
+
raise UnsafePathError, Redactor.diagnostic(e)
|
|
293
|
+
end
|
|
294
|
+
private_class_method :create_root!
|
|
295
|
+
|
|
296
|
+
def validate_ancestors!(path)
|
|
297
|
+
current = File.expand_path(path)
|
|
298
|
+
loop do
|
|
299
|
+
stat = File.lstat(current)
|
|
300
|
+
unless stat.directory? && !stat.symlink?
|
|
301
|
+
raise UnsafePathError,
|
|
302
|
+
"OpenCode invocation root has an unsafe ancestor"
|
|
303
|
+
end
|
|
304
|
+
parent = File.dirname(current)
|
|
305
|
+
break if parent == current
|
|
306
|
+
|
|
307
|
+
current = parent
|
|
308
|
+
end
|
|
309
|
+
end
|
|
310
|
+
private_class_method :validate_ancestors!
|
|
311
|
+
|
|
312
|
+
def create_directories(root)
|
|
313
|
+
{
|
|
314
|
+
config: "config-home",
|
|
315
|
+
data: "data-home",
|
|
316
|
+
cache: "cache-home",
|
|
317
|
+
state: "state-home",
|
|
318
|
+
source: "selected-config",
|
|
319
|
+
temporary: "tmp"
|
|
320
|
+
}.transform_values do |relative|
|
|
321
|
+
path = File.join(root, relative)
|
|
322
|
+
Dir.mkdir(path, 0o700)
|
|
323
|
+
path.freeze
|
|
324
|
+
end.freeze
|
|
325
|
+
end
|
|
326
|
+
private_class_method :create_directories
|
|
327
|
+
|
|
328
|
+
def selected_plugins(source, requested)
|
|
329
|
+
values = requested.empty? ? source.fetch("plugin", []) : requested
|
|
330
|
+
unless values.is_a?(Array) &&
|
|
331
|
+
values.all? { |plugin| plugin.is_a?(String) && !plugin.empty? }
|
|
332
|
+
raise ConfigurationError,
|
|
333
|
+
"OpenCode plugin selection must be an array of non-empty strings"
|
|
334
|
+
end
|
|
335
|
+
values.uniq.freeze
|
|
336
|
+
end
|
|
337
|
+
private_class_method :selected_plugins
|
|
338
|
+
|
|
339
|
+
def generated_configuration(source, plugins, route, permission)
|
|
340
|
+
config = deep_copy(source)
|
|
341
|
+
if config["agent"].is_a?(Hash)
|
|
342
|
+
config["agent"].each_value do |agent|
|
|
343
|
+
agent.delete("permission") if agent.is_a?(Hash)
|
|
344
|
+
end
|
|
345
|
+
end
|
|
346
|
+
config["$schema"] ||= "https://opencode.ai/config.json"
|
|
347
|
+
config["model"] = route.to_s
|
|
348
|
+
config["permission"] = permission
|
|
349
|
+
if plugins.empty?
|
|
350
|
+
config.delete("plugin")
|
|
351
|
+
else
|
|
352
|
+
config["plugin"] = plugins.dup
|
|
353
|
+
end
|
|
354
|
+
config
|
|
355
|
+
end
|
|
356
|
+
private_class_method :generated_configuration
|
|
357
|
+
|
|
358
|
+
def stage_credential_file(source, destination)
|
|
359
|
+
return nil if source.nil?
|
|
360
|
+
|
|
361
|
+
source_path = safe_source_file!(source, label: "OpenCode credential file")
|
|
362
|
+
directory = File.dirname(destination)
|
|
363
|
+
Dir.mkdir(directory, 0o700)
|
|
364
|
+
contents = File.binread(source_path)
|
|
365
|
+
write_private_file(destination, contents)
|
|
366
|
+
[ directory, destination ]
|
|
367
|
+
end
|
|
368
|
+
private_class_method :stage_credential_file
|
|
369
|
+
|
|
370
|
+
def credential_file_supports_provider?(path, provider)
|
|
371
|
+
value = JSON.parse(File.binread(path))
|
|
372
|
+
value.is_a?(Hash) && value.key?(provider)
|
|
373
|
+
rescue JSON::ParserError
|
|
374
|
+
false
|
|
375
|
+
end
|
|
376
|
+
private_class_method :credential_file_supports_provider?
|
|
377
|
+
|
|
378
|
+
def safe_source_file!(value, label:)
|
|
379
|
+
path = File.expand_path(value)
|
|
380
|
+
unless File.absolute_path?(value.to_s)
|
|
381
|
+
raise UnsafePathError, "#{label} must be absolute"
|
|
382
|
+
end
|
|
383
|
+
stat = File.lstat(path)
|
|
384
|
+
unless stat.file? && !stat.symlink? && stat.uid == Process.uid
|
|
385
|
+
raise UnsafePathError,
|
|
386
|
+
"#{label} must be an owner-controlled regular file"
|
|
387
|
+
end
|
|
388
|
+
path.freeze
|
|
389
|
+
rescue Errno::ENOENT, Errno::EACCES => e
|
|
390
|
+
raise UnsafePathError, Redactor.diagnostic(e)
|
|
391
|
+
end
|
|
392
|
+
private_class_method :safe_source_file!
|
|
393
|
+
|
|
394
|
+
def safe_directory!(value, label:)
|
|
395
|
+
path = File.expand_path(value)
|
|
396
|
+
unless File.absolute_path?(value.to_s)
|
|
397
|
+
raise UnsafePathError, "#{label} must be absolute"
|
|
398
|
+
end
|
|
399
|
+
stat = File.lstat(path)
|
|
400
|
+
unless stat.directory? && !stat.symlink? && stat.uid == Process.uid
|
|
401
|
+
raise UnsafePathError,
|
|
402
|
+
"#{label} must be an owner-controlled real directory"
|
|
403
|
+
end
|
|
404
|
+
File.realpath(path).freeze
|
|
405
|
+
rescue Errno::ENOENT, Errno::EACCES => e
|
|
406
|
+
raise UnsafePathError, Redactor.diagnostic(e)
|
|
407
|
+
end
|
|
408
|
+
private_class_method :safe_directory!
|
|
409
|
+
|
|
410
|
+
def write_private_file(path, contents)
|
|
411
|
+
File.open(path, File::WRONLY | File::CREAT | File::EXCL, 0o600) do |file|
|
|
412
|
+
file.write(contents)
|
|
413
|
+
end
|
|
414
|
+
File.chmod(0o600, path)
|
|
415
|
+
path
|
|
416
|
+
end
|
|
417
|
+
private_class_method :write_private_file
|
|
418
|
+
|
|
419
|
+
def overlay_environment(paths, configuration_path, pure:)
|
|
420
|
+
environment = {
|
|
421
|
+
"XDG_CONFIG_HOME" => paths.fetch(:config),
|
|
422
|
+
"XDG_DATA_HOME" => paths.fetch(:data),
|
|
423
|
+
"XDG_CACHE_HOME" => paths.fetch(:cache),
|
|
424
|
+
"XDG_STATE_HOME" => paths.fetch(:state),
|
|
425
|
+
"TMPDIR" => paths.fetch(:temporary),
|
|
426
|
+
"OPENCODE_CONFIG" => configuration_path,
|
|
427
|
+
"OPENCODE_DISABLE_PROJECT_CONFIG" => "true",
|
|
428
|
+
"OPENCODE_DISABLE_CLAUDE_CODE" => "true",
|
|
429
|
+
"OPENCODE_DISABLE_MODELS_FETCH" => "true",
|
|
430
|
+
"OPENCODE_DISABLE_AUTOUPDATE" => "true",
|
|
431
|
+
"OPENCODE_PURE" => pure ? "true" : "false"
|
|
432
|
+
}
|
|
433
|
+
unless environment.keys.sort == OPENCODE_OVERLAY_ENVIRONMENT_KEYS.sort
|
|
434
|
+
raise ConfigurationError, "OpenCode overlay environment contract drifted"
|
|
435
|
+
end
|
|
436
|
+
environment.freeze
|
|
437
|
+
end
|
|
438
|
+
private_class_method :overlay_environment
|
|
439
|
+
|
|
440
|
+
def compile_invocation(preparation, profile, route, roots,
|
|
441
|
+
executable:, pure:, probe_result:)
|
|
442
|
+
source = preparation.request
|
|
443
|
+
trusted = source.trusted_cli_arguments.dup
|
|
444
|
+
trusted.concat([ "--dir", roots.fetch(:working) ])
|
|
445
|
+
trusted << "--pure" if pure
|
|
446
|
+
request = Request.new(
|
|
447
|
+
profile: profile,
|
|
448
|
+
prompt: source.prompt,
|
|
449
|
+
permission_mode: source.permission_mode,
|
|
450
|
+
permission_arguments: [ "--auto" ],
|
|
451
|
+
add_dirs: [],
|
|
452
|
+
require_add_dirs: false,
|
|
453
|
+
allowed_tools: nil,
|
|
454
|
+
disallowed_tools: nil,
|
|
455
|
+
# OpenCode has no verified per-run budget flag in the pinned
|
|
456
|
+
# contract. The caller may enforce external limits, but preparation
|
|
457
|
+
# must not claim that this value reached the CLI.
|
|
458
|
+
max_budget_usd: nil,
|
|
459
|
+
model: route.to_s,
|
|
460
|
+
effort: source.effort,
|
|
461
|
+
pin_model: true,
|
|
462
|
+
identity_arguments: source.identity_arguments,
|
|
463
|
+
capabilities: [],
|
|
464
|
+
raw_cli_arguments: source.raw_cli_arguments,
|
|
465
|
+
trusted_cli_arguments: trusted,
|
|
466
|
+
executable: executable,
|
|
467
|
+
command_prefix: source.command_prefix,
|
|
468
|
+
include_output_format: source.include_output_format
|
|
469
|
+
)
|
|
470
|
+
compiled = Runtime.compile(request)
|
|
471
|
+
CompiledInvocation.new(
|
|
472
|
+
argv: compiled.argv,
|
|
473
|
+
stdin_data: compiled.stdin_data,
|
|
474
|
+
provider: compiled.provider,
|
|
475
|
+
launcher_identity: compiled.launcher_identity,
|
|
476
|
+
capability_evidence:
|
|
477
|
+
compiled.capability_evidence + probe_result.capability_evidence
|
|
478
|
+
)
|
|
479
|
+
end
|
|
480
|
+
private_class_method :compile_invocation
|
|
481
|
+
|
|
482
|
+
def deep_copy(value)
|
|
483
|
+
JSON.parse(JSON.generate(value))
|
|
484
|
+
end
|
|
485
|
+
private_class_method :deep_copy
|
|
486
|
+
end
|
|
487
|
+
end
|
|
488
|
+
end
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
|
|
3
|
+
module AgentCliRuntime
|
|
4
|
+
module OpenCode
|
|
5
|
+
# Compiles provider-neutral permission modes into OpenCode's
|
|
6
|
+
# per-run permission document. The document can be written into an
|
|
7
|
+
# isolated overlay or supplied through OPENCODE_PERMISSION while the CLI
|
|
8
|
+
# continues to use its native configuration and login.
|
|
9
|
+
module Permissions
|
|
10
|
+
module_function
|
|
11
|
+
|
|
12
|
+
def compile(permission_mode:, permission_policy: nil,
|
|
13
|
+
working_directory:, additional_read_roots: [],
|
|
14
|
+
additional_write_roots: [], edit_patterns: [],
|
|
15
|
+
bash_patterns: [], plugins: [], runtime_write_roots: [])
|
|
16
|
+
mode = permission_mode
|
|
17
|
+
if mode.nil?
|
|
18
|
+
return nil unless permission_policy
|
|
19
|
+
unless permission_policy.is_a?(OpenCodePermissionPolicy)
|
|
20
|
+
raise ArgumentError,
|
|
21
|
+
"permission_policy must be an OpenCodePermissionPolicy"
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
return deep_copy(permission_policy.rules)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
unless %w[read-only workspace-write].include?(mode)
|
|
28
|
+
raise ConfigurationError, "unsupported OpenCode permission mode"
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
roots = {
|
|
32
|
+
working: File.expand_path(working_directory),
|
|
33
|
+
read: expanded_roots(additional_read_roots),
|
|
34
|
+
write: expanded_roots(additional_write_roots)
|
|
35
|
+
}
|
|
36
|
+
runtime_roots = expanded_roots(runtime_write_roots)
|
|
37
|
+
external = { "*" => "deny" }
|
|
38
|
+
[ *roots.fetch(:read), *roots.fetch(:write), *runtime_roots ].uniq.each do |root|
|
|
39
|
+
external[root] = "allow"
|
|
40
|
+
external["#{root}/**"] = "allow"
|
|
41
|
+
end
|
|
42
|
+
common = {
|
|
43
|
+
"*" => "deny",
|
|
44
|
+
"read" => {
|
|
45
|
+
"*" => "allow",
|
|
46
|
+
"*.env" => "deny",
|
|
47
|
+
"*.env.*" => "deny",
|
|
48
|
+
"*.env.example" => "allow"
|
|
49
|
+
},
|
|
50
|
+
"glob" => "allow",
|
|
51
|
+
"grep" => "allow",
|
|
52
|
+
"list" => "allow",
|
|
53
|
+
"lsp" => "allow",
|
|
54
|
+
"skill" => Array(plugins).empty? ? "deny" : "allow",
|
|
55
|
+
"external_directory" => external
|
|
56
|
+
}
|
|
57
|
+
if mode == "read-only"
|
|
58
|
+
return common.merge(
|
|
59
|
+
"edit" => "deny", "bash" => "deny", "task" => "deny",
|
|
60
|
+
"webfetch" => "deny", "websearch" => "deny",
|
|
61
|
+
"question" => "deny"
|
|
62
|
+
)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
writable_roots = [
|
|
66
|
+
roots.fetch(:working), *roots.fetch(:write), *runtime_roots
|
|
67
|
+
].uniq
|
|
68
|
+
edit = { "*" => "deny" }
|
|
69
|
+
allows = if Array(edit_patterns).empty?
|
|
70
|
+
writable_roots.flat_map do |root|
|
|
71
|
+
root_edit_patterns(root, working: roots.fetch(:working))
|
|
72
|
+
end
|
|
73
|
+
else
|
|
74
|
+
normalize_declared_edit_patterns(
|
|
75
|
+
edit_patterns, writable_roots, working: roots.fetch(:working)
|
|
76
|
+
)
|
|
77
|
+
end
|
|
78
|
+
allows.each { |pattern| edit[pattern] = "allow" }
|
|
79
|
+
(roots.fetch(:read) - roots.fetch(:write)).each do |root|
|
|
80
|
+
root_edit_patterns(root, working: roots.fetch(:working)).each do |pattern|
|
|
81
|
+
edit[pattern] = "deny"
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
bash = { "*" => "deny" }
|
|
85
|
+
Array(bash_patterns).each { |pattern| bash[pattern.to_s] = "allow" }
|
|
86
|
+
common.merge(
|
|
87
|
+
"edit" => edit,
|
|
88
|
+
"bash" => Array(bash_patterns).empty? ? "deny" : bash,
|
|
89
|
+
"task" => "deny",
|
|
90
|
+
"webfetch" => "deny", "websearch" => "deny",
|
|
91
|
+
"question" => "deny"
|
|
92
|
+
)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def expanded_roots(values)
|
|
96
|
+
Array(values).map { |value| File.expand_path(value) }.uniq.freeze
|
|
97
|
+
end
|
|
98
|
+
private_class_method :expanded_roots
|
|
99
|
+
|
|
100
|
+
def root_edit_patterns(root, working:)
|
|
101
|
+
if root == working
|
|
102
|
+
[ "**" ]
|
|
103
|
+
elsif root.start_with?(working + File::SEPARATOR)
|
|
104
|
+
relative = root.delete_prefix(working + File::SEPARATOR)
|
|
105
|
+
[ relative, "#{relative}/**" ]
|
|
106
|
+
else
|
|
107
|
+
[ root, "#{root}/**" ]
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
private_class_method :root_edit_patterns
|
|
111
|
+
|
|
112
|
+
def normalize_declared_edit_patterns(patterns, writable_roots, working:)
|
|
113
|
+
Array(patterns).map do |value|
|
|
114
|
+
pattern = value.to_s.sub(%r{\A//}, "/")
|
|
115
|
+
unless File.absolute_path?(pattern) && !pattern.include?("\0")
|
|
116
|
+
raise ConfigurationError,
|
|
117
|
+
"OpenCode edit patterns must be absolute path patterns"
|
|
118
|
+
end
|
|
119
|
+
literal_prefix = pattern.split(/[*?]/, 2).first.sub(%r{/+\z}, "")
|
|
120
|
+
unless writable_roots.any? do |root|
|
|
121
|
+
literal_prefix == root ||
|
|
122
|
+
literal_prefix.start_with?(root + File::SEPARATOR)
|
|
123
|
+
end
|
|
124
|
+
raise ConfigurationError,
|
|
125
|
+
"OpenCode edit pattern is outside the declared write roots"
|
|
126
|
+
end
|
|
127
|
+
if pattern == working
|
|
128
|
+
"**"
|
|
129
|
+
elsif pattern.start_with?(working + File::SEPARATOR)
|
|
130
|
+
pattern.delete_prefix(working + File::SEPARATOR)
|
|
131
|
+
else
|
|
132
|
+
pattern
|
|
133
|
+
end
|
|
134
|
+
end.uniq.freeze
|
|
135
|
+
end
|
|
136
|
+
private_class_method :normalize_declared_edit_patterns
|
|
137
|
+
|
|
138
|
+
def deep_copy(value)
|
|
139
|
+
JSON.parse(JSON.generate(value))
|
|
140
|
+
end
|
|
141
|
+
private_class_method :deep_copy
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
end
|