Rule: never create an atom from external or unbounded input at runtime. The BEAM atom table is capped (about 1,048,576 entries by default) and atoms are never garbage-collected. Turning attacker-, CLI-, JSON-, env-, or model-registry-derived strings into atoms is a memory-exhaustion / node-crash vector: one crafted stream of distinct strings can take the node down.

Banned on untrusted input

Safe patterns

  1. Static lookup with string fallback — an explicit map (or case) from known strings to literal atoms, where unknown input stays a string. The canonical example in this SDK is ClaudeAgentSDK.Message subtype handling: a @system_subtypes module-attribute map consulted via Map.get(@system_subtypes, subtype, subtype). New wire frames must follow this pattern — the static map is the whole point.
  2. String.to_existing_atom/1 — only when the atom is provably pre-defined (for example a whitelist the module itself declares). See the private <code>ClaudeAgentSDK.Auth.TokenStore.parse_provider/1</code> helper for a whitelist variant with an explicit default.
  3. Default string keysJason.decode/1 without keys:; or keys: :atoms! if atom keys are truly required (existing atoms only).
  4. Keep it a string — MCP tool names, model IDs, and message subtypes do not need to be atoms at all.

Guardrails in this workspace

Two independent guards run in mix ci across claude_agent_sdk, cli_subprocess_core, and agent_session_manager:

  • Credo.Check.Warning.UnsafeToAtom, enabled in each repo's .credo.exs (scoped to lib/).
  • scripts/atom_guard.sh — an rg backstop that fails CI if a dynamic-atom pattern appears in lib/ without a same-line # atom-safe: review annotation.

Reviewed compile-time sites (for example the Module.concat/2 calls in the <code>ClaudeAgentSDK.Tool.deftool</code> macro expansion, which are bounded by the number of deftool declarations in source) carry both a # atom-safe: annotation and a credo:disable-for-next-line marker.