[BUG] Workflow by-name launch fails on Windows: resolved script is rewritten to CRLF and rejected by the approval gate

Status Open
Reported on v2.1.193
Maintainer reply None cached
Activity 3 comments · opened Jun 26, 2026

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

  1. On Windows, create an LF-only workflow script at .claude/workflows/le-probe.js (verify zero \r bytes), e.g.:

``js
export const meta = {
name: 'le-probe',
description: 'line-ending probe',
phases: [{ title: 'Probe' }],
}
phase('Probe')
log('ran')
``

  1. Invoke it by name: Workflow({ name: "le-probe" }) (or a registered/built-in name resolving from .claude/workflows/).
  2. 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.

View original on GitHub ↗

3 Comments

agriffaut · 3 months ago

Still open on v2.1.202 / Windows 11 / Claude Desktop.
Cross-referencing the scriptPath sibling - #73730 (Workflow({scriptPath}) false-fails on a CRLF file): same root guard (0x0D rejected), 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:

  1. Workflow file checked out CRLF (Windows autocrlf=true) → Workflow({name}) fails with the control-character error.
  2. Convert the file to LF (git ls-files --eol → w/lf), same session → Workflow({name}) still fails, but Workflow({scriptPath}) now passes.
  3. Reload the session → 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):

.claude/workflows/*.js text eol=lf

Fix direction matches #73730 — normalize CRLF→LF (or exclude \r\n pairs) before the hidden-character scan; and for by-name specifically, write the persisted session script in binary/LF mode.

kaheidt · 1 month ago

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-line return {}, zero agent() 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:

path: ["script"], code: "custom",
message: "script contains control characters that would be hidden in the approval dialog"

2. The script is injected downstream of PreToolUse — no hook can intercept or fix it. A PreToolUse logging hook capturing the Workflow call sees clean input with no script field at all:

"tool_name": "Workflow",
"tool_input": { "name": "<plugin>:<workflow>", "args": "..." }

The CRLF-bearing script is materialized only at the approval/permission stage (the updatedInput the error names), after hooks run. So fixing the file / .gitattributes eol=lf is the only user-side lever — a PreToolUse hook workaround is structurally impossible.

3. bypassPermissions does not skip it. Reproduced under permission_mode: bypassPermissions; the guard is schema validation of updatedInput, which bypass doesn't skip (it skips prompts, not validation).

4. Confirms LF source, CRLF checkout. git ls-files --eol on the offending file → i/lf w/crlf. core.autocrlf=true (system) + plugins installed via git clone means every LF-committed .js without 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 the scriptPath sibling #73730.

6. Escaping path. A registered project-local .claude/workflows/*.js invoked by name runs fine even while CRLF — that resolution path evidently does not inline raw file bytes into the guarded script field the way plugin-name and scriptPath do. (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 on updatedInput.script, and write the persisted session workflow script in LF/binary mode.

kaheidt · 1 month ago

Filed #91540 as a consolidated root-cause report for this control-character-on-CRLF guard. It covers both the name path (this issue) and the scriptPath path (#73730) on Windows with core.autocrlf=true, plus findings not in either ticket: plugin-load caching (LF-normalizing the file isn't enough for a name-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's name case (there's no source string to pass for a plugin/registered workflow). Proposed fix in #91540: normalize \r\n→\n before the hidden-character scan on updatedInput.script, so name, scriptPath, and inline are all covered by one change.