Files
gbrain/src/core/sources-ops.ts
T
Garry TanandClaude Opus 4.8 213dfa8bc0 fix(security): confine routing dotfiles, skills dir, slugs, and transcription exec
Shared src/core/path-confine.ts consolidates the realpath-containment idiom
(moved from sources-ops.ts) and adds isTrustedDotfile + isWriteTargetContained.

- .gbrain-source (source-resolver) and .gbrain-mount (brain-resolver) walk-up
  dotfiles are now lstat trust-gated: a symlink, foreign-owned, or world-writable
  file is refused on multi-user hosts (#418), fail-closed on stat error.
- resolveWorkspaceSkillsDir + every skills-dir tier (env, cwd_walk_up, repo_root,
  cwd_skills, install_path) route through realpath containment so a symlinked
  workspace/skills can't escape the declared workspace (#419).
- resolveSourceId/resolveBrainId realpath both sides of the registered local_path
  / mount prefix match so a symlinked cwd can't misattribute source/brain.
- validateSlug rejects NUL/control, bidi/RTL overrides, backslashes, and
  URL-encoded path separators at the shared putPage/updateSlug chokepoint;
  write-through confirms the file path stays within the source tree.
- transcribeLargeFile uses execFileSync arg-arrays + fs.rmSync (no shell), so a
  path with shell metacharacters is never parsed by a shell (#245).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 15:38:29 -07:00

840 lines
31 KiB
TypeScript

/**
* gbrain sources-ops — pure async functions for source-management operations
* (v0.28). Extracted from src/commands/sources.ts so the CLI handlers and the
* MCP ops (sources_add / list / remove / status) share one implementation.
*
* Atomicity contract for addSource with --url (D3, eng-review):
*
* sources add --url <url>
* │
* ▼
* parseRemoteUrl(url) → SSRF gate
* │
* ▼ (URL ok)
* pre-flight SELECT id ─── id taken? ──► error (Q4)
* │
* ▼ (id free)
* mkdir $GBRAIN_HOME/clones/.tmp/<id>-<rand>/
* │
* ▼
* cloneRepo(url, tmp/) ─── fail ──► rm -rf tmp/, throw
* │
* ▼
* INSERT INTO sources ─── fail ──► rm -rf tmp/, throw
* │
* ▼
* fs.renameSync(tmp/, final) ─── fail ──► rm -rf tmp/, throw
* + best-effort
* DELETE row
* │
* ▼
* return SourceRow
*
* Symlink-safe clone-cleanup for removeSource: realpath + lstat confinement
* mirroring src/core/operations.ts:61 validateUploadPath. String startsWith
* is symlink-unsafe and would let $GBRAIN_HOME/clones/<id> → /etc resolve
* out of the confine.
*/
import { existsSync, mkdirSync, renameSync, rmSync, lstatSync } from 'fs';
import { join, dirname, basename, resolve as resolvePath } from 'path';
import { isPathContained } from './path-confine.ts';
import { randomBytes } from 'crypto';
import type { BrainEngine } from './engine.ts';
import {
parseRemoteUrl,
cloneRepo,
validateRepoState,
RemoteUrlError,
GitOperationError,
type RepoState,
} from './git-remote.ts';
import { gbrainPath } from './config.ts';
import { isValidSourceId } from './source-id.ts';
import { resolveSourceWithTier, type SourceTier } from './source-resolver.ts';
// ── Errors ──────────────────────────────────────────────────────────────────
export type SourceOpErrorCode =
| 'invalid_id'
| 'source_id_taken'
| 'overlapping_path'
| 'invalid_remote_url'
| 'clone_failed'
| 'insert_failed'
| 'rename_failed'
| 'not_found'
| 'protected_id'
| 'clone_dir_outside_gbrain'
| 'symlink_escape'
| 'unmanaged_path';
export class SourceOpError extends Error {
constructor(
public code: SourceOpErrorCode,
message: string,
public cause?: unknown,
) {
super(message);
this.name = 'SourceOpError';
}
}
// ── Types ───────────────────────────────────────────────────────────────────
export interface SourceRow {
id: string;
name: string;
local_path: string | null;
last_commit: string | null;
last_sync_at: Date | null;
config: Record<string, unknown>;
created_at: Date;
/**
* v0.40.3.0: per-source CR mode override. NULL falls through to global
* mode bundle. Written only by `gbrain sources set-cr-mode <id> <mode>`
* (CLI-write-only per D15 security gate); MCP / OAuth callers cannot
* mutate this field.
*/
contextual_retrieval_mode?: string | null;
/**
* v0.40.3.0: per-source mount-frontmatter trust gate (D15). FALSE for
* mounted sources by default. Flipped via
* `gbrain mounts trust-frontmatter <id>`. Host source (id='default') is
* always trusted in the resolver regardless of this column value.
*/
trust_frontmatter_overrides?: boolean;
}
export interface SourceListEntry {
id: string;
name: string;
local_path: string | null;
remote_url: string | null;
federated: boolean;
page_count: number;
last_sync_at: string | null;
}
export interface SourceStatus {
id: string;
name: string;
local_path: string | null;
remote_url: string | null;
federated: boolean;
page_count: number;
last_sync_at: string | null;
last_commit: string | null;
archived: boolean;
/**
* Discriminated union from validateRepoState. 'not-applicable' if the
* source has no local_path (pure DB source). Lets a remote MCP caller
* diagnose "is the clone OK?" without SSH access to the brain host.
*/
clone_state: RepoState | 'not-applicable';
}
export interface AddSourceOpts {
id: string;
name?: string;
localPath?: string | null;
remoteUrl?: string;
federated?: boolean | null;
/**
* Override clone destination. Defaults to $GBRAIN_HOME/clones/<id>/.
* Only honored when remoteUrl is set.
*/
cloneDir?: string;
}
export interface RemoveSourceOpts {
id: string;
confirmDestructive?: boolean;
yes?: boolean;
dryRun?: boolean;
keepStorage?: boolean;
}
// ── Helpers ─────────────────────────────────────────────────────────────────
/**
* Validate via the canonical regex from `source-id.ts` but rethrow as the
* sources-ops-tagged error so `gbrain sources add` keeps its user-facing
* SourceOpError shape. The regex itself is in one place; only the error
* envelope differs per caller.
*/
function validateSourceId(id: string): void {
if (!isValidSourceId(id)) {
throw new SourceOpError(
'invalid_id',
`Invalid source id "${id}". Must be 1-32 lowercase alnum chars with optional interior hyphens.`,
);
}
}
function parseConfig(config: unknown): Record<string, unknown> {
if (typeof config === 'string') {
try {
return JSON.parse(config) as Record<string, unknown>;
} catch {
return {};
}
}
if (typeof config === 'object' && config !== null) return config as Record<string, unknown>;
return {};
}
function isFederated(config: unknown): boolean {
return parseConfig(config).federated === true;
}
function getRemoteUrl(config: unknown): string | null {
const v = parseConfig(config).remote_url;
return typeof v === 'string' ? v : null;
}
async function fetchSourceRow(engine: BrainEngine, id: string): Promise<SourceRow | null> {
const rows = await engine.executeRaw<{
id: string;
name: string;
local_path: string | null;
last_commit: string | null;
last_sync_at: Date | null;
config: unknown;
created_at: Date;
}>(
`SELECT id, name, local_path, last_commit, last_sync_at, config, created_at
FROM sources WHERE id = $1`,
[id],
);
const r = rows[0];
if (!r) return null;
return { ...r, config: parseConfig(r.config) };
}
async function countPages(engine: BrainEngine, id: string): Promise<number> {
const rows = await engine.executeRaw<{ n: number }>(
`SELECT COUNT(*)::int AS n FROM pages WHERE source_id = $1`,
[id],
);
return rows[0]?.n ?? 0;
}
/** Default clone dir for a remote-URL source: $GBRAIN_HOME/clones/<id>/ */
export function defaultCloneDir(id: string): string {
return gbrainPath('clones', id);
}
/** Temp clone dir under $GBRAIN_HOME/clones/.tmp/<id>-<rand>/ */
function makeTempCloneDir(id: string): string {
const rand = randomBytes(6).toString('hex');
return gbrainPath('clones', '.tmp', `${id}-${rand}`);
}
// `isPathContained` moved to `src/core/path-confine.ts` (shared with the
// dotfile-trust + skills-dir confinement helpers). Re-exported here so existing
// importers (and recloneIfMissing below) keep working unchanged.
export { isPathContained };
/**
* 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(
engine: BrainEngine,
opts: AddSourceOpts,
): Promise<SourceRow> {
validateSourceId(opts.id);
// Q4: pre-flight collision check before any clone work.
const existing = await engine.executeRaw<{ id: string }>(
`SELECT id FROM sources WHERE id = $1`,
[opts.id],
);
if (existing.length > 0) {
throw new SourceOpError(
'source_id_taken',
`Source id "${opts.id}" is already registered. ` +
`Use 'gbrain sources remove ${opts.id} --confirm-destructive' first, then re-add.`,
);
}
// Validate URL before doing any filesystem work.
let parsedUrl: { url: string; hostname: string } | null = null;
if (opts.remoteUrl) {
try {
parsedUrl = parseRemoteUrl(opts.remoteUrl);
} catch (e) {
if (e instanceof RemoteUrlError) {
throw new SourceOpError('invalid_remote_url', e.message, e);
}
throw e;
}
}
// Overlap check for any local path (existing behavior).
let finalPath = opts.localPath ?? null;
if (parsedUrl) {
finalPath = opts.cloneDir ?? defaultCloneDir(opts.id);
}
if (finalPath) {
const others = await engine.executeRaw<{ id: string; local_path: string }>(
`SELECT id, local_path FROM sources WHERE local_path IS NOT NULL AND id != $1`,
[opts.id],
);
for (const other of others) {
const a = finalPath;
const b = other.local_path;
if (a === b || a.startsWith(b + '/') || b.startsWith(a + '/')) {
throw new SourceOpError(
'overlapping_path',
`path "${a}" overlaps with existing source "${other.id}" at "${b}". ` +
`Overlapping sources are not allowed.`,
);
}
}
}
// ── Path A: --url (clone + INSERT + rename) ────────────────────────────
if (parsedUrl) {
const tempDir = makeTempCloneDir(opts.id);
mkdirSync(dirname(tempDir), { recursive: true });
try {
cloneRepo(parsedUrl.url, tempDir);
} catch (e) {
// Clone failed before we've touched the DB. tempDir may or may not
// exist; nuke it just in case.
rmSync(tempDir, { recursive: true, force: true });
if (e instanceof GitOperationError) {
throw new SourceOpError('clone_failed', e.message, e);
}
throw e;
}
// 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;
}
const displayName = opts.name ?? opts.id;
try {
await engine.executeRaw(
`INSERT INTO sources (id, name, local_path, config)
VALUES ($1, $2, $3, $4::jsonb)`,
[opts.id, displayName, finalPath, JSON.stringify(config)],
);
} catch (e) {
rmSync(tempDir, { recursive: true, force: true });
throw new SourceOpError(
'insert_failed',
`INSERT failed for source "${opts.id}": ${(e as Error).message}`,
e,
);
}
// Final step: rename temp dir to final clone path. EXDEV (cross-device
// rename) is rare on a single-host brain but possible if $GBRAIN_HOME
// and the temp dir are on different mounts. We don't fall back to
// recursive copy because the temp dir is in $GBRAIN_HOME by design.
try {
mkdirSync(dirname(finalPath!), { recursive: true });
// Refuse to rename over an existing path. If finalPath exists at this
// point (race: another process created it between our pre-flight and
// now), back out cleanly.
if (existsSync(finalPath!)) {
throw new Error(`destination ${finalPath} appeared mid-flight`);
}
renameSync(tempDir, finalPath!);
} catch (e) {
rmSync(tempDir, { recursive: true, force: true });
// Best-effort DB rollback.
await engine
.executeRaw(`DELETE FROM sources WHERE id = $1`, [opts.id])
.catch(() => {});
throw new SourceOpError(
'rename_failed',
`Could not move clone to final path ${finalPath}: ${(e as Error).message}`,
e,
);
}
} else {
// ── Path B: --path or no path (existing behavior, pre-v0.28) ─────────
const config: Record<string, unknown> = {};
if (opts.federated !== null && opts.federated !== undefined) {
config.federated = opts.federated;
}
const displayName = opts.name ?? opts.id;
await engine.executeRaw(
`INSERT INTO sources (id, name, local_path, config)
VALUES ($1, $2, $3, $4::jsonb)`,
[opts.id, displayName, finalPath, JSON.stringify(config)],
);
}
const created = await fetchSourceRow(engine, opts.id);
if (!created) {
throw new SourceOpError(
'insert_failed',
`Source "${opts.id}" disappeared after INSERT (concurrent delete?).`,
);
}
return created;
}
// ── resolveDefaultSource ────────────────────────────────────────────────────
//
// v0.34 W0b — canonical helper for CLI commands that take an optional
// --source flag. The contract per the eng review D7:
// - exactly 1 registered source → return its id (single-source brains,
// the 80% case; --source flag is unnecessary friction)
// - 0 sources → throw (no source to scope to)
// - 2+ sources → throw with the list, forcing the caller to be explicit
//
// Codex finding #7: src/commands/code-callers.ts:54 + code-callees.ts:43
// historically set `allSources: allSources || !sourceId` — which means
// the documented "source-scoped by default" behavior INVERTED to global
// whenever `--source` was omitted. Multi-source brains silently
// cross-contaminated structural retrieval despite the docstring claim.
//
// Helper consolidates the resolution rule so blast/flow/clusters/wiki
// (v0.34 new commands) and code-callers/callees (v0.20.0 retrofit)
// behave identically.
export class SourceResolutionError extends Error {
constructor(
message: string,
public readonly code: 'no_sources' | 'multiple_sources_ambiguous',
public readonly availableSources: string[],
) {
super(message);
this.name = 'SourceResolutionError';
}
}
export async function resolveDefaultSource(engine: BrainEngine): Promise<string> {
const sources = await listSources(engine);
if (sources.length === 0) {
throw new SourceResolutionError(
'no sources registered; run `gbrain sources add` first',
'no_sources',
[],
);
}
if (sources.length === 1) {
return sources[0]!.id;
}
const ids = sources.map((s) => s.id);
throw new SourceResolutionError(
`multi-source brain — specify --source from: ${ids.join(', ')}`,
'multiple_sources_ambiguous',
ids,
);
}
/** Result of `resolveScopedSourceOrThrow`: the resolved source id plus the
* tier that won, so callers can nudge (sole_non_default) or surface the
* source in their output envelope. */
export interface ScopedSourceResolution {
source_id: string;
tier: SourceTier;
}
/**
* Source scope for the structural-retrieval commands (`code-callers` /
* `code-callees`) when neither `--source` nor `--all-sources` is given.
*
* Runs the FULL 7-tier resolution chain via `resolveSourceWithTier`
* (flag → env → dotfile → local_path → brain_default → sole_non_default →
* seed_default), so a `.gbrain-source` pin (or any real signal) selects the
* source. The multi-source ambiguity guard (`resolveDefaultSource`) is
* applied ONLY when the chain matched nothing real (tier `seed_default`):
* 1 source → returns it, 0 → `no_sources` throw, 2+ → `multiple_sources_ambiguous`.
*
* Contrast with `resolveSourceId` (silently returns `'default'` and never
* throws on ambiguity) — this helper deliberately preserves the loud
* multi-source error when there's genuinely no signal.
*
* @throws SourceResolutionError on a no-signal 0/2+-source brain (seed_default tier).
* @throws Error ("Source \"…\" not found." / "Invalid …") on a bad pin / env value
* via `assertSourceExists` inside `resolveSourceWithTier` — callers should
* surface these as clean usage errors, not uncaught stacks.
*/
export async function resolveScopedSourceOrThrow(
engine: BrainEngine,
cwd: string = process.cwd(),
): Promise<ScopedSourceResolution> {
const resolved = await resolveSourceWithTier(engine, null, cwd);
if (resolved.tier !== 'seed_default') {
return { source_id: resolved.source_id, tier: resolved.tier };
}
// Nothing in the chain matched → apply the ambiguity guard (may throw).
const id = await resolveDefaultSource(engine);
return { source_id: id, tier: 'seed_default' };
}
// ── listSources ─────────────────────────────────────────────────────────────
export async function listSources(
engine: BrainEngine,
opts: { includeArchived?: boolean } = {},
): Promise<SourceListEntry[]> {
// v0.28.1 codex finding (MEDIUM): the prior version ignored the
// includeArchived flag and returned every row. That leaked archived
// sources' ids, local_paths, and remote_urls to read-scoped MCP callers
// who shouldn't see soft-deleted state. Filter at the SQL level so the
// archived rows never reach the wire by default.
const archivedFilter = opts.includeArchived
? ''
: 'WHERE archived IS NOT TRUE';
const rows = await engine.executeRaw<{
id: string;
name: string;
local_path: string | null;
last_sync_at: Date | null;
config: unknown;
}>(
`SELECT id, name, local_path, last_sync_at, config
FROM sources ${archivedFilter} ORDER BY (id = 'default') DESC, id`,
);
const out: SourceListEntry[] = [];
for (const r of rows) {
const cfg = parseConfig(r.config);
out.push({
id: r.id,
name: r.name,
local_path: r.local_path,
remote_url: typeof cfg.remote_url === 'string' ? cfg.remote_url : null,
federated: cfg.federated === true,
page_count: await countPages(engine, r.id),
last_sync_at: r.last_sync_at ? new Date(r.last_sync_at).toISOString() : null,
});
}
return out;
}
// ── removeSource ────────────────────────────────────────────────────────────
export interface RemoveResult {
id: string;
pages_deleted: number;
clone_removed: boolean;
clone_path: string | null;
dryRun: boolean;
}
/**
* Hard-remove a source row + cascade. v0.28 additions:
* - protected-id guard for "default"
* - clone-cleanup: delete the on-disk clone IFF its resolved path is
* confined under $GBRAIN_HOME/clones/. realpath+lstat (not startsWith)
* to defeat symlink escape attacks.
*
* Soft-delete (archive / restore) lives in destructive-guard.ts and is the
* preferred path for users; this hard-remove is for the admin operator
* confirming via --confirm-destructive after the impact preview.
*/
export async function removeSource(
engine: BrainEngine,
opts: RemoveSourceOpts,
): Promise<RemoveResult> {
validateSourceId(opts.id);
if (opts.id === 'default') {
throw new SourceOpError(
'protected_id',
'Cannot remove the "default" source (it backs the pre-v0.17 brain).',
);
}
const src = await fetchSourceRow(engine, opts.id);
if (!src) {
throw new SourceOpError('not_found', `Source "${opts.id}" not found.`);
}
const pageCount = await countPages(engine, opts.id);
if (opts.dryRun) {
return {
id: opts.id,
pages_deleted: pageCount,
clone_removed: false,
clone_path: src.local_path,
dryRun: true,
};
}
// Confirmation gate (caller should usually have already shown the impact
// preview from destructive-guard.ts).
if (pageCount > 0 && !opts.confirmDestructive && !opts.yes) {
throw new SourceOpError(
'protected_id', // closest existing code; caller can frame as "needs confirm"
`Refusing to remove source "${opts.id}" with ${pageCount} pages without --confirm-destructive or --yes.`,
);
}
// Decide whether we own the clone dir before removing the row.
const remoteUrl = getRemoteUrl(src.config);
const cloneRoot = gbrainPath('clones');
let cloneRemoved = false;
if (
!opts.keepStorage &&
src.local_path &&
remoteUrl && // only auto-clean when this was a --url-managed clone
isPathContained(src.local_path, cloneRoot)
) {
try {
// Extra symlink-escape paranoia: lstat the resolved final path; if
// it's a symlink itself (not just contained under the parent), bail
// out rather than rm -rf following the link.
const lst = lstatSync(src.local_path);
if (lst.isSymbolicLink()) {
throw new SourceOpError(
'symlink_escape',
`Refusing to delete clone at ${src.local_path}: path is a symlink.`,
);
}
rmSync(src.local_path, { recursive: true, force: true });
cloneRemoved = true;
} catch (e) {
if (e instanceof SourceOpError) throw e;
// Don't fail the whole remove if rmSync had a permission hiccup — log
// and continue. The DB row deletion is the user-facing operation.
console.error(
`[gbrain] WARN: clone cleanup at ${src.local_path} failed: ${(e as Error).message}`,
);
}
}
await engine.executeRaw(`DELETE FROM sources WHERE id = $1`, [opts.id]);
return {
id: opts.id,
pages_deleted: pageCount,
clone_removed: cloneRemoved,
clone_path: src.local_path,
dryRun: false,
};
}
// ── getSourceStatus ─────────────────────────────────────────────────────────
export async function getSourceStatus(
engine: BrainEngine,
id: string,
): Promise<SourceStatus> {
validateSourceId(id);
const src = await fetchSourceRow(engine, id);
if (!src) {
throw new SourceOpError('not_found', `Source "${id}" not found.`);
}
// Archived check — sources.config.archived is a forward-compat slot;
// schema.sql also has dedicated `archived` column post-v0.26.5. Read the
// column directly via a separate query so we don't need to widen the
// SourceRow shape just for status.
const archivedRows = await engine.executeRaw<{ archived: boolean | null }>(
`SELECT archived FROM sources WHERE id = $1`,
[id],
);
const archived = archivedRows[0]?.archived === true;
const remoteUrl = getRemoteUrl(src.config);
let cloneState: SourceStatus['clone_state'] = 'not-applicable';
if (src.local_path) {
cloneState = validateRepoState(src.local_path, remoteUrl ?? undefined);
}
return {
id: src.id,
name: src.name,
local_path: src.local_path,
remote_url: remoteUrl,
federated: isFederated(src.config),
page_count: await countPages(engine, id),
last_sync_at: src.last_sync_at ? new Date(src.last_sync_at).toISOString() : null,
last_commit: src.last_commit,
archived,
clone_state: cloneState,
};
}
// ── recloneIfNeeded (used by sources.ts restore path) ──────────────────────
/**
* Re-clone a source's remote_url into its local_path if the clone is
* missing on disk. Used by `gbrain sources restore` after an operator
* autopurged $GBRAIN_HOME/clones/. Idempotent: returns false (didn't clone)
* if the clone is already there.
*
* Throws SourceOpError on clone failure. Does NOT touch the DB row.
*/
export async function recloneIfMissing(
engine: BrainEngine,
id: string,
): Promise<boolean> {
const src = await fetchSourceRow(engine, id);
if (!src) {
throw new SourceOpError('not_found', `Source "${id}" not found.`);
}
const remoteUrl = getRemoteUrl(src.config);
if (!remoteUrl || !src.local_path) return false;
const state = validateRepoState(src.local_path, remoteUrl);
if (state === 'healthy') return false;
// #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) {
rmSync(tempDir, { recursive: true, force: true });
if (e instanceof GitOperationError) {
throw new SourceOpError('clone_failed', e.message, e);
}
throw 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}.${asideNote}`,
e,
);
}
if (aside) rmSync(aside, { recursive: true, force: true });
return true;
}