Skip to content

session-start-hook.sh parses the payload in the same locale as its three siblings, not just with the same parser (#177) - #194

Merged
fdaviddpt merged 2 commits into
mainfrom
fix/177
Aug 18, 2026
Merged

session-start-hook.sh parses the payload in the same locale as its three siblings, not just with the same parser (#177)#194
fdaviddpt merged 2 commits into
mainfrom
fix/177

Conversation

@fdaviddpt

@fdaviddpt fdaviddpt commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

session-start-hook.sh:32 parses the hook payload with jit_json_fields + jit_session_key out of common.sh — the same functions pre-prompt, pre-tool and pre-path use — and the comment above it explains why: a second, simpler regex would be a second answer to "what is a session id", and the two would drift. Those three pin LC_ALL=C. This one did not. The same parser in a different locale is not the same parser, so the comment was a claim the code did not carry out.

What was measured

3 engines x 2 locales, on a payload whose session_id carries a lone 0xE9, reading the value jit_session_key() returns:

C en_US.UTF-8
one-true-awk 20200816 "" refused "" refused, plus a suppressed towc: multibyte conversion failure
gawk 5.4.1 "" refused the id ACCEPTED, 0xE9 and all
mawk 1.3.4 "" refused "" refused

gawk in a multibyte locale does not match a lone 0xE9 against [^A-Za-z0-9_-], so the bare-name check quietly stopped being a bare-name check — on the awk that is awk on most Linux boxes and on ubuntu-latest.

Bounded honestly. The separators are single-byte and still matched, verified independently:

LC_ALL=en_US.UTF-8 gawk 'BEGIN{k="jbad\351/x"; print (k ~ /[^A-Za-z0-9_-]/)?"MATCHED":"NOT"}'  -> MATCHED

So nothing ever left the state directory and this was never a traversal. What was lost was agreement: the matching hooks refused that id and kept no marker, while this hook built two marker names out of it and cleared files nothing had written.

The test, and why it is shaped oddly

The obvious fixture — plant the marker the unpinned parse would clear, assert it survives — cannot be built on macOS, because APFS enforces valid UTF-8 in file names. The first draft was red on all three engines for that reason alone, with the redirection error on stderr as the only tell. That is a vacuous negative and would have shipped as coverage.

Section J instead asks a question that has an answer on every filesystem: what was rm handed? A shimmed rm first on PATH logs its arguments. A refused id never reaches the if [ -n "$SESSION_ID" ] branch, so no marker path is built at all.

Red, before the pin:

  FAIL: [gawk] a malformed session id was treated as a session id
    2 marker path(s) reached rm; the three matching hooks refuse this id
      rm arg: .../state/vocab-shown-jbad?id.txt
      rm arg: .../state/path-shown-jbad?id.txt
56 passed, 1 failed

Green after: 57 passed, 0 failed. Every negative leg is paired with a positive control in the same fixture, engine and locale.

The second commit gives section J the third state: this file already had one (section I drives exit 2, which run-all.sh keeps apart from both), and without it a host with no UTF-8 locale printed N passed, 0 failed and exited 0 — byte-identical to a run that drove the gawk cell. Driven all three ways: rc=0 normal, rc=2 with locale shimmed to report US-ASCII, rc=1 with JIT_TESTS_REQUIRE_UTF8_LOCALE=1.

The $( ) NUL-drop at the same line

Measured rather than argued about: it costs nothing. one-true-awk truncates the record at the NUL, gawk carries it and the class check matches it, mawk refuses it — no path puts a NUL into the capture on any engine in either locale. Written into the comment so nobody re-measures it.

Scope — the sweep was done, the pins were not

I drove all nine unpinned sites, not just this one. Five of nine diverge. Full table in the report note; the invariant it found is sharper than "every awk that reads untrusted bytes is pinned":

An awk diverges by locale exactly when it matches a regex against a record that can carry an invalid byte. Sites doing only index(), substr() and == are identical in all six cells.

That predicts all nine rows. The four other divergent sites are deliberately not pinned here — they are build tooling under the opposite contract (tooling.md: fail loudly), and for three of them pinning would convert a loud one-engine abort into a quiet pass-through that relies on rebuild-tsv.sh's bad-byte reporter — which the issue itself documents as broken on mawk/ubuntu-latest. Pinning first would trade a loud bug for a quiet one. Details and the prerequisite are in the report.

Two corrections to the issue

  • Its per-site table is short by one row: jit-dry-run.sh:452 at 3b8caaa (check_paths_fragment) is absent, and it is one of the five that diverge. The stated count of nine does not match the eight unpinned rows listed.
  • README.md:486 is README.md:518 at HEAD. At HEAD, :486 is a sentence about @invocation macros.

The README sentence claimed LC_ALL=C was "pinned on every awk in the plugin". It was not and still is not. It now claims only what holds and what the sweep verified: every awk that reaches the pattern guard — pre-tool-hook.sh, pre-path-hook.sh and jit-dry-run.sh's pattern probes. (pre-prompt-hook.sh composes no $JIT_AWK_GUARD, so it is correctly excluded.)

Closes #177.

Verified by the maintainer

Both corrections to the issue are accepted, and the issue was mine. Its per-site table is short by one row and its README.md citation had rotted by 32 lines — which is, with some irony, the exact class of defect #191 is open about, committed inside the issue asking for a locale sweep.

Red re-run, mine, in a scratch worktree at this branch with only the pin reverted: 57 passed, 0 failed with it, 56 passed, 1 failed without — failing on the gawk leg only, FAIL: [gawk] a malformed session id was treated as a session id, with the awk and mawk legs green in the same run. That one-engine signature is what makes this a locale finding rather than a broken fixture, and it is why the shimmed-rm design matters: the obvious fixture cannot exist on APFS, and the draft that tried it was red on all three engines for a reason that had nothing to do with the bug.

The refusal to pin the other four is the best decision in this PR and I am accepting it in full. The reasoning is that one-true-awk's abort is today's loudness at those sites; pinning to C removes it and hands the bad byte to a reporter that is silent on mawk — the default awk on ubuntu-latest. That is trading the loud bug for the quiet one, and refusing it is worth more than the four-line diff that would have looked like completing the issue.

Filed rather than carried, with the measurement attached:

One class is not-checked and is recorded as such rather than rounded to a pass: whether Git Bash on windows-latest has a UTF-8 locale, and therefore whether section J runs there or skips. The auditor read the workflow and did not run CI. Section J's third state means a skip is now visible rather than silent, so this PR's own machinery will answer it on the first Windows leg.

Florian DAVID and others added 2 commits August 18, 2026 15:37
…ree siblings, not just with the same parser (#177)

session-start-hook.sh:32 reads session_id with jit_json_fields + jit_session_key
out of common.sh -- the same functions pre-prompt, pre-tool and pre-path use --
and the comment above it says a second, simpler regex would be a second answer to
"what is a session id". Those three pin LC_ALL=C. This one did not, so the
sentence was a claim the code did not carry out.

Measured at 98386f1, 3 engines x 2 locales, session_id carrying a lone 0xE9:

  one-true-awk C, gawk C, mawk C, mawk UTF-8   ""  refused, correct
  one-true-awk en_US.UTF-8                     ""  + a suppressed towc diagnostic
  gawk en_US.UTF-8                             the id ACCEPTED, 0xE9 and all

gawk in a multibyte locale does not match a lone 0xE9 against [^A-Za-z0-9_-], so
the bare-name check stopped being one -- on the awk that is awk on most Linux
boxes and on ubuntu-latest. Bounded honestly: the separators are single-byte and
still matched, so nothing left the state directory. What was lost was agreement.

tests/test-session-markers.sh section J drives all three engines through a
shimmed rm. The obvious fixture -- plant the marker the unpinned parse would
clear -- cannot be built on macOS, because APFS refuses a file name that is not
valid UTF-8; the first draft was red on all three engines for that reason alone.
Asking what rm was handed answers on every filesystem. Every negative leg is
paired with a positive control in the same fixture.

The NUL-drop in the command substitution on the same line was measured rather
than argued about: no NUL reaches the capture on any engine in either locale,
so it costs nothing.

README.md said LC_ALL=C was pinned on every awk in the plugin. It was not and
still is not -- nine invocations under scripts/ carry no pin. The sentence now
claims only what holds: every awk that reaches the pattern guard.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Found by the audit of d066595, in the test that commit added.

tests/test-session-markers.sh already carries the third state -- section I sets
SKIPPED_SECTIONS and run-all.sh keeps exit 2 apart from both a pass and a
failure. Section J did not use it. On a host with no UTF-8 locale it printed a
SKIP-NOTE and exited 0, which is byte-identical to a run that DID drive the one
cell the section exists for: gawk under a multibyte locale. That is this
repository own defect class, in the test written to close an instance of it.

Five sibling suites share the note-and-exit-0 convention. Not changed here --
they are not this diff, and flipping them is a CI decision about the Windows leg
rather than a fix.

Driven, all three states:

  UTF-8 locale present                    rc=0  57 passed, 0 failed
  locale shimmed to report US-ASCII       rc=2  section J SKIPPED, named
  same + JIT_TESTS_REQUIRE_UTF8_LOCALE=1  rc=1  a failure outranks a skip

The summary line is fixed in the same edit: the symlink clause was
unconditional, so an H-only skip already rendered as "0 section(s) SKIPPED (no
symbolic links here)" -- a count of zero beside a reason for a section that ran.
Adding J made that reachable a third way.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@fdaviddpt
fdaviddpt merged commit 5b46095 into main Aug 18, 2026
5 checks passed
@fdaviddpt
fdaviddpt deleted the fix/177 branch August 18, 2026 14:24
fdaviddpt pushed a commit that referenced this pull request Aug 18, 2026
…pen (#191)

CI caught these, and the check that caught them is the one this branch adds.
Neither is a defect in this diff -- they are in tests/test-session-markers.sh,
section J, which #194 (issue #177) added to main after this branch was cut.

- `session-start-hook.sh:32 parses the payload with jit_json_fields +
  jit_session_key` -- the line number was ALREADY WRONG on the day it was
  written: line 32 is mid-sentence in the LC_ALL paragraph, and the parse claim
  is three lines earlier. The comment names its own functions, so the number
  was carrying nothing the prose did not already carry. Dropped.
- `the comment at session-start-hook.sh:25-29` -- accurate today, and a pointer
  to a comment rather than to code, which is the shape most likely to move.
  This one wanted more than a rename: it is a claim about TWO hooks agreeing,
  so it now names the pair of functions the claim is about and says which half
  of the claim this leg proves.

The counts pinned to 98386f1 are re-pinned to 5b46095 in all three places that
carry them, and the second measurement is kept beside the first rather than
replacing it. Nine citations at 98386f1, eleven at 5b46095; 96 basenames / 10
hits / 0 false, then 98 / 12 / 0. Pinning was already the right call and the
rebase is what proved it: the pin went stale in under a day and SAID SO,
where a present-tense number would have read true and been wrong.

That interval is now the argument the suite header leads with. The citations
this check first went red on in CI were not the ones it was written for, they
were written by somebody else while it was in review, and one of the two was
born broken. A cleanup would not have caught either.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
fdaviddpt added a commit that referenced this pull request Aug 18, 2026
…ses new ones (#191) (#197)

* Cross-file line-number citations rotted silently, and nothing was watching (#191)

A `<file>.sh:NNN` pointer is exact the day it is written and wrong on the next
PR that inserts a line above it. It rots silently: a rotted citation reads
exactly like a live one, so the reader follows it and lands on a plausible
comment rather than on an error.

Counted on main at 98386f1, outside the assembled changelog: nine citations of
that shape. Six pointed at the wrong thing, one had drifted off the block it
named, two were still right. #191 names three of the six; the sweep added here
found the other three. #185 added one of them three hours before #190 moved it.

Every citation in an enforced file now points at something greppable -- a
function name, a distinctive literal, or the issue number, which additionally
says why rather than where.

tests/test-line-citations.sh has three outcomes rather than two. It FAILS on a
citation in a tracked scripts or tests shell file; it REPORTS WITHOUT FAILING
over every other tracked file; and it names the assembled changelog as
deliberately not swept, because that file is generated and a finding in it is
unactionable. The advisory half is advisory rather than enforced for one stated
reason: its only remaining finding lives in an entry held by PR #192, and
reddening a file this change may not edit is how a check gets disabled in its
first week. Widening it is one awk pattern.

The false-positive surface was costed before the check was written: over all 96
tracked basenames and the whole tree, 10 hits and 0 false. A bash diagnostic, a
shellcheck line, an awk error and a longer basename are driven as controls that
must not match, alongside a planted citation that must.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Address the audit: an advisory sweep that could report coverage it never had (#191)

Four findings from the self-review, all in tests/test-line-citations.sh unless
noted:

- The advisory sweep had no floor guard on its file count, so a broken selector
  would print `Clean across 0 file(s)` and exit 0 -- byte-identical, to
  run-all.sh which reads only the exit code, to a genuinely clean tree. It now
  takes the same `< 10 -> exit 2` floor the enforced sweep already had.
- The enforced selector was `[^/]*`, so a shell file landing in a subdirectory
  one day would fall silently into the report-only bucket and stay there. Now
  `.*`, which closes the shape rather than half of it.
- A tracked path that is not a readable file was skipped in silence in both
  loops, while the header above claimed a count the sweep never reached. Both
  are counted now; an unreadable path in the enforced set fails the suite.
- The needle accepts an optional directory prefix, and no enforced file
  exercises that branch -- every real citation was bare-basename -- so a dead
  prefix branch would have read as clean. It has its own positive control now.

scripts/jit-dry-run.sh named `jit_log_name()` and `jit_row_id()` as being "in
pre-tool-hook.sh". They are defined in common.sh and only called there, which
is an anchor wrong in the same way the line numbers were. Reworded to what is
true: pre-tool-hook.sh routes every refused row through them.

The 96-basename and 10-hit figures are now pinned to 98386f1 in all three
places that carry them, rather than stated in the present tense. They were
already stale by two the moment this branch added a file, which is the exact
failure mode the change is about.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Two prose findings from the re-review (#191)

- CLAUDE.md read as attributing the move to #191 itself, because the issue
  citation sat immediately after "the PR that moved it". #185 added it and #190
  moved it three hours later; the sentence now says so.
- scripts/jit-dry-run.sh: "which #7 is the reason for" -> "which exists because
  of #7". Accurate either way, clunky one way.

A third finding was argued down rather than applied. The reviewer recounted the
audit as 3 right / 1 drifted / 5 wrong and read the tooling.md citation as
correct, on the strength of that entry own sentence "The truncation is
pre-tool-hook.sh:127-144 and it is correct" -- where "it" is the truncation
BEHAVIOUR being deliberate, not the line range being accurate. The same range
in scripts/jit-dry-run.sh was judged wrong in the same review, which is the
contradiction: at 98386f1 those lines are the #182 subagent_type comment, and
the truncation is at 162-185. The count stands at 6 wrong / 1 drifted / 2 right.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* README said "Two suites are the exception" and this change made it three (#191)

The test section enumerates the suites that are about this repository rather
than the hooks, and the enumeration was a count. Adding tests/test-line-citations.sh
made the sentence wrong in the quietest possible way -- a reader counting two
and finding three has no reason to think anything is missing.

Named rather than counted where it can be: the third one is called out by name
and by what it refuses, and the "assembler they are about" clause now says
which two it means.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Rebase onto 5b46095: two citations #194 wrote while this branch was open (#191)

CI caught these, and the check that caught them is the one this branch adds.
Neither is a defect in this diff -- they are in tests/test-session-markers.sh,
section J, which #194 (issue #177) added to main after this branch was cut.

- `session-start-hook.sh:32 parses the payload with jit_json_fields +
  jit_session_key` -- the line number was ALREADY WRONG on the day it was
  written: line 32 is mid-sentence in the LC_ALL paragraph, and the parse claim
  is three lines earlier. The comment names its own functions, so the number
  was carrying nothing the prose did not already carry. Dropped.
- `the comment at session-start-hook.sh:25-29` -- accurate today, and a pointer
  to a comment rather than to code, which is the shape most likely to move.
  This one wanted more than a rename: it is a claim about TWO hooks agreeing,
  so it now names the pair of functions the claim is about and says which half
  of the claim this leg proves.

The counts pinned to 98386f1 are re-pinned to 5b46095 in all three places that
carry them, and the second measurement is kept beside the first rather than
replacing it. Nine citations at 98386f1, eleven at 5b46095; 96 basenames / 10
hits / 0 false, then 98 / 12 / 0. Pinning was already the right call and the
rebase is what proved it: the pin went stale in under a day and SAID SO,
where a present-tense number would have read true and been wrong.

That interval is now the argument the suite header leads with. The citations
this check first went red on in CI were not the ones it was written for, they
were written by somebody else while it was in review, and one of the two was
born broken. A cleanup would not have caught either.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* The advisory half said it was blocked on #192, which has landed (#191)

A reason that has been spent reads exactly like a reason that still holds --
which is the defect class this whole branch is about, in the file that exists
to refuse it. The suite printed "advisory only while #192 holds one of the
files it would flag" on every run, and #192 merged at eb9cf71.

Replaced with what is true: widening is a scope decision nobody has taken, not
a blocked one. Deliberately NOT widened here. It is unrequested scope on a pull
request already in review, and the question it settles -- whether every future
doc, template, example and jit-context entry is bound by this rule, in every
installed project's contributor path -- is a design decision this branch was
not briefed to make. Filed for the maintainer with the exact cost instead: two
awk alternations in the selectors, one line in the entry currently flagged,
and zero measured false positives on that surface across two commits.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Florian DAVID <fdavid@digital-village.fr>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

session-start-hook.sh parses the payload with the same parser as its three siblings and not the same locale

1 participant