Skip to content

Fix positional JSON key confusion in the hook dispatch loops, and cap JIT_MISSING_REQUIRES - #430

Merged
fdaviddpt merged 2 commits into
mainfrom
fix/426-427-json-key-value-confusion
Sep 19, 2026
Merged

fdaviddpt merged 2 commits into
mainfrom
fix/426-427-json-key-value-confusion

Conversation

@fdaviddpt

Copy link
Copy Markdown
Contributor

Two AI-generated findings from a Claude Security whole-repository scan (report CLAUDE-SECURITY-20260919-101841/, findings F1/F2/F4 for #426, F3 for #427), reasoned from source reading rather than executed by the issue author; independently confirmed by reading the code before implementing.

#426 -- a tool_input string value can pose as a JSON key

pre-tool-hook.sh, pre-path-hook.sh and post-tool-hook.sh parsed their JSON payload positionally via jit_json_fields() (common.sh): any quoted field at an even split position was treated as a candidate key, with no check it sat at real key position (followed by :) and no brace-depth tracking. A tool_input STRING VALUE equal to tool_name, command, file_path, pattern, skill or subagent_type could therefore repoint the field it named, last-wins, defeating every mode: block / require / forbid rule byte-identically to a genuine non-match.

Fixed with a new jit_hook_fields() (common.sh) that reads structurally: a string is a key only when the next raw piece begins with :, and brace depth is tracked so a value is only read at the correct nesting level -- top level (depth 1) for tool_name, directly inside the top-level tool_input object for everything else -- first-wins throughout. All three hooks' dispatch loops were rewritten to call it.

A self-review finding (Explore reviewer spawn) caught a second-order variant of the same bug in the fix itself: ti_depth was locked onto the FIRST key anywhere in the payload spelled tool_input, regardless of what depth that key itself sat at, so a decoy nested object also named tool_input could hijack it. Fixed by tracking the depth at which the candidate key was itself read (pending_key_depth) and requiring it equal 1 before accepting -- mirroring the TOP path's own hardcoded check. Landed as a separate, reviewable commit.

Closes #426

#427 -- JIT_MISSING_REQUIRES had no byte cap

JIT_MISSING_REQUIRES (built by jit_missing_requires(), common.sh) was the one list crossing an exec boundary (pre-tool-hook.sh hands it to awk as a single -v argument) with no byte cap, unlike five sibling lists. An oversized or malformed committed requires: value could trip E2BIG and refuse -- or, on an unwritable TMPDIR, silently pass -- every tool call in a session.

Fixed: jit_missing_requires() now refuses any requires: value that is not a bare binary name (^[A-Za-z0-9._+-]{1,255}$) before adding it, and caps the accumulated list at 4096 bytes with a one-time truncation sentinel. rebuild-tsv.sh applies the same check at index time, refusing to index a malformed row.

Closes #427

Tests

Red confirmed before each fix (isolated by surgical reverse-patches, not a full-file stash, to avoid breaking unrelated parts of the same diff), green after. Final state: test-pre-tool-hook.sh (336/336), test-pre-path-hook.sh (101/101), test-post-tool-hook.sh (61/61), test-security.sh (108/108), test-requires-field.sh (25/25) -- 631 assertions, 0 failures. Collateral-checked (not part of red/green): test-arg-flag-values.sh, test-line-citations.sh, test-dogfood-entries.sh, all green. bash tests/run-all.sh deliberately not run locally -- CI is the authority for the whole matrix.

Review

Self-reviewed by two concurrent spawns (Explore, oss:auditor) against the committed diff. Explore found the ti_depth hijack above (fixed, separate commit). oss:auditor returned no findings, but flagged one non-blocking maintainability note, judged below the intake bar rather than fixed: jit_missing_requires()'s bash case/${#bin} check re-implements JIT_VALID_REQUIRES_RE's policy rather than invoking the regex itself, so a future edit to the regex alone could silently desync the two. The two checks are character-for-character equivalent today; this is left as-is rather than refactored under this fix.

Docs

README.md checked against both matching sections (host-support matrix, writing-entries.md pointer) -- neither documents the JSON-parsing internals or the requires: value format in a way this fix changes; no change needed.

Tooling friction (adjacent, non-blocking)

Several supertool edit calls landed in the main clone instead of the worktree despite a cd-worktree-root prefix on every call, recovered with git diff/git apply/git checkout, no data lost -- a tooling-improvement note for the maintainer, not a blocker on this pull request.

[AI-generated]

Florian DAVID added 2 commits September 19, 2026 14:16
…o byte cap (#426, #427)

pre-tool-hook.sh/pre-path-hook.sh/post-tool-hook.sh read their JSON payload
positionally: every quoted field at an even split position was treated as a
candidate key with no check that it sat at key position and no brace-depth
tracking, so a tool_input STRING VALUE equal to tool_name/command/file_path/
pattern/skill/subagent_type could repoint the field it named -- last-wins,
byte-identical to a genuine non-match -- bypassing every mode: block rule
(#426). jit_hook_fields() (common.sh) replaces the positional read with a
structural one: a string is a key only when followed by a colon, and a value
is read only at the correct depth.

Separately, JIT_MISSING_REQUIRES was the one exec-crossing list in common.sh
with no byte cap, unlike every sibling that crosses the same pre-tool-hook.sh
exec boundary -- an oversized committed requires: value could trip E2BIG and
refuse (or, on an unwritable TMPDIR, silently pass) every tool call in a
session (#427). jit_missing_requires() now refuses a non-bare-name value
outright and caps the accumulated list with a reported truncation;
rebuild-tsv.sh applies the same bare-name check at index time.

Found by a Claude Security whole-repository scan; both confirmed by reading
the awk/bash source rather than executed.

Co-Authored-By: Max <noreply>
…lds' real one

Self-review finding on #426: jit_hook_fields() (common.sh) locked ti_depth onto
the FIRST key anywhere in the payload spelled "tool_input", regardless of what
depth that key itself sat at -- unlike the top-level TOP path, which already
hardcodes depth == 1. A JSON payload carrying an earlier, unrelated top-level
object with its own nested key coincidentally spelled "tool_input" would hijack
every subsequent TI[...] read for the rest of the object it wraps, silently
discarding the real top-level tool_input's own fields under first-wins.

Fixed by tracking the depth a candidate key was itself read at
(pending_key_depth) and only accepting it to set ti_depth when that key sat at
depth 1 -- the genuine top level, mirroring TOP's own hardcoded check.

Not known to be triggerable against Claude Code's actual PreToolUse payload
shape today (every field preceding the real tool_input key is runner-written),
but the code did not enforce that assumption, and the new-code comments and
changelog fragment both claimed a guarantee this did not actually provide.

Co-Authored-By: Max <noreply>
@fdaviddpt
fdaviddpt merged commit 8f282bf into main Sep 19, 2026
13 checks passed
@fdaviddpt
fdaviddpt deleted the fix/426-427-json-key-value-confusion branch September 19, 2026 12:53
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant