From 613ad5bfef697281ff5b4b1a6d30539552ec8cf4 Mon Sep 17 00:00:00 2001 From: masashiono0611 Date: Sun, 19 Jul 2026 02:08:38 +0900 Subject: [PATCH] fix(sync): restrict git auto-init self-heal to the anchor-resolved path only (#2964) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second Codex review round (a10eeab) found the ownership check still too loose: - P1 (security): !opts.sourceId alone isn't proof gbrain owns repoPath. jobs.ts's `sync` job handler leaves sourceId undefined whenever job.data.repoPath doesn't match a registered source's local_path, so an admin-scope submit_job({name:'sync', data:{repoPath}}) MCP call could point the self-heal at an arbitrary directory and have it silently git-init + commit + ingest it. Gated both self-heal sites on !opts.repoPath too — only the path resolved from gbrain's own sync.repo_path anchor (never a caller-supplied one) is eligible. - P2: the unborn-HEAD recovery site calls discoverGitRoot, which walks UP from repoPath and can resolve to an ANCESTOR repo for a --src-subpath/subdir-as-repoPath sync with an unborn HEAD. Committing there would `git add -A` sibling files well outside the sync scope. Added a check that gitContextRoot === realpathSync(repoPath) before self-healing; refuses (falls through to the original error) otherwise. Tests rewritten to exercise the true self-heal-eligible path (anchor config via engine.setConfig('sync.repo_path', dir), no repoPath/sourceId passed) instead of an explicit repoPath, plus a new test asserting a caller-supplied repoPath with no sourceId still throws. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01NCVEvUVm15bFuKqskWgfNS --- src/commands/sync.ts | 45 +++++++++++++++------- test/sync-git-autoinit.test.ts | 69 ++++++++++++++++++---------------- 2 files changed, 67 insertions(+), 47 deletions(-) diff --git a/src/commands/sync.ts b/src/commands/sync.ts index d73319a81..60a3a2dc6 100644 --- a/src/commands/sync.ts +++ b/src/commands/sync.ts @@ -1655,22 +1655,29 @@ async function performSyncInner(engine: BrainEngine, opts: SyncOpts): Promise { if (dir) rmSync(dir, { recursive: true, force: true }); }); - test('default (no sourceId) sync on a non-git dir auto-inits git and imports files', async () => { + test('anchor-resolved sync (no repoPath, no sourceId) on a non-git dir auto-inits git and imports files', async () => { const { performSync } = await import('../src/commands/sync.ts'); expect(existsSync(join(dir, '.git'))).toBe(false); const result = await performSync(engine, { - repoPath: dir, noPull: true, noEmbed: true, full: true, @@ -74,23 +79,14 @@ describe('#2964: sync auto-inits a never-git-initialized default brain dir', () test('a second sync after auto-init sees no changes (baseline commit captured current on-disk state)', async () => { const { performSync } = await import('../src/commands/sync.ts'); - const first = await performSync(engine, { - repoPath: dir, - noPull: true, - noEmbed: true, - full: true, - }); + const first = await performSync(engine, { noPull: true, noEmbed: true, full: true }); expect(first.added).toBe(2); // No new files, no explicit `full` — a real incremental sync against the // auto-init baseline. Before this fix there was no baseline to diff // against (sync errored outright); a naive fix that skipped the initial // commit would make this call re-report both files as "added" again. - const second = await performSync(engine, { - repoPath: dir, - noPull: true, - noEmbed: true, - }); + const second = await performSync(engine, { noPull: true, noEmbed: true }); expect(second.status).not.toBe('first_sync'); expect(second.added).toBe(0); expect(second.modified).toBe(0); @@ -110,17 +106,29 @@ describe('#2964: sync auto-inits a never-git-initialized default brain dir', () expect(existsSync(join(dir, '.git'))).toBe(false); }); - test('--dry-run on a non-git dir throws without writing anything to disk', async () => { + test('a caller-supplied repoPath with no sourceId still throws — not auto-inited (P1: MCP submit_job arbitrary-path guard)', async () => { + // Mirrors jobs.ts: submit_job({name:'sync', data:{repoPath}}) reaches + // performSyncInner with sourceId left undefined whenever repoPath + // doesn't match a registered source's local_path. Auto-init must not + // fire here even though sourceId is unset — only the anchor-resolved + // default path is gbrain's own. const { performSync } = await import('../src/commands/sync.ts'); await expect( performSync(engine, { repoPath: dir, - dryRun: true, noPull: true, noEmbed: true, full: true, }), ).rejects.toThrow(/git repository/i); + expect(existsSync(join(dir, '.git'))).toBe(false); + }); + + test('--dry-run on the anchor-resolved path throws without writing anything to disk', async () => { + const { performSync } = await import('../src/commands/sync.ts'); + await expect( + performSync(engine, { dryRun: true, noPull: true, noEmbed: true, full: true }), + ).rejects.toThrow(/git repository/i); // The whole point of --dry-run is "preview only" — it must never git-init // or commit on our behalf, even though we could self-heal. expect(existsSync(join(dir, '.git'))).toBe(false); @@ -134,12 +142,7 @@ describe('#2964: sync auto-inits a never-git-initialized default brain dir', () // discoverGitRoot succeeds, but `git rev-parse HEAD` still fails. execSync('git init -q', { cwd: dir }); - const result = await performSync(engine, { - repoPath: dir, - noPull: true, - noEmbed: true, - full: true, - }); + const result = await performSync(engine, { noPull: true, noEmbed: true, full: true }); expect(result.status).toBe('first_sync'); expect(result.added).toBe(2);