fix(skills): per-tool extraction dispatcher (LSP URI + grep glob + integration test)

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.
This commit is contained in:
Shaojin Wen 2026-05-02 05:13:58 +08:00
parent 18e30aae39
commit 58836f1c3d
2 changed files with 415 additions and 96 deletions

View file

@ -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<typeof vi.fn>;
skillToolPresent: boolean;
}): {
scheduler: CoreToolScheduler;
onAllToolCallsComplete: ReturnType<typeof vi.fn>;
} {
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();
});
});

View file

@ -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<string> = new Set<string>([
]);
/**
* 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); `<path>/<pattern>` 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<string, string>)[toolName] ?? toolName;
if (!FS_PATH_TOOL_NAMES.has(canonical)) return [];
if (!toolInput || typeof toolInput !== 'object') return [];
const obj = toolInput as Record<string, unknown>;
const out: string[] = [];
const push = (v: unknown): void => {
if (typeof v === 'string' && v.length > 0) out.push(v);
};
const obj = toolInput as Record<string, unknown>;
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 `<path>/<pattern>` — 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<string, unknown>)['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 = (