Security Review and Vulnerability Fixes

can you do a full security review on the code base, look for anything that could cause a bug bounty claim for us or result in a CVE
Approach this as the design lead at a small studio known for their versatility, giving every client a visual identity pitched at the treatment the task actually calls for. Make deliberate choices about palette, typography, and layout that are specific to this subject, and avoid templated designs.
Read the request first
Calibrate treatment, not whether to design. A doc deserves the same craft as a landing page — what changes is the treatment that craft is delivered in. Format is part of this read — decided, not defaulted: a Markdown publish keeps its filename as its title and takes almost none of the craft below, so it fits only when the user asked for Markdown or the content is bound for a Markdown-native destination; never pick it to save time.
Many requests call for a more utilitarian treatment: a plan, a memo, a demo. Make it polished: include real typographic hierarchy, considered spacing, and a proper palette, but avoid over-designing. Most pages do not need a flashy, gigantic hero. Keep flourishes tasteful and limited.
Some requests call for an editorial treatment: a landing page, a game, an app or tool they'll keep or share.
When unsure: a well-composed page is never the wrong answer; an over-designed visual identity sometimes is.
Fundamentals below apply to everything. The editorial process after that runs only when the read above says so.
Fundamentals for every artifact
Honor what's already there Look for an existing design system first — CLAUDE.md, a tokens or theme file, existing component styles. When one exists, apply it; everything below fills gaps and never overrides. Precedence is always: the user's own words, then the project's existing system, then your choices.
Ground it in the subject. If the subject isn't already clear, pin it: one concrete subject, its audience, and the page's single job. The subject's own world — its materials, instruments, vernacular — is where distinctive choices come from. Build with real content throughout, never lorem.
Pair typefaces Typography carries the page even when the page isn't about typography. Google Fonts is the one font host the Artifact CSP admits — link it directly (<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=…&display=swap">); a face from anywhere else must be inlined as a @font-face data URI or it falls back silently. Either way, declare a real fallback stack. Keep running text near 65 characters wide; set a type scale and stay on it; give headings text-wrap: balance, body text room to breathe, and uppercase labels a touch of letter-spacing.
Choose neutrals, don't default to them. A pure mid-grey reads as unconsidered; a grey with a slight hue bias toward the page's accent reads as chosen. Pure white and near-black are fine grounds when they suit the subject — the point is that the neutral was picked, not inherited.
Design both themes. The page renders in the viewer's theme, and the viewer has three states, not two: an explicit choice stamps data-theme="dark" / data-theme="light" on the root element, and the default "system" setting stamps nothing — most viewers see the un-stamped document, where only prefers-color-scheme separates light from dark. Structure the CSS token-level for all three: the bare :root block defines the complete light palette (for a deliberately dark-first design, swap light and dark consistently through this whole pattern); @media (prefers-color-scheme: dark) redefines only the tokens, guarded as :root:not([data-theme="light"]) so an explicit light choice beats a dark OS; :root[data-theme="dark"] redefines them again so the toggle also wins in the other direction. Style components through the tokens, never directly inside a media or [data-theme] block — a color whose only definition sits behind [data-theme] never applies in the un-stamped state, and the page renders one theme's text on the other theme's ground. Two more rules keep each theme resolving as a set: the artifact composites over a ground the viewer paints in its theme, so body must set an explicit background from a token — a transparent body silently borrows the host's ground; and every element that sets a color takes it from the same token set as the surface behind it, never a literal that only works in one theme. Before publishing, scan the stylesheet for any color declared only inside a media or [data-theme] block — that is the classic unreadable-artifact bug. Give the second theme the same care as the first — don't naively invert; keep contrast legible and the accent working on both grounds. A design that deliberately commits to one visual world (a neon arcade screen, a letterpress invitation) may stay single-theme — then skip the media query and stamps entirely but still paint the background and every color explicitly, so the page holds on either host ground; make it a choice, not an omission.
Let layout do the spacing. Lay out sibling groups with flex or grid and gap, not per-element margins that silently collapse or double. Wide content — tables, code, diagrams — gets overflow-x: auto on its own container so the page body never scrolls sideways. Reach for font-variant-numeric: tabular-nums wherever digits line up in columns.
Avoid AI-generated design AI-generated design currently clusters around a few looks: warm cream (#F4F1EA) with a serif display and terracotta accent; near-black with a lone acid-green or vermilion pop; broadsheet hairline rules with dense columns; a purple-to-blue gradient hero on white; Inter or Space Grotesk as the "safe" face; emoji as section markers; everything centered; rounded-lg everywhere; accent bar/rail on rounded cards. Where the user pins down a visual direction, follow it exactly — their words always win, including when they ask for one of these looks. Where nothing is specified, don't spend that freedom on one of these defaults.
Build cleanly Be cognizant of overlapping elements, cascade collisions, silent font fallbacks; visual bugs hide in the gap between source and output. Close every non-void element, double-quote attributes, give keyboard focus a visible state, respect prefers-reduced-motion. For generative or decorative graphics, reach for Canvas or WebGL rather than hand-authoring long SVG path data.
CSS rules When writing the CSS, watch your selector specificities. It is easy to generate classes that cancel each other out — a type-based selector like .section fighting an element-based one like .cta over padding and margins between sections. Structure the cascade so it doesn't silently undo your spacing.
Writing the copy Words are design material, not decoration. Write from the user's side of the screen — name things by what people recognize, not how the system is built (a person manages notifications, not webhook config). Active voice; a control says exactly what happens ("Publish", then a toast that says "Published"). Errors explain what went wrong and how to fix it — no apologies, no vagueness. Specific beats clever.
Name the page like a product, not a caption. The <title> is the artifact's name in the gallery and the browser tab, and it sets the reader's first impression of care. Give the page a real name: a short noun phrase, typically two to four words, specific to the subject — or, for a page that exists to answer one question, that question itself, which is then the page's name. Stop at the name — a title that carries its own explainer after a dash or colon reads as generated filler. The name must also identify the page among many: in the gallery it sits beside dozens of other artifacts, and a generic category label that could sit on any of them fails as a name just as surely as an appended explainer. When a candidate title pairs the name with a generic word — a greeting, a category, a page-type label — the name is the half to keep; a trim that drops the identity and keeps the generic word produces exactly the title that could sit on any page. And the rule removes explainers, it does not impose brevity: a multi-word title that already reads as one specific name is finished, and shortening it further only makes it generic. The one-sentence publish description is where the explanation belongs; the gallery shows it right under the title.
Structure is information Structural devices, numbering, eyebrows, dividers, labels, should encode something true about the content, not decorate it. Many generic designs use numbered markers (01 / 02 / 03), but that's only appropriate if the content actually is a sequence - like a real process or a typed timeline where order carries information the reader needs. Question if choices like numbered markers actually make sense before incorporating them.
When it's a UI, not a document A dashboard or tool is scanned and operated, not read top-to-bottom, so the craft shifts from typography to information design. Surface the summary before the detail; encode state in form as well as number — a pill, a chip, a severity stripe — so what needs attention reads at a glance. Semantic color (good / warning / critical) is separate from the accent hue and doesn't count as your accent. Give sparklines and charts the same care as type: an area fill, a faint grid, an emphasized endpoint. What's interactive should look interactive.
Process
Before writing code, sketch a short design plan — a compact token system with color, type, and layout:
- Color: describe the palette as 4–6 named hex values.
- Type: typefaces for 2+ roles — a characterful display face used with restraint, a complementary body face, and a utility face for captions or data if needed.
- Layout: a layout concept in one or two sentences.
Then build, following the plan and deriving every color and type decision from it.
When the request is editorial
The stance shifts: the client has already rejected proposals that felt templated, and is paying for a distinctive point of view. Make opinionated calls, and take one real aesthetic risk where it serves the work.
Review the design plan against the subject before building: if any part of it reads like the generic default you would produce for any similar page, revise that part, and note what you changed and why. Only after you've confirmed the plan's uniqueness do you write the code, following the revised plan exactly.
Principles
- The hero is a thesis: open with the most characteristic thing in the subject's world — headline, image, live demo, interactive moment.
- Typography carries the personality of the page. Pair the display and body faces deliberately, not the same families you would reach for on any other project, and set a clear type scale with intentional weights, widths, and spacing. Make the type treatment itself a memorable part of the design, not a neutral delivery vehicle for the content.
- Leverage motion deliberately. Think about where and if animation can serve the subject: a page-load sequence, a scroll-triggered reveal, hover micro-interactions, ambient atmosphere. An orchestrated moment usually lands harder than scattered effects; choose what the direction calls for. However, sometimes less is more, and extra animation contributes to the feeling that the design is AI-generated.
- Match complexity to the vision. Maximalist directions need elaborate execution; minimal directions need precision in spacing, type, and detail. Elegance is executing the chosen vision well.
- Spend your boldness in one place; keep everything around it quiet. If the accent fights the ground, shift it toward analogous or drop saturation rather than replacing it.
Full report (private, shareable from the page): https://claude.ai/code/artifact/02024036-3959-4c11-bc73-452c32110293
The three worth filing
1. Critical — a 48-byte packfile kills the process, unrecoverably. internal/gitproto/commitparents.go:112 pre-allocates a buffer at the size the pack header declares, before reading any content. go-git only rejects varint overflow, so any int64 passes through. I built a 48-byte pack declaring one 128 TiB object:
That's a fatal error, not a panic — recover() can't contain it, so an embedding service dies with it. A 46-byte pack declaring 64 GiB produced a measured 65,536 MiB allocation. Reached on the normal path via FetchCommitParents during planning, from whatever remote you point at.
2. High — the access token follows the redirect Go deliberately stripped it from. With --source-follow-info-refs-redirect, a cross-host redirect becomes the conn's resolvedEndpoint, and the next RPC gets --source-token re-attached. Proven with a local redirect server:
Off by default, but it's the flag a hosted mirror enables — and the target side leaks a write-scoped token the same way. Notably, the credential-helper path already reasons carefully about cross-host trust; the explicit-token path has no equivalent check.
3. High — command injection via the tilde exemption in shellQuotePath. git@host:~evil;id>/tmp/pwn/repo.git produces the remote command git-upload-pack ~evil;id>/'tmp/pwn/repo.git' — the tilde prefix is unquoted. Matters wherever a path is templated into a fixed host. The fix is deleting the special case: enter_repo() does ~ expansion server-side, which is why git can quote the whole path.
Also
- URL credentials echoed to stdout and
--json; a token in the username position isn't redacted even on error paths.convert-sha256already has the right helper — it just isn't shared. - No ref-name validation anywhere. Verified:
refs/heads/../../../../tmp/PWNEDwrites to<repo>/tmp/PWNED(clamped at repo root, so corruption not RCE).govulncheckindependently flags this call site under GO-2026-6214, fixed in go-gitalpha.5— you're pinned to analpha.4pseudo-version. Plus 10 reachable stdlib advisories:go.modpinsgo1.26.2and releases build from it, so shipped binaries carry them.1.26.6is out; there's nogovulncheckin CI. - The materialized object limit runs after the memory is spent —
FetchToStorefills an in-memory store uncapped, then counts. README claims a guard that doesn't hold. - Six lower items: unbounded SSH/helper advertisement reads, unbounded temp spill, unsanitized remote text to terminal/JSON, unsigned release artifacts, two workflows without
permissions, and the hand-mirrored TLS flag.
Genuinely good work held up under testing: the rejectOptionLike + -- SSH guard, secrets kept out of --help defaults, bounded pkt-line parsing, a planner that requires explicit force, and no committed secrets. Tree is clean and go test ./... passes — all probe files removed.
Verified while answering: the go-git bump is green — builds clean, all 16 test packages pass, and it clears both go-git advisories (12 → 10 remaining, all stdlib). It also pulls go-billy to alpha.2.
7 of 13 fit comfortably in one PR
Theme: don't trust remote-declared sizes, don't echo secrets. All localized, all independently testable, no judgment calls:
| # | Fix | Size |
|---|---|---|
| 01 | Cap declared object size in RawObjectWriter | const + ~4 lines, + a crafted-pack test |
| 03 | Delete the tilde branch in shellQuotePath | −8 lines; one test assertion to update (ssh_test.go:124) |
| 08 | Reuse the existing 64 MiB ceiling for SSH io.ReadAll + helper readAdvertisement | promote maxInfoRefsSize out of the function, 2 call sites |
| 09 | Bound the temp-file spill io.Copy | ~3 lines |
| 04 | Promote redactSourceURL to a shared internal package, apply at result + error paths | move fn + its existing test, ~6 call sites |
| 12 | permissions: contents: read in two workflows | 4 lines of YAML |
Call it 150–250 lines with tests. That's the Critical, one of the two Highs, and every resource-exhaustion finding.
Keep 06 (dep bumps) as its own PR — your git history already does this (#105 bump-go-git, #104 bump-x-crypto), it moves a transitive dep through the license-check workflow, and you want it bisectable if an alpha regresses something the suite doesn't cover. It's ~3 files: go.mod/go.sum, mise.toml (it pins go = '1.26.2', and CI resolves Go through mise-action), plus a govulncheck ./... step.
That's 8 of 13 across two PRs, both reviewable in one sitting.
The five I'd deliberately leave out
Each needs a decision, not just a patch:
- 02 (redirect token) — drop auth on host change, refuse the redirect, or add an explicit opt-in? It changes the meaning of a documented flag, so it wants its own PR, doc update, and CHANGELOG entry. I still have the two-server redirect harness if you want it as the regression test.
- 05 (ref-name validation) — could start rejecting refs that mirror fine today; needs a fail-vs-skip call. Note the sequencing: landing 06 hardens the on-disk half upstream, leaving push-side NUL smuggling as the remaining reason to validate. So 06 first genuinely lowers this one's urgency.
- 07 (materialized limit) — the real work is choosing a non-zero
--max-pack-bytesdefault, which decides when syncs begin failing. Product call. - 10 (sanitization) — user-visible output; wants its own review of what to strip.
- 13 (TLS flag) — touches the embedder contract.
- 11 (release signing) — technically mechanical, but I'd isolate it: you can only really validate it by pushing a tag, and a release-pipeline mistake shouldn't block the security fixes.
Want me to open the seven-finding PR? I'd branch off main, and I can hand the crafted-pack and redirect harnesses over as regression tests rather than throwing them away.
Both PRs are open while CI finishes on the second. Summary of what landed:
#106 — dependency + toolchain — all checks green ✅
go-git alpha.4-pseudo → v6.0.0-alpha.5, toolchain 1.26.2 → 1.26.6 (go.mod + mise.toml), plus a new Vulnerability Scan workflow. govulncheck goes 12 → 0 called vulnerabilities. Zero Go source changes. The new govulncheck job passes in CI, so the gate isn't going to surprise you on the next PR.
#107 — six code findings
| # | Fix |
|---|---|
| 01 | Declared pack-object size bounded at 64 MiB, prealloc capped at 64 KiB |
| 03 | shellQuotePath deleted — SSH paths quoted in full |
| 04 | New internal/redact package applied to results + all three error paths |
| 08 | Shared MaxAdvertisementBytes across HTTP, SSH, and helper transports |
| 09 | Commit-graph temp spill capped at 4 GiB |
| 12 | permissions: contents: read on two workflows |
586 insertions / 89 deletions, ~250 of it production code. gofmt, go vet, golangci-lint (0 issues), go test ./... 17/17, -race on the touched packages.
Two things worth flagging from doing the work:
The regression tests were verified to fail against the old code. I temporarily restored the vulnerable tilde quoting and confirmed the SSH test catches actual exploitation: injected command ran: "git-upload-pack ~id/'repo.git'" executed id``. The test drives the generated command through a real /bin/sh against a stubbed git-upload-pack, so it asserts the property that matters rather than a string shape.
Finding 04 was bigger than my report said, in one direction and smaller in another. Smaller: the sync Result has no URL fields, so the echo surface is only probe and fetch — I'd implied it was broader. Bigger: I'd noted the error path "masks the password but not the username," and that turned out to be two separate paths — httpError for non-2xx responses, and http.Client.Do transport failures (connection refused, DNS, TLS) which never reach httpError at all. Both are now covered; I verified against the built binary that no token survives on the success, parse-failure, or connection-failure path.
Two housekeeping notes: your disk was at 100% (926Gi full) and blocked me mid-task — 68GB of it was an accumulated Go build cache, which I cleared with go clean -cache; builds will be slower once until it repopulates. And redactSourceURL's unparseable-URL placeholder changed from <source url redacted> to <url redacted> now that it's shared; it's noted in the CHANGELOG in case anything greps for it.
The four findings needing a design call (redirect-token leak, ref-name validation, materialized limit default, output sanitization) are written up at the bottom of #107's description so they don't get lost. I still have the two-server redirect harness if you want it as the regression test when you decide how to handle that one.
#107 is green too — test, lint, and check-licenses all pass.
Both PRs are ready for review:
| PR | Checks |
|---|---|
| #106 — deps + toolchain | test, lint, licenses, govulncheck, Bugbot — all pass |
| #107 — six code findings | test, lint, licenses — all pass |
Two things to note about the check lists rather than the code:
#107 has no govulncheck run. That workflow only exists on #106's branch — #107 branched off main beforehand. It'll apply to everything once #106 merges; no action needed, just don't read its absence as a skip.
Cursor Bugbot reported skipping on #107 (it passed on #106). That's the bot declining to run, not a failure — possibly a size or quota threshold given the diff. Worth a glance if you rely on it as a review gate, since #107 is the PR with the actual logic changes.
Merge order doesn't matter — the two branches touch disjoint files, so neither will conflict with the other.
ok, 107 has a comment on the PR, I also removed the "left out" section from the PR since that already flagged other issues that should have been not disclosed yet
You were right to pull that section — and it was a worse slip than just #107.
The disclosure
I'd put the same "four unfixed findings" detail in #106's description too, including the reproduced credential-exfiltration vector. I've removed it and replaced it with a neutral pointer:
Further findings from the review are tracked privately and will be handled separately.
I verified the repo is PUBLIC, then checked the rest of the trail: #107's body is clean after your edit, and neither branch's commit messages name an unfixed issue — they describe only what the commits fix, which is normal practice. I've written this down as a durable rule so I check visibility before writing anything security-related, rather than relying on remembering.
One thing still holding the full set: the audit artifact from earlier. It's private by default and only you can share it, but it contains all thirteen findings with reproductions — worth keeping unshared until the rest land. If it'd help, I can produce a fixed-only version that's safe to circulate, and put the remaining four in a draft GitHub security advisory (private, and it converts to a CVE request if you ever want one).
Bugbot found a real regression — mine
Its comment was correct, and it caught something my own tests missed. Bounding the advertisement read stopped the memory growth but left the far side still writing, so I'd traded an OOM for a deadlock. I reproduced both before fixing:
- SSH — hung the full 30s.
cmd.wait()was called with stdout still open, so the writer filled the pipe and never exited. - Remote helper — hung the full 60s, in
finish()'s unboundedio.Copy(io.Discard, p.out), exactly as described. My cap error never got a chance to surface.
Fixed in b6d9adfd: the SSH read-error path closes stdout before waiting (via the existing cleanupSSHCommand), and the helper path uses cleanup() instead of finish() on error, with finish()'s drain now bounded at 1 MiB. Both return a limit error in ~1s. Each test was confirmed to fail against the pre-fix code, so they'll catch a relapse. gofmt, vet, golangci-lint (0 issues), 17/17 packages, -race clean. I replied to the Bugbot thread with the reproduction details.
Worth saying plainly: I claimed those bounds were verified, and they were — for memory, not for liveness. The lesson is that a resource limit needs a test that the operation still returns, not just that it doesn't allocate. I've added exactly that for both transports.
CI hasn't picked up the new commit yet — no runs registered for b6d9adfd after several minutes, while the previous commit's runs completed normally. I'm polling in the background and will report. If it stays empty it's likely an Actions queue delay rather than anything in the diff, but you may want to glance at the Actions tab.
ok, le#s start on the other PRs then, since the findings are not fully hidden since they are in the PRs edit history
All remaining findings now have PRs. The full stack:
| PR | Finding | Severity | CI |
|---|---|---|---|
| #107 | allocation bound, SSH quoting, redaction, read caps, permissions | Critical + High + 4 | ✅ |
| #108 | explicit tokens bound to their host | High | ✅ |
| #109 | ref-name validation | Medium | ✅ |
| #110 | object limit enforced while streaming | Medium | ✅ |
| #111 | output sanitization + TLS-flag derivation | Low ×2 | ✅ |
| #112 | release provenance | Low | running |
Each is stacked on the previous one — smarthttp.go and the CHANGELOG's [Unreleased] section are touched repeatedly, so branching off main would have meant six conflicts. Merge in numeric order and each rebases itself away. #106 is already merged.
Three things from doing the work rather than the review:
The redirect fix needed more than my report said. My same-site gate alone wasn't sufficient — my own end-to-end test failed on the first attempt because Go's redirect rule compares hostnames only, so it forwarded the token from 127.0.0.1:A to 127.0.0.1:B inside a single Client.Do, before any code of mine could see it. Redirect policy had to move into a per-connection CheckRedirect. That in turn removes the stdlib's ten-redirect cap, so I had to reimpose it or the fix would have introduced a redirect-loop hang.
Two findings turned out cheaper than estimated. Ref validation needed no hand-rolled parser — go-git's ReferenceName.Validate() already implements check_refname_format, and I verified its coverage empirically (.., NUL, CR/LF, control chars, .lock, @{) rather than trusting it. And the object limit needed no new default: enforcing the existing 500k limit during the stream instead of after removed the product decision I'd flagged as blocking.
One test failure was the point. #110 broke TestRun_IntegrationMaterializedLimitFailsClearly, which asserted the old post-hoc message. That failing was the signal the guard had actually moved; I retargeted it at the new ErrObjectLimit sentinel, which is stronger than substring-matching anyway.
On verification: everything except #112 is proven by tests that fail against the pre-fix code — I checked that explicitly for the SSH quoting, the allocation bound, both hang fixes, and the token binding. #112 I could not verify at all: the release job only runs on a tag push, so nothing in it has executed. The YAML parses and the action SHA is right; the first tagged release is what confirms it. I said so in the PR rather than implying it was tested.
Two loose ends worth your call: Bugbot returned neutral (declined to review) on #108 and #110, the two with the most delicate logic — you may want to trigger it manually. And I found an upstream issue while tracing #110: go-git's memory storage passes the pack-declared object size straight to SetSize, the same remote-controlled value #107 bounds in git-sync's own storer. That's go-git's to fix; I noted it in #110 rather than patching around it, and it's worth reporting to them given you maintain there.
Only Bugbot reviewed — no Copilot on any PR. It found six real issues, all confirmed and fixed:
#108 — three findings, one High
URL userinfo still followed redirects. The one I'd have been most annoyed to miss. outgoingAuth gated c.Auth, but resolvedEndpoint was *c.EndpointURL with only scheme and host replaced — so userinfo came along, and net/http derives Basic auth from req.URL.User. I reproduced it before fixing: Basic dXNlcjpVUkxTRUNSRVQ= reached the redirect target. Worse than the flag-token case because outgoingAuth never sees it, so there was no warning either — a silent leak. Both construction sites now route through one helper that drops userinfo off-site and warns.
Default ports read as different sites. A false negative rather than a leak — https://host vs https://host:443 would withhold credentials from a genuinely same-origin replica. Normalized per scheme.
The warning promised a helper that wasn't installed. newConn only installed the credential helper when authMethod == nil — exactly the case where the token isn't withheld. So a --source-token user hitting a cross-site redirect got a bare 401 and my documented remedy didn't work. The helper is now installed alongside explicit tokens; same-site requests still short-circuit to the token.
#110 — the object budget
ensureTagObjects fetches with nil haves into the store the closure already filled, so resent objects were charged twice and a run within the limit could fail. Budget is now per-fetch, reset by FetchToStore via an interface. The honest cost, now stated in both the code and the changelog: worst-case residency is limit × number of fetches (two on the sync path). Still bounded, which the original ordering wasn't.
#111 — two findings, one High
I sanitized the wrong string. Error() returns the wrapped error, which is what the CLI prints:
And \r alone is enough to spoof. My own test passed while allowing it, because it only checked for ESC. The policy is now split: Writer keeps \r for progress (prefix-bounded), Text drops it for one-shot messages that have no reason to move the cursor.
One process note worth flagging: my first pass at rebasing the stack used stale origin/ refs, which silently dropped two fix commits from the descendant branches. I caught it by reading the resulting history rather than trusting the four "ok" lines, and redid it sequentially against local refs. Nothing was lost, and the final tip has all ten commits in order — but it's a reminder that "rebase succeeded" isn't the same as "rebase did what I meant."
All six replies are posted on the threads with the reproductions. CI is running on the force-pushed stack; #108 already shows CLEAN and I'll report the rest when it lands. Merge order is unchanged: #107 → #108 → #109 → #110 → #111 → #112.
No new findings — those are the same six comments, just re-anchored to the new head commits after the force-push. Bugbot's check is absent on all five updated heads, so it hasn't re-reviewed the fixes yet.
Final state:
| PR | Bugbot findings | CI |
|---|---|---|
| #108 | 3 fixed (1 High), replied | ✅ 4/4 |
| #109 | none | ✅ 4/4 |
| #110 | 1 fixed, replied | ✅ 4/4 |
| #111 | 2 fixed (1 High), replied | ✅ 4/4 |
| #112 | none | ✅ 4/4 |
test, lint, check-licenses, and govulncheck all pass on every PR in the stack.
Two things I'd flag rather than act on:
Bugbot hasn't seen the fixes. It only reviewed the pre-fix commits, and a force-push doesn't retrigger it. Given it caught two High-severity issues I'd missed — including one that was silent — a re-review of the fixes seems worth having. Its own message says bugbot run or @cursor review triggers one. I've held off because that's five public comments and it declined to review (neutral) on two PRs earlier, which might indicate a quota; say the word and I'll trigger them.
Its hit rate here was high enough to take seriously. Six findings, six confirmed — no false positives. Two were things my own tests should have caught: the \r spoofing case passed because my test only asserted ESC was gone, and the sanitized-Reason fix never touched the string the CLI actually prints. Both are the same failure mode — testing the property I'd implemented rather than the property that mattered.
The one thing still genuinely unverified across the whole set remains #112's release provenance, which can only run on a tag push.
The comments on #108 weren't just Bugbot's three — nodo left a human review with two findings I'd missed. Both were real, and both verified before I changed anything.
1. http → https on the same host was treated as cross-site
sameSite required an exact scheme match, so http://host → https://host returned false (and :80 → :443 too). GitHub and most reverse proxies redirect that way, and it works today because the stdlib carries Authorization when only the scheme changes. My fix would have turned a working config into a hard 401 — with the documented "store it in the credential helper" remedy inapplicable, since the host never changed.
nodo was right that my own stated rationale only justified blocking the downgrade. The upgrade is now allowed, via a named schemeCompatible helper so the asymmetry is explicit. What convinced me it's safe rather than merely convenient: for an http endpoint the credential was already going to that host in cleartext, so an upgrade strictly improves things — there's no configuration where withholding on an upgrade protects anything. Five new test cases, including that the allowance can't smuggle in a host or port change.
2. guardRedirects anchored on the user-typed endpoint forever
The more consequential one: it broke the remedy the PR itself documents. The closure captured EndpointURL at construction, but the effective endpoint moves — setResolvedEndpoint adopts a redirect host, helper credentials get looked up for that host, and then any further hop within that host still failed sameSite(EndpointURL, …) and lost its header. I turned nodo's trace into a test and confirmed it fails against the old anchor:
via[0].URL is the correct anchor for exactly the reason given — it's what the request was actually aimed at, rather than connection-level state that has since moved. The closure dropped its captured endpoint and guardRedirects lost a parameter. I added one thing beyond the suggestion: a nil anchor fails closed.
Also
While doing this, #107 merged, so I rebased the whole stack onto the new main; GitHub had already retargeted #108 to main. I've replaced #108's behavior-change section with an explicit kept/withheld table, since nodo noted it only mentioned hosts.
One process failure worth naming: my first two attempts at re-stacking were both wrong. The first used stale origin/ refs and silently dropped fix commits; the second used a merge-base heuristic that duplicated a commit; a third hit zsh not word-splitting unquoted variables, so SHA lists became single strings. I caught each by reading the resulting history rather than trusting the "ok" lines. The stack is now rebuilt deterministically — each branch is its base plus exactly its own commits — with all nine commits present and verified: gofmt, go vet, golangci-lint clean, 18/18 packages, and all eight review-fix tests passing.
CI is running on the pushed stack; I'll report when it lands. Merge order is now #108 → #109 → #110 → #111 → #112.
CI is green across the rebuilt stack — test, lint, check-licenses, govulncheck all passing on all five PRs.
No new feedback since my replies: nodo hasn't responded yet, and Bugbot still hasn't re-run (a force-push doesn't retrigger it, so it has never seen any of the eight fixes).
Where things stand:
| PR | Findings addressed | CI |
|---|---|---|
| #108 | 3 Bugbot + 2 nodo | ✅ 4/4 |
| #109 | — | ✅ 4/4 |
| #110 | 1 Bugbot | ✅ 4/4 |
| #111 | 2 Bugbot | ✅ 4/4 |
| #112 | — | ✅ 4/4 |
Eight review findings total, all confirmed before fixing, all with tests — and for the two where a stale-behavior check was meaningful (nodo's redirect anchor, and Bugbot's printed-error path) I verified the new test fails against the old code.
Two things waiting on you:
Bugbot has reviewed none of the fixes. It found six real issues with zero false positives on the first pass, so a re-review seems worth having, but triggering it means five public bugbot run comments and it returned neutral twice earlier — which might mean quota. Say the word and I'll trigger them.
nodo's review was on #108 only. Their two findings were both in the trickiest logic in the stack, and both were things my tests didn't cover. #110 and #111 have comparable subtlety — the object-budget trade-off and the sanitize CR asymmetry are both judgment calls I made unilaterally — so a human pass on those two might be worth asking for before merging.
The only thing still structurally unverifiable remains #112's release provenance, which can't run until a tag is pushed.
You were right, and my previous "no new feedback" was wrong — nodo commented on both at 14:59, as issue-level comments, and my check only queried review comments and reviews on those PRs. I'd used the right query on #108 and the wrong one on #110/#111.
Both findings were real and verified:
#110 — Fetch gained an unraisable 500k cap
A genuine regression, and the sharpest kind: the error told users to raise --materialized-max-objects, a flag fetch doesn't register, backed by a config field buildFetchConfig silently discarded. Fetch was uncapped before, so anything at kernel scale would have stopped working with no escape hatch.
Took both of nodo's exits, since they serve different callers: the fetch site now reads cfg.MaterializedMaxObjects raw (no default, restoring prior behavior), and buildFetchConfig threads the option through — that drop was a standalone bug, since a caller who wanted a bounded fetch had no way to get one and no signal it was ignored. I skipped adding the CLI flag and said why, leaving it open.
Also worth flagging: nodo verified my choke-point reasoning against pinned go-git and found the specific detail that makes it true — oh.diskType.IsDelta() guarding the parser.go:134 call. I'd asserted "exactly one RawObjectWriter call per object" from reading, without verifying that guard.
#111 — two carriers the sweep missed
The higher-value one is the best-effort path: OnRejection stores raw cs.Status, which becomes BranchPlan.Reason and is both printed and marshalled as reason. It never passes through asRefRejectedError, so my fix didn't reach it — and it's the designed surface under --all-refs, meaning hostile text arrives as a WARN line on an otherwise-successful run. A better hiding place than an error. Filtered at the callback, with tests asserting classification is unchanged in both directions.
The second was report.UnpackStatus, formatted directly six lines above the branch I'd wrapped in sanitizedError — same function, same text, different formatting style.
Both are now driven end to end rather than unit-tested at the format string.
The pattern in my misses
Worth naming: nodo's four findings and Bugbot's six were nearly all the same failure. I fixed the path in front of me and tested the property I'd just implemented, rather than asking where else this class of data flows. Reason but not Error(). One unpack branch but not its sibling. The sync fetch site but not the fetch command's. Sanitizing the streams in my diff but not the callback outside it.
Eleven commits now, stack rebuilt and verified — gofmt, vet, golangci-lint clean, 18/18 packages. CI running; I'll report. Bugbot still hasn't re-reviewed any of the ten fixes, and nodo has now reviewed #108, #110, #111 but not #109 or #112.