mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-05-13 15:32:19 +00:00
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:
parent
18e30aae39
commit
58836f1c3d
2 changed files with 415 additions and 96 deletions
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 = (
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue