feat(source-control): harden /commit — exec-bit backstop, trailer SSOT, composition resilience - #1590
Conversation
…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 finished @kyle-sexton's task in 5m 15s —— View job Code Review —
|
|
Claude finished @kyle-sexton's task in 3m 15s —— View job Security Review — PR #1590
Result: No security vulnerabilities foundThis PR adds one new shell script ( exec-bit-check.shOption injection — all git invocations use Path special characters — git diff is consumed NUL-delimited ( Blob SHA extraction —
pathspec-commits.md — hide/restore code sampleThe sample at L96–143 correctly:
exec-bit-check.test.shTemporary repos are created under No security issues found. |
There was a problem hiding this comment.
💡 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".
--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 finished @kyle-sexton's task in 3m 25s —— View job PR Review —
|
|
Claude finished @kyle-sexton's task in 5m 0s —— View job Security Review — PR #1590
|
There was a problem hiding this comment.
💡 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".
…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 finished @kyle-sexton's task in 2m 52s —— View job PR Review —
|
|
Claude finished @kyle-sexton's task in 4m 8s —— View job Security Review — PR #1590
|
There was a problem hiding this comment.
💡 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".
…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 encountered an error after 2s —— View job I'll analyze this and get back to you. |
|
Claude encountered an error after 0s —— View job I'll analyze this and get back to you. |
|
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. |
|
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. |
There was a problem hiding this comment.
💡 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".
| C*) | ||
| IFS= read -r -d '' _src || break | ||
| IFS= read -r -d '' _dst || break | ||
| ;; |
There was a problem hiding this comment.
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 👍 / 👎.
There was a problem hiding this comment.
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.
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>
…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>
…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>
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; theskill 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:
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=false— the default onWindows/NTFS, and the setting in this repo — git ignores worktree permission bits entirely and
stages every file
100644. On such a repochmod +xalone never reaches the index; onlygit update-index --chmod=+xproduces a100755entry. So on the most common platform here, thehalf 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.filemodeset explicitly so it tests the samething everywhere.
Trailer resolution — #1581
The
(<context>)clause is now optional. A census of this repo found compliance not merely lowbut 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 5trailer injected into an Opus 5 session). Copying it verbatim writes afalse 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
/commit, or run the per-commit checklistyourself 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.
reference/spokes (format-check,exec-bit,pathspec-commits,staging-preconditions) with a load-when index, so the re-attach window isspent 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.
git ls-files --error-unmatch, not file existence, and reports an untracked file at that path aspresent but UNTRACKED — not a config layer— preserving the rule 0.25.1 established rather thanreintroducing it as a drafting-surface bug.
Recorded deviation — #1584
disable-model-invocation: falseis now declared explicitly (same effective behavior as theomitted default, but visible). A
/commit-shaped skill is the canonical archetype fortrue, andevery 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
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.
trailer_policy." Already implemented.trailer_policyis a documented key in
reference/config-resolution.md, and/source-control:setupalreadyinterviews for it and writes it. No change made.
PreToolUsehook tier. It is the only tier coveringmemory-following commits that never invoke the skill, but
source-controlships zero hooks todayand a
Bash-matching hook changes the install footprint for every consumer — it needs its ownsecurity review. There is also an unsettled design question:
guardrailsalready ownsPreToolUsegit-commit gating, so which plugin should own this check is genuinely open.axis — 134 of 137 trailers use
Co-authored-by:against 3 using the skill'sCo-Authored-By:.Git preserves the key's case verbatim (
git interpret-trailersdoes not normalize it). Whether tostandardize 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-edand staged — and staged at
100644anyway, becausecore.filemode=false. The new probe reported2 staged shebang file(s) still at mode 100644,--fixcorrected both, and they are committed at100755. This is precisely the failure the finding describes, caught pre-commit instead of by CIpost-push.
Gates run locally, all green:
scripts/check-changed-skills.sh origin/main→ PASS, 0 errors. All 3 base-ref trigger phrasespreserved; description unchanged (418/1536); SKILL.md 304/500 lines; the new script test is
auto-detected and passes.
exec-bit-check.test.sh→ 30 cases, 0 failures (detection scope, symlink skip, staged-deletionskip, already-tracked skip, worktree+index fix ordering,
core.filemode=falsepinned, pathspeclimiting, paths with spaces, all error exits).
shellcheck --rcfile .shellcheckrc→ clean, with the repo'srequire-double-bracketsandadd-default-caseoptional 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-cli2over all 44source-controlmarkdown 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-invocationsemantics fromhttps://code.claude.com/docs/en/skills; the
PreToolUsemechanism cited in #1585 fromhttps://code.claude.com/docs/en/hooks and https://code.claude.com/docs/en/plugins-reference.
Related
PreToolUseexec-bit tier, with its open ownership question.guardrails'block-noncanonical-commit.shfalse-fired on agh issue createwhose body text merely mentioned the guarded command. Noted in source-control:commit: evaluate a PreToolUse exec-bit tier for memory-following commits (deferred from #1579) #1585'sprecision section; it belongs with the existing hook-precision class (guardrails: hooks ship false-positive over-fires with no per-hook fixtures or shared precision convention (6-issue class: diff-scope, quoted-arg, boolean-intent, stdin, path-branch) #547), not this PR.
20260724-165259-source-control-commit-audit.