Skip to content

Accept the natural Ruby spellings in Hash-form options (sandbox keys, Symbol type:, String tools:, Pathname) - #160

Merged
ya-luotao merged 11 commits into
mainfrom
fix/hash-form-option-spellings
Oct 3, 2026
Merged

ya-luotao merged 11 commits into
mainfrom
fix/hash-form-option-spellings

Conversation

@ya-luotao

@ya-luotao ya-luotao commented Oct 2, 2026 •

Copy link
Copy Markdown
Collaborator

What and why

Input a Ruby caller writes naturally, that the SDK accepted and then silently did not apply. Each fix is one commit
with a spec that fails before it. No new warn, no new error for input that works today (one exception, stated
below), no changed default, no new public method; the RBS signatures only widen. Whether unknown shapes should warn
or raise is a separate maintainer decision and not in here.

Stacked: this branch contains #149 (hook output / wire contract) and the docs PR #155, merged into it. Please
merge it after both; until then the diff here shows their commits too. Its own commits are b724e0f, 774081a,
2421298, fbaf269, e3f1b0c, 1685eff, 8d74996.

  1. A sandbox: Hash in snake_case is applied (b724e0f, 2421298, e3f1b0c). The typed sandbox classes take
    snake_case attributes and write camelCase; a Hash was forwarded as written, and the CLI ignores sandbox keys it
    does not know. sandbox: { enabled: true, network: { denied_domains: […] } } ran with network: {} — read back
    from the real CLI with get_settings — and so did SandboxSettings.new(network: { denied_domains: […] }). A
    Hash now stands for the typed value with the same fields: known fields in snake_case or camelCase, Symbol or
    String, at the top level and inside network / filesystem; unknown keys sent as written; the wire spelling
    wins when a Hash has both; a field holding nil left out, as the typed classes do. A typed network /
    filesystem inside a Hash is written by its #to_h instead of its inspect text (which made the CLI drop the
    sandbox).
    The rename can never cost a session its sandbox. The CLI discards the whole --settings value — sandbox and
    permissions — when one value under a key it knows fails its schema; only its get_settings control response lists
    the error, and the SDK does not surface it. So a snake_case key is
    renamed only when its value has the shape CLI 2.1.287 accepts for that key (SandboxKeys::SHAPES, read from the
    CLI's schema and checked against the running CLI; a proxy port must be an Integer from 0 to 65535); otherwise it is sent as written and stays inert, as in 1.1.0.
  2. A Symbol type: is honored in system_prompt, tools, output_format and plugins Hashes (774081a,
    1685eff). tools: { type: :preset, preset: :claude_code } started a session with no tools;
    system_prompt: { type: :custom, prompt: '…' } dropped the prompt; output_format: { type: :json_schema, … }
    made the CLI exit; a mixed-key json_schema Hash turned structured output off; plugins: [{ type: :local, … }]
    raised. The tag is compared as a String, and schema is found under either key style. An unknown type does
    what it did. The one new error: { type: :custom } without a :prompt String now raises the ArgumentError
    that { type: 'custom' } already raised (it used to be silently ignored, so the custom prompt never applied).
  3. tools: as the CLI's comma-separated String, and Pathname where a path goes (fbaf269).
    tools: 'Read,Grep' sent no flag, so the session got every built-in tool; settings: Pathname sent no
    --settings; a Pathname prompt file or plugin path failed at spawn. All four work; sig/ widens (tools: +
    String; settings:, SystemPromptFile#path, SdkPluginConfig#path + Pathname) and docs/options.md
    follows. If you pass tools: as a String, the session is now restricted to the tools it names.
  4. Docs (8d74996): docs/configuration.md describes the accepted spellings, and says plainly what the CLI
    does with an invalid sandbox value (it discards the whole --settings value; the SDK does not surface it, only the
    CLI's get_settings response lists the error) — true before this PR and for every form.

Verification: three spec files (127 examples), each fix red before and green after, on the lock,
gemfiles/latest.gemfile (json 3.x) and gemfiles/floor.gemfile bundles, under the RBS runtime checker and on both
Rails legs; 55 single-substitution mutants of the fixes are all killed. Against the real CLI (2.1.287 and 2.1.286),
handshake only, no model call: the effective sandbox read back with get_settings is identical for every
spelling; 60 fuzz rounds of random snake_case values never made the CLI discard the settings, and 25 rounds of
well-shaped edge values were all accepted; a Symbol tools preset loads the default tools; tools: 'Read,Grep'
loads two; settings: Pathname applies the file. An independent adversarial review checked the same.

Not in this PR

  • Surfacing the CLI's settings errors (reading get_settings back after connect and warning or raising): a
    policy decision. This PR documents the behavior and makes sure its own renaming cannot trigger it.
  • SandboxNetworkConfig.new(allow_unix_sockets: true) silently disables the whole sandbox today (pre-existing):
    the signature allows a bool there, the CLI accepts only an Array of paths (the switch is
    allow_all_unix_sockets), and the typed class sends the value as given. Narrowing the signature or mapping
    true is a maintainer call. (In a snake_case Hash the same value is now sent as written and ignored, which keeps
    the sandbox.)
  • A snake_case sandbox section inside settings: (the CLI's own settings document); unknown sandbox keys given
    both as a Symbol and a String; the default_options merge of Hash options; sharing one renamer between hook
    outputs and sandbox keys; SHAPES is a snapshot of CLI 2.1.287 (a narrower future schema would need the table
    re-checked — the probes take two minutes, no model call).
  • Any warning or error for unknown shapes, unknown type values or near-miss spellings; failIfUnavailable: not
    added, not changed.
  • CHANGELOG.md: this PR's entries are in the batch's combined CHANGELOG PR.

Checklist

  • bundle exec rake passes (specs + RuboCop)
  • Specs cover the change; a bug fix includes one that failed before the fix
  • CHANGELOG entry under ## [Unreleased] — in the batch's combined CHANGELOG PR
  • Docs updated (docs/configuration.md, docs/options.md, YARD on SandboxSettings, sig/, both skill trees)
  • The CLI wire protocol is unchanged in shape; handshake-only probes against the real CLI instead of the keyed
    integration suite

🤖 Generated with Claude Code

https://claude.ai/code/session_01CNgMt2RjeBLyRP3CWFWNy2

ya-luotao and others added 10 commits October 3, 2026 04:41
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S9UuLogZdvUXMxTuufTemS
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S9UuLogZdvUXMxTuufTemS
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S9UuLogZdvUXMxTuufTemS
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S9UuLogZdvUXMxTuufTemS
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, "#<ClaudeAgentSDK::SandboxNetworkConfig ...>". 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S9UuLogZdvUXMxTuufTemS
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S9UuLogZdvUXMxTuufTemS
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S9UuLogZdvUXMxTuufTemS
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S9UuLogZdvUXMxTuufTemS
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S9UuLogZdvUXMxTuufTemS
@ya-luotao
ya-luotao marked this pull request as ready for review October 2, 2026 22:20
@ya-luotao

Copy link
Copy Markdown
Collaborator Author

@codex review

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 3, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-03T02:09:07.777277Z 8c80bd3 Manual request
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. You're on a roll.

Reviewed commit: 8c80bd355a

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

#149 and #155, which this branch was stacked on, are in main now. With
both of them merged into this branch earlier, main and this branch had
two merge bases, which GitHub reports as a conflict although git merges
them cleanly; merging main in leaves a single base.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CNgMt2RjeBLyRP3CWFWNy2
@ya-luotao
ya-luotao merged commit 6c50a67 into main Oct 3, 2026
12 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant