Write/Edit denied by permission-validation with 401 "API key is invalid" — gated purely by file extension, and it also disables Auto mode (2.1.258)
Summary
Built-in Write/Edit are denied by the permission-validation step, which fails its own
authenticated call with a 401. On 2.1.258 this is deterministic and selected purely by file
extension: script-like targets always fail, a small set of data/text extensions always pass.
Error during validation: Failed to authenticate. API Error: 401
{"type":"error","error":{"type":"authentication_error","message":"API key is invalid."},"request_id":null}
The transcript entry carries toolDenialKind: "permission-rule" and request_id: null.
Environment
- Claude Code 2.1.258, bundled in Claude Desktop 1.44121.4.0 (MSIX), Windows 11 Pro 26200
- Claude Max, OAuth login. No
ANTHROPIC_API_KEYin Process, User or Machine scope - Session is a delegated child:
CLAUDE_CODE_CHILD_SESSION=1,CLAUDE_CODE_ENTRYPOINT=claude-desktop - First reported on 2.1.219, where the message was
OAuth access token has been revoked.
Reproduction
In a long-lived Desktop session, Write the same body to files differing only by extension:
| target | result |
|---|---|
| .mjs .cjs .js .ts .py .ps1 .sh .bat .jsonl, and extension-less | 401, every time |
| .md .txt .json .html .yaml | passes validation |
14 targets, one turn, neutral scratchpad directory, identical content. .ts and .py had
464/0 and 295/0 success/failure in a July transcript scan; they now fail every time.
Note .json passes while .jsonl fails, and .gitignore fails. So the gate is not "is this
executable" — it behaves like an allowlist of known-safe extensions, and everything else is
routed through the failing validation path.
Why we think the credential is wrong, and how far we got
We could not capture the failing request, so we decoded the response instead. Through a local
logging proxy we issued seven real requests to /v1/messages, one per auth-header shape. The API
returns a distinct message per shape:
| what was sent | response |
|---|---|
| no auth header · empty x-api-key · whitespace x-api-key · empty authorization | x-api-key header is required |
| non-empty but invalid x-api-key | API key is invalid. ← what the failing write returns |
| Authorization: Bearer <invalid> · Bearer (empty) | Invalid bearer token |
Three classes, three messages, no overlap. Therefore the failing call sends an x-api-key header
with a non-empty value the API rejects — not a missing header, not an empty one, and not the
OAuth Bearer path.
Leading hypothesis (unproven): the validation layer puts the OAuth access token intox-api-key instead of Authorization: Bearer. That value is non-empty, well-formed, and invalid
as an API key, producing exactly this message — and it would explain the wording change fromrevoked (2.1.219) to invalid (2.1.258). Confirming it needs the header value's length, which
we do not have.
This also rules out a scope theory we had held: the session advertises four OAuth scopes against
five on disk (missing user:mcp_servers), but a Bearer request would return Invalid bearer, so the scope delta cannot be the cause.
token
Additional measured facts
1. It fires under bypassPermissions. Independently reproduced in a second session. Permission
mode is not a factor.
2. It precedes PreToolUse hooks. Targets that passed validation were then stopped by a local
PreToolUse hook; script targets returned 401 and the hook never ran at all. A user therefore cannot
mitigate this locally — the failure is above the hook layer.
3. It disables Auto mode. Auto cannot approve anything in these sessions, because the approval
path depends on the same failing validation call. One bug, two visible surfaces.
4. The error string is not in the CLI at all. Error during validation appears nowhere inclaude.exe 2.1.258 — not literally, not as a template — nor in the Desktop app.asar. This is a
controlled negative, not a failed search: the same scan of the same binary returns 38 hits fortoolDenialKind and 21 for permission-rule, so the payload is plaintext-searchable. Combined withrequest_id: null and zero authentication_error entries in the live main.log, the denial appears
to be assembled above the CLI rather than surfaced from it.
5. The same account and binary succeed outside the Desktop session. A headless claude -p run
on the same machine with the same 2.1.258 executable wrote a .mjs file with Write and no error.
The discriminator is the session, not the account, version, or extension.
6. It is not rare. Filtering transcripts for live tool errors (excluding quotes and attachments)
gives 36 denials across 16 sessions in 36 hours on one machine. Zero of those 36 landed on a
content-type target, so the extension explains 100% of them.
Impact
Writes are pushed to shell fallbacks that bypass all of the user's configured PreToolUse policy
hooks, and because the failure happens before any hook runs, no hook can compensate. As of 2.1.258
the harness itself advises using Bash instead of Write/Edit while auto mode is active, so the bypass
is now the recommended path rather than an improvised one.
Expected
Either the validation call authenticates the way the main loop does, or a 401 there routes to
re-authentication rather than surfacing as a per-tool permission denial.
---
Second, separable defect: a failed OAuth refresh wipes still-valid credentials
Found while attempting to capture the request. Pointing ANTHROPIC_BASE_URL at a local listener and
running claude -p printed:
Failed to authenticate: OAuth session expired and could not be refreshed
with zero TCP connections to that listener — the client abandons OAuth before opening a socket
when the base URL is not canonical. Afterwards ~/.claude/.credentials.json had accessToken andrefreshToken as empty strings and expiresAt: 0, while refreshTokenExpiresAt was still
valid for another ten days. mcpOAuth and the stored scopes were untouched, so this was a targeted
clear of the OAuth block rather than file corruption. Recovery required an interactive /login.
Two separable problems:
- A non-canonical
ANTHROPIC_BASE_URLsilently disables OAuth instead of erroring clearly. - A refresh failure destroys credentials that had not expired. This is the damaging one: a
transient or misconfigured endpoint should not be able to log a user out permanently.
Capture routes we eliminated, so you don't repeat them
| route | result |
|---|---|
| Client logs (main.log) | zero matches for the error strings — the call is not logged |
| claude --debug in a subprocess | the subprocess does not reproduce the failure; it writes .mjs fine |
| Proxy via ANTHROPIC_BASE_URL | redirecting the base URL is what disables OAuth — it removes the call it was meant to observe |
| NODE_OPTIONS=--require <shim> | claude.exe is a Bun single-file executable and ignores it. Control: the same shim loads under plain node |
What remains is transparent interception at the OS level (hosts entry plus a trusted CA), which we
have not done. Given fact 4 above, we would also expect the request to originate from the host layer
rather than from claude.exe, which is likely why in-process instrumentation of the CLI cannot see it.
---
<!-- contributed by bughunt-271 (row 271), 2026-09-07 — two facts absent from this report; nothing above was edited -->
Additional measurement A — candidate mechanism, from strings in the shipped binary
In claude.exe 2.1.258 a mid-turn classifier config sits directly beside the auth-failure matchers, in the
same chunk (byte offset ~104428500): tengu_bg_classifier_config with useSmallFastModel,disableThinking, midTurnLlmDebounceMs, lastClassifyAt, capturedIntent, permissionBridgeSubscribed —
adjacent to the regexes ^Failed to authenticate. , ^Please run /login and ^Not logged in$.CLAUDE_PREVIEW_CLASSIFIER_FLOOR=1 and CLAUDE_CODE_CLASSIFIER_SUMMARY=0 are set in the Desktop session
environment and not in a plain CLI one. This is consistent with the denial originating in a small-fast-model
classifier call made before the write, rather than in the main conversation path — which is why the session's
own inference keeps working in the same turn.
Additional measurement B — the denial is slow, and the 2026-07 figure needs the same caveat
Across 15 Write calls in one session: the 10 validation denials have a median message-to-message delta of
363s (min 177s, max 920s) and none returned in under 5 seconds. Local PreToolUse hook denials in the
same session median 2.1s, with 4 of 5 under 3 seconds.
Caveat stated plainly, because it cuts both ways: transcript deltas include model generation time and are not
tool latency — one local hook denial in the same data registered 194,830ms, which no local hook can actually
take. So no single number here is a duration. What survives the caveat is the separation: 0 of 10 versus 4 of 5.
The earlier 2026-07 measurement of ~9m15s per denial came from the same method and should be read the same way —
direction, not precision. Both need a real timer to pin down.
Practical consequence for anyone reproducing this: each denied attempt costs minutes, so a retry loop on a
denied write is expensive, and a detector that probes by attempting a write at session start is not free.
This issue has 1 comment on GitHub. Read the full discussion on GitHub ↗