Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/fix-glob-normalize-braces.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@moonshot-ai/agent-core": patch
"@moonshot-ai/kimi-code": patch
---

Normalize glob patterns before brace expansion to prevent incorrect path matching.
15 changes: 7 additions & 8 deletions packages/agent-core/src/tools/builtin/file/glob.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,14 +154,9 @@ export class GlobTool implements BuiltinTool<GlobInput> {
}

private async execution(args: GlobInput, searchRoots: string[]): Promise<ExecutableToolResult> {
// Expand brace alternations into a list of sub-patterns the kaos
// walker can actually understand. `*.{ts,tsx}` → ["*.ts", "*.tsx"];
// unbalanced or comma-less braces (`{abc}`, `{a,b`) fall through as
// a single-element list with the original pattern. When the fan-out
// would exceed MAX_BRACE_EXPANSIONS we also return the original so
// the caller sees an obvious zero-match outcome rather than a silent
// partial walk.
const subPatterns = expandBraces(args.pattern);
const subPatterns = expandBraces(args.pattern).map((p) =>
hasGlobEscape(p) ? p : normalize(p),
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Do not treat Windows separators before wildcards as escapes

For Windows-style glob patterns where a backslash separator is immediately before a wildcard, such as src\*.ts or src\**\*.ts, this branch treats the separator as an escaped glob metacharacter and skips normalization. kaos.glob only splits patterns on / (packages/kaos/src/local.ts:252), and its regex builder then interprets \* as a literal *, so these inputs search the root for a literal src*.ts-style name instead of walking under src. This leaves the Windows-backslash normalization path broken for the most common wildcard cases.

Useful? React with 👍 / 👎.

);

// Default true. When false, directories yielded by kaos are
// filtered out using the same stat that fuels the mtime sort
Expand Down Expand Up @@ -345,6 +340,10 @@ export function expandBraces(pattern: string): string[] {
return out;
}

function hasGlobEscape(pattern: string): boolean {
return /\\[{}[\]*?,]/.test(pattern);
}

function expandInto(pattern: string, out: string[], cap: number): boolean {
// Find the first balanced `{...}` group containing a top-level comma.
let depth = 0;
Expand Down
83 changes: 83 additions & 0 deletions packages/agent-core/test/tools/glob.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,89 @@ describe('GlobTool', () => {
expect(lines.filter((l) => l === 'shared.ts')).toHaveLength(1);
});

it('normalizes the pattern before brace expansion so redundant separators are collapsed', async () => {
// `src//*.{ts,tsx}` should be normalized → `src/*.{ts,tsx}` before
// expandBraces splits it, so each sub-pattern is clean.
const glob = vi.fn((_root: string, pattern: string) => {
if (pattern === 'src/*.ts') return asyncPaths(['/workspace/src/a.ts']);
if (pattern === 'src/*.tsx') return asyncPaths(['/workspace/src/b.tsx']);
return asyncPaths([]);
});
const tool = new GlobTool(
createFakeKaos({ glob, stat: vi.fn().mockResolvedValue(stat(1)) }),
workspace,
);

const result = await executeTool(tool, context({ pattern: 'src//*.{ts,tsx}' }));

expect(result.isError).toBeFalsy();
expect(glob).toHaveBeenCalledWith('/workspace', 'src/*.ts');
expect(glob).toHaveBeenCalledWith('/workspace', 'src/*.tsx');
});

it('normalizes the pattern before brace expansion so a leading ./ is removed', async () => {
// `./src/*.{ts,tsx}` should be normalized → `src/*.{ts,tsx}` before
// expandBraces splits it, so each sub-pattern is clean.
const glob = vi.fn((_root: string, pattern: string) => {
if (pattern === 'src/*.ts') return asyncPaths(['/workspace/src/a.ts']);
if (pattern === 'src/*.tsx') return asyncPaths(['/workspace/src/b.tsx']);
return asyncPaths([]);
});
const tool = new GlobTool(
createFakeKaos({ glob, stat: vi.fn().mockResolvedValue(stat(1)) }),
workspace,
);

const result = await executeTool(tool, context({ pattern: './src/*.{ts,tsx}' }));

expect(result.isError).toBeFalsy();
expect(glob).toHaveBeenCalledWith('/workspace', 'src/*.ts');
expect(glob).toHaveBeenCalledWith('/workspace', 'src/*.tsx');
});

it('normalizes `..` inside a brace alternative without collapsing across the braces', async () => {
// `src/{foo/../bar,baz}/*.ts` must first split on the brace group,
// *then* normalize each alternative — otherwise pathe collapses
// `foo/../bar,baz}` together and the whole brace structure is lost.
const glob = vi.fn((_root: string, pattern: string) => {
if (pattern === 'src/bar/*.ts') return asyncPaths(['/workspace/src/bar/a.ts']);
if (pattern === 'src/baz/*.ts') return asyncPaths(['/workspace/src/baz/b.ts']);
return asyncPaths([]);
});
const tool = new GlobTool(
createFakeKaos({ glob, stat: vi.fn().mockResolvedValue(stat(1)) }),
workspace,
);

const result = await executeTool(tool, context({ pattern: 'src/{foo/../bar,baz}/*.ts' }));

expect(result.isError).toBeFalsy();
expect(glob).toHaveBeenCalledWith('/workspace', 'src/bar/*.ts');
expect(glob).toHaveBeenCalledWith('/workspace', 'src/baz/*.ts');
});

it('preserves backslash-escaped glob metacharacters end-to-end', async () => {
// `\{a,b\}.ts` opts out of brace expansion (the user wants to match
// a file literally named `{a,b}.ts`). kaos.glob must receive the
// pattern unchanged — running pathe.normalize over it would rewrite
// the escape backslashes into path separators and break the intent.
const glob = vi.fn((_root: string, pattern: string) => {
if (pattern === '\\{a,b\\}.ts') return asyncPaths(['/workspace/{a,b}.ts']);
return asyncPaths([]);
});
const tool = new GlobTool(
createFakeKaos({ glob, stat: vi.fn().mockResolvedValue(stat(1)) }),
workspace,
);

const result = await executeTool(tool, context({ pattern: '\\{a,b\\}.ts' }));

expect(result.isError).toBeFalsy();
expect(glob).toHaveBeenCalledWith('/workspace', '\\{a,b\\}.ts');
// And it must *not* have been called with any brace-expanded form.
expect(glob).not.toHaveBeenCalledWith('/workspace', expect.stringContaining('/'));
});

it('searches only the current workspace when path is omitted', async () => {
const glob = vi.fn().mockReturnValue(asyncPaths(['/workspace/a.ts', '/workspace/shared.ts']));
const tool = new GlobTool(
Expand Down
Loading