mirror of https://github.com/garrytan/gstack.git
fix(gbrain-sync): defensive guards against destructive gbrain ops (#1734)
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) <noreply@anthropic.com>
This commit is contained in:
parent
9e48669c61
commit
8933cf7c0e
|
|
@ -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 <id> --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<StageResult> {
|
|||
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<StageResult> {
|
|||
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,
|
||||
|
|
|
|||
|
|
@ -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 <id>` 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 <id>` 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" };
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue