From 864feb721a9da770f46736314f6df6616476a531 Mon Sep 17 00:00:00 2001 From: ggbdpq Date: Wed, 28 Oct 2026 04:44:09 +0800 Subject: [PATCH 1/2] fix(runtime): contain glob results against brace-expanded escapes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Glob patterns are validated lexically (`assertGlobPatternInScope` and the worker's `assertContainedGlobPattern`): a pattern is rejected only when its literal text carries an absolute path or a `..` segment. Brace expansion happens later, inside the walker, so a lexically clean pattern like `{a,../*}` walks above its search root and glob results listed entries outside the session cwd with no downstream filter (#6397). Filter at the shared reader instead: `globFiles` gains an optional `containmentRoot` and drops matched paths that resolve outside it using the existing `isPathInside` predicate (literal resolve-space containment — the walk already refuses to follow symlinks, and results are names, not opened content). Dropped escapes do not count toward the limit, so truncation semantics still describe the returned prefix. The workspace executor passes the canonicalised session cwd (same spelling space as the walker's search root, or a symlinked root like /var -> /private/var would reject every legitimate entry); host scope keeps raw results and zero added work. The filesystem worker passes its search root, matching its "stay inside its search root" guard contract. The Windows sandbox preview's non-following walker (`windowsNonFollowingGlob`) is a separate traversal implementation and is intentionally untouched. Fixes #6397 Generated-by: GLM-5.3-Flash (ZCode) --- .../src/__tests__/builtin-tools.test.ts | 36 +++++++++++++++++++ packages/runtime/src/filesystem-executor.ts | 9 +++++ .../src/filesystem-worker/operations.ts | 8 ++++- packages/runtime/src/glob-search.ts | 15 ++++++++ packages/runtime/src/workspace-executor.ts | 2 ++ 5 files changed, 69 insertions(+), 1 deletion(-) diff --git a/packages/runtime/src/__tests__/builtin-tools.test.ts b/packages/runtime/src/__tests__/builtin-tools.test.ts index 361c070fefa..fb3ec583403 100644 --- a/packages/runtime/src/__tests__/builtin-tools.test.ts +++ b/packages/runtime/src/__tests__/builtin-tools.test.ts @@ -2181,6 +2181,42 @@ describe('builtin read tools path containment', () => { ); assert.strictEqual(JSON.stringify(absoluteGrepResult).includes('main.ts'), true); }); + + test('Glob brace expansion cannot list entries outside the session cwd', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-glob-brace-')); + const outside = await mkdtemp(join(tmpdir(), 'maka-glob-outside-')); + try { + await mkdir(join(root, 'a'), { recursive: true }); + await mkdir(join(root, 'b'), { recursive: true }); + await writeFile(join(root, 'a', 'inner.txt'), 'in', 'utf8'); + await writeFile(join(root, 'b', 'other.txt'), 'other', 'utf8'); + await writeFile(join(outside, 'secret.txt'), 'out', 'utf8'); + const glob = tool('Glob'); + + // `{a,../*}` carries no literal `..` segment, so the lexical pattern guard + // admits it, yet the `../*` brace branch walks above the session cwd. + const escaped = await runTool(glob, { pattern: '{a,../*}' }, root); + const escapedFiles = (escaped as { files: string[] }).files; + assert.strictEqual( + escapedFiles.some((file) => file.startsWith('../')), + false, + `glob listed entries outside the session cwd: ${JSON.stringify(escapedFiles)}`, + ); + // The `../*` branch also matches the session cwd itself under its own + // name; glob reports that one as `.`, which is inside and stays. + assert.deepStrictEqual(escapedFiles.sort(), ['.', 'a']); + + // Brace patterns that stay inside keep their full results. + const contained = await runTool(glob, { pattern: '{a/*.txt,b/*.txt}' }, root); + assert.deepStrictEqual((contained as { files: string[] }).files.sort(), [ + 'a/inner.txt', + 'b/other.txt', + ]); + } finally { + await rm(root, { recursive: true, force: true }); + await rm(outside, { recursive: true, force: true }); + } + }); }); describe('builtin write tools path containment', () => { diff --git a/packages/runtime/src/filesystem-executor.ts b/packages/runtime/src/filesystem-executor.ts index 99921db8dc2..02dc70a2dd9 100644 --- a/packages/runtime/src/filesystem-executor.ts +++ b/packages/runtime/src/filesystem-executor.ts @@ -666,6 +666,15 @@ function createWorkspaceFilesystemExecutor( const { files, truncated } = await workspace.globFiles({ cwd: base, pattern: operation.pattern, + // Brace expansion can walk above the session cwd without the + // pattern text ever containing a `..` segment, so the lexical + // guard alone cannot contain workspace globs; results are + // filtered against the session cwd instead. The root shares the + // canonical spelling of the search base (the walker's cwd), or a + // symlinked workspace root (/var → /private/var) would reject + // every legitimate entry. Host scope has no boundary to escape + // and keeps raw results. + ...(scope === 'workspace' ? { containmentRoot: await canonicalExistingPath(cwd) } : {}), ...(operation.limit !== undefined ? { limit: operation.limit } : {}), ...(abortSignal ? { abortSignal } : {}), }); diff --git a/packages/runtime/src/filesystem-worker/operations.ts b/packages/runtime/src/filesystem-worker/operations.ts index 24deca9ae0c..22428f6684c 100644 --- a/packages/runtime/src/filesystem-worker/operations.ts +++ b/packages/runtime/src/filesystem-worker/operations.ts @@ -392,7 +392,13 @@ export async function executeFilesystemOperation( )), }; } - return { kind: 'glob', ...(await globFiles({ ...operation, cwd: path })) }; + // Brace expansion can walk above the search root without the pattern + // text ever containing a `..` segment, so results are filtered back to + // the search root the lexical guard promises. + return { + kind: 'glob', + ...(await globFiles({ ...operation, cwd: path, containmentRoot: path })), + }; } case 'grep': { const path = await resolveExistingAllowed( diff --git a/packages/runtime/src/glob-search.ts b/packages/runtime/src/glob-search.ts index a514ab163fd..019deea1590 100644 --- a/packages/runtime/src/glob-search.ts +++ b/packages/runtime/src/glob-search.ts @@ -20,12 +20,20 @@ import { promises as fs, readdir } from 'node:fs'; import { resolve } from 'node:path'; import { globIterate } from 'glob'; +import { isPathInside } from './path-containment.js'; export async function globFiles(input: { cwd: string; pattern: string; limit?: number; abortSignal?: AbortSignal; + /** + * When set, matched paths that resolve outside this root are dropped. Glob + * expands braces before walking, so a lexically clean pattern like + * `{a,../*}` can still walk above its search root; the walk cannot be + * contained, but its results can. + */ + containmentRoot?: string; }): Promise<{ files: string[]; truncated: boolean }> { let failure: NodeJS.ErrnoException | undefined; const directories = new Set([resolve(input.cwd)]); @@ -71,6 +79,13 @@ export async function globFiles(input: { }, }, })) { + // Dropped escapes do not count toward the limit: the cap bounds returned + // files, not walked ones. + if ( + input.containmentRoot !== undefined && + !isPathInside(input.containmentRoot, resolve(input.cwd, file)) + ) + continue; if (files.length >= limit) { // One match past the cap is the only proof the pattern had more to give. truncated = true; diff --git a/packages/runtime/src/workspace-executor.ts b/packages/runtime/src/workspace-executor.ts index fc09a8507bc..1980abe5779 100644 --- a/packages/runtime/src/workspace-executor.ts +++ b/packages/runtime/src/workspace-executor.ts @@ -202,6 +202,8 @@ export interface WorkspaceGlobInput { pattern: string; limit?: number; abortSignal?: AbortSignal; + /** When set, matched paths that resolve outside this root are dropped. */ + containmentRoot?: string; } export interface WorkspaceGlobResult { From e7fb6e1920168413bfd9b6a75bd6ec76bf30a3ad Mon Sep 17 00:00:00 2001 From: ggbdpq Date: Sun, 1 Nov 2026 09:07:33 +0800 Subject: [PATCH 2/2] fix(runtime): contain glob matches in real-path space Directory symlinks escape the glob containment check: `resolve()` only normalizes the spelling, so an explicit pattern like `link-out/**/*.txt` with `link-out -> ../outside` returns files whose real location is outside the containment root while staying lexically inside it. Decide containment in real-path space instead: realpath the containment root once, realpath each candidate match, and run the pure predicate on those (the same idiom skills-discovery already uses). A root that cannot be realpath'd propagates, because the walk cannot claim containment it cannot place; a match that cannot be realpath'd (raced deletion) is dropped and marks the walk incomplete rather than silently complete. Fixes #6397 Generated-by: GLM-5.3-Flash (ZCode) --- .../src/__tests__/builtin-tools.test.ts | 27 +++++++++++++++++ packages/runtime/src/glob-search.ts | 30 +++++++++++++++---- 2 files changed, 51 insertions(+), 6 deletions(-) diff --git a/packages/runtime/src/__tests__/builtin-tools.test.ts b/packages/runtime/src/__tests__/builtin-tools.test.ts index fb3ec583403..b8eb4cec5b3 100644 --- a/packages/runtime/src/__tests__/builtin-tools.test.ts +++ b/packages/runtime/src/__tests__/builtin-tools.test.ts @@ -2217,6 +2217,33 @@ describe('builtin read tools path containment', () => { await rm(outside, { recursive: true, force: true }); } }); + + test('Glob drops matches reached through a directory symlink that leaves the session cwd', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-glob-link-')); + const outside = await mkdtemp(join(tmpdir(), 'maka-glob-linkout-')); + try { + await mkdir(join(root, 'src'), { recursive: true }); + await writeFile(join(root, 'src', 'inner.txt'), 'in', 'utf8'); + await writeFile(join(outside, 'secret.txt'), 'out', 'utf8'); + await symlink(outside, join(root, 'link-out')); + const glob = tool('Glob'); + + // `resolve()` normalizes the spelling but does not follow the directory + // symlink, so lexical containment admits `link-out/secret.txt` even + // though its real location is outside; containment must be decided in + // real-path space, matching the skills-discovery idiom. + const linked = await runTool(glob, { pattern: 'link-out/**/*.txt' }, root); + assert.deepStrictEqual((linked as { files: string[] }).files, []); + + // A symlinked directory that stays inside the root keeps its matches. + await symlink(join(root, 'src'), join(root, 'alias')); + const contained = await runTool(glob, { pattern: 'alias/*.txt' }, root); + assert.deepStrictEqual((contained as { files: string[] }).files, ['alias/inner.txt']); + } finally { + await rm(root, { recursive: true, force: true }); + await rm(outside, { recursive: true, force: true }); + } + }); }); describe('builtin write tools path containment', () => { diff --git a/packages/runtime/src/glob-search.ts b/packages/runtime/src/glob-search.ts index 019deea1590..329efefa572 100644 --- a/packages/runtime/src/glob-search.ts +++ b/packages/runtime/src/glob-search.ts @@ -31,7 +31,10 @@ export async function globFiles(input: { * When set, matched paths that resolve outside this root are dropped. Glob * expands braces before walking, so a lexically clean pattern like * `{a,../*}` can still walk above its search root; the walk cannot be - * contained, but its results can. + * contained, but its results can. Matches are compared in real-path space: + * `resolve()` normalizes spellings but does not follow directory symlinks, + * so a pattern naming a `link -> ../outside` directory stays lexically + * inside while its real location escapes. */ containmentRoot?: string; }): Promise<{ files: string[]; truncated: boolean }> { @@ -41,6 +44,13 @@ export async function globFiles(input: { const limit = input.limit ?? 200; let probingOverflow = false; let probeIncomplete = false; + // Containment is decided on real paths (skills-discovery idiom: realpath + // both sides, then the pure predicate). Cost: one realpath for the root plus + // at most `limit` for the kept-or-dropped matches, on an interactive tool + // call. A root that cannot be realpath'd is an infrastructure failure and + // propagates; the walk cannot claim containment it cannot place. + const containmentReal = + input.containmentRoot !== undefined ? await fs.realpath(input.containmentRoot) : undefined; function record(error: NodeJS.ErrnoException, path: string): void { // Speculative literal components may miss. A directory already admitted by // cwd, stat, or enumeration disappearing instead makes this walk incomplete. @@ -81,11 +91,19 @@ export async function globFiles(input: { })) { // Dropped escapes do not count toward the limit: the cap bounds returned // files, not walked ones. - if ( - input.containmentRoot !== undefined && - !isPathInside(input.containmentRoot, resolve(input.cwd, file)) - ) - continue; + if (containmentReal !== undefined) { + let matchReal: string; + try { + matchReal = await fs.realpath(resolve(input.cwd, file)); + } catch { + // The match cannot be placed in the root's path space. Dropping it + // silently would read the walk as complete, so flag incompleteness + // the way vanished directories already do. + probeIncomplete = true; + continue; + } + if (!isPathInside(containmentReal, matchReal)) continue; + } if (files.length >= limit) { // One match past the cap is the only proof the pattern had more to give. truncated = true;