Forráskód Böngészése

fix: pre-check grep/glob search paths to fail fast on missing paths

Upstream maps a missing search path to a bare "ripgrep execution failed"
(spawn ENOENT with the cause swallowed) or silently searches the parent
directory with the wrong scope. Add a search-path-guard on
tool.execute.before that mirrors the host's resolution rule
(isAbsolute ? p : join(directory, p)) and throws an actionable error
before ripgrep runs. Conservative fallback: never block when the
resolution base is unknown.
GoldJohnKing 2 hete
szülő
commit
cd74e91115

+ 6 - 0
docs/tools.md

@@ -38,6 +38,12 @@ Fast, structural code search and refactoring - more powerful than plain text gre
 
 `ast_grep` understands code structure, so it can find patterns like "all arrow functions that return a JSX element" rather than relying on exact text matching.
 
+Before the built-in `grep`/`glob` tools run, the plugin pre-checks that the
+requested `path` exists (resolved against the project directory, mirroring the
+host's resolution rule). A missing path fails fast with an actionable error
+instead of an opaque "ripgrep execution failed" message or a silent search of
+the parent directory.
+
 ---
 
 ## Background Task Control

+ 2 - 1
src/hooks/codemap.md

@@ -40,7 +40,7 @@ from `index.ts`) that returns the hook points OpenCode invokes.
 | Category | Factories | Hook points |
 |---|---|---|
 | Prompt transforms | `createPhaseReminderHook`, `createPostFileToolNudgeHook`, `createChatHeadersHook`, task-session-manager board injection, `processImageAttachments` | `experimental.chat.messages.transform`, `chat.headers` |
-| Tool interception | `createApplyPatchHook` (tool), task-session-manager | `tool.execute.before` / `tool.execute.after` |
+| Tool interception | `createApplyPatchHook` (tool), `createSearchPathGuardHook`, task-session-manager | `tool.execute.before` / `tool.execute.after` |
 | Error recovery | `createJsonErrorRecoveryHook`, `createAutoUpdateCheckerHook` | message transform, tool-execute after |
 | Lifecycle/event | task-session-manager, `createCacheMonitorHook`, `createOrchestratorWakeScheduler` | `event` |
 | Runtime commands | `createDeepworkCommandHook`, `createReflectCommandHook`, `createLoopCommandHook` | `command.execute.before` |
@@ -116,6 +116,7 @@ from `index.ts`) that returns the hook points OpenCode invokes.
 | `phase-reminder/` | Message-transform reminder enforcing orchestrator workflow phases |
 | `post-file-tool-nudge/` | Post-read/write reminder nudging delegation-aware next steps |
 | `reflect/` | `/reflect` runtime command |
+| `search-path-guard/` | Pre-checks `grep`/`glob` `args.path` existence in `tool.execute.before` and fails fast with an actionable error instead of upstream "ripgrep execution failed" noise or silent parent-directory searches |
 | `task-session-manager/` | Resumable task session tracking, job-board injection, reconciliation |
 
 ### Dependencies

+ 1 - 0
src/hooks/index.ts

@@ -34,6 +34,7 @@ export {
 export { createPhaseReminderHook } from './phase-reminder';
 export { createPostFileToolNudgeHook } from './post-file-tool-nudge';
 export { createReflectCommandHook } from './reflect';
+export { createSearchPathGuardHook } from './search-path-guard';
 export { SessionLifecycle } from './session-lifecycle';
 export { createTaskSessionManagerHook } from './task-session-manager';
 export { createToolLoopGuardHook } from './tool-loop-guard/hook';

+ 108 - 0
src/hooks/search-path-guard/index.test.ts

@@ -0,0 +1,108 @@
+import { afterAll, beforeAll, describe, expect, test } from 'bun:test';
+import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
+import * as os from 'node:os';
+import * as path from 'node:path';
+
+import type { PluginInput } from '@opencode-ai/plugin';
+
+import { createSearchPathGuardHook } from './index';
+
+describe('search-path-guard hook', () => {
+  let tempRoot: string;
+
+  const createHook = (
+    directory: string,
+  ): ReturnType<typeof createSearchPathGuardHook> =>
+    createSearchPathGuardHook({
+      client: {} as PluginInput['client'],
+      directory,
+    } as PluginInput);
+
+  const runHook = (
+    hook: ReturnType<typeof createSearchPathGuardHook>,
+    tool: string,
+    args: Record<string, unknown> | undefined,
+  ): Promise<void> =>
+    hook['tool.execute.before']({ tool }, args === undefined ? {} : { args });
+
+  beforeAll(async () => {
+    tempRoot = await mkdtemp(path.join(os.tmpdir(), 'search-path-guard-'));
+  });
+
+  afterAll(async () => {
+    await rm(tempRoot, { recursive: true, force: true });
+  });
+
+  test('blocks grep when the absolute path does not exist', async () => {
+    const hook = createHook(tempRoot);
+    const missing = path.join(tempRoot, 'does-not-exist', 'missing.txt');
+
+    const promise = runHook(hook, 'grep', { path: missing });
+
+    await expect(promise).rejects.toThrow(/Search path does not exist/);
+    await expect(promise).rejects.toThrow(missing);
+  });
+
+  test('blocks glob when the relative path does not exist under directory', async () => {
+    const hook = createHook(tempRoot);
+    const raw = 'no-such-dir';
+    const resolved = path.join(tempRoot, raw);
+
+    const promise = runHook(hook, 'glob', { path: raw });
+
+    await expect(promise).rejects.toThrow(/Search path does not exist/);
+    await expect(promise).rejects.toThrow(resolved);
+  });
+
+  test('allows an existing absolute file path', async () => {
+    const filePath = path.join(tempRoot, 'file.txt');
+    await writeFile(filePath, 'content');
+
+    const hook = createHook(tempRoot);
+
+    await runHook(hook, 'grep', { path: filePath });
+  });
+
+  test('allows an existing relative directory', async () => {
+    await mkdir(path.join(tempRoot, 'subdir'));
+
+    const hook = createHook(tempRoot);
+
+    await runHook(hook, 'glob', { path: 'subdir' });
+  });
+
+  test('never blocks a relative path when directory is falsy', async () => {
+    const hook = createHook('');
+
+    await runHook(hook, 'grep', { path: 'definitely-missing-relative' });
+  });
+
+  test('ignores tools other than grep and glob', async () => {
+    const hook = createHook(tempRoot);
+    const missing = path.join(tempRoot, 'does-not-exist');
+
+    await runHook(hook, 'read', { path: missing });
+    await runHook(hook, 'bash', { path: missing });
+  });
+
+  test('ignores absent, non-string, or sentinel path values', async () => {
+    const hook = createHook(tempRoot);
+
+    await runHook(hook, 'grep', undefined);
+    await runHook(hook, 'grep', {});
+    await runHook(hook, 'glob', { path: 42 });
+    await runHook(hook, 'glob', { path: null });
+    await runHook(hook, 'grep', { path: 'undefined' });
+    await runHook(hook, 'grep', { path: 'null' });
+    await runHook(hook, 'glob', { path: '   ' });
+  });
+
+  test('allows grep when the path points to an existing file', async () => {
+    const filePath = path.join(tempRoot, 'target-file.ts');
+    await writeFile(filePath, 'export {}');
+
+    const hook = createHook(tempRoot);
+
+    await runHook(hook, 'grep', { path: filePath });
+  });
+});

+ 82 - 0
src/hooks/search-path-guard/index.ts

@@ -0,0 +1,82 @@
+import { statSync } from 'node:fs';
+import path from 'node:path';
+
+import type { PluginInput } from '@opencode-ai/plugin';
+
+import { log } from '../../utils/logger';
+
+interface ToolExecuteBeforeInput {
+  tool: string;
+}
+
+interface ToolExecuteBeforeOutput {
+  args?: {
+    path?: unknown;
+    [key: string]: unknown;
+  };
+}
+
+// Upstream's glob tool treats the literal strings 'undefined'/'null' in the
+// path argument as absent. Mirror that so the guard never blocks a call the
+// host would have run unscoped.
+const ABSENT_PATH_LITERALS = new Set(['undefined', 'null']);
+
+export function createSearchPathGuardHook(ctx: PluginInput) {
+  return {
+    'tool.execute.before': async (
+      input: ToolExecuteBeforeInput,
+      output: ToolExecuteBeforeOutput,
+    ): Promise<void> => {
+      if (input.tool !== 'grep' && input.tool !== 'glob') {
+        return;
+      }
+
+      const args = output.args;
+      if (!args || typeof args !== 'object') {
+        return;
+      }
+
+      const raw = args.path;
+      if (typeof raw !== 'string') {
+        return;
+      }
+      const candidate = raw.trim();
+      if (candidate.length === 0 || ABSENT_PATH_LITERALS.has(candidate)) {
+        return;
+      }
+
+      // Mirror the host's resolution rule exactly: absolute paths pass
+      // through, relative paths resolve against the instance directory.
+      // Without a resolution base, never block (conservative fallback).
+      const resolved = path.isAbsolute(candidate)
+        ? candidate
+        : ctx.directory
+          ? path.join(ctx.directory, candidate)
+          : null;
+      if (resolved === null) {
+        return;
+      }
+
+      let exists = true;
+      try {
+        statSync(resolved);
+      } catch {
+        exists = false;
+      }
+
+      if (!exists) {
+        log('search-path-guard blocked', {
+          tool: input.tool,
+          path: candidate,
+          resolved,
+        });
+        throw new Error(
+          `Search path does not exist: ${resolved} (from "${candidate}"). ` +
+            `The ${input.tool} search was blocked before ripgrep ran. ` +
+            'Verify the target path, or list its parent directory to find ' +
+            'the correct location.',
+        );
+      }
+    },
+  };
+}

+ 8 - 0
src/index.ts

@@ -31,6 +31,7 @@ import {
   createPhaseReminderHook,
   createPostFileToolNudgeHook,
   createReflectCommandHook,
+  createSearchPathGuardHook,
   createTaskSessionManagerHook,
   createToolLoopGuardHook,
   ForegroundFallbackManager,
@@ -244,6 +245,7 @@ export const OhMyOpenCodeLite: Plugin = async (ctx) => {
   let filterAvailableSkills: ReturnType<typeof createFilterAvailableSkillsHook>;
   let postFileToolNudge: ReturnType<typeof createPostFileToolNudgeHook>;
   let applyPatch: ReturnType<typeof createApplyPatchHook>;
+  let searchPathGuard: ReturnType<typeof createSearchPathGuardHook>;
   let jsonErrorRecovery: ReturnType<typeof createJsonErrorRecoveryHook>;
   let toolLoopGuard: ToolLoopGuardHook;
   let postFileToolNudgeAfter: (i: unknown, o: unknown) => Promise<void>;
@@ -528,6 +530,8 @@ export const OhMyOpenCodeLite: Plugin = async (ctx) => {
 
     applyPatch = createApplyPatchHook(ctx);
 
+    searchPathGuard = createSearchPathGuardHook(ctx);
+
     jsonErrorRecovery = createJsonErrorRecoveryHook(ctx);
     toolLoopGuard = createToolLoopGuardHook();
 
@@ -1210,6 +1214,10 @@ export const OhMyOpenCodeLite: Plugin = async (ctx) => {
         output as never,
       );
       await applyPatch['tool.execute.before'](input as never, output as never);
+      await searchPathGuard['tool.execute.before'](
+        input as never,
+        output as never,
+      );
       await taskSessionManagerHook['tool.execute.before'](
         input as never,
         output as never,