Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions claude/roles/base.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,23 @@
// session logs
"showThinkingSummaries": true,

// Pin the shell so the system prompt tells the truth about it.
//
// The Bash tool only ever runs bash or zsh. Its resolver reads $SHELL and,
// when that is neither (our login shell is fish), silently falls back to zsh
// (/bin/zsh on macOS). But the prompt's "Shell:" line is built from raw
// $SHELL verbatim, so Claude is told "fish" while commands actually run in
// zsh. Result: Claude blames fish for zsh behavior (e.g. `!` history
// expansion, quoting) that fish never produced.
//
// Setting SHELL=/bin/zsh makes that prompt line match reality (it already
// ran in zsh), so the misattribution stops. The Bash tool cannot run fish
// regardless; this only corrects what Claude is told. See ADR 0038.
// https://github.com/anthropics/claude-code/issues/68349
"env": {
"SHELL": "/bin/zsh",
},

// Permissions: base safety rules
"permissions": {
"defaultMode": "acceptEdits",
Expand Down
115 changes: 115 additions & 0 deletions doc/adr/0038-pin-bash-tool-shell-via-shell-env.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
# 38. Pin the Bash tool shell via SHELL env

Date: 2026-06-17

## Status

Accepted

## Context

Our login shell is fish. Claude Code's Bash tool does not, and cannot, run
fish. The two facts collide in a way that quietly degrades Claude's reasoning.

Reading the bundled JS (v2.1.178) shows two separate functions both read
`process.env.SHELL` and then do opposite things with it:

- **The system prompt's `Shell:` line.** A helper builds the line as
`Shell: ${value}`, where `value` is `"zsh"` or `"bash"` only when `$SHELL`
contains those substrings, and otherwise the raw `$SHELL` verbatim. fish is
not special-cased, so the prompt prints `Shell: /opt/homebrew/bin/fish`.
- **The Bash tool's shell resolver.** It accepts a shell path only if it
contains `bash` or `zsh` (after an optional `CLAUDE_CODE_SHELL` override,
which is validated the same way). When `$SHELL` is neither (fish), it is
dropped from the candidate list entirely and the resolver falls back to a
detection list ordered `zsh`, then `bash`, across `/bin`, `/usr/bin`,
`/usr/local/bin`, `/opt/homebrew/bin`. On macOS that resolves to `/bin/zsh`.

Net effect: the prompt tells Claude the shell is **fish**, while every Bash
command actually runs in **zsh** (confirmed live: `ZSH_VERSION=5.9`,
`$SHELL` inside the tool is `/bin/zsh`). Claude has no signal that the
substitution happened.

The observable symptom is misattribution. When a command misbehaves on
quoting, globbing, or `!`, Claude reaches for the one shell name it was given
and blames fish, even though fish never ran the command. The clearest tell:
`!`-mangling is zsh history expansion, a thing fish does not even do. Past
sessions also "explained" a grep `--include` failure as fish not expanding a
flag, which is not a shell concern at all. The wrong mental model produces
confidently wrong diagnoses.

The Bash tool genuinely cannot be made to run fish (it sources shell snapshots
written in bash/zsh syntax: `shopt`, `setopt NO_EXTENDED_GLOB`, `.zshrc`). So
the fix is not to change the shell. It is to stop lying to Claude about which
shell it already uses.

This is a known upstream gap: anthropics/claude-code#68349 documents the same
resolver behavior and the undocumented `CLAUDE_CODE_SHELL` override.

## Decision

Set `SHELL=/bin/zsh` in the `env` block of `claude/roles/base.jsonc`, so it
applies to every role.

`claudeconfig.sh` merges `base.jsonc` into `~/.claude/settings.json` (see
[ADR 0013](0013-claude-code-configuration-management.md)), and Claude Code
applies the `env` block to `process.env` at session init (the same mechanism
[ADR 0031](0031-role-scoped-agent-git-identity.md) uses for
`GIT_CONFIG_GLOBAL`). Both functions above then read `/bin/zsh` instead of the
fish path:

- The prompt's `Shell:` line normalizes it to `Shell: zsh`. Honest now.
- The resolver was already selecting `/bin/zsh`, so execution is unchanged.

The change corrects what Claude is _told_, not what it _does_. `/bin/zsh` is the
exact binary the resolver lands on, and it exists on every macOS host. The
resolver guards each candidate (including a pinned `$SHELL`) with an existence
check, so even on a host without `/bin/zsh` the value falls back to detection
rather than breaking commands.

### Alternatives Considered

1. **`CLAUDE_CODE_SHELL=/bin/zsh` instead of `SHELL`**

- Pros: the purpose-built override for the Bash tool's shell
- Cons: it only feeds the resolver, which already picks zsh, so it changes
nothing observable. The prompt's `Shell:` line reads `SHELL`, not
`CLAUDE_CODE_SHELL`, so it would stay wrong
- Rejected: wrong knob for the actual problem (the misleading prompt line)

2. **Do nothing, rely on a memory note telling Claude "Bash runs zsh, not fish"**

- Pros: no config change; works in sessions where the note is loaded
- Cons: depends on the note being recalled every session and on every
machine; the prompt keeps actively asserting "fish" underneath it
- Rejected as the sole fix: treats the symptom, leaves the false signal in
place. Still worth keeping as a belt-and-suspenders

3. **Switch the login shell to zsh**
- Pros: removes the mismatch at the source
- Cons: throws out the fish setup this whole repo is built around for a
problem scoped entirely to one tool's prompt line
- Rejected: wildly disproportionate

## Consequences

### Positive

- The prompt's `Shell:` line matches the shell that actually runs commands, so
the fish-blaming misattribution stops
- No change to command execution: the Bash tool already ran zsh
- Applies to every role via `base.jsonc`, including fresh machines, without
per-role setup

### Negative

- `SHELL` is now overridden for the Claude process and everything it spawns.
Interactive fish is launched by the terminal, not by Claude, so it is
untouched. But any hook, MCP server, or `$SHELL -c` subprocess that keys off
`$SHELL` now sees zsh instead of fish. This is more internally consistent
(everything is zsh), not less, but it is a real behavior change
- Takes effect on the next fresh session, not the current one, since `env` is
applied once at session init
- The pinned path assumes macOS. The resolver's existence check makes a missing
path safe (it falls back to detection), but the literal `/bin/zsh` is a
macOS-shaped choice
1 change: 1 addition & 0 deletions doc/adr/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,3 +37,4 @@
- [35. canonical-dotpickles-role-names](0035-canonical-dotpickles-role-names.md)
- [36. fail-loud-role-resolution](0036-fail-loud-role-resolution.md)
- [37. validate-agent-ssh-identity-in-claudeconfig](0037-validate-agent-ssh-identity-in-claudeconfig.md)
- [38. pin-bash-tool-shell-via-shell-env](0038-pin-bash-tool-shell-via-shell-env.md)
Loading