// v1.13.17-cross-repo-reads: resolveGrantRoot decision tree. // // Sam's dispatch note (2026-05-22): "in the project-root resolver ancestor // walk, stop the moment parent exits PROJECT_ROOT_WHITELIST or hits // filesystem root — check on every iteration, not just final parent. // Symlinked input must not be able to escape the whitelist during the // walk." The symlink-escape-mid-walk test below pins that invariant — // without the per-iteration whitelist check, this case would walk OUTSIDE // the whitelist root and return a phantom grant. import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'; import { mkdtemp, rm, mkdir, writeFile, symlink } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { realpath } from 'node:fs/promises'; import { resolveGrantRoot } from '../grant_resolver.js'; import type { Sql } from '../../db.js'; let tmp: string; let whitelist: string; let project: string; let fork: string; let outside: string; // Fake sql tag — returns the projects rows we want without touching a real // database. The resolver only ever does a single SELECT, so a single-shot // mock that returns the prepared rows on every invocation is enough. function makeSql(rows: Array<{ path: string }>): Sql { const tag = ((..._args: unknown[]) => Promise.resolve(rows)) as unknown as Sql; return tag; } beforeAll(async () => { tmp = await realpath(await mkdtemp(join(tmpdir(), 'boocode-gr-'))); whitelist = join(tmp, 'whitelist'); project = join(whitelist, 'boocode'); fork = join(whitelist, 'forks', 'codecontext'); outside = join(tmp, 'outside'); await mkdir(project, { recursive: true }); await mkdir(fork, { recursive: true }); await mkdir(outside, { recursive: true }); // Mark project as a repo (.git directory). await mkdir(join(project, '.git')); await writeFile(join(project, 'README.md'), 'project readme'); // Mark fork as a repo via go.mod (matches the proposal's example). await writeFile(join(fork, 'go.mod'), 'module example.com/foo'); await writeFile(join(fork, 'main.go'), 'package main'); await writeFile(join(outside, 'secret.txt'), 'forbidden'); }); afterAll(async () => { await rm(tmp, { recursive: true, force: true }); }); describe('resolveGrantRoot — happy paths', () => { it('refuses when the requested path is already under projectRoot', async () => { const result = await resolveGrantRoot(makeSql([]), join(project, 'README.md'), project, whitelist); expect(result.ok).toBe(false); if (!result.ok) expect(result.reason).toMatch(/already accessible/); }); it('returns the project root when the path falls under a registered project', async () => { // Register `fork` as a known project. Resolver should return the project // ancestor (LONGEST match wins) rather than the repo-shape fallback. const result = await resolveGrantRoot( makeSql([{ path: fork }]), join(fork, 'main.go'), project, whitelist, ); expect(result.ok).toBe(true); if (result.ok) { expect(result.root).toBe(fork); expect(result.source).toBe('project'); } }); it('falls back to the nearest repo-shaped ancestor when no project matches', async () => { const result = await resolveGrantRoot( makeSql([]), join(fork, 'main.go'), project, whitelist, ); expect(result.ok).toBe(true); if (result.ok) { expect(result.root).toBe(fork); expect(result.source).toBe('whitelist'); } }); }); describe('resolveGrantRoot — refusals', () => { it('refuses paths outside PROJECT_ROOT_WHITELIST', async () => { const result = await resolveGrantRoot( makeSql([]), join(outside, 'secret.txt'), project, whitelist, ); expect(result.ok).toBe(false); if (!result.ok) expect(result.reason).toMatch(/outside permitted scope/); }); it('refuses non-absolute paths', async () => { const result = await resolveGrantRoot(makeSql([]), 'relative/path', project, whitelist); expect(result.ok).toBe(false); if (!result.ok) expect(result.reason).toMatch(/absolute/); }); it('refuses missing paths without prompting', async () => { const result = await resolveGrantRoot( makeSql([]), join(whitelist, 'nope'), project, whitelist, ); expect(result.ok).toBe(false); if (!result.ok) expect(result.reason).toMatch(/does not exist/); }); it('refuses when no repo-shape marker is found before hitting the whitelist root', async () => { // Build a directory tree under the whitelist that has NO repo markers // all the way up to the whitelist root. const plain = join(whitelist, 'plain-dir', 'nested'); await mkdir(plain, { recursive: true }); await writeFile(join(plain, 'just-a-file.txt'), 'x'); const result = await resolveGrantRoot( makeSql([]), join(plain, 'just-a-file.txt'), project, whitelist, ); expect(result.ok).toBe(false); if (!result.ok) expect(result.reason).toMatch(/no repo-shaped ancestor/); }); it('does not grant the whitelist root itself as a fallback', async () => { // Even if .git existed at the whitelist root (it doesn't), we'd refuse. // Easier to assert: a path directly under whitelist with no repo marker. const direct = join(whitelist, 'lone-file.txt'); await writeFile(direct, 'x'); const result = await resolveGrantRoot(makeSql([]), direct, project, whitelist); expect(result.ok).toBe(false); }); }); describe('resolveGrantRoot — symlink-escape-mid-walk guard (Sam 2026-05-22)', () => { it('refuses a symlinked input whose realpath sits outside the whitelist', async () => { // The symlink lives nominally inside the whitelist, but its target // (realpath) is outside. The guard's first realpath() call normalizes // and the up-front whitelist check refuses immediately. const link = join(whitelist, 'escape-link'); try { await symlink(outside, link); const result = await resolveGrantRoot( makeSql([]), join(link, 'secret.txt'), project, whitelist, ); expect(result.ok).toBe(false); if (!result.ok) expect(result.reason).toMatch(/outside permitted scope/); } finally { await rm(link, { force: true }); } }); it('walk loop terminates at the whitelist root, not at filesystem /', async () => { // Construct a deep tree with NO repo markers anywhere. Without a bound, // the walk would chase parents up to "/". The bound flips the loop into // a refusal once the cursor equals the realpath'd whitelist root. const deep = join(whitelist, 'a', 'b', 'c', 'd'); await mkdir(deep, { recursive: true }); await writeFile(join(deep, 'leaf.txt'), 'x'); const result = await resolveGrantRoot(makeSql([]), join(deep, 'leaf.txt'), project, whitelist); expect(result.ok).toBe(false); if (!result.ok) expect(result.reason).toMatch(/no repo-shaped ancestor/); }); }); describe('resolveGrantRoot — nearest-project disambiguation', () => { it('prefers the longest matching project path over a shorter ancestor', async () => { const outer = whitelist; const inner = fork; // /whitelist/forks/codecontext, deeper than outer const result = await resolveGrantRoot( makeSql([{ path: outer }, { path: inner }]), join(fork, 'main.go'), project, whitelist, ); expect(result.ok).toBe(true); if (result.ok) expect(result.root).toBe(inner); }); }); // Belt-and-suspenders: silence a known dynamic-import warning that vitest // occasionally emits on transient fs operations in CI but never in dev. vi.spyOn(console, 'warn').mockImplementation(() => {});