Claude Code 2.1.251: when the permission check ran too late

The month's biggest release fixes a whole family of bypasses: a symlink swapped after the green light, deny rules Grep walked straight past, a shell assignment that auto-approved itself. It also quietly flips CLAUDE_CODE_SUBAGENT_MODEL, which the docs still describe the old way.

claude-code changelog security permissions

One release since the last digest, 2.1.251 on August 28, and it is the fattest of the month: seventy-plus entries. Time to triage.

The through-line is never stated, but it shows up the moment you line the security fixes side by side. The problem was not that Claude Code lacked permission checks. It was that a handful of them ran at the wrong moment, or against the wrong path. A green light granted on one file, an open performed on another. A deny rule written for a directory, walked around through a symlink. A shell command auto-approved for a reason that has nothing to do with what it actually does. Underneath sits a second thread, quieter and more likely to break your setup: several settings no longer apply when they come from a project file.

The most important fix in the release:

Fixed file tools (Read, Write, Edit) following a symlink swapped inside the working directory after the permission check, which could read or write outside the approved location.

The textbook name for this is TOCTOU: time-of-check to time-of-use. The check happens against a path, the operation happens a moment later, and nothing guaranteed the path still pointed at the same thing in between. All it took was a concurrent process replacing ./project/notes.md with a link to ~/.ssh/id_rsa inside that window. Permission was granted for the first, the read landed on the second.

What makes this one worth reading closely is that the permissions docs were updated alongside it and go further than the changelog. They restate the baseline rule for links, which already existed: an allow rule applies only when both the symlink and its target match, while a deny rule applies as soon as either one matches. Then they add the new guarantee: when a tool opens an approved file, Claude Code "confirms the path still resolves to the location the permission check approved."

The errors page carries the part the changelog leaves out, and it is the part you will actually meet: the exact messages.

Refusing to read <path>: its symlink resolution changed after permission was checked
Refusing to write <path>: it is a symbolic link. Write to the link's target path instead
Refusing to search <path>: it could not be opened

The second one is the one to remember. Writing to a symlink is now refused outright; you have to aim at the target. If your repo keeps config files as links into a shared directory, the standard monorepo or dotfiles layout, your sessions will start refusing writes that worked yesterday. That is not a regression, that is the fix. The docs recommend resolving the link and working against the real path.

A fourth message deserves a look too: its permission check expired before it ran (too many concurrent file operations). Permission checks now have a shelf life, and a swarm of subagents hammering the disk can outlive it. If you see that one, it is contention, not an attack.

Grep and Glob walked straight past your deny rules

Second bypass, same family:

Fixed Grep and Glob not applying Read(...) deny rules to files reached through a symlinked search path.

Put plainly: you could have Read(~/.ssh/**) denied, a direct read correctly blocked, and a Grep pointed at a directory that led there through a link happily returning the contents. The docs now spell the rule out: "Grep and Glob search the directory the path argument resolves to. Claude Code applies Read deny rules to that directory."

There is a caveat worth internalizing, and it sits in that same page in plain sight: Claude Code makes a best-effort attempt to apply Read rules to the built-in tools that read files without being Read, Grep and Glob included, plus the @file mentions in your prompts and the open-file context a connected IDE shares. Best-effort is not guaranteed. If a directory must genuinely never be read, a deny rule is necessary but should not be your only wall: filesystem permissions remain the control that does not depend on a tool remembering to ask.

OPTIND=1/0 sailed through without asking

The most entertaining fix in the release, and the one that captures the theme best:

Fixed Bash permission checks auto-approving commands that assign an arithmetic expression to an integer shell variable (e.g. OPTIND=1/0, RANDOM=2+2); these now prompt for approval.

You can see the original reasoning: a variable assignment executes nothing, so why bother the user. Except arithmetic evaluation in a shell is not inert. It can trigger a division by zero, and more to the point OPTIND and RANDOM are special shell variables: overwriting them changes how getopts and the pseudo-random generator behave for the rest of the session. An innocuous-looking preamble slips through with no prompt, and the commands that follow no longer behave the way you think.

This is the third digest running where the Bash parser gets tightened, after the rule wider than its own text in 2.1.246. The pattern never changes: a syntactic category that looks harmless in the abstract, and is not harmless in a real shell.

Nearby, the release changes how Bash output files are created and read back when a command runs in the sandbox, so a sandboxed command can no longer redirect or replace them. The sandbox contained the command, not the channel its results came back through.

Two more in the same vein, briefly. The Workflow tool was reading a scriptPath outside what the session may read before the permission check ran, and quoting its contents back in the error message. And plugin commands declared in a marketplace entry could point outside the plugin directory; those paths are now rejected with a path-traversal error.

Settings that applied without you approving them

The release's second thread, and the one most likely to break something you own. Four entries, one idea: a project settings file, meaning a file any contributor to the repo can edit, had more authority than it should.

  • Tracing and raw request-body logging. Project settings could turn on detailed beta tracing or raw API body logging. On a public repo, that is one line of JSON away from writing your prompts and responses to a file. Fixed, and a lower-scope tracing endpoint no longer bypasses an OTLP collector pinned by managed settings.
  • ANTHROPIC_CUSTOM_HEADERS from managed or project settings now requires approval when it sets a credential, org, routing, or API-behavior header, with Authorization and Host leading the list. The variable's docs still say nothing about this approval; they only cover the Name: Value format and character validation.
  • Server-managed settings that terminate sandbox TLS, route sandbox traffic through your own proxy, inject credentials, or weaken sandbox isolation now require approval before they apply. The approval dialog itself now lists only what changed since you last approved, which finally makes it readable.
  • And the real trap: the env block of a project .claude/settings.json no longer sets CLAUDE_CONFIG_DIR, CLAUDE_CODE_TMPDIR, or TMPDIR/TMP/TEMP. Set those in your shell, user settings, or managed settings instead. If your repo relied on that to isolate a config or temp directory, it stopped working, and nothing warns you at startup: you just silently fall back to the defaults. Go read your project settings.json files today.

Two ergonomics fixes round out the set, and they solve a real annoyance: plugin and LSP install suggestions, plus the auto-mode default offer, now wait until you have sent or cleared whatever you are typing, so the Enter that sends your prompt cannot answer them on your behalf. Same fix on the unattended side, where the "make auto mode your default" offer no longer appears at all.

CLAUDE_CODE_SUBAGENT_MODEL no longer wins

A behavior change worth knowing, because it is silent and the docs have not caught up:

Changed CLAUDE_CODE_SUBAGENT_MODEL to set the default subagent model rather than override everything: an agent definition's model: and an explicit per-spawn model now take precedence over it.

The meaning of that variable inverts. It used to impose its model on every subagent regardless of what their definitions said. Now it is only a fallback. If you set it in your environment to pin your whole subagent fleet onto something cheaper, every agent carrying an explicit model: in its frontmatter just took its own model back. Depending on what is written there, your bill changed yesterday without notice.

The trap is that the subagents docs still describe the old ordering, and describe it very explicitly: they list model resolution with CLAUDE_CODE_SUBAGENT_MODEL in position one, ahead of the per-invocation parameter, then the frontmatter, then the main conversation's model. The changelog says the opposite for the first two positions. The changelog is the one that is right, it shipped yesterday. Do not trust that table until it is corrected; check against a real invocation instead.

The rest, briefly

  • Two new hooks, PreModelSwitch and PostModelSwitch, which can block, confirm, or annotate a model switch. SessionStart resume hooks additionally receive session staleness and the estimated re-cache cost. Note that all three are missing from the hooks docs as of writing: the exact input shape is not published, so you will be discovering it by hand.
  • /cost gains a per-session prompt-cache line: hit ratio, misses, tokens re-cached, warm or cold. Plus a matching prompt_cache object for status line scripts. This is the natural sequel to the three cache fixes in 2.1.248: now that the leaks are plugged, you get the instrument to see them.
  • /usage gains a spend limit bar, with a rate_limits.spend_limit status line field, for anyone behind a Claude apps gateway.
  • /effort now remembers your level per model, so each keeps its own setting when you switch. A related fix: Opus 5 requests were failing with "effort … is not supported when thinking is disabled" when effort was xhigh or max and thinking was off; effort is now sent as high in that case.
  • The default model for seat-based Enterprise subscriptions moves to Opus 5, matching the other premium plans.
  • Subagents and background sessions: a foreground subagent's tool calls now stream live to Remote Control clients (background subagents, which are the default, still show status only). TUI lag with many parallel subagents is fixed, progress ticks replace each other instead of piling up. A teammate's final answer finally reaches the team lead. And background sessions can again edit files inside a worktree they created themselves.
  • claude --help finally documents attach, logs, stop, respawn, and rm, and the --resume message for a running background session names the exact claude attach <id> command.
  • Size and speed: the native binary drops about 5 MB, plus another 2.5 MB from removing syntax highlighting for six rarely used languages (1c, gml, isbl, mathematica, maxima, sqf). CPU gets a break from cutting redundant UI re-renders.
  • Odds and ends: conversations no longer get stuck on "text content blocks must be non-empty" after a thinking-only turn. Transcripts are no longer silently overwritten when a directory change relocates a session onto an existing same-ID transcript. --input-format stream-json no longer merges client-injected tool calls sent without a message id. The footer PR badge calls the GitHub API directly on Bedrock, Vertex, and Foundry. And /radio becomes available on those same platforms.

What to take away

Update, and not for the features. The bypasses fixed here share one profile: each of them quietly disabled a control you believed was active, your deny rules in particular, the very ones you write for the files you care about. A permissions catch-up release is invisible in daily use, which is exactly why it gets postponed.

Then spend ten minutes on two concrete checks. Open your projects' .claude/settings.json and see whether the env block was setting TMPDIR or CLAUDE_CONFIG_DIR: those lines died yesterday, silently. And if you use CLAUDE_CODE_SUBAGENT_MODEL to keep costs down, re-read the model: fields in your agent definitions, because they are the ones deciding now.

Finally, if you write hooks, keep an eye on PreModelSwitch. Being able to block a model switch is the first serious control point over what a session actually spends. Just treat its shape as unstable until the docs describe it.

Pierre Rondeau

Pierre Rondeau

Developer and indie builder. I build products and automations with AI. Creator of Claude Hub.

LinkedIn