Skip to content

feat(source-control): harden /commit — exec-bit backstop, trailer SSOT, composition resilience - #1590

Merged
kyle-sexton merged 4 commits into
mainfrom
feat/source-control-commit-hardening
Jul 26, 2026
Merged

feat(source-control): harden /commit — exec-bit backstop, trailer SSOT, composition resilience#1590
kyle-sexton merged 4 commits into
mainfrom
feat/source-control-commit-hardening

Conversation

@kyle-sexton

Copy link
Copy Markdown
Contributor

Closes #1579
Closes #1581
Closes #1583
Closes #1584

Summary

Six findings from a post-use behavioral audit of source-control:commit (audited at 0.25.1; the
skill had zero content changes between then and 0.32.1, so all six were still live). Every
finding was verified before implementing — two claims are refuted, one tier is deferred with
its own issue
, and one previously-unknown cross-platform hazard was found while implementing.

Fix

Exec-bit backstop — #1579

The ordered exec-bit procedure was advisory prose with no tier under it. Two tiers now sit below it:

  • a pre-computed probe at the top of the skill, inside the documented 5,000-token compaction
    re-attach window, reporting staged newly-added shebang files still at 100644;
  • skills/commit/scripts/exec-bit-check.sh (--list / --probe / --fix) with a 30-case
    .test.sh, making the per-commit step a command with an exit code instead of a paragraph to recall.

Both, because the probe is only a snapshot at invocation and cannot see files staged later in the
flow — the skill says so explicitly rather than implying the probe is the check.

New hazard found and pinned while implementing: under core.filemode=falsethe default on
Windows/NTFS
, and the setting in this repo — git ignores worktree permission bits entirely and
stages every file 100644. On such a repo chmod +x alone never reaches the index; only
git update-index --chmod=+x produces a 100755 entry. So on the most common platform here, the
half of the old prose procedure that "looks like it worked" did nothing. The script always performs
both writes, and the test pins the case with core.filemode set explicitly so it tests the same
thing everywhere.

Trailer resolution — #1581

The (<context>) clause is now optional. A census of this repo found compliance not merely low
but collapsing: 41.6% of trailers carry the clause over the last 150 commits, 12.1% over the
last 40. A mandate nobody follows is worse than no mandate.

The ladder gains the rung it never had. Harness-injected commit guidance is neither a config layer
nor a project convention, so a session receiving both it and this skill had no stated tiebreak. It is
now rung 3, with an explicit rule: adopt its shape, never its literal text. Observed
first-hand this session — that guidance can carry a hardcoded model name that does not match the
running session
(a Fable 5 trailer injected into an Opus 5 session). Copying it verbatim writes a
false provenance claim into durable git history, which is exactly the harm the skill's own text says
the template exists to prevent.

Composition, structure, observability — #1583

  • Composition is now two named forms — re-invoke /commit, or run the per-commit checklist
    yourself as commands — and a composing skill must say which. "Remembered convention" is neither.
    The policy also names what decays: not the message shape (reinforced visibly every commit) but
    the ordered per-commit checks, which produce no signal when skipped.
  • Progressive-disclosure split into four reference/ spokes (format-check, exec-bit,
    pathspec-commits, staging-preconditions) with a load-when index, so the re-attach window is
    spent on the per-commit path rather than on ~130 lines of edge machinery. No rule was dropped
    the staging preconditions keep their detection command and action inline as a table; only the
    per-condition rationale moved.
  • A per-commit checklist at the top as the cheap re-anchor.
  • Config-layer probes for all three layers. The tracked-team probe tests tracked-ness via
    git ls-files --error-unmatch, not file existence, and reports an untracked file at that path as
    present but UNTRACKED — not a config layer — preserving the rule 0.25.1 established rather than
    reintroducing it as a drafting-surface bug.

Recorded deviation — #1584

disable-model-invocation: false is now declared explicitly (same effective behavior as the
omitted default, but visible). A /commit-shaped skill is the canonical archetype for true, and
every other skill in this plugin declares the field. The body records the deviation, its reason
(composition requires model reachability), and the compensating controls. Prose, not an invented
frontmatter key.

Refuted / deferred — deliberate non-changes

  • REFUTED: the "62 of 74 trailers" figure. It does not reproduce on any window of this branch —
    at the window where the total is 74, the non-compliant count is 49. The figures are wrong; the
    direction is right and the trend is worse than claimed. Corrected in the issue and changelog rather
    than repeated.
  • REFUTED: "have setup write an explicit trailer_policy." Already implemented. trailer_policy
    is a documented key in reference/config-resolution.md, and /source-control:setup already
    interviews for it and writes it. No change made.
  • DEFERRED with its own issue (source-control:commit: evaluate a PreToolUse exec-bit tier for memory-following commits (deferred from #1579) #1585): the PreToolUse hook tier. It is the only tier covering
    memory-following commits that never invoke the skill, but source-control ships zero hooks today
    and a Bash-matching hook changes the install footprint for every consumer — it needs its own
    security review. There is also an unsettled design question: guardrails already owns
    PreToolUse git-commit gating, so which plugin should own this check is genuinely open.
  • DEFERRED, needs a verified claim: trailer key CASE. The census found a second, unreported drift
    axis — 134 of 137 trailers use Co-authored-by: against 3 using the skill's Co-Authored-By:.
    Git preserves the key's case verbatim (git interpret-trailers does not normalize it). Whether to
    standardize on the dominant lowercase form needs a verified claim about how the forge parses the
    key, which this work did not establish — so it is recorded as an open question in source-control:commit: Co-Authored-By trailer default drifts three ways and the ladder never names the harness #1581, not
    answered by assumption.

Verification

Dogfooded live, and it caught a real defect in this very PR. Both new scripts were chmod +x-ed
and staged — and staged at 100644 anyway, because core.filemode=false. The new probe reported
2 staged shebang file(s) still at mode 100644, --fix corrected both, and they are committed at
100755. This is precisely the failure the finding describes, caught pre-commit instead of by CI
post-push.

Gates run locally, all green:

  • scripts/check-changed-skills.sh origin/mainPASS, 0 errors. All 3 base-ref trigger phrases
    preserved; description unchanged (418/1536); SKILL.md 304/500 lines; the new script test is
    auto-detected and passes.
  • exec-bit-check.test.sh30 cases, 0 failures (detection scope, symlink skip, staged-deletion
    skip, already-tracked skip, worktree+index fix ordering, core.filemode=false pinned, pathspec
    limiting, paths with spaces, all error exits).
  • shellcheck --rcfile .shellcheckrc → clean, with the repo's require-double-brackets and
    add-default-case optional checks enabled. shfmt -d → clean.
  • scripts/check-shell-portability.sh / check-skill-portability.sh / check-skill-leaf-names.sh /
    check-silent-skips.sh → all clean.
  • scripts/check-changelog-parity.sh --check-bump origin/main → pass (0.32.1 → 0.33.0 with a matching
    ## [0.33.0] entry). scripts/validate-plugins.sh → all manifests + catalog valid.
  • markdownlint-cli2 over all 44 source-control markdown files → 0 issues.

Four evals added: the exec-bit backstop running as a command, an anti-pattern eval for copying the
harness trailer's hardcoded model verbatim, the untracked-team-config-file case, and the
composition-form case.

Every harness-behavior claim is grounded in a page fetched this session — the 5,000-token re-attach
budget, the 500-line SKILL.md guidance, and the disable-model-invocation semantics from
https://code.claude.com/docs/en/skills; the PreToolUse mechanism cited in #1585 from
https://code.claude.com/docs/en/hooks and https://code.claude.com/docs/en/plugins-reference.

Related

…T, composition resilience

Six findings from a post-use behavioral audit of the commit skill, all verified
against current official docs and this repo's own history before implementing.

Exec-bit (#1579): the ordered procedure was advisory prose with no tier under
it. Adds a pre-computed probe inside the documented 5,000-token compaction
re-attach window plus skills/commit/scripts/exec-bit-check.sh (--list/--probe/
--fix, 30-case test suite). Found and pinned a new hazard: under
core.filemode=false (the Windows default) git ignores worktree permission bits
entirely, so chmod +x NEVER reaches the index and update-index --chmod=+x is
the only thing that produces 100755. Demonstrated live — this change's own two
new scripts staged 100644 despite chmod +x, and the check caught them.

Trailer (#1581): the mandatory (<context>) clause is now optional; a census
found compliance collapsing (41.6% over 150 commits, 12.1% over 40). Adds the
harness-injected guidance as ladder rung 3 with the rule "adopt its shape,
never its literal text" — observed first-hand that it can carry a hardcoded
model name mismatching the running session, which would write false provenance
into durable history.

Composition + structure + observability (#1583): composition is now two named
forms with "remembered convention" excluded, and names the per-commit CHECKS
(not the message shape) as what decays silently. Splits the hub into four
reference/ spokes so the window is spent on the per-commit path. Adds
config-layer probes that test tracked-ness, not existence.

Model invocation (#1584): declares disable-model-invocation: false explicitly
and records the archetype deviation, its reason, and compensating controls.

Refuted: the audit's "62 of 74" trailer figure does not reproduce on any window
of this branch, and its "have setup write trailer_policy" remediation was
already implemented. Deferred with its own issue: the PreToolUse hook tier
(#1585), which changes the install footprint for every consumer.

Closes #1579
Closes #1581
Closes #1583
Closes #1584

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

claude Bot commented Jul 26, 2026

Copy link
Copy Markdown
Contributor

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


Code Review — feat(source-control): harden /commit

  • Gather context and understand the request
  • Read all changed files
  • Review SKILL.md changes
  • Review new reference/ spoke files
  • Review exec-bit-check.sh script
  • Review exec-bit-check.test.sh test script
  • Review evals.json, plugin.json, and CHANGELOG.md
  • Synthesize findings

This is a well-scoped and carefully documented hardening. The core.filemode=false discovery is a real platform hazard and the double-write fix is correct. Three findings below — one documentation/code mismatch that could mislead a future reader, one scope gap in the detection logic, and one minor test coverage gap — followed by several positive observations.


Finding 1 — Header comment overstates the --fix guarantee

File: exec-bit-check.sh lines 34–37

The header says:

Both writes are therefore always attempted: the chmod for the file's own sake and for filemode=true repos, the update-index for what actually gets committed.

But in --fix mode, when chmod +x fails for a disk-present file, continue exits the per-path block before git update-index runs:

# lines 213–227
if [[ -e "$path" ]]; then
  chmod +x -- "$path" 2>/dev/null || {
    …
    failed=1
    continue          # ← index update skipped
  }
fi
git update-index --chmod=+x -- "$path"

The implementation is defensible — doing the index-only fix would create a worktree/index mismatch that a later git add would silently revert, so aborting the path is arguably safer. But the comment contradicts the code and would mislead someone debugging an edge case where chmod fails (e.g., a read-only mount, FAT32 with strict permissions). The core.filemode=false case is not affected in practice (chmod is a no-op that still exits 0), but the comment should match what the code does.

Suggested wording: replace "always attempted" with "both attempted when the worktree bit can be set; the index update is skipped if chmod fails to keep the two writes consistent." Fix this →


Finding 2 — C (copy) entries silently excluded from exec-bit detection

File: exec-bit-check.sh lines 146–149

When git diff --cached --name-status -z reports a copy (C<score> <src> <dst>), the while-loop reads and discards both _src and _dst — the destination is never added to added:

C*)
  IFS= read -r -d '' _src || break
  IFS= read -r -d '' _dst || break
  ;;

A file staged via copy (possible when --find-copies-harder is active or similarity thresholds are met) could need an exec-bit fix but will never be reported. The A (add) case covers the common workflow, so the practical impact is low — but the script's stated contract ("every path staged as a NEW file") doesn't explicitly exclude copies, and a copy destination is structurally a new file in the index.

Either add _dst to added alongside A entries, or explicitly document the exclusion in the header's "Deliberate scope limits" block alongside the symlink and tracked-file limits. Fix this →


Finding 3 — Test case 5b doesn't assert the worktree bit under core.filemode=false

File: exec-bit-check.test.sh lines 173–193

Case 5b pins the core.filemode=false regression and asserts the index mode:

bash "$HELPER" --repo-dir "$repo5b" --fix >/dev/null 2>&1
assert_eq "--fix reaches the index under core.filemode=false" \
  "100755" "$(staged_mode "$repo5b" nofilemode.sh)"

But the worktree chmod is also executed for the file's "own sake" (per the header comment and reference/exec-bit.md — worktree mismatch causes git status noise and breaks the pathspec-commit form). Case 5 (without core.filemode=false) does check the worktree bit at line 155, but case 5b doesn't. Adding the same check here — gated by skip_case if the bit isn't observable, as in case 5 — would make the regression test cover both writes rather than only the one that was the regression.


Positive observations

core.filemode=false handling. The double-write (worktree first, index second) with explicit git update-index --chmod=+x is correct. The reasoning is clearly explained in the header, in reference/exec-bit.md, and in the CHANGELOG. The live dogfood catch (the PR's own scripts shipped at 100644) is a strong confirmation that the check does what it claims.

NUL-delimited path parsing. The while IFS= read -r -d '' status loop with per-status field reads is correct and handles paths with spaces, quotes, and newlines. The pathspec-to-added mapping for A entries is exact; R and C field consumption prevents byte-stream misalignment.

Progressive-disclosure structure. Moving ~130 lines of edge machinery (pathspec commits, format-check caveats, staging rationale) into reference/ spokes is well-motivated by the 5,000-token re-attach window constraint. The load-when index table at the bottom of SKILL.md is a clean solution — the hub stays within the attach window while full detail is one explicit load away. No content was lost.

Trailer resolution ladder. Adding rung 3 (harness-injected guidance) closes a real gap — a session receiving both the harness instruction and the skill had no stated tiebreak. The "adopt shape, never literal text" rule with the false-provenance motivation is concrete and actionable. The new eval #11 directly tests the anti-pattern.

The exec-bit probe in the pre-computed context block. Combining an early-read snapshot (probe at load time) with a mandatory re-run at step 4 is the right two-tier design; the skill explicitly says the probe is a snapshot and cannot see files staged later. This avoids the false confidence of a probe that claims to substitute for the real check.

Version bump. 0.32.1 → 0.33.0 is correct for a feature release (new backstop, new reference spokes, new evals, behavior changes to the trailer ladder).

disable-model-invocation: false recorded deviation. Making the default explicit with a stated reason (composition requires model reachability) and named compensating controls is exactly the right approach. The deviation section doubles as documentation for any future reviewer wondering why this skill doesn't match the fleet archetype.


Three findings total: one documentation/code mismatch (comment overstates "always attempted"), one undocumented scope gap (copy destinations not checked), one test coverage gap (5b doesn't assert the worktree bit). None are blocking — all three are minor/informational and the overall implementation is solid.

@claude

claude Bot commented Jul 26, 2026

Copy link
Copy Markdown
Contributor

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


Security Review — PR #1590

  • Set up task list
  • Read PR diff and changed files
  • Analyze for security vulnerabilities
  • Post findings

Result: No security vulnerabilities found

This PR adds one new shell script (exec-bit-check.sh) and its test suite, plus four reference markdown files and updates to SKILL.md and evals.json. Reviewed against injection surfaces, trust-boundary issues, filesystem hazards, and unsafe credential/secret handling.

exec-bit-check.sh

Option injection — all git invocations use -- before path arguments (L158, L163, L175, L214, L222). chmod +x -- "$path" is likewise guarded.

Path special characters — git diff is consumed NUL-delimited (-z) with IFS= read -r -d '' at L140–158, correctly handling spaces, newlines, and quotes in filenames. Rename (R*) and copy (C*) records consume their extra fields explicitly so the byte-stream stays in sync.

Blob SHA extractionblob_sha is derived from the <mode> <sha> <stage>\t<path> format of git ls-files --stage, fed directly to git cat-file blob "$blob_sha". Because git only emits valid hex SHAs in this position, the string is controlled by git, not by any user-supplied input. The || continue guards every early-exit path.

--repo-dir filesystem traversalcd -- "$repo_dir" accepts any path reachable by the process, but the effect is bounded: --list and --probe are read-only, and --fix only modifies files already staged as new additions in a git repository. The subsequent git rev-parse --is-inside-work-tree check at L129 gates all operations on a valid git context. No concern at the tool's designed threat surface.

${CLAUDE_PLUGIN_ROOT} in SKILL.md pre-computed context — the invocation bash "${CLAUDE_PLUGIN_ROOT}/skills/commit/scripts/exec-bit-check.sh" --probe double-quotes the expansion; this is set by the trusted plugin runtime, not by user input, so no injection vector.

pathspec-commits.md — hide/restore code sample

The sample at L96–143 correctly:

  • arms the trap restore_hidden EXIT before any mv (L103) so the hidden file is restored on every exit path including failures;
  • uses -e "$f" -o -L "$f" (L124, L100) rather than -e alone, correctly catching dangling symlinks where -e would return false;
  • guards the hide path with both a disk check and git ls-files --error-unmatch (L126) to prevent clobbering a tracked file staged for deletion;
  • uses -z NUL-delimited reads for the same path-safety reasons as the script.

exec-bit-check.test.sh

Temporary repos are created under mktemp -d with trap 'rm -rf "$TEST_TMPDIR"' EXIT. GPG signing is disabled via config only in the test-scoped repos. git config core.filemode false is set per-repo (not globally), so the test does not mutate the developer's git config. The source path "$SCRIPT_DIR/../../../scripts/test-helpers.sh" resolves within the plugin directory tree.

No security issues found.

@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: f031174e7f

ℹ️ 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/source-control/skills/commit/scripts/exec-bit-check.sh
Comment thread plugins/source-control/skills/commit/scripts/exec-bit-check.sh Outdated
--fix mutates index entries and chmods worktree files, so defaulting it to the
whole staged set inverted this skill's own surgical-staging discipline: the
staged set can hold a concurrent session's work — the entire premise of the
pathspec-limited commit form — and a bare --fix would silently rewrite that
session's mode entries.

It now requires either an explicit '-- <path>...' scope or a deliberate --all
opt-in, and exits 2 changing nothing otherwise. --list and --probe stay
unscoped because they only read; that asymmetry is deliberate and documented.

Seven new test cases cover the refusal, that a refused run mutates nothing,
that the read-only modes are unaffected, and both permitted scoping forms.

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

claude Bot commented Jul 26, 2026

Copy link
Copy Markdown
Contributor

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


PR Review — feat(source-control): harden /commit

  • Gather context and understand the request
  • Read exec-bit-check.sh in full
  • Read exec-bit-check.test.sh in full
  • Read updated SKILL.md
  • Read new reference/ spokes
  • Verify Codex P1 findings
  • Verify previous review findings
  • Synthesize and post findings

Two Codex P1 findings needed independent verification before this could merge. Both confirmed. Summary: one is a silent correctness failure in the real-world invocation path; one is a narrow but real security hazard. Three prior findings from the earlier Claude review are also confirmed below for completeness.


Finding 1 — Silent false-clean when script is invoked from a repo subdirectory (confirmed, high)

File: exec-bit-check.sh lines 174, 179

git diff --cached --name-status outputs paths relative to the repository root (per the git diff manpage: "file names are relative to the repository root if --relative is not specified"). The git ls-files --stage -- "$path" call on line 179 resolves its argument relative to the current working directory. When these two are not the same — i.e., when the script is invoked from a subdirectory — every git ls-files lookup returns an empty string, stage_line is empty, continue fires, and no offender is ever reported or fixed, even if staged shebang files exist.

The test suite uses --repo-dir "$repo" in every case, which cds to the test repo root. So all 30 test cases run from the root and do not exercise this path. In the real invocation from SKILL.md:

Exec-bit backstop: !`bash "${CLAUDE_PLUGIN_ROOT}/skills/commit/scripts/exec-bit-check.sh" --probe …`

— no --repo-dir is passed. If Claude Code's working directory happens to be a project subdirectory, the probe silently returns none and the backstop misses every staged shebang file.

Fix: After the git rev-parse --is-inside-work-tree guard, cd to $(git rev-parse --show-toplevel). Any pathspec arguments passed via -- <path>... must be resolved to repo-root-relative form before the cd. One approach:

toplevel="$(git rev-parse --show-toplevel)"
if [[ "${#paths[@]}" -gt 0 ]] && [[ "$(pwd)" != "$toplevel" ]]; then
  resolved=()
  for p in "${paths[@]}"; do
    resolved+=("$(realpath --relative-to="$toplevel" "$(pwd)/$p" 2>/dev/null || printf '%s' "$p")")
  done
  paths=("${resolved[@]}")
fi
cd -- "$toplevel" || { echo "$PROG: cannot enter repo root" >&2; exit 3; }

Fix this →


Finding 2 — chmod +x follows a worktree symlink, potentially reaching outside the repository (confirmed, medium)

File: exec-bit-check.sh lines 229–234

The index check (line 185) correctly skips entries with mode 120000. But that guard covers index symlinks. Once a file has been staged as 100644, the worktree path is free to be replaced by a symlink without re-staging. When that happens, [[ -e "$path" ]] returns true (follows the link to the target), and chmod +x -- "$path" follows the link and sets the execute bit on the symlink's target — which may be a file outside the repository entirely.

The attack surface is narrow (it requires either a race between staging and --fix, or a deliberate worktree manipulation), but the consequence — chmod +x applied to an unrelated file — is the exact hazard the index-mode guard was added to prevent and the prose in reference/exec-bit.md documents.

Fix: add an explicit -L check before the chmod:

if [[ -e "$path" ]]; then
  if [[ -L "$path" ]]; then
    echo "$PROG: warning: '$path' is a symlink in the worktree; setting index entry only" >&2
  else
    chmod +x -- "$path" 2>/dev/null || {
      echo "$PROG: chmod +x failed for '$path'" >&2
      failed=1
      continue
    }
  fi
fi
git update-index --chmod=+x -- "$path"

Fix this →


Findings 3–5 — Confirming the earlier Claude review

These three were already reported; confirming all three from my read.

Finding 3 — Header comment says "Both writes are therefore always attempted" but at line 233, continue after a chmod failure skips git update-index. The comment is inaccurate for the failure path and would mislead a reader debugging a chmod-fails-but-index-should-still-be-set scenario (read-only mount, FAT32 with strict permissions). Minor documentation bug; the implementation choice itself is defensible.

Finding 4C* (copy) entries at lines 162–165 consume _src and _dst but never add _dst to added. A copy destination is structurally a new file in the index and could need an exec-bit fix. The scope doc says "every path staged as a NEW file (A)" — copies are implicitly excluded but not stated. Either add _dst to added or add "Copies skipped" to the "Deliberate scope limits" block.

Finding 5 — Test case 5b asserts the index mode (100755) after --fix under core.filemode=false, which is the regression being pinned. But the worktree chmod is also executed (for core.filemode=true repos and for git status cleanliness). Case 5 conditionally checks [[ -x "$repo5/fixme.sh" ]] with a skip_case fallback. Case 5b doesn't. On Linux, core.filemode=false means git ignores the worktree bit — it doesn't prevent chmod from actually setting it — so [[ -x "$repo5b/nofilemode.sh" ]] should pass on Linux and the check could be added with the same skip_case guard as case 5.


Overall

The core logic — double-write ordering, shebang-from-blob, NUL-delimited parsing, --fix scope refusal — is correct and well-tested on the paths the test suite covers. The progressive-disclosure structure, the trailer resolution ladder with its new rung 3, and the core.filemode=false discovery and fix are all solid. Finding 1 is the one that needs addressing before merge: the pre-computed probe is the primary consumer of the script and it runs without --repo-dir, so the CWD hazard is live in the exact codepath this PR added.

@claude

claude Bot commented Jul 26, 2026

Copy link
Copy Markdown
Contributor

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


Security Review — PR #1590 feat(source-control): harden /commit

  • Gather context and understand the request
  • Read PR diff and changed shell scripts
  • Analyze exec-bit-check.sh for security issues
  • Analyze exec-bit-check.test.sh for security issues
  • Analyze reference markdown files for embedded code samples
  • Synthesize and post findings

This PR introduces exec-bit-check.sh (252 lines), its 30-case test suite, and four reference markdown files. The injection prevention posture is strong throughout: NUL-delimited parsing, -- before every path argument to git, blob-based shebang detection that never touches worktree paths during the read phase, and git update-index confined to paths already in the git-controlled index. No command injection, SQL injection, secret handling, or dangerous GitHub Actions patterns are present.

Two logic-level findings — both independently confirming the Codex review's P1 items — are below. No new findings beyond these two.


Finding 1 — Symlink traversal via chmod in --fix mode

Severity: SUGGESTION | Confidence: HIGH

Location: exec-bit-check.sh L229–234

if [[ -e "$path" ]]; then
  chmod +x -- "$path" 2>/dev/null || {
    …
    continue
  }
fi

The detection phase correctly filters staged paths whose index mode is 120000 (symlink) before the blob probe — so a symlink that was staged as a symlink is never an offender. The fix phase does not re-check whether the worktree path is currently a symlink. If a file staged as a new regular file (A, 100644) is later replaced in the worktree by a symlink pointing outside the repository, chmod +x -- "$path" follows the link and makes the target executable. On Linux, chmod follows symlinks by default; there is no --no-dereference that works portably.

Exploitability constraint: The attacker must have write access to the worktree (to plant the symlink) and the target file must be owned by the user running --fix. If the target is not user-owned, chmod exits non-zero, the path is skipped, and the index update is also skipped (so no false-"fixed" state is recorded). Practical exposure is therefore limited to files the invoking user already owns.

Suggested mitigation: Add a [[ -L "$path" ]] && { warn and skip; continue; } guard immediately before the chmod block. The index update (git update-index --chmod=+x) is still valid for a path that the index records as a regular file but the worktree has swapped for a symlink — but skipping both writes and surfacing a warning is the safer choice, matching the rationale already applied at the detection phase.


Finding 2 — False-negative when invoked from a repository subdirectory

Severity: SUGGESTION | Confidence: HIGH

Location: exec-bit-check.sh L174, L179 and SKILL.md L16

# L174 — git diff emits repo-root-relative paths
done < <(git diff --cached --name-status -z -- … 2>/dev/null)# L179 — git ls-files interprets $path relative to CWD
stage_line="$(git ls-files --stage -- "$path" 2>/dev/null | head -n 1)"

git diff --cached --name-status outputs paths relative to the repository root regardless of CWD. git ls-files --stage -- "$path" interprets its pathspec relative to the current working directory. When the script is invoked from a subdirectory (without --repo-dir pointing to the repo root), the two coordinate systems diverge: a path like src/tool.sh from git diff is looked up as src/src/tool.sh from within src/, returning empty. Every stage_line check fails, every offender is missed, and the script silently exits 0 with no findings.

Security relevance: The script is the described "deterministic tier" under the prose backstop — the thing that catches what a long session's prose recall misses. A systematic false negative on every invocation from a subdirectory means the safety net silently passes regardless of actual exec-bit state, which is precisely the failure mode the script exists to prevent.

SKILL.md pre-computed probe invokes the script without --repo-dir (L16: bash "${CLAUDE_PLUGIN_ROOT}/…/exec-bit-check.sh" --probe). Claude Code's CWD is typically the project root, so this is low-frequency in practice — but any session started from a subdirectory hits the silent false negative for both the snapshot probe and the step-4 --fix run.

Suggested mitigation: Either (a) change all git ls-files and git update-index calls to use --full-name / --work-tree flags or resolve to the repo root via git rev-parse --show-toplevel before processing paths, or (b) document that the script must be invoked with --repo-dir "$(git rev-parse --show-toplevel)" and update the SKILL.md pre-computed context line accordingly.


What was checked and found clean

  • Option injection — all git calls use -- before path arguments; chmod +x -- "$path" likewise. No unquoted expansions in command position.
  • NUL-delimited path parsingwhile IFS= read -r -d '' status with per-field reads for R*/C* is correct; paths with spaces, quotes, or newlines are handled intact.
  • Blob SHA extraction — derived from git ls-files --stage output, which produces 40-hex-character SHAs only; cannot produce option-flag prefixes. The [[ -n "$blob_sha" ]] || continue guard handles any empty-output edge case.
  • Shebang read from staged blobgit cat-file blob "$blob_sha" reads the index content, not the worktree file; eliminates both quoting hazards and TOCTOU races between the shebang check and a worktree modification.
  • --repo-dir scopegit rev-parse --is-inside-work-tree gates all operations; a path that is not a valid git repository exits immediately.
  • --fix scope guard — unscoped --fix refuses (exit 2) rather than silently mutating another session's staged entries; tested explicitly in case group 8b.
  • Test isolation — temp repos under mktemp -d with trap 'rm -rf "$TEST_TMPDIR"' EXIT; core.filemode=false and commit.gpgsign=false set per-repo, not globally.
  • pathspec-commits.md code sample — trap armed before the hide loop; -e ... -o -L ... probes handle dangling symlinks; git ls-files --error-unmatch collision check prevents the hide path from clobbering a tracked absent file.

@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: 26e52499ce

ℹ️ 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/source-control/skills/commit/SKILL.md Outdated
Comment thread plugins/source-control/skills/commit/scripts/exec-bit-check.sh
…worktree symlinks, add --list0

Four review findings on #1590, three of them P1.

Repository-root anchoring (P1, fail-open). git diff --cached --name-status
emits repo-root-relative paths while a git ls-files pathspec resolves against
the cwd. Run from a subdirectory the two disagreed, every lookup missed, and
both --probe and --fix silently reported no offenders even when staged
non-executable shebang files existed. Caller pathspecs are re-anchored via
--show-prefix BEFORE the directory change, so a scoped --fix from a
subdirectory still matches what the caller meant.

The same bug hit the skill's config-layer probes (P1): unanchored, a session
started in a subdirectory reported both repo-scoped layers absent and silently
dropped the team convention and trailer_policy, contradicting the
root-resolution rule reference/config-resolution.md already states.

Worktree symlink refusal (P1, escape). A path staged as a regular 100644 blob
but replaced in the worktree by a symlink passed the -e test, which follows the
link — chmod +x would have made the link's target executable, a file that can
sit entirely outside the repository. -L now runs before -e and the path is
refused loudly rather than handled.

Path output (P2). A git pathname may legally contain a newline, breaking
--list's one-record-per-line contract and --probe's single-line promise. Adds
--list0 (NUL-delimited, the unambiguous form) and shell-quotes such a path in
the two line-based modes so the ambiguity is visible rather than silent.

Seven new test cases: subdirectory invocation for --list/--probe/--fix
including pathspec re-anchoring and out-of-scope non-mutation, the symlink
refusal plus an assertion the target did not become executable, and --list0
terminator counting.

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

claude Bot commented Jul 26, 2026

Copy link
Copy Markdown
Contributor

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


PR Review — feat(source-control): harden /commit (updated commits)

  • Gather context and understand the request
  • Read current state of exec-bit-check.sh
  • Read current state of exec-bit-check.test.sh
  • Read updated SKILL.md
  • Verify fixes for P1/P2 findings from prior reviews
  • Check for any new issues introduced
  • Synthesize findings

The three commits since the initial review (26e5249, 5ad60b6) addressed all P1 and P2 findings. Verification of the fixes below, followed by the three minor items from the first review that remain open.


P1 fixes — verified

Finding: silent false-clean when invoked from a subdirectory

Fixed at lines 148–175. The script now:

  1. captures repo_prefix via git rev-parse --show-prefix (trailing slash included, so prepending is exact)
  2. re-anchors any caller-supplied relative pathspecs from paths[] before the directory change
  3. cds to git rev-parse --show-toplevel

The re-anchoring logic correctly leaves absolute paths (/*) and magic pathspecs (:*) alone. Case group 10 (lines 290–319 of the test) exercises the exact scenario: --list and --probe from sub/, plus a --fix with a CWD-relative pathspec (nested/deep.sh) that is re-anchored to sub/nested/deep.sh. ✓

Finding: chmod +x could follow a worktree symlink outside the repository

Fixed at lines 290–309. The [[ -L "$path" ]] test is ordered before [[ -e "$path" ]] (load-bearing, as the comment notes — -e follows links). A worktree symlink over a staged regular file fails the entire path with exit 4 and skips both writes. Case group 11 (lines 321–351) confirms exit 4, the error message, and that the symlink target is not made executable. ✓

Finding: Codex P2 — newline in path breaks --list one-record-per-line contract

Addressed two ways: --list now uses %q for any path containing a newline (lines 244–249), making the ambiguity visible rather than silent; --list0 (NUL-delimited) added as the fully unambiguous machine-readable form (lines 252–258). Case group 12 (lines 353–367) counts NUL terminators with tr -dc '\0' | wc -c. ✓

Codex P1: SKILL.md config probes not resolving from repo root

Not present in the current code. Lines 18–19 of SKILL.md already compute R="$(git rev-parse --show-toplevel ...)" and reference $R/.claude/... for both the tracked-team and personal-overlay probes. Lines 29–36 document this explicitly. ✓


Three minor items still open (same as prior review — none blocking)

1 — Header comment still overstates "always attempted"

exec-bit-check.sh lines 35–37 says:

Both writes are therefore always attempted: the chmod for the file's own sake…

There are now two early exits before git update-index: the symlink refusal (continue at line 300) and a chmod failure (continue at line 306). Neither reaches the index write. The comment is still explaining the motivation for the double-write, but "always attempted" is no longer accurate in these cases. A follow-up wording fix is fine; not blocking.

2 — C* (copy) destinations silently excluded

Lines 201–204 read and discard _src and _dst for copy entries without adding _dst to added. A copy destination is structurally a new file in the index. The "Deliberate scope limits" header block (lines 14–23) doesn't mention this exclusion. The practical impact is low (copies outside of --find-copies-harder are rare in typical commits), but either adding _dst or documenting "Copies skipped" would close the undocumented gap.

3 — Test case 5b doesn't assert the worktree bit under core.filemode=false

Case 5 at line 155 uses [[ -x "$repo5/fixme.sh" ]] with a skip_case fallback to verify the worktree bit was set. Case 5b at lines 191–193 only asserts the index mode. On Linux, core.filemode=false tells git to ignore the worktree bit — it doesn't prevent chmod from setting it — so the same [[ -x ... ]] + skip_case pattern from case 5 could be added here to fully cover both writes in the regression fixture.


Overall

All P1 and P2 blocking findings are resolved. The implementation is solid: the repo-root anchor with pathspec re-anchoring is correct, the symlink guard ordering is correct and well-commented, and the new test groups (10, 11, 12) cover the added code precisely. The SKILL.md probe documentation at lines 29–36 is a clean explicit statement of the invariant. The three remaining items are the same informational notes from the first review — none are blocking.

@claude

claude Bot commented Jul 26, 2026

Copy link
Copy Markdown
Contributor

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


Security Review — PR #1590 feat(source-control): harden /commit

  • Gather context and understand the request
  • Read current state of changed files (HEAD SHA 5ad60b6)
  • Verify fixes for previously-reported P1 findings (repo-root anchoring, symlink traversal)
  • Analyze exec-bit-check.sh for residual or new security issues
  • Analyze exec-bit-check.test.sh for security issues
  • Analyze reference markdown code samples for embedded security issues
  • Synthesize and post findings

Both P1 findings reported in prior reviews are fixed at the current HEAD. One residual issue — the manual-fallback code sample in a reference file — carries the same class of defect the script was just hardened against.


P1 fixes verified

Repo-root anchoring — FIXED

exec-bit-check.sh lines 160–175

The script now captures --show-prefix before any cd, prepends it to caller pathspecs that are not already absolute or magic-prefixed, then cds to --show-toplevel. The --show-prefix approach is correct and portable — it uses git's own view of the prefix rather than realpath, which requires the file to exist and behaves differently across platforms. Test case group 10 (lines 297–319 of the test file) exercises this: runs the script from a subdirectory without --repo-dir and asserts both root-level and nested offenders are reported, and that a cwd-relative pathspec passed to --fix is re-anchored and fixes the right entry without touching out-of-scope paths. The SKILL.md pre-computed config-layer probes are also fixed: both use R="$(git rev-parse --show-toplevel)" before any file test (SKILL.md lines 18–19).

Symlink traversal via chmod in --fix mode — FIXED

exec-bit-check.sh lines 297–309

[[ -L "$path" ]] is now tested before [[ -e "$path" ]]. On a worktree/index disagreement (path staged as a regular 100644 blob but the worktree entry is a symlink), the script refuses the path loudly, sets failed=1, skips both the chmod and the git update-index, and exits 4. The comment at lines 290–296 explicitly documents the ordering constraint and why it is load-bearing. Test case group 11 pins the regression: it stages a regular file, replaces the worktree entry with a symlink pointing to outside/victim.txt, asserts --fix exits 4 and refuses with the expected message, and asserts the symlink's target was not made executable.


Finding — Manual-fallback snippet missing the -L guard

Severity: SUGGESTION | Confidence: HIGH

reference/exec-bit.md lines 113–119

for f in <newly-added paths>; do
  mode=$(git ls-files --stage -- "$f" | cut -d' ' -f1)
  case "$mode" in 100644) ;; *) continue ;; esac  # skip 100755, symlinks (120000), gitlinks
  git cat-file blob "$(git ls-files --stage -- "$f" | cut -d' ' -f2)" | head -c 2 | grep -q '^#!' || continue
  chmod +x -- "$f"
  git update-index --chmod=+x -- "$f"
done

The case "$mode" guard correctly skips paths whose staged mode is 120000, which is the form git uses when the path was originally added as a symlink. It does not guard against the case the script was just hardened against: a path staged as a regular 100644 blob whose worktree entry is subsequently replaced by a symlink. In that state, mode is still 100644, the blob still starts with #!, and chmod +x -- "$f" follows the link — potentially making a file outside the repository executable.

This is documentation guidance rather than auto-executed code, so a user must actively reach for the fallback and a worktree symlink must be present for the path to be live. But the fallback is framed as the equivalent inline form of the script ("exec-bit-check.sh is the mechanic; this file explains why — and what to do when it cannot run") and teaches the same pattern the script was just fixed to prevent. Adding a [[ -L "$f" ]] && { echo "skipping symlink: $f" >&2; continue; } guard before chmod +x would keep the sample consistent with the script.

Fix this →


What was checked and found clean

  • Option injection — all git invocations use -- before path arguments; chmod +x -- "$path" likewise. No unquoted expansions in command position.
  • NUL-delimited path parsing — the while IFS= read -r -d '' status loop with per-field reads for R*/C* is correct throughout; paths with spaces, newlines, and quotes are handled intact.
  • Blob SHA from controlled outputblob_sha is derived from git ls-files --stage output, which produces 40-char hex SHAs only. Cannot carry option-flag prefixes. The [[ -n "$blob_sha" ]] || continue guard handles any empty-output edge case.
  • Shebang read from staged blobgit cat-file blob "$blob_sha" reads the index content, not the worktree file, eliminating both path-quoting hazards and TOCTOU races between the shebang check and a worktree modification.
  • --fix scope refusal — unscoped --fix exits 2 and mutates nothing; test case 8b pins this. The --all explicit opt-in is documented and tested.
  • --show-prefix re-anchoring — empty-prefix case is guarded ([[ -n "$repo_prefix" ]]); absolute paths and :*-prefixed magic pathspecs are left untouched.
  • Test isolation — temp repos under mktemp -d with trap 'rm -rf "$TEST_TMPDIR"' EXIT; core.filemode=false and commit.gpgsign=false set per-repo, not globally; source path "$SCRIPT_DIR/../../../scripts/test-helpers.sh" resolves within the plugin tree.
  • pathspec-commits.md code sample — trap armed before the hide loop; -e ... -o -L ... probes handle dangling symlinks; git ls-files --error-unmatch collision check guards against clobbering a tracked file.
  • SKILL.md pre-computed probebash "${CLAUDE_PLUGIN_ROOT}/…/exec-bit-check.sh" --probe double-quotes the expansion; CLAUDE_PLUGIN_ROOT is set by the trusted plugin runtime, not user input.

One finding — the manual-fallback snippet missing its -L guard. All other surfaces are clean.

@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: 5ad60b668c

ℹ️ 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/source-control/skills/commit/scripts/exec-bit-check.sh
…spec commit under filemode=false

Reviewer-reported P1 on #1590, reproduced and confirmed: with core.filemode=false
a pathspec (--only) commit records the WORKTREE mode, git cannot see the chmod,
and a correctly-set 100755 index entry is rebuilt as 100644. The skill's two
documented mechanics — the exec-bit fix and the pathspec-limited commit form —
silently did not compose on exactly the platform where the exec-bit fix matters
most.

Measured both directions on a core.filemode=false fixture:
  plain index commit    index 100755 -> HEAD 100755  (preserved)
  pathspec --only commit index 100755 -> HEAD 100644  (lost)

Two candidate workarounds were tested and BOTH failed on that platform, so
neither is offered rather than shipping a fix that only appears to work:
`git -c core.fileMode=true commit -- <path>` still recorded 100644 (the
filesystem carries no exec bit for git to read — Git Bash's chmod is emulated),
and a post-commit update-index plus `commit --amend --only` regressed
identically for the same reason.

So this is documented as a hard constraint, not worked around: an
exec-bit-corrected path is committed with the plain index form, splitting the
commit if the rest genuinely needs a pathspec, and the recorded mode is
confirmed with `git ls-tree HEAD` — never the index, which stays 100755 and
lies about what landed.

Both git behaviors are pinned as characterization tests so a future change
fails loudly instead of quietly invalidating the guidance.

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

claude Bot commented Jul 26, 2026

Copy link
Copy Markdown
Contributor

Claude encountered an error after 2s —— View job


I'll analyze this and get back to you.

@claude

claude Bot commented Jul 26, 2026

Copy link
Copy Markdown
Contributor

Claude encountered an error after 0s —— View job


I'll analyze this and get back to you.

@github-actions

Copy link
Copy Markdown

Warning

Automated review did not complete — this is an infrastructure failure, not a review verdict.

Treat any Claude comment on this PR (including a placeholder like "I'll analyze this and get back to you") as incomplete, not "no findings."

Re-running the job, or pushing a new commit, will retry the review.

@github-actions

Copy link
Copy Markdown

Warning

Automated security review did not complete — this is an infrastructure failure, not a review verdict.

Treat any Claude comment on this PR (including a placeholder like "I'll analyze this and get back to you") as incomplete, not "no findings."

Re-running the job, or pushing a new commit, will retry the review.

@kyle-sexton
kyle-sexton merged commit 6c8d128 into main Jul 26, 2026
31 checks passed
@kyle-sexton
kyle-sexton deleted the feat/source-control-commit-hardening branch July 26, 2026 19:15

@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: 35131b7592

ℹ️ 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 on lines +201 to +204
C*)
IFS= read -r -d '' _src || break
IFS= read -r -d '' _dst || break
;;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Include copy destinations in the exec-bit scan

When copy detection is enabled and a tracked source is modified in the same staged change, git diff --cached --name-status can report the new path as C100 <source> <destination> rather than A; git diff -h describes -C as “detect copies.” This branch consumes and discards the destination, so a newly copied shebang file staged as 100644 is never reported or fixed and can be committed non-executable. Treat the C* destination as an added candidate.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Only half discharged. Leaving this thread OPEN, tracked at #2118.

PR #2098 widened the candidate set to R*/C* destinations but gated both on the source being 100755. Your ask was that a C* destination be treated as an added candidate; only the exec-source half of that shipped.

A fresh-context reviewer, briefed to refute rather than confirm the discharge, caught this attacking my own work. I reproduced it independently against origin/main before accepting it:

########## NON-exec source (the defect)
  diff.renames=false   raw=:000000 100644 … A    copy.sh          --list -> [copy.sh]
  diff.renames=copies  raw=:100644 100644 … C095 lib.sh copy.sh   --list -> []

########## exec source (control, proves the fixture discriminates)
  diff.renames=false   raw=:000000 100644 … A    copy.sh          --list -> [copy.sh]
  diff.renames=copies  raw=:100755 100644 … C095 lib.sh copy.sh   --list -> [copy.sh]

Identical staged content, two different answers depending on the consumer's diff.renames setting — which is the precise failure mode #2098's own body says the fix exists to remove.

The reason it slipped: R* and C* share one predicate and one justification, and the justification only fits one of them. The script comment reads "a deliberately non-executable tracked file merely being moved… squarely outside this check's newly-added-only scope." True for a rename — same tracked file, new path. False for a copy — the destination did not previously exist, so it is newly added and squarely inside scope.

Worse, #2098 added a test that pins the defect. Case group 21 asserts "a copy whose SOURCE was never executable is NOT reported." I wrote that case in response to a reviewer asking for a copy-branch negative twin of the rename negative, and implemented it without noticing the two branches are not symmetric here. So the gap is now locked in by a passing test — the shape most likely to read as intended behaviour to whoever looks next.

#2118 carries the proposed fix (split the arms: R* keeps the source-mode gate, C* drops it), the required test changes including inverting case group 21, and a new case asserting that the two diff.renames configurations agree — the property this finding is actually about, which nothing currently pins.

kyle-sexton added a commit that referenced this pull request Aug 9, 2026
No linked issue

## Summary

Four stranded P2 review findings, each filed by
`chatgpt-codex-connector` on a merged
`source-control` PR and left unresolved, verdicted REAL by an
independent auditor at `main`. All
four are discharged here. A fifth thread on the same sweep
(`babysit-prs/reference/safety.md:457`,
from #1264) was verdicted ALREADY-FIXED by `72ee66e0` and carries **no
change** in this PR.

Filed line anchors are stale on several of these threads; every fix was
made against the complaint
text, not the anchor.

## Fix

### `exec-bit-check.sh` keys its candidate set on a new index *entry*
(#1590)

`git diff --cached --name-status` reports the same staged file as `A
<path>` with rename/copy
detection off and as `R<score> <old> <new>` / `C<score> <src> <dst>`
with it on. The script read
and discarded both pair forms, so whether a newly added shebang file
staged `100644` got caught was
a function of the consumer's `diff.renames` setting rather than of the
staged content.

A pair destination is now a candidate when its **source was `100755`** —
the mode pairing that
means the bit was *dropped*. The scan reads `git diff --cached --raw`
rather than `--name-status`
for exactly this reason: only the raw record (`:<srcmode> <dstmode>
<srcsha> <dstsha> <status>`)
carries the source mode. The existing `100644`-plus-shebang filter still
does the rest.

### `prune_babysit_worktrees.py` restores the gitfile on every surviving
path (#1331)

Restoration was keyed on `rmdir` raising. Two other paths leave the
directory standing: the rescan
after the unlink can itself raise, and a file appearing between the
unlink and the rmdir skips the
removal *without raising at all*. Either way the directory outlived the
only record of its owning
repository, turning a retryable failure into a permanent `unresolved`.
Restoration is now keyed on
whether the removal actually happened (a `removed` flag, not a second
`exists()` probe — a probe
that transiently failed would skip the restore precisely when the
directory survives), and the
`Path.exists()` probe runs inside the guarded write.

### Two defects this PR's own first pass introduced, caught in review
and fixed here

Both were filed by `chatgpt-codex-connector` on this PR, both reproduced
before fixing, both real.

- **A source-mode-blind candidate set** (`exec-bit-check.sh`). Widening
to every `R*`/`C*`
destination reported a shebang file that is *deliberately*
non-executable — a sourced library, a
template — merely for being renamed, and `--fix` flipped it to `100755`.
Nothing dropped a bit;
the file is already tracked, outside the newly-added-only scope.
Reproduced: a committed `100644`
shebang plus `git mv` gives `:100644 100644 … R100`, and the pre-fix
script listed the
  destination. Hence the source-mode gate above.
- **An unguarded existence probe** (`prune_babysit_worktrees.py`).
`pointer.exists()` sat in the
`finally` *outside* the try guarding the write. `Path.exists()`
re-raises an `OSError` whose
errno is outside the ignored not-found family, so a permission denial on
the very directory the
block exists to rescue escaped the `finally` — replacing the original
exception and leaving the
pointer deleted, the exact loss the block prevents. On `main` this was
contained because the
probe sat inside an `except OSError` handler; moving it to `finally`
uncontained it. The probe is
  now inside the guard.

### The conflict orchestrator runs base → head → push, in that order
(#1355, two threads)

Both threads edit the same push-contract bullet, so they land together.

- `safety.md` requires the head check immediately before every push, but
the base re-fetch — a
network round trip — sat between that check and the push, re-opening the
exact window the check
closes. The contract is now a three-step numbered list with nothing
between step 2 (head) and
  step 3 (push).
- Both orchestrator head checks now spell `GH_REPO=<owner>/<repo>`. The
bare `gh pr view <N>` had
no target: the orchestrator's cwd is whatever the fleet run started
from, never reliably the
  target repository.

Deliberately **not** changed: the bare `gh pr view --json headRefOid` at
`orchestration.md:513`.
That one is in the **Conflict-Worker Contract**, whose cwd *is* the
assigned worktree, and the
worker contract's own rule offers `cd`-into-the-worktree and `GH_REPO`
as alternatives. The finding
scopes itself to "both orchestrator head checks".

### The `VALID (defer)` grounding rule states its no-tracker branch
(#1633)

**Narrower than filed.** The finding claims the missing branch
"permanently blocks `full` mode";
it does not — `pull-request/SKILL.md` §Adapting to your environment and
a `VALID (fix now)`
reclassification both already escape it. The real defect is the
*unstated branch*: the rule
mandated filing a tracker item before the D5 reply and said nothing
about the consumer with no
tracker, even though the same skill documents a tracker as optional.
That branch is now stated.
The CHANGELOG entry was rewritten to the narrower framing rather than
restating the overstated
claim.

**Surface scope, stated explicitly.** The branch is added to the three
surfaces that state the
*filing mandate*: the canonical `reference/review-discipline.md` §3
clause and its
`pull-request/SKILL.md` and `pull-request/reference/monitor.md`
restatements.
`babysit-prs/reference/independent-resolution.md` also carries the
`D4.6-deferral-grounding` tag
and is deliberately left alone — it states what an already-chosen `VALID
(defer)` must *show*
(eligibility), not an instruction to file, so it has no dead end to
branch out of. One rule, both
directions: mandate ⇒ branch, eligibility criterion ⇒ no branch.

`monitor.md` is **not** forced by `check-contract-clause-coverage.py` —
verified by reverting only
that file's hunk and re-running the gate, which still passes. It is
included on the merits above.

## Verification

Every fix was confirmed to stop the complaint reproducing, each with a
pre-fix control proving the
fixture discriminates.

**#1590** — `git version 2.54.0.windows.1`. Same fixture, three
configurations:

| fixture | raw record | `origin/main` `--list` | this branch `--list` |
| --- | --- | --- | --- |
| copy, **control** (`diff.renames` unset) | `A dest.sh` | reported |
reported |
| copy, `diff.renames=copies` | `:100755 100644 … C095 src.sh dest.sh` |
*(nothing)* | `dest.sh` |
| rename, **default** config | `:100755 100644 … R100 src.sh moved.sh` |
*(nothing)* | `moved.sh` |
| rename off a **non-exec** source | `:100644 100644 … R100 lib.sh
lib-moved.sh` | *(nothing)* | *(nothing)* |

The control row is the discriminator: the identical `cp` produces `A`
with copy detection off and
`C095` with it on, and the destination stages `100644` in both. The last
row is the regression
guard — nothing dropped a bit there, so nothing is reported on either
tree.

Running this branch's `exec-bit-check.test.sh` against `origin/main`'s
script fails exactly cases
50 and 52; against the intermediate source-mode-blind version it fails
exactly case 59; against
this branch, **59 cases, 0 failures**. Fixture-assertion cases 57/58
pass on every tree, which is
what makes 59 a real discriminator rather than a broken fixture.

Note for reviewers: the sibling `--fix -- <dest>` cases pass on *both*
trees and are not
discriminating — a pathspec naming only the destination breaks the
rename pairing back to `A`. The
defect is in unscoped detection (`--list` / `--probe` / `--fix --all`),
which is what cases 50/52
cover. Case 56 is the other negative half: an ordinary rename that
*kept* `100755`.

**#1331** — both new tests run against `origin/main`'s module (branch
tests, old code) **FAIL** on
`assertTrue(pointer.is_file())`. The third test (the raising probe)
**ERRORs** with an escaped
`PermissionError` against the intermediate version, while its sibling
passes there — the control
that shows the new fixture targets the new defect. Against this branch
the full suite is
**45 tests, OK**.

**#1355** — prose. Control: `origin/main`'s bullet textually places the
base re-fetch after the
head check and before the push ("Revalidate the base side in the same
breath"). Current: numbered
1-Base / 2-Head / 3-Push with nothing between 2 and 3, and `GH_REPO=` on
both orchestrator head
checks (`:615`, `:681`).

**#1633** — prose. Control: `origin/main` states the filing mandate on
all three surfaces with no
no-tracker branch. The cited escape hatch (`SKILL.md` §Adapting to your
environment, line 36) was
read and does say a work-item tracker is optional and that its absence
must never block a phase.

**Gates run locally from the worktree root, all green:**

- `python scripts/check-contract-clause-coverage.py` — passed (4
canonical, 14 tagged restatements,
  16 pointing surfaces)
- `scripts/check-changelog-parity.sh` `--check` / `--check-order` /
`--check-bump origin/main`
- `scripts/check-contract-slice-prune.sh` `--check` / `--check-diff
origin/main`
- `scripts/check-shell-portability.sh origin/main`,
`scripts/check-skill-portability.sh origin/main`
- `scripts/check-changed-skills.sh origin/main`
- `scripts/validate-plugins.sh`
- `markdownlint-cli2` over all six changed markdown files — 0 errors
- `shellcheck -x` over both changed shell files — clean
- `scripts/run-ruff.sh check plugins/source-control` — all checks passed
- all nine affected `source-control` suites from
`scripts/affected-tests.sh origin/main`, plus the
  two suites the changed scripts own (`exec-bit-check.test.sh` 59/59,
  `test_prune_babysit_worktrees.py` 45/45)

The prune test file's diff is **purely additive** vs `origin/main` — an
editor format-on-write pass
had rewrapped three untouched regions, and that drift was stripped so
every hunk maps to a finding.

`plugins/source-control/skills/babysit-loop/SKILL.md` is untouched and
stays at 499 lines.

Version `0.48.0` → `0.49.3`, renumbered above `main`'s current `0.49.2`
after the merge.

## Related

Refs #1590, #1331, #1355, #1633 — the merged PRs carrying the four
review threads.
Refs #1264 — the fifth thread on this sweep, verdicted ALREADY-FIXED
(`72ee66e0`); no change here.
Refs #1939 — owns the defects in `babysit_resolve_thread.py`,
deliberately untouched by this PR.

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
kyle-sexton added a commit that referenced this pull request Aug 10, 2026
…le source (#2140)

## Summary

`exec-bit-check.sh` admitted a `C*` (copy) destination only when the
**source** was `100755`, so a
copy off a `100644` shebang source was never reported — while the
*identical staged content* under
`diff.renames=false` reports as `A` and **is** reported. Catching a file
became a function of the
consumer's diff configuration, which is the exact failure the candidate
set was widened in #1590 /
#2098 to remove.

`R*` and `C*` shared one predicate and one justification, and the
justification only fits one of
them. The script's own comment said a `100644 -> 100644` pair is "a
deliberately non-executable
tracked file merely being moved… outside this check's newly-added-only
scope" — true for a
**rename** (same tracked file, new path), **false for a copy** (the
destination is a path that did
not previously exist, so it is newly added and squarely *inside* scope).

The arms are now split rather than the shared gate widened:

- **`R*` keeps the `src_mode == "100755"` gate.** `repo19`'s case is
legitimate — a deliberately
non-executable sourced library being renamed must not be flipped to
`100755`.
- **`C*` drops the gate** and defers to the existing
`100644`-plus-shebang filter, exactly as the
  `A` branch does.

Both arms still consume BOTH path fields: a pair arm that reads short
desynchronizes every record
behind it in the NUL stream.

## Reproduction, before and after

One fixture per row-group, run twice with **nothing changing but
`diff.renames`**. The liveness
column is the record the script's *own* parser read and whether that arm
admitted it — instrumented
on a temp copy, so it proves the `C*` path was exercised rather than
merely that the script exited.
git 2.54.0.windows.1.

| fixture | `diff.renames` | raw record | PRE `--list` | POST `--list` |
liveness (POST) |
| --- | --- | --- | --- | --- | --- |
| **non-exec source** | `false` | `:000000 100644 … A copy.sh` |
`[copy.sh]` | `[copy.sh]` | `SAW status=A src_mode=000000` → `ADMIT
status=A` |
| **non-exec source** | `true` *(the default)* | `:000000 100644 … A
copy.sh` | `[copy.sh]` | `[copy.sh]` | `SAW status=A src_mode=000000` →
`ADMIT status=A` |
| **non-exec source** | `copies` | `:100644 100644 … C095 lib.sh
copy.sh` | `[]` ❌ | `[copy.sh]` ✅ | `SAW status=C095 src_mode=100644` →
`ADMIT status=C095` |
| exec source (control) | `false` | `:000000 100644 … A copy.sh` |
`[copy.sh]` | `[copy.sh]` | `SAW status=A src_mode=000000` → `ADMIT
status=A` |
| exec source (control) | `true` *(the default)* | `:000000 100644 … A
copy.sh` | `[copy.sh]` | `[copy.sh]` | `SAW status=A src_mode=000000` →
`ADMIT status=A` |
| exec source (control) | `copies` | `:100755 100644 … C095 lib.sh
copy.sh` | `[copy.sh]` | `[copy.sh]` | `SAW status=C095 src_mode=100755`
→ `ADMIT status=C095` |

Row 3 is the defect: identical staged content, two answers. The
exec-source rows are the control
proving the fixture discriminates. In every POST run the trailing `SAW
status=M` record is still
read intact, so the new arm did not desynchronize the stream.

The `true` rows are the repository default (rename detection on, copy
detection off) and are
measured, not assumed: rename detection cannot pair `copy.sh` against
`lib.sh` because `lib.sh`
still exists — modified, not deleted — so the record stays `A` and is
reported on both trees.

## Tests

`exec-bit-check.test.sh`: **70 cases / 0 failures** on this branch (63
on `main`).

- **Case group 21 (`repo21`) inverted** — a copy off a `100644` shebang
source **is** reported. That
case asserted the defect as intended behaviour; it was added in #2098
answering a reviewer's
request for a copy negative, without noticing the two arms are not
symmetric.
- **`repo19` unchanged** — a rename off a `100644` shebang source is
still not reported. With the
arms split these two stop being twins and each pins its own arm, which
is what the original
  reviewer wanted from a copy negative, correctly aimed.
- **New case group 21b (`repo22`)** — ONE fixture repo run twice with
nothing changing between the
runs but `diff.renames`, asserting both configurations return the same
**non-empty expected**
answer. Asserting only that the two sides match would pass vacuously
when this git declines to
pair the fixture, so both sides are asserted against the expected set
and the case `skip_case`s
unless it observes both an `A` and a `C` pairing. `extra.sh` sorts after
the destination so its
record follows the copy pair — the exact two-path assertion is what
would notice a short read.
- **`repo21`'s `--fix` assertion goes through `--all`**, not `--fix --
tpl-copy.sh`. A pathspec
naming only the destination breaks the pairing back into `A` (which
`repo17` pins), so a scoped
`--fix` reaches the destination through the `A` branch and passes
identically against the gated
arm. That first draft was a non-discriminating control and was replaced.
- **New `repo23`** — spaced paths through the `C` arm under
`core.quotepath=true`. `repo20` covered
spaced paths on the rename arm only; the copy arm reads the same three
fields through its own
`read` calls, and it is now the arm that admits unconditionally, so a
mis-read field becomes a
  *wrong path reported* rather than a path silently dropped.

**Discrimination control.** The new test file run against
`origin/main`'s unmodified
`exec-bit-check.sh` fails exactly the five assertions about the defect:

```
FAIL: [62] a copy whose SOURCE was never executable IS reported — expected tpl-copy.sh got ''
FAIL: [63] --fix corrects a copy destination off a non-executable source — expected 100755 got 100644
FAIL: [66] diff.renames=copies reports the same destination (as a copy) — expected copy.sh\ extra.sh\  got extra.sh\
FAIL: [67] the two diff.renames configurations AGREE on identical staged content — expected copy.sh\ extra.sh\  got extra.sh\
FAIL: [70] a copy destination is reported when BOTH paths contain spaces — expected tpl\ copy.sh got ''
70 case(s), 5 failure(s)
```

One added assertion — "the copy SOURCE is not itself admitted by the
copy arm" — answers the same on
both trees and is labelled in the test as such. It is not a defect
control; it guards the other way
this arm can be got wrong, an arm that admits `_source` alongside
`path`.

## The trade-off, named

Dropping the `C*` gate means **copying a deliberately non-executable
shebang library is now reported
under `diff.renames=copies`.** That is not a new class of finding:

- Copy detection is **opt-in** — the default `diff.renames=true` detects
renames only; `copies` must
be set explicitly. Under that default this same staged content already
reports through the `A`
  branch on `main` today, as the PRE table's `true` row measures.
- Creating such a library from scratch is likewise already reported by
the `A` branch.

One clarification so it does not read as a latent bug later:
`diff.renames=copies` is what enables
copy detection for the script's invocation. `diff.findCopiesHarder` is
**not a git config
variable** — `git help -c` lists only `diff.renames` and
`diff.renameLimit` under that prefix — so
setting it in config is a no-op by design. The harder search exists only
as the command-line
`--find-copies-harder` (`-C -C`) and is therefore outside anything a
consumer's config can turn on.

So the change makes the minority opt-in configuration agree with what
every other configuration
already does, rather than widening what the check reports. Accepting
that trade for a copy is the
same trade the `A` branch already accepts for a newly created file —
which is the consistency
argument for the change. The rename arm, where the destination is *not*
a new path, keeps its gate
and this trade is not extended to it.

## Verification

- `scripts/affected-tests.sh --run` → 1 suite selected, `PASS`.
- `bash scripts/check-changelog-parity.sh --check-bump origin/main` →
passes; `source-control`
  `0.51.2` → `0.51.3` with a matching CHANGELOG entry.
- `shellcheck` and `shfmt -d` clean on both changed shell files;
`markdownlint-cli2` clean on the
  changed markdown.
- Three prose surfaces stated the pre-change rule and would have shipped
contradicting the code:
`skills/commit/reference/exec-bit.md` ("a rename or copy destination is
a candidate **only when
its source was `100755`**"), and the script's own file header plus its
`usage()` text, both of
which still described the candidate set as "every path staged as a NEW
file (`A`)" — an `A`-only
set that stopped being true in #2098 and is further from true now. All
three are corrected here.
The older #2098 CHANGELOG entry also states the superseded rule and is
deliberately **left
alone**: it is the record of a past release, and the new entry
supersedes it.
- A fresh-context adversarial verifier was given the finding and the
diff only — rationale withheld
— and instructed to refute that the finding is discharged and to state
what it did not test. It
returned **NOT REFUTED**. Its report — verdict, per-assertion
discrimination table, and its own
"what I did not test" enumeration — is **posted verbatim as a comment on
this PR**, with an
addendum marking what changed after it and therefore what it never saw.
It confirmed the defect is
gone, found no vacuous passes, and found the false-positive sweep clean
across no-shebang,
already-`100755`, `120000` and `160000` destinations. Every technical
claim it made was
independently re-verified here before being acted on (commands in the
commit messages). It raised
  three gaps, all fixed in this PR:
1. the script header and `usage()` still described an `A`-only candidate
set;
2. `repo22`'s comment asserted the agreement property universally, which
the rename arm
     falsifies on the default config — now scoped, with #2141 cited;
3. the `C` arm had no spaced-path coverage — now `repo23`, which turned
out to discriminate,
     taking the control from 4 failures to 5.

The "Not covered" list below summarises the highlights; the verifier's
own full enumeration is in
  the comment.

## Scoped out, on the record

**The rename arm has the same config-dependence and is deliberately left
alone — see #2141.** The
agreement property this PR pins is asserted of the **copy arm only**,
because the rename arm
measurably does not have it:

```
seed a.sh (100644, shebang), commit; git mv a.sh b.sh
diff.renames=false  -> :100644 000000 D a.sh | :000000 100644 A b.sh  -> --list=[b.sh]
diff.renames=true   -> :100644 100644 R100 a.sh b.sh                  -> --list=[]
```

Identical index and HEAD; only the display setting differs — and `true`
is the default. This is not
being asserted as a bug and the `R*` gate is not presumed wrong:
`repo19` pins a real false positive
it prevents. There are at least three defensible policies (keep the
gate; drop it for consistency
with `A`; or make the `A` branch skip a rename-as-add off a `100644`
source), and #2141 holds that
decision rather than this PR prejudging it. The test comment says the
same, so a future reader does
not widen the case without going through the issue.

### Not covered by this change's tests

- **Newline in a path through the `C` arm** — the fixture will not build
on NTFS. `--list0` and the
`%q` quoting path are pinned elsewhere; the pair-arm field reads for
that case are not.
- **A symlink or gitlink as the copy SOURCE** (destinations of both
kinds are covered and correctly
  filtered).
- **`core.filemode=true` on a non-Windows platform**, so the `chmod +x`
half of `--fix` is
effectively unverified here; only the `update-index` half is exercised
on this box.
- **git versions other than 2.54.0.windows.1.**

### Recommendation, carried forward

**Pin in CI which git version the copy-pairing cases are trusted on.**
This is the one untested-space
item that can silently invalidate the suite rather than merely leave a
corner uncovered: copy
detection depends on similarity scoring, and a git that declines to pair
`repo21`/`repo22`/`repo23`
sends every headline assertion down `skip_case` — at which point the
suite reports **green while
proving nothing**. The cases already guard themselves with `skip_case`
rather than asserting into
the void, which is correct, but nothing today makes the downgrade
*visible*. Out of scope for this
PR; worth its own issue.

Closes #2118

## Related

- #1590 — where the finding was originally filed; review thread
`PRRT_kwDOTCGFQM6T4klg` asked to
"treat the `C*` destination as an added candidate" and is left
unresolved pointing at #2118.
- #2098 — the partial discharge that shipped only the exec-source half
and added the `repo21` case
  this PR inverts.
- #1938 — the stranded-findings sweep the finding came out of.

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
kyle-sexton added a commit that referenced this pull request Aug 12, 2026
…m to the A/C arms (#2167)

## What

`exec-bit-check.sh`'s rename arm reports a newly-staged `100644` shebang
file or not **depending on
the consumer's `diff.renames` setting**, and it splits on git's
**default**:

```
seed a.sh (100644, shebang), commit; git mv a.sh b.sh; git add

diff.renames=false   :100644 000000 D a.sh / :000000 100644 A b.sh   -> reported [b.sh]
diff.renames=true    :100644 100644 R100 a.sh  b.sh                  -> reported []
```

Index and HEAD are identical across those two runs. Only the config
differs.

## The disposition: KEEP the gate. No behaviour change.

`repo19` pins a real false positive the `src_mode == "100755"` gate
prevents — a deliberately
non-executable sourced library or template must not be flipped to
`100755` because someone moved it.
The two alternatives were weighed and rejected:

| Policy | What it buys | What it costs |
| --- | --- | --- |
| **Keep the gate** (shipped) | no false positive on a moved library |
the rename arm's answer depends on `diff.renames` |
| Drop the gate for renames | config-agreement | ships the `repo19`
false positive to every consumer |
| Make the `A` branch skip a rename-as-add | config-agreement by
reporting *less* | risks silencing genuinely new files |

**So this is a documentation change, and #2141 closes as a decision
recorded — not as a code fix.**

## What actually changed

Content-determinism is now stated as a property of the **`A` and `C`
classes only**, never of the
whole tool. Every surface was checked, not just the expected one:

| Surface | Before | Now |
| --- | --- | --- |
| `exec-bit-check.sh` header | no scope note | explicit `WHAT IT DOES
NOT CLAIM` block |
| candidate-set comment (`R --` arm) | explained the gate, not its cost
| `THE ACCEPTED TRADE (#2141)` at the gate |
| `--help` / `usage()` | silent on the residual | one-line scope note |
| `reference/exec-bit.md` | rename bullet ended at the gate's rationale
| adds the accepted-trade paragraph |
| `exec-bit-check.test.sh` at `repo19` | said *why the gate exists* |
says *what it costs and who decided* |
| `plugins/source-control/CHANGELOG.md` | — | new `0.51.5` entry records
the decision |
| `SKILL.md` | **checked — carried no such claim**; unchanged |
unchanged |
| CHANGELOG `0.49.3` / `0.51.4` | historical entries, deliberately not
rewritten | the new entry supersedes them |

`#2140` had already scoped the universal claim to the copy arm in the
test file's group 21b. The
residual was in the script header, the gate comment, `--help`, and
`exec-bit.md`.

## New case group 19b — the decision, executable

One fixture repo, run twice with nothing changing but the `diff.renames`
key, asserting the
**deliberate disagreement**:

```
PASS: HEAD is identical across the two diff.renames runs
PASS: the INDEX is identical across the two diff.renames runs
PASS: the diff.renames=true run really pairs it as a rename off a 100644 source
PASS: diff.renames=false REPORTS the moved 100644 shebang file (as an add)
PASS: diff.renames=true does NOT report it (the kept #2141 gate)
PASS: the two diff.renames configurations DISAGREE on identical staged content — deliberate, kept in #2141
```

HEAD and index trees are asserted **equal to each other** across the
runs. Without that, the case
would prove only that two different repositories differ.

**Disclosed inversion of the issue's stated acceptance.** #2141 asked
for "a case asserting the
`diff.renames=false` and `diff.renames=true` answers **agree**". Under
"keep the gate" they do not
agree, so this case asserts the opposite — deliberate disagreement — and
says so in its own comment.
Naming that here rather than leaving it to be discovered.

## The control, stated honestly

**No behaviour test can fail against unmodified `main`, because the
disposition is no behaviour
change.** That is stated plainly rather than shipped as a green test
implying otherwise.

The available substitute is **mutation**: the case group is run against
the two rejected policies,
implemented as patched copies of the script. Real output:

```
=== BASELINE (shipped script) ===                     6 case(s), 0 failure(s)

=== MUTANT 1: rename gate dropped ===
FAIL: [5] diff.renames=true does NOT report it (the kept #2141 gate)
          — expected zz-dis-extra.sh  got dis-lib-moved.sh zz-dis-extra.sh
FAIL: [6] the two diff.renames configurations DISAGREE ... — expected DISAGREE got AGREE
                                                      6 case(s), 2 failure(s)

=== MUTANT 2: A branch skips a rename-as-add ===
FAIL: [4] diff.renames=false REPORTS the moved 100644 shebang file (as an add)
          — expected dis-lib-moved.sh zz-dis-extra.sh  got zz-dis-extra.sh
FAIL: [6] the two diff.renames configurations DISAGREE ... — expected DISAGREE got AGREE
                                                      6 case(s), 2 failure(s)
```

Each mutant is killed **in the predicted direction, on a different
half**, and mutant 2 still reports
`zz-dis-extra.sh` — so it is a faithful policy-3 mutant, not a blanket
skip.

## Proof the fixture reached the path under test

It did not, at first — and that is worth stating.

Group 19b passed standalone and **skipped silently inside the full
suite**. Cause: `mkrepo`
increments `REPO_SEQ` inside a command substitution, so the increment
never reaches the caller and
**every fixture in this suite is handed the same repository path**. Each
fixture's own unqualified
`git commit` clears the previous one's staged set, which is why it has
gone unnoticed — but file
*names* persist, and my `lib.sh` collided with `repo19`'s, so `git mv`
returned
`fatal: destination exists` inside the `>/dev/null 2>&1` subshell.

Fixed by prefixing this group's paths `dis-`, and the `skip_case` now
**reports what it saw** rather
than only that it gave up — which is what surfaced this. Final
full-suite run: **76 cases, 0
failures**, with all six 19b cases running (`[60]`–`[65]`), not skipped.

**Follow-up worth filing separately (not fixed here, to keep this change
documentation-scope):** the
`REPO_SEQ` bug means the suite's ~24 fixtures share one repository
rather than being isolated. That
is a latent hazard for any future fixture, and possibly weakens existing
ones.

## Merge-order note

This PR and the #2146 PR **both bump `plugins/source-control`** (#2146
touches the shared
`lib/hook-utils.sh`, which `--check-bump` requires every carrying plugin
to bump). Whichever merges
second needs a fresh `origin/main` merge plus a re-bump of that plugin's
version and CHANGELOG.

Closes #2141

## Related

- #2118 / #2140 — the copy-arm fix, where this was found; group 21b
there is the arm that *does* have
  the property, and 19b is its deliberate inverse
- #1590 — the original stranded finding
- #2098 — where the shared `R*`/`C*` gate was introduced
- #2146 — the other PR in this pair; collides with this one on the
`source-control` version bump

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