Repository navigation
Accept the natural Ruby spellings in Hash-form options (sandbox keys, Symbol type:, String tools:, Pathname) - #160
Merged
Merged
Conversation
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
marked this pull request as ready for review
October 2, 2026 22:20
4 tasks done
Collaborator
Author
|
@codex review |
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
|
Codex Review: Didn't find any major issues. You're on a roll. Reviewed commit: ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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, statedbelow), 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.sandbox:Hash in snake_case is applied (b724e0f,2421298,e3f1b0c). The typed sandbox classes takesnake_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 withnetwork: {}— read backfrom the real CLI with
get_settings— and so didSandboxSettings.new(network: { denied_domains: […] }). AHash 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 spellingwins when a Hash has both; a field holding
nilleft out, as the typed classes do. A typednetwork/filesysteminside a Hash is written by its#to_hinstead of its inspect text (which made the CLI drop thesandbox).
The rename can never cost a session its sandbox. The CLI discards the whole
--settingsvalue — sandbox andpermissions — when one value under a key it knows fails its schema; only its
get_settingscontrol response liststhe 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 theCLI'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.
type:is honored insystem_prompt,tools,output_formatandpluginsHashes (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
schemais found under either key style. An unknowntypedoeswhat it did. The one new error:
{ type: :custom }without a:promptString now raises theArgumentErrorthat
{ type: 'custom' }already raised (it used to be silently ignored, so the custom prompt never applied).tools:as the CLI's comma-separated String, andPathnamewhere a path goes (fbaf269).tools: 'Read,Grep'sent no flag, so the session got every built-in tool;settings: Pathnamesent no--settings; aPathnameprompt file or plugin path failed at spawn. All four work;sig/widens (tools:+String;settings:,SystemPromptFile#path,SdkPluginConfig#path+Pathname) anddocs/options.mdfollows. If you pass
tools:as a String, the session is now restricted to the tools it names.8d74996):docs/configuration.mddescribes the accepted spellings, and says plainly what the CLIdoes with an invalid sandbox value (it discards the whole
--settingsvalue; the SDK does not surface it, only theCLI's
get_settingsresponse 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) andgemfiles/floor.gemfilebundles, under the RBS runtime checker and on bothRails 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_settingsis identical for everyspelling; 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: Pathnameapplies the file. An independent adversarial review checked the same.Not in this PR
get_settingsback after connect and warning or raising): apolicy 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 mappingtrueis a maintainer call. (In a snake_case Hash the same value is now sent as written and ignored, which keepsthe sandbox.)
sandboxsection insidesettings:(the CLI's own settings document); unknown sandbox keys givenboth as a Symbol and a String; the
default_optionsmerge of Hash options; sharing one renamer between hookoutputs and sandbox keys;
SHAPESis a snapshot of CLI 2.1.287 (a narrower future schema would need the tablere-checked — the probes take two minutes, no model call).
typevalues or near-miss spellings;failIfUnavailable: notadded, not changed.
CHANGELOG.md: this PR's entries are in the batch's combined CHANGELOG PR.Checklist
bundle exec rakepasses (specs + RuboCop)## [Unreleased]— in the batch's combined CHANGELOG PRdocs/configuration.md,docs/options.md, YARD onSandboxSettings,sig/, both skill trees)integration suite
🤖 Generated with Claude Code
https://claude.ai/code/session_01CNgMt2RjeBLyRP3CWFWNy2