fix(gl): sign the visibility-gated client reads so a repo owner can read their own private repo (#115) - #392
Conversation
…ead their own private repo (#115) `gl pr list/view/diff/comments` and `gl repo commits` loaded the caller's keypair, used it only to derive the owner string, then built NodeClient with None and sent an unsigned GET. Those routes are gated by authorize_repo_read, which denies an anonymous caller with a 404, so a private repo's own owner was told the repo had no pull requests and no commits. The six MCP read arms had the same shape while already holding the keypair. pr.rs now passes Some(keypair) at the four sites and calls get_maybe_signed. repo.rs cmd_commits builds the client with load_keypair_from_dir(..).ok() so `owner/name` against a public repo still works with no identity on disk. mcp.rs switches repo_get, repo_commits, pr_list, pr_view (both fetches) and pr_diff to get_maybe_signed. This is the pattern PR #113 established for the subset of routes it gated. Tests: the existing happy-path mocks in pr.rs and repo.rs now assert the signature and signature-input headers, following the protect.rs pattern; cmd_diff gets its first test; a new test pins that the anonymous public-repo path stays unsigned. Reverting the production hunks turns 7 tests red. Full gl suite 365 passed, fmt and clippy clean. DO NOT MERGE BEFORE #186. Review found two ordering hazards, both verified by execution. First, this regresses public-repo reads on its own. These call sites sent no signature before, so the node's require_signature was unreachable for them. Now that they always sign, a client whose clock is more than 300s off gets a 400 clock_skew, and since no changed call site checks the response status, that renders as an empty list with exit 0. A public repo the user could always read reports itself empty. #186 adds the status check these lines need underneath them, which turns that failure into an error the user can act on. Second, #186 rewrites these same expressions and its replacement keeps `client.get(`, so if #186 merges after this branch it silently reverts the signing. Land #186 first and rebase this on top. Known gaps left for the follow-up, none of them fixed here: the MCP arms have no signing test and reverting all six leaves the suite green; MCP owner/repo arguments are interpolated into the path unvalidated, so a signed request can be aimed at another owner-gated route; the signed path is not percent-encoded, so a non-ASCII branch or a repo argument containing a space signs a string the node cannot reproduce; `.ok()` cannot tell a missing identity from an unreadable one; and repo_list, repo_tree, git_refs and gl status remain unsigned against the same class of route. Refs #115
|
Important Draft PR not reviewedDraft PRs are not automatically reviewed by default.
To automatically review draft PRs, update your CodeRabbit configuration: reviews:
auto_review:
drafts: trueComment |
jatmn
left a comment
There was a problem hiding this comment.
I found issues that need to be addressed before this is ready.
The signing hunks themselves look correct for the primary #115 paths: CLI PR commands derive owner from the loaded keypair and now call get_maybe_signed, and cmd_commits preserves the anonymous public-repo path via load_keypair_from_dir(..).ok(). The items below are what still block merge-ready status.
Merge readiness
-
[P1] Rebase onto the current
#186branch tip and resolve thepr.rsconflict deliberately
crates/gl/src/pr.rs
GitHub still reports this draft PR asCONFLICTING/DIRTY. The three-dot merge-base isfba90038, but the live#186tip is31e78f60(443 lines ahead, including morepr.rstests andcmd_viewsub-fetch degradation). A naive conflict resolution that keeps this branch’s olderpr.rswould drop#186behavior even though the signing diff itself is fine against its recorded base.Root cause: stacked branch fell behind its declared base while both PRs edited the same file.
What to do:
- Wait for or rebase onto current
fix/issue-123-client-read-status-check(31e78f60). - In the conflict, keep both layers:
#186:read_jsonstatus-before-parse (already on your base) and live-tipcmd_viewsoft-fail for denied reviews/comments sub-fetches (matchblocks +cmd_view_continues_when_*tests).#392:NodeClient::new(..., Some(keypair))/get_maybe_signedon the gated read call sites.
- Re-run
cargo test -p gl --bin gl pr::testson the resolved tree, not the pre-rebase head. - Push and confirm GitHub is no longer
CONFLICTING.
- Wait for or rebase onto current
-
[P1] Preserve the
#186→#392landing order ontomain
PR body / commit message
This branch is draft until#186lands. The signing change is only safe relative to#186because these call sites already route throughread_json; without that layer, clock-skew and other non-2xx bodies can still render as empty success on signed reads.Root cause:
#115(identity) and#123/#186(status-check) fix different halves of the same failure; this PR depends on the second half being onmainfirst.What to do: merge
#186first, then rebase this branch ontomain(or onto the post-#186base) and re-review the resolved diff before undrafting.
Findings
-
[P2] Restore the removed
gl pr list/gl pr viewdenial regression tests
crates/gl/src/pr.rs(tests section, formerlycmd_list_surfaces_denial_not_emptyandcmd_view_surfaces_denial_not_stub)
Relative to merge-basefba90038, this PR deletes two#123guards that were added on the stacked#186base. Productioncmd_listandcmd_viewstill callread_jsontoday, so behavior may still be correct — but a future revert to unsignedclient.getor parse-before-status on just those two commands would no longer fail CI. Onlycmd_diff/cmd_commentsdenial tests remain on head.Root cause: test-module edit churn while adding signature header mocks and
test_cmd_diff_signs_request; the deletions look accidental, not an intentional scope change (production handlers were not simplified).What to do:
- Restore both tests from the stacked base verbatim, then update their mocks for the new contract:
- keep identity via
write_identity(&dir); - add
.match_header("signature", mockito::Matcher::Any)and.match_header("signature-input", mockito::Matcher::Any)because these commands now always sign when identity is present; - keep the 404 body and
assert!(result.is_err(), …)plus_m.assert_async()so the test still proves the mocked route was hit.
- keep identity via
- Confirm revert-check: temporarily change
cmd_listback toNodeClient::new(&node, None)+client.getand verify these two tests fail.
- Restore both tests from the stacked base verbatim, then update their mocks for the new contract:
-
[P2] Align MCP owner default with the signing keypair on the six changed read tools (or require owner explicitly)
crates/gl/src/mcp.rs:695-897,crates/gl/src/mcp.rs:1313-1319; contrastwebhook_listatmcp.rs:971-985andtest_webhook_list_default_owner_is_keypair_not_node_did
This PR correctly switchesrepo_get,repo_commits,pr_list,pr_view, andpr_difftoget_maybe_signed, which fixes#115when the caller supplies a matchingowner(all current MCP tests do). However, those six arms still default the path owner viaresolve_owner(), which falls back to the node root DID fromGET /, whilecall_toolsigns with the caller's keypair. That mismatch predates this PR for unsigned calls, but signing makes it sharper: a signed request can hit/api/v1/repos/{node_short}/…with a signature whosekeyidis the user's DID. The same file already documents and fixes the opposite pattern forwebhook_list— whenowneris omitted on an owner-gated signed route, default to the keypair short DID, not the node DID.Root cause: owner-gated MCP reads need the path owner segment and the signer identity to refer to the same principal;
resolve_owner()was written for node-scoped defaults before these routes were signed.What to do (pick one consistent approach):
- Recommended (matches
webhook_list): for the six changed tools only, whenargs["owner"]is absent and a keypair is present, default owner to the keypair short DID; keepresolve_owner()for unsigned / no-identity paths. Add a test mirroringtest_webhook_list_default_owner_is_keypair_not_node_didfor at leastrepo_getorpr_list. - Alternative: make
ownerrequired in the MCP schema for these tools and fail client-side with a clear error when omitted; document that private-repo reads must pass owner explicitly. Test both the required-owner error and the signed happy path.
Do not change
get_maybe_signeditself or the anonymous public-repo behavior incmd_commits. - Recommended (matches
-
[P2] Add signing regression tests for the six MCP read arms
crates/gl/src/mcp.rs:695-897; pattern attest_webhook_list_via_mcp_signs_the_request(mcp.rs:1911-1943)
You noted in the PR body that reverting all six MCP hunks leaves the suite green. That is accurate: existing MCP denial tests call withdir: None, so they exercise the unsigned path only. CLI/repo tests in this PR added signature header guards; MCP did not.Root cause: mutation tests were added for CLI/repo signing in this PR but not extended to the MCP surface, even though MCP is part of the
#115claim.What to do:
- For each of
repo_get,repo_commits,pr_list,pr_view(both GETs if feasible), andpr_diff, add one happy-path test with a temp identity dir and mocks that requiresignature+signature-input, followingtest_webhook_list_via_mcp_signs_the_request. - Optionally add one combined revert-check script comment in the PR (like the existing
cmd_listrevert instructions) showing that reverting one MCP arm fails its new test. - Keep the existing unsigned denial tests — they still validate
#186error surfacing when no identity is present.
- For each of
-
[P3] Tighten
test_cmd_comments_with_resultsto assert signing
crates/gl/src/pr.rs:820-845
In the same PR edit,test_cmd_comments_emptyand the list/view happy-path mocks gainedsignature/signature-inputmatchers, buttest_cmd_comments_with_resultsdid not. Becausecmd_commentsnow usesNodeClient::new(..., Some(keypair))andget_maybe_signed, an unsigned regression would still pass this test.Root cause: incomplete application of the new signing test convention across sibling tests in one module.
What to do: copy the two
.match_header(...)lines fromtest_cmd_comments_emptyonto this mock; no production change needed. -
[P3] Restore
test_cmd_merge_successor drop the deletion from this PR
crates/gl/src/pr.rs(tests section)
Merge-base includestest_cmd_merge_success; head deletes it whilecmd_mergeproduction code is untouched. This is unrelated to#115signing.Root cause: same test-section churn as the denial-test deletions — coverage removed without a corresponding production change.
What to do: restore the test from merge-base unchanged, or if there is a deliberate reason to remove it, say so in the PR body and point to replacement coverage elsewhere.
Notes on items intentionally not raised as blockers
These were considered and left out of the findings list because they are pre-existing, author-documented, or out of this PR's scoped intent:
- MCP path injection / percent-encoding gaps (PR body "Known gaps")
.ok()oncmd_commitsidentity load conflating missing vs corrupt PEM (documented; unchanged by signing hunk)- Unsigned sibling routes (
repo_list,repo_tree,git_refs, etc.) — explicitly out of scope @authorityreplay on read signatures — pre-existinghttp_sig.rsseam, noted in protocol section
Stacked on #186. Base is
fix/issue-123-client-read-status-check, so this diff is the signing change alone. Draft until #186 lands.Summary
Visibility-gated
gland MCP read commands sent unsigned requests, so a private repo's own owner was told it had no pull requests and no commits. They now present the caller's identity.Motivation & context
Closes #115
gl pr list/view/diff/commentsandgl repo commitsloaded the caller's keypair, used it only to derive the owner segment, then builtNodeClientwithNoneand sent an unsigned GET. Those routes are gated byauthorize_repo_read, which denies an anonymous caller with a 404. The client then parsed the error body without checking status, so the denial rendered as an empty list. The six MCP read arms had the same shape while already holding the keypair.#186 fixes the rendering half. This fixes the identity half, and it has to sit on top rather than beside: these call sites sent no signature at all before, so the node's
require_signaturewas unreachable for them. Now that they always sign, a client whose clock is more than 300s off gets a 400clock_skew, and without a status check above the parse that renders as an empty list with exit 0. A public repo the user could always read would report itself empty. Merging the signing first would trade one silent wrong answer for another, on a larger population. The reverse order matters too: #186 rewrites these same expressions and its replacement keepsclient.get(, so if it merged after this branch it would silently revert the signing.Kind of change
What changed
Crate touched:
gl.crates/gl/src/pr.rs:cmd_list,cmd_view,cmd_diffandcmd_commentspassSome(keypair)toNodeClientand callget_maybe_signed. The keypair was already loaded there with?, so it was always available.crates/gl/src/repo.rs:cmd_commitsbuilds its client withload_keypair_from_dir(dir.as_deref()).ok()and callsget_maybe_signed, soowner/nameagainst a public repo still works with no identity on disk.crates/gl/src/mcp.rs:repo_get,repo_commits,pr_list,pr_view(both fetches) andpr_diffswitch toget_maybe_signed. That client already held the keypair.signatureandsignature-input, followingprotect.rs;cmd_diffgains its first test; a new test pins that the anonymous public-repo path sends no signature headers.get_maybe_signedoverget_signedis deliberate: these routes read anonymously for public repos, andget_signedwould turn a working identity-less public read into an error.How a reviewer can verify
Node side, traced to confirm this does more than change which error you get:
optional_signatureinjects the DID, each handler lifts it intocaller,authorize_repo_readpasses it tovisibility_check, and the owner is allowed on its first branch. The short key in the path versus the full DID in the signaturekeyidis absorbed bydid_matches.Before you request review
cargo test --workspacepasses locally (via the pre-push hook, which runs it;cargo test -p gl --bin glis 460 passed on this branch)cargo fmt --allandcargo clippy --workspace --all-targets -- -D warningsare cleanfeat(...),fix(...),docs(...)).env.exampleupdated if behavior or config changed (or N/A). N/A: no config or documented behavior changes.Protocol & signing impact
did:key, Ed25519 / RFC 9421 signatures, UCAN, ref certs, or P2P wire formatsThese routes already accept a signature through
optional_signature, so an updated client talking to an older node is unaffected; the node treats the added headers exactly as it does for the routesglalready signed. No wire format changes, no new component in the signing base, no change to how anything is verified.Notes for reviewers
Known gaps, stated rather than left for the next reader:
owner/repoarguments are interpolated into the path unvalidated. Now that these arms sign, an argument containing a literal/can aim a signed request at another owner-gated route under/api/v1/repos/. Worth fixing before the MCP half is relied on.--branch, or a repo argument containing a space, signs a string the node cannot reproduce. The seam is inget_maybe_signed, not in any one call site..ok()cannot distinguish a missing identity from an unreadable one, so a corruptidentity.pemsilently downgrades to anonymous.repo_list,repo_tree,git_refsandgl statusremain unsigned against the same class of route.@method,@pathandcontent-digestbut not@authority, so a signature handed to a hostile--nodeis replayable elsewhere within the 300s window.