[BUG] Workflow by-name launch fails on Windows: resolved script is rewritten to CRLF and rejected by the approval gate
Summary
On Windows, launching a Workflow by name (Workflow({name: "..."}), resolving a script from .claude/workflows/) fails at the approval gate with a control-character validation error -- even when the installed script file is pure LF on disk. Invoking the same file by scriptPath succeeds. The by-name resolution path appears to re-persist the resolved script with CRLF line endings before the approval gate inspects it, so the gate rejects its own re-written copy.
Environment
- Claude Code: 2.1.193
- OS: Windows 11 (10.0.26200)
- Shell: Git Bash + PowerShell
Steps to reproduce
- On Windows, create an LF-only workflow script at
.claude/workflows/le-probe.js(verify zero\rbytes), e.g.:
``js``
export const meta = {
name: 'le-probe',
description: 'line-ending probe',
phases: [{ title: 'Probe' }],
}
phase('Probe')
log('ran')
- Invoke it by name:
Workflow({ name: "le-probe" })(or a registered/built-in name resolving from.claude/workflows/). - Invoke the same file by path:
Workflow({ scriptPath: ".claude/workflows/le-probe.js" }).
Expected
Both forms launch the workflow; an LF script with no carriage returns passes the approval gate.
Actual
- By name -> rejected at the permission gate:
````
script contains control characters that would be hidden in the approval dialog
(validation path: ["script"])
- By scriptPath -> launches and runs normally.
The only difference is the invocation form; the on-disk file is byte-identical and LF-only in both cases.
Diagnosis
The approval gate correctly rejects carriage returns (0x0D) as control characters that could hide content in the approval dialog -- that part is working as intended. The problem is upstream of the gate: the by-name resolver persists the resolved script to a session file, and on Windows that write uses text mode (translating \n->\r\n), so the script handed to the gate contains CRLF regardless of the source file's endings. scriptPath reuses the on-disk file directly (the launch result reports Script file: as the original path), so it never hits the re-write.
This is the same class of CRLF-on-Windows write issue documented in #19565 (Edit/Write) and #38887 (general CRLF handling), surfacing here in the Workflow name-resolution path.
Impact
Narrow but total where it lands: any project that ships named workflow scripts under .claude/workflows/ and runs them on Windows cannot launch them by name at all. scriptPath is a working workaround, but it bypasses the name registry.
Suggested fix
Persist the resolved by-name script with LF endings (write bytes, or newline=""/newline="\n"), so a clean LF workflow survives to the approval dialog intact.
3 Comments
Still open on v2.1.202 / Windows 11 / Claude Desktop.
Cross-referencing the
scriptPathsibling - #73730 (Workflow({scriptPath})false-fails on a CRLF file): same root guard (0x0Drejected), different invocation angle; the two don't currently link each other.One refinement on 2.1.202: contrary to "always rewritten to CRLF," an LF source + a session reload makes the by-name launch pass:
autocrlf=true) →Workflow({name})fails with the control-character error.git ls-files --eol→w/lf), same session →Workflow({name})still fails, butWorkflow({scriptPath})now passes.Workflow({name})now passes.So on 2.1.202 the residual behaves like a session-start capture rather than an unconditional per-call rewrite. Workaround fixing all three paths (by-name after a reload):
Fix direction matches #73730 — normalize CRLF→LF (or exclude
\r\npairs) before the hidden-character scan; and for by-name specifically, write the persisted session script in binary/LF mode.Still reproducing on v2.1.257 / Windows 11 / Claude Code CLI — hit it independently before finding this issue, so here are a few extra data points that narrow where the guard lives.
1. The guard is content-based and size-independent. A throwaway 7-line project-local workflow (
meta+ a one-linereturn {}, zeroagent()calls) forced to CRLF fails with the identical error as a 510-line plugin workflow — so it's every\r, not a size threshold or a specific file:2. The
scriptis injected downstream ofPreToolUse— no hook can intercept or fix it. APreToolUselogging hook capturing theWorkflowcall sees clean input with noscriptfield at all:The CRLF-bearing
scriptis materialized only at the approval/permission stage (theupdatedInputthe error names), after hooks run. So fixing the file /.gitattributes eol=lfis the only user-side lever — aPreToolUsehook workaround is structurally impossible.3.
bypassPermissionsdoes not skip it. Reproduced underpermission_mode: bypassPermissions; the guard is schema validation ofupdatedInput, which bypass doesn't skip (it skips prompts, not validation).4. Confirms LF source, CRLF checkout.
git ls-files --eolon the offending file →i/lf w/crlf.core.autocrlf=true(system) + plugins installed viagit clonemeans every LF-committed.jswithout eol protection lands CRLF on disk.5. The LF + reload sequence reproduces exactly as the top comment describes, which pins the residual as a load-time capture: stripping the file to LF in-session does nothing to
Workflow({name})(still fails), because the plugin workflow definition was read into memory at plugin-load; only a session reload (/reload-plugins) re-reads the now-LF file and makes by-name pass.Workflow({scriptPath})reads fresh and passes as soon as the file is LF — matching thescriptPathsibling #73730.6. Escaping path. A registered project-local
.claude/workflows/*.jsinvoked by name runs fine even while CRLF — that resolution path evidently does not inline raw file bytes into the guardedscriptfield the way plugin-nameandscriptPathdo. (Couldn't directly reproduce a launching CRLF project workflow, since registering a fresh one by name needs a session restart, but it's consistent with the load-time-capture behavior in #5.)Fix direction seconded: normalize
\r\n→\n(or exclude CR from CR/LF pairs) before the hidden-character scan onupdatedInput.script, and write the persisted session workflow script in LF/binary mode.Filed #91540 as a consolidated root-cause report for this control-character-on-CRLF guard. It covers both the
namepath (this issue) and thescriptPathpath (#73730) on Windows withcore.autocrlf=true, plus findings not in either ticket: plugin-load caching (LF-normalizing the file isn't enough for aname-resolved plugin workflow until a reload), a registered project-local-by-name path that escapes the guard, and Desktop-bundled-engine vs standalone-CLI version drift. Note the inline{script}workaround suggested on #73730 cannot cover this issue'snamecase (there's no source string to pass for a plugin/registered workflow). Proposed fix in #91540: normalize\r\n→\nbefore the hidden-character scan onupdatedInput.script, soname,scriptPath, and inline are all covered by one change.