diff --git a/docs/configuration.md b/docs/configuration.md index e3187b08..120e5e6b 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, 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. 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): > 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/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.rb b/lib/claude_agent_sdk.rb index dc5b5de2..81315bba 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 c0882c6c..e190cf82 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. @@ -90,12 +90,25 @@ 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 + + # 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) - 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 + 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)) @@ -306,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 @@ -327,12 +338,36 @@ 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 + # --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 + # 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 @@ -417,10 +452,13 @@ 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 - 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)) @@ -431,13 +469,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`). @@ -447,6 +479,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 @@ -493,15 +541,15 @@ 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 - cmd.push('--plugin-dir', plugin_path) + cmd.push('--plugin-dir', path_string(plugin_path)) end end diff --git a/lib/claude_agent_sdk/types/option_values.rb b/lib/claude_agent_sdk/types/option_values.rb index e0fd8422..af67f7f3 100644 --- a/lib/claude_agent_sdk/types/option_values.rb +++ b/lib/claude_agent_sdk/types/option_values.rb @@ -158,7 +158,19 @@ 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. + # + # 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 @@ -169,15 +181,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 +200,176 @@ 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 + + # 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 + # :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. 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 + allowManagedReadPathsOnly], + strings: %i[excludedCommands allowedDomains deniedDomains allowUnixSockets + allowWrite denyWrite denyRead allowRead], + mach_services: %i[allowMachLookup], + port: %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 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) + normalized[:network] = network(normalized[:network]) if normalized.key?(:network) + normalized[:filesystem] = filesystem(normalized[:filesystem]) if normalized.key?(:filesystem) + normalized + end + + # 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 + when Hash then rename(section, NETWORK) + else section + end + end + + # A filesystem section as the CLI reads it (see .network). + 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. + # + # 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 } + next if wire && value.nil? + + if wire && wire.name == name + wire_spelled[wire] = true + 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 + + # 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 :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 + 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. # 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/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..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,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, 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 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 dbbd277c..3d8bb1c6 100644 --- a/sig/claude_agent_sdk/types/option_values.rbs +++ b/sig/claude_agent_sdk/types/option_values.rbs @@ -91,15 +91,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 @@ -148,6 +148,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 @@ -191,15 +198,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..2a503000 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,10 +186,13 @@ 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). + # 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..99d1b468 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, 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 Use `agents:` to configure sub-agent definitions passed to the CLI via the control protocol. 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..dcbf7c7c --- /dev/null +++ b/spec/unit/option_hash_symbol_type_spec.rb @@ -0,0 +1,308 @@ +# 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 + + # 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| + 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 diff --git a/spec/unit/sandbox_hash_keys_spec.rb b/spec/unit/sandbox_hash_keys_spec.rb new file mode 100644 index 00000000..cb234b20 --- /dev/null +++ b/spec/unit/sandbox_hash_keys_spec.rb @@ -0,0 +1,653 @@ +# 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 + + 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 + # 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 + # 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: :port, socks_proxy_port: :port + }, + 'SandboxFilesystemConfig' => { + allow_write: :strings, deny_write: :strings, deny_read: :strings, allow_read: :strings, + allow_managed_read_paths_only: :boolean + } + }.freeze + + # 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]], + port: ['8080', true, [8080], 10**400, 65_536, -1], + 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( + 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 + + 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 outside every kind it uses' do + expect(ill_shaped.keys).to match_array(kinds.values.flat_map(&:values).uniq) + 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/*'], 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/*'], 'denied_domains' => ['kept'], 'network' => ['localhost'] }, + 'network' => { 'allowUnixSockets' => ['/var/run/docker.sock'], 'allowMachLookup' => ['com.apple.coresimulator.*'] } + ) + 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 + + # 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); 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. + 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 = ill_shaped.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, 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, '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, httpProxyPort: 10**400, 'socksProxyPort' => -1 } } + + expect(sandbox_section(sandbox)).to eq(on_the_wire(sandbox)) + 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)] + .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 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