Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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 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/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",
"simple-git": "^3.36.0",
"strip-ansi": "^7.1.0",
Expand Down
34 changes: 34 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,40 @@ describe('Server Config (config.ts)', () => {
});

describe('createToolRegistry', () => {
it('registers zoom_image for an image-capable primary model', async () => {
const config = new Config(baseParams);
vi.spyOn(config, 'getEffectiveInputModalities').mockReturnValue({
image: true,
});

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('does not register zoom_image for a text-only primary model', async () => {
const config = new Config(baseParams);
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]),
).not.toContain(ToolNames.ZOOM_IMAGE);
});

it('should ignore coreTools overrides in bare mode', async () => {
const config = new Config({
...baseParams,
Expand Down
6 changes: 6 additions & 0 deletions packages/core/src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7154,6 +7154,12 @@ export class Config {
const { ReadFileTool } = await import('../tools/read-file.js');
return new ReadFileTool(this);
});
if (this.getEffectiveInputModalities().image === true) {
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