Skip to content

fix(markdown-format): resolve the repo root from the filesystem so nested files reach the root config without git - #2130

Merged
kyle-sexton merged 12 commits into
mainfrom
fix/markdown-format-nogit-root-followup
Aug 11, 2026
Merged

fix(markdown-format): resolve the repo root from the filesystem so nested files reach the root config without git#2130
kyle-sexton merged 12 commits into
mainfrom
fix/markdown-format-nogit-root-followup

Conversation

@kyle-sexton

@kyle-sexton kyle-sexton commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Follow-up to #2121. Both gaps are live on main right now — not stale review findings. Reproduced independently: the two new tests, run against main own unmodified hook, give PASS=136 FAIL=2. With the change, 138/0.

The two defects

1. markdown-format.sh:119 calls hook::repo_root raw. With git and jq both absent, a nested file makes the opt-in pre-check read an opted-in repo as opted-out, and the jq notice is swallowed. A repository that did opt in is treated as if it had not, silently.

2. The REPO_ROOT guard at 229-237 covers only the CLAUDE_PROJECT_DIR-set case. The membership scope it exists to fix is gated on that variable being unset, and the no-git fixture runs unset — so the configuration the fix was written for is still broken for nested files. hook::repo_root falls back to the file own directory, the root markdownlint config is never discovered, and the edit is skipped with no diagnostic.

The second is the one #2121 review comment described as "leaving the normal nested-docs case unfixed". That reading was correct and remains correct at main.

The change

Resolve the repository root from the filesystem rather than from a variable: walk up for a .git entry, accepting a directory or a file so linked worktrees and submodules resolve. Git own answer is returned untouched whenever git produced one, and CLAUDE_PROJECT_DIR is kept as a further fallback, so the case main already handles is subsumed rather than replaced.

Four commits, ordered so the defect is demonstrated before it is fixed:

9cbb3c27  tests      (red against main)
4d2cd84e  fix
b42a9351  coverage
66a100df  changelog + version

Verification

  • Baseline main 135/0; with the change 138/0; the two new tests red against main own hook (independently reproduced at e47964ca).
  • main newest positive override test passes unchanged under the replacement — verified rather than assumed, after confirming no .git sits on the temp-dir ancestor chain that would have made the walk answer differently on this host.
  • shellcheck -x -S warning, shell-portability, silent-skips, markdownlint, and changelog-parity all clean.

Stated rather than glossed — three things not confirmed

  • The POSIX-host spawn count was simulated, by addressing the repo in git own path spelling on a Windows host. It was never observed on a real POSIX host.
  • A perf claim was wrong on first pass and is corrected here. An unconditional ~140ms Git Bash cost was expected; measurement showed zero extra spawns on Git Bash, because rev-parse --show-toplevel and dirname never produce the same path spelling there. The extra probe fires only where the spellings agree — 2 to 3 spawns, root-level files only.
  • One PASS=133 FAIL=1 intermittent was seen at an abandoned intermediate commit. It was unnamed, did not reproduce in five runs at the successor commit, and never recurred in any run backing these numbers. Unconfirmed rather than dismissed.

Provenance

Prepared as a cherry-pickable offer while #2121 was open; #2121 merged at 5f92d946 without taking it, leaving no branch to cherry-pick onto, so this is cut from main instead. The offer comment on #2121 remains accurate for what it offered at the time.

Fixes #2134

Conflict resolution against a moving main

main moved under this branch twice and the PR went DIRTY. The version collision was resolved
twice, and the branch now carries the second resolution's numbers.

History note, stated rather than glossed. This resolution was first delivered as two merge
commits (git merge origin/main, never a rebase, since force-push is blocked here). The branch was
subsequently force-pushed to a rebased, linear history carrying the same resolved content and the
same 0.11.4 numbers, which discarded those merge commits. The shipped branch is therefore a rebase,
not the merge described above; the resolution it carries is the same one.

Version coordination with #2135: that PR also bumps markdown-format, and after its own merges
of main it currently takes 0.11.4 as well. Whichever of the two merges second must re-bump — the
manifests will auto-merge to the same number without conflicting, exactly as described above.

Related

@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.

@claude

claude Bot commented Aug 10, 2026

Copy link
Copy Markdown

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


Code review complete

Reviewed the four changed files (hooks/markdown-format.sh, hooks/markdown-format.test.sh, CHANGELOG.md, .claude-plugin/plugin.json) against origin/main per REVIEW.md's code-review lane scope (correctness, design, error handling, tests, docs — security is left to the claude-security-review workflow, which this repo has). One inline finding posted; one cross-cutting finding below.

Note on verification: I could not run shellcheck or the hook's own test suite in this environment (bash execution beyond simple read-only commands required approval that wasn't available in this session), so the PR body's "138/0", "PASS=136 FAIL=2 on main", and "shellcheck clean" claims are author-reported and unverified by me — treat them as such rather than as independently confirmed. Everything below is from reading the diff and the surrounding file directly.

Findings

  1. [Inline, markdown-format.sh:137] 🟡 Nit — resolve_repo_root's fallback-detection only strips a trailing /.claude from hint when checking whether hook::repo_root returned its fallback, but hook::repo_root itself strips both /.claude and \.claude. The accompanying comment even undercounts it as "one rewrite." Looks unreachable in practice today (dirname can't produce a trailing \.claude from this hook's own call sites), but it's a real asymmetry against the shared utility worth closing, and the comment is factually off either way.

  2. Test coverage gap — the PR's headline claim isn't exercised. The core design change is walking up for a .git entry "accepted as a directory or a file so linked worktrees and submodules resolve" (markdown-format.sh#L146: [[ -e "$dir/.git" ]] rather than -d). None of the tests — old or newly added — exercise a .git that is a file (the linked-worktree/submodule layout, per gitrepository-layout). Every git-absent fixture in markdown-format.test.sh lives under $REPO, which is a real git init'd clone (.git is always a directory there). Given how rigorously this PR tests every other claimed behavior (the CLAUDE_PROJECT_DIR-unset case, the no-jq+no-git pre-check, the last-resort case), the one branch that differs from a naive -d-only walk — and is the reason -e was chosen over -d in the first place — has no regression test. A fixture with .git written as a gitdir: <path> file (mimicking git worktree add or a submodule) pointed at a real .git common dir would close this.

Other things checked, no issues found

  • The removed REPO_ROOT guard (old lines 171-178) is correctly subsumed: traced both the CLAUDE_PROJECT_DIR-set and CLAUDE_PROJECT_DIR-unset git-absent cases against the old code and confirmed the new resolve_repo_root covers both, including the nested-file case the old guard's dirname-based condition couldn't reach when unset.
  • resolve_repo_root's physical-path walk (cd "$hint" && pwd -P) is consistent with CONFIG_ROOT/markdownlint_config_discoverable's use of pwd -P, so comparisons stay on physical paths throughout.
  • The new tests correctly target the two gaps described in the PR body (unset CLAUDE_PROJECT_DIR + nested file; the jq-free pre-check with both jq and git absent), and the NOVCS fixture is deliberately placed outside $REPO so it doesn't pick up the real .git there.
  • CHANGELOG.md/plugin.json version bump (0.11.1 → 0.11.2) is consistent with a Fixed entry and Keep a Changelog format.

@claude

claude Bot commented Aug 10, 2026

Copy link
Copy Markdown

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


Security review

  • Gather context (PR diff, changed files)
  • Review markdown-format.sh changes for injection / logic / trust-boundary issues
  • Review test file changes
  • Review plugin.json / CHANGELOG changes
  • Post final findings

Scope: the 4 files changed in this PR (diff against 5f92d94, the merge base). No command-injection, secret-handling, or workflow-permission issues found in the changed code — the only genuinely new security-relevant surface is the filesystem-based root walk itself.


IMPORTANT (confidence: plausible, narrow preconditions) — the new no-git root walk trusts any .git marker's existence, not its content, letting a writer of a shared ancestor directory fabricate a "repo root" for someone else's file

resolve_repo_root, specifically the check at L146:

if [[ -e "$dir/.git" ]]; then
  printf '%s' "$dir"
  return 0
fi

This walks upward from the edited file's directory and stops at the first directory containing anything named .git — a plain file with arbitrary (or no) content satisfies -e. Real git does more than test existence: a file-form .git must contain a gitdir: pointer or git does not treat that directory as a repository boundary. This reimplementation accepts any file named .git, valid or not.

Before this PR, the no-git fallback never walked the filesystem at all — it just returned the file's own directory (hint), a narrow, safe answer. This PR intentionally adds the upward walk (that's the fix), but as a side effect it also adds a new way to redirect root resolution: on a host with no git binary and no CLAUDE_PROJECT_DIR (the documented target case — "an unpacked archive, a vendored copy"), anyone who can write a .git file into a shared ancestor directory of the edited file can make that directory the resolved root, even though it isn't the project's actual boundary.

Impact chain, given the surrounding gates:

  • REPO_ROOT feeds both markdownlint_config_discoverable (L326) and CONFIG_ROOT (L479), which bounds the upward search for .markdownlint* config files.
  • The opt-in gate (markdownlint_config_discoverable) accepts a plain .markdownlint.json/.yaml with no approval step at all — so a config planted in a shared ancestor the legitimate project doesn't own can silently opt a project into the mandatory --fix rewrite / lint-finding flow it never chose, which is exactly the "single-writer" policy marketplace: two unconditional in-place rewriters race on every Write with no defined precedence, nobody sums the always-on per-turn cost, and thirteen CRITICALs need an ordering #1809 exists to prevent.
  • A .cjs/.mjs config planted the same way lands in RISK_CONFIGS and does require explicit trust approval before it can execute — that gate is a real mitigation for the code-execution path, but a user who approves a plausible-looking config without checking which directory it actually lives in would still be tricked into running it.

This is a real trust-boundary widening, but the practical blast radius is narrow: it requires simultaneously (a) git absent on the host, (b) CLAUDE_PROJECT_DIR unset, and (c) the edited file living under a filesystem path where some other writer controls an ancestor directory (e.g. a shared /tmp-style workspace) — the common case (a normal git clone, or CLAUDE_PROJECT_DIR set by the harness) is unaffected, since git's own answer or CLAUDE_PROJECT_DIR is used first and this walk never runs.

Worth deciding explicitly whether that precondition is acceptable, or whether the walk should validate the .git file's content (parse for a gitdir: line) before accepting it as a root marker, matching git's own contract more closely.

Fix this →


No other issues found. Specifically checked and clear:

  • No shell/command injection: REPO_ROOT, FILE, FILE_REL, hint/dir are always passed as quoted arguments or used in [[ ]]/parameter expansion, never interpolated into an eval or unquoted command string.
  • The .git-boundary git-env-clearing (GIT_DIR/GIT_WORK_TREE/etc.) that protects hook::repo_root's underlying git rev-parse from an inherited environment pointing at a different repository is preserved.
  • The code-execution trust gate for .cjs/.mjs markdownlint configs (unrelated to this diff, but consumed by the new root resolution) is unchanged and still requires explicit per-repo approval — this diff doesn't weaken it directly, only widens which directory's config can reach it, per the finding above.
  • Test-file additions (markdown-format.test.sh) only add fixtures/assertions; no unsafe temp-file handling or injection introduced.
  • plugin.json/CHANGELOG.md changes are version/documentation only.

zizmor-covered categories (unpinned actions, dangerous triggers, excessive permissions, template injection) are out of scope for this lane per instructions, and this PR touches no workflow files anyway.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 66a100dfcd

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread plugins/markdown-format/hooks/markdown-format.sh Outdated
Comment thread plugins/markdown-format/hooks/markdown-format.sh Outdated
@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.

@kyle-sexton

Copy link
Copy Markdown
Contributor Author

P1 PRRT_kwDOTCGFQM6XwCBM — confirmed, and fixed in df8cf3d9

The reviewer is right. Reproduced with the real markdownlint-cli2 v0.23.2 and md5 of the out-of-tree target, with a canary in every run — a plain nested in-repo file that must be rewritten — so a hook that dies before linting cannot read as a clean pass:

hook canary (does the nested fix work?) out-of-tree symlink target
main 5f92d946 unchanged — this is the bug this PR fixes UNCHANGED (vacuously: nothing was linted)
this branch 66a100df REWRITTEN REWRITTEN 3c62fb6e60fdfc85
with df8cf3d9 REWRITTEN UNCHANGED 3c62fb6e3c62fb6e

Main's row is UNCHANGED only because it never lints a nested file at all — so it is not evidence of containment, and I would not have caught that without the canary.

Measured while I was there: the root-level form of the same escape already rewrites the external target on main. So this branch did not invent the hole; it widened it from root-level files to every nested one. df8cf3d9 closes both.

Root cause

Making discovery succeed is what puts a file in front of --fix. Without git, the membership scope could not ask in_git_working_tree anything, so containment was not checked at all — and a symlink is the one shape whose lexical parent (inside the repository, where the config lives) and physical parent (outside it) disagree.

So containment is now decided from the filesystem instead of skipped. It runs only where the physical path differs from the lexical one, which for an ordinary file it never does — a git-less repository lints exactly as before. An undecidable git verdict still lints; an escape the filesystem can prove does not.

The two remedies that don't work, and why this one does

Resolving from FILE_PHYSICAL at the REPO_ROOT line does not help. markdownlint_config_discoverable is called with "$FILE", and it anchors on dirname "$1" — the symlink's own parent, a real directory inside the repository — independently of REPO_ROOT. Moving the root does not move discovery. The guard therefore sits in the membership scope, before discovery, where an early exit governs whether any of it runs.

A containment check comparing hook::physical_path output against REPO_ROOT is a spelling mismatch, not a containment answer. hook::physical_path resolves via realpath, which leaves /tmp as /tmp, while pwd -P resolves it to the underlying directory — hook-utils.sh documents exactly this divergence on hook::under_temp_root ("realpath resolves the Windows form to a drive path while leaving /tmp as /tmp"). Compare the two currencies and a file plainly inside the tree is rejected.

physically_inside therefore canonicalizes both operands through cd … && pwd -P at the point of comparison. That is the deliberate answer to "how are the two spellings reconciled":

  • pwd -P is already this hook's local currency — markdownlint_config_discoverable (both operands), CONFIG_ROOT, CONFIG_TARGET_DIR, and resolve_repo_root's walk all use it. Adding a third regime would be the drift, not the fix.
  • hook::normalize_path is a spelling normalizer (backslashes, drive-letter case) — it does not canonicalize /tmp, so pairing it with physical_path reproduces the divergence rather than resolving it. It is right for hook::read_file_path's guard, where both operands are physical_path output, and wrong for a comparison against a pwd -P root.
  • hook::physical_path is documented to degrade to the unchanged lexical path when no canonicalizer exists. A containment guard whose currency can silently become un-normalized input is the wrong basis for deciding whether a write may leave the repository.

Tests

Two cases, both nestings, asserting on link survival rather than the target's bytes. The suite's stub linter rewrites with sed -i, which renames a temp over the path and so replaces a symlink while leaving the target untouched — verified on this host. Under that stub an unchanged target proves nothing; a surviving symlink proves --fix never ran on it. They also use MSYS=winsymlinks:nativestrict, which does create real symlinks here — the file's existing escape cases probe with bare ln -s and skip on Windows, which is why this shape had no Windows coverage before.

suite before this commit   PASS=138 FAIL=2   (the two new cases, red)
suite after                PASS=140 FAIL=0

shellcheck -x -S warning, check-shell-portability.sh --paths, check-silent-skips.sh --paths, and markdownlint-cli2 on the CHANGELOG all clean.

Nit PRRT_kwDOTCGFQM6XwDTK — fixed in the same commit

Correct as stated. hook::repo_root strips /.claude and \.claude; the signature check tested only the first, so a backslash-spelled hint would not have been recognised as a fallback. Both forms are now checked and the comment no longer says "one rewrite".

Not verified

The /tmp-versus-pwd -P divergence is documented in hook-utils.sh and I reproduced its effect on this Windows host; I have not observed it on a POSIX host, where realpath and pwd -P may well agree and the mismatch would not arise. The reconciliation is correct either way — it only ever normalizes — but the necessity argument above is Windows-grounded.

I have not resolved either thread.

@claude

claude Bot commented Aug 10, 2026

Copy link
Copy Markdown

Claude finished @kyle-sexton's task in 5m 11s —— View job


Code review complete

Reviewed the current state of all 4 changed files (hooks/markdown-format.sh, hooks/markdown-format.test.sh, CHANGELOG.md, .claude-plugin/plugin.json) against origin/main, focused on the new commit (df8cf3d9) that landed since the prior review round: the physically_inside() containment guard added for the git-absent symlink-escape regression Codex and the security lane both flagged.

  • Gather context (diff since previous review, focus on new df8cf3d9 containment fix)
  • Review markdown-format.sh changes (resolve_repo_root, physically_inside, containment guard)
  • Review test file changes (new symlink-containment tests)
  • Review plugin.json / CHANGELOG.md
  • Post findings and final summary

Note on verification: I could not run shellcheck, the test suite, or any other shell command beyond read-only git/gh lookups in this environment (Bash execution required approval that wasn't available in this session). The PR/thread's "138/140 passing", "shellcheck clean", and the symlink-rewrite reproduction numbers in the thread are author-reported and unverified by me — everything below is from reading the diff and the surrounding file directly.

Findings

1 inline finding posted on markdown-format.test.sh:

  • 🟡 Test coverage gap — the new git-absent escaping-symlink loop (markdown-format.test.sh:583-603) thoroughly covers the case where physically_inside() must return false (skip), nested and root-level, but nothing exercises the opposite: a symlinked .md whose target legitimately resolves inside the repo, run with git absent, asserting --fix still applies. Every other escape assertion in this file is paired with a control proving the guard isn't over-broad (e.g. the git-present "neutered canonicalizers" control at line 397-409); physically_inside has no such control, so a regression that made it always return false would ship silently — every existing test expects a skip either way.

Traced through and correct, no issues found

  • resolve_repo_root() (markdown-format.sh:137-164): the fallback-signature check now correctly tests both the /.claude and \.claude forms (resolves last round's nit), the .git-file-or-directory walk terminates correctly at filesystem root, and CLAUDE_PROJECT_DIR is used strictly as a terminator, never widening scope.
  • physically_inside() (markdown-format.sh:225-233) canonicalizes both operands through cd … && pwd -P before comparing, so it isn't exposed to the /tmp-vs-pwd -P spelling mismatch identified on the PR thread — verified by tracing that REPO_ROOT, however resolve_repo_root produced it (git answer, filesystem walk, CLAUDE_PROJECT_DIR, or unresolved hint), is re-canonicalized inside physically_inside itself rather than trusted as already-physical.
  • The new containment elif at markdown-format.sh:301-304 only invokes physically_inside when FILE_PHYSICAL != FILE, so an ordinary (non-symlink) file on a git-less host is untouched by the new check, matching the changelog's "a git-less repository lints exactly as before" claim.
  • CHANGELOG.md/plugin.json version bump (0.11.2 → 0.11.3) is consistent with a new Fixed entry and Keep a Changelog format; no dangling link-reference section to update.

Both inline threads from the previous round (the .claude/\.claude fallback-signature nit and Codex's symlink-escape P1) read as resolved in this diff, matching the extensive reproduction the author posted on those threads.
· branch fix/markdown-format-nogit-root-followup

@claude

claude Bot commented Aug 10, 2026

Copy link
Copy Markdown

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


Security review complete

Scope: the 4 files changed in this PR at HEAD 5b34b5d (diff against origin/main) — hooks/markdown-format.sh, hooks/markdown-format.test.sh, CHANGELOG.md, .claude-plugin/plugin.json. hook-utils.sh is unchanged by this PR and out of scope. This head already includes the symlink-escape fix from df8cf3d9 (physically_inside) that closed the Codex/kyle-sexton-confirmed finding from earlier in this thread — I re-traced that fix and it holds (both operands canonicalized through cd … && pwd -P, so the /tmp-vs-pwd -P spelling mismatch that broke the first candidate fix cannot arise here).


IMPORTANT (confidence: plausible) — the no-git .git-marker walk in resolve_repo_root is unauthenticated (any file or directory named .git is accepted) and can override CLAUDE_PROJECT_DIR too, not only the git-absent case

resolve_repo_root, specifically L148-L158:

if dir="$(cd "$hint" 2>/dev/null && pwd -P)"; then
  while :; do
    if [[ -e "$dir/.git" ]]; then
      printf '%s' "$dir"
      return 0
    fi
    parent="$(dirname "$dir")"
    [[ "$parent" != "$dir" ]] || break
    dir="$parent"
  done
fi
if [[ -n "${CLAUDE_PROJECT_DIR:-}" ]]; then
  printf '%s' "$CLAUDE_PROJECT_DIR"
  return 0
fi

Two issues compound here, both already latent in this PR's design and neither addressed by df8cf3d9:

  1. -e accepts any .git, valid or not. Real git requires a file-form .git to contain a gitdir: pointer (https://git-scm.com/docs/gitrepository-layout); this walk accepts an empty file, an empty directory, or arbitrary content with the right name as a repository boundary. Anyone who can write a .git-named entry into a shared ancestor directory of the edited file can redirect root resolution there.

  2. The walk runs, and can match, before the CLAUDE_PROJECT_DIR fallback is ever consulted — so setting CLAUDE_PROJECT_DIR does not bound this the way the surrounding comments imply. CLAUDE_PROJECT_DIR is documented at L127-L133 as the answer "for a project that is no working tree at all (an unpacked archive, a vendored copy)" — exactly the scenario where hook::repo_root fails and the filesystem walk starts. But the walk climbs from hint (the edited file's directory) all the way to the filesystem root looking for .git, with no upper bound at CLAUDE_PROJECT_DIR. If CLAUDE_PROJECT_DIR=/home/user/vendored-project (no .git inside, by construction — that's why this fallback exists) and any ancestor above it/home/user, /home, a shared CI workspace root — contains something named .git (planted maliciously, or just an unrelated real repo one level up), the walk stops there and returns that wider directory as REPO_ROOT, and CLAUDE_PROJECT_DIR is never reached. This is broader than the precondition the earlier reviewer described ("no git, no CLAUDE_PROJECT_DIR") — a set CLAUDE_PROJECT_DIR does not protect the archived/vendored case this fallback exists for.

Impact chain: REPO_ROOT feeds markdownlint_config_discoverable (L361) and CONFIG_ROOT/collect_risky_configs (L514-L529), which bound the upward search for .markdownlint* configs. A plain (non-.cjs/.mjs) config planted in that wider, attacker-writable ancestor opts the vendored project into --fix rewrites under rules it never chose, with no approval prompt at all — the trust gate only fires for code-loading .cjs/.mjs configs or declarative configs naming customRules/markdownItPlugins/outputFormatters. A planted .cjs config does hit the trust gate (real mitigation for the code-execution path), but only if the user notices which directory it actually lives in before approving.

Practical blast radius stays narrow — it requires (a) git absent, or the edited file sitting outside any git working tree git itself can discover (the documented target scenario for this whole fallback), and (b) some other writer controlling a directory above the project root (CLAUDE_PROJECT_DIR, when set, does not exclude this — an ancestor above it is still fair game). The ordinary case (a real git clone, or an archived project with no untrusted ancestor) is unaffected.

This was flagged by an earlier automated review on this same PR (as "narrow preconditions: git absent AND CLAUDE_PROJECT_DIR unset") and does not appear to have been addressed by any of the commits since — df8cf3d9 fixed the separate symlink-escape issue but left this .git-marker walk unchanged. Worth deciding explicitly whether the precondition is acceptable, or whether the file-form .git should be content-validated (gitdir: prefix) and/or the walk should stop at CLAUDE_PROJECT_DIR rather than climbing past it when that variable is set.

Fix this →


Other things checked, no issues found

  • The symlink-escape fix (df8cf3d9) is sound. physically_inside (L225-L233) canonicalizes both the file's physical parent and REPO_ROOT through cd … && pwd -P before comparing, so it fails closed on an unresolvable path and cannot be defeated by the /tmp-vs-pwd -P spelling divergence that broke the first candidate remedy (per hook-utils.sh's documented hook::under_temp_root divergence). It's invoked only when FILE_PHYSICAL != FILE (L301), so an ordinary (non-symlink) git-less file takes the same path as before — no regression there.
  • No command/shell injection introduced. REPO_ROOT, FILE, hint/dir in the new/changed code are always used in quoted [[ ]]/parameter expansions or passed as quoted arguments, never interpolated into eval or an unquoted command string.
  • git-selection env vars stay cleared where git is actually invoked (in_git_working_tree, file_is_gitignored); resolve_repo_root's own filesystem walk correctly avoids needing that clearing by reading the filesystem directly rather than asking git — the design intent stated in its comment holds for the walk itself (separate from the .git-marker trust issue above).
  • plugin.json/CHANGELOG.md changes are version/documentation only, no security-relevant content.
  • Test-file additions only add fixtures/assertions (including the new symlink-escape regression tests); no unsafe temp-file handling or injection introduced there.
  • No workflow files touched, so zizmor-covered categories (unpinned actions, dangerous triggers, permissions, template injection) are correctly out of scope for this lane.
    · branch fix/markdown-format-nogit-root-followup

Comment thread plugins/markdown-format/hooks/markdown-format.test.sh
kyle-sexton and others added 5 commits August 10, 2026 09:59
…ll open

The nested no-git case landed here is anchored by CLAUDE_PROJECT_DIR. Two
neighbours of it are not covered and do not pass.

CLAUDE_PROJECT_DIR UNSET. Not an exotic variant: the membership scope this PR
fixes is itself gated on `[[ -z "${CLAUDE_PROJECT_DIR:-}" ]]`, and the primary
no-git fixture runs unset — so unset is the configuration the fix is about, and
a root taken from CLAUDE_PROJECT_DIR cannot serve it. Nothing but the
filesystem can anchor the root there.

The opt-in PRE-CHECK's own root resolution, on the path that runs before jq
exists. No case in this file can reach it: every jq-absence case runs with git
present, every git-absence case runs with jq present. With both absent and the
file nested, a root that collapses to the file's own directory reads a
repository that DID opt in as one that never did, and swallows the jq notice it
is owed. The existing config-less pair pins the opposite direction, so this
cannot pass by the hook merely having stopped warning.

Both fail on this commit by design; the fix follows.

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

Replaces the CLAUDE_PROJECT_DIR override with resolve_repo_root, which
recognizes hook::repo_root's fallback by its signature (an answer equal to the
hint) and then performs the walk git's own discovery performs: upward for a
`.git` entry, accepted as a directory for an ordinary clone or as a FILE for a
linked worktree or submodule. git's answer is returned untouched whenever git
produced one. CLAUDE_PROJECT_DIR stays as a last resort, for a project that is
no working tree at all, so the previous behaviour is subsumed rather than
dropped; when nothing resolves, the hint stands, which keeps the documented
out-of-tree bound true.

Three things follow from resolving the root rather than reading it off a
variable.

The case this PR is about is covered. The membership scope is gated on
CLAUDE_PROJECT_DIR being UNSET and the no-git fixture runs unset, so a root
taken from that variable cannot serve the configuration the gate exists for.

The opt-in pre-check gets the same resolution. It runs before jq exists and had
the identical defect; with git and jq both absent a nested file made it read an
opted-in repository as one that never opted in and swallowed the jq notice.

The `[[ "$REPO_ROOT" == "$(dirname "$FILE")" ]]` guard goes away, and with it
two problems that are one guard seen from two sides: it is true only for a file
at the repository root, so it spawns a redundant `git rev-parse` on every
root-level Markdown edit wherever the payload's path spelling matches git's,
and it is false for every nested file, so a nested fixture cannot exercise the
branch behind it at all.

The shared hook::repo_root is deliberately NOT changed: it is a synced library
whose edit fans out to sixteen plugin copies and their manifest versions under
CI's --check-bump, and it is under concurrent edit for ccp#2122.

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

resolve_repo_root keeps CLAUDE_PROJECT_DIR below the filesystem walk, for a
project that is no working tree at all — an unpacked archive, a vendored copy —
where the walk finds no `.git` to stop at. Every other fixture in this file
lives in a real git tree, so nothing else can reach that branch; the fixture is
deliberately outside $REPO for that reason.

Not vacuous: with the clause cut from resolve_repo_root the same fixture is
skipped rather than linted.

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

The earlier form of this change was cut against the #2121 branch before it
merged, so it edited the then-unreleased entry in place. That version has since
shipped, and rewriting a released entry would misstate what it did. Restores
0.11.1 verbatim and gives this change its own entry, with the manifest bumped to
match — the plugin version is the update cache key, so an unbumped plugin never
delivers the change.

The number is whatever sits above the highest released entry at rebase time;
main has taken several while this branch was in review, so it is deliberately
not restated here.

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

Review found that resolving the root from the filesystem introduced a new
failure mode, and it reproduces: an in-repository symlink whose target lives
outside the tree. Making discovery succeed is what puts a file in front of
--fix, so the repository's own config opened the gate and the linter followed
the link and rewrote a file outside the repository. Measured with the real
markdownlint-cli2 v0.23.2 and md5 of the target, and the same measurement shows
the root-level form of the escape was already reachable before this branch.

The cause is that without git this scope could not ask in_git_working_tree
anything, so containment was not checked at all — and a symlink is the one shape
whose lexical parent (inside the repository, where the config lives) and
physical parent (outside it) disagree. Containment is now decided from the
filesystem instead of skipped. It runs only where the physical path differs from
the lexical one, which for an ordinary file it never does, so a git-less
repository lints exactly as before: an undecidable GIT verdict still lints, an
escape the filesystem can prove does not.

physically_inside canonicalizes BOTH operands through `cd … && pwd -P`. That is
load-bearing rather than incidental. hook::physical_path resolves via realpath,
which leaves /tmp as /tmp where pwd -P resolves it to the underlying directory —
hook-utils.sh documents that divergence on hook::under_temp_root — so comparing
a physical_path result against a pwd -P root compares spellings, not
containment, and rejects a file that is plainly inside the tree. pwd -P is also
the currency markdownlint_config_discoverable, CONFIG_ROOT and CONFIG_TARGET_DIR
already use, and unlike hook::physical_path it cannot silently degrade to the
lexical path when no canonicalizer exists.

The guard sits in the membership scope, before config discovery rather than at
the REPO_ROOT line: discovery is anchored on the file's lexical parent
independently of REPO_ROOT, so moving the root alone cannot contain the escape.

Also fixes the signature check in resolve_repo_root to recognize both suffix
forms hook::repo_root strips, `/.claude` and `\.claude`, not only the first.

Tests cover both nestings and assert on LINK SURVIVAL rather than target bytes:
the suite's stub rewrites with `sed -i`, which replaces a symlink instead of
writing through it, so an unchanged target proves nothing there while a
surviving link proves --fix never ran.

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

claude Bot commented Aug 10, 2026

Copy link
Copy Markdown

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


Security review complete

  • Gather context (PR diff against origin/main, changed files)
  • Review markdown-format.sh changes for injection / logic / trust-boundary issues
  • Review test file changes
  • Review plugin.json / CHANGELOG.md changes
  • Post final findings

Scope: the 4 files changed in this PR at HEAD 62765c6 (diff against origin/main) — hooks/markdown-format.sh, hooks/markdown-format.test.sh, CHANGELOG.md, .claude-plugin/plugin.json.

No security-relevant code changed since the last review round. I diffed 5b34b5d (the commit the two prior review rounds on this thread covered) against current HEAD 62765c6 for all four files: markdown-format.sh and markdown-format.test.sh are byte-identical. The only changes are the version bump (0.11.30.11.4 in plugin.json) and a new CHANGELOG.md entry, both from merging origin/main in twice (bringing in an unrelated #2120/#2124 hook-utils.sh fix that doesn't touch this hook) plus this PR's own bump. Nothing here is new security surface.


IMPORTANT (confidence: plausible) — still open: the no-git .git-marker walk in resolve_repo_root remains unauthenticated and unbounded by CLAUDE_PROJECT_DIR

This is the same finding posted on this thread twice already (05:12:59 and 13:29:13) and it is unchanged at current HEAD — resolve_repo_root, specifically L148-L163:

if dir="$(cd "$hint" 2>/dev/null && pwd -P)"; then
  while :; do
    if [[ -e "$dir/.git" ]]; then
      printf '%s' "$dir"
      return 0
    fi
    parent="$(dirname "$dir")"
    [[ "$parent" != "$dir" ]] || break
    dir="$parent"
  done
fi
if [[ -n "${CLAUDE_PROJECT_DIR:-}" ]]; then
  printf '%s' "$CLAUDE_PROJECT_DIR"
  return 0
fi

Two compounding issues, neither addressed by the df8cf3d9 symlink-containment fix (which closed a separate, already-confirmed regression):

  1. -e "$dir/.git" accepts any file or directory named .git, valid or not — real git requires a file-form .git to contain a gitdir: pointer (gitrepository-layout). Anyone who can write a .git-named entry into a shared ancestor directory of the edited file can redirect root resolution there.
  2. The walk runs — and can match — before the CLAUDE_PROJECT_DIR fallback is consulted, with no upper bound at CLAUDE_PROJECT_DIR. For the documented last-resort case this fallback exists for (CLAUDE_PROJECT_DIR set to an unpacked archive or vendored copy with no .git inside, by construction), a .git-named entry in any ancestor above CLAUDE_PROJECT_DIR — a shared workspace root, a CI checkout parent — is matched first and wins, so setting CLAUDE_PROJECT_DIR does not bound this the way the surrounding comments (L127-L133) imply.

Impact chain is unchanged from the prior report: REPO_ROOT feeds markdownlint_config_discoverable and CONFIG_ROOT/collect_risky_configs, bounding the upward search for .markdownlint* config. A plain (non-.cjs/.mjs) config planted in that wider, attacker-writable ancestor opts a git-less/vendored project into --fix rewrites under rules it never chose, with no approval prompt — the trust gate only fires for code-loading configs. Blast radius stays narrow: it needs (a) git absent or the file outside any working tree git can discover, and (b) some other writer controlling a directory above the project root.

Worth an explicit decision: content-validate a file-form .git (gitdir: prefix) before accepting it as a root marker, and/or stop the walk at CLAUDE_PROJECT_DIR rather than climbing past it when that variable is set.

Fix this →


Other things checked, no issues found

  • The symlink-escape fix (df8cf3d9) still holds at this HEAD — physically_inside (L225-L233) canonicalizes both operands through cd … && pwd -P before comparing, fails closed, and is only invoked when FILE_PHYSICAL != FILE — no regression for the ordinary git-less case.
  • No command/shell injection in the unchanged code: REPO_ROOT, FILE, hint/dir are always quoted in [[ ]]/parameter expansions or passed as quoted arguments, never interpolated into eval or an unquoted command string.
  • git-selection env vars stay cleared where git is actually invoked; the filesystem walk correctly avoids needing that clearing by reading the filesystem directly.
  • plugin.json/CHANGELOG.md changes in this diff are version/documentation only, including the 0.11.3 entry pulled in from the origin/main merge (unrelated hook-utils.sh fix, not part of this hook).
  • No workflow files touched, so zizmor-covered categories are correctly out of scope for this lane.

@claude

claude Bot commented Aug 10, 2026

Copy link
Copy Markdown

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


Code review complete

This lane's job on synchronize is to review what changed since the last round. All four changed files were already reviewed twice at 5b34b5d (code-review + security lanes), and both P1s raised there — the git-absent symlink-escape regression (Codex, confirmed by the author and fixed in df8cf3d9) and the .claude/\.claude fallback-signature nit (fixed in the same commit) — read as resolved, with the author's own reproduction numbers matching the diff.

  • Diff since last review round (5b34b5d → current HEAD 62765c6)
  • Confirm markdown-format.sh / markdown-format.test.sh unchanged since last full review
  • Review the new CHANGELOG.md / plugin.json changes (second origin/main merge)
  • Post findings

Verified, not just diffed: git rev-parse HEAD:<path> for all four files matches the blob SHAs listed for this PR exactly (ab0cb2c…, b69df6c…, 3e6f33f…, 2fb6639…), and git diff 5b34b5d..HEAD -- .../markdown-format.sh .../markdown-format.test.sh is empty — those two files carry no changes since the round both lanes already reviewed and cleared. Only CHANGELOG.md and plugin.json moved, via the second origin/main merge.

What changed since the last round

The PR body's predicted version collision with #2135 happened as anticipated: origin/main picked up 0.11.3 from elsewhere while this branch's entry also claimed it, so this merge re-bumped to 0.11.4.

  • plugin.json0.11.30.11.4.
  • CHANGELOG.md — this branch's actual Fixed content (the two no-git root-resolution defects + the symlink-escape closure) now sits under ## [0.11.4]; the ## [0.11.3] slot it vacated is correctly occupied by origin/main's own env -S / hook-utils.sh entry brought in by the merge. Version headers descend strictly (0.11.40.11.30.11.20.11.1 → ...), plugin.json's version matches the top entry, and there's no link-reference footer to keep in sync.

No new findings. The merge-conflict resolution is mechanically correct and consistent with what the PR body described as the expected coordination outcome with #2135.
· branch fix/markdown-format-nogit-root-followup

@kyle-sexton
kyle-sexton force-pushed the fix/markdown-format-nogit-root-followup branch from 62765c6 to 193c9d2 Compare August 10, 2026 14:44
@claude

claude Bot commented Aug 10, 2026

Copy link
Copy Markdown

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


Code review complete

  • Gather context (diff since last review round, CLAUDE.md/REVIEW.md scope)
  • Identify what changed since HEAD 62765c6 (last code-review round)
  • Review new commits for correctness, error handling, tests, maintainability
  • Post findings and final summary

No content changes since the last full review round. I compared the blob SHAs of all 4 changed files at current HEAD 193c9d2 against the ones the 14:31:24 review round verified at 62765c6:

markdown-format.sh        ab0cb2c…  (identical)
markdown-format.test.sh   b69df6c…  (identical)
CHANGELOG.md               3e6f33f…  (identical)
plugin.json                2fb6639…  (identical)

All four are byte-identical — git rev-parse HEAD:<path> for each file returns the same SHA already listed in this PR's changed_files. This synchronize was a history rewrite (the branch's commits were squashed/reordered into the cleaner 5-commit red→fix→coverage→docs narrative the PR body describes — 39fdaac/5ca5852/9a9a714/b62c814/193c9d2), not a code change. The commit SHAs the earlier rounds on this thread cite (66a100df, df8cf3d9, 5b34b5d, 62765c6) no longer exist on the branch as a result, but the tree they produced is exactly what's here now — worth knowing if anyone goes looking for those SHAs later, not a defect.

Since the diff against the last-reviewed state is empty, I have nothing new to add on correctness, error handling, or maintainability — both P1s from earlier rounds (the git-absent symlink escape, and the .claude/\.claude fallback-signature nit) are confirmed still fixed at this content.

Two test-coverage gaps flagged by this lane in earlier rounds are still open at this content (not re-flagging as new inline comments since nothing changed to re-review, just noting they remain unaddressed):

  • No fixture exercises a file-form .git (the worktree/submodule shape resolve_repo_root's -e check was written to accept) — markdown-format.sh#L150.
  • No control test proves physically_inside returns true for a legitimately in-repo symlink on a git-absent host — every current git-absent symlink test expects a skip, so a regression collapsing it to always-false would pass the suite — markdown-format.sh#L225-L233 / markdown-format.test.sh#L603.

The unbounded/unauthenticated .git-marker-walk finding raised repeatedly by the security lane is that lane's scope per REVIEW.md's split, not restated here.
· branch fix/markdown-format-nogit-root-followup

@claude

claude Bot commented Aug 10, 2026

Copy link
Copy Markdown

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


Security review complete

  • Gather context (PR diff against origin/main, changed files)
  • Review markdown-format.sh changes for injection / logic / trust-boundary issues
  • Review test file changes
  • Review plugin.json / CHANGELOG.md changes
  • Post final findings

Scope: the 4 files changed in this PR at HEAD 193c9d2 (diff against origin/main) — hooks/markdown-format.sh, hooks/markdown-format.test.sh, CHANGELOG.md, .claude-plugin/plugin.json. This head includes the newest commit (193c9d2, "keep the no-git root walk from letting --fix leave the repo") — the physically_inside() symlink-containment fix and the resolve_repo_root .claude/\.claude fallback-signature fix that this thread's prior rounds asked for.


IMPORTANT (confidence: plausible, narrow preconditions) — the unauthenticated .git-marker walk this thread already flagged twice can now also defeat the symlink-containment fix just added in this same commit, not only widen config discovery

This is a real escalation of the finding posted twice already on this thread (05:12:59, 13:29:13, 14:26:36), not a duplicate of it. resolve_repo_root, specifically the walk at L148-L158, still accepts any file or directory literally named .git as a repo-root marker ([[ -e "$dir/.git" ]], no content check — real git requires a file-form .git to contain a gitdir: pointer per gitrepository-layout), and the walk still climbs unbounded past CLAUDE_PROJECT_DIR rather than stopping at it. That part is unchanged since the last two rounds and remains open.

What's new at this head: REPO_ROOT — the output of that unauthenticated walk — is now also the trust boundary for the containment guard this very commit introduces to fix the symlink-escape bug:

290  REPO_ROOT="$(resolve_repo_root "$(dirname "$FILE")")"
...
301  elif [[ "$FILE_PHYSICAL" != "$FILE" ]] &&
302    ! physically_inside "$FILE_PHYSICAL" "$REPO_ROOT"; then
303    exit 0

physically_inside canonicalizes both operands correctly (cd … && pwd -P) — that part is sound, as confirmed in the prior review round. But it can only be as trustworthy as the REPO_ROOT it's handed, and REPO_ROOT is exactly the value the unauthenticated walk produces.

Concrete chain, under the same preconditions the prior finding already required (git absent from PATH, CLAUDE_PROJECT_DIR unset, the edited file a symlink whose physical target sits outside the true project directory, and an attacker-writable ancestor directory shared with that target):

  1. Attacker writes any file or directory named .git into a shared ancestor (e.g. a shared /tmp-style workspace root that is an ancestor of both the project and the symlink's external target).
  2. resolve_repo_root's walk (L148-158) stops at that ancestor and returns it as REPO_ROOT — wider than the true project root.
  3. At L292-304, physically_inside("$FILE_PHYSICAL", "$REPO_ROOT") now finds the external target's directory is under the (attacker-widened) REPO_ROOT, so the exit 0 skip does not fire.
  4. --fix proceeds and markdownlint-cli2 follows the symlink, rewriting the out-of-tree file — the exact class of bug this commit was written to close (confirmed reproducible by the author against --fix following a symlink, per the thread's df8cf3d9/193c9d2 writeup), just reopened through the root-resolution side instead of the containment-check side.

So the .git-marker trust gap is no longer only "a plain config gets silently applied under rules the project never chose" (the impact described in the last two rounds) — under the same narrow preconditions it can fully re-open the arbitrary-out-of-tree-write escape that this commit's physically_inside guard exists to close, because the guard's own boundary is attacker-influenceable.

Blast radius is unchanged from the prior rounds' assessment — still requires git absent, CLAUDE_PROJECT_DIR unset, and a writer-controlled ancestor directory — but the impact ceiling for that same precondition is now higher than previously described.

Worth deciding explicitly, same remedy shape suggested in the last two rounds: content-validate a file-form .git (require a gitdir: prefix, matching git's own contract) before accepting it as a root marker, and/or stop the walk at CLAUDE_PROJECT_DIR rather than climbing past it when that variable is set. Given this now also undermines the symlink-containment fix, closing it before merge seems warranted rather than deferred.

Fix this →


Other things checked, no issues found

  • The symlink-escape fix (physically_inside, L221-233, added this commit) is sound on its own terms — it canonicalizes both operands through cd … && pwd -P, fails closed on an unresolvable path, and is only invoked when FILE_PHYSICAL != FILE (L301) — no regression for the ordinary non-symlink git-less case. It's the input to this check (REPO_ROOT) that carries the pre-existing trust gap, not the check's own logic.
  • The .claude/\.claude fallback-signature fix (L140-141) correctly tests both suffix forms now, closing the nit from the first review round.
  • No new command/shell injection. REPO_ROOT, FILE, FILE_PHYSICAL, hint/dir in the new/changed code are always used in quoted [[ ]]/parameter expansions or passed as quoted arguments, never interpolated into eval or an unquoted command string.
  • git-selection env vars stay cleared where git is actually invoked (in_git_working_tree); the filesystem walk correctly avoids needing that clearing by reading the filesystem directly.
  • Test-file additions (symlink-escape regression tests, the CLAUDE_PROJECT_DIR-unset nested case, the no-jq+no-git pre-check case) only add fixtures/assertions using mktemp-scoped temp dirs; no unsafe temp-file handling or injection introduced.
  • plugin.json/CHANGELOG.md changes are version/documentation only (0.11.30.11.4), no security-relevant content.
  • No workflow files touched, so zizmor-covered categories (unpinned actions, dangerous triggers, permissions, template injection) are correctly out of scope for this lane.

Note on verification: I could not execute markdownlint-cli2, the hook, or its test suite in this environment (git network fetch and most Bash execution required approval that wasn't available in this session) — everything above is from tracing the diff and surrounding file directly, matching the verification constraints noted in this thread's earlier review rounds. The escalation chain in the main finding is a code trace, not a reproduced measurement.
· branch fix/markdown-format-nogit-root-followup

@kyle-sexton

Copy link
Copy Markdown
Contributor Author

Rebased onto current main; every number below re-measured from scratch

New head: 193c9d2ea89b6677e9ef605afb8c583e749b44c6. mergeStateStatus was DIRTY, now BLOCKED (checks only).

193c9d2e fix(markdown-format): keep the no-git root walk from letting --fix leave the repo
b62c8148 docs(markdown-format): record the follow-on as its own version, not a rewrite of 0.11.1
9a9a7144 test(markdown-format): cover CLAUDE_PROJECT_DIR as the root of last resort
5ca58522 fix(markdown-format): resolve the repo root from the filesystem, not from a variable
39fdaac9 test(markdown-format): cover the two no-git root-resolution cases still open
28768645 (main at rebase time)

The branch previously carried two Merge origin/main commits. Nothing from them was lostgit diff 62765c64 193c9d2e is empty across every file, so the merged and rebased trees are byte-identical; only the history shape differs. The force-push carried an explicit lease on 62765c64.

Renumbered 0.11.3 → 0.11.4: main released 0.11.3 for the env -S guard fix (#2124) while this was in review, after having taken 0.11.2 for the NUL fix (#2120). The version commit's message no longer names a number, so the next rebase will not invalidate it again.

Matrix, re-measured against the new BEFORE tree

Not carried across — the BEFORE arm is a different tree now (it picked up hook-utils.sh from #2120/#2124; all three arms verified to share hook-utils.sh md5 ae6709ff). Real markdownlint-cli2 v0.23.2, md5 of the out-of-tree target, canary in every arm.

arm canary (nested regular) nested symlink root symlink
main 28768645 unchanged — the defect this PR fixes UNCHANGED (vacuous) REWRITTEN 3c62fb6e60fdfc85
branch b62c8148 (pre-containment) REWRITTEN REWRITTEN 3c62fb6e60fdfc85 REWRITTEN 3c62fb6e60fdfc85
branch 193c9d2e (HEAD) REWRITTEN UNCHANGED 3c62fb6e UNCHANGED 3c62fb6e

One row needs an honest caveat rather than a blanket dismissal. On the main arm the canary correctly does not fire — that is precisely the bug this PR fixes, so main's nested row is vacuous. Its root row is still genuine evidence: a REWRITE can only happen if the linter ran, so it is self-evidencing regardless of the canary. That row is the #2134 finding re-confirmed on today's main.

Suite

PASS=141 FAIL=0
  ok: git absent: a nested escaping symlink is skipped, not handed to --fix
  ok: git absent: a root escaping symlink is skipped, not handed to --fix
  ok: git present: the override stays inert — the hook resolved REPO_ROOT to the git toplevel

That third line is #2128's negative test, which landed on main after this branch was cut and which this PR deletes the override behind. It passes unchanged: resolve_repo_root returns git's own answer whenever git produced one, so REPO_ROOT is still the git toplevel. I also ran that case in isolation against both hooks — main reports INNER, this branch reports INNER.

Also clean: shellcheck -x -S warning, check-shell-portability.sh --paths, check-silent-skips.sh --paths, sync-hook-utils.sh --check (all 16 copies match), check-changelog-parity.sh --check-bump, and markdownlint-cli2 on the CHANGELOG.

The intermittent from earlier is now identified

I previously reported an unnamed PASS=133 FAIL=1 I could not reproduce. It is telemetry/slow-sink, a wall-clock assertion that the hook does not block on a slow sink. It fired once during this round at delta 12870ms. It is not caused by this branch — measured on main's own hook, untouched by me, 8 samples:

delta 7764ms  1472ms  888ms  16007ms  174ms  26605ms  -2202ms  13723ms

Four of eight exceed the threshold on unmodified main, with baseline invocation times swinging 1572ms→13343ms. A genuine fd1 leak would pin the delta at ≈8000ms on every sample; deltas of 26.6s against an 8s sleep, and occasional negative deltas, are host contention. The final 141/0 run above did not trip it. Flagging rather than filing, since it is a pre-existing test-robustness issue in someone else's lane.

Unverified, unchanged

The POSIX-host git rev-parse spawn count is still simulated via git's path spelling on Windows, never observed on a POSIX host. The /tmp-versus-pwd -P divergence behind physically_inside is likewise Windows-grounded — the reconciliation is correct on any host because it only ever normalizes, but the argument for its necessity is not verified on POSIX.

I have not resolved any thread.

…hellCheck passes

The hygiene lane runs ShellCheck with no severity floor, so SC2249 (info,
"Consider adding a default *) case") failed the whole lane on
markdown-format.test.sh:584 — the only failing check on this PR.

Confirmed from the job log rather than inferred: run 31399762274, job hygiene,
`shellcheck=failure` with SC2249 as the sole finding. `shellcheck -x` on the file
is rc 0 after this change.

The branch exits rather than falling through. A silently unhandled case there
would leave _link unset from the previous iteration and report a PASS for a
symlink case that never ran, which is the failure shape these two cases exist to
catch.

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

claude Bot commented Aug 10, 2026

Copy link
Copy Markdown

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


Code review complete

  • Gather context (diff since last code-review round)
  • Identify what changed since HEAD 193c9d2 (last code-review round)
  • Review new commit(s) for correctness, error handling, tests, maintainability
  • Post findings and final summary

Only one new commit since the last review round, and it's a mechanical ShellCheck fix. Diffing 193c9d2..fe5d6a7 (current HEAD): markdown-format.sh, CHANGELOG.md, and plugin.json are byte-identical to what the last round reviewed (git rev-parse HEAD:<path> matches the previously-listed blob SHAs ab0cb2c…/3e6f33f…/2fb6639… exactly). Only markdown-format.test.sh changed, via fe5d6a7.

The new commit

Adds a *) default branch to the _case statement in make_symlink's escape-case loop (markdown-format.test.sh#L584-L599):

for _case in nested root; do
  case "$_case" in
  nested) _link="$REPO/docs/escapeNoGitNested.md" ;;
  root) _link="$REPO/escapeNoGitRoot.md" ;;
  *)
    echo "unhandled escape case: $_case" >&2
    exit 1
    ;;
  esac

This closes SC2249 ("consider adding a default case"), the sole ShellCheck finding blocking the hygiene lane per the commit message (job 31399762274). The branch is genuinely unreachable — the driving for loop only ever produces nested/root — so this is pure defensive/lint compliance, not a behavior change. Verified trap cleanup EXIT is registered near the top of the file (L35), so the exit 1 in the new branch still runs cleanup rather than leaking the temp workspace — no correctness or maintainability issue here.

No new findings. This commit doesn't touch markdown-format.sh, so it has no bearing on the two P1s already confirmed fixed on this thread (the git-absent symlink escape via physically_inside, and the .claude/\.claude fallback-signature nit) or on the security lane's open .git-marker-walk finding, which remains that lane's concern per the REVIEW.md split.

The two test-coverage gaps this lane flagged in earlier rounds are still open and unaffected by this commit:

Verification note: I could not run ShellCheck or the test suite myself in this environment (no Bash execution beyond read-only git lookups was available) — the commit message's "job 31399762274, SC2249, rc 0 after this change" claim is author-reported and unverified by me; everything above is from reading the diff and surrounding file directly.
· branch fix/markdown-format-nogit-root-followup

@claude

claude Bot commented Aug 10, 2026

Copy link
Copy Markdown

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


Security review complete

  • Gather context (PR diff against origin/main at HEAD fe5d6a7)
  • Identify what changed since the last reviewed HEAD (193c9d2)
  • Re-check whether the previously-flagged unauthenticated .git-marker walk finding still applies
  • Review any new commit content for injection / logic / trust-boundary issues
  • Post final findings

Scope: the 4 files changed in this PR at HEAD fe5d6a7 (diff against origin/main) — hooks/markdown-format.sh, hooks/markdown-format.test.sh, CHANGELOG.md, .claude-plugin/plugin.json.

Nothing security-relevant changed since the last full round on this thread. git diff 193c9d2..fe5d6a7 -- plugins/markdown-format/ touches only markdown-format.test.sh, adding a *) default branch to the escape-case case in make_symlink() purely to satisfy ShellCheck's SC2249 at a no-severity-floor CI setting:

case "$_case" in
nested) _link="$REPO/docs/escapeNoGitNested.md" ;;
root) _link="$REPO/escapeNoGitRoot.md" ;;
# Unreachable from the loop above, and present because CI runs ShellCheck with
# no severity floor, so SC2249 (info) fails the lane. Exiting rather than
# falling through: a silently unhandled case here would report a PASS for a
# symlink case that never ran.
*)
echo "unhandled escape case: $_case" >&2
exit 1
;;
esac
_ext="$NOGIT_OUTSIDE/external-$_case.md"
printf '# External\n\n* star item\n' >"$_ext"
if make_symlink "$_ext" "$_link"; then
_ext_before="$(cat "$_ext")"

That's test-fixture control flow, not production logic reachable from the hook, and doesn't change any trust boundary. markdown-format.sh, CHANGELOG.md, and plugin.json are byte-identical to the previously-reviewed 193c9d2 head (confirmed by diff, and the REPO_ROOT/physically_inside call sites still sit at the same line numbers: L290, L293-302).


IMPORTANT (confidence: plausible, narrow preconditions) — still open: the unauthenticated .git-marker walk in resolve_repo_root remains unbounded by CLAUDE_PROJECT_DIR and can widen the trust boundary physically_inside relies on

Raised on this thread four times already (05:12:59, 13:29:13, 14:26:36, and escalated at 14:44:41) and unchanged at this HEAD. resolve_repo_root, specifically the walk at L148-L158:

if dir="$(cd "$hint" 2>/dev/null && pwd -P)"; then
  while :; do
    if [[ -e "$dir/.git" ]]; then
      printf '%s' "$dir"
      return 0
    fi
    parent="$(dirname "$dir")"
    [[ "$parent" != "$dir" ]] || break
    dir="$parent"
  done
fi
if [[ -n "${CLAUDE_PROJECT_DIR:-}" ]]; then
  printf '%s' "$CLAUDE_PROJECT_DIR"
  return 0
fi
  1. -e "$dir/.git" accepts any file or directory literally named .git, valid or not — real git requires a file-form .git to contain a gitdir: pointer (gitrepository-layout). Anyone who can write a .git-named entry into a shared ancestor directory of the edited file can redirect root resolution there.
  2. The walk climbs unbounded and can match before the CLAUDE_PROJECT_DIR fallback is ever consulted, with no stop at CLAUDE_PROJECT_DIR itself. For the documented last-resort case (CLAUDE_PROJECT_DIR pointed at an unpacked archive or vendored copy with no .git inside, by construction), a .git-named entry in any ancestor above CLAUDE_PROJECT_DIR wins first, so setting CLAUDE_PROJECT_DIR does not bound this the way the surrounding comment (L127-L133) implies.

The escalation from the 14:44:41 round still holds at this HEAD: REPO_ROOT (produced by this walk, L290) is now also the trust boundary physically_inside uses at L301-L302 to contain the symlink-escape fix landed in 193c9d2. physically_inside's own canonicalization (cd … && pwd -P on both operands) is sound in isolation, but it can only be as trustworthy as the REPO_ROOT it's handed — and under the same preconditions already established (git absent, CLAUDE_PROJECT_DIR unset, a writer-controlled ancestor directory shared with a symlink's out-of-tree target), an attacker-widened REPO_ROOT can make physically_inside accept an out-of-tree symlink target as "inside", reopening the arbitrary-out-of-tree-write escape that 193c9d2 was written to close.

Blast radius is unchanged from prior rounds: requires git absent from PATH (or the file outside any working tree git can discover), and a writer-controlled ancestor directory — the ordinary case (a real git clone, or CLAUDE_PROJECT_DIR set with no attacker-writable ancestor) is unaffected. This has been open across five review rounds on this thread without a code change addressing it; worth an explicit decision (ship with the documented narrow precondition accepted, or close it) before merge rather than continuing to carry it forward silently.

Fix this →


Other things checked, no issues found

  • The symlink-escape fix (physically_inside, L225-233) remains sound on its own terms — canonicalizes both operands through cd … && pwd -P, fails closed on an unresolvable path, and only runs when FILE_PHYSICAL != FILE (L301) — no regression for the ordinary non-symlink git-less case.
  • The new ShellCheck-only case default branch in markdown-format.test.sh (L584-599) is test-fixture code, executes only inside the test suite, and introduces no new production surface.
  • No new command/shell injection. REPO_ROOT, FILE, FILE_PHYSICAL, hint/dir continue to be used only in quoted [[ ]]/parameter expansions or as quoted arguments, never interpolated into eval or an unquoted command string.
  • git-selection env vars stay cleared where git is actually invoked (in_git_working_tree, L213-219).
  • plugin.json/CHANGELOG.md changes are version/documentation only, unchanged since the last round.
  • No workflow files touched, so zizmor-covered categories (unpinned actions, dangerous triggers, permissions, template injection) are correctly out of scope for this lane.

Note on verification: as in prior rounds on this thread, I could not execute markdownlint-cli2, the hook, or its test suite in this environment — this review is a code trace against the diff and surrounding file, not a reproduced measurement.
· branch fix/markdown-format-nogit-root-followup

main advanced again (#2140, #2149 and later). No conflicts this pass — the
markdown-format changelog and manifest both auto-merged, and 0.11.4 is still one
patch above main's 0.11.3.

check-changelog-parity.sh --check-bump origin/main is clean on the merged tree.
Merging rather than rebasing, and pushed fast-forward on top of the branch's
current head.

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

claude Bot commented Aug 10, 2026

Copy link
Copy Markdown

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


Security review complete

  • Gather context (diff since last reviewed HEAD fe5d6a7)
  • Review changes for injection / logic / trust-boundary issues
  • Post final findings

Scope: the 4 files changed in this PR at current HEAD 52521b6 (diff against origin/main) — hooks/markdown-format.sh, hooks/markdown-format.test.sh, CHANGELOG.md, .claude-plugin/plugin.json.

Nothing changed since the last review round. 52521b6 is a merge of origin/main that only brings in unrelated #2154 (fix(ci): close the reverse direction of the changelog-parity gate), which touches no file in this plugin. Verified by blob SHA, not just by commit message:

markdown-format.sh        ab0cb2c…  (identical to fe5d6a7)
markdown-format.test.sh   1d34044…  (identical to fe5d6a7)
CHANGELOG.md               3e6f33f…  (identical to fe5d6a7)
plugin.json                2fb6639…  (identical to fe5d6a7)

resolve_repo_root() and physically_inside() sit at the same line numbers as the last round (L137/L225), and the body of the .git-marker walk is byte-identical.


IMPORTANT (confidence: plausible, narrow preconditions) — still open: the unauthenticated .git-marker walk in resolve_repo_root remains unbounded by CLAUDE_PROJECT_DIR, and it's the trust boundary the new physically_inside symlink-containment guard relies on

Raised on this thread five times already and unchanged at this HEAD. resolve_repo_root, specifically the walk at L148-L158:

if dir="$(cd "$hint" 2>/dev/null && pwd -P)"; then
  while :; do
    if [[ -e "$dir/.git" ]]; then
      printf '%s' "$dir"
      return 0
    fi
    parent="$(dirname "$dir")"
    [[ "$parent" != "$dir" ]] || break
    dir="$parent"
  done
fi
if [[ -n "${CLAUDE_PROJECT_DIR:-}" ]]; then
  printf '%s' "$CLAUDE_PROJECT_DIR"
  return 0
fi
  1. -e "$dir/.git" accepts any file or directory literally named .git, valid or not — real git requires a file-form .git to contain a gitdir: pointer (gitrepository-layout). Anyone who can write a .git-named entry into a shared ancestor directory of the edited file can redirect root resolution there.
  2. The walk climbs unbounded and can match before the CLAUDE_PROJECT_DIR fallback is consulted, with no stop at CLAUDE_PROJECT_DIR itself — so for the documented last-resort case (an unpacked archive / vendored copy with no .git inside, by construction), a .git-named entry in any ancestor above CLAUDE_PROJECT_DIR wins first.

This still matters beyond config discovery: REPO_ROOT (produced by this walk, L290) is the trust boundary physically_inside uses at L301-L302 to contain the symlink-escape fix landed in 193c9d2. physically_inside's own canonicalization is sound in isolation, but an attacker-widened REPO_ROOT can still make it accept an out-of-tree symlink target as "inside," reopening the arbitrary-out-of-tree-write escape that fix was written to close. Preconditions are unchanged and narrow: git absent from PATH (or the file outside any working tree git can discover), CLAUDE_PROJECT_DIR unset or with an attacker-writable ancestor above it, and a writer-controlled ancestor directory. The ordinary case (a real git clone, or CLAUDE_PROJECT_DIR set with no attacker-writable ancestor) is unaffected.

This has now been open across six review rounds on this thread without a code change addressing it. Not re-filing a new inline comment since nothing changed to re-review — restating so it isn't lost in the merge noise. Worth an explicit decision (accept the documented narrow precondition, or close it with a gitdir:-prefix content check and/or bounding the walk at CLAUDE_PROJECT_DIR) before merge.


Other things checked, no issues found

  • No new command/shell injection: REPO_ROOT, FILE, FILE_PHYSICAL, hint/dir are always used in quoted [[ ]]/parameter expansions or passed as quoted arguments, never interpolated into eval or an unquoted command string.
  • git-selection env vars stay cleared where git is actually invoked.
  • plugin.json/CHANGELOG.md changes remain version/documentation only.
  • No workflow files touched by this PR, so zizmor-covered categories (unpinned actions, dangerous triggers, permissions, template injection) are correctly out of scope for this lane.

Note on verification: as in prior rounds, I could not execute markdownlint-cli2, the hook, or its test suite in this environment — this review is a code trace against the diff and surrounding file, not a reproduced measurement.
· branch fix/markdown-format-nogit-root-followup

kyle-sexton pushed a commit that referenced this pull request Aug 10, 2026
…y gate

The --check-bump heading probe exited on first match while rendered_lines
was still writing. Under the script's pipefail, the writer's SIGPIPE death
(exit 141) became the pipeline's status, so a correctly documented bump in
any changelog larger than one stdio buffer — and the newest heading is
always near the top — was reported as UNDOCUMENTED BUMP. gawk, the CI
runner's awk, loses that race deterministically; the suite's small
fixtures fit in one buffer and never tripped it, which is how the gate
shipped green at PASS=55 and then failed the first real bump PR (#2130).

Scan the whole input instead of exiting on first match, and add a
large-changelog fixture (~260 KB, entry near the top) that fails against
the early-exit reader.

Closes #2158

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
kyle-sexton added a commit that referenced this pull request Aug 10, 2026
…y gate

The --check-bump heading probe exited on first match while rendered_lines
was still writing. Under the script's pipefail, the writer's SIGPIPE death
(exit 141) became the pipeline's status, so a correctly documented bump in
any changelog larger than one stdio buffer — and the newest heading is
always near the top — was reported as UNDOCUMENTED BUMP. gawk, the CI
runner's awk, loses that race deterministically; the suite's small
fixtures fit in one buffer and never tripped it, which is how the gate
shipped green at PASS=55 and then failed the first real bump PR (#2130).

Scan the whole input instead of exiting on first match, and add a
large-changelog fixture (~260 KB, entry near the top) that fails against
the early-exit reader.

Closes #2158

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
kyle-sexton added a commit that referenced this pull request Aug 10, 2026
…y gate

The --check-bump heading probe exited on first match while rendered_lines
was still writing. Under the script's pipefail, the writer's SIGPIPE death
(exit 141) became the pipeline's status, so a correctly documented bump in
any changelog larger than one stdio buffer — and the newest heading is
always near the top — was reported as UNDOCUMENTED BUMP. gawk, the CI
runner's awk, loses that race deterministically; the suite's small
fixtures fit in one buffer and never tripped it, which is how the gate
shipped green at PASS=55 and then failed the first real bump PR (#2130).

Scan the whole input instead of exiting on first match, and add a
large-changelog fixture (~260 KB, entry near the top) that fails against
the early-exit reader.

Closes #2158

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
kyle-sexton added a commit that referenced this pull request Aug 10, 2026
…y gate (#2159)

Closes #2158

## Problem

`changelog-parity-gate` failed PR #2130 twice with `UNDOCUMENTED BUMP:
markdown-format went 0.11.3 -> 0.11.4 ...` even though
`plugins/markdown-format/CHANGELOG.md` carries `## [0.11.4]` at line 6,
column one — a required merge gate confidently asserting the opposite of
the truth, while the same command passed locally. PR #2135 then failed
the same gate on **every one of its sixteen bumped plugins** (smallest
flagged changelog: 15 KB). The regression landed on `main` at 15:19:19Z
in #2154 and blocks **every PR that bumps a plugin whose changelog
exceeds roughly one stdio buffer (~4 KB)** — which newest-first ordering
makes essentially all of them.

## Blast radius — precisely

Confined to the `--check-bump` path: `has_heading` is defined inside
that branch and called in exactly two places (the head-side check and
the base-side `git show "$base:$changelog" | has_heading`). `--check`
and `--check-order` read changelogs through `changelog_versions`, whose
`grep -oE` stages drain stdin with no early exit and cannot take
SIGPIPE. So the failure class is exactly "PRs that bump a manifest
version"; both failing call sites go through the one function this PR
fixes.

## Root cause

`has_heading` runs a pipeline under `set -o pipefail` whose reader
`exit`s on first match:

```bash
rendered_lines - | awk -v h="$heading" 'index($0, h) == 1 { found = 1; exit } END { exit !found }'
```

The newest heading sits near the top, so the reader exits while
`rendered_lines` is still writing; the writer dies of SIGPIPE (141) and
pipefail reports the pipeline — the FOUND heading — as a failure.
Reproduced deterministically in an `ubuntu:24.04` container at the exact
CI merge commit `ba4b72fb`: `PIPESTATUS=141 0` and the byte-identical CI
error under **gawk** (what the `ubuntu-24.04` runner resolves
`/usr/bin/awk` to — gawk outranks mawk in the alternatives system, and
only the gawk mechanism explains CI failing 15 KB files). mawk survives
the closed pipe and passes at every size tested, and Windows/MSYS
process timing lets the writer finish first — which is why the failure
existed only in CI. The suite's 55 fixtures all fit in one buffer —
hence `PASS=55` in the very job that then failed on the real file.

## Fix

The reader consumes to EOF; `END { exit !found }` decides. Correct **by
construction**: no reader exits early, so no writer can ever take
SIGPIPE, under any awk — the failure is impossible, not rarer. Chosen
over restoring the pre-#2154 single-awk form because it preserves the
one-tracker-three-modes property (`rendered_lines` shared by all modes,
so they cannot drift). Both `has_heading` call sites are covered (same
function); the script has no other early-exiting reader downstream of a
pipe (`changelog_versions` greps drain stdin; the `grep -m1` format
probe reads a file directly, not a pipe).

## Regression fixture — with its fails-against-unfixed proof, per engine

New `--check-bump` case: ~260 KB changelog, new `## [1.1.0]` entry near
the top, expected pass. 260 KB deliberately exceeds the 64 KB pipe
**capacity**, so against the unfixed script the writer blocks mid-write
and the SIGPIPE is deterministic, not a winnable race.

| script | gawk | mawk |
|---|---|---|
| unfixed | **FAIL=1** (exact #2130 error text) | PASS (mawk survives
the closed pipe) |
| fixed | 56/56 | 56/56 |

The fixture's guard is therefore **engine-conditional — it discriminates
only where `awk` resolves to gawk**, which is what the runner resolves;
this is recorded in the fixture comment. The fix itself is
engine-independent. Windows local: 56/56 fixed.

## Related

- #2158 — the defect issue this closes
- #2154 — shipped the early-exit reader; this is its first contact with
a production-size changelog
- #2130, #2135, #2155 — blocked by this regression; once this lands,
their recomputed merge refs carry the fixed gate and need no branch-side
changes
- Known adjacent gap, tracked separately and deliberately NOT fixed
here: the gate cannot see a **deleted** predecessor heading — a change
set that renames the top heading in place (rather than adding a new one
above it) destroys a shipped release section while `--check` and
`--check-order` both pass

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
@claude

claude Bot commented Aug 10, 2026

Copy link
Copy Markdown

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


Security review complete

  • Gather context (PR diff against origin/main, changed files)
  • Review markdown-format.sh changes for injection / logic / trust-boundary issues
  • Review test file changes
  • Review plugin.json / CHANGELOG.md changes
  • Post final findings

Scope: the 4 files changed in this PR at current HEAD e723444 (diff against origin/main) — hooks/markdown-format.sh, hooks/markdown-format.test.sh, CHANGELOG.md, .claude-plugin/plugin.json.

Nothing changed since the last review round. e723444 is a merge of origin/main that only brings in unrelated #2165 (songwriting plugin), which touches no file in this plugin. Verified by blob SHA, not just commit message — all four files are byte-identical to what was reviewed at 52521b6:

plugin.json                2fb6639…
CHANGELOG.md               3e6f33f…
markdown-format.sh        ab0cb2c…
markdown-format.test.sh   1d34044…

resolve_repo_root() and physically_inside() sit at the same lines as the last round (L137 / L225), and the .git-marker walk body is unchanged.


IMPORTANT (confidence: plausible, narrow preconditions) — still open: the unauthenticated .git-marker walk in resolve_repo_root remains unbounded by CLAUDE_PROJECT_DIR, and it's the trust boundary the physically_inside symlink-containment guard relies on

Raised on this thread seven times now, unchanged at this HEAD. resolve_repo_root, specifically the walk at L148-L158:

if dir="$(cd "$hint" 2>/dev/null && pwd -P)"; then
  while :; do
    if [[ -e "$dir/.git" ]]; then
      printf '%s' "$dir"
      return 0
    fi
    parent="$(dirname "$dir")"
    [[ "$parent" != "$dir" ]] || break
    dir="$parent"
  done
fi
if [[ -n "${CLAUDE_PROJECT_DIR:-}" ]]; then
  printf '%s' "$CLAUDE_PROJECT_DIR"
  return 0
fi
  1. -e "$dir/.git" accepts any file or directory literally named .git, valid or not — real git requires a file-form .git to contain a gitdir: pointer (gitrepository-layout). Anyone who can write a .git-named entry into a shared ancestor directory of the edited file can redirect root resolution there.
  2. The walk climbs unbounded and can match before the CLAUDE_PROJECT_DIR fallback is consulted, with no stop at CLAUDE_PROJECT_DIR itself — so for the documented last-resort case (an unpacked archive / vendored copy with no .git inside, by construction), a .git-named entry in any ancestor above CLAUDE_PROJECT_DIR wins first.

This matters beyond config discovery: REPO_ROOT (produced by this walk, L290) is the trust boundary physically_inside uses at L301-L302 to contain the symlink-escape fix landed in 193c9d2. physically_inside's own canonicalization is sound in isolation (both operands go through cd … && pwd -P), but an attacker-widened REPO_ROOT can still make it accept an out-of-tree symlink target as "inside", reopening the arbitrary-out-of-tree-write escape that fix was written to close. Preconditions are unchanged and narrow: git absent from PATH (or the file outside any working tree git can discover), CLAUDE_PROJECT_DIR unset or with an attacker-writable ancestor above it, and a writer-controlled ancestor directory. The ordinary case (a real git clone, or CLAUDE_PROJECT_DIR set with no attacker-writable ancestor) is unaffected.

This has now been open across seven review rounds on this thread without a code change addressing it. Not re-filing as a new inline comment since nothing changed to re-review — restating so it isn't lost in the merge noise. Worth an explicit decision (accept the documented narrow precondition, or close it with a gitdir:-prefix content check and/or bounding the walk at CLAUDE_PROJECT_DIR) before merge.


Other things checked, no issues found

  • No new command/shell injection: REPO_ROOT, FILE, FILE_PHYSICAL, hint/dir are always used in quoted [[ ]]/parameter expansions or passed as quoted arguments, never interpolated into eval or an unquoted command string.
  • git-selection env vars stay cleared where git is actually invoked.
  • plugin.json/CHANGELOG.md changes remain version/documentation only.
  • No workflow files touched by this PR, so zizmor-covered categories (unpinned actions, dangerous triggers, permissions, template injection) are correctly out of scope for this lane.

Note on verification: as in prior rounds, I could not execute markdownlint-cli2, the hook, or its test suite in this environment — this review is a code trace against the diff and surrounding file, not a reproduced measurement.
· branch fix/markdown-format-nogit-root-followup

kyle-sexton added a commit that referenced this pull request Aug 10, 2026
…ing guards (#2135)

Closes #2122

## Update — `main` moved under this PR, and the disposition changed with
it

**#2120 merged (`fd075c27`), and it fixed the same function with the
opposite value disposition:
it STRIPS every NUL out of a value where this branch TRUNCATED at the
first one.** The PR went
`DIRTY`. Resolved by merging `origin/main` into the branch — never a
rebase, since force-push is
blocked here twice over.

**The resolution keeps `main`'s strip and this branch's flag plus
fail-closed guards.** That is
additive over `main` rather than a choice between the two sides, and it
is what this body already
argued for in its own words: the disposition is *immaterial for this
PR's own two callers*, which
refuse on the flag before reading a value, while `main` now carries the
ten scanner-class callers
#2120 converted, none of which consults the flag. Truncating would have
hidden a credential placed
after a NUL from `secret-pattern-detection` and `hardcoded-path-check`.
**Everything below that
says "truncate" describes the pre-merge branch; the shipped behaviour is
strip + flag.**

### The textual merge git produced was silently fatal, and was not taken

git auto-merged the function body into a hybrid carrying BOTH `main`'s
per-filter
`split("\u0000") | join("")` **and** this branch's array-level `explode
| .[0:(index(0) // length)]
| implode`. Strip runs first, so `index(0)` looked at a value with no
NUL left in it and **the flag
read `0` on every payload** — the guards would never have fired, with no
conflict marker and no test
of the pre-merge branch able to see it. The flag is now computed from
the untouched values with the
strip applied after, and both the library and the guard comments say the
ordering is load-bearing,
because it is exactly what the next textual merge will get wrong again.

### Why the flag and the guards are still needed after #2120

#2120 closed the fail-open for the CONTENT guards. It did not close the
COMMAND guards: stripping
SPLICES the bytes either side of the NUL into a token the payload never
carried contiguously, and
the guards then match against that token. Re-measured at the hook
boundary, `origin/main` at
`fd075c27` versus this tree, same script, same host, on fixtures whose
NUL is a real byte — verified
by decoding each fixture and counting the byte (`jq -j
.tool_input.command | tr -dc '\u0000' | wc -c` =
1) rather than trusting that the escape survived construction:

| payload | `main` | this change |
| --- | --- | --- |
| `git commit --no-verify<NUL>x` | **0 ALLOWED** | **2 blocked** |
| `git push --force<NUL>x` | **0 ALLOWED** | **2 blocked** |
| a lone NUL | **0 ALLOWED** | **2 blocked** |
| a trailing NUL | **0 ALLOWED** | **2 blocked** |
| `git commit --no-veri<NUL>fy` | 2 blocked | 2 blocked |
| clean `--no-verify` | 2 | 2 |
| clean `--force` | 2 | 2 |
| harmless (`git status`) | 0 | 0 |

Identical on both guards. **The fifth row is stated, not counted:** the
splice happens to reassemble
a real `--no-verify` there, so `main` already blocks it and it evidences
nothing about this change.
The live rows are the first four, and the first two are the ones that
matter — a real `--no-verify`
and a real `--force` that `main` waves through. No clean command changed
verdict in either
direction.

### Tests re-pointed rather than deleted

Every assertion this branch wrote against truncation was measuring a
value the helper no longer
produces, so each was rewritten for strip and two new cases were added:
the splice
(`--no-verify<NUL>x` -> the single token `--no-verifyx`), and an ALL-NUL
value, which strips to
empty — that case, and not a leading NUL, is the real reason both guards
consult the flag ahead of
their empty-command skip. The guard suites keep every NUL row at exit 2;
the verdict never depended
on the disposition, only its justification did, and one mislabelled row
was corrected accordingly.

### Conflicts and versions

- `lib/hook-utils.sh` — header comment and jq program, resolved by hand.
- The 16 vendored copies were **regenerated with
`scripts/sync-hook-utils.sh`**, not hand-resolved;
  `--check` reports 16/16 byte-identical.
- 16 CHANGELOGs where both sides claimed the same version: this branch's
entry moves up one patch
  above `main`'s and is rewritten for the resolved design.
- **All 16 `plugin.json` files had auto-merged to `main`'s number,
leaving no bump at all** — no
conflict, only `--check-bump` catches it, exactly the trap flagged
below. Re-bumped:
  `guardrails 0.23.1 -> 0.23.2`, `markdown-format 0.11.2 -> 0.11.3`,
  `source-control 0.51.2 -> 0.51.3`, patch bumps for the other 13.
- **Coordination with #2130:** it also bumps `markdown-format` to
`0.11.3`. Whichever merges second
  must re-bump.

### `main` moved twice more: three merges, and one of them was silently
lossy

`main` landed #2147, then #2140 and #2149, while this PR sat. Three
merge passes, no rebase at any
point. Second pass: #2147 took `guardrails` to `0.24.0` and edited
`block-dangerous-git.sh`, which this branch also edits — resolved by
keeping main's three-field
`hook::jq_fields "$INPUT" '.tool_input.command' '.cwd' '.tool_name'`
call verbatim and appending this
branch's NUL block after it. Third pass: one changelog conflict on
`source-control`. Every plugin
manifest had auto-merged to main's number with no bump on **both**
passes.

**The second pass exposed a defect this branch had introduced, and it is
worth reading even if you
skip the rest.** An earlier commit here accidentally wrote a **real NUL
byte** into
`plugins/guardrails/CHANGELOG.md` — a `\u0000` that was meant to be
literal text in a prose
description of the fixtures. git classifies any file containing a NUL as
**binary**, so the textual
three-way merge never ran on that changelog: it kept ours wholesale and
**silently discarded main's
entire `0.24.0` section**, with no conflict marker and nothing in `git
status` to distinguish it from
a file that merged cleanly. It was caught by counting NUL bytes across
the touched files, not by
reading the diff. The byte is gone, the section is restored, and the
changelog's `0.24.1` entry now
sits above main's `0.24.0`.

That is a mistake this PR made, not a pre-existing one, and it is
reported rather than quietly fixed
because the failure mode generalises: **a NUL in a tracked text file
turns every future merge of that
file into a silent take-ours.** In a repository whose CHANGELOGs are the
merge-conflict surface for
every shared-library change, that is worth knowing independently of this
fix.

### Incidental, and relevant to the "what I could NOT verify" list below

While posting a review reply, the **harness itself refused a tool call**
whose `command` field
carried a stray control character, with `command contains control
characters that would be hidden in
the approval dialog`. That is a live observation of the validation the
list below names as unverified
— it fires, and it fires on the `command` field. It is **not** the
discriminating probe: it says
nothing about whether that validation runs before or after PreToolUse
hooks, and nothing about
whether the rejected class includes NUL specifically rather than the
control characters it does
cover. Recorded as an observation, not as evidence that the guards are
unreachable. Nothing in this
change leans on it in either direction.

### Gates re-run after the merge

`sync-hook-utils.sh --check` (16/16) - `sync-hook-utils.sh --check-bump
origin/main` -
`check-changelog-parity.sh --check` / `--check-bump origin/main` /
`--check-order` -
`shellcheck -x` with **no severity floor** on `lib/hook-utils.sh`, the
`bash-format` vendored copy,
both guards and all three test files (rc 0 — this is what the two open
review threads reported
failing; the jq-variable spelling they flagged is gone from the current
program text) -
`shfmt -d -i 2` (rc 0).

Suite results after the merge are in the thread below.

## The defect

`hook::jq_fields` frames its fields with a NUL delimiter drawn from the
same byte space as the
values it separates. A JSON NUL escape inside a value splits that value
in two, the cardinality
check `((${#values[@]} == $#)) || return 1` fires, and both real callers
spell that `|| exit 0` —
a PreToolUse **ALLOW**, emitted with no diagnostic of any kind.

One correction to the issue's mechanism, because it moves where the fix
belongs. The collision is
**reliably detected**, not intermittently: every NUL adds exactly one
record, so the count is always
`N + k` for `k >= 1` and the check never misses. The defect therefore
never lived in the library's
return value. It lives in **one exit path serving two conditions with
opposite correct responses** —
"jq is absent or cannot parse this" (where allowing is the documented,
deliberate behaviour) and
"this payload carries a NUL" (where allowing is wrong). Separating those
two is the fix.

## Design

**jq truncates each value at its first NUL and reports the fact; the
caller owns the verdict.**

- `lib/hook-utils.sh` — each filter becomes `... | explode |
.[0:(index(0) // length)] | implode`.
The separator then cannot occur inside a value, so the record count no
longer depends on what a
  parseable payload holds.
- A leading record carries the NUL flag, computed from the untruncated
values and emitted by the
**same** jq program, so reporting it costs no second spawn. It surfaces
as `HOOK_JQ_FIELDS_NUL`,
assigned in the same unconditional block that resets `HOOK_JQ_FIELDS` —
above all three return
paths, so no early return can leak a stale `1`, which in a guard would
mean blocking a clean
  payload on the strength of an earlier one.
- `block-no-verify.sh` and `block-dangerous-git.sh` fail **CLOSED** on
that flag, **before** their
empty-command skip, because the helper truncates at the first NUL and a
leading one therefore
leaves an empty value that would otherwise be waved through as "no
command".

### Why fail CLOSED, and why that argument does not depend on the
executor

**No executor-fidelity claim is made here, in either direction.** Two
behaviours were measured and
they disagree, and which of them a hook payload actually reaches has
**not been traced by anyone**:

| measured | result |
| --- | --- |
| bash parsing a command it reads (stdin, script file) | **discards**
the NUL — `echo ha<NUL>rd` prints `hard`, and `--no-verify<NUL>x`
becomes `--no-verifyx` |
| a NUL inside an argv word handed to `execve` | the string simply ends
there |
| Node v24.18.0 `child_process` — argv, `shell: true`, and `execSync` |
**refuses** outright, `ERR_INVALID_ARG_VALUE: must be a string without
null bytes`, while the same calls with a clean string run normally |

An earlier draft of this PR argued that truncation was right *because
the executor truncates*. That
was wrong — it generalised the argv case to a path that is not known to
be the one in use. **The
correct argument is that the design does not need it:** failing closed
on the flag is correct under
deletion, under truncation, and under refusal alike, so it cannot be
invalidated by tracing the path
later. That is the whole case for it. Matching the value would need the
trace; refusing does not.

### Truncate rather than delete, on grounds that appeal to no shell

Truncation never fabricates a token the payload did not carry
contiguously, and when a caller
forgets the flag it is the *content* class that degrades rather than the
command class — a matcher
sees a prefix rather than a joined token that matches nothing. **For
this PR's own two callers the
choice is immaterial: they refuse on the flag before reading a value at
all.** It is the
conservative default, not the accurate one, and the flag is the
load-bearing part.

### Why the library does not block on its own

It is sourced by 15 other plugins, formatters among them, for which
exiting 2 would be wrong; and a
sourced library calling `exit` on its caller's behalf is hidden control
flow. Policy stays with the
caller and the library only reports the fact.

### Rejected alternatives

| Alternative | Why not |
| --- | --- |
| Delete the NUL (`map(select(. != 0))`) | Fabricates contiguity the
payload did not have, and inverts which caller class degrades unsafely
when a hook forgets the flag; see above. Not rejected on executor
grounds. |
| `gsub` / `split`+`join` on a NUL | Both work on jq 1.8.2 here, but
each puts a NUL inside the jq **program** text — a regex pattern and a
string literal. A construct whose behaviour varied across jq builds
would fail EVERY payload: a universal fail-open, strictly worse than the
payload-dependent one. `explode`/`implode` use integer comparison only,
with no NUL anywhere in the program. This is a reason, not a measurement
— see the unverified list. |
| Length-prefixed framing | Needs `read -N` (bash 4.1+); this lib
supports 3.2+. |
| An explicit emitted count | Redundant once the separator is absent
from the value space. |
| Per-field `@base64` | Needs a `base64` binary; only `jq` is a
documented prerequisite. |
| `@sh` + `eval` | Puts payload-derived text through `eval`. |
| Fail closed inside the library | Impossible without the library
exiting on its caller's behalf, which is wrong for the 15 other plugins.
|

## Scope

**This is a shared-library change, and the repo's own gate makes it 55
files.**
`plugins/guardrails/hooks/hook-utils.sh` is a **vendored copy**;
`lib/hook-utils.sh` is the source of
truth. CI enforces `scripts/sync-hook-utils.sh --check` (all 16 copies
byte-identical) and
`--check-bump` (every carrying plugin bumped when the lib changes), so
editing only the guardrails
copy would fail CI. Precedent: 9b90e35, 50 files. Hence 16 vendored
copies, 16 `plugin.json` bumps
and 16 changelog entries, plus the lib, its test, the two guards, their
two test files and the
guardrails README.

**`hook::jq_field` — SINGULAR — is untouched.** It is a separate
two-line function; there is no
shared internal the two route through. `grep -rn "hook::jq_field "
--include=*.sh plugins/`, with the
vendored copies excluded, finds **22 call sites across 12 files** in
`claude-ops`, `context-guard`
and `source-control`. None of them are touched. `git diff origin/main --
lib/hook-utils.sh` mentions
`hook::jq_field` on exactly two lines, both of them the same doc-comment
cross-reference inside the
*plural* function's header ("Values are CR-stripped, as in
`hook::jq_field`"); the singular
function's own body appears nowhere in the diff. **Blast radius is
exactly the two guards.**

**No other plugin is affected by the truncation.** `grep -rn
"hook::jq_fields" --include=*.sh .`,
excluding the 16 vendored copies and `lib/hook-utils.*`, returns exactly
two call sites — both in
this PR. Every other hit across the 16 plugins is the doc comment in the
vendored library. Nothing
round-trips a value into a file, and nothing compares a length or hash
against one.

**Versions**, taken against `origin/main` at the time of the last
rebase: `guardrails 0.23.0 ->
0.23.1`, `markdown-format 0.11.1 -> 0.11.2`, `source-control 0.51.1 ->
0.51.2`, and plain patch bumps
for the other 13. Worth flagging for anyone rebasing a sibling branch:
when a plugin's version moved
on `main` mid-flight, `git` **auto-merged the manifest to main's
number**, silently leaving no bump
at all — no conflict, and only `sync-hook-utils.sh --check-bump` catches
it. That happened three
times here. #2120 is still open against the same guardrails files and
owes a re-bump.

## Two caller classes want opposite dispositions — which is why there is
a flag

This is the strongest argument for the design, and it is demonstrated
rather than theoretical.
#2120 has independently fixed the same function with the **opposite**
disposition: at its head
`9fb8383d`, `hook::jq_fields` does `... | tostring | split("<NUL>") |
join("")` — it **strips**.

Neither disposition is simply right, because the two caller classes
disagree:

| payload | under strip | under truncate |
| --- | --- | --- |
| `content: harmless<NUL>aws_secret=AKIA…` (a scanner) | secret is
joined and **scanned** | secret is cut off and **invisible** |
| `command: --no-verify<NUL>x` (a guard) | joins to `--no-verifyx`,
matches nothing, **allowed** | leaves `--no-verify`, **blocked** |

(Which of those two readings the executor would agree with is untraced,
and is not the argument —
see above. The point is only that a caller ignoring the flag degrades
unsafely in one class or the
other, depending which disposition the helper picks.)

Both halves measured. The command half is the boundary table below. The
content half I measured by
driving the helper directly, since no shipped hook reads
`.tool_input.content` through it on `main`:

```
payload: .tool_input.content = "harmless preamble<NUL>aws_secret=AKIA…"
this branch (truncate)  rc=0  flag=1  value=[harmless preamble]   credential NOT visible
468bb2d    (base)      rc=1  flag=-  value=[<none>]              credential NOT visible
```

**So yes — truncation loses post-NUL content for a scanning caller.**
Stated plainly because it is a
real consequence of this design. It is not a regression (the base loses
it too, and additionally
allows), and truncation is still the chosen default: it keeps the
*command* class safe when a caller
ignores the flag, where strip keeps the *content* class safe instead.
Strip inverts which class fails
unsafely; it does not remove the failure. Neither is chosen on executor
grounds.

**A single disposition cannot serve both callers. The flag is what
resolves it** — the helper
reports, and each caller decides: a command guard refuses outright, a
content scanner refuses the
write rather than scanning a value it knows is incomplete. Either way
the credential never lands.

### The count, measured on `9fb8383d`

**Every one of the ten hooks #2120 converts calls `hook::jq_fields`.
Zero of them consult any NUL
signal. Six own an `exit 2` verdict:**

| hook | `jq_fields` calls | flag checks | `exit 2` paths |
| --- | --- | --- | --- |
| `secret-pattern-detection` | 2 | **0** | 2 |
| `hardcoded-path-check` | 2 | **0** | 2 |
| `block-convention-violation` | 2 | **0** | 3 |
| `block-hook-bypass` | 2 | **0** | 2 |
| `block-noncanonical-commit` | 2 | **0** | 5 |
| `cli-flag-verify` | 2 | **0** | 1 |
| `skill-reference-verify` | 3 | **0** | 0 |
| `stale-path-verify` | 3 | **0** | 0 |
| `flag-commit-pr-skill-bypass` | 2 | **0** | 0 |
| `workflow-resilience-check` | 2 | **0** | 0 |

Zero flag checks is expected — the flag does not exist on their branch.
The point is what it implies
for whichever of us merges second: **merge order does not rescue it.**
This PR first, then their
rebase, and the scanning hooks receive truncated values with no flag
check. Theirs first, then this
one, and the same is true the moment strip becomes truncate. **A reader
must not conclude that this
PR makes that conversion safe. It does not.** Adding the flag checks to
those ten hooks is a
prerequisite for the conversion, not a follow-up — and it is theirs to
do, since those hooks exist in
converted form only on their branch. This PR deliberately does not touch
them.

`hardcoded-path-check.sh` is a **third** caller class worth calling out:
it reads `.tool_input.content`,
`.new_string` and `.new_source` **and** owns two `exit 2` paths, so it
is both scanner and guard.

Per-field reachability was checked separately and holds: at their head,
both
`secret-pattern-detection.sh` and `hardcoded-path-check.sh` reach `exit
2` through `.content` and
through `.new_string`. (`hardcoded-path-check.sh` returns early unless
`CLAUDE_PROJECT_DIR` is set,
so a probe without it exits 0 on every payload and looks exactly like
"not reachable".)

#2123 needs nothing — its diff introduces zero `hook::jq_fields` call
sites.

**Merge coordination:** #2120 now also edits `lib/hook-utils.sh`, so
this is a direct conflict on the
same function rather than only on the manifest and changelog. Whoever
merges second must **keep both
correctness properties** — the flag and the fail-closed guards from
here, and the scanning-caller
requirement from there — rather than resolving by taking one side of the
hunk.

## Evidence

### Hook boundary, before and after

Real hooks, payload piped on stdin, exit code read. BEFORE is a `git
archive` of `origin/main` at
`468bb2d9` — re-measured after #2123 merged, because #2123 changed
`plugins/guardrails/lib/powershell/ps-command.sh`, which both guards
source. AFTER is this branch.
Same script, same host.

| case | before | after |
| --- | --- | --- |
| clean `git push --no-verify` / `git reset --hard` | 2 | 2 |
| clean harmless (`echo hi` / `git status`) | 0 | 0 |
| trailing NUL | **0** | **2** |
| NUL splitting the flag (`--no-veri<NUL>fy`) | **0** | **2** |
| NUL then junk (`--no-verify<NUL>x`) | **0** | **2** |
| leading NUL | **0** | **2** |
| NUL in an otherwise harmless command | **0** | **2** |

Identical for both guards. No row where a clean command changed verdict.
The `<NUL>x` row is the one
that matters most: it is the payload that executes as the dangerous
command.

### The leading-NUL row blocks for the right reason

Identical truncated content, opposite verdicts, so the flag decides
rather than incidental matching:

| payload | exit |
| --- | --- |
| `"command": ""` (empty, no NUL) | 0 |
| `command` field absent entirely | 0 |
| leading NUL, truncates to empty | **2** |
| a lone NUL and nothing else | **2** |

Same on both guards.

### Test suites, same host, baseline vs branch

**Both arms ran in full**, serially, on an uncontended host: every
`*.test.sh` under
`plugins/guardrails/hooks/` plus `lib/hook-utils.test.sh` — 14 suites,
every one of them listed
below. BASELINE is the same `468bb2d9` tree used for the boundary table;
BRANCH is this tip.

| suite | baseline | branch | delta |
| --- | --- | --- | --- |
| `lib/hook-utils.test.sh` | 156 / 0 | **162 / 0** | +6 new cases |
| `block-dangerous-git.test.sh` | 341 / 0 | **346 / 0** | +5 new cases |
| `block-no-verify.test.sh` | 120 / 0 | **127 / 0** | +7 new cases |
| `block-convention-violation.test.sh` | 31 / 0 | 31 / 0 | — |
| `block-hook-bypass.test.sh` | 260 / 0 | 260 / 0 | — |
| `block-noncanonical-commit.test.sh` | 202 / 0 | 202 / 0 | — |
| `cli-flag-verify.test.sh` | 52 / 0 | 52 / 0 | — |
| `flag-commit-pr-skill-bypass.test.sh` | 29 / 0 | 29 / 0 | — |
| `hardcoded-path-check.test.sh` | 94 / 0 | 94 / 0 | — |
| `require-jq-notice-isolation.test.sh` | 2 / 0 | 2 / 0 | — |
| `secret-pattern-detection.test.sh` | 52 / 0 | 52 / 0 | — |
| `skill-reference-verify.test.sh` | 96 / 0 | 96 / 0 | — |
| `stale-path-verify.test.sh` | 87 / 0 | 87 / 0 | — |
| `workflow-resilience-check.test.sh` | 16 / 0 | 16 / 0 | — |
| **total** | **1538 / 0** | **1556 / 0** | **+18, 0 failures either
side** |

Every suite that does not exercise the new path is byte-identical across
the two arms, so the +18 is
entirely the new cases. No pre-existing failure to disambiguate.

Two of the new library tests look redundant and are not:
`HOOK_JQ_FIELDS_NUL` is checked both after
a clean payload and after an **early return**, each running a NUL
payload first, because a
single-call test cannot observe a stale flag however it is written, and
two of the three return
paths fire before any NUL could be seen.

### Other gates, all re-run after the rebase

`sync-hook-utils.sh --check` (16/16) - `sync-hook-utils.sh --check-bump
origin/main` -
`check-changelog-parity.sh --check` / `--check-bump origin/main` /
`--check-order` -
`check-silent-skips.sh` - `check-contract-clause-coverage.py` -
`check-cross-plugin-source-drift.sh --check` -
`check-hook-userconfig-argv.sh` -
`check-plugin-manifest-presence.sh` - `sync-parse-concern-value.sh
--check` -
`sync-resolve-convention-pattern.sh --check` -
`sync-standards-contract.sh --check` -
`check-skill-leaf-names.sh --check` - `check-shell-portability.sh
--paths` -
`shellcheck -x -S warning` (rc 0) - `shfmt -d -i 2` (rc 0) -
`markdownlint-cli2` (0 issues) -
`check-manifest-duplicate-keys.py`.

## What this PR does NOT fix, stated rather than implied

**A payload jq cannot parse still returns 1 and is still allowed.**
Malformed JSON, a wrongly typed
field or an empty buffer all reach the same `|| exit 0`, exactly as
before this change. Process
substitution also means jq's own exit status is never observed. That
path is untouched here and out
of scope, and the header comment now says so instead of claiming — as an
earlier draft of this very
fix did — that nothing a payload contains can reach it. That claim is
the same reasoning shape that
produced #2122, and it should not ship inside its fix.

## What I could NOT verify

- **How a command actually travels from hook payload to execution.**
Nobody traced it. Two shell
behaviours were measured and they disagree, and Node refuses NUL-bearing
strings on every shape
tried, so the command may never reach a shell parser at all. The design
is built so this does not
matter: fail-closed is right under deletion, truncation, and refusal
alike. An earlier draft of
this PR did lean on it, in one direction and then the other; both are
gone, from the body and from
  the code comments, the README and the changelog.
- **Whether the harness's control-character validation runs before or
after PreToolUse hooks**, and
**whether the class it rejects includes NUL specifically.** The
discriminating probe is
bypass-shaped and was deliberately not run. The guard that exists is
worded *"contains control
characters that would be hidden in the approval dialog"* —
approval-surface anti-spoofing, covering
`command` / `script` / `url` only, with no equivalent on `content` /
`new_string` / `file_text`. It
is an implementation detail, not a documented guarantee, and nothing
here leans on it in either
  direction.
- **Behaviour on jq builds other than 1.8.2, and on bash other than
5.3.9 (Cygwin).** The chosen
construct uses only `explode`, `implode`, `index`, array slicing and
`any` — core since jq 1.5 —
precisely to keep that risk low, but it was not executed against an
older jq. The repo's
  `hook-utils-windows` job exercises Git Bash on windows-2025 in CI.
- **Any performance claim.** The spawn count is unchanged at one, which
is structural. Measured
per-field cost of the sanitiser was below spawn noise on this host — the
no-op control benchmarked
  *slower* than all three candidates — so no number is claimed.
- **Whether a NUL payload can reach a PreToolUse hook through the
harness's own serialization.**
Being settled separately. This fix does not depend on the answer:
failing open on a parse failure
  is wrong regardless of how the parse came to fail.

## Related

- Closes #2122 — the reproduction and the shipped-hook measurements this
PR fixes.
- Refs #2120 (`perf/guardrails-jq-fields`) — converts ten further
guardrails hooks to
`hook::jq_fields` and, at `9fb8383d`, independently fixes the same
function by **stripping** NULs.
Direct conflict on `lib/hook-utils.sh`; whoever merges second must keep
both correctness
properties rather than taking one side of the hunk, and those ten hooks
need `HOOK_JQ_FIELDS_NUL`
  checks as a prerequisite. Not closed by this PR.
- Refs #2123 — merged mid-work; changed
`plugins/guardrails/lib/powershell/ps-command.sh`, which
both guards source, so the boundary table was re-measured against it.
Introduces no
  `hook::jq_fields` call site, so it needs nothing from this change.
- Refs 9b90e35 (#1979) — the precedent for a shared-lib change costing
a version bump and a
  changelog entry in every carrying plugin.

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
kyle-sexton and others added 2 commits August 10, 2026 16:18
Resolves the markdown-format version collision: #2135 landed the vendored
hook-utils NUL-reporting bump as 0.11.4 on main while this branch also claimed
0.11.4. main's entry keeps 0.11.4; this branch's two no-git fixes move to
0.11.5.

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

Every git-absent symlink case asserted a SKIP, so a physically_inside that
regressed to always-false would satisfy all of them while silently costing
every legitimately in-repo symlinked .md its --fix on a git-less host.

The assertion is on the link being GONE rather than the target's bytes: the
stub linter rewrites with sed -i, which renames a temp over the path it was
handed, so a --fix that ran replaces the symlink with a regular file and
leaves the target untouched. Asserting target bytes would fail against
correct code.

Negative control run before committing: with physically_inside forced to
refuse, this case fails with link-intact=yes and no other case changes.

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

claude Bot commented Aug 10, 2026

Copy link
Copy Markdown

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


Security review complete

  • Gather context (PR diff against origin/main, changed files)
  • Identify what changed since the last reviewed HEAD (e723444)
  • Review markdown-format.sh changes for injection / logic / trust-boundary issues
  • Review test file changes
  • Review plugin.json / CHANGELOG.md changes
  • Post final findings

Scope: the 4 files changed in this PR at current HEAD 7596ef7 (diff against origin/main) — hooks/markdown-format.sh, hooks/markdown-format.test.sh, CHANGELOG.md, .claude-plugin/plugin.json.

No production code changed since the last review round. git diff e723444..7596ef7 -- plugins/markdown-format/hooks/markdown-format.sh is empty — confirmed by blob SHA (ab0cb2c…, unchanged). The one new commit (7596ef7, "pair the git-absent symlink skips with an admit control") only touches markdown-format.test.sh: it adds a control fixture asserting a symlink whose target legitimately resolves inside the repo still gets --fixed on a git-absent host. That's a test-only addition (a case-branch/return-value regression guard), introduces no new production surface, and doesn't change any trust boundary. resolve_repo_root() and physically_inside() sit at the same lines as every prior round (L137 / L225), body unchanged.


IMPORTANT (confidence: plausible, narrow preconditions) — still open: the unauthenticated .git-marker walk in resolve_repo_root remains unbounded by CLAUDE_PROJECT_DIR, and it's the trust boundary the physically_inside symlink-containment guard relies on

Raised on this thread eight times now across prior rounds, unchanged at this HEAD. resolve_repo_root, specifically the walk at L148-L158:

if dir="$(cd "$hint" 2>/dev/null && pwd -P)"; then
  while :; do
    if [[ -e "$dir/.git" ]]; then
      printf '%s' "$dir"
      return 0
    fi
    parent="$(dirname "$dir")"
    [[ "$parent" != "$dir" ]] || break
    dir="$parent"
  done
fi
if [[ -n "${CLAUDE_PROJECT_DIR:-}" ]]; then
  printf '%s' "$CLAUDE_PROJECT_DIR"
  return 0
fi
  1. -e "$dir/.git" accepts any file or directory literally named .git, valid or not — real git requires a file-form .git to contain a gitdir: pointer (gitrepository-layout). Anyone who can write a .git-named entry into a shared ancestor directory of the edited file can redirect root resolution there.
  2. The walk climbs unbounded and can match before the CLAUDE_PROJECT_DIR fallback is consulted, with no stop at CLAUDE_PROJECT_DIR itself — so for the documented last-resort case (an unpacked archive / vendored copy with no .git inside, by construction), a .git-named entry in any ancestor above CLAUDE_PROJECT_DIR wins first.

This matters beyond config discovery: REPO_ROOT (produced by this walk, L290) is the trust boundary physically_inside uses at L301-L302 to contain the symlink-escape fix landed in 193c9d2. physically_inside's own canonicalization is sound in isolation (both operands go through cd … && pwd -P), but an attacker-widened REPO_ROOT can still make it accept an out-of-tree symlink target as "inside", reopening the arbitrary-out-of-tree-write escape that fix was written to close. Preconditions are unchanged and narrow: git absent from PATH (or the file outside any working tree git can discover), CLAUDE_PROJECT_DIR unset or with an attacker-writable ancestor above it, and a writer-controlled ancestor directory. The ordinary case (a real git clone, or CLAUDE_PROJECT_DIR set with no attacker-writable ancestor) is unaffected.

This has now been open across eight review rounds on this thread without a code change addressing it. Not re-filing as a new inline comment since nothing changed to re-review — restating so it isn't lost in the merge noise. Worth an explicit decision (accept the documented narrow precondition, or close it with a gitdir:-prefix content check and/or bounding the walk at CLAUDE_PROJECT_DIR) before merge.


Other things checked, no issues found

  • The new test control (markdown-format.test.sh:613-644) only adds fixtures/assertions in a mktemp-scoped temp dir; no unsafe temp-file handling or injection introduced, and it exercises no new code path in the hook itself.
  • No new command/shell injection: REPO_ROOT, FILE, FILE_PHYSICAL, hint/dir are always used in quoted [[ ]]/parameter expansions or passed as quoted arguments, never interpolated into eval or an unquoted command string.
  • git-selection env vars stay cleared where git is actually invoked.
  • plugin.json/CHANGELOG.md changes are version/documentation only (0.11.40.11.5), no security-relevant content. (Noting for the record, out of this lane's security scope: the CHANGELOG.md currently contains a literal, unresolved diff3 conflict marker — ||||||| 38bb041d at line 56 — left over from a merge resolution; it's a documentation defect, not a code-execution or trust-boundary issue, since markdown-format.sh/.test.sh/plugin.json carry no such markers.)
  • No workflow files touched by this PR, so zizmor-covered categories (unpinned actions, dangerous triggers, permissions, template injection) are correctly out of scope for this lane.

Note on verification: as in prior rounds, I could not execute markdownlint-cli2, the hook, or its test suite in this environment — this review is a code trace against the diff and surrounding file, not a reproduced measurement.
· branch fix/markdown-format-nogit-root-followup

The merge resolution removed the <<<<<<<, ======= and >>>>>>> markers but not
the ||||||| base marker, which markdownlint then read as body text abutting the
0.11.4 heading.

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

claude Bot commented Aug 10, 2026

Copy link
Copy Markdown

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


Security review complete

  • Gather context (PR diff since last reviewed HEAD 7596ef7, current HEAD 99cf498)
  • Review new commit(s) for injection / logic / trust-boundary issues
  • Re-check status of the previously-flagged .git-marker walk finding
  • Post final findings

Scope: the 4 files changed in this PR at current HEAD 99cf498 (diff against origin/main) — hooks/markdown-format.sh, hooks/markdown-format.test.sh, CHANGELOG.md, .claude-plugin/plugin.json.

Nothing security-relevant changed since the last review round. git diff 7596ef7..99cf498 -- plugins/markdown-format/ touches only CHANGELOG.md, removing a stray unresolved diff3 base marker (||||||| 38bb041d) that a prior round in this thread had already flagged as a documentation defect:

-||||||| 38bb041d
+
 ## [0.11.4]

That's a whitespace/marker cleanup in prose, not code. markdown-format.sh, markdown-format.test.sh, and plugin.json are byte-identical to the last reviewed state (confirmed via git rev-parse HEAD:<path>ab0cb2c… / a1b3425… / 4ba070a…, all unchanged since 7596ef7). resolve_repo_root() and physically_inside() sit at the same lines as every prior round (L137 / L225 in the current file), bodies unchanged.


IMPORTANT (confidence: plausible, narrow preconditions) — still open: the unauthenticated .git-marker walk in resolve_repo_root remains unbounded by CLAUDE_PROJECT_DIR, and it's the trust boundary the physically_inside symlink-containment guard relies on

Raised on this thread nine times now across prior rounds, unchanged at this HEAD. resolve_repo_root, specifically the walk at L148-L158:

if dir="$(cd "$hint" 2>/dev/null && pwd -P)"; then
  while :; do
    if [[ -e "$dir/.git" ]]; then
      printf '%s' "$dir"
      return 0
    fi
    parent="$(dirname "$dir")"
    [[ "$parent" != "$dir" ]] || break
    dir="$parent"
  done
fi
if [[ -n "${CLAUDE_PROJECT_DIR:-}" ]]; then
  printf '%s' "$CLAUDE_PROJECT_DIR"
  return 0
fi
  1. -e "$dir/.git" accepts any file or directory literally named .git, valid or not — real git requires a file-form .git to contain a gitdir: pointer (gitrepository-layout). Anyone who can write a .git-named entry into a shared ancestor directory of the edited file can redirect root resolution there.
  2. The walk climbs unbounded and can match before the CLAUDE_PROJECT_DIR fallback is consulted, with no stop at CLAUDE_PROJECT_DIR itself — so for the documented last-resort case (an unpacked archive / vendored copy with no .git inside, by construction), a .git-named entry in any ancestor above CLAUDE_PROJECT_DIR wins first.

This matters beyond config discovery: REPO_ROOT (produced by this walk, L290) is the trust boundary physically_inside uses at L301-L302 to contain the symlink-escape fix landed in 193c9d2. physically_inside's own canonicalization is sound in isolation (both operands go through cd … && pwd -P), but an attacker-widened REPO_ROOT can still make it accept an out-of-tree symlink target as "inside", reopening the arbitrary-out-of-tree-write escape that fix was written to close. Preconditions are unchanged and narrow: git absent from PATH (or the file outside any working tree git can discover), CLAUDE_PROJECT_DIR unset or with an attacker-writable ancestor above it, and a writer-controlled ancestor directory. The ordinary case (a real git clone, or CLAUDE_PROJECT_DIR set with no attacker-writable ancestor) is unaffected.

This has now been open across nine review rounds on this thread without a code change addressing it. Not re-filing as a new inline comment since nothing changed to re-review — restating so it isn't lost in the merge noise. Worth an explicit decision (accept the documented narrow precondition, or close it with a gitdir:-prefix content check and/or bounding the walk at CLAUDE_PROJECT_DIR) before merge.


Other things checked, no issues found

  • No new command/shell injection: REPO_ROOT, FILE, FILE_PHYSICAL, hint/dir are always used in quoted [[ ]]/parameter expansions or passed as quoted arguments, never interpolated into eval or an unquoted command string.
  • git-selection env vars stay cleared where git is actually invoked.
  • plugin.json/CHANGELOG.md changes remain version/documentation only; the only change since the last round is the diff3-marker removal, which is prose cleanup with no security relevance.
  • No workflow files touched by this PR, so zizmor-covered categories (unpinned actions, dangerous triggers, permissions, template injection) are correctly out of scope for this lane.

Note on verification: as in prior rounds, I could not execute markdownlint-cli2, the hook, or its test suite in this environment — this review is a code trace against the diff and surrounding file, not a reproduced measurement.
· branch fix/markdown-format-nogit-root-followup

main advanced markdown-format to 0.11.6 while this branch held 0.11.5, so the
branch's two no-git fixes move to 0.11.7. main's 0.11.6 and 0.11.5 sections are
kept under their own headings with their own content.

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

claude Bot commented Aug 11, 2026

Copy link
Copy Markdown

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


Security review complete

  • Gather context (PR diff against origin/main at HEAD e6da7da)
  • Review markdown-format.sh changes for injection / logic / trust-boundary issues
  • Review test file changes
  • Review plugin.json / CHANGELOG.md changes
  • Post final findings

Scope: the 4 files changed in this PR at HEAD e6da7da (diff against origin/main) — hooks/markdown-format.sh, hooks/markdown-format.test.sh, CHANGELOG.md, .claude-plugin/plugin.json.

No code change since the last reviewed HEAD (99cf498). I diffed 99cf498..e6da7da restricted to these 4 files: markdown-format.sh and markdown-format.test.sh are byte-identical (git diff empty). Only plugin.json (0.11.50.11.7) and CHANGELOG.md moved, via two intervening origin/main merges that brought in an unrelated shared-library entry (0.11.6, hook::is_enabled) and a doc-timestamp bump — neither touches this hook's logic. resolve_repo_root() and physically_inside() sit at the same lines as every prior round (L137 / L225), bodies unchanged.


IMPORTANT (confidence: plausible, narrow preconditions) — still open: the unauthenticated .git-marker walk in resolve_repo_root remains unbounded by CLAUDE_PROJECT_DIR, and it is the trust boundary the physically_inside symlink-containment guard relies on

Raised on this thread ten times now across prior rounds (05:12:59, 13:29:13, 14:26:36, 14:44:41, 15:28:43, 15:59:42, 20:08:36, 20:24:16, 20:31:19, and now this round), unchanged at this HEAD. resolve_repo_root, specifically the walk at L148-L158:

if dir="$(cd "$hint" 2>/dev/null && pwd -P)"; then
  while :; do
    if [[ -e "$dir/.git" ]]; then
      printf '%s' "$dir"
      return 0
    fi
    parent="$(dirname "$dir")"
    [[ "$parent" != "$dir" ]] || break
    dir="$parent"
  done
fi
if [[ -n "${CLAUDE_PROJECT_DIR:-}" ]]; then
  printf '%s' "$CLAUDE_PROJECT_DIR"
  return 0
fi
  1. -e "$dir/.git" accepts any file or directory literally named .git, valid or not — real git requires a file-form .git to contain a gitdir: pointer (gitrepository-layout). Anyone who can write a .git-named entry into a shared ancestor directory of the edited file can redirect root resolution there.
  2. The walk climbs unbounded and can match before the CLAUDE_PROJECT_DIR fallback is consulted, with no stop at CLAUDE_PROJECT_DIR itself — so for the documented last-resort case (an unpacked archive / vendored copy with no .git inside, by construction), a .git-named entry in any ancestor above CLAUDE_PROJECT_DIR wins first, and setting CLAUDE_PROJECT_DIR does not bound this the way the surrounding comment (L127-L133) implies.

This matters beyond config discovery: REPO_ROOT (produced by this walk, L290) is the trust boundary physically_inside uses at L301-L302 to contain the symlink-escape fix landed earlier in this PR's history. physically_inside's own canonicalization is sound in isolation (both operands go through cd … && pwd -P), but an attacker-widened REPO_ROOT can still make it accept an out-of-tree symlink target as "inside", reopening the arbitrary-out-of-tree-write escape that fix was written to close.

Preconditions are unchanged and narrow: git absent from PATH (or the file outside any working tree git can discover), CLAUDE_PROJECT_DIR unset or with an attacker-writable ancestor above it, and a writer-controlled ancestor directory shared with the file/symlink target. The ordinary case (a real git clone, or CLAUDE_PROJECT_DIR set with no attacker-writable ancestor) is unaffected.

This has now been open across ten review rounds on this thread without a code change addressing it. Not re-filing as a new inline comment since nothing changed to re-review — restating so it isn't lost in the merge noise. Worth an explicit decision before merge: accept the documented narrow precondition, or close it by requiring a file-form .git to start with gitdir: before accepting it as a root marker, and/or bounding the walk at CLAUDE_PROJECT_DIR rather than letting it climb past.


Other things checked, no issues found

  • No new command/shell injection: REPO_ROOT, FILE, FILE_PHYSICAL, hint/dir are always used in quoted [[ ]]/parameter expansions or passed as quoted arguments, never interpolated into eval or an unquoted command string.
  • git-selection env vars stay cleared where git is actually invoked.
  • Test-file additions (markdown-format.test.sh) only add fixtures/assertions in mktemp-scoped temp dirs; no unsafe temp-file handling or injection.
  • plugin.json/CHANGELOG.md changes since the last round are version/documentation only, pulled in from unrelated origin/main merges (0.11.6 shared-library note, doc-timestamp refresh) — no security-relevant content.
  • No workflow files touched by this PR, so zizmor-covered categories (unpinned actions, dangerous triggers, permissions, template injection) are correctly out of scope for this lane.

Note on verification: as in all prior rounds on this thread, I could not execute markdownlint-cli2, the hook, or its test suite in this environment — this review is a code trace against the diff and surrounding file, not a reproduced measurement.
· branch fix/markdown-format-nogit-root-followup

@kyle-sexton
kyle-sexton merged commit 6b2f54b into main Aug 11, 2026
34 checks passed
@kyle-sexton
kyle-sexton deleted the fix/markdown-format-nogit-root-followup branch August 11, 2026 22:17
kyle-sexton added a commit that referenced this pull request Aug 11, 2026
… last (#2171)

No linked issue

## Summary

`babysit_merge.branch_rules` reads the right endpoint —
`repos/{repo}/rules/branches/{branch}` — but folds it as if each rule
type appeared at most once. That endpoint returns one rule of a given
type **per ruleset** governing the branch, and the fold is a plain
assignment inside the loop, so each ruleset overwrote the previous one
and only the last survived.

Measured live on this repository. `main` is governed by two rulesets
carrying required contexts, both org-sourced:

| ruleset id | contexts |
| --- | --- |
| 17989001 | `pr-title / pr-title`, `do-not-merge / do-not-merge`,
`ci-status` |
| 19388547 | `security-review / security-review` |

19388547 is returned last, so the helper reported
`effectiveRules.requiredContexts` as **only** `["security-review /
security-review"]` — three of four required contexts silently dropped.
The single-rule assumption held under classic branch protection, which
has exactly one such rule. It does not hold under rulesets.

**Impact: a reporting and defence-in-depth defect, not a merge-safety
hole.** The gate refuses independently on `mergeStateStatus not in
READY_MERGE_STATES` (`{CLEAN, HAS_HOOKS}`), and GitHub integrates
required checks into that field — live `MergeStateStatus` introspection
gives `CLEAN: "Mergeable and passing commit status"`, `UNSTABLE:
"Mergeable with non-passing commit status"`, `BLOCKED: "The merge is
blocked"` — so a **failing** required context cannot present as
`CLEAN`/`HAS_HOOKS`. The **absent**-context case is derived from
required-status-check semantics, not observed: every required context
runs on every PR here, so there was no live PR to reproduce it against.
Unconditional `if failing:` / `if pending:` blockers built from the
whole rollup cover the rest. What the bug cost is the **explanation**:
`effectiveRules` and the `required checks not satisfied` blocker both
under-reported, so an operator could not see which contexts actually
govern.

One safety-adjacent consequence, in the **over-holding** direction.
`base_is_unprotected = not required_reviews and not
required_context_list`, and this repo's `pull_request` rule sets
`required_approving_review_count: 0`, so the flag hangs entirely on
`requiredContexts` being empty. Under the bug that meant "the **last**
status-checks rule is empty"; fixed, it means "**all** of them are".
"All empty" is a subset of "last empty", and both consumers of the flag
only ever *add* blockers — so the bug produced a **false hold** on a
superset of cases and never retired one. Latent here, since neither
ruleset carries an empty context list. It is not a fail-open.

## Fix

**Commit 1 — `required_status_checks`.**

- Accumulate contexts into a set across **all** rules, reported
`sorted()`. Deduped because two rulesets may legitimately require the
same context; sorted so the reported set is stable regardless of the
order the API returns rulesets in.
- Entries carrying no `context` are dropped rather than carried.
Previously a missing key produced a `None` that reached the
reconciliation loop and surfaced as a literal `"None"` required context;
it would also crash the new sort. This is a visible change in the
helper's output.
- `base_is_unprotected` needs **no code change** and is confirm-safe
once the union is correct: the union is empty only when no ruleset
requires anything, which is exactly what the flag means.

**Commit 2 — `pull_request`.** The same assign-in-loop shape sat three
lines below, in the same function. Not observed misreporting — exactly
one `pull_request` rule (ruleset 17988999) governs the branch today —
but nothing prevents a second, and a ruleset requiring 2 approvals
returned before one requiring 0 would have reported 0.
`requiredApprovingReviews` now takes the `max`,
`requireThreadResolution` the `OR`.

That fold direction is deliberately argued from safety, not from
GitHub's internal composition rule, which this change does not claim to
know: **max/OR can only ever over-report**, which holds a PR for a
human, where last-wins can under-report and release one.

This one could lose a blocker outright, not merely under-report: a
trailing `pull_request` rule with `required_approving_review_count: 0`
erased an earlier ruleset's requirement and **dropped the `needs N
approving review(s)` blocker**. Keep that distinct from the
`base_is_unprotected` consequence above, which runs the other way
(over-hold).

A malformed-but-present count reads as **one** review, never zero —
reading it as zero would be the single fail-open step in a fold whose
whole argument is that it can only over-report.

Severity split, kept separate on purpose:

- `requiredApprovingReviews` — a **fail-closed behaviour change**, not
currently firing. It feeds both `base_is_unprotected` and the `needs N
approving review(s)` blocker.
- `requireThreadResolution`, `requireSignatures`, `requireLinearHistory`
— **report-only**. Set into the summary, never consumed as a blocker;
the gate holds on unresolved threads unconditionally via `if threads:`.
They do not borrow the first item's severity.

Version bumped `0.51.5` → `0.51.6` with a matching CHANGELOG entry,
following the plugin's convention — every comparable
`fix(source-control)` commit in recent history (`cf743d61`, `ac27ea5a`,
`30be2a0b`, `e6ee72ef`) bumped the manifest version.

## Verification

New module `tests/test_babysit_merge_branch_rules.py` (7 tests), each
run against the fixed code and against the unfixed file:

| test | fixed | unfixed |
| --- | --- | --- |
| `test_contexts_from_every_ruleset_survive` | ok | **FAIL** —
`['security-review / security-review'] != ['ci-status', 'do-not-merge /
do-not-merge', 'pr-title / pr-title', 'security-review /
security-review']` |
| `test_a_context_required_by_two_rulesets_is_reported_once` | ok |
**FAIL** — `['ci-status'] != ['ci-status', 'pr-title / pr-title']` |
| `test_a_context_less_entry_is_dropped` | ok | **FAIL** — `[None] !=
[]` |
| `test_empty_trailing_rule_leaves_the_base_protected` | ok | **FAIL** —
`True is not false` (`baseUnprotected` flipped) |
| `test_the_strictest_approval_count_wins` | ok | **FAIL** — `0 != 2` |
| `test_thread_resolution_required_by_any_ruleset_survives` | ok |
**FAIL** — `False is not true` |
| `test_no_context_anywhere_still_reports_an_unprotected_base` | ok | ok
|

Six regress. The seventh passes both ways **by design** — it is the
over-correction guard, pinning that a genuinely context-less base still
reports unprotected. It is labelled as such in its class docstring so
nobody counts it among the regression tests.

`test_empty_trailing_rule_leaves_the_base_protected` asserts on
`evaluate()`'s `baseUnprotected` and blocker list, not on `branch_rules`
alone, and its fixture sets `required_approving_review_count: 0` — with
a non-zero count the flag would be `False` against the unfixed code too
and the test would prove nothing.

**End-to-end against a live CLEAN PR.** The fix feeds four contexts into
the reconciliation matcher where one went before, so a context that
failed to match its rollup entry would convert a silent under-report
into a spurious blocker. Ran `evaluate()` against #2150 (CLEAN, all four
contexts green):

```
requiredContexts: ["ci-status", "do-not-merge / do-not-merge",
                   "pr-title / pr-title", "security-review / security-review"]
requiredChecks:   all four found: true, satisfied: true, category: "success"
baseUnprotected: false   blockers: []   ready: true   mergeStateStatus: CLEAN
```

**Suite.** `bash
plugins/source-control/skills/babysit-prs/scripts/engine.test.sh` exits
0 — 612 tests OK, `ruff` (CI pin) clean, guarded-wrapper behaviour all
PASS. No shell files changed, so no shellcheck surface.

**Changelog parity.** `--check` and `--check-order` pass. `--check-bump
origin/main` passed 8/8 consecutive local runs on GNU Awk 5.4.0.
Recording that as an observation, not a health claim: the gate is
reported to have a SIGPIPE race after #2154, and local green does not
establish CI green.

**Not verified — recorded, not claimed.** A ruleset carrying **bypass
actors** is the one shape where GitHub could plausibly report `CLEAN` to
a bypassing identity while a required context is unmet; there the
unmet-required blocker would be the only defence, which raises the
severity of the under-report. Untestable here — every ruleset carries
`bypass_actors: []`. Likewise the absent-required-context case above.
Neither refutes the characterisation; both are open.

**Two things worth knowing about the union.** Adding `security-review /
security-review` does not mint a false blocker when that check skips:
`babysit_checks.py` treats `NEUTRAL`/`SKIPPED`/`SUCCESS` as success
states, so a name-stable skipped check still satisfies. And the
deliberately loose context matcher now processes four contexts where it
processed one — this **amplifies** pre-existing false-match exposure
rather than introducing it, which is exactly what the live `evaluate()`
check above is there to catch.

**Sibling scripts.** `babysit_resolve_thread.py` reads no branch rules,
and a repo-wide search for `rules/branches` / `required_status_checks` /
`effectiveRules` finds no other fold and no other consumer —
`babysit_merge.py` is the only one.
`plugins/source-control/skills/setup/SKILL.md:121` documents the same
endpoint to operators but instructs them to read the whole payload and
flag zero-reviews-and-zero-contexts repos, so it carries no
one-rule-wins assumption and needs no change.

## Related

Refs #2130, #2135 — reported as observed there.

---------

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.

markdown-format: nested files never reach the root config when git is absent — two live defects on main

1 participant