anthropics / anthropics/claude-code

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)

Open
#92,599 1 comment 0 reactions 0 assignees View on GitHub
area:auth area:permissions bug has repro platform:windows
Dominant language
Python
Stars
145k
Forks
23.1k
PR merge metrics
PR metrics pending

Description

## 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_KEY` in 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 ` · `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** into
`x-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 from
`revoked` (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
token`, so the scope delta cannot be the cause.

## 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 in
`claude.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 for
`toolDenialKind` and 21 for `permission-rule`, so the payload is plaintext-searchable. Combined with
`request_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` and
`refreshToken` 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:

1. A non-canonical `ANTHROPIC_BASE_URL` silently disables OAuth instead of erroring clearly.
2. **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 ` | `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.

---

## 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.

Contributor guide

No contributing guide indexed for this repository

Research direction

Start with the permission-validation path in the long-lived Claude Desktop session and reproduce the extension-dependent denial, then compare it with the headless claude -p entry point. Inspect claude.exe, app.asar, main.log, and ~/.claude/.credentials.json as described; done means validation authenticates correctly, Auto mode and hooks remain usable, and failed OAuth refreshes preserve valid credentials.

Written by the indexing model from the issue text.

Assessment

Domain
authentication, cli, desktop, security
Issue type
Bug
Difficulty
5/5
Estimated time
Over a week
Activity status
Active
Clarity
Mostly clear
Newbie friendliness
35/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.