Skip to content

fix(signing): assert the commit object, not local verification - #163

Merged
CybotTM merged 5 commits into
mainfrom
fix/signing-probe-commit-object
Aug 10, 2026
Merged

fix(signing): assert the commit object, not local verification#163
CybotTM merged 5 commits into
mainfrom
fix/signing-probe-commit-object

Conversation

@tripleawwy

Copy link
Copy Markdown
Contributor

Problem

The signing-readiness preflight in references/pull-request-workflow.md asserted signing worked with:

git log --show-signature -1 | grep -q Good && echo "SIGNING READY"

--show-signature and %G? answer "can this machine verify the signature", not "did git commit -S sign". Under gpg.format=ssh with no gpg.ssh.allowedSignersFile they report failure on a setup that signs correctly, so the preflight reports SIGNING NOT READY and sends the reader to fix a working configuration.

Measured (git 2.54.0)

Same commit throughout, gpg.format=ssh, user.signingkey set:

$ git commit -S --allow-empty -m probe
$ git log --show-signature -1
error: gpg.ssh.allowedSignersFile needs to be configured and exist for SSH signature verification
No signature
$ git log --show-signature -1 >/dev/null 2>&1; echo $?
0
$ git log -1 --format='%G?'
N
$ git cat-file commit HEAD | sed -n '/^$/q;p' | grep -c '^gpgsig'
1

N is indistinguishable from genuinely unsigned, and the command exits 0 while printing the error, so a driver reading $? also sees success. Setting gpg.ssh.allowedSignersFile flips the identical commit:

$ git log --show-signature -1
Good "git" signature for ... with ED25519 key SHA256:...
$ git log -1 --format='%G?'
G

git tag -v needs the same config, and without it exits 1 with the same error, so a failed Good match there means "not verifiable here", not "unsigned".

Two defects in the replaced pattern

grep -q Good is unanchored and --show-signature output includes the author line, so an unsigned commit matches:

$ git -c user.name="Alice Goodwin" -c commit.gpgsign=false commit --allow-empty -m unsigned
$ git log --show-signature -1 | grep -q Good && echo "SIGNING READY"
SIGNING READY
$ git cat-file commit HEAD | sed -n '/^$/q;p' | grep -c '^gpgsig'
0

And the header check must cut at the first blank line: matched over the whole object, ^gpgsig also matches a message body line starting with gpgsig.

The && chaining is kept deliberately. git commit -S aborts rather than producing an unsigned commit, but an unchained check then reads the parent, which in signed history carries its own gpgsig header and reports ready for a probe that never ran.

Change

  • references/pull-request-workflow.md — the preflight assertion, plus why the commit object is the right thing to assert on
  • references/commit-conventions.md — the same probe, the %G?-in-scripts claim, the git tag -v caveat, and a "Verifying a Signed Commit" section
  • checkpoints.yaml — GW-21 judged signedness from --show-signature -5, which fails a fully signed repository
  • two evals

Whether the host accepts the key stays a separate question, answered by the existing unknown_key commits-API check.

@github-actions

Copy link
Copy Markdown
Contributor

Dependency Review

✅ No vulnerabilities or license issues or OpenSSF Scorecard issues found.

Scanned Files

None

@github-actions github-actions Bot added documentation Improvements or additions to documentation skill evals labels Aug 10, 2026
- replace the `grep -q Good` assertion in the signing preflight with a
  `gpgsig` commit-header check: under `gpg.format=ssh` with no
  `gpg.ssh.allowedSignersFile`, `--show-signature` prints `No signature`
  and `%G?` returns `N` on a correctly signed commit, and setting that
  config flips the identical commit to `G`
- note that exit status is no substitute: `git log --show-signature -1`
  exits 0 while printing the error
- anchor the header check to the region before the first blank line, so a
  message body starting with `gpgsig` cannot report unsigned as signed
- keep the commit chained with `&&`, since an unchained check reads the
  parent commit, which in signed history carries its own header
- record the opposite defect of the replaced pattern: unanchored `Good`
  matches the `Author:` line, so an unsigned commit by an author named
  "Goodwin" reports ready
- fix the same probe in commit-conventions.md and drop the claim that
  `%G?` is what to use in scripts
- note `git tag -v` needs the same config, exits 1 without it, so a failed
  match means not verifiable here rather than unsigned
- read the commit object in checkpoint GW-21 instead of judging signedness
  from `--show-signature -5`
- add evals for the SSH readiness probe and the `%G?`-in-scripts question

Signed-off-by: tripleawwy <43851917+tripleawwy@users.noreply.github.com>
@tripleawwy
tripleawwy force-pushed the fix/signing-probe-commit-object branch from f85dfaf to b8a0e8d Compare August 10, 2026 12:29
@tripleawwy
tripleawwy requested a review from CybotTM August 10, 2026 12:29
The preflight probe committed with `-m probe`. Under a commit-msg hook
enforcing Conventional Commits — the convention this skill itself
recommends — that commit aborts, and the probe prints `SIGNING NOT READY
— commit failed` for a signing setup that works. Measured in a repo with
such a hook and a loaded SSH signing key: NOT READY with `-m probe`,
READY with `-m "chore: signing probe"`. The sibling snippet in
commit-conventions.md already used a conventional message for exactly
this reason.

The same block carried a comment telling the reader to probe on a
throwaway branch while committing on the current one and undoing it with
`reset --soft HEAD~1`, which additionally fails on a root commit. It now
performs the `switch -c` / `branch -D` dance the comment describes.

Also:

- Separate the two signing failures in the preflight intro. "A signing
  failure surfaces only at the merge gate" contradicted the paragraph
  below it, which documents `git commit -S` aborting with exit 128. A
  local failure surfaces at commit time; only host verification waits
  for the gate.
- Rename "Verifying a Signed Commit" to "Detecting a Signed Commit" and
  restate GW-21 as signature presence. `grep '^gpgsig'` proves a
  signature is embedded, never that it verifies or that the host accepts
  the key.
- Reword the `%G?` eval into decision form. "Is G a reliable check"
  invited "yes", and G does imply signed — it is the G-or-nothing test
  that is unreliable, because not-G does not imply unsigned. The
  assertion now also accepts precise answers ("does not prove", "false
  negative") that the previous pattern scored as misses.

Signed-off-by: Sebastian Mendel <github@sebastianmendel.de>

Copilot AI 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.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

The signing knowledge in this skill lived entirely in reference prose, one
LLM checkpoint and regex evals over model output. Nothing executed it, so
none of the measured claims could regress visibly. This adds the
mechanical layer and keeps the prose as its explanation.

signing-preflight.sh answers the three separable questions with one
implementation:

- default          prove that `git commit -S` signs, here, now
- --check-commit   does an existing commit carry a signature
- --config-only    report the config without creating a commit

It probes on a throwaway branch through a temporary index, so a staged
change is never swept into the probe commit and silently unstaged when
the branch is deleted. On a rejected commit it retries once with
--no-verify: if that succeeds the first failure was a hook, not signing —
a distinction the exit code alone cannot make, and one that previously
turned a working setup into "SIGNING NOT READY".

Checkpoint GW-17 applies the same header rule mechanically to an assessed
repository; GW-21 keeps the judgement part. verify-git-workflow.sh grows
a Commit Signing section — it had none at all.

tests/test_signing_preflight.sh turns every measured claim into a
regression test: %G? = N on a correctly signed SSH commit without
gpg.ssh.allowedSignersFile (the premise of the whole design — if git ever
changes it, the test says so), the Goodwin false positive, a `gpgsig`
line in the message body, gpgsig-sha256 in SHA-256 repositories, hook
interference, an unloadable key, and that the probe leaves branch, HEAD,
index and worktree untouched. Both defects were re-injected and the
suite fails in the right position for each.

Two defects found while wiring this up, both in verify-git-workflow.sh
and both silent:

- `set -e` with `((WARNINGS++))`: post-increment returns the old value,
  so the first increment of a zero counter exits 1 and aborts the script.
  Any repository that tripped an early warning got one section out of
  twelve and an exit code that read like an ordinary failed verification.
- `[[ ! -d ".git" ]]` as the repository test: in a worktree `.git` is a
  file, so the verifier refused to run in the layout this skill's own
  references recommend.

tests/test_verify_git_workflow_sections.sh covers both.

The header pattern is now `^gpgsig(-sha256)? ` everywhere — script,
checkpoint, verifier and both reference docs — and a test asserts script
and checkpoint keep using the same rule.

Signed-off-by: Sebastian Mendel <github@sebastianmendel.de>

Copilot AI 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.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

Findings from a review pass over the branch, after Copilot answered twice
with a quota error and never read the code.

The script's own header promised exit 2 for "hooks rejected it even with
--no-verify" while that path exits 1 — the same doc-contradicts-code
defect this branch set out to remove. Exit 2 now means what the code
does: only --config-only returns it. The NOT READY message says hooks
were ruled out by the retry, since --no-verify skips pre-commit and
commit-msg both.

GW-21's prompt still carried the weak `^gpgsig` form while the script,
GW-17, the verifier and both reference docs had moved to
`^gpgsig(-sha256)? `. All five now agree, and the suite asserts it: each
consumer uses the shared rule, and the weak form appears nowhere under
skills/ — mutation-checked by reintroducing it in a doc.

The "never skip hooks" rule now names its one exception, so the skill
does not forbid what its own script does.

Also covers a rejecting pre-commit hook, not just commit-msg.

Signed-off-by: Sebastian Mendel <github@sebastianmendel.de>

Copilot AI 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.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

The checkpoint runner rejects the pattern GW-17 shipped with. Measured by
sourcing is_safe_eval_command out of automated-assessment's
run-checkpoints.sh and calling it:

  OLD: REJECTED — pattern contains command-chaining metacharacter (; && || ` $())
  NEW: accepted

Two independent reasons, both fatal: the allowlist rejects `$(`, `;`,
`&&`, `||` and backticks outright, and a multi-line YAML scalar reaches
the runner as an EMPTY pattern ("checkpoint likely uses multi-line YAML
scalar; use single-line pattern"). A mechanical checkpoint that cannot
execute is the same defect this branch exists to remove, one layer up.

GW-17 is now single-line, HEAD-only, and free of chaining
metacharacters. `sed -n -e '/^$/q' -e p` is the semicolon-free spelling
of the header cut, so the message-body false positive stays closed —
verified against a commit whose body starts with `gpgsig`. The sweep over
several commits stays where a full shell is available:
verify-git-workflow.sh (last 10) and signing-preflight.sh. Mirroring
rather than reproducing is the arrangement skill-repo already documents
for SR-37.

The suite now asserts both allowlist constraints on the pattern, so the
next edit cannot reintroduce either.

Signed-off-by: Sebastian Mendel <github@sebastianmendel.de>
@sonarqubecloud

Copy link
Copy Markdown

Copilot AI 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.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@CybotTM
CybotTM merged commit 53144e7 into main Aug 10, 2026
23 of 24 checks passed
@CybotTM
CybotTM deleted the fix/signing-probe-commit-object branch August 10, 2026 14:57
CybotTM added a commit that referenced this pull request Aug 10, 2026
The new validate-checkpoints.sh reports GW-15 and GW-16 as rejected by
the assessment runner. Neither has ever executed, and nothing said so —
a rejected checkpoint is skipped silently and the report stays quiet.

GW-16 is expressible without command substitution: `! git ls-files -- …
| grep -q .` reaches the same verdict, and the runner strips a leading
`!` before checking the base command.

GW-15 is not. Counting commits since the last tag needs `<tag>..HEAD` and
`$( )`, and the allowlist rejects `..` as path traversal and `$(` as
chaining. The checkpoint is removed and the rule moves to
verify-git-workflow.sh, where a full shell is available.

GW-21 declares `# mechanical-counterpart: GW-17`; GW-30 declares
`none (<reason>)` — its commands fetch a staging lockfile for a judgement
and decide nothing on their own.

A third abort in verify-git-workflow.sh, same family as the two fixed in
#163: `git rev-parse origin/<branch>` echoes the ref NAME on stdout when
it does not resolve, so the non-empty guard passed, the rev-list failed
on the literal string, and `set -e` killed the script three sections
early — on every unpushed branch, which is every branch before its first
push.

Tests for the three shipped gates that had none: merge-gate.sh (denies
BLOCKED, denies UNSTABLE, denies an unresolved thread even when CLEAN,
passes unrelated commands), conflict-marker-gate.py (denies staged
markers, fails open outside a repo), spec-cleanup-guard.sh (reports
artifacts and — its stated invariant — modifies nothing).

test_checkpoint_patterns.sh asserts every `type: command` pattern is
runnable: single line, no chaining metacharacters, no `..`, base command
on the allowlist. The rule is mirrored rather than imported, because
automated-assessment is not a dependency of this repo and a test that
needs an absent checkout does not run.

tests.yml is now a thin caller of the skill-repo-skill reusable.

Signed-off-by: Sebastian Mendel <github@sebastianmendel.de>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation evals skill

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants