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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions esbuild.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,7 @@ const external = [
'@teddyzhu/clipboard-linux-arm64-gnu',
'@teddyzhu/clipboard-win32-x64-msvc',
'@teddyzhu/clipboard-win32-arm64-msvc',
'sharp',
];

// Name of the directory under `dist/` that esbuild emits shared chunks into.
Expand Down
1 change: 1 addition & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions packages/cli/src/i18n/locales/en.js
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,7 @@ export default {
'toolDisplayName.Edit': 'toolDisplayName.Edit',
'toolDisplayName.WriteFile': 'toolDisplayName.WriteFile',
'toolDisplayName.ReadFile': 'toolDisplayName.ReadFile',
'toolDisplayName.ZoomImage': 'toolDisplayName.ZoomImage',
'toolDisplayName.Grep': 'toolDisplayName.Grep',
'toolDisplayName.Glob': 'toolDisplayName.Glob',
'toolDisplayName.Shell': 'toolDisplayName.Shell',
Expand Down
1 change: 1 addition & 0 deletions packages/cli/src/i18n/locales/zh-TW.js
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,7 @@ export default {
'toolDisplayName.Edit': '編輯',
'toolDisplayName.WriteFile': '寫入檔案',
'toolDisplayName.ReadFile': '讀取檔案',
'toolDisplayName.ZoomImage': '縮放圖像',
'toolDisplayName.Grep': 'Grep',
'toolDisplayName.Glob': 'Glob',
'toolDisplayName.Shell': '運行命令',
Expand Down
1 change: 1 addition & 0 deletions packages/cli/src/i18n/locales/zh.js
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,7 @@ export default {
'toolDisplayName.Edit': '编辑',
'toolDisplayName.WriteFile': '写入文件',
'toolDisplayName.ReadFile': '读取文件',
'toolDisplayName.ZoomImage': '缩放图像',
'toolDisplayName.Grep': 'Grep',
'toolDisplayName.Glob': 'Glob',
'toolDisplayName.Shell': '运行命令',
Expand Down
1 change: 1 addition & 0 deletions packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@
"picomatch": "^4.0.1",
"prompts": "^2.4.2",
"proper-lockfile": "^4.1.2",
"sharp": "^0.34.5",
Comment thread
qwen-code-dev-bot marked this conversation as resolved.
"shell-quote": "^1.9.0",
Comment thread
qwen-code-dev-bot marked this conversation as resolved.
"simple-git": "^3.36.0",
"strip-ansi": "^7.1.0",
Expand Down
19 changes: 19 additions & 0 deletions packages/core/src/config/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5644,6 +5644,25 @@ describe('Server Config (config.ts)', () => {
});

describe('createToolRegistry', () => {
it('registers zoom_image unconditionally so it survives model switches', async () => {
const config = new Config(baseParams);
// A first-run / text-only session reports no image modality, yet the tool
// must still register: the gate moved to execute time so a hot /model
// switch to an image model picks it up without re-running initialize().
vi.spyOn(config, 'getEffectiveInputModalities').mockReturnValue({});

await config.initialize();

const registerToolMock = (
(await vi.importMock('../tools/tool-registry')) as {
ToolRegistry: { prototype: { registerFactory: Mock } };
}
).ToolRegistry.prototype.registerFactory;
expect(
(registerToolMock as Mock).mock.calls.map((call) => call[0]),
).toContain(ToolNames.ZOOM_IMAGE);
});

it('should ignore coreTools overrides in bare mode', async () => {
const config = new Config({
...baseParams,
Expand Down
4 changes: 4 additions & 0 deletions packages/core/src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7154,6 +7154,10 @@ export class Config {
const { ReadFileTool } = await import('../tools/read-file.js');
return new ReadFileTool(this);
});
await registerLazy(ToolNames.ZOOM_IMAGE, async () => {
const { ZoomImageTool } = await import('../tools/zoom-image.js');
return new ZoomImageTool(this);
});

// --- Grep / RipGrep (conditional) ---
if (this.getUseRipgrep()) {
Expand Down
12 changes: 12 additions & 0 deletions packages/core/src/core/coreToolScheduler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14519,6 +14519,18 @@ describe('extractToolFilePaths', () => {
]);
});

it('extracts the source path from zoom_image', () => {
expect(
extractToolFilePaths(ToolNames.ZOOM_IMAGE, {
file_path: '/proj/chart.png',
x1: 0,
y1: 0,
x2: 500,
y2: 500,
}),
).toEqual(['/proj/chart.png']);
});

it('extracts notebook_path for notebook_edit', () => {
expect(
extractToolFilePaths('notebook_edit', {
Expand Down
4 changes: 3 additions & 1 deletion packages/core/src/core/coreToolScheduler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -492,6 +492,7 @@ export type CompletedToolCall =
*/
const FS_PATH_TOOL_NAMES: ReadonlySet<string> = new Set<string>([
ToolNames.READ_FILE,
ToolNames.ZOOM_IMAGE,
ToolNames.EDIT,
ToolNames.WRITE_FILE,
ToolNames.GREP,
Expand Down Expand Up @@ -574,7 +575,7 @@ function pushLspPathCandidate(out: string[], v: unknown): void {
* Pull the filesystem path-bearing fields out of a tool's input.
* Per-tool dispatcher because the field name and shape differ:
*
* - read_file / edit / write_file → `file_path`
* - read_file / zoom_image / edit / write_file → `file_path`
* - notebook_edit → `notebook_path`
* - list_directory → `path` (search root)
* - glob → `path` (search root, optional) + `pattern` (path-shaped
Expand Down Expand Up @@ -693,6 +694,7 @@ export function extractToolFilePaths(
return out;

case ToolNames.READ_FILE:
case ToolNames.ZOOM_IMAGE:
case ToolNames.EDIT:
case ToolNames.WRITE_FILE:
push(obj['file_path']);
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/permissions/autoMode.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ describe('SAFE_TOOL_ALLOWLIST', () => {
it('includes the canonical read-only / metadata tools', () => {
const expected = [
ToolNames.READ_FILE,
ToolNames.ZOOM_IMAGE,
ToolNames.GREP,
ToolNames.GLOB,
ToolNames.LS,
Expand Down Expand Up @@ -97,6 +98,7 @@ describe('SAFE_TOOL_ALLOWLIST', () => {
"task_stop",
"todo_write",
"tool_search",
"zoom_image",
]
`);
});
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/permissions/autoMode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ const RAW_PROTECTED_WRITE_COMMANDS =
export const SAFE_TOOL_ALLOWLIST: ReadonlySet<string> = new Set<string>([
// Read-only file / search
ToolNames.READ_FILE,
ToolNames.ZOOM_IMAGE,
ToolNames.GREP,
ToolNames.GLOB,
ToolNames.LS,
Expand Down
21 changes: 20 additions & 1 deletion packages/core/src/permissions/permission-manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ describe('getSpecifierKind', () => {

it('returns "path" for file read/edit tools', async () => {
expect(getSpecifierKind('read_file')).toBe('path');
expect(getSpecifierKind('zoom_image')).toBe('path');
expect(getSpecifierKind('edit')).toBe('path');
expect(getSpecifierKind('notebook_edit')).toBe('path');
expect(getSpecifierKind('write_file')).toBe('path');
Expand Down Expand Up @@ -118,7 +119,8 @@ describe('toolMatchesRuleToolName', () => {
expect(toolMatchesRuleToolName('edit', 'edit')).toBe(true);
});

it('"Read" (read_file) covers grep_search, glob, list_directory', async () => {
it('"Read" (read_file) covers all read-only file tools', async () => {
expect(toolMatchesRuleToolName('read_file', 'zoom_image')).toBe(true);
expect(toolMatchesRuleToolName('read_file', 'grep_search')).toBe(true);
expect(toolMatchesRuleToolName('read_file', 'glob')).toBe(true);
expect(toolMatchesRuleToolName('read_file', 'list_directory')).toBe(true);
Expand Down Expand Up @@ -2290,11 +2292,19 @@ describe('PermissionManager', () => {
pm = new PermissionManager(makeConfig({ coreTools: ['read_file'] }));
pm.initialize();
expect(await pm.isToolEnabled('read_file')).toBe(true);
expect(await pm.isToolEnabled('zoom_image')).toBe(false);
expect(await pm.isToolEnabled('run_shell_command')).toBe(false);
expect(await pm.isToolEnabled('edit')).toBe(false);
expect(await pm.isToolEnabled('notebook_edit')).toBe(false);
});

it('coreTools allowlist: ZoomImage alias enables zoom_image', async () => {
pm = new PermissionManager(makeConfig({ coreTools: ['ZoomImage'] }));
pm.initialize();
expect(await pm.isToolEnabled('zoom_image')).toBe(true);
expect(await pm.isToolEnabled('read_file')).toBe(false);
});

it('coreTools allowlist: NotebookEdit alias enables notebook_edit', async () => {
pm = new PermissionManager(makeConfig({ coreTools: ['NotebookEdit'] }));
pm.initialize();
Expand Down Expand Up @@ -2583,6 +2593,7 @@ describe('PermissionManager', () => {
describe('getRuleDisplayName', () => {
it('maps read tools to "Read" meta-category', async () => {
expect(getRuleDisplayName('read_file')).toBe('Read');
expect(getRuleDisplayName('zoom_image')).toBe('Read');
expect(getRuleDisplayName('grep_search')).toBe('Read');
expect(getRuleDisplayName('glob')).toBe('Read');
expect(getRuleDisplayName('list_directory')).toBe('Read');
Expand Down Expand Up @@ -2625,6 +2636,14 @@ describe('buildPermissionRules', () => {
expect(rules).toEqual(['Read(//Users/alice/**)']);
});

it('generates Read rule scoped to parent directory for zoom_image', async () => {
const rules = buildPermissionRules({
toolName: 'zoom_image',
filePath: '/Users/alice/chart.png',
});
expect(rules).toEqual(['Read(//Users/alice/**)']);
});

it('generates Read rule with directory as-is for grep_search', async () => {
const rules = buildPermissionRules({
toolName: 'grep_search',
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/permissions/permission-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -553,6 +553,7 @@ export class PermissionManager {
*/
private static readonly CORE_TOOLS = new Set([
'read_file',
'zoom_image',
'write_file',
'edit',
'notebook_edit',
Expand Down
20 changes: 15 additions & 5 deletions packages/core/src/permissions/rule-parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,11 @@ export const TOOL_NAME_ALIASES: Readonly<Record<string, string>> = {
ReadFileTool: 'read_file',
Read: 'read_file',

// Zoom Image tool — also matched by "Read" meta-category rules
zoom_image: 'zoom_image',
ZoomImage: 'zoom_image',
ZoomImageTool: 'zoom_image',

// Grep tool — also matched by "Read" meta-category rules
grep_search: 'grep_search',
Grep: 'grep_search',
Expand Down Expand Up @@ -171,6 +176,7 @@ export const SHELL_TOOL_NAMES: ReadonlySet<string> = new Set([
*/
const READ_TOOLS = new Set([
'read_file',
'zoom_image',
'grep_search',
'glob',
'list_directory',
Expand Down Expand Up @@ -222,7 +228,8 @@ export function getSpecifierKind(canonicalToolName: string): SpecifierKind {
* Check whether a given tool (by canonical name) is covered by a rule's tool name,
* taking meta-categories into account.
*
* "Read" → resolves to "read_file", but also covers grep_search, glob, list_directory
* "Read" → resolves to "read_file", but also covers zoom_image, grep_search,
* glob, and list_directory
* "Edit" → resolves to "edit", but also covers write_file
* "Bash" → resolves to "run_shell_command", but also covers monitor
* "Monitor" → resolves to "monitor" only; it does not cover shell
Expand Down Expand Up @@ -393,13 +400,14 @@ export function parseRules(raws: string[]): PermissionRule[] {
* permission rule strings.
*
* Read tools all map to "Read" (meta-category) so a single rule covers the
* entire family (read_file, grep_search, glob, list_directory).
* entire family (read_file, zoom_image, grep_search, glob, list_directory).
* Edit tools map to "Edit" (meta-category) covering edit + write_file.
* Other tools use their individual display alias.
*/
const CANONICAL_TO_RULE_DISPLAY: Readonly<Record<string, string>> = {
// Read meta-category
read_file: 'Read',
zoom_image: 'Read',
grep_search: 'Read',
glob: 'Read',
list_directory: 'Read',
Expand Down Expand Up @@ -441,13 +449,14 @@ export function getRuleDisplayName(canonicalToolName: string): string {
*
* For these tools the minimum-scope rule uses `path.dirname()` so the rule
* covers the containing directory rather than a single file — e.g.
* read_file("/Users/alice/.secrets") → `Read(//Users/alice)`
* zoom_image("/Users/alice/chart.png") → `Read(//Users/alice)`
*
* Directory-targeted tools (list_directory, grep_search, glob) already receive
* a directory path, so they use it as-is.
*/
const FILE_TARGETED_TOOLS = new Set([
'read_file',
'zoom_image',
'edit',
'write_file',
'notebook_edit',
Expand All @@ -463,8 +472,9 @@ const FILE_TARGETED_TOOLS = new Set([
*
* Specifier selection by tool category:
* - **path** tools (Read/Edit):
* File-targeted tools (read_file, edit, write_file) use the **parent
* directory** so the rule covers the whole directory, not a single file.
* File-targeted tools (read_file, zoom_image, edit, write_file) use the
* **parent directory** so the rule covers the whole directory, not a
* single file.
* Directory-targeted tools (grep, glob, ls) use the directory as-is.
* The `//` prefix denotes an absolute filesystem path in the rule grammar.
* - **domain** tools (WebFetch): `WebFetch(example.com)`
Expand Down
12 changes: 9 additions & 3 deletions packages/core/src/services/loopDetectionService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1242,8 +1242,8 @@ describe('LoopDetectionService', () => {
primeNonReadTool();

// Mix of read-like tool names that either appear in the exact allowlist
// (read_file, read_many_files, list_directory) or match the read_/list_
// prefix fallback used for MCP-provided tools.
// (read_file, read_many_files, list_directory, zoom_image) or match the
// read_/list_ prefix fallback used for MCP-provided tools.
service.addAndCheck(
createToolCallRequestEvent('read_many_files', {
paths: ['file1.txt'],
Expand All @@ -1256,7 +1256,13 @@ describe('LoopDetectionService', () => {
createToolCallRequestEvent('read_resource', { uri: 'a' }),
);
service.addAndCheck(
createToolCallRequestEvent('read_file', { path: 'file3.txt' }),
createToolCallRequestEvent('zoom_image', {
file_path: 'chart.png',
x1: 0,
y1: 0,
x2: 500,
y2: 500,
}),
);
service.addAndCheck(createToolCallRequestEvent('list_projects', {}));
service.addAndCheck(
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/services/loopDetectionService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -746,6 +746,7 @@ export class LoopDetectionService {
'read_file',
'read_many_files',
'list_directory',
'zoom_image',
]);

// Prefix fallback for MCP-provided tools that follow the same naming
Expand Down
52 changes: 52 additions & 0 deletions packages/core/src/tools/file-read-permission.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/

import path from 'node:path';
import type { Config } from '../config/config.js';
import { Storage } from '../config/storage.js';
import { isAnyAutoMemPath } from '../memory/paths.js';
import type { PermissionDecision } from '../permissions/types.js';
import { isSubpaths } from '../utils/paths.js';

export function getFileReadDefaultPermission(
config: Config,
requestedPath: string,
): PermissionDecision {
const filePath = path.resolve(requestedPath);
const workspaceContext = config.getWorkspaceContext();

// SYNC: Keep these base roots and the auto-memory check below aligned with
// AcpAgent.buildAcpLocalReadRoots' mirrored ReadFileTool group. ACP may
// append fallback-only roots after that group.
const allowedRoots = [
config.storage.getProjectTempDir(),
// Background subagent transcripts live under <projectDir>/subagents/ and
// are advertised to the model as polling targets via read_file.
path.join(config.storage.getProjectDir(), 'subagents'),
Storage.getGlobalTempDir(),
...config.storage.getUserSkillsDirs(),
Storage.getUserExtensionsDir(),
// Approved plans are persisted here (default ~/.qwen/plans, outside
// the workspace) and after approval nothing re-injects the plan text,
// so the saved file is the model's only recovery route — reading it
// back must not stall on a confirmation prompt. The dir holds only
// session plan files, never credentials or settings.
config.getPlansDir(),
];

if (
workspaceContext.isPathWithinWorkspace(filePath) ||
isSubpaths(allowedRoots, filePath) ||
// isAnyAutoMemPath narrows to the managed auto-memory roots
// (per-project + user-level under ~/.qwen/memories/) — never the
// broad getMemoryBaseDir() — to avoid exposing sensitive ~/.qwen
// files such as settings.json or OAuth credentials.
isAnyAutoMemPath(filePath, config.getTargetDir())
) {
return 'allow';
}
return 'ask';
}
Loading
Loading