Skip to content
2 changes: 1 addition & 1 deletion docs/core/subagents.md
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@ description: Specialized in finding security vulnerabilities in code.
kind: local
tools:
- read_file
- search_file_content
- grep_search
model: gemini-2.5-pro
temperature: 0.2
max_turns: 10
Expand Down
11 changes: 5 additions & 6 deletions docs/tools/file-system.md
Original file line number Diff line number Diff line change
Expand Up @@ -117,14 +117,13 @@ directories) will be created.
`Found 5 file(s) matching "*.ts" within src, sorted by modification time (newest first):\nsrc/file1.ts\nsrc/subdir/file2.ts...`
- **Confirmation:** No.

## 5. `search_file_content` (SearchText)
## 5. `grep_search` (SearchText)

`search_file_content` searches for a regular expression pattern within the
content of files in a specified directory. Can filter files by a glob pattern.
Returns the lines containing matches, along with their file paths and line
numbers.
`grep_search` searches for a regular expression pattern within the content of
files in a specified directory. Can filter files by a glob pattern. Returns the
lines containing matches, along with their file paths and line numbers.

- **Tool name:** `search_file_content`
- **Tool name:** `grep_search`
- **Display name:** SearchText
- **File:** `grep.ts`
- **Parameters:**
Expand Down
30 changes: 15 additions & 15 deletions packages/core/src/core/__snapshots__/prompts.test.ts.snap

Large diffs are not rendered by default.

8 changes: 4 additions & 4 deletions packages/core/src/core/prompts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ import { ApprovalMode } from '../policy/types.js';
vi.mock('../tools/ls', () => ({ LSTool: { Name: 'list_directory' } }));
vi.mock('../tools/edit', () => ({ EditTool: { Name: 'replace' } }));
vi.mock('../tools/glob', () => ({ GlobTool: { Name: 'glob' } }));
vi.mock('../tools/grep', () => ({ GrepTool: { Name: 'search_file_content' } }));
vi.mock('../tools/grep', () => ({ GrepTool: { Name: 'grep_search' } }));
vi.mock('../tools/read-file', () => ({ ReadFileTool: { Name: 'read_file' } }));
vi.mock('../tools/read-many-files', () => ({
ReadManyFilesTool: { Name: 'read_many_files' },
Expand Down Expand Up @@ -241,14 +241,14 @@ describe('Core System Prompt (prompts.ts)', () => {
);
expect(prompt).toContain(`do not ignore the output of the agent`);
expect(prompt).not.toContain(
"Use 'search_file_content' and 'glob' search tools extensively",
"Use 'grep_search' and 'glob' search tools extensively",
);
} else {
expect(prompt).not.toContain(
`your **first and primary action** must be to delegate to the '${CodebaseInvestigatorAgent.name}' agent`,
);
expect(prompt).toContain(
"Use 'search_file_content' and 'glob' search tools extensively",
"Use 'grep_search' and 'glob' search tools extensively",
);
}
expect(prompt).toMatchSnapshot();
Expand Down Expand Up @@ -291,7 +291,7 @@ describe('Core System Prompt (prompts.ts)', () => {
// Should NOT include disabled tools
expect(prompt).not.toContain('`google_web_search`');
expect(prompt).not.toContain('`list_directory`');
expect(prompt).not.toContain('`search_file_content`');
expect(prompt).not.toContain('`grep_search`');
});
});

Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/policy/policies/plan.toml
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ priority = 50
modes = ["plan"]

[[rule]]
toolName = "search_file_content"
toolName = "grep_search"
decision = "allow"
priority = 50
modes = ["plan"]
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/policy/policies/read-only.toml
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ decision = "allow"
priority = 50

[[rule]]
toolName = "search_file_content"
toolName = "grep_search"
decision = "allow"
priority = 50

Expand Down
83 changes: 83 additions & 0 deletions packages/core/src/policy/policy-engine.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,43 @@ vi.mock('../utils/shell-utils.js', async (importOriginal) => {
};
});

// Mock tool-names to provide a consistent alias for testing

vi.mock('../tools/tool-names.js', async (importOriginal) => {
const actual =
await importOriginal<typeof import('../tools/tool-names.js')>();

const mockedAliases: Record<string, string> = {
...actual.TOOL_LEGACY_ALIASES,

legacy_test_tool: 'current_test_tool',

another_legacy_test_tool: 'current_test_tool',
};

return {
...actual,

TOOL_LEGACY_ALIASES: mockedAliases,

getToolAliases: vi.fn().mockImplementation((name: string) => {
const aliases = new Set<string>([name]);

const canonicalName = mockedAliases[name] ?? name;

aliases.add(canonicalName);

for (const [legacyName, currentName] of Object.entries(mockedAliases)) {
if (currentName === canonicalName) {
aliases.add(legacyName);
}
}

return Array.from(aliases);
}),
};
});

describe('PolicyEngine', () => {
let engine: PolicyEngine;
let mockCheckerRunner: CheckerRunner;
Expand Down Expand Up @@ -187,6 +224,52 @@ describe('PolicyEngine', () => {
);
});

it('should match current tool call against legacy tool name rules', async () => {
const legacyName = 'legacy_test_tool';
const currentName = 'current_test_tool';

const rules: PolicyRule[] = [
{ toolName: legacyName, decision: PolicyDecision.DENY },
];

engine = new PolicyEngine({ rules });

// Call using the CURRENT name, should be denied because of legacy rule
const { decision } = await engine.check({ name: currentName }, undefined);
expect(decision).toBe(PolicyDecision.DENY);
});

it('should match legacy tool call against current tool name rules (for skills support)', async () => {
const legacyName = 'legacy_test_tool';
const currentName = 'current_test_tool';

const rules: PolicyRule[] = [
{ toolName: currentName, decision: PolicyDecision.ALLOW },
];

engine = new PolicyEngine({ rules });

// Call using the LEGACY name (from a skill), should be allowed because of current rule
const { decision } = await engine.check({ name: legacyName }, undefined);
expect(decision).toBe(PolicyDecision.ALLOW);
});

it('should match tool call using one legacy name against policy for another legacy name (same canonical tool)', async () => {
const legacyName1 = 'legacy_test_tool';
const legacyName2 = 'another_legacy_test_tool';

const rules: PolicyRule[] = [
{ toolName: legacyName2, decision: PolicyDecision.DENY },
];

engine = new PolicyEngine({ rules });

// Call using legacyName1, should be denied because legacyName2 has a deny rule
// and they both point to the same canonical tool.
const { decision } = await engine.check({ name: legacyName1 }, undefined);
expect(decision).toBe(PolicyDecision.DENY);
});

it('should apply wildcard rules (no toolName)', async () => {
const rules: PolicyRule[] = [
{ decision: PolicyDecision.DENY }, // Applies to all tools
Expand Down
25 changes: 19 additions & 6 deletions packages/core/src/policy/policy-engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
splitCommands,
hasRedirection,
} from '../utils/shell-utils.js';
import { getToolAliases } from '../tools/tool-names.js';

function ruleMatches(
rule: PolicyRule | SafetyCheckerRule,
Expand Down Expand Up @@ -308,12 +309,24 @@ export class PolicyEngine {

// For tools with a server name, we want to try matching both the
// original name and the fully qualified name (server__tool).
const toolCallsToTry: FunctionCall[] = [toolCall];
if (serverName && toolCall.name && !toolCall.name.includes('__')) {
toolCallsToTry.push({
...toolCall,
name: `${serverName}__${toolCall.name}`,
});
// We also want to check legacy aliases for the tool name.
const toolNamesToTry = new Set<string>();
if (toolCall.name) {
const aliases = getToolAliases(toolCall.name);
for (const alias of aliases) {
toolNamesToTry.add(alias);
}
}

const toolCallsToTry: FunctionCall[] = [];
for (const name of toolNamesToTry) {
toolCallsToTry.push({ ...toolCall, name });
if (serverName && !name.includes('__')) {
toolCallsToTry.push({
...toolCall,
name: `${serverName}__${name}`,
});
}
}

for (const rule of this.rules) {
Expand Down
10 changes: 8 additions & 2 deletions packages/core/src/tools/grep.ts
Original file line number Diff line number Diff line change
Expand Up @@ -263,7 +263,13 @@ class GrepToolInvocation extends BaseToolInvocation<
const matchCount = allMatches.length;
const matchTerm = matchCount === 1 ? 'match' : 'matches';

let llmContent = `Found ${matchCount} ${matchTerm} for pattern "${this.params.pattern}" ${searchLocationDescription}${this.params.include ? ` (filter: "${this.params.include}")` : ''}${wasTruncated ? ` (results limited to ${totalMaxMatches} matches for performance)` : ''}:\n---\n`;
let llmContent = `Found ${matchCount} ${matchTerm} for pattern "${this.params.pattern}" ${searchLocationDescription}${this.params.include ? ` (filter: "${this.params.include}")` : ''}`;

if (wasTruncated) {
llmContent += ` (results limited to ${totalMaxMatches} matches for performance)`;
}

llmContent += `:\n---\n`;

for (const filePath in matchesByFile) {
llmContent += `File: ${filePath}
Expand Down Expand Up @@ -570,7 +576,7 @@ export class GrepTool extends BaseDeclarativeTool<GrepToolParams, ToolResult> {
super(
GrepTool.Name,
'SearchText',
'Searches for a regular expression pattern within the content of files in a specified directory (or current working directory). Can filter files by a glob pattern. Returns the lines containing matches, along with their file paths and line numbers.',
'Searches for a regular expression pattern within file contents. Max 100 matches.',
Kind.Search,
{
properties: {
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/tools/ripGrep.ts
Original file line number Diff line number Diff line change
Expand Up @@ -495,7 +495,7 @@ export class RipGrepTool extends BaseDeclarativeTool<
super(
RipGrepTool.Name,
'SearchText',
'FAST, optimized search powered by `ripgrep`. PREFERRED over standard `run_shell_command("grep ...")` due to better performance and automatic output limiting (max 20k matches).',
'Searches for a regular expression pattern within file contents. Max 100 matches.',
Kind.Search,
{
properties: {
Expand Down
60 changes: 59 additions & 1 deletion packages/core/src/tools/tool-names.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,44 @@
* SPDX-License-Identifier: Apache-2.0
*/

import { describe, it, expect } from 'vitest';
import { describe, it, expect, vi } from 'vitest';
import {
isValidToolName,
getToolAliases,
ALL_BUILTIN_TOOL_NAMES,
DISCOVERED_TOOL_PREFIX,
LS_TOOL_NAME,
} from './tool-names.js';

// Mock tool-names to provide a consistent alias for testing
vi.mock('./tool-names.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('./tool-names.js')>();
const mockedAliases: Record<string, string> = {
...actual.TOOL_LEGACY_ALIASES,
legacy_test_tool: 'current_test_tool',
another_legacy_test_tool: 'current_test_tool',
};
return {
...actual,
TOOL_LEGACY_ALIASES: mockedAliases,
isValidToolName: vi.fn().mockImplementation((name: string, options) => {
if (mockedAliases[name]) return true;
return actual.isValidToolName(name, options);
}),
getToolAliases: vi.fn().mockImplementation((name: string) => {
const aliases = new Set<string>([name]);
const canonicalName = mockedAliases[name] ?? name;
aliases.add(canonicalName);
for (const [legacyName, currentName] of Object.entries(mockedAliases)) {
if (currentName === canonicalName) {
aliases.add(legacyName);
}
}
return Array.from(aliases);
}),
};
});

describe('tool-names', () => {
describe('isValidToolName', () => {
it('should validate built-in tool names', () => {
Expand All @@ -30,6 +60,13 @@ describe('tool-names', () => {
expect(isValidToolName('my-server__my-tool')).toBe(true);
});

it('should validate legacy tool aliases', async () => {
const { TOOL_LEGACY_ALIASES } = await import('./tool-names.js');
for (const legacyName of Object.keys(TOOL_LEGACY_ALIASES)) {
expect(isValidToolName(legacyName)).toBe(true);
}
});

it('should reject invalid tool names', () => {
expect(isValidToolName('')).toBe(false);
expect(isValidToolName('invalid-name')).toBe(false);
Expand All @@ -54,4 +91,25 @@ describe('tool-names', () => {
);
});
});

describe('getToolAliases', () => {
it('should return all associated names for a current tool', () => {
const aliases = getToolAliases('current_test_tool');
expect(aliases).toContain('current_test_tool');
expect(aliases).toContain('legacy_test_tool');
expect(aliases).toContain('another_legacy_test_tool');
});

it('should return all associated names for a legacy tool', () => {
const aliases = getToolAliases('legacy_test_tool');
expect(aliases).toContain('current_test_tool');
expect(aliases).toContain('legacy_test_tool');
expect(aliases).toContain('another_legacy_test_tool');
});

it('should return only the name itself if no aliases exist', () => {
const aliases = getToolAliases('unknown_tool');
expect(aliases).toEqual(['unknown_tool']);
});
});
});
40 changes: 38 additions & 2 deletions packages/core/src/tools/tool-names.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ export const WEB_SEARCH_TOOL_NAME = 'google_web_search';
export const WEB_FETCH_TOOL_NAME = 'web_fetch';
export const EDIT_TOOL_NAME = 'replace';
export const SHELL_TOOL_NAME = 'run_shell_command';
export const GREP_TOOL_NAME = 'search_file_content';
export const GREP_TOOL_NAME = 'grep_search';
export const READ_MANY_FILES_TOOL_NAME = 'read_many_files';
export const READ_FILE_TOOL_NAME = 'read_file';
export const LS_TOOL_NAME = 'list_directory';
Expand All @@ -26,7 +26,38 @@ export const EDIT_TOOL_NAMES = new Set([EDIT_TOOL_NAME, WRITE_FILE_TOOL_NAME]);
export const ASK_USER_TOOL_NAME = 'ask_user';
export const ASK_USER_DISPLAY_NAME = 'Ask User';

/** Prefix used for tools discovered via the toolDiscoveryCommand. */
/**
* Mapping of legacy tool names to their current names.
* This ensures backward compatibility for user-defined policies, skills, and hooks.
*/
export const TOOL_LEGACY_ALIASES: Record<string, string> = {
// Add future renames here, e.g.:
search_file_content: GREP_TOOL_NAME,
};

/**
* Returns all associated names for a tool (including legacy aliases and current name).
* This ensures that if multiple legacy names point to the same tool, we consider all of them
* for policy application.
*/
export function getToolAliases(name: string): string[] {
const aliases = new Set<string>([name]);

// Determine the canonical (current) name
const canonicalName = TOOL_LEGACY_ALIASES[name] ?? name;
aliases.add(canonicalName);

// Find all other legacy aliases that point to the same canonical name
for (const [legacyName, currentName] of Object.entries(TOOL_LEGACY_ALIASES)) {
if (currentName === canonicalName) {
aliases.add(legacyName);
}
}

return Array.from(aliases);
}

/** Prefix used for tools discovered via the tool DiscoveryCommand. */
export const DISCOVERED_TOOL_PREFIX = 'discovered_tool_';

/**
Expand Down Expand Up @@ -76,6 +107,11 @@ export function isValidToolName(
return true;
}

// Legacy aliases
if (TOOL_LEGACY_ALIASES[name]) {
return true;
}

// Discovered tools
if (name.startsWith(DISCOVERED_TOOL_PREFIX)) {
return true;
Expand Down
Loading
Loading