Workflow() tool rejects a clean ~79 KB script with control characters false positive

Status Open
Maintainer reply None cached
Activity 4 comments · opened Jul 11, 2026

Titre

Workflow() tool rejects a clean ~79 KB script with "script contains control characters that would be hidden in the approval dialog" (false positive)

Description

The Workflow() tool (Claude Agent SDK / Claude Code) consistently refuses to run a specific .js workflow script, failing before execution even starts, with:

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"
  }
]

This happens when invoking via scriptPath (not inline script):

Workflow({
  scriptPath: "<path>\\workflows\\traiter-cr.js",
  args: "CR-243-....md"
})

Why this looks like a false positive, not an actual control-character issue

The target file (traiter-cr.js, ~78,798 bytes / 1079 lines, UTF-8 with a leading BOM) was scanned exhaustively for any character that could legitimately trigger this check:

  • Full character-by-character scan of Unicode categories Control, Format, Surrogate, PrivateUse, OtherNotAssigned (including astral-plane characters/emoji decoded correctly as UTF-32 surrogate pairs) → 0 matches across all 77,118 decoded characters.
  • Targeted search for zero-width / bidi / invisible characters: U+200B, U+200C, U+200D, U+2060, U+FEFF (outside byte offset 0), U+2028, U+2029, U+00A0, U+200E, U+200F, U+202A–U+202E, U+2066–U+2069, U+00AD, and ESC (U+001B, including inside JS string literals) → 0 matches.
  • No unpaired UTF-16 surrogates.
  • Byte-level scan for raw bytes < 0x20 (excluding tab/LF/CR) and DEL (0x7F) → 0 matches.
  • BOM present only at file start (EF BB BF), no BOM occurrences mid-file.

Reproduction pattern observed

  • The error is 100% reproducible across multiple separate attempts, with different args (different target files referenced by the workflow), over multiple sessions — including after fully restarting the Claude Desktop app (a pending update was applied at restart; the bug persisted identically afterward).
  • The same script had worked successfully earlier in an earlier session, then started failing after later (legitimate, content-only) edits to the file — i.e., this is not "the file has always been broken," something about editing/re-saving triggered it.
  • Comparison with two other workflow scripts in the same directory that do run successfully via the same tool:

| File | Size | Result |
|---|---|---|
| traiter-cr.js | 78,798 bytes / 1079 lines | fails |
| revue-globale.js | 13,729 bytes | succeeds |
| traiter-toutes-cr.js | 5,120 bytes | succeeds |

The failing file is roughly 6× and 15× larger than the two working files, respectively. This size correlation (rather than any specific character found) is the strongest available signal.

Hypothesis

The permission-handler's script preview/validation step (used to render the approval dialog) may buffer or truncate the script content for display, and on large files this could be splitting a multi-byte UTF-8 sequence (e.g. an emoji or accented character) at the wrong byte boundary — producing an invalid/control-looking sequence that does not exist in the actual source file, but appears in whatever buffer the validation logic inspects. This could not be fully confirmed without visibility into the SDK's internal handling, but no alternative explanation survived the content-level investigation above.

Impact

Any workflow script that grows large enough to hit this (apparently size-correlated) threshold becomes permanently unusable via the Workflow() tool, with no actionable fix available on the content side — repeated content edits, restarts, and retries all reproduce the identical failure.

Environment

  • Claude Code / Claude Agent SDK, Windows 11
  • Script encoding: UTF-8 with BOM, CRLF/LF mixed (standard for the repo)

View original on GitHub ↗

4 Comments

netcrawler00 · 1 month ago

Update from a later session on the same repo (Moteur CR), confirming the size-correlation hypothesis with a new data point.

workflows/traiter-toutes-cr.js — listed above as 5,120 bytes / succeeds at the time of the original report — has since grown (legitimate content-only edits, several CRs) and now reproducibly fails top-level Workflow(scriptPath: ...) invocation with the identical error:

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"
  }
]

Re-scanned this file with the same two independent methods used for the original report (ASCII control-character range, and full .NET CharUnicodeInfo.GetUnicodeCategory classification over every decoded character) — 0 matches, same as before. So this isn't a one-off: the same false positive now affects a second, previously-clean file, purely as a function of size growth.

Updated size/outcome table (current state of the same 4 files, same repo):

| File | Size (bytes) | Result |
|---|---|---|
| revue-globale.js | 26,284 | succeeds |
| revue-boite-idees.js | 30,801 | succeeds |
| traiter-toutes-cr.js | 38,351 | fails (was 5,120 / succeeded at time of original report) |
| traiter-cr.js | 154,512 | fails (was 78,798 at time of original report) |

This narrows the failure threshold to somewhere between 30,801 and 38,351 bytes on this file set — consistent with the buffering/truncation-at-byte-boundary hypothesis already proposed (a fixed-size preview buffer used for the approval dialog would explain a stable byte threshold rather than a line-count or character-type correlation).

No mitigation found on the content side (again, exhaustive character-level scan comes back clean) — reinforcing that this needs a fix in the Workflow() tool's script-preview/validation path itself, not in the affected scripts.

netcrawler00 · 1 month ago

Follow-up (18/07/2026, ~6h après le commentaire précédent) : la corrélation pure à la taille du fichier ne tient plus.

Contexte : suite au commentaire précédent, nous avons réduit traiter-toutes-cr.js de 38 351 à 29 854 octets (compression de commentaires, déduplication de blocs de prompt identiques — refactoring pur, comportement fonctionnel inchangé, revue de code indépendante effectuée). Cette taille est inférieure à celle de revue-boite-idees.js (30 801 octets), qui passait la validation Workflow() sans problème d'après nos mesures précédentes.

Résultat du nouvel essai top-level : échec identique, même message d'erreur exact :

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" }
]

Nouveau scan indépendant (troisième méthode, après grep ASCII et classification .NET CharUnicodeInfo déjà effectués précédemment) : classification Unicode complète via Python3 unicodedata.category() sur chacun des 29 546 caractères décodés du fichier — 0 caractère de catégorie Control/Format/Surrogate/etc. hors CR/LF/TAB légitimes.

Donc : un fichier plus petit qu'un autre fichier qui passe la validation échoue quand même, sans caractère de contrôle détectable par trois méthodes de scan indépendantes. La taille seule n'explique pas la bascule observée — il doit y avoir un facteur additionnel (contenu spécifique déclenchant la troncature de buffer suspectée à une mauvaise frontière UTF-8, état de session, ou autre variable non encore isolée). Content de creuser davantage si des pistes de diagnostic supplémentaires sont utiles côté SDK.

netcrawler00 · 1 month ago

Second follow-up (18-19/07/2026): found the actual determining factor — it's the scriptPath string used for invocation, not the script's own byte content.

Reproduced 5 times, alternating strictly between two scriptPath values with byte-identical script content each time (only the invocation path differs — same file, same projectRoot data target unaffected):

| Content | scriptPath length | Result |
|---|---|---|
| File A (25,278 bytes) | 113 chars (shorter path) | fails |
| File A (25,278 bytes) | 153 chars (longer path) | succeeds |
| File B (25,451 bytes) | 113 chars (shorter path) | fails |
| File B (25,451 bytes) | 153 chars (longer path) | succeeds |
| Original file (29,854 bytes) | 153 chars (longer path) | fails |

Each row reproduced twice (10 total invocations), 100% consistent per combination. Files A and B are the same script split into two halves (a bisection attempt to isolate a "bad" section) — critically, neither half fails when combined with the shorter path, and both succeed when combined with the longer path, ruling out any specific line/character in either half being the culprit.

No simple "content length + path length = combined total" rule holds either: File A at the longer path (25,278 + 153 ≈ 25,431) succeeds, but File A at the shorter path (25,278 + 113 ≈ 25,391 — a smaller combined total) fails. And the original 29,854-byte file fails even at the longer (153-char) path that made the smaller files succeed.

This is consistent with (and sharpens) the buffer-truncation-at-a-fixed-offset hypothesis from earlier comments: if the approval-dialog preview concatenates or otherwise combines scriptPath with the script content before a fixed-size truncation, the scriptPath length shifts exactly where that cutoff falls inside the script text — landing mid-character (false positive) or on a clean boundary (pass) depending on the specific combination, not on any single measurable quantity (content size, path length, or their sum) in isolation.

Practical workaround found for our own use: for scripts near/above the apparent threshold, invoking via a scriptPath that happens to land in the "succeeds" zone (verified empirically per file) reliably works around the bug without any content changes — confirmed end-to-end on a real multi-agent pipeline run (full review → implementation → validation cycle, auto-committed) with zero errors. Not a fix, but useful confirmation that the failure is deterministic per (content, path) pair rather than flaky.

netcrawler00 · 1 month ago

Third follow-up (20-21/07/2026): found a simpler, more robust workaround than the scriptPath-tuning one above — bypass the validation path entirely by passing the script's content via the script parameter instead of scriptPath.

Same tool (Workflow()), same script (traiter-toutes-cr.js, since grown further to 34,139 bytes at the time of this test — still well within the range that reliably fails via scriptPath), only the invocation shape changes:

  • Workflow({ scriptPath: "<path to file>", args: {...} }) → fails immediately with the same "script contains control characters that would be hidden in the approval dialog" error.
  • Workflow({ script: "<full file content as a string>", args: {...} }) → validation succeeds on the first attempt, run starts normally.

Confirmed reproducible: this has since become our routine invocation method for this script and a second one (traiter-cr.js, 154KB+ at the time) across several real production runs over the following two days, with zero recurrence of the false positive — regardless of file size or the exact scriptPath that would otherwise land in the "fails" zone per our previous comment's table.

This narrows the likely location of the bug: whatever preview/truncation logic produces the false positive appears to sit specifically in the scriptPath → read-file → build-approval-preview path, and is not shared with the script (inline content) invocation path — otherwise we'd expect the same truncation-at-a-fixed-offset failure regardless of which parameter carries the content. If the two paths share the same validation function today, this would suggest the bug is actually upstream of it (e.g. in how the file is read/decoded before validation, rather than in the validation logic itself).

Practical takeaway for anyone hitting this: passing script (file content read and passed inline) instead of scriptPath is a strictly simpler workaround than hunting for a path length that happens to avoid the truncation boundary — no experimentation needed, no dependency on an unrelated invocation path's length.

For anyone wanting to reproduce this directly: the actual scripts referenced across all four comments on this ticket (current state, not the exact byte-for-byte versions from each historical measurement) are here: https://gist.github.com/netcrawler00/19dbc1de6ce091fc6c4fa753d0a1aa74

  • traiter-cr.js / traiter-toutes-cr.js — the two real, currently-maintained workflow files.
  • traiter-toutes-cr-phase-1.js / traiter-toutes-cr-phase-2.js — the bisection halves from the earlier comment (split of traiter-toutes-cr.js at its 29,854-byte state), kept as-is, not part of the maintained pipeline.