[BUG] Workflow approval rejects benign CRLF for all file-backed invocations (name + scriptPath) on Windows

Status Open
Reported on v2.1.257
Maintainer reply None cached
Activity 1 comment · opened Sep 2, 2026

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

  1. 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 }
`
Confirm it is CRLF:
git ls-files --eol crlf-probe.jsi/lf w/crlf` (and that it has no control chars besides the CRLF pairs).

  1. Invoke it by path: Workflow({ scriptPath: "C:/.../crlf-probe.js" }) → fails with the schema error above (this is #73730).
  2. 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. scriptPath reads fresh, so LF fixes that path immediately.
  3. Pass the identical content inline: Workflow({ script: "<same source as a string>" }) → succeeds, but only because reading + re-emitting the file normalizes \r\n\n in the JSON payload (no raw \r survives). 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 --eoli/lf w/crlf; the remote stores LF); the approval inlines the raw bytes into updatedInput.script; the guard rejects \r.

Findings beyond #71553 / #73730:

  1. Uniform & content-based. A 7-line CRLF probe fails identically to a 510-line plugin workflow — size/file-independent, any \r. bypassPermissions does not skip it (schema validation of updatedInput, not a prompt). The PreToolUse hook payload shows clean {name, args} with no script field — the script is injected downstream at approval, so no hook can intercept or fix it.
  2. 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. scriptPath reads fresh, so LF fixes that path immediately.
  3. Inconsistent reach. A registered project-local .claude/workflows/*.js invoked by name runs despite CRLF, because that path doesn't inline raw bytes into the guarded field — while scriptPath and plugin-name both fail on the same bytes.
  4. 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.
  5. 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).

View original on GitHub ↗

This issue has 1 comment on GitHub. Read the full discussion on GitHub ↗