From 58836f1c3d5a35cf53e14100b30d21911e599b88 Mon Sep 17 00:00:00 2001 From: Shaojin Wen Date: Sat, 2 May 2026 05:13:58 +0800 Subject: [PATCH] fix(skills): per-tool extraction dispatcher (LSP URI + grep glob + integration test) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four findings from /review on the activation extractor: C1 (Critical): LSP allowlisted but the extractor pushed `filePath` through unchanged. The LSP tool accepts non-file URI schemes (`http://`, `git://`, etc.); forwarding any of those to SkillActivationRegistry as a project-relative candidate let an LSP call against a non-file resource activate path-gated skills without the model touching a real project file. Fix is two-part: decode `file://` URIs via `fileURLToPath` (so a project file expressed as a URI still activates correctly) and silently drop any string containing `://` that's not `file://`. S1: LSP `incomingCalls` / `outgoingCalls` operate on `callHierarchyItem.uri`, not the top-level `filePath`. After `prepareCallHierarchy` returns a file-backed item, following the hierarchy with that item produced no candidate, so path-gated skills for that file stayed dormant. Same URI-aware extraction is applied to the nested `uri` field. S2: grep_search has a path-shaped `glob` field (`GrepToolParams.glob`) — distinct from `pattern`, which is a regex on contents. The extractor previously ignored `glob`, so `grep_search({ pattern, glob: 'src/**/*.ts' })` produced no activation candidate even though the call walked every file under `src/**/*.ts`. Same `path + glob` join treatment as GLOB. S3: No scheduler-side integration test covered the extractToolFilePaths → matchAndActivateByPaths → reminder-append wiring, so a regression there could land while extractor and registry unit tests still passed. Added three integration tests covering: (a) reminder appended when SkillTool present, (b) reminder suppressed when SkillTool absent (subagent case), (c) hook not invoked for non-FS tools. Restructured `extractToolFilePaths` from a generic `file_path/filePath/path/paths` extractor into a per-tool dispatcher (`switch` on canonical tool name). The previous generic shape was overly permissive — every FS tool got every field name, including ones it doesn't accept — and it was the wrong shape to add LSP URI semantics to. Per-tool means each branch reflects the actual `XToolParams` interface. Test reshape: - Removed tests asserting cross-tool field acceptance (e.g. grep reading `filePath` / `paths`); those documented inaccurate input. - Added per-tool realistic tests for grep glob, lsp file:// URI, lsp callHierarchyItem.uri, lsp non-file scheme dropped. - Plus the three CoreToolScheduler activation wiring tests. 639 tests pass (was 632); types and lint clean. DEFERRED S4: Activation driven from input selector rather than concrete matched files. For `glob({ pattern: '**/*.ts' })` the selector itself may not match a skill scoped narrower than the query. Real concern, but the fix needs typed result-path metadata feedback from each tool — a cross-cutting addition to every FS tool's return shape. Logged for follow-up. --- .../core/src/core/coreToolScheduler.test.ts | 311 ++++++++++++++++-- packages/core/src/core/coreToolScheduler.ts | 200 +++++++---- 2 files changed, 415 insertions(+), 96 deletions(-) diff --git a/packages/core/src/core/coreToolScheduler.test.ts b/packages/core/src/core/coreToolScheduler.test.ts index 41a4104cf..422356b5b 100644 --- a/packages/core/src/core/coreToolScheduler.test.ts +++ b/packages/core/src/core/coreToolScheduler.test.ts @@ -27,6 +27,7 @@ import { DEFAULT_TRUNCATE_TOOL_OUTPUT_THRESHOLD, } from '../index.js'; import { SkillTool } from '../tools/skill.js'; +import { ToolNames } from '../tools/tool-names.js'; import type { ToolCall, WaitingToolCall } from './coreToolScheduler.js'; import { CoreToolScheduler, @@ -4332,53 +4333,114 @@ describe('extractToolFilePaths', () => { ]); }); - it('extracts filePath (grep / lsp camelCase convention)', () => { - expect( - extractToolFilePaths('grep_search', { filePath: '/proj/b.ts' }), - ).toEqual(['/proj/b.ts']); + it('extracts filePath for lsp (camelCase convention)', () => { + expect(extractToolFilePaths('lsp', { filePath: '/proj/b.ts' })).toEqual([ + '/proj/b.ts', + ]); }); - it('extracts path (ls / glob convention)', () => { + it('extracts path for list_directory', () => { expect( extractToolFilePaths('list_directory', { path: '/proj/dir' }), ).toEqual(['/proj/dir']); }); - it('extracts paths array (ripGrep multi-path convention)', () => { + it('drops empty / non-string file_path on read_file', () => { + expect(extractToolFilePaths(FS_TOOL, { file_path: '' })).toEqual([]); + expect(extractToolFilePaths(FS_TOOL, { file_path: undefined })).toEqual([]); + expect(extractToolFilePaths(FS_TOOL, { file_path: 42 })).toEqual([]); + }); + + it('ignores file_path with the wrong shape on read_file', () => { + expect( + extractToolFilePaths(FS_TOOL, { file_path: { not: 'a string' } }), + ).toEqual([]); + }); + + it('ignores irrelevant fields on the wrong tool', () => { + // Realistic per-tool dispatch: read_file does not look at `path`, + // `filePath`, or `paths`; grep_search does not look at `filePath` + // or `paths`. The previous generic extractor accepted everything for + // every FS tool — overly permissive given that the field names mean + // different things across tools. + expect( + extractToolFilePaths(FS_TOOL, { + file_path: '/correct', + path: '/wrong-for-read', + filePath: '/wrong-for-read', + }), + ).toEqual(['/correct']); expect( extractToolFilePaths('grep_search', { - paths: ['/proj/a.ts', '/proj/b.ts'], + filePath: '/wrong-for-grep', + paths: ['/wrong-for-grep'], }), - ).toEqual(['/proj/a.ts', '/proj/b.ts']); + ).toEqual([]); }); - it('combines all field variants in input order', () => { + it('extracts grep_search.glob as a path-shaped file filter', () => { + // GrepToolParams.glob is a path-shaped selector; `pattern` is a + // regex on contents and intentionally NOT extracted. Without this + // branch, `grep_search({ pattern: 'TODO', glob: 'src/**/*.ts' })` + // produces no candidate even though the call walks every file under + // `src/**/*.ts`. expect( - extractToolFilePaths(FS_TOOL, { - file_path: '/a', - filePath: '/b', - path: '/c', - paths: ['/d', '/e'], + extractToolFilePaths('grep_search', { glob: 'src/**/*.ts' }), + ).toEqual(['src/**/*.ts']); + expect( + extractToolFilePaths('grep_search', { + path: 'packages/core', + glob: '**/*.ts', + pattern: 'TODO|FIXME', }), - ).toEqual(['/a', '/b', '/c', '/d', '/e']); + ).toEqual(['packages/core', 'packages/core/**/*.ts']); }); - it('drops empty strings and non-string entries', () => { - expect( - extractToolFilePaths(FS_TOOL, { - file_path: '', - filePath: undefined, - path: 42, - paths: ['/ok', '', null, '/also-ok'], - }), - ).toEqual(['/ok', '/also-ok']); + it('decodes file:// URIs for lsp via fileURLToPath', () => { + // Regression: LSP `filePath` is allowed to be a `file://` URI. + // Forwarding the URI as-is to the activation registry would never + // match a project-relative skill glob (the leading `file:///` + // never occurs inside project-relative path strings). + const out = extractToolFilePaths('lsp', { + filePath: 'file:///proj/src/App.ts', + }); + expect(out).toEqual(['/proj/src/App.ts']); }); - it('ignores fields with the right name but the wrong shape', () => { + it('drops non-file URI schemes for lsp (http://, git://, etc.)', () => { + // Regression: forwarding `http://api/x` or `git://repo/foo` into + // the activation pipeline would let an LSP call against a + // non-file resource activate path-gated skills without the model + // having touched a real project file. + expect(extractToolFilePaths('lsp', { filePath: 'http://api/x' })).toEqual( + [], + ); + expect(extractToolFilePaths('lsp', { filePath: 'git://repo/foo' })).toEqual( + [], + ); + }); + + it('extracts callHierarchyItem.uri for lsp (incomingCalls / outgoingCalls)', () => { + // Regression: incomingCalls / outgoingCalls operate on + // `callHierarchyItem.uri`, NOT the top-level `filePath`. Following + // the call hierarchy through a project file would otherwise never + // contribute an activation candidate. expect( - extractToolFilePaths(FS_TOOL, { - file_path: { not: 'a string' }, - paths: 'not-an-array', + extractToolFilePaths('lsp', { + method: 'incomingCalls', + callHierarchyItem: { uri: 'file:///proj/src/App.ts' }, + }), + ).toEqual(['/proj/src/App.ts']); + // Plain path also accepted. + expect( + extractToolFilePaths('lsp', { + callHierarchyItem: { uri: '/proj/src/App.ts' }, + }), + ).toEqual(['/proj/src/App.ts']); + // Non-file URI on the item is also dropped. + expect( + extractToolFilePaths('lsp', { + callHierarchyItem: { uri: 'http://api/x' }, }), ).toEqual([]); }); @@ -4459,9 +4521,9 @@ describe('extractToolFilePaths', () => { expect( extractToolFilePaths('grep_search', { pattern: 'TODO|FIXME', - filePath: '/proj/a.ts', + path: 'src', }), - ).toEqual(['/proj/a.ts']); + ).toEqual(['src']); }); it('canonicalizes legacy tool-name aliases before the allowlist check', () => { @@ -4474,9 +4536,11 @@ describe('extractToolFilePaths', () => { expect( extractToolFilePaths('replace', { file_path: '/proj/a.ts' }), ).toEqual(['/proj/a.ts']); + // search_file_content canonicalizes to grep_search; use its actual + // shape (`path` / `glob`). expect( - extractToolFilePaths('search_file_content', { filePath: '/proj/b.ts' }), - ).toEqual(['/proj/b.ts']); + extractToolFilePaths('search_file_content', { path: 'src' }), + ).toEqual(['src']); }); it('returns empty for tool names outside the FS allowlist', () => { @@ -4496,3 +4560,186 @@ describe('extractToolFilePaths', () => { expect(extractToolFilePaths('skill', { skill: 'review' })).toEqual([]); }); }); + +describe('CoreToolScheduler activation wiring', () => { + // Integration coverage for the scheduler-side hook that ties + // extractToolFilePaths → matchAndActivateByPaths → system-reminder + // append. Unit tests on extractToolFilePaths alone don't catch + // wiring regressions (e.g. forgetting the await, dropping the + // SkillTool gate, posting the reminder before the listener chain + // settled). + + function buildSchedulerWithSkillManager(opts: { + matchAndActivateByPaths: ReturnType; + skillToolPresent: boolean; + }): { + scheduler: CoreToolScheduler; + onAllToolCallsComplete: ReturnType; + } { + const fsTool = new MockTool({ + name: ToolNames.READ_FILE, + execute: vi.fn().mockResolvedValue({ + llmContent: 'file contents', + returnDisplay: 'file contents', + }), + }); + const mockToolRegistry = { + // Return the fs tool when asked by name; for SkillTool, mirror the + // configured presence so the scheduler's reminder gate sees what + // the test wants. + getTool: (n: string) => { + if (n === ToolNames.SKILL) + return opts.skillToolPresent ? fsTool : undefined; + return fsTool; + }, + ensureTool: async () => fsTool, + getToolByName: () => fsTool, + getFunctionDeclarations: () => [], + tools: new Map(), + discovery: {}, + registerTool: () => {}, + getToolByDisplayName: () => fsTool, + getTools: () => [], + discoverTools: async () => {}, + getAllTools: () => [], + getToolsByServer: () => [], + } as unknown as ToolRegistry; + + const onAllToolCallsComplete = vi.fn(); + const onToolCallsUpdate = vi.fn(); + + const mockConfig = { + getSessionId: () => 'test-session-id', + getUsageStatisticsEnabled: () => true, + getDebugMode: () => false, + getApprovalMode: () => ApprovalMode.YOLO, + getPermissionsAllow: () => [], + getContentGeneratorConfig: () => ({ + model: 'test-model', + authType: 'gemini', + }), + getShellExecutionConfig: () => ({ + terminalWidth: 90, + terminalHeight: 30, + }), + storage: { getProjectTempDir: () => '/tmp' }, + getTruncateToolOutputThreshold: () => + DEFAULT_TRUNCATE_TOOL_OUTPUT_THRESHOLD, + getTruncateToolOutputLines: () => DEFAULT_TRUNCATE_TOOL_OUTPUT_LINES, + getToolRegistry: () => mockToolRegistry, + getUseModelRouter: () => false, + getGeminiClient: () => null, + getChatRecordingService: () => undefined, + getMessageBus: vi.fn().mockReturnValue(undefined), + getDisableAllHooks: vi.fn().mockReturnValue(true), + getConditionalRulesRegistry: () => undefined, + getSkillManager: () => ({ + matchAndActivateByPaths: opts.matchAndActivateByPaths, + }), + } as unknown as Config; + + const scheduler = new CoreToolScheduler({ + config: mockConfig, + onAllToolCallsComplete, + onToolCallsUpdate, + getPreferredEditor: () => 'vscode', + onEditorClose: vi.fn(), + }); + return { scheduler, onAllToolCallsComplete }; + } + + function getResponseText(call: ToolCall): string { + const r = call as unknown as { + response?: { responseParts?: unknown }; + }; + return JSON.stringify(r.response?.responseParts ?? null); + } + + it('invokes matchAndActivateByPaths with extracted candidates and appends the reminder when SkillTool is present', async () => { + const matchAndActivateByPaths = vi.fn().mockResolvedValue(['tsx-helper']); + const { scheduler, onAllToolCallsComplete } = + buildSchedulerWithSkillManager({ + matchAndActivateByPaths, + skillToolPresent: true, + }); + + await scheduler.schedule( + [ + { + callId: '1', + name: ToolNames.READ_FILE, + args: { file_path: '/proj/src/App.tsx' }, + isClientInitiated: false, + prompt_id: 'p1', + }, + ], + new AbortController().signal, + ); + + expect(matchAndActivateByPaths).toHaveBeenCalledWith(['/proj/src/App.tsx']); + const completed = onAllToolCallsComplete.mock.calls[0][0] as ToolCall[]; + expect(completed[0].status).toBe('success'); + const responseText = getResponseText(completed[0]); + expect(responseText).toContain('tsx-helper'); + expect(responseText).toContain('now available via the Skill tool'); + }); + + it('suppresses the activation reminder when SkillTool is absent (subagent without skill in toolslist)', async () => { + const matchAndActivateByPaths = vi.fn().mockResolvedValue(['tsx-helper']); + const { scheduler, onAllToolCallsComplete } = + buildSchedulerWithSkillManager({ + matchAndActivateByPaths, + skillToolPresent: false, + }); + + await scheduler.schedule( + [ + { + callId: '1', + name: ToolNames.READ_FILE, + args: { file_path: '/proj/src/App.tsx' }, + isClientInitiated: false, + prompt_id: 'p1', + }, + ], + new AbortController().signal, + ); + + // Activation registry still mutates (correct — model in another + // context might want it), but the reminder is suppressed for this + // subagent's tool result because invoking the announced skill from + // here would fail. + expect(matchAndActivateByPaths).toHaveBeenCalled(); + const completed = onAllToolCallsComplete.mock.calls[0][0] as ToolCall[]; + const responseText = getResponseText(completed[0]); + expect(responseText).not.toContain('now available via the Skill tool'); + expect(responseText).not.toContain('tsx-helper'); + }); + + it('does not call matchAndActivateByPaths for non-FS tools', async () => { + const matchAndActivateByPaths = vi.fn().mockResolvedValue([]); + const { scheduler } = buildSchedulerWithSkillManager({ + matchAndActivateByPaths, + skillToolPresent: true, + }); + + // Use a tool name outside FS_PATH_TOOL_NAMES; the mock fsTool above + // is registered under read_file, but the scheduler will look up by + // request.name. We override request.name to a non-FS name and + // confirm the activation hook never fires. + await scheduler.schedule( + [ + { + callId: '1', + name: 'web_fetch', + args: { url: 'https://example.com' }, + isClientInitiated: false, + prompt_id: 'p1', + }, + ], + new AbortController().signal, + ); + + expect(matchAndActivateByPaths).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/core/src/core/coreToolScheduler.ts b/packages/core/src/core/coreToolScheduler.ts index 5deb38f27..6e64f01a6 100644 --- a/packages/core/src/core/coreToolScheduler.ts +++ b/packages/core/src/core/coreToolScheduler.ts @@ -47,6 +47,7 @@ import type { Part, PartListUnion, } from '@google/genai'; +import { fileURLToPath } from 'node:url'; import { ToolNames, ToolNamesMigration } from '../tools/tool-names.js'; import { CONCURRENCY_SAFE_KINDS } from '../tools/tools.js'; import { isShellCommandReadOnly } from '../utils/shellReadOnlyChecker.js'; @@ -204,29 +205,76 @@ const FS_PATH_TOOL_NAMES: ReadonlySet = new Set([ ]); /** - * Pull the filesystem path-bearing fields out of a tool's input. Different - * core tools name these differently: - * - `file_path` — read-file, edit, write-file - * - `path` — ls (search root); glob (search root, optional) - * - `filePath` — grep, lsp - * - `paths` — ripGrep array form - * - `pattern` — glob (treated as a path-shaped glob, see below) + * Trim trailing forward / back slashes from a path-shaped string without + * a regex. The regex form `s.replace(/[\\/]+$/, '')` is functionally + * equivalent but CodeQL #145 flags `+` on uncontrolled input as a + * polynomial ReDoS candidate; the loop is O(n) on the trailing + * separator run, no different from the regex engine, but quieter. + */ +function trimTrailingSlash(s: string): string { + let trimmed = s; + while (trimmed.endsWith('/') || trimmed.endsWith('\\')) { + trimmed = trimmed.slice(0, -1); + } + return trimmed; +} + +/** + * Combine a search-root path and a path-shaped glob into the effective + * selector that the tool actually walks. Used by GLOB (`path` + `pattern`) + * and GREP (`path` + `glob`). Plain string concat (rather than + * `path.join`) so we don't (1) emit OS-specific backslashes on Windows + * and silently diverge from the forward-slash form the activation + * registry matches against, or (2) collapse `..` segments and lose + * information about which directory the call escaped from. + */ +function joinSearchRootAndGlob( + searchRoot: string | undefined, + globField: string, +): string { + if (!searchRoot || searchRoot.length === 0) return globField; + return `${trimTrailingSlash(searchRoot)}/${globField}`; +} + +/** + * For LSP-shaped inputs, normalize `filePath`-style strings into project + * candidates. Accepts a plain absolute/relative path or a `file://` URI; + * silently drops other URI schemes (`http://`, `git://`, etc.) so an + * LSP call against a non-file resource cannot reach the activation + * registry as if it had touched a project file. + */ +function pushLspPathCandidate(out: string[], v: unknown): void { + if (typeof v !== 'string' || v.length === 0) return; + if (v.startsWith('file://')) { + try { + out.push(fileURLToPath(v)); + } catch { + // Malformed file URI — drop silently rather than corrupt the + // activation pipeline. + } + return; + } + if (v.includes('://')) return; // non-file URI scheme: ignore + out.push(v); +} + +/** + * Pull the filesystem path-bearing fields out of a tool's input. + * Per-tool dispatcher because the field name and shape differ: + * + * - read_file / edit / write_file → `file_path` + * - list_directory → `path` (search root) + * - glob → `path` (search root, optional) + `pattern` (path-shaped + * selector); `/` is the effective glob walked + * - grep_search → `path` (search root, optional) + `glob` (path-shaped + * file filter); `pattern` is a regex on contents, NOT a path + * - lsp → `filePath` (URI-aware: `file://` accepted, others dropped) + * plus `callHierarchyItem.uri` for incomingCalls / outgoingCalls + * * Used by ConditionalRulesRegistry / SkillActivationRegistry hooks to - * route every path the tool just touched through the same activation - * pipeline. Returns input order, with empty / non-string entries dropped. - * - * For `glob` specifically, `pattern` is the actual selector — `path` is - * only the optional search root. `glob({ pattern: 'src/**\/*.tsx' })` - * with no `path` therefore needs `pattern` extracted, otherwise no - * conditional skill keyed on `paths: ['src/**\/*.tsx']` would ever - * activate from a glob call. The pattern goes through the same - * registry; picomatch matches glob-vs-glob on common shapes (e.g. - * `**\/*.tsx` matches `src/**\/*.tsx` because `**` accepts the - * literal `**` in the input). False matches are bounded to the same - * project-root scope as any other path. - * - * Returns `[]` for tool names outside `FS_PATH_TOOL_NAMES` — see that - * set's docstring for why this is gated rather than universal. + * route every project-relative path the tool actually touched through + * the same activation pipeline. Returns `[]` for tool names outside + * `FS_PATH_TOOL_NAMES` — see that set's docstring for why this is gated. */ export function extractToolFilePaths( toolName: string, @@ -241,55 +289,79 @@ export function extractToolFilePaths( (ToolNamesMigration as Record)[toolName] ?? toolName; if (!FS_PATH_TOOL_NAMES.has(canonical)) return []; if (!toolInput || typeof toolInput !== 'object') return []; + const obj = toolInput as Record; const out: string[] = []; const push = (v: unknown): void => { if (typeof v === 'string' && v.length > 0) out.push(v); }; - const obj = toolInput as Record; - push(obj['file_path']); - push(obj['filePath']); - push(obj['path']); - const pathsField = obj['paths']; - if (Array.isArray(pathsField)) { - for (const p of pathsField) push(p); - } - if (canonical === ToolNames.GLOB) { - // Glob-only: derive the effective selector from `path` + `pattern`. - // The previous version pushed `pattern` standalone, but the glob - // call actually searches `/` — so a skill keyed on - // `paths: ['src/**/*.ts']` would not activate from - // `glob({ path: 'src', pattern: '**/*.ts' })` because the candidate - // was just `**/*.ts`. Concat (rather than `path.join`) for two - // reasons: (1) `path.join` is OS-aware, so on Windows the result - // contains backslashes and silently diverges from the forward-slash - // form the registry matches against; (2) `path.join` normalizes - // `..` segments — `path.join('src', '../foo')` collapses to `foo`, - // losing information about the directory the glob actually - // escaped from. Trim trailing separators on `pathField` to avoid - // `src//pattern`. Don't add `pattern` for grep_search even though - // it also has a `pattern` field — grep's pattern is a regex, not - // a path glob, and would false-match. - const pathField = obj['path']; - const patternField = obj['pattern']; - if (typeof patternField === 'string' && patternField.length > 0) { - if (typeof pathField === 'string' && pathField.length > 0) { - // Trim trailing `/` or `\` without a regex. The previous form - // `pathField.replace(/[\\/]+$/, '')` is functionally equivalent - // but CodeQL flags `+` on uncontrolled input as a polynomial - // ReDoS candidate (rule #145). The loop is O(n) on the trailing - // separator run, no different from the regex engine, but - // sidesteps the warning. - let trimmed = pathField; - while (trimmed.endsWith('/') || trimmed.endsWith('\\')) { - trimmed = trimmed.slice(0, -1); - } - push(`${trimmed}/${patternField}`); - } else { - push(patternField); + + switch (canonical) { + case ToolNames.LSP: { + // `filePath` may be a plain path, a `file://` URI, or a non-file + // URI (`http://`, `git://`, etc.). Only the first two correspond + // to project files — everything else must be ignored, otherwise + // an LSP call on a non-file resource could activate path-gated + // skills without the model having touched the project. + pushLspPathCandidate(out, obj['filePath']); + // incomingCalls / outgoingCalls operate on `callHierarchyItem.uri`, + // not the top-level `filePath`. Without this, the model can follow + // a call hierarchy through a project file and never trigger + // activation for a skill scoped to that file. + const item = obj['callHierarchyItem']; + if (item && typeof item === 'object') { + pushLspPathCandidate(out, (item as Record)['uri']); } + return out; } + + case ToolNames.GLOB: { + const pathField = obj['path']; + const patternField = obj['pattern']; + // The standalone search-root candidate (so a broad skill keyed on + // `paths: ['src/**']` still activates from `glob({ path: 'src' })`). + push(pathField); + // `pattern` is the actual selector. Combine with `path` to form + // the effective walked glob. + if (typeof patternField === 'string' && patternField.length > 0) { + push( + joinSearchRootAndGlob( + typeof pathField === 'string' ? pathField : undefined, + patternField, + ), + ); + } + return out; + } + + case ToolNames.GREP: { + const pathField = obj['path']; + const globField = obj['glob']; + push(pathField); + // `glob` is the path-shaped file filter (NOT `pattern`, which is a + // regex on contents). Combine with `path` for the effective + // filter selector. + if (typeof globField === 'string' && globField.length > 0) { + push( + joinSearchRootAndGlob( + typeof pathField === 'string' ? pathField : undefined, + globField, + ), + ); + } + return out; + } + + case ToolNames.LS: + push(obj['path']); + return out; + + case ToolNames.READ_FILE: + case ToolNames.EDIT: + case ToolNames.WRITE_FILE: + default: + push(obj['file_path']); + return out; } - return out; } export type ConfirmHandler = (