v0.42.33.0 fix(sources): confine sync re-clone to gbrain-owned clones; never delete a user working tree (#1881) (#1960)

* fix(sources): confine sync re-clone to gbrain-owned clones; never delete a user working tree (#1881)

recloneIfMissing deleted local_path whenever a source had a remote_url and a
non-healthy on-disk state, with no check that gbrain actually created the clone.
A source whose local_path was a user's live working tree (remote_url set, no
gbrain-created clone) could have its directory removed and re-cloned over.

- isOwnedClone(): ownership, not path-containment. True only for a config
  .managed_clone marker (written by addSource --url) or exact normalized-path
  equality with defaultCloneDir(id) (back-compat for pre-marker default clones).
- recloneIfMissing: ownership guard aborts before ANY filesystem op; EXDEV-safe
  sibling-temp clone + atomic swap (old aside -> new in -> drop old) with
  best-effort restore + a message naming where the original is preserved;
  symlink-leaf reject before the destructive rename.
- sync.ts validate_repo_state guards reclone on isOwnedClone (no per-sync warn).
- sources restore degrades to a warning for an unowned source instead of the
  misleading "missing clone, try sync" hint.

Tests: #1881 regression (tree survives), isOwnedClone matrix, symlink reject,
EXDEV swap residue-free, --clone-dir owned-via-marker, restore CV3, unownedHint
healthy/degraded, sync-level refusal.

* chore: bump version and changelog (v0.42.33.0)

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

* docs: document sources-ops reclone-ownership invariant for v0.42.33.0 (#1881)

Add the missing src/core/sources-ops.ts entry to KEY_FILES.md capturing the
must-never-violate reclone-ownership guarantee: gbrain only deletes/re-clones a
clone it created (isOwnedClone), never a user working tree. Covers managed_clone
marker, defaultCloneDir back-compat, EXDEV-safe swap, TOCTOU + symlink-leaf
guards, unmanaged_path SourceOpError, and the read-only sources restore path.

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Garry Tan
2026-06-07 21:43:28 -07:00
committed by GitHub
co-authored by Claude Opus 4.8
parent 5a06af5a57
commit b31de6613e
10 changed files with 541 additions and 20 deletions
+8 -1
View File
@@ -394,7 +394,14 @@ async function runRestore(engine: BrainEngine, args: string[]): Promise<void> {
console.log(` re-cloned from remote_url (clone dir was missing).`);
}
} catch (e) {
if (e instanceof SourceOpError) {
if (e instanceof SourceOpError && e.code === 'unmanaged_path') {
// #1881: local_path is the user's own working tree, not a clone gbrain
// created. gbrain won't re-clone over it, and `gbrain sync` will refuse it
// too — so the generic "missing clone, try sync to recover" guidance below
// would be actively misleading. Surface the real situation instead.
console.error(` WARN: ${e.message}`);
console.error(` The DB row is restored; gbrain syncs this path read-only.`);
} else if (e instanceof SourceOpError) {
console.error(` WARN: could not re-clone: ${e.message}`);
console.error(` The DB row is restored but the on-disk clone is missing.`);
console.error(` Try \`gbrain sync --source ${id}\` to recover, or remove + re-add.`);
+18 -3
View File
@@ -1084,9 +1084,11 @@ async function performSyncInner(engine: BrainEngine, opts: SyncOpts): Promise<Sy
if (opts.sourceId) {
serr(`[gbrain phase] sync.validate_repo_state`);
const { validateRepoState } = await import('../core/git-remote.ts');
const { recloneIfMissing } = await import('../core/sources-ops.ts');
const cfgRows = await engine.executeRaw<{ config: unknown }>(
`SELECT config FROM sources WHERE id = $1`,
const { recloneIfMissing, isOwnedClone, unownedHint } = await import(
'../core/sources-ops.ts'
);
const cfgRows = await engine.executeRaw<{ local_path: string | null; config: unknown }>(
`SELECT local_path, config FROM sources WHERE id = $1`,
[opts.sourceId],
);
const cfg =
@@ -1095,13 +1097,26 @@ async function performSyncInner(engine: BrainEngine, opts: SyncOpts): Promise<Sy
: ((cfgRows[0]?.config ?? {}) as Record<string, unknown>);
const remoteUrl = typeof cfg.remote_url === 'string' ? cfg.remote_url : null;
if (remoteUrl) {
const ownSrc = {
id: opts.sourceId,
local_path: cfgRows[0]?.local_path ?? repoPath,
config: cfg,
};
const state = validateRepoState(repoPath, remoteUrl);
switch (state) {
case 'healthy':
// No per-sync warning for an unowned-but-healthy source — it would
// spam every sync. The misconfig is surfaced by the doctor check
// (TODO1) instead. Healthy unowned paths sync read-only and are safe.
break;
case 'missing':
case 'no-git':
case 'not-a-dir':
// #1881: only re-clone a clone gbrain owns. An unowned local_path
// (the user's working tree) is refused loudly, never deleted.
if (!isOwnedClone(ownSrc)) {
throw new Error(unownedHint(ownSrc, state));
}
serr(
`[gbrain] auto-recovery: re-cloning "${opts.sourceId}" (clone state: ${state}).`,
);
+142 -14
View File
@@ -38,7 +38,7 @@
import { existsSync, mkdirSync, renameSync, rmSync, lstatSync } from 'fs';
import { realpathSync } from 'fs';
import { join, dirname, resolve as resolvePath } from 'path';
import { join, dirname, basename, resolve as resolvePath } from 'path';
import { randomBytes } from 'crypto';
import type { BrainEngine } from './engine.ts';
import {
@@ -66,7 +66,8 @@ export type SourceOpErrorCode =
| 'not_found'
| 'protected_id'
| 'clone_dir_outside_gbrain'
| 'symlink_escape';
| 'symlink_escape'
| 'unmanaged_path';
export class SourceOpError extends Error {
constructor(
@@ -253,6 +254,70 @@ export function isPathContained(child: string, parent: string): boolean {
return resolvedChild === resolvedParent || resolvedChild.startsWith(parentWithSep);
}
/**
* Did gbrain CREATE this clone (so re-clone/delete is safe)? Ownership, NOT
* path-containment — a user-supplied working tree is NEVER owned, even if it
* happens to sit under $GBRAIN_HOME. This is the #1881 guard: recloneIfMissing
* deletes local_path, so it must only ever fire on a clone gbrain owns.
*
* Ownership is proven by either:
* 1. config.managed_clone === true — written by addSource's --url path
* (covers both default-location and --clone-dir clones), OR
* 2. local_path === defaultCloneDir(id) — back-compat for clones created
* before the marker existed (gbrain's default location), via exact
* normalized-path equality (symlink-free, so none of isPathContained's
* symlinked-parent / lexical-escape edge cases apply).
*
* Everything else is fail-closed (NOT owned → refuse to touch): the bug's
* federated row (remote_url + a user tree), and pre-marker --clone-dir clones
* (rare, local-only) which are byte-for-byte indistinguishable from it. Those
* must be re-added to regain auto-reclone — the correct trade-off when ownership
* is unprovable.
*/
export function isOwnedClone(src: {
id: string;
local_path: string | null;
config: unknown;
}): boolean {
if (!src.local_path) return false;
const cfg =
typeof src.config === 'string'
? (JSON.parse(src.config) as Record<string, unknown>)
: ((src.config ?? {}) as Record<string, unknown>);
if (cfg.managed_clone === true) return true;
return resolvePath(src.local_path) === resolvePath(defaultCloneDir(src.id));
}
/**
* Recovery hint for an unowned source with a remote_url. Splits guidance by
* on-disk state: a healthy unowned path syncs read-only (just drop remote_url),
* but a degraded one (missing/no-git/not-a-dir) cannot be recovered by dropping
* remote_url — that would only defer the failure to the "Not a git repository"
* check. Shared by the core SourceOpError and the sync.ts CLI error so they read
* identically.
*/
export function unownedHint(
src: { id: string; local_path: string | null },
state: RepoState,
): string {
const path = src.local_path ?? '(none)';
if (state === 'healthy') {
return (
`Source "${src.id}" has config.remote_url set but local_path ${path} is not a ` +
`clone gbrain created. gbrain syncs it read-only and will never re-clone or delete ` +
`it. To silence this, drop config.remote_url, or re-register with --url so gbrain ` +
`owns the clone.`
);
}
return (
`Source "${src.id}" has config.remote_url set but local_path ${path} is not a clone ` +
`gbrain created and is not a usable git repo (state: ${state}). gbrain will NOT ` +
`re-clone over it (it is your working tree, not a gbrain-managed mirror). Restore the ` +
`directory yourself, or remove + re-add the source with --url to let gbrain manage the ` +
`clone.`
);
}
// ── addSource ───────────────────────────────────────────────────────────────
export async function addSource(
@@ -327,7 +392,14 @@ export async function addSource(
throw e;
}
const config: Record<string, unknown> = { remote_url: parsedUrl.url };
// managed_clone:true is the ownership marker (#1881). It authorizes
// recloneIfMissing to rm+replace this clone — gbrain created it, here or at
// a --clone-dir path. A user-tree row (created by an external INSERT, no
// --url) never carries this, so it can never be deleted by reclone.
const config: Record<string, unknown> = {
remote_url: parsedUrl.url,
managed_clone: true,
};
if (opts.federated !== null && opts.federated !== undefined) {
config.federated = opts.federated;
}
@@ -698,9 +770,22 @@ export async function recloneIfMissing(
const state = validateRepoState(src.local_path, remoteUrl);
if (state === 'healthy') return false;
// Re-clone via temp + rename, mirroring addSource's atomicity contract.
const tempDir = makeTempCloneDir(id);
mkdirSync(dirname(tempDir), { recursive: true });
// #1881 ownership guard — abort BEFORE any filesystem op. recloneIfMissing
// deletes local_path; gbrain may only do that to a clone it created, never a
// user working tree. A row with remote_url + an unowned local_path (the
// gstack-orchestrator federated shape) is refused here, loudly, untouched.
if (!isOwnedClone(src)) {
throw new SourceOpError('unmanaged_path', unownedHint(src, state));
}
// EXDEV-safe atomic reclone. Clone into a SIBLING temp of local_path (not the
// shared clones/.tmp, which can be on a different mount than a --clone-dir
// target → EXDEV → "deleted but not recloned"). Then swap: move old aside →
// move new in → drop old, so local_path is never left missing-and-unrecoverable.
const parent = dirname(src.local_path);
mkdirSync(parent, { recursive: true });
const rand = randomBytes(6).toString('hex');
const tempDir = join(parent, `.gbrain-reclone-${basename(src.local_path)}-${rand}`);
try {
cloneRepo(remoteUrl, tempDir);
} catch (e) {
@@ -711,19 +796,62 @@ export async function recloneIfMissing(
throw e;
}
// If the local_path partially exists (e.g., empty dir, file-not-dir), nuke
// it before the rename so renameSync doesn't fail on a non-empty target.
rmSync(src.local_path, { recursive: true, force: true });
mkdirSync(dirname(src.local_path), { recursive: true });
try {
renameSync(tempDir, src.local_path);
} catch (e) {
// TOCTOU re-check immediately before the destructive move: re-confirm
// ownership AND reject a symlink leaf swapped in after the entry check (never
// rm-rf / rename through a symlink).
if (!isOwnedClone(src)) {
rmSync(tempDir, { recursive: true, force: true });
throw new SourceOpError('unmanaged_path', unownedHint(src, state));
}
let aside: string | null = null;
try {
if (existsSync(src.local_path)) {
// Symlink leaf guard: never rename/rm *through* a symlinked leaf — that's
// the TOCTOU swap-in vector (an attacker plants a symlink at local_path
// between the entry ownership check and this rename). An owned clone's leaf
// is a real dir gbrain created; a symlink here means tamper, so fail closed.
// (Symlinked ANCESTORS are intentionally NOT rejected here: for an owned
// clone gbrain created the dir at this path — cloneRepo refuses a non-empty
// dest, so a pre-existing user tree can never become an owned clone — and a
// realpath-chain check false-positives on ubiquitous system symlinks like
// macOS /var -> /private/var. The residual DB-trust risk, a forged
// managed_clone marker on an arbitrary path, is tracked as a TODO and is
// not closable by a path check.)
if (lstatSync(src.local_path).isSymbolicLink()) {
rmSync(tempDir, { recursive: true, force: true });
throw new SourceOpError(
'symlink_escape',
`Refusing to re-clone "${id}": local_path ${src.local_path} is a symlink.`,
);
}
aside = `${src.local_path}.old-${rand}`;
renameSync(src.local_path, aside); // same fs (sibling) — no EXDEV
}
renameSync(tempDir, src.local_path); // same fs — no EXDEV
} catch (e) {
// Best-effort restore of the original if the swap left local_path missing.
if (aside && !existsSync(src.local_path)) {
try {
renameSync(aside, src.local_path);
} catch {
/* original kept at `aside`; surfaced via the thrown error below */
}
}
rmSync(tempDir, { recursive: true, force: true });
if (e instanceof SourceOpError) throw e;
// If the original is still parked at `aside` (restore failed), tell the user
// exactly where it is — otherwise a "cleanup the failed reclone" reflex would
// delete their only copy.
const asideNote =
aside && existsSync(aside)
? ` Your original clone is preserved at ${aside} — restore it manually; do not delete it.`
: '';
throw new SourceOpError(
'rename_failed',
`Could not move re-cloned repo to ${src.local_path}: ${(e as Error).message}`,
`Could not move re-cloned repo to ${src.local_path}: ${(e as Error).message}.${asideNote}`,
e,
);
}
if (aside) rmSync(aside, { recursive: true, force: true });
return true;
}