From 132aed758fe34c222788009237e4fd7f9e1e2022 Mon Sep 17 00:00:00 2001 From: lishuceo Date: Mon, 8 Jun 2026 16:07:01 +0800 Subject: [PATCH 1/2] =?UTF-8?q?fix:=20workspace=20=E5=88=9D=E5=A7=8B?= =?UTF-8?q?=E5=8C=96=E6=97=B6=E8=87=AA=E5=8A=A8=E6=B3=A8=E5=85=A5=20settin?= =?UTF-8?q?gs.local.json=20=E8=A7=A3=E5=86=B3=20bot=20=E6=9D=83=E9=99=90?= =?UTF-8?q?=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 在 bot/飞书场景下,项目级 .claude/settings.json 的 permissions.allow 白名单过严会导致 Bash 写命令触发交互式权限审批,但无终端环境下 审批请求无法送达,报 "Stream closed" 错误。 现在 setupWorkspace 在 clone 完成后自动注入 .claude/settings.local.json, 预置 git/npm/npx/gh 等基础命令的白名单,覆盖项目级限制。 已存在的 settings.local.json 不会被覆盖。 Co-Authored-By: Claude Opus 4.6 (1M context) --- .../workspace-settings-injection.test.ts | 105 ++++++++++++++++++ src/workspace/manager.ts | 53 ++++++++- 2 files changed, 157 insertions(+), 1 deletion(-) create mode 100644 src/__tests__/workspace-settings-injection.test.ts diff --git a/src/__tests__/workspace-settings-injection.test.ts b/src/__tests__/workspace-settings-injection.test.ts new file mode 100644 index 00000000..60a80fb9 --- /dev/null +++ b/src/__tests__/workspace-settings-injection.test.ts @@ -0,0 +1,105 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { existsSync, mkdirSync, readFileSync, writeFileSync, rmSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { tmpdir } from 'node:os'; +import { randomBytes } from 'node:crypto'; + +/** + * Test the .claude/settings.local.json injection behavior in workspace setup. + * Since injectLocalSettings is not exported, we replicate its logic here to verify the contract. + */ + +function createTempWorkspace(): string { + const dir = resolve(tmpdir(), `ws-test-${randomBytes(4).toString('hex')}`); + mkdirSync(dir, { recursive: true }); + return dir; +} + +describe('workspace settings.local.json injection', () => { + let workspacePath: string; + + beforeEach(() => { + workspacePath = createTempWorkspace(); + }); + + afterEach(() => { + rmSync(workspacePath, { recursive: true, force: true }); + }); + + it('should create .claude/settings.local.json with permission whitelist', async () => { + // Simulate what injectLocalSettings does + const claudeDir = resolve(workspacePath, '.claude'); + const settingsPath = resolve(claudeDir, 'settings.local.json'); + + // Import the manager module to call setupWorkspace indirectly won't work without git, + // so we verify the contract: after injection, the file should contain expected permissions + mkdirSync(claudeDir, { recursive: true }); + + const settings = { + permissions: { + allow: [ + 'Bash(git *)', + 'Bash(npm *)', + 'Bash(npx *)', + 'Bash(node *)', + 'Bash(cat *)', + 'Bash(ls *)', + 'Bash(find *)', + 'Bash(grep *)', + 'Bash(echo *)', + 'Bash(pwd)', + 'Bash(which *)', + 'Bash(gh *)', + ], + }, + }; + writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n'); + + expect(existsSync(settingsPath)).toBe(true); + const content = JSON.parse(readFileSync(settingsPath, 'utf-8')); + expect(content.permissions.allow).toContain('Bash(git *)'); + expect(content.permissions.allow).toContain('Bash(npm *)'); + expect(content.permissions.allow).toContain('Bash(gh *)'); + }); + + it('should not overwrite existing settings.local.json', () => { + const claudeDir = resolve(workspacePath, '.claude'); + const settingsPath = resolve(claudeDir, 'settings.local.json'); + + // Pre-create a custom settings file + mkdirSync(claudeDir, { recursive: true }); + const customSettings = { permissions: { allow: ['Bash(custom *)'] } }; + writeFileSync(settingsPath, JSON.stringify(customSettings)); + + // Verify existing file is preserved (injectLocalSettings skips if exists) + expect(existsSync(settingsPath)).toBe(true); + const content = JSON.parse(readFileSync(settingsPath, 'utf-8')); + expect(content.permissions.allow).toEqual(['Bash(custom *)']); + }); + + it('should include git commands critical for bot workflow', () => { + const requiredPatterns = ['Bash(git *)', 'Bash(npm *)', 'Bash(npx *)']; + const settings = { + permissions: { + allow: [ + 'Bash(git *)', + 'Bash(npm *)', + 'Bash(npx *)', + 'Bash(node *)', + 'Bash(cat *)', + 'Bash(ls *)', + 'Bash(find *)', + 'Bash(grep *)', + 'Bash(echo *)', + 'Bash(pwd)', + 'Bash(which *)', + 'Bash(gh *)', + ], + }, + }; + + for (const pattern of requiredPatterns) { + expect(settings.permissions.allow).toContain(pattern); + } + }); +}); diff --git a/src/workspace/manager.ts b/src/workspace/manager.ts index 85d2c944..030d71e7 100644 --- a/src/workspace/manager.ts +++ b/src/workspace/manager.ts @@ -1,5 +1,5 @@ import { execFileSync } from 'node:child_process'; -import { existsSync, mkdirSync, realpathSync } from 'node:fs'; +import { existsSync, mkdirSync, writeFileSync, realpathSync } from 'node:fs'; import { randomBytes } from 'node:crypto'; import { basename, resolve } from 'node:path'; import { config } from '../config.js'; @@ -162,6 +162,12 @@ export function setupWorkspace(options: SetupWorkspaceOptions): SetupWorkspaceRe throw new Error(`git clone 失败: ${msg}`); } + // 注入 .claude/settings.local.json — 确保 bot 场景下基础命令不被项目级白名单拦截 + // SDK 的 permissionMode: 'acceptEdits' 只自动批准 Edit/Write,Bash 命令若不在 + // 项目 settings.json 的 permissions.allow 中会触发交互式审批,在无终端的 bot 环境下 + // 直接报 "Stream closed"。注入 local 设置覆盖项目级限制。 + injectLocalSettings(workspacePath); + if (mode === 'writable') { // writable: 设置 remote origin 为原始远程地址 (剥离认证信息) if (repoUrl) { @@ -223,3 +229,48 @@ export function parseRepoNameFromWorkspaceDir(dirName: string): string { // fallback: 原始目录名 return dirName; } + +/** + * 注入 .claude/settings.local.json 覆盖项目级权限限制。 + * 确保 bot 无终端场景下 git/npm/npx 等基础命令不会触发交互式权限审批。 + */ +function injectLocalSettings(workspacePath: string): void { + const claudeDir = resolve(workspacePath, '.claude'); + const settingsPath = resolve(claudeDir, 'settings.local.json'); + + // 如果已存在则不覆盖(用户或其他流程可能已配置) + if (existsSync(settingsPath)) { + logger.info({ settingsPath }, 'settings.local.json already exists, skipping injection'); + return; + } + + const settings = { + permissions: { + allow: [ + 'Bash(git *)', + 'Bash(npm *)', + 'Bash(npx *)', + 'Bash(node *)', + 'Bash(cat *)', + 'Bash(ls *)', + 'Bash(find *)', + 'Bash(grep *)', + 'Bash(echo *)', + 'Bash(pwd)', + 'Bash(which *)', + 'Bash(gh *)', + ], + }, + }; + + try { + if (!existsSync(claudeDir)) { + mkdirSync(claudeDir, { recursive: true }); + } + writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n'); + logger.info({ settingsPath }, 'Injected .claude/settings.local.json for bot permission bypass'); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + logger.warn({ err: msg, settingsPath }, 'Failed to inject settings.local.json, continuing'); + } +} From a1aa485a18366b38436bb33d29649f800a861bf9 Mon Sep 17 00:00:00 2001 From: lishuceo Date: Mon, 8 Jun 2026 16:25:52 +0800 Subject: [PATCH 2/2] =?UTF-8?q?fix:=20export=20injectLocalSettings=20?= =?UTF-8?q?=E5=B9=B6=E9=87=8D=E5=86=99=E6=B5=8B=E8=AF=95=E8=B0=83=E7=94=A8?= =?UTF-8?q?=E7=9C=9F=E5=AE=9E=E5=AE=9E=E7=8E=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review 指出测试重新实现了逻辑而非调用真实函数,导致无法检测漂移。 现在导出 injectLocalSettings 并在测试中直接 import 调用,覆盖: - 文件创建 + 白名单内容验证 - .claude 目录不存在时自动创建 - 已存在文件不被覆盖 Co-Authored-By: Claude Opus 4.6 (1M context) --- .../workspace-settings-injection.test.ts | 88 ++++++------------- src/workspace/manager.ts | 2 +- 2 files changed, 29 insertions(+), 61 deletions(-) diff --git a/src/__tests__/workspace-settings-injection.test.ts b/src/__tests__/workspace-settings-injection.test.ts index 60a80fb9..1aaec762 100644 --- a/src/__tests__/workspace-settings-injection.test.ts +++ b/src/__tests__/workspace-settings-injection.test.ts @@ -3,11 +3,7 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync, rmSync } from 'node import { resolve } from 'node:path'; import { tmpdir } from 'node:os'; import { randomBytes } from 'node:crypto'; - -/** - * Test the .claude/settings.local.json injection behavior in workspace setup. - * Since injectLocalSettings is not exported, we replicate its logic here to verify the contract. - */ +import { injectLocalSettings } from '../workspace/manager.js'; function createTempWorkspace(): string { const dir = resolve(tmpdir(), `ws-test-${randomBytes(4).toString('hex')}`); @@ -15,7 +11,7 @@ function createTempWorkspace(): string { return dir; } -describe('workspace settings.local.json injection', () => { +describe('injectLocalSettings', () => { let workspacePath: string; beforeEach(() => { @@ -26,80 +22,52 @@ describe('workspace settings.local.json injection', () => { rmSync(workspacePath, { recursive: true, force: true }); }); - it('should create .claude/settings.local.json with permission whitelist', async () => { - // Simulate what injectLocalSettings does - const claudeDir = resolve(workspacePath, '.claude'); - const settingsPath = resolve(claudeDir, 'settings.local.json'); - - // Import the manager module to call setupWorkspace indirectly won't work without git, - // so we verify the contract: after injection, the file should contain expected permissions - mkdirSync(claudeDir, { recursive: true }); - - const settings = { - permissions: { - allow: [ - 'Bash(git *)', - 'Bash(npm *)', - 'Bash(npx *)', - 'Bash(node *)', - 'Bash(cat *)', - 'Bash(ls *)', - 'Bash(find *)', - 'Bash(grep *)', - 'Bash(echo *)', - 'Bash(pwd)', - 'Bash(which *)', - 'Bash(gh *)', - ], - }, - }; - writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n'); + it('should create .claude/settings.local.json with permission whitelist', () => { + injectLocalSettings(workspacePath); + const settingsPath = resolve(workspacePath, '.claude', 'settings.local.json'); expect(existsSync(settingsPath)).toBe(true); + const content = JSON.parse(readFileSync(settingsPath, 'utf-8')); expect(content.permissions.allow).toContain('Bash(git *)'); expect(content.permissions.allow).toContain('Bash(npm *)'); + expect(content.permissions.allow).toContain('Bash(npx *)'); expect(content.permissions.allow).toContain('Bash(gh *)'); }); + it('should create .claude directory if it does not exist', () => { + const claudeDir = resolve(workspacePath, '.claude'); + expect(existsSync(claudeDir)).toBe(false); + + injectLocalSettings(workspacePath); + + expect(existsSync(claudeDir)).toBe(true); + expect(existsSync(resolve(claudeDir, 'settings.local.json'))).toBe(true); + }); + it('should not overwrite existing settings.local.json', () => { const claudeDir = resolve(workspacePath, '.claude'); const settingsPath = resolve(claudeDir, 'settings.local.json'); - // Pre-create a custom settings file mkdirSync(claudeDir, { recursive: true }); const customSettings = { permissions: { allow: ['Bash(custom *)'] } }; writeFileSync(settingsPath, JSON.stringify(customSettings)); - // Verify existing file is preserved (injectLocalSettings skips if exists) - expect(existsSync(settingsPath)).toBe(true); + injectLocalSettings(workspacePath); + const content = JSON.parse(readFileSync(settingsPath, 'utf-8')); expect(content.permissions.allow).toEqual(['Bash(custom *)']); }); - it('should include git commands critical for bot workflow', () => { - const requiredPatterns = ['Bash(git *)', 'Bash(npm *)', 'Bash(npx *)']; - const settings = { - permissions: { - allow: [ - 'Bash(git *)', - 'Bash(npm *)', - 'Bash(npx *)', - 'Bash(node *)', - 'Bash(cat *)', - 'Bash(ls *)', - 'Bash(find *)', - 'Bash(grep *)', - 'Bash(echo *)', - 'Bash(pwd)', - 'Bash(which *)', - 'Bash(gh *)', - ], - }, - }; - - for (const pattern of requiredPatterns) { - expect(settings.permissions.allow).toContain(pattern); + it('should include all critical bot workflow commands', () => { + injectLocalSettings(workspacePath); + + const settingsPath = resolve(workspacePath, '.claude', 'settings.local.json'); + const content = JSON.parse(readFileSync(settingsPath, 'utf-8')); + + const required = ['Bash(git *)', 'Bash(npm *)', 'Bash(npx *)', 'Bash(node *)', 'Bash(gh *)']; + for (const pattern of required) { + expect(content.permissions.allow).toContain(pattern); } }); }); diff --git a/src/workspace/manager.ts b/src/workspace/manager.ts index 030d71e7..26816983 100644 --- a/src/workspace/manager.ts +++ b/src/workspace/manager.ts @@ -234,7 +234,7 @@ export function parseRepoNameFromWorkspaceDir(dirName: string): string { * 注入 .claude/settings.local.json 覆盖项目级权限限制。 * 确保 bot 无终端场景下 git/npm/npx 等基础命令不会触发交互式权限审批。 */ -function injectLocalSettings(workspacePath: string): void { +export function injectLocalSettings(workspacePath: string): void { const claudeDir = resolve(workspacePath, '.claude'); const settingsPath = resolve(claudeDir, 'settings.local.json');