fix(hosted): contain the glob walk and bound patterns before expanding

Closes the five Critical findings from the round-4 review of #13168.

R1-4 / R3-4: the string gate matched `..` by segment, so `[.][.]` and
`\.\.` walked out of the Session, and the output guard judged only the
100 displayed paths while the message counted up to 1000 collected ones.
GlobTool gains an opt-in `containmentRoot`; the managed tool set passes
the Session directory, and glob's `ignored`/`childrenIgnored` hooks prune
every entry whose lexical path, or whose parent's realpath, leaves it. No
pattern spelling can walk, report or count anything outside, and an
existing and a missing outside path now answer identically. The ordinary
CLI does not set the option.

R4-2 / R4-3: brace-expansion runs without an output cap (minimatch passes
no `max`), recurses once per nesting level, and glob expands the same
pattern again, so a nested pattern overflowed the stack outside any try
and `{1..100000}` or `{a,b}x30` stalled or exhausted the shared worker.
A shared `checkHostedGlobPattern` bounds the pattern from its structure
before anything expands it: at most 1024 characters, nesting depth 8 and
64 alternatives; unbalanced or unboundable shapes are refused. Harness
(pre-acquisition) and worker apply the same check, with a
model-correctable message. A fuzz of 518k random patterns against
brace-expansion 2.1.1 found no admitted pattern that expands past its
bound; a seeded version runs in the unit test.

R4-1: Workspace recovery whitelisted only the `/1` profiles, so one `/2`
Session aborted recovery for the whole Workspace. The profile constants
and predicates move to `hosted-workspace-profiles.ts` (re-exported from
the tool turn) and recovery uses them; the W1 design (EN + zh) now names
the `/2` variants, which add only the read-only `glob` tool.
This commit is contained in:
yiliang114 committed 2026-10-03 21:43:33 +09:00
1 parent 9cce120d2f
commit 3e86d89c6d
15 files changed
+547 -94

No files matched your search

@@ -40,7 +40,7 @@ The merged [W0e recovery design](2026-09-27-managed-workspace-recovery.md), [tru
- Preserve `tenantId`, `workspaceId`, `workspaceGeneration`, `storageId`, `cwdRelative`, `contextConfigRef` and `contextRevision`. W1 does not change Session cwd, choose another Workspace, or replace storage. A logical change needs separate admission, W2, or a new Session.
- Preserve original command, input and execution identities, result receipts, journal bytes and model-consumed messages. Neither mount relocation nor backfill rewrites committed content or replays tool effects.
- Distinguish three facts: the original execution outcome, proof that old writers cannot return, and proof of usable storage/resources. None implies the other two. `ABANDONED` remains an unknown outcome, not a successful result or an automatic unlock.
- W1a targets process restart in a trusted single-host OpenJDK 21/Linux local-process deployment with an unambiguous persistent root birth time and the saved `hosted-workspace-files/1` or `hosted-workspace-shell/1` profile. The next tool Turn after a Broker restart requires `durable-local-process=true`. Whole-host restart is accepted only if the registered identity still matches; portable identity across reboot is not promised. Arbitrary configuration snapshots, Legacy import, cross-host takeover, Kubernetes, hostile same-UID writers, disk/VM rollback and online migration are outside its acceptance.
- W1a targets process restart in a trusted single-host OpenJDK 21/Linux local-process deployment with an unambiguous persistent root birth time and a saved file or Shell profile: `hosted-workspace-files/1`, `hosted-workspace-shell/1`, or their `/2` search variants, which add only the read-only `glob` tool and so leave retained-resource validation unchanged. The next tool Turn after a Broker restart requires `durable-local-process=true`. Whole-host restart is accepted only if the registered identity still matches; portable identity across reboot is not promised. Arbitrary configuration snapshots, Legacy import, cross-host takeover, Kubernetes, hostile same-UID writers, disk/VM rollback and online migration are outside its acceptance.
- New execution needs current authorization. Authorized history/export and saved-identity cleanup do not require a working mount. Revocation must block new work without preventing trusted cleanup.
- Published Items/Snapshots cannot reconstruct the private Session authority. O2 result bytes are not a backup of the Workspace filesystem.
@@ -40,7 +40,7 @@ W0 已持久保存 binding,并提供 Runtime 接管与回收。W1 补充的是
- 保留 `tenantId`、`workspaceId`、`workspaceGeneration`、`storageId`、`cwdRelative`、`contextConfigRef` 和 `contextRevision`。W1 不切换 Session cwd、不改选 Workspace、不替换存储。逻辑变更需要另行准入、W2 或新建 Session。
- 保留原 command、input、execution 身份、结果回执、日志字节和模型实际消费的消息。挂载迁移和回填都不改写已提交内容,也不重放工具副作用。
- 区分三件事:原执行结果、旧写入者不能再回来的证据、存储及资源可用的证据。三者互不推导。`ABANDONED` 仍表示结果未知,不是成功结果或自动解锁。
- W1a 面向 root 具有无歧义持久 birth time 的受信任单主机 OpenJDK 21/Linux local-process 部署的进程重启,以及已保存的 `hosted-workspace-files/1` 或 `hosted-workspace-shell/1` profile。Broker 重启后的下一工具 Turn 需要 `durable-local-process=true`。整机重启后只有登记身份仍匹配才允许恢复,不承诺可跨重启的通用身份。任意配置快照、Legacy 导入、跨主机接管、Kubernetes、同 UID 恶意写入者、磁盘/虚拟机回滚、在线迁移均不在其验收范围。
- W1a 面向 root 具有无歧义持久 birth time 的受信任单主机 OpenJDK 21/Linux local-process 部署的进程重启,以及已保存的文件或 Shell profile:`hosted-workspace-files/1`、`hosted-workspace-shell/1`,或它们的 `/2` 搜索变体。`/2` 只多了只读的 `glob` 工具,因此保留资源校验不变。Broker 重启后的下一工具 Turn 需要 `durable-local-process=true`。整机重启后只有登记身份仍匹配才允许恢复,不承诺可跨重启的通用身份。任意配置快照、Legacy 导入、跨主机接管、Kubernetes、同 UID 恶意写入者、磁盘/虚拟机回滚、在线迁移均不在其验收范围。
- 新执行需要当前授权。获授权的历史/导出,以及基于保存身份的清理,不要求挂载正常。撤权必须阻断新工作,同时允许可信清理继续。
- 公开 Items/Snapshots 不能重建私有 Session authority。O2 的结果字节也不是 Workspace 文件系统备份。
@@ -55,6 +55,18 @@ invariants hold there, because Glob's own validation admits external paths:
which spans sibling Sessions on the same mount), and any other value must
resolve inside it; anything else settles as a tool error the model can
correct.
- The walk itself is contained: the managed `GlobTool` is built with a
`containmentRoot` of the Session directory, and glob's traversal hooks
prune every entry whose lexical path, or whose parent's realpath, leaves
it. No pattern spelling (`..`, `[.][.]`, `\.\.`, brace alternatives, a
symlinked directory) can walk, report or count anything outside, so an
existing and a missing outside path answer identically.
- The pattern is bounded before anything expands it. brace-expansion runs
without an output cap and glob expands the same pattern again, so both the
Harness (pre-acquisition) and the worker refuse a pattern over 1024
characters, with unbalanced braces, or with more than 64 brace
alternatives, computed from its structure; only then is it expanded and
its alternatives checked for absolute or `..` segments as a fast path.
- Results are rewritten to Workspace-relative paths before they reach the
wire, the model, or the durable record. The Runtime host's physical layout
must not leak to the Harness; for a search tool the paths are the payload.
@@ -62,9 +74,9 @@ invariants hold there, because Glob's own validation admits external paths:
Core ignore filtering is rooted at the Session directory. A Session below
the repository root does not inherit ancestor `.gitignore` rules; dependency
files may consume the scan limit. Its own ignore files still apply. This
slice does not promise repository-root ignore semantics or change core.
An outward symlink listed by a broad glob currently refuses the whole result;
this conservative containment behavior also remains unchanged.
slice does not promise repository-root ignore semantics. An outward symlink
that a broad glob merely lists (a venv's `bin/python`) stays visible, because
entries are judged by their parent's realpath.
## Bounds
@@ -80,7 +92,10 @@ existing omitted path still applies.
- CLI Harness: profile acceptance and pinning, declaration, pre-acquisition
argument validation, bounded truncation.
- CLI worker: admission, containment, Workspace-relative output.
- Core: `GlobTool` is reused unchanged; no core edit.
- Core: `GlobTool` gains an opt-in `containmentRoot` constructor option; the
ordinary CLI does not set it and keeps external globs behind permission.
- Workspace recovery: W1 recovery accepts the `/2` profiles through the same
shared profile predicates as creation and load.
- Java: unchanged. The production connector still pins
`hosted-workspace-files/1`; enabling `/2` for public Sessions is a separate
deployment decision.
@@ -47,13 +47,22 @@ worker 准入 `GlobTool` 并将其构建进 managed 工具集。worker 侧维持
- 搜索被钉在 Session 已安装上下文的目录内。省略 `path` 时解析到该目录
(绝不使用跨 Session 共享挂载点的 workspace 级 include 列表),其他取值
必须解析到其内部;否则以模型可纠正的工具错误结算。
- 遍历本身受范围约束:managed `GlobTool` 以 Session 目录作为
`containmentRoot` 构建,glob 的遍历钩子会剪掉词法路径或父目录 realpath
离开该目录的每个条目。任何 pattern 写法(`..`、`[.][.]`、`\.\.`、花括号
备选、软链接目录)都无法遍历、报告或计数外部内容,因此外部路径存在与否
得到完全相同的回答。
- pattern 在被展开之前先设上界。brace-expansion 没有输出上限,glob 还会
再次展开同一个 pattern,因此 Harness(获取前)与 worker 都会拒绝超过
1024 字符、花括号不配对或按结构估算超过 64 个花括号备选的 pattern;通过
后才展开,并作为快速路径检查各备选是否为绝对路径或含 `..` 段。
- 结果在到达网络、模型或持久记录之前改写为 Workspace 相对路径。Runtime
宿主的物理目录布局不得泄露给 Harness;对搜索工具而言路径本身就是结果。
Core 的忽略规则以 Session 目录为根。位于仓库子目录的 Session 不继承祖先
目录的 `.gitignore`,依赖文件可能占满扫描上限;Session 自己的忽略文件仍
生效。本切片不承诺仓库根目录的忽略语义,也不修改 core。宽泛 glob 列出
外指软链接时,目前会拒绝整个结果;本轮保留这一保守的范围约束行为。
生效。本切片不承诺仓库根目录的忽略语义。宽泛 glob 仅列出的外指软链接(如 venv
的 `bin/python`)仍然可见,因为条目按其父目录的 realpath 判定。
## 上限
@@ -67,7 +76,9 @@ pattern or path.`),而不是把整个结果落入「输出被省略」路径
- CLI Harness:profile 接受与固定、声明、获取前的参数校验、有界截断。
- CLI worker:准入、范围约束、Workspace 相对输出。
- Core:原样复用 `GlobTool`,不改 core。
- Core:`GlobTool` 新增可选的 `containmentRoot` 构造选项;普通 CLI 不设置
它,外部 glob 仍需权限确认。
- Workspace 恢复:W1 恢复通过与创建、加载相同的共享 profile 判断接受 `/2`。
- Java:不变。生产 connector 仍固定 `hosted-workspace-files/1`;是否为公开
Session 启用 `/2` 是单独的部署决定。
@@ -0,0 +1,72 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { braceExpand } from 'minimatch';
import { describe, expect, it } from 'vitest';
import {
HOSTED_GLOB_MAX_ALTERNATIVES,
checkHostedGlobPattern,
} from './hosted-glob-pattern.js';
describe('checkHostedGlobPattern', () => {
it.each([
'src/*.{ts,tsx}',
'**/*.{ts,tsx,js,jsx}',
'{a,b}{c,d}{e,f}',
'{1..64}',
'{a..z}',
'x{a,{b,c{d,e}}}y',
'a/..b/*.ts',
'[.][.]/**/*',
'\\{a,b\\}',
])('admits %s', (pattern) => {
expect(checkHostedGlobPattern(pattern)).toBe('ok');
});
it.each(['/etc/host*', '../**/*', '{/etc,/zz}/host*', '{.,..}/**/*'])(
'refuses the escaping %s',
(pattern) => {
expect(checkHostedGlobPattern(pattern)).toBe('escapes');
},
);
it.each([
['a nesting bomb', '{a,'.repeat(3400) + 'x' + '}'.repeat(3400)],
['a range bomb', '{1..100000}/passwd'],
['a product bomb', '{a,b}'.repeat(30)],
['one alternative too many', '{1..65}'],
['an unbalanced brace', '{a,b'],
['a stray closing brace', '{x},b}'],
['a sequence assembled inside a group', '{1..{,9999}}'],
['an overlong pattern', 'x'.repeat(1025)],
])('refuses %s before expanding it', (_name, pattern) => {
const started = Date.now();
expect(checkHostedGlobPattern(pattern)).toBe('too-complex');
expect(Date.now() - started).toBeLessThan(1000);
});
it('never admits a pattern that expands past the bound', () => {
// Seeded, so a failure reproduces: any admitted pattern must expand to
// at most the bound, or glob would search more than was checked.
let seed = 0x2f6e2b1;
const random = () => {
seed = (seed * 1103515245 + 12345) % 2 ** 31;
return seed / 2 ** 31;
};
const tokens = ['{', '}', ',', 'a', 'b', '1', '..', '\\', '$', '/', '3'];
for (let sample = 0; sample < 20000; sample++) {
let pattern = '';
const length = 1 + Math.floor(random() * 16);
for (let index = 0; index < length; index++)
pattern += tokens[Math.floor(random() * tokens.length)];
if (checkHostedGlobPattern(pattern) === 'too-complex') continue;
expect(
braceExpand(pattern).length,
JSON.stringify(pattern),
).toBeLessThanOrEqual(HOSTED_GLOB_MAX_ALTERNATIVES);
}
});
});
@@ -0,0 +1,119 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import path from 'node:path';
import { braceExpand } from 'minimatch';
/** The longest Hosted glob pattern, matching the `path` argument's cap. */
export const HOSTED_GLOB_PATTERN_MAX_LENGTH = 1024;
/** glob searches every brace alternative, so their number is bounded. */
export const HOSTED_GLOB_MAX_ALTERNATIVES = 64;
// brace-expansion recurses once per nesting level and its output caps do not
// bound the depth, so a deeply nested pattern overflows the stack.
const MAX_BRACE_DEPTH = 8;
const SEQUENCE =
/^(?:(-?\d+)\.\.(-?\d+)|([a-zA-Z])\.\.([a-zA-Z]))(?:\.\.(-?\d+))?$/;
export const HOSTED_GLOB_TOO_COMPLEX = `Hosted glob patterns are limited to ${HOSTED_GLOB_PATTERN_MAX_LENGTH} characters, balanced braces and ${HOSTED_GLOB_MAX_ALTERNATIVES} brace alternatives. Split the search into simpler patterns and retry.`;
export type HostedGlobPatternCheck = 'ok' | 'escapes' | 'too-complex';
/**
* Classifies a model-supplied glob pattern before anything expands it.
* brace-expansion expands without an output cap here (minimatch passes no
* `max`), and glob expands the same pattern again, so the size is bounded
* from the pattern's structure first; only a pattern proven small is
* expanded, and its alternatives must stay relative and free of `..`
* segments. The walk itself is contained too (GlobTool's
* `containmentRoot`); this check is the cheap, model-correctable fast path.
*/
export function checkHostedGlobPattern(
pattern: string,
): HostedGlobPatternCheck {
if (
pattern.length > HOSTED_GLOB_PATTERN_MAX_LENGTH ||
alternativesBound(pattern) > HOSTED_GLOB_MAX_ALTERNATIVES
)
return 'too-complex';
// Segment equality, so a literal `a/..b/*.ts` stays usable.
return braceExpand(pattern).some(
(alternative) =>
path.isAbsolute(alternative) || alternative.split(/[\\/]/).includes('..'),
)
? 'escapes'
: 'ok';
}
/**
* An upper bound on the alternatives brace-expansion yields for `text`,
* without expanding it: a comma group sums its items, a `{x..y[..step]}`
* sequence counts its span, adjacent groups multiply. Anything it cannot
* bound (unbalanced braces, nesting inside a single-item group, excessive
* nesting) is unbounded.
*/
function alternativesBound(text: string): number {
let index = 0;
const limit = HOSTED_GLOB_MAX_ALTERNATIVES + 1;
// A run of literals and groups, up to an unmatched `,` or `}` at `depth`.
const sequence = (depth: number): number => {
let product = 1;
while (index < text.length) {
const char = text[index];
if (char === '\\') {
index += 2;
} else if (char === '{') {
if (depth >= MAX_BRACE_DEPTH) return Infinity;
index++;
product = Math.min(product * group(depth + 1), limit);
} else if ((char === ',' || char === '}') && depth > 0) {
return product;
} else if (char === '}') {
// brace-expansion re-reads a literal group before a stray `}` as a
// comma group (`{x},y}`), so an unmatched `}` cannot be bounded.
return Infinity;
} else {
index++;
}
}
return depth > 0 ? Infinity : product;
};
// The body of one `{...}`, consuming its closing brace.
const group = (depth: number): number => {
const start = index;
const items: number[] = [sequence(depth)];
while (text[index] === ',') {
index++;
items.push(sequence(depth));
}
if (text[index] !== '}') return Infinity;
const body = text.slice(start, index);
index++;
if (items.length > 1)
return Math.min(
items.reduce((sum, item) => sum + item, 0),
limit,
);
// A single-item group re-braces each inner alternative, which can
// assemble a new sequence (`{1..{,9999}}`), so nesting inside one is
// not bounded.
if (items[0] > 1 || body.includes('{')) return Infinity;
const range = SEQUENCE.exec(body);
if (!range) return 1;
const step = Math.max(Math.abs(Number(range[5] ?? 1)), 1);
const span =
range[1] !== undefined
? Math.abs(Number(range[2]) - Number(range[1]))
: Math.abs(range[4].charCodeAt(0) - range[3].charCodeAt(0));
return Math.min(Math.floor(span / step) + 1, limit);
};
return sequence(0);
}
@@ -0,0 +1,45 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
// The saved Hosted Workspace tool profiles. Creation, load and Workspace
// recovery all read this one vocabulary, so a new profile cannot be
// persisted by one and refused by another.
export const HOSTED_WORKSPACE_FILE_PROFILE = 'hosted-workspace-files/1';
export const HOSTED_WORKSPACE_SHELL_PROFILE = 'hosted-workspace-shell/1';
export const HOSTED_WORKSPACE_FILE_PROFILE_V2 = 'hosted-workspace-files/2';
export const HOSTED_WORKSPACE_SHELL_PROFILE_V2 = 'hosted-workspace-shell/2';
export type HostedWorkspaceToolProfile =
| typeof HOSTED_WORKSPACE_FILE_PROFILE
| typeof HOSTED_WORKSPACE_SHELL_PROFILE
| typeof HOSTED_WORKSPACE_FILE_PROFILE_V2
| typeof HOSTED_WORKSPACE_SHELL_PROFILE_V2;
export function isHostedWorkspaceProfile(
profile: unknown,
): profile is HostedWorkspaceToolProfile {
return (
profile === HOSTED_WORKSPACE_FILE_PROFILE ||
profile === HOSTED_WORKSPACE_SHELL_PROFILE ||
profile === HOSTED_WORKSPACE_FILE_PROFILE_V2 ||
profile === HOSTED_WORKSPACE_SHELL_PROFILE_V2
);
}
export function isHostedWorkspaceShellProfile(profile: unknown): boolean {
return (
profile === HOSTED_WORKSPACE_SHELL_PROFILE ||
profile === HOSTED_WORKSPACE_SHELL_PROFILE_V2
);
}
export function isHostedWorkspaceSearchProfile(profile: unknown): boolean {
return (
profile === HOSTED_WORKSPACE_FILE_PROFILE_V2 ||
profile === HOSTED_WORKSPACE_SHELL_PROFILE_V2
);
}
@@ -989,6 +989,13 @@ it.each([
{ pattern: '{/etc,/zz-nonexistent}/host*' },
{ pattern: '{.,..}/**/*' },
{ pattern: 'src/{..,x}/**' },
// Bounded before anything expands it: nesting deep enough to overflow
// brace-expansion's recursion, a range glob would search 100000 times,
// and an alternative product that exhausts memory.
{ pattern: '{a,'.repeat(3400) + 'x' + '}'.repeat(3400) },
{ pattern: '{1..100000}/passwd' },
{ pattern: '{a,b}'.repeat(30) },
{ pattern: 'x'.repeat(1025) },
{ pattern: '**/*.ts', path: '/private/secret-host-path' },
{ pattern: '**/*.ts', path: '../escape' },
{ pattern: '**/*.ts', path: 'a\\b' },
@@ -14,7 +14,20 @@ import {
import { createHash, randomBytes, randomUUID } from 'node:crypto';
import { isDeepStrictEqual } from 'node:util';
import path from 'node:path';
import { braceExpand } from 'minimatch';
import {
checkHostedGlobPattern,
HOSTED_GLOB_TOO_COMPLEX,
} from './hosted-glob-pattern.js';
import {
HOSTED_WORKSPACE_FILE_PROFILE,
HOSTED_WORKSPACE_SHELL_PROFILE,
HOSTED_WORKSPACE_FILE_PROFILE_V2,
HOSTED_WORKSPACE_SHELL_PROFILE_V2,
isHostedWorkspaceProfile,
isHostedWorkspaceShellProfile,
isHostedWorkspaceSearchProfile,
type HostedWorkspaceToolProfile,
} from './hosted-workspace-profiles.js';
import type { FunctionDeclaration, Part } from '@google/genai';
import type { ToolCallRequestInfo } from '@qwen-code/qwen-code-core/core/turn.js';
import type { DurableToolResultResourceStore } from '@qwen-code/qwen-code-core/managed-runtime/resource-tool-result-store.js';
@@ -83,41 +96,17 @@ import {
type HostedApprovalWaiters,
} from './hosted-tool-approval.js';
export const HOSTED_WORKSPACE_FILE_PROFILE = 'hosted-workspace-files/1';
export const HOSTED_WORKSPACE_SHELL_PROFILE = 'hosted-workspace-shell/1';
export const HOSTED_WORKSPACE_FILE_PROFILE_V2 = 'hosted-workspace-files/2';
export const HOSTED_WORKSPACE_SHELL_PROFILE_V2 = 'hosted-workspace-shell/2';
export {
HOSTED_WORKSPACE_FILE_PROFILE,
HOSTED_WORKSPACE_SHELL_PROFILE,
HOSTED_WORKSPACE_FILE_PROFILE_V2,
HOSTED_WORKSPACE_SHELL_PROFILE_V2,
isHostedWorkspaceProfile,
isHostedWorkspaceShellProfile,
isHostedWorkspaceSearchProfile,
type HostedWorkspaceToolProfile,
};
export type HostedWorkspaceToolProfile =
| typeof HOSTED_WORKSPACE_FILE_PROFILE
| typeof HOSTED_WORKSPACE_SHELL_PROFILE
| typeof HOSTED_WORKSPACE_FILE_PROFILE_V2
| typeof HOSTED_WORKSPACE_SHELL_PROFILE_V2;
export function isHostedWorkspaceProfile(
profile: unknown,
): profile is HostedWorkspaceToolProfile {
return (
profile === HOSTED_WORKSPACE_FILE_PROFILE ||
profile === HOSTED_WORKSPACE_SHELL_PROFILE ||
profile === HOSTED_WORKSPACE_FILE_PROFILE_V2 ||
profile === HOSTED_WORKSPACE_SHELL_PROFILE_V2
);
}
export function isHostedWorkspaceShellProfile(profile: unknown): boolean {
return (
profile === HOSTED_WORKSPACE_SHELL_PROFILE ||
profile === HOSTED_WORKSPACE_SHELL_PROFILE_V2
);
}
export function isHostedWorkspaceSearchProfile(profile: unknown): boolean {
return (
profile === HOSTED_WORKSPACE_FILE_PROFILE_V2 ||
profile === HOSTED_WORKSPACE_SHELL_PROFILE_V2
);
}
export function isRetryableWorkspaceAcquisition(
cause: unknown,
): cause is HostedWorkspaceBrokerRejection & {
@@ -917,25 +906,20 @@ export class HostedWorkspaceToolTurn {
'Hosted glob requires a nonempty pattern, and its optional path must be relative to the saved Session working directory. Absolute paths and ".." traversal are not allowed. Correct the arguments and retry.';
const pattern = call.args['pattern'];
const directory = call.args['path'];
if (
typeof pattern !== 'string' ||
!pattern.trim() ||
// A pattern is a second search root: refuse the absolute/`..`
// shapes here, pre-acquisition, with the identical segment
// equality rule the worker applies after dispatch — a refusal
// before acquisition stays model-correctable and costs no
// durable Runtime work (#13030). The rule runs on the
// brace-expanded alternatives: `{/etc,/zz}/host*` composes an
// absolute search root the literal check cannot see, while
// `*.{ts,tsx}` expands to ordinary input.
braceExpand(pattern).some(
(alternative) =>
path.isAbsolute(alternative) ||
alternative.split(/[\\/]/).includes('..'),
)
) {
// A pattern is a second search root: refuse the absolute/`..` and
// oversized shapes here, pre-acquisition, with the identical check
// the worker applies after dispatch — a refusal before acquisition
// stays model-correctable and costs no durable Runtime work
// (#13030).
const check =
typeof pattern === 'string' && pattern.trim()
? checkHostedGlobPattern(pattern.trim())
: 'escapes';
if (check === 'too-complex') {
validationError = HOSTED_GLOB_TOO_COMPLEX;
} else if (check === 'escapes') {
validationError = globError;
} else {
} else if (typeof pattern === 'string') {
// Dispatch what was validated: glob treats an untrimmed pattern as a
// literal, so it matches nothing and the false negative is persisted.
input['pattern'] = pattern.trim();
@@ -1174,13 +1174,11 @@ describe('Managed context tool gate', () => {
const missing = await glob('call-3', { pattern: '**/*', path: 'nope' });
// A link inside the Session context that points at a sibling Session.
const linked = await glob('call-4', { pattern: '*', path: 'peek' });
// Brace expansion happens after the input-side segment check, so the
// `..` alternative never appears as a literal segment; containment is
// enforced on glob's output instead.
// The `..` alternative of a brace pair is refused at the input gate.
const braced = await glob('call-5', { pattern: '{.,..}/**/*' });
// A symlink named as a literal pattern segment is resolved by the
// filesystem: `follow: false` governs links met during a globstar walk,
// not this one.
// filesystem (`follow: false` governs links met during a globstar walk,
// not this one), so the walk prunes it: nothing beyond it is searched.
const literalLink = await glob('call-6', { pattern: 'peek/**/*' });
// An outward link is reported under its own in-Session name, disclosing
// nothing: merely listing it must not fail the whole glob.
@@ -1192,13 +1190,36 @@ describe('Managed context tool gate', () => {
const bracedAbsolute = await glob('call-8', {
pattern: '{/etc,/zz-nonexistent}/host*',
});
// Character-class and escape spellings of `..` pass any string gate;
// the contained walk answers an existing and a missing outside file
// identically, so neither is an existence oracle.
const classExisting = await glob('call-9', {
pattern: '[.][.]/web/secret.txt',
});
const classMissing = await glob('call-10', {
pattern: '[.][.]/web/nope.txt',
});
const escapedWalk = await glob('call-11', { pattern: '\\.\\./**/*' });
// Range expansion is bounded before anything searches it.
const rangeBomb = await glob('call-12', { pattern: '{1..100000}/passwd' });
expect(dotdot.result.executionStatus).toBe('error');
expect(absolute.result.executionStatus).toBe('error');
expect(missing.result.executionStatus).toBe('error');
expect(linked.result.executionStatus).toBe('error');
expect(braced.result.executionStatus).toBe('error');
expect(literalLink.result.executionStatus).toBe('error');
expect(literalLink.result.executionStatus).toBe('success');
expect(JSON.stringify(literalLink)).toContain('No files found');
// The two answers differ only by the pattern each one echoes back.
expect(classExisting.result.executionStatus).toBe('success');
expect(JSON.stringify(classExisting)).toContain('No files found');
expect(
JSON.stringify(classExisting.result).replaceAll('secret.txt', 'nope.txt'),
).toBe(JSON.stringify(classMissing.result));
expect(escapedWalk.result.executionStatus).toBe('success');
expect(JSON.stringify(escapedWalk)).toContain('src/index.ts');
expect(rangeBomb.result.executionStatus).toBe('error');
expect(JSON.stringify(rangeBomb)).toContain('64 brace alternatives');
for (const response of [
dotdot,
absolute,
@@ -1206,6 +1227,9 @@ describe('Managed context tool gate', () => {
linked,
braced,
literalLink,
classMissing,
escapedWalk,
rangeBomb,
]) {
const text = JSON.stringify(response);
expect(text).not.toContain('secret.txt');
@@ -1221,6 +1245,38 @@ describe('Managed context tool gate', () => {
expect(JSON.stringify(bracedAbsolute)).not.toContain('host');
});
it('never counts an outside match the display sample would not show', async () => {
// glob judges up to 1000 collected entries but displays only the newest
// 100; an outside match must not reach the count either.
const root = workspace(['services/api/src', 'services/web']);
for (let index = 0; index < 137; index++)
fs.writeFileSync(path.join(root, `services/api/src/f${index}.ts`), '');
const sibling = path.join(root, 'services/web/old.ts');
fs.writeFileSync(sibling, 'sibling');
const past = new Date(Date.now() - 10 * 24 * 60 * 60 * 1000);
fs.utimesSync(sibling, past, past);
fs.symlinkSync(
path.join('..', 'web'),
path.join(root, 'services/api/peek'),
);
const origin = await startWorker({ ...BOOT, mountRoot: root });
await post(origin, CONTEXT, installation('session-1', 'services/api'));
await post(origin, CONTEXT, installation('session-2', 'services/web'));
const response = await (
await post(origin, EXECUTE, {
...shell('session-1', 'call-1', ''),
toolName: 'glob',
input: { pattern: '{**/*.ts,peek/**/*}' },
})
).json();
expect(response.result.executionStatus).toBe('success');
const text = JSON.stringify(response);
expect(text).toContain('Found 137 file(s)');
expect(text).not.toContain('old.ts');
});
it('refuses read_file through an in-context symlink that leaves the Session', async () => {
// The glob admission makes the link enumerable; reading through it must
// not hand a sibling Session's content to a files-only Session.
@@ -8,7 +8,10 @@ import path from 'node:path';
import { promises as fs } from 'node:fs';
import { realpath } from 'node:fs/promises';
import { isDeepStrictEqual } from 'node:util';
import { braceExpand } from 'minimatch';
import {
checkHostedGlobPattern,
HOSTED_GLOB_TOO_COMPLEX,
} from './hosted-glob-pattern.js';
import { ManagedRuntimeFileHistory } from './managed-runtime-file-history.js';
import type { RawFileHistoryOperation } from './hosted-file-history-protocol.js';
import {
@@ -889,22 +892,15 @@ export class ManagedToolExecutor {
);
const requested =
typeof params['path'] === 'string' ? params['path'].trim() : '';
// `pattern` is a second search root: glob resolves `..` segments and
// treats an absolute pattern as absolute, so it is contained too, by
// segment so a literal `a/..b/*.ts` stays usable. The rule runs on
// the brace-expanded alternatives: `{/etc,/zz}/host*` composes an
// absolute search root the literal check cannot see, while
// `*.{ts,tsx}` expands to ordinary input. A braceless pattern
// expands to itself, so this is also the fast path.
// `pattern` is a second search root, and glob searches every brace
// alternative: the same check as the harness refuses absolute/`..`
// shapes and patterns too large to search before anything expands
// them. The walk is contained regardless (`containmentRoot`).
const pattern =
typeof params['pattern'] === 'string' ? params['pattern'] : '';
if (
braceExpand(pattern).some(
(alternative) =>
path.isAbsolute(alternative) ||
alternative.split(/[\\/]/).includes('..'),
)
) {
const check = checkHostedGlobPattern(pattern);
if (check === 'too-complex') throw new Error(HOSTED_GLOB_TOO_COMPLEX);
if (check === 'escapes') {
throw new Error(
'Glob pattern must stay within the Session working directory.',
);
@@ -1201,7 +1197,8 @@ export function createManagedToolSet(
new ReadFileTool(config),
new WriteFileTool(config),
new EditTool(config),
new GlobTool(config),
// The walk never leaves the Session, whatever the pattern spells.
new GlobTool(config, { containmentRoot: directory }),
new ShellTool(config),
].map((tool): [string, AnyDeclarativeTool] => [tool.name, tool]),
),
@@ -39,7 +39,11 @@ const KEY = {
const hash = (bytes: Buffer) =>
createHash('sha256').update(bytes).digest('hex');
function fixture() {
function fixture(
definition: Record<string, unknown> = {
toolProfile: 'hosted-workspace-files/1',
},
) {
const resources = new Map<string, Buffer>();
const refs = new Map<string, ManagedSessionDurableRef>();
const complete = new Set<string>();
@@ -204,7 +208,7 @@ function fixture() {
definitionRef: resource('managed-definition', {
engine: 'managed',
sessionId: SESSION,
toolProfile: 'hosted-workspace-files/1',
...definition,
}),
rootSnapshotRef: resource('managed-root', { cwd: '/original/cwd' }),
createdBy: 'hosted-harness',
@@ -1175,6 +1179,30 @@ describe('verifyRecoverySession', () => {
expect(f.io.publicationReceipt).not.toHaveBeenCalled();
});
it.each([
{ toolProfile: 'hosted-workspace-files/1' },
{ toolProfile: 'hosted-workspace-files/2' },
{ toolProfile: 'hosted-workspace-shell/1', captureBytes: 1024 },
{ toolProfile: 'hosted-workspace-shell/2', captureBytes: 1024 },
])('recovers every saved Workspace profile (%j)', async (definition) => {
// One refused profile would abort recovery for the whole Workspace,
// so recovery reads the same profile vocabulary creation persists.
const f = fixture(definition);
await expect(verifyRecoverySession(f.source, f.io)).resolves.toEqual({
fileHistory: 'not_captured',
});
});
it.each([
{ toolProfile: 'hosted-workspace-files/3' },
{ toolProfile: 'hosted-workspace-files/2', captureBytes: 1024 },
])('still refuses an unknown Hosted definition (%j)', async (definition) => {
const f = fixture(definition);
await expect(verifyRecoverySession(f.source, f.io)).rejects.toThrow(
/unsupported Hosted profile|invalid frozen Hosted definition/u,
);
});
it('rejects changed bytes even when the stored transaction metadata was not changed', async () => {
const f = fixture();
f.transactions[0] = {
@@ -51,6 +51,10 @@ import {
} from './managed-workspace-binding.js';
import { parseHostedFileHistoryRecord } from './hosted-file-history-protocol.js';
import { readHostedApprovalDefinition } from './hosted-tool-approval.js';
import {
isHostedWorkspaceProfile,
isHostedWorkspaceShellProfile,
} from './hosted-workspace-profiles.js';
export interface RecoverySessionSource {
readonly sessionId: string;
@@ -745,15 +749,13 @@ export async function verifyRecoverySession(
definition['sessionId'] === source.sessionId &&
definition['mcpServers'] === undefined &&
(definition['toolProfile'] === undefined ||
['hosted-workspace-files/1', 'hosted-workspace-shell/1'].includes(
definition['toolProfile'] as string,
)),
isHostedWorkspaceProfile(definition['toolProfile'])),
'unsupported Hosted profile',
);
requireValue(
readHostedApprovalDefinition(definition) &&
(definition['captureBytes'] === undefined ||
(definition['toolProfile'] === 'hosted-workspace-shell/1' &&
(isHostedWorkspaceShellProfile(definition['toolProfile']) &&
Number.isSafeInteger(definition['captureBytes']) &&
(definition['captureBytes'] as number) >= 1 &&
(definition['captureBytes'] as number) <= 2 ** 41)),
+60
View File
@@ -739,6 +739,66 @@ describe('GlobTool', () => {
});
});
describe('containmentRoot', () => {
let session: string;
beforeEach(async () => {
await mkdirp('session/src');
await mkdirp('web');
await put('session/src/index.ts');
await put('web/secret.txt', 'sibling');
await fs.symlink(
path.join('..', 'web'),
path.join(tempRootDir, 'session/peek'),
);
session = path.join(tempRootDir, 'session');
});
const contained = () =>
new GlobTool(mockConfig, { containmentRoot: session });
it.each([
'[.][.]/web/secret.txt',
'\\.\\./web/*',
'{.,..}/**/*',
'peek/**/*',
'{**/*.ts,peek/*}',
])('never walks or reports outside the root: %s', async (pattern) => {
const result = await run({ pattern, path: session }, contained());
expect(String(result.llmContent)).not.toContain('secret.txt');
});
it('answers an existing and a missing outside file alike', async () => {
const existing = await run(
{ pattern: '[.][.]/web/secret.txt', path: session },
contained(),
);
const missing = await run(
{ pattern: '[.][.]/web/nope.txt', path: session },
contained(),
);
expect(existing.returnDisplay).toBe('No files found');
expect(missing.returnDisplay).toBe('No files found');
});
it('still finds and lists what is inside the root', async () => {
const result = await run({ pattern: '**/*', path: session }, contained());
expect(result.llmContent).toContain(
path.join(session, 'src', 'index.ts'),
);
// A merely listed outward symlink keeps its in-root name.
expect(result.llmContent).toContain(path.join(session, 'peek'));
});
it('leaves the ordinary tool able to search outside', async () => {
const result = await run({
pattern: '[.][.]/web/secret.txt',
path: session,
});
expect(result.llmContent).toContain('secret.txt');
});
});
describe('getDescription', () => {
const descriptionOf = (params: GlobToolParams) =>
globTool.build(params).getDescription();
+61 -4
View File
@@ -90,6 +90,16 @@ export interface GlobToolParams {
path?: string;
}
export interface GlobToolOptions {
/**
* A directory the search never leaves: the walk neither enters
* nor reports anything whose real location is outside it, whatever the
* pattern's grammar spells. Managed Sessions set it; the ordinary CLI
* keeps external globs, which ask for permission instead.
*/
containmentRoot?: string;
}
class GlobToolInvocation extends BaseToolInvocation<
GlobToolParams,
ToolResult
@@ -99,6 +109,7 @@ class GlobToolInvocation extends BaseToolInvocation<
constructor(
private config: Config,
params: GlobToolParams,
private readonly containmentRoot?: string,
) {
super(params);
this.fileService = config.getFileService();
@@ -194,6 +205,43 @@ class GlobToolInvocation extends BaseToolInvocation<
}
};
// Containment is judged per walked entry, not on the pattern: `..`,
// `[.][.]`, `\.\.` and brace alternatives all resolve to an entry whose
// lexical path leaves the root, and a file reached through a symlinked
// directory has a parent whose realpath does. Pruning both keeps an
// outside entry from being walked, reported or counted.
const root = this.containmentRoot;
const realpaths = new Map<string, string | null>();
const realpathOf = (target: string): string | null => {
let real = realpaths.get(target);
if (real === undefined) {
try {
real = fs.realpathSync(target);
} catch {
real = null;
}
realpaths.set(target, real);
}
return real;
};
const escapesRoot = (full: string, self: boolean): boolean => {
if (root === undefined) return false;
if (!isPathWithinRoot(full, root)) return true;
const realRoot = realpathOf(root) ?? root;
// Judge a listed entry by its parent's realpath, so a merely listed
// outward symlink (a venv's `bin/python`) stays visible; judge a
// directory about to be entered by its own.
const real = self
? realpathOf(full)
: (() => {
const parent = realpathOf(path.dirname(full));
return parent === null
? null
: path.join(parent, path.basename(full));
})();
return real === null || !isPathWithinRoot(real, realRoot);
};
const isAllowedByFileFilters = (entry: GlobPath): boolean => {
const relativePath = path.relative(projectRoot, entry.fullpath());
return (
@@ -212,8 +260,10 @@ class GlobToolInvocation extends BaseToolInvocation<
follow: false,
signal,
ignore: {
ignored: isTraversalIgnored,
childrenIgnored: isTraversalIgnored,
ignored: (entry) =>
escapesRoot(entry.fullpath(), false) || isTraversalIgnored(entry),
childrenIgnored: (entry) =>
escapesRoot(entry.fullpath(), true) || isTraversalIgnored(entry),
},
}) as AsyncIterable<GlobPath> & { destroy?: () => void };
@@ -387,7 +437,10 @@ class GlobToolInvocation extends BaseToolInvocation<
export class GlobTool extends BaseDeclarativeTool<GlobToolParams, ToolResult> {
static readonly Name = ToolNames.GLOB;
constructor(private config: Config) {
constructor(
private config: Config,
private readonly options: GlobToolOptions = {},
) {
super(
GlobTool.Name,
ToolDisplayNames.GLOB,
@@ -443,6 +496,10 @@ export class GlobTool extends BaseDeclarativeTool<GlobToolParams, ToolResult> {
protected createInvocation(
params: GlobToolParams,
): ToolInvocation<GlobToolParams, ToolResult> {
return new GlobToolInvocation(this.config, params);
return new GlobToolInvocation(
this.config,
params,
this.options.containmentRoot,
);
}
}