From be8e90c402da9ee91dfa66b84d83fdc75e31fc56 Mon Sep 17 00:00:00 2001 From: "haozhe.yang" Date: Tue, 2 Jun 2026 13:12:24 +0800 Subject: [PATCH 1/2] fix(glob): normalize pattern before brace expansion --- .changeset/fix-glob-normalize-braces.md | 6 +++ .../agent-core/src/tools/builtin/file/glob.ts | 2 +- packages/agent-core/test/tools/glob.test.ts | 40 +++++++++++++++++++ 3 files changed, 47 insertions(+), 1 deletion(-) create mode 100644 .changeset/fix-glob-normalize-braces.md diff --git a/.changeset/fix-glob-normalize-braces.md b/.changeset/fix-glob-normalize-braces.md new file mode 100644 index 00000000..5fbfef42 --- /dev/null +++ b/.changeset/fix-glob-normalize-braces.md @@ -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. diff --git a/packages/agent-core/src/tools/builtin/file/glob.ts b/packages/agent-core/src/tools/builtin/file/glob.ts index 690f56a8..a5a69623 100644 --- a/packages/agent-core/src/tools/builtin/file/glob.ts +++ b/packages/agent-core/src/tools/builtin/file/glob.ts @@ -161,7 +161,7 @@ export class GlobTool implements BuiltinTool { // 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(normalize(args.pattern)); // Default true. When false, directories yielded by kaos are // filtered out using the same stat that fuels the mtime sort diff --git a/packages/agent-core/test/tools/glob.test.ts b/packages/agent-core/test/tools/glob.test.ts index 8fc23791..f4f911ba 100644 --- a/packages/agent-core/test/tools/glob.test.ts +++ b/packages/agent-core/test/tools/glob.test.ts @@ -144,6 +144,46 @@ 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('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( From 25be777730573ec8b970aecbd53c4c488f9fa0ac Mon Sep 17 00:00:00 2001 From: "haozhe.yang" Date: Tue, 2 Jun 2026 15:46:49 +0800 Subject: [PATCH 2/2] fix(glob): preserve backslash-escaped metacharacters and normalize braces correctly - expand braces before normalizing to prevent `..` from collapsing across brace groups - skip normalization for patterns containing backslash-escaped glob metacharacters - add test coverage for escaped braces and nested `..` inside alternatives --- .../agent-core/src/tools/builtin/file/glob.ts | 15 +++---- packages/agent-core/test/tools/glob.test.ts | 43 +++++++++++++++++++ 2 files changed, 50 insertions(+), 8 deletions(-) diff --git a/packages/agent-core/src/tools/builtin/file/glob.ts b/packages/agent-core/src/tools/builtin/file/glob.ts index a5a69623..4c806c31 100644 --- a/packages/agent-core/src/tools/builtin/file/glob.ts +++ b/packages/agent-core/src/tools/builtin/file/glob.ts @@ -154,14 +154,9 @@ export class GlobTool implements BuiltinTool { } private async execution(args: GlobInput, searchRoots: string[]): Promise { - // 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(normalize(args.pattern)); + const subPatterns = expandBraces(args.pattern).map((p) => + hasGlobEscape(p) ? p : normalize(p), + ); // Default true. When false, directories yielded by kaos are // filtered out using the same stat that fuels the mtime sort @@ -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; diff --git a/packages/agent-core/test/tools/glob.test.ts b/packages/agent-core/test/tools/glob.test.ts index f4f911ba..2b920f80 100644 --- a/packages/agent-core/test/tools/glob.test.ts +++ b/packages/agent-core/test/tools/glob.test.ts @@ -184,6 +184,49 @@ describe('GlobTool', () => { 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(