on main help sign in
docs: document lenient shell expansion and security model

Updates the README and a handful of struct docs to describe the new behavior: missing variables expand to empty, ${VAR:?message} is the way to require a value, empty header values are dropped, extra_body is a plain JSON passthrough, and crush.json should be treated as trusted code because any $(...) in it runs at load time with the user's shell privileges. Co-Authored-By: Charm Crush <crush@charm.land>

commitbcce66238bfe697f6ff8aef71197d8e4b19f426e authorChristian Rocha <christian@rocha.is> dateSun 3 May 2026, 17:02 (6 months ago) parent71aa8d1
2 files changed+46−9
README.md+25−6 internal/config/config.go+21−3
README.md+25−6
@@ -297,12 +297,31 @@ types: `stdio` for command-line servers, `http` for HTTP endpoints, and `sse`
297297for Server-Sent Events.
298298
299299Shell-style value expansion (`$VAR`, `${VAR:-default}`, `$(command)`, quoting,
300−and nesting (works in `command`, `args`, `env`, `headers`, and `url`, so
301−file-based secrets like work out of the box, so you can use values like
302−"$TOKEN"` and `"$(cat /path/to/secret/token)"``. Expansion runs through Crush's
303−embedded shell, so the same syntax works on all supported systems, including
304−Windows. Unset variables are an error; use `${VAR:-fallback}` to opt in to
305−a default.
300+nesting) works in `command`, `args`, `env`, `headers`, and `url`, so
301+file-based secrets work out of the box. You can use values like `"$TOKEN"`
302+or `"$(cat /path/to/secret/token)"`. Expansion runs through Crush's embedded
303+shell, so the same syntax works on every supported system, Windows included.
304+
305+Unset variables expand to the empty string by default, matching bash. For
306+required credentials, use `${VAR:?message}` so an unset variable fails loudly
307+at load time with `message` instead of silently resolving to empty:
308+
309+```json
310+{ "api_key": "${CODEBERG_TOKEN:?set CODEBERG_TOKEN}" }
311+```
312+
313+Headers (both MCP `headers` and provider `extra_headers`) whose value
314+resolves to the empty string are dropped from the outgoing request rather
315+than sent as `Header:`. That keeps optional env-gated headers like
316+`"OpenAI-Organization": "$OPENAI_ORG_ID"` clean when the variable is unset.
317+
318+Provider `extra_body` is a non-expanding JSON passthrough; put env-driven
319+values in `extra_headers` or the provider's `api_key` / `base_url`, all of
320+which do expand.
321+
322+> **Security note:** `crush.json` is trusted code. Any `$(...)` in it runs at
323+> load time with your shell's privileges, before the UI appears. Don't launch
324+> Crush in a directory whose `crush.json` you haven't reviewed.
306325
307326```json
308327{
internal/config/config.go+21−3
@@ -108,9 +108,22 @@ type ProviderConfig struct {
108108 // Custom system prompt prefix.
109109 SystemPromptPrefix string `json:"system_prompt_prefix,omitempty" jsonschema:"description=Custom prefix to add to system prompts for this provider"`
110110
111− // Extra headers to send with each request to the provider.
111+ // Extra headers to send with each request to the provider. Values
112+ // run through shell expansion at config-load time, so $VAR and
113+ // $(cmd) work the same way they do in MCP headers. A header whose
114+ // value resolves to the empty string (unset bare $VAR under
115+ // lenient nounset, $(echo), or literal "") is omitted from the
116+ // outgoing request rather than sent as "Header:". See PLAN.md
117+ // Phase 2 design decision #18.
112118 ExtraHeaders map[string]string `json:"extra_headers,omitempty" jsonschema:"description=Additional HTTP headers to send with requests"`
113− // Extra body
119+ // ExtraBody is merged verbatim into OpenAI-compatible request
120+ // bodies. String values are NOT shell-expanded: this is a plain
121+ // JSON passthrough so that arbitrary provider-extension fields
122+ // (numbers, nested objects, booleans) round-trip without a
123+ // recursive walker guessing at intent. If you need an env-var-
124+ // driven value at request time, put it in extra_headers, or in
125+ // the provider's top-level api_key / base_url, all of which do
126+ // expand. See PLAN.md Phase 2 design decision #16.
114127 ExtraBody map[string]any `json:"extra_body,omitempty" jsonschema:"description=Additional fields to include in request bodies, only works with openai-compatible providers"`
115128
116129 ProviderOptions map[string]any `json:"provider_options,omitempty" jsonschema:"description=Additional provider-specific options for this provider"`
@@ -174,7 +187,12 @@ type MCPConfig struct {
174187 DisabledTools []string `json:"disabled_tools,omitempty" jsonschema:"description=List of tools from this MCP server to disable,example=get-library-doc"`
175188 Timeout int `json:"timeout,omitempty" jsonschema:"description=Timeout in seconds for MCP server connections,default=15,example=30,example=60,example=120"`
176189
177− // TODO: maybe make it possible to get the value from the env
190+ // Headers are HTTP headers for HTTP/SSE MCP servers. Values run
191+ // through shell expansion at MCP startup, so $VAR and $(cmd)
192+ // work. A header whose value resolves to the empty string (unset
193+ // bare $VAR under lenient nounset, $(echo), or literal "") is
194+ // omitted from the outgoing request rather than sent as
195+ // "Header:". See PLAN.md Phase 2 design decision #18.
178196 Headers map[string]string `json:"headers,omitempty" jsonschema:"description=HTTP headers for HTTP/SSE MCP servers"`
179197}
180198