fix(prompt-hook): skip unsafe roots during subproject down-scan (#1454) (#1812)

Reuse unsafeIndexRootReason before scanning indexed subprojects so stray manifests at home or broader roots cannot inject unrelated context. Preserve workspace adoption for #964.

Validation: four new regressions fail before the guard and pass after it; 48 relevant tests and npm run build pass. Confirmed the real os.homedir() leak before and after the fix with fixture cleanup.

Co-authored-by: Colby McHenry <colbymchenry@users.noreply.github.com>
This commit is contained in:
Colby Mchenry
2026-09-08 20:41:32 -05:00
committed by GitHub
co-authored by Colby McHenry
parent 4453310eef
commit 3193800bc8
3 changed files with 37 additions and 3 deletions
+2
View File
@@ -141,6 +141,8 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
#### MCP / indexing #### MCP / indexing
- The prompt hook no longer injects unrelated projects when run from your home directory or a broader directory containing a stray workspace manifest. (#1454)
- Indexing now succeeds when Node.js's SQLite lacks FTS5, with search falling back to name and fuzzy matching; thanks @aniruddhaadak80. (#1532) - Indexing now succeeds when Node.js's SQLite lacks FTS5, with search falling back to name and fuzzy matching; thanks @aniruddhaadak80. (#1532)
- `codegraph_explore` now makes clear that suggested call counts are advisory, so agents keep exploring when an answer is incomplete; thanks @rongbc. (#1504, #1570) - `codegraph_explore` now makes clear that suggested call counts are advisory, so agents keep exploring when an answer is incomplete; thanks @rongbc. (#1504, #1570)
+33 -3
View File
@@ -8,11 +8,15 @@
* logic), since the end-to-end hook is validated by a live agent run, not a * logic), since the end-to-end hook is validated by a live agent run, not a
* unit test. * unit test.
*/ */
import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import * as fs from 'fs'; import * as fs from 'fs';
import * as os from 'os'; import * as os from 'os';
import * as path from 'path'; import * as path from 'path';
import { planFrontload, findIndexedSubprojectRoots, isStructuralPrompt, hasStructuralKeyword, extractCodeTokens, PROMPT_HOOK_INJECTION_MAX, CLAUDE_CODE_INLINE_HOOK_OUTPUT_LIMIT, capPromptHookInjection } from '../src/directory'; import { planFrontload, findIndexedSubprojectRoots, unsafeIndexRootReason, isStructuralPrompt, hasStructuralKeyword, extractCodeTokens, PROMPT_HOOK_INJECTION_MAX, CLAUDE_CODE_INLINE_HOOK_OUTPUT_LIMIT, capPromptHookInjection } from '../src/directory';
// Make the built-in exports configurable so HOME can point at a real temp
// fixture without changing the process environment or the user's home files.
vi.mock('os', async (importOriginal) => ({ ...await importOriginal<typeof import('os')>() }));
/** Make `dir` look indexed (isInitialized needs `.codegraph/codegraph.db`). */ /** Make `dir` look indexed (isInitialized needs `.codegraph/codegraph.db`). */
function mkIndexed(dir: string): string { function mkIndexed(dir: string): string {
@@ -30,7 +34,10 @@ function mkWorkspaceRoot(dir: string): string {
describe('planFrontload — front-load hook project resolution (#964)', () => { describe('planFrontload — front-load hook project resolution (#964)', () => {
let tmp: string; let tmp: string;
beforeEach(() => { tmp = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'cg-frontload-'))); }); beforeEach(() => { tmp = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'cg-frontload-'))); });
afterEach(() => { fs.rmSync(tmp, { recursive: true, force: true }); }); afterEach(() => {
vi.restoreAllMocks();
fs.rmSync(tmp, { recursive: true, force: true });
});
it('cwd is itself indexed → front-load cwd (the common single-project case)', () => { it('cwd is itself indexed → front-load cwd (the common single-project case)', () => {
mkIndexed(tmp); mkIndexed(tmp);
@@ -92,6 +99,29 @@ describe('planFrontload — front-load hook project resolution (#964)', () => {
expect(plan.nudgeProjects).toEqual([]); expect(plan.nudgeProjects).toEqual([]);
}); });
it.each([
{ root: 'home', manifest: 'package.json', children: 1 },
{ root: 'home', manifest: 'package.json', children: 2 },
{ root: 'home', manifest: 'WORKSPACE', children: 1 },
{ root: 'parent of home', manifest: 'package.json', children: 1 },
])('$root with stray $manifest and $children indexed children → no-op (#1454)', ({ root, manifest, children }) => {
const homeDir = root === 'home' ? tmp : path.join(tmp, 'user');
fs.mkdirSync(homeDir, { recursive: true });
vi.spyOn(os, 'homedir').mockReturnValue(homeDir);
if (manifest === 'package.json') mkWorkspaceRoot(tmp);
else fs.mkdirSync(path.join(tmp, manifest)); // Even a WORKSPACE directory opens the manifest gate.
mkIndexed(path.join(tmp, 'packages', 'api'));
if (children === 2) mkIndexed(path.join(tmp, 'packages', 'web'));
expect(unsafeIndexRootReason(tmp)).toBe(root === 'home' ? 'your home directory' : 'a parent of your home directory');
expect(planFrontload(tmp, 'how does authentication work end to end?')).toEqual({
exploreRoot: null,
nudgeProjects: [],
viaSubScan: false,
});
expect(findIndexedSubprojectRoots(tmp)).toEqual([]);
});
it('nothing indexed anywhere → no-op', () => { it('nothing indexed anywhere → no-op', () => {
mkWorkspaceRoot(tmp); mkWorkspaceRoot(tmp);
fs.mkdirSync(path.join(tmp, 'packages', 'api'), { recursive: true }); fs.mkdirSync(path.join(tmp, 'packages', 'api'), { recursive: true });
+2
View File
@@ -213,6 +213,8 @@ export function findIndexedSubprojectRoots(
root: string, root: string,
opts: { maxDepth?: number; max?: number } = {}, opts: { maxDepth?: number; max?: number } = {},
): string[] { ): string[] {
// A stray workspace manifest must not enable scanning home or broader roots (#1454).
if (unsafeIndexRootReason(root) !== null) return [];
const maxDepth = opts.maxDepth ?? 4; const maxDepth = opts.maxDepth ?? 4;
const max = opts.max ?? 64; const max = opts.max ?? 64;
const out: string[] = []; const out: string[] = [];