mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-11-07 23:01:29 -10:00
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:
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)),
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user