Workflow approval handler rejects scripts by total size, independent of line length ("hidden control characters" false positive)

Status Open
Reported on v2.1.239
Maintainer reply None cached
Activity 0 comments · opened Aug 22, 2026

Summary

The Workflow tool's approval handler rejects scripts once they pass a total size threshold of somewhere between 850 bytes and 2.1 KB, with no long lines and no unusual characters involved. The rejection message names control characters in files that contain none:

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 is a configuration issue in your canUseTool callback, PermissionRequest hook, or
permission-prompt tool - updatedInput must satisfy the tool's input schema.
The tool input from the model was valid.

This is filed separately from #81640 on purpose. #81640 describes a line-length trigger, lines longer than about 128 characters. The repro below keeps every line at 80 to 83 characters and still fails, so line length is not required. #82576 reached the same conclusion from a different direction, bisecting string-literal length and finding a size trigger between roughly 2.2 KB and 5.2 KB that restructuring into many short literals did not avoid, but it was closed as a duplicate of #81640, whose body does not describe the size case. So there is currently no open issue documenting this trigger.

Environment: Claude Code 2.1.239, Windows 11, PowerShell.

Reproduction

Five files, each one nothing but a meta block, some comment filler, and a trivial return. Every filler line is identical in form and 80 characters wide:

// filler line 0000 aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa

Invoked as Workflow({scriptPath: "<file>", args: {probe: 1}}):

| file | bytes | lines | longest line | result |
|---|---|---|---|---|
| probe.js | 230 | 10 | 83 | PASS |
| pad900.js | 850 | 19 | 83 | PASS |
| pad2kb.js | 2,105 | 35 | 80 | FAIL |
| pad6kb.js | 6,155 | 85 | 80 | FAIL |
| pad12kb.js | 12,311 | 161 | 80 | FAIL |

Between pad900.js and pad2kb.js the only thing that changes is how many filler lines there are.

Complete failing file, pad2kb.js reduced to its essentials. Adding filler lines until it crosses roughly 1 KB flips it from PASS to FAIL:

export const meta = {
  name: 'padprobe',
  description: 'size bisect probe',
  phases: [{ title: 'Probe' }],
}

// filler line 0000 aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
// filler line 0001 aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
// ... repeat to roughly 2 KB ...

phase('Probe')
log('ran')
return { ok: true }

What was ruled out

Every test file is pure ASCII. Character-by-character scan in Python before each call: zero bytes below 0x20 other than newline, no tabs, no carriage returns, nothing at 0x7F or above, and zero backslashes anywhere in the file.

It is also not user configuration, despite what the message says. In the session where this reproduced, ~/.claude/settings.json had exactly one PreToolUse hook and its matcher was Bash|mcp__remote-agent__run_remote|mcp__remote-agent__run_script, which does not match Workflow. No canUseTool callback, no permission-prompt tool, and no Workflow entries in the deny list. defaultMode was auto.

Inline script behaves differently

The same content passed inline is accepted well past the point where scriptPath refuses it:

| how passed | bytes | lines | longest line | result |
|---|---|---|---|---|
| scriptPath | 2,105 | 35 | 80 | FAIL |
| inline script | 3,202 | 48 | 95 | PASS |
| inline script | 11,740 | 284 | 209 | PASS |

That last row is worth noting against #81640 as well: a 209-character line passes without complaint when the script arrives inline.

This matches what the error text says. It reports that the model's input was valid and that validation failed on the updatedInput the handler returned, so the failure appears to be in whatever the approval path builds for the dialog when it reads a file, rather than in the script or in scriptPath itself.

Workarounds

  1. Launch once with inline script. The result reports a scriptPath for the workflow-managed copy of that script, and later calls against that path are accepted, including with different args. One inline emission, any number of launches.
  2. Keep the script small. Moving agent prompt text out into .md files that the agents read at runtime took a real workflow from 54 KB to 14 KB. That is a better structure anyway, but it should not be forced by an approval dialog.

Impact

scriptPath is unusable for the first launch of any non-trivial workflow, since the threshold sits below 2 KB. For reference, the reference template shipped in Anthropic's own docs and examples for this tool is well past that.

The message also costs time in the wrong direction. It names control characters, so you scan the file. Then it names canUseTool, PreToolUse hooks and permission-prompt tooling, so you audit your settings. Neither is the problem, and the size relationship is not something the message hints at.

Reported with Claude Code.

View original on GitHub ↗