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