Explorar o código

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>
Colby Mchenry hai 9 horas
pai
achega
3193800bc8
Modificáronse 3 ficheiros con 37 adicións e 3 borrados
  1. 2 0
      CHANGELOG.md
  2. 33 3
      __tests__/frontload-hook.test.ts
  3. 2 0
      src/directory.ts

+ 2 - 0
CHANGELOG.md

@@ -141,6 +141,8 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
 
 #### 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)
 
 - `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
__tests__/frontload-hook.test.ts

@@ -8,11 +8,15 @@
  * logic), since the end-to-end hook is validated by a live agent run, not a
  * 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 os from 'os';
 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`). */
 function mkIndexed(dir: string): string {
@@ -30,7 +34,10 @@ function mkWorkspaceRoot(dir: string): string {
 describe('planFrontload — front-load hook project resolution (#964)', () => {
   let tmp: string;
   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)', () => {
     mkIndexed(tmp);
@@ -92,6 +99,29 @@ describe('planFrontload — front-load hook project resolution (#964)', () => {
     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', () => {
     mkWorkspaceRoot(tmp);
     fs.mkdirSync(path.join(tmp, 'packages', 'api'), { recursive: true });

+ 2 - 0
src/directory.ts

@@ -213,6 +213,8 @@ export function findIndexedSubprojectRoots(
   root: string,
   opts: { maxDepth?: number; max?: number } = {},
 ): 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 max = opts.max ?? 64;
   const out: string[] = [];