From 633bf1a587676895cecee22d77185d4f0776cfd2 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Fri, 14 Aug 2026 12:22:56 -0700 Subject: [PATCH 01/18] Make the Signed-Commit Verification Tech-Agnostic Replace the ssh-add -L / gpg-agent liveness probe in git-commit-conventions with a real scratch commit read back via git's own %G? verdict char. That probe drove a Windows agent (#706) to wrongly conclude signing was broken: gpg.format=ssh can sign straight from a key file with no ssh-agent running at all, which is this host's actual setup, and ssh-add -L says nothing about that path. The same scratch commit's %ae output now backs the identity check too, so one probe verifies both instead of trusting git config values that don't prove what lands on the commit object. Also documents why develop/main always show GitHub-committer, GPG-signed commits at every squash-merge point regardless of contributor host config (GitHub signs the merge commit itself, server-side) -- a second red herring from the same issue that had nothing to do with any host being misconfigured. --- .../skills/git-commit-conventions/SKILL.md | 46 +++++++++++++++---- .claude-plugin/fleet-skills/.source-digest | 2 +- .../skills/git-commit-conventions/SKILL.md | 46 +++++++++++++++---- 3 files changed, 77 insertions(+), 17 deletions(-) diff --git a/.agents/skills/git-commit-conventions/SKILL.md b/.agents/skills/git-commit-conventions/SKILL.md index c9700649..3d8cdefb 100644 --- a/.agents/skills/git-commit-conventions/SKILL.md +++ b/.agents/skills/git-commit-conventions/SKILL.md @@ -48,11 +48,39 @@ 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 `git commit -S` succeeds, 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: + ```sh + d=$(mktemp -d) && git init -q "$d" \ + && git -C "$d" commit -S --allow-empty -q -m check \ + && git -C "$d" log -1 --format='sig=%G? email=%ae' + rm -rf "$d" + ``` + (PowerShell: `$d = Join-Path $env:TEMP ([guid]::NewGuid()); git init -q $d; git -C $d commit -S + --allow-empty -q -m check; git -C $d log -1 --format='sig=%G? email=%ae'; Remove-Item -Recurse + -Force $d`.) `sig` must read `G`, git's own good-signature verdict char — don't grep localized + "Good" text, which 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 +93,11 @@ 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 `email=` 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. Match it 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 d14fbfc1..dcf9468e 100644 --- a/.claude-plugin/fleet-skills/.source-digest +++ b/.claude-plugin/fleet-skills/.source-digest @@ -1 +1 @@ -f1e6675e6e015c18 +20014edbab62441b 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..3d8cdefb 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,39 @@ 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 `git commit -S` succeeds, 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: + ```sh + d=$(mktemp -d) && git init -q "$d" \ + && git -C "$d" commit -S --allow-empty -q -m check \ + && git -C "$d" log -1 --format='sig=%G? email=%ae' + rm -rf "$d" + ``` + (PowerShell: `$d = Join-Path $env:TEMP ([guid]::NewGuid()); git init -q $d; git -C $d commit -S + --allow-empty -q -m check; git -C $d log -1 --format='sig=%G? email=%ae'; Remove-Item -Recurse + -Force $d`.) `sig` must read `G`, git's own good-signature verdict char — don't grep localized + "Good" text, which 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 +93,11 @@ 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 `email=` 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. Match it 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. From 8eb9050ae9fb7c9b5fa359913d5756511b4fe233 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Fri, 14 Aug 2026 13:33:38 -0700 Subject: [PATCH 02/18] Fix Review Findings: ASCII Punctuation and STANDUP.md Consistency - Restructure six em-dash sentences into ASCII punctuation (period, colon, parens) per the charset rule, never a spaced hyphen. - Fix two prose semicolons the same way, and move the PowerShell probe out of an inline backtick span into its own fenced block so it reads as code, not prose (prose_lint only exempts fenced blocks). - STANDUP.md carried the identical agent-liveness signing check (`ssh-add -L` / `gpg --list-secret-keys`) this PR just retired from git-commit-conventions, plus a paragraph defending it as what GOVERNANCE.md prescribes. Both now match: the same scratch-commit probe, and prose explaining why, not the old agent-liveness framing. --- .../skills/git-commit-conventions/SKILL.md | 28 +++++++++++-------- .claude-plugin/fleet-skills/.source-digest | 2 +- .../skills/git-commit-conventions/SKILL.md | 28 +++++++++++-------- STANDUP.md | 11 ++++++-- 4 files changed, 43 insertions(+), 26 deletions(-) diff --git a/.agents/skills/git-commit-conventions/SKILL.md b/.agents/skills/git-commit-conventions/SKILL.md index 3d8cdefb..96d55b73 100644 --- a/.agents/skills/git-commit-conventions/SKILL.md +++ b/.agents/skills/git-commit-conventions/SKILL.md @@ -52,33 +52,39 @@ scope-widened commit, a rewritten shared history, a destructive reset). 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 + check) is not a valid test and must not be used.** It tests one specific delivery path, not whether `git commit -S` succeeds, 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: + 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: ```sh d=$(mktemp -d) && git init -q "$d" \ && git -C "$d" commit -S --allow-empty -q -m check \ && git -C "$d" log -1 --format='sig=%G? email=%ae' rm -rf "$d" ``` - (PowerShell: `$d = Join-Path $env:TEMP ([guid]::NewGuid()); git init -q $d; git -C $d commit -S - --allow-empty -q -m check; git -C $d log -1 --format='sig=%G? email=%ae'; Remove-Item -Recurse - -Force $d`.) `sig` must read `G`, git's own good-signature verdict char — don't grep localized - "Good" text, which varies by git version and locale. Anything else, or the commit failing + PowerShell equivalent: + ```powershell + $d = Join-Path $env:TEMP ([guid]::NewGuid()) + git init -q $d + git -C $d commit -S --allow-empty -q -m check + git -C $d log -1 --format='sig=%G? email=%ae' + Remove-Item -Recurse -Force $d + ``` + `sig` must read `G`, git's own good-signature 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, + `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 + 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 @@ -94,7 +100,7 @@ scope-widened commit, a rewritten shared history, a destructive reset). 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**: the scratch commit -from the signing check above already proves this end-to-end — read its `email=` output rather +from the signing check above already proves this end-to-end. Read its `email=` 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. Match it against that address before committing, rather than writing a repo-local override. The identity is host configuration set globally once, so a repo-local diff --git a/.claude-plugin/fleet-skills/.source-digest b/.claude-plugin/fleet-skills/.source-digest index dcf9468e..f6dec463 100644 --- a/.claude-plugin/fleet-skills/.source-digest +++ b/.claude-plugin/fleet-skills/.source-digest @@ -1 +1 @@ -20014edbab62441b +8a526e2dd3e30493 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 3d8cdefb..96d55b73 100644 --- a/.claude-plugin/fleet-skills/skills/git-commit-conventions/SKILL.md +++ b/.claude-plugin/fleet-skills/skills/git-commit-conventions/SKILL.md @@ -52,33 +52,39 @@ scope-widened commit, a rewritten shared history, a destructive reset). 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 + check) is not a valid test and must not be used.** It tests one specific delivery path, not whether `git commit -S` succeeds, 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: + 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: ```sh d=$(mktemp -d) && git init -q "$d" \ && git -C "$d" commit -S --allow-empty -q -m check \ && git -C "$d" log -1 --format='sig=%G? email=%ae' rm -rf "$d" ``` - (PowerShell: `$d = Join-Path $env:TEMP ([guid]::NewGuid()); git init -q $d; git -C $d commit -S - --allow-empty -q -m check; git -C $d log -1 --format='sig=%G? email=%ae'; Remove-Item -Recurse - -Force $d`.) `sig` must read `G`, git's own good-signature verdict char — don't grep localized - "Good" text, which varies by git version and locale. Anything else, or the commit failing + PowerShell equivalent: + ```powershell + $d = Join-Path $env:TEMP ([guid]::NewGuid()) + git init -q $d + git -C $d commit -S --allow-empty -q -m check + git -C $d log -1 --format='sig=%G? email=%ae' + Remove-Item -Recurse -Force $d + ``` + `sig` must read `G`, git's own good-signature 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, + `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 + 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 @@ -94,7 +100,7 @@ scope-widened commit, a rewritten shared history, a destructive reset). 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**: the scratch commit -from the signing check above already proves this end-to-end — read its `email=` output rather +from the signing check above already proves this end-to-end. Read its `email=` 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. Match it against that address before committing, rather than writing a repo-local override. The identity is host configuration set globally once, so a repo-local diff --git a/STANDUP.md b/STANDUP.md index 40a238a9..ca3084bc 100644 --- a/STANDUP.md +++ b/STANDUP.md @@ -31,8 +31,13 @@ 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, 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 git-commit-conventions.md "Signing, verified not +# configured" for why. +d=$(mktemp -d) && git init -q "$d" && git -C "$d" commit -S --allow-empty -q -m check \ + && git -C "$d" log -1 --format='sig=%G? email=%ae' +rm -rf "$d" ``` `--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 +58,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 documented in `git-commit-conventions.md` "Signing, verified not configured", which [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`), never by which delivery path produced it. A missing `--global` value, `sig` not reading `G`, or `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: From 1d705d4e22d8454b105d4b34b5bbc7ac8404d63f Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Fri, 14 Aug 2026 13:43:45 -0700 Subject: [PATCH 03/18] Fix Broken References, Exit-Status Masking, and a Third Stale Probe Copilot's round-2 review on 8eb9050 found: - STANDUP.md pointed at 'git-commit-conventions.md', a filename that doesn't exist in this repo (twice). Fixed to the real path, .agents/skills/git-commit-conventions/SKILL.md, added as a reference link where used in prose. - The scratch-commit probe's final 'rm -rf "$d"' ran after the && chain, so the snippet's exit status always reflected cleanup, masking a real signing failure if an agent scripts around it. Wrapped the probe in a subshell with an EXIT trap in all three copies (git-commit-conventions.md, STANDUP.md, host-setup.md) so the probe's own exit status survives; verified locally (sig=G, exit=0, temp dir removed). - docs/host-setup.md's own 'Verify Host Setup' section still ran ssh-add -L ahead of a hardcoded 'git -c gpg.format=ssh commit -S', the same agent-liveness probe this PR retired elsewhere, and the hardcoded format meant it silently tested the wrong format on a GPG-configured host. Replaced both the POSIX and PowerShell blocks with the same tech-agnostic probe used everywhere else. --- .../skills/git-commit-conventions/SKILL.md | 10 ++++---- .claude-plugin/fleet-skills/.source-digest | 2 +- .../skills/git-commit-conventions/SKILL.md | 10 ++++---- STANDUP.md | 16 ++++++++----- docs/host-setup.md | 24 +++++++++++++------ 5 files changed, 40 insertions(+), 22 deletions(-) diff --git a/.agents/skills/git-commit-conventions/SKILL.md b/.agents/skills/git-commit-conventions/SKILL.md index 96d55b73..0cedcbde 100644 --- a/.agents/skills/git-commit-conventions/SKILL.md +++ b/.agents/skills/git-commit-conventions/SKILL.md @@ -60,10 +60,12 @@ scope-widened commit, a rewritten shared history, a destructive reset). 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: ```sh - d=$(mktemp -d) && git init -q "$d" \ - && git -C "$d" commit -S --allow-empty -q -m check \ - && git -C "$d" log -1 --format='sig=%G? email=%ae' - rm -rf "$d" + d=$(mktemp -d) && ( + trap 'rm -rf "$d"' EXIT + git init -q "$d" \ + && git -C "$d" commit -S --allow-empty -q -m check \ + && git -C "$d" log -1 --format='sig=%G? email=%ae' + ) ``` PowerShell equivalent: ```powershell diff --git a/.claude-plugin/fleet-skills/.source-digest b/.claude-plugin/fleet-skills/.source-digest index f6dec463..01b35ad7 100644 --- a/.claude-plugin/fleet-skills/.source-digest +++ b/.claude-plugin/fleet-skills/.source-digest @@ -1 +1 @@ -8a526e2dd3e30493 +4244ac208e727de8 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 96d55b73..0cedcbde 100644 --- a/.claude-plugin/fleet-skills/skills/git-commit-conventions/SKILL.md +++ b/.claude-plugin/fleet-skills/skills/git-commit-conventions/SKILL.md @@ -60,10 +60,12 @@ scope-widened commit, a rewritten shared history, a destructive reset). 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: ```sh - d=$(mktemp -d) && git init -q "$d" \ - && git -C "$d" commit -S --allow-empty -q -m check \ - && git -C "$d" log -1 --format='sig=%G? email=%ae' - rm -rf "$d" + d=$(mktemp -d) && ( + trap 'rm -rf "$d"' EXIT + git init -q "$d" \ + && git -C "$d" commit -S --allow-empty -q -m check \ + && git -C "$d" log -1 --format='sig=%G? email=%ae' + ) ``` PowerShell equivalent: ```powershell diff --git a/STANDUP.md b/STANDUP.md index ca3084bc..9a7fad60 100644 --- a/STANDUP.md +++ b/STANDUP.md @@ -33,11 +33,14 @@ git config --global --get gpg.format # ssh for an SSH key; unset or openp # prove signing works with a live scratch commit, 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 git-commit-conventions.md "Signing, verified not -# configured" for why. -d=$(mktemp -d) && git init -q "$d" && git -C "$d" commit -S --allow-empty -q -m check \ - && git -C "$d" log -1 --format='sig=%G? email=%ae' -rm -rf "$d" +# passes cleanly and fails that probe. See .agents/skills/git-commit-conventions/SKILL.md +# "Signing, verified not configured" for why. +d=$(mktemp -d) && ( + trap 'rm -rf "$d"' EXIT + git init -q "$d" \ + && git -C "$d" commit -S --allow-empty -q -m check \ + && git -C "$d" log -1 --format='sig=%G? email=%ae' +) ``` `--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. @@ -58,7 +61,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 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 documented in `git-commit-conventions.md` "Signing, verified not configured", which [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`), never by which delivery path produced it. A missing `--global` value, `sig` not reading `G`, or `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. +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`), never by which delivery path produced it. A missing `--global` value, `sig` not reading `G`, or `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: @@ -227,6 +230,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..66e58a3f 100644 --- a/docs/host-setup.md +++ b/docs/host-setup.md @@ -238,13 +238,21 @@ 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 +d=$(mktemp -d) && ( + trap 'rm -rf "$d"' EXIT + git init -q "$d" \ + && git -C "$d" commit -S --allow-empty -q -m check \ + && git -C "$d" log -1 --format='sig=%G? email=%ae' +) gh auth status ``` -If signing fails locally, the devcontainer will fail too, so fix here first. +`sig` must read `G` and `email` must match the noreply address from the config line above. +`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 +262,11 @@ 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()) +git init -q $d +git -C $d commit -S --allow-empty -q -m check +git -C $d log -1 --format='sig=%G? email=%ae' +Remove-Item -Recurse -Force $d gh auth status ``` From d3fbb1c49e7bfb9beee20d6ac06c26a53b35cc29 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Fri, 14 Aug 2026 13:50:20 -0700 Subject: [PATCH 04/18] Check Both Committer and Author Identity in the Scratch Probe Copilot's round-3 review on 1d705d4 found the same class of gap recurring 11 times across all four probe copies: the format string only printed %ae (author email), while both this skill's own Identity section and STANDUP.md's existing verification (which the probe was modeled after) require checking author and committer both, since a rebase, amend, or cherry-pick rewrites the committer while leaving the author untouched and GitHub verifies against the committer. Switched every copy (git-commit-conventions.md, STANDUP.md, host-setup.md POSIX and PowerShell) to STANDUP.md's own established format, 'sig=%G? author=%an <%ae> committer=%cn <%ce>', and updated the surrounding prose to say so. Also folded a hard-wrapped paragraph in host-setup.md back to the file's one-paragraph-per-line style. --- .agents/skills/git-commit-conventions/SKILL.md | 12 +++++++----- .claude-plugin/fleet-skills/.source-digest | 2 +- .../skills/git-commit-conventions/SKILL.md | 12 +++++++----- STANDUP.md | 4 ++-- docs/host-setup.md | 11 +++-------- 5 files changed, 20 insertions(+), 21 deletions(-) diff --git a/.agents/skills/git-commit-conventions/SKILL.md b/.agents/skills/git-commit-conventions/SKILL.md index 0cedcbde..da2c609c 100644 --- a/.agents/skills/git-commit-conventions/SKILL.md +++ b/.agents/skills/git-commit-conventions/SKILL.md @@ -64,7 +64,7 @@ scope-widened commit, a rewritten shared history, a destructive reset). trap 'rm -rf "$d"' EXIT git init -q "$d" \ && git -C "$d" commit -S --allow-empty -q -m check \ - && git -C "$d" log -1 --format='sig=%G? email=%ae' + && git -C "$d" log -1 --format='sig=%G? author=%an <%ae> committer=%cn <%ce>' ) ``` PowerShell equivalent: @@ -72,7 +72,7 @@ scope-widened commit, a rewritten shared history, a destructive reset). $d = Join-Path $env:TEMP ([guid]::NewGuid()) git init -q $d git -C $d commit -S --allow-empty -q -m check - git -C $d log -1 --format='sig=%G? email=%ae' + git -C $d log -1 --format='sig=%G? author=%an <%ae> committer=%cn <%ce>' Remove-Item -Recurse -Force $d ``` `sig` must read `G`, git's own good-signature verdict char. Don't grep localized @@ -102,9 +102,11 @@ scope-widened commit, a rewritten shared history, a destructive reset). 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**: the scratch commit -from the signing check above already proves this end-to-end. Read its `email=` 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. Match it against that address before committing, rather than +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 diff --git a/.claude-plugin/fleet-skills/.source-digest b/.claude-plugin/fleet-skills/.source-digest index 01b35ad7..6af82096 100644 --- a/.claude-plugin/fleet-skills/.source-digest +++ b/.claude-plugin/fleet-skills/.source-digest @@ -1 +1 @@ -4244ac208e727de8 +698156fea438589a 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 0cedcbde..da2c609c 100644 --- a/.claude-plugin/fleet-skills/skills/git-commit-conventions/SKILL.md +++ b/.claude-plugin/fleet-skills/skills/git-commit-conventions/SKILL.md @@ -64,7 +64,7 @@ scope-widened commit, a rewritten shared history, a destructive reset). trap 'rm -rf "$d"' EXIT git init -q "$d" \ && git -C "$d" commit -S --allow-empty -q -m check \ - && git -C "$d" log -1 --format='sig=%G? email=%ae' + && git -C "$d" log -1 --format='sig=%G? author=%an <%ae> committer=%cn <%ce>' ) ``` PowerShell equivalent: @@ -72,7 +72,7 @@ scope-widened commit, a rewritten shared history, a destructive reset). $d = Join-Path $env:TEMP ([guid]::NewGuid()) git init -q $d git -C $d commit -S --allow-empty -q -m check - git -C $d log -1 --format='sig=%G? email=%ae' + git -C $d log -1 --format='sig=%G? author=%an <%ae> committer=%cn <%ce>' Remove-Item -Recurse -Force $d ``` `sig` must read `G`, git's own good-signature verdict char. Don't grep localized @@ -102,9 +102,11 @@ scope-widened commit, a rewritten shared history, a destructive reset). 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**: the scratch commit -from the signing check above already proves this end-to-end. Read its `email=` 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. Match it against that address before committing, rather than +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 diff --git a/STANDUP.md b/STANDUP.md index 9a7fad60..9ca9284b 100644 --- a/STANDUP.md +++ b/STANDUP.md @@ -39,7 +39,7 @@ d=$(mktemp -d) && ( trap 'rm -rf "$d"' EXIT git init -q "$d" \ && git -C "$d" commit -S --allow-empty -q -m check \ - && git -C "$d" log -1 --format='sig=%G? email=%ae' + && git -C "$d" log -1 --format='sig=%G? author=%an <%ae> committer=%cn <%ce>' ) ``` @@ -61,7 +61,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 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`), never by which delivery path produced it. A missing `--global` value, `sig` not reading `G`, or `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. +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`), never by which delivery path produced it. A missing `--global` value, `sig` not reading `G`, 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: diff --git a/docs/host-setup.md b/docs/host-setup.md index 66e58a3f..428898a8 100644 --- a/docs/host-setup.md +++ b/docs/host-setup.md @@ -242,17 +242,12 @@ d=$(mktemp -d) && ( trap 'rm -rf "$d"' EXIT git init -q "$d" \ && git -C "$d" commit -S --allow-empty -q -m check \ - && git -C "$d" log -1 --format='sig=%G? email=%ae' + && git -C "$d" log -1 --format='sig=%G? author=%an <%ae> committer=%cn <%ce>' ) gh auth status ``` -`sig` must read `G` and `email` must match the noreply address from the config line above. -`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. +`sig` must read `G` and both the `author` and `committer` email must match the noreply address from the config line above. `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. @@ -265,7 +260,7 @@ git config --global --list | Select-String "user\.|signing|gpg\." $d = Join-Path $env:TEMP ([guid]::NewGuid()) git init -q $d git -C $d commit -S --allow-empty -q -m check -git -C $d log -1 --format='sig=%G? email=%ae' +git -C $d log -1 --format='sig=%G? author=%an <%ae> committer=%cn <%ce>' Remove-Item -Recurse -Force $d gh auth status ``` From 9b2e9417f1dcf6c61e289c53b2564c45972da7a3 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Fri, 14 Aug 2026 13:55:13 -0700 Subject: [PATCH 05/18] Quote $d and Use try/finally in the PowerShell Probe Copilot's round-4 review on d3fbb1c found the PowerShell scratch-commit snippet (all three copies) left $d unquoted, which breaks git init/-C on a Windows profile path containing a space, and ran cleanup as a plain trailing statement, so a failed git command skipped Remove-Item and left the temp dir behind. Quoted every $d use and wrapped the probe in try/finally so cleanup always runs, success or failure. --- .agents/skills/git-commit-conventions/SKILL.md | 11 +++++++---- .claude-plugin/fleet-skills/.source-digest | 2 +- .../skills/git-commit-conventions/SKILL.md | 11 +++++++---- docs/host-setup.md | 11 +++++++---- 4 files changed, 22 insertions(+), 13 deletions(-) diff --git a/.agents/skills/git-commit-conventions/SKILL.md b/.agents/skills/git-commit-conventions/SKILL.md index da2c609c..32028282 100644 --- a/.agents/skills/git-commit-conventions/SKILL.md +++ b/.agents/skills/git-commit-conventions/SKILL.md @@ -70,10 +70,13 @@ scope-widened commit, a rewritten shared history, a destructive reset). PowerShell equivalent: ```powershell $d = Join-Path $env:TEMP ([guid]::NewGuid()) - git init -q $d - git -C $d commit -S --allow-empty -q -m check - git -C $d log -1 --format='sig=%G? author=%an <%ae> committer=%cn <%ce>' - Remove-Item -Recurse -Force $d + try { + git init -q "$d" + git -C "$d" commit -S --allow-empty -q -m check + git -C "$d" log -1 --format='sig=%G? author=%an <%ae> committer=%cn <%ce>' + } finally { + Remove-Item -Recurse -Force "$d" + } ``` `sig` must read `G`, git's own good-signature verdict char. Don't grep localized "Good" text, since that varies by git version and locale. Anything else, or the commit failing diff --git a/.claude-plugin/fleet-skills/.source-digest b/.claude-plugin/fleet-skills/.source-digest index 6af82096..742e5902 100644 --- a/.claude-plugin/fleet-skills/.source-digest +++ b/.claude-plugin/fleet-skills/.source-digest @@ -1 +1 @@ -698156fea438589a +2fa7b44966ffbab1 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 da2c609c..32028282 100644 --- a/.claude-plugin/fleet-skills/skills/git-commit-conventions/SKILL.md +++ b/.claude-plugin/fleet-skills/skills/git-commit-conventions/SKILL.md @@ -70,10 +70,13 @@ scope-widened commit, a rewritten shared history, a destructive reset). PowerShell equivalent: ```powershell $d = Join-Path $env:TEMP ([guid]::NewGuid()) - git init -q $d - git -C $d commit -S --allow-empty -q -m check - git -C $d log -1 --format='sig=%G? author=%an <%ae> committer=%cn <%ce>' - Remove-Item -Recurse -Force $d + try { + git init -q "$d" + git -C "$d" commit -S --allow-empty -q -m check + git -C "$d" log -1 --format='sig=%G? author=%an <%ae> committer=%cn <%ce>' + } finally { + Remove-Item -Recurse -Force "$d" + } ``` `sig` must read `G`, git's own good-signature verdict char. Don't grep localized "Good" text, since that varies by git version and locale. Anything else, or the commit failing diff --git a/docs/host-setup.md b/docs/host-setup.md index 428898a8..6a0af18d 100644 --- a/docs/host-setup.md +++ b/docs/host-setup.md @@ -258,10 +258,13 @@ py -3 scripts/host_gate.py # presence and version floors, from py -3 scripts/skills_install.py --report # the skills install stamp is current git config --global --list | Select-String "user\.|signing|gpg\." $d = Join-Path $env:TEMP ([guid]::NewGuid()) -git init -q $d -git -C $d commit -S --allow-empty -q -m check -git -C $d log -1 --format='sig=%G? author=%an <%ae> committer=%cn <%ce>' -Remove-Item -Recurse -Force $d +try { + git init -q "$d" + git -C "$d" commit -S --allow-empty -q -m check + git -C "$d" log -1 --format='sig=%G? author=%an <%ae> committer=%cn <%ce>' +} finally { + Remove-Item -Recurse -Force "$d" +} gh auth status ``` From 0553d34edbdeebb41601226555cfba99911a783e Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Fri, 14 Aug 2026 14:01:34 -0700 Subject: [PATCH 06/18] Make the Scratch-Probe mktemp Call Portable to macOS Copilot's round-5 review on 9b2e941 found 'mktemp -d' with no template in all three POSIX probe copies (git-commit-conventions.md, STANDUP.md, host-setup.md): GNU coreutils accepts it bare, but BSD/macOS mktemp requires a template argument and errors without one, failing the signing check on a macOS host even when signing itself is fine. Switched to 'mktemp -d "${TMPDIR:-/tmp}/sign-check.XXXXXX"' in all three, the portable form both implementations accept; verified locally (sig=G, exit=0). --- .../skills/git-commit-conventions/SKILL.md | 2 +- .claude-plugin/fleet-skills/.source-digest | 2 +- .../skills/git-commit-conventions/SKILL.md | 2 +- STANDUP.md | 2 +- docs/host-setup.md | 2 +- host-setup/linux/README.md | 161 ++++++++++++++++++ 6 files changed, 166 insertions(+), 5 deletions(-) create mode 100644 host-setup/linux/README.md diff --git a/.agents/skills/git-commit-conventions/SKILL.md b/.agents/skills/git-commit-conventions/SKILL.md index 32028282..370fc1ee 100644 --- a/.agents/skills/git-commit-conventions/SKILL.md +++ b/.agents/skills/git-commit-conventions/SKILL.md @@ -60,7 +60,7 @@ scope-widened commit, a rewritten shared history, a destructive reset). 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: ```sh - d=$(mktemp -d) && ( + d=$(mktemp -d "${TMPDIR:-/tmp}/sign-check.XXXXXX") && ( trap 'rm -rf "$d"' EXIT git init -q "$d" \ && git -C "$d" commit -S --allow-empty -q -m check \ diff --git a/.claude-plugin/fleet-skills/.source-digest b/.claude-plugin/fleet-skills/.source-digest index 742e5902..60a5cd5e 100644 --- a/.claude-plugin/fleet-skills/.source-digest +++ b/.claude-plugin/fleet-skills/.source-digest @@ -1 +1 @@ -2fa7b44966ffbab1 +6aa42113384e96ff 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 32028282..370fc1ee 100644 --- a/.claude-plugin/fleet-skills/skills/git-commit-conventions/SKILL.md +++ b/.claude-plugin/fleet-skills/skills/git-commit-conventions/SKILL.md @@ -60,7 +60,7 @@ scope-widened commit, a rewritten shared history, a destructive reset). 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: ```sh - d=$(mktemp -d) && ( + d=$(mktemp -d "${TMPDIR:-/tmp}/sign-check.XXXXXX") && ( trap 'rm -rf "$d"' EXIT git init -q "$d" \ && git -C "$d" commit -S --allow-empty -q -m check \ diff --git a/STANDUP.md b/STANDUP.md index 9ca9284b..60632ee6 100644 --- a/STANDUP.md +++ b/STANDUP.md @@ -35,7 +35,7 @@ git config --global --get gpg.format # ssh for an SSH key; unset or openp # 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. -d=$(mktemp -d) && ( +d=$(mktemp -d "${TMPDIR:-/tmp}/sign-check.XXXXXX") && ( trap 'rm -rf "$d"' EXIT git init -q "$d" \ && git -C "$d" commit -S --allow-empty -q -m check \ diff --git a/docs/host-setup.md b/docs/host-setup.md index 6a0af18d..13bd874c 100644 --- a/docs/host-setup.md +++ b/docs/host-setup.md @@ -238,7 +238,7 @@ 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\." -d=$(mktemp -d) && ( +d=$(mktemp -d "${TMPDIR:-/tmp}/sign-check.XXXXXX") && ( trap 'rm -rf "$d"' EXIT git init -q "$d" \ && git -C "$d" commit -S --allow-empty -q -m check \ diff --git a/host-setup/linux/README.md b/host-setup/linux/README.md new file mode 100644 index 00000000..78a0d52d --- /dev/null +++ b/host-setup/linux/README.md @@ -0,0 +1,161 @@ +# Linux Host Setup + +The tooling that makes a Debian or Ubuntu based host satisfy the contract in [`docs/host-setup.md`][host-setup], Proxmox and WSL included. That document is the contract, meaning which tools a host must provide and why each floor exists. This directory is how a Linux host comes to satisfy it. + +## What Is Here + +- [`install-tools.sh`][install-tools] installs and upgrades the host tools, and reports what each is installed at, what upstream carries, where it comes from, and its status. +- [`upgrade-host.sh`][upgrade-host] upgrades the packages of the current release, and moves the host to the next release as a separate action. +- [`setup-github.sh`][setup-github] configures the SSH key, git, and commit signing, and checks both key registrations against what GitHub publishes. +- [`install-skills.sh`][install-skills] drives the hub's skills installer at [`scripts/skills_install.py`][skills-install] from this tree. + +Each runs on its own, and each takes `--help`. + +```shell +host-setup/linux/install-tools.sh # report +host-setup/linux/install-tools.sh --install +host-setup/linux/upgrade-host.sh --status +host-setup/linux/setup-github.sh --status +host-setup/linux/install-skills.sh --report +``` + +Each script is LF with a shebang, and its executable bit is tracked in git, so a fresh checkout runs it without a `bash` prefix. That is the Linux form of "this will run", and [`scripts/test_bootstrap.py`][test-bootstrap] asserts both. + +## Requirements + +**A Debian or Ubuntu based host, identified from `/etc/os-release`.** A distribution that is neither but declares `ID_LIKE` debian is treated as Debian, with a warning that it is untested. Proxmox reports itself as its Debian base, so it needs no case of its own. Anything else is refused, since every install path here is apt or assumes apt's layout. + +**Root or sudo.** Nothing here elevates wholesale. Each script runs as the caller and puts `sudo` in front of only the commands that change the host, so file staging and every read stay unprivileged. A run that is not root and finds no `sudo` refuses up front rather than failing partway. + +**A terminal, or `--yes`.** A run with no terminal on standard input and no `--yes` refuses to change the host, so a scheduled run cannot hang on a prompt nobody answers. On `upgrade-host.sh`, `--yes` also keeps the installed configuration file on a packaging conflict, since an unattended run has nobody to answer dpkg's prompt and a replaced config is the harder half to notice afterwards. + +`curl` is not a requirement, it is a managed prerequisite: a minimal image carries none, so every upstream read is guarded, and an install run puts `ca-certificates`, `curl`, `gnupg`, and `gpgv` in place before the first tool. + +## Why There Are Three Kinds of Source + +The Windows registry has one source because `winget` tracks upstream. Here the distro package trails upstream on `gh`, on `node`, and on `uv`, so a tool comes from whichever source keeps up: + +- **The distro**, for `git` and `python`, where apt's own package is current enough. +- **An upstream apt repository**, for `gh`, `node`, and `docker`, and for `dotnet` as a fallback, where upstream publishes one. +- **A released binary into `/usr/local/bin`**, for `jq`, `uv`, and `git-restore-mtime`, where upstream publishes no repository. + +No version is written into the script. Each upstream is asked what it carries now, so the script does not go stale between releases, and every step is idempotent: a keyring or sources file is written only when its content differs, and a re-run repairs drift rather than assuming a clean host. + +**A keyring is proved, not trusted.** Before a fetched signing key is installed, `gpgv` checks that it actually signs the repository's own `InRelease` metadata. An upstream that rotates or adds a key breaks a pinned fingerprint list but not this check, and a host where the check cannot run stops rather than trusting the download. A released binary is checked against the sha256 list its upstream publishes beside it, for the same reason. + +**Keyrings land in `/etc/apt/keyrings` and sources as deb822 files in `/etc/apt/sources.list.d`.** A predecessor in the old location or the old one-line format is removed first, so apt never reads the same repository twice. + +## PATH and Shadowing + +`/usr/local/bin` precedes `/usr/bin`, which is why the distro's `jq` can stay installed and stay shadowed: the upstream binary wins without removing a package something else may depend on. + +The hazard runs the other way too. A copy of `jq`, `uv`, or `git-restore-mtime` sitting earlier on `PATH` keeps answering after this script installs a newer one, which reads as an upgrade that did not take. The report names such a shadow. `--upgrade` removes it after a prompt, and `--install` removes it only when no managed copy exists yet, since removing a newer shadow beside an older managed copy would downgrade what `PATH` resolves to. A file a distro package owns is never removed, because deleting it would desync dpkg's database from the filesystem, so the remedy named there is the `PATH` order itself. The removal loops, since `PATH` can stack more than one shadow ahead of `/usr/local/bin`, and a relative `PATH` entry is never trusted as a shadow at all. + +## What the Report Says + +A report changes nothing and reads the apt cache as it stands, so an available version is as current as the last `apt update`. Versions compare like with like: apt versions for an apt managed tool, upstream versions for a standalone binary. `docker` is read from the CLI rather than from the `docker-ce` package, because on a WSL distribution Docker Desktop's integration is a working `docker` with no apt package behind it, and its target is stripped of the epoch and packaging revision for the same like-with-like reason. + +`unmanaged` means the tool is installed from the distro while its upstream repository is unconfigured. Reporting it as current against the distro's own version is the one thing the report must not say, since the question is currency against upstream. This is the same word the Windows report uses for a different mechanism, where it means a tool on `PATH` that `winget` knows no package for. + +An install or upgrade collects a tool whose install fails and carries on, so one failure does not strand the rest of the run. A refusal is different and ends the run: an unverifiable keyring, a checksum mismatch, or a declined prompt stops everything, because continuing past one would install something nobody vouched for. + +## Docker, node, and dotnet + +**Inside a WSL distribution, docker comes only from Docker Desktop's own WSL integration, never from installing `docker-ce`.** A native install would run a second engine beside Desktop's, so `--install` and `--upgrade` always skip it there and point at Docker Desktop's Settings instead. The skip counts as success only where `docker` already answers, so a run cannot exit clean having neither installed docker nor found it working. On a native host, the conflicting packages Docker's own uninstall list names are removed first, and non-root use (`usermod -aG docker`) is left to the operator as a group choice rather than a question of presence. + +**Installing `node` displaces distro packages.** The upstream package carries `npm` itself and conflicts with the distro's `npm` and `nodejs-doc`, so the script asks apt what it would remove and puts that list in front of the operator before continuing. Asking apt beats naming the conflicts here, because the conflict set belongs to the upstream package and changes without notice. The major line installed is whatever upstream currently marks LTS, read from its release index at run time. + +**For `dotnet`, the distro feed is the default and Microsoft's feed is the fallback**, added only where the distro carries no SDK at all. Mixing the two feeds is what breaks a host, and Microsoft's feed carries amd64 only, so any other architecture without a distro SDK is a named skip. The default set is the newest SDK line the feed carries, and `--optional` adds every other line, for a host that builds against more than one. + +## Release Upgrades + +`upgrade-host.sh` splits the routine from the rare: `--packages` upgrades within the current release, and `--release` is its own action because the release upgrade is where hosts differ. + +**A release upgrade is refused where this script cannot carry it safely.** Proxmox major upgrades are a documented procedure with their own preconditions, currently the [Proxmox upgrade guide][proxmox-upgrade], and the refusal points there. A distribution that is neither Debian nor Ubuntu is refused too, since the sources rewrite below has no meaning there. Refusing is the point of running this rather than apt by hand. + +**One release at a time.** Both distributions support exactly that, so a host two releases behind is upgraded by running this twice. + +**Debian is carried by rewriting the codename in its apt sources, and only in sources that point at Debian's own mirrors.** A third party repository may have no suite for the new release yet, so it is named and left alone, and what to do about it is the operator's call. The sources are backed up to `/var/backups/upgrade-host` first, and a backup that cannot be taken stops the upgrade, since it is the only way back. A host on a mirror outside `debian.org`, or one tracking `stable` rather than a codename, is refused with instructions to edit its own sources. + +**Ubuntu is carried by `do-release-upgrade`**, which handles its own sources. Whether an LTS or every release is offered is the host's own policy in `/etc/update-manager/release-upgrades`, deliberately not decided here. + +**Preconditions run before the point of no return.** Held packages, a dpkg audit reporting half-configured packages, and low free space on `/var` are each surfaced first, because a release upgrade failing partway is the worst place to find any of them. + +## Restarts, Kernels, and WSL + +A WSL distribution runs the kernel Windows gives it, so a restart there is `wsl --shutdown` from Windows followed by a relaunch, and the script says exactly that instead of suggesting `reboot`. Debian does not always write `/var/run/reboot-required`, so the newest kernel in `/boot` is also compared against `uname -r`, and a host with no `/boot`, which a container and a WSL distribution both are, has no kernel of its own to compare. + +## GitHub Setup + +Two steps cannot be automated, because they happen in a browser: registering the public key as an authentication key, and registering the same key again as a signing key. `setup-github.sh --configure` stops at each, prints the key and where to paste it, then checks afterwards that the registration took, by reading the key lists GitHub publishes for the account, which needs no token. A check that could not reach GitHub is reported apart from a key that is not registered, since sending someone to register a key that is already there is the wrong remedy. + +**`--status` is read-only end to end.** Its SSH probes run in batch mode so a passphrase prompt cannot hang an unattended run, and no probe ever enrolls github.com's host key behind the reader's back. Enrolling is `--configure`'s job, and the first enrollment is the one moment a substituted host key would be accepted for good, so the offered key is checked against the fingerprints GitHub publishes at `api.github.com/meta` before it is recorded, and a check that cannot run is a refusal. + +**The identity comes from the flags, then from what the host already carries, then from the maintainer's default, in that order.** Reading the host first is what keeps a machine configured for somebody else from being quietly rewritten by a run meant to be safe to repeat. + +**The managed key is probed on its own**, with the host's ssh config and agent excluded, because a default identity file or an agent key can authenticate as a different account. A host that reaches GitHub with some other key is working but not managed, and the run says so rather than ending in "Done" with the managed key registered nowhere. + +**Signing is proved end to end**, by signing and verifying a commit in a throwaway repository, since reading the settings back cannot catch a wrong `allowed_signers` entry: that reads as correct and fails only when a signature is actually checked. + +**The key path settings are written in tilde form** (`~/.ssh/id_ed25519.pub`), because git expands the tilde and the hosts configured by hand already hold that form, so writing it leaves an already configured host untouched. + +**`--shared-checkout` exists because `safe.directory` and `core.sharedRepository` are relaxations, not defaults.** They are applied only for a path the caller names, a host one account uses needs neither, and `*` is accepted but called out as turning the ownership check off everywhere. + +## install-skills.sh Is the Exception + +The sibling scripts are independently fetchable, and this one deliberately is not: it drives `scripts/skills_install.py` at the tree root, and the skills content lives in the tree, so a copy fetched alone has nothing to install. Python 3.7 or later is its one dependency, which is why the bootstrap runs it last, and run on a host without one it stops and names the tools step as its prerequisite. + +## Why There Is No Linter Category + +Neither this tooling nor its Windows sibling installs `markdownlint`, `cspell`, `actionlint`, `editorconfig-checker`, `shellcheck`, `PSScriptAnalyzer` or `ruff`, and that is a decision rather than a gap. Each runs as a pinned container image or through `uvx`, which is what keeps a local run and CI the same check, and installing native copies would put a second, unpinned version of each on the host. The only host requirements any of it creates are `docker` and `uv`, and both are already managed here. The [Windows README][windows-readme] states the same decision from its side. + +## bootstrap.sh + +[`bootstrap.sh`][bootstrap] sits beside [`bootstrap.ps1`][bootstrap-ps1] at the top of [`host-setup/`][host-setup-readme] rather than here, since standing up a host with no git and no checkout is one concern across two platforms rather than a fifth member of this directory. It needs only `curl` and `tar`, fetches this repository, and runs the scripts here from that tree. + +## Differences From the Windows Tooling + +The comparison is tabulated once, in the [Windows README][windows-readme], so the two columns cannot drift apart. The short version: this side needs three kinds of source where `winget` needs one, carries the release upgrade Windows Update owns on that side, elevates per command through `sudo` where `winget` raises UAC per installer, and manages `git-restore-mtime`, which the spec declares not applicable on Windows. + +## Verification + +Read-only first, and nothing below changes the host. + +```shell +host-setup/linux/install-tools.sh --help +host-setup/linux/install-tools.sh --list +host-setup/linux/install-tools.sh # report +host-setup/linux/upgrade-host.sh --status +host-setup/linux/setup-github.sh --status +host-setup/linux/install-skills.sh --report +``` + +Then the dry runs, which print what each action would run: + +```shell +host-setup/linux/install-tools.sh --upgrade --dry-run +host-setup/linux/upgrade-host.sh --release --dry-run +host-setup/linux/setup-github.sh --configure --dry-run +``` + +Two of those are guards rather than previews. `--release --dry-run` on a Proxmox host prints the refusal, not the commands, and a docker `--upgrade --dry-run` inside a WSL distribution prints the skip. A `[dry run]` line from either means the guard sits in the wrong place. + +The scripts are checked by `shellcheck`, which runs in CI over every `.sh` file `git ls-files` returns and locally through the same `koalaman/shellcheck:stable` container. [`scripts/test_bootstrap.py`][test-bootstrap] asserts that every tool the spec requires on Linux is one `install-tools.sh` can provide or a recorded exception, and that each script here is tracked executable so a fresh checkout can run it. + + + +[bootstrap]: ../bootstrap.sh +[bootstrap-ps1]: ../bootstrap.ps1 +[host-setup]: ../../docs/host-setup.md +[host-setup-readme]: ../README.md +[install-skills]: ./install-skills.sh +[install-tools]: ./install-tools.sh +[setup-github]: ./setup-github.sh +[skills-install]: ../../scripts/skills_install.py +[test-bootstrap]: ../../scripts/test_bootstrap.py +[upgrade-host]: ./upgrade-host.sh +[windows-readme]: ../windows/README.md + + + +[proxmox-upgrade]: https://pve.proxmox.com/wiki/Upgrade_from_8_to_9 From 21aec10d602e2114f73153eb12cbf8003eb8a591 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Fri, 14 Aug 2026 14:08:34 -0700 Subject: [PATCH 07/18] Test the Default Signing Config, Not a Forced Signature Copilot's round-6 review on 0553d34 found two real gaps: - The probe used 'git commit -S', which forces signing regardless of commit.gpgsign. A host with signing keys configured but gpgsign unset/false would still pass, which is exactly the default-config gap the probe exists to catch, since every real agent commit is plain (no -S). Dropped -S in all POSIX and PowerShell copies; verified locally that a plain commit still signs correctly here (sig=G), and added a sentence explaining why -S is deliberately absent. - The PowerShell 'finally' block's Remove-Item can throw if git init never created $d, masking the real signing/identity error. Guarded it with Test-Path in both PowerShell copies. --- .../skills/git-commit-conventions/SKILL.md | 15 ++-- .claude-plugin/fleet-skills/.source-digest | 2 +- .../skills/git-commit-conventions/SKILL.md | 15 ++-- STANDUP.md | 2 +- cspell.json | 8 +++ docs/host-setup.md | 6 +- host-setup/linux/README.md | 72 +++++++++---------- 7 files changed, 67 insertions(+), 53 deletions(-) diff --git a/.agents/skills/git-commit-conventions/SKILL.md b/.agents/skills/git-commit-conventions/SKILL.md index 370fc1ee..e3195eb6 100644 --- a/.agents/skills/git-commit-conventions/SKILL.md +++ b/.agents/skills/git-commit-conventions/SKILL.md @@ -53,17 +53,20 @@ scope-widened commit, a rewritten shared history, a destructive reset). `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 `git commit -S` succeeds, and a host that signs straight from a key file fails that - probe while signing correctly. + 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: + 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: ```sh d=$(mktemp -d "${TMPDIR:-/tmp}/sign-check.XXXXXX") && ( trap 'rm -rf "$d"' EXIT git init -q "$d" \ - && git -C "$d" commit -S --allow-empty -q -m check \ + && git -C "$d" commit --allow-empty -q -m check \ && git -C "$d" log -1 --format='sig=%G? author=%an <%ae> committer=%cn <%ce>' ) ``` @@ -72,10 +75,10 @@ scope-widened commit, a rewritten shared history, a destructive reset). $d = Join-Path $env:TEMP ([guid]::NewGuid()) try { git init -q "$d" - git -C "$d" commit -S --allow-empty -q -m check + git -C "$d" commit --allow-empty -q -m check git -C "$d" log -1 --format='sig=%G? author=%an <%ae> committer=%cn <%ce>' } finally { - Remove-Item -Recurse -Force "$d" + if (Test-Path "$d") { Remove-Item -Recurse -Force "$d" } } ``` `sig` must read `G`, git's own good-signature verdict char. Don't grep localized diff --git a/.claude-plugin/fleet-skills/.source-digest b/.claude-plugin/fleet-skills/.source-digest index 60a5cd5e..dc73f866 100644 --- a/.claude-plugin/fleet-skills/.source-digest +++ b/.claude-plugin/fleet-skills/.source-digest @@ -1 +1 @@ -6aa42113384e96ff +a02cf4b2755c1cf8 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 370fc1ee..e3195eb6 100644 --- a/.claude-plugin/fleet-skills/skills/git-commit-conventions/SKILL.md +++ b/.claude-plugin/fleet-skills/skills/git-commit-conventions/SKILL.md @@ -53,17 +53,20 @@ scope-widened commit, a rewritten shared history, a destructive reset). `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 `git commit -S` succeeds, and a host that signs straight from a key file fails that - probe while signing correctly. + 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: + 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: ```sh d=$(mktemp -d "${TMPDIR:-/tmp}/sign-check.XXXXXX") && ( trap 'rm -rf "$d"' EXIT git init -q "$d" \ - && git -C "$d" commit -S --allow-empty -q -m check \ + && git -C "$d" commit --allow-empty -q -m check \ && git -C "$d" log -1 --format='sig=%G? author=%an <%ae> committer=%cn <%ce>' ) ``` @@ -72,10 +75,10 @@ scope-widened commit, a rewritten shared history, a destructive reset). $d = Join-Path $env:TEMP ([guid]::NewGuid()) try { git init -q "$d" - git -C "$d" commit -S --allow-empty -q -m check + git -C "$d" commit --allow-empty -q -m check git -C "$d" log -1 --format='sig=%G? author=%an <%ae> committer=%cn <%ce>' } finally { - Remove-Item -Recurse -Force "$d" + if (Test-Path "$d") { Remove-Item -Recurse -Force "$d" } } ``` `sig` must read `G`, git's own good-signature verdict char. Don't grep localized diff --git a/STANDUP.md b/STANDUP.md index 60632ee6..73d6a564 100644 --- a/STANDUP.md +++ b/STANDUP.md @@ -38,7 +38,7 @@ git config --global --get gpg.format # ssh for an SSH key; unset or openp d=$(mktemp -d "${TMPDIR:-/tmp}/sign-check.XXXXXX") && ( trap 'rm -rf "$d"' EXIT git init -q "$d" \ - && git -C "$d" commit -S --allow-empty -q -m check \ + && git -C "$d" commit --allow-empty -q -m check \ && git -C "$d" log -1 --format='sig=%G? author=%an <%ae> committer=%cn <%ce>' ) ``` diff --git a/cspell.json b/cspell.json index 7a105523..80d0f573 100644 --- a/cspell.json +++ b/cspell.json @@ -34,6 +34,7 @@ "datebadge", "davidanson", "debuglevel", + "desync", "devcontainer", "distros", "dnsmasq", @@ -45,6 +46,7 @@ "dorny", "dotnettools", "downstreams", + "dpkg", "dryrun", "Emby", "envsubst", @@ -54,6 +56,7 @@ "finalizers", "Genericize", "gpgsign", + "gpgv", "gruntfuggly", "HACS", "hass", @@ -65,7 +68,10 @@ "isort", "Jellyfin", "Keychain", + "keyring", + "keyrings", "kicad", + "koalaman", "lastbuild", "libsecret", "LINQ", @@ -125,8 +131,10 @@ "Triaging", "tzdata", "unbuilt", + "unconfigured", "untriaged", "unvalidated", + "usermod", "USERPROFILE", "uvx", "venv", diff --git a/docs/host-setup.md b/docs/host-setup.md index 13bd874c..aa169da5 100644 --- a/docs/host-setup.md +++ b/docs/host-setup.md @@ -241,7 +241,7 @@ git config --global --list | grep -E "user\.|signing|gpg\." d=$(mktemp -d "${TMPDIR:-/tmp}/sign-check.XXXXXX") && ( trap 'rm -rf "$d"' EXIT git init -q "$d" \ - && git -C "$d" commit -S --allow-empty -q -m check \ + && git -C "$d" commit --allow-empty -q -m check \ && git -C "$d" log -1 --format='sig=%G? author=%an <%ae> committer=%cn <%ce>' ) gh auth status @@ -260,10 +260,10 @@ git config --global --list | Select-String "user\.|signing|gpg\." $d = Join-Path $env:TEMP ([guid]::NewGuid()) try { git init -q "$d" - git -C "$d" commit -S --allow-empty -q -m check + git -C "$d" commit --allow-empty -q -m check git -C "$d" log -1 --format='sig=%G? author=%an <%ae> committer=%cn <%ce>' } finally { - Remove-Item -Recurse -Force "$d" + if (Test-Path "$d") { Remove-Item -Recurse -Force "$d" } } gh auth status ``` diff --git a/host-setup/linux/README.md b/host-setup/linux/README.md index 78a0d52d..6651353c 100644 --- a/host-setup/linux/README.md +++ b/host-setup/linux/README.md @@ -4,9 +4,9 @@ The tooling that makes a Debian or Ubuntu based host satisfy the contract in [`d ## What Is Here -- [`install-tools.sh`][install-tools] installs and upgrades the host tools, and reports what each is installed at, what upstream carries, where it comes from, and its status. -- [`upgrade-host.sh`][upgrade-host] upgrades the packages of the current release, and moves the host to the next release as a separate action. -- [`setup-github.sh`][setup-github] configures the SSH key, git, and commit signing, and checks both key registrations against what GitHub publishes. +- [`install-tools.sh`][install-tools] installs and upgrades the host tools. It reports what each is installed at, what upstream carries, where it comes from, and its status. +- [`upgrade-host.sh`][upgrade-host] upgrades the packages of the current release. Moving to the next release is a separate action behind its own flag. +- [`setup-github.sh`][setup-github] configures the SSH key, git, and commit signing. It checks both key registrations against what GitHub publishes. - [`install-skills.sh`][install-skills] drives the hub's skills installer at [`scripts/skills_install.py`][skills-install] from this tree. Each runs on its own, and each takes `--help`. @@ -19,17 +19,17 @@ host-setup/linux/setup-github.sh --status host-setup/linux/install-skills.sh --report ``` -Each script is LF with a shebang, and its executable bit is tracked in git, so a fresh checkout runs it without a `bash` prefix. That is the Linux form of "this will run", and [`scripts/test_bootstrap.py`][test-bootstrap] asserts both. +Each script is LF with a shebang, and its executable bit is tracked in git. A fresh checkout therefore runs each without a `bash` prefix. That is the Linux form of "this will run", and [`scripts/test_bootstrap.py`][test-bootstrap] asserts both. ## Requirements -**A Debian or Ubuntu based host, identified from `/etc/os-release`.** A distribution that is neither but declares `ID_LIKE` debian is treated as Debian, with a warning that it is untested. Proxmox reports itself as its Debian base, so it needs no case of its own. Anything else is refused, since every install path here is apt or assumes apt's layout. +**A Debian or Ubuntu based host, identified from `/etc/os-release`.** A distribution that is neither but declares `ID_LIKE` debian is treated as Debian, with a warning that it is untested. Proxmox reports itself as its Debian base, so it needs no case of its own. `install-tools.sh` and `upgrade-host.sh` refuse anything else. `setup-github.sh` runs anywhere, and stops only when a missing prerequisite needs apt to install it. -**Root or sudo.** Nothing here elevates wholesale. Each script runs as the caller and puts `sudo` in front of only the commands that change the host, so file staging and every read stay unprivileged. A run that is not root and finds no `sudo` refuses up front rather than failing partway. +**Root or sudo.** Nothing here elevates wholesale. Each script runs as the caller and puts `sudo` in front of only the commands that change the host. Every read and all file staging stay unprivileged. A run that is not root and finds no `sudo` refuses up front rather than failing partway. -**A terminal, or `--yes`.** A run with no terminal on standard input and no `--yes` refuses to change the host, so a scheduled run cannot hang on a prompt nobody answers. On `upgrade-host.sh`, `--yes` also keeps the installed configuration file on a packaging conflict, since an unattended run has nobody to answer dpkg's prompt and a replaced config is the harder half to notice afterwards. +**A terminal, or `--yes`.** A run with no terminal on standard input and no `--yes` refuses to change the host. A scheduled run therefore cannot hang on a prompt nobody answers. On `upgrade-host.sh`, `--yes` also keeps the installed configuration file on a packaging conflict. An unattended run has nobody to answer dpkg's prompt, and a replaced config is the harder half to notice afterwards. -`curl` is not a requirement, it is a managed prerequisite: a minimal image carries none, so every upstream read is guarded, and an install run puts `ca-certificates`, `curl`, `gnupg`, and `gpgv` in place before the first tool. +`curl` is not a requirement, it is a managed prerequisite. A minimal image carries none, so every upstream read is guarded. An install run puts `ca-certificates`, `curl`, `gnupg`, and `gpgv` in place before the first tool. ## Why There Are Three Kinds of Source @@ -39,83 +39,83 @@ The Windows registry has one source because `winget` tracks upstream. Here the d - **An upstream apt repository**, for `gh`, `node`, and `docker`, and for `dotnet` as a fallback, where upstream publishes one. - **A released binary into `/usr/local/bin`**, for `jq`, `uv`, and `git-restore-mtime`, where upstream publishes no repository. -No version is written into the script. Each upstream is asked what it carries now, so the script does not go stale between releases, and every step is idempotent: a keyring or sources file is written only when its content differs, and a re-run repairs drift rather than assuming a clean host. +No version is written into the script. Each upstream is asked what it carries now, so the script does not go stale between releases. Every step is idempotent, so a keyring or sources file is written only when its content differs. A re-run repairs drift rather than assuming a clean host. -**A keyring is proved, not trusted.** Before a fetched signing key is installed, `gpgv` checks that it actually signs the repository's own `InRelease` metadata. An upstream that rotates or adds a key breaks a pinned fingerprint list but not this check, and a host where the check cannot run stops rather than trusting the download. A released binary is checked against the sha256 list its upstream publishes beside it, for the same reason. +**A keyring is proved, not trusted.** Before a fetched signing key is installed, `gpgv` checks that it signs the repository's own `InRelease` metadata. An upstream that rotates or adds a key breaks a pinned fingerprint list but not this check. A host where the check cannot run stops rather than trusting the download. A released binary is checked against the sha256 list its upstream publishes beside it, for the same reason. -**Keyrings land in `/etc/apt/keyrings` and sources as deb822 files in `/etc/apt/sources.list.d`.** A predecessor in the old location or the old one-line format is removed first, so apt never reads the same repository twice. +**Keyrings land in `/etc/apt/keyrings`, and sources land as deb822 files in `/etc/apt/sources.list.d`.** A predecessor in the old location or the old one-line format is removed first, so apt never reads the same repository twice. ## PATH and Shadowing -`/usr/local/bin` precedes `/usr/bin`, which is why the distro's `jq` can stay installed and stay shadowed: the upstream binary wins without removing a package something else may depend on. +`/usr/local/bin` precedes `/usr/bin`, which is why the distro's `jq` can stay installed and stay shadowed. The upstream binary wins without removing a package something else may depend on. -The hazard runs the other way too. A copy of `jq`, `uv`, or `git-restore-mtime` sitting earlier on `PATH` keeps answering after this script installs a newer one, which reads as an upgrade that did not take. The report names such a shadow. `--upgrade` removes it after a prompt, and `--install` removes it only when no managed copy exists yet, since removing a newer shadow beside an older managed copy would downgrade what `PATH` resolves to. A file a distro package owns is never removed, because deleting it would desync dpkg's database from the filesystem, so the remedy named there is the `PATH` order itself. The removal loops, since `PATH` can stack more than one shadow ahead of `/usr/local/bin`, and a relative `PATH` entry is never trusted as a shadow at all. +The hazard runs the other way too. A copy of `jq`, `uv`, or `git-restore-mtime` sitting earlier on `PATH` keeps answering after this script installs a newer one. That reads as an upgrade that did not take, so the report names such a shadow. `--upgrade` removes it after a prompt. `--install` removes it only when no managed copy exists yet, since removing a newer shadow would downgrade what `PATH` resolves to. A file a distro package owns is never removed, because deleting it would desync dpkg's database from the filesystem. The remedy named there is the `PATH` order itself. The removal loops, since `PATH` can stack more than one shadow ahead of `/usr/local/bin`. A relative `PATH` entry is never trusted as a shadow at all. ## What the Report Says -A report changes nothing and reads the apt cache as it stands, so an available version is as current as the last `apt update`. Versions compare like with like: apt versions for an apt managed tool, upstream versions for a standalone binary. `docker` is read from the CLI rather than from the `docker-ce` package, because on a WSL distribution Docker Desktop's integration is a working `docker` with no apt package behind it, and its target is stripped of the epoch and packaging revision for the same like-with-like reason. +A report changes nothing and reads the apt cache as it stands. An available version is therefore as current as the last `apt update`. Versions compare like with like: apt versions for an apt managed tool, upstream versions for a standalone binary. `docker` is read from the CLI rather than from the `docker-ce` package. On a WSL distribution, Docker Desktop's integration is a working `docker` with no apt package behind it. Its target is stripped of the epoch and packaging revision for the same like-with-like reason. -`unmanaged` means the tool is installed from the distro while its upstream repository is unconfigured. Reporting it as current against the distro's own version is the one thing the report must not say, since the question is currency against upstream. This is the same word the Windows report uses for a different mechanism, where it means a tool on `PATH` that `winget` knows no package for. +`unmanaged` means the tool is installed from the distro while its upstream repository is unconfigured. The one thing the report must not say is that such a tool is current against the distro's own version. The Windows report uses the same word for a different mechanism, a tool on `PATH` that `winget` knows no package for. -An install or upgrade collects a tool whose install fails and carries on, so one failure does not strand the rest of the run. A refusal is different and ends the run: an unverifiable keyring, a checksum mismatch, or a declined prompt stops everything, because continuing past one would install something nobody vouched for. +An install or upgrade collects a tool whose install fails and carries on, so one failure does not strand the rest of the run. A refusal is different and ends the run. An unverifiable keyring, a checksum mismatch, or a declined prompt stops everything, because continuing past one would install something nobody vouched for. ## Docker, node, and dotnet -**Inside a WSL distribution, docker comes only from Docker Desktop's own WSL integration, never from installing `docker-ce`.** A native install would run a second engine beside Desktop's, so `--install` and `--upgrade` always skip it there and point at Docker Desktop's Settings instead. The skip counts as success only where `docker` already answers, so a run cannot exit clean having neither installed docker nor found it working. On a native host, the conflicting packages Docker's own uninstall list names are removed first, and non-root use (`usermod -aG docker`) is left to the operator as a group choice rather than a question of presence. +**Inside a WSL distribution, docker comes only from Docker Desktop's own WSL integration, never from installing `docker-ce`.** A native install would run a second engine beside Desktop's. `--install` and `--upgrade` therefore always skip it there and point at Docker Desktop's settings instead. The skip counts as success only where `docker` already answers, so a run cannot exit clean having found nothing working. On a native host, the conflicting packages Docker's own uninstall list names are removed first. Non-root use (`usermod -aG docker`) is left to the operator, as a group choice rather than a question of presence. -**Installing `node` displaces distro packages.** The upstream package carries `npm` itself and conflicts with the distro's `npm` and `nodejs-doc`, so the script asks apt what it would remove and puts that list in front of the operator before continuing. Asking apt beats naming the conflicts here, because the conflict set belongs to the upstream package and changes without notice. The major line installed is whatever upstream currently marks LTS, read from its release index at run time. +**Installing `node` displaces distro packages.** The upstream package carries `npm` itself and conflicts with the distro's `npm` and `nodejs-doc`. The script asks apt what it would remove and puts that list in front of the operator first. Asking apt beats naming the conflicts here, because the conflict set belongs to the upstream package and changes without notice. The major line installed is whatever upstream currently marks LTS, read from its release index at run time. -**For `dotnet`, the distro feed is the default and Microsoft's feed is the fallback**, added only where the distro carries no SDK at all. Mixing the two feeds is what breaks a host, and Microsoft's feed carries amd64 only, so any other architecture without a distro SDK is a named skip. The default set is the newest SDK line the feed carries, and `--optional` adds every other line, for a host that builds against more than one. +**For `dotnet`, the distro feed is the default and Microsoft's feed is the fallback.** The fallback is added only where the distro carries no SDK at all, because mixing the two feeds is what breaks a host. Microsoft's feed carries amd64 only, so any other architecture without a distro SDK is a named skip. The default set is the newest SDK line the feed carries. `--optional` adds every other line, for a host that builds against more than one. ## Release Upgrades -`upgrade-host.sh` splits the routine from the rare: `--packages` upgrades within the current release, and `--release` is its own action because the release upgrade is where hosts differ. +`upgrade-host.sh` splits the routine from the rare. `--packages` upgrades within the current release, and `--release` is its own action because the release upgrade is where hosts differ. **A release upgrade is refused where this script cannot carry it safely.** Proxmox major upgrades are a documented procedure with their own preconditions, currently the [Proxmox upgrade guide][proxmox-upgrade], and the refusal points there. A distribution that is neither Debian nor Ubuntu is refused too, since the sources rewrite below has no meaning there. Refusing is the point of running this rather than apt by hand. **One release at a time.** Both distributions support exactly that, so a host two releases behind is upgraded by running this twice. -**Debian is carried by rewriting the codename in its apt sources, and only in sources that point at Debian's own mirrors.** A third party repository may have no suite for the new release yet, so it is named and left alone, and what to do about it is the operator's call. The sources are backed up to `/var/backups/upgrade-host` first, and a backup that cannot be taken stops the upgrade, since it is the only way back. A host on a mirror outside `debian.org`, or one tracking `stable` rather than a codename, is refused with instructions to edit its own sources. +**Debian is carried by rewriting the codename in its apt sources, and only in sources that point at Debian's own mirrors.** A third party repository may have no suite for the new release yet. It is therefore named and left alone, and what to do about it is the operator's call. The sources are backed up to `/var/backups/upgrade-host` first. A backup that cannot be taken stops the upgrade, since it is the only way back. A host on a mirror outside `debian.org`, or one tracking `stable` rather than a codename, is refused. The refusal says to move such a host by editing its sources itself. **Ubuntu is carried by `do-release-upgrade`**, which handles its own sources. Whether an LTS or every release is offered is the host's own policy in `/etc/update-manager/release-upgrades`, deliberately not decided here. -**Preconditions run before the point of no return.** Held packages, a dpkg audit reporting half-configured packages, and low free space on `/var` are each surfaced first, because a release upgrade failing partway is the worst place to find any of them. +**Preconditions run before the point of no return.** Held packages, half-configured packages from a dpkg audit, and low free space on `/var` are each surfaced first. A release upgrade failing partway is the worst place to find any of them. ## Restarts, Kernels, and WSL -A WSL distribution runs the kernel Windows gives it, so a restart there is `wsl --shutdown` from Windows followed by a relaunch, and the script says exactly that instead of suggesting `reboot`. Debian does not always write `/var/run/reboot-required`, so the newest kernel in `/boot` is also compared against `uname -r`, and a host with no `/boot`, which a container and a WSL distribution both are, has no kernel of its own to compare. +A WSL distribution runs the kernel Windows gives it. A restart there is `wsl --shutdown` from Windows followed by a relaunch, and the script says exactly that instead of suggesting `reboot`. Debian does not always write `/var/run/reboot-required`, so the newest kernel in `/boot` is also compared against `uname -r`. A host with no `/boot`, which a container and a WSL distribution both are, has no kernel of its own to compare. ## GitHub Setup -Two steps cannot be automated, because they happen in a browser: registering the public key as an authentication key, and registering the same key again as a signing key. `setup-github.sh --configure` stops at each, prints the key and where to paste it, then checks afterwards that the registration took, by reading the key lists GitHub publishes for the account, which needs no token. A check that could not reach GitHub is reported apart from a key that is not registered, since sending someone to register a key that is already there is the wrong remedy. +Two steps cannot be automated, because they happen in a browser. The public key is registered once as an authentication key and again as a signing key. `setup-github.sh --configure` stops at each, prints the key, and says where to paste it. It then checks that the registration took, by reading the key lists GitHub publishes for the account, which needs no token. A check that could not reach GitHub is reported apart from a key that is not registered. Sending someone to register a key that is already there is the wrong remedy. -**`--status` is read-only end to end.** Its SSH probes run in batch mode so a passphrase prompt cannot hang an unattended run, and no probe ever enrolls github.com's host key behind the reader's back. Enrolling is `--configure`'s job, and the first enrollment is the one moment a substituted host key would be accepted for good, so the offered key is checked against the fingerprints GitHub publishes at `api.github.com/meta` before it is recorded, and a check that cannot run is a refusal. +**`--status` is read-only end to end.** Its SSH probes run in batch mode, so a passphrase prompt cannot hang an unattended run. No probe ever enrolls github.com's host key behind the reader's back. Enrolling is `--configure`'s job, and the first enrollment is the one moment a substituted host key would be accepted for good. The offered key is therefore checked against the fingerprints GitHub publishes at `api.github.com/meta` before it is recorded. A check that cannot run is a refusal. -**The identity comes from the flags, then from what the host already carries, then from the maintainer's default, in that order.** Reading the host first is what keeps a machine configured for somebody else from being quietly rewritten by a run meant to be safe to repeat. +**The identity comes from the flags, then from what the host already carries, then from the maintainer's default, in that order.** Reading the host first keeps a machine configured for somebody else from being quietly rewritten by a repeatable run. -**The managed key is probed on its own**, with the host's ssh config and agent excluded, because a default identity file or an agent key can authenticate as a different account. A host that reaches GitHub with some other key is working but not managed, and the run says so rather than ending in "Done" with the managed key registered nowhere. +**The managed key is probed on its own**, with the host's ssh config and agent excluded. A default identity file or an agent key can authenticate as a different account. A host that reaches GitHub with some other key is working but not managed. The run says so rather than ending in "Done" with the managed key registered nowhere. -**Signing is proved end to end**, by signing and verifying a commit in a throwaway repository, since reading the settings back cannot catch a wrong `allowed_signers` entry: that reads as correct and fails only when a signature is actually checked. +**Signing is proved end to end**, by signing and verifying a commit in a throwaway repository. Reading the settings back cannot catch a wrong `allowed_signers` entry: that reads as correct and fails only when a signature is checked. -**The key path settings are written in tilde form** (`~/.ssh/id_ed25519.pub`), because git expands the tilde and the hosts configured by hand already hold that form, so writing it leaves an already configured host untouched. +**The key path settings are written in tilde form** (`~/.ssh/id_ed25519.pub`), because git expands the tilde. The hosts configured by hand already hold that form, so writing it leaves an already configured host untouched. -**`--shared-checkout` exists because `safe.directory` and `core.sharedRepository` are relaxations, not defaults.** They are applied only for a path the caller names, a host one account uses needs neither, and `*` is accepted but called out as turning the ownership check off everywhere. +**`--shared-checkout` exists because `safe.directory` and `core.sharedRepository` are relaxations, not defaults.** They are applied only for a path the caller names, and a host one account uses needs neither. `*` is accepted but called out as turning the ownership check off everywhere. ## install-skills.sh Is the Exception -The sibling scripts are independently fetchable, and this one deliberately is not: it drives `scripts/skills_install.py` at the tree root, and the skills content lives in the tree, so a copy fetched alone has nothing to install. Python 3.7 or later is its one dependency, which is why the bootstrap runs it last, and run on a host without one it stops and names the tools step as its prerequisite. +The sibling scripts are independently fetchable, and this one deliberately is not. It drives `scripts/skills_install.py` at the tree root, and the skills content lives in the tree, so a copy fetched alone has nothing to install. Python 3.7 or later is its one dependency, which is why the bootstrap runs it last. Run on a host without one, it stops and names the tools step as its prerequisite. ## Why There Is No Linter Category -Neither this tooling nor its Windows sibling installs `markdownlint`, `cspell`, `actionlint`, `editorconfig-checker`, `shellcheck`, `PSScriptAnalyzer` or `ruff`, and that is a decision rather than a gap. Each runs as a pinned container image or through `uvx`, which is what keeps a local run and CI the same check, and installing native copies would put a second, unpinned version of each on the host. The only host requirements any of it creates are `docker` and `uv`, and both are already managed here. The [Windows README][windows-readme] states the same decision from its side. +Neither this tooling nor its Windows sibling installs `markdownlint`, `cspell`, `actionlint`, `editorconfig-checker`, `shellcheck`, `PSScriptAnalyzer` or `ruff`. That is a decision rather than a gap. Each runs as a pinned container image or through `uvx`, which is what keeps a local run and CI the same check. Installing native copies would put a second, unpinned version of each on the host. The only host requirements any of it creates are `docker` and `uv`, and both are already managed here. The [Windows README][windows-readme] states the same decision from its side. ## bootstrap.sh -[`bootstrap.sh`][bootstrap] sits beside [`bootstrap.ps1`][bootstrap-ps1] at the top of [`host-setup/`][host-setup-readme] rather than here, since standing up a host with no git and no checkout is one concern across two platforms rather than a fifth member of this directory. It needs only `curl` and `tar`, fetches this repository, and runs the scripts here from that tree. +[`bootstrap.sh`][bootstrap] sits beside [`bootstrap.ps1`][bootstrap-ps1] at the top of [`host-setup/`][host-setup-readme] rather than here. Standing up a host with no git and no checkout is one concern across two platforms, not a fifth member of this directory. It needs only `curl` and `tar`, fetches this repository, and runs the scripts here from that tree. ## Differences From the Windows Tooling -The comparison is tabulated once, in the [Windows README][windows-readme], so the two columns cannot drift apart. The short version: this side needs three kinds of source where `winget` needs one, carries the release upgrade Windows Update owns on that side, elevates per command through `sudo` where `winget` raises UAC per installer, and manages `git-restore-mtime`, which the spec declares not applicable on Windows. +The comparison is tabulated once, in the [Windows README][windows-readme], so the two columns cannot drift apart. The short version: this side needs three kinds of source where `winget` needs one, and carries the release upgrade Windows Update owns over there. It elevates per command through `sudo` where `winget` raises UAC per installer. It also manages `git-restore-mtime`, which the spec declares not applicable on Windows. ## Verification @@ -138,9 +138,9 @@ host-setup/linux/upgrade-host.sh --release --dry-run host-setup/linux/setup-github.sh --configure --dry-run ``` -Two of those are guards rather than previews. `--release --dry-run` on a Proxmox host prints the refusal, not the commands, and a docker `--upgrade --dry-run` inside a WSL distribution prints the skip. A `[dry run]` line from either means the guard sits in the wrong place. +Two of those are guards rather than previews. `--release --dry-run` on a Proxmox host prints the refusal, not the commands. A docker `--upgrade --dry-run` inside a WSL distribution prints the skip. A `[dry run]` line from either means the guard sits in the wrong place. -The scripts are checked by `shellcheck`, which runs in CI over every `.sh` file `git ls-files` returns and locally through the same `koalaman/shellcheck:stable` container. [`scripts/test_bootstrap.py`][test-bootstrap] asserts that every tool the spec requires on Linux is one `install-tools.sh` can provide or a recorded exception, and that each script here is tracked executable so a fresh checkout can run it. +The scripts are checked by `shellcheck`, which runs in CI over every `.sh` file `git ls-files` returns. A local run uses the same `koalaman/shellcheck:stable` container. [`scripts/test_bootstrap.py`][test-bootstrap] asserts that every tool the spec requires on Linux is one `install-tools.sh` can provide, or a recorded exception. It also asserts each script here is tracked executable, so a fresh checkout can run it. From af0714c790b50cc39f09e6ee26a71da6ad2cef68 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Fri, 14 Aug 2026 14:21:59 -0700 Subject: [PATCH 08/18] Remove Foreign Content Swept In by git add -A A peer session working in this same shared checkout (not an isolated worktree) was concurrently authoring host-setup/linux/README.md and adding words to cspell.json for it. My 'git add -A' in 0553d34 and 21aec10 staged and committed that in-progress content alongside my own signing-check work, entirely by accident: neither commit message mentions it, because I never intended to write it and did not notice it was staged. This also means my claim in c3127fe, that the file's dead-path finding was 'pre-existing drift, not caused by this branch', was wrong. The file never existed on develop at all; it was the peer's uncommitted draft, and my 'fix' to its stale test_bootstrap.py reference was a fix to content I should never have carried in the first place. Correction: removed host-setup/linux/README.md entirely (the peer has re-landed it cleanly on their own branch as PR #710) and reverted cspell.json to develop's content plus only the one word my own content actually needs (keyring), dropping the other seven the peer's README required. Diff against origin/develop now touches exactly the four files this PR's signing-check work owns. --- cspell.json | 7 -- host-setup/linux/README.md | 161 ------------------------------------- 2 files changed, 168 deletions(-) delete mode 100644 host-setup/linux/README.md diff --git a/cspell.json b/cspell.json index 80d0f573..e0a34fe7 100644 --- a/cspell.json +++ b/cspell.json @@ -34,7 +34,6 @@ "datebadge", "davidanson", "debuglevel", - "desync", "devcontainer", "distros", "dnsmasq", @@ -46,7 +45,6 @@ "dorny", "dotnettools", "downstreams", - "dpkg", "dryrun", "Emby", "envsubst", @@ -56,7 +54,6 @@ "finalizers", "Genericize", "gpgsign", - "gpgv", "gruntfuggly", "HACS", "hass", @@ -69,9 +66,7 @@ "Jellyfin", "Keychain", "keyring", - "keyrings", "kicad", - "koalaman", "lastbuild", "libsecret", "LINQ", @@ -131,10 +126,8 @@ "Triaging", "tzdata", "unbuilt", - "unconfigured", "untriaged", "unvalidated", - "usermod", "USERPROFILE", "uvx", "venv", diff --git a/host-setup/linux/README.md b/host-setup/linux/README.md deleted file mode 100644 index 4b72d5f2..00000000 --- a/host-setup/linux/README.md +++ /dev/null @@ -1,161 +0,0 @@ -# Linux Host Setup - -The tooling that makes a Debian or Ubuntu based host satisfy the contract in [`docs/host-setup.md`][host-setup], Proxmox and WSL included. That document is the contract, meaning which tools a host must provide and why each floor exists. This directory is how a Linux host comes to satisfy it. - -## What Is Here - -- [`install-tools.sh`][install-tools] installs and upgrades the host tools. It reports what each is installed at, what upstream carries, where it comes from, and its status. -- [`upgrade-host.sh`][upgrade-host] upgrades the packages of the current release. Moving to the next release is a separate action behind its own flag. -- [`setup-github.sh`][setup-github] configures the SSH key, git, and commit signing. It checks both key registrations against what GitHub publishes. -- [`install-skills.sh`][install-skills] drives the hub's skills installer at [`scripts/skills_install.py`][skills-install] from this tree. - -Each runs on its own, and each takes `--help`. - -```shell -host-setup/linux/install-tools.sh # report -host-setup/linux/install-tools.sh --install -host-setup/linux/upgrade-host.sh --status -host-setup/linux/setup-github.sh --status -host-setup/linux/install-skills.sh --report -``` - -Each script is LF with a shebang, and its executable bit is tracked in git. A fresh checkout therefore runs each without a `bash` prefix. That is the Linux form of "this will run", and [`scripts/tests/test_bootstrap.py`][test-bootstrap] asserts both. - -## Requirements - -**A Debian or Ubuntu based host, identified from `/etc/os-release`.** A distribution that is neither but declares `ID_LIKE` debian is treated as Debian, with a warning that it is untested. Proxmox reports itself as its Debian base, so it needs no case of its own. `install-tools.sh` and `upgrade-host.sh` refuse anything else. `setup-github.sh` runs anywhere, and stops only when a missing prerequisite needs apt to install it. - -**Root or sudo.** Nothing here elevates wholesale. Each script runs as the caller and puts `sudo` in front of only the commands that change the host. Every read and all file staging stay unprivileged. A run that is not root and finds no `sudo` refuses up front rather than failing partway. - -**A terminal, or `--yes`.** A run with no terminal on standard input and no `--yes` refuses to change the host. A scheduled run therefore cannot hang on a prompt nobody answers. On `upgrade-host.sh`, `--yes` also keeps the installed configuration file on a packaging conflict. An unattended run has nobody to answer dpkg's prompt, and a replaced config is the harder half to notice afterwards. - -`curl` is not a requirement, it is a managed prerequisite. A minimal image carries none, so every upstream read is guarded. An install run puts `ca-certificates`, `curl`, `gnupg`, and `gpgv` in place before the first tool. - -## Why There Are Three Kinds of Source - -The Windows registry has one source because `winget` tracks upstream. Here the distro package trails upstream on `gh`, on `node`, and on `uv`, so a tool comes from whichever source keeps up: - -- **The distro**, for `git` and `python`, where apt's own package is current enough. -- **An upstream apt repository**, for `gh`, `node`, and `docker`, and for `dotnet` as a fallback, where upstream publishes one. -- **A released binary into `/usr/local/bin`**, for `jq`, `uv`, and `git-restore-mtime`, where upstream publishes no repository. - -No version is written into the script. Each upstream is asked what it carries now, so the script does not go stale between releases. Every step is idempotent, so a keyring or sources file is written only when its content differs. A re-run repairs drift rather than assuming a clean host. - -**A keyring is proved, not trusted.** Before a fetched signing key is installed, `gpgv` checks that it signs the repository's own `InRelease` metadata. An upstream that rotates or adds a key breaks a pinned fingerprint list but not this check. A host where the check cannot run stops rather than trusting the download. A released binary is checked against the sha256 list its upstream publishes beside it, for the same reason. - -**Keyrings land in `/etc/apt/keyrings`, and sources land as deb822 files in `/etc/apt/sources.list.d`.** A predecessor in the old location or the old one-line format is removed first, so apt never reads the same repository twice. - -## PATH and Shadowing - -`/usr/local/bin` precedes `/usr/bin`, which is why the distro's `jq` can stay installed and stay shadowed. The upstream binary wins without removing a package something else may depend on. - -The hazard runs the other way too. A copy of `jq`, `uv`, or `git-restore-mtime` sitting earlier on `PATH` keeps answering after this script installs a newer one. That reads as an upgrade that did not take, so the report names such a shadow. `--upgrade` removes it after a prompt. `--install` removes it only when no managed copy exists yet, since removing a newer shadow would downgrade what `PATH` resolves to. A file a distro package owns is never removed, because deleting it would desync dpkg's database from the filesystem. The remedy named there is the `PATH` order itself. The removal loops, since `PATH` can stack more than one shadow ahead of `/usr/local/bin`. A relative `PATH` entry is never trusted as a shadow at all. - -## What the Report Says - -A report changes nothing and reads the apt cache as it stands. An available version is therefore as current as the last `apt update`. Versions compare like with like: apt versions for an apt managed tool, upstream versions for a standalone binary. `docker` is read from the CLI rather than from the `docker-ce` package. On a WSL distribution, Docker Desktop's integration is a working `docker` with no apt package behind it. Its target is stripped of the epoch and packaging revision for the same like-with-like reason. - -`unmanaged` means the tool is installed from the distro while its upstream repository is unconfigured. The one thing the report must not say is that such a tool is current against the distro's own version. The Windows report uses the same word for a different mechanism, a tool on `PATH` that `winget` knows no package for. - -An install or upgrade collects a tool whose install fails and carries on, so one failure does not strand the rest of the run. A refusal is different and ends the run. An unverifiable keyring, a checksum mismatch, or a declined prompt stops everything, because continuing past one would install something nobody vouched for. - -## Docker, node, and dotnet - -**Inside a WSL distribution, docker comes only from Docker Desktop's own WSL integration, never from installing `docker-ce`.** A native install would run a second engine beside Desktop's. `--install` and `--upgrade` therefore always skip it there and point at Docker Desktop's settings instead. The skip counts as success only where `docker` already answers, so a run cannot exit clean having found nothing working. On a native host, the conflicting packages Docker's own uninstall list names are removed first. Non-root use (`usermod -aG docker`) is left to the operator, as a group choice rather than a question of presence. - -**Installing `node` displaces distro packages.** The upstream package carries `npm` itself and conflicts with the distro's `npm` and `nodejs-doc`. The script asks apt what it would remove and puts that list in front of the operator first. Asking apt beats naming the conflicts here, because the conflict set belongs to the upstream package and changes without notice. The major line installed is whatever upstream currently marks LTS, read from its release index at run time. - -**For `dotnet`, the distro feed is the default and Microsoft's feed is the fallback.** The fallback is added only where the distro carries no SDK at all, because mixing the two feeds is what breaks a host. Microsoft's feed carries amd64 only, so any other architecture without a distro SDK is a named skip. The default set is the newest SDK line the feed carries. `--optional` adds every other line, for a host that builds against more than one. - -## Release Upgrades - -`upgrade-host.sh` splits the routine from the rare. `--packages` upgrades within the current release, and `--release` is its own action because the release upgrade is where hosts differ. - -**A release upgrade is refused where this script cannot carry it safely.** Proxmox major upgrades are a documented procedure with their own preconditions, currently the [Proxmox upgrade guide][proxmox-upgrade], and the refusal points there. A distribution that is neither Debian nor Ubuntu is refused too, since the sources rewrite below has no meaning there. Refusing is the point of running this rather than apt by hand. - -**One release at a time.** Both distributions support exactly that, so a host two releases behind is upgraded by running this twice. - -**Debian is carried by rewriting the codename in its apt sources, and only in sources that point at Debian's own mirrors.** A third party repository may have no suite for the new release yet. It is therefore named and left alone, and what to do about it is the operator's call. The sources are backed up to `/var/backups/upgrade-host` first. A backup that cannot be taken stops the upgrade, since it is the only way back. A host on a mirror outside `debian.org`, or one tracking `stable` rather than a codename, is refused. The refusal says to move such a host by editing its sources itself. - -**Ubuntu is carried by `do-release-upgrade`**, which handles its own sources. Whether an LTS or every release is offered is the host's own policy in `/etc/update-manager/release-upgrades`, deliberately not decided here. - -**Preconditions run before the point of no return.** Held packages, half-configured packages from a dpkg audit, and low free space on `/var` are each surfaced first. A release upgrade failing partway is the worst place to find any of them. - -## Restarts, Kernels, and WSL - -A WSL distribution runs the kernel Windows gives it. A restart there is `wsl --shutdown` from Windows followed by a relaunch, and the script says exactly that instead of suggesting `reboot`. Debian does not always write `/var/run/reboot-required`, so the newest kernel in `/boot` is also compared against `uname -r`. A host with no `/boot`, which a container and a WSL distribution both are, has no kernel of its own to compare. - -## GitHub Setup - -Two steps cannot be automated, because they happen in a browser. The public key is registered once as an authentication key and again as a signing key. `setup-github.sh --configure` stops at each, prints the key, and says where to paste it. It then checks that the registration took, by reading the key lists GitHub publishes for the account, which needs no token. A check that could not reach GitHub is reported apart from a key that is not registered. Sending someone to register a key that is already there is the wrong remedy. - -**`--status` is read-only end to end.** Its SSH probes run in batch mode, so a passphrase prompt cannot hang an unattended run. No probe ever enrolls github.com's host key behind the reader's back. Enrolling is `--configure`'s job, and the first enrollment is the one moment a substituted host key would be accepted for good. The offered key is therefore checked against the fingerprints GitHub publishes at `api.github.com/meta` before it is recorded. A check that cannot run is a refusal. - -**The identity comes from the flags, then from what the host already carries, then from the maintainer's default, in that order.** Reading the host first keeps a machine configured for somebody else from being quietly rewritten by a repeatable run. - -**The managed key is probed on its own**, with the host's ssh config and agent excluded. A default identity file or an agent key can authenticate as a different account. A host that reaches GitHub with some other key is working but not managed. The run says so rather than ending in "Done" with the managed key registered nowhere. - -**Signing is proved end to end**, by signing and verifying a commit in a throwaway repository. Reading the settings back cannot catch a wrong `allowed_signers` entry: that reads as correct and fails only when a signature is checked. - -**The key path settings are written in tilde form** (`~/.ssh/id_ed25519.pub`), because git expands the tilde. The hosts configured by hand already hold that form, so writing it leaves an already configured host untouched. - -**`--shared-checkout` exists because `safe.directory` and `core.sharedRepository` are relaxations, not defaults.** They are applied only for a path the caller names, and a host one account uses needs neither. `*` is accepted but called out as turning the ownership check off everywhere. - -## install-skills.sh Is the Exception - -The sibling scripts are independently fetchable, and this one deliberately is not. It drives `scripts/skills_install.py` at the tree root, and the skills content lives in the tree, so a copy fetched alone has nothing to install. Python 3.7 or later is its one dependency, which is why the bootstrap runs it last. Run on a host without one, it stops and names the tools step as its prerequisite. - -## Why There Is No Linter Category - -Neither this tooling nor its Windows sibling installs `markdownlint`, `cspell`, `actionlint`, `editorconfig-checker`, `shellcheck`, `PSScriptAnalyzer` or `ruff`. That is a decision rather than a gap. Each runs as a pinned container image or through `uvx`, which is what keeps a local run and CI the same check. Installing native copies would put a second, unpinned version of each on the host. The only host requirements any of it creates are `docker` and `uv`, and both are already managed here. The [Windows README][windows-readme] states the same decision from its side. - -## bootstrap.sh - -[`bootstrap.sh`][bootstrap] sits beside [`bootstrap.ps1`][bootstrap-ps1] at the top of [`host-setup/`][host-setup-readme] rather than here. Standing up a host with no git and no checkout is one concern across two platforms, not a fifth member of this directory. It needs only `curl` and `tar`, fetches this repository, and runs the scripts here from that tree. - -## Differences From the Windows Tooling - -The comparison is tabulated once, in the [Windows README][windows-readme], so the two columns cannot drift apart. The short version: this side needs three kinds of source where `winget` needs one, and carries the release upgrade Windows Update owns over there. It elevates per command through `sudo` where `winget` raises UAC per installer. It also manages `git-restore-mtime`, which the spec declares not applicable on Windows. - -## Verification - -Read-only first, and nothing below changes the host. - -```shell -host-setup/linux/install-tools.sh --help -host-setup/linux/install-tools.sh --list -host-setup/linux/install-tools.sh # report -host-setup/linux/upgrade-host.sh --status -host-setup/linux/setup-github.sh --status -host-setup/linux/install-skills.sh --report -``` - -Then the dry runs, which print what each action would run: - -```shell -host-setup/linux/install-tools.sh --upgrade --dry-run -host-setup/linux/upgrade-host.sh --release --dry-run -host-setup/linux/setup-github.sh --configure --dry-run -``` - -Two of those are guards rather than previews. `--release --dry-run` on a Proxmox host prints the refusal, not the commands. A docker `--upgrade --dry-run` inside a WSL distribution prints the skip. A `[dry run]` line from either means the guard sits in the wrong place. - -The scripts are checked by `shellcheck`, which runs in CI over every `.sh` file `git ls-files` returns. A local run uses the same `koalaman/shellcheck:stable` container. [`scripts/tests/test_bootstrap.py`][test-bootstrap] asserts that every tool the spec requires on Linux is one `install-tools.sh` can provide, or a recorded exception. It also asserts each script here is tracked executable, so a fresh checkout can run it. - - - -[bootstrap]: ../bootstrap.sh -[bootstrap-ps1]: ../bootstrap.ps1 -[host-setup]: ../../docs/host-setup.md -[host-setup-readme]: ../README.md -[install-skills]: ./install-skills.sh -[install-tools]: ./install-tools.sh -[setup-github]: ./setup-github.sh -[skills-install]: ../../scripts/skills_install.py -[test-bootstrap]: ../../scripts/tests/test_bootstrap.py -[upgrade-host]: ./upgrade-host.sh -[windows-readme]: ../windows/README.md - - - -[proxmox-upgrade]: https://pve.proxmox.com/wiki/Upgrade_from_8_to_9 From d50b305ab62db5a512f0a32db2ef962df2918afb Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Fri, 14 Aug 2026 14:30:45 -0700 Subject: [PATCH 09/18] Fix a Real CI Failure and Three More Review Findings markdownlint's 'Lint sources job' failed on af0714c (MD031, fenced code blocks need blank lines around them): the SKILL.md probe's two fences sit back-to-back inside a list item with no separating blank line. This gate isn't in prose_lint.py or the local test suite, only the docker markdownlint-cli2 invocation from OPERATIONS.md, which I should have run earlier alongside the others and didn't; added the blank lines and confirmed 0 issues locally before this push. Also fixed three findings from rounds 8-9: - 'trap ... EXIT' is a bashism; POSIX sh defines only signal 0 for shell-exit cleanup. Switched all three POSIX probe copies to 'trap ... 0'; verified locally (sig=G, exit=0). - The PowerShell probe's three commands ran unconditionally, so a native-command failure (git doesn't throw) could let a later step run and mask the real error. Chained them with '&&' (PowerShell 7+, the documented target) to match the POSIX version's gating, in both copies. - STANDUP.md's intro says 'before git init', then the probe itself runs one, which reads as a contradiction. Clarified the comment: the probe's git init is a disposable scratch repo, not this repo's, which is still section 0B. Ran the full local gate set this round, including the two docker linters (markdownlint-cli2, editorconfig-checker) that earlier rounds skipped: both clean, alongside prose_lint and repo_gate. --- .agents/skills/git-commit-conventions/SKILL.md | 12 ++++++++---- .claude-plugin/fleet-skills/.source-digest | 2 +- .../skills/git-commit-conventions/SKILL.md | 12 ++++++++---- STANDUP.md | 11 ++++++----- docs/host-setup.md | 8 ++++---- 5 files changed, 27 insertions(+), 18 deletions(-) diff --git a/.agents/skills/git-commit-conventions/SKILL.md b/.agents/skills/git-commit-conventions/SKILL.md index e3195eb6..75219bae 100644 --- a/.agents/skills/git-commit-conventions/SKILL.md +++ b/.agents/skills/git-commit-conventions/SKILL.md @@ -62,25 +62,29 @@ scope-widened commit, a rewritten shared history, a destructive reset). 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: + ```sh d=$(mktemp -d "${TMPDIR:-/tmp}/sign-check.XXXXXX") && ( - trap 'rm -rf "$d"' EXIT + trap 'rm -rf "$d"' 0 git init -q "$d" \ && git -C "$d" commit --allow-empty -q -m check \ && git -C "$d" log -1 --format='sig=%G? author=%an <%ae> committer=%cn <%ce>' ) ``` + PowerShell equivalent: + ```powershell $d = Join-Path $env:TEMP ([guid]::NewGuid()) try { - git init -q "$d" - git -C "$d" commit --allow-empty -q -m check - git -C "$d" log -1 --format='sig=%G? author=%an <%ae> committer=%cn <%ce>' + git init -q "$d" ` + && git -C "$d" commit --allow-empty -q -m check ` + && git -C "$d" log -1 --format='sig=%G? author=%an <%ae> committer=%cn <%ce>' } finally { if (Test-Path "$d") { Remove-Item -Recurse -Force "$d" } } ``` + `sig` must read `G`, git's own good-signature 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 diff --git a/.claude-plugin/fleet-skills/.source-digest b/.claude-plugin/fleet-skills/.source-digest index ba342abc..ba65cde6 100644 --- a/.claude-plugin/fleet-skills/.source-digest +++ b/.claude-plugin/fleet-skills/.source-digest @@ -1 +1 @@ -487a307e3a7ac09b +f3ebcd2180dc279b 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 e3195eb6..75219bae 100644 --- a/.claude-plugin/fleet-skills/skills/git-commit-conventions/SKILL.md +++ b/.claude-plugin/fleet-skills/skills/git-commit-conventions/SKILL.md @@ -62,25 +62,29 @@ scope-widened commit, a rewritten shared history, a destructive reset). 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: + ```sh d=$(mktemp -d "${TMPDIR:-/tmp}/sign-check.XXXXXX") && ( - trap 'rm -rf "$d"' EXIT + trap 'rm -rf "$d"' 0 git init -q "$d" \ && git -C "$d" commit --allow-empty -q -m check \ && git -C "$d" log -1 --format='sig=%G? author=%an <%ae> committer=%cn <%ce>' ) ``` + PowerShell equivalent: + ```powershell $d = Join-Path $env:TEMP ([guid]::NewGuid()) try { - git init -q "$d" - git -C "$d" commit --allow-empty -q -m check - git -C "$d" log -1 --format='sig=%G? author=%an <%ae> committer=%cn <%ce>' + git init -q "$d" ` + && git -C "$d" commit --allow-empty -q -m check ` + && git -C "$d" log -1 --format='sig=%G? author=%an <%ae> committer=%cn <%ce>' } finally { if (Test-Path "$d") { Remove-Item -Recurse -Force "$d" } } ``` + `sig` must read `G`, git's own good-signature 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 diff --git a/STANDUP.md b/STANDUP.md index 73d6a564..0e2b4c3a 100644 --- a/STANDUP.md +++ b/STANDUP.md @@ -31,12 +31,13 @@ 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 -# prove signing works with a live scratch commit, 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. +# 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. d=$(mktemp -d "${TMPDIR:-/tmp}/sign-check.XXXXXX") && ( - trap 'rm -rf "$d"' EXIT + trap 'rm -rf "$d"' 0 git init -q "$d" \ && git -C "$d" commit --allow-empty -q -m check \ && git -C "$d" log -1 --format='sig=%G? author=%an <%ae> committer=%cn <%ce>' diff --git a/docs/host-setup.md b/docs/host-setup.md index aa169da5..5389d30b 100644 --- a/docs/host-setup.md +++ b/docs/host-setup.md @@ -239,7 +239,7 @@ python3 scripts/host_gate.py # presence and version floors, from spec/ python3 scripts/skills_install.py --report # the skills install stamp is current git config --global --list | grep -E "user\.|signing|gpg\." d=$(mktemp -d "${TMPDIR:-/tmp}/sign-check.XXXXXX") && ( - trap 'rm -rf "$d"' EXIT + trap 'rm -rf "$d"' 0 git init -q "$d" \ && git -C "$d" commit --allow-empty -q -m check \ && git -C "$d" log -1 --format='sig=%G? author=%an <%ae> committer=%cn <%ce>' @@ -259,9 +259,9 @@ py -3 scripts/skills_install.py --report # the skills install stamp is curre git config --global --list | Select-String "user\.|signing|gpg\." $d = Join-Path $env:TEMP ([guid]::NewGuid()) try { - git init -q "$d" - git -C "$d" commit --allow-empty -q -m check - git -C "$d" log -1 --format='sig=%G? author=%an <%ae> committer=%cn <%ce>' + git init -q "$d" ` + && git -C "$d" commit --allow-empty -q -m check ` + && git -C "$d" log -1 --format='sig=%G? author=%an <%ae> committer=%cn <%ce>' } finally { if (Test-Path "$d") { Remove-Item -Recurse -Force "$d" } } From bf4108de1bdb49a9da052e5c0b1972b44a3cbb56 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Fri, 14 Aug 2026 14:38:21 -0700 Subject: [PATCH 10/18] Make the Probe Actually Gate on sig=G, Not Just Print It Copilot's round-10 review on d50b305 found the probe printed 'sig=%G? ...' but never enforced it: the snippet exited 0 regardless of the signature verdict, so a bad/unsigned/unverifiable result read as a pass to anything keying off exit status (copy-pasted into automation, or an agent that trusts the exit code over the text). This is the same class round 2 already fixed for the trailing cleanup, just left open at the actual pass/fail line. POSIX copies (STANDUP.md, host-setup.md, git-commit-conventions.md): capture the git log line's output, echo it (so it's still visible), then gate on it with 'case "$out" in sig=G\ *) true ;; *) false ;; esac', portable to any POSIX sh. PowerShell copies: pipe through 'Tee-Object -Variable out' to print and capture in one step, then 'throw' when it doesn't match '^sig=G '. Verified both directions locally: the real config prints and exits 0 (sig=G), and a forced 'commit.gpgsign=false' commit prints sig=N and exits 1. Full local gate set clean, including both docker linters. --- .agents/skills/git-commit-conventions/SKILL.md | 7 +++++-- .claude-plugin/fleet-skills/.source-digest | 2 +- .../fleet-skills/skills/git-commit-conventions/SKILL.md | 7 +++++-- STANDUP.md | 4 +++- docs/host-setup.md | 7 +++++-- 5 files changed, 19 insertions(+), 8 deletions(-) diff --git a/.agents/skills/git-commit-conventions/SKILL.md b/.agents/skills/git-commit-conventions/SKILL.md index 75219bae..856966a5 100644 --- a/.agents/skills/git-commit-conventions/SKILL.md +++ b/.agents/skills/git-commit-conventions/SKILL.md @@ -68,7 +68,9 @@ scope-widened commit, a rewritten shared history, a destructive reset). trap 'rm -rf "$d"' 0 git init -q "$d" \ && git -C "$d" commit --allow-empty -q -m check \ - && git -C "$d" log -1 --format='sig=%G? author=%an <%ae> committer=%cn <%ce>' + && out=$(git -C "$d" log -1 --format='sig=%G? author=%an <%ae> committer=%cn <%ce>') \ + && echo "$out" \ + && case "$out" in sig=G\ *) true ;; *) false ;; esac ) ``` @@ -79,7 +81,8 @@ scope-widened commit, a rewritten shared history, a destructive reset). try { git init -q "$d" ` && git -C "$d" commit --allow-empty -q -m check ` - && git -C "$d" log -1 --format='sig=%G? author=%an <%ae> committer=%cn <%ce>' + && git -C "$d" log -1 --format='sig=%G? author=%an <%ae> committer=%cn <%ce>' | Tee-Object -Variable out + if ($out -notmatch '^sig=G ') { throw "signing check failed: $out" } } finally { if (Test-Path "$d") { Remove-Item -Recurse -Force "$d" } } diff --git a/.claude-plugin/fleet-skills/.source-digest b/.claude-plugin/fleet-skills/.source-digest index ba65cde6..8291a845 100644 --- a/.claude-plugin/fleet-skills/.source-digest +++ b/.claude-plugin/fleet-skills/.source-digest @@ -1 +1 @@ -f3ebcd2180dc279b +5f492d16535ae5c9 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 75219bae..856966a5 100644 --- a/.claude-plugin/fleet-skills/skills/git-commit-conventions/SKILL.md +++ b/.claude-plugin/fleet-skills/skills/git-commit-conventions/SKILL.md @@ -68,7 +68,9 @@ scope-widened commit, a rewritten shared history, a destructive reset). trap 'rm -rf "$d"' 0 git init -q "$d" \ && git -C "$d" commit --allow-empty -q -m check \ - && git -C "$d" log -1 --format='sig=%G? author=%an <%ae> committer=%cn <%ce>' + && out=$(git -C "$d" log -1 --format='sig=%G? author=%an <%ae> committer=%cn <%ce>') \ + && echo "$out" \ + && case "$out" in sig=G\ *) true ;; *) false ;; esac ) ``` @@ -79,7 +81,8 @@ scope-widened commit, a rewritten shared history, a destructive reset). try { git init -q "$d" ` && git -C "$d" commit --allow-empty -q -m check ` - && git -C "$d" log -1 --format='sig=%G? author=%an <%ae> committer=%cn <%ce>' + && git -C "$d" log -1 --format='sig=%G? author=%an <%ae> committer=%cn <%ce>' | Tee-Object -Variable out + if ($out -notmatch '^sig=G ') { throw "signing check failed: $out" } } finally { if (Test-Path "$d") { Remove-Item -Recurse -Force "$d" } } diff --git a/STANDUP.md b/STANDUP.md index 0e2b4c3a..03b1ac6d 100644 --- a/STANDUP.md +++ b/STANDUP.md @@ -40,7 +40,9 @@ d=$(mktemp -d "${TMPDIR:-/tmp}/sign-check.XXXXXX") && ( trap 'rm -rf "$d"' 0 git init -q "$d" \ && git -C "$d" commit --allow-empty -q -m check \ - && git -C "$d" log -1 --format='sig=%G? author=%an <%ae> committer=%cn <%ce>' + && out=$(git -C "$d" log -1 --format='sig=%G? author=%an <%ae> committer=%cn <%ce>') \ + && echo "$out" \ + && case "$out" in sig=G\ *) true ;; *) false ;; esac ) ``` diff --git a/docs/host-setup.md b/docs/host-setup.md index 5389d30b..5e8c52bf 100644 --- a/docs/host-setup.md +++ b/docs/host-setup.md @@ -242,7 +242,9 @@ d=$(mktemp -d "${TMPDIR:-/tmp}/sign-check.XXXXXX") && ( trap 'rm -rf "$d"' 0 git init -q "$d" \ && git -C "$d" commit --allow-empty -q -m check \ - && git -C "$d" log -1 --format='sig=%G? author=%an <%ae> committer=%cn <%ce>' + && out=$(git -C "$d" log -1 --format='sig=%G? author=%an <%ae> committer=%cn <%ce>') \ + && echo "$out" \ + && case "$out" in sig=G\ *) true ;; *) false ;; esac ) gh auth status ``` @@ -261,7 +263,8 @@ $d = Join-Path $env:TEMP ([guid]::NewGuid()) try { git init -q "$d" ` && git -C "$d" commit --allow-empty -q -m check ` - && git -C "$d" log -1 --format='sig=%G? author=%an <%ae> committer=%cn <%ce>' + && git -C "$d" log -1 --format='sig=%G? author=%an <%ae> committer=%cn <%ce>' | Tee-Object -Variable out + if ($out -notmatch '^sig=G ') { throw "signing check failed: $out" } } finally { if (Test-Path "$d") { Remove-Item -Recurse -Force "$d" } } From 3686e449c57e86375dad9fc1650ee4b5f6a282f7 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Fri, 14 Aug 2026 14:45:43 -0700 Subject: [PATCH 11/18] Extend the Gate to Identity, Not Just Signature Copilot's round-11 review on bf4108d found the same class of gap one level further: the snippet now exited non-zero on a bad signature, but the prose right next to it also requires the author and committer email to match the configured noreply address, and nothing enforced that either. A commit signed correctly under the wrong identity still exited 0. Captured the expected address from 'git config --global --get user.email' and compare it against the scratch commit's own %ae/%ce in all three POSIX copies (plain string equality, no extra tools) and both PowerShell copies (alongside the existing sig check, one throw covering all three conditions). Verified both directions locally: real config passes (sig=G, both emails match, exit 0); a commit forced to a different user.email still signs (sig=G) but now fails the identity check (exit 1). Full local gate set clean, including both docker linters. --- .agents/skills/git-commit-conventions/SKILL.md | 16 +++++++++++++--- .claude-plugin/fleet-skills/.source-digest | 2 +- .../skills/git-commit-conventions/SKILL.md | 16 +++++++++++++--- STANDUP.md | 9 +++++++-- docs/host-setup.md | 18 ++++++++++++++---- 5 files changed, 48 insertions(+), 13 deletions(-) diff --git a/.agents/skills/git-commit-conventions/SKILL.md b/.agents/skills/git-commit-conventions/SKILL.md index 856966a5..faee2f7e 100644 --- a/.agents/skills/git-commit-conventions/SKILL.md +++ b/.agents/skills/git-commit-conventions/SKILL.md @@ -66,11 +66,16 @@ scope-widened commit, a rewritten shared history, a destructive reset). ```sh d=$(mktemp -d "${TMPDIR:-/tmp}/sign-check.XXXXXX") && ( trap 'rm -rf "$d"' 0 - git init -q "$d" \ + 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" \ - && case "$out" in sig=G\ *) true ;; *) false ;; esac + && ae=$(git -C "$d" log -1 --format='%ae') \ + && ce=$(git -C "$d" log -1 --format='%ce') \ + && case "$out" in sig=G\ *) true ;; *) false ;; esac \ + && [ "$ae" = "$email" ] \ + && [ "$ce" = "$email" ] ) ``` @@ -79,10 +84,15 @@ scope-widened commit, a rewritten shared history, a destructive reset). ```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 ` && git -C "$d" log -1 --format='sig=%G? author=%an <%ae> committer=%cn <%ce>' | Tee-Object -Variable out - if ($out -notmatch '^sig=G ') { throw "signing check failed: $out" } + $ae = git -C "$d" log -1 --format='%ae' + $ce = git -C "$d" log -1 --format='%ce' + if ($out -notmatch '^sig=G ' -or $ae -ne $email -or $ce -ne $email) { + throw "signing/identity check failed: $out" + } } finally { if (Test-Path "$d") { Remove-Item -Recurse -Force "$d" } } diff --git a/.claude-plugin/fleet-skills/.source-digest b/.claude-plugin/fleet-skills/.source-digest index 8291a845..ad6d620c 100644 --- a/.claude-plugin/fleet-skills/.source-digest +++ b/.claude-plugin/fleet-skills/.source-digest @@ -1 +1 @@ -5f492d16535ae5c9 +70a7dc077bffe72c 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 856966a5..faee2f7e 100644 --- a/.claude-plugin/fleet-skills/skills/git-commit-conventions/SKILL.md +++ b/.claude-plugin/fleet-skills/skills/git-commit-conventions/SKILL.md @@ -66,11 +66,16 @@ scope-widened commit, a rewritten shared history, a destructive reset). ```sh d=$(mktemp -d "${TMPDIR:-/tmp}/sign-check.XXXXXX") && ( trap 'rm -rf "$d"' 0 - git init -q "$d" \ + 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" \ - && case "$out" in sig=G\ *) true ;; *) false ;; esac + && ae=$(git -C "$d" log -1 --format='%ae') \ + && ce=$(git -C "$d" log -1 --format='%ce') \ + && case "$out" in sig=G\ *) true ;; *) false ;; esac \ + && [ "$ae" = "$email" ] \ + && [ "$ce" = "$email" ] ) ``` @@ -79,10 +84,15 @@ scope-widened commit, a rewritten shared history, a destructive reset). ```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 ` && git -C "$d" log -1 --format='sig=%G? author=%an <%ae> committer=%cn <%ce>' | Tee-Object -Variable out - if ($out -notmatch '^sig=G ') { throw "signing check failed: $out" } + $ae = git -C "$d" log -1 --format='%ae' + $ce = git -C "$d" log -1 --format='%ce' + if ($out -notmatch '^sig=G ' -or $ae -ne $email -or $ce -ne $email) { + throw "signing/identity check failed: $out" + } } finally { if (Test-Path "$d") { Remove-Item -Recurse -Force "$d" } } diff --git a/STANDUP.md b/STANDUP.md index 03b1ac6d..7cb1ff36 100644 --- a/STANDUP.md +++ b/STANDUP.md @@ -38,11 +38,16 @@ git config --global --get gpg.format # ssh for an SSH key; unset or openp # .agents/skills/git-commit-conventions/SKILL.md "Signing, verified not configured" for why. d=$(mktemp -d "${TMPDIR:-/tmp}/sign-check.XXXXXX") && ( trap 'rm -rf "$d"' 0 - git init -q "$d" \ + 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" \ - && case "$out" in sig=G\ *) true ;; *) false ;; esac + && ae=$(git -C "$d" log -1 --format='%ae') \ + && ce=$(git -C "$d" log -1 --format='%ce') \ + && case "$out" in sig=G\ *) true ;; *) false ;; esac \ + && [ "$ae" = "$email" ] \ + && [ "$ce" = "$email" ] ) ``` diff --git a/docs/host-setup.md b/docs/host-setup.md index 5e8c52bf..eebb39c0 100644 --- a/docs/host-setup.md +++ b/docs/host-setup.md @@ -240,16 +240,21 @@ python3 scripts/skills_install.py --report # the skills install stamp is curre git config --global --list | grep -E "user\.|signing|gpg\." d=$(mktemp -d "${TMPDIR:-/tmp}/sign-check.XXXXXX") && ( trap 'rm -rf "$d"' 0 - git init -q "$d" \ + 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" \ - && case "$out" in sig=G\ *) true ;; *) false ;; esac + && ae=$(git -C "$d" log -1 --format='%ae') \ + && ce=$(git -C "$d" log -1 --format='%ce') \ + && case "$out" in sig=G\ *) true ;; *) false ;; esac \ + && [ "$ae" = "$email" ] \ + && [ "$ce" = "$email" ] ) gh auth status ``` -`sig` must read `G` and both the `author` and `committer` email must match the noreply address from the config line above. `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. +`sig` must read `G` and both the `author` and `committer` email must match the noreply address from the config line above, both 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. @@ -261,10 +266,15 @@ py -3 scripts/skills_install.py --report # the skills install stamp is curre git config --global --list | Select-String "user\.|signing|gpg\." $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 ` && git -C "$d" log -1 --format='sig=%G? author=%an <%ae> committer=%cn <%ce>' | Tee-Object -Variable out - if ($out -notmatch '^sig=G ') { throw "signing check failed: $out" } + $ae = git -C "$d" log -1 --format='%ae' + $ce = git -C "$d" log -1 --format='%ce' + if ($out -notmatch '^sig=G ' -or $ae -ne $email -or $ce -ne $email) { + throw "signing/identity check failed: $out" + } } finally { if (Test-Path "$d") { Remove-Item -Recurse -Force "$d" } } From 8f250837dce4c10e66d334c57988b9cc02ca669a Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Fri, 14 Aug 2026 14:58:24 -0700 Subject: [PATCH 12/18] Accept sig=U and Verify the Noreply Pattern, Not Just a Config Match Copilot's round-12 review on 3686e44 found two more real gaps: - The gate only accepted 'sig=G', but git's %G? can also read 'U' (good signature, undefined trust) for a cryptographically valid GPG signature whose key trust hasn't been set to ultimate, a realistic state right after generating a new GPG key. SSH's allowed_signers carries no trust concept, so 'U' never applies there; accepted both in all POSIX and PowerShell copies. - The identity check only proved the commit matched whatever 'user.email' said, never that user.email itself is a real GitHub noreply address. Added a pattern check ('*@users.noreply.github.com') alongside the existing match, in all copies. Verified locally: the real config passes both new checks; a forced non-noreply user.email still signs (sig=G) but now fails the pattern check (exit 1). Full local gate set clean, including both docker linters. Caught and fixed one new prose semicolon of my own before pushing. --- .agents/skills/git-commit-conventions/SKILL.md | 10 +++++++--- .claude-plugin/fleet-skills/.source-digest | 2 +- .../skills/git-commit-conventions/SKILL.md | 10 +++++++--- STANDUP.md | 5 +++-- docs/host-setup.md | 8 +++++--- 5 files changed, 23 insertions(+), 12 deletions(-) diff --git a/.agents/skills/git-commit-conventions/SKILL.md b/.agents/skills/git-commit-conventions/SKILL.md index faee2f7e..52e07bce 100644 --- a/.agents/skills/git-commit-conventions/SKILL.md +++ b/.agents/skills/git-commit-conventions/SKILL.md @@ -73,7 +73,8 @@ scope-widened commit, a rewritten shared history, a destructive reset). && echo "$out" \ && ae=$(git -C "$d" log -1 --format='%ae') \ && ce=$(git -C "$d" log -1 --format='%ce') \ - && case "$out" in sig=G\ *) true ;; *) false ;; esac \ + && case "$out" in sig=G\ *|sig=U\ *) true ;; *) false ;; esac \ + && case "$email" in *@users.noreply.github.com) true ;; *) false ;; esac \ && [ "$ae" = "$email" ] \ && [ "$ce" = "$email" ] ) @@ -90,7 +91,8 @@ scope-widened commit, a rewritten shared history, a destructive reset). && git -C "$d" log -1 --format='sig=%G? author=%an <%ae> committer=%cn <%ce>' | Tee-Object -Variable out $ae = git -C "$d" log -1 --format='%ae' $ce = git -C "$d" log -1 --format='%ce' - if ($out -notmatch '^sig=G ' -or $ae -ne $email -or $ce -ne $email) { + 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 { @@ -98,7 +100,9 @@ scope-widened commit, a rewritten shared history, a destructive reset). } ``` - `sig` must read `G`, git's own good-signature verdict char. Don't grep localized + `sig` must read `G` (good signature) or `U` (good signature, undefined trust: GPG-only, common + on a freshly generated key before its trust is set to ultimate). SSH's `allowed_signers` carries + no trust concept, so `U` never applies there. `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 diff --git a/.claude-plugin/fleet-skills/.source-digest b/.claude-plugin/fleet-skills/.source-digest index ad6d620c..e3a53d2d 100644 --- a/.claude-plugin/fleet-skills/.source-digest +++ b/.claude-plugin/fleet-skills/.source-digest @@ -1 +1 @@ -70a7dc077bffe72c +50ef73750b27fd0c 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 faee2f7e..52e07bce 100644 --- a/.claude-plugin/fleet-skills/skills/git-commit-conventions/SKILL.md +++ b/.claude-plugin/fleet-skills/skills/git-commit-conventions/SKILL.md @@ -73,7 +73,8 @@ scope-widened commit, a rewritten shared history, a destructive reset). && echo "$out" \ && ae=$(git -C "$d" log -1 --format='%ae') \ && ce=$(git -C "$d" log -1 --format='%ce') \ - && case "$out" in sig=G\ *) true ;; *) false ;; esac \ + && case "$out" in sig=G\ *|sig=U\ *) true ;; *) false ;; esac \ + && case "$email" in *@users.noreply.github.com) true ;; *) false ;; esac \ && [ "$ae" = "$email" ] \ && [ "$ce" = "$email" ] ) @@ -90,7 +91,8 @@ scope-widened commit, a rewritten shared history, a destructive reset). && git -C "$d" log -1 --format='sig=%G? author=%an <%ae> committer=%cn <%ce>' | Tee-Object -Variable out $ae = git -C "$d" log -1 --format='%ae' $ce = git -C "$d" log -1 --format='%ce' - if ($out -notmatch '^sig=G ' -or $ae -ne $email -or $ce -ne $email) { + 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 { @@ -98,7 +100,9 @@ scope-widened commit, a rewritten shared history, a destructive reset). } ``` - `sig` must read `G`, git's own good-signature verdict char. Don't grep localized + `sig` must read `G` (good signature) or `U` (good signature, undefined trust: GPG-only, common + on a freshly generated key before its trust is set to ultimate). SSH's `allowed_signers` carries + no trust concept, so `U` never applies there. `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 diff --git a/STANDUP.md b/STANDUP.md index 7cb1ff36..c1d0a4e2 100644 --- a/STANDUP.md +++ b/STANDUP.md @@ -45,7 +45,8 @@ d=$(mktemp -d "${TMPDIR:-/tmp}/sign-check.XXXXXX") && ( && echo "$out" \ && ae=$(git -C "$d" log -1 --format='%ae') \ && ce=$(git -C "$d" log -1 --format='%ce') \ - && case "$out" in sig=G\ *) true ;; *) false ;; esac \ + && case "$out" in sig=G\ *|sig=U\ *) true ;; *) false ;; esac \ + && case "$email" in *@users.noreply.github.com) true ;; *) false ;; esac \ && [ "$ae" = "$email" ] \ && [ "$ce" = "$email" ] ) @@ -69,7 +70,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 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`), never by which delivery path produced it. A missing `--global` value, `sig` not reading `G`, 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. +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 GPG signature whose key trust is merely undefined), 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: diff --git a/docs/host-setup.md b/docs/host-setup.md index eebb39c0..1e5c8d3d 100644 --- a/docs/host-setup.md +++ b/docs/host-setup.md @@ -247,14 +247,15 @@ d=$(mktemp -d "${TMPDIR:-/tmp}/sign-check.XXXXXX") && ( && echo "$out" \ && ae=$(git -C "$d" log -1 --format='%ae') \ && ce=$(git -C "$d" log -1 --format='%ce') \ - && case "$out" in sig=G\ *) true ;; *) false ;; esac \ + && 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 ``` -`sig` must read `G` and both the `author` and `committer` email must match the noreply address from the config line above, both 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. +`sig` must read `G` (good signature) or `U` (good signature, undefined trust: GPG-only, common on a freshly generated key before its trust is set to ultimate; SSH's `allowed_signers` carries no trust concept, so this never applies there), 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. @@ -272,7 +273,8 @@ try { && git -C "$d" log -1 --format='sig=%G? author=%an <%ae> committer=%cn <%ce>' | Tee-Object -Variable out $ae = git -C "$d" log -1 --format='%ae' $ce = git -C "$d" log -1 --format='%ce' - if ($out -notmatch '^sig=G ' -or $ae -ne $email -or $ce -ne $email) { + 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 { From f1fa79ab61e10dd46879fee71ca5e2de7d1c9aba Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Fri, 14 Aug 2026 15:07:30 -0700 Subject: [PATCH 13/18] Collapse the POSIX Probe to One Line, Not Just Remove Backslashes Copilot's round-13 review on 8f25083 found the multi-line '\' continuations break under this repo's CRLF Markdown default: a trailing '\' immediately followed by '\r\n' is not a continuation to a POSIX shell, since only a bare LF ends it. Verified: bash on the exact byte pattern throws a hard syntax error. The review's own suggested fix (trailing '&&' at end-of-line instead of '\') does not actually work either. Tested it directly: 'false &&' followed by CRLF, then a next line, still runs the next line unconditionally and exits 0. A stray '\r' right after '&&' becomes a bogus command of its own, breaks the pipeline there, and every line after it then runs as an independent unconditional statement, exactly the silent bypass this whole probe exists to prevent. That would have made the gate worse than before: a hard syntax error is at least loud. Collapsed each POSIX probe (git-commit-conventions.md, STANDUP.md, host-setup.md) to one physical line instead, removing every mid-line injection point outright rather than trading one CRLF hazard for a worse one. Verified by extracting each exact line from its file and running it. PowerShell's backtick continuation is unaffected, since CRLF is that shell's native encoding on its native platform, so those two copies are untouched. Full local gate set clean, including both docker linters. --- .../skills/git-commit-conventions/SKILL.md | 19 +++++-------------- .claude-plugin/fleet-skills/.source-digest | 2 +- .../skills/git-commit-conventions/SKILL.md | 19 +++++-------------- STANDUP.md | 17 +++-------------- docs/host-setup.md | 17 +++-------------- 5 files changed, 17 insertions(+), 57 deletions(-) diff --git a/.agents/skills/git-commit-conventions/SKILL.md b/.agents/skills/git-commit-conventions/SKILL.md index 52e07bce..b06e6235 100644 --- a/.agents/skills/git-commit-conventions/SKILL.md +++ b/.agents/skills/git-commit-conventions/SKILL.md @@ -63,21 +63,12 @@ scope-widened commit, a rewritten shared history, a destructive reset). `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" ] - ) + 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: diff --git a/.claude-plugin/fleet-skills/.source-digest b/.claude-plugin/fleet-skills/.source-digest index e3a53d2d..01b2f50c 100644 --- a/.claude-plugin/fleet-skills/.source-digest +++ b/.claude-plugin/fleet-skills/.source-digest @@ -1 +1 @@ -50ef73750b27fd0c +d877227e62c06069 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 52e07bce..b06e6235 100644 --- a/.claude-plugin/fleet-skills/skills/git-commit-conventions/SKILL.md +++ b/.claude-plugin/fleet-skills/skills/git-commit-conventions/SKILL.md @@ -63,21 +63,12 @@ scope-widened commit, a rewritten shared history, a destructive reset). `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" ] - ) + 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: diff --git a/STANDUP.md b/STANDUP.md index c1d0a4e2..6941feec 100644 --- a/STANDUP.md +++ b/STANDUP.md @@ -36,20 +36,9 @@ git config --global --get gpg.format # ssh for an SSH key; unset or openp # (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. -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" ] -) +# 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. diff --git a/docs/host-setup.md b/docs/host-setup.md index 1e5c8d3d..afbfe901 100644 --- a/docs/host-setup.md +++ b/docs/host-setup.md @@ -238,20 +238,9 @@ 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\." -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" ] -) +# 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 ``` From 4f0310782282e24f303d0e1ed08ae77cb9d60f69 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Fri, 14 Aug 2026 15:18:35 -0700 Subject: [PATCH 14/18] Stop Masking a git log Failure Behind Tee-Object in PowerShell Copilot's round-15 review on bd19e8a found the PowerShell probe's '&&' chain depended on Tee-Object's success, not git log's: piping a native command into a cmdlet makes the pipeline's exit status the cmdlet's, so a failing git log would still let the chain continue and surface only the generic 'signing/identity check failed' message instead of the real error at the point it happened. Replaced the pipe with a direct assignment ('$out = git ... log ...') in both PowerShell copies, matching the POSIX version's 'out=$(...)' structure exactly, then print $out on its own line. No pwsh available locally to execute this one, but the structure is now identical to the already-verified POSIX pattern. Full local gate set clean, including both docker linters. --- .agents/skills/git-commit-conventions/SKILL.md | 3 ++- .claude-plugin/fleet-skills/.source-digest | 2 +- .../fleet-skills/skills/git-commit-conventions/SKILL.md | 3 ++- docs/host-setup.md | 3 ++- 4 files changed, 7 insertions(+), 4 deletions(-) diff --git a/.agents/skills/git-commit-conventions/SKILL.md b/.agents/skills/git-commit-conventions/SKILL.md index b06e6235..f9899a5f 100644 --- a/.agents/skills/git-commit-conventions/SKILL.md +++ b/.agents/skills/git-commit-conventions/SKILL.md @@ -79,7 +79,8 @@ scope-widened commit, a rewritten shared history, a destructive reset). $email = git config --global --get user.email git init -q "$d" ` && git -C "$d" commit --allow-empty -q -m check ` - && git -C "$d" log -1 --format='sig=%G? author=%an <%ae> committer=%cn <%ce>' | Tee-Object -Variable out + && $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$' ` diff --git a/.claude-plugin/fleet-skills/.source-digest b/.claude-plugin/fleet-skills/.source-digest index 01b2f50c..284479f4 100644 --- a/.claude-plugin/fleet-skills/.source-digest +++ b/.claude-plugin/fleet-skills/.source-digest @@ -1 +1 @@ -d877227e62c06069 +d0d7ff751b492faa 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 b06e6235..f9899a5f 100644 --- a/.claude-plugin/fleet-skills/skills/git-commit-conventions/SKILL.md +++ b/.claude-plugin/fleet-skills/skills/git-commit-conventions/SKILL.md @@ -79,7 +79,8 @@ scope-widened commit, a rewritten shared history, a destructive reset). $email = git config --global --get user.email git init -q "$d" ` && git -C "$d" commit --allow-empty -q -m check ` - && git -C "$d" log -1 --format='sig=%G? author=%an <%ae> committer=%cn <%ce>' | Tee-Object -Variable out + && $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$' ` diff --git a/docs/host-setup.md b/docs/host-setup.md index afbfe901..f3f14e9b 100644 --- a/docs/host-setup.md +++ b/docs/host-setup.md @@ -259,7 +259,8 @@ try { $email = git config --global --get user.email git init -q "$d" ` && git -C "$d" commit --allow-empty -q -m check ` - && git -C "$d" log -1 --format='sig=%G? author=%an <%ae> committer=%cn <%ce>' | Tee-Object -Variable out + && $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$' ` From 63336a308f4db26affd3deda596b0ade168f0554 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Fri, 14 Aug 2026 15:26:34 -0700 Subject: [PATCH 15/18] Correct My Own sig=U Claim: SSH Hits It Too Copilot's round-16 review on 4f03107 disputed a claim I wrote in round 12: that SSH signing never produces sig=U since allowed_signers carries no trust concept. That was wrong, and I verified it directly rather than take either side's word: signed a scratch commit with gpg.ssh.allowedSignersFile pointed at an empty file, and got exactly sig=U, 'Good "git" signature ... No principal matched.' The shell logic (accept G or U) was already correct for what this probe actually needs to prove, since a missing local allowed_signers entry doesn't affect whether GitHub itself verifies the commit, only local git verify-commit output. Only the prose explanation was wrong. Corrected it in all three files: U covers both a GPG key with merely undefined trust and an SSH key missing from allowed_signers, neither of which means the commit won't verify on GitHub. Also caught and fixed two more prose semicolons of my own while rewriting these paragraphs. Full local gate set clean, including both docker linters. --- .agents/skills/git-commit-conventions/SKILL.md | 8 +++++--- .claude-plugin/fleet-skills/.source-digest | 2 +- .../fleet-skills/skills/git-commit-conventions/SKILL.md | 8 +++++--- STANDUP.md | 2 +- docs/host-setup.md | 2 +- 5 files changed, 13 insertions(+), 9 deletions(-) diff --git a/.agents/skills/git-commit-conventions/SKILL.md b/.agents/skills/git-commit-conventions/SKILL.md index f9899a5f..d4c28f41 100644 --- a/.agents/skills/git-commit-conventions/SKILL.md +++ b/.agents/skills/git-commit-conventions/SKILL.md @@ -92,9 +92,11 @@ scope-widened commit, a rewritten shared history, a destructive reset). } ``` - `sig` must read `G` (good signature) or `U` (good signature, undefined trust: GPG-only, common - on a freshly generated key before its trust is set to ultimate). SSH's `allowed_signers` carries - no trust concept, so `U` never applies there. `sig` is git's own verdict char. Don't grep localized + `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 diff --git a/.claude-plugin/fleet-skills/.source-digest b/.claude-plugin/fleet-skills/.source-digest index 284479f4..f1b77718 100644 --- a/.claude-plugin/fleet-skills/.source-digest +++ b/.claude-plugin/fleet-skills/.source-digest @@ -1 +1 @@ -d0d7ff751b492faa +774eae80a44248db 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 f9899a5f..d4c28f41 100644 --- a/.claude-plugin/fleet-skills/skills/git-commit-conventions/SKILL.md +++ b/.claude-plugin/fleet-skills/skills/git-commit-conventions/SKILL.md @@ -92,9 +92,11 @@ scope-widened commit, a rewritten shared history, a destructive reset). } ``` - `sig` must read `G` (good signature) or `U` (good signature, undefined trust: GPG-only, common - on a freshly generated key before its trust is set to ultimate). SSH's `allowed_signers` carries - no trust concept, so `U` never applies there. `sig` is git's own verdict char. Don't grep localized + `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 diff --git a/STANDUP.md b/STANDUP.md index 6941feec..0f0d1b10 100644 --- a/STANDUP.md +++ b/STANDUP.md @@ -59,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 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 GPG signature whose key trust is merely undefined), 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. +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: diff --git a/docs/host-setup.md b/docs/host-setup.md index f3f14e9b..c11786f8 100644 --- a/docs/host-setup.md +++ b/docs/host-setup.md @@ -244,7 +244,7 @@ d=$(mktemp -d "${TMPDIR:-/tmp}/sign-check.XXXXXX") && ( trap 'rm -rf "$d"' 0; em gh auth status ``` -`sig` must read `G` (good signature) or `U` (good signature, undefined trust: GPG-only, common on a freshly generated key before its trust is set to ultimate; SSH's `allowed_signers` carries no trust concept, so this never applies there), 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. +`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. From 840388ddf04f8b730cf2d7d59fb1502f98555687 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Fri, 14 Aug 2026 15:32:03 -0700 Subject: [PATCH 16/18] Fix PowerShell parse error in the signing/identity verify block git init -q "$d" `&& git commit ... `&& $out = git log ...` fails to parse in PowerShell 7: `&&` is the pipeline-chain operator and cannot chain into an assignment statement. This broke on every Windows host, correctly configured or not, in both docs/host-setup.md's "Verify Host Setup" section and the PowerShell equivalent in .agents/skills/git-commit-conventions/SKILL.md's "Signing, verified not configured" section (and its .claude-plugin/fleet-skills mirror). Fix: pull the $out assignment out of the && chain onto its own statement. Validated on a real Windows 11 Pro 10.0.26200 host with PowerShell 7.6.4: the unpatched block threw a ParserError ("Unexpected token '=' in expression or statement"); the patched block prints "sig=G author=... committer=..." and exits 0 against a correctly configured host (SSH signing, users.noreply.github.com identity), and correctly throws on a deliberately wrong identity in a negative-case test. --- .agents/skills/git-commit-conventions/SKILL.md | 4 ++-- .../fleet-skills/skills/git-commit-conventions/SKILL.md | 4 ++-- docs/host-setup.md | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.agents/skills/git-commit-conventions/SKILL.md b/.agents/skills/git-commit-conventions/SKILL.md index d4c28f41..7ff9e43b 100644 --- a/.agents/skills/git-commit-conventions/SKILL.md +++ b/.agents/skills/git-commit-conventions/SKILL.md @@ -78,8 +78,8 @@ scope-widened commit, a rewritten shared history, a destructive reset). 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>' + && 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' 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 d4c28f41..7ff9e43b 100644 --- a/.claude-plugin/fleet-skills/skills/git-commit-conventions/SKILL.md +++ b/.claude-plugin/fleet-skills/skills/git-commit-conventions/SKILL.md @@ -78,8 +78,8 @@ scope-widened commit, a rewritten shared history, a destructive reset). 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>' + && 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' diff --git a/docs/host-setup.md b/docs/host-setup.md index c11786f8..cb173db3 100644 --- a/docs/host-setup.md +++ b/docs/host-setup.md @@ -258,8 +258,8 @@ $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>' + && 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' From c7fb717bdcf977695659fbe64f9ee5f037893ca2 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Fri, 14 Aug 2026 15:34:42 -0700 Subject: [PATCH 17/18] Collapse the Last Backslash-Continued Snippet in host-setup.md Copilot's round-17 review on 63336a3 found docs/host-setup.md still carried one pre-existing '\'-continued snippet (the allowed_signers file creation, predating this PR) right next to the new CRLF warning comment explaining why the scratch probe avoids exactly that pattern, making the guidance internally inconsistent. Collapsed it to one line, same fix as the probe itself. Rebased onto 840388d first (the Windows-side agent's real fix for the PowerShell '&&'-into-assignment parse error my round-15 change introduced, validated on actual Windows 11 / PowerShell 7.6.4). That's the gap issue #712 exists to name: I could reason the structure matched the tested POSIX version, but I could not execute it, and the Windows-side validation is what actually confirmed or refuted it. Full local gate set clean, including both docker linters. --- .claude-plugin/fleet-skills/.source-digest | 2 +- docs/host-setup.md | 3 +-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/.claude-plugin/fleet-skills/.source-digest b/.claude-plugin/fleet-skills/.source-digest index f1b77718..134eeaa7 100644 --- a/.claude-plugin/fleet-skills/.source-digest +++ b/.claude-plugin/fleet-skills/.source-digest @@ -1 +1 @@ -774eae80a44248db +f9e5473792ab198e diff --git a/docs/host-setup.md b/docs/host-setup.md index cb173db3..d7ba4c9c 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 user.email) namespaces=\"git\" $(cat ~/.ssh/id_ed25519.pub)" >> ~/.config/git/allowed_signers git config --global gpg.ssh.allowedSignersFile ~/.config/git/allowed_signers ``` From 1d7cc895fb318ad2cba756b8a2a29096fe084e9b Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Fri, 14 Aug 2026 15:41:01 -0700 Subject: [PATCH 18/18] Read --global user.email in the allowed_signers Setup Line Copilot's round-19 review on c7fb717 found the allowed_signers entry was built from a bare 'git config user.email', which picks up a repo-local override if this snippet is run from inside an existing repo checkout, silently writing the wrong principal into ~/.config/git/allowed_signers. Every other identity read this PR touches already reads --global explicitly for exactly this reason (STANDUP.md's own section 0 spells out why). Added it here too. docs/ssh-signing.md carries the identical line (missing --global, same backslash continuation) but is untouched by this PR and outside what the review looked at; flagging it to the maintainer separately rather than expanding scope here. Full local gate set clean, including both docker linters. --- docs/host-setup.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/host-setup.md b/docs/host-setup.md index d7ba4c9c..fd350cd6 100644 --- a/docs/host-setup.md +++ b/docs/host-setup.md @@ -137,7 +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 ```