agent-cli-runtime 0.2.0 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 38323f74e355a6a9da1dfb45ccee48c253ffbd0c9693a16cd62910ced381c7cb
4
- data.tar.gz: 2e38766e9b737df1e5360ac8013849cf2108dab3f8b937f24ec393c001aba669
3
+ metadata.gz: e0202bb311856ad96fcd486517d11cfc7175db0102a188920c4d851bf338b7d6
4
+ data.tar.gz: 2e72a23b5e7a021fc439a9ce8ef34bf166c0af0141de491a27686d6f3ba3f139
5
5
  SHA512:
6
- metadata.gz: 95e1213e6586ea162e2a12294b350d4ea5190c8f09239e276fe4de17ceb1a7787c0c4979e3e71c6acb0f8e318facea099820a221130b123549d8a123c13e62b7
7
- data.tar.gz: 1cca33e3af240a7abf5b8627d69256ee43647557fc2aeeab4f74692c7ba08ff74a0b0a5cdef202796f4559cc04d0498138762003fd14845c61029bd0b7603231
6
+ metadata.gz: b3736f38aa29d809028b1e99c9df1dd17ddb4a210382e89f752def380e84cf22e3f144da2d10ee6c86c2dab8496865a35347196277367a0734b9c39991c33f0e
7
+ data.tar.gz: 5adacccc362958a8cc9a2eeab22b9100041101de4795d7f31ddaa563406ec031f6b3662e84eca058b144ab86ee41d1505c42baa267c1eae436cda4566680b100
data/CHANGELOG.md CHANGED
@@ -2,23 +2,90 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.2.4 - 2026-08-25
6
+
7
+ - Expose the OpenCode permission compiler independently from the prepared
8
+ overlay so callers can supply the same deny-first rules through native
9
+ per-process configuration without duplicating policy generation.
10
+
11
+ - Carry the already-resolved OpenCode executable on `ProbeRequest` (`executable:`)
12
+ instead of re-encoding it as the `AGENT_CLI_RUNTIME_OPENCODE_BIN` environment
13
+ override. The route-aware probe now resolves the executable once — honoring a
14
+ caller-supplied value first — and threads that single value through the
15
+ installation check, version check, every local inspection command, and the
16
+ reported result executable. `Profile#binary_installed?`,
17
+ `Profile#check_version!`, and `Profile#capture_local` accept an optional
18
+ `executable:` override; existing environment-based resolution is unchanged
19
+ when no explicit value is carried.
20
+
21
+ ## 0.2.3 - 2026-08-21
22
+
23
+ - Classify extracted provider failures once at the runtime boundary as
24
+ `provider_limit`, `rate_limited`, `model_output_limit`, or `provider_error`.
25
+ HTTP 402/429 payloads are authoritative even when their provider-specific
26
+ wording does not match an orchestrator's legacy quota phrases.
27
+
28
+ ## 0.2.2 - 2026-08-18
29
+
30
+ - Deliver built-in Pi prompts through its native non-TTY stdin reader instead
31
+ of one positional argv element. This prevents implementation-sized prompts
32
+ from failing at process spawn with the operating system's per-argument
33
+ `E2BIG` limit; Codex retains its separate stdin-plus-`-` transport.
34
+ - Treat Pi's `stopReason: "length"` as a typed `model_output_limit` failure.
35
+ Pi exits zero after this provider stop even when the model exhausted its
36
+ output allowance before writing the requested artifact; callers can now
37
+ distinguish that incomplete turn from an agent that silently produced
38
+ 0 bytes and tell operators to raise `maxTokens` or lower reasoning effort.
39
+ - Declare Grok's filesystem sandbox flags, so a caller asking for confined
40
+ execution gets it instead of being told Grok cannot confine. `--sandbox
41
+ workspace` limits writes to the working directory and `--sandbox read-only`
42
+ forbids them, both built-in profiles that custom ones extend from
43
+ `~/.grok/sandbox.toml`; `--always-approve` suppresses the approval prompt a
44
+ headless run can never answer, leaving the sandbox — not the prompt — as the
45
+ boundary. Only the Grok profile changes.
46
+
47
+ ## 0.2.1 - 2026-08-17
48
+
49
+ - Add per-profile provider-error extraction so a refusal a CLI reports on its
50
+ event stream is distinguishable from an agent that genuinely produced
51
+ nothing. `extract_provider_error` returns the provider, the HTTP status when
52
+ the provider supplied one, and the redacted provider text.
53
+ - Add the `pi` extractor for turns that keep the envelope type and carry the
54
+ refusal in `stopReason`/`errorMessage`, which no event-type match observes.
55
+ Such turns previously reached the caller as a clean run with empty content
56
+ while the process exited zero.
57
+ - Default every other profile to the previously assumed shapes — dedicated
58
+ `error`, `turn.failed`, and `rate_limit_event` events plus failed `result`
59
+ events — so no existing profile changes behaviour.
60
+ - Read usage from the assistant message and accept the bare
61
+ `input`/`output`/`cacheRead`/`cacheWrite` spellings, so a provider reporting
62
+ usage there is metered instead of silently recording nothing. The bare
63
+ spellings are matched last, leaving a provider that reports explicit
64
+ `*_tokens` keys with its existing reading.
65
+
5
66
  ## 0.2.0 - 2026-08-15
6
67
 
7
- - Add OpenCode `1.18.16+` as a fifth immutable built-in profile with exact
8
- `provider/model` routing and faithful model-variant validation.
9
- - Add route-aware offline probing for the required run/export flags, selected
10
- authentication source, cached model inventory, and exact requested route.
11
- - Add invocation-owned OpenCode config/data/cache/state overlays with
12
- deny-first `read-only` and `workspace-write` policies, explicit credential
13
- forwarding, owner-private resources, and idempotent cleanup.
14
- - Add strict run/export correlation and typed outcomes for completion,
15
- authentication/configuration/CLI failure, malformed output, cancellation,
16
- and timeout while preserving requested versus actual route identity.
17
- - Preserve unavailable separately from numeric zero for input, output,
18
- cache-read, cache-write, reasoning, and cost evidence.
19
- - Keep process spawning, streaming, timeout/cancellation supervision,
20
- process-tree cleanup, retries, and post-run inspection execution with the
21
- caller; the component returns commands and normalizes captured evidence.
68
+ - Add first-class OpenCode `1.18.16+` support alongside the built-in Claude
69
+ Code, Codex CLI, Pi, and Grok CLI profiles. OpenCode uses the shared request
70
+ and facade, then adds provider-specific preparation, route probing, and
71
+ strict result normalization.
72
+ - Route every OpenCode run to an exact `provider/model` and validate model
73
+ variants, preserving the route the application requested and the route the
74
+ captured result reports.
75
+ - Prepare owner-private OpenCode config, data, cache, and state overlays for
76
+ each invocation, with scoped `read-only` and `workspace-write` policies,
77
+ deliberate credential forwarding, and idempotent cleanup.
78
+ - Check run/export capabilities, selected authentication source, cached model
79
+ inventory, and the exact requested route locally before an application
80
+ starts work.
81
+ - Correlate run and sanitized-export evidence into typed outcomes for
82
+ completion, authentication, configuration, CLI failure, malformed output,
83
+ cancellation, and timeout.
84
+ - Report input, output, cache-read, cache-write, reasoning, and cost evidence
85
+ while preserving the difference between unavailable data and numeric zero.
86
+ - Return prepared commands and normalized evidence through lifecycle-safe
87
+ values that fit an application's existing streaming, timeout, cancellation,
88
+ retry, and process-supervision stack.
22
89
 
23
90
  ## 0.1.1 - 2026-08-11
24
91
 
data/README.md CHANGED
@@ -1,12 +1,31 @@
1
1
  # Agent CLI Runtime
2
2
 
3
- `agent-cli-runtime` is a small Ruby library for tools that integrate with
4
- locally installed headless agent CLIs. It ships immutable profiles for Claude
5
- Code, Codex CLI, Pi, Grok CLI, and OpenCode and compiles provider-neutral
6
- requests into argv/stdin. It also reports typed capability evidence, extracts
7
- usage from provider JSON events, and exposes an honest local diagnostic
8
- command. OpenCode adds an invocation-owned overlay and strict captured-result
9
- normalizer while leaving process supervision with the caller.
3
+ Agent CLI Runtime gives Ruby applications one stable integration layer for
4
+ Claude Code, Codex CLI, Pi, Grok CLI, and OpenCode. It builds provider-specific
5
+ commands from a shared request model, checks local versions and named
6
+ capabilities, and normalizes usage and results afterward. Centralized profiles,
7
+ environment rules, and parsers make agent providers easier to add, switch, and
8
+ upgrade.
9
+
10
+ Version 0.2.0 adds first-class OpenCode support with exact `provider/model`
11
+ routing, isolated per-invocation configuration, scoped permission policies,
12
+ offline readiness checks, and typed outcome normalization. See the
13
+ [changelog](CHANGELOG.md) for the complete release notes.
14
+
15
+ ## Why use it?
16
+
17
+ Agent CLIs disagree on flags, prompt transport, configuration locations,
18
+ permission controls, usage events, and result formats. Agent CLI Runtime keeps
19
+ those differences in versioned profiles so application code can use one
20
+ request and result vocabulary.
21
+
22
+ - Add or switch agent CLIs without spreading provider conditionals throughout
23
+ the application.
24
+ - Validate installed versions, capabilities, and exact routes before starting
25
+ work.
26
+ - Preserve typed usage and outcome evidence across provider-specific output
27
+ formats.
28
+ - Add custom profiles through the same immutable compatibility contract.
10
29
 
11
30
  ## Install
12
31
 
@@ -41,6 +60,11 @@ Compilation does not execute the returned command. Unsupported requested
41
60
  controls raise `AgentCliRuntime::UnsupportedCapability` with typed evidence
42
61
  instead of silently widening the request.
43
62
 
63
+ Prompt transport is profile-owned. `:stdin` writes the prompt to stdin and
64
+ adds the CLI's conventional `-` argv marker; `:piped_stdin` writes the prompt
65
+ to stdin without a marker for CLIs such as Pi that consume a non-TTY stream
66
+ directly. Built-in Pi therefore keeps arbitrarily large prompts out of argv.
67
+
44
68
  `permission_mode: nil` selects the profile's default non-interactive permission
45
69
  flags, which may include a provider's bypass flag. Pass `"read-only"` or
46
70
  `"workspace-write"` explicitly when the integration requires that constraint.
@@ -58,12 +82,14 @@ represent.
58
82
  - `extract_usage(profile, event)` normalizes provider usage when present and
59
83
  returns `nil` when usage is absent or malformed.
60
84
  - `observe(profile, result)` normalizes bounded, redacted result metadata.
61
- - `prepare!(open_code_preparation)` creates and probes an isolated OpenCode
62
- overlay and returns a `PreparedInvocation`; it does not spawn OpenCode.
85
+ - `prepare!(open_code_preparation)` returns a `PreparedInvocation` containing
86
+ an isolated OpenCode overlay, argv, and child environment.
87
+ - `OpenCode::Permissions.compile(...)` returns the same deny-first permission
88
+ document independently for callers that retain native OpenCode state.
63
89
  - `parse_run(profile, stdout:)` parses a successful OpenCode JSONL capture
64
90
  into the session and terminal-message identity required for inspection.
65
- - `prepare_inspection(prepared, parsed_run)` compiles the non-model sanitized
66
- session-export command without executing it.
91
+ - `prepare_inspection(prepared, parsed_run)` returns the non-model sanitized
92
+ session-export invocation.
67
93
  - `normalize(profile, captured, requested_route:)` returns one typed OpenCode
68
94
  outcome from caller-captured run, termination, and inspection evidence.
69
95
 
@@ -125,6 +151,11 @@ read-only configuration source. Configuration may define providers and an
125
151
  exact default model, but it must not contain credential values. Name the
126
152
  credential environment variables the caller is allowed to forward instead.
127
153
 
154
+ Callers that deliberately retain native OpenCode config and login can use
155
+ `OpenCode::Permissions.compile` directly and pass the JSON document through
156
+ OpenCode's per-process permission input. The prepared-overlay API below
157
+ remains the closed, isolated option.
158
+
128
159
  ```ruby
129
160
  require "agent_cli_runtime"
130
161
  require "tmpdir"
@@ -150,7 +181,8 @@ preparation = AgentCliRuntime::OpenCodePreparationRequest.new(
150
181
  },
151
182
  credential_environment_keys: ["ANTHROPIC_API_KEY"],
152
183
  additional_read_roots: [Dir.pwd],
153
- additional_write_roots: [Dir.pwd]
184
+ additional_write_roots: [Dir.pwd],
185
+ bash_patterns: ["git*", "bundle*", "bin/*"]
154
186
  )
155
187
 
156
188
  prepared = AgentCliRuntime.prepare!(preparation)
@@ -198,8 +230,10 @@ to call twice. Call it from the process owner's `ensure` path after every
198
230
  pre-spawn and post-spawn outcome.
199
231
 
200
232
  `read-only` denies edits, shell, unsafe tools, and external writes.
201
- `workspace-write` permits edits only under the declared write roots and still
202
- denies unrestricted shell. A `nil` permission mode is rejected unless the
233
+ `workspace-write` permits edits only under the declared write roots. Shell is
234
+ denied unless the caller supplies explicit OpenCode `bash_patterns`; those
235
+ patterns are application permissions, not an OS sandbox. A `nil` permission
236
+ mode is rejected unless the
203
237
  consumer supplies an explicit typed `OpenCodePermissionPolicy`; the ordinary
204
238
  preparation API never silently falls back to a bypass. Plugin sources are
205
239
  explicit, and `--pure` remains enabled when no plugin was selected.
@@ -218,10 +252,12 @@ model request:
218
252
  bundle exec ruby -Itest test/opencode_offline_smoke_test.rb
219
253
  ```
220
254
 
221
- If an installation command is itself a package-manager shim, set
222
- `AGENT_CLI_RUNTIME_OPENCODE_OFFLINE_BIN` to the already-installed native
223
- OpenCode executable so the smoke cannot trigger shim installation or refresh
224
- behavior.
255
+ Auto-discovery considers every matching executable in `PATH` and prefers an
256
+ already-installed native executable over an earlier package-manager launcher,
257
+ including script and multicall-binary shims, so the
258
+ smoke cannot trigger package-manager installation or refresh behavior. If only
259
+ a shim is discoverable, set `AGENT_CLI_RUNTIME_OPENCODE_OFFLINE_BIN` to the
260
+ native OpenCode executable explicitly.
225
261
 
226
262
  The authenticated atomic-edit smoke is separately gated and refuses to run
227
263
  without an explicit route, config path, credential variable name, opt-in, and
@@ -5,13 +5,14 @@ Gem::Specification.new do |spec|
5
5
  spec.version = AgentCliRuntime::VERSION
6
6
  spec.authors = [ "Ivan Kuznetsov" ]
7
7
  spec.email = [ "ivan@ikuznetsov.com" ]
8
- spec.summary = "Provider-neutral contracts for installed agent CLIs"
8
+ spec.summary =
9
+ "One Ruby API for Claude Code, Codex CLI, Pi, Grok CLI, and OpenCode"
9
10
  spec.description = <<~DESC
10
- Agent CLI Runtime provides immutable profiles, invocation compilation,
11
- local prerequisite probes, capability evidence, usage extraction, and
12
- result normalization for Claude Code, Codex CLI, Pi, Grok CLI, and
13
- OpenCode. It does not spawn agents or claim live provider health, quota,
14
- or credential validity.
11
+ Agent CLI Runtime gives Ruby applications one stable integration layer for
12
+ local coding-agent CLIs. It builds provider-specific commands from a shared
13
+ request model, checks local versions and named capabilities, and normalizes
14
+ usage and results afterward. Centralized profiles, environment rules, and
15
+ parsers make agent providers easier to add, switch, and upgrade.
15
16
  DESC
16
17
  spec.homepage = "https://github.com/ivankuznetsov/agent-cli-runtime"
17
18
  spec.license = "MIT"
@@ -0,0 +1,95 @@
1
+ module AgentCliRuntime
2
+ # Provider-side failures that a CLI reports on its event stream while still
3
+ # exiting zero. Each profile owns the shape its own CLI emits. Extractors
4
+ # return either the raw provider text, an ExtractedFailure, or nil when the
5
+ # event carries no failure. Runtime.extract_provider_error normalizes and
6
+ # redacts the result.
7
+ module ErrorExtractors
8
+ module_function
9
+
10
+ # CLIs that name the failure with a dedicated event type. This is the same
11
+ # set Hive scanned for inline before provider shape moved behind the
12
+ # profile, so wiring a profile to DEFAULT preserves its behaviour.
13
+ DEFAULT = lambda do |event|
14
+ next nil unless event.is_a?(Hash)
15
+
16
+ case event["type"]
17
+ when "error", "turn.failed", "rate_limit_event"
18
+ ErrorExtractors.message_from(event)
19
+ when "result"
20
+ if event["is_error"] == true || event["subtype"].to_s.start_with?("error")
21
+ ErrorExtractors.message_from(event)
22
+ end
23
+ end
24
+ end
25
+
26
+ # Claude's subscription wall is a dedicated structured event with no
27
+ # human-readable message. The generic extractor therefore returned nil,
28
+ # leaving callers to flatten a rejected five-hour/seven-day window into a
29
+ # generic exit-code failure. Accept only the exact rejected account-window
30
+ # shapes observed from Claude Code and synthesize bounded provider text for
31
+ # the normal typed limit path. Other Claude errors retain DEFAULT behavior.
32
+ CLAUDE_ACCOUNT_LIMIT_TYPES = %w[five_hour seven_day].freeze
33
+ CLAUDE = lambda do |event|
34
+ if event.is_a?(Hash) && event["type"] == "rate_limit_event"
35
+ info = event["rate_limit_info"]
36
+ if info.is_a?(Hash) && info["status"] == "rejected"
37
+ limit_type = info["rateLimitType"].to_s
38
+ if CLAUDE_ACCOUNT_LIMIT_TYPES.include?(limit_type)
39
+ next ExtractedFailure.new(
40
+ kind: :provider_limit,
41
+ message: "Claude account quota reached (#{limit_type})"
42
+ )
43
+ end
44
+ end
45
+ end
46
+
47
+ DEFAULT.call(event)
48
+ end
49
+
50
+ # pi keeps the envelope type ("message_start"/"message_end") and moves the
51
+ # terminal state into stopReason. Provider refusals use
52
+ # stopReason=error/errorMessage. A model that consumes its entire output
53
+ # allowance uses stopReason=length and can exit zero without executing the
54
+ # final write tool. Matching on type alone sees neither failure.
55
+ PI = lambda do |event|
56
+ next nil unless event.is_a?(Hash)
57
+
58
+ message = event["message"].is_a?(Hash) ? event["message"] : event
59
+ case message["stopReason"].to_s
60
+ when "error"
61
+ ErrorExtractors.message_from(message) || ErrorExtractors.message_from(event)
62
+ when "length"
63
+ ExtractedFailure.new(
64
+ kind: :model_output_limit,
65
+ message: "model response reached its maximum output tokens"
66
+ )
67
+ end
68
+ end
69
+
70
+ # OpenCode keeps provider failures under error.data.message rather than
71
+ # the top-level/message and error.message shapes used by the default
72
+ # extractor. Read only the dedicated error event so ordinary model text
73
+ # cannot forge a provider-limit signal.
74
+ OPENCODE = lambda do |event|
75
+ next nil unless event.is_a?(Hash) && event["type"] == "error"
76
+
77
+ data = event.dig("error", "data")
78
+ message = data["message"] if data.is_a?(Hash)
79
+ message if message.is_a?(String) && !message.strip.empty?
80
+ end
81
+
82
+ def message_from(event)
83
+ return nil unless event.is_a?(Hash)
84
+
85
+ candidates = [
86
+ event["errorMessage"],
87
+ event["message"],
88
+ event.dig("error", "message"),
89
+ event["error"]
90
+ ]
91
+ text = candidates.find { |value| value.is_a?(String) && !value.strip.empty? }
92
+ text&.strip
93
+ end
94
+ end
95
+ end
@@ -66,12 +66,26 @@ module AgentCliRuntime
66
66
  preparation.credential_environment_keys
67
67
  )
68
68
  plugins = selected_plugins(source_config, preparation.plugins)
69
- permission = permission_rules(preparation, roots, plugins: plugins)
70
69
 
71
70
  root = create_root!(preparation.invocation_root)
72
71
  cleanup = Cleanup.new(root)
73
72
  begin
74
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
75
89
  pure = preparation.pure && plugins.empty?
76
90
  config = generated_configuration(
77
91
  source_config, plugins, requested_route, permission
@@ -91,9 +105,6 @@ module AgentCliRuntime
91
105
  )
92
106
  executable =
93
107
  preparation.request.executable || profile.bin(env: env)
94
- probe_env = env.to_h.merge(
95
- "AGENT_CLI_RUNTIME_OPENCODE_BIN" => executable
96
- )
97
108
  probe_request = ProbeRequest.new(
98
109
  profile: profile,
99
110
  route: requested_route,
@@ -103,9 +114,12 @@ module AgentCliRuntime
103
114
  credential_file_staged:
104
115
  staged_credential && credential_file_supports_provider?(
105
116
  staged_credential.last, requested_route.provider
106
- )
117
+ ),
118
+ configured_variants:
119
+ configured_variants(source_config, requested_route),
120
+ executable: executable
107
121
  )
108
- probe_result = OpenCode::Probe.call!(probe_request, env: probe_env)
122
+ probe_result = OpenCode::Probe.call!(probe_request, env: env)
109
123
  invocation = compile_invocation(
110
124
  preparation, profile, requested_route, roots,
111
125
  executable:, pure: pure,
@@ -175,6 +189,21 @@ module AgentCliRuntime
175
189
  end
176
190
  private_class_method :validate_provider!
177
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
+
178
207
  def validate_nonsecret!(value, key = nil)
179
208
  case value
180
209
  when Hash
@@ -244,120 +273,6 @@ module AgentCliRuntime
244
273
  end
245
274
  private_class_method :resolve_roots
246
275
 
247
- def permission_rules(preparation, roots, plugins: preparation.plugins)
248
- mode = preparation.request.permission_mode
249
- if mode.nil?
250
- policy = preparation.permission_policy
251
- unless policy
252
- raise ConfigurationError,
253
- "OpenCode requires an explicit OpenCode permission policy when permission_mode is nil"
254
- end
255
- return deep_copy(policy.rules)
256
- end
257
-
258
- unless %w[read-only workspace-write].include?(mode)
259
- raise ConfigurationError,
260
- "unsupported OpenCode permission mode"
261
- end
262
-
263
- external = { "*" => "deny" }
264
- (roots.fetch(:read) + roots.fetch(:write)).uniq.each do |root|
265
- external[root] = "allow"
266
- external["#{root}/**"] = "allow"
267
- end
268
- common = {
269
- "*" => "deny",
270
- "read" => {
271
- "*" => "allow",
272
- "*.env" => "deny",
273
- "*.env.*" => "deny",
274
- "*.env.example" => "allow"
275
- },
276
- "glob" => "allow",
277
- "grep" => "allow",
278
- "list" => "allow",
279
- "lsp" => "allow",
280
- "skill" => plugins.empty? ? "deny" : "allow",
281
- "external_directory" => external
282
- }
283
- if mode == "read-only"
284
- return common.merge(
285
- "edit" => "deny", "bash" => "deny", "task" => "deny",
286
- "webfetch" => "deny", "websearch" => "deny",
287
- "question" => "deny"
288
- )
289
- end
290
-
291
- # OpenCode applies the last matching permission rule. Start from a
292
- # denial and append only the invocation's declared write roots. The
293
- # working directory is an implicit write root for workspace-write;
294
- # additional read roots remain explicitly denied unless the caller
295
- # also declared them writable.
296
- writable_roots = [ roots.fetch(:working), *roots.fetch(:write) ].uniq
297
- edit = { "*" => "deny" }
298
- allows = if preparation.edit_patterns.empty?
299
- writable_roots.flat_map do |root|
300
- edit_patterns(root, working: roots.fetch(:working))
301
- end
302
- else
303
- normalize_declared_edit_patterns(
304
- preparation.edit_patterns, writable_roots,
305
- working: roots.fetch(:working)
306
- )
307
- end
308
- allows.each do |pattern|
309
- edit[pattern] = "allow"
310
- end
311
- (roots.fetch(:read) - roots.fetch(:write)).each do |root|
312
- edit_patterns(root, working: roots.fetch(:working)).each do |pattern|
313
- edit[pattern] = "deny"
314
- end
315
- end
316
- common.merge(
317
- "edit" => edit, "bash" => "deny", "task" => "deny",
318
- "webfetch" => "deny", "websearch" => "deny",
319
- "question" => "deny"
320
- )
321
- end
322
- private_class_method :permission_rules
323
-
324
- def edit_patterns(root, working:)
325
- if root == working
326
- [ "**" ]
327
- elsif root.start_with?(working + File::SEPARATOR)
328
- relative = root.delete_prefix(working + File::SEPARATOR)
329
- [ relative, "#{relative}/**" ]
330
- else
331
- [ root, "#{root}/**" ]
332
- end
333
- end
334
- private_class_method :edit_patterns
335
-
336
- def normalize_declared_edit_patterns(patterns, writable_roots, working:)
337
- patterns.map do |value|
338
- pattern = value.sub(%r{\A//}, "/")
339
- unless File.absolute_path?(pattern) && !pattern.include?("\0")
340
- raise ConfigurationError,
341
- "OpenCode edit patterns must be absolute path patterns"
342
- end
343
- literal_prefix = pattern.split(/[*?]/, 2).first.sub(%r{/+\z}, "")
344
- unless writable_roots.any? do |root|
345
- literal_prefix == root || literal_prefix.start_with?(root + File::SEPARATOR)
346
- end
347
- raise ConfigurationError,
348
- "OpenCode edit pattern is outside the declared write roots"
349
- end
350
- if pattern == working
351
- "**"
352
- elsif pattern.start_with?(working + File::SEPARATOR)
353
- pattern.delete_prefix(working + File::SEPARATOR)
354
- else
355
- pattern
356
- end
357
- end.uniq.freeze
358
- end
359
- private_class_method :normalize_declared_edit_patterns
360
-
361
276
  def create_root!(value)
362
277
  path = File.expand_path(value)
363
278
  unless File.absolute_path?(value.to_s)