From b724e0faf2d15d349de62e9ab62706675892a6a6 Mon Sep 17 00:00:00 2001 From: ya-luotao Date: Sat, 3 Oct 2026 04:41:35 +0800 Subject: [PATCH 1/9] Send a sandbox Hash under the keys the CLI reads, in any spelling The sandbox: option takes a typed SandboxSettings or a Hash, and a SandboxSettings takes a typed or a Hash network / filesystem. The typed classes have snake_case attributes and write the camelCase keys the CLI reads; a Hash was forwarded as written. The CLI's sandbox schema keeps unknown keys at the top level and strips them below it, so a Hash in Ruby spelling was accepted without an error and not applied: sandbox: { enabled: true, auto_allow_bash_if_sandboxed: false, network: { denied_domains: ['evil.example'] }, filesystem: { deny_read: ['/private/etc/ssh'] } } came back from the CLI's get_settings as {"enabled":true,"network":{}, "filesystem":{}}: no deny rule, and autoAllowBashIfSandboxed left at its default. SandboxSettings.new(network: { denied_domains: [...] }) lost its rules the same way, although a misspelled key of SandboxSettings itself raises. A Hash now stands for the typed value with the same fields. The vocabulary is a table kept next to the three sandbox classes (SandboxKeys, @api private): every attribute and the key its #to_h emits, pinned by a spec that walks the classes. At the top level and inside a Hash network / filesystem, a known field may be written as its attribute name or its wire key, as a Symbol or a String, and is sent under the wire key. This applies to a Hash sandbox: and to a typed SandboxSettings holding Hashes (SandboxSettings#to_h). Any other key is sent as written, so fields the classes do not model (allowAppleEvents, network.strictAllowlist, filesystem.disabled) still get through, and the values of ignore_violations and ripgrep are not touched. The caller's Hash is never modified. When one Hash spells a field both ways the wire spelling is sent, whichever comes first: that is the one the CLI honored until now. A key given as a Symbol and as a String is written once, the later one (json 3.x raised JSON::GeneratorError on such a Hash; 2.x wrote it twice). One rule concerns values. A known key holding nil is left out, as #to_h leaves out a nil attribute. CLI 2.1.287 rejects null under every one of these keys ("sandbox.excludedCommands: Expected array, but received null") and then drops the whole --settings value, sandbox included. Renaming alone would have turned { enabled: true, excluded_commands: nil }, whose nil the CLI ignored, into a session without any sandbox. User-visible: sandbox keys written in snake_case now take effect, so a Hash that carried them is validated and applied by the CLI like its camelCase form. { enabled: true, excludedCommands: nil }, which made the CLI discard the settings, is now sent without that key. Exploration finding: C-P2 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01S9UuLogZdvUXMxTuufTemS --- lib/claude_agent_sdk/command_builder.rb | 15 +- lib/claude_agent_sdk/types/option_values.rb | 130 +++++- spec/unit/sandbox_hash_keys_spec.rb | 454 ++++++++++++++++++++ 3 files changed, 594 insertions(+), 5 deletions(-) create mode 100644 spec/unit/sandbox_hash_keys_spec.rb diff --git a/lib/claude_agent_sdk/command_builder.rb b/lib/claude_agent_sdk/command_builder.rb index c0882c6c..898bb425 100644 --- a/lib/claude_agent_sdk/command_builder.rb +++ b/lib/claude_agent_sdk/command_builder.rb @@ -327,12 +327,25 @@ def append_settings(cmd) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticCo # read from JSON spell that key as a String; left next to the Symbol # key below it would be written twice (json 3.x raises on that). settings_hash = settings_hash.reject { |key, _| key.to_s == 'sandbox' } - settings_hash[:sandbox] = @options.sandbox.is_a?(SandboxSettings) ? @options.sandbox.to_h : @options.sandbox + settings_hash[:sandbox] = sandbox_section(@options.sandbox) end cmd.push('--settings', JSON.generate(settings_hash)) if !settings_is_path && !settings_hash.empty? end + # The sandbox section as the CLI reads it. A Hash stands for the + # SandboxSettings with the same fields: the CLI only knows the camelCase + # keys that class writes, and it ignores the others without an error, so + # a Hash in Ruby spelling (deny_read, denied_domains) is renamed like the + # typed value would be (SandboxKeys). Booleans go out as they are. + def sandbox_section(sandbox) + case sandbox + when SandboxSettings then sandbox.to_h + when Hash then SandboxKeys.normalize(sandbox) + else sandbox + end + end + def append_budget(cmd) cmd.push('--max-budget-usd', @options.max_budget_usd.to_s) if @options.max_budget_usd diff --git a/lib/claude_agent_sdk/types/option_values.rb b/lib/claude_agent_sdk/types/option_values.rb index e0fd8422..0ba227e7 100644 --- a/lib/claude_agent_sdk/types/option_values.rb +++ b/lib/claude_agent_sdk/types/option_values.rb @@ -158,7 +158,14 @@ def to_h end end - # Sandbox settings for isolated command execution + # Sandbox settings for isolated command execution. + # + # +network+ and +filesystem+ take a SandboxNetworkConfig / + # SandboxFilesystemConfig or a Hash. A Hash may spell the fields of those + # classes as their attributes (+denied_domains+) or as the CLI does + # (+deniedDomains+), with Symbol or String keys; any other key is sent as + # written. The same holds for a Hash given as +sandbox:+ in place of a + # SandboxSettings. class SandboxSettings < Type include Type::OptionValue @@ -169,15 +176,15 @@ class SandboxSettings < Type :ignore_violations, :enable_weaker_nested_sandbox, :enable_weaker_network_isolation, :ripgrep - def to_h # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity -- one optional key per sandbox field + def to_h result = {} result[:enabled] = @enabled unless @enabled.nil? result[:failIfUnavailable] = @fail_if_unavailable unless @fail_if_unavailable.nil? result[:autoAllowBashIfSandboxed] = @auto_allow_bash_if_sandboxed unless @auto_allow_bash_if_sandboxed.nil? result[:excludedCommands] = @excluded_commands if @excluded_commands result[:allowUnsandboxedCommands] = @allow_unsandboxed_commands unless @allow_unsandboxed_commands.nil? - result[:network] = @network.is_a?(SandboxNetworkConfig) ? @network.to_h : @network if @network - result[:filesystem] = @filesystem.is_a?(SandboxFilesystemConfig) ? @filesystem.to_h : @filesystem if @filesystem + result[:network] = SandboxKeys.network(@network) if @network + result[:filesystem] = SandboxKeys.filesystem(@filesystem) if @filesystem result[:ignoreViolations] = @ignore_violations if @ignore_violations result[:enableWeakerNestedSandbox] = @enable_weaker_nested_sandbox unless @enable_weaker_nested_sandbox.nil? unless @enable_weaker_network_isolation.nil? @@ -188,6 +195,121 @@ def to_h # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComple end end + # The spellings a sandbox Hash may use for a field of the three sandbox + # classes above, mapped to the key their #to_h emits, which is the key the + # CLI reads. A Hash given as the +sandbox:+ option, or as the network / + # filesystem of a SandboxSettings, stands for the typed value with the same + # fields: its keys may be Symbols or Strings, the attribute names + # (snake_case) or the wire keys (camelCase). + # + # Every attribute is listed, also the ones whose name is their wire key. A + # key that is not listed is sent as written, so a field of the CLI that the + # typed classes do not model (allowAppleEvents, network.strictAllowlist, + # filesystem.disabled, ...) still gets through, in the CLI's own spelling. + # spec/unit/sandbox_hash_keys_spec.rb walks the attributes of the three + # classes and fails when one of them and these tables disagree. + # + # @api private + module SandboxKeys + # SandboxSettings attribute => wire key. + TOP_LEVEL = { + 'enabled' => :enabled, + 'fail_if_unavailable' => :failIfUnavailable, + 'auto_allow_bash_if_sandboxed' => :autoAllowBashIfSandboxed, + 'excluded_commands' => :excludedCommands, + 'allow_unsandboxed_commands' => :allowUnsandboxedCommands, + 'network' => :network, + 'filesystem' => :filesystem, + 'ignore_violations' => :ignoreViolations, + 'enable_weaker_nested_sandbox' => :enableWeakerNestedSandbox, + 'enable_weaker_network_isolation' => :enableWeakerNetworkIsolation, + 'ripgrep' => :ripgrep + }.freeze + + # SandboxNetworkConfig attribute => wire key. + NETWORK = { + 'allowed_domains' => :allowedDomains, + 'denied_domains' => :deniedDomains, + 'allow_managed_domains_only' => :allowManagedDomainsOnly, + 'allow_unix_sockets' => :allowUnixSockets, + 'allow_all_unix_sockets' => :allowAllUnixSockets, + 'allow_local_binding' => :allowLocalBinding, + 'allow_mach_lookup' => :allowMachLookup, + 'http_proxy_port' => :httpProxyPort, + 'socks_proxy_port' => :socksProxyPort + }.freeze + + # SandboxFilesystemConfig attribute => wire key. + FILESYSTEM = { + 'allow_write' => :allowWrite, + 'deny_write' => :denyWrite, + 'deny_read' => :denyRead, + 'allow_read' => :allowRead, + 'allow_managed_read_paths_only' => :allowManagedReadPathsOnly + }.freeze + + # A Hash +sandbox:+ as the CLI reads it: its known keys under their wire + # keys, at the top level and inside a Hash network / filesystem. Values + # are not rewritten: ignore_violations and ripgrep hold structures of + # the CLI's own. + def self.normalize(sandbox) + normalized = rename(sandbox, TOP_LEVEL) + { network: NETWORK, filesystem: FILESYSTEM }.each do |section, table| + normalized[section] = rename(normalized[section], table) if normalized[section].is_a?(Hash) + end + normalized + end + + # The network of a SandboxSettings as the CLI reads it. + def self.network(section) + case section + when SandboxNetworkConfig then section.to_h + when Hash then rename(section, NETWORK) + else section + end + end + + # The filesystem of a SandboxSettings as the CLI reads it. + def self.filesystem(section) + case section + when SandboxFilesystemConfig then section.to_h + when Hash then rename(section, FILESYSTEM) + else section + end + end + + # A known key ends up as the one Symbol #to_h emits, so the spellings of + # a field (attribute name or wire key, Symbol or String) cannot reach + # JSON.generate side by side: json 3.x raises on a key given as a Symbol + # and as a String, 2.x writes it twice. When a Hash carries both + # spellings of a field the wire spelling wins, whichever comes first; + # between two keys in the same spelling the later one does. + # + # A known key holding nil is left out, as #to_h leaves out a nil + # attribute. The CLI rejects null under every one of these keys, and + # when it does it drops the whole --settings value, sandbox included. + def self.rename(hash, table) + renamed = {} + wire_spelled = {} + hash.each do |key, value| + name = key.to_s + wire = table[name] || table.each_value.find { |candidate| candidate.name == name } + if wire.nil? + renamed[key] = value + elsif value.nil? + next + elsif wire.name == name + wire_spelled[wire] = true + renamed[wire] = value + elsif !wire_spelled.key?(wire) + renamed[wire] = value + end + end + renamed + end + private_class_method :rename + end + # API-side task budget in tokens. # When set, the model is made aware of its remaining token budget so it can # pace tool use and wrap up before the limit. diff --git a/spec/unit/sandbox_hash_keys_spec.rb b/spec/unit/sandbox_hash_keys_spec.rb new file mode 100644 index 00000000..9d4e7cad --- /dev/null +++ b/spec/unit/sandbox_hash_keys_spec.rb @@ -0,0 +1,454 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'json' + +# `sandbox:` takes a typed SandboxSettings or a Hash, and a SandboxSettings +# takes a typed or a Hash `network` / `filesystem`. The typed classes have +# snake_case attributes and write the camelCase keys the CLI reads; a Hash +# used to be forwarded as written. The CLI's sandbox schema keeps unknown keys +# at the top level and strips them below it, so `deny_read`, `denied_domains` +# or `auto_allow_bash_if_sandboxed` were accepted without an error and never +# applied: the session ran with a weaker sandbox than the one written. +# +# The examples assert on the --settings argument the CLI is started with (and +# on SandboxSettings#to_h, the public form of the same section). +RSpec.describe 'sandbox settings written as a Hash' do + # The --settings argument built for this `sandbox:` option. + def settings_argument(sandbox, **options) + cmd = ClaudeAgentSDK::CommandBuilder.new( + '/usr/bin/claude', ClaudeAgentSDK::ClaudeAgentOptions.new(sandbox: sandbox, **options) + ).build + cmd[cmd.index('--settings') + 1] + end + + # Its sandbox section, as the CLI's JSON parser reads it. + def sandbox_section(sandbox) + JSON.parse(settings_argument(sandbox)).fetch('sandbox') + end + + # What JSON makes of a Ruby value (Symbol keys become Strings). + def on_the_wire(value) + JSON.parse(JSON.generate(value)) + end + + describe 'the same sandbox, written three ways' do + typed = ClaudeAgentSDK::SandboxSettings.new( + enabled: true, auto_allow_bash_if_sandboxed: false, excluded_commands: ['docker'], + network: ClaudeAgentSDK::SandboxNetworkConfig.new(denied_domains: ['evil.example']), + filesystem: ClaudeAgentSDK::SandboxFilesystemConfig.new(deny_read: ['/private/etc/ssh']) + ) + snake_case_hash = { + enabled: true, auto_allow_bash_if_sandboxed: false, excluded_commands: ['docker'], + network: { denied_domains: ['evil.example'] }, + filesystem: { deny_read: ['/private/etc/ssh'] } + } + typed_outer_hash_inner = ClaudeAgentSDK::SandboxSettings.new( + enabled: true, auto_allow_bash_if_sandboxed: false, excluded_commands: ['docker'], + network: { denied_domains: ['evil.example'] }, + filesystem: { deny_read: ['/private/etc/ssh'] } + ) + + it 'reaches the CLI under its own keys when every level is typed' do + expect(settings_argument(typed)).to eq( + '{"sandbox":{"enabled":true,"autoAllowBashIfSandboxed":false,"excludedCommands":["docker"],' \ + '"network":{"deniedDomains":["evil.example"]},"filesystem":{"denyRead":["/private/etc/ssh"]}}}' + ) + end + + it 'builds the identical --settings value from a snake_case Hash' do + expect(settings_argument(snake_case_hash)).to eq(settings_argument(typed)) + end + + it 'builds the identical --settings value from a typed SandboxSettings holding snake_case Hashes' do + expect(settings_argument(typed_outer_hash_inner)).to eq(settings_argument(typed)) + end + + it 'gives SandboxSettings#to_h the keys a typed network and filesystem give it' do + expect(typed_outer_hash_inner.to_h).to eq(typed.to_h) + end + + it 'builds it from a snake_case Hash with String keys, such as one read from YAML or JSON' do + expect(settings_argument(JSON.parse(JSON.generate(snake_case_hash)))).to eq(settings_argument(typed)) + end + + it 'still merges into settings that carry their own sandbox section' do + settings = '{"permissions":{"deny":["WebFetch"]},"sandbox":{"enabled":false}}' + + expect(JSON.parse(settings_argument(snake_case_hash, settings: settings))).to eq( + 'permissions' => { 'deny' => ['WebFetch'] }, 'sandbox' => on_the_wire(typed.to_h) + ) + end + end + + # Every field of the three sandbox classes: attribute => [wire key, value]. + # The wire keys are spelled out here, not derived from the SDK, so a renamed + # key on either side fails the examples below. + network_fields = { + allowed_domains: ['allowedDomains', %w[rubygems.org *.github.com]], + denied_domains: ['deniedDomains', %w[evil.example]], + allow_managed_domains_only: ['allowManagedDomainsOnly', false], + allow_unix_sockets: ['allowUnixSockets', %w[/var/run/docker.sock]], + allow_all_unix_sockets: ['allowAllUnixSockets', false], + allow_local_binding: ['allowLocalBinding', true], + allow_mach_lookup: ['allowMachLookup', %w[com.apple.coreservices.launchservicesd]], + http_proxy_port: ['httpProxyPort', 8080], + socks_proxy_port: ['socksProxyPort', 1080] + }.freeze + + filesystem_fields = { + allow_write: ['allowWrite', %w[/work/app/tmp]], + deny_write: ['denyWrite', %w[/work/app/config]], + deny_read: ['denyRead', %w[/private/etc/ssh ~/.aws]], + allow_read: ['allowRead', %w[/work/app]], + allow_managed_read_paths_only: ['allowManagedReadPathsOnly', false] + }.freeze + + # `network` and `filesystem` are attributes too; each form below fills them + # in with the nested section in the spelling it is about. + top_level_fields = { + enabled: ['enabled', true], + fail_if_unavailable: ['failIfUnavailable', true], + auto_allow_bash_if_sandboxed: ['autoAllowBashIfSandboxed', false], + excluded_commands: ['excludedCommands', %w[docker]], + allow_unsandboxed_commands: ['allowUnsandboxedCommands', false], + network: ['network', nil], + filesystem: ['filesystem', nil], + ignore_violations: ['ignoreViolations', { 'file' => ['/tmp/*'], 'network' => ['localhost'] }], + enable_weaker_nested_sandbox: ['enableWeakerNestedSandbox', false], + enable_weaker_network_isolation: ['enableWeakerNetworkIsolation', false], + ripgrep: ['ripgrep', { 'command' => '/usr/bin/rg', 'args' => ['--hidden'] }] + }.freeze + + # The spellings a Hash may use for those fields. + spellings = { + 'snake_case Symbol keys' => ->(fields) { fields.transform_values(&:last) }, + 'snake_case String keys' => ->(fields) { fields.to_h { |name, (_wire, value)| [name.to_s, value] } }, + 'camelCase Symbol keys' => ->(fields) { fields.to_h { |_name, (wire, value)| [wire.to_sym, value] } }, + 'camelCase String keys' => ->(fields) { fields.to_h { |_name, (wire, value)| [wire, value] } } + }.freeze + attributes_of = spellings.fetch('snake_case Symbol keys') + wire_form_of = spellings.fetch('camelCase String keys') + + # The top-level fields around one network and one filesystem section. + around_sections = lambda do |network, filesystem| + top_level_fields.merge(network: ['network', network], filesystem: ['filesystem', filesystem]) + end + + describe 'every field of every sandbox class' do + # The section the CLI must receive, whichever way it was written. + wire = wire_form_of.call( + around_sections.call(wire_form_of.call(network_fields), wire_form_of.call(filesystem_fields)) + ) + + it 'has a field for every attribute' do + expect( + 'SandboxSettings' => top_level_fields.keys.map(&:to_s).sort, + 'SandboxNetworkConfig' => network_fields.keys.map(&:to_s).sort, + 'SandboxFilesystemConfig' => filesystem_fields.keys.map(&:to_s).sort + ).to eq( + 'SandboxSettings' => ClaudeAgentSDK::SandboxSettings.attribute_names, + 'SandboxNetworkConfig' => ClaudeAgentSDK::SandboxNetworkConfig.attribute_names, + 'SandboxFilesystemConfig' => ClaudeAgentSDK::SandboxFilesystemConfig.attribute_names + ) + end + + it 'is written under its wire key when every level is typed' do + typed = ClaudeAgentSDK::SandboxSettings.new( + attributes_of.call( + around_sections.call( + ClaudeAgentSDK::SandboxNetworkConfig.new(attributes_of.call(network_fields)), + ClaudeAgentSDK::SandboxFilesystemConfig.new(attributes_of.call(filesystem_fields)) + ) + ) + ) + + expect(sandbox_section(typed)).to eq(wire) + end + + spellings.each do |inner_spelling, inner| + it "is written the same way from a typed SandboxSettings holding Hashes with #{inner_spelling}" do + sandbox = ClaudeAgentSDK::SandboxSettings.new( + attributes_of.call(around_sections.call(inner.call(network_fields), inner.call(filesystem_fields))) + ) + + expect(sandbox_section(sandbox)).to eq(wire) + end + + spellings.each do |outer_spelling, outer| + it "is written the same way from a Hash with #{outer_spelling} holding Hashes with #{inner_spelling}" do + sandbox = outer.call(around_sections.call(inner.call(network_fields), inner.call(filesystem_fields))) + + expect(sandbox_section(sandbox)).to eq(wire) + end + end + end + end + + # The vocabulary is a hand-written table next to the typed classes. Walk + # every attribute of every class so the two cannot drift: a new attribute, + # or a renamed wire key in a #to_h, fails here until the table follows. + describe 'ClaudeAgentSDK::SandboxKeys' do + { + 'TOP_LEVEL' => ['SandboxSettings', around_sections.call({ deniedDomains: %w[evil.example] }, { denyRead: %w[~/.aws] })], + 'NETWORK' => ['SandboxNetworkConfig', network_fields], + 'FILESYSTEM' => ['SandboxFilesystemConfig', filesystem_fields] + }.each do |table_name, (class_name, fields)| + describe table_name do + let(:table) { ClaudeAgentSDK::SandboxKeys.const_get(table_name) } + let(:klass) { ClaudeAgentSDK.const_get(class_name) } + + it "maps every #{class_name} attribute to the key its #to_h emits for it" do + emitted = klass.attribute_names.to_h do |name| + [name, klass.new(name => fields.fetch(name.to_sym).last).to_h.keys] + end + + expect(table.transform_values { |wire_key| [wire_key] }).to eq(emitted) + end + + it 'agrees with the wire keys this file spells out' do + expect(table).to eq(fields.to_h { |name, (wire_key, _value)| [name.to_s, wire_key.to_sym] }) + end + end + end + end + + describe 'a key the typed classes do not model' do + # allowAppleEvents, network.strictAllowlist and filesystem.disabled are + # fields of CLI 2.1.287's sandbox schema that no typed class has yet; a + # Hash is the only way to send them. + it 'is sent as written, at every level' do + sandbox = { + enabled: true, + allowAppleEvents: true, + future_field: { inner_key: 1 }, + 'x-vendor' => nil, + network: { denied_domains: ['evil.example'], strictAllowlist: true, future_field: 1 }, + filesystem: { deny_read: ['~/.aws'], 'disabled' => false, 'future_field' => [2] } + } + + expect(sandbox_section(sandbox)).to eq( + 'enabled' => true, + 'allowAppleEvents' => true, + 'future_field' => { 'inner_key' => 1 }, + 'x-vendor' => nil, + 'network' => { 'deniedDomains' => ['evil.example'], 'strictAllowlist' => true, 'future_field' => 1 }, + 'filesystem' => { 'denyRead' => ['~/.aws'], 'disabled' => false, 'future_field' => [2] } + ) + end + + it 'is sent as written inside a typed SandboxSettings too' do + sandbox = ClaudeAgentSDK::SandboxSettings.new( + enabled: true, + network: { denied_domains: ['evil.example'], strictAllowlist: true }, + filesystem: { 'deny_read' => ['~/.aws'], 'disabled' => false } + ) + + expect(sandbox_section(sandbox)).to eq( + 'enabled' => true, + 'network' => { 'deniedDomains' => ['evil.example'], 'strictAllowlist' => true }, + 'filesystem' => { 'denyRead' => ['~/.aws'], 'disabled' => false } + ) + expect(sandbox.to_h).to eq( + enabled: true, + network: { deniedDomains: ['evil.example'], strictAllowlist: true }, + filesystem: { denyRead: ['~/.aws'], 'disabled' => false } + ) + end + + it 'includes a name the SDK only knows at another level' do + sandbox = { + enabled: true, + deny_read: ['~/.aws'], + network: { excluded_commands: ['docker'], deny_read: ['~/.aws'] }, + filesystem: { denied_domains: ['evil.example'], auto_allow_bash_if_sandboxed: false } + } + + expect(sandbox_section(sandbox)).to eq( + 'enabled' => true, + 'deny_read' => ['~/.aws'], + 'network' => { 'excluded_commands' => ['docker'], 'deny_read' => ['~/.aws'] }, + 'filesystem' => { 'denied_domains' => ['evil.example'], 'auto_allow_bash_if_sandboxed' => false } + ) + end + end + + describe 'the value of a field' do + # ignore_violations and ripgrep hold Hashes of their own, and no typed + # class models what is inside them: a key in there that happens to be + # spelled like a sandbox field is not one. + it 'is not rewritten' do + inner = { command: '/usr/bin/rg', args: ['--hidden'], deny_read: 'kept', 'allowed_domains' => 'kept' } + sandbox = { + enabled: true, + ripgrep: inner, + ignore_violations: { 'excluded_commands' => ['/tmp/*'], network: { denied_domains: ['kept'] } }, + network: { allow_unix_sockets: ['/var/run/docker.sock'], allow_mach_lookup: [{ deny_read: 'kept' }] } + } + + expect(sandbox_section(sandbox)).to eq( + 'enabled' => true, + 'ripgrep' => on_the_wire(inner), + 'ignoreViolations' => { 'excluded_commands' => ['/tmp/*'], 'network' => { 'denied_domains' => ['kept'] } }, + 'network' => { 'allowUnixSockets' => ['/var/run/docker.sock'], 'allowMachLookup' => [{ 'deny_read' => 'kept' }] } + ) + end + end + + describe 'a Hash that spells one field both ways' do + it 'sends the camelCase spelling at the top level, whichever comes first' do + sandboxes = [ + { enabled: true, auto_allow_bash_if_sandboxed: true, autoAllowBashIfSandboxed: false }, + { enabled: true, autoAllowBashIfSandboxed: false, auto_allow_bash_if_sandboxed: true }, + { 'enabled' => true, 'auto_allow_bash_if_sandboxed' => true, 'autoAllowBashIfSandboxed' => false }, + { enabled: true, 'autoAllowBashIfSandboxed' => false, auto_allow_bash_if_sandboxed: true } + ] + + expect(sandboxes.map { |sandbox| sandbox_section(sandbox) }) + .to eq([{ 'enabled' => true, 'autoAllowBashIfSandboxed' => false }] * 4) + end + + it 'sends the camelCase spelling inside network and filesystem, whichever comes first' do + sandboxes = [ + { network: { denied_domains: [], deniedDomains: ['evil.example'] }, + filesystem: { denyRead: ['/private/etc/ssh', '~/.aws'], deny_read: [] } }, + { 'network' => { 'deniedDomains' => ['evil.example'], 'denied_domains' => [] }, + 'filesystem' => { 'deny_read' => [], 'denyRead' => ['/private/etc/ssh', '~/.aws'] } }, + ClaudeAgentSDK::SandboxSettings.new( + network: { deniedDomains: ['evil.example'], denied_domains: [] }, + filesystem: { deny_read: [], denyRead: ['/private/etc/ssh', '~/.aws'] } + ) + ] + expected = { + 'network' => { 'deniedDomains' => ['evil.example'] }, + 'filesystem' => { 'denyRead' => ['/private/etc/ssh', '~/.aws'] } + } + + expect(sandboxes.map { |sandbox| sandbox_section(sandbox) }).to eq([expected] * 3) + end + + it 'writes the field once' do + argument = settings_argument( + { excluded_commands: ['git'], excludedCommands: ['docker'], + network: { deniedDomains: ['evil.example'], denied_domains: ['other.example'] } } + ) + + expect(argument.scan('"excludedCommands"').size).to eq(1) + expect(argument.scan('"deniedDomains"').size).to eq(1) + expect(argument).not_to include('excluded_commands', 'denied_domains') + end + + # json 3.x raises on a Hash that carries one key as a Symbol and as a + # String; json 2.x writes it twice. + it 'writes a key once when a Symbol and a String spell it, taking the later one' do + argument = settings_argument( + { enabled: false, 'enabled' => true, + 'network' => { 'deniedDomains' => ['other.example'] }, + network: { deniedDomains: [], 'deniedDomains' => ['evil.example'] } } + ) + + expect(JSON.parse(argument).fetch('sandbox')).to eq( + 'enabled' => true, 'network' => { 'deniedDomains' => ['evil.example'] } + ) + expect(%w[enabled network deniedDomains].map { |key| argument.scan("\"#{key}\"").size }).to eq([1, 1, 1]) + end + + it 'takes the later one of a snake_case Symbol and a snake_case String too' do + sandbox = { + 'excluded_commands' => ['git'], excluded_commands: ['docker'], + filesystem: { deny_read: [], 'deny_read' => ['~/.aws'] } + } + + expect(sandbox_section(sandbox)).to eq('excludedCommands' => ['docker'], 'filesystem' => { 'denyRead' => ['~/.aws'] }) + end + end + + # The typed classes leave a nil attribute out of #to_h. A Hash stands for + # the typed value with the same fields, so a known key holding nil is left + # out as well, in either spelling. It matters: CLI 2.1.287 rejects null + # under every one of these keys ("sandbox.excludedCommands: Expected array, + # but received null") and then drops the whole --settings value, sandbox + # included. Written in snake_case the same key used to be one the CLI did + # not know and ignored. + describe 'a nil value under a known key' do + typed = ClaudeAgentSDK::SandboxSettings.new( + enabled: true, excluded_commands: nil, auto_allow_bash_if_sandboxed: nil, filesystem: nil, + network: ClaudeAgentSDK::SandboxNetworkConfig.new(denied_domains: ['evil.example'], allowed_domains: nil) + ) + expected = { 'enabled' => true, 'network' => { 'deniedDomains' => ['evil.example'] } } + + it 'is left out by the typed classes' do + expect(sandbox_section(typed)).to eq(expected) + end + + { + 'snake_case keys' => { + enabled: true, excluded_commands: nil, auto_allow_bash_if_sandboxed: nil, filesystem: nil, + network: { denied_domains: ['evil.example'], allowed_domains: nil } + }, + 'camelCase keys' => { + enabled: true, excludedCommands: nil, autoAllowBashIfSandboxed: nil, filesystem: nil, + network: { deniedDomains: ['evil.example'], allowedDomains: nil } + }, + 'String keys' => { + 'enabled' => true, 'excluded_commands' => nil, 'autoAllowBashIfSandboxed' => nil, 'filesystem' => nil, + 'network' => { 'denied_domains' => ['evil.example'], 'allowedDomains' => nil } + }, + 'a typed SandboxSettings holding a Hash' => ClaudeAgentSDK::SandboxSettings.new( + enabled: true, network: { denied_domains: ['evil.example'], allowed_domains: nil, 'allowLocalBinding' => nil } + ) + }.each do |form, sandbox| + it "is left out of a Hash with #{form}" do + expect(sandbox_section(sandbox)).to eq(expected) + end + end + + it 'does not stand in for a value the other spelling carries' do + sandbox = { filesystem: { deny_read: ['~/.aws'], denyRead: nil }, excludedCommands: nil, excluded_commands: ['docker'] } + + expect(sandbox_section(sandbox)).to eq('filesystem' => { 'denyRead' => ['~/.aws'] }, 'excludedCommands' => ['docker']) + end + + it 'keeps false, which is a value' do + sandbox = { enabled: false, auto_allow_bash_if_sandboxed: false, network: { allow_local_binding: false } } + + expect(sandbox_section(sandbox)).to eq( + 'enabled' => false, 'autoAllowBashIfSandboxed' => false, 'network' => { 'allowLocalBinding' => false } + ) + end + end + + describe 'what the caller did not write' do + it 'is not added: an enabled sandbox is sent without failIfUnavailable' do + expect([{ enabled: true }, { 'enabled' => true }, ClaudeAgentSDK::SandboxSettings.new(enabled: true)] + .map { |sandbox| sandbox_section(sandbox) }).to eq([{ 'enabled' => true }] * 3) + end + + it 'stays an empty section for an empty Hash' do + expect(settings_argument({})).to eq('{"sandbox":{}}') + end + end + + describe 'the Hash the caller passed' do + it 'is left untouched, so a frozen constant can be used for every session' do + sandbox = { + enabled: true, excluded_commands: ['docker'].freeze, + network: { denied_domains: ['evil.example'].freeze }.freeze + }.freeze + expected = { 'enabled' => true, 'excludedCommands' => ['docker'], 'network' => { 'deniedDomains' => ['evil.example'] } } + + expect([sandbox_section(sandbox), sandbox_section(sandbox)]).to eq([expected, expected]) + expect(sandbox).to eq( + enabled: true, excluded_commands: ['docker'], network: { denied_domains: ['evil.example'] } + ) + end + + it 'is not modified when a typed SandboxSettings holds it' do + network = { denied_domains: ['evil.example'], deniedDomains: ['other.example'], allowed_domains: nil }.freeze + sandbox = ClaudeAgentSDK::SandboxSettings.new(enabled: true, network: network) + + expect(sandbox.to_h).to eq(enabled: true, network: { deniedDomains: ['other.example'] }) + expect(network).to eq(denied_domains: ['evil.example'], deniedDomains: ['other.example'], allowed_domains: nil) + end + end +end From 774081a348755da658591cac08863219d42f57e2 Mon Sep 17 00:00:00 2001 From: ya-luotao Date: Sat, 3 Oct 2026 04:46:09 +0800 Subject: [PATCH 2/9] Honor a Symbol type in system_prompt, tools and output_format Hashes These three options take a Hash tagged with a type. The tag was compared with String literals, so the Ruby spelling `type: :preset` matched no branch, and each option failed in its own way: - tools: { type: :preset, preset: :claude_code } sent the Hash's JSON text as the --tools value. The CLI reads that as a tool NAME, so the session started with no tools at all. - system_prompt: { type: :custom, prompt: '...' } sent no flag: the prompt was dropped. { type: :file, ... } and the append text of { type: :preset, ... } were dropped the same way, and so were exclude_dynamic_sections and snapshot on the initialize request. - output_format: { type: :json_schema, schema: {...} } sent the wrapper as the schema, and the CLI exits on it ("--json-schema is not a valid JSON Schema"). - output_format with `type` under one key style and `schema` under the other ({ 'type' => 'json_schema', schema: {...} }) sent no flag, which turned structured output off. thinking and the MCP server configs already read a Symbol type. The tag is now compared as a String at all five sites: the command builder's system prompt, tools and output format, and the two readers of the initialize fields. output_format finds `schema` under the other key style when the one `type` was written in does not have it. Nothing reads the value of `preset`, so `preset: :claude_code` needs no change. A type the SDK does not know does exactly what it did: no system prompt flag, a tools Hash sent as JSON text, an output_format Hash sent as the schema itself. No warning and no new error for those. User-visible: a Symbol-typed Hash now means what the String-typed one means. That includes its one check: { type: :custom } without a :prompt String raises the ArgumentError that { type: 'custom' } already raised, where it used to be ignored. Exploration finding: D-P2 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01S9UuLogZdvUXMxTuufTemS --- lib/claude_agent_sdk.rb | 5 +- lib/claude_agent_sdk/command_builder.rb | 37 ++- spec/unit/option_hash_symbol_type_spec.rb | 281 ++++++++++++++++++++++ 3 files changed, 311 insertions(+), 12 deletions(-) create mode 100644 spec/unit/option_hash_symbol_type_spec.rb diff --git a/lib/claude_agent_sdk.rb b/lib/claude_agent_sdk.rb index 1722a3e2..e30a115b 100644 --- a/lib/claude_agent_sdk.rb +++ b/lib/claude_agent_sdk.rb @@ -142,7 +142,8 @@ def self.extract_exclude_dynamic_sections(system_prompt) eds = system_prompt.exclude_dynamic_sections return eds if [true, false].include?(eds) elsif system_prompt.is_a?(Hash) - type = system_prompt[:type] || system_prompt['type'] + # The tag may be a Symbol (type: :preset), as CommandBuilder reads it. + type = (system_prompt[:type] || system_prompt['type']).to_s if type == 'preset' eds = system_prompt.fetch(:exclude_dynamic_sections) { system_prompt['exclude_dynamic_sections'] } return eds if [true, false].include?(eds) @@ -163,7 +164,7 @@ def self.extract_system_prompt_snapshot(system_prompt) snapshot = system_prompt.snapshot return snapshot if [true, false].include?(snapshot) when Hash - type = system_prompt[:type] || system_prompt['type'] + type = (system_prompt[:type] || system_prompt['type']).to_s if %w[preset custom].include?(type) snapshot = system_prompt.fetch(:snapshot) { system_prompt['snapshot'] } return snapshot if [true, false].include?(snapshot) diff --git a/lib/claude_agent_sdk/command_builder.rb b/lib/claude_agent_sdk/command_builder.rb index 898bb425..d08543f8 100644 --- a/lib/claude_agent_sdk/command_builder.rb +++ b/lib/claude_agent_sdk/command_builder.rb @@ -90,9 +90,16 @@ def append_system_prompt(cmd) end end + # The type tag of a Hash option, as a String: `type: :preset` is the + # natural Ruby spelling of `type: 'preset'`, and thinking and the MCP + # server configs already read it that way. A missing tag is '', which + # matches no branch, like any other tag the SDK does not know. + def hash_type(hash) + (hash[:type] || hash['type']).to_s + end + def append_hash_system_prompt(cmd, prompt_hash) - prompt_type = prompt_hash[:type] || prompt_hash['type'] - case prompt_type + case hash_type(prompt_hash) when 'file' prompt_path = prompt_hash[:path] || prompt_hash['path'] cmd.push('--system-prompt-file', prompt_path) if prompt_path @@ -433,7 +440,7 @@ def append_tools(cmd) when ToolsPreset cmd.push('--tools', 'default') when Hash - if (@options.tools[:type] || @options.tools['type']) == 'preset' + if hash_type(@options.tools) == 'preset' cmd.push('--tools', 'default') else cmd.push('--tools', JSON.generate(@options.tools)) @@ -444,13 +451,7 @@ def append_tools(cmd) def append_output_format(cmd) return unless @options.output_format - schema = if @options.output_format.is_a?(Hash) && @options.output_format[:type] == 'json_schema' - @options.output_format[:schema] - elsif @options.output_format.is_a?(Hash) && @options.output_format['type'] == 'json_schema' - @options.output_format['schema'] - else - @options.output_format - end + schema = output_schema(@options.output_format) # A json_schema output_format with a nil/absent schema must skip the # flag — `--json-schema null` is rejected by the CLI (Python guards # `schema is not None`). @@ -460,6 +461,22 @@ def append_output_format(cmd) cmd.push('--json-schema', schema_json) end + # The schema of a { type: 'json_schema', schema: ... } output format; any + # other value is the schema itself. The tag may be a Symbol, and `schema` + # is read under the key style `type` was written in, or under the other + # one when that key is absent ({ 'type' => 'json_schema', schema: {...} }). + def output_schema(format) + return format unless format.is_a?(Hash) + + if format[:type].to_s == 'json_schema' + format.fetch(:schema) { format['schema'] } + elsif format['type'].to_s == 'json_schema' + format.fetch('schema') { format[:schema] } + else + format + end + end + def append_additional_dirs(cmd) (@options.add_dirs || []).each { |dir| cmd.push('--add-dir', dir.to_s) } end diff --git a/spec/unit/option_hash_symbol_type_spec.rb b/spec/unit/option_hash_symbol_type_spec.rb new file mode 100644 index 00000000..8cc4c1e2 --- /dev/null +++ b/spec/unit/option_hash_symbol_type_spec.rb @@ -0,0 +1,281 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'async' +require 'json' + +# system_prompt, tools and output_format take a Hash tagged with a `type`. +# The tag was compared with String literals, so the Ruby spelling +# `type: :preset` matched no branch: +# +# tools: { type: :preset, preset: :claude_code } sent the Hash's JSON +# text as a tool NAME, +# so the session had no +# tools at all +# system_prompt: { type: :custom, prompt: '...' } sent no flag: the +# prompt was dropped +# output_format: { type: :json_schema, schema: ... } sent the wrapper as +# the schema, which the +# CLI rejects +# +# and a Hash with `type` under one key style and `schema` under the other +# turned structured output off. The same Hashes with a String tag worked, and +# other options (thinking, MCP server configs) already took a Symbol tag. +RSpec.describe 'an option Hash tagged with a Symbol type' do + # The command line built for these options. + def argv(**options) + ClaudeAgentSDK::CommandBuilder.new('/usr/bin/claude', ClaudeAgentSDK::ClaudeAgentOptions.new(**options)).build + end + + # The value that follows +flag+ on that command line (nil without the flag). + def flag_value(cmd, flag) + index = cmd.index(flag) + index && cmd[index + 1] + end + + schema = { + type: 'object', + properties: { + verdict: { type: 'string', enum: %w[approve reject] }, + reasons: { type: 'array', items: { type: 'string' } } + }, + required: ['verdict'] + }.freeze + + # option => { form => [the Hash with a String tag, the same Hash in other spellings] } + forms = { + system_prompt: { + 'a custom prompt' => [ + { type: 'custom', prompt: 'You are a terse reviewer.' }, + { + 'a Symbol type' => { type: :custom, prompt: 'You are a terse reviewer.' }, + 'a Symbol type under String keys' => { 'type' => :custom, 'prompt' => 'You are a terse reviewer.' } + } + ], + 'a prompt file' => [ + { type: 'file', path: 'prompts/reviewer.md' }, + { + 'a Symbol type' => { type: :file, path: 'prompts/reviewer.md' }, + 'a Symbol type under String keys' => { 'type' => :file, 'path' => 'prompts/reviewer.md' } + } + ], + 'the preset with an appended prompt' => [ + { type: 'preset', preset: 'claude_code', append: 'Answer in one paragraph.' }, + { + 'a Symbol type and preset' => { type: :preset, preset: :claude_code, append: 'Answer in one paragraph.' }, + 'a Symbol type and preset under String keys' => { + 'type' => :preset, 'preset' => :claude_code, 'append' => 'Answer in one paragraph.' + } + } + ] + }, + tools: { + 'the preset' => [ + { type: 'preset', preset: 'claude_code' }, + { + 'a Symbol type and preset' => { type: :preset, preset: :claude_code }, + 'a Symbol type and preset under String keys' => { 'type' => :preset, 'preset' => :claude_code } + } + ] + }, + output_format: { + 'a JSON schema' => [ + { type: 'json_schema', schema: schema }, + { + 'a Symbol type' => { type: :json_schema, schema: schema }, + 'a Symbol type under String keys' => { 'type' => :json_schema, 'schema' => schema }, + 'a String-keyed type and a Symbol-keyed schema' => { 'type' => 'json_schema', schema: schema }, + 'a Symbol-keyed type and a String-keyed schema' => { type: 'json_schema', 'schema' => schema }, + 'a Symbol type and a String-keyed schema' => { type: :json_schema, 'schema' => schema } + } + ] + } + }.freeze + + describe 'the command line' do + it 'carries the custom prompt, the prompt file, the appended prompt, the tools preset and the schema ' \ + 'for a String type' do + custom, file, preset = forms.fetch(:system_prompt).values.map(&:first) + tools = forms.dig(:tools, 'the preset').first + output_format = forms.dig(:output_format, 'a JSON schema').first + + expect(flag_value(argv(system_prompt: custom), '--system-prompt')).to eq('You are a terse reviewer.') + expect(flag_value(argv(system_prompt: file), '--system-prompt-file')).to eq('prompts/reviewer.md') + expect(flag_value(argv(system_prompt: preset), '--append-system-prompt')).to eq('Answer in one paragraph.') + expect(argv(system_prompt: preset)).not_to include('--system-prompt') + expect(flag_value(argv(tools: tools), '--tools')).to eq('default') + expect(JSON.parse(flag_value(argv(output_format: output_format), '--json-schema'))).to eq(JSON.parse(JSON.generate(schema))) + end + + forms.each do |option, by_form| + by_form.each do |form, (string_typed, spellings)| + spellings.each do |spelling, hash| + it "is the same for #{option} given as #{form} with #{spelling}" do + expect(argv(option => hash)).to eq(argv(option => string_typed)) + end + end + end + end + + it 'is the one the typed classes build' do + typed = { + system_prompt: [ + ClaudeAgentSDK::SystemPromptCustom.new(prompt: 'You are a terse reviewer.'), + ClaudeAgentSDK::SystemPromptFile.new(path: 'prompts/reviewer.md'), + ClaudeAgentSDK::SystemPromptPreset.new(preset: 'claude_code', append: 'Answer in one paragraph.') + ], + tools: [ClaudeAgentSDK::ToolsPreset.new(preset: 'claude_code')] + } + symbol_typed = { + system_prompt: forms.fetch(:system_prompt).values.map { |(_string_typed, spellings)| spellings.values.first }, + tools: [forms.dig(:tools, 'the preset').last.values.first] + } + + built = symbol_typed.to_h { |option, hashes| [option, hashes.map { |hash| argv(option => hash) }] } + + expect(built).to eq(typed.to_h { |option, values| [option, values.map { |value| argv(option => value) }] }) + end + + # A custom prompt without its text must not fall through to the default + # Claude Code prompt; the String spelling already raised here. + it 'rejects a custom system prompt without a prompt, whichever way the type is written' do + errors = [{ type: 'custom' }, { type: :custom }, { 'type' => :custom, 'snapshot' => false }].map do |system_prompt| + argv(system_prompt: system_prompt) + rescue ArgumentError => e + e.message + end + + expect(errors).to eq(["system_prompt of type 'custom' requires a :prompt String"] * 3) + end + end + + describe 'a type the SDK does not know' do + it 'still sends no system prompt flag at all' do + [{ type: 'bogus', prompt: 'x' }, { type: :bogus, prompt: 'x' }, { prompt: 'x' }, { type: nil }].each do |system_prompt| + expect(argv(system_prompt: system_prompt)) + .not_to include('--system-prompt', '--system-prompt-file', '--append-system-prompt') + end + end + + it 'still sends a tools Hash as JSON text' do + expect([{ type: 'bogus', names: ['Read'] }, { type: :bogus, names: ['Read'] }, { names: ['Read'] }] + .map { |tools| flag_value(argv(tools: tools), '--tools') }) + .to eq(['{"type":"bogus","names":["Read"]}', '{"type":"bogus","names":["Read"]}', '{"names":["Read"]}']) + end + + it 'still sends an output_format Hash as the schema itself' do + bare_schema = { type: 'object', properties: { verdict: { type: 'string' } } } + + expect([bare_schema, bare_schema.merge(type: :object), { 'type' => 'text' }] + .map { |output_format| flag_value(argv(output_format: output_format), '--json-schema') }) + .to eq([JSON.generate(bare_schema), JSON.generate(bare_schema), '{"type":"text"}']) + end + end + + # exclude_dynamic_sections and snapshot travel on the initialize request, + # not on the command line, and are read off the same Hash. End to end + # through the real control protocol: a fake Transport records each control + # request as the CLI's JSON parser sees it and answers it. + describe 'the initialize request' do + let(:transport_class) do + Class.new(ClaudeAgentSDK::Transport) do + def initialize(_options, requests:) + super() + @requests = requests + @queue = Thread::Queue.new + end + + def connect = @ready = true + def ready? = @ready + def end_input; end + def close = @queue.push(:eof) + + def write(data) + message = JSON.parse(data, symbolize_names: true) + return unless message[:type] == 'control_request' + + @requests << JSON.parse(data).fetch('request') + response = { subtype: 'success', request_id: message[:request_id], response: {} } + @queue.push({ type: 'control_response', response: response }) + end + + def read_messages + loop do + message = @queue.pop + break if message == :eof + + yield message + end + end + end + end + + # What a session with this system prompt asks for on initialize. + def prompt_fields_on_the_wire(system_prompt) + requests = [] + Sync do + client = ClaudeAgentSDK::Client.new( + options: ClaudeAgentSDK::ClaudeAgentOptions.new(system_prompt: system_prompt), + transport_class: transport_class, transport_args: { requests: requests } + ) + client.connect + ensure + client&.disconnect + end + requests.find { |request| request['subtype'] == 'initialize' }.slice('excludeDynamicSections', 'systemPromptSnapshot') + end + + { + 'the preset' => [ + { type: 'preset', preset: 'claude_code', exclude_dynamic_sections: true, snapshot: false }, + { 'excludeDynamicSections' => true, 'systemPromptSnapshot' => false }, + { + 'a Symbol type' => { type: :preset, preset: :claude_code, exclude_dynamic_sections: true, snapshot: false }, + 'a Symbol type under String keys' => { + 'type' => :preset, 'preset' => :claude_code, 'exclude_dynamic_sections' => true, 'snapshot' => false + } + } + ], + 'a custom prompt' => [ + { type: 'custom', prompt: 'You are a terse reviewer.', snapshot: false }, + { 'systemPromptSnapshot' => false }, + { + 'a Symbol type' => { type: :custom, prompt: 'You are a terse reviewer.', snapshot: false }, + 'a Symbol type under String keys' => { + 'type' => :custom, 'prompt' => 'You are a terse reviewer.', 'snapshot' => false + } + } + ] + }.each do |form, (string_typed, fields, spellings)| + it "carries the prompt fields of #{form} with a String type" do + expect(prompt_fields_on_the_wire(string_typed)).to eq(fields) + end + + spellings.each do |spelling, hash| + it "carries the same fields for #{form} with #{spelling}" do + expect(prompt_fields_on_the_wire(hash)).to eq(fields) + end + end + end + + it 'still carries neither field for a prompt file or a type the SDK does not know' do + prompts = [ + { type: 'file', path: 'prompts/reviewer.md', snapshot: false, exclude_dynamic_sections: true }, + { type: :file, path: 'prompts/reviewer.md', snapshot: false, exclude_dynamic_sections: true }, + { type: :bogus, snapshot: false, exclude_dynamic_sections: true }, + { snapshot: false, exclude_dynamic_sections: true } + ] + + expect(prompts.map { |system_prompt| prompt_fields_on_the_wire(system_prompt) }).to eq([{}] * 4) + end + + it 'still leaves exclude_dynamic_sections to the preset: a custom prompt does not carry it' do + prompts = [ + { type: 'custom', prompt: 'You are a terse reviewer.', exclude_dynamic_sections: true }, + { type: :custom, prompt: 'You are a terse reviewer.', exclude_dynamic_sections: true } + ] + + expect(prompts.map { |system_prompt| prompt_fields_on_the_wire(system_prompt) }).to eq([{}] * 2) + end + end +end From 2421298e855122bef392800dacbea6a79bd0481a Mon Sep 17 00:00:00 2001 From: ya-luotao Date: Sat, 3 Oct 2026 05:21:07 +0800 Subject: [PATCH 3/9] Rename a snake_case sandbox key only when the CLI accepts its value The previous commit on this branch sends the snake_case keys of a sandbox Hash under the names the CLI reads. That has a cost the CLI decides: it validates what it finds under a name it knows, and one value that fails its schema makes it discard the whole --settings value, the sandbox and the permissions passed next to it, and report that nowhere but in get_settings. A snake_case key is a name the CLI does not know, so until now such a value was ignored and the sandbox applied. Renamed, sandbox: { enabled: true, network: { allow_unix_sockets: true } } (a bool, which the signature of allow_unix_sockets allows and the CLI does not: it wants an Array of paths) left the session with no sandbox at all. A snake_case key is now renamed only when its value has the shape CLI 2.1.287 accepts under the wire key: true or false; an Array of Strings; for allow_mach_lookup an Array of Strings with a "*" only as the last character; an Integer for the two proxy ports; a Hash of String Arrays for ignore_violations. The kinds sit in a table next to the key tables (SandboxKeys::SHAPES), read from the schema in the CLI binary and checked against the running CLI, and a spec pins one row for every attribute that has a spelling of its own. With any other value the key is sent exactly as written, which the CLI ignores as before: the worst a snake_case key can do is what it did before this branch. Where the table is stricter than the CLI (it takes any number as a port) a key stays without effect; it is never looser. A key the caller wrote in wire spelling is not looked at and reaches the CLI as it always did, whatever its value. A known key holding nil is still left out in either spelling. One fixture of the spec changes: "the value of a field is not rewritten" put a Hash inside ignore_violations and inside allow_mach_lookup, two values the CLI rejects. It now uses values the CLI accepts; what it asserts is unchanged. Exploration finding: C-P2 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01S9UuLogZdvUXMxTuufTemS --- lib/claude_agent_sdk/types/option_values.rb | 72 ++++++++- spec/unit/sandbox_hash_keys_spec.rb | 163 +++++++++++++++++++- 2 files changed, 223 insertions(+), 12 deletions(-) diff --git a/lib/claude_agent_sdk/types/option_values.rb b/lib/claude_agent_sdk/types/option_values.rb index 0ba227e7..caa74d04 100644 --- a/lib/claude_agent_sdk/types/option_values.rb +++ b/lib/claude_agent_sdk/types/option_values.rb @@ -166,6 +166,11 @@ def to_h # (+deniedDomains+), with Symbol or String keys; any other key is sent as # written. The same holds for a Hash given as +sandbox:+ in place of a # SandboxSettings. + # + # A snake_case key in such a Hash is sent under the CLI's name only when + # its value has the shape the CLI accepts there (an Array of Strings for + # +denied_domains+, +true+ or +false+ for +allow_local_binding+, ...). + # With any other value it is sent as written, and the CLI ignores it. class SandboxSettings < Type include Type::OptionValue @@ -248,6 +253,34 @@ module SandboxKeys 'allow_managed_read_paths_only' => :allowManagedReadPathsOnly }.freeze + # Wire key => the kind of value CLI 2.1.287's sandbox schema accepts + # under it, for every wire key an attribute spells differently (read from + # the schema in the CLI binary and checked against the running CLI): + # + # :boolean true or false + # :strings an Array of Strings + # :mach_services an Array of Strings; a "*" only as the last character + # :integer a number (the CLI takes any; an Integer is asked for) + # :string_lists a Hash whose values are Arrays of Strings + # + # One value outside its kind makes the CLI discard the whole --settings + # value, the sandbox and the permissions next to it, without a word. A + # key in snake_case is one the CLI does not know and ignores, so it is + # renamed only when its value is of the kind listed here (see #rename). + # A kind that is too strict leaves a key without effect, as it was before + # the renaming existed; one that is too loose can cost a session its + # sandbox. + SHAPES = { + boolean: %i[failIfUnavailable autoAllowBashIfSandboxed allowUnsandboxedCommands enableWeakerNestedSandbox + enableWeakerNetworkIsolation allowManagedDomainsOnly allowAllUnixSockets allowLocalBinding + allowManagedReadPathsOnly], + strings: %i[excludedCommands allowedDomains deniedDomains allowUnixSockets + allowWrite denyWrite denyRead allowRead], + mach_services: %i[allowMachLookup], + integer: %i[httpProxyPort socksProxyPort], + string_lists: %i[ignoreViolations] + }.flat_map { |kind, wire_keys| wire_keys.map { |wire| [wire, kind] } }.to_h.freeze + # A Hash +sandbox:+ as the CLI reads it: its known keys under their wire # keys, at the top level and inside a Hash network / filesystem. Values # are not rewritten: ignore_violations and ripgrep hold structures of @@ -288,26 +321,49 @@ def self.filesystem(section) # A known key holding nil is left out, as #to_h leaves out a nil # attribute. The CLI rejects null under every one of these keys, and # when it does it drops the whole --settings value, sandbox included. + # + # A key in snake_case is renamed only when the CLI accepts its value + # under the wire key (SHAPES). Otherwise it is sent as written, which + # the CLI ignores, as it ignored every snake_case key before: renaming + # must not be what makes the CLI drop the settings. A key the caller + # wrote in wire spelling is not looked at; it goes out as it always did. def self.rename(hash, table) renamed = {} wire_spelled = {} hash.each do |key, value| name = key.to_s wire = table[name] || table.each_value.find { |candidate| candidate.name == name } - if wire.nil? - renamed[key] = value - elsif value.nil? - next - elsif wire.name == name + next if wire && value.nil? + + if wire && wire.name == name wire_spelled[wire] = true renamed[wire] = value - elsif !wire_spelled.key?(wire) - renamed[wire] = value + elsif wire && well_shaped?(wire, value) + renamed[wire] = value unless wire_spelled.key?(wire) + else + renamed[key] = value end end renamed end - private_class_method :rename + + # Whether the CLI accepts +value+ under +wire+. A key without a kind is + # answered false, the side that cannot cost the sandbox. + def self.well_shaped?(wire, value) + case SHAPES[wire] + when :boolean then [true, false].include?(value) + when :strings then strings?(value) + when :mach_services then strings?(value) && value.none? { |name| name.delete_suffix('*').include?('*') } + when :integer then value.is_a?(Integer) + when :string_lists then value.is_a?(Hash) && value.each_value.all? { |list| strings?(list) } + else false + end + end + + def self.strings?(value) + value.is_a?(Array) && value.all?(String) + end + private_class_method :rename, :well_shaped?, :strings? end # API-side task budget in tokens. diff --git a/spec/unit/sandbox_hash_keys_spec.rb b/spec/unit/sandbox_hash_keys_spec.rb index 9d4e7cad..f4386b34 100644 --- a/spec/unit/sandbox_hash_keys_spec.rb +++ b/spec/unit/sandbox_hash_keys_spec.rb @@ -135,6 +135,59 @@ def on_the_wire(value) top_level_fields.merge(network: ['network', network], filesystem: ['filesystem', filesystem]) end + fields_of = { + 'SandboxSettings' => top_level_fields, + 'SandboxNetworkConfig' => network_fields, + 'SandboxFilesystemConfig' => filesystem_fields + }.freeze + + # What CLI 2.1.287's sandbox schema accepts under each key that has a + # snake_case spelling: attribute => kind. Read from the schema in the CLI + # binary and checked against the running CLI, key by key, with get_settings: + # + # boolean true or false + # strings an Array of Strings + # mach_services an Array of Strings; a "*" only as the last character + # integer a number (the CLI takes any; the SDK asks for an Integer) + # string_lists a Hash whose values are Arrays of Strings + # + # Spelled out here, not derived from the SDK. enabled, network, filesystem + # and ripgrep are missing on purpose: their name is their wire key. + kinds = { + 'SandboxSettings' => { + fail_if_unavailable: :boolean, auto_allow_bash_if_sandboxed: :boolean, excluded_commands: :strings, + allow_unsandboxed_commands: :boolean, ignore_violations: :string_lists, + enable_weaker_nested_sandbox: :boolean, enable_weaker_network_isolation: :boolean + }, + 'SandboxNetworkConfig' => { + allowed_domains: :strings, denied_domains: :strings, allow_managed_domains_only: :boolean, + allow_unix_sockets: :strings, allow_all_unix_sockets: :boolean, allow_local_binding: :boolean, + allow_mach_lookup: :mach_services, http_proxy_port: :integer, socks_proxy_port: :integer + }, + 'SandboxFilesystemConfig' => { + allow_write: :strings, deny_write: :strings, deny_read: :strings, allow_read: :strings, + allow_managed_read_paths_only: :boolean + } + }.freeze + + # For each kind, values the CLI rejects under such a key. Each of them makes + # it discard the whole --settings value, sandbox and permissions included. + rejected = { + boolean: ['true', 0, []], + strings: ['docker', ['docker', 1], [nil], true, { 'docker' => true }], + mach_services: ['com.apple.audio', ['com.*.helper'], ['*.helper'], ['com.apple.**'], [1]], + integer: ['8080', true, [8080]], + string_lists: [{ 'file' => '/tmp/*' }, { 'file' => [1] }, { 'file' => { 'read' => [] } }, ['/tmp/*'], 'file'] + }.freeze + + # A sandbox Hash with +fields+ at the level of the class: the top, or inside + # the section the class stands for. + at_level_of = { + 'SandboxSettings' => ->(fields) { { enabled: true }.merge(fields) }, + 'SandboxNetworkConfig' => ->(fields) { { enabled: true, network: fields } }, + 'SandboxFilesystemConfig' => ->(fields) { { enabled: true, filesystem: fields } } + }.freeze + describe 'every field of every sandbox class' do # The section the CLI must receive, whichever way it was written. wire = wire_form_of.call( @@ -211,6 +264,28 @@ def on_the_wire(value) end end end + + describe 'SHAPES' do + it 'has a kind for every attribute whose name is not its wire key, and for nothing else' do + with_a_spelling_of_their_own = fields_of.transform_values do |fields| + fields.reject { |name, (wire_key, _value)| name.to_s == wire_key }.keys + end + + expect(kinds.transform_values(&:keys)).to eq(with_a_spelling_of_their_own) + end + + it 'agrees with the kinds this file spells out' do + spelled_out = kinds.flat_map do |class_name, by_attribute| + by_attribute.map { |attribute, kind| [fields_of.fetch(class_name).fetch(attribute).first.to_sym, kind] } + end + + expect(ClaudeAgentSDK::SandboxKeys::SHAPES).to eq(spelled_out.to_h) + end + + it 'has values the CLI rejects for every kind it uses' do + expect(rejected.keys).to match_array(kinds.values.flat_map(&:values).uniq) + end + end end describe 'a key the typed classes do not model' do @@ -282,15 +357,15 @@ def on_the_wire(value) sandbox = { enabled: true, ripgrep: inner, - ignore_violations: { 'excluded_commands' => ['/tmp/*'], network: { denied_domains: ['kept'] } }, - network: { allow_unix_sockets: ['/var/run/docker.sock'], allow_mach_lookup: [{ deny_read: 'kept' }] } + ignore_violations: { 'excluded_commands' => ['/tmp/*'], denied_domains: ['kept'], network: ['localhost'] }, + network: { allow_unix_sockets: ['/var/run/docker.sock'], allow_mach_lookup: ['com.apple.coresimulator.*'] } } expect(sandbox_section(sandbox)).to eq( 'enabled' => true, 'ripgrep' => on_the_wire(inner), - 'ignoreViolations' => { 'excluded_commands' => ['/tmp/*'], 'network' => { 'denied_domains' => ['kept'] } }, - 'network' => { 'allowUnixSockets' => ['/var/run/docker.sock'], 'allowMachLookup' => [{ 'deny_read' => 'kept' }] } + 'ignoreViolations' => { 'excluded_commands' => ['/tmp/*'], 'denied_domains' => ['kept'], 'network' => ['localhost'] }, + 'network' => { 'allowUnixSockets' => ['/var/run/docker.sock'], 'allowMachLookup' => ['com.apple.coresimulator.*'] } ) end end @@ -418,6 +493,86 @@ def on_the_wire(value) end end + # A snake_case key is one the CLI does not know: it ignores it, value and + # all. Under its wire name the same value is validated, and one value that + # fails the CLI's schema makes it discard the whole --settings value (the + # sandbox, and the permissions passed next to it) without a word. So a + # snake_case key is renamed only when its value has the shape the CLI + # accepts for that key. Otherwise it goes out as written and stays inert, + # as it always was: renaming must never cost a session its sandbox. + describe 'a snake_case key whose value the CLI would reject' do + # allowUnixSockets takes an Array of socket paths; the boolean switch is + # allow_all_unix_sockets. The shipped signature of allow_unix_sockets + # allows a bool all the same, which makes `true` the value most likely to + # be written here. Sent as allowUnixSockets: true it would leave the + # session with no sandbox at all. + it 'sends allow_unix_sockets: true as written, not as allowUnixSockets' do + sandbox = { enabled: true, network: { allow_unix_sockets: true, denied_domains: ['evil.example'] } } + + expect(sandbox_section(sandbox)).to eq( + 'enabled' => true, 'network' => { 'allow_unix_sockets' => true, 'deniedDomains' => ['evil.example'] } + ) + end + + kinds.each do |class_name, by_attribute| + by_attribute.each do |attribute, kind| + it "is sent as written for #{attribute} (#{kind}), under a Symbol or a String key" do + sandboxes = rejected.fetch(kind).flat_map do |value| + [attribute, attribute.to_s].map { |key| at_level_of.fetch(class_name).call(key => value) } + end + + expect(sandboxes.map { |sandbox| sandbox_section(sandbox) }).to eq(sandboxes.map { |sandbox| on_the_wire(sandbox) }) + end + end + end + + it 'is left as written inside a typed SandboxSettings too' do + sandbox = ClaudeAgentSDK::SandboxSettings.new( + enabled: true, network: { allow_unix_sockets: true }, filesystem: { 'deny_read' => '~/.aws' } + ) + + expect(sandbox.to_h).to eq( + enabled: true, network: { allow_unix_sockets: true }, filesystem: { 'deny_read' => '~/.aws' } + ) + end + + it 'stays next to the same field written in a form the CLI accepts, and never replaces it' do + sandbox = { + excluded_commands: 'docker', excludedCommands: ['git'], + filesystem: { deny_read: ['~/.aws'], 'deny_read' => '~/.ssh' } + } + + expect(sandbox_section(sandbox)).to eq( + 'excluded_commands' => 'docker', 'excludedCommands' => ['git'], + 'filesystem' => { 'denyRead' => ['~/.aws'], 'deny_read' => '~/.ssh' } + ) + end + + it 'is still renamed for the values the CLI accepts at the edges' do + sandbox = { + excluded_commands: [], ignore_violations: {}, + network: { allowed_domains: ['*.example.com', ''], allow_mach_lookup: ['*', 'com.apple.coresimulator.*'], + http_proxy_port: 0 }, + filesystem: { deny_read: ['/work/**/*.pem'] } + } + + expect(sandbox_section(sandbox)).to eq( + 'excludedCommands' => [], 'ignoreViolations' => {}, + 'network' => { 'allowedDomains' => ['*.example.com', ''], 'allowMachLookup' => ['*', 'com.apple.coresimulator.*'], + 'httpProxyPort' => 0 }, + 'filesystem' => { 'denyRead' => ['/work/**/*.pem'] } + ) + end + + # The check is only about renaming. A key the caller wrote in the CLI's + # own spelling reaches the CLI as before, whatever its value. + it 'does not concern a key already in wire spelling' do + sandbox = { enabled: true, excludedCommands: 'docker', 'failIfUnavailable' => 'yes', network: { allowUnixSockets: true } } + + expect(sandbox_section(sandbox)).to eq(on_the_wire(sandbox)) + end + end + describe 'what the caller did not write' do it 'is not added: an enabled sandbox is sent without failIfUnavailable' do expect([{ enabled: true }, { 'enabled' => true }, ClaudeAgentSDK::SandboxSettings.new(enabled: true)] From fbaf269035115ee3cd8ff309a4a4f40d24dc2159 Mon Sep 17 00:00:00 2001 From: ya-luotao Date: Sat, 3 Oct 2026 05:25:04 +0800 Subject: [PATCH 4/9] Accept tools: as the CLI's String and Pathnames where a path goes Two forms a Ruby caller writes naturally had no branch in the command builder, and each was dropped or broke without a useful word: - tools: 'Read,Grep', the syntax of the CLI's own --tools flag. The option was matched against Array, ToolsPreset and Hash only, so no flag was sent and the session got EVERY built-in tool instead of the two it was restricted to. - settings: Pathname (Rails.root.join('config/claude.json')) is neither a String nor a Hash: no --settings was sent, and the file's permission rules were not applied. - SystemPromptFile.new(path: Pathname), a Hash system prompt with a Pathname path, and a plugin path given as a Pathname put the Pathname itself on the command line. The spawn rejects it, so connect raised CLIConnectionError "no implicit conversion of Pathname into String". tools: now takes a String and passes it to --tools as written. A Pathname (anything that answers #to_path) is converted where the builder puts a path on the command line: the system prompt file, typed or Hash, and each plugin directory, typed or Hash. settings: as a Pathname is a settings file: with no sandbox: option the path is passed through, with one the file is read and merged, both exactly as for a String path. It is never tried as inline JSON, which a String is first. The typed values keep the Pathname they were given; only the command line gets a String. Values of any other class keep doing what they did (tools: 42 still sends no flag): no new error. The signatures widen accordingly: tools: gains String, settings: gains Pathname, and SystemPromptFile#path and SdkPluginConfig#path gain Pathname (constructor, accessor and #to_h). docs/options.md follows for those rows. User-visible: a tools: String that was ignored now restricts the session to the tools it names. Exploration finding: D-P3 / C-P4 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01S9UuLogZdvUXMxTuufTemS --- docs/options.md | 8 +- lib/claude_agent_sdk/command_builder.rb | 38 ++++-- sig/claude_agent_sdk/types/option_values.rbs | 12 +- sig/claude_agent_sdk/types/options.rbs | 14 +- .../tools_string_and_pathname_options_spec.rb | 129 ++++++++++++++++++ 5 files changed, 175 insertions(+), 26 deletions(-) create mode 100644 spec/unit/tools_string_and_pathname_options_spec.rb diff --git a/docs/options.md b/docs/options.md index 509739ee..5c7d9ad0 100644 --- a/docs/options.md +++ b/docs/options.md @@ -64,7 +64,7 @@ Guides: [Structured Output](configuration.md#structured-output), [Thinking Confi |-----------------|--------------| | `nil` (the default) | `--system-prompt ""`: an empty prompt, not Claude Code's default one | | a String, `SystemPromptCustom`, or `{ type: 'custom', prompt: '...' }` | `--system-prompt ` | -| `SystemPromptFile`, or `{ type: 'file', path: '...' }` | `--system-prompt-file ` | +| `SystemPromptFile`, or `{ type: 'file', path: '...' }` (the path a String or a `Pathname`) | `--system-prompt-file ` | | `SystemPromptPreset`, or `{ type: 'preset', preset: 'claude_code' }` | no system prompt flag, so the CLI uses its default prompt; `append:` adds `--append-system-prompt ` | `exclude_dynamic_sections` on a preset is sent as `initialize.excludeDynamicSections` ([Cross-User Prompt Caching](configuration.md#cross-user-prompt-caching)), and `snapshot` on a preset or a custom prompt as `initialize.systemPromptSnapshot` ([System Prompt Snapshot](configuration.md#system-prompt-snapshot)). Both are left out when unset. @@ -79,7 +79,7 @@ Guides: [Tools Configuration](configuration.md#tools-configuration), [Skills](co | Option | Type | Default | Sent to the CLI as | |--------|------|---------|--------------------| -| `tools` | `Array[String] \| ToolsPreset \| Hash[Symbol \| String, untyped]` | `nil` | `--tools `. `[]` sends `--tools ""` (no built-in tools); a `ToolsPreset` sends `--tools default` | +| `tools` | `Array[String] \| ToolsPreset \| Hash[Symbol \| String, untyped] \| String` | `nil` | `--tools `; a String is sent as written, in the CLI's own comma-separated form. `[]` sends `--tools ""` (no built-in tools); a `ToolsPreset`, or `{ type: 'preset' }`, sends `--tools default` | | `allowed_tools` | `Array[String]` | `[]` | `--allowedTools` (comma-joined; no flag when empty) | | `disallowed_tools` | `Array[String]` | `[]` | `--disallowedTools` (comma-joined; no flag when empty) | | `permission_mode` | `String` | `nil` | `--permission-mode` | @@ -100,7 +100,7 @@ Guides: [Custom Tools (SDK MCP Servers)](mcp-servers.md), [Subagent capabilities | `mcp_servers` | `Hash[String \| Symbol, mcp_server_config] \| String` | `{}` | `--mcp-config`: a Hash as `{"mcpServers": {...}}` JSON, a String (a file path or JSON) as given. An SDK server contributes only its `type` and `name`; its tools are served in your process over the control protocol | | `strict_mcp_config` | `bool` | `false`; explicit `nil` stays `nil` | `--strict-mcp-config`. See [`strict_mcp_config`](#strict_mcp_config) | | `agents` | `Hash[String \| Symbol, AgentDefinition \| Hash[Symbol \| String, untyped]]` | `nil` | `initialize.agents` | -| `plugins` | `Array[SdkPluginConfig \| Hash[Symbol \| String, untyped]]` | `nil` | `--plugin-dir `, once per plugin | +| `plugins` | `Array[SdkPluginConfig \| Hash[Symbol \| String, untyped]]` | `nil` | `--plugin-dir `, once per plugin. The path may be a String or a `Pathname` | ### `strict_mcp_config` @@ -114,7 +114,7 @@ Guides: [Bare Mode](configuration.md#bare-mode), [Verbatim Prompts](configuratio | Option | Type | Default | Sent to the CLI as | |--------|------|---------|--------------------| -| `settings` | `String \| Hash[Symbol \| String, untyped]` | `nil` | `--settings`: a Hash as JSON, a String (a file path or JSON) as given. With `sandbox` set as well, one merged JSON value | +| `settings` | `String \| Pathname \| Hash[Symbol \| String, untyped]` | `nil` | `--settings`: a Hash as JSON, a String (a file path or JSON) as given, a `Pathname` as the path of a settings file. With `sandbox` set as well, one merged JSON value | | `setting_sources` | `Array[String]` | `nil` | `--setting-sources` (comma-joined). `[]` sends `--setting-sources ""`; `nil` sends no flag | | `add_dirs` | `Array[String \| Pathname]` | `[]` | `--add-dir`, once per directory | | `bare` | `bool` | `nil` | `--bare` | diff --git a/lib/claude_agent_sdk/command_builder.rb b/lib/claude_agent_sdk/command_builder.rb index d08543f8..e25ef99c 100644 --- a/lib/claude_agent_sdk/command_builder.rb +++ b/lib/claude_agent_sdk/command_builder.rb @@ -76,7 +76,7 @@ def append_system_prompt(cmd) when String cmd.push('--system-prompt', @options.system_prompt) when SystemPromptFile - cmd.push('--system-prompt-file', @options.system_prompt.path) + cmd.push('--system-prompt-file', path_string(@options.system_prompt.path)) when SystemPromptCustom # The object form of a String prompt; snapshot travels on the # initialize request, not as a CLI flag. @@ -98,11 +98,17 @@ def hash_type(hash) (hash[:type] || hash['type']).to_s end + # A path as the String the command line takes. A Pathname (anything that + # answers #to_path) is converted; every other value is returned as it is. + def path_string(path) + path.respond_to?(:to_path) ? path.to_path : path + end + def append_hash_system_prompt(cmd, prompt_hash) case hash_type(prompt_hash) when 'file' prompt_path = prompt_hash[:path] || prompt_hash['path'] - cmd.push('--system-prompt-file', prompt_path) if prompt_path + cmd.push('--system-prompt-file', path_string(prompt_path)) if prompt_path when 'custom' prompt = prompt_hash.fetch(:prompt) { prompt_hash['prompt'] } cmd.push('--system-prompt', custom_prompt_text(prompt)) @@ -313,16 +319,14 @@ def append_settings(cmd) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticCo settings_is_path = false if @options.settings - if @options.settings.is_a?(String) + if @options.settings.respond_to?(:to_path) + # A Pathname names a settings file; it is never tried as inline JSON. + settings_hash, settings_is_path = settings_file(cmd, @options.settings.to_path) + elsif @options.settings.is_a?(String) begin settings_hash = JSON.parse(@options.settings) rescue JSON::ParserError - if @options.sandbox.nil? # rubocop:disable Metrics/BlockNesting -- settings-is-a-path fallback inside the JSON parse rescue - settings_is_path = true - cmd.push('--settings', @options.settings) - else - settings_hash = load_settings_file(@options.settings) - end + settings_hash, settings_is_path = settings_file(cmd, @options.settings) end elsif @options.settings.is_a?(Hash) settings_hash = @options.settings.dup @@ -340,6 +344,17 @@ def append_settings(cmd) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticCo cmd.push('--settings', JSON.generate(settings_hash)) if !settings_is_path && !settings_hash.empty? end + # --settings for a settings file, as [settings_hash, settings_is_path]. + # Without a sandbox option the path itself is passed and the CLI reads + # the file. With one, the file is read here, so that the option can be + # folded into its content. + def settings_file(cmd, path) + return [load_settings_file(path), false] unless @options.sandbox.nil? + + cmd.push('--settings', path) + [{}, true] + end + # The sandbox section as the CLI reads it. A Hash stands for the # SandboxSettings with the same fields: the CLI only knows the camelCase # keys that class writes, and it ignores the others without an error, so @@ -437,6 +452,9 @@ def append_tools(cmd) when Array tools_value = @options.tools.empty? ? '' : @options.tools.join(',') cmd.push('--tools', tools_value) + when String + # The CLI's own syntax ("Read,Grep", "default", ""): passed as written. + cmd.push('--tools', @options.tools) when ToolsPreset cmd.push('--tools', 'default') when Hash @@ -531,7 +549,7 @@ def append_plugins(cmd) end next unless plugin_path - cmd.push('--plugin-dir', plugin_path) + cmd.push('--plugin-dir', path_string(plugin_path)) end end diff --git a/sig/claude_agent_sdk/types/option_values.rbs b/sig/claude_agent_sdk/types/option_values.rbs index 397618b7..5e9afb3a 100644 --- a/sig/claude_agent_sdk/types/option_values.rbs +++ b/sig/claude_agent_sdk/types/option_values.rbs @@ -81,15 +81,15 @@ module ClaudeAgentSDK # A local plugin directory (ClaudeAgentOptions#plugins entries). class SdkPluginConfig < Type - def initialize: (?path: String?) -> void + def initialize: (?path: (String | Pathname)?) -> void | (Hash[Symbol | String, untyped]? attributes) -> void - attr_accessor path: String? + attr_accessor path: (String | Pathname)? # "local". attr_reader type: String - def to_h: () -> { type: String, path: String? } + def to_h: () -> { type: String, path: (String | Pathname)? } end class SandboxNetworkConfig < Type @@ -181,15 +181,15 @@ module ClaudeAgentSDK # A system prompt loaded from a file. class SystemPromptFile < Type - def initialize: (?path: String?) -> void + def initialize: (?path: (String | Pathname)?) -> void | (Hash[Symbol | String, untyped]? attributes) -> void - attr_accessor path: String? + attr_accessor path: (String | Pathname)? # "file". attr_reader type: String - def to_h: () -> { type: String, path: String? } + def to_h: () -> { type: String, path: (String | Pathname)? } end # The Claude Code preset system prompt, optionally extended. diff --git a/sig/claude_agent_sdk/types/options.rbs b/sig/claude_agent_sdk/types/options.rbs index d8803e3f..fa3837fb 100644 --- a/sig/claude_agent_sdk/types/options.rbs +++ b/sig/claude_agent_sdk/types/options.rbs @@ -48,7 +48,7 @@ module ClaudeAgentSDK ?permission_prompt_tool_name: String?, ?cwd: (String | Pathname)?, ?cli_path: (String | Pathname)?, - ?settings: (String | Hash[Symbol | String, untyped])?, + ?settings: (String | Pathname | Hash[Symbol | String, untyped])?, ?add_dirs: Array[String | Pathname]?, ?env: Hash[String | Symbol, String?]?, ?extra_args: Hash[String | Symbol, untyped]?, @@ -68,7 +68,7 @@ module ClaudeAgentSDK ?plugins: Array[SdkPluginConfig | Hash[Symbol | String, untyped]]?, ?debug_stderr: (_Puts | String)?, ?betas: Array[String]?, - ?tools: (Array[String] | ToolsPreset | Hash[Symbol | String, untyped])?, + ?tools: (Array[String] | ToolsPreset | Hash[Symbol | String, untyped] | String)?, ?sandbox: (SandboxSettings | Hash[Symbol | String, untyped] | bool)?, ?thinking: thinking_config?, ?effort: (String | Symbol | Integer)?, @@ -133,8 +133,9 @@ module ClaudeAgentSDK attr_accessor cli_path: (String | Pathname)? - # A settings JSON String, a path to a settings file, or a Hash. - attr_accessor settings: (String | Hash[Symbol | String, untyped])? + # A settings JSON String, a path to a settings file (a String or a + # Pathname), or a Hash. + attr_accessor settings: (String | Pathname | Hash[Symbol | String, untyped])? attr_accessor add_dirs: Array[String | Pathname]? @@ -185,8 +186,9 @@ module ClaudeAgentSDK # Entries of SDK_BETAS. attr_accessor betas: Array[String]? - # The base tool set: tool names, or a preset. - attr_accessor tools: (Array[String] | ToolsPreset | Hash[Symbol | String, untyped])? + # The base tool set: tool names (an Array, or the CLI's comma-separated + # String such as "Read,Grep"), or a preset. + attr_accessor tools: (Array[String] | ToolsPreset | Hash[Symbol | String, untyped] | String)? # true / false are forwarded as-is (a bare toggle). attr_accessor sandbox: (SandboxSettings | Hash[Symbol | String, untyped] | bool)? diff --git a/spec/unit/tools_string_and_pathname_options_spec.rb b/spec/unit/tools_string_and_pathname_options_spec.rb new file mode 100644 index 00000000..05a24021 --- /dev/null +++ b/spec/unit/tools_string_and_pathname_options_spec.rb @@ -0,0 +1,129 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'json' +require 'pathname' +require 'tmpdir' + +# Two forms a Ruby caller writes naturally and the command builder had no +# branch for, so they were dropped without a word: +# +# tools: 'Read,Grep' the CLI's own --tools syntax. No flag was sent, so +# the session got EVERY built-in tool, not two. +# a Pathname where a path is expected. settings: Pathname sent no +# --settings at all; SystemPromptFile#path and a plugin +# path put the Pathname itself on the command line, +# which the spawn rejects ("no implicit conversion of +# Pathname into String"). +RSpec.describe 'tools as a String and paths as Pathnames' do + # The command line built for these options. + def argv(**options) + ClaudeAgentSDK::CommandBuilder.new('/usr/bin/claude', ClaudeAgentSDK::ClaudeAgentOptions.new(**options)).build + end + + # The value that follows +flag+ on that command line (nil without the flag). + def flag_value(cmd, flag) + index = cmd.index(flag) + index && cmd[index + 1] + end + + describe 'tools: as a String' do + it 'builds the command line the Array of the same names builds' do + expect(argv(tools: 'Read,Grep')).to eq(argv(tools: %w[Read Grep])) + end + + it 'is passed to --tools as written: it is the CLI\'s own syntax' do + values = ['Read,Grep', 'Read', 'default', ''].map { |tools| flag_value(argv(tools: tools), '--tools') } + + expect(values).to eq(['Read,Grep', 'Read', 'default', '']) + end + + it 'still sends no --tools flag for a value of another class', + rbs_incompatible: 'passes values outside the signature of tools to show they are still ignored' do + expect([42, :default, true].map { |tools| argv(tools: tools) }).to eq([argv] * 3) + end + end + + describe 'settings: as a Pathname' do + it 'builds the command line the String path builds' do + path = '/srv/app/config/claude.json' + + expect(argv(settings: Pathname.new(path))).to eq(argv(settings: path)) + expect(flag_value(argv(settings: Pathname.new(path)), '--settings')).to eq(path) + end + + it 'is read and merged with the sandbox option like the String path, relative to the CLI\'s cwd' do + Dir.mktmpdir('pathname-settings') do |dir| + File.write(File.join(dir, 'claude.json'), JSON.generate(permissions: { deny: ['WebFetch'] })) + absolute = File.join(dir, 'claude.json') + forms = [ + { settings: Pathname.new(absolute) }, + { settings: Pathname.new('claude.json'), cwd: dir }, + { settings: Pathname.new('claude.json'), cwd: Pathname.new(dir) } + ] + + merged = forms.map { |form| JSON.parse(flag_value(argv(sandbox: { enabled: true }, **form), '--settings')) } + + expect(merged).to eq([{ 'permissions' => { 'deny' => ['WebFetch'] }, 'sandbox' => { 'enabled' => true } }] * 3) + expect(argv(sandbox: { enabled: true }, settings: Pathname.new(absolute))) + .to eq(argv(sandbox: { enabled: true }, settings: absolute)) + end + end + + # A String is tried as inline JSON first and taken for a path when it does + # not parse. A Pathname says which of the two it is. + it 'is a path even when its name would parse as JSON' do + expect(flag_value(argv(settings: Pathname.new('42')), '--settings')).to eq('42') + end + end + + describe 'the path of a system prompt file, as a Pathname' do + path = '/srv/app/prompts/reviewer.md' + { + 'a SystemPromptFile' => ClaudeAgentSDK::SystemPromptFile.new(path: Pathname.new(path)), + 'a Hash' => { type: 'file', path: Pathname.new(path) }, + 'a Hash with a Symbol type and String keys' => { 'type' => :file, 'path' => Pathname.new(path) } + }.each do |form, system_prompt| + it "reaches the command line as a String for #{form}" do + cmd = argv(system_prompt: system_prompt) + + expect(cmd).to all(be_a(String)) + expect(cmd).to eq(argv(system_prompt: ClaudeAgentSDK::SystemPromptFile.new(path: path))) + expect(flag_value(cmd, '--system-prompt-file')).to eq(path) + end + end + + it 'stays the Pathname the caller gave on the typed value' do + pathname = Pathname.new(path) + prompt = ClaudeAgentSDK::SystemPromptFile.new(path: pathname) + + expect(prompt.path).to equal(pathname) + expect(prompt.to_h).to eq(type: 'file', path: pathname) + end + end + + describe 'a plugin path as a Pathname' do + path = '/srv/app/plugins/review' + { + 'an SdkPluginConfig' => ClaudeAgentSDK::SdkPluginConfig.new(path: Pathname.new(path)), + 'a Hash' => { type: 'local', path: Pathname.new(path) }, + 'a Hash with String keys' => { 'type' => 'local', 'path' => Pathname.new(path) } + }.each do |form, plugin| + it "reaches the command line as a String for #{form}" do + cmd = argv(plugins: [plugin]) + + expect(cmd).to all(be_a(String)) + expect(cmd).to eq(argv(plugins: [ClaudeAgentSDK::SdkPluginConfig.new(path: path)])) + expect(flag_value(cmd, '--plugin-dir')).to eq(path) + end + end + + it 'stays the Pathname the caller gave on the typed value' do + pathname = Pathname.new(path) + plugin = ClaudeAgentSDK::SdkPluginConfig.new(path: pathname) + + expect(plugin.path).to equal(pathname) + expect(plugin.to_h).to eq(type: 'local', path: pathname) + end + end +end From e3f1b0c73c0ccbf753c09ab68628008f68daddec Mon Sep 17 00:00:00 2001 From: ya-luotao Date: Sat, 3 Oct 2026 05:25:53 +0800 Subject: [PATCH 5/9] Write a typed network or filesystem inside a sandbox Hash as a Hash A sandbox: Hash may hold a typed section: sandbox: { enabled: true, network: SandboxNetworkConfig.new(denied_domains: [...]) } JSON.generate has no way to write a typed value, so the section went out as its #inspect text, "#". The CLI wants an object there ("sandbox.network: Expected object, but received string") and discards the whole --settings value over it: the session ran with no sandbox at all, and without the permissions passed in the same settings. The network and filesystem of a Hash sandbox: now go through the same two functions a typed SandboxSettings uses for its own sections, so a typed config is written by its #to_h and a Hash has its keys renamed, as before. Any other value is still sent as written. Exploration finding: C-P2 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01S9UuLogZdvUXMxTuufTemS --- lib/claude_agent_sdk/types/option_values.rb | 16 ++++++++-------- spec/unit/sandbox_hash_keys_spec.rb | 20 ++++++++++++++++++++ 2 files changed, 28 insertions(+), 8 deletions(-) diff --git a/lib/claude_agent_sdk/types/option_values.rb b/lib/claude_agent_sdk/types/option_values.rb index caa74d04..fa879d41 100644 --- a/lib/claude_agent_sdk/types/option_values.rb +++ b/lib/claude_agent_sdk/types/option_values.rb @@ -282,18 +282,18 @@ module SandboxKeys }.flat_map { |kind, wire_keys| wire_keys.map { |wire| [wire, kind] } }.to_h.freeze # A Hash +sandbox:+ as the CLI reads it: its known keys under their wire - # keys, at the top level and inside a Hash network / filesystem. Values - # are not rewritten: ignore_violations and ripgrep hold structures of - # the CLI's own. + # keys, at the top level and inside its network / filesystem, which may + # each be a Hash or the typed config. Other values are not rewritten: + # ignore_violations and ripgrep hold structures of the CLI's own. def self.normalize(sandbox) normalized = rename(sandbox, TOP_LEVEL) - { network: NETWORK, filesystem: FILESYSTEM }.each do |section, table| - normalized[section] = rename(normalized[section], table) if normalized[section].is_a?(Hash) - end + normalized[:network] = network(normalized[:network]) if normalized.key?(:network) + normalized[:filesystem] = filesystem(normalized[:filesystem]) if normalized.key?(:filesystem) normalized end - # The network of a SandboxSettings as the CLI reads it. + # A network section as the CLI reads it: the typed config's #to_h, or a + # Hash with its known keys renamed. Any other value is returned as it is. def self.network(section) case section when SandboxNetworkConfig then section.to_h @@ -302,7 +302,7 @@ def self.network(section) end end - # The filesystem of a SandboxSettings as the CLI reads it. + # A filesystem section as the CLI reads it (see .network). def self.filesystem(section) case section when SandboxFilesystemConfig then section.to_h diff --git a/spec/unit/sandbox_hash_keys_spec.rb b/spec/unit/sandbox_hash_keys_spec.rb index f4386b34..c60bbeb7 100644 --- a/spec/unit/sandbox_hash_keys_spec.rb +++ b/spec/unit/sandbox_hash_keys_spec.rb @@ -573,6 +573,26 @@ def on_the_wire(value) end end + # JSON.generate has no way to write a typed value: left inside a Hash it + # went out as its #inspect text ("#"), a String where the CLI wants an object, and the CLI discarded the + # whole --settings value over it. + describe 'a typed network or filesystem inside a Hash' do + it 'is written like a Hash with the same fields' do + sandbox = { + enabled: true, + network: ClaudeAgentSDK::SandboxNetworkConfig.new(denied_domains: ['evil.example']), + 'filesystem' => ClaudeAgentSDK::SandboxFilesystemConfig.new(deny_read: ['/private/etc/ssh']) + } + + expect(sandbox_section(sandbox)).to eq( + 'enabled' => true, + 'network' => { 'deniedDomains' => ['evil.example'] }, + 'filesystem' => { 'denyRead' => ['/private/etc/ssh'] } + ) + end + end + describe 'what the caller did not write' do it 'is not added: an enabled sandbox is sent without failIfUnavailable' do expect([{ enabled: true }, { 'enabled' => true }, ClaudeAgentSDK::SandboxSettings.new(enabled: true)] From 1685efffbdbaec32c7418d2d39f7be791395819b Mon Sep 17 00:00:00 2001 From: ya-luotao Date: Sat, 3 Oct 2026 05:26:35 +0800 Subject: [PATCH 6/9] Honor a Symbol type in a plugins Hash A plugin given as a Hash carries a type, 'local' (or 'plugin', its older name). The command builder compared it with those Strings, so the Ruby spelling plugins: [{ type: :local, path: '/srv/app/plugins/review' }] raised ArgumentError "Unsupported plugin type: :local" when the command line was built. The type is now read the way system_prompt, tools and output_format read theirs, as a String whatever it was written as. A type that is not a plugin type is refused as before, and the message still shows it as the caller wrote it (:remote, "remote", nil). Exploration finding: D-P2 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01S9UuLogZdvUXMxTuufTemS --- lib/claude_agent_sdk/command_builder.rb | 4 ++-- spec/unit/option_hash_symbol_type_spec.rb | 27 +++++++++++++++++++++++ 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/lib/claude_agent_sdk/command_builder.rb b/lib/claude_agent_sdk/command_builder.rb index e25ef99c..e190cf82 100644 --- a/lib/claude_agent_sdk/command_builder.rb +++ b/lib/claude_agent_sdk/command_builder.rb @@ -541,10 +541,10 @@ def append_plugins(cmd) @options.plugins.each do |plugin| plugin_config = plugin.is_a?(SdkPluginConfig) ? plugin.to_h : plugin - plugin_type = plugin_config[:type] || plugin_config['type'] plugin_path = plugin_config[:path] || plugin_config['path'] - unless %w[local plugin].include?(plugin_type) + unless %w[local plugin].include?(hash_type(plugin_config)) + plugin_type = plugin_config[:type] || plugin_config['type'] raise ArgumentError, "Unsupported plugin type: #{plugin_type.inspect}" end next unless plugin_path diff --git a/spec/unit/option_hash_symbol_type_spec.rb b/spec/unit/option_hash_symbol_type_spec.rb index 8cc4c1e2..dcbf7c7c 100644 --- a/spec/unit/option_hash_symbol_type_spec.rb +++ b/spec/unit/option_hash_symbol_type_spec.rb @@ -149,6 +149,33 @@ def flag_value(cmd, flag) end end + # plugins: entries carry a `type` too ('local', or 'plugin', its older + # name). A Symbol there was refused: "Unsupported plugin type: :local". + describe 'a plugin Hash' do + it 'builds the same command line with a Symbol type' do + string_typed = argv(plugins: [{ type: 'local', path: '/srv/app/plugins/review' }]) + symbol_typed = [ + { type: :local, path: '/srv/app/plugins/review' }, + { 'type' => :local, 'path' => '/srv/app/plugins/review' }, + { type: :plugin, path: '/srv/app/plugins/review' } + ].map { |plugin| argv(plugins: [plugin]) } + + expect(flag_value(string_typed, '--plugin-dir')).to eq('/srv/app/plugins/review') + expect(symbol_typed).to eq([string_typed] * 3) + end + + it 'still refuses a type that is not a plugin type, naming it as written' do + messages = [{ type: 'remote', path: '/x' }, { type: :remote, path: '/x' }, { path: '/x' }].map do |plugin| + argv(plugins: [plugin]) + rescue ArgumentError => e + e.message + end + + expect(messages).to eq(['Unsupported plugin type: "remote"', 'Unsupported plugin type: :remote', + 'Unsupported plugin type: nil']) + end + end + describe 'a type the SDK does not know' do it 'still sends no system prompt flag at all' do [{ type: 'bogus', prompt: 'x' }, { type: :bogus, prompt: 'x' }, { prompt: 'x' }, { type: nil }].each do |system_prompt| From 8d749961add0d328d3f34a5900c51ffacd369618 Mon Sep 17 00:00:00 2001 From: ya-luotao Date: Sat, 3 Oct 2026 05:29:09 +0800 Subject: [PATCH 7/9] Document the accepted spellings of Hash-form options docs/configuration.md, in the sections that show these options: - Sandbox Settings: sandbox: and its network / filesystem take a Hash, which may spell the fields of the typed classes in snake_case or in the CLI's camelCase, with Symbol or String keys; any other key is sent as written; a snake_case key is sent under the CLI's name only when its value has the shape the CLI accepts, and is otherwise sent as written; a field holding nil is left out. - The same section now says what makes that last rule necessary, and is true of every form, the typed classes included: when one sandbox value fails the CLI's settings schema, the CLI (2.1.287) discards the whole --settings value, sandbox and permissions rules alike, and reports it nowhere. connect succeeds, nothing appears on stderr, and the session runs unsandboxed. - Structured Output, System Prompt Snapshot and Tools Configuration: next to each Hash example, `type` may be a String or a Symbol. Tools Configuration also shows the String form of tools: and the preset as a Hash. The signature comments of SandboxSettings and ClaudeAgentOptions#sandbox and the Sandbox section of the two skill trees (kept byte-identical) say the same in one paragraph. No type changes here; the Pathname and String widenings and their docs/options.md rows went in with the code. Docs only. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01S9UuLogZdvUXMxTuufTemS --- docs/configuration.md | 30 +++++++++++++++++++ .../claude-agent-ruby/references/options.md | 2 ++ sig/claude_agent_sdk/types/option_values.rbs | 7 +++++ sig/claude_agent_sdk/types/options.rbs | 4 ++- skills/references/options.md | 2 ++ 5 files changed, 44 insertions(+), 1 deletion(-) diff --git a/docs/configuration.md b/docs/configuration.md index 9221e00e..51d416d4 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -35,6 +35,8 @@ ClaudeAgentSDK.query(prompt: "Create a profile for a software engineer", options end ``` +In the `output_format` Hash, `type` may be a String or a Symbol (`type: :json_schema`), and the keys Symbols or Strings. + See [examples/structured_output_example.rb](https://github.com/ya-luotao/claude-agent-sdk-ruby/blob/main/examples/structured_output_example.rb). ## Thinking Configuration @@ -96,6 +98,8 @@ options = ClaudeAgentSDK::ClaudeAgentOptions.new( ) ``` +In a Hash form, `type` may be a String or a Symbol (`type: :preset`). + `snapshot` is sent on the control-protocol `initialize` request (never as a CLI flag), so it applies to both `query()` and `Client`. When omitted it acts as `true`, except in bare mode (`bare: true`), where it acts as `false`. A `SystemPromptFile` has no `snapshot`. Requires Claude Code CLI 2.1.257 or later. Before 2.1.265, a session with an `append` or custom prompt recorded it only when `snapshot` was `true`. See [Modifying system prompts](https://code.claude.com/docs/en/agent-sdk/modifying-system-prompts#change-the-prompt-of-an-existing-session) for details. @@ -177,10 +181,18 @@ Available beta features are listed in the `SDK_BETAS` constant. # Array of tool names options = ClaudeAgentSDK::ClaudeAgentOptions.new(tools: ['Read', 'Edit', 'Bash']) +# The same names as one String, in the comma-separated form of the CLI's --tools flag +options = ClaudeAgentSDK::ClaudeAgentOptions.new(tools: 'Read,Edit,Bash') + # Preset options = ClaudeAgentSDK::ClaudeAgentOptions.new(tools: ClaudeAgentSDK::ToolsPreset.new(preset: 'claude_code')) + +# The preset as a Hash +options = ClaudeAgentSDK::ClaudeAgentOptions.new(tools: { type: 'preset', preset: 'claude_code' }) ``` +A String is passed to the CLI as written. In the Hash form, `type` may be a String or a Symbol (`type: :preset`). + ## Skills `skills` is the single place to enable skills for the main session — it auto-allows the `Skill` tool and defaults `setting_sources` to `['user', 'project']` (when unset) so skill files are discovered: @@ -218,6 +230,24 @@ options = ClaudeAgentSDK::ClaudeAgentOptions.new( ) ``` +`sandbox` also takes a Hash, and so do `network` and `filesystem`, inside that Hash or inside a `SandboxSettings`. A Hash may spell the fields of `SandboxSettings`, `SandboxNetworkConfig` and `SandboxFilesystemConfig` as the classes do (`denied_domains`) or as the CLI does (`deniedDomains`), with Symbol or String keys: + +```ruby +options = ClaudeAgentSDK::ClaudeAgentOptions.new( + sandbox: { + enabled: true, + network: { denied_domains: ['evil.example'] }, + filesystem: { deny_read: ['~/.ssh'] } + } +) +``` + +- Any other key is sent as written. That is how to pass a sandbox setting the classes have no attribute for (`allowAppleEvents`, `strictAllowlist` inside `network`): spell it as the CLI does. +- A snake_case key is sent under the CLI's name only when its value has the shape the CLI accepts for that key: `true` or `false` for a switch, an Array of Strings for a list, an Integer for a proxy port, a Hash of String Arrays for `ignore_violations`. With a value of any other shape (`denied_domains: 'evil.example'`, a String where an Array belongs) the key is sent as written, and the CLI ignores a key it does not know. +- A field holding `nil` is left out, in either spelling, as a `nil` attribute of the typed classes is. + +The second rule exists because of how the CLI validates these settings (observed with CLI 2.1.287). When one sandbox value fails its settings schema, the CLI discards the **whole** `--settings` value: the sandbox, and everything you passed in `settings:` next to it, `permissions` rules included. It reports this nowhere: `connect` succeeds, nothing appears on stderr, and the session runs unsandboxed. That applies to a value written under the CLI's own name (`excludedCommands: 'docker'`) and to the typed classes, which send each value as you gave it: `SandboxSettings.new(excluded_commands: 'docker')` leaves the session without a sandbox. + `enabled: true` asks for a sandbox. It does not make one a requirement. When the sandbox cannot start on the host (missing dependencies, an unsupported platform), the CLI carries on without it. Its settings schema describes the setting that decides this, `failIfUnavailable`, as follows (CLI 2.1.287): > Exit with an error at startup if sandbox.enabled is true but the sandbox cannot start (missing dependencies or unsupported platform). When false (default), a warning is shown and commands run unsandboxed. diff --git a/plugins/claude-agent-ruby/skills/claude-agent-ruby/references/options.md b/plugins/claude-agent-ruby/skills/claude-agent-ruby/references/options.md index 73a01911..3cfe1d7d 100644 --- a/plugins/claude-agent-ruby/skills/claude-agent-ruby/references/options.md +++ b/plugins/claude-agent-ruby/skills/claude-agent-ruby/references/options.md @@ -99,6 +99,8 @@ Use `mcp_servers:` to add SDK MCP servers (in-process) or external MCP servers. Use `sandbox:` with `ClaudeAgentSDK::SandboxSettings` to run tool execution in an isolated sandbox when supported. +A Hash works too, for `sandbox:` and for its `network:` / `filesystem:`. It may spell the fields of the typed classes in snake_case or in the CLI's camelCase, with Symbol or String keys; other keys are sent as written. A snake_case key is sent under the CLI's name only when its value has the shape the CLI accepts for it (`true` / `false`, an Array of Strings, an Integer port); otherwise it is sent as written and ignored. The CLI discards the whole `--settings` value, sandbox and `permissions` included, when one sandbox value fails its schema, and reports it nowhere. + ## Agents Use `agents:` to configure sub-agent definitions passed to the CLI via the control protocol. diff --git a/sig/claude_agent_sdk/types/option_values.rbs b/sig/claude_agent_sdk/types/option_values.rbs index 5e9afb3a..52fa6e01 100644 --- a/sig/claude_agent_sdk/types/option_values.rbs +++ b/sig/claude_agent_sdk/types/option_values.rbs @@ -138,6 +138,13 @@ module ClaudeAgentSDK # Sandbox settings for isolated command execution # (ClaudeAgentOptions#sandbox). + # + # network / filesystem take the typed config or a Hash. A Hash may spell + # the fields of the typed class in snake_case or in the CLI's camelCase, + # with Symbol or String keys; other keys are sent as written. A snake_case + # key is sent under the CLI's name only when its value has the shape the + # CLI accepts for it; otherwise it is sent as written, which the CLI + # ignores. class SandboxSettings < Type def initialize: (?enabled: bool?, ?fail_if_unavailable: bool?, ?auto_allow_bash_if_sandboxed: bool?, ?excluded_commands: Array[String]?, ?allow_unsandboxed_commands: bool?, ?network: (SandboxNetworkConfig | Hash[Symbol | String, untyped])?, ?filesystem: (SandboxFilesystemConfig | Hash[Symbol | String, untyped])?, ?ignore_violations: Hash[Symbol | String, Array[String]]?, ?enable_weaker_nested_sandbox: bool?, ?enable_weaker_network_isolation: bool?, ?ripgrep: Hash[Symbol | String, untyped]?) -> void | (Hash[Symbol | String, untyped]? attributes) -> void diff --git a/sig/claude_agent_sdk/types/options.rbs b/sig/claude_agent_sdk/types/options.rbs index fa3837fb..2a503000 100644 --- a/sig/claude_agent_sdk/types/options.rbs +++ b/sig/claude_agent_sdk/types/options.rbs @@ -190,7 +190,9 @@ module ClaudeAgentSDK # String such as "Read,Grep"), or a preset. attr_accessor tools: (Array[String] | ToolsPreset | Hash[Symbol | String, untyped] | String)? - # true / false are forwarded as-is (a bare toggle). + # true / false are forwarded as-is (a bare toggle). A Hash may spell the + # fields of SandboxSettings, and of its network / filesystem, in + # snake_case or in the CLI's camelCase (see SandboxSettings). attr_accessor sandbox: (SandboxSettings | Hash[Symbol | String, untyped] | bool)? attr_accessor thinking: thinking_config? diff --git a/skills/references/options.md b/skills/references/options.md index 73a01911..3cfe1d7d 100644 --- a/skills/references/options.md +++ b/skills/references/options.md @@ -99,6 +99,8 @@ Use `mcp_servers:` to add SDK MCP servers (in-process) or external MCP servers. Use `sandbox:` with `ClaudeAgentSDK::SandboxSettings` to run tool execution in an isolated sandbox when supported. +A Hash works too, for `sandbox:` and for its `network:` / `filesystem:`. It may spell the fields of the typed classes in snake_case or in the CLI's camelCase, with Symbol or String keys; other keys are sent as written. A snake_case key is sent under the CLI's name only when its value has the shape the CLI accepts for it (`true` / `false`, an Array of Strings, an Integer port); otherwise it is sent as written and ignored. The CLI discards the whole `--settings` value, sandbox and `permissions` included, when one sandbox value fails its schema, and reports it nowhere. + ## Agents Use `agents:` to configure sub-agent definitions passed to the CLI via the control protocol. From c50c5ed5b1a9a67c96db3d6752ccd009f03b1328 Mon Sep 17 00:00:00 2001 From: ya-luotao Date: Sat, 3 Oct 2026 06:07:45 +0800 Subject: [PATCH 8/9] Rename a snake_case proxy port only when it is a port number A snake_case sandbox key is renamed only when the CLI accepts its value under the wire key. For the two proxy ports the check was "an Integer", and that is not what the CLI accepts: an Integer too large for it to read as a number (2**1024 and up, 10**400 for one) reaches it as a non-finite value, fails its schema ("Expected number, but received null"), and the CLI discards the whole --settings value over it. So sandbox: { enabled: true, network: { http_proxy_port: 10**400 } } ran with no sandbox, and without the permissions passed in the same settings, where the key was simply ignored before it was renamed. That is the one thing the rename must never do. The kind for http_proxy_port and socks_proxy_port is now a port: an Integer from 0 to 65535 (and is called :port, no longer :integer). A value outside that range is sent as written and has no effect, as in 1.1.0. The bound is tighter than what the CLI takes, which is any number it can read; a bound that is too tight leaves a key inert, and one that is too loose can cost the sandbox. A key written in wire spelling is still not looked at. The spec gets the three out-of-range values (10**400, 65536, -1) for both attributes under Symbol and String keys, the two ends of the range (0 and 65535) renamed, and httpProxyPort: 10**400 still sent as it is. The comment on the kinds table also said the CLI discards the settings "without a word". It lists the error in its get_settings response; the SDK is what is not told. Found by review. Exploration finding: C-P2 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01S9UuLogZdvUXMxTuufTemS --- lib/claude_agent_sdk/types/option_values.rb | 22 +++++---- spec/unit/sandbox_hash_keys_spec.rb | 50 +++++++++++++++------ 2 files changed, 50 insertions(+), 22 deletions(-) diff --git a/lib/claude_agent_sdk/types/option_values.rb b/lib/claude_agent_sdk/types/option_values.rb index fa879d41..af67f7f3 100644 --- a/lib/claude_agent_sdk/types/option_values.rb +++ b/lib/claude_agent_sdk/types/option_values.rb @@ -260,16 +260,20 @@ module SandboxKeys # :boolean true or false # :strings an Array of Strings # :mach_services an Array of Strings; a "*" only as the last character - # :integer a number (the CLI takes any; an Integer is asked for) + # :port an Integer from 0 to 65535 # :string_lists a Hash whose values are Arrays of Strings # # One value outside its kind makes the CLI discard the whole --settings - # value, the sandbox and the permissions next to it, without a word. A - # key in snake_case is one the CLI does not know and ignores, so it is - # renamed only when its value is of the kind listed here (see #rename). - # A kind that is too strict leaves a key without effect, as it was before - # the renaming existed; one that is too loose can cost a session its - # sandbox. + # value, the sandbox and the permissions next to it. It lists the error + # in its get_settings response only, so the SDK is not told. A key in + # snake_case is one the CLI does not know and ignores, so it is renamed + # only when its value is of the kind listed here (see #rename). A kind + # that is too strict leaves a key without effect, as it was before the + # renaming existed; one that is too loose can cost a session its sandbox. + # + # :port is the strict side of what the CLI does. It takes any number it + # can read as a port, but an Integer too large for that (2**1024 and up, + # say 10**400) reaches it as a non-finite value and fails its schema. SHAPES = { boolean: %i[failIfUnavailable autoAllowBashIfSandboxed allowUnsandboxedCommands enableWeakerNestedSandbox enableWeakerNetworkIsolation allowManagedDomainsOnly allowAllUnixSockets allowLocalBinding @@ -277,7 +281,7 @@ module SandboxKeys strings: %i[excludedCommands allowedDomains deniedDomains allowUnixSockets allowWrite denyWrite denyRead allowRead], mach_services: %i[allowMachLookup], - integer: %i[httpProxyPort socksProxyPort], + port: %i[httpProxyPort socksProxyPort], string_lists: %i[ignoreViolations] }.flat_map { |kind, wire_keys| wire_keys.map { |wire| [wire, kind] } }.to_h.freeze @@ -354,7 +358,7 @@ def self.well_shaped?(wire, value) when :boolean then [true, false].include?(value) when :strings then strings?(value) when :mach_services then strings?(value) && value.none? { |name| name.delete_suffix('*').include?('*') } - when :integer then value.is_a?(Integer) + when :port then value.is_a?(Integer) && value.between?(0, 65_535) when :string_lists then value.is_a?(Hash) && value.each_value.all? { |list| strings?(list) } else false end diff --git a/spec/unit/sandbox_hash_keys_spec.rb b/spec/unit/sandbox_hash_keys_spec.rb index c60bbeb7..cb234b20 100644 --- a/spec/unit/sandbox_hash_keys_spec.rb +++ b/spec/unit/sandbox_hash_keys_spec.rb @@ -148,7 +148,8 @@ def on_the_wire(value) # boolean true or false # strings an Array of Strings # mach_services an Array of Strings; a "*" only as the last character - # integer a number (the CLI takes any; the SDK asks for an Integer) + # port a number (the CLI takes any it can read; the SDK asks for + # what a port is, an Integer from 0 to 65535) # string_lists a Hash whose values are Arrays of Strings # # Spelled out here, not derived from the SDK. enabled, network, filesystem @@ -162,7 +163,7 @@ def on_the_wire(value) 'SandboxNetworkConfig' => { allowed_domains: :strings, denied_domains: :strings, allow_managed_domains_only: :boolean, allow_unix_sockets: :strings, allow_all_unix_sockets: :boolean, allow_local_binding: :boolean, - allow_mach_lookup: :mach_services, http_proxy_port: :integer, socks_proxy_port: :integer + allow_mach_lookup: :mach_services, http_proxy_port: :port, socks_proxy_port: :port }, 'SandboxFilesystemConfig' => { allow_write: :strings, deny_write: :strings, deny_read: :strings, allow_read: :strings, @@ -170,13 +171,18 @@ def on_the_wire(value) } }.freeze - # For each kind, values the CLI rejects under such a key. Each of them makes - # it discard the whole --settings value, sandbox and permissions included. - rejected = { + # For each kind, values outside it: a snake_case key holding one is not + # renamed. The CLI rejects them under the wire key, and one rejected value + # makes it discard the whole --settings value, sandbox and permissions + # included. That goes for 10**400 too: it is an Integer, and a number the + # CLI cannot read (it fails the schema as a non-finite value). 65_536 and -1 + # are the exception, numbers the CLI takes: they are outside the kind + # because a port is 0..65535, and a bound that is too tight costs nothing. + ill_shaped = { boolean: ['true', 0, []], strings: ['docker', ['docker', 1], [nil], true, { 'docker' => true }], mach_services: ['com.apple.audio', ['com.*.helper'], ['*.helper'], ['com.apple.**'], [1]], - integer: ['8080', true, [8080]], + port: ['8080', true, [8080], 10**400, 65_536, -1], string_lists: [{ 'file' => '/tmp/*' }, { 'file' => [1] }, { 'file' => { 'read' => [] } }, ['/tmp/*'], 'file'] }.freeze @@ -282,8 +288,8 @@ def on_the_wire(value) expect(ClaudeAgentSDK::SandboxKeys::SHAPES).to eq(spelled_out.to_h) end - it 'has values the CLI rejects for every kind it uses' do - expect(rejected.keys).to match_array(kinds.values.flat_map(&:values).uniq) + it 'has values outside every kind it uses' do + expect(ill_shaped.keys).to match_array(kinds.values.flat_map(&:values).uniq) end end end @@ -496,7 +502,7 @@ def on_the_wire(value) # A snake_case key is one the CLI does not know: it ignores it, value and # all. Under its wire name the same value is validated, and one value that # fails the CLI's schema makes it discard the whole --settings value (the - # sandbox, and the permissions passed next to it) without a word. So a + # sandbox, and the permissions passed next to it); the SDK is not told. So a # snake_case key is renamed only when its value has the shape the CLI # accepts for that key. Otherwise it goes out as written and stays inert, # as it always was: renaming must never cost a session its sandbox. @@ -517,7 +523,7 @@ def on_the_wire(value) kinds.each do |class_name, by_attribute| by_attribute.each do |attribute, kind| it "is sent as written for #{attribute} (#{kind}), under a Symbol or a String key" do - sandboxes = rejected.fetch(kind).flat_map do |value| + sandboxes = ill_shaped.fetch(kind).flat_map do |value| [attribute, attribute.to_s].map { |key| at_level_of.fetch(class_name).call(key => value) } end @@ -552,22 +558,40 @@ def on_the_wire(value) sandbox = { excluded_commands: [], ignore_violations: {}, network: { allowed_domains: ['*.example.com', ''], allow_mach_lookup: ['*', 'com.apple.coresimulator.*'], - http_proxy_port: 0 }, + http_proxy_port: 0, socks_proxy_port: 65_535 }, filesystem: { deny_read: ['/work/**/*.pem'] } } expect(sandbox_section(sandbox)).to eq( 'excludedCommands' => [], 'ignoreViolations' => {}, 'network' => { 'allowedDomains' => ['*.example.com', ''], 'allowMachLookup' => ['*', 'com.apple.coresimulator.*'], - 'httpProxyPort' => 0 }, + 'httpProxyPort' => 0, 'socksProxyPort' => 65_535 }, 'filesystem' => { 'denyRead' => ['/work/**/*.pem'] } ) end + # A proxy port is renamed for what a port can be, whichever of the two + # attributes it is and whichever class its key has. Beyond that range the + # CLI still takes a number it can read, but not one it cannot: an Integer + # such as 10**400 reaches it as a non-finite value, and it discards the + # whole --settings value over that (the examples for the port kind above). + %i[http_proxy_port socks_proxy_port].each do |attribute| + it "is renamed for #{attribute} at 0 and at 65535, under a Symbol or a String key" do + wire_key = network_fields.fetch(attribute).first + sections = [0, 65_535].flat_map do |port| + [attribute, attribute.to_s].map { |key| sandbox_section({ enabled: true, network: { key => port } }) } + end + + expect(sections) + .to eq([0, 0, 65_535, 65_535].map { |port| { 'enabled' => true, 'network' => { wire_key => port } } }) + end + end + # The check is only about renaming. A key the caller wrote in the CLI's # own spelling reaches the CLI as before, whatever its value. it 'does not concern a key already in wire spelling' do - sandbox = { enabled: true, excludedCommands: 'docker', 'failIfUnavailable' => 'yes', network: { allowUnixSockets: true } } + sandbox = { enabled: true, excludedCommands: 'docker', 'failIfUnavailable' => 'yes', + network: { allowUnixSockets: true, httpProxyPort: 10**400, 'socksProxyPort' => -1 } } expect(sandbox_section(sandbox)).to eq(on_the_wire(sandbox)) end From 8c80bd355ad99ac8384e511522cbb52a387e725a Mon Sep 17 00:00:00 2001 From: ya-luotao Date: Sat, 3 Oct 2026 06:08:23 +0800 Subject: [PATCH 9/9] Say where the CLI reports a rejected sandbox value docs/configuration.md ("Sandbox Settings") and the Sandbox paragraph of the two skill trees said that the CLI, when it discards the --settings value over one invalid sandbox value, reports this "nowhere". It does report it: in the errors of its get_settings control response. What is true, and what the text now says, is that the SDK does not surface that error: connect succeeds, nothing appears on stderr, and the session runs unsandboxed. The same passages said a snake_case key is renamed for "an Integer" as a proxy port. The check is now for a port number, an Integer from 0 to 65535, and the text says so. The two skill trees stay byte-identical. Docs only. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01S9UuLogZdvUXMxTuufTemS --- docs/configuration.md | 4 ++-- .../skills/claude-agent-ruby/references/options.md | 2 +- skills/references/options.md | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index 51d416d4..7bec245a 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -243,10 +243,10 @@ options = ClaudeAgentSDK::ClaudeAgentOptions.new( ``` - Any other key is sent as written. That is how to pass a sandbox setting the classes have no attribute for (`allowAppleEvents`, `strictAllowlist` inside `network`): spell it as the CLI does. -- A snake_case key is sent under the CLI's name only when its value has the shape the CLI accepts for that key: `true` or `false` for a switch, an Array of Strings for a list, an Integer for a proxy port, a Hash of String Arrays for `ignore_violations`. With a value of any other shape (`denied_domains: 'evil.example'`, a String where an Array belongs) the key is sent as written, and the CLI ignores a key it does not know. +- A snake_case key is sent under the CLI's name only when its value has the shape the CLI accepts for that key: `true` or `false` for a switch, an Array of Strings for a list, a port number (an Integer from 0 to 65535) for a proxy port, a Hash of String Arrays for `ignore_violations`. With a value of any other shape (`denied_domains: 'evil.example'`, a String where an Array belongs) the key is sent as written, and the CLI ignores a key it does not know. - A field holding `nil` is left out, in either spelling, as a `nil` attribute of the typed classes is. -The second rule exists because of how the CLI validates these settings (observed with CLI 2.1.287). When one sandbox value fails its settings schema, the CLI discards the **whole** `--settings` value: the sandbox, and everything you passed in `settings:` next to it, `permissions` rules included. It reports this nowhere: `connect` succeeds, nothing appears on stderr, and the session runs unsandboxed. That applies to a value written under the CLI's own name (`excludedCommands: 'docker'`) and to the typed classes, which send each value as you gave it: `SandboxSettings.new(excluded_commands: 'docker')` leaves the session without a sandbox. +The second rule exists because of how the CLI validates these settings (observed with CLI 2.1.287). When one sandbox value fails its settings schema, the CLI discards the **whole** `--settings` value: the sandbox, and everything you passed in `settings:` next to it, `permissions` rules included. The CLI reports the failure in the `errors` of its `get_settings` control response, and the SDK does not surface that: `connect` succeeds, nothing appears on stderr, and the session runs unsandboxed. That applies to a value written under the CLI's own name (`excludedCommands: 'docker'`) and to the typed classes, which send each value as you gave it: `SandboxSettings.new(excluded_commands: 'docker')` leaves the session without a sandbox. `enabled: true` asks for a sandbox. It does not make one a requirement. When the sandbox cannot start on the host (missing dependencies, an unsupported platform), the CLI carries on without it. Its settings schema describes the setting that decides this, `failIfUnavailable`, as follows (CLI 2.1.287): diff --git a/plugins/claude-agent-ruby/skills/claude-agent-ruby/references/options.md b/plugins/claude-agent-ruby/skills/claude-agent-ruby/references/options.md index 3cfe1d7d..99d1b468 100644 --- a/plugins/claude-agent-ruby/skills/claude-agent-ruby/references/options.md +++ b/plugins/claude-agent-ruby/skills/claude-agent-ruby/references/options.md @@ -99,7 +99,7 @@ Use `mcp_servers:` to add SDK MCP servers (in-process) or external MCP servers. Use `sandbox:` with `ClaudeAgentSDK::SandboxSettings` to run tool execution in an isolated sandbox when supported. -A Hash works too, for `sandbox:` and for its `network:` / `filesystem:`. It may spell the fields of the typed classes in snake_case or in the CLI's camelCase, with Symbol or String keys; other keys are sent as written. A snake_case key is sent under the CLI's name only when its value has the shape the CLI accepts for it (`true` / `false`, an Array of Strings, an Integer port); otherwise it is sent as written and ignored. The CLI discards the whole `--settings` value, sandbox and `permissions` included, when one sandbox value fails its schema, and reports it nowhere. +A Hash works too, for `sandbox:` and for its `network:` / `filesystem:`. It may spell the fields of the typed classes in snake_case or in the CLI's camelCase, with Symbol or String keys; other keys are sent as written. A snake_case key is sent under the CLI's name only when its value has the shape the CLI accepts for it (`true` / `false`, an Array of Strings, a port number from 0 to 65535); otherwise it is sent as written and ignored. The CLI discards the whole `--settings` value, sandbox and `permissions` included, when one sandbox value fails its schema. It reports that in the `errors` of its `get_settings` control response, which the SDK does not surface: `connect` succeeds and nothing appears on stderr. ## Agents diff --git a/skills/references/options.md b/skills/references/options.md index 3cfe1d7d..99d1b468 100644 --- a/skills/references/options.md +++ b/skills/references/options.md @@ -99,7 +99,7 @@ Use `mcp_servers:` to add SDK MCP servers (in-process) or external MCP servers. Use `sandbox:` with `ClaudeAgentSDK::SandboxSettings` to run tool execution in an isolated sandbox when supported. -A Hash works too, for `sandbox:` and for its `network:` / `filesystem:`. It may spell the fields of the typed classes in snake_case or in the CLI's camelCase, with Symbol or String keys; other keys are sent as written. A snake_case key is sent under the CLI's name only when its value has the shape the CLI accepts for it (`true` / `false`, an Array of Strings, an Integer port); otherwise it is sent as written and ignored. The CLI discards the whole `--settings` value, sandbox and `permissions` included, when one sandbox value fails its schema, and reports it nowhere. +A Hash works too, for `sandbox:` and for its `network:` / `filesystem:`. It may spell the fields of the typed classes in snake_case or in the CLI's camelCase, with Symbol or String keys; other keys are sent as written. A snake_case key is sent under the CLI's name only when its value has the shape the CLI accepts for it (`true` / `false`, an Array of Strings, a port number from 0 to 65535); otherwise it is sent as written and ignored. The CLI discards the whole `--settings` value, sandbox and `permissions` included, when one sandbox value fails its schema. It reports that in the `errors` of its `get_settings` control response, which the SDK does not surface: `connect` succeeds and nothing appears on stderr. ## Agents