diff --git a/.agents/skills/git-commit-conventions/SKILL.md b/.agents/skills/git-commit-conventions/SKILL.md index c9700649..7ff9e43b 100644 --- a/.agents/skills/git-commit-conventions/SKILL.md +++ b/.agents/skills/git-commit-conventions/SKILL.md @@ -48,11 +48,68 @@ scope-widened commit, a rewritten shared history, a destructive reset). - **Every commit must be cryptographically signed (SSH or GPG).** Branch protection enforces this on every fleet branch, and an unsigned commit is rejected on push. Signing depends on - environment configuration: `git config commit.gpgsign true`, a configured `user.signingkey`, and - a working signing agent (`ssh-agent` for SSH, `gpg-agent` for GPG). **If signing is not - configured, do not commit.** Surface the missing config to the developer and stop at `git add`. - Verify before the first agent-authored commit, don't assume a prior session left it set: - `git config --get commit.gpgsign && ssh-add -L`, or the GPG equivalent. + environment configuration (`commit.gpgsign`, `user.signingkey`, `gpg.format`), but none of those + values prove signing actually works: `gpg.format=ssh` can sign straight from a key file with no + `ssh-agent` running at all (the common case on Git for Windows), just as GPG can sign + agent-backed or straight from a keyring. **Probing agent liveness (`ssh-add -L`, a `gpg-agent` + check) is not a valid test and must not be used.** It tests one specific delivery path, not + whether a commit actually ends up signed, and a host that signs straight from a key file fails + that probe while signing correctly. +- **Verify with a real scratch commit, read back with git's own verdict, not a text grep.** This + single probe is tech-agnostic (SSH agent-backed, SSH key-file, GPG agent-backed, and GPG keyring + all exercise the same code path) and doubles as the identity check below. Run it once before the + first agent-authored commit of a session. Don't assume a prior session left config correct. The + commit below is plain, deliberately no `-S`: forcing it would still succeed on a host where + `commit.gpgsign` is unset or false, which is the exact default-config gap this probe exists to + catch, since every real commit an agent makes is plain too: + + This file is CRLF (the repo's Markdown default), and a `\` line continuation stops working + the moment a stray `\r` lands after it, so the probe is one physical line, not backslash-joined + ones: + + ```sh + d=$(mktemp -d "${TMPDIR:-/tmp}/sign-check.XXXXXX") && ( trap 'rm -rf "$d"' 0; email=$(git config --global --get user.email) && git init -q "$d" && git -C "$d" commit --allow-empty -q -m check && out=$(git -C "$d" log -1 --format='sig=%G? author=%an <%ae> committer=%cn <%ce>') && echo "$out" && ae=$(git -C "$d" log -1 --format='%ae') && ce=$(git -C "$d" log -1 --format='%ce') && case "$out" in sig=G\ *|sig=U\ *) true ;; *) false ;; esac && case "$email" in *@users.noreply.github.com) true ;; *) false ;; esac && [ "$ae" = "$email" ] && [ "$ce" = "$email" ] ) + ``` + + PowerShell equivalent: + + ```powershell + $d = Join-Path $env:TEMP ([guid]::NewGuid()) + try { + $email = git config --global --get user.email + git init -q "$d" ` + && git -C "$d" commit --allow-empty -q -m check + $out = git -C "$d" log -1 --format='sig=%G? author=%an <%ae> committer=%cn <%ce>' + $out + $ae = git -C "$d" log -1 --format='%ae' + $ce = git -C "$d" log -1 --format='%ce' + if ($out -notmatch '^sig=[GU] ' -or $email -notmatch '@users\.noreply\.github\.com$' ` + -or $ae -ne $email -or $ce -ne $email) { + throw "signing/identity check failed: $out" + } + } finally { + if (Test-Path "$d") { Remove-Item -Recurse -Force "$d" } + } + ``` + + `sig` must read `G` (good signature) or `U` (good signature, unrecognized signer). For GPG, `U` + is a valid signature from a key whose trust level is merely undefined, common right after + generating a new key. For SSH, it's a valid signature from a key not found in the local + `allowed_signers` file, which doesn't affect whether GitHub itself verifies the commit, only + local `git verify-commit` output. `sig` is git's own verdict char. Don't grep localized + "Good" text, since that varies by git version and locale. Anything else, or the commit failing + outright, means **do not commit**: surface the actual error to the developer and stop at + `git add`. Nothing else is contrary evidence: not an unreachable agent, not a config value, not a + signature type you can't otherwise explain in past history (see below). +- **A mix of SSH- and GPG-signed commits in history is structural, not a host to track down.** + `git log --pretty='%G? %GK'` shows two distinct shapes, not two health states: a commit committed + by the PR's own author carries that host's own signature type, while a commit committed by + `GitHub ` is a squash-merge: GitHub creates and signs that commit itself, + server-side, with GitHub's own GPG key, regardless of what the PR author signed with locally. + Every commit on `develop`/`main` past its first squash-merge shows `GitHub` as committer and a + GPG signature. That's expected on every fleet repo, on every host, and is not evidence anything + is misconfigured. Check `commit.committer.name` before treating a differing signature type as a + clue worth chasing. - **Signing must be live before the *first* commit, not retrofitted.** Turning on a require-signed-commits rule against a branch that already carries unsigned commits forces a rewrite of that entire history to re-sign it, changing every commit SHA and making whoever does @@ -65,9 +122,13 @@ scope-widened commit, a rewritten shared history, a destructive reset). **Commit under the committing account's own GitHub `noreply` identity, never a private, personal, or invented address.** `author` and `committer` on every agent-authored commit are the GitHub `noreply` address of the account whose key signs the commit, in `username@users.noreply.github.com` -or `ID+username@users.noreply.github.com` form. **Verify it, do not set it**: check -`git config --get user.email` matches that address before committing, rather than writing a -repo-local override. The identity is host configuration set globally once, so a repo-local +or `ID+username@users.noreply.github.com` form. **Verify it, do not set it**: the scratch commit +from the signing check above already proves this end-to-end. Read its `author=`/`committer=` +output rather than trusting `git config --get user.email` alone, since a global config value +doesn't prove what actually lands on a commit object, and read both rather than the author alone +since a rebase, amend, or cherry-pick can rewrite the committer while leaving the author +untouched. Match both against that address before committing, rather than +writing a repo-local override. The identity is host configuration set globally once, so a repo-local `user.email` is redundant where the global is right and a silently-shadowing wrong identity where it is not. A mismatch is a host fault to surface to the maintainer, not to patch per repo, because a local override hides a broken host that then commits wrong in every other repo on that machine. diff --git a/.claude-plugin/fleet-skills/.source-digest b/.claude-plugin/fleet-skills/.source-digest index e88ea0aa..134eeaa7 100644 --- a/.claude-plugin/fleet-skills/.source-digest +++ b/.claude-plugin/fleet-skills/.source-digest @@ -1 +1 @@ -f01d13d03542af68 +f9e5473792ab198e diff --git a/.claude-plugin/fleet-skills/skills/git-commit-conventions/SKILL.md b/.claude-plugin/fleet-skills/skills/git-commit-conventions/SKILL.md index c9700649..7ff9e43b 100644 --- a/.claude-plugin/fleet-skills/skills/git-commit-conventions/SKILL.md +++ b/.claude-plugin/fleet-skills/skills/git-commit-conventions/SKILL.md @@ -48,11 +48,68 @@ scope-widened commit, a rewritten shared history, a destructive reset). - **Every commit must be cryptographically signed (SSH or GPG).** Branch protection enforces this on every fleet branch, and an unsigned commit is rejected on push. Signing depends on - environment configuration: `git config commit.gpgsign true`, a configured `user.signingkey`, and - a working signing agent (`ssh-agent` for SSH, `gpg-agent` for GPG). **If signing is not - configured, do not commit.** Surface the missing config to the developer and stop at `git add`. - Verify before the first agent-authored commit, don't assume a prior session left it set: - `git config --get commit.gpgsign && ssh-add -L`, or the GPG equivalent. + environment configuration (`commit.gpgsign`, `user.signingkey`, `gpg.format`), but none of those + values prove signing actually works: `gpg.format=ssh` can sign straight from a key file with no + `ssh-agent` running at all (the common case on Git for Windows), just as GPG can sign + agent-backed or straight from a keyring. **Probing agent liveness (`ssh-add -L`, a `gpg-agent` + check) is not a valid test and must not be used.** It tests one specific delivery path, not + whether a commit actually ends up signed, and a host that signs straight from a key file fails + that probe while signing correctly. +- **Verify with a real scratch commit, read back with git's own verdict, not a text grep.** This + single probe is tech-agnostic (SSH agent-backed, SSH key-file, GPG agent-backed, and GPG keyring + all exercise the same code path) and doubles as the identity check below. Run it once before the + first agent-authored commit of a session. Don't assume a prior session left config correct. The + commit below is plain, deliberately no `-S`: forcing it would still succeed on a host where + `commit.gpgsign` is unset or false, which is the exact default-config gap this probe exists to + catch, since every real commit an agent makes is plain too: + + This file is CRLF (the repo's Markdown default), and a `\` line continuation stops working + the moment a stray `\r` lands after it, so the probe is one physical line, not backslash-joined + ones: + + ```sh + d=$(mktemp -d "${TMPDIR:-/tmp}/sign-check.XXXXXX") && ( trap 'rm -rf "$d"' 0; email=$(git config --global --get user.email) && git init -q "$d" && git -C "$d" commit --allow-empty -q -m check && out=$(git -C "$d" log -1 --format='sig=%G? author=%an <%ae> committer=%cn <%ce>') && echo "$out" && ae=$(git -C "$d" log -1 --format='%ae') && ce=$(git -C "$d" log -1 --format='%ce') && case "$out" in sig=G\ *|sig=U\ *) true ;; *) false ;; esac && case "$email" in *@users.noreply.github.com) true ;; *) false ;; esac && [ "$ae" = "$email" ] && [ "$ce" = "$email" ] ) + ``` + + PowerShell equivalent: + + ```powershell + $d = Join-Path $env:TEMP ([guid]::NewGuid()) + try { + $email = git config --global --get user.email + git init -q "$d" ` + && git -C "$d" commit --allow-empty -q -m check + $out = git -C "$d" log -1 --format='sig=%G? author=%an <%ae> committer=%cn <%ce>' + $out + $ae = git -C "$d" log -1 --format='%ae' + $ce = git -C "$d" log -1 --format='%ce' + if ($out -notmatch '^sig=[GU] ' -or $email -notmatch '@users\.noreply\.github\.com$' ` + -or $ae -ne $email -or $ce -ne $email) { + throw "signing/identity check failed: $out" + } + } finally { + if (Test-Path "$d") { Remove-Item -Recurse -Force "$d" } + } + ``` + + `sig` must read `G` (good signature) or `U` (good signature, unrecognized signer). For GPG, `U` + is a valid signature from a key whose trust level is merely undefined, common right after + generating a new key. For SSH, it's a valid signature from a key not found in the local + `allowed_signers` file, which doesn't affect whether GitHub itself verifies the commit, only + local `git verify-commit` output. `sig` is git's own verdict char. Don't grep localized + "Good" text, since that varies by git version and locale. Anything else, or the commit failing + outright, means **do not commit**: surface the actual error to the developer and stop at + `git add`. Nothing else is contrary evidence: not an unreachable agent, not a config value, not a + signature type you can't otherwise explain in past history (see below). +- **A mix of SSH- and GPG-signed commits in history is structural, not a host to track down.** + `git log --pretty='%G? %GK'` shows two distinct shapes, not two health states: a commit committed + by the PR's own author carries that host's own signature type, while a commit committed by + `GitHub ` is a squash-merge: GitHub creates and signs that commit itself, + server-side, with GitHub's own GPG key, regardless of what the PR author signed with locally. + Every commit on `develop`/`main` past its first squash-merge shows `GitHub` as committer and a + GPG signature. That's expected on every fleet repo, on every host, and is not evidence anything + is misconfigured. Check `commit.committer.name` before treating a differing signature type as a + clue worth chasing. - **Signing must be live before the *first* commit, not retrofitted.** Turning on a require-signed-commits rule against a branch that already carries unsigned commits forces a rewrite of that entire history to re-sign it, changing every commit SHA and making whoever does @@ -65,9 +122,13 @@ scope-widened commit, a rewritten shared history, a destructive reset). **Commit under the committing account's own GitHub `noreply` identity, never a private, personal, or invented address.** `author` and `committer` on every agent-authored commit are the GitHub `noreply` address of the account whose key signs the commit, in `username@users.noreply.github.com` -or `ID+username@users.noreply.github.com` form. **Verify it, do not set it**: check -`git config --get user.email` matches that address before committing, rather than writing a -repo-local override. The identity is host configuration set globally once, so a repo-local +or `ID+username@users.noreply.github.com` form. **Verify it, do not set it**: the scratch commit +from the signing check above already proves this end-to-end. Read its `author=`/`committer=` +output rather than trusting `git config --get user.email` alone, since a global config value +doesn't prove what actually lands on a commit object, and read both rather than the author alone +since a rebase, amend, or cherry-pick can rewrite the committer while leaving the author +untouched. Match both against that address before committing, rather than +writing a repo-local override. The identity is host configuration set globally once, so a repo-local `user.email` is redundant where the global is right and a silently-shadowing wrong identity where it is not. A mismatch is a host fault to surface to the maintainer, not to patch per repo, because a local override hides a broken host that then commits wrong in every other repo on that machine. diff --git a/STANDUP.md b/STANDUP.md index 40a238a9..0f0d1b10 100644 --- a/STANDUP.md +++ b/STANDUP.md @@ -31,8 +31,14 @@ git config --global --get commit.gpgsign # true git config --global --get user.signingkey # set git config --global --get gpg.format # ssh for an SSH key; unset or openpgp for GPG -# the agent holding the key, selected by the format above -if [ "$(git config --global --get gpg.format)" = ssh ]; then ssh-add -L; else gpg --list-secret-keys; fi +# prove signing works with a live scratch commit in a disposable scratch repo, not this +# repo (its own git init is still section 0B, below), and not an agent-liveness probe +# (ssh-add -L, gpg --list-secret-keys): a host that signs straight from a key file with no +# agent running passes cleanly and fails that probe. See +# .agents/skills/git-commit-conventions/SKILL.md "Signing, verified not configured" for why. +# One physical line, not backslash-joined: this file is CRLF (the repo's Markdown default), +# and a `\` continuation stops working the moment a stray `\r` lands after it. +d=$(mktemp -d "${TMPDIR:-/tmp}/sign-check.XXXXXX") && ( trap 'rm -rf "$d"' 0; email=$(git config --global --get user.email) && git init -q "$d" && git -C "$d" commit --allow-empty -q -m check && out=$(git -C "$d" log -1 --format='sig=%G? author=%an <%ae> committer=%cn <%ce>') && echo "$out" && ae=$(git -C "$d" log -1 --format='%ae') && ce=$(git -C "$d" log -1 --format='%ce') && case "$out" in sig=G\ *|sig=U\ *) true ;; *) false ;; esac && case "$email" in *@users.noreply.github.com) true ;; *) false ;; esac && [ "$ae" = "$email" ] && [ "$ce" = "$email" ] ) ``` `--global` rather than the effective config, because the effective value depends on where the command runs: inside any existing repository a repo-local override wins, so a bare `git config --get user.email` there reports that repository's identity and hides the host setting this step exists to check. The two scopes together are what make the result sound, since this block proves the host is right and the block below proves nothing shadows it. @@ -53,7 +59,7 @@ python3 scripts/host_gate.py --repo # after section A finding at either point is a **host** misconfiguration to fix on the machine or surface to the maintainer, never something to patch per repo, and [`docs/host-setup.md`][host-setup] is the contract it checks. -The agent check branches rather than listing both forms, because they are alternatives and running the wrong one fails on a correctly configured host: an SSH host need not have `gpg` installed at all. Signing is **SSH or GPG**, so judge the format and its agent together rather than requiring `ssh`: what matters is that the configured format has a matching agent holding the key, which is the check [GOVERNANCE.md "Git and Commit Rules"][governance-git-and-commit-rules] prescribes. Any of these wrong or absent is a **host** misconfiguration to surface to the maintainer ([`docs/host-setup.md`][host-setup] is the setup procedure), not something to patch per repo. Patching it locally hides a broken host that then produces wrong identities in every other repo on that machine. +The scratch commit exercises the whole signing pipeline rather than one delivery path, since `ssh-add -L` or `gpg --list-secret-keys` only prove an agent holds a key and say nothing about a host that signs straight from a key file with no agent running at all, a live and correctly configured case [git-commit-conventions][git-commit-conventions] documents in "Signing, verified not configured", the same rules [GOVERNANCE.md "Git and Commit Rules"][governance-git-and-commit-rules] points to. Signing is **SSH or GPG**, so this judges the configured format by its actual result (`sig=G`, or `sig=U` for a cryptographically good signature from an unrecognized signer, either a GPG key whose trust is merely undefined or an SSH key missing from the local `allowed_signers` file), never by which delivery path produced it. A missing `--global` value, `sig` not reading `G` or `U`, or either printed email not matching the noreply address is a **host** misconfiguration to surface to the maintainer ([`docs/host-setup.md`][host-setup] is the setup procedure), not something to patch per repo. Patching it locally hides a broken host that then produces wrong identities in every other repo on that machine. After `git init` and before the first commit, confirm the repo added no override of its own. This one needs a repository, since `--local` fails outside one. Read it here and run it in section 0B, which places it between the init and the first commit, so nothing here is a prompt to init early: @@ -222,6 +228,7 @@ The same [`AUDIT.md`][audit] run is the on-demand audit for any known repo, and [content-import]: ./docs/content-import.md [files]: ./spec/files.json [fleet-map]: ./docs/fleet-map.md +[git-commit-conventions]: ./.agents/skills/git-commit-conventions/SKILL.md [governance]: ./GOVERNANCE.md [governance-git-and-commit-rules]: ./GOVERNANCE.md#git-and-commit-rules [governance-repository-boundaries-and-write-safety]: ./GOVERNANCE.md#repository-boundaries-and-write-safety diff --git a/docs/host-setup.md b/docs/host-setup.md index e77add58..fd350cd6 100644 --- a/docs/host-setup.md +++ b/docs/host-setup.md @@ -137,8 +137,7 @@ Required for SSH signature verification by `git verify-commit` and similar tools ```shell mkdir -p ~/.config/git -echo "$(git config user.email) namespaces=\"git\" $(cat ~/.ssh/id_ed25519.pub)" \ - >> ~/.config/git/allowed_signers +echo "$(git config --global user.email) namespaces=\"git\" $(cat ~/.ssh/id_ed25519.pub)" >> ~/.config/git/allowed_signers git config --global gpg.ssh.allowedSignersFile ~/.config/git/allowed_signers ``` @@ -238,13 +237,13 @@ The `claude` CLI is deliberately absent from the tool catalog in [`spec/host-too python3 scripts/host_gate.py # presence and version floors, from spec/host-tools.json python3 scripts/skills_install.py --report # the skills install stamp is current git config --global --list | grep -E "user\.|signing|gpg\." -ssh-add -L # should list your public key -git -c gpg.format=ssh commit -S --allow-empty -m "verify-signing" -git log --show-signature -1 +# One physical line, not backslash-joined: this file is CRLF (the repo's Markdown default), +# and a `\` continuation stops working the moment a stray `\r` lands after it. +d=$(mktemp -d "${TMPDIR:-/tmp}/sign-check.XXXXXX") && ( trap 'rm -rf "$d"' 0; email=$(git config --global --get user.email) && git init -q "$d" && git -C "$d" commit --allow-empty -q -m check && out=$(git -C "$d" log -1 --format='sig=%G? author=%an <%ae> committer=%cn <%ce>') && echo "$out" && ae=$(git -C "$d" log -1 --format='%ae') && ce=$(git -C "$d" log -1 --format='%ce') && case "$out" in sig=G\ *|sig=U\ *) true ;; *) false ;; esac && case "$email" in *@users.noreply.github.com) true ;; *) false ;; esac && [ "$ae" = "$email" ] && [ "$ce" = "$email" ] ) gh auth status ``` -If signing fails locally, the devcontainer will fail too, so fix here first. +`sig` must read `G` (good signature) or `U` (good signature, unrecognized signer). For GPG, `U` is a valid signature from a key whose trust level is merely undefined, common right after generating a new key. For SSH, it's a valid signature from a key not found in the local `allowed_signers` file, which doesn't affect whether GitHub itself verifies the commit, only local `git verify-commit` output. Both the `author` and `committer` email must be an actual noreply address, and both must match `user.email` from the config line above, all enforced by the snippet itself. `ssh-add -L` (or a `gpg --list-secret-keys` equivalent) is not a substitute: it only proves an agent holds a key, and a host that signs straight from a key file with no agent running passes this scratch commit while failing that probe, per [GOVERNANCE.md "Git and Commit Rules"][governance-git-and-commit-rules]. If signing fails locally, the devcontainer will fail too, so fix here first. The gate replaced a line that ran `--version` on each tool and read only whether it answered. That form reported a host carrying the broken `gh` as fully set up, which is the failure it exists to stop. It exits non-zero on a missing required tool or one below its floor, and a below-floor finding prints the defect behind the floor rather than the number alone, names where to install from, and prints the command that installs or upgrades the tool on the current platform, so that failure carries its own fix. A missing tool prints the one-line fact, and [`host-setup/`][host-setup-dir] is its remedy. @@ -254,9 +253,22 @@ The gate replaced a line that ran `--version` on each tool and read only whether py -3 scripts/host_gate.py # presence and version floors, from spec/host-tools.json py -3 scripts/skills_install.py --report # the skills install stamp is current git config --global --list | Select-String "user\.|signing|gpg\." -ssh-add -L # should list your public key -git -c gpg.format=ssh commit -S --allow-empty -m "verify-signing" -git log --show-signature -1 +$d = Join-Path $env:TEMP ([guid]::NewGuid()) +try { + $email = git config --global --get user.email + git init -q "$d" ` + && git -C "$d" commit --allow-empty -q -m check + $out = git -C "$d" log -1 --format='sig=%G? author=%an <%ae> committer=%cn <%ce>' + $out + $ae = git -C "$d" log -1 --format='%ae' + $ce = git -C "$d" log -1 --format='%ce' + if ($out -notmatch '^sig=[GU] ' -or $email -notmatch '@users\.noreply\.github\.com$' ` + -or $ae -ne $email -or $ce -ne $email) { + throw "signing/identity check failed: $out" + } +} finally { + if (Test-Path "$d") { Remove-Item -Recurse -Force "$d" } +} gh auth status ```