claude-agent-sdk 1.0.0 → 1.2.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.
Files changed (47) hide show
  1. checksums.yaml +4 -4
  2. data/.yardopts +10 -0
  3. data/CHANGELOG.md +110 -0
  4. data/README.md +43 -31
  5. data/docs/cli-installer.md +26 -4
  6. data/docs/client.md +40 -11
  7. data/docs/configuration.md +206 -1
  8. data/docs/errors.md +32 -2
  9. data/docs/hooks-and-permissions.md +30 -10
  10. data/docs/mcp-servers.md +30 -9
  11. data/docs/observability.md +61 -10
  12. data/docs/options.md +232 -0
  13. data/docs/rails.md +263 -18
  14. data/docs/sessions.md +40 -12
  15. data/docs/subagents.md +1 -1
  16. data/docs/types.md +100 -11
  17. data/lib/claude_agent_sdk/cli_installer.rb +140 -19
  18. data/lib/claude_agent_sdk/command_builder.rb +84 -27
  19. data/lib/claude_agent_sdk/fiber_boundary.rb +45 -2
  20. data/lib/claude_agent_sdk/instrumentation/otel.rb +90 -28
  21. data/lib/claude_agent_sdk/query.rb +547 -132
  22. data/lib/claude_agent_sdk/railtie.rb +27 -2
  23. data/lib/claude_agent_sdk/sdk_mcp_server.rb +78 -26
  24. data/lib/claude_agent_sdk/session_mutations.rb +112 -92
  25. data/lib/claude_agent_sdk/session_resume.rb +356 -39
  26. data/lib/claude_agent_sdk/session_store.rb +31 -2
  27. data/lib/claude_agent_sdk/sessions.rb +720 -138
  28. data/lib/claude_agent_sdk/subprocess_cli_transport.rb +252 -29
  29. data/lib/claude_agent_sdk/testing/session_store_conformance.rb +18 -7
  30. data/lib/claude_agent_sdk/transcript_mirror_batcher.rb +45 -37
  31. data/lib/claude_agent_sdk/transport.rb +28 -12
  32. data/lib/claude_agent_sdk/types/attributes.rb +9 -0
  33. data/lib/claude_agent_sdk/types/base.rb +85 -15
  34. data/lib/claude_agent_sdk/types/hooks.rb +73 -0
  35. data/lib/claude_agent_sdk/types/mcp.rb +37 -1
  36. data/lib/claude_agent_sdk/types/messages.rb +7 -1
  37. data/lib/claude_agent_sdk/types/option_values.rb +186 -4
  38. data/lib/claude_agent_sdk/types/options.rb +104 -17
  39. data/lib/claude_agent_sdk/types/permissions.rb +18 -9
  40. data/lib/claude_agent_sdk/version.rb +1 -1
  41. data/lib/claude_agent_sdk.rb +111 -53
  42. data/lib/generators/claude_agent_sdk/install/templates/claude_agent_sdk.rb.tt +6 -0
  43. data/sig/claude_agent_sdk/types/hooks.rbs +6 -3
  44. data/sig/claude_agent_sdk/types/option_values.rbs +23 -6
  45. data/sig/claude_agent_sdk/types/options.rbs +20 -7
  46. data/sig/claude_agent_sdk/types/permissions.rbs +4 -2
  47. metadata +6 -4
@@ -1,42 +1,58 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ClaudeAgentSDK
4
- # Abstract transport for Claude communication
4
+ # Abstract base class for transports: the channel between the SDK and a
5
+ # Claude Code CLI process.
5
6
  #
6
- # WARNING: This internal API is exposed for custom transport implementations
7
- # (e.g., remote Claude Code connections). The Claude Code team may change or
8
- # remove this abstract class in any future release. Custom implementations
9
- # must be updated to match interface changes.
7
+ # Public API, covered by this gem's SemVer promise like the rest of the
8
+ # documented surface. {SubprocessCLITransport}, the default, spawns the CLI
9
+ # locally. To run the CLI somewhere else (a container, an SSH host, a
10
+ # sandbox VM), subclass this class, or write any object with the same
11
+ # methods, and pass it to {Client#initialize} as `transport_class:` or to
12
+ # {ClaudeAgentSDK.query} / {ClaudeAgentSDK.ask} as `transport:`. See
13
+ # docs/client.md, "Custom Transport".
14
+ #
15
+ # The SDK calls five methods: {#connect}, {#write}, {#read_messages},
16
+ # {#end_input} and {#close}. It never calls {#ready?}, so that one is
17
+ # optional. Every method here raises NotImplementedError until a subclass
18
+ # overrides it.
10
19
  class Transport
11
- # Connect the transport and prepare for communication
20
+ # Establish the connection (spawn or reach the CLI) and prepare for IO.
12
21
  def connect
13
22
  raise NotImplementedError, 'Subclasses must implement #connect'
14
23
  end
15
24
 
16
- # Write raw data to the transport
25
+ # Write raw data to the CLI's stdin.
17
26
  # @param data [String] Raw string data to write (typically JSON + newline)
18
27
  def write(data)
19
28
  raise NotImplementedError, 'Subclasses must implement #write'
20
29
  end
21
30
 
22
- # Read and parse messages from the transport
23
- # @return [Enumerator] Async enumerator of parsed JSON messages
31
+ # Read the CLI's stdout and yield each line as a parsed message, blocking
32
+ # until the stream ends. Parse each line with
33
+ # `JSON.parse(line, symbolize_names: true)`: the SDK reads Symbol keys.
34
+ # The SDK always passes a block.
35
+ # @yield [Hash{Symbol => Object}] Each parsed message
24
36
  def read_messages
25
37
  raise NotImplementedError, 'Subclasses must implement #read_messages'
26
38
  end
27
39
 
28
- # Close the transport connection and clean up resources
40
+ # Terminate the CLI and clean up resources. Must be idempotent: the SDK
41
+ # can call it more than once, and calls it after a failed {#connect} too.
29
42
  def close
30
43
  raise NotImplementedError, 'Subclasses must implement #close'
31
44
  end
32
45
 
33
- # Check if transport is ready for communication
46
+ # Check if transport is ready for communication. Optional: the SDK never
47
+ # calls it.
34
48
  # @return [Boolean] True if transport is ready to send/receive messages
35
49
  def ready?
36
50
  raise NotImplementedError, 'Subclasses must implement #ready?'
37
51
  end
38
52
 
39
- # End the input stream (close stdin for process transports)
53
+ # End the input stream (close stdin for process transports). The SDK
54
+ # calls it when a one-shot query's run is over and when a streamed prompt
55
+ # is exhausted.
40
56
  def end_input
41
57
  raise NotImplementedError, 'Subclasses must implement #end_input'
42
58
  end
@@ -184,6 +184,10 @@ module ClaudeAgentSDK
184
184
  end
185
185
 
186
186
  def read_attribute(name)
187
+ # A writer's name (`session_id=`) is not readable. Checked before the
188
+ # cache, which also holds the camelCase writers method_missing resolved.
189
+ return if writer_name?(name)
190
+
187
191
  reader = self.class.cached_attribute_reader(name)
188
192
  return public_send(reader) if reader
189
193
 
@@ -198,6 +202,11 @@ module ClaudeAgentSDK
198
202
  end
199
203
  end
200
204
 
205
+ def writer_name?(name)
206
+ name = name.to_s unless name.is_a?(Symbol) || name.is_a?(String)
207
+ name.end_with?('=')
208
+ end
209
+
201
210
  # Whether a normalized reader, writer or predicate name belongs to an
202
211
  # attribute: a declared one (`session_id`, `session_id=`,
203
212
  # `fork_session?`), or a method user code defined on its own subclass, a
@@ -122,8 +122,19 @@ module ClaudeAgentSDK
122
122
  # dumps every ivar recursively — those (SDK MCP server instances, store
123
123
  # adapters, observers) show as `#<ClassName>`. For display only: nothing
124
124
  # sent to the CLI goes through #inspect or #to_s (wire output uses #to_h).
125
+ #
126
+ # Attributes that carry credentials print as `[FILTERED]` (a Hash keeps
127
+ # its keys): `env`, `settings` and `extra_args` of ClaudeAgentOptions,
128
+ # and `env`, `args` and `headers` of the MCP server configs. An MCP
129
+ # server `url` prints its scheme and host only, and a Hash server config
130
+ # (in `mcp_servers`, or echoed by the CLI in McpServerStatus#config) is
131
+ # filtered like a typed one. `mcp_servers` given as a String prints as
132
+ # `[FILTERED]`.
133
+ #
134
+ # It does not raise: a value that fails while it is rendered shows as
135
+ # `#<ClassName>` (`#<?>` when even its class cannot be asked).
125
136
  def inspect
126
- inspect_with(0, {}.compare_by_identity)
137
+ inspect_bounded(self, 0, {}.compare_by_identity)
127
138
  end
128
139
 
129
140
  # Object#to_s ignores instance variables, so `puts message` would print
@@ -134,9 +145,10 @@ module ClaudeAgentSDK
134
145
  inspect
135
146
  end
136
147
 
137
- # Declares attributes that carry credentials (env vars, auth headers).
138
- # Objects get logged, so #inspect shows them filtered; #to_h and
139
- # everything sent to the CLI are unaffected. Inherited by subclasses.
148
+ # Declares attributes that carry credentials (env vars, auth headers,
149
+ # settings, extra CLI arguments, an MCP server's args). Objects get
150
+ # logged, so #inspect shows them filtered; #to_h and everything sent to
151
+ # the CLI are unaffected. Inherited by subclasses.
140
152
  #
141
153
  # @api private
142
154
  def self.inspect_filtered(*names)
@@ -182,7 +194,7 @@ module ClaudeAgentSDK
182
194
  filtered = self.class.inspect_filtered_attributes
183
195
  instance_variables.filter_map do |ivar|
184
196
  value = instance_variable_get(ivar)
185
- next if value.nil?
197
+ next if nil.equal?(value) # not value.nil?: a BasicObject has no such method
186
198
 
187
199
  name = ivar.to_s.delete_prefix('@')
188
200
  [name, filtered.include?(name) ? inspect_filter(value) : value]
@@ -191,15 +203,65 @@ module ClaudeAgentSDK
191
203
 
192
204
  # A credential-bearing Hash keeps its keys (useful when debugging which
193
205
  # variables are set) with every value replaced; anything else is replaced
194
- # outright. Builds a new Hash; the object itself is never touched.
206
+ # outright, and so is a value that fails while it is asked for its keys.
207
+ # Builds a new Hash; the object itself is never touched.
195
208
  def inspect_filter(value)
196
209
  value.respond_to?(:each_key) ? value.each_key.to_h { |key| [key, '[FILTERED]'] } : '[FILTERED]'
210
+ rescue StandardError
211
+ '[FILTERED]'
212
+ end
213
+
214
+ INSPECT_URL_ORIGIN = %r{
215
+ \A(https?|wss?):// # scheme
216
+ (?:[^/?\#\\]*@)? # userinfo, up to the last @ before the path
217
+ (\[[0-9a-f:.]+\]|[[:alnum:]._-]+) # host: an IPv6 literal or a name
218
+ (:\d{1,5})? # port
219
+ (?=[/?\#]|\z) # then the path, the query, the fragment or the end
220
+ }ix
221
+ private_constant :INSPECT_URL_ORIGIN
222
+
223
+ # A URL can carry a credential in its userinfo, path, query or fragment,
224
+ # so only where it points is shown: `https://mcp.example.com/[FILTERED]`
225
+ # (scheme, host and port). Anything else is replaced outright: a value
226
+ # that is not a String, another scheme, a host or port with a character
227
+ # that does not belong there.
228
+ def inspect_filter_url(value)
229
+ origin = value.is_a?(String) && INSPECT_URL_ORIGIN.match(value)
230
+ origin ? "#{origin[1]}://#{origin[2]}#{origin[3]}/[FILTERED]" : '[FILTERED]'
231
+ rescue StandardError
232
+ '[FILTERED]'
233
+ end
234
+
235
+ # Keys of a raw Hash MCP server config whose values #inspect shows as they are.
236
+ INSPECT_MCP_CONFIG_KEYS = %w[type command name instance].freeze
237
+ private_constant :INSPECT_MCP_CONFIG_KEYS
238
+
239
+ # An MCP server config held as a raw Hash (in ClaudeAgentOptions#mcp_servers,
240
+ # or echoed by the CLI in McpServerStatus#config) carries the same
241
+ # credentials as a typed one: env, headers, args, a token in the url. It
242
+ # keeps its keys and shows what identifies the server: its type, its
243
+ # command (or the SDK server's name and instance) and the scheme and host
244
+ # of its url. Every other value is filtered here rather than left to the
245
+ # nesting limit of #inspect.
246
+ def inspect_mcp_server_config(config)
247
+ config.to_h do |key, value|
248
+ name = key.to_s
249
+ next [key, value] if INSPECT_MCP_CONFIG_KEYS.include?(name)
250
+
251
+ [key, name == 'url' ? inspect_filter_url(value) : '[FILTERED]']
252
+ end
197
253
  end
198
254
 
199
255
  def inspect_class_name
200
256
  self.class.name || self.class.inspect
201
257
  end
202
258
 
259
+ # Printing must never raise (it runs inside loggers and `puts`). The
260
+ # value's own methods run here (a String subclass's #length, a Hash
261
+ # subclass's #size, a Type subclass's #inspect_attributes), so a value
262
+ # that fails while it is rendered becomes a placeholder and the rest of
263
+ # the object still prints. Only StandardError is rescued: Interrupt,
264
+ # SystemExit and the like pass through.
203
265
  def inspect_bounded(value, depth, seen)
204
266
  case value
205
267
  when Type then value.inspect_with(depth, seen)
@@ -212,6 +274,16 @@ module ClaudeAgentSDK
212
274
  when Proc, Method, UnboundMethod then inspect_callable(value)
213
275
  else inspect_leaf(value)
214
276
  end
277
+ rescue StandardError
278
+ inspect_placeholder(value)
279
+ end
280
+
281
+ # `#<ClassName>` for a value that could not be rendered; a BasicObject
282
+ # cannot even be asked for its class.
283
+ def inspect_placeholder(value)
284
+ inspect_truncated_text("#<#{value.class}>")
285
+ rescue StandardError
286
+ '#<?>'
215
287
  end
216
288
 
217
289
  def inspect_container(value, open, close, depth, seen, &)
@@ -231,7 +303,10 @@ module ClaudeAgentSDK
231
303
  # Rendered by hand rather than via Hash#inspect, whose format differs
232
304
  # between Ruby 3.3 (`{:a=>1}`) and 3.4 (`{a: 1}`).
233
305
  def inspect_hash_key(key, depth, seen)
234
- return "#{key.name}: " if key.is_a?(Symbol) && key.inspect.match?(/\A:\w+[?!]?\z/)
306
+ case key # not key.is_a?: a BasicObject key has no such method
307
+ when Symbol
308
+ return "#{key.name}: " if key.inspect.match?(/\A:\w+[?!]?\z/)
309
+ end
235
310
 
236
311
  "#{inspect_bounded(key, depth, seen)} => "
237
312
  end
@@ -265,19 +340,14 @@ module ClaudeAgentSDK
265
340
  "#{rendered[0, INSPECT_MAX_STRING]}…(+#{rendered.length - INSPECT_MAX_STRING} chars)"
266
341
  end
267
342
 
268
- # Printing must never raise (it runs inside loggers and `puts`), so an
269
- # object whose #inspect raises, or a BasicObject without one, falls back
270
- # to a placeholder.
343
+ # An object whose #inspect raises, or a BasicObject without one, falls
344
+ # back to a placeholder.
271
345
  def inspect_leaf(value)
272
346
  return "#<#{value.class}>" if kernel_inspect_only?(value)
273
347
 
274
348
  inspect_truncated_text(value.inspect)
275
349
  rescue StandardError
276
- begin
277
- "#<#{value.class}>"
278
- rescue StandardError
279
- '#<?>'
280
- end
350
+ inspect_placeholder(value)
281
351
  end
282
352
 
283
353
  def kernel_inspect_only?(value)
@@ -637,4 +637,77 @@ module ClaudeAgentSDK
637
637
  result
638
638
  end
639
639
  end
640
+
641
+ # The spellings a hook callback's return value may use for a field of the
642
+ # typed output classes above, mapped to the key the CLI reads. A Hash a
643
+ # callback returns stands for the typed output with the same fields: its
644
+ # keys may be Symbols or Strings, the attribute names (snake_case) or what
645
+ # #to_h emits (camelCase), at the top level and inside hook_specific_output.
646
+ #
647
+ # Only names that differ from their wire key are listed. A key that is not
648
+ # listed is sent as written, so a field of a newer CLI that the typed
649
+ # classes do not model still gets through, in the CLI's own spelling.
650
+ # spec/unit/hook_output_normalization_spec.rb walks every typed output
651
+ # class and fails when an attribute and these tables disagree.
652
+ #
653
+ # @api private
654
+ module HookOutputKeys
655
+ # SyncHookJSONOutput and AsyncHookJSONOutput attributes, plus the
656
+ # Ruby-safe spellings of the two keywords.
657
+ TOP_LEVEL = {
658
+ 'continue_' => 'continue',
659
+ 'async_' => 'async',
660
+ 'suppress_output' => 'suppressOutput',
661
+ 'stop_reason' => 'stopReason',
662
+ 'system_message' => 'systemMessage',
663
+ 'hook_specific_output' => 'hookSpecificOutput',
664
+ 'async_timeout' => 'asyncTimeout'
665
+ }.freeze
666
+
667
+ # Attributes of the *HookSpecificOutput classes.
668
+ HOOK_SPECIFIC = {
669
+ 'hook_event_name' => 'hookEventName',
670
+ 'permission_decision' => 'permissionDecision',
671
+ 'permission_decision_reason' => 'permissionDecisionReason',
672
+ 'updated_input' => 'updatedInput',
673
+ 'additional_context' => 'additionalContext',
674
+ 'updated_tool_output' => 'updatedToolOutput',
675
+ 'updated_mcp_tool_output' => 'updatedMCPToolOutput',
676
+ 'watch_paths' => 'watchPaths'
677
+ }.freeze
678
+
679
+ # The hook output Hash as the CLI reads it: String keys in wire
680
+ # spelling, at the top level and one level down, inside
681
+ # hookSpecificOutput. Values are never rewritten: updatedInput and the
682
+ # tool outputs are the tool's own payloads, and a PermissionRequest
683
+ # decision goes out as the caller wrote it.
684
+ def self.normalize(output)
685
+ normalized = rename(output, TOP_LEVEL)
686
+ specific = normalized['hookSpecificOutput']
687
+ normalized['hookSpecificOutput'] = rename(specific, HOOK_SPECIFIC) if specific.is_a?(Hash)
688
+ normalized
689
+ end
690
+
691
+ # Every key ends up as one String, so a Symbol and a String spelling the
692
+ # same field cannot both reach JSON.generate (json 3.x raises on that;
693
+ # 2.x emits the key twice). When a Hash carries both spellings of one
694
+ # field the wire spelling wins, whichever comes first; between two keys
695
+ # in the same spelling the later one does.
696
+ def self.rename(hash, table)
697
+ renamed = {}
698
+ wire_spelled = {}
699
+ hash.each do |key, value|
700
+ name = key.to_s
701
+ wire = table.fetch(name, name)
702
+ if wire == name
703
+ wire_spelled[wire] = true
704
+ elsif wire_spelled.key?(wire)
705
+ next
706
+ end
707
+ renamed[wire] = value
708
+ end
709
+ renamed
710
+ end
711
+ private_class_method :rename
712
+ end
640
713
  end
@@ -109,6 +109,25 @@ module ClaudeAgentSDK
109
109
  config
110
110
  end
111
111
  end
112
+
113
+ private
114
+
115
+ # The CLI echoes a server's config back with its credentials (headers, a
116
+ # token in the url, args). A Hash config prints like a raw Hash config in
117
+ # ClaudeAgentOptions#mcp_servers; the typed proxy and SDK configs print
118
+ # as they are.
119
+ def inspect_attributes
120
+ super.map { |name, value| [name, name == 'config' ? inspect_config(value) : value] }
121
+ end
122
+
123
+ def inspect_config(config)
124
+ case config # not config.is_a?: a BasicObject has no such method
125
+ when Hash then inspect_mcp_server_config(config)
126
+ else config
127
+ end
128
+ rescue StandardError
129
+ '[FILTERED]'
130
+ end
112
131
  end
113
132
 
114
133
  # Response from get_mcp_status containing all server statuses
@@ -138,7 +157,8 @@ module ClaudeAgentSDK
138
157
  attr_accessor :command, :args, :env
139
158
  attr_reader :type
140
159
 
141
- inspect_filtered :env
160
+ # `args` carries flags such as `--api-key <key>`.
161
+ inspect_filtered :env, :args
142
162
 
143
163
  def initialize(attributes = {})
144
164
  super
@@ -173,6 +193,14 @@ module ClaudeAgentSDK
173
193
  result[:headers] = @headers if @headers
174
194
  result
175
195
  end
196
+
197
+ private
198
+
199
+ # The url can carry a token (userinfo, path or query): #inspect shows
200
+ # its scheme and host.
201
+ def inspect_attributes
202
+ super.map { |name, value| [name, name == 'url' ? inspect_filter_url(value) : value] }
203
+ end
176
204
  end
177
205
 
178
206
  class McpHttpServerConfig < Type
@@ -195,6 +223,14 @@ module ClaudeAgentSDK
195
223
  result[:headers] = @headers if @headers
196
224
  result
197
225
  end
226
+
227
+ private
228
+
229
+ # The url can carry a token (userinfo, path or query): #inspect shows
230
+ # its scheme and host.
231
+ def inspect_attributes
232
+ super.map { |name, value| [name, name == 'url' ? inspect_filter_url(value) : value] }
233
+ end
198
234
  end
199
235
 
200
236
  class McpSdkServerConfig < Type
@@ -192,7 +192,13 @@ module ClaudeAgentSDK
192
192
  :outcome # "success", "error", or "cancelled"
193
193
  end
194
194
 
195
- # Session state changed system message
195
+ # Session state changed system message.
196
+ #
197
+ # Reaches your code only when you opt in with
198
+ # `CLAUDE_CODE_EMIT_SESSION_STATE_EVENTS=1` in ClaudeAgentOptions#env. The
199
+ # SDK also asks the CLI for these frames on its own behalf (to tell when a
200
+ # query() run is over), but those arrive marked `sdk_host_only` and are
201
+ # dropped before the message stream.
196
202
  class SessionStateChangedMessage < SystemMessage
197
203
  attr_accessor :uuid, :session_id,
198
204
  :state # "idle", "running", or "requires_action"
@@ -158,7 +158,19 @@ module ClaudeAgentSDK
158
158
  end
159
159
  end
160
160
 
161
- # Sandbox settings for isolated command execution
161
+ # Sandbox settings for isolated command execution.
162
+ #
163
+ # +network+ and +filesystem+ take a SandboxNetworkConfig /
164
+ # SandboxFilesystemConfig or a Hash. A Hash may spell the fields of those
165
+ # classes as their attributes (+denied_domains+) or as the CLI does
166
+ # (+deniedDomains+), with Symbol or String keys; any other key is sent as
167
+ # written. The same holds for a Hash given as +sandbox:+ in place of a
168
+ # SandboxSettings.
169
+ #
170
+ # A snake_case key in such a Hash is sent under the CLI's name only when
171
+ # its value has the shape the CLI accepts there (an Array of Strings for
172
+ # +denied_domains+, +true+ or +false+ for +allow_local_binding+, ...).
173
+ # With any other value it is sent as written, and the CLI ignores it.
162
174
  class SandboxSettings < Type
163
175
  include Type::OptionValue
164
176
 
@@ -169,15 +181,15 @@ module ClaudeAgentSDK
169
181
  :ignore_violations, :enable_weaker_nested_sandbox,
170
182
  :enable_weaker_network_isolation, :ripgrep
171
183
 
172
- def to_h # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity -- one optional key per sandbox field
184
+ def to_h
173
185
  result = {}
174
186
  result[:enabled] = @enabled unless @enabled.nil?
175
187
  result[:failIfUnavailable] = @fail_if_unavailable unless @fail_if_unavailable.nil?
176
188
  result[:autoAllowBashIfSandboxed] = @auto_allow_bash_if_sandboxed unless @auto_allow_bash_if_sandboxed.nil?
177
189
  result[:excludedCommands] = @excluded_commands if @excluded_commands
178
190
  result[:allowUnsandboxedCommands] = @allow_unsandboxed_commands unless @allow_unsandboxed_commands.nil?
179
- result[:network] = @network.is_a?(SandboxNetworkConfig) ? @network.to_h : @network if @network
180
- result[:filesystem] = @filesystem.is_a?(SandboxFilesystemConfig) ? @filesystem.to_h : @filesystem if @filesystem
191
+ result[:network] = SandboxKeys.network(@network) if @network
192
+ result[:filesystem] = SandboxKeys.filesystem(@filesystem) if @filesystem
181
193
  result[:ignoreViolations] = @ignore_violations if @ignore_violations
182
194
  result[:enableWeakerNestedSandbox] = @enable_weaker_nested_sandbox unless @enable_weaker_nested_sandbox.nil?
183
195
  unless @enable_weaker_network_isolation.nil?
@@ -188,6 +200,176 @@ module ClaudeAgentSDK
188
200
  end
189
201
  end
190
202
 
203
+ # The spellings a sandbox Hash may use for a field of the three sandbox
204
+ # classes above, mapped to the key their #to_h emits, which is the key the
205
+ # CLI reads. A Hash given as the +sandbox:+ option, or as the network /
206
+ # filesystem of a SandboxSettings, stands for the typed value with the same
207
+ # fields: its keys may be Symbols or Strings, the attribute names
208
+ # (snake_case) or the wire keys (camelCase).
209
+ #
210
+ # Every attribute is listed, also the ones whose name is their wire key. A
211
+ # key that is not listed is sent as written, so a field of the CLI that the
212
+ # typed classes do not model (allowAppleEvents, network.strictAllowlist,
213
+ # filesystem.disabled, ...) still gets through, in the CLI's own spelling.
214
+ # spec/unit/sandbox_hash_keys_spec.rb walks the attributes of the three
215
+ # classes and fails when one of them and these tables disagree.
216
+ #
217
+ # @api private
218
+ module SandboxKeys
219
+ # SandboxSettings attribute => wire key.
220
+ TOP_LEVEL = {
221
+ 'enabled' => :enabled,
222
+ 'fail_if_unavailable' => :failIfUnavailable,
223
+ 'auto_allow_bash_if_sandboxed' => :autoAllowBashIfSandboxed,
224
+ 'excluded_commands' => :excludedCommands,
225
+ 'allow_unsandboxed_commands' => :allowUnsandboxedCommands,
226
+ 'network' => :network,
227
+ 'filesystem' => :filesystem,
228
+ 'ignore_violations' => :ignoreViolations,
229
+ 'enable_weaker_nested_sandbox' => :enableWeakerNestedSandbox,
230
+ 'enable_weaker_network_isolation' => :enableWeakerNetworkIsolation,
231
+ 'ripgrep' => :ripgrep
232
+ }.freeze
233
+
234
+ # SandboxNetworkConfig attribute => wire key.
235
+ NETWORK = {
236
+ 'allowed_domains' => :allowedDomains,
237
+ 'denied_domains' => :deniedDomains,
238
+ 'allow_managed_domains_only' => :allowManagedDomainsOnly,
239
+ 'allow_unix_sockets' => :allowUnixSockets,
240
+ 'allow_all_unix_sockets' => :allowAllUnixSockets,
241
+ 'allow_local_binding' => :allowLocalBinding,
242
+ 'allow_mach_lookup' => :allowMachLookup,
243
+ 'http_proxy_port' => :httpProxyPort,
244
+ 'socks_proxy_port' => :socksProxyPort
245
+ }.freeze
246
+
247
+ # SandboxFilesystemConfig attribute => wire key.
248
+ FILESYSTEM = {
249
+ 'allow_write' => :allowWrite,
250
+ 'deny_write' => :denyWrite,
251
+ 'deny_read' => :denyRead,
252
+ 'allow_read' => :allowRead,
253
+ 'allow_managed_read_paths_only' => :allowManagedReadPathsOnly
254
+ }.freeze
255
+
256
+ # Wire key => the kind of value CLI 2.1.287's sandbox schema accepts
257
+ # under it, for every wire key an attribute spells differently (read from
258
+ # the schema in the CLI binary and checked against the running CLI):
259
+ #
260
+ # :boolean true or false
261
+ # :strings an Array of Strings
262
+ # :mach_services an Array of Strings; a "*" only as the last character
263
+ # :port an Integer from 0 to 65535
264
+ # :string_lists a Hash whose values are Arrays of Strings
265
+ #
266
+ # One value outside its kind makes the CLI discard the whole --settings
267
+ # value, the sandbox and the permissions next to it. It lists the error
268
+ # in its get_settings response only, so the SDK is not told. A key in
269
+ # snake_case is one the CLI does not know and ignores, so it is renamed
270
+ # only when its value is of the kind listed here (see #rename). A kind
271
+ # that is too strict leaves a key without effect, as it was before the
272
+ # renaming existed; one that is too loose can cost a session its sandbox.
273
+ #
274
+ # :port is the strict side of what the CLI does. It takes any number it
275
+ # can read as a port, but an Integer too large for that (2**1024 and up,
276
+ # say 10**400) reaches it as a non-finite value and fails its schema.
277
+ SHAPES = {
278
+ boolean: %i[failIfUnavailable autoAllowBashIfSandboxed allowUnsandboxedCommands enableWeakerNestedSandbox
279
+ enableWeakerNetworkIsolation allowManagedDomainsOnly allowAllUnixSockets allowLocalBinding
280
+ allowManagedReadPathsOnly],
281
+ strings: %i[excludedCommands allowedDomains deniedDomains allowUnixSockets
282
+ allowWrite denyWrite denyRead allowRead],
283
+ mach_services: %i[allowMachLookup],
284
+ port: %i[httpProxyPort socksProxyPort],
285
+ string_lists: %i[ignoreViolations]
286
+ }.flat_map { |kind, wire_keys| wire_keys.map { |wire| [wire, kind] } }.to_h.freeze
287
+
288
+ # A Hash +sandbox:+ as the CLI reads it: its known keys under their wire
289
+ # keys, at the top level and inside its network / filesystem, which may
290
+ # each be a Hash or the typed config. Other values are not rewritten:
291
+ # ignore_violations and ripgrep hold structures of the CLI's own.
292
+ def self.normalize(sandbox)
293
+ normalized = rename(sandbox, TOP_LEVEL)
294
+ normalized[:network] = network(normalized[:network]) if normalized.key?(:network)
295
+ normalized[:filesystem] = filesystem(normalized[:filesystem]) if normalized.key?(:filesystem)
296
+ normalized
297
+ end
298
+
299
+ # A network section as the CLI reads it: the typed config's #to_h, or a
300
+ # Hash with its known keys renamed. Any other value is returned as it is.
301
+ def self.network(section)
302
+ case section
303
+ when SandboxNetworkConfig then section.to_h
304
+ when Hash then rename(section, NETWORK)
305
+ else section
306
+ end
307
+ end
308
+
309
+ # A filesystem section as the CLI reads it (see .network).
310
+ def self.filesystem(section)
311
+ case section
312
+ when SandboxFilesystemConfig then section.to_h
313
+ when Hash then rename(section, FILESYSTEM)
314
+ else section
315
+ end
316
+ end
317
+
318
+ # A known key ends up as the one Symbol #to_h emits, so the spellings of
319
+ # a field (attribute name or wire key, Symbol or String) cannot reach
320
+ # JSON.generate side by side: json 3.x raises on a key given as a Symbol
321
+ # and as a String, 2.x writes it twice. When a Hash carries both
322
+ # spellings of a field the wire spelling wins, whichever comes first;
323
+ # between two keys in the same spelling the later one does.
324
+ #
325
+ # A known key holding nil is left out, as #to_h leaves out a nil
326
+ # attribute. The CLI rejects null under every one of these keys, and
327
+ # when it does it drops the whole --settings value, sandbox included.
328
+ #
329
+ # A key in snake_case is renamed only when the CLI accepts its value
330
+ # under the wire key (SHAPES). Otherwise it is sent as written, which
331
+ # the CLI ignores, as it ignored every snake_case key before: renaming
332
+ # must not be what makes the CLI drop the settings. A key the caller
333
+ # wrote in wire spelling is not looked at; it goes out as it always did.
334
+ def self.rename(hash, table)
335
+ renamed = {}
336
+ wire_spelled = {}
337
+ hash.each do |key, value|
338
+ name = key.to_s
339
+ wire = table[name] || table.each_value.find { |candidate| candidate.name == name }
340
+ next if wire && value.nil?
341
+
342
+ if wire && wire.name == name
343
+ wire_spelled[wire] = true
344
+ renamed[wire] = value
345
+ elsif wire && well_shaped?(wire, value)
346
+ renamed[wire] = value unless wire_spelled.key?(wire)
347
+ else
348
+ renamed[key] = value
349
+ end
350
+ end
351
+ renamed
352
+ end
353
+
354
+ # Whether the CLI accepts +value+ under +wire+. A key without a kind is
355
+ # answered false, the side that cannot cost the sandbox.
356
+ def self.well_shaped?(wire, value)
357
+ case SHAPES[wire]
358
+ when :boolean then [true, false].include?(value)
359
+ when :strings then strings?(value)
360
+ when :mach_services then strings?(value) && value.none? { |name| name.delete_suffix('*').include?('*') }
361
+ when :port then value.is_a?(Integer) && value.between?(0, 65_535)
362
+ when :string_lists then value.is_a?(Hash) && value.each_value.all? { |list| strings?(list) }
363
+ else false
364
+ end
365
+ end
366
+
367
+ def self.strings?(value)
368
+ value.is_a?(Array) && value.all?(String)
369
+ end
370
+ private_class_method :rename, :well_shaped?, :strings?
371
+ end
372
+
191
373
  # API-side task budget in tokens.
192
374
  # When set, the model is made aware of its remaining token budget so it can
193
375
  # pace tool use and wrap up before the limit.