cli.sgit.ai / Research / Review: the pack-indexes branch — quality, security, and side effects on 0.25.0 vaults (26 Oct 2026)

Review: the pack-indexes branch — quality, security, and side effects on 0.25.0 vaults (26 Oct 2026)

Date2026-10-26 · 21:05 UTCKindreviewResearch topicsscale security-reviewWritten bythe sgit-ai CLI team (Claude Code sessions), for Dinis CruzSourceteam/humans/dinis_cruz/claude-code-web/10/26/review__pack-indexes-branch-quality-security-and-0-25-side-effects.md in SGit-AI__CLI at 09172b0. Published here as written; the CLI repository is where the work happened.

Scope: c3f410ba..a9f24d8 on claude/charming-babbage-5hzcxq (56 source files, +3,494 / −168), the seven commits since 0.25.0. Method: three independent reviewers (security; compatibility with 0.25.0 clients and vaults; correctness of pull, checkout and pack store), each given the code and not my conclusions, each proving findings with scripts; plus a cross-version run against the real SG/Send test server with the released sgit-ai==0.25.0 from PyPI and the branch build, in three shared-vault scenarios. Every finding below was re-run by me. Nothing was fixed yet: this is the assessment.

1. Side effects on 0.25.0 vaults and clients: none breaking, four to know about

The cross-version run (server at sgraph-ai-app-send, tests/integration's fixture, in-memory storage):

ScenarioResult
A. a pack vault the new client made and pushed; 0.25.0 clones, pulls, pushes; the new client compacts; 0.25.0 pulls, verifies, pushes again; a fresh 0.25.0 clone after compactionevery command OK; trees identical at every hop; pack verify ok on path-ordered packs
B. a pack vault only 0.25.0 wrote; the new client clones (full, --no-checkout, --pack-store, --path), pulls, pushes, runs pack index build; 0.25.0 pulls and verifies betweenevery command OK; trees identical
C. a loose vault shared by both, interleaved commits and pullsevery command OK; trees identical

Why nothing breaks, with the code: 0.25.0 never lists bare/packs/indexes/ (its list_folder filters on commits/ and snapshots/); it ignores the unknown feature pack-indexes (Vault__Format.check_client returns once the build is at or above min_client, and no FEATURE_MIN_CLIENT entry raises the gate); extraction and pack verify do not depend on member order; the pack name regex, packs_absorbed.json, remote_heads.json, flat-cache v2 and scan-cache v2 are unchanged; Schema__Local_Config.from_json ignores the new pack_store key, so 0.25.0 reads a config the new client wrote.

The side effects that are real:

#PopulationEffectSeverity
S10.25.0 sparse clones (loose vaults)sgit fetch with no path, or --all, now goes through checkout --all, which clears the sparse flag and removes the record: the clone becomes full, and on-disk absences become deletions at the next commit. 0.25.0's sparse_fetch never flipped the flag.Medium: a semantic change on an existing clone type
S2any pack vault, old or newevery push by the new client uploads an index pack beside its data pack (one more batch write, at least 16 KiB each because the header is padded), feature or not; on a 0.25.0-created vault nobody reads them until sgit pack index build runs, which also writes the pack-indexes feature into the branch index (a CAS write other clones may contend with)Low: host bytes and one write per push
S3sgit dump (both versions)takes every file under bare/packs/indexes/ for a branch index ('/indexes/' in f) and reports a decrypt error per index pack; the dump is otherwise fineMedium as noise in a check tool
S40.25.0 compaction of a vault with index packs0.25.0 does not move index packs to indexes/archive/, so orphans accumulate; the new client only reports them (pack index verify → orphans); the docstring's "deleted by the next run" is not implementedLow
S5request counts (pack vaults)push: 3 batch writes instead of 2; pull with new packs: 3 listings instead of 2 (0 extra when up to date); fresh clone: 4 listings instead of 2; compaction: about three more listings per moved pack. A scoped clone's ranged reads cost two requests per range (the presigned URL is fetched per read_range) and two per pack for the directory; sgit serve and memory hosts answer 400 and the client falls back to whole packs after one failed callLow; Medium for scoped clones on S3 hosts
S6branch buildsthe branch's sgit_ai/version says 0.24.3 (the pipeline bumps it on dev), so a vault created by 0.25.0 refuses the branch build by its own gate (min_client = max(build, 0.24.0) at init). The merge fixes it; until then test against 0.25.0-created vaults with the version file setInfo

Also worth knowing: a --pack-store clone must not be opened by 0.25.0 (its bare/data/ is empty: 0.25.0 would refetch); the status line Sparse mode: n/m file(s) fetched locally is now Selective working copy: n/m tracked file(s) checked out (JSON keys unchanged); sgit checkout main out of git habit prints "not a tracked file" and exits 1 (sgit branch checkout switches); every new flag defaults off.

2. Findings in the new code (ranked)

#SeverityWhereWhatFix
F1High (data loss)Vault__Sync__Checkout.checkout, :42-43checkout --all calls _make_full even when blobs failed to download (missing non-empty): the clone becomes full, status shows those files deleted, the next commit of anything else deletes them from the vault for everyone. Proven on a loose sparse clone with one blob removed from the host.call _make_full only when not missing; otherwise refuse as the clone's _refuse_incomplete does
F2High (data loss)Vault__Sync__Checkout.checkout, :40checkout <path>, --all, --changed-since and fetch <path> overwrite an uncommitted edit with HEAD, no warning (the pull has Vault__Pull__Guard; this path has nothing). sparse_fetch had the same hole, but --all and --changed-since are new and touch many files at once. --since-pull is safe only because the preceding pull's guard blocks.scan the directory (_scan_local_directory(verify=wanted)), skip paths whose hash differs from HEAD and report them as kept
F3Medium (security, integrity)Vault__Pack_Index__Reader.seed_head_flat → Step__Pull__Merge.write → _remove_deleted_flatA commit index whose folder: member omits an unchanged file passes the root anchor; the puller's merge deletes that file from disk, status is clean, and the victim's next commit removes it from the vault under the victim's key. Attacker: a write-key holder (with signatures-required, any key in bare/keys/), the same trust as a commit, but the lie becomes someone else's attributed commit and persists through _flatten_incremental. TM-R42 accepted "the working copy holds what the index says"; this goes a step further.anchor every folder member of a commit index to its own tree (_entries_by_name(member['tree_id']): all trees are local on a pack vault, and a commit index carries only changed folders, so cost follows the change), drop the index on a mismatch; then TM-R42 keeps the below-root gap for the snapshot flat only
F4MediumStep__Pull__Merge.py:246-249 (rewind path)--accept-rewind on a selective clone: the guard is called without the checkout set, so on a pack vault every never-checked-out file reads as deleted and blocks; and the rewind write bypasses write(), so it would write every file.pass selective to the guard and route the write through write()
F5Medium (pre-existing)Step__Pull__Merge.py:70-71, _checkout_flat_mapa commit that replaces folder x/ by file x: the write hits IsADirectoryError (swallowed), then the prune empties x/; the puller's disk lacks x and its next commit deletes it. Unchanged since before the branch.prune before writing, or retry a failed write after the prune
F6Low (security, availability)Vault__Pack_Ranges.scoped_fetch, :267-268a scoped fetch records a pack as absorbed even when a member was refused; with the head commit then local, later pulls never re-fetch and raise "Pull incomplete" for good. One corrupt range from a flaky host wedges the clone.exclude packs with refusals from record_absorbed, and re-run the scoped fetch when the head's trees or blobs are missing, not only its commit
F7Low (security, resource)Vault__Pack_Index.open_member, :95zlib.decompress with no plaintext cap (ciphertext is capped at 256 MiB): a write-key holder can make every reader allocate up to ~1000×. Before any write; deflated blobs have the same exposure today.decompressobj().decompress(data, max_length=PLAIN_MAX) and refuse on unconsumed_tail
F8Low (latent)Vault__Pack_Store._handle, :202-217handle eviction is not thread-safe: an fd can be closed under another thread's pread; not reachable today (no threaded caller loads from packs) but the docstring promises itrefcount handles or open per read
F9LowStep__Pull__Merge.py:71pull --no-checkout still deletes files on disk (the docstring says it writes nothing); safe (the guard blocks an edited file) but surprisingskip the prune under no_checkout; let checkout --since-pull report it
F10LowVault__Object_Store.packs()caches None for the instance's life; a store built before the table exists never sees it. No production path hits it today.re-probe when pack_store is None and the file now exists
F11Low (cosmetic)Vault__Sync__Pull._fetch_missing_objectsn_commits = len(between) is capped at 5,000 while the lines printed are between[:500]; a pull far behind says "N new commits" with 500 linescap both, or say "first 500 of N"
F12Low (latent)Vault__Packs.sync_down, take()fetch_packs keys by name and by file id; a data pack and its index pack of identical byte size would share the name keyread got[e['file_id']]

3. What the reviewers checked and found sound

4. Recommendation

Before this branch goes to dev: fix F1, F2 and F3 (half a day together: a dirty scan in checkout, a refusal on missing blobs, and per-folder anchoring in the reader with a Known_Gaps update), plus F4 and F6 (an hour). S1 deserves a decision: keep fetch --all meaning "become a full clone" (then say so in its help and the CHANGELOG) or keep 0.25.0's behaviour (fetch everything, stay sparse) and reserve the flip for checkout --all. S3 is a one-line filter in Vault__Dump. The rest can ride with the next release. The threat-model wording of TM-R42 should change with F3: commit-index deltas become exact, and the below-root gap is the snapshot's flat alone.

Proof scripts (not part of the suite) are under the session scratchpad: review-sec/, review-quality/, review-compat/, and xver/ (the cross-version harness: server.py for the 3.12 venv, xver.py driving sgit-ai==0.25.0 and the branch build).

This page as markdown · its source, admin/content/research/pack-indexes-cli-self-review.md