Skip to content

fix(guardrails): pin the reconstruction scan's locale so it stops decoding a literal search - #2127

Merged
kyle-sexton merged 6 commits into
mainfrom
fix/guardrails-locale-pin
Aug 10, 2026
Merged

fix(guardrails): pin the reconstruction scan's locale so it stops decoding a literal search#2127
kyle-sexton merged 6 commits into
mainfrom
fix/guardrails-locale-pin

Conversation

@kyle-sexton

Copy link
Copy Markdown
Contributor

Summary

skill-reference-verify's reconstruct_partial_edit searched in whatever locale the invoking
shell happened to carry. Every search it performs is literal, but bash's %% pattern strip
decodes rather than compares under a multibyte locale, so the scan paid for a decode it never
used — and the hook's cost and its matcher semantics both became a function of the consumer's
ambient environment rather than of its own code.

This lands as a correctness and determinism fix, not a performance claim. No wall-clock bound
is asserted anywhere in the diff. The measured ratio below is evidence of magnitude only.

Measurements (mine, this host)

Windows / Git Bash, bash 5.3.15, 32 logical cores at ~18% CPU, 30.7 GB of 63.7 GB free,
63 bash processes alive — several agents share this box, so these are ratios on a
lightly-loaded host
, not a bound. One no-match %% strip, best of three, measured in-process
(no fork inside the timed region):

size LC_ALL=C en_US.UTF-8 ratio
32 KiB 0.054 s 0.395 s 7.3x
64 KiB 0.221 s 1.447 s 6.5x
128 KiB 0.880 s 5.786 s 6.6x
192 KiB 1.964 s 12.458 s 6.3x
256 KiB 3.410 s

The ambient environment on this host is LANG=en_US.UTF-8 with LC_ALL unset, so the hook really
did run in the multibyte column.

Why local +x, not local

The +x is load-bearing, and this is the one place the prepared patch was wrong.

The entire ~6.5x is bash's own matcher. The child processes are locale-insensitive here — the
inline-code-span grep -oE over the same 64 KiB measured 0.139 s under both locales. So
exporting the pin buys nothing — and it costs real behavior.

A plain local LC_ALL=C inherits the export attribute whenever the consumer exported LC_ALL,
which pushes the pin into emit_refs' grep/sed. GNU [[:space:]] matches U+00A0 / U+3000 /
U+2028 under a UTF-8 locale but only ASCII under C. Instrumented on the real hook, with the caller
exporting LC_ALL=en_US.UTF-8 and a reference reachable only through reconstruction:

local LC_ALL=C     ctx=…ghost-nbsp c2 a0 arg…   emit_refs -> []                    SILENT
local +x LC_ALL=C  ctx=…ghost-nbsp c2 a0 arg…   emit_refs -> [/alpha:ghost-nbsp]   REPORTED

Same ctx bytes in both — the byte slicing is correct either way. The plain local form silently
drops a real finding. +x keeps the whole benefit and none of that.

The prepared patch's comment asserted the children's "output is byte-identical either way,
verified on non-ASCII input". That is false as written; it is also moot under +x, and the comment
now says what was actually measured.

Correctness sub-claims, each verified rather than taken

claim verdict
assigning LC_ALL re-runs setlocale even for a local (and for local +x) holds${#} and %% both switch to byte semantics inside the function
bash restores the prior value on return holds in all three caller states (unset / set-unexported / exported)
…and restores the export attribute holds — an exported LC_ALL is still declare -x with its original value after return
children inherit the pin only when the consumer exported LC_ALL holds for plain local; under +x they never inherit it in any state
byte-vs-char offsets stay inside the function holds — every offset is produced and consumed within the pinned region
no UTF-8 multibyte sequence contains an ASCII byte holds; slices land only at a literal match or a newline, so a byte slice cannot split a character

The regression test: what it can and cannot discriminate

The shipped multibyte fixture is not vacuous in the way the first draft was — the skill name is
ASCII (the emit_refs grammar is [a-z0-9-], so a non-ASCII name is unreportable by design) and
the multibyte text sits around the anchor. But it cannot discriminate the pin. I mutated it:
with the pin reverted entirely, it still passes. That is expected and correct — byte offsets and
character offsets are each internally self-consistent, so a mis-slice is not constructible while
every offset is produced and consumed inside one locale. I state that plainly rather than claim the
case catches something it does not.

What is constructible, and what I added, is a case that discriminates the pin form — the thing
that can actually regress. With the consumer exporting a UTF-8 locale, a reference whose argument
separator is U+00A0 must still be reported; a plain local pin makes it silent. Its separator is
built from printf '\xc2\xa0' rather than a literal byte, because a literal one was silently
normalized to an ASCII space while I was writing it, which made an earlier run of my own A/B vacuous
in exactly the way the previous agent's first draft had been.

Two observable threshold shifts the pin does introduce, both toward less work and both noted
in the docblock:

  • RECONSTRUCT_MAX_CHARS is now read as bytes, the stricter reading — it cannot raise the ceiling it
    exists to set.
  • the fallback's KiB estimate stops understating a multibyte file and over-granting its anchor cap.

Docblock

Re-labelled, not re-measured. The constants docblock published 0.07 s at 32 KiB … 3.94 s at 256 KiB with no locale named. Those figures match the C column almost exactly, but the hook did not
then run in C — so the table described a locale the code never used. The pin makes C the actual
locale, so the label is now correct as written. I added a re-check from this host (0.054 / 0.221 /
0.880 / 3.410 s at 32 / 64 / 128 / 256 KiB) as corroboration, and a note explaining why the label is
not a footnote.

Coverage gap — left open, deliberately

The fallback-scale case previously traded its wall-clock assertion for behavior assertions. That was
defensible (those readings were mostly ambient overhead), but it left no test that would catch a
locale-driven cost regression
. This PR does not close that gap. The one wall-clock assertion
still present — big_elapsed < 30 on the 1000-line case — cannot close it either: that fixture is
≈38 KiB, so one scan is ~0.07 s under C and ~0.5 s under UTF-8, both three orders below the ceiling.
The new case pins the pin's form, not its cost. Closing the cost gap needs a deterministic
proxy rather than a wall clock, and I did not invent one here.

Noted, not fixed

The direct emit_refs scan (outside the reconstruction) still runs in the ambient locale, so its
[[:space:]] breadth remains locale-dependent. Pinning the whole hook would change the grammar the
guard reports on and needs its own justification, so it is out of scope here.

No linked issue

Related

…oding a literal search

skill-reference-verify's reconstruct_partial_edit searched in whatever locale the
invoking shell happened to carry. Every search it performs is LITERAL, but bash's
`%%` pattern strip DECODES rather than compares under a multibyte locale, so the
scan paid for a decode it never used.

Measured on a quiescent Windows/Git Bash host (bash 5.3, 32 logical cores at ~18%),
one no-match scan costs 0.054 s at 32 KiB / 0.221 s at 64 KiB / 0.880 s at 128 KiB
under LC_ALL=C, against 0.395 s / 1.447 s / 5.786 s under en_US.UTF-8 — ~6.5x. The
ratio is the finding; no wall-clock bound is claimed from it, and the caps the hook
already carries are calibrated end to end rather than off that table.

The pin is `local +x LC_ALL=C`, and the `+x` is load-bearing. A plain `local`
inherits the export attribute whenever the consumer exported LC_ALL, pushing the
pin into the grep/sed children. GNU `[[:space:]]` matches U+00A0 and U+3000 under a
UTF-8 locale but not under C, so an exported pin makes the hook silently drop a
reference whose argument separator is a non-ASCII space. That costs detection and
buys nothing: the whole ~6.5x is bash's own matcher, and `grep -oE` over the same
64 KiB measured 0.139 s under BOTH locales. Un-exported, children keep the caller's
locale exactly as before, and no offset ever crosses a process boundary — the
fallback's grep yields anchor strings and emit_refs consumes a context string.

Scope and lifetime were verified rather than assumed: assigning LC_ALL re-runs
setlocale even for a `local +x`, and bash restores the previous value and its
export attribute on return in all three caller states (unset, set, exported).

Two regression cases. The multibyte-content case pins that byte offsets never
split a character. It cannot discriminate the pin on its own — both locales are
internally self-consistent, so it passes with and without one — so a second case
covers the pin FORM, which is what can actually regress: with the consumer
exporting a UTF-8 locale, a non-ASCII-separated reference must still be reported.
Its separator is built from escapes rather than a literal byte, because a literal
one was silently normalized to an ASCII space while the case was being written,
which made an earlier draft vacuous.

Also re-labels the constants docblock's cost curve as C-locale figures. They always
matched the C column, but the hook did not then run in C, so the table described a
locale the code never used; the pin makes C the actual locale. Re-labelled rather
than re-measured, with a re-check on a second host of the same shape.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@cursor

cursor Bot commented Aug 10, 2026

Copy link
Copy Markdown

Bugbot is not enabled for your account, so this pull request was not reviewed.

Enable Bugbot in the Cursor dashboard to get automatic reviews on future PRs.

…-pin

# Conflicts:
#	plugins/guardrails/.claude-plugin/plugin.json
#	plugins/guardrails/CHANGELOG.md
@claude

claude Bot commented Aug 10, 2026

Copy link
Copy Markdown

Claude finished @kyle-sexton's task in 6m 51s —— View job


Review complete

  • Gather context (diff, changed files, CLAUDE.md/REVIEW.md)
  • Understand the request (code review lane)
  • Review skill-reference-verify.sh changes
  • Review skill-reference-verify.test.sh changes
  • Review CHANGELOG.md / plugin.json version bump
  • Post findings as inline comments + summary

Summary

This is a well-reasoned, well-documented fix. I read the full diff (git diff origin/main...HEAD) and the surrounding skill-reference-verify.sh/.test.sh context, and worked through the core claims by hand:

  • local +x LC_ALL=C is the right construct: bash's local (like declare) inherits the export attribute from a same-named variable already exported in an enclosing scope unless explicitly overridden, so a plain local LC_ALL=C would indeed re-export the pin into emit_refs' grep/sed children whenever the consumer already exported LC_ALL — silently narrowing [[:space:]] to ASCII for those children and dropping a non-ASCII-separated reference. +x correctly prevents that, and un-exported children keep the ambient locale exactly as before.
  • The byte/char-offset safety argument holds: every offset reconstruct_partial_edit produces (via anchor_offsets/collect_overlapping_spans) is also consumed within the same pinned-locale function call, slices only land at a literal-match or newline boundary, and a UTF-8 continuation byte can't collide with either — so a byte slice can't split a character.
  • emit_refs's own scan (outside reconstruction) is correctly left unpinned and out of scope, as the PR states.

Two things worth a look (posted inline):

  1. Test-coverage risk (skill-reference-verify.test.sh:442-457) — the only case in the suite that actually discriminates local +x LC_ALL=C from the buggy plain local LC_ALL=C silently SKIPs if the runner's locale -a lacks en_US.UTF-8. A green run gives no signal about whether that case ran.
  2. Nit (plugin.json:4) — minor (not patch) version bump for a Fixed/Changed-only release; likely defensible given the byte-vs-char cap shift, but the CHANGELOG doesn't say so the way the 0.22.1 entry explicitly justified its own bump.

No correctness bugs found in the core locale-pinning logic itself.

Verification limits, stated plainly: this sandboxed review session's Bash tool would not execute anything beyond read-only git inspection (bash, shellcheck, even bash --version all required approval that wasn't available here). I could not run skill-reference-verify.test.sh, so I have not independently confirmed the test suite passes or that the measured timing ratios reproduce — those remain author-claimed. If you'd like me to actually execute the suite, --allowedTools would need to permit Bash(bash:*) or similar for this workflow.

@claude

claude Bot commented Aug 10, 2026

Copy link
Copy Markdown

Claude finished @kyle-sexton's task in 1m 31s —— View job


Security review

  • Gather context (PR diff, changed files)
  • Review skill-reference-verify.sh for injection / logic / trust-boundary issues
  • Review skill-reference-verify.test.sh for issues
  • Review plugin.json / CHANGELOG.md (metadata-only changes expected)
  • Post findings

No security issues found.

Scope reviewed (git diff origin/main...HEAD, 4 files, +137/-6):

  • plugins/guardrails/.claude-plugin/plugin.json — version bump + description string only. No permission or schema change.
  • plugins/guardrails/CHANGELOG.md — documentation only.
  • plugins/guardrails/hooks/skill-reference-verify.sh — adds local +x LC_ALL=C inside reconstruct_partial_edit, plus explanatory comments. This is a shell-builtin scoped variable assignment; it does not introduce any new eval, command substitution over untrusted content, subshell escape, or child-process argument construction. The +x is specifically there to prevent the pin from leaking into the grep/sed children via an inherited export attribute — i.e., the change closes a detection-loss gap rather than opening one. emit_refs/anchor_offsets, the functions that actually consume file content and construct child-process invocations, are unchanged by this diff. The hook remains advisory/non-blocking (hookSpecificOutput additionalContext, no exit-code enforcement), so even a worst-case regression here is a missed advisory, not a bypassed control.
  • plugins/guardrails/hooks/skill-reference-verify.test.sh — new regression fixtures built from printf/byte-escape constants (e.g. U+00A0 via printf '\xc2\xa0') run only through the existing test harness. No new external input path.

No pull_request_target/workflow_run usage, no workflow permission changes, no secrets/token handling, and no dependency/action pins touched by this diff — nothing in scope for the zizmor advisory lane either, since no .github/workflows files are part of this PR.

Comment thread plugins/guardrails/hooks/skill-reference-verify.test.sh Outdated
Comment thread plugins/guardrails/.claude-plugin/plugin.json
@github-actions

github-actions Bot commented Aug 10, 2026

Copy link
Copy Markdown

Claude has reviewed this PR 5 times. The lane skips further automatic reviews after 5; deleting this comment resets the count.

…eable

Two review findings, both real.

The non-ASCII separator case is the ONLY one that separates `local +x LC_ALL=C`
from a plain `local LC_ALL=C`; every other case in the file passes identically
under both. Its `locale -a` guard could therefore retire the single regression
guard this fix exists to add, silently, while the suite stayed green. The skip
message now says so outright, and the comment records that CI's Ubuntu runners
carry en_US.UTF-8, so the branch is expected to be DEAD there -- a skip in a CI
log is the finding, not a shrug. It stays a skip rather than a hard failure only
so a developer on a minimal host is not blocked by a locale they cannot generate.

The MINOR bump also lacked the "Note on the version bump" rationale that the
0.22.1 entry established as the precedent. There IS an observable narrowing here
-- RECONSTRUCT_MAX_CHARS is now read as bytes, so a large multibyte file that fit
under the character cap can exceed the byte cap and skip reconstruction -- which
is what puts this above a patch. Stated now instead of left to inference.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@claude

claude Bot commented Aug 10, 2026

Copy link
Copy Markdown

Claude finished @kyle-sexton's task in 56s —— View job


Security review complete

  • Gather context (git diff origin/main...HEAD, 4 changed files)
  • Review skill-reference-verify.sh locale-pin changes for injection / logic / trust-boundary issues
  • Review skill-reference-verify.test.sh for issues
  • Review plugin.json / CHANGELOG.md (metadata-only changes expected)
  • Post findings

No security issues found.

Scope reviewed (git diff origin/main...HEAD, 4 files, +151/-6):

  • skill-reference-verify.sh#L227-L264 — docblock re-label only (adds the locale under which the existing timing figures were measured). No behavior change.
  • skill-reference-verify.sh#L398-L437 — the entire functional diff is a single new statement, local +x LC_ALL=C, at the top of reconstruct_partial_edit, preceded by explanatory comments. This is a shell-builtin scoped variable assignment:
    • It does not introduce eval, command substitution over untrusted content, a subshell escape, or any new argument construction for a child process.
    • The +x explicitly withholds the export attribute, so the pin cannot leak into emit_refs'/anchor_offsets' grep/sed children even when the invoking shell already exported LC_ALL — i.e. this closes a potential detection-loss path (an exported pin narrowing [[:space:]] to ASCII and silently dropping a non-ASCII-separated reference) rather than opening one.
    • emit_refs and anchor_offsets — the functions that actually consume file content and construct child-process invocations — are unchanged by this diff.
    • Scope is standard bash local semantics: the prior value and export attribute are restored on return in all caller states (unset / set-unexported / exported), so no locale state leaks past the function.
    • The hook remains advisory/non-blocking (hookSpecificOutput additionalContext, no exit-code enforcement per the file's own "ENFORCEABILITY TIER" docblock), so even a worst-case regression here is a missed advisory, not a bypassed control.
  • skill-reference-verify.test.sh#L406-L465 — new fixtures built from printf/byte escapes (e.g. U+00A0 via printf '\xc2\xa0') written to temp files under the test's own $REPO sandbox and run only through the existing test harness. No new external input path, no injection surface.
  • plugin.json — version bump + description string only. No permission or schema change.
  • CHANGELOG.md — documentation only.

No pull_request_target/workflow_run usage, no workflow permission changes, no secrets/token handling, and no dependency/action pins touched by this diff — nothing in scope for the zizmor advisory lane either, since no .github/workflows files are part of this PR.

@claude

claude Bot commented Aug 10, 2026

Copy link
Copy Markdown

Claude finished @kyle-sexton's task in 2m 17s —— View job


Review complete

  • Gather context (diff, changed files, CLAUDE.md/AGENTS.md)
  • Understand the request (code-review lane, focus on correctness/architecture/error handling/test coverage/maintainability; security out of scope)
  • Review skill-reference-verify.sh changes
  • Review skill-reference-verify.test.sh changes
  • Review CHANGELOG.md / plugin.json version bump
  • Check whether latest commit (ff47864) addresses prior review feedback
  • Post findings

Summary

This review is scoped to what's new since the last automated pass: commit ff47864 ("make the locale guard's skip loud and the bump traceable"), which is docs/comment-only (no logic changes to skill-reference-verify.sh itself beyond the earlier 1988a21). I re-read the full diff (git diff origin/main...HEAD) and traced reconstruct_partial_edit, emit_refs, and anchor_offsets for context.

Both prior review findings are adequately addressed:

  1. Silent-skip risk (skill-reference-verify.test.sh:449-457) — the commit renames the skip message to "SKIP non-ASCII separator — NO LOCALE CONTRAST AVAILABLE, the +x guard did NOT run" and adds a comment explaining CI's Ubuntu runners are expected to always hit the non-skip branch. This doesn't turn the skip into a hard failure (it's still routed through ok() in guardrails-test-helpers.sh:13, so PASS=$((PASS+1)) either way — the suite's report() exit status can't distinguish a skip from a real pass), but that was a deliberate, reasoned tradeoff (not blocking a contributor on a minimal host), and .github/workflows/ci.yml's plugin-contract-tests job runs on ubuntu-24.04, which does carry en_US.UTF-8 — so the skip path is in fact dead in CI, and a maintainer scanning CI output for SKIP would now actually notice it if it ever fired. Reasonable resolution given the constraint.

  2. Version-bump justification (CHANGELOG.md:6-13) — a "Note on the version bump" section was added mirroring the 0.22.1 precedent, correctly identifying the RECONSTRUCT_MAX_CHARS bytes-vs-chars narrowing as the observable-behavior reason for MINOR over patch. Matches the CHANGELOG's own established convention for justifying bumps.

No new issues found. The local +x LC_ALL=C construct, the byte/char-offset safety argument (every offset produced by reconstruct_partial_edit is consumed within the same pinned-locale call, slices land only at literal matches or newlines, and no UTF-8 continuation byte is ASCII), and the un-exported-so-children-are-unaffected reasoning all check out on a second read. RECONSTRUCT_MAX_CHARS/the KiB estimate correctly shift to bytes as a consequence of the pin, and that's the one documented, narrowing behavior change — consistently reflected in both the docblock and the CHANGELOG.

Verification limits, stated plainly: this session's Bash tool required approval for any nested-shell invocation (bash -c, bash --version, git fetch) that wasn't available here, so — like the prior automated pass — I could not execute skill-reference-verify.test.sh myself. I did confirm origin/main was already present locally (no fetch needed) and read the diff directly via git diff/git log. The claimed test results and timing measurements remain author-claimed and unverified by me.

… lane

CI's typos lane failed on the French multibyte fixture: it read the preposition
"sur" as a truncated "sure". The prose is French only to get multibyte bytes
cheaply -- the words carry no meaning for the assertion -- so the fixture is
reworded rather than adding a global dictionary entry for a throwaway string.

The comment explaining this deliberately does not spell the flagged word either,
since the lane scans comments too.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@claude

claude Bot commented Aug 10, 2026

Copy link
Copy Markdown

Claude finished @kyle-sexton's task in 1m 10s —— View job


Review complete

  • Gather context (diff, changed files, CLAUDE.md/AGENTS.md)
  • Understand the request (code-review lane; this is a synchronize re-review — the branch has one new commit, 29739d9, since the last full review pass at ff47864)
  • Review skill-reference-verify.sh changes
  • Review skill-reference-verify.test.sh changes
  • Review CHANGELOG.md / plugin.json version bump
  • Post findings

Summary

Scoped this pass to what's new since the prior review rounds (which already covered 1988a21/ff47864 in depth, and whose two findings — the silent-skip risk and the version-bump justification — the author addressed in ff47864c and I can confirm are still in place on HEAD). The only change since then is 29739d9, which touches only skill-reference-verify.test.sh.

29739d9 — non-functional fixture reword, no issues found.

The commit swaps "Préambule sur les conventions""Préambule des conventions" in the multibyte-content fixture, purely to stop CI's typos lane misreading "sur" as a truncated "sure". I checked the property the surrounding comment (lines 405–415) requires — multibyte bytes present on the line before the anchor, and on both sides of the anchor within its own line — and it still holds after the edit: é//à remain before the anchor line, and Exécutez/après/déploiement/voilà remain on both sides of `/alpha:ghost-cafe` on the anchor's own line. The assertion target (UNRESOLVED_SKILL: /alpha:ghost-cafe) is untouched, so this can't change what the case discriminates.

No correctness, architecture, or test-coverage issues in this commit — it's a pure fixture-wording fix with no logic change.

Not independently verified: CI (ci, claude-security-review) was still in_progress at review time, so I have not independently confirmed the typos lane now passes on this wording — that remains author-claimed pending the run linked below.

No new inline findings to post.
· branch fix/guardrails-locale-pin

@claude

claude Bot commented Aug 10, 2026

Copy link
Copy Markdown

Claude finished @kyle-sexton's task in 50s —— View job


Security review complete

  • Gather context (git diff origin/main...HEAD, 4 changed files)
  • Review skill-reference-verify.sh locale-pin changes for injection / logic / trust-boundary issues
  • Review skill-reference-verify.test.sh for issues
  • Review plugin.json / CHANGELOG.md (metadata-only changes expected)
  • Post findings

No security issues found.

Scope reviewed (git diff origin/main...HEAD, 4 files, +155/-6, HEAD 29739d9):

  • skill-reference-verify.sh#L227-L243 — docblock re-label of existing measured timings (adds the locale they were measured under). Pure comment change, no behavior.
  • skill-reference-verify.sh#L398-L437 — the entire functional diff is one statement, local +x LC_ALL=C, at the top of reconstruct_partial_edit, plus explanatory comments:
    • Pure shell-builtin scoped variable assignment. No eval, no command substitution over untrusted content, no subshell escape, no new argument construction feeding a child process.
    • +x withholds the export attribute, so the pin cannot leak into emit_refs'/anchor_offsets' grep/sed children even when the invoking shell already exported LC_ALL — this closes a detection-loss path (an exported pin narrowing [[:space:]] to ASCII and silently dropping a non-ASCII-separated reference) rather than opening one.
    • emit_refs and anchor_offsets — the functions that actually consume file content and build child-process invocations — are unchanged by this diff.
    • Standard local scoping: prior value and export attribute are restored on return in all caller states (unset / set-unexported / exported), so nothing leaks past the function call.
    • The hook remains advisory/non-blocking (hookSpecificOutput additionalContext, no exit-code enforcement), so even a worst-case regression here is a missed advisory, not a bypassed control.
  • skill-reference-verify.test.sh#L406-L470 — new fixtures built from printf/byte escapes (e.g. U+00A0 via printf '\xc2\xa0'), written to temp files under the test's own mktemp -d sandbox ($REPO) and fed to the hook only through the existing edit_json test helper (unchanged, defined in guardrails-test-helpers.sh, out of this diff's scope). No new external input path, no injection surface — this is black-box subprocess testing of the hook's own stdin contract, same as the surrounding suite.
  • plugin.json — version bump + description string only. No permission, schema, or trust-boundary change.
  • CHANGELOG.md — documentation only.

No pull_request_target/workflow_run usage, no workflow permission changes, no secrets/token handling, and no dependency/action pins touched — nothing in scope for the zizmor advisory lane either, since no .github/workflows files are part of this PR.

…rdcoding a libc's table

The regression case added with the `+x` pin hardcoded U+00A0 as the argument
separator whose classification differs between C and a UTF-8 locale. That is a
property of the host C library, not of this hook, and it is not portable: glibc
dropped U+00A0 and U+202F from `space` in 2.26 (a no-break space is deliberately
not a separator), while Cygwin/MSYS still classifies them. So the case passed on
Windows and failed on Linux CI — PASS=99 FAIL=1 — asserting a libc's table as if
it were this hook's behavior.

Measured on both platforms rather than reasoned about. `local +x LC_ALL=C` behaves
IDENTICALLY on Git Bash (Cygwin 3.6.9, bash 5.3) and Linux (glibc 2.39, bash 5.2):
it pins bash's own matcher to byte semantics and leaves the child environment at
the caller's locale, restoring value and export attribute on return. That rules
out a platform-specific `+x` semantic as the cause. What differs is only which
codepoints `[[:space:]]` admits:

  separator                     MSYS      glibc 2.39
  U+00A0 NO-BREAK SPACE         DIFFER    SAME     <- the bad fixture
  U+202F NARROW NBSP            DIFFER    SAME
  U+3000 IDEOGRAPHIC SPACE      DIFFER    DIFFER
  U+2000 / U+2028 / U+205F      DIFFER    DIFFER

The pin itself is unchanged and its correctness argument is untouched.

The case now DISCOVERS its separator: it takes the first candidate this host
actually classifies differently between the two locales, probed through the very
sed stage the assertion depends on. It cannot go vacuous (a candidate that does
not discriminate is never selected) and cannot go platform-brittle (no codepoint
is baked in). Both platforms select U+3000. If no candidate discriminates the case
reports a loud, reasoned skip naming the platform rather than passing quietly.

Verified: PASS=100 FAIL=0 on Windows/Git Bash AND on Linux/glibc 2.39, with the
case executing rather than skipping on both. Mutated back to a plain `local` pin,
it fails on Linux too (PASS=99 FAIL=1) — so it discriminates on glibc rather than
merely passing there.

Also corrects the hook docblock and the CHANGELOG, which both published the same
unmeasured U+00A0 claim.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@claude

claude Bot commented Aug 10, 2026

Copy link
Copy Markdown

Claude finished @kyle-sexton's task in 4m 49s —— View job


Review complete

  • Gather context (diff, changed files, CLAUDE.md/AGENTS.md)
  • Understand the request (code-review lane; this is a synchronize re-review — one new commit since the last full pass)
  • Review the new commit (3ec40de)
  • Post findings

Summary

Scoped this pass to what changed since the prior review rounds (which already covered 1988a21/ff47864/29739d9 in depth — the silent-skip and version-bump findings were addressed in ff47864 and remain in place). The only change on HEAD is 3ec40de ("discover the locale-contrast separator instead of hardcoding a libc's table"), touching skill-reference-verify.sh (comment-only) and skill-reference-verify.test.sh (the regression case's separator selection).

What this commit fixes: the PR body notes the prior hardcoded U+00A0 separator passed on Windows/Cygwin (which still classifies U+00A0 as [[:space:]]) but silently went vacuous on Linux glibc ≥2.26 (which dropped U+00A0/U+202F from space). The new code replaces the hardcoded byte with a discovery loop that probes five candidate separators (U+3000, U+2000, U+2028, U+205F, U+00A0) through the exact sed program emit_refs uses in production (skill-reference-verify.sh:220), selecting the first one this host classifies as space under en_US.UTF-8 but not under C.

I traced the regex logic by hand rather than executing it (see verification limits below):

  • The anchor capture (/[a-z][a-z0-9-]*:[a-z][a-z0-9-]*) stops at the first non-ASCII byte identically under both locales (C treats multi-byte UTF-8 as raw bytes outside [a-z0-9-]'s range; UTF-8 treats it as one non-matching character) — so the two locales only diverge on whether the optional [[:space:]].* group can consume the separator, which is exactly the property being probed.
  • sed -n ... p only emits output when the whole anchored pattern matches; a separator not classified as space leaves trailing bytes that can't be consumed under $, so no match → empty output. This makes the discovery loop's -n "$_u" && -z "$_c" check correct.
  • The discovery loop can't go vacuous (a non-discriminating candidate is never selected) and the fallback skip path is loud and reasoned, consistent with the fix addressed in ff47864 for the same case.
  • Every candidate byte sequence is built from octal escapes via printf used as the format string (not %s data) — correctly required, since %s would insert the literal backslash-digit text rather than decode it. The shellcheck disable=SC2059 comments are placed immediately before each of the two call sites (the for loop and the standalone SEP=$(printf ...)), which is the correct scope for those disables.

One minor nit posted inline: skill-reference-verify.test.sh:457SEP_SED hand-duplicates emit_refs's actual sed program rather than referencing it, so the two could drift if emit_refs's grammar changes later. Low severity: a drift would surface as a loud assert_contains failure against the real hook (line 495-497 still runs bash "$HOOK" end-to-end), not a silent gap — just suggested a cross-reference comment for discoverability.

No correctness issues found in this commit. The CHANGELOG update mirrors the code accurately (glibc version/codepoint claims match what's in the code comments), and no plugin.json change accompanies this commit (version stays at the previously-reviewed bump).

Verification limits, stated plainly: as with every prior automated pass on this PR, this session's Bash tool required approval for shell execution (sed, awk, export, even piped printf | sed) that wasn't available here, so I could not execute the discovery loop or the test suite myself. I confirmed SEP_SED is byte-identical to emit_refs's production sed program (skill-reference-verify.sh:220) by direct file comparison, and worked through the regex semantics by hand rather than running them. The claimed cross-platform behavior (Windows/Cygwin selecting U+00A0 previously, both dev platforms now selecting U+3000) remains author-claimed and unverified by me.
· branch fix/guardrails-locale-pin

@claude

claude Bot commented Aug 10, 2026

Copy link
Copy Markdown

Claude finished @kyle-sexton's task in 51s —— View job


Security review complete

  • Gather context (git diff 29739d9..3ec40de — the only change since the last security pass)
  • Review new commit 3ec40de ("discover the locale-contrast separator instead of hardcoding a libc's table")
  • Confirm no changes to previously-reviewed files introduce new issues
  • Post findings

No security issues found.

This pass is scoped to what's new since the prior security review (which covered 29739d9 in full — see that pass's findings, still valid on HEAD). The only change since then is 3ec40de, touching CHANGELOG.md, skill-reference-verify.sh, and skill-reference-verify.test.sh:

  • skill-reference-verify.sh#L411-L433 — comment-only rewording (generalizes "U+00A0/U+3000/U+2028" to "some non-ASCII spaces" and explains the libc portability rationale). No executable change; the local +x LC_ALL=C statement itself is untouched.
  • CHANGELOG.md — documentation only.
  • skill-reference-verify.test.sh#L434-L490 — replaces the hardcoded U+00A0 fixture with a discovery loop that probes a fixed list of five candidate separators (U+3000, U+2000, U+2028, U+205F, U+00A0) against sed under both locales to find one this host's libc actually classifies differently, then uses it to build the regression fixture:
    • The candidate list (_cand entries) is a hardcoded literal array in the test source, not derived from any external or untrusted input — the # shellcheck disable=SC2059 use of $_cand/$SEP as a printf format string is safe because the format string is always one of these five fixed literals, never attacker- or environment-controlled data.
    • SEP_SED is a fixed pattern string, not constructed from any variable input.
    • The resulting fixture file ($REPO/sepspace.md) is written under the test's own mktemp-based sandbox and fed to the hook only through the existing edit_json test helper — same shape as the rest of the suite, no new external input path.
    • This is pure test-harness logic; it does not touch emit_refs, anchor_offsets, or any code path that handles untrusted consumer input in the shipped hook.

No pull_request_target/workflow_run usage, no workflow permission changes, no secrets/token handling, and no dependency/action pins touched — nothing in scope for the zizmor advisory lane either, since no .github/workflows files are part of this PR.
· branch fix/guardrails-locale-pin

Comment thread plugins/guardrails/hooks/skill-reference-verify.test.sh
… mirrors

SEP_SED is a byte-for-byte copy of emit_refs' reference-extraction sed at
skill-reference-verify.sh:220, and nothing enforces that they stay identical.
Noted in review. The comment now names the exact source line so a future editor
of emit_refs knows to check here.

Deliberately left as a copy rather than sourced: the probe must run the grammar
in isolation to classify a candidate separator, and a drift cannot pass silently
anyway -- the end-to-end assertion below runs the REAL hook, so a separator
discovered against a stale copy fails assert_contains rather than quietly
selecting the wrong codepoint.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@claude

claude Bot commented Aug 10, 2026

Copy link
Copy Markdown

Claude finished @kyle-sexton's task in 35s —— View job


Security review complete

  • Gather context (git diff origin/main...HEAD, identify what changed since last security pass)
  • Review new commit since last security review (580fd09)
  • Confirm no changes to previously-reviewed files introduce new issues
  • Post findings

No security issues found.

This pass is scoped to what's new since the prior security review (which covered through 3ec40de in full — see that pass's findings, still valid on HEAD). The only change since then is 580fd09, and it touches exactly one file:

  • skill-reference-verify.test.sh#L457-L461 — adds a 5-line comment above SEP_SED cross-referencing skill-reference-verify.sh:220 (the production emit_refs sed program this fixture hand-mirrors), and notes that a future drift between the two can't pass silently because the end-to-end assertion further down still runs the real hook. This is a pure comment addition — SEP_SED's value, the candidate-separator discovery loop, and every other line are byte-identical to what was already reviewed at 3ec40de. No executable change of any kind.

No other files changed in this commit. skill-reference-verify.sh, plugin.json, and CHANGELOG.md are unchanged from the state already reviewed (no eval, no command substitution over untrusted content, no new child-process argument construction, no permission/schema/trust-boundary changes — see prior passes for full reasoning on the local +x LC_ALL=C pin and the locale-discovery loop).

No pull_request_target/workflow_run usage, no workflow permission changes, no secrets/token handling, and no dependency/action pins touched — nothing in scope for the zizmor advisory lane either, since no .github/workflows files are part of this PR.
· branch fix/guardrails-locale-pin

@claude

claude Bot commented Aug 10, 2026

Copy link
Copy Markdown

Claude finished @kyle-sexton's task in 48s —— View job


Review complete

  • Gather context (diff, changed files, CLAUDE.md/AGENTS.md)
  • Understand the request (code-review lane; synchronize re-review — one new commit since the last full pass)
  • Review the new commit (580fd09)
  • Post findings

Summary

Scoped this pass to what changed since the prior review round (3ec40de, already covered in depth — all prior findings were addressed and remain resolved). The only change on HEAD is 580fd09 ("cross-reference the sed program the separator probe mirrors"), touching only skill-reference-verify.test.sh.

580fd09 — resolves the outstanding nit, no issues found.

This is exactly the fix requested by the maintainability nit from the previous pass: SEP_SED now carries a comment cross-referencing skill-reference-verify.sh:220, the exact emit_refs sed line it hand-duplicates. I confirmed the line number is accurate — skill-reference-verify.sh:220 is sed -nE 's|^(/[a-z][a-z0-9-]*:[a-z][a-z0-9-]*)([[:space:]].*)?$|\1|p', byte-identical to SEP_SED (the -E/-nE difference is a sed invocation flag, not part of the program string, so it doesn't break the "byte for byte" claim in the new comment). The comment also correctly notes the drift-safety net: a stale copy can't pass silently, since the end-to-end assertion further down still runs the real hook via assert_contains.

This is a comment-only addition — no logic, test-assertion, or behavior change. No new issues found.

Not independently verified: as with every prior pass on this PR, this session's Bash tool required approval for shell execution beyond read-only git/file inspection, so I have not run skill-reference-verify.test.sh myself. This is a pure comment addition with no assertion changes, so there is no new test behavior to verify — the suite's pass/fail status is unaffected by this commit.

No new inline findings to post; all previously-raised findings on this PR have been addressed by the author across earlier commits (ff47864c, 3ec40de, 580fd09).
· branch fix/guardrails-locale-pin

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.

1 participant