v1.56.1.0 fix(sync): staging-dir ownership guard + resume-correctness fixes (#1802) (#1856)

* fix(sync): fail-closed staging-dir ownership guard — prevent rm -rf of repo (#1802)

Adopts community fix #1827 by @diazMelgarejo (cyre). New lib/staging-guard.ts
exports checkOwnedStagingDir(), the single fail-closed predicate for 'safe to
recurse-delete or resume into', wired at cleanupStagingDir() (the deletion
chokepoint), decideResume(), the ingest entry point, and makeStagingDir()
(mints the .gstack-staging marker).

Fixes #1802.

Co-Authored-By: cyre <diazMelgarejo@users.noreply.github.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(sync): don't route the remote-http persistent transcript dir through cleanup (#1802)

The ingest finally ran cleanupStagingDir() unconditionally, but in remote-http
mode stagingDir is the PERSISTENT transcript dir (~/.gstack/transcripts/) that
gstack-brain-sync push must consume. The remote-http branch documents the intent
to skip cleanup, but a finally runs on its return. Gate the call on
!remoteHttpMode so the ownership guard only ever sees .staging-ingest-* dirs.
Pre-gate this dir was deleted outright (broken artifacts handoff); post-#1827 it
produced a false 'prevent data loss' warning every sync.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(sync): preserve staging dir on internal import timeout (#1802 C3)

The import-timeout branch printed 'checkpoint preserved' but the finally then
deleted the staging dir: the SIGTERM forwarder's preserve branch only runs when
the PARENT is signalled, and an internal runGbrainImport timeout kills just the
child and returns normally. So #1611 resume-after-timeout never actually worked.
Mirror the forwarder in the timeout branch: set preserveStaging only when gbrain
checkpointed against this dir (finally then skips cleanup); otherwise clean up
and tell the user it restages instead of falsely promising a resume.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(sync): resume must not mark failed files as ingested (#1802 C4)

On resume, stagedPathToSource was rebuilt as an empty Map, so readNewFailures()
could not map gbrain's per-file failures back to source paths. Every failure
fell through to state recording — failed files were silently marked ingested and
never retried. Reconstruct the map from the prepared pages via a shared
stagedRelPath() helper (single source of truth with writeStaged, so the keys
can never drift). Exports stagedRelPath + readNewFailures for a behavioral test
proving the reconstructed map recovers the failure the empty map dropped.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* harden(sync): close staging-guard TOCTOU + fail hard on marker write (#1802 C5)

checkOwnedStagingDir() now returns the realpath-resolved canonicalPath on a
pass, and cleanupStagingDir() rmSync's that instead of the raw input — closing
the gap where the input is a symlink swapped between the ownership check and the
delete. makeStagingDir() tears down the partial dir and rethrows if the marker
write fails, so a marker-less dir (which the guard would refuse forever) can
never leak.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore: v1.56.1.0 — staging-dir ownership guard + resume-correctness fixes (#1802)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* ci: grant the eval report job issues:write so PR comment upsert stops 401ing

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: cyre <diazMelgarejo@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Garry Tan
2026-06-07 06:51:10 -07:00
committed by GitHub
parent cab774cced
commit 476b0ec597
8 changed files with 565 additions and 31 deletions
+69
View File
@@ -1,5 +1,74 @@
# Changelog
## [1.56.1.0] - 2026-06-03
## **`/sync-gbrain` can no longer delete your repo. Cleanup now refuses any directory it cannot prove it created.**
A `/sync-gbrain` memory sync could recursively delete your entire working tree. A
crashed import left a checkpoint pointing at the repo root, the next sync
"resumed" into it, and the cleanup step `rm -rf`'d it, taking uncommitted and
untracked work with it. This release closes that path and fixes three more bugs
hiding in the same resume machinery: cleanup now deletes only directories it can
prove are gstack-minted staging dirs, the remote-http transcript dir is never
touched, an interrupted import actually keeps its checkpoint so the next run
resumes instead of restaging, and a resumed run no longer marks files that failed
to import as successfully ingested.
### The numbers that matter
Source: `bun test test/regression-1611-gbrain-sync-resume.test.ts` on this branch.
| Metric | Before | After | Δ |
|--------|--------|-------|---|
| Repo-root `rm -rf` reachable | yes | no | closed |
| Proof required before delete | none | 5 checks | realpath + direct-child + name + .git tripwire + minted-marker |
| Resume after a timed-out import | broken (dir deleted) | works | fixed |
| Failed files mislabeled "ingested" on resume | yes | no | fixed |
| Resume regression-test assertions | 9 | 64 | +55 |
The guard is fail-closed: anything it cannot prove it owns is left on disk (a few
seconds of re-staging next run) rather than deleted. That asymmetry is the design
- a missing marker can cost a little work, never your data.
### What this means for you
If you use `/sync-gbrain`, a crashed or timed-out import can no longer cost you
uncommitted work. Resume now does what it always claimed: a large sync that times
out picks up where it left off next run instead of starting over, and files that
failed to import get retried instead of silently skipped. Nothing to configure.
Upgrade and keep syncing.
### Itemized changes
#### Fixed
- **`/sync-gbrain` could `rm -rf` your repo root.** A poisoned resume checkpoint
(dir = the repo, written when an import was interrupted while the repo was the
working directory) was adopted as the staging dir and recursively deleted. A
single fail-closed ownership check now guards every staging delete and every
resume: a path must resolve cleanly, be a direct child of `~/.gstack` named
`.staging-ingest-*`, contain no `.git`, and carry a marker file gstack minted.
Anything else is refused. Contributed by @diazMelgarejo (cyre).
- **Remote-http syncs no longer churn (or scare you).** The persistent transcript
dir that the brain sync pushes is no longer routed through staging cleanup, so
it stops being deleted on every run and stops emitting a false "preventing data
loss" warning.
- **A timed-out import now actually resumes.** Previously the run said "checkpoint
preserved" but then deleted the staging dir, so the next run always restaged.
The staging dir is now kept when a checkpoint points at it, and the message is
honest when there is nothing to resume.
- **Resume no longer hides import failures.** A resumed run could mark files that
failed to import as ingested, so they were never retried. Failures now map back
to their source files on resume and get another pass.
#### For contributors
- New `lib/staging-guard.ts` exports `checkOwnedStagingDir()`, the single
fail-closed predicate shared by the deletion chokepoint and the resume gate. It
returns the realpath-resolved canonical path so callers delete exactly what they
validated (closes a symlink TOCTOU). `makeStagingDir()` tears down and rethrows
if its marker write fails, so a marker-less dir can never leak. The
`#1611` resume regression suite grew to 64 assertions covering the poison
matrix, the remote-http gate, timeout-preserve, and resume failure-mapping.
## [1.56.0.0] - 2026-06-03
## **Five heavy skills now load their bulk on demand, the shared question preamble slimmed corpus-wide, and a paranoid test suite proves the questions never got worse.**