diff --git a/integration-tests/cli/qwen-serve-routes.test.ts b/integration-tests/cli/qwen-serve-routes.test.ts index 6fb45cc4f78..85debcbb798 100644 --- a/integration-tests/cli/qwen-serve-routes.test.ts +++ b/integration-tests/cli/qwen-serve-routes.test.ts @@ -275,6 +275,7 @@ describe('qwen serve — capabilities envelope', () => { 'session_approval_mode_control', 'workspace_tool_toggle', 'workspace_settings', + 'workspace_permissions', 'workspace_init', 'workspace_mcp_restart', 'session_recap', diff --git a/packages/cli/src/acp-integration/acpAgent.test.ts b/packages/cli/src/acp-integration/acpAgent.test.ts index d2c6f73ba67..6fca99e42a9 100644 --- a/packages/cli/src/acp-integration/acpAgent.test.ts +++ b/packages/cli/src/acp-integration/acpAgent.test.ts @@ -313,6 +313,20 @@ vi.mock('@qwen-code/qwen-code-core', () => ({ Storage: { getGlobalQwenDir: vi.fn(() => '/tmp/qwen-global-test'), }, + parseRule: vi.fn((raw: string) => { + const trimmed = raw.trim(); + const openParen = trimmed.indexOf('('); + if (openParen === -1) { + return { raw: trimmed, toolName: trimmed }; + } + return { + raw: trimmed, + toolName: trimmed.slice(0, openParen).trim(), + ...(trimmed.endsWith(')') + ? { specifier: trimmed.slice(openParen + 1, -1) } + : { invalid: true }), + }; + }), parse: vi.fn((yaml: string) => { const record: Record = {}; for (const line of yaml.split('\n')) { @@ -535,7 +549,11 @@ import { } from '@qwen-code/qwen-code-core'; import type { McpServer } from '@agentclientprotocol/sdk'; import { AgentSideConnection } from '@agentclientprotocol/sdk'; -import { loadSettings } from '../config/settings.js'; +import { loadSettings, SettingScope } from '../config/settings.js'; +import { + MAX_PERMISSION_RULE_LENGTH, + MAX_PERMISSION_RULES_COUNT, +} from '../config/permission-settings.js'; import { loadCliConfig } from '../config/config.js'; import { Session, buildAvailableCommandsSnapshot } from './session/Session.js'; import { @@ -1317,13 +1335,73 @@ describe('QwenAgent MCP SSE/HTTP support', () => { } function makeCoreSettings(outputLanguage = 'English') { - const userSettings = { general: { outputLanguage } }; - const workspaceSettings = {}; - const mergedSettings = { general: { outputLanguage } }; + type PermissionRules = { allow: string[]; ask: string[]; deny: string[] }; + + const userGeneral = { outputLanguage }; + const mergedGeneral = { outputLanguage }; + const userSettings: Record = { general: userGeneral }; + const workspaceSettings: Record = {}; + const mergedSettings: Record = { general: mergedGeneral }; + const emptyRules = (): PermissionRules => ({ + allow: [], + ask: [], + deny: [], + }); + const readPermissionRules = ( + settings: Record, + ): PermissionRules => { + const permissions = settings['permissions']; + if ( + typeof permissions !== 'object' || + permissions === null || + Array.isArray(permissions) + ) { + return emptyRules(); + } + const permissionRecord = permissions as Record; + const readList = (key: keyof PermissionRules) => + Array.isArray(permissionRecord[key]) + ? permissionRecord[key].filter( + (value): value is string => typeof value === 'string', + ) + : []; + return { + allow: readList('allow'), + ask: readList('ask'), + deny: readList('deny'), + }; + }; + const updateMergedPermissions = () => { + const userRules = readPermissionRules(userSettings); + const workspaceRules = readPermissionRules(workspaceSettings); + mergedSettings['permissions'] = { + allow: [...userRules.allow, ...workspaceRules.allow], + ask: [...userRules.ask, ...workspaceRules.ask], + deny: [...userRules.deny, ...workspaceRules.deny], + }; + }; const setValue = vi.fn((_scope: string, key: string, value: unknown) => { + const target = _scope === 'Workspace' ? workspaceSettings : userSettings; + if (key.startsWith('permissions.')) { + const ruleType = key.slice('permissions.'.length); + if (ruleType !== 'allow' && ruleType !== 'ask' && ruleType !== 'deny') { + return; + } + const existing = target['permissions']; + const permissions = + typeof existing === 'object' && + existing !== null && + !Array.isArray(existing) + ? { ...(existing as Record) } + : {}; + permissions[ruleType] = value; + target['permissions'] = permissions; + updateMergedPermissions(); + return; + } if (key !== 'general.outputLanguage') return; - userSettings.general.outputLanguage = value as string; - mergedSettings.general.outputLanguage = value as string; + userGeneral.outputLanguage = value as string; + mergedGeneral.outputLanguage = value as string; }); return { merged: mergedSettings, @@ -2625,6 +2703,51 @@ describe('QwenAgent MCP SSE/HTTP support', () => { return { agent, agentPromise }; } + it('qwen/permissions/getSettings returns user workspace merged and trust state', async () => { + const settings = makeCoreSettings(); + settings.setValue(SettingScope.User, 'permissions.allow', [ + 'ShellTool(git status)', + ]); + settings.setValue(SettingScope.Workspace, 'permissions.allow', [ + 'ShellTool(npm test)', + ]); + settings.setValue(SettingScope.Workspace, 'permissions.deny', [ + 'ReadFileTool(**/.env)', + ]); + const { agent, agentPromise } = await bootCoreSettingsAgent(settings); + + const result = await agent.extMethod('qwen/permissions/getSettings', {}); + + expect(result).toEqual({ + v: 1, + user: { + path: '/home/test/.qwen/settings.json', + rules: { + allow: ['ShellTool(git status)'], + ask: [], + deny: [], + }, + }, + workspace: { + path: '/work/.qwen/settings.json', + rules: { + allow: ['ShellTool(npm test)'], + ask: [], + deny: ['ReadFileTool(**/.env)'], + }, + }, + merged: { + allow: ['ShellTool(git status)', 'ShellTool(npm test)'], + ask: [], + deny: ['ReadFileTool(**/.env)'], + }, + isTrusted: true, + }); + + mockConnectionState.resolve(); + await agentPromise; + }); + it('qwen/settings/getCore returns user, workspace, and merged views', async () => { const settings = makeCoreSettings(); const { agent, agentPromise } = await bootCoreSettingsAgent(settings); @@ -3276,6 +3399,88 @@ describe('QwenAgent MCP SSE/HTTP support', () => { await agentPromise; }); + it('qwen/permissions/setRules rejects new malformed permission rules', async () => { + const settings = makeCoreSettings(); + const { agent, agentPromise } = await bootCoreSettingsAgent(settings); + + await expect( + agent.extMethod('qwen/permissions/setRules', { + scope: 'user', + ruleType: 'allow', + rules: ['ShellTool(git status'], + }), + ).rejects.toThrowError(/Malformed permission rule/); + expect(settings.setValue).not.toHaveBeenCalled(); + + mockConnectionState.resolve(); + await agentPromise; + }); + + it('qwen/permissions/setRules rejects oversized permission rule lists', async () => { + const settings = makeCoreSettings(); + const { agent, agentPromise } = await bootCoreSettingsAgent(settings); + + await expect( + agent.extMethod('qwen/permissions/setRules', { + scope: 'user', + ruleType: 'allow', + rules: Array.from( + { length: MAX_PERMISSION_RULES_COUNT + 1 }, + (_, index) => `ShellTool(echo ${index})`, + ), + }), + ).rejects.toThrowError( + `rules array exceeds ${MAX_PERMISSION_RULES_COUNT} entries`, + ); + expect(settings.setValue).not.toHaveBeenCalled(); + + mockConnectionState.resolve(); + await agentPromise; + }); + + it('qwen/permissions/setRules rejects oversized permission rule strings', async () => { + const settings = makeCoreSettings(); + const { agent, agentPromise } = await bootCoreSettingsAgent(settings); + + await expect( + agent.extMethod('qwen/permissions/setRules', { + scope: 'user', + ruleType: 'allow', + rules: [`ShellTool(${'x'.repeat(MAX_PERMISSION_RULE_LENGTH + 1)})`], + }), + ).rejects.toThrowError( + `rule exceeds ${MAX_PERMISSION_RULE_LENGTH}-character limit`, + ); + expect(settings.setValue).not.toHaveBeenCalled(); + + mockConnectionState.resolve(); + await agentPromise; + }); + + it('qwen/permissions/setRules preserves already-stored malformed permission rules', async () => { + const settings = makeCoreSettings(); + settings.setValue(SettingScope.User, 'permissions.allow', [ + 'ShellTool(git status', + ]); + vi.mocked(settings.setValue).mockClear(); + const { agent, agentPromise } = await bootCoreSettingsAgent(settings); + + await agent.extMethod('qwen/permissions/setRules', { + scope: 'user', + ruleType: 'allow', + rules: ['ShellTool(git status', 'ShellTool(npm test)'], + }); + + expect(settings.setValue).toHaveBeenCalledWith( + 'User', + 'permissions.allow', + ['ShellTool(git status', 'ShellTool(npm test)'], + ); + + mockConnectionState.resolve(); + await agentPromise; + }); + it('qwen/permissions/setRules persists normalized rules for the requested scope', async () => { const settings = makeCoreSettings(); const { agent, agentPromise } = await bootCoreSettingsAgent(settings); @@ -3301,6 +3506,62 @@ describe('QwenAgent MCP SSE/HTTP support', () => { await agentPromise; }); + it('qwen/permissions/setRules syncs live permission managers after replacement', async () => { + const settings = makeCoreSettings(); + settings.setValue(SettingScope.User, 'permissions.allow', [ + 'ShellTool(git status)', + 'ShellTool(git diff)', + ]); + vi.mocked(settings.setValue).mockClear(); + const addPersistentRule = vi.fn(); + const removePersistentRule = vi.fn(); + const permissionManager = { + addPersistentRule, + removePersistentRule, + }; + const innerConfig = await setupSessionMocks('test-session-id'); + ( + innerConfig as ReturnType & { + getPermissionManager: () => typeof permissionManager; + } + ).getPermissionManager = vi.fn(() => permissionManager); + vi.mocked(loadSettings).mockReturnValue(settings); + const agentPromise = runAcpAgent(mockConfig, settings, mockArgv); + await vi.waitFor(() => expect(capturedAgentFactory).toBeDefined()); + const agent = capturedAgentFactory!({ + get closed() { + return mockConnectionState.promise; + }, + }) as AgentLike; + await agent.newSession({ cwd: '/tmp', mcpServers: [] }); + + await agent.extMethod('qwen/permissions/setRules', { + scope: 'user', + ruleType: 'allow', + rules: ['ShellTool(git diff)', 'ShellTool(npm test)'], + }); + + expect(removePersistentRule).toHaveBeenCalledWith( + 'ShellTool(git status)', + 'allow', + ); + expect(removePersistentRule).not.toHaveBeenCalledWith( + 'ShellTool(git diff)', + 'allow', + ); + expect(addPersistentRule).toHaveBeenCalledWith( + 'ShellTool(npm test)', + 'allow', + ); + expect(addPersistentRule).not.toHaveBeenCalledWith( + 'ShellTool(git diff)', + 'allow', + ); + + mockConnectionState.resolve(); + await agentPromise; + }); + const VALID_SESSION_ID = '12345678-1234-1234-1234-1234567890ab'; function mockSessionServiceLoad(result: unknown) { diff --git a/packages/cli/src/acp-integration/acpAgent.ts b/packages/cli/src/acp-integration/acpAgent.ts index 3eff7062dcf..90efc2f4e78 100644 --- a/packages/cli/src/acp-integration/acpAgent.ts +++ b/packages/cli/src/acp-integration/acpAgent.ts @@ -133,6 +133,14 @@ import { reloadEnvironment, SettingScope, } from '../config/settings.js'; +import { + buildPermissionSettings, + normalizePermissionRules, + PermissionRulesValidationError, + PERMISSION_RULE_TYPES, + readPermissionRuleSet, + type PermissionRuleSet, +} from '../config/permission-settings.js'; import { createLoadedSettingsAdapter } from '../config/loadedSettingsAdapter.js'; import type { ApprovalModeValue, SessionContext } from './session/types.js'; import type { CliArgs } from '../config/config.js'; @@ -277,71 +285,6 @@ export const AUTH_PREFLIGHT_WAIVED_AUTH_TYPES: ReadonlySet = new Set([ 'qwen-oauth', ]); -type PermissionRuleType = 'allow' | 'ask' | 'deny'; - -interface PermissionRuleSet { - allow: string[]; - ask: string[]; - deny: string[]; -} - -interface PermissionSettingsScopeState { - path: string; - rules: PermissionRuleSet; -} - -interface QwenPermissionSettings { - user: PermissionSettingsScopeState; - workspace: PermissionSettingsScopeState; - merged: PermissionRuleSet; - isTrusted: boolean; -} - -const PERMISSION_RULE_TYPES: PermissionRuleType[] = ['allow', 'ask', 'deny']; - -function readPermissionRuleSet(settings: unknown): PermissionRuleSet { - const permissions = - settings && typeof settings === 'object' - ? ( - settings as { - permissions?: Partial>; - } - ).permissions - : undefined; - - const readRules = (type: PermissionRuleType): string[] => { - const value = permissions?.[type]; - return Array.isArray(value) - ? value.filter((item): item is string => typeof item === 'string') - : []; - }; - - return { - allow: readRules('allow'), - ask: readRules('ask'), - deny: readRules('deny'), - }; -} - -function normalizePermissionRules(value: unknown): string[] { - if (!Array.isArray(value)) { - throw RequestError.invalidParams(undefined, 'rules must be an array'); - } - return Array.from( - new Set( - value.map((item) => { - if (typeof item !== 'string' || !item.trim()) { - throw RequestError.invalidParams( - undefined, - 'rules must contain only non-empty strings', - ); - } - return item.trim(); - }), - ), - ); -} - type QwenMemorySettings = { enableManagedAutoMemory: boolean; enableManagedAutoDream: boolean; @@ -3042,23 +2985,6 @@ class QwenAgent implements Agent { return this.settings; } - private buildPermissionSettings( - settings: LoadedSettings, - ): QwenPermissionSettings { - return { - user: { - path: settings.user.path, - rules: readPermissionRuleSet(settings.user.settings), - }, - workspace: { - path: settings.workspace.path, - rules: readPermissionRuleSet(settings.workspace.settings), - }, - merged: readPermissionRuleSet(settings.merged), - isTrusted: settings.isTrusted, - }; - } - private async buildCoreSettings( settings: LoadedSettings, cwd: string, @@ -6754,7 +6680,7 @@ class QwenAgent implements Agent { } case 'qwen/permissions/getSettings': { const settings = this.loadPermissionSettings(cwd); - return this.buildPermissionSettings(settings) as unknown as Record< + return buildPermissionSettings(settings) as unknown as Record< string, unknown >; @@ -6777,9 +6703,24 @@ class QwenAgent implements Agent { const settings = this.loadPermissionSettings(cwd); const before = readPermissionRuleSet(settings.merged); - const rules = normalizePermissionRules(params['rules']); const settingScope = scope === 'workspace' ? SettingScope.Workspace : SettingScope.User; + const scopeSettings = + scope === 'workspace' + ? settings.workspace.settings + : settings.user.settings; + const existingRules = readPermissionRuleSet(scopeSettings)[ruleType]; + let rules: string[]; + try { + rules = normalizePermissionRules(params['rules'], { + existingRules, + }); + } catch (error) { + if (error instanceof PermissionRulesValidationError) { + throw RequestError.invalidParams(undefined, error.message); + } + throw error; + } settings.setValue(settingScope, `permissions.${ruleType}`, rules); // `setValue` already recomputed the in-memory merged view, so read the @@ -6788,7 +6729,7 @@ class QwenAgent implements Agent { // could mutate settings between the two loads). const after = readPermissionRuleSet(settings.merged); this.syncLivePermissionManagers(before, after); - return this.buildPermissionSettings(settings) as unknown as Record< + return buildPermissionSettings(settings) as unknown as Record< string, unknown >; diff --git a/packages/cli/src/config/permission-settings.ts b/packages/cli/src/config/permission-settings.ts new file mode 100644 index 00000000000..c64d556c21f --- /dev/null +++ b/packages/cli/src/config/permission-settings.ts @@ -0,0 +1,161 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { parseRule } from '@qwen-code/qwen-code-core'; +import type { LoadedSettings } from './settings.js'; + +export const PERMISSION_RULE_TYPES = ['allow', 'ask', 'deny'] as const; +export const MAX_PERMISSION_RULES_COUNT = 500; +export const MAX_PERMISSION_RULE_LENGTH = 512; + +export type PermissionRuleType = (typeof PERMISSION_RULE_TYPES)[number]; +export type PermissionSettingsScope = 'user' | 'workspace'; + +export interface PermissionRuleSet { + allow: string[]; + ask: string[]; + deny: string[]; +} + +export interface PermissionSettingsScopeState { + path: string; + rules: PermissionRuleSet; +} + +export interface QwenPermissionSettings { + v: 1; + user: PermissionSettingsScopeState; + workspace: PermissionSettingsScopeState; + merged: PermissionRuleSet; + isTrusted: boolean; +} + +export class PermissionRulesValidationError extends Error { + constructor( + message: string, + readonly code: 'invalid_rules', + ) { + super(message); + this.name = 'PermissionRulesValidationError'; + } +} + +export function isPermissionRuleType( + value: unknown, +): value is PermissionRuleType { + return ( + typeof value === 'string' && + PERMISSION_RULE_TYPES.includes(value as PermissionRuleType) + ); +} + +export function readPermissionRuleSet(settings: unknown): PermissionRuleSet { + const permissions = + settings && typeof settings === 'object' + ? ( + settings as { + permissions?: Partial>; + } + ).permissions + : undefined; + + const readRules = (type: PermissionRuleType): string[] => { + const value = permissions?.[type]; + return Array.isArray(value) + ? value.filter((item): item is string => typeof item === 'string') + : []; + }; + + return { + allow: readRules('allow'), + ask: readRules('ask'), + deny: readRules('deny'), + }; +} + +export function normalizePermissionRules( + value: unknown, + opts?: { existingRules?: readonly string[] }, +): string[] { + const inputRules = normalizePermissionRuleInputs(value); + const result: string[] = []; + const seen = new Set(); + const existingRules = new Set( + (opts?.existingRules ?? []).map((rule) => rule.trim()), + ); + for (const rule of inputRules) { + if (parseRule(rule).invalid) { + if (existingRules.has(rule)) { + if (!seen.has(rule)) { + seen.add(rule); + result.push(rule); + } + continue; + } + throw new PermissionRulesValidationError( + `Malformed permission rule: ${rule}`, + 'invalid_rules', + ); + } + if (!seen.has(rule)) { + seen.add(rule); + result.push(rule); + } + } + return result; +} + +export function normalizePermissionRuleInputs(value: unknown): string[] { + if (!Array.isArray(value)) { + throw new PermissionRulesValidationError( + 'rules must be an array', + 'invalid_rules', + ); + } + if (value.length > MAX_PERMISSION_RULES_COUNT) { + throw new PermissionRulesValidationError( + `rules array exceeds ${MAX_PERMISSION_RULES_COUNT} entries`, + 'invalid_rules', + ); + } + + const result: string[] = []; + for (const item of value) { + if (typeof item !== 'string' || !item.trim()) { + throw new PermissionRulesValidationError( + 'rules must contain only non-empty strings', + 'invalid_rules', + ); + } + const rule = item.trim(); + if (rule.length > MAX_PERMISSION_RULE_LENGTH) { + throw new PermissionRulesValidationError( + `rule exceeds ${MAX_PERMISSION_RULE_LENGTH}-character limit`, + 'invalid_rules', + ); + } + result.push(rule); + } + return result; +} + +export function buildPermissionSettings( + settings: LoadedSettings, +): QwenPermissionSettings { + return { + v: 1, + user: { + path: settings.user.path, + rules: readPermissionRuleSet(settings.user.settings), + }, + workspace: { + path: settings.workspace.path, + rules: readPermissionRuleSet(settings.workspace.settings), + }, + merged: readPermissionRuleSet(settings.merged), + isTrusted: settings.isTrusted, + }; +} diff --git a/packages/cli/src/serve/capabilities.ts b/packages/cli/src/serve/capabilities.ts index 8aaf85ce43f..f96c45dec0f 100644 --- a/packages/cli/src/serve/capabilities.ts +++ b/packages/cli/src/serve/capabilities.ts @@ -130,6 +130,7 @@ export const SERVE_CAPABILITY_REGISTRY = { // (`tools.disabled` is consulted at `Config` construction time). workspace_tool_toggle: { since: 'v1' }, workspace_settings: { since: 'v1' }, + workspace_permissions: { since: 'v1' }, // `POST /workspace/init` scaffolds an empty // `QWEN.md` (or whatever `getCurrentGeminiMdFilename()` returns) at // the bound workspace root. Body: `{force?: boolean}`. Default @@ -304,6 +305,10 @@ export const CONDITIONAL_SERVE_FEATURES: ReadonlyMap< toggles.writerIdleTimeoutMs > 0, ], ['workspace_settings', (toggles) => toggles.persistSettingAvailable === true], + [ + 'workspace_permissions', + (toggles) => toggles.persistSettingAvailable === true, + ], [ 'session_shell_command', (toggles) => toggles.sessionShellCommandEnabled === true, diff --git a/packages/cli/src/serve/routes/workspace-permissions.test.ts b/packages/cli/src/serve/routes/workspace-permissions.test.ts new file mode 100644 index 00000000000..f39204da2c3 --- /dev/null +++ b/packages/cli/src/serve/routes/workspace-permissions.test.ts @@ -0,0 +1,773 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import express from 'express'; +import type { Request, Response } from 'express'; +import { randomBytes } from 'node:crypto'; +import { promises as fsp } from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import request from 'supertest'; +import { SessionNotFoundError } from '../acp-session-bridge.js'; +import { + MAX_PERMISSION_RULE_LENGTH, + MAX_PERMISSION_RULES_COUNT, + normalizePermissionRules, +} from '../../config/permission-settings.js'; +import { + type LoadedSettings, + loadSettings, + resetHomeEnvBootstrapForTesting, + SettingScope, + SETTINGS_DIRECTORY_NAME, +} from '../../config/settings.js'; +import { + resetTrustedFoldersForTesting, + TRUSTED_FOLDERS_FILENAME, + TrustLevel, +} from '../../config/trustedFolders.js'; +import { registerWorkspacePermissionsRoutes } from './workspace-permissions.js'; + +interface Harness { + app: express.Application; + scratch: string; + workspace: string; + home: string; + events: Array<{ + key: string; + value: unknown; + scope: string; + clientId?: string; + }>; + invokeWorkspaceCommand: ReturnType; + persistSetting: ReturnType; +} + +const originalQwenHome = process.env['QWEN_HOME']; +const originalTrustedFoldersPath = + process.env['QWEN_CODE_TRUSTED_FOLDERS_PATH']; + +function safeBody(req: Request): Record { + return req.body && typeof req.body === 'object' + ? (req.body as Record) + : {}; +} + +async function writeJson(file: string, value: unknown): Promise { + await fsp.mkdir(path.dirname(file), { recursive: true }); + await fsp.writeFile(file, JSON.stringify(value, null, 2), 'utf8'); +} + +async function makeHarness(opts?: { + invokeWorkspaceCommand?: ReturnType; + loadSettings?: (workspace: string) => LoadedSettings; + persistSetting?: ReturnType; + broadcastSettingsChanged?: ( + key: string, + value: unknown, + scope: string, + clientId: string | undefined, + ) => void; +}): Promise { + const scratch = await fsp.mkdtemp( + path.join( + os.tmpdir(), + `qwen-permission-routes-${randomBytes(4).toString('hex')}-`, + ), + ); + const home = path.join(scratch, 'home'); + const workspace = path.join(scratch, 'workspace'); + await fsp.mkdir(home, { recursive: true }); + await fsp.mkdir(workspace, { recursive: true }); + process.env['QWEN_HOME'] = home; + process.env['QWEN_CODE_TRUSTED_FOLDERS_PATH'] = path.join( + home, + TRUSTED_FOLDERS_FILENAME, + ); + resetHomeEnvBootstrapForTesting(); + resetTrustedFoldersForTesting(); + + const app = express(); + app.use(express.json()); + const events: Harness['events'] = []; + const invokeWorkspaceCommand = + opts?.invokeWorkspaceCommand ?? + vi.fn(async () => { + throw new SessionNotFoundError('workspace-command:qwen/permissions'); + }); + const persistSetting = + opts?.persistSetting ?? + vi.fn( + async ( + targetWorkspace: string, + scope: SettingScope, + key: string, + value: unknown, + ) => { + const settings = loadSettings(targetWorkspace); + settings.setValue(scope, key, value); + return settings; + }, + ); + const broadcastSettingsChanged: ( + key: string, + value: unknown, + scope: string, + clientId: string | undefined, + ) => void = + opts?.broadcastSettingsChanged ?? + ((key, value, scope, clientId) => { + events.push({ + key, + value, + scope, + ...(clientId !== undefined ? { clientId } : {}), + }); + }); + + registerWorkspacePermissionsRoutes(app, { + boundWorkspace: workspace, + mutate: () => (_req, _res, next) => next(), + safeBody, + persistSetting, + ...(opts?.loadSettings ? { loadSettings: opts.loadSettings } : {}), + invokeWorkspaceCommand, + broadcastSettingsChanged, + parseAndValidateClientId: (req: Request, res: Response) => { + const clientId = req.get('X-Qwen-Client-Id'); + if (clientId === 'unknown-client') { + res.status(400).json({ + error: 'Unknown client id', + code: 'invalid_client_id', + }); + return null; + } + return clientId; + }, + }); + + return { + app, + scratch, + workspace, + home, + events, + invokeWorkspaceCommand, + persistSetting, + }; +} + +async function teardown(h: Harness): Promise { + await fsp.rm(h.scratch, { recursive: true, force: true }); + if (originalQwenHome === undefined) { + delete process.env['QWEN_HOME']; + } else { + process.env['QWEN_HOME'] = originalQwenHome; + } + if (originalTrustedFoldersPath === undefined) { + delete process.env['QWEN_CODE_TRUSTED_FOLDERS_PATH']; + } else { + process.env['QWEN_CODE_TRUSTED_FOLDERS_PATH'] = originalTrustedFoldersPath; + } + resetHomeEnvBootstrapForTesting(); + resetTrustedFoldersForTesting(); +} + +describe('workspace permissions routes', () => { + let h: Harness; + + beforeEach(async () => { + h = await makeHarness(); + }); + + afterEach(async () => { + await teardown(h); + }); + + it('GET returns scoped and merged permission rules', async () => { + await writeJson(path.join(h.home, 'settings.json'), { + permissions: { + allow: ['Bash(git *)'], + deny: ['Read(.env)'], + }, + }); + await writeJson( + path.join(h.workspace, SETTINGS_DIRECTORY_NAME, 'settings.json'), + { + permissions: { + allow: ['Edit(src/**)'], + ask: ['Bash(npm *)'], + }, + }, + ); + + const res = await request(h.app).get('/workspace/permissions'); + + expect(res.status).toBe(200); + expect(res.body).toMatchObject({ + v: 1, + user: { + rules: { + allow: ['Bash(git *)'], + ask: [], + deny: ['Read(.env)'], + }, + }, + workspace: { + rules: { + allow: ['Edit(src/**)'], + ask: ['Bash(npm *)'], + deny: [], + }, + }, + merged: { + allow: ['Bash(git *)', 'Edit(src/**)'], + ask: ['Bash(npm *)'], + deny: ['Read(.env)'], + }, + isTrusted: true, + }); + expect(res.body.user).not.toHaveProperty('path'); + expect(res.body.workspace).not.toHaveProperty('path'); + }); + + it('GET returns 500 when loading permission settings fails', async () => { + await teardown(h); + h = await makeHarness({ + loadSettings: () => { + throw new Error('load failed'); + }, + }); + + const res = await request(h.app).get('/workspace/permissions'); + + expect(res.status).toBe(500); + expect(res.body).toMatchObject({ + code: 'internal_error', + error: 'Failed to load permission rules', + }); + }); + + it('POST rejects invalid scope ruleType rules and malformed rule syntax', async () => { + const invalidScope = await request(h.app) + .post('/workspace/permissions') + .send({ scope: 'system', ruleType: 'allow', rules: [] }); + expect(invalidScope.status).toBe(400); + expect(invalidScope.body.code).toBe('invalid_scope'); + + const userScope = await request(h.app) + .post('/workspace/permissions') + .send({ scope: 'user', ruleType: 'allow', rules: [] }); + expect(userScope.status).toBe(400); + expect(userScope.body).toMatchObject({ + code: 'invalid_scope', + error: 'scope must be "workspace"', + }); + + const invalidRuleType = await request(h.app) + .post('/workspace/permissions') + .send({ scope: 'workspace', ruleType: 'maybe', rules: [] }); + expect(invalidRuleType.status).toBe(400); + expect(invalidRuleType.body.code).toBe('invalid_rule_type'); + + const invalidRules = await request(h.app) + .post('/workspace/permissions') + .send({ scope: 'workspace', ruleType: 'allow', rules: 'Bash(git *)' }); + expect(invalidRules.status).toBe(400); + expect(invalidRules.body.code).toBe('invalid_rules'); + + const nonStringRule = await request(h.app) + .post('/workspace/permissions') + .send({ scope: 'workspace', ruleType: 'allow', rules: [123] }); + expect(nonStringRule.status).toBe(400); + expect(nonStringRule.body).toMatchObject({ + code: 'invalid_rules', + error: 'rules must contain only non-empty strings', + }); + + const emptyRule = await request(h.app) + .post('/workspace/permissions') + .send({ scope: 'workspace', ruleType: 'allow', rules: [' '] }); + expect(emptyRule.status).toBe(400); + expect(emptyRule.body).toMatchObject({ + code: 'invalid_rules', + error: 'rules must contain only non-empty strings', + }); + + const malformedRule = await request(h.app) + .post('/workspace/permissions') + .send({ scope: 'workspace', ruleType: 'allow', rules: ['Bash(git *'] }); + expect(malformedRule.status).toBe(400); + expect(malformedRule.body.code).toBe('invalid_rules'); + + const tooManyRules = await request(h.app) + .post('/workspace/permissions') + .send({ + scope: 'workspace', + ruleType: 'allow', + rules: Array.from( + { length: MAX_PERMISSION_RULES_COUNT + 1 }, + (_, index) => `Bash(echo ${index})`, + ), + }); + expect(tooManyRules.status).toBe(400); + expect(tooManyRules.body).toMatchObject({ + code: 'invalid_rules', + error: `rules array exceeds ${MAX_PERMISSION_RULES_COUNT} entries`, + }); + + const tooLongRule = await request(h.app) + .post('/workspace/permissions') + .send({ + scope: 'workspace', + ruleType: 'allow', + rules: [`Bash(${'x'.repeat(MAX_PERMISSION_RULE_LENGTH + 1)})`], + }); + expect(tooLongRule.status).toBe(400); + expect(tooLongRule.body).toMatchObject({ + code: 'invalid_rules', + error: `rule exceeds ${MAX_PERMISSION_RULE_LENGTH}-character limit`, + }); + + expect(h.persistSetting).not.toHaveBeenCalled(); + }); + + it('POST returns 500 when loading existing rules fails', async () => { + await teardown(h); + h = await makeHarness({ + loadSettings: () => { + throw new Error('load failed'); + }, + }); + + const res = await request(h.app) + .post('/workspace/permissions') + .send({ + scope: 'workspace', + ruleType: 'allow', + rules: ['Bash(ls)'], + }); + + expect(res.status).toBe(500); + expect(res.body).toMatchObject({ + code: 'internal_error', + error: 'Failed to load permission rules', + }); + expect(h.persistSetting).not.toHaveBeenCalled(); + }); + + it('POST preserves already-stored malformed rules while accepting valid updates', async () => { + await writeJson( + path.join(h.workspace, SETTINGS_DIRECTORY_NAME, 'settings.json'), + { + permissions: { + allow: ['Bash(git log)', 'Bash(rm '], + }, + }, + ); + + const res = await request(h.app) + .post('/workspace/permissions') + .send({ + scope: 'workspace', + ruleType: 'allow', + rules: ['Bash(git log)', 'Bash(rm ', 'Bash(ls)'], + }); + + expect(res.status).toBe(200); + expect(h.persistSetting).toHaveBeenCalledWith( + h.workspace, + SettingScope.Workspace, + 'permissions.allow', + ['Bash(git log)', 'Bash(rm', 'Bash(ls)'], + ); + expect(res.body.workspace.rules.allow).toEqual([ + 'Bash(git log)', + 'Bash(rm', + 'Bash(ls)', + ]); + }); + + it('POST rejects user-scope writes before invoking a live ACP child', async () => { + const live = vi.fn(async () => ({})); + await teardown(h); + h = await makeHarness({ invokeWorkspaceCommand: live }); + + const res = await request(h.app) + .post('/workspace/permissions') + .set('X-Qwen-Client-Id', 'client-1') + .send({ + scope: 'user', + ruleType: 'allow', + rules: [' Bash(git status) ', 'Bash(git status)'], + }); + + expect(res.status).toBe(400); + expect(res.body.code).toBe('invalid_scope'); + expect(live).not.toHaveBeenCalled(); + expect(h.persistSetting).not.toHaveBeenCalled(); + expect(h.events).toEqual([]); + }); + + it('POST replaces one workspace rule list through a live ACP child and publishes settings_changed', async () => { + const live = vi.fn( + async (_method: string, params: Record) => { + const settings = loadSettings(h.workspace); + settings.setValue( + SettingScope.Workspace, + 'permissions.allow', + normalizePermissionRules(params['rules']), + ); + return {}; + }, + ); + await teardown(h); + h = await makeHarness({ invokeWorkspaceCommand: live }); + + const res = await request(h.app) + .post('/workspace/permissions') + .set('X-Qwen-Client-Id', 'client-1') + .send({ + scope: 'workspace', + ruleType: 'allow', + rules: [' Bash(git status) ', 'Bash(git status)'], + }); + + expect(res.status).toBe(200); + expect(res.body).toEqual({ + v: 1, + user: { + rules: { allow: [], ask: [], deny: [] }, + }, + workspace: { + rules: { allow: ['Bash(git status)'], ask: [], deny: [] }, + }, + merged: { allow: ['Bash(git status)'], ask: [], deny: [] }, + isTrusted: true, + appliedVia: 'live-child', + }); + expect(live).toHaveBeenCalledWith('qwen/permissions/setRules', { + cwd: h.workspace, + scope: 'workspace', + ruleType: 'allow', + rules: ['Bash(git status)', 'Bash(git status)'], + }); + expect(h.persistSetting).not.toHaveBeenCalled(); + expect(h.events).toEqual([ + { + key: 'permissions.allow', + scope: 'workspace', + value: ['Bash(git status)'], + clientId: 'client-1', + }, + ]); + }); + + it('POST still succeeds when live ACP broadcast throws', async () => { + const live = vi.fn( + async (_method: string, params: Record) => { + const settings = loadSettings(h.workspace); + settings.setValue( + SettingScope.Workspace, + 'permissions.allow', + normalizePermissionRules(params['rules']), + ); + return {}; + }, + ); + const broadcastSettingsChanged = vi.fn(() => { + throw new Error('broadcast failed'); + }); + await teardown(h); + h = await makeHarness({ + invokeWorkspaceCommand: live, + broadcastSettingsChanged, + }); + + const res = await request(h.app) + .post('/workspace/permissions') + .send({ + scope: 'workspace', + ruleType: 'allow', + rules: ['Bash(git status)'], + }); + + expect(res.status).toBe(200); + expect(res.body).toEqual({ + v: 1, + user: { + rules: { allow: [], ask: [], deny: [] }, + }, + workspace: { + rules: { allow: ['Bash(git status)'], ask: [], deny: [] }, + }, + merged: { allow: ['Bash(git status)'], ask: [], deny: [] }, + isTrusted: true, + appliedVia: 'live-child', + }); + expect(broadcastSettingsChanged).toHaveBeenCalledWith( + 'permissions.allow', + ['Bash(git status)'], + 'workspace', + undefined, + ); + expect(h.persistSetting).not.toHaveBeenCalled(); + }); + + it('POST maps live ACP plain invalid params to invalid_rules', async () => { + const err = { + code: -32602, + message: 'Invalid params: Malformed permission rule: Bash(git *', + }; + const live = vi.fn(async () => { + throw err; + }); + await teardown(h); + h = await makeHarness({ invokeWorkspaceCommand: live }); + + const res = await request(h.app) + .post('/workspace/permissions') + .send({ + scope: 'workspace', + ruleType: 'allow', + rules: ['Bash(git *'], + }); + + expect(res.status).toBe(400); + expect(res.body).toMatchObject({ + code: 'invalid_rules', + error: 'Malformed permission rule: Bash(git *', + }); + expect(h.persistSetting).not.toHaveBeenCalled(); + }); + + it('POST returns response_build_error when live response reload fails', async () => { + const live = vi.fn(async () => ({})); + const loadSettingsForRoute = vi.fn(() => { + throw new Error('settings unreadable'); + }); + const broadcastSettingsChanged = vi.fn(); + await teardown(h); + h = await makeHarness({ + invokeWorkspaceCommand: live, + loadSettings: loadSettingsForRoute, + broadcastSettingsChanged, + }); + + const res = await request(h.app) + .post('/workspace/permissions') + .send({ + scope: 'workspace', + ruleType: 'allow', + rules: ['Bash(git status)'], + }); + + expect(res.status).toBe(500); + expect(res.body.code).toBe('response_build_error'); + expect(live).toHaveBeenCalledWith('qwen/permissions/setRules', { + cwd: h.workspace, + scope: 'workspace', + ruleType: 'allow', + rules: ['Bash(git status)'], + }); + expect(broadcastSettingsChanged).not.toHaveBeenCalled(); + expect(h.persistSetting).not.toHaveBeenCalled(); + }); + + it('POST falls back to daemon settings write when no ACP child is running', async () => { + const res = await request(h.app) + .post('/workspace/permissions') + .send({ + scope: 'workspace', + ruleType: 'deny', + rules: [' Read(.env) ', 'Read(.env)', 'Bash(rm *)'], + }); + + expect(res.status).toBe(200); + expect(h.invokeWorkspaceCommand).toHaveBeenCalled(); + expect(h.persistSetting).toHaveBeenCalledWith( + h.workspace, + SettingScope.Workspace, + 'permissions.deny', + ['Read(.env)', 'Bash(rm *)'], + ); + expect(res.body.workspace.rules.deny).toEqual(['Read(.env)', 'Bash(rm *)']); + expect(res.body.merged.deny).toEqual(['Read(.env)', 'Bash(rm *)']); + expect(res.body.appliedVia).toBe('persist-fallback'); + expect(h.events).toEqual([ + { + key: 'permissions.deny', + scope: 'workspace', + value: ['Read(.env)', 'Bash(rm *)'], + }, + ]); + }); + + it('POST reuses persisted settings for fallback response', async () => { + const loadSettingsForRoute = vi.fn((workspace: string) => + loadSettings(workspace), + ); + await teardown(h); + h = await makeHarness({ loadSettings: loadSettingsForRoute }); + + const res = await request(h.app) + .post('/workspace/permissions') + .send({ + scope: 'workspace', + ruleType: 'deny', + rules: ['Read(.env)'], + }); + + expect(res.status).toBe(200); + expect(res.body.workspace.rules.deny).toEqual(['Read(.env)']); + expect(loadSettingsForRoute).toHaveBeenCalledTimes(1); + }); + + it('POST returns response_build_error when fallback response reload fails', async () => { + const loadSettingsForRoute = vi + .fn() + .mockImplementationOnce((workspace: string) => loadSettings(workspace)) + .mockImplementationOnce(() => { + throw new Error('settings unreadable'); + }); + const persistSetting = vi.fn( + async ( + targetWorkspace: string, + scope: SettingScope, + key: string, + value: unknown, + ) => { + loadSettings(targetWorkspace).setValue(scope, key, value); + }, + ); + const broadcastSettingsChanged = vi.fn(); + await teardown(h); + h = await makeHarness({ + loadSettings: loadSettingsForRoute, + persistSetting, + broadcastSettingsChanged, + }); + + const res = await request(h.app) + .post('/workspace/permissions') + .send({ + scope: 'workspace', + ruleType: 'deny', + rules: ['Read(.env)'], + }); + + expect(res.status).toBe(500); + expect(res.body.code).toBe('response_build_error'); + expect(persistSetting).toHaveBeenCalledWith( + h.workspace, + SettingScope.Workspace, + 'permissions.deny', + ['Read(.env)'], + ); + expect(loadSettingsForRoute).toHaveBeenCalledTimes(2); + expect(broadcastSettingsChanged).not.toHaveBeenCalled(); + }); + + it('POST still succeeds when fallback broadcast throws after persisting', async () => { + const broadcastSettingsChanged = vi.fn(() => { + throw new Error('broadcast failed'); + }); + await teardown(h); + h = await makeHarness({ broadcastSettingsChanged }); + + const res = await request(h.app) + .post('/workspace/permissions') + .send({ + scope: 'workspace', + ruleType: 'deny', + rules: ['Read(.env)'], + }); + + expect(res.status).toBe(200); + expect(h.persistSetting).toHaveBeenCalledWith( + h.workspace, + SettingScope.Workspace, + 'permissions.deny', + ['Read(.env)'], + ); + expect(res.body.workspace.rules.deny).toEqual(['Read(.env)']); + expect(broadcastSettingsChanged).toHaveBeenCalledWith( + 'permissions.deny', + ['Read(.env)'], + 'workspace', + undefined, + ); + }); + + it('POST returns 500 when ACP child throws non-SessionNotFoundError', async () => { + await teardown(h); + h = await makeHarness({ + invokeWorkspaceCommand: vi.fn(async () => { + throw new Error('unexpected ACP failure'); + }), + }); + + const res = await request(h.app) + .post('/workspace/permissions') + .send({ scope: 'workspace', ruleType: 'allow', rules: ['Bash(git *)'] }); + + expect(res.status).toBe(500); + expect(res.body.code).toBe('permission_update_failed'); + expect(h.persistSetting).not.toHaveBeenCalled(); + }); + + it('POST returns 500 when persistSetting throws', async () => { + h.persistSetting.mockRejectedValueOnce(new Error('disk full')); + + const res = await request(h.app) + .post('/workspace/permissions') + .send({ scope: 'workspace', ruleType: 'allow', rules: ['Bash(git *)'] }); + + expect(res.status).toBe(500); + expect(res.body.code).toBe('persist_error'); + }); + + it('POST rejects unknown client id', async () => { + const res = await request(h.app) + .post('/workspace/permissions') + .set('X-Qwen-Client-Id', 'unknown-client') + .send({ scope: 'workspace', ruleType: 'allow', rules: ['Bash(git *)'] }); + + expect(res.status).toBe(400); + expect(res.body.code).toBe('invalid_client_id'); + expect(h.persistSetting).not.toHaveBeenCalled(); + }); + + it('POST persists untrusted workspace rules without merging them into effective rules', async () => { + await writeJson(path.join(h.home, 'settings.json'), { + security: { folderTrust: { enabled: true } }, + }); + await writeJson(path.join(h.home, TRUSTED_FOLDERS_FILENAME), { + [process.cwd()]: TrustLevel.DO_NOT_TRUST, + }); + resetTrustedFoldersForTesting(); + + const res = await request(h.app) + .post('/workspace/permissions') + .send({ + scope: 'workspace', + ruleType: 'deny', + rules: ['Read(.env)'], + }); + + expect(res.status).toBe(200); + expect(res.body.isTrusted).toBe(false); + expect(res.body.workspace.rules.deny).toEqual(['Read(.env)']); + expect(res.body.merged.deny).toEqual([]); + expect(h.persistSetting).toHaveBeenCalledWith( + h.workspace, + SettingScope.Workspace, + 'permissions.deny', + ['Read(.env)'], + ); + }); +}); diff --git a/packages/cli/src/serve/routes/workspace-permissions.ts b/packages/cli/src/serve/routes/workspace-permissions.ts new file mode 100644 index 00000000000..35f68647009 --- /dev/null +++ b/packages/cli/src/serve/routes/workspace-permissions.ts @@ -0,0 +1,316 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { Application, Request, Response } from 'express'; +import { + buildPermissionSettings, + isPermissionRuleType, + normalizePermissionRuleInputs, + normalizePermissionRules, + type PermissionRuleSet, + PermissionRulesValidationError, + readPermissionRuleSet, +} from '../../config/permission-settings.js'; +import { + loadSettings as defaultLoadSettings, + SettingScope, + type LoadedSettings, +} from '../../config/settings.js'; +import { writeStderrLine } from '../../utils/stdioHelpers.js'; +import { SessionNotFoundError } from '../acp-session-bridge.js'; + +function getInvalidParamsMessage(err: unknown): string | undefined { + if (err && typeof err === 'object' && 'code' in err) { + const { code, message } = err as { + code?: unknown; + message?: unknown; + }; + if (code === -32602 && typeof message === 'string') { + return message.replace(/^Invalid params:\s*/, ''); + } + } + return undefined; +} + +interface WorkspacePermissionsRestResponse { + v: 1; + user: { rules: PermissionRuleSet }; + workspace: { rules: PermissionRuleSet }; + merged: PermissionRuleSet; + isTrusted: boolean; +} + +function buildRestPermissionSettings( + settings: LoadedSettings, +): WorkspacePermissionsRestResponse { + const full = buildPermissionSettings(settings); + return { + v: full.v, + user: { rules: full.user.rules }, + workspace: { rules: full.workspace.rules }, + merged: full.merged, + isTrusted: full.isTrusted, + }; +} + +export interface WorkspacePermissionsRouteDeps { + boundWorkspace: string; + mutate: (opts?: { strict?: boolean }) => import('express').RequestHandler; + safeBody: (req: Request) => Record; + persistSetting: ( + workspace: string, + scope: SettingScope, + key: string, + value: unknown, + ) => Promise; + loadSettings?: (workspace: string) => LoadedSettings; + invokeWorkspaceCommand: ( + method: string, + params: Record, + ) => Promise; + broadcastSettingsChanged: ( + key: string, + value: unknown, + scope: string, + clientId: string | undefined, + ) => void; + parseAndValidateClientId: ( + req: Request, + res: Response, + ) => string | undefined | null; +} + +export function registerWorkspacePermissionsRoutes( + app: Application, + deps: WorkspacePermissionsRouteDeps, +): void { + const { + boundWorkspace, + mutate, + safeBody, + persistSetting, + loadSettings = defaultLoadSettings, + invokeWorkspaceCommand, + broadcastSettingsChanged, + parseAndValidateClientId, + } = deps; + + app.get('/workspace/permissions', (_req: Request, res: Response) => { + try { + res + .status(200) + .json(buildRestPermissionSettings(loadSettings(boundWorkspace))); + } catch (err) { + writeStderrLine( + `qwen serve: GET /workspace/permissions error: ${ + err instanceof Error ? err.message : String(err) + }`, + ); + res.status(500).json({ + error: 'Failed to load permission rules', + code: 'internal_error', + }); + } + }); + + app.post( + '/workspace/permissions', + mutate({ strict: true }), + async (req: Request, res: Response) => { + const body = safeBody(req); + const scope = body['scope']; + const ruleType = body['ruleType']; + + if (scope !== 'workspace') { + res.status(400).json({ + error: 'scope must be "workspace"', + code: 'invalid_scope', + }); + return; + } + const permissionScope = scope; + + if (!isPermissionRuleType(ruleType)) { + res.status(400).json({ + error: 'ruleType must be "allow", "ask", or "deny"', + code: 'invalid_rule_type', + }); + return; + } + + let rules: string[]; + try { + rules = normalizePermissionRuleInputs(body['rules']); + } catch (err) { + if (err instanceof PermissionRulesValidationError) { + res.status(400).json({ + error: err.message, + code: err.code, + }); + return; + } + writeStderrLine( + `qwen serve: POST /workspace/permissions load error: ${ + err instanceof Error ? err.message : String(err) + }`, + ); + res.status(500).json({ + error: 'Failed to load permission rules', + code: 'internal_error', + }); + return; + } + + const clientId = parseAndValidateClientId(req, res); + if (clientId === null) return; + + const key = `permissions.${ruleType}`; + let updatedThroughLiveChild = false; + try { + await invokeWorkspaceCommand('qwen/permissions/setRules', { + cwd: boundWorkspace, + scope: permissionScope, + ruleType, + rules, + }); + updatedThroughLiveChild = true; + } catch (err) { + if (!(err instanceof SessionNotFoundError)) { + const invalidParamsMessage = getInvalidParamsMessage(err); + if (invalidParamsMessage) { + res.status(400).json({ + error: invalidParamsMessage, + code: 'invalid_rules', + }); + return; + } + writeStderrLine( + `qwen serve: POST /workspace/permissions ACP error (key=${key}, scope=${permissionScope}, workspace=${boundWorkspace}): ${ + err instanceof Error ? err.message : String(err) + }`, + ); + res.status(500).json({ + error: 'Failed to update permission rules', + code: 'permission_update_failed', + }); + return; + } + } + + if (updatedThroughLiveChild) { + let response; + try { + response = buildRestPermissionSettings(loadSettings(boundWorkspace)); + } catch (err) { + writeStderrLine( + `qwen serve: POST /workspace/permissions response error: ${ + err instanceof Error ? err.message : String(err) + }`, + ); + res.status(500).json({ + error: 'Permission rules updated but response could not be loaded', + code: 'response_build_error', + }); + return; + } + const updatedRules = response.workspace.rules[ruleType]; + try { + broadcastSettingsChanged( + key, + updatedRules, + permissionScope, + clientId, + ); + } catch (err) { + writeStderrLine( + `qwen serve: POST /workspace/permissions broadcast error (key=${key}, scope=${permissionScope}): ${ + err instanceof Error ? err.message : String(err) + }`, + ); + } + res.status(200).json({ ...response, appliedVia: 'live-child' }); + return; + } + + try { + const settings = loadSettings(boundWorkspace); + rules = normalizePermissionRules(rules, { + existingRules: readPermissionRuleSet(settings.workspace.settings)[ + ruleType + ], + }); + } catch (err) { + if (err instanceof PermissionRulesValidationError) { + res.status(400).json({ + error: err.message, + code: err.code, + }); + return; + } + writeStderrLine( + `qwen serve: POST /workspace/permissions load error: ${ + err instanceof Error ? err.message : String(err) + }`, + ); + res.status(500).json({ + error: 'Failed to load permission rules', + code: 'internal_error', + }); + return; + } + + let updatedSettings: LoadedSettings | void; + try { + updatedSettings = await persistSetting( + boundWorkspace, + SettingScope.Workspace, + key, + rules, + ); + } catch (err) { + writeStderrLine( + `qwen serve: POST /workspace/permissions persist error (key=${key}, scope=${permissionScope}, workspace=${boundWorkspace}): ${ + err instanceof Error ? err.message : String(err) + }`, + ); + res.status(500).json({ + error: 'Failed to persist permission rules', + code: 'persist_error', + }); + return; + } + + let response; + try { + response = buildRestPermissionSettings( + updatedSettings ?? loadSettings(boundWorkspace), + ); + } catch (err) { + writeStderrLine( + `qwen serve: POST /workspace/permissions response error: ${ + err instanceof Error ? err.message : String(err) + }`, + ); + res.status(500).json({ + error: 'Permission rules updated but response could not be loaded', + code: 'response_build_error', + }); + return; + } + + try { + broadcastSettingsChanged(key, rules, permissionScope, clientId); + } catch (err) { + writeStderrLine( + `qwen serve: POST /workspace/permissions broadcast error (key=${key}, scope=${permissionScope}): ${ + err instanceof Error ? err.message : String(err) + }`, + ); + } + res.status(200).json({ ...response, appliedVia: 'persist-fallback' }); + }, + ); +} diff --git a/packages/cli/src/serve/run-qwen-serve.ts b/packages/cli/src/serve/run-qwen-serve.ts index f2d02edb190..182a1521316 100644 --- a/packages/cli/src/serve/run-qwen-serve.ts +++ b/packages/cli/src/serve/run-qwen-serve.ts @@ -1089,6 +1089,7 @@ export async function runQwenServe( withSettingsLock(workspace, async () => { const fresh = loadSettings(workspace); fresh.setValue(scope, key, value); + return fresh; }), installAuthProvider: (req) => withSettingsLock( diff --git a/packages/cli/src/serve/server.test.ts b/packages/cli/src/serve/server.test.ts index a5c8c38afa9..dfa8b95f2c3 100644 --- a/packages/cli/src/serve/server.test.ts +++ b/packages/cli/src/serve/server.test.ts @@ -259,6 +259,7 @@ const EXPECTED_REGISTERED_FEATURES = [ f !== 'session_branch', ), 'workspace_settings', + 'workspace_permissions', 'workspace_init', 'workspace_mcp_restart', 'session_recap', @@ -1578,6 +1579,20 @@ describe('createServeApp', () => { ); continue; } + if (feature === 'workspace_permissions') { + expect(predicate({ persistSettingAvailable: true })).toBe(true); + expect(predicate({ persistSettingAvailable: false })).toBe(false); + expect(predicate({})).toBe(false); + expect( + getAdvertisedServeFeatures(undefined, { + persistSettingAvailable: true, + }), + ).toContain(feature); + expect(getAdvertisedServeFeatures(undefined, {})).not.toContain( + feature, + ); + continue; + } if (feature === 'rate_limit') { expect(predicate({ rateLimit: true })).toBe(true); expect(predicate({ rateLimit: false })).toBe(false); diff --git a/packages/cli/src/serve/server.ts b/packages/cli/src/serve/server.ts index 16d62dc1b54..83492dc1710 100644 --- a/packages/cli/src/serve/server.ts +++ b/packages/cli/src/serve/server.ts @@ -136,6 +136,7 @@ import { type DaemonWorkspaceService, type WorkspaceRequestContext, } from './workspace-service/index.js'; +import { registerWorkspacePermissionsRoutes } from './routes/workspace-permissions.js'; import { registerWorkspaceSettingsRoutes } from './routes/workspace-settings.js'; import { registerA2uiActionRoutes } from './routes/a2ui-action.js'; import { @@ -779,7 +780,7 @@ export interface ServeAppDeps { scope: import('../config/settings.js').SettingScope, key: string, value: unknown, - ) => Promise; + ) => Promise; } function resolveDaemonTelemetryRoute( @@ -886,6 +887,10 @@ function resolveDaemonTelemetryRoute( if (req.method === 'GET') return { route: 'GET /workspace/settings' }; if (req.method === 'POST') return { route: 'POST /workspace/settings' }; } + if (path === '/workspace/permissions') { + if (req.method === 'GET') return { route: 'GET /workspace/permissions' }; + if (req.method === 'POST') return { route: 'POST /workspace/permissions' }; + } return undefined; } @@ -2519,10 +2524,29 @@ export function createServeApp( if (deps.persistSetting) { const persistSetting = deps.persistSetting; registerWorkspaceSettingsRoutes(app, { + boundWorkspace, + mutate, + safeBody, + persistSetting: async (...args) => { + await persistSetting(...args); + }, + broadcastSettingsChanged: (key, value, scope, clientId) => { + bridge.publishWorkspaceEvent({ + type: 'settings_changed', + data: { key, value, scope }, + ...(clientId ? { originatorClientId: clientId } : {}), + }); + }, + parseAndValidateClientId: (req, res) => + parseAndValidateWorkspaceClientId(req, res, bridge), + }); + registerWorkspacePermissionsRoutes(app, { boundWorkspace, mutate, safeBody, persistSetting, + invokeWorkspaceCommand: (method, params) => + bridge.invokeWorkspaceCommand(method, params), broadcastSettingsChanged: (key, value, scope, clientId) => { bridge.publishWorkspaceEvent({ type: 'settings_changed', diff --git a/packages/sdk-typescript/src/daemon/DaemonClient.ts b/packages/sdk-typescript/src/daemon/DaemonClient.ts index ef707782680..7d2331665d8 100644 --- a/packages/sdk-typescript/src/daemon/DaemonClient.ts +++ b/packages/sdk-typescript/src/daemon/DaemonClient.ts @@ -92,6 +92,8 @@ import type { DaemonWorkspaceHooksStatus, DaemonWorkspaceSettingsStatus, DaemonSettingUpdateResult, + DaemonPermissionRuleType, + DaemonWorkspacePermissionsStatus, } from './types.js'; /** @@ -1753,6 +1755,81 @@ export class DaemonClient { ); } + async workspacePermissions(opts?: { + clientId?: string; + }): Promise { + return this.jsonRequest( + '/workspace/permissions', + 'GET /workspace/permissions', + { clientId: opts?.clientId }, + ); + } + + async setWorkspacePermissionRules( + scope: 'workspace', + ruleType: DaemonPermissionRuleType, + rules: readonly string[], + opts?: { clientId?: string }, + ): Promise { + return this.jsonRequest( + '/workspace/permissions', + 'POST /workspace/permissions', + { + method: 'POST', + body: { scope, ruleType, rules: [...rules] }, + clientId: opts?.clientId, + }, + ); + } + + /** + * Convenience helper that appends a single rule to the specified scope/type + * list. Performs a non-atomic read-modify-write: GETs the current rules, + * appends the new rule locally, then POSTs the full replacement list. + * + * @remarks Not safe for concurrent use — a concurrent modification between + * the GET and POST will be silently overwritten (lost-update / TOCTOU). + */ + async addWorkspacePermissionRule( + scope: 'workspace', + ruleType: DaemonPermissionRuleType, + rule: string, + opts?: { clientId?: string }, + ): Promise { + const normalizedRule = rule.trim(); + const status = await this.workspacePermissions(opts); + const currentRules = status[scope].rules[ruleType]; + if (currentRules.includes(normalizedRule)) return status; + return this.setWorkspacePermissionRules( + scope, + ruleType, + [...currentRules, normalizedRule], + opts, + ); + } + + /** + * Convenience helper that removes a single rule from the specified scope/type + * list. Performs a non-atomic read-modify-write: GETs the current rules, + * removes the rule locally, then POSTs the full replacement list. + * + * @remarks Not safe for concurrent use — a concurrent modification between + * the GET and POST will be silently overwritten (lost-update / TOCTOU). + */ + async removeWorkspacePermissionRule( + scope: 'workspace', + ruleType: DaemonPermissionRuleType, + rule: string, + opts?: { clientId?: string }, + ): Promise { + const normalizedRule = rule.trim(); + const status = await this.workspacePermissions(opts); + const currentRules = status[scope].rules[ruleType]; + const nextRules = currentRules.filter((item) => item !== normalizedRule); + if (nextRules.length === currentRules.length) return status; + return this.setWorkspacePermissionRules(scope, ruleType, nextRules, opts); + } + /** * Restart a configured MCP server through the ACP child's * `McpClientManager`. The daemon pre-checks the live budget diff --git a/packages/sdk-typescript/src/daemon/index.ts b/packages/sdk-typescript/src/daemon/index.ts index 301c7f8ded8..717a2722740 100644 --- a/packages/sdk-typescript/src/daemon/index.ts +++ b/packages/sdk-typescript/src/daemon/index.ts @@ -311,7 +311,12 @@ export type { DaemonRuntimeMcpRemoveResult, DaemonToolToggleResult, DaemonSettingDescriptor, + DaemonPermissionRuleSet, + DaemonPermissionRuleType, + DaemonPermissionScope, DaemonWorkspaceSettingsStatus, + DaemonWorkspacePermissionScopeState, + DaemonWorkspacePermissionsStatus, DaemonSettingUpdateResult, DaemonAvailableCommand, DaemonCapabilities, diff --git a/packages/sdk-typescript/src/daemon/types.ts b/packages/sdk-typescript/src/daemon/types.ts index 54aa3ba027d..62ed946ec9f 100644 --- a/packages/sdk-typescript/src/daemon/types.ts +++ b/packages/sdk-typescript/src/daemon/types.ts @@ -1077,6 +1077,27 @@ export interface DaemonSettingUpdateResult { requiresRestart: boolean; } +export type DaemonPermissionScope = 'workspace'; +export type DaemonPermissionRuleType = 'allow' | 'ask' | 'deny'; + +export interface DaemonPermissionRuleSet { + allow: string[]; + ask: string[]; + deny: string[]; +} + +export interface DaemonWorkspacePermissionScopeState { + rules: DaemonPermissionRuleSet; +} + +export interface DaemonWorkspacePermissionsStatus { + v: 1; + user: DaemonWorkspacePermissionScopeState; + workspace: DaemonWorkspacePermissionScopeState; + merged: DaemonPermissionRuleSet; + isTrusted: boolean; +} + /** * Result body of `POST /workspace/init`. * diff --git a/packages/sdk-typescript/test/unit/DaemonClient.test.ts b/packages/sdk-typescript/test/unit/DaemonClient.test.ts index 4fd8a2c0bcf..b9b8e02840e 100644 --- a/packages/sdk-typescript/test/unit/DaemonClient.test.ts +++ b/packages/sdk-typescript/test/unit/DaemonClient.test.ts @@ -2632,6 +2632,273 @@ describe('DaemonClient', () => { }); }); + describe('workspace permissions helpers', () => { + const permissionsStatus = { + v: 1 as const, + user: { + rules: { + allow: ['ShellTool(git status)'], + ask: [], + deny: [], + }, + }, + workspace: { + rules: { + allow: ['ShellTool(npm test)'], + ask: [], + deny: ['ReadFileTool(**/.env)'], + }, + }, + merged: { + allow: ['ShellTool(git status)', 'ShellTool(npm test)'], + ask: [], + deny: ['ReadFileTool(**/.env)'], + }, + isTrusted: true, + }; + + it('workspacePermissions calls GET /workspace/permissions', async () => { + const { fetch, calls } = recordingFetch(() => + jsonResponse(200, permissionsStatus), + ); + const client = new DaemonClient({ baseUrl: 'http://daemon', fetch }); + + await expect( + client.workspacePermissions({ clientId: 'client-1' }), + ).resolves.toEqual(permissionsStatus); + + expect(calls[0]).toMatchObject({ + method: 'GET', + url: 'http://daemon/workspace/permissions', + }); + expect(calls[0]?.headers['x-qwen-client-id']).toBe('client-1'); + }); + + it('setWorkspacePermissionRules posts scope ruleType and rules', async () => { + const { fetch, calls } = recordingFetch(() => + jsonResponse(200, permissionsStatus), + ); + const client = new DaemonClient({ baseUrl: 'http://daemon', fetch }); + + await expect( + client.setWorkspacePermissionRules( + 'workspace', + 'deny', + ['ReadFileTool(**/.env)'], + { clientId: 'client-2' }, + ), + ).resolves.toEqual(permissionsStatus); + + expect(calls[0]).toMatchObject({ + method: 'POST', + url: 'http://daemon/workspace/permissions', + }); + expect(calls[0]?.headers['content-type']).toContain('application/json'); + expect(calls[0]?.headers['x-qwen-client-id']).toBe('client-2'); + expect(JSON.parse(calls[0]!.body!)).toEqual({ + scope: 'workspace', + ruleType: 'deny', + rules: ['ReadFileTool(**/.env)'], + }); + }); + + it('addWorkspacePermissionRule deduplicates against scope-local rules', async () => { + const { fetch, calls } = recordingFetch(() => + jsonResponse(200, permissionsStatus), + ); + const client = new DaemonClient({ baseUrl: 'http://daemon', fetch }); + + await expect( + client.addWorkspacePermissionRule( + 'workspace', + 'allow', + ' ShellTool(npm test) ', + ), + ).resolves.toEqual(permissionsStatus); + + expect(calls).toHaveLength(1); + expect(calls[0]?.method).toBe('GET'); + }); + + it('removeWorkspacePermissionRule preserves missing rule as no-op', async () => { + const { fetch, calls } = recordingFetch(() => + jsonResponse(200, permissionsStatus), + ); + const client = new DaemonClient({ baseUrl: 'http://daemon', fetch }); + + await expect( + client.removeWorkspacePermissionRule( + 'workspace', + 'ask', + 'ShellTool(yarn test)', + ), + ).resolves.toEqual(permissionsStatus); + + expect(calls).toHaveLength(1); + expect(calls[0]?.method).toBe('GET'); + }); + + it('removeWorkspacePermissionRule propagates GET failures without POSTing', async () => { + const { fetch, calls } = recordingFetch(() => + jsonResponse(500, { error: 'failed to load permissions' }), + ); + const client = new DaemonClient({ baseUrl: 'http://daemon', fetch }); + + await expect( + client.removeWorkspacePermissionRule( + 'workspace', + 'deny', + 'ReadFileTool(**/.env)', + ), + ).rejects.toBeInstanceOf(DaemonHttpError); + + expect(calls).toHaveLength(1); + expect(calls[0]).toMatchObject({ + method: 'GET', + url: 'http://daemon/workspace/permissions', + }); + }); + + it('addWorkspacePermissionRule POSTs when rule is absent', async () => { + const updatedStatus = { + ...permissionsStatus, + workspace: { + ...permissionsStatus.workspace, + rules: { + ...permissionsStatus.workspace.rules, + allow: ['ShellTool(npm test)', 'ShellTool(npm run build)'], + }, + }, + }; + const { fetch, calls } = recordingFetch((req) => { + if (req.method === 'GET') return jsonResponse(200, permissionsStatus); + return jsonResponse(200, updatedStatus); + }); + const client = new DaemonClient({ baseUrl: 'http://daemon', fetch }); + + await expect( + client.addWorkspacePermissionRule( + 'workspace', + 'allow', + 'ShellTool(npm run build)', + ), + ).resolves.toEqual(updatedStatus); + + expect(calls).toHaveLength(2); + expect(calls[1]).toMatchObject({ + method: 'POST', + url: 'http://daemon/workspace/permissions', + }); + expect(JSON.parse(calls[1]!.body!)).toEqual({ + scope: 'workspace', + ruleType: 'allow', + rules: ['ShellTool(npm test)', 'ShellTool(npm run build)'], + }); + }); + + it('addWorkspacePermissionRule propagates GET failures without POSTing', async () => { + const { fetch, calls } = recordingFetch(() => + jsonResponse(500, { error: 'failed to load permissions' }), + ); + const client = new DaemonClient({ baseUrl: 'http://daemon', fetch }); + + await expect( + client.addWorkspacePermissionRule( + 'workspace', + 'allow', + 'ShellTool(npm run build)', + ), + ).rejects.toBeInstanceOf(DaemonHttpError); + + expect(calls).toHaveLength(1); + expect(calls[0]).toMatchObject({ + method: 'GET', + url: 'http://daemon/workspace/permissions', + }); + }); + + it('addWorkspacePermissionRule propagates POST failures after GET', async () => { + const { fetch, calls } = recordingFetch((req) => { + if (req.method === 'GET') return jsonResponse(200, permissionsStatus); + return jsonResponse(500, { error: 'failed to update permissions' }); + }); + const client = new DaemonClient({ baseUrl: 'http://daemon', fetch }); + + await expect( + client.addWorkspacePermissionRule( + 'workspace', + 'allow', + 'ShellTool(npm run build)', + ), + ).rejects.toBeInstanceOf(DaemonHttpError); + + expect(calls).toHaveLength(2); + expect(calls[1]).toMatchObject({ + method: 'POST', + url: 'http://daemon/workspace/permissions', + }); + }); + + it('removeWorkspacePermissionRule POSTs when rule exists', async () => { + const updatedStatus = { + ...permissionsStatus, + workspace: { + ...permissionsStatus.workspace, + rules: { + ...permissionsStatus.workspace.rules, + deny: [], + }, + }, + }; + const { fetch, calls } = recordingFetch((req) => { + if (req.method === 'GET') return jsonResponse(200, permissionsStatus); + return jsonResponse(200, updatedStatus); + }); + const client = new DaemonClient({ baseUrl: 'http://daemon', fetch }); + + await expect( + client.removeWorkspacePermissionRule( + 'workspace', + 'deny', + 'ReadFileTool(**/.env)', + ), + ).resolves.toEqual(updatedStatus); + + expect(calls).toHaveLength(2); + expect(calls[1]).toMatchObject({ + method: 'POST', + url: 'http://daemon/workspace/permissions', + }); + expect(JSON.parse(calls[1]!.body!)).toEqual({ + scope: 'workspace', + ruleType: 'deny', + rules: [], + }); + }); + + it('removeWorkspacePermissionRule propagates POST failures after GET', async () => { + const { fetch, calls } = recordingFetch((req) => { + if (req.method === 'GET') return jsonResponse(200, permissionsStatus); + return jsonResponse(500, { error: 'failed to update permissions' }); + }); + const client = new DaemonClient({ baseUrl: 'http://daemon', fetch }); + + await expect( + client.removeWorkspacePermissionRule( + 'workspace', + 'deny', + 'ReadFileTool(**/.env)', + ), + ).rejects.toBeInstanceOf(DaemonHttpError); + + expect(calls).toHaveLength(2); + expect(calls[1]).toMatchObject({ + method: 'POST', + url: 'http://daemon/workspace/permissions', + }); + }); + }); + describe('workspace memory + agents helpers (issue #4175 PR 16)', () => { it('GETs /workspace/memory and parses the snapshot', async () => { const snapshot = {