From 651da053aa09b3d50c7f6a586c6cb1a728a8084b Mon Sep 17 00:00:00 2001 From: qwen-code-dev-bot Date: Thu, 30 Jul 2026 17:24:13 +0800 Subject: [PATCH 01/17] feat(cli): /summary supports custom export path (#8113) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `/summary` now accepts an optional path argument, matching `/export`'s behavior. When a path is provided, the summary is saved there instead of the default `.qwen/PROJECT_SUMMARY.md`. - `/summary` → saves to `.qwen/PROJECT_SUMMARY.md` (unchanged) - `/summary docs/summary.md` → saves to `docs/summary.md` - `/summary /absolute/path/summary.md` → saves to absolute path - `/summary docs/` → saves to `docs/PROJECT_SUMMARY.md` If the path is a directory (existing or ending with `/`), the default filename `PROJECT_SUMMARY.md` is appended. Parent directories are created automatically. --- .../cli/src/ui/commands/summaryCommand.ts | 60 +++++++++++++++---- 1 file changed, 48 insertions(+), 12 deletions(-) diff --git a/packages/cli/src/ui/commands/summaryCommand.ts b/packages/cli/src/ui/commands/summaryCommand.ts index 5a1b5e67403..a1d0bb0f3c8 100644 --- a/packages/cli/src/ui/commands/summaryCommand.ts +++ b/packages/cli/src/ui/commands/summaryCommand.ts @@ -22,12 +22,13 @@ export const summaryCommand: SlashCommand = { name: 'summary', get description() { return t( - 'Generate a project summary and save it to .qwen/PROJECT_SUMMARY.md', + 'Generate a project summary and save it to .qwen/PROJECT_SUMMARY.md (or a custom path)', ); }, + argumentHint: '[path]', kind: CommandKind.BUILT_IN, supportedModes: ['interactive', 'non_interactive', 'acp'] as const, - action: async (context): Promise => { + action: async (context, args): Promise => { const { config } = context.services; const { ui } = context; const executionMode = context.executionMode ?? 'interactive'; @@ -140,29 +141,64 @@ export const summaryCommand: SlashCommand = { filePathForDisplay: string; fullPath: string; }> => { - // Ensure .qwen directory exists const projectRoot = config.getProjectRoot(); - const qwenDir = path.join(projectRoot, '.qwen'); - try { - await fsPromises.mkdir(qwenDir, { recursive: true }); - } catch (_err) { - // Directory might already exist, ignore error + const customPath = args?.trim(); + + let summaryPath: string; + let filePathForDisplay: string; + + if (customPath) { + // Resolve relative to project root + const resolved = path.isAbsolute(customPath) + ? customPath + : path.resolve(projectRoot, customPath); + + // If the path ends with a separator or is an existing directory, + // append the default filename + const isDir = + resolved.endsWith('/') || + resolved.endsWith('\\') || + (await fsPromises + .stat(resolved) + .then((s) => s.isDirectory()) + .catch(() => false)); + + if (isDir) { + summaryPath = path.join(resolved, 'PROJECT_SUMMARY.md'); + } else { + summaryPath = resolved; + } + + // Ensure parent directory exists + await fsPromises.mkdir(path.dirname(summaryPath), { recursive: true }); + + filePathForDisplay = path.isAbsolute(customPath) + ? summaryPath + : path.relative(projectRoot, summaryPath); + } else { + // Default: save to .qwen/PROJECT_SUMMARY.md + const qwenDir = path.join(projectRoot, '.qwen'); + try { + await fsPromises.mkdir(qwenDir, { recursive: true }); + } catch (_err) { + // Directory might already exist, ignore error + } + summaryPath = path.join(qwenDir, 'PROJECT_SUMMARY.md'); + filePathForDisplay = '.qwen/PROJECT_SUMMARY.md'; } - // Save the summary to PROJECT_SUMMARY.md - const summaryPath = path.join(qwenDir, 'PROJECT_SUMMARY.md'); const summaryContent = `${markdownSummary} --- ## Summary Metadata -**Update time**: ${new Date().toISOString()} +**Update time**: ${new Date().toISOString()} `; await fsPromises.writeFile(summaryPath, summaryContent, 'utf8'); return { - filePathForDisplay: '.qwen/PROJECT_SUMMARY.md', + filePathForDisplay, fullPath: summaryPath, }; }; From ae966bbf6ba1fdb8e94295140a9ee6f051bc18f7 Mon Sep 17 00:00:00 2001 From: qwen-code-dev-bot Date: Thu, 30 Jul 2026 11:07:28 +0000 Subject: [PATCH 02/17] fix(cli): summary custom path dir detection and i18n key (#8116) --- .../src/ui/commands/summaryCommand.test.ts | 113 ++++++++++++++++++ .../cli/src/ui/commands/summaryCommand.ts | 6 +- 2 files changed, 116 insertions(+), 3 deletions(-) create mode 100644 packages/cli/src/ui/commands/summaryCommand.test.ts diff --git a/packages/cli/src/ui/commands/summaryCommand.test.ts b/packages/cli/src/ui/commands/summaryCommand.test.ts new file mode 100644 index 00000000000..d43c2ad290d --- /dev/null +++ b/packages/cli/src/ui/commands/summaryCommand.test.ts @@ -0,0 +1,113 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import * as fs from 'node:fs/promises'; +import * as os from 'node:os'; +import path from 'node:path'; +import { summaryCommand } from './summaryCommand.js'; +import { createMockCommandContext } from '../../test-utils/mockCommandContext.js'; +import type { CommandContext } from './types.js'; + +vi.mock('@qwen-code/qwen-code-core', () => ({ + getProjectSummaryPrompt: () => 'summary prompt', + runSideQuery: vi.fn(async () => ({ text: 'SUMMARY BODY' })), +})); + +const makeContext = (projectRoot: string): CommandContext => { + const chat = { + getHistoryShallow: () => [ + { role: 'user', parts: [{ text: 'a' }] }, + { role: 'model', parts: [{ text: 'b' }] }, + { role: 'user', parts: [{ text: 'c' }] }, + ], + getGenerationConfig: () => ({ systemInstruction: 'sys' }), + }; + const config = { + getProjectRoot: () => projectRoot, + getGeminiClient: () => ({ getChat: () => chat }), + getModel: () => 'test-model', + }; + return createMockCommandContext({ + executionMode: 'non_interactive', + services: { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + config: config as any, + }, + }); +}; + +describe('summaryCommand custom export path', () => { + let projectRoot: string; + + beforeEach(async () => { + projectRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'summary-cmd-')); + }); + + afterEach(async () => { + await fs.rm(projectRoot, { recursive: true, force: true }); + }); + + interface MessageResult { + type: string; + messageType: string; + content: string; + } + + const run = async (args: string): Promise => + (await summaryCommand.action?.( + makeContext(projectRoot), + args, + )) as MessageResult; + + const fileExists = async (p: string): Promise => { + try { + return (await fs.stat(p)).isFile(); + } catch { + return false; + } + }; + + it('defaults to .qwen/PROJECT_SUMMARY.md with no argument', async () => { + const result = await run(''); + const fullPath = path.join(projectRoot, '.qwen', 'PROJECT_SUMMARY.md'); + expect(await fileExists(fullPath)).toBe(true); + expect(result).toMatchObject({ type: 'message', messageType: 'info' }); + expect(result.content).toContain('.qwen/PROJECT_SUMMARY.md'); + }); + + it('writes a relative file path as-is', async () => { + await run('notes.md'); + expect(await fileExists(path.join(projectRoot, 'notes.md'))).toBe(true); + }); + + it('treats a relative path with a trailing separator as a directory', async () => { + // Regression: path.resolve strips the trailing separator, so the directory + // must be detected from the raw argument, not the resolved path. + await run('docs/'); + expect( + await fileExists(path.join(projectRoot, 'docs', 'PROJECT_SUMMARY.md')), + ).toBe(true); + expect(await fileExists(path.join(projectRoot, 'docs'))).toBe(false); + }); + + it('appends the default filename for an existing directory', async () => { + await fs.mkdir(path.join(projectRoot, 'existingdir')); + await run('existingdir'); + expect( + await fileExists( + path.join(projectRoot, 'existingdir', 'PROJECT_SUMMARY.md'), + ), + ).toBe(true); + }); + + it('writes an absolute path as-is and reports it absolutely', async () => { + const target = path.join(projectRoot, 'abs', 'out.md'); + const result = await run(target); + expect(await fileExists(target)).toBe(true); + expect(result.content).toContain(target); + }); +}); diff --git a/packages/cli/src/ui/commands/summaryCommand.ts b/packages/cli/src/ui/commands/summaryCommand.ts index a1d0bb0f3c8..a36fbc9cb0c 100644 --- a/packages/cli/src/ui/commands/summaryCommand.ts +++ b/packages/cli/src/ui/commands/summaryCommand.ts @@ -22,7 +22,7 @@ export const summaryCommand: SlashCommand = { name: 'summary', get description() { return t( - 'Generate a project summary and save it to .qwen/PROJECT_SUMMARY.md (or a custom path)', + 'Generate a project summary and save it to .qwen/PROJECT_SUMMARY.md', ); }, argumentHint: '[path]', @@ -156,8 +156,8 @@ export const summaryCommand: SlashCommand = { // If the path ends with a separator or is an existing directory, // append the default filename const isDir = - resolved.endsWith('/') || - resolved.endsWith('\\') || + customPath.endsWith('/') || + customPath.endsWith(path.sep) || (await fsPromises .stat(resolved) .then((s) => s.isDirectory()) From 721ae1bb5b9aa3a6e2e1f851b028c8b3cc8f5319 Mon Sep 17 00:00:00 2001 From: Qwen Code Autofix Date: Thu, 30 Jul 2026 12:19:31 +0000 Subject: [PATCH 03/17] test(cli): assert relative display path in summary tests (#8116) --- .../cli/src/ui/commands/summaryCommand.test.ts | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/packages/cli/src/ui/commands/summaryCommand.test.ts b/packages/cli/src/ui/commands/summaryCommand.test.ts index d43c2ad290d..4cad9902de2 100644 --- a/packages/cli/src/ui/commands/summaryCommand.test.ts +++ b/packages/cli/src/ui/commands/summaryCommand.test.ts @@ -80,28 +80,36 @@ describe('summaryCommand custom export path', () => { }); it('writes a relative file path as-is', async () => { - await run('notes.md'); + const result = await run('notes.md'); expect(await fileExists(path.join(projectRoot, 'notes.md'))).toBe(true); + expect(result.content).toContain('notes.md'); + expect(result.content).not.toContain(projectRoot); }); it('treats a relative path with a trailing separator as a directory', async () => { // Regression: path.resolve strips the trailing separator, so the directory // must be detected from the raw argument, not the resolved path. - await run('docs/'); + const result = await run('docs/'); expect( await fileExists(path.join(projectRoot, 'docs', 'PROJECT_SUMMARY.md')), ).toBe(true); expect(await fileExists(path.join(projectRoot, 'docs'))).toBe(false); + expect(result.content).toContain(path.join('docs', 'PROJECT_SUMMARY.md')); + expect(result.content).not.toContain(projectRoot); }); it('appends the default filename for an existing directory', async () => { await fs.mkdir(path.join(projectRoot, 'existingdir')); - await run('existingdir'); + const result = await run('existingdir'); expect( await fileExists( path.join(projectRoot, 'existingdir', 'PROJECT_SUMMARY.md'), ), ).toBe(true); + expect(result.content).toContain( + path.join('existingdir', 'PROJECT_SUMMARY.md'), + ); + expect(result.content).not.toContain(projectRoot); }); it('writes an absolute path as-is and reports it absolutely', async () => { From 96f73b149f8b5fa9598a91412e41b639d424239c Mon Sep 17 00:00:00 2001 From: Qwen Code Bot Date: Thu, 30 Jul 2026 13:44:56 +0000 Subject: [PATCH 04/17] fix(cli): summary path containment, early validation, and mkdir hardening (#8116) --- packages/cli/src/i18n/locales/ca.js | 2 + packages/cli/src/i18n/locales/de.js | 2 + packages/cli/src/i18n/locales/en.js | 2 + packages/cli/src/i18n/locales/fr.js | 2 + packages/cli/src/i18n/locales/ja.js | 2 + packages/cli/src/i18n/locales/pt.js | 2 + packages/cli/src/i18n/locales/ru.js | 2 + packages/cli/src/i18n/locales/zh-TW.js | 1 + packages/cli/src/i18n/locales/zh.js | 1 + .../src/ui/commands/summaryCommand.test.ts | 31 ++++-- .../cli/src/ui/commands/summaryCommand.ts | 104 ++++++++++-------- 11 files changed, 96 insertions(+), 55 deletions(-) diff --git a/packages/cli/src/i18n/locales/ca.js b/packages/cli/src/i18n/locales/ca.js index 177e08d0293..4329d200279 100644 --- a/packages/cli/src/i18n/locales/ca.js +++ b/packages/cli/src/i18n/locales/ca.js @@ -1120,6 +1120,8 @@ export default { "Ja s'està generant el resum, espereu que acabi la sol·licitud anterior", 'No conversation found to summarize.': "No s'ha trobat cap conversa per resumir.", + 'Summary path must be within the project root.': + 'El camí del resum ha de ser dins de la arrel del projecte.', 'Failed to generate project context summary: {{error}}': 'Error en generar el resum del context del projecte: {{error}}', 'Saved project summary to {{filePathForDisplay}}.': diff --git a/packages/cli/src/i18n/locales/de.js b/packages/cli/src/i18n/locales/de.js index 975d28c6b0e..c2a177af457 100644 --- a/packages/cli/src/i18n/locales/de.js +++ b/packages/cli/src/i18n/locales/de.js @@ -995,6 +995,8 @@ export default { 'Zusammenfassung wird bereits generiert, warten Sie auf Abschluss der vorherigen Anfrage', 'No conversation found to summarize.': 'Kein Gespräch zum Zusammenfassen gefunden.', + 'Summary path must be within the project root.': + 'Der Zusammenfassungspfad muss sich im Projektstammverzeichnis befinden.', 'Failed to generate project context summary: {{error}}': 'Fehler beim Generieren der Projektkontextzusammenfassung: {{error}}', 'Saved project summary to {{filePathForDisplay}}.': diff --git a/packages/cli/src/i18n/locales/en.js b/packages/cli/src/i18n/locales/en.js index 4ee231dbcb9..e296b34754b 100644 --- a/packages/cli/src/i18n/locales/en.js +++ b/packages/cli/src/i18n/locales/en.js @@ -1472,6 +1472,8 @@ export default { 'Already generating summary, wait for previous request to complete': 'Already generating summary, wait for previous request to complete', 'No conversation found to summarize.': 'No conversation found to summarize.', + 'Summary path must be within the project root.': + 'Summary path must be within the project root.', 'Failed to generate project context summary: {{error}}': 'Failed to generate project context summary: {{error}}', 'Saved project summary to {{filePathForDisplay}}.': diff --git a/packages/cli/src/i18n/locales/fr.js b/packages/cli/src/i18n/locales/fr.js index d5bd122b1b2..a5c144ddfc9 100644 --- a/packages/cli/src/i18n/locales/fr.js +++ b/packages/cli/src/i18n/locales/fr.js @@ -1126,6 +1126,8 @@ export default { 'Génération de résumé déjà en cours, attendez que la demande précédente se termine', 'No conversation found to summarize.': 'Aucune conversation trouvée à résumer.', + 'Summary path must be within the project root.': + 'Le chemin du résumé doit se trouver dans la racine du projet.', 'Failed to generate project context summary: {{error}}': 'Échec de la génération du résumé du contexte du projet : {{error}}', 'Saved project summary to {{filePathForDisplay}}.': diff --git a/packages/cli/src/i18n/locales/ja.js b/packages/cli/src/i18n/locales/ja.js index d51e8895ee5..22b7f6b0bc2 100644 --- a/packages/cli/src/i18n/locales/ja.js +++ b/packages/cli/src/i18n/locales/ja.js @@ -766,6 +766,8 @@ export default { 'Already generating summary, wait for previous request to complete': 'サマリー生成中です。前のリクエストの完了をお待ちください', 'No conversation found to summarize.': '要約する会話が見つかりません', + 'Summary path must be within the project root.': + 'サマリーパスはプロジェクトルート内にある必要があります', 'Failed to generate project context summary: {{error}}': 'プロジェクトコンテキストサマリーの生成に失敗: {{error}}', 'Saved project summary to {{filePathForDisplay}}.': diff --git a/packages/cli/src/i18n/locales/pt.js b/packages/cli/src/i18n/locales/pt.js index 5d981fc4668..e59d65f0b2f 100644 --- a/packages/cli/src/i18n/locales/pt.js +++ b/packages/cli/src/i18n/locales/pt.js @@ -1000,6 +1000,8 @@ export default { 'Já gerando resumo, aguarde a conclusão da solicitação anterior', 'No conversation found to summarize.': 'Nenhuma conversa encontrada para resumir.', + 'Summary path must be within the project root.': + 'O caminho do resumo deve estar dentro da raiz do projeto.', 'Failed to generate project context summary: {{error}}': 'Falha ao gerar resumo do contexto do projeto: {{error}}', 'Saved project summary to {{filePathForDisplay}}.': diff --git a/packages/cli/src/i18n/locales/ru.js b/packages/cli/src/i18n/locales/ru.js index 9bb5904a385..59e672497f7 100644 --- a/packages/cli/src/i18n/locales/ru.js +++ b/packages/cli/src/i18n/locales/ru.js @@ -1008,6 +1008,8 @@ export default { 'Генерация сводки уже выполняется, дождитесь завершения предыдущего запроса', 'No conversation found to summarize.': 'Не найдено диалогов для создания сводки.', + 'Summary path must be within the project root.': + 'Путь сводки должен находиться в корне проекта.', 'Failed to generate project context summary: {{error}}': 'Не удалось сгенерировать сводку контекста проекта: {{error}}', 'Saved project summary to {{filePathForDisplay}}.': diff --git a/packages/cli/src/i18n/locales/zh-TW.js b/packages/cli/src/i18n/locales/zh-TW.js index 08c5e41ad68..58454ff6467 100644 --- a/packages/cli/src/i18n/locales/zh-TW.js +++ b/packages/cli/src/i18n/locales/zh-TW.js @@ -1302,6 +1302,7 @@ export default { 'Already generating summary, wait for previous request to complete': '正在生成摘要,請等待上一個請求完成', 'No conversation found to summarize.': '未找到要總結的對話', + 'Summary path must be within the project root.': '摘要路徑必須在專案根目錄內', 'Failed to generate project context summary: {{error}}': '生成項目上下文摘要失敗:{{error}}', 'Saved project summary to {{filePathForDisplay}}.': diff --git a/packages/cli/src/i18n/locales/zh.js b/packages/cli/src/i18n/locales/zh.js index 95d293cece6..036e0c1eb36 100644 --- a/packages/cli/src/i18n/locales/zh.js +++ b/packages/cli/src/i18n/locales/zh.js @@ -1412,6 +1412,7 @@ export default { 'Already generating summary, wait for previous request to complete': '正在生成摘要,请等待上一个请求完成', 'No conversation found to summarize.': '未找到要总结的对话', + 'Summary path must be within the project root.': '摘要路径必须在项目根目录内', 'Failed to generate project context summary: {{error}}': '生成项目上下文摘要失败:{{error}}', 'Saved project summary to {{filePathForDisplay}}.': diff --git a/packages/cli/src/ui/commands/summaryCommand.test.ts b/packages/cli/src/ui/commands/summaryCommand.test.ts index 4cad9902de2..7a0934a7e60 100644 --- a/packages/cli/src/ui/commands/summaryCommand.test.ts +++ b/packages/cli/src/ui/commands/summaryCommand.test.ts @@ -12,10 +12,15 @@ import { summaryCommand } from './summaryCommand.js'; import { createMockCommandContext } from '../../test-utils/mockCommandContext.js'; import type { CommandContext } from './types.js'; -vi.mock('@qwen-code/qwen-code-core', () => ({ - getProjectSummaryPrompt: () => 'summary prompt', - runSideQuery: vi.fn(async () => ({ text: 'SUMMARY BODY' })), -})); +vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + getProjectSummaryPrompt: () => 'summary prompt', + runSideQuery: vi.fn(async () => ({ text: 'SUMMARY BODY' })), + }; +}); const makeContext = (projectRoot: string): CommandContext => { const chat = { @@ -94,7 +99,7 @@ describe('summaryCommand custom export path', () => { await fileExists(path.join(projectRoot, 'docs', 'PROJECT_SUMMARY.md')), ).toBe(true); expect(await fileExists(path.join(projectRoot, 'docs'))).toBe(false); - expect(result.content).toContain(path.join('docs', 'PROJECT_SUMMARY.md')); + expect(result.content).toContain('docs/PROJECT_SUMMARY.md'); expect(result.content).not.toContain(projectRoot); }); @@ -106,9 +111,7 @@ describe('summaryCommand custom export path', () => { path.join(projectRoot, 'existingdir', 'PROJECT_SUMMARY.md'), ), ).toBe(true); - expect(result.content).toContain( - path.join('existingdir', 'PROJECT_SUMMARY.md'), - ); + expect(result.content).toContain('existingdir/PROJECT_SUMMARY.md'); expect(result.content).not.toContain(projectRoot); }); @@ -118,4 +121,16 @@ describe('summaryCommand custom export path', () => { expect(await fileExists(target)).toBe(true); expect(result.content).toContain(target); }); + + it('rejects a relative path that escapes the project root', async () => { + const result = await run('../outside/leak.md'); + expect(result).toMatchObject({ type: 'message', messageType: 'error' }); + expect(result.content).toContain('within the project root'); + }); + + it('rejects an absolute path outside the project root', async () => { + const result = await run('/tmp/summary-escape/leak.md'); + expect(result).toMatchObject({ type: 'message', messageType: 'error' }); + expect(result.content).toContain('within the project root'); + }); }); diff --git a/packages/cli/src/ui/commands/summaryCommand.ts b/packages/cli/src/ui/commands/summaryCommand.ts index a36fbc9cb0c..f9736a6c695 100644 --- a/packages/cli/src/ui/commands/summaryCommand.ts +++ b/packages/cli/src/ui/commands/summaryCommand.ts @@ -13,6 +13,7 @@ import { } from './types.js'; import { getProjectSummaryPrompt, + isSubpath, runSideQuery, } from '@qwen-code/qwen-code-core'; import type { HistoryItemSummary } from '../types.js'; @@ -135,58 +136,63 @@ export const summaryCommand: SlashCommand = { return result.text; }; - const saveSummaryToDisk = async ( - markdownSummary: string, - ): Promise<{ + const resolveSummaryTarget = async (): Promise<{ + summaryPath: string; filePathForDisplay: string; - fullPath: string; }> => { const projectRoot = config.getProjectRoot(); const customPath = args?.trim(); - let summaryPath: string; - let filePathForDisplay: string; - - if (customPath) { - // Resolve relative to project root - const resolved = path.isAbsolute(customPath) - ? customPath - : path.resolve(projectRoot, customPath); - - // If the path ends with a separator or is an existing directory, - // append the default filename - const isDir = - customPath.endsWith('/') || - customPath.endsWith(path.sep) || - (await fsPromises - .stat(resolved) - .then((s) => s.isDirectory()) - .catch(() => false)); - - if (isDir) { - summaryPath = path.join(resolved, 'PROJECT_SUMMARY.md'); - } else { - summaryPath = resolved; - } + if (!customPath) { + const qwenDir = path.join(projectRoot, '.qwen'); + await fsPromises.mkdir(qwenDir, { recursive: true, mode: 0o700 }); + return { + summaryPath: path.join(qwenDir, 'PROJECT_SUMMARY.md'), + filePathForDisplay: '.qwen/PROJECT_SUMMARY.md', + }; + } - // Ensure parent directory exists - await fsPromises.mkdir(path.dirname(summaryPath), { recursive: true }); + const resolved = path.isAbsolute(customPath) + ? customPath + : path.resolve(projectRoot, customPath); - filePathForDisplay = path.isAbsolute(customPath) - ? summaryPath - : path.relative(projectRoot, summaryPath); - } else { - // Default: save to .qwen/PROJECT_SUMMARY.md - const qwenDir = path.join(projectRoot, '.qwen'); - try { - await fsPromises.mkdir(qwenDir, { recursive: true }); - } catch (_err) { - // Directory might already exist, ignore error - } - summaryPath = path.join(qwenDir, 'PROJECT_SUMMARY.md'); - filePathForDisplay = '.qwen/PROJECT_SUMMARY.md'; + if (!isSubpath(projectRoot, resolved)) { + throw new Error(t('Summary path must be within the project root.')); } + const isDir = + customPath.endsWith('/') || + customPath.endsWith(path.sep) || + (await fsPromises + .stat(resolved) + .then((s) => s.isDirectory()) + .catch(() => false)); + + const summaryPath = isDir + ? path.join(resolved, 'PROJECT_SUMMARY.md') + : resolved; + + await fsPromises.mkdir(path.dirname(summaryPath), { + recursive: true, + mode: 0o700, + }); + + const filePathForDisplay = ( + path.isAbsolute(customPath) + ? summaryPath + : path.relative(projectRoot, summaryPath) + ).replaceAll(path.sep, '/'); + + return { summaryPath, filePathForDisplay }; + }; + + const saveSummaryToDisk = async ( + markdownSummary: string, + target: { summaryPath: string; filePathForDisplay: string }, + ): Promise<{ + filePathForDisplay: string; + fullPath: string; + }> => { const summaryContent = `${markdownSummary} --- @@ -195,11 +201,11 @@ export const summaryCommand: SlashCommand = { **Update time**: ${new Date().toISOString()} `; - await fsPromises.writeFile(summaryPath, summaryContent, 'utf8'); + await fsPromises.writeFile(target.summaryPath, summaryContent, 'utf8'); return { - filePathForDisplay, - fullPath: summaryPath, + filePathForDisplay: target.filePathForDisplay, + fullPath: target.summaryPath, }; }; @@ -288,13 +294,17 @@ export const summaryCommand: SlashCommand = { markdownSummary: string; filePathForDisplay: string; }> => { + const target = await resolveSummaryTarget(); emitInteractivePending('generating'); const markdownSummary = await generateSummaryMarkdown(history); if (abortSignal?.aborted) { throw new DOMException('Summary generation cancelled.', 'AbortError'); } emitInteractivePending('saving'); - const { filePathForDisplay } = await saveSummaryToDisk(markdownSummary); + const { filePathForDisplay } = await saveSummaryToDisk( + markdownSummary, + target, + ); completeInteractive(filePathForDisplay); return { markdownSummary, filePathForDisplay }; }; From 4e6a6f40c326487612e7dae2a2b744e28584fc8d Mon Sep 17 00:00:00 2001 From: Qwen Code Autofix Date: Thu, 30 Jul 2026 15:21:20 +0000 Subject: [PATCH 05/17] fix(cli): defer summary mkdir to save time so failed generation leaves no empty dir (#8116) --- .../cli/src/ui/commands/summaryCommand.test.ts | 16 ++++++++++++++++ packages/cli/src/ui/commands/summaryCommand.ts | 10 ++++------ 2 files changed, 20 insertions(+), 6 deletions(-) diff --git a/packages/cli/src/ui/commands/summaryCommand.test.ts b/packages/cli/src/ui/commands/summaryCommand.test.ts index 7a0934a7e60..3e61361f50a 100644 --- a/packages/cli/src/ui/commands/summaryCommand.test.ts +++ b/packages/cli/src/ui/commands/summaryCommand.test.ts @@ -11,6 +11,7 @@ import path from 'node:path'; import { summaryCommand } from './summaryCommand.js'; import { createMockCommandContext } from '../../test-utils/mockCommandContext.js'; import type { CommandContext } from './types.js'; +import { runSideQuery } from '@qwen-code/qwen-code-core'; vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => { const actual = @@ -76,6 +77,14 @@ describe('summaryCommand custom export path', () => { } }; + const dirExists = async (p: string): Promise => { + try { + return (await fs.stat(p)).isDirectory(); + } catch { + return false; + } + }; + it('defaults to .qwen/PROJECT_SUMMARY.md with no argument', async () => { const result = await run(''); const fullPath = path.join(projectRoot, '.qwen', 'PROJECT_SUMMARY.md'); @@ -133,4 +142,11 @@ describe('summaryCommand custom export path', () => { expect(result).toMatchObject({ type: 'message', messageType: 'error' }); expect(result.content).toContain('within the project root'); }); + + it('does not create the target directory when generation fails', async () => { + vi.mocked(runSideQuery).mockRejectedValueOnce(new Error('rate limit')); + const result = await run('reports/2026/summary.md'); + expect(result).toMatchObject({ type: 'message', messageType: 'error' }); + expect(await dirExists(path.join(projectRoot, 'reports'))).toBe(false); + }); }); diff --git a/packages/cli/src/ui/commands/summaryCommand.ts b/packages/cli/src/ui/commands/summaryCommand.ts index f9736a6c695..a79c116cc8a 100644 --- a/packages/cli/src/ui/commands/summaryCommand.ts +++ b/packages/cli/src/ui/commands/summaryCommand.ts @@ -145,7 +145,6 @@ export const summaryCommand: SlashCommand = { if (!customPath) { const qwenDir = path.join(projectRoot, '.qwen'); - await fsPromises.mkdir(qwenDir, { recursive: true, mode: 0o700 }); return { summaryPath: path.join(qwenDir, 'PROJECT_SUMMARY.md'), filePathForDisplay: '.qwen/PROJECT_SUMMARY.md', @@ -172,11 +171,6 @@ export const summaryCommand: SlashCommand = { ? path.join(resolved, 'PROJECT_SUMMARY.md') : resolved; - await fsPromises.mkdir(path.dirname(summaryPath), { - recursive: true, - mode: 0o700, - }); - const filePathForDisplay = ( path.isAbsolute(customPath) ? summaryPath @@ -201,6 +195,10 @@ export const summaryCommand: SlashCommand = { **Update time**: ${new Date().toISOString()} `; + await fsPromises.mkdir(path.dirname(target.summaryPath), { + recursive: true, + mode: 0o700, + }); await fsPromises.writeFile(target.summaryPath, summaryContent, 'utf8'); return { From 9fbecfec5e0b06bc863900403687635311272a98 Mon Sep 17 00:00:00 2001 From: Qwen Code Autofix Date: Thu, 30 Jul 2026 16:52:24 +0000 Subject: [PATCH 06/17] fix(cli): normalize path separators in summary test and assert file content (#8116) --- packages/cli/src/ui/commands/summaryCommand.test.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/ui/commands/summaryCommand.test.ts b/packages/cli/src/ui/commands/summaryCommand.test.ts index 3e61361f50a..f819730f9be 100644 --- a/packages/cli/src/ui/commands/summaryCommand.test.ts +++ b/packages/cli/src/ui/commands/summaryCommand.test.ts @@ -89,6 +89,9 @@ describe('summaryCommand custom export path', () => { const result = await run(''); const fullPath = path.join(projectRoot, '.qwen', 'PROJECT_SUMMARY.md'); expect(await fileExists(fullPath)).toBe(true); + const written = await fs.readFile(fullPath, 'utf8'); + expect(written).toContain('SUMMARY BODY'); + expect(written).toContain('## Summary Metadata'); expect(result).toMatchObject({ type: 'message', messageType: 'info' }); expect(result.content).toContain('.qwen/PROJECT_SUMMARY.md'); }); @@ -128,7 +131,7 @@ describe('summaryCommand custom export path', () => { const target = path.join(projectRoot, 'abs', 'out.md'); const result = await run(target); expect(await fileExists(target)).toBe(true); - expect(result.content).toContain(target); + expect(result.content).toContain(target.replaceAll(path.sep, '/')); }); it('rejects a relative path that escapes the project root', async () => { From 7238cbf76ef80f22c78e7907d551763a49d548b3 Mon Sep 17 00:00:00 2001 From: Qwen Code Autofix Date: Thu, 30 Jul 2026 18:13:25 +0000 Subject: [PATCH 07/17] test(cli): assert directory permission mode in summary test (#8116) --- packages/cli/src/ui/commands/summaryCommand.test.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/cli/src/ui/commands/summaryCommand.test.ts b/packages/cli/src/ui/commands/summaryCommand.test.ts index f819730f9be..59397da0698 100644 --- a/packages/cli/src/ui/commands/summaryCommand.test.ts +++ b/packages/cli/src/ui/commands/summaryCommand.test.ts @@ -94,6 +94,10 @@ describe('summaryCommand custom export path', () => { expect(written).toContain('## Summary Metadata'); expect(result).toMatchObject({ type: 'message', messageType: 'info' }); expect(result.content).toContain('.qwen/PROJECT_SUMMARY.md'); + if (process.platform !== 'win32') { + const stat = await fs.stat(path.dirname(fullPath)); + expect(stat.mode & 0o777).toBe(0o700); + } }); it('writes a relative file path as-is', async () => { From 25e810b26635f0472fcc8ae745b55f38009c3d77 Mon Sep 17 00:00:00 2001 From: Qwen Code Autofix Date: Thu, 30 Jul 2026 19:30:29 +0000 Subject: [PATCH 08/17] fix(cli): resolve symlinks in summary path containment check (#8116) --- .../src/ui/commands/summaryCommand.test.ts | 23 ++++++++++++++ .../cli/src/ui/commands/summaryCommand.ts | 30 +++++++++++++++++++ 2 files changed, 53 insertions(+) diff --git a/packages/cli/src/ui/commands/summaryCommand.test.ts b/packages/cli/src/ui/commands/summaryCommand.test.ts index 59397da0698..2bb0056bff8 100644 --- a/packages/cli/src/ui/commands/summaryCommand.test.ts +++ b/packages/cli/src/ui/commands/summaryCommand.test.ts @@ -50,6 +50,7 @@ describe('summaryCommand custom export path', () => { let projectRoot: string; beforeEach(async () => { + vi.mocked(runSideQuery).mockClear(); projectRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'summary-cmd-')); }); @@ -142,12 +143,34 @@ describe('summaryCommand custom export path', () => { const result = await run('../outside/leak.md'); expect(result).toMatchObject({ type: 'message', messageType: 'error' }); expect(result.content).toContain('within the project root'); + expect(runSideQuery).not.toHaveBeenCalled(); }); it('rejects an absolute path outside the project root', async () => { const result = await run('/tmp/summary-escape/leak.md'); expect(result).toMatchObject({ type: 'message', messageType: 'error' }); expect(result.content).toContain('within the project root'); + expect(runSideQuery).not.toHaveBeenCalled(); + }); + + it('rejects a path that escapes the project root via a symlink', async () => { + // Symlink creation typically requires elevated privileges on Windows. + if (process.platform === 'win32') { + return; + } + const outside = await fs.mkdtemp( + path.join(os.tmpdir(), 'summary-outside-'), + ); + try { + await fs.symlink(outside, path.join(projectRoot, 'link')); + const result = await run('link/leak.md'); + expect(result).toMatchObject({ type: 'message', messageType: 'error' }); + expect(result.content).toContain('within the project root'); + expect(await fileExists(path.join(outside, 'leak.md'))).toBe(false); + expect(runSideQuery).not.toHaveBeenCalled(); + } finally { + await fs.rm(outside, { recursive: true, force: true }); + } }); it('does not create the target directory when generation fails', async () => { diff --git a/packages/cli/src/ui/commands/summaryCommand.ts b/packages/cli/src/ui/commands/summaryCommand.ts index a79c116cc8a..623847457a9 100644 --- a/packages/cli/src/ui/commands/summaryCommand.ts +++ b/packages/cli/src/ui/commands/summaryCommand.ts @@ -19,6 +19,28 @@ import { import type { HistoryItemSummary } from '../types.js'; import { t } from '../../i18n/index.js'; +// Resolves the real path of the nearest existing ancestor of targetPath. The +// target file itself usually does not exist yet, but a symlinked parent directory +// can still point outside the project root, so the ancestor must be resolved to +// detect that. Mirrors realpathNearestExisting in exportCommand.ts/statsCommand.ts. +const realpathNearestExisting = async (targetPath: string): Promise => { + let currentPath = targetPath; + for (;;) { + try { + return await fsPromises.realpath(currentPath); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + throw error; + } + const parentPath = path.dirname(currentPath); + if (parentPath === currentPath) { + return currentPath; + } + currentPath = parentPath; + } + } +}; + export const summaryCommand: SlashCommand = { name: 'summary', get description() { @@ -159,6 +181,14 @@ export const summaryCommand: SlashCommand = { throw new Error(t('Summary path must be within the project root.')); } + // A lexical check cannot see through symlinks: a link inside the project + // root may point outside it. Re-check containment on the real paths. + const realProjectRoot = await fsPromises.realpath(projectRoot); + const realResolved = await realpathNearestExisting(resolved); + if (!isSubpath(realProjectRoot, realResolved)) { + throw new Error(t('Summary path must be within the project root.')); + } + const isDir = customPath.endsWith('/') || customPath.endsWith(path.sep) || From f6467bfd38fd788cbf1fe5217b52e31ef21a0927 Mon Sep 17 00:00:00 2001 From: qwen-code-ci-bot Date: Fri, 31 Jul 2026 17:27:57 +0000 Subject: [PATCH 09/17] fix(cli): reject broken symlinks escaping project root in /summary (#8116) --- .../cli/src/ui/commands/summaryCommand.test.ts | 17 +++++++++++++++++ packages/cli/src/ui/commands/summaryCommand.ts | 15 +++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/packages/cli/src/ui/commands/summaryCommand.test.ts b/packages/cli/src/ui/commands/summaryCommand.test.ts index 2bb0056bff8..5629d229002 100644 --- a/packages/cli/src/ui/commands/summaryCommand.test.ts +++ b/packages/cli/src/ui/commands/summaryCommand.test.ts @@ -173,6 +173,23 @@ describe('summaryCommand custom export path', () => { } }); + it('rejects a broken symlink whose target is outside the project root', async () => { + if (process.platform === 'win32') { + return; + } + const outsideTarget = path.join( + os.tmpdir(), + `summary-broken-${Date.now()}`, + 'leak.md', + ); + await fs.symlink(outsideTarget, path.join(projectRoot, 'broken-link')); + const result = await run('broken-link'); + expect(result).toMatchObject({ type: 'message', messageType: 'error' }); + expect(result.content).toContain('within the project root'); + expect(await fileExists(outsideTarget)).toBe(false); + expect(runSideQuery).not.toHaveBeenCalled(); + }); + it('does not create the target directory when generation fails', async () => { vi.mocked(runSideQuery).mockRejectedValueOnce(new Error('rate limit')); const result = await run('reports/2026/summary.md'); diff --git a/packages/cli/src/ui/commands/summaryCommand.ts b/packages/cli/src/ui/commands/summaryCommand.ts index 623847457a9..d8656e2e4dd 100644 --- a/packages/cli/src/ui/commands/summaryCommand.ts +++ b/packages/cli/src/ui/commands/summaryCommand.ts @@ -184,6 +184,21 @@ export const summaryCommand: SlashCommand = { // A lexical check cannot see through symlinks: a link inside the project // root may point outside it. Re-check containment on the real paths. const realProjectRoot = await fsPromises.realpath(projectRoot); + + // A broken symlink (target absent) makes realpathNearestExisting walk up + // to the parent and miss the escape — detect symlinks at the leaf. + const leafStat = await fsPromises.lstat(resolved).catch(() => null); + if (leafStat?.isSymbolicLink()) { + const linkTarget = await fsPromises.readlink(resolved); + const resolvedTarget = path.isAbsolute(linkTarget) + ? linkTarget + : path.resolve(path.dirname(resolved), linkTarget); + const realTarget = await realpathNearestExisting(resolvedTarget); + if (!isSubpath(realProjectRoot, realTarget)) { + throw new Error(t('Summary path must be within the project root.')); + } + } + const realResolved = await realpathNearestExisting(resolved); if (!isSubpath(realProjectRoot, realResolved)) { throw new Error(t('Summary path must be within the project root.')); From bc463835cfb7078f411978ca967eec049af02718 Mon Sep 17 00:00:00 2001 From: qwen-code-dev-bot Date: Fri, 31 Jul 2026 18:30:06 +0000 Subject: [PATCH 10/17] fix(cli): guard /summary overwrite and expand tilde in path (#8116) --- packages/cli/src/i18n/locales/ca.js | 2 ++ packages/cli/src/i18n/locales/de.js | 2 ++ packages/cli/src/i18n/locales/en.js | 2 ++ packages/cli/src/i18n/locales/fr.js | 2 ++ packages/cli/src/i18n/locales/ja.js | 2 ++ packages/cli/src/i18n/locales/pt.js | 2 ++ packages/cli/src/i18n/locales/ru.js | 2 ++ packages/cli/src/i18n/locales/zh-TW.js | 2 ++ packages/cli/src/i18n/locales/zh.js | 2 ++ .../src/ui/commands/summaryCommand.test.ts | 33 +++++++++++++++++++ .../cli/src/ui/commands/summaryCommand.ts | 25 ++++++++++++-- 11 files changed, 73 insertions(+), 3 deletions(-) diff --git a/packages/cli/src/i18n/locales/ca.js b/packages/cli/src/i18n/locales/ca.js index 4329d200279..76db6055b74 100644 --- a/packages/cli/src/i18n/locales/ca.js +++ b/packages/cli/src/i18n/locales/ca.js @@ -1120,6 +1120,8 @@ export default { "Ja s'està generant el resum, espereu que acabi la sol·licitud anterior", 'No conversation found to summarize.': "No s'ha trobat cap conversa per resumir.", + 'Summary path already exists and is not a generated summary: {{path}}': + 'El camí del resum ja existeix i no és un resum generat: {{path}}', 'Summary path must be within the project root.': 'El camí del resum ha de ser dins de la arrel del projecte.', 'Failed to generate project context summary: {{error}}': diff --git a/packages/cli/src/i18n/locales/de.js b/packages/cli/src/i18n/locales/de.js index c2a177af457..d322aebf072 100644 --- a/packages/cli/src/i18n/locales/de.js +++ b/packages/cli/src/i18n/locales/de.js @@ -995,6 +995,8 @@ export default { 'Zusammenfassung wird bereits generiert, warten Sie auf Abschluss der vorherigen Anfrage', 'No conversation found to summarize.': 'Kein Gespräch zum Zusammenfassen gefunden.', + 'Summary path already exists and is not a generated summary: {{path}}': + 'Der Zusammenfassungspfad existiert bereits und ist keine generierte Zusammenfassung: {{path}}', 'Summary path must be within the project root.': 'Der Zusammenfassungspfad muss sich im Projektstammverzeichnis befinden.', 'Failed to generate project context summary: {{error}}': diff --git a/packages/cli/src/i18n/locales/en.js b/packages/cli/src/i18n/locales/en.js index e296b34754b..01accc1a052 100644 --- a/packages/cli/src/i18n/locales/en.js +++ b/packages/cli/src/i18n/locales/en.js @@ -1472,6 +1472,8 @@ export default { 'Already generating summary, wait for previous request to complete': 'Already generating summary, wait for previous request to complete', 'No conversation found to summarize.': 'No conversation found to summarize.', + 'Summary path already exists and is not a generated summary: {{path}}': + 'Summary path already exists and is not a generated summary: {{path}}', 'Summary path must be within the project root.': 'Summary path must be within the project root.', 'Failed to generate project context summary: {{error}}': diff --git a/packages/cli/src/i18n/locales/fr.js b/packages/cli/src/i18n/locales/fr.js index a5c144ddfc9..fd472f78b99 100644 --- a/packages/cli/src/i18n/locales/fr.js +++ b/packages/cli/src/i18n/locales/fr.js @@ -1126,6 +1126,8 @@ export default { 'Génération de résumé déjà en cours, attendez que la demande précédente se termine', 'No conversation found to summarize.': 'Aucune conversation trouvée à résumer.', + 'Summary path already exists and is not a generated summary: {{path}}': + "Le chemin du résumé existe déjà et n'est pas un résumé généré : {{path}}", 'Summary path must be within the project root.': 'Le chemin du résumé doit se trouver dans la racine du projet.', 'Failed to generate project context summary: {{error}}': diff --git a/packages/cli/src/i18n/locales/ja.js b/packages/cli/src/i18n/locales/ja.js index 22b7f6b0bc2..5991e060040 100644 --- a/packages/cli/src/i18n/locales/ja.js +++ b/packages/cli/src/i18n/locales/ja.js @@ -766,6 +766,8 @@ export default { 'Already generating summary, wait for previous request to complete': 'サマリー生成中です。前のリクエストの完了をお待ちください', 'No conversation found to summarize.': '要約する会話が見つかりません', + 'Summary path already exists and is not a generated summary: {{path}}': + 'サマリーパスは既に存在し、生成されたサマリーではありません: {{path}}', 'Summary path must be within the project root.': 'サマリーパスはプロジェクトルート内にある必要があります', 'Failed to generate project context summary: {{error}}': diff --git a/packages/cli/src/i18n/locales/pt.js b/packages/cli/src/i18n/locales/pt.js index e59d65f0b2f..52d64168334 100644 --- a/packages/cli/src/i18n/locales/pt.js +++ b/packages/cli/src/i18n/locales/pt.js @@ -1000,6 +1000,8 @@ export default { 'Já gerando resumo, aguarde a conclusão da solicitação anterior', 'No conversation found to summarize.': 'Nenhuma conversa encontrada para resumir.', + 'Summary path already exists and is not a generated summary: {{path}}': + 'O caminho do resumo já existe e não é um resumo gerado: {{path}}', 'Summary path must be within the project root.': 'O caminho do resumo deve estar dentro da raiz do projeto.', 'Failed to generate project context summary: {{error}}': diff --git a/packages/cli/src/i18n/locales/ru.js b/packages/cli/src/i18n/locales/ru.js index 59e672497f7..7faea00104a 100644 --- a/packages/cli/src/i18n/locales/ru.js +++ b/packages/cli/src/i18n/locales/ru.js @@ -1008,6 +1008,8 @@ export default { 'Генерация сводки уже выполняется, дождитесь завершения предыдущего запроса', 'No conversation found to summarize.': 'Не найдено диалогов для создания сводки.', + 'Summary path already exists and is not a generated summary: {{path}}': + 'Путь сводки уже существует и не является сгенерированной сводкой: {{path}}', 'Summary path must be within the project root.': 'Путь сводки должен находиться в корне проекта.', 'Failed to generate project context summary: {{error}}': diff --git a/packages/cli/src/i18n/locales/zh-TW.js b/packages/cli/src/i18n/locales/zh-TW.js index 58454ff6467..2ffc3f53440 100644 --- a/packages/cli/src/i18n/locales/zh-TW.js +++ b/packages/cli/src/i18n/locales/zh-TW.js @@ -1302,6 +1302,8 @@ export default { 'Already generating summary, wait for previous request to complete': '正在生成摘要,請等待上一個請求完成', 'No conversation found to summarize.': '未找到要總結的對話', + 'Summary path already exists and is not a generated summary: {{path}}': + '摘要路徑已存在且非產生的摘要:{{path}}', 'Summary path must be within the project root.': '摘要路徑必須在專案根目錄內', 'Failed to generate project context summary: {{error}}': '生成項目上下文摘要失敗:{{error}}', diff --git a/packages/cli/src/i18n/locales/zh.js b/packages/cli/src/i18n/locales/zh.js index 036e0c1eb36..d7437f92bfc 100644 --- a/packages/cli/src/i18n/locales/zh.js +++ b/packages/cli/src/i18n/locales/zh.js @@ -1412,6 +1412,8 @@ export default { 'Already generating summary, wait for previous request to complete': '正在生成摘要,请等待上一个请求完成', 'No conversation found to summarize.': '未找到要总结的对话', + 'Summary path already exists and is not a generated summary: {{path}}': + '摘要路径已存在且不是生成的摘要:{{path}}', 'Summary path must be within the project root.': '摘要路径必须在项目根目录内', 'Failed to generate project context summary: {{error}}': '生成项目上下文摘要失败:{{error}}', diff --git a/packages/cli/src/ui/commands/summaryCommand.test.ts b/packages/cli/src/ui/commands/summaryCommand.test.ts index 5629d229002..7fe60a4e5e5 100644 --- a/packages/cli/src/ui/commands/summaryCommand.test.ts +++ b/packages/cli/src/ui/commands/summaryCommand.test.ts @@ -196,4 +196,37 @@ describe('summaryCommand custom export path', () => { expect(result).toMatchObject({ type: 'message', messageType: 'error' }); expect(await dirExists(path.join(projectRoot, 'reports'))).toBe(false); }); + + it('expands a leading ~ and rejects it when outside the project root', async () => { + const result = await run('~/summary-tilde-leak.md'); + expect(result).toMatchObject({ type: 'message', messageType: 'error' }); + expect(result.content).toContain('within the project root'); + expect(runSideQuery).not.toHaveBeenCalled(); + // The unexpanded argument must not create a literal "~" directory. + expect(await dirExists(path.join(projectRoot, '~'))).toBe(false); + }); + + it('refuses to overwrite an existing file that is not a generated summary', async () => { + const target = path.join(projectRoot, 'IMPORTANT.md'); + await fs.writeFile(target, 'precious content', 'utf8'); + const result = await run('IMPORTANT.md'); + expect(result).toMatchObject({ type: 'message', messageType: 'error' }); + expect(result.content).toContain('already exists'); + expect(await fs.readFile(target, 'utf8')).toBe('precious content'); + expect(runSideQuery).not.toHaveBeenCalled(); + }); + + it('overwrites a previously generated summary', async () => { + const target = path.join(projectRoot, 'summary.md'); + await fs.writeFile( + target, + 'old body\n\n---\n\n## Summary Metadata\n**Update time**: old\n', + 'utf8', + ); + const result = await run('summary.md'); + expect(result).toMatchObject({ type: 'message', messageType: 'info' }); + const written = await fs.readFile(target, 'utf8'); + expect(written).toContain('SUMMARY BODY'); + expect(written).not.toContain('old body'); + }); }); diff --git a/packages/cli/src/ui/commands/summaryCommand.ts b/packages/cli/src/ui/commands/summaryCommand.ts index d8656e2e4dd..119c12f61e9 100644 --- a/packages/cli/src/ui/commands/summaryCommand.ts +++ b/packages/cli/src/ui/commands/summaryCommand.ts @@ -14,6 +14,7 @@ import { import { getProjectSummaryPrompt, isSubpath, + resolvePath, runSideQuery, } from '@qwen-code/qwen-code-core'; import type { HistoryItemSummary } from '../types.js'; @@ -173,9 +174,9 @@ export const summaryCommand: SlashCommand = { }; } - const resolved = path.isAbsolute(customPath) - ? customPath - : path.resolve(projectRoot, customPath); + // resolvePath expands a leading ~ so the path is honest before the + // containment check rejects it, instead of creating a literal "~" dir. + const resolved = resolvePath(projectRoot, customPath); if (!isSubpath(projectRoot, resolved)) { throw new Error(t('Summary path must be within the project root.')); @@ -222,6 +223,24 @@ export const summaryCommand: SlashCommand = { : path.relative(projectRoot, summaryPath) ).replaceAll(path.sep, '/'); + // Refuse to clobber a pre-existing file that is not a generated summary + // (e.g. a mistyped path such as `package.json`). A generated summary + // carries a `## Summary Metadata` footer, so regenerating one is allowed. + const existingStat = await fsPromises.stat(summaryPath).catch(() => null); + if (existingStat?.isFile()) { + const existing = await fsPromises + .readFile(summaryPath, 'utf8') + .catch(() => ''); + if (!existing.includes('## Summary Metadata')) { + throw new Error( + t( + 'Summary path already exists and is not a generated summary: {{path}}', + { path: filePathForDisplay }, + ), + ); + } + } + return { summaryPath, filePathForDisplay }; }; From ddee5164a9b723e369278d40bc8205801a708267 Mon Sep 17 00:00:00 2001 From: Qwen Code Autofix Date: Fri, 31 Jul 2026 19:36:55 +0000 Subject: [PATCH 11/17] fix(cli): re-validate appended default filename for symlink escape in /summary (#8116) --- .../src/ui/commands/summaryCommand.test.ts | 43 +++++++++++++++++++ .../cli/src/ui/commands/summaryCommand.ts | 40 +++++++++++------ 2 files changed, 70 insertions(+), 13 deletions(-) diff --git a/packages/cli/src/ui/commands/summaryCommand.test.ts b/packages/cli/src/ui/commands/summaryCommand.test.ts index 7fe60a4e5e5..0708d77a923 100644 --- a/packages/cli/src/ui/commands/summaryCommand.test.ts +++ b/packages/cli/src/ui/commands/summaryCommand.test.ts @@ -153,6 +153,23 @@ describe('summaryCommand custom export path', () => { expect(runSideQuery).not.toHaveBeenCalled(); }); + it('allows a symlink that resolves inside the project root', async () => { + // Symlink creation typically requires elevated privileges on Windows. + if (process.platform === 'win32') { + return; + } + await fs.mkdir(path.join(projectRoot, 'real-dir')); + await fs.symlink( + path.join(projectRoot, 'real-dir'), + path.join(projectRoot, 'internal-link'), + ); + const result = await run('internal-link/summary.md'); + expect(result).toMatchObject({ type: 'message', messageType: 'info' }); + expect( + await fileExists(path.join(projectRoot, 'real-dir', 'summary.md')), + ).toBe(true); + }); + it('rejects a path that escapes the project root via a symlink', async () => { // Symlink creation typically requires elevated privileges on Windows. if (process.platform === 'win32') { @@ -190,6 +207,32 @@ describe('summaryCommand custom export path', () => { expect(runSideQuery).not.toHaveBeenCalled(); }); + it('rejects a directory whose appended default filename is a symlink escaping the project root', async () => { + if (process.platform === 'win32') { + return; + } + const outside = await fs.mkdtemp( + path.join(os.tmpdir(), 'summary-outside-'), + ); + try { + const docsDir = path.join(projectRoot, 'docs'); + await fs.mkdir(docsDir); + await fs.symlink( + path.join(outside, 'evil-target.md'), + path.join(docsDir, 'PROJECT_SUMMARY.md'), + ); + const result = await run('docs'); + expect(result).toMatchObject({ type: 'message', messageType: 'error' }); + expect(result.content).toContain('within the project root'); + expect(await fileExists(path.join(outside, 'evil-target.md'))).toBe( + false, + ); + expect(runSideQuery).not.toHaveBeenCalled(); + } finally { + await fs.rm(outside, { recursive: true, force: true }); + } + }); + it('does not create the target directory when generation fails', async () => { vi.mocked(runSideQuery).mockRejectedValueOnce(new Error('rate limit')); const result = await run('reports/2026/summary.md'); diff --git a/packages/cli/src/ui/commands/summaryCommand.ts b/packages/cli/src/ui/commands/summaryCommand.ts index 119c12f61e9..27fdb22fcb4 100644 --- a/packages/cli/src/ui/commands/summaryCommand.ts +++ b/packages/cli/src/ui/commands/summaryCommand.ts @@ -42,6 +42,25 @@ const realpathNearestExisting = async (targetPath: string): Promise => { } }; +// A broken symlink (target absent) makes realpathNearestExisting walk up the +// lexical path and miss the escape — detect symlinks at the leaf itself. +const assertLeafNotSymlinkEscape = async ( + filePath: string, + realProjectRoot: string, +): Promise => { + const stat = await fsPromises.lstat(filePath).catch(() => null); + if (stat?.isSymbolicLink()) { + const linkTarget = await fsPromises.readlink(filePath); + const resolvedTarget = path.isAbsolute(linkTarget) + ? linkTarget + : path.resolve(path.dirname(filePath), linkTarget); + const realTarget = await realpathNearestExisting(resolvedTarget); + if (!isSubpath(realProjectRoot, realTarget)) { + throw new Error(t('Summary path must be within the project root.')); + } + } +}; + export const summaryCommand: SlashCommand = { name: 'summary', get description() { @@ -186,19 +205,7 @@ export const summaryCommand: SlashCommand = { // root may point outside it. Re-check containment on the real paths. const realProjectRoot = await fsPromises.realpath(projectRoot); - // A broken symlink (target absent) makes realpathNearestExisting walk up - // to the parent and miss the escape — detect symlinks at the leaf. - const leafStat = await fsPromises.lstat(resolved).catch(() => null); - if (leafStat?.isSymbolicLink()) { - const linkTarget = await fsPromises.readlink(resolved); - const resolvedTarget = path.isAbsolute(linkTarget) - ? linkTarget - : path.resolve(path.dirname(resolved), linkTarget); - const realTarget = await realpathNearestExisting(resolvedTarget); - if (!isSubpath(realProjectRoot, realTarget)) { - throw new Error(t('Summary path must be within the project root.')); - } - } + await assertLeafNotSymlinkEscape(resolved, realProjectRoot); const realResolved = await realpathNearestExisting(resolved); if (!isSubpath(realProjectRoot, realResolved)) { @@ -217,6 +224,13 @@ export const summaryCommand: SlashCommand = { ? path.join(resolved, 'PROJECT_SUMMARY.md') : resolved; + // The appended filename may itself be a symlink escaping the project + // root (e.g. a malicious repo tracks docs/PROJECT_SUMMARY.md -> /etc/ + // passwd). Re-check the leaf after the join. + if (isDir) { + await assertLeafNotSymlinkEscape(summaryPath, realProjectRoot); + } + const filePathForDisplay = ( path.isAbsolute(customPath) ? summaryPath From 610984025d74f974e30d293eb885b3250291d99b Mon Sep 17 00:00:00 2001 From: Qwen Code Bot Date: Sat, 1 Aug 2026 01:27:15 +0000 Subject: [PATCH 12/17] fix(cli): harden /summary symlink chain walk, file mode, and overwrite guard (#8116) --- .../src/ui/commands/summaryCommand.test.ts | 234 ++++++++++++------ .../cli/src/ui/commands/summaryCommand.ts | 67 +++-- 2 files changed, 216 insertions(+), 85 deletions(-) diff --git a/packages/cli/src/ui/commands/summaryCommand.test.ts b/packages/cli/src/ui/commands/summaryCommand.test.ts index 0708d77a923..b209a4c5802 100644 --- a/packages/cli/src/ui/commands/summaryCommand.test.ts +++ b/packages/cli/src/ui/commands/summaryCommand.test.ts @@ -101,6 +101,24 @@ describe('summaryCommand custom export path', () => { } }); + it('overwrites a hand-written file at the default path', async () => { + const qwenDir = path.join(projectRoot, '.qwen'); + await fs.mkdir(qwenDir, { recursive: true }); + await fs.writeFile( + path.join(qwenDir, 'PROJECT_SUMMARY.md'), + 'hand-written notes', + 'utf8', + ); + const result = await run(''); + expect(result).toMatchObject({ type: 'message', messageType: 'info' }); + const written = await fs.readFile( + path.join(qwenDir, 'PROJECT_SUMMARY.md'), + 'utf8', + ); + expect(written).toContain('SUMMARY BODY'); + expect(written).not.toContain('hand-written notes'); + }); + it('writes a relative file path as-is', async () => { const result = await run('notes.md'); expect(await fileExists(path.join(projectRoot, 'notes.md'))).toBe(true); @@ -153,85 +171,136 @@ describe('summaryCommand custom export path', () => { expect(runSideQuery).not.toHaveBeenCalled(); }); - it('allows a symlink that resolves inside the project root', async () => { - // Symlink creation typically requires elevated privileges on Windows. - if (process.platform === 'win32') { - return; - } - await fs.mkdir(path.join(projectRoot, 'real-dir')); - await fs.symlink( - path.join(projectRoot, 'real-dir'), - path.join(projectRoot, 'internal-link'), - ); - const result = await run('internal-link/summary.md'); - expect(result).toMatchObject({ type: 'message', messageType: 'info' }); - expect( - await fileExists(path.join(projectRoot, 'real-dir', 'summary.md')), - ).toBe(true); - }); + it.skipIf(process.platform === 'win32')( + 'allows a symlink that resolves inside the project root', + async () => { + await fs.mkdir(path.join(projectRoot, 'real-dir')); + await fs.symlink( + path.join(projectRoot, 'real-dir'), + path.join(projectRoot, 'internal-link'), + ); + const result = await run('internal-link/summary.md'); + expect(result).toMatchObject({ type: 'message', messageType: 'info' }); + expect( + await fileExists(path.join(projectRoot, 'real-dir', 'summary.md')), + ).toBe(true); + }, + ); - it('rejects a path that escapes the project root via a symlink', async () => { - // Symlink creation typically requires elevated privileges on Windows. - if (process.platform === 'win32') { - return; - } - const outside = await fs.mkdtemp( - path.join(os.tmpdir(), 'summary-outside-'), - ); - try { - await fs.symlink(outside, path.join(projectRoot, 'link')); - const result = await run('link/leak.md'); + it.skipIf(process.platform === 'win32')( + 'rejects a path that escapes the project root via a symlink', + async () => { + const outside = await fs.mkdtemp( + path.join(os.tmpdir(), 'summary-outside-'), + ); + try { + await fs.symlink(outside, path.join(projectRoot, 'link')); + const result = await run('link/leak.md'); + expect(result).toMatchObject({ + type: 'message', + messageType: 'error', + }); + expect(result.content).toContain('within the project root'); + expect(await fileExists(path.join(outside, 'leak.md'))).toBe(false); + expect(runSideQuery).not.toHaveBeenCalled(); + } finally { + await fs.rm(outside, { recursive: true, force: true }); + } + }, + ); + + it.skipIf(process.platform === 'win32')( + 'rejects a broken symlink whose target is outside the project root', + async () => { + const outsideTarget = path.join( + os.tmpdir(), + `summary-broken-${Date.now()}`, + 'leak.md', + ); + await fs.symlink(outsideTarget, path.join(projectRoot, 'broken-link')); + const result = await run('broken-link'); expect(result).toMatchObject({ type: 'message', messageType: 'error' }); expect(result.content).toContain('within the project root'); - expect(await fileExists(path.join(outside, 'leak.md'))).toBe(false); + expect(await fileExists(outsideTarget)).toBe(false); expect(runSideQuery).not.toHaveBeenCalled(); - } finally { - await fs.rm(outside, { recursive: true, force: true }); - } - }); - - it('rejects a broken symlink whose target is outside the project root', async () => { - if (process.platform === 'win32') { - return; - } - const outsideTarget = path.join( - os.tmpdir(), - `summary-broken-${Date.now()}`, - 'leak.md', - ); - await fs.symlink(outsideTarget, path.join(projectRoot, 'broken-link')); - const result = await run('broken-link'); - expect(result).toMatchObject({ type: 'message', messageType: 'error' }); - expect(result.content).toContain('within the project root'); - expect(await fileExists(outsideTarget)).toBe(false); - expect(runSideQuery).not.toHaveBeenCalled(); - }); + }, + ); - it('rejects a directory whose appended default filename is a symlink escaping the project root', async () => { - if (process.platform === 'win32') { - return; - } - const outside = await fs.mkdtemp( - path.join(os.tmpdir(), 'summary-outside-'), - ); - try { - const docsDir = path.join(projectRoot, 'docs'); - await fs.mkdir(docsDir); - await fs.symlink( - path.join(outside, 'evil-target.md'), - path.join(docsDir, 'PROJECT_SUMMARY.md'), + it.skipIf(process.platform === 'win32')( + 'rejects a multi-link symlink chain that escapes the project root', + async () => { + const outsideTarget = path.join( + os.tmpdir(), + `summary-chain-${Date.now()}`, + 'evil.md', ); - const result = await run('docs'); + // link1 -> link2 (relative, inside root), link2 -> outside (absolute, + // broken). The old single-readlink check saw only the inside-root link2 + // and passed containment; the full-chain walk reaches the outside target. + await fs.symlink('link2', path.join(projectRoot, 'link1')); + await fs.symlink(outsideTarget, path.join(projectRoot, 'link2')); + const result = await run('link1'); expect(result).toMatchObject({ type: 'message', messageType: 'error' }); expect(result.content).toContain('within the project root'); - expect(await fileExists(path.join(outside, 'evil-target.md'))).toBe( - false, - ); + expect(await fileExists(outsideTarget)).toBe(false); expect(runSideQuery).not.toHaveBeenCalled(); - } finally { - await fs.rm(outside, { recursive: true, force: true }); - } - }); + }, + ); + + it.skipIf(process.platform === 'win32')( + 'rejects a symlink with a relative target escaping the project root', + async () => { + const outsideDir = await fs.mkdtemp( + path.join(os.tmpdir(), 'summary-outside-'), + ); + try { + // Git stores symlink targets verbatim, so a committed + // `ln -s ../outside/leak.md` recreates a relative target on checkout. + await fs.symlink( + path.join('..', path.basename(outsideDir), 'leak.md'), + path.join(projectRoot, 'rel-link'), + ); + const result = await run('rel-link'); + expect(result).toMatchObject({ + type: 'message', + messageType: 'error', + }); + expect(result.content).toContain('within the project root'); + expect(runSideQuery).not.toHaveBeenCalled(); + } finally { + await fs.rm(outsideDir, { recursive: true, force: true }); + } + }, + ); + + it.skipIf(process.platform === 'win32')( + 'rejects a directory whose appended default filename is a symlink escaping the project root', + async () => { + const outside = await fs.mkdtemp( + path.join(os.tmpdir(), 'summary-outside-'), + ); + try { + const docsDir = path.join(projectRoot, 'docs'); + await fs.mkdir(docsDir); + await fs.symlink( + path.join(outside, 'evil-target.md'), + path.join(docsDir, 'PROJECT_SUMMARY.md'), + ); + const result = await run('docs'); + expect(result).toMatchObject({ + type: 'message', + messageType: 'error', + }); + expect(result.content).toContain('within the project root'); + expect(await fileExists(path.join(outside, 'evil-target.md'))).toBe( + false, + ); + expect(runSideQuery).not.toHaveBeenCalled(); + } finally { + await fs.rm(outside, { recursive: true, force: true }); + } + }, + ); it('does not create the target directory when generation fails', async () => { vi.mocked(runSideQuery).mockRejectedValueOnce(new Error('rate limit')); @@ -259,6 +328,22 @@ describe('summaryCommand custom export path', () => { expect(runSideQuery).not.toHaveBeenCalled(); }); + it('refuses to overwrite a file that merely mentions Summary Metadata in prose', async () => { + const target = path.join(projectRoot, 'DESIGN.md'); + await fs.writeFile( + target, + 'The summary file ends with a `## Summary Metadata` footer.\n', + 'utf8', + ); + const result = await run('DESIGN.md'); + expect(result).toMatchObject({ type: 'message', messageType: 'error' }); + expect(result.content).toContain('already exists'); + expect(await fs.readFile(target, 'utf8')).toContain( + '`## Summary Metadata`', + ); + expect(runSideQuery).not.toHaveBeenCalled(); + }); + it('overwrites a previously generated summary', async () => { const target = path.join(projectRoot, 'summary.md'); await fs.writeFile( @@ -272,4 +357,13 @@ describe('summaryCommand custom export path', () => { expect(written).toContain('SUMMARY BODY'); expect(written).not.toContain('old body'); }); + + it.skipIf(process.platform === 'win32')( + 'creates a custom-path summary with mode 0o600', + async () => { + await run('private-notes.md'); + const stat = await fs.stat(path.join(projectRoot, 'private-notes.md')); + expect(stat.mode & 0o777).toBe(0o600); + }, + ); }); diff --git a/packages/cli/src/ui/commands/summaryCommand.ts b/packages/cli/src/ui/commands/summaryCommand.ts index 27fdb22fcb4..d16baf04bee 100644 --- a/packages/cli/src/ui/commands/summaryCommand.ts +++ b/packages/cli/src/ui/commands/summaryCommand.ts @@ -42,22 +42,31 @@ const realpathNearestExisting = async (targetPath: string): Promise => { } }; -// A broken symlink (target absent) makes realpathNearestExisting walk up the -// lexical path and miss the escape — detect symlinks at the leaf itself. +// Follows the full symlink chain at filePath (with cycle detection) and +// verifies the final target is contained within realProjectRoot. A broken +// chain whose terminal target is absent still resolves via +// realpathNearestExisting, catching multi-hop escapes. const assertLeafNotSymlinkEscape = async ( filePath: string, realProjectRoot: string, ): Promise => { - const stat = await fsPromises.lstat(filePath).catch(() => null); - if (stat?.isSymbolicLink()) { - const linkTarget = await fsPromises.readlink(filePath); - const resolvedTarget = path.isAbsolute(linkTarget) - ? linkTarget - : path.resolve(path.dirname(filePath), linkTarget); - const realTarget = await realpathNearestExisting(resolvedTarget); - if (!isSubpath(realProjectRoot, realTarget)) { + let current = filePath; + const seen = new Set(); + for (;;) { + const linkStat = await fsPromises.lstat(current).catch(() => null); + if (!linkStat?.isSymbolicLink()) break; + if (seen.has(current)) { throw new Error(t('Summary path must be within the project root.')); } + seen.add(current); + const linkTarget = await fsPromises.readlink(current); + current = path.isAbsolute(linkTarget) + ? linkTarget + : path.resolve(path.dirname(current), linkTarget); + } + const realTarget = await realpathNearestExisting(current); + if (!isSubpath(realProjectRoot, realTarget)) { + throw new Error(t('Summary path must be within the project root.')); } }; @@ -181,15 +190,22 @@ export const summaryCommand: SlashCommand = { const resolveSummaryTarget = async (): Promise<{ summaryPath: string; filePathForDisplay: string; + isDefaultTarget: boolean; + realProjectRoot: string; }> => { const projectRoot = config.getProjectRoot(); const customPath = args?.trim(); if (!customPath) { const qwenDir = path.join(projectRoot, '.qwen'); + // The default target always overwrites: regenerating the summary is + // the command's purpose, and .qwen/PROJECT_SUMMARY.md is a generated + // artifact — not user prose the overwrite guard protects. return { summaryPath: path.join(qwenDir, 'PROJECT_SUMMARY.md'), filePathForDisplay: '.qwen/PROJECT_SUMMARY.md', + isDefaultTarget: true, + realProjectRoot: await fsPromises.realpath(projectRoot), }; } @@ -245,7 +261,7 @@ export const summaryCommand: SlashCommand = { const existing = await fsPromises .readFile(summaryPath, 'utf8') .catch(() => ''); - if (!existing.includes('## Summary Metadata')) { + if (!/\n---\n\n## Summary Metadata\n/.test(existing)) { throw new Error( t( 'Summary path already exists and is not a generated summary: {{path}}', @@ -255,12 +271,22 @@ export const summaryCommand: SlashCommand = { } } - return { summaryPath, filePathForDisplay }; + return { + summaryPath, + filePathForDisplay, + isDefaultTarget: false, + realProjectRoot, + }; }; const saveSummaryToDisk = async ( markdownSummary: string, - target: { summaryPath: string; filePathForDisplay: string }, + target: { + summaryPath: string; + filePathForDisplay: string; + isDefaultTarget: boolean; + realProjectRoot: string; + }, ): Promise<{ filePathForDisplay: string; fullPath: string; @@ -273,11 +299,22 @@ export const summaryCommand: SlashCommand = { **Update time**: ${new Date().toISOString()} `; + // Re-check the leaf right before writing to narrow the TOCTOU window + // between resolveSummaryTarget (pre-LLM) and the write (post-LLM). + await assertLeafNotSymlinkEscape( + target.summaryPath, + target.realProjectRoot, + ); + await fsPromises.mkdir(path.dirname(target.summaryPath), { recursive: true, - mode: 0o700, + ...(target.isDefaultTarget ? { mode: 0o700 } : {}), + }); + await fsPromises.writeFile(target.summaryPath, summaryContent, { + encoding: 'utf8', + mode: 0o600, }); - await fsPromises.writeFile(target.summaryPath, summaryContent, 'utf8'); + await fsPromises.chmod(target.summaryPath, 0o600).catch(() => undefined); return { filePathForDisplay: target.filePathForDisplay, From 578baa1409a10875a63569ca91000863bf151780 Mon Sep 17 00:00:00 2001 From: Qwen Code Bot Date: Sat, 1 Aug 2026 04:01:32 +0000 Subject: [PATCH 13/17] fix(cli): address review feedback on /summary custom path (#8116) - Fix CRLF false-negative in overwrite guard by normalizing line endings - Allow overwriting empty pre-created files (zero-length bypass) - Detect trailing separator on existing file and report clearly - Log chmod failures via debugLogger matching exportCommand convention - Add comment explaining mkdir mode asymmetry - Update docs: /summary usage table and custom-path welcome-back note - Add i18n key for trailing-separator error in all 9 locales - Add tests for CRLF, empty file, and trailing separator cases --- docs/users/features/commands.md | 6 +++- packages/cli/src/i18n/locales/ca.js | 2 ++ packages/cli/src/i18n/locales/de.js | 2 ++ packages/cli/src/i18n/locales/en.js | 2 ++ packages/cli/src/i18n/locales/fr.js | 2 ++ packages/cli/src/i18n/locales/ja.js | 2 ++ packages/cli/src/i18n/locales/pt.js | 2 ++ packages/cli/src/i18n/locales/ru.js | 2 ++ packages/cli/src/i18n/locales/zh-TW.js | 2 ++ packages/cli/src/i18n/locales/zh.js | 2 ++ .../src/ui/commands/summaryCommand.test.ts | 31 +++++++++++++++++ .../cli/src/ui/commands/summaryCommand.ts | 33 +++++++++++++------ 12 files changed, 77 insertions(+), 11 deletions(-) diff --git a/docs/users/features/commands.md b/docs/users/features/commands.md index ecb9ca29f3a..ddcab6e8055 100644 --- a/docs/users/features/commands.md +++ b/docs/users/features/commands.md @@ -21,7 +21,7 @@ These commands help you save, restore, and summarize work progress. | Command | Description | Usage Examples | | ---------------- | ------------------------------------------------------------------------ | ------------------------------------------------------------- | | `/init` | Analyze current directory and create initial context file | `/init` | -| `/summary` | Generate project summary based on conversation history | `/summary` | +| `/summary` | Generate project summary based on conversation history | `/summary` or `/summary docs/my-summary.md` | | `/compress` | Replace chat history with summary to save Tokens | `/compress` or `/summarize` | | `/compress-fast` | Fast compression without AI — strips old tool outputs and thinking parts | `/compress-fast` | | `/resume` | Resume a previous conversation session | `/resume` or `/continue` | @@ -38,6 +38,10 @@ These commands help you save, restore, and summarize work progress. > > `/summarize` is an alias for `/compress` (it compresses chat history — a destructive operation). To generate a non-destructive project summary instead, use `/summary`. +> [!note] +> +> `/summary` accepts an optional `[path]` argument to save the summary to a custom location within the project root. Without an argument, it saves to `.qwen/PROJECT_SUMMARY.md`. Custom-path summaries are not detected by the welcome-back flow (`ui.enableWelcomeBack`), which only reads the default `.qwen/PROJECT_SUMMARY.md` location. + ### 1.2 Interface and Workspace Control Commands for adjusting interface appearance and work environment. diff --git a/packages/cli/src/i18n/locales/ca.js b/packages/cli/src/i18n/locales/ca.js index 76db6055b74..1133e3b2b87 100644 --- a/packages/cli/src/i18n/locales/ca.js +++ b/packages/cli/src/i18n/locales/ca.js @@ -1124,6 +1124,8 @@ export default { 'El camí del resum ja existeix i no és un resum generat: {{path}}', 'Summary path must be within the project root.': 'El camí del resum ha de ser dins de la arrel del projecte.', + 'Summary path ends with a separator but is an existing file: {{path}}': + 'El camí del resum acaba amb un separador però és un fitxer existent: {{path}}', 'Failed to generate project context summary: {{error}}': 'Error en generar el resum del context del projecte: {{error}}', 'Saved project summary to {{filePathForDisplay}}.': diff --git a/packages/cli/src/i18n/locales/de.js b/packages/cli/src/i18n/locales/de.js index d322aebf072..d7b18927234 100644 --- a/packages/cli/src/i18n/locales/de.js +++ b/packages/cli/src/i18n/locales/de.js @@ -999,6 +999,8 @@ export default { 'Der Zusammenfassungspfad existiert bereits und ist keine generierte Zusammenfassung: {{path}}', 'Summary path must be within the project root.': 'Der Zusammenfassungspfad muss sich im Projektstammverzeichnis befinden.', + 'Summary path ends with a separator but is an existing file: {{path}}': + 'Der Zusammenfassungspfad endet mit einem Trennzeichen, ist aber eine vorhandene Datei: {{path}}', 'Failed to generate project context summary: {{error}}': 'Fehler beim Generieren der Projektkontextzusammenfassung: {{error}}', 'Saved project summary to {{filePathForDisplay}}.': diff --git a/packages/cli/src/i18n/locales/en.js b/packages/cli/src/i18n/locales/en.js index 01accc1a052..c6d7f650dfb 100644 --- a/packages/cli/src/i18n/locales/en.js +++ b/packages/cli/src/i18n/locales/en.js @@ -1476,6 +1476,8 @@ export default { 'Summary path already exists and is not a generated summary: {{path}}', 'Summary path must be within the project root.': 'Summary path must be within the project root.', + 'Summary path ends with a separator but is an existing file: {{path}}': + 'Summary path ends with a separator but is an existing file: {{path}}', 'Failed to generate project context summary: {{error}}': 'Failed to generate project context summary: {{error}}', 'Saved project summary to {{filePathForDisplay}}.': diff --git a/packages/cli/src/i18n/locales/fr.js b/packages/cli/src/i18n/locales/fr.js index fd472f78b99..df1d704b28c 100644 --- a/packages/cli/src/i18n/locales/fr.js +++ b/packages/cli/src/i18n/locales/fr.js @@ -1130,6 +1130,8 @@ export default { "Le chemin du résumé existe déjà et n'est pas un résumé généré : {{path}}", 'Summary path must be within the project root.': 'Le chemin du résumé doit se trouver dans la racine du projet.', + 'Summary path ends with a separator but is an existing file: {{path}}': + 'Le chemin du résumé se termine par un séparateur mais est un fichier existant : {{path}}', 'Failed to generate project context summary: {{error}}': 'Échec de la génération du résumé du contexte du projet : {{error}}', 'Saved project summary to {{filePathForDisplay}}.': diff --git a/packages/cli/src/i18n/locales/ja.js b/packages/cli/src/i18n/locales/ja.js index 5991e060040..38dc1d09da5 100644 --- a/packages/cli/src/i18n/locales/ja.js +++ b/packages/cli/src/i18n/locales/ja.js @@ -770,6 +770,8 @@ export default { 'サマリーパスは既に存在し、生成されたサマリーではありません: {{path}}', 'Summary path must be within the project root.': 'サマリーパスはプロジェクトルート内にある必要があります', + 'Summary path ends with a separator but is an existing file: {{path}}': + 'サマリーパスは区切り文字で終わっていますが、既存のファイルです: {{path}}', 'Failed to generate project context summary: {{error}}': 'プロジェクトコンテキストサマリーの生成に失敗: {{error}}', 'Saved project summary to {{filePathForDisplay}}.': diff --git a/packages/cli/src/i18n/locales/pt.js b/packages/cli/src/i18n/locales/pt.js index 52d64168334..053cbd427b1 100644 --- a/packages/cli/src/i18n/locales/pt.js +++ b/packages/cli/src/i18n/locales/pt.js @@ -1004,6 +1004,8 @@ export default { 'O caminho do resumo já existe e não é um resumo gerado: {{path}}', 'Summary path must be within the project root.': 'O caminho do resumo deve estar dentro da raiz do projeto.', + 'Summary path ends with a separator but is an existing file: {{path}}': + 'O caminho do resumo termina com um separador, mas é um arquivo existente: {{path}}', 'Failed to generate project context summary: {{error}}': 'Falha ao gerar resumo do contexto do projeto: {{error}}', 'Saved project summary to {{filePathForDisplay}}.': diff --git a/packages/cli/src/i18n/locales/ru.js b/packages/cli/src/i18n/locales/ru.js index 7faea00104a..173ebe168b8 100644 --- a/packages/cli/src/i18n/locales/ru.js +++ b/packages/cli/src/i18n/locales/ru.js @@ -1012,6 +1012,8 @@ export default { 'Путь сводки уже существует и не является сгенерированной сводкой: {{path}}', 'Summary path must be within the project root.': 'Путь сводки должен находиться в корне проекта.', + 'Summary path ends with a separator but is an existing file: {{path}}': + 'Путь сводки заканчивается разделителем, но является существующим файлом: {{path}}', 'Failed to generate project context summary: {{error}}': 'Не удалось сгенерировать сводку контекста проекта: {{error}}', 'Saved project summary to {{filePathForDisplay}}.': diff --git a/packages/cli/src/i18n/locales/zh-TW.js b/packages/cli/src/i18n/locales/zh-TW.js index 2ffc3f53440..d7eae6183ed 100644 --- a/packages/cli/src/i18n/locales/zh-TW.js +++ b/packages/cli/src/i18n/locales/zh-TW.js @@ -1305,6 +1305,8 @@ export default { 'Summary path already exists and is not a generated summary: {{path}}': '摘要路徑已存在且非產生的摘要:{{path}}', 'Summary path must be within the project root.': '摘要路徑必須在專案根目錄內', + 'Summary path ends with a separator but is an existing file: {{path}}': + '摘要路徑以分隔符結尾,但是一個已存在的檔案:{{path}}', 'Failed to generate project context summary: {{error}}': '生成項目上下文摘要失敗:{{error}}', 'Saved project summary to {{filePathForDisplay}}.': diff --git a/packages/cli/src/i18n/locales/zh.js b/packages/cli/src/i18n/locales/zh.js index d7437f92bfc..eff8125ec8a 100644 --- a/packages/cli/src/i18n/locales/zh.js +++ b/packages/cli/src/i18n/locales/zh.js @@ -1415,6 +1415,8 @@ export default { 'Summary path already exists and is not a generated summary: {{path}}': '摘要路径已存在且不是生成的摘要:{{path}}', 'Summary path must be within the project root.': '摘要路径必须在项目根目录内', + 'Summary path ends with a separator but is an existing file: {{path}}': + '摘要路径以分隔符结尾,但是一个已存在的文件:{{path}}', 'Failed to generate project context summary: {{error}}': '生成项目上下文摘要失败:{{error}}', 'Saved project summary to {{filePathForDisplay}}.': diff --git a/packages/cli/src/ui/commands/summaryCommand.test.ts b/packages/cli/src/ui/commands/summaryCommand.test.ts index b209a4c5802..8f45daff10b 100644 --- a/packages/cli/src/ui/commands/summaryCommand.test.ts +++ b/packages/cli/src/ui/commands/summaryCommand.test.ts @@ -366,4 +366,35 @@ describe('summaryCommand custom export path', () => { expect(stat.mode & 0o777).toBe(0o600); }, ); + + it('overwrites a previously generated summary with CRLF line endings', async () => { + const target = path.join(projectRoot, 'crlf-summary.md'); + await fs.writeFile( + target, + 'old body\r\n\r\n---\r\n\r\n## Summary Metadata\r\n**Update time**: old\r\n', + 'utf8', + ); + const result = await run('crlf-summary.md'); + expect(result).toMatchObject({ type: 'message', messageType: 'info' }); + const written = await fs.readFile(target, 'utf8'); + expect(written).toContain('SUMMARY BODY'); + expect(written).not.toContain('old body'); + }); + + it('overwrites an empty pre-created file', async () => { + const target = path.join(projectRoot, 'empty.md'); + await fs.writeFile(target, '', 'utf8'); + const result = await run('empty.md'); + expect(result).toMatchObject({ type: 'message', messageType: 'info' }); + const written = await fs.readFile(target, 'utf8'); + expect(written).toContain('SUMMARY BODY'); + }); + + it('rejects a trailing separator on an existing file', async () => { + await fs.writeFile(path.join(projectRoot, 'notes.md'), 'content', 'utf8'); + const result = await run('notes.md/'); + expect(result).toMatchObject({ type: 'message', messageType: 'error' }); + expect(result.content).toContain('ends with a separator'); + expect(runSideQuery).not.toHaveBeenCalled(); + }); }); diff --git a/packages/cli/src/ui/commands/summaryCommand.ts b/packages/cli/src/ui/commands/summaryCommand.ts index d16baf04bee..f58f6ca3d2c 100644 --- a/packages/cli/src/ui/commands/summaryCommand.ts +++ b/packages/cli/src/ui/commands/summaryCommand.ts @@ -12,6 +12,7 @@ import { type SlashCommandActionReturn, } from './types.js'; import { + createDebugLogger, getProjectSummaryPrompt, isSubpath, resolvePath, @@ -20,6 +21,8 @@ import { import type { HistoryItemSummary } from '../types.js'; import { t } from '../../i18n/index.js'; +const debugLogger = createDebugLogger('SUMMARY_COMMAND'); + // Resolves the real path of the nearest existing ancestor of targetPath. The // target file itself usually does not exist yet, but a symlinked parent directory // can still point outside the project root, so the ancestor must be resolved to @@ -228,13 +231,18 @@ export const summaryCommand: SlashCommand = { throw new Error(t('Summary path must be within the project root.')); } - const isDir = - customPath.endsWith('/') || - customPath.endsWith(path.sep) || - (await fsPromises - .stat(resolved) - .then((s) => s.isDirectory()) - .catch(() => false)); + const hasTrailingSep = + customPath.endsWith('/') || customPath.endsWith(path.sep); + const resolvedStat = await fsPromises.stat(resolved).catch(() => null); + if (hasTrailingSep && resolvedStat?.isFile()) { + throw new Error( + t( + 'Summary path ends with a separator but is an existing file: {{path}}', + { path: customPath }, + ), + ); + } + const isDir = hasTrailingSep || (resolvedStat?.isDirectory() ?? false); const summaryPath = isDir ? path.join(resolved, 'PROJECT_SUMMARY.md') @@ -257,11 +265,12 @@ export const summaryCommand: SlashCommand = { // (e.g. a mistyped path such as `package.json`). A generated summary // carries a `## Summary Metadata` footer, so regenerating one is allowed. const existingStat = await fsPromises.stat(summaryPath).catch(() => null); - if (existingStat?.isFile()) { + if (existingStat?.isFile() && existingStat.size > 0) { const existing = await fsPromises .readFile(summaryPath, 'utf8') .catch(() => ''); - if (!/\n---\n\n## Summary Metadata\n/.test(existing)) { + const normalized = existing.replace(/\r\n/g, '\n'); + if (!/\n---\n\n## Summary Metadata\n/.test(normalized)) { throw new Error( t( 'Summary path already exists and is not a generated summary: {{path}}', @@ -306,6 +315,8 @@ export const summaryCommand: SlashCommand = { target.realProjectRoot, ); + // Only the default .qwen/ target gets 0o700; a user-chosen directory + // keeps its existing permissions. await fsPromises.mkdir(path.dirname(target.summaryPath), { recursive: true, ...(target.isDefaultTarget ? { mode: 0o700 } : {}), @@ -314,7 +325,9 @@ export const summaryCommand: SlashCommand = { encoding: 'utf8', mode: 0o600, }); - await fsPromises.chmod(target.summaryPath, 0o600).catch(() => undefined); + await fsPromises.chmod(target.summaryPath, 0o600).catch((error) => { + debugLogger.debug('Failed to tighten summary file permissions:', error); + }); return { filePathForDisplay: target.filePathForDisplay, From 523cf481f76f7bb1dacea5006f7e68ef468b8769 Mon Sep 17 00:00:00 2001 From: Qwen Code Bot Date: Sat, 1 Aug 2026 05:39:01 +0000 Subject: [PATCH 14/17] fix(cli): address review feedback on /summary custom path (#8116) - Skip symlink-escape check for the default .qwen/ target so a symlinked .qwen/ directory (shared team config, overlay mounts) keeps working, and the check no longer runs after the LLM call - Re-run the overwrite guard immediately before writing to close the TOCTOU window across the slow generation step - Determine isDefaultTarget by comparing the resolved path against the default so `/summary .qwen/` gets the same 0o700 permissions - Only chmod 0o600 on file creation; preserve existing permissions on regeneration - Return empty content in interactive-mode errors to avoid double rendering (failInteractive already adds the error to history) - Tighten the overwrite-guard regex to require `**Update time**: ` after the Summary Metadata heading, preventing false positives - Fix the realpathNearestExisting comment to document the missing containment-during-walk guard vs export/stats copies - Add tests: symlink cycle, default target with symlinked .qwen, TOCTOU overwrite guard, explicit .qwen/ permissions, chmod preservation, interactive error content, regex false-positive --- .../src/ui/commands/summaryCommand.test.ts | 119 ++++++++++++++++++ .../cli/src/ui/commands/summaryCommand.ts | 81 +++++++++--- 2 files changed, 182 insertions(+), 18 deletions(-) diff --git a/packages/cli/src/ui/commands/summaryCommand.test.ts b/packages/cli/src/ui/commands/summaryCommand.test.ts index 8f45daff10b..931bfcbef44 100644 --- a/packages/cli/src/ui/commands/summaryCommand.test.ts +++ b/packages/cli/src/ui/commands/summaryCommand.test.ts @@ -302,6 +302,18 @@ describe('summaryCommand custom export path', () => { }, ); + it.skipIf(process.platform === 'win32')( + 'rejects a symlink cycle', + async () => { + await fs.symlink('link-b', path.join(projectRoot, 'link-a')); + await fs.symlink('link-a', path.join(projectRoot, 'link-b')); + const result = await run('link-a'); + expect(result).toMatchObject({ type: 'message', messageType: 'error' }); + expect(result.content).toContain('within the project root'); + expect(runSideQuery).not.toHaveBeenCalled(); + }, + ); + it('does not create the target directory when generation fails', async () => { vi.mocked(runSideQuery).mockRejectedValueOnce(new Error('rate limit')); const result = await run('reports/2026/summary.md'); @@ -344,6 +356,19 @@ describe('summaryCommand custom export path', () => { expect(runSideQuery).not.toHaveBeenCalled(); }); + it('refuses to overwrite a file with a Summary Metadata heading but no Update time', async () => { + const target = path.join(projectRoot, 'DESIGN.md'); + await fs.writeFile( + target, + 'Some content\n\n---\n\n## Summary Metadata\n\nThis is a design doc.\n', + 'utf8', + ); + const result = await run('DESIGN.md'); + expect(result).toMatchObject({ type: 'message', messageType: 'error' }); + expect(result.content).toContain('already exists'); + expect(runSideQuery).not.toHaveBeenCalled(); + }); + it('overwrites a previously generated summary', async () => { const target = path.join(projectRoot, 'summary.md'); await fs.writeFile( @@ -397,4 +422,98 @@ describe('summaryCommand custom export path', () => { expect(result.content).toContain('ends with a separator'); expect(runSideQuery).not.toHaveBeenCalled(); }); + + it.skipIf(process.platform === 'win32')( + 'allows the default target when .qwen is a symlink', + async () => { + const outside = await fs.mkdtemp( + path.join(os.tmpdir(), 'summary-shared-'), + ); + try { + await fs.symlink(outside, path.join(projectRoot, '.qwen')); + const result = await run(''); + expect(result).toMatchObject({ + type: 'message', + messageType: 'info', + }); + expect(await fileExists(path.join(outside, 'PROJECT_SUMMARY.md'))).toBe( + true, + ); + } finally { + await fs.rm(outside, { recursive: true, force: true }); + } + }, + ); + + it('rejects a non-summary file created during generation', async () => { + vi.mocked(runSideQuery).mockImplementationOnce(async () => { + await fs.writeFile( + path.join(projectRoot, 'race.md'), + 'precious content', + 'utf8', + ); + return { text: 'SUMMARY BODY' }; + }); + const result = await run('race.md'); + expect(result).toMatchObject({ type: 'message', messageType: 'error' }); + expect(result.content).toContain('already exists'); + expect(await fs.readFile(path.join(projectRoot, 'race.md'), 'utf8')).toBe( + 'precious content', + ); + }); + + it.skipIf(process.platform === 'win32')( + 'applies 0o700 to .qwen/ when spelled explicitly', + async () => { + const result = await run('.qwen/'); + expect(result).toMatchObject({ type: 'message', messageType: 'info' }); + const stat = await fs.stat(path.join(projectRoot, '.qwen')); + expect(stat.mode & 0o777).toBe(0o700); + }, + ); + + it.skipIf(process.platform === 'win32')( + 'preserves existing file permissions on regeneration', + async () => { + const target = path.join(projectRoot, 'summary.md'); + await fs.writeFile( + target, + 'old body\n\n---\n\n## Summary Metadata\n**Update time**: old\n', + 'utf8', + ); + await fs.chmod(target, 0o644); + await run('summary.md'); + const stat = await fs.stat(target); + expect(stat.mode & 0o777).toBe(0o644); + }, + ); + + it('returns empty content in interactive mode errors to avoid double rendering', async () => { + const chat = { + getHistoryShallow: () => [ + { role: 'user', parts: [{ text: 'a' }] }, + { role: 'model', parts: [{ text: 'b' }] }, + { role: 'user', parts: [{ text: 'c' }] }, + ], + getGenerationConfig: () => ({ systemInstruction: 'sys' }), + }; + const config = { + getProjectRoot: () => projectRoot, + getGeminiClient: () => ({ getChat: () => chat }), + getModel: () => 'test-model', + }; + const context = createMockCommandContext({ + executionMode: 'interactive', + services: { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + config: config as any, + }, + }); + const result = (await summaryCommand.action?.( + context, + '../outside/leak.md', + )) as MessageResult; + expect(result).toMatchObject({ type: 'message', messageType: 'error' }); + expect(result.content).toBe(''); + }); }); diff --git a/packages/cli/src/ui/commands/summaryCommand.ts b/packages/cli/src/ui/commands/summaryCommand.ts index f58f6ca3d2c..44a9deecf20 100644 --- a/packages/cli/src/ui/commands/summaryCommand.ts +++ b/packages/cli/src/ui/commands/summaryCommand.ts @@ -24,9 +24,12 @@ import { t } from '../../i18n/index.js'; const debugLogger = createDebugLogger('SUMMARY_COMMAND'); // Resolves the real path of the nearest existing ancestor of targetPath. The -// target file itself usually does not exist yet, but a symlinked parent directory -// can still point outside the project root, so the ancestor must be resolved to -// detect that. Mirrors realpathNearestExisting in exportCommand.ts/statsCommand.ts. +// target file itself usually does not exist yet, but a symlinked parent +// directory can still point outside the project root, so the ancestor must be +// resolved to detect that. Unlike the realpathNearestExisting copies in +// exportCommand.ts/statsCommand.ts, this one does not guard the upward walk +// itself (they loop while isSubpath(cwd, currentPath) and throw on escape); +// every call site here must check containment on the returned path. const realpathNearestExisting = async (targetPath: string): Promise => { let currentPath = targetPath; for (;;) { @@ -73,6 +76,11 @@ const assertLeafNotSymlinkEscape = async ( } }; +const isGeneratedSummary = (content: string): boolean => { + const normalized = content.replace(/\r\n/g, '\n'); + return /\n---\n\n## Summary Metadata\n\*\*Update time\*\*: /.test(normalized); +}; + export const summaryCommand: SlashCommand = { name: 'summary', get description() { @@ -197,15 +205,19 @@ export const summaryCommand: SlashCommand = { realProjectRoot: string; }> => { const projectRoot = config.getProjectRoot(); + const defaultSummaryPath = path.join( + projectRoot, + '.qwen', + 'PROJECT_SUMMARY.md', + ); const customPath = args?.trim(); if (!customPath) { - const qwenDir = path.join(projectRoot, '.qwen'); // The default target always overwrites: regenerating the summary is // the command's purpose, and .qwen/PROJECT_SUMMARY.md is a generated // artifact — not user prose the overwrite guard protects. return { - summaryPath: path.join(qwenDir, 'PROJECT_SUMMARY.md'), + summaryPath: defaultSummaryPath, filePathForDisplay: '.qwen/PROJECT_SUMMARY.md', isDefaultTarget: true, realProjectRoot: await fsPromises.realpath(projectRoot), @@ -269,8 +281,7 @@ export const summaryCommand: SlashCommand = { const existing = await fsPromises .readFile(summaryPath, 'utf8') .catch(() => ''); - const normalized = existing.replace(/\r\n/g, '\n'); - if (!/\n---\n\n## Summary Metadata\n/.test(normalized)) { + if (!isGeneratedSummary(existing)) { throw new Error( t( 'Summary path already exists and is not a generated summary: {{path}}', @@ -283,7 +294,7 @@ export const summaryCommand: SlashCommand = { return { summaryPath, filePathForDisplay, - isDefaultTarget: false, + isDefaultTarget: summaryPath === defaultSummaryPath, realProjectRoot, }; }; @@ -310,13 +321,39 @@ export const summaryCommand: SlashCommand = { // Re-check the leaf right before writing to narrow the TOCTOU window // between resolveSummaryTarget (pre-LLM) and the write (post-LLM). - await assertLeafNotSymlinkEscape( - target.summaryPath, - target.realProjectRoot, - ); + // The default target is the user's own .qwen/ directory — a symlinked + // .qwen/ is a deliberate setup, not an attack vector. + if (!target.isDefaultTarget) { + await assertLeafNotSymlinkEscape( + target.summaryPath, + target.realProjectRoot, + ); + } + + const preWriteStat = await fsPromises + .stat(target.summaryPath) + .catch(() => null); + + // Re-run the overwrite guard: a file created during generation (the + // slow step) would otherwise be silently destroyed. + if ( + !target.isDefaultTarget && + preWriteStat?.isFile() && + preWriteStat.size > 0 + ) { + const existing = await fsPromises + .readFile(target.summaryPath, 'utf8') + .catch(() => ''); + if (!isGeneratedSummary(existing)) { + throw new Error( + t( + 'Summary path already exists and is not a generated summary: {{path}}', + { path: target.filePathForDisplay }, + ), + ); + } + } - // Only the default .qwen/ target gets 0o700; a user-chosen directory - // keeps its existing permissions. await fsPromises.mkdir(path.dirname(target.summaryPath), { recursive: true, ...(target.isDefaultTarget ? { mode: 0o700 } : {}), @@ -325,9 +362,16 @@ export const summaryCommand: SlashCommand = { encoding: 'utf8', mode: 0o600, }); - await fsPromises.chmod(target.summaryPath, 0o600).catch((error) => { - debugLogger.debug('Failed to tighten summary file permissions:', error); - }); + // writeFile's mode only applies at creation; skip the explicit chmod + // on pre-existing files the user may have relaxed. + if (!preWriteStat) { + await fsPromises.chmod(target.summaryPath, 0o600).catch((error) => { + debugLogger.debug( + 'Failed to tighten summary file permissions:', + error, + ); + }); + } return { filePathForDisplay: target.filePathForDisplay, @@ -496,7 +540,8 @@ export const summaryCommand: SlashCommand = { return { type: 'message', messageType: 'error', - content: formatErrorMessage(error), + content: + executionMode === 'interactive' ? '' : formatErrorMessage(error), }; } }, From 504492104e00c2477f7efe611169d6e31bbd7ec0 Mon Sep 17 00:00:00 2001 From: Qwen Code Bot Date: Sat, 1 Aug 2026 08:19:22 +0000 Subject: [PATCH 15/17] fix(cli): address review feedback on /summary custom path (#8116) --- packages/cli/src/i18n/locales/ca.js | 2 + packages/cli/src/i18n/locales/de.js | 2 + packages/cli/src/i18n/locales/en.js | 2 + packages/cli/src/i18n/locales/fr.js | 2 + packages/cli/src/i18n/locales/ja.js | 2 + packages/cli/src/i18n/locales/pt.js | 2 + packages/cli/src/i18n/locales/ru.js | 2 + packages/cli/src/i18n/locales/zh-TW.js | 2 + packages/cli/src/i18n/locales/zh.js | 2 + .../src/ui/commands/summaryCommand.test.ts | 56 +++++++++++ .../cli/src/ui/commands/summaryCommand.ts | 95 ++++++++++++------- 11 files changed, 135 insertions(+), 34 deletions(-) diff --git a/packages/cli/src/i18n/locales/ca.js b/packages/cli/src/i18n/locales/ca.js index 1133e3b2b87..aa27b31ae2e 100644 --- a/packages/cli/src/i18n/locales/ca.js +++ b/packages/cli/src/i18n/locales/ca.js @@ -1124,6 +1124,8 @@ export default { 'El camí del resum ja existeix i no és un resum generat: {{path}}', 'Summary path must be within the project root.': 'El camí del resum ha de ser dins de la arrel del projecte.', + 'Summary path resolves to an existing directory: {{path}}': + 'El camí del resum es resol a un directori existent: {{path}}', 'Summary path ends with a separator but is an existing file: {{path}}': 'El camí del resum acaba amb un separador però és un fitxer existent: {{path}}', 'Failed to generate project context summary: {{error}}': diff --git a/packages/cli/src/i18n/locales/de.js b/packages/cli/src/i18n/locales/de.js index d7b18927234..7d8e0ef1fb0 100644 --- a/packages/cli/src/i18n/locales/de.js +++ b/packages/cli/src/i18n/locales/de.js @@ -999,6 +999,8 @@ export default { 'Der Zusammenfassungspfad existiert bereits und ist keine generierte Zusammenfassung: {{path}}', 'Summary path must be within the project root.': 'Der Zusammenfassungspfad muss sich im Projektstammverzeichnis befinden.', + 'Summary path resolves to an existing directory: {{path}}': + 'Der Zusammenfassungspfad verweist auf ein vorhandenes Verzeichnis: {{path}}', 'Summary path ends with a separator but is an existing file: {{path}}': 'Der Zusammenfassungspfad endet mit einem Trennzeichen, ist aber eine vorhandene Datei: {{path}}', 'Failed to generate project context summary: {{error}}': diff --git a/packages/cli/src/i18n/locales/en.js b/packages/cli/src/i18n/locales/en.js index c6d7f650dfb..e776e14f797 100644 --- a/packages/cli/src/i18n/locales/en.js +++ b/packages/cli/src/i18n/locales/en.js @@ -1476,6 +1476,8 @@ export default { 'Summary path already exists and is not a generated summary: {{path}}', 'Summary path must be within the project root.': 'Summary path must be within the project root.', + 'Summary path resolves to an existing directory: {{path}}': + 'Summary path resolves to an existing directory: {{path}}', 'Summary path ends with a separator but is an existing file: {{path}}': 'Summary path ends with a separator but is an existing file: {{path}}', 'Failed to generate project context summary: {{error}}': diff --git a/packages/cli/src/i18n/locales/fr.js b/packages/cli/src/i18n/locales/fr.js index df1d704b28c..eb76090516a 100644 --- a/packages/cli/src/i18n/locales/fr.js +++ b/packages/cli/src/i18n/locales/fr.js @@ -1130,6 +1130,8 @@ export default { "Le chemin du résumé existe déjà et n'est pas un résumé généré : {{path}}", 'Summary path must be within the project root.': 'Le chemin du résumé doit se trouver dans la racine du projet.', + 'Summary path resolves to an existing directory: {{path}}': + 'Le chemin du résumé correspond à un répertoire existant : {{path}}', 'Summary path ends with a separator but is an existing file: {{path}}': 'Le chemin du résumé se termine par un séparateur mais est un fichier existant : {{path}}', 'Failed to generate project context summary: {{error}}': diff --git a/packages/cli/src/i18n/locales/ja.js b/packages/cli/src/i18n/locales/ja.js index 38dc1d09da5..3e2eaa713b7 100644 --- a/packages/cli/src/i18n/locales/ja.js +++ b/packages/cli/src/i18n/locales/ja.js @@ -770,6 +770,8 @@ export default { 'サマリーパスは既に存在し、生成されたサマリーではありません: {{path}}', 'Summary path must be within the project root.': 'サマリーパスはプロジェクトルート内にある必要があります', + 'Summary path resolves to an existing directory: {{path}}': + 'サマリーパスは既存のディレクトリに解決されます: {{path}}', 'Summary path ends with a separator but is an existing file: {{path}}': 'サマリーパスは区切り文字で終わっていますが、既存のファイルです: {{path}}', 'Failed to generate project context summary: {{error}}': diff --git a/packages/cli/src/i18n/locales/pt.js b/packages/cli/src/i18n/locales/pt.js index 053cbd427b1..eca3073a9b1 100644 --- a/packages/cli/src/i18n/locales/pt.js +++ b/packages/cli/src/i18n/locales/pt.js @@ -1004,6 +1004,8 @@ export default { 'O caminho do resumo já existe e não é um resumo gerado: {{path}}', 'Summary path must be within the project root.': 'O caminho do resumo deve estar dentro da raiz do projeto.', + 'Summary path resolves to an existing directory: {{path}}': + 'O caminho do resumo resolve para um diretório existente: {{path}}', 'Summary path ends with a separator but is an existing file: {{path}}': 'O caminho do resumo termina com um separador, mas é um arquivo existente: {{path}}', 'Failed to generate project context summary: {{error}}': diff --git a/packages/cli/src/i18n/locales/ru.js b/packages/cli/src/i18n/locales/ru.js index 173ebe168b8..d24e597bd02 100644 --- a/packages/cli/src/i18n/locales/ru.js +++ b/packages/cli/src/i18n/locales/ru.js @@ -1012,6 +1012,8 @@ export default { 'Путь сводки уже существует и не является сгенерированной сводкой: {{path}}', 'Summary path must be within the project root.': 'Путь сводки должен находиться в корне проекта.', + 'Summary path resolves to an existing directory: {{path}}': + 'Путь сводки указывает на существующий каталог: {{path}}', 'Summary path ends with a separator but is an existing file: {{path}}': 'Путь сводки заканчивается разделителем, но является существующим файлом: {{path}}', 'Failed to generate project context summary: {{error}}': diff --git a/packages/cli/src/i18n/locales/zh-TW.js b/packages/cli/src/i18n/locales/zh-TW.js index d7eae6183ed..7b1b8165fe1 100644 --- a/packages/cli/src/i18n/locales/zh-TW.js +++ b/packages/cli/src/i18n/locales/zh-TW.js @@ -1305,6 +1305,8 @@ export default { 'Summary path already exists and is not a generated summary: {{path}}': '摘要路徑已存在且非產生的摘要:{{path}}', 'Summary path must be within the project root.': '摘要路徑必須在專案根目錄內', + 'Summary path resolves to an existing directory: {{path}}': + '摘要路徑解析為一個已存在的目錄:{{path}}', 'Summary path ends with a separator but is an existing file: {{path}}': '摘要路徑以分隔符結尾,但是一個已存在的檔案:{{path}}', 'Failed to generate project context summary: {{error}}': diff --git a/packages/cli/src/i18n/locales/zh.js b/packages/cli/src/i18n/locales/zh.js index eff8125ec8a..febc16275de 100644 --- a/packages/cli/src/i18n/locales/zh.js +++ b/packages/cli/src/i18n/locales/zh.js @@ -1415,6 +1415,8 @@ export default { 'Summary path already exists and is not a generated summary: {{path}}': '摘要路径已存在且不是生成的摘要:{{path}}', 'Summary path must be within the project root.': '摘要路径必须在项目根目录内', + 'Summary path resolves to an existing directory: {{path}}': + '摘要路径解析为一个已存在的目录:{{path}}', 'Summary path ends with a separator but is an existing file: {{path}}': '摘要路径以分隔符结尾,但是一个已存在的文件:{{path}}', 'Failed to generate project context summary: {{error}}': diff --git a/packages/cli/src/ui/commands/summaryCommand.test.ts b/packages/cli/src/ui/commands/summaryCommand.test.ts index 931bfcbef44..a10eabe616e 100644 --- a/packages/cli/src/ui/commands/summaryCommand.test.ts +++ b/packages/cli/src/ui/commands/summaryCommand.test.ts @@ -488,6 +488,62 @@ describe('summaryCommand custom export path', () => { }, ); + it('overwrites a hand-written file when the default path is spelled explicitly', async () => { + const qwenDir = path.join(projectRoot, '.qwen'); + await fs.mkdir(qwenDir, { recursive: true }); + await fs.writeFile( + path.join(qwenDir, 'PROJECT_SUMMARY.md'), + 'hand-written notes', + 'utf8', + ); + const result = await run('.qwen/PROJECT_SUMMARY.md'); + expect(result).toMatchObject({ type: 'message', messageType: 'info' }); + const written = await fs.readFile( + path.join(qwenDir, 'PROJECT_SUMMARY.md'), + 'utf8', + ); + expect(written).toContain('SUMMARY BODY'); + expect(written).not.toContain('hand-written notes'); + }); + + it('rejects an existing directory at the leaf before generating', async () => { + await fs.mkdir(path.join(projectRoot, 'docs', 'PROJECT_SUMMARY.md'), { + recursive: true, + }); + const result = await run('docs'); + expect(result).toMatchObject({ type: 'message', messageType: 'error' }); + expect(result.content).toContain('existing directory'); + expect(runSideQuery).not.toHaveBeenCalled(); + }); + + it('refuses to overwrite a file that embeds a full footer mid-document', async () => { + const target = path.join(projectRoot, 'ARCHIVE.md'); + await fs.writeFile( + target, + 'intro\n\n---\n\n## Summary Metadata\n**Update time**: old\n\ntrailing prose\n', + 'utf8', + ); + const result = await run('ARCHIVE.md'); + expect(result).toMatchObject({ type: 'message', messageType: 'error' }); + expect(result.content).toContain('already exists'); + expect(runSideQuery).not.toHaveBeenCalled(); + }); + + it('detects the footer in a file larger than the tail window', async () => { + const target = path.join(projectRoot, 'big-summary.md'); + const padding = 'x'.repeat(8192); + await fs.writeFile( + target, + `${padding}\n\n---\n\n## Summary Metadata\n**Update time**: old\n`, + 'utf8', + ); + const result = await run('big-summary.md'); + expect(result).toMatchObject({ type: 'message', messageType: 'info' }); + const written = await fs.readFile(target, 'utf8'); + expect(written).toContain('SUMMARY BODY'); + expect(written).not.toContain(padding); + }); + it('returns empty content in interactive mode errors to avoid double rendering', async () => { const chat = { getHistoryShallow: () => [ diff --git a/packages/cli/src/ui/commands/summaryCommand.ts b/packages/cli/src/ui/commands/summaryCommand.ts index 44a9deecf20..fc3564e9522 100644 --- a/packages/cli/src/ui/commands/summaryCommand.ts +++ b/packages/cli/src/ui/commands/summaryCommand.ts @@ -12,7 +12,6 @@ import { type SlashCommandActionReturn, } from './types.js'; import { - createDebugLogger, getProjectSummaryPrompt, isSubpath, resolvePath, @@ -21,8 +20,6 @@ import { import type { HistoryItemSummary } from '../types.js'; import { t } from '../../i18n/index.js'; -const debugLogger = createDebugLogger('SUMMARY_COMMAND'); - // Resolves the real path of the nearest existing ancestor of targetPath. The // target file itself usually does not exist yet, but a symlinked parent // directory can still point outside the project root, so the ancestor must be @@ -76,11 +73,37 @@ const assertLeafNotSymlinkEscape = async ( } }; -const isGeneratedSummary = (content: string): boolean => { - const normalized = content.replace(/\r\n/g, '\n'); - return /\n---\n\n## Summary Metadata\n\*\*Update time\*\*: /.test(normalized); +// Static footer prefix shared by the writer (saveSummaryToDisk) and the +// detector below, kept in one place so the two cannot drift apart. +const SUMMARY_FOOTER_PREFIX = '\n---\n\n## Summary Metadata\n**Update time**: '; + +const buildSummaryFooter = (timestamp: string): string => + `${SUMMARY_FOOTER_PREFIX}${timestamp}\n`; + +// Reads only the file tail: the footer always lives in the last ~90 bytes, so +// a mistyped multi-GB path is not loaded fully into memory just to test for it. +const readSummaryTail = async (p: string, bytes = 4096): Promise => { + const handle = await fsPromises.open(p, 'r'); + try { + const { size } = await handle.stat(); + const length = Math.min(size, bytes); + const buffer = Buffer.alloc(length); + await handle.read(buffer, 0, length, Math.max(0, size - length)); + return buffer.toString('utf8'); + } finally { + await handle.close(); + } }; +// Built from the same prefix the writer emits (escaped so the literal `**` is +// not read as a quantifier) and anchored to end-of-file, so a document that +// merely embeds a sample footer is not mistaken for a generated summary and +// clobbered. +const isGeneratedSummary = (content: string): boolean => + new RegExp( + `${SUMMARY_FOOTER_PREFIX.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}[^\\n]*\\n?$`, + ).test(content.replace(/\r\n/g, '\n')); + export const summaryCommand: SlashCommand = { name: 'summary', get description() { @@ -273,14 +296,30 @@ export const summaryCommand: SlashCommand = { : path.relative(projectRoot, summaryPath) ).replaceAll(path.sep, '/'); - // Refuse to clobber a pre-existing file that is not a generated summary - // (e.g. a mistyped path such as `package.json`). A generated summary - // carries a `## Summary Metadata` footer, so regenerating one is allowed. + // Compute the default-target flag before the guards so an explicitly + // spelled default path (`.qwen/PROJECT_SUMMARY.md` or `.qwen/`) behaves + // exactly like the no-arg command and always overwrites. + const isDefaultTarget = summaryPath === defaultSummaryPath; + const existingStat = await fsPromises.stat(summaryPath).catch(() => null); - if (existingStat?.isFile() && existingStat.size > 0) { - const existing = await fsPromises - .readFile(summaryPath, 'utf8') - .catch(() => ''); + + // Reject an existing directory at the leaf (e.g. `/summary docs` where + // docs/PROJECT_SUMMARY.md is itself a directory) before the LLM call, + // instead of surfacing a raw EISDIR only at write time. + if (existingStat?.isDirectory()) { + throw new Error( + t('Summary path resolves to an existing directory: {{path}}', { + path: filePathForDisplay, + }), + ); + } + + // For any non-default path, refuse to clobber a pre-existing file that is + // not a generated summary (e.g. a mistyped `package.json`). A generated + // summary carries a `## Summary Metadata` footer, so regenerating one is + // allowed. + if (!isDefaultTarget && existingStat?.isFile() && existingStat.size > 0) { + const existing = await readSummaryTail(summaryPath).catch(() => ''); if (!isGeneratedSummary(existing)) { throw new Error( t( @@ -294,7 +333,7 @@ export const summaryCommand: SlashCommand = { return { summaryPath, filePathForDisplay, - isDefaultTarget: summaryPath === defaultSummaryPath, + isDefaultTarget, realProjectRoot, }; }; @@ -311,13 +350,9 @@ export const summaryCommand: SlashCommand = { filePathForDisplay: string; fullPath: string; }> => { - const summaryContent = `${markdownSummary} - ---- - -## Summary Metadata -**Update time**: ${new Date().toISOString()} -`; + const summaryContent = `${markdownSummary}\n${buildSummaryFooter( + new Date().toISOString(), + )}`; // Re-check the leaf right before writing to narrow the TOCTOU window // between resolveSummaryTarget (pre-LLM) and the write (post-LLM). @@ -341,9 +376,9 @@ export const summaryCommand: SlashCommand = { preWriteStat?.isFile() && preWriteStat.size > 0 ) { - const existing = await fsPromises - .readFile(target.summaryPath, 'utf8') - .catch(() => ''); + const existing = await readSummaryTail(target.summaryPath).catch( + () => '', + ); if (!isGeneratedSummary(existing)) { throw new Error( t( @@ -358,20 +393,12 @@ export const summaryCommand: SlashCommand = { recursive: true, ...(target.isDefaultTarget ? { mode: 0o700 } : {}), }); + // writeFile's mode applies at creation; for an existing file the user may + // have relaxed, the mode argument is ignored and permissions are kept. await fsPromises.writeFile(target.summaryPath, summaryContent, { encoding: 'utf8', mode: 0o600, }); - // writeFile's mode only applies at creation; skip the explicit chmod - // on pre-existing files the user may have relaxed. - if (!preWriteStat) { - await fsPromises.chmod(target.summaryPath, 0o600).catch((error) => { - debugLogger.debug( - 'Failed to tighten summary file permissions:', - error, - ); - }); - } return { filePathForDisplay: target.filePathForDisplay, From f18300f53c871a68a350eb1e74d5823243c49e40 Mon Sep 17 00:00:00 2001 From: qwen-code-dev-bot Date: Sat, 1 Aug 2026 10:52:56 +0000 Subject: [PATCH 16/17] test(cli): cover post-LLM symlink re-check and interactive error UI (#8116) --- .../src/ui/commands/summaryCommand.test.ts | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/packages/cli/src/ui/commands/summaryCommand.test.ts b/packages/cli/src/ui/commands/summaryCommand.test.ts index a10eabe616e..beebc4f4e01 100644 --- a/packages/cli/src/ui/commands/summaryCommand.test.ts +++ b/packages/cli/src/ui/commands/summaryCommand.test.ts @@ -462,6 +462,29 @@ describe('summaryCommand custom export path', () => { ); }); + it.skipIf(process.platform === 'win32')( + 'rejects a symlink planted at the target during generation', + async () => { + const outside = await fs.mkdtemp( + path.join(os.tmpdir(), 'summary-toctou-'), + ); + try { + vi.mocked(runSideQuery).mockImplementationOnce(async () => { + await fs.symlink(outside, path.join(projectRoot, 'race-link.md')); + return { text: 'SUMMARY BODY' }; + }); + const result = await run('race-link.md'); + expect(result).toMatchObject({ + type: 'message', + messageType: 'error', + }); + expect(result.content).toContain('within the project root'); + } finally { + await fs.rm(outside, { recursive: true, force: true }); + } + }, + ); + it.skipIf(process.platform === 'win32')( 'applies 0o700 to .qwen/ when spelled explicitly', async () => { @@ -571,5 +594,9 @@ describe('summaryCommand custom export path', () => { )) as MessageResult; expect(result).toMatchObject({ type: 'message', messageType: 'error' }); expect(result.content).toBe(''); + expect(vi.mocked(context.ui.addItem)).toHaveBeenCalledWith( + expect.objectContaining({ type: 'error' }), + expect.any(Number), + ); }); }); From 812e233ca34cf684692cea462f77d24979ac8e05 Mon Sep 17 00:00:00 2001 From: qwen-code-ci-bot Date: Sat, 1 Aug 2026 16:53:13 +0000 Subject: [PATCH 17/17] fix(cli): address review feedback on /summary custom path (#8116) --- packages/cli/src/ui/commands/summaryCommand.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/ui/commands/summaryCommand.ts b/packages/cli/src/ui/commands/summaryCommand.ts index fc3564e9522..603d0c69567 100644 --- a/packages/cli/src/ui/commands/summaryCommand.ts +++ b/packages/cli/src/ui/commands/summaryCommand.ts @@ -297,8 +297,10 @@ export const summaryCommand: SlashCommand = { ).replaceAll(path.sep, '/'); // Compute the default-target flag before the guards so an explicitly - // spelled default path (`.qwen/PROJECT_SUMMARY.md` or `.qwen/`) behaves - // exactly like the no-arg command and always overwrites. + // spelled default path (`.qwen/PROJECT_SUMMARY.md` or `.qwen/`) is + // treated as the default target for the overwrite guard and always + // overwrites. Unlike the no-arg command, a spelled path still runs the + // custom-path symlink containment checks. const isDefaultTarget = summaryPath === defaultSummaryPath; const existingStat = await fsPromises.stat(summaryPath).catch(() => null);