Repository navigation
scaffold-project.py: a re-run whose read of a leg ssh refused for the host key is told that key's fix, checked against GitHub's published fingerprints, in place of "Let git read it" (#1137) - #1156
Conversation
…to check that key against GitHub's fingerprints and keep it, then the re-run, instead of the ruleset hint, and a ruleset's refusal on GitHub says what the hint's re-run can still wait at (#1108) Follow-up to #915 (U037), from RV1101's S1 on #1101. A host key ssh refused. Where a `url.<base>.insteadOf` or `url.<base>.pushInsteadOf` rule sends a push over ssh and ssh cannot verify the host's key, ssh says `Host key verification failed.` and the push printed `RULESET_HINT` alone, whose re-run fails the same way. Measured on main at 823624d with the real OpenSSH 9.6 against a fake server: an unknown key under `BatchMode=yes` (which #1101 advises), answered no, and on a `--reuse-empty-repo` re-run, and a changed key with and without `BatchMode=yes`; each exits 2 in under 2 s with the hint alone. `_host_key_remedy` now names the cause, how to see the key's fingerprint (`ssh -T` without `BatchMode=yes`), that it must be one of the fingerprints GitHub publishes (`gh api meta --jq .ssh_key_fingerprints`), and then the re-run, in place of the hint: ssh refused before git sent anything, so no ruleset was asked. A key that CHANGED (`REMOTE HOST IDENTIFICATION HAS CHANGED`) is never told to be accepted as it comes: nothing is accepted, removed or pushed until its fingerprint is checked. The re-run is the one the login remedies name, now shared as `_then_re_run`: the push fails before git sends anything, so the repositories are left empty or holding their seed, and `--reuse-empty-repo` reuses them. The ruleset hint on GitHub. `RULESET_HINT`'s one exit is the `--reuse-empty-repo` re-run, and it did not say what every other remedy into that re-run says (`PROMPTS_STILL_ASK`). The hint is printed under `--local-remote-dir` too, where no prompt can come, and its text is pinned byte for byte beside `family.py`'s and the adoption's, so the words stay in the hint and `_refused_push` appends `RULESET_HINT_PROMPTS` after it on GitHub alone, and not where a login remedy before it has said the same. `_refused_push` takes `local` from both call sites. The two comments that named #1108 now say where it is said. No refusal code: a refused push is printed as `REFUSED pushing ...`, with no code, as the login remedies of #915 and #993 are. Tests: `test_what_a_refused_push_prints` gains the host-key rows and runs every row on GitHub and on disk; two end-to-end tests run the scaffold on GitHub against a fake ssh saying OpenSSH's measured words and against a ruleset hook; `test_ruleset_hint_tracking_branch.py` pins that the scaffold's hint is the last thing it prints under `--local-remote-dir`. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… for names `ssh -T -o StrictHostKeyChecking=ask` first, so ssh shows the fingerprint and asks whatever the person's ssh_config says, and a changed key is kept only on a yes to the fingerprint checked; GitHub's fingerprints come from `gh api --hostname github.com meta`; and `remote:` lines the server sent no longer switch the remedy (#1108) RV1130's review of #1130: - M1: `HOST_KEY_CHECK` names `ssh -T -o StrictHostKeyChecking=ask`, that option first. ssh keeps the first value it is given for an option, so it wins over an ssh_config's `accept-new`, which kept the key without showing or asking (measured, known_hosts 0 -> 98 bytes), over its `yes`, which refused without asking, and over a later `-o`. The changed-key remedy ends "answering yes only to the fingerprint you checked", as the unknown-key remedy does. - N1: `GITHUB_FINGERPRINTS` is `gh api --hostname github.com meta --jq .ssh_key_fingerprints` and says ssh shows each with `SHA256:` before it. - N2: the host-key match skips lines that start with `remote:` (`_without_remote_lines`). - N3, N4, N5: the comments say what `LogLevel=QUIET`, `LogLevel=FATAL` and a revoked key give, `_then_re_run` says "before any ref is updated", and the two comments over `REMOTE_ENV` and `PROMPTS_STILL_ASK` say `_refused_push` appends `RULESET_HINT_PROMPTS`. Tests: `SEE_THE_KEY`, `PUBLISHED` and `CHANGED_HOST_KEY_REMEDY` follow the words; `test_what_a_refused_push_prints` gains a ruleset whose `remote:` lines carry ssh's words, a `\r` among them, and an unknown key beside `remote:` lines that say it changed. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…atchMode=no -o StrictHostKeyChecking=ask` first, so ssh shows the fingerprint and asks over an ssh_config's `BatchMode yes` and a later `-o`, and the filter's comment says git prefixes each part of a server's line after a carriage return with `remote:` (#1108) RV1130's round 2: - N1: `HOST_KEY_CHECK` names `ssh -T -o BatchMode=no -o StrictHostKeyChecking=ask`, both options first, and drops "but `BatchMode=yes`". Measured in a terminal against the fake sshd: with `BatchMode yes` and `StrictHostKeyChecking accept-new` or `yes` in ssh_config, and again with a later `-o BatchMode=yes` and `-o StrictHostKeyChecking=accept-new`, ssh showed the fingerprint and asked; answering no left known_hosts unchanged. `-o StrictHostKeyChecking=ask` alone refused without asking under `BatchMode yes`. - S1: the `_without_remote_lines` docstring and the test comment say git prefixes each line the server sent, and each part after a carriage return in it, with `remote:`; the test row is git's real shape ("remote: see the log \rremote: Host key verification failed. \n", measured with git 2.43 under LC_ALL=C). Tests: `SEE_THE_KEY` follows the words. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…to a fingerprint GitHub publishes, which can be another of its keys than the one you checked", since ssh asks first about a key type it holds, and a form feed in a server's `remote:` line keeps it one line for the filter (#1108) RV1130's round 3: - N1: after a changed RSA or ECDSA key is checked and `ssh-keygen -R` removes it, `ssh -T` asks about GitHub's ED25519 key, not the key that was checked (ssh asks first about the key types it already holds, RFC 4253 7.1), so the remedy no longer says "the fingerprint you checked"; the docstring and the pin say the same. - S1: the test row `"remote: see the log\x0cHost key verification failed. \n"` is git's real shape for a form feed (git 2.43, `cat -A`: one `remote:` prefix over the separator), and kills the filter's mutant that splits at every separator `str.splitlines` knows. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
… ssh asked first for the key type it held, and once that key is removed the `ssh -T` asks first for ssh's default, ED25519 (#1108) RV1130c's NIT: the docstring said the `ssh -T` "asks first about a key type ssh holds", which is the push's ssh. After `ssh-keygen -R` ssh holds no key for the host, so the `ssh -T` asks first for its default, ED25519. A docstring alone: no printed word and no test changes. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…g ssh refused for the host key is told that key's fix, unknown or changed, checked against GitHub's published fingerprints, in place of "Let git read it", and keeps `scaffold-remote-exists` and its re-run sentence (#1137) `_held_seed` hands on git's refused read whole, because its one line (`_unread_because`) is `Host key verification failed.` for an unknown key and a changed one alike. `_exists_remedy` names that line as before, then gives `_read_host_key_fix`, #1108's two host-key fixes in a read's words, or else the login fix, then the same re-run. `_host_key_refused` is shared with `_host_key_remedy`, which behaves as before. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Reviewer's GuideThe PR improves Sequence diagram for host-key-aware seeded-leg re-run diagnosticssequenceDiagram
participant R as Re-run
participant S as _held_seed
participant G as git ls-remote
participant E as _exists_remedy
participant H as _host_key_refused
participant F as _read_host_key_fix
R->>S: only_commit(url, scratch)
S->>G: read seeded leg
G-->>S: CommandFailed(output)
S-->>E: failed output and command
E->>H: classify complete output
alt changed host key
H-->>E: changed
E->>F: _read_host_key_fix(output)
F-->>E: verify fingerprint, remove old key, keep checked key
else unknown host key
H-->>E: unknown
E->>F: _read_host_key_fix(output)
F-->>E: HOST_KEY_CHECK and published fingerprints
else other refused read
H-->>E: no host-key refusal
E-->>R: existing login remediation
end
E-->>R: scaffold-remote-exists and re-run instructions
Flow diagram for classifying a refused git readflowchart TD
A[git ls-remote fails] --> B["_host_key_refused(output)"]
B -->|changed| C[Check fingerprint from a repeated git ls-remote]
C --> D[Only then remove old key and keep new key with HOST_KEY_CHECK]
B -->|unknown| E[Run HOST_KEY_CHECK and accept only a GitHub-published fingerprint]
B -->|no host-key refusal| F[Use existing git login remediation]
D --> G[Keep scaffold-remote-exists and re-run guidance]
E --> G
F --> G
File-Level Changes
Possibly linked issues
Tips and commandsInteracting with Sourcery
Customizing Your ExperienceAccess your dashboard to:
Getting Help
|
|
(lane openRepoShape-3, 2026-10-22T15:38:56Z) RV1156, independent review of #1156 at 49bfb99: NOT READY for one SHOULD only. No MUST. SHOULD S1 - text the server sends can still steer the read into the host-key fix. scaffold-project.py:2367 (_host_key_refused, used for the read at :893). Measured with real git 2.43: a fake "ssh" standing for a server already reached answers git ls-remote with an ERR packet; git dies with "fatal: remote error: <msg>" and keeps the newlines in it, so no line starts with "remote:". With msg "see the log\n@ WARNING: REMOTE HOST IDENTIFICATION HAS CHANGED! @\nHost key verification failed." the head gives the changed-key fix; with "see the log\nHost key verification failed." or the one line "Host key verification failed." it gives the unknown-key fix. The parent gave "Let git read it" for all three. The outcome is only the safer remedy, and only a host ssh or TLS already verified can send it, but it fails the rule that server text must not steer the branch. Fix, proven in my clone: said = _without_remote_lines(output).split("fatal: remote error:", 1)[0] (git dies right after that line, so all from it on is the server's; where ssh refused the key no server was reached). Add two rows to test_what_a_leg_git_could_not_read_is_told (tests/test_scaffold_reuse_seeded_legs.py:1894-1905), each expecting the login fix: "fatal: remote error: see the log\n@ WARNING: REMOTE HOST IDENTIFICATION HAS CHANGED! @\nHost key verification failed." and "fatal: remote error: Host key verification failed.". The head fails both (2 failed); with the fix the whole reuse file passes (134). Reword the comments at :2365-2366 and :2177-2180: stderr the far side writes through ssh's channel arrives with no prefix and cannot be told apart (not measured), but only a host ssh already verified can write it, and it leads only to the safe remedy. NITs:
Verified:
Lane 3's plan: S1 goes into a fix round in the next usage window, as RV1156 proved it (the Lane: openreposhape-3 (openRepoShape-3) |
… steers a re-run's read into the host-key fix, and the read's fix names "the host git reached for that read", so it holds for `only_commit`'s `git fetch` as for its `git ls-remote` (#1137) RV1156's S1. git dies on a server's error packet with `fatal: remote error: <message>`, keeps the message's newlines and prefixes none of its lines (measured, git 2.43, over a fake ssh that sends the packet), so `_without_remote_lines` kept all of it, and a message that said `Host key verification failed.` gave a read the unknown-key or changed-key fix. `_host_key_refused` now cuts from `fatal: remote error:` on: the server's words, and where ssh itself refused the key no server answered. Two rows of `test_what_a_leg_git_could_not_read_is_told` expect the login fix for such a message, and fail without the cut. The comments on `HOST_KEY_FAILED` and `_host_key_refused` no longer say `remote:` alone separates the server's lines: stderr the far side writes through ssh's channel arrives unprefixed too (not measured; from how OpenSSH relays a remote command's stderr), but only a host ssh already verified can write it, and it leads only to a fix that keeps no key before its fingerprint is checked against GitHub's. RV1156's N1. The refused command can be `only_commit`'s `git fetch`, so the read's fix says "the host git reached for that read" and "when you run `git ls-remote` of that URL again", words that hold for either command. The two literals in the reuse test follow. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
|
(lane openRepoShape-3, 2026-10-22T17:41:42Z) RV1156 delta review of #1156 at f914e38: READY once CI's tests job passes on f914e38 (in progress at 17:40Z). No MUST, no SHOULD; S1, N1 and N2 are fixed. NIT N3 (optional) - scaffold-project.py:2177-2179 says "git prefixes each line the server sent with Note: f914e38's trailer says Claude Sonnet 5.5 and 49bfb99's says Opus 5.5; set the one you want in the explicit squash message. Verified:
Lane 3's plan: #1156 is READY once Lane: openreposhape-3 (openRepoShape-3) |
…arent's own files Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
|
|
Sorry @brettheap, you've used your own review budget of 250,000 diff characters for the last 7 days. You can request another review in 3 hours and 58 minutes by commenting |



What changed
A follow-up outside v1.2.0 (Brett Heap, 2026-10-21T10:32:04Z). It lands only after the v1.2.0 tag, on a later merge word.
Stacked on #1130: its base is #1130's branch. Once #1130 lands, lane 3 brings main into this branch and moves its base to main.
A re-run's read that ssh refused for the host key is told that key's fix, not "Let git read it"
A
--reuse-empty-repore-run over a leg with commits asks git (git ls-remote) whether the leg's one commit is the seed. Where aurl.<base>.insteadOfrule sends that read over ssh and ssh refuses GitHub's host key, the run is still refused asscaffold-remote-exists, naming the same reason line. In place of the login fix ("Let git read it", and anssh -Tthat says whether GitHub takes your key), it now gives #1130's host-key fix, in a read's words, and then the same re-run sentence:BatchMode=yes,StrictHostKeyChecking=yes, or answered no): keep it first, once you know it is GitHub's, withHOST_KEY_CHECK(ssh -T -o BatchMode=no -o StrictHostKeyChecking=ask, both options first), and answer yes only to a fingerprint GitHub publishes (GITHUB_FINGERPRINTS).git ls-remoteof that URL again is one GitHub publishes. Only then remove the old key (thessh-keygen -Rline ssh prints with it) and keep the new one (HOST_KEY_CHECK), answering yes only to a fingerprint GitHub publishes, which can be another of its keys than the one you checked.Security, as on #1130. A changed key is never kept before its fingerprint is checked against GitHub's published list. Each
ssh -Tis answered yes only to a published fingerprint.Decisions the Direction left open
unreadcarries ssh's host-key words". Measured at scaffold-project.py: a push ssh refused the host's key for names how to check that key against GitHub's fingerprints, not the ruleset hint, and the hint on GitHub says what its re-run can still wait at (#1108) #1130's head,unread(_unread_because, one line) isHost key verification failed.for an unknown key and a changed key alike: only the lines before it tell them apart. So_held_seednow returns git's refused read itself (theCommandFailed), and_exists_remedynames its one line as before and reads the whole output for the fix. Mutant h below proves the one line is not enough._host_key_refused(output)returns"changed","unknown"or"", skippingremote:lines with_without_remote_linesand cutting all from a server'sfatal: remote error:on (the fix round)._host_key_remedy(the push's) now uses it too, and behaves as before: its tests are unchanged and pass._read_host_key_fix):only_commit'sgit fetch, RV1156's N1);insteadOfrule only, as nopushInsteadOfrule touches a read;git ls-remoteof that URL again", not "printed above". The refusal prints only git's one line. Measured: thatgit ls-remote, run again in a terminal withoutBatchMode, printed ssh's banner, theSHA256:fingerprint and thessh-keygen -Rline, then exited 128 without asking, and known_hosts was unchanged.scaffold-remote-existsis kept, and the docs do not quote this remedy.Fix round: RV1156
RV1156's review, posted on this PR, found one SHOULD (S1) and two NITs (N1, N2). This round changes the code and its tests for S1 and N1, and the body for N2. Nothing else in the diff moved.
git ls-remotewith anERRpacket (run again here at49bfb99): git dies onfatal: remote error: <message>, keeps the message's newlines and putsremote:on none of its lines, so_without_remote_lineskept all of it._host_key_refusedgavechangedfor a message holding the changed-key banner andHost key verification failed., andunknownfor the other two messages, so the read was given a host-key fix. Nowsaid = _without_remote_lines(output).split("fatal: remote error:", 1)[0]: all from that line on is the server's. The same three messages give""and the login fix. Where ssh itself refused the key, no server answered, so the cut hides none of ssh's lines.test_what_a_leg_git_could_not_read_is_told, each expecting the login fix. They FAIL without the cut (2 failed, 7 passed) and PASS with it (9 passed).HOST_KEY_FAILEDand_host_key_refusedclaim as measured only what was measured, the error packet. Stderr the far side writes through ssh's channel would reach git unprefixed too and cannot be told from ssh's own lines. That is NOT measured: it follows from how OpenSSH relays a remote command's stderr. Only a host ssh has already verified can write it, and it leads only to a fix that keeps no key before its fingerprint is checked against GitHub's.only_commit'sgit fetch. The read's fix now says "the host git reached for that read" and "when you rungit ls-remoteof that URL again". The two literals in the reuse test follow. No other file pins them (git grep).At the fix round's head
f914e38, each file alone, under a stub gh:tests/test_scaffold_reuse_seeded_legs.py134 passed,tests/test_ruleset_hint_tracking_branch.py23 passed,tests/test_cli_reference.py23 passed,tests/test_readme_sections.py76 passed,tests/test_repo_hygiene.py100 passed,tests/test_agents_md_codes.py47 passed.Mutants, each against the whole reuse file and
cmp-equal after:_unread_because)split("fatal: remote error:", 1)[0]droppedgit merge-tree --write-tree origin/main f914e38, with main at038dafb, is clean (tree47a44f9). Checked out as a detached commit in my clone,tests/test_path_guard_hygiene.pygave 49 passed and the reuse file 134 passed. The fix round adds nois_file,is_dir,exists,is_symlinkoros.pathguard.Measurements
Each case ran in a pseudo-terminal, with real OpenSSH 9.6p1 behind a copy of W915's fake sshd (
Host *isProxyCommand /bin/false), under a stub gh,GH_TOKEN=bogusand an emptyGH_CONFIG_DIR. Each run is a--reuse-empty-repore-run over a seeded leg that git reads through aninsteadOfrule.dee55dc49bfb99BatchMode=yesBatchMode=yesPermission denied (publickey).In every case,
gh repo createwas called 0 times.Tests
Each file ran alone at
49bfb99, under a stub gh first on PATH, withGH_TOKEN=bogus, an emptyGH_CONFIG_DIR,GIT_SSH_COMMAND=falseandGIT_CONFIG_GLOBAL=/dev/null:tests/test_scaffold_reuse_seeded_legs.pytests/test_ruleset_hint_tracking_branch.pytests/test_cli_reference.pytests/test_readme_sections.pytests/test_repo_hygiene.pytests/test_agents_md_codes.pytests/test_update_add_codes_documented.pytests/test_no_auto_maintenance.pytests/test_dry_run_asks_remotes.pytests/test_reuse_visibility.pyThe new tests:
test_a_leg_whose_read_ssh_refused_the_host_key_for_names_the_key: end to end with a fake ssh, for an unknown key, one checked strictly, and a changed key.test_what_a_leg_git_could_not_read_is_told: in-process, 9 rows: three host-key cases, one amongremote:lines,remote:lines alone, a refused ssh key, no username, and two of a server's error packet (the fix round).test_each_re_run_remedy_says_the_prompts_it_can_still_wait_at. The test now passes aCommandFailed.No other file pins
_exists_remedy's text (git grep).Path-guard hygiene.
git merge-tree --write-tree origin/main 49bfb99, with main at038dafb, is clean (tree2273e68). Checked out as a detached commit in my clone,tests/test_path_guard_hygiene.pygave 49 passed. The diff adds nois_file,is_dir,exists,is_symlinkoros.pathguard.Mutants
Each ran against the whole reuse file. The file was restored and
cmp-equal afterwards._exists_remedynever calls_read_host_key_fix(always the login fix)HOST_KEY_CHECKdropped from the unknown-key fix_host_key_refusedreadsremote:lines too_unread_because) in place of its whole outputLeft out of scope
REVOKED HOST KEY DETECTED) gets the unknown-key fix. That is safe, because ssh always refuses a revoked key.LogLevel=QUIETandLogLevel=FATAL, as scaffold-project.py: a push ssh refused the host's key for names how to check that key against GitHub's fingerprints, not the ruleset hint, and the hint on GitHub says what its re-run can still wait at (#1108) #1130 documents. QUIET prints neither phrase, so the read gets the login fix. FATAL drops the CHANGED banner, so a changed key gets the unknown-key fix, which is safe becausessh -Trefuses a changed key again.Closes #1137
Lane: openreposhape-3 (openRepoShape-3)
🤖 Generated with Claude Code
Summary by Sourcery
Give reuse re-runs safe, host-key-specific remediation when GitHub reads are refused over SSH while retaining the existing login guidance for other access failures.
New Features:
Bug Fixes:
Enhancements:
Tests: