From 8933cf7c0e396e4bee44afa512dbdbdc1e18df9d Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Sat, 30 May 2026 10:49:31 -0700 Subject: [PATCH] fix(gbrain-sync): defensive guards against destructive gbrain ops (#1734) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The orchestrator shelled out to gbrain's destructive subcommands as if they were safe. gbrain can rm-rf a user's working tree during an autopilot race (its own bug, upstream gbrain #1526); gstack now defends itself. New lib/gbrain-guards.ts gates the two destructive reach points, all checked immediately before the op: - Autopilot refuse (multi-signal, affirmative-only): refuse a destructive op when a live 'gbrain autopilot' process (primary) or a known autopilot lock file (secondary; checked under both GBRAIN_HOME and ~/.gbrain since gbrain #1226 ignores GBRAIN_HOME) is present. No signal → proceed; inability to introspect never bricks a normal sync. - sources remove: routed through safeSourcesRemove → decideSourceRemove. Fail CLOSED — refuse to remove a user-managed source (remote_url set, local_path outside gbrain's clones) when gbrain has no --keep-storage to protect the files (it doesn't in 0.41.x). Also fail closed when the source list can't be read. Path containment uses realpath so a symlink can't smuggle a delete out of clones. - sync --strategy code: decideCodeSync refuses URL-managed sources (remote_url set) unless --allow-reclone is passed, since the walk can auto-reclone (rm-rf). Capability detection memoizes per process keyed to gbrain's identity (no stale persistent cache); --keep-storage can't be probed (generic help) so it defaults unsupported → fail closed. Every guard surfaces a visible reason; autopilot/reclone refusals fail the code stage (verdict ERR) rather than silently skipping protection. test/gbrain-guards.test.ts covers all branches hermetically (injected rows + probe overrides): autopilot signals, fail-closed remove, keep-storage path, reclone gate, realpath/symlink containment. Supersedes #1736 (which guarded a nonexistent path). Co-Authored-By: Claude Opus 4.8 (1M context) --- bin/gstack-gbrain-sync.ts | 97 +++++++++++--- lib/gbrain-guards.ts | 266 +++++++++++++++++++++++++++++++++++++ test/gbrain-guards.test.ts | 140 +++++++++++++++++++ 3 files changed, 484 insertions(+), 19 deletions(-) create mode 100644 lib/gbrain-guards.ts create mode 100644 test/gbrain-guards.test.ts diff --git a/bin/gstack-gbrain-sync.ts b/bin/gstack-gbrain-sync.ts index 2515a763c..d88fc51a4 100644 --- a/bin/gstack-gbrain-sync.ts +++ b/bin/gstack-gbrain-sync.ts @@ -38,6 +38,7 @@ import { createHash } from "crypto"; import "../lib/conductor-env-shim"; import { detectEngineTier, withErrorContext, canonicalizeRemote } from "../lib/gstack-memory-helpers"; import { ensureSourceRegistered, sourcePageCount, parseSourcesList } from "../lib/gbrain-sources"; +import { detectAutopilot, decideSourceRemove, decideCodeSync } from "../lib/gbrain-guards"; import { localEngineStatus, type LocalEngineStatus } from "../lib/gbrain-local-status"; import { buildGbrainEnv, spawnGbrain, execGbrainJson, NEEDS_SHELL_ON_WINDOWS } from "../lib/gbrain-exec"; @@ -52,6 +53,8 @@ interface CliArgs { noMemory: boolean; noBrainSync: boolean; codeOnly: boolean; + /** #1734: opt-in to sync a URL-managed source whose code walk may auto-reclone. */ + allowReclone: boolean; } interface CodeStageDetail { @@ -59,7 +62,7 @@ interface CodeStageDetail { source_path?: string; page_count?: number | null; last_imported?: string; - status?: "ok" | "skipped" | "failed"; + status?: "ok" | "skipped" | "failed" | "refused-autopilot" | "refused-reclone"; } interface StageResult { @@ -205,6 +208,8 @@ Options: --no-memory Skip the gstack-memory-ingest stage (transcripts + artifacts). --no-brain-sync Skip the gstack-brain-sync git pipeline stage. --code-only Only run the code-import stage (alias for --no-memory --no-brain-sync). + --allow-reclone Permit the code walk for URL-managed sources (remote_url set) + even though gbrain may auto-reclone the working tree (#1734). --help This text. Stages run in order: code → memory ingest → curated git push. @@ -220,6 +225,7 @@ function parseArgs(): CliArgs { let noMemory = false; let noBrainSync = false; let codeOnly = false; + let allowReclone = false; for (let i = 0; i < args.length; i++) { const a = args[i]; @@ -231,6 +237,7 @@ function parseArgs(): CliArgs { case "--no-code": noCode = true; break; case "--no-memory": noMemory = true; break; case "--no-brain-sync": noBrainSync = true; break; + case "--allow-reclone": allowReclone = true; break; case "--code-only": codeOnly = true; noMemory = true; @@ -247,7 +254,7 @@ function parseArgs(): CliArgs { } } - return { mode, quiet, noCode, noMemory, noBrainSync, codeOnly }; + return { mode, quiet, noCode, noMemory, noBrainSync, codeOnly, allowReclone }; } // ── Helpers ──────────────────────────────────────────────────────────────── @@ -466,20 +473,50 @@ export function planHostnameFoldMigration( return { kind: "pending-cleanup", oldId: legacyPathHashId }; } +export interface GuardedRemoveResult { + removed: boolean; + /** True when a guard refused the remove (autopilot active or unsafe source). */ + skipped: boolean; + reason: string; +} + +/** + * #1734: run `gbrain sources remove --confirm-destructive` only behind the + * data-loss guards. Checked immediately before the destructive op (E8: as late + * as possible) so the autopilot window is as small as we can make it without a + * gbrain-side lease. Refuses when autopilot is active or when the source is + * user-managed and gbrain can't keep its storage. Pure side-effect helper; the + * caller decides whether a skip is fatal (it never is today — removes are + * best-effort cleanup). + */ +export function safeSourcesRemove(sourceId: string, env?: NodeJS.ProcessEnv): GuardedRemoveResult { + const ap = detectAutopilot(env); + if (ap.active) { + return { + removed: false, + skipped: true, + reason: `autopilot active (${ap.signal}); refusing destructive remove of ${sourceId}. ` + + `Stop autopilot, then re-run /sync-gbrain.`, + }; + } + const decision = decideSourceRemove(sourceId, env); + if (!decision.allow) { + return { removed: false, skipped: true, reason: decision.reason }; + } + const r = spawnGbrain( + ["sources", "remove", sourceId, "--confirm-destructive", ...decision.extraArgs], + { baseEnv: env }, + ); + return { removed: r.status === 0, skipped: false, reason: decision.reason }; +} + /** * Remove an orphaned source. Called only after new-source sync verifies pages - * exist, so the old source is provably redundant before deletion. - * - * Flag note: existing call sites used `--confirm-destructive` here and - * `--yes` in `lib/gbrain-sources.ts` — gbrain 0.35.0.0 accepts neither - * deterministically (the subcommand surface help is generic). We pass - * `--confirm-destructive` to match the existing call site convention; the - * flag-helper centralization in commit 4 (lib/gbrain-exec.ts) will resolve - * the inconsistency across the codebase. + * exist, so the old source is provably redundant before deletion. Routed through + * safeSourcesRemove for the #1734 guards. */ export function removeOrphanedSource(oldId: string, env?: NodeJS.ProcessEnv): boolean { - const r = spawnGbrain(["sources", "remove", oldId, "--confirm-destructive"], { baseEnv: env }); - return r.status === 0; + return safeSourcesRemove(oldId, env).removed; } /** @@ -658,13 +695,12 @@ async function runCodeImport(args: CliArgs): Promise { const legacyId = deriveLegacyCodeSourceId(root); let legacyRemoved = false; if (legacyId !== sourceId) { - const rm = spawnGbrain(["sources", "remove", legacyId, "--confirm-destructive"], { - timeout: 30_000, - baseEnv: gbrainEnv, - }); - // Treat absent-source as success (clean state). gbrain emits "not found" on - // missing id; treat any non-zero exit without "not found" as a soft fail. - if (rm.status === 0) legacyRemoved = true; + // #1734: route through the data-loss guards (autopilot + source-safety). + const rm = safeSourcesRemove(legacyId, gbrainEnv); + if (rm.skipped && !args.quiet) { + console.error(`[sync:code] legacy-source cleanup skipped: ${rm.reason}`); + } + if (rm.removed) legacyRemoved = true; } // Step 0b: Hostname-fold migration (#1414). @@ -717,6 +753,29 @@ async function runCodeImport(args: CliArgs): Promise { process.env.GSTACK_SYNC_CODE_TIMEOUT_MS, "GSTACK_SYNC_CODE_TIMEOUT_MS", ); + + // #1734 guards, checked immediately before the destructive walk (E8): + // - autopilot active → refuse (the race that wiped a working tree). + // - URL-managed source → the walk can auto-reclone (rm-rf); require + // --allow-reclone. Both surface a visible reason and fail the stage so the + // verdict shows ERR rather than silently skipping protection. + const apBeforeWalk = detectAutopilot(gbrainEnv); + if (apBeforeWalk.active) { + return { + name: "code", ran: true, ok: false, duration_ms: Date.now() - t0, + summary: `refused: gbrain autopilot active (${apBeforeWalk.signal}). Stop autopilot, then re-run /sync-gbrain.`, + detail: { source_id: sourceId, source_path: root, status: "refused-autopilot" }, + }; + } + const reclone = decideCodeSync(sourceId, gbrainEnv, args.allowReclone); + if (!reclone.allow) { + return { + name: "code", ran: true, ok: false, duration_ms: Date.now() - t0, + summary: `refused: ${reclone.reason}`, + detail: { source_id: sourceId, source_path: root, status: "refused-reclone" }, + }; + } + const walkResult = spawnGbrain(["sync", "--strategy", "code", "--source", sourceId], { stdio: args.quiet ? ["ignore", "ignore", "ignore"] : ["ignore", "inherit", "inherit"], timeout: codeTimeoutMs, diff --git a/lib/gbrain-guards.ts b/lib/gbrain-guards.ts new file mode 100644 index 000000000..3a4edacba --- /dev/null +++ b/lib/gbrain-guards.ts @@ -0,0 +1,266 @@ +/** + * gbrain-guards — defense-in-depth against gbrain's destructive code paths (#1734). + * + * gbrain (the separate CLI gstack shells out to) can rm-rf a user's working tree + * during an autopilot race (its own bug, upstream gbrain #1526). gstack can't fix + * that, but it MUST stop treating gbrain's destructive subcommands as safe. These + * guards gate the two ways the orchestrator can reach destruction: + * + * 1. `sources remove --confirm-destructive` → decideSourceRemove() + * 2. `sync --strategy code` (can auto-reclone) → decideCodeSync() + * + * plus an autopilot-active check (detectAutopilot) that refuses to run destructive + * ops concurrently with the daemon. + * + * Design notes grounded in the real gbrain 0.41.x surface: + * - There is NO `--keep-storage` flag and NO structured capability command, and + * subcommand `--help` is generic — so capability detection is best-effort and + * defaults to "unsupported". When we can't protect a user-managed source's + * files, we FAIL CLOSED (refuse the remove) rather than delete unprotected. + * - The autopilot lock filename isn't documented and (gbrain #1226) ignores + * GBRAIN_HOME, so the live `gbrain autopilot` process is the PRIMARY signal; + * known lock paths under both the configured home and ~/.gbrain are secondary. + * - We refuse only on an AFFIRMATIVE autopilot signal — inability to introspect + * never blocks a normal sync (that would brick the tool). + * - Path containment uses realpath so a symlink inside ~/.gbrain/clones can't + * smuggle a delete out to a user repo. + * + * Pure decision functions; the orchestrator logs the reasons (observability). + */ + +import { spawnSync } from "child_process"; +import { existsSync, realpathSync } from "fs"; +import { homedir } from "os"; +import { join, resolve, sep } from "path"; +import { execGbrainJson, execGbrainText, NEEDS_SHELL_ON_WINDOWS } from "./gbrain-exec"; +import { parseSourcesList, type GbrainSourceRow } from "./gbrain-sources"; + +export function gbrainHome(env: NodeJS.ProcessEnv = process.env): string { + return env.GBRAIN_HOME || join(homedir(), ".gbrain"); +} + +/** + * Directories gbrain owns and may delete safely. A source whose local_path + * resolves inside one of these is gbrain-managed; outside = user-managed and + * must be protected. Both the configured home and the default ~/.gbrain are + * checked because gbrain #1226 shows home-resolution is inconsistent. + */ +function clonesDirs(env: NodeJS.ProcessEnv = process.env): string[] { + return [...new Set([join(gbrainHome(env), "clones"), join(homedir(), ".gbrain", "clones")])]; +} + +/** True if `p` resolves (symlinks + `..` collapsed) to a location inside `dir`. */ +export function isInside(p: string, dir: string): boolean { + let rp: string; + let rd: string; + try { rp = realpathSync(p); } catch { rp = resolve(p); } + try { rd = realpathSync(dir); } catch { rd = resolve(dir); } + const base = rd.endsWith(sep) ? rd : rd + sep; + return rp === rd || rp.startsWith(base); +} + +// ── Autopilot detection (E1: multi-signal, affirmative-only) ──────────────── + +export interface AutopilotStatus { + active: boolean; + /** Which signal fired (lock path or "process"), or null when inactive. */ + signal: string | null; +} + +export interface AutopilotProbe { + /** Override the lock-path list (tests). */ + lockPaths?: string[]; + /** Override the live-process check (tests). */ + processRunning?: () => boolean; +} + +/** + * Detect a running gbrain autopilot. Refuse the caller's destructive op only on + * an affirmative signal; absence of a confirmable mechanism returns inactive so + * normal syncs are never bricked. + */ +export function detectAutopilot( + env: NodeJS.ProcessEnv = process.env, + probe: AutopilotProbe = {}, +): AutopilotStatus { + // Secondary signal: known lock files. gbrain #1226 — the lock ignores + // GBRAIN_HOME, so check both the configured home and the default ~/.gbrain. + const lockPaths = probe.lockPaths ?? [ + join(gbrainHome(env), "autopilot.lock"), + join(homedir(), ".gbrain", "autopilot.lock"), + join(gbrainHome(env), "autopilot.pid"), + join(homedir(), ".gbrain", "autopilot.pid"), + ]; + for (const lp of lockPaths) { + if (existsSync(lp)) return { active: true, signal: `lock:${lp}` }; + } + // Primary signal: a live `gbrain autopilot` process. + const running = (probe.processRunning ?? defaultProcessRunning)(); + if (running) return { active: true, signal: "process:gbrain autopilot" }; + return { active: false, signal: null }; +} + +function defaultProcessRunning(): boolean { + // No reliable pgrep on Windows; rely on the lock-file signal there. + if (process.platform === "win32") return false; + const r = spawnSync("pgrep", ["-f", "gbrain autopilot"], { encoding: "utf-8", timeout: 3_000 }); + return r.status === 0 && (r.stdout || "").trim().length > 0; +} + +// ── Capability detection (E4 + Codex: per-process memo, no persistent cache) ─ +// +// No structured capability command exists and subcommand --help is generic, so +// --keep-storage support can't be probed reliably; default unsupported. Memoize +// per process (keyed to the resolved gbrain identity) rather than persisting a +// cross-run cache — Codex flagged stale persistent caches, and the probe is cheap. + +let _keepStorageMemo: { key: string; value: boolean } | undefined; + +function gbrainIdentity(env: NodeJS.ProcessEnv): string { + const r = spawnSync("gbrain", ["--version"], { + encoding: "utf-8", + timeout: 3_000, + shell: NEEDS_SHELL_ON_WINDOWS, + env, + }); + return (r.stdout || "").trim() || "unknown"; +} + +export function gbrainSupportsKeepStorage(env: NodeJS.ProcessEnv = process.env): boolean { + const key = gbrainIdentity(env); + if (_keepStorageMemo && _keepStorageMemo.key === key) return _keepStorageMemo.value; + let value = false; + for (const args of [["sources", "remove", "--help"], ["--help"]]) { + try { + if (/--keep-storage/.test(execGbrainText(args, { baseEnv: env, timeout: 5_000 }))) { + value = true; + break; + } + } catch { + // generic/empty help or non-zero exit → treat as unsupported + } + } + _keepStorageMemo = { key, value }; + return value; +} + +/** Test-only: reset the per-process capability memo. */ +export function _resetCapabilityMemo(): void { + _keepStorageMemo = undefined; +} + +// ── Destructive-op decisions ──────────────────────────────────────────────── + +/** + * Fetch + normalize the source list. Throws on read/parse failure so callers can + * distinguish "couldn't read" (fail closed) from "empty list" (source absent). + * Injectable for hermetic tests. + */ +export function fetchSources(env: NodeJS.ProcessEnv = process.env): GbrainSourceRow[] { + const raw = execGbrainJson(["sources", "list", "--json"], { baseEnv: env }); + if (raw === null) throw new Error("gbrain sources list returned no JSON"); + return parseSourcesList(raw); +} + +export interface RemoveDecision { + allow: boolean; + /** Extra args to append to `sources remove` (e.g. --keep-storage). */ + extraArgs: string[]; + reason: string; +} + +/** + * Decide whether `sources remove ` is safe, and with what flags. + * + * Fail-closed cases (allow=false): + * - sources list unreadable/unparseable (can't prove the row is safe). + * - the row is user-managed (remote_url set AND local_path outside gbrain's + * clones) and gbrain has no --keep-storage to protect the files. + * + * Allowed: absent row (no-op), gbrain-managed (inside clones), or path-managed + * without a remote_url (gbrain's remove won't touch an outside-clones path that + * it didn't clone). --keep-storage is appended whenever supported, as extra armor. + */ +export interface DecideRemoveOpts { + /** Override capability detection (tests / cached caps). */ + keepStorage?: boolean; + /** Override the source-list fetch (tests). Throwing simulates a read failure. */ + fetchRows?: (env: NodeJS.ProcessEnv) => GbrainSourceRow[]; +} + +export function decideSourceRemove( + sourceId: string, + env: NodeJS.ProcessEnv = process.env, + opts: DecideRemoveOpts = {}, +): RemoveDecision { + const keepStorage = opts.keepStorage ?? gbrainSupportsKeepStorage(env); + const extra = keepStorage ? ["--keep-storage"] : []; + + let rows: GbrainSourceRow[]; + try { + rows = (opts.fetchRows ?? fetchSources)(env); + } catch { + return { allow: false, extraArgs: [], reason: "could not read sources list; refusing remove (fail closed)" }; + } + + const row = rows.find((r) => r.id === sourceId); + if (!row) return { allow: true, extraArgs: extra, reason: "source absent (no-op)" }; + + const remoteUrl = row.config?.remote_url; + const userManaged = + !!remoteUrl && !!row.local_path && !clonesDirs(env).some((d) => isInside(row.local_path!, d)); + + if (userManaged) { + if (keepStorage) { + return { allow: true, extraArgs: ["--keep-storage"], reason: "user-managed; --keep-storage protects files" }; + } + return { + allow: false, + extraArgs: [], + reason: + `refusing remove of user-managed source "${sourceId}" (remote_url set, local_path ` + + `${row.local_path} outside gbrain clones) — this gbrain has no --keep-storage to ` + + `protect the working tree. Upgrade gbrain or remove the source manually.`, + }; + } + + return { allow: true, extraArgs: extra, reason: "gbrain-managed or path-managed without remote_url" }; +} + +export interface SyncDecision { + allow: boolean; + reason: string; +} + +/** + * Decide whether `sync --strategy code --source ` is safe to run. + * + * A source with a remote_url can trigger gbrain's auto-reclone, the ungated + * rm-rf path behind the data loss (gbrain #1526). Require an explicit + * --allow-reclone opt-in for URL-managed sources. Read failure here is NOT + * itself destructive, so it fails open (proceed) — the autopilot guard, checked + * first, is the primary protection against the race that caused the loss. + */ +export function decideCodeSync( + sourceId: string, + env: NodeJS.ProcessEnv = process.env, + allowReclone = false, + fetchRows: (env: NodeJS.ProcessEnv) => GbrainSourceRow[] = fetchSources, +): SyncDecision { + let rows: GbrainSourceRow[]; + try { + rows = fetchRows(env); + } catch { + return { allow: true, reason: "sources unreadable; proceeding (sync read is non-destructive)" }; + } + const row = rows.find((r) => r.id === sourceId); + if (row?.config?.remote_url && !allowReclone) { + return { + allow: false, + reason: + `source "${sourceId}" is URL-managed (remote_url set); sync may auto-reclone and ` + + `delete the working tree. Re-run /sync-gbrain with --allow-reclone to proceed.`, + }; + } + return { allow: true, reason: "no remote_url, or reclone explicitly allowed" }; +} diff --git a/test/gbrain-guards.test.ts b/test/gbrain-guards.test.ts new file mode 100644 index 000000000..0740148f9 --- /dev/null +++ b/test/gbrain-guards.test.ts @@ -0,0 +1,140 @@ +import { describe, test, expect, afterEach } from "bun:test"; +import * as fs from "fs"; +import * as os from "os"; +import { join } from "path"; +import { + detectAutopilot, + decideSourceRemove, + decideCodeSync, + isInside, + _resetCapabilityMemo, + type GbrainSourceRow, +} from "../lib/gbrain-guards"; + +const HOME = os.homedir(); +const clonesPath = (name: string) => join(HOME, ".gbrain", "clones", name); + +afterEach(() => _resetCapabilityMemo()); + +// ── #1734 autopilot detection (E1: affirmative multi-signal) ──────────────── +describe("detectAutopilot", () => { + test("refuses on a present lock file (secondary signal)", () => { + const tmp = fs.mkdtempSync(join(os.tmpdir(), "ap-")); + const lock = join(tmp, "autopilot.lock"); + fs.writeFileSync(lock, ""); + const r = detectAutopilot(process.env, { lockPaths: [lock], processRunning: () => false }); + expect(r.active).toBe(true); + expect(r.signal).toContain("lock:"); + }); + + test("refuses on a live autopilot process (primary signal)", () => { + const r = detectAutopilot(process.env, { lockPaths: [], processRunning: () => true }); + expect(r.active).toBe(true); + expect(r.signal).toBe("process:gbrain autopilot"); + }); + + test("proceeds when no signal fires (never blanket-refuses)", () => { + const r = detectAutopilot(process.env, { lockPaths: [], processRunning: () => false }); + expect(r.active).toBe(false); + expect(r.signal).toBeNull(); + }); +}); + +// ── #1734 remove safety (E7: fail closed on user-managed without keep-storage) ─ +describe("decideSourceRemove", () => { + const rows = (extra: GbrainSourceRow[] = []): GbrainSourceRow[] => [ + { id: "gbrain-managed", local_path: clonesPath("repo"), config: { remote_url: "https://x/r.git" } }, + { id: "user-managed", local_path: "/tmp/user-repo", config: { remote_url: "https://x/r.git" } }, + { id: "path-managed", local_path: "/tmp/path-repo" }, // no remote_url + ...extra, + ]; + const fetchRows = (extra?: GbrainSourceRow[]) => () => rows(extra); + + test("absent source → allow (no-op)", () => { + const d = decideSourceRemove("nope", process.env, { keepStorage: false, fetchRows: fetchRows() }); + expect(d.allow).toBe(true); + expect(d.reason).toContain("absent"); + }); + + test("user-managed + no --keep-storage → FAIL CLOSED", () => { + const d = decideSourceRemove("user-managed", process.env, { keepStorage: false, fetchRows: fetchRows() }); + expect(d.allow).toBe(false); + expect(d.reason).toContain("user-managed"); + }); + + test("user-managed + --keep-storage supported → allow with flag", () => { + const d = decideSourceRemove("user-managed", process.env, { keepStorage: true, fetchRows: fetchRows() }); + expect(d.allow).toBe(true); + expect(d.extraArgs).toContain("--keep-storage"); + }); + + test("gbrain-managed (inside clones) → allow even without keep-storage", () => { + const d = decideSourceRemove("gbrain-managed", process.env, { keepStorage: false, fetchRows: fetchRows() }); + expect(d.allow).toBe(true); + }); + + test("path-managed without remote_url → allow (normal --path case)", () => { + const d = decideSourceRemove("path-managed", process.env, { keepStorage: false, fetchRows: fetchRows() }); + expect(d.allow).toBe(true); + }); + + test("sources unreadable → FAIL CLOSED", () => { + const d = decideSourceRemove("user-managed", process.env, { + keepStorage: false, + fetchRows: () => { throw new Error("boom"); }, + }); + expect(d.allow).toBe(false); + expect(d.reason).toContain("fail closed"); + }); +}); + +// ── #1734 reclone guard (E-level: require --allow-reclone for URL-managed) ─── +describe("decideCodeSync", () => { + const rows: GbrainSourceRow[] = [ + { id: "url-managed", local_path: "/tmp/u", config: { remote_url: "https://x/r.git" } }, + { id: "plain", local_path: "/tmp/p" }, + ]; + const fetch = () => rows; + + test("URL-managed + no --allow-reclone → refuse", () => { + const d = decideCodeSync("url-managed", process.env, false, fetch); + expect(d.allow).toBe(false); + expect(d.reason).toContain("auto-reclone"); + }); + + test("URL-managed + --allow-reclone → allow", () => { + const d = decideCodeSync("url-managed", process.env, true, fetch); + expect(d.allow).toBe(true); + }); + + test("no remote_url → allow", () => { + const d = decideCodeSync("plain", process.env, false, fetch); + expect(d.allow).toBe(true); + }); + + test("sources unreadable → fail OPEN (sync read is non-destructive)", () => { + const d = decideCodeSync("url-managed", process.env, false, () => { throw new Error("boom"); }); + expect(d.allow).toBe(true); + }); +}); + +// ── path containment uses realpath (symlink can't smuggle a delete out) ────── +describe("isInside", () => { + test("plain path inside dir", () => { + expect(isInside("/a/b/c", "/a/b")).toBe(true); + expect(isInside("/a/x", "/a/b")).toBe(false); + }); + + test("sibling-prefix is not 'inside' (clonesX vs clones)", () => { + expect(isInside("/a/clones-evil/x", "/a/clones")).toBe(false); + }); + + test("symlink pointing outside resolves outside", () => { + const base = fs.mkdtempSync(join(os.tmpdir(), "clones-")); + const outside = fs.mkdtempSync(join(os.tmpdir(), "outside-")); + const link = join(base, "sneaky"); + fs.symlinkSync(outside, link); + // link lives under base, but realpath resolves to `outside` → not inside base. + expect(isInside(link, base)).toBe(false); + }); +});