[BUG] Workflow approval rejects benign CRLF for all file-backed invocations (name + scriptPath) on Windows
Preflight
- [x] Searched existing issues — this is related to but distinct from #71553 (
Workflow({name}), open) and #73730 (Workflow({scriptPath}), closed not-planned). It reports the shared root guard across both file-backed paths plus findings neither ticket has. - [x] Single bug report.
- [x] Current version — tested on Claude Code Desktop 1.40609.1 (bundled engine 2.1.255) and standalone CLI 2.1.257; latest published is 2.1.258, whose changelog does not mention anything related. The failure is structural (
autocrlf+ the approval guard), not version-specific.
What's Wrong?
On Windows with git core.autocrlf=true (the git default), a workflow file whose only "control characters" are the \r of CRLF line endings is rejected at Workflow-approval time with a schema-validation error, blocking the call entirely:
path: ["script"], code: "custom", message: "script contains control characters that would be hidden in the approval dialog"
autocrlf checks every LF-committed workflow file out as CRLF, so ordinary .claude/workflows/*.js and plugin-bundled workflows trip this. It affects both Workflow({ scriptPath }) and Workflow({ name }) — the approval inlines the file's raw on-disk bytes into updatedInput.script and the guard rejects the \r. The file is otherwise clean: every \r is part of a \r\n pair, no lone \r, no other \x00–\x1F, no C1/zero-width/bidi characters.
What Should Happen?
CRLF line endings are benign and ubiquitous on Windows. The approval guard should normalize \r\n→\n (or otherwise disregard the \r of a CR/LF pair) before its hidden-character scan, and the workflow should launch normally — the same as when the identical content is LF.
Error Messages/Logs
The permission handler returned updatedInput for Workflow that failed schema validation: [
{
"code": "custom",
"path": [ "script" ],
"message": "script contains control characters that would be hidden in the approval dialog"
}
]
Steps to Reproduce
- On Windows with
git config --get core.autocrlf=true, create a trivial no-op workflow file that is CRLF on disk. Either commit it LF and let autocrlf smudge it, or write it with CRLF:
``js`
// crlf-probe.js (CRLF / \r\n line endings)
export const meta = { name: 'crlf-probe', description: 'no-op' }
return { ok: true }
git ls-files --eol crlf-probe.js
Confirm it is CRLF: → i/lf w/crlf` (and that it has no control chars besides the CRLF pairs).
- Invoke it by path:
Workflow({ scriptPath: "C:/.../crlf-probe.js" })→ fails with the schema error above (this is #73730). - Invoke a CRLF plugin/registered workflow by name:
Workflow({ name: "<plugin>:<workflow>" })→ same schema error (this is #71553). Note: for a name-resolved plugin workflow, LF-normalizing the file is not enough in-session — the engine serves the copy cached at plugin load; a plugin/session reload is required before the by-name call succeeds.scriptPathreads fresh, so LF fixes that path immediately. - Pass the identical content inline:
Workflow({ script: "<same source as a string>" })→ succeeds, but only because reading + re-emitting the file normalizes\r\n→\nin the JSON payload (no raw\rsurvives). It is not a real exception to the guard, and it cannot be used for plugin/named workflows (there is no source string to pass).
Claude Model
Not sure / Multiple — model-independent; the guard fires at approval, before the workflow runs.
Is this a regression?
Yes, this worked in a previous version — file-backed Workflow (both name and scriptPath) launched and completed on an earlier build (see Last Working Version and Regression evidence below). Caveated: the exact break point isn't pinned, and CRLF-specific acceptance on the older build isn't directly recoverable.
Last Working Version
Confirmed working on 2.1.224 (2026-08-07) — a lower bound, not necessarily the last good version. Versions 2.1.225–2.1.254 are untested, so the exact break point is unknown.
Claude Code Version
Claude Code Desktop 1.40609.1 (bundled engine 2.1.255); standalone CLI 2.1.257. Latest published is 2.1.258 (changelog addresses nothing related). Note: the Desktop app bundles its own engine, separate from the CLI, and a Desktop local routine runs the bundled engine — so fix-verification must name the Desktop/engine build, not just claude --version.
Platform
Anthropic API
Operating System
Windows
Terminal/Shell
Claude Code Desktop app (bundled engine); also reproduces via the CLI in Windows Terminal / PowerShell.
Additional Information
Root cause (verified): autocrlf=true rewrites LF→CRLF on checkout (git ls-files --eol → i/lf w/crlf; the remote stores LF); the approval inlines the raw bytes into updatedInput.script; the guard rejects \r.
Findings beyond #71553 / #73730:
- Uniform & content-based. A 7-line CRLF probe fails identically to a 510-line plugin workflow — size/file-independent, any
\r.bypassPermissionsdoes not skip it (schema validation ofupdatedInput, not a prompt). ThePreToolUsehook payload shows clean{name, args}with noscriptfield — the script is injected downstream at approval, so no hook can intercept or fix it. - Plugin-load caching. For
name-resolved plugin workflows, LF-normalizing the file alone does not fix it in-session; only a plugin/session reload (re-reading the LF file) does.scriptPathreads fresh, so LF fixes that path immediately. - Inconsistent reach. A registered project-local
.claude/workflows/*.jsinvoked by name runs despite CRLF, because that path doesn't inline raw bytes into the guarded field — whilescriptPathand plugin-nameboth fail on the same bytes. - The inline
{script}workaround (from #73730) doesn't generalize — you can't pass a plugin or registered workflow inline, so #71553 (name) has no escape hatch. - Desktop ≠ CLI build. The Desktop app bundles its own engine, independent of the standalone CLI; state the tested Desktop/engine build when verifying a fix.
Regression evidence: on a Windows machine, a registered project-local file-backed workflow launched and completed on CLI 2.1.224 (2026-08-07) via both name and scriptPath — the scriptPath run is the datapoint that maps directly to #73730. Two honest caveats: (a) 2.1.224 is a confirmed-working lower bound, not a proven last-good version (2.1.225–2.1.254 untested); and (b) because persisted script copies are engine-normalized, this shows the file-backed mechanisms worked then, not that a CRLF file specifically passed the guard then — it supports the regression framing without being a CRLF-accepted demonstration.
Proposed fix: normalize \r\n→\n (or exclude the \r of a CR/LF pair) before the hidden-character scan on updatedInput.script, so name, scriptPath, and inline are all covered by one change.
References: #71553 (name, open) · #73730 (scriptPath, closed not-planned).
This issue has 1 comment on GitHub. Read the full discussion on GitHub ↗