Security Review and Vulnerability Fixes

Claude Code·Opus 5.[1m]·Soph·2w ago·28hr 53min·14 Checkpoints·260 file changes·+8313/-536·303.6K tokens

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

2w ago·13m·72 calls

I have a complete picture. Let me prepare the report deliverable.

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.
2w ago·5m·6 calls

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-sha256 already has the right helper — it just isn't shared.
  • No ref-name validation anywhere. Verified: refs/heads/../../../../tmp/PWNED writes to <repo>/tmp/PWNED (clamped at repo root, so corruption not RCE). govulncheck independently flags this call site under GO-2026-6214, fixed in go-git alpha.5 — you're pinned to an alpha.4 pseudo-version. Plus 10 reachable stdlib advisories: go.mod pins go1.26.2 and releases build from it, so shipped binaries carry them. 1.26.6 is out; there's no govulncheck in CI.
  • The materialized object limit runs after the memory is spentFetchToStore fills 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.

how much of those could we do in one PR?

2w ago·2m·5 calls

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:

#FixSize
01Cap declared object size in RawObjectWriterconst + ~4 lines, + a crafted-pack test
03Delete the tilde branch in shellQuotePath−8 lines; one test assertion to update (ssh_test.go:124)
08Reuse the existing 64 MiB ceiling for SSH io.ReadAll + helper readAdvertisementpromote maxInfoRefsSize out of the function, 2 call sites
09Bound the temp-file spill io.Copy~3 lines
04Promote redactSourceURL to a shared internal package, apply at result + error pathsmove fn + its existing test, ~6 call sites
12permissions: contents: read in two workflows4 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-bytes default, 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.

ok, do those two you can just do

2w ago·23m·72 calls

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
01Declared pack-object size bounded at 64 MiB, prealloc capped at 64 KiB
03shellQuotePath deleted — SSH paths quoted in full
04New internal/redact package applied to results + all three error paths
08Shared MaxAdvertisementBytes across HTTP, SSH, and helper transports
09Commit-graph temp spill capped at 4 GiB
12permissions: 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.

867b2f4Bump go-git to alpha.5 and Go to 1.26.6, add govulncheck to CI govulncheck reported 12 vulnerabilities reachable from git-sync's own call graph. This clears all of them. go-git v6.0.0-alpha.5 fixes two advisories we call into: - GO-2026-6214, path traversal via crafted reference names, reached where convert-sha256 writes advertised refs to disk via SetReference. Refs come from the source advertisement, so the input is remote-controlled. - GO-2026-6213, worktree operations following symlinks. Pulls go-billy to v6.0.0-alpha.2 transitively. No new modules, and both are already-allowed Apache-2.0. Toolchain 1.26.6 clears ten standard-library advisories reachable from the HTTP transport and the convert-sha256 filesystem paths, including quadratic complexity in net/url resolvePath, an os root escape via symlink plus trailing slash, and an HTTP/2 infinite loop on a bad SETTINGS_MAX_FRAME_SIZE. Released binaries build from the go.mod toolchain, so they shipped with these. mise.toml is bumped in step, as its comment asks. The new Vulnerability Scan workflow gates this going forward. The existing lint suite cannot see this class of issue. It runs on PRs and pushes plus a weekly schedule, because advisories are published against versions already in go.mod — without the schedule a fresh disclosure goes unreported until someone opens a PR. Symbol-level scan, so an advisory in an unreachable corner of a dependency does not block the queue. Verified: go build ./... and go test ./... green under the new toolchain (16/16 packages), go mod tidy clean, and `govulncheck ./...` now reports 0 called vulnerabilities. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Entire-Checkpoint: 01M0CWQ7YES0F862AV9KZPAVAZ+51/-8
4a69ed9Bound remote-declared sizes, quote SSH paths, redact URL credentials Six findings from a security review of the codebase. Each is localized and independently testable; the four that need a design call are not here. 1. A packfile header can no longer request an unbounded allocation. ExtractCommitParents sized its per-object buffer from the size the pack header declares, before reading any content. go-git validates only that the size varint does not overflow, so the value is remote-controlled up to int64: a 48-byte pack declaring 128 TiB produced `fatal error: out of memory`, a runtime fatal rather than a panic, so an embedding process could not contain it with recover(). A 46-byte pack declaring 64 GiB allocated 64 GiB. Now rejected above 64 MiB — this path reads tree:0-filtered packs, whose objects are commits and tags measured in kilobytes — and otherwise used only to size the initial buffer, capped at 64 KiB, so allocation tracks bytes actually received. Reachable from ordinary planning: FetchCommitParents runs against the source for ancestry checks. 2. SSH repository paths are shell-quoted in full. The remote command string is interpreted by the remote login shell, and shellQuotePath exempted everything before the first slash when the path began with "~". A SCP-style URL puts attacker-influenceable text exactly there: git@host:~a;id/repo.git produced `git-upload-pack ~a;id/'repo.git'`, running `id` on the remote host as the authenticated SSH user. This matters wherever only part of the URL is caller-controlled — a path templated into a fixed host, or one arriving from a webhook. Quoting is free: git-upload-pack resolves the path through enter_repo(), which interpolates a leading "~" itself, which is why canonical git quotes the whole path. Tests assert the generated command against a real /bin/sh and confirm no injected command runs. 3. Credentials embedded in a remote URL are no longer echoed. probe and fetch carried Source.URL/Target.URL verbatim into results that print to stdout and serialize into --json, so the standard CI form https://user:token@host/repo.git put the token where automation collects it. Parse failures leaked it too (url.Parse embeds the raw URL in its error), as did transport failures — net/http masks only the password, leaving a token in the username position, the form GitHub App and PAT URLs use. New internal/redact package strips the whole userinfo; httpError moves off url.URL.Redacted() for the same reason. This generalizes the helper convert-sha256 already had. 4. Advertisement reads are bounded on every transport. HTTP capped /info/refs at 64 MiB; SSH used a bare io.ReadAll and the remote helper accumulated pkt-lines until a flush a remote need never send. All three now share MaxAdvertisementBytes. 5. The commit-graph pack spill to a temp file is capped at 4 GiB. It was unbounded, so a source could fill the disk during what is only a planning round trip. 6. `permissions: contents: read` on the Tests and License Check workflows, which inherited the repository default while running PR-controlled code. lint.yml already had it. Defence in depth — GitHub restricts the token for fork PRs already — but same-repo branches are not restricted. Verified the reusable license workflow only checks out and runs a mise task, so read is sufficient. Verified: gofmt, go vet, golangci-lint (0 issues), go test ./... (17/17 packages), and -race on the touched packages. The SSH and allocation tests were confirmed to fail against the pre-fix code. Credential redaction was checked end to end against the built CLI on the success, parse-failure, and connection-failure paths. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Entire-Checkpoint: 01M0CXHDFSB6FPV2AN0R6WNHRG+586/-89
<task-notification> <task-id>bllafh5lq</task-id> <tool-use-id>REDACTED</tool-use-id> <output-file>/private/tmp/claude-501/-Users-soph-Work-entire-devenv-git-sync/9f1c8ebe-1a05-4632-9630-d373eac3e09b/tasks/bllafh5lq.output</output-file> <status>completed</status> <summary>Background command "Poll PR 107 CI to completion" completed (exit code 0)</summary> </task-notification>
2w ago·15.9s·1 call

#107 is green tootest, lint, and check-licenses all pass.

Both PRs are ready for review:

PRChecks
#106 — deps + toolchaintest, lint, licenses, govulncheck, Bugbot — all pass
#107 — six code findingstest, 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

2w ago·11m·36 calls

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 unbounded io.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.

b6d9adfTear down the producer when an advertisement hits its size limit Bugbot caught a regression in the previous commit: bounding the advertisement read stopped the in-memory growth but left the process on the other end still writing, so the limit turned an out-of-memory into a hang. Both transports were affected. SSH: requestInfoRefsWithCommand called cmd.wait() with stdout still open. A remote that keeps writing fills the pipe, blocks in write(), and never exits, so Wait never returns. Now the read-error path goes through cleanupSSHCommand, which closes stdout first so the write side fails and the process can exit. The resulting non-zero exit is a consequence of that teardown rather than the cause, so the read error stays the reported failure, with ssh's stderr still attached. Remote helper: RequestInfoRefs called finish() unconditionally, and finish() drained remaining stdout into io.Discard with no bound — so an endless advertisement hung in the drain instead of returning the limit error. The read-error path now uses cleanup() (which closes stdout) instead, and finish()'s drain is bounded at 1 MiB, closing the pipe if output continues past that, so the success path cannot hang either. Both are covered by tests that run the operation in a goroutine and fail if it does not return: each was confirmed to hang for the full timeout against the pre-fix code (30s and 60s respectively) and to return a limit error in about a second after it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Entire-Checkpoint: 01M0D0SS9HH9AVZME1XB200NVJ+111/-9

ok, le#s start on the other PRs then, since the findings are not fully hidden since they are in the PRs edit history

2w ago·36m·91 calls

All remaining findings now have PRs. The full stack:

PRFindingSeverityCI
#107allocation bound, SSH quoting, redaction, read caps, permissionsCritical + High + 4
#108explicit tokens bound to their hostHigh
#109ref-name validationMedium
#110object limit enforced while streamingMedium
#111output sanitization + TLS-flag derivationLow ×2
#112release provenanceLowrunning

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.

86b555dTear down the producer when an advertisement hits its size limit Bugbot caught a regression in the previous commit: bounding the advertisement read stopped the in-memory growth but left the process on the other end still writing, so the limit turned an out-of-memory into a hang. Both transports were affected. SSH: requestInfoRefsWithCommand called cmd.wait() with stdout still open. A remote that keeps writing fills the pipe, blocks in write(), and never exits, so Wait never returns. Now the read-error path goes through cleanupSSHCommand, which closes stdout first so the write side fails and the process can exit. The resulting non-zero exit is a consequence of that teardown rather than the cause, so the read error stays the reported failure, with ssh's stderr still attached. Remote helper: RequestInfoRefs called finish() unconditionally, and finish() drained remaining stdout into io.Discard with no bound — so an endless advertisement hung in the drain instead of returning the limit error. The read-error path now uses cleanup() (which closes stdout) instead, and finish()'s drain is bounded at 1 MiB, closing the pipe if output continues past that, so the success path cannot hang either. Both are covered by tests that run the operation in a goroutine and fail if it does not return: each was confirmed to hang for the full timeout against the pre-fix code (30s and 60s respectively) and to return a limit error in about a second after it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Entire-Checkpoint: 01M0D0SS9HH9AVZME1XB200NVJ+111/-9
d4f12ffBound remote-declared sizes, quote SSH paths, redact URL credentials Six findings from a security review of the codebase. Each is localized and independently testable; the four that need a design call are not here. 1. A packfile header can no longer request an unbounded allocation. ExtractCommitParents sized its per-object buffer from the size the pack header declares, before reading any content. go-git validates only that the size varint does not overflow, so the value is remote-controlled up to int64: a 48-byte pack declaring 128 TiB produced `fatal error: out of memory`, a runtime fatal rather than a panic, so an embedding process could not contain it with recover(). A 46-byte pack declaring 64 GiB allocated 64 GiB. Now rejected above 64 MiB — this path reads tree:0-filtered packs, whose objects are commits and tags measured in kilobytes — and otherwise used only to size the initial buffer, capped at 64 KiB, so allocation tracks bytes actually received. Reachable from ordinary planning: FetchCommitParents runs against the source for ancestry checks. 2. SSH repository paths are shell-quoted in full. The remote command string is interpreted by the remote login shell, and shellQuotePath exempted everything before the first slash when the path began with "~". A SCP-style URL puts attacker-influenceable text exactly there: git@host:~a;id/repo.git produced `git-upload-pack ~a;id/'repo.git'`, running `id` on the remote host as the authenticated SSH user. This matters wherever only part of the URL is caller-controlled — a path templated into a fixed host, or one arriving from a webhook. Quoting is free: git-upload-pack resolves the path through enter_repo(), which interpolates a leading "~" itself, which is why canonical git quotes the whole path. Tests assert the generated command against a real /bin/sh and confirm no injected command runs. 3. Credentials embedded in a remote URL are no longer echoed. probe and fetch carried Source.URL/Target.URL verbatim into results that print to stdout and serialize into --json, so the standard CI form https://user:token@host/repo.git put the token where automation collects it. Parse failures leaked it too (url.Parse embeds the raw URL in its error), as did transport failures — net/http masks only the password, leaving a token in the username position, the form GitHub App and PAT URLs use. New internal/redact package strips the whole userinfo; httpError moves off url.URL.Redacted() for the same reason. This generalizes the helper convert-sha256 already had. 4. Advertisement reads are bounded on every transport. HTTP capped /info/refs at 64 MiB; SSH used a bare io.ReadAll and the remote helper accumulated pkt-lines until a flush a remote need never send. All three now share MaxAdvertisementBytes. 5. The commit-graph pack spill to a temp file is capped at 4 GiB. It was unbounded, so a source could fill the disk during what is only a planning round trip. 6. `permissions: contents: read` on the Tests and License Check workflows, which inherited the repository default while running PR-controlled code. lint.yml already had it. Defence in depth — GitHub restricts the token for fork PRs already — but same-repo branches are not restricted. Verified the reusable license workflow only checks out and runs a mise task, so read is sufficient. Verified: gofmt, go vet, golangci-lint (0 issues), go test ./... (17/17 packages), and -race on the touched packages. The SSH and allocation tests were confirmed to fail against the pre-fix code. Credential redaction was checked end to end against the built CLI on the success, parse-failure, and connection-failure paths. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Entire-Checkpoint: 01M0CXHDFSB6FPV2AN0R6WNHRG+584/-89
b0e0f88Bind explicit credentials to the host they were configured for With --source-follow-info-refs-redirect / --target-follow-info-refs-redirect, git-sync adopts the host /info/refs redirected to and addresses it directly for the follow-up RPCs — and re-attached the configured token to those requests, after Go's http.Client had deliberately stripped the Authorization header on the cross-host hop. Any redirect an attacker could cause collected the token: an open redirect on the real host, a hostile mirror, or a man-in-the-middle on a plain-http source. On the target side that token usually carries write access. An explicitly supplied credential — token flags, bearer flags, or userinfo in the URL — is now sent only to the host named on the command line and its subdomains, on the same scheme and port. Anything else gets nothing, plus a warning naming the withheld host, and the credential helper is consulted for that host instead. Credentials resolved *from* the helper are untouched: those are looked up keyed on the host actually being challenged, so they were already bound to their destination. The same-site rule is deliberately stricter than the stdlib's. Go compares hostnames only, ignoring scheme and port, so it will carry Authorization from host:443 to host:9999 — a different service, and on shared infrastructure potentially a different tenant. That gap is not visible from the request-building code either, because the redirect is followed inside a single http.Client.Do: by the time control returns the header is already on the wire. So redirect policy moves into a per-connection CheckRedirect that strips the header on any hop leaving the endpoint's site, layered over whatever policy the caller set. Setting CheckRedirect replaces the stdlib's ten-redirect default, so that cap is reimposed explicitly rather than lost. The subdomain allowance keeps the case the flag exists for working: a hosting replica under the same domain, example.com to replica.example.com, still authenticates. Tests cover the rule in isolation (including the near-misses: suffix without a dot boundary, attacker-controlled prefix domain, scheme downgrade, port change, IP addresses having no subdomains) and end to end against httptest servers, asserting the cross-site POST carries no credentials while a same-site redirect still does. Verified by hand too: the original two-server reproduction that leaked the token now logs an empty Authorization on both requests to the redirect target. docs/usage.md gains a "Credentials and redirects" section — the flags were previously documented only in their flag help. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Entire-Checkpoint: 01M0D278EFFYH2FZKTEA4VQKR6+392/-8
c75c359Validate ref names arriving from a remote Nothing checked the names a remote advertises, and git-sync acts on them in two ways that make a malformed one dangerous. convert-sha256 writes them to disk. go-git resolves a ref name through the repository filesystem, so "refs/heads/../../config" escapes refs/heads. go-billy clamps the traversal at the repository root, so this is repository corruption rather than arbitrary file write — but config, HEAD, and packed-refs are all in range, with the content constrained to an object hash. Ref-update commands embed them in the receive-pack request. receive-pack reads a feature list from everything after the first NUL on a command line, so a source advertising a name containing NUL can inject capabilities into the push git-sync sends to the target. Validation defers to plumbing.ReferenceName.Validate, which implements git's check_refname_format: "..", NUL, CR, LF, other control characters, DEL, space, "~^:?*[", backslash, "@{", a ".lock" suffix, a leading dot, empty components, and a leading dash on a branch or tag. Deferring rather than hand-rolling keeps git-sync's notion of a valid ref identical to git's, so nothing git considers legitimate gets skipped — verified against refs/pull/*, refs/notes/*, and nested branch names. It runs at the two decode boundaries (AdvRefsToSlice and decodeV2LSRefs) rather than at each call site, so every path that reads an advertisement is covered by construction, on both the source and target side. A skipped target ref is also one the planner never sees, so it is never picked as a prune candidate — the safe direction. Bad names are skipped with a warning rather than failing the run, matching how per-ref push rejections behave under BestEffort: one malformed ref upstream should not stop a mirror. The warning prints names with %q, because they are remote-controlled and an embedded escape sequence would otherwise reach the terminal; the individual list is capped while the count still covers everything skipped. --map values get the same validation but are rejected outright rather than skipped. They are configuration, and a mapped target ref reaches the same places an advertised one does, so failing at startup beats silently not mirroring what was asked for. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Entire-Checkpoint: 01M0D2HM0JN5QVYSWAPFZCSPNR+344/-26
382c861Enforce the materialized object limit while the pack streams FetchToStore filled the in-memory store with no cap. The object limit (--materialized-max-objects, default 500,000) was checked against the object closure once the fetch had already finished, so it reported an overrun rather than preventing one: by then the objects were resident and the process may have died getting there. The constant's own comment says "fail early rather than OOM", which the ordering did not deliver, and README's "guarded by an explicit object-count limit" overstated it. The store is now wrapped so the count is enforced as objects are decoded, failing the write that would exceed the limit. RawObjectWriter is the choke point: go-git's pack scanner routes every object through it, and memory storage's implementation only reaches SetEncodedObject via its own internal closer, which never passes back through the wrapper — so counting in both places would risk double-counting rather than add coverage. Reads pass straight through, leaving planning and the push path seeing the store exactly as before. No new default was needed. The streaming guard reuses the limit that was already configured and documented, so this changes when the existing limit bites, not what the limit is. Errors carry an ErrObjectLimit sentinel and an *ObjectLimitError holding the limit, so callers can branch without matching message text, and the message names the flag to raise. The closure check in the materialized strategy stays in place as a second line of defence for the case where a closure is large without the fetch itself exceeding the cap. The integration test that covered this asserted the old post-hoc message; it now asserts the sentinel plus an actionable message, which is both stronger and independent of wording. A new unit test decodes a 50-object pack into a store bounded at 10 and verifies the decode fails, the error carries the sentinel and limit, and the store admitted no more than the limit — the property the old ordering could not provide. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Entire-Checkpoint: 01M0D2W9V8DXHFAKZX62Z7D3KB+245/-4
7ec8c77Sanitize server-authored text and derive the TLS-verification state Two hardening items from the security review. Server-authored text reached terminals, logs and --json output unfiltered, on five paths: sideband progress, up to 64 KiB of HTTP error body, receive-pack "ng" rejection reasons, diagnostic response headers, and ssh's relayed "remote:" output. A hostile remote could embed an escape sequence to redraw the line its own message was printed on — a rejected push made to read like a successful one — or smuggle control characters into whatever ingests the JSON. All five now go through internal/sanitize, which drops everything below 0x20 plus DEL while keeping tab, newline and carriage return: git's in-place progress output depends on them, and the "source:"/"target:" line prefix already bounds how much a '\r' alone can hide. The single-byte C1 introducers are deliberately out of scope — in a UTF-8 stream they are not valid standalone bytes and terminals in UTF-8 mode do not act on them. Rejection classification still runs on the raw status, so filtering cannot change whether a rejection is treated as a concurrent move. The streaming filter reports the byte count it was given rather than the smaller number written, so a caller checking n against len(p) does not see a short write for text that was intentionally dropped. Separately, HTTPConn.InsecureSkipTLSVerify was documented as a field callers must set to match the client they pass in, and the cross-host credential guard reads it. A caller who disabled verification on their transport but forgot the field silently lost that protection — the wrong direction for a security check to fail. The transport is now inspected directly, following Unwrap through wrappers so an instrumentation layer cannot hide the setting; the syncer's counting round-tripper gained an Unwrap for exactly that. The walk is bounded, so a cyclic chain terminates rather than hanging. The explicit field still forces the guard on, for transports that cannot be inspected. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Entire-Checkpoint: 01M0D34RXV53B6KCSH2SK92DE4+359/-8

can you look at each PR if copilot/bugbot left comments

2w ago·15m·35 calls

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.

d3eb5b9Address review: userinfo, default ports, and the helper fallback Three findings from Bugbot on the previous commit, all confirmed. Credentials in the URL's userinfo were not bound to the endpoint's site. outgoingAuth gates c.Auth, but requests are built from requestURL().String(), and the resolved endpoint was a copy of EndpointURL with only scheme and host replaced — userinfo came along. net/http derives Basic auth from req.URL.User, so a cross-site redirect authenticated against the redirect host with no Authorization header of ours involved: outgoingAuth never saw it, so there was not even a warning. Reproduced against httptest servers, where "user:URLSECRET" reached the redirect target, and confirmed against the built binary. Resolved endpoints now drop userinfo when they leave the site, and warn when they do. This also makes the docs' claim that userinfo is bound like a flag token true rather than aspirational. sameSite compared ports literally, so "https://host" and "https://host:443" read as different sites. Proxies and load balancers do spell the default port out in a Location header, and treating that as cross-site would withhold credentials from a genuinely same-origin replica — a false negative that breaks working setups rather than leaking anything. Default ports are now normalized per scheme, with the default port for the *other* scheme still counting as a real port. The withheld-credentials warning promised a credential-helper lookup that could not happen: syncer.newConn installed the helper only when no explicit auth was configured, which is exactly the case where the token is now withheld. So a --source-token user hitting a cross-site redirect got a bare 401 and the documented remedy did not work. The helper is now installed alongside an explicit token — for same-site requests the token still wins, so the helper is only consulted where the token is deliberately not sent — and the warning names the real next step either way. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Entire-Checkpoint: 01M0D4P9499AAACJ0WNVWMQCR2+128/-15
5b52225Validate ref names arriving from a remote Nothing checked the names a remote advertises, and git-sync acts on them in two ways that make a malformed one dangerous. convert-sha256 writes them to disk. go-git resolves a ref name through the repository filesystem, so "refs/heads/../../config" escapes refs/heads. go-billy clamps the traversal at the repository root, so this is repository corruption rather than arbitrary file write — but config, HEAD, and packed-refs are all in range, with the content constrained to an object hash. Ref-update commands embed them in the receive-pack request. receive-pack reads a feature list from everything after the first NUL on a command line, so a source advertising a name containing NUL can inject capabilities into the push git-sync sends to the target. Validation defers to plumbing.ReferenceName.Validate, which implements git's check_refname_format: "..", NUL, CR, LF, other control characters, DEL, space, "~^:?*[", backslash, "@{", a ".lock" suffix, a leading dot, empty components, and a leading dash on a branch or tag. Deferring rather than hand-rolling keeps git-sync's notion of a valid ref identical to git's, so nothing git considers legitimate gets skipped — verified against refs/pull/*, refs/notes/*, and nested branch names. It runs at the two decode boundaries (AdvRefsToSlice and decodeV2LSRefs) rather than at each call site, so every path that reads an advertisement is covered by construction, on both the source and target side. A skipped target ref is also one the planner never sees, so it is never picked as a prune candidate — the safe direction. Bad names are skipped with a warning rather than failing the run, matching how per-ref push rejections behave under BestEffort: one malformed ref upstream should not stop a mirror. The warning prints names with %q, because they are remote-controlled and an embedded escape sequence would otherwise reach the terminal; the individual list is capped while the count still covers everything skipped. --map values get the same validation but are rejected outright rather than skipped. They are configuration, and a mapped target ref reaches the same places an advertised one does, so failing at startup beats silently not mirroring what was asked for. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Entire-Checkpoint: 01M0D2HM0JN5QVYSWAPFZCSPNR+344/-26
1922accEnforce the materialized object limit while the pack streams FetchToStore filled the in-memory store with no cap. The object limit (--materialized-max-objects, default 500,000) was checked against the object closure once the fetch had already finished, so it reported an overrun rather than preventing one: by then the objects were resident and the process may have died getting there. The constant's own comment says "fail early rather than OOM", which the ordering did not deliver, and README's "guarded by an explicit object-count limit" overstated it. The store is now wrapped so the count is enforced as objects are decoded, failing the write that would exceed the limit. RawObjectWriter is the choke point: go-git's pack scanner routes every object through it, and memory storage's implementation only reaches SetEncodedObject via its own internal closer, which never passes back through the wrapper — so counting in both places would risk double-counting rather than add coverage. Reads pass straight through, leaving planning and the push path seeing the store exactly as before. No new default was needed. The streaming guard reuses the limit that was already configured and documented, so this changes when the existing limit bites, not what the limit is. Errors carry an ErrObjectLimit sentinel and an *ObjectLimitError holding the limit, so callers can branch without matching message text, and the message names the flag to raise. The closure check in the materialized strategy stays in place as a second line of defence for the case where a closure is large without the fetch itself exceeding the cap. The integration test that covered this asserted the old post-hoc message; it now asserts the sentinel plus an actionable message, which is both stronger and independent of wording. A new unit test decodes a 50-object pack into a store bounded at 10 and verifies the decode fails, the error carries the sentinel and limit, and the store admitted no more than the limit — the property the old ordering could not provide. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Entire-Checkpoint: 01M0D2W9V8DXHFAKZX62Z7D3KB+245/-4
3300f40Sanitize server-authored text and derive the TLS-verification state Two hardening items from the security review. Server-authored text reached terminals, logs and --json output unfiltered, on five paths: sideband progress, up to 64 KiB of HTTP error body, receive-pack "ng" rejection reasons, diagnostic response headers, and ssh's relayed "remote:" output. A hostile remote could embed an escape sequence to redraw the line its own message was printed on — a rejected push made to read like a successful one — or smuggle control characters into whatever ingests the JSON. All five now go through internal/sanitize, which drops everything below 0x20 plus DEL while keeping tab, newline and carriage return: git's in-place progress output depends on them, and the "source:"/"target:" line prefix already bounds how much a '\r' alone can hide. The single-byte C1 introducers are deliberately out of scope — in a UTF-8 stream they are not valid standalone bytes and terminals in UTF-8 mode do not act on them. Rejection classification still runs on the raw status, so filtering cannot change whether a rejection is treated as a concurrent move. The streaming filter reports the byte count it was given rather than the smaller number written, so a caller checking n against len(p) does not see a short write for text that was intentionally dropped. Separately, HTTPConn.InsecureSkipTLSVerify was documented as a field callers must set to match the client they pass in, and the cross-host credential guard reads it. A caller who disabled verification on their transport but forgot the field silently lost that protection — the wrong direction for a security check to fail. The transport is now inspected directly, following Unwrap through wrappers so an instrumentation layer cannot hide the setting; the syncer's counting round-tripper gained an Unwrap for exactly that. The walk is bounded, so a cyclic chain terminates rather than hanging. The explicit field still forces the guard on, for transports that cannot be inspected. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Entire-Checkpoint: 01M0D34RXV53B6KCSH2SK92DE4+359/-8
<task-notification> <task-id>bxy12cig5</task-id> <tool-use-id>REDACTED</tool-use-id> <output-file>/private/tmp/claude-501/-Users-soph-Work-entire-devenv-git-sync/9f1c8ebe-1a05-4632-9630-d373eac3e09b/tasks/bxy12cig5.output</output-file> <status>completed</status> <summary>Background command "Wait for and report CI across the stack" completed (exit code 0)</summary> </task-notification>
2w ago·1m·3 calls

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:

PRBugbot findingsCI
#1083 fixed (1 High), replied✅ 4/4
#109none✅ 4/4
#1101 fixed, replied✅ 4/4
#1112 fixed (1 High), replied✅ 4/4
#112none✅ 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.

can you look at the comments on 108

2w ago·8m·24 calls

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. httphttps on the same host was treated as cross-site

sameSite required an exact scheme match, so http://hosthttps://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.

3b320bbAddress review: allow http→https upgrades, anchor the guard per request Two findings from nodo, both confirmed by direct check, and both false negatives — credentials withheld where they should not be, so they break working configurations rather than leaking anything. sameSite required an exact scheme match, so an http endpoint redirecting to https on the same host read as cross-site. GitHub does this, as does most every reverse proxy, and it works today because the stdlib carries Authorization when only the scheme changed. The result would have been a hard 401 on a working config, with the documented "store it in the credential helper" remedy inapplicable because the host never changed. The stated goal was only to stop a downgrade putting a credential on the wire in plaintext, so the upgrade direction is now allowed and the downgrade still is not. The credential was already reaching that host in the clear, so an upgrade strictly improves matters. guardRedirects closed over EndpointURL at construction, but a connection's effective endpoint moves: setResolvedEndpoint adopts a redirect host, and helper credentials are then looked up for that host. Any further hop that never left the adopted host still failed sameSite(EndpointURL, ...) and lost its Authorization — undercutting exactly the path the withheld-credentials warning sends people down. The guard now anchors on via[0].URL, the URL the redirect chain started from, which is accurate per request for both the token case and the helper-retry case, and lets the closure drop the captured endpoint entirely. A nil anchor still fails closed. Both are covered by tests, and the anchor test was confirmed to fail against the construction-time version. docs/usage.md and the changelog described an exact scheme match and now describe the downgrade rule. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Entire-Checkpoint: 01M0D8T9MSEKBDHAQV2D6P5N3T+148/-12
2559b09Bind explicit credentials to the host they were configured for With --source-follow-info-refs-redirect / --target-follow-info-refs-redirect, git-sync adopts the host /info/refs redirected to and addresses it directly for the follow-up RPCs — and re-attached the configured token to those requests, after Go's http.Client had deliberately stripped the Authorization header on the cross-host hop. Any redirect an attacker could cause collected the token: an open redirect on the real host, a hostile mirror, or a man-in-the-middle on a plain-http source. On the target side that token usually carries write access. An explicitly supplied credential — token flags, bearer flags, or userinfo in the URL — is now sent only to the host named on the command line and its subdomains, on the same scheme and port. Anything else gets nothing, plus a warning naming the withheld host, and the credential helper is consulted for that host instead. Credentials resolved *from* the helper are untouched: those are looked up keyed on the host actually being challenged, so they were already bound to their destination. The same-site rule is deliberately stricter than the stdlib's. Go compares hostnames only, ignoring scheme and port, so it will carry Authorization from host:443 to host:9999 — a different service, and on shared infrastructure potentially a different tenant. That gap is not visible from the request-building code either, because the redirect is followed inside a single http.Client.Do: by the time control returns the header is already on the wire. So redirect policy moves into a per-connection CheckRedirect that strips the header on any hop leaving the endpoint's site, layered over whatever policy the caller set. Setting CheckRedirect replaces the stdlib's ten-redirect default, so that cap is reimposed explicitly rather than lost. The subdomain allowance keeps the case the flag exists for working: a hosting replica under the same domain, example.com to replica.example.com, still authenticates. Tests cover the rule in isolation (including the near-misses: suffix without a dot boundary, attacker-controlled prefix domain, scheme downgrade, port change, IP addresses having no subdomains) and end to end against httptest servers, asserting the cross-site POST carries no credentials while a same-site redirect still does. Verified by hand too: the original two-server reproduction that leaked the token now logs an empty Authorization on both requests to the redirect target. docs/usage.md gains a "Credentials and redirects" section — the flags were previously documented only in their flag help. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Entire-Checkpoint: 01M0D278EFFYH2FZKTEA4VQKR6+392/-8
503ac1bAddress review: allow http→https upgrades, anchor the guard per request Two findings from nodo, both confirmed by direct check, and both false negatives — credentials withheld where they should not be, so they break working configurations rather than leaking anything. sameSite required an exact scheme match, so an http endpoint redirecting to https on the same host read as cross-site. GitHub does this, as does most every reverse proxy, and it works today because the stdlib carries Authorization when only the scheme changed. The result would have been a hard 401 on a working config, with the documented "store it in the credential helper" remedy inapplicable because the host never changed. The stated goal was only to stop a downgrade putting a credential on the wire in plaintext, so the upgrade direction is now allowed and the downgrade still is not. The credential was already reaching that host in the clear, so an upgrade strictly improves matters. guardRedirects closed over EndpointURL at construction, but a connection's effective endpoint moves: setResolvedEndpoint adopts a redirect host, and helper credentials are then looked up for that host. Any further hop that never left the adopted host still failed sameSite(EndpointURL, ...) and lost its Authorization — undercutting exactly the path the withheld-credentials warning sends people down. The guard now anchors on via[0].URL, the URL the redirect chain started from, which is accurate per request for both the token case and the helper-retry case, and lets the closure drop the captured endpoint entirely. A nil anchor still fails closed. Both are covered by tests, and the anchor test was confirmed to fail against the construction-time version. docs/usage.md and the changelog described an exact scheme match and now describe the downgrade rule. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Entire-Checkpoint: 01M0D8T9MSEKBDHAQV2D6P5N3T+148/-12
1b0ac93Address review: userinfo, default ports, and the helper fallback Three findings from Bugbot on the previous commit, all confirmed. Credentials in the URL's userinfo were not bound to the endpoint's site. outgoingAuth gates c.Auth, but requests are built from requestURL().String(), and the resolved endpoint was a copy of EndpointURL with only scheme and host replaced — userinfo came along. net/http derives Basic auth from req.URL.User, so a cross-site redirect authenticated against the redirect host with no Authorization header of ours involved: outgoingAuth never saw it, so there was not even a warning. Reproduced against httptest servers, where "user:URLSECRET" reached the redirect target, and confirmed against the built binary. Resolved endpoints now drop userinfo when they leave the site, and warn when they do. This also makes the docs' claim that userinfo is bound like a flag token true rather than aspirational. sameSite compared ports literally, so "https://host" and "https://host:443" read as different sites. Proxies and load balancers do spell the default port out in a Location header, and treating that as cross-site would withhold credentials from a genuinely same-origin replica — a false negative that breaks working setups rather than leaking anything. Default ports are now normalized per scheme, with the default port for the *other* scheme still counting as a real port. The withheld-credentials warning promised a credential-helper lookup that could not happen: syncer.newConn installed the helper only when no explicit auth was configured, which is exactly the case where the token is now withheld. So a --source-token user hitting a cross-site redirect got a bare 401 and the documented remedy did not work. The helper is now installed alongside an explicit token — for same-site requests the token still wins, so the helper is only consulted where the token is deliberately not sent — and the warning names the real next step either way. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Entire-Checkpoint: 01M0D4P9499AAACJ0WNVWMQCR2+128/-15
b7f6db1Validate ref names arriving from a remote Nothing checked the names a remote advertises, and git-sync acts on them in two ways that make a malformed one dangerous. convert-sha256 writes them to disk. go-git resolves a ref name through the repository filesystem, so "refs/heads/../../config" escapes refs/heads. go-billy clamps the traversal at the repository root, so this is repository corruption rather than arbitrary file write — but config, HEAD, and packed-refs are all in range, with the content constrained to an object hash. Ref-update commands embed them in the receive-pack request. receive-pack reads a feature list from everything after the first NUL on a command line, so a source advertising a name containing NUL can inject capabilities into the push git-sync sends to the target. Validation defers to plumbing.ReferenceName.Validate, which implements git's check_refname_format: "..", NUL, CR, LF, other control characters, DEL, space, "~^:?*[", backslash, "@{", a ".lock" suffix, a leading dot, empty components, and a leading dash on a branch or tag. Deferring rather than hand-rolling keeps git-sync's notion of a valid ref identical to git's, so nothing git considers legitimate gets skipped — verified against refs/pull/*, refs/notes/*, and nested branch names. It runs at the two decode boundaries (AdvRefsToSlice and decodeV2LSRefs) rather than at each call site, so every path that reads an advertisement is covered by construction, on both the source and target side. A skipped target ref is also one the planner never sees, so it is never picked as a prune candidate — the safe direction. Bad names are skipped with a warning rather than failing the run, matching how per-ref push rejections behave under BestEffort: one malformed ref upstream should not stop a mirror. The warning prints names with %q, because they are remote-controlled and an embedded escape sequence would otherwise reach the terminal; the individual list is capped while the count still covers everything skipped. --map values get the same validation but are rejected outright rather than skipped. They are configuration, and a mapped target ref reaches the same places an advertised one does, so failing at startup beats silently not mirroring what was asked for. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Entire-Checkpoint: 01M0D2HM0JN5QVYSWAPFZCSPNR+344/-26
c726cb2Enforce the materialized object limit while the pack streams FetchToStore filled the in-memory store with no cap. The object limit (--materialized-max-objects, default 500,000) was checked against the object closure once the fetch had already finished, so it reported an overrun rather than preventing one: by then the objects were resident and the process may have died getting there. The constant's own comment says "fail early rather than OOM", which the ordering did not deliver, and README's "guarded by an explicit object-count limit" overstated it. The store is now wrapped so the count is enforced as objects are decoded, failing the write that would exceed the limit. RawObjectWriter is the choke point: go-git's pack scanner routes every object through it, and memory storage's implementation only reaches SetEncodedObject via its own internal closer, which never passes back through the wrapper — so counting in both places would risk double-counting rather than add coverage. Reads pass straight through, leaving planning and the push path seeing the store exactly as before. No new default was needed. The streaming guard reuses the limit that was already configured and documented, so this changes when the existing limit bites, not what the limit is. Errors carry an ErrObjectLimit sentinel and an *ObjectLimitError holding the limit, so callers can branch without matching message text, and the message names the flag to raise. The closure check in the materialized strategy stays in place as a second line of defence for the case where a closure is large without the fetch itself exceeding the cap. The integration test that covered this asserted the old post-hoc message; it now asserts the sentinel plus an actionable message, which is both stronger and independent of wording. A new unit test decodes a 50-object pack into a store bounded at 10 and verifies the decode fails, the error carries the sentinel and limit, and the store admitted no more than the limit — the property the old ordering could not provide. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Entire-Checkpoint: 01M0D2W9V8DXHFAKZX62Z7D3KB+245/-4
d1eb10bSanitize server-authored text and derive the TLS-verification state Two hardening items from the security review. Server-authored text reached terminals, logs and --json output unfiltered, on five paths: sideband progress, up to 64 KiB of HTTP error body, receive-pack "ng" rejection reasons, diagnostic response headers, and ssh's relayed "remote:" output. A hostile remote could embed an escape sequence to redraw the line its own message was printed on — a rejected push made to read like a successful one — or smuggle control characters into whatever ingests the JSON. All five now go through internal/sanitize, which drops everything below 0x20 plus DEL while keeping tab, newline and carriage return: git's in-place progress output depends on them, and the "source:"/"target:" line prefix already bounds how much a '\r' alone can hide. The single-byte C1 introducers are deliberately out of scope — in a UTF-8 stream they are not valid standalone bytes and terminals in UTF-8 mode do not act on them. Rejection classification still runs on the raw status, so filtering cannot change whether a rejection is treated as a concurrent move. The streaming filter reports the byte count it was given rather than the smaller number written, so a caller checking n against len(p) does not see a short write for text that was intentionally dropped. Separately, HTTPConn.InsecureSkipTLSVerify was documented as a field callers must set to match the client they pass in, and the cross-host credential guard reads it. A caller who disabled verification on their transport but forgot the field silently lost that protection — the wrong direction for a security check to fail. The transport is now inspected directly, following Unwrap through wrappers so an instrumentation layer cannot hide the setting; the syncer's counting round-tripper gained an Unwrap for exactly that. The walk is bounded, so a cyclic chain terminates rather than hanging. The explicit field still forces the guard on, for transports that cannot be inspected. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Entire-Checkpoint: 01M0D34RXV53B6KCSH2SK92DE4+359/-8
<task-notification> <task-id>b401ezfrs</task-id> <tool-use-id>toolu_01M5eF9tiWyhaco1311GZsqE</tool-use-id> <output-file>/private/tmp/claude-501/-Users-soph-Work-entire-devenv-git-sync/9f1c8ebe-1a05-4632-9630-d373eac3e09b/tasks/b401ezfrs.output</output-file> <status>completed</status> <summary>Background command "Wait for CI on the rebuilt stack" completed (exit code 0)</summary> </task-notification>
2w ago·48.0s·2 calls

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:

PRFindings addressedCI
#1083 Bugbot + 2 nodo✅ 4/4
#109✅ 4/4
#1101 Bugbot✅ 4/4
#1112 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.

110 and 111 have comments too

2w ago·8m·30 calls

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.

f097993Address review: filter the two rejection paths the sweep missed nodo found two carriers of server-authored text that the original sweep did not reach, neither of them in that diff — which is likely why. The best-effort rejection path is the higher-value one. OnRejection is called with the raw cs.Status, stored in s.rejections, and applyRejections turns it into BranchPlan.Reason, which FormatPlanLine prints and which is marshalled as "reason". That path never goes through asRefRejectedError, so sanitizing there did not cover it. It also matters more than an error would: --all-refs turns on BestEffort for sync and bootstrap, so surfacing the server's ng text is the designed behaviour, and a hostile reason was printed as a WARN line and embedded in --json. Filtering happens at the callback now, which is the single point every consumer reads through. IsLeaseFailure and applyRejections only substring-match, so this cannot change how a rejection is classified — the test asserts both directions, that a non-lease reason stays non-lease and a real "stale info" marker still matches after filtering. report.UnpackStatus was interpolated directly, about six lines above the sibling branch whose unpack failure the previous commit wrapped in sanitizedError. Same function, same kind of text, formatted rather than wrapped, so it needed its own call. Covered end to end through PushPack against a fake receive-pack server reporting a hostile unpack status. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Entire-Checkpoint: 01M0DBA8KN6ZDB8RPQCTR8GRBF+83/-3
10e5175Sanitize server-authored text and derive the TLS-verification state Two hardening items from the security review. Server-authored text reached terminals, logs and --json output unfiltered, on five paths: sideband progress, up to 64 KiB of HTTP error body, receive-pack "ng" rejection reasons, diagnostic response headers, and ssh's relayed "remote:" output. A hostile remote could embed an escape sequence to redraw the line its own message was printed on — a rejected push made to read like a successful one — or smuggle control characters into whatever ingests the JSON. All five now go through internal/sanitize, which drops everything below 0x20 plus DEL while keeping tab, newline and carriage return: git's in-place progress output depends on them, and the "source:"/"target:" line prefix already bounds how much a '\r' alone can hide. The single-byte C1 introducers are deliberately out of scope — in a UTF-8 stream they are not valid standalone bytes and terminals in UTF-8 mode do not act on them. Rejection classification still runs on the raw status, so filtering cannot change whether a rejection is treated as a concurrent move. The streaming filter reports the byte count it was given rather than the smaller number written, so a caller checking n against len(p) does not see a short write for text that was intentionally dropped. Separately, HTTPConn.InsecureSkipTLSVerify was documented as a field callers must set to match the client they pass in, and the cross-host credential guard reads it. A caller who disabled verification on their transport but forgot the field silently lost that protection — the wrong direction for a security check to fail. The transport is now inspected directly, following Unwrap through wrappers so an instrumentation layer cannot hide the setting; the syncer's counting round-tripper gained an Unwrap for exactly that. The walk is bounded, so a cyclic chain terminates rather than hanging. The explicit field still forces the guard on, for transports that cannot be inspected. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Entire-Checkpoint: 01M0D34RXV53B6KCSH2SK92DE4+359/-8
7418f3fAddress review: filter the two rejection paths the sweep missed nodo found two carriers of server-authored text that the original sweep did not reach, neither of them in that diff — which is likely why. The best-effort rejection path is the higher-value one. OnRejection is called with the raw cs.Status, stored in s.rejections, and applyRejections turns it into BranchPlan.Reason, which FormatPlanLine prints and which is marshalled as "reason". That path never goes through asRefRejectedError, so sanitizing there did not cover it. It also matters more than an error would: --all-refs turns on BestEffort for sync and bootstrap, so surfacing the server's ng text is the designed behaviour, and a hostile reason was printed as a WARN line and embedded in --json. Filtering happens at the callback now, which is the single point every consumer reads through. IsLeaseFailure and applyRejections only substring-match, so this cannot change how a rejection is classified — the test asserts both directions, that a non-lease reason stays non-lease and a real "stale info" marker still matches after filtering. report.UnpackStatus was interpolated directly, about six lines above the sibling branch whose unpack failure the previous commit wrapped in sanitizedError. Same function, same kind of text, formatted rather than wrapped, so it needed its own call. Covered end to end through PushPack against a fake receive-pack server reporting a hostile unpack status. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Entire-Checkpoint: 01M0DBA8KN6ZDB8RPQCTR8GRBF+83/-3
0f6a015Validate ref names arriving from a remote Nothing checked the names a remote advertises, and git-sync acts on them in two ways that make a malformed one dangerous. convert-sha256 writes them to disk. go-git resolves a ref name through the repository filesystem, so "refs/heads/../../config" escapes refs/heads. go-billy clamps the traversal at the repository root, so this is repository corruption rather than arbitrary file write — but config, HEAD, and packed-refs are all in range, with the content constrained to an object hash. Ref-update commands embed them in the receive-pack request. receive-pack reads a feature list from everything after the first NUL on a command line, so a source advertising a name containing NUL can inject capabilities into the push git-sync sends to the target. Validation defers to plumbing.ReferenceName.Validate, which implements git's check_refname_format: "..", NUL, CR, LF, other control characters, DEL, space, "~^:?*[", backslash, "@{", a ".lock" suffix, a leading dot, empty components, and a leading dash on a branch or tag. Deferring rather than hand-rolling keeps git-sync's notion of a valid ref identical to git's, so nothing git considers legitimate gets skipped — verified against refs/pull/*, refs/notes/*, and nested branch names. It runs at the two decode boundaries (AdvRefsToSlice and decodeV2LSRefs) rather than at each call site, so every path that reads an advertisement is covered by construction, on both the source and target side. A skipped target ref is also one the planner never sees, so it is never picked as a prune candidate — the safe direction. Bad names are skipped with a warning rather than failing the run, matching how per-ref push rejections behave under BestEffort: one malformed ref upstream should not stop a mirror. The warning prints names with %q, because they are remote-controlled and an embedded escape sequence would otherwise reach the terminal; the individual list is capped while the count still covers everything skipped. --map values get the same validation but are rejected outright rather than skipped. They are configuration, and a mapped target ref reaches the same places an advertised one does, so failing at startup beats silently not mirroring what was asked for. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Entire-Checkpoint: 01M0D2HM0JN5QVYSWAPFZCSPNR+344/-26
21211cdEnforce the materialized object limit while the pack streams FetchToStore filled the in-memory store with no cap. The object limit (--materialized-max-objects, default 500,000) was checked against the object closure once the fetch had already finished, so it reported an overrun rather than preventing one: by then the objects were resident and the process may have died getting there. The constant's own comment says "fail early rather than OOM", which the ordering did not deliver, and README's "guarded by an explicit object-count limit" overstated it. The store is now wrapped so the count is enforced as objects are decoded, failing the write that would exceed the limit. RawObjectWriter is the choke point: go-git's pack scanner routes every object through it, and memory storage's implementation only reaches SetEncodedObject via its own internal closer, which never passes back through the wrapper — so counting in both places would risk double-counting rather than add coverage. Reads pass straight through, leaving planning and the push path seeing the store exactly as before. No new default was needed. The streaming guard reuses the limit that was already configured and documented, so this changes when the existing limit bites, not what the limit is. Errors carry an ErrObjectLimit sentinel and an *ObjectLimitError holding the limit, so callers can branch without matching message text, and the message names the flag to raise. The closure check in the materialized strategy stays in place as a second line of defence for the case where a closure is large without the fetch itself exceeding the cap. The integration test that covered this asserted the old post-hoc message; it now asserts the sentinel plus an actionable message, which is both stronger and independent of wording. A new unit test decodes a 50-object pack into a store bounded at 10 and verifies the decode fails, the error carries the sentinel and limit, and the store admitted no more than the limit — the property the old ordering could not provide. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Entire-Checkpoint: 01M0D2W9V8DXHFAKZX62Z7D3KB+245/-4
d2473fdSanitize server-authored text and derive the TLS-verification state Two hardening items from the security review. Server-authored text reached terminals, logs and --json output unfiltered, on five paths: sideband progress, up to 64 KiB of HTTP error body, receive-pack "ng" rejection reasons, diagnostic response headers, and ssh's relayed "remote:" output. A hostile remote could embed an escape sequence to redraw the line its own message was printed on — a rejected push made to read like a successful one — or smuggle control characters into whatever ingests the JSON. All five now go through internal/sanitize, which drops everything below 0x20 plus DEL while keeping tab, newline and carriage return: git's in-place progress output depends on them, and the "source:"/"target:" line prefix already bounds how much a '\r' alone can hide. The single-byte C1 introducers are deliberately out of scope — in a UTF-8 stream they are not valid standalone bytes and terminals in UTF-8 mode do not act on them. Rejection classification still runs on the raw status, so filtering cannot change whether a rejection is treated as a concurrent move. The streaming filter reports the byte count it was given rather than the smaller number written, so a caller checking n against len(p) does not see a short write for text that was intentionally dropped. Separately, HTTPConn.InsecureSkipTLSVerify was documented as a field callers must set to match the client they pass in, and the cross-host credential guard reads it. A caller who disabled verification on their transport but forgot the field silently lost that protection — the wrong direction for a security check to fail. The transport is now inspected directly, following Unwrap through wrappers so an instrumentation layer cannot hide the setting; the syncer's counting round-tripper gained an Unwrap for exactly that. The walk is bounded, so a cyclic chain terminates rather than hanging. The explicit field still forces the guard on, for transports that cannot be inspected. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Entire-Checkpoint: 01M0D34RXV53B6KCSH2SK92DE4+359/-8
22208adAddress review: filter the two rejection paths the sweep missed nodo found two carriers of server-authored text that the original sweep did not reach, neither of them in that diff — which is likely why. The best-effort rejection path is the higher-value one. OnRejection is called with the raw cs.Status, stored in s.rejections, and applyRejections turns it into BranchPlan.Reason, which FormatPlanLine prints and which is marshalled as "reason". That path never goes through asRefRejectedError, so sanitizing there did not cover it. It also matters more than an error would: --all-refs turns on BestEffort for sync and bootstrap, so surfacing the server's ng text is the designed behaviour, and a hostile reason was printed as a WARN line and embedded in --json. Filtering happens at the callback now, which is the single point every consumer reads through. IsLeaseFailure and applyRejections only substring-match, so this cannot change how a rejection is classified — the test asserts both directions, that a non-lease reason stays non-lease and a real "stale info" marker still matches after filtering. report.UnpackStatus was interpolated directly, about six lines above the sibling branch whose unpack failure the previous commit wrapped in sanitizedError. Same function, same kind of text, formatted rather than wrapped, so it needed its own call. Covered end to end through PushPack against a fake receive-pack server reporting a hostile unpack status. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Entire-Checkpoint: 01M0DBA8KN6ZDB8RPQCTR8GRBF+83/-3